feat(runner): event-driven tool relay (fs.watch + Daytona long-poll watch, polling fallback)#5243
Conversation
…lay writer Slice 0 of the event-driven tool relay: move the in-sandbox writer (relayToolCall) out of tools/dispatch.ts into tools/relay-client.ts, and the wire protocol (suffixes, request/response types, sanitizeRelayId, request serialization, shared timing env constants) into tools/relay-protocol.ts. Both new modules are bundle-safe (node builtins only) so the Pi extension bundle and the future in-sandbox MCP shim (#5234) can consume them without pulling in server-side runner code. dispatch.ts and relay.ts re-export every moved symbol, so all call sites compile unchanged. Zero behavior change; a golden test pins the byte-exact .req.json serialization as the cross-writer contract. Side effect: the extension bundle drops the server-side relay loop it previously pulled in through dispatch's constant imports. Claude-Session: https://claude.ai/code/session_01Fr4A5zjs5rsufRaWgWiUNC
…op 1) Slice 1 of the event-driven tool relay. Two mechanisms: Atomic publication (plan decision 2), both directions: every relay file is now published via a temp name (<final>.tmp.<nonce>) plus a same-directory rename, so a reader or watcher can never observe partial JSON. The writer renames with fs.renameSync; the runner-side response write goes through a new required RelayHost.rename capability (localRelayHost: renameSync; sandboxRelayHost: daemon moveFs, verified rename(2)-atomic against the daemon v0.4.2 source - open question 2 resolved). Temp names never match the .req.json/.res.json suffix filters. Final on-disk bytes are unchanged (golden test). Hop 1 response watch (decisions 3, 6, 7): waitForRelayResponse arms one coalescing fs.watch on the relay dir BEFORE its first existsSync check, so a response file wakes the writer instantly instead of after a 0-300 ms poll sleep. The existing RELAY_POLL_MS cadence survives as the racing safety timer, and any watch failure degrades to the plain poll - never a hang, never a rejection. Kill switch: AGENTA_AGENT_TOOLS_RELAY_RESPONSE_WATCH_ENABLED (default true), forwarded to the sandbox env only when the operator set it. Claude-Session: https://claude.ai/code/session_01Fr4A5zjs5rsufRaWgWiUNC
Slice 2 of the event-driven tool relay. The runner relay loop gains wake sources
(plan decisions 3, 4, 6, 7):
- RelayActivitySource (tools/relay-watch.ts): a coalesced, single-flight wait
contract - wait({timeoutMs}) -> activity|timeout|closed - with sticky wakes, no
listener growth, and errors that degrade instead of rejecting.
- Local backend: an in-process fs.watch source (no flag); the watch only shortens
the existing poll sleeps, the cadence and idle backoff stay untouched.
- Daytona: a re-issued bounded watch exec behind
AGENTA_AGENT_TOOLS_RELAY_REMOTE_WATCH_ENABLED (default false). One inline
node -e script per window (relay dir passed argv-only, arm-watch-then-list,
in-script 2 s readdir fallback, idempotent finish, no stdout, natural exit).
While the watch is healthy the runner suspends its remote ls polling and keeps a
30 s safety poll; repeated failure (exec rejection, daemon timeout, nonzero/null
exit, runner-side outer bound, safety-poll misses) demotes the turn to classic
polling with jittered exponential backoff and one demotion log line.
- Window config: AGENTA_AGENT_TOOLS_RELAY_REMOTE_WATCH_WINDOW_MS, default 25 s,
clamped to [5s, 120s], downward-only ~20% jitter so a window always completes
before the safety poll.
- Delete-on-pickup: the runner removes a request file right after reading it, so
an in-flight execution cannot insta-complete every watch window (the rearm-storm
found in review). This deliberately ends crash-redelivery-by-re-listing: a
request executes at most once per publication.
- Runner-side outer bound: a generation-tagged local timer abandons a wedged exec
request at window+grace+margin and counts the failure, so a blackholed HTTP
request cannot starve demotion.
Deviations from the plan, both recorded in the workspace: close() abandons (not
aborts) the in-flight exec because the SDK's runProcess takes no per-call
AbortSignal (bounded by the script's own window timer); window jitter is
downward-only instead of +/-20% to keep windows shorter than the safety poll.
Claude-Session: https://claude.ai/code/session_01Fr4A5zjs5rsufRaWgWiUNC
Slice 3 of the event-driven tool relay. Orphan-request residue (accepted ownership of open question 4): a reused relay dir on a warm-continued turn could hold a stale .req.json from a crashed prior turn, and the next turn's fresh seen set would re-execute it (workspace.ts clears the dir only on cold builds). The relay loop's first successful list is now a snapshot pass: every request file already present is marked seen and best-effort deleted, never executed. A turn only executes requests created after it started. The loop starts before the prompt (and before respondPermission on resume), so no legitimate request can predate the snapshot. Telemetry: one '[relay] stage=relay_pickup id=... pickup_ms=... wake=...' log per picked-up request, measuring publication (file mtime, via a new optional RelayHost.statMtimeMs) to handler start, tagged with the wake outcome at discovery (activity/timeout/closed/poll). Stat failures degrade to pickup_ms=-1 and never affect execution. Costs one stat per executed tool call; on Daytona the number is cross-clock and approximate, for QA distributions. The engine now passes its turn logger into startToolRelay so the pickup, stale-file, demotion, and watch-failure lines reach the run log. Five existing tests moved their request-file seeding to after startToolRelay: the snapshot deliberately inverts 'a request present at startup is served', and in production the sandbox cannot write a request before the loop exists. Claude-Session: https://claude.ai/code/session_01Fr4A5zjs5rsufRaWgWiUNC
Update the tools page's file-relay description (writer in relay-client.ts, wire protocol in relay-protocol.ts, atomic temp+rename publication, delete-on-pickup / at-most-once, per-turn stale snapshot, event-driven pickup with the polling fallback, the three new AGENTA_AGENT_TOOLS_RELAY_*WATCH* env vars and the keep-the-window-below-30s guidance), fix the stale services/agent paths in its where-this-lives table for the relay rows, refresh the runner-to-MCP-server interface page's relay lifecycle (no more 'polls every 300ms'; atomic publication; crash redelivery gone on both backends), and add the new relay modules to the interface index row. Claude-Session: https://claude.ai/code/session_01Fr4A5zjs5rsufRaWgWiUNC
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
🚥 Pre-merge checks | ✅ 5✅ Passed checks (5 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
Five verified findings from the high-effort review, fixed: - The orphan snapshot raced the resume flow: the first successful list's READ time is unordered against respondPermission on Daytona, so a just-approved request could be swallowed as stale, and a transiently failing first list deferred the snapshot behind legitimate requests. The snapshot is now a dedicated pre-loop sweep (3 bounded list attempts; removes stale .req.json, .res.json, and relay tmp names concurrently; never touches other files) and startToolRelay exposes it as 'ready', which the engine awaits BEFORE respondPermission and the prompt - the causal ordering is now explicit, not a comment. Stale responses are swept too: a resumed approval reuses its original toolCallId, so a crashed attempt's response file could otherwise satisfy the new wait with stale bytes. - A nullish runProcess result (or missing exitCode) classified as watch success: an insta-resolving broken daemon path stormed forever with the demotion counter pinned at zero. Success now requires exitCode === 0 and no daemon timeout; everything else counts a failure. - Delete-on-pickup landed asynchronously after the loop re-armed the next watch window, so each pickup still bought an insta-complete/rearm burst, and a silently failed remove revived the full storm with counter-resetting exit-0 wakes. Pickup (read, then stat and remove concurrently) now completes before the next window arms; failed removes are retried on later list passes; execution still starts as soon as the read returns. - A window failure while a wait was already parked left the source windowless until the 30 s safety timer and miscounted the safety-poll discovery as a watch miss. countFailure now schedules the deferred re-arm when a waiter is parked; the arm gate is one tryArm() used by all three arm sites; the inFlight flag collapsed into the generation counter. - The safety-poll independence promised by the plan was implemented inside the watch source it guards. The loop now bounds every source.wait with its own racing timer, so a wedged wait can never stop the list pass. Plus cheap wins: waitForRelayResponse fast-paths a pre-existing response before arming a watcher and re-checks once at the deadline; the pickup stat is skipped when no log sink is wired; one relayEnvFlag parser for both watch flags; the extension build now FAILS if server-side relay symbols leak into the sandbox bundle (negative-tested); the orchestration test pins the engine's relay logger wiring. Claude-Session: https://claude.ai/code/session_01Fr4A5zjs5rsufRaWgWiUNC
|
Reviewed with Codex (high effort). The event-driven loop is sound: no missed wakeups, no partial reads, no double execution, and local pickup is verified at about 1.3ms. The review found two failure-path bugs and one parse nit: a hung file delete could wedge a turn, the demotion counter could fail to accumulate a real miss, and the window fallback accepted partial garbage. All three are fixed in #5248 on top of the stack. Nothing here needs a decision from you. |
There was a problem hiding this comment.
Actionable comments posted: 3
ℹ️ Review info
⚙️ Run configuration
Configuration used: Organization UI
Review profile: CHILL
Plan: Pro Plus
Run ID: e5bf685f-cfd5-4bfa-b9ae-f00f277fac23
📒 Files selected for processing (20)
docs/design/agent-workflows/documentation/tools.mddocs/design/agent-workflows/interfaces/README.mddocs/design/agent-workflows/interfaces/cross-service/runner-to-mcp-server.mdservices/runner/scripts/build-extension.mjsservices/runner/src/engines/sandbox_agent.tsservices/runner/src/engines/sandbox_agent/pi-assets.tsservices/runner/src/tools/dispatch.tsservices/runner/src/tools/relay-client.tsservices/runner/src/tools/relay-protocol.tsservices/runner/src/tools/relay-watch.tsservices/runner/src/tools/relay.tsservices/runner/tests/unit/relay-atomic-publish.test.tsservices/runner/tests/unit/relay-client.test.tsservices/runner/tests/unit/relay-loop.test.tsservices/runner/tests/unit/relay-watch.test.tsservices/runner/tests/unit/sandbox-agent-orchestration.test.tsservices/runner/tests/unit/sandbox-agent-pi-assets.test.tsservices/runner/tests/unit/tool-callref-bindings.test.tsservices/runner/tests/unit/tool-direct.test.tsservices/runner/tests/unit/tool-relay-guard.test.ts
| - `AGENTA_AGENT_TOOLS_RELAY_REMOTE_WATCH_WINDOW_MS`: one watch exec window. Default 25000, | ||
| clamped to [5000, 120000], with downward-only jitter of up to 20 percent. Keep it below the | ||
| 30 s safety poll: a window of 30 s or more still works but degrades pickup latency and can | ||
| demote a healthy watch. |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
#!/bin/bash
set -euo pipefail
rg -n "AGENTA_AGENT_TOOLS_RELAY_REMOTE_WATCH_WINDOW_MS|REMOTE_WATCH_WINDOW_MS|watch window|parseInt" .Repository: Agenta-AI/agenta
Length of output: 9202
🏁 Script executed:
#!/bin/bash
set -euo pipefail
sed -n '60,100p' services/runner/src/tools/relay-watch.ts
printf '\n--- TESTS ---\n'
sed -n '522,620p' services/runner/tests/unit/relay-watch.test.ts
printf '\n--- DOCS ---\n'
sed -n '275,288p' docs/design/agent-workflows/documentation/tools.mdRepository: Agenta-AI/agenta
Length of output: 5923
Use full-string validation for the watch-window env var. resolveRemoteWatchWindowMs() uses Number.parseInt, so values like 30000junk are accepted as 30000. Either reject non-numeric suffixes or document that prefix parsing is allowed.
| | [`/run`](cross-service/service-to-agent-runner.md) | cross-service (the spine) | `protocol.ts`, `utils/wire.py`, `utils/ts_runner.py`, `server.ts`/`cli.ts` | stable (pinned by golden) | `unit/agents/test_wire_contract.py` + `golden/`, `services/agent/tests/unit/wire-contract.test.ts` | | ||
| | [Runner to harness](cross-service/runner-to-harness.md) | cross-service (ACP) | `engines/sandbox_agent.ts` + `sandbox_agent/{run-plan,capabilities,permissions}.ts` | evolving | `services/agent/tests/unit/sandbox-agent-*.test.ts` | | ||
| | [Runner to MCP server](cross-service/runner-to-mcp-server.md) | cross-service | `agents/mcp/`, `engines/sandbox_agent/mcp.ts`, `tools/{mcp-bridge,mcp-server,relay}.ts` | evolving (stdio wired; remote deferred) | `services/agent/tests/unit/mcp-servers.test.ts` | | ||
| | [Runner to MCP server](cross-service/runner-to-mcp-server.md) | cross-service | `agents/mcp/`, `engines/sandbox_agent/mcp.ts`, `tools/{mcp-bridge,mcp-server,relay,relay-client,relay-protocol,relay-watch}.ts` | evolving (stdio wired; remote deferred) | `services/agent/tests/unit/mcp-servers.test.ts` | |
There was a problem hiding this comment.
📐 Maintainability & Code Quality | 🟡 Minor | ⚡ Quick win
Align the inventory status with the contract page.
This row says “stdio wired”, but runner-to-mcp-server.md says user stdio servers are disabled and mcp-server.ts is a refusing stub. If “stdio wired” refers to another channel, name it; otherwise update the status.
| export const RELAY_POLL_MS = Number( | ||
| process.env.AGENTA_AGENT_TOOLS_RELAY_POLLING ?? 300, | ||
| ); | ||
| export const RELAY_TIMEOUT_MS = Number( | ||
| process.env.AGENTA_AGENT_TOOLS_RELAY_TIMEOUT ?? 60000, | ||
| ); |
There was a problem hiding this comment.
🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win
Validate RELAY_POLL_MS/RELAY_TIMEOUT_MS parsing — bare Number() silently yields NaN on malformed input.
Unlike relayEnvFlag below (explicit allow-list, safe fallback), these two constants have no validation. A malformed env value (e.g. "30000junk", empty string, or a negative number) makes Number(...) return NaN or a negative number. Since waitForRelayResponse's deadline = Date.now() + opts.timeoutMs would then be NaN/in the past, Date.now() < deadline is always false, so every relay call throws RelayTimeoutError immediately regardless of the actual response timing.
🛡️ Proposed fix
+function parsePositiveIntEnv(value: string | undefined, fallback: number): number {
+ const n = Number(value);
+ return Number.isFinite(n) && n > 0 ? n : fallback;
+}
+
-export const RELAY_POLL_MS = Number(
- process.env.AGENTA_AGENT_TOOLS_RELAY_POLLING ?? 300,
-);
-export const RELAY_TIMEOUT_MS = Number(
- process.env.AGENTA_AGENT_TOOLS_RELAY_TIMEOUT ?? 60000,
-);
+export const RELAY_POLL_MS = parsePositiveIntEnv(
+ process.env.AGENTA_AGENT_TOOLS_RELAY_POLLING,
+ 300,
+);
+export const RELAY_TIMEOUT_MS = parsePositiveIntEnv(
+ process.env.AGENTA_AGENT_TOOLS_RELAY_TIMEOUT,
+ 60000,
+);📝 Committable suggestion
‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.
| export const RELAY_POLL_MS = Number( | |
| process.env.AGENTA_AGENT_TOOLS_RELAY_POLLING ?? 300, | |
| ); | |
| export const RELAY_TIMEOUT_MS = Number( | |
| process.env.AGENTA_AGENT_TOOLS_RELAY_TIMEOUT ?? 60000, | |
| ); | |
| function parsePositiveIntEnv(value: string | undefined, fallback: number): number { | |
| const n = Number(value); | |
| return Number.isFinite(n) && n > 0 ? n : fallback; | |
| } | |
| export const RELAY_POLL_MS = parsePositiveIntEnv( | |
| process.env.AGENTA_AGENT_TOOLS_RELAY_POLLING, | |
| 300, | |
| ); | |
| export const RELAY_TIMEOUT_MS = parsePositiveIntEnv( | |
| process.env.AGENTA_AGENT_TOOLS_RELAY_TIMEOUT, | |
| 60000, | |
| ); |
| log( | ||
| `[relay] stage=relay_pickup id=${id} pickup_ms=${ | ||
| mtimeMs === undefined ? -1 : Date.now() - mtimeMs | ||
| } wake=${wake}`, |
There was a problem hiding this comment.
The at-most-once guarantee, worth reading closely. The runner deletes each request file the instant it reads it, and its first list of a reused relay directory marks any leftover files as seen (around line 692) instead of running them. Together these end crash-redelivery on both backends: a request runs at most once per publication, and a request lost to a crash surfaces as a writer timeout and a tool error, never as a replayed side effect. The follow-up fix in #5248 bounds these deletes with a timeout so a hung daemon call cannot wedge the loop here.
| // in-sandbox writer defaults it to true, so it is only forwarded — verbatim — when | ||
| // the operator set it on the runner. | ||
| const responseWatch = | ||
| process.env.AGENTA_AGENT_TOOLS_RELAY_RESPONSE_WATCH_ENABLED; |
There was a problem hiding this comment.
where are these defined. are they on the compose level? we re starting to have to many env vars and ff, just want to make sure it is maintainable
Addresses the mechanical review findings on #5243, #5244, and #5245 that carry no design decision and need no live verification. Landed as one commit on top of the stack because GitButler cannot retro-assign a new delta onto a lower applied lane; each fix is mapped to its PR below. Relay (#5243): - relay.ts: bound every relay-file removal with a timeout so a hung daemon deletion can no longer wedge pickup, stale-sweep, or shutdown; a timed-out removal is treated as failed and the loop continues. - relay-watch.ts: tie a safety-poll miss to the active watch generation so a later zero-exit of the same generation cannot erase it (the demotion counter now accumulates); validate the whole watch-window string instead of parseInt accepting partial garbage. Shim (#5244): - tool-mcp-stdio.ts: answer MCP 'ping' with an empty result; return -32700 on unparseable input and -32600 on an invalid request; document the at-least-once relay delivery contract. Protocol-version negotiation is left unchanged (that needs a live check). - tool-mcp-assets.ts: cache the shim bundle by path and write bundle + specs concurrently, removing avoidable serial Daytona round trips. - restored the direct client-only Daytona refusal test; pinned the Pi vs non-Pi ask guard wiring; updated the build:extension docs. Continuation (#5245): - tool-mcp-http.ts: authenticate before the method check so an unauthenticated GET/DELETE gets 401, not 405. - added auth tests: unauthorized GET/DELETE, wrong scheme, empty and whitespace tokens, and that neither the callback executor nor the client relay runs on an unauthorized call. Deferred (need a design decision or a live check, not in this commit): the non-Pi ask authorization model (#5244), the bearer-in-argv delivery (#5245), MCP protocol-version negotiation (#5244). Claude-Session: https://claude.ai/code/session_0127AM79khCdvD2b8BG2joZL
…ndbox stdio MCP shim Slice 1 of in-sandbox-tool-mcp (docs/design/agent-workflows/projects/in-sandbox-tool-mcp/). Today a Claude run on Daytona with any custom tool is refused up front (REMOTE_TOOLS_UNSUPPORTED_MESSAGE): execution via the file relay is solved, but nothing inside the sandbox advertises the tools to an MCP-client harness. This revives PR Agenta-AI#4873 onto today's paths, consuming the relay client PR Agenta-AI#5243 extracted. - tools/tool-mcp-stdio.ts: a dependency-free stdio MCP shim (9.6 kB bundled) the harness's ACP adapter spawns inside the sandbox. NDJSON JSON-RPC; tools/list serves the run's public specs from a specs FILE; tools/call writes a relay request through the shared relay client (atomic publication, per-tool timeoutMs) and maps failures to isError results. Fail-loud exit 1 on missing env/specs. Env contract in tools/tool-mcp-env.ts: AGENTA_AGENT_TOOLS_RELAY_DIR + AGENTA_AGENT_TOOLS_PUBLIC_SPECS_FILE (a file, not an unbounded env value copied through four exec layers). - engines/sandbox_agent/tool-mcp-assets.ts: bundle + specs upload, always-write (closes bundle-version skew in reused sandboxes), THROWS on missing bundle/failed upload on the path that requires the shim. Bundle override SANDBOX_AGENT_RELAY_MCP_BUNDLE. - engines/sandbox_agent/mcp.ts: dedicated internal-entry constructor building the typeless ACP stdio shape named agenta-tools (claude_settings.py couples permission rules to it); McpServerStdio moved here out of mcp-bridge.ts; user-declared servers may not claim the reserved name; toAcpMcpServers still cannot emit stdio; user-stdio refusal unrelaxed. - run-plan.ts: the remote-tools gate narrows - executable tools on Daytona pass (new toolMcpDir ephemeral sibling of the relay dir), client tools refuse loud with their own message, non-Daytona remote providers stay fail-closed. - engines/sandbox_agent/relay-guard.ts: the relay execution guard now guards EVERY harness (review finding): allow/deny identical everywhere; ask consumes the Pi grant ledger on Pi and passes on MCP harnesses whose own dialog gates the call, with the forged-ask residual documented. Previously Claude relay records executed unguarded. - The slice-0 restart spike locked the A2 transport: the pinned Claude ACP adapter (claude-agent-acp 0.22.2 in snapshot agenta-sandbox-pi) respawns the stdio MCP subprocess on the session/load path after a VM stop/restart (spike-restart.md in the workspace). Tests: 25+ new (shim handler + real startToolRelay round trip incl. crash-after-write, concurrency, stdout purity; assets fail-loud; three-layer session MCP pin; gate matrix; non-Pi guard semantics). Suite 1054 passed / 4 known pre-existing failures; typecheck clean for the slice; both bundles build with the forbidden-server-symbols gate. Claude-Session: https://claude.ai/code/session_01Fr4A5zjs5rsufRaWgWiUNC
Context
Every tool call from a sandboxed agent pays a polling tax. The in-sandbox writer drops a request file and sleeps in 300ms steps until the response appears; the runner finds requests by listing the relay directory every 300ms (1.5s once idle). One tool call adds 0.3 to 1.8s of pure waiting, and a ten-tool turn wastes over ten seconds. On Daytona the poll is also traffic: about 200 remote
lscalls per minute per active turn.This PR makes the relay event-driven. A filesystem event wakes the waiter the moment a file lands, and polling stays underneath as the correctness fallback. If a watcher dies, the relay degrades to exactly today's polling behavior, never to a hang.
Design and open questions:
docs/design/agent-workflows/projects/event-driven-tool-relay/(reviewed on #5232).Changes
Atomic publication. Both directions now write to a temp file and rename it into place, so a reader can never see half-written JSON. On Daytona the response rename is the daemon's
moveFs, which isrename(2)(verified against the daemon source), atomic for a same-directory move.Wake on events, poll as backup. The writer arms one filesystem watch on the relay directory before it first checks for the response, so the response wakes it in about a millisecond. The runner does the same for incoming requests. Every watch path has a poll racing underneath it as the safety net, and repeated watch failure demotes the turn back to plain polling with one log line.
Before: the response is noticed 0 to 300ms after it lands. After: one filesystem event, about 1ms, and the poll only runs if the watch fails.
At most once. The runner deletes each request file the instant it reads it, and its first list of a reused relay directory marks any leftover files as already seen instead of running them. Together these end crash-redelivery: a request runs at most once, and a request lost to a crash surfaces as a writer timeout and a tool error, never as a repeated side effect. (This closed a real storm: without the delete, an in-flight request re-triggered every watch window.)
Telemetry. One
stage=relay_pickup id=... pickup_ms=... wake=...line per executed request.Two knobs, both defaulting to safe: the hop-1 response watch is on (
..._RESPONSE_WATCH_ENABLED, degrades to poll); the Daytona remote watch is off until its live pass (..._REMOTE_WATCH_ENABLED).Request volume
Active Daytona polling costs about 200 requests/min today. A healthy watch costs about 4.4/min (window re-issues plus two safety polls), roughly 45x less. Each executed tool call adds two daemon calls: the delete on pickup and a stat for the timing log.
Tests
services/runnersuite: 1011 passed. The 4 remaining failures are pre-existing on this stack, from other in-flight lanes, and byte-identical before and after.Live QA
pickup_ms=1.28 wake=activity. Event-driven pickup at 1.3ms against the 0 to 300ms polling baseline.What to QA
stage=relay_pickupline withwake=activityand a single-digitpickup_ms.AGENTA_AGENT_TOOLS_RELAY_RESPONSE_WATCH_ENABLED=falseand repeat. Behavior is identical,pickup_msgrows up to about 300,wake=poll.Notes
https://claude.ai/code/session_01Fr4A5zjs5rsufRaWgWiUNC