Skip to content

SessionManager._persist() throws EEXIST when file is pre-written by _rewriteFile(), masking real agent errors #5844

@valtterimelkko

Description

@valtterimelkko

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

  1. Create a SessionManager via SessionManager.create(cwd, sessionDir).
  2. Call _rewriteFile() (or otherwise cause the session file to exist on disk with the header) without an assistant message in fileEntries.
  3. Trigger an agent prompt that fails (e.g. invalid API key, provider error) before producing an assistant message.
  4. 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)

  1. 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.
  2. Alternative: Have _rewriteFile() set this.flushed = true after writing, matching the pattern already used in setSessionFile() and branch().
  3. 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    Type

    No type
    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions