Skip to content

Commit ead2022

Browse files
committed
fix: reconcile maestro replay with latest main
1 parent 8f7a70b commit ead2022

9 files changed

Lines changed: 41 additions & 186 deletions

src/daemon/handlers/__tests__/session-replay-resume.test.ts

Lines changed: 5 additions & 97 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,6 @@ import assert from 'node:assert/strict';
22
import { test } from 'vitest';
33
import {
44
buildReplayDivergenceResume,
5-
evaluateReplayResumePreflight,
65
stampPendingRecordAndHealWatermark,
76
} from '../session-replay-resume.ts';
87
import type { SessionAction, SessionState } from '../../types.ts';
@@ -12,29 +11,6 @@ function action(overrides: Partial<SessionAction> = {}): SessionAction {
1211
return { ts: 0, command: 'click', positionals: ['label="Save"'], flags: {}, ...overrides };
1312
}
1413

15-
test('generic .ad replay allows resuming from the first action', () => {
16-
const actions: SessionAction[] = [action({ command: 'back', positionals: [] }), action()];
17-
assert.deepEqual(evaluateReplayResumePreflight({ from: 1, actions }), { allowed: true });
18-
});
19-
20-
test('generic .ad replay allows resuming after earlier actions', () => {
21-
const actions: SessionAction[] = [
22-
action({ command: 'open', positionals: ['Demo'] }),
23-
action({ command: 'click', positionals: ['label="Continue"'] }),
24-
action({ command: 'click', positionals: ['label="Save"'] }),
25-
];
26-
assert.deepEqual(evaluateReplayResumePreflight({ from: 3, actions }), { allowed: true });
27-
});
28-
29-
test('repeated generic .ad action lines occupy distinct plan indices', () => {
30-
const actions: SessionAction[] = [
31-
action({ command: 'click', positionals: ['label="Item"'] }),
32-
action({ command: 'click', positionals: ['label="Item"'] }),
33-
action({ command: 'click', positionals: ['label="Item"'] }),
34-
];
35-
assert.deepEqual(evaluateReplayResumePreflight({ from: 3, actions }), { allowed: true });
36-
});
37-
3814
test('buildReplayDivergenceResume reports a resumable generic .ad failure', () => {
3915
const actions: SessionAction[] = [
4016
action({ command: 'open', positionals: ['Demo'] }),
@@ -91,13 +67,11 @@ test('buildReplayDivergenceResume with repairHint record-and-heal on the LAST pl
9167
});
9268
// --- buildReplayDivergenceResume: `resume.alternateFrom` (#1262). The
9369
// `caution`/`manual` dual-path's SECOND ordinal (`failedIndex + 1`), present
94-
// ONLY when a `--from failedIndex + 1` request would actually be accepted —
95-
// i.e. `evaluateReplayResumePreflight({ from: failedIndex + 1 })` passes,
96-
// which additionally requires the DIVERGED step itself to be skip-safe. This
97-
// closes the parity bug where the text renderer offered `--from N + 1` based
98-
// on `N`'s preflight (which does not check step `N`'s own skip-safety). ---
70+
// ONLY when a `--from failedIndex + 1` request would actually be accepted.
71+
// Generic `.ad` plans contain no runtime variable producers or control
72+
// wrappers, so only the empty-tail session requirement can block it. ---
9973

100-
test('buildReplayDivergenceResume: caution mid-plan (skip-safe diverged step) carries alternateFrom = failedIndex + 1', () => {
74+
test('buildReplayDivergenceResume: caution mid-plan carries alternateFrom = failedIndex + 1', () => {
10175
const actions: SessionAction[] = [
10276
action({ command: 'open' }),
10377
action({ command: 'click' }),
@@ -116,7 +90,7 @@ test('buildReplayDivergenceResume: caution mid-plan (skip-safe diverged step) ca
11690
assert.equal(resume.alternateFrom, 3);
11791
});
11892

119-
test('buildReplayDivergenceResume: manual last-step (skip-safe diverged step) carries alternateFrom = actions.length + 1', () => {
93+
test('buildReplayDivergenceResume: manual last-step carries alternateFrom = actions.length + 1', () => {
12094
const actions: SessionAction[] = [action({ command: 'open' }), action({ command: 'click' })];
12195
const resume = buildReplayDivergenceResume({
12296
failedIndex: 2,
@@ -131,44 +105,6 @@ test('buildReplayDivergenceResume: manual last-step (skip-safe diverged step) ca
131105
assert.equal(resume.alternateFrom, 3); // empty-tail ordinal
132106
});
133107

134-
test('buildReplayDivergenceResume: caution whose diverged step is a runScript carries NO alternateFrom (mid-plan AND last-step)', () => {
135-
// The `N + 1` alternate would skip the runScript (outputEnv producer) at
136-
// step N, which `evaluateReplayResumePreflight({ from: N + 1 })` refuses —
137-
// so alternateFrom is absent even though resuming AT N stays allowed.
138-
const midPlan: SessionAction[] = [
139-
action({ command: 'open' }),
140-
action({ command: '__maestroRunScript', positionals: ['./setup.js'] }),
141-
action({ command: 'click' }),
142-
];
143-
const midPlanResume = buildReplayDivergenceResume({
144-
failedIndex: 2,
145-
actions: midPlan,
146-
planDigest: 'abc123',
147-
repairHint: 'caution',
148-
sessionExists: true,
149-
});
150-
assert.equal(midPlanResume.allowed, true); // resuming AT 2 is fine
151-
assert.equal(midPlanResume.from, 2);
152-
if (!midPlanResume.allowed) return;
153-
assert.equal(midPlanResume.alternateFrom, undefined);
154-
155-
const lastStep: SessionAction[] = [
156-
action({ command: 'open' }),
157-
action({ command: '__maestroRunScript', positionals: ['./setup.js'] }),
158-
];
159-
const lastStepResume = buildReplayDivergenceResume({
160-
failedIndex: 2,
161-
actions: lastStep,
162-
planDigest: 'abc123',
163-
repairHint: 'caution',
164-
sessionExists: true,
165-
});
166-
assert.equal(lastStepResume.allowed, true);
167-
assert.equal(lastStepResume.from, 2);
168-
if (!lastStepResume.allowed) return;
169-
assert.equal(lastStepResume.alternateFrom, undefined);
170-
});
171-
172108
test('buildReplayDivergenceResume: record-and-heal and state-repair never carry alternateFrom (no separate recorded-action alternate)', () => {
173109
const actions: SessionAction[] = [
174110
action({ command: 'open' }),
@@ -381,34 +317,6 @@ test('stampPendingRecordAndHealWatermark does NOT stamp for a MID-PLAN caution/m
381317
}
382318
});
383319

384-
test('stampPendingRecordAndHealWatermark does not stamp for a LAST-step caution/manual divergence when the N + 1 target is not itself preflight-safe', () => {
385-
// failedIndex (3) IS the last step, but skipping it (to reach N + 1 = 4)
386-
// requires also skipping step 2 (an outputEnv-producing maestro runScript),
387-
// which cannot be proven safe — even though `resume.from` (unshifted, at
388-
// N = 3 — resuming AT it, not skipping it) is independently fine.
389-
const actions: SessionAction[] = [
390-
action({ command: 'open' }),
391-
action({ command: '__maestroRunScript', positionals: ['./setup.js'] }),
392-
action({ command: 'click' }),
393-
];
394-
const resume = buildReplayDivergenceResume({
395-
failedIndex: 3,
396-
actions,
397-
planDigest: 'abc123',
398-
repairHint: 'caution',
399-
sessionExists: true,
400-
});
401-
const session = sessionWithActions(2);
402-
stampPendingRecordAndHealWatermark({
403-
session,
404-
resume,
405-
repairHint: 'caution',
406-
failedIndex: 3,
407-
actions,
408-
});
409-
assert.equal(session.pendingRecordAndHeal, undefined);
410-
});
411-
412320
test('stampPendingRecordAndHealWatermark never stamps for state-repair (not in the extended eligible set)', () => {
413321
const actions: SessionAction[] = [action({ command: 'open' }), action({ command: 'click' })];
414322
const resume = buildReplayDivergenceResume({

src/daemon/handlers/interaction-runtime.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,7 @@ function createInteractionBackend(
7777
(await dispatchGestureViewport(
7878
session.device,
7979
params.contextFromFlags(req.flags, session.appBundleId, session.trace?.outPath),
80-
),
80+
)),
8181
tap: async (_context, point): Promise<BackendActionResult> => {
8282
// ADR 0014 side-effect seam: the point is resolved; expire the ref frame
8383
// synchronously before dispatching so a later step cannot reuse it.

src/daemon/handlers/session-replay-divergence.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -289,7 +289,7 @@ export async function captureDivergenceObservation(params: {
289289
* produce a narrowed divergence `screen`. Only the interactive-only policy is
290290
* carried, since it governs whether static-text suggestion targets survive.
291291
*/
292-
function divergenceCaptureFlags(action: SessionAction): CommandFlags {
292+
function divergenceCaptureFlags(action: ReplayReportAction): CommandFlags {
293293
return { snapshotInteractiveOnly: divergenceCaptureInteractiveOnly(action) };
294294
}
295295

src/daemon/handlers/session-replay-resume.ts

Lines changed: 14 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,17 +1,6 @@
11
import type { SessionAction, SessionState } from '../types.ts';
22
import type { ReplayDivergenceResume, ReplayRepairHint } from '../../replay/divergence.ts';
33

4-
/** Generic `.ad` actions have no runtime variable producers or control wrappers. */
5-
export type ReplayResumePreflightResult = { allowed: true } | { allowed: false; reason: string };
6-
7-
export function evaluateReplayResumePreflight(params: {
8-
from: number;
9-
actions: SessionAction[];
10-
}): ReplayResumePreflightResult {
11-
void params;
12-
return { allowed: true };
13-
}
14-
154
/**
165
* Builds the `resume` object attached to every divergence report. `from` is
176
* the ordinal the agent should actually pass to `--from`, not merely the
@@ -28,12 +17,10 @@ export function evaluateReplayResumePreflight(params: {
2817
* sites resolve it from the plan they are actively executing), so the shifted
2918
* `from` is at most `actions.length + 1` — never further out of range. That
3019
* boundary case (`record-and-heal` diverged on the plan's LAST step) is a
31-
* legal EMPTY-TAIL resume, not an error: `evaluateReplayResumePreflight`
32-
* already proves it safe (it only checks whether the SKIPPED range 1..from-1
33-
* is safe to skip; there is no `from`-th step to reject), and the runtime
34-
* loop (`runReplayScriptFile`) executes zero steps and reaches the normal
20+
* legal EMPTY-TAIL resume, not an error: the runtime loop
21+
* (`runReplayScriptFile`) executes zero steps and reaches the normal
3522
* end-of-plan completion path, correctly flipping a repair transaction
36-
* COMPLETE. Rejecting it here would send the agent to `close` instead —
23+
* COMPLETE. Rejecting it would send the agent to `close` instead —
3724
* which discards the just-recorded corrective action, since commit is gated
3825
* on that same COMPLETE flag.
3926
*
@@ -53,8 +40,6 @@ export function buildReplayDivergenceResume(params: {
5340
}): ReplayDivergenceResume {
5441
const { failedIndex, actions, planDigest, repairHint, sessionExists } = params;
5542
const from = repairHint === 'record-and-heal' ? failedIndex + 1 : failedIndex;
56-
const preflight = evaluateReplayResumePreflight({ from, actions });
57-
if (!preflight.allowed) return { allowed: false, from, planDigest, reason: preflight.reason };
5843
const alternateFrom = computeReplayResumeAlternateFrom({
5944
failedIndex,
6045
actions,
@@ -71,18 +56,13 @@ export function buildReplayDivergenceResume(params: {
7156

7257
/**
7358
* ADR 0012 decision 4 / #1262: the `caution`/`manual` dual-path's SECOND
74-
* ordinal, the record-and-heal-shaped alternate (`failedIndex + 1`). Returned
75-
* ONLY when a `--from failedIndex + 1` request for this divergence would
76-
* actually be accepted by the daemon — i.e. exactly its own resume preflight
77-
* passes (`evaluateReplayResumePreflight`, which unlike `from`'s own preflight
78-
* ALSO requires the DIVERGED step to be skip-safe, since reaching
79-
* `failedIndex + 1` skips the diverged step). Its checked range is a strict
80-
* superset of `from`'s, so `alternateFrom` present implies `resume.allowed` —
81-
* never a contradiction.
59+
* ordinal, the record-and-heal-shaped alternate (`failedIndex + 1`). Generic
60+
* `.ad` plans contain neither runtime variable producers nor control wrappers,
61+
* so every in-range ordinal is resumable.
8262
*
8363
* The alternate has two acceptance regimes by position:
8464
* - MID-PLAN (`failedIndex + 1 <= actions.length`): in range, needs no
85-
* watermark session-independent, gated on the preflight alone.
65+
* watermark and is session-independent.
8666
* - LAST STEP / EMPTY-TAIL (`failedIndex + 1 > actions.length`, one past the
8767
* plan's end): the range check accepts this ordinal ONLY when it matches a
8868
* stamped `pendingRecordAndHeal` watermark (`describeOutOfRangeResumeFrom`),
@@ -95,10 +75,8 @@ export function buildReplayDivergenceResume(params: {
9575
* additionally requires `sessionExists`.
9676
*
9777
* Absent for `record-and-heal` (its `from` already IS `failedIndex + 1`) and
98-
* `state-repair` (no recorded-action alternate); absent when the diverged step
99-
* is a `runScript` (outputEnv producer) or inside runtime control flow, so the
100-
* alternate `--from failedIndex + 1` would be refused. The text renderer gates
101-
* the `N + 1` command on this field's PRESENCE rather than re-deriving
78+
* `state-repair` (no recorded-action alternate). The text renderer gates the
79+
* `N + 1` command on this field's presence rather than re-deriving
10280
* resumability, keeping text and the structured wire in agreement.
10381
*/
10482
function computeReplayResumeAlternateFrom(params: {
@@ -110,7 +88,6 @@ function computeReplayResumeAlternateFrom(params: {
11088
const { failedIndex, actions, repairHint, sessionExists } = params;
11189
if (repairHint !== 'caution' && repairHint !== 'manual') return undefined;
11290
const alternateFrom = failedIndex + 1;
113-
if (!evaluateReplayResumePreflight({ from: alternateFrom, actions }).allowed) return undefined;
11491
// Empty-tail: authorizable only via a watermark, which needs a live session.
11592
if (alternateFrom > actions.length && !sessionExists) return undefined;
11693
return alternateFrom;
@@ -138,10 +115,8 @@ function computeReplayResumeAlternateFrom(params: {
138115
* the plan's LAST step, `N + 1` is one past the plan's end — previously
139116
* ALWAYS out of range for `caution`/`manual` (dead end: `close` on the
140117
* not-yet-COMPLETE transaction discards a just-recorded corrective action).
141-
* Only THAT boundary ordinal is newly authorized here, and only when it is
142-
* independently preflight-safe (`evaluateReplayResumePreflight` on `N + 1`,
143-
* NOT the unrelated preflight verdict `resume.allowed` already carries for
144-
* `N` — a different ordinal). Authorizing it stamps the SAME watermark
118+
* Only THAT boundary ordinal is newly authorized here. Authorizing it stamps
119+
* the SAME watermark
145120
* `record-and-heal` uses, which — as an unavoidable side effect of sharing
146121
* the mechanism — also puts `describeUnperformedRecordAndHeal`'s
147122
* recorded-corrective-action guard in front of that specific `N + 1`
@@ -150,9 +125,8 @@ function computeReplayResumeAlternateFrom(params: {
150125
*
151126
* Called at every divergence site (not only the eligible hints/positions) so
152127
* a stale watermark from an earlier divergence never survives an unrelated
153-
* later one: an ineligible hint, a mid-plan `caution`/`manual` divergence, or
154-
* a last-step `caution`/`manual` divergence whose `N + 1` target is not
155-
* itself preflight-safe, all clear the field.
128+
* later one: an ineligible hint or a mid-plan `caution`/`manual` divergence
129+
* clears the field.
156130
*/
157131
export function stampPendingRecordAndHealWatermark(params: {
158132
session: SessionState;
@@ -187,6 +161,5 @@ function computeRecordAndHealWatermark(params: {
187161
// see the function doc comment for why mid-plan `N + 1` stays un-gated.
188162
if (failedIndex !== actions.length) return undefined;
189163
const expectedFrom = failedIndex + 1;
190-
const authorized = evaluateReplayResumePreflight({ from: expectedFrom, actions }).allowed;
191-
return authorized ? { expectedFrom, actionsCountAtDivergence } : undefined;
164+
return { expectedFrom, actionsCountAtDivergence };
192165
}

src/daemon/handlers/session-replay-runtime-plan.ts

Lines changed: 3 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import type { CommandFlags } from '../../core/dispatch.ts';
22
import type { ReplayPlanDigestMetadata } from '../../replay/plan-digest.ts';
33
import type { ReplayScriptMetadata } from '../../replay/script.ts';
4-
import type { DaemonResponse, SessionAction } from '../types.ts';
5-
import { evaluateReplayResumePreflight } from './session-replay-resume.ts';
4+
import type { DaemonResponse } from '../types.ts';
65
import { errorResponse } from './response.ts';
76

87
export function buildReplayMetadataFlags(
@@ -64,7 +63,6 @@ export function resolveReplayEntryIndex(
6463
flags: CommandFlags | undefined,
6564
actionCount: number,
6665
planDigest: string,
67-
actions: SessionAction[],
6866
pendingRecordAndHeal: PendingRecordAndHeal | undefined,
6967
sessionActionsLength: number,
7068
): ReplayEntryIndexResult {
@@ -81,7 +79,6 @@ export function resolveReplayEntryIndex(
8179
digest,
8280
planDigest,
8381
actionCount,
84-
actions,
8582
pendingRecordAndHeal,
8683
sessionActionsLength,
8784
});
@@ -100,24 +97,15 @@ function validateReplayResumeRequest(params: {
10097
digest: string;
10198
planDigest: string;
10299
actionCount: number;
103-
actions: SessionAction[];
104100
pendingRecordAndHeal: PendingRecordAndHeal | undefined;
105101
sessionActionsLength: number;
106102
}): string | undefined {
107-
const {
108-
from,
109-
digest,
110-
planDigest,
111-
actionCount,
112-
actions,
113-
pendingRecordAndHeal,
114-
sessionActionsLength,
115-
} = params;
103+
const { from, digest, planDigest, actionCount, pendingRecordAndHeal, sessionActionsLength } =
104+
params;
116105
const checks: ReplayResumeCheck[] = [
117106
() => describeOutOfRangeResumeFrom({ from, actionCount, pendingRecordAndHeal }),
118107
() => describeUnperformedRecordAndHeal({ from, pendingRecordAndHeal, sessionActionsLength }),
119108
() => describeStaleResumeDigest(digest, planDigest),
120-
() => describeUnsafeResumePreflight(from, actions),
121109
];
122110
for (const check of checks) {
123111
const message = check();
@@ -194,8 +182,3 @@ function describeStaleResumeDigest(digest: string, planDigest: string): string |
194182
'replay to get a new digest.'
195183
);
196184
}
197-
198-
function describeUnsafeResumePreflight(from: number, actions: SessionAction[]): string | undefined {
199-
const preflight = evaluateReplayResumePreflight({ from, actions });
200-
return preflight.allowed ? undefined : `replay --from ${from} cannot resume: ${preflight.reason}`;
201-
}

src/daemon/handlers/session-replay-runtime.ts

Lines changed: 0 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -206,7 +206,6 @@ export async function runReplayScriptFile(params: {
206206
req.flags,
207207
actions.length,
208208
planDigest,
209-
actions,
210209
preEntrySession?.pendingRecordAndHeal,
211210
preEntrySession?.actions.length ?? 0,
212211
);

src/daemon/handlers/session-replay-target-verification.ts

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -174,10 +174,9 @@ function buildTargetBindingDivergenceResponse(
174174
// executed — resuming AT `step` re-runs exactly the action that did not
175175
// send (unless `repairHint` is `record-and-heal`, in which case the agent
176176
// performs it manually and `buildReplayDivergenceResume` targets `step +
177-
// 1` instead). `buildReplayDivergenceResume` runs the same skip-safety
178-
// preflight as an action-failure divergence (allowed unless a skipped
179-
// step produces outputEnv or the range crosses runtime control flow).
180-
// This is the only resume site for target-binding divergences.
177+
// 1` instead). Generic `.ad` actions have no runtime variable producers
178+
// or control wrappers, so the reported ordinal is resumable. This is the
179+
// only resume site for target-binding divergences.
181180
resume,
182181
repairHint,
183182
targetBinding,

src/daemon/types.ts

Lines changed: 5 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -409,9 +409,9 @@ export type SessionState = {
409409
* failed step's index unchanged (never made illegal) — but ONLY when the
410410
* diverged step is the plan's LAST one: those hints have a legitimate
411411
* record-and-heal-SHAPED alternate repair targeting `failedIndex + 1`,
412-
* stamped when that target is independently preflight-safe
413-
* (`stampPendingRecordAndHealWatermark`, `session-replay-resume.ts`). A
414-
* MID-PLAN `caution`/`manual` `failedIndex + 1` was already unconditionally
412+
* stamped by `stampPendingRecordAndHealWatermark`
413+
* (`session-replay-resume.ts`). A MID-PLAN `caution`/`manual`
414+
* `failedIndex + 1` was already unconditionally
415415
* legal (in range) and un-gated before #1262 — these hints never mandate a
416416
* corrective action the way `record-and-heal` does, so an agent may
417417
* legitimately skip the diverged step without repairing it — and stays
@@ -423,9 +423,8 @@ export type SessionState = {
423423
* new action recorded since) is rejected — proof the corrective press
424424
* never happened, so the resume would silently skip the unrepaired step
425425
* instead of healing it. Overwritten by the next divergence (cleared to
426-
* `undefined` for any hint outside the eligible set, a mid-plan
427-
* `caution`/`manual` divergence, or a last-step `caution`/`manual`
428-
* divergence whose `failedIndex + 1` target is not itself preflight-safe),
426+
* `undefined` for any hint outside the eligible set or a mid-plan
427+
* `caution`/`manual` divergence),
429428
* and cleared once a `--from` request observes the action count having
430429
* grown, so it never fires against an unrelated later request.
431430
*/

0 commit comments

Comments
 (0)