Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions .changes/pr-pipeline/fix-kill-switch-strand-pr-unblock-5c2e8a17.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
---
bump: minor
---

### Changed

- A new `skip-reason`, `kill-switch`: a lanes kill switch stopped the run. Every lane's gate stop by
its own kill-switch step now posts neutral `kill-switch` instead of `prerequisite-missing`
(`report-check-run`'s `gate-skip-reasons.json`), in every lane check it posts.

### Fixed

- A pr-unblock or pr-refine write job that the token broker refuses with reason `kill-switch` (a
switch turned on after the run's own kill-switch step read it) no longer ends red.
`pr-run-activity-write.yml` now passes `kill-switch-ok` on a pull request run, so the token step
ends green with no token, every later step skips, and the check is neutral `kill-switch`. An
issue run (work-resolve-issue) stays red, since work-unblock re-fires its issue from the failed
mint step, and so does pr-unblock's `report-escalation`, which nothing re-fires. Before, a reply job stopped this way ended `feedback-publish-failed`, routed to a
failing status.

- The pr-unblock dispatchers dispatch a head again once the kill switch lifts. A `kill-switch`
check never handles a head (`limit-stop.mjs` `killSwitchStop`), and on a push, reply or verdict
job's check it releases the head's earlier activity checks as a lane stop does: in
`stopRelease` (resolve-conflicts), the address-feedback reply releases and half-hourly sweep,
the review-dependency verdict release, and fix-ci, where it spends no restart. Such stops count
toward `limits.limit-stops-per-head` (`SWEEP_HEAD_CAP` for address-feedback), so a switch that
keeps flipping mid-run still ends the head. Before, a head whose activity check had run stayed
handled until a new push.
14 changes: 12 additions & 2 deletions .github/actions/report-check-run/decide.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,10 @@ const SKIP_REASONS = [
"superseded-sha",
"disabled-by-config",
"untrusted-trigger",
"kill-switch",
];
const GATE_SKIP_REASONS = {
"kill-switch": "prerequisite-missing",
"kill-switch": "kill-switch",
"list-unreadable": "prerequisite-missing",
fork: "untrusted-trigger",
"no-pr": "untrusted-trigger",
Expand Down Expand Up @@ -257,7 +258,7 @@ test("an empty env head SHA with a reason other than no-pr posts on the fallback

test("each gate reason maps to its contract skip reason", () => {
const expected = [
["kill-switch", "kill-switch", "prerequisite-missing"],
["kill-switch", "kill-switch", "kill-switch"],
["trigger", "list-unreadable", "prerequisite-missing"],
["trigger", "fork", "untrusted-trigger"],
["trigger", "bot-actor", "untrusted-trigger"],
Expand Down Expand Up @@ -540,6 +541,15 @@ test("every key of the shipped gate-skip-reasons.json posts neutral with its map
}
});

// request-lane-token's kill-switch-ok: the broker refused the mint for a
// switch turned on after the kill step proceeded, so no gate stopped the run
// and the token step's skip reason alone names it.
test("a broker kill-switch refusal after the gates proceeded posts neutral kill-switch", () => {
const report = posted(decide(verdict({ "skip-reason": "kill-switch" }), inputs({ actOutcome: "skipped" })));
assert.equal(report.conclusion, "neutral");
assert.equal(report.output.title, "Skipped: kill-switch");
});

test("a skip-reason outside the enum posts failure", () => {
for (const reason of ["looks-fine", ""]) {
const report = posted(decide(verdict({ "skip-reason": reason }), inputs()));
Expand Down
2 changes: 1 addition & 1 deletion .github/actions/report-check-run/gate-skip-reasons.json
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
{
"kill-switch": "prerequisite-missing",
"kill-switch": "kill-switch",
"list-unreadable": "prerequisite-missing",
"fork": "untrusted-trigger",
"no-pr": "untrusted-trigger",
Expand Down
70 changes: 58 additions & 12 deletions .github/actions/resolve-config/callers.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6907,14 +6907,29 @@ test("every model leg is followed by a sealed transcript upload of its own execu
// token, so nothing after it may run on success() alone: every later step is
// gated, a step that reads the token runs only when it is set, and a job that
// needs the stopped job's success and reads none of its outputs also needs its
// skip output empty (an output only a token step sets already stops it).
// skip output empty (an output only a token step sets already stops it). A
// reusable workflow's caller job stands for its job, so the same holds for
// the caller's dependents, through the skip output the workflow exposes.
test("every kill-switch-ok caller runs no token step and no dependent job on a kill-switch stop", () => {
const callers = [];
for (const name of readdirSync(new URL("../../workflows/", import.meta.url)).filter((f) => /\.ya?ml$/.test(f))) {
const { jobs } = workflow(name);
const dependents = (name, jobs, id, skip) => {
for (const [otherId, other] of Object.entries(jobs)) {
const needs = [other.needs ?? []].flat();
const cond = String(other.if ?? "");
if (!needs.includes(id) || !cond.includes(`needs.${id}.result == 'success'`)) continue;
if (cond.replaceAll(`needs.${id}.outputs.${skip} `, "").includes(`needs.${id}.outputs.`)) continue;
assert.ok(skip, `${name}:${id} exposes the token step's skip-reason for ${otherId}`);
assert.ok(cond.includes(`needs.${id}.outputs.${skip} == ''`), `${name}:${otherId} skips on ${id}'s ${skip}`);
}
};
const names = readdirSync(new URL("../../workflows/", import.meta.url)).filter((f) => /\.ya?ml$/.test(f));
for (const name of names) {
const parsed = workflow(name);
const { jobs } = parsed;
for (const [id, job] of Object.entries(jobs ?? {})) {
const steps = job.steps ?? [];
const at = steps.findIndex((step) => step.with?.["kill-switch-ok"] === true);
// A literal true, or an expression that may render it.
const at = steps.findIndex((step) => step.with?.["kill-switch-ok"] === true || String(step.with?.["kill-switch-ok"] ?? "").includes("${{"));
if (at === -1) continue;
callers.push(`${name}:${id}`);
const tokenId = steps[at].id;
Expand All @@ -6928,13 +6943,17 @@ test("every kill-switch-ok caller runs no token step and no dependent job on a k
}
}
const [skip] = Object.entries(job.outputs ?? {}).find(([, value]) => value.includes(`steps.${tokenId}.outputs.skip-reason`)) ?? [];
for (const [otherId, other] of Object.entries(jobs)) {
const needs = [other.needs ?? []].flat();
const cond = String(other.if ?? "");
if (!needs.includes(id) || !cond.includes(`needs.${id}.result == 'success'`)) continue;
if (cond.replaceAll(`needs.${id}.outputs.${skip} `, "").includes(`needs.${id}.outputs.`)) continue;
assert.ok(skip, `${name}:${id} exposes the token step's skip-reason for ${otherId}`);
assert.ok(cond.includes(`needs.${id}.outputs.${skip} == ''`), `${name}:${otherId} skips on ${id}'s ${skip}`);
dependents(name, jobs, id, skip);
const called = parsed.on?.workflow_call;
if (!called) continue;
const [callSkip] = Object.entries(called.outputs ?? {}).find(([, output]) => String(output.value).includes(`jobs.${id}.outputs.${skip}`)) ?? [];
for (const caller of names) {
const callerJobs = workflow(caller).jobs ?? {};
for (const [callerId, callerJob] of Object.entries(callerJobs)) {
if (String(callerJob.uses ?? "").split(" ")[0] !== `./.github/workflows/${name}`) continue;
callers.push(`${caller}:${callerId} -> ${name}`);
dependents(caller, callerJobs, callerId, callSkip);
}
}
}
}
Expand All @@ -6943,5 +6962,32 @@ test("every kill-switch-ok caller runs no token step and no dependent job on a k
"intake-triage.yml:apply-verdict",
"pr-merge.yml:enable-auto-merge",
"pr-merge.yml:merge",
]);
"pr-refine.yml:apply-docs-patch -> pr-run-activity-write.yml",
"pr-run-activity-write.yml:run",
"pr-unblock.yml:apply-conflict-patch -> pr-run-activity-write.yml",
"pr-unblock.yml:apply-dependency-patch -> pr-run-activity-write.yml",
"pr-unblock.yml:apply-dependency-verdict -> pr-run-activity-write.yml",
"pr-unblock.yml:apply-feedback-patch -> pr-run-activity-write.yml",
"pr-unblock.yml:apply-patch -> pr-run-activity-write.yml",
"pr-unblock.yml:apply-patch-rung-up -> pr-run-activity-write.yml",
"pr-unblock.yml:publish-feedback -> pr-run-activity-write.yml",
"pr-unblock.yml:report-escalation -> pr-run-activity-write.yml",
"work-resolve-issue.yml:apply-issue-patch -> pr-run-activity-write.yml",
"work-resolve-issue.yml:report-outcome -> pr-run-activity-write.yml",
"work-resolve-issue.yml:report-skip -> pr-run-activity-write.yml",
].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", () => {
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("", "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");
});
9 changes: 5 additions & 4 deletions .github/scripts/pr-unblock/dependency-dispatch.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,8 +7,8 @@
// apply-dependency-verdict check run skipped no-model-turn: the
// review-dependency check runs that started before it handled nothing, so the
// head is dispatched again. A verdict check that is a lane stop (a reason
// escalation-routes.json routes to a failing status) releases them the same
// way, until `limits.limit-stops-per-head` such stops stand on the head, as
// escalation-routes.json routes to a failing status) or a kill-switch stop
// releases them the same way, until `limits.limit-stops-per-head` such stops stand on the head, as
// in dispatch.mjs. The model job's own selection decides the round
// cap, and every such round posts its round marker. A limit stop holds the
// head only until its limit resets, at most `limits.limit-stops-per-head`
Expand Down Expand Up @@ -133,8 +133,9 @@ export async function dispatchDependencyReviews({
}
existing = await api.listCheckRuns({ sha: node.headRefOid, checkName, appId });
// A round whose verdict job skipped no-model-turn handled nothing, and
// one whose verdict job stopped on a reason routed to a failing status
// is retried on the same head, a fresh model session, until
// one whose verdict job stopped on a reason routed to a failing status,
// or that the kill switch stopped (limit-stop.mjs releasesHead), is
// retried on the same head, a fresh model session, until
// limit-stops-per-head such stops stand on it: either way the
// review-dependency check runs that started before that verdict job
// stop counting, so this run dispatches the head again. A limit stop is
Expand Down
17 changes: 17 additions & 0 deletions .github/scripts/pr-unblock/dependency-dispatch.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -235,6 +235,23 @@ test("a head whose last round had no model turn dispatches again, and is not rec
}
});

// A kill switch turned on after the model call ran stops the verdict job at
// the broker (request-lane-token kill-switch-ok): its check skips
// kill-switch and acts on nothing, so the head is released like a
// no-model-turn round, and so is a kill-switch stop of the review itself.
test("a head the kill switch stopped dispatches again, and is not recorded as handled", async () => {
for (const [label, checks] of [
["the verdict job's mint was refused", [roundRun("review-dependency", "success", "Passed", "2026-10-17T10:00:00Z"), roundRun("apply-dependency-verdict", "neutral", "Skipped: kill-switch", "2026-10-17T10:05:00Z")]],
["the review run's own kill step stopped it", [roundRun("review-dependency", "neutral", "Skipped: kill-switch", "2026-10-17T10:00:00Z")]],
]) {
const node = pr(60);
const record = emptyRecord();
const { dispatched } = await run(fakeApi({ nodes: [node], checks: headOf(node, checks) }), { record });
assert.deepEqual(dispatched, [{ number: 60, workflowRunId: "7060" }], label);
assert.equal(record.handled.get(CHECK), undefined, label);
}
});

// A review-dependency run the usage limit stopped at `started`, its lane record
// naming a reset an hour later.
const limitStop = (started) => {
Expand Down
22 changes: 13 additions & 9 deletions .github/scripts/pr-unblock/feedback-dispatch.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -31,8 +31,9 @@
// - that head has no pr-unblock / address-feedback check run yet (one the
// usage limit stopped stops counting once its limit resets, and one that
// started before the head's newest pr-unblock / publish-feedback check run
// when that run skipped no-model-turn or finding-stands: that round left
// the head for another round), and no
// when that run skipped no-model-turn, finding-stands, lane-stopped or
// kill-switch: that round left the head for another round; a kill-switch
// stop of the address-feedback run itself does not count either), and no
// pr-unblock.yml address-feedback run for the PR is still queued or going;
// - the base config's pr-unblock lane is enabled, holds enabled
// address-feedback and publish-feedback slots, and address-feedback applies
Expand Down Expand Up @@ -62,11 +63,13 @@
// on a PR a person opened, verify-notes.mjs holds a fail with a label it
// removes itself on the next pass, so there is no thread to resolve.
//
// A round whose reply job skipped no-model-turn, or finding-stands (an
// A round whose reply job skipped no-model-turn, finding-stands (an
// adjudicator let a disputed finding stand and the head did not move, so no
// review run follows), releases its head only for the next review or
// pr-verify completion there, which may never come. So a completed
// scheduled pr-unblock-dispatch.yml run, the half-hourly tick, also starts
// review run follows), or kill-switch (the lanes kill switch stopped it, and
// the switch lifting starts no review run), releases its head only for the
// next review or pr-verify completion there, which may never come. So a
// completed scheduled pr-unblock-dispatch.yml run, the half-hourly tick, which
// the kill switch also stops, starts
// the file, and main() sweeps (sweepReleased): each open lanes App PR into
// main whose head's newest reply check took one of those skips, fewer than
// SWEEP_HEAD_CAP times at that head, is decided as above with the
Expand Down Expand Up @@ -117,7 +120,7 @@ import { LOOKBACK_HOURS, LOST } from "../maintenance-reconcile-lost-runs/infra-f
import { fixRequesters, humanGatedLabelFrom, onReviewedHead, reviewedHeads, reviewerIdsFrom, threadAuthors, trustedHumansFrom } from "./address-feedback.mjs";
import { commentItems, gateSource, listLabelEvents } from "./gate-source.mjs";
import { emptyRecord, isHandled, markHandled, readRecord, writeRecord } from "./dispatch-record.mjs";
import { anyHandles, LANE_STOP, laneStop, limitResetsAt, permanentlyHandles } from "./limit-stop.mjs";
import { anyHandles, KILL_SWITCH_STOP, LANE_STOP, laneStop, limitResetsAt, permanentlyHandles } from "./limit-stop.mjs";
import { budgetedFetch } from "./request-budget.mjs";

export const DEFAULTS = Object.freeze({
Expand Down Expand Up @@ -163,8 +166,9 @@ export const SWEEP_HEAD_CAP = 3;
const startOf = (run) => Date.parse(run.started_at ?? "");
// The reply check skips that leave the head for another round: a lane stop
// (a stop escalation-routes.json routes to a failing status) is retried on
// the same head, a fresh model session, under SWEEP_HEAD_CAP with the rest.
const RELEASES = new Set(["Skipped: no-model-turn", "Skipped: finding-stands", LANE_STOP]);
// the same head, a fresh model session, under SWEEP_HEAD_CAP with the rest,
// and so is a kill-switch stop, which posted no reply.
const RELEASES = new Set(["Skipped: no-model-turn", "Skipped: finding-stands", LANE_STOP, KILL_SWITCH_STOP]);
const releases = (run) => run?.status === "completed" && run.conclusion === "neutral" && RELEASES.has(run.output?.title);
// A pass-only round's reply check (publish-feedback.mjs PASS_ONLY_SKIP)
// answers no thread, so it neither releases a head nor ends a release.
Expand Down
38 changes: 38 additions & 0 deletions .github/scripts/pr-unblock/feedback-dispatch.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -1435,6 +1435,44 @@ test("a scheduled tick retries a head whose reply job stopped, until SWEEP_HEAD_
assert.deepEqual(terminal.calls.dispatch, []);
});

// A kill switch turned on after a round's model leg ran stops its reply job
// at the broker (request-lane-token kill-switch-ok): its publish-feedback
// check skips kill-switch and posts no reply, so the head is released like a
// lane stop. The tick, which the switch also stops, re-dispatches it once the
// switch lifts, under SWEEP_HEAD_CAP; a trigger at that head does too, and so
// does one after a kill-switch stop of the address-feedback run itself.
const killSwitched = (name, started) => roundRun(name, "neutral", "Skipped: kill-switch", started);

test("a scheduled tick re-dispatches a head whose reply job the kill switch stopped, until SWEEP_HEAD_CAP stops stand", async (t) => {
const api = sweepApi({ checksAt: { [HEAD]: [answeredRound, killSwitched("publish-feedback", "2026-10-17T10:05:00Z")] } });
await sweepMain(t, api);
assert.deepEqual(api.calls.dispatch.map((call) => call.inputs["pr-number"]), ["42"]);
const capped = sweepApi({
checksAt: {
[HEAD]: [
answeredRound,
killSwitched("publish-feedback", "2026-10-17T10:05:00Z"),
killSwitched("publish-feedback", "2026-10-17T11:05:00Z"),
killSwitched("publish-feedback", "2026-10-17T12:05:00Z"),
],
},
});
assert.equal((await sweepMain(t, capped)).output, "");
assert.deepEqual(capped.calls.dispatch, []);
});

test("a head the kill switch stopped dispatches again, and is not recorded as handled", async () => {
for (const [label, checks] of [
["the reply job's mint was refused", [roundRun("address-feedback", "success", "Passed", "2026-10-17T10:00:00Z"), killSwitched("publish-feedback", "2026-10-17T10:05:00Z")]],
["the address-feedback run's own kill step stopped it", [killSwitched("address-feedback", "2026-10-17T10:00:00Z")]],
]) {
const record = emptyRecord();
const { result } = await decideWith(fakeApi({ checkRuns: checks }), record);
assert.deepEqual(result, { number: 42, workflowRunId: "38779200001" }, label);
assert.equal(record.handled.size, 0, label);
}
});

// The recovery: a pr-feedback-dispatch.yml run an outage lost in the
// lookback window (24 hours before NOW) makes the tick decide every open
// lanes App head through dispatchFeedback. `runs` is the one listing, `jobs`
Expand Down
Loading
Loading