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

The planner is never parked by the direction's run cap: planning is paced, not capped #2154

Merged opened this in answer to

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

Merged

mason/forge:mason/2143-the-planner-is-never-parked-by-the-direc → main 1 commit fork

  • Merged. mason/2143-the-planner-is-never-parked-by-the-direc was merged into main as 928d1c8.

Review

Sign in to review this change.
6 files changed +238 −28
src/app.ts +5
@@ -85,6 +85,11 @@ export interface ForgeConfig {
8585 * and asks a person (`crew/guard.ts`). Undefined is `ISSUE_RUN_CAP`.
8686 */
8787 issueRunCap?: number;
88 /**
89 * How many planning passes the direction issue may start in a day
90 * (`crew/guard.ts`). Undefined is `DIRECTION_RUN_CAP`.
91 */
92 directionRunCap?: number;
8893 /**
8994 * Where mail goes out: SMTP submission (`FORGE_SMTP_URL`, `FORGE_MAIL_FROM`).
9095 * Undefined is a forge that sends no mail; a newcomer's email then waits
src/crew/autonomy.ts +50 −24
@@ -94,7 +94,7 @@ import { BREAKER_NOTE, breakerTripped } from "./breaker.ts";
9494 import {
9595 capReached,
9696 handedOverPulls,
97 ISSUE_RUN_CAP,
97 runCapOf,
9898 SKIP_SUMMARY,
9999 skipsReplan,
100100 } from "./guard.ts";
@@ -351,30 +351,12 @@ export async function driveRepo(
351351 const builders = crew.filter((c) => c.role === "builder").map((c) => c.agent);
352352 const planner = crew.find((c) => c.role === "planner")?.agent;
353353 const active = activeRuns(app, repo.id);
354 // An issue a planner or a reviewer holds is theirs, not a builder's.
355 const planners = new Set(
356 crew.filter((c) => c.role !== "builder").map((c) => c.agent.user.id),
357 );
358354 const slotted = slottedRuns(active, crew);
359355 // Slots follow the subscriptions' remaining windows, never past the
360356 // operator's; at a wall nothing new starts until the reset.
361357 const paced = repoPace(app, repo, now);
362358 if (paced.slots === 0 && slotted.length === 0) return "waiting";
363 const busyIssues = new Set(active.map((r) => r.issueId));
364 const graph = repoGraph(app.db, repo.id, { sealed: true });
365 const proposed = proposedWork(app.db, repo);
366 const ready = readyNow(graph).filter(
367 (r) =>
368 r.issue.id !== repo.directionIssueId &&
369 !busyIssues.has(r.issue.id) &&
370 !proposed.has(r.issue.number) &&
371 (r.issue.assigneeId === null || !planners.has(r.issue.assigneeId)) &&
372 !stalled(app, repo, r.issue) &&
373 !atRunCap(app, repo, r.issue) &&
374 parkedCount(app, repo, r.issue.id) < PARK_STRIKES &&
375 !coveredByRedFix(app.db, repo.id, r.issue) &&
376 !isSettingFinding(app.db, r.issue.id),
377 );
359 const ready = startable(app, repo, readyNow(repoGraph(app.db, repo.id, { sealed: true })), crew, active);
378360 if (slotted.length >= paced.slots) {
379361 // The builders are busy; the planner can still refill the map, when
380362 // it is thin — counted, never assumed: a full crew with a full map is
@@ -428,6 +410,39 @@ export async function driveRepo(
428410 return "idle";
429411 }
430412
413 /**
414 * The ready issues a builder may start now: not the direction, not in
415 * flight, not already proposed in an open pull, not held by the planner or
416 * a reviewer, not stalled, capped, parked, covered by a red fix, or a
417 * setting's finding. The tick log counts these beside the raw ready list.
418 */
419 export function startable(
420 app: App,
421 repo: Repo,
422 ready: readonly Ranked[],
423 crew: readonly { agent: Agent; role: CrewRole }[],
424 active: readonly Run[],
425 ): Ranked[] {
426 const busyIssues = new Set(active.map((r) => r.issueId));
427 const proposed = proposedWork(app.db, repo);
428 // An issue a planner or a reviewer holds is theirs, not a builder's.
429 const planners = new Set(
430 crew.filter((c) => c.role !== "builder").map((c) => c.agent.user.id),
431 );
432 return ready.filter(
433 (r) =>
434 r.issue.id !== repo.directionIssueId &&
435 !busyIssues.has(r.issue.id) &&
436 !proposed.has(r.issue.number) &&
437 (r.issue.assigneeId === null || !planners.has(r.issue.assigneeId)) &&
438 !stalled(app, repo, r.issue) &&
439 !atRunCap(app, repo, r.issue) &&
440 parkedCount(app, repo, r.issue.id) < PARK_STRIKES &&
441 !coveredByRedFix(app.db, repo.id, r.issue) &&
442 !isSettingFinding(app.db, r.issue.id),
443 );
444 }
445
431446 /** The map is thin below this many ready issues; the planner refills it. */
432447 const THIN_MAP = 3;
433448
@@ -581,8 +596,10 @@ async function planIfThin(
581596 const seen = lastPlan?.created_at ?? "";
582597 const owed = handed.filter((issue) => issue.at > seen);
583598 if (ready >= THIN_MAP && owed.length === 0) return false;
599 // The direction is the planner's standing issue, not one issue retried:
600 // it is held to its own day's share, never the builders' cap (#2143).
584601 if (atRunCap(app, repo, direction))
585 return notPlanned(repo, "parked: the direction is at its run cap");
602 return notPlanned(repo, "parked: the direction is at its day's planning cap");
586603 // An issue the crew handed over is owed its pass whatever the hold: the
587604 // planner splitting it is how work starts landing again.
588605 const hold = owed.length === 0 ? plannerHold(app, repo, now) : null;
@@ -776,7 +793,7 @@ export function parkForBreaker(
776793
777794 /** Whether `issue` is at its run cap (`guard.ts`): the clock neither picks nor plans it. */
778795 export function atRunCap(app: App, repo: Repo, issue: Issue): boolean {
779 return capReached(app.db, repo, issue, app.config.issueRunCap ?? ISSUE_RUN_CAP);
796 return capReached(app.db, repo, issue, runCapOf(repo, issue, app.config));
780797 }
781798
782799 /** How many times the crew has parked this issue since a person who directs it last spoke (`parks.ts`). */
@@ -1118,11 +1135,20 @@ export async function autonomyTick(
11181135 // Every tick says what it did and why, so an app that sits still is
11191136 // never a mystery: the numbers behind "idle" are on the line.
11201137 const status = autonomyStatus(app, current, now);
1121 const graph = repoGraph(app.db, current.id, { sealed: true });
1138 const ready = readyNow(repoGraph(app.db, current.id, { sealed: true }));
11221139 log.info("tick", {
11231140 repo: `${repo.ownerName}/${repo.name}`,
11241141 did,
1125 ready: readyNow(graph).length,
1142 ready: ready.length,
1143 // What the builders could start after the filters: 47 ready with 0
1144 // startable is every one in a pull, stalled or capped (#2143).
1145 startable: startable(
1146 app,
1147 current,
1148 ready,
1149 listCrewRoles(app.db, current.id),
1150 activeRuns(app, current.id),
1151 ).length,
11261152 active: status.active,
11271153 slots: status.slots,
11281154 spent: `${status.spentTodayUsd.toFixed(2)}/${status.ceilingUsd}`,
src/crew/dispatch.ts +4 −3
@@ -64,8 +64,8 @@ import {
6464 handoverNote,
6565 handoverOf,
6666 type Handover,
67 ISSUE_RUN_CAP,
6867 recordCap,
68 runCapOf,
6969 skipsFeedback,
7070 } from "./guard.ts";
7171 import { noteProtection } from "../kernel/protection.ts";
@@ -309,8 +309,9 @@ export function queueAgentRun(
309309 }
310310 // A day's share of runs on one issue without a person's word: a loop,
311311 // parked once and asked about once (`guard.ts`). A run carrying on after
312 // a pause or a restart is the same start, never a new one.
313 const cap = app.config.issueRunCap ?? ISSUE_RUN_CAP;
312 // a pause or a restart is the same start, never a new one. The direction
313 // issue has its own, larger share (`runCapOf`).
314 const cap = runCapOf(repo, issue, app.config);
314315 if (again === undefined && capReached(app.db, repo, issue, cap)) {
315316 if (
316317 !capNoted(app.db, repo, issue) &&
src/crew/guard.ts +20 −1
@@ -9,7 +9,9 @@
99 * - An issue that has started `issueRunCap` runs inside a day since a
1010 * person who may direct the crew last spoke is capped: parked with one
1111 * note, raised as one ask, and given nothing more until that person
12 * speaks. A loop is a question, not a retry.
12 * speaks. A loop is a question, not a retry. The direction issue is not
13 * one issue retried but the planner's standing one: it is paced
14 * (`autonomy.ts`) and bounded by its own, generous `directionRunCap`.
1315 *
1416 * Each skip and each cap is a row in the decision ledger (kind `guard`,
1517 * by `rule`), so what the guard saved can be counted (`guardCounts`).
@@ -27,6 +29,23 @@ import { nothingNew, plannerNothingNew } from "./news.ts";
2729 /** Runs one issue may start in `CAP_WINDOW_MS` unless the setting says otherwise. */
2830 export const ISSUE_RUN_CAP = 6;
2931 export const CAP_WINDOW_MS = 24 * 60 * 60_000;
32 /**
33 * Planning passes the direction issue may start in `CAP_WINDOW_MS` unless
34 * the setting says otherwise. Generous: the planner is already paced by the
35 * replan floor, the breaker, its hold and the dollar ceiling (#2143).
36 */
37 export const DIRECTION_RUN_CAP = 48;
38
39 /** The run cap that applies to `issue`: the direction's own, or the issue cap. */
40 export function runCapOf(
41 repo: Repo,
42 issue: Issue,
43 caps: { readonly issueRunCap?: number; readonly directionRunCap?: number },
44 ): number {
45 return issue.id === repo.directionIssueId
46 ? (caps.directionRunCap ?? DIRECTION_RUN_CAP)
47 : (caps.issueRunCap ?? ISSUE_RUN_CAP);
48 }
3049
3150 /** The note a capped issue is parked with: the map reads it as `parked`, the asks as a question. */
3251 export const CAP_NOTE =
src/main.ts +6
@@ -217,6 +217,12 @@ const app = createApp({
217217 Number(process.env.FORGE_CREW_ISSUE_RUNS) > 0
218218 ? { issueRunCap: Number(process.env.FORGE_CREW_ISSUE_RUNS) }
219219 : {}),
220 // FORGE_CREW_DIRECTION_RUNS: planning passes the direction issue may start
221 // in a day; the planner is otherwise paced, never capped (#2143).
222 ...(Number.isInteger(Number(process.env.FORGE_CREW_DIRECTION_RUNS)) &&
223 Number(process.env.FORGE_CREW_DIRECTION_RUNS) > 0
224 ? { directionRunCap: Number(process.env.FORGE_CREW_DIRECTION_RUNS) }
225 : {}),
220226 ...((process.env.FORGE_EDGE_NAME ?? "").trim() === ""
221227 ? {}
222228 : { edgeName: (process.env.FORGE_EDGE_NAME ?? "").trim() }),
test/crew-direction-cap.test.ts added +153
@@ -0,0 +1,153 @@
1 /**
2 * The planner is paced, not capped (#2143): the direction issue is its
3 * standing issue, so the builders' per-issue run cap never parks it. What
4 * bounds planning is the breaker, the replan floor, the hold, the dollar
5 * ceiling — and the direction's own, generous day's share.
6 */
7 import { afterAll, beforeAll, describe, expect, 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 { assignIssue, openIssue, setIssueState } from "../src/actions/issues.ts";
14 import { createRepo, updateRepoSettings } from "../src/actions/repos.ts";
15 import { 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 { driveRepo } from "../src/crew/autonomy.ts";
21 import { ISSUE_RUN_CAP, runCapOf } from "../src/crew/guard.ts";
22
23 describe("the direction is paced, not capped", () => {
24 let dataDir: string;
25 let app: App;
26 let alice: User;
27 let atlas: User;
28 let repo: Repo;
29 let direction: Issue;
30 const fresh = () => getRepoById(app.db, repo.id)!;
31 const hour = 60 * 60_000;
32
33 /** A finished planning pass, `ago` ms before now. */
34 function pass(ago: number, state: "ok" | "failed" = "ok"): void {
35 const run = createRun(app.db, {
36 repoId: repo.id,
37 kind: "agent",
38 issueId: direction.id,
39 actorId: atlas.id,
40 reason: "assigned",
41 });
42 claimRun(app.db, run.id);
43 finishRun(app.db, run.id, {
44 state,
45 summary: state === "ok" ? "opened #9" : "the provider said no",
46 costUsd: 0.1,
47 });
48 const at = new Date(Date.now() - ago).toISOString();
49 app.db
50 .query(
51 "UPDATE run SET created_at = ?2, started_at = ?2, finished_at = ?2 WHERE id = ?1",
52 )
53 .run(run.id, at);
54 }
55
56 /** Clear the direction and the run history, so each case starts alike. */
57 function reset(): void {
58 app.db.query("DELETE FROM run WHERE issue_id = ?1").run(direction.id);
59 const current = getIssueById(app.db, direction.id)!;
60 if (current.assigneeId !== null)
61 assignIssue(app, alice, fresh(), current, null);
62 }
63
64 /** The map moves after the last pass, so the replan guard has news. */
65 async function moveTheMap(): Promise<void> {
66 const opened = await openIssue(app, alice, fresh(), { title: "Gone soon" });
67 if (!opened.ok) throw new Error(String(opened.error));
68 setIssueState(app, alice, fresh(), opened.value, "closed");
69 }
70
71 beforeAll(async () => {
72 dataDir = await mkdtemp(join(tmpdir(), "forge-direction-cap-"));
73 app = createApp({ dataDir, port: 0 });
74 const a = await createUser(app.db, {
75 trusted: true,
76 username: "alice",
77 email: "[email protected]",
78 password: "a-good-passphrase",
79 adminIfFirst: true,
80 });
81 if (!a.ok) throw new Error(a.error);
82 alice = a.value;
83 const made = await createRepo(app, alice, {
84 name: "engine",
85 visibility: "private",
86 });
87 if (!made.ok) throw new Error(String(made.error));
88 repo = made.value;
89 for (const name of ["atlas", "mason"]) {
90 const agent = await createAgent(app, alice, { username: name });
91 if (!agent.ok) throw new Error(String(agent.error));
92 expect(addToCrew(app, alice, repo, name).ok).toBe(true);
93 }
94 atlas = getUserByName(app.db, "atlas")!;
95 setCrewRole(app.db, repo.id, atlas.id, "planner");
96 const set = await updateRepoSettings(app, alice, repo, {
97 autonomy: "autonomous",
98 autonomyDirection: "Keep the engine small.",
99 autonomyCeilingUsd: 100,
100 autonomyBy: alice.id,
101 });
102 if (!set.ok) throw new Error(JSON.stringify(set.error));
103 expect(await driveRepo(app, fresh())).toBe("planned");
104 direction = getIssueById(app.db, fresh().directionIssueId!)!;
105 });
106
107 afterAll(async () => {
108 app.close();
109 await rm(dataDir, { recursive: true, force: true });
110 });
111
112 test("the direction has its own cap, larger than an issue's", () => {
113 expect(runCapOf(fresh(), direction, {})).toBeGreaterThan(ISSUE_RUN_CAP);
114 expect(runCapOf(fresh(), direction, { issueRunCap: 2 })).toBeGreaterThan(2);
115 expect(runCapOf(fresh(), direction, { directionRunCap: 5 })).toBe(5);
116 });
117
118 test("twenty passes with no person's word: the twenty-first still runs when the map is thin", async () => {
119 reset();
120 for (let n = 20; n >= 1; n--) pass(n * 40 * 60_000);
121 expect(20).toBeGreaterThan(ISSUE_RUN_CAP);
122 await moveTheMap();
123 expect(await driveRepo(app, fresh())).toBe("planned");
124 expect(getIssueById(app.db, direction.id)!.assigneeId).toBe(atlas.id);
125 });
126
127 test("the direction's own day's share still parks it", async () => {
128 reset();
129 for (let n = 20; n >= 1; n--) pass(n * 40 * 60_000);
130 await moveTheMap();
131 app.config.directionRunCap = 20;
132 try {
133 expect(await driveRepo(app, fresh())).toBe("idle");
134 expect(getIssueById(app.db, direction.id)!.assigneeId).toBeNull();
135 } finally {
136 delete app.config.directionRunCap;
137 }
138 });
139
140 test("a tripped breaker still parks the planner", async () => {
141 reset();
142 pass(50 * 60_000, "failed");
143 pass(40 * 60_000, "failed");
144 pass(30 * 60_000, "failed");
145 await moveTheMap();
146 expect(await driveRepo(app, fresh())).toBe("idle");
147 expect(getIssueById(app.db, direction.id)!.assigneeId).toBeNull();
148 });
149
150 test("the run cap still parks an ordinary issue", () => {
151 expect(runCapOf(fresh(), { ...direction, id: "other" }, {})).toBe(ISSUE_RUN_CAP);
152 });
153 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs