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 ❤️
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.
latekvo
left a comment
There was a problem hiding this comment.
[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.
latekvo
left a comment
There was a problem hiding this comment.
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. |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
Fixed in 73a72ab — assertNotDisposed 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; |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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); |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
Fixed in 73a72ab — restarted 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"); |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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 |
There was a problem hiding this comment.
[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 yieldsnull.packages/registry/src/types.ts:113-115— "re-derived from each sub-tool's ownchildArgs(itsudid/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 injectsdevices:[deviceId]into a replayedstop-all-simulator-serversstep, 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.
There was a problem hiding this comment.
Fixed in 5313367 — extractDeviceArg 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 |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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: [] }], |
There was a problem hiding this comment.
[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.
There was a problem hiding this comment.
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.
2bd1937 to
40fd0cb
Compare
40fd0cb to
96410fe
Compare
| } | ||
|
|
||
| /** One human-readable line per recorded step, in the flow file's own spellings. */ | ||
| function summarizeSteps(flow: FlowFile): string[] { |
There was a problem hiding this comment.
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???
There was a problem hiding this comment.
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.
d326f82 to
c6fbf40
Compare
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.
…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.
`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.
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.No recording tool can detect that another agent took its key.
flow-add-step,flow-add-echoandflow-finish-recordingall 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" inargent-create-flowrather 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>).