From ed7e13a978022294548ee7addd4a17ee9eb131bf Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Wed, 16 Sep 2026 00:31:27 +0700 Subject: [PATCH 1/3] feat(website): Queen HUD says why free bees are idle On 2026-09-15 the owner read "BEES 0/4" on #/queen as broken bees. The swarm was healthy_idle: the last round refused with "nothing to choose", 449 of 488 candidate issues had no ## Boundary, 12 were claimed and 27 were accepted but still open. /queen/status already carried all of it; the HUD did not say it. - queenHud.ts: idleReason() reads /queen/status alone (free slots from workers, the tick, the scheduler interval) and idleLine() words it. Free slots plus a refused round: the refusal, and for "nothing to choose" the three largest skip reasons with their wire counts. A tick older than two intervals (the server's TICK_STALENESS_INTERVALS) is called stale instead. A round that dispatched, no free slot, or an unreadable tick gives no line. A missing or malformed skipSummary gives the refusal alone, never a fabricated zero. - Queen.tsx: the BEES tile's sub-line carries the short head; a line under the map's head carries the full sentence and, for "nothing to choose", a link to gHashTag/t27#3587 as an example of an issue bees can take. en and ru copy; other refusals print the wire's own text. - The round popover crashed the page (React #31) on the real wire: each skipSummary entry is {count, issues, more}, and the list rendered the object. It now lists counts read through skipCounts(). - "3 ready" is dispatches.unreviewed: finished dispatch rows with no verdict, not issues ready to take. It reads "finished, no verdict"; the Russian copy says "no verdict", since the longer form was clipped in the 1440 px tile. - qa/queen-idle-reason-contract.mjs (check:queen-idle, wired into website-checks.yml): 51 checks over four real snapshots in qa/fixtures/queen-status and malformed variants. It failed before the change (missing export) and fails on five mutants (stale boundary, zero reasons, counts as text, the old ready word, the old popover). queen-honesty-contract.mjs follows the renamed copy key. Co-Authored-By: Claude Opus 5 --- .github/workflows/website-checks.yml | 5 + apps/website/package.json | 1 + .../healthy-idle-2026-09-15T1250Z.json | 1 + .../working-2026-09-15T1400Z.json | 1 + .../working-capacity12-2026-09-15T1533Z.json | 1 + .../working-refused-2026-09-15T1350Z.json | 1 + apps/website/qa/queen-honesty-contract.mjs | 4 +- .../website/qa/queen-idle-reason-contract.mjs | 124 +++++++++++++++++ apps/website/src/components/queenHud.ts | 127 ++++++++++++++++++ apps/website/src/pages/Queen.css | 56 +++++++- apps/website/src/pages/Queen.tsx | 87 ++++++++++-- 11 files changed, 397 insertions(+), 11 deletions(-) create mode 100644 apps/website/qa/fixtures/queen-status/healthy-idle-2026-09-15T1250Z.json create mode 100644 apps/website/qa/fixtures/queen-status/working-2026-09-15T1400Z.json create mode 100644 apps/website/qa/fixtures/queen-status/working-capacity12-2026-09-15T1533Z.json create mode 100644 apps/website/qa/fixtures/queen-status/working-refused-2026-09-15T1350Z.json create mode 100644 apps/website/qa/queen-idle-reason-contract.mjs diff --git a/.github/workflows/website-checks.yml b/.github/workflows/website-checks.yml index 190f9f1254..eb50159eb5 100644 --- a/.github/workflows/website-checks.yml +++ b/.github/workflows/website-checks.yml @@ -123,6 +123,11 @@ jobs: # memory only; whoami never carries X-Agent-Key. Pure, no browser. - name: TRI identity run: npm run check:tri-identity + # Why free bees are idle, read from /queen/status alone: real snapshots + # and malformed ones through the pure reader, no fabricated zeros, a + # stale round called stale. Pure, no browser. + - name: Queen idle reason + run: npm run check:queen-idle - name: Catalog generators run: npm run test:sync-skills && npm run test:sync-crons # The tiers, the touch minimum and the QA matrix come from diff --git a/apps/website/package.json b/apps/website/package.json index f2d1bf40bd..cb4af8f861 100644 --- a/apps/website/package.json +++ b/apps/website/package.json @@ -44,6 +44,7 @@ "check:queen-review": "node qa/queen-review-lifecycle-contract.mjs", "check:queen-viewport": "node qa/queen-viewport-contract.mjs", "check:queen-honesty": "node --experimental-strip-types qa/queen-honesty-contract.mjs", + "check:queen-idle": "node --experimental-strip-types qa/queen-idle-reason-contract.mjs", "check:queen-coverage": "node --experimental-strip-types qa/queen-coverage-contract.mjs", "check:queen-displays": "node --experimental-strip-types qa/queen-hive-display-contract.mjs", "check:queen-worlds": "node --experimental-strip-types qa/queen-repository-worlds.mjs", diff --git a/apps/website/qa/fixtures/queen-status/healthy-idle-2026-09-15T1250Z.json b/apps/website/qa/fixtures/queen-status/healthy-idle-2026-09-15T1250Z.json new file mode 100644 index 0000000000..da1722edcc --- /dev/null +++ b/apps/website/qa/fixtures/queen-status/healthy-idle-2026-09-15T1250Z.json @@ -0,0 +1 @@ +{"status":"ok","swarmState":"healthy_idle","workers":{"capacity":4,"active":0,"idle":4,"utilization":0},"queue":{"state":"no-eligible-work","observedAt":"2026-09-15T12:50:33.230Z"},"scheduler":{"enabled":true,"intervalSeconds":300,"billingMode":"coding_plan","estimatedUSDGateEnabled":false},"lastTick":{"decidedAt":"2026-09-15T12:50:33.230Z","allowed":false,"refusal":"nothing to choose","skippedCount":488,"skipSummary":{"claimed":{"count":12,"issues":[3587,3586,3585,3584,3582,3576,3533,3527,3521,3510,3509,3507],"more":0},"completed":{"count":27,"issues":[3619,3591,3590,3589,3588,3583,3542,3541,3540,3539,3537,3535,3532,3529,3528,3526,3523,3522,3520,3519,3518,3517,3516,3514,3513],"more":2},"missingBoundary":{"count":449,"issues":[3573,3572,3571,3570,3569,3474,3473,3472,3464,3445,3443,3440,3404,3399,3386,3377,3371,3370,3368,3366,3337,3333,3331,3328,3316],"more":424}},"skipIssueListCap":25},"dispatches":{"total":442,"finished":442,"running":0,"unreviewed":3,"latest":{"issue":3576,"dispatchedAt":"2026-09-15T12:20:33.844Z","finishedAt":"2026-09-15T12:43:01.735Z","outcome":"finished"}}} \ No newline at end of file diff --git a/apps/website/qa/fixtures/queen-status/working-2026-09-15T1400Z.json b/apps/website/qa/fixtures/queen-status/working-2026-09-15T1400Z.json new file mode 100644 index 0000000000..06524c7d46 --- /dev/null +++ b/apps/website/qa/fixtures/queen-status/working-2026-09-15T1400Z.json @@ -0,0 +1 @@ +{"status":"ok","swarmState":"working","workers":{"capacity":4,"active":2,"idle":2,"utilization":50},"queue":{"state":"work-dispatched","observedAt":"2026-09-15T14:00:24.187Z"},"scheduler":{"enabled":true,"intervalSeconds":300,"billingMode":"coding_plan","estimatedUSDGateEnabled":false},"lastTick":{"decidedAt":"2026-09-15T13:59:58.978Z","allowed":true,"refusal":null,"skippedCount":96,"skipSummary":{"claimed":{"count":12,"issues":[3587,3586,3585,3584,3582,3576,3533,3527,3521,3510,3509,3507],"more":0},"completed":{"count":27,"issues":[3619,3591,3590,3589,3588,3583,3542,3541,3540,3539,3537,3535,3532,3529,3528,3526,3523,3522,3520,3519,3518,3517,3516,3514,3513],"more":2},"missingBoundary":{"count":57,"issues":[3622,3573,3572,3571,3570,3569,3474,3473,3472,3464,3445,3443,3440,3404,3399,3386,3377,3371,3370,3368,3366,3337,3333,3331,3328],"more":32}},"skipIssueListCap":25},"dispatches":{"total":443,"finished":441,"running":2,"unreviewed":3,"latest":{"issue":3624,"dispatchedAt":"2026-09-15T14:00:00.585Z","finishedAt":null,"outcome":null}}} \ No newline at end of file diff --git a/apps/website/qa/fixtures/queen-status/working-capacity12-2026-09-15T1533Z.json b/apps/website/qa/fixtures/queen-status/working-capacity12-2026-09-15T1533Z.json new file mode 100644 index 0000000000..d801d0d95a --- /dev/null +++ b/apps/website/qa/fixtures/queen-status/working-capacity12-2026-09-15T1533Z.json @@ -0,0 +1 @@ +{"status":"ok","swarmState":"working","workers":{"capacity":12,"active":1,"idle":11,"utilization":8},"queue":{"state":"work-dispatched","observedAt":"2026-09-15T15:33:47.532Z"},"scheduler":{"enabled":true,"intervalSeconds":300,"billingMode":"coding_plan","estimatedUSDGateEnabled":false},"lastTick":{"decidedAt":"2026-09-15T15:30:11.960Z","allowed":true,"refusal":null,"skippedCount":439,"skipSummary":{"claimed":{"count":12,"issues":[3587,3586,3585,3584,3582,3576,3533,3527,3521,3510,3509,3507],"more":0},"completed":{"count":27,"issues":[3619,3591,3590,3589,3588,3583,3542,3541,3540,3539,3537,3535,3532,3529,3528,3526,3523,3522,3520,3519,3518,3517,3516,3514,3513],"more":2},"missingBoundary":{"count":400,"issues":[3672,3573,3572,3571,3570,3569,3474,3473,3472,3464,3445,3443,3440,3404,3399,3386,3377,3371,3370,3368,3366,3337,3333,3331,3328],"more":375}},"skipIssueListCap":25},"dispatches":{"total":443,"finished":442,"running":1,"unreviewed":3,"latest":{"issue":3624,"dispatchedAt":"2026-09-15T15:30:12.894Z","finishedAt":null,"outcome":null}}} \ No newline at end of file diff --git a/apps/website/qa/fixtures/queen-status/working-refused-2026-09-15T1350Z.json b/apps/website/qa/fixtures/queen-status/working-refused-2026-09-15T1350Z.json new file mode 100644 index 0000000000..b069decda2 --- /dev/null +++ b/apps/website/qa/fixtures/queen-status/working-refused-2026-09-15T1350Z.json @@ -0,0 +1 @@ +{"status":"ok","swarmState":"working","workers":{"capacity":4,"active":1,"idle":3,"utilization":25},"queue":{"state":"work-dispatched","observedAt":"2026-09-15T13:50:45.629Z"},"scheduler":{"enabled":true,"intervalSeconds":300,"billingMode":"coding_plan","estimatedUSDGateEnabled":false},"lastTick":{"decidedAt":"2026-09-15T13:50:37.349Z","allowed":false,"refusal":"nothing to choose","skippedCount":487,"skipSummary":{"claimed":{"count":12,"issues":[3587,3586,3585,3584,3582,3576,3533,3527,3521,3510,3509,3507],"more":0},"completed":{"count":27,"issues":[3619,3591,3590,3589,3588,3583,3542,3541,3540,3539,3537,3535,3532,3529,3528,3526,3523,3522,3520,3519,3518,3517,3516,3514,3513],"more":2},"missingBoundary":{"count":448,"issues":[3622,3573,3572,3571,3570,3569,3474,3473,3472,3464,3445,3443,3440,3404,3399,3386,3377,3371,3370,3368,3366,3337,3333,3331,3328],"more":423}},"skipIssueListCap":25},"dispatches":{"total":442,"finished":441,"running":1,"unreviewed":3,"latest":{"issue":3576,"dispatchedAt":"2026-09-15T13:45:36.746Z","finishedAt":null,"outcome":null}}} \ No newline at end of file diff --git a/apps/website/qa/queen-honesty-contract.mjs b/apps/website/qa/queen-honesty-contract.mjs index 5a682cba86..bdc085242b 100644 --- a/apps/website/qa/queen-honesty-contract.mjs +++ b/apps/website/qa/queen-honesty-contract.mjs @@ -143,8 +143,8 @@ check(/`\$\{c\.hudSince\} \$\{formatMoment\(lastRoundAt, lang\)\} · ≤ \$\{for check(/schedulerOff \|\| !roundKnown \? "—" : `\+\$\{formatCountdown\(elapsedSeconds\)\}`/.test(src), "the value counts up since the last round; no countdown promises the next round at a second"); // the status pill reads swarmState through COPY keys; the VERDICTS tile adds the unreviewed count only when the wire has it (P1-28) check(/data\?\.swarmState === "working"[\s\S]{0,40}c\.swarmWorking[\s\S]{0,120}c\.swarmIdle[\s\S]{0,120}c\.swarmPaused[\s\S]{0,80}data\.swarmState\.toUpperCase\(\)[\s\S]{0,40}c\.swarmUnknown/.test(src), "the pill maps working/idle/paused to COPY keys, prints an unfamiliar wire state as itself, and a missing one as the unknown key"); -check(/swarmWorking:\s*"WORKING"/.test(src) && /swarmWorking:\s*"РАБОТАЕТ"/.test(src) && /hudReady:\s*"ready"/.test(src) && /hudReady:\s*"готово"/.test(src), "the four swarm words and the ready word exist in both languages"); -check(/typeof data\?\.dispatches\.unreviewed === "number" \? ` · \$\{data\.dispatches\.unreviewed\} \$\{c\.hudReady\}` : ""/.test(src), "the VERDICTS sub-line prints the unreviewed count only when the wire carries the field, never 0 for an absent one"); +check(/swarmWorking:\s*"WORKING"/.test(src) && /swarmWorking:\s*"РАБОТАЕТ"/.test(src) && /hudNoVerdict:\s*"finished, no verdict"/.test(src) && /hudNoVerdict:\s*"без вердикта"/.test(src), "the four swarm words and the no-verdict words exist in both languages (dispatches.unreviewed counts finished rows with no verdict, not issues ready)"); +check(/typeof data\?\.dispatches\.unreviewed === "number" \? ` · \$\{data\.dispatches\.unreviewed\} \$\{c\.hudNoVerdict\}` : ""/.test(src), "the VERDICTS sub-line prints the unreviewed count only when the wire carries the field, never 0 for an absent one"); check(/`\$\{decisionInfo\} · \$\{decision\.allowed \? c\.chose : c\.stoodDown\}`/.test(src), "the gold block's decision line leads with the wire field (the refusal or what the round did), the verb follows"); // the INTEL FEED header states what it holds, from its rows (P1-27) const cov = feedCoverage([{ at: "2026-09-05T00:40:00Z" }, { at: "2026-09-05T00:41:02Z" }, { at: "2026-09-05T00:40:30Z" }]); diff --git a/apps/website/qa/queen-idle-reason-contract.mjs b/apps/website/qa/queen-idle-reason-contract.mjs new file mode 100644 index 0000000000..346bee9b0e --- /dev/null +++ b/apps/website/qa/queen-idle-reason-contract.mjs @@ -0,0 +1,124 @@ +// Queen idle-reason contract. BEES read 0/4 on 2026-09-15 and the owner asked +// why the bees were broken: the swarm was healthy_idle and the last round said +// "nothing to choose" - 449 of 488 candidates had no ## Boundary. The status +// the page already fetched carried the reason; the HUD did not say it. This +// runs the pure reader and formatter on real /queen/status snapshots +// (qa/fixtures/queen-status) and on malformed ones, then checks the page uses +// them. Pure, no browser. Node 22 needs --experimental-strip-types. +import { readFileSync } from "node:fs"; +import { idleLine, idleReason, skipCounts } from "../src/components/queenHud.ts"; + +const fails = []; +let checks = 0; +const check = (cond, msg) => { checks += 1; if (!cond) fails.push(msg); }; +const fixture = (name) => JSON.parse(readFileSync(new URL(`./fixtures/queen-status/${name}`, import.meta.url), "utf8")); +const at = (status, seconds) => Date.parse(status.lastTick.decidedAt) + seconds * 1000; +const clone = (value) => structuredClone(value); + +const EN = { + idle: "idle", nothingToChoose: "nothing to choose", of: "of", issues: "issues", + stale: "round stale", staleDetail: "last decision {age} ago, rounds every {interval}", + unitS: "s", unitMin: "min", unitH: "h", + reasons: { missingBoundary: "lack a ## Boundary", claimed: "claimed", completed: "done but open", fileConflict: "touch held files", incompleteSpec: "incomplete spec", notFirst: "not first", other: "other" }, +}; +const RU = { + idle: "свободно", nothingToChoose: "нечего выбрать", of: "из", issues: "задач", + stale: "раунд устарел", staleDetail: "последнее решение {age} назад, раунды каждые {interval}", + unitS: "с", unitMin: "мин", unitH: "ч", + reasons: { missingBoundary: "без ## Boundary", claimed: "заняты", completed: "сделаны и не закрыты", fileConflict: "задевают занятые файлы", incompleteSpec: "спека неполная", notFirst: "не первые", other: "прочие" }, +}; +const line = (status, nowMs, words = EN) => { const r = idleReason(status, nowMs); return r ? idleLine(r, words) : null; }; + +// 1. the real healthy_idle snapshot: every number comes from the file +const idle = fixture("healthy-idle-2026-09-15T1250Z.json"); +const t = idle.lastTick, sum = t.skipSummary, free = idle.workers.capacity - idle.workers.active; +const en = line(idle, at(idle, 60)); +check(en !== null && en.text === `${free} idle: nothing to choose — ${sum.missingBoundary.count} of ${t.skippedCount} issues lack a ## Boundary, ${sum.completed.count} done but open, ${sum.claimed.count} claimed`, `healthy_idle (en): the refusal, then the three reasons by size, numbers from the wire (got "${en?.text}")`); +check(en?.text === "4 idle: nothing to choose — 449 of 488 issues lack a ## Boundary, 27 done but open, 12 claimed", `healthy_idle (en) literal, so a reader sees the sentence the owner will see (got "${en?.text}")`); +check(en?.head === "4 idle: nothing to choose" && en?.example === true, "healthy_idle: the tile's short head is the free count and the refusal; the example link is offered"); +const ru = line(idle, at(idle, 60), RU); +check(ru?.text === "4 свободно: нечего выбрать — 449 из 488 задач без ## Boundary, 27 сделаны и не закрыты, 12 заняты", `healthy_idle (ru) (got "${ru?.text}")`); +check(!/\d/.test(RU.nothingToChoose + EN.nothingToChoose) && !/[А-яЁё]/.test(en?.text ?? ""), "no digit lives in the copy, no Cyrillic leaks into English"); + +// 2. working snapshots: the round dispatched, so there is no idle line +const working = fixture("working-2026-09-15T1400Z.json"); +const cap12 = fixture("working-capacity12-2026-09-15T1533Z.json"); +check(line(working, at(working, 60)) === null, "working (allowed, 2 of 4 busy): no idle line"); +check(line(cap12, at(cap12, 60)) === null, "working (allowed, capacity 12): no idle line"); +// a real snapshot where one bee works and the round still refused: three slots are idle and it says why +const busyRefused = fixture("working-refused-2026-09-15T1350Z.json"); +check(line(busyRefused, at(busyRefused, 60))?.text === "3 idle: nothing to choose — 448 of 487 issues lack a ## Boundary, 27 done but open, 12 claimed", `1 of 4 busy, round refused: the three idle slots are explained (got "${line(busyRefused, at(busyRefused, 60))?.text}")`); + +// 3. staleness: older than two intervals says the round is stale, never the reasons +check(line(idle, at(idle, 600))?.head === "4 idle: nothing to choose", "exactly two intervals old is still fresh (the server's own bound)"); +const stale = line(idle, at(idle, 601)); +check(stale?.text === "4 idle: round stale — last decision 10 min ago, rounds every 5 min" && stale.example === false, `601 s past a 300 s round: stale, no reasons (got "${stale?.text}")`); +check(line(idle, at(idle, 11 * 3600 + 5), RU)?.text === "4 свободно: раунд устарел — последнее решение 11 ч назад, раунды каждые 5 мин", "stale in Russian, hours"); +check(line(cap12, at(cap12, 3600))?.text === "11 idle: round stale — last decision 60 min ago, rounds every 5 min", "a stale ALLOW with idle slots is stale too: its dispatch is no longer news"); +check(line(idle, at(idle, 90))?.text.startsWith("4 idle: nothing to choose") && line(idle, at(idle, -30)) !== null, "a tick a little in the future (clock skew) is fresh, not negative"); + +// 4. missing or malformed skipSummary: the refusal alone, no fabricated zeros +const without = (mutate) => { const s = clone(idle); mutate(s); return line(s, at(s, 60)); }; +const bare = "4 idle: nothing to choose"; +for (const [label, mutate] of [ + ["absent", (s) => { delete s.lastTick.skipSummary; }], + ["null", (s) => { s.lastTick.skipSummary = null; }], + ["a string", (s) => { s.lastTick.skipSummary = "449 missing"; }], + ["an array", (s) => { s.lastTick.skipSummary = [449, 12, 27]; }], + ["a count as text", (s) => { s.lastTick.skipSummary.claimed.count = "12"; }], + ["a negative count", (s) => { s.lastTick.skipSummary.missingBoundary.count = -1; }], + ["a fractional count", (s) => { s.lastTick.skipSummary.completed.count = 27.5; }], + ["an entry without count", (s) => { s.lastTick.skipSummary.claimed = { issues: [1] }; }], + ["a count above skippedCount", (s) => { s.lastTick.skipSummary.missingBoundary.count = 489; }], + ["empty", (s) => { s.lastTick.skipSummary = {}; }], + ["all zero", (s) => { s.lastTick.skipSummary = { claimed: { count: 0, issues: [], more: 0 } }; }], +]) { + const got = without(mutate); + check(got?.text === bare && got.tail === null && got.example === true, `skipSummary ${label}: "${bare}" and nothing about reasons (got "${got?.text}")`); +} +check(without((s) => { delete s.lastTick.skippedCount; })?.text === "4 idle: nothing to choose — 449 issues lack a ## Boundary, 27 done but open, 12 claimed", "no skippedCount: the counts stand, no 'of N' is invented"); +check(skipCounts({ claimed: 17, missingBoundary: 2 }, 33)?.map((r) => `${r.key}=${r.count}`).join(",") === "claimed=17,missingBoundary=2", "the older wire shape (bare numbers) reads as the same counts, in wire order"); +check(skipCounts(idle.lastTick.skipSummary, 488)?.map((r) => r.count).join(",") === "12,27,449" && skipCounts(undefined) === null && skipCounts({ x: { count: 1 } }, 0) === null, "skipCounts: wire order; absent or contradictory is null"); +const many = clone(idle); +many.lastTick.skipSummary = { notFirst: { count: 3 }, claimed: { count: 40 }, other: { count: 0 }, fileConflict: { count: 40 }, oddNewReason: { count: 41 } }; +check(line(many, at(many, 60))?.tail === "41 of 488 issues odd new reason, 40 claimed, 40 touch held files", `only the three largest, ties in wire order, an unknown key spaced not invented (got "${line(many, at(many, 60))?.tail}")`); + +// 5. other refusals: the refusal text itself, no reasons, no example +const refusing = (refusal, workers) => { const s = clone(idle); s.lastTick.refusal = refusal; if (workers) s.workers = { ...s.workers, ...workers }; return line(s, at(s, 60)); }; +check(refusing("no registry mirror published yet")?.text === "4 idle: no registry mirror published yet", "no registry mirror: verbatim"); +const capacity = refusing("4 workers already running (limit 4)", { capacity: 12, active: 4 }); +check(capacity?.text === "8 idle: 4 workers already running (limit 4)" && capacity.tail === null && capacity.example === false, `the policy limit under a larger capacity: verbatim, eight slots idle (got "${capacity?.text}")`); +const s1 = clone(idle); s1.lastTick.refusal = "daily cap reached: $20.00 of $20.00"; +check(line(s1, at(s1, 60), RU)?.text === "4 свободно: daily cap reached: $20.00 of $20.00", "the daily cap in Russian: the wire's words, not a translation guessed from them"); + +// 6. nothing to explain, or nothing trustworthy to explain it with +const gate = (mutate) => { const s = clone(idle); mutate(s); return line(s, at(idle, 60)); }; +for (const [label, value] of [ + ["null status", line(null, 0)], ["a string status", line("ok", 0)], ["an empty object", line({}, 0)], + ["no lastTick", gate((s) => { s.lastTick = null; })], + ["an undatable decidedAt", gate((s) => { s.lastTick.decidedAt = "yesterday"; })], + ["allowed not a boolean", gate((s) => { s.lastTick.allowed = "false"; })], + ["a refusal of null", gate((s) => { s.lastTick.refusal = null; })], + ["an empty refusal", gate((s) => { s.lastTick.refusal = " "; })], + ["the scheduler off", gate((s) => { s.scheduler.enabled = false; })], + ["no interval", gate((s) => { s.scheduler.intervalSeconds = 0; })], + ["no workers", gate((s) => { delete s.workers; })], + ["workers as text", gate((s) => { s.workers.capacity = "4"; })], + ["every slot busy", gate((s) => { s.workers.active = 4; })], + ["no capacity", gate((s) => { s.workers = { capacity: 0, active: 0, idle: 0, utilization: 0 }; })], +]) check(value === null, `${label}: no line`); + +// 7. the page uses the reader, and says what "ready" counted +const src = readFileSync(new URL("../src/pages/Queen.tsx", import.meta.url), "utf8"); +check(/idleReason\(data, now \+ \(state\.offsetMs \?\? 0\)\)/.test(src) && /idleLine\(/.test(src), "Queen.tsx reads the idle reason from the status it already fetched, on the server's clock"); +check(/className="queen27-hud-idle"/.test(src) && /href="https:\/\/github\.com\/gHashTag\/t27\/issues\/3587"/.test(src), "the line has its own element and links the example work order"); +check(/idleExample:\s*"example of an issue bees can take"/.test(src) && /idleExample:\s*"пример задачи, которую пчёлы могут взять"/.test(src), "the link is labelled as an example in both languages"); +check(/skipCounts\(decision\?\.skipSummary\)/.test(src) && !/skipEntries\.map\(\(\[reason, count\]\)/.test(src), "the round popover lists counts read through skipCounts: the wire's {count, issues, more} objects crashed it (React #31)"); +check(/hudNoVerdict:\s*"finished, no verdict"/.test(src) && /hudNoVerdict:\s*"без вердикта"/.test(src) && !/hudReady/.test(src), "dispatches.unreviewed is named for what it counts: finished rows with no verdict, not issues ready"); + +// self-test: a changed wire number changes the sentence, so the numbers are read, not written +const moved = clone(idle); moved.lastTick.skipSummary.missingBoundary.count = 448; +check(line(moved, at(moved, 60))?.text.includes("448 of 488") && !line(moved, at(moved, 60))?.text.includes("449"), "self-test: 448 in the wire reads 448 on the page"); + +if (fails.length) { for (const f of fails) console.log(" ✗ " + f); console.log(`Queen idle-reason contract: FAIL (${fails.length} of ${checks})`); process.exit(1); } +console.log(`Queen idle-reason contract: PASS (${checks} checks)`); diff --git a/apps/website/src/components/queenHud.ts b/apps/website/src/components/queenHud.ts index bd70c813eb..532a5547a1 100644 --- a/apps/website/src/components/queenHud.ts +++ b/apps/website/src/components/queenHud.ts @@ -303,6 +303,133 @@ export function skipReasonWords(key: string): string { return key.replace(/([a-z0-9])([A-Z])/g, "$1 $2").toLowerCase(); } +/** One skip category and how many candidates the round filed under it. */ +export interface SkipCount { key: string; count: number } + +const isCount = (value: unknown): value is number => + typeof value === "number" && Number.isInteger(value) && value >= 0; + +/** + * lastTick.skipSummary as counts in wire order. The wire sends + * { count, issues, more } per category (older servers a bare number); only + * the count is read. Null when the summary is absent or any entry is + * unreadable, including a count above skippedCount: a malformed summary says + * nothing, never a zero. + */ +export function skipCounts(summary: unknown, skipped: number | null = null): SkipCount[] | null { + if (!summary || typeof summary !== "object" || Array.isArray(summary)) return null; + const counts: SkipCount[] = []; + for (const [key, value] of Object.entries(summary)) { + const count = value && typeof value === "object" ? (value as { count?: unknown }).count : value; + if (!isCount(count) || (skipped !== null && count > skipped)) return null; + counts.push({ key, count }); + } + return counts; +} + +/** The refusal queend writes when no candidate survives a round. */ +const NOTHING_TO_CHOOSE = "nothing to choose"; + +export type IdleReason = + | { kind: "stale"; free: number; ageSeconds: number; intervalSeconds: number } + | { kind: "refused"; free: number; refusal: string; skipped: number | null; counts: SkipCount[] | null }; + +/** + * Why free worker slots started nothing, from /queen/status alone. Measured + * 2026-09-15: BEES 0/4 was read as broken bees while the round had said + * "nothing to choose" and 449 of 488 candidates had no ## Boundary. Null when + * there is nothing to explain or nothing trustworthy to explain it with: no + * free slot, a round that dispatched, a scheduler that is off, an unreadable + * tick. A tick older than two intervals (the server's TICK_STALENESS_INTERVALS) + * is stale, and a stale round explains nothing. + */ +export function idleReason(status: unknown, serverNowMs: number): IdleReason | null { + if (!status || typeof status !== "object") return null; + const { scheduler, workers, lastTick } = status as { + scheduler?: { enabled?: unknown; intervalSeconds?: unknown } | null; + workers?: { capacity?: unknown; active?: unknown } | null; + lastTick?: { decidedAt?: unknown; allowed?: unknown; refusal?: unknown; skippedCount?: unknown; skipSummary?: unknown } | null; + }; + if (!scheduler || scheduler.enabled !== true) return null; + const interval = scheduler.intervalSeconds; + if (typeof interval !== "number" || !Number.isFinite(interval) || interval <= 0) return null; + if (!workers || !isCount(workers.capacity) || !isCount(workers.active)) return null; + const free = workers.capacity - workers.active; + if (free <= 0) return null; + if (!lastTick || typeof lastTick.decidedAt !== "string" || typeof lastTick.allowed !== "boolean") return null; + const decidedMs = Date.parse(lastTick.decidedAt); + if (Number.isNaN(decidedMs)) return null; + const ageMs = serverNowMs - decidedMs; + if (ageMs > interval * 1000 * 2) { + return { kind: "stale", free, ageSeconds: Math.floor(ageMs / 1000), intervalSeconds: interval }; + } + if (lastTick.allowed || typeof lastTick.refusal !== "string" || !lastTick.refusal.trim()) return null; + const skipped = isCount(lastTick.skippedCount) ? lastTick.skippedCount : null; + const counts = lastTick.refusal === NOTHING_TO_CHOOSE ? skipCounts(lastTick.skipSummary, skipped) : null; + return { kind: "refused", free, refusal: lastTick.refusal, skipped, counts }; +} + +export interface IdleWords { + idle: string; + nothingToChoose: string; + of: string; + issues: string; + stale: string; + /** With {age} and {interval}. */ + staleDetail: string; + unitS: string; + unitMin: string; + unitH: string; + /** Words per skip category; an unknown category is spaced, not invented. */ + reasons: Record; +} + +export interface IdleLine { head: string; tail: string | null; text: string; example: boolean } + +function spanWords(seconds: number, words: IdleWords): string { + if (seconds < 120) return `${seconds} ${words.unitS}`; + if (seconds < 7200) return `${Math.floor(seconds / 60)} ${words.unitMin}`; + return `${Math.floor(seconds / 3600)} ${words.unitH}`; +} + +/** + * The idle reason as one line. The head is short enough for the BEES tile: + * the free slots and the refusal, or "round stale". The tail is what the wire + * counted: the three largest skip reasons, or the stale tick's age. A refusal + * prints as the wire wrote it; only queend's fixed "nothing to choose" has + * words of its own. The example work order is offered only when nothing could + * be chosen. + */ +export function idleLine(reason: IdleReason, words: IdleWords): IdleLine { + const lead = `${reason.free} ${words.idle}`; + let head: string; + let tail: string | null; + if (reason.kind === "stale") { + head = `${lead}: ${words.stale}`; + tail = words.staleDetail + .replace("{age}", spanWords(reason.ageSeconds, words)) + .replace("{interval}", spanWords(reason.intervalSeconds, words)); + } else { + head = `${lead}: ${reason.refusal === NOTHING_TO_CHOOSE ? words.nothingToChoose : reason.refusal}`; + const top = (reason.counts ?? []).filter((r) => r.count > 0).sort((a, b) => b.count - a.count).slice(0, 3); + tail = top.length === 0 + ? null + : top + .map((r, i) => { + const n = i > 0 ? `${r.count}` : reason.skipped !== null ? `${r.count} ${words.of} ${reason.skipped} ${words.issues}` : `${r.count} ${words.issues}`; + const said = Object.prototype.hasOwnProperty.call(words.reasons, r.key) ? words.reasons[r.key] : skipReasonWords(r.key); + return `${n} ${said}`; + }) + .join(", "); + } + return { + head, + tail, + text: tail ? `${head} — ${tail}` : head, + example: reason.kind === "refused" && reason.refusal === NOTHING_TO_CHOOSE, + }; +} + /** * A hardware device's family string -> the crystal hue on the comb: gold for * CPU (the default), cyan for FPGA, green for GPU - the ring colours of the diff --git a/apps/website/src/pages/Queen.css b/apps/website/src/pages/Queen.css index 008f07f954..ce60448f35 100644 --- a/apps/website/src/pages/Queen.css +++ b/apps/website/src/pages/Queen.css @@ -5573,6 +5573,60 @@ main:where(.queen27-page.is-shell) section { } } +/* ---- why free bees are idle (2026-09-15) ---------------------------------- + BEES 0/4 was read as broken bees while the round had said "nothing to + choose". The line sits under the map's head, over the top edge of the + field, and wraps rather than being cut: the head itself is one row that + clips anything taller. On a phone the head is in the flow (row 1), so the + line takes the top of row 2 without resizing it. */ +.queen27-page.is-shell .queen27-hud-viewport > .queen27-hud-idle { + position: absolute; + z-index: 3; + grid-area: 1 / 1 / -1 / -1; + top: calc(var(--hud-chrome-top) + 40px); + right: calc(var(--hud-col-w) + var(--hud-gap) * 2); + left: calc(var(--hud-rail-w) + var(--hud-gap) * 2); + align-self: start; + margin: 0; + padding: 0.3rem 0.6rem; + border: 1px solid var(--hud-line); + background: #000000b8; + color: var(--hud-gold); + font-size: 0.66rem; + line-height: 1.35; + overflow-wrap: anywhere; +} + +.queen27-page.is-shell.is-command-collapsed .queen27-hud-viewport > .queen27-hud-idle { + left: 84px; +} + +.queen27-page.is-shell .queen27-hud-idle a { + color: var(--hud-cyan); + text-decoration: underline; +} + +.queen27-page.is-shell .queen27-hud-idle a:focus-visible { + outline: 2px solid var(--hud-cyan); + outline-offset: 2px; +} + +.queen27-page.is-shell.is-bare .queen27-hud-idle { + display: none; +} + +@media (max-width: 900px) { + .queen27-page.is-shell .queen27-hud-viewport > .queen27-hud-idle { + position: relative; + z-index: 6; + grid-area: 2 / 1 / 3 / -1; + top: auto; + right: auto; + left: auto; + margin-top: var(--hud-gap); + } +} + /* ---- a round's resolution moment (loop cycle 004, P1-1) ------------------- */ .queen27-page.is-shell .queen27-hud-res-round.is-resolved, .queen27-page.is-shell .queen27-hud-round.is-resolved { @@ -6588,7 +6642,7 @@ body.queen-shell .queen-starfield-source { markup rather than hidden here — four previews each booting Babylon is four more WebGL contexts on one page. */ .queen27-page.is-shell.is-embed .queen27-hud-viewport - > :is(.queen27-hud-top, .queen27-hud-command, .queen27-hud-intel, .queen27-hud-vp-head, .queen27-hud-bottom, .queen27-context-chip) { + > :is(.queen27-hud-top, .queen27-hud-command, .queen27-hud-intel, .queen27-hud-vp-head, .queen27-hud-idle, .queen27-hud-bottom, .queen27-context-chip) { display: none; } diff --git a/apps/website/src/pages/Queen.tsx b/apps/website/src/pages/Queen.tsx index 4f205e4b89..f8aba60ac2 100644 --- a/apps/website/src/pages/Queen.tsx +++ b/apps/website/src/pages/Queen.tsx @@ -27,6 +27,9 @@ import { rewriteEndpoints, roundStrip, skipReasonWords, + skipCounts, + idleReason, + idleLine, latestEventFor, sectorRows, type CombHandle, @@ -161,6 +164,8 @@ interface QueenStatus { status: "ok"; /** The swarm's own word for its state on the wire (working, idle, …). */ swarmState?: string | null; + /** Paid worker slots: configured capacity and the started, unfinished bees. */ + workers?: { capacity: number; active: number; idle: number } | null; scheduler: { enabled: boolean; intervalSeconds: number; @@ -170,7 +175,8 @@ interface QueenStatus { allowed: boolean; refusal: string | null; skippedCount: number; - skipSummary?: Record; + /** Per category { count, issues, more }; older servers sent a bare count. */ + skipSummary?: Record; } | null; dispatches: { total: number; @@ -265,7 +271,20 @@ const COPY = { swarmIdle: "IDLE", swarmPaused: "PAUSED", swarmUnknown: "STATE —", - hudReady: "ready", + hudNoVerdict: "finished, no verdict", + idleNothing: "nothing to choose", + idleOf: "of", + idleIssues: "issues", + idleStale: "round stale", + idleStaleDetail: "last decision {age} ago, rounds every {interval}", + idleMissingBoundary: "lack a ## Boundary", + idleClaimed: "claimed", + idleCompleted: "done but open", + idleFileConflict: "touch held files", + idleIncompleteSpec: "incomplete spec", + idleNotFirst: "not first", + idleOther: "other", + idleExample: "example of an issue bees can take", unavailable: "BACKEND UNAVAILABLE", checking: "CHECKING BACKEND", scheduler: "Scheduler", @@ -607,7 +626,20 @@ const COPY = { swarmIdle: "ЖДЁТ", swarmPaused: "ПАУЗА", swarmUnknown: "СОСТОЯНИЕ —", - hudReady: "готово", + hudNoVerdict: "без вердикта", + idleNothing: "нечего выбрать", + idleOf: "из", + idleIssues: "задач", + idleStale: "раунд устарел", + idleStaleDetail: "последнее решение {age} назад, раунды каждые {interval}", + idleMissingBoundary: "без ## Boundary", + idleClaimed: "заняты", + idleCompleted: "сделаны и не закрыты", + idleFileConflict: "задевают занятые файлы", + idleIncompleteSpec: "спека неполная", + idleNotFirst: "не первые", + idleOther: "прочие", + idleExample: "пример задачи, которую пчёлы могут взять", unavailable: "BACKEND НЕДОСТУПЕН", checking: "ПРОВЕРЯЮ BACKEND", scheduler: "Планировщик", @@ -2360,6 +2392,32 @@ export default function Queen({sharedCatalog}:{sharedCatalog?:UniverseAtlas}={}) const decisionInfo = decision ? decisionDetail(decision, data?.dispatches.running ?? null, latest?.issue ?? null, c) : null; + // Why free slots are idle, from the status already fetched, on the server's + // clock. Measured 2026-09-15: BEES 0/4 was read as broken bees while the + // round said "nothing to choose" and 449 of 488 issues had no ## Boundary. + const idleNow = idleReason(data, now + (state.offsetMs ?? 0)); + const idleWhy = idleNow + ? idleLine(idleNow, { + idle: c.factoryIdle, + nothingToChoose: c.idleNothing, + of: c.idleOf, + issues: c.idleIssues, + stale: c.idleStale, + staleDetail: c.idleStaleDetail, + unitS: c.unitS, + unitMin: c.unitMin, + unitH: c.unitH, + reasons: { + missingBoundary: c.idleMissingBoundary, + claimed: c.idleClaimed, + completed: c.idleCompleted, + fileConflict: c.idleFileConflict, + incompleteSpec: c.idleIncompleteSpec, + notFirst: c.idleNotFirst, + other: c.idleOther, + }, + }) + : null; // wire field first (P1-18): the refusal or what the round did leads, the // verb follows, so a narrow gold block cuts the verb, never the reason @@ -2519,7 +2577,7 @@ export default function Queen({sharedCatalog}:{sharedCatalog?:UniverseAtlas}={}) ? c.unavailable : c.checking; const statusTone = isLive ? "is-live" : state.kind === "error" ? "is-cold" : "is-muted"; - const skipEntries = Object.entries(decision?.skipSummary ?? {}); + const skipEntries = skipCounts(decision?.skipSummary) ?? []; // The feed used to be a second list beside the Queen, saying the same things // she now says in her own log — and it left her 90px of a column she is meant @@ -2606,7 +2664,7 @@ export default function Queen({sharedCatalog}:{sharedCatalog?:UniverseAtlas}={}) )} {skipEntries.length > 0 && (
    - {skipEntries.map(([reason, count]) => ( + {skipEntries.map(({ key: reason, count }) => (
  • {count} {skipReasonWords(reason)}
  • @@ -2845,6 +2903,19 @@ export default function Queen({sharedCatalog}:{sharedCatalog?:UniverseAtlas}={}) + {idleWhy && ( +

    + {idleWhy.text} + {idleWhy.example && ( + <> + {" · "} + + {c.idleExample} + + + )} +

    + )}
    {/* Embedded, the scene is skipped — a page of previews would be a page of WebGL contexts — except on the comb, where the scene IS the @@ -3076,8 +3147,8 @@ export default function Queen({sharedCatalog}:{sharedCatalog?:UniverseAtlas}={}) {data ? data.dispatches.running : "—"}/{workers?.capacity ?? "—"} - - {workers?.idle ?? "—"} {c.factoryIdle} + + {idleWhy ? idleWhy.head : `${workers?.idle ?? "—"} ${c.factoryIdle}`}
    @@ -3096,7 +3167,7 @@ export default function Queen({sharedCatalog}:{sharedCatalog?:UniverseAtlas}={}) {pulse?.verdicts ?? "—"} {c.hud24h} · {board ? `${reviewCards.length} ${reviewColumnTitle}` : "—"} - {typeof data?.dispatches.unreviewed === "number" ? ` · ${data.dispatches.unreviewed} ${c.hudReady}` : ""} + {typeof data?.dispatches.unreviewed === "number" ? ` · ${data.dispatches.unreviewed} ${c.hudNoVerdict}` : ""} From 0d1194eae4bd680f1c15866023bf4261a16b77d9 Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Wed, 16 Sep 2026 01:14:31 +0700 Subject: [PATCH 2/3] fix(website): the idle line counts issues, not skip lines, and only when live Review of the Queen idle line (f64af31b01) found its words claimed more than /queen/status carries. - skippedCount counts skip LINES. queend (main.swift, choose) files "delegatable but ..." and goes on judging the same issue, which in a round that chose nothing always files " held by " next. The line listed incompleteSpec as a cause and called skippedCount "issues": a refused round of 469 issues, 30 of them bounded but incomplete and held, read "30 incomplete spec" of "499 issues". incompleteSpec is now left out of the reasons and subtracted from "checked"; more incompleteSpec than fileConflict lines contradicts the decider and prints the refusal alone. - skipCounts() returns null when the counts do not sum to skippedCount (the server's guarantee) and files keys outside the server's closed category set under "other" instead of making words from their names. - Counts follow labels ("no ## Boundary: 449", "zanyaty: 1"), so no count has to agree with a word: "1 issues", "iz 21 zadach" and "1 zanyaty" were wrong for counts ending in 1 and 2 to 4. - Another refusal keeps the wire's text in the line and title; the BEES tile head says "round refused", so its two-line clamp cuts nothing. - The idle reason is read only when state.kind is "ready". After a failed fetch the hook keeps the last data, and a kept round aged into "round stale", blaming the scheduler for a page that could not see the server. - The BEES tile reads bees, slots and free slots from /queen/status's workers alone; it mixed dispatches.running and research's capacity with status's free slots ("0/10" over "4 idle"). The old reading stays when the status carries no workers. - The link to t27#3587 is labelled as a format ("an issue written the way bees can take it") and shows only when some issue lacks a ## Boundary; #3587 itself was claimed in the snapshot that shows it. qa/queen-idle-reason-contract.mjs: 71 checks, including a round built from queend's own sentences filed as the server files them, counts 1, 21 and 3 in both languages, and source checks for the live gate and the tile. HEAD's reader and page fail 40 of them; eleven single-fix mutants each fail at least one. Co-Authored-By: Claude Opus 5 --- .../website/qa/queen-idle-reason-contract.mjs | 125 ++++++++++++++---- apps/website/src/components/queenHud.ts | 86 +++++++----- apps/website/src/pages/Queen.tsx | 37 +++--- 3 files changed, 170 insertions(+), 78 deletions(-) diff --git a/apps/website/qa/queen-idle-reason-contract.mjs b/apps/website/qa/queen-idle-reason-contract.mjs index 346bee9b0e..b039816983 100644 --- a/apps/website/qa/queen-idle-reason-contract.mjs +++ b/apps/website/qa/queen-idle-reason-contract.mjs @@ -3,8 +3,9 @@ // "nothing to choose" - 449 of 488 candidates had no ## Boundary. The status // the page already fetched carried the reason; the HUD did not say it. This // runs the pure reader and formatter on real /queen/status snapshots -// (qa/fixtures/queen-status) and on malformed ones, then checks the page uses -// them. Pure, no browser. Node 22 needs --experimental-strip-types. +// (qa/fixtures/queen-status), on a round built from queend's own sentences and +// on malformed ones, then checks the page uses them. Pure, no browser. Node 22 +// needs --experimental-strip-types. The layout is qa/queen-idle-layout-contract.mjs. import { readFileSync } from "node:fs"; import { idleLine, idleReason, skipCounts } from "../src/components/queenHud.ts"; @@ -16,29 +17,32 @@ const at = (status, seconds) => Date.parse(status.lastTick.decidedAt) + seconds const clone = (value) => structuredClone(value); const EN = { - idle: "idle", nothingToChoose: "nothing to choose", of: "of", issues: "issues", + idle: "idle", nothingToChoose: "nothing to choose", refused: "round refused", checked: "checked", stale: "round stale", staleDetail: "last decision {age} ago, rounds every {interval}", unitS: "s", unitMin: "min", unitH: "h", - reasons: { missingBoundary: "lack a ## Boundary", claimed: "claimed", completed: "done but open", fileConflict: "touch held files", incompleteSpec: "incomplete spec", notFirst: "not first", other: "other" }, + reasons: { missingBoundary: "no ## Boundary", claimed: "claimed", completed: "done but open", fileConflict: "touch held files", notFirst: "not first", other: "other" }, }; const RU = { - idle: "свободно", nothingToChoose: "нечего выбрать", of: "из", issues: "задач", + idle: "свободно", nothingToChoose: "нечего выбрать", refused: "раунд отказал", checked: "проверено", stale: "раунд устарел", staleDetail: "последнее решение {age} назад, раунды каждые {interval}", unitS: "с", unitMin: "мин", unitH: "ч", - reasons: { missingBoundary: "без ## Boundary", claimed: "заняты", completed: "сделаны и не закрыты", fileConflict: "задевают занятые файлы", incompleteSpec: "спека неполная", notFirst: "не первые", other: "прочие" }, + reasons: { missingBoundary: "без ## Boundary", claimed: "заняты", completed: "сделаны и не закрыты", fileConflict: "задевают занятые файлы", notFirst: "не первые", other: "прочие" }, }; const line = (status, nowMs, words = EN) => { const r = idleReason(status, nowMs); return r ? idleLine(r, words) : null; }; +// No count is followed by a word, so none has to agree with one (21 задача, 3 задачи, 1 issue). +const agreesWithNothing = (tail) => !/\d+ [A-Za-zА-яЁё]/.test(tail ?? ""); // 1. the real healthy_idle snapshot: every number comes from the file const idle = fixture("healthy-idle-2026-09-15T1250Z.json"); const t = idle.lastTick, sum = t.skipSummary, free = idle.workers.capacity - idle.workers.active; const en = line(idle, at(idle, 60)); -check(en !== null && en.text === `${free} idle: nothing to choose — ${sum.missingBoundary.count} of ${t.skippedCount} issues lack a ## Boundary, ${sum.completed.count} done but open, ${sum.claimed.count} claimed`, `healthy_idle (en): the refusal, then the three reasons by size, numbers from the wire (got "${en?.text}")`); -check(en?.text === "4 idle: nothing to choose — 449 of 488 issues lack a ## Boundary, 27 done but open, 12 claimed", `healthy_idle (en) literal, so a reader sees the sentence the owner will see (got "${en?.text}")`); -check(en?.head === "4 idle: nothing to choose" && en?.example === true, "healthy_idle: the tile's short head is the free count and the refusal; the example link is offered"); +check(en !== null && en.text === `${free} idle: nothing to choose — checked: ${t.skippedCount}; no ## Boundary: ${sum.missingBoundary.count}, done but open: ${sum.completed.count}, claimed: ${sum.claimed.count}`, `healthy_idle (en): the refusal, how many were checked, then the three reasons by size, numbers from the wire (got "${en?.text}")`); +check(en?.text === "4 idle: nothing to choose — checked: 488; no ## Boundary: 449, done but open: 27, claimed: 12", `healthy_idle (en) literal, so a reader sees the sentence the owner will see (got "${en?.text}")`); +check(en?.head === "4 idle: nothing to choose" && en?.example === true, "healthy_idle: the tile's short head is the free count and the refusal; 449 without a boundary, so the format example is offered"); const ru = line(idle, at(idle, 60), RU); -check(ru?.text === "4 свободно: нечего выбрать — 449 из 488 задач без ## Boundary, 27 сделаны и не закрыты, 12 заняты", `healthy_idle (ru) (got "${ru?.text}")`); -check(!/\d/.test(RU.nothingToChoose + EN.nothingToChoose) && !/[А-яЁё]/.test(en?.text ?? ""), "no digit lives in the copy, no Cyrillic leaks into English"); +check(ru?.text === "4 свободно: нечего выбрать — проверено: 488; без ## Boundary: 449, сделаны и не закрыты: 27, заняты: 12", `healthy_idle (ru) (got "${ru?.text}")`); +check(!/\d/.test(JSON.stringify(EN) + JSON.stringify(RU)) && !/[А-яЁё]/.test(en?.text ?? ""), "no digit lives in the copy, no Cyrillic leaks into English"); +check(agreesWithNothing(en?.tail) && agreesWithNothing(ru?.tail), "healthy_idle: every count stands after its label"); // 2. working snapshots: the round dispatched, so there is no idle line const working = fixture("working-2026-09-15T1400Z.json"); @@ -47,9 +51,52 @@ check(line(working, at(working, 60)) === null, "working (allowed, 2 of 4 busy): check(line(cap12, at(cap12, 60)) === null, "working (allowed, capacity 12): no idle line"); // a real snapshot where one bee works and the round still refused: three slots are idle and it says why const busyRefused = fixture("working-refused-2026-09-15T1350Z.json"); -check(line(busyRefused, at(busyRefused, 60))?.text === "3 idle: nothing to choose — 448 of 487 issues lack a ## Boundary, 27 done but open, 12 claimed", `1 of 4 busy, round refused: the three idle slots are explained (got "${line(busyRefused, at(busyRefused, 60))?.text}")`); +check(line(busyRefused, at(busyRefused, 60))?.text === "3 idle: nothing to choose — checked: 487; no ## Boundary: 448, done but open: 27, claimed: 12", `1 of 4 busy, round refused: the three idle slots are explained (got "${line(busyRefused, at(busyRefused, 60))?.text}")`); -// 3. staleness: older than two intervals says the round is stale, never the reasons +// 3. a round built from queend's own sentences (main.swift, choose) and filed as +// the server files them (queen-public-status.ts, classifySkipReason). An issue +// with a boundary but an incomplete spec gets TWO lines: "delegatable but ..." +// and, since the round chose nothing, " held by ...". skippedCount counts lines. +const file = (l) => l.includes(" held by ") ? "fileConflict" + : /a worker already has it|claimed, but no worker is attached yet|spoken for by a task with no recorded state|so it is finished or waiting on you/.test(l) ? "claimed" + : l.includes("the work already landed") ? "completed" + : l.includes("no issue body was supplied") ? "missingBoundary" + : l.includes("delegatable but") ? "incompleteSpec" + : /not yet a spec|declares no boundary/.test(l) ? "missingBoundary" + : l.includes("not first") ? "notFirst" : "other"; +const refusedRound = ({ unbounded, claimed, done, heldIncomplete, heldExtra = 0 }) => { + const lines = []; let n = 1000; + for (let i = 0; i < unbounded; i++) lines.push(`#${n++}: not yet a spec - missing boundary, requirements`); + for (let i = 0; i < claimed; i++) lines.push(`#${n++}: a worker already has it (running)`); + for (let i = 0; i < done; i++) lines.push(`#${n++}: the work already landed (merged) - the issue is open and nobody closed it`); + for (let i = 0; i < heldIncomplete; i++) { const k = n++; lines.push(`#${k}: delegatable but not yet a spec - missing success criteria`); lines.push(`#${k}: rings/SR-00/A.swift held by gHashTag/trios#1286`); } + const s = clone(idle); const summary = {}; + for (const l of lines) { const c = file(l); summary[c] = { count: (summary[c]?.count ?? 0) + 1 }; } + for (let i = 0; i < heldExtra; i++) summary.incompleteSpec.count += 1, lines.push("#9: delegatable but (contradiction)"); + s.lastTick.skippedCount = lines.length; s.lastTick.skipSummary = summary; + return { status: s, issues: unbounded + claimed + done + heldIncomplete }; +}; +const twoLine = refusedRound({ unbounded: 400, claimed: 12, done: 27, heldIncomplete: 30 }); +const tl = line(twoLine.status, at(twoLine.status, 60)); +check(twoLine.status.lastTick.skippedCount === 499 && twoLine.issues === 469, "the built round: 499 skip lines for 469 issues"); +check(tl?.text === "4 idle: nothing to choose — checked: 469; no ## Boundary: 400, touch held files: 30, done but open: 27", `incompleteSpec is no reason (it never blocks) and not a second issue: checked 469, not 499 (got "${tl?.text}")`); +const tlRu = line(twoLine.status, at(twoLine.status, 60), RU); +check(tlRu?.text === "4 свободно: нечего выбрать — проверено: 469; без ## Boundary: 400, задевают занятые файлы: 30, сделаны и не закрыты: 27" && !/неполн|incomplete/.test(tl.text + tlRu.text), `the same in Russian, and no word for an incomplete spec in either (got "${tlRu?.text}")`); +const bigHeld = refusedRound({ unbounded: 72, claimed: 12, done: 15, heldIncomplete: 200 }); +check(line(bigHeld.status, at(bigHeld.status, 60))?.text === "4 idle: nothing to choose — checked: 299; touch held files: 200, no ## Boundary: 72, done but open: 15", `200 held and incomplete: named once, as held files (got "${line(bigHeld.status, at(bigHeld.status, 60))?.text}")`); +const contradicts = refusedRound({ unbounded: 400, claimed: 12, done: 27, heldIncomplete: 30, heldExtra: 1 }); +check(line(contradicts.status, at(contradicts.status, 60))?.text === "4 idle: nothing to choose", "more incompleteSpec than fileConflict lines in a round that chose nothing contradicts queend: the refusal alone"); + +// 4. counts of 1, 21 and 3 in both languages: labels first, so nothing to decline +const small = clone(idle); +small.lastTick.skippedCount = 25; +small.lastTick.skipSummary = { claimed: { count: 1 }, completed: { count: 3 }, missingBoundary: { count: 21 } }; +const smallEn = line(small, at(small, 60)), smallRu = line(small, at(small, 60), RU); +check(smallEn?.text === "4 idle: nothing to choose — checked: 25; no ## Boundary: 21, done but open: 3, claimed: 1", `1, 21 and 3 in English (got "${smallEn?.text}")`); +check(smallRu?.text === "4 свободно: нечего выбрать — проверено: 25; без ## Boundary: 21, сделаны и не закрыты: 3, заняты: 1", `1, 21 and 3 in Russian (got "${smallRu?.text}")`); +check(agreesWithNothing(smallEn?.tail) && agreesWithNothing(smallRu?.tail), "1, 21 and 3: no count followed by a word in either language"); + +// 5. staleness: older than two intervals says the round is stale, never the reasons check(line(idle, at(idle, 600))?.head === "4 idle: nothing to choose", "exactly two intervals old is still fresh (the server's own bound)"); const stale = line(idle, at(idle, 601)); check(stale?.text === "4 idle: round stale — last decision 10 min ago, rounds every 5 min" && stale.example === false, `601 s past a 300 s round: stale, no reasons (got "${stale?.text}")`); @@ -57,7 +104,7 @@ check(line(idle, at(idle, 11 * 3600 + 5), RU)?.text === "4 свободно: р check(line(cap12, at(cap12, 3600))?.text === "11 idle: round stale — last decision 60 min ago, rounds every 5 min", "a stale ALLOW with idle slots is stale too: its dispatch is no longer news"); check(line(idle, at(idle, 90))?.text.startsWith("4 idle: nothing to choose") && line(idle, at(idle, -30)) !== null, "a tick a little in the future (clock skew) is fresh, not negative"); -// 4. missing or malformed skipSummary: the refusal alone, no fabricated zeros +// 6. missing or malformed skipSummary: the refusal alone, no fabricated zeros, no example const without = (mutate) => { const s = clone(idle); mutate(s); return line(s, at(s, 60)); }; const bare = "4 idle: nothing to choose"; for (const [label, mutate] of [ @@ -70,28 +117,43 @@ for (const [label, mutate] of [ ["a fractional count", (s) => { s.lastTick.skipSummary.completed.count = 27.5; }], ["an entry without count", (s) => { s.lastTick.skipSummary.claimed = { issues: [1] }; }], ["a count above skippedCount", (s) => { s.lastTick.skipSummary.missingBoundary.count = 489; }], + ["counts summing past skippedCount (449 three times of 488)", (s) => { s.lastTick.skipSummary.claimed.count = 449; s.lastTick.skipSummary.completed.count = 449; }], + ["counts summing short of skippedCount", (s) => { s.lastTick.skipSummary.missingBoundary.count = 400; }], ["empty", (s) => { s.lastTick.skipSummary = {}; }], ["all zero", (s) => { s.lastTick.skipSummary = { claimed: { count: 0, issues: [], more: 0 } }; }], ]) { const got = without(mutate); - check(got?.text === bare && got.tail === null && got.example === true, `skipSummary ${label}: "${bare}" and nothing about reasons (got "${got?.text}")`); + check(got?.text === bare && got.tail === null && got.example === false, `skipSummary ${label}: "${bare}", nothing about reasons, no example (got "${got?.text}")`); } -check(without((s) => { delete s.lastTick.skippedCount; })?.text === "4 idle: nothing to choose — 449 issues lack a ## Boundary, 27 done but open, 12 claimed", "no skippedCount: the counts stand, no 'of N' is invented"); -check(skipCounts({ claimed: 17, missingBoundary: 2 }, 33)?.map((r) => `${r.key}=${r.count}`).join(",") === "claimed=17,missingBoundary=2", "the older wire shape (bare numbers) reads as the same counts, in wire order"); +check(without((s) => { delete s.lastTick.skippedCount; })?.text === "4 idle: nothing to choose — no ## Boundary: 449, done but open: 27, claimed: 12", "no skippedCount: the counts stand, no 'checked' is invented"); +const alien = without((s) => { s.lastTick.skipSummary = { claimed: { count: 12 }, toString: { count: 449 }, completed: { count: 27 } }; }); +check(alien?.text === "4 idle: nothing to choose — checked: 488; other: 449, done but open: 27, claimed: 12" && alien.example === false, `a key outside the server's closed set is "other", never words made from its name, and offers no format example (got "${alien?.text}")`); +const noBoundary = without((s) => { s.lastTick.skippedCount = 39; s.lastTick.skipSummary = { claimed: { count: 12 }, completed: { count: 27 } }; }); +check(noBoundary?.text === "4 idle: nothing to choose — checked: 39; done but open: 27, claimed: 12" && noBoundary.example === false, "every candidate bounded but claimed or done: no format example, since no boundary is missing"); +check(skipCounts({ claimed: 17, completed: 6, missingBoundary: 2, fileConflict: 1, incompleteSpec: 1, notFirst: 6 }, 33)?.map((r) => `${r.key}=${r.count}`).join(",") === "claimed=17,completed=6,missingBoundary=2,fileConflict=1,incompleteSpec=1,notFirst=6", "the older wire shape (bare numbers) reads as the same counts, in wire order"); +check(skipCounts({ claimed: 17, missingBoundary: 2 }, 33) === null && skipCounts({ claimed: 17, missingBoundary: 2 })?.length === 2, "counts that do not sum to skippedCount are null; with no skippedCount there is nothing to sum against"); check(skipCounts(idle.lastTick.skipSummary, 488)?.map((r) => r.count).join(",") === "12,27,449" && skipCounts(undefined) === null && skipCounts({ x: { count: 1 } }, 0) === null, "skipCounts: wire order; absent or contradictory is null"); +check(skipCounts({ oddNewReason: { count: 1 }, claimed: { count: 4 }, other: { count: 2 } }, 7)?.map((r) => `${r.key}=${r.count}`).join(",") === "other=3,claimed=4", "unknown keys fold into one 'other', at the place the first of them stood"); const many = clone(idle); -many.lastTick.skipSummary = { notFirst: { count: 3 }, claimed: { count: 40 }, other: { count: 0 }, fileConflict: { count: 40 }, oddNewReason: { count: 41 } }; -check(line(many, at(many, 60))?.tail === "41 of 488 issues odd new reason, 40 claimed, 40 touch held files", `only the three largest, ties in wire order, an unknown key spaced not invented (got "${line(many, at(many, 60))?.tail}")`); +many.lastTick.skippedCount = 124; +many.lastTick.skipSummary = { claimed: { count: 40 }, other: { count: 0 }, fileConflict: { count: 40 }, oddNewReason: { count: 41 }, missingBoundary: { count: 3 } }; +const manyLine = line(many, at(many, 60)); +check(manyLine?.tail === "checked: 124; other: 41, claimed: 40, touch held files: 40" && manyLine.example === true, `only the three largest, ties in wire order; 3 without a boundary still offer the format example (got "${manyLine?.tail}")`); -// 5. other refusals: the refusal text itself, no reasons, no example +// 7. other refusals: the refusal text itself in the line, a short word in the tile const refusing = (refusal, workers) => { const s = clone(idle); s.lastTick.refusal = refusal; if (workers) s.workers = { ...s.workers, ...workers }; return line(s, at(s, 60)); }; -check(refusing("no registry mirror published yet")?.text === "4 idle: no registry mirror published yet", "no registry mirror: verbatim"); +const mirror = refusing("no registry mirror published yet"); +check(mirror?.text === "4 idle: no registry mirror published yet" && mirror.head === "4 idle: round refused", "no registry mirror: verbatim in the line, 'round refused' in the tile"); const capacity = refusing("4 workers already running (limit 4)", { capacity: 12, active: 4 }); check(capacity?.text === "8 idle: 4 workers already running (limit 4)" && capacity.tail === null && capacity.example === false, `the policy limit under a larger capacity: verbatim, eight slots idle (got "${capacity?.text}")`); +const dailyCap = "the swarm has spent about $41.20 today, $21.20 past its $20.00 daily limit (raise it with TRIOS_DAILY_USD_CAP)"; +const cap = refusing(dailyCap); +check(cap?.text === `4 idle: ${dailyCap}` && cap.head === "4 idle: round refused" && cap.head.length < 30, "a long refusal stays whole in the line and title; the tile's head stays short, so its two-line clamp cuts nothing"); const s1 = clone(idle); s1.lastTick.refusal = "daily cap reached: $20.00 of $20.00"; -check(line(s1, at(s1, 60), RU)?.text === "4 свободно: daily cap reached: $20.00 of $20.00", "the daily cap in Russian: the wire's words, not a translation guessed from them"); +const s1Ru = line(s1, at(s1, 60), RU); +check(s1Ru?.text === "4 свободно: daily cap reached: $20.00 of $20.00" && s1Ru.head === "4 свободно: раунд отказал", "the daily cap in Russian: the wire's words, not a translation guessed from them"); -// 6. nothing to explain, or nothing trustworthy to explain it with +// 8. nothing to explain, or nothing trustworthy to explain it with const gate = (mutate) => { const s = clone(idle); mutate(s); return line(s, at(idle, 60)); }; for (const [label, value] of [ ["null status", line(null, 0)], ["a string status", line("ok", 0)], ["an empty object", line({}, 0)], @@ -108,17 +170,22 @@ for (const [label, value] of [ ["no capacity", gate((s) => { s.workers = { capacity: 0, active: 0, idle: 0, utilization: 0 }; })], ]) check(value === null, `${label}: no line`); -// 7. the page uses the reader, and says what "ready" counted +// 9. the page: a live read only, one response for the tile, the words above const src = readFileSync(new URL("../src/pages/Queen.tsx", import.meta.url), "utf8"); -check(/idleReason\(data, now \+ \(state\.offsetMs \?\? 0\)\)/.test(src) && /idleLine\(/.test(src), "Queen.tsx reads the idle reason from the status it already fetched, on the server's clock"); -check(/className="queen27-hud-idle"/.test(src) && /href="https:\/\/github\.com\/gHashTag\/t27\/issues\/3587"/.test(src), "the line has its own element and links the example work order"); -check(/idleExample:\s*"example of an issue bees can take"/.test(src) && /idleExample:\s*"пример задачи, которую пчёлы могут взять"/.test(src), "the link is labelled as an example in both languages"); +check(/const isLive = state\.kind === "ready";/.test(src) && /const idleNow = isLive \? idleReason\(data, now \+ \(state\.offsetMs \?\? 0\)\) : null;/.test(src) && /idleLine\(/.test(src), "Queen.tsx reads the idle reason only from a live status (a kept copy after a failed fetch says nothing), on the server's clock"); +check((src.match(/idleReason\(/g) || []).length === 1, "no second, ungated call of idleReason"); +check(/className="queen27-hud-idle"/.test(src) && /\{idleWhy\.example && \(/.test(src) && /href="https:\/\/github\.com\/gHashTag\/t27\/issues\/3587"/.test(src), "the line has its own element; the format example is behind idleWhy.example"); +check(/idleExample:\s*"an issue written the way bees can take it"/.test(src) && /idleExample:\s*"задача в формате, который пчёлы берут"/.test(src), "the link is labelled as a format, not as an issue bees can take now, in both languages"); +check(/idleRefused:\s*"round refused"/.test(src) && /idleRefused:\s*"раунд отказал"/.test(src) && /idleChecked:\s*"checked"/.test(src) && /idleChecked:\s*"проверено"/.test(src) && /idleMissingBoundary:\s*"no ## Boundary"/.test(src) && /idleMissingBoundary:\s*"без ## Boundary"/.test(src), "the page's words are the ones this contract formats with"); +check(!/idleIncompleteSpec|idleOf:|idleIssues:/.test(src), "no word for incompleteSpec in the idle copy, and no 'issues' noun for a count of lines"); +check(/data\?\.workers\s*\?\s*`\$\{data\.workers\.active\}\/\$\{data\.workers\.capacity\}`/.test(src) && /data\?\.workers \? data\.workers\.capacity - data\.workers\.active :/.test(src), "the BEES tile reads bees, slots and free slots from /queen/status alone when it carries workers"); check(/skipCounts\(decision\?\.skipSummary\)/.test(src) && !/skipEntries\.map\(\(\[reason, count\]\)/.test(src), "the round popover lists counts read through skipCounts: the wire's {count, issues, more} objects crashed it (React #31)"); check(/hudNoVerdict:\s*"finished, no verdict"/.test(src) && /hudNoVerdict:\s*"без вердикта"/.test(src) && !/hudReady/.test(src), "dispatches.unreviewed is named for what it counts: finished rows with no verdict, not issues ready"); // self-test: a changed wire number changes the sentence, so the numbers are read, not written -const moved = clone(idle); moved.lastTick.skipSummary.missingBoundary.count = 448; -check(line(moved, at(moved, 60))?.text.includes("448 of 488") && !line(moved, at(moved, 60))?.text.includes("449"), "self-test: 448 in the wire reads 448 on the page"); +const moved = clone(idle); moved.lastTick.skipSummary.missingBoundary.count = 448; moved.lastTick.skippedCount = 487; +const movedText = line(moved, at(moved, 60))?.text ?? ""; +check(movedText.includes("checked: 487; no ## Boundary: 448") && !movedText.includes("449"), `self-test: 448 of 487 in the wire reads so on the page (got "${movedText}")`); if (fails.length) { for (const f of fails) console.log(" ✗ " + f); console.log(`Queen idle-reason contract: FAIL (${fails.length} of ${checks})`); process.exit(1); } console.log(`Queen idle-reason contract: PASS (${checks} checks)`); diff --git a/apps/website/src/components/queenHud.ts b/apps/website/src/components/queenHud.ts index 532a5547a1..64be318535 100644 --- a/apps/website/src/components/queenHud.ts +++ b/apps/website/src/components/queenHud.ts @@ -309,22 +309,32 @@ export interface SkipCount { key: string; count: number } const isCount = (value: unknown): value is number => typeof value === "number" && Number.isInteger(value) && value >= 0; +/** The server's closed category set (queen-public-status.ts, SKIP_CATEGORIES). */ +const SKIP_CATEGORIES = new Set(["claimed", "completed", "missingBoundary", "fileConflict", "incompleteSpec", "notFirst", "other"]); + /** * lastTick.skipSummary as counts in wire order. The wire sends * { count, issues, more } per category (older servers a bare number); only - * the count is read. Null when the summary is absent or any entry is - * unreadable, including a count above skippedCount: a malformed summary says + * the count is read. A key outside the server's closed set is filed under + * "other", as the server files a sentence it does not know. Null when the + * summary is absent, any entry is unreadable, or the counts do not sum to + * skippedCount, which the server guarantees they do: a malformed summary says * nothing, never a zero. */ export function skipCounts(summary: unknown, skipped: number | null = null): SkipCount[] | null { if (!summary || typeof summary !== "object" || Array.isArray(summary)) return null; const counts: SkipCount[] = []; - for (const [key, value] of Object.entries(summary)) { + let total = 0; + for (const [wireKey, value] of Object.entries(summary)) { const count = value && typeof value === "object" ? (value as { count?: unknown }).count : value; - if (!isCount(count) || (skipped !== null && count > skipped)) return null; - counts.push({ key, count }); + if (!isCount(count)) return null; + const key = SKIP_CATEGORIES.has(wireKey) ? wireKey : "other"; + const filed = counts.find((r) => r.key === key); + if (filed) filed.count += count; + else counts.push({ key, count }); + total += count; } - return counts; + return skipped !== null && total !== skipped ? null : counts; } /** The refusal queend writes when no candidate survives a round. */ @@ -332,7 +342,7 @@ const NOTHING_TO_CHOOSE = "nothing to choose"; export type IdleReason = | { kind: "stale"; free: number; ageSeconds: number; intervalSeconds: number } - | { kind: "refused"; free: number; refusal: string; skipped: number | null; counts: SkipCount[] | null }; + | { kind: "refused"; free: number; refusal: string; checked: number | null; counts: SkipCount[] | null }; /** * Why free worker slots started nothing, from /queen/status alone. Measured @@ -364,23 +374,36 @@ export function idleReason(status: unknown, serverNowMs: number): IdleReason | n return { kind: "stale", free, ageSeconds: Math.floor(ageMs / 1000), intervalSeconds: interval }; } if (lastTick.allowed || typeof lastTick.refusal !== "string" || !lastTick.refusal.trim()) return null; + if (lastTick.refusal !== NOTHING_TO_CHOOSE) return { kind: "refused", free, refusal: lastTick.refusal, checked: null, counts: null }; + // skippedCount counts skip LINES, not issues. queend (main.swift, choose) + // files "delegatable but ..." and goes on judging the same issue; in a round + // that chose nothing, that issue always files " held by " next. So an + // incompleteSpec line never blocked anything and counts its issue twice: it + // is left out of the reasons and out of how many issues were checked. More + // of them than fileConflict lines contradicts the decider, and says nothing. const skipped = isCount(lastTick.skippedCount) ? lastTick.skippedCount : null; - const counts = lastTick.refusal === NOTHING_TO_CHOOSE ? skipCounts(lastTick.skipSummary, skipped) : null; - return { kind: "refused", free, refusal: lastTick.refusal, skipped, counts }; + const read = skipCounts(lastTick.skipSummary, skipped); + const countOf = (key: string) => read?.find((r) => r.key === key)?.count ?? 0; + const incomplete = countOf("incompleteSpec"); + const counts = read && incomplete <= countOf("fileConflict") ? read.filter((r) => r.key !== "incompleteSpec") : null; + const checked = counts && skipped !== null ? skipped - incomplete : null; + return { kind: "refused", free, refusal: lastTick.refusal, checked, counts }; } export interface IdleWords { idle: string; nothingToChoose: string; - of: string; - issues: string; + /** The tile's word for any other refusal; the line keeps the wire's text. */ + refused: string; + /** Label for how many issues the round checked. */ + checked: string; stale: string; /** With {age} and {interval}. */ staleDetail: string; unitS: string; unitMin: string; unitH: string; - /** Words per skip category; an unknown category is spaced, not invented. */ + /** A label per skip category, printed "label: count", so no count has to agree with a word. */ reasons: Record; } @@ -394,39 +417,34 @@ function spanWords(seconds: number, words: IdleWords): string { /** * The idle reason as one line. The head is short enough for the BEES tile: - * the free slots and the refusal, or "round stale". The tail is what the wire - * counted: the three largest skip reasons, or the stale tick's age. A refusal - * prints as the wire wrote it; only queend's fixed "nothing to choose" has - * words of its own. The example work order is offered only when nothing could - * be chosen. + * the free slots and "nothing to choose", "round refused" or "round stale". + * The tail is what the wire counted: how many issues the round checked and + * the three largest skip reasons, or the stale tick's age. Any other refusal + * prints in the line as the wire wrote it; only queend's fixed "nothing to + * choose" has words of its own. The format example is offered only when + * issues were skipped for having no ## Boundary. */ export function idleLine(reason: IdleReason, words: IdleWords): IdleLine { const lead = `${reason.free} ${words.idle}`; - let head: string; - let tail: string | null; if (reason.kind === "stale") { - head = `${lead}: ${words.stale}`; - tail = words.staleDetail + const head = `${lead}: ${words.stale}`; + const tail = words.staleDetail .replace("{age}", spanWords(reason.ageSeconds, words)) .replace("{interval}", spanWords(reason.intervalSeconds, words)); - } else { - head = `${lead}: ${reason.refusal === NOTHING_TO_CHOOSE ? words.nothingToChoose : reason.refusal}`; - const top = (reason.counts ?? []).filter((r) => r.count > 0).sort((a, b) => b.count - a.count).slice(0, 3); - tail = top.length === 0 - ? null - : top - .map((r, i) => { - const n = i > 0 ? `${r.count}` : reason.skipped !== null ? `${r.count} ${words.of} ${reason.skipped} ${words.issues}` : `${r.count} ${words.issues}`; - const said = Object.prototype.hasOwnProperty.call(words.reasons, r.key) ? words.reasons[r.key] : skipReasonWords(r.key); - return `${n} ${said}`; - }) - .join(", "); + return { head, tail, text: `${head} — ${tail}`, example: false }; + } + if (reason.refusal !== NOTHING_TO_CHOOSE) { + return { head: `${lead}: ${words.refused}`, tail: null, text: `${lead}: ${reason.refusal}`, example: false }; } + const head = `${lead}: ${words.nothingToChoose}`; + const top = (reason.counts ?? []).filter((r) => r.count > 0).sort((a, b) => b.count - a.count).slice(0, 3); + const said = top.map((r) => `${words.reasons[r.key] ?? words.reasons.other}: ${r.count}`).join(", "); + const tail = top.length === 0 ? null : reason.checked !== null ? `${words.checked}: ${reason.checked}; ${said}` : said; return { head, tail, text: tail ? `${head} — ${tail}` : head, - example: reason.kind === "refused" && reason.refusal === NOTHING_TO_CHOOSE, + example: (reason.counts ?? []).some((r) => r.key === "missingBoundary" && r.count > 0), }; } diff --git a/apps/website/src/pages/Queen.tsx b/apps/website/src/pages/Queen.tsx index f8aba60ac2..4bdc8d3b2d 100644 --- a/apps/website/src/pages/Queen.tsx +++ b/apps/website/src/pages/Queen.tsx @@ -273,18 +273,17 @@ const COPY = { swarmUnknown: "STATE —", hudNoVerdict: "finished, no verdict", idleNothing: "nothing to choose", - idleOf: "of", - idleIssues: "issues", + idleRefused: "round refused", + idleChecked: "checked", idleStale: "round stale", idleStaleDetail: "last decision {age} ago, rounds every {interval}", - idleMissingBoundary: "lack a ## Boundary", + idleMissingBoundary: "no ## Boundary", idleClaimed: "claimed", idleCompleted: "done but open", idleFileConflict: "touch held files", - idleIncompleteSpec: "incomplete spec", idleNotFirst: "not first", idleOther: "other", - idleExample: "example of an issue bees can take", + idleExample: "an issue written the way bees can take it", unavailable: "BACKEND UNAVAILABLE", checking: "CHECKING BACKEND", scheduler: "Scheduler", @@ -628,18 +627,17 @@ const COPY = { swarmUnknown: "СОСТОЯНИЕ —", hudNoVerdict: "без вердикта", idleNothing: "нечего выбрать", - idleOf: "из", - idleIssues: "задач", + idleRefused: "раунд отказал", + idleChecked: "проверено", idleStale: "раунд устарел", idleStaleDetail: "последнее решение {age} назад, раунды каждые {interval}", idleMissingBoundary: "без ## Boundary", idleClaimed: "заняты", idleCompleted: "сделаны и не закрыты", idleFileConflict: "задевают занятые файлы", - idleIncompleteSpec: "спека неполная", idleNotFirst: "не первые", idleOther: "прочие", - idleExample: "пример задачи, которую пчёлы могут взять", + idleExample: "задача в формате, который пчёлы берут", unavailable: "BACKEND НЕДОСТУПЕН", checking: "ПРОВЕРЯЮ BACKEND", scheduler: "Планировщик", @@ -2395,13 +2393,16 @@ export default function Queen({sharedCatalog}:{sharedCatalog?:UniverseAtlas}={}) // Why free slots are idle, from the status already fetched, on the server's // clock. Measured 2026-09-15: BEES 0/4 was read as broken bees while the // round said "nothing to choose" and 449 of 488 issues had no ## Boundary. - const idleNow = idleReason(data, now + (state.offsetMs ?? 0)); + // Only on a live read: after a failed fetch the hook keeps the last data, + // and a kept round would age into "round stale", blaming the scheduler for + // a page that cannot see the server. + const idleNow = isLive ? idleReason(data, now + (state.offsetMs ?? 0)) : null; const idleWhy = idleNow ? idleLine(idleNow, { idle: c.factoryIdle, nothingToChoose: c.idleNothing, - of: c.idleOf, - issues: c.idleIssues, + refused: c.idleRefused, + checked: c.idleChecked, stale: c.idleStale, staleDetail: c.idleStaleDetail, unitS: c.unitS, @@ -2412,7 +2413,6 @@ export default function Queen({sharedCatalog}:{sharedCatalog?:UniverseAtlas}={}) claimed: c.idleClaimed, completed: c.idleCompleted, fileConflict: c.idleFileConflict, - incompleteSpec: c.idleIncompleteSpec, notFirst: c.idleNotFirst, other: c.idleOther, }, @@ -3144,11 +3144,18 @@ export default function Queen({sharedCatalog}:{sharedCatalog?:UniverseAtlas}={})
    {c.hudBees} + {/* One response for the whole tile: /queen/status's started, unfinished + bees over its slots, the reading idleReason takes free slots from. + The research poll's reading only when the status carries none. */} - {data ? data.dispatches.running : "—"}/{workers?.capacity ?? "—"} + {data?.workers + ? `${data.workers.active}/${data.workers.capacity}` + : `${data ? data.dispatches.running : "—"}/${workers?.capacity ?? "—"}`} - {idleWhy ? idleWhy.head : `${workers?.idle ?? "—"} ${c.factoryIdle}`} + {idleWhy + ? idleWhy.head + : `${data?.workers ? data.workers.capacity - data.workers.active : (workers?.idle ?? "—")} ${c.factoryIdle}`}
    From f741b2f943e53f87756f58da0eec752e65a8f03a Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Wed, 16 Sep 2026 01:17:26 +0700 Subject: [PATCH 3/3] fix(website): the idle line takes no tap from the Queen's controls Measured with trusted input on the build served as https://t27.ai with the healthy-idle snapshot as /queen/status: - 390x844: the line was relative over the top of the body's row, so it covered TRI's screen tabs and the first disclosure of the specs, skills and crons views; a tap on TRI's Agent tab did nothing, and one on the specs directive opened the line's link. At 900 px and below (portrait, or taller than 500 px) the line is now a grid row of its own between the head and the body, and the body gives up its height. - 844x390: the head's tools spill below its 40 px band, and the line lay over all of them and the identity chip. On a landscape phone (height 500 px or less) the line is not drawn; the BEES tile's sub-line is hidden at that size too, so it says what it said before the line. - The collapsed rail's left: 84px outranked the phone rules and pushed the line out of a 390 px window after collapsing at 1440; it is desktop only. - Desktop: the line's top was the head's height guessed on 127.0.0.1 (40 px). On t27.ai the head is 42 px, and the line covered its bottom rule. It now starts at 42 px. qa/queen-idle-layout-contract.mjs (check:queen-idle-layout, browser, not in CI like the other Queen browser contracts): the line against the head at 1440x900 and 1280x720 in en and ru; inside a 390 px window after a collapse; between head and body on one screen at 390x844 with TRI's tabs and the specs disclosure topmost and working under trusted taps; no line and every map tool topmost and working at 844x390; no line once /queen/status fails. It fails on the previous build. Co-Authored-By: Claude Opus 5 --- apps/website/package.json | 1 + .../website/qa/queen-idle-layout-contract.mjs | 189 ++++++++++++++++++ apps/website/src/pages/Queen.css | 56 ++++-- 3 files changed, 232 insertions(+), 14 deletions(-) create mode 100644 apps/website/qa/queen-idle-layout-contract.mjs diff --git a/apps/website/package.json b/apps/website/package.json index cb4af8f861..8484e901de 100644 --- a/apps/website/package.json +++ b/apps/website/package.json @@ -45,6 +45,7 @@ "check:queen-viewport": "node qa/queen-viewport-contract.mjs", "check:queen-honesty": "node --experimental-strip-types qa/queen-honesty-contract.mjs", "check:queen-idle": "node --experimental-strip-types qa/queen-idle-reason-contract.mjs", + "check:queen-idle-layout": "node qa/queen-idle-layout-contract.mjs", "check:queen-coverage": "node --experimental-strip-types qa/queen-coverage-contract.mjs", "check:queen-displays": "node --experimental-strip-types qa/queen-hive-display-contract.mjs", "check:queen-worlds": "node --experimental-strip-types qa/queen-repository-worlds.mjs", diff --git a/apps/website/qa/queen-idle-layout-contract.mjs b/apps/website/qa/queen-idle-layout-contract.mjs new file mode 100644 index 0000000000..ab8a23b2c1 --- /dev/null +++ b/apps/website/qa/queen-idle-layout-contract.mjs @@ -0,0 +1,189 @@ +// Queen idle-line layout contract, in a real browser. The line that says why +// free bees are idle (queen27-hud-idle) sits among the map's controls, and in +// its first version it took taps it had no right to: on a phone it lay over +// TRI's screen tabs and the specs view's first disclosure, on a landscape phone +// over every map tool, and on a desktop over the head's bottom rule +// (measured 2026-09-16 with trusted input). The words are +// qa/queen-idle-reason-contract.mjs; this is where they are drawn. +// +// Builds the site (unless --no-build; --dist serves another build) and +// drives the installed Chrome over CDP. The build is served AS https://t27.ai +// through CDP Fetch, because the identity chip, and so the head's height, is +// only its real size there; /queen/status is answered with the committed +// healthy-idle snapshot, decidedAt moved to 30 s ago on every poll. Every tap +// claimed below is trusted CDP input after a hit test. +// 1 1440x900 and 1280x720, en and ru: the line is on screen, unclipped, and +// starts at or below the head's bottom (1 px of rounding) +// 2 1440x900 with the rail collapsed by a trusted click, then 390x844: the +// line is inside the window +// 3 390x844, en and ru: the line lies between the head and the body, the page +// is one screen, and TRI's screen tabs and the specs view's first +// disclosure are topmost at all five sample points; in en a trusted tap on +// TRI's second tab switches it, and one on the disclosure opens it +// 4 844x390: no line is drawn, every map tool and the identity chip inside the +// window is topmost, and a trusted tap on the foundation layer toggles it +// 5 /queen/status starts failing: the line goes, though the page keeps the +// last status it read (a kept round would age into "round stale") +// node qa/queen-idle-layout-contract.mjs [--no-build] [--dist ] +import { execSync, spawn } from 'node:child_process'; +import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { extname, join, normalize, resolve } from 'node:path'; + +const ROOT = new URL('..', import.meta.url).pathname; +const argAt = (flag) => { const i = process.argv.indexOf(flag); return i > 0 ? process.argv[i + 1] : null; }; +const DIST = resolve(argAt('--dist') ?? join(ROOT, 'dist')); +const CHROME = [process.env.CHROME_PATH, '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome', '/Applications/Chromium.app/Contents/MacOS/Chromium', '/usr/bin/google-chrome', '/usr/bin/chromium'].filter(Boolean).find((p) => existsSync(p)); +if (!CHROME) { console.log(' no Chrome found — skipping the idle-line layout check.'); process.exit(0); } +if (!process.argv.includes('--no-build') && !argAt('--dist')) { console.log(' building…'); execSync('npx vite build', { cwd: ROOT, stdio: ['ignore', 'ignore', 'inherit'] }); } + +const SNAP = JSON.parse(readFileSync(new URL('./fixtures/queen-status/healthy-idle-2026-09-15T1250Z.json', import.meta.url), 'utf8')); +const ORIGIN = 'https://t27.ai'; +const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.wasm': 'application/wasm', '.woff2': 'font/woff2', '.webp': 'image/webp' }; +const distFile = (pathname) => { const p = normalize(decodeURIComponent(pathname)).replace(/^(\.\.[/\\])+/, ''); const f = join(DIST, p); return !f.startsWith(DIST) || p === '/' || !existsSync(f) ? join(DIST, 'index.html') : f; }; +const wait = (ms) => new Promise((r) => setTimeout(r, ms)); + +const fails = []; +let checks = 0; +const check = (cond, msg) => { checks += 1; if (!cond) fails.push(msg); console.log(` ${cond ? '✓' : '✗'} ${msg}`); }; + +const profile = mkdtempSync(join(tmpdir(), 'queen-idle-layout-')); +const chrome = spawn(CHROME, ['--headless=new', '--remote-debugging-port=0', `--user-data-dir=${profile}`, '--no-first-run', '--no-default-browser-check', '--disable-extensions', '--mute-audio', '--use-gl=angle', '--use-angle=swiftshader', '--enable-unsafe-swiftshader', 'about:blank'], { stdio: ['ignore', 'ignore', 'pipe'] }); +const cleanup = () => { try { chrome.kill('SIGKILL'); } catch {} try { rmSync(profile, { recursive: true, force: true }); } catch {} }; +const watchdog = setTimeout(() => { console.log(' ✗ watchdog: 20 min'); cleanup(); process.exit(2); }, 20 * 60 * 1000); + +let statusFails = false; +try { + const wsUrl = await new Promise((res, rej) => { const t = setTimeout(() => rej(new Error('no devtools port')), 60000); let buf = ''; chrome.stderr.on('data', (d) => { buf += d; const m = buf.match(/DevTools listening on (ws:\/\/\S+)/); if (m) { clearTimeout(t); res(m[1]); } }); }); + const ws = new WebSocket(wsUrl); await new Promise((r, j) => { ws.onopen = r; ws.onerror = j; }); + let id = 0; const pending = new Map(); const listeners = []; + ws.onmessage = (ev) => { const m = JSON.parse(ev.data); if (m.id && pending.has(m.id)) { const p = pending.get(m.id); pending.delete(m.id); m.error ? p.reject(new Error(m.error.message)) : p.resolve(m.result); } else if (m.method) listeners.forEach((f) => f(m)); }; + const send = (method, params = {}, sessionId) => new Promise((res, rej) => { const i = ++id; pending.set(i, { resolve: res, reject: rej }); ws.send(JSON.stringify({ id: i, method, params, ...(sessionId ? { sessionId } : {}) })); }); + const { targetId } = await send('Target.createTarget', { url: 'about:blank' }); + const { sessionId } = await send('Target.attachToTarget', { targetId, flatten: true }); + const call = (m, p) => send(m, p, sessionId); + listeners.push((m) => { + if (m.sessionId !== sessionId || m.method !== 'Fetch.requestPaused') return; + const { requestId, request } = m.params; + if (/\/queen\/status/.test(request.url)) { + const s = structuredClone(SNAP); const decided = new Date(Date.now() - 30000).toISOString(); s.lastTick.decidedAt = decided; if (s.queue) s.queue.observedAt = decided; + const body = statusFails ? '{"error":"bad gateway"}' : JSON.stringify(s); + call('Fetch.fulfillRequest', { requestId, responseCode: statusFails ? 502 : 200, responseHeaders: [{ name: 'Content-Type', value: 'application/json' }, { name: 'Access-Control-Allow-Origin', value: '*' }], body: Buffer.from(body).toString('base64') }).catch(() => {}); + return; + } + if (request.url.startsWith(`${ORIGIN}/`)) { + const f = distFile(new URL(request.url).pathname); + call('Fetch.fulfillRequest', { requestId, responseCode: 200, responseHeaders: [{ name: 'Content-Type', value: MIME[extname(f)] || 'application/octet-stream' }], body: readFileSync(f).toString('base64') }).catch(() => {}); + return; + } + call('Fetch.continueRequest', { requestId }).catch(() => {}); + }); + await call('Runtime.enable'); await call('Page.enable'); + await call('Fetch.enable', { patterns: [{ urlPattern: `${ORIGIN}/*` }, { urlPattern: '*queen/status*' }] }); + + const evaluate = async (expr) => { const r = await call('Runtime.evaluate', { expression: expr, awaitPromise: true, returnByValue: true }); if (r.exceptionDetails) throw new Error(r.exceptionDetails.exception?.description || r.exceptionDetails.text); return r.result.value; }; + const until = async (expr, ms) => { const t0 = Date.now(); while (Date.now() - t0 < ms) { try { const v = await evaluate(expr); if (v) return v; } catch {} await wait(500); } return null; }; + const size = (w, h) => call('Emulation.setDeviceMetricsOverride', { width: w, height: h, deviceScaleFactor: 1, mobile: w < 768 || h <= 500 }); + const tap = async (x, y) => { for (const type of ['mouseMoved', 'mousePressed', 'mouseReleased']) await call('Input.dispatchMouseEvent', { type, x, y, button: 'left', buttons: type === 'mousePressed' ? 1 : 0, clickCount: 1 }); }; + const ready = `(() => { const b = document.getElementById('stat-bees'); return !!document.querySelector('main[data-view]') && !!b && /^\\s*\\d+\\s*\\//.test(b.textContent); })()`; + const load = async (w, h, lang, tag) => { + await size(w, h); + await call('Page.navigate', { url: `${ORIGIN}/?lang=${lang}&idle-layout=${process.pid}-${tag}#/queen` }); + if (!(await until(ready, 150000))) throw new Error(`the Queen never rendered at ${w}x${h} ${lang}`); + await until(`!!document.querySelector('.queen27-hud-idle')`, 30000); + await wait(2500); + }; + const view = async (name) => { + await evaluate(`location.hash = ${JSON.stringify(name === 'comb' ? '#/queen' : `#/queen?tab=${name}`)}; true`); + await until(`document.querySelector('main[data-view]')?.getAttribute('data-view') === ${JSON.stringify(name)}`, 20000); + await wait(name === 'comb' ? 1500 : 3000); + }; + // In-page geometry: a box, and how many of five points of an element are the element itself. + const GEOM = `const R = (e) => { if (!e) return null; const cs = getComputedStyle(e); const r = e.getBoundingClientRect(); return cs.display === 'none' || r.width === 0 ? null : { l: r.left, t: r.top, r: r.right, b: r.bottom, w: r.width, h: r.height }; }; + const own = (e) => { const r = e.getBoundingClientRect(); return [[.5,.5],[.15,.2],[.85,.2],[.15,.8],[.85,.8]].filter(([fx, fy]) => { const u = document.elementFromPoint(r.left + r.width * fx, r.top + r.height * fy); return !!u && (u === e || e.contains(u)); }).length; }; + const centre = (e) => { const r = e.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; };`; + const boxes = () => evaluate(`(() => { ${GEOM} const line = document.querySelector('.queen27-hud-idle'); return { line: R(line), head: R(document.querySelector('.queen27-hud-vp-head')), body: R(document.querySelector('.queen27-hud-vp-body')), clipped: !!line && (line.scrollWidth > line.clientWidth + 1 || line.scrollHeight > line.clientHeight + 1), text: line ? line.textContent.trim() : null, iw: innerWidth, ih: innerHeight, sh: document.documentElement.scrollHeight }; })()`); + + // 1. desktop + for (const lang of ['en', 'ru']) { + await load(1440, 900, lang, `desk-${lang}`); + for (const [w, h] of [[1440, 900], [1280, 720]]) { + await size(w, h); await wait(1500); + const b = await boxes(); + const where = `${w}x${h} ${lang}`; + check(!!b.line && b.line.l >= 0 && b.line.r <= b.iw && b.line.t >= 0 && b.line.b <= b.ih && !b.clipped, `${where}: the idle line is on screen and unclipped ("${(b.text ?? '').slice(0, 60)}…")`); + check(!!b.line && !!b.head && b.line.t + 1 >= b.head.b, `${where}: the line starts at or below the head's bottom (line ${b.line?.t}, head ${b.head?.b})`); + } + if (lang !== 'en') continue; + // 2. collapsed on a desktop, then narrowed to a phone + await size(1440, 900); await wait(1500); + const c = await evaluate(`(() => { ${GEOM} const b = document.querySelector('nav.queen27-hud-command > button.queen27-hud-cmd-collapse'); return b ? { ...centre(b), own: own(b) } : null; })()`); + check(!!c && c.own > 0, 'the rail\'s collapse button is reachable at 1440x900'); + if (c?.own) { + await tap(c.x, c.y); + check(!!(await until(`!!document.querySelector('main.is-command-collapsed')`, 5000)), 'a trusted click collapses the rail'); + await size(390, 844); await wait(2500); + const b = await boxes(); + check(!!b.line && b.line.l >= 0 && b.line.r <= b.iw + 0.5, `collapsed at 1440, then 390x844: the line is inside the window (${b.line?.l}-${b.line?.r} of ${b.iw})`); + await size(1440, 900); await wait(800); + const again = await evaluate(`(() => { const b = document.querySelector('nav.queen27-hud-command > button.queen27-hud-cmd-collapse'); if (!b) return null; const r = b.getBoundingClientRect(); return { x: r.left + r.width / 2, y: r.top + r.height / 2 }; })()`); + if (again) { await tap(again.x, again.y); await wait(800); } + } + } + + // 3. phone portrait + for (const lang of ['en', 'ru']) { + await load(390, 844, lang, `phone-${lang}`); + await view('specs'); + await until(`!!document.querySelector('.queen27-hud-vp-body details > summary')`, 20000); + const specs = await evaluate(`(() => { ${GEOM} const s = document.querySelector('.queen27-hud-vp-body details > summary'); return s ? { own: own(s), ...centre(s), open: s.parentElement.open, text: s.textContent.trim().slice(0, 30) } : null; })()`); + const b = await boxes(); + const where = `390x844 ${lang}`; + check(!!b.line && !!b.head && !!b.body && b.line.t + 1 >= b.head.b && b.body.t + 1 >= b.line.b, `${where}: the line lies between the head and the body (head to ${b.head?.b}, line ${b.line?.t}-${b.line?.b}, body from ${b.body?.t})`); + check(b.sh <= b.ih && !!b.line && b.line.l >= 0 && b.line.r <= b.iw, `${where}: one screen (document ${b.sh} of ${b.ih}), the line inside the window`); + check(specs?.own === 5, `${where}: the specs view's first disclosure ("${specs?.text}") is topmost at 5 of 5 points (got ${specs?.own})`); + if (lang === 'en' && specs?.own) { + await tap(specs.x, specs.y); await wait(1200); + check((await evaluate(`document.querySelector('.queen27-hud-vp-body details > summary')?.parentElement.open`)) !== specs.open, `${where}: a trusted tap on that disclosure opens it`); + } + await view('tri'); + await until(`document.querySelectorAll('button.queen27-tri-screen').length > 1`, 20000); + const tabs = await evaluate(`(() => { ${GEOM} return [...document.querySelectorAll('button.queen27-tri-screen')].map((t) => ({ text: t.textContent.trim(), own: own(t), ...centre(t), active: t.classList.contains('is-active') })); })()`); + check(tabs.length > 1 && tabs.every((t) => t.own === 5), `${where}: all ${tabs.length} of TRI's screen tabs are topmost at 5 of 5 points (${tabs.map((t) => `${t.text} ${t.own}`).join(', ')})`); + if (lang === 'en' && tabs.length > 1) { + const target = tabs.find((t) => !t.active) ?? tabs[1]; + await tap(target.x, target.y); await wait(1500); + const active = await evaluate(`document.querySelector('button.queen27-tri-screen.is-active')?.textContent.trim() ?? null`); + check(active === target.text, `${where}: a trusted tap on "${target.text}" switches TRI to it (active: "${active}")`); + } + } + + // 4. phone landscape + await load(844, 390, 'en', 'landscape'); + await view('comb'); + const land = await evaluate(`(() => { ${GEOM} const line = document.querySelector('.queen27-hud-idle'); + const els = [...document.querySelectorAll('.queen27-hud-vp-tools button, .queen27-hud-vp-tools .queen27-identity')].filter((e) => { const r = R(e); return r && r.l >= 0 && r.r <= innerWidth && r.t >= 0 && r.b <= innerHeight; }); + const layer = document.querySelector('.queen27-hud-vp-tools button[data-layer]'); + return { present: !!line, drawn: !!R(line), items: els.map((e) => ({ k: e.getAttribute('data-tool') || e.getAttribute('data-layer') || e.className.split(' ')[0], own: own(e) })), layer: layer ? { ...centre(layer), own: own(layer), pressed: layer.getAttribute('aria-pressed') } : null }; })()`); + check(land.present && !land.drawn, '844x390: the idle line is in the page but not drawn'); + check(land.items.length >= 5 && land.items.every((i) => i.own === 5), `844x390: all ${land.items.length} map tools and the identity chip are topmost at 5 of 5 points (${land.items.filter((i) => i.own !== 5).map((i) => `${i.k} ${i.own}`).join(', ') || 'none short'})`); + if (land.layer?.own) { + await tap(land.layer.x, land.layer.y); await wait(1000); + const pressed = await evaluate(`document.querySelector('.queen27-hud-vp-tools button[data-layer]')?.getAttribute('aria-pressed')`); + check(pressed !== land.layer.pressed, `844x390: a trusted tap on the foundation layer toggles it (${land.layer.pressed} -> ${pressed})`); + } else check(false, '844x390: the foundation layer button is reachable'); + + // 5. the status endpoint fails: the kept status explains nothing + await load(1440, 900, 'en', 'failing'); + check(!!(await evaluate(`!!document.querySelector('.queen27-hud-idle')`)), 'live: the idle line is shown'); + statusFails = true; + const gone = await until(`!document.querySelector('.queen27-hud-idle') && !document.querySelector('#stat-status.is-live')`, 30000); + const bees = await evaluate(`document.querySelector('.queen27-hud-res-bees > span')?.textContent.trim() ?? null`); + check(!!gone && !/nothing to choose|round stale/.test(bees ?? ''), `after /queen/status fails: no idle line, and the BEES tile claims no reason (sub-line "${bees}")`); +} catch (e) { + check(false, `harness: ${e && e.message}`); +} +clearTimeout(watchdog); +cleanup(); +if (fails.length) { console.log(`Queen idle-line layout contract: FAIL (${fails.length} of ${checks})`); process.exit(1); } +console.log(`Queen idle-line layout contract: PASS (${checks} checks)`); diff --git a/apps/website/src/pages/Queen.css b/apps/website/src/pages/Queen.css index ce60448f35..b2e38129f9 100644 --- a/apps/website/src/pages/Queen.css +++ b/apps/website/src/pages/Queen.css @@ -5575,15 +5575,18 @@ main:where(.queen27-page.is-shell) section { /* ---- why free bees are idle (2026-09-15) ---------------------------------- BEES 0/4 was read as broken bees while the round had said "nothing to - choose". The line sits under the map's head, over the top edge of the - field, and wraps rather than being cut: the head itself is one row that - clips anything taller. On a phone the head is in the flow (row 1), so the - line takes the top of row 2 without resizing it. */ + choose". On a desktop the line floats under the map's head, over the top + edge of the field, and wraps rather than being cut: the head itself is one + row that clips anything taller. Its top is the head as served from t27.ai, + 42px (measured 1440x900 and 1280x720, en and ru: the identity chip is 28px + there, and the head's bottom rule is its last pixel; 40px covered that + rule). qa/queen-idle-layout-contract.mjs holds the line at or below the + head. */ .queen27-page.is-shell .queen27-hud-viewport > .queen27-hud-idle { position: absolute; z-index: 3; grid-area: 1 / 1 / -1 / -1; - top: calc(var(--hud-chrome-top) + 40px); + top: calc(var(--hud-chrome-top) + 42px); right: calc(var(--hud-col-w) + var(--hud-gap) * 2); left: calc(var(--hud-rail-w) + var(--hud-gap) * 2); align-self: start; @@ -5597,8 +5600,12 @@ main:where(.queen27-page.is-shell) section { overflow-wrap: anywhere; } -.queen27-page.is-shell.is-command-collapsed .queen27-hud-viewport > .queen27-hud-idle { - left: 84px; +/* Desktop only: at 0,5,0 it outranked the phone block, and a page collapsed at + 1440 then narrowed to 390 pushed the line 84px out of the window. */ +@media (min-width: 901px) { + .queen27-page.is-shell.is-command-collapsed .queen27-hud-viewport > .queen27-hud-idle { + left: 84px; + } } .queen27-page.is-shell .queen27-hud-idle a { @@ -5615,15 +5622,36 @@ main:where(.queen27-page.is-shell) section { display: none; } -@media (max-width: 900px) { +/* On a phone the line is a row of its own between the head and the body. + Laid over the top of the body's row it took the taps meant for what starts + there (measured 390x844 on t27.ai: TRI's screen tabs, and the first + disclosure of the specs, skills and crons views), so the body gives up the + line's height instead and the page stays one screen. Portrait, or tall + enough; a landscape phone is the next block. */ +@media (max-width: 900px) and (orientation: portrait), (max-width: 900px) and (min-height: 501px) { + .queen27-page.is-shell .queen27-hud-viewport:has(> .queen27-hud-idle) { + grid-template-rows: auto auto minmax(0, 1fr); + } + .queen27-page.is-shell .queen27-hud-viewport > .queen27-hud-idle { - position: relative; - z-index: 6; + position: static; grid-area: 2 / 1 / 3 / -1; - top: auto; - right: auto; - left: auto; - margin-top: var(--hud-gap); + margin: var(--hud-gap) var(--hud-gap) 0; + } + + .queen27-page.is-shell .queen27-hud-viewport:has(> .queen27-hud-idle) > .queen27-hud-vp-body { + grid-area: 3 / 1 / -1 / -1; + } +} + +/* A landscape phone has no row to give. Its head is a 40px band whose tools + spill below it (measured 844x390: tools at y 126-154 under a head ending at + 124), and the line over that band took every tool and the identity chip. + The line is not shown there. The BEES tile's sub-line is hidden at that + size as well, so this orientation says what it said before the line. */ +@media (max-height: 500px) and (orientation: landscape) { + .queen27-page.is-shell .queen27-hud-viewport > .queen27-hud-idle { + display: none; } }