mirror of
https://github.com/software-mansion/argent.git
synced 2026-09-14 19:27:14 +08:00
4975ff490c
## Why
PostHog (30d) shows the debugger tool family is argent's least reliable
surface:
| tool | fail rate | dominant causes |
|---|---|---|
| `debugger-status` | **37%** | METRO_NO_TARGETS /
SERVICE_INITIALIZATION_FAILED / zod |
| `debugger-connect` | **24%** | SERVICE_INITIALIZATION_FAILED (~7k, >1k
users), METRO_NO_TARGETS |
| `debugger-log-registry` | 41.7% raw / 4.2% excl. storms | one user
retry-looped it **12,742×** on NO_TARGETS |
Three design issues drive this: (1) "app/Metro not ready yet" — an
expected precondition — is reported as a tool *error*, which both
inflates fail rates and invites agent retry storms; (2) every CDP
init/lifecycle fault lands in telemetry as the catch-all
`REGISTRY_SERVICE_INITIALIZATION_FAILED` with `error_kind=unknown`; (3)
~600 users/30d hit zod validation failures with zero visibility into
which parameter was wrong.
## What
**1. Classified failures** (`d71efa43`) — six former plain-`Error` sites
in `cdp-client.ts` / `target-selection.ts` now carry dedicated
`DEBUGGER_CDP_*` / `DEBUGGER_TARGET_DEVICE_MISMATCH` signals (messages
byte-identical). The registry's two causeless "terminating window"
`ServiceInitializationError`s get their own
`REGISTRY_SERVICE_TERMINATING` code.
**2. Narrow recovery** (`ff9c1955`) — `recoverable()` on both debugger
blueprints, scoped to windows where retry is provably safe: Metro
recovers only `DEBUGGER_CDP_NOT_CONNECTED` (request never left the host;
a detected socket death already self-heals for the *next* call via the
terminated cascade — recovering `CONNECTION_CLOSED` there would be dead
code, see the blueprint comment). Chromium also recovers
`CONNECTION_CLOSED` for the tab-switch reconnect window.
`REQUEST_TIMEOUT` stays unrecoverable (double-execution risk). A
reachability test proves the retry actually fires for a RUNNING node.
**3. Structured not-connected results** (`48b91ffd`) — `debugger-status`
and `debugger-log-registry` become factories that return `{ status:
"not_connected", reason, detail, guidance }` for classified
preconditions (`metro_not_running | no_app_connected | device_mismatch |
cdp_unreachable | stale_connection | reconnecting`) and still throw on
unexpected faults. `detail` preserves the original error text for
string-matching agents. `debugger-status` gains a socket-state gate
(Metro: dispose stale node; Chromium: report transient reconnect, no
dispose — the client belongs to the ChromiumCdp dependency).
log-registry keeps **no** gate on purpose: captured logs are readable
over a dead socket, and disposal would destroy the post-crash log file.
Flows/run-sequence map a not_connected result to a **failed step**
(mirrors `isUnmetUiWaitResult`) so recorded connectivity gates don't
silently pass. `debugger-evaluate` re-codes agent-expression throws as
`DEBUGGER_EVALUATE_EXPRESSION_THREW`.
**4. Telemetry + guidance + docs** (`7602a70d`) —
`tool:fail.invalid_params` (schema-declared names only; unknown keys →
literal `"unrecognized_keys"`; capped emit-side at 16), new
`debugger:tool_outcome` event (coded enum; joins
`tool:invoke`/`complete` via `tool_invocation_id` —
`ai_client`/`duration` live on the joined row), anti-retry-loop guidance
appended to the Metro discovery errors (first sentences verbatim —
skills match on them), six skills updated, Telemetry.md bumped to v1.03
with the new coded diagnostics disclosed.
## Dashboard migration note
`debugger-status` / `debugger-log-registry` precondition failures stop
emitting `tool:fail` — track them via `debugger:tool_outcome` (`outcome
!= 'connected'`). Per-AI-client breakdowns of those outcomes need a join
on `tool_invocation_id` against `tool:complete`.
## Testing
- Full suites green: tool-server 3,142, telemetry 290, registry 87. New
coverage: recoverable truth tables (incl. non-Error inputs), status
not-connected matrix
(Metro/Chromium/terminating-window/stale/dispose-side-effect/fall-through-rethrow),
log-registry rows, zod `invalid_params` derivation + emission-level
guard, flow gate, evaluate rewrap, sanitize-level enum checks.
- Live-tested against a booted iOS simulator + RN app and an Electron
target (details in PR comments).
Plan hardened through 3 adversarial review rounds (46 confirmed findings
addressed) before implementation.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
190 lines
6.4 KiB
TypeScript
190 lines
6.4 KiB
TypeScript
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
import * as fs from "node:fs/promises";
|
|
import * as os from "node:os";
|
|
import * as path from "node:path";
|
|
import type { Registry } from "@argent/registry";
|
|
import { createRunFlowTool, type FlowRunResult } from "../../src/tools/flows/flow-run";
|
|
|
|
/**
|
|
* A debugger-status / debugger-log-registry step returning the structured
|
|
* not_connected shape is a SUCCESSFUL tool call, but a flow using it as a
|
|
* connectivity gate must not silently green-pass on it. Mirrors the
|
|
* await-ui-element unmet-condition mapping (flow-run-await.test.ts).
|
|
*/
|
|
|
|
const PROJECT_ROOT = path.join(os.tmpdir(), `flow-debugger-gate-tests-${process.pid}`);
|
|
|
|
function makeRegistry(invoke: (id: string, args: unknown) => Promise<unknown>) {
|
|
return {
|
|
invokeTool: vi.fn(invoke),
|
|
getTool: vi.fn(() => undefined),
|
|
} as unknown as Registry;
|
|
}
|
|
|
|
async function writeFlow(yaml: string): Promise<string> {
|
|
const flowsDir = path.join(PROJECT_ROOT, ".argent", "flows");
|
|
const file = path.join(flowsDir, "gated.yaml");
|
|
await fs.mkdir(flowsDir, { recursive: true });
|
|
await fs.writeFile(file, yaml, "utf8");
|
|
return file;
|
|
}
|
|
|
|
afterEach(async () => {
|
|
await fs.rm(PROJECT_ROOT, { recursive: true, force: true });
|
|
});
|
|
|
|
const GATED_FLOW = `executionPrerequisite: ""
|
|
steps:
|
|
- tool: debugger-status
|
|
args:
|
|
port: 8081
|
|
device_id: X
|
|
- tool: gesture-tap
|
|
args:
|
|
udid: X
|
|
x: 0.5
|
|
y: 0.5
|
|
`;
|
|
|
|
const NOT_CONNECTED_RESULT = {
|
|
status: "not_connected",
|
|
connected: false,
|
|
port: 8081,
|
|
reason: "metro_not_running",
|
|
detail: "Metro at port 8081 is not running",
|
|
guidance: "Do not retry in a loop — start Metro first.",
|
|
};
|
|
|
|
function asRun(r: FlowRunResult | { notice: string }): FlowRunResult {
|
|
if (!("steps" in r)) throw new Error(`expected a FlowRunResult, got a notice: ${r.notice}`);
|
|
return r;
|
|
}
|
|
|
|
describe("flow-execute with a debugger-status connectivity gate", () => {
|
|
it("maps a not_connected result to step status fail and stops the flow", async () => {
|
|
const flowFile = await writeFlow(GATED_FLOW);
|
|
const registry = makeRegistry(async (id) => {
|
|
if (id === "debugger-status") return NOT_CONNECTED_RESULT;
|
|
return { tapped: true };
|
|
});
|
|
const tool = createRunFlowTool(registry);
|
|
|
|
const result = asRun(
|
|
await tool.execute(
|
|
{},
|
|
{ name: "gated", project_root: PROJECT_ROOT, flow_file: flowFile, device: "X" }
|
|
)
|
|
);
|
|
|
|
// debugger-status ran; the trailing tap did NOT.
|
|
expect(registry.invokeTool).toHaveBeenCalledTimes(1);
|
|
const executed = result.steps.filter((s) => s.kind === "tool" && s.status !== "skip");
|
|
expect(executed).toHaveLength(1);
|
|
const gate = executed[0];
|
|
expect(gate.tool).toBe("debugger-status");
|
|
expect(gate.status).toBe("fail");
|
|
expect(gate.reason).toMatch(/debugger not connected \(metro_not_running\)/);
|
|
expect(gate.reason).toMatch(/Do not retry in a loop/);
|
|
// `detail` must reach the report: it is the only field that names what
|
|
// actually answered the port (and device_mismatch's guidance explicitly
|
|
// forwards the agent to it for the valid logicalDeviceIds).
|
|
expect(gate.reason).toContain(NOT_CONNECTED_RESULT.detail);
|
|
expect(gate.result).toEqual(NOT_CONNECTED_RESULT);
|
|
expect(result.ok).toBe(false);
|
|
});
|
|
|
|
it("maps a debugger-log-registry gate the same way — the second tool id is load-bearing", async () => {
|
|
// Narrowing the predicate to debugger-status alone would silently
|
|
// green-pass a log-registry connectivity gate; this pins the second arm.
|
|
const LOG_FLOW = `executionPrerequisite: ""
|
|
steps:
|
|
- tool: debugger-log-registry
|
|
args:
|
|
port: 8081
|
|
device_id: X
|
|
- tool: gesture-tap
|
|
args:
|
|
udid: X
|
|
x: 0.5
|
|
y: 0.5
|
|
`;
|
|
const flowsDir = path.join(PROJECT_ROOT, ".argent", "flows");
|
|
const file = path.join(flowsDir, "log-gated.yaml");
|
|
await fs.mkdir(flowsDir, { recursive: true });
|
|
await fs.writeFile(file, LOG_FLOW, "utf8");
|
|
|
|
const registry = makeRegistry(async (id) => {
|
|
if (id === "debugger-log-registry") return NOT_CONNECTED_RESULT;
|
|
return { tapped: true };
|
|
});
|
|
const tool = createRunFlowTool(registry);
|
|
|
|
const result = asRun(
|
|
await tool.execute(
|
|
{},
|
|
{ name: "log-gated", project_root: PROJECT_ROOT, flow_file: file, device: "X" }
|
|
)
|
|
);
|
|
|
|
expect(registry.invokeTool).toHaveBeenCalledTimes(1);
|
|
const executed = result.steps.filter((s) => s.kind === "tool" && s.status !== "skip");
|
|
expect(executed).toHaveLength(1);
|
|
expect(executed[0].tool).toBe("debugger-log-registry");
|
|
expect(executed[0].status).toBe("fail");
|
|
expect(executed[0].reason).toContain(NOT_CONNECTED_RESULT.detail);
|
|
expect(result.ok).toBe(false);
|
|
});
|
|
|
|
it("passes the gate and runs the whole flow on a connected result", async () => {
|
|
const flowFile = await writeFlow(GATED_FLOW);
|
|
const registry = makeRegistry(async (id) => {
|
|
if (id === "debugger-status") {
|
|
return { status: "connected", connected: true, port: 8081 };
|
|
}
|
|
return { tapped: true };
|
|
});
|
|
const tool = createRunFlowTool(registry);
|
|
|
|
const result = asRun(
|
|
await tool.execute(
|
|
{},
|
|
{ name: "gated", project_root: PROJECT_ROOT, flow_file: flowFile, device: "X" }
|
|
)
|
|
);
|
|
|
|
expect(registry.invokeTool).toHaveBeenCalledTimes(2);
|
|
expect(result.steps.filter((s) => s.kind === "tool")).toHaveLength(2);
|
|
expect(result.ok).toBe(true);
|
|
});
|
|
|
|
it("does NOT trip the mapping for other tools returning a status field", async () => {
|
|
// The predicate is keyed on the two debugger tool ids — an unrelated tool
|
|
// whose result happens to carry status:"not_connected" must stay a pass.
|
|
const OTHER_FLOW = `executionPrerequisite: ""
|
|
steps:
|
|
- tool: some-custom-tool
|
|
args:
|
|
udid: X
|
|
`;
|
|
const flowsDir = path.join(PROJECT_ROOT, ".argent", "flows");
|
|
const file = path.join(flowsDir, "other.yaml");
|
|
await fs.mkdir(flowsDir, { recursive: true });
|
|
await fs.writeFile(file, OTHER_FLOW, "utf8");
|
|
|
|
const registry = makeRegistry(async () => ({ status: "not_connected" }));
|
|
const tool = createRunFlowTool(registry);
|
|
|
|
const result = asRun(
|
|
await tool.execute(
|
|
{},
|
|
{ name: "other", project_root: PROJECT_ROOT, flow_file: file, device: "X" }
|
|
)
|
|
);
|
|
|
|
const executed = result.steps.filter((s) => s.kind === "tool");
|
|
expect(executed).toHaveLength(1);
|
|
expect(executed[0].status).toBe("pass");
|
|
expect(result.ok).toBe(true);
|
|
});
|
|
});
|