Skip to content

Commit 56470d8

Browse files
Elon Muskclaude
andauthored
fix(scripts): name the shared job clock in the stall-guard budget census (#13119)
The census prints one `slack` per guard-wrapped site, but `timeout-minutes` runs one clock per JOB. Two guarded steps in the same job therefore do not each get the slack printed beside them -- the later one starts with the earlier one's entire runtime already spent -- and nothing in the line said so. Sibling grouping is derived from job membership, keyed on (file, job id): a job id is unique only within its file, and this tree reuses three of them across files today. Grouping by file, or by a matching --stall-minutes value, would invent shared clocks that do not exist; the self-test pins both. Census wording only. The criterion `T - C >= W` does not move. Claude-Session: https://claude.ai/code/session_01CPrUz21stTFhJRUirdc4yw Co-authored-by: Claude <noreply@anthropic.com>
1 parent 4f9df8f commit 56470d8

1 file changed

Lines changed: 244 additions & 7 deletions

File tree

scripts/check-stall-guard-budget.mjs

Lines changed: 244 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -144,10 +144,21 @@
144144
* item 3; it does not extend the guard's reach.
145145
*
146146
* The affected population is 7 guard-wrapped STEPS across 5 jobs, not 4 jobs:
147-
* `temporal-conformance` carries TWO guarded steps on ONE job clock, so the
148-
* second one's `p` includes the first one's entire runtime. The census below
149-
* prints each site's `slack` independently, which is correct for the criterion
150-
* and reads as more independent than the clock actually is.
147+
* TWO of those jobs carry two guarded steps each on ONE job clock --
148+
* `temporal-conformance` in ci.yml, and `rerun-safety` in
149+
* rerun-safety-nightly.yml, whose two sites are consecutive full passes of the
150+
* suite -- so in each pair the second site's `p` includes the first one's
151+
* entire runtime.
152+
*
153+
* Each site's `slack` is still computed independently, which is correct for the
154+
* criterion; on its own it also READ as more independent than the clock
155+
* actually is. So the census now names the sharing where it exists:
156+
*
157+
* ... · slack 10m (1 of 2 guarded steps in this job; they share one timeout clock)
158+
*
159+
* A job holding exactly one guarded step gets no such clause -- see
160+
* `attachSiblings` for how the grouping is derived, and why it is keyed on job
161+
* membership rather than on the file or on a matching `--stall-minutes`.
151162
*
152163
* ### Re-deriving these numbers instead of trusting them
153164
*
@@ -378,6 +389,8 @@ export function scan(root, defaults, parseYaml) {
378389
if (lines.length === found.length) found.forEach((site, i) => { site.line = lines[i]; });
379390
}
380391

392+
attachSiblings(sites);
393+
381394
for (const site of sites) {
382395
const judged = judge(site, defaults);
383396
Object.assign(site, judged);
@@ -388,6 +401,83 @@ export function scan(root, defaults, parseYaml) {
388401
return { sites, violations, problems, files: names.length, jobs, steps };
389402
}
390403

404+
/**
405+
* Mark, on every site, how many guard-wrapped steps share its job clock and
406+
* which one of them it is (`siblingCount`, 1-based `siblingIndex`).
407+
*
408+
* `timeout-minutes` runs ONE clock for a whole job, so two guarded steps in the
409+
* same job do not each get the `slack` the census prints beside them: the later
410+
* one starts with the earlier one's entire runtime already spent. The criterion
411+
* `T - C >= W` is unaffected and deliberately still models neither the prep
412+
* before a step nor the step's own runtime -- this is what the census SAYS, not
413+
* what the gate DECIDES.
414+
*
415+
* ## The grouping key is (file, job id), and each half is load-bearing
416+
*
417+
* NOT the job id alone. A job id is unique only within its own workflow file,
418+
* and this tree really does reuse three of them across files (`publish` in
419+
* docker-publish.yml and release.yml, `patrol` in half-state-patrol.yml and
420+
* release-coverage-patrol.yml, `registry-canary` in publish-smoke.yml and
421+
* scaffold-e2e.yml). Keyed on the id alone, two single-guard jobs in different
422+
* files would be printed as siblings -- inventing a shared clock that does not
423+
* exist, which is the more dangerous direction to be wrong in.
424+
*
425+
* NOT the file either: one workflow file holds many independent job clocks (28
426+
* files, 51 jobs here). And NOT a matching `--stall-minutes` value: five of the
427+
* seven sites in this tree are 10m, spread over four different jobs, and a
428+
* coincidence of budgets is not a shared clock. Those are the two mistakes the
429+
* self-test pins against, because they are the ones the next reader reaches for.
430+
*
431+
* ## Why the ordinal can be trusted
432+
*
433+
* A site's position within its job's group is the order `scan` pushed it, which
434+
* comes from `job.steps` -- an ARRAY. Document order there is guaranteed by
435+
* construction, not by object-key iteration order, so `2 of 2` really is the
436+
* second step on the clock and not merely one of two. That is the half a reader
437+
* needs: a SECOND guarded step is the one whose real headroom is quietly
438+
* smaller than the number printed next to it.
439+
*
440+
* @param {object[]} sites in sweep order; annotated in place
441+
*/
442+
export function attachSiblings(sites) {
443+
const byJobClock = new Map();
444+
for (const site of sites) {
445+
const key = JSON.stringify([site.file, site.job]);
446+
const group = byJobClock.get(key);
447+
if (group) group.push(site);
448+
else byJobClock.set(key, [site]);
449+
}
450+
for (const group of byJobClock.values()) {
451+
group.forEach((site, index) => {
452+
site.siblingIndex = index + 1;
453+
site.siblingCount = group.length;
454+
});
455+
}
456+
return sites;
457+
}
458+
459+
/**
460+
* The clause that stops a census `slack` from reading as an independent
461+
* per-step budget, or `''` for a job holding exactly one guarded step.
462+
*
463+
* It names the CONSEQUENCE and not only the count: `1 of 2` alone leaves a
464+
* reader knowing there is another guarded step somewhere without knowing that
465+
* it spends the same clock. The note is unconditional on the budget source
466+
* because a job's timeout clock is per-job whether it is declared or is
467+
* GitHub's default; when a tighter STEP-level `timeout-minutes` is what binds,
468+
* the census already says so in the `budget ...m (step timeout-minutes)` field
469+
* printed immediately before this clause.
470+
*
471+
* @param {object} site a judged site carrying the fields `attachSiblings` set
472+
*/
473+
export function siblingNote(site) {
474+
if (!(site.siblingCount > 1)) return '';
475+
return (
476+
` (${site.siblingIndex} of ${site.siblingCount} guarded steps in this job; ` +
477+
'they share one timeout clock)'
478+
);
479+
}
480+
391481
/** `file:line job/step`, for a message. */
392482
function where(site) {
393483
const at = site.line ? `${site.file}:${site.line}` : site.file;
@@ -485,7 +575,7 @@ function census(result) {
485575
(site) =>
486576
` ${where(site)}\n` +
487577
` window ${site.window}m (${site.windowSource}) · cap ${site.cap}m (${site.capSource}) · ` +
488-
`budget ${site.budget}m (${site.budgetSource}) · slack ${site.slack}m`,
578+
`budget ${site.budget}m (${site.budgetSource}) · slack ${site.slack}m${siblingNote(site)}`,
489579
);
490580
}
491581

@@ -604,6 +694,31 @@ export async function selfTest() {
604694

605695
const GUARDED = 'node scripts/run-with-stall-guard.mjs --log "$RUNNER_TEMP/x.log" --stall-minutes 10 -- pnpm test';
606696

697+
/** A guard invocation with its own window, so a fixture can vary that alone. */
698+
const guarded = (window) => `node scripts/run-with-stall-guard.mjs --log x --stall-minutes ${window} -- pnpm test`;
699+
700+
/**
701+
* A workflow of several jobs, each with several guarded steps:
702+
* `{ jobId: { timeout, steps: [[stepName, command], ...] } }`. Needed because
703+
* `workflow()` above builds exactly one job of exactly one step, and the
704+
* property under test here is precisely how sites are grouped ACROSS jobs.
705+
*/
706+
const multi = (jobs) =>
707+
`name: fixture\non: push\njobs:\n` +
708+
Object.entries(jobs)
709+
.map(
710+
([jobId, { timeout, steps }]) =>
711+
` ${jobId}:\n timeout-minutes: ${timeout}\n runs-on: ubuntu-latest\n steps:\n` +
712+
steps.map(([name, command]) => ` - name: ${name}\n run: |\n ${command}\n`).join(''),
713+
)
714+
.join('');
715+
716+
/** The census lines `--list` and a green `run()` print, one per site. */
717+
const censusOf = (root) => {
718+
const { defaults } = guardDefaults(root);
719+
return census(scan(root, defaults, parse));
720+
};
721+
607722
try {
608723
// ── 1. The shape the repo ships: W=10 C=20 T=30, slack exactly one window ─
609724
const green = fixture({ 'a.yml': workflow({ jobTimeout: 30, command: GUARDED }) });
@@ -741,7 +856,90 @@ export async function selfTest() {
741856
// ── 8. Line numbers: attached when they can be trusted, never guessed ────
742857
assert('a site carries the line its command sits on', greenScan.sites[0].line === 10, JSON.stringify(greenScan.sites[0]));
743858

744-
// ── 9. The real repository -- the direction the fixtures cannot prove ────
859+
// ── 9. Siblings on one job clock: what the census must and must NOT say ──
860+
//
861+
// The census prints one `slack` per SITE, but `timeout-minutes` runs one
862+
// clock per JOB. These cases pin the note that closes that gap -- and, more
863+
// importantly, pin the two ways of deriving it that are WRONG. Both wrong
864+
// ways are green against a single-sibling tree, so only fixtures shaped
865+
// like the mistake can hold them.
866+
867+
// The positive case: two guarded steps, one job, one clock.
868+
const pair = fixture({
869+
'a.yml': multi({
870+
conformance: { timeout: 30, steps: [['first', guarded(10)], ['second', guarded(10)]] },
871+
}),
872+
});
873+
const pairCensus = censusOf(pair);
874+
assert('two guarded steps in one job produce two sites', pairCensus.length === 2, JSON.stringify(pairCensus));
875+
assert(
876+
'the FIRST of two guarded steps is named as 1 of 2 sharing one clock',
877+
/slack 10m \(1 of 2 guarded steps in this job; they share one timeout clock\)/.test(pairCensus[0]),
878+
pairCensus[0],
879+
);
880+
assert(
881+
'...and the SECOND as 2 of 2 -- the ordinal follows job.steps order, so it is the later step on the clock',
882+
/slack 10m \(2 of 2 guarded steps in this job; they share one timeout clock\)/.test(pairCensus[1]),
883+
pairCensus[1],
884+
);
885+
assert(
886+
'...and the note names the CONSEQUENCE, not only the count',
887+
pairCensus.every((line) => line.includes('they share one timeout clock')),
888+
JSON.stringify(pairCensus),
889+
);
890+
// The verdict must not move: this card changes what the census SAYS only.
891+
const pairRun = drive(pair);
892+
assert('...and a sibling pair is still judged on T - C >= W alone, so it stays green', pairRun.code === 0, pairRun.out);
893+
894+
// THE NEGATIVE CONTROL this card was accepted on: one guarded step, no note.
895+
const lone = fixture({ 'a.yml': workflow({ jobTimeout: 30, command: GUARDED }) });
896+
const loneCensus = censusOf(lone);
897+
assert('a job with a single guarded step gets NO sibling note', !/guarded steps in this job/.test(loneCensus.join('\n')), JSON.stringify(loneCensus));
898+
assert('...and its slack line is otherwise unchanged', /slack 10m$/.test(loneCensus[0]), loneCensus[0]);
899+
900+
// WRONG DERIVATION 1: grouping by a matching `--stall-minutes` value. Five
901+
// of this repo's seven real sites are 10m across four different jobs, so
902+
// this mistake would report siblings almost everywhere.
903+
const sameWindowTwoJobs = fixture({
904+
'a.yml': multi({
905+
alpha: { timeout: 30, steps: [['only', guarded(10)]] },
906+
beta: { timeout: 30, steps: [['only', guarded(10)]] },
907+
}),
908+
});
909+
const sameWindowCensus = censusOf(sameWindowTwoJobs);
910+
assert('two jobs are read as two sites', sameWindowCensus.length === 2, JSON.stringify(sameWindowCensus));
911+
assert(
912+
'two SEPARATE jobs that merely share a --stall-minutes value are NOT siblings',
913+
!/guarded steps in this job/.test(sameWindowCensus.join('\n')),
914+
JSON.stringify(sameWindowCensus),
915+
);
916+
// ...and they are not siblings for merely sharing a FILE, either: the two
917+
// jobs above live in one workflow file and still get no note.
918+
919+
// WRONG DERIVATION 2: grouping by job id alone. A job id is unique only
920+
// within its file, and this tree reuses three ids across files today.
921+
const sameIdTwoFiles = fixture({
922+
'a.yml': multi({ publish: { timeout: 30, steps: [['only', guarded(10)]] } }),
923+
'b.yml': multi({ publish: { timeout: 30, steps: [['only', guarded(10)]] } }),
924+
});
925+
const sameIdCensus = censusOf(sameIdTwoFiles);
926+
assert('the same job id in two files yields two sites', sameIdCensus.length === 2, JSON.stringify(sameIdCensus));
927+
assert(
928+
'the same job id in two DIFFERENT files is two clocks, not a sibling pair',
929+
!/guarded steps in this job/.test(sameIdCensus.join('\n')),
930+
JSON.stringify(sameIdCensus),
931+
);
932+
933+
// A job holding three, to prove the count is the group size and not a
934+
// hardcoded pair -- the wording has to stay right above N = 2.
935+
const trio = fixture({
936+
'a.yml': multi({ probe: { timeout: 30, steps: [['a', guarded(10)], ['b', guarded(10)], ['c', guarded(10)]] } }),
937+
});
938+
const trioCensus = censusOf(trio);
939+
assert('a job holding three guarded steps counts three, not two', /\(3 of 3 guarded steps in this job/.test(trioCensus[2]), JSON.stringify(trioCensus));
940+
assert('...and the middle one is 2 of 3', /\(2 of 3 guarded steps in this job/.test(trioCensus[1]), trioCensus[1]);
941+
942+
// ── 10. The real repository -- the direction the fixtures cannot prove ───
745943
const realDefaults = guardDefaults(repoRoot());
746944
assert('the real guard still declares both defaults', Boolean(realDefaults.defaults), JSON.stringify(realDefaults.problems));
747945
if (realDefaults.defaults) {
@@ -750,6 +948,43 @@ export async function selfTest() {
750948
assert('the repo scan actually reads workflows', real.files > 0 && real.jobs > 0, `${real.files}/${real.jobs}`);
751949
assert('the repo has guard-wrapped steps -- at 0 this gate guards nothing', real.sites.length > 0, `${real.sites.length}`);
752950
assert('the repo is green on the invariant', real.violations.length === 0, JSON.stringify(real.violations.map((v) => where(v))));
951+
952+
// The sibling annotation, re-derived here independently of the code that
953+
// wrote it, so agreement is a check rather than a restatement.
954+
const expected = new Map();
955+
for (const site of real.sites) {
956+
const key = `${site.file} ${site.job}`;
957+
expected.set(key, (expected.get(key) ?? 0) + 1);
958+
}
959+
assert(
960+
'every real site knows its group size, and its ordinal is inside it',
961+
real.sites.every((s) => s.siblingCount === expected.get(`${s.file} ${s.job}`) && s.siblingIndex >= 1 && s.siblingIndex <= s.siblingCount),
962+
JSON.stringify(real.sites.map((s) => `${s.file} ${s.job} ${s.siblingIndex}/${s.siblingCount}`)),
963+
);
964+
for (const [key, size] of expected) {
965+
const ordinals = real.sites.filter((s) => `${s.file} ${s.job}` === key).map((s) => s.siblingIndex);
966+
assert(`the ordinals in \`${key}\` are exactly 1..${size}`, JSON.stringify(ordinals) === JSON.stringify([...Array(size).keys()].map((i) => i + 1)), JSON.stringify(ordinals));
967+
}
968+
969+
// Non-vacuity for the note itself. Every assertion above about the ABSENCE
970+
// of a note would also pass if the note could never be produced, and the
971+
// fixtures are the only thing proving it can -- on fixture trees. This is
972+
// the same proof against the population the census actually prints: at
973+
// least one real job holds more than one guarded step. If that ever stops
974+
// being true, this reds so the header prose above gets re-measured too,
975+
// rather than quietly describing a population that no longer exists.
976+
const realSiblingJobs = [...expected].filter(([, size]) => size > 1);
977+
assert(
978+
'at least one REAL job holds more than one guarded step, so the census note is exercised by the live tree',
979+
realSiblingJobs.length > 0,
980+
`groups: ${JSON.stringify([...expected])}`,
981+
);
982+
const noted = census(real).filter((line) => line.includes('guarded steps in this job'));
983+
assert(
984+
'...and the real census prints one note per site in those jobs',
985+
noted.length === realSiblingJobs.reduce((n, [, size]) => n + size, 0),
986+
`${noted.length} note(s) for ${JSON.stringify(realSiblingJobs)}`,
987+
);
753988
}
754989
} finally {
755990
for (const dir of roots) rmSync(dir, { recursive: true, force: true });
@@ -762,7 +997,9 @@ export async function selfTest() {
762997
}
763998
console.log(
764999
`✓ check-stall-guard-budget --self-test: ${checked} assertions over real fixture trees on disk (real run() path) -- ` +
765-
'both violation tiers driven red, the guard defaults proven READ rather than copied, and the empty population proven to REFUSE.',
1000+
'both violation tiers driven red, the guard defaults proven READ rather than copied, the empty population proven to ' +
1001+
'REFUSE, and the sibling note proven to follow JOB membership -- absent for a lone guarded step, and absent for two ' +
1002+
'jobs sharing only a --stall-minutes value or only a job id.',
7661003
);
7671004
return 0;
7681005
}

0 commit comments

Comments
 (0)