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..8484e901de 100644 --- a/apps/website/package.json +++ b/apps/website/package.json @@ -44,6 +44,8 @@ "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-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/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-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/qa/queen-idle-reason-contract.mjs b/apps/website/qa/queen-idle-reason-contract.mjs new file mode 100644 index 0000000000..b039816983 --- /dev/null +++ b/apps/website/qa/queen-idle-reason-contract.mjs @@ -0,0 +1,191 @@ +// 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), 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"; + +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", refused: "round refused", checked: "checked", + stale: "round stale", staleDetail: "last decision {age} ago, rounds every {interval}", + unitS: "s", unitMin: "min", unitH: "h", + reasons: { missingBoundary: "no ## Boundary", claimed: "claimed", completed: "done but open", fileConflict: "touch held files", notFirst: "not first", other: "other" }, +}; +const RU = { + idle: "свободно", nothingToChoose: "нечего выбрать", refused: "раунд отказал", checked: "проверено", + stale: "раунд устарел", staleDetail: "последнее решение {age} назад, раунды каждые {interval}", + unitS: "с", unitMin: "мин", unitH: "ч", + 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 — 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 свободно: нечего выбрать — проверено: 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"); +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 — 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. 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}")`); +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"); + +// 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 [ + ["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; }], + ["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 === 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 — 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.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}")`); + +// 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)); }; +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"; +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"); + +// 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)], + ["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`); + +// 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(/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; 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 bd70c813eb..64be318535 100644 --- a/apps/website/src/components/queenHud.ts +++ b/apps/website/src/components/queenHud.ts @@ -303,6 +303,151 @@ 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; + +/** 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. 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[] = []; + 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)) 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 skipped !== null && total !== skipped ? null : 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; checked: 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; + 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 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; + /** 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; + /** A label per skip category, printed "label: count", so no count has to agree with a word. */ + 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 "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}`; + if (reason.kind === "stale") { + const head = `${lead}: ${words.stale}`; + const tail = words.staleDetail + .replace("{age}", spanWords(reason.ageSeconds, words)) + .replace("{interval}", spanWords(reason.intervalSeconds, words)); + 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.counts ?? []).some((r) => r.key === "missingBoundary" && r.count > 0), + }; +} + /** * 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..b2e38129f9 100644 --- a/apps/website/src/pages/Queen.css +++ b/apps/website/src/pages/Queen.css @@ -5573,6 +5573,88 @@ 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". 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) + 42px); + 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; +} + +/* 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 { + 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; +} + +/* 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: static; + grid-area: 2 / 1 / 3 / -1; + 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; + } +} + /* ---- 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 +6670,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..4bdc8d3b2d 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,19 @@ const COPY = { swarmIdle: "IDLE", swarmPaused: "PAUSED", swarmUnknown: "STATE —", - hudReady: "ready", + hudNoVerdict: "finished, no verdict", + idleNothing: "nothing to choose", + idleRefused: "round refused", + idleChecked: "checked", + idleStale: "round stale", + idleStaleDetail: "last decision {age} ago, rounds every {interval}", + idleMissingBoundary: "no ## Boundary", + idleClaimed: "claimed", + idleCompleted: "done but open", + idleFileConflict: "touch held files", + idleNotFirst: "not first", + idleOther: "other", + idleExample: "an issue written the way bees can take it", unavailable: "BACKEND UNAVAILABLE", checking: "CHECKING BACKEND", scheduler: "Scheduler", @@ -607,7 +625,19 @@ const COPY = { swarmIdle: "ЖДЁТ", swarmPaused: "ПАУЗА", swarmUnknown: "СОСТОЯНИЕ —", - hudReady: "готово", + hudNoVerdict: "без вердикта", + idleNothing: "нечего выбрать", + idleRefused: "раунд отказал", + idleChecked: "проверено", + idleStale: "раунд устарел", + idleStaleDetail: "последнее решение {age} назад, раунды каждые {interval}", + idleMissingBoundary: "без ## Boundary", + idleClaimed: "заняты", + idleCompleted: "сделаны и не закрыты", + idleFileConflict: "задевают занятые файлы", + idleNotFirst: "не первые", + idleOther: "прочие", + idleExample: "задача в формате, который пчёлы берут", unavailable: "BACKEND НЕДОСТУПЕН", checking: "ПРОВЕРЯЮ BACKEND", scheduler: "Планировщик", @@ -2360,6 +2390,34 @@ 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. + // 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, + refused: c.idleRefused, + checked: c.idleChecked, + 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, + 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 @@ -3073,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 ?? "—"}`} - - {workers?.idle ?? "—"} {c.factoryIdle} + + {idleWhy + ? idleWhy.head + : `${data?.workers ? data.workers.capacity - data.workers.active : (workers?.idle ?? "—")} ${c.factoryIdle}`}
    @@ -3096,7 +3174,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}` : ""}