Summary
SessionManager._persist() throws EEXIST: file already exists, open '<path>.jsonl' when the session file has been pre-written by _rewriteFile() (or any external caller) before the first assistant message is appended. This masks any real error from the agent run because the EEXIST is thrown from inside handleRunFailure while attempting to persist the failure, swallowing the original cause.
Reproduction
- Create a
SessionManager via SessionManager.create(cwd, sessionDir).
- Call
_rewriteFile() (or otherwise cause the session file to exist on disk with the header) without an assistant message in fileEntries.
- Trigger an agent prompt that fails (e.g. invalid API key, provider error) before producing an assistant message.
- The failure handler calls
appendMessage() → _appendEntry() → _persist(), which throws EEXIST.
Root cause
_persist() in session-manager.js (around line 640-665 in the compiled output of v0.79.6):
_persist(entry) {
if (!this.persist || !this.sessionFile) return;
const hasAssistant = this.fileEntries.some((e) => e.type === "message" && e.message.role === "assistant");
if (!hasAssistant) {
// ... buffer, return
}
if (!this.flushed) {
const fd = openSync(this.sessionFile, "wx"); // ← exclusive create, fails if file exists
// ...
this.flushed = true;
} else {
appendFileSync(this.sessionFile, `${JSON.stringify(entry)}\n`);
}
}
The openSync(path, "wx") call assumes the file does not exist yet on the first assistant-message write (when flushed === false). However, the SDK's own _rewriteFile() writes the file without setting this.flushed = true, and external integrations (like Pi Web UI) legitimately call _rewriteFile() at session creation so the file exists immediately for registry/debug/replay tooling.
The SDK's own setSessionFile() already handles this correctly when loading an existing file (it sets flushed = true at line ~551 and ~1046), but _rewriteFile() itself does not.
Observed behaviour (real log)
Error: EEXIST: file already exists, open '/root/.pi/agent/sessions/<cwd>/<ts>_<id>.jsonl'
at openSync (node:fs:560:18)
at SessionManager._persist (.../session-manager.js:652:24)
at SessionManager._appendEntry (.../session-manager.js:671:14)
at SessionManager.appendMessage (.../session-manager.js:687:14)
at _handleAgentEvent (.../agent-session.js:280:37)
at async Agent.processEvents (.../agent.js:398:13)
at async Agent.handleRunFailure (.../agent.js:342:9) ← masks the real error
at async Agent.runWithLifecycle (.../agent.js:323:13)
at async Agent.prompt (.../agent.js:222:9)
The real underlying error (e.g. an OpenAI Codex 401/429) is never surfaced to the caller because handleRunFailure throws EEXIST while trying to persist it.
Suggested fixes (any one would resolve it)
- Preferred: In
_persist(), change openSync(this.sessionFile, "wx") → openSync(this.sessionFile, "w"). The wx exclusive-create semantics are unnecessary because the !this.flushed branch is only reached once per session, and _rewriteFile() (which uses "w") already tolerates overwrite.
- Alternative: Have
_rewriteFile() set this.flushed = true after writing, matching the pattern already used in setSessionFile() and branch().
- Alternative: Expose a public
flush() method that callers can invoke after _rewriteFile().
Environment
Workaround
Downstream callers who call _rewriteFile() must also set the internal flushed flag:
(sessionManager as any)._rewriteFile();
(sessionManager as any).flushed = true;
Happy to open a PR if any of the fix options above looks like the right direction. Thanks for the great work on Pi.
Summary
SessionManager._persist()throwsEEXIST: file already exists, open '<path>.jsonl'when the session file has been pre-written by_rewriteFile()(or any external caller) before the first assistant message is appended. This masks any real error from the agent run because the EEXIST is thrown from insidehandleRunFailurewhile attempting to persist the failure, swallowing the original cause.Reproduction
SessionManagerviaSessionManager.create(cwd, sessionDir)._rewriteFile()(or otherwise cause the session file to exist on disk with the header) without an assistant message infileEntries.appendMessage()→_appendEntry()→_persist(), which throwsEEXIST.Root cause
_persist()insession-manager.js(around line 640-665 in the compiled output of v0.79.6):The
openSync(path, "wx")call assumes the file does not exist yet on the first assistant-message write (whenflushed === false). However, the SDK's own_rewriteFile()writes the file without settingthis.flushed = true, and external integrations (like Pi Web UI) legitimately call_rewriteFile()at session creation so the file exists immediately for registry/debug/replay tooling.The SDK's own
setSessionFile()already handles this correctly when loading an existing file (it setsflushed = trueat line ~551 and ~1046), but_rewriteFile()itself does not.Observed behaviour (real log)
The real underlying error (e.g. an OpenAI Codex 401/429) is never surfaced to the caller because
handleRunFailurethrows EEXIST while trying to persist it.Suggested fixes (any one would resolve it)
_persist(), changeopenSync(this.sessionFile, "wx")→openSync(this.sessionFile, "w"). Thewxexclusive-create semantics are unnecessary because the!this.flushedbranch is only reached once per session, and_rewriteFile()(which uses"w") already tolerates overwrite._rewriteFile()setthis.flushed = trueafter writing, matching the pattern already used insetSessionFile()andbranch().flush()method that callers can invoke after_rewriteFile().Environment
@earendil-works/pi-coding-agentv0.79.6_rewriteFile()at session creationWorkaround
Downstream callers who call
_rewriteFile()must also set the internalflushedflag:Happy to open a PR if any of the fix options above looks like the right direction. Thanks for the great work on Pi.