Skip to content

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

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

feat(flow): support concurrent flow recordings#574
hubgan wants to merge 60 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.

  • No recording tool can detect that another agent took its key. flow-add-step, flow-add-echo and flow-finish-recording all re-resolve the key on every call, so after a takeover they act on whichever take is now live and report success: an append lands in the other agent's file, and a finish finishes and clears it. The only case that fails loudly is the narrow one the liveness check covers - an append already IN FLIGHT when the restart lands. There is no caller identity to key ownership on (see the rejected alternatives above), so this is spelled out for the agent under "Pick a name unique to your task" in argent-create-flow 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 Outdated
@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 ❤️

Comment thread packages/tool-server/src/tools/flows/flow-add-step.ts Outdated
Comment thread packages/tool-server/src/tools/flows/flow-finish-recording.ts Outdated
Comment thread packages/tool-server/src/tools/flows/flow-insert-echo.ts Outdated
Comment thread packages/tool-server/src/tools/flows/flow-start-recording.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.

[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.

Comment thread packages/tool-server/src/tools/simulator/stop-all-simulator-servers.ts Outdated
Comment thread packages/tool-server/src/tools/simulator/device-services.ts
Comment thread packages/tool-server/src/tools/flows/flow-utils.ts Outdated
Comment thread packages/tool-server/test/stop-tools.test.ts Outdated
Comment thread packages/argent-cli/test/run-flow-add-step-payload.test.ts Outdated
Comment thread packages/argent-cli/test/run-help.test.ts Outdated
Comment thread packages/tool-server/src/tools/flows/flow-start-recording.ts
Comment thread packages/tool-server/src/tools/flows/flow-add-step.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.

[Diplomat]: Ran a full end-to-end sweep of this branch at 6c682c38, then three further independent passes over it, deduplicating against all 72 existing inline comments before reporting anything.

A tool-server built from the branch drove the real tools over HTTP: concurrent recordings of the same flow name across two project roots with interleaved bursts of appends (each YAML kept exactly its own steps, in order); a full record -> replay round trip against an Electron device, where the tap was captured as a portable tap: { id: go } selector and the replay changed the real DOM, read back through CDP rather than through the flow tree; double-finish; restart accounting; the MAX_RECORDINGS backstop; client/remote persist mode with nothing written on the host; the cross-project run: warning and its correct silence when the same root is spelled with a trailing slash; and the scoped, unscoped, empty-array and misspelled-key teardown paths including a case-varied id, a bogus id, an ERROR-state node and a re-stop of an already-stopped device.

Several documented claims I set out to falsify held up exactly as written and are worth recording as checked: the prerequisite-notice-still-records-run: caveat, the bare-string launch: + platform: chromium failure text (Electron boot: path does not exist), the No active recording … message verbatim, (plus N in other projects), discardedSteps counted from disk, and the unmatched case-insensitive dedup plus ownership-counts-regardless-of-state semantics. The device-id matcher also behaves as its rationale claims: a wireless adb serial matches whole, a bare IP does not claim it, a prefix id does not claim a longer one, and :tcp resolves for both namespaces that can mint it.

Suites, typecheck, eslint and prettier are clean: tool-server 3164 passed, argent-cli 278 passed; the 6 boot-device-hotboot.test.ts failures are pre-existing on this Linux host. The new concurrency tests ran five consecutive times under load 15 with no flakes.

The recording, locking and atomic-write machinery held up everywhere I pushed on it — I could not produce a lost update, a torn read, or a cross-recording leak. Most candidates died in verification as pre-existing at the merge base or as limitations the PR body already concedes. Seven findings survived: one behavioural (a recorded scoped teardown is not portable off the machine that recorded it), one invariant the diff documents as load-bearing that a mutation shows nothing pins, and five permanent comments the codebase now contradicts. A fourth sweep was cut short by an infrastructure limit rather than by convergence, so the comment-accuracy class in particular may still have a tail.

Comment thread packages/tool-server/src/tools/flows/flow-utils.ts Outdated
Comment thread packages/tool-server/src/tools/simulator/device-services.ts
Comment thread packages/tool-server/src/tools/flows/flow-finish-recording.ts Outdated
Comment thread packages/tool-server/test/flows/flow-concurrent-recording.test.ts Outdated
Comment thread packages/tool-server/src/tools/flows/flow-finish-recording.ts Outdated
Comment thread packages/tool-server/src/tools/simulator/device-services.ts Outdated
@hubgan
hubgan requested a review from latekvo August 3, 2026 13:23

@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.

Ran a full end-to-end sweep of this branch at a06aa6ad, driving a tool-server built from it over HTTP: concurrent recordings across projects and against one key, a hammering reader during appends, restart/supersede/finish races, the MAX_RECORDINGS backstop, client persist mode, scoped and unscoped teardown, the new devices record/replay binding, and the released CLI against this server. I also mutated the core invariants directly — the per-file lock, the temp-file+rename swap, touch()/LRU, .strict(), the lock-map self-cleanup, every entry of DEVICE_OWNED_NAMESPACES, the URN matcher, and both halves of the devices bind — and the suite caught essentially all of them.

The recording machinery itself held up everywhere I pushed on it: isolation, the per-file lock and the atomic swap all behaved as described, with no torn reads, no cross-contamination and no lost updates. With ARGENT_EMULATOR_GPU_MODE unset the full tool-server suite is 3186 passed | 1 skipped, zero failures. Nothing below is a defect in the concurrency design.

What did survive verification is two diagnostics that now misreport their cause, one reporting path that drops a computed field, three invariants this PR argues for but leaves unpinned, and five claims the code contradicts. Findings are inline.

// abandoned, so the video is a best-effort salvage rather than something a
// caller is waiting on: closing ffmpeg's stdin is what finalizes the container,
// so give that one short grace before SIGKILL. A caller that wants the file
// calls `screen-recording-stop`, which has its own (longer) finalize contract.

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]: Adding ScreenRecordingSession to DEVICE_OWNED_NAMESPACES makes dispose() reachable from an ordinary in-session tool call. This file's comments were updated for that; the guard that acts on it, session-guards.ts:129-140, still reports the only cause it had at the merge base.

At 7189333a the tool's PREFIXES were SimulatorServer / NativeDevtools / AndroidDevtools / ChromiumCdp / TvControl / AndroidTvControl; the namespace was absent, the blueprint declares no getDependencies, and no other disposeService call site targets it — registry.dispose() at process exit was the only route to dispose(). So api.disposed really did mean shutdown, and this message was accurate.

Driving the real blueprint and the real tool through a Registry at this head:

urn: ScreenRecordingSession:emulator-5554   disposed before: false
stop-all { devices: ["emulator-5554"] } -> {"stopped":["ScreenRecordingSession:emulator-5554"]}
disposed after : true
guard message  : The tool-server is shutting down; screen recording was not started on device emulator-5554.

The trigger is agent A's screen-recording-start sitting at a pre-spawn await (openMjpegStream / waitForFirstFrame, capture.ts:311/:319) when agent B's session-end teardown lands — which is the call rules/argent.md:80 instructs every agent to make. Agent A is told the tool-server is going away and that retrying is pointless; the actual cause is another agent's teardown and a retry would succeed. SCREEN_RECORDING_SERVER_SHUTTING_DOWN carries the same diagnosis into telemetry.

So this file and session-guards.ts now disagree about when dispose() runs. Four further sites in this family still say dispose means shutdown and are outside the diff: session-guards.ts:124-125, capture.ts:332-333, capture.ts:426-427, screen-recording-start.ts:163. I checked the equivalent files for the other newly-added namespaces (native-profiler, ax-service, js-runtime-debugger, network-inspector, react-profiler-session, chromium-js-runtime-debugger, tv-control) — none makes a when-does-dispose-run claim, so this file family is the whole class.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 73a72abassertNotDisposed no longer claims the server is shutting down. It names both causes (process shutdown, or a stop-all-simulator-servers reaping this device — commonly another agent's teardown) and no longer says a retry is pointless. The four sibling dispose comments in this file family were reworded to match; the SCREEN_RECORDING_SERVER_SHUTTING_DOWN enum is unchanged (noted in the comment as historical). Confirmed on-device: at a ~1ms init-window race the corrected message appears rather than the old one.

// fail with the file already created (ENOSPC, EIO), so this has to cover it
// too — nothing else ever sweeps this directory.
await fs.rm(tmpPath, { force: true }).catch(() => {});
throw err;

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]: A failed swap surfaces an errno against the internal scratch file, which by then no longer exists, and never names the flow it was recording.

Live, against a tool-server built from this head, with .argent/flows at mode 500:

$ curl -sX POST .../tools/flow-add-echo -d '{"name":"ro","project_root":"…/proj","message":"m"}'
{"error":"[Tool:flow-add-echo] EACCES: permission denied, open
 '/tmp/…/proj/.argent/flows/.argent-flow-2761411-90.tmp'"}

$ ls -a …/proj/.argent/flows
.  ..  ro.yaml            <- the file the message names does not exist

The cleanup at :2229 has already removed it, the name appears in no agent-facing doc, and the suffix is a process-global counter so it differs every run. The flow name, the flow file, and the directory whose permissions are the actual cause are all absent.

This shape is new here. At the merge base appendStep did fs.writeFile(filePath, …), which opens the existing file and needs no directory write permission:

merge-base in-place write of s.yaml: OK
head scratch-file write:            FAIL EACCES … '.argent-flow-1-1.tmp'

The behavioural half of that trade-off is disclosed at :2210-2213 ("it needs write permission on the DIRECTORY rather than on the file"); what the failure then says is not.

Same shape at flow-start-recording, flow-add-step and on a restart over a live take. On flow-add-step the sub-tool has already run on the device when this fires, and this branch says nothing about it — while the sibling failure branch added by this same PR (assertSessionStillLive, :2344-2349) goes out of its way to say "the step itself already ran on the device — repeating it repeats that action", and the suite pins that distinction.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 47cbb4a — a failed swap now rethrows FLOW_FILE_WRITE_FAILED naming the flow file and its directory (the real cause), keeping the errno as cause; the internal .argent-flow-*.tmp path is no longer surfaced. Reproduced on-device with the flows dir at mode 555: the append fails with the flow-named message and (EACCES), and no .tmp is left behind. Pinned by the write-half test in this PR.

// mid-recording is part of the take and the session's in-memory copy
// only catches up on the next append (see {@link countStepsOnDisk}). In
// client mode this host has no file and the in-memory copy IS the take.
const previous = getRecordingSession(params.project_root, params.name);

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]: discardedSteps and restarted are read from the map at two different moments, and an eviction landing between them makes a destructive restart report itself as a fresh start.

discardedSteps comes off getRecordingSession(...) here at :132; replaced comes off startRecordingSession's own recordings.get(key) at :147. In host mode two awaits sit between them — countStepsOnDisk and writeNewFlowFile. That window is inside this key's lock, which excludes the other flow tools but not evictIfOverCapacity, which runs under some other key's lock. When it fires there, replaced is null, the if (replaced) branch at :161 is skipped, and the already-computed count is discarded — after the file has been truncated.

Reproduced deterministically (32 host recordings, the victim as LRU, its restart parked inside its own lock on countStepsOnDisk's readFile, one further flow-start-recording released during the gate):

RESULT:      {"message":"Started recording \"rec-0\" flow", ...}
FILE BEFORE: "steps:\n  - echo: real step\n"
FILE AFTER:  "steps: []\n"
  × reports a destructive restart as a plain fresh start
    AssertionError: expected undefined to be true

The expect(after).not.toContain("real step") assertion passed — the take really was destroyed — while restarted came back undefined.

Client mode is unaffected: clientFileDirective is synchronous, so the two reads cannot diverge there.

Stating the reachability plainly rather than overselling it: it needs the cap reached, the restarted flow to be exactly the LRU, and the eviction to land in a sub-millisecond window, and requireRecordingSession restamps on every append so the documented re-record workflow keeps the key near most-recently-used. What makes it worth a note anyway is that the cap is reachable without concurrent agents — clearRecordingSession has exactly one production caller (flow-finish-recording.ts:122), so a recording started and never finished stays in the map for the life of the process.

The claim this falsifies is the tool's own description at :82 — "optionally { restarted, discardedSteps } if a live recording of the same flow was discarded" — which reads as a sufficient condition. SKILL.md:134 covers the neighbouring case (starting over a committed file with no recording in progress) but is a necessary-condition statement, so it does not reach this one.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 73a72abrestarted and discardedSteps are now both read once from the in-lock getRecordingSession, so an eviction landing between startRecordingSession's own read and the register can no longer report a destructive restart as a plain fresh start. Pinned by a new test that parks the restart on its countStepsOnDisk read, evicts the key, then releases; mutation-verified — reverting to startRecordingSession's return turns it red.


expect(messages.join("\n")).not.toContain(secret);
expect(messages).toContain("Opening example.com");
expect(messages).toContain("Added note to flow checkout");

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]: Ten of the twelve interaction formatters on the four recording tools render strings that nothing asserts, so they can revert to the merge-base unqualified wording with the suite green.

This PR rewrote all twelve so each names the flow, with the rationale stated verbatim in three of the four files: "recordings are concurrent, so several of these lines can interleave in one log and 'flow recording' would not identify which." Asserted anywhere at this head:

flow-start-recording.completedMsg   interaction-messages.test.ts:90,97,110,123
flow-add-echo.completedMsg          interaction-messages.test.ts:157

Unpinned: flow-start-recording startedMsg/failedMsg, flow-add-echo startedMsg/failedMsg, and all three of flow-add-step and flow-finish-recording.

Reverting just flow-finish-recording's two to name-free wording (Finishing flow recording / Saved recorded flow), leaving the other eight intact:

$ npx vitest run test/flows test/interaction-messages.test.ts
  Test Files  33 passed (33)
       Tests  615 passed (615)

Flattening all ten at once leaves the full package suite byte-identical to baseline (Tests 3186 passed | 1 skipped). Nothing outside these files reads the strings — grep -rn over --include=*.ts --include=*.md, excluding node_modules/dist, returns only the definitions and the assertion at :157. Note that :157 pins its formatter only incidentally, as a side effect of a secrets test asserting toContain("Added note to flow checkout").

The consequence lands hardest on the failure lines, which are the diagnostic when several recordings are live: Failed to add note to flow alpha: FLOW_NO_ACTIVE_RECORDING says which recording died; Failed to add note to a flow does not.

Noting the scope deliberately: this class was raised twice before — #3683739051 on stop-all-simulator-servers and #3683739066 on flow-start-recording.completedMsg — and each was pinned at exactly the site named, stop-all's two now fully covered at stop-tools.test.ts:967-1021. These ten are the remainder, and the a06aa6ad replacement for #3702324470's flow-finish-recording.completedMsg is itself among them.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 2bd1937 — added names the flow in every recording-tool interaction line, pinning all twelve formatters (the ten that were unasserted plus the two already covered) to naming the flow, including the failure lines. Non-vacuous: dropping the name from any formatter fails it.

// so it is always the same one.
await fs.rename(tmpPath, filePath);
} catch (err) {
// Leave no scratch file behind, whichever half failed. The write itself can

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 half of this guard the comment singles out as load-bearing is the half nothing tests.

The comment states it outright — "The write itself can fail with the file already created (ENOSPC, EIO), so this has to cover it too" — and the PR body lists it as a regression a later pass caught.

Moving the try back to begin after fs.writeFile, i.e. the exact pre-fix shape:

  await fs.writeFile(tmpPath, content, "utf8");
  try {
    await fs.rename(tmpPath, filePath);
  } catch (err) {
$ npx vitest run test/flows test/stop-tools.test.ts
  Test Files  33 passed (33)
       Tests  669 passed (669)

(and the full package suite is likewise byte-identical to baseline under that mutation)

The neighbouring case propagates a failed swap and leaves no scratch file behind does fail when the whole try/catch is deleted, so the guard is not unprotected as a whole — but its own comment says its trigger reaches fs.rename "and nothing else", and it explicitly rejects the read-only-directory trigger. That is the point: a read-only directory fails at the open, before the file exists, so no filesystem-level trigger in the suite reaches the write-half. The live trigger the guard names (ENOSPC, EIO, file already created) has no case.

What is left behind on the untested path is a .argent-flow-<pid>-<n>.tmp in the user's committed .argent/flows/, which nothing in the tree sweeps.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 2bd1937 — added a test that drives the write-half directly: it makes fs.writeFile create the temp file and then throw (ENOSPC-shaped), and asserts the scratch file is swept and the surfaced error names the flow file. The rename-half case is separate, so the guard's write-half no longer rides on the success path.

* legitimately run to NAME_MAX — and prefixing that with a discriminator would
* push the scratch name past the limit, turning an append that used to work
* into ENAMETOOLONG. pid + counter is unique on its own: the counter separates
* writers inside this process, the pid separates this process from any CLI

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 pid's stated purpose is a writer that never mints a scratch file here, while the writer it actually guards against is documented 2,000 lines above.

Exactly one site in the repo mints .argent-flow-<pid>-<n>.tmp — this one, in the tool-server. The CLI does write a flow YAML in client persist mode (argent-tools-client/src/file-inputs.ts:265-278, gated by isAllowedClientFilePath to .argent/flows/), but it writes the destination file directly and creates no temp file; and host and client modes are mutually exclusive per call (flow-start-recording.ts:117), so whenever the tool-server is writing a scratch file into that directory, no CLI is writing there at all. The pid separates nothing from a CLI.

The second writer it does separate from is a second tool-server from a second install bundle — named in this same file at :176-183, which states the temp-file swap is the only remaining protection in that configuration. flowWriteSeq is module-global and starts at 0 in every process, so without the pid two such servers compute the same scratch path; one writer's writeFile is overwritten by the other, and the losing rename either lands foreign content in a flow file or fails ENOENT on a path it just created.

The constant is also undefended. Dropping ${process.pid} from the scratch name:

$ npx vitest run test/flows
  Test Files  32 passed (32)
       Tests  611 passed (611)

The combination is what makes this worth raising: this paragraph is preoccupied with keeping the scratch name short for the ENAMETOOLONG budget, and it tells the next person the pid only guards a CLI. The same attribution appears at :2176-2177.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 47cbb4a — the pid's purpose is corrected: it separates this tool-server from a second install bundle writing the same directory, not from a CLI (which writes its destination flow file directly, mints no scratch file, and is mutually exclusive with host mode per call). Both the counter comment and the ENAMETOOLONG paragraph now say so.


/**
* For a recorded `flow-execute` call, decide whether to record it as a
* `run: <name>` directive. Returns the flow name to compose, or a warning

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 contract says the two results are alternatives, and since this branch added the cross-project warning they can be returned together.

The or reads as exclusive — a flow name to compose, or a warning explaining why the raw step was kept. At :201-206 the resolved branch returns both, and when the warning is set the step was not kept raw at all; it was rewritten to run:.

Driven through the real registry and the real tools (recording wrapper in project A, mid-recording flow-execute running a same-named sibling helper from project B):

message: Step added to "wrapper" flow — recorded "run: helper", which replays THIS project's
         helper.yaml — the step ran helper from /tmp/…/projB, a different project
--- recorded YAML ---
steps:
  - run: helper

The return was { flow: "helper", warning: … }.

The JSDoc sentence predates this branch (551c781f); ranElsewhere is new here, and the diff adds a sibling comment at :302-305 acknowledging the new both-case — "A resolved target can still carry a warning … so this branch surfaces it too" — 145 lines below the contract that still says otherwise. A maintainer reading the contract would conclude flow and warning are mutually exclusive and that the runTarget?.flow branch's warning = runTarget.warning is dead.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 5313367 — the captureRunTarget JSDoc now states that flow and warning are not exclusive: the resolved branch returns both when a same-named sibling ran in a different project (the step is still rewritten to run:; the warning only flags the ambiguity).

];
const zodSchema = z
.object({
devices: z

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 new key is a third spelling of a device-carrying parameter, and the enumeration that decides what counts as one lists two.

http.ts:728-732 states it: "Tools spell the device parameter two ways — udid (legacy iOS-only tools and gestures) and device_id (debugger / profiler / network tools)."

extractDeviceArg (http.ts:129-135) reads only those two, so a {"devices":["…"]} call returns null.

Two more statements are falsified the same way, both outside the diff:

  • http.ts:189 — "Telemetry platform from a tool call's device arg, or null when it carries none." A scoped teardown carries one and yields null.
  • packages/registry/src/types.ts:113-115 — "re-derived from each sub-tool's own childArgs (its udid / device_id / avdName), falling back to the outer request's platform when the sub-tool carries no device arg". bindDeviceArgs (flow-device.ts:149) now injects devices:[deviceId] into a replayed stop-all-simulator-servers step, so that child does carry one and still falls back.

Effect today is confined to telemetry platform attribution — stop-all-simulator-servers declares no capability, so the gate at :734 is not reached — but this comment is the enumeration a future device-arg-carrying tool would be checked against.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 5313367extractDeviceArg now reads the devices spelling (first element, for the coarse platform), and the three enumerations name all three spellings: the capability-gate comment, platformFromArgs, and recordChildInvocation in registry/types.ts. A replayed stop-all step (which bindDeviceArgs injects devices into) now resolves its platform rather than falling back. The capability gate is unaffected — that tool declares no capability, and udid/device_id are checked first, so no capability-bearing tool can reach the devices branch.

const registry = createMockRegistry(services);
const tool = createStopAllSimulatorServersTool(registry);

// Upper-cased id against a lower-cased URN AND vice versa: passing the

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 comment describes coverage the case does not have, and its counterfactual does not hold either.

The body passes one spelling: devices: [MINE] at :510, with MINE = "AAAA-1111". The pairs exercised are upper-id against upper-URN (SimulatorServer:${MINE}) and upper-id against lower-URN (NativeDevtools:${MINE.toLowerCase()}:tcp). There is no "vice versa" — no lower-cased id is ever passed.

The counterfactual then inverts: passing MINE.toLowerCase() would give lower/upper and lower/lower, so there would be no "upper/upper pair" left matching. Running exactly that mutation:

# devices: [MINE]  ->  devices: [MINE.toLowerCase()]
$ npx vitest run test/stop-tools.test.ts -t "matches the device id case-insensitively"
  Test Files  1 passed (1)
       Tests  1 passed | 57 skipped (58)

Both spellings are exactly as strong, so the reason given for preferring this one does not distinguish them. (The lower-id/upper-URN direction is genuinely covered, but by :937-953, not here.) The cost is to whoever edits this case next: the comment reads as a reason not to touch the spelling, and the reason is not real.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 2bd1937 — the comment now describes what the case actually covers (upper-id vs upper-URN, and upper-id vs lower-URN) and notes the reverse direction is covered by a separate case, dropping the incorrect "vice versa" and its counterfactual.

// A device-owned namespace like any other: a session that ran
// debugger-connect against a Chromium app owns it, and the sweep drains
// it whether or not its transport happens to be in the same snapshot.
["ChromiumJsRuntimeDebugger:CCC", { state: ServiceState.RUNNING, dependents: [] }],

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 slot held the file's only negative control for namespace scoping, and converting it to a positive one leaves the unscoped sweep's namespace filter unpinned everywhere.

At the merge base the same map held ["JsRuntimeDebugger:CCC", RUNNING] — a URN whose namespace was outside PREFIXES — and asserted it absent:

$ git show 7189333a:packages/tool-server/test/stop-tools.test.ts | sed -n '137,150p'
      ["JsRuntimeDebugger:CCC", { state: ServiceState.RUNNING, dependents: [] }],
    …
    expect(result).toEqual({
      stopped: ["SimulatorServer:AAA", "SimulatorServer:BBB"],
    });
    expect(registry.disposeService).toHaveBeenCalledTimes(2);

It is now ChromiumJsRuntimeDebugger:CCC, asserted present, with three dispose calls. Every URN in every stop-all case is a member of DEVICE_OWNED_NAMESPACES, so nothing left says the machine-wide sweep is namespace-scoped at all:

# device-services.ts:218  isDeviceServiceUrn -> `return true;`
$ npx vitest run test/stop-tools.test.ts test/flows
  Test Files  33 passed (33)
       Tests  669 passed (669)

and the whole package suite is byte-identical to baseline under that mutation.

Nothing leaks today, because all 13 registered blueprints happen to be device-owned. But isDeviceServiceUrn is the single guard between stop-all-simulator-servers — the call rules/argent.md:80 instructs every agent to make at session end — and any future non-device service in the registry, and device-services.ts has no test file of its own. Breaking that guard, or adding a namespace to the list by mistake, now produces no signal.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Fixed in 2bd1937 — restored a negative control (leaves a service whose namespace is not device-owned untouched): a synthetic out-of-set URN the unscoped sweep must not dispose. Degrading isDeviceServiceUrn to return true now fails it, so the sweep's namespace filter is pinned again.

}

/** One human-readable line per recorded step, in the flow file's own spellings. */
function summarizeSteps(flow: FlowFile): string[] {

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.

Wait, i'm pretty sure we JUST added human-readable descriptions to every step in another place. See #582

Doesn't summarizeSteps duplicate that mechanism 1:1???

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Not really. summarizeSteps is not a new code. It was already existing before this PR and is currently on main branch. Is is the same step-summary const summary = flow.steps.map(...) block that has been in this file. I just moved it and close into the summarizeSteps function. The function body has not been changed in this PR.

The PR you mention and this function do different jobs that cannot be folded together.
The #582 emits one message per tool call, and it goes to the event log.
summarizeSteps function emits one line per recorder step and it goes into what flow-finish-recording tool returns to the agent.

One thing that I find is that this PR overlaps a little bit with #568 when it comes to getFlowsDir(projectRoot) / flowsDirFor(root) functions overlap. This is something I'll fix.

@hubgan
hubgan force-pushed the feat/concurrent-flow-recordings branch from d326f82 to c6fbf40 Compare August 6, 2026 08:07
hubgan added 16 commits August 6, 2026 14:38
The tool description was strengthened to "Absent that note, empty really does
mean the app has logged nothing", and the tool documents itself as working
against Hermes (iOS / Android / Vega) and V8 (Chromium). Only the Hermes
blueprint recorded the breadcrumb; chromium-js-runtime-debugger's dispose
deletes its log file and recorded nothing — and this PR added
ChromiumJsRuntimeDebugger to DEVICE_OWNED_NAMESPACES, so the teardown now
reaches it by name. Destroyed console history therefore read as "the app logged
nothing" on V8, which is the wrong conclusion to hand an agent debugging a
silent app.

Record the same breadcrumb on that side rather than weakening the sentence. One
id there, not two: a chromium device's logicalDeviceId IS its device id.

Also fixes the consume: the disposer writes ONE event under two keys so either
spelling can read it back, but `take(canonical) ?? take(raw)` short-circuited
and spent only the key that matched. The survivor then attached a stale
explanation to a later, unrelated empty read, against the report-once invariant
the breadcrumb store states. All of the device's ids are now spent on that one
read — including the logical id, which after `forgetDeviceAlias` only the
freshly resolved api still knows.

teardown-log-history only ever connected with the logical id, so
`api.logicalDeviceId === deviceId` and the two-key write never fired — the
Chromium/Vega shape, not the iOS/Android one. It now covers the differing-id
case from both spellings.

Verified over HTTP against a tool-server built from this branch, driving a
throwaway Electron app: 18 entries captured, torn down via
stop-all-simulator-servers, reconnect reports totalEntries 0 WITH the note and
the second read is silent; same via stop-simulator-server with 29 entries.
`writeNewFlowFile`'s `mkdir` sat outside the FailureError wrapping, so only half
of what the tool description promises — "fails if the `.argent/flows/` directory
cannot be created OR the flow file cannot be written" — was actually kept. A
`project_root` naming an existing file, or an unwritable one, returned
REGISTRY_TOOL_EXECUTION_FAILED with a bare `ENOTDIR`/`EACCES` and no remediation
hint, while the same permission problem one line later returned
FLOW_FILE_WRITE_FAILED with one. Telemetry attributed the first to the registry
rather than to flows.

Its hint is its own rather than the swap's: `mkdir -p`'s surprising failure is a
path COMPONENT that is not a directory, which for a caller-supplied project_root
almost always means it named a file — so that case says so, instead of
explaining that a rename needs directory permission.
…at failed

`writeFailureHint` used `path.dirname(filePath)` while the temp file and the
rename use `path.dirname(realpath(filePath))`. For a flow file that is a symlink
into a shared vault those are different directories, and only the second can be
the cause — so a 0755 flows dir holding a link into a 0555 vault produced "…so
<project>/.argent/flows must be writable", naming a directory that already is,
while the vault went unmentioned.

Take the resolved target the swap actually uses, and say outright why it is not
the directory the reader expected when the two differ: "your flows dir is fine,
the link target is not" is the whole diagnosis there.
`setActiveProjectRoot` ran unconditionally at the top of `resolveFlowSource`,
and its body is exactly today's `assertValidProjectRoot`. Deleting it left the
only surviving check inside `getFlowPath`, which the `name` branch alone
reaches, so relative and ".."-bearing roots now resolved on the `flow_path`
branch — and the JSDoc's "Name and project_root are validated in every branch"
stopped being true.

No exploit today: project_root is unused on that branch, and `flow_file` is
`skipWhenSet: flow_path`. That is why the guardrail is pinned by a test rather
than left to be re-derived by whoever next reads project_root there.

Also fixes flow-add-step's citation of `setActiveProjectRoot` — a function this
PR deleted, and the last reference to it in the repo — to name the check that
actually backs the claim.
…laim

`http.ts`'s "Latent today: BOTH consumers of this function are gated on the tool
declaring a capability" was wrong on both counts. `extractDeviceArg` has three
call sites and only the capability gate is gated: `emitHttpFailure` classifies a
rejected call straight from `req.body`, and `platformFromArgs` via
`deriveChildInvocationMeta` attributes a sub-tool from its own args. The
`devices` branch is therefore live today, which is what `registry/src/types.ts`
— added by this same PR — already said. The claim was repeated at the capability
gate and in http-tools-meta, and both now say what is actually true of each.

`deriveChildInvocationMeta`'s own doc still named `udid` as "the only correct
source" while its parallel comment in registry/src/types.ts had been updated to
include `devices`.

flow-utils' atomic-swap rationale was orphaned: commit c95ffe2 inserted
`writeFailureHint`'s JSDoc immediately after it, so the swap rationale sat on an
errno-string builder while `writeFlowFile` had none and two
`{@link writeFlowFile}` references pointed at an undocumented symbol. Moved back
onto the function it describes, with the "sibling temp file" wording corrected —
the sibling is the resolved TARGET's, which for a symlinked flow is the vault,
and that pairing is what makes rename(2) atomic. Its "the one thing that
enumerates this directory" also undercounted; the conclusion holds (every site
filters on .yaml + FLOW_NAME_PATTERN) but the count did not.

`flow-start-recording`'s description asserted unconditionally that it creates
the .yaml and fails if the directory cannot be created, which client persist
mode does not do — the tool's own code says so at :181-185.

Two tests that pinned nothing now pin something: http-tools-meta's "ignores a
devices list that holds no usable id" never sent a non-string element, so
deleting the `typeof devices[0] === "string"` guard left the suite green
(verified: it now fails), and the ungated failure-classification path that makes
the branch live had no coverage at all.
Every case below was verified to FAIL against the code it describes before being
kept, by mutating that code and re-running — the same check that showed the two
it replaces were pinning nothing.

- `hadUnretrievedCapture`'s other two arms. Only `recordingActive` was covered;
  `pendingRetrieval` (the likeliest real sequence — the cap fires, the video is
  finalized and waiting, the teardown lands in that window) and `startPending`
  (a start still mid-readiness) both owe the caller a video and now say so.

- `wasLive` for STARTING and TERMINATING in the stop-all sweep. Only RUNNING and
  ERROR were exercised, and these are the two arms that decide whether a caller
  is told their device was reaped.

- The `unmatched` de-duplication across CASE variants. It lowercases to match
  the lookup, but every existing case repeated an id in one spelling, so
  mutating `seen` to identity kept all 61 stop-tool tests green.

- What the mock registry's cascade recursion is actually for. The case that
  claimed to pin a cascaded dependent matched the device directly, so removing
  the recursion changed nothing; it is reframed as the insertion-order case it
  really is, and a dependent this tool does NOT match by device is added — it
  dies with its dependency but must not be reported in `stopped`.

- `reaped-sessions`' key semantics, which no test reached: kind-scoping (one
  teardown reaps all three of a device's capture services, and each owner reads
  back separately) and case folding (matching every device-id lookup in the stop
  tools), plus that the message names the disposer's spelling, not the reader's.

- The `takeReapedSession` clear at profiler start, driven through the real
  `startNativeProfilerAndroid` rather than called directly, including that it
  leaves another device's breadcrumb alone.

- Both react-profiler message rewrites. Reverting either to main's wording left
  `test/react-profiler/**` entirely green; each now fails.

- http-tools-meta's `devices` guard, which never saw a non-string element, and
  the ungated failure-classification path that makes the branch live.
It returns a Promise, so `getFlowPath`'s validation throws belong in the
rejection like every other failure on this path. Every call site today is an
async function that converts them anyway; this removes the footgun for the next
one.
With two or more devices on one Metro, `debugger-connect` refuses a udid or
serial and instructs the caller to re-target with the `logicalDeviceId` Metro
echoed. That id then keys the JsRuntimeDebugger URN, so a teardown scoped to
`list-devices` ids can never match it — and because the caller's serial still
matches that device's other services, it is not reported `unmatched` either.
A teardown leaving a CDP socket, a bound loopback console server and a log
file handle behind read as a clean machine.

Record the case where the connect id IS the logicalDeviceId (the one place
both are compared) and report those live sessions as `left_running`. A
session another agent opened with its own serial is deliberately not named:
that id is one a scope could have supplied, and reporting it would invite the
cross-agent teardown the `devices` scope exists to prevent.

The tool description and the metro-debugger skill now say to pass the
logicalDeviceId alongside the device id, which does reap the session.
…s it

ReactProfilerSession.dispose() sent only Profiler.disable, on the assumption
that react-profiler-stop had already ended the run. That held while the stop
tool was the only route to a dispose; it stopped holding when this session
joined stop-all-simulator-servers' namespace set, which disposes it mid-run.

The React DevTools backend inside the app then keeps recording every commit
into a buffer only an app or bundle reload frees, while the patched commit
hook keeps re-serializing that whole accumulated buffer on React's commit
path — outliving the argent session, with the teardown reporting the session
as stopped.

Stop the renderers (STOP_FOR_TAKEOVER_SCRIPT) and the Hermes sampler when the
run is still active. Registry._teardown disposes dependents before their
dependency, so the JsRuntimeDebugger's CDP session is still up here.
… named

A recorded `stop-all-simulator-servers` scope was rebound to the run device
unconditionally. When that device was auto-detected — a cleanup flow resolves
one opportunistically whenever exactly one is booted — the flow named device A
and the replay tore down device B's services, which is precisely the
cross-agent teardown the `devices` scope was added to prevent.

Rebind a recorded scope only when the caller named the run device explicitly;
an auto-detected one names nobody's intent, so the recorded ids stand. A step
that recorded no scope is still narrowed onto the run device, since binding
can only make the machine-wide sweep smaller.

Corrects the comments on both sides of the binding, and the create-flow
skill's prose form of the same claim (including its stale cross-reference to
"Strategy 2 - Manual execution").
…ave happened

When two spellings resolve to one flow file the guard cannot tell "another
caller restarted this key" from "the same caller respelled its own root or
flow name" — and it asserted the former as fact: "Starting that recording
truncated this one ... restarting here would destroy their take in turn."

In the second case nothing was truncated, there is no other caller, and the
take is live and intact; the message sent the agent to abandon a healthy
in-progress recording and re-walk the whole flow on the device. On macOS the
respelling needs no mistake at all: /tmp is a symlink, so any path that
realpaths a root produces it.

Report the fact instead — the key is held by a take registered under another
spelling — and give the advice that recovers both readings: re-address it
exactly as flow-start-recording was given it.
…acing it

realpath fails on the whole path when a symlink's target is missing, so
canonicalFlowPath fell back to the link's own spelling and the atomic swap
renamed onto it — replacing the symlink with a regular file. That is the
shared-vault workflow's normal starting state: the link is created before the
first recording, or the vault copy goes with a branch switch or a git clean.
The vault target was never created, the project was permanently detached from
the vault, any sibling project on the same target was left dangling, and
flow-start-recording reported success.

Resolve such a link by hand, one hop at a time, canonicalizing each target's
directory so the result agrees with what a later append computes via plain
realpath. Since resolveFlowKey shares this resolution, two projects linking
one not-yet-created vault file now key as the one file the write produces.
… mid-handshake

native-profiler-start spawns its capture child and only then awaits a
readiness handshake. A stop-all-simulator-servers arriving in that window saw
profilingActive still false, so it disposed the session without killing the
child and reported it stopped; the start then resumed and returned
status: "recording" against a session the registry had already destroyed. The
owner's native-profiler-stop answered "call native-profiler-start first" and
the trace file was left with nothing able to reach it.

Kill the capture child on dispose whenever one exists — the flag says the run
has been declared active, not that a process was spawned — and mark the
session disposed so a resuming start reaps what it spawned and fails with
NATIVE_PROFILER_SESSION_TORN_DOWN instead of reporting a recording.
writeFailureHint compared the fully realpath-resolved swap directory against
an unresolved one, so any symlinked ANCESTOR tripped the clause — which on
macOS is every /tmp and /var/folders path. The message then claimed the flow
file "is a symlink" and contrasted two spellings of one directory.

Return the resolved flows directory alongside the target and compare against
that, so the clause fires only when the flow file itself is a link.
…nect

The breadcrumb's only consumer, debugger-log-registry, is gated on an EMPTY
registry, so one left behind survives every read that finds entries — and
then attaches "a teardown ate your logs" to a later, unrelated empty read,
which the tool description tells the agent to trust.

debugger-connect now drops it, the way the screen-recording and
native-profiler starts drop theirs: from an explicit connect the capture is
this session's own, so an empty registry honestly means nothing has been
logged since. Not in the blueprint factory, which also runs for the implicit
resolve debugger-log-registry itself performs — clearing there would consume
the breadcrumb one line before the read that exists to report it.
… caller

The message always said "torn down by a stop-all-simulator-servers", but a
blueprint's dispose() is called by Registry._teardown with no caller, so
nothing that writes a breadcrumb knows which tool triggered it. Two other
routes reach the same services: stop-simulator-server on Chromium cascades
into the debugger through ChromiumCdp, and react-profiler-start { force: true }
disposes it to reclaim the session.

Name the family instead, keeping the common case first.
hubgan added 7 commits August 6, 2026 17:14
…ure too

abandonedCapture read profilingActive alone, but the 10-minute cap and the
unexpected-exit handler both clear that flag while leaving the trace
recoverable — native-profiler-stop has a whole branch for exporting it. A
teardown there destroyed the owner's only route to the trace and left no
breadcrumb, so the stop tool reverted to "you never started one".

That arm also needs its own salvage text: it already sent SIGINT (or the
process exited on its own), so on iOS the bundle was finalized rather than
half-written, and on Android the on-device .pftrace is still there because
dispose's `rm -f` branch never runs.
…ilures

resolveRunDevice's bare `catch {}` was scoped by its comment to "nothing
booted, or several", but resolveFlowDevice reaches list-devices through the
registry, so an adb/simctl error, a dead sub-tool or an abort landed there
too. The teardown step then ran UNSCOPED and reported pass — the machine-wide
sweep this path exists to avoid, on a machine whose device list nobody could
read.

Swallow only FLOW_DEVICE_RESOLUTION; rethrow the rest.
clearRecordingSession re-resolved its spelling instead of using session.key —
the opposite of the deliberate choice appendStepToFlow documents. Once the
flow file's identity has moved under the session (a symlink repointed
mid-recording, in the window between requireRecordingSession and the finish's
own file read), the re-resolution looks up a key the map no longer holds: the
delete missed silently and the finish reported success while the session
stayed live, unfinishable, and holding the key against its own restart.

Takes the session, so re-resolution is not expressible.
…ording

The dangling-symlink resolution keeps the recording key stable when the
target goes away mid-take, so the session stays addressable and the append
fails as the missing file it is rather than as a recording that was never
started — the second answer sends the agent to flow-start-recording, which
truncates. Restoring the target resumes the same take.
execute never took ctx, so a caller that had given up — an MCP client timing
out, a cancelled CLI run — was still billed for a full loop of awaited
disposals across thirteen namespaces. Check the signal between disposals (a
dispose already under way finishes, since abandoning a blueprint mid-teardown
leaks the handles this tool exists to free) and report the partial teardown
as { aborted: true } rather than computing `unmatched` / `left_running` from
a snapshot the sweep never finished reading.

Also moves the `unmatched` caveats back next to `unmatched` in the
description, where the left_running sentence had split them.
…ly one

The scratch file is created under this process's umask and rename carries ITS
mode over, so every append quietly rewrote the flow file's permissions to
0644 — and since the swap needs permission on the DIRECTORY rather than on
the file, a `chmod 0444` that a plain write refused now succeeded.

Preserve the target's mode on the scratch file before the rename, and refuse
up front when the existing flow file is not writable, so a read-only flow
goes on meaning what it meant before the write became atomic.
… seven times

The microtask loop claimed to exercise both, but every iteration produced
`fulfilled` and the `if (rejected)` branch never ran: the finish awaits a real
realpath before joining the lock queue, so no microtask tuning can make it
overtake an append already queued.

Replace it with two cases fixed by the lock rather than by timing — an append
that wins the queue and must appear in what the finish reports, and one parked
in its live phase across a completed finish, which must be rejected and must
not be on disk — sharing one helper for the report-matches-disk invariant.
hubgan added 5 commits August 6, 2026 17:31
`tsc --build` covers src only, so the new required `disposed` field on
NativeProfilerSessionApi and the event-map generic in the ReactProfilerSession
dispose test only surfaced under `typecheck:tests`.
…rror the registry

Four cases survived an always-match matcher because each snapshot held only
the target device's URNs — and AXService, ScreenRecordingSession,
NativeProfilerSession and ChromiumJsRuntimeDebugger were covered only that
way. Each now carries a second-device control.

The registry mock is also brought back in line with Registry._teardown, which
early-returns for TERMINATING as well as IDLE, and the fabricated `Metro:8081`
node is gone: it is not a registry namespace, every namespace a blueprint
declares as a dependency is device-owned, and what that case asserted was the
mock's own recursion rather than any production line. It is replaced by the
IDLE-dependent and TERMINATING cases, which are production lines that had no
coverage.
- device-services' `afterPort < 0` arm: a port-keyed URN missing its device
  half owns no device, so the literal Metro port cannot claim it.
- the keyResolutions FIFO sequencer, which nothing imported: realpath mocked
  to complete in decreasing time, so removing the sequencer inverts the lock
  queue deterministically rather than flakily.
- the iOS breadcrumb clear (and the iOS half of the disposed-session guard),
  driven through the real startNativeProfilerIos with xctrace, simctl, the
  readiness handshake and the capture strategy stubbed at their boundaries —
  the only start-side test file was Android-only.
- failedMsg for both stop tools.
- the ENAMETOOLONG and missing-vault arms of writeFailureHint, and the
  ENOTDIR / unwritable-parent / ENAMETOOLONG arms of mkdirFailureHint.
- the `flow_recording_key_aliased` failure stage, which shares its error code
  with a key that was never started and wants a different fix.
Three CLI test files encode that schema as a fixture, because @argent/cli does
not depend on the tool-server and cannot derive it. Drift was therefore silent
in the direction that matters: relaxing the real schema — making project_root
optional, renaming `args` — left all three green while the CLI's --args
handling and help output were decided by a schema nothing resembled any more.

Put the guard where the schema is, asserting the exact property and required
sets those fixtures encode plus the description sentence they quote, and point
each fixture at it.
- flow-deviceless.test.ts said the `devices` key is "stripped at record time
  and re-injected at replay". It is not stripped — stripDeviceKeys touches
  only the target keys, and flow-tools.test.ts asserts the opposite. The empty
  args in that fixture are a recording of the UNSCOPED sweep.
- 90-cleanup.sh said it stops "any simulator-servers this run started". The
  unscoped `{}` is now the machine-wide sweep across every device-owned
  namespace. Say so, and say why unscoped is right in that one place (the
  run's HOME is the sandbox, so the server it discovers is its own).
- 20-validation.sh skipped stop-all-simulator-servers entirely, so neither the
  `.strict()` rejection of the `udids` slip nor the `unmatched` report had any
  E2E coverage. Both now have a targeted case; the bogus-id scope reaps
  nothing, so it is safe to run there.
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