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. */
392482function 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+ / s l a c k 1 0 m \( 1 o f 2 g u a r d e d s t e p s i n t h i s j o b ; t h e y s h a r e o n e t i m e o u t c l o c k \) / . 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+ / s l a c k 1 0 m \( 2 o f 2 g u a r d e d s t e p s i n t h i s j o b ; t h e y s h a r e o n e t i m e o u t c l o c k \) / . 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' , ! / g u a r d e d s t e p s i n t h i s j o b / . test ( loneCensus . join ( '\n' ) ) , JSON . stringify ( loneCensus ) ) ;
898+ assert ( '...and its slack line is otherwise unchanged' , / s l a c k 1 0 m $ / . 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+ ! / g u a r d e d s t e p s i n t h i s j o b / . 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+ ! / g u a r d e d s t e p s i n t h i s j o b / . 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 o f 3 g u a r d e d s t e p s i n t h i s j o b / . test ( trioCensus [ 2 ] ) , JSON . stringify ( trioCensus ) ) ;
940+ assert ( '...and the middle one is 2 of 3' , / \( 2 o f 3 g u a r d e d s t e p s i n t h i s j o b / . 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