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

Knobs: one panel on the crew page with five plain dials, each showing its live effect, editable in place #10605

Merged opened this in answer to

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

Merged

scout/forge:scout/10461-knobs-one-panel-on-the-crew-page-with-fi → main 14 commits fork 4 threads · 4 unresolved

  • Merged. scout/10461-knobs-one-panel-on-the-crew-page-with-fi was merged into main as b37e50e.

Review

Sign in to review this change.
10 files changed +92 −16
docs/features/knobs.md +5 −2
@@ -9,6 +9,9 @@ Five dials for the crew, on **Settings › Crew** (admin), in the terminal's pal
99 5. **Feedback first**: always on; shown, not tuned.
1010
1111 A change applies on the next pump and leaves the event the setting's own page leaves (`capacity.changed`, `repo.settings_updated`).
12 Not done: the per-repository dollar ceiling (`autonomyCeilingUsd`) and `reserve.ts`'s two-per-slot reserve still stand.
1312
outdated

The text mixes done and not done in one paragraph. Say plainly what the panel derives and where it shows as derived; put the unfinished removals in an issue, not in the how-to.

14 The pipeline's own dials (`queue.depth`, `pace.floor`, `slots.smallShare`, `polish.capMinutes`) are untouched: they stay on the capacity page and nothing in the panel derives them. Only `week.use` is new, and a stored or `FORGE_WEEK_USE` value is read as the budget; no stored value is dropped.
13 ## Derived from Speed
14
15 `pace.floor` (half of Speed, at least 1) and `queue.depth` (half of Speed rounded up, 1 to 4) follow Speed until someone sets them on **Settings › Capacity**; a stored value always wins and nothing stored is dropped. While they follow, the Speed row (page, terminal, MCP) says `(derived from Speed)` beside each, and the capacity page and its terminal screen mark the row `derived`. Because the floor was a fixed 2 before, an install with no stored floor now gets half its Speed (Speed 10: 5). That is a deliberate move of the default pace; set `pace.floor` to keep the old number.
16
17 `week.use` is new; a stored or `FORGE_WEEK_USE` value is read as the budget.
src/actions/capacity.ts +5 −4
@@ -14,13 +14,14 @@ import {
1414 CAPACITY_KEYS,
1515 capacityChanges,
1616 bootDefault,
17 capacityValue,
1817 isCapacityKey,
1918 setCapacity,
2019 storedCapacity,
2120 validCapacity,
2221 type CapacityChange,
2322 type CapacityKey,
23 effectiveCapacity,
24 followsSpeed,
2425 } from "../core/capacity.ts";
2526 import type { EgressTotal } from "../apps/egress.ts";
2627 import {
@@ -52,7 +53,7 @@ export interface CapacityRow {
5253 readonly help: string;
5354 readonly value: string;
5455 /** Where `value` comes from: an admin's setting, or the boot default. */
55 readonly source: "setting" | "default";
56 readonly source: "setting" | "default" | "derived";
5657 readonly default: string;
5758 readonly env: string;
5859 /** For a setting away from its default: when and by whom it was set. */
@@ -120,8 +121,8 @@ export function listCapacity(
120121 key,
121122 label: CAPACITY[key].label,
122123 help: CAPACITY[key].help,
123 value: capacityValue(app.db, key),
124 source: storedCapacity(app.db, key) === undefined ? "default" : "setting",
124 value: effectiveCapacity(app.db, key),
125 source: followsSpeed(app.db, key) ? "derived" : storedCapacity(app.db, key) === undefined ? "default" : "setting",
125126 default: bootDefault(app.db, key),
126127 env: CAPACITY[key].env,
127128 ...changedOf(app.db, key),
src/core/capacity.ts +21 −2
@@ -375,7 +375,26 @@ export function capacityCount(
375375
376376 /** The slots the pace leaves reviews and assigned work (`pace.floor`). */
377377 export function paceFloor(db: Database): number {

This moves the default floor from the stored default to Speed/2 for every install with no stored value. State it as a deliberate pace change in the pull, or keep the old default. Over-long single line too; split the ternary.

378 return Number(capacityValue(db, "pace.floor"));
378 return Number(effectiveCapacity(db, "pace.floor"));
379 }
380
381 /** The dials that follow Speed (`crew.concurrency`) until set on their own. */
382 export type SpeedDerivedKey = "pace.floor" | "queue.depth";
383
outdated

The derived value has no way to be shown as derived: return {value, derived} or expose a helper the crew page and terminal use to label the row "follows Speed" (box 3).

384 export function followsSpeed(db: Database, key: CapacityKey): key is SpeedDerivedKey {
385 return (key === "pace.floor" || key === "queue.depth") && storedCapacity(db, key) === undefined;
386 }
387
388 /** What Speed implies for a dial that follows it: half of Speed, at least 1 for the floor; half rounded up, 1 to 4, for the depth. */
389 export function derivedFromSpeed(db: Database, key: SpeedDerivedKey): number {
390 const speed = Number(capacityValue(db, "crew.concurrency"));
391 if (key === "pace.floor") return Math.max(1, Math.floor(speed / 2));
392 return Math.min(4, Math.max(1, Math.ceil(speed / 2)));
393 }
394
395 /** A capacity's value as the crew reads it: derived from Speed while a following dial is unset. */
396 export function effectiveCapacity(db: Database, key: CapacityKey): string {
397 return followsSpeed(db, key) ? String(derivedFromSpeed(db, key)) : capacityValue(db, key);
379398 }
380399
381400 /** The share of the week the crew may use, in percent (`week.use`); the rest is the reserve. */
@@ -429,7 +448,7 @@ export const BUILD_HOSTS: readonly string[] = [
429448
430449 /** How many merge-queue candidates of one repository gate at once (#4053). */
431450 export function queueDepth(db: Database): number {
432 return Number(capacityValue(db, "queue.depth"));
451 return Number(effectiveCapacity(db, "queue.depth"));
433452 }
434453
435454 /** The most pulls one merge-queue candidate carries (#6674); the line adapts below it. */
src/crew/knobs.ts +8 −3
@@ -4,12 +4,12 @@
44 * line on what it does, the value, a control kind and its live effect. The
55 * crew page, the terminal and the MCP `knobs` tool print these same rows;
66 * `actions/knobs.ts` is the one place that turns them. The pipeline's own
7 * dials (queue depth, pace floor, small share, polish cap) stay on the
7 * dials (small share, polish cap) stay on the
88 * capacity page.
99 */
1010
1111 import type { App } from "../app.ts";
12 import { capacityValue } from "../core/capacity.ts";
12 import { capacityValue, followsSpeed, paceFloor, queueDepth } from "../core/capacity.ts";
1313 import { labelsForIssues } from "../core/issues.ts";
1414 import { getRepoById, type Repo } from "../core/repos.ts";
1515 import { listActive } from "../core/runs.ts";
@@ -43,6 +43,11 @@ export function autonomyWord(repo: Repo): AutonomyWord {
4343 return !repo.plansNeedNod && repo.decisionsBy !== "me" ? "go" : "ask";
4444 }
4545
46 /** Whether a dial follows Speed, in the words the capacity page and the terminal use. */
47 function from(app: App, key: "pace.floor" | "queue.depth"): string {
48 return followsSpeed(app.db, key) ? " (derived from Speed)" : " (set)";
49 }
50
4651 function runsLine(app: App): string {
4752 const active = listActive(app.db, "agent");
4853 const running = active.filter((r) => r.state === "running").length;
@@ -86,7 +91,7 @@ export function readKnobs(app: App, now: Date = new Date()): KnobRow[] {
8691 control: "number",
8792 min: 1,
8893 max: 16,
89 effect: runsLine(app),
94 effect: `${runsLine(app)} · pace floor ${paceFloor(app.db)}${from(app, "pace.floor")} · queue depth ${queueDepth(app.db)}${from(app, "queue.depth")}`,
9095 },
9196 {
9297 id: "budget",
src/tui/screens/capacity.ts +1 −1
@@ -60,7 +60,7 @@ export function capacityScreen(f: Session, ctx: Ctx): Screen {
6060 const low = belowCeiling(f.app.db, r.key);
6161 return {
6262 title: `${r.label} (${r.key})`,
63 meta: `${shown(r.value)} · ${r.source} · takes ${boundsOf(r.key)}${r.source === "setting" ? ` · default ${shown(r.default)}` : ""}${low === null ? "" : ` · below ceiling ${low.ceiling}`}`,
63 meta: `${shown(r.value)} · ${r.source === "derived" ? "derived from Speed" : r.source} · takes ${boundsOf(r.key)}${r.source === "setting" ? ` · default ${shown(r.default)}` : ""}${low === null ? "" : ` · below ceiling ${low.ceiling}`}`,
6464 ...(low === null ? {} : { tone: "attn" as const }),
6565 };
6666 });
src/web/pages/capacity.ts +3 −1
@@ -101,7 +101,9 @@ export function capacityPage(ctx: Ctx): Response {
101101 ? html`Set here; the boot default is
102102 <span class="mono">${s.default === "" ? "auto" : s.default}</span>
103103 (<span class="mono">${s.env}</span>).`
104 : html`The boot default, from <span class="mono">${s.env}</span>.`}
104 : s.source === "derived"
105 ? html`Derived from Speed (<span class="mono">crew.concurrency</span>); set it here to stop following.`
106 : html`The boot default, from <span class="mono">${s.env}</span>.`}
105107 ${awayLine(s, now)}`,
106108 trail: html`<form
107109 class="row-fields"
test/knobs.test.ts +45 −2
@@ -3,14 +3,15 @@
33 * turn read by the next pump; the pipeline's own dials stay on the capacity page.
44 */
55
6 import { afterAll, beforeAll, expect, test } from "bun:test";
6 import { afterAll, afterEach, beforeAll, expect, test } from "bun:test";
77 import { mkdtemp, rm } from "node:fs/promises";
88 import { tmpdir } from "node:os";
99 import { join } from "node:path";
1010 import { createRepo } from "../src/actions/repos.ts";
1111 import { setKnob } from "../src/actions/knobs.ts";
1212 import { createApp, type App } from "../src/app.ts";
13 import { CAPACITY_KEYS, capacityCount, setCapacity, weekUse } from "../src/core/capacity.ts";
13 import { listCapacity } from "../src/actions/capacity.ts";
14 import { CAPACITY_KEYS, capacityCount, followsSpeed, paceFloor, queueDepth, setCapacity, weekUse } from "../src/core/capacity.ts";
1415 import { listEvents } from "../src/core/events.ts";
1516 import { getRepoById } from "../src/core/repos.ts";
1617 import { createUser, type User } from "../src/core/users.ts";
@@ -115,3 +116,45 @@ test("only a forge admin turns a knob, and the MCP tool says the same words", as
115116 expect(JSON.stringify(dry)).toContain("Would set speed");
116117 expect(JSON.stringify(await tool.run(ctx, bob, {}))).toContain("not_found");
117118 });
119
120 afterEach(() => {
121 for (const key of ["crew.concurrency", "pace.floor", "queue.depth"] as const) setCapacity(app.db, key, null, admin.id);
122 });
123
124 test("pace floor and queue depth follow Speed, and the next pump reads the new floor", async () => {
125 setCapacity(app.db, "crew.concurrency", "6", admin.id);
126 expect(paceFloor(app.db)).toBe(3);
outdated

Add a case where a stored queue.depth wins over Speed, and reset state in an afterEach so a failing expect does not leak Speed into later tests.

127 expect(queueDepth(app.db)).toBe(3);
128 // The dispatch pump asks paceFloor on every pass: turning Speed moves it at once.
129 expect((await setKnob(app, admin, "speed", "1")).ok).toBe(true);
130 expect(paceFloor(app.db)).toBe(1);
131 expect(queueDepth(app.db)).toBe(1);
132 });
133
134 test("a stored pace floor or queue depth wins over Speed", () => {
135 setCapacity(app.db, "crew.concurrency", "6", admin.id);
136 setCapacity(app.db, "pace.floor", "2", admin.id);
137 setCapacity(app.db, "queue.depth", "4", admin.id);
138 expect(paceFloor(app.db)).toBe(2);
139 expect(queueDepth(app.db)).toBe(4);
140 expect(followsSpeed(app.db, "queue.depth")).toBe(false);
141 });
142
143 test("derived rows are labelled on the capacity page, the terminal's lines and the knobs", async () => {
144 setCapacity(app.db, "crew.concurrency", "6", admin.id);
145 const rows = listCapacity(app, admin);
146 if (!rows.ok) throw new Error("not admin");
147 const floor = rows.value.settings.find((r) => r.key === "pace.floor")!;
148 expect(floor.source).toBe("derived");
149 expect(floor.value).toBe("3");
150 setCapacity(app.db, "queue.depth", "2", admin.id);
151 const set = listCapacity(app, admin);
152 if (!set.ok) throw new Error("not admin");
153 expect(set.value.settings.find((r) => r.key === "queue.depth")!.source).toBe("setting");
154 const effect = readKnobs(app)[0]!.effect;
155 expect(effect).toContain("pace floor 3 (derived from Speed)");
156 expect(effect).toContain("queue depth 2 (set)");
157 // The MCP tool prints the same rows, in the same words.
158 const ctx = { app, auth: { scope: "admin" } } as never;
159 expect(JSON.stringify(await tool.run(ctx, admin, {}))).toContain("pace floor 3 (derived from Speed)");
160 });
test/pace-floor.test.ts +1
@@ -23,6 +23,7 @@ let standIn: StandInCrew;
2323
2424 beforeAll(async () => {
2525 standIn = await startStandInCrew({ tickMs: 50 });
26 setCapacity(standIn.app.db, "pace.floor", "2", standIn.alice.id);
2627 setCapacity(standIn.app.db, "crew.concurrency", "10", standIn.alice.id);
2728 }, 30_000);
2829 afterAll(() => stopStandInCrew(standIn));
test/week-pump.test.ts +1
@@ -79,6 +79,7 @@ test("builds fill the pace slots, yet a review or assigned run still starts; a t
7979 setHeadroom(app.db, sub.value.id, 0);
8080 app.db.query("INSERT INTO subscription_grant (scope_id, agent_id, subscription_id) VALUES (?1, '', ?2)").run(made.value.id, sub.value.id);
8181 setCapacity(app.db, "crew.concurrency", "10", alice.id);
82 setCapacity(app.db, "pace.floor", "2", alice.id);
8283 const now = Date.now();
8384 noteMeasure(app.db, sub.value.id, { window: "week", usedPct: 70, resetsAt: new Date(now + 3.5 * DAY).toISOString(), observedAt: new Date(now).toISOString() });
8485 // 30% free now, 36% an hour ago: 6 points an hour, far over what is allowed, so the pace is the floor of 2 slots.
test/week-wiring.test.ts +2 −1
@@ -19,7 +19,7 @@ import { recordUsage } from "../src/core/usage.ts";
1919 import { createUser, type User } from "../src/core/users.ts";
2020 import { driveRepo, repoPace } from "../src/crew/autonomy.ts";
2121 import { changeCapacity } from "../src/actions/capacity.ts";
22 import { capacityDefault } from "../src/core/capacity.ts";
22 import { capacityDefault, setCapacity } from "../src/core/capacity.ts";
2323 import { crewLineWords, forgetWeek, weekWords } from "../src/crew/week_read.ts";
2424 import { createForgeApp } from "../src/tui/forge.ts";
2525 import { headlessTerminal } from "../src/tui/term/terminal.ts";
@@ -83,6 +83,7 @@ beforeAll(async () => {
8383 const a = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "a-long-passphrase" });
8484 if (!a.ok) throw new Error("signup failed");
8585 alice = a.value;
86 setCapacity(app.db, "pace.floor", "2", alice.id);
8687 const made = await createRepo(app, alice, { name: "svc", visibility: "private" });
8788 if (!made.ok) throw new Error(String(made.error));
8889 repo = made.value;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs