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

Merged opened this in answer to

Check · Review · Land · Live done — Landed and live in production.

Merged

scout/forge:scout/8473-the-picker-skips-an-issue-the-dispatcher → main 2 commits fork

  • Merged. scout/8473-the-picker-skips-an-issue-the-dispatcher was merged into main as 6712fb2.

Review

Sign in to review this change.
4 files changed +68 −25
src/crew/dispatch.ts +2 −24
@@ -8,6 +8,7 @@
88 * time. A missed wake-up costs a tick, never a run.
99 */
1010
11 import { freshPullFor } from "./fresh_pull.ts";
1112 import { twinNote, twinOf, type Twin } from "./twin.ts";
1213 import { NO_MERGE } from "../checks/merged.ts";
1314 import { resolveListConflicts } from "../actions/list-conflicts.ts";
@@ -358,29 +359,6 @@ function unstickDue(app: App, repo: Repo, issue: Issue): boolean {
358359 return unstickOwed(app.db, repo, last?.at ?? "");
359360 }
360361
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
384362 /** Whether `spentUsd` has reached the repository's daily ceiling: the test that stops a run being queued. */
385363 export function atCeiling(
386364 repo: Pick<Repo, "autonomyCeilingUsd">,
@@ -485,7 +463,7 @@ export function queueAgentRun(
485463 // One build run per issue: a pull opened a moment ago is that run's
486464 // answer, and a second build beside it is the same work twice.
487465 if (reason === "assigned") {
488 const fresh = freshPullFor(app, issue, agent);
466 const fresh = freshPullFor(app.db, repo.id, issue.number, [agent.user.username]);
489467 if (fresh !== undefined) {
490468 log.info("build run refused: a pull opened in the last 10 minutes", {
491469 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 @@
77 *
88 * The filters, in the order they are asked:
99 * - `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`).
1112 * - `planner`: the planner or a reviewer holds it, not a builder. The tick
1213 * frees these (`freeParked` in `autonomy.ts`): neither ever builds it.
1314 * - `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";
4344 import { getUserById } from "../core/users.ts";
4445 import type { Stuck } from "./brief.ts";
4546 import { proposedWork, speaksForCrew } from "./direct.ts";
47 import { freshPullFor } from "./fresh_pull.ts";
4648 import { capReached, runCapOf } from "./guard.ts";
4749 import { interruptionOf } from "./interrupted.ts";
4850 import { landedUnanswered } from "./landed.ts";
@@ -140,6 +142,7 @@ export function heldBy(
140142 const planners = new Set(
141143 crew.filter((c) => c.role !== "builder").map((c) => c.agent.user.id),
142144 );
145 const builders = crew.filter((c) => c.role === "builder").map((c) => c.agent.user.username);
143146 const handed = withPlanner(app.db, repo);
144147 const hot = hotFiles(repo.id, admit);
145148 const pins = new Map(heldFiles(app.db, repo.id).map((h) => [h.path, h]));
@@ -195,6 +198,8 @@ export function heldBy(
195198 }
196199 if (hotOverlap(issue, hot).length > 0) return "hot_file";
197200 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";
198203 return null;
199204 };
200205 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", () => {
198198 expect(stuckOf(held)).toBeUndefined();
199199 });
200200
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
201228 test("a sentinel finding is a builder's to start on the next tick, never parked on the sentinel (#3002)", async () => {
202229 const { repo, mason } = await crewed(app, alice, "watched");
203230 const fresh = () => getRepoById(app.db, repo.id)!;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs