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

Knobs: the small share and polish cap are gone, and pace floor, reserve and queue depth follow Speed #10670

Merged opened this

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

Merged

scout/forge:scout/10622-knobs-the-small-share-and-polish-cap-are → main 4 commits fork 1 thread · 1 unresolved

  • Merged. scout/10622-knobs-the-small-share-and-polish-cap-are was merged into main as d8c968e.

Review

Sign in to review this change.
41 files changed +219 −544
docs/CREW.md +10 −10
@@ -216,7 +216,8 @@ while the suspect is judged. A pull in two red batches is never in a third:
216216 it waits for a gate of its own. Red alone, it is handed back (`[queue] red
217217 alone`); green alone, it rides at the tail of a later batch, never its head.
218218 While a candidate gates, the next one gates on top of it (`queue.depth`
219 under Settings → Capacity, `FORGE_QUEUE_DEPTH`, default 2): when the first
219 derived from Speed: half of it rounded up, 1 to 4, shown read-only under Settings → Capacity
220 and on the knobs panel): when the first
220221 lands whole the next lands right after it, on the tree it was gated on; when
221222 it does not, the next is formed again on main. A gate beyond a line's first
222223 starts only on a free check slot and holds it, and two slots are always kept
@@ -296,13 +297,12 @@ bar, and the terminal's Flow command prints it as a line per app
296297 (`flow/held.ts`, kept 5 s). Of the startable, the picker takes the order Next up shows
297298 (#8826, `pickOrder` in `core/next_up.ts`): the owner's ranked issues by place,
298299 then the ready order, what the owner raised again first. Small issues — a title like "A test pins …" or
299 "Tests: …", or a label `test`, `tests`, `tests-only` or `small` (#7571, `crew/small_share.ts`) — hold
300 at most `slots.smallShare` percent of the builder slots (default 30, rounded up:
301 2 of 5; an admin sets it on `/settings/capacity`). At the cap a small issue is
302 skipped, not waited on, unless the owner ranked it. The tick line's `small: 1/2`, the
303 Flow command's `small 1/2`, the app's row in Today on `/boards` (`small
304 issues: 1 of 2 slots`) and the studio's crew panel show small issues in
305 flight of what they may hold. An issue labelled `polish` is a small issue too, and its run is stopped past `polish.capMinutes` (default 60) with one comment saying what is left (`crew/polish_cap.ts`, `docs/features/polish-cap.md`). When nothing is startable while builder slots are free and some of the
300 "Tests: …", or a label `test`, `tests`, `tests-only`, `small` or `polish`
301 (`crew/polish_share.ts`) — hold no slots of
302 their own: Speed and that order bound them like any work (the small share and the polish run
303 cap are gone; a stored old value is ignored). Polish is held to its share of the week
304 (`docs/features/polish-share.md`). When nothing is startable while builder slots are free
305 and some of the
306306 held are stalled, capped or parked twice, the planner is owed one pass with
307307 that breakdown in its brief, to re-plan them into smaller, fresh issues; a
308308 full plate and its own unanswered decline do not hold that pass, and the
@@ -500,8 +500,8 @@ each subscription's `week 38%, 5h 80%` line, and the forge usage block
500500 The week's pace (`src/crew/week.ts`) limits only the crew's own picks: the
501501 builds the tick assigns under the operator's name (marked `pick` on the
502502 `issue.assigned` event). Reviews and issues a person assigned are not slowed
503 by it: while the pace allows any slots at all, up to `pace.floor` of them (a
504 capacity setting, env `FORGE_PACE_FLOOR`, default 2, 1 to 16, read on every
503 by it: while the pace allows any slots at all, up to `pace.floor` of them (derived from Speed:
504 half of it, at least 1, read-only on the capacity page and the knobs panel, read on every
505505 pump) run at once, apart from the picks; it is also the fewest slots a fast burn
506506 slows a repository to. At the reserve's wall (no slots) everything waits for the
507507 reset. Not covered: feedback, check and conflict runs still count with the picks.
docs/features/bets.md +1 −1
@@ -6,7 +6,7 @@ A bet is a name, an appetite (a share of the week), what done looks like in a fe
66 - **Propose.** "Ask the planner to propose bets" groups open issues in no live bet: by first label; else by the pillar an unlabelled issue holds up (later and structural issues are skipped); else by the title word most unlabelled issues share. A group of two or more becomes a proposal at 20% of the week. At most five proposals a pass; no model, so the same state gives the same proposals.
77 - **Notes.** Only notes written by the repo's owner or an admin count (a visitor's never join or appear). Such a note joins a proposal when its issue is held there, it names one of the group's issues as `#N`, or it shares two words with the group's name and its issues' titles; it is quoted in the journeys as "The owner's note".
88 - **Back.** The owner (a person who administers the repo, never an agent) backs one with a share. At most 5 run, and their shares stay within 100%.
9 - **Picks.** Once a repository has ever had a bet backed, builders pick only scopes of a running bet, issues the owner asked for or raised, and small fixes, polish included (held to `slots.smallShare`, #7571; a polish run is capped, see polish-cap.md). An issue in no bet does not start by itself. A repository with no bet picks as before.
9 - **Picks.** Once a repository has ever had a bet backed, builders pick only scopes of a running bet, issues the owner asked for or raised, and small fixes, polish included (polish held to its share of the week, see polish-share.md). An issue in no bet does not start by itself. A repository with no bet picks as before.
1010 - **Appetite.** The budget is share × builder slots × one week of builder time, counted from the runs on the bet's scopes since it was backed. At the budget the bet stops: no extension. Its left line defaults to the open scopes; the lead rewrites it in one line; the owner re-backs, cuts or ends it (ending as done is for a stopped bet only; a running bet finishes when every scope closes).
1111 - **Scopes** read *figuring out* (no run yet), *executing*, or *raised hand* (nothing moved for two days), or *done*.
1212 - **Finishing.** When every scope is closed the bet is finished and its page says "This is done. Try it." with a link; the owner taps *Feels done* or *Not yet, because…* (the bet returns to stopped with that reason). `/changelog` marks each landing with its bet and filters by it (`?bet=`).
docs/features/knobs.md +7 −1
@@ -12,6 +12,12 @@ A change applies on the next pump and leaves the event the setting's own page le
1212
1313 ## Derived from Speed
1414
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.
15 `pace.floor` (half of Speed, at least 1) and `queue.depth` (half of Speed rounded up, 1 to 4)
16 are computed from Speed every time they are read. They show with their value, read-only, on
17 the knobs panel and under Settings › Capacity; a POST that tries to set one is refused with
18 a plain line, and `FORGE_PACE_FLOOR`, `FORGE_QUEUE_DEPTH` and any stored value are ignored.
19 The reserve is not one of them: it is what Weekly budget leaves (100 minus `week.use`), so it
20 moves with that dial, not with Speed. The small share and the polish run cap are gone; a
21 stored old value is ignored.
1622
1723 `week.use` is new; a stored or `FORGE_WEEK_USE` value is read as the budget.
docs/features/polish-cap.md deleted −10
@@ -1,10 +0,0 @@
1 # Polish cap
2
3 An issue labelled `polish` is small work, and it is capped small (#8852). A builder run on it that has gone `polish.capMinutes` of run time (default 60) is stopped, not extended. The crew's tick checks every minute, from the run's own start time.
4
5 - **Set it.** An admin changes **Run time a polish issue may take (minutes)** on `/settings/capacity`. It is read on every tick.
6 - **What you see.** The run ends cancelled with the summary "stopped at the N-minute polish cap", and the issue gets one comment: `Stopped at the 60-minute polish cap, not extended. Left: pull #M is open, unmerged.` or `Left: nothing landed, no pull open.` It is the run's open pull that says what is left; a polish run never produces a second comment.
7 - **Who says it.** The person who turned autonomy on, else the user the run acted as. If neither exists the run is left running rather than stopped silently.
8 - **Other work.** Only the label `polish` (any case) is capped; polish also counts as a small fix against `slots.smallShare`.
9
10 Left out: stopping a polish issue before its run starts, and reading the run's last summary into the line (a running run has none yet).
docs/features/polish-share.md +1 −1
@@ -1,6 +1,6 @@
11 # The polish share
22
3 The percent of the last seven days' agent spend that polish may take: issues labelled `polish`, or in the small-fixes lane (`isSmall`, `src/crew/small_share.ts`). 10 to start; a whole number 0 to 50. At 0 no polish starts.
3 The percent of the last seven days' agent spend that polish may take: issues labelled `polish`, or in the small-fixes lane (`isSmall`, `src/crew/polish_share.ts`). 10 to start; a whole number 0 to 50. At 0 no polish starts.
44
55 - **Set it.** Web: Settings → Branch and gates → Polish share. Terminal: Autonomy, key `o`. Both go through `updateRepoSettings`, refuse anything but 0–50, and are on the settings trail, so an open week page or screen updates.
66 - **See it.** `/:owner/:name/week` ("Polish this week"), the terminal's week screen (last line) and the `week` MCP tool (`polish: { share, percent, polishUsd, totalUsd, under, says }`) print the same `polishWords`.
src/actions/capacity.ts +6 −2
@@ -37,6 +37,9 @@ import { signinReach, signsInWithoutEdge } from "../core/accounts.ts";
3737 import type { User } from "../core/users.ts";
3838 import { err, ok, type Result } from "../kernel/result.ts";
3939
40 /** What a refused change to a dial that follows Speed says, on every door. */
41 export const DERIVED_WORDS = "The pace floor and the queue depth follow Speed: change Speed (crew.concurrency) and they move with it.";
42
4043 /** The six guards a caller may set by `key`: the MCP tool's enum and the terminal's `e`. */
4144 export const SETTABLE_CAPACITY = [
4245 "crew.concurrency",
@@ -122,7 +125,7 @@ export function listCapacity(
122125 label: CAPACITY[key].label,
123126 help: CAPACITY[key].help,
124127 value: effectiveCapacity(app.db, key),
125 source: followsSpeed(app.db, key) ? "derived" : storedCapacity(app.db, key) === undefined ? "default" : "setting",
128 source: followsSpeed(key) ? "derived" : storedCapacity(app.db, key) === undefined ? "default" : "setting",
126129 default: bootDefault(app.db, key),
127130 env: CAPACITY[key].env,
128131 ...changedOf(app.db, key),
@@ -166,11 +169,12 @@ export function changeCapacity(
166169 value: string | null,
167170 ): Result<
168171 { key: CapacityKey; from: string; to: string },
169 "not_found" | "no_such_setting" | "invalid_value" | "no_credential"
172 "not_found" | "no_such_setting" | "invalid_value" | "no_credential" | "derived"
170173 > {
171174 const who = admin(actor);
172175 if (!who.ok) return who;
173176 if (!isCapacityKey(key)) return err("no_such_setting");
177 if (followsSpeed(key)) return err("derived");
174178 const next = value === null ? null : value.trim();
175179 if (next !== null && next !== "" && !validCapacity(key, next))
176180 return err("invalid_value");
src/actions/knobs.ts +4 −3
@@ -9,12 +9,12 @@ import type { App } from "../app.ts";
99 import { changeCapacity } from "./capacity.ts";
1010 import { updateRepoSettings } from "./repos.ts";
1111 import { getRepo } from "../core/repos.ts";
12 import { AUTONOMY_WORDS, knobRepos, type AutonomyWord, type KnobId } from "../crew/knobs.ts";
13 import { validPolishShare } from "../crew/small_share.ts";
12 import { AUTONOMY_WORDS, DERIVED_KNOBS, knobRepos, type AutonomyWord, type KnobId } from "../crew/knobs.ts";
13 import { validPolishShare } from "../crew/polish_share.ts";
1414 import { err, ok, type Result } from "../kernel/result.ts";
1515 import type { User } from "../core/users.ts";
1616
17 export type KnobError = "not_found" | "no_such_knob" | "invalid_value";
17 export type KnobError = "not_found" | "no_such_knob" | "invalid_value" | "derived";
1818
1919 const WORDS: Record<AutonomyWord, { autonomy: "off" | "autonomous"; plansNeedNod: boolean; decisionsBy: "me" | "decider" }> = {
2020 off: { autonomy: "off", plansNeedNod: true, decisionsBy: "me" },
@@ -31,6 +31,7 @@ export async function setKnob(
3131 ): Promise<Result<{ knob: KnobId; value: string }, KnobError>> {
3232 if (actor?.isAdmin !== true || actor.kind === "agent") return err("not_found");
3333 const v = value.trim();
34 if (DERIVED_KNOBS.includes(knob)) return err("derived");
3435 if (knob === "speed" || knob === "budget") {
3536 const changed = changeCapacity(app, actor, knob === "speed" ? "crew.concurrency" : "week.use", v);
3637 if (!changed.ok) return err(changed.error === "not_found" ? "not_found" : "invalid_value");
src/actions/repos.ts +1 −1
@@ -74,7 +74,7 @@ import {
7474 requireWriter,
7575 type AccessError,
7676 } from "./access.ts";
77 import { validPolishShare } from "../crew/small_share.ts";
77 import { validPolishShare } from "../crew/polish_share.ts";
7878 import { removeInstallCache } from "../crew/workdir.ts";
7979 import type { SettingsChanged } from "../core/settings_changed.ts";
8080
src/contract/openapi/routes/putApiV1AdminCapacityKey.ts +1
@@ -10,6 +10,7 @@ export const route: Route = {
1010 refusals: {
1111 not_found: "the token is not a person who administers the forge (404)",
1212 no_such_setting: "{key} is not a capacity setting (422)",
13 derived: "{key} is pace.floor or queue.depth, which follow Speed and are not set directly (422)",
1314 invalid_value: "the value is outside what this setting takes (422)",
1415 no_credential: "the setting needs a credential the forge does not hold (422)",
1516 },
src/core/capacity.ts +7 −33
@@ -28,8 +28,6 @@ export type CapacityKey =
2828 | "children.registryEgress"
2929 | "guard_new_accounts"
3030 | "signin.edge"
31 | "slots.smallShare"
32 | "polish.capMinutes"
3331 | "pace.floor";
3432
3533 interface Definition {
@@ -77,7 +75,7 @@ export const CAPACITY: Record<CapacityKey, Definition> = {
7775 env: "FORGE_QUEUE_DEPTH",
7876 kind: { type: "count", min: 1, max: 4 },
7977 fallback: "2",
80 help: "How many merge-queue candidates of one repository gate at once, each on top of the one before: while one gates on main the next gates on it, and lands right after it if both are green. 1 gates one candidate at a time. A gate beyond the first takes a free check slot only, never one of the two kept for pull checks.",
78 help: "How many merge-queue candidates of one repository gate at once, each on top of the one before: while one gates on main the next gates on it, and lands right after it if both are green. Derived from Speed (half of it rounded up, 1 to 4), read-only; FORGE_QUEUE_DEPTH is ignored. A gate beyond the first takes a free check slot only, never one of the two kept for pull checks.",
8179 },
8280 "queue.batch": {
8381 label: "Merge queue batch size",
@@ -135,26 +133,12 @@ export const CAPACITY: Record<CapacityKey, Definition> = {
135133 fallback: "on",
136134 help: "on reads the platform edge's identity headers (when the forge has an edge secret) and offers its sign-in; off ignores them on every door, so people sign in with the forge's own methods — password and passkey. Accounts the edge made keep their names but have no password until they set one. Takes effect on the next request.",
137135 },
138 "slots.smallShare": {
139 label: "Builder slots small issues may hold (%)",
140 env: "FORGE_SLOTS_SMALL_SHARE",
141 kind: { type: "count", min: 0, max: 100 },
142 fallback: "30",
143 help: "The most of a repository's builder slots that tests-only and small-addition issues (\"A test pins …\", or a label test, tests, tests-only or small) hold at once, as a percent rounded up: 30 lets 2 of 5 slots go to them. At the cap a small issue is skipped and the slot goes to the next issue in the Next up order; a ranked small issue is still taken. 100 lifts the cap; 0 starts none. Read on every pick.",
144 },
145 "polish.capMinutes": {
146 label: "Run time a polish issue may take (minutes)",
147 env: "FORGE_POLISH_CAP_MINUTES",
148 kind: { type: "count", min: 1, max: 1440 },
149 fallback: "60",
150 help: "An issue labelled polish is small work: a builder run on it that has gone this many minutes is stopped, not extended, and the issue gets one comment saying what is left. Read on every tick.",
151 },
152136 "pace.floor": {
153137 label: "Slots the pace always leaves reviews and assigned work",
154138 env: "FORGE_PACE_FLOOR",
155139 kind: { type: "count", min: 1, max: 16 },
156140 fallback: "2",
157 help: "While the week's pace allows any slots, this many reviews and issues a person assigned may run at once, apart from the crew's own picks, however hard the pace slows them; also the fewest slots a fast burn slows a repository to. At the reserve's wall everything waits. Read on every pump.",
141 help: "While the week's pace allows any slots, this many reviews and issues a person assigned may run at once, apart from the crew's own picks, however hard the pace slows them; also the fewest slots a fast burn slows a repository to. At the reserve's wall everything waits. Derived from Speed (half of it, at least 1), read-only; FORGE_PACE_FLOOR is ignored; read on every pump.",
158142 },
159143 };
160144
@@ -378,11 +362,11 @@ export function paceFloor(db: Database): number {
378362 return Number(effectiveCapacity(db, "pace.floor"));
379363 }
380364
381 /** The dials that follow Speed (`crew.concurrency`) until set on their own. */
365 /** The dials Speed (`crew.concurrency`) sets: always derived, never stored. */
382366 export type SpeedDerivedKey = "pace.floor" | "queue.depth";
383367
384 export function followsSpeed(db: Database, key: CapacityKey): key is SpeedDerivedKey {
385 return (key === "pace.floor" || key === "queue.depth") && storedCapacity(db, key) === undefined;
368 export function followsSpeed(key: CapacityKey): key is SpeedDerivedKey {
outdated

Unused _db parameter and a function that is now a constant check. Drop _db, and fix the comments above it ("until set on their own").

369 return key === "pace.floor" || key === "queue.depth";
386370 }
387371
388372 /** 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. */
@@ -392,9 +376,9 @@ export function derivedFromSpeed(db: Database, key: SpeedDerivedKey): number {
392376 return Math.min(4, Math.max(1, Math.ceil(speed / 2)));
393377 }
394378
395 /** A capacity's value as the crew reads it: derived from Speed while a following dial is unset. */
379 /** A capacity's value as the crew reads it: derived from Speed for the dials that follow it. */
396380 export function effectiveCapacity(db: Database, key: CapacityKey): string {
397 return followsSpeed(db, key) ? String(derivedFromSpeed(db, key)) : capacityValue(db, key);
381 return followsSpeed(key) ? String(derivedFromSpeed(db, key)) : capacityValue(db, key);
398382 }
399383
400384 /** The share of the week the crew may use, in percent (`week.use`); the rest is the reserve. */
@@ -456,16 +440,6 @@ export function queueBatch(db: Database): number {
456440 return Number(capacityValue(db, "queue.batch"));
457441 }
458442
459 /** The percent of builder slots small issues may hold (#7571). */
460 export function smallShare(db: Database): number {
461 return Number(capacityValue(db, "slots.smallShare"));
462 }
463
464 /** The minutes of run time a polish issue may take before its run is stopped (#8852). */
465 export function polishCapMinutes(db: Database): number {
466 return Number(capacityValue(db, "polish.capMinutes"));
467 }
468
469443 /** Whether the platform edge's identity is read at all; on every request. */
470444 export function edgeSigninOn(db: Database): boolean {
471445 return capacityValue(db, "signin.edge") !== "off";
src/crew/autonomy.ts +5 −37
@@ -31,10 +31,8 @@ import { queueStore } from "../checks/queue_store.ts";
3131 import { addLearned } from "../checks/learned.ts";
3232 import { batchWords, dropFromLine, enqueue, type GateCandidate, type Queued, type QueueOptions, queueClashes, queueWords, waitingPulls } from "../checks/queue.ts";
3333 import { checksFor } from "../checks/service.ts";
34 import { queueBatch, queueDepth, smallShare } from "../core/capacity.ts";
35 import { shareWords, slotOrder, smallCap, smallIssues, smallOfIds } from "./small_share.ts";
34 import { queueBatch, queueDepth } from "../core/capacity.ts";
3635 import { crewFor as crewForIssue, passedWords, pickFilter, routable } from "./pick_filter.ts";
37 import { tickPolish } from "./polish_cap.ts";
3836 import { readConflictLane, readConflictRoom } from "./conflict_room.ts";
3937 import { mergeable, resolveOid, treeOf } from "../git/merge.ts";
4038 import { CLOSED_NOTE, leftNote, reopenedByPerson, unticked } from "../core/done_on_main.ts";
@@ -48,7 +46,6 @@ import {
4846 crewRole,
4947 } from "../core/agents.ts";
5048 import { pickBuilder, routeIssue } from "./route.ts";
51 import { firstAt } from "../core/first.ts";
5249 import { fromAppIssues } from "../core/from_app.ts";
5350 import { pickOrder } from "../core/next_up.ts";
5451 import { ranksInRepo } from "../core/rank.ts";
@@ -434,38 +431,12 @@ function freeSlots(app: App, repo: Repo, now: Date): number {
434431 return Math.max(0, repoPace(app, repo, now, false).slots - slotted.length);
435432 }
436433
437 /** The pickable issues in slot order: the order Next up shows, small work held to its share (#7571). */
438 export function inSlotOrder(app: App, repo: Repo, ready: readonly Ranked[], slotted: readonly Run[], slots: number, picked: ReadonlySet<string> = new Set(), now: Date = new Date()): Ranked[] {
439 const issues = ready.map((r) => r.issue);
440 const small = smallIssues(app.db, issues);
441 const first = firstAt(app.db, issues.map((i) => i.id));
434 /** The pickable issues in slot order: the order Next up shows. */
435 export function inSlotOrder(app: App, repo: Repo, ready: readonly Ranked[], now: Date = new Date()): Ranked[] {
442436 const ranked = ranksInRepo(app.db, repo.id);
443 // Issues picked earlier in this tick hold their share before their runs show (#8729).
444 const running = smallOfIds(app.db, [...new Set([...slotted.flatMap((r) => (r.issueId === null ? [] : [r.issueId])), ...picked])]);
445437 const { allowed } = pickFilter(app, repo, ready, now);
446438 // The order is the one Next up shows (#8826): ranked by place, then the ready order.
447 return slotOrder(
448 pickOrder(allowed, ranked, (r) => r.issue.id, [], fromAppIssues(app.db, allowed.map((r) => r.issue.id))),
449 (r) => ({
450 ranked: ranked.has(r.issue.id),
451 small: small.has(r.issue.id) && !first.has(r.issue.id),
452 }),
453 running,
454 smallCap(slots, smallShare(app.db)),
455 );
456 }
457
458 /** Slotted runs on small issues, and the slots they may hold (#7571). */
459 export function smallShareOf(app: App, repo: Repo, now = new Date()): { running: number; cap: number } {
460 const slotted = slottedRuns(activeRuns(app, repo.id), listCrewRoles(app.db, repo.id));
461 const running = smallOfIds(app.db, slotted.flatMap((r) => (r.issueId === null ? [] : [r.issueId])));
462 return { running, cap: smallCap(repoPace(app, repo, now).slots, smallShare(app.db)) };
463 }
464
465 /** `1/2`: {@link smallShareOf} as the tick line, the terminal and the studio print it. */
466 export function smallShareWords(app: App, repo: Repo, now = new Date()): string {
467 const { running, cap } = smallShareOf(app, repo, now);
468 return shareWords(running, cap);
439 return pickOrder(allowed, ranked, (r) => r.issue.id, [], fromAppIssues(app.db, allowed.map((r) => r.issue.id)));
469440 }
470441
471442 export type Drove = "off" | "paused" | "waiting" | "full" | "picked" | "planned" | "released" | "idle";
@@ -506,7 +477,6 @@ async function driveOnce(
506477 ): Promise<Drove> {
507478 if (repo.autonomy === "off") return "off";
508479 tickBets(app, repo, now);
509 tickPolish(app, repo, now);
510480 const status = autonomyStatus(app, repo, now);
511481 if (status.paused !== null) return "paused";
512482 const by = getUserById(app.db, repo.autonomyBy!);
@@ -565,7 +535,7 @@ async function driveOnce(
565535 const crewFor = (issue: Issue) => crewForIssue(app, repo, issue.id, crew, now);
566536 const pickable = await withoutRetired(app, repo, routable(app, repo, ready, crew, now).kept);
567537 // The roadmap first, and small issues held to their share of the slots (#7571).
568 const next = await nextOf(app, repo, inSlotOrder(app, repo, pickable, slotted, paced.slots, taken, now));
538 const next = await nextOf(app, repo, inSlotOrder(app, repo, pickable, now));
569539 if (next !== undefined) {
570540 taken.add(next.issue.id);
571541 const routed = await routeIssue(app, repo, next.issue, crewFor(next.issue), inFlight);
@@ -1432,8 +1402,6 @@ export async function autonomyTick(
14321402 slots: status.slots,
14331403 // Builders with nothing in flight: what bounds active beside slots and startable (#7006).
14341404 idle: idleBuilders(activeNow, crewNow),
1435 // Small issues in flight of the slots they may hold (#7571).
1436 small: smallShareWords(app, current, now),
14371405 ...(governor === undefined ? {} : { governor }),
14381406 // Approved conflicted pulls: queued, in flight, and why each other is
14391407 // skipped (#3682, #6237), or "0 candidates of N open" (#6263); and the lane for rebases: conflicted N,
src/crew/knobs.ts +28 −14
@@ -1,23 +1,26 @@
11 /**
2 * The five knobs (#10461): Speed, Weekly budget, Polish share, Autonomy per
2 * The knobs (#10461): Speed, Weekly budget, Polish share, Autonomy per
33 * repository, Feedback first. Plain data out: one row each with its name, a
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;
6 * `actions/knobs.ts` is the one place that turns them. The pipeline's own
7 * dials (small share, polish cap) stay on the
8 * capacity page.
6 * `actions/knobs.ts` is the one place that turns them. Pace floor and queue
7 * depth ride along as derived rows: Speed sets them, nothing else does. The
8 * reserve is not one of them: it is what Weekly budget leaves (100 minus it),
9 * so it moves with that dial and not with Speed.
910 */
1011
1112 import type { App } from "../app.ts";
12 import { capacityValue, followsSpeed, paceFloor, queueDepth } from "../core/capacity.ts";
13 import { capacityValue, paceFloor, queueDepth } from "../core/capacity.ts";
1314 import { labelsForIssues } from "../core/issues.ts";
1415 import { getRepoById, type Repo } from "../core/repos.ts";
1516 import { listActive } from "../core/runs.ts";
1617 import { waitingOnPeople } from "./people_first.ts";
17 import { POLISH_SHARE_DEFAULT, isPolish, polishSpend } from "./small_share.ts";
18 import { POLISH_SHARE_DEFAULT, isPolish, polishSpend } from "./polish_share.ts";
1819 import { budgetEffect } from "./week_read.ts";
1920
20 export type KnobId = "speed" | "budget" | "polish" | "autonomy" | "feedback";
21 export type KnobId = "speed" | "budget" | "polish" | "autonomy" | "feedback" | "floor" | "depth";
22 /** The dials Speed sets: shown on the panel with their value, never turned there. */
23 export const DERIVED_KNOBS: readonly string[] = ["floor", "depth", "pace.floor", "queue.depth"];
2124 export type AutonomyWord = "off" | "ask" | "go";
2225 export const AUTONOMY_WORDS: readonly AutonomyWord[] = ["off", "ask", "go"];
2326
@@ -27,7 +30,7 @@ export interface KnobRow {
2730 readonly does: string;
2831 /** The value as typed back: a number, a percent's number, or one of the words. */
2932 readonly value: string;
30 readonly control: "number" | "percent" | "words" | "fixed";
33 readonly control: "number" | "percent" | "words" | "fixed" | "derived";
3134 readonly min?: number;
3235 readonly max?: number;
3336 readonly words?: readonly string[];
@@ -43,11 +46,6 @@ export function autonomyWord(repo: Repo): AutonomyWord {
4346 return !repo.plansNeedNod && repo.decisionsBy !== "me" ? "go" : "ask";
4447 }
4548
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
5149 function runsLine(app: App): string {
5250 const active = listActive(app.db, "agent");
5351 const running = active.filter((r) => r.state === "running").length;
@@ -91,7 +89,23 @@ export function readKnobs(app: App, now: Date = new Date()): KnobRow[] {
9189 control: "number",
9290 min: 1,
9391 max: 16,
94 effect: `${runsLine(app)} · pace floor ${paceFloor(app.db)}${from(app, "pace.floor")} · queue depth ${queueDepth(app.db)}${from(app, "queue.depth")}`,
92 effect: runsLine(app),
93 },
94 {
95 id: "floor",
96 name: "Pace floor",
97 does: "The slots the pace always leaves reviews and assigned work. Derived from Speed: half of it, at least 1.",
98 value: String(paceFloor(app.db)),
99 control: "derived",
100 effect: `${paceFloor(app.db)} slots`,
101 },
102 {
103 id: "depth",
104 name: "Queue depth",
105 does: "How many merge-queue candidates of one repository gate at once. Derived from Speed: half of it rounded up, 1 to 4.",
106 value: String(queueDepth(app.db)),
107 control: "derived",
108 effect: `${queueDepth(app.db)} candidates`,
95109 },
96110 {
97111 id: "budget",
src/crew/pick_filter.ts +1 −1
@@ -17,7 +17,7 @@ import type { Repo } from "../core/repos.ts";
1717 import { raisedAt } from "../core/thought_links.ts";
1818 import type { Agent, CrewRole } from "../core/agents.ts";
1919 import { mayPick } from "./bets.ts";
20 import { isPolish, polishSpend, smallIssues } from "./small_share.ts";
20 import { isPolish, polishSpend, smallIssues } from "./polish_share.ts";
2121 import { shunnedBy } from "./stalls.ts";
2222
2323 /** Why the picker passes a startable issue over. */
src/crew/polish_cap.ts deleted −68
@@ -1,68 +0,0 @@
1 /**
2 * A polish issue is capped small (#8852): a builder run on an issue labelled
3 * `polish` that has gone past `polish.capMinutes` of run time is stopped, not
4 * extended, and the issue gets one comment with what is left in a line, as a
5 * bet past its appetite does (#8713). Time is the run's own: `startedAt` to
6 * now, so a fake clock decides it.
7 */
8
9 import type { App } from "../app.ts";
10 import { addComment } from "../actions/issues.ts";
11 import { announceRun } from "../actions/runs.ts";
12 import { polishCapMinutes } from "../core/capacity.ts";
13 import { getIssueById, labelsForIssues } from "../core/issues.ts";
14 import { openPullFromHead } from "../core/pulls.ts";
15 import { cancelRun, listActive } from "../core/runs.ts";
16 import { getUserById } from "../core/users.ts";
17 import type { Repo } from "../core/repos.ts";
18 import { abortAgentRun } from "./running.ts";
19
20 /** Whether a run begun at `startedAt` has used `capMinutes` of run time by `now`. */
21 export function pastCap(startedAt: string | null, capMinutes: number, now: Date): boolean {
22 if (startedAt === null) return false;
23 const from = Date.parse(startedAt);
24 return Number.isFinite(from) && now.getTime() - from >= capMinutes * 60_000;
25 }
26
27 /** What the run left: its open pull, or plainly that nothing landed. */
28 export function leftOf(pullNumber: number | undefined): string {
29 return pullNumber === undefined ? "nothing landed, no pull open" : `pull #${pullNumber} is open, unmerged`;
30 }
31
32 /** The one line a stopped polish run leaves: the cap, and what is left. */
33 export function leftLine(capMinutes: number, left: string): string {
34 return `Stopped at the ${capMinutes}-minute polish cap, not extended. Left: ${left}.`;
35 }
36
37 /** Stops this repository's polish runs past the cap; answers how many. */
38 export function tickPolish(app: App, repo: Repo, now = new Date()): number {
39 const runs = listActive(app.db, "agent").filter(
40 (r) => r.repoId === repo.id && r.state === "running" && r.issueId !== null,
41 );
42 if (runs.length === 0) return 0;
43 const cap = polishCapMinutes(app.db);
44 const late = runs.filter((r) => pastCap(r.startedAt, cap, now));
45 if (late.length === 0) return 0;
46 const labels = labelsForIssues(app.db, late.map((r) => r.issueId!));
47 // The comment is the stop's one line, so it is never silent: the autonomy
48 // author says it, else the user the run acted as.
49 const speaker = (actorId: string | null) => {
50 const id = repo.autonomyBy ?? actorId;
51 return id === null ? undefined : getUserById(app.db, id);
52 };
53 let stopped = 0;
54 for (const run of late) {
55 if (!(labels.get(run.issueId!) ?? []).some((l) => l.name.toLowerCase() === "polish")) continue;
56 const by = speaker(run.actorId);
57 const issue = getIssueById(app.db, run.issueId!);
58 if (by === undefined || issue === undefined) continue; // no one to say it: leave the run, never stop it silently
59 const gone = cancelRun(app.db, run.id, `stopped at the ${cap}-minute polish cap`);
60 if (gone === undefined) continue;
61 abortAgentRun(run.id);
62 announceRun(app, gone);
63 stopped++;
64 const pull = run.branch === "" ? undefined : openPullFromHead(app.db, repo.id, repo.id, run.branch);
65 addComment(app, by, repo, issue, leftLine(cap, leftOf(pull?.number)));
66 }
67 return stopped;
68 }
src/crew/small_share.ts → src/crew/polish_share.ts renamed +5 −67
@@ -1,19 +1,9 @@
11 /**
2 * Builder slots serve the roadmap first (#7571). Cheap pulls — a test that
3 * pins something, a small terminal or MCP addition — are quick to start and
4 * quick to land, so left alone they fill every slot and the open-pull limit
5 * while the roadmap waits. Two rules keep them in their place:
6 *
7 * - **Pick order:** the order Next up shows, ranked rows then the ready tail
8 * (`pickOrder` in core/next_up.ts); the picker has no order of its own.
9 * - **A cap:** small issues hold at most `slots.smallShare` percent of the
10 * builder slots (rounded up, so one slot may still take one). At the cap a
11 * small issue is skipped, never waited on: the slot goes to other work or
12 * stays free.
13 *
14 * Small is read from the issue alone: a title like "A test pins …" or
15 * "Tests: …", or a label `test`, `tests`, `tests-only`, `small` or `polish`. Plain data in,
16 * plain data out; the reads are two grouped queries whatever the count.
2 * Small work and polish. An issue is small when its title or labels say so
3 * (a title like "A test pins …" or "Tests: …", or a label `test`, `tests`,
4 * `tests-only`, `small` or `polish`); polish is spent against a share of the
5 * week (`polishSpend`). Small work holds no slots of its own: Speed and the
6 * pick order bound it like any other work. Plain data in, plain data out.
177 */
188
199 import type { Database } from "bun:sqlite";
@@ -28,46 +18,6 @@ export function isSmall(title: string, labels: readonly string[]): boolean {
2818 return SMALL_TITLE.test(title.trim()) || labels.some((l) => SMALL_LABELS.has(l.toLowerCase()));
2919 }
3020
31 /** How many slots small issues may hold: `share` percent of `slots`, rounded up; 5 at 30% is 2. */
32 export function smallCap(slots: number, share: number): number {
33 if (slots <= 0 || share <= 0) return 0;
34 return Math.min(slots, Math.ceil((slots * Math.min(share, 100)) / 100));
35 }
36
37 /** `1/2`: small issues in flight of what they may hold; each door adds its own word around it. */
38 export function shareWords(running: number, cap: number): string {
39 return `${running}/${cap}`;
40 }
41
42 /** `small issues: 1 of 2 slots`, for the app's row in Today on /boards. */
43 export function smallIssuesWords(share: { readonly running: number; readonly cap: number }): string {
44 return `small issues: ${share.running} of ${share.cap} ${share.cap === 1 ? "slot" : "slots"}`;
45 }
46
47 export interface Classed {
48 /** On the owner's ranked list (#8694): never held back by the cap, small or not. */
49 readonly ranked?: boolean;
50 readonly small: boolean;
51 }
52
53 /**
54 * The order is not decided here: `ordered` arrives in the order Next up shows
55 * (#8826) and keeps it. All this does is leave small work out — raised or
56 * not, unless the owner ranked it — once `running` small issues fill `cap`.
57 */
58 export function slotOrder<T>(
59 ordered: readonly T[],
60 classOf: (t: T) => Classed,
61 running: number,
62 cap: number,
63 ): T[] {
64 const open = running < cap;
65 return ordered.filter((t) => {
66 const c = classOf(t);
67 return open || c.ranked === true || !c.small;
68 });
69 }
70
7121 /** Which of these issues are small, from their titles and labels. */
7222 export function smallIssues(
7323 db: Database,
@@ -81,18 +31,6 @@ export function smallIssues(
8131 );
8232 }
8333
84 /** Of these issue ids, the small ones: what the slotted runs are working on. */
85 export function smallOfIds(db: Database, ids: readonly string[]): number {
86 const unique = [...new Set(ids)];
87 if (unique.length === 0) return 0;
88 const rows = db
89 .query<{ id: string; title: string }, string[]>(
90 `SELECT id, title FROM issue WHERE id IN (${unique.map((_, n) => `?${n + 1}`).join(",")})`,
91 )
92 .all(...unique);
93 return smallIssues(db, rows).size;
94 }
95
9634 /** The week polish is counted over: the seven days before `now`, on the clock the caller passes. */
9735 export const POLISH_WINDOW_MS = 7 * 24 * 60 * 60 * 1000;
9836
src/flow/held.ts −4
@@ -10,7 +10,6 @@
1010
1111 import type { App } from "../app.ts";
1212 import { listAutonomousRepos, type Repo } from "../core/repos.ts";
13 import { smallShareWords } from "../crew/autonomy.ts";
1413 import { admits, heldCounts, heldNow, mapHolds, type Held, type HoldName } from "../crew/held.ts";
1514
1615 export interface HeldRow {
@@ -21,8 +20,6 @@ export interface HeldRow {
2120 readonly counts: Readonly<Record<HoldName, number>>;
2221 /** Issues a hot file holds, with the open pulls on that file, oldest first. */
2322 readonly hot: readonly { readonly number: number; readonly detail: string; readonly pulls: readonly number[] }[];
24 /** `1/2`: small issues in flight of the builder slots they may hold (#7571). */
25 readonly small: string;
2623 }
2724
2825 /** The most hot-file holds a row lists. */
@@ -53,7 +50,6 @@ export function readHeld(app: App, owner: boolean, now = Date.now()): HeldRow[]
5350 public: repo.visibility === "public",
5451 startable: held.startable.length,
5552 counts: heldCounts(held),
56 small: smallShareWords(app, repo),
5753 hot: [...holds].flatMap(([number, h]) =>
5854 h.by === "hot_file" && h.pulls !== undefined && h.pulls.length > 0
5955 ? [{ number, detail: h.detail, pulls: h.pulls }]
src/http/api/admin.ts +4 −2
@@ -13,7 +13,7 @@ import { kpiProbe } from "../../web/studio/kpi_probe.ts";
1313 import { MAX_SECONDS, sampleStacks } from "../../perf/sample.ts";
1414 import { listAccounts, removeAccount, retireAndRemoveAccount, retirementPlan, suspendAccount, unsuspendAccount, verifyAccount } from "../../actions/accounts.ts";
1515 import { ADMIN_WORDS } from "../../actions/admin_words.ts";
16 import { changeCapacity, edgeOffDryRun, listCapacity } from "../../actions/capacity.ts";
16 import { DERIVED_WORDS, changeCapacity, edgeOffDryRun, listCapacity } from "../../actions/capacity.ts";
1717 import { changeShield, lastShieldChange, shieldState } from "../../actions/shield.ts";
1818 import { inviteLink, list as listInviteLinks, mint as mintInviteLink, revoke as revokeInviteLink } from "../../actions/invites.ts";
1919 import { changeLimit, listLimits } from "../../actions/limits.ts";
@@ -196,7 +196,9 @@ export function adminRoutes(router: Router<Handler>): void {
196196 422,
197197 changed.error === "no_such_setting"
198198 ? "no such capacity setting"
199 : SETTING_WORDS.outOfRange,
199 : changed.error === "derived"
200 ? DERIVED_WORDS
201 : SETTING_WORDS.outOfRange,
200202 );
201203 });
202204
src/http/mcp/tools/capacity.ts +2 −2
@@ -1,4 +1,4 @@
1 import { SETTABLE_CAPACITY, changeCapacity, listCapacity } from "../../../actions/capacity.ts";
1 import { DERIVED_WORDS, SETTABLE_CAPACITY, changeCapacity, listCapacity } from "../../../actions/capacity.ts";
22 import { SETTING_WORDS } from "../../../actions/setting_words.ts";
33 import { scopeAllows } from "../../../core/tokens.ts";
44 import { good, bad, type Tool } from "../shared.ts";
@@ -36,7 +36,7 @@ export const tool: Tool = {
3636 if (!scopeAllows(ctx.auth.scope ?? "ro", "admin"))
3737 return bad("forbidden", "changing the forge's capacity needs an admin-scope token");
3838 const changed = changeCapacity(ctx.app, user, key, value);
39 if (!changed.ok) return bad(changed.error, SETTING_WORDS.outOfRange);
39 if (!changed.ok) return bad(changed.error, changed.error === "derived" ? DERIVED_WORDS : SETTING_WORDS.outOfRange);
4040 return good({
4141 says: `${key}: ${changed.value.from || "auto"} → ${changed.value.to || "auto"}, from the next pick on.`,
4242 ...changed.value,
src/http/mcp/tools/week.ts +1 −1
@@ -1,5 +1,5 @@
11 import { takeProposal } from "../../../actions/week.ts";
2 import { polishSpend, polishWords } from "../../../crew/small_share.ts";
2 import { polishSpend, polishWords } from "../../../crew/polish_share.ts";
33 import { latestRetro, takenOf } from "../../../flow/retro.ts";
44 import { bad, good, type Tool, APP_ARG, planRepo, isAnswer } from "../shared.ts";
55
src/tui/screens/autonomy.ts +1 −1
@@ -15,7 +15,7 @@
1515 */
1616
1717 import { updateRepoSettings } from "../../actions/repos.ts";
18 import { POLISH_SHARE_MAX, validPolishShare } from "../../crew/small_share.ts";
18 import { POLISH_SHARE_MAX, validPolishShare } from "../../crew/polish_share.ts";
1919 import { seatSlotsSaid, setSeatSlots } from "../../actions/seat_slots.ts";
2020 import { checkSeatSlots, seatSlotsOf, seatSlotsWords } from "../../crew/seat_slots.ts";
2121 import type { CrewRole } from "../../core/agents.ts";
src/tui/screens/capacity.ts +5 −3
@@ -6,7 +6,7 @@
66 */
77
88 import type { SelfSwap } from "../../release/swap.ts";
9 import { changeCapacity, listCapacity, type CapacityRow } from "../../actions/capacity.ts";
9 import { DERIVED_WORDS, changeCapacity, listCapacity, type CapacityRow } from "../../actions/capacity.ts";
1010 import { SETTING_WORDS } from "../../actions/setting_words.ts";
1111 import { CAPACITY, belowCeiling, validCapacity, type CapacityKey } from "../../core/capacity.ts";
1212 import type { Ctx, Screen } from "../app/app.ts";
@@ -28,8 +28,10 @@ export function boundsOf(key: CapacityKey): string {
2828 }
2929
3030 /** What a refused change says, in its own words. */
31 const refused = (error: "not_found" | "no_such_setting" | "invalid_value" | "no_credential"): string =>
32 error === "no_credential"
31 const refused = (error: "not_found" | "no_such_setting" | "invalid_value" | "no_credential" | "derived"): string =>
32 error === "derived"
33 ? DERIVED_WORDS
34 : error === "no_credential"
3335 ? "Set a password or a passkey first: turning the edge off would lock you out"
3436 : error === "invalid_value"
3537 ? SETTING_WORDS.outOfRange
src/tui/screens/flow.ts +1 −2
@@ -241,7 +241,7 @@ export function openerLines(rows: readonly Opener[], inner: number): string[] {
241241 return rows.length > 5 ? [...lines, `and ${rows.length - 5} more`] : lines;
242242 }
243243
244 /** One line per autonomous repository: its name, then `startable=N`, each filter holding any, and `small 1/2` (#7571). */
244 /** One line per autonomous repository: its name, then `startable=N`, each filter holding any. */
245245 export function heldLines(rows: readonly HeldRow[], inner: number): string[] {
246246 if (rows.length === 0) return ["Ready, and why it waits: no repository runs on its own."];
247247 return [
@@ -252,7 +252,6 @@ export function heldLines(rows: readonly HeldRow[], inner: number): string[] {
252252 r.repo,
253253 `startable=${r.startable}`,
254254 ...HOLDS.filter((h) => r.counts[h] > 0).map((h) => `${h}=${r.counts[h]}`),
255 `small ${r.small}`,
256255 ].join(" "),
257256 inner,
258257 ),
src/tui/screens/knobs.ts +1
@@ -42,6 +42,7 @@ export function knobsScreen(f: Session, ctx: Ctx): Screen {
4242 if (e.key !== "e") return state.key(e, rows.length);
4343 const row = rows[state.index];
4444 if (row === undefined) return true;
45 if (row.control === "derived") return ctx.toast(`${row.name} follows Speed.`, "success"), true;
4546 if (row.control === "fixed") return ctx.toast(`${row.name} is always on.`, "success"), true;
4647 later(ctx, () =>
4748 promptScreen(ctx, screen, {
src/tui/screens/week.ts +1 −1
@@ -11,7 +11,7 @@
1111
1212 import { takeProposal } from "../../actions/week.ts";
1313 import { canRead, canWrite, getRepoById, type Repo } from "../../core/repos.ts";
14 import { polishSpend, polishWords } from "../../crew/small_share.ts";
14 import { polishSpend, polishWords } from "../../crew/polish_share.ts";
1515 import { latestRetro, takenOf, type Retro } from "../../flow/retro.ts";
1616 import type { Ctx, Screen } from "../app/app.ts";
1717 import { binding } from "../app/keys.ts";
src/web/pages/board.ts −4
@@ -83,10 +83,8 @@ import {
8383 autonomyStatus,
8484 holdWords,
8585 plannerHold,
86 smallShareOf,
8786 startOfTodayUtc,
8887 } from "../../crew/autonomy.ts";
89 import { smallIssuesWords } from "../../crew/small_share.ts";
9088 import { distinct, notesRegion, type Note } from "./board-notes.ts";
9189 import type { Ctx } from "../../http/context.ts";
9290 import { repoBase, signInFirst } from "../chrome.ts";
@@ -1043,8 +1041,6 @@ function today(ctx: Ctx, scope: BoardScope): Html {
10431041 <span><b>${shipped}</b> shipped</span>`,
10441042 `${perShippedWords(week.get(repo.id))} this week`,
10451043 holdLine,
1046 // Small issues in flight of the slots they may hold (#7571).
1047 repo.autonomy === "off" ? null : smallIssuesWords(smallShareOf(ctx.app, repo, now)),
10481044 review,
10491045 ],
10501046 under: meter(
src/web/pages/capacity.ts +6 −3
@@ -6,7 +6,7 @@
66 * and each one is listed below the settings: who, when, from what to what.
77 */
88
9 import { changeCapacity, edgeOffDryRun, listCapacity, type CapacityRow } from "../../actions/capacity.ts";
9 import { DERIVED_WORDS, changeCapacity, edgeOffDryRun, listCapacity, type CapacityRow } from "../../actions/capacity.ts";
1010 import { CAPACITY, belowCeiling, type CapacityKey } from "../../core/capacity.ts";
1111 import { bytes } from "../../system/format.ts";
1212 import { egressRowsFor } from "../../spawn/process_counters.ts";
@@ -102,10 +102,12 @@ export function capacityPage(ctx: Ctx): Response {
102102 <span class="mono">${s.default === "" ? "auto" : s.default}</span>
103103 (<span class="mono">${s.env}</span>).`
104104 : s.source === "derived"
105 ? html`Derived from Speed (<span class="mono">crew.concurrency</span>); set it here to stop following.`
105 ? html`Derived from Speed (<span class="mono">crew.concurrency</span>); read-only here: change Speed and it moves.`
106106 : html`The boot default, from <span class="mono">${s.env}</span>.`}
107107 ${awayLine(s, now)}`,
108 trail: html`<form
108 trail: s.source === "derived"
109 ? html`<span class="mono" data-derived="${s.key}">${s.value}</span>`
110 : html`<form
109111 class="row-fields"
110112 method="post"
111113 action="/settings/capacity"
@@ -240,6 +242,7 @@ export function capacityPage(ctx: Ctx): Response {
240242
241243 const ERRORS: Record<string, string> = {
242244 no_such_setting: "There is no such setting.",
245 derived: DERIVED_WORDS,
243246 invalid_value: "That value is outside what this setting takes.",
244247 no_credential:
245248 "Add a passkey (or a password) to your own account first: with the edge off, you would be signed out for good.",
src/web/pages/knobs.ts +6 −2
@@ -16,6 +16,7 @@ import { btn } from "../ui.ts";
1616
1717 function control(r: KnobRow): Html {
1818 if (r.control === "fixed") return html`<span class="badge">${r.value}</span>`;
19 if (r.control === "derived") return html`<span class="mono" data-derived="${r.id}" title="Read-only: Speed sets it">${r.value}</span>`;
1920 const label = `${r.name}${r.repo === undefined ? "" : ` for ${r.repo}`}`;
2021 const input =
2122 r.control === "words"
@@ -35,7 +36,7 @@ export function knobsCard(ctx: Ctx): Html {
3536 const rows = readKnobs(ctx.app);
3637 return html`<section id="knobs">
3738 <h2 class="mb">Knobs</h2>
38 <p class="muted small">Five dials for the crew; a change applies on the next pump.</p>
39 <p class="muted small">Dials for the crew; a change applies on the next pump.</p>
3940 <dl class="status-rows">
4041 ${rows.map(
4142 (r) => html`<div class="status-row" data-knob="${r.id}">
@@ -62,6 +63,9 @@ export async function knobSet(ctx: Ctx): Promise<Response> {
6263 "/settings/crew",
6364 set.ok
6465 ? { message: `${knob}: ${set.value.value}, from the next pump on.` }
65 : { tone: "danger", message: "That value is outside what this knob takes." },
66 : {
67 tone: "danger",
68 message: set.error === "derived" ? "That dial follows Speed: turn Speed and it moves." : "That value is outside what this knob takes.",
69 },
6670 );
6771 }
src/web/pages/settings.ts +1 −1
@@ -8,7 +8,7 @@
88 * same-origin POST and every one of them says what happened.
99 */
1010
11 import { POLISH_SHARE_DEFAULT, POLISH_SHARE_MAX } from "../../crew/small_share.ts";
11 import { POLISH_SHARE_DEFAULT, POLISH_SHARE_MAX } from "../../crew/polish_share.ts";
1212 import { appLimitWords } from "../../actions/app_limit.ts";
1313 import { showProvenance } from "../../actions/provenance.ts";
1414 import { getProvenance, parentUrl, sourceLabel } from "../../core/provenance.ts";
src/web/pages/week.ts +1 −1
@@ -5,7 +5,7 @@
55 * and nothing else on the page changes anything.
66 */
77 import { takeProposal } from "../../actions/week.ts";
8 import { polishSpend } from "../../crew/small_share.ts";
8 import { polishSpend } from "../../crew/polish_share.ts";
99 import { canWrite, type Repo } from "../../core/repos.ts";
1010 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
1111 import { latestRetro, takenOf, type Line, type Retro } from "../../flow/retro.ts";
src/web/studio/crew.ts +1 −3
@@ -31,7 +31,7 @@ import { getAppByRepo } from "../../core/apps.ts";
3131 import { canAdmin, canSeeOwnerFacts, type Repo } from "../../core/repos.ts";
3232 import { seatsOf, type Seat } from "../../crew/seats.ts";
3333 import { crewHasCredential } from "../../core/subscriptions.ts";
34 import { autonomyStatus, smallShareWords } from "../../crew/autonomy.ts";
34 import { autonomyStatus } from "../../crew/autonomy.ts";
3535 import { governorSeen } from "../../crew/governed.ts";
3636 import { crewOffInPreview } from "../../crew/preview.ts";
3737 import type { Ctx } from "../../http/context.ts";
@@ -298,8 +298,6 @@ function autonomyCard(ctx: Ctx, repo: Repo, base: string, admin: boolean): Html
298298 </dd>
299299 <dt>Slots</dt>
300300 <dd>${status.active} of ${status.slots} in use</dd>
301 <dt>Small issues</dt>
302 <dd>${smallShareWords(ctx.app, repo)} slots</dd>
303301 ${governor === undefined
304302 ? null
305303 : html`<dt>Governor</dt>
test/crew-small-share.test.ts +20 −73
@@ -1,7 +1,7 @@
11 /**
2 * Builder slots serve the roadmap first (#7571): small issues — tests-only
3 * or small additions — hold at most `slots.smallShare` of the slots, and an
4 * issue under an open epic starts before a small one.
2 * The pick order (#7571, #10622): small issues — tests-only or small
3 * additions — hold no slots of their own now; Speed and the Next up order bound
4 * them like any work. An old `slots.smallShare` is ignored.
55 */
66 import { quietWeek } from "./helpers/week-quiet.ts";
77 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
@@ -14,7 +14,6 @@ import { addToCrew, createAgent } from "../src/actions/crew.ts";
1414 import { linkIssue } from "../src/actions/dependencies.ts";
1515 import { openIssue } from "../src/actions/issues.ts";
1616 import { commitToBranch, createRepo, updateRepoSettings } from "../src/actions/repos.ts";
17 import { smallShare } from "../src/core/capacity.ts";
1817 import { readyNow, repoGraph } from "../src/core/dependencies.ts";
1918 import { nextUp } from "../src/core/next_up.ts";
2019 import { putFirst } from "../src/core/first.ts";
@@ -22,8 +21,8 @@ import { getIssueById, type Issue } from "../src/core/issues.ts";
2221 import { getRepoById, type Repo } from "../src/core/repos.ts";
2322 import { createRun } from "../src/core/runs.ts";
2423 import { createUser, type User } from "../src/core/users.ts";
25 import { driveRepo, inSlotOrder, smallShareWords } from "../src/crew/autonomy.ts";
26 import { isSmall, shareWords, slotOrder, smallCap, smallIssuesWords } from "../src/crew/small_share.ts";
24 import { driveRepo, inSlotOrder } from "../src/crew/autonomy.ts";
25 import { isSmall } from "../src/crew/polish_share.ts";
2726 import { readHeld } from "../src/flow/held.ts";
2827 import { serve, type Servers } from "../src/http/server.ts";
2928 import { heldLines } from "../src/tui/screens/flow.ts";
@@ -36,23 +35,8 @@ describe("the rules", () => {
3635 expect(isSmall("Search notes by word", ["feature"])).toBe(false);
3736 });
3837
39 test("5 slots at 30% hold 2 small; 1 slot still holds 1; 0% holds none", () => {
40 expect(smallCap(5, 30)).toBe(2);
41 expect(smallCap(1, 30)).toBe(1);
42 expect(smallCap(5, 0)).toBe(0);
43 expect(smallCap(5, 100)).toBe(5);
44 });
45
46 test("the order given is kept, and small work is left out at the cap unless ranked", () => {
47 const c = (t: string) => ({ ranked: t.endsWith("^"), small: t.startsWith("s") });
48 expect(slotOrder(["s1", "o1", "r1", "s2"], c, 0, 2)).toEqual(["s1", "o1", "r1", "s2"]);
49 expect(slotOrder(["s1", "o1", "s2^", "r1"], c, 2, 2)).toEqual(["o1", "s2^", "r1"]);
50 });
51
52 test("each label the docs name is small, and the words read as the doors print them", () => {
38 test("each label the docs name is small", () => {
5339 for (const label of ["test", "tests", "tests-only", "small"]) expect(isSmall("Anything", [label])).toBe(true);
54 expect(shareWords(1, 2)).toBe("1/2");
55 expect(smallIssuesWords({ running: 1, cap: 2 })).toBe("small issues: 1 of 2 slots");
5640 });
5741 });
5842
@@ -66,15 +50,6 @@ let cookie: string;
6650 const get = (path: string) =>
6751 fetch(`${servers.url}${path}`, { headers: { cookie: `${cookie}; forge_mode=studio` }, redirect: "manual" });
6852
69 /** Sets `slots.smallShare` as an admin does, on /settings/capacity. */
70 const setShare = (value: string) =>
71 fetch(`${servers.url}/settings/capacity`, {
72 method: "POST",
73 redirect: "manual",
74 headers: { cookie, "content-type": "application/x-www-form-urlencoded", origin: servers.url },
75 body: new URLSearchParams({ key: "slots.smallShare", value }),
76 });
77
7853 const fresh = () => getRepoById(app.db, repo.id)!;
7954
8055 /** Every issue opened here, for a drive that may pick any of them. */
@@ -150,34 +125,25 @@ afterAll(async () => {
150125 await rm(dataDir, { recursive: true, force: true });
151126 });
152127
153 test("with 5 slots and only small issues ready, at most 2 start; then an epic's issue starts before a small one", async () => {
154 expect(smallShare(app.db)).toBe(30);
128 test("no cap holds small issues back: with 5 slots and four small issues ready, all four start, whatever an old slots.smallShare says", async () => {
129 // A value stored by the knob that used to be here: ignored, not an error.
130 app.db.query("INSERT INTO forge_setting (key, value, updated_by, updated_at) VALUES ('slots.smallShare', '0', ?1, ?2)").run(alice.id, new Date().toISOString());
155131 const small = ["A test pins the title", "A test pins the body", "A test pins the date", "A test pins the tags"].map(open);
156132 const started: Issue[] = [];
157 for (let n = 0; n < 5; n++) {
158 started.push(...(await drive(small)));
159 }
160 expect(started.length).toBe(2);
161 expect(smallShareWords(app, fresh())).toBe("2/2");
162
163 // Room is made for one more small one, and an epic's issue arrives beside it.
164 const epic = open("Notes search");
165 const child = open("Search notes by word");
166 expect(linkIssue(app, alice, fresh(), epic, child).ok).toBe(true);
167 app.db.query("UPDATE run SET state = 'ok' WHERE issue_id = ?1").run(started[0]!.id);
168 const next = await drive([...small, child]);
169 expect(next.map((i) => i.id)).toContain(child.id);
133 for (let n = 0; n < 5; n++) started.push(...(await drive(small)));
134 expect(started.length).toBe(4);
135 // Those runs end, so the slots are free for the tests below.
136 app.db.query("UPDATE run SET state = 'ok' WHERE repo_id = ?1 AND state IN ('queued','running')").run(repo.id);
170137 });
171138
172 test("one small issue of two slots shows on /board's Today, the studio's crew panel and the terminal's Flow", async () => {
173 expect(smallShareWords(app, fresh())).toBe("2/2");
139 test("small issues in flight show nowhere: not on /board's Today, the studio's crew panel or the terminal's Flow", async () => {
174140 const board = await (await get("/board")).text();
175 const today = board.slice(board.indexOf('id="today"'));
176 expect(today).toContain("small issues: 2 of 2 slots");
141 expect(board).not.toContain("small issues:");
177142 const studio = await (await get("/alice/notes/settings")).text();
178 expect(studio).toMatch(/<dt>Small issues<\/dt>\s*<dd>2\/2 slots<\/dd>/);
143 expect(studio).not.toContain("<dt>Small issues</dt>");
179144 const flow = heldLines(readHeld(app, true), 200).join("\n");
180 expect(flow).toMatch(/^alice\/notes startable=\d+.* small 2\/2$/m);
145 expect(flow).toMatch(/^alice\/notes startable=\d+/m);
146 expect(flow).not.toContain(" small ");
181147 });
182148
183149 test("what the owner raised again is first in the page's order and in the picker's, ahead of an older epic issue", async () => {
@@ -188,7 +154,7 @@ test("what the owner raised again is first in the page's order and in the picker
188154 app.db.query("INSERT INTO issue_raise (issue_id, raised_at) VALUES (?1, ?2)").run(plain.id, new Date().toISOString());
189155 const mine = new Set([plain.id, child.id]);
190156 const ready = readyNow(repoGraph(app.db, repo.id, { sealed: true }));
191 const picker = inSlotOrder(app, fresh(), ready, [], 5).map((r) => r.issue.id).filter((id) => mine.has(id));
157 const picker = inSlotOrder(app, fresh(), ready).map((r) => r.issue.id).filter((id) => mine.has(id));
192158 const page = nextUp(app.db, fresh()).rows.map((r) => r.issue.id).filter((id) => mine.has(id));
193159 expect(picker).toEqual([plain.id, child.id]);
194160 expect(page).toEqual(picker);
@@ -204,7 +170,7 @@ test("an epic's issue leads older plain work only as the page's ready order has
204170 const order = () => {
205171 const mine = new Set([older.id, child.id]);
206172 const ready = readyNow(repoGraph(app.db, repo.id, { sealed: true }));
207 const picker = inSlotOrder(app, fresh(), ready, [], 5).map((r) => r.issue.id).filter((id) => mine.has(id));
173 const picker = inSlotOrder(app, fresh(), ready).map((r) => r.issue.id).filter((id) => mine.has(id));
208174 const page = nextUp(app.db, fresh()).rows.map((r) => r.issue.id).filter((id) => mine.has(id));
209175 expect(page).toEqual(picker);
210176 return picker;
@@ -217,22 +183,3 @@ test("an epic's issue leads older plain work only as the page's ready order has
217183 for (const i of [older, epic, child]) app.db.query("UPDATE issue SET state = 'closed' WHERE id = ?1").run(i.id);
218184 opened.splice(opened.length - 3, 3);
219185 });
220
221 test("slots.smallShare set on /settings/capacity changes the pick: 0 starts no small issue, 100 does; out of range is refused", async () => {
222 // The earlier ticks filled the slots; those runs end so this pick has room.
223 app.db.query("UPDATE run SET state = 'ok' WHERE repo_id = ?1 AND state IN ('queued','running')").run(repo.id);
224 const small = open("A test pins the colour");
225 expect((await setShare("0")).status).toBe(303);
226 expect(smallShare(app.db)).toBe(0);
227 expect(await drive(opened)).toEqual([]);
228 expect(getIssueById(app.db, small.id)!.assigneeId).toBeNull();
229 expect((await setShare("100")).status).toBe(303);
230 expect(smallShare(app.db)).toBe(100);
231 const picked = await drive(opened);
232 expect(picked.length).toBeGreaterThan(0);
233 for (const i of picked) expect(isSmall(i.title, [])).toBe(true);
234 for (const value of ["101", "-1", "half"]) {
235 await setShare(value);
236 expect(smallShare(app.db)).toBe(100);
237 }
238 });
test/knobs.test.ts +60 −24
@@ -7,11 +7,12 @@ 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";
10 import { changeCapacity } from "../src/actions/capacity.ts";
1011 import { createRepo } from "../src/actions/repos.ts";
1112 import { setKnob } from "../src/actions/knobs.ts";
1213 import { createApp, type App } from "../src/app.ts";
1314 import { listCapacity } from "../src/actions/capacity.ts";
14 import { CAPACITY_KEYS, capacityCount, followsSpeed, paceFloor, queueDepth, setCapacity, weekUse } from "../src/core/capacity.ts";
15 import { CAPACITY_KEYS, capacityCount, paceFloor, queueDepth, setCapacity, weekUse } from "../src/core/capacity.ts";
1516 import { listEvents } from "../src/core/events.ts";
1617 import { getRepoById } from "../src/core/repos.ts";
1718 import { createUser, type User } from "../src/core/users.ts";
@@ -47,16 +48,16 @@ afterAll(async () => {
4748 await rm(dir, { recursive: true, force: true });
4849 });
4950
50 test("the panel has Speed, Weekly budget, Polish share, Autonomy per repository and Feedback first, each with an effect", () => {
51 test("the panel has Speed, the derived pace floor and queue depth, Weekly budget, Polish share, Autonomy per repository and Feedback first, each with an effect", () => {
5152 const rows = readKnobs(app);
52 expect(rows.map((r) => r.id)).toEqual(["speed", "budget", "polish", "autonomy", "feedback"]);
53 expect(rows.map((r) => r.id)).toEqual(["speed", "floor", "depth", "budget", "polish", "autonomy", "feedback"]);
5354 for (const r of rows) {
5455 expect(r.does.length).toBeGreaterThan(0);
5556 expect(r.effect.length).toBeGreaterThan(0);
5657 }
5758 expect(rows[0]!.effect).toMatch(/\d+ running · \d+ queued/);
58 expect(rows[4]!.effect).toBe("nothing from a person waiting");
59 expect(knobLines(rows)).toHaveLength(5);
59 expect(rows[6]!.effect).toBe("nothing from a person waiting");
60 expect(knobLines(rows)).toHaveLength(7);
6061 });
6162
6263 test("Speed 4 → 2 is read by the next pump", async () => {
@@ -69,9 +70,24 @@ test("Speed 4 → 2 is read by the next pump", async () => {
6970 expect(listEvents(app.db, {}).some((e) => e.kind === "capacity.changed")).toBe(true);
7071 });
7172
72 test("the pipeline's dials stay on capacity and no knob reaches them", async () => {
73 for (const key of ["slots.smallShare", "polish.capMinutes", "pace.floor", "queue.depth"]) expect(CAPACITY_KEYS as string[]).toContain(key);
74 for (const knob of ["queue.depth", "pace.floor", "slots.smallShare"]) expect((await setKnob(app, admin, knob, "1")).ok).toBe(false);
73 test("the small share and the polish cap are gone from capacity; a stored old value is ignored", () => {
74 for (const key of ["slots.smallShare", "polish.capMinutes"]) expect(CAPACITY_KEYS as string[]).not.toContain(key);
75 app.db.query("INSERT OR REPLACE INTO forge_setting (key, value, updated_by, updated_at) VALUES ('slots.smallShare', '5', ?1, ?2)").run(admin.id, new Date().toISOString());
76 const listed = listCapacity(app, admin);
77 if (!listed.ok) throw new Error("not admin");
78 expect(listed.value.settings.map((r) => r.key)).not.toContain("slots.smallShare");
79 expect(readKnobs(app).length).toBeGreaterThan(0);
80 });
81
82 test("no knob or capacity POST sets the pace floor or the queue depth: a plain line, nothing stored", async () => {
83 for (const knob of ["queue.depth", "pace.floor", "floor", "depth", "slots.smallShare"]) expect((await setKnob(app, admin, knob, "1")).ok).toBe(false);
84 const knob = await setKnob(app, admin, "pace.floor", "1");
85 expect(!knob.ok && knob.error).toBe("derived");
86 for (const key of ["pace.floor", "queue.depth"]) {
87 const changed = changeCapacity(app, admin, key, "3");
88 expect(!changed.ok && changed.error).toBe("derived");
89 expect(app.db.query("SELECT 1 FROM forge_setting WHERE key = ?1").get(key)).toBeNull();
90 }
7591 });
7692
7793 test("Weekly budget sets the reserve the pacer holds back", () => {
@@ -131,30 +147,50 @@ test("pace floor and queue depth follow Speed, and the next pump reads the new f
131147 expect(queueDepth(app.db)).toBe(1);
132148 });
133149
134 test("a stored pace floor or queue depth wins over Speed", () => {
150 test("a stored pace floor or queue depth is ignored: Speed decides", () => {
135151 setCapacity(app.db, "crew.concurrency", "6", admin.id);
136152 setCapacity(app.db, "pace.floor", "2", admin.id);
137153 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);
154 expect(paceFloor(app.db)).toBe(3);
155 expect(queueDepth(app.db)).toBe(3);
141156 });
142157
143 test("derived rows are labelled on the capacity page, the terminal's lines and the knobs", async () => {
158 test("the derivation at the lowest, middle and highest Speed", () => {
159 const at = (speed: string) => {
160 setCapacity(app.db, "crew.concurrency", speed, admin.id);
161 return [paceFloor(app.db), queueDepth(app.db)];
162 };
163 expect(at("1")).toEqual([1, 1]);
164 expect(at("8")).toEqual([4, 4]);
165 expect(at("16")).toEqual([8, 4]);
166 });
167
168 test("the capacity page and the knobs list the derived dials with their value, read-only", async () => {
144169 setCapacity(app.db, "crew.concurrency", "6", admin.id);
145170 const rows = listCapacity(app, admin);
146171 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)");
172 for (const key of ["pace.floor", "queue.depth"]) {
173 const r = rows.value.settings.find((x) => x.key === key)!;
174 expect(r.source).toBe("derived");
175 expect(r.value).toBe("3");
176 }
177 const knobs = readKnobs(app);
178 for (const [id, unit] of [["floor", "slots"], ["depth", "candidates"]]) {
179 const r = knobs.find((x) => x.id === id)!;
180 expect([r.control, r.value, r.effect]).toEqual(["derived", "3", `3 ${unit}`]);
181 }
157182 // The MCP tool prints the same rows, in the same words.
158183 const ctx = { app, auth: { scope: "admin" } } as never;
159 expect(JSON.stringify(await tool.run(ctx, admin, {}))).toContain("pace floor 3 (derived from Speed)");
184 expect(JSON.stringify(await tool.run(ctx, admin, {}))).toContain("Pace floor: 3 — 3 slots");
185 });
186
187 test("the knobs card renders the derived dials as plain values with no form", async () => {
188 const { knobsCard } = await import("../src/web/pages/knobs.ts");
189 const out = String(knobsCard({ app } as never));
190 for (const id of ["floor", "depth"]) {
191 const row = out.slice(out.indexOf(`data-knob="${id}"`), out.indexOf("</div>", out.indexOf(`data-knob="${id}"`)));
192 expect(row).toContain(`data-derived="${id}"`);
193 expect(row).not.toContain("<form");
194 expect(row).not.toContain("<input");
195 }
160196 });
test/mcp-next-up.test.ts +3 −3
@@ -106,7 +106,7 @@ describe("next_up over MCP", () => {
106106 };
107107
108108 const picked = (repo: Repo, slots: number): number[] =>
109 inSlotOrder(app, repo, readyNow(repoGraph(app.db, repo.id, { sealed: true })), [], slots).map((r) => r.issue.number);
109 inSlotOrder(app, repo, readyNow(repoGraph(app.db, repo.id, { sealed: true }))).map((r) => r.issue.number);
110110
111111 test("the list lists the order the picker starts in, ranked first and then the rest, and a ranked small issue survives a full small share", async () => {
112112 const { repo, numbers } = await orderRepo();
@@ -116,11 +116,11 @@ describe("next_up over MCP", () => {
116116 const before = await listed();
117117 expect(before.slice(0, 2)).toEqual([numbers[3]!, numbers[2]!]);
118118 expect(picked(repo, 5)).toEqual(before);
119 // With no small slot at all, the unranked small issue is dropped; ranking it keeps it, in its place.
120 expect(picked(repo, 0)).toEqual(before.filter((n) => n !== numbers[4]));
119 // No cap holds a small issue back; ranking it moves it to the top.
121120 await tool(rw, { app: "alice/order", issue: numbers[4], to: "top" });
122121 const after = await listed();
123122 expect(after[0]).toBe(numbers[4]!);
124123 expect(picked(repo, 0)).toEqual(after);
124 expect(after).toContain(numbers[4]!);
125125 });
126126 });
test/next-up.test.ts +1 −14
@@ -261,19 +261,6 @@ describe("the ranked list is bounded", () => {
261261 });
262262 });
263263
264 describe("the picker's order", () => {
265 test("at the small cap a ranked small issue survives and keeps its place; an unranked one is left out", async () => {
266 const { slotOrder } = await import("../src/crew/small_share.ts");
267 const items = [
268 { id: "a", small: false },
269 { id: "ranked-small", ranked: true, small: true },
270 { id: "b", small: true },
271 ];
272 const picks = slotOrder(items, (t) => t, 1, 1).map((t) => t.id);
273 expect(picks).toEqual(["a", "ranked-small"]);
274 });
275 });
276
277264 describe("the picker follows the page (#8826)", () => {
278265 test("with two ranked and three unranked issues, five ticks start the page's rows top to bottom", async () => {
279266 const { inSlotOrder } = await import("../src/crew/autonomy.ts");
@@ -305,7 +292,7 @@ describe("the picker follows the page (#8826)", () => {
305292 const ready = readyNow(repoGraph(app.db, repo.id, { sealed: true })).filter(
306293 (x) => !started.includes(x.issue.number),
307294 );
308 const next = inSlotOrder(app, repo, ready, [], 5)[0]!;
295 const next = inSlotOrder(app, repo, ready)[0]!;
309296 started.push(next.issue.number);
310297 }
311298 expect(started).toEqual(page);
test/pace-floor.test.ts +8 −5
@@ -4,13 +4,13 @@
44 * pace sentence, and at the reserve's wall everything waits (#8962).
55 */
66
7 import { afterAll, beforeAll, expect, test } from "bun:test";
7 import { afterAll, afterEach, beforeAll, expect, test } from "bun:test";
88 import { addToCrew } from "../src/actions/crew.ts";
99 import { assignIssue, openIssue } from "../src/actions/issues.ts";
1010 import { commitToBranch, createRepo, updateRepoSettings } from "../src/actions/repos.ts";
1111 import { paceFloor, setCapacity } from "../src/core/capacity.ts";
1212 import { getRepoById, type Repo } from "../src/core/repos.ts";
13 import { claimRun, createRun, getRun, listActive } from "../src/core/runs.ts";
13 import { cancelRun, claimRun, createRun, getRun, listActive } from "../src/core/runs.ts";
1414 import { noteMeasure } from "../src/core/measure.ts";
1515 import { setHeadroom } from "../src/core/standing.ts";
1616 import { addSubscription } from "../src/core/subscriptions.ts";
@@ -23,10 +23,13 @@ let standIn: StandInCrew;
2323
2424 beforeAll(async () => {
2525 standIn = await startStandInCrew({ tickMs: 50 });
26 setCapacity(standIn.app.db, "pace.floor", "2", standIn.alice.id);
27 setCapacity(standIn.app.db, "crew.concurrency", "10", standIn.alice.id);
26 setCapacity(standIn.app.db, "crew.concurrency", "4", standIn.alice.id);
2827 }, 30_000);
2928 afterAll(() => stopStandInCrew(standIn));
29 // Speed bounds every run at once: each test starts with no run left over from the one before.
30 afterEach(() => {
31 for (const r of listActive(standIn.app.db, "agent")) cancelRun(standIn.app.db, r.id);
32 });
3033
3134 async function until(done: () => boolean, what: string, ms = 20_000): Promise<void> {
3235 const deadline = Date.now() + ms;
@@ -72,7 +75,7 @@ function assigned(repo: Repo, title: string, pick: boolean): string {
7275 return run.id;
7376 }
7477
75 test("the floor is a config value, 2 by default", () => {
78 test("the floor follows Speed: half of 4 is 2", () => {
7679 expect(paceFloor(standIn.app.db)).toBe(2);
7780 });
7881
test/polish-cap.test.ts deleted −131
@@ -1,131 +0,0 @@
1 /**
2 * A polish issue is capped small (#8852): past the cap its run is stopped
3 * with one comment; under it, or on any other issue, nothing happens. The
4 * clock is a Date handed in, never the wall's.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { openIssue, setIssueLabels } from "../src/actions/issues.ts";
12 import { createLabel, createRepo } from "../src/actions/repos.ts";
13 import { listComments, type Issue } from "../src/core/issues.ts";
14 import { getRepoById, type Repo } from "../src/core/repos.ts";
15 import { claimRun, createRun, getRun } from "../src/core/runs.ts";
16 import { createUser, type User } from "../src/core/users.ts";
17 import { isSmall } from "../src/crew/small_share.ts";
18 import { leftLine, leftOf, pastCap, tickPolish } from "../src/crew/polish_cap.ts";
19
20 let dataDir: string;
21 let app: App;
22 let alice: User;
23 let repo: Repo;
24
25 function runOn(title: string, labels: string[]) {
26 const made = openIssue(app, alice, getRepoById(app.db, repo.id)!, { title });
27 if (!made.ok) throw new Error(String(made.error));
28 const issue: Issue = made.value;
29 if (labels.length > 0) expect(setIssueLabels(app, alice, repo, issue, labels).ok).toBe(true);
30 const run = createRun(app.db, { repoId: repo.id, kind: "agent", issueId: issue.id, actorId: alice.id, reason: "assigned" });
31 claimRun(app.db, run.id);
32 return { issue, run };
33 }
34
35 const startedAt = (id: string) => Date.parse(getRun(app.db, id)!.startedAt!);
36 const comments = (issue: Issue) => listComments(app.db, issue.id).filter((c) => c.body.includes("polish cap"));
37
38 beforeAll(async () => {
39 dataDir = await mkdtemp(join(tmpdir(), "forge-polish-"));
40 app = createApp({ dataDir, port: 0 });
41 const a = await createUser(app.db, {
42 origin: "seed", trusted: true, username: "alice", email: "[email protected]",
43 password: "a-good-passphrase", adminIfFirst: true,
44 });
45 if (!a.ok) throw new Error(a.error);
46 alice = a.value;
47 const made = await createRepo(app, alice, { name: "notes", visibility: "private" });
48 if (!made.ok) throw new Error(String(made.error));
49 repo = made.value;
50 for (const [n, c] of [["polish", "aabbcc"], ["feature", "112233"]] as const) {
51 const l = createLabel(app, alice, repo, n, c);
52 if (!l.ok && l.error !== "taken") throw new Error(String(l.error));
53 }
54 // The drive reads the repository as stored, with its autonomy author.
55 repo = { ...getRepoById(app.db, repo.id)!, autonomyBy: alice.id };
56 });
57
58 afterAll(async () => {
59 await app.close?.();
60 await rm(dataDir, { recursive: true, force: true });
61 });
62
63 describe("the rules", () => {
64 test("polish counts as small-fixes work", () => {
65 expect(isSmall("Round the corners", ["polish"])).toBe(true);
66 expect(isSmall("Round the corners", ["Polish"])).toBe(true);
67 });
68
69 test("a run is past the cap at exactly 60 minutes, not at 59", () => {
70 const at = "2026-11-02T10:00:00.000Z";
71 expect(pastCap(at, 60, new Date("2026-11-02T10:59:59.000Z"))).toBe(false);
72 expect(pastCap(at, 60, new Date("2026-11-02T11:00:00.000Z"))).toBe(true);
73 expect(pastCap(null, 60, new Date("2030-01-01T00:00:00Z"))).toBe(false);
74 });
75
76 test("the line names the cap and what is left, plainly", () => {
77 expect(leftLine(60, leftOf(undefined))).toBe(
78 "Stopped at the 60-minute polish cap, not extended. Left: nothing landed, no pull open.",
79 );
80 expect(leftLine(45, leftOf(12))).toBe(
81 "Stopped at the 45-minute polish cap, not extended. Left: pull #12 is open, unmerged.",
82 );
83 });
84 });
85
86 describe("the tick", () => {
87 test("a polish run under the cap is left running", () => {
88 const { issue, run } = runOn("Under: nudge the margin", ["polish"]);
89 expect(tickPolish(app, repo, new Date(startedAt(run.id) + 59 * 60_000))).toBe(0);
90 expect(getRun(app.db, run.id)!.state).toBe("running");
91 expect(comments(issue)).toHaveLength(0);
92 });
93
94 test("past the cap it stops with one comment, however often the tick runs; a non-polish issue is untouched", () => {
95 const polish = runOn("Past: nudge the padding", ["polish"]);
96 const other = runOn("Past: a feature", ["feature"]);
97 const unlabelled = runOn("Past: no label", []);
98 const late = new Date(startedAt(polish.run.id) + 61 * 60_000);
99 // Earlier tests' runs may also be past by now; this run is the one under test.
100 expect(tickPolish(app, repo, late)).toBeGreaterThanOrEqual(1);
101 expect(tickPolish(app, repo, late)).toBe(0);
102 expect(getRun(app.db, polish.run.id)!.state).toBe("cancelled");
103 expect(getRun(app.db, polish.run.id)!.summary).toBe("stopped at the 60-minute polish cap");
104 const said = comments(polish.issue);
105 expect(said).toHaveLength(1);
106 expect(said[0]!.body).toContain("Left: nothing landed, no pull open.");
107 expect(getRun(app.db, other.run.id)!.state).toBe("running");
108 expect(getRun(app.db, unlabelled.run.id)!.state).toBe("running");
109 expect(comments(other.issue)).toHaveLength(0);
110 });
111 });
112
113 describe("who says it", () => {
114 test("with no autonomy author the run's actor says it: the stop is never silent", () => {
115 const { issue, run } = runOn("Silent: nudge the gap", ["polish"]);
116 const bare = { ...repo, autonomyBy: null };
117 tickPolish(app, bare, new Date(startedAt(run.id) + 61 * 60_000));
118 expect(getRun(app.db, run.id)!.state).toBe("cancelled");
119 const said = comments(issue);
120 expect(said).toHaveLength(1);
121 expect(said[0]!.authorId).toBe(alice.id);
122 });
123
124 test("with no one to say it the run is left, not stopped silently", () => {
125 const { issue, run } = runOn("Mute: nudge the gap", ["polish"]);
126 app.db.query("UPDATE run SET actor_id = NULL WHERE id = ?1").run(run.id);
127 tickPolish(app, { ...repo, autonomyBy: null }, new Date(startedAt(run.id) + 61 * 60_000));
128 expect(getRun(app.db, run.id)!.state).toBe("running");
129 expect(comments(issue)).toHaveLength(0);
130 });
131 });
test/polish-share.test.ts +1 −1
@@ -17,7 +17,7 @@ import { getRepoById, type Repo } from "../src/core/repos.ts";
1717 import { createRun } from "../src/core/runs.ts";
1818 import { createUser, type User } from "../src/core/users.ts";
1919 import { driveRepo } from "../src/crew/autonomy.ts";
20 import { isPolish, polishPosition, polishSpend, polishWords, validPolishShare } from "../src/crew/small_share.ts";
20 import { isPolish, polishPosition, polishSpend, polishWords, validPolishShare } from "../src/crew/polish_share.ts";
2121 import { serve, type Servers } from "../src/http/server.ts";
2222
2323 describe("the rules", () => {
test/settings-visible.test.ts +4 −2
@@ -80,10 +80,12 @@ test("every capacity row shows its value and control, hover-free", async () => {
8080 const listed = listCapacity(app, admin);
8181 if (!listed.ok) throw new Error("no capacity");
8282 for (const s of listed.value.settings) {
83 const row = rowsOf(page).find((r) => r.includes(`name="key" value="${s.key}"`));
83 // a row derived from Speed is read-only: its value, no form
84 const mark = s.source === "derived" ? `data-derived="${s.key}"` : `name="key" value="${s.key}"`;
85 const row = rowsOf(page).find((r) => r.includes(mark));
8486 expect(row, s.key).toBeDefined();
8587 expect(row).toContain(s.label);
86 expect(row).toContain("Now <span");
88 if (s.source !== "derived") expect(row).toContain("Now <span");
8789 expect(row).not.toContain("row-actions");
8890 }
8991 });
test/tui-capacity.test.ts +4 −3
@@ -134,14 +134,15 @@ describe("tui capacity", () => {
134134 h.stop();
135135 });
136136
137 test("queue.depth, which the page sets, changes after a confirm", async () => {
137 test("queue.depth follows Speed: an edit there changes nothing", async () => {
138 const before = capacityValue(app.db, "queue.depth");
138139 const h = boot(carol);
139140 for (let i = 0; i < CAPACITY_KEYS.indexOf("queue.depth"); i++) await h.press("j");
140141 await h.press("e");
141142 for (let i = 0; i < 4; i++) await h.press("backspace");
142 await h.press("4", "enter");
143 await h.press("9", "enter");
143144 await h.press("left", "enter");
144 expect(capacityValue(app.db, "queue.depth")).toBe("4");
145 expect(capacityValue(app.db, "queue.depth")).toBe(before);
145146 h.stop();
146147 });
147148
test/week-pump.test.ts +6 −7
@@ -78,11 +78,10 @@ test("builds fill the pace slots, yet a review or assigned run still starts; a t
7878 if (!sub.ok) throw new Error(sub.error);
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);
81 setCapacity(app.db, "crew.concurrency", "10", alice.id);
82 setCapacity(app.db, "pace.floor", "2", alice.id);
81 setCapacity(app.db, "crew.concurrency", "7", alice.id); // the pace floor follows: half of 7 is 3
8382 const now = Date.now();
8483 noteMeasure(app.db, sub.value.id, { window: "week", usedPct: 70, resetsAt: new Date(now + 3.5 * DAY).toISOString(), observedAt: new Date(now).toISOString() });
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.
84 // 30% free now, 36% an hour ago: 6 points an hour, far over what is allowed, so the pace is the floor of 3 slots.
8685 app.db.query("INSERT INTO week_reading (scope, at, free_pct) VALUES (?1, ?2, 36)").run(sub.value.id, new Date(now - 3_600_000).toISOString());
8786 const on = await updateRepoSettings(app, alice, made.value, { autonomy: "autonomous", autonomyDirection: "Ship.", autonomyCeilingUsd: 1000, autonomySlots: 4, autonomyBy: alice.id });
8887 expect(on.ok).toBe(true);
@@ -100,15 +99,15 @@ test("builds fill the pace slots, yet a review or assigned run still starts; a t
10099 };
101100 const stateOf = (id: string) => getRun(app.db, id)!.state;
102101 const logOf = (id: string) => readRunLog(app.config.dataDir, id).lines.join("\n");
103 // Two builds fill the pace; one review and one assigned run are going in their own lane.
104 run("push", true);
105 run("push", true);
102 // Three builds fill the pace; one review and one assigned run are going in their own lane.
103 for (let i = 0; i < 3; i++) run("push", true);
104 run("review", true);
106105 run("review", true);
107106 run("assigned", true);
108107 const build = run("push", false);
109108 const review = run("review", false);
110109 await until(() => logOf(build).includes("queued, not started: Ahead of pace"), "the build to be held");
111 await until(() => logOf(review).includes("2 of 2 places for reviews and assigned work are taken"), "the third priority run to be held");
110 await until(() => logOf(review).includes("3 of 3 places for reviews and assigned work are taken"), "the third priority run to be held");
112111 expect(stateOf(build)).toBe("queued");
113112 expect(stateOf(review)).toBe("queued");
114113 // One priority run ends: the lane has room and the review starts, the build still waits.
test/week-wiring.test.ts +4 −2
@@ -83,7 +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);
86 setCapacity(app.db, "crew.concurrency", "4", alice.id); // pace floor follows Speed: half of 4 is 2
8787 const made = await createRepo(app, alice, { name: "svc", visibility: "private" });
8888 if (!made.ok) throw new Error(String(made.error));
8989 repo = made.value;
@@ -120,7 +120,7 @@ afterAll(async () => {
120120 beforeEach(() => forgetWeek());
121121 // A failed expectation must not leave the lowered crew concurrency for later tests.
122122 afterEach(() => {
123 changeCapacity(app, alice, "crew.concurrency", null);
123 changeCapacity(app, alice, "crew.concurrency", "4");
124124 });
125125
126126 test("behind pace, with an idle slot and nothing startable, the tick releases the gated issue and says why", async () => {
@@ -196,6 +196,8 @@ test("Usage → Spend carries the same sentence, so pacing is not mistaken for t
196196 });
197197 const cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
198198 const spend = () => fetch(`${servers.url}/settings/spend`, { headers: { cookie } }).then((r) => r.text());
199 expect(changeCapacity(app, alice, "crew.concurrency", String(Number(capacityDefault("crew.concurrency")))).ok).toBe(true);
200 forgetWeek();
199201 expect(await spend()).not.toContain("below its ceiling");
200202 expect(changeCapacity(app, alice, "crew.concurrency", "1").ok).toBe(true);
201203 forgetWeek();

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs