From 9a9c1dda09ac836cdee196b5b2ea3dfb732aa8c5 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Fri, 30 Oct 2026 07:06:58 -0400 Subject: [PATCH 1/2] ci: end a work-resolve-issue kill-switch deny neutral, keeping work-unblock's re-fire An issue run of pr-run-activity-write.yml now takes request-lane-token's kill-switch-ok, so a broker kill-switch refusal at apply-issue-patch, report-outcome or report-skip ends green with no token. report-escalation stays red. work-unblock's label-unblocked.mjs reads that stop as a breaker trip: a `Request the lane token` success whose `Stage the activity from the base` step skipped is a stopped mint, and its job log's timestamped `##[notice]lane-token-stopped: ... kill switch is on` line makes it a trip. A stopped mint with no such line is a failed mint. The failed-step DENIAL.token path stays for runs from before this change. Closes #10753 Co-Authored-By: Claude Opus 5.5 --- .../actions/resolve-config/callers.test.mjs | 11 +-- .../scripts/work-unblock/label-unblocked.mjs | 48 ++++++++--- .../work-unblock/label-unblocked.test.mjs | 82 +++++++++++++++++++ .github/workflows/pr-run-activity-write.yml | 16 ++-- 4 files changed, 131 insertions(+), 26 deletions(-) diff --git a/.github/actions/resolve-config/callers.test.mjs b/.github/actions/resolve-config/callers.test.mjs index e3bf4b0cc5..99c9b8e38d 100644 --- a/.github/actions/resolve-config/callers.test.mjs +++ b/.github/actions/resolve-config/callers.test.mjs @@ -7118,15 +7118,16 @@ test("every kill-switch-ok caller runs no token step and no dependent job on a k ].sort()); }); -// work-unblock re-fires an issue from the failed mint step, and no -// dispatcher re-fires report-escalation, so only a pull request run of any -// other activity ends a kill-switch refusal green; those two stay red. -test("pr-run-activity-write.yml takes kill-switch-ok on a pull request run only, never for report-escalation", () => { +// work-unblock re-fires an issue from its stopped mint step (label-unblocked.mjs +// STOPPED), but no dispatcher re-fires report-escalation, so every run but +// report-escalation ends a kill-switch refusal green; that one stays red. +test("pr-run-activity-write.yml takes kill-switch-ok on every run but report-escalation", () => { const step = workflow("pr-run-activity-write.yml").jobs.run.steps.find((s) => s.id === "lane-token"); const value = step.with["kill-switch-ok"]; const at = (issue, activity) => evaluate(value, { "inputs.issue-number": issue, "inputs.activity": activity }); assert.equal(at("", "publish-feedback"), true, "a pull request run skips"); - assert.equal(at("7", "apply-issue-patch"), false, "an issue run stays red"); + assert.equal(at("7", "apply-issue-patch"), true, "an issue run's patch skips"); + assert.equal(at("7", "report-outcome"), true, "an issue run's report skips"); assert.equal(at("", "report-escalation"), false, "an escalation stays red"); const { jobs } = workflow("pr-unblock.yml"); assert.equal(jobs["report-escalation"].with.activity, "report-escalation", "the escalation caller passes that activity"); diff --git a/.github/scripts/work-unblock/label-unblocked.mjs b/.github/scripts/work-unblock/label-unblocked.mjs index a54d567cab..162f3a82d4 100755 --- a/.github/scripts/work-unblock/label-unblocked.mjs +++ b/.github/scripts/work-unblock/label-unblocked.mjs @@ -277,6 +277,12 @@ const DENIAL = { token: /^\d{4}-\d\d-\d\dT[\d:.]+Z ##\[error\]lane-token-denied: the broker answered 403 with reason ([a-z]+(?:-[a-z]+)*)(?::|$)/, }; const STEP_FAILED = /^\d{4}-\d\d-\d\dT[\d:.]+Z ##\[error\]Process completed with exit code [0-9]+\.$/; +// The write runner's step after MINT_STEP that runs exactly when a token was +// minted; a MINT_STEP success with it skipped is a mint that stopped green. +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(?:,|$)/; 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. @@ -1021,10 +1027,12 @@ async function laneRuns(actions, repo, number, addedAt) { // How every one of `runs` (completed) did no work in its latest attempt, or // null when one did some: no job minted a lane token, and the agent-ready run // either had its `Request the lane token` step or broker-check's BROKER_STEP -// fail for a reason other than the kill switch, or with no reason in its log -// (`mint-failed`, which wins; the read runner resolve-issue calls has no +// fail for a reason other than the kill switch, or with no reason in its log, +// or had that mint step stop green with no token and no STOPPED notice in its +// log (`mint-failed`, which wins; the read runner resolve-issue calls has no // MINT_STEP, so its broker ask is the one the broker can fail), had the broker -// refuse it for the kill switch at broker-check or a mint, or had broker-check +// refuse it for the kill switch at broker-check or a mint (failed, or stopped +// green with its STOPPED notice), or had broker-check // skip its broker ask for the kill switch (`breaker`), was skipped by the WIP // cap (`capped`: pick-slot ran, resolve-issue did not), or was skipped by the // admit job's blocker gate (`blocked`: its BLOCKER_STEP ran and pick-slot did @@ -1050,16 +1058,17 @@ async function didNoWork(jobsOf, refusalOf, runs) { return null; } const jobs = await jobsOf(run); - const mints = jobs.flatMap((job) => (job?.steps ?? []).filter((step) => step?.name === MINT_STEP)); - if (mints.some((step) => step.conclusion === "success")) { + const endedAt = (job, name, conclusion) => (job?.steps ?? []).some((step) => step?.name === name && step.conclusion === conclusion); + const failedAt = (job, name) => endedAt(job, name, "failure"); + const stoppedAt = (job) => endedAt(job, MINT_STEP, "success") && endedAt(job, STAGE_STEP, "skipped"); + if (jobs.some((job) => endedAt(job, MINT_STEP, "success") && !stoppedAt(job))) { return null; } const named = (name) => namedJobs(jobs, name); - const endedAt = (job, name, conclusion) => (job?.steps ?? []).some((step) => step?.name === name && step.conclusion === conclusion); - const failedAt = (job, name) => endedAt(job, name, "failure"); const killed = named("resolve-issue").some((job) => endedAt(job, KILL_STEP, "success") && endedAt(job, BROKER_STEP, "skipped")); const checked = named("resolve-issue").find((job) => failedAt(job, BROKER_STEP)); const minting = jobs.find((job) => failedAt(job, MINT_STEP)); + const stopped = jobs.find(stoppedAt); const capped = named("pick-slot").some((job) => job.conclusion === "success") && named("resolve-issue").length > 0 && @@ -1067,11 +1076,20 @@ async function didNoWork(jobsOf, refusalOf, runs) { const blocked = named("admit").some((job) => (job?.steps ?? []).some((step) => step?.name === BLOCKER_STEP && step.conclusion === "success")) && named("pick-slot").every((job) => job.conclusion === "skipped"); - // One log per run: broker-check's when it refused, else the first failed mint's. - const refused = killed ? null : checked ? refusalOf(checked, "check") : minting ? refusalOf(minting, "token") : null; + // One log per run: broker-check's when it refused, else the first failed + // mint's, else the first stopped mint's. + const refused = killed + ? null + : checked + ? refusalOf(checked, "check") + : minting + ? refusalOf(minting, "token") + : stopped + ? refusalOf(stopped, "stop") + : null; if (killed || (await refused) === BREAKER) { atLeast("breaker", run, killed || checked ? "check" : "token"); - } else if (minting || checked) { + } else if (minting || checked || stopped) { atLeast("mint-failed"); } else if (capped) { atLeast("capped"); @@ -1084,9 +1102,10 @@ 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). Null when that line is no such refusal 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 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. function refusalReader(jobLog, repo) { return async (job, mode) => { if (!jobLog || !RUN_ID.test(String(job?.id))) { @@ -1100,6 +1119,9 @@ function refusalReader(jobLog, repo) { return null; } const lines = String(log ?? "").split("\n"); + if (mode === "stop") { + return lines.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 543bfa86b7..7513625c48 100644 --- a/.github/scripts/work-unblock/label-unblocked.test.mjs +++ b/.github/scripts/work-unblock/label-unblocked.test.mjs @@ -2335,6 +2335,83 @@ test("breaker trips at the mint never count toward the failed-mint bound", async assert.deepEqual(writes(calls), refired(30)); }); +// 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. +const stoppedJob = (id, name) => ({ + id, + name, + conclusion: "success", + steps: [ + { name: "Request the lane token", conclusion: "success" }, + { name: "Select the trusted PR text", conclusion: "skipped" }, + { name: "Stage the activity from the base", conclusion: "skipped" }, + { name: "Write the verdict", conclusion: "success" }, + ], +}); +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", + `${stamp}##[notice]${line}`, + "2026-10-30T01:57:52.0000000Z ##[group]Run jq -n \\", + "", + ].join("\n"); +const MINT_STOPPED = [ + ADMITTED, + pickSlot, + BROKER_ALLOWED, + { name: "resolve-issue / run", conclusion: "success", steps: [{ name: "Run the skill activity", conclusion: "success" }] }, + stoppedJob(PATCH_MINT_ID, "apply-issue-patch / run"), + stoppedJob(REPORT_MINT_ID, "report-outcome / run"), +]; +const MINT_STOPPED_LOGS = { [PATCH_MINT_ID]: stoppedLog(), [REPORT_MINT_ID]: stoppedLog() }; + +test("a green run whose mints the kill switch stopped is a breaker trip, read from one log", async () => { + const { github, actions, calls } = stalledRun({ jobs: MINT_STOPPED, logs: MINT_STOPPED_LOGS, conclusion: "success" }); + assert.equal(await sweep({ github, actions }), 0); + tripped(calls); + assert.deepEqual(logReads(calls), [`GET /repos/${REPO}/actions/jobs/${PATCH_MINT_ID}/logs`]); + // Past the failed-mint bound on these workflow files it still goes as a trip. + const pumped = stalledRun({ jobs: MINT_STOPPED, logs: MINT_STOPPED_LOGS, conclusion: "success", ...earlierPumps(3) }); + assert.equal(await sweep({ github: pumped.github, actions: pumped.actions }), 0); + tripped(pumped.calls); + // The model ran, so like a mint refusal it waits limits.reset-clamp-min after the run ended. + const early = stalledRun({ jobs: MINT_STOPPED, logs: MINT_STOPPED_LOGS, conclusion: "success", ended: "2026-10-16T11:30:00Z" }); + assert.equal(await sweep({ github: early.github, actions: early.actions }), 0); + assert.deepEqual(writes(early.calls), []); +}); + +test("a stopped patch mint before a report the switch also stopped, or one alone, is a breaker trip", async () => { + const alone = MINT_STOPPED.filter((job) => job.id !== REPORT_MINT_ID).concat({ name: "report-outcome", conclusion: "skipped", steps: [] }); + const { github, actions, calls } = stalledRun({ jobs: alone, logs: MINT_STOPPED_LOGS, conclusion: "success" }); + assert.equal(await sweep({ github, actions }), 0); + tripped(calls); +}); + +test("a run whose stopped mint is followed by a mint that took its token did work, read with no log", async () => { + const minted = { id: REPORT_MINT_ID, name: "report-outcome / run", conclusion: "failure", steps: [{ name: "Request the lane token", conclusion: "success" }, { name: "Stage the activity from the base", conclusion: "success" }] }; + const jobs = [...MINT_STOPPED.slice(0, -1), minted]; + const { github, actions, calls } = stalledRun({ jobs, logs: MINT_STOPPED_LOGS, ...earlierPumps(1) }); + assert.equal(await sweep({ github, actions }), 0); + assert.deepEqual(writes(calls), []); + assert.deepEqual(logReads(calls), []); +}); + +test("a stopped mint whose log does not carry the kill-switch notice is a failed mint, never a breaker trip", async () => { + for (const [name, log] of [ + ["a lookalike notice", stoppedLog({ line: "lane-token-stopped: the broker refused the token because a lanes kill switch is onward" })], + ["no timestamp", stoppedLog({ stamp: "" })], + ["a warning, not a notice", stoppedLog().replace("##[notice]", "##[warning]")], + ["over the size cap", `${"x".repeat(2 * 1024 * 1024)}\n${stoppedLog()}`], + ["no log", undefined], + ]) { + const { github, actions, calls } = stalledRun({ jobs: MINT_STOPPED, logs: log === undefined ? {} : { [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); @@ -2569,6 +2646,11 @@ test("the job ids and step names the sweep matches exist in the lane workflows", // The write runner mints; the read runner resolve-issue calls never does. assert.ok(Object.values(jobs(workflow("pr-run-activity-write.yml"))).some((block) => steps(block).includes("Request the lane token"))); assert.ok(!Object.values(read).some((block) => steps(block).includes("Request the lane token"))); + // A stopped mint is read from the stage step after it, which runs exactly when the token is set. + const write = jobs(workflow("pr-run-activity-write.yml")).run; + const named = steps(write); + assert.ok(named.indexOf("Stage the activity from the base") > named.indexOf("Request the lane token")); + assert.match(write, /^ {6}- name: Stage the activity from the base\n {8}id: stage\n {8}if: steps\.lane-token\.outputs\.token != ''\n/m); }); // Each re-runner check matches by id. The dispatch lane cannot read its run's diff --git a/.github/workflows/pr-run-activity-write.yml b/.github/workflows/pr-run-activity-write.yml index ba1330630c..a9e1e297fc 100644 --- a/.github/workflows/pr-run-activity-write.yml +++ b/.github/workflows/pr-run-activity-write.yml @@ -1063,20 +1063,20 @@ jobs: # of a workflow file that GitHub's compare of the default branch with # the gated head lists as changed by this PR ends it green with no token # and skip reason workflow-modified, so every later step skips and the - # check is neutral. Every later step keys on the token. A PR run the + # check is neutral. Every later step keys on the token. A run the # broker refuses `kill-switch` (a switch turned on after the kill step - # above read it) ends the same way, with skip reason kill-switch, so the - # check is neutral and the dispatchers re-fire the head once the switch - # lifts (limit-stop.mjs killSwitchStop). An issue run stays red: - # work-unblock re-fires its issue from the failed mint step. So does - # report-escalation: no dispatcher re-fires it, so a skip would drop - # the escalation's comment and label. + # above read it) ends the same way, with skip reason kill-switch: the + # dispatchers re-fire a PR head once the switch lifts (limit-stop.mjs + # killSwitchStop), and work-unblock re-fires an issue from this step's + # notice (label-unblocked.mjs STOPPED). report-escalation stays red: no + # dispatcher re-fires it, so a skip would drop the escalation's comment + # and label. - name: Request the lane token id: lane-token if: steps.head-recheck.outputs.proceed == 'true' uses: ./.base/.github/actions/request-lane-token # zizmor: ignore[self-repository] with: - kill-switch-ok: ${{ inputs.issue-number == '' && inputs.activity != 'report-escalation' }} + kill-switch-ok: ${{ inputs.activity != 'report-escalation' }} pr-workflow-change-ok: ${{ inputs.issue-number == '' && github.event_name == 'pull_request' }} head-sha: ${{ steps.gate.outputs.head-sha }} default-branch: ${{ github.event.repository.default_branch }} From d2504da7362facc3c4728039759811edffaf9704 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Fri, 30 Oct 2026 07:16:39 -0400 Subject: [PATCH 2/2] ci: keep a kill-switch stop from reading as a reported or passed run A green kill-switch stop now reaches the jobs after the stopped mint, so: - report-outcome skips when apply-issue-patch stopped on the kill switch, instead of reporting a run whose patch was dropped with no escalation reason; work-unblock re-fires the issue as a breaker trip. - report-statuses posts a report-outcome the switch stopped as `error` ("did not finish"), never `success`. - work-unblock re-fires a green run whose report mint stopped after a patch mint like a failed run, at once, instead of waiting the stranded hours. Refs: #10753 Co-Authored-By: Claude Opus 5.5 --- .../actions/resolve-config/callers.test.mjs | 29 +++++++++++++++++++ .../scripts/work-unblock/label-unblocked.mjs | 24 +++++++++++++-- .../work-unblock/label-unblocked.test.mjs | 13 +++++++++ .github/workflows/work-resolve-issue.yml | 7 +++-- 4 files changed, 68 insertions(+), 5 deletions(-) diff --git a/.github/actions/resolve-config/callers.test.mjs b/.github/actions/resolve-config/callers.test.mjs index 61b30b8395..6479259105 100644 --- a/.github/actions/resolve-config/callers.test.mjs +++ b/.github/actions/resolve-config/callers.test.mjs @@ -5591,6 +5591,32 @@ test("work-resolve-issue.yml: report-outcome passes apply-issue-patch's escalati assert.equal(evaluate(job.with["escalation-reason"], context("", "skipped")), ""); }); +// A kill switch that stopped apply-issue-patch green dropped this run's patch, +// so report-outcome skips and work-unblock re-fires the issue as a breaker +// trip; and a report-outcome the switch stopped green never posts its status +// as passed. +test("work-resolve-issue.yml: a kill-switch stop skips report-outcome and never reads as a passed report", () => { + const { jobs } = workflow("work-resolve-issue.yml"); + const started = { + "needs.resolve-issue-fallback.result": "skipped", + "needs.resolve-issue-subscription.result": "skipped", + "needs.resolve-issue.outputs.scan-issue-run-started": "true", + }; + for (const id of ["resolve-issue", "resolve-issue-subscription", "resolve-issue-fallback"]) { + started[`needs.${id}.outputs.scan-reuse-branch`] = ""; + started[`needs.${id}.outputs.scan-issue-run-started`] ??= ""; + } + const report = (skip) => evaluate(jobs["report-outcome"].if, { ...started, "needs.apply-issue-patch.outputs.skip-reason": skip }); + assert.equal(report(""), true); + assert.equal(report("rung-up"), true, "another skip still reports"); + assert.equal(report("kill-switch"), false); + const post = jobs["report-statuses"].steps.find((step) => step.env?.REPORT_RESULT !== undefined); + const result = (value, skip) => evaluate(post.env.REPORT_RESULT, { "needs.report-outcome.result": value, "needs.report-outcome.outputs.skip-reason": skip }); + assert.equal(result("success", ""), "success"); + assert.equal(result("failure", ""), "failure"); + assert.equal(result("success", "kill-switch"), "stopped"); +}); + test("pr-run-activity-write.yml: a patch-* escalation reason passes the input check in issue mode only", () => { const step = workflow("pr-run-activity-write.yml").jobs.run.steps.find((s) => s.name?.startsWith("Assert the lanes App sender id")); const status = (reason, issueMode) => @@ -6869,6 +6895,8 @@ test("work-resolve-issue.yml: every job after the calls takes each resolve-issue if (rest === "result") context[ref] = call === "resolve-issue-fallback" ? "skipped" : "success"; else context[ref] = call === "resolve-issue" ? first : ""; } + // Another job's value (apply-issue-patch's skip-reason) is the same in both runs. + for (const [ref] of String(expression).matchAll(/needs\.[\w-]+\.(?:result|outputs\.[\w-]+)/g)) context[ref] ??= ""; return evaluate(expression, context); }; let reads = 0; @@ -6900,6 +6928,7 @@ test("work-resolve-issue.yml: every job after the calls takes each resolve-issue "needs.resolve-issue.outputs.scan-outcome-uploaded": first.uploaded ?? "", "needs.resolve-issue.outputs.scan-reuse-branch": "", "needs.resolve-issue.outputs.scan-patch-state": first.patch ?? "", + "needs.apply-issue-patch.outputs.skip-reason": "", })); const handedOff = { started: "true" }; const ran = { started: "true", uploaded: "true", patch: "written" }; diff --git a/.github/scripts/work-unblock/label-unblocked.mjs b/.github/scripts/work-unblock/label-unblocked.mjs index 162f3a82d4..be284c30e8 100755 --- a/.github/scripts/work-unblock/label-unblocked.mjs +++ b/.github/scripts/work-unblock/label-unblocked.mjs @@ -1058,9 +1058,7 @@ async function didNoWork(jobsOf, refusalOf, runs) { return null; } const jobs = await jobsOf(run); - const endedAt = (job, name, conclusion) => (job?.steps ?? []).some((step) => step?.name === name && step.conclusion === conclusion); const failedAt = (job, name) => endedAt(job, name, "failure"); - const stoppedAt = (job) => endedAt(job, MINT_STEP, "success") && endedAt(job, STAGE_STEP, "skipped"); if (jobs.some((job) => endedAt(job, MINT_STEP, "success") && !stoppedAt(job))) { return null; } @@ -1147,6 +1145,21 @@ function jobLister(actions, repo) { }; } +const endedAt = (job, name, conclusion) => (job?.steps ?? []).some((step) => step?.name === name && step.conclusion === conclusion); +// A mint that ended green with no token: kill-switch-ok's stop. +const stoppedAt = (job) => endedAt(job, MINT_STEP, "success") && endedAt(job, STAGE_STEP, "skipped"); + +// Whether a mint of one of `runs` stopped green: such a run that also minted +// did work and never reported, as a failed run did before kill-switch-ok. +async function stoppedMint(jobsOf, runs) { + for (const run of runs.filter((item) => RUN_ID.test(String(item.id)))) { + if ((await jobsOf(run)).some(stoppedAt)) { + return true; + } + } + return false; +} + const namedJobs = (jobs, name) => jobs.filter((job) => job?.name === name || String(job?.name).startsWith(`${name} /`)); // Whether one of `runs` stopped at the admit job's provenance stop. @@ -1499,7 +1512,12 @@ async function refireRefusal({ github, actions, jobLog, repo, number, issue, las const trip = stalled === "breaker" ? { run: refused.id, ended: Date.parse(refused.updated_at) || ended } : undefined; return { reason: null, resets: due, refire: key, recorded, stall: stalled, trip }; } - if (!runs.some((run) => FAILED.has(run.conclusion)) && !(await cancelledBeforeWork(jobsOf, runs)) && !(await stoppedStale(jobsOf, runs))) { + if ( + !runs.some((run) => FAILED.has(run.conclusion)) && + !(await stoppedMint(jobsOf, runs)) && + !(await cancelledBeforeWork(jobsOf, runs)) && + !(await stoppedStale(jobsOf, runs)) + ) { return stranded(ended, "its lane run did not fail", "no-report"); } const key = await workflowKey(); diff --git a/.github/scripts/work-unblock/label-unblocked.test.mjs b/.github/scripts/work-unblock/label-unblocked.test.mjs index 7513625c48..b94301104f 100644 --- a/.github/scripts/work-unblock/label-unblocked.test.mjs +++ b/.github/scripts/work-unblock/label-unblocked.test.mjs @@ -2398,6 +2398,19 @@ test("a run whose stopped mint is followed by a mint that took its token did wor assert.deepEqual(logReads(calls), []); }); +// The reverse order: apply-issue-patch minted and pushed, then the switch +// stopped report-outcome green. The run did work but never reported, so it +// gets a failed run's once-per-version re-fire at once, not the stranded +// wait for a green run, and it reads no log. +test("a green run whose report mint the kill switch stopped after a patch mint is re-fired like a failed run", async () => { + const minted = { id: PATCH_MINT_ID, name: "apply-issue-patch / run", conclusion: "success", steps: [{ name: "Request the lane token", conclusion: "success" }, { name: "Stage the activity from the base", conclusion: "success" }] }; + const jobs = [...MINT_STOPPED.slice(0, -2), minted, stoppedJob(REPORT_MINT_ID, "report-outcome / run")]; + const { github, actions, calls } = stalledRun({ jobs, logs: MINT_STOPPED_LOGS, conclusion: "success" }); + assert.equal(await sweep({ github, actions }), 0); + assert.deepEqual(writes(calls), refired(30)); + assert.deepEqual(logReads(calls), []); +}); + test("a stopped mint whose log does not carry the kill-switch notice is a failed mint, never a breaker trip", async () => { for (const [name, log] of [ ["a lookalike notice", stoppedLog({ line: "lane-token-stopped: the broker refused the token because a lanes kill switch is onward" })], diff --git a/.github/workflows/work-resolve-issue.yml b/.github/workflows/work-resolve-issue.yml index 24ebbf5e56..5deccba890 100644 --- a/.github/workflows/work-resolve-issue.yml +++ b/.github/workflows/work-resolve-issue.yml @@ -583,7 +583,7 @@ jobs: RESUME_BRANCH: ${{ needs.resolve-issue-fallback.result != 'skipped' && needs.resolve-issue-fallback.outputs.scan-resume-branch || needs.resolve-issue-fallback.result == 'skipped' && needs.resolve-issue-subscription.result != 'skipped' && needs.resolve-issue-subscription.outputs.scan-resume-branch || needs.resolve-issue-fallback.result == 'skipped' && needs.resolve-issue-subscription.result == 'skipped' && needs.resolve-issue.outputs.scan-resume-branch || '' }} RUNNER_CHECKS: ${{ needs.run-checks.result == 'success' && needs.run-checks.outputs.checks || '' }} RESOLVE_RESULT: ${{ needs.resolve-issue-fallback.result != 'skipped' && needs.resolve-issue-fallback.result || needs.resolve-issue-subscription.result != 'skipped' && needs.resolve-issue-subscription.result || needs.resolve-issue.result }} - REPORT_RESULT: ${{ needs.report-outcome.result }} + REPORT_RESULT: ${{ needs.report-outcome.outputs.skip-reason == 'kill-switch' && 'stopped' || needs.report-outcome.result }} run: node .base/.github/scripts/work-resolve-issue/post-statuses.mjs - name: Log the API budget at the end @@ -813,9 +813,12 @@ jobs: # summary pass described the branch, and this opens the PR from it. # Needing resolve-issue waits for every job of that call, the summary job # included; !cancelled() runs this whether that job failed or skipped. + # A kill switch that stopped apply-issue-patch dropped this run's patch, + # so this skips and work-unblock re-fires the issue as a breaker trip. if: >- ${{ !cancelled() && ((needs.resolve-issue-fallback.result != 'skipped' && needs.resolve-issue-fallback.outputs.scan-issue-run-started || needs.resolve-issue-fallback.result == 'skipped' && needs.resolve-issue-subscription.result != 'skipped' && needs.resolve-issue-subscription.outputs.scan-issue-run-started || needs.resolve-issue-fallback.result == 'skipped' && needs.resolve-issue-subscription.result == 'skipped' && needs.resolve-issue.outputs.scan-issue-run-started || '') == 'true' - || (needs.resolve-issue-fallback.result != 'skipped' && needs.resolve-issue-fallback.outputs.scan-reuse-branch || needs.resolve-issue-fallback.result == 'skipped' && needs.resolve-issue-subscription.result != 'skipped' && needs.resolve-issue-subscription.outputs.scan-reuse-branch || needs.resolve-issue-fallback.result == 'skipped' && needs.resolve-issue-subscription.result == 'skipped' && needs.resolve-issue.outputs.scan-reuse-branch || '') != '') }} + || (needs.resolve-issue-fallback.result != 'skipped' && needs.resolve-issue-fallback.outputs.scan-reuse-branch || needs.resolve-issue-fallback.result == 'skipped' && needs.resolve-issue-subscription.result != 'skipped' && needs.resolve-issue-subscription.outputs.scan-reuse-branch || needs.resolve-issue-fallback.result == 'skipped' && needs.resolve-issue-subscription.result == 'skipped' && needs.resolve-issue.outputs.scan-reuse-branch || '') != '') + && needs.apply-issue-patch.outputs.skip-reason != 'kill-switch' }} permissions: contents: read pull-requests: read