Public: anyone can read this; the crew can change it.
A tick starts one builder run per free slot, not one issue per tick #8796
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/8729-a-tick-starts-one-builder-run-per-free-s → main 1 commit fork
3 files
changed +92
−24
src/crew/autonomy.ts +33 −6
| @@ -400,13 +400,14 @@ function freeSlots(app: App, repo: Repo, now: Date): number { | ||
| 400 | 400 | } |
| 401 | 401 | |
| 402 | 402 | /** The pickable issues in slot order: the owner's raised on top, then the roadmap, small last and none past their share (#7571). */ |
| 403 | function inSlotOrder(app: App, repo: Repo, ready: readonly Ranked[], slotted: readonly Run[], slots: number): Ranked[] { | |
| 403 | function inSlotOrder(app: App, repo: Repo, ready: readonly Ranked[], slotted: readonly Run[], slots: number, picked: ReadonlySet<string> = new Set()): Ranked[] { | |
| 404 | 404 | const issues = ready.map((r) => r.issue); |
| 405 | 405 | const small = smallIssues(app.db, issues); |
| 406 | 406 | const roadmap = roadmapIssues(app.db, repo, issues.map((i) => i.id)); |
| 407 | 407 | const first = firstAt(app.db, issues.map((i) => i.id)); |
| 408 | 408 | const raised = raisedAt(app.db, issues.map((i) => i.id)); |
| 409 | const running = smallOfIds(app.db, slotted.flatMap((r) => (r.issueId === null ? [] : [r.issueId]))); | |
| 409 | // Issues picked earlier in this tick hold their share before their runs show (#8729). | |
| 410 | const running = smallOfIds(app.db, [...new Set([...slotted.flatMap((r) => (r.issueId === null ? [] : [r.issueId])), ...picked])]); | |
| 410 | 411 | return slotOrder( |
| 411 | 412 | ready, |
| 412 | 413 | (r) => ({ |
| @@ -443,6 +444,27 @@ export async function driveRepo( | ||
| 443 | 444 | app: App, |
| 444 | 445 | repo: Repo, |
| 445 | 446 | now = new Date(), |
| 447 | ): Promise<Drove> { | |
| 448 | // One start per free builder slot (#8729). Each pass re-reads the ceiling, | |
| 449 | // the paced slots and the held filters, so a start the dispatcher refused | |
| 450 | // or a spend that reached the wall ends the tick's starts there. | |
| 451 | // An issue picked this tick may not have its run yet: it is kept out of the | |
| 452 | // next pass and counted against the slots until the run shows. | |
| 453 | const taken = new Set<string>(); | |
| 454 | const did = await driveOnce(app, repo, now, taken); | |
| 455 | if (did !== "picked") return did; | |
| 456 | for (let n = 1; n < Math.max(1, repo.autonomySlots); n++) { | |
| 457 | const next = await driveOnce(app, getRepoById(app.db, repo.id) ?? repo, now, taken); | |
| 458 | if (next !== "picked") break; | |
| 459 | } | |
| 460 | return did; | |
| 461 | } | |
| 462 | ||
| 463 | async function driveOnce( | |
| 464 | app: App, | |
| 465 | repo: Repo, | |
| 466 | now: Date, | |
| 467 | taken: Set<string>, | |
| 446 | 468 | ): Promise<Drove> { |
| 447 | 469 | if (repo.autonomy === "off") return "off"; |
| 448 | 470 | const status = autonomyStatus(app, repo, now); |
| @@ -452,6 +474,9 @@ export async function driveRepo( | ||
| 452 | 474 | const crew = listCrewRoles(app.db, repo.id); |
| 453 | 475 | const active = activeRuns(app, repo.id); |
| 454 | 476 | const slotted = slottedRuns(active, crew); |
| 477 | // Picked earlier in this tick and not yet running: still a slot spent. | |
| 478 | const running = new Set(active.map((r) => r.issueId)); | |
| 479 | const pending = [...taken].filter((id) => !running.has(id)).length; | |
| 455 | 480 | // Slots follow the subscriptions' remaining windows, never past the |
| 456 | 481 | // operator's; at a wall nothing new starts until the reset. |
| 457 | 482 | const paced = repoPace(app, repo, now); |
| @@ -478,10 +503,11 @@ export async function driveRepo( | ||
| 478 | 503 | ), |
| 479 | 504 | held.relieving, |
| 480 | 505 | ); |
| 481 | const ready = held.startable; | |
| 482 | if (slotted.length < paced.slots && releaseForPace(app, repo, by, slotted.length, ready.length, now) !== undefined) | |
| 506 | const ready = held.startable.filter((r) => !taken.has(r.issue.id)); | |
| 507 | const used = slotted.length + pending; | |
| 508 | if (used < paced.slots && releaseForPace(app, repo, by, used, ready.length, now) !== undefined) | |
| 483 | 509 | return "released"; |
| 484 | if (slotted.length >= paced.slots) { | |
| 510 | if (used >= paced.slots) { | |
| 485 | 511 | // The builders are busy; the planner can still refill the map, when |
| 486 | 512 | // the reserve is short — counted, never assumed: a full crew with a |
| 487 | 513 | // full reserve is not a reason to plan (#1097, #3237). |
| @@ -508,8 +534,9 @@ export async function driveRepo( | ||
| 508 | 534 | ) |
| 509 | 535 | ); |
| 510 | 536 | // The roadmap first, and small issues held to their share of the slots (#7571). |
| 511 | const next = await nextOf(app, repo, inSlotOrder(app, repo, pickable, slotted, paced.slots)); | |
| 537 | const next = await nextOf(app, repo, inSlotOrder(app, repo, pickable, slotted, paced.slots, taken)); | |
| 512 | 538 | if (next !== undefined) { |
| 539 | taken.add(next.issue.id); | |
| 513 | 540 | const routed = await routeIssue(app, repo, next.issue, crewFor(next.issue), inFlight); |
| 514 | 541 | if (!routed) return "paused"; |
| 515 | 542 | const builder = routed.builder; |
test/crew-held.test.ts +40
| @@ -225,6 +225,46 @@ describe("the tick says why each ready issue cannot start", () => { | ||
| 225 | 225 | expect(heldWords(held)).toBe("open_pull=1"); |
| 226 | 226 | }); |
| 227 | 227 | |
| 228 | test("one tick starts a run per free slot, and a held issue never takes one (#8729)", async () => { | |
| 229 | const { repo: made, mason } = await crewed(app, alice, "slots"); | |
| 230 | for (const name of ["mason2", "mason3"]) { | |
| 231 | const created = await createAgent(app, alice, { username: name }); | |
| 232 | if (!created.ok) throw new Error(String(created.error)); | |
| 233 | expect(addToCrew(app, alice, getRepoById(app.db, made.id)!, name).ok).toBe(true); | |
| 234 | } | |
| 235 | const set = await updateRepoSettings(app, alice, getRepoById(app.db, made.id)!, { autonomySlots: 3 }); | |
| 236 | if (!set.ok) throw new Error(JSON.stringify(set.error)); | |
| 237 | const repo = getRepoById(app.db, made.id)!; | |
| 238 | const seeded = await commitToBranch(app, alice, repo, { | |
| 239 | message: "Start", | |
| 240 | files: [{ path: "README.md", content: "# slots\n" }], | |
| 241 | }); | |
| 242 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 243 | const held = issue(repo, "Alpha rounding of corners"); | |
| 244 | const rest = [ | |
| 245 | issue(repo, "Bravo caching of thumbnails"), | |
| 246 | issue(repo, "Charlie escaping of titles"), | |
| 247 | issue(repo, "Delta printing of receipts"), | |
| 248 | ]; | |
| 249 | // The first has a pull opened a moment ago, so the dispatcher would refuse it. | |
| 250 | const headBranch = `mason/${held.number}-alpha`; | |
| 251 | const branch = await commitToBranch(app, alice, repo, { | |
| 252 | branch: headBranch, | |
| 253 | base: "main", | |
| 254 | message: "Alpha", | |
| 255 | files: [{ path: "alpha.ts", content: "export const n = 1;\n" }], | |
| 256 | }); | |
| 257 | if (!branch.ok) throw new Error(JSON.stringify(branch.error)); | |
| 258 | const pull = await openPull(app, mason, repo, { title: "Alpha", body: "Done.", baseBranch: "main", headBranch }); | |
| 259 | if (!pull.ok) throw new Error(String(pull.error)); | |
| 260 | ||
| 261 | expect(await driveRepo(app, getRepoById(app.db, repo.id)!)).toBe("picked"); | |
| 262 | // Assigning is the event that starts a run: exactly the three startable issues are assigned. | |
| 263 | const taken = [held, ...rest].filter((i) => getIssueById(app.db, i.id)!.assigneeId !== null).map((i) => i.id); | |
| 264 | expect(taken.sort()).toEqual(rest.map((i) => i.id).sort()); | |
| 265 | expect(heldWords(heldNow(app, getRepoById(app.db, repo.id)!))).toContain("open_pull=1"); | |
| 266 | }); | |
| 267 | ||
| 228 | 268 | test("a sentinel finding is a builder's to start on the next tick, never parked on the sentinel (#3002)", async () => { |
| 229 | 269 | const { repo, mason } = await crewed(app, alice, "watched"); |
| 230 | 270 | const fresh = () => getRepoById(app.db, repo.id)!; |
new above
offline