Skip to content

Commit 1bc65ff

Browse files
committed
fix(ci): settle an indeterminate label write against the board, not the response
`Auto Label` went red on PR #17982 for work it had COMPLETED: the `--paths` step POSTed `tests`, the API answered HTTP 500, the script exited 1 — and the PR's label set read `size/s, skip-changeset, tests` immediately afterwards. A 500 is not evidence the write failed. The job's red said "the response failed"; every reader takes it to mean "the label is missing". Those are different facts, and that gap — not a missing retry — is the defect. A bounded 5xx retry with exponential backoff has been in this file since #10777 and did not close it. Failures are now classified: 5xx and a thrown fetch are INDETERMINATE (the server may have acted before the answer was lost) and are settled by re-reading the PR's labels and judging the step's post-condition; 4xx including 429 stays DETERMINATE, fatal and loud, even when the board happens to satisfy the post-condition — a 403 is a broken token and a 422 is a label that does not exist in the repo. A settling re-read that itself fails settles nothing: the write is reported UNVERIFIED and the original error is raised. `failureIsIndeterminate`, `postconditionOf` and `settleWriteFailure` are pure and pinned by a new 16-case `--self-test` battery covering both directions. Claude-Session: https://claude.ai/code/session_012GKcPZbMoGq7WPzKLfRBTU Co-authored-by: Claude <noreply@anthropic.com>
1 parent a90a9f2 commit 1bc65ff

1 file changed

Lines changed: 246 additions & 6 deletions

File tree

scripts/pr-labels.mjs

Lines changed: 246 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -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

431548
async 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

507663
async 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', /DETERMINATELY/.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

Comments
 (0)