fix(replay): repair-transaction lifecycle — keep-alive, no-partial-emit, close-as-lifecycle, atomic publish
Summary
Implements the four "repair transaction" lifecycle fixes for ADR-0012 Decision 6 (agent-supervised re-record repair, #1228), closing real end-to-end gaps found in live testing.
Unifying model: replay <file>.ad --save-script opens a multi-invocation repair TRANSACTION (ARMED → COMPLETE → COMMITTED). It is committed (the healed .ad atomically published) on any teardown of the session — explicit close, idle-reap, or daemon shutdown — but only when the transaction is COMPLETE; a teardown while INCOMPLETE never publishes a prefix. Continuation keys off persisted transaction state, so replay --from continues without repeating --save-script. Everything below serves that model.
- Fix 1 (Q1) —
cleanupDaemonAfterRequest(src/daemon/client/daemon-client-lifecycle.ts) no longer tears down a cold, owned-ephemeral daemon when a repair-armed replay returns a heldREPLAY_DIVERGENCE. Keep-alive keys off the daemon'sresume.repairSessionHeldsignal (markRepairSessionHeldIfArmed, set from persisted armed-uncommitted session state — not the per-request--save-scriptflag, so areplay --fromcontinuation that itself diverges stays held) viaisHeldRepairDivergence; it is independent ofresume.allowed(plan-resumability). Because an owned/ephemeral state dir (fs.mkdtempSync) is otherwise unaddressable by the agent's next command, the response carries ahintpointing at--state-dir <path>(attachRepairSessionAddressHint,src/daemon/client/daemon-client.ts). - Fix 2 (Q2a) —
SessionScriptWriter.write(src/daemon/session-script-writer.ts) publishes a repair-armed session's (saveScriptBoundaryset) healed.adonly when the transaction isCOMPLETE(isRepairArmedWriteBlockedgates onsaveScriptComplete, idempotent oncesaveScriptCommitted). Commit happens at any teardown of a COMPLETE transaction — explicitclose, idle-reap, and daemon shutdown all route throughfinalizeRepairTeardown(src/daemon/session-store.ts) → the writer. A teardown while INCOMPLETE publishes nothing (never a prefix): a plainclosediscards; an idle-reap/shutdown leaves aREPAIR_SESSION_EXPIREDtombstone (surfaced byrequest-router), andhasReapBlockingOpenSessions(src/daemon/handlers/daemon-idle-reap.ts) lets an uncommitted repair session reap rather than pinning the daemon forever. - Fix 3 (Q2b) — the source plan's own terminal
closeis skipped (isRepairArmedTerminalClose,src/daemon/handlers/session-replay-runtime.ts) while a repair is armed, instead of dispatching as an ordinary step and diverging on lifecycle. Verification already exempted unannotated actions from target-binding divergence kinds (verifyReplayActionTarget); the fix removes the remainingaction-failuremisfire by never dispatching it at all during repair. - Fix 4 (Q2c) — scripts publish atomically via create-exclusive
linkSync, with the temp file created in the target's own directory (publishHealedScriptAtomically). The publish contract is uniformly no-clobber: repair heals and ordinary recordings, default and explicit--save-script=<path>targets, and complete or partial existing files are all refused and left byte-for-byte unchanged. A--force/--overwriteescape hatch is tracked separately in #1258. Completed repair artifacts carry a# agent-device:heal-completesentinel.
Files touched
src/daemon/client/daemon-client-lifecycle.ts—cleanupDaemonAfterRequest(+isResumableRepairDivergence,attachRepairSessionAddressHint)src/daemon/client/daemon-client.ts—sendToDaemon(+withRepairSessionAddressHintIfOwned)src/daemon/session-script-writer.ts—SessionScriptWriter.write,isRepairArmedWriteBlocked,HEAL_COMPLETE_SENTINEL,publishHealedScriptAtomically,assertNoCompleteHealedClobbersrc/daemon/session-store.ts—finalizeRepairTeardown(commit-or-tombstone at teardown)src/daemon/handlers/session-replay-runtime.ts—isRepairArmedTerminalClose,markRepairSessionHeldIfArmed,createReplaySaveScriptArmer,preflightReplayAgainstActiveRepair, setssaveScriptCompleteat plan completionsrc/daemon/request-router.ts—REPAIR_SESSION_EXPIREDfor a reaped uncommitted repair session (tombstone)src/daemon/handlers/daemon-idle-reap.ts—hasReapBlockingOpenSessionssrc/daemon/types.ts—SessionState.saveScriptComplete/saveScriptCommitted/saveScriptBoundary;resume.repairSessionHeld
New tests
src/utils/__tests__/daemon-client-lifecycle.test.ts: keeps an owned daemon alive + hints--state-diron a resumable divergence; still tears down on a non-resumable divergence; still tears down without--save-script.src/daemon/__tests__/session-script-writer.test.ts: discards an unfinalized repair-armed write; never sentinel-tags ordinary recordings; refuses every pre-existing target without changing its bytes (complete, partial, explicit, and ordinary-recording cases); permits absent targets; proves first-writer-wins atomic publication and leaves no stray temp file.src/daemon/handlers/__tests__/session-replay-repair-loop.test.ts: terminal close skipped while armed; ordinary replay still dispatches its close; only the terminal close is skipped (mid-plan close still dispatches); a--fromresume landing on the terminal close completes instead of diverging.src/daemon/handlers/__tests__/session-replay-repair-transaction.test.ts(new): end-to-end chain — cold divergence stays alive (session never deleted) → corrective press +--fromresume runs to completion (terminal close skipped) →close --save-scriptcommits a complete, atomically-published healed.adwith no bare@refs → a separate diverged-and-abandoned repair (closewithout--save-script) leaves no partial file on disk.
Test plan
-
npx vitest run— full suite: 467 files / 4097 tests, all green -
npx tsc --noEmit— clean -
npx oxlint --deny-warnings— clean -
npx oxfmt --check— clean on all touched files (37 pre-existing unrelated files were already failing onorigin/mainbefore this change) -
pnpm check:fallow --base origin/main— no issues in 11 changed files
Live route evidence
A simulator run on route-equivalent head 6beb13f5f exercised the production repair workflow: a held divergence preserved the session, corrective interaction plus replay --from completed the plan, close --save-script published the complete healed script, and a fresh replay consumed that artifact successfully. Later commits changed daemon/filesystem publication, teardown-failure reporting, and close-retry identity rather than platform behavior; focused boundary regressions and the fully green platform smoke matrix cover those changes, so another device run is not required.
No device-lease semantics were changed beyond the session staying open (hasOpenSessions) for the repair transaction's lifetime.