agent-device 0.4.1 → 0.4.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (41) hide show
  1. package/README.md +18 -12
  2. package/dist/src/bin.js +32 -32
  3. package/dist/src/daemon.js +18 -14
  4. package/ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift +8 -2
  5. package/package.json +1 -1
  6. package/skills/agent-device/SKILL.md +19 -13
  7. package/skills/agent-device/references/permissions.md +7 -2
  8. package/skills/agent-device/references/session-management.md +3 -1
  9. package/src/__tests__/cli-close.test.ts +155 -0
  10. package/src/cli.ts +32 -16
  11. package/src/core/__tests__/capabilities.test.ts +2 -1
  12. package/src/core/__tests__/dispatch-open.test.ts +25 -0
  13. package/src/core/__tests__/open-target.test.ts +40 -1
  14. package/src/core/capabilities.ts +1 -1
  15. package/src/core/dispatch.ts +22 -0
  16. package/src/core/open-target.ts +14 -0
  17. package/src/daemon/__tests__/device-ready.test.ts +52 -0
  18. package/src/daemon/device-ready.ts +146 -4
  19. package/src/daemon/handlers/__tests__/session.test.ts +477 -0
  20. package/src/daemon/handlers/session.ts +196 -91
  21. package/src/daemon/session-store.ts +0 -2
  22. package/src/daemon-client.ts +118 -18
  23. package/src/platforms/android/__tests__/index.test.ts +118 -1
  24. package/src/platforms/android/index.ts +77 -47
  25. package/src/platforms/ios/__tests__/index.test.ts +292 -4
  26. package/src/platforms/ios/__tests__/runner-client.test.ts +42 -0
  27. package/src/platforms/ios/apps.ts +358 -0
  28. package/src/platforms/ios/config.ts +28 -0
  29. package/src/platforms/ios/devicectl.ts +134 -0
  30. package/src/platforms/ios/devices.ts +15 -2
  31. package/src/platforms/ios/index.ts +20 -455
  32. package/src/platforms/ios/runner-client.ts +72 -16
  33. package/src/platforms/ios/simulator.ts +164 -0
  34. package/src/utils/__tests__/args.test.ts +20 -2
  35. package/src/utils/__tests__/daemon-client.test.ts +21 -4
  36. package/src/utils/args.ts +6 -1
  37. package/src/utils/command-schema.ts +7 -14
  38. package/src/utils/interactors.ts +2 -2
  39. package/src/utils/timeouts.ts +9 -0
  40. package/src/daemon/__tests__/app-state.test.ts +0 -138
  41. package/src/daemon/app-state.ts +0 -65
@@ -1,455 +1,20 @@
1
- import { runCmd } from '../../utils/exec.ts';
2
- import type { ExecResult } from '../../utils/exec.ts';
3
- import { AppError } from '../../utils/errors.ts';
4
- import type { DeviceInfo } from '../../utils/device.ts';
5
- import { Deadline, isEnvTruthy, retryWithPolicy, TIMEOUT_PROFILES, type RetryTelemetryEvent } from '../../utils/retry.ts';
6
- import { isDeepLinkTarget } from '../../core/open-target.ts';
7
- import { bootFailureHint, classifyBootFailure } from '../boot-diagnostics.ts';
8
-
9
- const ALIASES: Record<string, string> = {
10
- settings: 'com.apple.Preferences',
11
- };
12
-
13
- const IOS_BOOT_TIMEOUT_MS = resolveTimeoutMs(
14
- process.env.AGENT_DEVICE_IOS_BOOT_TIMEOUT_MS,
15
- TIMEOUT_PROFILES.ios_boot.totalMs,
16
- 5_000,
17
- );
18
- const IOS_SIMCTL_LIST_TIMEOUT_MS = resolveTimeoutMs(
19
- process.env.AGENT_DEVICE_IOS_SIMCTL_LIST_TIMEOUT_MS,
20
- TIMEOUT_PROFILES.ios_boot.operationMs,
21
- 1_000,
22
- );
23
- const IOS_APP_LAUNCH_TIMEOUT_MS = resolveTimeoutMs(
24
- process.env.AGENT_DEVICE_IOS_APP_LAUNCH_TIMEOUT_MS,
25
- 30_000,
26
- 5_000,
27
- );
28
- const RETRY_LOGS_ENABLED = isEnvTruthy(process.env.AGENT_DEVICE_RETRY_LOGS);
29
-
30
- export async function resolveIosApp(device: DeviceInfo, app: string): Promise<string> {
31
- const trimmed = app.trim();
32
- if (trimmed.includes('.')) return trimmed;
33
-
34
- const alias = ALIASES[trimmed.toLowerCase()];
35
- if (alias) return alias;
36
-
37
- if (device.kind === 'simulator') {
38
- const list = await listSimulatorApps(device);
39
- const matches = list.filter((entry) => entry.name.toLowerCase() === trimmed.toLowerCase());
40
- if (matches.length === 1) return matches[0].bundleId;
41
- if (matches.length > 1) {
42
- throw new AppError('INVALID_ARGS', `Multiple apps matched "${app}"`, { matches });
43
- }
44
- }
45
-
46
- throw new AppError('APP_NOT_INSTALLED', `No app found matching "${app}"`);
47
- }
48
-
49
- export async function openIosApp(
50
- device: DeviceInfo,
51
- app: string,
52
- options?: { appBundleId?: string },
53
- ): Promise<void> {
54
- const deepLinkTarget = app.trim();
55
- if (isDeepLinkTarget(deepLinkTarget)) {
56
- if (device.kind !== 'simulator') {
57
- throw new AppError('UNSUPPORTED_OPERATION', 'Deep link open is only supported on iOS simulators');
58
- }
59
- await ensureBootedSimulator(device);
60
- await runCmd('open', ['-a', 'Simulator'], { allowFailure: true });
61
- await runCmd('xcrun', ['simctl', 'openurl', device.id, deepLinkTarget]);
62
- return;
63
- }
64
- const bundleId = options?.appBundleId ?? (await resolveIosApp(device, app));
65
- if (device.kind === 'simulator') {
66
- await ensureBootedSimulator(device);
67
- await runCmd('open', ['-a', 'Simulator'], { allowFailure: true });
68
- const launchDeadline = Deadline.fromTimeoutMs(IOS_APP_LAUNCH_TIMEOUT_MS);
69
- await retryWithPolicy(
70
- async ({ deadline: attemptDeadline }) => {
71
- if (attemptDeadline?.isExpired()) {
72
- throw new AppError('COMMAND_FAILED', 'App launch deadline exceeded', {
73
- timeoutMs: IOS_APP_LAUNCH_TIMEOUT_MS,
74
- });
75
- }
76
- const result = await runCmd('xcrun', ['simctl', 'launch', device.id, bundleId], {
77
- allowFailure: true,
78
- });
79
- if (result.exitCode === 0) return;
80
- throw new AppError('COMMAND_FAILED', `xcrun exited with code ${result.exitCode}`, {
81
- cmd: 'xcrun',
82
- args: ['simctl', 'launch', device.id, bundleId],
83
- stdout: result.stdout,
84
- stderr: result.stderr,
85
- exitCode: result.exitCode,
86
- });
87
- },
88
- {
89
- maxAttempts: 30,
90
- baseDelayMs: 1_000,
91
- maxDelayMs: 5_000,
92
- jitter: 0.2,
93
- shouldRetry: isTransientSimulatorLaunchFailure,
94
- },
95
- { deadline: launchDeadline },
96
- );
97
- return;
98
- }
99
- await runCmd('xcrun', [
100
- 'devicectl',
101
- 'device',
102
- 'process',
103
- 'launch',
104
- '--device',
105
- device.id,
106
- bundleId,
107
- ]);
108
- }
109
-
110
- export async function openIosDevice(device: DeviceInfo): Promise<void> {
111
- if (device.kind !== 'simulator') return;
112
- const state = await getSimulatorState(device.id);
113
- if (state === 'Booted') return;
114
- await ensureBootedSimulator(device);
115
- await runCmd('open', ['-a', 'Simulator'], { allowFailure: true });
116
- }
117
-
118
- export async function closeIosApp(device: DeviceInfo, app: string): Promise<void> {
119
- const bundleId = await resolveIosApp(device, app);
120
- if (device.kind === 'simulator') {
121
- await ensureBootedSimulator(device);
122
- const result = await runCmd('xcrun', ['simctl', 'terminate', device.id, bundleId], {
123
- allowFailure: true,
124
- });
125
- if (result.exitCode !== 0) {
126
- const stderr = result.stderr.toLowerCase();
127
- if (stderr.includes('found nothing to terminate')) return;
128
- throw new AppError('COMMAND_FAILED', `xcrun exited with code ${result.exitCode}`, {
129
- cmd: 'xcrun',
130
- args: ['simctl', 'terminate', device.id, bundleId],
131
- stdout: result.stdout,
132
- stderr: result.stderr,
133
- exitCode: result.exitCode,
134
- });
135
- }
136
- return;
137
- }
138
- await runCmd('xcrun', [
139
- 'devicectl',
140
- 'device',
141
- 'process',
142
- 'terminate',
143
- '--device',
144
- device.id,
145
- bundleId,
146
- ]);
147
- }
148
-
149
- export async function uninstallIosApp(device: DeviceInfo, app: string): Promise<{ bundleId: string }> {
150
- ensureSimulator(device, 'reinstall');
151
- const bundleId = await resolveIosApp(device, app);
152
- await ensureBootedSimulator(device);
153
- const result = await runCmd('xcrun', ['simctl', 'uninstall', device.id, bundleId], {
154
- allowFailure: true,
155
- });
156
- if (result.exitCode !== 0) {
157
- const output = `${result.stdout}\n${result.stderr}`.toLowerCase();
158
- if (!output.includes('not installed') && !output.includes('not found') && !output.includes('no such file')) {
159
- throw new AppError('COMMAND_FAILED', `simctl uninstall failed for ${bundleId}`, {
160
- stdout: result.stdout,
161
- stderr: result.stderr,
162
- exitCode: result.exitCode,
163
- });
164
- }
165
- }
166
- return { bundleId };
167
- }
168
-
169
- export async function installIosApp(device: DeviceInfo, appPath: string): Promise<void> {
170
- ensureSimulator(device, 'reinstall');
171
- await ensureBootedSimulator(device);
172
- await runCmd('xcrun', ['simctl', 'install', device.id, appPath]);
173
- }
174
-
175
- export async function reinstallIosApp(
176
- device: DeviceInfo,
177
- app: string,
178
- appPath: string,
179
- ): Promise<{ bundleId: string }> {
180
- const { bundleId } = await uninstallIosApp(device, app);
181
- await installIosApp(device, appPath);
182
- return { bundleId };
183
- }
184
-
185
- export async function screenshotIos(device: DeviceInfo, outPath: string): Promise<void> {
186
- if (device.kind === 'simulator') {
187
- await ensureBootedSimulator(device);
188
- await runCmd('xcrun', ['simctl', 'io', device.id, 'screenshot', outPath]);
189
- return;
190
- }
191
- await runCmd('xcrun', ['devicectl', 'device', 'screenshot', '--device', device.id, outPath]);
192
- }
193
-
194
- export async function setIosSetting(
195
- device: DeviceInfo,
196
- setting: string,
197
- state: string,
198
- appBundleId?: string,
199
- ): Promise<void> {
200
- ensureSimulator(device, 'settings');
201
- await ensureBootedSimulator(device);
202
- const normalized = setting.toLowerCase();
203
- const enabled = parseSettingState(state);
204
- switch (normalized) {
205
- case 'wifi': {
206
- const mode = enabled ? 'active' : 'failed';
207
- await runCmd('xcrun', ['simctl', 'status_bar', device.id, 'override', '--wifiMode', mode]);
208
- return;
209
- }
210
- case 'airplane': {
211
- if (enabled) {
212
- await runCmd('xcrun', [
213
- 'simctl',
214
- 'status_bar',
215
- device.id,
216
- 'override',
217
- '--dataNetwork',
218
- 'hide',
219
- '--wifiMode',
220
- 'failed',
221
- '--wifiBars',
222
- '0',
223
- '--cellularMode',
224
- 'failed',
225
- '--cellularBars',
226
- '0',
227
- '--operatorName',
228
- '',
229
- ]);
230
- } else {
231
- await runCmd('xcrun', ['simctl', 'status_bar', device.id, 'clear']);
232
- }
233
- return;
234
- }
235
- case 'location': {
236
- if (!appBundleId) {
237
- throw new AppError('INVALID_ARGS', 'location setting requires an active app in session');
238
- }
239
- const action = enabled ? 'grant' : 'revoke';
240
- await runCmd('xcrun', ['simctl', 'privacy', device.id, action, 'location', appBundleId]);
241
- return;
242
- }
243
- default:
244
- throw new AppError('INVALID_ARGS', `Unsupported setting: ${setting}`);
245
- }
246
- }
247
-
248
- function ensureSimulator(device: DeviceInfo, command: string): void {
249
- if (device.kind !== 'simulator') {
250
- throw new AppError(
251
- 'UNSUPPORTED_OPERATION',
252
- `${command} is only supported on iOS simulators`,
253
- );
254
- }
255
- }
256
-
257
- function parseSettingState(state: string): boolean {
258
- const normalized = state.toLowerCase();
259
- if (normalized === 'on' || normalized === 'true' || normalized === '1') return true;
260
- if (normalized === 'off' || normalized === 'false' || normalized === '0') return false;
261
- throw new AppError('INVALID_ARGS', `Invalid setting state: ${state}`);
262
- }
263
-
264
- function isTransientSimulatorLaunchFailure(error: unknown): boolean {
265
- if (!(error instanceof AppError)) return false;
266
- if (error.code !== 'COMMAND_FAILED') return false;
267
- const details = (error.details ?? {}) as { exitCode?: number; stderr?: unknown };
268
- if (details.exitCode !== 4) return false;
269
- const stderr = String(details.stderr ?? '').toLowerCase();
270
- return (
271
- stderr.includes('fbsopenapplicationserviceerrordomain') &&
272
- stderr.includes('the request to open')
273
- );
274
- }
275
-
276
- export async function listSimulatorApps(
277
- device: DeviceInfo,
278
- ): Promise<{ bundleId: string; name: string }[]> {
279
- const result = await runCmd('xcrun', ['simctl', 'listapps', device.id], { allowFailure: true });
280
- const stdout = result.stdout as string;
281
- const trimmed = stdout.trim();
282
- if (!trimmed) return [];
283
- let parsed: Record<string, { CFBundleDisplayName?: string; CFBundleName?: string }> | null = null;
284
- if (trimmed.startsWith('{')) {
285
- try {
286
- parsed = JSON.parse(trimmed) as Record<
287
- string,
288
- { CFBundleDisplayName?: string; CFBundleName?: string }
289
- >;
290
- } catch {
291
- parsed = null;
292
- }
293
- }
294
- if (!parsed && trimmed.startsWith('{')) {
295
- try {
296
- const converted = await runCmd('plutil', ['-convert', 'json', '-o', '-', '-'], {
297
- allowFailure: true,
298
- stdin: trimmed,
299
- });
300
- if (converted.exitCode === 0 && converted.stdout.trim().startsWith('{')) {
301
- parsed = JSON.parse(converted.stdout) as Record<
302
- string,
303
- { CFBundleDisplayName?: string; CFBundleName?: string }
304
- >;
305
- }
306
- } catch {
307
- parsed = null;
308
- }
309
- }
310
- if (!parsed) return [];
311
- return Object.entries(parsed).map(([bundleId, info]) => ({
312
- bundleId,
313
- name: info.CFBundleDisplayName ?? info.CFBundleName ?? bundleId,
314
- }));
315
- }
316
-
317
- export async function ensureBootedSimulator(device: DeviceInfo): Promise<void> {
318
- if (device.kind !== 'simulator') return;
319
- const state = await getSimulatorState(device.id);
320
- if (state === 'Booted') return;
321
- const deadline = Deadline.fromTimeoutMs(IOS_BOOT_TIMEOUT_MS);
322
- let bootResult: ExecResult | undefined;
323
- let bootStatusResult: ExecResult | undefined;
324
- try {
325
- await retryWithPolicy(
326
- async ({ deadline: attemptDeadline }) => {
327
- if (attemptDeadline?.isExpired()) {
328
- throw new AppError('COMMAND_FAILED', 'iOS simulator boot deadline exceeded', {
329
- timeoutMs: IOS_BOOT_TIMEOUT_MS,
330
- });
331
- }
332
- const remainingMs = Math.max(1_000, attemptDeadline?.remainingMs() ?? IOS_BOOT_TIMEOUT_MS);
333
- bootResult = await runCmd('xcrun', ['simctl', 'boot', device.id], {
334
- allowFailure: true,
335
- timeoutMs: remainingMs,
336
- });
337
- const bootOutput = `${bootResult.stdout}\n${bootResult.stderr}`.toLowerCase();
338
- const bootAlreadyDone =
339
- bootOutput.includes('already booted') || bootOutput.includes('current state: booted');
340
- if (bootResult.exitCode !== 0 && !bootAlreadyDone) {
341
- throw new AppError('COMMAND_FAILED', 'simctl boot failed', {
342
- stdout: bootResult.stdout,
343
- stderr: bootResult.stderr,
344
- exitCode: bootResult.exitCode,
345
- });
346
- }
347
- bootStatusResult = await runCmd('xcrun', ['simctl', 'bootstatus', device.id, '-b'], {
348
- allowFailure: true,
349
- timeoutMs: remainingMs,
350
- });
351
- if (bootStatusResult.exitCode !== 0) {
352
- throw new AppError('COMMAND_FAILED', 'simctl bootstatus failed', {
353
- stdout: bootStatusResult.stdout,
354
- stderr: bootStatusResult.stderr,
355
- exitCode: bootStatusResult.exitCode,
356
- });
357
- }
358
- const nextState = await getSimulatorState(device.id);
359
- if (nextState !== 'Booted') {
360
- throw new AppError('COMMAND_FAILED', 'Simulator is still booting', {
361
- state: nextState,
362
- });
363
- }
364
- },
365
- {
366
- maxAttempts: 3,
367
- baseDelayMs: 500,
368
- maxDelayMs: 2000,
369
- jitter: 0.2,
370
- shouldRetry: (error) => {
371
- const reason = classifyBootFailure({
372
- error,
373
- stdout: bootStatusResult?.stdout ?? bootResult?.stdout,
374
- stderr: bootStatusResult?.stderr ?? bootResult?.stderr,
375
- context: { platform: 'ios', phase: 'boot' },
376
- });
377
- return reason !== 'IOS_BOOT_TIMEOUT' && reason !== 'CI_RESOURCE_STARVATION_SUSPECTED';
378
- },
379
- },
380
- {
381
- deadline,
382
- phase: 'boot',
383
- classifyReason: (error) =>
384
- classifyBootFailure({
385
- error,
386
- stdout: bootStatusResult?.stdout ?? bootResult?.stdout,
387
- stderr: bootStatusResult?.stderr ?? bootResult?.stderr,
388
- context: { platform: 'ios', phase: 'boot' },
389
- }),
390
- onEvent: (event: RetryTelemetryEvent) => {
391
- if (!RETRY_LOGS_ENABLED) return;
392
- process.stderr.write(`[agent-device][retry] ${JSON.stringify(event)}\n`);
393
- },
394
- },
395
- );
396
- } catch (error) {
397
- const bootStdout = bootResult?.stdout;
398
- const bootStderr = bootResult?.stderr;
399
- const bootExitCode = bootResult?.exitCode;
400
- const bootstatusStdout = bootStatusResult?.stdout;
401
- const bootstatusStderr = bootStatusResult?.stderr;
402
- const bootstatusExitCode = bootStatusResult?.exitCode;
403
- const reason = classifyBootFailure({
404
- error,
405
- stdout: bootstatusStdout ?? bootStdout,
406
- stderr: bootstatusStderr ?? bootStderr,
407
- context: { platform: 'ios', phase: 'boot' },
408
- });
409
- throw new AppError('COMMAND_FAILED', 'iOS simulator failed to boot', {
410
- platform: 'ios',
411
- deviceId: device.id,
412
- timeoutMs: IOS_BOOT_TIMEOUT_MS,
413
- elapsedMs: deadline.elapsedMs(),
414
- reason,
415
- hint: bootFailureHint(reason),
416
- boot: bootResult
417
- ? { exitCode: bootExitCode, stdout: bootStdout, stderr: bootStderr }
418
- : undefined,
419
- bootstatus: bootStatusResult
420
- ? {
421
- exitCode: bootstatusExitCode,
422
- stdout: bootstatusStdout,
423
- stderr: bootstatusStderr,
424
- }
425
- : undefined,
426
- });
427
- }
428
- }
429
-
430
- async function getSimulatorState(udid: string): Promise<string | null> {
431
- const result = await runCmd('xcrun', ['simctl', 'list', 'devices', '-j'], {
432
- allowFailure: true,
433
- timeoutMs: IOS_SIMCTL_LIST_TIMEOUT_MS,
434
- });
435
- if (result.exitCode !== 0) return null;
436
- try {
437
- const payload = JSON.parse(result.stdout as string) as {
438
- devices: Record<string, { udid: string; state: string }[]>;
439
- };
440
- for (const runtime of Object.values(payload.devices ?? {})) {
441
- const match = runtime.find((d) => d.udid === udid);
442
- if (match) return match.state;
443
- }
444
- } catch {
445
- return null;
446
- }
447
- return null;
448
- }
449
-
450
- function resolveTimeoutMs(raw: string | undefined, fallback: number, min: number): number {
451
- if (!raw) return fallback;
452
- const parsed = Number(raw);
453
- if (!Number.isFinite(parsed)) return fallback;
454
- return Math.max(min, Math.floor(parsed));
455
- }
1
+ export {
2
+ closeIosApp,
3
+ installIosApp,
4
+ listIosApps,
5
+ listSimulatorApps,
6
+ openIosApp,
7
+ openIosDevice,
8
+ reinstallIosApp,
9
+ resolveIosApp,
10
+ screenshotIos,
11
+ setIosSetting,
12
+ uninstallIosApp,
13
+ } from './apps.ts';
14
+
15
+ export { ensureBootedSimulator } from './simulator.ts';
16
+
17
+ export {
18
+ parseIosDeviceAppsPayload,
19
+ type IosAppInfo,
20
+ } from './devicectl.ts';
@@ -10,6 +10,7 @@ import { withKeyedLock } from '../../utils/keyed-lock.ts';
10
10
  import { isProcessAlive } from '../../utils/process-identity.ts';
11
11
  import net from 'node:net';
12
12
  import { bootFailureHint, classifyBootFailure } from '../boot-diagnostics.ts';
13
+ import { resolveTimeoutMs, resolveTimeoutSeconds } from '../../utils/timeouts.ts';
13
14
 
14
15
  export type RunnerCommand = {
15
16
  command:
@@ -60,7 +61,7 @@ const runnerSessions = new Map<string, RunnerSession>();
60
61
  const runnerSessionLocks = new Map<string, Promise<unknown>>();
61
62
  const RUNNER_STARTUP_TIMEOUT_MS = resolveTimeoutMs(
62
63
  process.env.AGENT_DEVICE_RUNNER_STARTUP_TIMEOUT_MS,
63
- 120_000,
64
+ 45_000,
64
65
  5_000,
65
66
  );
66
67
  const RUNNER_COMMAND_TIMEOUT_MS = resolveTimeoutMs(
@@ -75,35 +76,33 @@ const RUNNER_CONNECT_ATTEMPT_INTERVAL_MS = resolveTimeoutMs(
75
76
  );
76
77
  const RUNNER_CONNECT_RETRY_BASE_DELAY_MS = resolveTimeoutMs(
77
78
  process.env.AGENT_DEVICE_RUNNER_CONNECT_RETRY_BASE_DELAY_MS,
78
- 100,
79
+ 300,
79
80
  10,
80
81
  );
81
82
  const RUNNER_CONNECT_RETRY_MAX_DELAY_MS = resolveTimeoutMs(
82
83
  process.env.AGENT_DEVICE_RUNNER_CONNECT_RETRY_MAX_DELAY_MS,
83
- 500,
84
+ 2_000,
84
85
  10,
85
86
  );
86
87
  const RUNNER_CONNECT_REQUEST_TIMEOUT_MS = resolveTimeoutMs(
87
88
  process.env.AGENT_DEVICE_RUNNER_CONNECT_REQUEST_TIMEOUT_MS,
88
- 1_000,
89
- 50,
89
+ 5_000,
90
+ 250,
90
91
  );
91
92
  const RUNNER_DEVICE_INFO_TIMEOUT_MS = resolveTimeoutMs(
92
93
  process.env.AGENT_DEVICE_IOS_DEVICE_INFO_TIMEOUT_MS,
93
94
  10_000,
94
95
  500,
95
96
  );
97
+ const RUNNER_DESTINATION_TIMEOUT_SECONDS = resolveTimeoutSeconds(
98
+ process.env.AGENT_DEVICE_RUNNER_DESTINATION_TIMEOUT_SECONDS,
99
+ 20,
100
+ 5,
101
+ );
96
102
  const RUNNER_STOP_WAIT_TIMEOUT_MS = 10_000;
97
103
  const RUNNER_SHUTDOWN_TIMEOUT_MS = 15_000;
98
104
  const RUNNER_DERIVED_ROOT = path.join(os.homedir(), '.agent-device', 'ios-runner');
99
105
 
100
- function resolveTimeoutMs(raw: string | undefined, fallback: number, min: number): number {
101
- if (!raw) return fallback;
102
- const parsed = Number(raw);
103
- if (!Number.isFinite(parsed)) return fallback;
104
- return Math.max(min, Math.floor(parsed));
105
- }
106
-
107
106
  export type RunnerSnapshotNode = {
108
107
  index: number;
109
108
  type?: string;
@@ -156,7 +155,9 @@ async function executeRunnerCommand(
156
155
  if (
157
156
  appErr.code === 'COMMAND_FAILED' &&
158
157
  typeof appErr.message === 'string' &&
159
- appErr.message.includes('Runner did not accept connection')
158
+ appErr.message.includes('Runner did not accept connection') &&
159
+ shouldRetryRunnerConnectError(appErr) &&
160
+ session?.ready
160
161
  ) {
161
162
  if (session) {
162
163
  await stopRunnerSession(session);
@@ -184,7 +185,7 @@ async function executeRunnerCommandWithSession(
184
185
  logPath: string | undefined,
185
186
  timeoutMs: number,
186
187
  ): Promise<Record<string, unknown>> {
187
- const response = await waitForRunner(device, session.port, command, logPath, timeoutMs);
188
+ const response = await waitForRunner(device, session.port, command, logPath, timeoutMs, session);
188
189
  return await parseRunnerResponse(response, session, logPath);
189
190
  }
190
191
 
@@ -304,6 +305,8 @@ async function ensureRunnerSession(
304
305
  'NO',
305
306
  resolveRunnerMaxConcurrentDestinationsFlag(device),
306
307
  '1',
308
+ '-destination-timeout',
309
+ String(RUNNER_DESTINATION_TIMEOUT_SECONDS),
307
310
  '-xctestrun',
308
311
  xctestrunPath,
309
312
  '-destination',
@@ -437,6 +440,10 @@ function resolveRunnerDerivedPath(kind: DeviceInfo['kind']): string {
437
440
  if (override) {
438
441
  return path.resolve(override);
439
442
  }
443
+ if (kind === 'simulator') {
444
+ // Keep simulator runtime path aligned with pnpm build:xcuitest/build:all.
445
+ return path.join(RUNNER_DERIVED_ROOT, 'derived');
446
+ }
440
447
  return path.join(RUNNER_DERIVED_ROOT, 'derived', kind);
441
448
  }
442
449
 
@@ -565,10 +572,12 @@ function logChunk(chunk: string, logPath?: string, traceLogPath?: string, verbos
565
572
  }
566
573
  }
567
574
 
568
- function isRetryableRunnerError(err: unknown): boolean {
575
+ export function isRetryableRunnerError(err: unknown): boolean {
569
576
  if (!(err instanceof AppError)) return false;
570
577
  if (err.code !== 'COMMAND_FAILED') return false;
571
578
  const message = `${err.message ?? ''}`.toLowerCase();
579
+ if (message.includes('xcodebuild exited early')) return false;
580
+ if (message.includes('device is busy') && message.includes('connecting')) return false;
572
581
  if (message.includes('runner did not accept connection')) return true;
573
582
  if (message.includes('fetch failed')) return true;
574
583
  if (message.includes('econnrefused')) return true;
@@ -631,12 +640,56 @@ function buildRunnerConnectError(params: {
631
640
  });
632
641
  }
633
642
 
643
+ export function resolveRunnerEarlyExitHint(message: string, stdout: string, stderr: string): string {
644
+ const haystack = `${message}\n${stdout}\n${stderr}`.toLowerCase();
645
+ if (haystack.includes('device is busy') && haystack.includes('connecting')) {
646
+ return 'Target iOS device is still connecting. Keep it unlocked, wait for device trust/connection to settle, then retry.';
647
+ }
648
+ return bootFailureHint('IOS_RUNNER_CONNECT_TIMEOUT');
649
+ }
650
+
651
+ export function shouldRetryRunnerConnectError(error: unknown): boolean {
652
+ if (!(error instanceof AppError)) return true;
653
+ if (error.code !== 'COMMAND_FAILED') return true;
654
+ const message = String(error.message ?? '').toLowerCase();
655
+ if (message.includes('xcodebuild exited early')) return false;
656
+ return true;
657
+ }
658
+
659
+ async function buildRunnerEarlyExitError(params: {
660
+ session: RunnerSession;
661
+ port: number;
662
+ logPath?: string;
663
+ }): Promise<AppError> {
664
+ const { session, port, logPath } = params;
665
+ const result = await session.testPromise;
666
+ const message = 'Runner did not accept connection (xcodebuild exited early)';
667
+ const reason = classifyBootFailure({
668
+ message,
669
+ stdout: result.stdout,
670
+ stderr: result.stderr,
671
+ context: { platform: 'ios', phase: 'connect' },
672
+ });
673
+ return new AppError('COMMAND_FAILED', message, {
674
+ port,
675
+ logPath,
676
+ xcodebuild: {
677
+ exitCode: result.exitCode,
678
+ stdout: result.stdout,
679
+ stderr: result.stderr,
680
+ },
681
+ reason,
682
+ hint: resolveRunnerEarlyExitHint(message, result.stdout, result.stderr),
683
+ });
684
+ }
685
+
634
686
  async function waitForRunner(
635
687
  device: DeviceInfo,
636
688
  port: number,
637
689
  command: RunnerCommand,
638
690
  logPath?: string,
639
691
  timeoutMs: number = RUNNER_STARTUP_TIMEOUT_MS,
692
+ session?: RunnerSession,
640
693
  ): Promise<Response> {
641
694
  const deadline = Deadline.fromTimeoutMs(timeoutMs);
642
695
  let endpoints = await resolveRunnerCommandEndpoints(device, port, deadline.remainingMs());
@@ -651,6 +704,9 @@ async function waitForRunner(
651
704
  timeoutMs,
652
705
  });
653
706
  }
707
+ if (session && session.child.exitCode !== null && session.child.exitCode !== undefined) {
708
+ throw await buildRunnerEarlyExitError({ session, port, logPath });
709
+ }
654
710
  if (device.kind === 'device') {
655
711
  endpoints = await resolveRunnerCommandEndpoints(device, port, attemptDeadline?.remainingMs());
656
712
  }
@@ -688,7 +744,7 @@ async function waitForRunner(
688
744
  baseDelayMs: RUNNER_CONNECT_RETRY_BASE_DELAY_MS,
689
745
  maxDelayMs: RUNNER_CONNECT_RETRY_MAX_DELAY_MS,
690
746
  jitter: 0.2,
691
- shouldRetry: () => true,
747
+ shouldRetry: shouldRetryRunnerConnectError,
692
748
  },
693
749
  { deadline, phase: 'ios_runner_connect' },
694
750
  );