fix(replay): repair-transaction lifecycle — keep-alive, no-partial-emit, close-as-lifecycle, atomic publish#1235
fix(replay): repair-transaction lifecycle — keep-alive, no-partial-emit, close-as-lifecycle, atomic publish#1235thymikee wants to merge 10 commits into
Conversation
Size Report
Startup median (7 runs, lower is better):
Top changed chunks:
|
- C1: R7 keep-alive keys off a DISTINCT resume.repairSessionHeld signal, not resume.allowed (which means plan-resumability and fires for every divergence). - C2: define the ARMED -> COMPLETE -> COMMITTED commit state machine. close before COMPLETE = abort (publish nothing); close at COMPLETE = atomic commit; close after COMMITTED = idempotent teardown, no re-publish. No auto-commit. - C4: precise terminal-source-close contract (last source action == close) — SKIPPED (not dispatched) under armed repair so the session is not deleted; regression required (added to migration step 9). - C5a: REPAIR_SESSION_EXPIRED backed by a bounded tombstone keyed by session key (owner + expiry), cleared by a fresh replay --save-script. - C5b: atomic publication temp file in the target's own directory; race-safe no-clobber via create-exclusive/rename-if-absent. - C6: Status block corrected — Decisions 1-6 base MERGED (#1228 et al.); R7 + commit machine UNIMPLEMENTED, tracked by #1235. Migration split into step 8 (merged) and step 9 (#1235). Validation extended for all of the above.
…tion Resolves the two contract ambiguities blocking merge (aligns with #1235): - Completion model = TEARDOWN-COMMITS (not explicit-close-only). A repair-armed session stays addressable until the transaction ends; ANY teardown (explicit close, idle-reap, daemon shutdown) commits the healed .ad atomically iff the transaction is COMPLETE, else aborts with no publish (never a prefix). An incomplete reap/shutdown leaves the REPAIR_SESSION_EXPIRED tombstone; an explicit close of an incomplete tx just discards. Kept the ARMED->COMPLETE->COMMITTED machine and idempotent post-COMMITTED teardown; removed the "no auto-commit / commit only on explicit close" language. - Continuation by PERSISTED transaction state, not the per-request flag: replay --from <n> --plan-digest <sha> resumes on the persisted repair-armed session WITHOUT repeating --save-script; --save-script appears only on the transaction opener. Implementation MUST key keep-alive/continuation off persisted state. Decision 4 repairSessionHeld updated to match. Edited: R7, Decision 4 resume signal, terminal-close, Emitting, commit state machine, tombstone, migration step 9, and validation.
c3829c4 to
d72ec31
Compare
|
Coordinator review at
Ordering remains: resolve and merge governing ADR #1234 first, then rebase #1235 onto it and align the stale PR description. Practical shipped-CLI simulator evidence is still required for held divergence/continuation, commit and fresh replay, expiry recovery, clobber/unwritable/concurrent output, and cleanup. No fixer dispatched; readiness remains withheld. |
- C1: R7 keep-alive keys off a DISTINCT resume.repairSessionHeld signal, not resume.allowed (which means plan-resumability and fires for every divergence). - C2: define the ARMED -> COMPLETE -> COMMITTED commit state machine. close before COMPLETE = abort (publish nothing); close at COMPLETE = atomic commit; close after COMMITTED = idempotent teardown, no re-publish. No auto-commit. - C4: precise terminal-source-close contract (last source action == close) — SKIPPED (not dispatched) under armed repair so the session is not deleted; regression required (added to migration step 9). - C5a: REPAIR_SESSION_EXPIRED backed by a bounded tombstone keyed by session key (owner + expiry), cleared by a fresh replay --save-script. - C5b: atomic publication temp file in the target's own directory; race-safe no-clobber via create-exclusive/rename-if-absent. - C6: Status block corrected — Decisions 1-6 base MERGED (#1228 et al.); R7 + commit machine UNIMPLEMENTED, tracked by #1235. Migration split into step 8 (merged) and step 9 (#1235). Validation extended for all of the above.
…tion Resolves the two contract ambiguities blocking merge (aligns with #1235): - Completion model = TEARDOWN-COMMITS (not explicit-close-only). A repair-armed session stays addressable until the transaction ends; ANY teardown (explicit close, idle-reap, daemon shutdown) commits the healed .ad atomically iff the transaction is COMPLETE, else aborts with no publish (never a prefix). An incomplete reap/shutdown leaves the REPAIR_SESSION_EXPIRED tombstone; an explicit close of an incomplete tx just discards. Kept the ARMED->COMPLETE->COMMITTED machine and idempotent post-COMMITTED teardown; removed the "no auto-commit / commit only on explicit close" language. - Continuation by PERSISTED transaction state, not the per-request flag: replay --from <n> --plan-digest <sha> resumes on the persisted repair-armed session WITHOUT repeating --save-script; --save-script appears only on the transaction opener. Implementation MUST key keep-alive/continuation off persisted state. Decision 4 repairSessionHeld updated to match. Edited: R7, Decision 4 resume signal, terminal-close, Emitting, commit state machine, tombstone, migration step 9, and validation.
d72ec31 to
d55395b
Compare
|
Additional documentation finding: this PR description still presents The previously reported implementation blockers remain unchanged; this comment does not supersede them. |
|
Addressed: PR description updated to match the amended ADR + implementation — commit is teardown-gated on |
… semantics) (#1234) * docs: ADR-0012 Decision 6 — repair-transaction lifecycle (R7 + commit semantics) Frames --save-script as a multi-invocation repair TRANSACTION committed only on completion: - R7 (new normative rule): a repair-armed replay returning resume.allowed:true must keep its daemon/session live until close; heal/--from target that same session (strengthens R2). Plain close/teardown/idle-reap while armed = abort/ discard. Bounded-expiry must surface REPAIR_SESSION_EXPIRED, not bare SESSION_NOT_FOUND. Persistent-daemon precondition rejected: fail-fast before step 1, never a later SESSION_NOT_FOUND. - Decision 4 resume: one sentence noting the session is kept addressable so resume.allowed:true is not misleading. - Commit semantics: healed .ad committed only on full-plan completion or explicit close --save-script; never on divergence-only exit, teardown, or idle-reap; atomic temp->publish. R6 defines the slice, this defines when it is complete. - Terminal lifecycle steps: non-target steps (incl. source close, unannotated steps) are already exempt from target-binding divergence per decision 3 (clarification, not a change); prefer SKIPPING the source terminal close while armed and finalize via close --save-script. - Clobber P2: no-clobber guards a COMPLETE (heal-complete sentinel) artifact only; partials are overwritable; auto-versioned names out of scope. Validation + migration step 8 extended accordingly. * docs: ADR-0012 R7 — fix 6 architecture blockers (transaction contract) - C1: R7 keep-alive keys off a DISTINCT resume.repairSessionHeld signal, not resume.allowed (which means plan-resumability and fires for every divergence). - C2: define the ARMED -> COMPLETE -> COMMITTED commit state machine. close before COMPLETE = abort (publish nothing); close at COMPLETE = atomic commit; close after COMMITTED = idempotent teardown, no re-publish. No auto-commit. - C4: precise terminal-source-close contract (last source action == close) — SKIPPED (not dispatched) under armed repair so the session is not deleted; regression required (added to migration step 9). - C5a: REPAIR_SESSION_EXPIRED backed by a bounded tombstone keyed by session key (owner + expiry), cleared by a fresh replay --save-script. - C5b: atomic publication temp file in the target's own directory; race-safe no-clobber via create-exclusive/rename-if-absent. - C6: Status block corrected — Decisions 1-6 base MERGED (#1228 et al.); R7 + commit machine UNIMPLEMENTED, tracked by #1235. Migration split into step 8 (merged) and step 9 (#1235). Validation extended for all of the above. * docs: ADR-0012 R7 — teardown-commits model + persisted-state continuation Resolves the two contract ambiguities blocking merge (aligns with #1235): - Completion model = TEARDOWN-COMMITS (not explicit-close-only). A repair-armed session stays addressable until the transaction ends; ANY teardown (explicit close, idle-reap, daemon shutdown) commits the healed .ad atomically iff the transaction is COMPLETE, else aborts with no publish (never a prefix). An incomplete reap/shutdown leaves the REPAIR_SESSION_EXPIRED tombstone; an explicit close of an incomplete tx just discards. Kept the ARMED->COMPLETE->COMMITTED machine and idempotent post-COMMITTED teardown; removed the "no auto-commit / commit only on explicit close" language. - Continuation by PERSISTED transaction state, not the per-request flag: replay --from <n> --plan-digest <sha> resumes on the persisted repair-armed session WITHOUT repeating --save-script; --save-script appears only on the transaction opener. Implementation MUST key keep-alive/continuation off persisted state. Decision 4 repairSessionHeld updated to match. Edited: R7, Decision 4 resume signal, terminal-close, Emitting, commit state machine, tombstone, migration step 9, and validation.
Live CLI evidence — repair transaction end-to-end (iOS iPhone 17 Pro, RN prevent-remove)Captured on the shipped CLI built from this branch ( 1. Held divergence + keep-alive (cold and warm). A 2. Continuation to completion (terminal-close no longer diverges). Corrective 3. Commit → complete healed artifact.
4. Fresh-session replay (acceptance). Replaying the committed healed The Not captured live here: |
6beb13f to
1a09ed1
Compare
|
Coordinator + independent re-review at
The |
5d4d999 to
fb44cf4
Compare
|
Addressed all three blockers from the 1. Partial-target no-clobber race — fixed.
Every interleaving converges on exactly one winning 2. Invisible COMPLETE-commit failures on idle-reap/shutdown — fixed. 3. Auto-commit now records the skipped terminal Checks: |
|
Current-head review at Please make the restore/publish protocol mutually exclusive with competing publishers (an atomic lock/claim protocol is one viable shape) and add a deterministic regression where writer B publishes after writer A quarantines a COMPLETE target but before A restores it. Assert no writer reports success unless its complete bytes remain at the target, and that the pre-existing COMPLETE artifact is never silently clobbered. No fixer was dispatched. |
|
Independent exact-head review confirmed the publication race above and found two additional close-path issues:
Readiness remains withheld. No fixer was dispatched. |
Agent-supervised re-record repair lifecycle, rebased onto #1225's failure-isolated close teardown. Consolidated from the earlier iterative rounds into the final teardown-commits model: - R7 keep-alive keyed off PERSISTED transaction state (repairSessionHeld signal), so a `replay --from` continuation without --save-script is still held on divergence. - Commit gated on transaction COMPLETION (saveScriptComplete/saveScriptCommitted), never on `close` alone — no prefix is ever published. - Single commit path: `commitRepairBeforeClose` runs before #1225's `runSessionCloseTeardown` destructive steps; a repair-armed session skips the teardown's ordinary writeSessionLog, non-repair keeps it. Idle-reap/shutdown commit-on-completion or tombstone via `finalizeRepairTeardown`. - BLOCKER fixes: reaped `replay --from` -> REPAIR_SESSION_EXPIRED; commit failures surfaced (not swallowed) and keep the session for retry (with healed-path reporting on success); race-safe atomic no-clobber publish; minimal `[open, close]` arms the transaction. Integrated with #1225: keeps runSessionCloseTeardown's failure-isolated cleanup + preserved platform-close error; repair commit happens first so a failed commit keeps the session addressable.
…race winner BLOCKER 1 (coordinator re-review): after linkSync saw an existing target, the no-clobber publish fell back to an unconditional renameSync once the target was classified "incomplete" — two concurrent writers could both read the SAME pre-existing partial as overwritable and both renameSync over it, each returning success with no signal to the loser. A silent, undetectable clobber. publishNoClobberAtomically now makes every winner decision an atomic primitive: - linkSync is the only way to win outright (EEXIST iff a file is at the target at that instant). - On EEXIST, the existing file is grabbed via an atomic renameSync into a private, uniquely-named quarantine path *before* it is inspected, so the completeness check never races the shared path. A competing writer's own grab racing ours surfaces as ENOENT, and we re-evaluate from the top instead of trusting a stale read. - A COMPLETE quarantined file is restored (best effort) and the publish is refused; a genuinely partial one is discarded and the exclusive linkSync is retried. Every interleaving converges on exactly one winning linkSync and every other writer observing a definitive, thrown "already exists" — never two silent successes, never a torn file. Adds a regression (session-script-writer.test.ts) with both writers starting against the SAME pre-existing partial target, using a renameSync spy to force a genuine interleaving (writer B's whole publish runs inside writer A's grab step) instead of the existing competing-writer test's sequential complete-vs-complete scenario, which never exercised this race.
…a generic expiry
BLOCKER 2 (coordinator re-review): finalizeRepairTeardown ignored the
writer's { written: false, error } outcome, so a COMPLETE transaction whose
commit failed at idle-reap/daemon-shutdown teardown (no-clobber refusal,
bare-@ref, or a filesystem error) was silently swallowed. Daemon teardown
then deleted the session and left a generic "reaped before it was finalized"
REPAIR_SESSION_EXPIRED tombstone — losing the only record that a commit was
even attempted, let alone why it failed.
finalizeRepairTeardown now captures the writer's result. On a real commit
failure it writes a distinct commit-failure tombstone (RepairSessionTombstone
gains an optional commitFailure: { code, message }); request-router's
repairExpiredIfTombstoned surfaces that as a new REPAIR_COMMIT_FAILED error
carrying the real cause instead of folding it into REPAIR_SESSION_EXPIRED.
Adds a new AppErrorCode REPAIR_COMMIT_FAILED (kernel/errors.ts) with its own
hint, a session-store regression proving finalizeRepairTeardown preserves the
failure (and leaves the prior complete artifact untouched), and a
request-router regression proving the router translates a commit-failure
tombstone to REPAIR_COMMIT_FAILED rather than the generic expiry.
…auto-commit BLOCKER 3 (coordinator re-review): the source plan's terminal `close` is skipped-while-armed (Fix 3), so it never lands in session.actions. The explicit `close --save-script` path accounts for this by recording a synthetic finalize close (commitRepairBeforeClose) before committing, but finalizeRepairTeardown's auto-commit at idle-reap/daemon-shutdown never runs that handler — its committed healed .ad was missing its own terminal close, so the ADR's "self-contained, fresh-replayable artifact" requirement didn't hold for this path even though the existing auto-commit test only checked existence + the completeness sentinel. finalizeRepairTeardown now calls a new recordRepairFinalizeCloseIfCommitting before writeSessionLog, mirroring commitRepairBeforeClose's recording exactly (same command/positionals/flags shape), but only when the transaction is actually about to be committed (COMPLETE, not yet COMMITTED) — an aborted transaction's write is a no-op regardless. Strengthens the existing auto-commit test (session-replay-repair-transaction .test.ts) to use a source plan with a real terminal close, and to parse the committed script and assert it ends with ['open', 'click', 'close'] with no bare @ref — not just sentinel/existence. Adds a session-store.ts unit regression exercising finalizeRepairTeardown directly for the same self-contained-artifact assertion.
…usive lock BLOCKER 1 (review follow-up): publishNoClobberAtomically's inspect/restore/ publish sequence was atomic per-step but not exclusive as a whole. Writer A could quarantine an existing COMPLETE target, and — before A restored it — writer B could linkSync its own COMPLETE artifact into the now-empty target and return success, only for A's restore (renameSync, which replaces an existing destination per POSIX) to silently stomp B's freshly published bytes. Wrap the whole decide-and-act sequence in an exclusive publish lock (acquireNoClobberLock/releaseNoClobberLock, an atomic linkSync claim over a PID-stamped lock file) so a competing writer for the same scriptPath cannot begin its own decision until the lock holder's sequence has finished and released it. A lock whose PID is provably dead is reclaimed immediately; a lock held by a live process is never stolen, only waited on with a bounded backoff before failing loudly. Adds a deterministic regression that forces the exact reported interleaving via a renameSync spy (mirroring the existing BLOCKER 1 test's technique) and confirms the pre-existing COMPLETE artifact is never clobbered. Also relaxes the older PARTIAL-race test's loser-message assertion, since a losing writer may now fail via lock contention instead of the no-clobber-specific message, depending on interleaving timing.
BLOCKER 3: buildRepairCloseFailureResponse preserves the session specifically so the agent can retry close/close --save-script, but reported details.retriable: false — machine-consistent recovery guidance requires retriable: true whenever the session was kept addressable for a retry. Extends the existing BLOCKER 2b/2c no-clobber-failure test with an assertion on this contract.
BLOCKER 2: commitRepairBeforeClose recorded a successful terminal `close` and published the healed artifact BEFORE dispatchTargetedPlatformClose ran. If the platform close then failed, the session was torn down and the committed .ad falsely contained a successful close — contradicting the existing failed-close lifecycle contract (a failed close is never recorded as Closed). For a repair-armed session, dispatch the targeted platform close first; only on success does the commit (record + publish) proceed. On failure, return without touching the session at all — same as the existing commit-failure contract, so the agent can fix the cause and retry. runSessionCloseTeardown gains a skipPlatformClose flag so the already-confirmed-successful close is never dispatched a second time during teardown. Adds a regression: a COMPLETE repair whose targeted platform close rejects must not commit a healed .ad, must not record a close action, and must keep the session addressable for retry; a subsequent successful retry then commits cleanly with exactly one terminal close.
fb44cf4 to
65d6679
Compare
|
Addressed the 1. No-clobber restore race — fixed with an exclusive lock. The prior grab/restore made each step atomic but not the sequence, so A could quarantine a COMPLETE target, B win the emptied slot, and A's 2. Successful 3. Checks: tsc / oxlint / oxfmt / fallow clean; full vitest green (only the documented Android runtime-hints / runner-client contention flakies, pass in isolation). I traced the lock interleaving and confirmed the close-ordering + double-dispatch guard myself. CI green. |
|
Blocking findings on
CI is green, but these keep the PR from readiness. |
…me-CAS Two waiters could both observe the same dead-PID publish lock and both decide to reclaim it. If one waiter's reclaim (remove + re-acquire with its own LIVE lock) completed inside the other's decision window, the first waiter's stale rmSync(lockPath) deleted the SECOND waiter's live lock by pathname (not the dead one it actually inspected), letting both enter the exclusive publish section at once. Reclaim is now a rename-based compare-and-swap: renameSync(lockPath, uniquePath) is the atomic claim (only one racer's rename of a given source ever succeeds; the loser gets ENOENT and retries). Only the winner inspects what it actually grabbed at the private claim path — if genuinely dead, discard it; if the claim raced with someone else's fresh reclaim and grabbed their live lock instead, restore it untouched and back off. The live holder's lock is never stolen. Regression drives the exact two-reclaimer interleaving deterministically via a readFileSync spy (writer B reclaims+re-acquires live, inside writer A's reclaim window) and confirms it fails against the prior rmSync-based reclaim.
…he wire top level buildRepairCloseFailureResponse hand-rolled its response shape instead of going through normalizeError, so it put retriable under error.details.retriable — a location neither the router's enrichDaemonError nor the client reads (both read the top-level DaemonError.retriable) — and silently dropped the underlying platform/ commit error's details, diagnosticId, and logPath entirely. Now routes through normalizeError like every other AppError -> DaemonResponse conversion in this codebase, preserving the underlying error's details/diagnosticId/logPath, with retriable forced true at the top level (the session is retained specifically for retry, which must never be contradicted by the underlying error's own classification). Also fixes a companion gap in finalizeDaemonResponse: it rebuilds every handler-RETURNED (non-thrown) failure response into a fresh AppError before re-normalizing, but only carried hint/diagnosticId/logPath through that reconstruction, not retriable/supportedOn — so even a handler setting them correctly at the top level still lost them at this step. Both are now carried through the same way, discovered only by verifying the close fix through the actual router boundary as requested. Regression: an updated handler-level test confirms diagnosticId/ logPath/details survive a repair-close failure, and a new router-level test (through createRequestHandler, not just the raw builder) confirms retriable:true and the platform error's diagnosticId/logPath/details all survive to the client. Both fail against the pre-fix code.
…form close When a repair-armed close's targeted platform close SUCCEEDED but the subsequent script commit FAILED (no-clobber refusal, a bare-@ref failure, or an fs error), the session was correctly retained for retry -- but nothing recorded that the platform close had already happened. A retry (close --save-script=<other>) dispatched dispatchTargetedPlatformClose again, so a non-idempotent backend could fail or wedge recovery on a second close of an already-closed target. SessionState now carries repairPlatformCloseSucceeded, set the moment the platform close returns success. A subsequent repair close consumes it and skips straight to the commit instead of re-dispatching; it is cleared once the transaction's outcome (commit or abort) is settled, so it never lingers past a single close attempt. Regression: platform close succeeds, commit fails (no-clobber), session is retained; a retry does not re-invoke dispatchTargetedPlatformClose (asserted via call count) and still commits cleanly to the retry path. Fails against the pre-fix code (dispatch called twice).
|
Blocking finding on The revised stale-lock reclaim still breaks exclusion under a three-writer interleaving. In The new regression covers preservation of B's live lock with only A and B; it does not introduce C during the rename/restore gap, so it misses the remaining race. The ownership primitive must never make a live holder's canonical claim disappear, and release must remove only the caller's own claim. The error-contract and already-succeeded platform-close findings are otherwise addressed. Readiness remains withheld. No fixer was dispatched. |
|
Additional independent-review blockers on
These are in addition to the live-lock rename gap in the preceding comment. No fixer was dispatched. |
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-scriptopens a multi-invocation repair TRANSACTION (ARMED → COMPLETE → COMMITTED). It is committed (the healed.adatomically published) on any teardown of the session — explicitclose, idle-reap, or daemon shutdown — but only when the transaction isCOMPLETE; a teardown while INCOMPLETE never publishes a prefix. Continuation keys off persisted transaction state, soreplay --fromcontinues without repeating--save-script. Everything below serves that model.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).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.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.linkSyncwith the temp file in the target's own directory (publishHealedScriptAtomically— same-filesystem rename, race-safe no-clobber even for a cross-mount explicit--save-script=<path>) and carries a# agent-device:heal-completesentinel;assertNoCompleteHealedClobberrefuses to overwrite only a complete prior artifact — a stale/partial file (no sentinel) is silently overwritable.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.repairSessionHeldNew 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; ordinary recording unaffected; never sentinel-tags ordinary recordings; overwrites a stale incomplete.healed.ad; refuses to clobber a complete one; atomic publish 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 greennpx tsc --noEmit— cleannpx oxlint --deny-warnings— cleannpx 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 filesNo live simulator was run (per instructions — a human will re-run the live prevent-remove heal separately). No device-lease semantics were touched beyond the session staying open (
hasOpenSessions) for the repair's lifetime.