Skip to content

Commit 30cd269

Browse files
committed
fix(replay): default-exclude observation-only reads from repair heals, add --record opt-in (#1271 stage 2)
Amends ADR 0012 decision 6: snapshot/get/is/a read-only find are excluded from a repair-armed heal by default (session.saveScriptBoundary set), never from ordinary open --save-script authoring recording. wait keeps recording (flow timing, not observation). The corrective-read trap (wave-3 E3: the diverged step was itself a get) means blanket read-exclusion is unsafe, so a new --record flag forces one action through when the correction is itself a read. --record/--no-record are mutually exclusive (INVALID_ARGS if both are set) and are plumbed identically across CLI, the Node client, and MCP. The exclusion lives at the single daemon-side choke point (recordActionEntry/isExcludedRepairSegmentObservation), so an excluded read never grows session.actions.length -- the same counter the existing record-and-heal resume watermark (describeUnperformedRecordAndHeal) already checks, so the empty-segment fail-loud guard falls out for free (message updated to mention --record). Also fixes a latent bug found along the way: the get/is/find/snapshot CLI readers never forwarded --no-record/--record into the built request (only `open` did), so stage 1's "use --no-record" guidance was silently inert via the CLI.
1 parent c245906 commit 30cd269

32 files changed

Lines changed: 1148 additions & 81 deletions

‎docs/adr/0012-interactive-replay.md‎

Lines changed: 109 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22

33
## Status
44

5-
Accepted (2026-07-10); partially implemented (last updated 2026-07-13). See [Migration progress](#migration-progress) for the per-step landing record.
5+
Accepted (2026-07-10); partially implemented (last updated 2026-07-16). See [Migration progress](#migration-progress) for the per-step landing record.
66

77
**Implemented and merged to `main`:**
88

@@ -12,6 +12,10 @@ Accepted (2026-07-10); partially implemented (last updated 2026-07-13). See [Mig
1212
`REPLAY_DIVERGENCE` repair-loop fix (#1223).
1313
- Decision 6, the base agent-supervised re-record repair — `replay --save-script` arming, the
1414
post-watermark healed slice, `repairHint`, and the writer's bare-`@ref` fail-loud guard (#1228).
15+
- Decision 6, R7 + commit state machine (repair-transaction lifecycle) — the ARMED → COMPLETE → COMMITTED
16+
commit state machine, keep-alive keyed off the persisted transaction (`resume.repairSessionHeld`, never
17+
the per-request `--save-script` flag), terminal-source-`close` skipping, the `REPAIR_SESSION_EXPIRED`
18+
tombstone, and race-safe atomic publication via exclusive `linkSync` (#1235).
1519
- Decision 4 amendment, `screen`'s capture scope and ref selection — the divergence `screen` capture runs
1620
through the same `captureSnapshot` wrapper as plain `snapshot` (full-window scope + Android freshness /
1721
post-action retry parity) under a clean, fixed capture-flags policy (a failed raw/scoped/`-d` action can
@@ -31,6 +35,9 @@ Accepted (2026-07-10); partially implemented (last updated 2026-07-13). See [Mig
3135
end-to-end; a container whose subtree holds a competing interactive node (canonical classification or
3236
hittable flag), or whose selected descendant's center falls outside the container rect, is recorded
3337
unchanged; `fill` is excluded (#1280).
38+
- Decision 6 amendment, repair-segment default exclusion of observation-only commands and the `--record`
39+
opt-in — stage 1 (interim guidance only, #1287) and stage 2 (the semantic default-exclusion fix,
40+
this amendment) of #1271. See the amendment under decision 6 below.
3441

3542
**Accepted but NOT yet implemented** (this amendment; tracked by #1235 — repair-transaction lifecycle):
3643
the R7 repair-transaction keep-alive and its distinct `resume.repairSessionHeld` signal, the ARMED →
@@ -976,6 +983,90 @@ re-run guidance to restart the repair from the original script), never a bare `S
976983
`replay --save-script` on the same key **clears the tombstone and starts a fresh transaction** (`ARMED`).
977984
The tombstone itself expires after its bounded window, after which the key is fully free.
978985

986+
> **Amendment (#1271 stage 2): repair-segment default exclusion of observation-only commands, and the
987+
> `--record` opt-in.** #1271 reported that read-only diagnostics an agent runs mid-repair to LOCATE the
988+
> repair target — `snapshot -i`, `get attrs`, `find`, `is` — recorded into the healed script exactly like
989+
> the corrective action itself. The wave-3 E3 experiment measured 0/4 trials producing a clean healed
990+
> script hands-off; one recorded `get attrs` caused a second, self-inflicted `identity-mismatch` divergence
991+
> on fresh replay. Stage 1 (#1287) shipped interim guidance only — the divergence `repairHint` and `help
992+
> workflow` told the agent to pass `--no-record` on those commands by hand. This amendment makes the safer
993+
> behavior the default, superseding stage 1's "opt out by hand" guidance with "opt in by hand" for the one
994+
> case that needs it.
995+
>
996+
> **1. Which read-only commands are excluded.** Exactly the enumerated set stage 1 already named:
997+
> `snapshot`, `get`, `is`, and a **read-only** `find` sub-action (`exists`, `wait`, `get_text`, `get_attrs`).
998+
> A **mutating** `find … click|fill|focus|type` is not in this set — it is a corrective action like `press`/
999+
> `fill` and always records. The top-level `wait` command is deliberately **not** in this set either: it is
1000+
> flow timing/synchronisation (pacing the script), not observation, so it must keep recording unconditionally
1001+
> even inside a repair segment — a healed script that silently dropped its own timing steps would replay
1002+
> differently from what actually happened.
1003+
>
1004+
> **2. The corrective-read trap, and why blanket exclusion by command class is unsafe.** "Exclude every
1005+
> read from a repair segment" is not a safe rule on its own: a divergence's **correction** can itself be a
1006+
> read. In the wave-3 E3 drift, the diverged step **was** a `get`, so its corrective replacement is a
1007+
> recorded `get` that **must** land in the heal — a blanket read-exclusion would silently drop the very
1008+
> step being repaired, producing an incomplete heal with no signal anything was missing (the same class of
1009+
> silent-failure mode decision 3 exists to close for target-binding, and decision 6 R4 exists to close for
1010+
> bare `@ref` exports). Distinguishing "the read that replaces the diverged step" from "reads used to locate
1011+
> the target" by POSITION (e.g., relative to the eventual `--from` resume) was considered and rejected: the
1012+
> daemon records each action as it happens and has no reliable way to know, at record time, which one the
1013+
> agent will later treat as the resume boundary — and a position-based rule would silently reinterpret
1014+
> intent after the fact rather than let the agent state it. The adopted rule instead makes this an **explicit
1015+
> opt-in per action**: `--record` (below) marks the ONE read that must count, independent of its position in
1016+
> the repair segment.
1017+
>
1018+
> **3. Explicit recording remains possible.** `--record` is a new, standalone flag — distinct from
1019+
> `saveScript`, and the mirror image of `--no-record` — that forces the action it is passed on into the heal
1020+
> even though its command is observation-only. It is a no-op outside a repair-armed session (there is no
1021+
> default exclusion to override) and a no-op on any non-observation-only command (those already always
1022+
> record). `--record` and `--no-record` express opposite intents for the same action and are **mutually
1023+
> exclusive**: passing both is rejected as `INVALID_ARGS` before the command runs, uniformly for every
1024+
> surface, rather than letting one silently win.
1025+
>
1026+
> **4. How the rule projects across CLI/Node/MCP surfaces.** The exclusion is enforced at a single
1027+
> daemon-side choke point — `isExcludedRepairSegmentObservation` in `recordActionEntry`
1028+
> (`src/daemon/session-action-recorder.ts`), gated on `session.saveScriptBoundary !== undefined` (set ONLY
1029+
> by a repair-armed `replay --save-script`; an ordinary, non-repair `open --save-script`/`close --save-script`
1030+
> authoring recording never sets it — see decision 6's "Scope" note above) and on the entry's
1031+
> `observationOnly` marker (set by the caller — `get`/`is`/a read-only `find` in
1032+
> `src/daemon/selector-recording.ts`, `snapshot` in `src/daemon/snapshot-runtime.ts` — never inferred from
1033+
> `command`/`positionals`, since a mutating `find … click` shares the literal string `command: 'find'` with
1034+
> its read-only sibling). Every surface reaches this same function because every surface's request reaches
1035+
> the same daemon: the CLI parses `--record`/`--no-record` into `CommandFlags` (`src/contracts/cli-flags.ts`,
1036+
> `src/commands/cli-grammar/flag-definitions-action.ts`, `src/commands/cli-grammar/flag-groups.ts`'s
1037+
> `COMMON_COMMAND_SUPPORTED_FLAG_KEYS` — so both flags are syntactically valid on every command, exactly like
1038+
> `--no-record` already was); the Node client threads the same-named option through
1039+
> `buildRequestFlags`/`buildFlags` (`src/commands/command-flags.ts`) from `InternalRequestOptions` and
1040+
> `AppOpenOptions` (`src/client/client-types.ts`) and from each typed per-command Options type
1041+
> (`GetOptions`/`IsOptions`/`FindOptions`/`CaptureSnapshotOptions`); and the MCP tool schema exposes `record`/
1042+
> `noRecord` fields on the `get`/`is`/`find`/`snapshot` tools (`src/commands/interaction/metadata.ts`,
1043+
> `src/commands/capture/snapshot.ts`) that flow through the same `CommandMetadata.inputSchema` +
1044+
> `readMetadataCommandFlags` path every other typed field already uses. `management/app.ts` mirrors both
1045+
> flags onto `open` for symmetry with `--no-record`'s existing presence there, though neither has an effect
1046+
> on a command that is never observation-only. No surface has a separate recording contract; all of them
1047+
> hit the identical daemon-side default.
1048+
>
1049+
> **5. The fail-loud guard is the EXISTING resume watermark, not new state.** Because the exclusion happens
1050+
> at the same point `--no-record` is enforced — before an action ever reaches `session.actions` — an excluded
1051+
> diagnostic read never grows `session.actions.length`. That counter is exactly what
1052+
> `describeUnperformedRecordAndHeal` (`src/daemon/handlers/session-replay-runtime-plan.ts`, R2/#1262's
1053+
> `pendingRecordAndHeal` watermark) already compares against `actionsCountAtDivergence` to prove a corrective
1054+
> action happened before authorizing a `record-and-heal`-shaped `--from` resume. No additional bookkeeping is
1055+
> needed: a repair segment containing only excluded diagnostic reads is indistinguishable, at that check,
1056+
> from a repair segment containing no activity at all, so the existing guard already refuses it. This
1057+
> amendment only updates the guard's message to name `--record` alongside the existing `--no-record`
1058+
> mention, since the missing corrective action may have been a read rather than a press: "no corrective
1059+
> action was recorded in this repair segment; press the correct control via a blessed `@ref` … — or, if your
1060+
> corrective action was a read, re-run it with `--record` … — before resuming."
1061+
>
1062+
> **Regression coverage** proves: a diagnostic read inside an armed repair segment is omitted from the
1063+
> healed script by default; a corrective action remains for both a mutating press/fill (unaffected — never
1064+
> observation-only) and a `--record`ed read (the diverged-step-was-a-`get` case); the resulting healed script
1065+
> replays cleanly in a fresh session; the empty-segment guard refuses a blind resume with the actionable
1066+
> `--record` hint; and ordinary, non-repair `open --save-script` authoring recording is completely unchanged
1067+
> — a read in a fresh authoring session still records with no flag needed, because `session.saveScriptBoundary`
1068+
> is never set there.
1069+
9791070
## Consequences
9801071

9811072
- `--from` makes app state the caller's responsibility, and only accepts a resume when the planner can
@@ -1073,8 +1164,8 @@ each states its dependencies explicitly.
10731164
daemon-side `repairHint` computation over all four `kind`s (R3), `--save-script` arming plus the
10741165
repair-run boundary watermark on `replay` (R1/R6), the writer's post-watermark slice and bare-`@ref`
10751166
fail-loud guard (R4/R6) — reusing `close --save-script`'s existing `session.actions` serializer.
1076-
9. **Repair-transaction lifecycle** (decision 6, R7 + commit state machine) — **NOT YET IMPLEMENTED,
1077-
tracked by #1235**; depends on 8. Adds: the distinct `resume.repairSessionHeld` divergence signal and
1167+
9. **Repair-transaction lifecycle** (decision 6, R7 + commit state machine) — **SHIPPED (#1235)**;
1168+
depends on 8. Adds: the distinct `resume.repairSessionHeld` divergence signal and
10781169
R7 keep-alive keyed off the session's **persisted** repair-transaction state (so `--from` continuations
10791170
need no repeat of `--save-script`), with `fail-fast-before-step-1` when keep-alive is impossible; the
10801171
`ARMED → COMPLETE → COMMITTED` commit state machine where **any teardown** (explicit `close`,
@@ -1087,10 +1178,19 @@ each states its dependencies explicitly.
10871178
pre-existing target — complete or partial, default or explicit path alike, and uniformly for an
10881179
ordinary non-repair recording's target too, since the publish primitive is shared — never overwriting
10891180
one; see "Scope" under Decision 6 above).
1181+
10. **Repair-segment default exclusion of observation-only commands, and the `--record` opt-in**
1182+
(decision 6 amendment) — **SHIPPED (#1271 stage 2)**; depends on 9 (`session.saveScriptBoundary`/
1183+
`repairSessionHeld` are the signals the exclusion and its guidance key off). Adds: default exclusion of
1184+
`snapshot`/`get`/`is`/a read-only `find` from `session.actions` while repair-armed
1185+
(`isExcludedRepairSegmentObservation`, `session-action-recorder.ts`); the `--record` opt-in, plumbed
1186+
across CLI/Node client/MCP identically to `--no-record`, and rejected together with it as
1187+
`INVALID_ARGS`; and an updated `describeUnperformedRecordAndHeal` message naming `--record` for the
1188+
corrective-read case. Stage 1 (#1287) shipped interim `--no-record` guidance only, gated on this same
1189+
`repairSessionHeld` signal.
10901190

10911191
## Migration progress
10921192

1093-
Landing record for the plan above (main as of 2026-07-13). This section tracks progress only; it does
1193+
Landing record for the plan above (main as of 2026-07-16). This section tracks progress only; it does
10941194
not restate or amend the decisions.
10951195

10961196
| Step | Decision | Status | Landed in |
@@ -1103,11 +1203,13 @@ not restate or amend the decisions.
11031203
| 6. `--update` retirement | 1 | Shipped | #1211 |
11041204
| 7. Benchmark extension | 5 | Deferred | — |
11051205
| 8. Agent-supervised re-record repair, base | 6 (R1-R6) | Shipped | #1228 |
1106-
| 9. Repair-transaction lifecycle | 6 (R7) | Not yet | #1235 |
1206+
| 9. Repair-transaction lifecycle | 6 (R7) | Shipped | #1235 |
1207+
| 10. Repair-segment default exclusion + `--record` | 6 amendment | Shipped | #1271 stage 2 |
11071208

11081209
Step 4 (#1209) added the `selector-miss`/`identity-mismatch`/`identity-unverifiable` divergence kinds
11091210
and a post-resolution target guard that cross-checks the dispatched winner against the verified member.
11101211
Issue #1221 ("Complete ADR 0012 replay target-binding verification") was closed as already implemented:
11111212
its verification scope is covered by step 4. Step 7 remains deferred. Step 8 implements the repair
1112-
design accepted in #1226 and shipped in #1228; step 9 (R7 + commit machine) remains unimplemented,
1113-
tracked by #1235.
1213+
design accepted in #1226 and shipped in #1228; step 9 (R7 + commit machine) shipped in #1235. Step 10
1214+
is #1271's two-stage arc: stage 1 (#1287) shipped interim `--no-record` guidance only; stage 2 (this
1215+
amendment) makes exclusion the default and adds the `--record` opt-in.

0 commit comments

Comments
 (0)