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
7 changes: 7 additions & 0 deletions .changes/pr-pipeline/agent-a910bd2b34d6554d6-39aa50cf.md
Original file line number Diff line number Diff line change
@@ -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.
64 changes: 48 additions & 16 deletions .github/actions/resolve-config/callers.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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 = {
Expand Down Expand Up @@ -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
Expand Down
69 changes: 51 additions & 18 deletions .github/scripts/work-unblock/label-unblocked.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 = /^<!-- work-unblock refired ([0-9a-f]{40}-[0-9a-f]{40}) -->/;
const handedMarker = (pr) => `<!-- work-unblock handed triage #${pr} -->`;
const DEFAULT_BOT = "melodic-automation-lanes[bot]";
Expand Down Expand Up @@ -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) =>
`<!-- work-unblock refired ${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.`;
`<!-- work-unblock refired ${key} -->\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.
Expand Down Expand Up @@ -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 &&
Expand All @@ -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) =>
Expand All @@ -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") &&
Expand Down Expand Up @@ -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;
Expand All @@ -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();
Expand Down
61 changes: 61 additions & 0 deletions .github/scripts/work-unblock/label-unblocked.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<caller job id> / <called job id>` when
// the called job sets no name.
Expand All @@ -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`);
}
Expand Down
Loading
Loading