Skip to content

Commit 2c2df03

Browse files
authored
feat: keep replay session active on request (#1554)
* feat: keep replay session active on request * test: cover replay keep-session provider route * fix: make replay session handoff reliable * refactor(daemon): extract the replay terminal-lifecycle policy module (#1554 review) session-replay-runtime.ts was already over the 500-line extract-before-adding-behavior tripwire before this PR; the keep-session/repair terminal-close decision, its live-session postcondition, and the dispatched-action count pushed it further past budget. Move that policy into a focused session-replay-terminal-lifecycle.ts (isExecutableReplayAction, resolveSuppressedTerminalCloseIndex, countExecutedReplayActions, requireLiveSessionForKeepSession) so the runtime file stays orchestration-only, and mirror its PR-added unit tests into session-replay-terminal-lifecycle.test.ts. Pure extraction: no assertions changed.
1 parent 4551fb7 commit 2c2df03

21 files changed

Lines changed: 494 additions & 70 deletions

packages/contracts/src/cli-flags.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -136,6 +136,8 @@ export type CliFlags = CloudProviderProfileFields &
136136
replayShellEnv?: Record<string, string>;
137137
replayFrom?: number;
138138
replayPlanDigest?: string;
139+
/** Replay: leave the session active by suppressing an authored terminal close in native .ad. */
140+
replayKeepSession?: boolean;
139141
failFast?: boolean;
140142
timeoutMs?: number;
141143
retries?: number;

packages/contracts/src/client-replay.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,8 @@ export type ReplayRunOptions = AgentDeviceRequestOverrides &
3030
resumeFrom?: number;
3131
/** The `resume.planDigest` from the divergence report `resumeFrom` came from. */
3232
resumePlanDigest?: string;
33+
/** Leave the session active by suppressing an authored terminal `close` in native `.ad`. */
34+
keepSession?: boolean;
3335
/**
3436
* ADR 0012 decision 6, R1/R6: arms agent-supervised re-record repair
3537
* from this replay attempt onward. Optional string value is the healed

packages/contracts/src/client-request.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ export type CommandExecutionOptions = Partial<ScreenshotRequestFlags> & {
5656
replayShellEnv?: Record<string, string>;
5757
replayFrom?: number;
5858
replayPlanDigest?: string;
59+
replayKeepSession?: boolean;
5960
failFast?: boolean;
6061
timeoutMs?: number;
6162
retries?: number;

packages/contracts/src/replay.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,10 @@ export type ReplayCommandResult = {
77
session: string;
88
/**
99
* True iff `session` still exists in the daemon's session store when the
10-
* response is built — i.e. the replayed script had no terminal `close`
11-
* (ADR 0016's consumption contract). The client uses this, not script
12-
* parsing, to decide whether an owned one-shot daemon must stay alive so
13-
* the caller can keep addressing this session.
10+
* response is built. This remains true when replay suppresses an authored
11+
* terminal `close` for an explicit live-session handoff. The client uses
12+
* this, not script parsing, to decide whether an owned one-shot daemon must
13+
* stay alive so the caller can keep addressing this session.
1414
*/
1515
sessionActive: boolean;
1616
artifactPaths: string[];

scripts/integration-progress-model.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,7 @@ function summarizeProviderScenarioFlagCoverage(files) {
182182
['replayEnv', 'replay/test variable injection', ['env']],
183183
['replayFrom', 'replay resume skips completed steps (ADR 0012)', ['resumeFrom']],
184184
['replayPlanDigest', 'replay resume plan-digest preflight binding', ['resumePlanDigest']],
185+
['replayKeepSession', 'native replay terminal-close suppression', ['keepSession']],
185186
['failFast', 'test suite stops after first failure'],
186187
['timeoutMs', 'wait/test timeout flags'],
187188
['retries', 'test suite retry budget flows through request path'],

src/cli/parser/cli-help.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -358,7 +358,7 @@ Validation and evidence:
358358
agent-device screenshot
359359
agent-device press 124 817
360360
agent-device snapshot -i
361-
Startup/CPU/memory/frame first pass: perf metrics --json (bare perf and metrics are aliases). Focused frame/jank health: perf frames --json. Memory-only sample: perf memory sample --json returns compact JSON with bounded top offenders. Heap/memgraph artifact escalation: perf memory snapshot --out heap.artifact; use --kind android-hprof on Android or --kind memgraph on supported Apple simulator/macOS app sessions. Android native profiling: perf cpu profile start|stop|report --kind simpleperf --out <path>; Android native traces: perf trace start|stop --kind perfetto --out <path>. Artifact collectors return compact state/path/size metadata only; raw heap/profile/trace files stay on disk. Treat native perf output as the agent evidence: for example, a Perfetto stop can return state=stopped, outPath=/tmp/app.perfetto-trace, sizeBytes=5392410, and method=adb-shell-perfetto while the 5.3 MB raw trace stays in the artifact. This is better than raw dumps for agents because it is stable, bounded, and keeps large artifacts out of context. heapprofd is deferred until Perfetto plumbing is available. Replay divergence and resume: a failing replay/test step returns REPLAY_DIVERGENCE with a bounded report (screen digest, ranked selector suggestions, resume). Repair app state, then resume with replay --from <n> --plan-digest <sha256> (both from the report's resume field) to continue from the failed step without re-running earlier ones; resume never re-executes skipped steps, so app state is the caller's responsibility, and it is rejected with INVALID_ARGS when the plan digest is stale, --from is out of range, or the skipped range/target touches runtime control flow. The plan digest binds the script, its includes, the effective --platform/--target, and per-action runtime/identity. Native .ad interpolation is late-bound after planning, so changing only its values keeps the digest; Maestro environment substitution occurs during compatibility parsing and can change action inputs, includes, or control expansion, so it can change the digest. --from is replay-only; test rejects it. --update/-u no longer rewrites the script (ADR 0012) — it is a no-op kept for compatibility; every divergence already carries the same ranked suggestions. Agent-supervised repair (heal-by-doing): arm replay <file>.ad --save-script[=<out>] before step 1 (armed once; --from continuations do not need it again). Every divergence carries a repairHint: record-and-heal means press the correct control via a blessed @ref from the divergence's screen.refs, recorded (no --no-record), then continue with replay --from <n+1> --plan-digest <sha256>; state-repair means the script is correct but app state is not, so fix state with --no-record actions, then replay --from <n> --plan-digest <sha256> to re-run the unchanged step; caution means something already matches the recorded selector, so a blind re-press may repeat the mistake; manual means no safe automated repair could be proven. (If close --save-script is run before a required resume, the repair is aborted and no script is written). While armed, read-only inspection YOU run to locate the repair target (snapshot -i, get attrs, find, is) is excluded from the healed script by default — no --no-record needed; the script's own authored get/is/find steps are unaffected and stay in the heal. If the step you are repairing is itself a read, pass --record on that one command so it lands in the heal (--record is accepted only on snapshot/get/is and a read-only find; it is mutually exclusive with --no-record). Ending the repair with close --save-script[=<out>] writes only the steps recorded since the arming replay as <out>, defaulting to the <file> sibling <stem>.healed.ad — review its diff before promoting it over the original.
361+
Startup/CPU/memory/frame first pass: perf metrics --json (bare perf and metrics are aliases). Focused frame/jank health: perf frames --json. Memory-only sample: perf memory sample --json returns compact JSON with bounded top offenders. Heap/memgraph artifact escalation: perf memory snapshot --out heap.artifact; use --kind android-hprof on Android or --kind memgraph on supported Apple simulator/macOS app sessions. Android native profiling: perf cpu profile start|stop|report --kind simpleperf --out <path>; Android native traces: perf trace start|stop --kind perfetto --out <path>. Artifact collectors return compact state/path/size metadata only; raw heap/profile/trace files stay on disk. Treat native perf output as the agent evidence: for example, a Perfetto stop can return state=stopped, outPath=/tmp/app.perfetto-trace, sizeBytes=5392410, and method=adb-shell-perfetto while the 5.3 MB raw trace stays in the artifact. This is better than raw dumps for agents because it is stable, bounded, and keeps large artifacts out of context. heapprofd is deferred until Perfetto plumbing is available. Replay divergence and resume: a failing replay/test step returns REPLAY_DIVERGENCE with a bounded report (screen digest, ranked selector suggestions, resume). Repair app state, then resume with replay --from <n> --plan-digest <sha256> (both from the report's resume field) to continue from the failed step without re-running earlier ones; resume never re-executes skipped steps, so app state is the caller's responsibility, and it is rejected with INVALID_ARGS when the plan digest is stale, --from is out of range, or the skipped range/target touches runtime control flow. The plan digest binds the script, its includes, the effective --platform/--target, and per-action runtime/identity. Native .ad interpolation is late-bound after planning, so changing only its values keeps the digest; Maestro environment substitution occurs during compatibility parsing and can change action inputs, includes, or control expansion, so it can change the digest. --from is replay-only; test rejects it. Native .ad session takeover: replay <file>.ad --keep-session suppresses exactly an authored terminal close and returns the surviving session for continued commands; interior closes still run, close-less scripts are unchanged, and test/Maestro reject the option. --update/-u no longer rewrites the script (ADR 0012) — it is a no-op kept for compatibility; every divergence already carries the same ranked suggestions. Agent-supervised repair (heal-by-doing): arm replay <file>.ad --save-script[=<out>] before step 1 (armed once; --from continuations do not need it again). Every divergence carries a repairHint: record-and-heal means press the correct control via a blessed @ref from the divergence's screen.refs, recorded (no --no-record), then continue with replay --from <n+1> --plan-digest <sha256>; state-repair means the script is correct but app state is not, so fix state with --no-record actions, then replay --from <n> --plan-digest <sha256> to re-run the unchanged step; caution means something already matches the recorded selector, so a blind re-press may repeat the mistake; manual means no safe automated repair could be proven. (If close --save-script is run before a required resume, the repair is aborted and no script is written). While armed, read-only inspection YOU run to locate the repair target (snapshot -i, get attrs, find, is) is excluded from the healed script by default — no --no-record needed; the script's own authored get/is/find steps are unaffected and stay in the heal. If the step you are repairing is itself a read, pass --record on that one command so it lands in the heal (--record is accepted only on snapshot/get/is and a read-only find; it is mutually exclusive with --no-record). Ending the repair with close --save-script[=<out>] writes only the steps recorded since the arming replay as <out>, defaulting to the <file> sibling <stem>.healed.ad — review its diff before promoting it over the original.
362362
Recording: record start/stop. The default scope is app and expects an active session created by open <app>; this keeps app proof videos tied to the intended app session. Use record start --scope device/system to explicitly request whole-screen capture where the selected backend supports it, such as recordings that intentionally span multiple apps, home screen, settings, or app transitions. Use --max-size to cap the longest edge and --quality medium|high to choose output quality across Android and Apple targets. By default, stop burns touch overlays into the video; use record start --hide-touches for the fastest raw recording. Android record start publishes a durable device manifest. Android adb screenrecord has a 180s platform limit, so longer Android recordings are returned as multiple MP4 chunks while the daemon stays alive; after daemon restart, record stop recovers only manifest-owned chunks and warns when gesture overlays are unavailable. For gesture-heavy iOS simulator proof videos, prefer --hide-touches because overlay timing depends on a stable runner session while gestures are executing. Tracing: trace start ./trace.log, trace stop ./trace.log. Paths are positional.
363363
Stable known flow: batch ./steps.json, not workflow batch.
364364
Inline batch JSON example:

src/commands/cli-grammar/flag-definitions-workflow.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,14 @@ export const WORKFLOW_FLAG_DEFINITIONS: readonly FlagDefinition[] = [
3030
'Replay: the plan digest a --from resume must match (from a prior divergence report); mismatch, ' +
3131
'edits, or include/platform-expansion changes fail INVALID_ARGS before any action',
3232
},
33+
{
34+
key: 'replayKeepSession',
35+
names: ['--keep-session'],
36+
type: 'boolean',
37+
usageLabel: '--keep-session',
38+
usageDescription:
39+
'Replay: leave the session active by suppressing exactly an authored terminal close in a native .ad script; replay only, not test or Maestro YAML',
40+
},
3341
{
3442
key: 'replayMaestro',
3543
names: ['--maestro'],

src/commands/cli-grammar/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export type CommandInput = Omit<InternalRequestOptions, 'batchSteps' | 'target'>
3737
from?: PointInput;
3838
include?: CliFlags['networkInclude'];
3939
kind?: string;
40+
keepSession?: boolean;
4041
locator?: string;
4142
mode?: 'in-app' | 'system' | 'full' | 'limited';
4243
button?: ClickButton;

src/commands/command-flags.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,6 +103,7 @@ function buildFlags(options: InternalRequestOptions): CommandFlags {
103103
replayShellEnv: options.replayShellEnv,
104104
replayFrom: options.replayFrom,
105105
replayPlanDigest: options.replayPlanDigest,
106+
replayKeepSession: options.replayKeepSession,
106107
failFast: options.failFast,
107108
timeoutMs: options.timeoutMs,
108109
retries: options.retries,

src/commands/replay/index.test.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -228,6 +228,26 @@ describe('replay resume (ADR 0012 decision 4 / migration step 5)', () => {
228228
});
229229
});
230230

231+
describe('replay --keep-session', () => {
232+
test('projects the CLI flag through structured replay input and the daemon request', () => {
233+
const input = replayCliReader(['./checkout.ad'], flags({ replayKeepSession: true }));
234+
expect(input).toMatchObject({ path: './checkout.ad', keepSession: true });
235+
expect(replayDaemonWriter(input)).toMatchObject({
236+
command: 'replay',
237+
positionals: ['./checkout.ad'],
238+
options: { replayKeepSession: true },
239+
});
240+
expect(replayCommandMetadata.inputSchema.properties).toHaveProperty('keepSession');
241+
});
242+
243+
test('test exposes and forwards no keep-session option', () => {
244+
const input = testCliReader(['./suite.ad'], flags({ replayKeepSession: true } as never));
245+
expect(input).not.toHaveProperty('keepSession');
246+
expect(testCommandMetadata.inputSchema.properties).not.toHaveProperty('keepSession');
247+
expect(testDaemonWriter(input).options).not.toHaveProperty('replayKeepSession');
248+
});
249+
});
250+
231251
describe('replay --save-script arming (ADR 0012 decision 6, R1/R6)', () => {
232252
test('reads --save-script as a boolean flag', () => {
233253
expect(replayCliReader(['./checkout.ad'], flags({ saveScript: true }))).toMatchObject({

0 commit comments

Comments
 (0)