Skip to content

Commit 50ab09b

Browse files
committed
Complete issue #39 phase diagnostics telemetry and boot command
1 parent 0d26971 commit 50ab09b

13 files changed

Lines changed: 271 additions & 42 deletions

File tree

README.md

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ Coordinates:
7575
- X increases to the right, Y increases downward.
7676

7777
## Command Index
78-
- `open`, `close`, `home`, `back`, `app-switcher`
78+
- `boot`, `open`, `close`, `home`, `back`, `app-switcher`
7979
- `snapshot`, `find`, `get`
8080
- `click`, `focus`, `type`, `fill`, `press`, `long-press`, `scroll`, `scrollintoview`, `is`
8181
- `alert`, `wait`, `screenshot`
@@ -186,8 +186,10 @@ App state:
186186

187187
Boot diagnostics:
188188
- Boot failures include normalized reason codes in `error.details.reason` (JSON mode) and verbose logs.
189-
- Reason codes: `BOOT_TIMEOUT`, `DEVICE_UNAVAILABLE`, `DEVICE_OFFLINE`, `PERMISSION_DENIED`, `TOOL_MISSING`, `BOOT_COMMAND_FAILED`, `UNKNOWN`.
189+
- Reason codes: `IOS_BOOT_TIMEOUT`, `IOS_RUNNER_CONNECT_TIMEOUT`, `ANDROID_BOOT_TIMEOUT`, `ADB_TRANSPORT_UNAVAILABLE`, `CI_RESOURCE_STARVATION_SUSPECTED`, `BOOT_COMMAND_FAILED`, `UNKNOWN`.
190190
- Android boot waits fail fast for permission/tooling issues and do not always collapse into timeout errors.
191+
- Use `agent-device boot --platform ios|android` for explicit CI preflight readiness checks.
192+
- Set `AGENT_DEVICE_RETRY_LOGS=1` to print structured retry telemetry (attempt, phase, delay, elapsed/remaining deadline, reason).
191193

192194
## App resolution
193195
- Bundle/package identifiers are accepted directly (e.g., `com.apple.Preferences`).

src/cli.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -105,6 +105,13 @@ export async function runCli(argv: string[]): Promise<void> {
105105
if (logTailStopper) logTailStopper();
106106
return;
107107
}
108+
if (command === 'boot') {
109+
const platform = (response.data as any)?.platform ?? 'unknown';
110+
const device = (response.data as any)?.device ?? (response.data as any)?.id ?? 'unknown';
111+
process.stdout.write(`Boot ready: ${device} (${platform})\n`);
112+
if (logTailStopper) logTailStopper();
113+
return;
114+
}
108115
if (command === 'click') {
109116
const ref = (response.data as any)?.ref ?? '';
110117
const x = (response.data as any)?.x;

src/core/__tests__/capabilities.test.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ test('iOS simulator + Android commands reject iOS devices', () => {
3737
'app-switcher',
3838
'apps',
3939
'back',
40+
'boot',
4041
'click',
4142
'close',
4243
'fill',

src/core/capabilities.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const COMMAND_CAPABILITY_MATRIX: Record<string, CommandCapability> = {
1919
'app-switcher': { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
2020
apps: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
2121
back: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
22+
boot: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
2223
click: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
2324
close: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },
2425
fill: { ios: { simulator: true }, android: { emulator: true, device: true, unknown: true } },

src/daemon/handlers/session.ts

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -106,6 +106,32 @@ export async function handleSessionCommands(params: {
106106
return { ok: true, data: { apps } };
107107
}
108108

109+
if (command === 'boot') {
110+
const session = sessionStore.get(sessionName);
111+
const flags = req.flags ?? {};
112+
if (!session && !flags.platform && !flags.device && !flags.udid && !flags.serial) {
113+
return {
114+
ok: false,
115+
error: {
116+
code: 'INVALID_ARGS',
117+
message: 'boot requires an active session or an explicit device selector (e.g. --platform ios).',
118+
},
119+
};
120+
}
121+
const device = session?.device ?? (await resolveTargetDevice(flags));
122+
await ensureDeviceReady(device);
123+
return {
124+
ok: true,
125+
data: {
126+
platform: device.platform,
127+
device: device.name,
128+
id: device.id,
129+
kind: device.kind,
130+
booted: true,
131+
},
132+
};
133+
}
134+
109135
if (command === 'appstate') {
110136
const session = sessionStore.get(sessionName);
111137
const flags = req.flags ?? {};
Lines changed: 20 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,30 +1,43 @@
11
import test from 'node:test';
22
import assert from 'node:assert/strict';
3-
import { classifyBootFailure } from '../boot-diagnostics.ts';
3+
import { bootFailureHint, classifyBootFailure } from '../boot-diagnostics.ts';
44
import { AppError } from '../../utils/errors.ts';
55

66
test('classifyBootFailure maps timeout errors', () => {
7-
const reason = classifyBootFailure({ message: 'bootstatus timed out after 120s' });
8-
assert.equal(reason, 'BOOT_TIMEOUT');
7+
const reason = classifyBootFailure({
8+
message: 'bootstatus timed out after 120s',
9+
context: { platform: 'ios', phase: 'boot' },
10+
});
11+
assert.equal(reason, 'IOS_BOOT_TIMEOUT');
912
});
1013

1114
test('classifyBootFailure maps adb offline errors', () => {
12-
const reason = classifyBootFailure({ stderr: 'error: device offline' });
13-
assert.equal(reason, 'DEVICE_OFFLINE');
15+
const reason = classifyBootFailure({
16+
stderr: 'error: device offline',
17+
context: { platform: 'android', phase: 'transport' },
18+
});
19+
assert.equal(reason, 'ADB_TRANSPORT_UNAVAILABLE');
1420
});
1521

1622
test('classifyBootFailure maps tool missing from AppError code', () => {
1723
const reason = classifyBootFailure({
1824
error: new AppError('TOOL_MISSING', 'adb not found in PATH'),
25+
context: { platform: 'android', phase: 'transport' },
1926
});
20-
assert.equal(reason, 'TOOL_MISSING');
27+
assert.equal(reason, 'ADB_TRANSPORT_UNAVAILABLE');
2128
});
2229

2330
test('classifyBootFailure reads stderr from AppError details', () => {
2431
const reason = classifyBootFailure({
2532
error: new AppError('COMMAND_FAILED', 'adb failed', {
2633
stderr: 'error: device unauthorized',
2734
}),
35+
context: { platform: 'android', phase: 'transport' },
2836
});
29-
assert.equal(reason, 'PERMISSION_DENIED');
37+
assert.equal(reason, 'ADB_TRANSPORT_UNAVAILABLE');
38+
});
39+
40+
test('bootFailureHint returns actionable guidance', () => {
41+
const hint = bootFailureHint('IOS_RUNNER_CONNECT_TIMEOUT');
42+
assert.equal(hint.includes('xcodebuild logs'), true);
3043
});

src/platforms/android/devices.ts

Lines changed: 31 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,14 @@ import { runCmd, whichCmd } from '../../utils/exec.ts';
22
import type { ExecResult } from '../../utils/exec.ts';
33
import { AppError, asAppError } from '../../utils/errors.ts';
44
import type { DeviceInfo } from '../../utils/device.ts';
5-
import { Deadline, retryWithPolicy } from '../../utils/retry.ts';
6-
import { classifyBootFailure } from '../boot-diagnostics.ts';
5+
import { Deadline, retryWithPolicy, type RetryTelemetryEvent } from '../../utils/retry.ts';
6+
import { bootFailureHint, classifyBootFailure } from '../boot-diagnostics.ts';
77

88
const EMULATOR_SERIAL_PREFIX = 'emulator-';
99
const ANDROID_BOOT_POLL_MS = 1000;
10+
const RETRY_LOGS_ENABLED = ['1', 'true', 'yes', 'on'].includes(
11+
(process.env.AGENT_DEVICE_RETRY_LOGS ?? '').toLowerCase(),
12+
);
1013

1114
function adbArgs(serial: string, args: string[]): string[] {
1215
return ['-s', serial, ...args];
@@ -79,8 +82,9 @@ export async function isAndroidBooted(serial: string): Promise<boolean> {
7982
}
8083

8184
export async function waitForAndroidBoot(serial: string, timeoutMs = 60000): Promise<void> {
82-
const deadline = Deadline.fromTimeoutMs(timeoutMs);
83-
const maxAttempts = Math.max(1, Math.ceil(timeoutMs / ANDROID_BOOT_POLL_MS));
85+
const timeoutBudget = timeoutMs;
86+
const deadline = Deadline.fromTimeoutMs(timeoutBudget);
87+
const maxAttempts = Math.max(1, Math.ceil(timeoutBudget / ANDROID_BOOT_POLL_MS));
8488
let lastBootResult: ExecResult | undefined;
8589
let timedOut = false;
8690
try {
@@ -115,11 +119,26 @@ export async function waitForAndroidBoot(serial: string, timeoutMs = 60000): Pro
115119
error,
116120
stdout: lastBootResult?.stdout,
117121
stderr: lastBootResult?.stderr,
122+
context: { platform: 'android', phase: 'boot' },
118123
});
119-
return reason !== 'PERMISSION_DENIED' && reason !== 'TOOL_MISSING' && reason !== 'BOOT_TIMEOUT';
124+
return reason !== 'ADB_TRANSPORT_UNAVAILABLE' && reason !== 'ANDROID_BOOT_TIMEOUT';
125+
},
126+
},
127+
{
128+
deadline,
129+
phase: 'boot',
130+
classifyReason: (error) =>
131+
classifyBootFailure({
132+
error,
133+
stdout: lastBootResult?.stdout,
134+
stderr: lastBootResult?.stderr,
135+
context: { platform: 'android', phase: 'boot' },
136+
}),
137+
onEvent: (event: RetryTelemetryEvent) => {
138+
if (!RETRY_LOGS_ENABLED) return;
139+
process.stderr.write(`[agent-device][retry] ${JSON.stringify(event)}\n`);
120140
},
121141
},
122-
{ deadline },
123142
);
124143
} catch (error) {
125144
const appErr = asAppError(error);
@@ -130,26 +149,28 @@ export async function waitForAndroidBoot(serial: string, timeoutMs = 60000): Pro
130149
error,
131150
stdout,
132151
stderr,
152+
context: { platform: 'android', phase: 'boot' },
133153
});
134154
const baseDetails = {
135155
serial,
136-
timeoutMs,
156+
timeoutMs: timeoutBudget,
137157
elapsedMs: deadline.elapsedMs(),
138158
reason,
159+
hint: bootFailureHint(reason),
139160
stdout,
140161
stderr,
141162
exitCode,
142163
};
143-
if (timedOut || reason === 'BOOT_TIMEOUT') {
164+
if (timedOut || reason === 'ANDROID_BOOT_TIMEOUT') {
144165
throw new AppError('COMMAND_FAILED', 'Android device did not finish booting in time', baseDetails);
145166
}
146-
if (appErr.code === 'TOOL_MISSING' || reason === 'TOOL_MISSING') {
167+
if (appErr.code === 'TOOL_MISSING') {
147168
throw new AppError('TOOL_MISSING', appErr.message, {
148169
...baseDetails,
149170
...(appErr.details ?? {}),
150171
});
151172
}
152-
if (reason === 'PERMISSION_DENIED' || reason === 'DEVICE_UNAVAILABLE' || reason === 'DEVICE_OFFLINE') {
173+
if (reason === 'ADB_TRANSPORT_UNAVAILABLE') {
153174
throw new AppError('COMMAND_FAILED', appErr.message, {
154175
...baseDetails,
155176
...(appErr.details ?? {}),

src/platforms/boot-diagnostics.ts

Lines changed: 59 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,22 +1,32 @@
11
import { asAppError } from '../utils/errors.ts';
22

33
export type BootFailureReason =
4-
| 'BOOT_TIMEOUT'
5-
| 'DEVICE_UNAVAILABLE'
6-
| 'DEVICE_OFFLINE'
7-
| 'PERMISSION_DENIED'
8-
| 'TOOL_MISSING'
4+
| 'IOS_BOOT_TIMEOUT'
5+
| 'IOS_RUNNER_CONNECT_TIMEOUT'
6+
| 'ANDROID_BOOT_TIMEOUT'
7+
| 'ADB_TRANSPORT_UNAVAILABLE'
8+
| 'CI_RESOURCE_STARVATION_SUSPECTED'
99
| 'BOOT_COMMAND_FAILED'
1010
| 'UNKNOWN';
1111

12+
type BootDiagnosticContext = {
13+
platform?: 'ios' | 'android';
14+
phase?: 'boot' | 'connect' | 'transport';
15+
};
16+
1217
export function classifyBootFailure(input: {
1318
error?: unknown;
1419
message?: string;
1520
stdout?: string;
1621
stderr?: string;
22+
context?: BootDiagnosticContext;
1723
}): BootFailureReason {
1824
const appErr = input.error ? asAppError(input.error) : null;
19-
if (appErr?.code === 'TOOL_MISSING') return 'TOOL_MISSING';
25+
const platform = input.context?.platform;
26+
const phase = input.context?.phase;
27+
if (appErr?.code === 'TOOL_MISSING') {
28+
return platform === 'android' ? 'ADB_TRANSPORT_UNAVAILABLE' : 'BOOT_COMMAND_FAILED';
29+
}
2030
const details = (appErr?.details ?? {}) as Record<string, unknown>;
2131
const detailMessage = typeof details.message === 'string' ? details.message : undefined;
2232
const detailStdout = typeof details.stdout === 'string' ? details.stdout : undefined;
@@ -45,23 +55,56 @@ export function classifyBootFailure(input: {
4555
.join('\n')
4656
.toLowerCase();
4757

48-
if (haystack.includes('timed out') || haystack.includes('timeout')) return 'BOOT_TIMEOUT';
58+
if (platform === 'ios' && (phase === 'connect' || haystack.includes('runner did not accept connection'))) {
59+
return 'IOS_RUNNER_CONNECT_TIMEOUT';
60+
}
61+
if (platform === 'ios' && phase === 'boot' && (haystack.includes('timed out') || haystack.includes('timeout'))) {
62+
return 'IOS_BOOT_TIMEOUT';
63+
}
64+
if (platform === 'android' && phase === 'boot' && (haystack.includes('timed out') || haystack.includes('timeout'))) {
65+
return 'ANDROID_BOOT_TIMEOUT';
66+
}
4967
if (
50-
haystack.includes('device not found') ||
51-
haystack.includes('no devices') ||
52-
haystack.includes('unable to locate device') ||
53-
haystack.includes('invalid device')
68+
haystack.includes('resource temporarily unavailable') ||
69+
haystack.includes('killed: 9') ||
70+
haystack.includes('cannot allocate memory') ||
71+
haystack.includes('system is low on memory')
5472
) {
55-
return 'DEVICE_UNAVAILABLE';
73+
return 'CI_RESOURCE_STARVATION_SUSPECTED';
5674
}
57-
if (haystack.includes('offline')) return 'DEVICE_OFFLINE';
5875
if (
59-
haystack.includes('permission denied') ||
76+
platform === 'android' && (
77+
haystack.includes('device not found') ||
78+
haystack.includes('no devices') ||
79+
haystack.includes('device offline') ||
80+
haystack.includes('offline') ||
81+
haystack.includes('unauthorized') ||
6082
haystack.includes('not authorized') ||
61-
haystack.includes('unauthorized')
83+
haystack.includes('unable to locate device') ||
84+
haystack.includes('invalid device')
85+
)
6286
) {
63-
return 'PERMISSION_DENIED';
87+
return 'ADB_TRANSPORT_UNAVAILABLE';
6488
}
6589
if (appErr?.code === 'COMMAND_FAILED' || haystack.length > 0) return 'BOOT_COMMAND_FAILED';
6690
return 'UNKNOWN';
6791
}
92+
93+
export function bootFailureHint(reason: BootFailureReason): string {
94+
switch (reason) {
95+
case 'IOS_BOOT_TIMEOUT':
96+
return 'Retry simulator boot and inspect simctl bootstatus logs; in CI consider increasing AGENT_DEVICE_IOS_BOOT_TIMEOUT_MS.';
97+
case 'IOS_RUNNER_CONNECT_TIMEOUT':
98+
return 'Retry runner startup, inspect xcodebuild logs, and verify simulator responsiveness before command execution.';
99+
case 'ANDROID_BOOT_TIMEOUT':
100+
return 'Retry emulator startup and verify sys.boot_completed reaches 1; consider increasing startup budget in CI.';
101+
case 'ADB_TRANSPORT_UNAVAILABLE':
102+
return 'Check adb server/device transport (adb devices -l), restart adb, and ensure the target device is online and authorized.';
103+
case 'CI_RESOURCE_STARVATION_SUSPECTED':
104+
return 'CI machine may be resource constrained; reduce parallel jobs or use a larger runner.';
105+
case 'BOOT_COMMAND_FAILED':
106+
return 'Inspect command stderr/stdout for the failing boot phase and retry after environment validation.';
107+
default:
108+
return 'Retry once and inspect verbose logs for the failing phase.';
109+
}
110+
}

0 commit comments

Comments
 (0)