Skip to content

feat(flow): support concurrent flow recordings - #574

Open
hubgan wants to merge 18 commits into
mainfrom
feat/concurrent-flow-recordings
Open

feat(flow): support concurrent flow recordings#574
hubgan wants to merge 18 commits into
mainfrom
feat/concurrent-flow-recordings

Conversation

@hubgan

@hubgan hubgan commented Jul 27, 2026

Copy link
Copy Markdown
Collaborator

Problem

Only one flow could be recorded at a time, anywhere on the machine. Recording state was three module globals in flow-utils.ts — harmless in a per-client process, but the tool-server is a host-wide singleton per install bundle. Every MCP client, every subagent sharing a parent's connection, every argent CLI call and every project on the machine attaches to the same process, the same Registry, and those same three globals. HTTP requests are served concurrently with no serialization.

Three failures followed, all reproduced against the pre-change code:

  1. A second recording clobbered the first. startRecordingSession just overwrote, so the first agent's next flow-add-step silently appended into the other agent's flow file — in a different project.
  2. Any agent could finish any agent's recording. flow-finish-recording's schema was literally z.object({}).
  3. Replay stomped recording. resolveFlowFilePath called setActiveProjectRoot, so a flow-execute in project B rebound the global root mid-recording of project A. Already worked around, locally only, in flow-add-step.

Approach

Recording state is now a Map keyed by the resolved flow file path, <project_root>/.argent/flows/<name>.yaml — the identity of the artifact being built, so "same key" means "same output file" and two sessions on one key are a genuine collision rather than an accident of scoping. flow-add-step, flow-add-echo and flow-finish-recording each take name + project_root, so every call is self-contained; required rather than optional-with-fallback, since a fallback would succeed while an agent is alone and fail only once a second recording exists.

resolveFlowFilePath is pure, which fixes defect 3 and retires FLOW_PROJECT_ROOT_REQUIRED (the state it guarded can no longer exist).

Rejected alternatives — keying by device udid (flows are deliberately device-portable, and flow-add-echo/flow-finish-recording have no device at all), an opaque recordingId (an agent that loses the token cannot recover), and per-caller identity from the transport (ToolContext carries no caller identity, and subagents share their parent's MCP connection, so it would fail the exact scenario being fixed).

Locking, and why readers needed more than a lock

Every mutation of one flow file runs under a lock keyed by that path — appends, flow-start-recording's truncate-and-register, and flow-finish-recording's read-summarize-clear. Keyed by file rather than owned by the session, because a restart replaces the session: a session-owned lock cannot exclude the operation that supersedes it. Per file, not global, so two recordings never queue behind each other. An append whose session was finished, restarted or evicted meanwhile fails with FLOW_NO_ACTIVE_RECORDING rather than writing into a different take.

The lock serializes writers only, and no reader of a flow YAML can join it — flow-execute's own load, its run: fragment load, flow-read-prerequisite, flow-add-step's sibling-fragment check, and the argent CLI reading from another process entirely, where an in-process lock cannot reach. Both writers used a plain fs.writeFile, which opens O_TRUNC, so a reader could land between the truncate and the write. That case is silent: parseFlow("") returns { steps: [] } with no error and summarize derives ok from "no failures", so a flow-execute racing an append reports a top-level PASS having replayed zero steps. Both writes now go through a sibling temp file and a rename, so a reader sees either the whole old file or the whole new one — and unlike the lock, that also holds cross-process.

stop-all-simulator-servers gets a devices scope

Every agent is told to call this at session end, and unscoped it walked the whole registry — so agent A wrapping up tore down agent B's devtools mid-recording, degrading B's flow to brittle coordinate taps. It now takes an optional devices scope and reports unmatched ids, so a mistyped id doesn't read as a clean machine.

Which namespaces count as "owned by a device" turned out to be the load-bearing part, because nothing cascades to most of them and unmatched turns any omission into an actively wrong diagnosis — a correct device id reported as a typo. The set now covers AXService (the in-sim ax daemon, spawned --timeout 3600; an iOS session that only ran boot/launch/describe owns this and nothing else), ScreenRecordingSession (an ffmpeg child plus the touch-visualizer overlay it enabled on the device), NativeProfilerSession (an xctrace child, or an on-device perfetto process and its trace file) and JsRuntimeDebugger (a bound loopback server, the CDP socket to Metro, a log handle). The debugger URNs interpose the Metro port — <ns>:<port>:<deviceId> — so the matcher understands both shapes, consuming only the first colon so a wireless adb serial after the port still compares whole.

stop-simulator-server shared none of this and had drifted: it looked URNs up with an exact, case-sensitive services.get(), so a lower-cased UDID silently no-op'd there while the scoped stop-all reaped it. Both tools now share one matcher. Their namespace sets stay deliberately different, documented where they are defined — stop-simulator-server is also the documented recovery for a wedged transport, and widening it to devtools/AX would make a routine retry drop the native-devtools connection another agent's recording depends on, which is the hazard the devices scope exists to prevent.

Verification

End-to-end, three devices, one shared tool-server (iPhone 16 Pro, iPhone 17 Pro, Pixel 3a): three interleaved recordings, plus an unrelated flow-execute in a fourth project while all three were live. Both iOS devices held their own live native-devtools concurrently (two per-UDID sockets, connected: true). Each YAML contained exactly its own steps in order, taps captured as selectors rather than coordinates, and neither picked up another's or the third root's path. All three replayed green — also through the released 0.17.0 CLI against the modified server, which checks wire compatibility. A device-scoped teardown of sim 1 left sim 2 and the emulator running.

Re-run against the final build:

  • Two interleaved recordings across two project roots on one device, with a separate process polling one of the flow files as fast as it could for the duration. Zero empty or truncated observations; each YAML held exactly its own steps; both replayed green (6/6 and 5/5). Against the pre-change in-place write the same reader observes zero-step reads interleaved among the real ones — the silent-PASS input.
  • A scoped teardown of the iPhone reaped AXService and NativeDevtools for that UDID only, reported no unmatched, and left the second sim and the emulator running. Lower-casing the UDID matched; a genuinely bogus id was still reported.
  • With a screen recording live on the Pixel 3a, a scoped teardown reaped ScreenRecordingSession and the ffmpeg child actually exited (1 → 0). Before the fix it survived to the 180 s cap while the tool called the correct serial a typo.

The pre-change repros were re-run afterwards to confirm observed behavior changed, not just the tests.

npx vitest run in packages/tool-server (295 files, 3154 tests) and packages/argent-cli (280), tsc --build, tsc --noEmit on both test projects, eslint and prettier — all green.

Review notes

Several rounds of review found real defects in this change; each was reproduced before fixing and re-verified after. Worth knowing when reading the diff, since the tests exist to pin them:

  • flow-start-recording truncated the .yaml outside any lock, so a step from the take being discarded landed in the freshly reset file and was reported as success (26-29 of 200 runs).
  • flow-finish-recording never took the lock; its await fs.readFile was a yield during which a concurrent append committed, leaving the reported summary disagreeing with disk (11-16 of 200).
  • A failed finish destroyed the recording. Hand-editing the .yaml mid-recording is a documented workflow, so parseFlow can legitimately throw — and the error told the agent to call flow-start-recording, which truncates the very take it would recover.
  • unmatched reported a device this session had just stopped, because disposeService returns a node to IDLE and keeps it.
  • A bare IP claimed every wireless-adb device at that address, since an adb serial is itself ip:port.
  • The eviction backstop stamped with Date.now(); ties at millisecond resolution let it drop the session that was just used.
  • requireRecordingSession ended with "Call flow-start-recording first" — reached for a key that was finished, superseded or evicted as well as one never started, and on those branches the file on disk is fully populated while that call truncates it and reports no restarted.
  • The atomic write regressed twice on its own terms: its scratch name was derived from the flow file's basename, which has no length cap, so a long flow name that appended fine before failed ENAMETOOLONG; and the cleanup guard began after the temp file was created, leaking a scratch file into a committed directory on any write that failed after opening.
  • Two tests passed against implementations they claimed to reject: the stop-simulator-server narrowness guard used a udid that classifies as android, where the iOS-only services it guards can never appear, so widening the iOS branch kept it green; and the client-mode finish asserted only the shape of savedTo, never its content, though that directive is the only thing that lands the file in client mode.

Notes for reviewers

  • Client/remote persist mode is the least-exercised path, and now has its own concurrency tests (overlapping appends, and a restart superseding an in-flight one) — it previously had none. The project_root key is byte-stable across the file boundary (kind: "probe" passes it through unchanged), which is what keeps a remote recording addressable from flow-add-step, which declares no file input.

  • Two spellings of one flow file still mint two sessions — and two independent locks — over one file, which bypasses every guarantee here. Two ways in: the root spelled two ways (a symlink, or a case variant on APFS), and the likelier one, the name cased two ways on a case-insensitive volume. Neither is normalized away because the correct normalization is the filesystem's: case-folding the key would wrongly merge two distinct flows on ext4, and resolving symlinks is impossible in client mode, where the root does not exist on this host. Documented at getFlowPath.

  • flow-finish-recording cannot detect that another agent took its key; it finishes whatever take is live and reports it as yours. flow-add-step fails loudly in the same situation. There is no caller identity to key ownership on (see the rejected alternatives above), so the asymmetry is documented in the tool description rather than mechanized.

  • Skew hazard in one direction only: a new client against an older tool-server. Zod strips the undeclared name/project_root, and the old server falls back to its module globals — so concurrent recordings silently collapse onto one session. The other direction (released 0.17.0 CLI against this server) was verified end-to-end.

  • packages/argent-private/docs/reference.md still describes flow-add-step as appending to "the current recording", and names two tool ids that do not exist (flow-insert-echo/flow-run are file names; the ids are flow-add-echo/flow-execute). It is a submodule, so the fix cannot ride on this branch.

  • A restart reported discardedSteps from the superseded session's in-memory flow while it truncates the file. Hand-editing the .yaml mid-recording is documented, so the two diverge: four steps wiped, one reported. It now counts the file, and reports no number at all when that file cannot be read or parsed - 0 is the answer a genuinely empty take gives. Client mode still counts from memory (this host cannot see the client's file), and the description now says which mode it is promising.

  • ChromiumJsRuntimeDebugger cascades from ChromiumCdp and was therefore already being torn down - silently, while NetworkInspector and ReactProfilerSession cascade the same way and were named. stopped is documented as the services that were live and got shut down, so it is listed now too.

  • The client-mode case named for a superseded in-flight append restarted between calls, so the next append re-resolved the key and succeeded - the guard was never reached, and neutering it left the whole file green. It now parks an append in its live sub-tool call across the restart.

  • stop-tools.test.ts' mock handed the tool the live service map where the real getSnapshot copies, so a cascade rewrote the state the sweep was still reading and the answer depended on map insertion order. The mock copies and recurses like _teardown now, and the cascade case runs under both orders.

  • Prose the code contradicted, each verified against source before rewriting: Vega owns JsRuntimeDebugger/NetworkInspector once the debugger has run (and can match anything at all through an ERROR node); "nothing here cascades" was denied twice in its own file; an iOS boot/launch/describe session owns NativeDevtools too; :tcp is mintable but unreached in production; the tool-server is a singleton per install bundle, not per machine; argent flow list does enumerate .argent/flows; the stop-tools comments narrated a "before" that only existed on this branch.

Found while verifying, out of scope, worth their own issues: a tool: flow-execute self-reference recurses past both cycle guards (run: is guarded, a nested flow-execute reseeds the stack); react-profiler artifacts collide host-wide on a second-resolution filename; stop-metro is port-scoped and kills another agent's bundler; debugger-reload-metro's HTTP fallback broadcasts to every attached client. A tool's capability is enforced only by the HTTP layer, not by registry.invokeTool, so a call that reaches the registry another way - flow-add-step takes command as a bare string - can resolve a service the tool declares it does not support, leaving an ERROR node behind (e.g. SimulatorServer:<vega serial>).

@hubgan
hubgan marked this pull request as ready for review July 28, 2026 08:09
@hubgan
hubgan requested review from j-piasecki and latekvo July 28, 2026 08:09

@latekvo latekvo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Diplomat]: Ran a full end-to-end sweep across the concurrency, ripple/call-site, client-mode, device-scoping, and docs lenses — the recording logic held up throughout. Two low-impact notes on the eviction test scaffolding are left inline.

Comment thread packages/tool-server/test/flows/flow-concurrent-recording.test.ts Outdated
Comment thread packages/tool-server/test/flows/flow-concurrent-recording.test.ts Outdated

@j-piasecki j-piasecki left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Three findings from a full review pass (each independently verified against the working tree; already-reported items and PR-acknowledged limitations excluded).

Comment thread packages/tool-server/src/tools/simulator/stop-all-simulator-servers.ts Outdated
Comment thread packages/tool-server/src/tools/simulator/stop-all-simulator-servers.ts Outdated
Comment thread packages/tool-server/src/tools/flows/flow-utils.ts
@hubgan
hubgan requested review from j-piasecki and latekvo July 28, 2026 10:52

@latekvo latekvo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Diplomat]: Ran a full end-to-end sweep of this branch across the concurrency, atomic-write, tool-semantics, call-site/ripple, device-scoping, state-machine, resource-lifecycle, input-domain, test-quality and docs lenses, driving a tool-server built from af79e86 over HTTP: concurrent recordings across projects and against one device, a hammering reader during appends, restart/supersede, client persist mode, the MAX_RECORDINGS backstop, scoped teardown, replay, and the released 0.17.0 CLI against this server. The recording machinery itself held up everywhere I pushed on it — the isolation, the per-file lock and the temp-file+rename swap all behaved as described, with no torn reads and no cross-contamination.

Five notes inline. The one on stop-all-simulator-servers' schema is the one I would look at first — it is the only one with a behavioural consequence rather than a wording one.

Comment thread packages/skills/skills/argent-create-flow/SKILL.md Outdated
Comment thread packages/tool-server/src/tools/flows/flow-finish-recording.ts Outdated
Comment thread packages/tool-server/src/tools/flows/flow-utils.ts Outdated
Comment thread packages/tool-server/src/tools/simulator/stop-simulator-server.ts Outdated
Comment thread packages/tool-server/src/tools/simulator/stop-all-simulator-servers.ts Outdated
@j-piasecki

Copy link
Copy Markdown
Member

LGTM! I'll leave it for @latekvo

@latekvo latekvo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Seven findings from a pass over the current head, each verified against a build of af79e86 and deduplicated against every existing thread. Six are prose that no longer describes the codebase; one is an unpinned behaviour I confirmed by mutation.

Comment thread packages/tool-server/src/tools/flows/flow-utils.ts
Comment thread packages/tool-server/src/tools/flows/flow-utils.ts Outdated
Comment thread packages/tool-server/src/tools/simulator/device-services.ts Outdated
Comment thread packages/tool-server/src/tools/flows/flow-utils.ts Outdated
Comment thread packages/tool-server/src/tools/flows/flow-start-recording.ts Outdated
Comment thread packages/argent-cli/test/run-help.test.ts Outdated
Comment thread packages/argent-cli/test/run-flow-add-step-payload.test.ts Outdated
@hubgan
hubgan requested a review from latekvo July 29, 2026 09:56

@latekvo latekvo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Diplomat]: Ran a full end-to-end sweep of this branch at a0ee67e6, then repeated independent passes over it until two consecutive sweeps came back clean.

A tool-server built from the branch drove two interleaved concurrent recordings across two project roots - each YAML kept exactly its own steps in order and both replayed green - while a separate reader observed 7,193,301 reads across 40 concurrent appends with zero empty or partial files. Also exercised: client/remote persist mode (nothing written to the host), the scoped and unscoped teardown paths including a case-varied UDID, a re-stop of an already-idle device, the devices: [] and misspelled-key cases, and the Chromium debugger cascade. Suites, typechecks, eslint and prettier are clean; the 6 failures in test/boot-device-hotboot.test.ts are pre-existing and identical at the merge base.

I also mutated the core invariants directly - the atomic write, the append lock, .strict(), touch()/LRU, the lock-map self-cleanup, the devices: [] semantics, the ERROR-node omission from stopped, and ownership-counted-regardless-of-state. Every one was caught by the suite. The recording, locking and atomic-write machinery held up throughout, and nothing inline below is a logic defect.

What did survive verification is prose that the code contradicts, plus one reported value. Findings are inline.

Comment thread packages/tool-server/src/tools/simulator/device-services.ts Outdated
Comment thread packages/tool-server/src/tools/simulator/device-services.ts Outdated
Comment thread packages/tool-server/src/tools/flows/flow-start-recording.ts Outdated
Comment thread packages/tool-server/test/flows/flow-utils.test.ts Outdated
Comment thread packages/tool-server/test/stop-tools.test.ts Outdated
Comment thread packages/tool-server/test/stop-tools.test.ts Outdated
Comment thread packages/tool-server/src/tools/simulator/device-services.ts Outdated
Comment thread packages/tool-server/test/flows/flow-remote-recording.test.ts Outdated
Comment thread packages/tool-server/src/tools/flows/flow-utils.ts Outdated
Comment thread packages/tool-server/src/tools/simulator/device-services.ts Outdated

@latekvo latekvo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Left inlines, please fix.

Other than these linguistic notes this is a great PR and i think its ready to merge ❤️

return {
id: "flow-add-step",
description: `Execute a tool call and record it as a step in the active flow. Use when recording a flow with flow-start-recording and you want to run and capture each action. A coordinate \`gesture-tap\` is recorded as a portable \`tap: { selector }\` step when the tapped element has stable text/identifier (otherwise coordinates are kept with a warning); a \`restart-app\` is recorded as a \`launch\` step (record one FIRST to make the flow a self-contained e2e flow). Returns { message, toolResult, flowFile } on success. If it fails an error is returned and nothing is recorded.
description: `Execute a tool call and record it as a step in the flow named by \`name\` + \`project_root\` (the recording must already be open — see flow-start-recording). Use when recording a flow and you want to run and capture each action. A coordinate \`gesture-tap\` is recorded as a portable \`tap: { selector }\` step when the tapped element has stable text/identifier (otherwise coordinates are kept with a warning); a \`restart-app\` is recorded as a \`launch\` step (record one FIRST to make the flow a self-contained e2e flow). Returns { message, toolResult, flowFile, savedTo } on success - \`savedTo\` is where the YAML landed: a host path, or, against a remote client, the directive that has the client write it (the only field naming the destination in that mode). If it fails an error is returned and nothing is recorded.

@latekvo latekvo Jul 29, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
description: `Execute a tool call and record it as a step in the flow named by \`name\` + \`project_root\` (the recording must already be open — see flow-start-recording). Use when recording a flow and you want to run and capture each action. A coordinate \`gesture-tap\` is recorded as a portable \`tap: { selector }\` step when the tapped element has stable text/identifier (otherwise coordinates are kept with a warning); a \`restart-app\` is recorded as a \`launch\` step (record one FIRST to make the flow a self-contained e2e flow). Returns { message, toolResult, flowFile, savedTo } on success - \`savedTo\` is where the YAML landed: a host path, or, against a remote client, the directive that has the client write it (the only field naming the destination in that mode). If it fails an error is returned and nothing is recorded.
description: `Execute a tool call and record it as a step in the flow named by \`name\` + \`project_root\` (the recording must already be open — see flow-start-recording). Use when recording a flow and you want to run and capture each action. A coordinate \`gesture-tap\` is recorded as a portable \`tap: { selector }\` step when the tapped element has stable text/identifier (otherwise coordinates are kept with a warning); a \`restart-app\` is recorded as a \`launch\` step (record one FIRST to make the flow a self-contained e2e flow). Returns { message, toolResult, flowFile, savedTo } on success. If it fails an error is returned and nothing is recorded.

Redundant. The model knows what a path is, there is no need to explain what a path is or where it could be leading to.

> = {
id: "flow-finish-recording",
description: `Finish recording the active flow. Returns a summary of all recorded steps and the final YAML content. Use when you have added all desired steps and want to finalize the flow file. Fails if no active flow recording is in progress.
description: `Finish recording the flow named by \`name\` + \`project_root\`, leaving recordings under any OTHER key untouched. On this key it finishes whatever take is live, which is not necessarily the one you started: the (project_root, name) key has no ownership check, so if another agent restarted this name your steps were already discarded and you get ITS take, reported as yours. Usually NOTHING tells you: flow-add-step detects a takeover only if one lands while a step of yours is mid-flight, so between calls - the common case - it re-resolves the key and appends into the other agent's take, reporting success. A run of successful \`Step added\` results is therefore not evidence that the key is still yours. Pick a name unique to your task. Returns { message, path, executionPrerequisite, steps, summary, flowFile, savedTo } - a summary of all recorded steps plus the final YAML. In client mode \`savedTo\` is the directive that lands the file in your project, while \`path\` names a file that does not exist on the tool-server host. Use when you have added all desired steps and want to finalize the flow file. Fails if that flow has no recording in progress.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This description grew 4x but 70% of it just says "be careful to not stop other agent recordings"

It's nonsensical, this description contains more rambling about a single edge-case than what the tool does, it's an insane level of overfixation on an irrelevant thing.

Please fix.

description: `Record an echo step in the flow named by \`name\` + \`project_root\`. Echo steps print a message when the flow is replayed — useful as labels between tool calls.
Use when you want to annotate a recorded flow with a human-readable label or checkpoint message.
Returns { message, flowFile }. Fails if no active flow recording is in progress.`,
Returns { message, flowFile, savedTo } - \`savedTo\` is where the YAML landed: a host path, or, against a remote client, the directive that has the client write it (the only field naming the destination in that mode). Fails if that flow has no recording in progress.`,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Once again, savedTo is self evident. If you believe it does require any more explanation than message or flowFile, both of which are not as self-evident, then please at least don't make it occupy so much space.

Comment on lines +60 to +69
Starting ALWAYS truncates <project_root>/.argent/flows/<name>.yaml to an empty flow — including a name that exists only as a saved file with no recording in progress, so starting under the name of a committed flow overwrites it. { restarted } is added only when a LIVE recording of the same flow was discarded; its absence does NOT mean nothing was overwritten. \`discardedSteps\` counts the flow file as it stood at the reset, so a hand-edit made mid-recording is included — but it is omitted, \`restarted\` alone, when that file could not be read or parsed. That holds when the project root is on the tool-server host; against a REMOTE client the host never sees the file, so there the count is of the steps recorded through this server and a hand-edit on your machine is not in it. Either way, re-record from the top rather than expecting to resume.
Fails before anything is written on a \`project_root\` that is not absolute or contains a ".." segment, or a \`name\` outside letters/digits/underscore/hyphen. It can also fail on the .argent/flows/ directory not being creatable or the file not being writable - but only when the project root is on the tool-server host; against a remote client the YAML travels back in \`savedTo\` for the client to write and no host filesystem access happens.

Recording state is independent: several flows can be recorded at once (different
names, different projects) and one recording's steps never land in another's
file. Steps still execute LIVE on a device, so give each concurrent recording its
own device. Every subsequent recording tool takes the same \`name\` +
\`project_root\` to say which one it is addressing — and the (project_root, name)
key has no ownership check, so pick a name unique to your task or another agent
starting the same one takes the key and your next step lands in its recording.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Another case of edge-case overfixation inflating a description to three times the size.

hubgan and others added 16 commits July 30, 2026 10:25
…bals

The tool-server is a host-wide singleton: every MCP client, subagent and
CLI call on the machine shares one process and, until now, one set of
recording globals. A second flow-start-recording clobbered the first, any
caller could finish a recording it never named (flow-finish-recording took
no arguments at all), and replaying a flow rebound the shared project root.

Recording state now lives in a Map keyed by <project_root>/.argent/flows/
<name>.yaml — the identity of the artifact being built — and flow-add-step,
flow-add-echo and flow-finish-recording each take name + project_root, so
every call is self-contained. Appends are serialized per session, since two
in-flight calls on one recording are now legitimate. resolveFlowFilePath is
pure, so the replay path no longer mutates anything shared, which retires
FLOW_PROJECT_ROOT_REQUIRED.

stop-all-simulator-servers gains a devices scope: every agent is told to
call it at session end, and unscoped it tore down other agents' devtools
mid-recording — degrading their flows to coordinate taps, silently.
Ports the flow suite to the map-keyed sessions, adds
flow-concurrent-recording.test.ts for the isolation and append-ordering
guarantees, and covers the devices scope on stop-all-simulator-servers.
Docs and MCP instructions updated to match.
Review of the map-keyed sessions turned up two races that survived the
per-session append chain, both reproducible through the HTTP API:

- flow-start-recording truncated the .yaml outside any lock and registered
  the replacing session two statements later, so a step from the take being
  discarded landed in the freshly reset file (26-29 of 200 runs) and was
  reported as success.
- flow-finish-recording never took the lock at all; its await fs.readFile
  was a yield during which a concurrent append committed, leaving the
  returned steps/summary/flowFile disagreeing with disk (11-16 of 200).

The lock is now keyed by the flow file rather than owned by the session --
a restart replaces the session, so a session-owned lock could not exclude
the operation superseding it. start's truncate+register and finish's
read+clear each run inside it, and an append whose session was finished,
restarted or evicted meanwhile fails with FLOW_NO_ACTIVE_RECORDING instead
of writing into another take. Still per file: two recordings never queue
behind each other.

Also from review: client-mode rollback now covers serializeFlow, not just
validateFlow (a step that failed to serialize used to stay in the in-memory
copy and poison every later call, unrecoverably); stop-all-simulator-servers
reports unmatched device ids so a mistyped id no longer reads as a clean
machine; and the test-reset export follows the house __resetXForTesting
convention.
…sclose

Three findings from the second review pass:

- flow-finish-recording cleared the session before parseFlow, so a botched
  hand-edit of the .yaml (a workflow the tool descriptions invite) destroyed
  the recording on the way out: the error told the agent to call
  flow-start-recording, which truncates the very take it would recover.
  Parse first, clear after.
- stop-all-simulator-servers counted a device as unmatched unless it owned a
  non-IDLE service, but disposeService returns a node to IDLE and keeps it,
  so the routine stop-one-then-stop-the-rest sequence reported the device
  the session had just stopped as a mistyped id. Ownership now counts
  regardless of state, and unmatched is de-duplicated and case-folded to
  match the lookup.
- the not-found error enumerated every live recording's flow name and
  absolute project root. A tool-server bound beyond loopback serves
  unrelated callers, so other projects are now counted rather than named;
  a typo in your own project, the case worth recovering from, still is.

Also corrects agent-facing prose that a first round of doc fixes overshot,
most importantly the chromium self-boot advice: passing platform=chromium
does not fall through to device auto-detection, it selects the self-boot
branch and treats the launch string as an Electron app path -- so on a
recorder-written flow, whose launch holds a bundle id, it failed the whole
run.
Third review pass. Nothing blocking in the lock itself, but three edges:

- flow-finish-recording rendered the step summary AFTER clearing the
  session, and that renderer walks step bodies the parser does not fully
  constrain, so a throw there destroyed the recording the previous commit
  had just made recoverable. Summary now renders inside the lock, before
  the clear, so nothing that can throw runs after a destructive step.
- the not-found message compared project roots as raw strings while the
  map key is path.join-normalized, so a caller spelling its own root with
  a trailing slash was told its recording lived in another project --
  degrading the message in the case it exists to diagnose.
- stop-all-simulator-servers de-duplicated unmatched ids by exact string
  though it matches case-insensitively, so two spellings of one wrong id
  were reported as two mistakes.

The start side of the lock was also load-bearing but unpinned: moving
startRecordingSession out of the critical section passed the entire suite.
It is now covered by a test that queues an append from the discarded take
directly on the flow file's lock, behind the restart -- proven to fail
against that mutation, 15/15 runs.

Plus the agent-facing prose for the error format the previous commit
changed, and three descriptions that over-claimed.
The eviction backstop stamped sessions with Date.now(), which has
millisecond resolution, so recordings touched inside one millisecond tied
and the scan fell back to map insertion order. Observed directly: fill to
the cap, touch the first-registered key, overflow -- and the session just
touched is the one evicted while the untouched next-oldest survives. It
takes 33 recordings inside a millisecond to hit, so this is a backstop
edge rather than a live bug, but the entry it drops is the one whose steps
would be stranded. A counter cannot tie, so least-recently-used now means
that. The test pins LRU against FIFO, which the previous stamps could not
distinguish.
… address

matchingDeviceId treated anything after a colon as the transport
discriminator, but only NativeDevtools appends one (:tcp) and an adb serial
over wifi is itself ip:port. So `devices: ["192.168.1.5"]` matched
AndroidDevtools:192.168.1.5:5555 AND SimulatorServer:192.168.1.5:5556 --
tearing down a second agent's device and reporting nothing unmatched, which
is the exact failure the devices scope exists to prevent. The suffix is now
enumerated rather than wildcarded, so that id reports itself unmatched and
disposes nothing, while the full serial and the :tcp form still match.
…clock mock

The LRU case filled the table, touched everything but `rec-0`, and asserted
`rec-0` was evicted — but `rec-0` was also the first-registered key, so an
insertion-order eviction picks the same victim and the assertion passes under
either policy. It now leaves an entry in the middle of the table untouched, so
the least-recently-used key and the first-registered one differ; degrading
`touch()` to a constant fails it, which it did not before.

`useMonotonicClock` dated from when the backstop stamped with `Date.now()`.
The eviction path reads `lastTouchedSeq` from a counter now and never calls the
wall clock, so the spy had no effect on either case it wrapped.
… restart

`assertSessionStillLive` closed with "Call flow-start-recording and re-record
the step." That truncates unconditionally, so every branch of the message sent
the agent at something worth keeping: on "restarted", the live take that had
just claimed the key (which restarting both wipes and steals back); on
"finished", the completed flow now sitting on disk. This is the defect 38995ca
fixed one file over, still spelled out in the error the other site emits.

It now points at a fresh name, and says the step already ran on the device —
`flow-add-step` invokes the sub-tool before it appends, so "nothing was
recorded" was true of the file and false of the phone, and an agent re-issuing
a tap on "Place order" would order twice. `flow-add-echo` runs nothing live and
gets the shorter wording.

Two more from the same sweep:

`summarizeSteps` stringified a tool step's `args` unguarded. That is the one
step body the parser does not constrain, so a cyclic YAML anchor in a
hand-edited file — a documented workflow — reached it as a cyclic object and
threw a raw TypeError naming neither the flow nor the step. `parseFlow` already
falls back to a marker for the same input class; do the same here.

`captureRunTarget` resolved the `run:` target by name alone, so running project
B's `login` while recording in project A recorded `run: login` and replayed
A's copy — a different file than the one that ran, silently. Generic fragment
names are exactly what collides now that recordings span projects. Still
recorded as composition, but the substitution is stated; the resolved-target
branch was also dropping warnings on the floor rather than surfacing them.
A mutation sweep over the new suite found two guarantees that no test held.

Replacing `if (flowFileLocks.get(key) === held) delete` with an unconditional
delete passed all 639 tests. It is not harmless: once the first holder finishes
while the second is still inside its critical section, the key is gone from the
map, so a third acquirer finds no predecessor and runs concurrently with the
second — the lost update the lock exists to prevent. Every existing case used
two parties on two different files, which is exactly where the identity guard
does not matter. Now pinned three-deep on one key.

Serializing the in-memory take on a host-mode append instead of re-reading the
file also passed everything, and silently resurrects a step the agent had just
hand-deleted. Editing the .yaml mid-recording is what both tools' descriptions
tell the agent to do, and the finish path's re-read is covered while the append
path's was not.

Also softened the LRU comment in flow-utils.test.ts. It claimed the case fails
an LRU keyed on a millisecond clock; that only holds when the fill and the touch
land in one millisecond, which is true for the file alone and false under
full-suite load — it survived 2 of 4 runs. FIFO is what this case rules out
deterministically, so that is what it now claims.
…s matcher

Three findings from review.

The flow-file lock serializes writers, but every reader stays outside it —
flow-execute's own load, its `run:` fragment load, flow-read-prerequisite,
flow-add-step's sibling check, and the CLI reading from another process, where
an in-process lock cannot reach. `appendStep` and flow-start-recording both
persisted with a plain `fs.writeFile`, which opens O_TRUNC, so a reader could
land between the truncate and the write. That case is silent: parseFlow("")
returns { steps: [] } with no error, and summarize derives `ok` from "no
failures", so a flow-execute racing an append reports a top-level PASS having
replayed zero steps. Both writes now go through a sibling temp file and a
rename, so a reader sees either the whole old file or the whole new one. The
added reader test observes zero-step reads against the old write and none
against the new one.

`unmatched` diagnosed real device ids as typos whenever their services lived
outside PREFIXES. An iOS session that only ran boot/launch/describe owns
`AXService:<udid>` and nothing else, so the mandated session-end call both left
the in-sim ax daemon (spawned --timeout 3600) running and reported a correct
UDID as a mistyped one. AXService joins the namespace set — its `:tcp` shape was
already covered — and the description now admits that a serviceless device
(Vega, driven entirely by CLI/adb shell-outs) lands in `unmatched` legitimately.

The device→services mapping had become two implementations that disagreed:
stop-simulator-server looked URNs up with an exact case-sensitive
`services.get()` and no `:tcp` handling, so a lower-cased UDID silently no-op'd
there while the scoped stop-all reaped it. Both now share one matcher in
device-services.ts. The namespace sets stay deliberately different, documented
where they are defined: stop-simulator-server is the recovery path for a wedged
transport, and widening it to devtools/AX would make a routine retry drop the
native-devtools connection another agent's recording depends on.
…only implied

A swarm review pass over the previous commit. Every finding below was
reproduced before the fix and re-run after.

stop-all-simulator-servers still misreported real device ids as typos, for the
same reason AXService did: three more namespaces are keyed by a device and
cascade from nothing, so a session that used them owned services the tool could
not see. ScreenRecordingSession holds an ffmpeg child and the touch-visualizer
overlay it enabled on the device; NativeProfilerSession holds an xctrace child,
or an on-device perfetto process and its trace file; JsRuntimeDebugger holds a
bound loopback server, the CDP socket to Metro and a log handle. The debugger
URNs also interpose the Metro port (`<ns>:<port>:<id>`), so matching them as
`<ns>:<id>` attributed them to no device at all — the matcher now knows both
shapes, consuming only the first colon so a wireless adb serial after the port
still compares whole.

The atomic write introduced two regressions of its own. Its scratch name was
derived from the flow file's basename, which has no length cap, so a long flow
name that appended fine before now failed ENAMETOOLONG — the name is fixed-length
now. And the cleanup guard started after the temp file was created, leaking a
scratch file into the user's committed .argent/flows/ on any write that failed
after opening.

requireRecordingSession ended with "Call flow-start-recording first." That is
reached for a key that was finished, superseded, or dropped by the concurrency
cap as well as one never started, and in those cases the flow file on disk is
fully populated while flow-start-recording truncates unconditionally and reports
no `restarted`. The same doctrine assertSessionStillLive already follows now
applies here.

Tests: several passed against implementations they claimed to reject. The
stop-simulator-server narrowness guard used a udid that classifies as android,
where the iOS-only services it guards can never appear, so widening the iOS
branch — the exact regression it exists to catch — kept it green. The
client-mode finish asserted only the shape of `savedTo`, never its content,
though in client mode that directive is the only thing that lands the file; an
empty body satisfied every other assertion. Adds coverage for the swap-failure
branch, the not-found message's root normalization, the superseded-echo wording,
and concurrent recordings in client mode, which had none.

Descriptions: teardown of a mid-recording devtools session is not silent (every
degraded capture warns in the returned message), a TERMINATING node is not
disposed (the registry early-returns), `devices: []` stops nothing, and
stop-simulator-server also reaps TV-control daemons while deliberately leaving
devtools alone.
`stop-all-simulator-servers` declared `devices` on a plain z.object, so zod
stripped any unrecognised key before execute ran. `udids` is the natural slip —
every sibling tool in this directory spells the device parameter `udid` — and
that typo left `params.devices` undefined, which is the machine-wide sweep: the
caller tore down every other agent's devices believing it had scoped, with
`unmatched` unreachable on that path so nothing in the response said otherwise.
`.strict()` turns it into a validation error and puts `additionalProperties:
false` in the schema advertised by GET /tools, so MCP, `argent run` and raw HTTP
callers all get the rejection.

`assertSessionStillLive` branched its `why` clause on whether the key is held by
another session or by nothing, but always asserted "This key now belongs to
another take" in the recovery clause. On the empty-key branch that is false by
construction, so an agent whose recording was ended by its own finish, or by the
concurrent-recording cap, was sent looking for a competing agent that does not
exist — and in the eviction case it buried the actionable cause named one clause
earlier. The recovery now branches too, and both branches are pinned.

Also pins the touch inside `appendStepToFlow`, which nothing separated from the
touch on resolve: a step that takes minutes on a device would otherwise leave
its own session least-recently-used for that whole time, so the next
flow-start-recording anywhere on the host evicted the recording that had just
appended.

The rest is documentation that had drifted from the code it describes:

- flow-finish-recording claimed flow-add-step "detects that case and fails
  loudly". It only detects a takeover landing while a step is mid-flight;
  between calls it appends into the other agent's take and reports success — so
  a run of successful results read as evidence the key was still yours.
- stop-simulator-server claimed it leaves the debugger running. True on
  iOS/Android/TV; on Chromium the JS-runtime debugger declares the CDP session
  as a dependency, so the transport stop cascades to it.
- The argent-create-flow skill quoted a "Call flow-start-recording first"
  message the server no longer emits — and which the PR removed precisely
  because that advice truncates a populated file.
- The recordings-map comment claimed concurrent agents "never see each other's
  state", contradicting the deliberate, bounded disclosure in the not-found
  path a few lines below.
- The eviction backstop pointed at assertSessionStillLive for an append issued
  after an eviction; that one fails in requireRecordingSession.
- The case-fold rationale enumerated three id spaces where the matcher accepts
  seven, omitting the only one that is an assumption rather than a guarantee.
- Two CLI fixture comments and one in flow-start-recording described what the
  code used to be, unresolvable without the diff.
…erved

A mutation pass over the new tests found every one of them load-bearing except
for a gap on the production side: deleting the whole `void held.then(...)`
self-cleanup block left the entire test/flows suite green. The *condition* was
already pinned — "still excludes a third acquirer" dies on an unconditional
delete — but not the delete itself, which has no other observable effect: a
retained lock entry behaves identically to a released one for every caller. So
a host-wide singleton server could accumulate one permanent entry per flow
anyone ever recorded with nothing to catch it.

Needs a test-only accessor, since the map is module-private and deliberately
invisible. The test asserts both directions, so it cannot pass by the map
simply never being used: the count returns to its baseline after three
record-append-finish cycles, and rises by exactly one while a lock is held.

Verified by deleting the block again — this test alone goes red.
…ocs claim

A restart reported `discardedSteps` from the superseded session's in-memory
flow, but in host mode it truncates the FILE. Hand-editing the .yaml
mid-recording is a documented workflow, so the two diverge: four steps on disk
were wiped and the agent was told it lost one. The count now comes from
`countStepsOnDisk`, and when that file cannot be read or parsed no number is
claimed at all — 0 would be the answer a genuinely empty take gives.

`ChromiumJsRuntimeDebugger` joins DEVICE_OWNED_NAMESPACES. It cascades from
`ChromiumCdp`, so it was already being torn down — silently, while
`NetworkInspector` and `ReactProfilerSession` cascade the same way and were
named. `stopped` is documented as the services that were live and got shut
down, so the reporting is now symmetric.

The rest is prose the code contradicts:

- Vega does not "register no service at all": DEBUGGER_TOOL_CAPABILITY declares
  `vega: { vvd: true }`, so a Vega device owns `JsRuntimeDebugger` and
  `NetworkInspector` once debugger-connect or a network-log tool has run.
- "Nothing here cascades" was denied 13 lines above it and again 18 below.
  Three blueprints declare getDependencies and all three are reachable by a
  cascade; listing them governs what `stopped` names, not whether they die.
- An iOS session that only ran boot/launch/describe owns `NativeDevtools` too —
  both bootIos and launch-app's iOS handler resolve it unconditionally — so
  AXService closes a daemon leak, not a total gap.
- `:tcp` is a shape `axServiceRef`/`nativeDevtoolsRef` can mint via `transport`,
  but no call site passes it and the remote host's forced-TCP decision happens
  inside the factory, after the URN is fixed. The coverage is defensive.
- The tool-server is a singleton per install bundle, not per machine
  (`stateFileForBundle` hashes the bundle path, autospawn takes a free port).
  Two installs hold two recording maps and cannot see each other; what survives
  there is the temp-file swap, not the lock.
- `argent flow list` does enumerate .argent/flows and filters on `.yaml` — that
  agreement is why a stray `.tmp` is invisible, not that nothing reads the dir.
- The stop-tools comments narrated a "before" that only ever existed on this
  branch: at the merge base stop-all took no device id at all.
- `renderToolArgs` referred to a "previous inline spelling" that exists only in
  the diff; the reason the body interpolates is now stated outright.
- The isolation this map provides is of the artifact, not of the fact that a
  recording exists — the header claimed the latter while its own cases pin the
  disclosure.

Tests: the client-mode case named for a superseded in-flight append restarted
between calls, so the next append re-resolved the key and succeeded — the guard
was never reached, and neutering it left the whole file green. It now parks an
append in its live sub-tool call across the restart, and is the only case in
that file that goes red when the guard is removed.
…om hiding a cascade

A five-lens sweep over the previous commit found the fixes had defects of
their own.

`flow-start-recording`'s new description asserted the disk semantics with no
mode qualifier, but the count only comes from disk in HOST mode — against a
remote client this host never sees the file, so it still counts the steps
recorded through the server and a hand-edit on the client is invisible. That
is verbatim the failure the disk fix was written to remove, still live on the
client path and now mis-promised. Qualified in the tool description, the skill
doc and `countStepsOnDisk` itself.

`stop-tools.test.ts`'s mock handed the tool the LIVE service map, while the
real `getSnapshot` copies each node. With the cascade added last commit, a
disposal retroactively rewrote the state the sweep was still iterating, so the
answer depended on map insertion order — an artifact production does not have,
and one that would give a future test a false red. The mock now copies and
recurses the way `_teardown` does, and the cascade case runs under both orders,
asserting membership rather than order.

Two Vega claims were wrong in the other direction: only boot and launch go
through the `vega` CLI (describe, screenshot and tv-remote are adb-only, and
say so in their own source), and "the only namespaces a Vega serial can match"
is falsified by ERROR nodes — `registry.invokeTool` does not enforce a tool's
capability, only the HTTP layer does, so a call that reaches the registry
another way leaves a node behind under whatever namespace it resolved.
Reproduced: `SimulatorServer:<vega serial>` in ERROR, matched and counted.

Also: the `devices` param's own `.describe()` still said "shared by every agent
on the host" 22 lines above the corrected sentence, and it ships in the schema
`GET /tools` advertises; the prose list of what gets stopped never mentioned
the network inspector or React profiler sessions, which `stopped` names; the
`ChromiumJsRuntimeDebugger` rationale landed inside the `PORT_KEYED_NAMESPACES`
literal, where acting on it would have moved the namespace into the wrong array
and matched no device at all.

`countStepsOnDisk` was public API covered only through two tool-level tests; it
now has direct cases for the distinction it exists to make — 0 for an empty
take, undefined for one that cannot be read.
Hubert Gancarczyk added 2 commits July 30, 2026 10:29
… does

The four recording tools had grown descriptions where the edge cases
outweighed the behaviour. flow-finish-recording spent most of its text on
the no-ownership-check takeover, and three tools explained `savedTo` at
length - which the client resolves to the flow file's path before the
agent ever sees it, so there was nothing there to explain.

flow-start-recording is back to the original four-line header: the
truncation paragraph is three words on the first line, and the return
line uses the "optionally { ... } if ..." form again, which is also more
honest than promising `discardedSteps` is always present.

Input constraints moved to the inputs - `name` now documents its charset
in its own describe, which is why the failure line no longer enumerates
validation errors. The long-form concurrency warning stays in
argent-create-flow/SKILL.md, where the workflow guidance lives.
…ema requires

The sensitive-input case built `flow-add-echo` params as `{ message }`
alone, which was the whole schema when it was written. Recordings are now
keyed by `name` + `project_root`, both required, and the completed message
names the flow — so against the current tool that fixture rendered
"Added note to flow undefined" and the assertion passed on a string no
real call can produce.

Pass the key, and assert the rendered line: the flow name is shown (it is
a constrained identifier, not caller free text) while the echoed message —
the part that is caller-authored — still stays out.

@latekvo latekvo left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Diplomat]: Ran a full end-to-end sweep of this branch at 6c682c3, then a second independent pass over it.

A tool-server built from the branch drove concurrent recordings across two project roots and a burst of 8 concurrent appends on one key (all ordered, no cross-contamination, both replays green); a separate process observed 1,593,459 reads of a flow file during 120 concurrent appends with zero empty or partial — against a real in-place O_TRUNC writer the same detector sees 695,532 empty reads out of 909,926, so it is not blind. Also exercised: client/remote persist mode (nothing written to the host), the run: composition capture and its cross-project warning, the MAX_RECORDINGS backstop, hostile name / project_root inputs, the scoped and unscoped teardown paths including a case-varied UDID and a bogus id, and the real argent run path for start/add-step/add-echo/finish plus --help. Every documented behaviour I could execute matched, including the No active recording … message verbatim, (plus N in other projects), discardedSteps counted from disk (4) and omitted on an unparseable file, and the honest caveat that starting over a committed flow wipes it with no restarted.

Suites, typecheck and prettier are clean: tool-server 3164 passed, argent-cli 278 passed. The 6 boot-device-hotboot.test.ts failures are pre-existing — identical at the merge base 7189333 on this Linux host.

The recording, locking and atomic-write machinery held up everywhere I pushed on it, and nothing below is a runtime defect. What survived verification is prose the code contradicts, plus four invariants the diff documents as load-bearing that mutation shows nothing pins. Each finding was reproduced, then adversarially re-checked; several candidates were dropped as unreproducible, out of scope, or already covered by an open thread.

description: `Stop all running simulator-server processes (iOS + Android), native devtools services, and Chromium CDP sessions, freeing their resources. Call this when your session ends or the user says they are done. Returns { stopped } — an array of URNs that were shut down. Fails silently if no servers are running.`,
description: `Stop the services a device owns - simulator-server processes (iOS + Android), native devtools, the iOS accessibility service, TV-control daemons, Chromium CDP sessions, screen recordings, native profiler sessions, and JS-runtime debugger sessions along with the network inspectors and React profiler sessions that ride on them - freeing their spawned processes, sockets and ports. Call this when your session ends or the user says they are done.
PASS \`devices\` with the device ids this session used — one tool-server serves every agent, subagent and CLI call using this argent install, and an unscoped call tears down THEIR devices too (a mid-recording devtools teardown degrades another agent's flow to brittle coordinate taps; that agent is warned, but its recorded steps are already the worse kind). Omit \`devices\` only when a machine-wide cleanup is what you actually want. Passing an EMPTY array scopes to nothing and stops nothing - it is not a way to ask for the machine-wide sweep.
Returns { stopped } - the URNs of the services that were actually live and got shut down; an ERROR node is disposed too but never appears there, so an empty \`stopped\` only means nothing was still running. { unmatched } lists supplied ids that own no service here, so a mistyped id - or a device NAME passed where an id was expected - does not read as a clean machine. It is NOT proof the id is wrong: a Vega device is driven through CLI/adb shell-outs, so one you only booted and drove with the remote registers no service and always lands here — as does a real device of any platform this session never started anything on. Present ONLY when \`devices\` was supplied AND at least one id matched nothing - absent on an unscoped call and when every id matched. Stopping the same device twice does not report it unmatched: ownership counts regardless of service state. Never throws.`,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Diplomat]: The description's closing sentence is Never throws., while the schema declared 34 lines above is .strict() — whose own comment says the udids slip now "fails loudly" and is "a validation error instead". Two statements in one file give opposite answers to whether this call can fail, and only the false one is delivered to an agent.

Driven against a tool-server built from 6c682c3, every dispatch surface errors on that exact typo:

POST /tools/stop-all-simulator-servers {"devices":["x"]} -> 200 {"data":{"stopped":[],"unmatched":["x"]}}
POST /tools/stop-all-simulator-servers {"udids":["x"]}   -> 400 {"error":"[{\"code\":\"unrecognized_keys\",\"keys\":[\"udids\"],...}]"}
registry.invokeTool("stop-all-simulator-servers", {udids:["ABC"]})
                                                        -> ToolExecutionError: Invalid params for tool "stop-all-simulator-servers"
argent run stop-all-simulator-servers --udids x         -> unrecognized_keys
MCP CallTool                                            -> { isError: true }

execute() itself genuinely never throws — _teardown swallows dispose() errors, and disposeService cannot raise ServiceNotFoundError for a URN read out of the snapshot it just took. The sentence is false only about the schema gate every caller must pass, which is the only failure surface an agent can observe.

Provenance puts both halves inside this PR rather than inherited: git log -S places Never throws. in 0dd0609 and .strict() in 2f5d342 — the later commit, titled "make the scope key strict, and correct what the docs promise", which audited and corrected seven other drifted claims and left this one standing. At the merge base the sentence read "Fails silently if no servers are running."

Across the 20 files carrying a description: template literal under packages/tool-server/src/tools/, Never throws appears only here; every other repo hit is JSDoc on an internal helper that sits behind no runtime schema.

// reports: a session that had a network inspector or a React profiler open is
// told those went away by name, rather than inferring it from the debugger
// line.
NETWORK_INSPECTOR_NAMESPACE,

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Diplomat]: NetworkInspector and ReactProfilerSession reach DEVICE_OWNED_NAMESPACES through the ...PORT_KEYED_NAMESPACES spread, and the block above documents their membership as load-bearing for what stopped names ("listed for what stopped reports: a session that had a network inspector or a React profiler open is told those went away by name"). Neither their membership nor their port-keyed URN grammar is pinned.

Mutation-tested in a private worktree at 6c682c3, test/stop-tools.test.ts:

baseline                                       Tests  49 passed (49)
drop both from PORT_KEYED_NAMESPACES           Tests  49 passed (49)      <- green
control: drop JS_RUNTIME_DEBUGGER_NAMESPACE    Tests   2 failed | 47 passed (49)
   x scopes the port-keyed debugger URNs to the right device
   x does not let a port-keyed URN's port be mistaken for a wireless-adb device id

The control is what makes this concrete rather than a coverage complaint: the suite can catch this class, it simply has no case for these two. grep -rn "NetworkInspector\|ReactProfilerSession" packages/tool-server/test/stop-tools.test.ts returns nothing, and DEVICE_OWNED_NAMESPACES is consumed only by the two stop tools, so nothing elsewhere pins it either. The same mutation against the full suite is byte-identical to baseline.

The reachable consequence is the reporting the block claims, not a leak or a false unmatched: Registry._resolve always inserts the JsRuntimeDebugger dependency node with the identical device-id payload, so an inspector or profiler node cannot appear without it and both still die via the cascade — they just stop being named in stopped, which is the one thing their membership is documented to buy.

* called in "host" mode. In "client" mode the file lives on the client's
* machine and this host cannot read it at all, so the in-memory copy is both
* the take and the only thing countable — the guarantee below does not carry
* across that boundary, and `flow-start-recording`'s description says so.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Diplomat]: This sentence points at a paragraph that no longer exists. Printing the advertised description from a build of 6c682c3:

node -e "const d=require('.../dist/tools/flows/flow-start-recording.js').flowStartRecordingTool.description;
         for (const w of ['client','remote','host','memory','boundary']) console.log(w, new RegExp(w,'i').test(d))"
client false / remote false / host false / memory false / boundary false

The description says nothing about the host/client split. git log -S places this JSDoc sentence in 7f918db, and shows 9c6c116 ("cut the recording tool descriptions back to what the tool does") — a later commit on this branch — deleting exactly the paragraph it refers to: "That holds when the project root is on the tool-server host; against a REMOTE client the host never sees the file, so there the count is of the steps recorded through this server and a hand-edit on your machine is not in it."

The caveat itself survives, but in a different artifact — packages/skills/skills/argent-create-flow/SKILL.md:134 carries it verbatim. So the defect is that the JSDoc names the wrong surface: a maintainer checking whether the client-mode meaning of discardedSteps is stated on the tool itself finds that it is not, and the cross-reference cannot be resolved by anyone who did not watch the two commits land.


expect(parsed.success).toBe(false);
// And the same rejection reaches MCP / `argent run` / raw HTTP callers,
// which validate against the advertised JSON schema rather than the zod one.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Diplomat]: The rationale names the layer that does not do the validating. All three callers POST the args verbatim and the tool-server validates with the zod schema — def.zodSchema.safeParse (http.ts:705) and definition.zodSchema.safeParse (registry.ts:126). Two of the three named callers are provably schema-blind:

  • argent run is handed the advertised schema including additionalProperties: false and accepts the unknown flag anyway. parseFlags(["--udids","SIM-1"], <that schema>) returns {"udids":"SIM-1"} with no error, and flag-parser.ts:254-256 says so outright: "We still accept unknown flags so tools can evolve their schemas without breaking the CLI; tool-server will return a 400 if invalid".
  • argent-mcp forwards params.arguments verbatim; CallToolRequestSchema.safeParse on {udids:['X'],totallyBogus:1} succeeds with the arguments preserved.

grep -rn 'ajv|json-schema-validat|@cfworker/json-schema' packages/*/package.json returns nothing — no JSON-schema validator exists anywhere in the request path.

The assertion the comment justifies also cannot fail independently of the safeParse line above it: the advertised additionalProperties: false is derived from the zod .strict() by z.toJSONSchema (packages/registry/src/zod-to-json-schema.ts), so the two cannot diverge.

// packages/tool-server/src/tools/flows/flow-add-step.ts. `name`
// and `project_root` identify which open recording the step
// belongs to and are required alongside `command`. All three have
// to be marked required here for the parser regression this test

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Diplomat]: The required array is inert on the path these tests exercise. In a private worktree at 6c682c3:

baseline (this file + run-help.test.ts)          Tests  6 passed (6)
required: []  ->  run-flow-add-step-payload      Tests  3 passed (3)

The regression this file guards also stays fully reachable and fully caught with required: [] still in place: re-introducing #452 by changing flag-parser.ts:157 from if (flag === "args" && properties.args === undefined) to if (flag === "args") gives Tests 2 failed | 1 passed (3). So "reachable at all" does not hold in the strong sense either.

grep -rn required packages/argent-cli/src/*.ts shows schema.required is read in exactly one place — formatSchemaUsage (flag-parser.ts:273,288), the help renderer, which this file never invokes — and parseFlags' own JSDoc states the caller is responsible for "validating required fields server-side". The fixture's requiredness is therefore free to drift from the real flow-add-step schema with no signal here, which is the opposite of what this note tells a maintainer.

// by `name` + `project_root`, so both are required alongside `command` and only
// `args` / `delayMs` are optional. Keep the fixture in step with that schema: a
// fixture marking fewer fields required renders help for a tool the server does
// not expose, and the mismatch passes silently.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Diplomat]: This file hard-fails on exactly the drift the comment says passes silently. In a private worktree at 6c682c3:

baseline                  Tests  3 passed (3)
required: ["command"]     Tests  1 failed | 2 passed (3)
  x test/run-help.test.ts:127
    expect(help).toMatch(/--name <value>\s+string \(required\)/)

The negative assertions (/--args <value>\s+string(?! \(required\))/) pin the array against added entries too, so it is guarded in both directions.

The timeline makes it wrong-when-written rather than stale: git log -S 'renders each required flag with the (required) marker' puts that test in f12388b, while git log -L 28,34 puts this wording in 2f5d342 — the later commit. The drift direction that genuinely goes unnoticed is the opposite one, where the real flow-add-step schema relaxes and this fixture does not; nothing here covers that.

? `Stopping simulator servers for ${devices.length} ${devices.length === 1 ? "device" : "devices"}`
: "Stopping all simulator servers";
},
completedMsg: ({ result }) => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Diplomat]: Both interaction formatters this diff rewrites are unpinned, including the case the comment three lines below names as the one that must not happen.

Flattened both to their unconditional forms (startedMsg at line 37 always returning "Stopping all simulator servers"; this one dropping the unmatched clause) in a private worktree at 6c682c3:

baseline  test/interaction-messages.test.ts test/stop-tools.test.ts   Tests  52 passed (52)
mutated                                                               Tests  52 passed (52)
baseline  full tool-server suite   Tests  6 failed | 3164 passed | 1 skipped (3171)
mutated                            Tests  6 failed | 3164 passed | 1 skipped (3171)

(the 6 are the pre-existing boot-device-hotboot.test.ts GPU-mode failures, identical at the merge base)

npm run build passes under the mutation as well, so typecheck is not a backstop — the now-unused params destructure is simply dropped. grep -rn for "Stopping simulator servers for", "matched no service" and "Stopping all simulator servers" each return exactly one hit, the source definition itself. test/interaction-messages.test.ts does carry this tool in its catalog, but asserts only expect(definition.interaction?.startedMsg).toBeTypeOf("function"), which a constant-returning arrow satisfies — so that file gives the appearance of coverage without pinning either string.

The consequence is the one stated at line 46: a regression dropping the unmatched clause renders a scoped teardown in which every id was mistyped as plain "Stopped 0 simulator servers" — byte-identical to a clean machine.

// Name the flow: recordings are concurrent, so several of these lines can
// interleave in one log and "flow recording" would not identify which.
startedMsg: ({ params }) => `Starting recording of flow ${params.name}`,
completedMsg: ({ params, result }) => {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Diplomat]: The restart branch is unpinned, even though the diff pins the equivalent claim on the result payload (restarted / discardedSteps). Two mutations in a private worktree at 6c682c3:

baseline   test/interaction-messages.test.ts test/flows/   Tests  608 passed (608)
(1) whole body -> `Started recording flow ${params.name}`  Tests  608 passed (608)
(2) keep the branch, drop the `discarded === undefined`
    sub-branch (always format a number)                    Tests  608 passed (608)
full tool-server suite, both mutations
    Tests  6 failed | 3164 passed | 1 skipped (3171)       (identical to baseline)

tsc --noEmit -p tsconfig.test.json exits 0 under mutation (1), so the mutated tree is a legitimately green build rather than a broken one.

Mutation (1) makes a destructive re-record announce itself in the client log exactly like a fresh start — the outcome this comment calls "the one outcome here worth reading twice". Mutation (2) is the case the sub-branch was added for: when the superseded file cannot be read or parsed, the line reports a step count that was never obtained.

* root must not throw here — that would divert a resolvable target into the
* keep-the-raw-step branch on the strength of an advisory comparison.
*/
function safeFlowsDir(root: string): string | null {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[Diplomat]: Neither state this helper exists for can occur. captureRunTarget runs only after await invokeSubTool(registry, ctx, params.command, args) has already resolved for command === "flow-execute", and two upstream guards reject every triggering input before that returns:

  • an absent or non-string args.project_root is rejected by Registry.invokeTool's definition.zodSchema.safeParse (registry.ts:125-129) before flow-execute's execute is entered, since it declares project_root: z.string();
  • a string root that getFlowsDir would reject is rejected by flow-execute calling that same function first — execute opens with resolveFlowFilePath(params, …) -> getFlowPath(params.project_root, …) -> getFlowsDir -> assertValidProjectRoot, unconditional and ahead of every early return.

Driven through the real Registry with the real createRunFlowTool + createFlowAddStepTool, a live host recording open and a sibling fragment present:

project_root absent     -> Invalid params for tool "flow-execute": expected string, received undefined
project_root number     -> Invalid params for tool "flow-execute": expected string, received number
project_root relative   -> project_root must be an absolute path (got "rel/dir")
project_root with ".."  -> project_root must not contain ".." segments
flow_file only          -> expected string, received undefined  (path ["project_root"])
control: valid root     -> Step added to "wrapper" flow    <- reaches captureRunTarget, records `run: helper`

Instrumenting the catch and adding a typeof ranIn !== "string" arm that appends a marker: test/flows/ runs 611 passed and the marker file is never created. flow_file cannot reach it either — resolveFlowFilePath computes getFlowPath(params.project_root, …) ahead of both the !params.flow_file and viaUpload escapes, and invokeSubTool forwards only { signal, toolInvocationId, recordChildInvocation }, so a nested flow-execute never receives ctx.fileInputs.

The JSDoc states a hazard — "a malformed root must not throw here — that would divert a resolvable target into the keep-the-raw-step branch" — that the code cannot experience, so a later change can be justified or blocked on it.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants