Skip to content

Commit 8db2910

Browse files
committed
fix: clarify ios runner recovery errors
1 parent ad6268d commit 8db2910

2 files changed

Lines changed: 100 additions & 3 deletions

File tree

src/platforms/ios/__tests__/runner-command-retry.test.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,6 +213,86 @@ test('read-only commands retry when completed status has no retained response',
213213
assert.equal(mockExecuteRunnerCommandWithSession.mock.calls[2]?.[2].command, 'snapshot');
214214
});
215215

216+
test('mutating commands report recovery guidance when completed status has no retained response', async () => {
217+
const session = makeRunnerSession({ port: 8100, ready: true });
218+
219+
mockEnsureRunnerSession.mockResolvedValueOnce(session);
220+
mockExecuteRunnerCommandWithSession
221+
.mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'fetch failed'))
222+
.mockResolvedValueOnce({ lifecycleState: 'completed' });
223+
224+
await assert.rejects(
225+
() => runIosRunnerCommand(IOS_SIMULATOR, { command: 'tap', x: 120, y: 240 }),
226+
(error: unknown) => {
227+
assert.ok(error instanceof AppError);
228+
assert.match(error.message, /"tap" completed after the transport response was lost/);
229+
assert.equal(error.details?.recovery, 'completed_without_retained_response');
230+
assert.match(String(error.details?.hint), /will not replay/);
231+
assert.match(String(error.details?.hint), /snapshot -i/);
232+
assert.equal(error.details?.transportError, 'fetch failed');
233+
return true;
234+
},
235+
);
236+
237+
assert.equal(mockInvalidateRunnerSession.mock.calls.length, 0);
238+
assert.equal(mockExecuteRunnerCommandWithSession.mock.calls.length, 2);
239+
});
240+
241+
test('mutating commands preserve runner failure details from status recovery', async () => {
242+
const session = makeRunnerSession({ port: 8100, ready: true });
243+
244+
mockEnsureRunnerSession.mockResolvedValueOnce(session);
245+
mockExecuteRunnerCommandWithSession
246+
.mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'fetch failed'))
247+
.mockResolvedValueOnce({
248+
lifecycleState: 'failed',
249+
lifecycleErrorCode: 'AMBIGUOUS_MATCH',
250+
lifecycleErrorMessage: 'Found 2 matching buttons',
251+
lifecycleErrorHint: 'Use a more specific selector.',
252+
});
253+
254+
await assert.rejects(
255+
() => runIosRunnerCommand(IOS_SIMULATOR, { command: 'tap', x: 120, y: 240 }),
256+
(error: unknown) => {
257+
assert.ok(error instanceof AppError);
258+
assert.equal(error.code, 'AMBIGUOUS_MATCH');
259+
assert.equal(error.message, 'Found 2 matching buttons');
260+
assert.equal(error.details?.recovery, 'runner_reported_failure');
261+
assert.equal(error.details?.hint, 'Use a more specific selector.');
262+
assert.equal(error.details?.transportError, 'fetch failed');
263+
return true;
264+
},
265+
);
266+
267+
assert.equal(mockInvalidateRunnerSession.mock.calls.length, 0);
268+
assert.equal(mockExecuteRunnerCommandWithSession.mock.calls.length, 2);
269+
});
270+
271+
test('mutating commands report wait-and-inspect guidance when status shows in-flight work', async () => {
272+
const session = makeRunnerSession({ port: 8100, ready: true });
273+
274+
mockEnsureRunnerSession.mockResolvedValueOnce(session);
275+
mockExecuteRunnerCommandWithSession
276+
.mockRejectedValueOnce(new AppError('COMMAND_FAILED', 'fetch failed'))
277+
.mockResolvedValueOnce({ lifecycleState: 'started' });
278+
279+
await assert.rejects(
280+
() => runIosRunnerCommand(IOS_SIMULATOR, { command: 'tap', x: 120, y: 240 }),
281+
(error: unknown) => {
282+
assert.ok(error instanceof AppError);
283+
assert.match(error.message, /"tap" is still started/);
284+
assert.equal(error.details?.recovery, 'command_still_in_flight');
285+
assert.match(String(error.details?.hint), /may still finish/);
286+
assert.match(String(error.details?.hint), /snapshot -i/);
287+
assert.equal(error.details?.transportError, 'fetch failed');
288+
return true;
289+
},
290+
);
291+
292+
assert.equal(mockInvalidateRunnerSession.mock.calls.length, 0);
293+
assert.equal(mockExecuteRunnerCommandWithSession.mock.calls.length, 2);
294+
});
295+
216296
test('mutating commands invalidate the retry session without replaying again', async () => {
217297
const staleSession = makeRunnerSession({ port: 8100, ready: true });
218298
const freshSession = makeRunnerSession({ port: 8101, ready: false });

src/platforms/ios/runner-client.ts

Lines changed: 20 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -273,11 +273,13 @@ async function tryRecoverRunnerCommandAfterTransportError(
273273
}
274274
throw new AppError(
275275
'COMMAND_FAILED',
276-
'Runner command completed, but its response was not retained for recovery.',
276+
`Runner command "${command.command}" completed after the transport response was lost, but no recoverable response was retained.`,
277277
{
278278
command: command.command,
279279
commandId: command.commandId,
280280
lifecycleState,
281+
recovery: 'completed_without_retained_response',
282+
hint: completedWithoutRetainedResponseHint(command.command),
281283
logPath: options.logPath,
282284
transportError: transportError.message,
283285
},
@@ -301,7 +303,8 @@ async function tryRecoverRunnerCommandAfterTransportError(
301303
command: command.command,
302304
commandId: command.commandId,
303305
lifecycleState,
304-
hint,
306+
recovery: 'runner_reported_failure',
307+
hint: hint ?? runnerReportedFailureHint(command.command),
305308
logPath: options.logPath,
306309
transportError: transportError.message,
307310
},
@@ -312,11 +315,13 @@ async function tryRecoverRunnerCommandAfterTransportError(
312315
if (lifecycleState === 'accepted' || lifecycleState === 'started') {
313316
throw new AppError(
314317
'COMMAND_FAILED',
315-
`Runner command is ${lifecycleState}, but its response was lost.`,
318+
`Runner command "${command.command}" is still ${lifecycleState} after the transport response was lost.`,
316319
{
317320
command: command.command,
318321
commandId: command.commandId,
319322
lifecycleState,
323+
recovery: 'command_still_in_flight',
324+
hint: inFlightAfterLostResponseHint(command.command),
320325
logPath: options.logPath,
321326
transportError: transportError.message,
322327
},
@@ -343,6 +348,18 @@ function parseLifecycleResponseJson(value: unknown): Record<string, unknown> | u
343348
return {};
344349
}
345350

351+
function completedWithoutRetainedResponseHint(command: string): string {
352+
return `The runner reports "${command}" already completed, so agent-device will not replay it. Run snapshot -i to inspect the current UI, then continue from that observed state. If the session is stale, close and reopen the session before retrying.`;
353+
}
354+
355+
function runnerReportedFailureHint(command: string): string {
356+
return `The runner observed "${command}" fail after the transport response was lost, so agent-device did not replay it. Run snapshot -i to inspect the current UI and retry with a selector visible in that snapshot. If the session is stale, close and reopen the session before retrying.`;
357+
}
358+
359+
function inFlightAfterLostResponseHint(command: string): string {
360+
return `The runner has accepted "${command}" and it may still finish, so agent-device will not replay it. Wait briefly, run snapshot -i to inspect the current UI, then continue from that observed state. If the session stops responding, close and reopen the session before retrying.`;
361+
}
362+
346363
function isRunnerReadinessPreflightError(error: AppError): boolean {
347364
return error.details?.runnerReadinessPreflightFailed === true;
348365
}

0 commit comments

Comments
 (0)