Skip to content

Commit 4fb435b

Browse files
committed
fix(pm): a claim its own session later yielded is not the card's claim
The race re-read rule makes the loser yield in a comment, never by deleting its claim, so the loser's claim is always the newer one. claimGovernance read it as the governing claim and h46ClaimNamesBranch still counted it, so the closing-target guard went red on the winner's PR. yieldedClaimIndexes (exported once from check-half-states) recognises the protocol's `already claimed — yielding` first line naming exactly one session, and withdraws only the earlier claims whose Session: line names that session. Both readers skip them; the guard imports the same predicate. Claude-Session: https://claude.ai/code/session_01LqKsGyMNFwwnbF5FTAqyPn Co-authored-by: Claude <noreply@anthropic.com>
1 parent 05f7758 commit 4fb435b

2 files changed

Lines changed: 243 additions & 6 deletions

File tree

‎scripts/check-closing-target-claim.mjs‎

Lines changed: 74 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,14 @@
5959
* one names another: H46 calls that claimed and so does this gate. That is the
6060
* permissive direction — a blocking gate erring toward not-blocking on an
6161
* ambiguous thread — and it is the no-fork property, not an accident.
62+
*
63+
* Both readings skip a `Claim:` its OWN session later yielded, through the one
64+
* recogniser `yieldedClaimIndexes` exports beside them (its header carries the
65+
* three conditions). The race re-read rule makes the loser yield in a comment,
66+
* never by deleting its claim, so the loser's claim is always the NEWER one;
67+
* read as current, it made this gate red on the WINNER's PR, which went green
68+
* only after the winner re-posted its claim by hand. Skipped, the loser's
69+
* branch is no longer claimed and the winner's claim is the one judged.
6270
* - the merge-queue ref reading is `pullNumberFromQueueRef`, the governed
6371
* queue guard's, which already carries the `release/v5`-style base-branch
6472
* lesson its own header records.
@@ -281,7 +289,7 @@ import { maskCommentsAndLiterals } from './js-comment-mask.mjs';
281289
// about whether this container's fetch reaches GitHub at all. Only the guard
282290
// VARIABLE below is this file's own, and the block above `rearmThroughProxy`
283291
// says why.
284-
import { claimGovernance, closingKeywordTargets, EXIT_PREREQUISITE_NOT_MET, h46ClaimNamesBranch, PROXY_FLAG, PROXY_REARM_GUARD, proxyRearmPlan } from './pm/check-half-states.mjs';
292+
import { claimGovernance, closingKeywordTargets, EXIT_PREREQUISITE_NOT_MET, h46ClaimNamesBranch, PROXY_FLAG, PROXY_REARM_GUARD, proxyRearmPlan, yieldedClaimIndexes } from './pm/check-half-states.mjs';
285293
import { pullNumberFromQueueRef } from './pm/check-governed-queue-guard.mjs';
286294

287295
/** This file, resolved once, so the re-exec below hands off to THIS script and not to an argv guess. */
@@ -314,11 +322,12 @@ const SELF_TEST_BATTERIES = Object.freeze({
314322
'A transport failure SPEAKS, and is held apart from UNDETERMINED. Both used': 16,
315323
'The wiring itself. A gate whose workflow step is deleted or whose': 10,
316324
'The predicate sources this gate reuses must still be there to reuse.': 3,
325+
'A claim its own session later YIELDED is not the card\'s claim. The': 16,
317326
});
318327

319328
// DELETING an entry silences that battery's floor exactly as effectively as
320329
// zeroing it, so the roster's own size is pinned too.
321-
const SELF_TEST_BATTERY_FLOOR = 12;
330+
const SELF_TEST_BATTERY_FLOOR = 13;
322331

323332
// The key an assertion is filed under when no battery is open. It is not a
324333
// declared battery, so it reds by the same set difference rather than silently
@@ -618,8 +627,9 @@ export function judge(ctx) {
618627

619628
const rows = unclaimed.map((t) =>
620629
(t.names ?? []).length === 0
621-
? ` - \`${t.keyword} #${t.number}\` — no comment on #${t.number} is a \`Claim:\` at all, so the card ` +
622-
`records no owner and nothing on it would have stopped a second seat.`
630+
? ` - \`${t.keyword} #${t.number}\` — no comment on #${t.number} is a \`Claim:\` at all (a claim its own ` +
631+
`session later yielded does not count), so the card records no owner and nothing on it would have ` +
632+
`stopped a second seat.`
623633
: ` - \`${t.keyword} #${t.number}\` — the governing \`Claim:\` on #${t.number} names ` +
624634
`${(t.names ?? []).map((b) => `\`${b}\``).join(', ')}, not \`${ctx.head}\`.`,
625635
);
@@ -1245,6 +1255,66 @@ async function selfTest() {
12451255
const wiringCommands = wiring.split('\n').filter((line) => !/^\s*#/.test(line)).join('\n');
12461256
t('the guard job invokes no package manager (it needs node and nothing else)', /\b(pnpm|corepack|yarn|npm)\b/.test(wiringCommands), false);
12471257

1258+
// --- A claim its own session later YIELDED is not the card's claim. The
1259+
// measured race: seat 1 claims, seat 2 claims the same card minutes later,
1260+
// re-reads the thread and yields in a comment, so the loser's claim is the
1261+
// NEWER one. Read as current it made this gate red on the winner's PR.
1262+
battery('A claim its own session later YIELDED is not the card\'s claim. The');
1263+
const WIN = `session_01${'W'.repeat(22)}`;
1264+
const LOSE = `session_01${'L'.repeat(22)}`;
1265+
const THREAD_BRANCH = 'claude/project-thread-fixture';
1266+
const claimBy = (session, ref, iso) => ({
1267+
body: `Claim: race fixture\nSession: \`${session}\` (seat)\nBranch: \`${ref}\``,
1268+
created_at: iso,
1269+
});
1270+
// The yield's BODY names the winner and its session, as the measured one
1271+
// does; only its first line may say who stepped back.
1272+
const yieldBy = (firstLine, iso) => ({
1273+
body: `${firstLine}\n\nClaim (\`${WIN}\`) precedes this seat's claim; the earlier claim decides.`,
1274+
created_at: iso,
1275+
});
1276+
const lostBy = (session) => yieldBy(`already claimed — yielding · seat 2 · \`${session}\` · 10:00Z`, '2026-01-01T10:01:00Z');
1277+
const claimA = (ref = HEAD) => claimBy(WIN, ref, '2026-01-01T09:47:00Z');
1278+
const claimB = claimBy(LOSE, OTHER, '2026-01-01T09:54:00Z');
1279+
const RACE = [claimA(), claimB, lostBy(LOSE)];
1280+
const raceRoutes = (rows) => ({
1281+
'/repos/o/r/issues/15845/comments': rows,
1282+
'/repos/o/r/issues/15845': { number: 15845, state: 'open' },
1283+
});
1284+
const raced = (rows, head) => run('Closes #15845', raceRoutes(rows), { head });
1285+
const winner = await raced(RACE, HEAD);
1286+
t('the WINNER\'s PR is CLEAN on a thread whose newest claim was yielded', winner.verdict.exit, EXIT_CLEAN);
1287+
t('…and costs one comment page, the clean path\'s bound', winner.calls.length, 1);
1288+
const loser = await raced(RACE, OTHER);
1289+
t('the LOSER\'s PR is a FINDING — a yielded claim names no branch', loser.verdict.exit, EXIT_UNCLAIMED);
1290+
t('…and the failure names the WINNER\'s branch as the governing claim', text(loser.verdict).includes(`names \`${HEAD}\``), true);
1291+
const threadRace = [claimA(THREAD_BRANCH), claimB, lostBy(LOSE)];
1292+
const threadWinner = await raced(threadRace, THREAD_BRANCH);
1293+
t('a winner on a card-thread branch is no longer RED (the measured specimen)', threadWinner.verdict.exit, EXIT_CLEAN);
1294+
t('…it reads UNDETERMINED, warned about, because its branch is not one the reader parses', text(threadWinner.verdict).includes('::warning::UNDETERMINED'), true);
1295+
t('…and the loser\'s PR on that thread is no longer read as CLAIMED', (await raced(threadRace, OTHER)).collected.targets[0].verdict !== TARGET_CLAIMED, true);
1296+
const noSession = [claimA(), claimB, yieldBy('already claimed — yielding · seat 2', '2026-01-01T10:01:00Z')];
1297+
t('a yield naming NO session withdraws nothing — today\'s reading holds', (await raced(noSession, OTHER)).verdict.exit, EXIT_CLEAN);
1298+
const twoSessions = [claimA(), claimB, yieldBy(`already claimed — yielding · \`${LOSE}\` to \`${WIN}\``, '2026-01-01T10:01:00Z')];
1299+
t('a yield naming TWO sessions says nobody in particular stepped back — today\'s reading holds', (await raced(twoSessions, OTHER)).verdict.exit, EXIT_CLEAN);
1300+
t('a yield by ANOTHER session does not withdraw this session\'s claim', (await raced([claimA(), claimB, lostBy(WIN)], OTHER)).verdict.exit, EXIT_CLEAN);
1301+
const reclaimed = [...RACE, claimBy(LOSE, OTHER, '2026-01-01T11:00:00Z')];
1302+
t('a `Claim:` the yielding session posts AFTER its yield stands', (await raced(reclaimed, OTHER)).verdict.exit, EXIT_CLEAN);
1303+
const yieldFirst = [lostBy(LOSE), claimBy(LOSE, OTHER, '2026-01-01T11:00:00Z')];
1304+
t('a yield withdraws only claims that PRECEDE it', (await raced(yieldFirst, OTHER)).verdict.exit, EXIT_CLEAN);
1305+
const hyphen = [claimA(), claimB, yieldBy(`already claimed - yielding · \`${LOSE}\``, '2026-01-01T10:01:00Z')];
1306+
t('a hyphen-written yield is no dialect — today\'s reading holds', (await raced(hyphen, OTHER)).verdict.exit, EXIT_CLEAN);
1307+
t('the recogniser is the shipped one, and over the race it withdraws the loser\'s claim alone', [...yieldedClaimIndexes(RACE)], [1]);
1308+
t('…not the winner\'s, whose session only the yield\'s BODY names', yieldedClaimIndexes(RACE).has(0), false);
1309+
const yieldFixtures = [[RACE, HEAD], [RACE, OTHER], [threadRace, OTHER], [noSession, OTHER], [reclaimed, OTHER], [yieldFirst, OTHER]];
1310+
const yieldDelegated = [];
1311+
for (const [rows, head] of yieldFixtures) yieldDelegated.push((await raced(rows, head)).collected.targets[0].verdict === TARGET_CLAIMED);
1312+
t(
1313+
'over every yield fixture, CLAIMED is exactly the shipped `h46ClaimNamesBranch` (no forked predicate)',
1314+
yieldDelegated,
1315+
yieldFixtures.map(([rows, head]) => h46ClaimNamesBranch(rows.map((row) => row.body), head)),
1316+
);
1317+
12481318
// --- The predicate sources this gate reuses must still be there to reuse.
12491319
battery('The predicate sources this gate reuses must still be there to reuse.');
12501320
for (const source of PREDICATE_SOURCES) t(`the predicate source ${source} exists`, existsSync(join(ROOT, source)), true);

0 commit comments

Comments
 (0)