From 3ff2ba251714d4538f1918e8ac75dc3bd32d2ae6 Mon Sep 17 00:00:00 2001 From: Dmitrii Vasilev Date: Sat, 5 Sep 2026 06:17:18 +0700 Subject: [PATCH] fix(loop): four finished branches were re-offered as debt every round, and close-done kept its own copy of the rule `tri why` warned ahead: all nine remaining accepted branches conflict, so nothing can land, close-done will close nothing, and the pipeline stops as soon as those boundaries are all that is left. Four of the nine were finished work. THE ROUTE NO COMPARISON OF BYTES CAN TAKE. `isLanded` had four routes - ancestry, an identical merged tree, a patch-id match, and a hand-applied change. Every one of them compares CONTENT. They are all blind to the thing this loop does constantly: when a bee's branch goes stale, I re-cut its change against the current base and squash-merge THAT. The carry is a new commit with a new tree and a new patch-id, so nothing content-shaped connects it back, and the bee's original branch becomes permanent debt - re-offered every round, conflicting every round, holding its boundary fenced for ever. So read the message. L1 of this repository is "no code merged without `Closes #N`", which makes the message a load-bearing record and not a courtesy: #1310 landed as PR #330 #1308 landed as PR #331 #1362 `Closes #1362` in a base commit #1421 carried by a commit that says "Carries the #1421 work it belongs with" Nine conflicting branches became six. The matching is done in JavaScript rather than in git's regex, because two rounds ago a BRE read as a JavaScript regex convicted a bee - the dialect belongs somewhere it is known. AND THE TIGHTENING BROKE THE CASE IT WAS WRITTEN FOR. I required `(#N)` to end the line, to reject "unlike (#1421), this does X". One minute later #1310 stopped being recognised: a squash subject here reads `feat(queen): explain idle paid slots (#1310) (#330)` - the issue first, then the pull request. A trailing CHAIN of references is a subject; a parenthesis in the middle of a sentence is not. CLOSE-DONE KEPT ITS OWN COPY OF THE RULE. It had the tree test and nothing else, for weeks, while `land.mjs` grew four more routes it never learned. A rule transcribed twice is two rules that agree until somebody edits one - which is L2 of this repository, and it had happened here in the file that decides whether an issue may be closed. It asks `land.mjs` now. What remains is real debt and is reported as such: #1387, #1302 and #1303 carry 880 insertions of finished work outside the base, all three with their issues already CLOSED - the inverse of the false statement close-done exists to prevent. A conflict is still reported for a person and never resolved by guessing. selftest 144 pass 0 fail. --- trios/.trinity/loop/close-done.mjs | 14 ++++-- trios/.trinity/loop/land.mjs | 70 ++++++++++++++++++++++++++++-- trios/.trinity/loop/selftest.mjs | 53 ++++++++++++++++++++-- 3 files changed, 128 insertions(+), 9 deletions(-) diff --git a/trios/.trinity/loop/close-done.mjs b/trios/.trinity/loop/close-done.mjs index 36f3bced2a..b8220e2816 100644 --- a/trios/.trinity/loop/close-done.mjs +++ b/trios/.trinity/loop/close-done.mjs @@ -30,6 +30,7 @@ import { fileURLToPath } from 'node:url' const DIR = path.dirname(fileURLToPath(import.meta.url)) const L = await import(path.join(DIR, 'loop.mjs')) +const LAND = await import(path.join(DIR, 'land.mjs')) const { shq } = L // IMPORT-SAFE. This module ran its production query and called process.exit at @@ -130,9 +131,16 @@ for (const r of rows) { // change the base AT ALL? Ancestry says no for ever after a squash, and a // three-dot diff measures from the divergence point rather than from what the // base holds now. Comparing the merged tree with the base tree is route-blind. - const baseTree = tryShell(`git rev-parse ${BASE}^{tree}`) - const mergedTree = tryShell(`git merge-tree --write-tree ${BASE} ${branch}`) - const landed = baseTree && mergedTree && mergedTree.split('\n')[0].trim() === baseTree + // ONE RULE, IN ONE PLACE. This file carried its own copy of the tree test for + // weeks while `land.mjs` grew four more routes it never learned: ancestry, a + // drifted cherry-pick found by patch-id, and - the one that jammed the + // pipeline on 2026-09-05 - a change re-cut onto a fresh base and squash-merged + // under a different branch, which no comparison of bytes can see at all. + // + // Nine branches conflicted, four of them finished work. `land.mjs` learned to + // recognise them and this file went on refusing to close their issues, because + // a rule transcribed twice is two rules that agree until somebody edits one. + const landed = LAND.isLanded(String(branch).replace(/^origin\//, '')) if (!landed) { plan.push({ n, skip: `work is on ${r.branch || `queen-${n}`} but NOT in ${BASE} - run \`tri land --land\` first; closing now would be a false statement` }) continue diff --git a/trios/.trinity/loop/land.mjs b/trios/.trinity/loop/land.mjs index 970778da15..b284637b5b 100644 --- a/trios/.trinity/loop/land.mjs +++ b/trios/.trinity/loop/land.mjs @@ -66,6 +66,66 @@ const sh = (c, opts = {}) => { * catches a squash, a cherry-pick, and a change somebody applied by hand - all * of which leave the branch looking unmerged for ever. */ + +/** + * THE FIFTH ROUTE: the base's own history says it closed this issue. + * + * Every content-based route above compares BYTES - a tree, a patch-id, an + * ancestry. All four are blind to the one thing this loop does constantly: + * when a bee's branch goes stale, I re-cut its change against the current base + * and squash-merge THAT. The carry is a new commit with a new tree and a new + * patch-id, so nothing content-shaped can connect it back, and the bee's + * original branch becomes permanent debt - re-offered every round, conflicting + * every round, holding its issue open and its boundary fenced for ever. + * + * Measured 2026-09-05 on the nine branches that had jammed the pipeline: + * #1310 landed as PR #330, #1308 as PR #331, #1362 carries `Closes #1362` in a + * base commit. Three of nine were finished work that no content route could see. + * + * So read the message. L1 of this repository is "no code merged without + * `Closes #N`", which makes the message a load-bearing record rather than a + * courtesy - and the squash subject convention `(#N)` says the same thing. + * + * THE MATCHING IS DONE IN JAVASCRIPT, not in git's regex. Two rounds ago a BRE + * read as a JavaScript regex convicted a bee; the lesson is to keep the dialect + * somewhere it is known. git is asked the loose question and the boundary is + * checked here. + */ +export function closedInBase(branch, run = sh) { + const m = String(branch).match(/queen-(\d+)$/) + if (!m) return false + const n = m[1] + const raw = run(`git log origin/${BASE} --format=%B%x1e --grep=${JSON.stringify('#' + n)} -i`) + if (!raw) return false + const closes = new RegExp(`(?:closes|fixes|resolves)\\s+#${n}(?![0-9])`, 'i') + // `(#N)` is how a squash subject records the issue it came from, and it must + // END A LINE, which is what makes it a subject rather than a mention. The + // loose version would match "unlike (#1421), this does X" in any paragraph. + // + // In THIS repository issue numbers are four digits and pull-request numbers + // three, so `(#1310)` cannot be a PR reference - a repo-specific fact, stated + // because it is not a general one. + // + // ...ALLOWING THE REFERENCES THAT FOLLOW IT. A squash subject here reads + // `feat(queen): explain idle paid slots (#1310) (#330)` - the issue first, + // then the pull request that merged it. Demanding `(#N)` at the very end of + // the line rejected exactly the case this route was written for, which I + // discovered by tightening the rule and watching #1310 stop being recognised + // one minute later. A trailing CHAIN of parenthesised references is a + // subject; a parenthesis in the middle of a sentence is not. + const subject = new RegExp(`\\(#${n}\\)(?:\\s*\\(#\\d+\\))*\\s*$`, 'm') + // And the phrasing my own carry commits use, which is the case this route + // exists for: the change was re-cut onto a fresh base and merged under a + // different branch, saying so in words because no byte-comparison can. + const carried = new RegExp(`carr(?:y|ies|ied)\\s+(?:the\\s+)?#${n}(?![0-9])`, 'i') + for (const message of raw.split('\x1e')) { + if (closes.test(message)) return true + if (carried.test(message)) return true + if (Number(n) >= 1000 && subject.test(message)) return true + } + return false +} + export function isLanded(branch) { if (sh(`git merge-base --is-ancestor origin/${branch} origin/${BASE} && echo yes`) === 'yes') return true // THE EXACT QUESTION: would merging this change the base at all? @@ -102,9 +162,13 @@ export function isLanded(branch) { // A branch with no commits left unaccounted for has landed, whatever route it // took. An empty answer from `git cherry` is not evidence of anything. const cherry = sh(`git cherry origin/${BASE} origin/${branch}`) - if (cherry === null) return false - const unaccounted = cherry.split('\n').filter((l) => l.trim().startsWith('+')) - return cherry.trim() !== '' && unaccounted.length === 0 + if (cherry !== null) { + const unaccounted = cherry.split('\n').filter((l) => l.trim().startsWith('+')) + if (cherry.trim() !== '' && unaccounted.length === 0) return true + } + + // ...and the route no comparison of bytes can reach. + return closedInBase(branch) } export function mergesCleanly(branch) { diff --git a/trios/.trinity/loop/selftest.mjs b/trios/.trinity/loop/selftest.mjs index 07743de451..77981bf848 100644 --- a/trios/.trinity/loop/selftest.mjs +++ b/trios/.trinity/loop/selftest.mjs @@ -1170,8 +1170,15 @@ check('close-done demands LANDED, not merely pushed', () => { // the remote" is what manufactured 169 closed issues whose code was outside // the base. const src = fs.readFileSync(path.join(DIR, 'close-done.mjs'), 'utf8') - if (!/merge-tree --write-tree/.test(src)) { - throw new Error('it must ask whether merging would change the base at all') + // The rule moved into land.mjs so there is exactly one of it. The demand is + // unchanged and is now asserted where it lives: close-done must ASK, and the + // answer must still include "would merging change the base at all". + if (!/LAND\.isLanded/.test(src)) { + throw new Error('it must ask the landing rule rather than close on "pushed"') + } + const land = fs.readFileSync(path.join(DIR, 'land.mjs'), 'utf8') + if (!/merge-tree --write-tree/.test(land)) { + throw new Error('the rule it asks must still ask whether merging would change the base at all') } if (!/NOT in/.test(src)) throw new Error('the refusal must say the work is not in the base') if (!/false statement/.test(src)) throw new Error('and why that matters') @@ -1451,8 +1458,13 @@ check('landed knows the fourth route: patch-id equivalence', () => { check('an unreadable cherry answer is not evidence of landing', () => { const code = codeOf('land.mjs') const fn = code.slice(code.indexOf('export function isLanded'), code.indexOf('export function mergesCleanly')) - if (!/cherry === null\) return false/.test(fn)) throw new Error('unreadable is not landed') + // The route now falls through to closedInBase rather than returning, so the + // assertion is about what an unreadable answer may NOT do: it may not become + // a "yes" from this route. + if (!/cherry !== null/.test(fn)) throw new Error('an unreadable cherry answer must not be read by this route at all') + if (/cherry === null[\s\S]{0,40}return true/.test(fn)) throw new Error('unreadable is never landed') if (!/cherry\.trim\(\) !== ''/.test(fn)) throw new Error('an EMPTY answer must not read as "no unaccounted commits"') + if (!/closedInBase\(branch\)/.test(fn)) throw new Error('the fifth route is a different question, asked separately') }) check('a section is read from its heading, not from the first mention of it', async () => { @@ -1782,6 +1794,41 @@ check('the audits do not pay for the freeing steps', () => { if (!/reportPhaseStartedAt/.test(code)) throw new Error('the report budget starts when the report phase does, not when the chain did') }) +check('a carried change is landed, and a mention of it is not', async () => { + const { closedInBase } = await import('./land.mjs') + // Every content route compares bytes. A carry is re-cut onto a fresh base and + // squash-merged, so it has a new tree and a new patch-id and nothing + // byte-shaped can connect it back. Three of the nine branches jamming the + // pipeline on 2026-09-05 were finished work in exactly this state. + const msg = (s) => () => s + if (!closedInBase('queen-1310', msg('feat: something\n\nCloses #1310\n'))) throw new Error('Closes is L1 of this repo and must count') + if (!closedInBase('queen-1310', msg('feat(queen): explain idle paid slots (#1310)\n'))) throw new Error('a squash subject records the issue') + // The real shape: the issue, then the pull request that merged it. Demanding + // (#N) at the very end of the line rejected the exact case this exists for. + if (!closedInBase('queen-1310', msg('feat(queen): explain idle paid slots (#1310) (#330)\n'))) { + throw new Error('a trailing chain of references is still a subject') + } + if (!closedInBase('queen-1308', msg('feat(queen): carry #1308 onto the supervisor base\n'))) throw new Error('a carry says so in words') + // ...and the mentions that must NOT count. + if (closedInBase('queen-1421', msg('unlike (#1421), this one does something else\n'))) { + throw new Error('a parenthetical mid-sentence is not a squash subject') + } + if (closedInBase('queen-1310', msg('this is unrelated to #1310 and does not close it\n'))) { + throw new Error('a bare mention closes nothing') + } + if (closedInBase('queen-131', msg('Closes #1310\n'))) throw new Error('#131 is not #1310') + if (closedInBase('not-a-queen-branch', msg('Closes #1310\n'))) throw new Error('only a queen-N branch has an issue number') +}) + +check('close-done and land agree about what landed, because it is one rule', () => { + const code = codeOf('close-done.mjs') + // This file carried its own copy of the tree test while land.mjs grew four + // more routes it never learned. Nine branches conflicted, four were finished + // work, and close-done went on refusing to close their issues. + if (!/LAND\.isLanded/.test(code)) throw new Error('the landing rule lives in land.mjs and is asked, not re-implemented') + if (/const landed = baseTree && mergedTree/.test(code)) throw new Error('the second copy of the rule must be gone, not merely bypassed') +}) + check('the harness can fail an async check', async () => { // Guarding the fix above: before it, this file reported 0 failures while an // async case was rejecting into the void.