Public: anyone can read this; the crew can change it.
An issue never has two build runs at once: a second dispatch while one runs, or within 10 minutes of its pull opening, is refused #6582
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6574-an-issue-never-has-two-build-runs-at-onc → main 1 commit fork 1 thread · 1 unresolved
2 files
changed +90
−0
src/crew/dispatch.ts +36
| @@ -314,6 +314,29 @@ function unstickDue(app: App, repo: Repo, issue: Issue): boolean { | ||
| 314 | 314 | return unstickOwed(app.db, repo, last?.at ?? ""); |
| 315 | 315 | } |
| 316 | 316 | |
| 317 | /** How long after a crew pull opens its issue takes no second build run. */ | |
| 318 | export const BUILD_SETTLE_MS = 10 * 60_000; | |
| 319 | ||
| 320 | /** The crew pull opened for the issue within `BUILD_SETTLE_MS`, if any. */ | |
| 321 | function freshPullFor( | |
| 322 | app: App, | |
| 323 | issue: Issue, | |
| 324 | agent: Agent, | |
| 325 | ): { number: number } | undefined { | |
| 326 | const since = new Date(Date.now() - BUILD_SETTLE_MS).toISOString(); | |
| 327 | const name = agent.user.username.replace(/[\\%_]/g, "\\$&"); | |
| 328 | return ( | |
| 329 | app.db | |
| 330 | .query<{ number: number }, [string, string, string, string]>( | |
| 331 | `SELECT i.number FROM pull p JOIN issue i ON i.id = p.issue_id | |
| 332 | WHERE i.repo_id = ?1 AND i.created_at > ?2 | |
| 333 | AND (p.head_branch = ?3 OR p.head_branch LIKE ?4 ESCAPE '\\') | |
| 334 | LIMIT 1`, | |
| 335 | ) | |
| 336 | .get(issue.repoId, since, `${agent.user.username}/${issue.number}`, `${name}/${issue.number}-%`) ?? undefined | |
| 337 | ); | |
| 338 | } | |
| 339 | ||
| 317 | 340 | /** Queue a run about an issue for an agent, unless one is already waiting. */ |
| 318 | 341 | export function queueAgentRun( |
| 319 | 342 | app: App, |
| @@ -406,6 +429,19 @@ export function queueAgentRun( | ||
| 406 | 429 | hasQueuedRun(app.db, "agent", issue.id, mine)) |
| 407 | 430 | ) |
| 408 | 431 | return undefined; |
| 432 | // One build run per issue: a pull opened a moment ago is that run's | |
| 433 | // answer, and a second build beside it is the same work twice. | |
| 434 | if (reason === "assigned") { | |
| 435 | const fresh = freshPullFor(app, issue, agent); | |
| 436 | if (fresh !== undefined) { | |
| 437 | log.info("build run refused: a pull opened in the last 10 minutes", { | |
| 438 | repo: `${repo.ownerName}/${repo.name}`, | |
| 439 | issue: issue.number, | |
| 440 | pull: fresh.number, | |
| 441 | }); | |
| 442 | return undefined; | |
| 443 | } | |
| 444 | } | |
| 409 | 445 | const group = variantGroup ?? variantGroupOf(app.db, issue.id); |
| 410 | 446 | return createRun(app.db, { |
| 411 | 447 | repoId: repo.id, |
test/build-once.test.ts added +54
| @@ -0,0 +1,54 @@ | ||
| 1 | /** | |
| 2 | * An issue never has two build runs at once: a second assigned dispatch | |
| 3 | * while one runs, or within 10 minutes of its pull opening, is refused (#6574). | |
| 4 | */ | |
| 5 | ||
| 6 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 7 | import type { App } from "../src/app.ts"; | |
| 8 | import { assignIssue, openIssue } from "../src/actions/issues.ts"; | |
| 9 | import { getIssue, type Issue } from "../src/core/issues.ts"; | |
| 10 | import { listPulls } from "../src/core/pulls.ts"; | |
| 11 | import type { Repo } from "../src/core/repos.ts"; | |
| 12 | import { getAgent } from "../src/core/agents.ts"; | |
| 13 | import type { User } from "../src/core/users.ts"; | |
| 14 | import { queueAgentRun } from "../src/crew/dispatch.ts"; | |
| 15 | import { | |
| 16 | agentRunSettled, | |
| 17 | startStandInCrew, | |
| 18 | stopStandInCrew, | |
| 19 | type StandInCrew, | |
| 20 | } from "./helpers/crew-standin.ts"; | |
| 21 | ||
| 22 | let standIn: StandInCrew; | |
| 23 | let app: App; | |
| 24 | let alice: User; | |
| 25 | let bot: User; | |
| 26 | let repo: Repo; | |
| 27 | let issue: Issue; | |
| 28 | ||
| 29 | beforeAll(async () => { | |
| 30 | standIn = await startStandInCrew(); | |
| 31 | ({ app, alice, bot, repo } = standIn); | |
| 32 | const opened = openIssue(app, alice, repo, { title: "Plant tulips", body: "Along the fence." }); | |
| 33 | if (!opened.ok) throw new Error("issue not opened"); | |
| 34 | issue = opened.value; | |
| 35 | assignIssue(app, alice, repo, issue, bot); | |
| 36 | const run = await agentRunSettled(app, repo, (r) => r.reason === "assigned"); | |
| 37 | expect(run.state).toBe("ok"); | |
| 38 | }, 60_000); | |
| 39 | ||
| 40 | afterAll(() => stopStandInCrew(standIn)); | |
| 41 | ||
| 42 | test("a second build dispatch within 10 minutes of the pull opening is refused; later it starts", () => { | |
| 43 | const agent = getAgent(app.db, bot.id)!; | |
| 44 | const fresh = getIssue(app.db, repo.id, issue.number)!; | |
| 45 | const pull = listPulls(app.db, repo.id, { state: "open" })[0]!; | |
| 46 | expect(queueAgentRun(app, repo, fresh, agent, "assigned")).toBeUndefined(); | |
| 47 | // Other runs are unaffected by the pull's age. | |
| 48 | const old = new Date(Date.now() - 11 * 60_000).toISOString(); | |
| 49 | app.db.query("UPDATE issue SET created_at = ?1 WHERE id = ?2").run(old, pull.id); | |
| 50 | const run = queueAgentRun(app, repo, fresh, agent, "assigned"); | |
| 51 | expect(run?.state).toBe("queued"); | |
| 52 | // And now that one is queued, a third is refused. | |
| 53 | expect(queueAgentRun(app, repo, fresh, agent, "assigned")).toBeUndefined(); | |
| 54 | }); |
new above
offline
Add
AND p.state = 'open'(or the column the pull uses) so a closed pull does not block a re-assign; today the refusal is silent.