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

Settings → Capacity flags a crew concurrency set below its ceiling and resets it in one click #9057

Merged opened this

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

Merged

scout/forge:scout/9049-settings-capacity-flags-a-crew-concurren → main 3 commits fork 2 threads · 2 unresolved

  • Merged. scout/9049-settings-capacity-flags-a-crew-concurren was merged into main as 3f0912e.

Review

Sign in to review this change.
9 files changed +147 −16
docs/features/capacity.md added +9
@@ -0,0 +1,9 @@
1 # Capacity flags a count set below its ceiling (#9049)
2
3 A capacity count (`crew.concurrency`, `checks.concurrency`, `queue.depth` and the rest) that an admin has set **below its default** holds the forge back, and nothing used to show it. Now:
4
5 - **Settings → Usage → Capacity** puts a "below ceiling N" badge beside the key, N being the default in force (`belowCeiling` in `src/core/capacity.ts`). A value at or above the ceiling, a word-valued key such as `cells.egress`, and a key with no stored value are not flagged.
6 - **The terminal's Capacity screen** prints "below ceiling N" in the same row's meta line.
7 - **The pace sentence** (Home, the terminal dashboard and Usage → Spend) says "Crew concurrency is set to X, below its ceiling of Y" while `crew.concurrency` is lowered, so the cap is not mistaken for pacing.
8
9 **Reset:** the row's **Put back** button is the reset. It is one same-origin POST to `/settings/capacity` with `reset=1`, it takes effect on the next pick, and it is logged under Changes like any other capacity change.
src/core/capacity.ts +14
@@ -321,6 +321,20 @@ export function bootDefault(db: Database, key: CapacityKey): string {
321321 return booted.get(db)?.get(key) ?? capacityDefault(key);
322322 }
323323
324 /**
325 * The ceiling `key` is held under: its default in force, when an admin's
326 * stored count is below it; null for a choice, an unset key or a value at
327 * or above the ceiling. A crew concurrency lowered at runtime holds the
328 * crew back, and this is how a page can say so.
329 */
330 export function belowCeiling(db: Database, key: CapacityKey): { value: number; ceiling: number } | null {
331 if (CAPACITY[key].kind.type !== "count") return null;
332 const stored = storedCapacity(db, key);
333 const ceiling = Number(bootDefault(db, key));
334 if (stored === undefined || !Number.isFinite(ceiling) || ceiling <= 0) return null;
335 return Number(stored) < ceiling ? { value: Number(stored), ceiling } : null;
336 }
337
324338 /**
325339 * A count setting as a number: an admin's value, else `fallback` — the
326340 * value the calling service booted with, which is the default in force.
src/crew/week_read.ts +8 −2
@@ -9,7 +9,7 @@ import { measureFor } from "../core/measure.ts";
99 import { getHeadroom, WINDOW_MS } from "../core/standing.ts";
1010 import { grantedAccounts, PLATFORM } from "../core/subscriptions.ts";
1111 import { burnFrom, noteReading, readingsSince } from "./week_burn.ts";
12 import { capacityCount, paceFloor } from "../core/capacity.ts";
12 import { belowCeiling, capacityCount, paceFloor } from "../core/capacity.ts";
1313 import { efficiencyWords, WEEK, weekDecision, type Efficiency, type WeekDecision, type WeekMeter } from "./week.ts";
1414
1515 /** The week as the provider's meter reads it: a meter, or null while no account's limit is known. */
@@ -140,6 +140,12 @@ export function crewWeekFor(app: App, repo: Repo, now: Date): WeekDecision | nul
140140 return weekDecision({ ...week, now, ceiling: crewCeiling(app, repo), floorSlots: paceFloor(app.db), idleSlots: 0, startable: 0, gated: 0 });
141141 }
142142
143 /** A sentence saying the crew's cap is lowered, so pacing is not mistaken for it; empty at the ceiling. */
144 function heldWords(app: App): string {
145 const held = belowCeiling(app.db, "crew.concurrency");
146 return held === null ? "" : ` Crew concurrency is set to ${held.value}, below its ceiling of ${held.ceiling}: that cap holds the crew back, not the pace. Settings → Usage → Capacity puts it back.`;
147 }
148
143149 /**
144150 * The pace sentence and the efficiency beside it for the autonomous
145151 * repository the crew works most this week (the most agent runs; the first by
@@ -154,7 +160,7 @@ export function weekWords(app: App, now: Date = new Date()): string | null {
154160 const week = crewWeekFor(app, repo, now);
155161 if (week === null) continue;
156162 const efficiency = weekEfficiency(app, repo, now);
157 if (best === null || efficiency.runs > best.runs) best = { words: `${week.sentence} The week allows the whole crew ${week.slots} of ${crewCeiling(app, repo)} slots across every repository. ${efficiencyWords(efficiency)}`, runs: efficiency.runs };
163 if (best === null || efficiency.runs > best.runs) best = { words: `${week.sentence} The week allows the whole crew ${week.slots} of ${crewCeiling(app, repo)} slots across every repository. ${efficiencyWords(efficiency)}${heldWords(app)}`, runs: efficiency.runs };
158164 }
159165 return best?.words ?? null;
160166 }
src/tui/screens/capacity.ts +9 −5
@@ -8,7 +8,7 @@
88 import type { SelfSwap } from "../../release/swap.ts";
99 import { changeCapacity, listCapacity, type CapacityRow } from "../../actions/capacity.ts";
1010 import { SETTING_WORDS } from "../../actions/setting_words.ts";
11 import { CAPACITY, validCapacity, type CapacityKey } from "../../core/capacity.ts";
11 import { CAPACITY, belowCeiling, validCapacity, type CapacityKey } from "../../core/capacity.ts";
1212 import type { Ctx, Screen } from "../app/app.ts";
1313 import { binding } from "../app/keys.ts";
1414 import type { Session } from "../session.ts";
@@ -56,10 +56,14 @@ export function capacityScreen(f: Session, ctx: Ctx): Screen {
5656 const lines = (): ListRow[] => {
5757 const listed = listCapacity(f.app, f.user);
5858 if (!listed.ok) return [];
59 const guards = listed.value.settings.map((r) => ({
60 title: `${r.label} (${r.key})`,
61 meta: `${shown(r.value)} · ${r.source} · takes ${boundsOf(r.key)}${r.source === "setting" ? ` · default ${shown(r.default)}` : ""}`,
62 }));
59 const guards = listed.value.settings.map((r) => {
60 const low = belowCeiling(f.app.db, r.key);
61 return {
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}`}`,
64 ...(low === null ? {} : { tone: "attn" as const }),
65 };
66 });
6367 // After the guards, so the cursor (bounded by the settings) never lands on it.
6468 return [...guards, selfSwapRow(listed.value.selfSwap)];
6569 };
src/web/pages/capacity.ts +10 −6
@@ -7,7 +7,7 @@
77 */
88
99 import { changeCapacity, listCapacity, type CapacityRow } from "../../actions/capacity.ts";
10 import { CAPACITY, type CapacityKey } from "../../core/capacity.ts";
10 import { CAPACITY, belowCeiling, type CapacityKey } from "../../core/capacity.ts";
1111 import { bytes } from "../../system/format.ts";
1212 import { egressSnapshot } from "../../apps/egress.ts";
1313 import type { Ctx } from "../../http/context.ts";
@@ -85,10 +85,14 @@ export function capacityPage(ctx: Ctx): Response {
8585 );
8686 const now = Date.now();
8787 const rendering = performance.now();
88 const rows = settings.map((s) =>
89 row({
88 const rows = settings.map((s) => {
89 const low = belowCeiling(ctx.app.db, s.key);
90 return row({
9091 lead: icon("activity"),
91 title: html`${s.label} <span class="muted small mono">${s.key}</span>`,
92 title: html`${s.label} <span class="muted small mono">${s.key}</span>${when(
93 low !== null,
94 () => html` ${badge(`below ceiling ${low?.ceiling}`, { tone: "attn" })}`,
95 )}`,
9296 meta: html`${s.help} <br />Now <span class="mono">${s.value === "" ? "auto" : s.value}</span>. ${s.source === "setting"
9397 ? html`Set here; the boot default is
9498 <span class="mono">${s.default === "" ? "auto" : s.default}</span>
@@ -118,8 +122,8 @@ export function capacityPage(ctx: Ctx): Response {
118122 })}`,
119123 )}
120124 </form>`,
121 }),
122 );
125 });
126 });
123127 const history = changes.map((c) =>
124128 row({
125129 lead: icon("history"),
src/web/pages/spend-tab.ts +2 −1
@@ -10,6 +10,7 @@ import type { Ctx } from "../../http/context.ts";
1010 import { notFoundPage, signInFirst } from "../chrome.ts";
1111 import { html } from "../html.ts";
1212 import { card, icon } from "../ui.ts";
13 import { weekLine } from "../studio/week_line.ts";
1314 import { spendView } from "./spend.ts";
1415 import { usageShell } from "./usage-tabs.ts";
1516
@@ -20,7 +21,7 @@ export function spendPage(ctx: Ctx): Response {
2021 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
2122 const spend = timePhase(ctx.request, "spend", () => readSpend(ctx.app, new Date()));
2223 const rendering = performance.now();
23 const body = html`<div id="spend" data-live="run.queued,run.started,run.finished,usage.*">${card(
24 const body = html`${weekLine(ctx.app)}<div id="spend" data-live="run.queued,run.started,run.finished,usage.*">${card(

The week line on Spend is untested. Add a test that fetches the Spend tab and expects the below-ceiling sentence.

2425 spendView(spend),
2526 { head: html`${icon("clock")}<h2>Spend</h2>` },
2627 )}</div>`;
test/capacity.test.ts +37
@@ -419,6 +419,43 @@ describe("report mode is loud to admins, and to nobody else", () => {
419419 const needs = (who: User | undefined): string[] =>
420420 "needs" in readHealth(app, who) ? (readHealth(app, who) as { needs: string[] }).needs : [];
421421
422 const signedIn = async (): Promise<string> => {
423 const res = await fetch(`${servers.url}/signin`, {
424 method: "POST",
425 redirect: "manual",
426 headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url },
427 body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }),
428 });
429 return (res.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
430 };
431 const post = (cookie: string, origin: string, body: Record<string, string>): Promise<Response> =>
432 fetch(`${servers.url}/settings/capacity`, {
433 method: "POST",
434 redirect: "manual",
435 headers: { cookie, "content-type": "application/x-www-form-urlencoded", origin },
436 body: new URLSearchParams(body),
437 });
438
439 test("below its ceiling: a lowered crew concurrency is flagged, a cross-origin reset is refused, one reset clears it", async () => {
440 const ceiling = Number(capacityDefault("crew.concurrency"));
441 expect(ceiling).toBeGreaterThan(1);
442 const cookie = await signedIn();
443 expect(changeCapacity(app, alice, "crew.concurrency", String(ceiling)).ok).toBe(true);
444 expect(await page()).not.toContain("below ceiling");
445 expect(changeCapacity(app, alice, "crew.concurrency", "1").ok).toBe(true);
446 expect(await page()).toContain(`below ceiling ${ceiling}`);
447
448 const refused = await post(cookie, "https://evil.example", { key: "crew.concurrency", reset: "1" });
449 expect(refused.status).toBeGreaterThanOrEqual(400);
450 expect(capacityValue(app.db, "crew.concurrency")).toBe("1");
451
452 const before = capacityChanges(app.db, 100).length;
453 expect((await post(cookie, servers.url, { key: "crew.concurrency", reset: "1" })).status).toBe(303);
454 expect(capacityValue(app.db, "crew.concurrency")).toBe(String(ceiling));
455 expect(capacityChanges(app.db, 100).length).toBe(before + 1);
456 expect(await page()).not.toContain("below ceiling");
457 });
458
422459 test("reported: the Capacity page says it once, as a note; vitals do not", async () => {
423460 expect(changeCapacity(app, alice, "cells.egress", "report").ok).toBe(true);
424461 expect(needs(alice)).toEqual([]);
test/tui-capacity.test.ts +19 −1
@@ -11,7 +11,7 @@ import { createAgent } from "../src/actions/crew.ts";
1111 import { createApp, type App } from "../src/app.ts";
1212 import { listEvents } from "../src/core/events.ts";
1313 import { changeCapacity } from "../src/actions/capacity.ts";
14 import { CAPACITY_KEYS, capacityValue } from "../src/core/capacity.ts";
14 import { CAPACITY_KEYS, capacityDefault, capacityValue } from "../src/core/capacity.ts";
1515 import { createUser, type User } from "../src/core/users.ts";
1616 import { createForgeApp, forgeCommands } from "../src/tui/forge.ts";
1717 import { capacityScreen } from "../src/tui/screens/capacity.ts";
@@ -86,6 +86,24 @@ describe("tui capacity", () => {
8686 h.stop();
8787 });
8888
89 test("a crew concurrency below its ceiling is marked, and not at it", async () => {
90 const ceiling = capacityDefault("crew.concurrency");
91 try {
92 changeCapacity(app, carol, "crew.concurrency", "1");
93 const low = boot(carol);
94 await low.press();
95 expect(low.text()).toContain(`below ceiling ${ceiling}`);
96 low.stop();
97 changeCapacity(app, carol, "crew.concurrency", ceiling);
98 const level = boot(carol);
99 await level.press();
100 expect(level.text()).not.toContain("below ceiling");
101 level.stop();
102 } finally {
103 changeCapacity(app, carol, "crew.concurrency", null);
104 }
105 });
106
89107 test("changes crew concurrency after the plan and a confirm", async () => {
90108 const h = boot(carol);
91109 await h.press("e");
test/week-wiring.test.ts +39 −1
@@ -1,5 +1,5 @@
11 /** The week's pace wired in (#8695): the tick releases gated work when behind, the pace cuts slots when ahead, and home and the terminal print the sentence. */
2 import { afterAll, beforeAll, beforeEach, expect, test } from "bun:test";
2 import { afterAll, afterEach, beforeAll, beforeEach, expect, test } from "bun:test";
33 import { mkdtemp, rm } from "node:fs/promises";
44 import { tmpdir } from "node:os";
55 import { join } from "node:path";
@@ -18,12 +18,15 @@ import { addSubscription } from "../src/core/subscriptions.ts";
1818 import { recordUsage } from "../src/core/usage.ts";
1919 import { type User } from "../src/core/users.ts";
2020 import { driveRepo, repoPace } from "../src/crew/autonomy.ts";
21 import { changeCapacity } from "../src/actions/capacity.ts";
22 import { capacityDefault } from "../src/core/capacity.ts";
2123 import { forgetWeek, weekWords } from "../src/crew/week_read.ts";
2224 import { createForgeApp } from "../src/tui/forge.ts";
2325 import { headlessTerminal } from "../src/tui/term/terminal.ts";
2426 import { weekLine } from "../src/web/studio/week_line.ts";
2527 import { seedAdmin } from "./helpers/admin.ts";
2628 import { quietLogs } from "./helpers/globals.ts";
29 import { serve } from "../src/http/server.ts";
2730 import { settleApp } from "./helpers/tui.ts";
2831
2932 quietLogs();
@@ -114,6 +117,10 @@ afterAll(async () => {
114117 });
115118
116119 beforeEach(() => forgetWeek());
120 // A failed expectation must not leave the lowered crew concurrency for later tests.
121 afterEach(() => {
122 changeCapacity(app, alice, "crew.concurrency", null);
123 });
117124
118125 test("behind pace, with an idle slot and nothing startable, the tick releases the gated issue and says why", async () => {
119126 week(20);
@@ -166,6 +173,37 @@ test("home and the terminal print the same sentence, the terminal for an admin",
166173 expect(text).toContain("Behind pace");
167174 });
168175
176 test("the pace sentence says when crew concurrency is below its ceiling, and not at it", () => {
177 week(20);
178 const ceiling = Number(capacityDefault("crew.concurrency"));
179 expect(changeCapacity(app, alice, "crew.concurrency", String(ceiling)).ok).toBe(true);
180 expect(weekWords(app, NOW)!).not.toContain("below its ceiling");
outdated

This cleanup is skipped when an earlier expect throws. Move it to afterEach.

181 expect(changeCapacity(app, alice, "crew.concurrency", "1").ok).toBe(true);
182 forgetWeek();
183 expect(weekWords(app, NOW)!).toContain(`Crew concurrency is set to 1, below its ceiling of ${ceiling}`);
184 });
185
186 test("Usage → Spend carries the same sentence, so pacing is not mistaken for the cap", async () => {
187 week(20);
188 const servers = await serve(app);
189 try {
190 const signin = await fetch(`${servers.url}/signin`, {
191 method: "POST",
192 redirect: "manual",
193 headers: { origin: servers.url },
194 body: new URLSearchParams({ username: "alice", password: "a-long-passphrase" }),
195 });
196 const cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
197 const spend = () => fetch(`${servers.url}/settings/spend`, { headers: { cookie } }).then((r) => r.text());
198 expect(await spend()).not.toContain("below its ceiling");
199 expect(changeCapacity(app, alice, "crew.concurrency", "1").ok).toBe(true);
200 forgetWeek();
201 expect((await spend()).replace(/\s+/g, " ")).toContain("below its ceiling of");
202 } finally {
203 servers.stop();
204 }
205 });
206
169207 test("a fast afternoon slows the crew at once, on a week whose total is on pace", () => {
170208 week(50);
171209 const calm = repoPace(app, repo, NOW).slots;

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs