Public: anyone can read this; the crew can change it.
The decider never closes a pull while its builder's run is still running; it waits for the run to end #6581
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6575-the-decider-never-closes-a-pull-while-it → main 2 commits fork 2 threads · 2 unresolved
2 files
changed +193
−1
src/crew/dispatch.ts +50 −1
| @@ -27,7 +27,7 @@ import { | ||
| 27 | 27 | listComments, |
| 28 | 28 | type Issue, |
| 29 | 29 | } from "../core/issues.ts"; |
| 30 | import { getPull, type Pull } from "../core/pulls.ts"; | |
| 30 | import { closesFromBody, getPull, type Pull } from "../core/pulls.ts"; | |
| 31 | 31 | import { claimedClones, sweepOrphanClones } from "./orphans.ts"; |
| 32 | 32 | import { pruneRunFiles } from "../runs/prune.ts"; |
| 33 | 33 | import { crewRoot } from "./workdir.ts"; |
| @@ -595,6 +595,7 @@ function route(app: App, event: ForgeEvent): void { | ||
| 595 | 595 | reason: String(event.payload["reason"] ?? ""), |
| 596 | 596 | actorId: finished?.actorId ?? null, |
| 597 | 597 | }); |
| 598 | decideDeferred(app, repo); | |
| 598 | 599 | return; |
| 599 | 600 | } |
| 600 | 601 | if ( |
| @@ -645,6 +646,15 @@ function route(app: App, event: ForgeEvent): void { | ||
| 645 | 646 | // is the agent's to look at. |
| 646 | 647 | const agent = agentFor(app, issue); |
| 647 | 648 | if (!agent) return; |
| 649 | // The builder's run is still at work on it: nothing is decided now, | |
| 650 | // and the failure is decided again when that run ends. | |
| 651 | if (builderAtWork(app, repo, issue)) { | |
| 652 | deferredFailures.set(issue.id, event); | |
| 653 | log.info("check failed while the builder's run is at work; deciding when it ends", { | |
| 654 | issue: issue.number, | |
| 655 | }); | |
| 656 | return; | |
| 657 | } | |
| 648 | 658 | // A red that main shares (#1971), or that three pulls share within |
| 649 | 659 | // the hour (#2266), is not the pull's: no rework, no park, no |
| 650 | 660 | // strike; it waits and is checked again when main is green. |
| @@ -731,6 +741,45 @@ function route(app: App, event: ForgeEvent): void { | ||
| 731 | 741 | } |
| 732 | 742 | } |
| 733 | 743 | |
| 744 | /** | |
| 745 | * Failures that arrived while the builder's run was still at work on the | |
| 746 | * pull, by pull. Memory only: a restart forgets them, and the pull's next | |
| 747 | * push or a person's ask checks it again. | |
| 748 | */ | |
| 749 | const deferredFailures = new Map<string, ForgeEvent>(); | |
| 750 | ||
| 751 | /** Whether a build run on the pull's issue, or on an issue it closes, is queued or running. */ | |
| 752 | function builderAtWork(app: App, repo: Repo, pull: Issue): boolean { | |
| 753 | if (activeRunFor(app.db, "agent", pull.id) !== undefined) return true; | |
| 754 | const row = getPull(app.db, repo.id, pull.number); | |
| 755 | if (!row) return false; | |
| 756 | for (const number of closesFromBody(row.body)) { | |
| 757 | const about = getIssue(app.db, repo.id, number); | |
| 758 | if (about && activeRunFor(app.db, "agent", about.id) !== undefined) | |
| 759 | return true; | |
| 760 | } | |
| 761 | return false; | |
| 762 | } | |
| 763 | ||
| 764 | /** A run ended: decide again on the failures that waited for it, when they still stand. */ | |
| 765 | function decideDeferred(app: App, repo: Repo): void { | |
| 766 | for (const [issueId, event] of [...deferredFailures]) { | |
| 767 | if (event.repoId !== repo.id) continue; | |
| 768 | const issue = getIssueById(app.db, issueId); | |
| 769 | if (!issue || issue.state !== "open") { | |
| 770 | deferredFailures.delete(issueId); | |
| 771 | continue; | |
| 772 | } | |
| 773 | if (builderAtWork(app, repo, issue)) continue; | |
| 774 | deferredFailures.delete(issueId); | |
| 775 | const pull = getPull(app.db, repo.id, issue.number); | |
| 776 | const oid = event.payload["commitOid"]; | |
This stale-head drop has no test. Push during the run, end the run, and assert nothing is parked and no rework is queued. |
||
| 777 | // A push since then made a newer check; this one is history. | |
| 778 | if (!pull || (typeof oid === "string" && oid !== pull.headOid)) continue; | |
| 779 | route(app, event); | |
| 780 | } | |
| 781 | } | |
| 782 | ||
| 734 | 783 | /** |
| 735 | 784 | * Pulls whose check failed only on what main fails, or on what the lane |
| 736 | 785 | * fails across pulls (`checks/shared_red.ts`), per repository, with |
test/crew-failed-while-building.test.ts added +143
| @@ -0,0 +1,143 @@ | ||
| 1 | /** | |
| 2 | * A failed check or deploy never closes a pull while its builder's run is | |
| 3 | * queued or running (#6575): nothing is decided then, and the failure is | |
| 4 | * decided again when the run ends. | |
| 5 | */ | |
| 6 | ||
| 7 | import { afterAll, beforeAll, describe, expect, test } from "bun:test"; | |
| 8 | import type { App } from "../src/app.ts"; | |
| 9 | import { announceRun } from "../src/actions/runs.ts"; | |
| 10 | import { commitToBranch } from "../src/actions/repos.ts"; | |
| 11 | import { openPull } from "../src/actions/pulls.ts"; | |
| 12 | import { openIssue, setIssueState } from "../src/actions/issues.ts"; | |
| 13 | import { CHECK_FAILED_LIMIT } from "../src/crew/dispatch.ts"; | |
| 14 | import { getIssueById } from "../src/core/issues.ts"; | |
| 15 | import { getPull, type Pull } from "../src/core/pulls.ts"; | |
| 16 | import type { Repo } from "../src/core/repos.ts"; | |
| 17 | import { claimRun, createRun, finishRun, getRun, type Run } from "../src/core/runs.ts"; | |
| 18 | import type { User } from "../src/core/users.ts"; | |
| 19 | import { startStandInCrew, stopStandInCrew, type StandInCrew } from "./helpers/crew-standin.ts"; | |
| 20 | ||
| 21 | describe("a failure during the builder's run", () => { | |
| 22 | let standIn: StandInCrew; | |
| 23 | let app: App; | |
| 24 | let alice: User; | |
| 25 | let bot: User; | |
| 26 | let repo: Repo; | |
| 27 | let n = 0; | |
| 28 | ||
| 29 | beforeAll(async () => { | |
| 30 | standIn = await startStandInCrew(); | |
| 31 | ({ app, alice, bot, repo } = standIn); | |
| 32 | standIn.checks.request = async () => undefined; | |
| 33 | app.db.run("UPDATE repo SET autonomy = 'autonomous', autonomy_by = ?1 WHERE id = ?2", [alice.id, repo.id]); | |
| 34 | }, 30_000); | |
| 35 | afterAll(() => stopStandInCrew(standIn)); | |
| 36 | ||
| 37 | /** A pull whose automatic tries are spent, so a failure parks it. */ | |
| 38 | const spentPull = async (body?: string): Promise<Pull> => { | |
| 39 | const branch = `busy-${++n}`; | |
| 40 | const c = await commitToBranch(app, alice, repo, { | |
| 41 | message: branch, | |
| 42 | branch, | |
| 43 | files: [{ path: `${branch}.md`, content: "x\n" }], | |
| 44 | }); | |
| 45 | if (!c.ok) throw new Error(JSON.stringify(c.error)); | |
| 46 | const o = await openPull(app, alice, repo, { title: branch, baseBranch: "main", headBranch: branch, ...(body === undefined ? {} : { body }) }); | |
| 47 | if (!o.ok) throw new Error(String(o.error)); | |
| 48 | app.db.run("UPDATE issue SET assignee_id = ?1 WHERE id = ?2", [bot.id, o.value.id]); | |
| 49 | for (let i = 0; i < CHECK_FAILED_LIMIT; i++) { | |
| 50 | const tried = createRun(app.db, { repoId: repo.id, kind: "agent", issueId: o.value.id, reason: "check_failed" }); | |
| 51 | claimRun(app.db, tried.id); | |
| 52 | finishRun(app.db, tried.id, { state: "ok", summary: "reworked" }); | |
| 53 | } | |
| 54 | return getPull(app.db, repo.id, o.value.number)!; | |
| 55 | }; | |
| 56 | const failure = (pull: Pull, kind: "check" | "deploy"): Run => { | |
| 57 | const run = createRun(app.db, { | |
| 58 | repoId: repo.id, | |
| 59 | kind, | |
| 60 | env: `pull-${pull.number}`, | |
| 61 | commitOid: pull.headOid, | |
| 62 | reason: "push", | |
| 63 | issueId: pull.id, | |
| 64 | }); | |
| 65 | claimRun(app.db, run.id); | |
| 66 | return finishRun(app.db, run.id, { state: "failed", summary: `${kind} failed` })!; | |
| 67 | }; | |
| 68 | const open = (pull: Pull) => getIssueById(app.db, pull.id)?.state === "open"; | |
| 69 | ||
| 70 | const building = (issueId: string): Run => { | |
| 71 | const run = createRun(app.db, { repoId: repo.id, kind: "agent", issueId, reason: "feedback" }); | |
| 72 | claimRun(app.db, run.id); | |
| 73 | return run; | |
| 74 | }; | |
| 75 | const ends = (run: Run): void => | |
| 76 | announceRun(app, finishRun(app.db, run.id, { state: "ok", summary: "pushed nothing" })!); | |
| 77 | const push = async (pull: Pull): Promise<void> => { | |
| 78 | const c = await commitToBranch(app, alice, repo, { | |
| 79 | message: "more", | |
| 80 | branch: pull.headBranch, | |
| 81 | files: [{ path: `more-${++n}.md`, content: "y\n" }], | |
| 82 | }); | |
| 83 | if (!c.ok) throw new Error(JSON.stringify(c.error)); | |
| 84 | }; | |
| 85 | ||
| 86 | test("a failed deploy during a running build leaves the pull open, and it is decided when the run ends", async () => { | |
| 87 | const pull = await spentPull(); | |
| 88 | const run = building(pull.id); | |
| 89 | announceRun(app, failure(pull, "deploy")); | |
| 90 | expect(open(pull)).toBe(true); | |
| 91 | ends(run); | |
| 92 | expect(open(pull)).toBe(false); | |
| 93 | }); | |
| 94 | ||
| 95 | test("a queued run holds it too, and the end of that run decides it", async () => { | |
| 96 | const pull = await spentPull(); | |
| 97 | const queued = createRun(app.db, { repoId: repo.id, kind: "agent", issueId: pull.id, reason: "feedback" }); | |
| 98 | announceRun(app, failure(pull, "check")); | |
| 99 | expect(open(pull)).toBe(true); | |
| 100 | claimRun(app.db, queued.id); | |
| 101 | ends(queued); | |
| 102 | expect(open(pull)).toBe(false); | |
| 103 | }); | |
| 104 | ||
| 105 | test("a run on the issue the pull closes holds it as well", async () => { | |
| 106 | const source = openIssue(app, alice, repo, { title: "source" }); | |
| 107 | if (!source.ok) throw new Error(String(source.error)); | |
| 108 | const pull = await spentPull(`Closes #${source.value.number}`); | |
| 109 | const run = building(source.value.id); | |
| 110 | announceRun(app, failure(pull, "deploy")); | |
| 111 | expect(open(pull)).toBe(true); | |
| 112 | ends(run); | |
| 113 | expect(open(pull)).toBe(false); | |
| 114 | }); | |
| 115 | ||
| 116 | test("a push during the run makes the failure history: the run's end decides nothing", async () => { | |
| 117 | const pull = await spentPull(); | |
| 118 | const run = building(pull.id); | |
| 119 | announceRun(app, failure(pull, "check")); | |
| 120 | await push(pull); | |
| 121 | ends(run); | |
| 122 | expect(open(pull)).toBe(true); | |
| 123 | }); | |
| 124 | ||
| 125 | test("a pull closed during the run forgets the failure; reopened, it is not parked by it", async () => { | |
| 126 | const pull = await spentPull(); | |
| 127 | const run = building(pull.id); | |
| 128 | announceRun(app, failure(pull, "check")); | |
| 129 | const issue = getIssueById(app.db, pull.id)!; | |
| 130 | expect(setIssueState(app, alice, repo, issue, "closed").ok).toBe(true); | |
| 131 | // Closing the pull cancels the run, which announces its end. | |
| 132 | expect(getRun(app.db, run.id)?.state).not.toBe("running"); | |
| 133 | expect(setIssueState(app, alice, repo, getIssueById(app.db, pull.id)!, "open").ok).toBe(true); | |
| 134 | ends(building(pull.id)); | |
| 135 | expect(open(pull)).toBe(true); | |
| 136 | }); | |
| 137 | ||
| 138 | test("the same failure after the run ended closes it as before", async () => { | |
| 139 | const pull = await spentPull(); | |
| 140 | announceRun(app, failure(pull, "deploy")); | |
| 141 | expect(open(pull)).toBe(false); | |
| 142 | }); | |
| 143 | }); |
new above
offline
The closesFromBody branch is untested. Queue the run on the closed source issue and assert the failure is held.