fix(replay): repair-transaction lifecycle — keep-alive, no-partial-emit, close-as-lifecycle, atomic publish

Merged
TH
4mo ago
+3247-131

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 held REPLAY_DIVERGENCE. Keep-alive keys off the daemon's resume.repairSessionHeld signal (markRepairSessionHeldIfArmed, set from persisted armed-uncommitted session state — not the per-request --save-script flag, so a replay --from continuation that itself diverges stays held) via isHeldRepairDivergence; it is independent of resume.allowed (plan-resumability). Because an owned/ephemeral state dir (fs.mkdtempSync) is otherwise unaddressable by the agent's next command, the response carries a hint pointing 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 (saveScriptBoundary set) healed .ad only when the transaction is COMPLETE (isRepairArmedWriteBlocked gates on saveScriptComplete, idempotent once saveScriptCommitted). Commit happens at any teardown of a COMPLETE transaction — explicit close, idle-reap, and daemon shutdown all route through finalizeRepairTeardown (src/daemon/session-store.ts) → the writer. A teardown while INCOMPLETE publishes nothing (never a prefix): a plain close discards; an idle-reap/shutdown leaves a REPAIR_SESSION_EXPIRED tombstone (surfaced by request-router), and hasReapBlockingOpenSessions (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 close is 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 remaining action-failure misfire 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/--overwrite escape hatch is tracked separately in #1258. Completed repair artifacts carry a # agent-device:heal-complete sentinel.

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, assertNoCompleteHealedClobber
  • src/daemon/session-store.ts — finalizeRepairTeardown (commit-or-tombstone at teardown)
  • src/daemon/handlers/session-replay-runtime.ts — isRepairArmedTerminalClose, markRepairSessionHeldIfArmed, createReplaySaveScriptArmer, preflightReplayAgainstActiveRepair, sets saveScriptComplete at plan completion
  • src/daemon/request-router.ts — REPAIR_SESSION_EXPIRED for a reaped uncommitted repair session (tombstone)
  • src/daemon/handlers/daemon-idle-reap.ts — hasReapBlockingOpenSessions
  • src/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-dir on 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 --from resume 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 + --from resume runs to completion (terminal close skipped) → close --save-script commits a complete, atomically-published healed .ad with no bare @refs → a separate diverged-and-abandoned repair (close without --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 on origin/main before 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.