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

One function names why builders are idle: ceiling reached, flow only, no subscription with headroom, or nothing ready #7695

Merged opened this

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

Merged

mason/forge:mason/7663-one-function-names-why-builders-are-idle → main 4 commits fork 1 thread · 1 unresolved

  • Merged. mason/7663-one-function-names-why-builders-are-idle was merged into main as 868239a.

Review

Sign in to review this change.
6 files changed +324 −1
README.md +2
@@ -51,6 +51,8 @@ A hold can be set "until green" (a checkbox on the hold form, `until: "green"` o
5151
5252 In the terminal, `h` on the pull screen asks for a one-line reason and holds the pull (an empty or over-200-character reason is refused in the prompt, as on the web and in MCP); on a held pull `h` asks to confirm and lets it go. Both go through `holdPull` and `releasePull`, so the header shows `held by <who>: <why>`. `h` is offered to a person who can write only: an agent or a reader does not get it.
5353
54 A forge admin's terminal home ends its forge strip with `Builders idle: <word> (<link>)` while no builder is starting anything: `ceiling reached` (/meter), `flow only` (/map), `no subscription with headroom` (/subscriptions) or `nothing ready` (/map). `idleReason` in `src/crew/idle_reason.ts` names it, asking the dispatcher's own predicates (`atCeiling`, the holds of `held.ts`, `pace`); it says nothing while a builder slot is busy or something can start. The web home tile is the next piece.
55
5456 A hold an agent placed does not stand forever: the tick releases one still standing after 24 h, in one transaction, with a comment on the pull, "Hold expired after 24 h: <why>. Back to @builder.", which shows in the pull's timeline like any comment, and the pull goes back to its builder (`expireCrewHolds` in `src/actions/holds.ts`). A person's hold is never expired. The window is fixed; a pull held again and expiring again leaves one such comment per round in its timeline.
5557
5658 In the terminal, `T` on a shipped pull's screen takes it back, as the pull page's button does: a one-line confirm names the pull, Yes opens the pull that undoes it (`revertPull`, `src/actions/revert.ts`) and shows it, No changes nothing. The key appears only to a person who can write a local repository (never an agent, a reader, a guest or a mirror), and refusals read as the page's do (`takeBackWords`). `T` also opens an app preview's terminal on an open pull; a shipped pull has no live preview, so the two never meet.
src/crew/dispatch.ts +9 −1
@@ -341,6 +341,14 @@ function freshPullFor(
341341 );
342342 }
343343
344 /** Whether `spentUsd` has reached the repository's daily ceiling: the test that stops a run being queued. */
345 export function atCeiling(
346 repo: Pick<Repo, "autonomyCeilingUsd">,
347 spentUsd: number,
348 ): boolean {
349 return spentUsd >= repo.autonomyCeilingUsd;
350 }
351
344352 /** Queue a run about an issue for an agent, unless one is already waiting. */
345353 export function queueAgentRun(
346354 app: App,
@@ -373,7 +381,7 @@ export function queueAgentRun(
373381 if (
374382 reason !== "assigned" &&
375383 repo.autonomy === "autonomous" &&
376 spentToday(app.db, repo.id) >= repo.autonomyCeilingUsd
384 atCeiling(repo, spentToday(app.db, repo.id))
377385 ) {
378386 log.info("at the ceiling; not queued", {
379387 repo: `${repo.ownerName}/${repo.name}`,
src/crew/idle_reason.ts added +68
@@ -0,0 +1,68 @@
1 /**
2 * Why the builders are idle, as one word and a link (#7663): plain data for
3 * the terminal's dashboard (and a later tile) to print.
4 *
5 * Every reason is asked of the module the dispatcher asks it of, in the
6 * dispatcher's order, and the first that holds is the answer: the day's
7 * ceiling (`atCeiling` against `spentToday`, as `queueAgentRun` asks it), then
8 * what `heldNow` sorts (`held.ts`: FLOW ONLY is one of its holds, `nothing
9 * ready` is its empty `startable`), and the subscriptions' headroom (`pace`,
10 * as `repoPace` asks it). The repository is the first autonomous one with a
11 * crew and someone to act as, which is what the loop drives.
12 */
13
14 import type { App } from "../app.ts";
15 import { listCrewRoles } from "../core/agents.ts";
16 import { listAutonomousRepos } from "../core/repos.ts";
17 import { listActive } from "../core/runs.ts";
18 import { standings } from "../core/standing.ts";
19 import { grantedAccounts } from "../core/subscriptions.ts";
20 import { slottedRuns, spentToday } from "./autonomy.ts";
21 import { atCeiling } from "./dispatch.ts";
22 import { heldNow } from "./held.ts";
23 import { pace } from "./pacing.ts";
24
25 export interface IdleReason {
26 readonly word: string;
27 readonly href: string;
28 }
29
30 /** Why no builder is starting anything now; null while a builder slot is busy or something can start. */
31 export function idleReason(app: App, now: Date): IdleReason | null {
32 const { db } = app;
33 for (const repo of listAutonomousRepos(db)) {
34 if (repo.autonomy !== "autonomous" || repo.autonomyBy === null || repo.archivedAt !== null) continue;
35 const crew = listCrewRoles(db, repo.id);
36 if (crew.length === 0) continue;
37
38 // Slots are the builders' work in progress: the very runs the loop counts.
39 const active = listActive(db, "agent").filter((r) => r.repoId === repo.id);
40 if (slottedRuns(active, crew).length >= repo.autonomySlots) return null;
41
42 if (atCeiling(repo, spentToday(db, repo.id, now))) return { word: "ceiling reached", href: "/meter" };
43
44 const held = heldNow(app, repo);
45 if (held.startable.length === 0 && held.by.flow_only.length > 0)
46 return { word: "flow only", href: "/map" };
47
48 const granted = grantedAccounts(db, repo.id, repo.ownerId);
49 if (granted.length > 0) {
50 const stand = standings(db, granted.map((a) => a.id), now);
51 const accounts = granted.flatMap((a) => {
52 const s = stand.get(a.id);
53 return s === undefined ? [] : [{ name: a.name, standing: s }];
54 });
55 // Whether a window has room does not depend on what a run costs.
56 if (pace(accounts, repo.autonomySlots, 1, now).slots === 0)
57 return { word: "no subscription with headroom", href: "/subscriptions" };
58 }
59
60 return held.startable.length === 0 ? { word: "nothing ready", href: "/map" } : null;
61 }
62 return null;
63 }
64
65 /** The reason as one line, the same words wherever it is printed. */
66 export function idleLine(reason: IdleReason): string {
67 return `Builders idle: ${reason.word} (${reason.href})`;
68 }
src/tui/screens/dashboard.ts +9
@@ -41,6 +41,7 @@ import { decideAsk, undoDecideAsk } from "../../actions/asks_decide.ts";
4141 import { actOnProposal, type ProposalVerb } from "../../actions/proposals.ts";
4242 import { putAsideWaves } from "../../web/studio/asks.ts";
4343 import type { Proposal } from "../../core/proposals.ts";
44 import type { App } from "../../app.ts";
4445 import {
4546 applyAsksBulk,
4647 BULK_MAX,
@@ -111,6 +112,7 @@ import { firedAlarms } from "../../flow/alarms.ts";
111112 import { lastClearedNote } from "../../flow/clog_tick.ts";
112113 import { forgeCeiling } from "../../flow/ceiling.ts";
113114 import { alarmIdleCrew } from "../../flow/alarm_idle_crew.ts";
115 import { idleLine, idleReason } from "../../crew/idle_reason.ts";
114116 import { noReleaseAlarm } from "../../flow/alarm_no_release.ts";
115117 import { redMainAlarm } from "../../flow/alarm_red_main.ts";
116118 import { alarmStuck } from "../../flow/alarm_stuck.ts";
@@ -342,6 +344,7 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
342344 f.user?.isAdmin !== true
343345 ? []
344346 : [
347 ...idleLines(f.app, now),
345348 ...kpiLines(readKpis(f.db, now, kpiForge(f.app)), commit === null ? null : { sha: commit, since: ago(BOOTED) }),
346349 vitalsLine(readVitals(f.db, now)),
347350 ...firedAlarms(f.db, now, { ceilingUsd: forgeCeiling(f.app.config) }).map((a) => a.says),
@@ -1431,6 +1434,12 @@ export function dashboardScreen(f: Session, ctx: Ctx): Screen {
14311434 return screen;
14321435 }
14331436
1437 /** Why the builders are idle, as the one line an admin's home reads; none while they are not. */
1438 function idleLines(app: App, now: Date): string[] {
1439 const reason = idleReason(app, now);
1440 return reason === null ? [] : [idleLine(reason)];
1441 }
1442
14341443 /**
14351444 * The mark a repository row wears.
14361445 *
test/idle-reason.test.ts added +159
@@ -0,0 +1,159 @@
1 /**
2 * Why the builders are idle (#7663): one function, four reasons in the
3 * dispatcher's order, and null while every builder slot is busy.
4 */
5 import { afterAll, beforeAll, beforeEach, describe, expect, test } from "bun:test";
6 import { mkdtemp, rm } from "node:fs/promises";
7 import { tmpdir } from "node:os";
8 import { join } from "node:path";
9 import { addToCrew, createAgent } from "../src/actions/crew.ts";
10 import { openIssue } from "../src/actions/issues.ts";
11 import { createRepo } from "../src/actions/repos.ts";
12 import { createApp, type App } from "../src/app.ts";
13 import { addDependency } from "../src/core/dependencies.ts";
14 import { createLabel, getLabel, setIssueLabels, type Issue } from "../src/core/issues.ts";
15 import type { Repo } from "../src/core/repos.ts";
16 import { createRun } from "../src/core/runs.ts";
17 import { noteLimit } from "../src/core/standing.ts";
18 import { addSubscription, setGrants } from "../src/core/subscriptions.ts";
19 import { createUser, getUserByName, type User } from "../src/core/users.ts";
20 import { idleLine, idleReason } from "../src/crew/idle_reason.ts";
21
22 const NOW = new Date("2026-10-02T12:00:00Z");
23
24 describe("idleReason", () => {
25 let dataDir: string;
26 let app: App;
27 let alice: User;
28 let bot: User;
29 let repo: Repo;
30 let n = 0;
31
32 const open = (title: string): Issue => {
33 const o = openIssue(app, alice, repo, { title, body: "" });
34 if (!o.ok) throw new Error(String(o.error));
35 return o.value;
36 };
37 const set = (sql: string, ...args: (string | number)[]) => app.db.query(sql).run(...args);
38
39 beforeAll(async () => {
40 dataDir = await mkdtemp(join(tmpdir(), "forge-idle-reason-"));
41 app = createApp({ dataDir, port: 0 });
42 const a = await createUser(app.db, {
43 origin: "seed",
44 trusted: true,
45 username: "alice",
46 email: "[email protected]",
47 password: "a-good-passphrase",
48 adminIfFirst: true,
49 });
50 if (!a.ok) throw new Error(a.error);
51 alice = a.value;
52 });
53
54 afterAll(async () => {
55 app.close();
56 await rm(dataDir, { recursive: true, force: true });
57 });
58
59 // A fresh autonomous repository with one builder, a $10 ceiling and two slots.
60 beforeEach(async () => {
61 if (repo) set("UPDATE repo SET autonomy = 'off' WHERE id = ?1", repo.id);
62 const made = await createRepo(app, alice, { name: `svc${n++}`, visibility: "private" });
63 if (!made.ok) throw new Error(String(made.error));
64 repo = made.value;
65 const created = await createAgent(app, alice, { username: `bot${n}` });
66 if (!created.ok) throw new Error(String(created.error));
67 expect(addToCrew(app, alice, repo, `bot${n}`).ok).toBe(true);
68 bot = getUserByName(app.db, `bot${n}`)!;
69 set(
70 "UPDATE repo SET autonomy = 'autonomous', autonomy_by = ?2, autonomy_ceiling_usd = 10, autonomy_slots = 2, flow_only = 0 WHERE id = ?1",
71 repo.id,
72 alice.id,
73 );
74 });
75
76 const flowLabel = (): string => {
77 const made = getLabel(app.db, repo.id, "flow") ?? createLabel(app.db, repo.id, "flow", "00aa00");
78 if ("ok" in made && !made.ok) throw new Error(made.error);
79 return ("ok" in made ? made.value : made).id;
80 };
81
82 const spend = (usd: number) => {
83 const run = createRun(app.db, { repoId: repo.id, kind: "agent", actorId: bot.id });
84 set("UPDATE run SET cost_usd = ?2, state = 'ok' WHERE id = ?1", run.id, usd);
85 };
86
87 test("every slot busy: null, even with the ceiling spent", () => {
88 open("Work");
89 for (let i = 0; i < 2; i++) {
90 const run = createRun(app.db, { repoId: repo.id, kind: "agent", actorId: bot.id, reason: "assigned" });
91 set("UPDATE run SET state = 'running', cost_usd = 6 WHERE id = ?1", run.id);
92 }
93 expect(idleReason(app, new Date())).toBeNull();
94 });
95
96 test("a reviewer's or planner's run takes no slot", () => {
97 const run = createRun(app.db, { repoId: repo.id, kind: "agent", actorId: bot.id, reason: "review" });
98 set("UPDATE run SET state = 'running' WHERE id = ?1", run.id);
99 expect(idleReason(app, NOW)?.word).toBe("nothing ready");
100 });
101
102 test("ceiling reached", () => {
103 open("Work");
104 spend(10);
105 expect(idleReason(app, new Date())).toEqual({ word: "ceiling reached", href: "/meter" });
106 });
107
108 test("flow only: ready issues, none labelled flow", () => {
109 open("Plain");
110 set("UPDATE repo SET flow_only = 1 WHERE id = ?1", repo.id);
111 expect(idleReason(app, NOW)).toEqual({ word: "flow only", href: "/map" });
112 // A flow issue is startable: the switch no longer explains the idleness.
113 const flow = open("Flowing");
114 setIssueLabels(app.db, flow.id, [flowLabel()]);
115 expect(idleReason(app, NOW)).toBeNull();
116 });
117
118 test("no subscription with headroom", () => {
119 open("Work");
120 const added = addSubscription(app.db, dataDir, { name: `acct${n}`, token: "sk-ant-api03-idle-reason-test-key" });
121 if (!added.ok) throw new Error(String(added.error));
122 setGrants(app.db, repo.id, "", [added.value.id]);
123 noteLimit(app.db, added.value.id, {
124 window: "5h",
125 resetsAt: "2026-10-02T14:00:00.000Z",
126 observedAt: "2026-10-02T11:00:00.000Z",
127 said: "You've hit your session limit",
128 });
129 expect(idleReason(app, NOW)).toEqual({ word: "no subscription with headroom", href: "/subscriptions" });
130 });
131
132 test("nothing ready: no open issue, or every one waits on another", () => {
133 expect(idleReason(app, NOW)).toEqual({ word: "nothing ready", href: "/map" });
134 const first = open("First");
135 const second = open("Second");
136 expect(addDependency(app.db, second, first, alice.id).ok).toBe(true);
137 set("UPDATE issue SET state = 'closed' WHERE id = ?1", first.id);
138 expect(idleReason(app, NOW)).toBeNull();
139 set("UPDATE issue SET state = 'closed' WHERE id = ?1", second.id);
140 expect(idleReason(app, NOW)?.word).toBe("nothing ready");
141 });
142
143 test("the first reason that holds wins: the ceiling before flow only before nothing", () => {
144 set("UPDATE repo SET flow_only = 1 WHERE id = ?1", repo.id);
145 spend(10);
146 expect(idleReason(app, new Date())?.word).toBe("ceiling reached");
147 });
148
149 test("an issue the dispatcher holds is not ready: a run in flight on the only one says nothing ready", () => {
150 const only = open("Held by a run");
151 const run = createRun(app.db, { repoId: repo.id, kind: "agent", actorId: bot.id, reason: "review", issueId: only.id });
152 set("UPDATE run SET state = 'running' WHERE id = ?1", run.id);
153 expect(idleReason(app, NOW)).toEqual({ word: "nothing ready", href: "/map" });
154 });
155
156 test("the line says the word and the link", () => {
157 expect(idleLine({ word: "flow only", href: "/map" })).toBe("Builders idle: flow only (/map)");
158 });
159 });
test/tui-dashboard-idle.test.ts added +77
@@ -0,0 +1,77 @@
1 /** The terminal's dashboard says why the builders are idle in the one line, for an admin alone (#7663). */
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 { addToCrew, createAgent } from "../src/actions/crew.ts";
7 import { createRepo } from "../src/actions/repos.ts";
8 import { createApp, type App as Forge } from "../src/app.ts";
9 import { createRun } from "../src/core/runs.ts";
10 import { createUser, getUserByName, type User } from "../src/core/users.ts";
11 import { idleLine, idleReason } from "../src/crew/idle_reason.ts";
12 import { createForgeApp } from "../src/tui/forge.ts";
13 import { headlessTerminal } from "../src/tui/term/terminal.ts";
14 import { quietLogs } from "./helpers/globals.ts";
15 import { seedAdmin } from "./helpers/admin.ts";
16 import { settleApp } from "./helpers/tui.ts";
17
18 quietLogs();
19
20 let dir: string;
21 let forge: Forge;
22 let alice: User;
23 let bob: User;
24
25 beforeAll(async () => {
26 dir = await mkdtemp(join(tmpdir(), "forge-tui-idle-"));
27 forge = createApp({ dataDir: dir, port: 0 });
28 const a = await seedAdmin(forge, { username: "alice", email: "[email protected]", password: "a-long-passphrase" });
29 if (!a.ok) throw new Error("signup failed");
30 alice = a.value;
31 const b = await createUser(forge.db, {
32 origin: "seed",
33 trusted: true,
34 username: "bob",
35 email: "[email protected]",
36 password: "a-long-passphrase",
37 });
38 if (!b.ok) throw new Error(b.error);
39 bob = b.value;
40 });
41
42 afterAll(async () => {
43 forge.close();
44 await rm(dir, { recursive: true, force: true });
45 });
46
47 async function firstScreen(user: User): Promise<string> {
48 const terminal = headlessTerminal(200, 32);
49 const { app } = createForgeApp(
50 forge,
51 { sshUsername: user.username, remoteAddress: "127.0.0.1", fingerprint: "SHA256:t", user },
52 { terminal, exitHooks: false },
53 );
54 app.start();
55 await settleApp(app);
56 const text = app.screen.toText();
57 app.stop();
58 return text;
59 }
60
61 test("the admin's home prints the reason's line; nobody else's does", async () => {
62 const made = await createRepo(forge, alice, { name: "svc", visibility: "private" });
63 if (!made.ok) throw new Error(String(made.error));
64 const created = await createAgent(forge, alice, { username: "idlebot" });
65 if (!created.ok) throw new Error(String(created.error));
66 expect(addToCrew(forge, alice, made.value, "idlebot").ok).toBe(true);
67 forge.db
68 .query("UPDATE repo SET autonomy = 'autonomous', autonomy_by = ?2, autonomy_ceiling_usd = 1, autonomy_slots = 2 WHERE id = ?1")
69 .run(made.value.id, alice.id);
70 const run = createRun(forge.db, { repoId: made.value.id, kind: "agent", actorId: getUserByName(forge.db, "idlebot")!.id });
71 forge.db.query("UPDATE run SET cost_usd = 5, state = 'ok' WHERE id = ?1").run(run.id);
72
73 const reason = idleReason(forge, new Date());
74 expect(reason).toEqual({ word: "ceiling reached", href: "/meter" });
75 expect(await firstScreen(alice)).toContain(idleLine(reason!));
76 expect(await firstScreen(bob)).not.toContain("Builders idle");
77 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs