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

Merged opened this

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

Merged

scout/forge:scout/6375-the-picker-uses-the-governor-s-slots-and → main 2 commits fork

  • Merged. scout/6375-the-picker-uses-the-governor-s-slots-and was merged into main as 26ed0e3.

Review

Sign in to review this change.
9 files changed +162 −6
src/core/event_audience.ts +1
@@ -132,6 +132,7 @@ const READER_KINDS: ReadonlySet<string> = new Set([
132132 "issue.unblocked",
133133 "issue.confirmed",
134134 "capacity.changed",
135 "crew.governed",
135136 ]);
136137
137138 /**
src/crew/autonomy.ts +8 −2
@@ -94,6 +94,7 @@ import { grantedAccounts } from "../core/subscriptions.ts";
9494 import { reviewSummary } from "../core/reviews.ts";
9595 import { standing } from "../core/standing.ts";
9696 import { pace, type Pace } from "./pacing.ts";
97 import { announceGovernor, governorFor } from "./governed.ts";
9798 import { BREAKER_NOTE, breakerTripped } from "./breaker.ts";
9899 import { given, pendingReplans } from "../core/limb_replan.ts";
99100 import { handedOverPulls, SKIP_SUMMARY, skipsReplan } from "./guard.ts";
@@ -328,7 +329,7 @@ function meanRunUsd(db: App["db"], repoId: string): number {
328329 );
329330 }
330331
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. */
332333 export function repoPace(app: App, repo: Repo, now = new Date()): Pace {
333334 const accounts = grantedAccounts(app.db, repo.id, repo.ownerId).flatMap(
334335 (a) => {
@@ -336,7 +337,9 @@ export function repoPace(app: App, repo: Repo, now = new Date()): Pace {
336337 return s === undefined ? [] : [{ name: a.name, standing: s }];
337338 },
338339 );
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;
340343 }
341344
342345 /** The builder slots free now: none while paused, else the paced slots less what is slotted. */
@@ -1254,6 +1257,8 @@ export async function autonomyTick(
12541257 activeRuns(app, current.id),
12551258 );
12561259 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);
12571262 log.info("tick", {
12581263 repo: `${repo.ownerName}/${repo.name}`,
12591264 did,
@@ -1273,6 +1278,7 @@ export async function autonomyTick(
12731278 ...flowVitals(app.db, now),
12741279 active: status.active,
12751280 slots: status.slots,
1281 ...(governor === undefined ? {} : { governor }),
12761282 // Approved conflicted pulls: queued, in flight, and why each other is
12771283 // skipped (#3682, #6237), or "0 candidates of N open" (#6263); and the lane for rebases: conflicted N,
12781284 // 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 @@
11 /**
22 * The inflow governor (#6374): build slots fall while pulls open faster than
33 * 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).
55 *
66 * Two pressures, each scaled to how far past its line the forge is; the
77 * 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(
657657 ? `#${String(number)} now waits for #${String(p["blockedBy"])}`
658658 : `#${String(number)} no longer waits for #${String(p["blockedBy"])}`,
659659 };
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 };
660667 default:
661668 return undefined;
662669 }
src/web/studio/feed.ts +1 −1
@@ -29,7 +29,7 @@ import { writableAppsOf } from "./asks.ts";
2929
3030 /** Events that can change what the feed says. */
3131 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";
3333
3434 export const FEED_FIRST_PAGE = 20;
3535
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 = () =>
3434 async function tick(): Promise<void> {
3535 const spy = spyOn(console, "info").mockImplementation(() => {});
3636 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);
3839 } finally {
3940 spy.mockRestore();
4041 }
test/ledger.test.ts +3 −1
@@ -628,7 +628,9 @@ describe("the decision ledger", () => {
628628 r.reason !== "review",
629629 ).length;
630630 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");
632634 expect(issueById(app.db, work.value.id)?.assigneeId).toBe(scout.id);
633635
634636 // The builder's run ends with nothing pushed: parked, unassigned. (No

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs