Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 24 additions & 0 deletions docs/adr/0012-interactive-replay.md
Original file line number Diff line number Diff line change
Expand Up @@ -476,6 +476,30 @@ repeat iteration label. Static includes, platform conditions, and fixed-count re
indexing, so repeated source lines are distinguished by their plan index.

Every divergence includes `resume: { allowed, from, reason?, planDigest, repairSessionHeld? }`.
`from` is not merely the failed step's ordinal — it is the ordinal the caller should actually pass to
`--from`, computed from the same `repairHint` carried alongside it (decision 6, R2/R3): for `record-and-heal`,
`from` is the failed step's index **+ 1** (the agent performs that step manually before resuming, so
resuming AT it would re-diverge on the exact step just completed); for every other hint (`state-repair`,
`caution`, `manual`), `from` equals the failed step's index unchanged. This keeps the structured `resume`
block and the `repairHint` text guidance below in agreement — a JSON/MCP-first caller that blindly resumes
at `resume.from` gets the same continuation a text caller reads out of the rendered guidance, never a stale
`from` that loops the caller back onto the step it just repaired.

If the shifted `from` equals `actions.length + 1` (the diverged step was the plan's LAST step), that is a
legal EMPTY-TAIL resume, not an error: there is nothing left to replay, so the resumed run executes zero
device actions and falls straight through to the normal end-of-plan completion path, correctly flipping an
armed repair transaction COMPLETE (decision 6, R7's `close` commit gate). Rejecting this ordinal outright
would force the agent to `close` an INCOMPLETE transaction instead, which aborts and discards the corrective
action it just recorded. This one-past-the-end ordinal is authorized ONLY for the EXACT session and target
that produced it — the daemon stamps a per-session watermark (`expectedFrom`, the recorded action count at
divergence time) whenever a `record-and-heal` divergence reports `allowed: true`, and a later `--from`
request is accepted at `actions.length + 1` only when it matches that watermark AND the session's action
count has grown since (proof the corrective press was actually recorded) — never a blanket "one past the
end is fine" for any session or repair kind, which would let an unrelated or blind resume silently skip the
plan's unresolved final step and commit an incomplete repair. The same watermark match, independent of
whether `from` lands one past the end or still inside the plan, also gates every OTHER `record-and-heal`
continuation: resuming at the reported `from` with the action count unchanged is rejected as proof the
corrective press never happened, rather than silently resuming past the unrepaired step.
`planDigest` is SHA-256 over
the canonical fully expanded plan, including each action's command, normalized inputs, control shape,
platform-conditioned expansion, and source provenance. Concretely "normalized inputs" bind each action's
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,10 @@ function throwSelectorMiss(selector: string): never {
});
}

function assertDivergenceShape(response: Awaited<ReturnType<typeof runReplayScriptFile>>): {
function assertDivergenceShape(
response: Awaited<ReturnType<typeof runReplayScriptFile>>,
expectedResume: { allowed: boolean; from: number } = { allowed: true, from: 1 },
): {
divergence: Record<string, unknown>;
targetBinding: Record<string, unknown> | undefined;
} {
Expand All @@ -78,8 +81,8 @@ function assertDivergenceShape(response: Awaited<ReturnType<typeof runReplayScri
expect(typeof divergence.kind).toBe('string');

const resume = divergence.resume as { allowed: boolean; from?: number; planDigest?: string };
expect(resume.allowed).toBe(true);
expect(resume.from).toBe(1);
expect(resume.allowed).toBe(expectedResume.allowed);
expect(resume.from).toBe(expectedResume.from);
expect(typeof resume.planDigest).toBe('string');
expect((resume.planDigest ?? '').length).toBeGreaterThan(0);

Expand Down Expand Up @@ -128,11 +131,20 @@ test('(a) an ANNOTATED press whose dispatch throws a selector-miss yields REPLAY
});

expect(invoked).toEqual(['click']);
const { divergence } = assertDivergenceShape(response);
// The recorded evidence carries a real, non-empty ancestry (a nested tab
// button) and the post-response capture still contains that same
// container, so this action-failure divergence routes to `record-and-heal`
// (ADR 0012 decision 6, R3's container-presence test) — not the `manual`
// default. `resume.from` must therefore target `failedIndex + 1` (decision
// 6, R2): since this is the plan's only (and last) step, that is `from: 2`
// = actions.length + 1 — a legal EMPTY-TAIL resume (nothing left to run
// after the agent performs the corrective press), not an error.
const { divergence } = assertDivergenceShape(response, { allowed: true, from: 2 });
// A thrown dispatch failure is a generic action-failure divergence — it is
// NOT re-derived as a target-binding classification (that only happens
// when verification's OWN pre-action check finds the mismatch).
expect(divergence.kind).toBe('action-failure');
expect(divergence.repairHint).toBe('record-and-heal');
const cause = divergence.cause as { code: string; message: string };
expect(cause.code).toBe('COMMAND_FAILED');
expect(cause.message).toContain('No element matched selector');
Expand Down
Loading
Loading