Public: anyone can read this; the crew can change it.
A test proves the crew governor: a repository fills the smaller of the spend pacing's and the governor's slots, and one `crew.governed` event is said per change #7529
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7523-a-test-proves-the-crew-governor-a-reposi → main 2 commits fork
1 file
changed +160
−0
test/crew-governed-unit.test.ts added +160
| @@ -0,0 +1,160 @@ | ||
| 1 | /** | |
| 2 | * `governorFor` and `announceGovernor` (src/crew/governed.ts) on their own: | |
| 3 | * the governor's slots read from the real vitals, one `crew.governed` event | |
| 4 | * per change of the governed number, the reason returned only while the | |
| 5 | * governor holds the crew below the operator's slots, and `repoPace` taking | |
| 6 | * the smaller of spend pacing's slots and the governor's. The picker itself | |
| 7 | * (runs started, the feed, the tick line) is proved in crew-governed.test.ts. | |
| 8 | * Each test has its own database, so none depends on another's pulls. | |
| 9 | */ | |
| 10 | import { afterEach, expect, test } from "bun:test"; | |
| 11 | import { mkdtempSync, rmSync } from "node:fs"; | |
| 12 | import { tmpdir } from "node:os"; | |
| 13 | import { join } from "node:path"; | |
| 14 | import { createApp, type App } from "../src/app.ts"; | |
| 15 | import { createRepo, type Repo } from "../src/core/repos.ts"; | |
| 16 | import { createRun } from "../src/core/runs.ts"; | |
| 17 | import { noteLimit, setHeadroom } from "../src/core/standing.ts"; | |
| 18 | import { addSubscription, setGrants } from "../src/core/subscriptions.ts"; | |
| 19 | import { recordUsage } from "../src/core/usage.ts"; | |
| 20 | import { createUser } from "../src/core/users.ts"; | |
| 21 | import { repoPace } from "../src/crew/autonomy.ts"; | |
| 22 | import { announceGovernor, governorFor } from "../src/crew/governed.ts"; | |
| 23 | import { governedSlots } from "../src/crew/governor.ts"; | |
| 24 | import { readVitals } from "../src/flow/vitals.ts"; | |
| 25 | import { id } from "../src/kernel/id.ts"; | |
| 26 | ||
| 27 | const NOW = new Date("2026-10-02T15:00:00Z"); | |
| 28 | const ago = (h: number) => new Date(NOW.getTime() - h * 3_600_000).toISOString(); | |
| 29 | ||
| 30 | const cleanup: (() => void)[] = []; | |
| 31 | afterEach(() => { | |
| 32 | while (cleanup.length > 0) cleanup.pop()!(); | |
| 33 | }); | |
| 34 | ||
| 35 | /** A fresh forge with one repository whose operator's slots are `slots`. */ | |
| 36 | async function forge(slots: number) { | |
| 37 | const root = mkdtempSync(join(tmpdir(), "forge-governed-unit-")); | |
| 38 | const app: App = createApp({ dataDir: root, port: 0 }); | |
| 39 | cleanup.push(() => { | |
| 40 | app.close(); | |
| 41 | rmSync(root, { recursive: true, force: true }); | |
| 42 | }); | |
| 43 | const u = await createUser(app.db, { username: "alice", email: "[email protected]", password: "a-good-passphrase", origin: "seed", trusted: true, adminIfFirst: true }); | |
| 44 | if (!u.ok) throw new Error(u.error); | |
| 45 | const r = await createRepo(app.db, app.reposRoot, { ownerId: u.value.id, name: "gov", visibility: "public", description: "g" }); | |
| 46 | if (!r.ok) throw new Error(r.error); | |
| 47 | const repo: Repo = { ...r.value, autonomySlots: slots }; | |
| 48 | let n = 0; | |
| 49 | /** A pull opened `openedH` hours before NOW; merged `mergedH` hours before it, when given. */ | |
| 50 | const pull = (openedH: number, mergedH: number | null): void => { | |
| 51 | const issueId = id(); | |
| 52 | n++; | |
| 53 | app.db | |
| 54 | .query(`INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state, created_at) VALUES (?1, ?2, ?3, 'pull', ?4, '', ?5, ?6, ?7)`) | |
| 55 | .run(issueId, repo.id, n, `P${n}`, u.value.id, mergedH === null ? "open" : "closed", ago(openedH)); | |
| 56 | app.db | |
| 57 | .query(`INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, head_repo_id, merged_at) VALUES (?1, ?2, ?3, '', '', ?4, ?5)`) | |
| 58 | .run(issueId, repo.defaultBranch, `b/${n}`, repo.id, mergedH === null ? null : ago(mergedH)); | |
| 59 | }; | |
| 60 | const events = () => | |
| 61 | app.db | |
| 62 | .query<{ payload: string }, [string]>(`SELECT payload FROM event WHERE kind = 'crew.governed' AND repo_id = ?1 ORDER BY rowid`) | |
| 63 | .all(repo.id) | |
| 64 | .map((e) => JSON.parse(e.payload) as { from: number; to: number; why: string }); | |
| 65 | return { app, repo, pull, events, userId: u.value.id }; | |
| 66 | } | |
| 67 | ||
| 68 | const later = (s: number) => new Date(NOW.getTime() + s * 1000); | |
| 69 | ||
| 70 | test("a quiet forge answers the operator's slots, says nothing, and the pace follows", async () => { | |
| 71 | const { app, repo, events } = await forge(5); | |
| 72 | const g = governorFor(app, repo, NOW); | |
| 73 | expect(g.slots).toBe(5); | |
| 74 | expect(g.why).toContain("Builders 5"); | |
| 75 | expect(announceGovernor(app, repo, NOW)).toBeUndefined(); | |
| 76 | expect(events()).toEqual([]); | |
| 77 | expect(repoPace(app, repo, NOW).slots).toBe(5); | |
| 78 | }); | |
| 79 | ||
| 80 | test("a pile lowers the slots: governorFor and the pace take the smaller, announce says it once", async () => { | |
| 81 | const { app, repo, pull, events } = await forge(4); | |
| 82 | for (let i = 0; i < 12; i++) pull(20 + i, null); // 12 open, none landed | |
| 83 | const vitals = readVitals(app.db, NOW); | |
| 84 | expect(vitals).toMatchObject({ open: 12, landed1h: 0 }); | |
| 85 | const want = governedSlots(vitals, 4); | |
| 86 | expect(want.slots).toBeLessThan(4); | |
| 87 | ||
| 88 | const g = governorFor(app, repo, NOW); | |
| 89 | expect(g).toEqual(want); | |
| 90 | expect(g.why).toContain("12 open vs 0 landed"); | |
| 91 | // No subscription accounts: spend pacing allows all 4, so the governor's is the smaller. | |
| 92 | expect(repoPace(app, repo, NOW).slots).toBe(g.slots); | |
| 93 | ||
| 94 | expect(announceGovernor(app, repo, NOW)).toBe(g.why); | |
| 95 | expect(announceGovernor(app, repo, later(1))).toBe(g.why); | |
| 96 | expect(announceGovernor(app, repo, later(2))).toBe(g.why); | |
| 97 | expect(events()).toEqual([{ from: 4, to: g.slots, why: g.why }]); | |
| 98 | }); | |
| 99 | ||
| 100 | test("spend pacing is the smaller when the windows are nearly spent, the governor when the pile is", async () => { | |
| 101 | const { app, repo, pull, userId } = await forge(5); | |
| 102 | const added = addSubscription(app.db, app.config.dataDir, { name: "acct", token: "sk-ant-oat01-governed-test" }); | |
| 103 | if (!added.ok) throw new Error(added.error); | |
| 104 | const sub = added.value.id; | |
| 105 | setGrants(app.db, repo.id, userId, [sub]); | |
| 106 | expect(setHeadroom(app.db, sub, 0)).toBe(true); | |
| 107 | const spend = (start: string, usd: number): void => { | |
| 108 | recordUsage(app.db, { | |
| 109 | runId: createRun(app.db, { repoId: repo.id, kind: "agent" }).id, | |
| 110 | repoId: repo.id, issueId: null, agentId: null, subscriptionId: sub, | |
| 111 | tokensIn: 1000, tokensOut: 100, costUsd: usd, wallMs: 60_000, outcome: "ok", | |
| 112 | at: new Date(Date.parse(start) + 60_000).toISOString(), | |
| 113 | }); | |
| 114 | }; | |
| 115 | // The provider refused at a $5 cap in the window 09:00–14:00; a run for $3 in the next window leaves $2. | |
| 116 | spend("2026-10-02T09:30:00Z", 5); | |
| 117 | noteLimit(app.db, sub, { window: "5h", resetsAt: "2026-10-02T14:00:00.000Z", observedAt: "2026-10-02T11:00:00.000Z", said: "You've hit your session limit · resets 2pm (UTC)" }); | |
| 118 | spend("2026-10-02T14:30:00Z", 3); | |
| 119 | ||
| 120 | // Quiet forge: the governor allows 5, spend pacing allows 2; the pace is spend's. | |
| 121 | expect(governorFor(app, repo, NOW).slots).toBe(5); | |
| 122 | const paced = repoPace(app, repo, NOW); | |
| 123 | expect(paced.slots).toBe(2); | |
| 124 | expect(paced.reason).toContain("windows are filling"); | |
| 125 | // The reason the tick line carries is the governor's, and it holds nothing back here. | |
| 126 | expect(announceGovernor(app, repo, NOW)).toBeUndefined(); | |
| 127 | ||
| 128 | // A pile: the governor's 1 is now the smaller of the two. | |
| 129 | for (let i = 0; i < 12; i++) pull(20 + i, null); | |
| 130 | expect(governorFor(app, repo, NOW).slots).toBe(1); | |
| 131 | expect(repoPace(app, repo, NOW).slots).toBe(1); | |
| 132 | }); | |
| 133 | ||
| 134 | test("the reason is absent when the governed number equals the operator's slots", async () => { | |
| 135 | const one = await forge(1); | |
| 136 | for (let i = 0; i < 12; i++) one.pull(20 + i, null); | |
| 137 | expect(governorFor(one.app, one.repo, NOW).slots).toBe(1); | |
| 138 | expect(announceGovernor(one.app, one.repo, NOW)).toBeUndefined(); | |
| 139 | expect(one.events()).toEqual([]); | |
| 140 | const off = await forge(0); | |
| 141 | expect(announceGovernor(off.app, off.repo, NOW)).toBeUndefined(); | |
| 142 | expect(off.events()).toEqual([]); | |
| 143 | }); | |
| 144 | ||
| 145 | test("a recovery is a second change: one more event, then no reason", async () => { | |
| 146 | const { app, repo, pull, events } = await forge(4); | |
| 147 | for (let i = 0; i < 12; i++) pull(20 + i, null); | |
| 148 | expect(readVitals(app.db, NOW)).toMatchObject({ open: 12, landed1h: 0 }); | |
| 149 | expect(announceGovernor(app, repo, NOW)).toBeDefined(); | |
| 150 | const held = governorFor(app, repo, NOW).slots; | |
| 151 | expect(held).toBeLessThan(4); | |
| 152 | ||
| 153 | // Six more land in the last hour: 12 open is no more than 3 x 6 landed, and the hour's inflow is zero. | |
| 154 | for (let i = 0; i < 6; i++) pull(30 + i, 0.1); | |
| 155 | expect(readVitals(app.db, later(5))).toMatchObject({ open: 12, landed1h: 6, opened1h: 0 }); | |
| 156 | expect(governorFor(app, repo, later(5)).slots).toBe(4); | |
| 157 | expect(announceGovernor(app, repo, later(5))).toBeUndefined(); | |
| 158 | expect(announceGovernor(app, repo, later(6))).toBeUndefined(); | |
| 159 | expect(events().map((e) => [e.from, e.to])).toEqual([[4, held], [held, 4]]); | |
| 160 | }); |
new above
offline