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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/2143-the-planner-is-never-parked-by-the-direc → main 1 commit fork
6 files
changed +238
−28
src/app.ts +5
| @@ -85,6 +85,11 @@ export interface ForgeConfig { | ||
| 85 | 85 | * and asks a person (`crew/guard.ts`). Undefined is `ISSUE_RUN_CAP`. |
| 86 | 86 | */ |
| 87 | 87 | 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; | |
| 88 | 93 | /** |
| 89 | 94 | * Where mail goes out: SMTP submission (`FORGE_SMTP_URL`, `FORGE_MAIL_FROM`). |
| 90 | 95 | * 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"; | ||
| 94 | 94 | import { |
| 95 | 95 | capReached, |
| 96 | 96 | handedOverPulls, |
| 97 | ISSUE_RUN_CAP, | |
| 97 | runCapOf, | |
| 98 | 98 | SKIP_SUMMARY, |
| 99 | 99 | skipsReplan, |
| 100 | 100 | } from "./guard.ts"; |
| @@ -351,30 +351,12 @@ export async function driveRepo( | ||
| 351 | 351 | const builders = crew.filter((c) => c.role === "builder").map((c) => c.agent); |
| 352 | 352 | const planner = crew.find((c) => c.role === "planner")?.agent; |
| 353 | 353 | 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 | ); | |
| 358 | 354 | const slotted = slottedRuns(active, crew); |
| 359 | 355 | // Slots follow the subscriptions' remaining windows, never past the |
| 360 | 356 | // operator's; at a wall nothing new starts until the reset. |
| 361 | 357 | const paced = repoPace(app, repo, now); |
| 362 | 358 | 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); | |
| 378 | 360 | if (slotted.length >= paced.slots) { |
| 379 | 361 | // The builders are busy; the planner can still refill the map, when |
| 380 | 362 | // it is thin — counted, never assumed: a full crew with a full map is |
| @@ -428,6 +410,39 @@ export async function driveRepo( | ||
| 428 | 410 | return "idle"; |
| 429 | 411 | } |
| 430 | 412 | |
| 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 | ||
| 431 | 446 | /** The map is thin below this many ready issues; the planner refills it. */ |
| 432 | 447 | const THIN_MAP = 3; |
| 433 | 448 | |
| @@ -581,8 +596,10 @@ async function planIfThin( | ||
| 581 | 596 | const seen = lastPlan?.created_at ?? ""; |
| 582 | 597 | const owed = handed.filter((issue) => issue.at > seen); |
| 583 | 598 | 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). | |
| 584 | 601 | 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"); | |
| 586 | 603 | // An issue the crew handed over is owed its pass whatever the hold: the |
| 587 | 604 | // planner splitting it is how work starts landing again. |
| 588 | 605 | const hold = owed.length === 0 ? plannerHold(app, repo, now) : null; |
| @@ -776,7 +793,7 @@ export function parkForBreaker( | ||
| 776 | 793 | |
| 777 | 794 | /** Whether `issue` is at its run cap (`guard.ts`): the clock neither picks nor plans it. */ |
| 778 | 795 | 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)); | |
| 780 | 797 | } |
| 781 | 798 | |
| 782 | 799 | /** 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( | ||
| 1118 | 1135 | // Every tick says what it did and why, so an app that sits still is |
| 1119 | 1136 | // never a mystery: the numbers behind "idle" are on the line. |
| 1120 | 1137 | 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 })); | |
| 1122 | 1139 | log.info("tick", { |
| 1123 | 1140 | repo: `${repo.ownerName}/${repo.name}`, |
| 1124 | 1141 | 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, | |
| 1126 | 1152 | active: status.active, |
| 1127 | 1153 | slots: status.slots, |
| 1128 | 1154 | spent: `${status.spentTodayUsd.toFixed(2)}/${status.ceilingUsd}`, |
src/crew/dispatch.ts +4 −3
| @@ -64,8 +64,8 @@ import { | ||
| 64 | 64 | handoverNote, |
| 65 | 65 | handoverOf, |
| 66 | 66 | type Handover, |
| 67 | ISSUE_RUN_CAP, | |
| 68 | 67 | recordCap, |
| 68 | runCapOf, | |
| 69 | 69 | skipsFeedback, |
| 70 | 70 | } from "./guard.ts"; |
| 71 | 71 | import { noteProtection } from "../kernel/protection.ts"; |
| @@ -309,8 +309,9 @@ export function queueAgentRun( | ||
| 309 | 309 | } |
| 310 | 310 | // A day's share of runs on one issue without a person's word: a loop, |
| 311 | 311 | // 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); | |
| 314 | 315 | if (again === undefined && capReached(app.db, repo, issue, cap)) { |
| 315 | 316 | if ( |
| 316 | 317 | !capNoted(app.db, repo, issue) && |
src/crew/guard.ts +20 −1
| @@ -9,7 +9,9 @@ | ||
| 9 | 9 | * - An issue that has started `issueRunCap` runs inside a day since a |
| 10 | 10 | * person who may direct the crew last spoke is capped: parked with one |
| 11 | 11 | * 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`. | |
| 13 | 15 | * |
| 14 | 16 | * Each skip and each cap is a row in the decision ledger (kind `guard`, |
| 15 | 17 | * by `rule`), so what the guard saved can be counted (`guardCounts`). |
| @@ -27,6 +29,23 @@ import { nothingNew, plannerNothingNew } from "./news.ts"; | ||
| 27 | 29 | /** Runs one issue may start in `CAP_WINDOW_MS` unless the setting says otherwise. */ |
| 28 | 30 | export const ISSUE_RUN_CAP = 6; |
| 29 | 31 | 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 | } | |
| 30 | 49 | |
| 31 | 50 | /** The note a capped issue is parked with: the map reads it as `parked`, the asks as a question. */ |
| 32 | 51 | export const CAP_NOTE = |
src/main.ts +6
| @@ -217,6 +217,12 @@ const app = createApp({ | ||
| 217 | 217 | Number(process.env.FORGE_CREW_ISSUE_RUNS) > 0 |
| 218 | 218 | ? { issueRunCap: Number(process.env.FORGE_CREW_ISSUE_RUNS) } |
| 219 | 219 | : {}), |
| 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 | : {}), | |
| 220 | 226 | ...((process.env.FORGE_EDGE_NAME ?? "").trim() === "" |
| 221 | 227 | ? {} |
| 222 | 228 | : { 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 | }); |
new above
offline