Recover agent context from torn run-state.json backups - #1195
Conversation
When run-state.json is unreadable, loadMostRecentChatState now falls back to the previous generation (.bak rotated aside by each synchronous save) and to complete checkpoint temps left by SIGKILLed writes, ordered newest-first with a deterministic tie-break, self-heals the primary from whichever recovered, and clears the .bak on chat deletion. The async atomic write retries EPERM/EBUSY/EACCES renames briefly for Windows AV/indexer locks. The resume flow prepends an error-variant notice when nothing could be recovered, so the context loss is surfaced instead of reading as a broken assistant. Companion to CodebuffAI#1166; details and repro in CodebuffAI#1168. Recovery half of CodebuffAI#1169, split per review so the fsync durability fix can land on its own.
|
Solid piece of work. The core idea in A few things worth double-checking before this lands:
Good test coverage (7 new cases) and a clear root-cause writeup. This is exactly the kind of change worth a maintainer's time to review and port, modulo the compile-completeness check above. |
Summary
Recovery half of #1169, split out per review so the fsync durability fix (#1169, now fsync + retry tests only) can land on its own. A resumed chat could start amnesiac through a chain with no single point of failure (details and repro in #1168):
loadMostRecentChatStaterecovers instead of giving up. Whenrun-state.jsonis unreadable it now tries every intact older generation in the chat directory: the rotatedrun-state.json.bak(each synchronous save rotates the current file aside before overwriting) and any complete checkpoint.tmpleft behind when a process died between write and rename. Candidates are ordered newest-first — the generation that lost the least agent context wins, and recency beats a fixed .bak-first order when a newer checkpoint exists. mtime ties break on path comparison so coarse-mtime filesystems stay deterministic.loadMostRecentChatStatereturnsrunStateRestored, and the resume flow prepends an error-variant notice ("the assistant starts this chat without memory of earlier turns; the transcript below is intact") when it isfalse. Previously the transcript rendered normally and the model just "forgot" — indistinguishable from a broken assistant.clearChatStatealso removes the.bak, so deleting a chat does not leave recoverable state behind.Root cause
The atomic write makes the rename indivisible, but without an fsync a power loss can land the rename while the file's data blocks were never written — leaving a truncated or empty primary (and even with the fsync, external corruption exists).
loadMostRecentChatStatetreated any unreadable primary as fatal and handed back a placeholder RunState withoutsessionState; the SDK then started a fresh session next turn, which reads as the assistant having "forgotten" everything.Changes
cli/src/utils/run-state-storage.ts—readRunStateWithRecovery(chatDir): primary → newest-first candidates (.bak+ checkpoint temps, mtime-ordered with path tie-break) →null; self-heals the primary from the winner (best-effort, try/catch); per-attempt and final-failure warnings viabestEffortLog.saveChatStaterotates the previous primary intorun-state.json.bakbefore overwriting (best-effort; the overwrite proceeds regardless).renameSyncis a metadata-only directory operation — no data copy — so the per-step cost is one extra inode rename on an already multi-syscall save.cli/src/hooks/use-send-message.ts— resume path prepends the context-loss notice whenrunStateRestoredisfalse.Tests
cli/src/utils/__tests__/run-state-storage.test.ts:.bakrecovery + self-heal, newest-temp recovery (mtimes pinned withutimesSyncso back-to-back writes cannot flake on mtime quantization), newer temp beats.bak(the recency rule; fails under a fixed .bak-first order), healthy primary flagged restored, all-candidates-unreadable fallback with the loss flagged, backup rotation on save, backup cleanup on delete.NODE_ENV=production bun test src/utils/__tests__/run-state-storage.test.ts— 44 pass (37 pre-existing + 7), 0 fail.Validation
NODE_ENV=production bun test src/utils/__tests__/run-state-storage.test.ts src/utils/__tests__/write-file-atomic.test.ts— 54 pass, 0 fail.bun x tsc --noEmit -p cli/tsconfig.json— 10 errors, all pre-existing onmainand all in unrelated test files (tartypes,react-dom/servertypes); none in the three changed files.bunx prettier --checkclean on all three changed files.Refs #1168, #1169
Bench (rotation cost, review's perf note)
renameSyncon a 5 MiB run-state-sized file measured on this machine (bun 1.4.0, 500 iterations, filesystem: tmpfs —statfsSync.type16914836 =TMPFS_MAGIC). The real chat dir lives on the user's home filesystem, so absolute numbers there differ, but the property being measured is not FS-dependent:.bakrotationRotation adds no measurable cost (delta −0.04 ms/op, within noise): it is one extra metadata-only directory-entry re-link, never a data copy — renaming a multi-MB file does not move its blocks. Worst case on a non-tmpfs disk is still one inode rename vs the ~5 MiB write+fsync the save already performs.
Tradeoff: async checkpoints do not rotate
.bak.bakreflects the last synchronous save (exit flush, step-boundary writes). The async checkpoint writer (saveChatStateAsync) skips rotation deliberately: an un-awaitedrenameSyncin it would contend with the in-flightwriteFileAtomicAsyncrename for the same target, and deferring it torenameWithRetryis a follow-up. A crash between sync saves leaves the.bakup to one sync-save older than the newest complete checkpoint temp — which the recovery order in this PR already prefers. Recovery quality is unaffected; only the.bak's freshness lags.