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
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
---
bump: patch
---

### Fixed

- `work-resolve-issue` no longer resumes an earlier run's lane branch that is behind the default branch. Such a branch carried its stale base into the runner's whole-branch checks, which failed and handed the issue to a person with no pull request. The run now starts fresh from the default branch under a new lane branch and leaves the old one in place. `issue-prior` reads `behind_by` from the compare call it already makes, and fails the step when that value is not a whole number. The outcome comments say a restart continues from the branch only while it is not behind.
34 changes: 23 additions & 11 deletions .github/scripts/work-resolve-issue/report-outcome.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -336,7 +336,7 @@ function handBack({ outcome, issueNumber, branch, humanLabel, runUrl, runId, onR
"",
pushback
? `Partial work is on the branch \`${branch}\`.`
: `Partial work is on the branch \`${branch}\`. A retry continues from it; delete the branch first to start again from the default branch.`,
: `Partial work is on the branch \`${branch}\`. A retry continues from it while it is not behind the default branch, and otherwise starts fresh from the default branch; delete the branch first to always start fresh.`,
);
}
if (runUrl) {
Expand Down Expand Up @@ -427,7 +427,7 @@ function fundingPlan({ issueNumber, branch, regain, runId, runUrl, onResumed })
: "The work-resolve-issue lane stopped on its funding (`funding`: no credit or a disabled key), not on this issue. It keeps `agent-ready`; once the lane is funded again, a person removes and adds `agent-ready` to restart it.",
];
if (branch) {
lines.push("", `Partial work is on the branch \`${branch}\`; the restart continues from it.`);
lines.push("", `Partial work is on the branch \`${branch}\`; the restart continues from it while it is not behind the default branch, and otherwise starts fresh.`);
}
if (runUrl) {
lines.push("", `Run: ${runUrl}`);
Expand All @@ -443,20 +443,20 @@ function turnCapPlan({ issueNumber, branch, cap, restart, humanLabel, runId, run
const lines = [
turnCapMarker({ cap, runId, onResumed }),
restart
? `The work-resolve-issue lane stopped on this issue: ${reached}. It keeps \`agent-ready\`, and work-unblock starts it again from the branch.`
? `The work-resolve-issue lane stopped on this issue: ${reached}. It keeps \`agent-ready\`, and work-unblock starts it again from the branch, or fresh from the default branch once the branch is behind it.`
: `The work-resolve-issue lane stopped on this issue: ${reached}. It has used its restarts, so it needs a person.`,
];
lines.push(
"",
branch
? `Partial work is on the branch \`${branch}\`; a restart or retry continues from it. Delete the branch first to start again from the default branch.`
? `Partial work is on the branch \`${branch}\`; a restart or retry continues from it while it is not behind the default branch, and otherwise starts fresh. Delete the branch first to always start fresh.`
: "The run committed nothing.",
);
if (runUrl) {
lines.push("", `Run: ${runUrl}`);
}
if (!restart) {
lines.push("", `To retry from the branch, remove \`${humanLabel}\` and add \`agent-ready\` again; a smaller issue, or a larger Size or Complexity field, may fit the cap.`);
lines.push("", `To retry (from the branch while it is not behind the default branch, else fresh), remove \`${humanLabel}\` and add \`agent-ready\` again; a smaller issue, or a larger Size or Complexity field, may fit the cap.`);
}
return {
action: restart ? "turn-cap" : "hand-back",
Expand Down Expand Up @@ -902,11 +902,15 @@ export function readBranch(dir, issueNumber, key = "branch") {
// runs that kept no branch, is turn-cap, stopped, limited or funding
// and names a run whose newest lane branch passes the same
// tip, commit and PR checks, with every commit ahead of the default
// branch verified, by the lanes App, committed by web-flow: the model runs on a new lane branch
// made at that branch's tip (`tip`, the sha read), so it continues
// the work instead of starting from the default branch;
// branch verified, by the lanes App, committed by web-flow, and no
// commit behind the default branch: the model runs on a new lane
// branch made at that branch's tip (`tip`, the sha read), so it
// continues the work instead of starting from the default branch.
// A branch behind the default branch would carry its stale base into
// the runner's whole-branch checks, so it starts fresh instead
// (`stale` names it) and is left as it is;
// run otherwise.
// `branches` are {name, sha, aheadBy, tip, tipAt, pulls, commits} (commits
// `branches` are {name, sha, aheadBy, behindBy, tip, tipAt, pulls, commits} (commits
// from GET /compare, null when the listing is cut short; tip from GET
// /commits/{sha}, tipAt epoch ms, pulls from
// GET /pulls?state=all&head=); `closing` the issue's open closing PRs as
Expand Down Expand Up @@ -961,6 +965,9 @@ export function findPrior({ issueNumber, repo, bot, humans, branches, closing, m
RESUMABLE.has(decisive?.outcome) && decisive.run !== ""
? lane.find((branch) => branch.match[1] === decisive.run && usable(branch) && allLane(branch))
: null;
if (resume && resume.behindBy !== 0) {
return { action: "run", stale: { branch: resume.name, behindBy: resume.behindBy } };
}
return resume ? { action: "resume", branch: resume.name, tip: resume.sha, cause: decisive.outcome } : { action: "run" };
}

Expand Down Expand Up @@ -1161,6 +1168,9 @@ export async function findPriorMain({ env = process.env, fetch = globalThis.fetc
if (!Number.isSafeInteger(comparison?.ahead_by) || comparison.ahead_by < 0) {
throw new Error(`the commits ${ref.name} holds ahead of ${base} could not be read`);
}
if (!Number.isSafeInteger(comparison.behind_by) || comparison.behind_by < 0) {
throw new Error(`the commits ${ref.name} lacks from ${base} could not be read`);
}
const pulls = await paginate(github, `${repo}/pulls?state=all&head=${encodeURIComponent(`${owner}:${ref.name}`)}`);
if (!/^[0-9a-f]{40}$/.test(ref.sha ?? "")) {
throw new Error(`the tip of ${ref.name} could not be read`);
Expand All @@ -1175,7 +1185,7 @@ export async function findPriorMain({ env = process.env, fetch = globalThis.fetc
// carries no commit list, so it is never resumed.
const commits =
Array.isArray(comparison.commits) && comparison.commits.length === comparison.total_commits ? comparison.commits : null;
branches.push({ name: ref.name, sha: ref.sha, aheadBy: comparison.ahead_by, tip, tipAt, pulls, commits });
branches.push({ name: ref.name, sha: ref.sha, aheadBy: comparison.ahead_by, behindBy: comparison.behind_by, tip, tipAt, pulls, commits });
}
const prior = findPrior({ issueNumber, repo: env.GITHUB_REPOSITORY, bot, humans, branches, closing, markers });
const runs = prior.action === "run" || prior.action === "resume";
Expand All @@ -1191,7 +1201,9 @@ export async function findPriorMain({ env = process.env, fetch = globalThis.fetc
: { proceed: "false", "skip-reason": "not-applicable", "reuse-branch": prior.action === "reuse" ? prior.branch : "" };
appendFileSync(env.GITHUB_OUTPUT, Object.entries(outputs).map(([key, value]) => `${key}=${value}\n`).join(""));
console.log(
prior.action === "run"
prior.stale
? `report-outcome: ${prior.stale.branch} is ${prior.stale.behindBy} commits behind the default branch, so the model starts issue #${issueNumber} fresh from the default branch instead of resuming it; the branch is left as it is`
: prior.action === "run"
? `report-outcome: no earlier run of issue #${issueNumber} left work to report; the model runs`
: prior.action === "resume"
? `report-outcome: the model resumes issue #${issueNumber} from ${prior.branch} at ${prior.tip} (${prior.cause})`
Expand Down
52 changes: 48 additions & 4 deletions .github/scripts/work-resolve-issue/report-outcome.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -624,7 +624,7 @@ test("stopped removes agent-ready and names the branch that holds partial work",
assert.deepEqual(plan.addLabels, ["needs-human"]);
assert.deepEqual(plan.removeLabels, ["agent-ready"]);
assert.ok(plan.comment.includes(BRANCH));
assert.match(plan.comment, /A retry continues from it; delete the branch first to start again from the default branch\./);
assert.match(plan.comment, /A retry continues from it while it is not behind the default branch, and otherwise starts fresh from the default branch; delete the branch first to always start fresh\./);
assert.ok(plan.comment.includes("remove `needs-human` and add `agent-ready` again"), plan.comment);
});

Expand Down Expand Up @@ -1482,11 +1482,12 @@ const LANE_TIP = {
committer: { login: "web-flow" },
commit: { committer: { date: "2026-10-16T10:00:00Z" }, verification: { verified: true, reason: "valid" } },
};
const branchFact = (name, { aheadBy = 2, tip = LANE_TIP, tipAt = Date.parse("2026-10-16T10:00:00Z"), pulls = [], sha = "a".repeat(40), commits = [LANE_TIP, LANE_TIP] } = {}) => ({
const branchFact = (name, { aheadBy = 2, behindBy = 0, tip = LANE_TIP, tipAt = Date.parse("2026-10-16T10:00:00Z"), pulls = [], sha = "a".repeat(40), commits = [LANE_TIP, LANE_TIP] } = {}) => ({
sha,
commits,
name,
aheadBy,
behindBy,
tip,
tipAt,
pulls,
Expand Down Expand Up @@ -1697,6 +1698,35 @@ test("a marked run's branch with any commit not the lanes App's, or a cut-short
}
});

// A resumed branch carries its base into the runner's whole-branch checks, so
// one behind main starts fresh from main under a new branch, and the old
// branch is left for a person to delete.
test("a marked run's branch behind main starts fresh instead of resuming", () => {
for (const behindBy of [1, 865]) {
const branches = [branchFact(lane(400), { sha: SHA_400, behindBy })];
assert.deepEqual(
prior({ branches, markers: [marked("stopped", "400")] }),
{ action: "run", stale: { branch: lane(400), behindBy } },
String(behindBy),
);
}
});

test("a lanes-App PR open from a lane branch behind main still stops the run", () => {
const branches = [branchFact(lane(400), { sha: SHA_400, behindBy: 865, pulls: [pullBy(BOT)] })];
assert.equal(prior({ branches, markers: [marked("stopped", "400")] }).action, "stop");
});

// The fresh run that replaced a stale resume reports under its own run, so
// the next admission never falls back to the stale branch.
test("after a fresh run replaced a stale resume, the stale branch is not resumed again", () => {
const branches = [branchFact(lane(400), { sha: SHA_400, behindBy: 865 })];
for (const outcome of ["stopped", "turn-cap", "pushback"]) {
const markers = [marked("stopped", "400"), marked(outcome, "401", "2026-10-23T16:00:00Z")];
assert.deepEqual(prior({ branches, markers }), { action: "run" }, outcome);
}
});

test("of a marked run's attempts, the newest is resumed", () => {
const branches = [branchFact(lane(400, 1), { sha: "1".repeat(40) }), branchFact(lane(400, 2), { sha: "2".repeat(40) })];
assert.equal(prior({ branches, markers: [marked("turn-cap", "400")] }).tip, "2".repeat(40));
Expand All @@ -1709,12 +1739,20 @@ test("an unreported branch is still reused before a marked one is resumed", () =

test("--find-prior names the resumed branch, its tip and the cause, and still runs the model", async () => {
const comments = [{ user: { login: BOT }, body: "<!-- work-resolve-issue outcome=turn-cap cap=75 run=400 -->", created_at: "2026-10-16T11:00:00Z" }];
const comparison = { ahead_by: 1, total_commits: 1, commits: [LANE_TIP] };
const comparison = { ahead_by: 1, behind_by: 0, total_commits: 1, commits: [LANE_TIP] };
const { code, output } = await runPrior({ comments, refs: [refOf(lane(400))], comparison });
assert.equal(code, 0);
assert.equal(output, `proceed=true\nskip-reason=\nreuse-branch=\nresume-branch=${lane(400)}\nresume-tip=${"a".repeat(40)}\nresume-cause=turn-cap\n`);
});

test("--find-prior runs the model fresh, with no resume outputs, when the marked run's branch is behind main", async () => {
const comments = [{ user: { login: BOT }, body: "<!-- work-resolve-issue outcome=stopped run=400 -->", created_at: "2026-10-16T11:00:00Z" }];
const comparison = { ahead_by: 1, behind_by: 865, total_commits: 1, commits: [LANE_TIP] };
const { code, output } = await runPrior({ comments, refs: [refOf(lane(400))], comparison });
assert.equal(code, 0);
assert.equal(output, "proceed=true\nskip-reason=\nreuse-branch=\n");
});

test("another issue's lane branches are never this issue's", () => {
const branches = [branchFact("work-resolve-issue/issue-7-400-1"), branchFact("work-resolve-issue/issue-770-400-1", { pulls: [pullBy(BOT)] })];
assert.deepEqual(prior({ branches }), { action: "run" });
Expand Down Expand Up @@ -1758,7 +1796,7 @@ async function runPrior({
closingAnswer = graphql(nodes),
refs = [],
pulls = [],
comparison = { ahead_by: 2 },
comparison = { ahead_by: 2, behind_by: 0 },
repoAnswer = { default_branch: "main" },
commitAnswer = LANE_TIP,
graphqlPages,
Expand Down Expand Up @@ -1926,6 +1964,12 @@ test("--find-prior fails on a tip commit with no readable time when a marker nam
}
});

test("--find-prior fails on a comparison with no whole-number behind_by", async () => {
for (const behind of [undefined, "3", null, 1.5, -1]) {
await failsClosed(String(behind), { refs: [refOf(lane(400))], comparison: { ahead_by: 1, behind_by: behind } });
}
});

test("--find-prior fails on a comparison with no whole-number ahead_by", async () => {
for (const comparison of [{}, { ahead_by: "3" }, { ahead_by: null }, { ahead_by: 1.5 }, { ahead_by: -1 }]) {
const { code, output } = await runPrior({ refs: [refOf(lane(400))], comparison });
Expand Down
7 changes: 4 additions & 3 deletions .github/workflows/pr-run-activity-read.yml
Original file line number Diff line number Diff line change
Expand Up @@ -807,9 +807,10 @@ jobs:
# human opened a PR that closes the issue (stop), or a lane branch
# holds commits no report handed on (reuse-branch, which report-outcome
# opens a PR from). Both skip not-applicable. When the newest outcome
# marker is turn-cap, stopped or limited for a run whose lane branch
# passes the same tip check, the model runs on that branch's tip
# instead (resume-branch, resume-tip, resume-cause).
# marker is turn-cap, stopped, limited or funding for a run whose lane branch
# passes the same tip check and is not behind the default branch, the
# model runs on that branch's tip instead (resume-branch, resume-tip,
# resume-cause); a branch behind it starts fresh from the default tip.
- name: Check for an earlier run's work on the issue
id: issue-prior
if: >-
Expand Down
8 changes: 5 additions & 3 deletions docs/conventions/pr-pipeline/pr-run-activity.md
Original file line number Diff line number Diff line change
Expand Up @@ -1768,9 +1768,11 @@ step under an `outcome=<reason>` marker. What changes from the PR flow:
those markers that carry `branch=resumed`, left by a resumed run that kept no branch of its own)
and names a run whose newest lane branch passes the same commit, tip and PR checks
with every commit ahead of the default branch verified, authored by the lanes App and committed
by `web-flow` (a listing the compare cuts short resumes nothing), the run resumes it; when that
run's branch is gone or fails a check, the run starts from the default branch, never from an
older run's branch: `issue-prior`
by `web-flow` (a listing the compare cuts short resumes nothing) and no commit behind the default
branch, the run resumes it; when that run's branch is gone, fails a check or is behind the
default branch (its stale base would fail the runner's whole-branch checks), the run starts from
the default branch under a new lane branch, never from an older run's branch, and leaves the old
branch as it is: `issue-prior`
outputs `resume-branch`, `resume-tip` (the tip it read) and `resume-cause`, and the job checks
out that tip, so its patch is made there instead of on the default branch tip. Any failed
read, or an answer of the wrong shape, fails the step, so no model runs.
Expand Down
Loading