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
Check · Review · Land · Live done — Landed and live in production.
mason/forge:mason/50-4-of-2-in-use-a-slot-count-that-means-wh → main 1 commit fork
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. | ||
| 183 | 183 | |
| 184 | 184 | **What holds it.** The ceiling is dollars of agent spend per UTC day; at |
| 185 | 185 | 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. | |
| 193 | 197 | |
| 194 | 198 | ## The decider |
| 195 | 199 |
src/crew/autonomy.ts +33 −8
| @@ -86,7 +86,10 @@ export interface AutonomyStatus { | ||
| 86 | 86 | readonly spentTodayUsd: number; |
| 87 | 87 | readonly ceilingUsd: number; |
| 88 | 88 | 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 | */ | |
| 90 | 93 | readonly active: number; |
| 91 | 94 | readonly paused: Paused | null; |
| 92 | 95 | /** When a ceiling pause lifts: the next UTC midnight. */ |
| @@ -120,6 +123,32 @@ function activeRuns(app: App, repoId: string) { | ||
| 120 | 123 | return listActive(app.db, "agent").filter((r) => r.repoId === repoId); |
| 121 | 124 | } |
| 122 | 125 | |
| 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 | ||
| 123 | 152 | export function autonomyStatus( |
| 124 | 153 | app: App, |
| 125 | 154 | repo: Repo, |
| @@ -144,7 +173,7 @@ export function autonomyStatus( | ||
| 144 | 173 | spentTodayUsd: spent, |
| 145 | 174 | ceilingUsd: repo.autonomyCeilingUsd, |
| 146 | 175 | slots: repo.autonomySlots, |
| 147 | active: activeRuns(app, repo.id).length, | |
| 176 | active: slottedRuns(activeRuns(app, repo.id), crew).length, | |
| 148 | 177 | paused, |
| 149 | 178 | resumesAt: paused === "ceiling" ? tomorrow.toISOString() : null, |
| 150 | 179 | }; |
| @@ -171,15 +200,11 @@ export async function driveRepo( | ||
| 171 | 200 | const builders = crew.filter((c) => c.role === "builder").map((c) => c.agent); |
| 172 | 201 | const planner = crew.find((c) => c.role === "planner")?.agent; |
| 173 | 202 | 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. | |
| 176 | 204 | const planners = new Set( |
| 177 | 205 | crew.filter((c) => c.role !== "builder").map((c) => c.agent.user.id), |
| 178 | 206 | ); |
| 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); | |
| 183 | 208 | if (slotted.length >= repo.autonomySlots) { |
| 184 | 209 | // The builders are busy; the planner can still refill the map. |
| 185 | 210 | 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", () => { | ||
| 447 | 447 | expect(map).toContain("of $25 today"); |
| 448 | 448 | }); |
| 449 | 449 | }); |
| 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"; | ||
| 14 | 14 | import { getAppByRepo, type AppRecord } from "../src/core/apps.ts"; |
| 15 | 15 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; |
| 16 | 16 | 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"; | |
| 18 | 24 | import { getIssue, listIssues } from "../src/core/issues.ts"; |
| 19 | 25 | import { recordLook } from "../src/core/looks.ts"; |
| 20 | 26 | import { getPull, type Pull } from "../src/core/pulls.ts"; |
| @@ -1197,3 +1203,113 @@ describe("the app's live fragment: /:owner/:name/app/state", () => { | ||
| 1197 | 1203 | expect(live).toContain(link); |
| 1198 | 1204 | }); |
| 1199 | 1205 | }); |
| 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 | }); |
new above
offline