Closes #173#184
Conversation
…led spawn performRollover() banked the retired session's cost and minted a fresh sessionId/resumeId, THEN spawned the fresh `claude -p`. If that spawn failed (ENOENT / resource exhaustion — plausible exactly under the long-session load that triggers rollover, #136), the catch returned null but the state was already mutated: sessionId/resumeId pointed at a conversation that never existed, so every subsequent recovery `--resume`d that phantom id for the rest of the meeting (translation silently stops), and the retired session's cost stayed banked in rolledOverCostUsd. Per the Batch 36 amendment (rollover banks/resets cumulative cost before the risky spawn), make the WHOLE transition transactional: snapshot the prior sessionId/resumeId + rolledOverCostUsd + sessionCumulativeCostUsd, provisionally commit (the fresh id must be live before spawnSession, which spawns with --session-id this.sessionId), and on a spawn failure restore all four. Recovery then respawns via --resume the still-valid prior session with consistent, non-duplicated accounting — no phantom id, no double-counted cost. Regression (claude-cli-rollover.e2e): a one-shot injected fresh-spawn failure (the one mode a real fake-cli can't produce — a started process can't make the parent's spawn() emit 'error') drives a failed rollover, then asserts (a) the recovery respawn resumes the ORIGINAL id (not a phantom) via LIVECAP_FAKE_ARGV_OUT and (b) cumulative cost stays monotonic and equals one session's total, not double. A negative control (rollback removed) confirmed the test catches the bug (respawn --resume'd the minted phantom id). Engine suite 263 green; app+packages typecheck, lint, build clean. No new deps; no caption content logged. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
RE2 — APPROVE at PR #184 head (pending
|
project7-interns
left a comment
There was a problem hiding this comment.
Verdict: APPROVE
Epic Alignment: PASS
The #173 rollover transition is transactional across both identity and cost state, with recovery remaining on the prior viable CLI session after a failed fresh spawn.
Checked (evidence)
- Transactional rollback:
packages/engine/src/claude-cli-engine.ts:506-543snapshots and restores sessionId, resumeId, rolled-over cost, and session cumulative cost on fresh-spawn failure. - Recovery regression:
packages/engine/test/claude-cli-rollover.e2e.test.ts:106-189verifies--resumeuses the original id and final cumulative cost remains one session’s cost. - Riskiest part of this diff: post-failure cost accounting; restoring both cost fields prevents double banking when the old session is resumed.
- Kill-list: scanned new ranges — clean.
- CI:
gh pr checks 184→ app-macos, color-guard, no-stub-gate, packages-linux all pass.
Findings
- None.
Decision
The amended identity and cost-accounting invariant is implemented and regression-tested without scope expansion.
Closes #173
Make the Claude CLI session rollover transactional: commit the fresh session id + banked cost only after the fresh
claude -pspawns, and roll the whole transition back on a spawn failure — so a failed rollover no longer strands recovery on a phantom--resumeid or double-counts the retired session's cost.EPIC Alignment
Part of EPIC #134 (v1.3 audit follow-up), Batch 41 (standing-order sequence #172 → #171 → #170 → #173). TS engine cluster; scoped to
packages/engine/src/claude-cli-engine.ts(+ its rollover e2e test). Touches onlyperformRollover's failure handling — no change to the #136 rollover trigger, the #135 respawn path, or the #24 cost model. Does not touchresample.rs/render.ts/parse.ts/pipeline.rs(reserved for #178/#179) or dashboard/settings.Self-Verification
The bug.
performRollover()(claude-cli-engine.ts:499) banked the retired session's cost (rolledOverCostUsd += sessionCumulativeCostUsd) and minted a freshsessionId/resumeId, then spawned the freshclaude -p. On a spawn failure (the child emits'error'— ENOENT / resource exhaustion, plausible exactly under the sustained long-session load that triggers rollover per #136), thecatchreturnednullbut the state was already mutated:resumeIdpointed at a session that was never created — the nextrunTurnseeschild === null, callsrespawn()→spawnSession(this.resumeId)→--resume <phantom>. If the CLI errors on--resumeof an unknown id, the engine is stuck degraded for the rest of the meeting (translation silently stops), recoverable only by restarting the app.rolledOverCostUsd, so restoring only the ids would double-count it once the old session resumed.The fix (transactional, per the Batch 36 amendment). Snapshot the pre-rollover
sessionId,resumeId,rolledOverCostUsd, andsessionCumulativeCostUsd; provisionally commit the rollover (the fresh id must be live beforespawnSession, which spawns with--session-id this.sessionId); and on a spawn failure restore all four (claude-cli-engine.ts:516-541). Recovery then respawns via--resumethe still-valid prior session with consistent, non-duplicated accounting — no phantom id, no double bank. The success path is unchanged (a reseed failure after a confirmed spawn still returnsnullwith the fresh id committed, because that session did spawn — the ticket's cited failure is the pre-spawn'error').Security invariants. No new dependencies. No caption/audio content logged (the rollback touches only ids + numeric cost fields). Simplest fix that solves the ticket — snapshot/restore in the existing
catch; no drive-by refactors.Tests / evidence. New regression in
claude-cli-rollover.e2e.test.ts:spawnSession's reject (a successfully-started process can't make the parent'sspawn()emit'error'), so the test injects a one-shot fresh-spawn failure — only the rollover'sspawnSession(undefined)once armed;start()and the recovery respawn take the real fake-cli path. It drives turn 1 (arms rollover) → turn 2 (rollover spawn fails →runTurnthrows not-started) → turn 3 (recovery), then asserts:LIVECAP_FAKE_ARGV_OUT) is--resume <ORIGINAL id>with no--session-id, i.e. it resumes the prior session, not a stranded phantom;0.001, not the0.002a restore-ids-only fix (leaving the cost double-banked) would report.--resumed the minted phantom id (79b30220-…) instead of the original — proving the regression is not vacuous.typecheck,lint,buildall clean.Design Fidelity
N/A — no UI/webview/CSS surface changed (translation-engine internals only).
Deviations
spawnSessionthrough a typed seam, rather than via the fake-cli, because a spawn'error'(OS-level spawn failure) is the one failure mode a real child process cannot self-induce.start()and the recovery respawn still use the real spawn/stdio path, so the recover-and-translate behavior is exercised end-to-end.'error'path the ticket cites; a reseed failure after a confirmed spawn is left as-is (that fresh session did spawn, so its id is valid to--resume), avoiding a behavior change to the [bug] Long meetings hit a session context cliff (~2h) → prompt-too-long on every turn (N2) #136 reseed path.🤖 Generated with Claude Code