Public: anyone can read this; the crew can change it.
The picker skips an issue the dispatcher will refuse, so one fresh pull does not idle every builder #8509
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8473-the-picker-skips-an-issue-the-dispatcher → main 2 commits fork
4 files
changed +68
−25
src/crew/dispatch.ts +2 −24
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | * time. A missed wake-up costs a tick, never a run. |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | import { freshPullFor } from "./fresh_pull.ts"; | |
| 11 | 12 | import { twinNote, twinOf, type Twin } from "./twin.ts"; |
| 12 | 13 | import { NO_MERGE } from "../checks/merged.ts"; |
| 13 | 14 | import { resolveListConflicts } from "../actions/list-conflicts.ts"; |
| @@ -358,29 +359,6 @@ function unstickDue(app: App, repo: Repo, issue: Issue): boolean { | ||
| 358 | 359 | return unstickOwed(app.db, repo, last?.at ?? ""); |
| 359 | 360 | } |
| 360 | 361 | |
| 361 | /** How long after a crew pull opens its issue takes no second build run. */ | |
| 362 | export const BUILD_SETTLE_MS = 10 * 60_000; | |
| 363 | ||
| 364 | /** The crew pull opened for the issue within `BUILD_SETTLE_MS`, if any. */ | |
| 365 | function freshPullFor( | |
| 366 | app: App, | |
| 367 | issue: Issue, | |
| 368 | agent: Agent, | |
| 369 | ): { number: number } | undefined { | |
| 370 | const since = new Date(Date.now() - BUILD_SETTLE_MS).toISOString(); | |
| 371 | const name = agent.user.username.replace(/[\\%_]/g, "\\$&"); | |
| 372 | return ( | |
| 373 | app.db | |
| 374 | .query<{ number: number }, [string, string, string, string]>( | |
| 375 | `SELECT i.number FROM pull p JOIN issue i ON i.id = p.issue_id | |
| 376 | WHERE i.repo_id = ?1 AND i.created_at > ?2 | |
| 377 | AND (p.head_branch = ?3 OR p.head_branch LIKE ?4 ESCAPE '\\') | |
| 378 | LIMIT 1`, | |
| 379 | ) | |
| 380 | .get(issue.repoId, since, `${agent.user.username}/${issue.number}`, `${name}/${issue.number}-%`) ?? undefined | |
| 381 | ); | |
| 382 | } | |
| 383 | ||
| 384 | 362 | /** Whether `spentUsd` has reached the repository's daily ceiling: the test that stops a run being queued. */ |
| 385 | 363 | export function atCeiling( |
| 386 | 364 | repo: Pick<Repo, "autonomyCeilingUsd">, |
| @@ -485,7 +463,7 @@ export function queueAgentRun( | ||
| 485 | 463 | // One build run per issue: a pull opened a moment ago is that run's |
| 486 | 464 | // answer, and a second build beside it is the same work twice. |
| 487 | 465 | if (reason === "assigned") { |
| 488 | const fresh = freshPullFor(app, issue, agent); | |
| 466 | const fresh = freshPullFor(app.db, repo.id, issue.number, [agent.user.username]); | |
| 489 | 467 | if (fresh !== undefined) { |
| 490 | 468 | log.info("build run refused: a pull opened in the last 10 minutes", { |
| 491 | 469 | repo: `${repo.ownerName}/${repo.name}`, |
src/crew/fresh_pull.ts added +33
| @@ -0,0 +1,33 @@ | ||
| 1 | /** | |
| 2 | * A pull a crew member opened for an issue a moment ago (#8473). The | |
| 3 | * dispatcher refuses a second build run on such an issue, and the picker | |
| 4 | * holds it as `open_pull`: both ask this one function. | |
| 5 | */ | |
| 6 | ||
| 7 | import type { Database } from "bun:sqlite"; | |
| 8 | ||
| 9 | /** How long after a crew pull opens its issue takes no second build run. */ | |
| 10 | export const BUILD_SETTLE_MS = 10 * 60_000; | |
| 11 | ||
| 12 | /** The pull opened within `BUILD_SETTLE_MS` on a branch of one of `usernames` for the issue, if any. */ | |
| 13 | export function freshPullFor( | |
| 14 | db: Database, | |
| 15 | repoId: string, | |
| 16 | issueNumber: number, | |
| 17 | usernames: readonly string[], | |
| 18 | ): { number: number } | undefined { | |
| 19 | const since = new Date(Date.now() - BUILD_SETTLE_MS).toISOString(); | |
| 20 | for (const username of usernames) { | |
| 21 | const name = username.replace(/[\\%_]/g, "\\$&"); | |
| 22 | const found = db | |
| 23 | .query<{ number: number }, [string, string, string, string]>( | |
| 24 | `SELECT i.number FROM pull p JOIN issue i ON i.id = p.issue_id | |
| 25 | WHERE i.repo_id = ?1 AND i.created_at > ?2 | |
| 26 | AND (p.head_branch = ?3 OR p.head_branch LIKE ?4 ESCAPE '\\') | |
| 27 | LIMIT 1`, | |
| 28 | ) | |
| 29 | .get(repoId, since, `${username}/${issueNumber}`, `${name}/${issueNumber}-%`); | |
| 30 | if (found) return found; | |
| 31 | } | |
| 32 | return undefined; | |
| 33 | } |
src/crew/held.ts +6 −1
| @@ -7,7 +7,8 @@ | ||
| 7 | 7 | * |
| 8 | 8 | * The filters, in the order they are asked: |
| 9 | 9 | * - `running`: a run is in flight on it. |
| 10 | * - `open_pull`: an open pull already proposes it (`direct.ts`). | |
| 10 | * - `open_pull`: an open pull already proposes it (`direct.ts`), or one a | |
| 11 | * builder opened in the last 10 minutes (`fresh_pull.ts`). | |
| 11 | 12 | * - `planner`: the planner or a reviewer holds it, not a builder. The tick |
| 12 | 13 | * frees these (`freeParked` in `autonomy.ts`): neither ever builds it. |
| 13 | 14 | * - `with_planner`: its limb was handed to the planner by a person and the |
| @@ -43,6 +44,7 @@ import { listActive, type Run } from "../core/runs.ts"; | ||
| 43 | 44 | import { getUserById } from "../core/users.ts"; |
| 44 | 45 | import type { Stuck } from "./brief.ts"; |
| 45 | 46 | import { proposedWork, speaksForCrew } from "./direct.ts"; |
| 47 | import { freshPullFor } from "./fresh_pull.ts"; | |
| 46 | 48 | import { capReached, runCapOf } from "./guard.ts"; |
| 47 | 49 | import { interruptionOf } from "./interrupted.ts"; |
| 48 | 50 | import { landedUnanswered } from "./landed.ts"; |
| @@ -140,6 +142,7 @@ export function heldBy( | ||
| 140 | 142 | const planners = new Set( |
| 141 | 143 | crew.filter((c) => c.role !== "builder").map((c) => c.agent.user.id), |
| 142 | 144 | ); |
| 145 | const builders = crew.filter((c) => c.role === "builder").map((c) => c.agent.user.username); | |
| 143 | 146 | const handed = withPlanner(app.db, repo); |
| 144 | 147 | const hot = hotFiles(repo.id, admit); |
| 145 | 148 | const pins = new Map(heldFiles(app.db, repo.id).map((h) => [h.path, h])); |
| @@ -195,6 +198,8 @@ export function heldBy( | ||
| 195 | 198 | } |
| 196 | 199 | if (hotOverlap(issue, hot).length > 0) return "hot_file"; |
| 197 | 200 | if (waitsOnRelief(issue)) return "hot_file"; |
| 201 | // The dispatcher refuses a build on an issue whose pull just opened (#8473): held, not picked. | |
| 202 | if (freshPullFor(app.db, repo.id, issue.number, builders) !== undefined) return "open_pull"; | |
| 198 | 203 | return null; |
| 199 | 204 | }; |
| 200 | 205 | const by = Object.fromEntries(HOLDS.map((h) => [h, [] as Issue[]])) as Record< |
test/crew-held.test.ts +27
| @@ -198,6 +198,33 @@ describe("the tick says why each ready issue cannot start", () => { | ||
| 198 | 198 | expect(stuckOf(held)).toBeUndefined(); |
| 199 | 199 | }); |
| 200 | 200 | |
| 201 | test("an issue whose pull opened in the last 10 minutes is held, so the next one starts (#8473)", async () => { | |
| 202 | const { repo, mason } = await crewed(app, alice, "settle"); | |
| 203 | const first = issue(repo, "First in line"); | |
| 204 | const second = issue(repo, "Second in line"); | |
| 205 | const seeded = await commitToBranch(app, alice, repo, { | |
| 206 | message: "Start", | |
| 207 | files: [{ path: "README.md", content: "# settle\n" }], | |
| 208 | }); | |
| 209 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 210 | const headBranch = `mason/${first.number}-first`; | |
| 211 | const branch = await commitToBranch(app, alice, repo, { | |
| 212 | branch: headBranch, | |
| 213 | base: "main", | |
| 214 | message: "First", | |
| 215 | files: [{ path: "first.ts", content: "export const n = 1;\n" }], | |
| 216 | }); | |
| 217 | if (!branch.ok) throw new Error(JSON.stringify(branch.error)); | |
| 218 | // The pull names no issue, so only the branch ties it to the first. | |
| 219 | const pull = await openPull(app, mason, repo, { title: "First", body: "Done.", baseBranch: "main", headBranch }); | |
| 220 | if (!pull.ok) throw new Error(String(pull.error)); | |
| 221 | ||
| 222 | const held = heldNow(app, getRepoById(app.db, repo.id)!); | |
| 223 | expect(held.by.open_pull.map((i) => i.number)).toEqual([first.number]); | |
| 224 | expect(held.startable.map((r) => r.issue.number)).toEqual([second.number]); | |
| 225 | expect(heldWords(held)).toBe("open_pull=1"); | |
| 226 | }); | |
| 227 | ||
| 201 | 228 | test("a sentinel finding is a builder's to start on the next tick, never parked on the sentinel (#3002)", async () => { |
| 202 | 229 | const { repo, mason } = await crewed(app, alice, "watched"); |
| 203 | 230 | const fresh = () => getRepoById(app.db, repo.id)!; |
new above
offline