diff --git a/trios/docs/queen-queue-depth.md b/trios/docs/queen-queue-depth.md new file mode 100644 index 0000000000..65d147c104 --- /dev/null +++ b/trios/docs/queen-queue-depth.md @@ -0,0 +1,260 @@ +# The Queue Depth the Swarm Defends + +Issue: gHashTag/trios#1333 + +## What this document is + +The contract for the number of startable issues the Queen wants ahead of +her: the constant and the reason for its value, what counts toward the +number and what does not, the two numbers every round report carries, the +low-queue state that is distinct from `nothing to choose`, and the two +cases that prove the exclusions. The measured facts below are from the +live deployment; the report shapes are the contract the implementation +must produce. Every rule names the code it binds: + +- the constant: `QueenDelegationPolicy` + (`rings/SR-00/QueenDelegation.swift`, copied byte for byte into + `agent-server/queen-core/Sources/QueenPolicy/QueenDelegation.swift` by + the container build and compared by `make queen-core-sync`) +- the count and the report: the `choose` answer of `queend` + (`agent-server/queen-core/Sources/queend/main.swift`), recorded by + `recordTick` in + `agent-server/apps/server/src/api/services/queen-tick.ts` +- the proving cases: the round harness + (`agent-server/apps/server/tests/api/queen-round.test.ts`), which + drives the real policy binary rather than a stub + +## The problem, measured + +2026-09-01 to 09-03, on the live deployment: every time a person fed the +backlog by hand, the Queen drained it within the hour and returned to +`nothing to choose`. The board at the moment of this issue: 41 done, 17 +open, 0 she may start. The rounds keep firing every few minutes and every +one reports the same sentence. + +The sentence is the defect. `nothing to choose` reads identically whether +the queue is empty, blocked, or merely between rounds, and only one of +those is a problem. It also reads identically at 0 startable and at 3 +startable, and no number anywhere says which depth the swarm wants, so +nothing can say "fewer" out loud. A swarm that never stops needs a number +it defends, and a report that says when it is below it. + +## The number, with its reason beside it + +```swift + /// How many delegatable, unheld issues the Queen wants ahead of her. + /// + /// Four, which is `maximumConcurrentWorkers` and not a new opinion. + /// The refill gate folds a burst of completions into exactly ONE + /// follow-up round, and that single pass hands out at most one issue + /// per worker slot - so a queue that cannot refill every slot at once + /// strands a freed key with nothing to refill it. Bursts are the + /// normal mode: measured 2026-09-01..09-03, every hand-fed backlog + /// was drained within the hour, which is a same-round burst by + /// definition. Four is the smallest depth that survives a full burst + /// and the largest that is not hoarding, since no single pass can eat + /// more than four. It tracks the worker ceiling, never the provider + /// key count: keys can be added without a deploy, issues cannot + /// appear without a person. + public static let targetQueueDepth = 4 +``` + +It sits in the policy beside `maximumConcurrentWorkers`, and the +TypeScript side declares nothing: the round reads `target` out of the +recorded answer. A second declaration of the number in another language +is the drift the board's `REVIEW_BOUNDARY_HOLD_HOURS` comment already +warns about. + +The four points, spelled out: + +1. The refill gate coalesces a burst of completions into exactly one + follow-up round (`docs/queen-work-conserving-swarm.md`: two, five or + fifty completions set the same one flag), and that one pass can + dispatch up to capacity. A queue that cannot refill every slot at + once strands a freed key until a person feeds the backlog - and the + only report is a sentence nobody can read a warning out of. +2. Bursts are the normal mode, not the exception. A backlog drained + within the hour is four starts inside one or two rounds. +3. Four is the smallest depth that survives a full burst. Three leaves + the fourth freed key stranded on the very afternoon the backlog was + richest. +4. Four is also the largest depth that is not hoarding. The swarm cannot + eat more than four in any single pass, so a deeper target would fire + on inventory rather than starvation, and an alarm tuned to the wrong + thing stops being read. + +The depth defends the swarm the policy permits, not the swarm this +morning's environment permits. Provider keys can be added without a +deploy (`providerKeyCount` reads them live) while issues cannot appear +without a person; a target that tracked the key count would fall every +time a key was pulled and rise silently when one was added, and would +need watching that a constant does not. + +## What counts toward the depth + +Food is an issue the round may start now. Both halves are existing rules +of the `choose` loop, and the depth is their intersection: + +- **delegatable**: the body's boundary section parses to at least one + path. `QueenSpecQuality.judge` sets `delegatable` to exactly + `hasBoundary`, and `QueenIssueBoundary.paths` does the parsing. An + issue that is delegatable but not yet a full spec IS food - the chooser + may take it and names the gap, which is why the loop passes it by for + nothing. +- **unheld**: no live claim on the issue (`claimOnIssue` - a task in + queued, running, awaitingReview or rejected state makes it spoken for) + and no still-holding task on its paths (`conflictingTasks` over + `pathsOverlap`; `stillHoldsBoundary` holds forever for queued, running + and rejected, for 48 hours for awaitingReview, and never for terminal + states). + +The depth is counted in the choose loop itself, because the loop already +walks every candidate through exactly these rules: the count is the set +that survived every pass-over - the issue chosen plus every one passed +over only as `not first`. Summed where it is already decided, it adds no +second policy. A counter written in TypeScript beside the round would be +a second implementation of "which bee starts next", which is the defect +the round's own header exists to avoid. + +The count is taken against the board the round's FIRST `choose` answer +sees - the registry mirror plus the container's own in-flight dispatches +- before this round starts anything. The issue the round is about to +dispatch is still startable at the moment of the count and is counted; +the dispatch makes it held for the next round, not for this report. The +dispatch loop's later answers, each folded with the previous dispatch, +feed the loop and are not the report. + +A truncated candidate list counts what the round saw: `openIssues` +already marks such a list `complete: false` and the round already logs it +loudly. The depth never invents the issues a page cap hid. + +## The round report: both numbers, always + +Every `choose` answer - and so every decision the round records in +`queen_tick.decision` - carries: + +```json +"queue": { "delegatable": 2, "target": 4 } +``` + +`delegatable` is this round's depth. `target` is the constant, verbatim +from the policy, so the report and the code cannot drift. Always means +every answer the round can produce, including the refusals that fire +before a candidate is ever walked: + +She chose, and the queue is low anyway - allowed, chosen, AND low, +because the state is about what is left, not what she did: + +```json +{ "kind": "choose", "allowed": true, "chosen": 1337, "refusal": null, + "skipped": ["#1338: not first"], + "queue": { "delegatable": 2, "target": 4, "low": true, + "phrase": "queue low: 2 of 4 delegatable and unheld" } } +``` + +Nothing startable - the refusal stands, unchanged, and the pair rides +with it: + +```json +{ "kind": "choose", "allowed": false, "refusal": "nothing to choose", + "skipped": ["#1401: not yet a spec - missing boundary"], + "queue": { "delegatable": 0, "target": 4, "low": true, + "phrase": "queue low: 0 of 4 delegatable and unheld" } } +``` + +Full, by capacity - no low state at or above target, even though nothing +was started: + +```json +{ "kind": "choose", "allowed": false, + "refusal": "4 workers already running (limit 4)", + "queue": { "delegatable": 6, "target": 4 } } +``` + +The pair is a fact about every round, not an alarm bell: carrying it +always is what makes `low` mean something when it appears. + +## The low-queue state: distinct, and silent until it matters + +Below target, the `queue` object gains `low: true` and a phrase naming +the depth it has and the depth it wants: + + queue low: 2 of 4 delegatable and unheld + +At or above target the state is absent - no `low`, no `phrase` - so the +signal means something when it appears. + +Distinct from `nothing to choose`, three ways, each checkable: + +1. Different field. The phrase is carried in `queue.phrase`. The + `refusal` field keeps the chooser's own sentences, unchanged, and the + capacity and money refusals keep theirs. The low-queue state never + replaces a refusal and never travels inside one. +2. Different list. `skipped` stays one pass-over line per candidate + issue; the phrase is never appended there, so the public skip + categories (`classifySkipReason`) and their counts are untouched. +3. Different words. The phrase and `nothing to choose` share no word - + queue, low, of, delegatable, and, unheld, plus the two numbers, + against nothing, to, choose - so no substring or word matcher can + file one as the other, and the `/queen/status` voucher for + `healthy_idle` (an explicit no-choice decision, read off `allowed` + and its refusal) reads exactly as it always has. + +The two MAY appear together - a depth of 0 reports both the refusal and +the phrase - but they are never the same string, and neither contains +the other. + +## The two exclusions, proven + +Two cases, one per excluded shape, both riding the round harness that +drives the real `queend` binary. Each asserts the depth from the +recorded decision, never from a recomputation. + +### Case 1 - a boundary-less issue does not count + +Given the candidate list is `[#1400, #1401]`; `#1401`'s body has no +boundary section, so its paths parse empty and `delegatable` is false +(and a body never supplied reads `no issue body was supplied, so its +boundary is unknown` - same exclusion); `#1400` names +`docs/queue-note.md` and nothing holds it, +When a round runs, +Then the report carries `"queue": { "delegatable": 1, "target": 4 }` +with the low-queue phrase (1 is below 4), and `#1401` appears in +`skipped` as `#1401: not yet a spec - missing boundary, ...`. + +The count is 1, not 2. An issue that names no files cannot be reserved +for anyone, and counting it is how a board of 17 open and 0 startable +would read as "17 of 4, healthy". + +### Case 2 - a held issue does not count + +Given the candidate list is `[#1400, #1402]`; both bodies carry boundary +sections, `#1400` claiming `docs/queue-note.md` and `#1402` claiming +`rings/SR-00/Queue.swift`; a running task on `#1286` already owns +`rings/SR-00`, which overlaps by path component, +When a round runs, +Then the report carries `"queue": { "delegatable": 1, "target": 4 }`, +and `#1402` appears in `skipped` as `#1402: rings/SR-00/Queue.swift held +by gHashTag/trios#1286`. + +The count is 1, not 2. A held issue is not food even though it is +delegatable - a bee started on it would collide with the holder, and the +48-hour review hold means "held" can outlast the day. This is the +measured case: it is why a backlog can be non-empty while the depth is +zero. + +## What this does not change + +- No value of `swarmState`. The `/queen/status` vocabulary is closed by + its own contract (`docs/public-swarm-state.md`); the low-queue state + is a field of the round's decision, not a public swarm state. Joining + the numbers to that endpoint would be a revision of that contract, + not of this one. +- No refusal sentence and no pass-over rule. The depth observes the + choose loop; it does not steer it. Order, capacity, money and holds + are untouched. +- No new author of issues. What the Queen does when she has fewer than + the target is this report: the low-queue state, both numbers, every + round, until a person feeds the backlog. Filling the queue remains a + person's act - this issue asked for the defended number to be visible, + and that is all this specifies.