Public: anyone can read this; the crew can change it.
The planner keeps a standing reserve of startable work: two per builder slot, planned before the plate runs dry #3246
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/3237-the-planner-keeps-a-standing-reserve-of → main 1 commit fork 1 thread · 1 unresolved
6 files
changed +326
−33
docs/PLANNING.md +12 −7
| @@ -421,10 +421,12 @@ asked, under **Settings → Autonomy**: | ||
| 421 | 421 | could not be brought up to date, is _parked_ — closed with the reason, |
| 422 | 422 | its issue put back on the map unassigned with the same note — and the |
| 423 | 423 | crew moves on; the planner's next pass splits or rewrites a parked |
| 424 | issue, and one parked twice is handed to the planner (below). A dry map is | |
| 425 | planned again within twenty minutes, and the planner is told the | |
| 426 | direction is never finished: when what it names is built, it proposes | |
| 427 | the next increment and leaves at least three ready issues. The reviewer | |
| 424 | issue, and one parked twice is handed to the planner (below). The planner | |
| 425 | keeps a standing reserve of startable work — two issues per builder | |
| 426 | slot, never fewer than three — and is asked whenever fewer than that can | |
| 427 | start, not only when the map runs dry. It is told the direction is never | |
| 428 | finished: when what it names is built, it proposes the next increment, | |
| 429 | and a pass that leaves the reserve short says why on the direction. The reviewer | |
| 428 | 430 | gets two rounds of changes on a pull; the third hands the pull to a |
| 429 | 431 | person. The only pause is the day's ceiling. A person |
| 430 | 432 | steers by editing the direction and the map; the crew does the rest. When nothing is |
| @@ -447,12 +449,15 @@ map runs dry the forge opens an issue titled _Direction: …_ that carries | ||
| 447 | 449 | it, assigns the planner, and the planner's brief says the rest: open |
| 448 | 450 | issues each small enough for one run, link what waits for what, assign |
| 449 | 451 | nobody, push nothing. The direction issue stays open, is never picked as |
| 450 | work, and the planner is not asked again for twenty minutes unless its run | |
| 451 | ended. Nor is it asked again with nothing new: when nobody but the planner | |
| 452 | work. A pass that did not end well — a decline a person answered, a | |
| 453 | failure — is not followed by another for twenty minutes; one that ended | |
| 454 | well has no floor. Nor is it asked again with nothing new: when nobody but the planner | |
| 452 | 455 | has spoken on the direction since its last pass began, and no issue has |
| 453 | 456 | been opened by anyone else or closed since that pass ended, the clock |
| 454 | 457 | skips it and records one `guard` decision by rule. A busy crew counts the |
| 455 | ready issues before it plans; full slots alone are not a thin map. | |
| 458 | startable issues before it plans; full slots alone are not a short | |
| 459 | reserve. Every tick line says `reserve=startable/target` | |
| 460 | (`src/crew/reserve.ts`). | |
| 456 | 461 | |
| 457 | 462 | **What counts as done and as taken.** A pull that says _Closes #12_ |
| 458 | 463 | closes #12 when it lands, as the person who landed it; that is what frees |
src/crew/autonomy.ts +24 −22
| @@ -108,6 +108,7 @@ import { | ||
| 108 | 108 | parkReason, |
| 109 | 109 | parkReasons, |
| 110 | 110 | } from "./parks.ts"; |
| 111 | import { reserveOf, reserveWords, short } from "./reserve.ts"; | |
| 111 | 112 | import { createLog } from "../kernel/log.ts"; |
| 112 | 113 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 113 | 114 | import { |
| @@ -123,12 +124,11 @@ import { lastWaits, noteWaits, tallyWaits, type Wait, waitWords } from "./waits. | ||
| 123 | 124 | const log = createLog("autonomy"); |
| 124 | 125 | |
| 125 | 126 | export const AUTONOMY_TICK_MS = 60_000; |
| 126 | /** A planner is not asked again sooner than this unless the map changed. */ | |
| 127 | 127 | /** |
| 128 | * How soon a dry map is planned again. A repository on its own is never | |
| 129 | * finished: when nothing is ready and nothing is in flight, the planner | |
| 130 | * is asked for the next increment, with only a short floor so a planner | |
| 131 | * that produced nothing is not asked every minute. | |
| 128 | * How soon a planner whose last pass did not end well — a decline a person | |
| 129 | * answered, a failure — is asked again. A pass that ended well has no | |
| 130 | * floor: while the reserve is short the planner is asked again as soon as | |
| 131 | * the map moves, and a pass that moved nothing is held by `skipsReplan`. | |
| 132 | 132 | */ |
| 133 | 133 | const REPLAN_AFTER_MS = 20 * 60_000; |
| 134 | 134 | |
| @@ -368,8 +368,8 @@ export async function driveRepo( | ||
| 368 | 368 | const ready = held.startable; |
| 369 | 369 | if (slotted.length >= paced.slots) { |
| 370 | 370 | // The builders are busy; the planner can still refill the map, when |
| 371 | // it is thin — counted, never assumed: a full crew with a full map is | |
| 372 | // not a reason to plan (#1097). | |
| 371 | // the reserve is short — counted, never assumed: a full crew with a | |
| 372 | // full reserve is not a reason to plan (#1097, #3237). | |
| 373 | 373 | if (await planIfThin(app, repo, by, crew, active, ready.length, now)) |
| 374 | 374 | return "planned"; |
| 375 | 375 | return "full"; |
| @@ -407,17 +407,16 @@ export async function driveRepo( | ||
| 407 | 407 | size: routed.size, |
| 408 | 408 | by: routed.by, |
| 409 | 409 | }); |
| 410 | // Planning runs alongside building: when the ready list is thin, the | |
| 410 | // Planning runs alongside building: when the reserve is short, the | |
| 411 | 411 | // planner is asked now, not when the builders have run it dry. |
| 412 | 412 | await planIfThin(app, repo, by, crew, active, ready.length - 1, now); |
| 413 | 413 | return "picked"; |
| 414 | 414 | } |
| 415 | 415 | |
| 416 | // Nothing is startable with slots free: the planner is asked, thin or | |
| 417 | // dry, at most every REPLAN_AFTER_MS. When ready issues are held by what | |
| 418 | // re-planning lifts — stalled, capped, parked twice — it is asked with | |
| 419 | // that breakdown, and neither a full plate nor its own unanswered | |
| 420 | // decline holds it: nothing else will move them (#2244). | |
| 416 | // Nothing is startable with slots free: the planner is asked. When | |
| 417 | // ready issues are held by what re-planning lifts — stalled, capped, | |
| 418 | // parked twice — it is asked with that breakdown, and neither a full | |
| 419 | // plate nor its own unanswered decline holds it: nothing else will move them (#2244). | |
| 421 | 420 | const stuck = replannable(held).length > 0 ? held : null; |
| 422 | 421 | if (await planIfThin(app, repo, by, crew, active, 0, now, stuck)) return "planned"; |
| 423 | 422 | return "idle"; |
| @@ -462,12 +461,9 @@ export function startable( | ||
| 462 | 461 | return heldBy(app, repo, ready, crew, active).startable; |
| 463 | 462 | } |
| 464 | 463 | |
| 465 | /** The map is thin below this many ready issues; the planner refills it. */ | |
| 466 | const THIN_MAP = 3; | |
| 467 | ||
| 468 | 464 | /** |
| 469 | * Give the planner the direction when the map is thin — fewer than a few | |
| 470 | * ready issues — or when the crew has handed it an issue it parked twice, | |
| 465 | * Give the planner the direction when the reserve is short — fewer | |
| 466 | * startable issues than two per builder slot (`reserve.ts`) — or when the crew has handed it an issue it parked twice, | |
| 471 | 467 | * and no planning run is in flight, and the last one is old enough. |
| 472 | 468 | * Returns whether a planning run was set up. Planning runs alongside |
| 473 | 469 | * building: the builders are never waited for. |
| @@ -596,7 +592,8 @@ async function planIfThin( | ||
| 596 | 592 | ): Promise<boolean> { |
| 597 | 593 | const handed = [...parkedTwice(app.db, [repo]), ...handedOverPulls(app.db, repo)]; |
| 598 | 594 | const limbs = pendingReplans(app.db, repo); |
| 599 | if (ready >= THIN_MAP && handed.length === 0 && limbs.length === 0) return false; | |
| 595 | const reserve = reserveOf(ready, repo.autonomySlots); | |
| 596 | if (!short(reserve) && handed.length === 0 && limbs.length === 0) return false; | |
| 600 | 597 | const planner = crew.find((c) => c.role === "planner")?.agent; |
| 601 | 598 | if (planner === undefined) return notPlanned(repo, "no planner on the crew"); |
| 602 | 599 | if (repo.autonomyDirection.trim() === "") |
| @@ -619,7 +616,7 @@ async function planIfThin( | ||
| 619 | 616 | // A limb a person handed over is owed one pass, whatever the map and |
| 620 | 617 | // whatever the planner said of the direction before (#761). |
| 621 | 618 | const limbsOwed = limbs.filter((r) => !given(app.db, repo, r.at)); |
| 622 | if (ready >= THIN_MAP && owed.length === 0 && limbsOwed.length === 0) return false; | |
| 619 | if (!short(reserve) && owed.length === 0 && limbsOwed.length === 0) return false; | |
| 623 | 620 | // Nothing startable for issues held since its last pass that re-planning |
| 624 | 621 | // lifts: owed one pass like a hand-over, once (#2244). |
| 625 | 622 | const unstuck = stuck === null ? [] : replannable(stuck).filter((issue) => heldSince(app, issue, seen)); |
| @@ -643,9 +640,11 @@ async function planIfThin( | ||
| 643 | 640 | return notPlanned(repo, "the planner declined; waiting for a person"); |
| 644 | 641 | if (lastPlan.state === "queued" || lastPlan.state === "running") |
| 645 | 642 | return notPlanned(repo, "a planning run is in flight"); |
| 646 | // A person's hand-over is not made to wait for the last pass's hour. | |
| 643 | // Only a pass that did not end well waits out the floor; a person's | |
| 644 | // hand-over never does. | |
| 647 | 645 | if ( |
| 648 | 646 | limbsOwed.length === 0 && |
| 647 | lastPlan.state !== "ok" && | |
| 649 | 648 | now.getTime() - new Date(lastPlan.created_at).getTime() < |
| 650 | 649 | REPLAN_AFTER_MS |
| 651 | 650 | ) |
| @@ -678,11 +677,12 @@ async function planIfThin( | ||
| 678 | 677 | ? "nothing startable; planner asked" |
| 679 | 678 | : ready === 0 |
| 680 | 679 | ? "map ran dry; planner asked" |
| 681 | : "map is thin; planner asked", | |
| 680 | : "reserve is short; planner asked", | |
| 682 | 681 | { |
| 683 | 682 | repo: `${repo.ownerName}/${repo.name}`, |
| 684 | 683 | planner: planner.user.username, |
| 685 | 684 | ready, |
| 685 | reserve: reserveWords(reserve), | |
| 686 | 686 | ...(owed.length > 0 |
| 687 | 687 | ? { parkedTwice: owed.map((issue) => `#${issue.number}`).join(" ") } |
| 688 | 688 | : {}), |
| @@ -1173,6 +1173,8 @@ export async function autonomyTick( | ||
| 1173 | 1173 | // What the builders could start after the filters (#2143), and |
| 1174 | 1174 | // what holds the rest, per filter (#2244). |
| 1175 | 1175 | startable: held.startable.length, |
| 1176 | // The standing reserve the planner keeps: startable/target (#3237). | |
| 1177 | reserve: reserveWords(reserveOf(held.startable.length, status.slots)), | |
| 1176 | 1178 | held: heldWords(held), |
| 1177 | 1179 | active: status.active, |
| 1178 | 1180 | slots: status.slots, |
src/crew/brief.ts +21 −2
| @@ -15,6 +15,7 @@ import { OUTSIDE_LABEL, OUTSIDE_TITLE, saysOutside } from "../core/reports.ts"; | ||
| 15 | 15 | import type { Repo } from "../core/repos.ts"; |
| 16 | 16 | import { READER } from "./direct.ts"; |
| 17 | 17 | import { newsSince } from "./news.ts"; |
| 18 | import type { Reserve } from "./reserve.ts"; | |
| 18 | 19 | import { |
| 19 | 20 | REVIEW_ROUNDS, |
| 20 | 21 | type Review, |
| @@ -124,7 +125,7 @@ export function systemPrompt(input: BriefInput): string { | ||
| 124 | 125 | "- A worked example:", |
| 125 | 126 | ` curl -sS -X POST -H "authorization: Bearer $FORGE_TOKEN" -H "content-type: application/json" "$FORGE_URL/api/v1/repos/${repo.ownerName}/${repo.name}/plan" -d '{"issues":[{"key":"a","title":"Refuse an unsigned upload","body":"Done: an unsigned upload is a 422.","pillar":"P0"},{"key":"b","title":"Show the upload in the feed","body":"Done: it is listed.","blockedBy":["a"],"pillar":"P1"},{"key":"c","title":"Offer a dark theme","later":true,"why":"No pillar needs it until the feed lands."}]}'`, |
| 126 | 127 | " It answers with what it opened (key and number), the numbers now ready, and those deferred.", |
| 127 | "- The direction is never finished. When what it names is built, propose its next increment in the same spirit: what a demanding user would want next, what is fragile, slow, ugly, untested or undocumented, what the pictures and the previews show is off. Leave the map with at least three ready issues, every time.", | |
| 128 | "- The direction is never finished. When what it names is built, propose its next increment in the same spirit: what a demanding user would want next, what is fragile, slow, ugly, untested or undocumented, what the pictures and the previews show is off. Leave the map with the reserve your task names startable, every time.", | |
| 128 | 129 | '- An issue with a comment that starts "Parked by the crew" was tried and could not be finished as written. Split it into smaller issues, or rewrite what done looks like, or close it with a word if the direction no longer needs it — never leave it as it was. One the crew parked twice is yours: your task lists it, with what you may do with it.', |
| 129 | 130 | "- Closing what you re-plan: PATCH …/issues/N with {\"state\":\"closed\",\"reason\":\"split\",\"into\":[A,B]} (open issues made since it was handed to you, or since the issue itself when it was never handed over), {\"state\":\"closed\",\"reason\":\"dropped\"} for an issue the crew wrote, or {\"state\":\"closed\",\"reason\":\"done\"} only when an \"already done on main\" note on it names commits on main. A person's ask is never yours to drop; nothing else is yours to close, and nothing to reopen.", |
| 130 | 131 | "- Assign nobody and push nothing: the map is the result. The direction issue stays open.", |
| @@ -213,6 +214,8 @@ export interface TaskInput extends BriefInput { | ||
| 213 | 214 | readonly stuck?: Stuck | undefined; |
| 214 | 215 | /** For a planner: each team with a dial above zero and the work it holds (`team_share.ts`). */ |
| 215 | 216 | readonly owed?: readonly Owed[] | undefined; |
| 217 | /** For a planner: the startable issues against the standing reserve (`reserve.ts`). */ | |
| 218 | readonly reserve?: Reserve | undefined; | |
| 216 | 219 | } |
| 217 | 220 | |
| 218 | 221 | /** The reviewer's latest verdict, and the final answer of the run that did not push for it. */ |
| @@ -271,7 +274,7 @@ function taskBody(input: TaskInput): string { | ||
| 271 | 274 | const { issue } = input; |
| 272 | 275 | const out: string[] = []; |
| 273 | 276 | const isAgent = (name: string): boolean => name === input.agent.user.username; |
| 274 | const handed = [parkedTwiceLines(input), limbLines(input), stuckLines(input), teamLines(input)] | |
| 277 | const handed = [reserveLines(input), parkedTwiceLines(input), limbLines(input), stuckLines(input), teamLines(input)] | |
| 275 | 278 | .filter((lines) => lines.length > 0) |
| 276 | 279 | .flatMap((lines, n) => (n === 0 ? lines : ["", ...lines])); |
| 277 | 280 | const readers = input.readers ?? new Set<string>(); |
| @@ -631,6 +634,22 @@ function teamLines(input: TaskInput): string[] { | ||
| 631 | 634 | ]; |
| 632 | 635 | } |
| 633 | 636 | |
| 637 | /** | |
| 638 | * What a planner is told of the standing reserve (#3237): how many issues a | |
| 639 | * builder could start now, how many the crew keeps, and that a pass which | |
| 640 | * leaves it short says why on the direction — the word that keeps the | |
| 641 | * forge from asking again before the map moves. | |
| 642 | */ | |
| 643 | function reserveLines(input: TaskInput): string[] { | |
| 644 | const reserve = input.role === "planner" ? input.reserve : undefined; | |
| 645 | if (reserve === undefined) return []; | |
| 646 | const gap = Math.max(0, reserve.target - reserve.startable); | |
| 647 | return [ | |
| 648 | `The crew keeps a standing reserve of ${reserve.target} startable issues, two per builder slot, so builders never wait for a plan. ${reserve.startable} can start now${gap > 0 ? `: plan at least ${gap} more a builder can start at once — ready, with nothing open in front of it` : ""}.`, | |
| 649 | `If you finish with fewer than ${reserve.target} startable, say why in a comment on #${input.issue.number} before you finish: what the direction has left, or what holds the rest.`, | |
| 650 | ]; | |
| 651 | } | |
| 652 | ||
| 634 | 653 | /** |
| 635 | 654 | * What a planner is told when no ready issue can start while builders sit |
| 636 | 655 | * free: the count per filter, and each stalled or capped issue, to re-plan |
src/crew/reserve.ts added +37
| @@ -0,0 +1,37 @@ | ||
| 1 | /** | |
| 2 | * The standing reserve: how much startable work the planner keeps on the | |
| 3 | * map, so builders never wait between waves for it (#3237). Two issues per | |
| 4 | * builder slot, never fewer than a thin map's three. The autonomy tick asks | |
| 5 | * the planner whenever startable work is below it, the planner's brief | |
| 6 | * names it, and the tick line says it as `reserve=startable/target`. | |
| 7 | */ | |
| 8 | ||
| 9 | /** The map is thin below this many startable issues, whatever the slots. */ | |
| 10 | export const THIN_MAP = 3; | |
| 11 | /** Startable issues the planner keeps per builder slot. */ | |
| 12 | export const RESERVE_PER_SLOT = 2; | |
| 13 | ||
| 14 | /** The reserve for a repository with this many builder slots. */ | |
| 15 | export function reserveTarget(slots: number): number { | |
| 16 | return Math.max(THIN_MAP, RESERVE_PER_SLOT * Math.max(0, slots)); | |
| 17 | } | |
| 18 | ||
| 19 | /** What the planner is told and the tick line says: startable against the target. */ | |
| 20 | export interface Reserve { | |
| 21 | readonly startable: number; | |
| 22 | readonly target: number; | |
| 23 | } | |
| 24 | ||
| 25 | export function reserveOf(startable: number, slots: number): Reserve { | |
| 26 | return { startable, target: reserveTarget(slots) }; | |
| 27 | } | |
| 28 | ||
| 29 | /** `5/14`: the tick line's form. */ | |
| 30 | export function reserveWords(reserve: Reserve): string { | |
| 31 | return `${reserve.startable}/${reserve.target}`; | |
| 32 | } | |
| 33 | ||
| 34 | /** Whether the reserve is short: the planner is owed a pass. */ | |
| 35 | export function short(reserve: Reserve): boolean { | |
| 36 | return reserve.startable < reserve.target; | |
| 37 | } |
src/crew/runner.ts +10 −2
| @@ -114,6 +114,7 @@ import { laterGate } from "./later.ts"; | ||
| 114 | 114 | import { handedLimbs } from "./handed_limbs.ts"; |
| 115 | 115 | import { parkedTwice } from "./parks.ts"; |
| 116 | 116 | import { heldNow, stuckOf } from "./held.ts"; |
| 117 | import { reserveOf } from "./reserve.ts"; | |
| 117 | 118 | import { owed } from "./team_share.ts"; |
| 118 | 119 | import { noteLimit } from "../core/standing.ts"; |
| 119 | 120 | import { pauseReason } from "./pacing.ts"; |
| @@ -1005,13 +1006,20 @@ async function work( | ||
| 1005 | 1006 | } |
| 1006 | 1007 | : {}), |
| 1007 | 1008 | // A planning run is given what the crew handed over (`parks.ts`), |
| 1008 | // and what holds the ready list when nothing on it can start (`held.ts`). | |
| 1009 | // what holds the ready list when nothing on it can start (`held.ts`), | |
| 1010 | // and the standing reserve it keeps (`reserve.ts`). | |
| 1009 | 1011 | ...(brief.role === "planner" && issue.id === repo.directionIssueId |
| 1010 | 1012 | ? { |
| 1011 | 1013 | parkedTwice: [...parkedTwice(app.db, [repo]), ...handedOverPulls(app.db, repo)], |
| 1012 | 1014 | limbs: handedLimbs(app, repo), |
| 1013 | 1015 | laterGate: laterGate(app.db, repo.id), |
| 1014 | stuck: stuckOf(heldNow(app, repo)), | |
| 1016 | ...(() => { | |
| 1017 | const held = heldNow(app, repo); | |
| 1018 | return { | |
| 1019 | stuck: stuckOf(held), | |
| 1020 | reserve: reserveOf(held.startable.length, repo.autonomySlots), | |
| 1021 | }; | |
| 1022 | })(), | |
| 1015 | 1023 | owed: owed(app.db, repo.id), |
| 1016 | 1024 | } |
| 1017 | 1025 | : {}), |
test/crew-reserve.test.ts added +222
| @@ -0,0 +1,222 @@ | ||
| 1 | /** | |
| 2 | * The standing reserve (#3237): the planner is asked whenever fewer issues | |
| 3 | * can start than two per builder slot, not only when the map runs dry; its | |
| 4 | * brief names the target; the tick line says `reserve=startable/target`; | |
| 5 | * and only a pass that did not end well waits out the 20-minute floor. | |
| 6 | */ | |
| 7 | import { afterAll, afterEach, beforeAll, describe, expect, spyOn, test } from "bun:test"; | |
| 8 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 9 | import { tmpdir } from "node:os"; | |
| 10 | import { join } from "node:path"; | |
| 11 | import { createApp, type App } from "../src/app.ts"; | |
| 12 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 13 | import { addComment, openIssue, setIssueState } from "../src/actions/issues.ts"; | |
| 14 | import { commitToBranch, createRepo, updateRepoSettings } from "../src/actions/repos.ts"; | |
| 15 | import { getAgentByName, setCrewRole } from "../src/core/agents.ts"; | |
| 16 | import { getIssueById, type Issue } from "../src/core/issues.ts"; | |
| 17 | import { getRepoById, type Repo } from "../src/core/repos.ts"; | |
| 18 | import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; | |
| 19 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; | |
| 20 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 21 | import { autonomyTick, driveRepo, settleAgentRun } from "../src/crew/autonomy.ts"; | |
| 22 | import { taskPrompt } from "../src/crew/brief.ts"; | |
| 23 | import { reserveOf, reserveTarget, reserveWords } from "../src/crew/reserve.ts"; | |
| 24 | import { logLevel, setLogLevel } from "../src/kernel/log.ts"; | |
| 25 | ||
| 26 | describe("the reserve's size", () => { | |
| 27 | test("two per builder slot, never fewer than a thin map's three", () => { | |
| 28 | expect(reserveTarget(7)).toBe(14); | |
| 29 | expect(reserveTarget(2)).toBe(4); | |
| 30 | expect(reserveTarget(1)).toBe(3); | |
| 31 | expect(reserveTarget(0)).toBe(3); | |
| 32 | expect(reserveWords(reserveOf(5, 7))).toBe("5/14"); | |
| 33 | }); | |
| 34 | }); | |
| 35 | ||
| 36 | describe("a planner that keeps a standing reserve", () => { | |
| 37 | let dataDir: string; | |
| 38 | let app: App; | |
| 39 | let servers: Servers; | |
| 40 | let alice: User; | |
| 41 | let mason: User; | |
| 42 | let atlas: User; | |
| 43 | let repo: Repo; | |
| 44 | const free: Issue[] = []; | |
| 45 | const fresh = () => getRepoById(app.db, repo.id)!; | |
| 46 | const minutesOn = (n: number) => new Date(Date.now() + n * 60_000); | |
| 47 | const level = logLevel(); | |
| 48 | let lines: string[] = []; | |
| 49 | let spy: ReturnType<typeof spyOn> | undefined; | |
| 50 | const listen = () => { | |
| 51 | lines = []; | |
| 52 | spy = spyOn(console, "info").mockImplementation((line: unknown) => { | |
| 53 | lines.push(String(line)); | |
| 54 | }); | |
| 55 | }; | |
| 56 | const close = (issue: Issue) => | |
| 57 | expect(setIssueState(app, alice, fresh(), getIssueById(app.db, issue.id)!, "closed").ok).toBe(true); | |
| 58 | const planningRun = (state: "ok" | "failed", summary: string) => { | |
| 59 | const direction = getIssueById(app.db, fresh().directionIssueId!)!; | |
| 60 | const run = createRun(app.db, { | |
| 61 | repoId: repo.id, | |
| 62 | kind: "agent", | |
| 63 | issueId: direction.id, | |
| 64 | actorId: atlas.id, | |
| 65 | reason: "assigned", | |
| 66 | }); | |
| 67 | claimRun(app.db, run.id); | |
| 68 | finishRun(app.db, run.id, { state, summary, costUsd: 0.5 }); | |
| 69 | settleAgentRun(app, fresh(), { | |
| 70 | issueId: direction.id, | |
| 71 | state, | |
| 72 | summary, | |
| 73 | reason: "assigned", | |
| 74 | actorId: atlas.id, | |
| 75 | }); | |
| 76 | expect(getIssueById(app.db, direction.id)!.assigneeId).toBeNull(); | |
| 77 | return direction; | |
| 78 | }; | |
| 79 | ||
| 80 | beforeAll(async () => { | |
| 81 | setLogLevel("info"); | |
| 82 | dataDir = await mkdtemp(join(tmpdir(), "forge-reserve-")); | |
| 83 | app = createApp({ dataDir, port: 0 }); | |
| 84 | servers = await serve(app); | |
| 85 | const a = await createUser(app.db, { origin: "seed", trusted: true, | |
| 86 | username: "alice", | |
| 87 | email: "[email protected]", | |
| 88 | password: "a-good-passphrase", | |
| 89 | adminIfFirst: true, | |
| 90 | }); | |
| 91 | if (!a.ok) throw new Error(a.error); | |
| 92 | alice = a.value; | |
| 93 | const made = await createRepo(app, alice, { name: "engine", visibility: "private" }); | |
| 94 | if (!made.ok) throw new Error(String(made.error)); | |
| 95 | repo = made.value; | |
| 96 | const seeded = await commitToBranch(app, alice, repo, { | |
| 97 | message: "Start", | |
| 98 | files: [{ path: "README.md", content: "# engine\n" }], | |
| 99 | }); | |
| 100 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 101 | for (const name of ["mason", "atlas"]) { | |
| 102 | const agent = await createAgent(app, alice, { username: name }); | |
| 103 | if (!agent.ok) throw new Error(String(agent.error)); | |
| 104 | expect(addToCrew(app, alice, repo, name).ok).toBe(true); | |
| 105 | } | |
| 106 | mason = getUserByName(app.db, "mason")!; | |
| 107 | atlas = getUserByName(app.db, "atlas")!; | |
| 108 | setCrewRole(app.db, repo.id, atlas.id, "planner"); | |
| 109 | // Seven slots, every one of them busy: seven issues with a run in flight. | |
| 110 | for (let n = 0; n < 7; n++) { | |
| 111 | const opened = openIssue(app, alice, repo, { title: `Busy ${n}` }); | |
| 112 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 113 | claimRun( | |
| 114 | app.db, | |
| 115 | createRun(app.db, { | |
| 116 | repoId: repo.id, | |
| 117 | kind: "agent", | |
| 118 | issueId: opened.value.id, | |
| 119 | actorId: mason.id, | |
| 120 | reason: "assigned", | |
| 121 | }).id, | |
| 122 | ); | |
| 123 | } | |
| 124 | // A full reserve: fourteen issues a builder could start. | |
| 125 | for (let n = 0; n < 14; n++) { | |
| 126 | const opened = openIssue(app, alice, repo, { title: `Free ${n}` }); | |
| 127 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 128 | free.push(opened.value); | |
| 129 | } | |
| 130 | const set = await updateRepoSettings(app, alice, repo, { | |
| 131 | autonomy: "autonomous", | |
| 132 | autonomyDirection: "Keep the engine small.", | |
| 133 | autonomyCeilingUsd: 100, | |
| 134 | autonomySlots: 7, | |
| 135 | autonomyBy: alice.id, | |
| 136 | }); | |
| 137 | if (!set.ok) throw new Error(JSON.stringify(set.error)); | |
| 138 | }); | |
| 139 | ||
| 140 | afterEach(() => { | |
| 141 | spy?.mockRestore(); | |
| 142 | spy = undefined; | |
| 143 | }); | |
| 144 | ||
| 145 | afterAll(async () => { | |
| 146 | setLogLevel(level); | |
| 147 | servers.stop(); | |
| 148 | app.close(); | |
| 149 | await rm(dataDir, { recursive: true, force: true }); | |
| 150 | }); | |
| 151 | ||
| 152 | test("a full reserve with every slot busy is not a reason to plan", async () => { | |
| 153 | expect(await driveRepo(app, fresh())).toBe("full"); | |
| 154 | expect(fresh().directionIssueId).toBeNull(); | |
| 155 | }); | |
| 156 | ||
| 157 | test("7 slots, 5 startable: the planner is asked, with target 14", async () => { | |
| 158 | for (const issue of free.splice(0, 9)) close(issue); | |
| 159 | listen(); | |
| 160 | expect(await driveRepo(app, fresh())).toBe("planned"); | |
| 161 | const direction = getIssueById(app.db, fresh().directionIssueId!)!; | |
| 162 | expect(direction.assigneeId).toBe(atlas.id); | |
| 163 | const asked = lines.find((l) => l.includes("reserve is short; planner asked")); | |
| 164 | expect(asked).toContain("ready=5"); | |
| 165 | expect(asked).toContain("reserve=5/14"); | |
| 166 | }); | |
| 167 | ||
| 168 | test("the planner's brief names the reserve and asks why when it is left short", () => { | |
| 169 | const direction = getIssueById(app.db, fresh().directionIssueId!)!; | |
| 170 | const brief = taskPrompt({ | |
| 171 | agent: getAgentByName(app.db, "atlas")!, | |
| 172 | repo: fresh(), | |
| 173 | issue: direction, | |
| 174 | branch: "atlas/1-direction", | |
| 175 | forgeUrl: "http://forge.test", | |
| 176 | isApp: false, | |
| 177 | resuming: false, | |
| 178 | role: "planner", | |
| 179 | reason: "assigned", | |
| 180 | comments: [], | |
| 181 | reviews: [], | |
| 182 | reviewComments: [], | |
| 183 | since: direction.createdAt, | |
| 184 | reserve: reserveOf(5, 7), | |
| 185 | }); | |
| 186 | expect(brief).toContain("standing reserve of 14 startable issues"); | |
| 187 | expect(brief).toContain("5 can start now: plan at least 9 more"); | |
| 188 | expect(brief).toContain(`If you finish with fewer than 14 startable, say why in a comment on #${direction.number}`); | |
| 189 | expect(brief).not.toContain("at least three ready issues"); | |
| 190 | }); | |
| 191 | ||
| 192 | test("the tick line says reserve=startable/target", async () => { | |
| 193 | listen(); | |
| 194 | await autonomyTick(app); | |
| 195 | const tick = lines.find((l) => l.includes("[autonomy] tick") && l.includes("did=")); | |
| 196 | expect(tick).toContain("startable=5"); | |
| 197 | expect(tick).toContain("reserve=5/14"); | |
| 198 | }); | |
| 199 | ||
| 200 | test("a pass that ended well has no floor: the map moved, so it is asked again at once", async () => { | |
| 201 | planningRun("ok", "opened #30; ready now: 5"); | |
| 202 | await Bun.sleep(5); | |
| 203 | close(free.shift()!); | |
| 204 | expect(await driveRepo(app, fresh(), minutesOn(1))).toBe("planned"); | |
| 205 | }); | |
| 206 | ||
| 207 | test("a pass that ended well and moved nothing is not asked again", async () => { | |
| 208 | planningRun("ok", "nothing left in the direction; said why on it"); | |
| 209 | expect(await driveRepo(app, fresh(), minutesOn(1))).toBe("full"); | |
| 210 | expect(await driveRepo(app, fresh(), minutesOn(30))).toBe("full"); | |
| 211 | }); | |
| 212 | ||
| 213 | test("a planner that declined waits out the 20-minute floor once a person answers", async () => { | |
| 214 | const direction = planningRun("failed", "declined: nothing in the direction to plan"); | |
| 215 | await Bun.sleep(5); | |
| 216 | expect(addComment(app, alice, fresh(), direction, "Plan the renderer next.").ok).toBe(true); | |
| 217 | listen(); | |
| 218 | expect(await driveRepo(app, fresh(), minutesOn(1))).toBe("full"); | |
| 219 | expect(lines.some((l) => l.includes("planned recently"))).toBe(true); | |
| 220 | expect(await driveRepo(app, fresh(), minutesOn(21))).toBe("planned"); | |
| 221 | }); | |
| 222 | }); |
new above
offline
Compute
heldonce above the spread instead of an immediately invoked function; it is easier to read and matches the surrounding style.