feat(flow): support concurrent flow recordings - #574
Conversation
latekvo
left a comment
There was a problem hiding this comment.
[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.
j-piasecki
left a comment
There was a problem hiding this comment.
Three findings from a full review pass (each independently verified against the working tree; already-reported items and PR-acknowledged limitations excluded).
latekvo
left a comment
There was a problem hiding this comment.
[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.
|
LGTM! I'll leave it for @latekvo |
latekvo
left a comment
There was a problem hiding this comment.
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.
latekvo
left a comment
There was a problem hiding this comment.
[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.
latekvo
left a comment
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
| 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. |
There was a problem hiding this comment.
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.`, |
There was a problem hiding this comment.
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.
| 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. |
There was a problem hiding this comment.
Another case of edge-case overfixation inflating a description to three times the size.
…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.
… 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.
16684ea to
6c682c3
Compare
latekvo
left a comment
There was a problem hiding this comment.
[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.`, |
There was a problem hiding this comment.
[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, |
There was a problem hiding this comment.
[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. |
There was a problem hiding this comment.
[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. |
There was a problem hiding this comment.
[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 runis handed the advertised schema includingadditionalProperties: falseand accepts the unknown flag anyway.parseFlags(["--udids","SIM-1"], <that schema>)returns{"udids":"SIM-1"}with no error, andflag-parser.ts:254-256says 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.argumentsverbatim;CallToolRequestSchema.safeParseon{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 |
There was a problem hiding this comment.
[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. |
There was a problem hiding this comment.
[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 }) => { |
There was a problem hiding this comment.
[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 }) => { |
There was a problem hiding this comment.
[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 { |
There was a problem hiding this comment.
[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_rootis rejected byRegistry.invokeTool'sdefinition.zodSchema.safeParse(registry.ts:125-129) before flow-execute'sexecuteis entered, since it declaresproject_root: z.string(); - a string root that
getFlowsDirwould reject is rejected by flow-execute calling that same function first —executeopens withresolveFlowFilePath(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.
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, everyargentCLI call and every project on the machine attaches to the same process, the sameRegistry, and those same three globals. HTTP requests are served concurrently with no serialization.Three failures followed, all reproduced against the pre-change code:
startRecordingSessionjust overwrote, so the first agent's nextflow-add-stepsilently appended into the other agent's flow file — in a different project.flow-finish-recording's schema was literallyz.object({}).resolveFlowFilePathcalledsetActiveProjectRoot, so aflow-executein project B rebound the global root mid-recording of project A. Already worked around, locally only, inflow-add-step.Approach
Recording state is now a
Mapkeyed 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-echoandflow-finish-recordingeach takename+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.resolveFlowFilePathis pure, which fixes defect 3 and retiresFLOW_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-recordinghave no device at all), an opaquerecordingId(an agent that loses the token cannot recover), and per-caller identity from the transport (ToolContextcarries 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, andflow-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 withFLOW_NO_ACTIVE_RECORDINGrather 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, itsrun:fragment load,flow-read-prerequisite,flow-add-step's sibling-fragment check, and theargentCLI reading from another process entirely, where an in-process lock cannot reach. Both writers used a plainfs.writeFile, which opensO_TRUNC, so a reader could land between the truncate and the write. That case is silent:parseFlow("")returns{ steps: [] }with no error andsummarizederivesokfrom "no failures", so aflow-executeracing 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-serversgets adevicesscopeEvery 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
devicesscope and reportsunmatchedids, 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
unmatchedturns any omission into an actively wrong diagnosis — a correct device id reported as a typo. The set now coversAXService(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) andJsRuntimeDebugger(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-servershared none of this and had drifted: it looked URNs up with an exact, case-sensitiveservices.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-serveris 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 thedevicesscope 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-executein 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:
AXServiceandNativeDevtoolsfor that UDID only, reported nounmatched, and left the second sim and the emulator running. Lower-casing the UDID matched; a genuinely bogus id was still reported.ScreenRecordingSessionand 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 runinpackages/tool-server(295 files, 3154 tests) andpackages/argent-cli(280),tsc --build,tsc --noEmiton 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-recordingtruncated the.yamloutside 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-recordingnever took the lock; itsawait fs.readFilewas a yield during which a concurrent append committed, leaving the reported summary disagreeing with disk (11-16 of 200)..yamlmid-recording is a documented workflow, soparseFlowcan legitimately throw — and the error told the agent to callflow-start-recording, which truncates the very take it would recover.unmatchedreported a device this session had just stopped, becausedisposeServicereturns a node to IDLE and keeps it.ip:port.Date.now(); ties at millisecond resolution let it drop the session that was just used.requireRecordingSessionended 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 norestarted.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.stop-simulator-servernarrowness 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 ofsavedTo, 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_rootkey is byte-stable across the file boundary (kind: "probe"passes it through unchanged), which is what keeps a remote recording addressable fromflow-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-recordingcannot detect that another agent took its key; it finishes whatever take is live and reports it as yours.flow-add-stepfails 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.mdstill describesflow-add-stepas appending to "the current recording", and names two tool ids that do not exist (flow-insert-echo/flow-runare file names; the ids areflow-add-echo/flow-execute). It is a submodule, so the fix cannot ride on this branch.A restart reported
discardedStepsfrom the superseded session's in-memory flow while it truncates the file. Hand-editing the.yamlmid-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.ChromiumJsRuntimeDebuggercascades fromChromiumCdpand was therefore already being torn down - silently, whileNetworkInspectorandReactProfilerSessioncascade the same way and were named.stoppedis 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 realgetSnapshotcopies, 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_teardownnow, and the cascade case runs under both orders.Prose the code contradicted, each verified against source before rewriting: Vega owns
JsRuntimeDebugger/NetworkInspectoronce 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 ownsNativeDevtoolstoo;:tcpis mintable but unreached in production; the tool-server is a singleton per install bundle, not per machine;argent flow listdoes 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-executeself-reference recurses past both cycle guards (run:is guarded, a nestedflow-executereseeds the stack); react-profiler artifacts collide host-wide on a second-resolution filename;stop-metrois port-scoped and kills another agent's bundler;debugger-reload-metro's HTTP fallback broadcasts to every attached client. A tool'scapabilityis enforced only by the HTTP layer, not byregistry.invokeTool, so a call that reaches the registry another way -flow-add-steptakescommandas a bare string - can resolve a service the tool declares it does not support, leaving an ERROR node behind (e.g.SimulatorServer:<vega serial>).