Skip to content

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

Merged
thymikee merged 3 commits into
mainfrom
feat/replay-repair-heal-by-doing
Jul 13, 2026
Merged

feat(replay): ADR 0012 decision 6 — agent-supervised re-record repair#1228
thymikee merged 3 commits into
mainfrom
feat/replay-repair-heal-by-doing

Conversation

@thymikee

Copy link
Copy Markdown
Member

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.tsarmReplaySaveScript (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.tsassertNoUnresolvedRefFallback 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.tsbuildOptimizedActions 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.

Implements "heal-by-doing": `replay --save-script` arms recording from the
first attempt (R1), a boundary watermark scopes the healed `.ad` to the
repair run (R6), the daemon computes a total `repairHint` enum at divergence
time via genuine ancestor-containment over the daemon's own tree (R3), and
the writer fails loud on a bare `@ref` in a repair-armed session instead of
emitting an unreplayable script (R4).

- session-replay-runtime.ts: `armReplaySaveScript` arms `session.recordSession`
  + `saveScriptBoundary` before every step; `--save-script`'s absence leaves
  replay byte-identical to today.
- session-replay-repair-hint.ts (new): `computeReplayRepairHint` — a total
  mapping over the 4 divergence kinds, with two fail-safes to `manual`
  (no recorded evidence; sparse/unavailable capture). Wired into both
  divergence-minting sites (session-replay-divergence.ts,
  session-replay-target-verification.ts) and threaded through
  divergence.ts's bounding/minimal-fallback/text-report paths so it
  survives every response level and projection.
- session-script-writer.ts: `saveScriptBoundary` slices the healed script to
  the repair run; a bare unresolved `@ref` throws in a repair-armed session
  (ordinary open/close --save-script keeps its existing refLabel fallback).
- cli-help.ts: documents the repairHint-routed repair sub-flows and the
  close --save-script / <stem>.healed.ad promotion step.

Tests: repairHint mapping (all 4 kinds x container-present/absent x
fail-safes, with genuine-containment fixtures), R1 fresh evidence, R6 sticky
boundary across --from legs, --from ordering with no prefix duplication,
opt-in byte-identical behavior, no-record/thrown-failure exclusion, R4
fail-loud, and a full fresh-session replay acceptance test.
@github-actions

github-actions Bot commented Jul 12, 2026

Copy link
Copy Markdown

Size Report

Metric Base Current Diff
JS raw 1.7 MB 1.7 MB +5.2 kB
JS gzip 536.7 kB 538.5 kB +1.8 kB
npm tarball 646.3 kB 648.2 kB +1.9 kB
npm unpacked 2.3 MB 2.3 MB +5.6 kB

Startup median (7 runs, lower is better):

Scenario Base Current Diff
CLI --version 27.6 ms 27.5 ms -0.0 ms
CLI --help 57.7 ms 57.9 ms +0.2 ms

Top changed chunks:

Chunk Raw diff Gzip diff
dist/src/session.js +2.9 kB +840 B
dist/src/cli-help.js +951 B +381 B
dist/src/internal/daemon.js +728 B +269 B
dist/src/tv-remote.js +576 B +263 B
dist/src/registry.js +95 B +44 B

Addresses P0-P2 semantic holes + CI found in review of the repair loop:

P0 (session-replay-repair-hint.ts): the scrollRegion container test was a
near-universal false positive. An UNIDENTIFIED scrollRegion (RN's default
ScrollView/FlatList with no testID) matched "any anonymous scrollview exists,"
routing unrelated screens to record-and-heal (the exact mis-binding --update
retirement prevents). Now an identified region must ALSO satisfy the recorded
ancestry containment (AND); an unidentified/absent region falls back to
ancestry alone, walking the WHOLE recorded chain (not just ancestry[0]).

P1 (session-replay-runtime.ts): the boundary watermark was captured pre-loop
from the current session, but step-1 open --relaunch can REPLACE the session
with a fresh actions:[] one, amputating the healed open/prefix. Reworked into
a per-step armer that stamps the boundary on the session that actually
accumulates the run (firstArm captures prior actions; a later arm on an unset
boundary means open replaced the session → boundary 0). Added R2 preflight:
a fresh full replay --save-script on an already-armed session is rejected
(INVALID_ARGS) rather than silently duplicating the recorded prefix.

P1 (session-replay.ts): test now rejects --save-script (replay-only), matching
the sibling --from/--plan-digest guard, so buildNestedReplayFlags can't fan it
across a suite run.

P2 (session-script-writer.ts): the default <stem>.healed.ad is never silently
clobbered — a second repair against the same original fails loud unless an
explicit --save-script=<path> is given, so an unreviewed diff survives.

CI (session-replay-runtime.ts): extracted the per-step verify/dispatch/guard
body into resolveReplayStepResponse (runReplayScriptFile 205→163 lines) and the
arming into preflight/armer helpers; refactored the branchy test mock invokes
into a shared makeRecordingReplayInvoke fixture — fallow complexity is green.

New/updated tests: unidentified-scrollRegion false-positive (state-repair/
manual) + labeled positive + full-chain ancestry; R6 no-amputation (open
replaces) + preserved-session prior-action exclusion; R2 full-replay rejection;
test --save-script rejection; P2 clobber refusal + explicit-path overwrite.
@thymikee

Copy link
Copy Markdown
Member Author

Re-review at 9d8c5dd found three actionable blockers:

  1. R2 can be bypassed by omitting --save-script. preflightReplaySaveScript returns early when the new request lacks that flag, but the session remains repair-armed (recordSession + saveScriptBoundary). A plain full replay on the same session therefore appends and duplicates the prefix in the healed slice. Reject any full replay on an already repair-armed session, and add the adversarial regression.

  2. An explicit close-time output retains the default-path clobber marker. recordActionEntry replaces saveScriptPath for close --save-script=<path> but does not clear saveScriptDefaultedHealedPath, so the writer can incorrectly refuse an explicitly directed overwrite. Clear the marker and cover this route.

  3. Root-level targets can produce an unsafe record-and-heal hint. Empty ancestry/no scroll region currently treats any non-empty capture as proof that the recorded container is present. An unrelated screen can therefore be classified as healable. With no structural container signal, fall back to manual (or another demonstrably safe classification) and update the test that currently asserts the unsafe behavior.

The focused PR suite is green (161 tests), but independent adversarial regressions for all three cases fail on this head. No fixer was dispatched per coordinator policy.

B1 (session-replay-runtime.ts): R2 was bypassable by OMITTING --save-script —
the session stays repair-armed (recordSession + saveScriptBoundary), so a
plain full replay re-appended the prefix. preflightReplayAgainstActiveRepair
now fires for ANY full replay (entryIndex 0) on an armed session, regardless
of the flag; --from resumes stay allowed.

B2 (session-replay-runtime.ts + session-action-recorder.ts): an explicit
--save-script=<path> at BOTH the replay-arming and close (recordActionEntry)
sites now clears saveScriptDefaultedHealedPath, so the clobber guard no longer
wrongly refuses a caller-directed overwrite. Invariant: marker set iff the
current saveScriptPath was defaulted.

B3 (session-replay-repair-hint.ts): a root-level target with NO usable
structural container signal (empty ancestry AND no identified scrollRegion)
was reading as "present" on any non-empty screen -> record-and-heal on an
unrelated screen. computeReplayRepairHint now fails such no-signal cases to
manual (same class as the unidentified-region P0); empty ancestry + IDENTIFIED
region still uses the region test.

Regressions: plain full replay on an armed session -> INVALID_ARGS + zero
dispatches + no prefix duplication; close --save-script=<explicit existing
path> overwrites (marker cleared) while a defaulted path still fails loud;
no-ancestry/no-region on an unrelated screen -> manual (selector-miss and
action-failure) with the identified-region positive kept.
@thymikee

Copy link
Copy Markdown
Member Author

Re-review at 30f21f3 found no remaining source blockers. All three reproduced defects are fixed on the production route, and focused exact-head validation passed 173 replay/repair/projection tests.

Readiness is still withheld pending practical evidence through the shipped CLI path. The acceptance test uses a fresh session store but mocks device dispatch, so it does not prove the real command -> daemon -> platform recording/resume flow. Please run one simulator/device repair loop on this head that demonstrates: divergence, a recorded corrective selector action, --from resume, close --save-script, and successful replay of the emitted healed script in a fresh session/state dir. Include the healed artifact path and confirm cleanup.

No fixer was dispatched. External Claude review was not run because export was not separately authorized.

@thymikee thymikee merged commit ddae892 into main Jul 13, 2026
22 checks passed
@thymikee thymikee deleted the feat/replay-repair-heal-by-doing branch July 13, 2026 07:05
@github-actions

Copy link
Copy Markdown
PR Preview Action v1.8.1
Preview removed because the pull request was closed.
2026-07-13 07:05 UTC

thymikee added a commit that referenced this pull request Jul 13, 2026
- 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.
thymikee added a commit that referenced this pull request Jul 13, 2026
- 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.
thymikee added a commit that referenced this pull request Jul 13, 2026
… 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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant