fix(coding-agent): make session-manager _rewriteFile atomic - #167
Closed
jasonkneen wants to merge 1 commit into
Closed
jasonkneen wants to merge 1 commit into
jasonkneen wants to merge 1 commit into
Conversation
Invariant: a persisted session file on disk must always be either the previous complete contents or the new complete contents, never a partial write. Cause: _rewriteFile() opened the live session file with openSync(file, "w"), which truncates it immediately, then wrote entries one at a time. A crash, thrown exception, or ENOSPC partway through left a truncated or empty session file on disk, silently losing the session. This path runs on empty-file init, on migrateToCurrentVersion() bumping the version, and on fork. Fix: _rewriteFile() now writes all entries to a sibling temp file (same directory, name includes pid + random UUID so concurrent instances can't collide), fsyncs it, and renameSync's it over the destination. The original file is untouched until the rename commits. On any failure the temp file is removed (best-effort) and the original error is rethrown. The destination's existing permission bits are preserved on the temp file before it's renamed in; no locking is added (tracked separately). Test: packages/coding-agent/test/session-manager/rewrite-atomic.test.ts mocks writeFileSync to throw after the header write during a migration- triggered rewrite of an existing (v1-style) session file. Before the fix this truncated the file to empty; after the fix the original file is byte-identical and no temp file is left behind. A second test confirms the happy path still produces the same migrated content.
There was a problem hiding this comment.
Copilot review overview
🟡 Changes recommended
Non-ENOENT stat errors must propagate, and permission preservation needs test coverage.
Get a fresh assessment by requesting another Copilot review.
Review effort: Lite
Findings: 1
Open (1)
What changed in this PR
This PR makes session-file rewrites atomic to prevent partial data loss during failures.
Changes:
- Writes to unique temporary files, preserves permissions, fsyncs, and atomically renames.
- Cleans up failed temporary writes.
- Adds failure and migration regression tests.
| File | Summary |
|---|---|
packages/coding-agent/test/session-manager/rewrite-atomic.test.ts |
Tests failed-write preservation and successful migration output. |
packages/coding-agent/src/core/session-manager.ts |
Implements atomic rewriting. Critical (3 votes): rethrow non-ENOENT statSync errors. Nit (1 vote): add POSIX permission-preservation coverage. |
💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.
Comment on lines
+997
to
+999
| } catch { | ||
| // Destination doesn't exist yet (e.g. empty-file init); use default mode. | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.

Problem
packages/coding-agent/src/core/session-manager.ts_rewriteFile()(~line 989) opens the live session file withopenSync(file, "w"), which truncates it immediately, then writes entries one at a time withwriteFileSync. If the process crashes, throws, or hits ENOSPC partway through, the file is left truncated or empty — silent data loss for the whole session. This path runs on empty-file init (~line 908), whenevermigrateToCurrentVersion()reports a change on load (~line 918), and on fork (~line 1520).packages/agent-core/src/harness/session/jsonl/storage.tsalready uses a staged "write sibling temp file, fsync, atomic rename" pattern for the async harness; the coding-agentSessionManager(the path the CLI actually uses) did not.Fix
_rewriteFile()now:statSync(...).mode & 0o777) if it exists.${sessionFile}.${pid}.${randomUUID()}.tmpso concurrent instances can't collide.fchmodSyncbefore writing (falls back to the default mode if the destination didn't exist yet, e.g. empty-file init).fsyncSync's the temp file descriptor before closing it.renameSync's the temp file over the destination — an atomic, same-filesystem operation, so the destination is either the old complete content or the new complete content, never partial.All three call sites (
_setSessionFile's empty-file init and migration-triggered rewrite, andfork()) go through this same function, so all are covered. No locking was added (tracked as a separate concern per the issue).Test
packages/coding-agent/test/session-manager/rewrite-atomic.test.ts:fs.writeFileSync(viavi.mock("fs", ...)+vi.hoistedcounters) to succeed on the first write (the header) and throw on the second, simulating a crash/ENOSPC partway through populating the temp file.id/parentId) somigrateToCurrentVersion()reports a change andSessionManager.open()triggers_rewriteFile().SessionManager.open()threw as expected, but the original file was truncated to empty ('') — confirmed by running this test against the pre-fix source.*.tmpfile is left in the session directory.Also ran the full targeted suite (
test/session-manager/, 8 files / 102 tests) and the fullpackages/coding-agentsuite (--maxWorkers=2): 8 pre-existing failures acrosscontext-projection.test.ts,resource-loader.test.ts,step-tool-profile.test.ts, andtest/suite/regressions/2791-fswatch-error-crash.test.tsremain, matching the documented baseline (these are unrelated to session persistence — provider-catalog fixtures, tool-profile expectations, and a workspace-resolution issue in a child-process regression test). No new failures were introduced.packages/agent-coreandpackages/providersremain fully green.Risk / behaviour change
renameSyncreplaces the destination inode rather than truncating in place. Any hard link to the session file would no longer see updates, and a symlink at the session-file path would be replaced by a regular file rather than having its target's content rewritten. Session files aren't expected to be hard-linked or watched by inode, but this is a real behavior change from in-place truncation.fsyncsyscall per rewrite (migration/init/fork only, not the hot per-turn append path), acceptable for durability._persist()'sopenSync(file, "wx")bulk-write loop (~line 1060), which is a separate, still-non-atomic write pattern. Left alone because it's out of the assigned scope and because it opens with"wx"(fails if the file already exists) — it only ever creates a brand-new file on the first flush of buffered entries, so a mid-write failure there leaves a partial new file rather than clobbering existing session data. Worth a follow-up if the team wants full parity.https://claude.ai/code/session_01GUdnnHEaDThHUATSwXBpV9