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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/7006-builder-routing-rebases-go-to-a-builder → main 2 commits fork
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 | ||
| 35 | 35 | queued check (`checks/finishing_first.ts`). So the room for more rebases |
| 36 | 36 | (`crew/conflict_room.ts`) is the check slots minus the rebases' checks and the |
| 37 | 37 | 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`. | |
| 42 | 49 | |
| 43 | 50 | The tick line's `lane` field, the `Rebases:` line on `/flow` and in the |
| 44 | 51 | 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( | ||
| 282 | 282 | ); |
| 283 | 283 | } |
| 284 | 284 | |
| 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 | ||
| 285 | 298 | export function autonomyStatus( |
| 286 | 299 | app: App, |
| 287 | 300 | repo: Repo, |
| @@ -1287,13 +1300,9 @@ export async function autonomyTick( | ||
| 1287 | 1300 | const status = autonomyStatus(app, current, now); |
| 1288 | 1301 | const approved = waitWords(lastWaits(current.id)); |
| 1289 | 1302 | 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); | |
| 1297 | 1306 | const boots = bootCounts(app.config.dataDir); |
| 1298 | 1307 | const staleToday = closedToday(app, current.id, now); |
| 1299 | 1308 | // Says when the governor lowers the slots (#6375); one feed item per change. |
| @@ -1319,6 +1328,8 @@ export async function autonomyTick( | ||
| 1319 | 1328 | ...flowVitals(app.db, now), |
| 1320 | 1329 | active: status.active, |
| 1321 | 1330 | slots: status.slots, |
| 1331 | // Builders with nothing in flight: what bounds active beside slots and startable (#7006). | |
| 1332 | idle: idleBuilders(activeNow, crewNow), | |
| 1322 | 1333 | ...(governor === undefined ? {} : { governor }), |
| 1323 | 1334 | // Approved conflicted pulls: queued, in flight, and why each other is |
| 1324 | 1335 | // 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 @@ | ||
| 10 | 10 | * - With a run already in flight on it, it is finishing: nothing more. |
| 11 | 11 | * - With a slot free, a conflict run (the #2039 resolver brief: merge the |
| 12 | 12 | * 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). | |
| 19 | 18 | * - Its push is a check, so it also waits for room in the check lane, as |
| 20 | 19 | * the conflict sweep's runs do (`conflict_room.ts`): a landing that |
| 21 | 20 | * 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"; | ||
| 48 | 47 | import { getHold } from "../core/holds.ts"; |
| 49 | 48 | import { getIssueById, type Issue } from "../core/issues.ts"; |
| 50 | 49 | 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"; | |
| 52 | 51 | import { reviewSummary } from "../core/reviews.ts"; |
| 53 | 52 | import { activeRunFor, listActive } from "../core/runs.ts"; |
| 54 | 53 | import { getUserById } from "../core/users.ts"; |
| @@ -264,11 +263,10 @@ function finishOne( | ||
| 264 | 263 | // A person's name on it is a person holding it — after a hand-over too. |
| 265 | 264 | if (issue.assigneeId !== null && getAgent(app.db, issue.assigneeId) === undefined) |
| 266 | 265 | 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); | |
| 272 | 270 | if (slots <= 0) return not(WAITS_FOR_BUILDER_SLOT); |
| 273 | 271 | if (room <= 0) return not("waits for room in the check lane"); |
| 274 | 272 | const run = queueAgentRun(app, repo, issue, builder, "conflict", undefined, { |
| @@ -277,48 +275,28 @@ function finishOne( | ||
| 277 | 275 | summary: `main moved: ${files.join(", ")}`, |
| 278 | 276 | }); |
| 279 | 277 | 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 }; | |
| 286 | 279 | } |
| 287 | 280 | |
| 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 | */ | |
| 289 | 286 | export function ownBuilder( |
| 290 | 287 | issue: Issue, |
| 291 | 288 | builders: readonly Agent[], |
| 292 | 289 | load: ReadonlyMap<string, number>, |
| 290 | forkOwner?: string, | |
| 293 | 291 | ): Agent | undefined { |
| 294 | 292 | return ( |
| 295 | builders.find((b) => b.user.id === issue.assigneeId) ?? | |
| 293 | builders.find((b) => b.user.id === forkOwner) ?? | |
| 296 | 294 | builders.find((b) => b.user.id === issue.authorId) ?? |
| 295 | builders.find((b) => b.user.id === issue.assigneeId) ?? | |
| 297 | 296 | pickBuilder(issue, builders, load) |
| 298 | 297 | ); |
| 299 | 298 | } |
| 300 | 299 | |
| 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 | ||
| 322 | 300 | /** |
| 323 | 301 | * Conflict runs that ended since a person last spoke on the pull and since |
| 324 | 302 | * 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 { | ||
| 394 | 372 | repo: where, |
| 395 | 373 | pull: pull.number, |
| 396 | 374 | builder: move.builder, |
| 397 | ...(move.for === undefined ? {} : { for: move.for }), | |
| 398 | 375 | }); |
| 399 | 376 | return; |
| 400 | 377 | } |
src/crew/route.ts +24 −3
| @@ -49,6 +49,27 @@ export function pickBuilder( | ||
| 49 | 49 | )[0]; |
| 50 | 50 | } |
| 51 | 51 | |
| 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 | ||
| 52 | 73 | /** The rule for size: how much was said, roughly how much there is to do. */ |
| 53 | 74 | export function sizeByWords(text: string): Size { |
| 54 | 75 | const words = text |
| @@ -160,15 +181,15 @@ export async function routeIssue( | ||
| 160 | 181 | questions, |
| 161 | 182 | rule, |
| 162 | 183 | ); |
| 184 | const preferred = decided.answers.builder; | |
| 163 | 185 | 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; | |
| 166 | 187 | return { |
| 167 | 188 | builder, |
| 168 | 189 | size: decided.answers.size.choice as Size, |
| 169 | 190 | kind: decided.answers.kind.choice as Kind, |
| 170 | 191 | by: decided.by, |
| 171 | 192 | ms: decided.ms, |
| 172 | p: decided.answers.builder.p, | |
| 193 | p: own ? preferred.p : (preferred.probabilities[builder.user.username] ?? 0), | |
| 173 | 194 | }; |
| 174 | 195 | } |
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", () => { | ||
| 347 | 347 | expect(kept?.builder.user.username).toBe("scout"); |
| 348 | 348 | expect(kept?.by).toBe("rule"); |
| 349 | 349 | 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"); | |
| 350 | 361 | withJev.close(); |
| 351 | 362 | }); |
| 352 | 363 | }); |
test/finishing-pages.test.ts +15 −1
| @@ -13,6 +13,7 @@ import { addToCrew, createAgent } from "../src/actions/crew.ts"; | ||
| 13 | 13 | import { openPull } from "../src/actions/pulls.ts"; |
| 14 | 14 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; |
| 15 | 15 | import { setCrewRole } from "../src/core/agents.ts"; |
| 16 | import { createRun } from "../src/core/runs.ts"; | |
| 16 | 17 | import type { Pull } from "../src/core/pulls.ts"; |
| 17 | 18 | import { getRepoById, type Repo } from "../src/core/repos.ts"; |
| 18 | 19 | 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 () | ||
| 125 | 126 | expect(lines).toEqual([]); |
| 126 | 127 | }); |
| 127 | 128 | |
| 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 | }); | |
| 129 | 143 | const moved = await commitToBranch(app, alice, repo, { |
| 130 | 144 | branch: "main", |
| 131 | 145 | message: "Zero, main's way", |
test/rebase-lane.test.ts +10 −19
| @@ -22,7 +22,7 @@ import type { Repo } from "../src/core/repos.ts"; | ||
| 22 | 22 | import { cancelRun, createRun, getRun, listActive } from "../src/core/runs.ts"; |
| 23 | 23 | import { createUser, type User } from "../src/core/users.ts"; |
| 24 | 24 | 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"; | |
| 26 | 26 | import { serve } from "../src/http/server.ts"; |
| 27 | 27 | import { |
| 28 | 28 | conflictedPulls, |
| @@ -134,30 +134,21 @@ describe("the room counts only the checks in front of a rebase", () => { | ||
| 134 | 134 | }); |
| 135 | 135 | }); |
| 136 | 136 | |
| 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)", () => { | |
| 138 | 138 | const builder = (id: string): Agent => ({ user: { id, username: id } }) as unknown as Agent; |
| 139 | 139 | const [ada, bo, cy, di] = ["ada", "bo", "cy", "di"].map(builder) as [Agent, Agent, Agent, Agent]; |
| 140 | 140 | const crew = [di, bo, ada, cy]; |
| 141 | 141 | |
| 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", () => { | |
| 143 | 143 | 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"); | |
| 146 | 147 | }); |
| 147 | 148 | |
| 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"); | |
| 161 | 152 | }); |
| 162 | 153 | }); |
| 163 | 154 | |
| @@ -170,7 +161,7 @@ describe("the lane reads as conflicted N, rebasing M, room R", () => { | ||
| 170 | 161 | test("the tick line carries the lane beside the finishing counts", () => { |
| 171 | 162 | const fields = finishFields( |
| 172 | 163 | [ |
| 173 | { pull: 1, did: "queued update", builder: "bo", for: "ada" }, | |
| 164 | { pull: 1, did: "queued update", builder: "bo" }, | |
| 174 | 165 | { pull: 2, did: "not moved", why: "waits for room in the check lane" }, |
| 175 | 166 | ], |
| 176 | 167 | { runs: 1, checks: 2, room: 0 }, |
new above
offline