diff --git a/trios/.trinity/loop/agree.mjs b/trios/.trinity/loop/agree.mjs new file mode 100644 index 0000000000..4950236733 --- /dev/null +++ b/trios/.trinity/loop/agree.mjs @@ -0,0 +1,307 @@ +#!/usr/bin/env node +// Two implementations of one rule, asked the same question about real data. +// +// WHY THIS EXISTS. This repository's most expensive defects are all the same +// shape: one rule written twice, drifting apart, with nothing comparing them. +// The boundary rule had three copies and only two knew `## Границы`, so seven +// bees were accused of straying. `can_start_another` has five. Five SR-00 rules +// were retyped into TypeScript and three disagreed in production. The rings +// audit found eleven more. +// +// On 2026-09-06 it cost 153 dispatches. `unjudgedCriteria` and +// `missingVerdictSlots` both answer "which promised criteria did the bee +// answer?" - one by matching text, one by reading the slot number. They +// disagree on 347 of 358 rows. The comment above the second one asserted they +// could not disagree, and that sentence is precisely what kept the defect +// hidden: it made a real divergence look like somebody else's solved problem. +// +// A COMMENT CLAIMING TWO FUNCTIONS AGREE IS A TEST THAT HAS NOT BEEN WRITTEN. +// This is that test. It does not read the comment and it does not reason about +// the code; it runs both implementations over the rows the system actually +// holds and prints where the answers differ. +// +// THE PRECEDENT IS ALREADY HERE. `t27-parity.mjs` does exactly this across +// languages - the generated ring against the hand-written twin, 460 cases. It +// has never once been wrong about a divergence, because a differential test +// cannot be talked out of its result. This is the same instrument pointed at +// two functions in one file, which is where the drift is cheapest to create +// and hardest to see. +// +// AGREEMENT IS NOT CORRECTNESS. Two implementations that agree may be wrong +// together, and this says nothing about that case - the same limit `rejudge` +// carries and states. What it rules out is the other case, which is the one +// nobody checks: a divergence that has been sitting in the open behind a +// sentence saying it cannot happen. +// +// DIVERGENCE HAS A DIRECTION, and reporting only a count hides the finding. +// Here the two disagree BOTH ways: text matching missed numbered lines that +// were shortened (153 send-backs of finished work), and slot matching is blind +// to 156 blocks that carry no numbering at all. Neither side is the answer, and +// a report that said only "347 differ" would have suggested picking one. +// +// Usage: +// node agree.mjs # every declared pair, over real rows +// node agree.mjs --limit 6 # fewer example shapes +// node agree.mjs --pair # just one + +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const DIR = path.dirname(fileURLToPath(import.meta.url)) +const isMain = process.argv[1] && process.argv[1].endsWith('/agree.mjs') + +/** + * The pairs, each a question two implementations both claim to answer. + * + * `program` runs INSIDE the container against the deployed module, because a + * differential test of a copy proves nothing about what ships - copying the + * rule here would make this file the third implementation, which is the defect + * it was written to catch. + * + * Each row it emits is `{ id, a, b }`, two arrays of the promised-criterion + * numbers each side considers ANSWERED. Comparing a common representation + * rather than each function's own return type is what lets one comparator serve + * every pair. + */ +export const PAIRS = [ + { + name: 'answered-criteria', + kind: 'remote', + question: 'which promised criteria did the bee answer?', + a: 'unjudgedCriteria (matches by text)', + b: 'missingVerdictSlots (matches by slot number)', + // A DOUBLE-QUOTED STRING, NOT A TEMPLATE LITERAL. This fragment is pasted + // into another template literal and then through `shq` into a shell, and a + // backtick cannot survive that intact - the first draft emitted an escaped + // backtick and bun refused the whole program. The SQL contains no double + // quotes, so the one quoting style that needs no escaping is the one used. + program: ` + const q = await p.query( + "select d.issue as id, d.review_state, coalesce(d.criteria,'[]'::jsonb) as criteria, " + + " (select string_agg(t.text, '' order by t.seq) from queen_transcript t " + + " where t.conversation_id = d.conversation_id and t.kind='say') as said " + + " from queen_dispatch d where d.review_state is not null") + for (const r of q.rows) { + const said = String(r.said || '') + const promised = Array.isArray(r.criteria) ? r.criteria : [] + const verdicts = mod.parseVerdictBlock(said) + if (!verdicts.length || !promised.length) { out.push({ id: r.id, skip: 'no verdict block or no criteria' }); continue } + const unjudged = new Set(mod.unjudgedCriteria(promised, verdicts)) + const a = [] + promised.forEach((c, i) => { if (!unjudged.has(c)) a.push(i + 1) }) + const missing = new Set(mod.missingVerdictSlots(said, promised.length)) + const b = [] + for (let i = 1; i <= promised.length; i++) if (!missing.has(i)) b.push(i) + out.push({ id: r.id, tag: r.review_state, a, b, total: promised.length }) + } + `, + }, + // THE SAME QUESTION, ASKED ACROSS THREE LANGUAGES. + // + // "Which paths may this bee touch?" is answered in the loop's own JavaScript, + // in the deployed TypeScript, and in the SR-00 Swift ring - and a fourth copy + // sits in `queen-brief-shape.ts`, whose comment justifies itself by saying the + // other TypeScript copy "is module-private". That copy is exported now. The + // reason expired and nobody noticed, which is the whole argument for testing + // the claim instead of reading it. + // + // This rule has already cost real work: two copies knew `## Границы` and one + // did not, and seven bees were accused of straying outside a boundary they had + // honoured. The heading was added everywhere; nothing was added that would + // notice the next divergence. + // + // Two pairs rather than one three-way comparison, because "A and B and C agree" + // hides WHICH two parted company, and that is the only part anybody can act on. + { + name: 'boundary-js-vs-ts', + kind: 'local', + script: 'boundary-parity.ts', + question: 'which paths may this bee touch?', + a: "the loop's own verdict-audit.mjs (JavaScript)", + b: 'the deployed queen-tick.ts (TypeScript)', + pick: (r) => ({ id: r.id, a: r.js, b: r.ts }), + }, + { + name: 'boundary-ts-vs-swift', + kind: 'local', + script: 'boundary-parity.ts', + question: 'which paths may this bee touch?', + a: 'the deployed queen-tick.ts (TypeScript)', + b: 'the QueenIssueBoundary ring (Swift)', + pick: (r) => ({ id: r.id, a: r.ts, b: r.swift }), + }, +] + +/** One row: do the two sides answer the same set? */ +export function classify(row) { + if (!row || row.id === undefined || row.id === null) { + return { id: row && row.id, kind: 'unknown', why: 'the row could not be read - it accuses nobody' } + } + if (row.skip) return { id: row.id, kind: 'skipped', why: row.skip } + if (!Array.isArray(row.a) || !Array.isArray(row.b)) { + return { id: row.id, kind: 'unknown', why: 'one side produced no answer at all' } + } + const A = new Set(row.a) + const B = new Set(row.b) + const onlyA = row.a.filter((x) => !B.has(x)) + const onlyB = row.b.filter((x) => !A.has(x)) + if (!onlyA.length && !onlyB.length) return { id: row.id, kind: 'agree', tag: row.tag } + return { id: row.id, kind: 'DIFFER', tag: row.tag, onlyA, onlyB, a: row.a.length, b: row.b.length, total: row.total } +} + +/** + * Which side is missing things the other found, counted separately. + * + * A single "347 differ" would invite picking a winner. Both directions being + * populated is the finding: neither implementation is the rule. + */ +export function directions(rows) { + let aMisses = 0 + let bMisses = 0 + for (const r of rows) { + if (r.kind !== 'DIFFER') continue + if (r.onlyB.length) aMisses++ + if (r.onlyA.length) bMisses++ + } + return { aMisses, bMisses } +} + +export function render(pair, rows, limit = 6) { + const by = { agree: [], DIFFER: [], skipped: [], unknown: [] } + for (const r of rows) by[r.kind].push(r) + const compared = by.agree.length + by.DIFFER.length + const out = [ + `pair "${pair.name}" - ${pair.question}`, + ` A: ${pair.a}`, + ` B: ${pair.b}`, + '', + `${compared} row(s) compared, ${by.skipped.length} skipped (nothing for either side to answer)`, + ` agree : ${by.agree.length}`, + ` DIFFER: ${by.DIFFER.length}`, + ] + if (by.unknown.length) out.push(` unreadable, so NOT counted against either side: ${by.unknown.length}`) + + if (by.DIFFER.length) { + const d = directions(rows) + out.push('') + out.push(`A missed something B found on ${d.aMisses} row(s); B missed something A found on ${d.bMisses}.`) + if (d.aMisses && d.bMisses) { + out.push('BOTH directions are populated, so neither side is the rule and picking') + out.push('one would trade this defect for its mirror image.') + } + const shape = new Map() + for (const r of by.DIFFER) { + const k = `${r.tag ?? '-'}: A=${r.a} B=${r.b} of ${r.total}` + shape.set(k, (shape.get(k) || 0) + 1) + } + out.push('') + for (const [k, n] of [...shape.entries()].sort((x, y) => y[1] - x[1]).slice(0, limit)) { + out.push(` ${String(n).padStart(4)} ${k}`) + } + if (shape.size > limit) out.push(` ... and ${shape.size - limit} more shapes`) + } else if (compared) { + out.push('') + out.push('The two agree on every row compared. That is NOT a proof either is') + out.push('right - two implementations can be wrong together, and this cannot see') + out.push('that. What it rules out is a divergence sitting in the open.') + } + return out.join('\n') +} + +if (isMain) { + const CH = await import(path.join(DIR, 'channel.mjs')) + const L = await import(path.join(DIR, 'loop.mjs')) + const { execFileSync } = await import('node:child_process') + + const at = process.argv.indexOf('--limit') + const limit = at >= 0 ? Number(process.argv[at + 1]) || 6 : 6 + const only = process.argv.indexOf('--pair') + const wanted = only >= 0 ? String(process.argv[only + 1] || '') : '' + const pairs = wanted ? PAIRS.filter((p) => p.name === wanted) : PAIRS + if (!pairs.length) { + console.log(`no pair named "${wanted}". Known: ${PAIRS.map((p) => p.name).join(', ')}`) + process.exit(2) + } + + /** Rows from the container, for a pair whose implementations live there. */ + const remoteRows = (pair) => { + const prog = ` + const {Pool} = require('pg') + const mod = await import('/app/apps/server/src/api/services/queen-tick.ts') + const p = new Pool({connectionString: process.env.DATABASE_URL}) + const out = [] + ${pair.program} + console.log('@@' + JSON.stringify(out)) + process.exit(0) + ` + try { + const res = String(CH.remote(`cd /app/apps/server && bun -e ${L.shq(prog)}`, { attempts: 2 })) + const i = res.indexOf('@@[') + return i >= 0 ? { rows: JSON.parse(res.slice(i + 2)) } : null + } catch { return null } + } + + // ONE RUN PER SCRIPT, SHARED BY EVERY PAIR THAT READS IT. Building the Swift + // ring twice to answer two questions about the same rows would double the + // slowest step for nothing, and - worse - could produce two different answers + // for one run, which is the confusion this whole tool exists to remove. + const localCache = new Map() + const localRun = (script) => { + if (localCache.has(script)) return localCache.get(script) + let got = null + try { + const out = String(execFileSync('bun', ['run', path.join(DIR, script)], { + encoding: 'utf8', maxBuffer: 256 * 1024 * 1024, timeout: 600000, stdio: ['ignore', 'pipe', 'ignore'], + })) + const i = out.indexOf('@@') + if (i >= 0) got = JSON.parse(out.slice(i + 2)) + } catch { got = null } + localCache.set(script, got) + return got + } + + let diverged = 0 + let measured = 0 + for (const pair of pairs) { + let rows = null + let sides = null + if (pair.kind === 'local') { + const got = localRun(pair.script) + if (got && Array.isArray(got.rows)) { + rows = got.rows.map(pair.pick) + sides = got.sides + } + } else { + const got = remoteRows(pair) + if (got) rows = got.rows + } + if (!rows) { + console.log(`pair "${pair.name}": the rows could not be read - NOTHING was compared. An unreachable source is not an agreeing one.\n`) + continue + } + const judged = rows.map(classify) + // COMPARED, not merely attempted. A pair whose rows all came back unreadable + // - because a side would not build - has measured NOTHING, and counting it + // as a pair that ran is how "0 diverging rows" comes to mean "we could not + // look". The exit code has to be able to tell those apart. + const compared = judged.filter((r) => r.kind === 'agree' || r.kind === 'DIFFER').length + if (compared > 0) measured++ + console.log(render(pair, judged, limit)) + // A SIDE THAT COULD NOT BE BUILT IS NAMED, not silently dropped. `classify` + // already returns `unknown` for it rather than agreement, and this says out + // loud which side that was - "all green" must never be able to mean "we + // looked at one of the two". + if (sides) { + const absent = Object.entries(sides).filter(([, ok]) => !ok).map(([k]) => k) + if (absent.length) console.log(` NOT BUILT, so nothing is claimed about it: ${absent.join(', ')}`) + } + console.log('') + diverged += judged.filter((r) => r.kind === 'DIFFER').length + } + if (!measured) { + console.log('NOTHING was compared. An unbuildable side is not an agreeing one.') + process.exit(3) + } + console.log(`${measured} pair(s) compared, ${diverged} diverging row(s)`) + process.exit(diverged ? 2 : 0) +} diff --git a/trios/.trinity/loop/boundary-parity.ts b/trios/.trinity/loop/boundary-parity.ts new file mode 100644 index 0000000000..68f22aae0c --- /dev/null +++ b/trios/.trinity/loop/boundary-parity.ts @@ -0,0 +1,155 @@ +// The boundary rule, read by all three of its implementations at once. +// +// "Which paths may this bee touch?" is answered in three places, in three +// languages: the loop's own `verdict-audit.mjs`, the deployed +// `queen-tick.ts`, and the `QueenIssueBoundary` ring in Swift. A fourth copy +// sits in `queen-brief-shape.ts`, whose comment says it exists because the +// other TypeScript copy "is module-private" - that copy is exported now, so +// the stated reason has expired and nobody noticed. +// +// This rule has already cost real work. Two of the three copies knew +// `## Границы` and one did not, and seven bees were accused of straying +// outside a boundary they had honoured. The heading was added everywhere; what +// was never added was anything that would notice the NEXT divergence. +// +// So this runs all three over the real issue bodies and reports where they +// differ. It emits rows; `agree.mjs` decides what they mean, because a +// comparator that also gathers its own inputs is two tools in one and only the +// gathering ever gets tested. +// +// A MISSING COMPILER IS NOT AGREEMENT. If `swiftc` is absent the Swift side is +// reported as absent, never as matching - a gate that quietly drops the side it +// could not build is how "all green" comes to mean "we looked at two of three". +// +// Usage: bun run boundary-parity.ts [--cache ] + +import { boundaryOf } from './verdict-audit.mjs' + +const CACHE = (() => { + const i = process.argv.indexOf('--cache') + return i >= 0 ? String(process.argv[i + 1]) : '/tmp/trios-all-issues.json' +})() + +const ROOT = '/Users/playra/BrowserOS' +const SHIP = process.env.TRIOS_SHIP_REF || 'origin/feat/queen-supervisor' +const TS_IN_SHIP = 'trios/agent-server/apps/server/src/api/services/queen-tick.ts' +const SWIFT_RING = `${ROOT}/trios/rings/SR-00/QueenIssueBoundary.swift` + +/** The named function's source text, brace-matched out of a larger file. */ +function functionText(src: string, signature: string): string | null { + const start = src.indexOf(signature) + if (start < 0) return null + const open = src.indexOf('{', src.indexOf('):', start)) + if (open < 0) return null + let depth = 0 + for (let j = open; j < src.length; j++) { + if (src[j] === '{') depth++ + else if (src[j] === '}') { + depth-- + if (depth === 0) return src.slice(start, j + 1) + } + } + return null +} + +async function shipSource(): Promise { + const p = Bun.spawn(['git', 'show', `${SHIP}:${TS_IN_SHIP}`], { cwd: ROOT, stdout: 'pipe', stderr: 'ignore' }) + const text = await new Response(p.stdout).text() + return (await p.exited) === 0 && text.length > 0 ? text : null +} + +/** + * The deployed TypeScript parser, compiled from the shipping ref's own text. + * + * Extracted rather than reimplemented: a reimplementation here would be the + * FIFTH copy of this rule, in the file whose whole purpose is to complain about + * there being four. + */ +async function tsParser(): Promise<((body: string) => string[]) | null> { + const src = await shipSource() + if (!src) return null + const fn = functionText(src, 'export function boundaryPathsOf(') + if (!fn) return null + const file = `${process.env.TMPDIR || '/tmp'}/trios-boundary-ts-${process.pid}.ts` + await Bun.write(file, `${fn}\n`) + try { + const mod = await import(file) + return typeof mod.boundaryPathsOf === 'function' ? mod.boundaryPathsOf : null + } catch { + return null + } +} + +/** The Swift ring, built and driven over stdin. Null when it cannot be built. */ +async function swiftAnswers(bodies: string[]): Promise { + const dir = `${process.env.TMPDIR || '/tmp'}/trios-boundary-swift-${process.pid}` + const bin = `${dir}/probe` + await Bun.write( + `${dir}/main.swift`, + 'import Foundation\n' + + 'while let line = readLine(strippingNewline: true) {\n' + + ' guard let d = Data(base64Encoded: line), let body = String(data: d, encoding: .utf8) else { print(""); continue }\n' + + ' let got = QueenIssueBoundary.paths(from: body)\n' + + ' print(got == nil ? "" : got!.joined(separator: "|||"))\n' + + '}\n', + ) + const build = Bun.spawn( + ['swiftc', '-O', SWIFT_RING, `${dir}/main.swift`, '-o', bin], + { env: { ...process.env, DEVELOPER_DIR: process.env.DEVELOPER_DIR || '/Library/Developer/CommandLineTools' }, stdout: 'ignore', stderr: 'pipe' }, + ) + if ((await build.exited) !== 0) return null + const run = Bun.spawn([bin], { stdin: 'pipe', stdout: 'pipe', stderr: 'ignore' }) + run.stdin.write(bodies.map((b) => Buffer.from(b, 'utf8').toString('base64')).join('\n') + '\n') + run.stdin.end() + const out = await new Response(run.stdout).text() + if ((await run.exited) !== 0) return null + const lines = out.split('\n') + // One answer per body, or the mapping is meaningless and nothing is claimed. + return lines.length - 1 >= bodies.length ? lines : null +} + +const rows: Array> = [] +let issues: Array<{ number: number; body?: string }> | null = null +try { + issues = JSON.parse(await Bun.file(CACHE).text()) +} catch { + issues = null +} + +if (!issues || !issues.length) { + console.log(`@@${JSON.stringify([{ fatal: `the issue cache ${CACHE} could not be read` }])}`) + process.exit(0) +} + +const ts = await tsParser() +const withBoundary = issues.filter((i) => boundaryOf(String(i.body || '')).reason !== 'absent') +const bodies = withBoundary.map((i) => String(i.body || '')) +const swift = await swiftAnswers(bodies) + +for (const [k, it] of withBoundary.entries()) { + const body = bodies[k] + const js = boundaryOf(body).paths as string[] + const tsPaths = ts ? ts(body) : null + const raw = swift ? swift[k] : undefined + const swPaths = + swift === null || raw === undefined + ? null + : raw === '' || raw === '' + ? [] + : raw.split('|||') + rows.push({ id: it.number, js, ts: tsPaths, swift: swPaths }) +} + +console.log( + `@@${JSON.stringify({ + rows, + sides: { + js: true, + ts: ts !== null, + swift: swift !== null, + }, + cache: CACHE, + considered: withBoundary.length, + total: issues.length, + })}`, +) diff --git a/trios/.trinity/loop/dash.mjs b/trios/.trinity/loop/dash.mjs index 4041abbf3b..ec5063be7a 100644 --- a/trios/.trinity/loop/dash.mjs +++ b/trios/.trinity/loop/dash.mjs @@ -94,6 +94,36 @@ export function loopingCounts(run = sh) { return m ? { examined: Number(m[1]), looping: Number(m[2]) } : null } +/** + * Rows where two implementations of one rule give different answers. + * + * Standing debt, not a transient: this does NOT fall to zero when a defect is + * fixed on one side. `unjudgedCriteria` and `missingVerdictSlots` will keep + * disagreeing on the 156 unnumbered blocks until one of them is deleted or + * wired properly, and that is exactly what the row is for - a duplicated rule + * is a liability for as long as it exists, not only on the day it bites. + */ +export function divergentRows(run = sh) { + const out = run(`node ${path.join(DIR, 'agree.mjs')}`, 300000) + const m = out.match(/(\d+) pair\(s\) compared, (\d+) diverging row\(s\)/) + return m ? { pairs: Number(m[1]), rows: Number(m[2]) } : null +} + +/** + * The share of the last twelve hours with NO bee working. + * + * The row beside it, `bees running (of 4)`, is an INSTANT - one sample per + * iteration of a quantity that turned out to be bimodal. It read 4 as often as + * 0 and could never have shown that half the day had no bee running at all. A + * rate needs a window; an instant needs none, which is exactly why the instant + * is the one that got measured for weeks. + */ +export function idlePercent(run = sh) { + const out = run(`node ${path.join(DIR, 'idle.mjs')} --hours 12`, 300000) + const m = out.match(/(\d+)% of the window had no bee working/) + return m ? Number(m[1]) : null +} + /** The worst-failing chain step, and its rate. */ export function worstStep(run = sh) { // A WINDOW, NOT A LIFETIME. The whole record contains two resolved outages - @@ -252,6 +282,8 @@ export function facts(deps = {}) { return { swarm: measure(() => swarmCounts(run)), looping: measure(() => loopingCounts(run)), + idle: measure(() => idlePercent(run)), + divergent: measure(() => divergentRows(run)), worstStep: measure(() => worstStep(run)), proven: measure(() => (read ? provenCounts(read) : provenCounts())), selftest: measure(() => (read ? selftestCases(read) : selftestCases())), @@ -312,7 +344,9 @@ export function rows(f, prev) { { k: 'dispatches finished', v: f.swarm?.finished ?? null, prev: p.swarm?.finished ?? null, goodDown: false }, { k: 'judged verdicts that prove', v: f.proven?.proven ?? null, prev: p.proven?.proven ?? null, goodDown: false }, { k: 'briefs with nothing checkable', v: f.proven?.unjudgeable ?? null, prev: p.proven?.unjudgeable ?? null }, + { k: 'hours 12: no bee working, percent', v: f.idle ?? null, prev: p.idle ?? null }, { k: 'send-backs looping with no ceiling', v: f.looping?.looping ?? null, prev: p.looping?.looping ?? null }, + { k: 'rows where one rule answers two ways', v: f.divergent?.rows ?? null, prev: p.divergent?.rows ?? null }, { k: `worst step, last 8 runs: ${f.worstStep?.step ?? '-'}, percent`, v: f.worstStep?.rate ?? null, prev: p.worstStep?.rate ?? null }, { k: 'selftest cases', v: f.selftest ?? null, prev: p.selftest ?? null, goodDown: false }, { k: 'ring T27-00 cases agreeing with the twin', v: f.parity?.agree ?? null, prev: p.parity?.agree ?? null, goodDown: false }, diff --git a/trios/.trinity/loop/heal.mjs b/trios/.trinity/loop/heal.mjs index 1ea0e8a7c4..2a18d812f2 100644 --- a/trios/.trinity/loop/heal.mjs +++ b/trios/.trinity/loop/heal.mjs @@ -200,6 +200,9 @@ const STEPS = [ { name: 'unverdicted', file: 'unverdicted.mjs', reportsOnly: true, act: '--limit 6', dryArgs: '--limit 6', why: 'a finished dispatch whose worker never wrote a verdict block' }, { name: 'silent-loop', file: 'silent-loop.mjs', reportsOnly: true, act: '', dryArgs: '', why: 'an attempt that charges no retry budget can be repeated for ever' }, { name: 'rejudge', file: 'rejudge.mjs', reportsOnly: true, act: '', dryArgs: '', why: 'a recorded verdict the current code would no longer give' }, + { name: 'idle', file: 'idle.mjs', reportsOnly: true, act: '', dryArgs: '', why: 'how much of the day the swarm spent doing nothing, and what stopped it' }, + { name: 'unwired', file: 'unwired.mjs', reportsOnly: true, act: '', dryArgs: '', why: 'a gate that exists and runs nowhere' }, + { name: 'agree', file: 'agree.mjs', reportsOnly: true, act: '', dryArgs: '', why: 'two implementations of one rule, asked the same question about real rows' }, { name: 'brief-gate', file: 'brief-gate.mjs', reportsOnly: true, act: '--open', dryArgs: '--open', why: 'which open briefs will produce a verdict nothing can check' }, { name: 'exposure', file: 'exposure.mjs', reportsOnly: true, act: '', dryArgs: '', why: 'what the live service serves to an origin it has never heard of' }, { name: 'forked-files', file: 'forked-files.mjs', reportsOnly: true, act: '', dryArgs: '', why: 'a file that exists twice must not start saying two things' }, diff --git a/trios/.trinity/loop/idle.mjs b/trios/.trinity/loop/idle.mjs new file mode 100644 index 0000000000..603521cace --- /dev/null +++ b/trios/.trinity/loop/idle.mjs @@ -0,0 +1,212 @@ +#!/usr/bin/env node +// How much of the day the swarm spent doing nothing, and what stopped it. +// +// THE GOLDEN RULE, in the operator's words: the bees work, and they start again +// the moment they finish. Nothing in this directory measured whether they do. +// Twenty instruments count issues, verdicts, briefs, branches and disagreements +// - and the one number that says whether the swarm is WORKING was missing. +// +// It had been false for a long time. Measured 2026-09-06 over twelve hours: +// +// minutes with ZERO bees running : 357 of 721 (50%) +// minutes with all four running : 258 (36%) +// median gap between bursts : 22 min, against a five-minute tick +// +// Bimodal - four bees or none - because a round either gets its issue list or +// dies whole, and 144 rounds in one log window died on `GitHub returned 403`. +// Every GitHub read went out unauthenticated against a sixty-an-hour limit +// while the service held a token good for fifteen thousand. +// +// WHY A DASHBOARD ROW WAS NOT ENOUGH. `bees running (of 4)` has been on the +// dashboard for weeks, and it read 4 as often as 0 - a sample of an instant, +// taken once an iteration, of a quantity that is bimodal. It could not have +// shown this and it never did. A rate needs a WINDOW; an instant needs none, +// which is exactly why the instant is the one that gets measured. +// +// WHAT IT DOES NOT DO. It does not restart anything. Idleness has causes - +// a failing round, an empty backlog, a full disk, a refused key - and the cure +// differs for each. It names the cause and stops. +// +// Usage: +// node idle.mjs # the last 12 hours +// node idle.mjs --hours 3 # a shorter window +// node idle.mjs --json # the same, as data + +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const DIR = path.dirname(fileURLToPath(import.meta.url)) +const isMain = process.argv[1] && process.argv[1].endsWith('/idle.mjs') + +/** + * Minutes at each concurrency level, by sampling the spans once a minute. + * + * SAMPLED, not integrated, because the question is "how much of the time were + * the bees working" and a sample answers it in the same units the operator + * asks it in. `capacity` is passed rather than assumed: a hard-coded 4 here + * would be the second place that number lives. + */ +export function utilisation(spans, from, to, capacity = 4) { + const level = {} + let samples = 0 + let idle = 0 + let full = 0 + // `t < to`, not `t <= to`. A twenty-minute window holds twenty one-minute + // buckets, and the inclusive form samples the closing boundary as a + // twenty-first - which made a half-idle window measure 52%. Caught by this + // file's own calibration case, which is what those cases are for. + for (let t = from; t < to; t += 60000) { + const n = spans.filter(([s, e]) => s <= t && t < e).length + level[n] = (level[n] || 0) + 1 + samples++ + if (n === 0) idle++ + if (n >= capacity) full++ + } + if (!samples) return null + return { samples, idle, full, level, idlePercent: Math.round((100 * idle) / samples), fullPercent: Math.round((100 * full) / samples) } +} + +/** + * The gaps between bursts of work, in minutes. + * + * Overlapping spans are merged first: two bees running at once is one busy + * period, and counting the space between them as idleness would invent gaps + * that never existed. + */ +export function gapsOf(spans) { + const sorted = [...spans].sort((a, b) => a[0] - b[0]) + const merged = [] + for (const s of sorted) { + const last = merged[merged.length - 1] + if (last && s[0] <= last[1]) last[1] = Math.max(last[1], s[1]) + else merged.push([...s]) + } + const gaps = [] + for (let i = 1; i < merged.length; i++) gaps.push((merged[i][0] - merged[i - 1][1]) / 60000) + gaps.sort((a, b) => a - b) + return { bursts: merged.length, gaps } +} + +/** The tick's own cadence, so a gap can be called long or not. */ +export function verdictOnGaps(gaps, tickSeconds) { + if (!gaps.length) return { kind: 'no-gaps', why: 'the swarm never stopped in this window, or it never started' } + const tickMin = tickSeconds / 60 + const median = gaps[Math.floor(gaps.length / 2)] + const overTick = gaps.filter((g) => g > tickMin * 1.5).length + const instant = gaps.filter((g) => g < 1).length + return { + kind: overTick > gaps.length / 2 ? 'ROUNDS-ARE-NOT-DISPATCHING' : 'within-cadence', + median, + overTick, + instant, + why: + overTick > gaps.length / 2 + ? `${overTick} of ${gaps.length} gaps are longer than the ${tickMin}-minute tick, so rounds are running and starting nothing` + : 'gaps are within the tick cadence, so the limit is how often a round happens', + } +} + +export function render(u, g, v, hours, failures) { + const out = [`the last ${hours} hour(s) of the swarm`, ''] + if (!u) return 'nothing was sampled - an unreadable board is not an idle one' + out.push(` minutes with ZERO bees working : ${String(u.idle).padStart(4)} of ${u.samples} (${u.idlePercent}%)`) + out.push(` minutes with every bee working : ${String(u.full).padStart(4)} (${u.fullPercent}%)`) + out.push('') + out.push(' concurrent bees, by minute:') + for (const k of Object.keys(u.level).sort((a, b) => Number(a) - Number(b))) { + out.push(` ${k} bees: ${String(u.level[k]).padStart(4)} min`) + } + out.push('') + out.push(` bursts of work: ${g.bursts}, with ${g.gaps.length} gap(s) between them`) + if (g.gaps.length) { + out.push(` median ${g.gaps[Math.floor(g.gaps.length / 2)].toFixed(1)} min, longest ${g.gaps[g.gaps.length - 1].toFixed(1)} min`) + out.push(` gaps under a minute: ${v.instant} <- the golden rule lives here`) + } + out.push('') + out.push(` ${v.why}`) + if (failures && failures.total > 0) { + out.push('') + out.push(` ${failures.total} round(s) FAILED in the log window. The reasons they gave:`) + for (const [reason, n] of failures.byReason.slice(0, 5)) out.push(` ${String(n).padStart(4)} ${reason}`) + } else if (failures) { + out.push('') + out.push(' no round reported a failure in the log window.') + } + out.push('') + out.push('This names the cause and stops. Idleness has several - a failing round, an') + out.push('empty backlog, a full volume, a refused key - and the cure differs for each,') + out.push('so nothing here restarts anything.') + return out.join('\n') +} + +if (isMain) { + const CH = await import(path.join(DIR, 'channel.mjs')) + const L = await import(path.join(DIR, 'loop.mjs')) + + const at = process.argv.indexOf('--hours') + const hours = at >= 0 ? Number(process.argv[at + 1]) || 12 : 12 + if (!Number.isFinite(hours) || hours <= 0 || hours > 168) { + console.log('--hours takes a number of hours between 1 and 168') + process.exit(2) + } + + const prog = ` + const {Pool} = require('pg') + const fs = require('fs') + const p = new Pool({connectionString: process.env.DATABASE_URL}) + const q = await p.query( + "select (snapshot->>'dispatched_at')::timestamptz as a, (snapshot->>'finished_at')::timestamptz as b " + + " from queen_dispatch_history where (snapshot->>'dispatched_at')::timestamptz > now() - interval '${hours} hours' " + + "union all select dispatched_at as a, finished_at as b from queen_dispatch " + + " where dispatched_at > now() - interval '${hours} hours'") + const spans = q.rows.filter(r => r.a).map(r => [new Date(r.a).getTime(), r.b ? new Date(r.b).getTime() : Date.now()]) + // The round's own failures, from the service log. Absent log, absent answer - + // never a zero, which would read as "no round has ever failed". + let failures = null + try { + const lines = fs.readFileSync('/app/browseros-server.log','utf8').split('\\n').slice(-8000) + const byReason = {} + let total = 0 + for (const l of lines) { + if (!l.startsWith('{')) continue + try { + const j = JSON.parse(l) + if (!/round failed/i.test(String(j.msg||''))) continue + total++ + const r = String(j.error || 'no reason recorded').slice(0,80) + byReason[r] = (byReason[r]||0)+1 + } catch {} + } + failures = { total, byReason: Object.entries(byReason).sort((x,y)=>y[1]-x[1]) } + } catch { failures = null } + console.log('@@' + JSON.stringify({ spans, failures, now: Date.now() })) + process.exit(0) + ` + + let got = null + try { + const out = String(CH.remote(`cd /app/apps/server && bun -e ${L.shq(prog)}`, { attempts: 2 })) + const i = out.indexOf('@@{') + if (i >= 0) got = JSON.parse(out.slice(i + 2)) + } catch { got = null } + if (!got) { + console.log('the swarm could not be read - NOTHING was measured. An unreadable board is not an idle one.') + process.exit(3) + } + if (!got.spans.length) { + console.log(`no dispatch at all in the last ${hours} hour(s). That is not idleness measured, it is a swarm that never started.`) + process.exit(2) + } + + const to = got.now + const from = to - hours * 3600 * 1000 + const T27 = await import(path.join(DIR, 't27-parity.mjs')) + const capacity = T27.ringConst('MAX_CONCURRENT_WORKERS') || 4 + const u = utilisation(got.spans, from, to, capacity) + const g = gapsOf(got.spans) + const v = verdictOnGaps(g.gaps, Number(process.env.TRIOS_TICK_SECONDS || 300)) + console.log(render(u, g, v, hours, got.failures)) + if (process.argv.includes('--json')) console.log(JSON.stringify({ u, g, v, failures: got.failures }, null, 1)) + console.log(`\n${u.idlePercent}% of the window had no bee working`) + process.exit(u.idlePercent >= 25 ? 2 : 0) +} diff --git a/trios/.trinity/loop/selftest.mjs b/trios/.trinity/loop/selftest.mjs index 7647fefb57..c9191a6d50 100644 --- a/trios/.trinity/loop/selftest.mjs +++ b/trios/.trinity/loop/selftest.mjs @@ -3035,6 +3035,216 @@ check('no block is unverdicted\'s question, and unreadable rows accuse nobody', } }) +// A COMMENT CLAIMING TWO FUNCTIONS AGREE IS A TEST THAT HAS NOT BEEN WRITTEN. +// +// `unjudgedCriteria` and `missingVerdictSlots` both answer "which promised +// criteria did the bee answer?" and disagree on 347 of 358 real rows, in BOTH +// directions - while the comment above the second asserted they could not +// disagree. That sentence is what kept the defect hidden for weeks. +check('two identical answers agree whatever order they arrive in', async () => { + const AG = await import('./agree.mjs') + const r = AG.classify({ id: 1, a: [3, 1, 2], b: [2, 3, 1] }) + if (r.kind !== 'agree') throw new Error(`the same set in a different order was called ${r.kind}`) + // A row neither side could answer is NOT agreement - counting it as one is + // how a gate reports health for rows it never looked at. + if (AG.classify({ id: 2, skip: 'no verdict block' }).kind !== 'skipped') throw new Error('an unanswerable row was counted as agreement') + for (const bad of [null, { id: 3 }, { id: 4, a: [1], b: 'nope' }]) { + if (AG.classify(bad).kind !== 'unknown') throw new Error('an unreadable row was counted against a side') + } +}) + +check('divergence is reported with its DIRECTION, never as a bare count', async () => { + const AG = await import('./agree.mjs') + const aMissed = AG.classify({ id: 5, tag: 'sendBack', a: [1, 2], b: [1, 2, 3], total: 3 }) + const bMissed = AG.classify({ id: 6, tag: 'accept', a: [1, 2, 3], b: [], total: 3 }) + if (aMissed.kind !== 'DIFFER' || bMissed.kind !== 'DIFFER') throw new Error('a real divergence was called agreement') + const d = AG.directions([aMissed, bMissed]) + if (d.aMisses !== 1 || d.bMisses !== 1) throw new Error(`directions counted ${d.aMisses}/${d.bMisses} instead of 1/1`) + const both = AG.render({ name: 'p', question: 'q', a: 'A', b: 'B' }, [aMissed, bMissed]) + if (!both.includes('BOTH directions')) throw new Error('a two-way divergence did not say so, which invites picking a winner') + // One-way divergence must NOT claim both directions - that is the branch that + // keeps the sentence meaningful when it does appear. + const oneWay = AG.render({ name: 'p', question: 'q', a: 'A', b: 'B' }, [aMissed]) + if (oneWay.includes('BOTH directions')) throw new Error('a one-way divergence claimed both directions') +}) + +// THE ONE NUMBER NOBODY MEASURED: is the swarm WORKING? +// +// Twenty instruments counted issues, verdicts, briefs and disagreements. The +// dashboard's `bees running (of 4)` is an INSTANT, sampled once an iteration, +// of a quantity that turned out to be bimodal - it read 4 as often as 0 and +// could never have shown that half the day had no bee running at all. +check('an instant is not a rate: idleness is counted over a window', async () => { + const I = await import('./idle.mjs') + const t0 = 1_000_000_000_000 + const min = 60_000 + // One bee for the first 10 minutes of a 20-minute window: half idle. + const u = I.utilisation([[t0, t0 + 10 * min]], t0, t0 + 20 * min, 4) + if (u.idlePercent !== 50) throw new Error(`half an idle window measured ${u.idlePercent}%`) + if (u.fullPercent !== 0) throw new Error('one bee of four was counted as full capacity') + // A window with nothing sampled is absent, not idle. + if (I.utilisation([], t0, t0 - 1, 4) !== null) throw new Error('an empty window returned a number instead of nothing') +}) + +check('overlapping bees are one burst, so no gap is invented between them', async () => { + const I = await import('./idle.mjs') + const t0 = 1_000_000_000_000 + const min = 60_000 + // Two bees running at once, then a real 10-minute gap, then another. + const g = I.gapsOf([[t0, t0 + 5 * min], [t0 + 2 * min, t0 + 6 * min], [t0 + 16 * min, t0 + 18 * min]]) + if (g.bursts !== 2) throw new Error(`two overlapping bees made ${g.bursts} bursts instead of one`) + if (g.gaps.length !== 1) throw new Error('an overlap invented a gap that never existed') + if (Math.round(g.gaps[0]) !== 10) throw new Error(`the gap measured ${g.gaps[0]} instead of 10 minutes`) +}) + +check('gaps longer than the tick mean rounds are starting nothing', async () => { + const I = await import('./idle.mjs') + // Most gaps far longer than a five-minute tick: the rounds are running and + // dispatching nothing, which is a different fault from "rounds are rare". + const bad = I.verdictOnGaps([20, 22, 30, 41], 300) + if (bad.kind !== 'ROUNDS-ARE-NOT-DISPATCHING') throw new Error(`long gaps were read as ${bad.kind}`) + // And the branch that refutes it: gaps within the cadence blame the cadence. + const ok = I.verdictOnGaps([1, 2, 3, 4], 300) + if (ok.kind !== 'within-cadence') throw new Error(`short gaps were read as ${ok.kind}`) + if (I.verdictOnGaps([], 300).kind !== 'no-gaps') throw new Error('no gaps at all produced a verdict about gaps') +}) + +// A GATE THAT EXISTS AND RUNS NOWHERE. +// +// `make queen-core-sync` compares the eleven policy files that exist twice, +// byte for byte, and appeared in no workflow. On 2026-09-06 one of the thirteen +// had already drifted: the ring gained a string-aware literal view on 09-05 and +// the Linux copy was last touched 08-29. +check('the declared gate list beats the word list, because names describe subjects', async () => { + const U = await import('./unwired.mjs') + const mk = ['check: type-floor t27-rings sources-drift', '', 'type-floor:', '\tgrep -c Any src', ''].join('\n') + const declared = U.declaredGates(mk) + if (!declared.has('type-floor') || !declared.has('t27-rings')) throw new Error('the declaration was not read') + // The correction this made: eleven of thirteen unwired gates were classified + // `not-a-gate` by the word list, because they are named after what they + // check rather than that they check it. `t27-rings` runs the 460-case parity + // and carries no gate word at all. + if (U.classify('t27-rings', ['\tbash ring.sh'], false).kind !== 'not-a-gate') throw new Error('the word list was expected to miss it') + if (U.classify('t27-rings', ['\tbash ring.sh'], true).kind === 'not-a-gate') throw new Error('a declared gate was still dismissed by name') + // And a Makefile with no `check:` line yields no declaration rather than a + // wrong one - an empty set, not a guess. + if (U.declaredGates('build:\n\techo hi\n').size !== 0) throw new Error('a declaration was invented where none exists') +}) + +check('an unwired script is not a finding when something says it should not run', async () => { + const U = await import('./unwired.mjs') + // A flat first pass listed nineteen unwired scripts and eighteen were fine. + // The classes are what leave one row worth reading. + const cases = [ + ['lint:fix', 'bunx biome check --write --unsafe', 'mutating'], + // `test:*` is outside the gate-word list on purpose, so these never reach a + // class at all - the audit says so in its own output rather than letting a + // count imply completeness. + ['test:watch', 'bun --watch test', 'not-a-gate'], + ['test:all', 'bun run ./tests/__helpers__/run-test-group.ts all', 'not-a-gate'], + ['check:watch', 'bun --watch qa', 'interactive'], + ['verify:all', 'bun run ./scripts/run-test-suite.ts all', 'aggregate'], + ['lint:cdp', 'bun run lint:browser', 'alias'], + ['check', 'bun run typecheck && bun run qa && bun run build', 'portable'], + ['dev', 'bun run server', 'not-a-gate'], + ] + for (const [name, body, want] of cases) { + const got = U.classifyScript(name, body).kind + if (got !== want) throw new Error(`${name} classified ${got}, expected ${want}`) + } + // The dashboard contract is the one that mattered: it must survive as a + // finding, not be filed away as an aggregate because it chains with &&. + const out = U.renderScripts([{ ...U.classifyScript('check', 'bun run typecheck && bun run qa'), pkg: 'apps/website' }], 14, 9) + if (!out.includes('apps/website')) throw new Error('the real finding was not reported') + if (!/biome ci/.test(out)) throw new Error('the limit about work wired under another command was not stated') +}) + +check('a recipe that calls make reaches that target, and a script is not judged', async () => { + const U = await import('./unwired.mjs') + // check-bypass is ONE LINE - `$(MAKE) check` - so following only + // prerequisites saw an empty recipe and called it portable, while `check` + // opens a window. Its own echo says "never for CI". + const t = U.targetsOf(['heavy:', '\tswiftc thing.swift', '', 'bypass-guard:', '\t$(MAKE) --no-print-directory heavy', ''].join('\n')) + const reached = U.reachedRecipes('bypass-guard', t) + if (U.classify('bypass-guard', reached).kind !== 'mac-only') throw new Error('a sub-make call was not followed') + // And the layer this cannot see at all: drift-guard is `bash script.sh`, with + // the compiler inside the script. Guessing there is how the first two + // misclassifications happened, so it answers `opaque` instead. + const sc = U.targetsOf(['deep-guard:', '\tbash tests/swift/run_chat_sse_e2e.sh', ''].join('\n')) + const r = U.classify('deep-guard', U.reachedRecipes('deep-guard', sc)) + if (r.kind !== 'opaque') throw new Error(`a target running a script was classified ${r.kind}`) + if (!/run_chat_sse_e2e\.sh/.test(r.why)) throw new Error('the report did not name the script it cannot see into') +}) + +check('a target with no recipe inherits what its prerequisites reach', async () => { + const U = await import('./unwired.mjs') + // The defect this file's own output caught before it shipped: `check:` and + // `verify:` have NO recipe - they are a list of other targets - so reading + // only the recipe called both portable while their prerequisites open a + // window and run swiftc. + const t = U.targetsOf(['heavy:', '\tswiftc thing.swift', '', 'check-all: heavy', ''].join('\n')) + const reached = U.reachedRecipes('check-all', t) + if (!reached.join('\n').includes('swiftc')) throw new Error('a prerequisite recipe was not reached') + if (U.classify('check-all', reached).kind !== 'mac-only') throw new Error('a target reaching swiftc was called portable') +}) + +check('a cycle in the Makefile does not hang the audit', async () => { + const U = await import('./unwired.mjs') + const t = U.targetsOf(['a-guard: b-guard', '\techo one', '', 'b-guard: a-guard', '\techo two', ''].join('\n')) + const reached = U.reachedRecipes('a-guard', t) + // Both recipes reached, each once, and the call returned at all. + if (!reached.join('\n').includes('echo two')) throw new Error('the cycle stopped before reaching the other side') +}) + +check('a wired target is not reported, and a plain name is not a gate', async () => { + const U = await import('./unwired.mjs') + const wired = U.wiredIn(' - run: make queen-core-sync\n - run: make other-thing\n') + if (!wired.has('queen-core-sync') || !wired.has('other-thing')) throw new Error('a workflow invocation was not seen') + // The branch that keeps the list short enough to read: most targets are not + // gates, and a report naming all fifty-seven would be ignored by week two. + if (U.classify('relaunch', ['\topen trios.app']).kind !== 'not-a-gate') throw new Error('a plain target was reported as a gate') +}) + +check('every declared pair can actually be run for its kind', async () => { + const AG = await import('./agree.mjs') + if (!AG.PAIRS.length) throw new Error('the registry is empty, so the gate compares nothing') + for (const pair of AG.PAIRS) { + for (const field of ['name', 'kind', 'question', 'a', 'b']) { + if (!pair[field]) throw new Error(`pair ${pair.name || '(unnamed)'} has no ${field}`) + } + if (pair.kind === 'remote' && !pair.program) throw new Error(`remote pair ${pair.name} has no program`) + if (pair.kind === 'local' && !(pair.script && typeof pair.pick === 'function')) { + throw new Error(`local pair ${pair.name} has no script and pick`) + } + if (!['remote', 'local'].includes(pair.kind)) throw new Error(`pair ${pair.name} has kind ${pair.kind}`) + } + // Two pairs must not share a name, or --pair picks whichever comes first and + // the other can never be run. + const names = AG.PAIRS.map((p) => p.name) + if (new Set(names).size !== names.length) throw new Error('two pairs share a name') +}) + +check('a side that could not be built is unknown, never agreement', async () => { + const AG = await import('./agree.mjs') + const boundary = AG.PAIRS.find((p) => p.name === 'boundary-ts-vs-swift') + if (!boundary) throw new Error('the cross-language pair is not registered') + // What `boundary-parity.ts` emits when swiftc is absent: the row is present + // and its Swift side is null. Counting that as agreement is how "all green" + // comes to mean "we looked at one of the two". + const picked = boundary.pick({ id: 42, js: ['a/b.ts'], ts: ['a/b.ts'], swift: null }) + const r = AG.classify(picked) + if (r.kind !== 'unknown') throw new Error(`an unbuilt side was classified ${r.kind}`) + // And with both sides present it must compare them rather than pass anything. + const both = AG.classify(boundary.pick({ id: 43, ts: ['a/b.ts'], swift: ['c/d.ts'] })) + if (both.kind !== 'DIFFER') throw new Error('two different answers were not reported as a divergence') +}) + +check('agreement is reported as agreement, never as proof', async () => { + const AG = await import('./agree.mjs') + const out = AG.render({ name: 'p', question: 'q', a: 'A', b: 'B' }, [AG.classify({ id: 7, a: [1], b: [1] })]) + if (!/NOT a proof/.test(out)) throw new Error('agreement was reported without the limit that makes it honest') +}) + check('an empty denominator is a dash, never a zero percent', async () => { const A = await import('./accept-rate.mjs') const s = A.split([{ number: 9999, body: '' }], () => null, () => true) diff --git a/trios/.trinity/loop/snapshot.mjs b/trios/.trinity/loop/snapshot.mjs index db118bd3ac..6d94d90d23 100644 --- a/trios/.trinity/loop/snapshot.mjs +++ b/trios/.trinity/loop/snapshot.mjs @@ -74,6 +74,8 @@ const skipMetric = (key, label, v) => // nothing has been. An absent reading is absent, never zero. const recorded = D.lastReading() const looping = recorded && recorded.looping ? recorded.looping.looping : null +const divergent = recorded && recorded.divergent ? recorded.divergent.rows : null +const idlePct = recorded && typeof recorded.idle === 'number' ? recorded.idle : null const swarm = j.error ? [{ k: 'QUEEN UNREACHABLE', v: j.error.slice(0, 20), prev: null, goodDown: true }] @@ -87,9 +89,20 @@ const swarm = j.error // An attempt is charged against the retry ceiling only when a criterion // was tested and FAILED, so a bee that goes silent is deliberately not // charged. A bee silent EVERY time therefore loops with no ceiling at all. + // THE GOLDEN RULE, as a number: the bees work, and start again the moment + // they finish. The row above this one is an instant of a bimodal quantity + // and hid a swarm that was idle half the day. + idlePct === null + ? { k: 'hours 12: no bee working, percent', v: 'not measured', prev: null, goodDown: true } + : metric('idle.percent', 'hours 12: no bee working, percent', idlePct), looping === null ? { k: 'send-backs looping with no ceiling', v: 'not measured', prev: null, goodDown: true } : metric('looping.noCeiling', 'send-backs looping with no ceiling', looping), + // Two implementations of one rule, disagreeing. Standing debt: it does + // not fall when one side is fixed, only when one of them stops existing. + divergent === null + ? { k: 'rows where one rule answers two ways', v: 'not measured', prev: null, goodDown: true } + : metric('agree.divergent', 'rows where one rule answers two ways', divergent), ] // argv only when this file IS the program. See loop.mjs: an importer's own diff --git a/trios/.trinity/loop/unwired.mjs b/trios/.trinity/loop/unwired.mjs new file mode 100644 index 0000000000..e7f8cbc466 --- /dev/null +++ b/trios/.trinity/loop/unwired.mjs @@ -0,0 +1,353 @@ +#!/usr/bin/env node +// A gate that exists and runs nowhere. +// +// WHAT THIS FOUND. `make queen-core-sync` compares the eleven policy files that +// exist twice - `rings/SR-00` for the app, `agent-server/queen-core` for the +// Linux container - byte for byte. Its comment states the stakes exactly: "a +// policy that differs between them is two arbiters of the same rule." +// +// It appeared in no workflow. On 2026-09-06, twelve of thirteen files were +// identical and one was not: the ring gained a string-aware literal view on +// 2026-09-05 and the copy that ships to Linux was last touched on 2026-08-29. +// The fix never crossed, and it was not a comment - the copy is missing the +// whole escape-and-quote branch. +// +// The gate was written, was correct, and had never been asked. Writing a check +// is the easy half; the half that decides whether it protects anything is +// whether something runs it without being reminded. +// +// WHY A LIST OF UNWIRED TARGETS IS NOT ENOUGH. Sixty-eight targets, eleven +// invoked by a workflow. Most of the rest SHOULD be local: `make` builds an +// app, `run` launches it, `relaunch` needs a window server. A report that +// listed all fifty-seven would be ignored by the second week, correctly. +// +// So this asks two questions rather than one: +// +// 1. Does the target LOOK like a gate? A name carrying check, gate, guard, +// audit, verify, drift, sync, parity or seal is a promise to refuse +// something. Naming is weak evidence, which is why it is not the only +// question. +// 2. COULD it run on a CI runner at all? A recipe that shells out to +// xcodebuild, swiftc, `open`, or the app bundle needs a Mac with a window +// server. One that runs cmp, grep, node or bun needs nothing. The second +// group is the actionable one, and it is where `queen-core-sync` sat. +// +// It reports the first group and RANKS the second, rather than accusing +// everything with a suggestive name. +// +// Usage: +// node unwired.mjs # gates nobody runs, portable ones first +// node unwired.mjs --all # every unwired target, including local-only + +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' + +const DIR = path.dirname(fileURLToPath(import.meta.url)) +const isMain = process.argv[1] && process.argv[1].endsWith('/unwired.mjs') + +/** A name that promises to refuse something. */ +export const GATE_WORDS = [ + 'check', 'gate', 'guard', 'audit', 'verify', 'drift', 'sync', 'parity', + 'seal', 'prove', 'honest', 'selftest', 'lint', +] + +/** Tools that need a desktop, a window server or Apple's toolchain. */ +export const MAC_ONLY = [ + 'xcodebuild', 'xcrun', 'swiftc', 'swift build', 'osascript', 'open ', + 'codesign', 'security ', 'defaults ', '.app', 'DEVELOPER_DIR', +] + +/** + * Every target in a Makefile, with the recipe lines that belong to it. + * + * Parsed rather than asked of `make`, because `make -qp` runs the file and this + * has to work against a checkout it is not standing in - the shipping ref, most + * often, which is where the answer that matters lives. + */ +export function targetsOf(makefile) { + const out = new Map() + let current = null + for (const raw of String(makefile || '').split('\n')) { + const header = raw.match(/^([a-zA-Z][a-zA-Z0-9._-]*)\s*:(?!=)(.*)$/) + if (header) { + current = header[1] + if (!out.has(current)) out.set(current, { recipe: [], needs: [] }) + // The prerequisites matter as much as the recipe: `check:` and `verify:` + // have NO recipe at all - they are a list of other targets - and reading + // only the recipe called both of them portable while their prerequisites + // build an app and open a window. Found by checking the tool's own output + // against two entries in it, before it shipped. + for (const dep of header[2].trim().split(/\s+/).filter(Boolean)) { + if (/^[a-zA-Z][a-zA-Z0-9._-]*$/.test(dep)) out.get(current).needs.push(dep) + } + continue + } + if (current && (raw.startsWith('\t') || raw.startsWith(' '))) { + out.get(current).recipe.push(raw) + continue + } + if (raw.trim() === '') continue + if (!raw.startsWith('\t')) current = null + } + return out +} + +/** + * Every recipe line a target reaches, through its prerequisites. + * + * Depth-limited and cycle-guarded rather than trusting the graph: a Makefile + * with a loop in it would otherwise hang the audit, and an audit that hangs is + * one somebody removes from the chain. + */ +export function reachedRecipes(name, targets, seen = new Set()) { + if (seen.has(name) || seen.size > 200) return [] + seen.add(name) + const node = targets.get(name) + if (!node) return [] + let lines = [...node.recipe] + // A RECIPE THAT CALLS `make` REACHES THAT TARGET TOO, and missing this was the + // second defect of the same family as the first. `check-bypass` is one line - + // `$(MAKE) check` - so following only prerequisites saw an empty recipe and + // called it portable, while `check` builds an app and opens a window. Its own + // echo says "never for CI", which is how it was caught: the tool disagreed + // with the target's own description of itself. + const submakes = new Set() + for (const line of node.recipe) { + for (const m of line.matchAll(/\$\((?:MAKE|make)\)[^\n]*?\s([a-zA-Z][a-zA-Z0-9._-]*)/g)) submakes.add(m[1]) + for (const m of line.matchAll(/\bmake\s+(?:--\S+\s+)*([a-zA-Z][a-zA-Z0-9._-]*)/g)) submakes.add(m[1]) + } + for (const dep of [...node.needs, ...submakes]) { + lines = lines.concat(reachedRecipes(dep, targets, seen)) + } + return lines +} + +/** Targets a workflow invokes, however the line is spelled. */ +export function wiredIn(workflowText) { + const wired = new Set() + for (const m of String(workflowText || '').matchAll(/\bmake\s+([a-zA-Z][a-zA-Z0-9._-]*)/g)) { + wired.add(m[1]) + } + return wired +} + +/** + * Sort one unwired target. + * + * `portable` is the finding. `mac-only` is an explanation, not an excuse - it + * says why nobody wired it, and a reader may still decide the gate deserves a + * macOS job. `not-a-gate` is everything else and is not reported by default. + */ +/** + * The gates the repository declares, rather than the ones a word list guesses. + * + * `make check` names its own suite in one line of prerequisites. That list is + * authoritative in a way no heuristic can be, and reading it corrected this file + * badly: of thirteen unwired gates it declares, ELEVEN were classified + * `not-a-gate` here because they are named after their SUBJECT and not their + * function - `t27-rings`, `type-floor`, `recipe-backticks`, `variant-fence`, + * `vendor-step`, `skill-frontmatter`. `t27-rings` runs the 460-case parity + * between the generated ring and the policy binary, and this file called it not + * a gate. + * + * WHEN THE SYSTEM UNDER AUDIT DECLARES THE THING YOU ARE INFERRING, READ THE + * DECLARATION. The word list stays as the fallback for targets outside the + * suite, where nothing has declared anything. + */ +export function declaredGates(makefile) { + const m = String(makefile || '').match(/^check:(.*)$/m) + if (!m) return new Set() + return new Set(m[1].split(/\s+/).filter(Boolean)) +} + +export function classify(name, recipe, declared = false) { + const looksLikeGate = declared || GATE_WORDS.some((w) => name.toLowerCase().includes(w)) + if (!looksLikeGate) return { name, kind: 'not-a-gate' } + const body = (recipe || []).join('\n') + const needsMac = MAC_ONLY.filter((t) => body.includes(t)) + if (needsMac.length) return { name, kind: 'mac-only', why: `it reaches ${needsMac.slice(0, 3).join(', ')}` } + // A RECIPE THAT SHELLS OUT TO A SCRIPT IS OPAQUE FROM HERE, and saying so is + // the difference between a heuristic and a claim. `drift-guard` is a single + // line - `bash tests/swift/run_chat_sse_e2e.sh` with an env var set - and it + // called the Swift compiler inside that script for four minutes before a + // 240-second run gave up on it. This scan sees one layer; a script is the + // second, and guessing about it is how the first two misclassifications + // happened. Third of the same family: prerequisites, then $(MAKE), now this. + const scripts = [...body.matchAll(/([\w./$()-]*\.sh)\b/g)].map((m) => m[1]) + if (scripts.length) { + return { name, kind: 'opaque', why: `it runs ${scripts[0].split('/').pop()}, and this cannot see inside a script` } + } + return { name, kind: 'portable', why: 'nothing it reaches needs a desktop, so a runner could do this' } +} + +/** + * A package script, sorted the way a make target is - but the categories differ + * because the dialect does. + * + * MOST UNWIRED SCRIPTS ARE LEGITIMATELY UNWIRED, and this is the lesson that + * shaped the classes. A first pass listed nineteen and eighteen of them were + * fine: `test:all` and `test:core` are aggregates CI deliberately splits into + * per-group jobs, `test:cdp` is an alias for a script that IS wired, + * `test:cleanup` is a helper other scripts call, `lint:fix` mutates and must + * never run in CI, `test:watch` is interactive. Reported flat, that list would + * bury the one row worth reading. + * + * THE ONE IT CANNOT DECIDE, and says so: a script whose WORK is wired under a + * different command. `lint` is `bunx biome check`, and CI runs `biome ci .` - + * the linting happens, the script name never appears. That is the same shape as + * `drift-guard`, whose script the macOS job already runs. Named in the output + * rather than silently dropped, because guessing either way has been wrong. + */ +export function classifyScript(name, body) { + const looksLikeGate = GATE_WORDS.some((w) => name.toLowerCase().includes(w)) + if (!looksLikeGate) return { name, kind: 'not-a-gate' } + const text = String(body || '') + if (/--write|--fix|--unsafe/.test(text)) return { name, kind: 'mutating', why: 'it rewrites files, so CI is the wrong place for it' } + if (/--watch/.test(text)) return { name, kind: 'interactive', why: 'it watches, so it never exits' } + if (/\brun-test-group|run-test-suite\b/.test(text) && /\b(all|core|main)\b/.test(text)) { + return { name, kind: 'aggregate', why: 'it runs every group at once; CI splits them into jobs on purpose' } + } + const alias = text.match(/^\s*bun run ([\w:.-]+)\s*$/) + if (alias) return { name, kind: 'alias', why: `it is just \`${alias[1]}\`` } + return { name, kind: 'portable', why: 'nothing about it says CI is the wrong place' } +} + +export function renderScripts(rows, total, wired) { + const by = {} + for (const r of rows) (by[r.kind] ||= []).push(r) + const out = [`${total} gate-shaped package script(s), ${wired} invoked by a workflow`, ''] + const portable = by.portable || [] + out.push(`${portable.length} that nothing runs and nothing says should not run:`) + for (const r of portable) out.push(` ${r.pkg} ${r.name}`) + if (!portable.length) out.push(' (none)') + for (const kind of ['aggregate', 'alias', 'mutating', 'interactive']) { + const g = by[kind] || [] + if (!g.length) continue + out.push(` ${g.length} ${kind}: ${g.map((r) => r.name).join(' ')}`) + } + out.push('') + out.push('A script whose WORK is wired under a different command still appears above:') + out.push('`lint` is `biome check` and CI runs `biome ci .` - the linting happens and') + out.push('the name never does. Read the row before acting on it.') + out.push('') + out.push('NOT AUDITED HERE: `test:*` scripts. The word is missing from the gate list') + out.push('on purpose - including it buries the report in aggregates and aliases - and') + out.push('that coverage already has a better guard: run-test-group.test.ts reads the') + out.push('CI matrix and fails when a group has no job, in both directions.') + return out.join('\n') +} + +export function render(rows, targetCount, wiredCount, showAll = false) { + const by = { portable: [], 'mac-only': [], opaque: [], 'not-a-gate': [] } + for (const r of rows) by[r.kind].push(r) + const out = [ + `${targetCount} make target(s), ${wiredCount} invoked by a workflow`, + '', + ] + out.push(`${by.portable.length} gate(s) that nothing runs and a CI runner COULD run:`) + for (const r of by.portable) out.push(` ${r.name}`) + if (!by.portable.length) out.push(' (none - every portable gate is wired)') + out.push('') + out.push(`${by['mac-only'].length} more look like gates but need a desktop:`) + for (const r of by['mac-only']) out.push(` ${r.name} - ${r.why}`) + if (by.opaque.length) { + out.push('') + out.push(`${by.opaque.length} cannot be judged from here - they run a script:`) + for (const r of by.opaque) out.push(` ${r.name} - ${r.why}`) + } + if (showAll && by['not-a-gate'].length) { + out.push('') + out.push(`${by['not-a-gate'].length} unwired target(s) whose names promise nothing:`) + out.push(` ${by['not-a-gate'].map((r) => r.name).join(' ')}`) + } + out.push('') + out.push('A NAME IS WEAK EVIDENCE and this leans on it, so read the list rather') + out.push('than counting it. What it cannot tell you is whether a gate is worth') + out.push('running - only that nothing is running it. `queen-core-sync` sat in the') + out.push('portable group while the copy it guards had already drifted.') + return out.join('\n') +} + +if (isMain) { + const { execFileSync } = await import('node:child_process') + const ROOT = process.env.TRIOS_ROOT || '/Users/playra/BrowserOS' + const REF = process.env.TRIOS_SHIP_REF || 'origin/feat/queen-supervisor' + + // READ THE SHIPPING REF, not this checkout. The local tree is hundreds of + // commits behind what runs, and a gate wired upstream would read as unwired + // here - an accusation produced entirely by standing in the wrong place. + const show = (p) => { + try { + return String(execFileSync('git', ['show', `${REF}:${p}`], { + cwd: ROOT, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'], + })) + } catch { return null } + } + + const makefile = show('trios/Makefile') + if (!makefile) { + console.log(`the Makefile could not be read at ${REF} - NOTHING was examined.`) + process.exit(3) + } + + let workflows = '' + try { + const names = String(execFileSync('git', ['ls-tree', '--name-only', `${REF}`, '.github/workflows/'], { + cwd: ROOT, encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], + })).split('\n').filter(Boolean) + for (const n of names) workflows += `${show(n) ?? ''}\n` + } catch { workflows = '' } + if (!workflows.trim()) { + console.log(`no workflow could be read at ${REF} - every target would look unwired, so NOTHING is reported.`) + process.exit(3) + } + + const targets = targetsOf(makefile) + const wired = wiredIn(workflows) + const declared = declaredGates(makefile) + const rows = [] + for (const [name] of targets) { + if (wired.has(name)) continue + rows.push(classify(name, reachedRecipes(name, targets), declared.has(name))) + } + const declaredUnwired = [...declared].filter((g) => !wired.has(g)) + console.log(`\`make check\` declares ${declared.size} gate(s); ${declared.size - declaredUnwired.length} of them run in a workflow.\n`) + console.log(render(rows, targets.size, wired.size, process.argv.includes('--all'))) + + // MAKE WAS ONLY ONE DIALECT. The same question asked of package scripts found + // the t27.ai dashboard's 194-check review-lifecycle contract, which appeared + // in no workflow at all - the string `apps/website` matched nothing. + let manifests = [] + try { + manifests = String(execFileSync('git', ['ls-tree', '-r', '--name-only', REF], { + cwd: ROOT, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'], + })).split('\n').filter((f) => f.endsWith('package.json') && !f.includes('node_modules')) + } catch { manifests = [] } + + const scriptRows = [] + let scriptTotal = 0 + let scriptWired = 0 + for (const file of manifests) { + let scripts = {} + try { scripts = JSON.parse(show(file) || '{}').scripts || {} } catch { continue } + const pkg = file.replace(/^trios\/(agent-server\/)?/, '').replace(/\/package\.json$/, '') || 'root' + for (const [name, body] of Object.entries(scripts)) { + const row = classifyScript(name, body) + if (row.kind === 'not-a-gate') continue + scriptTotal++ + const called = new RegExp(`\\b(?:bun|npm|pnpm|yarn)\\s+(?:run\\s+)?${name.replace(/[.*+?^\${}()|[\]\\]/g, '\\$&')}(?:\\s|$|\\))`, 'm') + if (called.test(workflows)) { scriptWired++; continue } + scriptRows.push({ ...row, pkg }) + } + } + if (scriptTotal) { + console.log('') + console.log(renderScripts(scriptRows, scriptTotal, scriptWired)) + } + + const portable = rows.filter((r) => r.kind === 'portable').length + const scriptPortable = scriptRows.filter((r) => r.kind === 'portable').length + console.log(`\n${portable} portable make gate(s) and ${scriptPortable} package script(s) that nothing runs`) + process.exit(portable + scriptPortable ? 2 : 0) +}