Skip to content

feat: the dispatch barrier — N members, one joined result (P3 panel) - #268

Merged
saucam merged 3 commits into
mainfrom
feat/p3-dispatch-barrier
Jul 29, 2026
Merged

feat: the dispatch barrier — N members, one joined result (P3 panel)#268
saucam merged 3 commits into
mainfrom
feat/p3-dispatch-barrier

Conversation

@saucam

@saucam saucam commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

The N-way join from docs/collaborative-session-design.md §7 — "the one genuinely new dispatch primitive (today dispatch is per-task event injection, not an N-way join)" — plus the fleet_panel verb that uses it and the synthesis contract that consumes it.

Two commits: the barrier, and a migration fix that a live upgrade probe caught.


1. f05bce2 — the barrier

dispatch_tasks gains group_id + group_ordinal, both additive. NULL means standalone, which is every task queued before panels existed — those keep emitting their own completion event, unchanged.

#barrierAbsorb swallows each member's individual completion; whichever member observes the last terminal transition emits one merged event.

Design decisions worth reviewing rather than skimming:

  • Members are inserted in ONE IMMEDIATE transaction. A crash mid-fan-out would leave a group smaller than the panel the owner approved, and the barrier would then fire on a quorum nobody asked for.
  • It joins on all-TERMINAL, not all-DONE. Waiting for success hangs a goal on its weakest member, and §7 is explicit that disagreement is shown rather than hidden — a failed reviewer belongs in the merged digest as a failure, not dropped and not blocking its peers forever.
  • The merge is mechanical only — collect, label, order. §7 forbids a silent auto-vote, so no tallying happens here; the judgement is the orchestrator's synthesis step or a human's.
  • group_ordinal is stored, not derived. Every member shares one created_at, so a timestamp sort falls back to the random UUID and the "member N of 3" labels would shuffle between reads. My own comment claimed fan-out order while the code didn't deliver it; a test caught the lie.

What the barrier forced: grouped sends join on WORK, not delivery

This is the part that isn't obvious from the design doc. An ungrouped send completes the moment the prompt is handed over — dispatchComplete("delivered to session …"). A barrier over that fires the instant all N briefs are delivered, before a single reviewer has read anything. The panel would join on nothing.

So a grouped send is now watched to completion like a spawn. Ungrouped sends are untouched — nobody joins on those. That change pulls in three crash-safety invariants, and each fails silently:

# invariant what would have happened
1 #finishWorkerTask destroyed the session unconditionally on its done path Grouped sends are the FIRST sends ever routed through there, and a send's target is a long-lived role-child — so every joining panel would have dismantled its own fleet. Now guarded on kind === "spawn", with a contrast test proving a spawned worker is still destroyed so the guard can't be over-applied into a leak.
2 re-delivery is not idempotent After a restart the task is requeued and re-executed; blind re-sending hands the reviewer its brief twice. workerSessionId being set marks that delivery already happened, so it re-watches.
3 the turn may finish while the daemon is down Re-watching an already-idle target waits for a transition that never comes, until the lease expires and burns an attempt. Current status decides: still working → watch; already settled → finish now; gone → fail non-retryably, and the barrier joins with that member marked failed.

fleet_panel

Send-class by construction, which is what puts it behind the R3 approval gate and — for a collaborative session — carries the goal's cost roll-up into that prompt for free. Granted to the orchestrator and scoped in the deps like its siblings: every target must be one of its own children, checked where a second caller of that closure cannot route around it.

Unknown or foreign targets fail the whole fan-out rather than running a smaller panel. The owner approved a panel of N, and quietly reaching a 2-of-3 panel is exactly the silent degradation §7 warns about.

The orchestrator's constitution now states both halves of the contract: prefer a panel when the answers belong together and do not synthesize before the join arrives; then read each role's artifact, merge, and show disagreement rather than resolving it silently.

2. 900df32 — the migration bug, and why the suite couldn't see it

I declared the partial index on group_id inside the CREATE TABLE IF NOT EXISTS block. On an existing database that block is a no-op, so CREATE INDEX … ON dispatch_tasks(group_id) ran before ALTER TABLE ADD COLUMN and SQLite failed the whole migration:

error: Cannot open the codeoid database at …/codeoid.db: no such column: group_id

The daemon then refuses to open a database it had been using happily. Every existing install would have been bricked on upgrade.

Every unit test builds a fresh database, where CREATE declares every column and the ALTER path never runs — so all 2163 tests were structurally blind to it. What found it was a live probe: build the previous binary, let it write a database, then open that file with the new one.

The fix moves the index creation after the ALTERs. More importantly, dispatch-store.test.ts now reproduces the upgrade path — it strips the columns from a built database and reopens it — so this class of bug is covered by tests rather than by remembering to run a probe. Mutation-checked: reinstating the original ordering fails all three of the new cases.


Verification

Daemon suite 2149 → 2166. biome + typecheck clean; web untouched at 370.

Seven mutations, each caught by exactly the tests that should catch it:

mutation caught by
the destroy guard removed NEVER destroys a panel member — they are long-lived role-children
grouped sends complete on delivery 7 tests, incl. waits for the target's TURN, not for delivery
barrier fires on the first terminal member stays silent until the last member finishes
waits for all-DONE instead of all-terminal one failed member cannot hang the panel
restart re-sends the brief re-watches after a restart instead of re-sending
panel scoping removed a panel must target only its own children
index back inside CREATE TABLE all 3 upgrade-path tests

Measured in a detached worktree at the commit, not in my working tree — there was concurrent uncommitted work in the repo, so a tree-based measurement wouldn't have been a clean read of this change alone.

Live-verified old-binary → new-binary upgrade: columns added in place, the pre-upgrade session survives, the index lands on the upgraded database, a collaboration creates on it, 3 children spawn, and a group of 3 persists with stable ordinals 1/2/3.

Not in this PR

The pipeline panel phase kind (§8 item 4) — engine work in pipeline/ rather than dispatcher work, and it now has a barrier to build on. Also unchanged: reads/writes as live scheduling edges (P4), and the per-collaboration token budget (P6), which is the enforcement counterpart to the cost visibility shipped in #267.

🤖 Generated with Claude Code

saucam added 2 commits July 29, 2026 18:48
The one genuinely new dispatch primitive in the collaborative-session design
(§7 step 3). Today's dispatcher does per-task event injection; a review
panel needs an N-way join, because synthesis has to see every verdict at
once. N completions arriving one at a time give the orchestrator N chances
to synthesize from partial input — a panel silently degraded into a race.

## The barrier

`dispatch_tasks` gains `group_id` + `group_ordinal` (both additive; NULL
means standalone, which is every task queued before panels existed and
keeps its current behaviour exactly). Members are inserted in ONE
IMMEDIATE transaction: a crash mid-fan-out would otherwise leave a group
smaller than the panel the owner approved, and the barrier would fire on a
quorum nobody asked for.

`#barrierAbsorb` swallows each member's individual completion; the member
that observes the LAST terminal transition emits one merged event.

It joins on **all-terminal, not all-done**. Waiting for success would hang
a goal on its weakest member, and §7 is explicit that disagreement is shown
rather than hidden — a failed reviewer belongs in the merged digest as a
failure, not dropped and not blocking its peers forever.

The merge is mechanical only: collect, label, order. §7 forbids a silent
auto-vote, so there is no tallying here — the judgement is the
orchestrator's synthesis step or a human's. `group_ordinal` is stored
rather than derived because every member shares one `created_at`, so a
timestamp sort falls back to the random UUID and the "member N of 3" labels
would shuffle between reads.

## What the barrier forced: grouped sends join on WORK, not delivery

This is the part that isn't obvious. An ungrouped `send` completes the
moment the prompt is handed over — `dispatchComplete("delivered to
session …")`. A barrier over that fires the instant all N briefs are
delivered, before a single reviewer has read anything. The panel would join
on nothing.

So a GROUPED send is now watched to completion like a spawn: mark running,
watch the target, finish on its idle transition. Ungrouped sends are
untouched — nobody is joining on those. That change drags in three of the
dispatcher's crash-safety invariants, and each one fails silently:

1. **`#finishWorkerTask` destroyed the session unconditionally** on its
   done path. Grouped sends are the FIRST sends ever routed through there,
   and a send's target is a long-lived ROLE-CHILD — so every joining panel
   would have dismantled its own fleet. Now guarded on `kind === "spawn"`,
   with a contrast test proving a spawned worker is still destroyed so the
   guard can't be over-applied into a leak.
2. **Re-delivery is not idempotent.** After a restart the task is requeued
   and re-executed; blindly re-sending hands the reviewer its brief twice.
   `workerSessionId` being set is the marker that delivery already
   happened, so `#startGroupedSend` re-WATCHES instead.
3. **The turn may have finished while the daemon was down.** Re-watching an
   already-idle target waits for a transition that never comes, until the
   lease expires and burns an attempt. So current status decides: still
   working → watch; already settled → finish now; gone → fail
   non-retryably, and the barrier joins with that member marked failed.

## fleet_panel

Send-class by construction, which is what puts it behind the R3 approval
gate and — for a collaborative session — carries the goal's cost roll-up
into that prompt for free. Granted to the orchestrator, scoped in the DEPS
like its siblings: every target must be one of its own children, checked
where a second caller of that closure cannot route around it.

Unknown or foreign targets fail the WHOLE fan-out rather than running a
smaller panel. The owner approved a panel of N, and quietly reaching a
2-of-3 panel is exactly the silent degradation §7 warns about.

The orchestrator's constitution now states both halves of the contract:
prefer a panel when the answers belong together and do not synthesize
before the join arrives; then read each role's artifact, merge, and SHOW
disagreement rather than resolving it silently.

## Verification

Daemon suite 2149 to 2163. Six mutations, each caught by exactly the tests
that should catch it: the destroy guard removed (a joined panel tears down
its role-children), grouped sends completing on delivery, the barrier
firing on the first terminal member, waiting for all-DONE instead of
all-terminal, a restart re-sending the brief, and the panel scoping
removed.

One test caught a false claim in my own comment: `dispatchGroupMembers`
documented fan-out order while ordering by `created_at` — identical across
a group, so it fell back to random UUID order. That is what `group_ordinal`
exists for.

Not in this PR: the pipeline `panel` phase kind (§8 item 4). That is engine
work in `pipeline/` rather than dispatcher work, and it now has a barrier to
build on.
…side CREATE TABLE

On an existing database `CREATE TABLE IF NOT EXISTS` is a no-op, so a partial
index declared inside that block ran against a table without the column and
SQLite failed the whole migration: "no such column: group_id". The daemon then
refused to open a database it had been using — every existing install would
have been bricked on upgrade.

Every unit test builds a FRESH database, where CREATE declares every column and
the ALTER path never runs, so the entire suite was structurally blind to it. A
live upgrade probe (old binary writes the DB, new binary opens it) caught it.

`dispatch-store.test.ts` now reproduces the upgrade path by dropping the
columns from a built database and reopening it, so the additive-migration path
is covered by tests rather than by remembering to run a probe. Mutation-checked:
reinstating the original ordering fails all three.
@gemini-code-assist

Copy link
Copy Markdown

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@saucam

saucam commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator Author

pr-shepherd: CI is green at 900df32 — no fixes applied.

All 4 required/reported checks concluded SUCCESS on the PR head SHA (900df32), run 30445623772:

check result
daemon SUCCESS — lint, typecheck, test, build
web SUCCESS — lint, typecheck, test, build
Socket Security: Project Report SUCCESS
Socket Security: Pull Request Alerts SUCCESS

daemon tests: 2166 pass / 12 skip / 0 fail (2178 across 150 files, 83s). web: 39 test files passed, eslint 30 warnings / 0 errors.

The three error: lines in the daemon log (network down, db gone, bad listener) are deliberate fixtures in push.test.ts / messages.test.ts, not failures.

Notably the new migration tests in dispatch-store.test.ts executed for real — CI's bundled SQLite accepted ALTER TABLE … DROP COLUMN, so the upgrade-path regression guard is genuinely covered, and the barrier-timing block in dispatcher.test.ts did not flake.

Branch hygiene: 2 commits ahead of origin/main, 0 behind, no rebase needed; both subjects (feat: / fix:) satisfy the commit-prefix regex. I pushed nothing and changed no files.

Remaining gate is human review only: the main ruleset requires 1 approving review (required_approving_review_count: 1, no code-owner or thread-resolution requirement). mergeable=MERGEABLE, mergeStateStatus=BLOCKED solely on REVIEW_REQUIRED. Merging stays a human decision.

…hree barrier defects an audit found

A full audit of the collaborative-sessions feature turned up one Critical and
two High defects. All four fixes here; the Critical one also repairs
already-merged behaviour from #267.

## Critical — an orchestrator never received its own dispatch results

`deliverEvents` resolved its recipient with `#conductorFor()` — the tenant's
CONDUCTOR — for every event. But a collaboration orchestrator queues its own
tasks (`fleet_send` since #267, `fleet_panel` here) and they are attributed
`orchestrator:<goalId>`.

With no conductor on the daemon — the common case, since one is created only
on an explicit `session.create` with `role:"conductor"`, never at boot — the
host returned false and the events were retried forever. Reproduced: a joined
panel left `pending: 1 ["group_done"]` after a tick, permanently. With a
conductor present it was wrong differently: the panel's result landed in the
CONDUCTOR's session and the orchestrator still never learned its panel had
joined.

So the entire §7 barrier→synthesis loop was unreachable. Everything upstream
worked; the last mile went to the wrong session.

Events now route by `task.createdBy`: an orchestrator-attributed task delivers
to that goal session, everything else to the conductor as before. Attribution
is parsed through `goalIdFromCreatedBy`, paired with the
`orchestratorCreatedBy` that writes it, because this bug WAS the drift between
an inline template string on one side and a `startsWith` on the other.

`deliverEvents` now returns the ids it delivered rather than a boolean. With
several possible recipients, "one is mid-turn" is a normal state, and
all-or-nothing would either stall an idle recipient or re-deliver its events
later as duplicates. Events whose collaboration no longer exists are retired
with an audit line instead of being retried forever.

## High — the barrier swallowed the "member is wedged" notice

`#emitEvent` is also how a `waiting_approval` wedge is surfaced, and
`#barrierAbsorb` swallowed it: the group was not terminal, so the notice was
absorbed and never written. A panel member that exhausted its tool budget went
unreported and the panel hung to lease expiry — and that notice is the one
message whose entire purpose is to reach a human.

The barrier now absorbs only THIS member's terminal completion. Keying on the
member's own status rather than on the event type keeps the rule in one place:
a non-terminal task has not completed, so it is not the barrier's business.

## High — two live tasks targeting one session evicted each other

`#watched` was `Map<sessionId, taskId>`. Every key used to be a freshly created
spawn worker, unique by construction — but a grouped send watches a
PRE-EXISTING session, and the same role-child can be the target of two live
dispatches (a second panel, or a plain `fleet_send` alongside a running one).
The second registration silently evicted the first, whose task then sat in
`running` until the lease expired, hanging its barrier for the whole lease.

Now `Map<sessionId, Set<taskId>>` behind `#watch`/`#unwatch`/`tasksForSession`,
with every watcher completing on the shared transition and every watcher's
lease renewed.

## Medium — duplicate-delivery window on a grouped send

`dispatchMarkRunning` now runs BEFORE `sendToSession`. `running` is already a
reclaimable state so ordering it first costs nothing, whereas marking after
left a window where a crash between delivery and the marker made the retry
re-send — the reviewer working its brief twice with the digest describing only
the second pass.

## Why the suite could not see any of this

`dispatcher.test.ts` drives a FakeHost whose `deliverEvents` always accepts, so
every barrier test passed against events that were undeliverable in production.
A fake that rubber-stamps the last mile cannot see a last-mile bug. The new
routing tests run against the REAL host in `dispatch-host.test.ts`.

Daemon suite 2166 to 2172. Six mutations caught by exactly the right tests:
conductor-only routing, all-or-nothing event marking, the watched collision,
the absorbed wedge notice, plus the earlier barrier set.

The mark-running ordering is NOT covered: it is a two-statement crash window
with no seam to inject a crash, so it rests on argument rather than a test.

Mutation testing also caught a flaw in these very tests. The first draft
asserted `pending().length === 0`, which a RETIRED event satisfies just as well
as a delivered one — so reverting to conductor-only routing passed. They now
assert a completed turn on the specific recipient session, since draining a
queue is not delivering to anybody.
@saucam
saucam merged commit 9c677d1 into main Jul 29, 2026
4 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.

2 participants