Skip to content

Closes #173#184

Merged
realproject7 merged 1 commit into
mainfrom
task/173-rollover-transactional
Jul 14, 2026
Merged

Closes #173#184
realproject7 merged 1 commit into
mainfrom
task/173-rollover-transactional

Conversation

@realproject7

Copy link
Copy Markdown
Owner

Closes #173

Make the Claude CLI session rollover transactional: commit the fresh session id + banked cost only after the fresh claude -p spawns, and roll the whole transition back on a spawn failure — so a failed rollover no longer strands recovery on a phantom --resume id 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 only performRollover's failure handling — no change to the #136 rollover trigger, the #135 respawn path, or the #24 cost model. Does not touch resample.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 fresh sessionId/resumeId, then spawned the fresh claude -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), the catch returned null but the state was already mutated:

  • resumeId pointed at a session that was never created — the next runTurn sees child === null, calls respawn()spawnSession(this.resumeId)--resume <phantom>. If the CLI errors on --resume of an unknown id, the engine is stuck degraded for the rest of the meeting (translation silently stops), recoverable only by restarting the app.
  • the retired session's cost stayed banked in 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, and sessionCumulativeCostUsd; provisionally commit the rollover (the fresh id must be live before spawnSession, 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 --resume the 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 returns null with 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:

  • A real fake-cli binary cannot produce spawnSession's reject (a successfully-started process can't make the parent's spawn() emit 'error'), so the test injects a one-shot fresh-spawn failure — only the rollover's spawnSession(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 → runTurn throws not-started) → turn 3 (recovery), then asserts:
    • (a) viable identity — the recovery respawn's argv (via LIVECAP_FAKE_ARGV_OUT) is --resume <ORIGINAL id> with no --session-id, i.e. it resumes the prior session, not a stranded phantom;
    • (b) monotonic, non-duplicated cost — usages never regress and the final cumulative equals one session's 0.001, not the 0.002 a restore-ids-only fix (leaving the cost double-banked) would report.
  • Negative control (scratch): with the rollback removed, the test fails — the respawn --resumed the minted phantom id (79b30220-…) instead of the original — proving the regression is not vacuous.
  • Full engine suite 263 tests pass (26 files, incl. the existing rollover/watchdog/respawn e2e); app + packages typecheck, lint, build all clean.

Design Fidelity

N/A — no UI/webview/CSS surface changed (translation-engine internals only).

Deviations

  • The regression injects the fresh-spawn failure by wrapping the private spawnSession through 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.
  • Scoped the rollback to the pre-spawn '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

…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>
@realproject7

Copy link
Copy Markdown
Owner Author

RE2 — APPROVE at PR #184 head (pending app-macos/packages-linux green)

The #173 rollover fix is transactional across both IDs and cost (the Batch-36 amendment's core requirement), and the regression proves it. Reviewed the whole diff (2 files) and independently ran the engine suite + typecheck + lint on Linux (real pnpm install) and an adversarial negative control.

Checked (evidence)

  • Bug correctly diagnosed. performRollover banked the retired session's cost and minted a fresh sessionId/resumeId before the fresh claude -p spawn was confirmed (claude-cli-engine.ts). On a pre-spawn spawn('error'), the old catch returned null with state already mutated → resumeId stranded on a phantom (every later --resume targets a session that never existed), and the retired cost stayed banked (double-counted on resume).
  • Fix is a complete four-field transaction. Snapshots sessionId/resumeId/rolledOverCostUsd/sessionCumulativeCostUsd before mutating (:517-520), provisionally commits, and on spawn failure restores all four (:535-538) → recovery --resumes the still-valid prior session with un-banked, non-duplicated cost. this.child = null is correctly NOT restored (the old child was killed; recovery respawns). Success path unchanged — a reseed failure after a confirmed spawn keeps the fresh id (that session really spawned, so no phantom; cost already banked correctly). This satisfies the amendment's "rollback for rolledOverCostUsd/sessionCumulativeCostUsd," not just IDs.
  • Test proves BOTH amendment requirements. Injects a one-shot fresh-spawn failure via a spawnSession seam (only resume===undefined; start + recovery use the real path — legitimate, since a started fake-cli can't make the parent's spawn() emit 'error'). Turn 1 arms rollover → turn 2 rollover-spawn fails & runTurn throws → turn 3 recovers, asserting (a) the respawn argv is --resume <ORIG> with no --session-id (viable identity, not a phantom), and (b) cumulative cost is monotonic AND final ≈ 0.001, not the 0.002 an ids-only fix would report.
  • Adversarially verified the cost assertion is load-bearing. I patched the fix to restore IDs only (leaving cost double-banked — the exact anti-pattern the amendment warns about) and ran the test: it fails on (b)AssertionError: expected 0.002 to be close to 0.001. So the four-field restore is what makes it pass; the cost half of the test is non-vacuous. (Reverted the patch after.)
  • Independently reproduced (node 24 / real pnpm install): @livecap/engine suite 26 files / 263 tests pass (incl. the 4 rollover e2e); engine tsc --noEmit clean; eslint on the 2 changed files clean.
  • Boundary/invariants: 2 files (claude-cli-engine.ts + its rollover e2e); package.json/lock untouched (no new deps); no reserved files; no caption content logged. Design Fidelity N/A (engine internals).

CI condition

color-guard/no-stub-gate pass; app-macos/packages-linux pending — packages-linux runs the engine suite (reproduced green locally); the merge gate should still wait for both green. (Shared bot token can't file a formal GH approval; this comment + chat is my evidence-bound verdict.)

@project7-interns project7-interns left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-543 snapshots 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-189 verifies --resume uses 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.

@realproject7
realproject7 merged commit 8f40f03 into main Jul 14, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

2 participants