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

Merged opened this in answer to

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

Merged

mason/forge:mason/3237-the-planner-keeps-a-standing-reserve-of → main 1 commit fork 1 thread · 1 unresolved

  • Merged. mason/3237-the-planner-keeps-a-standing-reserve-of was merged into main as 3f407e4.

Review

Sign in to review this change.
6 files changed +326 −33
docs/PLANNING.md +12 −7
@@ -421,10 +421,12 @@ asked, under **Settings → Autonomy**:
421421 could not be brought up to date, is _parked_ — closed with the reason,
422422 its issue put back on the map unassigned with the same note — and the
423423 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
428430 gets two rounds of changes on a pull; the third hands the pull to a
429431 person. The only pause is the day's ceiling. A person
430432 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
447449 it, assigns the planner, and the planner's brief says the rest: open
448450 issues each small enough for one run, link what waits for what, assign
449451 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
452455 has spoken on the direction since its last pass began, and no issue has
453456 been opened by anyone else or closed since that pass ended, the clock
454457 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`).
456461
457462 **What counts as done and as taken.** A pull that says _Closes #12_
458463 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 {
108108 parkReason,
109109 parkReasons,
110110 } from "./parks.ts";
111 import { reserveOf, reserveWords, short } from "./reserve.ts";
111112 import { createLog } from "../kernel/log.ts";
112113 import { err, ok, type Result } from "../kernel/result.ts";
113114 import {
@@ -123,12 +124,11 @@ import { lastWaits, noteWaits, tallyWaits, type Wait, waitWords } from "./waits.
123124 const log = createLog("autonomy");
124125
125126 export const AUTONOMY_TICK_MS = 60_000;
126 /** A planner is not asked again sooner than this unless the map changed. */
127127 /**
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`.
132132 */
133133 const REPLAN_AFTER_MS = 20 * 60_000;
134134
@@ -368,8 +368,8 @@ export async function driveRepo(
368368 const ready = held.startable;
369369 if (slotted.length >= paced.slots) {
370370 // 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).
373373 if (await planIfThin(app, repo, by, crew, active, ready.length, now))
374374 return "planned";
375375 return "full";
@@ -407,17 +407,16 @@ export async function driveRepo(
407407 size: routed.size,
408408 by: routed.by,
409409 });
410 // Planning runs alongside building: when the ready list is thin, the
410 // Planning runs alongside building: when the reserve is short, the
411411 // planner is asked now, not when the builders have run it dry.
412412 await planIfThin(app, repo, by, crew, active, ready.length - 1, now);
413413 return "picked";
414414 }
415415
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).
421420 const stuck = replannable(held).length > 0 ? held : null;
422421 if (await planIfThin(app, repo, by, crew, active, 0, now, stuck)) return "planned";
423422 return "idle";
@@ -462,12 +461,9 @@ export function startable(
462461 return heldBy(app, repo, ready, crew, active).startable;
463462 }
464463
465 /** The map is thin below this many ready issues; the planner refills it. */
466 const THIN_MAP = 3;
467
468464 /**
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,
471467 * and no planning run is in flight, and the last one is old enough.
472468 * Returns whether a planning run was set up. Planning runs alongside
473469 * building: the builders are never waited for.
@@ -596,7 +592,8 @@ async function planIfThin(
596592 ): Promise<boolean> {
597593 const handed = [...parkedTwice(app.db, [repo]), ...handedOverPulls(app.db, repo)];
598594 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;
600597 const planner = crew.find((c) => c.role === "planner")?.agent;
601598 if (planner === undefined) return notPlanned(repo, "no planner on the crew");
602599 if (repo.autonomyDirection.trim() === "")
@@ -619,7 +616,7 @@ async function planIfThin(
619616 // A limb a person handed over is owed one pass, whatever the map and
620617 // whatever the planner said of the direction before (#761).
621618 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;
623620 // Nothing startable for issues held since its last pass that re-planning
624621 // lifts: owed one pass like a hand-over, once (#2244).
625622 const unstuck = stuck === null ? [] : replannable(stuck).filter((issue) => heldSince(app, issue, seen));
@@ -643,9 +640,11 @@ async function planIfThin(
643640 return notPlanned(repo, "the planner declined; waiting for a person");
644641 if (lastPlan.state === "queued" || lastPlan.state === "running")
645642 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.
647645 if (
648646 limbsOwed.length === 0 &&
647 lastPlan.state !== "ok" &&
649648 now.getTime() - new Date(lastPlan.created_at).getTime() <
650649 REPLAN_AFTER_MS
651650 )
@@ -678,11 +677,12 @@ async function planIfThin(
678677 ? "nothing startable; planner asked"
679678 : ready === 0
680679 ? "map ran dry; planner asked"
681 : "map is thin; planner asked",
680 : "reserve is short; planner asked",
682681 {
683682 repo: `${repo.ownerName}/${repo.name}`,
684683 planner: planner.user.username,
685684 ready,
685 reserve: reserveWords(reserve),
686686 ...(owed.length > 0
687687 ? { parkedTwice: owed.map((issue) => `#${issue.number}`).join(" ") }
688688 : {}),
@@ -1173,6 +1173,8 @@ export async function autonomyTick(
11731173 // What the builders could start after the filters (#2143), and
11741174 // what holds the rest, per filter (#2244).
11751175 startable: held.startable.length,
1176 // The standing reserve the planner keeps: startable/target (#3237).
1177 reserve: reserveWords(reserveOf(held.startable.length, status.slots)),
11761178 held: heldWords(held),
11771179 active: status.active,
11781180 slots: status.slots,
src/crew/brief.ts +21 −2
@@ -15,6 +15,7 @@ import { OUTSIDE_LABEL, OUTSIDE_TITLE, saysOutside } from "../core/reports.ts";
1515 import type { Repo } from "../core/repos.ts";
1616 import { READER } from "./direct.ts";
1717 import { newsSince } from "./news.ts";
18 import type { Reserve } from "./reserve.ts";
1819 import {
1920 REVIEW_ROUNDS,
2021 type Review,
@@ -124,7 +125,7 @@ export function systemPrompt(input: BriefInput): string {
124125 "- A worked example:",
125126 ` 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."}]}'`,
126127 " 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.",
128129 '- 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.',
129130 "- 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.",
130131 "- Assign nobody and push nothing: the map is the result. The direction issue stays open.",
@@ -213,6 +214,8 @@ export interface TaskInput extends BriefInput {
213214 readonly stuck?: Stuck | undefined;
214215 /** For a planner: each team with a dial above zero and the work it holds (`team_share.ts`). */
215216 readonly owed?: readonly Owed[] | undefined;
217 /** For a planner: the startable issues against the standing reserve (`reserve.ts`). */
218 readonly reserve?: Reserve | undefined;
216219 }
217220
218221 /** 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 {
271274 const { issue } = input;
272275 const out: string[] = [];
273276 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)]
275278 .filter((lines) => lines.length > 0)
276279 .flatMap((lines, n) => (n === 0 ? lines : ["", ...lines]));
277280 const readers = input.readers ?? new Set<string>();
@@ -631,6 +634,22 @@ function teamLines(input: TaskInput): string[] {
631634 ];
632635 }
633636
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
634653 /**
635654 * What a planner is told when no ready issue can start while builders sit
636655 * 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";
114114 import { handedLimbs } from "./handed_limbs.ts";
115115 import { parkedTwice } from "./parks.ts";
116116 import { heldNow, stuckOf } from "./held.ts";
117 import { reserveOf } from "./reserve.ts";
117118 import { owed } from "./team_share.ts";
118119 import { noteLimit } from "../core/standing.ts";
119120 import { pauseReason } from "./pacing.ts";
@@ -1005,13 +1006,20 @@ async function work(
10051006 }
10061007 : {}),
10071008 // 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`).
10091011 ...(brief.role === "planner" && issue.id === repo.directionIssueId
10101012 ? {
10111013 parkedTwice: [...parkedTwice(app.db, [repo]), ...handedOverPulls(app.db, repo)],
10121014 limbs: handedLimbs(app, repo),
10131015 laterGate: laterGate(app.db, repo.id),
1014 stuck: stuckOf(heldNow(app, repo)),
1016 ...(() => {

Compute held once above the spread instead of an immediately invoked function; it is easier to read and matches the surrounding style.

1017 const held = heldNow(app, repo);
1018 return {
1019 stuck: stuckOf(held),
1020 reserve: reserveOf(held.startable.length, repo.autonomySlots),
1021 };
1022 })(),
10151023 owed: owed(app.db, repo.id),
10161024 }
10171025 : {}),
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 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs