Skip to content

feat: dispatch + routing — the conductor directs the fleet (P4) - #130

Merged
saucam merged 4 commits into
mainfrom
feat/conductor-p4
Jul 9, 2026
Merged

feat: dispatch + routing — the conductor directs the fleet (P4)#130
saucam merged 4 commits into
mainfrom
feat/conductor-p4

Conversation

@saucam

@saucam saucam commented Jul 9, 2026

Copy link
Copy Markdown
Collaborator

What

P4 of the conductor (docs/conductor-build-plan.md) — the phase that turns the read-only supervisor (P3, #124) into something that can safely direct existing sessions and spawn disposable workers. Four pillars:

1. Durable work queue (hermes Kanban pattern)

dispatch_tasks in the Store owns every dispatch's lifecycle — not the conductor's turn — so tasks survive daemon restarts:

  • Atomic claims: single-statement UPDATE…RETURNING with a scalar subquery — two claimers can never take the same task.
  • Crash recovery: claim_owner is the daemon boot id; any claim held by another boot is a crashed run and gets reclaimed on the first tick. Every reclaim costs an attempt, so the reclaim counter doubles as the stuck-loop guard — a worker that keeps dying across restarts auto-blocks at failure_limit.
  • Retry backoff: a not_before gate with exponential delay, plus an execute-at-most-once-per-tick invariant (without it, a failing task burned its whole failure budget inside one tick — caught by the tests).
  • Lease renewal only while the worker is verifiably alive; a hung or approval-wedged worker stops renewing and lease expiry reclaims it.
  • Per-tenant worker cap with anti-starvation kind filtering (a deferred spawn never blocks the sends behind it, and a deferral burns no attempts).

2. R3 confirm as an invariant, not a mode default

  • fleet_send / fleet_spawn / fleet_interrupt are registered on codeoid_fleet but deliberately kept out of allowedTools — the SDK auto-allows listed tools and skips canUseTool entirely, so keeping them off the list is what makes every dispatch ride the existing approvalId flow with the full tool input shown to the owner. Zero new wire types.
  • On top: a hard gate in the auto-approve path — send-class fleet tools never auto-approve, not in autonomous mode, not under a turn budget. An autonomous conductor cannot dispatch silently.
  • Both guards are mutation-tested: removing either one fails the suite.

3. Leaf workers with shape-capped identities

  • fleet_spawn creates disposable role:"worker" sessions. Identity profiles by shape: scout (investigate-and-report) holds no tools:write; ship (deliver-a-change) does. No worker ever holds session:* — it cannot see or direct the fleet (the leaf property).
  • Design subtlety worth reading in agent-identity.ts: the worker's token is a root grant sanctioned by the owner's spawn approval, deliberately not a conductor delegation — ZeroID's three-way scope intersection means the conductor's session:*-scoped chain can never carry tools:write, which is R1 working as intended. created_by records the conductor lineage for audit.
  • Workers run autonomous with a bounded tool budget; exhaustion reverts to guarded → waiting_approval, which the dispatcher surfaces to the conductor (the owner can attach and approve) and otherwise resolves via lease expiry.

4. Digests, never transcripts

  • Worker completion → bounded digest (final assistant message excerpt + episode one-liners) → durable dispatch_eventsone batched <fleet_events> injection into the conductor when it's idle (burst-collapse: N completions = one wake). Events survive a crash between completion and delivery.
  • Restart recovery continues a surviving worker session (re-arming its budget) or respawns fresh.

Bonus fix: a pre-existing lost-approval race

The waiting_confirmation broadcast lands a beat before canUseTool registers its resolver — an approve/deny arriving in that window was silently dropped and the turn hung forever. Found by the new gate test; fixed by buffering early decisions and consuming them at registration.

Exit criteria (build plan P4)

  • "continue the authz fix in that session" → resolves → confirms → sends: fleet_findfleet_send behind approval ✓
  • a spawned child's result returns as a digest ✓ (dispatcher tests)
  • a worker survives a daemon restart and resumes ✓ (boot-reclaim + continuation tests)
  • conductor context stays O(active threads) ✓ (digests + batched events, never transcripts)

Deferred (documented): per-workspace autonomy modes (v1 = always confirm, the safest default), heartbeat backoff supervision beyond the dispatcher tick, .clean_shutdown marker.

Testing

45 new tests: dispatch-store (claim/reclaim/backoff/tenancy semantics), dispatcher (send/spawn lifecycle, crash recovery, cap, wedge, event batching), fleet send handlers, worker identity profiles, and the R3 approval gate driven through a real Session + MockSessionProvider. Two mutation checks verified the load-bearing guards. 1110 unit tests, 6 live-ZeroID integration tests, typecheck + lint all green.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Added a durable dispatch queue for queued, retryable, and blocked session actions.
    • Introduced worker sessions with limited capabilities and batched fleet event updates.
    • Added new fleet tools for sending, spawning, interrupting, and reviewing queued tasks.
  • Bug Fixes

    • Improved approval handling so early responses are preserved and no longer get lost.
    • Added safer recovery for stalled work, including reclaiming expired claims and retry backoff.
  • Tests

    • Expanded automated coverage for dispatch, worker identity, fleet approvals, and queue persistence.

Send-class fleet actions, safely. Four pillars:

DURABLE WORK QUEUE (hermes Kanban). dispatch_tasks in the Store owns every
dispatch's lifecycle — not the conductor's turn. Atomic single-statement
claims (UPDATE..RETURNING), boot-id stale reclaim (a claim held by a dead
boot = crash; attempts++ makes the reclaim counter double as the stuck-loop
guard, auto-blocking at failure_limit), exponential retry backoff via a
not_before gate, execute-at-most-once-per-tick invariant, lease renewal only
while the worker is verifiably alive, per-tenant worker cap with
anti-starvation kind filtering. Dispatcher loop mirrors the OAuth sweeper
lifecycle; tick() is public so tests drive it without timers.

R3 CONFIRM AS AN INVARIANT. fleet_send / fleet_spawn / fleet_interrupt are
registered on codeoid_fleet but deliberately kept OUT of allowedTools (the
SDK auto-allows listed tools and skips canUseTool entirely), so every
dispatch rides the existing approvalId flow with the full input shown to the
owner. On top, a HARD gate in #shouldAutoApprove/#peekAutoApprove: send-class
fleet tools never auto-approve — not in autonomous mode, not under a budget.
Both guards are mutation-tested.

LEAF WORKERS. fleet_spawn creates disposable role:"worker" sessions with
shape-capped identities: scout holds no tools:write, and NO worker ever
holds session:* (it cannot see or direct the fleet). The token is a root
grant sanctioned by the owner's spawn approval — deliberately NOT a
conductor delegation, since ZeroID's scope intersection means the
conductor's session-scoped chain can never carry tools:write (R1 working as
intended); created_by records the conductor lineage. Workers run autonomous
on a bounded tool budget; exhaustion wedges into waiting_approval, which the
dispatcher surfaces to the conductor and resolves via lease expiry.

DIGESTS, NEVER TRANSCRIPTS. Worker completion → bounded digest (final
assistant message excerpt + episode summaries) → durable dispatch_events →
ONE batched <fleet_events> injection into the conductor when it's idle
(burst-collapse; events survive a crash between completion and delivery).
Restart recovery: reclaimed spawn tasks continue their resumed worker
session (re-arming the autonomous budget) or respawn fresh.

Also fixes a pre-existing lost-approval race: the waiting_confirmation
broadcast lands a beat before canUseTool registers its resolver, so a fast
approve/deny in that window was silently dropped and the turn hung forever.
Decisions arriving early are now buffered and consumed at registration.

Tests: 45 new across dispatch-store (claim/reclaim/backoff semantics),
dispatcher (lifecycle, crash recovery, cap, wedge, event batching),
fleet send handlers, worker identity profiles, and the R3 gate (mutation-
verified: removing either the hard gate or the once-per-tick guard fails
the suite). 1110 unit tests + 6 live-ZeroID integration tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jul 9, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@saucam, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 40 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 380a2fde-0fe5-4ee4-af93-d36d6920499a

📥 Commits

Reviewing files that changed from the base of the PR and between c252733 and 95385b6.

📒 Files selected for processing (7)
  • src/config.ts
  • src/daemon/dispatch.ts
  • src/daemon/session-manager.ts
  • src/daemon/store.ts
  • src/tests/dispatch-host.test.ts
  • src/tests/dispatch-store.test.ts
  • src/tests/dispatcher.test.ts
📝 Walkthrough

Walkthrough

This PR introduces a durable SQLite-backed dispatch queue (P4) enabling a conductor session to send prompts, spawn/continue worker sessions, and interrupt them via approval-gated fleet tools. It adds a Dispatcher engine with retry/backoff/failure-blocking, worker identity scoping, session role widening, hard-blocked auto-approval for send-class tools, and daemon lifecycle wiring, along with config, docs, and tests.

Changes

Fleet Dispatch Queue (P4)

Layer / File(s) Summary
Dispatch configuration
src/config.ts
Adds DispatchSchema Zod config with defaults, wires it into root schema, CodeoidConfig, and loadConfig().
Durable queue storage
src/daemon/store.ts, src/tests/dispatch-store.test.ts
Adds dispatch_tasks/dispatch_events tables, row types, and Store methods for enqueue/claim/complete/fail/reclaim/release/event delivery, with corresponding tests.
Dispatcher engine
src/daemon/dispatch.ts, src/tests/dispatcher.test.ts
Implements Dispatcher class with tick loop, DispatcherHost contract, retry/backoff, failure-limit blocking, worker finalization, and batched event delivery, with full test coverage.
Worker identity
src/daemon/agent-identity.ts, src/tests/agent-identity-conductor.test.ts
Adds WORKER_SCOPE_PROFILES (ship/scout) and registerWorker(...) minting scoped worker identities with conductor lineage; tests cover scope rules and failure fallback.
Session roles and approval gate
src/daemon/session.ts, packages/protocol/src/types.ts, src/daemon/transcript.ts, src/tests/fleet-approval-gate.test.ts
Widens role to include "worker", adds worker session options and early-approval buffering, hard-blocks fleet send/spawn/interrupt from auto-approval, and wires registerWorker into identity setup, with approval-gate tests.
Fleet send-class tools
src/daemon/fleet.ts, src/tests/fleet.test.ts
Adds FleetTaskView, FleetDispatchDeps, FLEET_SEND_TOOL_NAMES/isFleetSendTool, fleet_send/fleet_spawn/fleet_interrupt/fleet_tasks handlers, registers them in the MCP server, and updates the conductor prompt/tests.
Daemon wiring
src/daemon/session-manager.ts, src/daemon/server.ts
Wires dispatcher lifecycle, status observer propagation, dispatcher host implementation, conditional dispatch tool exposure, and startup/shutdown ordering.
Design documentation
docs/conductor-design.md
Rewrites the read-only vs dispatch section describing the P4 queue, worker model, and event digest behavior.

Estimated code review effort: 5 (Critical) | ~100 minutes

Sequence Diagram(s)

sequenceDiagram
  participant Conductor
  participant FleetMCP as Fleet MCP Server
  participant Dispatcher
  participant Store
  participant Worker as Worker Session

  Conductor->>FleetMCP: fleet_spawn(prompt, workdir)
  FleetMCP->>Dispatcher: enqueue(spawn task)
  Dispatcher->>Store: dispatchEnqueue(task)
  Dispatcher->>Store: dispatchClaimNext(bootId)
  Dispatcher->>Worker: spawnWorker(prompt, shape)
  Worker-->>Dispatcher: onSessionStatus(idle)
  Dispatcher->>Worker: buildWorkerDigest()
  Dispatcher->>Store: dispatchComplete(id, digest)
  Dispatcher->>Store: dispatchEventAdd(task_done)
  Dispatcher->>Conductor: deliverEvents(<fleet_events>)
Loading
sequenceDiagram
  participant Owner
  participant Session
  participant Client

  Session->>Session: shouldAutoApprove(fleet_send tool)
  Session->>Client: waiting_confirmation tool call
  Client->>Owner: display approval request
  Owner->>Session: approve()/deny()
  Session->>Session: waitForApproval resolves decision
  Session->>Session: proceed or abort dispatch action
Loading

Possibly related PRs

  • saucam/codeoid#38: Both PRs modify approval/auto-approval logic in src/daemon/session.ts, one refactoring it and the other hard-blocking fleet send tools and adding early-approval buffering.
  • saucam/codeoid#51: Both PRs extend identity/scoping machinery in src/daemon/agent-identity.ts, with this PR's worker registration relying on the conductor wimseUri/scopes introduced previously.
  • saucam/codeoid#124: This PR expands the read-only codeoid_fleet tool surface from PR #124 into a split send-class/approval-gated dispatch model in src/daemon/fleet.ts.
🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 15.38% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title is concise and clearly reflects the main change: dispatch/routing for the conductor and fleet in P4.
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 unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/conductor-p4

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.

@codecov

codecov Bot commented Jul 9, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 96.65552% with 30 lines in your changes missing coverage. Please review.
✅ Project coverage is 79.49%. Comparing base (a710eaa) to head (95385b6).
✅ All tests successful. No failed tests found.

Files with missing lines Patch % Lines
src/daemon/session-manager.ts 93.22% 16 Missing ⚠️
src/daemon/dispatch.ts 97.08% 8 Missing ⚠️
src/daemon/agent-identity.ts 93.10% 4 Missing ⚠️
src/daemon/fleet.ts 98.21% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #130      +/-   ##
==========================================
+ Coverage   77.85%   79.49%   +1.64%     
==========================================
  Files          89       90       +1     
  Lines       14395    15251     +856     
==========================================
+ Hits        11207    12124     +917     
+ Misses       3188     3127      -61     
Flag Coverage Δ
daemon 79.49% <96.65%> (+1.64%) ⬆️

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
packages/protocol/src/types.ts 100.00% <ø> (ø)
src/config.ts 90.44% <100.00%> (+0.54%) ⬆️
src/daemon/session.ts 85.72% <100.00%> (+3.09%) ⬆️
src/daemon/store.ts 92.38% <100.00%> (+5.43%) ⬆️
src/daemon/transcript.ts 96.66% <ø> (ø)
src/daemon/fleet.ts 97.71% <98.21%> (+0.28%) ⬆️
src/daemon/agent-identity.ts 51.73% <93.10%> (+6.11%) ⬆️
src/daemon/dispatch.ts 97.08% <97.08%> (ø)
src/daemon/session-manager.ts 62.25% <93.22%> (+7.98%) ⬆️
🚀 New features to boost your workflow:
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

dispatcher.test.ts fakes the host; this adds the other half — the REAL
SessionManager host with MockSessionProviders injected via a new test-only
_testProviderFactory (mirrors SessionCreateOptions._testProvider):

- spawn end-to-end: a real role:"worker" session with the autonomous budget,
  the sentinel-marked scout brief, a digest built from the worker's actual
  final message, teardown after completion, and ONE <fleet_events> injection
  into a real conductor session.
- send end-to-end: conductor-attributed delivery with the owner-approved
  prefix; cross-tenant targets fail terminally (tenancy wall).
- fleet dispatch deps exercised as real closures (enqueue lineage stamping,
  board mapping, workdir validation, cross-tenant interrupt refusal) via an
  extracted _fleetDispatchDeps — behavior unchanged.

Test harness drains sessions before removing its temp dir so in-flight meta
writes can't ENOENT into unrelated test files.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 3

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

Inline comments:
In `@src/config.ts`:
- Around line 344-366: The dispatch configuration schema is missing
`retryBaseMs`, so `DispatchConfig.retryBaseMs` cannot be set through
`CodeoidConfig.dispatch` or overridden from env and remains stuck at the default
used by `Dispatcher`. Update `DispatchSchema` and the mirrored
`CodeoidConfig.dispatch` type in `src/config.ts` to include `retryBaseMs` with
the same default as `DEFAULT_DISPATCH_CONFIG.retryBaseMs`, and wire it into the
parsed `dispatch` block so `SessionManager` passes it through to `new
Dispatcher(...)`. Also add the corresponding `CODEOID_DISPATCH_*` environment
precedence handling for the new dispatch fields, matching the existing config
sections.

In `@src/daemon/dispatch.ts`:
- Around line 264-298: The spawn deferral logic in `#claimAndExecute` is too
broad: setting kindFilter to "send" after one tenant hits maxConcurrentWorkers
blocks all remaining spawn tasks across every tenant for the rest of the tick.
Update the scheduling in `#claimAndExecute` and, if needed, dispatchClaimNext to
scope deferrals to the specific offending (accountId, projectId) tenant instead
of the global kind, so other tenants’ spawn rows can still be claimed. Also
ensure dispatchRelease does not cause the same capped tenant’s deferred spawn to
monopolize the queue across ticks; use tenant-aware skipping or per-tenant
iteration to prevent starvation.
- Around line 300-353: The catch path in `#execute` leaves a worker session
orphaned when spawnWorker or continueWorker throws and dispatchFail returns
blocked or failed. Update the error handling in `#execute` to destroy the worker
via this.#host.destroyWorker for any task that already has task.workerSessionId
(especially after a failing continueWorker attempt), matching the cleanup
behavior used in `#finishWorkerTask`. Keep the existing dispatchFail and emitEvent
flow, but ensure the session is torn down before exiting the catch.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 337464c6-b675-4902-8b96-86b194bf8313

📥 Commits

Reviewing files that changed from the base of the PR and between a710eaa and c252733.

📒 Files selected for processing (16)
  • docs/conductor-design.md
  • packages/protocol/src/types.ts
  • src/config.ts
  • src/daemon/agent-identity.ts
  • src/daemon/dispatch.ts
  • src/daemon/fleet.ts
  • src/daemon/server.ts
  • src/daemon/session-manager.ts
  • src/daemon/session.ts
  • src/daemon/store.ts
  • src/daemon/transcript.ts
  • src/tests/agent-identity-conductor.test.ts
  • src/tests/dispatch-store.test.ts
  • src/tests/dispatcher.test.ts
  • src/tests/fleet-approval-gate.test.ts
  • src/tests/fleet.test.ts

Comment thread src/config.ts
Comment thread src/daemon/dispatch.ts
Comment thread src/daemon/dispatch.ts
saucam and others added 2 commits July 9, 2026 09:50
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…own, retryBaseMs config

- Replace the global kind='send' cap-deferral filter with per-task claim
  exclusion (json_each id list): a capped tenant defers only ITS task, so
  neither the sends behind it nor other tenants' spawns are starved. One
  mechanism now enforces both tick invariants (execute-at-most-once +
  no-head-of-line-blocking); test proves tenant B's spawn runs while
  tenant A sits at its cap.
- Close ALL THREE worker-orphan paths on terminal spawn failure: the
  #execute catch (continueWorker throwing), the reclaim-to-blocked path
  (crash-looped to the limit with a resumed worker — found by the new test,
  beyond the reviewed site), and a partial spawn in the manager (session
  created but the brief send failed → destroy before rethrow).
- Expose retryBaseMs through the config schema/type so the backoff is
  actually tunable, plus a CODEOID_DISPATCH_ENABLED env kill switch
  (other dispatch knobs stay file-config, matching the conductor block).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@saucam
saucam merged commit 8ac9c11 into main Jul 9, 2026
5 checks passed
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.

1 participant