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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/9049-settings-capacity-flags-a-crew-concurren → main 3 commits fork 2 threads · 2 unresolved
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 { | ||
| 321 | 321 | return booted.get(db)?.get(key) ?? capacityDefault(key); |
| 322 | 322 | } |
| 323 | 323 | |
| 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 | ||
| 324 | 338 | /** |
| 325 | 339 | * A count setting as a number: an admin's value, else `fallback` — the |
| 326 | 340 | * 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"; | ||
| 9 | 9 | import { getHeadroom, WINDOW_MS } from "../core/standing.ts"; |
| 10 | 10 | import { grantedAccounts, PLATFORM } from "../core/subscriptions.ts"; |
| 11 | 11 | 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"; | |
| 13 | 13 | import { efficiencyWords, WEEK, weekDecision, type Efficiency, type WeekDecision, type WeekMeter } from "./week.ts"; |
| 14 | 14 | |
| 15 | 15 | /** 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 | ||
| 140 | 140 | return weekDecision({ ...week, now, ceiling: crewCeiling(app, repo), floorSlots: paceFloor(app.db), idleSlots: 0, startable: 0, gated: 0 }); |
| 141 | 141 | } |
| 142 | 142 | |
| 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 | ||
| 143 | 149 | /** |
| 144 | 150 | * The pace sentence and the efficiency beside it for the autonomous |
| 145 | 151 | * 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 { | ||
| 154 | 160 | const week = crewWeekFor(app, repo, now); |
| 155 | 161 | if (week === null) continue; |
| 156 | 162 | 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 }; | |
| 158 | 164 | } |
| 159 | 165 | return best?.words ?? null; |
| 160 | 166 | } |
src/tui/screens/capacity.ts +9 −5
| @@ -8,7 +8,7 @@ | ||
| 8 | 8 | import type { SelfSwap } from "../../release/swap.ts"; |
| 9 | 9 | import { changeCapacity, listCapacity, type CapacityRow } from "../../actions/capacity.ts"; |
| 10 | 10 | 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"; | |
| 12 | 12 | import type { Ctx, Screen } from "../app/app.ts"; |
| 13 | 13 | import { binding } from "../app/keys.ts"; |
| 14 | 14 | import type { Session } from "../session.ts"; |
| @@ -56,10 +56,14 @@ export function capacityScreen(f: Session, ctx: Ctx): Screen { | ||
| 56 | 56 | const lines = (): ListRow[] => { |
| 57 | 57 | const listed = listCapacity(f.app, f.user); |
| 58 | 58 | 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 | }); | |
| 63 | 67 | // After the guards, so the cursor (bounded by the settings) never lands on it. |
| 64 | 68 | return [...guards, selfSwapRow(listed.value.selfSwap)]; |
| 65 | 69 | }; |
src/web/pages/capacity.ts +10 −6
| @@ -7,7 +7,7 @@ | ||
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | 9 | 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"; | |
| 11 | 11 | import { bytes } from "../../system/format.ts"; |
| 12 | 12 | import { egressSnapshot } from "../../apps/egress.ts"; |
| 13 | 13 | import type { Ctx } from "../../http/context.ts"; |
| @@ -85,10 +85,14 @@ export function capacityPage(ctx: Ctx): Response { | ||
| 85 | 85 | ); |
| 86 | 86 | const now = Date.now(); |
| 87 | 87 | 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({ | |
| 90 | 91 | 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 | )}`, | |
| 92 | 96 | meta: html`${s.help} <br />Now <span class="mono">${s.value === "" ? "auto" : s.value}</span>. ${s.source === "setting" |
| 93 | 97 | ? html`Set here; the boot default is |
| 94 | 98 | <span class="mono">${s.default === "" ? "auto" : s.default}</span> |
| @@ -118,8 +122,8 @@ export function capacityPage(ctx: Ctx): Response { | ||
| 118 | 122 | })}`, |
| 119 | 123 | )} |
| 120 | 124 | </form>`, |
| 121 | }), | |
| 122 | ); | |
| 125 | }); | |
| 126 | }); | |
| 123 | 127 | const history = changes.map((c) => |
| 124 | 128 | row({ |
| 125 | 129 | lead: icon("history"), |
src/web/pages/spend-tab.ts +2 −1
| @@ -10,6 +10,7 @@ import type { Ctx } from "../../http/context.ts"; | ||
| 10 | 10 | import { notFoundPage, signInFirst } from "../chrome.ts"; |
| 11 | 11 | import { html } from "../html.ts"; |
| 12 | 12 | import { card, icon } from "../ui.ts"; |
| 13 | import { weekLine } from "../studio/week_line.ts"; | |
| 13 | 14 | import { spendView } from "./spend.ts"; |
| 14 | 15 | import { usageShell } from "./usage-tabs.ts"; |
| 15 | 16 | |
| @@ -20,7 +21,7 @@ export function spendPage(ctx: Ctx): Response { | ||
| 20 | 21 | notePhase(ctx.request, "session", takeSessionTime(ctx.request)); |
| 21 | 22 | const spend = timePhase(ctx.request, "spend", () => readSpend(ctx.app, new Date())); |
| 22 | 23 | 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( | |
| 24 | 25 | spendView(spend), |
| 25 | 26 | { head: html`${icon("clock")}<h2>Spend</h2>` }, |
| 26 | 27 | )}</div>`; |
test/capacity.test.ts +37
| @@ -419,6 +419,43 @@ describe("report mode is loud to admins, and to nobody else", () => { | ||
| 419 | 419 | const needs = (who: User | undefined): string[] => |
| 420 | 420 | "needs" in readHealth(app, who) ? (readHealth(app, who) as { needs: string[] }).needs : []; |
| 421 | 421 | |
| 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 | ||
| 422 | 459 | test("reported: the Capacity page says it once, as a note; vitals do not", async () => { |
| 423 | 460 | expect(changeCapacity(app, alice, "cells.egress", "report").ok).toBe(true); |
| 424 | 461 | expect(needs(alice)).toEqual([]); |
test/tui-capacity.test.ts +19 −1
| @@ -11,7 +11,7 @@ import { createAgent } from "../src/actions/crew.ts"; | ||
| 11 | 11 | import { createApp, type App } from "../src/app.ts"; |
| 12 | 12 | import { listEvents } from "../src/core/events.ts"; |
| 13 | 13 | 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"; | |
| 15 | 15 | import { createUser, type User } from "../src/core/users.ts"; |
| 16 | 16 | import { createForgeApp, forgeCommands } from "../src/tui/forge.ts"; |
| 17 | 17 | import { capacityScreen } from "../src/tui/screens/capacity.ts"; |
| @@ -86,6 +86,24 @@ describe("tui capacity", () => { | ||
| 86 | 86 | h.stop(); |
| 87 | 87 | }); |
| 88 | 88 | |
| 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 | ||
| 89 | 107 | test("changes crew concurrency after the plan and a confirm", async () => { |
| 90 | 108 | const h = boot(carol); |
| 91 | 109 | await h.press("e"); |
test/week-wiring.test.ts +39 −1
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | /** 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"; | |
| 3 | 3 | import { mkdtemp, rm } from "node:fs/promises"; |
| 4 | 4 | import { tmpdir } from "node:os"; |
| 5 | 5 | import { join } from "node:path"; |
| @@ -18,12 +18,15 @@ import { addSubscription } from "../src/core/subscriptions.ts"; | ||
| 18 | 18 | import { recordUsage } from "../src/core/usage.ts"; |
| 19 | 19 | import { type User } from "../src/core/users.ts"; |
| 20 | 20 | import { driveRepo, repoPace } from "../src/crew/autonomy.ts"; |
| 21 | import { changeCapacity } from "../src/actions/capacity.ts"; | |
| 22 | import { capacityDefault } from "../src/core/capacity.ts"; | |
| 21 | 23 | import { forgetWeek, weekWords } from "../src/crew/week_read.ts"; |
| 22 | 24 | import { createForgeApp } from "../src/tui/forge.ts"; |
| 23 | 25 | import { headlessTerminal } from "../src/tui/term/terminal.ts"; |
| 24 | 26 | import { weekLine } from "../src/web/studio/week_line.ts"; |
| 25 | 27 | import { seedAdmin } from "./helpers/admin.ts"; |
| 26 | 28 | import { quietLogs } from "./helpers/globals.ts"; |
| 29 | import { serve } from "../src/http/server.ts"; | |
| 27 | 30 | import { settleApp } from "./helpers/tui.ts"; |
| 28 | 31 | |
| 29 | 32 | quietLogs(); |
| @@ -114,6 +117,10 @@ afterAll(async () => { | ||
| 114 | 117 | }); |
| 115 | 118 | |
| 116 | 119 | 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 | }); | |
| 117 | 124 | |
| 118 | 125 | test("behind pace, with an idle slot and nothing startable, the tick releases the gated issue and says why", async () => { |
| 119 | 126 | week(20); |
| @@ -166,6 +173,37 @@ test("home and the terminal print the same sentence, the terminal for an admin", | ||
| 166 | 173 | expect(text).toContain("Behind pace"); |
| 167 | 174 | }); |
| 168 | 175 | |
| 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 | ||
| 169 | 207 | test("a fast afternoon slows the crew at once, on a week whose total is on pace", () => { |
| 170 | 208 | week(50); |
| 171 | 209 | const calm = repoPace(app, repo, NOW).slots; |
new above
offline
The week line on Spend is untested. Add a test that fetches the Spend tab and expects the below-ceiling sentence.