From 6c28a7b765e78d15c2ab9be334ddd8c02d73f7ec Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Sun, 6 Sep 2026 12:43:54 +0700 Subject: [PATCH 01/10] feat(loop): tri agree - two implementations of one rule, asked the same question MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit This repository's most expensive defects are all one shape: a rule written twice, drifting, 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. Yesterday it cost 153 dispatches, and the sentence that hid it was a comment asserting the two functions could not disagree. 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 does not reason about the code, it runs both implementations over the rows the system actually holds. The precedent is already here - `t27-parity.mjs` does exactly this across languages, 460 cases against the deployed twin. This is the same instrument pointed at two functions in one file, which is where drift is cheapest to create and hardest to see. FIRST RUN, AND IT CORRECTED THE ROUND THAT MOTIVATED IT. 358 rows compared, 11 agree, 347 differ - and the divergence runs BOTH ways: A missed something B found on 196 rows, B missed something A found on 151. I had written that `missingVerdictSlots` was "the right answer the repository already had". It is not: 156 blocks carry no numbering at all and slot matching finds NOTHING covered on every one of them, so wiring it would have marked every criterion in 44% of the board unanswered. Corrected at the call site and on the PR. So divergence is reported with its DIRECTION. A bare "347 differ" invites picking a winner, and here neither side is the rule. AGREEMENT IS NOT CORRECTNESS - two implementations can be wrong together and this cannot see that, the same limit `rejudge` carries and states. What it rules out is the other case: a divergence sitting in the open behind a sentence saying it cannot happen. Three calibration cases, each proving the negative: a row neither side could answer is not agreement, an unreadable row accuses nobody, a one-way divergence must not claim both directions, and agreement is never reported as proof. Co-Authored-By: Claude Opus 5 --- trios/.trinity/loop/agree.mjs | 221 +++++++++++++++++++++++++++++++ trios/.trinity/loop/heal.mjs | 1 + trios/.trinity/loop/selftest.mjs | 39 ++++++ 3 files changed, 261 insertions(+) create mode 100644 trios/.trinity/loop/agree.mjs diff --git a/trios/.trinity/loop/agree.mjs b/trios/.trinity/loop/agree.mjs new file mode 100644 index 000000000..1571abed5 --- /dev/null +++ b/trios/.trinity/loop/agree.mjs @@ -0,0 +1,221 @@ +#!/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', + 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 }) + } + `, + }, +] + +/** 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 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) + } + + let diverged = 0 + let measured = 0 + for (const pair of pairs) { + 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) + ` + let rows = null + try { + const res = String(CH.remote(`cd /app/apps/server && bun -e ${L.shq(prog)}`, { attempts: 2 })) + const i = res.indexOf('@@[') + if (i >= 0) rows = JSON.parse(res.slice(i + 2)) + } catch { rows = null } + if (!rows) { + console.log(`pair "${pair.name}": the rows could not be read - NOTHING was compared. An unreachable board is not an agreeing one.`) + continue + } + measured++ + const judged = rows.map(classify) + console.log(render(pair, judged, limit)) + console.log('') + diverged += judged.filter((r) => r.kind === 'DIFFER').length + } + if (!measured) process.exit(3) + console.log(`${measured} pair(s) compared, ${diverged} diverging row(s)`) + process.exit(diverged ? 2 : 0) +} diff --git a/trios/.trinity/loop/heal.mjs b/trios/.trinity/loop/heal.mjs index 1ea0e8a7c..0570481df 100644 --- a/trios/.trinity/loop/heal.mjs +++ b/trios/.trinity/loop/heal.mjs @@ -200,6 +200,7 @@ 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: '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/selftest.mjs b/trios/.trinity/loop/selftest.mjs index 7647fefb5..c1780a213 100644 --- a/trios/.trinity/loop/selftest.mjs +++ b/trios/.trinity/loop/selftest.mjs @@ -3035,6 +3035,45 @@ 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') +}) + +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) From 233ed2b9b335eee6549822ce885f4760996bc2d7 Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Sun, 6 Sep 2026 12:49:33 +0700 Subject: [PATCH 02/10] feat(loop): a duplicated rule is debt on the dashboard, not an incident `tri agree` measures it; the box the operator reads is rendered by snapshot.mjs from its own list, so without this the number lived in a file nobody opens. The row does NOT fall to zero when one side is fixed. `unjudgedCriteria` and `missingVerdictSlots` will keep disagreeing on the 156 unnumbered blocks until one of them is deleted or wired properly - which is exactly the point. A rule implemented twice is a liability for as long as it exists, not only on the day it bites, and a metric that vanished on the fix would say the opposite. Read from the recorded reading rather than recomputed, the rule `provenCounts` already follows: the comparison asks production and takes about a minute, and snapshot runs on the tick path. An absent reading renders "not measured", never zero, and does not write the anchor. Co-Authored-By: Claude Opus 5 --- trios/.trinity/loop/dash.mjs | 17 +++++++++++++++++ trios/.trinity/loop/snapshot.mjs | 6 ++++++ 2 files changed, 23 insertions(+) diff --git a/trios/.trinity/loop/dash.mjs b/trios/.trinity/loop/dash.mjs index 4041abbf3..73a3ac743 100644 --- a/trios/.trinity/loop/dash.mjs +++ b/trios/.trinity/loop/dash.mjs @@ -94,6 +94,21 @@ 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 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 +267,7 @@ export function facts(deps = {}) { return { swarm: measure(() => swarmCounts(run)), looping: measure(() => loopingCounts(run)), + divergent: measure(() => divergentRows(run)), worstStep: measure(() => worstStep(run)), proven: measure(() => (read ? provenCounts(read) : provenCounts())), selftest: measure(() => (read ? selftestCases(read) : selftestCases())), @@ -313,6 +329,7 @@ export function rows(f, prev) { { 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: '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/snapshot.mjs b/trios/.trinity/loop/snapshot.mjs index db118bd3a..52293e9e4 100644 --- a/trios/.trinity/loop/snapshot.mjs +++ b/trios/.trinity/loop/snapshot.mjs @@ -74,6 +74,7 @@ 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 swarm = j.error ? [{ k: 'QUEEN UNREACHABLE', v: j.error.slice(0, 20), prev: null, goodDown: true }] @@ -90,6 +91,11 @@ const swarm = j.error 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 From c1e37187dae7e0cef78594bda0fb2d5ef76302f1 Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Sun, 6 Sep 2026 12:59:57 +0700 Subject: [PATCH 03/10] feat(loop): the boundary rule, read by all three of its implementations at once MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `tri agree` had one pair and one shape - two functions in one TypeScript file. A gate that only works on the case it was written for is not a gate. This adds the hardest shape available: one rule, three languages, three processes. "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. 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 stated reason expired and nobody noticed, which is the argument for testing the claim rather than 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. THE ANSWER IS AGREEMENT, and that is worth reporting plainly. All three return identical path lists on all 521 issue bodies that carry a boundary section, and on 18 adversarial shapes besides - backticked paths followed by commas, nested brackets, tab-separated markers, markdown emphasis, two paths on one line. The Swift comment predicts that sequential stripping mishandles a backtick before a comma; it does not, because the TypeScript trailing class contains the backtick. So this pair is a regression guard rather than a find, and it says so. A MISSING COMPILER IS NOT AGREEMENT. With `swiftc` unavailable the run reports 521 rows unreadable, names the side that would not build, claims nothing, and exits 3 - not 0. A pair that compared zero rows no longer counts as a pair that ran, because "0 diverging rows" must never be able to mean "we could not look". The TypeScript side is EXTRACTED from the shipping ref rather than reimplemented. A reimplementation would be the fifth copy of this rule, in the file whose whole purpose is to complain about there being four. One build per script, shared by both boundary pairs: building the ring twice to answer two questions about the same rows could produce two different answers for one run, which is the confusion this tool exists to remove. Two calibration cases: every declared pair is runnable for its kind and no two share a name, and a side that could not be built classifies as unknown rather than agreement. Co-Authored-By: Claude Opus 5 --- trios/.trinity/loop/agree.mjs | 104 +++++++++++++++-- trios/.trinity/loop/boundary-parity.ts | 155 +++++++++++++++++++++++++ trios/.trinity/loop/selftest.mjs | 34 ++++++ 3 files changed, 284 insertions(+), 9 deletions(-) create mode 100644 trios/.trinity/loop/boundary-parity.ts diff --git a/trios/.trinity/loop/agree.mjs b/trios/.trinity/loop/agree.mjs index 1571abed5..495023673 100644 --- a/trios/.trinity/loop/agree.mjs +++ b/trios/.trinity/loop/agree.mjs @@ -66,6 +66,7 @@ const isMain = process.argv[1] && process.argv[1].endsWith('/agree.mjs') 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)', @@ -95,6 +96,40 @@ export const PAIRS = [ } `, }, + // 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? */ @@ -176,6 +211,7 @@ export function render(pair, rows, limit = 6) { 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 @@ -187,9 +223,8 @@ if (isMain) { process.exit(2) } - let diverged = 0 - let measured = 0 - for (const pair of pairs) { + /** 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') @@ -199,23 +234,74 @@ if (isMain) { console.log('@@' + JSON.stringify(out)) process.exit(0) ` - let rows = null try { const res = String(CH.remote(`cd /app/apps/server && bun -e ${L.shq(prog)}`, { attempts: 2 })) const i = res.indexOf('@@[') - if (i >= 0) rows = JSON.parse(res.slice(i + 2)) - } catch { rows = null } + 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 board is not an agreeing one.`) + console.log(`pair "${pair.name}": the rows could not be read - NOTHING was compared. An unreachable source is not an agreeing one.\n`) continue } - measured++ 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) process.exit(3) + 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 000000000..68f22aae0 --- /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/selftest.mjs b/trios/.trinity/loop/selftest.mjs index c1780a213..b7334389a 100644 --- a/trios/.trinity/loop/selftest.mjs +++ b/trios/.trinity/loop/selftest.mjs @@ -3068,6 +3068,40 @@ check('divergence is reported with its DIRECTION, never as a bare count', async if (oneWay.includes('BOTH directions')) throw new Error('a one-way divergence claimed both directions') }) +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] })]) From 1b0512c5eff284541162a33b8831025752c5876f Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Sun, 6 Sep 2026 13:25:39 +0700 Subject: [PATCH 04/10] feat(loop): tri idle - the one number nobody was measuring The operator's golden rule is that the bees work, and start again the moment they finish. Twenty instruments in this directory count issues, verdicts, briefs, branches and disagreements. Not one of them measured whether the swarm was WORKING, and it had been false for a long time: minutes with ZERO bees working : 356 of 721 (49%) minutes with every bee working : 268 (37%) median gap between bursts : 18.4 min against a 5-minute tick gaps under a minute : 1 of 18 WHY THE DASHBOARD COULD NOT SHOW IT. `bees running (of 4)` has been on the board for weeks. It is an INSTANT - one sample per iteration - of a quantity that turns out to be bimodal, and it read 4 as often as 0. A rate needs a window; an instant needs none, which is exactly why the instant is the one that gets measured. It also names what stopped the rounds, from the service log: 135 of 136 recent round failures were `GitHub returned 403`, and one was `deadlock detected` - the second of which nothing else in this repository has ever mentioned. IT RESTARTS NOTHING. Idleness has several causes - a failing round, an empty backlog, a full volume, a refused key - and the cure differs for each, so this names the cause and stops. Three calibration cases, and one of them caught a real defect in this file before it shipped: a twenty-minute window sampled inclusively holds twenty-ONE one-minute buckets, so a half-idle window measured 52%. The loop is `t < to` now. That is what those cases are for. The other two pin the parts that would otherwise flatter: overlapping bees are one burst so no gap is invented between them, and gaps longer than the tick are reported as "rounds are running and starting nothing" - a different fault from "rounds are rare", with a branch that refutes it when gaps are within cadence. Co-Authored-By: Claude Opus 5 --- trios/.trinity/loop/dash.mjs | 17 +++ trios/.trinity/loop/heal.mjs | 1 + trios/.trinity/loop/idle.mjs | 212 +++++++++++++++++++++++++++++++ trios/.trinity/loop/selftest.mjs | 41 ++++++ trios/.trinity/loop/snapshot.mjs | 7 + 5 files changed, 278 insertions(+) create mode 100644 trios/.trinity/loop/idle.mjs diff --git a/trios/.trinity/loop/dash.mjs b/trios/.trinity/loop/dash.mjs index 73a3ac743..ec5063be7 100644 --- a/trios/.trinity/loop/dash.mjs +++ b/trios/.trinity/loop/dash.mjs @@ -109,6 +109,21 @@ export function divergentRows(run = sh) { 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 - @@ -267,6 +282,7 @@ 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())), @@ -328,6 +344,7 @@ 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 }, diff --git a/trios/.trinity/loop/heal.mjs b/trios/.trinity/loop/heal.mjs index 0570481df..c2aacb0cb 100644 --- a/trios/.trinity/loop/heal.mjs +++ b/trios/.trinity/loop/heal.mjs @@ -200,6 +200,7 @@ 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: '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' }, diff --git a/trios/.trinity/loop/idle.mjs b/trios/.trinity/loop/idle.mjs new file mode 100644 index 000000000..603521cac --- /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 b7334389a..d79f42e9a 100644 --- a/trios/.trinity/loop/selftest.mjs +++ b/trios/.trinity/loop/selftest.mjs @@ -3068,6 +3068,47 @@ check('divergence is reported with its DIRECTION, never as a bare count', async 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') +}) + 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') diff --git a/trios/.trinity/loop/snapshot.mjs b/trios/.trinity/loop/snapshot.mjs index 52293e9e4..6d94d90d2 100644 --- a/trios/.trinity/loop/snapshot.mjs +++ b/trios/.trinity/loop/snapshot.mjs @@ -75,6 +75,7 @@ const skipMetric = (key, label, v) => 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 }] @@ -88,6 +89,12 @@ 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), From c95564b9830ea1153a1a20268387b88df3fafc58 Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Sun, 6 Sep 2026 15:32:00 +0700 Subject: [PATCH 05/10] feat(loop): tri unwired - a gate that exists and runs nowhere `make queen-core-sync` compares the eleven policy files that exist twice - the ring the app compiles and the copy the Docker build compiles for Linux - byte for byte. Its own comment states the stakes: "a policy that differs between them is two arbiters of the same rule." It appeared in no workflow, and one of the thirteen files had already drifted. Writing a check is the easy half. The half that decides whether it protects anything is whether something runs it without being reminded, and nothing in this repository was asking that question. Sixty-eight targets, eleven invoked by a workflow. A LIST OF FIFTY-SEVEN WOULD BE IGNORED BY WEEK TWO, correctly: most of them should be local. `make` builds an app, `run` launches it, `relaunch` needs a window server. So this asks two questions instead of one - does the name promise to refuse something, and could a runner execute it at all - and reports nine rather than fifty-seven. THE TOOL'S OWN OUTPUT CAUGHT A DEFECT IN IT before it shipped. `check` and `verify` appeared in the portable group, which is wrong: both have NO recipe at all - they are a list of other targets - and reading only the recipe found no `swiftc` to object to. Following prerequisites moves them where they belong, and it is why the count went from eleven to nine. Read your own report against two entries you already know the answer for. It reads the SHIPPING REF, not this checkout, which is hundreds of commits behind: a gate wired upstream would otherwise read as unwired here, an accusation produced entirely by standing in the wrong place. Three calibration cases: a target with no recipe inherits what its prerequisites reach, a cycle does not hang the audit, and a plain name is not reported as a gate. Co-Authored-By: Claude Opus 5 --- trios/.trinity/loop/heal.mjs | 1 + trios/.trinity/loop/selftest.mjs | 35 ++++++ trios/.trinity/loop/unwired.mjs | 209 +++++++++++++++++++++++++++++++ 3 files changed, 245 insertions(+) create mode 100644 trios/.trinity/loop/unwired.mjs diff --git a/trios/.trinity/loop/heal.mjs b/trios/.trinity/loop/heal.mjs index c2aacb0cb..2a18d812f 100644 --- a/trios/.trinity/loop/heal.mjs +++ b/trios/.trinity/loop/heal.mjs @@ -201,6 +201,7 @@ const STEPS = [ { 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' }, diff --git a/trios/.trinity/loop/selftest.mjs b/trios/.trinity/loop/selftest.mjs index d79f42e9a..ec26b4e69 100644 --- a/trios/.trinity/loop/selftest.mjs +++ b/trios/.trinity/loop/selftest.mjs @@ -3109,6 +3109,41 @@ check('gaps longer than the tick mean rounds are starting nothing', async () => 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('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') diff --git a/trios/.trinity/loop/unwired.mjs b/trios/.trinity/loop/unwired.mjs new file mode 100644 index 000000000..a14316c85 --- /dev/null +++ b/trios/.trinity/loop/unwired.mjs @@ -0,0 +1,209 @@ +#!/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] + for (const dep of node.needs) 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. + */ +export function classify(name, recipe) { + const looksLikeGate = 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(', ')}` } + return { name, kind: 'portable', why: 'nothing it reaches needs a desktop, so a runner could do this' } +} + +export function render(rows, targetCount, wiredCount, showAll = false) { + const by = { portable: [], 'mac-only': [], '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 (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 rows = [] + for (const [name] of targets) { + if (wired.has(name)) continue + rows.push(classify(name, reachedRecipes(name, targets))) + } + console.log(render(rows, targets.size, wired.size, process.argv.includes('--all'))) + const portable = rows.filter((r) => r.kind === 'portable').length + console.log(`\n${portable} portable gate(s) that nothing runs`) + process.exit(portable ? 2 : 0) +} From 3954cab5e70cf903f74d182438b19a5cafb569af Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Sun, 6 Sep 2026 15:44:19 +0700 Subject: [PATCH 06/10] fix(loop): tri unwired sees one layer, and now says so Two more misclassifications of the same family, both caught by reading the tool's output against targets whose answer I already knew: check-bypass came out portable. It is one line - `$(MAKE) check` - and following only prerequisites saw an empty recipe. Its own echo says 'never for CI', which is how it was caught: the tool disagreed with the target's description of itself. Sub-make calls are followed now. drift-guard came out portable and then ran for four minutes without finishing. It is `bash run_chat_sse_e2e.sh` with an env var, and the Swift compiler is inside the script. A scan of make recipes sees one layer; a script is the second. So there is a third answer now - opaque - rather than a guess. A gate that shells out is reported as unjudgeable from here, which is what it is. The portable list went 11 -> 9 -> 6 -> 3 as each layer was followed, and every step of that shrinking was a false accusation withdrawn. Co-Authored-By: Claude Opus 5 --- trios/.trinity/loop/unwired.mjs | 33 +++++++++++++++++++++++++++++++-- 1 file changed, 31 insertions(+), 2 deletions(-) diff --git a/trios/.trinity/loop/unwired.mjs b/trios/.trinity/loop/unwired.mjs index a14316c85..1c123bf6b 100644 --- a/trios/.trinity/loop/unwired.mjs +++ b/trios/.trinity/loop/unwired.mjs @@ -106,7 +106,20 @@ export function reachedRecipes(name, targets, seen = new Set()) { const node = targets.get(name) if (!node) return [] let lines = [...node.recipe] - for (const dep of node.needs) lines = lines.concat(reachedRecipes(dep, targets, seen)) + // 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 } @@ -132,11 +145,22 @@ export function classify(name, recipe) { 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' } } export function render(rows, targetCount, wiredCount, showAll = false) { - const by = { portable: [], 'mac-only': [], 'not-a-gate': [] } + 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`, @@ -148,6 +172,11 @@ export function render(rows, targetCount, wiredCount, showAll = false) { 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:`) From dd9df8e284022c0e5c9214ba8663c5d65bebb166 Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Sun, 6 Sep 2026 15:44:53 +0700 Subject: [PATCH 07/10] test(loop): pin the two layers tri unwired now follows A sub-make call reaches its target; a target that shells out to a script is opaque and names the script rather than guessing. Both cases fail against the version that shipped an hour ago. Co-Authored-By: Claude Opus 5 --- trios/.trinity/loop/selftest.mjs | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/trios/.trinity/loop/selftest.mjs b/trios/.trinity/loop/selftest.mjs index ec26b4e69..6886801d6 100644 --- a/trios/.trinity/loop/selftest.mjs +++ b/trios/.trinity/loop/selftest.mjs @@ -3115,6 +3115,23 @@ check('gaps longer than the tick mean rounds are starting nothing', async () => // 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('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 From 4a9096605309aacfdec4c93eafdcde3c0366ec96 Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Sun, 6 Sep 2026 15:56:52 +0700 Subject: [PATCH 08/10] feat(loop): tri unwired reads package scripts too - make was one dialect The same question asked of npm scripts found the t27.ai dashboard: trios/apps/website carries a 194-check review-lifecycle contract that imports the real page module and pins the states against what the server publishes, and the string 'apps/website' appeared in NO workflow. MOST UNWIRED SCRIPTS ARE LEGITIMATELY UNWIRED, and that shaped the classes. A flat first pass listed nineteen and eighteen were fine: aggregates CI deliberately splits into per-group jobs, an alias for a wired script, a helper other scripts call, a mutating lint:fix, an interactive watch. Reported flat, the one row worth reading was buried. And the one it cannot decide, said out loud: a script whose WORK is wired under a different command. `lint` is `biome check` while CI runs `biome ci .` - the linting happens, the name never appears. Same shape as drift-guard, whose script the macOS job already runs. Named rather than silently dropped, because guessing either way has been wrong. test:* is deliberately outside the word list: including it buries the report, and that coverage already has a better guard in run-test-group.test.ts. Stated in the output so the count cannot imply completeness. Co-Authored-By: Claude Opus 5 --- trios/.trinity/loop/unwired.mjs | 94 ++++++++++++++++++++++++++++++++- 1 file changed, 92 insertions(+), 2 deletions(-) diff --git a/trios/.trinity/loop/unwired.mjs b/trios/.trinity/loop/unwired.mjs index 1c123bf6b..efde4ebd4 100644 --- a/trios/.trinity/loop/unwired.mjs +++ b/trios/.trinity/loop/unwired.mjs @@ -159,6 +159,63 @@ export function classify(name, recipe) { 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) @@ -232,7 +289,40 @@ if (isMain) { rows.push(classify(name, reachedRecipes(name, targets))) } 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 - console.log(`\n${portable} portable gate(s) that nothing runs`) - process.exit(portable ? 2 : 0) + 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) } From 3a92456e4c5cbefd03688346f67bf4e3fbb850e4 Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Sun, 6 Sep 2026 15:57:36 +0700 Subject: [PATCH 09/10] test(loop): pin the script classes, including the one that is deliberately blind Six cases. Two of them assert not-a-gate for test:* - the word is outside the gate list on purpose - and the case that first claimed 'interactive' for test:watch was wrong, not the code. The classifier never sees a test:* script at all, and the audit says so in its own output. Co-Authored-By: Claude Opus 5 --- trios/.trinity/loop/selftest.mjs | 28 ++++++++++++++++++++++++++++ 1 file changed, 28 insertions(+) diff --git a/trios/.trinity/loop/selftest.mjs b/trios/.trinity/loop/selftest.mjs index 6886801d6..0bc13bbcd 100644 --- a/trios/.trinity/loop/selftest.mjs +++ b/trios/.trinity/loop/selftest.mjs @@ -3115,6 +3115,34 @@ check('gaps longer than the tick mean rounds are starting nothing', async () => // 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('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 From cc1018b9cb93ff4b761a3ca044f79cfcc36fbed5 Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Sun, 6 Sep 2026 16:12:06 +0700 Subject: [PATCH 10/10] fix(loop): read the declaration, not the names `make check` names its own gate suite in one line of prerequisites. Reading it corrected this file badly: of thirteen unwired gates the repository 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, plus a Verilog simulation, and this file called it not a gate. The word list stays as the fallback for targets outside the suite, where nothing has declared anything. The report now prints the declaration first: 24 declared, 8 in a workflow. Co-Authored-By: Claude Opus 5 --- trios/.trinity/loop/selftest.mjs | 16 ++++++++++++++++ trios/.trinity/loop/unwired.mjs | 31 ++++++++++++++++++++++++++++--- 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/trios/.trinity/loop/selftest.mjs b/trios/.trinity/loop/selftest.mjs index 0bc13bbcd..c9191a6d5 100644 --- a/trios/.trinity/loop/selftest.mjs +++ b/trios/.trinity/loop/selftest.mjs @@ -3115,6 +3115,22 @@ check('gaps longer than the tick mean rounds are starting nothing', async () => // 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. diff --git a/trios/.trinity/loop/unwired.mjs b/trios/.trinity/loop/unwired.mjs index efde4ebd4..e7f8cbc46 100644 --- a/trios/.trinity/loop/unwired.mjs +++ b/trios/.trinity/loop/unwired.mjs @@ -139,8 +139,30 @@ export function wiredIn(workflowText) { * 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. */ -export function classify(name, recipe) { - const looksLikeGate = GATE_WORDS.some((w) => name.toLowerCase().includes(w)) +/** + * 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)) @@ -283,11 +305,14 @@ if (isMain) { 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))) + 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