Public: anyone can read this; the crew can change it.

Builder routing: rebases go to a builder who can't push (0/5 ok), and the picker sends 23 of 23 picks to Scout (5 slots run 1 builder) #7016

Merged opened this in answer to

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

Merged

mason/forge:mason/7006-builder-routing-rebases-go-to-a-builder → main 2 commits fork

  • Merged. mason/7006-builder-routing-rebases-go-to-a-builder was merged into main as a3bac53.

Review

Sign in to review this change.
8 files changed +195 −76
docs/FLOW.md +11 −4
@@ -35,10 +35,17 @@ A check on a pull whose last builder's run brought it up to date (a
3535 queued check (`checks/finishing_first.ts`). So the room for more rebases
3636 (`crew/conflict_room.ts`) is the check slots minus the rebases' checks and the
3737 bring-up-to-date runs in flight; a new build's check does not stand in front
38 of a rebase and takes no room. When the pull's own builder already has a run
39 in flight, the finishing pass (`crew/finishing.ts`) lends the update to an
40 idle builder, round-robin by name; the run's agent is who rebased, and the log
41 line names whose pull it was.
38 of a rebase and takes no room. The finishing pass (`crew/finishing.ts`) gives
39 the update to the builder who can push the pull's branch — the fork's owner,
40 else its author — and when that builder already has a run in flight the
41 update queues behind it: lent to another builder, it could never push
42 (#7006).
43
44 New work is the other way round: the decider's choice of builder is a
45 ranking, and the most preferred builder with nothing in flight takes the
46 issue (`rankedBuilder` in `crew/route.ts`), so the slots run more than one
47 builder. The tick line's `idle` field counts the builders with nothing in
48 flight beside `active` and `slots`.
4249
4350 The tick line's `lane` field, the `Rebases:` line on `/flow` and in the
4451 terminal's Flow, and `rebases` in the MCP `flow` tool all read
src/crew/autonomy.ts +18 −7
@@ -282,6 +282,19 @@ export function slottedRuns(
282282 );
283283 }
284284
285 /**
286 * The builders with nothing in flight here. One agent runs one thing at a
287 * time, so `active` on the tick line climbs to at most
288 * min(slots, startable, idle builders + active) (#7006).
289 */
290 export function idleBuilders(
291 active: readonly Run[],
292 crew: readonly { readonly agent: Agent; readonly role: CrewRole }[],
293 ): number {
294 const busy = new Set(active.map((r) => r.actorId));
295 return crew.filter((c) => c.role === "builder" && !busy.has(c.agent.user.id)).length;
296 }
297
285298 export function autonomyStatus(
286299 app: App,
287300 repo: Repo,
@@ -1287,13 +1300,9 @@ export async function autonomyTick(
12871300 const status = autonomyStatus(app, current, now);
12881301 const approved = waitWords(lastWaits(current.id));
12891302 const ready = readyNow(repoGraph(app.db, current.id, { sealed: true }));
1290 const held = heldBy(
1291 app,
1292 current,
1293 ready,
1294 listCrewRoles(app.db, current.id),
1295 activeRuns(app, current.id),
1296 );
1303 const crewNow = listCrewRoles(app.db, current.id);
1304 const activeNow = activeRuns(app, current.id);
1305 const held = heldBy(app, current, ready, crewNow, activeNow);
12971306 const boots = bootCounts(app.config.dataDir);
12981307 const staleToday = closedToday(app, current.id, now);
12991308 // Says when the governor lowers the slots (#6375); one feed item per change.
@@ -1319,6 +1328,8 @@ export async function autonomyTick(
13191328 ...flowVitals(app.db, now),
13201329 active: status.active,
13211330 slots: status.slots,
1331 // Builders with nothing in flight: what bounds active beside slots and startable (#7006).
1332 idle: idleBuilders(activeNow, crewNow),
13221333 ...(governor === undefined ? {} : { governor }),
13231334 // Approved conflicted pulls: queued, in flight, and why each other is
13241335 // skipped (#3682, #6237), or "0 candidates of N open" (#6263); and the lane for rebases: conflicted N,
src/crew/finishing.ts +19 −42
@@ -10,12 +10,11 @@
1010 * - With a run already in flight on it, it is finishing: nothing more.
1111 * - With a slot free, a conflict run (the #2039 resolver brief: merge the
1212 * base, resolve keeping both intents, re-run the check) is queued for
13 * its builder — the agent on it, else its author, else any builder —
14 * and it takes that slot. When that builder already has a run in
15 * flight, the run goes to an idle builder instead, round-robin, so one
16 * busy agent holding many pulls does not bring them up to date one at
17 * a time (#6140); the run's agent is who rebased, and the log names
18 * whose pull it was.
13 * the builder who can push its branch — the owner of the fork it lives
14 * in, else its author, else the agent on it, else any builder — and it
15 * takes that slot. When that builder already has a run in flight, the
16 * update queues behind it: lent to another builder (#6140) it could
17 * never push, and failed every time (#7006).
1918 * - Its push is a check, so it also waits for room in the check lane, as
2019 * the conflict sweep's runs do (`conflict_room.ts`): a landing that
2120 * conflicts ten approved pulls at once must not put ten checks in the
@@ -48,7 +47,7 @@ import { getAgent, listCrewRoles, type Agent } from "../core/agents.ts";
4847 import { getHold } from "../core/holds.ts";
4948 import { getIssueById, type Issue } from "../core/issues.ts";
5049 import { listOpenPulls, pullDir, type Pull } from "../core/pulls.ts";
51 import type { Repo } from "../core/repos.ts";
50 import { getRepoById, type Repo } from "../core/repos.ts";
5251 import { reviewSummary } from "../core/reviews.ts";
5352 import { activeRunFor, listActive } from "../core/runs.ts";
5453 import { getUserById } from "../core/users.ts";
@@ -264,11 +263,10 @@ function finishOne(
264263 // A person's name on it is a person holding it — after a hand-over too.
265264 if (issue.assigneeId !== null && getAgent(app.db, issue.assigneeId) === undefined)
266265 return not("a person has it");
267 const own = ownBuilder(issue, builders, load);
268 if (own === undefined) return not(NO_BUILDER_ON_CREW);
269 // Two failed updates are counted against the pull's own builder, whoever ran them.
270 const builder = (slots > 0 && room > 0 ? lentBuilder(own, builders, load) : undefined) ?? own;
271 if (failedUpdates(app, issue, own, movedAt) >= CHECK_FAILED_LIMIT) return handOver(app, repo, pull, issue, files);
266 const forkOwner = getRepoById(app.db, pull.headRepoId)?.ownerId;
267 const builder = ownBuilder(issue, builders, load, forkOwner);
268 if (builder === undefined) return not(NO_BUILDER_ON_CREW);
269 if (failedUpdates(app, issue, builder, movedAt) >= CHECK_FAILED_LIMIT) return handOver(app, repo, pull, issue, files);
272270 if (slots <= 0) return not(WAITS_FOR_BUILDER_SLOT);
273271 if (room <= 0) return not("waits for room in the check lane");
274272 const run = queueAgentRun(app, repo, issue, builder, "conflict", undefined, {
@@ -277,48 +275,28 @@ function finishOne(
277275 summary: `main moved: ${files.join(", ")}`,
278276 });
279277 if (run === undefined) return not(`${builder.user.username}'s run was not queued (ceiling or breaker)`);
280 return {
281 pull: pull.number,
282 did: "queued update",
283 builder: builder.user.username,
284 ...(builder === own ? {} : { for: own.user.username }),
285 };
278 return { pull: pull.number, did: "queued update", builder: builder.user.username };
286279 }
287280
288 /** The pull's own builder: the agent on it, else its author, else the builder with least in flight. */
281 /**
282 * The pull's own builder, the one who can push its branch: the owner of the
283 * fork it lives in, else its author, else the agent on it, else the builder
284 * with least in flight. Busy or not, it is theirs (#7006).
285 */
289286 export function ownBuilder(
290287 issue: Issue,
291288 builders: readonly Agent[],
292289 load: ReadonlyMap<string, number>,
290 forkOwner?: string,
293291 ): Agent | undefined {
294292 return (
295 builders.find((b) => b.user.id === issue.assigneeId) ??
293 builders.find((b) => b.user.id === forkOwner) ??
296294 builders.find((b) => b.user.id === issue.authorId) ??
295 builders.find((b) => b.user.id === issue.assigneeId) ??
297296 pickBuilder(issue, builders, load)
298297 );
299298 }
300299
301 /** Whose turn it is among the idle builders, kept across ticks so lent updates go round. */
302 let turn = 0;
303
304 /**
305 * The builder who takes an update when `own` already has a run in flight:
306 * the next idle builder, round-robin by name (#6140). Undefined when `own`
307 * is idle or every builder is busy: then the update is `own`'s.
308 */
309 export function lentBuilder(
310 own: Agent,
311 builders: readonly Agent[],
312 load: ReadonlyMap<string, number>,
313 ): Agent | undefined {
314 if ((load.get(own.user.id) ?? 0) === 0) return undefined;
315 const idle = builders
316 .filter((b) => (load.get(b.user.id) ?? 0) === 0)
317 .sort((a, b) => a.user.username.localeCompare(b.user.username));
318 if (idle.length === 0) return undefined;
319 return idle[turn++ % idle.length];
320 }
321
322300 /**
323301 * Conflict runs that ended since a person last spoke on the pull and since
324302 * its base last moved. Each one reached the agent and left the approved
@@ -394,7 +372,6 @@ function say(repo: Repo, pull: Pull, move: FinishMove): void {
394372 repo: where,
395373 pull: pull.number,
396374 builder: move.builder,
397 ...(move.for === undefined ? {} : { for: move.for }),
398375 });
399376 return;
400377 }
src/crew/route.ts +24 −3
@@ -49,6 +49,27 @@ export function pickBuilder(
4949 )[0];
5050 }
5151
52 /**
53 * Who takes an issue given the decider's preference: a ranking, not an
54 * exclusive pick. The most preferred builder with nothing in flight takes
55 * it — one agent runs one thing at a time, so a busy favourite would leave
56 * the other slots empty (#7006) — and with every builder busy, the most
57 * preferred. Ties keep the decider's choice first, then the crew's order.
58 */
59 export function rankedBuilder(
60 builders: readonly Agent[],
61 preference: Readonly<Record<string, number>>,
62 choice: string,
63 inFlight: ReadonlyMap<string, number>,
64 ): Agent | undefined {
65 const score = (b: Agent) => (b.user.username === choice ? Infinity : 0);
66 const ranked = [...builders].sort(
67 (a, b) =>
68 (preference[b.user.username] ?? 0) - (preference[a.user.username] ?? 0) || score(b) - score(a),
69 );
70 return ranked.find((b) => (inFlight.get(b.user.id) ?? 0) === 0) ?? ranked[0];
71 }
72
5273 /** The rule for size: how much was said, roughly how much there is to do. */
5374 export function sizeByWords(text: string): Size {
5475 const words = text
@@ -160,15 +181,15 @@ export async function routeIssue(
160181 questions,
161182 rule,
162183 );
184 const preferred = decided.answers.builder;
163185 const builder =
164 builders.find((b) => b.user.username === decided.answers.builder.choice) ??
165 byRule;
186 own ?? rankedBuilder(builders, preferred.probabilities, preferred.choice, inFlight) ?? byRule;
166187 return {
167188 builder,
168189 size: decided.answers.size.choice as Size,
169190 kind: decided.answers.kind.choice as Kind,
170191 by: decided.by,
171192 ms: decided.ms,
172 p: decided.answers.builder.p,
193 p: own ? preferred.p : (preferred.probabilities[builder.user.username] ?? 0),
173194 };
174195 }
test/crew-ranked-pick.test.ts added +87
@@ -0,0 +1,87 @@
1 /**
2 * The decider's preference is a ranking, not an exclusive pick (#7006):
3 * with the favourite builder busy and another idle, the picker hands the
4 * startable issue to the idle one, so the slots run more than one builder.
5 */
6 import { afterAll, beforeAll, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { addToCrew, createAgent } from "../src/actions/crew.ts";
12 import { openIssue } from "../src/actions/issues.ts";
13 import { commitToBranch, createRepo, updateRepoSettings } from "../src/actions/repos.ts";
14 import { listCrewRoles } from "../src/core/agents.ts";
15 import { getIssueById } from "../src/core/issues.ts";
16 import { getRepoById, type Repo } from "../src/core/repos.ts";
17 import { createRun, listActive } from "../src/core/runs.ts";
18 import { createUser, getUserByName, type User } from "../src/core/users.ts";
19 import { driveRepo, idleBuilders } from "../src/crew/autonomy.ts";
20 import type { Decider } from "../src/crew/decide.ts";
21
22 /** Prefers scout for every builder question; the first option for anything else. */
23 const likesScout: Decider = {
24 name: "likes-scout",
25 async decide(_state, questions) {
26 const out: Record<string, unknown> = {};
27 for (const [key, q] of Object.entries(questions)) {
28 const choice = key === "builder" && q.options.includes("scout") ? "scout" : q.options[0]!;
29 const probabilities = Object.fromEntries(q.options.map((o) => [o, o === choice ? 0.9 : 0.1 / (q.options.length - 1 || 1)]));
30 out[key] = { choice, p: probabilities[choice], probabilities };
31 }
32 return out as never;
33 },
34 };
35
36 let dataDir: string;
37 let app: App;
38 let alice: User;
39 let repo: Repo;
40
41 beforeAll(async () => {
42 dataDir = await mkdtemp(join(tmpdir(), "forge-ranked-pick-"));
43 app = createApp({ dataDir, port: 0, decider: likesScout });
44 const a = await createUser(app.db, {
45 origin: "seed", trusted: true, username: "alice", email: "[email protected]",
46 password: "a-good-passphrase", adminIfFirst: true,
47 });
48 if (!a.ok) throw new Error(a.error);
49 alice = a.value;
50 const made = await createRepo(app, alice, { name: "notes", visibility: "private" });
51 if (!made.ok) throw new Error(String(made.error));
52 repo = made.value;
53 const first = await commitToBranch(app, alice, repo, {
54 message: "Start",
55 files: [{ path: "README.md", content: "# notes\n" }],
56 });
57 if (!first.ok) throw new Error(JSON.stringify(first.error));
58 for (const name of ["mason", "scout"]) {
59 const agent = await createAgent(app, alice, { username: name });
60 if (!agent.ok) throw new Error(String(agent.error));
61 expect(addToCrew(app, alice, repo, name).ok).toBe(true);
62 }
63 const on = await updateRepoSettings(app, alice, repo, {
64 autonomy: "autonomous",
65 autonomyDirection: "Keep the notes tidy.",
66 autonomyCeilingUsd: 10,
67 autonomySlots: 3,
68 autonomyBy: alice.id,
69 });
70 expect(on.ok).toBe(true);
71 });
72
73 afterAll(async () => {
74 app.close();
75 await rm(dataDir, { recursive: true, force: true });
76 });
77
78 test("with scout busy and mason idle, the startable issue goes to mason", async () => {
79 const scout = getUserByName(app.db, "scout")!;
80 createRun(app.db, { repoId: repo.id, kind: "agent", actorId: scout.id, reason: "assigned" });
81 const crew = listCrewRoles(app.db, repo.id);
82 expect(idleBuilders(listActive(app.db, "agent"), crew)).toBe(1);
83 const issue = openIssue(app, alice, getRepoById(app.db, repo.id)!, { title: "Sort the notes by date" });
84 if (!issue.ok) throw new Error(String(issue.error));
85 expect(await driveRepo(app, getRepoById(app.db, repo.id)!)).toBe("picked");
86 expect(getIssueById(app.db, issue.value.id)!.assigneeId).toBe(getUserByName(app.db, "mason")!.id);
87 });
test/decide.test.ts +11
@@ -347,6 +347,17 @@ describe("routing an issue", () => {
347347 expect(kept?.builder.user.username).toBe("scout");
348348 expect(kept?.by).toBe("rule");
349349 expect(asked).toHaveLength(1);
350 // The preference is a ranking: with scout busy and mason idle, mason
351 // takes it; with both busy, the favourite does (#7006).
352 const next = openIssue(withJev, alice, repo, { title: "Ship another banner" });
353 if (!next.ok) throw new Error(String(next.error));
354 const crew = listCrewRoles(withJev.db, repo.id);
355 const mason = getUserByName(withJev.db, "mason")!;
356 const busy = await routeIssue(withJev, repo, next.value, crew, new Map([[scout.id, 1]]));
357 expect(busy?.builder.user.username).toBe("mason");
358 expect(busy?.p).toBe(0.1);
359 const allBusy = await routeIssue(withJev, repo, next.value, crew, new Map([[scout.id, 1], [mason.id, 1]]));
360 expect(allBusy?.builder.user.username).toBe("scout");
350361 withJev.close();
351362 });
352363 });
test/finishing-pages.test.ts +15 −1
@@ -13,6 +13,7 @@ import { addToCrew, createAgent } from "../src/actions/crew.ts";
1313 import { openPull } from "../src/actions/pulls.ts";
1414 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
1515 import { setCrewRole } from "../src/core/agents.ts";
16 import { createRun } from "../src/core/runs.ts";
1617 import type { Pull } from "../src/core/pulls.ts";
1718 import { getRepoById, type Repo } from "../src/core/repos.ts";
1819 import { submitReview } from "../src/core/reviews.ts";
@@ -125,7 +126,20 @@ test("said once per pull and reason: the same pass again logs nothing", async ()
125126 expect(lines).toEqual([]);
126127 });
127128
128 test("an approved, conflicted pull older than fifty newer ones is brought up to date", async () => {
129 test("an approved, conflicted pull older than fifty newer ones is brought up to date, by its busy author", async () => {
130 // Its author is busy and another builder idle: the update still goes to
131 // the author, who can push the branch, queued behind that run (#7006).
132 const torque = await createAgent(app, alice, { username: "torque" });
133 if (!torque.ok) throw new Error(String(torque.error));
134 expect(addToCrew(app, alice, repo, "torque").ok).toBe(true);
135 const elsewhere = await pullOn(NEWER + 1, "export const busy = 1;\n");
136 createRun(app.db, {
137 repoId: repo.id,
138 kind: "agent",
139 issueId: elsewhere.id,
140 actorId: getUserByName(app.db, "mason")!.id,
141 reason: "assigned",
142 });
129143 const moved = await commitToBranch(app, alice, repo, {
130144 branch: "main",
131145 message: "Zero, main's way",
test/rebase-lane.test.ts +10 −19
@@ -22,7 +22,7 @@ import type { Repo } from "../src/core/repos.ts";
2222 import { cancelRun, createRun, getRun, listActive } from "../src/core/runs.ts";
2323 import { createUser, type User } from "../src/core/users.ts";
2424 import { readConflictLane } from "../src/crew/conflict_room.ts";
25 import { finishFields, lentBuilder, ownBuilder } from "../src/crew/finishing.ts";
25 import { finishFields, ownBuilder } from "../src/crew/finishing.ts";
2626 import { serve } from "../src/http/server.ts";
2727 import {
2828 conflictedPulls,
@@ -134,30 +134,21 @@ describe("the room counts only the checks in front of a rebase", () => {
134134 });
135135 });
136136
137 describe("a busy builder's rebases go to idle builders", () => {
137 describe("a pull's rebase goes to the builder who can push it (#7006)", () => {
138138 const builder = (id: string): Agent => ({ user: { id, username: id } }) as unknown as Agent;
139139 const [ada, bo, cy, di] = ["ada", "bo", "cy", "di"].map(builder) as [Agent, Agent, Agent, Agent];
140140 const crew = [di, bo, ada, cy];
141141
142 test("the pull's own builder is the agent on it, else its author", () => {
142 test("the fork's owner, else the author, else the agent on it", () => {
143143 const on = { assigneeId: "cy", authorId: "bo" } as Issue;
144 expect(ownBuilder(on, crew, new Map())?.user.id).toBe("cy");
145 expect(ownBuilder({ ...on, assigneeId: null } as Issue, crew, new Map())?.user.id).toBe("bo");
144 expect(ownBuilder(on, crew, new Map(), "ada")?.user.id).toBe("ada");
145 expect(ownBuilder(on, crew, new Map(), "alice")?.user.id).toBe("bo");
146 expect(ownBuilder({ ...on, authorId: "alice" } as Issue, crew, new Map())?.user.id).toBe("cy");
146147 });
147148
148 test("an idle own builder keeps its pull; a busy one lends it round-robin among the idle", () => {
149 expect(lentBuilder(ada, crew, new Map())).toBeUndefined();
150 const load = new Map([["ada", 1], ["di", 2]]);
151 const lent = [1, 2, 3, 4].map(() => lentBuilder(ada, crew, load)?.user.id);
152 // Only bo and cy are idle; the turns alternate between them.
153 expect(new Set(lent)).toEqual(new Set(["bo", "cy"]));
154 expect(lent[0]).not.toBe(lent[1]);
155 expect(lent[1]).not.toBe(lent[2]);
156 });
157
158 test("with every builder busy, the update stays with its own builder", () => {
159 const load = new Map(crew.map((b) => [b.user.id, 1]));
160 expect(lentBuilder(ada, crew, load)).toBeUndefined();
149 test("a busy author keeps the rebase: it is never lent to an idle builder", () => {
150 const load = new Map([["bo", 1]]);
151 expect(ownBuilder({ assigneeId: null, authorId: "bo" } as Issue, crew, load, "bo")?.user.id).toBe("bo");
161152 });
162153 });
163154
@@ -170,7 +161,7 @@ describe("the lane reads as conflicted N, rebasing M, room R", () => {
170161 test("the tick line carries the lane beside the finishing counts", () => {
171162 const fields = finishFields(
172163 [
173 { pull: 1, did: "queued update", builder: "bo", for: "ada" },
164 { pull: 1, did: "queued update", builder: "bo" },
174165 { pull: 2, did: "not moved", why: "waits for room in the check lane" },
175166 ],
176167 { runs: 1, checks: 2, room: 0 },

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs