diff --git a/.changes/pr-pipeline/agent-a910bd2b34d6554d6-39aa50cf.md b/.changes/pr-pipeline/agent-a910bd2b34d6554d6-39aa50cf.md new file mode 100644 index 00000000000..db62c9f5ecc --- /dev/null +++ b/.changes/pr-pipeline/agent-a910bd2b34d6554d6-39aa50cf.md @@ -0,0 +1,7 @@ +--- +bump: patch +--- + +### Fixed + +- An issue-lane run that waited in its slot while a change to `work-resolve-issue.yml` or `pr-run-activity-write.yml` merged no longer fails red as `workflow-stale`. The read runner's `workflow-current` step sets `stale=true` and ends green with a warning, the `Stop on stale workflow files` step runs, and `go` is `false`, so no model turn, earlier-work check, outcome marker or caller job follows, and lane telemetry counts no failure. work-unblock's hourly sweep finds that step in the run's job listing and re-fires the issue on main's files, once per version of the two lane workflow files, as it does a failed run. The sweep reads each run's job listing once, shared by its checks. diff --git a/.github/actions/resolve-config/callers.test.mjs b/.github/actions/resolve-config/callers.test.mjs index 5954189d80a..6eb203683b0 100644 --- a/.github/actions/resolve-config/callers.test.mjs +++ b/.github/actions/resolve-config/callers.test.mjs @@ -717,9 +717,9 @@ test("work-resolve-issue.yml: resolve-issue's timeout is the resolved issue dime // and its apply job mints from them. The broker refuses that mint as // workflow-modified when the caller's or the write runner's blob at the run's // commit differs from main's tip (azure-iac binding.mjs), so the read runner's run job checks the same -// rule before any model turn and fails red; work-unblock's sweep re-fires a -// failed run that left no outcome marker. -test("pr-run-activity-read.yml: an issue run on stale workflow files fails before any model turn", () => { +// rule before any model turn and, on a difference, ends green with stale=true, +// which stops the run; work-unblock's sweep re-fires a run that stopped there. +test("pr-run-activity-read.yml: an issue run on stale workflow files stops green before any model turn", () => { const { jobs } = workflow("pr-run-activity-read.yml"); const steps = jobs.run.steps; const index = (id) => steps.findIndex((step) => step.id === id); @@ -733,6 +733,27 @@ test("pr-run-activity-read.yml: an issue run on stale workflow files fails befor for (const step of steps.slice(index("workflow-current") + 1, index("go") + 1)) { assert.doesNotMatch(String(step.if ?? ""), /always\(\)|failure\(\)|!cancelled\(\)/, step.name); } + // A stale run stops at the stop step: no earlier-work check and no activity. + const stop = steps.find((step) => step.name === "Stop on stale workflow files"); + assert.ok(index("workflow-current") < steps.indexOf(stop) && steps.indexOf(stop) < index("issue-prior")); + const prior = steps[index("issue-prior")]; + const issueSkill = { "inputs.issue-number": "7", "steps.config.outputs.applies": "true", "steps.config.outputs.kind": "skill" }; + for (const [stale, stops] of [["true", true], ["", false]]) { + const context = { ...issueSkill, "steps.workflow-current.outputs.stale": stale }; + assert.equal(evaluate(stop.if, context), stops, `stop step, stale=${stale}`); + assert.equal(evaluate(prior.if, context), !stops, `issue-prior, stale=${stale}`); + } + const go = steps[index("go")]; + assert.equal(go.env.STALE, "${{ steps.workflow-current.outputs.stale }}"); + const decideGo = (env) => { + const out = join(mkdtempSync(join(tmpdir(), "workflow-go-")), "out"); + writeFileSync(out, ""); + spawnSync("bash", ["-c", go.run], { encoding: "utf8", env: { ...process.env, PRIOR: "", STALE: "", ...env, GITHUB_OUTPUT: out } }); + return readFileSync(out, "utf8").trim(); + }; + assert.equal(decideGo({ STALE: "true" }), "run=false"); + assert.equal(decideGo({ PRIOR: "false" }), "run=false"); + assert.equal(decideGo({}), "run=true"); // A skill run in issue mode only. for (const [issue, kind, runs] of [["7", "skill", true], ["7", "script", false], ["", "skill", false]]) { const context = { @@ -764,31 +785,42 @@ test("pr-run-activity-read.yml: an issue run on stale workflow files fails befor spawnSync("git", ["init", "-q", "-b", "main", base]); spawnSync("mkdir", ["-p", join(base, ".github/workflows")]); const run = commit({ [caller]: "caller 1\n", [reusable]: "write 1\n", "README.md": "a\n" }); - const runCheck = (sha = run, ref = `melodic-software/claude-code-plugins/${caller}@refs/heads/main`) => - spawnSync("bash", ["-c", check.run], { + const output = join(dir, "github-output"); + // The check's exit status and what it wrote to GITHUB_OUTPUT. + const runCheck = (sha = run, ref = `melodic-software/claude-code-plugins/${caller}@refs/heads/main`) => { + writeFileSync(output, ""); + const result = spawnSync("bash", ["-c", check.run], { cwd: dir, encoding: "utf8", - env: { ...process.env, WORKFLOW_REF: ref, WORKFLOW_SHA: sha }, + env: { ...process.env, WORKFLOW_REF: ref, WORKFLOW_SHA: sha, GITHUB_OUTPUT: output }, }); + return { ...result, written: readFileSync(output, "utf8") }; + }; + const proceeds = (result, message) => { + assert.equal(result.status, 0, message); + assert.equal(result.written, "", message); + }; - assert.equal(runCheck().status, 0, "the run's own commit is main's tip"); + proceeds(runCheck(), "the run's own commit is main's tip"); commit({ "README.md": "b\n" }); - assert.equal(runCheck().status, 0, "main moved, but neither workflow file changed"); + proceeds(runCheck(), "main moved, but neither workflow file changed"); commit({ [caller]: "caller 2\n" }); const callerStale = runCheck(); - assert.equal(callerStale.status, 1, "the caller changed on main"); - assert.match(callerStale.stdout, /::error::workflow-stale: \.github\/workflows\/work-resolve-issue\.yml /); + assert.equal(callerStale.status, 0, "the caller changed on main: the step ends green"); + assert.equal(callerStale.written, "stale=true\n"); + assert.match(callerStale.stdout, /::warning::workflow-stale: \.github\/workflows\/work-resolve-issue\.yml /); const fresh = git("rev-parse", "HEAD"); - assert.equal(runCheck(fresh).status, 0, "a run on main's current files proceeds"); + proceeds(runCheck(fresh), "a run on main's current files proceeds"); commit({ [reusable]: "write 2\n" }); const reusableStale = runCheck(fresh); - assert.equal(reusableStale.status, 1, "the write runner changed on main"); - assert.match(reusableStale.stdout, /::error::workflow-stale: \.github\/workflows\/pr-run-activity-write\.yml /); + assert.equal(reusableStale.status, 0, "the write runner changed on main: the step ends green"); + assert.equal(reusableStale.written, "stale=true\n"); + assert.match(reusableStale.stdout, /::warning::workflow-stale: \.github\/workflows\/pr-run-activity-write\.yml /); // A commit or caller this checkout cannot read is left to the broker. - assert.equal(runCheck("0".repeat(40)).status, 0); - assert.equal(runCheck("").status, 0); - assert.equal(runCheck(run, "").status, 0); + proceeds(runCheck("0".repeat(40))); + proceeds(runCheck("")); + proceeds(runCheck(run, "")); }); // The issue lane's model job holds no write token. resolve-issue diff --git a/.github/scripts/work-unblock/label-unblocked.mjs b/.github/scripts/work-unblock/label-unblocked.mjs index f326c26088b..5075ded8a5f 100755 --- a/.github/scripts/work-unblock/label-unblocked.mjs +++ b/.github/scripts/work-unblock/label-unblocked.mjs @@ -38,10 +38,12 @@ // // It re-fires the same way an open agent-ready issue whose newest // work-resolve-issue run (the one its newest agent-ready label started, by a -// listed human or the App) failed with no lanes-App outcome marker since: a +// listed human or the App) did no work and left no lanes-App outcome marker since: a // crash before the report, a rerun the token broker refused as -// workflow-modified, or a run cancelled after admit passed and before any -// resolve-issue job started (pick-slot's timeout, a slot queue past GitHub's limit). +// workflow-modified, a run cancelled after admit passed and before any +// resolve-issue job started (pick-slot's timeout, a slot queue past GitHub's limit), +// or a run that ended green at the read runner's `Stop on stale workflow files` +// step (it waited in its slot past a lane workflow change, so no model ran). // A run a newer agent-ready run replaced while it waited is never re-fired: // admit never ran in it. A rerun keeps its first event's workflow files, so only a // fresh label event runs on main's. Each issue is re-fired at most once per @@ -127,6 +129,9 @@ const RUN_ID = /^[1-9][0-9]{0,14}$/; const HOUR_MS = 3_600_000; // The write runner's step that mints the lane token, which every model run follows. const MINT_STEP = "Request the lane token"; +// The read runner's step a run stops at, green, when its lane workflow files +// differ from main's: no model ran and no outcome marker follows. +const STALE_STEP = "Stop on stale workflow files"; const REFIRED = /^/; const handedMarker = (pr) => ``; const DEFAULT_BOT = "melodic-automation-lanes[bot]"; @@ -430,7 +435,7 @@ export const escalationComment = (stops) => // The re-fire's record, posted before the label moves: one per issue per // version of the lane workflow files. export const refireComment = (key) => - `\nThe work-resolve-issue run that \`${LABEL}\` started failed or was cancelled before it reported, or the token broker refused it because its workflow files differ from main's. work-unblock removed and added \`${LABEL}\` back to start a fresh run on main's workflow files. It does this once per version of those files, so if this run fails the same way, a person looks at it.`; + `\nThe 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. work-unblock removed and added \`${LABEL}\` back to start a fresh run on main's workflow files. It does this once per version of those files, so if this run fails the same way, a person looks at it.`; // The relabel's record, posted before the label moves: one per recurrence // comment. @@ -476,21 +481,18 @@ async function laneRuns(actions, repo, number, addedAt) { // wins) or was skipped by the WIP cap (`capped`: pick-slot ran, resolve-issue // did not). A run another gate stopped before its mint (an open closing PR, a // reused branch) is neither, so it is never re-fired here. -async function didNoWork(actions, repo, runs) { +async function didNoWork(jobsOf, runs) { let stalled = null; for (const run of runs) { if (!RUN_ID.test(String(run.id))) { return null; } - const jobs = (await actions("GET", `/repos/${repo}/actions/runs/${run.id}/jobs?filter=latest&per_page=100`))?.jobs; - if (!Array.isArray(jobs)) { - throw new Error(`run ${run.id}'s job listing has an unexpected shape`); - } + 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")) { return null; } - const named = (name) => jobs.filter((job) => job?.name === name || String(job?.name).startsWith(`${name} /`)); + const named = (name) => namedJobs(jobs, name); const capped = named("pick-slot").some((job) => job.conclusion === "success") && named("resolve-issue").length > 0 && @@ -504,6 +506,39 @@ async function didNoWork(actions, repo, runs) { return stalled; } +// Each run's latest-attempt job listing, read once per run id. +function jobLister(actions, repo) { + const listed = new Map(); + return (run) => { + if (!listed.has(run.id)) { + listed.set( + run.id, + (async () => { + const jobs = (await actions("GET", `/repos/${repo}/actions/runs/${run.id}/jobs?filter=latest&per_page=100`))?.jobs; + if (!Array.isArray(jobs)) { + throw new Error(`run ${run.id}'s job listing has an unexpected shape`); + } + return jobs; + })(), + ); + } + return listed.get(run.id); + }; +} + +const namedJobs = (jobs, name) => jobs.filter((job) => job?.name === name || String(job?.name).startsWith(`${name} /`)); + +// Whether one of `runs` ended green at the read runner's stale-workflow stop. +async function stoppedStale(jobsOf, runs) { + for (const run of runs.filter((item) => item.conclusion === "success" && RUN_ID.test(String(item.id)))) { + const resolve = namedJobs(await jobsOf(run), "resolve-issue"); + if (resolve.some((job) => (job?.steps ?? []).some((step) => step?.name === STALE_STEP && step.conclusion === "success"))) { + return true; + } + } + return false; +} + // A job that never ran on a runner: skipped, or no step ran and no runner took // it. A job listing with no steps for a job a runner took is not one. const neverStarted = (job) => @@ -515,13 +550,10 @@ const neverStarted = (job) => // while it waited for its slot (GitHub cancels a slot queue past its limit). // A run a newer agent-ready run replaced in the workflow's concurrency group // was cancelled while it waited, before admit ran, so it never counts. -async function cancelledBeforeWork(actions, repo, runs) { +async function cancelledBeforeWork(jobsOf, runs) { for (const run of runs.filter((item) => item.conclusion === "cancelled" && RUN_ID.test(String(item.id)))) { - const jobs = (await actions("GET", `/repos/${repo}/actions/runs/${run.id}/jobs?filter=latest&per_page=100`))?.jobs; - if (!Array.isArray(jobs)) { - throw new Error(`run ${run.id}'s job listing has an unexpected shape`); - } - const named = (name) => jobs.filter((job) => job?.name === name || String(job?.name).startsWith(`${name} /`)); + const jobs = await jobsOf(run); + const named = (name) => namedJobs(jobs, name); const resolve = named("resolve-issue"); if ( named("admit").some((job) => job.conclusion === "success") && @@ -698,7 +730,8 @@ async function refireRefusal({ github, actions, repo, number, lastAdd, addedAt, if (runs.some((run) => run.status !== "completed")) { return { reason: "its lane run is still going" }; } - const stalled = await didNoWork(actions, repo, runs); + const jobsOf = jobLister(actions, repo); + const stalled = await didNoWork(jobsOf, runs); if (stalled) { const ended = Math.max(...runs.map((run) => Date.parse(run.updated_at)).filter(Number.isFinite), addedAt); const due = ended + limits["reset-clamp-min"] * HOUR_MS; @@ -722,7 +755,7 @@ async function refireRefusal({ github, actions, repo, number, lastAdd, addedAt, } return { reason: null, resets: due, refire: key, recorded }; } - if (!runs.some((run) => FAILED.has(run.conclusion)) && !(await cancelledBeforeWork(actions, repo, runs))) { + if (!runs.some((run) => FAILED.has(run.conclusion)) && !(await cancelledBeforeWork(jobsOf, runs)) && !(await stoppedStale(jobsOf, runs))) { return { reason: "its lane run did not fail" }; } 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 00c400f6b00..4d790fd2522 100644 --- a/.github/scripts/work-unblock/label-unblocked.test.mjs +++ b/.github/scripts/work-unblock/label-unblocked.test.mjs @@ -1555,6 +1555,66 @@ test("a cancelled run admit did not pass, one whose resolve-issue started, or a } }); +// A run that waited in its slot past a lane workflow change ends green +// at the read runner's `Stop on stale workflow files` step, with no model turn +// and no outcome marker, so the sweep re-fires it like a failed run. +const staleRunJob = (stop) => ({ + name: "resolve-issue / run", + conclusion: "success", + steps: [ + { name: "Check the run's workflow files against main", conclusion: "success" }, + { name: "Stop on stale workflow files", conclusion: stop }, + ], +}); +const STOPPED_STALE = [ADMITTED, pickSlot, BROKER_ALLOWED, staleRunJob("success"), { name: "report-outcome", conclusion: "skipped", steps: [] }]; + +test("a run that stopped green on stale workflow files is re-fired, once per version of the lane workflow files", async () => { + const fresh = stalledRun({ jobs: STOPPED_STALE, conclusion: "success" }); + assert.equal(await sweep({ github: fresh.github, actions: fresh.actions }), 0); + assert.deepEqual(writes(fresh.calls), refired(30)); + // didNoWork and stoppedStale share one read of the run's job listing. + assert.equal(fresh.calls.filter((call) => call.startsWith(`GET /repos/${REPO}/actions/runs/5030/jobs`)).length, 1); + // The App's re-add on these workflow files stopped stale again: stop there. + const again = stalledRun({ + jobs: STOPPED_STALE, + conclusion: "success", + comments: [note(), refireNote(KEY, "2026-10-16T09:59:59Z")], + events: [ + added("2026-10-16T09:00:00Z", OWNER), + { event: "unlabeled", label: { name: "agent-ready" }, actor: LANES_BOT, created_at: "2026-10-16T09:59:59Z" }, + added(LABELED_AT), + ], + }); + assert.equal(await sweep({ github: again.github, actions: again.actions }), 0); + assert.deepEqual(writes(again.calls), []); + // A newer version of the lane workflow files re-fires it once more. + const newer = "3".repeat(40); + const changed = stalledRun({ + jobs: STOPPED_STALE, + conclusion: "success", + comments: [note(), refireNote(KEY, "2026-10-16T09:59:59Z")], + events: [ + added("2026-10-16T09:00:00Z", OWNER), + { event: "unlabeled", label: { name: "agent-ready" }, actor: LANES_BOT, created_at: "2026-10-16T09:59:59Z" }, + added(LABELED_AT), + ], + blobs: { ...BLOBS, ".github/workflows/work-resolve-issue.yml": newer }, + }); + assert.equal(await sweep({ github: changed.github, actions: changed.actions }), 0); + assert.deepEqual(writes(changed.calls), refired(30, `${WRITE_SHA}-${newer}`)); +}); + +test("a green run whose stale stop did not run, or a stop step outside resolve-issue, is not re-fired", async () => { + for (const [name, jobs] of [ + ["stop step skipped", [ADMITTED, pickSlot, BROKER_ALLOWED, staleRunJob("skipped")]], + ["stop step in another job", [ADMITTED, pickSlot, { ...staleRunJob("success"), name: "report-outcome / run" }]], + ]) { + const { github, actions, calls } = stalledRun({ jobs, conclusion: "success" }); + assert.equal(await sweep({ github, actions }), 0, name); + assert.deepEqual(writes(calls), [], name); + } +}); + // The job ids and step names the sweep matches in a run's job listing. A job a // reusable workflow runs is listed as ` / ` when // the called job sets no name. @@ -1575,6 +1635,7 @@ test("the job ids and step names the sweep matches exist in the lane workflows", const read = jobs(workflow("pr-run-activity-read.yml")); assert.ok(steps(read["broker-check"]).includes("Ask the broker whether the lane may run")); assert.ok(read.run !== undefined); + assert.ok(steps(read.run).includes("Stop on stale workflow files")); for (const id of ["broker-check", "run"]) { assert.doesNotMatch(read[id], /^ {4}name:/m, `read runner job ${id} keeps its id as its name`); } diff --git a/.github/workflows/pr-run-activity-read.yml b/.github/workflows/pr-run-activity-read.yml index cbe55c12251..dc61b55bc09 100644 --- a/.github/workflows/pr-run-activity-read.yml +++ b/.github/workflows/pr-run-activity-read.yml @@ -773,11 +773,12 @@ jobs: # workflow-modified when the caller's or the write file's blob at the # run's commit differs from main's tip (the files work-unblock's re-fire # sweep compares too), so this checks the same rule against the default branch - # checkout first and fails red before any model turn. The failed run - # leaves no outcome marker, so work-unblock's hourly sweep re-fires the - # issue on main's files. A commit the checkout cannot read is left to - # the broker. This file is called by a local path, so it runs from the - # caller's commit. + # checkout first. On a difference it sets stale=true and ends green: no + # model turn, no outcome marker, and go=false skips every later step and + # caller job. The stop step below marks the run for work-unblock's hourly + # sweep, which re-fires the issue on main's files. A commit the checkout + # cannot read is left to the broker. This file is called by a local path, + # so it runs from the caller's commit. - name: Check the run's workflow files against main id: workflow-current if: >- @@ -796,11 +797,18 @@ jobs: fi for path in "$caller" .github/workflows/pr-run-activity-write.yml; do if [ "$(git -C .base rev-parse -q --verify "$WORKFLOW_SHA:$path")" != "$(git -C .base rev-parse -q --verify "HEAD:$path")" ]; then - echo "::error::workflow-stale: $path differs from main's tip, so the broker would refuse this run's apply job; no model ran, and work-unblock's sweep re-fires the issue on main's files" - exit 1 + echo "::warning::workflow-stale: $path differs from main's tip, so the broker would refuse this run's apply job; no model ran, and work-unblock's sweep re-fires the issue on main's files" + echo "stale=true" >>"$GITHUB_OUTPUT" + exit 0 fi done + # work-unblock's sweep matches this step's name, run, in the job listing: + # a green run that stopped here did no work and is re-fired. + - name: Stop on stale workflow files + if: steps.workflow-current.outputs.stale == 'true' + run: echo "workflow-stale; the run stops before any model turn" + # Issue mode, before any model turn, with the job's read-only token: # skips the model when an earlier run already did the work: a # lanes-App PR from a lane branch is open, or the lanes App or a listed @@ -816,22 +824,24 @@ jobs: if: >- inputs.issue-number != '' && steps.config.outputs.applies == 'true' && steps.config.outputs.kind == 'skill' + && steps.workflow-current.outputs.stale != 'true' env: GH_TOKEN: ${{ github.token }} ISSUE_NUMBER: ${{ inputs.issue-number }} TRUSTED_ACTORS_PATH: .base/.github/standards/trusted-actors/trusted-actors.json run: node .base/.github/scripts/work-resolve-issue/report-outcome.mjs --find-prior - # Whether the activity runs: it applies, and in issue mode no earlier - # run's work stops it. Every step below that touches the activity keys - # on this. + # Whether the activity runs: it applies, and in issue mode neither stale + # workflow files nor an earlier run's work stops it. Every step below + # that touches the activity keys on this. - name: Decide whether the activity runs id: go if: steps.config.outputs.applies == 'true' env: PRIOR: ${{ steps.issue-prior.outputs.proceed }} + STALE: ${{ steps.workflow-current.outputs.stale }} run: | - if [ "$PRIOR" = false ]; then + if [ "$PRIOR" = false ] || [ "$STALE" = true ]; then echo "run=false" >>"$GITHUB_OUTPUT" else echo "run=true" >>"$GITHUB_OUTPUT" diff --git a/.github/workflows/work-unblock.yml b/.github/workflows/work-unblock.yml index 3be9ed53303..8b3f0aef1b0 100644 --- a/.github/workflows/work-unblock.yml +++ b/.github/workflows/work-unblock.yml @@ -26,10 +26,11 @@ name: work-unblock # The hourly sweep also restarts an issue the usage limit stopped: once the # limit has reset, the App removes and re-adds agent-ready (at most five a run); # after three limit stops since a person added agent-ready it adds needs-human. -# It re-fires the same way an issue whose work-resolve-issue run failed with no -# report: a crash, a rerun the token broker refused as workflow-modified -# (a rerun keeps its first event's workflow files), or a run that waited in -# its slot past a lane workflow change and stopped as workflow-stale, or a run +# It re-fires the same way an issue whose work-resolve-issue run did no work and +# left no report: a crash, a rerun the token broker refused as workflow-modified +# (a rerun keeps its first event's workflow files), a run that waited in its +# slot past a lane workflow change and stopped green at `Stop on stale workflow +# files`, or a run # 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. diff --git a/docs/conventions/pr-pipeline/pr-run-activity.md b/docs/conventions/pr-pipeline/pr-run-activity.md index 0848774fbff..eae787bdcfd 100644 --- a/docs/conventions/pr-pipeline/pr-run-activity.md +++ b/docs/conventions/pr-pipeline/pr-run-activity.md @@ -1725,8 +1725,10 @@ in line, holding no runner, instead of spending the lanes App's hourly API quota caller or the write file merges while it waits, the broker would refuse the apply job's mint as `workflow-modified`. Before any model turn, the read file's `workflow-current` step compares the caller's and the write file's blobs at `github.workflow_sha` with the default branch checkout, the broker's -rule, and on a difference fails red `workflow-stale` with no model turn and no outcome marker; -work-unblock's hourly sweep re-fires such a failed run on main's files. An issue that +rule. On a difference it sets `stale=true` and ends green: the `Stop on stale workflow files` +step runs, `go` is `false`, so no model turn, no outcome marker and no caller job follows, and +the run counts as no failure. work-unblock's hourly sweep finds that step in the run's job +listing and re-fires the issue on main's files, once per version of the two files. An issue that `pick-slot`'s resolve skips on its dimensions (`split-required`, `research-first` or `probe-first`) takes no model turn and leaves no outcome, so the caller's `report-skip` job, which needs `admit` and `pick-slot` only, runs `report-outcome` in the write file with