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

"4 of 2 in use": a slot count that means what the crew's rule means #58

Merged opened this

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

Merged

mason/forge:mason/50-4-of-2-in-use-a-slot-count-that-means-wh → main 1 commit fork

  • Merged. mason/50-4-of-2-in-use-a-slot-count-that-means-wh was merged into main as fb86277.

Review

Sign in to review this change.
4 files changed +264 −16
docs/PLANNING.md +11 −7
@@ -183,13 +183,17 @@ passed before autonomy was on — land, so nothing green waits on chance.
183183
184184 **What holds it.** The ceiling is dollars of agent spend per UTC day; at
185185 the ceiling the crew stops picking until midnight, and nothing in flight is
186 cut. Slots are runs at once — the WIP limit — and a repository never
187 starts more than that, whatever the forge-wide concurrency allows. A
188 failing check on an agent's pull goes back to the agent as ever, and after
189 the same bounded number of tries the crew stops and waits for a person:
190 autonomy re-queues nothing a person must decide. The map page says how it
191 stands: _Autonomous · $12.40 of $40 today · 1 of 2 slots_, and why it is
192 paused when it is.
186 cut. Slots are the builders' runs at once — the WIP limit — and a
187 repository never starts more than that, whatever the forge-wide concurrency
188 allows. A reviewer's pass over a pull and a planner's run on the direction
189 take no slot: they refill the map rather than work it, and they never keep
190 a builder from picking the next issue. The number a page shows is that
191 same count, so what the page says and what the loop gates on are one
192 thing. A failing check on an agent's pull goes back to the agent as ever,
193 and after the same bounded number of tries the crew stops and waits for a
194 person: autonomy re-queues nothing a person must decide. The map page says
195 how it stands: _Autonomous · $12.40 of $40 today · 1 of 2 slots_, and why
196 it is paused when it is.
193197
194198 ## The decider
195199
src/crew/autonomy.ts +33 −8
@@ -86,7 +86,10 @@ export interface AutonomyStatus {
8686 readonly spentTodayUsd: number;
8787 readonly ceilingUsd: number;
8888 readonly slots: number;
89 /** Agent runs queued or running here now. */
89 /**
90 * Agent runs in flight here that spend a slot — what `slottedRuns` counts,
91 * which is what the loop gates on. Not every run in flight: see there.
92 */
9093 readonly active: number;
9194 readonly paused: Paused | null;
9295 /** When a ceiling pause lifts: the next UTC midnight. */
@@ -120,6 +123,32 @@ function activeRuns(app: App, repoId: string) {
120123 return listActive(app.db, "agent").filter((r) => r.repoId === repoId);
121124 }
122125
126 /**
127 * Of the agent runs in flight here, the ones that spend a slot. Slots are
128 * the builders' work-in-progress limit, so two kinds of run take none: a
129 * review, which is the reviewer's short pass over a pull and must never
130 * keep a builder from picking up the next issue, and any run by a planner
131 * or a reviewer, whose work refills the map rather than works it.
132 *
133 * The rule lives here only: `driveRepo` gates on this set and
134 * `autonomyStatus` counts it, so the number a page shows is the number the
135 * loop enforces. The runs it leaves out are still in flight and still shown
136 * — Now on the studio page lists every one of them.
137 */
138 export function slottedRuns(
139 active: readonly Run[],
140 crew: readonly { readonly agent: Agent; readonly role: CrewRole }[],
141 ): Run[] {
142 const notBuilders = new Set(
143 crew.filter((c) => c.role !== "builder").map((c) => c.agent.user.id),
144 );
145 return active.filter(
146 (r) =>
147 r.reason !== "review" &&
148 (r.actorId === null || !notBuilders.has(r.actorId)),
149 );
150 }
151
123152 export function autonomyStatus(
124153 app: App,
125154 repo: Repo,
@@ -144,7 +173,7 @@ export function autonomyStatus(
144173 spentTodayUsd: spent,
145174 ceilingUsd: repo.autonomyCeilingUsd,
146175 slots: repo.autonomySlots,
147 active: activeRuns(app, repo.id).length,
176 active: slottedRuns(activeRuns(app, repo.id), crew).length,
148177 paused,
149178 resumesAt: paused === "ceiling" ? tomorrow.toISOString() : null,
150179 };
@@ -171,15 +200,11 @@ export async function driveRepo(
171200 const builders = crew.filter((c) => c.role === "builder").map((c) => c.agent);
172201 const planner = crew.find((c) => c.role === "planner")?.agent;
173202 const active = activeRuns(app, repo.id);
174 // Slots bound the builders. A review is the reviewer's short pass on a
175 // pull and never keeps a builder from picking the next issue.
203 // An issue a planner or a reviewer holds is theirs, not a builder's.
176204 const planners = new Set(
177205 crew.filter((c) => c.role !== "builder").map((c) => c.agent.user.id),
178206 );
179 const slotted = active.filter(
180 (r) =>
181 r.reason !== "review" && (r.actorId === null || !planners.has(r.actorId)),
182 );
207 const slotted = slottedRuns(active, crew);
183208 if (slotted.length >= repo.autonomySlots) {
184209 // The builders are busy; the planner can still refill the map.
185210 if (await planIfThin(app, repo, by, crew, active, 0, now)) return "planned";
test/autonomy.test.ts +103
@@ -447,3 +447,106 @@ describe("a repository on its own", () => {
447447 expect(map).toContain("of $25 today");
448448 });
449449 });
450
451 /**
452 * One rule says which runs in flight spend a slot, and the number a page
453 * shows is the number the loop gates on — never more than the slots.
454 */
455 describe("the slot count is the rule the loop enforces", () => {
456 let dataDir: string;
457 let app: App;
458 let alice: User;
459 let repo: Repo;
460 let builder: User;
461 let reviewer: User;
462 let planner: User;
463 const work: Issue[] = [];
464 const fresh = () => getRepoById(app.db, repo.id)!;
465 const running = (issue: Issue, actor: User, reason: string) => {
466 const run = createRun(app.db, {
467 repoId: repo.id,
468 kind: "agent",
469 issueId: issue.id,
470 actorId: actor.id,
471 reason,
472 });
473 claimRun(app.db, run.id);
474 return run;
475 };
476
477 beforeAll(async () => {
478 dataDir = await mkdtemp(join(tmpdir(), "forge-slots-"));
479 app = createApp({ dataDir, port: 0 });
480 const a = await createUser(app.db, {
481 username: "alice",
482 email: "[email protected]",
483 password: "a-good-passphrase",
484 adminIfFirst: true,
485 });
486 if (!a.ok) throw new Error(a.error);
487 alice = a.value;
488 const made = await createRepo(app, alice, {
489 name: "loom",
490 visibility: "private",
491 });
492 if (!made.ok) throw new Error(String(made.error));
493 repo = made.value;
494 const seeded = await commitToBranch(app, alice, repo, {
495 message: "Start",
496 files: [{ path: "README.md", content: "# loom\n" }],
497 });
498 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
499 for (const name of ["brick", "quill", "chart"]) {
500 const agent = await createAgent(app, alice, { username: name });
501 if (!agent.ok) throw new Error(String(agent.error));
502 expect(addToCrew(app, alice, repo, name).ok).toBe(true);
503 }
504 const byName = (await import("../src/core/users.ts")).getUserByName;
505 builder = byName(app.db, "brick")!;
506 reviewer = byName(app.db, "quill")!;
507 planner = byName(app.db, "chart")!;
508 setCrewRole(app.db, repo.id, reviewer.id, "reviewer");
509 setCrewRole(app.db, repo.id, planner.id, "planner");
510 for (const title of ["Warp", "Weft", "Shuttle", "Heddle"]) {
511 const opened = openIssue(app, alice, repo, { title });
512 if (!opened.ok) throw new Error(String(opened.error));
513 work.push(opened.value);
514 }
515 const on = await updateRepoSettings(app, alice, repo, {
516 autonomy: "autonomous",
517 autonomyDirection: "",
518 autonomyCeilingUsd: 10,
519 autonomySlots: 2,
520 autonomyBy: alice.id,
521 });
522 expect(on.ok).toBe(true);
523 });
524
525 afterAll(async () => {
526 app.close();
527 await rm(dataDir, { recursive: true, force: true });
528 });
529
530 test("a review and a planner's run take no slot: one builder of two slots, and a builder still starts", async () => {
531 running(work[0]!, builder, "assigned");
532 running(work[3]!, reviewer, "review");
533 running(work[2]!, planner, "assigned");
534 const status = autonomyStatus(app, fresh());
535 expect(status.slots).toBe(2);
536 expect(status.active).toBe(1);
537 // Three runs are in flight; one of them spends a slot.
538 expect(listRuns(app.db, repo.id, { kind: "agent" }).length).toBe(3);
539 expect(await driveRepo(app, fresh())).toBe("picked");
540 expect(getIssue(app.db, repo.id, work[1]!.number)!.assigneeId).toBe(
541 builder.id,
542 );
543 });
544
545 test("two builder runs fill both slots: 2 of 2, and the loop says full", async () => {
546 running(work[1]!, builder, "assigned");
547 const status = autonomyStatus(app, fresh());
548 expect(status.active).toBe(2);
549 expect(status.active).toBeLessThanOrEqual(status.slots);
550 expect(await driveRepo(app, fresh())).toBe("full");
551 });
552 });
test/studio.test.ts +117 −1
@@ -14,7 +14,13 @@ import { AppsRuntime } from "../src/apps/runtime.ts";
1414 import { getAppByRepo, type AppRecord } from "../src/core/apps.ts";
1515 import { addToCrew, createAgent } from "../src/actions/crew.ts";
1616 import { mergePull, openPull } from "../src/actions/pulls.ts";
17 import { commitToBranch, createRepo } from "../src/actions/repos.ts";
17 import { openIssue } from "../src/actions/issues.ts";
18 import {
19 commitToBranch,
20 createRepo,
21 updateRepoSettings,
22 } from "../src/actions/repos.ts";
23 import { setCrewRole } from "../src/core/agents.ts";
1824 import { getIssue, listIssues } from "../src/core/issues.ts";
1925 import { recordLook } from "../src/core/looks.ts";
2026 import { getPull, type Pull } from "../src/core/pulls.ts";
@@ -1197,3 +1203,113 @@ describe("the app's live fragment: /:owner/:name/app/state", () => {
11971203 expect(live).toContain(link);
11981204 });
11991205 });
1206
1207 /**
1208 * On its own: the slots line counts what the loop counts — the builders —
1209 * while Now still shows every run in flight, review and plan included.
1210 */
1211 describe("the studio's slot count", () => {
1212 let dataDir: string;
1213 let app: App;
1214 let servers: Servers;
1215 let base: string;
1216 let cookie: string;
1217
1218 beforeAll(async () => {
1219 dataDir = await mkdtemp(join(tmpdir(), "forge-studio-slots-"));
1220 app = createApp({ dataDir, port: 0 });
1221 servers = await serve(app);
1222 base = servers.url;
1223 const a = await createUser(app.db, {
1224 username: "alice",
1225 email: "[email protected]",
1226 password: "a-good-passphrase",
1227 adminIfFirst: true,
1228 });
1229 if (!a.ok) throw new Error(a.error);
1230 const alice = a.value;
1231 cookie = (
1232 (
1233 await fetch(`${base}/signin`, {
1234 method: "POST",
1235 body: new URLSearchParams({
1236 username: "alice",
1237 password: "a-good-passphrase",
1238 }),
1239 redirect: "manual",
1240 })
1241 ).headers.get("set-cookie") ?? ""
1242 ).split(";")[0]!;
1243 const made = await createRepo(app, alice, {
1244 name: "loom",
1245 visibility: "private",
1246 description: "a place for cloth",
1247 });
1248 if (!made.ok) throw new Error(String(made.error));
1249 const repo = made.value;
1250 const seeded = await commitToBranch(app, alice, repo, {
1251 message: "Start",
1252 files: [{ path: "README.md", content: "# loom\n" }],
1253 });
1254 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
1255 const enabled = await enableApp(app, alice, repo, { slug: "loom" });
1256 if (!enabled.ok) throw new Error(String(enabled.error));
1257 for (const name of ["brick", "quill", "chart"]) {
1258 const agent = await createAgent(app, alice, { username: name });
1259 if (!agent.ok) throw new Error(String(agent.error));
1260 expect(addToCrew(app, alice, repo, name).ok).toBe(true);
1261 }
1262 const reviewer = getUserByName(app.db, "quill")!;
1263 const planner = getUserByName(app.db, "chart")!;
1264 setCrewRole(app.db, repo.id, reviewer.id, "reviewer");
1265 setCrewRole(app.db, repo.id, planner.id, "planner");
1266 const on = await updateRepoSettings(app, alice, repo, {
1267 autonomy: "autonomous",
1268 autonomyDirection: "",
1269 autonomyCeilingUsd: 10,
1270 autonomySlots: 2,
1271 autonomyBy: alice.id,
1272 });
1273 expect(on.ok).toBe(true);
1274 // One builder building, one reviewer reviewing, one planner planning.
1275 const inFlight: [string, User, string][] = [
1276 ["Warp the loom", getUserByName(app.db, "brick")!, "assigned"],
1277 ["Look at the shuttle", reviewer, "review"],
1278 ["Plan the next cloth", planner, "assigned"],
1279 ];
1280 for (const [title, actor, reason] of inFlight) {
1281 const issue = openIssue(app, alice, repo, { title });
1282 if (!issue.ok) throw new Error(String(issue.error));
1283 const run = createRun(app.db, {
1284 repoId: repo.id,
1285 kind: "agent",
1286 issueId: issue.value.id,
1287 actorId: actor.id,
1288 reason,
1289 });
1290 claimRun(app.db, run.id);
1291 }
1292 });
1293
1294 afterAll(async () => {
1295 servers.stop();
1296 app.close();
1297 await rm(dataDir, { recursive: true, force: true });
1298 });
1299
1300 test("reads 1 of 2 in use with three runs in flight, and Now still lists all three", async () => {
1301 const page = await (
1302 await fetch(`${base}/alice/loom`, {
1303 headers: { cookie: `${cookie}; forge_mode=studio` },
1304 redirect: "manual",
1305 })
1306 ).text();
1307 expect(page).toContain("1 of 2 in use");
1308 expect(page).not.toContain("3 of 2");
1309 expect(page).not.toContain("2 of 2");
1310 // The runs that take no slot are not hidden: Now has all of them.
1311 expect(page).toContain("Warp the loom");
1312 expect(page).toContain("Look at the shuttle");
1313 expect(page).toContain("Plan the next cloth");
1314 });
1315 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs