diff --git a/.changes/pr-pipeline/work-resolve-issue-issue-12635-41571640418-1-8cca56d5.md b/.changes/pr-pipeline/work-resolve-issue-issue-12635-41571640418-1-8cca56d5.md new file mode 100644 index 00000000000..b6b02abf039 --- /dev/null +++ b/.changes/pr-pipeline/work-resolve-issue-issue-12635-41571640418-1-8cca56d5.md @@ -0,0 +1,7 @@ +--- +bump: minor +--- + +### Added + +- `limits.stranded-backoff-hours` sets, per cause (`no-run`, `no-report`, `failed-again`, `failed-mint`, `kill-switch`), the base hours of work-unblock's re-fire backoff for a stranded `agent-ready` issue, doubled once per re-fire already made and held at 24 hours. Every cause defaults to 3 hours, the base before this key existed. diff --git a/.github/actions/resolve-config/resolve.mjs b/.github/actions/resolve-config/resolve.mjs index 09b62591daa..614b714c3d0 100644 --- a/.github/actions/resolve-config/resolve.mjs +++ b/.github/actions/resolve-config/resolve.mjs @@ -980,6 +980,14 @@ const limitsOf = (limits = {}) => ({ "fix-docs-per-run": limits["fix-docs-per-run"] ?? 2, "admission-factor": limits["admission-factor"] ?? 0.8, "admission-min-per-hour": limits["admission-min-per-hour"] ?? 1, + "stranded-backoff-hours": { + "no-run": 3, + "no-report": 3, + "failed-again": 3, + "failed-mint": 3, + "kill-switch": 3, + ...limits["stranded-backoff-hours"], + }, // As set: rate-guard.mjs fills it from rate-limits.json beside it. api: limits.api ?? {}, }); diff --git a/.github/actions/resolve-config/resolve.test.mjs b/.github/actions/resolve-config/resolve.test.mjs index 015ffbf0db8..53b899690c1 100644 --- a/.github/actions/resolve-config/resolve.test.mjs +++ b/.github/actions/resolve-config/resolve.test.mjs @@ -347,6 +347,8 @@ const LIMITS = { "admission-factor": 0.8, // The release-point floor the owner chose (2026-10-21 interview). "admission-min-per-hour": 1, + // work-unblock's stranded re-fire backoff starts at 3 hours for every cause. + "stranded-backoff-hours": { "no-run": 3, "no-report": 3, "failed-again": 3, "failed-mint": 3, "kill-switch": 3 }, // Passed through as set; rate-guard.mjs fills it from the schema's defaults. api: {}, }; @@ -376,6 +378,19 @@ test("limits: api passes through as set, and an unknown pool or field, or a shar } }); +test("limits: stranded-backoff-hours overrides a cause's hours and keeps the rest; an unknown cause or hours past 24 is rejected", () => { + const config = edited((doc) => { + doc.limits = { "stranded-backoff-hours": { "no-run": 1 } }; + }); + assert.deepEqual(run(config).limits["stranded-backoff-hours"], { ...LIMITS["stranded-backoff-hours"], "no-run": 1 }); + for (const bad of [{ "no-runs": 1 }, { "no-run": 0 }, { "no-run": 25 }, { "no-run": 1.5 }]) { + const rejected = edited((doc) => { + doc.limits = { "stranded-backoff-hours": bad }; + }); + assertRejected(() => run(rejected), "invalid-config"); + } +}); + test("limits: an unknown key, a value that is not an integer, or one out of its range is rejected", () => { for (const limits of [{ "fallback-resets": 5 }, { "fallback-reset": 0 }, { "restarts-per-run": -1 }, { "reset-clamp-max": 1.5 }, { "limit-stops-per-head": "3" }, { "limit-stops-per-head": 11 }, { "limit-stops-per-issue": 11 }, { "restarts-per-run": 51 }, { "readmit-per-run": 0 }, { "readmit-per-run": 101 }, { "parent-rescan-per-run": 0 }, { "parent-rescan-per-run": 101 },{ "fallback-reset": 721 }, { "reset-clamp-max": 721 }, { "wip-cap": 0 }, { "wip-cap": 501 }, { "turn-cap-restarts": -1 }, { "turn-cap-restarts": 6 }, { "stop-retries": -1 }, { "stop-retries": 6 }, { "fix-ci-per-run": 0 }, { "fix-ci-per-run": 21 }, { "fix-ci-restarts": -1 }, { "fix-ci-restarts": 6 }, { "fix-docs-per-run": 0 }, { "fix-docs-per-run": 21 }, { "admission-window-hours": 24 }, { "admission-factor": 0 }, { "admission-factor": 1.1 }, { "admission-min-per-hour": -1 }, { "admission-min-per-hour": 101 }]) { const config = edited((doc) => { diff --git a/.github/scripts/work-resolve-issue/limits.mjs b/.github/scripts/work-resolve-issue/limits.mjs index 95881965c23..e5d86851d30 100644 --- a/.github/scripts/work-resolve-issue/limits.mjs +++ b/.github/scripts/work-resolve-issue/limits.mjs @@ -40,9 +40,31 @@ export function readLimits(path) { } catch { limits = undefined; } - return { ...resolveLimits(limits), ...resolveAdmission(limits) }; + return { ...resolveLimits(limits), ...resolveAdmission(limits), "stranded-backoff-hours": resolveStrandedBackoff(limits?.["stranded-backoff-hours"]) }; } +// The `limits.stranded-backoff-hours` leaf: per cause work-unblock's sweep +// re-fires a stranded issue for, the base hours of its backoff, doubled once +// per re-fire already counted and held at STRANDED_BACKOFF_MAX. +export const STRANDED_BACKOFF_MAX = 24; +export const STRANDED_BACKOFF_DEFAULTS = Object.freeze({ + "no-run": 3, + "no-report": 3, + "failed-again": 3, + "failed-mint": 3, + "kill-switch": 3, +}); + +// Every cause, each set whole number of hours from 1 to STRANDED_BACKOFF_MAX +// over its default. +export const resolveStrandedBackoff = (hours) => + Object.fromEntries( + Object.entries(STRANDED_BACKOFF_DEFAULTS).map(([key, value]) => { + const set = hours?.[key]; + return [key, Number.isInteger(set) && set >= 1 && set <= STRANDED_BACKOFF_MAX ? set : value]; + }), + ); + const positive = (value) => typeof value === "number" && Number.isFinite(value) && value > 0; // The two `limits` leaves that pace new issue-lane admissions (check-wip-cap's diff --git a/.github/scripts/work-unblock/label-unblocked.mjs b/.github/scripts/work-unblock/label-unblocked.mjs index ec7042f7a5a..01c38d2820b 100755 --- a/.github/scripts/work-unblock/label-unblocked.mjs +++ b/.github/scripts/work-unblock/label-unblocked.mjs @@ -75,10 +75,11 @@ // An agent-ready issue left with nothing running is stranded: no hold, no open // lane pull request or open pull request of a listed human's for it, no // outcome marker since its newest agent-ready, and no work-resolve-issue run -// for that label, or only one that ended with no report, over STRANDED_HOURS -// ago. It is re-fired with the re-fire record below once per version of the -// lane workflow files, and after that on a backoff (STRANDED_HOURS doubling -// with each re-fire, at most BACKOFF_CAP_HOURS), recorded in one comment +// for that label, or only one that ended with no report, over its cause's +// limits.stranded-backoff-hours ago. It is re-fired with the re-fire record +// below once per version of the lane workflow files, and after that on a +// backoff (those hours doubling with each re-fire, at most +// BACKOFF_CAP_HOURS), recorded in one comment // edited in place; it never gets needs-human. All share // limits.restarts-per-run. // @@ -278,7 +279,7 @@ import { createGitHub, isTrustedClose as closedByHumanOrMerge, loadTrustedList, import { writeOutputs } from "../../actions/check-kill-switch/check-kill-switch.mjs"; import { admissionLine, checkWip, searchRateLimit } from "../../actions/check-wip-cap/check-wip-cap.mjs"; import { clampReset, isLaneBranch, LANE_HOLDS, LANE_WORKFLOWS, parseOutcomeMarker, REFIRED, refireComment as refireRecord, refsHoldMarker } from "../work-resolve-issue/report-outcome.mjs"; -import { readLimits, readWip } from "../work-resolve-issue/limits.mjs"; +import { readLimits, readWip, resolveStrandedBackoff, STRANDED_BACKOFF_MAX } from "../work-resolve-issue/limits.mjs"; import { ADMIT_MARKER, admissionFacts, admissionParked, admissionSettled, ATTENDED, authorAdmitted, attendedHold, blockersOf, CONFIRMED, decide, deferralLapsed, loadRules, NEEDS_DECISION, openPullsFor, openSubIssues, READY, refusalText, removalStands, sayWhy, SIZE_SPLIT_PENDING, specOf, stillBlocking, subIssueWait, UNTRUSTED, untrustedHold } from "../intake-triage/admit.mjs"; import { clearedPastReport, retriageNote, doneReason, judgedSince, mergedRefsReader, splitLeftOpen, splitParentClosed } from "../intake-triage/admit.mjs"; import { budgetLatch } from "../../actions/log-api-budget/rate-guard.mjs"; @@ -369,8 +370,6 @@ const IN_FLIGHT = ["queued", "in_progress", "waiting", "pending", "requested"]; export const LOOKBACK_HOURS = 3; // The sweep reads from this long before the last good sweep started. export const SWEEP_MARGIN_MIN = 15; -// How long an agent-ready issue may sit with no run working on it. -export const STRANDED_HOURS = 3; // The marker issue's label: a listed human closing an issue that carries it // runs the sweep at once instead of waiting for the schedule (the hub reopens // and closes it after a rate-limit breaker clears). That sweep re-fires at @@ -385,8 +384,10 @@ export const SWEEP_LABEL = "work-unblock-sweep"; // last edit. const INFRA = /^/; // The longest wait between backoff re-fires. -export const BACKOFF_CAP_HOURS = 24; -const backoffDue = (since, refires) => since + Math.min(STRANDED_HOURS * 2 ** refires, BACKOFF_CAP_HOURS) * HOUR_MS; +export const BACKOFF_CAP_HOURS = STRANDED_BACKOFF_MAX; +// When the next re-fire is due: `hours`, the cause's +// limits.stranded-backoff-hours, doubled per re-fire so far. +const backoffDue = (since, refires, hours) => since + Math.min(hours * 2 ** refires, BACKOFF_CAP_HOURS) * HOUR_MS; const DIMENSIONS = ["size", "complexity", "uncertainty"]; // Whether the account that closed the issue (ACTOR_ID) and the one that @@ -1189,11 +1190,13 @@ export const failedComment = ({ stops, run = "", onResumed = false }) => const FAILED_CAUSE = `The work-resolve-issue run that \`${LABEL}\` started failed or was cancelled before it reported, or it stopped or the token broker refused it because its workflow files differ from main's.`; export const refireComment = (key, cause = FAILED_CAUSE) => refireRecord(key, cause); -// Why a stranded issue has nothing running, as the comments say it. +// Why a stranded issue has nothing running, as the comments say it, after +// `hours` with nothing running. +const hoursText = (hours) => `${hours} hour${hours === 1 ? "" : "s"}`; const strandedCause = { - "no-run": `No work-resolve-issue run started in the ${STRANDED_HOURS} hours since \`${LABEL}\` was last added.`, - "no-report": `The work-resolve-issue run that \`${LABEL}\` started ended over ${STRANDED_HOURS} hours ago with no report and no pull request.`, - "failed-again": `The work-resolve-issue run that \`${LABEL}\` started failed or was cancelled before it reported, over ${STRANDED_HOURS} hours ago.`, + "no-run": (hours) => `No work-resolve-issue run started in the ${hoursText(hours)} since \`${LABEL}\` was last added.`, + "no-report": (hours) => `The work-resolve-issue run that \`${LABEL}\` started ended over ${hoursText(hours)} ago with no report and no pull request.`, + "failed-again": (hours) => `The work-resolve-issue run that \`${LABEL}\` started failed or was cancelled before it reported, over ${hoursText(hours)} ago.`, }; const MINT_CAUSE = `The work-resolve-issue run that \`${LABEL}\` started could not get its lane token, or the token broker's leave to run.`; @@ -1657,10 +1660,11 @@ async function refireRefusal({ github, actions, jobLog, repo, number, issue, las const refiredOn = (marker, key) => marker.outcome === "refired" && !marker.breaker && marker.key === key; const relabeled = (marker) => events.some((event) => isLabel(event, "labeled") && event.actor?.login === bot && Date.parse(event.created_at) >= marker.at); + const hoursFor = resolveStrandedBackoff(limits["stranded-backoff-hours"]); // A re-fire past the first on these workflow files waits longer each time - // (backoffDue from `since`), and its record is the one backoff comment, - // edited in place, never a new comment or a hold. - const backoff = (key, since, cause) => { + // (backoffDue from `since`, starting at `hours`), and its record is the one + // backoff comment, edited in place, never a new comment or a hold. + const backoff = (key, since, cause, hours) => { const first = Math.min(...markers.filter((marker) => refiredOn(marker, key)).map((marker) => marker.at)); // Each breaker record's own re-add, the App's first after it (dated by // when it was posted), is that trip's, not this cause's; the backoff @@ -1668,19 +1672,20 @@ async function refireRefusal({ github, actions, jobLog, repo, number, issue, las const adds = events.filter((event) => isLabel(event, "labeled") && event.actor?.login === bot); const trips = new Set(markers.filter((marker) => marker.breaker).map((marker) => adds.find((event) => Date.parse(event.created_at) >= marker.at))); const refires = adds.filter((event) => Date.parse(event.created_at) >= first && !trips.has(event)).length; - const due = backoffDue(since, refires); + const due = backoffDue(since, refires, hours); if (now < due) { return { reason: `re-fired ${refires} time(s) on workflow files ${key}; it is re-fired again after ${new Date(due).toISOString()}` }; } const own = comments.filter((comment) => comment.user?.login === bot && INFRA.test(String(comment.body ?? ""))).at(-1); return { reason: null, resets: due, refire: key, recorded: true, infra: { id: own?.id, body: infraComment(key, cause, refires + 1) } }; }; - // An issue with nothing running since `since`: once STRANDED_HOURS have + // An issue with nothing running since `since`: once its cause's hours have // passed with no open lane PR and no open pull request of a listed human's // for it, re-fired, once per version of the lane workflow files and then on // a backoff. const stranded = async (since, why, cause) => { - if (now - since < STRANDED_HOURS * HOUR_MS) { + const hours = hoursFor[cause]; + if (now - since < hours * HOUR_MS) { return { reason: why }; } const open = (await lanePulls({ github, repo, number, bot })).find((item) => item.state === "open"); @@ -1694,9 +1699,9 @@ async function refireRefusal({ github, actions, jobLog, repo, number, issue, las const key = await workflowKey(); const fired = markers.filter((marker) => refiredOn(marker, key)); if (fired.some(relabeled)) { - return backoff(key, since, strandedCause[cause]); + return backoff(key, since, strandedCause[cause](hours), hours); } - return { reason: null, resets: since, refire: key, recorded: fired.length > 0, cause: strandedCause[cause] }; + return { reason: null, resets: since, refire: key, recorded: fired.length > 0, cause: strandedCause[cause](hours) }; }; const { mine: runs, untitled } = await laneRuns(actions, repo, number, addedAt); if (runs.length === 0) { @@ -1733,7 +1738,7 @@ async function refireRefusal({ github, actions, jobLog, repo, number, issue, las // kill switch off, so it re-fires now; a mint trip ran the model and // waits like any failed mint. const clamp = stalled === "breaker" && stage === "check" ? 0 : limits["reset-clamp-min"]; - const due = over >= 0 ? backoffDue(ended, over) : ended + clamp * HOUR_MS; + const due = over >= 0 ? backoffDue(ended, over, hoursFor["kill-switch"]) : ended + clamp * HOUR_MS; if (now < due) { const why = stalled === "breaker" ? `the broker refused its lane run for the lanes kill switch (trip ${trips + 1})` : "its lane run did no work"; return { reason: `${why}; it is re-fired after ${new Date(due).toISOString()}` }; @@ -1751,7 +1756,7 @@ async function refireRefusal({ github, actions, jobLog, repo, number, issue, las // attempt, and the cap and restarts-per-run bound it. const fired = markers.filter((marker) => refiredOn(marker, key)).length; if (stalled === "mint-failed" && !recorded && fired >= limits["limit-stops-per-issue"]) { - return backoff(key, ended, MINT_CAUSE); + return backoff(key, ended, MINT_CAUSE, hoursFor["failed-mint"]); } 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 }; diff --git a/.github/scripts/work-unblock/label-unblocked.test.mjs b/.github/scripts/work-unblock/label-unblocked.test.mjs index 44371a7b539..83a5bbecf8e 100644 --- a/.github/scripts/work-unblock/label-unblocked.test.mjs +++ b/.github/scripts/work-unblock/label-unblocked.test.mjs @@ -4318,6 +4318,40 @@ test("the backoff between re-fires stops growing at 24 hours", async () => { assert.deepEqual(writes(calls), [`PATCH /repos/${REPO}/issues/comments/52 ${JSON.stringify({ body: infraComment(KEY, NO_RUN_CAUSE, 7) })}`, ...restarted(30)]); }); +// limits.stranded-backoff-hours sets each cause's wait: a shorter no-run +// re-fires a label with no run after 1 hour, doubling from there, while a run +// that ended with no report keeps the 3-hour default. +test("a cause's stranded-backoff-hours shortens its wait and backoff; other causes keep 3 hours", async () => { + const limits = withLimits({ "stranded-backoff-hours": { "no-run": 1 } }); + const oneHourCause = "No work-resolve-issue run started in the 1 hour since `agent-ready` was last added."; + // NOW is 2026-10-16T12:00:00Z: a label 1 h 0 min 1 s old with no run is re-fired. + const quick = failedRun({ runs: [], events: [added("2026-10-16T10:59:59Z", OWNER)] }); + assert.equal(await sweep({ github: quick.github, actions: quick.actions }, limits), 0); + assert.deepEqual(writes(quick.calls), refiredFor(30, oneHourCause)); + // The same label under the default waits. + const plain = failedRun({ runs: [], events: [added("2026-10-16T10:59:59Z", OWNER)] }); + assert.equal(await sweep({ github: plain.github, actions: plain.actions }), 0); + assert.deepEqual(writes(plain.calls), []); + // A green run with no report that ended 2 hours ago still waits 3 hours. + const report = failedRun({ runs: [laneRun({ at: "2026-10-16T10:00:00Z", conclusion: "success" })], events: [added("2026-10-16T09:59:57Z", OWNER)] }); + assert.equal(await sweep({ github: report.github, actions: report.actions }, limits), 0); + assert.deepEqual(writes(report.calls), []); + // After one re-fire the backoff doubles from 1 hour: 2 hours after the App's re-add. + const events = [ + added("2026-10-16T06:00:00Z", OWNER), + { event: "unlabeled", label: { name: "agent-ready" }, actor: LANES_BOT, created_at: "2026-10-16T08:00:00Z" }, + added("2026-10-16T08:00:01Z"), + ]; + const comments = [note(), refireNote(KEY, "2026-10-16T07:59:59Z")]; + const early = failedRun({ runs: [], comments, events }); + assert.equal(await sweep({ github: early.github, actions: early.actions, now: Date.parse("2026-10-16T10:00:00Z") }, limits), 0); + assert.deepEqual(writes(early.calls), []); + const due = failedRun({ runs: [], comments, events }); + assert.equal(await sweep({ github: due.github, actions: due.actions, now: Date.parse("2026-10-16T10:00:02Z") }, limits), 0); + const body = infraComment(KEY, oneHourCause, 2); + assert.deepEqual(writes(due.calls), [`POST /repos/${REPO}/issues/30/comments ${JSON.stringify({ body })}`, ...restarted(30)]); +}); + // A listed human's open pull request for the issue is work running on it, as // an open lane PR is: the issue is not stranded. test("a stranded issue with a listed human's open closing PR is left alone; an unlisted author's PR is not running work", async () => { diff --git a/.github/workflows/work-unblock.yml b/.github/workflows/work-unblock.yml index 30266e5046a..002568e03b6 100644 --- a/.github/workflows/work-unblock.yml +++ b/.github/workflows/work-unblock.yml @@ -48,7 +48,8 @@ name: work-unblock # cancelled after admit and before resolve-issue started (pick-slot's timeout); a # run a newer agent-ready run replaced is not. It does that once per issue # per version of the lane's two workflow files on main, recorded in a comment, -# and after that on a backoff (three hours, doubling each time, at most 24), +# and after that on a backoff (its cause's limits.stranded-backoff-hours, +# doubling each time, at most 24), # recorded in one comment it edits; it never adds needs-human for it. An open # pull request of a listed human's for the issue counts as work running on it. # When that run opened a lanes-App PR instead, and the PR merged with `Refs:` diff --git a/docs/conventions/pr-pipeline.yaml b/docs/conventions/pr-pipeline.yaml index f91b51c3ab1..d68b3c4af38 100644 --- a/docs/conventions/pr-pipeline.yaml +++ b/docs/conventions/pr-pipeline.yaml @@ -547,9 +547,10 @@ activities: # admission rules, and admits it only on their admit verdict; a refusal # moves it to `status: needs-decision` with a comment. Hourly it also # re-fires an agent-ready issue left stranded (no run, or one that ended - # with no report, for three hours, and no open PR of the lanes App's or a - # listed human's for it) once per version of the lane workflow files, then - # on a backoff (three hours doubling, at most 24) with one comment it + # with no report, for its cause's limits.stranded-backoff-hours, and no + # open PR of the lanes App's or a listed human's for it) once per version + # of the lane workflow files, then on a backoff (those hours doubling, at + # most 24) with one comment it # edits, never needs-human (a run the token broker refused for the lanes # kill switch re-fires on its own count and never spends that once, on the # first sweep after the switch clears when broker-check stopped it before @@ -1050,6 +1051,19 @@ limits: # stranded issues wait for the next one; far above it, queued runs go stale # on a lane workflow change. restarts-per-run: 10 + # Base hours of work-unblock's re-fire backoff for a stranded agent-ready + # issue, per cause, doubled once per re-fire already made and held at 24 + # (the schema's description says where each cause's doubling starts). The + # transient causes, where no model ran and nothing in the issue failed (no + # run started, no lane token, a kill-switch refusal), take 1; a run that + # ended with no report or failed again after a re-fire may fail the same way + # and spend run-hours each time, so those keep 3. + stranded-backoff-hours: + no-run: 1 + no-report: 3 + failed-again: 3 + failed-mint: 1 + kill-switch: 1 # upstream-apply-changelog runs that reach the model in any 24 hours (counted from # its gate's start records, as red-main.daily-cap counts reverts). Each run # takes one release, so 3 is the most anthropics/claude-code releases diff --git a/docs/conventions/pr-pipeline/README.md b/docs/conventions/pr-pipeline/README.md index af439174499..8254cd9126c 100644 --- a/docs/conventions/pr-pipeline/README.md +++ b/docs/conventions/pr-pipeline/README.md @@ -3382,6 +3382,7 @@ with its default. | `wip-cap` | 60 | The issue lane's WIP cap when the lane merge rate cannot be read ([WIP cap](#wip-cap)); at most 500. | | `turn-cap-restarts` | 0 | Restarts after a run reaches its turn cap, since a listed human last added `agent-ready`, before report-outcome stops the issue on the `turn-cap-spent` route; at most 5, and 0 never restarts. | | `stop-retries` | 2 | Restarts after a stop the issue lane's `stop-routes.json` routes to `retry` (or fewer, a row's own `retries`), and, counted apart, restarts after a `wait`, each since a listed human last added `agent-ready`, before report-outcome stops the issue on the `retry-spent` route; at most 5, and 0 never retries. | +| `stranded-backoff-hours` | 3 each | Per cause, the base hours of work-unblock's re-fire backoff for a stranded `agent-ready` issue: the wait is these hours doubled once per re-fire already made on the same lane workflow files (per trip past `limit-stops-per-issue` for `kill-switch`), up to 24. Causes: `no-run` (no work-resolve-issue run started) and `no-report` (a run ended green with no report and no pull request), where it is also the wait before the first re-fire; `failed-again` (a re-fired run failed or was cancelled again), at least twice these hours; `failed-mint` (no lane token), backed off only past `limit-stops-per-issue` re-fires, so at least these hours doubled that many times; and `kill-switch` (a kill-switch refusal, past `limit-stops-per-issue` trips). Each 1 to 24. | | `fix-ci-per-run` | 2 | fix-ci dispatches one pr-unblock-dispatch run sends; at most 20. | | `fix-ci-restarts` | 1 | Further fix-ci dispatches on one PR head after its first fix-ci run failed; 0 to 5. | | `fix-docs-per-run` | 2 | fix-docs dispatches one pr-unblock-dispatch run sends; at most 20. | diff --git a/docs/conventions/pr-pipeline/pr-pipeline.schema.json b/docs/conventions/pr-pipeline/pr-pipeline.schema.json index 614d8f2593d..e449feb45cd 100644 --- a/docs/conventions/pr-pipeline/pr-pipeline.schema.json +++ b/docs/conventions/pr-pipeline/pr-pipeline.schema.json @@ -143,6 +143,18 @@ "default": 2, "description": "Times work-unblock restarts an issue whose stop routes to retry in the issue lane's stop-routes.json, since a listed human last added agent-ready; past it the stop takes the retry-spent route. 0 never retries." }, + "stranded-backoff-hours": { + "type": "object", + "additionalProperties": false, + "description": "Per cause work-unblock's sweep re-fires a stranded agent-ready issue for, the base hours of its re-fire backoff: the wait after the run ended (or the label was added) is these hours doubled once per re-fire already made on the same lane workflow files, or per trip past limit-stops-per-issue for kill-switch, up to 24 hours. no-run: no work-resolve-issue run started, and these hours are also the wait before the first re-fire; no-report: a run ended green with no report and no pull request, the same; failed-again: a re-fired run failed or was cancelled again, so the wait is at least twice these hours; failed-mint: a run got no lane token, backed off only past limit-stops-per-issue re-fires, so the wait is at least these hours doubled that many times; kill-switch: a run the lanes kill switch refused, backed off past limit-stops-per-issue trips.", + "properties": { + "no-run": { "type": "integer", "minimum": 1, "maximum": 24, "default": 3 }, + "no-report": { "type": "integer", "minimum": 1, "maximum": 24, "default": 3 }, + "failed-again": { "type": "integer", "minimum": 1, "maximum": 24, "default": 3 }, + "failed-mint": { "type": "integer", "minimum": 1, "maximum": 24, "default": 3 }, + "kill-switch": { "type": "integer", "minimum": 1, "maximum": 24, "default": 3 } + } + }, "fix-ci-per-run": { "type": "integer", "minimum": 1,