Skip to content

Fix silent worker spawn and strict identity failures#1342

Open
miyaontherelay wants to merge 4 commits into
mainfrom
fix/spawn-readiness
Open

Fix silent worker spawn and strict identity failures#1342
miyaontherelay wants to merge 4 commits into
mainfrom
fix/spawn-readiness

Conversation

@miyaontherelay

Copy link
Copy Markdown
Contributor

Summary

Fix three related silent-agent-failure paths:

  • Gate broker worker spawn success on a worker_ready handshake; an immediately exiting CLI now fails the action and is removed instead of being reported as spawned: true.
  • Emit a structured warning before the existing dead_letter_added broker event so persisted undeliverable messages are visible in operational logs.
  • For RELAY_STRICT_AGENT_NAME=1, use atomic plain registration rather than token-rotating registration, so a second session gets an explicit conflict instead of invalidating the first session.

The commits are intentionally separable: readiness, dead-letter observability, strict-identity registration, then changelog.

Concrete evidence

Bug 1 — false spawn success

Pre-fix path on main: crates/broker/src/worker.rs:798 launched the process, :834 inserted it into the registry, and :852 returned Ok(spec) without a readiness acknowledgement. crates/broker/src/runtime/fleet.rs:447-454 then emitted success solely because the registry contained the name.

  • Before, the live nightcto-sf reproduction completed: {"output":{"name":"ncto-probe","spawned":true},"status":"completed"}.
  • After, a rebuilt release broker node ran with codex deliberately replaced by a command that exits 64 before readiness. The spawned action reached the terminal result {"status":"failed","error":"spawn_failed"} (the relay also delivered spawn_failed). No spawned:true completion was emitted.

The regression test executes false after receiving init_worker; before the readiness gate it failed because spawn returned Ok, and it now rejects and leaves no registered worker.

Bug 3 — strict identity token ping-pong

Before this change, strict-name rebind/bootstrap unconditionally called registerOrRotate at packages/cli/src/cli/agent-relay-mcp.ts:360 and :916-921. The SDK describes that operation as adopting an existing identity and invalidating the old token at packages/sdk/src/facade.ts:189-193. Two sessions with the same strict name therefore alternated invalidating one another. This was a live operational issue: this workspace's lead identity was invalidated four times in one session by another live session sharing the name.

Strict rebind/bootstrap now calls atomic agents.register; an existing live identity produces a server conflict and leaves its token usable. Non-strict callers retain rotation behavior.

Bug 2 — silent dead letters

dead_letter_pending_delivery now writes a structured warning containing worker, delivery_id, event_id, attempts, and reason immediately before the existing dead_letter_added event.

Verification

  • cargo fmt --check
  • cargo test -p agent-relay-broker — 783 passed, 4 ignored
  • cargo build --release --bin agent-relay-broker
  • npm run typecheck
  • npx vitest run packages/cli/src/cli/agent-relay-mcp.test.ts packages/cli/src/cli/agent-relay-mcp.startup.test.ts — 28 passed
  • Live rebuilt-binary node run described above (the key runtime proof, not just a unit test)

Risk / review focus

The 30-second outer worker_ready deadline is the riskiest change and deserves scrutiny. It deliberately fails closed: every WorkerRegistry::spawn caller, including fleet placement, now waits for readiness. PTY workers emit readiness after their existing boot/prompt detection, with their existing 25-second fallback; the outer five seconds are transport headroom. Thus a cold npx fetch or first-run binary can still reach the PTY fallback around 25 seconds, but a worker that cannot emit the broker frame by 30 seconds fails instead of becoming a phantom agent.

Headless and app-server workers already emit worker_ready immediately after receiving init_worker, so they take the same handshake path without waiting for PTY prompt detection. This is a liveness handshake, not a stronger proof that an external provider is fully authenticated; reviewers should assess whether the PTY fallback and 30-second cutoff are right for unusually slow cold-cache CLIs.

@coderabbitai

coderabbitai Bot commented Jul 19, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

📝 Walkthrough

Walkthrough

Worker startup now requires a readiness handshake, strict agent identities use conflict-safe registration, and dead-letter moves emit structured warnings. Tests cover failed startup, strict registration recovery and conflicts, and bootstrap behavior.

Changes

Runtime safety and observability

Layer / File(s) Summary
Readiness-gated worker spawning
crates/broker/src/worker.rs
Worker creation waits for worker_ready, reports startup failures and timeouts, cleans up failed workers, and tests immediately exiting CLIs.
Conflict-safe strict registration
packages/sdk/src/messaging/thin-client.ts, packages/cli/src/cli/agent-relay-mcp.ts, packages/cli/src/cli/agent-relay-mcp*.test.ts
Strict identities use register instead of registerOrRotate, with SDK support and coverage for recovery, bootstrap, and name conflicts.
Structured dead-letter warning
crates/broker/src/runtime/dead_letter.rs, CHANGELOG.md
Dead-letter moves log worker, delivery, event, attempt, and reason fields, and the fixes are documented in the unreleased changelog.

Estimated code review effort: 3 (Moderate) | ~25 minutes

Possibly related PRs

Suggested labels: size:L

Suggested reviewers: khaliqgant, willwashburn

Poem

A rabbit watched the workers wake,
And checked each ready signal’s shake.
Strict names kept their tokens whole,
Dead letters found a warning scroll.
“Hop safely onward!” cried the hare.

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title matches the main changes: worker spawn readiness, strict identity registration, and related failure handling.
Description check ✅ Passed The PR description covers the summary and testing well, but it omits the template's explicit Test Plan checklist and Screenshots section.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/spawn-readiness

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Code Review

This pull request implements a worker readiness handshake during startup to prevent false success reports, switches strict worker registration to a conflict-safe atomic registration to avoid token rotation conflicts, and adds structured warning logs for dead-lettered messages. The feedback suggests optimizing the worker executable path resolution in crates/broker/src/worker.rs by using lazy evaluation instead of eager evaluation with unwrap_or to avoid unnecessary system calls.

Important

The consumer version of Gemini Code Assist on GitHub is being sunset. Starting June 18, 2026, new organization installations will be blocked, and all code review activity will officially cease on July 17, 2026.
For more details on the timeline and next steps, please review the Help Documentation.

Comment on lines +326 to +330
let worker_program = self.worker_program_override.clone();
let mut command = Command::new(
worker_program
.unwrap_or(std::env::current_exe().context("failed to locate current executable")?),
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

medium

Using unwrap_or eagerly evaluates its argument, which means std::env::current_exe() (a system call) is always executed and its potential errors are propagated even when self.worker_program_override is Some. Using a match expression avoids this eager evaluation, making it more efficient and robust, especially in tests where an override is provided.

        let mut command = Command::new(match &self.worker_program_override {
            Some(path) => path.clone(),
            None => std::env::current_exe().context("failed to locate current executable")?,
        });

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Devin Review found 1 potential issue.

View 2 additional findings in Devin Review.

Open in Devin Review

Comment on lines +883 to +899
let startup_result = match timeout(WORKER_STARTUP_READY_TIMEOUT, startup_ready_rx).await {
Ok(Ok(Ok(()))) => Ok(()),
Ok(Ok(Err(error))) => Err(anyhow::anyhow!(error)),
Ok(Err(_)) => Err(anyhow::anyhow!(
"worker '{}' closed its startup stream before worker_ready",
spec.name
)),
Err(_) => Err(anyhow::anyhow!(
"worker '{}' did not emit worker_ready within {} seconds",
spec.name,
WORKER_STARTUP_READY_TIMEOUT.as_secs()
)),
};
if let Err(error) = startup_result {
self.remove_failed_startup(&spec.name).await;
return Err(error).context("worker failed startup readiness handshake");
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🔴 Broker stops handling all work while waiting for a new agent to start, and can kill a healthy agent

The broker now waits inline for a newly launched agent's readiness signal (timeout(WORKER_STARTUP_READY_TIMEOUT, startup_ready_rx) at crates/broker/src/worker.rs:883) inside its single event loop, so while any agent is starting the broker processes nothing else and stops reading agents' output; that output backlog can fill and block the very readiness message being waited for, so a healthy agent is torn down as failed.
Impact: Every agent launch freezes the whole broker for up to 30 seconds, and under concurrent output a starting agent that is actually fine can be killed and reported as a spawn failure.

Event-loop starvation and shared-channel backpressure deadlock during the readiness handshake

The runtime is a single-task tokio::select! loop (crates/broker/src/runtime/event_loop.rs:212-227) that awaits spawn handlers inline (handle_fleet_action_spawnspawn_worker_from_request at crates/broker/src/runtime/relaycast_events.rs:481WorkerRegistry::spawn). The new readiness wait at crates/broker/src/worker.rs:883 blocks that loop for up to WORKER_STARTUP_READY_TIMEOUT (30s), during which worker_event_rx (capacity 1024, created at crates/broker/src/runtime/init.rs:505) is never drained.

All worker stdout/stderr reader tasks share the single cloned event_tx. The readiness oneshot is sent before tx.send(...) only for the worker_ready/worker_exited line itself; every line emitted before worker_ready still goes through tx.send(WorkerEvent::Message {...}).await at crates/broker/src/worker.rs:1658. If the shared channel fills — from this worker's own pre-ready worker_stream output or from other already-running chatty workers whose readers keep producing while the loop is blocked — the new worker's reader blocks on that tx.send().await and can never advance to the worker_ready line (crates/broker/src/worker.rs:1623-1636). The oneshot never fires, the 30s timeout elapses, and remove_failed_startup (crates/broker/src/worker.rs:135-147) kills and unregisters a healthy worker, returning spawn_failed.

This backpressure interaction is new to this change: prior inline blocking during spawn (e.g. Codex model detection) happened before the child/readers existed, so no reader was producing into the shared channel while the loop was blocked.

Prompt for agents
The readiness handshake at crates/broker/src/worker.rs:883 is awaited inline inside the single-task broker event loop (crates/broker/src/runtime/event_loop.rs run() -> handle_fleet_action_spawn -> spawn_worker_from_request -> WorkerRegistry::spawn). While it waits (up to 30s), the loop does not drain worker_event_rx (mpsc capacity 1024, init.rs:505), which is shared by every worker reader task via cloned event_tx. Worker reader tasks forward each pre-worker_ready output line through tx.send(...).await (worker.rs:1658). If that shared channel fills during the wait (from the new worker's own pre-ready worker_stream output, or from other running workers' output), the new worker's reader blocks before it can reach and detect the worker_ready line, the readiness oneshot never fires, the 30s timeout expires, and remove_failed_startup kills a healthy worker and returns spawn_failed. Additionally, the whole broker (API, deliveries, relaycast, other workers' event processing) is frozen for the entire handshake. Consider decoupling the readiness wait from the event loop so worker events keep being drained during startup (for example: keep processing worker_event_rx while a spawn is pending, drive the readiness handshake from a task that does not hold the event loop, or ensure the readiness signal cannot be starved by shared-channel backpressure — e.g. detect readiness on a path independent of the shared bounded WorkerEvent channel).
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🧹 Nitpick comments (2)
crates/broker/src/worker.rs (2)

1622-1657: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Redundant re-fetch of the JSON type field.

msg_type is already computed at line 1622 but line 1637-1640 recomputes value.get("type").and_then(Value::as_str) instead of reusing it.

♻️ Reuse the already-computed `msg_type`
-                    if value
-                        .get("type")
-                        .and_then(Value::as_str)
-                        .is_some_and(|msg_type| msg_type == "worker_stream")
-                    {
+                    if msg_type.is_some_and(|msg_type| msg_type == "worker_stream") {
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/broker/src/worker.rs` around lines 1622 - 1657, Reuse the existing
msg_type value in the worker_stream check instead of fetching and parsing
value["type"] again. Update the condition following the startup readiness match
while preserving the current worker_stream payload handling and append_log_chunk
behavior.

326-330: 🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

unwrap_or eagerly evaluates current_exe() even when an override is set.

Option::unwrap_or evaluates its argument unconditionally (unlike unwrap_or_else), so std::env::current_exe().context(...)? runs — and can fail/short-circuit spawn() — even when worker_program_override is Some. In practice this only affects the #[cfg(test)] override path, but it's a real logic smell that could break the override's intent if current_exe() ever fails in a constrained test/sandbox environment.

♻️ Proposed fix using lazy evaluation
-        let worker_program = self.worker_program_override.clone();
-        let mut command = Command::new(
-            worker_program
-                .unwrap_or(std::env::current_exe().context("failed to locate current executable")?),
-        );
+        let worker_program = self.worker_program_override.clone();
+        let program = match worker_program {
+            Some(program) => program,
+            None => std::env::current_exe().context("failed to locate current executable")?,
+        };
+        let mut command = Command::new(program);
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/broker/src/worker.rs` around lines 326 - 330, Update the worker
command construction in the relevant spawn flow to use lazy fallback evaluation,
replacing the eager Option fallback around worker_program_override with the
corresponding deferred variant. Preserve the existing current_exe lookup,
context, and error propagation when no override is configured.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Nitpick comments:
In `@crates/broker/src/worker.rs`:
- Around line 1622-1657: Reuse the existing msg_type value in the worker_stream
check instead of fetching and parsing value["type"] again. Update the condition
following the startup readiness match while preserving the current worker_stream
payload handling and append_log_chunk behavior.
- Around line 326-330: Update the worker command construction in the relevant
spawn flow to use lazy fallback evaluation, replacing the eager Option fallback
around worker_program_override with the corresponding deferred variant. Preserve
the existing current_exe lookup, context, and error propagation when no override
is configured.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: f30d72e7-9c26-4300-9f21-08378666719a

📥 Commits

Reviewing files that changed from the base of the PR and between 7d1faa4 and 87c0456.

📒 Files selected for processing (7)
  • CHANGELOG.md
  • crates/broker/src/runtime/dead_letter.rs
  • crates/broker/src/worker.rs
  • packages/cli/src/cli/agent-relay-mcp.startup.test.ts
  • packages/cli/src/cli/agent-relay-mcp.test.ts
  • packages/cli/src/cli/agent-relay-mcp.ts
  • packages/sdk/src/messaging/thin-client.ts

@miyaontherelay

Copy link
Copy Markdown
Contributor Author

Additional production evidence for the strict-identity registration fix: at approximately 09:47 UTC on 2026-07-19, a live NightCTO lead session failed mid-post_message with agent_token_invalid: The selected Relaycast agent token is no longer valid. The stale token was cleared from this MCP session. The session recovered only by re-registering under a distinct name and retrying. This is the same token-rotation/ping-pong failure addressed here; it also previously stranded ncto-nango-slack for roughly 2.5 hours while a sent response was effectively unreachable. No code changes accompany this comment.

@khaliqgant

Copy link
Copy Markdown
Member

CI diagnosis, from outside this repo — the failing check is not what the review-risk note predicted.

Posting because the PR notes flagged "the 30s readiness deadline / cold-cache PTY behavior" as the review risk, and that turns out not to be what the E2E is failing on. Anyone reaching for a wider deadline would get a green test that fixed nothing.

Actual failing assertions (run 29679806461, step 10 Run two-node fleet E2E):

Error: waitFor timed out (node-a load exceeds node-b); last=null
AssertionError: expected 404 to be less than 300     ×3

No worker_ready, no readiness timeout, no spawn failure. It fails on load distribution across two nodes — waiting for node-a's load to exceed node-b's, value never appeared — plus three 404s.

The same job passes on main:

main — workflow "Fleet E2E" run 29683471018 @ b26789fbf, 2026-07-19T10:30:51Z
  job "Two-node fleet matrix" → SUCCESS

Verified as the same job, not a similarly-named one.

What that establishes, and what it doesn't. Green on main makes "this PR introduced it" the leading explanation and moves the PR from blocked by flaky CI to blocked by its own regression. It does not prove causation — that is one main run against one PR run, and an intermittent flake produces the same picture. Two runs is not a rate.

Cheapest way to settle it: re-run the failing job. Green on re-run → flake. Red again → regression confirmed. One click, and it resolves what a single-run comparison can only suggest.


Separately, the production evidence for the bugs this PR fixes has grown and is worsening:

  • 12 dead letters on one host, all addressed to a single agent, all after 10 attempts, for messages that were received — delivery succeeding while acks fail
  • the same relay message ID redelivered 8+ times, rate increasing rather than decaying, which rules out producer loops, Slack redelivery, and exponential backoff
  • a register_agent token invalidated mid-session, recovered only by re-registering
  • a task dispatched to a worker whose node had died 7 minutes earlier, with no error surfaced to the sender

To be explicit, because these two halves get conflated: the severity of the underlying bugs does not make a failing fix mergeable. The evidence argues for someone picking this up, not for merging it as-is.

Not my repo. No changes made, nothing pushed, no merge attempted — diagnosis only, so whoever owns this does not have to re-derive it.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants