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
11 changes: 11 additions & 0 deletions docs/adr/0012-interactive-replay.md
Original file line number Diff line number Diff line change
Expand Up @@ -875,6 +875,17 @@ works" and "healed scripts are always valid":
prevent keep-alive, the armed replay must **fail-fast before step 1** with actionable guidance, never
proceed and then fail a later `--from` with `SESSION_NOT_FOUND`.

**Interaction with the CLI client's one-shot teardown (issue #1384).** A repair-armed replay that
completes cleanly (no divergence, transaction reaches `COMPLETE`) skips its terminal source `close` by
design (above), so the session always survives the run. Once the client keeps a one-shot `replay`'s
owning daemon alive whenever the session survives (`ReplayCommandResult.sessionActive`, independent of
repair state), that keep-alive now applies here too: the healed `.ad` commit — gated on teardown, not on
the replay response — is deferred past the request that completed it, landing only when the agent issues
an explicit `close`/`close --save-script` or ordinary idle-reap tears the session down. This is the
correct shape of "commit at teardown" (the session stays addressable for inspection immediately after a
completed repair, per R7), but it changes observable timing for a scripted caller that previously saw
the healed sibling appear the instant the one-shot client process exited.

**Terminal lifecycle steps during a repair-armed `--from` resume.** Verification is scoped, per decision
3, to *annotated resolved targets* — so a non-target step (a source `close`, or any step carrying no
`target-v1` `targetEvidence`) is already exempt from target-binding divergence: it may still surface an
Expand Down
18 changes: 17 additions & 1 deletion docs/adr/0016-active-session-script-publication.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,16 @@ enough for this boundary.
On consumption, a script without `close` preserves the existing replay behavior: the named session stays
active and the successful `ReplayCommandResult` returns its `session` id. The caller binds subsequent
commands to that returned id. Replay reports success only after the destination guard completes; the
absence of `close` changes neither action dispatch nor the success response shape.
absence of `close` changes neither action dispatch nor when replay reports success.

**Response shape (issue #1384).** `ReplayCommandResult` carries a required `sessionActive: boolean`,
computed from whether `session` still exists in the daemon's own store when the response is built — never
by re-parsing the script for `close`. This is what lets the real CLI/IPC client (not just the in-process
handler) actually honor "the session stays active": without an explicit signal in the response, the
client's one-shot `replay`/`test` teardown had no way to distinguish a still-active handoff from a
finished one, and tore the owning daemon down regardless (`src/daemon/client/daemon-client-lifecycle.ts`).
`sessionActive` is `true` for every close-less run (including a `--from` resume) and `false` once the
script's terminal `close` — or a repair-armed run's deferred equivalent — has executed.

### Sensitive inputs

Expand Down Expand Up @@ -169,6 +178,13 @@ executing that script, not the artifact being saved.
fragment pinning remain entirely under #1336.
- Secret-bearing authoring remains unsafe until #1348; the initial workflow is limited to journeys that
do not enter secrets. Arbitrary history ranges remain out of scope.
- On consumption (issue #1384), a `replay` whose script has no terminal `close` leaves a live daemon and
app session behind — including the ordinary one-shot `replay` invocation's own owned/ephemeral daemon,
which the client keeps alive and reports a `--state-dir` address hint for instead of tearing down. An
unattended close-less replay (e.g. in CI) therefore leaks a session exactly like one opened
interactively: bounded by ordinary idle-reap or an explicit `close`, never by the one-shot command's own
process lifetime. This is the intended shape of "the caller owns close," not an accident, and recorded
test flows already end with `close` so `test` runs are unaffected.

## Alternatives Considered

Expand Down
2 changes: 1 addition & 1 deletion src/commands/replay/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ export const testCommandDefinition = defineExecutableCommand(testCommandMetadata
const replayCliSchema = {
usageOverride: 'replay <path> | replay export <file.ad> [--out <path>]',
helpDescription:
'Replay a recorded session. For Maestro YAML compatibility flows, use replay <flow.yaml> --maestro and keep the target binding such as --platform ios on the replay command.',
'Replay a recorded session. For Maestro YAML compatibility flows, use replay <flow.yaml> --maestro and keep the target binding such as --platform ios on the replay command. A script with no terminal close leaves its session (and daemon) running until you close it or it idle-reaps — no different from a session opened interactively.',
summary: replayCommandDescription,
positionalArgs: ['path'],
allowsExtraPositionals: true,
Expand Down
8 changes: 8 additions & 0 deletions src/contracts/replay.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ export type ReplayCommandResult = {
replayed: number;
healed: number;
session: string;
/**
* True iff `session` still exists in the daemon's session store when the
* response is built — i.e. the replayed script had no terminal `close`
* (ADR 0016's consumption contract). The client uses this, not script
* parsing, to decide whether an owned one-shot daemon must stay alive so
* the caller can keep addressing this session.
*/
sessionActive: boolean;
artifactPaths: string[];
warnings?: string[];
snapshotDiagnostics?: SnapshotDiagnosticsSummary;
Expand Down
81 changes: 80 additions & 1 deletion src/daemon/client/daemon-client-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import {
} from '../config.ts';
import { computeDaemonCodeSignature } from '../code-signature.ts';
import { PUBLIC_COMMANDS } from '../../command-catalog.ts';
import { shellQuoteIfNeeded } from '../../utils/shell-quote.ts';
import { sleep } from '../../utils/timeouts.ts';
import {
cleanupFailedDaemonStartupMetadata,
Expand Down Expand Up @@ -342,7 +343,19 @@ export async function cleanupDaemonAfterRequest(
// `REPAIR_SESSION_EXPIRED` tombstone on reap), so an abandoned repair still
// cannot leak indefinitely; this only stops the ONE-SHOT-COMMAND teardown
// below from racing ahead of that window.
isHeldRepairDivergence(response)
isHeldRepairDivergence(response) ||
// ADR 0016: a `replay` whose script had no terminal `close` reports its
// session as still active by design (the consumption contract this ADR
// defines) — tearing down its owning daemon here would make that contract
// unaddressable over the real CLI path the instant the response is sent.
// Keyed off the session surviving the run (`sessionActive`), never off
// parsing the script for `close`, so a `--from` resume is covered too.
// Unlike the repair case, this session has no bounded reap of its own: an
// unattended close-less replay leaves a live daemon+app session until
// ordinary idle-reap or an explicit `close` ends it — the same lifetime an
// interactively opened session already has, and exactly what the ADR's
// "caller owns close" contract asks for.
isActiveReplaySessionResponse(req, response)
) {
return response;
}
Expand Down Expand Up @@ -476,6 +489,72 @@ function isOneShotReplayCommand(command: string | undefined): boolean {
return command === PUBLIC_COMMANDS.replay || command === PUBLIC_COMMANDS.test;
}

/**
* ADR 0016: true when a successful `replay` response reports its session as
* still active (`ReplayCommandResult.sessionActive`, set by the daemon from
* whether the session survived in its own store — never derived here by
* re-parsing the script). Restricted to `replay` itself, never `test`: a
* `test` run's own per-file runner already closes each session before the
* suite summary is built, and its `ReplaySuiteResult` carries no such field
* anyway, but the explicit command check keeps that carve-out a decision
* rather than an accident of the response shape.
*/
export function isActiveReplaySessionResponse(
req: Omit<DaemonRequest, 'token'>,
response: DaemonResponse | undefined,
): boolean {
if (req.command !== PUBLIC_COMMANDS.replay) return false;
if (!response || !response.ok) return false;
return response.data?.sessionActive === true;
}

/**
* ADR 0016 counterpart to `attachRepairSessionAddressHint`: a still-active
* replay session is only unaddressable by `--state-dir` when it lives on an
* OWNED, randomly generated one (`stateDir` undefined otherwise — an explicit
* `--state-dir`/`AGENT_DEVICE_STATE_DIR` caller already knows it). But the
* SESSION name is always cwd-qualified (`cwd:<hash>:default`) and, per #1394,
* `session list` cannot rediscover it either — so `--session` is always
* emitted when a name is available, explicit state dir or not. Attached to
* both a structured `hint` field (for `--json` consumers) and appended to
* `message` — the only field the default text renderer surfaces
* (`src/utils/success-text.ts`) — so the hint reaches a caller in either mode.
*
* `data.session` is used verbatim, never reconstructed as `default`: an
* EXPLICIT `--session <value>` is used as-is by `resolveEffectiveSessionName`,
* skipping cwd-scoping entirely (`hasExplicitSessionFlag`), so passing the
* qualified name back unchanged is what actually reaches the same session
* from any cwd — a bare `--session default` would only match by coincidence
* (an implicit, no-`--session` follow-up run from the identical cwd). Both
* the state dir and the session name are shell-quoted (only when needed) so
* the hint stays literally copy-pasteable even if either contains spaces or
* shell metacharacters.
*/
export function attachActiveSessionAddressHint(
response: Extract<DaemonResponse, { ok: true }>,
stateDir: string | undefined,
): Extract<DaemonResponse, { ok: true }> {
const data = response.data ?? {};
const sessionName = typeof data.session === 'string' ? data.session : undefined;
const addressFlags = [
...(stateDir ? [`--state-dir ${shellQuoteIfNeeded(stateDir)}`] : []),
...(sessionName ? [`--session ${shellQuoteIfNeeded(sessionName)}`] : []),
];
if (addressFlags.length === 0) return response;
const addressHint =
`This session's daemon was kept alive because its script left the session active; ` +
`pass ${addressFlags.join(' ')} on your next command to reach it.`;
const existingMessage = typeof data.message === 'string' ? data.message : undefined;
return {
...response,
data: {
...data,
hint: addressHint,
message: existingMessage ? `${existingMessage} ${addressHint}` : addressHint,
},
};
}

async function waitForDaemonStartup(
timeoutMs: number,
settings: DaemonClientSettings,
Expand Down
34 changes: 33 additions & 1 deletion src/daemon/client/daemon-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,11 @@ import { createRequestId, emitDiagnostic, withDiagnosticTimer } from '../../util
import { INTERNAL_COMMANDS, PUBLIC_COMMANDS } from '../../command-catalog.ts';
import { prepareRemoteRequestArtifacts } from '../../remote/daemon-artifacts.ts';
import {
attachActiveSessionAddressHint,
attachRepairSessionAddressHint,
cleanupDaemonAfterRequest,
ensureDaemon,
isActiveReplaySessionResponse,
isHeldRepairDivergence,
resolveClientSettings,
type DaemonClientSettings,
Expand Down Expand Up @@ -94,7 +96,11 @@ export async function sendToDaemon(
),
{ requestId, command: req.command },
);
return withRepairSessionAddressHintIfOwned(response, settings);
return withActiveSessionAddressHint(
withRepairSessionAddressHintIfOwned(response, settings),
req,
settings,
);
});
}

Expand Down Expand Up @@ -153,6 +159,32 @@ function withRepairSessionAddressHintIfOwned(
return attachRepairSessionAddressHint(response, settings.paths.baseDir);
}

/**
* ADR 0016 counterpart to `withRepairSessionAddressHintIfOwned` — but unlike
* that one, NOT gated on `settings.ownedStateDir`. An owned ephemeral state
* dir is unaddressable by a later invocation either way, so it's included
* when owned; an explicit `--state-dir`/`AGENT_DEVICE_STATE_DIR` caller
* already knows their own dir, so it's omitted then. But the session's own
* name is cwd-qualified and, per #1394, `session list` cannot rediscover it
* either — so `--session` is still worth hinting even at an explicit state
* dir, which is why this runs for every active-session response regardless
* of `ownedStateDir` (`attachActiveSessionAddressHint` itself decides what,
* if anything, is worth attaching).
*/
function withActiveSessionAddressHint(
response: DaemonResponse,
req: Omit<DaemonRequest, 'token'>,
settings: DaemonClientSettings,
): DaemonResponse {
if (!response.ok || !isActiveReplaySessionResponse(req, response)) {
return response;
}
return attachActiveSessionAddressHint(
response,
settings.ownedStateDir ? settings.paths.baseDir : undefined,
);
}

function writeInstallInProgressNotice(command: string | undefined): void {
if (!isInstallLikeCommand(command) || process.stderr.isTTY !== true || process.env.CI) return;
process.stderr.write(
Expand Down
80 changes: 80 additions & 0 deletions src/daemon/handlers/__tests__/session-replay-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,61 @@ test('a successful replay prints one line with the step count and wall time', as
expect(data.message).toMatch(/^Replayed 2 steps in \d+\.\ds$/);
});

// --- ADR 0016 / issue #1384: `sessionActive` is derived from the REAL
// producer (`completeReplayRun`'s `sessionStore.get(sessionName)` check), not
// asserted against a hand-crafted fixture — deleting that line would fail
// these, unlike the client-lifecycle tests in
// `src/utils/__tests__/daemon-client-lifecycle.test.ts`, which only prove the
// CLIENT'S reaction to a `sessionActive` value it is handed. ---

test('a close-less replay reports sessionActive: true (real producer, session still in the store)', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-session-active-'));
const sessionStore = new SessionStore(path.join(root, 'sessions'));
const sessionName = 'default';
sessionStore.set(sessionName, makeIosSession(sessionName));
const filePath = writeReplayFile(root, ['open "Demo"', 'click "Save"']);

const response = await runReplayScriptFile({
req: baseReq({ positionals: [filePath] }),
sessionName,
logPath: path.join(root, 'daemon.log'),
sessionStore,
invoke: async () => ({ ok: true, data: {} }),
});

expect(response.ok).toBe(true);
if (!response.ok) return;
expect(sessionStore.get(sessionName)).toBeDefined();
expect((response.data as { sessionActive: boolean }).sessionActive).toBe(true);
});

test('a replay whose terminal close removes the session reports sessionActive: false', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-replay-session-closed-'));
const sessionStore = new SessionStore(path.join(root, 'sessions'));
const sessionName = 'default';
sessionStore.set(sessionName, makeIosSession(sessionName));
const filePath = writeReplayFile(root, ['open "Demo"', 'close']);

const response = await runReplayScriptFile({
req: baseReq({ positionals: [filePath] }),
sessionName,
logPath: path.join(root, 'daemon.log'),
sessionStore,
// Mirrors the one real effect of `handleCloseCommand` this test needs:
// removing the session from the store. Every other command is a no-op,
// same as the rest of this file's `invoke` stubs.
invoke: async (req) => {
if (req.command === 'close') sessionStore.delete(sessionName);
return { ok: true, data: {} };
},
});

expect(response.ok).toBe(true);
if (!response.ok) return;
expect(sessionStore.get(sessionName)).toBeUndefined();
expect((response.data as { sessionActive: boolean }).sessionActive).toBe(false);
});

test('Maestro YAML uses the typed engine while .ad remains generic', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-typed-maestro-route-'));
const sessionStore = new SessionStore(path.join(root, 'sessions'));
Expand Down Expand Up @@ -94,6 +149,31 @@ test('Maestro YAML uses the typed engine while .ad remains generic', async () =>
expect(commands).toEqual(['open']);
});

test('ADR 0016 / #1384: a Maestro replay reports sessionActive: true (real producer, no fake HTTP)', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-maestro-session-active-'));
const sessionStore = new SessionStore(path.join(root, 'sessions'));
const sessionName = 'default';
sessionStore.set(sessionName, makeIosSession(sessionName));
const yamlPath = path.join(root, 'flow.yaml');
fs.writeFileSync(yamlPath, 'appId: com.example.app\n---\n- launchApp\n');

const response = await runReplayScriptFile({
req: baseReq({
positionals: [yamlPath],
flags: { replayBackend: 'maestro', platform: 'ios' },
}),
sessionName,
logPath: path.join(root, 'daemon.log'),
sessionStore,
invoke: async () => ({ ok: true, data: {} }),
});

expect(response.ok).toBe(true);
if (!response.ok) return;
expect(sessionStore.get(sessionName)).toBeDefined();
expect((response.data as { sessionActive: boolean }).sessionActive).toBe(true);
});

test('typed Maestro nested commands receive the runtime hints bound into the plan', async () => {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'agent-device-maestro-runtime-envelope-'));
const sessionStore = new SessionStore(path.join(root, 'sessions'));
Expand Down
1 change: 1 addition & 0 deletions src/daemon/handlers/session-replay-maestro-response.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ export function buildTypedMaestroSuccessResponse(params: {
replayed,
healed: 0,
session: sessionName,
sessionActive: sessionStore.get(sessionName) !== undefined,
artifactPaths: result.artifactPaths,
...(result.warnings ? { warnings: result.warnings } : {}),
...(snapshotDiagnostics ? { snapshotDiagnostics } : {}),
Expand Down
1 change: 1 addition & 0 deletions src/daemon/handlers/session-replay-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,7 @@ function completeReplayRun(params: {
replayed: replayedCount,
healed: 0,
session: sessionName,
sessionActive: completedSession !== undefined,
artifactPaths: [...artifactPaths],
...(snapshotDiagnosticsSummary ? { snapshotDiagnostics: snapshotDiagnosticsSummary } : {}),
message: formatReplaySuccessMessage(replayedCount, Date.now() - startedAt),
Expand Down
5 changes: 4 additions & 1 deletion src/mcp/command-output-schemas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -607,11 +607,14 @@ export const COMMAND_OUTPUT_SCHEMAS = {
replayed: numberSchema(),
healed: numberSchema(),
session: stringSchema(),
sessionActive: booleanSchema(
'True iff the session is still active — the script had no terminal close.',
),
artifactPaths: stringArraySchema,
snapshotDiagnostics: looseObjectSchema(),
message: stringSchema(),
},
['replayed', 'healed', 'session', 'artifactPaths', 'message'],
['replayed', 'healed', 'session', 'sessionActive', 'artifactPaths', 'message'],
),
test: objectSchema(
{
Expand Down
Loading
Loading