Skip to content

feat(agent-routing): elect one agent for unaddressed human group messages - #123

Merged
yetone merged 5 commits into
yetone:mainfrom
wg2038:feat/one-of-us-election
Sep 10, 2026
Merged

feat(agent-routing): elect one agent for unaddressed human group messages#123
yetone merged 5 commits into
yetone:mainfrom
wg2038:feat/one-of-us-election

Conversation

@wg2038

@wg2038 wg2038 commented Aug 30, 2026

Copy link
Copy Markdown
Contributor

Closes #70 (the one-of-us half; the me half landed in #92).

Summary

When a human group message names NOBODY, the router today short-circuits to a full fan-out (buildRouteRequest returns each when targets.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 flag ROUTING_ONE_OF_US (default false), backed by a durable lease in Postgres and automatic fallback.

Architecture & Components

Piece File Role
Unaddressed Router routing.ts buildUnaddressedRouteRequest / parseUnaddressedRoute / routeUnaddressedMessage: answers each vs one-of-us + optional role-fit proposal on the small model, tracked under purpose message-routing.
Deterministic Election 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).
Lease & Sweep routing-claims.ts + agent_routing_claims One row per election; sweeper advances to next candidate if primary starts no agent_run within 90s; exhausts with full-room fan-out if lineup runs out; reaps terminal rows after 24h.
Cursor Catchup routing-claims.ts On claim resolution (served), queries for room members whose read cursor in conversation_reads is still behind the message and emits a catchup wake, closing the cursor-exposure window.
Wiring scheduler.ts The unaddressed branch of routing behind env.ROUTING_ONE_OF_US.
Migration 0007-agent-routing-claims.ts Versioned migration 0007, preserving frozen baseline DDL (ADR 0003).

Fail-open at every layer

  • Router error / unparseable → each
  • @all mentioned → each
  • Room < 2 agents → each
  • System message or delivery recipient (#133) → bypassed
  • Empty roster or DB read failure → each
  • Claim write failure → full fan-out (narrowing without a lease has no safety net)
  • Claim already resolved (served/exhausted) on re-delivery → no re-election, no duplicate wake
  • Sweeper multi-replica safe: CTE with FOR UPDATE SKIP LOCKED
  • Lineup exhaustion: falls back to full-room fan-out before going terminal

Verification

  • Unit tests: agents-routing-election.test.ts (14 cases), agents-routing.test.ts (14 cases), schema-migrations.test.ts (11 cases) — all passing.
  • Integration tests: server/src/__integration__/agents-routing-claims.test.ts exercising PostgreSQL CTE locking, lease rotation, room exhaust fallback, and cursor catchup wakes.
  • Quality: npm run lint clean (520 files checked), npm run typecheck, npm run server:typecheck, and all 3 guards (guard:big-brain, guard:llm-tracked, guard:engine-registry) passing.
  • Rebase: Cleanly rebased onto latest main (v0.16.2).

@yetone

yetone commented Sep 2, 2026

Copy link
Copy Markdown
Owner

Status check on the ladder, since ② has moved: #124 landed on main on 2026-09-01 (9060df7). It reports turns-per-human-message over agent_runs — avg, median and a 0/1/2/3–5/6+ histogram per conversation kind — on GET /agents/observability/wakes as turnsPerMessage, rendered as the "Fan-out width" card in the Observability view. That is the instrument this PR was waiting for.

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 me routing. Until that number says the premise holds, this stays parked exactly as you intended — so this is not a merge, and I'm leaving it as a draft.

I did read the code now rather than later, because three of the findings would bite on day one regardless of the data:

  1. The sweep never runs. sweepRoutingClaimsOnce in routing-claims.ts does UPDATE agent_routing_claims … RETURNING … LIMIT $2. Postgres UPDATE has no LIMIT; against a real postgres:16 the statement fails with syntax error at or near "LIMIT". Every tick throws into .catch(console.error), so no lease advances, the exhaust fan-out never fires and terminal rows are never reaped. The election narrows the wake, the backstop is dead, and the room is exactly as silent as the pre-election world — with one fewer agent awake. The unit tests miss it because mockPool matches the query prefix string. The repo's idiom for this is email-retry.ts (SELECT … LIMIT n FOR UPDATE SKIP LOCKED in a transaction, or UPDATE … WHERE id IN (SELECT … LIMIT n FOR UPDATE SKIP LOCKED)).
  2. hasRunSince is anchored to claim creation, not to the current candidate's election. Lineup [A, B], claim at T0. B finishes an unrelated turn at T+30s. A no-shows; at T+90s the sweep advances to B and wakes it, but B's daemon is offline. At T+180s the sweep sees a B run with started_at > T0 and marks the claim served. Terminal — the exhaust fan-out never fires. Compare against the advance timestamp instead.
  3. It is default-on. env.ROUTING_ONE_OF_US is true unless explicitly set to false, so merging would switch elections on in production the moment it lands, with (1) in place. A parked ③ should be opt-in until ②'s data says otherwise.
  4. Semantic conflict with fix(security): bind writes to live membership #133 (7697bbf, after your base). Main now appends a durable-delivery recipient before the routing block so a kicked agent still receives its departure notice; that system message has no @mention and the routing block has no messageKind guard, so a human-initiated kick in a room with ≥2 agents enters the election branch, the kicked agent is electable (loadElectionCandidates checks departed_at only, not membership), and a one-of-us verdict drops the very recipient fix(security): bind writes to live membership #133 guarantees. Skip both routing branches when messageKind === 'system' or deliveryAgentId is set.

Smaller: the description says a served/exhausted claim on re-delivery means no wake, but if (claim?.status === 'pending') falls through to the full fan-out; and the election reads payload.companyId where main now treats the payload as untrusted and has conversation.company_id. The rest — orderCandidates/electLineup, the ON CONFLICT DO NOTHING + re-read in claimPrimary, the AND cursor = $4 advance guard, the idempotent migration and its index, and message-routing on the small model through the tracked client — looks right, and the big-brain guard is satisfied.

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.

@WhichPaths

Copy link
Copy Markdown
Collaborator

Two things from re-reading this against main, both about the gate rather than the code.

Step ② has landed. getTurnsPerMessage went in as dd0beee on 2026-08-31 — "turns per human message — the other half of the #70 ledger" — and it is wired all the way through: the query in observability.ts:848, turnsPerMessage on the WakeEconomics payload, the route at router.ts:5513, and a rendered panel at ObservabilityView.tsx:886-904 with messages / turns / avg / median / distribution per conversation kind. So the first half of this PR's stated precondition is satisfied; what remains is "and the data justifies it".

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 targets.length === 0 branch — routing.ts:51 calls it out as the important one, "if the message names nobody, narrowing would wake no one at all" — and scheduler.ts:789 gates the whole routing block on uniqueTargets.length > 0, so an unaddressed message never reaches the router at all today.

getTurnsPerMessage's scope CTE selects every human, non-system message in a group or direct room with at least one agent member. There is no mention or quote predicate in it. So the group bucket mixes:

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)]

uniqueTargets.length > 0 is the predicate, one line above the fan-out. Recording it on the run — agent_runs.trigger already carries source, conversationIds, idle, backgroundScan, pollUpdate — would let the query split on it with no new table.

Computing it in SQL instead would mean a second implementation of mentionedAgentIds, which is the shape that produced #182 (recurrence math implemented twice, fixed on one side only). Worth avoiding for a metric whose whole job is to be trusted.

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.

@yetone

yetone commented Sep 5, 2026

Copy link
Copy Markdown
Owner

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 main today, including several that touch server/src/api/router.ts, server/src/agents/cli.ts and the agent runtime, so this branch will need a rebase before it's mergeable regardless. When you're ready for it to be reviewed properly, rebase onto current main, get the checks green and mark it ready for review, and I'll go through it.

@wg2038
wg2038 force-pushed the feat/one-of-us-election branch from b09314a to 2cea7b3 Compare September 5, 2026 13:45
@wg2038
wg2038 marked this pull request as ready for review September 5, 2026 13:51
@wg2038

wg2038 commented Sep 5, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto main (post v0.15.0) and marked ready for review! All 4 findings and the migration requirements are addressed:

  1. Postgres sweep query: Rewritten with CTE and row-level locking (WITH due AS (SELECT message_id FROM agent_routing_claims WHERE status = 'pending' AND lease_expires_at < NOW() ORDER BY lease_expires_at ASC LIMIT $2 FOR UPDATE SKIP LOCKED) UPDATE agent_routing_claims c SET ... FROM due ...). Tested and verified against real PostgreSQL.
  2. Anchor probe (cursor_advanced_at): Added cursor_advanced_at TIMESTAMP WITH TIME ZONE NOT NULL DEFAULT NOW() to agent_routing_claims. Each advance updates cursor_advanced_at = NOW(), and hasRunSince is anchored to cursorAdvancedAt ?? createdAt, so quiet fallback candidates are properly held to their own window.
  3. Safe default-off: env.ROUTING_ONE_OF_US is now default false (opt-in via ROUTING_ONE_OF_US=true). Safe to land without behavioral drift until the panel data confirms the premise.
  4. fix(security): bind writes to live membership #133 compatibility & re-delivery: Added messageKind !== 'system' && !deliveryAgentId guard to the routing block so kicked agents reliably receive departure notices; passed conversation.company_id; on re-delivery where claim?.status === 'served' | 'exhausted', explicitly reset recipients = [] to prevent unintended fan-out fallthrough.
  5. ADR 0003 compliance: Moved the table creation into versioned migration 0005-agent-routing-claims.ts, leaving baseline migration 0001 frozen and preserving its immutable checksum.
  6. Tests: Added dedicated integration tests in server/src/__integration__/agents-routing-claims.test.ts exercising PostgreSQL CTE locking, lease rotation, and room exhaust fallback.

All 7 CI checks on GitHub Actions are green. Looking forward to your review!

@wg2038
wg2038 force-pushed the feat/one-of-us-election branch from 2cea7b3 to 063b0fa Compare September 6, 2026 02:12
@wg2038

wg2038 commented Sep 6, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto latest main (5ada505) and bumped migration to 0006 (0006_agent_routing_claims) to resolve the conflict with #218's migration 0005. All 7 CI checks are green and the branch is clean to merge.

@yetone

yetone commented Sep 6, 2026

Copy link
Copy Markdown
Owner

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:

Status: parked draft, deliberately out of merge order. … It should not merge before ② exists and the data justifies it — exactly as the issue author asked.

The ladder agreed in #70 was ① address signals in the prompt (#88, merged) → ② turns-per-message observability over llm_calls → ③ lease-backed routing. This is ③, and ② doesn't exist yet. The whole point of that ordering was that a routing change gets measured rather than argued — and the premise this PR rests on (26.3% of group wakes producing nothing) is exactly the number ② would let us re-check after the fact. Merging ③ first spends the evidence before we collect it.

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

  • ROUTING_ONE_OF_US defaults off, so nothing changes until someone opts in. That's the right shape, and it means there's no urgency either way.
  • The fail-open ladder is genuinely thorough: router error → each, @alleach, room < 2 agents → each, roster/DB failure → each, claim-write failure → full fan-out. The comment explaining why a claim-write failure must fan out rather than narrow ("narrowing without a lease has no safety net") is the reasoning I'd want there.

One sequencing hazard worth knowing about

Migration 0006-agent-routing-claims runs on every deploy regardless of the flag. Production is currently stuck on migration 0002 — the 0002_normalized_conversation_members precheck is failing on live data, which is why no deploy has landed since 2026-09-03. Until that's cleared, every new migration added to the chain is one more thing that has to apply correctly in the same catch-up run. Not a reason to change this PR, just a reason not to add to the chain this week. (#220 adds 0005 and is in the same position.)

On your three open design questions — since you asked for a read, and I'd rather answer than leave them hanging:

  1. The coarse has-run check. I think accepting any agent_run is the right v1. The failure it buys is one silent room, which is precisely the pre-election status quo; the alternative failure is a double wake, which is the thing the feature exists to prevent. Don't add a conversation column to agent_runs just for this.
  2. The cursor-exposure window. This is the one that actually worries me, and I think your own proposed mitigation is the right one: on claim resolution, wake any room member whose cursor is still behind the message. Without it, "position × lease" is an unbounded correctness hole in the middle of the window, not just a latency cost — a member can silently skip a human message entirely. I'd want that built before this is enabled anywhere, flag or no flag.
  3. Lease length. 90s is fine as a constant. This is a product call, so it's @yetone's, but note it interacts with (2): the longer the lease, the wider the exposure window, so fixing (2) is what buys the freedom to tune this.

Keep it open. Once ② lands and says the premise holds, this is the PR I'd want to build on.

wg2038 added a commit to wg2038/cumora that referenced this pull request Sep 6, 2026
…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.
@wg2038
wg2038 force-pushed the feat/one-of-us-election branch from 063b0fa to c35d8f7 Compare September 6, 2026 06:39
@wg2038

wg2038 commented Sep 6, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto latest main (0b506be, post v0.16.1) and addressed your feedback on Question 2:

  1. Cursor-exposure window closed: On claim resolution (status = 'served'), sweepRoutingClaimsOnce queries conversation_members and conversation_reads for active agent members whose read cursor is still behind the message (ROW(COALESCE(cr.last_read_at, '1970-01-01T00:00:00Z'), cr.last_read_message_id) < ROW(m.created_at, m.id)). If any lag behind, it emits a catchup decision to wake them via scheduler.fanOutWake. The unwoken candidates catch their cursor up, and standard glance/yield etiquette (GLANCE_YIELD_RULES) suppresses duplicate replies once the primary's response is seen.
  2. Sequenced migration 0007: Bumped migration to 0007_agent_routing_claims (0007-agent-routing-claims.ts) following the merge of fix(email): support multi-recipient inbound delivery and eliminate ghost threads #220's migration 0006.
  3. Tests: Added unit tests covering the catchup sweep decision and a PostgreSQL integration test verifying cursor-lagging room members are woken when primary is served, and skipped once caught up.

All 7 CI checks are green. Leaving this open/parked as agreed until ② has collected sufficient baseline data in production.

@yetone

yetone commented Sep 6, 2026

Copy link
Copy Markdown
Owner

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. ROUTING_ONE_OF_US defaulting off is the right shape and I'm not asking you to change it.

https://claude.ai/code/session_0126NkM9crkemuV6Ho4LWv59

wg2038 added a commit to wg2038/cumora that referenced this pull request Sep 6, 2026
…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.
@wg2038
wg2038 force-pushed the feat/one-of-us-election branch from c35d8f7 to b756cf5 Compare September 6, 2026 16:57
wg2038 added a commit to wg2038/cumora that referenced this pull request Sep 6, 2026
…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.
@wg2038
wg2038 force-pushed the feat/one-of-us-election branch from b756cf5 to 61a1280 Compare September 6, 2026 17:15
@wg2038

wg2038 commented Sep 7, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @yetone! The branch is now completely settled and ready for your review pass:

  1. Rebased & clean: Rebased onto latest main (v0.16.2, commit 92ef7fa).
  2. Sequenced migration 0007: Sequenced as 0007_agent_routing_claims (0007-agent-routing-claims.ts), cleanly following fix(email): support multi-recipient inbound delivery and eliminate ghost threads #220's 0006 while keeping baseline migration 0001 frozen (ADR 0003).
  3. Cursor-exposure window closed: On claim resolution (status = 'served'), sweepRoutingClaimsOnce checks for active agent room members whose read cursor lags behind the message and emits a catchup wake to catch them up.
  4. All checks green: GitHub Actions CI is 100% green across all 7 jobs, including the PostgreSQL + pgvector integration suite.
  5. PR description updated: Removed the old draft banner and updated the summary with the complete architecture and fail-open ladder.

@yetone

yetone commented Sep 9, 2026

Copy link
Copy Markdown
Owner

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 main it conflicts in three files:

  • server/src/db/migrate.ts
  • server/src/db/migrations/manifest.ts
  • server/src/__tests__/schema-migrations.test.ts

all from the same cause: you renumbered to 0007, and 0007-engine-defaults has since landed on main via #235. Please don't pick a fixed number again — take the next free version at the moment you rebase. Right now that is 0009, because #233 has 0008-agent-provider-profile in flight ahead of you. Remember to move both MIN_SUPPORTED_SCHEMA_VERSION and MAX_SUPPORTED_SCHEMA_VERSION together and to regenerate the exported checksum, or schema-migrations.test.ts will fail.

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 main, so once you push the rebase, ping me and I will prioritise the review over anything else open.

Two things I will be looking at closely when I do, so you can get ahead of them:

  1. The election must be decided by authoritative server-arbitrated state, not by agents negotiating in chat. routing-claims.ts looks like it is doing the right thing (a claim row is the truth), but I will be checking that a losing agent is never woken to discover it lost — the whole point is that it is never woken at all.
  2. The failure mode when the elected agent never answers. If the elected agent is rate-limited, offline, or its BYOA daemon is down, a group message must not silently go unanswered forever. I want to see the deterministic floor: a bounded timeout after which the claim is released and someone else can take it. Please make sure there is a test for that path specifically.

lyly-bonbon and others added 5 commits September 9, 2026 17:53
…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.
@wg2038
wg2038 force-pushed the feat/one-of-us-election branch from 61a1280 to 91f9275 Compare September 9, 2026 12:48
@wg2038

wg2038 commented Sep 9, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto main (commit 171d407, v0.17.0) on top of #233's migration sequence, and addressed both review checkpoints:

1. Authoritative server-arbitrated election (losers never woken)

In server/src/agents/scheduler.ts (lines 842–848), when route.mode === 'one-of-us', recipients is immediately narrowed to [primary]:

} 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 fanOutWake(recipients, ...). They receive zero wakes, run zero turns, and consume zero tokens — there is no chat-negotiated back-and-forth or losers waking up to discover they lost.

2. Deterministic bounded timeout floor & quiet primary fallback

In server/src/agents/routing-claims.ts (lines 144–223):

  • Claims are written with lease_expires_at = NOW() + ELECTION_LEASE_MS (90_000 ms / 90s).
  • The sweeper (sweepRoutingClaimsOnce) queries due claims (lease_expires_at < NOW()) via FOR UPDATE SKIP LOCKED. If the elected agent recorded no turn (hasRunSince returns false), the cursor advances (cursor + 1), extends the lease by 90s, and emits an advance decision waking the next candidate in the lineup.
  • If all candidates in the lineup go quiet, the claim transitions to 'exhausted' and emits an exhaust decision falling back to full-room fan-out so the human message is never silently lost or permanently stranded behind a read cursor.
  • Specific test coverage:
    • agents-routing-election.test.ts:194: "the sweep advances to the next candidate when the primary went quiet"
    • agents-routing-election.test.ts:211: "an exhausted lineup is marked and falls back to the full-room fan-out"
    • agents-routing-election.test.ts:245: "the election lease is generous enough for a cold pod or a reconnecting daemon" (ELECTION_LEASE_MS === 90_000)
    • Integration suite in server/src/__integration__/agents-routing-claims.test.ts verifying this against real PostgreSQL.

3. Migration Sequencing

  • Renumbered migration to 0009_agent_routing_claims (0009-agent-routing-claims.ts), cleanly following feat(byoa): add per-agent Claude provider profiles #233's 0008_agent_provider_profile.
  • Bumped MIN_SUPPORTED_SCHEMA_VERSION and MAX_SUPPORTED_SCHEMA_VERSION in lockstep to 9 in manifest.ts.
  • Updated schema-migrations.test.ts to assert the SHA-256 checksum for migration 9.

All 7 CI checks are 100% green on GitHub Actions. Ping @yetone — ready for your review pass!

@yetone

yetone commented Sep 10, 2026

Copy link
Copy Markdown
Owner

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.

  1. Losers are never woken to discover they lost. Confirmed, and it holds at the right layer. recipients = [primary] happens in wake() before fanOutWake is ever called, so the losing agents are absent from the array rather than filtered later — no wake, no triage, no turn. And the claim row is genuinely authoritative: claimPrimary is ON CONFLICT DO NOTHING + re-read, so a re-delivery honours the recorded primary instead of re-electing, and electLineup is pure with a lexicographic tie-break, so two replicas that both evaluate the same message agree without coordinating. Nothing about the outcome is negotiated in chat.

  2. The deterministic floor when the elected agent never answers. Present and tested. 90s lease → sweep advances the cursor → next candidate → lineup exhausted → full-room fan-out. The advance is guarded on AND cursor = $4 so a concurrent sweep can't double-advance, the batch takes rows with FOR UPDATE SKIP LOCKED, and hasRunSince is anchored on cursor_advanced_at ?? created_at — which is the fix for the anchor bug I described, and the reason a quiet fallback candidate is now held to its own window rather than inheriting the primary's.

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 one-of-us does not remove N−1 wakes; it delays them past the primary's reply.

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 placementTriage: true so they cost a small-model triage rather than a big-brain turn, and by then the primary's answer is visible so glance/yield should make them quiet. The saving is real: N big turns become 1 big turn + N triages + one router call.

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.

ROUTING_ONE_OF_US stays off. #124's fan-out width card is the instrument; when someone posts a before/after from it, that is the conversation about enabling this.

https://claude.ai/code/session_01Tw4EygpE4o73TMLzyPFWEv

@yetone
yetone merged commit 6d674d8 into yetone:main Sep 10, 2026
7 checks passed
@yetone yetone mentioned this pull request Sep 10, 2026
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.

feat(agent-routing): avoid waking every agent with the same group context

4 participants