diff --git a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift index 34f87cf14..581a6b350 100644 --- a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift +++ b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+CommandExecution.swift @@ -181,6 +181,38 @@ extension RunnerTests { ) XCTAssertNil(xctestRecordedFailureResponse(command: tapCommand, response: runnerFatalResponse)) } + + func testExecuteDispatchedReturnsBusyBeforeMainThreadFastPath() throws { + let command = try runnerCommandFixture(#"{"command":"snapshot","commandId":"snapshot-busy"}"#) + abandonedMainThreadWorkCount = 1 + abandonedMainThreadWorkSince = Date(timeIntervalSinceNow: -2) + defer { + abandonedMainThreadWorkCount = 0 + abandonedMainThreadWorkSince = nil + } + + let response = try executeDispatched(command: command) + + XCTAssertFalse(response.ok) + XCTAssertEqual(response.error?.code, "RUNNER_BUSY") + XCTAssertTrue(response.error?.message.contains("previous command") == true) + } + + func testExecuteDispatchedReturnsWedgedBeforeMainThreadFastPath() throws { + let command = try runnerCommandFixture(#"{"command":"snapshot","commandId":"snapshot-wedged"}"#) + abandonedMainThreadWorkCount = 1 + abandonedMainThreadWorkSince = Date(timeIntervalSinceNow: -(mainThreadWedgeThreshold + 1)) + defer { + abandonedMainThreadWorkCount = 0 + abandonedMainThreadWorkSince = nil + } + + let response = try executeDispatched(command: command) + + XCTAssertFalse(response.ok) + XCTAssertEqual(response.error?.code, "RUNNER_WEDGED") + XCTAssertTrue(response.error?.hint?.contains("runner session will be restarted") == true) + } #endif func execute(command: Command) throws -> Response { @@ -296,9 +328,6 @@ extension RunnerTests { } private func executeDispatched(command: Command) throws -> Response { - if Thread.isMainThread { - return try executeOnMainSafely(command: command) - } // XCTest work cannot be cancelled mid-flight: once the watchdog abandons a main-queue // block, queueing more main-thread commands behind it only buries the runner deeper. // Refuse fast instead so the daemon backs off while the abandoned work drains; past the @@ -311,6 +340,9 @@ extension RunnerTests { case .wedged(let abandonedForSeconds): return runnerWedgedResponse(command: command, abandonedForSeconds: abandonedForSeconds) } + if Thread.isMainThread { + return try executeOnMainSafely(command: command) + } var result: Result? let semaphore = DispatchSemaphore(value: 0) let workState = MainThreadWorkState() diff --git a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntry.swift b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntry.swift index 69d979af8..d1f4c77ec 100644 --- a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntry.swift +++ b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+TextEntry.swift @@ -114,6 +114,17 @@ extension RunnerTests { func focusTextInputForTextEntry(app: XCUIApplication, x: Double?, y: Double?) -> TextEntryTarget { guard let x, let y else { + // Bare `type` targets the current first responder. On iOS we intentionally do not trust + // `hasKeyboardFocus`, but an already-visible software keyboard is sufficient evidence that + // app.typeText has a receiver; waiting the full readiness timeout cannot prove a stronger + // target because there is no selector/coordinate focus move to validate. + if isKeyboardVisible(app: app) { + return TextEntryTarget( + element: focusedTextInput(app: app), + refreshPoint: nil, + prefersFocusedElement: true + ) + } let focused = waitForTextEntryReadiness( app: app, target: TextEntryTarget( diff --git a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Transport.swift b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Transport.swift index ec625f7b2..1e877809b 100644 --- a/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Transport.swift +++ b/apple-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests+Transport.swift @@ -234,6 +234,45 @@ extension RunnerTests { return trimmed } +#if AGENT_DEVICE_RUNNER_UNIT_TESTS + func testDuplicateCommandIdCoalescesOntoInFlightExecution() throws { + let command = try JSONDecoder().decode( + Command.self, + from: Data(#"{"command":"snapshot","commandId":"snapshot-coalesce"}"#.utf8) + ) + var primaryData: Data? + var waiterData: Data? + defer { + inFlightCommandIds.removeAll() + inFlightCommandWaiters.removeAll() + } + + XCTAssertFalse( + attachToInFlightCommandIfNeeded(command: command) { result in + primaryData = result.data + } + ) + XCTAssertTrue( + attachToInFlightCommandIfNeeded(command: command) { result in + waiterData = result.data + } + ) + + let delivered = Data("single-result".utf8) + deliverCommandResult( + command: command, + result: (delivered, false) + ) { result in + primaryData = result.data + } + + XCTAssertEqual(primaryData, delivered) + XCTAssertEqual(waiterData, delivered) + XCTAssertFalse(inFlightCommandIds.contains("snapshot-coalesce")) + XCTAssertNil(inFlightCommandWaiters["snapshot-coalesce"]) + } +#endif + // MARK: - Response Encoding private func jsonResponse(status: Int, response: Response) -> Data { diff --git a/src/platforms/apple/core/__tests__/runner-client.test.ts b/src/platforms/apple/core/__tests__/runner-client.test.ts index 3aac3cb31..2deba22cb 100644 --- a/src/platforms/apple/core/__tests__/runner-client.test.ts +++ b/src/platforms/apple/core/__tests__/runner-client.test.ts @@ -700,6 +700,59 @@ test('parseRunnerResponse preserves XCTest recorded failure code and hint', asyn ); }); +test('parseRunnerResponse maps RUNNER_BUSY to retriable command failure', async () => { + const hint = 'Wait a few seconds and retry.'; + const response = new Response( + JSON.stringify({ + ok: false, + error: { + code: 'RUNNER_BUSY', + message: 'The runner is still finishing abandoned work.', + hint, + }, + }), + ); + const session = { ready: true }; + + await assert.rejects( + () => parseRunnerResponse(response, session, '/tmp/runner.log'), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.code, 'COMMAND_FAILED'); + assert.equal(error.details?.runnerErrorCode, 'RUNNER_BUSY'); + assert.equal(error.details?.retriable, true); + assert.equal(error.details?.hint, hint); + assert.equal(isRetryableRunnerError(error), true); + return true; + }, + ); +}); + +test('parseRunnerResponse preserves RUNNER_WEDGED as a fatal runner code', async () => { + const response = new Response( + JSON.stringify({ + ok: false, + error: { + code: 'RUNNER_WEDGED', + message: 'The runner main thread is wedged.', + hint: 'The runner session will be restarted.', + }, + }), + ); + const session = { ready: true }; + + await assert.rejects( + () => parseRunnerResponse(response, session, '/tmp/runner.log'), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.code, 'RUNNER_WEDGED'); + assert.equal(error.details?.runnerErrorCode, 'RUNNER_WEDGED'); + assert.equal(isRetryableRunnerError(error), false); + return true; + }, + ); +}); + test('parseRunnerResponse classifies target app AXRuntime CoreText font crashes from runner log tail', async () => { const logPath = writeRunnerLogTail(` Thread 0 Crashed:: Dispatch queue: com.apple.main-thread diff --git a/src/platforms/apple/core/__tests__/runner-command-retry.test.ts b/src/platforms/apple/core/__tests__/runner-command-retry.test.ts index c1ab796eb..1e87b42f5 100644 --- a/src/platforms/apple/core/__tests__/runner-command-retry.test.ts +++ b/src/platforms/apple/core/__tests__/runner-command-retry.test.ts @@ -10,12 +10,14 @@ const { mockEnsureRunnerSession, mockExecuteRunnerCommandWithSession, mockEmitDiagnostic, + mockGetRunnerSessionSnapshot, mockInvalidateRunnerSession, mockMarkRunnerXctestrunArtifactBadForRun, } = vi.hoisted(() => ({ mockEnsureRunnerSession: vi.fn(), mockExecuteRunnerCommandWithSession: vi.fn(), mockEmitDiagnostic: vi.fn(), + mockGetRunnerSessionSnapshot: vi.fn(), mockInvalidateRunnerSession: vi.fn(), mockMarkRunnerXctestrunArtifactBadForRun: vi.fn(), })); @@ -38,6 +40,7 @@ vi.mock('../runner/runner-session.ts', async () => { ...actual, ensureRunnerSession: mockEnsureRunnerSession, executeRunnerCommandWithSession: mockExecuteRunnerCommandWithSession, + getRunnerSessionSnapshot: mockGetRunnerSessionSnapshot, invalidateRunnerSession: mockInvalidateRunnerSession, }; }); @@ -63,6 +66,7 @@ import type { RunnerXctestrunArtifact } from '../runner/runner-xctestrun.ts'; beforeEach(() => { vi.resetAllMocks(); resetRunnerRecycleLedgerForTests(); + mockGetRunnerSessionSnapshot.mockReturnValue(null); mockMarkRunnerXctestrunArtifactBadForRun.mockResolvedValue(undefined); }); @@ -1190,6 +1194,54 @@ test('a request pays for at most one runner recycle, then fails fast with a pres assert.equal(mockEnsureRunnerSession.mock.calls.length, 2); }); +test('a failed replacement boot does not consume the request recycle budget', async () => { + const requestId = 'req-recycle-transient-boot-failure'; + mockEnsureRunnerSession + .mockResolvedValueOnce(makeRunnerSession({ port: 8100, ready: true })) + .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'Runner did not accept connection')) + .mockResolvedValueOnce(makeRunnerSession({ port: 8101, ready: true })); + mockExecuteRunnerCommandWithSession + .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'fetch failed')) + .mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'fetch failed')) + .mockResolvedValueOnce({ nodes: [], truncated: false }); + + const result = await runAppleRunnerCommand(IOS_SIMULATOR, { command: 'snapshot' }, { requestId }); + + assert.deepEqual(result, { nodes: [], truncated: false }); + assert.equal(mockEnsureRunnerSession.mock.calls.length, 3); +}); + +test('alive session reuse in the same request does not consume recycle budget', async () => { + const requestId = 'req-alive-reuse-before-recycle'; + mockGetRunnerSessionSnapshot + .mockReturnValueOnce(null) + .mockReturnValueOnce({ alive: true }) + .mockReturnValueOnce(null); + mockEnsureRunnerSession + .mockResolvedValueOnce(makeRunnerSession({ port: 8100, ready: true })) + .mockResolvedValueOnce(makeRunnerSession({ port: 8100, ready: true })) + .mockResolvedValueOnce(makeRunnerSession({ port: 8101, ready: true })); + mockExecuteRunnerCommandWithSession + .mockResolvedValueOnce({ message: 'first tap' }) + .mockResolvedValueOnce({ message: 'second tap' }) + .mockResolvedValueOnce({ message: 'recovered tap' }); + + assert.deepEqual( + await runAppleRunnerCommand(IOS_SIMULATOR, { command: 'tap', x: 120, y: 240 }, { requestId }), + { message: 'first tap' }, + ); + assert.deepEqual( + await runAppleRunnerCommand(IOS_SIMULATOR, { command: 'tap', x: 120, y: 240 }, { requestId }), + { message: 'second tap' }, + ); + assert.deepEqual( + await runAppleRunnerCommand(IOS_SIMULATOR, { command: 'tap', x: 120, y: 240 }, { requestId }), + { message: 'recovered tap' }, + ); + + assert.equal(mockEnsureRunnerSession.mock.calls.length, 3); +}); + test('a later command in the same request cannot pay for a second recycle boot', async () => { const requestId = 'req-restart-cap'; const staleSession = makeRunnerSession({ port: 8100, ready: true }); diff --git a/src/platforms/apple/core/__tests__/runner-recycle-ledger.test.ts b/src/platforms/apple/core/__tests__/runner-recycle-ledger.test.ts index e9bc258c1..4bfdaf987 100644 --- a/src/platforms/apple/core/__tests__/runner-recycle-ledger.test.ts +++ b/src/platforms/apple/core/__tests__/runner-recycle-ledger.test.ts @@ -3,11 +3,12 @@ import assert from 'node:assert/strict'; import { AppError } from '../../../../kernel/errors.ts'; import { buildRunnerRecycleBudgetExhaustedError, + commitRunnerRecycle, hasRunnerRequestTouchedSession, markRunnerRequestTouchedSession, resetRunnerRecycleLedgerForTests, runnerRecycleLedgerKey, - tryConsumeRunnerRecycle, + tryBeginRunnerRecycle, } from '../runner/runner-recycle-ledger.ts'; beforeEach(() => { @@ -23,18 +24,28 @@ test('ledger key prefers the request id and falls back to the command id', () => assert.equal(runnerRecycleLedgerKey({ requestId: ' ' }, { commandId: ' ' }), undefined); }); -test('a request gets exactly one runner recycle, then must fail fast', () => { +test('a request gets exactly one successful runner recycle, then must fail fast', () => { const key = 'request:req-wedge'; - assert.equal(tryConsumeRunnerRecycle(key), true); - assert.equal(tryConsumeRunnerRecycle(key), false); - assert.equal(tryConsumeRunnerRecycle(key), false); + assert.equal(tryBeginRunnerRecycle(key), true); + commitRunnerRecycle(key); + assert.equal(tryBeginRunnerRecycle(key), false); + assert.equal(tryBeginRunnerRecycle(key), false); // Other requests keep their own budget. - assert.equal(tryConsumeRunnerRecycle('request:req-other'), true); + assert.equal(tryBeginRunnerRecycle('request:req-other'), true); +}); + +test('failed replacement boots do not consume the recycle budget', () => { + const key = 'request:req-transient-boot-failure'; + assert.equal(tryBeginRunnerRecycle(key), true); + assert.equal(tryBeginRunnerRecycle(key), true); + commitRunnerRecycle(key); + assert.equal(tryBeginRunnerRecycle(key), false); }); test('untracked keys never block (no scope to account against)', () => { - assert.equal(tryConsumeRunnerRecycle(undefined), true); - assert.equal(tryConsumeRunnerRecycle(undefined), true); + assert.equal(tryBeginRunnerRecycle(undefined), true); + assert.equal(tryBeginRunnerRecycle(undefined), true); + commitRunnerRecycle(undefined); // no-op, must not throw assert.equal(hasRunnerRequestTouchedSession(undefined), false); markRunnerRequestTouchedSession(undefined); // no-op, must not throw }); diff --git a/src/platforms/apple/core/__tests__/runner-session.test.ts b/src/platforms/apple/core/__tests__/runner-session.test.ts index 8b6f6e9aa..0fca5ca4c 100644 --- a/src/platforms/apple/core/__tests__/runner-session.test.ts +++ b/src/platforms/apple/core/__tests__/runner-session.test.ts @@ -1650,6 +1650,38 @@ test('runner session clears recency when an allowlisted command returns XCTest r assert.equal(getRunnerSessionSnapshot(device.id), null); }); +test('runner session invalidates when the runner reports abandoned main-thread work is wedged', async () => { + const device = { ...IOS_SIMULATOR, id: 'runner-session-wedged-sim' }; + const session = await ensureRunnerSession(device, {}); + session.ready = true; + mockWaitForRunner.mockClear(); + mockWaitForRunner.mockResolvedValueOnce(runnerResponse({ uptimeMs: 42 })); + mockSendRunnerCommandOnce.mockResolvedValueOnce( + runnerError({ + code: 'RUNNER_WEDGED', + message: 'The runner main thread is wedged.', + }), + ); + + await assert.rejects( + () => + executeRunnerCommandWithSession( + device, + session, + { command: 'tap', x: 1, y: 2, appBundleId: 'com.example.demo' }, + '/tmp/runner.log', + 30_000, + ), + (error: unknown) => { + assert.ok(error instanceof AppError); + assert.equal(error.code, 'RUNNER_WEDGED'); + return true; + }, + ); + + assert.equal(getRunnerSessionSnapshot(device.id), null); +}); + function makeRunnerSession(overrides: Partial = {}): RunnerSession { return { sessionId: `session-${overrides.port ?? 8100}`, diff --git a/src/platforms/apple/core/runner/runner-contract.ts b/src/platforms/apple/core/runner/runner-contract.ts index 046ab18a1..8887dcbb0 100644 --- a/src/platforms/apple/core/runner/runner-contract.ts +++ b/src/platforms/apple/core/runner/runner-contract.ts @@ -118,6 +118,7 @@ export type RunnerSequenceStep = { export function isRetryableRunnerError(err: unknown): boolean { if (!(err instanceof AppError)) return false; if (err.code !== 'COMMAND_FAILED') return false; + if (err.details?.retriable === true) return true; const message = `${err.message ?? ''}`.toLowerCase(); if (message.includes('xcodebuild exited early')) return false; if (message.includes('device is busy') && message.includes('connecting')) return false; diff --git a/src/platforms/apple/core/runner/runner-lifecycle.ts b/src/platforms/apple/core/runner/runner-lifecycle.ts index dab9efcc8..a33b676ba 100644 --- a/src/platforms/apple/core/runner/runner-lifecycle.ts +++ b/src/platforms/apple/core/runner/runner-lifecycle.ts @@ -27,10 +27,11 @@ import { markRunnerXctestrunArtifactBadForRun } from './runner-xctestrun.ts'; import { handleRunnerTransportErrorAfterCommandSend } from './runner-command-recovery.ts'; import { buildRunnerRecycleBudgetExhaustedError, + commitRunnerRecycle, hasRunnerRequestTouchedSession, markRunnerRequestTouchedSession, runnerRecycleLedgerKey, - tryConsumeRunnerRecycle, + tryBeginRunnerRecycle, } from './runner-recycle-ledger.ts'; export type PrepareIosRunnerOptions = AppleRunnerPrepareOptions; @@ -251,18 +252,21 @@ export async function executeRunnerCommand( const signal = getRequestSignal(options.requestId); const recycleKey = runnerRecycleLedgerKey(options, command); let session: RunnerSession | undefined; + let recycleBootBegun = false; try { // A request that already used a runner session and finds none alive is about to pay for // a recycle boot (~25s): bound that to the per-request recycle budget so a hostile screen // fails fast with a preserved session instead of stacking runner boots (#1105). - if ( - !getRunnerSessionSnapshot(device.id)?.alive && - hasRunnerRequestTouchedSession(recycleKey) && - !tryConsumeRunnerRecycle(recycleKey) - ) { - throw buildRunnerRecycleBudgetExhaustedError(command, options); + if (!getRunnerSessionSnapshot(device.id)?.alive && hasRunnerRequestTouchedSession(recycleKey)) { + if (!tryBeginRunnerRecycle(recycleKey)) { + throw buildRunnerRecycleBudgetExhaustedError(command, options); + } + recycleBootBegun = true; } session = await ensureRunnerSession(device, options); + if (recycleBootBegun) { + commitRunnerRecycle(recycleKey); + } markRunnerRequestTouchedSession(recycleKey); const timeoutMs = session.ready ? RUNNER_COMMAND_TIMEOUT_MS @@ -337,7 +341,8 @@ async function restartSessionAndRunCommand(params: { // At most one recycle per request: when the budget is spent, fail fast and KEEP the current // session — if the runner is merely busy draining abandoned work it answers the next request // cheaply, and a dead process is detected and cleaned by the next ensureRunnerSession (#1105). - if (!tryConsumeRunnerRecycle(runnerRecycleLedgerKey(options, command))) { + const recycleKey = runnerRecycleLedgerKey(options, command); + if (!tryBeginRunnerRecycle(recycleKey)) { throw buildRunnerRecycleBudgetExhaustedError(command, options); } await invalidateRunnerSession(params.session, restartReason); @@ -345,6 +350,7 @@ async function restartSessionAndRunCommand(params: { ...options, cleanStaleBundles: true, }); + commitRunnerRecycle(recycleKey); try { const recovered = await executeRunnerCommandWithSession( device, diff --git a/src/platforms/apple/core/runner/runner-recycle-ledger.ts b/src/platforms/apple/core/runner/runner-recycle-ledger.ts index 86506834b..e5c615c96 100644 --- a/src/platforms/apple/core/runner/runner-recycle-ledger.ts +++ b/src/platforms/apple/core/runner/runner-recycle-ledger.ts @@ -47,22 +47,30 @@ export function hasRunnerRequestTouchedSession(key: string | undefined): boolean } /** - * Consumes one recycle from the request's budget. Returns false when the - * budget is exhausted — the caller must fail fast instead of booting another - * runner. Untracked keys always allow (no scope to account against). + * Checks whether a request may attempt a recycle boot. The caller must pair a + * successful boot with `commitRunnerRecycle`; failed boots stay free so a + * transient xcodebuild/simulator startup failure does not spend the request's + * only hostile-screen recovery slot. */ -export function tryConsumeRunnerRecycle(key: string | undefined): boolean { +export function tryBeginRunnerRecycle(key: string | undefined): boolean { if (!key) return true; const entry = readEntry(key); if (entry.recycles >= MAX_RUNNER_RECYCLES_PER_REQUEST) { writeEntry(key, entry); return false; } - entry.recycles += 1; writeEntry(key, entry); return true; } +/** Consumes one recycle after the replacement runner has booted successfully. */ +export function commitRunnerRecycle(key: string | undefined): void { + if (!key) return; + const entry = readEntry(key); + entry.recycles += 1; + writeEntry(key, entry); +} + export function buildRunnerRecycleBudgetExhaustedError( command: Pick, options: { requestId?: string; logPath?: string }, diff --git a/src/platforms/apple/core/runner/runner-session.ts b/src/platforms/apple/core/runner/runner-session.ts index 62fd50711..9ad3e30cc 100644 --- a/src/platforms/apple/core/runner/runner-session.ts +++ b/src/platforms/apple/core/runner/runner-session.ts @@ -843,43 +843,58 @@ export async function parseRunnerResponse( session: Pick, logPath?: string, ): Promise> { - const text = await response.text(); - let json: RunnerResponsePayload; - try { - const parsed: unknown = JSON.parse(text); - json = parsed && typeof parsed === 'object' ? (parsed as RunnerResponsePayload) : {}; - } catch { - throw new AppError('COMMAND_FAILED', 'Invalid runner response', { text }); - } + const json = parseRunnerResponsePayload(await response.text()); if (!json.ok) { - const rawCode = json.error?.code; - const errorCode = - typeof rawCode === 'string' && rawCode.trim().length > 0 - ? toAppErrorCode(rawCode) - : 'COMMAND_FAILED'; - const errorMessage = typeof json.error?.message === 'string' ? json.error.message : undefined; - const hint = typeof json.error?.hint === 'string' ? json.error.hint : undefined; throw await enrichRunnerFailureFromLog({ - error: new AppError(errorCode, errorMessage ?? 'Runner error', { - runner: json, - xcodebuild: { - exitCode: 1, - stdout: '', - stderr: '', - }, - hint, - logPath, - }), + error: buildRunnerResponseError(json, logPath), logPath, }); } session.ready = true; - if (json.data && typeof json.data === 'object' && !Array.isArray(json.data)) { - const data = json.data as Record; - emitRunnerResponseDiagnostics(data); - return data; + return readRunnerResponseData(json); +} + +function parseRunnerResponsePayload(text: string): RunnerResponsePayload { + try { + const parsed: unknown = JSON.parse(text); + return parsed && typeof parsed === 'object' ? (parsed as RunnerResponsePayload) : {}; + } catch { + throw new AppError('COMMAND_FAILED', 'Invalid runner response', { text }); } - return {}; +} + +function buildRunnerResponseError(json: RunnerResponsePayload, logPath?: string): AppError { + const runnerErrorCode = readRunnerErrorCode(json.error?.code); + const errorMessage = typeof json.error?.message === 'string' ? json.error.message : undefined; + const hint = typeof json.error?.hint === 'string' ? json.error.hint : undefined; + return new AppError(runnerAppErrorCode(runnerErrorCode), errorMessage ?? 'Runner error', { + runner: json, + runnerErrorCode, + retriable: runnerErrorCode === 'RUNNER_BUSY' ? true : undefined, + xcodebuild: { + exitCode: 1, + stdout: '', + stderr: '', + }, + hint, + logPath, + }); +} + +function readRunnerErrorCode(rawCode: unknown): string | undefined { + return typeof rawCode === 'string' && rawCode.trim().length > 0 ? rawCode.trim() : undefined; +} + +function runnerAppErrorCode(runnerErrorCode: string | undefined): AppError['code'] { + if (runnerErrorCode === 'RUNNER_BUSY') return 'COMMAND_FAILED'; + return runnerErrorCode ? toAppErrorCode(runnerErrorCode) : 'COMMAND_FAILED'; +} + +function readRunnerResponseData(json: RunnerResponsePayload): Record { + if (!json.data || typeof json.data !== 'object' || Array.isArray(json.data)) return {}; + const data = json.data as Record; + emitRunnerResponseDiagnostics(data); + return data; } function emitRunnerResponseDiagnostics(data: Record): void {