Skip to content

Commit ac07890

Browse files
authored
refactor: stabilize iOS app/session and daemon startup flows (#64)
* refactor: stabilize iOS app/session and daemon startup flows * fix: wait for android app launch completion * fix: address review findings for iOS appstate and daemon flows * fixups
1 parent 17940c1 commit ac07890

38 files changed

Lines changed: 2238 additions & 895 deletions

README.md

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ The project is in early development and considered experimental. Pull requests a
1515
## Features
1616
- Platforms: iOS (simulator + physical device core automation) and Android (emulator + device).
1717
- Core commands: `open`, `back`, `home`, `app-switcher`, `press`, `long-press`, `focus`, `type`, `fill`, `scroll`, `scrollintoview`, `wait`, `alert`, `screenshot`, `close`, `reinstall`.
18-
- Inspection commands: `snapshot` (accessibility tree).
18+
- Inspection commands: `snapshot` (accessibility tree), `appstate`, `apps`, `devices`.
1919
- Device tooling: `adb` (Android), `simctl`/`devicectl` (iOS via Xcode).
2020
- Minimal dependencies; TypeScript executed directly on Node 22+ (no build step).
2121

@@ -148,6 +148,7 @@ Sessions:
148148
- `--save-script` accepts an optional path: `--save-script ./workflows/my-flow.ad`.
149149
- For ambiguous bare values, use an explicit form: `--save-script=workflow.ad` or a path-like value such as `./workflow.ad`.
150150
- Deterministic replay is `.ad`-based; use `replay --update` (`-u`) to update selector drift and rewrite the replay file in place.
151+
- On iOS, `appstate` is session-scoped and requires an active session on the target device.
151152

152153
Navigation helpers:
153154
- `boot --platform ios|android` ensures the target is ready without launching an app.
@@ -217,8 +218,10 @@ Settings helpers:
217218
Note: iOS supports these only on simulators. iOS wifi/airplane toggles status bar indicators, not actual network state. Airplane off clears status bar overrides.
218219

219220
App state:
220-
- `appstate` shows the foreground app/activity (Android). On iOS it uses the current session app when available, otherwise it resolves via XCTest snapshot.
221-
- `apps --metadata` returns app list with minimal metadata.
221+
- `appstate` shows the foreground app/activity (Android).
222+
- On iOS, `appstate` returns the currently tracked session app (`source: session`) and requires an active session on the selected device.
223+
- `apps` supports Android, iOS simulators, and iOS devices.
224+
- `apps` includes default/system apps by default (use `--user-installed` to filter).
222225

223226
## Debug
224227

@@ -227,6 +230,7 @@ App state:
227230
- The trace log includes snapshot logs and XCTest runner logs for the session.
228231
- Built-in retries cover transient runner connection failures and Android UI dumps.
229232
- For snapshot issues (missing elements), compare with `--raw` flag for unaltered output and scope with `-s "<label>"`.
233+
- If startup fails with stale metadata hints, remove stale `~/.agent-device/daemon.json` / `~/.agent-device/daemon.lock` and retry.
230234

231235
Boot diagnostics:
232236
- Boot failures include normalized reason codes in `error.details.reason` (JSON mode) and verbose logs.
@@ -242,7 +246,8 @@ Boot diagnostics:
242246

243247
## iOS notes
244248
- Core runner commands (`snapshot`, `wait`, `click`, `fill`, `get`, `is`, `find`, `press`, `long-press`, `focus`, `type`, `scroll`, `scrollintoview`, `back`, `home`, `app-switcher`) support iOS simulators and iOS devices.
245-
- Simulator-only commands: `alert`, `pinch`, `record`, `reinstall`, `apps`, `settings`.
249+
- `apps` is supported on both iOS simulators and iOS devices.
250+
- Simulator-only commands: `alert`, `pinch`, `record`, `reinstall`, `settings`.
246251
- iOS deep link open (`open <url>`) is simulator-only.
247252
- iOS device runs require valid signing/provisioning (Automatic Signing recommended). Optional overrides: `AGENT_DEVICE_IOS_TEAM_ID`, `AGENT_DEVICE_IOS_SIGNING_IDENTITY`, `AGENT_DEVICE_IOS_PROVISIONING_PROFILE`.
248253

@@ -270,11 +275,11 @@ Environment selectors:
270275
- `ANDROID_DEVICE=Pixel_9_Pro_XL` or `ANDROID_SERIAL=emulator-5554`
271276
- `IOS_DEVICE="iPhone 17 Pro"` or `IOS_UDID=<udid>`
272277
- `AGENT_DEVICE_IOS_BOOT_TIMEOUT_MS=<ms>` to adjust iOS simulator boot timeout (default: `120000`, minimum: `5000`).
273-
- `AGENT_DEVICE_DAEMON_TIMEOUT_MS=<ms>` to increase daemon request timeout for slow first-run iOS device setup (for example `180000`).
278+
- `AGENT_DEVICE_DAEMON_TIMEOUT_MS=<ms>` to override daemon request timeout (default `90000`). Increase for slow physical-device setup (for example `120000`).
274279
- `AGENT_DEVICE_IOS_TEAM_ID=<team-id>` optional Team ID override for iOS device runner signing.
275280
- `AGENT_DEVICE_IOS_SIGNING_IDENTITY=<identity>` optional signing identity override.
276281
- `AGENT_DEVICE_IOS_PROVISIONING_PROFILE=<profile>` optional provisioning profile specifier for iOS device runner signing.
277-
- `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH=<path>` optional override for iOS runner derived data root. By default, agent-device separates caches by target kind (`.../derived/simulator` and `.../derived/device`). If you set this override, use separate paths per kind to avoid simulator/device artifact collisions.
282+
- `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH=<path>` optional override for iOS runner derived data root. By default, simulator uses `~/.agent-device/ios-runner/derived` and physical device uses `~/.agent-device/ios-runner/derived/device`. If you set this override, use separate paths per kind to avoid simulator/device artifact collisions.
278283
- `AGENT_DEVICE_IOS_CLEAN_DERIVED=1` rebuild iOS runner artifacts from scratch. When `AGENT_DEVICE_IOS_RUNNER_DERIVED_PATH` is set, cleanup is blocked by default; set `AGENT_DEVICE_IOS_ALLOW_OVERRIDE_DERIVED_CLEAN=1` only for trusted custom paths.
279284

280285
Test screenshots are written to:

ios-runner/AgentDeviceRunner/AgentDeviceRunnerUITests/RunnerTests.swift

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -216,14 +216,20 @@ final class RunnerTests: XCTestCase {
216216
}
217217

218218
private func executeOnMain(command: Command) throws -> Response {
219-
let bundleId = command.appBundleId ?? currentBundleId ?? "com.apple.Preferences"
220-
if currentBundleId != bundleId {
219+
let normalizedBundleId = command.appBundleId?
220+
.trimmingCharacters(in: .whitespacesAndNewlines)
221+
let requestedBundleId = (normalizedBundleId?.isEmpty == true) ? nil : normalizedBundleId
222+
if let bundleId = requestedBundleId, currentBundleId != bundleId {
221223
let target = XCUIApplication(bundleIdentifier: bundleId)
222224
NSLog("AGENT_DEVICE_RUNNER_ACTIVATE bundle=%@ state=%d", bundleId, target.state.rawValue)
223225
// activate avoids terminating and relaunching the target app
224226
target.activate()
225227
currentApp = target
226228
currentBundleId = bundleId
229+
} else if requestedBundleId == nil {
230+
// Do not reuse stale bundle targets when the caller does not explicitly request one.
231+
currentApp = nil
232+
currentBundleId = nil
227233
}
228234
let activeApp = currentApp ?? app
229235
_ = activeApp.waitForExistence(timeout: 5)

skills/agent-device/SKILL.md

Lines changed: 11 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -101,10 +101,12 @@ iOS settings helpers are simulator-only.
101101

102102
```bash
103103
agent-device appstate
104-
agent-device apps --metadata --platform ios
105-
agent-device apps --metadata --platform android
106104
```
107105

106+
- Android: `appstate` reports live foreground package/activity.
107+
- iOS: `appstate` is session-scoped and reports the app tracked by the active session on the target device.
108+
- For iOS `appstate`, ensure a matching session exists (for example `open --session <name> --platform ios --device "<name>" <app>`).
109+
108110
### Interactions (use @refs from snapshot)
109111

110112
```bash
@@ -167,9 +169,11 @@ agent-device trace stop ./trace.log # Stop and move trace log
167169

168170
```bash
169171
agent-device devices
170-
agent-device apps --platform ios
171-
agent-device apps --platform android # default: launchable only
172-
agent-device apps --platform android --all
172+
agent-device apps --platform ios # iOS simulator + iOS device, includes default/system apps
173+
agent-device apps --platform ios --all # explicit include-all (same as default)
174+
agent-device apps --platform ios --user-installed
175+
agent-device apps --platform android # includes default/system apps
176+
agent-device apps --platform android --all # explicit include-all (same as default)
173177
agent-device apps --platform android --user-installed
174178
```
175179

@@ -192,7 +196,8 @@ agent-device apps --platform android --user-installed
192196
- Use `--activity <component>` on Android to launch a specific activity (e.g. TV apps with LEANBACK); do not combine with URL opens.
193197
- iOS deep-link opens are simulator-only.
194198
- iOS physical-device runner requires Xcode signing/provisioning; optional overrides: `AGENT_DEVICE_IOS_TEAM_ID`, `AGENT_DEVICE_IOS_SIGNING_IDENTITY`, `AGENT_DEVICE_IOS_PROVISIONING_PROFILE`.
195-
- For long first-run physical-device setup/build, increase daemon timeout: `AGENT_DEVICE_DAEMON_TIMEOUT_MS=180000` (or higher).
199+
- Default daemon request timeout is `45000`ms. For slow physical-device setup/build, increase `AGENT_DEVICE_DAEMON_TIMEOUT_MS` (for example `120000`).
200+
- For daemon startup troubleshooting, follow stale metadata hints for `~/.agent-device/daemon.json` / `~/.agent-device/daemon.lock`.
196201
- Use `fill` when you want clear-then-type semantics.
197202
- Use `type` when you want to append/enter text without clearing.
198203
- On Android, prefer `fill` for important fields; it verifies entered text and retries once when IME reorders characters.

skills/agent-device/references/permissions.md

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,9 +24,14 @@ Use Automatic Signing in Xcode, or provide optional overrides:
2424
- `AGENT_DEVICE_IOS_SIGNING_IDENTITY`
2525
- `AGENT_DEVICE_IOS_PROVISIONING_PROFILE`
2626

27-
If first-run setup/build takes long, increase:
27+
If setup/build takes long, increase:
2828

29-
- `AGENT_DEVICE_DAEMON_TIMEOUT_MS` (for example `180000`)
29+
- `AGENT_DEVICE_DAEMON_TIMEOUT_MS` (default `45000`, for example `120000`)
30+
31+
If daemon startup fails with stale metadata hints, clean stale files and retry:
32+
33+
- `~/.agent-device/daemon.json`
34+
- `~/.agent-device/daemon.lock`
3035

3136
## Simulator troubleshooting
3237

skills/agent-device/references/session-management.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ Sessions isolate device context. A device can only be held by one session at a t
1515
- Close sessions when done.
1616
- Use separate sessions for parallel work.
1717
- In iOS sessions, use `open <app>` for simulator/device. `open <url>` is simulator-only.
18+
- On iOS, `appstate` is session-scoped and requires a matching active session on the target device.
1819
- For dev loops where runtime state can persist (for example React Native Fast Refresh), use `open <app> --relaunch` to restart the app process in the same session.
1920
- Use `--save-script [path]` to record replay scripts on `close`; path is a file path and parent directories are created automatically.
2021
- For ambiguous bare `--save-script` values, prefer `--save-script=workflow.ad` or `./workflow.ad`.

src/__tests__/cli-close.test.ts

Lines changed: 155 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,155 @@
1+
import test from 'node:test';
2+
import assert from 'node:assert/strict';
3+
import { runCli } from '../cli.ts';
4+
import { AppError } from '../utils/errors.ts';
5+
import type { DaemonResponse } from '../daemon-client.ts';
6+
7+
class ExitSignal extends Error {
8+
public readonly code: number;
9+
10+
constructor(code: number) {
11+
super(`EXIT_${code}`);
12+
this.code = code;
13+
}
14+
}
15+
16+
type RunResult = {
17+
code: number | null;
18+
stdout: string;
19+
stderr: string;
20+
daemonCalls: number;
21+
};
22+
23+
async function runCliCapture(argv: string[]): Promise<RunResult> {
24+
let daemonCalls = 0;
25+
let stdout = '';
26+
let stderr = '';
27+
let code: number | null = null;
28+
29+
const originalExit = process.exit;
30+
const originalStdoutWrite = process.stdout.write.bind(process.stdout);
31+
const originalStderrWrite = process.stderr.write.bind(process.stderr);
32+
33+
(process as any).exit = ((nextCode?: number) => {
34+
throw new ExitSignal(nextCode ?? 0);
35+
}) as typeof process.exit;
36+
(process.stdout as any).write = ((chunk: unknown) => {
37+
stdout += String(chunk);
38+
return true;
39+
}) as typeof process.stdout.write;
40+
(process.stderr as any).write = ((chunk: unknown) => {
41+
stderr += String(chunk);
42+
return true;
43+
}) as typeof process.stderr.write;
44+
45+
const sendToDaemon = async (): Promise<DaemonResponse> => {
46+
daemonCalls += 1;
47+
throw new AppError('COMMAND_FAILED', 'Failed to start daemon', {
48+
infoPath: '/tmp/daemon.json',
49+
hint: 'stale daemon info',
50+
});
51+
};
52+
53+
try {
54+
await runCli(argv, { sendToDaemon });
55+
} catch (error) {
56+
if (error instanceof ExitSignal) code = error.code;
57+
else throw error;
58+
} finally {
59+
process.exit = originalExit;
60+
process.stdout.write = originalStdoutWrite;
61+
process.stderr.write = originalStderrWrite;
62+
}
63+
64+
return { code, stdout, stderr, daemonCalls };
65+
}
66+
67+
async function runCliCaptureWithErrorDetails(
68+
argv: string[],
69+
details: Record<string, unknown>,
70+
message = 'Failed to start daemon',
71+
): Promise<RunResult> {
72+
let daemonCalls = 0;
73+
let stdout = '';
74+
let stderr = '';
75+
let code: number | null = null;
76+
77+
const originalExit = process.exit;
78+
const originalStdoutWrite = process.stdout.write.bind(process.stdout);
79+
const originalStderrWrite = process.stderr.write.bind(process.stderr);
80+
81+
(process as any).exit = ((nextCode?: number) => {
82+
throw new ExitSignal(nextCode ?? 0);
83+
}) as typeof process.exit;
84+
(process.stdout as any).write = ((chunk: unknown) => {
85+
stdout += String(chunk);
86+
return true;
87+
}) as typeof process.stdout.write;
88+
(process.stderr as any).write = ((chunk: unknown) => {
89+
stderr += String(chunk);
90+
return true;
91+
}) as typeof process.stderr.write;
92+
93+
const sendToDaemon = async (): Promise<DaemonResponse> => {
94+
daemonCalls += 1;
95+
throw new AppError('COMMAND_FAILED', message, details);
96+
};
97+
98+
try {
99+
await runCli(argv, { sendToDaemon });
100+
} catch (error) {
101+
if (error instanceof ExitSignal) code = error.code;
102+
else throw error;
103+
} finally {
104+
process.exit = originalExit;
105+
process.stdout.write = originalStdoutWrite;
106+
process.stderr.write = originalStderrWrite;
107+
}
108+
109+
return { code, stdout, stderr, daemonCalls };
110+
}
111+
112+
test('close treats daemon startup failure as no-op', async () => {
113+
const result = await runCliCapture(['close']);
114+
assert.equal(result.code, null);
115+
assert.equal(result.daemonCalls, 1);
116+
assert.equal(result.stdout, '');
117+
assert.equal(result.stderr, '');
118+
});
119+
120+
test('close --json treats daemon startup failure as no-op success', async () => {
121+
const result = await runCliCapture(['close', '--json']);
122+
assert.equal(result.code, null);
123+
assert.equal(result.daemonCalls, 1);
124+
const payload = JSON.parse(result.stdout);
125+
assert.equal(payload.success, true);
126+
assert.equal(payload.data.closed, 'session');
127+
assert.equal(payload.data.source, 'no-daemon');
128+
assert.equal(result.stderr, '');
129+
});
130+
131+
test('close treats lock-only daemon startup failure as no-op', async () => {
132+
const result = await runCliCaptureWithErrorDetails(['close'], {
133+
lockPath: '/tmp/daemon.lock',
134+
hint: 'stale daemon lock',
135+
});
136+
assert.equal(result.code, null);
137+
assert.equal(result.daemonCalls, 1);
138+
assert.equal(result.stdout, '');
139+
assert.equal(result.stderr, '');
140+
});
141+
142+
test('close treats structured daemon startup failure as no-op without relying on message text', async () => {
143+
const result = await runCliCaptureWithErrorDetails(
144+
['close'],
145+
{
146+
kind: 'daemon_startup_failed',
147+
lockPath: '/tmp/daemon.lock',
148+
},
149+
'daemon bootstrap failed',
150+
);
151+
assert.equal(result.code, null);
152+
assert.equal(result.daemonCalls, 1);
153+
assert.equal(result.stdout, '');
154+
assert.equal(result.stderr, '');
155+
});

src/cli.ts

Lines changed: 32 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -179,9 +179,6 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS):
179179
const bundleId = app.bundleId ?? app.package;
180180
const name = app.name ?? app.label;
181181
if (name && bundleId) return `${name} (${bundleId})`;
182-
if (bundleId && typeof app.launchable === 'boolean') {
183-
return `${bundleId} (launchable=${app.launchable})`;
184-
}
185182
if (bundleId) return String(bundleId);
186183
return JSON.stringify(app);
187184
}
@@ -199,7 +196,7 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS):
199196
const pkg = (data as any)?.package;
200197
const activity = (data as any)?.activity;
201198
if (platform === 'ios') {
202-
process.stdout.write(`Foreground app: ${appName ?? appBundleId}\n`);
199+
process.stdout.write(`Foreground app: ${appName ?? appBundleId ?? 'unknown'}\n`);
203200
if (appBundleId) process.stdout.write(`Bundle: ${appBundleId}\n`);
204201
if (source) process.stdout.write(`Source: ${source}\n`);
205202
if (logTailStopper) logTailStopper();
@@ -220,6 +217,13 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS):
220217
throw new AppError(response.error.code as any, response.error.message, response.error.details);
221218
} catch (err) {
222219
const appErr = asAppError(err);
220+
if (command === 'close' && isDaemonStartupFailure(appErr)) {
221+
if (flags.json) {
222+
printJson({ success: true, data: { closed: 'session', source: 'no-daemon' } });
223+
}
224+
if (logTailStopper) logTailStopper();
225+
return;
226+
}
223227
if (flags.json) {
224228
printJson({
225229
success: false,
@@ -229,9 +233,6 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS):
229233
printHumanError(appErr);
230234
if (flags.verbose) {
231235
try {
232-
const fs = await import('node:fs');
233-
const os = await import('node:os');
234-
const path = await import('node:path');
235236
const logPath = path.join(os.homedir(), '.agent-device', 'daemon.log');
236237
if (fs.existsSync(logPath)) {
237238
const content = fs.readFileSync(logPath, 'utf8');
@@ -251,6 +252,13 @@ export async function runCli(argv: string[], deps: CliDeps = DEFAULT_CLI_DEPS):
251252
}
252253
}
253254

255+
function isDaemonStartupFailure(error: AppError): boolean {
256+
if (error.code !== 'COMMAND_FAILED') return false;
257+
if (error.details?.kind === 'daemon_startup_failed') return true;
258+
if (!error.message.toLowerCase().includes('failed to start daemon')) return false;
259+
return typeof error.details?.infoPath === 'string' || typeof error.details?.lockPath === 'string';
260+
}
261+
254262
const isDirectRun = pathToFileURL(process.argv[1] ?? '').href === import.meta.url;
255263
if (isDirectRun) {
256264
runCli(process.argv.slice(2)).catch((err) => {
@@ -268,15 +276,23 @@ function startDaemonLogTail(): (() => void) | null {
268276
const interval = setInterval(() => {
269277
if (stopped) return;
270278
if (!fs.existsSync(logPath)) return;
271-
const stats = fs.statSync(logPath);
272-
if (stats.size <= offset) return;
273-
const fd = fs.openSync(logPath, 'r');
274-
const buffer = Buffer.alloc(stats.size - offset);
275-
fs.readSync(fd, buffer, 0, buffer.length, offset);
276-
fs.closeSync(fd);
277-
offset = stats.size;
278-
if (buffer.length > 0) {
279-
process.stdout.write(buffer.toString('utf8'));
279+
try {
280+
const stats = fs.statSync(logPath);
281+
if (stats.size < offset) offset = 0;
282+
if (stats.size <= offset) return;
283+
const fd = fs.openSync(logPath, 'r');
284+
try {
285+
const buffer = Buffer.alloc(stats.size - offset);
286+
fs.readSync(fd, buffer, 0, buffer.length, offset);
287+
offset = stats.size;
288+
if (buffer.length > 0) {
289+
process.stdout.write(buffer.toString('utf8'));
290+
}
291+
} finally {
292+
fs.closeSync(fd);
293+
}
294+
} catch {
295+
// Best-effort tailing should not crash CLI flow.
280296
}
281297
}, 200);
282298
return () => {

0 commit comments

Comments
 (0)