feat(replay): ADR 0012 decision 6 — agent-supervised re-record repair
Summary
Implements ADR-0012 Decision 6 ("heal-by-doing"): the agent-supervised re-record repair loop for replay divergences. All net-new implementation per the ADR's migration step 8 scope; no new splice engine — the healed script IS session.actions, reusing the close --save-script writer.
Workstream -> Rule -> Implementation map
| Workstream | Rule(s) | Where |
|---|---|---|
1. replay --save-script[=<out>] arm + watermark | R1, R6 | src/daemon/handlers/session-replay-runtime.ts — armReplaySaveScript (called at the top of every loop iteration; no-op until step 1 creates the session for a fresh one) sets session.recordSession = true and session.saveScriptBoundary once. Default <out> (healedScriptSiblingPath) is <original-stem>.healed.ad, an explicit <out> always wins. Absent --save-script, replay is unchanged (opt-in). |
2. repairHint computation + wire | R3, decision 4 | src/daemon/handlers/session-replay-repair-hint.ts (new) — computeReplayRepairHint, a total mapping over the 4 divergence kinds with two fail-safes to manual. The container-presence test is genuine ancestor containment walked over parentIndex (buildAncestryChain/computeScrollRegionKey, reusing decision 3's own tree-walking machinery in session-target-evidence.ts) against the daemon's full capture — never the capped screen.refs wire projection. Wired into both divergence-minting sites (session-replay-divergence.ts for action-failure's post-response capture, session-replay-target-verification.ts for the 3 target-binding kinds' pre-action capture) and threaded through src/replay/divergence.ts's type, minimal-fallback, and text-report renderer so it survives every response level (including --level digest) and all 4 projections (daemon text, JSON, Node client AppError, MCP structuredContent). |
3. Writer fails loud on a bare @ref | R4 | src/daemon/session-script-writer.ts — assertNoUnresolvedRefFallback throws AppError when a selector-targeting action's ref never resolved to a selectorChain; write()'s catch rethrows AppError instead of swallowing it. Scoped to a repair-armed session (saveScriptBoundary !== undefined) — R4's own text frames it as "corrective actions must materialize to selector form" and explicitly preserves "existing session-write paths swallow such failures" for ordinary open/close --save-script, which keeps its existing refLabel/scoped-snapshot fallback unchanged. |
| 4. Healed emit = post-watermark slice -> sibling path | R6 | src/daemon/session-script-writer.ts — buildOptimizedActions slices session.actions from saveScriptBoundary ?? 0; the default output path is computed at arm time in workstream 1. |
| 5. Help + tests | — | src/cli/parser/cli-help.ts — one added sentence documenting the two repairHint-routed sub-flows and the close --save-script / <stem>.healed.ad promotion step, appended to the existing "Replay divergence and resume" paragraph. |
Tests
session-replay-repair-hint.test.ts— therepairHintmapping: all 4 kinds x container-present/absent x both fail-safes (no evidence, unavailable capture), including fixtures that specifically distinguish genuine ancestor containment from a flat identity-string match (a decoy node sharing the container's role/label but with zero children is correctly "absent").session-replay-repair-loop.test.ts— R1 (prefix steps get fresh evidence, never the.ad's old annotation), R2 (--fromordering, no prefix duplication), R6 (boundary is sticky across a redundant--save-scripton a second--fromleg — mutation-tested to confirm the assertion actually depends on the guard), opt-in (no--save-script-> no arming/evidence), a thrown dispatch failure never records a partial action, and--no-recordstate-fix exclusion.session-replay-repair-acceptance.test.ts— the mandatory acceptance test: full repair loop (diverge -> corrective press -> resume -> write) then replays the healed.adend-to-end in a completely freshSessionStore/state dir, asserting a self-containedopen(relaunch + platform + metro flags travel with it) and no bare@refanywhere.session-script-writer.test.ts(new) — boundary slicing, snapshot stripping post-slice, R4 fail-loud (resolved ref -> clean line; unresolved -> throws, nothing written), and confirms an ordinary (non-repair-armed) recording keeps its existing bare-ref fallback unchanged.- Extended existing
divergence.test.ts,command-tools.test.ts(MCP), andoutput.test.ts(CLI) withrepairHintpresence assertions across the minimal-overflow fallback and the text/structuredContent projections.
Verification
- Full suite for changed areas:
npx vitest run src/replay src/daemon src/commands/replay src/mcp src/utils src/cli src/client— all green. - Broad
npx vitest run src/daemonsweep — 138 files / 1172 tests green. - Full
--project unit-corerun — 3787/3789 green; the 2 failures are insrc/platforms/apple/core/__tests__/index.test.ts(unrelated iOS IPA install tests, untouched by this diff) and pass in isolation — pre-existing CPU-contention flakiness, not a regression. npx tsc --noEmit— clean.oxlint . --deny-warnings— clean.oxfmt --check— clean.
Notes
No ADR-vs-code conflicts found. One real design nuance surfaced and resolved: a literal, unscoped reading of R4 would have broken the existing refLabel/scoped-snapshot fallback for ordinary (non-repair) --save-script recordings — 5 pre-existing tests in session-store.test.ts exercise exactly that fallback. R4's own text ("corrective actions," "existing session-write paths swallow such failures... this one must not") scopes the fail-loud guard to repair-armed sessions, so I gated on saveScriptBoundary !== undefined rather than changing SessionScriptWriter's general behavior — all 5 pre-existing tests pass unmodified.