feat(replay): ADR 0012 decision 6 — agent-supervised re-record repair

Merged
TH
4mo ago
+1961-63

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

WorkstreamRule(s)Where
1. replay --save-script[=<out>] arm + watermarkR1, R6src/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 + wireR3, decision 4src/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 @refR4src/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 pathR6src/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 — the repairHint mapping: 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 (--from ordering, no prefix duplication), R6 (boundary is sticky across a redundant --save-script on a second --from leg — 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-record state-fix exclusion.
  • session-replay-repair-acceptance.test.ts — the mandatory acceptance test: full repair loop (diverge -> corrective press -> resume -> write) then replays the healed .ad end-to-end in a completely fresh SessionStore/state dir, asserting a self-contained open (relaunch + platform + metro flags travel with it) and no bare @ref anywhere.
  • 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), and output.test.ts (CLI) with repairHint presence 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/daemon sweep — 138 files / 1172 tests green.
  • Full --project unit-core run — 3787/3789 green; the 2 failures are in src/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.