Skip to content

Commit dc0aa04

Browse files
authored
Merge pull request #37 from Lykhoyda/fix/b119-runner-leak-recovery
fix(b119): detect + auto-recover from agent-device runner leak (GH #35)
2 parents 54324f7 + f8eefa8 commit dc0aa04

11 files changed

Lines changed: 985 additions & 68 deletions

File tree

.claude-plugin/marketplace.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
{
1010
"name": "rn-dev-agent",
1111
"description": "AI agent that fully tests React Native features on simulator/emulator — navigates the app, verifies UI, walks user flows, and confirms internal state.",
12-
"version": "0.25.0",
12+
"version": "0.25.1",
1313
"source": "./",
1414
"category": "mobile-development",
1515
"homepage": "https://github.com/Lykhoyda/rn-dev-agent"

.claude-plugin/plugin.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "rn-dev-agent",
3-
"version": "0.25.0",
3+
"version": "0.25.1",
44
"description": "AI agent that fully tests React Native features on simulator/emulator — navigates the app, verifies UI, walks user flows, and confirms internal state.",
55
"author": {
66
"name": "Anton Lykhoyda",

scripts/cdp-bridge/dist/tools/device-interact.js

Lines changed: 90 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@ import { isFastRunnerAvailable, fastSwipe } from '../fast-runner-session.js';
55
import { withSession } from '../utils.js';
66
import { okResult, failResult } from '../utils.js';
77
import { runMaestroInline, yamlEscape } from '../maestro-invoke.js';
8+
import { isAgentDeviceRunnerSentinel, recoverFromRunnerLeak } from './runner-leak-recovery.js';
9+
import { reopenSessionForRecovery } from './device-session.js';
810
const execFile = promisify(execFileCb);
911
const ANDROID_UNSAFE_CHARS = /[+@#$%^&*(){}|\\<>~`[\]?*]/;
1012
const ANDROID_FILL_MAX_SAFE_LEN = 30;
@@ -18,30 +20,54 @@ function candidateFromNode(n) {
1820
position: n.rect ? { x: n.rect.x, y: n.rect.y } : undefined,
1921
};
2022
}
21-
async function fetchSnapshotNodes() {
23+
function parseSnapshotEnvelope(result) {
24+
if (result.isError)
25+
return null;
2226
try {
23-
const snapshotResult = await runAgentDevice(['snapshot', '-i']);
24-
if (snapshotResult.isError)
25-
return null;
26-
const envelope = JSON.parse(snapshotResult.content[0].text);
27+
const envelope = JSON.parse(result.content[0].text);
2728
if (!envelope.ok || !envelope.data?.nodes)
2829
return null;
29-
const nodes = envelope.data.nodes;
30-
const platform = getActiveSession()?.platform;
31-
if (platform)
32-
cacheSnapshot(platform, nodes);
33-
return nodes;
30+
return envelope.data.nodes;
3431
}
3532
catch {
3633
return null;
3734
}
3835
}
36+
async function fetchSnapshotNodes() {
37+
const first = await runAgentDevice(['snapshot', '-i']);
38+
const initialNodes = parseSnapshotEnvelope(first);
39+
if (initialNodes === null)
40+
return { ok: false, reason: 'fetch-failed' };
41+
if (!isAgentDeviceRunnerSentinel(initialNodes)) {
42+
const platform = getActiveSession()?.platform;
43+
if (platform)
44+
cacheSnapshot(platform, initialNodes);
45+
return { ok: true, nodes: initialNodes };
46+
}
47+
const session = getActiveSession();
48+
const recovery = await recoverFromRunnerLeak({ platform: session?.platform, appId: session?.appId, sessionName: session?.name }, {
49+
closeSession: () => runAgentDevice(['close']),
50+
openSession: ({ appId, platform, attachOnly }) => reopenSessionForRecovery(appId, platform, attachOnly),
51+
resnapshot: () => runAgentDevice(['snapshot', '-i']),
52+
parseNodes: parseSnapshotEnvelope,
53+
});
54+
if (!recovery.recovered) {
55+
return { ok: false, reason: 'runner-leak-unrecovered', recoveryReason: recovery.reason };
56+
}
57+
const recoveredNodes = parseSnapshotEnvelope(recovery.result);
58+
if (recoveredNodes === null)
59+
return { ok: false, reason: 'fetch-failed' };
60+
const platform = getActiveSession()?.platform;
61+
if (platform)
62+
cacheSnapshot(platform, recoveredNodes);
63+
return { ok: true, nodes: recoveredNodes, recoveredTier: recovery.tier };
64+
}
3965
async function fetchFindCandidates(query, exact) {
40-
const nodes = await fetchSnapshotNodes();
41-
if (!nodes)
42-
return null;
66+
const snap = await fetchSnapshotNodes();
67+
if (!snap.ok)
68+
return snap;
4369
const needle = query.toLowerCase();
44-
return nodes
70+
const candidates = snap.nodes
4571
.filter((n) => {
4672
const label = n.label ?? '';
4773
const id = n.identifier ?? '';
@@ -51,6 +77,15 @@ async function fetchFindCandidates(query, exact) {
5177
})
5278
.slice(0, 10)
5379
.map(candidateFromNode);
80+
return { ok: true, candidates, recoveredTier: snap.recoveredTier };
81+
}
82+
function runnerLeakFailResult(query, recoveryReason) {
83+
const queryHint = query ? ` (while resolving "${query}")` : '';
84+
return failResult(`device_find/snapshot returned AgentDeviceRunner's own UI tree instead of the target app${queryHint} (B119 / GH #35 — agent-device daemon dropped appBundleId on dispatch). Auto-recovery did not restore the target.`, {
85+
code: 'RUNNER_LEAK',
86+
recoveryReason,
87+
hint: 'Manually close + reopen the session with device_snapshot action=open appId=<your.bundle.id> platform=ios (full launch, not attachOnly). The recovery may have killed the JS context — re-establish CDP via cdp_connect before reading state. Upstream: Callstack/agent-device, see B119/GH#35.',
88+
});
5489
}
5590
async function pressCandidate(candidate, action) {
5691
const ref = candidate.ref.startsWith('@') ? candidate.ref : `@${candidate.ref}`;
@@ -59,31 +94,50 @@ async function pressCandidate(candidate, action) {
5994
}
6095
return okResult({ ref: candidate.ref, label: candidate.label, testID: candidate.testID });
6196
}
97+
// B119: when an underlying snapshot triggered runner-leak recovery, surface
98+
// that side-effect on the wrapping result so callers (LLM agents) know the
99+
// app may have been relaunched and CDP/state may have been invalidated.
100+
function tagPressIfRecovered(result, tier) {
101+
if (!tier || result.isError)
102+
return result;
103+
try {
104+
const envelope = JSON.parse(result.content[0].text);
105+
envelope.meta = { ...envelope.meta, recovered: 'agent-device-runner-leak', recoveryTier: tier };
106+
return { content: [{ type: 'text', text: JSON.stringify(envelope) }] };
107+
}
108+
catch {
109+
return result;
110+
}
111+
}
62112
export function createDeviceFindHandler() {
63113
return withSession(async (args) => {
64114
// Fast path when caller already knows they want exact or a specific index:
65115
// go straight to a snapshot-based client-side match so we never roll the dice
66116
// on agent-device's fuzzy matcher returning AMBIGUOUS_MATCH.
67117
if (args.exact === true || args.index !== undefined) {
68-
const candidates = await fetchFindCandidates(args.text, args.exact === true);
69-
if (candidates === null) {
118+
const find = await fetchFindCandidates(args.text, args.exact === true);
119+
if (!find.ok) {
120+
if (find.reason === 'runner-leak-unrecovered') {
121+
return runnerLeakFailResult(args.text, find.recoveryReason);
122+
}
70123
// Snapshot failed and caller has strict requirements — do NOT fall through
71124
// to the fuzzy agent-device path because it cannot honor exact/index. Fail
72125
// cleanly so the caller knows exact/index semantics aren't reachable.
73126
return failResult(`Snapshot unavailable — cannot resolve ${args.exact ? 'exact' : 'index-based'} match for "${args.text}". Retry after device_snapshot action=open/snapshot.`, { code: 'SNAPSHOT_UNAVAILABLE', query: args.text });
74127
}
128+
const { candidates, recoveredTier } = find;
75129
if (candidates.length === 0) {
76130
return failResult(`No element matches "${args.text}" (exact=${args.exact === true})`, { code: 'NOT_FOUND', query: args.text });
77131
}
78132
if (args.index !== undefined) {
79133
if (args.index < 0 || args.index >= candidates.length) {
80134
return failResult(`index ${args.index} out of range (got ${candidates.length} candidates)`, { code: 'INDEX_OUT_OF_RANGE', count: candidates.length, candidates });
81135
}
82-
return pressCandidate(candidates[args.index], args.action);
136+
return tagPressIfRecovered(await pressCandidate(candidates[args.index], args.action), recoveredTier);
83137
}
84138
// exact=true, no index: require single match
85139
if (candidates.length === 1) {
86-
return pressCandidate(candidates[0], args.action);
140+
return tagPressIfRecovered(await pressCandidate(candidates[0], args.action), recoveredTier);
87141
}
88142
return failResult(`AMBIGUOUS_MATCH: exact "${args.text}" matched ${candidates.length} elements`, { code: 'AMBIGUOUS_MATCH', query: args.text, candidates, hint: 'Add index: N to pick one.' });
89143
}
@@ -95,8 +149,12 @@ export function createDeviceFindHandler() {
95149
if (result.isError) {
96150
const text = result.content?.[0]?.text ?? '';
97151
if (text.includes('AMBIGUOUS_MATCH') || (text.includes('matched') && text.includes('elements'))) {
98-
const candidates = await fetchFindCandidates(args.text, false);
99-
if (candidates) {
152+
const find = await fetchFindCandidates(args.text, false);
153+
if (!find.ok && find.reason === 'runner-leak-unrecovered') {
154+
return runnerLeakFailResult(args.text, find.recoveryReason);
155+
}
156+
if (find.ok) {
157+
const candidates = find.candidates;
100158
return failResult(`AMBIGUOUS_MATCH: "${args.text}" matched ${candidates.length} elements. Use device_press with one of these refs, or retry with index: N.`, {
101159
code: 'AMBIGUOUS_MATCH',
102160
query: args.text,
@@ -455,10 +513,14 @@ export function createDeviceFocusNextHandler() {
455513
// Benchmark data: 4 serial finds = 10-22s on no-keyboard case; single
456514
// snapshot = 3-5s on the same case. Also more reliable — one accessibility
457515
// query races keyboard animations less than four sequential queries.
458-
const nodes = await fetchSnapshotNodes();
459-
if (!nodes) {
516+
const snap = await fetchSnapshotNodes();
517+
if (!snap.ok) {
518+
if (snap.reason === 'runner-leak-unrecovered') {
519+
return runnerLeakFailResult(undefined, snap.recoveryReason);
520+
}
460521
return failResult('Snapshot unavailable — cannot look for keyboard key. Retry after device_snapshot action=open/snapshot.', { code: 'SNAPSHOT_UNAVAILABLE' });
461522
}
523+
const { nodes, recoveredTier } = snap;
462524
for (const label of NEXT_KEY_LABELS) {
463525
const match = nodes.find((n) => n.label === label);
464526
if (!match)
@@ -468,7 +530,12 @@ export function createDeviceFocusNextHandler() {
468530
continue; // Match found but tap failed — try next label
469531
try {
470532
const envelope = JSON.parse(pressResult.content[0].text);
471-
return okResult(envelope.data, { meta: { keyUsed: label, ref: match.ref } });
533+
const meta = { keyUsed: label, ref: match.ref };
534+
if (recoveredTier) {
535+
meta.recovered = 'agent-device-runner-leak';
536+
meta.recoveryTier = recoveredTier;
537+
}
538+
return okResult(envelope.data, { meta });
472539
}
473540
catch {
474541
return pressResult;

scripts/cdp-bridge/dist/tools/device-session.js

Lines changed: 119 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { runAgentDevice, setActiveSession, clearActiveSession, getActiveSession,
44
import { stopFastRunner } from '../fast-runner-session.js';
55
import { okResult, failResult, warnResult } from '../utils.js';
66
import { resolveBundleId } from '../project-config.js';
7+
import { isAgentDeviceRunnerSentinel, recoverFromRunnerLeak, } from './runner-leak-recovery.js';
78
const execFile = promisify(execFileCb);
89
/**
910
* B112 (D641): check whether a given bundleId is currently running on the
@@ -103,6 +104,7 @@ export function createDeviceSnapshotHandler() {
103104
platform: args.platform,
104105
deviceId,
105106
openedAt: new Date().toISOString(),
107+
appId,
106108
});
107109
if (args.platform === 'ios' && deviceId) {
108110
ensureFastRunner(deviceId, appId).catch(() => { });
@@ -129,17 +131,125 @@ export function createDeviceSnapshotHandler() {
129131
if (!getActiveSession()) {
130132
return failResult('No device session open. Call device_snapshot with action="open" first.', { hint: 'Provide appId and platform to start a session.' });
131133
}
132-
const result = await runAgentDevice(['snapshot', '-i']);
133-
if (!result.isError) {
134-
try {
135-
const envelope = JSON.parse(result.content[0].text);
136-
const platform = getActiveSession()?.platform;
137-
if (platform && envelope.ok && envelope.data?.nodes) {
138-
cacheSnapshot(platform, envelope.data.nodes);
139-
}
134+
const result = await rawSnapshot();
135+
const nodes = parseSnapshotNodes(result);
136+
if (!result.isError && nodes && isAgentDeviceRunnerSentinel(nodes)) {
137+
const session = getActiveSession();
138+
const recovery = await recoverFromRunnerLeak({ platform: session?.platform, appId: session?.appId, sessionName: session?.name }, {
139+
closeSession: () => runAgentDevice(['close']),
140+
openSession: ({ appId, platform, attachOnly }) => reopenSessionForRecovery(appId, platform, attachOnly),
141+
resnapshot: () => rawSnapshot(),
142+
parseNodes: parseSnapshotNodes,
143+
});
144+
if (recovery.recovered) {
145+
cacheSnapshotIfPossible(recovery.result);
146+
return wrapWithMeta(recovery.result, {
147+
recovered: 'agent-device-runner-leak',
148+
recoveryTier: recovery.tier,
149+
});
140150
}
141-
catch { /* best-effort cache */ }
151+
return failResult(runnerLeakFailureMessage(recovery.reason, session), {
152+
code: 'RUNNER_LEAK',
153+
recoveryReason: recovery.reason,
154+
hint: runnerLeakFailureHint(recovery.reason, session),
155+
});
142156
}
157+
cacheSnapshotIfPossible(result);
143158
return result;
144159
};
145160
}
161+
export function runnerLeakFailureMessage(reason, session) {
162+
if (reason === 'no-session-context' && session && !session.appId) {
163+
return 'device_snapshot returned AgentDeviceRunner\'s own UI tree, but auto-recovery cannot run because the active session has no stored appId. This usually means the session was opened by a plugin version from before B119 / GH #35 landed.';
164+
}
165+
return 'device_snapshot returned AgentDeviceRunner\'s own UI tree instead of the target app (B119 / GH #35 — agent-device daemon dropped appBundleId on dispatch). Auto-recovery did not restore the target.';
166+
}
167+
export function runnerLeakFailureHint(reason, session) {
168+
if (reason === 'no-session-context' && session && !session.appId) {
169+
return 'Run device_snapshot action=close, then action=open appId=<your.bundle.id> platform=ios to start a session that supports auto-recovery.';
170+
}
171+
return 'Manually close + reopen the session with action=open appId=<your.bundle.id> platform=ios (full launch, not attachOnly). Upstream: Callstack/agent-device, see B119/GH#35.';
172+
}
173+
async function rawSnapshot() {
174+
return runAgentDevice(['snapshot', '-i']);
175+
}
176+
function parseSnapshotNodes(result) {
177+
if (result.isError)
178+
return null;
179+
try {
180+
const envelope = JSON.parse(result.content[0].text);
181+
if (!envelope.ok || !envelope.data?.nodes)
182+
return null;
183+
return envelope.data.nodes;
184+
}
185+
catch {
186+
return null;
187+
}
188+
}
189+
function cacheSnapshotIfPossible(result) {
190+
if (result.isError)
191+
return;
192+
try {
193+
const envelope = JSON.parse(result.content[0].text);
194+
const platform = getActiveSession()?.platform;
195+
if (platform && envelope.ok && envelope.data?.nodes) {
196+
cacheSnapshot(platform, envelope.data.nodes);
197+
}
198+
}
199+
catch { /* best-effort cache */ }
200+
}
201+
function wrapWithMeta(result, meta) {
202+
if (result.isError)
203+
return result;
204+
try {
205+
const envelope = JSON.parse(result.content[0].text);
206+
envelope.meta = { ...envelope.meta, ...meta };
207+
return { content: [{ type: 'text', text: JSON.stringify(envelope) }] };
208+
}
209+
catch {
210+
return result;
211+
}
212+
}
213+
export async function reopenSessionForRecovery(appId, platform, attachOnly) {
214+
// Always mint a fresh recovery name (Gemini G3): reusing the original
215+
// session name risks the daemon either rejecting as "already exists" or
216+
// silently re-attaching to the corrupted session, defeating the rebuild.
217+
const recoveryName = `rn-agent-recovery-${Date.now()}`;
218+
let cliArgs;
219+
if (attachOnly) {
220+
// attachOnly only makes sense if the target app is already running.
221+
// Otherwise there's nothing to attach to and we should let the caller
222+
// escalate (typically to the full-relaunch tier).
223+
const running = await isAppRunning(platform, appId);
224+
if (!running) {
225+
return failResult(`attachOnly recovery aborted: ${appId} is not running on ${platform}.`, { code: 'NOT_CONNECTED', recoveryAbort: true });
226+
}
227+
cliArgs = ['open', '--session', recoveryName, '--platform', platform];
228+
}
229+
else {
230+
cliArgs = ['open', appId, '--session', recoveryName, '--platform', platform];
231+
}
232+
const result = await runAgentDevice(cliArgs, { skipSession: true });
233+
if (result.isError)
234+
return result;
235+
let deviceId;
236+
try {
237+
const envelope = JSON.parse(result.content[0].text);
238+
const data = envelope?.data;
239+
const rawId = data?.deviceId
240+
?? data?.device_udid
241+
?? data?.id
242+
?? (typeof data?.device === 'object' ? data?.device?.id : undefined);
243+
const UDID_RE = /^[0-9A-Fa-f-]{25,}$/;
244+
deviceId = typeof rawId === 'string' && UDID_RE.test(rawId) ? rawId : undefined;
245+
}
246+
catch { /* best-effort */ }
247+
setActiveSession({
248+
name: recoveryName,
249+
platform,
250+
deviceId,
251+
openedAt: new Date().toISOString(),
252+
appId,
253+
});
254+
return result;
255+
}

0 commit comments

Comments
 (0)