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

The governor traps the forge at one builder: a stale pile and one quiet hour hold every repository down #7646

Merged opened this in answer to

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

Merged

torque/forge:torque/7644-the-governor-traps-the-forge-at-one-buil → main 5 commits fork 1 thread · 1 unresolved

  • Merged. torque/7644-the-governor-traps-the-forge-at-one-buil was merged into main as 58753ca.

Review

Sign in to review this change.
17 files changed +444 −80
README.md +9
@@ -30,6 +30,15 @@ claim state and the same apps-running note, never a claim link. Both carry the
3030 crew's spend today against the daily ceiling that pauses it (`$3.20 of $10 today`), as the
3131 child's heartbeat last reported it.
3232
33 While the crew is on, the governor says how many builders it allows and why,
34 from the repository's own flow — pulls opened and landed in the last hour, and
35 how many open pulls are moving (`Builders 5 → 2: 30 pulls moving, 12 opened vs 2
36 landed last hour`; `src/crew/governor.ts`). It is the Governor row on the app
37 page's crew card and on Settings → Crew, always; on the terminal's home it
38 takes the repository's row only while the governor holds the crew below its
39 slots, and the row keeps its description otherwise; and `governor { slots,
40 ceiling, held, why }` on MCP `crew`; for people who can write here and the crew.
41
3342 Each agent's scorecard — pulls landed of opened, review rounds, red checks,
3443 reverts, time to land — is on Settings → Crew (`/settings/crew`) and in the
3544 terminal's palette as "Crew scorecard per agent"; both are a forge admin's only.
docs/MCP.md +1 −1
@@ -70,7 +70,7 @@ for a tunnel and a forge that sleeps.
7070 | `confirm_wait` | `{ app, issue, blocker }`: a person says `issue` may go ahead although `blocker` closed as dropped or split; the API's confirm action, so writers only and never the crew | yes |
7171 | `contribute` | for any arriving agent: what this forge is, its bottleneck, open “help wanted” issues, how to propose | |
7272 | `converge` | the fork's crew merges where both sides changed the same files; comes back as a pull | yes |
73 | `crew` | `{ repo, add?, remove?, dryRun? }`: without `add` or `remove`, the repository's crew as Settings › Crew reads it (`username`, `displayName`, `role`, and `config` for those who may see it); `add` or `remove` (an agent's username) says what would change, planned first (`dryRun` is true unless `false`); applying is the repository's admin with an admin token, never an agent; refusals are `no_such_agent`, `not_an_agent`, `unverified` | yes |
73 | `crew` | `{ repo, add?, remove?, dryRun? }`: without `add` or `remove`, the repository's crew as Settings › Crew reads it (`username`, `displayName`, `role`, and `config` for those who may see it) and `governor { slots, ceiling, held, why }`, the builders the governor allows now and why (`null` while the crew is off or for a reader who cannot write here); `add` or `remove` (an agent's username) says what would change, planned first (`dryRun` is true unless `false`); applying is the repository's admin with an admin token, never an agent; refusals are `no_such_agent`, `not_an_agent`, `unverified` | yes |
7474 | `decisions_by` | `{ repo, by?, dryRun? }`: the standing rule for a repository's hand-overs (`me`, `decider`, `decider_tell`); with `by`, planned first (`dryRun` is true unless `false`); set only by the app's admin with an admin token, never an agent | yes |
7575 | `detach` | `{ repo, dryRun? }`: detach a fork from its parent — repack so it owns its objects, drop the alternates link, forget the parent; planned first (`dryRun` is true unless `false`); the repack runs on its own, so call again to read its `phase` (`repacking`, `failed`) until the fork has no `parent`; only the repository's admin with an admin token, never an agent | yes |
7676 | `drills` | each failure domain: `key`, what breaks, its last drill's time, green or red and why (or why it is not drilled), and the `command` that runs it; a forge admin who is a person only (`forbidden` otherwise), as Settings → Backups says them | |
src/crew/governed.ts +23 −4
@@ -7,13 +7,32 @@
77 */
88
99 import type { App } from "../app.ts";
10 import type { Repo } from "../core/repos.ts";
11 import { readVitals } from "../flow/vitals.ts";
10 import { canSeeOwnerFacts, type Repo } from "../core/repos.ts";
11 import type { User } from "../core/users.ts";
12 import { readRepoFlow } from "../flow/repo_flow.ts";
1213 import { governedSlots, type Governed } from "./governor.ts";
1314
14 /** The governor's reading of the forge's flow, against the operator's slots. */
15 /** The governor's reading of this repository's own flow, against the operator's slots (#7644). */
1516 export function governorFor(app: App, repo: Repo, now: Date): Governed {
16 return governedSlots(readVitals(app.db, now), repo.autonomySlots);
17 return governedSlots(readRepoFlow(app.db, repo.id, now), repo.autonomySlots);
18 }
19
20 /** The governor as one reader sees it: its slots, the operator's ceiling, and the reason. */
21 export interface GovernorSeen extends Governed {
22 readonly ceiling: number;
23 /** True while the governor holds the crew below the operator's slots. */
24 readonly held: boolean;
25 }
26
27 /**
28 * The governor for every door — the studio's two crew cards, the terminal's
29 * repository row and MCP `crew` (#7644) — gated and worded alike: undefined
30 * while the crew is off, or for a reader who may not see the owner's facts.
31 */
32 export function governorSeen(app: App, actor: User | undefined, repo: Repo, now: Date): GovernorSeen | undefined {
33 if (repo.autonomy === "off" || !canSeeOwnerFacts(app.db, repo, actor)) return undefined;
34 const g = governorFor(app, repo, now);
35 return { ...g, ceiling: repo.autonomySlots, held: g.slots < repo.autonomySlots };
1736 }
1837
1938 /** The governed number each repository was last seen at; unseen means the operator's slots. */
src/crew/governor.test.ts deleted −31
@@ -1,31 +0,0 @@
1 import { describe, expect, test } from "bun:test";
2 import type { Vitals } from "../flow/vitals.ts";
3 import { governedSlots } from "./governor.ts";
4
5 const v = (p: Partial<Vitals>): Vitals => ({
6 open: 0, opened1h: 0, landed1h: 0, opened6h: 0, landed6h: 0, conflicted: 0, red: 0, queued: 0, oldestApprovedHours: null, strandedUsd: 0, gateMinutes1h: 0, pullCheckMinutes1h: 0, batches6h: 0, redBatchRate: null, ...p,
7 });
8
9 describe("governedSlots", () => {
10 const rows: [string, Partial<Vitals>, number, number][] = [
11 ["quiet forge stays at the ceiling", {}, 7, 7],
12 ["balanced flow stays", { open: 5, opened1h: 10, landed1h: 9, opened6h: 50, landed6h: 50 }, 7, 7],
13 ["inflow falls", { open: 8, opened1h: 51, landed1h: 15, opened6h: 120, landed6h: 40 }, 7, 3],
14 ["one bad hour alone does not", { open: 8, opened1h: 51, landed1h: 15, opened6h: 60, landed6h: 60 }, 7, 7],
15 ["inflow with nothing landed floors at 1", { open: 4, opened1h: 20, landed1h: 0, opened6h: 60, landed6h: 2 }, 7, 1],
16 ["a pile falls", { open: 30, opened1h: 5, landed1h: 5, opened6h: 30, landed6h: 30 }, 6, 3],
17 ["a small pile is not a pile", { open: 6, opened1h: 0, landed1h: 0 }, 7, 7],
18 ["the pile drains and slots rise", { open: 12, opened1h: 4, landed1h: 8, opened6h: 30, landed6h: 40 }, 7, 7],
19 ["ceiling 0 stays 0", { open: 50, opened1h: 9 }, 0, 0],
20 ["ceiling 1 stays 1", { open: 50, opened1h: 9 }, 1, 1],
21 ["NaN ceiling is 0", {}, NaN, 0],
22 ["infinite ceiling is 0", {}, Infinity, 0],
23 ];
24 for (const [name, vit, ceiling, want] of rows)
25 test(name, () => expect(governedSlots(v(vit), ceiling).slots).toBe(want));
26
27 test("why is one plain sentence naming the numbers", () => {
28 const g = governedSlots(v({ open: 8, opened1h: 51, landed1h: 15, opened6h: 120, landed6h: 40 }), 7);
29 expect(g.why).toBe("Builders 7 → 3: 51 opened vs 15 landed last hour");
30 });
31 });
src/crew/governor.ts +45 −16
@@ -1,47 +1,76 @@
11 /**
2 * The inflow governor (#6374): build slots fall while pulls open faster than
3 * they land, and rise as the pile drains. Pure: the flow vitals and the
4 * operator's ceiling in, `{ slots, why }` out. The picker reads it through `governed.ts` (#6375).
2 * The inflow governor (#6374, #7644): build slots fall while pulls open
3 * faster than they land, and rise as the pile drains. Pure: one repository's
4 * flow and the operator's ceiling in, `{ slots, why }` out. The picker reads
5 * it through `governed.ts` (#6375).
56 *
6 * Two pressures, each scaled to how far past its line the forge is; the
7 * stronger wins. Vitals carry a 1 h and a 6 h window, so "stayed above 1.5×
8 * for 2 h" reads as both windows above it: the last hour, and the six-hour
9 * average. A pile of fewer than PILE_MIN open pulls is never a pile.
7 * Two brakes, each scaled to how far past its line the repository is; the
8 * stronger wins. Both react to pulls opened faster than they land, never to
9 * silence: an hour with nothing opened cannot lower the slots.
10 *
11 * Neither brakes until the hour opened at least LAG more than it landed:
12 * one pull per builder, and never under PILE_MIN / 2. A pull lands about an
13 * hour after it opens, so a crew rising from 1 to its ceiling opens up to
14 * ceiling − 1 more than it lands that hour by lag alone; braking on that
15 * swung the slots 5, 1, 5, 1 and held one builder at 1 over a pile (#7712).
16 *
17 * - Inflow: more than 1.5× as many opened as landed in the last hour and over
18 * six, and the six hours left at least PILE_MIN more open than they closed.
19 * - Pile: at least PILE_MIN pulls moving, more than 3× the hour's landings.
20 * Only moving pulls count
21 * (`repo_flow.ts`): a red or long-untouched pull is parked, and a parked
22 * pile no builder is working cannot hold the builders down.
23 *
24 * From one builder the brakes let go as soon as it lands what it opens, so
25 * a slow hour cannot trap the crew at one.
1026 */
1127
12 import type { Vitals } from "../flow/vitals.ts";
28 import type { RepoFlow } from "../flow/repo_flow.ts";
1329
1430 export const INFLOW_RATIO = 1.5;
1531 export const PILE_RATIO = 3;
1632 export const PILE_MIN = 10;
1733
34 /** The hour's excess (opened − landed) below which neither brake acts at `ceiling`. */
35 export const lag = (ceiling: number): number => Math.max(ceiling, PILE_MIN / 2);
36
1837 export interface Governed {
1938 readonly slots: number;
2039 /** One plain sentence; says nothing is held back at the ceiling. */
2140 readonly why: string;
2241 }
2342
24 /** Slots in 1..ceiling (0 when the ceiling is 0) for `vitals`. */
25 export function governedSlots(vitals: Vitals, ceiling: number): Governed {
43 /** Slots in 1..ceiling (0 when the ceiling is 0) for `flow`. */
44 export function governedSlots(flow: RepoFlow, ceiling: number): Governed {
2645 const top = Number.isFinite(ceiling) ? Math.max(0, Math.floor(ceiling)) : 0;
2746 // At 0 or 1 there is nothing to hold back; the final clamp to 1 below is for ceilings of 2 and up.
2847 if (top <= 1) return { slots: top, why: `Builders ${top}: nothing to hold back` };
29 const { open, opened1h, landed1h, opened6h, landed6h } = vitals;
48 const { open, moving, opened1h, landed1h, opened6h, landed6h } = flow;
3049 let slots = top;
31 let why = `Builders ${top}: ${opened1h} opened, ${landed1h} landed last hour, ${open} open`;
50 let why =
51 opened1h === 0 && landed1h === 0
52 ? `Builders ${top}: a quiet hour, ${moving} of ${open} open pulls moving`
53 : `Builders ${top}: ${opened1h} opened, ${landed1h} landed last hour, ${moving} of ${open} open pulls moving`;
3254
33 if (opened1h > INFLOW_RATIO * landed1h && opened6h > INFLOW_RATIO * landed6h) {
55 const flooding = opened1h - landed1h >= lag(top);
56
57 if (
outdated

High: with moving >= 10, landed1h = 0 and opened1h = 1 (one builder that has just opened its first pull), this brake gives floor(top*3*0/moving) = 0, so slots = 1. The crew stays held at 1 until something lands, which with one builder takes more than an hour. Fix: scale the brake by the hour's excess rather than by landings alone, e.g. require opened1h - landed1h >= PILE_MIN/2, or never brake below half the ceiling on a one-pull excess. Pin it with a row of moving 12, opened1h 1, landed1h 0, expecting the ceiling.

58 flooding &&
59 opened1h > INFLOW_RATIO * landed1h &&
60 opened6h > INFLOW_RATIO * landed6h &&
61 opened6h - landed6h >= PILE_MIN
62 ) {
3463 const n = Math.floor((top * INFLOW_RATIO * landed1h) / opened1h);
3564 if (n < slots) {
3665 slots = n;
3766 why = `Builders ${top} → ${Math.max(1, n)}: ${opened1h} opened vs ${landed1h} landed last hour`;
3867 }
3968 }
40 if (open >= PILE_MIN && open > PILE_RATIO * landed1h) {
41 const n = Math.floor((top * PILE_RATIO * landed1h) / open);
69 if (flooding && moving >= PILE_MIN && moving > PILE_RATIO * landed1h) {
70 const n = Math.floor((top * PILE_RATIO * landed1h) / moving);
4271 if (n < slots) {
4372 slots = n;
44 why = `Builders ${top} → ${Math.max(1, n)}: ${open} open vs ${landed1h} landed last hour`;
73 why = `Builders ${top} → ${Math.max(1, n)}: ${moving} pulls moving, ${opened1h} opened vs ${landed1h} landed last hour`;
4574 }
4675 }
4776 return { slots: Math.max(1, Math.min(top, slots)), why };
src/flow/repo_flow.ts added +60
@@ -0,0 +1,60 @@
1 /**
2 * One repository's flow, as the governor reads it (#7644): its open pulls,
3 * how many of them are moving, and how many opened and landed here in the
4 * last hour and the last 6 h. Pulls in other repositories do not count.
5 *
6 * A pull is moving when the latest check on its head is not red and it saw
7 * a word, a review or a run within `MOVING_HOURS`. A red pull, or one left
8 * alone that long (approved and waiting, parked), is open but not moving:
9 * no builder is working it, and more builders would not shrink it.
10 */
11
12 import type { Database } from "bun:sqlite";
13
14 const HOUR_MS = 3_600_000;
15 export const MOVING_HOURS = 24;
16
17 export interface RepoFlow {
18 readonly open: number;
19 readonly moving: number;
20 readonly opened1h: number;
21 readonly landed1h: number;
22 readonly opened6h: number;
23 readonly landed6h: number;
24 }
25
26 export function readRepoFlow(db: Database, repoId: string, now = new Date()): RepoFlow {
27 const to = now.toISOString();
28 const h1 = new Date(now.getTime() - HOUR_MS).toISOString();
29 const h6 = new Date(now.getTime() - 6 * HOUR_MS).toISOString();
30 const fresh = new Date(now.getTime() - MOVING_HOURS * HOUR_MS).toISOString();
31 const row = db
32 .query<RepoFlow, [string, string, string, string, string]>(
33 `SELECT
34 (SELECT COUNT(*) FROM pull p JOIN issue i ON i.id = p.issue_id
35 WHERE i.repo_id = ?1 AND i.state = 'open' AND p.merged_at IS NULL) AS open,
36 (SELECT COUNT(*) FROM pull p JOIN issue i ON i.id = p.issue_id
37 WHERE i.repo_id = ?1 AND i.state = 'open' AND p.merged_at IS NULL
38 AND COALESCE((SELECT c.state FROM run c WHERE c.kind = 'check' AND c.issue_id = i.id
39 AND c.commit_oid = p.head_oid ORDER BY c.id DESC LIMIT 1) = 'failed', 0) = 0
40 AND (i.updated_at BETWEEN ?5 AND ?4 OR i.created_at BETWEEN ?5 AND ?4
41 OR EXISTS (SELECT 1 FROM run c WHERE c.issue_id = i.id AND c.created_at BETWEEN ?5 AND ?4))) AS moving,
42 (SELECT COUNT(*) FROM pull p JOIN issue i ON i.id = p.issue_id
43 WHERE i.repo_id = ?1 AND i.created_at >= ?2 AND i.created_at <= ?4) AS opened1h,
44 (SELECT COUNT(*) FROM pull p JOIN issue i ON i.id = p.issue_id
45 WHERE i.repo_id = ?1 AND i.created_at >= ?3 AND i.created_at <= ?4) AS opened6h,
46 (SELECT COUNT(*) FROM pull p JOIN issue i ON i.id = p.issue_id JOIN repo r ON r.id = i.repo_id
47 WHERE i.repo_id = ?1 AND p.base_branch = r.default_branch AND p.merged_at >= ?2 AND p.merged_at <= ?4) AS landed1h,
48 (SELECT COUNT(*) FROM pull p JOIN issue i ON i.id = p.issue_id JOIN repo r ON r.id = i.repo_id
49 WHERE i.repo_id = ?1 AND p.base_branch = r.default_branch AND p.merged_at >= ?3 AND p.merged_at <= ?4) AS landed6h`,
50 )
51 .get(repoId, h1, h6, to, fresh);
52 return {
53 open: row?.open ?? 0,
54 moving: row?.moving ?? 0,
55 opened1h: row?.opened1h ?? 0,
56 landed1h: row?.landed1h ?? 0,
57 opened6h: row?.opened6h ?? 0,
58 landed6h: row?.landed6h ?? 0,
59 };
60 }
src/http/mcp/tools/crew.ts +4 −2
@@ -4,11 +4,12 @@ import { canAdmin } from "../../../core/repos.ts";
44 import { getUserByName } from "../../../core/users.ts";
55 import { crewAsSeen, addToCrew, removeFromCrew } from "../../../actions/crew.ts";
66 import { emailVerified } from "../../../core/verify.ts";
7 import { governorSeen } from "../../../crew/governed.ts";
78 import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
89
910 export const tool: Tool = {
1011 name: "crew",
11 doc: "`{ repo, add?, remove?, dryRun? }`: without `add` or `remove`, the repository's crew as Settings › Crew reads it (`username`, `displayName`, `role`, and `config` for those who may see it); `add` or `remove` (an agent's username) says what would change, planned first (`dryRun` is true unless `false`); applying is the repository's admin with an admin token, never an agent; refusals are `no_such_agent`, `not_an_agent`, `unverified`",
12 doc: "`{ repo, add?, remove?, dryRun? }`: without `add` or `remove`, the repository's crew as Settings › Crew reads it (`username`, `displayName`, `role`, and `config` for those who may see it) and `governor { slots, ceiling, held, why }`, the builders the governor allows now and why (`null` while the crew is off or for a reader who cannot write here); `add` or `remove` (an agent's username) says what would change, planned first (`dryRun` is true unless `false`); applying is the repository's admin with an admin token, never an agent; refusals are `no_such_agent`, `not_an_agent`, `unverified`",
1213 description: "List a repository's crew, or add or remove an agent. Plans first; admin token.",
1314 inputSchema: {
1415 type: "object",
@@ -29,7 +30,8 @@ export const tool: Tool = {
2930 const remove = args["remove"];
3031 if (add === undefined && remove === undefined) {
3132 const seen = crewAsSeen(app, user, repo);
32 return seen.ok ? good({ crew: seen.value }) : bad("not_found", "no such app or repository (owner/name)");
33 if (!seen.ok) return bad("not_found", "no such app or repository (owner/name)");
34 return good({ crew: seen.value, governor: governorSeen(app, user, repo, new Date()) ?? null });
3335 }
3436 if ((add !== undefined && typeof add !== "string") || (remove !== undefined && typeof remove !== "string"))
3537 return bad("invalid", "`add` and `remove` are an agent's username");
src/tui/screens/dashboard.ts +8 −2
@@ -17,6 +17,7 @@
1717 import { exploreLine, exploreNote, explorePage, parseExploreQuery, readExploreLine, type ExploreRow } from "../../actions/explore.ts";
1818 import { protectionNotice } from "../../kernel/protection.ts";
1919 import { holdWords, plannerHold } from "../../crew/autonomy.ts";
20 import { governorSeen } from "../../crew/governed.ts";
2021 import { forgeFlakes, forgeLane, forgeNeeds } from "../../actions/health.ts";
2122 import { ownerWaits, waitLine, withMore } from "../../actions/owner_waits.ts";
2223 import { forgeHealth } from "../../kernel/guard.ts";
@@ -183,7 +184,10 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
183184 const markedRepos = new Set<string>();
184185 /** The receipt of the last archive made here, which `u` reverses once. */
185186 let lastArchive: string | undefined;
186 /** Why planning is paused, for the autonomous apps where it is; as on the board. */
187 /**
188 * Why planning is paused, for the autonomous apps where it is, as on the
189 * board; else why the governor holds the crew below its slots (#7644).
190 */
187191 let holds = new Map<string, string>();
188192 let forge: string[] | undefined;
189193 /** What the crews wait on this person for, one line each, as the board's notes say it. */
@@ -307,7 +311,9 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
307311 all.flatMap((repo) => {
308312 const hold =
309313 repo.autonomy === "autonomous" ? plannerHold(f.app, repo, now) : null;
310 return hold === null ? [] : [[repo.id, holdWords(hold)] as const];
314 if (hold !== null) return [[repo.id, holdWords(hold)] as const];
315 const governor = governorSeen(f.app, f.user, repo, now);
316 return governor?.held === true ? [[repo.id, governor.why] as const] : [];
311317 }),
312318 );
313319 alarms =
src/web/studio/app.ts +7
@@ -28,6 +28,7 @@ import {
2828 type PreviewLatency,
2929 } from "../../core/runs.ts";
3030 import { autonomyStatus } from "../../crew/autonomy.ts";
31 import { governorSeen } from "../../crew/governed.ts";
3132 import { repoDir } from "../../git/repos.ts";
3233 import type { Ctx } from "../../http/context.ts";
3334 import { crumbsFor, repoBase, repoHeader, repoPalette } from "../chrome.ts";
@@ -312,6 +313,8 @@ export async function studioApp(
312313 !ownerFacts || repo.autonomy === "off"
313314 ? undefined
314315 : timePhase(req, "autonomy", () => autonomyStatus(ctx.app, repo));
316 // The governor's reason, one line on the crew card (#7644).
317 const governor = timePhase(req, "governor", () => governorSeen(ctx.app, ctx.auth.user, repo, new Date()));
315318
316319 // ---- the app itself ----
317320 // `deployTailing` says the head card is tailing production's own deploy —
@@ -463,6 +466,10 @@ export async function studioApp(
463466 </dd>
464467 <dt>Slots</dt>
465468 <dd>${autonomy.active} of ${autonomy.slots} in use</dd>
469 ${governor === undefined
470 ? null
471 : html`<dt>Governor</dt>
472 <dd>${governor.why}</dd>`}
466473 ${autonomy.paused
467474 ? html`<dt>Paused</dt>
468475 <dd>
src/web/studio/crew.ts +6
@@ -31,6 +31,7 @@ import { getAppByRepo } from "../../core/apps.ts";
3131 import { canAdmin, canSeeOwnerFacts, type Repo } from "../../core/repos.ts";
3232 import { crewHasCredential } from "../../core/subscriptions.ts";
3333 import { autonomyStatus } from "../../crew/autonomy.ts";
34 import { governorSeen } from "../../crew/governed.ts";
3435 import { crewOffInPreview } from "../../crew/preview.ts";
3536 import type { Ctx } from "../../http/context.ts";
3637 import { repoBase } from "../chrome.ts";
@@ -255,6 +256,7 @@ function autonomyCard(ctx: Ctx, repo: Repo, base: string, admin: boolean): Html
255256 (c) => c.role === "planner",
256257 );
257258 const status = repo.autonomy === "off" ? undefined : autonomyStatus(ctx.app, repo);
259 const governor = governorSeen(ctx.app, ctx.auth.user, repo, new Date());
258260 const state = crewOffInPreview()
259261 ? html`<p class="muted small">
260262 The crew is off in a preview: nothing here plans, picks or runs an
@@ -275,6 +277,10 @@ function autonomyCard(ctx: Ctx, repo: Repo, base: string, admin: boolean): Html
275277 </dd>
276278 <dt>Slots</dt>
277279 <dd>${status.active} of ${status.slots} in use</dd>
280 ${governor === undefined
281 ? null
282 : html`<dt>Governor</dt>
283 <dd>${governor.why}</dd>`}
278284 ${status.paused
279285 ? html`<dt>Paused</dt>
280286 <dd>
test/crew-governed-unit.test.ts +138 −21
@@ -1,6 +1,6 @@
11 /**
22 * `governorFor` and `announceGovernor` (src/crew/governed.ts) on their own:
3 * the governor's slots read from the real vitals, one `crew.governed` event
3 * the governor's slots read from the repository's own flow (#7644), one `crew.governed` event
44 * per change of the governed number, the reason returned only while the
55 * governor holds the crew below the operator's slots, and `repoPace` taking
66 * the smaller of spend pacing's slots and the governor's. The picker itself
@@ -19,9 +19,9 @@ import { addSubscription, setGrants } from "../src/core/subscriptions.ts";
1919 import { recordUsage } from "../src/core/usage.ts";
2020 import { createUser } from "../src/core/users.ts";
2121 import { repoPace } from "../src/crew/autonomy.ts";
22 import { announceGovernor, governorFor } from "../src/crew/governed.ts";
22 import { announceGovernor, governorFor, governorSeen } from "../src/crew/governed.ts";
2323 import { governedSlots } from "../src/crew/governor.ts";
24 import { readVitals } from "../src/flow/vitals.ts";
24 import { readRepoFlow } from "../src/flow/repo_flow.ts";
2525 import { id } from "../src/kernel/id.ts";
2626
2727 const NOW = new Date("2026-10-02T15:00:00Z");
@@ -46,23 +46,38 @@ async function forge(slots: number) {
4646 if (!r.ok) throw new Error(r.error);
4747 const repo: Repo = { ...r.value, autonomySlots: slots };
4848 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 => {
49 /**
50 * A pull opened (and last touched) `openedH` hours before NOW, negative for
51 * after it; merged `mergedH` hours before NOW, when given. Returns its issue id.
52 */
53 const pull = (openedH: number, mergedH: number | null): string => {
5154 const issueId = id();
5255 n++;
5356 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)`)
57 .query(`INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state, created_at, updated_at) VALUES (?1, ?2, ?3, 'pull', ?4, '', ?5, ?6, ?7, ?7)`)
5558 .run(issueId, repo.id, n, `P${n}`, u.value.id, mergedH === null ? "open" : "closed", ago(openedH));
5659 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)`)
60 .query(`INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, head_repo_id, merged_at) VALUES (?1, ?2, ?3, '', 'head', ?4, ?5)`)
5861 .run(issueId, repo.defaultBranch, `b/${n}`, repo.id, mergedH === null ? null : ago(mergedH));
62 return issueId;
5963 };
6064 const events = () =>
6165 app.db
6266 .query<{ payload: string }, [string]>(`SELECT payload FROM event WHERE kind = 'crew.governed' AND repo_id = ?1 ORDER BY rowid`)
6367 .all(repo.id)
6468 .map((e) => JSON.parse(e.payload) as { from: number; to: number; why: string });
65 return { app, repo, pull, events, userId: u.value.id };
69 /** Lands the pull `issueId` `mergedH` hours before NOW. */
70 const land = (issueId: string, mergedH: number): void => {
71 app.db.query(`UPDATE pull SET merged_at = ?2 WHERE issue_id = ?1`).run(issueId, ago(mergedH));
72 app.db.query(`UPDATE issue SET state = 'closed' WHERE id = ?1`).run(issueId);
73 };
74 /** A run on the pull `issueId`, started `atH` hours before NOW. */
75 const run = (issueId: string, kind: "check" | "agent", state: "ok" | "failed", atH: number, commit = "head"): void => {
76 app.db
77 .query(`INSERT INTO run (id, repo_id, kind, issue_id, commit_oid, state, created_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)`)
78 .run(id(), repo.id, kind, issueId, commit, state, ago(atH));
79 };
80 return { app, repo, pull, land, run, events, user: u.value, userId: u.value.id };
6681 }
6782
6883 const later = (s: number) => new Date(NOW.getTime() + s * 1000);
@@ -77,17 +92,21 @@ test("a quiet forge answers the operator's slots, says nothing, and the pace fol
7792 expect(repoPace(app, repo, NOW).slots).toBe(5);
7893 });
7994
80 test("a pile lowers the slots: governorFor and the pace take the smaller, announce says it once", async () => {
95 /** 12 pulls opened within the last hour, none landed: a flood. */
96 const flood = (pull: (openedH: number, mergedH: number | null) => string): string[] =>
97 Array.from({ length: 12 }, (_, i) => pull(0.1 + i * 0.05, null));
98
99 test("a flood lowers the slots: governorFor and the pace take the smaller, announce says it once", async () => {
81100 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);
101 flood(pull);
102 const flow = readRepoFlow(app.db, repo.id, NOW);
103 expect(flow).toMatchObject({ open: 12, moving: 12, opened1h: 12, landed1h: 0 });
104 const want = governedSlots(flow, 4);
86105 expect(want.slots).toBeLessThan(4);
87106
88107 const g = governorFor(app, repo, NOW);
89108 expect(g).toEqual(want);
90 expect(g.why).toContain("12 open vs 0 landed");
109 expect(g.why).toContain("12 opened vs 0 landed");
91110 // No subscription accounts: spend pacing allows all 4, so the governor's is the smaller.
92111 expect(repoPace(app, repo, NOW).slots).toBe(g.slots);
93112
@@ -125,15 +144,15 @@ test("spend pacing is the smaller when the windows are nearly spent, the governo
125144 // The reason the tick line carries is the governor's, and it holds nothing back here.
126145 expect(announceGovernor(app, repo, NOW)).toBeUndefined();
127146
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);
147 // A flood: the governor's 1 is now the smaller of the two.
148 flood(pull);
130149 expect(governorFor(app, repo, NOW).slots).toBe(1);
131150 expect(repoPace(app, repo, NOW).slots).toBe(1);
132151 });
133152
134153 test("the reason is absent when the governed number equals the operator's slots", async () => {
135154 const one = await forge(1);
136 for (let i = 0; i < 12; i++) one.pull(20 + i, null);
155 flood(one.pull);
137156 expect(governorFor(one.app, one.repo, NOW).slots).toBe(1);
138157 expect(announceGovernor(one.app, one.repo, NOW)).toBeUndefined();
139158 expect(one.events()).toEqual([]);
@@ -144,17 +163,115 @@ test("the reason is absent when the governed number equals the operator's slots"
144163
145164 test("a recovery is a second change: one more event, then no reason", async () => {
146165 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 });
166 flood(pull);
167 expect(readRepoFlow(app.db, repo.id, NOW)).toMatchObject({ open: 12, landed1h: 0 });
149168 expect(announceGovernor(app, repo, NOW)).toBeDefined();
150169 const held = governorFor(app, repo, NOW).slots;
151170 expect(held).toBeLessThan(4);
152171
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.
172 // Six more land in the last hour: 12 moving is no more than 3 x 6 landed, and 6 h opened only 6 more than landed.
154173 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 });
174 expect(readRepoFlow(app.db, repo.id, later(5))).toMatchObject({ open: 12, landed1h: 6, opened1h: 12 });
156175 expect(governorFor(app, repo, later(5)).slots).toBe(4);
157176 expect(announceGovernor(app, repo, later(5))).toBeUndefined();
158177 expect(announceGovernor(app, repo, later(6))).toBeUndefined();
159178 expect(events().map((e) => [e.from, e.to])).toEqual([[4, held], [held, 4]]);
160179 });
180
181 test("a stale pile, a red pull and another repository's flood do not hold the builders (#7644)", async () => {
182 const { app, repo, pull, userId } = await forge(5);
183 // 2026-11-01: 55 open, untouched for a day and more; nothing opened or landed this hour.
184 for (let i = 0; i < 55; i++) pull(30 + i, null);
185 // One fresh pull whose check on its head is red: open, not moving.
186 pull(0.5, null);
187 const red = app.db.query<{ id: string }, [string]>(`SELECT id FROM issue WHERE repo_id = ?1 ORDER BY created_at DESC LIMIT 1`).get(repo.id)!.id;
188 app.db
189 .query(`INSERT INTO run (id, repo_id, kind, issue_id, commit_oid, state, created_at) VALUES (?1, ?2, 'check', ?3, 'head', 'failed', ?4)`)
190 .run(id(), repo.id, red, ago(0.4));
191 // A flood in another repository: 30 opened this hour, none landed.
192 const other = await createRepo(app.db, app.reposRoot, { ownerId: userId, name: "other", visibility: "public", description: "o" });
193 if (!other.ok) throw new Error(other.error);
194 for (let i = 0; i < 30; i++) {
195 const issueId = id();
196 app.db
197 .query(`INSERT INTO issue (id, repo_id, number, kind, title, body, author_id, state, created_at) VALUES (?1, ?2, ?3, 'pull', 'o', '', ?4, 'open', ?5)`)
198 .run(issueId, other.value.id, i + 1, userId, ago(0.2));
199 app.db
200 .query(`INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid, head_repo_id) VALUES (?1, 'main', ?2, '', '', ?3)`)
201 .run(issueId, `o/${i}`, other.value.id);
202 }
203 expect(readRepoFlow(app.db, repo.id, NOW)).toEqual({ open: 56, moving: 0, opened1h: 1, landed1h: 0, opened6h: 1, landed6h: 0 });
204 const g = governorFor(app, repo, NOW);
205 expect(g.slots).toBe(5);
206 expect(g.why).toBe("Builders 5: 1 opened, 0 landed last hour, 0 of 56 open pulls moving");
207 });
208
209 test("a quiet hour over a moving pile does not lower the slots", async () => {
210 const { app, repo, pull } = await forge(5);
211 for (let i = 0; i < 20; i++) pull(2 + i * 0.1, null); // opened 2–4 h ago: moving, nothing this hour
212 expect(readRepoFlow(app.db, repo.id, NOW)).toMatchObject({ moving: 20, opened1h: 0, landed1h: 0 });
213 expect(governorFor(app, repo, NOW)).toEqual({ slots: 5, why: "Builders 5: a quiet hour, 20 of 20 open pulls moving" });
214 });
215
216 test("the latest check on the head says whether a pull moves; a red check on an older head does not count", async () => {
217 const { app, repo, pull, run } = await forge(5);
218 const p = pull(0.5, null);
219 const moving = () => readRepoFlow(app.db, repo.id, NOW).moving;
220 expect(moving()).toBe(1);
221 run(p, "check", "failed", 0.4);
222 expect(moving()).toBe(0);
223 run(p, "check", "ok", 0.3); // a newer green check on the same head wins
224 expect(moving()).toBe(1);
225 run(p, "check", "failed", 0.2, "older-head"); // red, but not on this head
226 expect(moving()).toBe(1);
227 run(p, "check", "failed", 0.1); // red again, latest
228 expect(moving()).toBe(0);
229 });
230
231 test("a pull untouched for a day moves again only when a run touches it within the day", async () => {
232 const { app, repo, pull, run } = await forge(5);
233 const p = pull(30, null);
234 const moving = () => readRepoFlow(app.db, repo.id, NOW).moving;
235 expect(moving()).toBe(0);
236 run(p, "agent", "ok", 26); // a run, but older than the day
237 expect(moving()).toBe(0);
238 run(p, "agent", "ok", 2);
239 expect(moving()).toBe(1);
240 expect(readRepoFlow(app.db, repo.id, NOW).open).toBe(1);
241 });
242
243 test("from 1 builder after a flood, over a stale pile and a young backlog, the slots climb back and stay (#7644, #7712)", async () => {
244 const { app, repo, pull, land } = await forge(5);
245 // 45 pulls nobody works: opened and last touched two days and more ago.
246 for (let i = 0; i < 45; i++) pull(48 + i, null);
247 // A young backlog of 10, opened 2–3 h ago, moving, and never landed: the pile the ramp opens over.
248 for (let i = 0; i < 10; i++) pull(2 + i * 0.1, null);
249 // Hour 0: a flood of 12 opened, none landed: the governor holds the crew at 1.
250 let lastHour = flood(pull);
251 let slots = governorFor(app, repo, NOW).slots;
252 expect(slots).toBe(1);
253 const seen: number[] = [];
254 // Each later hour the builders open one pull per slot and land what the hour before opened.
255 for (let hour = 1; hour <= 8; hour++) {
256 for (const p of lastHour) land(p, 0.25 - hour);
257 lastHour = Array.from({ length: slots }, (_, i) => pull(0.5 - hour + i * 0.01, null));
258 const at = new Date(NOW.getTime() + hour * 3_600_000);
259 const flow = readRepoFlow(app.db, repo.id, at);
260 expect(flow.open).toBeGreaterThanOrEqual(55);
261 expect(flow.moving).toBeGreaterThanOrEqual(10);
262 expect(flow.opened1h).toBe(slots);
263 slots = governorFor(app, repo, at).slots;
264 seen.push(slots);
265 }
266 expect(seen).toEqual([5, 5, 5, 5, 5, 5, 5, 5]);
267 });
268
269 test("governorSeen gates the governor alike for every door: off crew or a reader who cannot write sees none", async () => {
270 const { app, repo, pull, user } = await forge(4);
271 flood(pull);
272 expect(governorSeen(app, user, repo, NOW)).toBeUndefined(); // autonomy off
273 const on: Repo = { ...repo, autonomy: "assisted" };
274 const seen = governorSeen(app, user, on, NOW);
275 expect(seen).toEqual({ ...governorFor(app, on, NOW), ceiling: 4, held: true });
276 expect(governorSeen(app, undefined, on, NOW)).toBeUndefined();
277 });
test/crew-governor.test.ts added +51
@@ -0,0 +1,51 @@
1 import { describe, expect, test } from "bun:test";
2 import type { RepoFlow } from "../src/flow/repo_flow.ts";
3 import { governedSlots } from "../src/crew/governor.ts";
4
5 const f = (p: Partial<RepoFlow>): RepoFlow => ({
6 open: 0, moving: 0, opened1h: 0, landed1h: 0, opened6h: 0, landed6h: 0, ...p,
7 });
8
9 describe("governedSlots", () => {
10 const rows: [string, Partial<RepoFlow>, number, number][] = [
11 ["quiet forge stays at the ceiling", {}, 7, 7],
12 ["balanced flow stays", { open: 5, moving: 5, opened1h: 10, landed1h: 9, opened6h: 50, landed6h: 50 }, 7, 7],
13 ["inflow falls", { open: 8, moving: 8, opened1h: 51, landed1h: 15, opened6h: 120, landed6h: 40 }, 7, 3],
14 ["one bad hour alone does not", { open: 8, moving: 8, opened1h: 51, landed1h: 15, opened6h: 60, landed6h: 60 }, 7, 7],
15 ["inflow with nothing landed floors at 1", { open: 4, moving: 4, opened1h: 20, landed1h: 0, opened6h: 60, landed6h: 2 }, 7, 1],
16 ["a growing moving pile falls", { open: 30, moving: 30, opened1h: 12, landed1h: 5, opened6h: 30, landed6h: 30 }, 6, 3],
17 // floor(6 x 3 x 5 / 30) = 3; the same pile with the hour landing what it opened is no flood.
18 ["a moving pile that lands what it opens stays", { open: 30, moving: 30, opened1h: 5, landed1h: 5, opened6h: 30, landed6h: 30 }, 6, 6],
19 ["a small pile is not a pile", { open: 6, moving: 6, opened1h: 0, landed1h: 0 }, 7, 7],
20 ["the pile drains and slots rise", { open: 12, moving: 12, opened1h: 4, landed1h: 8, opened6h: 30, landed6h: 40 }, 7, 7],
21 ["a stale pile does not hold the builders", { open: 55, moving: 3, opened1h: 2, landed1h: 1, opened6h: 8, landed6h: 4 }, 5, 5],
22 ["a quiet hour over a moving pile does not lower slots", { open: 30, moving: 30, opened1h: 0, landed1h: 0, opened6h: 6, landed6h: 6 }, 5, 5],
23 ["one builder opening one, nothing landed yet, is no flood", { open: 55, moving: 9, opened1h: 1, landed1h: 0, opened6h: 6, landed6h: 1 }, 5, 5],
24 // #7712: one builder's first pull over a pile of ten and more is lag, not a flood.
25 ["one builder opening one over a moving pile, nothing landed yet, is no flood", { open: 55, moving: 12, opened1h: 1, landed1h: 0, opened6h: 6, landed6h: 1 }, 5, 5],
26 // A crew rising from 1 to 5 opens 5 and lands the 1 from the hour before: lag of 4, under LAG(5) = 5.
27 ["a crew ramping back to its ceiling over a pile is no flood", { open: 20, moving: 15, opened1h: 5, landed1h: 1, opened6h: 28, landed6h: 13 }, 5, 5],
28 ["the same ramp at a ceiling of 8 is no flood", { open: 30, moving: 20, opened1h: 8, landed1h: 1, opened6h: 40, landed6h: 13 }, 8, 8],
29 ["one pull per builder past the landings brakes", { open: 20, moving: 15, opened1h: 6, landed1h: 1, opened6h: 28, landed6h: 13 }, 5, 1],
30 ["a real flood still brakes", { open: 60, moving: 40, opened1h: 20, landed1h: 2, opened6h: 80, landed6h: 10 }, 5, 1],
31 ["ceiling 0 stays 0", { open: 50, moving: 50, opened1h: 9 }, 0, 0],
32 ["ceiling 1 stays 1", { open: 50, moving: 50, opened1h: 9 }, 1, 1],
33 ["NaN ceiling is 0", {}, NaN, 0],
34 ["infinite ceiling is 0", {}, Infinity, 0],
35 ];
36 for (const [name, flow, ceiling, want] of rows)
37 test(name, () => expect(governedSlots(f(flow), ceiling).slots).toBe(want));
38
39 test("why is one plain sentence naming the numbers", () => {
40 expect(governedSlots(f({ open: 8, moving: 8, opened1h: 51, landed1h: 15, opened6h: 120, landed6h: 40 }), 7).why).toBe(
41 "Builders 7 → 3: 51 opened vs 15 landed last hour",
42 );
43 expect(governedSlots(f({ open: 30, moving: 30, opened1h: 12, landed1h: 5, opened6h: 30, landed6h: 30 }), 6).why).toBe(
44 "Builders 6 → 3: 30 pulls moving, 12 opened vs 5 landed last hour",
45 );
46 expect(governedSlots(f({ open: 55, moving: 3 }), 5).why).toBe("Builders 5: a quiet hour, 3 of 55 open pulls moving");
47 expect(governedSlots(f({ open: 55, moving: 3, opened1h: 2, landed1h: 1 }), 5).why).toBe(
48 "Builders 5: 2 opened, 1 landed last hour, 3 of 55 open pulls moving",
49 );
50 });
51 });
test/flow-clog-prevent.test.ts +3 −3
@@ -2,7 +2,7 @@
22 * The seeded clog (#6523, part of #5930): 51 pulls opened against 15 landed in
33 * the last hour, and six open pulls on one file. The inflow governor lowers the
44 * builder slots and says why; the hot-file hold keeps a fresh build off that
5 * file while a build on another file may start. Vitals are plain data and the
5 * file while a build on another file may start. The flow is plain data and the
66 * pulls' files are remembered, so no clock and no git.
77 */
88 import { afterAll, beforeAll, expect, test } from "bun:test";
@@ -18,7 +18,7 @@ import type { Repo } from "../src/core/repos.ts";
1818 import { governedSlots } from "../src/crew/governor.ts";
1919 import { heldNow } from "../src/crew/held.ts";
2020 import { HOT_PULLS, rememberPullFiles } from "../src/crew/hot_files.ts";
21 import type { Vitals } from "../src/flow/vitals.ts";
21 import type { RepoFlow } from "../src/flow/repo_flow.ts";
2222
2323 let dataDir: string;
2424 let app: App;
@@ -52,7 +52,7 @@ const open = (title: string): Issue => {
5252 };
5353
5454 test("a seeded clog lowers the slots and holds a fresh build on the hot file", () => {
55 const clog: Vitals = { open: 8, opened1h: 51, landed1h: 15, opened6h: 120, landed6h: 40, conflicted: 0, red: 0, queued: 0, oldestApprovedHours: null, strandedUsd: 0, gateMinutes1h: 0, pullCheckMinutes1h: 0, batches6h: 0, redBatchRate: null };
55 const clog: RepoFlow = { open: 8, moving: 8, opened1h: 51, landed1h: 15, opened6h: 120, landed6h: 40 };
5656 const ceiling = 7;
5757 const governed = governedSlots(clog, ceiling);
5858 expect(governed.slots).toBeLessThan(ceiling);
test/mcp-crew.test.ts +12
@@ -126,3 +126,15 @@ test("config shows for the admin and not for a plain reader", async () => {
126126 expect(seen.value.crew[0].config).toBeUndefined();
127127 await crew(admin, { remove: "robo", dryRun: false });
128128 });
129
130 test("the listing carries the governor's slots and reason for a writer while the crew is on; null when off or for a reader (#7644)", async () => {
131 expect((await crew(admin, {})).value.governor).toBeNull(); // the crew is off
132 app.db.query("UPDATE repo SET autonomy = 'assisted', autonomy_slots = 4 WHERE name = 'game'").run();
133 try {
134 const on = (await crew(admin, {})).value.governor;
135 expect(on).toEqual({ slots: 4, ceiling: 4, held: false, why: "Builders 4: a quiet hour, 0 of 0 open pulls moving" });
136 expect((await crew(bob, {})).value.governor).toBeNull(); // bob cannot write to alice/game
137 } finally {
138 app.db.query("UPDATE repo SET autonomy = 'off' WHERE name = 'game'").run();
139 }
140 });
test/studio-runs.test.ts +2
@@ -155,6 +155,8 @@ describe("the studio's slot count", () => {
155155 })
156156 ).text();
157157 expect(page).toContain("1 of 2 in use");
158 // The crew card says the governor's reason in one line (#7644).
159 expect(page).toContain("Builders 2: a quiet hour, 0 of 0 open pulls moving");
158160 expect(page).not.toContain("3 of 2");
159161 expect(page).not.toContain("2 of 2");
160162 // The runs that take no slot are not hidden: Now has all of them.
test/studio-shipping.test.ts +2
@@ -365,6 +365,8 @@ describe("a stranger sees the app, not the owner's books", () => {
365365 expect(owner).toContain(">Crew<");
366366 expect(owner).toContain(">On its own<");
367367 expect(owner).toMatch(/\$42\.50\s+of\s+\$25/);
368 // The crew card says the governor's reason in one line (#7644).
369 expect(owner).toMatch(/<dt>Governor<\/dt>\s*<dd>Builders \d+: /);
368370 expect(owner).toContain("Remove");
369371 expect(owner).toContain("Save");
370372 // The rest is beneath, on the same page and in the same frame.
test/tui-dashboard-governor.test.ts added +73
@@ -0,0 +1,73 @@
1 /** The terminal's repository row says why the governor holds the crew below its slots, as the studio's crew card does (#7644). */
2 import { afterAll, beforeAll, expect, 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 as Forge } from "../src/app.ts";
7 import { createRepo, getRepo } from "../src/core/repos.ts";
8 import type { User } from "../src/core/users.ts";
9 import { governorSeen } from "../src/crew/governed.ts";
10 import { id } from "../src/kernel/id.ts";
11 import { createForgeApp } from "../src/tui/forge.ts";
12 import { headlessTerminal } from "../src/tui/term/terminal.ts";
13 import { quietLogs } from "./helpers/globals.ts";
14 import { settleApp } from "./helpers/tui.ts";
15 import { seedAdmin } from "./helpers/admin.ts";
16
17 quietLogs();
18
19 let dir: string;
20 let forge: Forge;
21 let alice: User;
22
23 beforeAll(async () => {
24 dir = await mkdtemp(join(tmpdir(), "forge-tui-governor-"));
25 forge = createApp({ dataDir: dir, port: 0 });
26 const a = await seedAdmin(forge, { username: "alice", email: "[email protected]", password: "a-long-passphrase" });
27 if (!a.ok) throw new Error("signup failed");
28 alice = a.value;
29 for (const name of ["flooded", "calm"]) {
30 const r = await createRepo(forge.db, forge.reposRoot, { ownerId: alice.id, name, visibility: "public", description: `the ${name} one` });
31 if (!r.ok) throw new Error(r.error);
32 }
33 forge.db.run("UPDATE repo SET autonomy = 'assisted', autonomy_slots = 4");
34 });
35
36 afterAll(async () => {
37 forge.close();
38 await rm(dir, { recursive: true, force: true });
39 });
40
41 async function firstScreen(user: User): Promise<string> {
42 const terminal = headlessTerminal(200, 32);
43 const { app } = createForgeApp(
44 forge,
45 { sshUsername: user.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:t", user },
46 { terminal, exitHooks: false },
47 );
48 app.start();
49 await settleApp(app);
50 const text = app.screen.toText();
51 app.stop();
52 return text;
53 }
54
55 test("a flooded repository's row says the governor's reason; one at its ceiling keeps its description", async () => {
56 const repo = getRepo(forge.db, "alice", "flooded")!;
57 // 12 pulls opened in the last few minutes, none landed: a flood.
58 for (let n = 1; n <= 12; n++) {
59 const issueId = id();
60 forge.db.run(
61 "INSERT INTO issue (id, repo_id, number, kind, title, author_id, state) VALUES (?1, ?2, ?3, 'pull', 't', ?4, 'open')",
62 [issueId, repo.id, n, alice.id],
63 );
64 forge.db.run("INSERT INTO pull (issue_id, base_branch, head_branch, base_oid, head_oid) VALUES (?1, 'main', ?2, '', 'h')", [issueId, `b/${n}`]);
65 }
66 const g = governorSeen(forge, alice, repo, new Date())!;
67 expect(g.held).toBe(true);
68 expect(g.why).toStartWith("Builders 4 → 1:");
69 const screen = await firstScreen(alice);
70 expect(screen).toContain(g.why);
71 expect(screen).toContain("the calm one");
72 expect(screen).not.toContain("the flooded one");
73 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs