From ea943cf03613040718eca6991facc170e068efb1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 23 Sep 2026 15:53:36 +0200 Subject: [PATCH 1/3] refactor(ios-runner): key runner retry rules on typed connect-failure reasons The runner connect path now publishes `details.runnerConnectFailureReason` (`xcodebuild_exited_early`, `runner_connect_refused`, `runner_endpoint_probe_exhausted`) where it builds each failure, and the retry table keys those three rules on that field instead of our own message text. The remaining text rules read only foreign text (Node's fetch/net/http errors, Xcode's device-busy text) under a renamed `foreignMessageIncludesAll` field, each naming its tool. Test doubles that stood in for these failures now carry the typed reason, and each converted rule has a test that the reason, not the message, decides the verdicts. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../runner/__tests__/runner-client.test.ts | 14 +- .../__tests__/runner-command-retry.test.ts | 46 ++++-- .../runner-error-classification.test.ts | 131 ++++++++++++++---- ...nner-lifecycle-readiness-preflight.test.ts | 11 +- .../__tests__/runner-session-fixtures.ts | 17 +++ .../runner-startup-transport.test.ts | 18 +++ .../src/runner/runner-error-classification.ts | 67 ++++++--- .../src/runner/runner-startup-transport.ts | 5 + 8 files changed, 245 insertions(+), 64 deletions(-) diff --git a/packages/platform-apple/src/runner/__tests__/runner-client.test.ts b/packages/platform-apple/src/runner/__tests__/runner-client.test.ts index d351529a3b..e341cbbea0 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-client.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-client.test.ts @@ -10,6 +10,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { mkdtempForTest } from './tmp-dir.ts'; +import { runnerConnectFailure } from './runner-session-fixtures.ts'; import { appleRunnerTestHost } from '../test-host.ts'; const mockRunCmdStreaming = vi.fn(); @@ -413,21 +414,24 @@ test('assertSafeDerivedCleanup allows cleaning override path under project .tmp' }); test('shouldRetryRunnerConnectError does not retry xcodebuild early-exit errors', () => { - const err = new AppError( - 'COMMAND_FAILED', + const err = runnerConnectFailure( + 'xcodebuild_exited_early', 'Runner did not accept connection (xcodebuild exited early)', ); assert.equal(shouldRetryRunnerConnectError(err), false); }); test('shouldRetryRunnerConnectError retries transient connect errors', () => { - const err = new AppError('COMMAND_FAILED', 'Runner endpoint probe failed'); + const err = runnerConnectFailure( + 'runner_endpoint_probe_exhausted', + 'Runner endpoint probe failed', + ); assert.equal(shouldRetryRunnerConnectError(err), true); }); test('isRetryableRunnerError does not retry xcodebuild early-exit errors', () => { - const err = new AppError( - 'COMMAND_FAILED', + const err = runnerConnectFailure( + 'xcodebuild_exited_early', 'Runner did not accept connection (xcodebuild exited early)', ); assert.equal(isRetryableRunnerError(err), false); 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 56019ce0ea..e1d2e5b845 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 @@ -1,7 +1,7 @@ import { beforeEach, test, vi } from 'vitest'; import assert from 'node:assert/strict'; import { IOS_SIMULATOR } from './device-fixtures.ts'; -import { createTestRequestCancellation } from './runner-session-fixtures.ts'; +import { createTestRequestCancellation, runnerConnectFailure } from './runner-session-fixtures.ts'; import { AppError } from '@agent-device/kernel/errors'; import { Deadline } from '../host.ts'; import { appleRunnerTestHost } from '../test-host.ts'; @@ -71,7 +71,9 @@ test('prepareIosRunner marks a bad restored artifact and rebuilds once after hea .mockResolvedValueOnce(fixtures.restoredSession) .mockResolvedValueOnce(fixtures.rebuiltSession); mockExecuteRunnerCommandWithSession - .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'Runner did not accept connection')) + .mockRejectedValueOnce( + runnerConnectFailure('runner_connect_refused', 'Runner did not accept connection'), + ) .mockResolvedValueOnce({ uptimeMs: 42 }); const result = await prepareIosRunner(IOS_SIMULATOR, { @@ -110,7 +112,9 @@ test('prepareIosRunner invalidates rebuilt sessions when bad-cache recovery heal .mockResolvedValueOnce(restoredSession) .mockResolvedValueOnce(rebuiltSession); mockExecuteRunnerCommandWithSession - .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'Runner endpoint probe failed')) + .mockRejectedValueOnce( + runnerConnectFailure('runner_endpoint_probe_exhausted', 'Runner endpoint probe failed'), + ) .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'Runner health timed out')); await assert.rejects( @@ -144,7 +148,9 @@ test('prepareIosRunner retries a fresh launch session when the health check cann .mockResolvedValueOnce(stuckSession) .mockResolvedValueOnce(relaunchedSession); mockExecuteRunnerCommandWithSession - .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'Runner did not accept connection')) + .mockRejectedValueOnce( + runnerConnectFailure('runner_connect_refused', 'Runner did not accept connection'), + ) .mockResolvedValueOnce({ uptimeMs: 42 }); const result = await prepareIosRunner(IOS_SIMULATOR, { @@ -232,8 +238,12 @@ test('prepareIosRunner does not force a rebuild when the relaunched fresh sessio .mockResolvedValueOnce(stuckSession) .mockResolvedValueOnce(relaunchedSession); mockExecuteRunnerCommandWithSession - .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'Runner did not accept connection')) - .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'Runner did not accept connection')); + .mockRejectedValueOnce( + runnerConnectFailure('runner_connect_refused', 'Runner did not accept connection'), + ) + .mockRejectedValueOnce( + runnerConnectFailure('runner_connect_refused', 'Runner did not accept connection'), + ); await assert.rejects( () => @@ -261,7 +271,7 @@ test('prepareIosRunner does not relaunch after non-retryable runner startup fail mockEnsureRunnerSession.mockResolvedValueOnce(failedSession); mockExecuteRunnerCommandWithSession.mockRejectedValueOnce( - new AppError('COMMAND_FAILED', 'xcodebuild exited early'), + runnerConnectFailure('xcodebuild_exited_early', 'xcodebuild exited early'), ); await assert.rejects( @@ -281,7 +291,7 @@ test('prepareIosRunner does not relaunch after request cancellation', async () = mockEnsureRunnerSession.mockResolvedValueOnce(stuckSession); mockExecuteRunnerCommandWithSession.mockImplementationOnce(() => { markRequestCanceled(requestId); - throw new AppError('COMMAND_FAILED', 'Runner did not accept connection'); + throw runnerConnectFailure('runner_connect_refused', 'Runner did not accept connection'); }); try { @@ -303,7 +313,9 @@ test('mutating commands restart stale ready sessions when the preflight probe ne mockEnsureRunnerSession.mockResolvedValueOnce(staleSession).mockResolvedValueOnce(freshSession); mockExecuteRunnerCommandWithSession - .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'Runner did not accept connection')) + .mockRejectedValueOnce( + runnerConnectFailure('runner_connect_refused', 'Runner did not accept connection'), + ) .mockResolvedValueOnce({ message: 'tapped' }); const result = await runAppleRunnerCommand(IOS_SIMULATOR, { command: 'tap', x: 120, y: 240 }); @@ -326,7 +338,9 @@ test('mutating commands retry startup sessions with stale bundle cleanup', async mockEnsureRunnerSession.mockResolvedValueOnce(startupSession).mockResolvedValueOnce(freshSession); mockExecuteRunnerCommandWithSession - .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'Runner did not accept connection')) + .mockRejectedValueOnce( + runnerConnectFailure('runner_connect_refused', 'Runner did not accept connection'), + ) .mockResolvedValueOnce({ message: 'tapped' }); const result = await runAppleRunnerCommand(IOS_SIMULATOR, { command: 'tap', x: 120, y: 240 }); @@ -811,7 +825,9 @@ test('mutating commands invalidate the retry session without replaying again', a mockEnsureRunnerSession.mockResolvedValueOnce(staleSession).mockResolvedValueOnce(freshSession); mockExecuteRunnerCommandWithSession - .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'Runner did not accept connection')) + .mockRejectedValueOnce( + runnerConnectFailure('runner_connect_refused', 'Runner did not accept connection'), + ) .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'fetch failed')) .mockResolvedValueOnce({ lifecycleState: 'notAccepted' }); @@ -1153,7 +1169,9 @@ test('a failed replacement boot does not consume the request recycle budget', as const requestId = 'req-recycle-transient-boot-failure'; mockEnsureRunnerSession .mockResolvedValueOnce(makeRunnerSession({ port: 8100, state: 'ready' })) - .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'Runner did not accept connection')) + .mockRejectedValueOnce( + runnerConnectFailure('runner_connect_refused', 'Runner did not accept connection'), + ) .mockResolvedValueOnce(makeRunnerSession({ port: 8101, state: 'ready' })); mockExecuteRunnerCommandWithSession .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'fetch failed')) @@ -1204,7 +1222,9 @@ test('a later command in the same request cannot pay for a second recycle boot', mockEnsureRunnerSession.mockResolvedValueOnce(staleSession).mockResolvedValueOnce(freshSession); mockExecuteRunnerCommandWithSession - .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'Runner did not accept connection')) + .mockRejectedValueOnce( + runnerConnectFailure('runner_connect_refused', 'Runner did not accept connection'), + ) .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-error-classification.test.ts b/packages/platform-apple/src/runner/__tests__/runner-error-classification.test.ts index 2ff3beb550..133bcd9ab3 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 @@ -14,6 +14,7 @@ import { shouldRestartRunnerBeforeCommandSend, shouldRetryRunnerConnectError, } from '../runner-error-classification.ts'; +import { runnerConnectFailure } from './runner-session-fixtures.ts'; function commandFailed(message: string, details?: Record): AppError { return new AppError('COMMAND_FAILED', message, details); @@ -27,12 +28,16 @@ test('every rule carries a unique reason', () => { // --- retryable axis (isRetryableRunnerError) --- test('transport-shaped failures are retryable', () => { - for (const message of [ - 'Runner did not accept connection on port 8100', - 'fetch failed', - 'connect ECONNREFUSED 127.0.0.1:8100', - 'socket hang up', - ]) { + assert.equal( + isRetryableRunnerError( + runnerConnectFailure( + 'runner_connect_refused', + 'Runner did not accept connection on port 8100', + ), + ), + true, + ); + for (const message of ['fetch failed', 'connect ECONNREFUSED 127.0.0.1:8100', 'socket hang up']) { assert.equal(isRetryableRunnerError(commandFailed(message)), true, message); } }); @@ -40,7 +45,10 @@ test('transport-shaped failures are retryable', () => { test('boot-shaped failures are not retryable', () => { assert.equal( isRetryableRunnerError( - commandFailed('Runner did not accept connection (xcodebuild exited early)'), + runnerConnectFailure( + 'xcodebuild_exited_early', + 'Runner did not accept connection (xcodebuild exited early)', + ), ), false, ); @@ -51,7 +59,9 @@ test('boot-shaped failures are not retryable', () => { }); test('an explicitly retriable flag wins over any message denial', () => { - const flagged = commandFailed('xcodebuild exited early', { retriable: true }); + const flagged = runnerConnectFailure('xcodebuild_exited_early', 'xcodebuild exited early', { + retriable: true, + }); assert.equal(isRetryableRunnerError(flagged), true); }); @@ -64,7 +74,9 @@ test('retryable requires an AppError with COMMAND_FAILED', () => { test('connect loop keeps waiting by default, including for unknown errors', () => { assert.equal( - shouldRetryRunnerConnectError(commandFailed('Runner did not accept connection')), + shouldRetryRunnerConnectError( + runnerConnectFailure('runner_connect_refused', 'Runner did not accept connection'), + ), true, ); assert.equal(shouldRetryRunnerConnectError(new Error('anything')), true); @@ -72,7 +84,12 @@ test('connect loop keeps waiting by default, including for unknown errors', () = }); test('connect loop stops for terminal verdicts', () => { - assert.equal(shouldRetryRunnerConnectError(commandFailed('xcodebuild exited early')), false); + assert.equal( + shouldRetryRunnerConnectError( + runnerConnectFailure('xcodebuild_exited_early', 'xcodebuild exited early'), + ), + false, + ); const unattached = new AppError('DEVICE_NOT_FOUND', 'device not attached', { usbmuxDeviceAttached: false, }); @@ -140,27 +157,37 @@ test('a deadline on its own earns no recovery verdict', () => { test('only a runner that never accepted a connection indicts the cached artifact', () => { assert.equal( - shouldRebuildCachedRunnerArtifact(commandFailed('Runner endpoint probe failed')), + shouldRebuildCachedRunnerArtifact( + runnerConnectFailure('runner_endpoint_probe_exhausted', 'Runner endpoint probe failed'), + ), true, ); assert.equal( - shouldRebuildCachedRunnerArtifact(commandFailed('Runner did not accept connection')), + shouldRebuildCachedRunnerArtifact( + runnerConnectFailure('runner_connect_refused', 'Runner did not accept connection'), + ), true, ); assert.equal( shouldRebuildCachedRunnerArtifact( - commandFailed('Runner did not accept connection (simctl spawn)', { port: 8100 }), + runnerConnectFailure( + 'runner_connect_refused', + 'Runner did not accept connection (simctl spawn)', + { + port: 8100, + }, + ), ), true, ); - // Wiping derived data cannot fix a boot that refuses to compile, and its message - // otherwise reads as a refused connection. + // Wiping derived data cannot fix a boot that refuses to compile. assert.equal( shouldRebuildCachedRunnerArtifact( - commandFailed('Runner did not accept connection (xcodebuild exited early)', { - port: 8100, - logPath: '/tmp/runner.log', - }), + runnerConnectFailure( + 'xcodebuild_exited_early', + 'Runner did not accept connection (xcodebuild exited early)', + { port: 8100, logPath: '/tmp/runner.log' }, + ), ), false, ); @@ -198,23 +225,71 @@ test('ordinary errors are never session-fatal', () => { // --- restart-before-send axis (shouldRestartRunnerBeforeCommandSend) --- -test('a refused connection before send restarts the session, case-insensitively', () => { - assert.equal( - shouldRestartRunnerBeforeCommandSend(commandFailed('Runner did not accept connection')), - true, - ); +test('a refused connection before send restarts the session', () => { assert.equal( - shouldRestartRunnerBeforeCommandSend(commandFailed('runner did not accept connection')), + shouldRestartRunnerBeforeCommandSend( + runnerConnectFailure('runner_connect_refused', 'Runner did not accept connection'), + ), true, ); }); -test('a terminal connect verdict refuses the restart even when the message matches', () => { - const both = commandFailed('xcodebuild exited early: runner did not accept connection'); - assert.equal(shouldRestartRunnerBeforeCommandSend(both), false); +test('a terminal connect verdict refuses the restart', () => { + const earlyExit = runnerConnectFailure( + 'xcodebuild_exited_early', + 'xcodebuild exited early: runner did not accept connection', + ); + assert.equal(shouldRestartRunnerBeforeCommandSend(earlyExit), false); + const busy = runnerConnectFailure( + 'runner_connect_refused', + 'Device is busy (Connecting to iPhone): runner did not accept connection', + ); + assert.equal(shouldRestartRunnerBeforeCommandSend(busy), false); assert.equal(shouldRestartRunnerBeforeCommandSend(commandFailed('socket hang up')), false); }); +// --- typed connect-failure reasons (agent-device's own connect path) --- + +test('xcodebuild_exited_early is decided by the typed reason, not the message', () => { + for (const message of ['Runner did not accept connection (xcodebuild exited early)', 'boom']) { + const error = runnerConnectFailure('xcodebuild_exited_early', 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); + } + // The same words without the reason earn no terminal verdict. + const untyped = commandFailed('Runner did not accept connection (xcodebuild exited early)'); + assert.equal(shouldRetryRunnerConnectError(untyped), true); +}); + +test('runner_connect_refused is decided by the typed reason, not the message', () => { + for (const message of ['Runner did not accept connection', 'boom']) { + const error = runnerConnectFailure('runner_connect_refused', 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); + } + const untyped = commandFailed('Runner did not accept connection'); + assert.equal(isRetryableRunnerError(untyped), false); + assert.equal(shouldRebuildCachedRunnerArtifact(untyped), false); + assert.equal(shouldRestartRunnerBeforeCommandSend(untyped), false); +}); + +test('runner_endpoint_probe_exhausted is decided by the typed reason, not the message', () => { + for (const message of ['Runner endpoint probe failed', 'boom']) { + 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( + shouldRebuildCachedRunnerArtifact(commandFailed('Runner endpoint probe failed')), + false, + ); +}); + // The literals are what the Swift runner encodes, so they are the contract and not the constant // names: a rename on one side has to fail here rather than silently split the pair (#2728). test('a refused screen capture keeps the runner reason and stays off the wire code', () => { diff --git a/packages/platform-apple/src/runner/__tests__/runner-lifecycle-readiness-preflight.test.ts b/packages/platform-apple/src/runner/__tests__/runner-lifecycle-readiness-preflight.test.ts index dd28f4152a..efe279f753 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-lifecycle-readiness-preflight.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-lifecycle-readiness-preflight.test.ts @@ -8,7 +8,11 @@ import { import { appleRunnerTestHost } from '../test-host.ts'; import type { RunnerXctestrunArtifact } from '../runner-xctestrun.ts'; import { IOS_SIMULATOR } from './device-fixtures.ts'; -import { createTestRequestCancellation, makeRunnerSession } from './runner-session-fixtures.ts'; +import { + createTestRequestCancellation, + makeRunnerSession, + runnerConnectFailure, +} from './runner-session-fixtures.ts'; const { mockEnsureRunnerSession, @@ -216,7 +220,10 @@ test('a boot that exited early does not wipe a restored runner artifact', async mockEnsureRunnerSession.mockResolvedValueOnce(restoredSession); mockExecuteRunnerCommandWithSession.mockRejectedValueOnce( - new AppError('COMMAND_FAILED', 'Runner did not accept connection (xcodebuild exited early)'), + runnerConnectFailure( + 'xcodebuild_exited_early', + 'Runner did not accept connection (xcodebuild exited early)', + ), ); await assert.rejects( 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 2fa54f1694..2130678857 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-session-fixtures.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-session-fixtures.ts @@ -2,11 +2,16 @@ import assert from 'node:assert/strict'; import fs from 'node:fs'; import { EventEmitter } from 'node:events'; import { vi } from 'vitest'; +import { AppError } from '@agent-device/kernel/errors'; import { IOS_SIMULATOR } from './device-fixtures.ts'; import { appleRunnerTestHost } from '../test-host.ts'; import { runnerOwnerStartTime, type RunnerLease } from '../runner-lease.ts'; import type { RunnerSession } from '../runner-session-types.ts'; import type { XcodebuildSimulatorSetRedirectHandle } from '../runner-device-set.ts'; +import { + runnerConnectFailureDetails, + type RunnerConnectFailureReason, +} from '../runner-error-classification.ts'; // Fabricated runner sessions, leases, background children, and transport // payloads shared by the runner-session tests. The child pids here are made up @@ -71,6 +76,18 @@ export function runnerError(error: { code: string; message: string }): Response return new Response(JSON.stringify({ ok: false, error })); } +/** A failure in the shape the runner connect path throws: its message plus its typed reason. */ +export function runnerConnectFailure( + reason: RunnerConnectFailureReason, + message: string, + details?: Record, +): AppError { + return new AppError('COMMAND_FAILED', message, { + ...details, + ...runnerConnectFailureDetails(reason), + }); +} + // 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 diff --git a/packages/platform-apple/src/runner/__tests__/runner-startup-transport.test.ts b/packages/platform-apple/src/runner/__tests__/runner-startup-transport.test.ts index 38ae2a55f2..a335e95414 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-startup-transport.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-startup-transport.test.ts @@ -142,6 +142,22 @@ test('waitForRunner uses simulator fallback within the attempt for ready session ]); }); +test('waitForRunner types a failed simulator fallback as a refused connection', async () => { + vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new Error('ECONNREFUSED'))); + mockRunCmd.mockResolvedValue({ exitCode: 7, stdout: '', stderr: 'curl: (7) Failed to connect' }); + + await assert.rejects( + () => waitForRunner(iosSimulator, 8100, { command: 'uptime' }, undefined, 100), + (error: unknown) => { + const appError = error as AppError; + assert.equal(appError.message, 'Runner did not accept connection (simctl spawn)'); + assert.equal(appError.details?.runnerConnectFailureReason, 'runner_connect_refused'); + return true; + }, + ); + assert.equal(mockRunCmd.mock.calls.length, 1); +}); + test('waitForRunner wakes a simulator startup retry when the listener reports ready', async () => { vi.useFakeTimers(); const readiness = new AbortController(); @@ -249,6 +265,7 @@ test('waitForRunner preserves xcodebuild diagnostics when the runner exits durin (error: unknown) => { const appError = error as AppError; assert.equal(appError.message, 'Runner did not accept connection (xcodebuild exited early)'); + assert.equal(appError.details?.runnerConnectFailureReason, 'xcodebuild_exited_early'); assert.equal( (appError.details?.xcodebuild as { exitCode?: number } | undefined)?.exitCode, 65, @@ -294,6 +311,7 @@ test('waitForRunner carries the disk-image state when the runner is still alive (error: unknown) => { const appError = error as AppError; assert.equal(appError.message, 'Runner did not accept connection'); + assert.equal(appError.details?.runnerConnectFailureReason, 'runner_connect_refused'); assert.equal(appError.details?.developerDiskImage, 'unavailable'); assert.equal(appError.details?.reason, 'IOS_RUNNER_CONNECT_TIMEOUT'); assert.doesNotMatch(String(appError.details?.hint), /Unlock the iPhone/); diff --git a/packages/platform-apple/src/runner/runner-error-classification.ts b/packages/platform-apple/src/runner/runner-error-classification.ts index 882dc73eaf..adf657e017 100644 --- a/packages/platform-apple/src/runner/runner-error-classification.ts +++ b/packages/platform-apple/src/runner/runner-error-classification.ts @@ -21,11 +21,32 @@ export const RUNNER_CACHE_RECOVERY_HINT = */ type RunnerErrorDetailsMatch = (details: AppErrorDetails) => boolean; +/** + * Why agent-device's own runner connect path gave up, published in + * `details.runnerConnectFailureReason` by the error that path throws. It sits beside + * `details.reason` rather than in it: on these failures `reason` already carries the + * `BootFailureReason` the caller's hint answers. + */ +export type RunnerConnectFailureReason = + | 'xcodebuild_exited_early' + | 'runner_connect_refused' + | 'runner_endpoint_probe_exhausted'; + +export function runnerConnectFailureDetails(reason: RunnerConnectFailureReason): { + runnerConnectFailureReason: RunnerConnectFailureReason; +} { + return { runnerConnectFailureReason: reason }; +} + type RunnerErrorMatch = { /** Required `AppError.code`; absent = any AppError. */ code?: AppErrorCode; - /** Every entry must appear in the lowercased message. */ - messageIncludesAll?: readonly string[]; + /** + * Every entry must appear in the lowercased message. Only for text a foreign runtime or tool + * wrote, which `asAppError` copies into the message verbatim; a failure agent-device produces + * publishes a typed detail instead, and its rule keys on that. + */ + foreignMessageIncludesAll?: readonly string[]; /** * Every entry must appear in the lowercased {@link runnerToolText}: our message plus the tool's * own `stdout`/`stderr`. Nothing else in `details` is read, so the argv we were asked to run and @@ -54,6 +75,10 @@ const hasDevToolsSecurityStatus: RunnerErrorDetailsMatch = (details) => typeof details.devToolsSecurityStatus === 'string'; const hasUsbmuxDeviceUnattached: RunnerErrorDetailsMatch = (details) => details.usbmuxDeviceAttached === false; +const hasRunnerConnectFailureReason = + (reason: RunnerConnectFailureReason): RunnerErrorDetailsMatch => + (details) => + details.runnerConnectFailureReason === reason; /** * The preflight marks whatever it was waiting on when it stopped, and one of the things it waits on * is a caller that stopped waiting. A canceled request is not a wedged runner: the restart this @@ -188,17 +213,21 @@ export const RUNNER_ERROR_RULES: readonly RunnerErrorRule[] = [ verdicts: { retryable: true, connectRetry: true }, }, { - // Says `artifactSuspect: false` on purpose: its message also reads as a refused - // connection, and a boot that cannot compile is not cured by wiping derived data. + // Says `artifactSuspect: false` on purpose: a boot that cannot compile is not cured by wiping + // derived data. reason: 'xcodebuild_exited_early', - match: { code: 'COMMAND_FAILED', messageIncludesAll: ['xcodebuild exited early'] }, + match: { + code: 'COMMAND_FAILED', + details: hasRunnerConnectFailureReason('xcodebuild_exited_early'), + }, verdicts: { retryable: false, connectRetry: false, artifactSuspect: false }, }, { // A device still mid-attachment is not a runner we can talk to yet, and waiting on // it inside this request is what the caller's own retry is for. reason: 'device_busy_connecting', - match: { code: 'COMMAND_FAILED', messageIncludesAll: ['device is busy', 'connecting'] }, + // Xcode/CoreDevice text: "Device is busy (Connecting to )". + match: { code: 'COMMAND_FAILED', foreignMessageIncludesAll: ['device is busy', 'connecting'] }, verdicts: { retryable: false, connectRetry: false }, }, { @@ -212,7 +241,10 @@ export const RUNNER_ERROR_RULES: readonly RunnerErrorRule[] = [ }, { reason: 'runner_connect_refused', - match: { code: 'COMMAND_FAILED', messageIncludesAll: ['runner did not accept connection'] }, + match: { + code: 'COMMAND_FAILED', + details: hasRunnerConnectFailureReason('runner_connect_refused'), + }, verdicts: { retryable: true, connectRetry: true, @@ -224,22 +256,28 @@ export const RUNNER_ERROR_RULES: readonly RunnerErrorRule[] = [ // Every endpoint answered and none of them had a runner: with a restored artifact // in hand, that artifact is the common cause. reason: 'runner_endpoint_probe_exhausted', - match: { code: 'COMMAND_FAILED', messageIncludesAll: ['runner endpoint probe failed'] }, + match: { + code: 'COMMAND_FAILED', + details: hasRunnerConnectFailureReason('runner_endpoint_probe_exhausted'), + }, verdicts: { artifactSuspect: true }, }, { reason: 'fetch_failed', - match: { code: 'COMMAND_FAILED', messageIncludesAll: ['fetch failed'] }, + // Node's fetch (undici) rejects a failed request with TypeError "fetch failed". + match: { code: 'COMMAND_FAILED', foreignMessageIncludesAll: ['fetch failed'] }, verdicts: { retryable: true, connectRetry: true }, }, { reason: 'econnrefused', - match: { code: 'COMMAND_FAILED', messageIncludesAll: ['econnrefused'] }, + // Node's net socket: "connect ECONNREFUSED
". + match: { code: 'COMMAND_FAILED', foreignMessageIncludesAll: ['econnrefused'] }, verdicts: { retryable: true, connectRetry: true }, }, { reason: 'socket_hang_up', - match: { code: 'COMMAND_FAILED', messageIncludesAll: ['socket hang up'] }, + // Node's http client: "socket hang up" when the peer closes before responding. + match: { code: 'COMMAND_FAILED', foreignMessageIncludesAll: ['socket hang up'] }, verdicts: { retryable: true, connectRetry: true }, }, { @@ -390,7 +428,7 @@ function matchesRunnerErrorRule(error: AppError, match: RunnerErrorMatch): boole if (!matchesRunnerErrorDetails(error, match.details)) return false; if (!matchesRunnerToolText(error, match.toolTextIncludesAll)) return false; if (!matchesRunnerToolTextLine(error, match.toolTextLineIncludesAll)) return false; - return matchesRunnerErrorMessage(error, match.messageIncludesAll); + return matchesRunnerErrorMessage(error, match.foreignMessageIncludesAll); } function matchesRunnerErrorDetails(error: AppError, details: RunnerErrorMatch['details']): boolean { @@ -523,10 +561,7 @@ 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. Composed with the connect-retry axis so - * a terminal connect verdict (cable unattached, xcodebuild exited early) - * still refuses the restart. Matching is table-driven and therefore - * case-insensitive, unlike the raw-message check it replaced; the message is - * our own transport literal, so no real error changes class. + * a terminal connect verdict still refuses the restart. */ export function shouldRestartRunnerBeforeCommandSend(error: unknown): boolean { return ( diff --git a/packages/platform-apple/src/runner/runner-startup-transport.ts b/packages/platform-apple/src/runner/runner-startup-transport.ts index df48f0863b..4eb7ce8488 100644 --- a/packages/platform-apple/src/runner/runner-startup-transport.ts +++ b/packages/platform-apple/src/runner/runner-startup-transport.ts @@ -23,6 +23,7 @@ import { enrichRunnerStartupFailureWithDeviceStates, isUsbmuxDeviceUnattachedError, RUNNER_CACHE_RECOVERY_HINT, + runnerConnectFailureDetails, shouldRetryRunnerConnectError, type IosRunnerDeviceStates, } from './runner-error-classification.ts'; @@ -296,6 +297,7 @@ function buildRunnerEndpointProbeError(params: { port: params.port, endpoints: params.endpoints, lastError: params.lastError ? String(params.lastError) : undefined, + ...runnerConnectFailureDetails('runner_endpoint_probe_exhausted'), }); } @@ -451,6 +453,7 @@ async function postCommandViaSimulator( port, reason, hint: bootFailureHint(reason), + ...runnerConnectFailureDetails('runner_connect_refused'), }; }, ); @@ -500,6 +503,7 @@ function buildRunnerConnectError(params: { context: { platform: 'ios', phase: 'connect' }, }), hint: bootFailureHint('IOS_RUNNER_CONNECT_TIMEOUT'), + ...runnerConnectFailureDetails('runner_connect_refused'), }); // The other way the connect stage gives up: `xcodebuild` is still alive at the deadline. It gets // the same enrichment as the early exit below (#2683). @@ -539,6 +543,7 @@ export async function buildRunnerEarlyExitError(params: { }, reason, hint: resolveRunnerEarlyExitHint(message, output, output, reason), + ...runnerConnectFailureDetails('xcodebuild_exited_early'), }); // The build catch is not the only way a runner stops before serving a command. A locked phone lets // the build finish and kills `xcodebuild test-without-building` instead, so nothing reaches that From 7aa67a2a73541eb57427e223c20ce356b4102829 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 23 Sep 2026 16:04:30 +0200 Subject: [PATCH 2/3] refactor(ios-runner): drop the restart composition typed reasons made redundant With the connect-failure reasons exclusive, no error that grants a restart before send can also carry a terminal connect verdict, so the restart predicate reads its own axis. Doc comments now say which failures carry a BootFailureReason and that the rows above the startup block include foreign-runtime text matchers. Co-Authored-By: Claude Opus 5.5 (1M context) --- .../__tests__/runner-error-classification.test.ts | 7 +------ .../src/runner/runner-error-classification.ts | 15 ++++++--------- 2 files changed, 7 insertions(+), 15 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 133bcd9ab3..1133c7a45a 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 @@ -234,17 +234,12 @@ test('a refused connection before send restarts the session', () => { ); }); -test('a terminal connect verdict refuses the restart', () => { +test('an early exit or a foreign transport failure earns no restart before send', () => { const earlyExit = runnerConnectFailure( 'xcodebuild_exited_early', 'xcodebuild exited early: runner did not accept connection', ); assert.equal(shouldRestartRunnerBeforeCommandSend(earlyExit), false); - const busy = runnerConnectFailure( - 'runner_connect_refused', - 'Device is busy (Connecting to iPhone): runner did not accept connection', - ); - assert.equal(shouldRestartRunnerBeforeCommandSend(busy), false); assert.equal(shouldRestartRunnerBeforeCommandSend(commandFailed('socket hang up')), false); }); diff --git a/packages/platform-apple/src/runner/runner-error-classification.ts b/packages/platform-apple/src/runner/runner-error-classification.ts index adf657e017..32514622aa 100644 --- a/packages/platform-apple/src/runner/runner-error-classification.ts +++ b/packages/platform-apple/src/runner/runner-error-classification.ts @@ -24,8 +24,8 @@ type RunnerErrorDetailsMatch = (details: AppErrorDetails) => boolean; /** * Why agent-device's own runner connect path gave up, published in * `details.runnerConnectFailureReason` by the error that path throws. It sits beside - * `details.reason` rather than in it: on these failures `reason` already carries the - * `BootFailureReason` the caller's hint answers. + * `details.reason` rather than in it: on the refused-connection and early-exit failures + * `reason` already carries the `BootFailureReason` the caller's hint answers. */ export type RunnerConnectFailureReason = | 'xcodebuild_exited_early' @@ -304,7 +304,8 @@ export const RUNNER_ERROR_RULES: readonly RunnerErrorRule[] = [ // caller and no recovery verdicts, because there is no session to invalidate and nothing was sent // to resend. Specific rows precede generic ones: the classifier takes the first match. // - // Why these rows are text matchers while the rows above key on a code or a typed field: + // Why these rows are text matchers while the rows above key on a code, a typed field, or a + // foreign runtime's message: // `runnerToolText` reads xcodebuild's own prose because that prose is the only publication these // failures have — there is no code and no typed field to key on. Its haystack is deliberately // narrow: our message plus the tool's stdout/stderr, never the whole details bag, which also @@ -560,14 +561,10 @@ 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. Composed with the connect-retry axis so - * a terminal connect verdict still refuses the restart. + * that never accepted the connection. */ export function shouldRestartRunnerBeforeCommandSend(error: unknown): boolean { - return ( - (runnerErrorVerdict(error, 'restartBeforeSend') ?? false) && - shouldRetryRunnerConnectError(error) - ); + return runnerErrorVerdict(error, 'restartBeforeSend') ?? false; } /** From 8c209239ee85eb361cd3ede48486490ab860b79a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Micha=C5=82=20Pierzcha=C5=82a?= Date: Wed, 23 Sep 2026 17:42:17 +0200 Subject: [PATCH 3/3] test(apple-runner): move connect-retry classification tests to their owning file runner-client.test.ts asserted shouldRetryRunnerConnectError and isRetryableRunnerError, both exported by runner-error-classification.ts and already covered there, growing it past the test-file size ratchet. Move the one uncovered case (runner_endpoint_probe_exhausted keeps the connect loop retrying) into runner-error-classification.test.ts and drop the rest as duplicate message-sniff coverage. Give runnerConnectFailure a reason-keyed default message so the retry suite's repeated connect-refused/probe-exhausted fixtures fit on one line again, bringing runner-command-retry.test.ts back to its merge-base length. --- .../runner/__tests__/runner-client.test.ts | 34 -------------- .../__tests__/runner-command-retry.test.ts | 44 +++++-------------- .../runner-error-classification.test.ts | 1 + .../__tests__/runner-session-fixtures.ts | 9 +++- 4 files changed, 21 insertions(+), 67 deletions(-) diff --git a/packages/platform-apple/src/runner/__tests__/runner-client.test.ts b/packages/platform-apple/src/runner/__tests__/runner-client.test.ts index e341cbbea0..1e2252e5eb 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-client.test.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-client.test.ts @@ -10,7 +10,6 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { mkdtempForTest } from './tmp-dir.ts'; -import { runnerConnectFailure } from './runner-session-fixtures.ts'; import { appleRunnerTestHost } from '../test-host.ts'; const mockRunCmdStreaming = vi.fn(); @@ -31,10 +30,6 @@ vi.mock('../runner-macos-products.ts', async () => { import type { DeviceInfo } from '@agent-device/kernel/device'; import { RUNNER_COMMAND_TRAITS, isReadOnlyRunnerCommand } from '../runner-command-traits.ts'; -import { - isRetryableRunnerError, - shouldRetryRunnerConnectError, -} from '../runner-error-classification.ts'; import { withRunnerCommandId, type RunnerCommand } from '../runner-contract.ts'; import { resolveRunnerBuildDestination, @@ -413,35 +408,6 @@ test('assertSafeDerivedCleanup allows cleaning override path under project .tmp' }); }); -test('shouldRetryRunnerConnectError does not retry xcodebuild early-exit errors', () => { - const err = runnerConnectFailure( - 'xcodebuild_exited_early', - 'Runner did not accept connection (xcodebuild exited early)', - ); - assert.equal(shouldRetryRunnerConnectError(err), false); -}); - -test('shouldRetryRunnerConnectError retries transient connect errors', () => { - const err = runnerConnectFailure( - 'runner_endpoint_probe_exhausted', - 'Runner endpoint probe failed', - ); - assert.equal(shouldRetryRunnerConnectError(err), true); -}); - -test('isRetryableRunnerError does not retry xcodebuild early-exit errors', () => { - const err = runnerConnectFailure( - 'xcodebuild_exited_early', - 'Runner did not accept connection (xcodebuild exited early)', - ); - assert.equal(isRetryableRunnerError(err), false); -}); - -test('isRetryableRunnerError does not retry busy-connecting errors', () => { - const err = new AppError('COMMAND_FAILED', 'Device is busy (Connecting to iPhone)'); - assert.equal(isRetryableRunnerError(err), false); -}); - test('xctestrunReferencesProjectRoot rejects stale worktree artifacts', async () => { const tmpDir = await makeTmpDir(); const xctestrunPath = path.join(tmpDir, 'AgentDeviceRunner.xctestrun'); 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 e1d2e5b845..e1eb003d7e 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 @@ -71,9 +71,7 @@ test('prepareIosRunner marks a bad restored artifact and rebuilds once after hea .mockResolvedValueOnce(fixtures.restoredSession) .mockResolvedValueOnce(fixtures.rebuiltSession); mockExecuteRunnerCommandWithSession - .mockRejectedValueOnce( - runnerConnectFailure('runner_connect_refused', 'Runner did not accept connection'), - ) + .mockRejectedValueOnce(runnerConnectFailure('runner_connect_refused')) .mockResolvedValueOnce({ uptimeMs: 42 }); const result = await prepareIosRunner(IOS_SIMULATOR, { @@ -112,9 +110,7 @@ test('prepareIosRunner invalidates rebuilt sessions when bad-cache recovery heal .mockResolvedValueOnce(restoredSession) .mockResolvedValueOnce(rebuiltSession); mockExecuteRunnerCommandWithSession - .mockRejectedValueOnce( - runnerConnectFailure('runner_endpoint_probe_exhausted', 'Runner endpoint probe failed'), - ) + .mockRejectedValueOnce(runnerConnectFailure('runner_endpoint_probe_exhausted')) .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'Runner health timed out')); await assert.rejects( @@ -148,9 +144,7 @@ test('prepareIosRunner retries a fresh launch session when the health check cann .mockResolvedValueOnce(stuckSession) .mockResolvedValueOnce(relaunchedSession); mockExecuteRunnerCommandWithSession - .mockRejectedValueOnce( - runnerConnectFailure('runner_connect_refused', 'Runner did not accept connection'), - ) + .mockRejectedValueOnce(runnerConnectFailure('runner_connect_refused')) .mockResolvedValueOnce({ uptimeMs: 42 }); const result = await prepareIosRunner(IOS_SIMULATOR, { @@ -238,12 +232,8 @@ test('prepareIosRunner does not force a rebuild when the relaunched fresh sessio .mockResolvedValueOnce(stuckSession) .mockResolvedValueOnce(relaunchedSession); mockExecuteRunnerCommandWithSession - .mockRejectedValueOnce( - runnerConnectFailure('runner_connect_refused', 'Runner did not accept connection'), - ) - .mockRejectedValueOnce( - runnerConnectFailure('runner_connect_refused', 'Runner did not accept connection'), - ); + .mockRejectedValueOnce(runnerConnectFailure('runner_connect_refused')) + .mockRejectedValueOnce(runnerConnectFailure('runner_connect_refused')); await assert.rejects( () => @@ -271,7 +261,7 @@ test('prepareIosRunner does not relaunch after non-retryable runner startup fail mockEnsureRunnerSession.mockResolvedValueOnce(failedSession); mockExecuteRunnerCommandWithSession.mockRejectedValueOnce( - runnerConnectFailure('xcodebuild_exited_early', 'xcodebuild exited early'), + runnerConnectFailure('xcodebuild_exited_early'), ); await assert.rejects( @@ -291,7 +281,7 @@ test('prepareIosRunner does not relaunch after request cancellation', async () = mockEnsureRunnerSession.mockResolvedValueOnce(stuckSession); mockExecuteRunnerCommandWithSession.mockImplementationOnce(() => { markRequestCanceled(requestId); - throw runnerConnectFailure('runner_connect_refused', 'Runner did not accept connection'); + throw runnerConnectFailure('runner_connect_refused'); }); try { @@ -313,9 +303,7 @@ test('mutating commands restart stale ready sessions when the preflight probe ne mockEnsureRunnerSession.mockResolvedValueOnce(staleSession).mockResolvedValueOnce(freshSession); mockExecuteRunnerCommandWithSession - .mockRejectedValueOnce( - runnerConnectFailure('runner_connect_refused', 'Runner did not accept connection'), - ) + .mockRejectedValueOnce(runnerConnectFailure('runner_connect_refused')) .mockResolvedValueOnce({ message: 'tapped' }); const result = await runAppleRunnerCommand(IOS_SIMULATOR, { command: 'tap', x: 120, y: 240 }); @@ -338,9 +326,7 @@ test('mutating commands retry startup sessions with stale bundle cleanup', async mockEnsureRunnerSession.mockResolvedValueOnce(startupSession).mockResolvedValueOnce(freshSession); mockExecuteRunnerCommandWithSession - .mockRejectedValueOnce( - runnerConnectFailure('runner_connect_refused', 'Runner did not accept connection'), - ) + .mockRejectedValueOnce(runnerConnectFailure('runner_connect_refused')) .mockResolvedValueOnce({ message: 'tapped' }); const result = await runAppleRunnerCommand(IOS_SIMULATOR, { command: 'tap', x: 120, y: 240 }); @@ -825,9 +811,7 @@ test('mutating commands invalidate the retry session without replaying again', a mockEnsureRunnerSession.mockResolvedValueOnce(staleSession).mockResolvedValueOnce(freshSession); mockExecuteRunnerCommandWithSession - .mockRejectedValueOnce( - runnerConnectFailure('runner_connect_refused', 'Runner did not accept connection'), - ) + .mockRejectedValueOnce(runnerConnectFailure('runner_connect_refused')) .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'fetch failed')) .mockResolvedValueOnce({ lifecycleState: 'notAccepted' }); @@ -1169,9 +1153,7 @@ test('a failed replacement boot does not consume the request recycle budget', as const requestId = 'req-recycle-transient-boot-failure'; mockEnsureRunnerSession .mockResolvedValueOnce(makeRunnerSession({ port: 8100, state: 'ready' })) - .mockRejectedValueOnce( - runnerConnectFailure('runner_connect_refused', 'Runner did not accept connection'), - ) + .mockRejectedValueOnce(runnerConnectFailure('runner_connect_refused')) .mockResolvedValueOnce(makeRunnerSession({ port: 8101, state: 'ready' })); mockExecuteRunnerCommandWithSession .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'fetch failed')) @@ -1222,9 +1204,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', 'Runner did not accept connection'), - ) + .mockRejectedValueOnce(runnerConnectFailure('runner_connect_refused')) .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-error-classification.test.ts b/packages/platform-apple/src/runner/__tests__/runner-error-classification.test.ts index 1133c7a45a..f1936ad802 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 @@ -278,6 +278,7 @@ test('runner_endpoint_probe_exhausted is decided by the typed reason, not the me assert.equal(shouldRebuildCachedRunnerArtifact(error), true, message); assert.equal(isRetryableRunnerError(error), false, message); assert.equal(shouldRestartRunnerBeforeCommandSend(error), false, message); + assert.equal(shouldRetryRunnerConnectError(error), true, message); } assert.equal( shouldRebuildCachedRunnerArtifact(commandFailed('Runner endpoint probe failed')), 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 2130678857..0aa6a8f605 100644 --- a/packages/platform-apple/src/runner/__tests__/runner-session-fixtures.ts +++ b/packages/platform-apple/src/runner/__tests__/runner-session-fixtures.ts @@ -76,10 +76,17 @@ export function runnerError(error: { code: string; message: string }): Response return new Response(JSON.stringify({ ok: false, error })); } +/** Each reason's canonical connect-path message, reused as `runnerConnectFailure`'s default. */ +const RUNNER_CONNECT_FAILURE_MESSAGES: Record = { + runner_connect_refused: 'Runner did not accept connection', + runner_endpoint_probe_exhausted: 'Runner endpoint probe failed', + xcodebuild_exited_early: 'xcodebuild exited early', +}; + /** A failure in the shape the runner connect path throws: its message plus its typed reason. */ export function runnerConnectFailure( reason: RunnerConnectFailureReason, - message: string, + message: string = RUNNER_CONNECT_FAILURE_MESSAGES[reason], details?: Record, ): AppError { return new AppError('COMMAND_FAILED', message, {