From 7cf5f2eb743732552fc60f509d5b088cfaadc583 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 30 Sep 2026 20:49:24 +0200 Subject: [PATCH 01/18] fix(apple-runner): name the lost reply when status cannot place a lost command A command whose reply was lost and whose status probe answers notAccepted (a restarted runner's empty journal) or an unnamed state now fails with details.reason runner_reply_lost beside dispatched unknown, so a caller keys on the typed reason instead of the message before it observes the screen. Tests (fake runner server through runAppleRunnerCommand and the recovery module): - runner-recovery-wiring: press and fill (runner tap and type) accept the command, drop the connection, status answers notAccepted: one dispatched command, error dispatched unknown with reason runner_reply_lost, the session is invalidated, and the next command gets a runner. Mutation: delete `reason: RUNNER_REPLY_LOST_REASON` from the unknown-state error -> both rows fail. - runner-recovery-wiring: a snapshot whose reply is dropped is resent and succeeds. Mutation: declare snapshot with DEFAULT_TRAITS (not read-only) -> fails (the lost read goes to status recovery and is not resent). - runner-command-recovery: notAccepted yields reason runner_reply_lost and dispatched unknown. Mutation: same deletion as above -> fails. --- .../__tests__/runner-command-recovery.test.ts | 19 ++++++- .../__tests__/runner-recovery-wiring.test.ts | 52 +++++++++++++++++++ .../src/runner/runner-command-recovery.ts | 7 +++ 3 files changed, 77 insertions(+), 1 deletion(-) diff --git a/packages/platform-apple/src/runner/__tests__/runner-command-recovery.test.ts b/packages/platform-apple/src/runner/__tests__/runner-command-recovery.test.ts index 24f847f53c..6fea3f0d4b 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-command-recovery.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-command-recovery.test.ts @@ -4,7 +4,10 @@ import { afterEach, test, vi } from 'vitest'; import { AppError } from '@agent-device/kernel/errors'; import { IOS_SIMULATOR } from './device-fixtures.ts'; import type { ExecResult } from '@agent-device/host-kit/command'; -import { handleRunnerTransportErrorAfterCommandSend } from '../runner-command-recovery.ts'; +import { + handleRunnerTransportErrorAfterCommandSend, + RUNNER_REPLY_LOST_REASON, +} from '../runner-command-recovery.ts'; import type { RunnerCommand } from '../runner-contract.ts'; import type { RunnerSession } from '../runner-session.ts'; import { @@ -122,6 +125,20 @@ test('an unknown lifecycle state invalidates the session and says so', async () assert.equal(invalidate.mock.calls[0]?.[1], 'transport_error_after_command_send'); }); +test('notAccepted from a restarted runner fails the lost command as unknown, naming the lost reply', async () => { + const { result, invalidate } = await runRecovery({ + script: [{ kind: 'ok', data: { lifecycleState: 'notAccepted' } }], + }); + + await assert.rejects(result, (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.details?.reason, RUNNER_REPLY_LOST_REASON); + assert.equal(error.details?.dispatched, 'unknown'); + return true; + }); + assert.equal(invalidate.mock.calls.length, 1); +}); + test('a failing status probe retains the invalidation and rethrows the transport error', async () => { const { result, invalidate, transportError } = await runRecovery({ script: [{ kind: 'runnerError', code: 'COMMAND_FAILED', message: 'status probe exploded' }], diff --git a/packages/platform-apple/src/runner/__tests__/runner-recovery-wiring.test.ts b/packages/platform-apple/src/runner/__tests__/runner-recovery-wiring.test.ts index 5e1c7a4294..a8e4ffa432 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-recovery-wiring.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-recovery-wiring.test.ts @@ -10,6 +10,7 @@ import type { RunnerSession } from '../runner-session.ts'; import { appleRunnerTestHost } from '../test-host.ts'; import { withAppleRunnerProvider } from '../runner-provider.ts'; import { classifyRunnerReportedError, type RunnerCommand } from '../runner-contract.ts'; +import { RUNNER_REPLY_LOST_REASON } from '../runner-command-recovery.ts'; import { createRunnerPhaseBudget, requireRunnerPhaseRemainingMs, @@ -160,6 +161,57 @@ test.each(Object.values(LOST_RESPONSE_MUTATION_ROWS))( }, ); +// #3074: the runner restarted between the send and the status probe, so its journal is empty and +// `status` answers `notAccepted`. That is no proof the first send did not run. +test.each(Object.values(LOST_RESPONSE_MUTATION_ROWS))( + 'a $acceptanceCommand whose reply is lost and whose restarted runner answers notAccepted is sent once', + async ({ runnerCommand, request }) => { + server = await startFakeRunnerServer({ + [runnerCommand]: [{ kind: 'hangUp' }, { kind: 'ok', data: {} }], + status: [{ kind: 'ok', data: { lifecycleState: 'notAccepted' } }], + snapshot: [{ kind: 'ok', data: { nodes: [] } }], + }); + const session = seedSession(server.port); + + await assert.rejects(runAppleRunnerCommand(IOS_SIMULATOR, { ...request }), (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.details?.dispatched, 'unknown'); + assert.equal(error.details?.reason, RUNNER_REPLY_LOST_REASON); + return true; + }); + assert.equal( + server.requests.filter((entry) => entry.command === runnerCommand).length, + 1, + `${runnerCommand} is dispatched once`, + ); + assert.deepEqual(invalidateRunnerSessionMock.mock.calls, [ + [session, 'transport_error_after_command_send'], + ]); + + assert.deepEqual(await runAppleRunnerCommand(IOS_SIMULATOR, { command: 'snapshot' }), { + nodes: [], + }); + assert.equal(ensureRunnerSessionMock.mock.calls.length, 2, 'the next command gets a runner'); + }, +); + +test('a read whose reply is lost is resent and succeeds', async () => { + server = await startFakeRunnerServer({ + snapshot: [{ kind: 'hangUp' }, { kind: 'ok', data: { nodes: [] } }], + status: [{ kind: 'ok', data: { lifecycleState: 'notAccepted' } }], + }); + seedSession(server.port); + // The simctl curl route of a ready simulator could not connect, so it sent nothing. + appleRunnerTestHost.update({ + runXcrun: vi.fn(async () => ({ exitCode: 7, stdout: '', stderr: 'curl exited 7' })), + }); + + assert.deepEqual(await runAppleRunnerCommand(IOS_SIMULATOR, { command: 'snapshot' }), { + nodes: [], + }); + assert.equal(server.requests.filter((entry) => entry.command === 'snapshot').length, 2); +}); + // #2965: an inline `status` probe answers while the command it probes may still be executing, so its // own reply must not clear the mutation's outstanding charge. The handoff verdict is asserted through // `resolveRunnerDetachDecision` — the exact gate `detachRunnerSessionForShutdown` consults. Rows come diff --git a/packages/platform-apple/src/runner/runner-command-recovery.ts b/packages/platform-apple/src/runner/runner-command-recovery.ts index 645196db27..b7bf5cd78e 100644 --- a/packages/platform-apple/src/runner/runner-command-recovery.ts +++ b/packages/platform-apple/src/runner/runner-command-recovery.ts @@ -47,6 +47,12 @@ type RunnerReadinessPreflightRecoveryDetails = { const RUNNER_STATUS_RECOVERY_TIMEOUT_MS = 3_000; +/** + * `details.reason` of a failure whose command was written, lost its reply, and has no proof it did + * not run. The command is not resent; the caller observes the screen before acting again. + */ +export const RUNNER_REPLY_LOST_REASON = 'runner_reply_lost'; + export async function handleRunnerTransportErrorAfterCommandSend(params: { device: DeviceInfo; session: RunnerSession; @@ -285,6 +291,7 @@ function handleRunnerCommandStatusRecovery( command: command.command, commandId: command.commandId, lifecycleState, + reason: RUNNER_REPLY_LOST_REASON, recovery: 'lifecycle_state_not_recoverable', hint: unknownLifecycleStateHint(command.command), logPath: options.logPath, From a02141989e2707319c176a8e1353972fdb6c54c9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 30 Sep 2026 20:49:38 +0200 Subject: [PATCH 02/18] fix(apple-runner): restart without resending a command written then lost A connect-shaped failure keeps its restart, but the command is sent again on the restarted runner only with proof the first send could not have run it: the attempt provably wrote nothing (a connect refused before the write, dispatched no), the readiness preflight gave up before the write, or the command is read-only by its runner trait. A mutating command whose first attempt may have written it now fails with reason runner_reply_lost and dispatched unknown after the restart; a restarted runner's journal is empty, so no status answer can prove the first send did not run. A read-only command's restart failure discloses no: a read has no side effect to repeat. dispatch-disclosure.json: ios-runner.transport.written-then-lost keeps unknown with a trigger that says the command is not resent; ios-runner.transport.read-only-written-then-lost is new and discloses no. Tests (runner-lifecycle-dispatch-disclosure, real connect loop over a stubbed fetch and simctl curl exit 28, restart succeeds): - written-then-lost: tap restarts the runner, is not sent on the restarted runner, fails unknown with reason runner_reply_lost. Mutation: make canResendAfterRestart return true -> fails (the tap is replayed and the request succeeds). - read-only-written-then-lost: snapshot is sent again on the restarted runner and its failure discloses no. Mutations: drop the read-only term from canResendAfterRestart -> fails (no resend); drop the read-only branch of discloseRestartDispatch -> fails (unknown). runner-command-retry: four mutating-restart fixtures now carry the proof a real refused connect stamps (dispatched no); without it they would test the removed replay. --- contracts/fixtures/dispatch-disclosure.json | 8 +- .../__tests__/runner-command-retry.test.ts | 12 ++- ...nner-lifecycle-dispatch-disclosure.test.ts | 83 ++++++++++++++++--- .../src/runner/runner-command-recovery.ts | 25 ++++++ .../src/runner/runner-error-classification.ts | 11 ++- .../src/runner/runner-lifecycle.ts | 53 +++++++++--- 6 files changed, 157 insertions(+), 35 deletions(-) diff --git a/contracts/fixtures/dispatch-disclosure.json b/contracts/fixtures/dispatch-disclosure.json index 81564e2a58..d01fa0caec 100644 --- a/contracts/fixtures/dispatch-disclosure.json +++ b/contracts/fixtures/dispatch-disclosure.json @@ -256,9 +256,15 @@ { "id": "ios-runner.transport.written-then-lost", "producer": "ios-runner", - "trigger": "a connect attempt posted the command and then timed out (fetch deadline, simctl curl exit 28); the failure keeps the runner_connect_refused restart verdict, and the restart that replays it failed", + "trigger": "a connect attempt posted a mutating command and then timed out (fetch deadline, simctl curl exit 28); the runner is restarted, and the command is not sent again because nothing proves the first send did not run", "dispatched": "unknown" }, + { + "id": "ios-runner.transport.read-only-written-then-lost", + "producer": "ios-runner", + "trigger": "a connect attempt posted a read-only command and then timed out (fetch deadline, simctl curl exit 28); the runner is restarted and the read is sent again, a read has no side effect, and the resend failed", + "dispatched": "no" + }, { "id": "ios-runner.pre-send.readiness-preflight-after-restart", "producer": "ios-runner", diff --git a/packages/platform-apple/src/runner/__tests__/runner-command-retry.test.ts b/packages/platform-apple/src/runner/__tests__/runner-command-retry.test.ts index 478e65ed06..3ede27bbb7 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-command-retry.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-command-retry.test.ts @@ -51,6 +51,10 @@ import { prepareIosRunner, runAppleRunnerCommand } from '../runner-client.ts'; import { resetRunnerRecycleLedgerForTests } from '../runner-recycle-ledger.ts'; import type { RunnerXctestrunArtifact } from '../runner-xctestrun.ts'; +/** Every connect attempt was refused before a byte was written, so a restart may resend the command. */ +const unwrittenConnectRefusal = (): AppError => + runnerConnectFailure('runner_connect_refused', undefined, { dispatched: 'no' }); + const requestCancellation = createTestRequestCancellation(); const { markRequestCanceled, clearRequestCanceled, isRequestCanceled } = requestCancellation; @@ -306,7 +310,7 @@ test('mutating commands restart stale ready sessions when the preflight probe ne mockEnsureRunnerSession.mockResolvedValueOnce(staleSession).mockResolvedValueOnce(freshSession); mockExecuteRunnerCommandWithSession - .mockRejectedValueOnce(runnerConnectFailure('runner_connect_refused')) + .mockRejectedValueOnce(unwrittenConnectRefusal()) .mockResolvedValueOnce({ message: 'tapped' }); const result = await runAppleRunnerCommand(IOS_SIMULATOR, { command: 'tap', x: 120, y: 240 }); @@ -329,7 +333,7 @@ test('mutating commands retry startup sessions with stale bundle cleanup', async mockEnsureRunnerSession.mockResolvedValueOnce(startupSession).mockResolvedValueOnce(freshSession); mockExecuteRunnerCommandWithSession - .mockRejectedValueOnce(runnerConnectFailure('runner_connect_refused')) + .mockRejectedValueOnce(unwrittenConnectRefusal()) .mockResolvedValueOnce({ message: 'tapped' }); const result = await runAppleRunnerCommand(IOS_SIMULATOR, { command: 'tap', x: 120, y: 240 }); @@ -814,7 +818,7 @@ test('mutating commands invalidate the retry session without replaying again', a mockEnsureRunnerSession.mockResolvedValueOnce(staleSession).mockResolvedValueOnce(freshSession); mockExecuteRunnerCommandWithSession - .mockRejectedValueOnce(runnerConnectFailure('runner_connect_refused')) + .mockRejectedValueOnce(unwrittenConnectRefusal()) .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'fetch failed')) .mockResolvedValueOnce({ lifecycleState: 'notAccepted' }); @@ -1192,7 +1196,7 @@ test('a later command in the same request cannot pay for a second recycle boot', mockEnsureRunnerSession.mockResolvedValueOnce(staleSession).mockResolvedValueOnce(freshSession); mockExecuteRunnerCommandWithSession - .mockRejectedValueOnce(runnerConnectFailure('runner_connect_refused')) + .mockRejectedValueOnce(unwrittenConnectRefusal()) .mockResolvedValueOnce({ message: 'tapped' }); // First command consumes the request's only recycle via restart-and-replay. diff --git a/packages/platform-apple/src/runner/__tests__/runner-lifecycle-dispatch-disclosure.test.ts b/packages/platform-apple/src/runner/__tests__/runner-lifecycle-dispatch-disclosure.test.ts index 6380c6295e..fb7c5ecae4 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-lifecycle-dispatch-disclosure.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-lifecycle-dispatch-disclosure.test.ts @@ -39,6 +39,8 @@ vi.mock('../runner-session.ts', async () => { }); import { runAppleRunnerCommand } from '../runner-client.ts'; +import { RUNNER_REPLY_LOST_REASON } from '../runner-command-recovery.ts'; +import type { RunnerCommand } from '../runner-contract.ts'; import { resetRunnerRecycleLedgerForTests } from '../runner-recycle-ledger.ts'; import { waitForRunner } from '../runner-startup-transport.ts'; @@ -72,12 +74,9 @@ const readinessPreflightFailure = (): AppError => /** * The first attempt runs the real connect loop against a simulator whose every fetch fails the - * same way and whose simctl curl fallback exits with `curlExitCode`; the restart it earns fails. + * same way and whose simctl curl fallback exits with `curlExitCode`. */ -async function connectLoopThenFailedRestart(transport: { - fetchFailure: () => Error; - curlExitCode: number; -}): Promise { +function stubConnectLoopFailure(transport: { fetchFailure: () => Error; curlExitCode: number }) { vi.stubGlobal( 'fetch', vi.fn(async () => { @@ -91,13 +90,21 @@ async function connectLoopThenFailedRestart(transport: { stderr: `curl exited ${transport.curlExitCode}`, })), }); - mockEnsureRunnerSession - .mockResolvedValueOnce(makeRunnerSession()) - .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'runner restart failed')); mockExecuteRunnerCommandWithSession.mockImplementationOnce( async (device, session, command) => await waitForRunner(device, session.port, command, undefined, 400), ); +} + +/** A connect loop that fails as `transport` says, then a restart that fails. */ +async function connectLoopThenFailedRestart(transport: { + fetchFailure: () => Error; + curlExitCode: number; +}): Promise { + stubConnectLoopFailure(transport); + mockEnsureRunnerSession + .mockResolvedValueOnce(makeRunnerSession()) + .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'runner restart failed')); try { return await tap(); } catch (error) { @@ -107,6 +114,38 @@ async function connectLoopThenFailedRestart(transport: { } } +/** A fetch deadline, then a simctl curl that timed out after its POST: the command may have run. */ +const writtenThenLost = { + fetchFailure: () => new AppError('COMMAND_FAILED', 'Runner command deadline exceeded'), + curlExitCode: 28, +}; + +/** + * `command` is written and its reply lost on the first runner, the runner restarts, and the + * restarted runner answers every command with `restartedAnswer`. + */ +async function writtenThenLostThenRestarted( + command: RunnerCommand, + restartedAnswer: () => Promise>, +): Promise> { + stubConnectLoopFailure(writtenThenLost); + mockEnsureRunnerSession + .mockResolvedValueOnce(makeRunnerSession()) + .mockResolvedValueOnce(makeRunnerSession({ port: 8101 })); + mockExecuteRunnerCommandWithSession.mockImplementation(restartedAnswer); + try { + return await runAppleRunnerCommand(IOS_SIMULATOR, command); + } finally { + assert.equal(mockEnsureRunnerSession.mock.calls.length, 2, 'the runner is restarted'); + } +} + +function sendsOnRestartedRunner(): number { + return mockExecuteRunnerCommandWithSession.mock.calls.filter( + ([, session]) => session.port === 8101, + ).length; +} + const DRIVERS: Record Promise> = { 'ios-runner.pre-send.session-start-failed': async () => { mockEnsureRunnerSession.mockRejectedValueOnce( @@ -124,11 +163,29 @@ const DRIVERS: Record Promise> = { }), curlExitCode: 7, }), - 'ios-runner.transport.written-then-lost': () => - connectLoopThenFailedRestart({ - fetchFailure: () => new AppError('COMMAND_FAILED', 'Runner command deadline exceeded'), - curlExitCode: 28, - }), + 'ios-runner.transport.written-then-lost': async () => { + try { + return await writtenThenLostThenRestarted( + { command: 'tap', x: 120, y: 240 }, + async () => ({}), + ); + } catch (error) { + assert.ok(error instanceof AppError); + assert.equal(error.details?.reason, RUNNER_REPLY_LOST_REASON); + assert.equal(error.details?.runnerRestarted, true); + assert.equal(sendsOnRestartedRunner(), 0, 'the tap is not sent again'); + throw error; + } + }, + 'ios-runner.transport.read-only-written-then-lost': async () => { + try { + return await writtenThenLostThenRestarted({ command: 'snapshot' }, async () => { + throw new AppError('COMMAND_FAILED', 'runner resend failed', { dispatched: 'unknown' }); + }); + } finally { + assert.equal(sendsOnRestartedRunner(), 1, 'the read is sent again'); + } + }, 'ios-runner.pre-send.readiness-preflight-after-restart': async () => { mockEnsureRunnerSession .mockResolvedValueOnce(makeRunnerSession()) diff --git a/packages/platform-apple/src/runner/runner-command-recovery.ts b/packages/platform-apple/src/runner/runner-command-recovery.ts index b7bf5cd78e..feeecefeca 100644 --- a/packages/platform-apple/src/runner/runner-command-recovery.ts +++ b/packages/platform-apple/src/runner/runner-command-recovery.ts @@ -53,6 +53,31 @@ const RUNNER_STATUS_RECOVERY_TIMEOUT_MS = 3_000; */ export const RUNNER_REPLY_LOST_REASON = 'runner_reply_lost'; +/** + * The command was written and its reply lost, and the runner was restarted without resending it: + * a restarted runner's journal cannot say whether the first send ran. + */ +export function buildRunnerRestartedWithoutResendError( + command: RunnerCommand, + transportError: AppError, + options: AppleRunnerCommandOptions, +): AppError { + return new AppError( + 'COMMAND_FAILED', + `Runner command "${command.command}" may have run before its reply was lost, so agent-device restarted the runner without sending it again.`, + { + command: command.command, + commandId: command.commandId, + reason: RUNNER_REPLY_LOST_REASON, + recovery: 'runner_restarted_without_resend', + hint: `Run snapshot -i to inspect the current UI and check whether "${command.command}" took effect before you retry it.`, + logPath: options.logPath, + transportError: transportError.message, + }, + transportError, + ); +} + export async function handleRunnerTransportErrorAfterCommandSend(params: { device: DeviceInfo; session: RunnerSession; diff --git a/packages/platform-apple/src/runner/runner-error-classification.ts b/packages/platform-apple/src/runner/runner-error-classification.ts index 772932e398..b4bd6a93cb 100644 --- a/packages/platform-apple/src/runner/runner-error-classification.ts +++ b/packages/platform-apple/src/runner/runner-error-classification.ts @@ -115,7 +115,10 @@ type RunnerErrorVerdicts = { connectRetry?: boolean; /** Session-fatal classification: invalidate the cached runner session with this reason. */ sessionFatalReason?: string; - /** Connect-shaped failure before the command was sent: restart the session and replay. */ + /** + * Connect-shaped failure: restart the session. The command is resent only when the attempt + * provably wrote nothing or the command is read-only. + */ restartBeforeSend?: boolean; /** Readiness preflight gave up before the command was written: restart the session and replay. */ restartAfterReadinessPreflight?: boolean; @@ -610,9 +613,9 @@ export function resolveRunnerFatalErrorReason(error: unknown): string | undefine } /** - * A connect-shaped failure that surfaced before the command was sent: restart - * the runner session and replay the command, rather than probing a runner - * that never accepted the connection. + * A connect-shaped failure: restart the runner session rather than probing a runner that never + * accepted the connection. Whether the command is resent on the restarted runner is decided by + * what proves the first attempt could not have run it. */ export function shouldRestartRunnerBeforeCommandSend(error: unknown): boolean { return runnerErrorVerdict(error, 'restartBeforeSend') ?? false; diff --git a/packages/platform-apple/src/runner/runner-lifecycle.ts b/packages/platform-apple/src/runner/runner-lifecycle.ts index 86cbbae03f..00df6256d5 100644 --- a/packages/platform-apple/src/runner/runner-lifecycle.ts +++ b/packages/platform-apple/src/runner/runner-lifecycle.ts @@ -42,7 +42,11 @@ import type { AppleRunnerPrepareResult, } from './runner-provider.ts'; import { markRunnerXctestrunArtifactBadForRun } from './runner-xctestrun.ts'; -import { handleRunnerTransportErrorAfterCommandSend } from './runner-command-recovery.ts'; +import { + buildRunnerRestartedWithoutResendError, + handleRunnerTransportErrorAfterCommandSend, +} from './runner-command-recovery.ts'; +import { isReadOnlyRunnerCommand } from './runner-command-traits.ts'; import { buildRunnerRecycleBudgetExhaustedError, commitRunnerRecycle, @@ -360,6 +364,7 @@ async function executeRunnerCommandAttempt( options, signal, restartReason: 'runner_connect_failed_before_command_send', + firstAttemptError: appErr, firstAttemptUnwritten: isRunnerPreSendRefusal(appErr), }); } @@ -373,6 +378,7 @@ async function executeRunnerCommandAttempt( signal, restartReason: 'runner_readiness_preflight_failed_before_command_send', recoveredDiagnosticPhase: 'ios_runner_readiness_preflight_recovered', + firstAttemptError: appErr, firstAttemptUnwritten: true, }); } @@ -405,10 +411,8 @@ async function restartSessionAndRunCommand(params: { | 'runner_connect_failed_before_command_send' | 'runner_readiness_preflight_failed_before_command_send'; recoveredDiagnosticPhase?: string; - /** - * The failed first attempt provably never wrote the command. When it may have, the replay can - * double-send, and no failure of this restart may claim `no`. - */ + firstAttemptError: AppError; + /** The failed first attempt provably never wrote the command. */ firstAttemptUnwritten: boolean; }): Promise> { const { device, command, options, signal, restartReason } = params; @@ -419,7 +423,7 @@ async function restartSessionAndRunCommand(params: { if (!tryBeginRunnerRecycle(recycleKey)) { throw discloseDispatch( buildRunnerRecycleBudgetExhaustedError(command, options), - params.firstAttemptUnwritten ? 'no' : 'unknown', + canResendAfterRestart(params) ? 'no' : 'unknown', ); } await invalidateRunnerSession(params.session, restartReason); @@ -430,6 +434,13 @@ async function restartSessionAndRunCommand(params: { throw markRunnerRestartError(error, params); }); commitRunnerRecycle(recycleKey); + if (!canResendAfterRestart(params)) { + throw markRunnerRestartError( + buildRunnerRestartedWithoutResendError(command, params.firstAttemptError, options), + params, + restartedSession, + ); + } try { const recovered = await executeRunnerCommandWithSession( device, @@ -474,6 +485,20 @@ async function restartSessionAndRunCommand(params: { } } +type RunnerRestartResendEvidence = Pick< + Parameters[0], + 'command' | 'firstAttemptUnwritten' +>; + +/** + * Whether sending the command again on the restarted runner cannot run it twice: the first attempt + * provably wrote nothing, or the command is read-only by its runner trait. A restarted runner's + * journal is empty, so its `status` cannot prove the first send did not run. + */ +function canResendAfterRestart(evidence: RunnerRestartResendEvidence): boolean { + return evidence.firstAttemptUnwritten || isReadOnlyRunnerCommand(evidence.command); +} + function markRunnerRestartError( error: unknown, params: Pick< @@ -500,21 +525,23 @@ function markRunnerRestartError( }, error.cause ?? error, ); - return discloseRestartDispatch(marked, params.firstAttemptUnwritten, restartedSession); + return discloseRestartDispatch(marked, params, restartedSession); } /** - * A restart that never replayed says what the first attempt did; a replay after a first attempt - * that may have written the command cannot claim `no` for the two sends together. After an unwritten - * first attempt the replay's own verdict stands; without one, only a pre-send refusal is `no`. + * A read-only command has no side effect to repeat, so its failure is `no` however many sends it + * took. A mutating command whose first attempt may have written it is `unknown`: it is never resent. + * After an unwritten first attempt, a restart that never replayed is `no`, and a replay's own verdict + * stands; without one, only a pre-send refusal is `no`. */ function discloseRestartDispatch( error: AppError, - firstAttemptUnwritten: boolean, + evidence: RunnerRestartResendEvidence, restartedSession: RunnerSession | undefined, ): AppError { - if (!restartedSession) return discloseDispatch(error, firstAttemptUnwritten ? 'no' : 'unknown'); - if (!firstAttemptUnwritten) return discloseDispatch(error, 'unknown'); + if (isReadOnlyRunnerCommand(evidence.command)) return discloseDispatch(error, 'no'); + if (!evidence.firstAttemptUnwritten) return discloseDispatch(error, 'unknown'); + if (!restartedSession) return discloseDispatch(error, 'no'); return discloseUnclassifiedDispatch(error, isRunnerPreSendRefusal(error) ? 'no' : 'unknown'); } From ec51f4a3ba2bd6b3ee7691ed108cf27e747f8bc7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 30 Sep 2026 21:06:01 +0200 Subject: [PATCH 03/18] test(apple): share the unwritten connect refusal fixture to keep the retry test file within its size ratchet --- .../src/runner/__tests__/runner-command-retry.test.ts | 6 +----- .../src/runner/__tests__/runner-session-fixtures.ts | 4 ++++ 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/platform-apple/src/runner/__tests__/runner-command-retry.test.ts b/packages/platform-apple/src/runner/__tests__/runner-command-retry.test.ts index 3ede27bbb7..459e590401 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-command-retry.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-command-retry.test.ts @@ -5,6 +5,7 @@ import { createTestRequestCancellation, makeRunnerSession, runnerConnectFailure, + unwrittenConnectRefusal, } from './runner-session-fixtures.ts'; import { AppError } from '@agent-device/kernel/errors'; import { Deadline } from '../host.ts'; @@ -51,10 +52,6 @@ import { prepareIosRunner, runAppleRunnerCommand } from '../runner-client.ts'; import { resetRunnerRecycleLedgerForTests } from '../runner-recycle-ledger.ts'; import type { RunnerXctestrunArtifact } from '../runner-xctestrun.ts'; -/** Every connect attempt was refused before a byte was written, so a restart may resend the command. */ -const unwrittenConnectRefusal = (): AppError => - runnerConnectFailure('runner_connect_refused', undefined, { dispatched: 'no' }); - const requestCancellation = createTestRequestCancellation(); const { markRequestCanceled, clearRequestCanceled, isRequestCanceled } = requestCancellation; @@ -1193,7 +1190,6 @@ test('a later command in the same request cannot pay for a second recycle boot', const requestId = 'req-restart-cap'; const staleSession = makeRunnerSession({ port: 8100, state: 'ready' }); const freshSession = makeRunnerSession({ port: 8101, state: 'starting' }); - mockEnsureRunnerSession.mockResolvedValueOnce(staleSession).mockResolvedValueOnce(freshSession); mockExecuteRunnerCommandWithSession .mockRejectedValueOnce(unwrittenConnectRefusal()) diff --git a/packages/platform-apple/src/runner/__tests__/runner-session-fixtures.ts b/packages/platform-apple/src/runner/__tests__/runner-session-fixtures.ts index d569b32090..ff209e4e55 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-session-fixtures.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-session-fixtures.ts @@ -92,6 +92,10 @@ export function runnerConnectFailure( }); } +/** Every connect attempt was refused before a byte was written, so a restart may resend the command. */ +export const unwrittenConnectRefusal = (): AppError => + runnerConnectFailure('runner_connect_refused', undefined, { dispatched: 'no' }); + // Records everything the runner package emits through host.emitDiagnostic / // host.withDiagnosticTimer during `callback` and renders it back as the same // newline-delimited-JSON shape a flushed diagnostics session file holds, so From 572edb4a5b687af5ab63503ff3ea857e67d00fc4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 30 Sep 2026 22:22:05 +0200 Subject: [PATCH 04/18] fix(apple-runner): report the restart transport fact for a read, not no The restart path disclosed `no` for a read-only command whose first send may have run, while the status-recovery path discloses `unknown` for the same read (read_only_completed_without_retained_response, and the notAccepted/unknown-state fallthrough). Both runner paths now report the transport fact: a first attempt that may have written the command is `unknown`, read or mutation. The read-only `no` has one owner, the daemon router seam (request-dispatch-disclosure.ts), which keys it on the registry's recordingEffect `observes-app` for every producer (daemon.read-only-command). A second copy keyed on the runner's own read-only trait reconstructed that rule from a different source of truth, and the two runner paths disagreed. The recycle-budget refusal on the restart path also stops deriving `no` from the read-only trait and uses only whether the first attempt was provably unwritten. The runner still resends a read on the restarted runner (canResendAfterRestart); only the disclosure changes. dispatch-disclosure.json: - ios-runner.transport.read-only-written-then-lost: now `unknown`; the trigger names the router rule that turns it into `no` for the caller. - ios-runner.status.read-only-completed-without-retained-reply: new, `unknown`, driven through the real connect loop and recovery module. It pins the status-recovery read to the same value as the restart read. Mutations: - put back `if (isReadOnlyRunnerCommand(evidence.command)) return discloseDispatch(error, 'no')` in discloseRestartDispatch -> read-only-written-then-lost fails. - set `dispatched: 'no'` on read_only_completed_without_retained_response -> status.read-only-completed-without-retained-reply fails. --- contracts/fixtures/dispatch-disclosure.json | 10 +++++-- .../runner-dispatch-disclosure.test.ts | 26 ++++++++++++++++--- .../src/runner/runner-lifecycle.ts | 12 ++++----- 3 files changed, 37 insertions(+), 11 deletions(-) diff --git a/contracts/fixtures/dispatch-disclosure.json b/contracts/fixtures/dispatch-disclosure.json index d01fa0caec..33d3cd5f99 100644 --- a/contracts/fixtures/dispatch-disclosure.json +++ b/contracts/fixtures/dispatch-disclosure.json @@ -262,8 +262,8 @@ { "id": "ios-runner.transport.read-only-written-then-lost", "producer": "ios-runner", - "trigger": "a connect attempt posted a read-only command and then timed out (fetch deadline, simctl curl exit 28); the runner is restarted and the read is sent again, a read has no side effect, and the resend failed", - "dispatched": "no" + "trigger": "a connect attempt posted a read-only command and then timed out (fetch deadline, simctl curl exit 28); the runner is restarted and the read is sent again, and the resend failed; the first send may have run, so the runner reports unknown, and the daemon's read-only rule (daemon.read-only-command) reports no to the caller", + "dispatched": "unknown" }, { "id": "ios-runner.pre-send.readiness-preflight-after-restart", @@ -295,6 +295,12 @@ "trigger": "transport lost; status probe reports lifecycleState completed without a readable retained reply", "dispatched": "unknown" }, + { + "id": "ios-runner.status.read-only-completed-without-retained-reply", + "producer": "ios-runner", + "trigger": "transport lost on a read-only command; status probe reports lifecycleState completed without a readable retained reply; the runner reports unknown, and the daemon's read-only rule (daemon.read-only-command) reports no to the caller", + "dispatched": "unknown" + }, { "id": "ios-runner.status.accepted", "producer": "ios-runner", diff --git a/packages/platform-apple/src/runner/__tests__/runner-dispatch-disclosure.test.ts b/packages/platform-apple/src/runner/__tests__/runner-dispatch-disclosure.test.ts index 012476536c..f3cd0d3a76 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-dispatch-disclosure.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-dispatch-disclosure.test.ts @@ -10,6 +10,7 @@ import { } from '@agent-device/contracts/dispatch-disclosure-fixtures'; import { IOS_SIMULATOR } from './device-fixtures.ts'; import { handleRunnerTransportErrorAfterCommandSend } from '../runner-command-recovery.ts'; +import { isReadOnlyRunnerCommand } from '../runner-command-traits.ts'; import type { RunnerCommand } from '../runner-contract.ts'; import { isRetryableRunnerError, @@ -18,6 +19,7 @@ import { import { runApplePressSeries } from '../runner-sequence.ts'; import { executeRunnerCommandWithSession, type RunnerSession } from '../runner-session.ts'; import { RunnerCommandAccounting } from '../runner-session-types.ts'; +import { appleRunnerTestHost } from '../test-host.ts'; import { startFakeRunnerServer, type FakeRunnerCommandScript, @@ -66,19 +68,32 @@ async function replyFailure(code: string): Promise { ); } -/** The runner hangs up on the command, then answers the status probe with `status`. */ +/** + * The runner hangs up on the command, then answers the status probe with `status`. A read goes + * through the connect loop, which posts again on each attempt, so the runner hangs up on every + * attempt and the loop's simctl curl fallback times out after its POST. + */ async function lostResponse( status: FakeRunnerResponse[], command: RunnerCommand = TAP, ): Promise { - server = await startFakeRunnerServer({ tap: [{ kind: 'hangUp' }], status }); + const readOnly = isReadOnlyRunnerCommand(command); + const hangUps: FakeRunnerResponse[] = Array.from({ length: readOnly ? 20 : 1 }, () => ({ + kind: 'hangUp', + })); + server = await startFakeRunnerServer({ [command.command]: hangUps, status }); + if (readOnly) { + appleRunnerTestHost.update({ + runXcrun: vi.fn(async () => ({ exitCode: 28, stdout: '', stderr: 'curl exited 28' })), + }); + } const session = runnerSession(server.port); const transportError = await executeRunnerCommandWithSession( IOS_SIMULATOR, session, command, undefined, - 5_000, + readOnly ? 400 : 5_000, ).then( () => assert.fail('the fake runner hangs up on the command'), (error: unknown) => asAppError(error, 'COMMAND_FAILED'), @@ -175,6 +190,11 @@ const DRIVERS: Record Promise> = { lostResponse(statusReply({ lifecycleState: 'failed', lifecycleErrorCode: 'RUNNER_BUSY' })), 'ios-runner.status.completed-without-retained-reply': () => lostResponse(statusReply({ lifecycleState: 'completed' })), + 'ios-runner.status.read-only-completed-without-retained-reply': () => + lostResponse(statusReply({ lifecycleState: 'completed' }), { + command: 'snapshot', + commandId: 'cmd-1', + }), 'ios-runner.status.accepted': () => lostResponse(statusReply({ lifecycleState: 'accepted' })), 'ios-runner.status.started': () => lostResponse(statusReply({ lifecycleState: 'started' })), 'ios-runner.status.notAccepted': () => diff --git a/packages/platform-apple/src/runner/runner-lifecycle.ts b/packages/platform-apple/src/runner/runner-lifecycle.ts index 00df6256d5..6c622c8143 100644 --- a/packages/platform-apple/src/runner/runner-lifecycle.ts +++ b/packages/platform-apple/src/runner/runner-lifecycle.ts @@ -423,7 +423,7 @@ async function restartSessionAndRunCommand(params: { if (!tryBeginRunnerRecycle(recycleKey)) { throw discloseDispatch( buildRunnerRecycleBudgetExhaustedError(command, options), - canResendAfterRestart(params) ? 'no' : 'unknown', + params.firstAttemptUnwritten ? 'no' : 'unknown', ); } await invalidateRunnerSession(params.session, restartReason); @@ -529,17 +529,17 @@ function markRunnerRestartError( } /** - * A read-only command has no side effect to repeat, so its failure is `no` however many sends it - * took. A mutating command whose first attempt may have written it is `unknown`: it is never resent. - * After an unwritten first attempt, a restart that never replayed is `no`, and a replay's own verdict - * stands; without one, only a pre-send refusal is `no`. + * The transport fact, for reads and mutations alike: a first attempt that may have written the + * command is `unknown`, whether or not a read was resent. After an unwritten first attempt, a + * restart that never replayed is `no`, and a replay's own verdict stands; without one, only a + * pre-send refusal is `no`. That a read repeats no app-visible action is the daemon router's rule + * over the registry's `recordingEffect`, not this producer's. */ function discloseRestartDispatch( error: AppError, evidence: RunnerRestartResendEvidence, restartedSession: RunnerSession | undefined, ): AppError { - if (isReadOnlyRunnerCommand(evidence.command)) return discloseDispatch(error, 'no'); if (!evidence.firstAttemptUnwritten) return discloseDispatch(error, 'unknown'); if (!restartedSession) return discloseDispatch(error, 'no'); return discloseUnclassifiedDispatch(error, isRunnerPreSendRefusal(error) ? 'no' : 'unknown'); From 9e0260b19a9bb9ca4402315fc4a1b86e4f5373e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 30 Sep 2026 22:22:06 +0200 Subject: [PATCH 05/18] test(apple-runner): drive a real runner death through the fake runner runner-recovery-wiring: the notAccepted mutation test no longer scripts a second `ok` for the mutation. Nothing consumed it, and it suggested that a resend was expected. Its comment now says what the test models: status answers notAccepted, the way a runner whose journal did not survive a restart would. The path is status recovery, not the daemon's restart. The fake runner server gains an `exit` reply: it hangs up and stops listening, like a runner process that died mid-command. Before this, a test could only hang up on one request while the same server stayed up. Two new tests use a second server as the restarted runner: - press/fill (runner tap/type): the runner dies on the command. The mutation is sent once, is not sent to the restarted runner, fails with dispatched unknown, and the dead session is invalidated. A readText on the next request is served by the restarted runner. Mutation: declare tap and type with READ_ONLY_TRAITS (the resend trait) -> both rows fail. - get (runner readText): the runner dies mid-read. The connect loop gives up with a possibly-written POST (simctl curl exit 28), the daemon restarts the runner (runner_connect_failed_before_command_send), and the read is resent once and succeeds. Mutation: reduce canResendAfterRestart to `evidence.firstAttemptUnwritten` -> fails (no resend). With the real transport, a mutating command never reaches restartSessionAndRunCommand with a possibly-written first attempt. Mutations are sent by sendRunnerCommandOnce, and only the connect loop (waitForRunner, used for reads and the readiness preflight) stamps runner_connect_refused. So the mutating arm of canResendAfterRestart is covered only by the mocked lifecycle driver (ios-runner.transport.written-then-lost). A dying runner sends a mutation to status recovery, and a failed status probe rethrows the transport error without a runner_reply_lost reason. --- .../runner/__tests__/fake-runner-server.ts | 44 +++++++---- .../__tests__/runner-recovery-wiring.test.ts | 79 +++++++++++++++++-- 2 files changed, 104 insertions(+), 19 deletions(-) diff --git a/packages/platform-apple/src/runner/__tests__/fake-runner-server.ts b/packages/platform-apple/src/runner/__tests__/fake-runner-server.ts index b96acf5634..f6a5f67152 100644 --- a/packages/platform-apple/src/runner/__tests__/fake-runner-server.ts +++ b/packages/platform-apple/src/runner/__tests__/fake-runner-server.ts @@ -13,7 +13,9 @@ import type { AddressInfo } from 'node:net'; export type FakeRunnerResponse = | { kind: 'ok'; data: Record } | { kind: 'runnerError'; code: string; message: string } - | { kind: 'hangUp' }; + | { kind: 'hangUp' } + /** Hangs up and stops listening, as a runner process that died mid-command. */ + | { kind: 'exit' }; export type FakeRunnerRequest = { command: string; @@ -44,6 +46,7 @@ export async function startFakeRunnerServer( : Object.fromEntries(Object.entries(script).map(([key, list]) => [key, [...list]])); const remaining = sequential ?? []; const requests: FakeRunnerRequest[] = []; + let stopped: Promise | undefined; const server = http.createServer((req, res) => { let raw = ''; req.on('data', (chunk) => { @@ -55,22 +58,13 @@ export async function startFakeRunnerServer( const next = byCommand ? (byCommand[String(body.command ?? '')]?.shift() ?? { kind: 'ok' as const, data: {} }) : remaining.shift(); - if (!next) { - res.statusCode = 500; - res.end(JSON.stringify({ ok: false, error: { message: 'fake runner script exhausted' } })); - return; - } - if (next.kind === 'hangUp') { + if (next?.kind === 'exit') { res.destroy(); + stopped ??= new Promise((resolve) => server.close(() => resolve())); + server.closeAllConnections(); return; } - if (next.kind === 'runnerError') { - res.setHeader('content-type', 'application/json'); - res.end(JSON.stringify({ ok: false, error: { code: next.code, message: next.message } })); - return; - } - res.setHeader('content-type', 'application/json'); - res.end(JSON.stringify({ ok: true, data: next.data })); + writeFakeRunnerResponse(res, next); }); }); await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); @@ -79,12 +73,34 @@ export async function startFakeRunnerServer( port, requests, close: () => + stopped ?? new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve())), ), }; } +function writeFakeRunnerResponse( + res: http.ServerResponse, + next: Exclude | undefined, +): void { + if (!next) { + res.statusCode = 500; + res.end(JSON.stringify({ ok: false, error: { message: 'fake runner script exhausted' } })); + return; + } + if (next.kind === 'hangUp') { + res.destroy(); + return; + } + res.setHeader('content-type', 'application/json'); + if (next.kind === 'runnerError') { + res.end(JSON.stringify({ ok: false, error: { code: next.code, message: next.message } })); + return; + } + res.end(JSON.stringify({ ok: true, data: next.data })); +} + function parseBody(raw: string): Record { try { const parsed: unknown = JSON.parse(raw); diff --git a/packages/platform-apple/src/runner/__tests__/runner-recovery-wiring.test.ts b/packages/platform-apple/src/runner/__tests__/runner-recovery-wiring.test.ts index a8e4ffa432..36c1fb6e52 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-recovery-wiring.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-recovery-wiring.test.ts @@ -17,7 +17,11 @@ import { resolveExpectedRunnerCacheMetadata, } from '../runner-cache-metadata.ts'; import { captureDiagnostics } from './runner-session-fixtures.ts'; -import { startFakeRunnerServer, type FakeRunnerServer } from './fake-runner-server.ts'; +import { + startFakeRunnerServer, + type FakeRunnerResponse, + type FakeRunnerServer, +} from './fake-runner-server.ts'; import { resolveRunnerDetachDecision, RunnerCommandAccounting } from '../runner-session-types.ts'; import { requireLifecycleSettlementRows } from './runner-swift-settlement-fixtures.ts'; @@ -41,6 +45,7 @@ import { requireLifecycleSettlementRows } from './runner-swift-settlement-fixtur */ let server: FakeRunnerServer | undefined; +let restartedServer: FakeRunnerServer | undefined; const { ensureRunnerSessionMock, invalidateRunnerSessionMock } = vi.hoisted(() => ({ ensureRunnerSessionMock: vi.fn(), @@ -93,6 +98,8 @@ const LOST_RESPONSE_MUTATION_ROWS = { afterEach(async () => { await server?.close(); server = undefined; + await restartedServer?.close(); + restartedServer = undefined; ensureRunnerSessionMock.mockReset(); invalidateRunnerSessionMock.mockReset(); }); @@ -161,13 +168,13 @@ test.each(Object.values(LOST_RESPONSE_MUTATION_ROWS))( }, ); -// #3074: the runner restarted between the send and the status probe, so its journal is empty and -// `status` answers `notAccepted`. That is no proof the first send did not run. +// #3074: `status` answers `notAccepted`, as a runner whose journal did not survive a restart between +// the send and the probe would. That is no proof the first send did not run. test.each(Object.values(LOST_RESPONSE_MUTATION_ROWS))( - 'a $acceptanceCommand whose reply is lost and whose restarted runner answers notAccepted is sent once', + 'a $acceptanceCommand whose reply is lost and whose status answers notAccepted is sent once', async ({ runnerCommand, request }) => { server = await startFakeRunnerServer({ - [runnerCommand]: [{ kind: 'hangUp' }, { kind: 'ok', data: {} }], + [runnerCommand]: [{ kind: 'hangUp' }], status: [{ kind: 'ok', data: { lifecycleState: 'notAccepted' } }], snapshot: [{ kind: 'ok', data: { nodes: [] } }], }); @@ -212,6 +219,68 @@ test('a read whose reply is lost is resent and succeeds', async () => { assert.equal(server.requests.filter((entry) => entry.command === 'snapshot').length, 2); }); +// #3074: the runner process dies mid-command (the fake hangs up and stops listening), and the next +// session the daemon gets is a new runner on a second server. +async function runnerDiesOnCommand( + runnerCommand: string, + restartedScript: Record, +): Promise { + server = await startFakeRunnerServer({ [runnerCommand]: [{ kind: 'exit' }] }); + restartedServer = await startFakeRunnerServer(restartedScript); + ensureRunnerSessionMock + .mockResolvedValueOnce(makeRunnerSession(server.port)) + .mockResolvedValueOnce(makeRunnerSession(restartedServer.port)); + // The simctl curl fallback of the connect loop timed out after its POST. + appleRunnerTestHost.update({ + runXcrun: vi.fn(async () => ({ exitCode: 28, stdout: '', stderr: 'curl exited 28' })), + }); + return restartedServer; +} + +function sendsOf(target: FakeRunnerServer, runnerCommand: string): number { + return target.requests.filter((entry) => entry.command === runnerCommand).length; +} + +test.each(Object.values(LOST_RESPONSE_MUTATION_ROWS))( + 'a $acceptanceCommand whose runner dies mid-command is not sent to the restarted runner', + async ({ runnerCommand, request }) => { + const restarted = await runnerDiesOnCommand(runnerCommand, { + readText: [{ kind: 'ok', data: { text: 'after' } }], + }); + + await assert.rejects(runAppleRunnerCommand(IOS_SIMULATOR, { ...request }), (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.details?.dispatched, 'unknown'); + return true; + }); + assert.equal(invalidateRunnerSessionMock.mock.calls.length, 1, 'the dead runner is dropped'); + assert.deepEqual( + await runAppleRunnerCommand(IOS_SIMULATOR, { command: 'readText', x: 5, y: 5 }), + { text: 'after' }, + ); + assert.equal(sendsOf(server!, runnerCommand), 1, `${runnerCommand} is dispatched once`); + assert.equal(sendsOf(restarted, runnerCommand), 0, `${runnerCommand} is not resent`); + }, +); + +test('a get whose runner dies mid-read is resent once on the restarted runner', async () => { + const restarted = await runnerDiesOnCommand('readText', { + readText: [{ kind: 'ok', data: { text: 'hello' } }], + }); + + assert.deepEqual( + await runAppleRunnerCommand(IOS_SIMULATOR, { command: 'readText', x: 5, y: 5 }), + { text: 'hello' }, + ); + expect(invalidateRunnerSessionMock).toHaveBeenCalledTimes(1); + expect(invalidateRunnerSessionMock).toHaveBeenCalledWith( + expect.anything(), + 'runner_connect_failed_before_command_send', + ); + assert.equal(sendsOf(server!, 'readText'), 1); + assert.equal(sendsOf(restarted, 'readText'), 1, 'the read is resent once'); +}); + // #2965: an inline `status` probe answers while the command it probes may still be executing, so its // own reply must not clear the mutation's outstanding charge. The handoff verdict is asserted through // `resolveRunnerDetachDecision` — the exact gate `detachRunnerSessionForShutdown` consults. Rows come From 00857c532efa526f2ecfee959e1e51375e7f0207 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 30 Sep 2026 22:25:09 +0200 Subject: [PATCH 06/18] fix(apple-runner): name the lost reply when the status probe fails A runner that dies mid-mutation drops the reply, and the status probe that follows fails. The error that reaches the caller was then a bare transport error (`fetch failed`, dispatched unknown), with no typed reason. #3074 requires a reason that names the lost reply, so this path now builds an error with reason runner_reply_lost (the name the restart arm already uses) and recovery status_probe_failed. The error keeps the transport message and details, and the transport error as cause. The session is still invalidated, and the command is still not resent. ADR 0011 gap list: the row ios-runner.transport.written-then-lost proves the rule behind canResendAfterRestart's mutating arm, not a production route. Over the real transport the arm cannot be reached, because mutations go through sendRunnerCommandOnce and only the connect loop raises the restart trigger. Tests: - runner-command-recovery: a failing status probe now asserts reason runner_reply_lost, recovery status_probe_failed, dispatched unknown, and the transport error as cause. - runner-recovery-wiring: press/fill whose runner dies mid-command (real fake-runner transport) now also asserts reason runner_reply_lost. Mutation: drop `reason: RUNNER_REPLY_LOST_REASON` from buildStatusProbeFailedError -> all three fail. --- .../0011-interaction-guarantee-contract.md | 6 ++++ .../__tests__/runner-command-recovery.test.ts | 12 ++++++-- .../__tests__/runner-recovery-wiring.test.ts | 1 + .../src/runner/runner-command-recovery.ts | 30 ++++++++++++++++++- 4 files changed, 46 insertions(+), 3 deletions(-) diff --git a/docs/adr/0011-interaction-guarantee-contract.md b/docs/adr/0011-interaction-guarantee-contract.md index 4f91843195..68b41bc67e 100644 --- a/docs/adr/0011-interaction-guarantee-contract.md +++ b/docs/adr/0011-interaction-guarantee-contract.md @@ -201,6 +201,12 @@ must read the absent field as `unknown`. `'mutates-app'` command; after one, the batch failure is `unknown` for every caller, including one that supplies its own `invoke`. +The row `ios-runner.transport.written-then-lost` proves the Apple runner's rule +that a mutating command whose first send may have run is not resent after a +restart, not a production route: over the real transport that arm is +unreachable, because mutations go through `sendRunnerCommandOnce` and only the +connect loop raises the restart trigger. + For `responseFields`, one `buildInteractionResponseData(...)` becomes the only construction site for interaction response payloads (this deletes the class of bug where `fill @ref` rebuilt its response by hand and dropped `evidence`). A diff --git a/packages/platform-apple/src/runner/__tests__/runner-command-recovery.test.ts b/packages/platform-apple/src/runner/__tests__/runner-command-recovery.test.ts index 6fea3f0d4b..5be495e6e8 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-command-recovery.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-command-recovery.test.ts @@ -139,12 +139,20 @@ test('notAccepted from a restarted runner fails the lost command as unknown, nam assert.equal(invalidate.mock.calls.length, 1); }); -test('a failing status probe retains the invalidation and rethrows the transport error', async () => { +test('a failing status probe retains the invalidation and names the lost reply', async () => { const { result, invalidate, transportError } = await runRecovery({ script: [{ kind: 'runnerError', code: 'COMMAND_FAILED', message: 'status probe exploded' }], }); - await assert.rejects(result, (error: unknown) => error === transportError); + await assert.rejects(result, (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.message, transportError.message); + assert.equal(error.cause, transportError); + assert.equal(error.details?.reason, RUNNER_REPLY_LOST_REASON); + assert.equal(error.details?.recovery, 'status_probe_failed'); + assert.equal(error.details?.dispatched, 'unknown'); + return true; + }); assert.equal(invalidate.mock.calls.length, 1); }); diff --git a/packages/platform-apple/src/runner/__tests__/runner-recovery-wiring.test.ts b/packages/platform-apple/src/runner/__tests__/runner-recovery-wiring.test.ts index 36c1fb6e52..244cc1a186 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-recovery-wiring.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-recovery-wiring.test.ts @@ -251,6 +251,7 @@ test.each(Object.values(LOST_RESPONSE_MUTATION_ROWS))( await assert.rejects(runAppleRunnerCommand(IOS_SIMULATOR, { ...request }), (error: unknown) => { assert.ok(error instanceof AppError); assert.equal(error.details?.dispatched, 'unknown'); + assert.equal(error.details?.reason, RUNNER_REPLY_LOST_REASON); return true; }); assert.equal(invalidateRunnerSessionMock.mock.calls.length, 1, 'the dead runner is dropped'); diff --git a/packages/platform-apple/src/runner/runner-command-recovery.ts b/packages/platform-apple/src/runner/runner-command-recovery.ts index feeecefeca..1eb10da920 100644 --- a/packages/platform-apple/src/runner/runner-command-recovery.ts +++ b/packages/platform-apple/src/runner/runner-command-recovery.ts @@ -204,7 +204,12 @@ async function tryRecoverRunnerCommandAfterTransportError( ...readinessPreflight, }, }); - return { type: 'retainInvalidation', reason: 'status_probe_failed', dispatched: 'unknown' }; + return { + type: 'retainInvalidation', + reason: 'status_probe_failed', + dispatched: 'unknown', + error: buildStatusProbeFailedError(command, transportError, options), + }; } const lifecycleState = typeof status.lifecycleState === 'string' ? status.lifecycleState : ''; @@ -505,6 +510,29 @@ function readReadinessPreflightRecoveryDetails( return details; } +/** The lost reply could not be placed because the status probe itself failed. */ +function buildStatusProbeFailedError( + command: RunnerCommand, + transportError: AppError, + options: AppleRunnerCommandOptions, +): AppError { + return new AppError( + transportError.code, + transportError.message, + { + ...transportError.details, + command: command.command, + commandId: command.commandId, + reason: RUNNER_REPLY_LOST_REASON, + recovery: 'status_probe_failed', + hint: unknownLifecycleStateHint(command.command), + logPath: options.logPath ?? transportError.details?.logPath, + transportError: transportError.message, + }, + transportError, + ); +} + function unknownLifecycleStateHint(command: string): string { return `The runner did not confirm that "${command}" reached a safe terminal state, so agent-device kept the conservative invalidation path. Run snapshot -i before retrying if the UI may have changed.`; } From ec954046b192c20fa9ba0cab5ef4944dc4e673b5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 30 Sep 2026 23:18:39 +0200 Subject: [PATCH 07/18] test(apple-runner): hang up on every read attempt without counting the connect loop The read-only lost-response driver scripted 20 hang-ups and a 400 ms timeout. That count depended on the connect loop's retry cadence (RUNNER_CONNECT_ATTEMPT_INTERVAL_MS): with a faster cadence the queue runs out, and the fake answers the next attempt with `ok`. The fake runner server now has a `hangUpAlways` reply that is never consumed, so the read is refused until the loop gives up, however often it retries. The timeout now only bounds how long the loop runs. Mutation: set RUNNER_CONNECT_ATTEMPT_INTERVAL_MS to 10 or 50 (with a 1 ms retry base delay) -> ios-runner.status.read-only-completed-without-retained-reply still passes. --- .../src/runner/__tests__/fake-runner-server.ts | 12 ++++++++---- .../__tests__/runner-dispatch-disclosure.test.ts | 11 ++++++----- 2 files changed, 14 insertions(+), 9 deletions(-) diff --git a/packages/platform-apple/src/runner/__tests__/fake-runner-server.ts b/packages/platform-apple/src/runner/__tests__/fake-runner-server.ts index f6a5f67152..2194146806 100644 --- a/packages/platform-apple/src/runner/__tests__/fake-runner-server.ts +++ b/packages/platform-apple/src/runner/__tests__/fake-runner-server.ts @@ -14,6 +14,8 @@ export type FakeRunnerResponse = | { kind: 'ok'; data: Record } | { kind: 'runnerError'; code: string; message: string } | { kind: 'hangUp' } + /** Hangs up on this request and every later one for the command: the entry is never consumed. */ + | { kind: 'hangUpAlways' } /** Hangs up and stops listening, as a runner process that died mid-command. */ | { kind: 'exit' }; @@ -55,9 +57,11 @@ export async function startFakeRunnerServer( req.on('end', () => { const body = parseBody(raw); requests.push({ command: String(body.command ?? ''), body }); - const next = byCommand - ? (byCommand[String(body.command ?? '')]?.shift() ?? { kind: 'ok' as const, data: {} }) - : remaining.shift(); + const queue = byCommand ? byCommand[String(body.command ?? '')] : remaining; + const next = + queue?.[0]?.kind === 'hangUpAlways' + ? queue[0] + : (queue?.shift() ?? (byCommand ? { kind: 'ok' as const, data: {} } : undefined)); if (next?.kind === 'exit') { res.destroy(); stopped ??= new Promise((resolve) => server.close(() => resolve())); @@ -89,7 +93,7 @@ function writeFakeRunnerResponse( res.end(JSON.stringify({ ok: false, error: { message: 'fake runner script exhausted' } })); return; } - if (next.kind === 'hangUp') { + if (next.kind === 'hangUp' || next.kind === 'hangUpAlways') { res.destroy(); return; } diff --git a/packages/platform-apple/src/runner/__tests__/runner-dispatch-disclosure.test.ts b/packages/platform-apple/src/runner/__tests__/runner-dispatch-disclosure.test.ts index f3cd0d3a76..f69501c4c8 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-dispatch-disclosure.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-dispatch-disclosure.test.ts @@ -71,17 +71,18 @@ async function replyFailure(code: string): Promise { /** * The runner hangs up on the command, then answers the status probe with `status`. A read goes * through the connect loop, which posts again on each attempt, so the runner hangs up on every - * attempt and the loop's simctl curl fallback times out after its POST. + * attempt until the loop gives up, and the loop's simctl curl fallback times out after its POST. + * The read's short timeout only bounds how long the loop runs. */ async function lostResponse( status: FakeRunnerResponse[], command: RunnerCommand = TAP, ): Promise { const readOnly = isReadOnlyRunnerCommand(command); - const hangUps: FakeRunnerResponse[] = Array.from({ length: readOnly ? 20 : 1 }, () => ({ - kind: 'hangUp', - })); - server = await startFakeRunnerServer({ [command.command]: hangUps, status }); + server = await startFakeRunnerServer({ + [command.command]: [{ kind: readOnly ? 'hangUpAlways' : 'hangUp' }], + status, + }); if (readOnly) { appleRunnerTestHost.update({ runXcrun: vi.fn(async () => ({ exitCode: 28, stdout: '', stderr: 'curl exited 28' })), From 10077dcfd42ab6182fab5d718e0f9aa341d05a8a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 30 Sep 2026 23:20:01 +0200 Subject: [PATCH 08/18] test(apple-runner): pick the fake runner's next reply in one helper The request handler of the fake runner server went over the fallow complexity threshold after hangUpAlways was added. The choice of the next scripted reply is now in takeScriptedResponse. Behavior is unchanged: the apple-runner suite passes (768 tests). --- .../src/runner/__tests__/fake-runner-server.ts | 18 +++++++++++++----- 1 file changed, 13 insertions(+), 5 deletions(-) diff --git a/packages/platform-apple/src/runner/__tests__/fake-runner-server.ts b/packages/platform-apple/src/runner/__tests__/fake-runner-server.ts index 2194146806..9b4298605c 100644 --- a/packages/platform-apple/src/runner/__tests__/fake-runner-server.ts +++ b/packages/platform-apple/src/runner/__tests__/fake-runner-server.ts @@ -57,11 +57,12 @@ export async function startFakeRunnerServer( req.on('end', () => { const body = parseBody(raw); requests.push({ command: String(body.command ?? ''), body }); - const queue = byCommand ? byCommand[String(body.command ?? '')] : remaining; - const next = - queue?.[0]?.kind === 'hangUpAlways' - ? queue[0] - : (queue?.shift() ?? (byCommand ? { kind: 'ok' as const, data: {} } : undefined)); + const next = byCommand + ? (takeScriptedResponse(byCommand[String(body.command ?? '')]) ?? { + kind: 'ok' as const, + data: {}, + }) + : takeScriptedResponse(remaining); if (next?.kind === 'exit') { res.destroy(); stopped ??= new Promise((resolve) => server.close(() => resolve())); @@ -84,6 +85,13 @@ export async function startFakeRunnerServer( }; } +/** The next scripted reply; a `hangUpAlways` entry stays at the head of its queue. */ +function takeScriptedResponse( + queue: FakeRunnerResponse[] | undefined, +): FakeRunnerResponse | undefined { + return queue?.[0]?.kind === 'hangUpAlways' ? queue[0] : queue?.shift(); +} + function writeFakeRunnerResponse( res: http.ServerResponse, next: Exclude | undefined, From 674e2d81318782f0b6752fb5d3c97a96d1bea686 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 1 Oct 2026 01:29:44 +0200 Subject: [PATCH 09/18] refactor(apple-runner): gate the restart resend at the connect-failure call site A mutation never reaches the connect loop, so the no-resend arm after a restart had no production route. The call site now restarts only when the first attempt provably wrote nothing or the command is read-only, and the arm and its error builder are gone. The written-then-lost row is driven through a fake runner that dies mid-command. A failed status probe keeps a transport reason under transportReason. --- contracts/fixtures/dispatch-disclosure.json | 2 +- .../0011-interaction-guarantee-contract.md | 12 ++-- .../__tests__/runner-command-recovery.test.ts | 2 + ...nner-lifecycle-dispatch-disclosure.test.ts | 37 +++++++++--- .../src/runner/runner-command-recovery.ts | 28 +-------- .../src/runner/runner-error-classification.ts | 11 ++-- .../src/runner/runner-lifecycle.ts | 59 ++++++------------- 7 files changed, 65 insertions(+), 86 deletions(-) diff --git a/contracts/fixtures/dispatch-disclosure.json b/contracts/fixtures/dispatch-disclosure.json index 33d3cd5f99..acf40bb0f2 100644 --- a/contracts/fixtures/dispatch-disclosure.json +++ b/contracts/fixtures/dispatch-disclosure.json @@ -256,7 +256,7 @@ { "id": "ios-runner.transport.written-then-lost", "producer": "ios-runner", - "trigger": "a connect attempt posted a mutating command and then timed out (fetch deadline, simctl curl exit 28); the runner is restarted, and the command is not sent again because nothing proves the first send did not run", + "trigger": "a mutating command was posted and the runner process died before replying; the status probe fails, the session is invalidated, and the command is not sent again (details.reason runner_reply_lost)", "dispatched": "unknown" }, { diff --git a/docs/adr/0011-interaction-guarantee-contract.md b/docs/adr/0011-interaction-guarantee-contract.md index 68b41bc67e..070bba4d9d 100644 --- a/docs/adr/0011-interaction-guarantee-contract.md +++ b/docs/adr/0011-interaction-guarantee-contract.md @@ -192,6 +192,12 @@ repeat. A producer that runs several device inputs inside one bound operation Each row names its driver file by id prefix, and that file drives the real producer. +The Apple runner does not resend a mutating command whose first send may have +run: a restart resends only a command the first attempt provably did not write, +or a read-only one. A mutation whose runner dies mid-command fails with +`reason: runner_reply_lost` and `dispatched: unknown` (row +`ios-runner.transport.written-then-lost`). + Remaining gaps: a failure before the router's locked scope (session resolution, lock acquisition, lease and daemon-policy admission) never reaches the disclosure and carries no `dispatched`. It sends nothing, but a consumer @@ -201,12 +207,6 @@ must read the absent field as `unknown`. `'mutates-app'` command; after one, the batch failure is `unknown` for every caller, including one that supplies its own `invoke`. -The row `ios-runner.transport.written-then-lost` proves the Apple runner's rule -that a mutating command whose first send may have run is not resent after a -restart, not a production route: over the real transport that arm is -unreachable, because mutations go through `sendRunnerCommandOnce` and only the -connect loop raises the restart trigger. - For `responseFields`, one `buildInteractionResponseData(...)` becomes the only construction site for interaction response payloads (this deletes the class of bug where `fill @ref` rebuilt its response by hand and dropped `evidence`). A diff --git a/packages/platform-apple/src/runner/__tests__/runner-command-recovery.test.ts b/packages/platform-apple/src/runner/__tests__/runner-command-recovery.test.ts index 5be495e6e8..dc6689a591 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-command-recovery.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-command-recovery.test.ts @@ -142,6 +142,7 @@ test('notAccepted from a restarted runner fails the lost command as unknown, nam test('a failing status probe retains the invalidation and names the lost reply', async () => { const { result, invalidate, transportError } = await runRecovery({ script: [{ kind: 'runnerError', code: 'COMMAND_FAILED', message: 'status probe exploded' }], + transportError: new AppError('COMMAND_FAILED', 'socket hang up', { reason: 'socket_reset' }), }); await assert.rejects(result, (error: unknown) => { @@ -149,6 +150,7 @@ test('a failing status probe retains the invalidation and names the lost reply', assert.equal(error.message, transportError.message); assert.equal(error.cause, transportError); assert.equal(error.details?.reason, RUNNER_REPLY_LOST_REASON); + assert.equal(error.details?.transportReason, 'socket_reset'); assert.equal(error.details?.recovery, 'status_probe_failed'); assert.equal(error.details?.dispatched, 'unknown'); return true; diff --git a/packages/platform-apple/src/runner/__tests__/runner-lifecycle-dispatch-disclosure.test.ts b/packages/platform-apple/src/runner/__tests__/runner-lifecycle-dispatch-disclosure.test.ts index fb7c5ecae4..fa1635cabf 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-lifecycle-dispatch-disclosure.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-lifecycle-dispatch-disclosure.test.ts @@ -10,6 +10,7 @@ import { import { IOS_SIMULATOR } from './device-fixtures.ts'; import { createTestRequestCancellation, makeRunnerSession } from './runner-session-fixtures.ts'; import { appleRunnerTestHost } from '../test-host.ts'; +import { startFakeRunnerServer, type FakeRunnerServer } from './fake-runner-server.ts'; // contracts/fixtures/dispatch-disclosure.json, ios-runner pre-send and transport rows: each row // drives runAppleRunnerCommand through the real lifecycle and restart path with the session start @@ -59,8 +60,12 @@ beforeEach(() => { }); }); -afterEach(() => { +let fakeRunner: FakeRunnerServer | undefined; + +afterEach(async () => { vi.unstubAllGlobals(); + await fakeRunner?.close(); + fakeRunner = undefined; }); async function tap(): Promise { @@ -164,17 +169,35 @@ const DRIVERS: Record Promise> = { curlExitCode: 7, }), 'ios-runner.transport.written-then-lost': async () => { + const runnerSession = + await vi.importActual('../runner-session.ts'); + const runner = await startFakeRunnerServer({ tap: [{ kind: 'exit' }] }); + fakeRunner = runner; + // The status probe finds no listener, over fetch or the simctl curl fallback. + const { retryWithPolicy } = appleRunnerTestHost.defaults(); + appleRunnerTestHost.update({ + runXcrun: vi.fn(async () => ({ exitCode: 7, stdout: '', stderr: 'curl exited 7' })), + retryWithPolicy: (task, policy, options) => + retryWithPolicy(task, { ...policy, baseDelayMs: 1, maxDelayMs: 1, jitter: 0 }, options), + }); + mockEnsureRunnerSession.mockResolvedValueOnce(makeRunnerSession({ port: runner.port })); + mockExecuteRunnerCommandWithSession.mockImplementation( + runnerSession.executeRunnerCommandWithSession, + ); try { - return await writtenThenLostThenRestarted( - { command: 'tap', x: 120, y: 240 }, - async () => ({}), - ); + return await tap(); } catch (error) { assert.ok(error instanceof AppError); assert.equal(error.details?.reason, RUNNER_REPLY_LOST_REASON); - assert.equal(error.details?.runnerRestarted, true); - assert.equal(sendsOnRestartedRunner(), 0, 'the tap is not sent again'); + assert.equal(error.details?.recovery, 'status_probe_failed'); + assert.equal(error.details?.runnerRestarted, undefined, 'the runner is not restarted'); throw error; + } finally { + assert.equal(runner.requests.filter((entry) => entry.command === 'tap').length, 1); + assert.deepEqual( + mockInvalidateRunnerSession.mock.calls[0]?.[1], + 'transport_error_after_command_send', + ); } }, 'ios-runner.transport.read-only-written-then-lost': async () => { diff --git a/packages/platform-apple/src/runner/runner-command-recovery.ts b/packages/platform-apple/src/runner/runner-command-recovery.ts index 1eb10da920..a0f071677e 100644 --- a/packages/platform-apple/src/runner/runner-command-recovery.ts +++ b/packages/platform-apple/src/runner/runner-command-recovery.ts @@ -53,31 +53,6 @@ const RUNNER_STATUS_RECOVERY_TIMEOUT_MS = 3_000; */ export const RUNNER_REPLY_LOST_REASON = 'runner_reply_lost'; -/** - * The command was written and its reply lost, and the runner was restarted without resending it: - * a restarted runner's journal cannot say whether the first send ran. - */ -export function buildRunnerRestartedWithoutResendError( - command: RunnerCommand, - transportError: AppError, - options: AppleRunnerCommandOptions, -): AppError { - return new AppError( - 'COMMAND_FAILED', - `Runner command "${command.command}" may have run before its reply was lost, so agent-device restarted the runner without sending it again.`, - { - command: command.command, - commandId: command.commandId, - reason: RUNNER_REPLY_LOST_REASON, - recovery: 'runner_restarted_without_resend', - hint: `Run snapshot -i to inspect the current UI and check whether "${command.command}" took effect before you retry it.`, - logPath: options.logPath, - transportError: transportError.message, - }, - transportError, - ); -} - export async function handleRunnerTransportErrorAfterCommandSend(params: { device: DeviceInfo; session: RunnerSession; @@ -521,6 +496,9 @@ function buildStatusProbeFailedError( transportError.message, { ...transportError.details, + ...(transportError.details?.reason === undefined + ? {} + : { transportReason: transportError.details.reason }), command: command.command, commandId: command.commandId, reason: RUNNER_REPLY_LOST_REASON, diff --git a/packages/platform-apple/src/runner/runner-error-classification.ts b/packages/platform-apple/src/runner/runner-error-classification.ts index b4bd6a93cb..772932e398 100644 --- a/packages/platform-apple/src/runner/runner-error-classification.ts +++ b/packages/platform-apple/src/runner/runner-error-classification.ts @@ -115,10 +115,7 @@ type RunnerErrorVerdicts = { connectRetry?: boolean; /** Session-fatal classification: invalidate the cached runner session with this reason. */ sessionFatalReason?: string; - /** - * Connect-shaped failure: restart the session. The command is resent only when the attempt - * provably wrote nothing or the command is read-only. - */ + /** Connect-shaped failure before the command was sent: restart the session and replay. */ restartBeforeSend?: boolean; /** Readiness preflight gave up before the command was written: restart the session and replay. */ restartAfterReadinessPreflight?: boolean; @@ -613,9 +610,9 @@ export function resolveRunnerFatalErrorReason(error: unknown): string | undefine } /** - * A connect-shaped failure: restart the runner session rather than probing a runner that never - * accepted the connection. Whether the command is resent on the restarted runner is decided by - * what proves the first attempt could not have run it. + * A connect-shaped failure that surfaced before the command was sent: restart + * the runner session and replay the command, rather than probing a runner + * that never accepted the connection. */ export function shouldRestartRunnerBeforeCommandSend(error: unknown): boolean { return runnerErrorVerdict(error, 'restartBeforeSend') ?? false; diff --git a/packages/platform-apple/src/runner/runner-lifecycle.ts b/packages/platform-apple/src/runner/runner-lifecycle.ts index 6c622c8143..d3ab81a5bc 100644 --- a/packages/platform-apple/src/runner/runner-lifecycle.ts +++ b/packages/platform-apple/src/runner/runner-lifecycle.ts @@ -42,10 +42,7 @@ import type { AppleRunnerPrepareResult, } from './runner-provider.ts'; import { markRunnerXctestrunArtifactBadForRun } from './runner-xctestrun.ts'; -import { - buildRunnerRestartedWithoutResendError, - handleRunnerTransportErrorAfterCommandSend, -} from './runner-command-recovery.ts'; +import { handleRunnerTransportErrorAfterCommandSend } from './runner-command-recovery.ts'; import { isReadOnlyRunnerCommand } from './runner-command-traits.ts'; import { buildRunnerRecycleBudgetExhaustedError, @@ -355,7 +352,12 @@ async function executeRunnerCommandAttempt( appErr, ); } - if (shouldRestartRunnerBeforeCommandSend(appErr) && session) { + const firstAttemptUnwritten = isRunnerPreSendRefusal(appErr); + if ( + shouldRestartRunnerBeforeCommandSend(appErr) && + session && + (firstAttemptUnwritten || isReadOnlyRunnerCommand(command)) + ) { assertRunnerRequestActive(options.requestId); return await restartSessionAndRunCommand({ device, @@ -364,8 +366,7 @@ async function executeRunnerCommandAttempt( options, signal, restartReason: 'runner_connect_failed_before_command_send', - firstAttemptError: appErr, - firstAttemptUnwritten: isRunnerPreSendRefusal(appErr), + firstAttemptUnwritten, }); } if (session && shouldRestartRunnerAfterReadinessPreflight(appErr)) { @@ -378,7 +379,6 @@ async function executeRunnerCommandAttempt( signal, restartReason: 'runner_readiness_preflight_failed_before_command_send', recoveredDiagnosticPhase: 'ios_runner_readiness_preflight_recovered', - firstAttemptError: appErr, firstAttemptUnwritten: true, }); } @@ -411,8 +411,10 @@ async function restartSessionAndRunCommand(params: { | 'runner_connect_failed_before_command_send' | 'runner_readiness_preflight_failed_before_command_send'; recoveredDiagnosticPhase?: string; - firstAttemptError: AppError; - /** The failed first attempt provably never wrote the command. */ + /** + * The failed first attempt provably never wrote the command. When it may have, the replay can + * double-send, and no failure of this restart may claim `no`. + */ firstAttemptUnwritten: boolean; }): Promise> { const { device, command, options, signal, restartReason } = params; @@ -434,13 +436,6 @@ async function restartSessionAndRunCommand(params: { throw markRunnerRestartError(error, params); }); commitRunnerRecycle(recycleKey); - if (!canResendAfterRestart(params)) { - throw markRunnerRestartError( - buildRunnerRestartedWithoutResendError(command, params.firstAttemptError, options), - params, - restartedSession, - ); - } try { const recovered = await executeRunnerCommandWithSession( device, @@ -485,20 +480,6 @@ async function restartSessionAndRunCommand(params: { } } -type RunnerRestartResendEvidence = Pick< - Parameters[0], - 'command' | 'firstAttemptUnwritten' ->; - -/** - * Whether sending the command again on the restarted runner cannot run it twice: the first attempt - * provably wrote nothing, or the command is read-only by its runner trait. A restarted runner's - * journal is empty, so its `status` cannot prove the first send did not run. - */ -function canResendAfterRestart(evidence: RunnerRestartResendEvidence): boolean { - return evidence.firstAttemptUnwritten || isReadOnlyRunnerCommand(evidence.command); -} - function markRunnerRestartError( error: unknown, params: Pick< @@ -525,23 +506,21 @@ function markRunnerRestartError( }, error.cause ?? error, ); - return discloseRestartDispatch(marked, params, restartedSession); + return discloseRestartDispatch(marked, params.firstAttemptUnwritten, restartedSession); } /** - * The transport fact, for reads and mutations alike: a first attempt that may have written the - * command is `unknown`, whether or not a read was resent. After an unwritten first attempt, a - * restart that never replayed is `no`, and a replay's own verdict stands; without one, only a - * pre-send refusal is `no`. That a read repeats no app-visible action is the daemon router's rule - * over the registry's `recordingEffect`, not this producer's. + * A restart that never replayed says what the first attempt did; a replay after a first attempt + * that may have written the command cannot claim `no` for the two sends together. After an unwritten + * first attempt the replay's own verdict stands; without one, only a pre-send refusal is `no`. */ function discloseRestartDispatch( error: AppError, - evidence: RunnerRestartResendEvidence, + firstAttemptUnwritten: boolean, restartedSession: RunnerSession | undefined, ): AppError { - if (!evidence.firstAttemptUnwritten) return discloseDispatch(error, 'unknown'); - if (!restartedSession) return discloseDispatch(error, 'no'); + if (!restartedSession) return discloseDispatch(error, firstAttemptUnwritten ? 'no' : 'unknown'); + if (!firstAttemptUnwritten) return discloseDispatch(error, 'unknown'); return discloseUnclassifiedDispatch(error, isRunnerPreSendRefusal(error) ? 'no' : 'unknown'); } From 044febecb6e1500bc5d464711dd1f1e0f96284a9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 1 Oct 2026 01:29:44 +0200 Subject: [PATCH 10/18] test(apple-runner): name runner_reply_lost in the lost-reply test titles --- .../src/runner/__tests__/runner-recovery-wiring.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/platform-apple/src/runner/__tests__/runner-recovery-wiring.test.ts b/packages/platform-apple/src/runner/__tests__/runner-recovery-wiring.test.ts index 244cc1a186..991f170aeb 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-recovery-wiring.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-recovery-wiring.test.ts @@ -171,7 +171,7 @@ test.each(Object.values(LOST_RESPONSE_MUTATION_ROWS))( // #3074: `status` answers `notAccepted`, as a runner whose journal did not survive a restart between // the send and the probe would. That is no proof the first send did not run. test.each(Object.values(LOST_RESPONSE_MUTATION_ROWS))( - 'a $acceptanceCommand whose reply is lost and whose status answers notAccepted is sent once', + 'a $acceptanceCommand whose status answers notAccepted fails as runner_reply_lost, sent once', async ({ runnerCommand, request }) => { server = await startFakeRunnerServer({ [runnerCommand]: [{ kind: 'hangUp' }], @@ -242,7 +242,7 @@ function sendsOf(target: FakeRunnerServer, runnerCommand: string): number { } test.each(Object.values(LOST_RESPONSE_MUTATION_ROWS))( - 'a $acceptanceCommand whose runner dies mid-command is not sent to the restarted runner', + 'a $acceptanceCommand whose runner dies mid-command fails as runner_reply_lost, not resent', async ({ runnerCommand, request }) => { const restarted = await runnerDiesOnCommand(runnerCommand, { readText: [{ kind: 'ok', data: { text: 'after' } }], From 4c9c1dfa52725fe9eae6cc02affcae73398b985b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 1 Oct 2026 08:48:17 +0200 Subject: [PATCH 11/18] test(apple-runner): the runner read-only set matches the registry's observes-app commands --- .../runner-read-only-registry-parity.test.ts | 120 ++++++++++++++++++ 1 file changed, 120 insertions(+) create mode 100644 src/__tests__/runner-read-only-registry-parity.test.ts diff --git a/src/__tests__/runner-read-only-registry-parity.test.ts b/src/__tests__/runner-read-only-registry-parity.test.ts new file mode 100644 index 0000000000..5326f81753 --- /dev/null +++ b/src/__tests__/runner-read-only-registry-parity.test.ts @@ -0,0 +1,120 @@ +import assert from 'node:assert/strict'; +import { test } from 'vitest'; +import { resolveCommandRecordingEffect } from '@agent-device/command-registry/registry'; +import { fileURLToPath } from 'node:url'; + +type RunnerName = string; +type RunnerCommand = { command: string; action?: string }; +type RunnerTraits = { + isReadOnlyRunnerCommand(command: RunnerCommand): boolean; + RUNNER_COMMAND_TRAITS: Record; +}; + +// The traits module is package-private and exports no subpath; loading it by computed file URL keeps +// this one cross-package parity check from adding a public subpath just for a test. +const { isReadOnlyRunnerCommand, RUNNER_COMMAND_TRAITS } = (await import( + fileURLToPath( + new URL('../../packages/platform-apple/src/runner/runner-command-traits.ts', import.meta.url), + ) +)) as RunnerTraits; + +type RegistryRequest = Readonly<{ command: string; positionals?: readonly string[] }>; + +// The runner owns no mapping to the registry, so this is the one declared place that pairs each +// runner wire command with the registry request that issues it. A runner command with no counterpart +// is runner-internal plumbing: it must be listed in RUNNER_INTERNAL and is checked on its own. +const REGISTRY_COUNTERPART: Record = { + tap: { command: 'press' }, + mouseClick: { command: 'click' }, + longPress: { command: 'longpress' }, + drag: { command: 'swipe' }, + remotePress: { command: 'tv-remote' }, + type: { command: 'type' }, + swipe: { command: 'swipe' }, + scroll: { command: 'scroll' }, + desktopScroll: { command: 'scroll' }, + findText: { command: 'find', positionals: ['text', 'x', 'exists'] }, + querySelector: { command: 'is' }, + readText: { command: 'get' }, + snapshot: { command: 'snapshot' }, + screenshot: { command: 'screenshot' }, + backInApp: { command: 'back' }, + backSystem: { command: 'back' }, + home: { command: 'home' }, + rotate: { command: 'orientation' }, + gesture: { command: 'gesture' }, + appSwitcher: { command: 'app-switcher' }, + actionButton: { command: 'action-button' }, + keyboardDismiss: { command: 'keyboard', positionals: ['dismiss'] }, + keyboardReturn: { command: 'keyboard', positionals: ['dismiss'] }, + pasteboardWrite: { command: 'clipboard', positionals: ['write', 'x'] }, +}; + +// Runner commands that drive runner or app lifecycle, not a user-visible command, with whether the +// resend gate may treat them as reads. +const RUNNER_INTERNAL: Readonly> = { + status: true, + uptime: true, + appState: true, + gestureViewport: true, + sequence: false, + shutdown: false, + activate: false, + terminate: false, + targetReset: false, +}; + +// `record` observes the app, but starting or stopping the recorder changes runner state, so a +// restart must not resend either; the runner is stricter than the registry here on purpose. +const RUNNER_STRICTER_THAN_REGISTRY: ReadonlySet = new Set([ + 'recordStart', + 'recordStop', +]); + +const RUNNER_COMMANDS = Object.keys(RUNNER_COMMAND_TRAITS); + +test('every runner command is paired with a registry request, internal, declared stricter, or the alert case', () => { + const declared = [ + ...Object.keys(REGISTRY_COUNTERPART), + ...Object.keys(RUNNER_INTERNAL), + ...RUNNER_STRICTER_THAN_REGISTRY, + 'alert', + ].sort(); + assert.deepEqual(declared, [...RUNNER_COMMANDS].sort()); +}); + +test('the runner read-only set equals the runner commands whose registry request observes the app', () => { + for (const [name, request] of Object.entries(REGISTRY_COUNTERPART)) { + const effect = resolveCommandRecordingEffect({ ...request, flags: {} } as never); + assert.equal( + isReadOnlyRunnerCommand({ command: name }), + effect === 'observes-app', + `${name} -> ${request.command}`, + ); + } + for (const [name, readOnly] of Object.entries(RUNNER_INTERNAL)) { + assert.equal(isReadOnlyRunnerCommand({ command: name }), readOnly, name); + } + for (const name of RUNNER_STRICTER_THAN_REGISTRY) { + assert.equal(isReadOnlyRunnerCommand({ command: name }), false, name); + assert.equal( + resolveCommandRecordingEffect({ command: 'record', flags: {} } as never), + 'observes-app', + ); + } +}); + +test('alert actions agree with the registry: only get observes', () => { + for (const action of [undefined, 'get', 'accept', 'dismiss']) { + const effect = resolveCommandRecordingEffect({ + command: 'alert', + positionals: action ? [action] : [], + flags: {}, + }); + assert.equal( + isReadOnlyRunnerCommand({ command: 'alert', action }), + effect === 'observes-app', + String(action), + ); + } +}); From 2ffd18aaabe3fb9a31f94676547cf84a53429392 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 1 Oct 2026 09:01:48 +0200 Subject: [PATCH 12/18] fix(apple-runner): decide the restart resend in the connect-failure classifier shouldRestartRunnerBeforeCommandSend now takes the command and owns the write-proof rule: a connect-loop failure restarts and resends only when no attempt could have written the command, or the command is read-only. An exit-28 POST that carries dispatched unknown no longer restarts a mutation, whatever call site asks. The call-site guard in executeRunnerCommandAttempt is gone. --- .../runner-error-classification.test.ts | 38 ++++++++++++------- ...nner-lifecycle-dispatch-disclosure.test.ts | 19 ++++++++++ .../src/runner/runner-error-classification.ts | 22 ++++++++--- .../src/runner/runner-lifecycle.ts | 10 +---- 4 files changed, 62 insertions(+), 27 deletions(-) diff --git a/packages/platform-apple/src/runner/__tests__/runner-error-classification.test.ts b/packages/platform-apple/src/runner/__tests__/runner-error-classification.test.ts index d5431f1322..bef2bd9c51 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-error-classification.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-error-classification.test.ts @@ -15,7 +15,10 @@ import { shouldRestartRunnerBeforeCommandSend, shouldRetryRunnerConnectError, } from '../runner-error-classification.ts'; -import { runnerConnectFailure } from './runner-session-fixtures.ts'; +import { runnerConnectFailure, unwrittenConnectRefusal } from './runner-session-fixtures.ts'; + +const TAP = { command: 'tap' } as const; +const SNAPSHOT = { command: 'snapshot' } as const; function commandFailed(message: string, details?: Record): AppError { return new AppError('COMMAND_FAILED', message, details); @@ -165,7 +168,7 @@ test('a deadline on its own earns no recovery verdict', () => { timeoutMs: 45_000, }); assert.equal(isRetryableRunnerError(deadline), false); - assert.equal(shouldRestartRunnerBeforeCommandSend(deadline), false); + assert.equal(shouldRestartRunnerBeforeCommandSend(deadline, SNAPSHOT), false); assert.equal(shouldRestartRunnerAfterReadinessPreflight(deadline), false); assert.equal(shouldRebuildCachedRunnerArtifact(deadline), false); assert.equal(shouldRetryRunnerConnectError(deadline), true); @@ -244,12 +247,16 @@ test('ordinary errors are never session-fatal', () => { // --- restart-before-send axis (shouldRestartRunnerBeforeCommandSend) --- test('a refused connection before send restarts the session', () => { - assert.equal( - shouldRestartRunnerBeforeCommandSend( - runnerConnectFailure('runner_connect_refused', 'Runner did not accept connection'), - ), - true, - ); + assert.equal(shouldRestartRunnerBeforeCommandSend(unwrittenConnectRefusal(), TAP), true); +}); + +test('a connect failure whose POST may have been written restarts only a read', () => { + // simctl curl exit 28: the POST left, then the request timed out. + const writtenThenTimedOut = runnerConnectFailure('runner_connect_refused', undefined, { + dispatched: 'unknown', + }); + assert.equal(shouldRestartRunnerBeforeCommandSend(writtenThenTimedOut, TAP), false); + assert.equal(shouldRestartRunnerBeforeCommandSend(writtenThenTimedOut, SNAPSHOT), true); }); test('an early exit or a foreign transport failure earns no restart before send', () => { @@ -257,8 +264,11 @@ test('an early exit or a foreign transport failure earns no restart before send' 'xcodebuild_exited_early', 'xcodebuild exited early: runner did not accept connection', ); - assert.equal(shouldRestartRunnerBeforeCommandSend(earlyExit), false); - assert.equal(shouldRestartRunnerBeforeCommandSend(commandFailed('socket hang up')), false); + assert.equal(shouldRestartRunnerBeforeCommandSend(earlyExit, SNAPSHOT), false); + assert.equal( + shouldRestartRunnerBeforeCommandSend(commandFailed('socket hang up'), SNAPSHOT), + false, + ); }); // --- typed connect-failure reasons (agent-device's own connect path) --- @@ -269,7 +279,7 @@ test('xcodebuild_exited_early is decided by the typed reason, not the message', assert.equal(isRetryableRunnerError(error), false, message); assert.equal(shouldRetryRunnerConnectError(error), false, message); assert.equal(shouldRebuildCachedRunnerArtifact(error), false, message); - assert.equal(shouldRestartRunnerBeforeCommandSend(error), false, message); + assert.equal(shouldRestartRunnerBeforeCommandSend(error, SNAPSHOT), false, message); } // The same words without the reason earn no terminal verdict. const untyped = commandFailed('Runner did not accept connection (xcodebuild exited early)'); @@ -282,12 +292,12 @@ test('runner_connect_refused is decided by the typed reason, not the message', ( assert.equal(isRetryableRunnerError(error), true, message); assert.equal(shouldRetryRunnerConnectError(error), true, message); assert.equal(shouldRebuildCachedRunnerArtifact(error), true, message); - assert.equal(shouldRestartRunnerBeforeCommandSend(error), true, message); + assert.equal(shouldRestartRunnerBeforeCommandSend(error, SNAPSHOT), true, message); } const untyped = commandFailed('Runner did not accept connection'); assert.equal(isRetryableRunnerError(untyped), false); assert.equal(shouldRebuildCachedRunnerArtifact(untyped), false); - assert.equal(shouldRestartRunnerBeforeCommandSend(untyped), false); + assert.equal(shouldRestartRunnerBeforeCommandSend(untyped, SNAPSHOT), false); }); test('runner_endpoint_probe_exhausted is decided by the typed reason, not the message', () => { @@ -295,7 +305,7 @@ test('runner_endpoint_probe_exhausted is decided by the typed reason, not the me const error = runnerConnectFailure('runner_endpoint_probe_exhausted', message); assert.equal(shouldRebuildCachedRunnerArtifact(error), true, message); assert.equal(isRetryableRunnerError(error), false, message); - assert.equal(shouldRestartRunnerBeforeCommandSend(error), false, message); + assert.equal(shouldRestartRunnerBeforeCommandSend(error, SNAPSHOT), false, message); assert.equal(shouldRetryRunnerConnectError(error), true, message); } assert.equal( diff --git a/packages/platform-apple/src/runner/__tests__/runner-lifecycle-dispatch-disclosure.test.ts b/packages/platform-apple/src/runner/__tests__/runner-lifecycle-dispatch-disclosure.test.ts index fa1635cabf..922b107275 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-lifecycle-dispatch-disclosure.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-lifecycle-dispatch-disclosure.test.ts @@ -254,6 +254,25 @@ for (const row of ROWS) { }); } +test('a mutation whose connect-loop POST timed out after writing is not restarted or resent', async () => { + stubConnectLoopFailure(writtenThenLost); + mockEnsureRunnerSession.mockResolvedValueOnce(makeRunnerSession()); + mockExecuteRunnerCommandWithSession.mockRejectedValue( + new AppError('COMMAND_FAILED', 'status probe failed'), + ); + await assert.rejects(tap(), (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.details?.runnerRestarted, undefined); + assert.equal(error.details?.dispatched, 'unknown'); + return true; + }); + assert.equal(mockEnsureRunnerSession.mock.calls.length, 1, 'the runner is not restarted'); + const taps = mockExecuteRunnerCommandWithSession.mock.calls.filter( + ([, , command]) => command.command === 'tap', + ); + assert.equal(taps.length, 1, 'the tap is sent once'); +}); + test('a plain Error before the exchange is normalized and discloses no', async () => { mockEnsureRunnerSession.mockRejectedValueOnce(new Error('spawn EACCES')); await assert.rejects(tap(), (error: unknown) => { diff --git a/packages/platform-apple/src/runner/runner-error-classification.ts b/packages/platform-apple/src/runner/runner-error-classification.ts index 772932e398..b26caaf0c1 100644 --- a/packages/platform-apple/src/runner/runner-error-classification.ts +++ b/packages/platform-apple/src/runner/runner-error-classification.ts @@ -13,7 +13,9 @@ import { MAIN_THREAD_TIMEOUT_RUNNER_CODE, RUNNER_BUSY_RUNNER_CODE, RUNNER_WEDGED_RUNNER_CODE, + type RunnerCommand, } from './runner-contract.ts'; +import { isReadOnlyRunnerCommand } from './runner-command-traits.ts'; export const RUNNER_CACHE_RECOVERY_HINT = 'If runner build products look stale or corrupted, run `pnpm clean:xcuitest` in a local checkout, or remove ~/.agent-device/apple-runner/derived, then retry.'; @@ -610,11 +612,21 @@ export function resolveRunnerFatalErrorReason(error: unknown): string | undefine } /** - * A connect-shaped failure that surfaced before the command was sent: restart - * the runner session and replay the command, rather than probing a runner - * that never accepted the connection. + * A connect-shaped failure that lets the session restart and resend `command`, rather than probing a + * runner that never accepted the connection. The connect loop posts the command on every attempt, so + * its failure restarts only when no attempt could have written the command, or when the command is + * read-only: a POST that timed out after it was written (simctl curl exit 28) is no proof the + * command did not run, and a mutation is never resent on it. */ -export function shouldRestartRunnerBeforeCommandSend(error: unknown): boolean { +export function shouldRestartRunnerBeforeCommandSend( + error: unknown, + command: RunnerCommand, +): boolean { + if (!isRunnerConnectRefusal(error)) return false; + return isRunnerCommandProvablyUnwritten(error) || isReadOnlyRunnerCommand(command); +} + +function isRunnerConnectRefusal(error: unknown): boolean { return runnerErrorVerdict(error, 'restartBeforeSend') ?? false; } @@ -625,7 +637,7 @@ export function shouldRestartRunnerBeforeCommandSend(error: unknown): boolean { */ export function isRunnerPreSendRefusal(error: unknown): boolean { return ( - (shouldRestartRunnerBeforeCommandSend(error) && isRunnerCommandProvablyUnwritten(error)) || + (isRunnerConnectRefusal(error) && isRunnerCommandProvablyUnwritten(error)) || shouldRestartRunnerAfterReadinessPreflight(error) || isRunnerBusyError(error) ); diff --git a/packages/platform-apple/src/runner/runner-lifecycle.ts b/packages/platform-apple/src/runner/runner-lifecycle.ts index d3ab81a5bc..31c912d5b6 100644 --- a/packages/platform-apple/src/runner/runner-lifecycle.ts +++ b/packages/platform-apple/src/runner/runner-lifecycle.ts @@ -43,7 +43,6 @@ import type { } from './runner-provider.ts'; import { markRunnerXctestrunArtifactBadForRun } from './runner-xctestrun.ts'; import { handleRunnerTransportErrorAfterCommandSend } from './runner-command-recovery.ts'; -import { isReadOnlyRunnerCommand } from './runner-command-traits.ts'; import { buildRunnerRecycleBudgetExhaustedError, commitRunnerRecycle, @@ -352,12 +351,7 @@ async function executeRunnerCommandAttempt( appErr, ); } - const firstAttemptUnwritten = isRunnerPreSendRefusal(appErr); - if ( - shouldRestartRunnerBeforeCommandSend(appErr) && - session && - (firstAttemptUnwritten || isReadOnlyRunnerCommand(command)) - ) { + if (shouldRestartRunnerBeforeCommandSend(appErr, command) && session) { assertRunnerRequestActive(options.requestId); return await restartSessionAndRunCommand({ device, @@ -366,7 +360,7 @@ async function executeRunnerCommandAttempt( options, signal, restartReason: 'runner_connect_failed_before_command_send', - firstAttemptUnwritten, + firstAttemptUnwritten: isRunnerPreSendRefusal(appErr), }); } if (session && shouldRestartRunnerAfterReadinessPreflight(appErr)) { From 66ef67596b75a1b805b160c2adf56ff9c51b27bf Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 1 Oct 2026 09:03:04 +0200 Subject: [PATCH 13/18] refactor(apple-runner): carry the first attempt's dispatch disclosure through the restart restartSessionAndRunCommand takes firstAttemptDispatched: DispatchDisclosure instead of a boolean that three ternaries decoded back into no or unknown. resolveFirstAttemptDispatch computes it once beside isRunnerCommandProvablyUnwritten, and the restart rule reads the same answer. The readiness-preflight call site passes no. --- ...nner-lifecycle-dispatch-disclosure.test.ts | 16 ++++++++++++ .../src/runner/runner-error-classification.ts | 8 +++++- .../src/runner/runner-lifecycle.ts | 25 +++++++++++-------- 3 files changed, 37 insertions(+), 12 deletions(-) diff --git a/packages/platform-apple/src/runner/__tests__/runner-lifecycle-dispatch-disclosure.test.ts b/packages/platform-apple/src/runner/__tests__/runner-lifecycle-dispatch-disclosure.test.ts index 922b107275..590dd901e1 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-lifecycle-dispatch-disclosure.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-lifecycle-dispatch-disclosure.test.ts @@ -273,6 +273,22 @@ test('a mutation whose connect-loop POST timed out after writing is not restarte assert.equal(taps.length, 1, 'the tap is sent once'); }); +test('a read whose first POST may have been written and whose restart fails discloses unknown', async () => { + stubConnectLoopFailure(writtenThenLost); + mockEnsureRunnerSession + .mockResolvedValueOnce(makeRunnerSession()) + .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'runner restart failed')); + await assert.rejects( + runAppleRunnerCommand(IOS_SIMULATOR, { command: 'snapshot' }), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.details?.runnerRestartReason, 'runner_connect_failed_before_command_send'); + assert.equal(error.details?.dispatched, 'unknown'); + return true; + }, + ); +}); + test('a plain Error before the exchange is normalized and discloses no', async () => { mockEnsureRunnerSession.mockRejectedValueOnce(new Error('spawn EACCES')); await assert.rejects(tap(), (error: unknown) => { diff --git a/packages/platform-apple/src/runner/runner-error-classification.ts b/packages/platform-apple/src/runner/runner-error-classification.ts index b26caaf0c1..95aa68697e 100644 --- a/packages/platform-apple/src/runner/runner-error-classification.ts +++ b/packages/platform-apple/src/runner/runner-error-classification.ts @@ -3,6 +3,7 @@ import { isRequestCanceledDetails, type AppErrorCode, type AppErrorDetails, + type DispatchDisclosure, } from '@agent-device/kernel/errors'; import { isCommandTimeoutError, @@ -623,7 +624,7 @@ export function shouldRestartRunnerBeforeCommandSend( command: RunnerCommand, ): boolean { if (!isRunnerConnectRefusal(error)) return false; - return isRunnerCommandProvablyUnwritten(error) || isReadOnlyRunnerCommand(command); + return resolveFirstAttemptDispatch(error) === 'no' || isReadOnlyRunnerCommand(command); } function isRunnerConnectRefusal(error: unknown): boolean { @@ -653,6 +654,11 @@ export function isRunnerCommandProvablyUnwritten(error: unknown): boolean { return isConnectionRefused(error, 0); } +/** What a failed connect attempt proves about writing the command: `no` only with that proof. */ +export function resolveFirstAttemptDispatch(error: unknown): DispatchDisclosure { + return isRunnerCommandProvablyUnwritten(error) ? 'no' : 'unknown'; +} + function isConnectionRefused(error: unknown, depth: number): boolean { if (depth > 4 || typeof error !== 'object' || error === null) return false; if ((error as { code?: unknown }).code === 'ECONNREFUSED') return true; diff --git a/packages/platform-apple/src/runner/runner-lifecycle.ts b/packages/platform-apple/src/runner/runner-lifecycle.ts index 31c912d5b6..4f5e51b5a0 100644 --- a/packages/platform-apple/src/runner/runner-lifecycle.ts +++ b/packages/platform-apple/src/runner/runner-lifecycle.ts @@ -5,6 +5,7 @@ import { discloseDispatch, discloseUnclassifiedDispatch, isRequestCanceledError, + type DispatchDisclosure, } from '@agent-device/kernel/errors'; import type { DeviceInfo } from '@agent-device/kernel/device'; import type { ReadinessPhase } from '@agent-device/contracts/wait'; @@ -31,6 +32,7 @@ import { isRetryableRunnerError, isRunnerPreSendRefusal, isStructuredRunnerFailure, + resolveFirstAttemptDispatch, shouldRebuildCachedRunnerArtifact, shouldRestartRunnerAfterReadinessPreflight, shouldRestartRunnerBeforeCommandSend, @@ -360,7 +362,7 @@ async function executeRunnerCommandAttempt( options, signal, restartReason: 'runner_connect_failed_before_command_send', - firstAttemptUnwritten: isRunnerPreSendRefusal(appErr), + firstAttemptDispatched: resolveFirstAttemptDispatch(appErr), }); } if (session && shouldRestartRunnerAfterReadinessPreflight(appErr)) { @@ -373,7 +375,7 @@ async function executeRunnerCommandAttempt( signal, restartReason: 'runner_readiness_preflight_failed_before_command_send', recoveredDiagnosticPhase: 'ios_runner_readiness_preflight_recovered', - firstAttemptUnwritten: true, + firstAttemptDispatched: 'no', }); } // Status recovery answers "did the command I lost the response to run?". A structured reply @@ -406,10 +408,10 @@ async function restartSessionAndRunCommand(params: { | 'runner_readiness_preflight_failed_before_command_send'; recoveredDiagnosticPhase?: string; /** - * The failed first attempt provably never wrote the command. When it may have, the replay can - * double-send, and no failure of this restart may claim `no`. + * What the failed first attempt disclosed about writing the command. After `unknown`, the replay + * can double-send, and no failure of this restart may claim `no`. */ - firstAttemptUnwritten: boolean; + firstAttemptDispatched: DispatchDisclosure; }): Promise> { const { device, command, options, signal, restartReason } = params; // At most one recycle per request: when the budget is spent, fail fast and KEEP the current @@ -419,7 +421,7 @@ async function restartSessionAndRunCommand(params: { if (!tryBeginRunnerRecycle(recycleKey)) { throw discloseDispatch( buildRunnerRecycleBudgetExhaustedError(command, options), - params.firstAttemptUnwritten ? 'no' : 'unknown', + params.firstAttemptDispatched, ); } await invalidateRunnerSession(params.session, restartReason); @@ -478,7 +480,7 @@ function markRunnerRestartError( error: unknown, params: Pick< Parameters[0], - 'session' | 'command' | 'options' | 'restartReason' | 'firstAttemptUnwritten' + 'session' | 'command' | 'options' | 'restartReason' | 'firstAttemptDispatched' >, restartedSession?: RunnerSession, ): unknown { @@ -500,7 +502,7 @@ function markRunnerRestartError( }, error.cause ?? error, ); - return discloseRestartDispatch(marked, params.firstAttemptUnwritten, restartedSession); + return discloseRestartDispatch(marked, params.firstAttemptDispatched, restartedSession); } /** @@ -510,11 +512,12 @@ function markRunnerRestartError( */ function discloseRestartDispatch( error: AppError, - firstAttemptUnwritten: boolean, + firstAttemptDispatched: DispatchDisclosure, restartedSession: RunnerSession | undefined, ): AppError { - if (!restartedSession) return discloseDispatch(error, firstAttemptUnwritten ? 'no' : 'unknown'); - if (!firstAttemptUnwritten) return discloseDispatch(error, 'unknown'); + if (!restartedSession || firstAttemptDispatched === 'unknown') { + return discloseDispatch(error, firstAttemptDispatched); + } return discloseUnclassifiedDispatch(error, isRunnerPreSendRefusal(error) ? 'no' : 'unknown'); } From 4e88908fc322514cf25ddf27cad030351124f4d2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 1 Oct 2026 09:11:09 +0200 Subject: [PATCH 14/18] refactor(apple-runner): build every lost-reply failure in one place with one reason Status recovery verdicts now carry either the runner's own answer or the facts of a lost reply. applyRunnerTransportRecovery turns a lost reply into one buildLostReplyError for a mutation, so runner_reply_lost is set on every lost-reply verdict (completed without a retained reply, still in flight, unknown or missing lifecycle, status probe failed, status unavailable) and the transport's reason stays under transportReason. A read keeps its transport error, which the read-only resend loop resends, so it never carries the reason. The four hand-built copies are gone. --- .../0011-interaction-guarantee-contract.md | 8 +- .../__tests__/runner-command-recovery.test.ts | 10 +- .../runner-dispatch-disclosure.test.ts | 35 ++- .../src/runner/runner-command-recovery.ts | 273 +++++++++--------- 4 files changed, 180 insertions(+), 146 deletions(-) diff --git a/docs/adr/0011-interaction-guarantee-contract.md b/docs/adr/0011-interaction-guarantee-contract.md index 070bba4d9d..8af24a26bc 100644 --- a/docs/adr/0011-interaction-guarantee-contract.md +++ b/docs/adr/0011-interaction-guarantee-contract.md @@ -194,9 +194,11 @@ producer. The Apple runner does not resend a mutating command whose first send may have run: a restart resends only a command the first attempt provably did not write, -or a read-only one. A mutation whose runner dies mid-command fails with -`reason: runner_reply_lost` and `dispatched: unknown` (row -`ios-runner.transport.written-then-lost`). +or a read-only one. A mutation whose reply stays lost (the runner dies +mid-command, or status recovery finds neither a retained result nor a runner +answer) fails with `reason: runner_reply_lost` and `dispatched: unknown` (row +`ios-runner.transport.written-then-lost` and the lost-reply `ios-runner.status.*` +rows). A read keeps its transport error and is resent. Remaining gaps: a failure before the router's locked scope (session resolution, lock acquisition, lease and daemon-policy admission) never reaches diff --git a/packages/platform-apple/src/runner/__tests__/runner-command-recovery.test.ts b/packages/platform-apple/src/runner/__tests__/runner-command-recovery.test.ts index dc6689a591..9eb91b840d 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-command-recovery.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-command-recovery.test.ts @@ -158,13 +158,19 @@ test('a failing status probe retains the invalidation and names the lost reply', assert.equal(invalidate.mock.calls.length, 1); }); -test('a command without an id cannot be probed: invalidate and rethrow', async () => { +test('a command without an id cannot be probed: invalidate and name the lost reply', async () => { const { result, invalidate, transportError } = await runRecovery({ script: [], command: { command: 'tap', x: 10, y: 10 } as RunnerCommand, }); - await assert.rejects(result, (error: unknown) => error === transportError); + await assert.rejects(result, (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.cause, transportError); + assert.equal(error.details?.reason, RUNNER_REPLY_LOST_REASON); + assert.equal(error.details?.recovery, 'status_recovery_unavailable'); + return true; + }); assert.equal(invalidate.mock.calls.length, 1); assert.equal(server?.requests.length, 0); }); diff --git a/packages/platform-apple/src/runner/__tests__/runner-dispatch-disclosure.test.ts b/packages/platform-apple/src/runner/__tests__/runner-dispatch-disclosure.test.ts index f69501c4c8..3eac6e05d8 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-dispatch-disclosure.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-dispatch-disclosure.test.ts @@ -9,7 +9,10 @@ import { dispatchDisclosureRowsOwnedBy, } from '@agent-device/contracts/dispatch-disclosure-fixtures'; import { IOS_SIMULATOR } from './device-fixtures.ts'; -import { handleRunnerTransportErrorAfterCommandSend } from '../runner-command-recovery.ts'; +import { + handleRunnerTransportErrorAfterCommandSend, + RUNNER_REPLY_LOST_REASON, +} from '../runner-command-recovery.ts'; import { isReadOnlyRunnerCommand } from '../runner-command-traits.ts'; import type { RunnerCommand } from '../runner-contract.ts'; import { @@ -206,6 +209,16 @@ const DRIVERS: Record Promise> = { lostResponse([], { command: 'tap', x: 10, y: 10 } as RunnerCommand), }; +/** The rows whose mutation's reply stayed lost: each fails as runner_reply_lost and is not resent. */ +const REPLY_LOST_ROWS: ReadonlySet = new Set([ + 'ios-runner.status.completed-without-retained-reply', + 'ios-runner.status.accepted', + 'ios-runner.status.started', + 'ios-runner.status.notAccepted', + 'ios-runner.status.probe-failed', + 'ios-runner.status.unavailable', +]); + const ROWS = dispatchDisclosureRowsOwnedBy( import.meta.url, fs.readFileSync(DISPATCH_DISCLOSURE_TABLE_PATH, 'utf8'), @@ -222,7 +235,27 @@ for (const row of ROWS) { await assert.rejects(drive(), (error: unknown) => { assert.ok(error instanceof AppError); assert.equal(error.details?.dispatched, row.dispatched); + assert.equal(error.details?.reason === RUNNER_REPLY_LOST_REASON, REPLY_LOST_ROWS.has(row.id)); return true; }); }); } + +const SNAPSHOT: RunnerCommand = { command: 'snapshot', commandId: 'cmd-1' }; + +test.each([ + ['the status probe fails', [{ kind: 'runnerError', code: 'COMMAND_FAILED', message: 'down' }]], + ['status answers notAccepted', statusReply({ lifecycleState: 'notAccepted' })], + ['status answers started', statusReply({ lifecycleState: 'started' })], + ['status answers completed with no retained reply', statusReply({ lifecycleState: 'completed' })], +] as const)( + 'a read whose reply is lost when %s keeps the transport error it is resent on', + async (_, status) => { + await assert.rejects(lostResponse([...status], SNAPSHOT), (error: unknown) => { + assert.ok(error instanceof AppError); + assert.notEqual(error.details?.reason, RUNNER_REPLY_LOST_REASON); + assert.equal(isRetryableRunnerError(error), true); + return true; + }); + }, +); diff --git a/packages/platform-apple/src/runner/runner-command-recovery.ts b/packages/platform-apple/src/runner/runner-command-recovery.ts index a0f071677e..253715c17b 100644 --- a/packages/platform-apple/src/runner/runner-command-recovery.ts +++ b/packages/platform-apple/src/runner/runner-command-recovery.ts @@ -16,25 +16,34 @@ import { executeRunnerCommandWithSession, type RunnerSession } from './runner-se type RunnerTransportRecovery = | { type: 'recovered'; data: Record; reason: string; lifecycleState?: string } - | { - type: 'skipInvalidation'; - error: AppError; - dispatched: DispatchDisclosure; + | ({ + type: 'skipInvalidation' | 'retainInvalidation'; reason: string; lifecycleState?: string; - } - | { - type: 'retainInvalidation'; - error?: AppError; - dispatched: DispatchDisclosure; - reason: string; - lifecycleState?: string; - }; + } & RunnerRecoveryFailure); + +/** + * What a verdict that recovered no result fails with: the runner's own answer read back from its + * journal, or a reply that stayed lost. + */ +type RunnerRecoveryFailure = + | { runnerAnswer: AppError; dispatched: DispatchDisclosure } + | { lostReply: LostReply }; + +/** What status recovery learned about a command whose reply stayed lost. */ +type LostReply = Readonly<{ + recovery: string; + lifecycleState?: string; + /** Defaults to the transport error's message. */ + message?: string; + hint: string; +}>; type RunnerTransportRecoveryContext = { command: RunnerCommand; session: RunnerSession; transportError: AppError; + options: AppleRunnerCommandOptions; invalidationReason: string; invalidateSession: (session: RunnerSession, reason: string) => Promise; }; @@ -48,8 +57,9 @@ type RunnerReadinessPreflightRecoveryDetails = { const RUNNER_STATUS_RECOVERY_TIMEOUT_MS = 3_000; /** - * `details.reason` of a failure whose command was written, lost its reply, and has no proof it did - * not run. The command is not resent; the caller observes the screen before acting again. + * `details.reason` of a mutation whose reply stayed lost: status recovery found no result and no + * runner answer, so nothing proves the command did not run. The command is not resent; the caller + * observes the screen before acting again. A read never carries it, because it is resent. */ export const RUNNER_REPLY_LOST_REASON = 'runner_reply_lost'; @@ -76,26 +86,64 @@ export async function handleRunnerTransportErrorAfterCommandSend(params: { command, session, transportError, + options, invalidationReason, invalidateSession: params.invalidateSession, }); } async function applyRunnerTransportRecovery( - recovery: RunnerTransportRecovery | undefined, + recovery: RunnerTransportRecovery, context: RunnerTransportRecoveryContext, ): Promise> { - if (!recovery) { - return await retainRunnerInvalidation(context, 'status_recovery_unavailable', 'unknown'); - } if (recovery.type === 'recovered') return recoverRunnerResponse(recovery, context); - if (recovery.type === 'skipInvalidation') throw skipRunnerInvalidation(recovery, context); - return await retainRunnerInvalidation( - context, - recovery.reason, - recovery.dispatched, - recovery.lifecycleState, - recovery.error, + const failure = resolveRunnerRecoveryFailure(recovery, context); + if (recovery.type === 'skipInvalidation') { + throw skipRunnerInvalidation(recovery, context, failure); + } + return await retainRunnerInvalidation(recovery, context, failure); +} + +/** + * A lost reply fails a mutation as {@link RUNNER_REPLY_LOST_REASON}: it is not resent. A read keeps + * the transport error, because `runAppleRunnerCommand` resends a read on exactly that error. + */ +function resolveRunnerRecoveryFailure( + failure: RunnerRecoveryFailure, + context: RunnerTransportRecoveryContext, +): AppError { + if ('runnerAnswer' in failure) return discloseDispatch(failure.runnerAnswer, failure.dispatched); + if (isReadOnlyRunnerCommand(context.command)) { + return discloseDispatch(context.transportError, 'unknown'); + } + return discloseDispatch(buildLostReplyError(context, failure.lostReply), 'unknown'); +} + +/** The one shape of a mutation's lost-reply failure; the transport's own reason stays readable. */ +function buildLostReplyError( + context: RunnerTransportRecoveryContext, + lostReply: LostReply, +): AppError { + const { command, transportError, options } = context; + const transportReason = transportError.details?.reason; + return new AppError( + 'COMMAND_FAILED', + lostReply.message ?? transportError.message, + { + command: command.command, + commandId: command.commandId, + ...(lostReply.lifecycleState === undefined + ? {} + : { lifecycleState: lostReply.lifecycleState }), + reason: RUNNER_REPLY_LOST_REASON, + ...(transportReason === undefined ? {} : { transportReason }), + recovery: lostReply.recovery, + ...readReadinessPreflightRecoveryDetails(transportError), + hint: lostReply.hint, + logPath: options.logPath ?? transportError.details?.logPath, + transportError: transportError.message, + }, + transportError, ); } @@ -115,8 +163,9 @@ function recoverRunnerResponse( } function skipRunnerInvalidation( - recovery: Extract, + recovery: Exclude, context: RunnerTransportRecoveryContext, + failure: AppError, ): AppError { emitRunnerInvalidationDecision({ command: context.command, @@ -126,26 +175,24 @@ function skipRunnerInvalidation( reason: recovery.reason, lifecycleState: recovery.lifecycleState, }); - return discloseDispatch(recovery.error, recovery.dispatched); + return failure; } async function retainRunnerInvalidation( + recovery: Exclude, context: RunnerTransportRecoveryContext, - reason: string, - dispatched: DispatchDisclosure, - lifecycleState?: string, - error?: AppError, + failure: AppError, ): Promise { emitRunnerInvalidationDecision({ command: context.command, session: context.session, transportError: context.transportError, decision: 'retained', - reason, - lifecycleState, + reason: recovery.reason, + lifecycleState: recovery.lifecycleState, }); await context.invalidateSession(context.session, context.invalidationReason); - throw discloseDispatch(error ?? context.transportError, dispatched); + throw failure; } async function tryRecoverRunnerCommandAfterTransportError( @@ -155,8 +202,17 @@ async function tryRecoverRunnerCommandAfterTransportError( transportError: AppError, options: AppleRunnerCommandOptions, signal?: AbortSignal, -): Promise { - if (command.command === 'status' || !command.commandId?.trim()) return undefined; +): Promise { + if (command.command === 'status' || !command.commandId?.trim()) { + return { + type: 'retainInvalidation', + reason: 'status_recovery_unavailable', + lostReply: { + recovery: 'status_recovery_unavailable', + hint: unknownLifecycleStateHint(command.command), + }, + }; + } const readinessPreflight = readReadinessPreflightRecoveryDetails(transportError); let status: Record; try { @@ -182,8 +238,10 @@ async function tryRecoverRunnerCommandAfterTransportError( return { type: 'retainInvalidation', reason: 'status_probe_failed', - dispatched: 'unknown', - error: buildStatusProbeFailedError(command, transportError, options), + lostReply: { + recovery: 'status_probe_failed', + hint: unknownLifecycleStateHint(command.command), + }, }; } @@ -252,9 +310,9 @@ function handleRunnerCommandStatusRecovery( command: RunnerCommand, transportError: AppError, options: AppleRunnerCommandOptions, -): RunnerTransportRecovery | undefined { +): RunnerTransportRecovery { if (lifecycleState === 'completed') { - return handleCompletedRunnerStatus(status, command, transportError, options); + return handleCompletedRunnerStatus(status, command, transportError); } if (lifecycleState === 'failed') { @@ -270,7 +328,13 @@ function handleRunnerCommandStatusRecovery( reason: 'runner_reported_failure', lifecycleState, dispatched: classification.details.dispatched, - error: runnerStatusFailureError(status, classification, command, transportError, options), + runnerAnswer: runnerStatusFailureError( + status, + classification, + command, + transportError, + options, + ), }; } @@ -279,8 +343,16 @@ function handleRunnerCommandStatusRecovery( type: 'skipInvalidation', reason: 'command_still_in_flight', lifecycleState, - dispatched: 'unknown', - error: runnerStatusInFlightError(lifecycleState, command, transportError, options), + lostReply: { + recovery: 'command_still_in_flight', + lifecycleState, + message: `Runner command "${command.command}" is still ${lifecycleState} after the transport response was lost.`, + hint: inFlightAfterLostResponseHint( + command.command, + lifecycleState, + readReadinessPreflightRecoveryDetails(transportError), + ), + }, }; } @@ -288,22 +360,12 @@ function handleRunnerCommandStatusRecovery( type: 'retainInvalidation', reason: lifecycleState ? 'unknown_lifecycle_state' : 'missing_lifecycle_state', lifecycleState, - dispatched: 'unknown', - error: new AppError( - 'COMMAND_FAILED', - `Runner command "${command.command}" lost its transport response and lifecycle status was ${lifecycleState ? `"${lifecycleState}"` : 'missing'}, so agent-device invalidated the runner session instead of replaying the command.`, - { - command: command.command, - commandId: command.commandId, - lifecycleState, - reason: RUNNER_REPLY_LOST_REASON, - recovery: 'lifecycle_state_not_recoverable', - hint: unknownLifecycleStateHint(command.command), - logPath: options.logPath, - transportError: transportError.message, - }, - transportError, - ), + lostReply: { + recovery: 'lifecycle_state_not_recoverable', + lifecycleState, + message: `Runner command "${command.command}" lost its transport response and lifecycle status was ${lifecycleState ? `"${lifecycleState}"` : 'missing'}, so agent-device invalidated the runner session instead of replaying the command.`, + hint: unknownLifecycleStateHint(command.command), + }, }; } @@ -311,7 +373,6 @@ function handleCompletedRunnerStatus( status: Record, command: RunnerCommand, transportError: AppError, - options: AppleRunnerCommandOptions, ): RunnerTransportRecovery { const recovered = parseLifecycleResponseJson(status.lifecycleResponseJson); if (recovered) { @@ -322,36 +383,21 @@ function handleCompletedRunnerStatus( lifecycleState: 'completed', }; } - if (isReadOnlyRunnerCommand(command)) { - return { - type: 'skipInvalidation', - error: transportError, - dispatched: 'unknown', - reason: 'read_only_completed_without_retained_response', - lifecycleState: 'completed', - }; - } - const readinessPreflight = readReadinessPreflightRecoveryDetails(transportError); return { type: 'skipInvalidation', - reason: 'completed_without_retained_response', + reason: isReadOnlyRunnerCommand(command) + ? 'read_only_completed_without_retained_response' + : 'completed_without_retained_response', lifecycleState: 'completed', - dispatched: 'unknown', - error: new AppError( - 'COMMAND_FAILED', - `Runner command "${command.command}" completed after the transport response was lost, but no recoverable response was retained.`, - { - command: command.command, - commandId: command.commandId, - lifecycleState: 'completed', - recovery: 'completed_without_retained_response', - ...readinessPreflight, - hint: completedWithoutRetainedResponseHint(command.command, readinessPreflight), - logPath: options.logPath, - transportError: transportError.message, - }, - transportError, - ), + lostReply: { + recovery: 'completed_without_retained_response', + lifecycleState: 'completed', + message: `Runner command "${command.command}" completed after the transport response was lost, but no recoverable response was retained.`, + hint: completedWithoutRetainedResponseHint( + command.command, + readReadinessPreflightRecoveryDetails(transportError), + ), + }, }; } @@ -387,33 +433,6 @@ function runnerStatusFailureError( ); } -function runnerStatusInFlightError( - lifecycleState: string, - command: RunnerCommand, - transportError: AppError, - options: AppleRunnerCommandOptions, -): AppError { - if (isReadOnlyRunnerCommand(command)) { - return transportError; - } - const readinessPreflight = readReadinessPreflightRecoveryDetails(transportError); - return new AppError( - 'COMMAND_FAILED', - `Runner command "${command.command}" is still ${lifecycleState} after the transport response was lost.`, - { - command: command.command, - commandId: command.commandId, - lifecycleState, - recovery: 'command_still_in_flight', - ...readinessPreflight, - hint: inFlightAfterLostResponseHint(command.command, lifecycleState, readinessPreflight), - logPath: options.logPath, - transportError: transportError.message, - }, - transportError, - ); -} - function parseLifecycleResponseJson(value: unknown): Record | undefined { if (typeof value !== 'string' || value.trim().length === 0) return undefined; let payload: RunnerResponsePayload; @@ -485,32 +504,6 @@ function readReadinessPreflightRecoveryDetails( return details; } -/** The lost reply could not be placed because the status probe itself failed. */ -function buildStatusProbeFailedError( - command: RunnerCommand, - transportError: AppError, - options: AppleRunnerCommandOptions, -): AppError { - return new AppError( - transportError.code, - transportError.message, - { - ...transportError.details, - ...(transportError.details?.reason === undefined - ? {} - : { transportReason: transportError.details.reason }), - command: command.command, - commandId: command.commandId, - reason: RUNNER_REPLY_LOST_REASON, - recovery: 'status_probe_failed', - hint: unknownLifecycleStateHint(command.command), - logPath: options.logPath ?? transportError.details?.logPath, - transportError: transportError.message, - }, - transportError, - ); -} - function unknownLifecycleStateHint(command: string): string { return `The runner did not confirm that "${command}" reached a safe terminal state, so agent-device kept the conservative invalidation path. Run snapshot -i before retrying if the UI may have changed.`; } From 75a418f6b03c2b14345ae6d667796a2f6bd25d64 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 1 Oct 2026 10:04:29 +0200 Subject: [PATCH 15/18] fix(apple-runner): key a lost reply on its typed reason, never on transport text --- .../__tests__/runner-command-recovery.test.ts | 9 ++++---- .../__tests__/runner-command-retry.test.ts | 11 ++++----- .../runner-dispatch-disclosure.test.ts | 6 ++--- .../runner-error-classification.test.ts | 10 ++++++++ ...nner-lifecycle-dispatch-disclosure.test.ts | 23 ++++++++++++++++++- .../__tests__/runner-recovery-wiring.test.ts | 2 +- .../src/runner/runner-command-recovery.ts | 23 +++++++++++-------- .../src/runner/runner-error-classification.ts | 21 +++++++++++++++++ 8 files changed, 78 insertions(+), 27 deletions(-) diff --git a/packages/platform-apple/src/runner/__tests__/runner-command-recovery.test.ts b/packages/platform-apple/src/runner/__tests__/runner-command-recovery.test.ts index 9eb91b840d..8749668dfd 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-command-recovery.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-command-recovery.test.ts @@ -4,11 +4,9 @@ import { afterEach, test, vi } from 'vitest'; import { AppError } from '@agent-device/kernel/errors'; import { IOS_SIMULATOR } from './device-fixtures.ts'; import type { ExecResult } from '@agent-device/host-kit/command'; -import { - handleRunnerTransportErrorAfterCommandSend, - RUNNER_REPLY_LOST_REASON, -} from '../runner-command-recovery.ts'; +import { handleRunnerTransportErrorAfterCommandSend } from '../runner-command-recovery.ts'; import type { RunnerCommand } from '../runner-contract.ts'; +import { RUNNER_REPLY_LOST_REASON } from '../runner-error-classification.ts'; import type { RunnerSession } from '../runner-session.ts'; import { startFakeRunnerServer, @@ -147,7 +145,8 @@ test('a failing status probe retains the invalidation and names the lost reply', await assert.rejects(result, (error: unknown) => { assert.ok(error instanceof AppError); - assert.equal(error.message, transportError.message); + assert.notEqual(error.message, transportError.message); + assert.equal(error.details?.transportError, transportError.message); assert.equal(error.cause, transportError); assert.equal(error.details?.reason, RUNNER_REPLY_LOST_REASON); assert.equal(error.details?.transportReason, 'socket_reset'); diff --git a/packages/platform-apple/src/runner/__tests__/runner-command-retry.test.ts b/packages/platform-apple/src/runner/__tests__/runner-command-retry.test.ts index 459e590401..6ed3423486 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-command-retry.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-command-retry.test.ts @@ -50,6 +50,7 @@ vi.mock('../runner-xctestrun.ts', async () => { import { prepareIosRunner, runAppleRunnerCommand } from '../runner-client.ts'; import { resetRunnerRecycleLedgerForTests } from '../runner-recycle-ledger.ts'; +import { RUNNER_REPLY_LOST_REASON } from '../runner-error-classification.ts'; import type { RunnerXctestrunArtifact } from '../runner-xctestrun.ts'; const requestCancellation = createTestRequestCancellation(); @@ -475,10 +476,9 @@ test('mutating commands keep invalidating when status recovery probe fails', asy await assert.rejects( () => runAppleRunnerCommand(IOS_SIMULATOR, { command: 'tap', x: 120, y: 240 }), (error: unknown) => { - // A failed status probe re-throws the original transport error, not the probe's own. assert.ok(error instanceof AppError); - assert.equal(error.code, 'COMMAND_FAILED'); - assert.equal(error.message, 'fetch failed'); + assert.equal(error.details?.reason, RUNNER_REPLY_LOST_REASON); + assert.equal(error.details?.transportError, 'fetch failed'); return true; }, ); @@ -971,10 +971,9 @@ test('sequence invalidates the session when the status probe fails', async () => steps: [{ kind: 'tap', x: 1, y: 2 }], }), (error: unknown) => { - // A failed status probe re-throws the original transport error, not the probe's own. assert.ok(error instanceof AppError); - assert.equal(error.code, 'COMMAND_FAILED'); - assert.equal(error.message, 'fetch failed'); + assert.equal(error.details?.reason, RUNNER_REPLY_LOST_REASON); + assert.equal(error.details?.transportError, 'fetch failed'); return true; }, ); diff --git a/packages/platform-apple/src/runner/__tests__/runner-dispatch-disclosure.test.ts b/packages/platform-apple/src/runner/__tests__/runner-dispatch-disclosure.test.ts index 3eac6e05d8..1580de20e7 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-dispatch-disclosure.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-dispatch-disclosure.test.ts @@ -9,15 +9,13 @@ import { dispatchDisclosureRowsOwnedBy, } from '@agent-device/contracts/dispatch-disclosure-fixtures'; import { IOS_SIMULATOR } from './device-fixtures.ts'; -import { - handleRunnerTransportErrorAfterCommandSend, - RUNNER_REPLY_LOST_REASON, -} from '../runner-command-recovery.ts'; +import { handleRunnerTransportErrorAfterCommandSend } from '../runner-command-recovery.ts'; import { isReadOnlyRunnerCommand } from '../runner-command-traits.ts'; import type { RunnerCommand } from '../runner-contract.ts'; import { isRetryableRunnerError, isStructuredRunnerFailure, + RUNNER_REPLY_LOST_REASON, } from '../runner-error-classification.ts'; import { runApplePressSeries } from '../runner-sequence.ts'; import { executeRunnerCommandWithSession, type RunnerSession } from '../runner-session.ts'; diff --git a/packages/platform-apple/src/runner/__tests__/runner-error-classification.test.ts b/packages/platform-apple/src/runner/__tests__/runner-error-classification.test.ts index bef2bd9c51..f5870a8f21 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-error-classification.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-error-classification.test.ts @@ -7,6 +7,7 @@ import { } from '../runner-contract.ts'; import { RUNNER_ERROR_RULES, + RUNNER_REPLY_LOST_REASON, isRetryableRunnerError, isRunnerBusyError, resolveRunnerFatalErrorReason, @@ -46,6 +47,15 @@ test('transport-shaped failures are retryable', () => { } }); +test('a lost reply keys on its typed reason, never on the transport text it carries', () => { + for (const message of ['fetch failed', 'connect ECONNREFUSED 127.0.0.1:8100', 'socket hang up']) { + const lostReply = commandFailed(message, { reason: RUNNER_REPLY_LOST_REASON }); + assert.equal(isRetryableRunnerError(lostReply), false, message); + assert.equal(shouldRetryRunnerConnectError(lostReply), false, message); + assert.equal(shouldRestartRunnerBeforeCommandSend(lostReply, TAP), false, message); + } +}); + test('boot-shaped failures are not retryable', () => { assert.equal( isRetryableRunnerError( diff --git a/packages/platform-apple/src/runner/__tests__/runner-lifecycle-dispatch-disclosure.test.ts b/packages/platform-apple/src/runner/__tests__/runner-lifecycle-dispatch-disclosure.test.ts index 590dd901e1..02076736c6 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-lifecycle-dispatch-disclosure.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-lifecycle-dispatch-disclosure.test.ts @@ -40,7 +40,10 @@ vi.mock('../runner-session.ts', async () => { }); import { runAppleRunnerCommand } from '../runner-client.ts'; -import { RUNNER_REPLY_LOST_REASON } from '../runner-command-recovery.ts'; +import { + isRetryableRunnerError, + RUNNER_REPLY_LOST_REASON, +} from '../runner-error-classification.ts'; import type { RunnerCommand } from '../runner-contract.ts'; import { resetRunnerRecycleLedgerForTests } from '../runner-recycle-ledger.ts'; import { waitForRunner } from '../runner-startup-transport.ts'; @@ -273,6 +276,24 @@ test('a mutation whose connect-loop POST timed out after writing is not restarte assert.equal(taps.length, 1, 'the tap is sent once'); }); +test('a mutation whose reply and status probe both fail on transport text is not resent', async () => { + mockEnsureRunnerSession.mockResolvedValueOnce(makeRunnerSession()); + mockExecuteRunnerCommandWithSession + .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'fetch failed')) + .mockRejectedValue(new AppError('COMMAND_FAILED', 'connect ECONNREFUSED 127.0.0.1:8100')); + await assert.rejects(tap(), (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.details?.reason, RUNNER_REPLY_LOST_REASON); + assert.equal(error.details?.transportError, 'fetch failed'); + assert.equal(isRetryableRunnerError(error), false); + return true; + }); + const taps = mockExecuteRunnerCommandWithSession.mock.calls.filter( + ([, , command]) => command.command === 'tap', + ); + assert.equal(taps.length, 1, 'the tap is sent once'); +}); + test('a read whose first POST may have been written and whose restart fails discloses unknown', async () => { stubConnectLoopFailure(writtenThenLost); mockEnsureRunnerSession diff --git a/packages/platform-apple/src/runner/__tests__/runner-recovery-wiring.test.ts b/packages/platform-apple/src/runner/__tests__/runner-recovery-wiring.test.ts index 991f170aeb..9f862ab941 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-recovery-wiring.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-recovery-wiring.test.ts @@ -10,7 +10,7 @@ import type { RunnerSession } from '../runner-session.ts'; import { appleRunnerTestHost } from '../test-host.ts'; import { withAppleRunnerProvider } from '../runner-provider.ts'; import { classifyRunnerReportedError, type RunnerCommand } from '../runner-contract.ts'; -import { RUNNER_REPLY_LOST_REASON } from '../runner-command-recovery.ts'; +import { RUNNER_REPLY_LOST_REASON } from '../runner-error-classification.ts'; import { createRunnerPhaseBudget, requireRunnerPhaseRemainingMs, diff --git a/packages/platform-apple/src/runner/runner-command-recovery.ts b/packages/platform-apple/src/runner/runner-command-recovery.ts index 253715c17b..837391355b 100644 --- a/packages/platform-apple/src/runner/runner-command-recovery.ts +++ b/packages/platform-apple/src/runner/runner-command-recovery.ts @@ -11,6 +11,7 @@ import { type RunnerResponsePayload, } from './runner-contract.ts'; import { isReadOnlyRunnerCommand } from './runner-command-traits.ts'; +import { RUNNER_REPLY_LOST_REASON } from './runner-error-classification.ts'; import type { AppleRunnerCommandOptions } from './runner-provider.ts'; import { executeRunnerCommandWithSession, type RunnerSession } from './runner-session.ts'; @@ -34,8 +35,11 @@ type RunnerRecoveryFailure = type LostReply = Readonly<{ recovery: string; lifecycleState?: string; - /** Defaults to the transport error's message. */ - message?: string; + /** + * Never the transport error's message: classification rows match foreign transport text, and a + * lost reply must not read as the retryable transport failure it wraps. + */ + message: string; hint: string; }>; @@ -56,13 +60,6 @@ type RunnerReadinessPreflightRecoveryDetails = { const RUNNER_STATUS_RECOVERY_TIMEOUT_MS = 3_000; -/** - * `details.reason` of a mutation whose reply stayed lost: status recovery found no result and no - * runner answer, so nothing proves the command did not run. The command is not resent; the caller - * observes the screen before acting again. A read never carries it, because it is resent. - */ -export const RUNNER_REPLY_LOST_REASON = 'runner_reply_lost'; - export async function handleRunnerTransportErrorAfterCommandSend(params: { device: DeviceInfo; session: RunnerSession; @@ -128,7 +125,7 @@ function buildLostReplyError( const transportReason = transportError.details?.reason; return new AppError( 'COMMAND_FAILED', - lostReply.message ?? transportError.message, + lostReply.message, { command: command.command, commandId: command.commandId, @@ -209,6 +206,7 @@ async function tryRecoverRunnerCommandAfterTransportError( reason: 'status_recovery_unavailable', lostReply: { recovery: 'status_recovery_unavailable', + message: lostReplyWithoutStatusMessage(command.command, 'status recovery was unavailable'), hint: unknownLifecycleStateHint(command.command), }, }; @@ -240,6 +238,7 @@ async function tryRecoverRunnerCommandAfterTransportError( reason: 'status_probe_failed', lostReply: { recovery: 'status_probe_failed', + message: lostReplyWithoutStatusMessage(command.command, 'the status probe failed'), hint: unknownLifecycleStateHint(command.command), }, }; @@ -504,6 +503,10 @@ function readReadinessPreflightRecoveryDetails( return details; } +function lostReplyWithoutStatusMessage(command: string, statusOutcome: string): string { + return `Runner command "${command}" lost its transport response and ${statusOutcome}, so agent-device invalidated the runner session instead of replaying the command.`; +} + function unknownLifecycleStateHint(command: string): string { return `The runner did not confirm that "${command}" reached a safe terminal state, so agent-device kept the conservative invalidation path. Run snapshot -i before retrying if the UI may have changed.`; } diff --git a/packages/platform-apple/src/runner/runner-error-classification.ts b/packages/platform-apple/src/runner/runner-error-classification.ts index 95aa68697e..40431444d5 100644 --- a/packages/platform-apple/src/runner/runner-error-classification.ts +++ b/packages/platform-apple/src/runner/runner-error-classification.ts @@ -45,6 +45,13 @@ export function runnerConnectFailureDetails(reason: RunnerConnectFailureReason): return { runnerConnectFailureReason: reason }; } +/** + * `details.reason` of a mutation whose reply stayed lost: status recovery found no result and no + * runner answer, so nothing proves the command did not run. The command is not resent; the caller + * observes the screen before acting again. A read never carries it, because it is resent. + */ +export const RUNNER_REPLY_LOST_REASON = 'runner_reply_lost'; + type RunnerErrorMatch = { /** Required `AppError.code`; absent = any AppError. */ code?: AppErrorCode; @@ -72,6 +79,8 @@ type RunnerErrorMatch = { details?: RunnerErrorDetailsMatch; }; +const hasRunnerReplyLostReason: RunnerErrorDetailsMatch = (details) => + details.reason === RUNNER_REPLY_LOST_REASON; /** * The runner refused the command before running it while abandoned main-thread work drains (#1105). * A resend keys on this code, never on `details.retriable`: that flag tells a caller's poll to try @@ -224,6 +233,18 @@ const PROFILE_UNUSABLE: RunnerErrorRule['buildFailure'] = { * typed verdict carries the recovery hint a generic connect failure would replace). */ export const RUNNER_ERROR_RULES: readonly RunnerErrorRule[] = [ + { + // A mutation that may have run: no axis may resend or restart it, whatever text it carries. + reason: RUNNER_REPLY_LOST_REASON, + match: { code: 'COMMAND_FAILED', details: hasRunnerReplyLostReason }, + verdicts: { + retryable: false, + drainResend: false, + connectRetry: false, + restartBeforeSend: false, + restartAfterReadinessPreflight: false, + }, + }, { reason: 'usbmux_device_unattached', match: { code: 'DEVICE_NOT_FOUND', details: hasUsbmuxDeviceUnattached }, From 72f27a68d169f16f644bb8db03219c9e5bf96c7f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 1 Oct 2026 10:04:43 +0200 Subject: [PATCH 16/18] docs(adr): name the lost-reply dispatch-disclosure rows in ADR 0011 --- docs/adr/0011-interaction-guarantee-contract.md | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/docs/adr/0011-interaction-guarantee-contract.md b/docs/adr/0011-interaction-guarantee-contract.md index 8af24a26bc..dab99333ee 100644 --- a/docs/adr/0011-interaction-guarantee-contract.md +++ b/docs/adr/0011-interaction-guarantee-contract.md @@ -196,9 +196,13 @@ The Apple runner does not resend a mutating command whose first send may have run: a restart resends only a command the first attempt provably did not write, or a read-only one. A mutation whose reply stays lost (the runner dies mid-command, or status recovery finds neither a retained result nor a runner -answer) fails with `reason: runner_reply_lost` and `dispatched: unknown` (row -`ios-runner.transport.written-then-lost` and the lost-reply `ios-runner.status.*` -rows). A read keeps its transport error and is resent. +answer) fails with `reason: runner_reply_lost` and `dispatched: unknown` (rows +`ios-runner.transport.written-then-lost`, +`ios-runner.status.completed-without-retained-reply`, +`ios-runner.status.accepted`, `ios-runner.status.started`, +`ios-runner.status.notAccepted`, `ios-runner.status.probe-failed`, and +`ios-runner.status.unavailable`). The `ios-runner.status.failed*` rows carry the +runner's own answer instead. A read keeps its transport error and is resent. Remaining gaps: a failure before the router's locked scope (session resolution, lock acquisition, lease and daemon-policy admission) never reaches From 28e492fa7617f8d51134b22b8bf852b115120f92 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 1 Oct 2026 10:05:02 +0200 Subject: [PATCH 17/18] test(apple-runner): pair keyboardReturn with the keyboard enter request that issues it --- src/__tests__/runner-read-only-registry-parity.test.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/__tests__/runner-read-only-registry-parity.test.ts b/src/__tests__/runner-read-only-registry-parity.test.ts index 5326f81753..49767e74e9 100644 --- a/src/__tests__/runner-read-only-registry-parity.test.ts +++ b/src/__tests__/runner-read-only-registry-parity.test.ts @@ -46,7 +46,7 @@ const REGISTRY_COUNTERPART: Record = { appSwitcher: { command: 'app-switcher' }, actionButton: { command: 'action-button' }, keyboardDismiss: { command: 'keyboard', positionals: ['dismiss'] }, - keyboardReturn: { command: 'keyboard', positionals: ['dismiss'] }, + keyboardReturn: { command: 'keyboard', positionals: ['enter'] }, pasteboardWrite: { command: 'clipboard', positionals: ['write', 'x'] }, }; From 25ce56e7d5629ee489f85cbf21ec4c5c771000fd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Thu, 1 Oct 2026 10:05:13 +0200 Subject: [PATCH 18/18] test(apple-runner): pin the lost-reply branch in the connect-loop mutation test --- .../__tests__/runner-lifecycle-dispatch-disclosure.test.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/platform-apple/src/runner/__tests__/runner-lifecycle-dispatch-disclosure.test.ts b/packages/platform-apple/src/runner/__tests__/runner-lifecycle-dispatch-disclosure.test.ts index 02076736c6..ac2f49b1a2 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-lifecycle-dispatch-disclosure.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-lifecycle-dispatch-disclosure.test.ts @@ -267,6 +267,8 @@ test('a mutation whose connect-loop POST timed out after writing is not restarte assert.ok(error instanceof AppError); assert.equal(error.details?.runnerRestarted, undefined); assert.equal(error.details?.dispatched, 'unknown'); + assert.equal(error.details?.reason, RUNNER_REPLY_LOST_REASON); + assert.equal(error.details?.recovery, 'status_probe_failed'); return true; }); assert.equal(mockEnsureRunnerSession.mock.calls.length, 1, 'the runner is not restarted');