refactor(replay-test): neutralize the values crossing the scheduler seam

#1478 P3, part 1 of 2. Prepares the replay-test extraction by removing every
non-neutral value that crosses the scheduler seam, in place under `src/`, so the
physical move to `packages/replay-test` is a file move rather than a redesign.

`DaemonResponse` no longer crosses the seam. `session-test-types.ts` typed
`runReplay`/`finalizeAttempt` as returning a daemon response and the scheduler read
`.error.code`, `.error.details`, and `.data.replayed/.healed/.warnings/
.snapshotDiagnostics` off it throughout. That is invisible to R10 today only
because `checkDaemonTypesImporters` skips `src/daemon/`; once the files live in a
package they become external `daemon/types.ts` importers, which the ratchet only
lets shrink. Attempts now resolve as tagged `ReplayTestAttemptOutcome` values
carrying exactly what the scheduler consumes, including an `infrastructure` tag —
classifying an environmental failure needs platform boot-diagnostic vocabulary the
scheduler must not import, so the host decides and the scheduler reads the verdict.
`session-test-outcome.ts` is the one place a daemon response becomes an outcome.

Step events get a narrow per-attempt port. They were emitted from
`session-replay-runtime.ts` and `session-replay-maestro-observer.ts`, both reading
a request-global `AsyncLocalStorage` seeded per attempt. The scheduler now hands
each attempt an `onStep` sink, threaded the way `tracePath` already is; both
engines call it and `withReplayTestActionProgress`/`readReplayTestActionProgress`
are gone. A direct `replay` simply has no sink.

ADR 0012 divergence becomes a neutral leaf. `src/replay/divergence.ts` depended
only on kernel contracts and redaction, yet Maestro constructs divergences too and
CLI/MCP both render them, so P5 could not have moved it into `packages/ad-replay`.
It is now `@agent-device/contracts/divergence`; the renderer's output text is
unchanged.

The progress wire vocabulary moves to `@agent-device/contracts/progress`. It is
serialized by `request-progress-protocol.ts` and reconstructed by the CLI reporter
path, so it belongs below both; `src/request/progress.ts` keeps only the sink and
its AsyncLocalStorage binding.

Together these clear all four of replay-test's recorded R10 migration imports, so
the rule now enforces unconditionally for that module.

Behavior is unchanged. The shipped reporter contract — export spellings,
object/factory loading, hook names, timing/order, value fields, the synchronous
live-hook rule, awaited suite completion, error handling, exit codes — is
untouched, and `session-test-reporter-values.test.ts` passes unmodified. The
`--shard-all` `total`/`runnable` asymmetry is preserved as characterized.

Refs #1478

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RXQLYV7etZx3gcXsUsrQJ8
This commit is contained in:
Claude
2026-07-30 19:29:21 +00:00
parent 32b9db2d7a
commit b339c640ff
40 changed files with 550 additions and 415 deletions
+8
View File
@@ -55,6 +55,14 @@
"./replay": {
"types": "./src/facades/replay.ts",
"default": "./src/facades/replay.ts"
},
"./divergence": {
"types": "./src/facades/divergence.ts",
"default": "./src/facades/divergence.ts"
},
"./progress": {
"types": "./src/facades/progress.ts",
"default": "./src/facades/progress.ts"
}
}
}
@@ -0,0 +1 @@
export * from '../replay-divergence.ts';
@@ -0,0 +1 @@
export * from '../request-progress.ts';
@@ -11,7 +11,7 @@ import {
REPLAY_DIVERGENCE_SUGGESTION_LIMIT,
truncateUtf8Field,
type ReplayDivergence,
} from '../divergence.ts';
} from './replay-divergence.ts';
function buildDivergence(overrides: Partial<ReplayDivergence> = {}): ReplayDivergence {
return {
@@ -239,7 +239,7 @@ test('sanitizeReplayDivergenceField redacts sensitive content even when no trunc
// --- Text report carries the repair data (bounded refs + unavailable hint) ---
test('formatReplayDivergenceReport lists a bounded ref/role/label subset for an available screen', async () => {
const { formatReplayDivergenceReport } = await import('../divergence.ts');
const { formatReplayDivergenceReport } = await import('./replay-divergence.ts');
const report = formatReplayDivergenceReport({
divergence: {
version: 1,
@@ -272,7 +272,7 @@ test('formatReplayDivergenceReport lists a bounded ref/role/label subset for an
});
test('formatReplayDivergenceReport carries the unavailable-screen hint', async () => {
const { formatReplayDivergenceReport } = await import('../divergence.ts');
const { formatReplayDivergenceReport } = await import('./replay-divergence.ts');
const report = formatReplayDivergenceReport({
divergence: {
version: 1,
@@ -295,7 +295,7 @@ test('formatReplayDivergenceReport carries the unavailable-screen hint', async (
});
test('scrubReplayVarValues replaces every occurrence with a named marker, longest value first', async () => {
const { scrubReplayVarValues } = await import('../divergence.ts');
const { scrubReplayVarValues } = await import('./replay-divergence.ts');
const entries = [
{ name: 'LONG', value: 'abc-def' },
{ name: 'SHORT', value: 'abc' },
@@ -414,7 +414,7 @@ test('boundReplayDivergence keeps targetBinding on the minimal overflow fallback
});
test('formatReplayDivergenceReport renders matchCount, mismatches, and candidates for a target-binding divergence', async () => {
const { formatReplayDivergenceReport } = await import('../divergence.ts');
const { formatReplayDivergenceReport } = await import('./replay-divergence.ts');
const report = formatReplayDivergenceReport({
divergence: {
version: 1,
@@ -442,7 +442,7 @@ test('formatReplayDivergenceReport renders matchCount, mismatches, and candidate
});
test('formatReplayDivergenceReport lists candidates for an identity-unverifiable target-binding divergence', async () => {
const { formatReplayDivergenceReport } = await import('../divergence.ts');
const { formatReplayDivergenceReport } = await import('./replay-divergence.ts');
const report = formatReplayDivergenceReport({
divergence: {
version: 1,
@@ -477,7 +477,7 @@ test('formatReplayDivergenceReport lists candidates for an identity-unverifiable
// refused — it surfaces `resume.reason` instead. ---
test('formatReplayDivergenceReport embeds the concrete resume command for an allowed record-and-heal divergence', async () => {
const { formatReplayDivergenceReport } = await import('../divergence.ts');
const { formatReplayDivergenceReport } = await import('./replay-divergence.ts');
const report = formatReplayDivergenceReport({
divergence: {
version: 1,
@@ -502,7 +502,7 @@ test('formatReplayDivergenceReport embeds the concrete resume command for an all
});
test('formatReplayDivergenceReport never renders a --from command when resume is NOT allowed, surfacing the reason instead', async () => {
const { formatReplayDivergenceReport } = await import('../divergence.ts');
const { formatReplayDivergenceReport } = await import('./replay-divergence.ts');
const report = formatReplayDivergenceReport({
divergence: {
version: 1,
@@ -532,7 +532,7 @@ test('formatReplayDivergenceReport never renders a --from command when resume is
});
test('formatReplayDivergenceReport falls back to a generic non-resumable sentence when resume carries no reason', async () => {
const { formatReplayDivergenceReport } = await import('../divergence.ts');
const { formatReplayDivergenceReport } = await import('./replay-divergence.ts');
const report = formatReplayDivergenceReport({
divergence: {
version: 1,
@@ -561,7 +561,7 @@ test('formatReplayDivergenceReport falls back to a generic non-resumable sentenc
// NEVER re-derives resumability, so text and structured wire never disagree. ---
test('formatReplayDivergenceReport embeds BOTH concrete resume commands for a caution divergence whose wire carries alternateFrom', async () => {
const { formatReplayDivergenceReport } = await import('../divergence.ts');
const { formatReplayDivergenceReport } = await import('./replay-divergence.ts');
const report = formatReplayDivergenceReport({
divergence: {
version: 1,
@@ -594,7 +594,7 @@ test('formatReplayDivergenceReport embeds BOTH concrete resume commands for a ca
});
test('formatReplayDivergenceReport embeds BOTH concrete resume commands for a manual divergence whose wire carries alternateFrom', async () => {
const { formatReplayDivergenceReport } = await import('../divergence.ts');
const { formatReplayDivergenceReport } = await import('./replay-divergence.ts');
const report = formatReplayDivergenceReport({
divergence: {
version: 1,
@@ -622,7 +622,7 @@ test('formatReplayDivergenceReport embeds BOTH concrete resume commands for a ma
});
test('formatReplayDivergenceReport renders ONLY the state-fix command for a caution divergence WITHOUT alternateFrom (the diverged step is not skip-safe)', async () => {
const { formatReplayDivergenceReport } = await import('../divergence.ts');
const { formatReplayDivergenceReport } = await import('./replay-divergence.ts');
// resume.allowed is true (resuming AT N is fine), but the diverged step N
// is a runScript/control-flow action, so `--from N + 1` would be refused —
// the daemon omits alternateFrom, and the text must NOT offer `--from N + 1`.
@@ -654,7 +654,7 @@ test('formatReplayDivergenceReport renders ONLY the state-fix command for a caut
});
test('formatReplayDivergenceReport renders neither caution command when resume is NOT allowed', async () => {
const { formatReplayDivergenceReport } = await import('../divergence.ts');
const { formatReplayDivergenceReport } = await import('./replay-divergence.ts');
const report = formatReplayDivergenceReport({
divergence: {
version: 1,
@@ -695,7 +695,7 @@ const REPAIR_DIAGNOSTICS_CLAUSE_PATTERN =
/Read-only inspection while armed \(snapshot -i, get attrs, find, is\) is excluded from the healed script by default — no --no-record needed\. If the step you are repairing is itself a read, add --record to that command so it lands in the heal\./;
test('formatReplayDivergenceReport appends the diagnostics default-exclusion clause for record-and-heal when repairSessionHeld is true', async () => {
const { formatReplayDivergenceReport } = await import('../divergence.ts');
const { formatReplayDivergenceReport } = await import('./replay-divergence.ts');
const report = formatReplayDivergenceReport({
divergence: {
version: 1,
@@ -718,7 +718,7 @@ test('formatReplayDivergenceReport appends the diagnostics default-exclusion cla
});
test('formatReplayDivergenceReport OMITS the diagnostics clause for record-and-heal when repairSessionHeld is absent (plain, non-repair divergence)', async () => {
const { formatReplayDivergenceReport } = await import('../divergence.ts');
const { formatReplayDivergenceReport } = await import('./replay-divergence.ts');
const report = formatReplayDivergenceReport({
divergence: {
version: 1,
@@ -741,7 +741,7 @@ test('formatReplayDivergenceReport OMITS the diagnostics clause for record-and-h
});
test('formatReplayDivergenceReport appends the diagnostics clause for state-repair when armed, distinct from the existing app-state --no-record clause', async () => {
const { formatReplayDivergenceReport } = await import('../divergence.ts');
const { formatReplayDivergenceReport } = await import('./replay-divergence.ts');
const report = formatReplayDivergenceReport({
divergence: {
version: 1,
@@ -765,7 +765,7 @@ test('formatReplayDivergenceReport appends the diagnostics clause for state-repa
});
test('formatReplayDivergenceReport OMITS the diagnostics clause for state-repair when repairSessionHeld is absent', async () => {
const { formatReplayDivergenceReport } = await import('../divergence.ts');
const { formatReplayDivergenceReport } = await import('./replay-divergence.ts');
const report = formatReplayDivergenceReport({
divergence: {
version: 1,
@@ -786,7 +786,7 @@ test('formatReplayDivergenceReport OMITS the diagnostics clause for state-repair
});
test('formatReplayDivergenceReport appends the diagnostics clause for caution when armed, alongside the dual-path resume commands', async () => {
const { formatReplayDivergenceReport } = await import('../divergence.ts');
const { formatReplayDivergenceReport } = await import('./replay-divergence.ts');
const report = formatReplayDivergenceReport({
divergence: {
version: 1,
@@ -816,7 +816,7 @@ test('formatReplayDivergenceReport appends the diagnostics clause for caution wh
});
test('formatReplayDivergenceReport OMITS the diagnostics clause for caution when repairSessionHeld is absent', async () => {
const { formatReplayDivergenceReport } = await import('../divergence.ts');
const { formatReplayDivergenceReport } = await import('./replay-divergence.ts');
const report = formatReplayDivergenceReport({
divergence: {
version: 1,
@@ -836,7 +836,7 @@ test('formatReplayDivergenceReport OMITS the diagnostics clause for caution when
});
test('formatReplayDivergenceReport appends the diagnostics clause for manual when armed, even when resume is NOT allowed', async () => {
const { formatReplayDivergenceReport } = await import('../divergence.ts');
const { formatReplayDivergenceReport } = await import('./replay-divergence.ts');
// `repairSessionHeld` reports the daemon KEPT THE SESSION LIVE, independent
// of `resume.allowed` (plan-resumability) — the diagnostics clause must
// still render here: the agent may still inspect the (held) session while
@@ -868,7 +868,7 @@ test('formatReplayDivergenceReport appends the diagnostics clause for manual whe
});
test('formatReplayDivergenceReport OMITS the diagnostics clause for manual when repairSessionHeld is absent', async () => {
const { formatReplayDivergenceReport } = await import('../divergence.ts');
const { formatReplayDivergenceReport } = await import('./replay-divergence.ts');
const report = formatReplayDivergenceReport({
divergence: {
version: 1,
@@ -0,0 +1,58 @@
/**
* Progress events the daemon streams to a connected client while a request runs.
*
* These are wire values: `src/daemon/request-progress-protocol.ts` serializes them onto the
* response stream and the CLI reconstructs them before handing them to the replay-test
* reporter registry. They therefore belong to neither side — the daemon's request-global
* progress plumbing carries them, and the replay-test scheduler produces and interprets
* them, so the vocabulary sits below both.
*/
export type ReplayTestSuiteProgressEvent = {
type: 'replay-test-suite';
status: 'start';
total: number;
runnable: number;
skipped: number;
artifactsDir: string;
shardMode?: 'all' | 'split';
shardCount?: number;
};
export type ReplayTestProgressEvent = {
type: 'replay-test';
file: string;
title?: string;
status: 'start' | 'progress' | 'pass' | 'fail' | 'skip';
index: number;
total: number;
stepIndex?: number;
stepTotal?: number;
stepCommand?: string;
stepValue?: string;
attempt?: number;
maxAttempts?: number;
durationMs?: number;
retrying?: boolean;
message?: string;
hint?: string;
session?: string;
artifactsDir?: string;
shardIndex?: number;
shardCount?: number;
deviceId?: string;
deviceName?: string;
};
export type CommandProgressEvent = {
type: 'command';
status: 'progress';
message: string;
};
export type RequestProgressEvent =
| ReplayTestSuiteProgressEvent
| ReplayTestProgressEvent
| CommandProgressEvent;
export type RequestProgressSink = (event: RequestProgressEvent) => void;
+13 -14
View File
@@ -128,21 +128,20 @@ test('replay-test may still import its own files inside the wider replay engine
assert.deepEqual(checkDaemonModularityRatchets([...baselineEdges(), ...edges], []), []);
});
test('recorded replay-test migration imports are exempt until the import is deleted', () => {
const recorded = LOGICAL_MODULE_POLICIES.find(
({ name }) => name === 'replay-test',
)?.recordedMigrationImports;
assert.deepEqual(recorded, [
'src/replay/test/reporters/default.ts -> src/replay/divergence.ts',
'src/replay/test/reporters/progress.ts -> src/request/progress.ts',
'src/replay/test/reporters/registry.ts -> src/request/progress.ts',
'src/replay/test/reporting.ts -> src/request/progress.ts',
]);
// #1478 P3 cleared every recorded replay-test migration import: the ADR 0012 divergence
// vocabulary became a neutral contracts leaf, and the reporter tree now reads the progress
// wire vocabulary from contracts instead of request-global plumbing. The rule enforces
// unconditionally for replay-test from here on.
test('replay-test carries no recorded migration imports', () => {
assert.equal(
LOGICAL_MODULE_POLICIES.find(({ name }) => name === 'replay-test')?.recordedMigrationImports,
undefined,
);
assert.deepEqual(
LOGICAL_MODULE_POLICIES.flatMap((module) => module.recordedMigrationImports ?? []),
[],
);
assert.deepEqual(checkDaemonModularityRatchets(baselineEdges(), []), []);
const withoutOne = checkDaemonModularityRatchets(baselineEdges().slice(0, -1), []);
assert.equal(withoutOne.length, 1);
assert.match(withoutOne[0]!.message, /delete it from replay-test's recordedMigrationImports/);
});
test('internal trees reject deep imports globally, including from daemon', () => {
-6
View File
@@ -83,12 +83,6 @@ export const LOGICAL_MODULE_POLICIES: readonly LogicalModulePolicy[] = [
'src/maestro/',
'src/ad-replay/',
],
recordedMigrationImports: [
'src/replay/test/reporters/default.ts -> src/replay/divergence.ts',
'src/replay/test/reporters/progress.ts -> src/request/progress.ts',
'src/replay/test/reporters/registry.ts -> src/request/progress.ts',
'src/replay/test/reporting.ts -> src/request/progress.ts',
],
},
];
@@ -40,9 +40,11 @@ const CONTRACT_EXPORTS = [
'@agent-device/contracts/client',
'@agent-device/contracts/command',
'@agent-device/contracts/device',
'@agent-device/contracts/divergence',
'@agent-device/contracts/interaction',
'@agent-device/contracts/observability',
'@agent-device/contracts/platform',
'@agent-device/contracts/progress',
'@agent-device/contracts/recording',
'@agent-device/contracts/remote',
'@agent-device/contracts/replay',
@@ -11,7 +11,7 @@ vi.mock('../../../core/dispatch.ts', async (importOriginal) => {
import { dispatchCommand } from '../../../core/dispatch.ts';
import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts';
import type { SnapshotState } from '@agent-device/kernel/snapshot';
import type { ReplayDivergence } from '../../../replay/divergence.ts';
import type { ReplayDivergence } from '@agent-device/contracts/divergence';
import { bindInternalObservationAuthority } from '../../internal-observation.ts';
import { expireRefFrame } from '../../ref-frame.ts';
import { markSessionPartialRefsIssued, setSessionSnapshot } from '../../session-snapshot.ts';
@@ -12,7 +12,7 @@ import { SessionStore } from '../../session-store.ts';
import type { DaemonResponse } from '../../types.ts';
import { dispatchCommand } from '../../../core/dispatch.ts';
import { makeIosSession } from '../../../__tests__/test-utils/session-factories.ts';
import { formatReplayDivergenceReport } from '../../../replay/divergence.ts';
import { formatReplayDivergenceReport } from '@agent-device/contracts/divergence';
import {
baseReplayRequest as baseReq,
writeReplayFile,
@@ -7,6 +7,7 @@ import {
materializeReplayTestAttemptArtifacts,
prepareReplayTestAttemptArtifacts,
} from '../session-test-artifacts.ts';
import { toReplayTestAttemptOutcome } from '../session-test-outcome.ts';
import type { DaemonResponse } from '../../types.ts';
test('materializeReplayTestAttemptArtifacts writes replay and result manifests for passing attempts', () => {
@@ -27,7 +28,7 @@ test('materializeReplayTestAttemptArtifacts writes replay and result manifests f
},
};
materializeReplayTestAttemptArtifacts({
response,
outcome: toReplayTestAttemptOutcome(response),
filePath: replayPath,
sessionName: 'default:test:suite:1',
attempts: 1,
@@ -83,7 +84,7 @@ test('materializeReplayTestAttemptArtifacts writes failure manifest and copies l
},
};
materializeReplayTestAttemptArtifacts({
response,
outcome: toReplayTestAttemptOutcome(response),
filePath: replayPath,
sessionName: 'default:test:suite:2',
attempts: 2,
@@ -2,9 +2,17 @@ import fs from 'node:fs';
import os from 'node:os';
import path from 'node:path';
import { afterEach, expect, test, vi } from 'vitest';
import type { DaemonResponse } from '../../types.ts';
import { isRequestCanceled } from '../../../request/cancel.ts';
import { runReplayTestAttempt } from '../session-test-runtime.ts';
import type { ReplayTestAttemptOutcome } from '../session-test-types.ts';
const PASSED: ReplayTestAttemptOutcome = {
status: 'passed',
replayed: 1,
healed: 0,
warnings: [],
artifactPaths: [],
};
afterEach(() => {
vi.useRealTimers();
@@ -25,8 +33,8 @@ function readTimingEventTypes(artifactsDir: string): string[] {
test('runReplayTestAttempt keeps cancellation active until a timed-out replay settles', async () => {
vi.useFakeTimers();
let resolveReplay: ((response: DaemonResponse) => void) | undefined;
const replayPromise = new Promise<DaemonResponse>((resolve) => {
let resolveReplay: ((outcome: ReplayTestAttemptOutcome) => void) | undefined;
const replayPromise = new Promise<ReplayTestAttemptOutcome>((resolve) => {
resolveReplay = resolve;
});
const replaySettled = replayPromise.then(() => undefined);
@@ -55,11 +63,14 @@ test('runReplayTestAttempt keeps cancellation active until a timed-out replay se
await vi.advanceTimersByTimeAsync(2_000);
const result = await attemptPromise;
expect(result.ok).toBe(false);
if (!result.ok) {
expect(result.status).toBe('failed');
if (result.status === 'failed') {
expect(result.error.message).toContain('TIMEOUT after 10ms');
expect(result.error.details?.reason).toBe('timeout_cleanup_pending');
expect(result.error.details?.timeoutCleanupPending).toBe(true);
// The abandoned replay still owns the device, so the outcome is tagged infrastructure and
// the scheduler stops the suite instead of retrying into a contended session.
expect(result.infrastructure).toBe(true);
}
expect(cleanupSession).toHaveBeenCalledWith('default:test:timeout');
expect(finalizeAttempt).toHaveBeenCalledWith(
@@ -76,8 +87,10 @@ test('runReplayTestAttempt keeps cancellation active until a timed-out replay se
expect(cleanupSession).toHaveBeenCalledTimes(1);
resolveReplay?.({
ok: false,
status: 'failed',
error: { code: 'COMMAND_FAILED', message: 'request canceled' },
artifactPaths: [],
infrastructure: false,
});
await replaySettled;
await vi.waitFor(() => {
@@ -97,19 +110,19 @@ test('runReplayTestAttempt keeps a passing replay passed when finalization fails
filePath: '01-pass.ad',
sessionName: 'default:test:pass',
requestId: 'req-pass',
runReplay: async () => ({ ok: true, data: { replayed: 1, healed: 0 } }),
runReplay: async () => PASSED,
finalizeAttempt: async () => ({
ok: false,
status: 'failed',
error: { code: 'COMMAND_FAILED', message: 'failed to stop recording' },
artifactPaths: [],
infrastructure: false,
}),
cleanupSession,
});
expect(result.ok).toBe(true);
if (!result.ok) throw new Error(result.error.message);
expect(result.data?.warnings).toEqual([
'Replay test finalization failed: failed to stop recording',
]);
expect(result.status).toBe('passed');
if (result.status !== 'passed') throw new Error(result.error.message);
expect(result.warnings).toEqual(['Replay test finalization failed: failed to stop recording']);
expect(cleanupSession).toHaveBeenCalledWith('default:test:pass');
});
@@ -127,7 +140,7 @@ test('runReplayTestAttempt finalizes before cleanup and records that order in th
artifactsDir,
runReplay: async () => {
lifecycleEvents.push('replay');
return { ok: true, data: { replayed: 1, healed: 0 } };
return PASSED;
},
finalizeAttempt: async () => {
lifecycleEvents.push('finalize');
@@ -138,7 +151,7 @@ test('runReplayTestAttempt finalizes before cleanup and records that order in th
},
});
expect(result.ok).toBe(true);
expect(result.status).toBe('passed');
expect(lifecycleEvents).toEqual(['replay', 'finalize', 'cleanup']);
expect(readTimingEventTypes(artifactsDir)).toEqual([
'replay_test_attempt_start',
@@ -154,8 +167,8 @@ test('runReplayTestAttempt cleans up once when a timed-out replay settles inside
vi.useFakeTimers();
const artifactsDir = makeArtifactsDir('grace');
let resolveReplay: ((response: DaemonResponse) => void) | undefined;
const replayPromise = new Promise<DaemonResponse>((resolve) => {
let resolveReplay: ((outcome: ReplayTestAttemptOutcome) => void) | undefined;
const replayPromise = new Promise<ReplayTestAttemptOutcome>((resolve) => {
resolveReplay = resolve;
});
const lifecycleEvents: string[] = [];
@@ -179,16 +192,17 @@ test('runReplayTestAttempt cleans up once when a timed-out replay settles inside
await vi.advanceTimersByTimeAsync(10);
// The replay comes back inside the 2s grace window, so no cleanup race is declared.
resolveReplay?.({ ok: true, data: { replayed: 1, healed: 0 } });
resolveReplay?.(PASSED);
const result = await attemptPromise;
// The raced timeout response still wins: a late success does not un-fail the attempt.
expect(result.ok).toBe(false);
if (!result.ok) {
// The raced timeout outcome still wins: a late success does not un-fail the attempt.
expect(result.status).toBe('failed');
if (result.status === 'failed') {
expect(result.error.message).toBe('TIMEOUT after 10ms');
expect(result.error.details?.reason).toBe('timeout');
expect(result.error.details?.timeoutCleanupPending).toBe(undefined);
expect(result.error.details?.timeoutMode).toBe('cooperative');
expect(result.infrastructure).toBe(false);
}
expect(lifecycleEvents).toEqual(['finalize', 'cleanup']);
expect(cleanupSession).toHaveBeenCalledTimes(1);
@@ -209,12 +223,12 @@ test('runReplayTestAttempt cleans up without a finalizer and adds no finalizatio
filePath: '01-no-finalizer.ad',
sessionName: 'default:test:no-finalizer',
requestId: 'req-no-finalizer',
runReplay: async () => ({ ok: true, data: { replayed: 1, healed: 0 } }),
runReplay: async () => PASSED,
cleanupSession,
});
expect(result.ok).toBe(true);
if (!result.ok) throw new Error(result.error.message);
expect(result.data?.warnings).toBe(undefined);
expect(result.status).toBe('passed');
if (result.status !== 'passed') throw new Error(result.error.message);
expect(result.warnings).toEqual([]);
expect(cleanupSession).toHaveBeenCalledWith('default:test:no-finalizer');
});
@@ -2,7 +2,7 @@ import fs from 'node:fs';
import path from 'node:path';
import type { ResponseLevel } from '@agent-device/kernel/contracts';
import { redactDiagnosticData } from '@agent-device/kernel/redaction';
import { boundReplayDivergence, type ReplayDivergence } from '../../replay/divergence.ts';
import { boundReplayDivergence, type ReplayDivergence } from '@agent-device/contracts/divergence';
import {
bindInternalObservationAuthority,
type InternalObservationEvidence,
@@ -41,7 +41,7 @@ import {
type ReplayDivergenceSuggestion,
type ReplayDivergenceSuggestionBasis,
type ReplayVarScrubEntry,
} from '../../replay/divergence.ts';
} from '@agent-device/contracts/divergence';
export type DivergenceFieldSanitizer = (value: string, limit?: number) => string;
@@ -7,7 +7,7 @@ import {
createReplayDivergenceSanitizer,
type ReplayDivergence,
type ReplayVarScrubEntry,
} from '../../replay/divergence.ts';
} from '@agent-device/contracts/divergence';
import { formatScriptArg } from '../../replay/script-utils.ts';
import { getRequestSignal } from '../../request/cancel.ts';
import { SessionStore } from '../session-store.ts';
@@ -5,20 +5,21 @@ import type {
MaestroFailedAction,
} from '@agent-device/maestro';
import { AppError } from '@agent-device/kernel/errors';
import { emitRequestProgress, readReplayTestActionProgress } from '../../request/progress.ts';
import type { ReplayTestAttemptStepSink } from './session-test-types.ts';
import { stripUndefined } from '../../utils/parsing.ts';
import { appendReplayTraceEvent } from './session-replay-trace.ts';
export function createMaestroReplayObserver(params: {
filePath: string;
tracePath: string | undefined;
onStep?: ReplayTestAttemptStepSink;
}): MaestroExecutionObserver {
const { filePath, tracePath } = params;
const { filePath, tracePath, onStep } = params;
const traceStarts = new Map<number, MaestroActionEvent>();
return {
actionStarted: (event) => {
traceStarts.set(event.stepIndex, event);
runTelemetrySink(() => emitMaestroProgress(filePath, event));
runTelemetrySink(() => emitMaestroStep(onStep, event));
runTelemetrySink(() => appendMaestroTraceStart(tracePath, filePath, event));
},
actionCompleted: (event) => {
@@ -64,18 +65,15 @@ function traceStopEvent(
});
}
function emitMaestroProgress(file: string, event: MaestroActionEvent): void {
const progress = readReplayTestActionProgress();
if (!progress) return;
emitRequestProgress({
type: 'replay-test',
...progress,
file: progress.file || file,
status: 'progress',
stepIndex: event.stepIndex,
stepTotal: event.stepTotal,
stepCommand: event.action,
...(event.value ? { stepValue: event.value } : {}),
function emitMaestroStep(
onStep: ReplayTestAttemptStepSink | undefined,
event: MaestroActionEvent,
): void {
onStep?.({
index: event.stepIndex,
total: event.stepTotal,
command: event.action,
...(event.value ? { value: event.value } : {}),
});
}
@@ -28,6 +28,7 @@ import { SessionStore } from '../session-store.ts';
import { errorResponse } from './response.ts';
import { buildReplayBuiltinVars } from './session-replay-vars.ts';
import { createMaestroReplayObserver } from './session-replay-maestro-observer.ts';
import type { ReplayTestAttemptStepSink } from './session-test-types.ts';
import {
buildTypedMaestroReplayErrorResponse,
buildTypedMaestroSuccessResponse,
@@ -42,6 +43,7 @@ type TypedMaestroReplayParams = {
logPath: string;
sessionStore: SessionStore;
tracePath?: string;
onStep?: ReplayTestAttemptStepSink;
invoke: DaemonInvokeFn;
};
@@ -104,7 +106,7 @@ async function executeTypedMaestroReplay(
state: TypedMaestroReplayState;
},
): Promise<DaemonResponse> {
const { req, sessionName, sessionStore, tracePath, invoke, state } = params;
const { req, sessionName, sessionStore, tracePath, onStep, invoke, state } = params;
const context = await prepareTypedMaestroReplay(params);
const port = createMaestroReplayPort({
req,
@@ -130,6 +132,7 @@ async function executeTypedMaestroReplay(
observer: createMaestroReplayObserver({
filePath: context.filePath,
tracePath,
onStep,
}),
});
if (!outcome.ok) {
@@ -22,7 +22,7 @@
*/
import type { SnapshotNode } from '@agent-device/kernel/snapshot';
import type { ReplayDivergenceKind, ReplayRepairHint } from '../../replay/divergence.ts';
import type { ReplayDivergenceKind, ReplayRepairHint } from '@agent-device/contracts/divergence';
import {
matchesAncestryPrefix,
type TargetAnnotationV1,
+2 -2
View File
@@ -1,5 +1,5 @@
import type { SessionAction, SessionState } from '../types.ts';
import type { ReplayDivergenceResume, ReplayRepairHint } from '../../replay/divergence.ts';
import type { ReplayDivergenceResume, ReplayRepairHint } from '@agent-device/contracts/divergence';
import { SessionStore } from '../session-store.ts';
export function buildAndPersistReplayDivergenceResume(params: {
@@ -40,7 +40,7 @@ export function buildAndPersistReplayDivergenceResume(params: {
* `failedIndex` would re-diverge on the step the agent already performed).
* Every other repair hint (including a plain `action-failure`) resumes AT
* `failedIndex` unchanged. This must agree with the text guidance rendered by
* `formatReplayDivergenceReport` (`src/replay/divergence.ts`) — both are
* `formatReplayDivergenceReport` (`packages/contracts/src/replay-divergence.ts`) — both are
* derived from the same computed `from` value.
*
* `failedIndex` is always a valid 1-based index into `actions` (both call
@@ -1,4 +1,4 @@
import { scrubReplayVarValues, type ReplayVarScrubEntry } from '../../replay/divergence.ts';
import { scrubReplayVarValues, type ReplayVarScrubEntry } from '@agent-device/contracts/divergence';
import { formatDivergenceActionLabel } from '../../replay/script-utils.ts';
import type { SnapshotDiagnosticsSummary } from '@agent-device/contracts/capture';
import { buildDisplayPositionals } from '../session-event-action.ts';
+22 -35
View File
@@ -9,11 +9,6 @@ import type {
SessionAction,
SessionState,
} from '../types.ts';
import {
emitRequestProgress,
readReplayTestActionProgress,
type ReplayTestProgressEvent,
} from '../../request/progress.ts';
import { SessionStore } from '../session-store.ts';
import { clearPendingRecordAndHealWatermark } from './session-replay-resume.ts';
import { expandSessionPath } from '../session-paths.ts';
@@ -38,7 +33,7 @@ import {
type SnapshotTimingSample,
} from '@agent-device/contracts/capture';
import type { ReplayCommandResult } from '@agent-device/contracts/replay';
import type { ReplayDivergenceResume } from '../../replay/divergence.ts';
import type { ReplayDivergenceResume } from '@agent-device/contracts/divergence';
import {
isMaestroYamlPath,
maestroBackendRequiredMessage,
@@ -62,6 +57,7 @@ import {
} from './session-replay-target-verification.ts';
import { buildReplayBuiltinVars } from './session-replay-vars.ts';
import { runTypedMaestroReplayFile } from './session-replay-maestro-runtime.ts';
import type { ReplayTestAttemptStep, ReplayTestAttemptStepSink } from './session-test-types.ts';
import { getRequestSignal } from '../../request/cancel.ts';
/** Per-run invariants for a single replay step (ADR 0012 step 4 verify + dispatch + guard). */
@@ -200,9 +196,15 @@ export async function runReplayScriptFile(params: {
logPath: string;
sessionStore: SessionStore;
tracePath?: string;
/**
* Per-attempt step sink supplied by the replay-test scheduler through its host (#1478 P3).
* Threaded alongside `tracePath` rather than read from request-global storage, so a direct
* `replay` simply has no sink and emits nothing.
*/
onStep?: ReplayTestAttemptStepSink;
invoke: DaemonInvokeFn;
}): Promise<DaemonResponse> {
const { req, sessionName, logPath, sessionStore, tracePath, invoke } = params;
const { req, sessionName, logPath, sessionStore, tracePath, onStep, invoke } = params;
const filePath = req.positionals?.[0];
if (!filePath) {
return errorResponse('INVALID_ARGS', 'replay requires a path');
@@ -285,6 +287,7 @@ export async function runReplayScriptFile(params: {
stepContext,
artifactPaths,
snapshotDiagnosticSamples,
onStep,
armSaveScript: sessionPreparation.armSaveScript,
});
if (failure) return failure;
@@ -323,6 +326,7 @@ type ReplayActionExecution = {
stepContext: ReplayStepContext;
artifactPaths: Set<string>;
snapshotDiagnosticSamples: SnapshotTimingSample[];
onStep: ReplayTestAttemptStepSink | undefined;
armSaveScript: () => void;
};
@@ -332,12 +336,12 @@ async function executeReplayActions(
const {
sessionName,
sessionStore,
resolved,
actions,
entryIndex,
stepContext,
artifactPaths,
snapshotDiagnosticSamples,
onStep,
armSaveScript,
} = params;
for (let index = entryIndex; index < actions.length; index += 1) {
@@ -357,7 +361,7 @@ async function executeReplayActions(
) {
continue;
}
emitReplayTestActionProgress(resolved, index, actions.length, action);
onStep?.(replayActionStep(index, actions.length, action));
const sampleStart = readSessionSnapshotSampleCount(sessionStore, sessionName);
const response = await resolveReplayStepResponse(stepContext, action, index, [
...artifactPaths,
@@ -452,42 +456,25 @@ function completeReplayRun(params: {
};
}
function emitReplayTestActionProgress(
file: string,
function replayActionStep(
actionIndex: number,
actionTotal: number,
action: SessionAction,
): void {
const progress = readReplayTestActionProgress();
if (!progress) return;
emitRequestProgress({
type: 'replay-test',
...progress,
file: progress.file || file,
status: 'progress',
stepIndex: actionIndex + 1,
stepTotal: actionTotal,
...formatReplayTestActionProgress(action),
});
}
function formatReplayTestActionProgress(
action: SessionAction,
): Pick<ReplayTestProgressEvent, 'stepCommand' | 'stepValue'> {
): ReplayTestAttemptStep {
return {
stepCommand: action.command,
...formatReplayTestProgressValue(action),
index: actionIndex + 1,
total: actionTotal,
command: action.command,
...replayActionStepValue(action),
};
}
function formatReplayTestProgressValue(
action: SessionAction,
): Pick<ReplayTestProgressEvent, 'stepValue'> {
function replayActionStepValue(action: SessionAction): Pick<ReplayTestAttemptStep, 'value'> {
const positionals = action.positionals ?? [];
const selectorValue = readSelectorDisplayValue(positionals[0]);
if (selectorValue) return { stepValue: selectorValue };
if (selectorValue) return { value: selectorValue };
if (positionals.length === 0) return {};
return { stepValue: positionals.join(' ') };
return { value: positionals.join(' ') };
}
function readSelectorDisplayValue(selector: string | undefined): string | undefined {
@@ -1,4 +1,4 @@
import type { ReplayDivergenceSuggestionBasis } from '../../replay/divergence.ts';
import type { ReplayDivergenceSuggestionBasis } from '@agent-device/contracts/divergence';
const BASIS_RANK: Record<ReplayDivergenceSuggestionBasis, number> = {
id: 0,
@@ -58,7 +58,7 @@ import {
type LocalIdentity,
type TargetAnnotationV1,
} from '../../replay/target-identity.ts';
import type { ReplayDivergenceTargetBindingKind } from '../../replay/divergence.ts';
import type { ReplayDivergenceTargetBindingKind } from '@agent-device/contracts/divergence';
// ---------------------------------------------------------------------------
// Pure classification core — no capture, no session, no wire shaping.
@@ -19,7 +19,7 @@ import {
type ReplayDivergenceTargetBindingKind,
type ReplayDivergenceTargetCandidate,
type ReplayDivergenceTargetIdentity,
} from '../../replay/divergence.ts';
} from '@agent-device/contracts/divergence';
import {
readNodeStructuralDenotation,
REPLAY_TARGET_GUARD_MISMATCH_REASON,
+15 -10
View File
@@ -8,6 +8,7 @@ import { collectReplayActionArtifactPaths } from './session-replay-runtime-artif
import { errorResponse } from './response.ts';
import type { ReplayScriptMetadata } from '../../replay/script.ts';
import { buildReplayTestShardFlags, type ReplayTestShardContext } from './session-test-sharding.ts';
import { toReplayTestAttemptOutcome, toReplayTestFinalizeFailure } from './session-test-outcome.ts';
import type { LeaseRegistry } from '../lease-registry.ts';
import {
buildReplayTestVideoOpenLifecycle,
@@ -104,6 +105,7 @@ export async function handleSessionReplayCommands(params: {
artifactPaths,
tracePath,
shard,
onStep,
}) => {
const captureArtifacts = (response: DaemonResponse): DaemonResponse => {
if (!artifactPaths) return response;
@@ -152,6 +154,7 @@ export async function handleSessionReplayCommands(params: {
logPath,
sessionStore,
tracePath,
onStep,
invoke: async (nestedReq) => {
const startResponse = await startReplayTestVideoRecordingIfReady(videoRecordingParams);
if (startResponse && !startResponse.ok) return startResponse;
@@ -159,7 +162,7 @@ export async function handleSessionReplayCommands(params: {
return response;
},
});
return replayResponse;
return toReplayTestAttemptOutcome(replayResponse);
},
finalizeAttempt: async ({
sessionName: testSessionName,
@@ -167,15 +170,17 @@ export async function handleSessionReplayCommands(params: {
artifactsDir,
tracePath,
}) =>
await finalizeReplayTestVideoRecording({
req,
sessionName: testSessionName,
logPath,
sessionStore,
artifactsDir,
tracePath,
artifactPaths,
}),
toReplayTestFinalizeFailure(
await finalizeReplayTestVideoRecording({
req,
sessionName: testSessionName,
logPath,
sessionStore,
artifactsDir,
tracePath,
artifactPaths,
}),
),
cleanupSession: async (testSessionName) => {
if (!sessionStore.get(testSessionName)) return;
await handleCloseCommand({
+16 -26
View File
@@ -1,7 +1,7 @@
import fs from 'node:fs';
import path from 'node:path';
import { isMaestroYamlPath } from '../../replay/format.ts';
import type { DaemonResponse } from '../types.ts';
import type { ReplayTestAttemptOutcome } from './session-test-types.ts';
import { SessionStore } from '../session-store.ts';
const DEFAULT_TEST_ARTIFACTS_ROOT = '.agent-device/test-artifacts';
@@ -40,18 +40,18 @@ export function prepareReplayTestAttemptArtifacts(
}
export function materializeReplayTestAttemptArtifacts(params: {
response: DaemonResponse;
outcome: ReplayTestAttemptOutcome;
filePath: string;
sessionName: string;
attempts: number;
maxAttempts: number;
attemptArtifactsDir: string;
}): void {
const { response, filePath, sessionName, attempts, maxAttempts, attemptArtifactsDir } = params;
const artifactPaths = getReplayTestArtifactPaths(response);
const sourcePaths = [...artifactPaths];
if (!response.ok && typeof response.error.logPath === 'string') {
sourcePaths.push(response.error.logPath);
const { outcome, filePath, sessionName, attempts, maxAttempts, attemptArtifactsDir } = params;
const passed = outcome.status === 'passed';
const sourcePaths = [...new Set(outcome.artifactPaths)];
if (outcome.status === 'failed' && typeof outcome.error.logPath === 'string') {
sourcePaths.push(outcome.error.logPath);
}
const copiedArtifacts = copyReplayTestArtifacts(sourcePaths, attemptArtifactsDir);
@@ -59,19 +59,17 @@ export function materializeReplayTestAttemptArtifacts(params: {
`file: ${filePath}`,
`session: ${sessionName}`,
`attempt: ${attempts}/${maxAttempts}`,
`status: ${response.ok ? 'passed' : 'failed'}`,
`status: ${passed ? 'passed' : 'failed'}`,
];
if (response.ok) {
const replayed = typeof response.data?.replayed === 'number' ? response.data.replayed : 0;
const healed = typeof response.data?.healed === 'number' ? response.data.healed : 0;
lines.push(`replayed: ${replayed}`, `healed: ${healed}`);
if (outcome.status === 'passed') {
lines.push(`replayed: ${outcome.replayed}`, `healed: ${outcome.healed}`);
} else {
lines.push(`code: ${response.error.code}`, `message: ${response.error.message}`);
if (response.error.hint) lines.push(`hint: ${response.error.hint}`);
if (response.error.diagnosticId) lines.push(`diagnosticId: ${response.error.diagnosticId}`);
if (response.error.logPath) lines.push(`logPath: ${response.error.logPath}`);
if (response.error.details?.reason === 'timeout') {
lines.push(`code: ${outcome.error.code}`, `message: ${outcome.error.message}`);
if (outcome.error.hint) lines.push(`hint: ${outcome.error.hint}`);
if (outcome.error.diagnosticId) lines.push(`diagnosticId: ${outcome.error.diagnosticId}`);
if (outcome.error.logPath) lines.push(`logPath: ${outcome.error.logPath}`);
if (outcome.error.details?.reason === 'timeout') {
lines.push('timeoutMode: cooperative');
}
}
@@ -85,19 +83,11 @@ export function materializeReplayTestAttemptArtifacts(params: {
const resultPath = path.join(attemptArtifactsDir, 'result.txt');
const output = `${lines.join('\n')}\n`;
fs.writeFileSync(resultPath, output);
if (!response.ok) {
if (!passed) {
fs.writeFileSync(path.join(attemptArtifactsDir, 'failure.txt'), output);
}
}
function getReplayTestArtifactPaths(response: DaemonResponse): string[] {
const raw = response.ok
? (response.data as Record<string, unknown> | undefined)?.artifactPaths
: response.error.details?.artifactPaths;
if (!Array.isArray(raw)) return [];
return [...new Set(raw.filter((entry): entry is string => typeof entry === 'string'))];
}
function copyReplayTestArtifacts(paths: string[], attemptArtifactsDir: string): string[] {
const copiedPaths: string[] = [];
const usedNames = new Map<string, number>();
+88 -78
View File
@@ -1,7 +1,7 @@
import path from 'node:path';
import { emitRequestProgress } from '../../request/progress.ts';
import type { DaemonResponse } from '../types.ts';
import type { ReplaySuiteTestFailed, ReplaySuiteTestResult } from '@agent-device/contracts/replay';
import type { ReplayTestProgressEvent } from '@agent-device/contracts/progress';
import {
buildReplayTestArtifactSlug,
materializeReplayTestAttemptArtifacts,
@@ -12,24 +12,38 @@ import {
buildReplayTestSessionName,
type ReplayTestRunEntry,
} from './session-test-discovery.ts';
import { isReplayInfrastructureFailure } from './session-test-infrastructure.ts';
import { runReplayTestAttempt } from './session-test-runtime.ts';
import type { ReplayTestRuntimeDependencies } from './session-test-types.ts';
import type {
ReplayTestAttemptOutcome,
ReplayTestRuntimeDependencies,
} from './session-test-types.ts';
import type { ReplayTestShardContext } from './session-test-sharding.ts';
import { isRequestCanceled } from '../../request/cancel.ts';
import { readSnapshotDiagnosticsSummary } from '@agent-device/contracts/capture';
type ReplayTestCaseResult = Extract<ReplaySuiteTestResult, { status: 'passed' | 'failed' }>;
type ReplayTestAttemptFailure = NonNullable<
Extract<ReplaySuiteTestResult, { status: 'passed' }>['attemptFailures']
>[number];
/**
* A finished test case plus the one scheduling fact the public result cannot carry: whether the
* final attempt failed for environmental reasons, which stops the suite instead of continuing.
*/
export type ReplayTestCaseReport = {
result: ReplayTestCaseResult;
infrastructure: boolean;
};
export async function runReplayTestCase(
params: ReplayTestCaseParams,
): Promise<ReplayTestCaseResult> {
): Promise<ReplayTestCaseReport> {
const context = buildReplayTestCaseContext(params);
const outcome = await runReplayTestCaseAttempts(params, context);
return buildReplayTestCaseResult(params, context, outcome);
return {
result: buildReplayTestCaseResult(params, context, outcome),
infrastructure:
outcome.finalOutcome?.status === 'failed' && outcome.finalOutcome.infrastructure,
};
}
type ReplayTestCaseParams = {
@@ -54,14 +68,14 @@ type ReplayTestCaseContext = {
};
type ReplayTestAttemptResult = {
response: DaemonResponse;
outcome: ReplayTestAttemptOutcome;
sessionName: string;
attempt: number;
durationMs: number;
};
type ReplayTestCaseOutcome = {
finalResponse?: DaemonResponse;
finalOutcome?: ReplayTestAttemptOutcome;
finalSessionName: string;
attempts: number;
finalAttemptDurationMs: number;
@@ -97,7 +111,7 @@ async function runReplayTestCaseAttempts(
if (isRequestCanceled(params.requestId)) break;
const attempt = await runSingleReplayTestAttempt(params, context, attemptIndex);
updateReplayTestCaseOutcome(outcome, attempt);
if (shouldStopReplayTestAttempts(params, attempt.response, attemptIndex)) break;
if (shouldStopReplayTestAttempts(params, attempt.outcome, attemptIndex)) break;
emitReplayTestRetryProgress(params, context, attempt);
}
@@ -139,8 +153,19 @@ async function runSingleReplayTestAttempt(
attemptIndex,
shardIndex: shard?.shardIndex,
});
const attemptProgress: ReplayTestAttemptProgressContext = {
file: entry.path,
title: entry.title,
index: suiteIndex,
total: suiteTotal,
attempt,
maxAttempts: context.maxAttempts,
session: testSessionName,
artifactsDir: context.testArtifactsDir,
...replayTestProgressShardMetadata(shard),
};
const response = await runReplayTestAttempt({
const outcome = await runReplayTestAttempt({
filePath: entry.path,
sessionName: testSessionName,
requestId: attemptRequestId,
@@ -150,16 +175,16 @@ async function runSingleReplayTestAttempt(
target: entry.metadata.target,
artifactsDir: attemptArtifactsDir,
shard,
progress: {
file: entry.path,
title: entry.title,
index: suiteIndex,
total: suiteTotal,
attempt,
maxAttempts: context.maxAttempts,
session: testSessionName,
artifactsDir: context.testArtifactsDir,
...replayTestProgressShardMetadata(shard),
onStep: (step) => {
emitRequestProgress({
type: 'replay-test',
...attemptProgress,
status: 'progress',
stepIndex: step.index,
stepTotal: step.total,
...(step.command !== undefined ? { stepCommand: step.command } : {}),
...(step.value !== undefined ? { stepValue: step.value } : {}),
});
},
runReplay: params.runReplay,
cleanupSession: params.cleanupSession,
@@ -167,16 +192,31 @@ async function runSingleReplayTestAttempt(
});
const durationMs = Date.now() - startedAt;
materializeReplayTestAttemptArtifacts({
response,
outcome,
filePath: entry.path,
sessionName: testSessionName,
attempts: attempt,
maxAttempts: context.maxAttempts,
attemptArtifactsDir,
});
return { response, sessionName: testSessionName, attempt, durationMs };
return { outcome, sessionName: testSessionName, attempt, durationMs };
}
/** The per-attempt reporter context every progress event for that attempt shares. */
type ReplayTestAttemptProgressContext = Omit<
ReplayTestProgressEvent,
| 'type'
| 'status'
| 'stepIndex'
| 'stepTotal'
| 'stepCommand'
| 'stepValue'
| 'durationMs'
| 'retrying'
| 'message'
| 'hint'
>;
function emitReplayTestStartProgress(
params: ReplayTestCaseParams,
context: ReplayTestCaseContext,
@@ -201,27 +241,27 @@ function updateReplayTestCaseOutcome(
outcome: ReplayTestCaseOutcome,
attempt: ReplayTestAttemptResult,
): void {
outcome.finalResponse = attempt.response;
outcome.finalOutcome = attempt.outcome;
outcome.finalSessionName = attempt.sessionName;
outcome.attempts = attempt.attempt;
outcome.finalAttemptDurationMs = attempt.durationMs;
if (attempt.response.ok) return;
if (attempt.outcome.status === 'passed') return;
outcome.attemptFailures.push({
attempt: attempt.attempt,
message: attempt.response.error.message,
message: attempt.outcome.error.message,
durationMs: attempt.durationMs,
});
}
function shouldStopReplayTestAttempts(
params: ReplayTestCaseParams,
response: DaemonResponse,
outcome: ReplayTestAttemptOutcome,
attemptIndex: number,
): boolean {
return (
response.ok ||
outcome.status === 'passed' ||
isRequestCanceled(params.requestId) ||
isReplayInfrastructureFailure(response) ||
outcome.infrastructure ||
attemptIndex >= params.retries
);
}
@@ -231,7 +271,7 @@ function emitReplayTestRetryProgress(
context: ReplayTestCaseContext,
attempt: ReplayTestAttemptResult,
): void {
if (attempt.response.ok) return;
if (attempt.outcome.status === 'passed') return;
emitRequestProgress({
type: 'replay-test',
file: params.entry.path,
@@ -243,8 +283,8 @@ function emitReplayTestRetryProgress(
maxAttempts: context.maxAttempts,
durationMs: attempt.durationMs,
retrying: true,
message: attempt.response.error.message,
hint: attempt.response.error.hint,
message: attempt.outcome.error.message,
hint: attempt.outcome.error.hint,
session: attempt.sessionName,
artifactsDir: context.testArtifactsDir,
...replayTestProgressShardMetadata(params.shard),
@@ -257,7 +297,7 @@ function buildReplayTestCaseResult(
outcome: ReplayTestCaseOutcome,
): ReplayTestCaseResult {
const durationMs = Date.now() - context.testStartedAt;
if (outcome.finalResponse?.ok) {
if (outcome.finalOutcome?.status === 'passed') {
return buildReplayTestPassedResult(params, context, outcome, durationMs);
}
return buildReplayTestFailedResult(params, context, outcome, durationMs);
@@ -270,8 +310,8 @@ function buildReplayTestPassedResult(
durationMs: number,
): Extract<ReplaySuiteTestResult, { status: 'passed' }> {
const { entry, suiteIndex, suiteTotal, shard } = params;
const response = outcome.finalResponse;
if (!response?.ok) throw new Error('Expected passing replay test response.');
const attemptOutcome = outcome.finalOutcome;
if (attemptOutcome?.status !== 'passed') throw new Error('Expected passing replay test outcome.');
emitRequestProgress({
type: 'replay-test',
file: entry.path,
@@ -295,9 +335,12 @@ function buildReplayTestPassedResult(
finalAttemptDurationMs: outcome.finalAttemptDurationMs,
attempts: outcome.attempts,
artifactsDir: context.testArtifactsDir,
...replayTestResponseMetrics(response),
...replayTestWarningsResultMetadata(response.data?.warnings),
...replayTestSnapshotDiagnosticsResultMetadata(response.data?.snapshotDiagnostics),
replayed: attemptOutcome.replayed,
healed: attemptOutcome.healed,
...(attemptOutcome.warnings.length > 0 ? { warnings: [...attemptOutcome.warnings] } : {}),
...(attemptOutcome.snapshotDiagnostics
? { snapshotDiagnostics: attemptOutcome.snapshotDiagnostics }
: {}),
...replayTestShardResultMetadata(shard),
...(outcome.attemptFailures.length > 0 ? { attemptFailures: outcome.attemptFailures } : {}),
};
@@ -310,7 +353,11 @@ function buildReplayTestFailedResult(
durationMs: number,
): Extract<ReplaySuiteTestResult, { status: 'failed' }> {
const { entry, suiteIndex, suiteTotal, shard } = params;
const error = replayTestFailureError(outcome.finalResponse);
const attemptOutcome = outcome.finalOutcome;
const error =
attemptOutcome?.status === 'failed'
? attemptOutcome.error
: { code: 'COMMAND_FAILED', message: 'Unknown replay test failure' };
emitRequestProgress({
type: 'replay-test',
file: entry.path,
@@ -336,50 +383,13 @@ function buildReplayTestFailedResult(
attempts: outcome.attempts,
artifactsDir: context.testArtifactsDir,
error,
...replayTestSnapshotDiagnosticsResultMetadata(
readReplayResponseSnapshotDiagnostics(outcome.finalResponse),
),
...(attemptOutcome?.snapshotDiagnostics
? { snapshotDiagnostics: attemptOutcome.snapshotDiagnostics }
: {}),
...replayTestShardResultMetadata(shard),
};
}
function replayTestFailureError(
response: DaemonResponse | undefined,
): Extract<ReplaySuiteTestResult, { status: 'failed' }>['error'] {
if (response && !response.ok) return response.error;
return { code: 'COMMAND_FAILED', message: 'Unknown replay test failure' };
}
function replayTestResponseMetrics(
response: Extract<DaemonResponse, { ok: true }>,
): Pick<Extract<ReplaySuiteTestResult, { status: 'passed' }>, 'replayed' | 'healed'> {
return {
replayed: typeof response.data?.replayed === 'number' ? response.data.replayed : 0,
healed: typeof response.data?.healed === 'number' ? response.data.healed : 0,
};
}
function replayTestWarningsResultMetadata(
warnings: unknown,
): Pick<Extract<ReplaySuiteTestResult, { status: 'passed' }>, 'warnings'> {
if (!Array.isArray(warnings)) return {};
const filtered = warnings.filter((entry): entry is string => typeof entry === 'string');
return filtered.length > 0 ? { warnings: filtered } : {};
}
function replayTestSnapshotDiagnosticsResultMetadata(
value: unknown,
): Pick<ReplayTestCaseResult, 'snapshotDiagnostics'> {
const snapshotDiagnostics = readSnapshotDiagnosticsSummary(value);
return snapshotDiagnostics ? { snapshotDiagnostics } : {};
}
function readReplayResponseSnapshotDiagnostics(response: DaemonResponse | undefined): unknown {
return response?.ok
? response.data?.snapshotDiagnostics
: response?.error.details?.snapshotDiagnostics;
}
function replayTestShardResultMetadata(
shard: ReplayTestShardContext | undefined,
): Pick<ReplaySuiteTestFailed, 'shardIndex' | 'shardCount' | 'deviceId' | 'deviceName'> {
@@ -0,0 +1,63 @@
import { readSnapshotDiagnosticsSummary } from '@agent-device/contracts/capture';
import type { DaemonResponse } from '../types.ts';
import { isReplayInfrastructureFailure } from './session-test-infrastructure.ts';
import type { ReplayTestAttemptFailed, ReplayTestAttemptOutcome } from './session-test-types.ts';
/**
* The one place a `DaemonResponse` becomes a neutral replay-test attempt outcome (#1478 P3).
*
* The scheduler owns retries, fail-fast, artifacts, and result aggregation, but it must not
* read a daemon response shape to do so: `ok`, `data`, and `error.details` are the daemon's
* projection vocabulary, not the scheduler's. Everything the scheduler actually consumes is
* pulled out here into an explicit tagged value, including the infrastructure verdict, which
* needs platform boot-diagnostic vocabulary the scheduler may not import.
*/
export function toReplayTestAttemptOutcome(response: DaemonResponse): ReplayTestAttemptOutcome {
if (!response.ok) {
return {
status: 'failed',
error: response.error,
artifactPaths: readArtifactPaths(response.error.details?.artifactPaths),
infrastructure: isReplayInfrastructureFailure(response),
...snapshotDiagnostics(response.error.details?.snapshotDiagnostics),
};
}
const data = response.data as Record<string, unknown> | undefined;
return {
status: 'passed',
replayed: typeof data?.replayed === 'number' ? data.replayed : 0,
healed: typeof data?.healed === 'number' ? data.healed : 0,
warnings: readStringArray(data?.warnings),
artifactPaths: readArtifactPaths(data?.artifactPaths),
...snapshotDiagnostics(data?.snapshotDiagnostics),
};
}
/**
* Attempt finalization reports only failure: a successful finalization has nothing the
* scheduler can act on, and `undefined` is what its port already means by "nothing to report".
*/
export function toReplayTestFinalizeFailure(
response: DaemonResponse | undefined,
): ReplayTestAttemptFailed | undefined {
if (!response || response.ok) return undefined;
const outcome = toReplayTestAttemptOutcome(response);
return outcome.status === 'failed' ? outcome : undefined;
}
function readStringArray(value: unknown): readonly string[] {
if (!Array.isArray(value)) return [];
return value.filter((entry): entry is string => typeof entry === 'string');
}
/** Artifact paths are copied by name, so the same path twice would collide on the copy. */
function readArtifactPaths(value: unknown): readonly string[] {
return [...new Set(readStringArray(value))];
}
function snapshotDiagnostics(
value: unknown,
): Pick<ReplayTestAttemptOutcome, 'snapshotDiagnostics'> {
const summary = readSnapshotDiagnosticsSummary(value);
return summary ? { snapshotDiagnostics: summary } : {};
}
+72 -76
View File
@@ -9,15 +9,14 @@ import {
markRequestCanceled,
registerRequestAbort,
} from '../../request/cancel.ts';
import {
type ReplayTestActionProgressContext,
withReplayTestActionProgress,
} from '../../request/progress.ts';
import type { DaemonResponse } from '../types.ts';
import type { ReplayScriptMetadata } from '../../replay/script.ts';
import type {
ReplayTestRunReplayParams,
ReplayTestRuntimeDependencies,
import {
replayTestAttemptFailure,
type ReplayTestAttemptFailed,
type ReplayTestAttemptOutcome,
type ReplayTestAttemptStepSink,
type ReplayTestRunReplayParams,
type ReplayTestRuntimeDependencies,
} from './session-test-types.ts';
const REPLAY_TIMEOUT_CLEANUP_GRACE_MS = 2_000;
@@ -36,9 +35,9 @@ export async function runReplayTestAttempt(
target?: ReplayScriptMetadata['target'];
artifactsDir?: string;
shard?: ReplayTestRunReplayParams['shard'];
progress?: ReplayTestActionProgressContext;
onStep?: ReplayTestAttemptStepSink;
} & ReplayTestRuntimeDependencies,
): Promise<DaemonResponse> {
): Promise<ReplayTestAttemptOutcome> {
const {
filePath,
sessionName,
@@ -49,7 +48,7 @@ export async function runReplayTestAttempt(
target,
artifactsDir,
shard,
progress,
onStep,
runReplay,
cleanupSession,
finalizeAttempt,
@@ -59,7 +58,7 @@ export async function runReplayTestAttempt(
const artifactPaths = new Set<string>();
let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
let timedOut = false;
let response: DaemonResponse | undefined;
let outcome: ReplayTestAttemptOutcome | undefined;
const attemptStartedAt = Date.now();
const tracePath = prepareReplayTestTimingTrace({
artifactsDir,
@@ -71,43 +70,34 @@ export async function runReplayTestAttempt(
platform,
target,
});
const replayPromise = withReplayTestActionProgress(
progress,
async () =>
await runReplay({
filePath,
sessionName,
platform,
target,
requestId,
artifactsDir,
artifactPaths,
tracePath,
shard,
}),
)
.catch((error) => {
const appErr = normalizeError(error);
return {
ok: false,
error: appErr,
} satisfies DaemonResponse;
})
const replayPromise = runReplay({
filePath,
sessionName,
platform,
target,
requestId,
artifactsDir,
artifactPaths,
tracePath,
shard,
onStep,
})
.catch((error) => replayTestAttemptFailure({ error: normalizeError(error) }))
.finally(() => {
clearParentAbortRelay();
clearRequestCanceled(requestId);
});
try {
response =
outcome =
typeof timeoutMs === 'number'
? await Promise.race([
replayPromise,
new Promise<DaemonResponse>((resolve) => {
new Promise<ReplayTestAttemptOutcome>((resolve) => {
timeoutHandle = setTimeout(() => {
timedOut = true;
markRequestCanceled(requestId);
resolve(createReplayTestTimeoutResponse(timeoutMs, [...artifactPaths]));
resolve(createReplayTestTimeoutOutcome(timeoutMs, [...artifactPaths]));
}, timeoutMs);
}),
])
@@ -116,17 +106,17 @@ export async function runReplayTestAttempt(
type: 'replay_test_attempt_stop',
ts: new Date().toISOString(),
session: sessionName,
ok: response.ok,
ok: outcome.status === 'passed',
timedOut,
durationMs: Date.now() - attemptStartedAt,
errorCode: response.ok ? undefined : response.error.code,
errorCode: outcome.status === 'passed' ? undefined : outcome.error.code,
});
} finally {
if (timeoutHandle) clearTimeout(timeoutHandle);
if (timedOut) {
const settled = await waitForReplayAfterTimeout(replayPromise);
if (!settled) {
markReplayTimeoutCleanupPending(response);
outcome = markReplayTimeoutCleanupPending(outcome);
emitDiagnostic({
level: 'warn',
phase: 'test_timeout_cleanup_race',
@@ -144,17 +134,17 @@ export async function runReplayTestAttempt(
});
}
}
const finalizedResponse = await finalizeReplayTestAttempt({
const finalizeFailure = await finalizeReplayTestAttempt({
finalizeAttempt,
sessionName,
artifactPaths,
artifactsDir,
tracePath,
});
if (response?.ok && finalizedResponse && !finalizedResponse.ok) {
appendReplayTestWarning(
response,
`Replay test finalization failed: ${finalizedResponse.error.message}`,
if (outcome?.status === 'passed' && finalizeFailure) {
outcome = appendReplayTestWarning(
outcome,
`Replay test finalization failed: ${finalizeFailure.error.message}`,
);
}
const cleanupStartedAt = Date.now();
@@ -193,13 +183,10 @@ export async function runReplayTestAttempt(
}
}
return (
response ?? {
ok: false,
error: {
code: 'COMMAND_FAILED',
message: 'Unknown replay test failure',
},
}
outcome ??
replayTestAttemptFailure({
error: { code: 'COMMAND_FAILED', message: 'Unknown replay test failure' },
})
);
}
@@ -224,7 +211,9 @@ function relayReplayTestAbortFromParent(
};
}
async function waitForReplayAfterTimeout(replayPromise: Promise<DaemonResponse>): Promise<boolean> {
async function waitForReplayAfterTimeout(
replayPromise: Promise<ReplayTestAttemptOutcome>,
): Promise<boolean> {
return await Promise.race([
replayPromise.then(() => true),
sleep(REPLAY_TIMEOUT_CLEANUP_GRACE_MS).then(() => false),
@@ -232,7 +221,7 @@ async function waitForReplayAfterTimeout(replayPromise: Promise<DaemonResponse>)
}
async function cleanupSessionAfterLateReplay(params: {
replayPromise: Promise<DaemonResponse>;
replayPromise: Promise<ReplayTestAttemptOutcome>;
cleanupSession: ReplayTestRuntimeDependencies['cleanupSession'];
sessionName: string;
requestId: string;
@@ -264,7 +253,7 @@ async function finalizeReplayTestAttempt(params: {
artifactPaths: Set<string>;
artifactsDir?: string;
tracePath?: string;
}): Promise<DaemonResponse | undefined> {
}): Promise<ReplayTestAttemptFailed | undefined> {
const { finalizeAttempt, sessionName, artifactPaths, artifactsDir, tracePath } = params;
if (!finalizeAttempt) return undefined;
const finalizeStartedAt = Date.now();
@@ -284,9 +273,9 @@ async function finalizeReplayTestAttempt(params: {
type: 'replay_test_finalize_stop',
ts: new Date().toISOString(),
session: sessionName,
ok: finalized?.ok ?? true,
ok: finalized === undefined,
durationMs: Date.now() - finalizeStartedAt,
errorCode: finalized?.ok === false ? finalized.error.code : undefined,
errorCode: finalized?.error.code,
});
return finalized;
} catch (error) {
@@ -307,28 +296,35 @@ async function finalizeReplayTestAttempt(params: {
error: appErr.message,
},
});
return { ok: false, error: appErr };
return replayTestAttemptFailure({ error: appErr });
}
}
function markReplayTimeoutCleanupPending(response: DaemonResponse | undefined): void {
if (!response || response.ok) return;
response.error.details = {
...(response.error.details ?? {}),
reason: REPLAY_TIMEOUT_CLEANUP_PENDING_REASON,
timeoutCleanupPending: true,
function markReplayTimeoutCleanupPending(
outcome: ReplayTestAttemptOutcome | undefined,
): ReplayTestAttemptOutcome | undefined {
if (!outcome || outcome.status === 'passed') return outcome;
return {
...outcome,
// The abandoned replay is still running against the device, so the attempt is an
// environmental failure rather than a test failure: the suite must stop rather than retry.
infrastructure: true,
error: {
...outcome.error,
details: {
...(outcome.error.details ?? {}),
reason: REPLAY_TIMEOUT_CLEANUP_PENDING_REASON,
timeoutCleanupPending: true,
},
},
};
}
function appendReplayTestWarning(
response: Extract<DaemonResponse, { ok: true }>,
outcome: Extract<ReplayTestAttemptOutcome, { status: 'passed' }>,
warning: string,
): void {
const data = (response.data ??= {});
const warnings = Array.isArray(data.warnings)
? data.warnings.filter((entry): entry is string => typeof entry === 'string')
: [];
data.warnings = [...warnings, warning];
): ReplayTestAttemptOutcome {
return { ...outcome, warnings: [...outcome.warnings, warning] };
}
function prepareReplayTestTimingTrace(params: {
@@ -377,12 +373,12 @@ export function appendReplayTestTimingEvent(
fs.appendFileSync(tracePath, `${JSON.stringify(event)}\n`);
}
function createReplayTestTimeoutResponse(
function createReplayTestTimeoutOutcome(
timeoutMs: number,
artifactPaths: string[] = [],
): DaemonResponse {
return {
ok: false,
): ReplayTestAttemptOutcome {
return replayTestAttemptFailure({
artifactPaths,
error: {
code: 'COMMAND_FAILED',
message: `TIMEOUT after ${timeoutMs}ms`,
@@ -394,5 +390,5 @@ function createReplayTestTimeoutResponse(
artifactPaths,
},
},
};
});
}
+77 -3
View File
@@ -1,7 +1,58 @@
import type { DaemonResponse } from '../types.ts';
import type { ReplaySuiteTestFailed } from '@agent-device/contracts/replay';
import type { SnapshotDiagnosticsSummary } from '@agent-device/contracts/capture';
import type { ReplayScriptMetadata } from '../../replay/script.ts';
import type { ReplayTestShardContext } from './session-test-sharding.ts';
/**
* One execution step an engine reports while an attempt runs (#1478 P3, finding 1).
*
* Step payloads originate below the attempt boundary, inside engine execution, and used to
* reach the reporter through a request-global `AsyncLocalStorage` seeded per attempt. The
* scheduler now hands each attempt a narrow sink instead, so step progress is an explicit
* per-attempt port with two real adapters (native `.ad` and Maestro) rather than ambient
* request state the scheduler cannot see.
*/
export type ReplayTestAttemptStep = {
index: number;
total: number;
command?: string;
value?: string;
};
export type ReplayTestAttemptStepSink = (step: ReplayTestAttemptStep) => void;
/**
* ADR 0010 error fields exactly as the public suite result publishes them. This is the
* neutral wire error, not `DaemonResponse`: the scheduler never sees a daemon response shape.
*/
export type ReplayTestAttemptError = ReplaySuiteTestFailed['error'];
export type ReplayTestAttemptPassed = {
status: 'passed';
replayed: number;
healed: number;
warnings: readonly string[];
artifactPaths: readonly string[];
snapshotDiagnostics?: SnapshotDiagnosticsSummary;
};
export type ReplayTestAttemptFailed = {
status: 'failed';
error: ReplayTestAttemptError;
artifactPaths: readonly string[];
snapshotDiagnostics?: SnapshotDiagnosticsSummary;
/**
* The host's verdict that this failure is environmental (device/runner/boot) rather than a
* test failure, so retrying and continuing the suite cannot help. Classification needs
* platform boot-diagnostic vocabulary, which the scheduler must not import, so the host
* tags the outcome and the scheduler only reads the tag.
*/
infrastructure: boolean;
};
/** Every expected attempt state resolves as a tagged outcome; nothing throws across the seam. */
export type ReplayTestAttemptOutcome = ReplayTestAttemptPassed | ReplayTestAttemptFailed;
export type ReplayTestRunReplayParams = {
filePath: string;
sessionName: string;
@@ -12,21 +63,44 @@ export type ReplayTestRunReplayParams = {
artifactPaths?: Set<string>;
tracePath?: string;
shard?: ReplayTestShardContext;
onStep?: ReplayTestAttemptStepSink;
};
export type ReplayTestRunReplay = (params: ReplayTestRunReplayParams) => Promise<DaemonResponse>;
export type ReplayTestRunReplay = (
params: ReplayTestRunReplayParams,
) => Promise<ReplayTestAttemptOutcome>;
export type ReplayTestCleanupSession = (sessionName: string) => Promise<void>;
/**
* Runs after the attempt settles and before cleanup. Returns a failure outcome when
* finalization itself failed, or `undefined` when there was nothing to finalize.
*/
export type ReplayTestFinalizeAttempt = (params: {
sessionName: string;
artifactPaths: Set<string>;
artifactsDir?: string;
tracePath?: string;
}) => Promise<DaemonResponse | undefined>;
}) => Promise<ReplayTestAttemptFailed | undefined>;
export type ReplayTestRuntimeDependencies = {
runReplay: ReplayTestRunReplay;
cleanupSession: ReplayTestCleanupSession;
finalizeAttempt?: ReplayTestFinalizeAttempt;
};
/** Neutral failure outcome helper; keeps timeout/unknown construction in one place. */
export function replayTestAttemptFailure(params: {
error: ReplayTestAttemptError;
artifactPaths?: readonly string[];
infrastructure?: boolean;
snapshotDiagnostics?: SnapshotDiagnosticsSummary;
}): ReplayTestAttemptFailed {
return {
status: 'failed',
error: params.error,
artifactPaths: params.artifactPaths ?? [],
infrastructure: params.infrastructure ?? false,
...(params.snapshotDiagnostics ? { snapshotDiagnostics: params.snapshotDiagnostics } : {}),
};
}
+10 -11
View File
@@ -15,8 +15,7 @@ import {
resolveReplayTestRetries,
resolveReplayTestTimeout,
} from './session-test-discovery.ts';
import { isReplayInfrastructureFailure } from './session-test-infrastructure.ts';
import { runReplayTestCase } from './session-test-attempt.ts';
import { runReplayTestCase, type ReplayTestCaseReport } from './session-test-attempt.ts';
import type { ReplayTestRuntimeDependencies } from './session-test-types.ts';
import {
buildReplayTestShardPlan,
@@ -309,7 +308,7 @@ async function runReplayTestEntriesInDiscoveryOrder(
continue;
}
executed += 1;
const result = await runReplayTestCase({
const report = await runReplayTestCase({
entry,
sessionName,
suiteInvocationId,
@@ -325,8 +324,8 @@ async function runReplayTestEntriesInDiscoveryOrder(
cleanupSession,
finalizeAttempt,
});
results.push(result);
if (shouldStopReplayTestExecution(result, flags, requestId)) break;
results.push(report.result);
if (shouldStopReplayTestExecution(report, flags, requestId)) break;
}
return results;
}
@@ -362,7 +361,7 @@ async function runReplayTestEntries(
for (const [entryIndex, queued] of entries.entries()) {
if (isRequestCanceled(requestId)) break;
const { entry, suiteIndex } = queued;
const result = await runReplayTestCase({
const report = await runReplayTestCase({
entry,
sessionName,
suiteInvocationId,
@@ -379,21 +378,21 @@ async function runReplayTestEntries(
cleanupSession,
finalizeAttempt,
});
results.push(result);
if (shouldStopReplayTestExecution(result, flags, requestId)) break;
results.push(report.result);
if (shouldStopReplayTestExecution(report, flags, requestId)) break;
}
return results;
}
function shouldStopReplayTestExecution(
result: ReplaySuiteTestResult,
report: ReplayTestCaseReport,
flags: DaemonRequest['flags'],
requestId: string | undefined,
): boolean {
return (
isRequestCanceled(requestId) ||
(flags?.failFast === true && result.status === 'failed') ||
isReplayInfrastructureFailure(result)
(flags?.failFast === true && report.result.status === 'failed') ||
report.infrastructure
);
}
+1 -1
View File
@@ -1,5 +1,5 @@
import { normalizeError, type NormalizedError } from '@agent-device/kernel/errors';
import { formatReplayDivergenceReport } from '../replay/divergence.ts';
import { formatReplayDivergenceReport } from '@agent-device/contracts/divergence';
/**
* Shared MCP error normalization + text rendering (executor and router
@@ -7,7 +7,7 @@
// `in`, so a key that exists today with an `undefined` value is part of the contract.
import assert from 'node:assert/strict';
import { test } from 'vitest';
import type { RequestProgressEvent } from '../../../../request/progress.ts';
import type { RequestProgressEvent } from '@agent-device/contracts/progress';
import { toReplayTestReporterProgressEvent } from '../progress.ts';
const SESSION = 'default:test:req-7:1-checkout:attempt-2';
+1 -1
View File
@@ -6,7 +6,7 @@ import {
} from '../progress.ts';
import { formatDurationSeconds } from '../../../utils/duration-format.ts';
import { colorize, supportsColor } from '../../../utils/output.ts';
import { formatReplayDivergenceReport } from '../../divergence.ts';
import { formatReplayDivergenceReport } from '@agent-device/contracts/divergence';
import type {
ReplayTestReporter,
ReplayTestReporterContext,
+1 -1
View File
@@ -1,4 +1,4 @@
import type { RequestProgressEvent } from '../../../request/progress.ts';
import type { RequestProgressEvent } from '@agent-device/contracts/progress';
import type { ReplayTestReporterProgressEvent } from './types.ts';
export function toReplayTestReporterProgressEvent(
+1 -1
View File
@@ -1,5 +1,5 @@
import type { ReplaySuiteResult } from '@agent-device/contracts/replay';
import type { RequestProgressEvent } from '../../../request/progress.ts';
import type { RequestProgressEvent } from '@agent-device/contracts/progress';
import { createCustomReplayTestReporter } from './custom.ts';
import { createDefaultReplayTestReporter } from './default.ts';
import { getReplayTestExitCode } from './format.ts';
+1 -1
View File
@@ -1,4 +1,4 @@
import type { RequestProgressEvent } from '../../request/progress.ts';
import type { RequestProgressEvent } from '@agent-device/contracts/progress';
import type { ReplaySuiteResult } from '@agent-device/contracts/replay';
import {
getReplayTestReporterExitCode,
+5 -73
View File
@@ -1,69 +1,12 @@
import { AsyncLocalStorage } from 'node:async_hooks';
import type { RequestProgressEvent, RequestProgressSink } from '@agent-device/contracts/progress';
export type ReplayTestSuiteProgressEvent = {
type: 'replay-test-suite';
status: 'start';
total: number;
runnable: number;
skipped: number;
artifactsDir: string;
shardMode?: 'all' | 'split';
shardCount?: number;
};
export type ReplayTestProgressEvent = {
type: 'replay-test';
file: string;
title?: string;
status: 'start' | 'progress' | 'pass' | 'fail' | 'skip';
index: number;
total: number;
stepIndex?: number;
stepTotal?: number;
stepCommand?: string;
stepValue?: string;
attempt?: number;
maxAttempts?: number;
durationMs?: number;
retrying?: boolean;
message?: string;
hint?: string;
session?: string;
artifactsDir?: string;
shardIndex?: number;
shardCount?: number;
deviceId?: string;
deviceName?: string;
};
export type CommandProgressEvent = {
type: 'command';
status: 'progress';
message: string;
};
export type RequestProgressEvent =
| ReplayTestSuiteProgressEvent
| ReplayTestProgressEvent
| CommandProgressEvent;
export type RequestProgressSink = (event: RequestProgressEvent) => void;
export type ReplayTestActionProgressContext = Omit<
ReplayTestProgressEvent,
| 'type'
| 'status'
| 'stepIndex'
| 'stepTotal'
| 'stepCommand'
| 'stepValue'
| 'durationMs'
| 'retrying'
| 'message'
>;
// The event vocabulary is a wire contract shared with the CLI reporter path, so it lives in
// `@agent-device/contracts/progress`. This module owns only the request-global plumbing that
// carries it: the per-request sink and its AsyncLocalStorage binding.
export type { RequestProgressEvent, RequestProgressSink } from '@agent-device/contracts/progress';
const requestProgress = new AsyncLocalStorage<RequestProgressSink | undefined>();
const replayTestActionProgress = new AsyncLocalStorage<
ReplayTestActionProgressContext | undefined
>();
export async function withRequestProgressSink<T>(
sink: RequestProgressSink | undefined,
@@ -75,14 +18,3 @@ export async function withRequestProgressSink<T>(
export function emitRequestProgress(event: RequestProgressEvent): void {
requestProgress.getStore()?.(event);
}
export async function withReplayTestActionProgress<T>(
context: ReplayTestActionProgressContext | undefined,
run: () => Promise<T>,
): Promise<T> {
return await replayTestActionProgress.run(context, run);
}
export function readReplayTestActionProgress(): ReplayTestActionProgressContext | undefined {
return replayTestActionProgress.getStore();
}
+1 -1
View File
@@ -5,7 +5,7 @@ import {
} from './android-helper-snapshot-presentation.ts';
import { AppError, normalizeError, type NormalizedError } from '@agent-device/kernel/errors';
import { detectPossibleRepeatedNavSubtree } from './repeated-nav-subtree.ts';
import { formatReplayDivergenceReport } from '../replay/divergence.ts';
import { formatReplayDivergenceReport } from '@agent-device/contracts/divergence';
import { buildSnapshotDisplayLines, formatSnapshotLine } from '../snapshot/snapshot-lines.ts';
import {
isSnapshotBackend,