From 932b103cee865362914bd085c3c9a7c1f0176432 Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Sun, 6 Sep 2026 13:22:09 +0700 Subject: [PATCH 1/4] fix(queen): the swarm read GitHub anonymously and died on 403 half the day MEASURED, over the last twelve hours of production: minutes with ZERO bees working : 357 of 721 (50%) minutes with all four working : 258 (36%) median gap between bursts : 22 min against a 5-minute tick longest gap : 41.7 min rounds ending "GitHub returned 403" in one log window : 144 The shape is bimodal - either four bees or none - because a round either gets its issue list or dies whole. `openIssues` throws on a bad status, so a single 403 takes the entire round with it: no review, no choice, no dispatch, and the capacity sits idle until some later round happens to get through. THE CAUSE. Every GitHub read in this file went out unauthenticated. The anonymous limit is sixty requests an hour, and this file has said so in a comment since it was written - "a second round trip per candidate against an anonymous rate limit that is 60 an hour". The limit was designed around instead of lifted: `openIssues` pages the backlog and `bodiesFor` fetches one body per candidate, twelve rounds an hour, against a budget of sixty. The token was in the environment the whole time. `GH_TOKEN` is set on this service, and `/rate_limit` answers 15000 of 15000 remaining - the measurement that turns "we are being throttled" into "we are throttled at the anonymous tier while holding a key to the other one". 60/h becomes 15000/h. WHAT THIS IS NOT. It hands nothing to a bee. This is the supervisor's own outbound read. The worker environment is still built from the ten-entry allowlist that deliberately excludes the GitHub token, and that is untouched. Falls back to anonymous when no token is set, so a local run without secrets behaves as it always has rather than failing to start. The tests pin the HEADER, not the fetch: a test that mocked `fetch` would pass against a call that still forgot to ask for these headers. `GH_TOKEN` is checked first by name, because reading only `GITHUB_TOKEN` would leave the limit at sixty an hour on the one deployment that matters while every test still passed. Co-Authored-By: Claude Opus 5 --- .../server/src/api/services/queen-tick.ts | 48 +++++++++++++++-- .../tests/api/queen-github-auth.test.ts | 52 +++++++++++++++++++ 2 files changed, 97 insertions(+), 3 deletions(-) create mode 100644 trios/agent-server/apps/server/tests/api/queen-github-auth.test.ts diff --git a/trios/agent-server/apps/server/src/api/services/queen-tick.ts b/trios/agent-server/apps/server/src/api/services/queen-tick.ts index 3cc0b37810..091d3d5dba 100644 --- a/trios/agent-server/apps/server/src/api/services/queen-tick.ts +++ b/trios/agent-server/apps/server/src/api/services/queen-tick.ts @@ -168,6 +168,44 @@ const ISSUE_PAGE_CAP = 5 * `complete` is what stops that: a truncated list is still worth deciding * against, but it must never be treated as the whole truth. */ +/** + * The headers for a GitHub read, carrying the token when there is one. + * + * ANONYMOUS IS SIXTY REQUESTS AN HOUR, and this file has said so in a comment + * since it was written - "a second round trip per candidate against an + * anonymous rate limit that is 60 an hour" - while every call it makes went out + * unauthenticated anyway. The limit was designed around instead of lifted. + * + * WHAT IT COST. Measured 2026-09-06 over twelve hours: 144 rounds ended in + * `GitHub returned 403`, the swarm had ZERO bees running for 50% of the wall + * clock, and the median gap between one burst of work and the next was 22 + * minutes against a five-minute tick. The pattern is bimodal - 36% of the time + * all four bees ran, 50% of the time none did - because a round either got its + * issue list or died whole. `openIssues` throws on a bad status, so one 403 + * takes the entire round with it: no review, no choice, no dispatch. + * + * The token was in the environment the whole time. `GH_TOKEN` is set on this + * service and `/rate_limit` answers 15000 of 15000 remaining, which is the + * measurement that turns "we are being throttled" into "we are throttled at + * the anonymous tier while holding a key to the other one". + * + * THIS HANDS NOTHING TO A BEE. It is the supervisor's own outbound read. The + * worker environment is built from a ten-entry allowlist that deliberately + * excludes the GitHub token, and that stays exactly as it is - a bee still gets + * no credential from here. + * + * Falls back to anonymous when no token is set, so a local run without secrets + * behaves as it always has rather than failing to start. + */ +export function githubReadHeaders(): Record { + const token = process.env.GH_TOKEN || process.env.GITHUB_TOKEN + const headers: Record = { + Accept: 'application/vnd.github+json', + } + if (token) headers.Authorization = `Bearer ${token}` + return headers +} + export async function openIssues(repo: string): Promise<{ issues: Array<{ number: number; body: string; title: string }> complete: boolean @@ -178,7 +216,7 @@ export async function openIssues(repo: string): Promise<{ const response = await fetch( `https://api.github.com/repos/${repo}/issues` + `?state=open&per_page=${ISSUE_PAGE_SIZE}&page=${page}`, - { headers: { Accept: 'application/vnd.github+json' } }, + { headers: githubReadHeaders() }, ) if (!response.ok) throw new Error(`GitHub returned ${response.status}`) const batch = (await response.json()) as Array<{ @@ -657,7 +695,7 @@ async function bodiesFor( for (const number of numbers) { const response = await fetch( `https://api.github.com/repos/${repo}/issues/${number}`, - { headers: { Accept: 'application/vnd.github+json' } }, + { headers: githubReadHeaders() }, ) if (!response.ok) continue const issue = (await response.json()) as { body?: string | null } @@ -1811,7 +1849,11 @@ export function parseVerdictBlock( // Trying each and keeping the longest parse is stable under either // convention, so a worker running an older brief is not punished for it. const starts: number[] = [] - for (let i = text.indexOf('## VERDICT'); i >= 0; i = text.indexOf('## VERDICT', i + 1)) { + for ( + let i = text.indexOf('## VERDICT'); + i >= 0; + i = text.indexOf('## VERDICT', i + 1) + ) { starts.push(i) } if (!starts.length) return [] diff --git a/trios/agent-server/apps/server/tests/api/queen-github-auth.test.ts b/trios/agent-server/apps/server/tests/api/queen-github-auth.test.ts new file mode 100644 index 0000000000..e304b101ec --- /dev/null +++ b/trios/agent-server/apps/server/tests/api/queen-github-auth.test.ts @@ -0,0 +1,52 @@ +import { afterEach, describe, expect, it } from 'bun:test' + +import { githubReadHeaders } from '../../src/api/services/queen-tick' + +/** + * The round died 144 times in twelve hours on `GitHub returned 403`, and the + * swarm sat with zero bees for half of the wall clock, because every GitHub + * read went out anonymous - sixty requests an hour - while `GH_TOKEN` sat in + * the environment unused. + * + * These cases pin the header, not the fetch: a test that mocked `fetch` would + * pass against a call that still forgot to ask for these headers. + */ +describe('githubReadHeaders', () => { + const saved = { gh: process.env.GH_TOKEN, github: process.env.GITHUB_TOKEN } + + afterEach(() => { + if (saved.gh === undefined) delete process.env.GH_TOKEN + else process.env.GH_TOKEN = saved.gh + if (saved.github === undefined) delete process.env.GITHUB_TOKEN + else process.env.GITHUB_TOKEN = saved.github + }) + + it('carries the token this service actually has', () => { + delete process.env.GITHUB_TOKEN + process.env.GH_TOKEN = 'test-token-value' + // GH_TOKEN is the name set on the deployed service. Reading only + // GITHUB_TOKEN would leave the limit at sixty an hour on the one + // deployment that matters, and every test here would still pass. + expect(githubReadHeaders().Authorization).toBe('Bearer test-token-value') + }) + + it('accepts GITHUB_TOKEN as well, so a differently-configured host still works', () => { + delete process.env.GH_TOKEN + process.env.GITHUB_TOKEN = 'other-token' + expect(githubReadHeaders().Authorization).toBe('Bearer other-token') + }) + + it('stays anonymous when there is no token, rather than failing to start', () => { + delete process.env.GH_TOKEN + delete process.env.GITHUB_TOKEN + const headers = githubReadHeaders() + expect(headers.Authorization).toBeUndefined() + // A local run with no secrets must behave as it always has. + expect(headers.Accept).toBe('application/vnd.github+json') + }) + + it('always asks for the JSON media type', () => { + process.env.GH_TOKEN = 'x' + expect(githubReadHeaders().Accept).toBe('application/vnd.github+json') + }) +}) From d09902e05dd71d6d7b0dec6541bdb244eaf38aaa Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Sun, 6 Sep 2026 13:45:49 +0700 Subject: [PATCH 2/4] fix(queen): a refusal on a later page is a truncation, not a dead round Measured from inside the container while writing this: the service burns 77 anonymous GitHub requests an hour against a limit of 60. The first commit said "twelve rounds an hour against a budget of sixty" and understated it - the round makes about six calls, not two. Once the budget is spent every call returns 403 until the hourly reset, which is why the longest observed idle stretch was 41.7 minutes: it is the reset window, not a coincidence. anonymous : 200 remaining 23/60, resets in 2352s with token: 200 remaining 14813/15000 TWO CORRECTIONS TO MY OWN FIRST COMMIT. The premise was not neglect, it was expiry, and the difference matters because the reasoning was good. The doc comment said: "Anonymous on purpose: the repository is public, this is a read, and a token here would be a credential in a container for no gain. GitHub's anonymous rate limit is 60/hour against a loop that ticks at most A FEW TIMES AN HOUR." The loop ticks twelve times an hour now. The sentence outlived its assumption, and it is kept in place, quoted, rather than deleted - a rule that expired teaches more than one that was wrong. The stated cost was also already paid: `GH_TOKEN` is on the SUPERVISOR, and the worker allowlist that excludes it is untouched. And the first commit's own patch was sloppy: inserting the helper immediately above `openIssues` split that function from its doc comment, leaving the paragraph about pagination and `complete` documenting a headers helper. Moved. THE SECOND DEFECT, which the token alone does not fix. `openIssues` threw on any bad status, so ONE refusal took the entire round: no review, no choice, no dispatch. 135 of 136 round failures were exactly that. But this function already has a word for a partial answer - `complete: false`, which `rememberIssues` is built to respect, precisely so a truncated list is "still worth deciding against but never treated as the whole truth". A refusal on page two is that case. It now breaks and reports the pages it did get. A refusal on page ONE still throws: a round with no issue list has nothing to decide against, and tolerating it would dispatch against an empty board. The tests replace `globalThis.fetch` and restore it rather than using `mock.module`, which is process-global in bun and cannot be undone - a fake left behind here would be inherited by every test file that ran after it. Co-Authored-By: Claude Opus 5 --- .../server/src/api/services/queen-tick.ts | 59 ++++++++++++----- .../tests/api/queen-github-auth.test.ts | 66 ++++++++++++++++++- 2 files changed, 107 insertions(+), 18 deletions(-) diff --git a/trios/agent-server/apps/server/src/api/services/queen-tick.ts b/trios/agent-server/apps/server/src/api/services/queen-tick.ts index 091d3d5dba..21da1a418c 100644 --- a/trios/agent-server/apps/server/src/api/services/queen-tick.ts +++ b/trios/agent-server/apps/server/src/api/services/queen-tick.ts @@ -152,22 +152,6 @@ const ISSUE_PAGE_SIZE = 100 */ const ISSUE_PAGE_CAP = 5 -/** - * Open issues, read without a credential. - * - * Anonymous on purpose: the repository is public, this is a read, and a token - * here would be a credential in a container for no gain. GitHub's anonymous - * rate limit is 60/hour against a loop that ticks at most a few times an hour. - * - * PAGINATED, and it says whether it got everything. One page of 50 was the - * whole list for as long as the repository stayed under the horizon - 44 open - * items on 2026-08-31, of which 4 were pull requests taking slots on the same - * page - and `rememberIssues` deletes every stored row that is not in the list - * it is handed. So at 51 open items the oldest backlog issue would have been - * erased from the board on every round, with nothing anywhere saying so. - * `complete` is what stops that: a truncated list is still worth deciding - * against, but it must never be treated as the whole truth. - */ /** * The headers for a GitHub read, carrying the token when there is one. * @@ -206,6 +190,34 @@ export function githubReadHeaders(): Record { return headers } +/** + * Open issues. + * + * WAS ANONYMOUS ON PURPOSE, and the reasoning is kept here because it was + * sound and it is instructive that it expired rather than that it was wrong: + * "the repository is public, this is a read, and a token here would be a + * credential in a container for no gain. GitHub's anonymous rate limit is + * 60/hour against a loop that ticks at MOST A FEW TIMES AN HOUR." + * + * The loop ticks twelve times an hour now. Measured 2026-09-06 from inside the + * container, the service burns 77 anonymous requests an hour against that limit + * of 60 - and once it is spent every call returns 403 until the hourly reset, + * which is why the longest observed idle stretch was 41.7 minutes. The premise + * expired quietly; the sentence did not. + * + * The stated cost is also already paid: `GH_TOKEN` is on this service. It is + * the SUPERVISOR's container, not a bee's, and the worker environment is still + * built from an allowlist that excludes the token. + * + * PAGINATED, and it says whether it got everything. One page of 50 was the + * whole list for as long as the repository stayed under the horizon - 44 open + * items on 2026-08-31, of which 4 were pull requests taking slots on the same + * page - and `rememberIssues` deletes every stored row that is not in the list + * it is handed. So at 51 open items the oldest backlog issue would have been + * erased from the board on every round, with nothing anywhere saying so. + * `complete` is what stops that: a truncated list is still worth deciding + * against, but it must never be treated as the whole truth. + */ export async function openIssues(repo: string): Promise<{ issues: Array<{ number: number; body: string; title: string }> complete: boolean @@ -218,7 +230,20 @@ export async function openIssues(repo: string): Promise<{ `?state=open&per_page=${ISSUE_PAGE_SIZE}&page=${page}`, { headers: githubReadHeaders() }, ) - if (!response.ok) throw new Error(`GitHub returned ${response.status}`) + if (!response.ok) { + // A REFUSAL ON A LATER PAGE IS A TRUNCATION, AND THIS FUNCTION ALREADY + // HAS A WORD FOR THAT. + // + // Throwing here took the whole round with it - no review, no choice, no + // dispatch - and 135 of 136 round failures measured on 2026-09-06 were + // exactly this, leaving the swarm with zero bees for half the day. But + // the contract below already covers a list that is not the whole truth: + // `complete` stays false and `rememberIssues` is told not to treat it as + // the full set. A first page that fails leaves nothing to decide against, + // so that one still throws. + if (page > 1) break + throw new Error(`GitHub returned ${response.status}`) + } const batch = (await response.json()) as Array<{ number: number title?: string diff --git a/trios/agent-server/apps/server/tests/api/queen-github-auth.test.ts b/trios/agent-server/apps/server/tests/api/queen-github-auth.test.ts index e304b101ec..0103e44376 100644 --- a/trios/agent-server/apps/server/tests/api/queen-github-auth.test.ts +++ b/trios/agent-server/apps/server/tests/api/queen-github-auth.test.ts @@ -1,6 +1,9 @@ import { afterEach, describe, expect, it } from 'bun:test' -import { githubReadHeaders } from '../../src/api/services/queen-tick' +import { + githubReadHeaders, + openIssues, +} from '../../src/api/services/queen-tick' /** * The round died 144 times in twelve hours on `GitHub returned 403`, and the @@ -50,3 +53,64 @@ describe('githubReadHeaders', () => { expect(githubReadHeaders().Accept).toBe('application/vnd.github+json') }) }) + +/** + * One 403 used to take the whole round with it - no review, no choice, no + * dispatch - and 135 of 136 round failures measured on 2026-09-06 were exactly + * that, leaving the swarm with ZERO bees for half the day. + * + * `globalThis.fetch` is replaced and restored here rather than mocked with + * `mock.module`, which is process-global in bun and cannot be undone: a fake + * left behind by this file would be inherited by every test that ran after it. + */ +describe('openIssues when GitHub refuses', () => { + const realFetch = globalThis.fetch + + afterEach(() => { + globalThis.fetch = realFetch + }) + + const page = (n: number) => + new Response( + JSON.stringify( + Array.from({ length: n }, (_, i) => ({ + number: i + 1, + title: 't', + body: 'b', + })), + ), + { status: 200, headers: { 'content-type': 'application/json' } }, + ) + + it('treats a refusal on a later page as a truncated list, not a dead round', async () => { + let call = 0 + globalThis.fetch = (async () => { + call += 1 + return call === 1 + ? page(100) + : new Response('rate limited', { status: 403 }) + }) as typeof fetch + + const got = await openIssues('owner/repo') + // The work from page one survives, and the caller is told it is partial. + expect(got.issues).toHaveLength(100) + expect(got.complete).toBe(false) + }) + + it('still throws when the FIRST page is refused, because nothing was read', async () => { + globalThis.fetch = (async () => + new Response('rate limited', { status: 403 })) as typeof fetch + // A round with no issue list at all has nothing to decide against, so this + // one must stay fatal - tolerating it would dispatch against an empty board. + await expect(openIssues('owner/repo')).rejects.toThrow( + 'GitHub returned 403', + ) + }) + + it('marks a short page complete, as it always has', async () => { + globalThis.fetch = (async () => page(7)) as typeof fetch + const got = await openIssues('owner/repo') + expect(got.issues).toHaveLength(7) + expect(got.complete).toBe(true) + }) +}) From c3ee622a3928fecf6ec523b588a80e8beb851527 Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Sun, 6 Sep 2026 13:49:21 +0700 Subject: [PATCH 3/4] fix(queen): bookkeeping must not be able to idle the swarm The same shape as the 403, one layer in. Review and reaping are housekeeping; dispatch is the thing the hive exists to do. An exception in either took the whole round with it, so a transient database error cost five minutes of every bee - and one round measured on 2026-09-06 died exactly that way, on `deadlock detected`, which nothing in this repository had ever mentioned. An audit of the round body says these were the last two: every other awaited call on the path is already guarded with `.catch(() => null)`. `openIssues` was the third and is fixed in the commit before this one. NEITHER IS LOST BY CONTINUING. The review re-reads every unjudged dispatch next round by construction - that is what `review_state IS NULL OR = 'wait'` means - and the reaper re-finds a stalled dispatch. What IS lost by throwing is the dispatch that would have happened, and that is the one thing a later round cannot give back: the idle minutes are already spent. Logged at warn with the reason rather than swallowed. `tri idle` reads these lines out of the service log and reports what stopped the rounds, so a review that fails EVERY round is loud rather than merely survivable. WHAT IS NOT TESTED HERE, said plainly: the catch itself has no unit test. `runQueenTickOnce` needs a live database to drive, and the round test file exercises the pieces rather than the whole. CI's type check covers the fallback shapes - `ReviewRound` for one, `Awaited>` for the other - and the failure this fixes is evidenced in the production log rather than in a fixture. A live-Postgres round test belongs in the `server-pglive` job and is not in this change. Co-Authored-By: Claude Opus 5 --- .../server/src/api/services/queen-tick.ts | 30 +++++++++++++++++-- 1 file changed, 28 insertions(+), 2 deletions(-) diff --git a/trios/agent-server/apps/server/src/api/services/queen-tick.ts b/trios/agent-server/apps/server/src/api/services/queen-tick.ts index 21da1a418c..00d3f4c514 100644 --- a/trios/agent-server/apps/server/src/api/services/queen-tick.ts +++ b/trios/agent-server/apps/server/src/api/services/queen-tick.ts @@ -995,12 +995,38 @@ export async function runRound( // own policy, and only an ESCALATION reaches a person. Without this the hold // added to stop the six-times loop would have become a different starvation: // every issue she finished would be locked out of the pool for ever. - const reviewed = await reviewFinishedDispatches(pool) + // BOOKKEEPING MUST NOT BE ABLE TO IDLE THE SWARM. + // + // The same shape as the 403 above, one layer in. Review and reaping are + // housekeeping; dispatch is the thing the hive exists to do. An exception in + // either used to take the whole round with it, so a transient database error + // cost five minutes of every bee - and one round measured on 2026-09-06 died + // exactly that way, on `deadlock detected`. + // + // Neither is lost by continuing: the review re-reads every unjudged dispatch + // next round by construction, and the reaper re-finds a stalled one. What IS + // lost by throwing is the dispatch that would have happened, and that is the + // one thing a later round cannot give back - the idle minutes are spent. + // + // Logged at warn with the reason, never swallowed: `tri idle` reads these + // lines out of the service log and reports what stopped the rounds, so a + // review that fails EVERY round is loud rather than merely survivable. + const reviewed = await reviewFinishedDispatches(pool).catch((error) => { + logger.warn('Queen review failed; dispatching anyway', { + error: error instanceof Error ? error.message : String(error), + }) + return { acted: [], strays: [], tally: [] } as ReviewRound + }) if (reviewed.acted.length > 0) { logger.info('Queen reviewed her own work', { verdicts: reviewed.acted }) } - const reaped = await reapStalledDispatches(pool) + const reaped = await reapStalledDispatches(pool).catch((error) => { + logger.warn('Queen reap failed; dispatching anyway', { + error: error instanceof Error ? error.message : String(error), + }) + return [] as Awaited> + }) if (reaped.length > 0) { logger.info('Queen tick reaped stalled dispatches', { issues: reaped }) } From 3f587866500d29f13139225ca72c32479ddb8541 Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Sun, 6 Sep 2026 14:06:08 +0700 Subject: [PATCH 4/4] test(queen): the round dispatches even when the review throws The `.catch()` in the commit before this one had no test, and I said so - along with a reason that was wrong. I claimed driving `runRound` needed a live database. It does not: `runRound` is exported, takes a pool double, and `queen-round.test.ts` has had a recording fake, a stubbed `fetch` and the real policy binary since it was written. I asserted the file's limits without reading it. So here is the test. `FROM queen_dispatch d` is the review's SELECT and the only query in the round using that alias, so failing it targets the review and nothing else. The round is then expected to reach `dispatchBee` anyway and record its INSERT. Reverting the `.catch()` turns this red: the round stops before the dispatch and the INSERT never appears. The shared fake gained a `throwOn` parameter rather than the test monkey- patching `pool.query`, which needed a cast through `unknown` to compile and would have left the next reader wondering which of the two queries recorded. The failing statement is recorded BEFORE it throws, because a query that throws is still a query the round issued. HONEST LIMIT, and it is why this is a separate commit: these cases are guarded by `it.if(present)` and the policy binary is not built in CI, so this test SKIPS there today along with the other seven. #477 builds it and makes the skip loud. Until that lands, this is proven on a machine with `queen-core` built and nowhere else - which is exactly the state #477 exists to end. Co-Authored-By: Claude Opus 5 --- .../apps/server/tests/api/queen-round.test.ts | 40 ++++++++++++++++--- 1 file changed, 35 insertions(+), 5 deletions(-) diff --git a/trios/agent-server/apps/server/tests/api/queen-round.test.ts b/trios/agent-server/apps/server/tests/api/queen-round.test.ts index f108e47872..7053d12a28 100644 --- a/trios/agent-server/apps/server/tests/api/queen-round.test.ts +++ b/trios/agent-server/apps/server/tests/api/queen-round.test.ts @@ -14,10 +14,7 @@ import { runRound, } from '../../src/api/services/queen-tick' import { logger } from '../../src/lib/logger' -import { - queendPathEnvVar, - resolveQueendPath, -} from '../__helpers__/queend-path' +import { queendPathEnvVar, resolveQueendPath } from '../__helpers__/queend-path' /** * The round itself, driven against the real policy binary. @@ -98,12 +95,19 @@ const isDispatchInsert = (sql: string) => * order is the thing under test in two of the cases below and a fake that * encodes it would agree with whatever the code does. */ -function roundPool(finished: FinishedRow[] = []) { +function roundPool(finished: FinishedRow[] = [], throwOn?: string) { const queries: Array<{ sql: string; params: unknown[] }> = [] const pool = { query: async (sql: string, params: unknown[] = []) => { queries.push({ sql: String(sql), params }) const text = String(sql) + // One named statement can be made to fail, so a test can ask what the + // round does when a single piece of bookkeeping breaks. Recorded first, + // so the failing statement still shows up in `sql()` as having been + // attempted - a query that throws is a query the round issued. + if (throwOn && text.includes(throwOn)) { + throw new Error('deadlock detected') + } if (text.includes('FROM queen_registry')) { return { rowCount: 1, rows: [{ tasks: [] }] } } @@ -237,6 +241,32 @@ describe('queen round, lease lost', () => { expect(sql().some(isDispatchInsert)).toBe(true) }, ) + + /** + * BOOKKEEPING MUST NOT BE ABLE TO IDLE THE SWARM. + * + * The review used to throw straight out of the round, so a transient database + * error cost five minutes of every bee. One round measured on 2026-09-06 died + * exactly that way, on `deadlock detected`, and 135 others died a page + * earlier on a GitHub 403 - together leaving the swarm with ZERO bees running + * for half of a twelve-hour window. + * + * Nothing is lost by continuing: the review re-reads every unjudged dispatch + * next round by construction. What IS lost by throwing is the dispatch that + * would have happened, and no later round gives those minutes back. + * + * `FROM queen_dispatch d` is the review's SELECT and the only query in the + * round that uses that alias, so failing it targets the review and nothing + * else. Reverting the `.catch()` turns this red: the round stops before + * `dispatchBee` and the INSERT never appears. + */ + it.if(present)('dispatches even when the review throws', async () => { + const { pool, sql } = roundPool([], 'FROM queen_dispatch d') + const result = await runRound(pool, 'me', 7, { held: true }, [ISSUE]) + + expect(result.choice?.chosen).toBe(ISSUE) + expect(sql().some(isDispatchInsert)).toBe(true) + }) }) /**