feat(agent-routing): elect one agent for unaddressed human group messages - #123
Conversation
|
Status check on the ladder, since ② has moved: #124 landed on main on 2026-09-01 ( What is still missing is the reading. Nobody has posted the group fan-out width and the silent-wake rate from that card over a real window, before/after #92's I did read the code now rather than later, because three of the findings would bite on day one regardless of the data:
Smaller: the description says a Textually it still merges clean. When you have the panel reading, post it here and on #70, fix the four above, and I'll pick it up. |
|
Two things from re-reading this against Step ② has landed. But the panel cannot see the population this PR changes. That is the part I would want settled before reading its numbers as a verdict. This PR narrows the
The average is therefore diluted by the half that was already fixed. If #92 is working, the group average is lower than the unaddressed fan-out actually is — so the panel understates the prize here, and after this ships the two effects move the same number in the same direction with no way to separate them. The cheap split, and why I did not just send it. The addressed bit already exists exactly where it is decided: // scheduler.ts:784-788
const targets = [
...mentionedAgentIds(messageBody, recipients),
...(quotedAuthorId && recipients.includes(quotedAuthorId) ? [quotedAuthorId] : []),
]
const uniqueTargets = [...new Set(targets)]
Computing it in SQL instead would mean a second implementation of The catch is that it only measures forward — no retroactive split — which for a gate phrased as "wait until the data justifies it" is fine, but it does mean the instrument wants to land some time before the change it judges. I'm happy to send that as its own PR if the shape looks right to you and @wg2038, since it is an extension of their panel rather than mine. Not proposing it as a blocker on this prototype — the design questions you opened it for stand on their own. |
|
Leaving this one open — it's still marked as a draft, so I've read it but not merged it. For context: I merged 16 PRs into |
b09314a to
2cea7b3
Compare
|
Rebased onto
All 7 CI checks on GitHub Actions are green. Looking forward to your review! |
2cea7b3 to
063b0fa
Compare
|
Rebased onto latest |
|
I read this one properly today because it came out of draft. Holding it — but on sequencing, not on quality. Why I'm not merging it Your own PR body still says it:
The ladder agreed in #70 was ① address signals in the prompt (#88, merged) → ② turns-per-message observability over If undrafting was deliberate and you'd like to reopen that decision, say so and I'll take it up on its merits — but I'd want the banner removed from the body first, because right now the PR argues against its own merge. Two things that make me comfortable waiting rather than rushing
One sequencing hazard worth knowing about Migration On your three open design questions — since you asked for a read, and I'd rather answer than leave them hanging:
Keep it open. Once ② lands and says the premise holds, this is the PR I'd want to build on. |
…se exposure window Addresses yetone's review on PR yetone#123 regarding the cursor-exposure window: when unaddressed human messages are handled via one-of-us election, unwoken candidates could permanently skip the message if a subsequent post auto-acks their read cursor to NOW. - In routing-claims.ts, check for active agent members whose read cursor in conversation_reads is behind the message when a claim resolves as 'served'. - Emit a 'catchup' SweepDecision to fanOutWake lagging room members, allowing them to catch up their read cursor while relying on existing glance/yield protocol to suppress redundant replies. - Add unit and PostgreSQL integration tests for cursor catch-up on claim resolution.
063b0fa to
c35d8f7
Compare
|
Rebased onto latest
All 7 CI checks are green. Leaving this open/parked as agreed until ② has collected sufficient baseline data in production. |
|
Status note, not a re-review — you've pushed three times in the last twenty minutes, so I'll do the full pass once you say it's settled. Two things outside this branch that affect it:
My earlier hold still stands on its own terms: the sequencing your own PR body sets out (this shouldn't land ahead of step ② of the ladder in #70), not the migration number. |
…se exposure window Addresses yetone's review on PR yetone#123 regarding the cursor-exposure window: when unaddressed human messages are handled via one-of-us election, unwoken candidates could permanently skip the message if a subsequent post auto-acks their read cursor to NOW. - In routing-claims.ts, check for active agent members whose read cursor in conversation_reads is behind the message when a claim resolves as 'served'. - Emit a 'catchup' SweepDecision to fanOutWake lagging room members, allowing them to catch up their read cursor while relying on existing glance/yield protocol to suppress redundant replies. - Add unit and PostgreSQL integration tests for cursor catch-up on claim resolution.
c35d8f7 to
b756cf5
Compare
…se exposure window Addresses yetone's review on PR yetone#123 regarding the cursor-exposure window: when unaddressed human messages are handled via one-of-us election, unwoken candidates could permanently skip the message if a subsequent post auto-acks their read cursor to NOW. - In routing-claims.ts, check for active agent members whose read cursor in conversation_reads is behind the message when a claim resolves as 'served'. - Emit a 'catchup' SweepDecision to fanOutWake lagging room members, allowing them to catch up their read cursor while relying on existing glance/yield protocol to suppress redundant replies. - Add unit and PostgreSQL integration tests for cursor catch-up on claim resolution.
b756cf5 to
61a1280
Compare
|
Thanks @yetone! The branch is now completely settled and ready for your review pass:
|
|
Status update, and an apology for how long this has sat. I want this one. #70 is about paying the expensive public-context reasoning N times for a single group message, and that is the most valuable thing in the queue right now — it is a direct token-cost fix, not a nicety. So this is not on hold for doubt about the idea any more. It cannot merge as it stands. Against current
all from the same cause: you renumbered to Since this is the third renumber you have had to do, that is on the process rather than on you. I would rather review it once and merge it than keep asking you to chase Two things I will be looking at closely when I do, so you can get ahead of them:
|
…ages The one-of-us half of yetone#70 (the me half landed in yetone#92). When a human group message names NOBODY, the router today short-circuits to a full fan-out, and production measures ~26% of group wakes replying with nothing: an open question makes every agent reason over the same room. This adds a second small-model decision, once per message: is the room expected to engage together (each), or should ONE agent take the turn (one-of-us)? - routing.ts: parseRoute stops lossily mapping one-of-us to each; a new unaddressed prompt answers each vs one-of-us and may propose a primary by roster role. The addressed path is unchanged — and even a rogue one-of-us there reads as full fan-out in recipientsForRoute. - routing-election.ts (pure): deterministic lineup — available agents first, stable id tie-break, the router's role-fit proposal honored only when that agent is available. Two replicas agree without coordinating. - routing-claims.ts: the durable lease row (agent_routing_claims). Wake delivery is already single-owner per message (the Redis wake-claim), so the row is not a mutex — it is the observable lease the sweeper uses to advance to the next candidate when the primary starts no agent_run within ELECTION_LEASE_MS (90s), and the history of how many candidates were burnt before one turned. Terminal rows reap after a day. - scheduler.ts: wire the unaddressed branch behind env.ROUTING_ONE_OF_US (kill-switch, same shape as STEER_ENABLED). Every uncertainty fails open: router error, an empty roster, a failed claim write, a claim that already resolved — all keep the full fan-out. This deliberately does NOT resurrect the daemon-side one-of-us claimReply that was removed for breaking chains: the election happens once, before waking, and the woken agent still runs its own glance/yield protocol. BYOA daemons are chosen by the same code path and still re-triage on wake; a deferred BYOA wake is exactly the no-show case the lease sweep exists to recover from. Known v1 coarseness: the sweep's has-run check accepts ANY agent_run started since the claim, not one tied to this conversation — a primary busy in another room reads as served, costing one silent room (the same silence the pre-election world lived in) rather than risking a double wake.
Raised by the yetone#70 review discussion: cursor semantics mean an agent that posts anything in the room acks its read cursor to NOW, skipping any message it was never woken for. The election widens that exposure — the later a candidate sits in the lineup, the longer the human message sits unprocessed for it — so an exhaust that merely marked the row terminal and waited for each member's next natural wake could leave the message permanently behind a cursor: not deferred, gone. The sweep now hands the room back to the pre-election behaviour when the lineup runs out: one fanOutWake of the original lineup, then the row is terminal and never touched again. For members already back online the wake is redundant but durable; for the ones whose laptop was shut during their window it is the difference between processing the message and skipping past it.
…and add integration tests - Fix Postgres syntax error in sweep query by using CTE with FOR UPDATE SKIP LOCKED - Anchor sweeper hasRunSince to cursor_advanced_at instead of claim creation time - Default ROUTING_ONE_OF_US to false (opt-in) for production safety - Add guard in scheduler against system messages and delivery recipients (PR yetone#133 compatibility) - Resolve claim re-delivery fallthrough by clearing recipients on served/exhausted claims - Comply with ADR 0003 by moving agent_routing_claims to versioned migration 0009, leaving frozen baseline DDL unchanged - Add PostgreSQL integration tests for routing claims lease and sweep behavior
…se exposure window Addresses yetone's review on PR yetone#123 regarding the cursor-exposure window: when unaddressed human messages are handled via one-of-us election, unwoken candidates could permanently skip the message if a subsequent post auto-acks their read cursor to NOW. - In routing-claims.ts, check for active agent members whose read cursor in conversation_reads is behind the message when a claim resolves as 'served'. - Emit a 'catchup' SweepDecision to fanOutWake lagging room members, allowing them to catch up their read cursor while relying on existing glance/yield protocol to suppress redundant replies. - Add unit and PostgreSQL integration tests for cursor catch-up on claim resolution.
61a1280 to
91f9275
Compare
|
Rebased onto 1. Authoritative server-arbitrated election (losers never woken)In } else if (claim?.status === 'pending') {
const primary = claim.candidates[claim.cursor]
if (primary && recipients.includes(primary)) {
console.log(`[scheduler] routed ${conversationId} to ${primary} (mode=one-of-us, ${recipients.length - 1} wake(s) avoided)`)
recipients = [primary]
}
}Losing agents are completely omitted from the wake array passed to 2. Deterministic bounded timeout floor & quiet primary fallbackIn
3. Migration Sequencing
All 7 CI checks are 100% green on GitHub Actions. Ping @yetone — ready for your review pass! |
|
Reviewed and merged. Thank you for the patience through four renumbers — that was the process's fault, not yours. On the two checkpoints I said I would look at closely.
One thing I want on the record before anyone turns the flag on. The catchup wake fires on every served claim, not just the unhappy ones. The sweep only sees a claim once its lease has lapsed, so the ordinary success path is: primary answers at T+2s, sweep marks it served at T+90s, and every other agent in the room — whose cursor is necessarily still behind, because we deliberately never woke them — is fanned out to. So That is still the right trade and I am not asking you to change it — it is exactly the mitigation I asked for, the catchup wakes carry But it means the number to watch when the flag goes on is turns per human message, not wakes, and "N−1 wake(s) avoided" in the scheduler log overstates what actually happened. Worth a follow-up to either soften that log line or resolve served claims eagerly (when the primary's run lands) instead of waiting out the full lease — the second would cut the latency the catchup adds as well. Neither blocks this.
|
Closes #70 (the
one-of-ushalf; themehalf landed in #92).Summary
When a human group message names NOBODY, the router today short-circuits to a full fan-out (
buildRouteRequestreturnseachwhentargets.length === 0), causing every agent to reason over the same room — production measures 26.3% of group wakes producing nothing.This PR wires
responseMode: 'one-of-us'for unaddressed human messages behind the opt-in flagROUTING_ONE_OF_US(defaultfalse), backed by a durable lease in Postgres and automatic fallback.Architecture & Components
routing.tsbuildUnaddressedRouteRequest/parseUnaddressedRoute/routeUnaddressedMessage: answerseachvsone-of-us+ optional role-fit proposal on the small model, tracked under purposemessage-routing.routing-election.ts(pure)orderCandidates(available first, stable id tie-break, busy-status lease honoured) +electLineup(router proposal wins only when that agent is available).routing-claims.ts+agent_routing_claimsagent_runwithin 90s; exhausts with full-room fan-out if lineup runs out; reaps terminal rows after 24h.routing-claims.tsserved), queries for room members whose read cursor inconversation_readsis still behind the message and emits acatchupwake, closing the cursor-exposure window.scheduler.tsenv.ROUTING_ONE_OF_US.0007-agent-routing-claims.tsFail-open at every layer
each@allmentioned →eacheach#133) → bypassedeachserved/exhausted) on re-delivery → no re-election, no duplicate wakeFOR UPDATE SKIP LOCKEDVerification
agents-routing-election.test.ts(14 cases),agents-routing.test.ts(14 cases),schema-migrations.test.ts(11 cases) — all passing.server/src/__integration__/agents-routing-claims.test.tsexercising PostgreSQL CTE locking, lease rotation, room exhaust fallback, and cursor catchup wakes.npm run lintclean (520 files checked),npm run typecheck,npm run server:typecheck, and all 3 guards (guard:big-brain,guard:llm-tracked,guard:engine-registry) passing.main(v0.16.2).