@@ -375,6 +375,111 @@ export function planPathWrites({ prNumber, matched, current }) {
375375 ] ;
376376}
377377
378+ // ---------------------------------------------------------------------------
379+ // Settling an indeterminate write (#17984). Pure -- the self-test pins it.
380+ // ---------------------------------------------------------------------------
381+ //
382+ // ## The defect
383+ //
384+ // On PR #17982 this job went red for work it had COMPLETED. Step `--paths`
385+ // POSTed `tests`, the API answered `500`, the retry loop below exhausted, the
386+ // script exited 1 -- and the PR's label set read `size/s, skip-changeset,
387+ // tests` immediately afterwards. The label was on the PR. The job said it was
388+ // not.
389+ //
390+ // ⛔ A `500` is NOT evidence the write failed. That is the whole finding: the
391+ // red says "the response failed", every reader takes it to mean "the label is
392+ // missing", and those are different facts. A bounded 5xx retry does not close
393+ // it (this file has had one since #10777 -- the card's "no retry on 5xx"
394+ // premise is false); what closes it is asking the BOARD instead of believing
395+ // the RESPONSE.
396+ //
397+ // ## The line this draws
398+ //
399+ // A failure is either DETERMINATE -- the server refused and did not act -- or
400+ // INDETERMINATE -- it may have acted before the answer was lost. Only the
401+ // second kind is settleable by a re-read, and only the second kind gets one:
402+ //
403+ // * `5xx`, and a fetch that THREW (the request may have been sent and the
404+ // response lost) -> indeterminate -> re-read the label set and judge the
405+ // POST-CONDITION;
406+ // * `4xx`, `429` included -> determinate -> fatal, loud, unchanged. A `403`
407+ // is a broken token and a `422` is a label that does not exist in the
408+ // repo; both are real misconfigurations and must stay red.
409+ //
410+ // ⛔ And a re-read that itself fails settles NOTHING -- the caller reports the
411+ // write as UNVERIFIED and fails on the original error rather than falling back
412+ // to a guess ("absence must be loud", AGENTS.md Route & surface ownership §3).
413+
414+ /**
415+ * Is a failed attempt one the server might have ACTED on before it failed?
416+ * @param {{ status?: number, threw?: boolean } } attempt
417+ */
418+ export function failureIsIndeterminate ( { status, threw = false } = { } ) {
419+ if ( threw ) return true ;
420+ return Number ( status ) >= 500 ;
421+ }
422+
423+ /**
424+ * The state of the world a plan step was trying to bring about -- read off the
425+ * step itself, so it cannot drift from what the step actually asks for.
426+ * `null` means "this step declares none this function can read", which settles
427+ * nothing.
428+ *
429+ * @returns {{ kind: 'present'|'absent', labels: string[] } | null }
430+ */
431+ export function postconditionOf ( step ) {
432+ if ( step ?. method === 'POST' ) {
433+ const labels = step ?. body ?. labels ;
434+ if ( ! Array . isArray ( labels ) || labels . length === 0 ) return null ;
435+ return { kind : 'present' , labels : [ ...labels ] } ;
436+ }
437+ if ( step ?. method === 'DELETE' && typeof step . path === 'string' ) {
438+ const marker = '/labels/' ;
439+ const at = step . path . indexOf ( marker ) ;
440+ if ( at < 0 ) return null ;
441+ const name = decodeURIComponent ( step . path . slice ( at + marker . length ) ) ;
442+ if ( ! name ) return null ;
443+ return { kind : 'absent' , labels : [ name ] } ;
444+ }
445+ return null ;
446+ }
447+
448+ /**
449+ * Given a failed write and the label set read back afterwards: did the thing we
450+ * wanted happen anyway?
451+ *
452+ * @param {{ step: object, liveLabels?: string[], indeterminate?: boolean } } input
453+ * @returns {{ settled: boolean, why: string } }
454+ */
455+ export function settleWriteFailure ( { step, liveLabels = [ ] , indeterminate = false } = { } ) {
456+ if ( ! indeterminate ) {
457+ return {
458+ settled : false ,
459+ why : 'the API answered DETERMINATELY (a 4xx, 429 included) -- the server refused and did not act, '
460+ + 'so this is a real misconfiguration and stays loud'
461+ } ;
462+ }
463+ const post = postconditionOf ( step ) ;
464+ if ( post === null ) {
465+ return {
466+ settled : false ,
467+ why : 'this step declares no post-condition that can be read off it, and an unreadable post-condition settles nothing'
468+ } ;
469+ }
470+ const present = new Set ( liveLabels ) ;
471+ if ( post . kind === 'present' ) {
472+ const missing = post . labels . filter ( ( label ) => ! present . has ( label ) ) ;
473+ return missing . length === 0
474+ ? { settled : true , why : `the write reported as failed had LANDED: ${ post . labels . join ( ', ' ) } is on the PR` }
475+ : { settled : false , why : `the write did NOT land: still missing from the PR: ${ missing . join ( ', ' ) } ` } ;
476+ }
477+ const lingering = post . labels . filter ( ( label ) => present . has ( label ) ) ;
478+ return lingering . length === 0
479+ ? { settled : true , why : `the write reported as failed had LANDED: ${ post . labels . join ( ', ' ) } is off the PR` }
480+ : { settled : false , why : `the write did NOT land: still on the PR: ${ lingering . join ( ', ' ) } ` } ;
481+ }
482+
378483// ---------------------------------------------------------------------------
379484// GitHub REST plumbing.
380485// ---------------------------------------------------------------------------
@@ -395,6 +500,11 @@ async function ghRequest(method, path, body) {
395500 const url = `${ apiUrl } /repos/${ repo } ${ path } ` ;
396501
397502 let lastError = null ;
503+ // Sticky across attempts on purpose: if ANY attempt could have reached the
504+ // server's state, the whole request is indeterminate, even when a later
505+ // attempt came back with a clean 4xx. The write we cannot rule out is the
506+ // one from the attempt that went dark, not the one that was refused.
507+ let indeterminate = false ;
398508 for ( let attempt = 1 ; attempt <= 4 ; attempt += 1 ) {
399509 try {
400510 const response = await fetch ( url , {
@@ -418,14 +528,21 @@ async function ghRequest(method, path, body) {
418528
419529 const text = await response . text ( ) ;
420530 lastError = new Error ( `${ method } ${ url } -> HTTP ${ response . status } : ${ text . slice ( 0 , 400 ) } ` ) ;
531+ indeterminate ||= failureIsIndeterminate ( { status : response . status } ) ;
421532 // 4xx other than 429 will not get better by trying again.
422533 if ( response . status < 500 && response . status !== 429 ) break ;
423534 } catch ( error ) {
424535 lastError = error ;
536+ indeterminate ||= failureIsIndeterminate ( { threw : true } ) ;
425537 }
426538 if ( attempt < 4 ) await new Promise ( ( resolve ) => setTimeout ( resolve , 2 ** attempt * 500 ) ) ;
427539 }
428- throw lastError ?? new Error ( `${ method } ${ url } failed` ) ;
540+ const failure = lastError ?? new Error ( `${ method } ${ url } failed` ) ;
541+ // The caller settles an indeterminate failure by re-reading the label set;
542+ // a determinate one it rethrows unchanged. Carrying the class on the error is
543+ // what keeps that decision out of string-matching on the message.
544+ failure . indeterminate = indeterminate ;
545+ throw failure ;
429546}
430547
431548async function listPullFiles ( prNumber ) {
@@ -444,7 +561,41 @@ async function listCurrentLabels(prNumber) {
444561 return Array . isArray ( labels ) ? labels . map ( ( l ) => l . name ) : [ ] ;
445562}
446563
447- async function runPlan ( plan , dryRun ) {
564+ /**
565+ * A write that failed indeterminately is settled against the BOARD, not against
566+ * the response. See the `settleWriteFailure` section header for why.
567+ */
568+ async function settleOrRethrow ( step , error , readLabels ) {
569+ if ( error ?. indeterminate !== true || typeof readLabels !== 'function' ) {
570+ // Determinate refusal, or nothing wired to read with: the response IS the
571+ // verdict and it is a failure.
572+ throw error ;
573+ }
574+ console . log ( `pr-labels: ${ error . message } ` ) ;
575+ console . log (
576+ 'pr-labels: that failure is INDETERMINATE -- the server may have acted before it failed. '
577+ + "Re-reading the PR's labels to settle it against the board."
578+ ) ;
579+
580+ let live ;
581+ try {
582+ live = await readLabels ( ) ;
583+ } catch ( readError ) {
584+ console . error (
585+ `pr-labels: the settling re-read ITSELF failed (${ readError . message } ) -- the write is UNVERIFIED, not verified.`
586+ ) ;
587+ throw error ;
588+ }
589+ console . log ( `pr-labels: labels on the PR after the failure: ${ live . join ( ', ' ) || '(none)' } ` ) ;
590+
591+ const verdict = settleWriteFailure ( { step, liveLabels : live , indeterminate : true } ) ;
592+ if ( ! verdict . settled ) {
593+ throw new Error ( `${ error . message } -- and ${ verdict . why } ` ) ;
594+ }
595+ console . log ( `pr-labels: SETTLED -- ${ verdict . why } . Not failing the job for a response that was wrong about its own effect.` ) ;
596+ }
597+
598+ async function runPlan ( plan , dryRun , readLabels ) {
448599 if ( plan . length === 0 ) {
449600 console . log ( 'pr-labels: nothing to write.' ) ;
450601 return ;
@@ -456,7 +607,12 @@ async function runPlan(plan, dryRun) {
456607 throw new Error ( `pr-labels: refusing to issue a whole-set ${ FORBIDDEN_VERB } (${ step . path } ).` ) ;
457608 }
458609 console . log ( `pr-labels: ${ step . method } ${ step . path } -- ${ step . why } ` ) ;
459- if ( ! dryRun ) await ghRequest ( step . method , step . path , step . body ) ;
610+ if ( dryRun ) continue ;
611+ try {
612+ await ghRequest ( step . method , step . path , step . body ) ;
613+ } catch ( error ) {
614+ await settleOrRethrow ( step , error , readLabels ) ;
615+ }
460616 }
461617}
462618
@@ -501,7 +657,7 @@ async function runSize(dryRun) {
501657 family : buckets . map ( ( b ) => b . label ) ,
502658 current
503659 } ) ;
504- await runPlan ( plan , dryRun ) ;
660+ await runPlan ( plan , dryRun , ( ) => listCurrentLabels ( prNumber ) ) ;
505661}
506662
507663async function runPaths ( dryRun ) {
@@ -516,7 +672,7 @@ async function runPaths(dryRun) {
516672
517673 const current = await listCurrentLabels ( prNumber ) ;
518674 console . log ( `pr-labels: labels on PR #${ prNumber } right now: ${ current . join ( ', ' ) || '(none)' } ` ) ;
519- await runPlan ( planPathWrites ( { prNumber, matched, current } ) , dryRun ) ;
675+ await runPlan ( planPathWrites ( { prNumber, matched, current } ) , dryRun , ( ) => listCurrentLabels ( prNumber ) ) ;
520676}
521677
522678// ---------------------------------------------------------------------------
@@ -550,13 +706,14 @@ const SELF_TEST_BATTERIES = Object.freeze({
550706 'size bucketing: `<`, not `<=`' : 10 ,
551707 'the write plans: POST and DELETE only' : 11 ,
552708 'the #10698 interleaving, replayed' : 3 ,
709+ 'the #17982 indeterminate write, settled against the board' : 16 ,
553710 'config parsing' : 6 ,
554711 'the REAL config, so drift fails lint rather than a PR run' : 2 ,
555712} ) ;
556713
557714// DELETING an entry silences that battery's floor exactly as effectively as
558715// zeroing it, so the roster's own size is pinned too.
559- const SELF_TEST_BATTERY_FLOOR = 6 ;
716+ const SELF_TEST_BATTERY_FLOOR = 7 ;
560717
561718// The key an assertion is filed under when no battery is open. It is not a
562719// declared battery, so it reds by the same set difference rather than silently
@@ -755,6 +912,89 @@ function selfTest() {
755912 [ 'skip-changeset' , 'size/l' ]
756913 ) ;
757914
915+ // --- the #17982 indeterminate write, settled against the board -----------
916+ //
917+ // TWO DIRECTIONS, and both are required. A battery that only proved the
918+ // settle succeeds where the write landed would be the same exit-0-by-
919+ // construction shape this card is about: it would stay green if the 4xx leg
920+ // were deleted tomorrow.
921+ battery ( 'the #17982 indeterminate write, settled against the board' ) ;
922+
923+ // ① Which failures could the server have ACTED on?
924+ check ( 'a 500 is indeterminate' , failureIsIndeterminate ( { status : 500 } ) , true ) ;
925+ check ( 'so is a 503' , failureIsIndeterminate ( { status : 503 } ) , true ) ;
926+ check ( 'a thrown fetch is indeterminate -- the response, not the request, is what was lost' ,
927+ failureIsIndeterminate ( { threw : true } ) , true ) ;
928+ check ( 'a 403 is DETERMINATE -- a broken token refused and did not act' , failureIsIndeterminate ( { status : 403 } ) , false ) ;
929+ check ( 'a 422 is DETERMINATE -- the label does not exist in this repo' , failureIsIndeterminate ( { status : 422 } ) , false ) ;
930+ check ( 'a 429 is DETERMINATE -- rate-limited means not served, not half-served' ,
931+ failureIsIndeterminate ( { status : 429 } ) , false ) ;
932+
933+ // ② The post-condition is read off the step, so it cannot drift from it.
934+ const addTests = { method : 'POST' , path : '/issues/17982/labels' , body : { labels : [ 'tests' ] } , why : 'add' } ;
935+ const dropSizeS = { method : 'DELETE' , path : '/issues/17982/labels/size%2Fs' , why : 'retire' } ;
936+ check ( 'a POST wants its labels PRESENT' , postconditionOf ( addTests ) , { kind : 'present' , labels : [ 'tests' ] } ) ;
937+ check ( 'a DELETE wants ONE named label ABSENT, url-decoded' , postconditionOf ( dropSizeS ) ,
938+ { kind : 'absent' , labels : [ 'size/s' ] } ) ;
939+ check ( 'a POST with no labels declares no post-condition' ,
940+ postconditionOf ( { method : 'POST' , path : '/issues/1/labels' , body : { labels : [ ] } } ) , null ) ;
941+ check ( 'a verb this file does not emit declares none either' ,
942+ postconditionOf ( { method : FORBIDDEN_VERB , path : '/issues/1/labels' , body : { labels : [ 'x' ] } } ) , null ) ;
943+
944+ // ③ DIRECTION ONE -- the incident, replayed. The POST 500'd; the board shows
945+ // the label. The job must NOT be red for work it completed.
946+ check (
947+ 'the #17982 incident: POST -> 500, and `tests` is on the PR -> SETTLED' ,
948+ settleWriteFailure ( {
949+ step : addTests ,
950+ liveLabels : [ 'size/s' , 'skip-changeset' , 'tests' ] ,
951+ indeterminate : true
952+ } ) . settled ,
953+ true
954+ ) ;
955+ check (
956+ 'a PARTIAL landing is not a landing' ,
957+ settleWriteFailure ( {
958+ step : { method : 'POST' , path : '/issues/17982/labels' , body : { labels : [ 'tests' , 'ci/cd' ] } , why : 'add' } ,
959+ liveLabels : [ 'tests' ] ,
960+ indeterminate : true
961+ } ) ,
962+ { settled : false , why : 'the write did NOT land: still missing from the PR: ci/cd' }
963+ ) ;
964+ check (
965+ 'a 5xx whose label really is missing stays red' ,
966+ settleWriteFailure ( { step : addTests , liveLabels : [ 'size/s' ] , indeterminate : true } ) . settled ,
967+ false
968+ ) ;
969+ check (
970+ 'a DELETE that 5xx-ed but took the label off IS settled' ,
971+ settleWriteFailure ( { step : dropSizeS , liveLabels : [ 'size/l' , 'tests' ] , indeterminate : true } ) . settled ,
972+ true
973+ ) ;
974+ check (
975+ 'a DELETE whose label is still there is not' ,
976+ settleWriteFailure ( { step : dropSizeS , liveLabels : [ 'size/s' ] , indeterminate : true } ) . settled ,
977+ false
978+ ) ;
979+
980+ // ④ DIRECTION TWO -- a genuine 4xx stays loud, and stays loud EVEN WHEN the
981+ // board happens to satisfy the post-condition. A determinate refusal is a
982+ // misconfiguration; the label being there by some other hand does not make
983+ // the token work. ⛔ This is the case that must never be "relaxed".
984+ const determinate = settleWriteFailure ( {
985+ step : addTests ,
986+ liveLabels : [ 'size/s' , 'skip-changeset' , 'tests' ] ,
987+ indeterminate : false
988+ } ) ;
989+ check ( 'a determinate 4xx is NOT settled, even with the label present' , determinate . settled , false ) ;
990+ check ( '…and it says so in those words' , / D E T E R M I N A T E L Y / . test ( determinate . why ) , true ) ;
991+ check (
992+ 'a step with no readable post-condition settles nothing either' ,
993+ settleWriteFailure ( { step : { method : 'POST' , path : '/issues/1/labels' } , liveLabels : [ 'tests' ] , indeterminate : true } )
994+ . settled ,
995+ false
996+ ) ;
997+
758998 // --- config parsing ------------------------------------------------------
759999 battery ( 'config parsing' ) ;
7601000 const parsed = parseLabelerConfig (
0 commit comments