diff --git a/trios/agent-server/apps/server/src/api/services/queen-claim-card.gen.ts b/trios/agent-server/apps/server/src/api/services/queen-claim-card.gen.ts new file mode 100644 index 0000000000..cfb61552b6 --- /dev/null +++ b/trios/agent-server/apps/server/src/api/services/queen-claim-card.gen.ts @@ -0,0 +1,82 @@ +// Generated by `t27c gen-ts` from claim.t27. Do not edit. +// +// Every name, value and type here comes from that spec. Change the spec and +// regenerate; an edit made here is lost the next time anyone builds. + +export const CL_RUNNING = 0 satisfies number; +export const CL_ACCEPTED = 1 satisfies number; +export const CL_REJECTED = 2 satisfies number; +export const CL_AWAITING_REVIEW = 3 satisfies number; +export const CL_FAILED = 4 satisfies number; +export const CL_CANCELLED = 5 satisfies number; +export const CLAIM_STATES = 6 satisfies number; +export const V_NONE = 0 satisfies number; +export const V_ACCEPT = 1 satisfies number; +export const V_SEND_BACK = 2 satisfies number; +export const V_ESCALATE = 3 satisfies number; +export const V_WAIT = 4 satisfies number; +export const V_FAILED = 5 satisfies number; +export const V_CANCELLED = 6 satisfies number; +export const V_EMPTY = 7 satisfies number; +export const V_STALE_CONTRACT = 8 satisfies number; +export const V_OBSOLETE = 9 satisfies number; +export const V_OTHER = 10 satisfies number; +export const VERDICT_CODES = 11 satisfies number; +export const SEND_BACK_FLOOR_MINUTES = 60 satisfies number; +export const WAIT_FROZEN_FLOOR_MINUTES = 360 satisfies number; +export const DEFAULT_SEND_BACK_CEILING = 2 satisfies number; +export const FREE_ATTEMPT_CEILING = 3 satisfies number; +export const REVIEWER_MISS_CEILING = 3 satisfies number; +export const UNREAD_CRITERIA = 1 satisfies number; +// t27c gen-ts: fn claim_of_step was not emitted -- this backend lowers declarations, not bodies. +// t27c gen-ts: fn criteria_read_as was not emitted -- this backend lowers declarations, not bodies. +// t27c gen-ts: fn escalation_kind_of was not emitted -- this backend lowers declarations, not bodies. +// t27c gen-ts: fn claim_of was not emitted -- this backend lowers declarations, not bodies. +// t27c gen-ts: a TestBlock was not emitted -- it is checked by the compiler, not by the artifact. +// t27c gen-ts: a TestBlock was not emitted -- it is checked by the compiler, not by the artifact. +// t27c gen-ts: a TestBlock was not emitted -- it is checked by the compiler, not by the artifact. +// t27c gen-ts: a TestBlock was not emitted -- it is checked by the compiler, not by the artifact. +// t27c gen-ts: a TestBlock was not emitted -- it is checked by the compiler, not by the artifact. +// t27c gen-ts: a TestBlock was not emitted -- it is checked by the compiler, not by the artifact. +// t27c gen-ts: a TestBlock was not emitted -- it is checked by the compiler, not by the artifact. +// t27c gen-ts: a TestBlock was not emitted -- it is checked by the compiler, not by the artifact. +// t27c gen-ts: a TestBlock was not emitted -- it is checked by the compiler, not by the artifact. +// t27c gen-ts: a TestBlock was not emitted -- it is checked by the compiler, not by the artifact. +// t27c gen-ts: a TestBlock was not emitted -- it is checked by the compiler, not by the artifact. +// t27c gen-ts: a InvariantBlock was not emitted -- it is checked by the compiler, not by the artifact. +// t27c gen-ts: a InvariantBlock was not emitted -- it is checked by the compiler, not by the artifact. +export const BACKFILL_WINDOW_MINUTES = 120 satisfies number; +export const CEILING_FLOOR_MINUTES = 60 satisfies number; +export const KIND_BASE_TRUTH_ALONE = 7 satisfies number; +export const KIND_BEYOND_THE_PATCH = 6 satisfies number; +export const KIND_DEAD_LETTER = 3 satisfies number; +export const KIND_NO_CRITERIA = 1 satisfies number; +export const KIND_REVIEWER_GAVE_UP = 4 satisfies number; +export const KIND_SEND_BACK_CEILING = 2 satisfies number; +export const KIND_UNRECORDED = 0 satisfies number; +export const MAX_RELEASES = 1 satisfies number; +export const OBSOLETE_STATE = "obsolete" satisfies string; +export const RETRY_FLOOR_MINUTES = 30 satisfies number; +export const STEP_BACKFILL = 2 satisfies number; +export const STEP_CLOSE = 3 satisfies number; +export const STEP_HOLD = 0 satisfies number; +export const STEP_RELEASE = 1 satisfies number; +// t27c gen-ts: fn escalation_kind was not emitted -- this backend lowers declarations, not bodies. +// t27c gen-ts: fn next_step was not emitted -- this backend lowers declarations, not bodies. +// t27c gen-ts: fn wants_backfill was not emitted -- this backend lowers declarations, not bodies. + +// Declaration order, which the spec's own laws depend on. +export const __STRUCT_ORDER__ = [] as const; +export const __DECL_ORDER__ = ["CL_RUNNING", "CL_ACCEPTED", "CL_REJECTED", "CL_AWAITING_REVIEW", "CL_FAILED", "CL_CANCELLED", "CLAIM_STATES", "V_NONE", "V_ACCEPT", "V_SEND_BACK", "V_ESCALATE", "V_WAIT", "V_FAILED", "V_CANCELLED", "V_EMPTY", "V_STALE_CONTRACT", "V_OBSOLETE", "V_OTHER", "VERDICT_CODES", "SEND_BACK_FLOOR_MINUTES", "WAIT_FROZEN_FLOOR_MINUTES", "DEFAULT_SEND_BACK_CEILING", "FREE_ATTEMPT_CEILING", "REVIEWER_MISS_CEILING", "UNREAD_CRITERIA", "BACKFILL_WINDOW_MINUTES", "CEILING_FLOOR_MINUTES", "KIND_BASE_TRUTH_ALONE", "KIND_BEYOND_THE_PATCH", "KIND_DEAD_LETTER", "KIND_NO_CRITERIA", "KIND_REVIEWER_GAVE_UP", "KIND_SEND_BACK_CEILING", "KIND_UNRECORDED", "MAX_RELEASES", "OBSOLETE_STATE", "RETRY_FLOOR_MINUTES", "STEP_BACKFILL", "STEP_CLOSE", "STEP_HOLD", "STEP_RELEASE"] as const; + +// What this spec holds and this backend did not print. Empty is the whole +// story most of the time; an entry here is a promise the artifact does not +// keep, and reading it is how a tool tells a partial module from a complete +// one without parsing comments. +// +// A LIST, not a map keyed by name. `specs/numeric/formats.t27` declares four +// separate consts called `result`, one per test block, and under an object +// literal three of the four omissions vanished into the fourth -- a record of +// what went missing that itself went missing. A spec is free to reuse a +// name; this file is not free to lose the second one. +export const __NOT_EMITTED__ = Object.freeze([] as const); diff --git a/trios/agent-server/apps/server/src/api/services/queen-claim.ts b/trios/agent-server/apps/server/src/api/services/queen-claim.ts new file mode 100644 index 0000000000..b17337c2c1 --- /dev/null +++ b/trios/agent-server/apps/server/src/api/services/queen-claim.ts @@ -0,0 +1,164 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * WHAT A DISPATCH ROW STILL CLAIMS (gHashTag/t27 specs/queen/claim.t27, + * t27#8765; gHashTag/trios#1767). + * + * Every round asks it of every queen_dispatch row, and the board asks it of + * every card: running, accepted, rejected, awaitingReview, failed or + * cancelled. The card's claim_of decides, as generated wasm, and it imports + * the review valve's own escalation_kind and next_step instead of restating + * them. This file only reads the row into the card's codes and the card's + * answer back into the word the round, the board and queend use. + * + * The rule was hand-written TypeScript in queen-tick.ts until this card. That + * body is kept verbatim in tests/api/queen-claim-card.test.ts, where the card + * is proven to answer it word for word over every input the round can pass. + */ + +import { logger } from '../../lib/logger' +import { flag, loadCardWasm, u32 } from './queen-card-wasm' +import { + CL_ACCEPTED, + CL_AWAITING_REVIEW, + CL_CANCELLED, + CL_FAILED, + CL_REJECTED, + CL_RUNNING, + DEFAULT_SEND_BACK_CEILING, + OBSOLETE_STATE, + V_ACCEPT, + V_CANCELLED, + V_EMPTY, + V_ESCALATE, + V_FAILED, + V_NONE, + V_OBSOLETE, + V_OTHER, + V_SEND_BACK, + V_STALE_CONTRACT, + V_WAIT, +} from './queen-claim-card.gen' + +export const CLAIM_CARD = 'queen/claim.wasm' + +export type DispatchClaim = + | 'running' + | 'accepted' + | 'rejected' + | 'awaitingReview' + | 'failed' + | 'cancelled' + +/** What a row carries besides its verdict, as stateOfDispatch has always taken it. */ +export interface ClaimLease { + /** Milliseconds since finished_at, the one clock nothing rewrites. */ + idleMs?: number + sendBacks?: number + /** The send-back ceiling; the card's DEFAULT_SEND_BACK_CEILING when absent. */ + ceiling?: number + /** How many times this issue's ceiling has already been handed back. */ + releases?: number + /** The escalation's kind is read from these (specs/queen/review_valve.t27). */ + criteria?: number + freeAttempts?: number + reviewerMisses?: number + /** + * TRIOS_QUEEN_RELEASE_DEAD_CLAIMS (holds.t27 send_back_claim, t27#9111). It + * is read by stateOfDispatch before the card is asked, never by the card; + * the environment's reading is used when it is not given. + */ + releaseDeadClaims?: boolean +} + +/** + * The review_state words the card has a rule for. Recognising a word is text + * work and stays here; what the word means is the card's. A word this table + * does not name is V_OTHER, which the card holds with no clock. + */ +const VERDICTS: ReadonlyMap = new Map([ + ['', V_NONE], + ['accept', V_ACCEPT], + ['sendBack', V_SEND_BACK], + ['escalate', V_ESCALATE], + ['wait', V_WAIT], + ['failed', V_FAILED], + ['cancelled', V_CANCELLED], + ['empty', V_EMPTY], + ['stale-contract', V_STALE_CONTRACT], + [OBSOLETE_STATE, V_OBSOLETE], +]) + +/** The card's code for a review_state value; null and undefined are no verdict. */ +export function verdictCode(reviewState: unknown): number { + return VERDICTS.get(String(reviewState ?? '')) ?? V_OTHER +} + +const CLAIMS: ReadonlyMap = new Map([ + [CL_RUNNING, 'running'], + [CL_ACCEPTED, 'accepted'], + [CL_REJECTED, 'rejected'], + [CL_AWAITING_REVIEW, 'awaitingReview'], + [CL_FAILED, 'failed'], + [CL_CANCELLED, 'cancelled'], +]) + +/** Codes already reported, so a drifted card logs once, not once per row per round. */ +const reported = new Set() + +/** + * The word for a claim code. claim.t27 answers only CL_ codes, so any other + * code means the vendored card drifted from its constants. That is logged as + * an error and the row is held as awaitingReview, the claim the card gives a + * verdict it does not know: the issue and its files stay held, so nothing is + * dispatched twice, and one row cannot fail every round and the board (the + * round maps every in-flight row through this, with no try per row). A card + * that cannot load still throws, from loadCardWasm. + */ +export function claimWord(code: number): DispatchClaim { + const word = CLAIMS.get(code) + if (word !== undefined) return word + if (!reported.has(code)) { + reported.add(code) + logger.error('The claim card answered a code that is no claim', { + card: CLAIM_CARD, + code, + held: 'awaitingReview', + }) + } + return 'awaitingReview' +} + +/** + * Whole minutes since finished_at. Every floor in the card is a whole number + * of minutes, so `idleMs >= floorMs` holds exactly when this is at least the + * floor in minutes. An unreadable (NaN) or negative clock reads 0: it keeps + * the hold, as every comparison against NaN did. + */ +export const idleMinutes = (idleMs: number | undefined): number => + u32((idleMs ?? 0) / 60_000) + +/** What the row claims, decided by the card's claim_of. */ +export function claimOf( + finished: boolean, + reviewState: unknown, + lease: ClaimLease = {}, +): DispatchClaim { + return claimWord( + loadCardWasm(CLAIM_CARD).call( + 'claim_of', + flag(finished), + verdictCode(reviewState), + idleMinutes(lease.idleMs), + u32(lease.sendBacks ?? 0), + u32(lease.ceiling ?? DEFAULT_SEND_BACK_CEILING), + u32(lease.releases ?? 0), + flag(lease.criteria != null), + u32(lease.criteria ?? 0), + u32(lease.freeAttempts ?? 0), + u32(lease.reviewerMisses ?? 0), + ), + ) +} 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 7430651a39..170f6247f9 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 @@ -51,6 +51,14 @@ import { handRoundToBeeActors, } from './queen-bee-actors-round' import { githubCiDeps, takeBackRefusedAcceptances } from './queen-ci-verdict' +import { type ClaimLease, claimOf, type DispatchClaim } from './queen-claim' +import { + FREE_ATTEMPT_CEILING as CLAIM_FREE_ATTEMPT_CEILING, + REVIEWER_MISS_CEILING as CLAIM_REVIEWER_MISS_CEILING, + DEFAULT_SEND_BACK_CEILING, + SEND_BACK_FLOOR_MINUTES, + WAIT_FROZEN_FLOOR_MINUTES, +} from './queen-claim-card.gen' import { contributorRuntime } from './queen-contributor-keys' import { clearAssigns, @@ -141,13 +149,9 @@ import { } from './queen-review-loop' import { CEILING_FLOOR_MINUTES, - escalationKind, - KIND_SEND_BACK_CEILING, MAX_RELEASES, - nextStep, OBSOLETE_STATE, RETRY_FLOOR_MINUTES, - stateOfStep, } from './queen-review-valve' import { chooseReviewerLane, @@ -748,8 +752,10 @@ const isoSeconds = (value: unknown): string => * delivery count is exhausted. * - `idle` defaults to 0, so every existing caller and test keeps today's * behaviour until it passes the new argument. + * + * The hour itself is the claim card's (specs/queen/claim.t27). */ -export const SEND_BACK_IDLE_FLOOR_MS = 60 * 60 * 1000 +export const SEND_BACK_IDLE_FLOOR_MS = SEND_BACK_FLOOR_MINUTES * 60 * 1000 /** * The same defect in the third state, with a much longer floor. @@ -768,8 +774,10 @@ export const SEND_BACK_IDLE_FLOOR_MS = 60 * 60 * 1000 * to flush will parse on a later sweep - so the clock must be long enough that * only a genuinely frozen one is released. A send-back gets no second look at * all, which is why its floor is an hour. + * + * The six hours are the claim card's (specs/queen/claim.t27). */ -export const WAIT_FROZEN_FLOOR_MS = 6 * 60 * 60 * 1000 +export const WAIT_FROZEN_FLOOR_MS = WAIT_FROZEN_FLOOR_MINUTES * 60 * 1000 /** * The floor on an empty attempt; see `stateOfDispatch`. From @@ -815,149 +823,41 @@ function nobodyComesBack( return sendBackClaim(sendBacks, ceiling) === CL_FREE } +/** + * What a dispatch row claims, decided by the claim card (gHashTag/t27 + * specs/queen/claim.t27, gHashTag/trios#1767) as generated wasm; see + * `queen-claim.ts`. The card imports the review valve's escalation_kind and + * next_step, so the valve is still its one home. + * + * One check stays in front of the card, as it stood on `queen` when this card + * was merged in (#1893): a send-back that nobody comes back to, behind + * TRIOS_QUEEN_RELEASE_DEAD_CLAIMS (off by default), frees its issue at once. + * It is asked only of a finished row whose verdict is `sendBack`, exactly where + * the hand-written rule asked it, before the send-back's own hour. With the + * flag off the card answers alone, as proven in + * tests/api/queen-claim-card.test.ts. + * + * The hand-written body this replaced, with every measurement its comments + * recorded, is kept verbatim in tests/api/queen-claim-card.test.ts: the card + * is proven there to give its answer for every input the round can pass. + */ export function stateOfDispatch( finished: boolean, reviewState: unknown, - lease: { - idleMs?: number - sendBacks?: number - ceiling?: number - /** How many times this issue's ceiling has already been handed back. */ - releases?: number - /** The escalation's kind is read from these (specs/queen/review_valve.t27). */ - criteria?: number - freeAttempts?: number - reviewerMisses?: number - /** - * TRIOS_QUEEN_RELEASE_DEAD_CLAIMS (holds.t27 send_back_claim, t27#9111). - * Read from the environment when not given, so the round and the board - * read one rule. - */ - releaseDeadClaims?: boolean - } = {}, -): - | 'running' - | 'accepted' - | 'rejected' - | 'awaitingReview' - | 'failed' - | 'cancelled' { - if (!finished) return 'running' - const verdict = String(reviewState ?? '') - if (verdict === 'accept') return 'accepted' - const idleMs = lease.idleMs ?? 0 - const sendBacks = lease.sendBacks ?? 0 - const releases = lease.releases ?? 0 - // Read from QueenRetryPolicy.maximumRealAttempts rather than restated, so - // there is one ceiling and not two that agree until someone edits one. - const ceiling = lease.ceiling ?? 2 - - // A verdict that SAYS failed is a failure. This case was missing, so - // `review_state = 'failed'` fell through to `awaitingReview` at the bottom - - // a LIVE claim in `QueenDelegationPolicy.claimOnIssue` - and the issue stayed - // held by the very row that recorded its release. - // - // Measured 2026-09-04: five dispatches were deliberately set to `failed` to - // return their issues to the pool (#1133, #1175, #1216, #1240, #1311). All - // five stayed in `claimed`, the tick kept refusing with "nothing to choose" - // against 22 candidates, and the swarm sat at zero bees of four. The write - // was correct; the reader had no case for it. - // - // The function already RETURNS 'failed' two lines below for a send-back that - // outlived its floor. It could produce the state and not recognise it. - if (verdict === 'failed' || verdict === 'cancelled') return 'failed' - // An EMPTY attempt committed nothing and said nothing, so there is no work - // to hold files for and no bee expected back to them. Measured 2026-09-17, - // most of the live waits on the board were exactly this - turns killed by a - // deploy or ended in seconds by a 1302 - each holding its boundary for six - // hours before the wait valve let go, while 30 of 32 worker lanes sat idle. - // - // Released after a SHORT floor, not at once. With no floor the same issue - // was chosen again in the same round, and a bee's close asks for the next - // round immediately: during a z.ai quota window (1308/1316, hours long) - // three empty attempts - and an escalation no timer releases - took - // minutes, and every lane moved one issue a cycle out of the backlog into - // needs-you. Half an hour lets a rate limit pass and a quota window be - // retried a handful of times, not a hundred. - if (verdict === 'empty') { - return idleMs >= EMPTY_ATTEMPT_FLOOR_MS ? 'failed' : 'rejected' - } - - // THE CONTRACT IT WAS JUDGED AGAINST IS GONE. An issue's criteria are frozen - // onto the dispatch row so a bee is judged by what it was told, and that is - // right - but when the ISSUE's criteria are rewritten, the verdict on the old - // row answers a question nobody is asking any more. Measured 2026-09-19: 126 - // open issues quoted a compiler a bee does not have and criteria the container - // could never satisfy; after they were rewritten, 72 of the 86 rows still - // holding their issues had been judged against the old text. Released at - // once, with no floor: there is nothing to wait for. - if (verdict === 'stale-contract') return 'failed' - - // CLOSED BY THE REVIEW VALVE: the reason is in judged_note, the files are - // free, and the round does not take the issue again while the row stands. - if (verdict === OBSOLETE_STATE) return 'cancelled' - const idleMinutes = Math.floor(idleMs / 60_000) - - if (verdict === 'sendBack') { - // NOBODY COMES BACK TO A SEND-BACK (holds.t27 send_back_claim, t27#9111, - // behind TRIOS_QUEEN_RELEASE_DEAD_CLAIMS). The verdict is written on a - // finished row, a runner deletes the bee's session when its turn ends, and - // nothing resumes it - so the idle hour below is an hour of waiting for no - // one. Measured 2026-10-11 12:55Z: 8 of 89 claims were send-backs under - // the ceiling. Under the ceiling the card frees the issue at once; at the - // ceiling it falls through to the review valve, as before. - if (nobodyComesBack(lease.releaseDeadClaims, sendBacks, ceiling)) - return 'failed' - if (idleMs >= SEND_BACK_IDLE_FLOOR_MS && sendBacks < ceiling) - return 'failed' - // AT THE CEILING, THE ISSUE STOPS BEING THE SWARM'S - BUT ITS FILES MUST - // NOT STAY HELD. - // - // `rejected` had no clock at all, so an issue that spent its two attempts - // pinned its boundary until the dispatch row fell out of the 7-day window. - // Measured 2026-09-20, the morning after the adversarial review began - // refusing work the compiler cannot build: 71 of the swarm's issues were - // claimed, 0 of 8 lanes ran, and the tick refused 673 candidates with - // "nothing to choose". Every one of those refusals was honest - the Zig the - // bees generated did not compile - and the swarm still had to stop. - // - // A ceiling is a statement about ATTEMPTS, and the old rule read it as a - // lease with no clock: "a person decides, not a timer". That is right the - // second time it happens and wrong the first, because the person is asleep - // and the swarm is not. So the ceiling is spent ONCE more - after the same - // hour a single send-back waits out - and the count of those releases is - // itself bounded: past `MAX_CEILING_RELEASES` the row stays `rejected` and - // the issue really is a person's. - // The review valve (specs/queen/review_valve.t27) decides from here: once - // after the ceiling floor, then closed rather than held for a person. - return stateOfStep( - nextStep(KIND_SEND_BACK_CEILING, idleMinutes, releases), - 'rejected', - ) - } - // AN ESCALATION HAS A NEXT STEP AND A CLOCK. It used to fall through to - // `awaitingReview` - "a timer is not a person" - and measured 2026-10-05 that - // was 144 of 219 review cards, each holding its files (fileConflict 108, - // 25 of 70 workers active). The owner's rule is that no person is needed, so - // specs/queen/review_valve.t27 decides: release, criteria backfill, or close. - if (verdict === 'escalate') { - const kind = escalationKind( - lease.criteria ?? 1, - sendBacks >= ceiling, - (lease.freeAttempts ?? 0) >= FREE_ATTEMPT_CEILING, - (lease.reviewerMisses ?? 0) >= REVIEWER_MISS_CEILING, + lease: ClaimLease = {}, +): DispatchClaim { + if ( + finished && + String(reviewState ?? '') === 'sendBack' && + nobodyComesBack( + lease.releaseDeadClaims, + lease.sendBacks ?? 0, + lease.ceiling ?? DEFAULT_SEND_BACK_CEILING, ) - return stateOfStep(nextStep(kind, idleMinutes, releases), 'awaitingReview') - } - // A wait that has outlasted the frozen floor was never judged and never will - // be, because nothing about its input can change. (`escalate` is handled - // above, by the review valve.) - if (verdict === '' || verdict === 'wait') { - if (idleMs >= WAIT_FROZEN_FLOOR_MS && sendBacks < ceiling) return 'failed' - } - return 'awaitingReview' + ) + return 'failed' + return claimOf(finished, reviewState, lease) } - /** * Write down each close the review valve decided this round, and name the * issues it closed. The reason goes first in `judged_note`, the note a @@ -3125,8 +3025,11 @@ export interface ReviewRound { * because a quota window ends every turn at once and three of them are * minutes, not evidence about the issue. Reaped attempts are not counted * either, for the same reason: a deploy restart reaps every running bee. + * + * The number is the claim card's (specs/queen/claim.t27), which reads the + * dead letter from it. */ -export const FREE_ATTEMPT_CEILING = 3 +export const FREE_ATTEMPT_CEILING = CLAIM_FREE_ATTEMPT_CEILING /** The note a send-back carries when an adversarial reviewer judged it. */ function reviewedSendBackMessage( @@ -4596,8 +4499,11 @@ export function sweepDeadlineMs(intervalSeconds: number): number { * Rounds a bought review may fail to arrive for one commit before a person is * asked. Three, the same order as `FREE_ATTEMPT_CEILING`: enough for a lane to * be backed off and another tried, not enough to hold finished work for hours. + * + * The number is the claim card's (specs/queen/claim.t27), which reads the + * reviewer giving up from it. */ -export const REVIEWER_MISS_CEILING = 3 +export const REVIEWER_MISS_CEILING = CLAIM_REVIEWER_MISS_CEILING /** * Whether a finished turn was ended by the provider or the transport rather diff --git a/trios/agent-server/apps/server/tests/api/queen-claim-card.test.ts b/trios/agent-server/apps/server/tests/api/queen-claim-card.test.ts new file mode 100644 index 0000000000..984fb56008 --- /dev/null +++ b/trios/agent-server/apps/server/tests/api/queen-claim-card.test.ts @@ -0,0 +1,830 @@ +/** + * @license + * Copyright 2025 BrowserOS + * SPDX-License-Identifier: AGPL-3.0-or-later + * + * THE CLAIM CARD (gHashTag/t27 specs/queen/claim.t27, t27#8765; + * gHashTag/trios#1767), proven equal to the rule it replaced before the round + * was handed to it. + * + * What is REAL here: the wasm the card generates (specs/queen/claim.wasm), + * called through the same loader the round uses, and stateOfDispatch as the + * round and the board call it. What is the REFERENCE: stateOfDispatch's + * hand-written body as it stood on queen at 82bcc934, copied below verbatim + * with its comments, and the constants it read, written as the values they + * had there. The reference calls the review valve's HAND mirror + * (queen-review-valve.ts), never the card, so the two sides share nothing but + * the inputs. + * + * The grid is the round's whole input space at every boundary that matters: + * each review_state word the column holds and words it does not, idle clocks + * a minute and a millisecond either side of every floor (and unreadable, + * negative and huge ones), and every counter either side of its ceiling. + * A grid that agrees could still be blind, so each mutant of the reference + * below (a floor moved by a minute, a ceiling or a default moved by one, a + * word the boundary forgets) must disagree with the card somewhere in it. + */ + +import { describe, expect, it, spyOn } from 'bun:test' +import { createHash } from 'node:crypto' +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { loadCardWasm } from '../../src/api/services/queen-card-wasm' +import { + CLAIM_CARD, + claimOf, + claimWord, + idleMinutes, + verdictCode, +} from '../../src/api/services/queen-claim' +import * as card from '../../src/api/services/queen-claim-card.gen' +import * as valve from '../../src/api/services/queen-review-valve' +import { + dispatchRowState, + stateOfDispatch, +} from '../../src/api/services/queen-tick' +import { DEFAULT_SPECS_ROOT } from '../../src/inngest/spec-catalog' +import { logger } from '../../src/lib/logger' + +// --- The reference: the hand-written rule, as it stood ----------------------- +// +// `let`, not `const`: a mutant below moves one of them and puts it back. + +let EMPTY_ATTEMPT_FLOOR_MS = valve.RETRY_FLOOR_MINUTES * 60 * 1000 +let SEND_BACK_IDLE_FLOOR_MS = 60 * 60 * 1000 +let WAIT_FROZEN_FLOOR_MS = 6 * 60 * 60 * 1000 +let FREE_ATTEMPT_CEILING = 3 +let REVIEWER_MISS_CEILING = 3 +const OBSOLETE_STATE = valve.OBSOLETE_STATE +const KIND_SEND_BACK_CEILING = valve.KIND_SEND_BACK_CEILING +let nextStep = valve.nextStep +const escalationKind = valve.escalationKind +const stateOfStep = valve.stateOfStep + +function handWrittenStateOfDispatch( + finished: boolean, + reviewState: unknown, + lease: { + idleMs?: number + sendBacks?: number + ceiling?: number + /** How many times this issue's ceiling has already been handed back. */ + releases?: number + /** The escalation's kind is read from these (specs/queen/review_valve.t27). */ + criteria?: number + freeAttempts?: number + reviewerMisses?: number + } = {}, +): + | 'running' + | 'accepted' + | 'rejected' + | 'awaitingReview' + | 'failed' + | 'cancelled' { + if (!finished) return 'running' + const verdict = String(reviewState ?? '') + if (verdict === 'accept') return 'accepted' + const idleMs = lease.idleMs ?? 0 + const sendBacks = lease.sendBacks ?? 0 + const releases = lease.releases ?? 0 + // Read from QueenRetryPolicy.maximumRealAttempts rather than restated, so + // there is one ceiling and not two that agree until someone edits one. + const ceiling = lease.ceiling ?? 2 + + // A verdict that SAYS failed is a failure. This case was missing, so + // `review_state = 'failed'` fell through to `awaitingReview` at the bottom - + // a LIVE claim in `QueenDelegationPolicy.claimOnIssue` - and the issue stayed + // held by the very row that recorded its release. + // + // Measured 2026-09-04: five dispatches were deliberately set to `failed` to + // return their issues to the pool (#1133, #1175, #1216, #1240, #1311). All + // five stayed in `claimed`, the tick kept refusing with "nothing to choose" + // against 22 candidates, and the swarm sat at zero bees of four. The write + // was correct; the reader had no case for it. + // + // The function already RETURNS 'failed' two lines below for a send-back that + // outlived its floor. It could produce the state and not recognise it. + if (verdict === 'failed' || verdict === 'cancelled') return 'failed' + // An EMPTY attempt committed nothing and said nothing, so there is no work + // to hold files for and no bee expected back to them. Measured 2026-09-17, + // most of the live waits on the board were exactly this - turns killed by a + // deploy or ended in seconds by a 1302 - each holding its boundary for six + // hours before the wait valve let go, while 30 of 32 worker lanes sat idle. + // + // Released after a SHORT floor, not at once. With no floor the same issue + // was chosen again in the same round, and a bee's close asks for the next + // round immediately: during a z.ai quota window (1308/1316, hours long) + // three empty attempts - and an escalation no timer releases - took + // minutes, and every lane moved one issue a cycle out of the backlog into + // needs-you. Half an hour lets a rate limit pass and a quota window be + // retried a handful of times, not a hundred. + if (verdict === 'empty') { + return idleMs >= EMPTY_ATTEMPT_FLOOR_MS ? 'failed' : 'rejected' + } + + // THE CONTRACT IT WAS JUDGED AGAINST IS GONE. An issue's criteria are frozen + // onto the dispatch row so a bee is judged by what it was told, and that is + // right - but when the ISSUE's criteria are rewritten, the verdict on the old + // row answers a question nobody is asking any more. Measured 2026-09-19: 126 + // open issues quoted a compiler a bee does not have and criteria the container + // could never satisfy; after they were rewritten, 72 of the 86 rows still + // holding their issues had been judged against the old text. Released at + // once, with no floor: there is nothing to wait for. + if (verdict === 'stale-contract') return 'failed' + + // CLOSED BY THE REVIEW VALVE: the reason is in judged_note, the files are + // free, and the round does not take the issue again while the row stands. + if (verdict === OBSOLETE_STATE) return 'cancelled' + const idleMinutes = Math.floor(idleMs / 60_000) + + if (verdict === 'sendBack') { + if (idleMs >= SEND_BACK_IDLE_FLOOR_MS && sendBacks < ceiling) + return 'failed' + // AT THE CEILING, THE ISSUE STOPS BEING THE SWARM'S - BUT ITS FILES MUST + // NOT STAY HELD. + // + // `rejected` had no clock at all, so an issue that spent its two attempts + // pinned its boundary until the dispatch row fell out of the 7-day window. + // Measured 2026-09-20, the morning after the adversarial review began + // refusing work the compiler cannot build: 71 of the swarm's issues were + // claimed, 0 of 8 lanes ran, and the tick refused 673 candidates with + // "nothing to choose". Every one of those refusals was honest - the Zig the + // bees generated did not compile - and the swarm still had to stop. + // + // A ceiling is a statement about ATTEMPTS, and the old rule read it as a + // lease with no clock: "a person decides, not a timer". That is right the + // second time it happens and wrong the first, because the person is asleep + // and the swarm is not. So the ceiling is spent ONCE more - after the same + // hour a single send-back waits out - and the count of those releases is + // itself bounded: past `MAX_CEILING_RELEASES` the row stays `rejected` and + // the issue really is a person's. + // The review valve (specs/queen/review_valve.t27) decides from here: once + // after the ceiling floor, then closed rather than held for a person. + return stateOfStep( + nextStep(KIND_SEND_BACK_CEILING, idleMinutes, releases), + 'rejected', + ) + } + // AN ESCALATION HAS A NEXT STEP AND A CLOCK. It used to fall through to + // `awaitingReview` - "a timer is not a person" - and measured 2026-10-05 that + // was 144 of 219 review cards, each holding its files (fileConflict 108, + // 25 of 70 workers active). The owner's rule is that no person is needed, so + // specs/queen/review_valve.t27 decides: release, criteria backfill, or close. + if (verdict === 'escalate') { + const kind = escalationKind( + lease.criteria ?? 1, + sendBacks >= ceiling, + (lease.freeAttempts ?? 0) >= FREE_ATTEMPT_CEILING, + (lease.reviewerMisses ?? 0) >= REVIEWER_MISS_CEILING, + ) + return stateOfStep(nextStep(kind, idleMinutes, releases), 'awaitingReview') + } + // A wait that has outlasted the frozen floor was never judged and never will + // be, because nothing about its input can change. (`escalate` is handled + // above, by the review valve.) + if (verdict === '' || verdict === 'wait') { + if (idleMs >= WAIT_FROZEN_FLOOR_MS && sendBacks < ceiling) return 'failed' + } + return 'awaitingReview' +} + +// --- The grid ---------------------------------------------------------------- + +type Lease = NonNullable[2]> +type Rule = (finished: boolean, reviewState: unknown, lease: Lease) => string + +const MINUTE = 60_000 +const WORDS: unknown[] = [ + undefined, + null, + '', + 'accept', + 'sendBack', + 'escalate', + 'wait', + 'failed', + 'cancelled', + 'empty', + 'stale-contract', + OBSOLETE_STATE, + 'Accept', + 'escalated', + '__proto__', + 0, +] +const FLOORS_MIN = [30, 60, 120, 360, 2880] +const IDLE_MS: Array = [ + undefined, + Number.NaN, + -MINUTE, + 0, + MINUTE, + ...FLOORS_MIN.flatMap((f) => [ + (f - 1) * MINUTE, + f * MINUTE - 1, + f * MINUTE, + (f + 1) * MINUTE, + ]), + 1e12, + Number.POSITIVE_INFINITY, +] +const COUNTS: Array = [undefined, 0, 1, 2, 3] +const CEILINGS: Array = [undefined, 1, 2, 3] +const RELEASES: Array = [undefined, 0, 1, 2] +const CRITERIA: Array = [undefined, null, 0, 2] +const MISSES: Array = [undefined, 0, 2, 3] + +/** The grid's axes, outermost first. */ +const AXES: ReadonlyArray = [ + WORDS, + IDLE_MS, + COUNTS, + CEILINGS, + RELEASES, + CRITERIA, + MISSES, + MISSES, +] +const GRID_SIZE = AXES.reduce((n, axis) => n * axis.length, 1) + +/** Every finished row of the grid, in a fixed order: a mixed-radix count. */ +function* finishedGrid(): Generator<[unknown, Lease]> { + const at = AXES.map(() => 0) + for (let n = 0; n < GRID_SIZE; n++) { + const [ + word, + idleMs, + sendBacks, + ceiling, + releases, + criteria, + freeAttempts, + reviewerMisses, + ] = at.map((i, axis) => AXES[axis][i]) + yield [ + word, + { + idleMs, + sendBacks, + ceiling, + releases, + criteria, + freeAttempts, + reviewerMisses, + } as Lease, + ] + for (let axis = AXES.length - 1; axis >= 0; axis--) { + if (++at[axis] < AXES[axis].length) break + at[axis] = 0 + } + } +} + +const CLAIM_CODE: Record = { + running: card.CL_RUNNING, + accepted: card.CL_ACCEPTED, + rejected: card.CL_REJECTED, + awaitingReview: card.CL_AWAITING_REVIEW, + failed: card.CL_FAILED, + cancelled: card.CL_CANCELLED, +} + +/** The card's answer for every finished row, computed once. */ +const cardAnswers = (() => { + const out = new Uint8Array(GRID_SIZE) + let i = 0 + for (const [word, lease] of finishedGrid()) + out[i++] = + CLAIM_CODE[ + stateOfDispatch(true, word, { ...lease, releaseDeadClaims: false }) + ] + return out +})() + +/** The first rows where `rule` disagrees with the card, up to `limit`. */ +function disagreements(rule: Rule, limit = 3): string[] { + const out: string[] = [] + let i = 0 + for (const [word, lease] of finishedGrid()) { + const want = cardAnswers[i++] + const got = rule(true, word, lease) + if (CLAIM_CODE[got] !== want) { + out.push( + `${JSON.stringify(word)} ${JSON.stringify(lease)}: rule ${got}, card ${claimWord(want)}`, + ) + if (out.length >= limit) break + } + } + return out +} + +describe('the vendored claim card is the one PIN names', () => { + const sha = (file: string) => + createHash('sha256') + .update(readFileSync(join(DEFAULT_SPECS_ROOT, file))) + .digest('hex') + const pin = readFileSync(join(DEFAULT_SPECS_ROOT, 'PIN'), 'utf8') + + it('claim.t27, the review_valve.t27 it imports and claim.wasm match', () => { + expect(pin).toContain(`queen/claim.t27 sha256 ${sha('queen/claim.t27')}`) + expect(pin).toContain( + `queen/review_valve.t27 sha256 ${sha('queen/review_valve.t27')}`, + ) + expect(pin).toContain(`queen/claim.wasm sha256 ${sha('queen/claim.wasm')}`) + }) + + it('imports nothing and exports the card and what it imports', () => { + const module = new WebAssembly.Module( + readFileSync(join(DEFAULT_SPECS_ROOT, CLAIM_CARD)), + ) + expect(WebAssembly.Module.imports(module)).toEqual([]) + const names = WebAssembly.Module.exports(module).map((e) => e.name) + for (const fn of [ + 'claim_of', + 'claim_of_step', + 'criteria_read_as', + 'escalation_kind_of', + 'escalation_kind', + 'next_step', + 'wants_backfill', + ]) + expect(names).toContain(fn) + }) +}) + +// Every assert in claim.t27's test blocks, read out of the vendored spec and +// run against the wasm. A wasm built from another card, or a spec edited +// without a rebuild, fails here. The grammar is the one those asserts use: +// calls, constants, integers, true/false, + and -. +describe('the generated card answers its own spec', () => { + const spec = readFileSync(join(DEFAULT_SPECS_ROOT, 'queen/claim.t27'), 'utf8') + const wasm = loadCardWasm(CLAIM_CARD) + const consts = card as unknown as Record + + function evaluate(src: string): number { + const tokens = src.match(/[A-Za-z_]\w*|\d+|==|[(),+-]/g) ?? [] + let at = 0 + const take = () => tokens[at++] + const term = (): number => { + const t = take() + if (t === undefined) throw new Error(`ran out in ${src}`) + if (/^\d+$/.test(t)) return Number(t) + if (t === 'true') return 1 + if (t === 'false') return 0 + if (tokens[at] === '(') { + take() + const args: number[] = [] + while (tokens[at] !== ')') { + args.push(expr()) + if (tokens[at] === ',') take() + } + take() + return wasm.call(t, ...args) + } + if (!(t in consts)) throw new Error(`unknown name ${t} in ${src}`) + return consts[t] + } + const expr = (): number => { + let v = term() + while (tokens[at] === '+' || tokens[at] === '-') + v = take() === '+' ? v + term() : v - term() + return v + } + const left = expr() + if (take() !== '==') throw new Error(`not an equality: ${src}`) + const right = expr() + if (at !== tokens.length) throw new Error(`trailing tokens in ${src}`) + return left === right ? 1 : 0 + } + + const blocks = [...spec.matchAll(/^test (\w+) \{\n([\s\S]*?)^\}/gm)] + it('has test blocks to read', () => { + expect(blocks.length).toBe(11) + }) + for (const [, name, body] of blocks) { + it(name, () => { + const asserts = body + .split('\n') + .map((line) => line.replace(/\/\/.*$/, '').trim()) + .filter((line) => line.startsWith('assert ')) + .map((line) => line.slice('assert '.length).replace(/;$/, '')) + expect(asserts.length).toBeGreaterThan(0) + for (const a of asserts) expect([a, evaluate(a)]).toEqual([a, 1]) + }) + } +}) + +describe('the card answers what the hand-written rule answered', () => { + it('on every finished row of the grid', () => { + expect(GRID_SIZE).toBe(2_211_840) + expect(cardAnswers.length).toBe(GRID_SIZE) + expect(disagreements(handWrittenStateOfDispatch)).toEqual([]) + }) + + it('reaches every claim, so the grid is not one-sided', () => { + const seen = new Set(cardAnswers) + expect([...seen].sort()).toEqual([1, 2, 3, 4, 5]) + }) + + it('a bee that has not ended claims, whatever its row says', () => { + for (const word of WORDS) + for (const idleMs of IDLE_MS) + for (const releases of RELEASES) { + const lease = { idleMs, releases, sendBacks: 3, criteria: 0 } + expect(stateOfDispatch(false, word, lease)).toBe('running') + expect(handWrittenStateOfDispatch(false, word, lease)).toBe('running') + } + }) + + it('reads a dispatch row the same way for the board', () => { + const now = Date.parse('2026-10-10T20:00:00Z') + for (const row of [ + { finished_at: null, review_state: 'accept' }, + { finished_at: '2026-10-10T19:30:00Z', review_state: 'empty' }, + { finished_at: '2026-10-10T19:29:59Z', review_state: 'empty' }, + { + finished_at: '2026-10-10T18:00:00Z', + review_state: 'sendBack', + send_backs: 2, + ceiling_releases: 1, + }, + { + finished_at: '2026-10-10T17:59:00Z', + review_state: 'escalate', + criteria: [], + }, + { finished_at: 'not a date', review_state: 'wait' }, + ]) { + const ms = Date.parse(String(row.finished_at)) + const want = handWrittenStateOfDispatch( + row.finished_at != null, + row.review_state, + { + idleMs: Number.isFinite(ms) ? Math.max(0, now - ms) : 0, + sendBacks: Number(row.send_backs ?? 0) || 0, + releases: Number(row.ceiling_releases ?? 0) || 0, + criteria: Array.isArray(row.criteria) + ? row.criteria.length + : undefined, + freeAttempts: 0, + reviewerMisses: 0, + }, + ) + expect(dispatchRowState(row, now)).toBe(want) + } + }) +}) + +// The one check in front of the card: TRIOS_QUEEN_RELEASE_DEAD_CLAIMS +// (holds.t27 send_back_claim, #1893). It frees a send-back under the ceiling +// at once and asks nothing else. With the flag on, every row of the grid must +// still answer what the hand-written rule answered, except exactly the finished +// send-backs under their ceiling, which are `failed` whatever their clock. +describe('the dead-claims check in front of the card', () => { + const ceilingOf = (l: Lease) => l.ceiling ?? 2 + const under = (word: unknown, l: Lease) => + String(word ?? '') === 'sendBack' && (l.sendBacks ?? 0) < ceilingOf(l) + + it('with the flag on, changes exactly the send-backs under the ceiling', () => { + const diverged: string[] = [] + let freed = 0 + let i = 0 + for (const [word, lease] of finishedGrid()) { + const want = under(word, lease) + ? 'failed' + : handWrittenStateOfDispatch(true, word, lease) + if (under(word, lease)) freed++ + const got = stateOfDispatch(true, word, { + ...lease, + releaseDeadClaims: true, + }) + if (got !== want && diverged.length < 3) + diverged.push( + `${JSON.stringify(word)} ${JSON.stringify(lease)}: ${got} not ${want}`, + ) + i++ + } + expect(i).toBe(GRID_SIZE) + expect(freed).toBeGreaterThan(0) + expect(diverged).toEqual([]) + }) + + it('with the flag off, frees nothing the card did not free', () => { + expect( + stateOfDispatch(true, 'sendBack', { + idleMs: 0, + sendBacks: 0, + releaseDeadClaims: false, + }), + ).toBe('rejected') + expect( + stateOfDispatch(true, 'sendBack', { + idleMs: 60 * MINUTE, + sendBacks: 0, + releaseDeadClaims: false, + }), + ).toBe('failed') + }) + + it('frees a send-back at once, and only under the ceiling', () => { + const on = (sendBacks: number, ceiling?: number, idleMs = 0) => + stateOfDispatch(true, 'sendBack', { + idleMs, + sendBacks, + ceiling, + releaseDeadClaims: true, + }) + expect(on(0)).toBe('failed') + expect(on(1)).toBe('failed') + expect(on(2)).toBe('rejected') + expect(on(3)).toBe('rejected') + expect(on(2, 3)).toBe('failed') + expect(on(0, 0)).toBe('rejected') + }) + + it("asks only a finished send-back: a running bee still claims, other words are the card's", () => { + for (const word of WORDS) { + expect( + stateOfDispatch(false, word, { sendBacks: 0, releaseDeadClaims: true }), + ).toBe('running') + if (String(word ?? '') === 'sendBack') continue + expect( + stateOfDispatch(true, word, { + idleMs: 0, + sendBacks: 0, + releaseDeadClaims: true, + }), + ).toBe( + stateOfDispatch(true, word, { + idleMs: 0, + sendBacks: 0, + releaseDeadClaims: false, + }), + ) + } + }) + + it('reads the environment when the caller names no flag', () => { + const was = process.env.TRIOS_QUEEN_RELEASE_DEAD_CLAIMS + try { + process.env.TRIOS_QUEEN_RELEASE_DEAD_CLAIMS = 'on' + expect(stateOfDispatch(true, 'sendBack', { sendBacks: 0 })).toBe('failed') + process.env.TRIOS_QUEEN_RELEASE_DEAD_CLAIMS = 'off' + expect(stateOfDispatch(true, 'sendBack', { sendBacks: 0 })).toBe( + 'rejected', + ) + } finally { + if (was === undefined) delete process.env.TRIOS_QUEEN_RELEASE_DEAD_CLAIMS + else process.env.TRIOS_QUEEN_RELEASE_DEAD_CLAIMS = was + } + }) +}) + +// A grid that agrees could be blind. Each mutant is the reference with one +// thing moved, and the grid must find a row where it no longer agrees. +describe('the grid notices a reference that drifts', () => { + const withInputs = + (wrap: (word: unknown, lease: Lease) => [unknown, Lease]): Rule => + (finished, word, lease) => + handWrittenStateOfDispatch(finished, ...wrap(word, lease)) + + const moved = (set: () => void, reset: () => void): Rule => { + return (finished, word, lease) => { + set() + try { + return handWrittenStateOfDispatch(finished, word, lease) + } finally { + reset() + } + } + } + + const floors: Array<[string, (d: number) => void]> = [ + ['empty floor', (d) => (EMPTY_ATTEMPT_FLOOR_MS += d)], + ['send-back floor', (d) => (SEND_BACK_IDLE_FLOOR_MS += d)], + ['frozen-wait floor', (d) => (WAIT_FROZEN_FLOOR_MS += d)], + ] + for (const [name, shift] of floors) + for (const d of [-MINUTE, MINUTE]) + it(`${name} moved by ${d / MINUTE} minute`, () => { + expect( + disagreements( + moved( + () => shift(d), + () => shift(-d), + ), + 1, + ).length, + ).toBe(1) + }) + + for (const d of [-1, 1]) + it(`the default send-back ceiling moved by ${d}`, () => { + expect( + disagreements( + withInputs((w, l) => [w, { ...l, ceiling: l.ceiling ?? 2 + d }]), + 1, + ).length, + ).toBe(1) + }) + + it('unread criteria read as none', () => { + expect( + disagreements( + withInputs((w, l) => [w, { ...l, criteria: l.criteria ?? 0 }]), + 1, + ).length, + ).toBe(1) + }) + + it("the valve's ceiling hour moved by a minute", () => { + expect( + disagreements( + moved( + () => { + nextStep = (kind, idle, releases) => + valve.nextStep( + kind, + kind === KIND_SEND_BACK_CEILING ? idle - 1 : idle, + releases, + ) + }, + () => { + nextStep = valve.nextStep + }, + ), + 1, + ).length, + ).toBe(1) + }) + + for (const word of WORDS.filter( + (w) => verdictCode(w) !== card.V_OTHER && w !== null && w !== undefined, + )) + it(`the boundary forgets the word ${JSON.stringify(word)}`, () => { + expect( + disagreements( + withInputs((w, l) => [w === word ? 'unheard-of' : w, l]), + 1, + ).length, + ).toBe(1) + }) +}) + +// The dead-letter and reviewer-miss ceilings choose the escalation's KIND, +// and today the valve gives those kinds the step of an unrecorded one, so the +// claim cannot show them (tri mutate found exactly that). They are pinned +// where they act: the card's escalation_kind_of against the valve's hand +// mirror with the ceilings the reference read. +describe('the escalation kind', () => { + const wasm = loadCardWasm(CLAIM_CARD) + const handKind = ( + sendBacks: number, + ceiling: number, + criteria: number | undefined, + free: number, + misses: number, + ) => + escalationKind( + criteria ?? 1, + sendBacks >= ceiling, + free >= FREE_ATTEMPT_CEILING, + misses >= REVIEWER_MISS_CEILING, + ) + const cardKind = ( + sendBacks: number, + ceiling: number, + criteria: number | undefined, + free: number, + misses: number, + ) => + wasm.call( + 'escalation_kind_of', + sendBacks, + ceiling, + criteria === undefined ? 0 : 1, + criteria ?? 0, + free, + misses, + ) + const rows: Array<[number, number, number | undefined, number, number]> = [] + for (const sendBacks of [0, 1, 2, 3]) + for (const ceiling of [1, 2, 3]) + for (const criteria of [undefined, 0, 1, 2]) + for (const free of [0, 1, 2, 3, 4]) + for (const misses of [0, 1, 2, 3, 4]) + rows.push([sendBacks, ceiling, criteria, free, misses]) + const drift = () => + rows.filter((r) => handKind(...r) !== cardKind(...r)).length + + it('is the same from the counters on every row', () => { + expect(rows.length).toBe(1200) + expect(drift()).toBe(0) + }) + + for (const [name, set] of [ + ['free-attempt', (d: number) => (FREE_ATTEMPT_CEILING += d)], + ['reviewer-miss', (d: number) => (REVIEWER_MISS_CEILING += d)], + ] as const) + for (const d of [-1, 1]) + it(`notices the ${name} ceiling moved by ${d}`, () => { + set(d) + try { + expect(drift()).toBeGreaterThan(0) + } finally { + set(-d) + } + }) +}) + +// The valve's functions are still mirrored by hand in queen-review-valve.ts +// for its other callers. The card carries the spec's own copies, compiled in, +// so the mirror can now be checked against them on its whole input space. +describe("the review valve's hand mirror is the spec's", () => { + const wasm = loadCardWasm(CLAIM_CARD) + it('escalation_kind', () => { + for (let criteria = 0; criteria <= 4; criteria++) + for (let bits = 0; bits < 8; bits++) { + const [a, b, c] = [bits & 1, bits & 2, bits & 4].map(Boolean) + expect(valve.escalationKind(criteria, a, b, c)).toBe( + wasm.call('escalation_kind', criteria, +a, +b, +c), + ) + } + }) + it('next_step and wants_backfill', () => { + const idles = [ + ...Array.from({ length: 182 }, (_, i) => i), + 2879, + 2880, + 2881, + 1_000_000, + ] + for (let kind = 0; kind <= 9; kind++) { + expect(+valve.wantsBackfill(kind)).toBe(wasm.call('wants_backfill', kind)) + for (const idle of idles) + for (let releases = 0; releases <= 3; releases++) + expect(valve.nextStep(kind, idle, releases)).toBe( + wasm.call('next_step', kind, idle, releases), + ) + } + }) +}) + +describe('the boundary', () => { + it('reads each word the card has a rule for, and nothing else', () => { + expect(verdictCode(undefined)).toBe(card.V_NONE) + expect(verdictCode(null)).toBe(card.V_NONE) + expect(verdictCode('')).toBe(card.V_NONE) + expect(verdictCode('accept')).toBe(card.V_ACCEPT) + expect(verdictCode('sendBack')).toBe(card.V_SEND_BACK) + expect(verdictCode('escalate')).toBe(card.V_ESCALATE) + expect(verdictCode('wait')).toBe(card.V_WAIT) + expect(verdictCode('failed')).toBe(card.V_FAILED) + expect(verdictCode('cancelled')).toBe(card.V_CANCELLED) + expect(verdictCode('empty')).toBe(card.V_EMPTY) + expect(verdictCode('stale-contract')).toBe(card.V_STALE_CONTRACT) + expect(verdictCode(OBSOLETE_STATE)).toBe(card.V_OBSOLETE) + for (const other of ['Accept', 'SENDBACK', '__proto__', 'constructor', 0]) + expect(verdictCode(other)).toBe(card.V_OTHER) + }) + + it('names every claim the card can answer', () => { + const words = new Set() + for (let code = 0; code < card.CLAIM_STATES; code++) + words.add(claimWord(code)) + expect(words.size).toBe(card.CLAIM_STATES) + }) + + it('holds a code that is no claim, and says so once per code', () => { + const error = spyOn(logger, 'error').mockImplementation(() => {}) + try { + expect(claimWord(card.CLAIM_STATES)).toBe('awaitingReview') + expect(claimWord(card.CLAIM_STATES)).toBe('awaitingReview') + expect(claimWord(255)).toBe('awaitingReview') + expect(error).toHaveBeenCalledTimes(2) + expect(error.mock.calls[0][1]).toEqual({ + card: CLAIM_CARD, + code: card.CLAIM_STATES, + held: 'awaitingReview', + }) + } finally { + error.mockRestore() + } + }) + + it('reads the clock in whole minutes, an unreadable one as 0', () => { + expect(idleMinutes(undefined)).toBe(0) + expect(idleMinutes(Number.NaN)).toBe(0) + expect(idleMinutes(-MINUTE)).toBe(0) + expect(idleMinutes(MINUTE - 1)).toBe(0) + expect(idleMinutes(MINUTE)).toBe(1) + expect(idleMinutes(Number.POSITIVE_INFINITY)).toBe(0xffff_ffff) + }) + + it('is what stateOfDispatch calls', () => { + expect(stateOfDispatch(true, 'sendBack', { idleMs: 60 * MINUTE })).toBe( + claimOf(true, 'sendBack', { idleMs: 60 * MINUTE }), + ) + }) +}) diff --git a/trios/agent-server/specs/PIN b/trios/agent-server/specs/PIN index 223abfc7a1..6f413818ca 100644 --- a/trios/agent-server/specs/PIN +++ b/trios/agent-server/specs/PIN @@ -123,3 +123,7 @@ derived_data: ci/derived_data.wasm sha256 0242878bc6a4828f453356881c1ab571f38189 worktree_keep card: gHashTag/t27 specs/queen/worktree_keep.t27 (gHashTag/trios#2005), byte-identical to the canonical card at commit 1530131bfacbdad8eb1ea09a8449dc3823fbed37, pushed to the owner's fork on branch codex/bee-safe-restart-20261014 (push completed; pull request head c7f281a6be5970c525d2564a2f5200f610b9efbd, a commit-message amend that adds "Closes #9552", force-pushed with lease over the previous head 291e131 on the owner's own branch; file bytes and the spec blob are unchanged; the spec's git blob there is 9cf42f0ea88cfb7669d272855752db39eda10fa8, a git object id, not the file sha256 2684225b... below) and proposed as gHashTag/t27 pull request #9553 (https://github.com/gHashTag/t27/pull/9553); the upstream issue is gHashTag/t27#9552 (OPEN, verified 2026-10-14 23:22 UTC); the card is NOT on t27 master until that pull request merges, so no master blob is recorded here; gHashTag/trios#2005 is an ISSUE, not a pull request; standalone, imports nothing; worktree_keep.wasm = t27c gen-rust of the card (t27-core/worktree_keep.gen.rs) + the forward-only export shim t27-core/worktree_keep_wasm.rs + rustc --target wasm32-unknown-unknown --crate-type cdylib --edition 2021 -C panic=abort -O, run by `bun run card:worktree-keep` (the recipe, not a claim of reproducibility: only one build is recorded), exports every fn of the card: worktree_verdict,boot_releases,retry_verdict,claims_again_at_once,reap_verdict,shape_count,code_reportable; constants: t27c gen-ts > apps/server/src/api/services/queen-worktree-keep-card.gen.ts; tests of the card are executed against the wasm by apps/server/tests/api/worktree-keep-card.test.ts; NATIVE SEAL (replaces the earlier blocked run on Zig 0.15.2): `t27c seal specs/queen/worktree_keep.t27 --save` was run on a Linux runner (r1) with t27c 0.5.2, reference /usr/local/share/t27c/REF d06eaedc9fb59cda13c3529e1c6ed5bc510f3e08 and Zig 0.16.0, on the source carried byte-exact (base64) from the canonical commit 1530131bfacbdad8eb1ea09a8449dc3823fbed37 into a private /tmp directory, without FORCE: 11 of 11 tests pass, failed 0, vacuous 0, and all four generated-output hashes match the earlier local generation (spec 2684225b... unchanged; zig 8bea97..., rust 127109..., c 4eea8a..., verilog f9ff25...). The raw seal JSON written by the tool is kept unedited in the t27 checkout at .trinity/seals/queen_QueenWorktreeKeep.json (not vendored here): its built_by is "unknown" and its test_runner is "missing"; the reason for those two values is UNVERIFIED, and no installed-runtime layout is claimed. Native execution of the tests passing is NOT cryptographic provenance of the compiler binary or its reference; no such claim is made. The vendored vectors file's seal metadata is updated to record the native pass (11 of 11) in place of the earlier blocked text; its 45 test_vectors have hash d949b9fa21a33767e325ee9ba13232c880d6381880cf5a57e0de885553aa15a1 (checked with t27c conformance), unchanged; t27c conformance takes a JSON file, it does not derive vectors from a .t27, and conformance/queen_worktree_keep_vectors.json (the flat conformance/ directory, as t27c validate-conformance reads it) is that JSON, vendored here byte-identical as queen/worktree_keep_vectors.json (vectors authored by hand from the card's own tests, each executed against the wasm by the Bun test; the file carries seal null and says why) worktree_keep: queen/worktree_keep.t27 sha256 2684225bc4e87d79b5d5846324d6171620f681c44ed9ccf8cf3957abdbbeae89 worktree_keep: queen/worktree_keep.wasm sha256 c5374faa834a7fe49fe31bc934bbc3beae7b3cf7a9b872edb10feb45284383d4 +claim card: gHashTag/t27 specs/queen/claim.t27 (t27#8765, PR t27#8768 head d4cedbc5, blob 2dd6c5c97d734a6204735db880e80dc6b01289d3; gHashTag/trios#1767), byte-identical; it imports escalation_kind and next_step (with wants_backfill), the kind and step codes, RETRY_FLOOR_MINUTES and OBSOLETE_STATE from queen/review_valve.t27 (master c31c059ef, blob 96c250bdd7307169e1e2a2076c431155796c144a, vendored byte-identical), so gen-c compiles them in; claim.wasm = t27c 0.5.2 (c31c059ef) gen-c + zig 0.16.0 cc --target=wasm32-wasi -Oz -DNDEBUG -mexec-model=reactor, exports every fn of the card and the ones it imports: claim_of,claim_of_step,criteria_read_as,escalation_kind_of,escalation_kind,next_step,wants_backfill (reproducible: two builds, one hash); constants: t27c gen-ts > apps/server/src/api/services/queen-claim-card.gen.ts +claim: queen/claim.t27 sha256 f031594c8c0c5078a126ad4ed3ec0e3ef6b1179658572878fa24361c4b17cec9 +claim: queen/review_valve.t27 sha256 5177806d264aa3611b5187a89b929c2231cbf7f95eb3cb78d246d913b658d8a6 +claim: queen/claim.wasm sha256 08fc2b32d46742638c00d20fe1e64dd81289080dcfc8fa662972acc411d4884a diff --git a/trios/agent-server/specs/queen/claim.t27 b/trios/agent-server/specs/queen/claim.t27 new file mode 100644 index 0000000000..2dd6c5c97d --- /dev/null +++ b/trios/agent-server/specs/queen/claim.t27 @@ -0,0 +1,291 @@ +// SPDX-License-Identifier: Apache-2.0 +; specs/queen/claim.t27 -- what a dispatch row still claims: the round's stateOfDispatch, from one source +; gHashTag/t27#8765; runtime gHashTag/trios#1767 (branch queen). +; Every round, for every queen_dispatch row, the trios supervisor asks one question: does this row still +; claim its issue, and is the issue free for another bee? The answer is one of six words -- running, +; accepted, rejected, awaitingReview, failed, cancelled -- and the round, the board (dispatchRowState) and +; the files a row holds all read it. Until this card it was written by hand: stateOfDispatch in +; trios/agent-server/apps/server/src/api/services/queen-tick.ts (gHashTag/trios queen @ 3654fdd2, lines +; 791-912), and two specs describe older versions of it that no longer agree with the code that runs: +; specs/queen/dispatch.t27 claim_of -- the audit at supervisor pin 879799a8 (2026-10-05): escalate holds +; with no clock, obsolete is not a state, criteria are not read; +; trios rings/T27-02/tick_core.t27 -- state_of_dispatch, four states, written 2026-09-04. +; This card is the rule as it runs on the queen branch. The review valve's two decisions -- the kind of an +; escalation and the next step after a clock -- are imported from review_valve.t27, not restated, so the +; valve stays the one home of "no person in the loop". The supervisor calls claim_of as generated wasm +; (t27c gen-c + zig cc wasm32-wasi), the way it calls holds.t27. +; The words are the boundary's: review_state is a text column, the supervisor hands this card one of the V_ +; codes below, and maps the CL_ code it gets back to its word. Recognising a word is text work and stays +; there; everything decided about the word is here. +; Clocks are whole minutes since finished_at, the one column nothing rewrites. The supervisor counts +; milliseconds; every floor here is a whole number of minutes, so idle_ms >= floor_ms holds exactly when +; floor(idle_ms / 60000) >= floor_minutes, and a clock that cannot be read (or reads negative) is 0: it +; keeps the hold, as today. +; ASCII only (L3). +; phi^2 + 1/phi^2 = 3 | TRINITY + +module QueenClaim; + +use queen::review_valve::escalation_kind; +use queen::review_valve::next_step; +use queen::review_valve::KIND_UNRECORDED; +use queen::review_valve::KIND_NO_CRITERIA; +use queen::review_valve::KIND_SEND_BACK_CEILING; +use queen::review_valve::KIND_DEAD_LETTER; +use queen::review_valve::KIND_REVIEWER_GAVE_UP; +use queen::review_valve::STEP_HOLD; +use queen::review_valve::STEP_RELEASE; +use queen::review_valve::STEP_BACKFILL; +use queen::review_valve::STEP_CLOSE; +use queen::review_valve::RETRY_FLOOR_MINUTES; +use queen::review_valve::OBSOLETE_STATE; + +/// The empty-attempt floor is the valve's retry floor (queen-tick.ts EMPTY_ATTEMPT_FLOOR_MS = +/// RETRY_FLOOR_MINUTES), imported above with the valve's kind and step codes: nothing of the valve is +/// restated here. + +/// What a dispatch row claims. The order is the supervisor's return type. +/// CL_RUNNING: the bee has not ended: its issue and its files are held. +/// CL_ACCEPTED: the Queen said yes: the work is done and holds nothing. +/// CL_REJECTED: sent back, or an empty attempt inside its floor: the same work is expected back. +/// CL_AWAITING_REVIEW: finished and not decided, or held by the valve while its clock runs. +/// CL_FAILED: released: the issue goes back to the swarm. +/// CL_CANCELLED: closed by the review valve as obsolete: free, and not taken again while the row stands. +pub const CL_RUNNING : u8 = 0; +pub const CL_ACCEPTED : u8 = 1; +pub const CL_REJECTED : u8 = 2; +pub const CL_AWAITING_REVIEW : u8 = 3; +pub const CL_FAILED : u8 = 4; +pub const CL_CANCELLED : u8 = 5; +pub const CLAIM_STATES : u8 = 6; + +/// The review_state column, as the boundary read it. +/// V_NONE: no verdict yet (the column is null or empty). V_ACCEPT 'accept'. V_SEND_BACK 'sendBack'. +/// V_ESCALATE 'escalate'. V_WAIT 'wait'. V_FAILED 'failed'. V_CANCELLED 'cancelled'. V_EMPTY 'empty'. +/// V_STALE_CONTRACT 'stale-contract'. V_OBSOLETE: OBSOLETE_STATE, the word review_valve.t27 closes a +/// row with, imported above so the boundary reads it from this card's generated constants. +/// V_OTHER: any other word. It is held for review with no clock: a word nobody wrote a rule for +/// releases nothing. A code past VERDICT_CODES is read the same way. +pub const V_NONE : u8 = 0; +pub const V_ACCEPT : u8 = 1; +pub const V_SEND_BACK : u8 = 2; +pub const V_ESCALATE : u8 = 3; +pub const V_WAIT : u8 = 4; +pub const V_FAILED : u8 = 5; +pub const V_CANCELLED : u8 = 6; +pub const V_EMPTY : u8 = 7; +pub const V_STALE_CONTRACT : u8 = 8; +pub const V_OBSOLETE : u8 = 9; +pub const V_OTHER : u8 = 10; +pub const VERDICT_CODES : u8 = 11; + +/// A send-back under its ceiling goes back to the swarm after an hour idle (queen-tick.ts +/// SEND_BACK_IDLE_FLOOR_MS): the bee it waits for was usually dispatched again within a round. +pub const SEND_BACK_FLOOR_MINUTES : u32 = 60; + +/// A wait, or no verdict at all, under the ceiling is released after six hours (WAIT_FROZEN_FLOOR_MS). +/// A finished bee's transcript never changes, so re-reading it is not re-judging it; six and not one, +/// because a transcript slow to flush can still parse on a later sweep. +pub const WAIT_FROZEN_FLOOR_MINUTES : u32 = 360; + +/// The send-back ceiling a row is read against when the caller names none (stateOfDispatch `?? 2`). +pub const DEFAULT_SEND_BACK_CEILING : u32 = 2; + +/// Free attempts (nothing judgeable) after which an escalation reads as a dead letter +/// (queen-tick.ts FREE_ATTEMPT_CEILING). +pub const FREE_ATTEMPT_CEILING : u32 = 3; + +/// Rounds a bought review may fail to arrive before its escalation reads as the reviewer giving up +/// (queen-tick.ts REVIEWER_MISS_CEILING). +pub const REVIEWER_MISS_CEILING : u32 = 3; + +/// The criteria count of a row that did not select its criteria: one, never none. Read as none, the +/// valve would close the row for a missing contract nobody looked for. +pub const UNREAD_CRITERIA : u32 = 1; + +/// The claim a valve step leaves: RELEASE frees the row, CLOSE cancels it, HOLD and BACKFILL keep the +/// claim the row holds while it waits (`held`). +pub fn claim_of_step(step: u8, held: u8) -> u8 { + if (step == STEP_RELEASE) { return CL_FAILED; } + if (step == STEP_CLOSE) { return CL_CANCELLED; } + return held; +} + +/// The criteria count an escalation's kind is read from. +pub fn criteria_read_as(criteria_read: bool, criteria: u32) -> u32 { + if (criteria_read) { return criteria; } + return UNREAD_CRITERIA; +} + +/// The kind an escalated row is read as, from its own counters, in review_valve.t27's order. Today the +/// valve gives a dead letter, a reviewer that gave up and an unrecorded reason the same step, so the claim +/// does not change between them; the kind is still the valve's input, and its own test pins each ceiling. +pub fn escalation_kind_of(send_backs: u32, ceiling: u32, criteria_read: bool, criteria: u32, free_attempts: u32, reviewer_misses: u32) -> u8 { + return escalation_kind(criteria_read_as(criteria_read, criteria), send_backs >= ceiling, free_attempts >= FREE_ATTEMPT_CEILING, reviewer_misses >= REVIEWER_MISS_CEILING); +} + +/// What the row claims. finished: the bee ended (finished_at is set). verdict: a V_ code. +/// idle_minutes: whole minutes since finished_at. send_backs, releases, free_attempts, reviewer_misses: +/// the row's counters (send_backs, ceiling_releases, free_attempts, reviewer_misses). ceiling: the +/// send-back ceiling (DEFAULT_SEND_BACK_CEILING unless the caller names one). criteria_read: the row +/// selected its criteria; criteria: how many. +pub fn claim_of(finished: bool, verdict: u8, idle_minutes: u32, send_backs: u32, ceiling: u32, releases: u32, criteria_read: bool, criteria: u32, free_attempts: u32, reviewer_misses: u32) -> u8 { + if (finished == false) { return CL_RUNNING; } + if (verdict == V_ACCEPT) { return CL_ACCEPTED; } + if (verdict == V_FAILED or verdict == V_CANCELLED) { return CL_FAILED; } + if (verdict == V_EMPTY) { + if (idle_minutes >= RETRY_FLOOR_MINUTES) { return CL_FAILED; } + return CL_REJECTED; + } + if (verdict == V_STALE_CONTRACT) { return CL_FAILED; } + if (verdict == V_OBSOLETE) { return CL_CANCELLED; } + if (verdict == V_SEND_BACK) { + if (idle_minutes >= SEND_BACK_FLOOR_MINUTES and send_backs < ceiling) { return CL_FAILED; } + return claim_of_step(next_step(KIND_SEND_BACK_CEILING, idle_minutes, releases), CL_REJECTED); + } + if (verdict == V_ESCALATE) { + var kind : u8 = escalation_kind_of(send_backs, ceiling, criteria_read, criteria, free_attempts, reviewer_misses); + return claim_of_step(next_step(kind, idle_minutes, releases), CL_AWAITING_REVIEW); + } + if (verdict == V_NONE or verdict == V_WAIT) { + if (idle_minutes >= WAIT_FROZEN_FLOOR_MINUTES and send_backs < ceiling) { return CL_FAILED; } + } + return CL_AWAITING_REVIEW; +} + +test a_running_bee_claims_whatever_its_column_says { + assert claim_of(false, V_ACCEPT, 999999, 9, 2, 9, true, 0, 9, 9) == CL_RUNNING; + assert claim_of(false, V_FAILED, 0, 0, 2, 0, true, 1, 0, 0) == CL_RUNNING; + assert claim_of(false, V_OBSOLETE, 0, 0, 2, 0, true, 1, 0, 0) == CL_RUNNING; + assert claim_of(false, V_NONE, 0, 0, 2, 0, false, 0, 0, 0) == CL_RUNNING; +} + +test a_decided_word_ends_the_claim_at_once { + assert claim_of(true, V_ACCEPT, 0, 0, 2, 0, true, 1, 0, 0) == CL_ACCEPTED; + assert claim_of(true, V_FAILED, 0, 9, 2, 0, true, 1, 0, 0) == CL_FAILED; + assert claim_of(true, V_CANCELLED, 0, 0, 2, 0, true, 1, 0, 0) == CL_FAILED; + assert claim_of(true, V_STALE_CONTRACT, 0, 0, 2, 0, true, 1, 0, 0) == CL_FAILED; + assert claim_of(true, V_OBSOLETE, 0, 0, 2, 0, true, 1, 0, 0) == CL_CANCELLED; +} + +test an_empty_attempt_is_held_until_its_floor { + assert claim_of(true, V_EMPTY, 0, 0, 2, 0, true, 1, 0, 0) == CL_REJECTED; + assert claim_of(true, V_EMPTY, RETRY_FLOOR_MINUTES - 1, 0, 2, 0, true, 1, 0, 0) == CL_REJECTED; + assert claim_of(true, V_EMPTY, RETRY_FLOOR_MINUTES, 0, 2, 0, true, 1, 0, 0) == CL_FAILED; + // the floor is the clock alone: spent counters do not hold an empty attempt longer + assert claim_of(true, V_EMPTY, RETRY_FLOOR_MINUTES, 9, 2, 9, true, 1, 9, 9) == CL_FAILED; +} + +test a_send_back_under_its_ceiling_goes_back_after_the_hour { + assert claim_of(true, V_SEND_BACK, SEND_BACK_FLOOR_MINUTES - 1, 1, 2, 0, true, 1, 0, 0) == CL_REJECTED; + assert claim_of(true, V_SEND_BACK, SEND_BACK_FLOOR_MINUTES, 1, 2, 0, true, 1, 0, 0) == CL_FAILED; + assert claim_of(true, V_SEND_BACK, SEND_BACK_FLOOR_MINUTES, 0, 2, 0, true, 1, 0, 0) == CL_FAILED; + // its own clock is asked first: from the hour on, a send-back under the ceiling is released even when + // the row's ceiling was already handed back once; inside the hour the valve answers, and a spent + // release closes it (the supervisor as it runs at 3654fdd2; gHashTag/trios#1767 records this) + assert claim_of(true, V_SEND_BACK, SEND_BACK_FLOOR_MINUTES, 1, 2, 1, true, 1, 0, 0) == CL_FAILED; + assert claim_of(true, V_SEND_BACK, SEND_BACK_FLOOR_MINUTES - 1, 1, 2, 1, true, 1, 0, 0) == CL_CANCELLED; +} + +test a_spent_ceiling_is_the_valves { + // at the ceiling the hour floor is the valve's (KIND_SEND_BACK_CEILING), once + assert claim_of(true, V_SEND_BACK, 59, 2, 2, 0, true, 1, 0, 0) == CL_REJECTED; + assert claim_of(true, V_SEND_BACK, 60, 2, 2, 0, true, 1, 0, 0) == CL_FAILED; + assert claim_of(true, V_SEND_BACK, 999999, 2, 2, 1, true, 1, 0, 0) == CL_CANCELLED; + // a ceiling handed back once closes the next send-back at once, inside the hour as well + assert claim_of(true, V_SEND_BACK, 0, 0, 2, 1, true, 1, 0, 0) == CL_CANCELLED; + // a caller's own ceiling is the ceiling + assert claim_of(true, V_SEND_BACK, 60, 2, 3, 0, true, 1, 0, 0) == CL_FAILED; + assert claim_of(true, V_SEND_BACK, 0, 3, 3, 0, true, 1, 0, 0) == CL_REJECTED; +} + +test an_escalation_has_a_kind_and_a_clock { + // unrecorded: the retry floor, then once + assert claim_of(true, V_ESCALATE, 29, 0, 2, 0, true, 2, 0, 0) == CL_AWAITING_REVIEW; + assert claim_of(true, V_ESCALATE, 30, 0, 2, 0, true, 2, 0, 0) == CL_FAILED; + assert claim_of(true, V_ESCALATE, 30, 0, 2, 1, true, 2, 0, 0) == CL_CANCELLED; + // no criteria: held for the backfill window, then closed; releases do not apply + assert claim_of(true, V_ESCALATE, 119, 0, 2, 1, true, 0, 0, 0) == CL_AWAITING_REVIEW; + assert claim_of(true, V_ESCALATE, 120, 0, 2, 0, true, 0, 0, 0) == CL_CANCELLED; + // a spent ceiling escalates on the hour floor + assert claim_of(true, V_ESCALATE, 59, 2, 2, 0, true, 2, 0, 0) == CL_AWAITING_REVIEW; + assert claim_of(true, V_ESCALATE, 60, 2, 2, 0, true, 2, 0, 0) == CL_FAILED; + // dead letter and a reviewer that gave up: the retry floor + assert claim_of(true, V_ESCALATE, 30, 0, 2, 0, true, 2, FREE_ATTEMPT_CEILING, 0) == CL_FAILED; + assert claim_of(true, V_ESCALATE, 30, 0, 2, 0, true, 2, 0, REVIEWER_MISS_CEILING) == CL_FAILED; +} + +test the_kind_is_read_from_the_counters_at_their_ceilings { + assert escalation_kind_of(0, 2, true, 2, 0, 0) == KIND_UNRECORDED; + assert escalation_kind_of(0, 2, true, 2, FREE_ATTEMPT_CEILING - 1, REVIEWER_MISS_CEILING - 1) == KIND_UNRECORDED; + assert escalation_kind_of(0, 2, true, 2, FREE_ATTEMPT_CEILING, 0) == KIND_DEAD_LETTER; + assert escalation_kind_of(0, 2, true, 2, 0, REVIEWER_MISS_CEILING) == KIND_REVIEWER_GAVE_UP; + assert escalation_kind_of(1, 2, true, 2, 9, 9) == KIND_DEAD_LETTER; + assert escalation_kind_of(2, 2, true, 2, 9, 9) == KIND_SEND_BACK_CEILING; + assert escalation_kind_of(2, 2, true, 0, 9, 9) == KIND_NO_CRITERIA; + assert escalation_kind_of(2, 2, false, 0, 9, 9) == KIND_SEND_BACK_CEILING; +} + +test criteria_that_were_not_read_are_not_none { + // unread criteria are one criterion: the row waits the retry floor, it is not closed as contract-less + assert criteria_read_as(false, 0) == UNREAD_CRITERIA; + assert criteria_read_as(true, 0) == 0; + assert claim_of(true, V_ESCALATE, 120, 0, 2, 0, false, 0, 0, 0) == CL_FAILED; + assert claim_of(true, V_ESCALATE, 120, 0, 2, 0, true, 0, 0, 0) == CL_CANCELLED; +} + +test a_wait_is_released_after_six_hours_under_the_ceiling { + assert claim_of(true, V_WAIT, 0, 0, 2, 0, true, 1, 0, 0) == CL_AWAITING_REVIEW; + assert claim_of(true, V_WAIT, WAIT_FROZEN_FLOOR_MINUTES - 1, 1, 2, 0, true, 1, 0, 0) == CL_AWAITING_REVIEW; + assert claim_of(true, V_WAIT, WAIT_FROZEN_FLOOR_MINUTES, 1, 2, 0, true, 1, 0, 0) == CL_FAILED; + assert claim_of(true, V_NONE, WAIT_FROZEN_FLOOR_MINUTES, 0, 2, 0, false, 0, 0, 0) == CL_FAILED; + // at the ceiling a wait is held: the frozen floor is for work the ceiling has not judged + assert claim_of(true, V_WAIT, 999999, 2, 2, 0, true, 1, 0, 0) == CL_AWAITING_REVIEW; +} + +test a_word_nobody_named_holds_and_releases_nothing { + assert claim_of(true, V_OTHER, 0, 0, 2, 0, true, 1, 0, 0) == CL_AWAITING_REVIEW; + assert claim_of(true, V_OTHER, 999999, 0, 2, 0, true, 1, 0, 0) == CL_AWAITING_REVIEW; + assert claim_of(true, VERDICT_CODES, 999999, 0, 2, 0, true, 1, 0, 0) == CL_AWAITING_REVIEW; + assert claim_of(true, 255, 999999, 0, 2, 0, true, 1, 0, 0) == CL_AWAITING_REVIEW; +} + +test the_valve_codes_are_the_valves { + // every valve step maps to one claim, and only RELEASE and CLOSE end the hold + assert claim_of_step(STEP_RELEASE, CL_REJECTED) == CL_FAILED; + assert claim_of_step(STEP_CLOSE, CL_REJECTED) == CL_CANCELLED; + assert claim_of_step(STEP_HOLD, CL_REJECTED) == CL_REJECTED; + assert claim_of_step(STEP_BACKFILL, CL_AWAITING_REVIEW) == CL_AWAITING_REVIEW; + // the send-back ceiling is the valve's kind, read on the valve's hour + assert next_step(KIND_SEND_BACK_CEILING, 60, 0) == STEP_RELEASE; + assert next_step(KIND_SEND_BACK_CEILING, 59, 0) == STEP_HOLD; + assert next_step(KIND_SEND_BACK_CEILING, 0, 1) == STEP_CLOSE; + assert escalation_kind(1, true, false, false) == KIND_SEND_BACK_CEILING; + // the empty floor is the valve's retry floor: an unrecorded escalation releases on the same minute + assert next_step(escalation_kind(1, false, false, false), RETRY_FLOOR_MINUTES - 1, 0) == STEP_HOLD; + assert next_step(escalation_kind(1, false, false, false), RETRY_FLOOR_MINUTES, 0) == STEP_RELEASE; +} + +invariant the_codes_never_move { + assert CL_RUNNING == 0; + assert CL_ACCEPTED == 1; + assert CL_REJECTED == 2; + assert CL_AWAITING_REVIEW == 3; + assert CL_FAILED == 4; + assert CL_CANCELLED == 5; + assert CLAIM_STATES == CL_CANCELLED + 1; + assert V_NONE == 0; + assert V_OTHER == 10; + assert VERDICT_CODES == V_OTHER + 1; +} + +invariant the_floors_rise { + // every clock here is a whole positive number of minutes, so the minute clock reads them exactly + assert RETRY_FLOOR_MINUTES > 0; + assert RETRY_FLOOR_MINUTES < SEND_BACK_FLOOR_MINUTES; + assert SEND_BACK_FLOOR_MINUTES < WAIT_FROZEN_FLOOR_MINUTES; + assert DEFAULT_SEND_BACK_CEILING > 0; + assert FREE_ATTEMPT_CEILING > 0; + assert REVIEWER_MISS_CEILING > 0; + assert UNREAD_CRITERIA > 0; +} diff --git a/trios/agent-server/specs/queen/claim.wasm b/trios/agent-server/specs/queen/claim.wasm new file mode 100755 index 0000000000..7ca185aad5 Binary files /dev/null and b/trios/agent-server/specs/queen/claim.wasm differ diff --git a/trios/agent-server/specs/queen/review_valve.t27 b/trios/agent-server/specs/queen/review_valve.t27 new file mode 100644 index 0000000000..96c250bdd7 --- /dev/null +++ b/trios/agent-server/specs/queen/review_valve.t27 @@ -0,0 +1,406 @@ +// SPDX-License-Identifier: Apache-2.0 +; specs/queen/review_valve.t27 -- what happens to work the review did not accept, with no person in the loop +; The owner's rule of 2026-10-05: no person is needed; the loop fixes and closes itself. This file is the +; one home of that rule. Runtimes read it, nobody restates it: the trios supervisor (gHashTag/BrowserOS, +; trios/agent-server queen-tick.ts and queen-ci-verdict.ts) imports the constants from `t27c gen-js` of +; this file and mirrors the functions line for line; `gen/c/queen/review_valve.c` is the executable copy. +; What it replaces. The supervisor's stateOfDispatch maps `escalate` to awaitingReview with no clock +; ("a timer is not a person"), and QueenReviewDecision escalates on: no criteria, every criterion met on +; an empty diff, the send-back ceiling, a reviewer that gave up, a dead letter (three empty attempts), +; criteria beyond what a patch shows, and an accept on criteria that were already true on master. An +; escalated dispatch holds its file boundary, so every other bee touching those files is skipped. +; Measured on the Queen's board, 2026-10-05 ~19:20Z: +; 219 cards in review, 144 of them `escalate`; 193 stuck cards in all; +; of the 193 bee branches: 125 EMPTY (no commit), 30 foreign code, 38 t27 work; +; candidates skipped: fileConflict 108, blocked 99; 25 of 70 workers active; +; GitHub: the reviewer bee (t27-bees[bot]) judges most heads REQUEST_CHANGES and then skips the head +; for ever ("this head was already judged: changes"); nothing fixes them; 98 CI runs queued, 8 running. +; The rule: every outcome that is not an accept has an automatic next step and a clock. +; empty diff, reviewer gave up, dead letter, unrecorded reason -> RELEASE as failed after a short +; floor; the issue goes back to the swarm, at most MAX_RELEASES times; an effect that gave up +; after its run limit (control.t27 DO_GIVE_UP, recorded by the runtime) takes the same road; +; no criteria, criteria beyond a patch, accepted on base truth -> BACKFILL: the criteria are rewritten +; (tools/queen/criteria_backfill.py) and the supervisor's releaseStaleContracts hands the issue to a +; fresh bee at once; if no new criteria arrive within the window -> CLOSE; +; send-back ceiling -> RELEASE once after the ceiling floor, +; then CLOSE; +; releases spent -> CLOSE as obsolete: the dispatch is +; cancelled with its reason written down, its boundary is free at once, and the issue is not taken +; again while the dispatch stays in the supervisor's 7-day window; +; reviewer bee REQUEST_CHANGES on a Queen-published pull request -> the review goes back to the Queen +; as a send-back for the same branch, at most PR_FIX_ATTEMPTS times; then the pull request is +; closed with the reason. +; a Queen-published pull request that conflicts with master -> GitHub's update-branch (master +; merged into the head, never a force-push) when the Actions queue has room; a branch that cannot +; be updated, or that conflicts again after CONFLICT_UPDATES updates, is closed and its issue goes +; back to the swarm from fresh master (one of MAX_RELEASES); see conflict_step. +; HOLD is the only state that keeps files, and every HOLD ends before the policy's own 48-hour hold +; (invariant below), so nothing here ever waits on a person. +; Not here: the review policy's own verdicts (QueenReviewDecision), the send-back and wait floors that +; already have clocks (queen-tick.ts SEND_BACK_IDLE_FLOOR_MS, WAIT_FROZEN_FLOOR_MS), merging. +; ASCII only (L3). +; phi^2 + 1/phi^2 = 3 | TRINITY + +module QueenReviewValve; + +/// Why a dispatch escalated. The supervisor records the counters, not the reason, so escalation_kind +/// below reads the kind back from the row's own columns; the last four kinds are named for a runtime +/// that records its reason (recorded_escalation_kind), and a row that cannot tell them apart is +/// UNRECORDED. The codes are mirrored by number: a new kind takes the next code, none moves. +pub const KIND_UNRECORDED : u8 = 0; +pub const KIND_NO_CRITERIA : u8 = 1; +pub const KIND_SEND_BACK_CEILING : u8 = 2; +pub const KIND_DEAD_LETTER : u8 = 3; +pub const KIND_REVIEWER_GAVE_UP : u8 = 4; +pub const KIND_EMPTY_ACCEPT : u8 = 5; +pub const KIND_BEYOND_THE_PATCH : u8 = 6; +pub const KIND_BASE_TRUTH_ALONE : u8 = 7; +/// #7092: control.t27 ended an attempt with DO_GIVE_UP -- one effect key ran EFFECT_RUN_LIMIT times +/// and each run crashed its holder before the result was written. The counters cannot show it (the +/// criteria exist, nobody sent it back, the attempts were not empty), so without the record it reads +/// UNRECORDED and the close note cannot say that the turn kept crashing its runtime. +pub const KIND_EFFECT_GAVE_UP : u8 = 8; + +/// The next step. HOLD keeps the files and waits for its clock; the other three never wait. +/// HOLD -> the supervisor reports the row as it does today (awaitingReview / rejected) +/// RELEASE -> `failed`: free in claimOnIssue, the issue is dispatched again +/// BACKFILL -> held while the criteria are rewritten; the rewrite itself releases the row +/// CLOSE -> `cancelled`, review_state `obsolete`, reason in judged_note, issue skipped +pub const STEP_HOLD : u8 = 0; +pub const STEP_RELEASE : u8 = 1; +pub const STEP_BACKFILL : u8 = 2; +pub const STEP_CLOSE : u8 = 3; + +/// The review_state a closed dispatch is written with. Read by the supervisor's candidate filter. +pub const OBSOLETE_STATE : str = "obsolete"; + +/// Floor before an attempt with nothing to judge goes back to the swarm. 30 minutes, the supervisor's +/// EMPTY_ATTEMPT_FLOOR_MS: long enough for a provider quota window (z.ai 1308/1316) to pass, short +/// enough that the 125 empty branches of 193 stop holding boundaries within one half-hour. +pub const RETRY_FLOOR_MINUTES : u32 = 30; + +/// Floor before a spent send-back ceiling goes back. 60 minutes, the supervisor's CEILING_RELEASE_MS: +/// judged work keeps its files for the hour a single send-back waits out, and no longer. +pub const CEILING_FLOOR_MINUTES : u32 = 60; + +/// How long a row waits for its criteria to be rewritten before it is closed. Two hours: two runs of an +/// hourly criteria backfill. A rewrite inside the window releases the row at once (releaseStaleContracts); +/// a backfill that cannot derive criteria from the issue's own words writes nothing, and then waiting +/// longer would only keep the files held. +pub const BACKFILL_WINDOW_MINUTES : u32 = 120; + +/// How many times one issue's escalation or spent ceiling is handed back before it is closed. One, and +/// one counter for both (the row's ceiling_releases), so the two roads cannot each spend it: 125 of the +/// 193 stuck branches are empty after FREE_ATTEMPT_CEILING silent attempts each, so a second identical +/// set is evidence about the issue, not the attempt; one more set after the floor recovers what a deploy +/// restart or a quota window took. +pub const MAX_RELEASES : u32 = 1; + +/// The policy's own hold on an awaitingReview boundary (QueenDelegation.reviewBoundaryHoldHours = 48), +/// in minutes. Every HOLD and BACKFILL here must end before it, or the valve would add a hold. +pub const POLICY_HOLD_MINUTES : u32 = 2880; + +/// The reviewer bee whose verdict on a pull request counts. +pub const REVIEWER_LOGIN : str = "t27-bees[bot]"; + +/// Branches the Queen publishes. Only these are sent back and closed here: any other pull request has an +/// author who answers its review. +pub const QUEEN_BRANCH_PREFIX : str = "queen-"; + +/// REQUEST_CHANGES rounds sent back to the Queen per pull request. Two, the review policy's +/// maximumSendBacks and the CI take-back's CI_MAXIMUM_SEND_BACKS: one pass that can teach, one that proves +/// it was taught; a bee that fails the same reviewer three times is telling you about the criteria. +pub const PR_FIX_ATTEMPTS : u32 = 2; + +/// Hours a REQUEST_CHANGES head may stand with no new push before the pull request is closed. 48: the +/// policy's own review hold; with 98 CI runs queued against 8 running a fix push can wait hours for its +/// checks, and a branch nobody pushed to in two days is not being fixed. +pub const PR_FIX_WINDOW_HOURS : u32 = 48; + +/// What the reviewer side does with one pull request. +/// NONE -> not this valve's (no REQUEST_CHANGES on the head, or not a Queen branch) +/// JUDGE -> a head the reviewer has not judged: judge it (never "already judged" for a new head) +/// WAIT_FIX -> the review is the Queen's send-back; the bee pushes to the same branch +/// CLOSE -> close the pull request, quoting the unmet criteria and the attempt count +pub const PR_NONE : u8 = 0; +pub const PR_JUDGE : u8 = 1; +pub const PR_WAIT_FIX : u8 = 2; +pub const PR_CLOSE : u8 = 3; + +/// What the publisher does with a Queen-published pull request that conflicts with master (#7571). +/// Measured 2026-10-07 15:51Z: 94 of 213 open pull requests conflicted, 67 of them on `queen-` branches, +/// the oldest opened 2026-09-21. GitHub runs no required check on a conflicting head, so the reviewer +/// bee never sees one and the merger never merges one; and no bee updates its own branch, so every +/// master merge that touches a bee's files leaves that pull request stranded for good. +/// C_NONE -> not this valve's (not a Queen branch, or it merges cleanly) +/// C_WAIT -> the Actions queue is over UPDATE_QUEUE_CEILING: an update now re-runs every workflow of +/// the pull request behind a queue that is already the bottleneck; look again next tick +/// C_UPDATE -> PUT /pulls/N/update-branch: master is merged into the head by GitHub, no force-push +/// C_REDO -> close the pull request with the reason and release the dispatch: a fresh bee redoes +/// the issue from current master on a new branch (counts against MAX_RELEASES) +/// C_CLOSE -> releases spent: close the pull request and the dispatch, as next_step's STEP_CLOSE +pub const C_NONE : u8 = 0; +pub const C_WAIT : u8 = 1; +pub const C_UPDATE : u8 = 2; +pub const C_REDO : u8 = 3; +pub const C_CLOSE : u8 = 4; + +/// update-branch calls per pull request. Two: one for the master merge that broke it, one for a second +/// merge landing while its checks ran; a head that conflicts a third time sits on files master keeps +/// rewriting, and a redo from fresh master is cheaper than a third chase. +pub const CONFLICT_UPDATES : u32 = 2; + +/// Queued Actions runs above which an update waits. 50, the loop rule that holds a second PR while the +/// queue is over ~50 (queen-actors-loop T5): one update re-runs about 44 workflows. +pub const UPDATE_QUEUE_CEILING : u32 = 50; + +/// Hours a conflicting pull request may wait on the queue before it is redone instead. 48, the review +/// policy's hold: a conflict that waited two days for a queue slot is not waiting, it is stuck. +pub const CONFLICT_WAIT_HOURS : u32 = 48; + +/// The publisher side, for one pull request. update_failed is GitHub's answer to the last update-branch +/// call (422 merge conflict); updates counts the calls made on this pull request; hours counts from the +/// first tick that saw it conflicting; releases is the dispatch's ceiling_releases. +pub fn conflict_step(queen_branch: bool, conflicting: bool, update_failed: bool, updates: u32, hours: u32, releases: u32, queued_runs: u32) -> u8 { + if (queen_branch == false) { return C_NONE; } + if (conflicting == false) { return C_NONE; } + var redo : bool = update_failed; + if (updates >= CONFLICT_UPDATES) { redo = true; } + if (hours >= CONFLICT_WAIT_HOURS) { redo = true; } + if (redo) { + if (releases >= MAX_RELEASES) { return C_CLOSE; } + return C_REDO; + } + if (queued_runs > UPDATE_QUEUE_CEILING) { return C_WAIT; } + return C_UPDATE; +} + +/// The kind of an escalated row, from what the row records. Order matters: a row with no criteria is +/// that whatever else it carries, and a spent ceiling outranks the empty-attempt counters. +pub fn escalation_kind(criteria: u32, send_back_ceiling: bool, dead_letter: bool, reviewer_gave_up: bool) -> u8 { + if (criteria == 0) { return KIND_NO_CRITERIA; } + if (send_back_ceiling) { return KIND_SEND_BACK_CEILING; } + if (dead_letter) { return KIND_DEAD_LETTER; } + if (reviewer_gave_up) { return KIND_REVIEWER_GAVE_UP; } + return KIND_UNRECORDED; +} + +/// Whether a kind is one only a runtime's record can name: escalation_kind never returns it. +pub fn is_recorded_kind(kind: u8) -> bool { + if (kind == KIND_EMPTY_ACCEPT) { return true; } + if (kind == KIND_BEYOND_THE_PATCH) { return true; } + if (kind == KIND_BASE_TRUTH_ALONE) { return true; } + if (kind == KIND_EFFECT_GAVE_UP) { return true; } + return false; +} + +/// The kind of an escalated row whose runtime also wrote down why. The row's counters decide first, in +/// escalation_kind's order, so the record cannot hide a missing criterion or a spent ceiling; the record +/// names only a row the counters leave UNRECORDED, and only with a kind that is a record's to name. A +/// runtime that records nothing passes KIND_UNRECORDED and gets escalation_kind's answer. +pub fn recorded_escalation_kind(recorded: u8, criteria: u32, send_back_ceiling: bool, dead_letter: bool, reviewer_gave_up: bool) -> u8 { + var kind : u8 = escalation_kind(criteria, send_back_ceiling, dead_letter, reviewer_gave_up); + if (kind != KIND_UNRECORDED) { return kind; } + if (is_recorded_kind(recorded)) { return recorded; } + return KIND_UNRECORDED; +} + +/// Whether a kind is fixed by rewriting the criteria rather than by another attempt. +pub fn wants_backfill(kind: u8) -> bool { + if (kind == KIND_NO_CRITERIA) { return true; } + if (kind == KIND_BEYOND_THE_PATCH) { return true; } + if (kind == KIND_BASE_TRUTH_ALONE) { return true; } + return false; +} + +/// The next step for a dispatch that was not accepted and has no clock of its own: an escalation of +/// any kind, or a send-back at its ceiling. idle_minutes counts from finished_at, which nothing rewrites; +/// releases is the row's ceiling_releases. +pub fn next_step(kind: u8, idle_minutes: u32, releases: u32) -> u8 { + if (wants_backfill(kind)) { + if (idle_minutes >= BACKFILL_WINDOW_MINUTES) { return STEP_CLOSE; } + return STEP_BACKFILL; + } + if (releases >= MAX_RELEASES) { return STEP_CLOSE; } + var floor : u32 = RETRY_FLOOR_MINUTES; + if (kind == KIND_SEND_BACK_CEILING) { floor = CEILING_FLOOR_MINUTES; } + if (idle_minutes >= floor) { return STEP_RELEASE; } + return STEP_HOLD; +} + +/// Whether a step keeps the dispatch's files. +pub fn holds_files(step: u8) -> bool { + if (step == STEP_HOLD) { return true; } + if (step == STEP_BACKFILL) { return true; } + return false; +} + +/// The reviewer side, for one pull request. changes_heads counts the distinct heads the reviewer judged +/// REQUEST_CHANGES; hours counts from its newest judgement. +pub fn pr_step(queen_branch: bool, changes_on_head: bool, head_judged: bool, changes_heads: u32, hours: u32) -> u8 { + if (queen_branch == false) { return PR_NONE; } + if (head_judged == false) { return PR_JUDGE; } + if (changes_on_head == false) { return PR_NONE; } + if (changes_heads > PR_FIX_ATTEMPTS) { return PR_CLOSE; } + if (hours >= PR_FIX_WINDOW_HOURS) { return PR_CLOSE; } + return PR_WAIT_FIX; +} + +test the_kind_is_read_from_the_row { + assert escalation_kind(0, true, true, true) == KIND_NO_CRITERIA; + assert escalation_kind(3, true, true, false) == KIND_SEND_BACK_CEILING; + assert escalation_kind(3, false, true, true) == KIND_DEAD_LETTER; + assert escalation_kind(3, false, false, true) == KIND_REVIEWER_GAVE_UP; + assert escalation_kind(3, false, false, false) == KIND_UNRECORDED; +} + +test an_effect_give_up_is_named_only_when_recorded { + assert recorded_escalation_kind(KIND_EFFECT_GAVE_UP, 3, false, false, false) == KIND_EFFECT_GAVE_UP; + assert escalation_kind(3, false, false, false) == KIND_UNRECORDED; + assert recorded_escalation_kind(KIND_UNRECORDED, 3, false, false, false) == KIND_UNRECORDED; + assert recorded_escalation_kind(KIND_EMPTY_ACCEPT, 3, false, false, false) == KIND_EMPTY_ACCEPT; + assert recorded_escalation_kind(KIND_BEYOND_THE_PATCH, 3, false, false, false) == KIND_BEYOND_THE_PATCH; + assert recorded_escalation_kind(KIND_BASE_TRUTH_ALONE, 3, false, false, false) == KIND_BASE_TRUTH_ALONE; +} + +test the_counters_outrank_the_record { + assert recorded_escalation_kind(KIND_EFFECT_GAVE_UP, 0, false, false, false) == KIND_NO_CRITERIA; + assert recorded_escalation_kind(KIND_EFFECT_GAVE_UP, 3, true, false, false) == KIND_SEND_BACK_CEILING; + assert recorded_escalation_kind(KIND_EFFECT_GAVE_UP, 3, false, true, false) == KIND_DEAD_LETTER; + assert recorded_escalation_kind(KIND_EFFECT_GAVE_UP, 3, false, false, true) == KIND_REVIEWER_GAVE_UP; + // a record cannot claim a kind the counters own, nor a code nobody defined + assert recorded_escalation_kind(KIND_DEAD_LETTER, 3, false, false, false) == KIND_UNRECORDED; + assert recorded_escalation_kind(KIND_NO_CRITERIA, 3, false, false, false) == KIND_UNRECORDED; + assert recorded_escalation_kind(KIND_EFFECT_GAVE_UP + 1, 3, false, false, false) == KIND_UNRECORDED; + assert is_recorded_kind(KIND_SEND_BACK_CEILING) == false; + assert is_recorded_kind(KIND_REVIEWER_GAVE_UP) == false; +} + +test a_give_up_waits_the_floor_then_goes_back_once { + // a crash loop that dispatch already moved between runtimes is not cured by an instant retry: + // it waits the empty-attempt floor (a deploy or a quota window), goes back once, then closes + assert next_step(KIND_EFFECT_GAVE_UP, 29, 0) == STEP_HOLD; + assert next_step(KIND_EFFECT_GAVE_UP, 30, 0) == STEP_RELEASE; + assert next_step(KIND_EFFECT_GAVE_UP, 30, 1) == STEP_CLOSE; + assert wants_backfill(KIND_EFFECT_GAVE_UP) == false; +} + +test an_empty_attempt_is_released_after_the_floor { + assert next_step(KIND_DEAD_LETTER, 0, 0) == STEP_HOLD; + assert next_step(KIND_DEAD_LETTER, 29, 0) == STEP_HOLD; + assert next_step(KIND_DEAD_LETTER, 30, 0) == STEP_RELEASE; + assert next_step(KIND_REVIEWER_GAVE_UP, 30, 0) == STEP_RELEASE; + assert next_step(KIND_EMPTY_ACCEPT, 30, 0) == STEP_RELEASE; + assert next_step(KIND_UNRECORDED, 30, 0) == STEP_RELEASE; + // #7077: a hold keeps the dispatch's files until the floor + assert holds_files(next_step(KIND_DEAD_LETTER, 29, 0)) == true; +} + +test a_spent_ceiling_waits_the_hour_then_goes_back_once { + assert next_step(KIND_SEND_BACK_CEILING, 59, 0) == STEP_HOLD; + assert next_step(KIND_SEND_BACK_CEILING, 60, 0) == STEP_RELEASE; + assert next_step(KIND_SEND_BACK_CEILING, 60, 1) == STEP_CLOSE; +} + +test spent_releases_close_at_once_not_hold { + assert next_step(KIND_DEAD_LETTER, 0, 1) == STEP_CLOSE; + assert next_step(KIND_SEND_BACK_CEILING, 0, 1) == STEP_CLOSE; + assert next_step(KIND_UNRECORDED, 0, 5) == STEP_CLOSE; + assert holds_files(STEP_CLOSE) == false; + assert holds_files(STEP_RELEASE) == false; +} + +test missing_criteria_are_backfilled_then_closed { + assert next_step(KIND_NO_CRITERIA, 0, 0) == STEP_BACKFILL; + assert next_step(KIND_BEYOND_THE_PATCH, 119, 0) == STEP_BACKFILL; + assert next_step(KIND_BASE_TRUTH_ALONE, 120, 0) == STEP_CLOSE; + assert next_step(KIND_NO_CRITERIA, 120, 0) == STEP_CLOSE; + assert wants_backfill(KIND_DEAD_LETTER) == false; + // #7077: a backfill keeps them too + assert holds_files(next_step(KIND_NO_CRITERIA, 0, 0)) == true; +} + +test no_kind_waits_for_a_person { + assert holds_files(next_step(KIND_UNRECORDED, POLICY_HOLD_MINUTES, 0)) == false; + assert holds_files(next_step(KIND_NO_CRITERIA, POLICY_HOLD_MINUTES, 0)) == false; + assert holds_files(next_step(KIND_SEND_BACK_CEILING, POLICY_HOLD_MINUTES, 0)) == false; + assert holds_files(next_step(KIND_DEAD_LETTER, POLICY_HOLD_MINUTES, 0)) == false; + assert holds_files(next_step(KIND_REVIEWER_GAVE_UP, POLICY_HOLD_MINUTES, 0)) == false; + assert holds_files(next_step(KIND_EMPTY_ACCEPT, POLICY_HOLD_MINUTES, 0)) == false; + assert holds_files(next_step(KIND_BEYOND_THE_PATCH, POLICY_HOLD_MINUTES, 0)) == false; + assert holds_files(next_step(KIND_BASE_TRUTH_ALONE, POLICY_HOLD_MINUTES, 0)) == false; + assert holds_files(next_step(KIND_EFFECT_GAVE_UP, POLICY_HOLD_MINUTES, 0)) == false; +} + +test request_changes_goes_back_to_the_queen_then_closes { + assert pr_step(true, true, true, 1, 0) == PR_WAIT_FIX; + assert pr_step(true, true, true, 2, 47) == PR_WAIT_FIX; + assert pr_step(true, true, true, 3, 0) == PR_CLOSE; + assert pr_step(true, true, true, 1, 48) == PR_CLOSE; +} + +test a_new_head_is_judged_and_a_stranger_is_left_alone { + assert pr_step(true, true, false, 2, 100) == PR_JUDGE; + assert pr_step(false, true, true, 9, 900) == PR_NONE; + assert pr_step(true, false, true, 0, 900) == PR_NONE; +} + +test a_conflicting_bee_branch_is_updated_not_left { + assert conflict_step(true, true, false, 0, 0, 0, 0) == C_UPDATE; + assert conflict_step(true, true, false, 1, 47, 0, 50) == C_UPDATE; + // the queue is the bottleneck: an update waits for room + assert conflict_step(true, true, false, 0, 0, 0, 51) == C_WAIT; + assert conflict_step(true, true, false, 1, 47, 1, 273) == C_WAIT; +} + +test a_branch_that_cannot_be_updated_is_redone_from_master { + assert conflict_step(true, true, true, 1, 0, 0, 0) == C_REDO; + assert conflict_step(true, true, false, 2, 0, 0, 0) == C_REDO; + assert conflict_step(true, true, false, 1, 48, 0, 273) == C_REDO; + // a redo spends the one release; a second conflict closes + assert conflict_step(true, true, true, 1, 0, 1, 0) == C_CLOSE; + assert conflict_step(true, true, false, 2, 0, 1, 0) == C_CLOSE; + assert conflict_step(true, true, false, 0, 48, 1, 0) == C_CLOSE; +} + +test a_clean_or_foreign_branch_is_left_alone { + assert conflict_step(true, false, true, 9, 900, 9, 999) == C_NONE; + assert conflict_step(false, true, true, 9, 900, 0, 0) == C_NONE; +} + +// #7092: the runtime mirrors the kinds by number, so a code that moved would read a recorded reason +// as another one; the values, not only their order, are the contract. +invariant the_kind_codes_never_move { + assert KIND_UNRECORDED == 0; + assert KIND_NO_CRITERIA == 1; + assert KIND_SEND_BACK_CEILING == 2; + assert KIND_DEAD_LETTER == 3; + assert KIND_REVIEWER_GAVE_UP == 4; + assert KIND_EMPTY_ACCEPT == 5; + assert KIND_BEYOND_THE_PATCH == 6; + assert KIND_BASE_TRUTH_ALONE == 7; + assert KIND_EFFECT_GAVE_UP == 8; +} + +// #7571: the publisher mirrors the conflict steps by number, as the runtime does the kinds. +invariant the_conflict_codes_never_move { + assert C_NONE == 0; + assert C_WAIT == 1; + assert C_UPDATE == 2; + assert C_REDO == 3; + assert C_CLOSE == 4; +} + +invariant every_clock_ends_before_the_policy_hold { + assert RETRY_FLOOR_MINUTES < CEILING_FLOOR_MINUTES; + assert CEILING_FLOOR_MINUTES < BACKFILL_WINDOW_MINUTES; + assert BACKFILL_WINDOW_MINUTES < POLICY_HOLD_MINUTES; + assert PR_FIX_WINDOW_HOURS * 60 <= POLICY_HOLD_MINUTES; + assert MAX_RELEASES > 0; + assert PR_FIX_ATTEMPTS > 0; + assert CONFLICT_WAIT_HOURS * 60 <= POLICY_HOLD_MINUTES; + assert CONFLICT_UPDATES > 0; + assert STEP_HOLD < STEP_RELEASE; + assert STEP_RELEASE < STEP_BACKFILL; + assert STEP_BACKFILL < STEP_CLOSE; +}