diff --git a/.github/scripts/work-unblock/label-unblocked.mjs b/.github/scripts/work-unblock/label-unblocked.mjs index 0795699e80..a38665a965 100755 --- a/.github/scripts/work-unblock/label-unblocked.mjs +++ b/.github/scripts/work-unblock/label-unblocked.mjs @@ -283,6 +283,11 @@ const STAGE_STEP = "Stage the activity from the base"; // request-lane-token's notice when kill-switch-ok ends a kill-switch refusal // green with no token. const STOPPED = /^\d{4}-\d\d-\d\dT[\d:.]+Z ##\[notice\]lane-token-stopped: the broker refused the token because a lanes kill switch is on(?:,|$)/; +// The runner's markers around request-lane-token's one composite step, its +// `Request the lane token` with id `request`; a STOPPED line counts only +// between the job's first start marker and its end marker. +const MINT_START = /^\d{4}-\d\d-\d\dT[\d:.]+Z ##\[start-action display=Request the lane token;id=([\w-]+\.request)\]$/; +const mintEnd = (id) => new RegExp(`^\\d{4}-\\d\\d-\\d\\dT[\\d:.]+Z ##\\[end-action id=${id.replace(".", "\\.")};`); const BREAKER = "kill-switch"; // The refused jobs' logs on 2026-10-24 were 45 KB (broker-check) and 94 KB // (apply-issue-patch); a longer one is left unread. @@ -1130,10 +1135,11 @@ async function didNoWork(jobsOf, refusalOf, runs) { // The reason the broker refused `job`, from its log: the failed step's last // line before the runner's first `Process completed`, in `mode` (check or -// token), or BREAKER for a `stop` mode log with a STOPPED notice line. Null -// when there is no such line or the log cannot be read within LOG_CAP_BYTES, -// or no log reader was given; the log is data, and no other line of it is -// read. +// token), or BREAKER for a `stop` mode log with a STOPPED line inside the +// mint step's MINT_START section. Null when there is no such line or the log +// cannot be read within LOG_CAP_BYTES, or no log reader was given; the log is +// data: check and token modes read only that one line, and a STOPPED line +// outside the section never counts. function refusalReader(jobLog, repo) { return async (job, mode) => { if (!jobLog || !RUN_ID.test(String(job?.id))) { @@ -1148,7 +1154,13 @@ function refusalReader(jobLog, repo) { } const lines = String(log ?? "").split("\n"); if (mode === "stop") { - return lines.some((line) => STOPPED.test(line)) ? BREAKER : null; + const start = lines.findIndex((line) => MINT_START.test(line)); + if (start < 0) { + return null; + } + const end = mintEnd(MINT_START.exec(lines[start])[1]); + const stop = lines.findIndex((line, at) => at > start && end.test(line)); + return stop > 0 && lines.slice(start, stop).some((line) => STOPPED.test(line)) ? BREAKER : null; } const end = lines.findIndex((line) => STEP_FAILED.test(line)); return end > 0 ? (DENIAL[mode].exec(lines[end - 1])?.[1] ?? null) : null; diff --git a/.github/scripts/work-unblock/label-unblocked.test.mjs b/.github/scripts/work-unblock/label-unblocked.test.mjs index 6f42a6bcf8..88ce3ec0e2 100644 --- a/.github/scripts/work-unblock/label-unblocked.test.mjs +++ b/.github/scripts/work-unblock/label-unblocked.test.mjs @@ -2338,7 +2338,10 @@ test("breaker trips at the mint never count toward the failed-mint bound", async // With request-lane-token's kill-switch-ok, the same refusal ends the mint // step green with no token and a `lane-token-stopped` notice: every step that // keys on the token, `Stage the activity from the base` first, skips, and the -// run ends green. Only that shape in the job listing earns a log read. +// run ends green. Only that shape in the job listing earns a log read. The +// log's shape is job 120970852314's: the composite's one step sits between a +// start-action and an end-action marker, and the step's own output follows +// its script group. const stoppedJob = (id, name) => ({ id, name, @@ -2352,8 +2355,13 @@ const stoppedJob = (id, name) => ({ }); const stoppedLog = ({ stamp = "2026-10-30T01:57:51.9466327Z ", line = "lane-token-stopped: the broker refused the token because a lanes kill switch is on, a designed stop; this run takes no token and skips the rest" } = {}) => [ - "2026-10-30T01:57:50.6158116Z ##[group]Run request-lane-token", + "2026-10-30T01:57:50.6157454Z ##[group]Run ./.base/.github/actions/request-lane-token", + "2026-10-30T01:57:50.6159328Z ##[endgroup]", + "2026-10-30T01:57:50.6165385Z ##[start-action display=Request the lane token;id=lane-token.request]", + "2026-10-30T01:57:50.6182178Z ##[group]Run set -euo pipefail", + "2026-10-30T01:57:50.6340248Z ##[endgroup]", `${stamp}##[notice]${line}`, + "2026-10-30T01:57:51.9483644Z ##[end-action id=lane-token.request;outcome=success;conclusion=success;duration_ms=1331]", "2026-10-30T01:57:52.0000000Z ##[group]Run jq -n \\", "", ].join("\n"); @@ -2425,6 +2433,23 @@ test("a stopped mint whose log does not carry the kill-switch notice is a failed } }); +test("a kill-switch notice outside the mint step's section is a failed mint, never a breaker trip", async () => { + const [head, tail] = stoppedLog().split(/^.*##\[notice\].*\n/m); + const notice = stoppedLog().split("\n").find((line) => line.includes("##[notice]")); + const start = head.split("\n").find((line) => line.includes("##[start-action")); + const end = tail.split("\n").find((line) => line.includes("##[end-action")); + for (const [name, log] of [ + ["in a later step", `${head}${tail}${notice}\n`], + ["before the mint step", `${notice}\n${head}${tail}`], + ["in a second mint block after the first", `${head}${tail}${start}\n${notice}\n${end}\n`], + ["with no end-action marker", `${head}${notice}\n`], + ]) { + const { github, actions, calls } = stalledRun({ jobs: MINT_STOPPED, logs: { [PATCH_MINT_ID]: log }, conclusion: "success" }); + assert.equal(await sweep({ github, actions }), 0, name); + assert.deepEqual(writes(calls), refired(30), name); + } +}); + test("breaker trips past limit-stops-per-issue since a person last added agent-ready wait on the stranded backoff", async () => { const under = stalledRun({ jobs: BROKER_REFUSED, logs: CHECK_KILLED, ...(await afterSweeps(2, BROKER_REFUSED, CHECK_KILLED)) }); assert.equal(await sweep({ github: under.github, actions: under.actions }), 0); diff --git a/.github/workflows/pr-run-activity-read.yml b/.github/workflows/pr-run-activity-read.yml index efd7bdf0fc..c1eaef8635 100644 --- a/.github/workflows/pr-run-activity-read.yml +++ b/.github/workflows/pr-run-activity-read.yml @@ -2097,8 +2097,10 @@ jobs: if ((RESUME_BRANCH === '') !== (RESUME_TIP === '') || (RESUME_TIP !== '' && STARTED !== 'true')) return bad('resume-tip'); if (STARTED === 'true' && BASE_SHA === '') return bad('base-sha'); // Branches to prune: at most 20 distinct lane branches of the - // issue, none of them the reuse or resume branch. Not re-derived: - // the prune job re-checks each against GitHub before deleting. + // issue, none of them the reuse or resume branch. The checked step + // publishes only the names the re-derived earlier-work check also + // names, and the prune job re-checks each against GitHub before + // deleting. const prune = PRUNE_BRANCHES === '' ? [] : PRUNE_BRANCHES.split(' '); if (prune.length > 20 || new Set(prune).size !== prune.length || prune.some((name) => !BRANCH.test(name) || name === REUSE_BRANCH || name === RESUME_BRANCH)) return bad('prune-branches');