diff --git a/packages/replay-test/src/internal/__tests__/session-test-runtime.test.ts b/packages/replay-test/src/internal/__tests__/session-test-runtime.test.ts index 1a45bb36f..60481798a 100644 --- a/packages/replay-test/src/internal/__tests__/session-test-runtime.test.ts +++ b/packages/replay-test/src/internal/__tests__/session-test-runtime.test.ts @@ -154,6 +154,33 @@ test('runReplayTestAttempt keeps a passing replay passed when finalization fails expect(cleanupSession).toHaveBeenCalledWith('default:test:pass'); }); +test('runReplayTestAttempt marks a failed cleanup as infrastructure so the scheduler cannot retry into its session', async () => { + const cleanupSession = vi.fn(async () => { + throw new Error('macOS session still owns host-macos-local'); + }); + + const result = await runReplayTestAttempt({ + filePath: '01-cleanup-failure.ad', + sessionName: 'default:test:cleanup-failure', + requestId: 'req-cleanup-failure', + runReplay: async () => ({ + status: 'failed', + error: { code: 'COMMAND_FAILED', message: 'open "System Settings" failed' }, + artifactPaths: [], + infrastructure: false, + }), + cleanupSession, + ...trackCancellation(), + }); + + expect(result.status).toBe('failed'); + if (result.status === 'failed') { + expect(result.error.message).toBe('open "System Settings" failed'); + expect(result.infrastructure).toBe(true); + } + expect(cleanupSession).toHaveBeenCalledWith('default:test:cleanup-failure'); +}); + // #1478 P3 characterization: attempt finalization happens before cleanup, always, and the // timing trace is the durable evidence of that order. P3 moves this `finally` orchestration // into replay-test, so the order and the recorded event names are pinned here as shipped. diff --git a/packages/replay-test/src/internal/session-test-runtime.ts b/packages/replay-test/src/internal/session-test-runtime.ts index ced7275d5..ecdf22b2f 100644 --- a/packages/replay-test/src/internal/session-test-runtime.ts +++ b/packages/replay-test/src/internal/session-test-runtime.ts @@ -190,6 +190,7 @@ export async function runReplayTestAttempt( error: appErr.message, }, }); + outcome = markReplayTestCleanupFailure(outcome, appErr, artifactPaths); } } return ( @@ -200,6 +201,27 @@ export async function runReplayTestAttempt( ); } +/** + * Cleanup is the ownership handoff between attempts. A failed cleanup leaves the prior attempt's + * device/session state unknown, so the scheduler must stop instead of retrying into a live claim. + * Keep an action failure as the user-facing cause when one already exists; the infrastructure tag + * is the scheduler-only fact that prevents a contended follow-up attempt. + */ +function markReplayTestCleanupFailure( + outcome: ReplayTestAttemptOutcome | undefined, + cleanupError: ReturnType, + artifactPaths: Set, +): ReplayTestAttemptOutcome { + if (outcome?.status === 'failed') { + return { ...outcome, infrastructure: true }; + } + return replayTestAttemptFailure({ + error: cleanupError, + artifactPaths: [...artifactPaths], + infrastructure: true, + }); +} + async function waitForReplayAfterTimeout( replayPromise: Promise, ): Promise { diff --git a/src/daemon/handlers/__tests__/session-test-suite.test.ts b/src/daemon/handlers/__tests__/session-test-suite.test.ts index a5a5866b6..418696a82 100644 --- a/src/daemon/handlers/__tests__/session-test-suite.test.ts +++ b/src/daemon/handlers/__tests__/session-test-suite.test.ts @@ -17,7 +17,7 @@ vi.mock('../snapshot-interactor-capture.ts', () => ({ import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { handleSessionCommands } from './session-command-harness.ts'; +import { handleSessionCommands, mockInspectDeviceRuntimeFacts } from './session-command-harness.ts'; import { SessionStore } from '../../session-store.ts'; import type { DaemonRequest, DaemonResponse, DaemonResponseData } from '../../types.ts'; import { withRequestProgressSink } from '../../../request/progress.ts'; @@ -29,7 +29,10 @@ import { } from '../../../request/cancel.ts'; import { withTestDeviceInventoryProvider as withDeviceInventoryProvider } from '../../../__tests__/test-utils/device-inventory-gateways.ts'; import type { DeviceInfo } from '@agent-device/kernel/device'; -import { makeAndroidSession } from '../../../__tests__/test-utils/session-factories.ts'; +import { + makeAndroidSession, + makeMacOsSession, +} from '../../../__tests__/test-utils/session-factories.ts'; function makeSessionStore(): SessionStore { const root = mkdtempForTestSync('agent-device-session-test-suite-'); @@ -324,6 +327,56 @@ test('test emits progress when attempts retry and pass', async () => { }); }); +test('test stops before retrying when a rejected close leaves the prior macOS session owning its device', async () => { + const sessionStore = makeSessionStore(); + const root = mkdtempForTestSync('agent-device-test-suite-macos-cleanup-failure-'); + fs.writeFileSync( + path.join(root, '01-system-settings.ad'), + 'context platform=macos\nopen "System Settings"\n', + ); + + let replayAttempts = 0; + const response = await handleSessionCommands({ + req: { + token: 't', + session: 'default', + command: 'test', + positionals: [root], + meta: { cwd: root, requestId: 'suite-macos-cleanup-failure' }, + flags: { retries: 2 }, + }, + sessionName: 'default', + logPath: path.join(os.tmpdir(), 'daemon.log'), + sessionStore, + inspectFacts: async (device) => { + const facts = await mockInspectDeviceRuntimeFacts(device); + return { + ...facts, + operations: { + ...facts.operations, + closeApplication: { available: false, reason: 'owner-capability-missing' }, + }, + }; + }, + invoke: async (req) => { + replayAttempts += 1; + sessionStore.set(req.session, makeMacOsSession(req.session)); + return { + ok: false, + error: { code: 'COMMAND_FAILED', message: 'open "System Settings" failed' }, + }; + }, + }); + + const data = expectOkData(response); + expect(replayAttempts).toBe(1); + expect(data.failed).toBe(1); + expect((data.tests as Array>)[0]).toMatchObject({ + status: 'failed', + attempts: 1, + }); +}); + test('test stops retrying after maxAttempts when every attempt fails', async () => { const sessionStore = makeSessionStore(); const root = mkdtempForTestSync('agent-device-test-suite-retry-exhaust-'); diff --git a/src/daemon/handlers/session-test-suite-command.ts b/src/daemon/handlers/session-test-suite-command.ts index e11921823..f6f82c65a 100644 --- a/src/daemon/handlers/session-test-suite-command.ts +++ b/src/daemon/handlers/session-test-suite-command.ts @@ -304,7 +304,7 @@ export async function runReplayTestSuiteCommand( resolveShardTargets: buildReplayTestShardTargetResolver(req.flags), cleanupSession: async (testSessionName) => { if (!sessionStore.get(testSessionName)) return; - await handleCloseCommand({ + const closeResponse = await handleCloseCommand({ req: { token: req.token, session: testSessionName, @@ -320,6 +320,12 @@ export async function runReplayTestSuiteCommand( inspectFacts: params.inspectFacts, bindDevice: params.bindDevice, }); + if (!closeResponse.ok) { + throw new AppError(closeResponse.error.code, closeResponse.error.message, { + ...(closeResponse.error.details ?? {}), + ...(closeResponse.error.hint ? { hint: closeResponse.error.hint } : {}), + }); + } }, }); return outcome.status === 'completed'