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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10461-knobs-one-panel-on-the-crew-page-with-fi → main 14 commits fork 4 threads · 4 unresolved
docs/features/knobs.md +5 −2
| @@ -9,6 +9,9 @@ Five dials for the crew, on **Settings › Crew** (admin), in the terminal's pal | ||
| 9 | 9 | 5. **Feedback first**: always on; shown, not tuned. |
| 10 | 10 | |
| 11 | 11 | 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. | |
| 13 | 12 | |
| 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 { | ||
| 14 | 14 | CAPACITY_KEYS, |
| 15 | 15 | capacityChanges, |
| 16 | 16 | bootDefault, |
| 17 | capacityValue, | |
| 18 | 17 | isCapacityKey, |
| 19 | 18 | setCapacity, |
| 20 | 19 | storedCapacity, |
| 21 | 20 | validCapacity, |
| 22 | 21 | type CapacityChange, |
| 23 | 22 | type CapacityKey, |
| 23 | effectiveCapacity, | |
| 24 | followsSpeed, | |
| 24 | 25 | } from "../core/capacity.ts"; |
| 25 | 26 | import type { EgressTotal } from "../apps/egress.ts"; |
| 26 | 27 | import { |
| @@ -52,7 +53,7 @@ export interface CapacityRow { | ||
| 52 | 53 | readonly help: string; |
| 53 | 54 | readonly value: string; |
| 54 | 55 | /** Where `value` comes from: an admin's setting, or the boot default. */ |
| 55 | readonly source: "setting" | "default"; | |
| 56 | readonly source: "setting" | "default" | "derived"; | |
| 56 | 57 | readonly default: string; |
| 57 | 58 | readonly env: string; |
| 58 | 59 | /** For a setting away from its default: when and by whom it was set. */ |
| @@ -120,8 +121,8 @@ export function listCapacity( | ||
| 120 | 121 | key, |
| 121 | 122 | label: CAPACITY[key].label, |
| 122 | 123 | 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", | |
| 125 | 126 | default: bootDefault(app.db, key), |
| 126 | 127 | env: CAPACITY[key].env, |
| 127 | 128 | ...changedOf(app.db, key), |
src/core/capacity.ts +21 −2
| @@ -375,7 +375,26 @@ export function capacityCount( | ||
| 375 | 375 | |
| 376 | 376 | /** The slots the pace leaves reviews and assigned work (`pace.floor`). */ |
| 377 | 377 | 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); | |
| 379 | 398 | } |
| 380 | 399 | |
| 381 | 400 | /** 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[] = [ | ||
| 429 | 448 | |
| 430 | 449 | /** How many merge-queue candidates of one repository gate at once (#4053). */ |
| 431 | 450 | export function queueDepth(db: Database): number { |
| 432 | return Number(capacityValue(db, "queue.depth")); | |
| 451 | return Number(effectiveCapacity(db, "queue.depth")); | |
| 433 | 452 | } |
| 434 | 453 | |
| 435 | 454 | /** The most pulls one merge-queue candidate carries (#6674); the line adapts below it. */ |
src/crew/knobs.ts +8 −3
| @@ -4,12 +4,12 @@ | ||
| 4 | 4 | * line on what it does, the value, a control kind and its live effect. The |
| 5 | 5 | * crew page, the terminal and the MCP `knobs` tool print these same rows; |
| 6 | 6 | * `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 | |
| 8 | 8 | * capacity page. |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | 11 | import type { App } from "../app.ts"; |
| 12 | import { capacityValue } from "../core/capacity.ts"; | |
| 12 | import { capacityValue, followsSpeed, paceFloor, queueDepth } from "../core/capacity.ts"; | |
| 13 | 13 | import { labelsForIssues } from "../core/issues.ts"; |
| 14 | 14 | import { getRepoById, type Repo } from "../core/repos.ts"; |
| 15 | 15 | import { listActive } from "../core/runs.ts"; |
| @@ -43,6 +43,11 @@ export function autonomyWord(repo: Repo): AutonomyWord { | ||
| 43 | 43 | return !repo.plansNeedNod && repo.decisionsBy !== "me" ? "go" : "ask"; |
| 44 | 44 | } |
| 45 | 45 | |
| 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 | ||
| 46 | 51 | function runsLine(app: App): string { |
| 47 | 52 | const active = listActive(app.db, "agent"); |
| 48 | 53 | const running = active.filter((r) => r.state === "running").length; |
| @@ -86,7 +91,7 @@ export function readKnobs(app: App, now: Date = new Date()): KnobRow[] { | ||
| 86 | 91 | control: "number", |
| 87 | 92 | min: 1, |
| 88 | 93 | 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")}`, | |
| 90 | 95 | }, |
| 91 | 96 | { |
| 92 | 97 | id: "budget", |
src/tui/screens/capacity.ts +1 −1
| @@ -60,7 +60,7 @@ export function capacityScreen(f: Session, ctx: Ctx): Screen { | ||
| 60 | 60 | const low = belowCeiling(f.app.db, r.key); |
| 61 | 61 | return { |
| 62 | 62 | 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}`}`, | |
| 64 | 64 | ...(low === null ? {} : { tone: "attn" as const }), |
| 65 | 65 | }; |
| 66 | 66 | }); |
src/web/pages/capacity.ts +3 −1
| @@ -101,7 +101,9 @@ export function capacityPage(ctx: Ctx): Response { | ||
| 101 | 101 | ? html`Set here; the boot default is |
| 102 | 102 | <span class="mono">${s.default === "" ? "auto" : s.default}</span> |
| 103 | 103 | (<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>.`} | |
| 105 | 107 | ${awayLine(s, now)}`, |
| 106 | 108 | trail: html`<form |
| 107 | 109 | class="row-fields" |
test/knobs.test.ts +45 −2
| @@ -3,14 +3,15 @@ | ||
| 3 | 3 | * turn read by the next pump; the pipeline's own dials stay on the capacity page. |
| 4 | 4 | */ |
| 5 | 5 | |
| 6 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 6 | import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"; | |
| 7 | 7 | import { mkdtemp, rm } from "node:fs/promises"; |
| 8 | 8 | import { tmpdir } from "node:os"; |
| 9 | 9 | import { join } from "node:path"; |
| 10 | 10 | import { createRepo } from "../src/actions/repos.ts"; |
| 11 | 11 | import { setKnob } from "../src/actions/knobs.ts"; |
| 12 | 12 | 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"; | |
| 14 | 15 | import { listEvents } from "../src/core/events.ts"; |
| 15 | 16 | import { getRepoById } from "../src/core/repos.ts"; |
| 16 | 17 | 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 | ||
| 115 | 116 | expect(JSON.stringify(dry)).toContain("Would set speed"); |
| 116 | 117 | expect(JSON.stringify(await tool.run(ctx, bob, {}))).toContain("not_found"); |
| 117 | 118 | }); |
| 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 |
||
| 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; | ||
| 23 | 23 | |
| 24 | 24 | beforeAll(async () => { |
| 25 | 25 | standIn = await startStandInCrew({ tickMs: 50 }); |
| 26 | setCapacity(standIn.app.db, "pace.floor", "2", standIn.alice.id); | |
| 26 | 27 | setCapacity(standIn.app.db, "crew.concurrency", "10", standIn.alice.id); |
| 27 | 28 | }, 30_000); |
| 28 | 29 | 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 | ||
| 79 | 79 | setHeadroom(app.db, sub.value.id, 0); |
| 80 | 80 | app.db.query("INSERT INTO subscription_grant (scope_id, agent_id, subscription_id) VALUES (?1, '', ?2)").run(made.value.id, sub.value.id); |
| 81 | 81 | setCapacity(app.db, "crew.concurrency", "10", alice.id); |
| 82 | setCapacity(app.db, "pace.floor", "2", alice.id); | |
| 82 | 83 | const now = Date.now(); |
| 83 | 84 | noteMeasure(app.db, sub.value.id, { window: "week", usedPct: 70, resetsAt: new Date(now + 3.5 * DAY).toISOString(), observedAt: new Date(now).toISOString() }); |
| 84 | 85 | // 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"; | ||
| 19 | 19 | import { createUser, type User } from "../src/core/users.ts"; |
| 20 | 20 | import { driveRepo, repoPace } from "../src/crew/autonomy.ts"; |
| 21 | 21 | import { changeCapacity } from "../src/actions/capacity.ts"; |
| 22 | import { capacityDefault } from "../src/core/capacity.ts"; | |
| 22 | import { capacityDefault, setCapacity } from "../src/core/capacity.ts"; | |
| 23 | 23 | import { crewLineWords, forgetWeek, weekWords } from "../src/crew/week_read.ts"; |
| 24 | 24 | import { createForgeApp } from "../src/tui/forge.ts"; |
| 25 | 25 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; |
| @@ -83,6 +83,7 @@ beforeAll(async () => { | ||
| 83 | 83 | const a = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "a-long-passphrase" }); |
| 84 | 84 | if (!a.ok) throw new Error("signup failed"); |
| 85 | 85 | alice = a.value; |
| 86 | setCapacity(app.db, "pace.floor", "2", alice.id); | |
| 86 | 87 | const made = await createRepo(app, alice, { name: "svc", visibility: "private" }); |
| 87 | 88 | if (!made.ok) throw new Error(String(made.error)); |
| 88 | 89 | repo = made.value; |
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.