From 88eadb172687d0634c6f29b40e7c5c2a9e696ebb Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Thu, 22 Oct 2026 11:16:24 -0400 Subject: [PATCH 1/4] fix(pr-pipeline): treat an incomplete GitHub search as unknown During the 2026-10-22 GitHub incident the search API answered total_count 0 with incomplete_results true for a repository with about 600 open issues. check-wip-cap already turned that answer into a failed read, but a failed queued read leaves admissions open, so work-unblock's admission gate had unlimited room on a false empty. readLaneWip now reports searchIncomplete when any answer says incomplete_results true, and wipStatus closes new admissions on or after pause-from with admission_basis and admission_reason search-incomplete; main logs a named warning. A read that fails without that answer still fails open, and no run pause changes. The deepEqual in the existing incomplete-read test gains the new searchIncomplete field: a shape addition, not a weakened assertion. Co-Authored-By: Claude Opus 5.5 --- .../fix-search-incomplete-results-7c41e0b9.md | 7 ++++ .../actions/check-wip-cap/check-wip-cap.mjs | 24 +++++++++---- .../check-wip-cap/check-wip-cap.test.mjs | 36 ++++++++++++++++++- .../work-unblock/label-unblocked.test.mjs | 13 +++++++ docs/conventions/pr-pipeline/README.md | 9 +++-- 5 files changed, 78 insertions(+), 11 deletions(-) create mode 100644 .changes/pr-pipeline/fix-search-incomplete-results-7c41e0b9.md diff --git a/.changes/pr-pipeline/fix-search-incomplete-results-7c41e0b9.md b/.changes/pr-pipeline/fix-search-incomplete-results-7c41e0b9.md new file mode 100644 index 0000000000..303a9d00b3 --- /dev/null +++ b/.changes/pr-pipeline/fix-search-incomplete-results-7c41e0b9.md @@ -0,0 +1,7 @@ +--- +bump: patch +--- + +### Fixed + +- check-wip-cap treats a GitHub search answer with `incomplete_results: true` as unknown, not as a failed read: on or after `pause-from` new admissions pause with `admission_basis` and `admission_reason` `search-incomplete` and a `check-wip-cap` warning, so work-unblock defers newly unblocked issues instead of admitting without limit on a false empty. A read that fails without that answer still fails open, and no run pause changes. diff --git a/.github/actions/check-wip-cap/check-wip-cap.mjs b/.github/actions/check-wip-cap/check-wip-cap.mjs index cb309d1625..86a4d17fec 100755 --- a/.github/actions/check-wip-cap/check-wip-cap.mjs +++ b/.github/actions/check-wip-cap/check-wip-cap.mjs @@ -77,11 +77,13 @@ const isCount = (value) => Number.isSafeInteger(value) && value >= 0; // The open lane PRs as { createdAt, held, issue }, the merged and opened // counts in the window, and the queued issues' numbers; each null when its -// read fails. Reads run one at a time, and +// read fails. `searchIncomplete` is whether any answer said incomplete_results +// true, which a degraded search answers with a false empty. Reads run one at a time, and // none starts once `remaining()` (the last x-ratelimit-remaining seen) is 0 or less. export async function readLaneWip(github, { repo, bot = DEFAULT_BOT, now, windowHours, remaining = () => null }) { const author = `author:app/${bot.replace(/\[bot\]$/, "")}`; const since = isoSecond(now - windowHours * HOUR_MS); + let searchIncomplete = false; const search = async (q, page, perPage) => { const left = remaining(); if (left !== null && left <= 0) { @@ -89,6 +91,7 @@ export async function readLaneWip(github, { repo, bot = DEFAULT_BOT, now, window } const answer = await github("GET", `/search/issues?q=${encodeURIComponent(q)}&per_page=${perPage}&page=${page}`); if (answer?.incomplete_results !== false) { + searchIncomplete ||= answer?.incomplete_results === true; throw new Error("the search answered incomplete results"); } return answer; @@ -147,14 +150,14 @@ export async function readLaneWip(github, { repo, bot = DEFAULT_BOT, now, window ); } } - return { open, merged, admitted, queued: queued?.items ?? null, queuedUnlisted: queued?.unlisted ?? null }; + return { open, merged, admitted, queued: queued?.items ?? null, queuedUnlisted: queued?.unlisted ?? null, searchIncomplete }; } const round = (value, places) => (value === null ? null : Number(value.toFixed(places))); // The status as one flat object; the field names are a published contract // (README.md, WIP cap). -export function wipStatus({ open, merged, admitted, queued = null, queuedUnlisted = 0 }, { limits, wip, now }) { +export function wipStatus({ open, merged, admitted, queued = null, queuedUnlisted = 0, searchIncomplete = false }, { limits, wip, now }) { const window = wip["merge-rate-window-hours"]; const held = open?.filter((pr) => pr.held) ?? null; const ages = held?.map((pr) => (now - pr.createdAt) / HOUR_MS).filter(Number.isFinite) ?? []; @@ -181,8 +184,10 @@ export function wipStatus({ open, merged, admitted, queued = null, queuedUnliste const queuedIssues = queued === null || open === null ? null : queued.filter((number) => !named.has(number)).length + (queuedUnlisted ?? 0); const queuedTotal = queuedIssues === null ? null : queuedIssues + heldCount; const queuedOver = queuedTotal !== null && (truncated || queuedTotal >= queuedCap - 1e-9); - // Admissions left before the cap; null when nothing gates them. - const room = pausing && queuedTotal !== null ? (truncated ? 0 : Math.max(0, Math.ceil(queuedCap - queuedTotal - 1e-9))) : null; + // Admissions left before the cap; null when nothing gates them. An + // incomplete search answer leaves none: its counts may be false empties. + const room = + !pausing ? null : searchIncomplete ? 0 : queuedTotal === null ? null : truncated ? 0 : Math.max(0, Math.ceil(queuedCap - queuedTotal - 1e-9)); return { countable_lane_prs: countable, held_lane_prs: heldCount, @@ -216,9 +221,9 @@ export function wipStatus({ open, merged, admitted, queued = null, queuedUnliste paused: pausing && reasons.length > 0, pause_reason: pausing && reasons.length > 0 ? reasons.join(",") : null, admission_open: room === null || room > 0, - admission_reason: room === 0 ? "queued" : null, + admission_reason: room === 0 ? (searchIncomplete ? "search-incomplete" : "queued") : null, admission_room: room, - admission_basis: queuedTotal === null ? "fail-open" : "read", + admission_basis: searchIncomplete ? "search-incomplete" : queuedTotal === null ? "fail-open" : "read", }; } @@ -326,6 +331,11 @@ export async function main({ env = process.env, github, now = Date.now(), log = `::warning title=check-wip-cap::${read.countable_lane_prs === null ? "the open lane PRs could not be listed" : "the base config could not be read"}, so ${status.pause_basis === "last-state" ? `the last known state applies (paused ${status.paused})` : "with no last known state the run starts"}`, ); } + if (status.admission_basis === "search-incomplete") { + log( + `::warning title=check-wip-cap::GitHub search answered incomplete results (admission_basis search-incomplete), so its counts are unknown and ${status.admission_open ? "admissions stay open (pause-from not reached)" : "no new admissions are made this run"}`, + ); + } if (status.alert) { log( `::warning title=check-wip-cap::${escape(alertText(status))}; ${status.paused ? "this run starts no work and work-unblock re-fires it once under both caps" : `the lane keeps running (${status.wip_over || status.held_over ? "pause-from not reached" : "only the WIP and held caps pause runs"})`}`, diff --git a/.github/actions/check-wip-cap/check-wip-cap.test.mjs b/.github/actions/check-wip-cap/check-wip-cap.test.mjs index 998921b247..582ac68e79 100644 --- a/.github/actions/check-wip-cap/check-wip-cap.test.mjs +++ b/.github/actions/check-wip-cap/check-wip-cap.test.mjs @@ -112,7 +112,33 @@ test("the open listing pages until a short page", async () => { test("a search that answers incomplete results is an unknown read", async () => { const reads = await read({ open: [pr()], merged: 0, admitted: 3, incomplete: true }); - assert.deepEqual(reads, { open: null, merged: null, admitted: null, queued: null, queuedUnlisted: null }); + assert.deepEqual(reads, { open: null, merged: null, admitted: null, queued: null, queuedUnlisted: null, searchIncomplete: true }); +}); + +// During a GitHub search incident the API answered total_count 0 with +// incomplete_results true for a repository with about 600 open issues +// (2026-10-22). A failed read keeps admissions open; an incomplete one is +// evidence the search itself is degraded, so past pause-from it closes them. +test("an incomplete search answer closes new admissions past pause-from, and only then", async () => { + const reads = await read({ open: [], merged: 0, admitted: 0, queued: [], incomplete: true }); + const closed = status(reads, { "pause-from": "2026-10-01" }); + assert.equal(closed.admission_open, false); + assert.equal(closed.admission_room, 0); + assert.equal(closed.admission_reason, "search-incomplete"); + assert.equal(closed.admission_basis, "search-incomplete"); + assert.equal(closed.paused, false); + const unset = status(reads); + assert.equal(unset.admission_open, true); + assert.equal(unset.admission_room, null); + assert.equal(unset.admission_basis, "search-incomplete"); +}); + +test("a search answer with no incomplete_results field is a failed read, not an incomplete one", async () => { + const reads = await read({ open: [pr()], merged: 48, queued: null }); + assert.equal(reads.searchIncomplete, false); + const result = status(reads, { "pause-from": "2026-10-01" }); + assert.equal(result.admission_open, true); + assert.equal(result.admission_basis, "fail-open"); }); test("no read is made once the rate limit has nothing left; those reads are unknown", async () => { @@ -632,6 +658,14 @@ test("main pauses new admissions over this repository's queued cap past pause-fr assert.ok(!under.logs.some((l) => l.includes("admission paused"))); }); +test("main closes new admissions and warns when the search answers incomplete results", async () => { + const result = await runMain({ open: [], merged: 0, admitted: 0, queued: [], incomplete: true }, {}, pastConfig()); + assert.equal(result.admissionOpen, "false"); + assert.equal(result.wip.admission_basis, "search-incomplete"); + const warning = result.logs.find((line) => line.includes("search-incomplete")); + assert.ok(warning?.startsWith("::warning title=check-wip-cap::"), result.logs.join("\n")); +}); + test("a queued read that fails keeps the run's read basis and leaves admissions open", async () => { const failed = new Error("GET /search/issues failed with status 502"); const result = await runMain({ open: [pr()], merged: 24, queued: failed }, { WIP_STATE: stateFile() }, pastConfig()); diff --git a/.github/scripts/work-unblock/label-unblocked.test.mjs b/.github/scripts/work-unblock/label-unblocked.test.mjs index fe235ec144..df7e2230b8 100644 --- a/.github/scripts/work-unblock/label-unblocked.test.mjs +++ b/.github/scripts/work-unblock/label-unblocked.test.mjs @@ -101,6 +101,7 @@ function world({ pulls = {}, queuedIssues = [], deferred = [], + searchIncomplete = false, failOn, } = {}) { const calls = []; @@ -134,6 +135,9 @@ function world({ return { workflow_runs: listed(inRange) }; } if (method === "GET" && route === "/search/issues") { + if (searchIncomplete) { + return { total_count: 0, incomplete_results: true, items: [] }; + } const q = new URL(path, "https://x").searchParams.get("q"); if (q.startsWith(`repo:${REPO} is:issue is:open label:agent-ready `)) { const items = queuedIssues.map((number) => ({ number })); @@ -1939,6 +1943,15 @@ test("under the queued cap, or before pause-from, a newly unblocked issue is lab } }); +// A degraded search answers a false empty (total_count 0, incomplete_results +// true): past pause-from that admits nothing, where a failed read fails open. +test("past pause-from an incomplete search answer defers a newly unblocked issue instead of labeling it", async (t) => { + t.mock.method(console, "log", () => {}); + const { github, calls } = world({ ...oneDependentWorld(), searchIncomplete: true }); + assert.equal(await main({ env: envFor({}, PAUSING), github }), 0); + assert.deepEqual(writes(calls), [deferredLabel(20)]); +}); + test("with room for one admission the first ready issue is labeled and the next deferred", async (t) => { t.mock.method(console, "log", () => {}); const blocker = [issue(10, { state: "closed" })]; diff --git a/docs/conventions/pr-pipeline/README.md b/docs/conventions/pr-pipeline/README.md index 382eeaffc5..21b46c87f4 100644 --- a/docs/conventions/pr-pipeline/README.md +++ b/docs/conventions/pr-pipeline/README.md @@ -1589,7 +1589,10 @@ the log and step summary carry one line annotation titled `admission paused`). Under it, `admission_room` is how many more issues may be admitted now. The queued cap gates only new `agent-ready` labels, never a run: a queued issue leaves the count only by running, so pausing runs on it would hold the count shut. A queued read -that fails leaves admissions open (`admission_basis: fail-open`) and changes no run pause. The +that fails leaves admissions open (`admission_basis: fail-open`) and changes no run pause. A +search that answers `incomplete_results: true` (a degraded search can answer a false empty) is +unknown, not failed: on or after `pause-from` admissions pause (`admission_basis` and +`admission_reason` `search-incomplete`), with a `check-wip-cap` warning, and no run pause changes. The search lists at most 1,000 issues: past that, the issues it counted but did not list count as queued, and admissions pause (`queued_truncated: true`). @@ -1643,8 +1646,8 @@ and the log line `check-wip-cap: `. `capped` is `true` only while runs pau | `pause_from`, `paused` | The pause date and whether the lane pauses now. | | `pause_reason` | `wip`, `held-cap` or `wip,held-cap` while paused; null otherwise. | | `pause_basis` | `read` from this run's reads; on a failed read `last-state` (the last known pause) or `fail-open` (none saved). | -| `admission_open`, `admission_reason`, `admission_room` | Whether new admissions are open; `queued` while paused, null otherwise; admissions left before the cap, null when nothing gates them. | -| `admission_basis` | `read`, or `fail-open` when the queued count could not be read. | +| `admission_open`, `admission_reason`, `admission_room` | Whether new admissions are open; `queued` or `search-incomplete` while paused, null otherwise; admissions left before the cap, null when nothing gates them. | +| `admission_basis` | `read`; `fail-open` when the queued count could not be read; `search-incomplete` when a search answered `incomplete_results: true`. | ## Adoption From ccb4e19445a3d3aad7032e1bd77af303680019bb Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Thu, 22 Oct 2026 11:18:21 -0400 Subject: [PATCH 2/4] docs(pr-pipeline): wrap the search-incomplete WIP cap line Co-Authored-By: Claude Opus 5.5 --- docs/conventions/pr-pipeline/README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/conventions/pr-pipeline/README.md b/docs/conventions/pr-pipeline/README.md index 21b46c87f4..8745ab3c39 100644 --- a/docs/conventions/pr-pipeline/README.md +++ b/docs/conventions/pr-pipeline/README.md @@ -1592,8 +1592,8 @@ leaves the count only by running, so pausing runs on it would hold the count shu that fails leaves admissions open (`admission_basis: fail-open`) and changes no run pause. A search that answers `incomplete_results: true` (a degraded search can answer a false empty) is unknown, not failed: on or after `pause-from` admissions pause (`admission_basis` and -`admission_reason` `search-incomplete`), with a `check-wip-cap` warning, and no run pause changes. The -search lists at most 1,000 issues: past that, the issues it counted but did not list count as +`admission_reason` `search-incomplete`), with a `check-wip-cap` warning, and no run pause changes. +The search lists at most 1,000 issues: past that, the issues it counted but did not list count as queued, and admissions pause (`queued_truncated: true`). work-unblock is the one lane that adds `agent-ready` to a new issue. Past the room it labels a From fa38ac9a24069e5fd61552f96dde16d784deefdb Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Thu, 22 Oct 2026 11:34:10 -0400 Subject: [PATCH 3/4] fix(pr-pipeline): pause admissions on an incomplete search whatever pause-from says This repository's pr-pipeline.yaml sets pause-from "" (the queued cap alerts only), and wipStatus returned room null whenever pausing was false, so an incomplete search still admitted without limit. A search-incomplete read now sets room 0 regardless of pause-from (owner decision 2026-10-22); the queued cap itself still pauses only past pause-from. check-wip-cap's over-cap warning now names why the lane keeps running (unreadable base config, pause-from unset, or not reached), and work-unblock's deferral log names its reason instead of always citing the queued cap. The earlier pause-from-unset assertion (admissions open) is replaced by the owner's rule; the same fields are asserted for unset, future and past pause-from. Co-Authored-By: Claude Opus 5.5 --- .../fix-search-incomplete-results-7c41e0b9.md | 3 +- .../actions/check-wip-cap/check-wip-cap.mjs | 16 ++++-- .../check-wip-cap/check-wip-cap.test.mjs | 53 ++++++++++++------- .../scripts/work-unblock/label-unblocked.mjs | 6 ++- .../work-unblock/label-unblocked.test.mjs | 21 ++++++-- docs/conventions/pr-pipeline/README.md | 6 ++- 6 files changed, 74 insertions(+), 31 deletions(-) diff --git a/.changes/pr-pipeline/fix-search-incomplete-results-7c41e0b9.md b/.changes/pr-pipeline/fix-search-incomplete-results-7c41e0b9.md index 303a9d00b3..de17fae3b0 100644 --- a/.changes/pr-pipeline/fix-search-incomplete-results-7c41e0b9.md +++ b/.changes/pr-pipeline/fix-search-incomplete-results-7c41e0b9.md @@ -4,4 +4,5 @@ bump: patch ### Fixed -- check-wip-cap treats a GitHub search answer with `incomplete_results: true` as unknown, not as a failed read: on or after `pause-from` new admissions pause with `admission_basis` and `admission_reason` `search-incomplete` and a `check-wip-cap` warning, so work-unblock defers newly unblocked issues instead of admitting without limit on a false empty. A read that fails without that answer still fails open, and no run pause changes. +- check-wip-cap treats a GitHub search answer with `incomplete_results: true` as unknown, not as a failed read: new admissions pause whether or not `pause-from` is set or reached, with `admission_basis` and `admission_reason` `search-incomplete` and a `check-wip-cap` warning, so work-unblock defers newly unblocked issues instead of admitting without limit on a false empty. A read that fails without that answer still fails open, the queued cap still pauses only past `pause-from`, and no run pause changes. +- check-wip-cap's over-cap warning names why the lane keeps running (the base config could not be read, `pause-from` is not set, or `pause-from` not reached), and work-unblock's deferral log names its reason instead of always citing the queued cap. diff --git a/.github/actions/check-wip-cap/check-wip-cap.mjs b/.github/actions/check-wip-cap/check-wip-cap.mjs index 86a4d17fec..caf8c519ea 100755 --- a/.github/actions/check-wip-cap/check-wip-cap.mjs +++ b/.github/actions/check-wip-cap/check-wip-cap.mjs @@ -185,9 +185,15 @@ export function wipStatus({ open, merged, admitted, queued = null, queuedUnliste const queuedTotal = queuedIssues === null ? null : queuedIssues + heldCount; const queuedOver = queuedTotal !== null && (truncated || queuedTotal >= queuedCap - 1e-9); // Admissions left before the cap; null when nothing gates them. An - // incomplete search answer leaves none: its counts may be false empties. - const room = - !pausing ? null : searchIncomplete ? 0 : queuedTotal === null ? null : truncated ? 0 : Math.max(0, Math.ceil(queuedCap - queuedTotal - 1e-9)); + // incomplete search answer leaves none whatever pause-from says: its counts + // may be false empties. + const room = searchIncomplete + ? 0 + : !pausing || queuedTotal === null + ? null + : truncated + ? 0 + : Math.max(0, Math.ceil(queuedCap - queuedTotal - 1e-9)); return { countable_lane_prs: countable, held_lane_prs: heldCount, @@ -333,12 +339,12 @@ export async function main({ env = process.env, github, now = Date.now(), log = } if (status.admission_basis === "search-incomplete") { log( - `::warning title=check-wip-cap::GitHub search answered incomplete results (admission_basis search-incomplete), so its counts are unknown and ${status.admission_open ? "admissions stay open (pause-from not reached)" : "no new admissions are made this run"}`, + `::warning title=check-wip-cap::GitHub search answered incomplete results (admission_basis search-incomplete), so its counts are unknown and no new admissions are made this run`, ); } if (status.alert) { log( - `::warning title=check-wip-cap::${escape(alertText(status))}; ${status.paused ? "this run starts no work and work-unblock re-fires it once under both caps" : `the lane keeps running (${status.wip_over || status.held_over ? "pause-from not reached" : "only the WIP and held caps pause runs"})`}`, + `::warning title=check-wip-cap::${escape(alertText(status))}; ${status.paused ? "this run starts no work and work-unblock re-fires it once under both caps" : `the lane keeps running (${!(status.wip_over || status.held_over) ? "only the WIP and held caps pause runs" : configError ? "the base config could not be read, so the defaults apply" : wip["pause-from"] === "" ? "pause-from is not set" : "pause-from not reached"})`}`, ); } if (status.paused) { diff --git a/.github/actions/check-wip-cap/check-wip-cap.test.mjs b/.github/actions/check-wip-cap/check-wip-cap.test.mjs index 582ac68e79..1f6256c930 100644 --- a/.github/actions/check-wip-cap/check-wip-cap.test.mjs +++ b/.github/actions/check-wip-cap/check-wip-cap.test.mjs @@ -118,19 +118,18 @@ test("a search that answers incomplete results is an unknown read", async () => // During a GitHub search incident the API answered total_count 0 with // incomplete_results true for a repository with about 600 open issues // (2026-10-22). A failed read keeps admissions open; an incomplete one is -// evidence the search itself is degraded, so past pause-from it closes them. -test("an incomplete search answer closes new admissions past pause-from, and only then", async () => { +// evidence the search itself is degraded, so it closes them whatever +// pause-from says (owner decision 2026-10-22); the queued cap stays as configured. +test("an incomplete search answer closes new admissions with pause-from unset, future or past", async () => { const reads = await read({ open: [], merged: 0, admitted: 0, queued: [], incomplete: true }); - const closed = status(reads, { "pause-from": "2026-10-01" }); - assert.equal(closed.admission_open, false); - assert.equal(closed.admission_room, 0); - assert.equal(closed.admission_reason, "search-incomplete"); - assert.equal(closed.admission_basis, "search-incomplete"); - assert.equal(closed.paused, false); - const unset = status(reads); - assert.equal(unset.admission_open, true); - assert.equal(unset.admission_room, null); - assert.equal(unset.admission_basis, "search-incomplete"); + for (const pauseFrom of ["", "2026-10-19", "2026-10-01"]) { + const closed = status(reads, { "pause-from": pauseFrom }); + assert.equal(closed.admission_open, false, pauseFrom); + assert.equal(closed.admission_room, 0, pauseFrom); + assert.equal(closed.admission_reason, "search-incomplete", pauseFrom); + assert.equal(closed.admission_basis, "search-incomplete", pauseFrom); + assert.equal(closed.paused, false, pauseFrom); + } }); test("a search answer with no incomplete_results field is a failed read, not an incomplete one", async () => { @@ -658,12 +657,30 @@ test("main pauses new admissions over this repository's queued cap past pause-fr assert.ok(!under.logs.some((l) => l.includes("admission paused"))); }); -test("main closes new admissions and warns when the search answers incomplete results", async () => { - const result = await runMain({ open: [], merged: 0, admitted: 0, queued: [], incomplete: true }, {}, pastConfig()); - assert.equal(result.admissionOpen, "false"); - assert.equal(result.wip.admission_basis, "search-incomplete"); - const warning = result.logs.find((line) => line.includes("search-incomplete")); - assert.ok(warning?.startsWith("::warning title=check-wip-cap::"), result.logs.join("\n")); +test("main closes new admissions and warns when the search answers incomplete results, under this repository's own pause-from", async () => { + const repoConfig = readFileSync(new URL(`../../../${CONFIG}`, import.meta.url), "utf8"); + for (const config of [repoConfig, pastConfig()]) { + const result = await runMain({ open: [], merged: 0, admitted: 0, queued: [], incomplete: true }, {}, config); + assert.equal(result.admissionOpen, "false"); + assert.equal(result.wip.admission_basis, "search-incomplete"); + const warning = result.logs.find((line) => line.includes("search-incomplete")); + assert.ok(warning?.startsWith("::warning title=check-wip-cap::"), result.logs.join("\n")); + assert.ok(warning.includes("no new admissions are made this run"), warning); + assert.ok(result.logs.includes("::notice title=admission paused::admission paused: reason=search-incomplete queued=null held=null cap=24"), result.logs.join("\n")); + } +}); + +test("over a cap with an unreadable base config the warning names the config, not pause-from", async () => { + const held = await runMain({ open: Array.from({ length: 60 }, () => pr({ labels: ["needs-human"] })), merged: 24 }); + const warning = held.logs.find((line) => line.startsWith("::warning title=check-wip-cap::60 held")); + assert.ok(warning?.includes("the lane keeps running (the base config could not be read, so the defaults apply)"), held.logs.join("\n")); + const unset = await runMain( + { open: Array.from({ length: 60 }, () => pr({ labels: ["needs-human"] })), merged: 24 }, + {}, + readFileSync(new URL(`../../../${CONFIG}`, import.meta.url), "utf8"), + ); + const unsetWarning = unset.logs.find((line) => line.startsWith("::warning title=check-wip-cap::")); + assert.ok(unsetWarning?.includes("the lane keeps running (pause-from is not set)"), unset.logs.join("\n")); }); test("a queued read that fails keeps the run's read basis and leaves admissions open", async () => { diff --git a/.github/scripts/work-unblock/label-unblocked.mjs b/.github/scripts/work-unblock/label-unblocked.mjs index e2fec718d5..9d7f5829a9 100755 --- a/.github/scripts/work-unblock/label-unblocked.mjs +++ b/.github/scripts/work-unblock/label-unblocked.mjs @@ -310,11 +310,15 @@ async function isTrustedClose(github, repo, closedIssue, humans) { // asked and counting each admission against its room. function admissionGate(status) { let room; + let reason; return { + // Why take() last answered false. + why: () => (reason === "search-incomplete" ? "GitHub search answered incomplete results" : "queued work is at the queued cap"), take: async () => { if (room === undefined) { const s = await status(); room = s.admission_room ?? Number.POSITIVE_INFINITY; + reason = s.admission_reason; if (!s.admission_open) { console.log(`::notice title=admission paused::${escapeCommand(admissionLine(s))}`); } @@ -335,7 +339,7 @@ async function admit({ github, repo, number, gate, deferred }) { if (!deferred) { await github("POST", `${base}/labels`, { labels: [DEFERRED_LABEL] }); } - console.log(`label-unblocked: #${number} admission deferred: queued work is at the queued cap`); + console.log(`label-unblocked: #${number} admission deferred: ${gate.why()}`); return; } await github("POST", `${base}/labels`, { labels: [LABEL] }); diff --git a/.github/scripts/work-unblock/label-unblocked.test.mjs b/.github/scripts/work-unblock/label-unblocked.test.mjs index df7e2230b8..ed46518c7b 100644 --- a/.github/scripts/work-unblock/label-unblocked.test.mjs +++ b/.github/scripts/work-unblock/label-unblocked.test.mjs @@ -1945,11 +1945,24 @@ test("under the queued cap, or before pause-from, a newly unblocked issue is lab // A degraded search answers a false empty (total_count 0, incomplete_results // true): past pause-from that admits nothing, where a failed read fails open. -test("past pause-from an incomplete search answer defers a newly unblocked issue instead of labeling it", async (t) => { - t.mock.method(console, "log", () => {}); - const { github, calls } = world({ ...oneDependentWorld(), searchIncomplete: true }); +test("an incomplete search answer defers a newly unblocked issue, with or without pause-from, and names why", async (t) => { + const log = t.mock.method(console, "log", () => {}); + for (const config of [PAUSING, {}]) { + const { github, calls } = world({ ...oneDependentWorld(), searchIncomplete: true }); + assert.equal(await main({ env: envFor({}, config), github }), 0); + assert.deepEqual(writes(calls), [deferredLabel(20)], JSON.stringify(config)); + } + const lines = log.mock.calls.map((call) => String(call.arguments[0])); + assert.ok(lines.includes("label-unblocked: #20 admission deferred: GitHub search answered incomplete results"), lines.join("\n")); + assert.ok(!lines.some((line) => line.includes("at the queued cap")), lines.join("\n")); +}); + +test("at the queued cap the deferral names the queued cap", async (t) => { + const log = t.mock.method(console, "log", () => {}); + const { github } = world({ ...oneDependentWorld(), queuedIssues: queuedN(24) }); assert.equal(await main({ env: envFor({}, PAUSING), github }), 0); - assert.deepEqual(writes(calls), [deferredLabel(20)]); + const lines = log.mock.calls.map((call) => String(call.arguments[0])); + assert.ok(lines.includes("label-unblocked: #20 admission deferred: queued work is at the queued cap"), lines.join("\n")); }); test("with room for one admission the first ready issue is labeled and the next deferred", async (t) => { diff --git a/docs/conventions/pr-pipeline/README.md b/docs/conventions/pr-pipeline/README.md index 8745ab3c39..ba477b1fb2 100644 --- a/docs/conventions/pr-pipeline/README.md +++ b/docs/conventions/pr-pipeline/README.md @@ -1591,8 +1591,10 @@ admitted now. The queued cap gates only new `agent-ready` labels, never a run: a leaves the count only by running, so pausing runs on it would hold the count shut. A queued read that fails leaves admissions open (`admission_basis: fail-open`) and changes no run pause. A search that answers `incomplete_results: true` (a degraded search can answer a false empty) is -unknown, not failed: on or after `pause-from` admissions pause (`admission_basis` and -`admission_reason` `search-incomplete`), with a `check-wip-cap` warning, and no run pause changes. +unknown, not failed: admissions pause whether or not `pause-from` is set or reached +(`admission_basis` and `admission_reason` `search-incomplete`), with a `check-wip-cap` warning, +and work-unblock's deferral log names that reason. The queued cap itself still pauses only on or +after `pause-from`, and no run pause changes. The search lists at most 1,000 issues: past that, the issues it counted but did not list count as queued, and admissions pause (`queued_truncated: true`). From a5cb225e60345d5048f592083612b77409d8c127 Mon Sep 17 00:00:00 2001 From: Kyle Sexton <153232337+kyle-sexton@users.noreply.github.com> Date: Thu, 22 Oct 2026 11:48:05 -0400 Subject: [PATCH 4/4] docs(pr-pipeline): state the incomplete-search admission close in the action description and header comments Co-Authored-By: Claude Sonnet 5.5 --- .github/actions/check-wip-cap/action.yml | 5 +++-- .github/actions/check-wip-cap/check-wip-cap.mjs | 5 +++-- .github/scripts/work-unblock/label-unblocked.mjs | 13 +++++++------ 3 files changed, 13 insertions(+), 10 deletions(-) diff --git a/.github/actions/check-wip-cap/action.yml b/.github/actions/check-wip-cap/action.yml index 1536c44c3c..f68fa8835e 100644 --- a/.github/actions/check-wip-cap/action.yml +++ b/.github/actions/check-wip-cap/action.yml @@ -11,7 +11,8 @@ description: > newest status a `save-state` run saved to the Actions cache) applies; with none the check fails open. Set `admission-open` to `false` only when new admissions pause (queued work at or over the queued cap on or after - `wip.pause-from`). + `wip.pause-from`, or a search answered `incomplete_results: true`, whatever + `wip.pause-from` says). Run it from the default branch checkout, before any slot or lane token. inputs: @@ -36,7 +37,7 @@ outputs: description: "`true` only while the lane pauses (over the WIP cap or the held cap on or after `wip.pause-from`, or on a failed read whose last known state paused); otherwise `false`." value: ${{ steps.check.outputs.capped }} admission-open: - description: "`false` only while new admissions pause (queued work at or over the queued cap on or after `wip.pause-from`); a failed queued read leaves `true`. Runs never pause on it." + description: "`false` only while new admissions pause (queued work at or over the queued cap on or after `wip.pause-from`, or a search answered `incomplete_results: true`, whatever `wip.pause-from` says); a failed queued read without that flag leaves `true`. Runs never pause on it." value: ${{ steps.check.outputs.admission-open }} wip: description: "The status as one flat JSON object: countable_lane_prs, held_lane_prs, held_oldest_age_hours, held_over_alarm_count, wip_cap, wip_cap_source and the rest (pr-pipeline README, WIP cap)." diff --git a/.github/actions/check-wip-cap/check-wip-cap.mjs b/.github/actions/check-wip-cap/check-wip-cap.mjs index caf8c519ea..2445e5da0b 100755 --- a/.github/actions/check-wip-cap/check-wip-cap.mjs +++ b/.github/actions/check-wip-cap/check-wip-cap.mjs @@ -24,8 +24,9 @@ // max(limits.admission-factor x lane merges per hour, limits.admission-min-per-hour) // x wip.merge-rate-window-hours, on or after wip.pause-from, new admissions // (work-unblock adding agent-ready) pause; runs never pause on it, since a -// queued issue leaves the count only by running. A failed queued read leaves -// admissions open. +// queued issue leaves the count only by running. A search that answers +// incomplete_results: true closes admissions whatever wip.pause-from says. A +// failed queued read without that flag leaves admissions open. // // As the action's step it writes `capped` (the run pause), `admission-open`, // `wip` (the status as one flat JSON object) and `state-saved` (`true` when a diff --git a/.github/scripts/work-unblock/label-unblocked.mjs b/.github/scripts/work-unblock/label-unblocked.mjs index 9d7f5829a9..de811eef75 100755 --- a/.github/scripts/work-unblock/label-unblocked.mjs +++ b/.github/scripts/work-unblock/label-unblocked.mjs @@ -7,12 +7,13 @@ // payload. No model. // // Each new agent-ready is paced: at or over the issue lane's queued cap -// (check-wip-cap.mjs, on or after wip.pause-from) a ready issue gets -// `admission-deferred` instead, and with room for k admissions only the first -// k ready issues are labeled. Every sweep revisits the open deferred issues -// oldest first, before the closed ones: one still ready is labeled agent-ready -// within the room and loses admission-deferred, a held one stays deferred, and -// any other loses admission-deferred. Restarts, re-fires and relabels below +// (check-wip-cap.mjs, on or after wip.pause-from, or whenever a search answers +// incomplete_results: true) a ready issue gets `admission-deferred` instead, +// and with room for k admissions only the first k ready issues are labeled. +// Every sweep revisits the open deferred issues oldest first, before the closed +// ones: one still ready is labeled agent-ready within the room and loses +// admission-deferred, a held one stays deferred, and any other loses +// admission-deferred. Restarts, re-fires and relabels below // re-run admitted issues and are never paced. // // On `schedule` it sweeps instead: every issue closed in the last