Public: anyone can read this; the crew can change it.
The picker uses the governor's slots, and the feed and tick line say when it lowers them #6463
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/6375-the-picker-uses-the-governor-s-slots-and → main 2 commits fork
9 files
changed +162
−6
src/core/event_audience.ts +1
| @@ -132,6 +132,7 @@ const READER_KINDS: ReadonlySet<string> = new Set([ | ||
| 132 | 132 | "issue.unblocked", |
| 133 | 133 | "issue.confirmed", |
| 134 | 134 | "capacity.changed", |
| 135 | "crew.governed", | |
| 135 | 136 | ]); |
| 136 | 137 | |
| 137 | 138 | /** |
src/crew/autonomy.ts +8 −2
| @@ -94,6 +94,7 @@ import { grantedAccounts } from "../core/subscriptions.ts"; | ||
| 94 | 94 | import { reviewSummary } from "../core/reviews.ts"; |
| 95 | 95 | import { standing } from "../core/standing.ts"; |
| 96 | 96 | import { pace, type Pace } from "./pacing.ts"; |
| 97 | import { announceGovernor, governorFor } from "./governed.ts"; | |
| 97 | 98 | import { BREAKER_NOTE, breakerTripped } from "./breaker.ts"; |
| 98 | 99 | import { given, pendingReplans } from "../core/limb_replan.ts"; |
| 99 | 100 | import { handedOverPulls, SKIP_SUMMARY, skipsReplan } from "./guard.ts"; |
| @@ -328,7 +329,7 @@ function meanRunUsd(db: App["db"], repoId: string): number { | ||
| 328 | 329 | ); |
| 329 | 330 | } |
| 330 | 331 | |
| 331 | /** The crew's pace here: the operator's slots, held to what the windows have left. */ | |
| 332 | /** The crew's pace here: the operator's slots, held to what the windows have left and to the governor's. */ | |
| 332 | 333 | export function repoPace(app: App, repo: Repo, now = new Date()): Pace { |
| 333 | 334 | const accounts = grantedAccounts(app.db, repo.id, repo.ownerId).flatMap( |
| 334 | 335 | (a) => { |
| @@ -336,7 +337,9 @@ export function repoPace(app: App, repo: Repo, now = new Date()): Pace { | ||
| 336 | 337 | return s === undefined ? [] : [{ name: a.name, standing: s }]; |
| 337 | 338 | }, |
| 338 | 339 | ); |
| 339 | return pace(accounts, repo.autonomySlots, meanRunUsd(app.db, repo.id), now); | |
| 340 | const paced = pace(accounts, repo.autonomySlots, meanRunUsd(app.db, repo.id), now); | |
| 341 | const governed = governorFor(app, repo, now).slots; | |
| 342 | return governed < paced.slots ? { ...paced, slots: governed } : paced; | |
| 340 | 343 | } |
| 341 | 344 | |
| 342 | 345 | /** The builder slots free now: none while paused, else the paced slots less what is slotted. */ |
| @@ -1254,6 +1257,8 @@ export async function autonomyTick( | ||
| 1254 | 1257 | activeRuns(app, current.id), |
| 1255 | 1258 | ); |
| 1256 | 1259 | const boots = bootCounts(app.config.dataDir); |
| 1260 | // Says when the governor lowers the slots (#6375); one feed item per change. | |
| 1261 | const governor = announceGovernor(app, current, now); | |
| 1257 | 1262 | log.info("tick", { |
| 1258 | 1263 | repo: `${repo.ownerName}/${repo.name}`, |
| 1259 | 1264 | did, |
| @@ -1273,6 +1278,7 @@ export async function autonomyTick( | ||
| 1273 | 1278 | ...flowVitals(app.db, now), |
| 1274 | 1279 | active: status.active, |
| 1275 | 1280 | slots: status.slots, |
| 1281 | ...(governor === undefined ? {} : { governor }), | |
| 1276 | 1282 | // Approved conflicted pulls: queued, in flight, and why each other is |
| 1277 | 1283 | // skipped (#3682, #6237), or "0 candidates of N open" (#6263); and the lane for rebases: conflicted N, |
| 1278 | 1284 | // rebasing M, room R (#6140). |
src/crew/governed.ts added +38
| @@ -0,0 +1,38 @@ | ||
| 1 | /** | |
| 2 | * The governor wired into the picker (#6375): the slots a repository may | |
| 3 | * fill are the smaller of the spend pacing's and `governedSlots`' (#6374). | |
| 4 | * When the governed number changes, one `crew.governed` event says so, which | |
| 5 | * the feed shows once per change; the tick line carries the reason while | |
| 6 | * the governor holds the crew below the operator's slots. | |
| 7 | */ | |
| 8 | ||
| 9 | import type { App } from "../app.ts"; | |
| 10 | import type { Repo } from "../core/repos.ts"; | |
| 11 | import { readVitals } from "../flow/vitals.ts"; | |
| 12 | import { governedSlots, type Governed } from "./governor.ts"; | |
| 13 | ||
| 14 | /** The governor's reading of the forge's flow, against the operator's slots. */ | |
| 15 | export function governorFor(app: App, repo: Repo, now: Date): Governed { | |
| 16 | return governedSlots(readVitals(app.db, now), repo.autonomySlots); | |
| 17 | } | |
| 18 | ||
| 19 | /** The governed number each repository was last seen at; unseen means the operator's slots. */ | |
| 20 | const seen = new Map<string, number>(); | |
| 21 | ||
| 22 | /** | |
| 23 | * Says a change of the governed number, once: the event is appended only | |
| 24 | * when the number differs from the last tick's. Returns the reason while the | |
| 25 | * governor holds the crew below the operator's slots, else undefined. | |
| 26 | */ | |
| 27 | export function announceGovernor(app: App, repo: Repo, now: Date): string | undefined { | |
| 28 | const g = governorFor(app, repo, now); | |
| 29 | const before = seen.get(repo.id) ?? repo.autonomySlots; | |
| 30 | seen.set(repo.id, g.slots); | |
| 31 | if (g.slots !== before) | |
| 32 | app.bus.append({ | |
| 33 | kind: "crew.governed", | |
| 34 | repoId: repo.id, | |
| 35 | payload: { from: before, to: g.slots, why: g.why }, | |
| 36 | }); | |
| 37 | return g.slots < repo.autonomySlots ? g.why : undefined; | |
| 38 | } |
src/crew/governor.ts +1 −1
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | /** |
| 2 | 2 | * The inflow governor (#6374): build slots fall while pulls open faster than |
| 3 | 3 | * they land, and rise as the pile drains. Pure: the flow vitals and the |
| 4 | * operator's ceiling in, `{ slots, why }` out. Nothing calls it yet. | |
| 4 | * operator's ceiling in, `{ slots, why }` out. The picker reads it through `governed.ts` (#6375). | |
| 5 | 5 | * |
| 6 | 6 | * Two pressures, each scaled to how far past its line the forge is; the |
| 7 | 7 | * stronger wins. Vitals carry a 1 h and a 6 h window, so "stayed above 1.5× |
src/feed/items.ts +7
| @@ -657,6 +657,13 @@ function fromEvent( | ||
| 657 | 657 | ? `#${String(number)} now waits for #${String(p["blockedBy"])}` |
| 658 | 658 | : `#${String(number)} no longer waits for #${String(p["blockedBy"])}`, |
| 659 | 659 | }; |
| 660 | case "crew.governed": | |
| 661 | return { | |
| 662 | ...common, | |
| 663 | kind: "map", | |
| 664 | link: `${base(repo)}/map`, | |
| 665 | what: clip(typeof p["why"] === "string" ? p["why"] : "the crew's slots changed"), | |
| 666 | }; | |
| 660 | 667 | default: |
| 661 | 668 | return undefined; |
| 662 | 669 | } |
src/web/studio/feed.ts +1 −1
| @@ -29,7 +29,7 @@ import { writableAppsOf } from "./asks.ts"; | ||
| 29 | 29 | |
| 30 | 30 | /** Events that can change what the feed says. */ |
| 31 | 31 | export const FEED_LIVE = |
| 32 | "run.finished,issue.opened,issue.commented,pull.commented,pull.opened,pull.merged,pull.reviewed,pull.closed,look.taken,app.serving,issue.blocked,issue.unblocked,issue.closed,decision.*,thought.received,thought.placed"; | |
| 32 | "run.finished,issue.opened,issue.commented,pull.commented,pull.opened,pull.merged,pull.reviewed,pull.closed,look.taken,app.serving,issue.blocked,issue.unblocked,crew.governed,issue.closed,decision.*,thought.received,thought.placed"; | |
| 33 | 33 | |
| 34 | 34 | export const FEED_FIRST_PAGE = 20; |
| 35 | 35 |
test/crew-governed.test.ts added +101
| @@ -0,0 +1,101 @@ | ||
| 1 | /** The governor in the picker (#6375): a pile that opens faster than it lands starts fewer runs, and the feed says so once. */ | |
| 2 | import { afterAll, beforeAll, describe, expect, spyOn, test } from "bun:test"; | |
| 3 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 4 | import { tmpdir } from "node:os"; | |
| 5 | import { join } from "node:path"; | |
| 6 | import { createApp, type App } from "../src/app.ts"; | |
| 7 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 8 | import { openIssue } from "../src/actions/issues.ts"; | |
| 9 | import { commitToBranch, createRepo, updateRepoSettings } from "../src/actions/repos.ts"; | |
| 10 | import { getRepoById, type Repo } from "../src/core/repos.ts"; | |
| 11 | import { createUser, type User } from "../src/core/users.ts"; | |
| 12 | import { listEventsVisibleTo } from "../src/core/events.ts"; | |
| 13 | import { readFeed } from "../src/feed/feed.ts"; | |
| 14 | import { autonomyTick } from "../src/crew/autonomy.ts"; | |
| 15 | import { id } from "../src/kernel/id.ts"; | |
| 16 | import { logLevel, setLogLevel } from "../src/kernel/log.ts"; | |
| 17 | ||
| 18 | describe("the governor in the picker", () => { | |
| 19 | let dataDir: string; | |
| 20 | let app: App; | |
| 21 | let alice: User; | |
| 22 | let repo: Repo; | |
| 23 | const level = logLevel(); | |
| 24 | const NOW = new Date(Date.now() + 120_000); | |
| 25 | const ago = (min: number) => new Date(NOW.getTime() - min * 60_000).toISOString(); | |
| 26 | const governed = () => | |
| 27 | listEventsVisibleTo(app.db, alice, { limit: 200 }).filter((e) => e.kind === "crew.governed"); | |
| 28 | const started = () => | |
| 29 | app.db.query<{ n: number }, [string]>("SELECT COUNT(*) AS n FROM issue WHERE repo_id = ?1 AND kind = 'issue' AND assignee_id IS NOT NULL").get(repo.id)!.n; | |
| 30 | ||
| 31 | beforeAll(async () => { | |
| 32 | setLogLevel("info"); | |
| 33 | dataDir = await mkdtemp(join(tmpdir(), "forge-governed-")); | |
| 34 | app = createApp({ dataDir, port: 0 }); | |
| 35 | const a = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }); | |
| 36 | if (!a.ok) throw new Error(a.error); | |
| 37 | alice = a.value; | |
| 38 | const made = await createRepo(app, alice, { name: "pile", visibility: "private" }); | |
| 39 | if (!made.ok) throw new Error(String(made.error)); | |
| 40 | repo = made.value; | |
| 41 | const seeded = await commitToBranch(app, alice, repo, { message: "Start", files: [{ path: "README.md", content: "# pile\n" }] }); | |
| 42 | if (!seeded.ok) throw new Error(JSON.stringify(seeded.error)); | |
| 43 | for (const name of ["brick", "chart", "lathe", "mold"]) { | |
| 44 | const agent = await createAgent(app, alice, { username: name }); | |
| 45 | if (!agent.ok) throw new Error(String(agent.error)); | |
| 46 | expect(addToCrew(app, alice, repo, name).ok).toBe(true); | |
| 47 | } | |
| 48 | const on = await updateRepoSettings(app, alice, repo, { | |
| 49 | autonomy: "autonomous", | |
| 50 | autonomyDirection: "Weave the loom", | |
| 51 | autonomyCeilingUsd: 100, | |
| 52 | autonomySlots: 5, | |
| 53 | autonomyBy: alice.id, | |
| 54 | }); | |
| 55 | expect(on.ok).toBe(true); | |
| 56 | // 40 opened in the hour, 10 of them landed half an hour ago: 30 stay open. | |
| 57 | for (let n = 1; n <= 40; n++) { | |
| 58 | const issueId = id(); | |
| 59 | const landed = n <= 10; | |
| 60 | app.db | |
| 61 | .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)`) | |
| 62 | .run(issueId, repo.id, 1000 + n, `P${n}`, alice.id, landed ? "closed" : "open", ago(20)); | |
| 63 | app.db | |
| 64 | .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)`) | |
| 65 | .run(issueId, repo.defaultBranch, `b/${n}`, repo.id, landed ? ago(30) : null); | |
| 66 | } | |
| 67 | for (let n = 0; n < 8; n++) { | |
| 68 | const opened = openIssue(app, alice, repo, { title: `Ready ${n}` }); | |
| 69 | if (!opened.ok) throw new Error(String(opened.error)); | |
| 70 | } | |
| 71 | }); | |
| 72 | ||
| 73 | afterAll(async () => { | |
| 74 | setLogLevel(level); | |
| 75 | app.close(); | |
| 76 | await rm(dataDir, { recursive: true, force: true }); | |
| 77 | }); | |
| 78 | ||
| 79 | test("starts the governed number, no more, says why on the tick line, and the feed item appears once across two ticks", async () => { | |
| 80 | const lines: string[] = []; | |
| 81 | const spy = spyOn(console, "info").mockImplementation((line: unknown) => { | |
| 82 | lines.push(String(line)); | |
| 83 | }); | |
| 84 | try { | |
| 85 | await autonomyTick(app, NOW, async () => 0); | |
| 86 | await autonomyTick(app, NOW, async () => 0); | |
| 87 | } finally { | |
| 88 | spy.mockRestore(); | |
| 89 | } | |
| 90 | // 5 slots × 1.5 × 10 landed ÷ 40 opened = 1. | |
| 91 | expect(getRepoById(app.db, repo.id)!.autonomySlots).toBe(5); | |
| 92 | expect(started()).toBe(1); | |
| 93 | const ticks = lines.filter((l) => l.includes("tick") && l.includes("pile")); | |
| 94 | expect(ticks.length).toBe(2); | |
| 95 | for (const t of ticks) expect(t).toContain("Builders 5 → 1: 40 opened vs 10 landed last hour"); | |
| 96 | expect(governed().length).toBe(1); | |
| 97 | expect(governed()[0]!.payload).toMatchObject({ from: 5, to: 1 }); | |
| 98 | const { items } = readFeed(app.db, alice, { now: NOW.getTime() }); | |
| 99 | expect(items.filter((i) => i.what.includes("Builders 5 → 1")).length).toBe(1); | |
| 100 | }); | |
| 101 | }); |
test/finishing-room.test.ts +2 −1
| @@ -34,7 +34,8 @@ const conflicts = () => | ||
| 34 | 34 | async function tick(): Promise<void> { |
| 35 | 35 | const spy = spyOn(console, "info").mockImplementation(() => {}); |
| 36 | 36 | try { |
| 37 | await autonomyTick(app, new Date(), async () => 0); | |
| 37 | // Seven hours on, the governor's flow windows hold nothing: the lane, not the pile, limits (#6375). | |
| 38 | await autonomyTick(app, new Date(Date.now() + 7 * 3_600_000), async () => 0); | |
| 38 | 39 | } finally { |
| 39 | 40 | spy.mockRestore(); |
| 40 | 41 | } |
test/ledger.test.ts +3 −1
| @@ -628,7 +628,9 @@ describe("the decision ledger", () => { | ||
| 628 | 628 | r.reason !== "review", |
| 629 | 629 | ).length; |
| 630 | 630 | updateRepoSettings(app.db, fresh(), { autonomySlots: builders + 1 }); |
| 631 | expect(await driveRepo(app, fresh())).toBe("picked"); | |
| 631 | // Seven hours on, the governor's flow windows hold nothing: this is about slots, not the pile (#6375). | |
| 632 | const later = new Date(Date.now() + 7 * 3_600_000); | |
| 633 | expect(await driveRepo(app, fresh(), later)).toBe("picked"); | |
| 632 | 634 | expect(issueById(app.db, work.value.id)?.assigneeId).toBe(scout.id); |
| 633 | 635 | |
| 634 | 636 | // The builder's run ends with nothing pushed: parked, unassigned. (No |
new above
offline