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
Check · Review · Land · Live done — Landed and live in production.
torque/forge:torque/7644-the-governor-traps-the-forge-at-one-buil → main 5 commits fork 1 thread · 1 unresolved
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 | ||
| 30 | 30 | crew's spend today against the daily ceiling that pauses it (`$3.20 of $10 today`), as the |
| 31 | 31 | child's heartbeat last reported it. |
| 32 | 32 | |
| 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 | ||
| 33 | 42 | Each agent's scorecard — pulls landed of opened, review rounds, red checks, |
| 34 | 43 | reverts, time to land — is on Settings → Crew (`/settings/crew`) and in the |
| 35 | 44 | 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. | ||
| 70 | 70 | | `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 | |
| 71 | 71 | | `contribute` | for any arriving agent: what this forge is, its bottleneck, open “help wanted” issues, how to propose | | |
| 72 | 72 | | `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 | | |
| 74 | 74 | | `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 | |
| 75 | 75 | | `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 | |
| 76 | 76 | | `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 @@ | ||
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | 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"; | |
| 12 | 13 | import { governedSlots, type Governed } from "./governor.ts"; |
| 13 | 14 | |
| 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). */ | |
| 15 | 16 | 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 }; | |
| 17 | 36 | } |
| 18 | 37 | |
| 19 | 38 | /** 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 @@ | ||
| 1 | 1 | /** |
| 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). | |
| 5 | 6 | * |
| 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. | |
| 10 | 26 | */ |
| 11 | 27 | |
| 12 | import type { Vitals } from "../flow/vitals.ts"; | |
| 28 | import type { RepoFlow } from "../flow/repo_flow.ts"; | |
| 13 | 29 | |
| 14 | 30 | export const INFLOW_RATIO = 1.5; |
| 15 | 31 | export const PILE_RATIO = 3; |
| 16 | 32 | export const PILE_MIN = 10; |
| 17 | 33 | |
| 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 | ||
| 18 | 37 | export interface Governed { |
| 19 | 38 | readonly slots: number; |
| 20 | 39 | /** One plain sentence; says nothing is held back at the ceiling. */ |
| 21 | 40 | readonly why: string; |
| 22 | 41 | } |
| 23 | 42 | |
| 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 { | |
| 26 | 45 | const top = Number.isFinite(ceiling) ? Math.max(0, Math.floor(ceiling)) : 0; |
| 27 | 46 | // At 0 or 1 there is nothing to hold back; the final clamp to 1 below is for ceilings of 2 and up. |
| 28 | 47 | 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; | |
| 30 | 49 | 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`; | |
| 32 | 54 | |
| 33 | if (opened1h > INFLOW_RATIO * landed1h && opened6h > INFLOW_RATIO * landed6h) { | |
| 55 | const flooding = opened1h - landed1h >= lag(top); | |
| 56 | ||
| 57 | if ( | |
| 58 | flooding && | |
| 59 | opened1h > INFLOW_RATIO * landed1h && | |
| 60 | opened6h > INFLOW_RATIO * landed6h && | |
| 61 | opened6h - landed6h >= PILE_MIN | |
| 62 | ) { | |
| 34 | 63 | const n = Math.floor((top * INFLOW_RATIO * landed1h) / opened1h); |
| 35 | 64 | if (n < slots) { |
| 36 | 65 | slots = n; |
| 37 | 66 | why = `Builders ${top} → ${Math.max(1, n)}: ${opened1h} opened vs ${landed1h} landed last hour`; |
| 38 | 67 | } |
| 39 | 68 | } |
| 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); | |
| 42 | 71 | if (n < slots) { |
| 43 | 72 | 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`; | |
| 45 | 74 | } |
| 46 | 75 | } |
| 47 | 76 | 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"; | ||
| 4 | 4 | import { getUserByName } from "../../../core/users.ts"; |
| 5 | 5 | import { crewAsSeen, addToCrew, removeFromCrew } from "../../../actions/crew.ts"; |
| 6 | 6 | import { emailVerified } from "../../../core/verify.ts"; |
| 7 | import { governorSeen } from "../../../crew/governed.ts"; | |
| 7 | 8 | import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts"; |
| 8 | 9 | |
| 9 | 10 | export const tool: Tool = { |
| 10 | 11 | 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`", | |
| 12 | 13 | description: "List a repository's crew, or add or remove an agent. Plans first; admin token.", |
| 13 | 14 | inputSchema: { |
| 14 | 15 | type: "object", |
| @@ -29,7 +30,8 @@ export const tool: Tool = { | ||
| 29 | 30 | const remove = args["remove"]; |
| 30 | 31 | if (add === undefined && remove === undefined) { |
| 31 | 32 | 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 }); | |
| 33 | 35 | } |
| 34 | 36 | if ((add !== undefined && typeof add !== "string") || (remove !== undefined && typeof remove !== "string")) |
| 35 | 37 | return bad("invalid", "`add` and `remove` are an agent's username"); |
src/tui/screens/dashboard.ts +8 −2
| @@ -17,6 +17,7 @@ | ||
| 17 | 17 | import { exploreLine, exploreNote, explorePage, parseExploreQuery, readExploreLine, type ExploreRow } from "../../actions/explore.ts"; |
| 18 | 18 | import { protectionNotice } from "../../kernel/protection.ts"; |
| 19 | 19 | import { holdWords, plannerHold } from "../../crew/autonomy.ts"; |
| 20 | import { governorSeen } from "../../crew/governed.ts"; | |
| 20 | 21 | import { forgeFlakes, forgeLane, forgeNeeds } from "../../actions/health.ts"; |
| 21 | 22 | import { ownerWaits, waitLine, withMore } from "../../actions/owner_waits.ts"; |
| 22 | 23 | import { forgeHealth } from "../../kernel/guard.ts"; |
| @@ -183,7 +184,10 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen { | ||
| 183 | 184 | const markedRepos = new Set<string>(); |
| 184 | 185 | /** The receipt of the last archive made here, which `u` reverses once. */ |
| 185 | 186 | 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 | */ | |
| 187 | 191 | let holds = new Map<string, string>(); |
| 188 | 192 | let forge: string[] | undefined; |
| 189 | 193 | /** 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 { | ||
| 307 | 311 | all.flatMap((repo) => { |
| 308 | 312 | const hold = |
| 309 | 313 | 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] : []; | |
| 311 | 317 | }), |
| 312 | 318 | ); |
| 313 | 319 | alarms = |
src/web/studio/app.ts +7
| @@ -28,6 +28,7 @@ import { | ||
| 28 | 28 | type PreviewLatency, |
| 29 | 29 | } from "../../core/runs.ts"; |
| 30 | 30 | import { autonomyStatus } from "../../crew/autonomy.ts"; |
| 31 | import { governorSeen } from "../../crew/governed.ts"; | |
| 31 | 32 | import { repoDir } from "../../git/repos.ts"; |
| 32 | 33 | import type { Ctx } from "../../http/context.ts"; |
| 33 | 34 | import { crumbsFor, repoBase, repoHeader, repoPalette } from "../chrome.ts"; |
| @@ -312,6 +313,8 @@ export async function studioApp( | ||
| 312 | 313 | !ownerFacts || repo.autonomy === "off" |
| 313 | 314 | ? undefined |
| 314 | 315 | : 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())); | |
| 315 | 318 | |
| 316 | 319 | // ---- the app itself ---- |
| 317 | 320 | // `deployTailing` says the head card is tailing production's own deploy — |
| @@ -463,6 +466,10 @@ export async function studioApp( | ||
| 463 | 466 | </dd> |
| 464 | 467 | <dt>Slots</dt> |
| 465 | 468 | <dd>${autonomy.active} of ${autonomy.slots} in use</dd> |
| 469 | ${governor === undefined | |
| 470 | ? null | |
| 471 | : html`<dt>Governor</dt> | |
| 472 | <dd>${governor.why}</dd>`} | |
| 466 | 473 | ${autonomy.paused |
| 467 | 474 | ? html`<dt>Paused</dt> |
| 468 | 475 | <dd> |
src/web/studio/crew.ts +6
| @@ -31,6 +31,7 @@ import { getAppByRepo } from "../../core/apps.ts"; | ||
| 31 | 31 | import { canAdmin, canSeeOwnerFacts, type Repo } from "../../core/repos.ts"; |
| 32 | 32 | import { crewHasCredential } from "../../core/subscriptions.ts"; |
| 33 | 33 | import { autonomyStatus } from "../../crew/autonomy.ts"; |
| 34 | import { governorSeen } from "../../crew/governed.ts"; | |
| 34 | 35 | import { crewOffInPreview } from "../../crew/preview.ts"; |
| 35 | 36 | import type { Ctx } from "../../http/context.ts"; |
| 36 | 37 | import { repoBase } from "../chrome.ts"; |
| @@ -255,6 +256,7 @@ function autonomyCard(ctx: Ctx, repo: Repo, base: string, admin: boolean): Html | ||
| 255 | 256 | (c) => c.role === "planner", |
| 256 | 257 | ); |
| 257 | 258 | const status = repo.autonomy === "off" ? undefined : autonomyStatus(ctx.app, repo); |
| 259 | const governor = governorSeen(ctx.app, ctx.auth.user, repo, new Date()); | |
| 258 | 260 | const state = crewOffInPreview() |
| 259 | 261 | ? html`<p class="muted small"> |
| 260 | 262 | 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 | ||
| 275 | 277 | </dd> |
| 276 | 278 | <dt>Slots</dt> |
| 277 | 279 | <dd>${status.active} of ${status.slots} in use</dd> |
| 280 | ${governor === undefined | |
| 281 | ? null | |
| 282 | : html`<dt>Governor</dt> | |
| 283 | <dd>${governor.why}</dd>`} | |
| 278 | 284 | ${status.paused |
| 279 | 285 | ? html`<dt>Paused</dt> |
| 280 | 286 | <dd> |
test/crew-governed-unit.test.ts +138 −21
| @@ -1,6 +1,6 @@ | ||
| 1 | 1 | /** |
| 2 | 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 | |
| 3 | * the governor's slots read from the repository's own flow (#7644), one `crew.governed` event | |
| 4 | 4 | * per change of the governed number, the reason returned only while the |
| 5 | 5 | * governor holds the crew below the operator's slots, and `repoPace` taking |
| 6 | 6 | * 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"; | ||
| 19 | 19 | import { recordUsage } from "../src/core/usage.ts"; |
| 20 | 20 | import { createUser } from "../src/core/users.ts"; |
| 21 | 21 | 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"; | |
| 23 | 23 | import { governedSlots } from "../src/crew/governor.ts"; |
| 24 | import { readVitals } from "../src/flow/vitals.ts"; | |
| 24 | import { readRepoFlow } from "../src/flow/repo_flow.ts"; | |
| 25 | 25 | import { id } from "../src/kernel/id.ts"; |
| 26 | 26 | |
| 27 | 27 | const NOW = new Date("2026-10-02T15:00:00Z"); |
| @@ -46,23 +46,38 @@ async function forge(slots: number) { | ||
| 46 | 46 | if (!r.ok) throw new Error(r.error); |
| 47 | 47 | const repo: Repo = { ...r.value, autonomySlots: slots }; |
| 48 | 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 => { | |
| 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 => { | |
| 51 | 54 | const issueId = id(); |
| 52 | 55 | n++; |
| 53 | 56 | 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)`) | |
| 55 | 58 | .run(issueId, repo.id, n, `P${n}`, u.value.id, mergedH === null ? "open" : "closed", ago(openedH)); |
| 56 | 59 | 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)`) | |
| 58 | 61 | .run(issueId, repo.defaultBranch, `b/${n}`, repo.id, mergedH === null ? null : ago(mergedH)); |
| 62 | return issueId; | |
| 59 | 63 | }; |
| 60 | 64 | const events = () => |
| 61 | 65 | app.db |
| 62 | 66 | .query<{ payload: string }, [string]>(`SELECT payload FROM event WHERE kind = 'crew.governed' AND repo_id = ?1 ORDER BY rowid`) |
| 63 | 67 | .all(repo.id) |
| 64 | 68 | .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 }; | |
| 66 | 81 | } |
| 67 | 82 | |
| 68 | 83 | 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 | ||
| 77 | 92 | expect(repoPace(app, repo, NOW).slots).toBe(5); |
| 78 | 93 | }); |
| 79 | 94 | |
| 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 () => { | |
| 81 | 100 | 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); | |
| 86 | 105 | expect(want.slots).toBeLessThan(4); |
| 87 | 106 | |
| 88 | 107 | const g = governorFor(app, repo, NOW); |
| 89 | 108 | expect(g).toEqual(want); |
| 90 | expect(g.why).toContain("12 open vs 0 landed"); | |
| 109 | expect(g.why).toContain("12 opened vs 0 landed"); | |
| 91 | 110 | // No subscription accounts: spend pacing allows all 4, so the governor's is the smaller. |
| 92 | 111 | expect(repoPace(app, repo, NOW).slots).toBe(g.slots); |
| 93 | 112 | |
| @@ -125,15 +144,15 @@ test("spend pacing is the smaller when the windows are nearly spent, the governo | ||
| 125 | 144 | // The reason the tick line carries is the governor's, and it holds nothing back here. |
| 126 | 145 | expect(announceGovernor(app, repo, NOW)).toBeUndefined(); |
| 127 | 146 | |
| 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); | |
| 130 | 149 | expect(governorFor(app, repo, NOW).slots).toBe(1); |
| 131 | 150 | expect(repoPace(app, repo, NOW).slots).toBe(1); |
| 132 | 151 | }); |
| 133 | 152 | |
| 134 | 153 | test("the reason is absent when the governed number equals the operator's slots", async () => { |
| 135 | 154 | const one = await forge(1); |
| 136 | for (let i = 0; i < 12; i++) one.pull(20 + i, null); | |
| 155 | flood(one.pull); | |
| 137 | 156 | expect(governorFor(one.app, one.repo, NOW).slots).toBe(1); |
| 138 | 157 | expect(announceGovernor(one.app, one.repo, NOW)).toBeUndefined(); |
| 139 | 158 | expect(one.events()).toEqual([]); |
| @@ -144,17 +163,115 @@ test("the reason is absent when the governed number equals the operator's slots" | ||
| 144 | 163 | |
| 145 | 164 | test("a recovery is a second change: one more event, then no reason", async () => { |
| 146 | 165 | 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 }); | |
| 149 | 168 | expect(announceGovernor(app, repo, NOW)).toBeDefined(); |
| 150 | 169 | const held = governorFor(app, repo, NOW).slots; |
| 151 | 170 | expect(held).toBeLessThan(4); |
| 152 | 171 | |
| 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. | |
| 154 | 173 | 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 }); | |
| 156 | 175 | expect(governorFor(app, repo, later(5)).slots).toBe(4); |
| 157 | 176 | expect(announceGovernor(app, repo, later(5))).toBeUndefined(); |
| 158 | 177 | expect(announceGovernor(app, repo, later(6))).toBeUndefined(); |
| 159 | 178 | expect(events().map((e) => [e.from, e.to])).toEqual([[4, held], [held, 4]]); |
| 160 | 179 | }); |
| 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 @@ | ||
| 2 | 2 | * The seeded clog (#6523, part of #5930): 51 pulls opened against 15 landed in |
| 3 | 3 | * the last hour, and six open pulls on one file. The inflow governor lowers the |
| 4 | 4 | * 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 | |
| 6 | 6 | * pulls' files are remembered, so no clock and no git. |
| 7 | 7 | */ |
| 8 | 8 | import { afterAll, beforeAll, expect, test } from "bun:test"; |
| @@ -18,7 +18,7 @@ import type { Repo } from "../src/core/repos.ts"; | ||
| 18 | 18 | import { governedSlots } from "../src/crew/governor.ts"; |
| 19 | 19 | import { heldNow } from "../src/crew/held.ts"; |
| 20 | 20 | 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"; | |
| 22 | 22 | |
| 23 | 23 | let dataDir: string; |
| 24 | 24 | let app: App; |
| @@ -52,7 +52,7 @@ const open = (title: string): Issue => { | ||
| 52 | 52 | }; |
| 53 | 53 | |
| 54 | 54 | 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 }; | |
| 56 | 56 | const ceiling = 7; |
| 57 | 57 | const governed = governedSlots(clog, ceiling); |
| 58 | 58 | 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 () => { | ||
| 126 | 126 | expect(seen.value.crew[0].config).toBeUndefined(); |
| 127 | 127 | await crew(admin, { remove: "robo", dryRun: false }); |
| 128 | 128 | }); |
| 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", () => { | ||
| 155 | 155 | }) |
| 156 | 156 | ).text(); |
| 157 | 157 | 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"); | |
| 158 | 160 | expect(page).not.toContain("3 of 2"); |
| 159 | 161 | expect(page).not.toContain("2 of 2"); |
| 160 | 162 | // 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", () => { | ||
| 365 | 365 | expect(owner).toContain(">Crew<"); |
| 366 | 366 | expect(owner).toContain(">On its own<"); |
| 367 | 367 | 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+: /); | |
| 368 | 370 | expect(owner).toContain("Remove"); |
| 369 | 371 | expect(owner).toContain("Save"); |
| 370 | 372 | // 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 | }); |
new above
offline
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.