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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/10622-knobs-the-small-share-and-polish-cap-are → main 4 commits fork 1 thread · 1 unresolved
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: | ||
| 216 | 216 | it waits for a gate of its own. Red alone, it is handed back (`[queue] red |
| 217 | 217 | alone`); green alone, it rides at the tail of a later batch, never its head. |
| 218 | 218 | 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 | |
| 220 | 221 | lands whole the next lands right after it, on the tree it was gated on; when |
| 221 | 222 | it does not, the next is formed again on main. A gate beyond a line's first |
| 222 | 223 | 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 | ||
| 296 | 297 | (`flow/held.ts`, kept 5 s). Of the startable, the picker takes the order Next up shows |
| 297 | 298 | (#8826, `pickOrder` in `core/next_up.ts`): the owner's ranked issues by place, |
| 298 | 299 | 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 | |
| 306 | 306 | held are stalled, capped or parked twice, the planner is owed one pass with |
| 307 | 307 | that breakdown in its brief, to re-plan them into smaller, fresh issues; a |
| 308 | 308 | 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 | ||
| 500 | 500 | The week's pace (`src/crew/week.ts`) limits only the crew's own picks: the |
| 501 | 501 | builds the tick assigns under the operator's name (marked `pick` on the |
| 502 | 502 | `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 | |
| 505 | 505 | pump) run at once, apart from the picks; it is also the fewest slots a fast burn |
| 506 | 506 | slows a repository to. At the reserve's wall (no slots) everything waits for the |
| 507 | 507 | 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 | ||
| 6 | 6 | - **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. |
| 7 | 7 | - **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". |
| 8 | 8 | - **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. | |
| 10 | 10 | - **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). |
| 11 | 11 | - **Scopes** read *figuring out* (no run yet), *executing*, or *raised hand* (nothing moved for two days), or *done*. |
| 12 | 12 | - **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 | ||
| 12 | 12 | |
| 13 | 13 | ## Derived from Speed |
| 14 | 14 | |
| 15 | `pace.floor` (half of Speed, at least 1) and `queue.depth` (half of Speed rounded up, 1 to 4) follow Speed until someone sets them on **Settings › Capacity**; a stored value always wins and nothing stored is dropped. While they follow, the Speed row (page, terminal, MCP) says `(derived from Speed)` beside each, and the capacity page and its terminal screen mark the row `derived`. Because the floor was a fixed 2 before, an install with no stored floor now gets half its Speed (Speed 10: 5). That is a deliberate move of the default pace; set `pace.floor` to keep the old number. | |
| 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. | |
| 16 | 22 | |
| 17 | 23 | `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). |
src/actions/capacity.ts +6 −2
| @@ -37,6 +37,9 @@ import { signinReach, signsInWithoutEdge } from "../core/accounts.ts"; | ||
| 37 | 37 | import type { User } from "../core/users.ts"; |
| 38 | 38 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 39 | 39 | |
| 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 | ||
| 40 | 43 | /** The six guards a caller may set by `key`: the MCP tool's enum and the terminal's `e`. */ |
| 41 | 44 | export const SETTABLE_CAPACITY = [ |
| 42 | 45 | "crew.concurrency", |
| @@ -122,7 +125,7 @@ export function listCapacity( | ||
| 122 | 125 | label: CAPACITY[key].label, |
| 123 | 126 | help: CAPACITY[key].help, |
| 124 | 127 | 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", | |
| 126 | 129 | default: bootDefault(app.db, key), |
| 127 | 130 | env: CAPACITY[key].env, |
| 128 | 131 | ...changedOf(app.db, key), |
| @@ -166,11 +169,12 @@ export function changeCapacity( | ||
| 166 | 169 | value: string | null, |
| 167 | 170 | ): Result< |
| 168 | 171 | { 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" | |
| 170 | 173 | > { |
| 171 | 174 | const who = admin(actor); |
| 172 | 175 | if (!who.ok) return who; |
| 173 | 176 | if (!isCapacityKey(key)) return err("no_such_setting"); |
| 177 | if (followsSpeed(key)) return err("derived"); | |
| 174 | 178 | const next = value === null ? null : value.trim(); |
| 175 | 179 | if (next !== null && next !== "" && !validCapacity(key, next)) |
| 176 | 180 | return err("invalid_value"); |
src/actions/knobs.ts +4 −3
| @@ -9,12 +9,12 @@ import type { App } from "../app.ts"; | ||
| 9 | 9 | import { changeCapacity } from "./capacity.ts"; |
| 10 | 10 | import { updateRepoSettings } from "./repos.ts"; |
| 11 | 11 | 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"; | |
| 14 | 14 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 15 | 15 | import type { User } from "../core/users.ts"; |
| 16 | 16 | |
| 17 | export type KnobError = "not_found" | "no_such_knob" | "invalid_value"; | |
| 17 | export type KnobError = "not_found" | "no_such_knob" | "invalid_value" | "derived"; | |
| 18 | 18 | |
| 19 | 19 | const WORDS: Record<AutonomyWord, { autonomy: "off" | "autonomous"; plansNeedNod: boolean; decisionsBy: "me" | "decider" }> = { |
| 20 | 20 | off: { autonomy: "off", plansNeedNod: true, decisionsBy: "me" }, |
| @@ -31,6 +31,7 @@ export async function setKnob( | ||
| 31 | 31 | ): Promise<Result<{ knob: KnobId; value: string }, KnobError>> { |
| 32 | 32 | if (actor?.isAdmin !== true || actor.kind === "agent") return err("not_found"); |
| 33 | 33 | const v = value.trim(); |
| 34 | if (DERIVED_KNOBS.includes(knob)) return err("derived"); | |
| 34 | 35 | if (knob === "speed" || knob === "budget") { |
| 35 | 36 | const changed = changeCapacity(app, actor, knob === "speed" ? "crew.concurrency" : "week.use", v); |
| 36 | 37 | if (!changed.ok) return err(changed.error === "not_found" ? "not_found" : "invalid_value"); |
src/actions/repos.ts +1 −1
| @@ -74,7 +74,7 @@ import { | ||
| 74 | 74 | requireWriter, |
| 75 | 75 | type AccessError, |
| 76 | 76 | } from "./access.ts"; |
| 77 | import { validPolishShare } from "../crew/small_share.ts"; | |
| 77 | import { validPolishShare } from "../crew/polish_share.ts"; | |
| 78 | 78 | import { removeInstallCache } from "../crew/workdir.ts"; |
| 79 | 79 | import type { SettingsChanged } from "../core/settings_changed.ts"; |
| 80 | 80 |
src/contract/openapi/routes/putApiV1AdminCapacityKey.ts +1
| @@ -10,6 +10,7 @@ export const route: Route = { | ||
| 10 | 10 | refusals: { |
| 11 | 11 | not_found: "the token is not a person who administers the forge (404)", |
| 12 | 12 | 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)", | |
| 13 | 14 | invalid_value: "the value is outside what this setting takes (422)", |
| 14 | 15 | no_credential: "the setting needs a credential the forge does not hold (422)", |
| 15 | 16 | }, |
src/core/capacity.ts +7 −33
| @@ -28,8 +28,6 @@ export type CapacityKey = | ||
| 28 | 28 | | "children.registryEgress" |
| 29 | 29 | | "guard_new_accounts" |
| 30 | 30 | | "signin.edge" |
| 31 | | "slots.smallShare" | |
| 32 | | "polish.capMinutes" | |
| 33 | 31 | | "pace.floor"; |
| 34 | 32 | |
| 35 | 33 | interface Definition { |
| @@ -77,7 +75,7 @@ export const CAPACITY: Record<CapacityKey, Definition> = { | ||
| 77 | 75 | env: "FORGE_QUEUE_DEPTH", |
| 78 | 76 | kind: { type: "count", min: 1, max: 4 }, |
| 79 | 77 | 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.", | |
| 81 | 79 | }, |
| 82 | 80 | "queue.batch": { |
| 83 | 81 | label: "Merge queue batch size", |
| @@ -135,26 +133,12 @@ export const CAPACITY: Record<CapacityKey, Definition> = { | ||
| 135 | 133 | fallback: "on", |
| 136 | 134 | 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.", |
| 137 | 135 | }, |
| 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 | }, | |
| 152 | 136 | "pace.floor": { |
| 153 | 137 | label: "Slots the pace always leaves reviews and assigned work", |
| 154 | 138 | env: "FORGE_PACE_FLOOR", |
| 155 | 139 | kind: { type: "count", min: 1, max: 16 }, |
| 156 | 140 | 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.", | |
| 158 | 142 | }, |
| 159 | 143 | }; |
| 160 | 144 | |
| @@ -378,11 +362,11 @@ export function paceFloor(db: Database): number { | ||
| 378 | 362 | return Number(effectiveCapacity(db, "pace.floor")); |
| 379 | 363 | } |
| 380 | 364 | |
| 381 | /** The dials that follow Speed (`crew.concurrency`) until set on their own. */ | |
| 365 | /** The dials Speed (`crew.concurrency`) sets: always derived, never stored. */ | |
| 382 | 366 | export type SpeedDerivedKey = "pace.floor" | "queue.depth"; |
| 383 | 367 | |
| 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 { | |
| 369 | return key === "pace.floor" || key === "queue.depth"; | |
| 386 | 370 | } |
| 387 | 371 | |
| 388 | 372 | /** 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 { | ||
| 392 | 376 | return Math.min(4, Math.max(1, Math.ceil(speed / 2))); |
| 393 | 377 | } |
| 394 | 378 | |
| 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. */ | |
| 396 | 380 | 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); | |
| 398 | 382 | } |
| 399 | 383 | |
| 400 | 384 | /** 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 { | ||
| 456 | 440 | return Number(capacityValue(db, "queue.batch")); |
| 457 | 441 | } |
| 458 | 442 | |
| 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 | ||
| 469 | 443 | /** Whether the platform edge's identity is read at all; on every request. */ |
| 470 | 444 | export function edgeSigninOn(db: Database): boolean { |
| 471 | 445 | return capacityValue(db, "signin.edge") !== "off"; |
src/crew/autonomy.ts +5 −37
| @@ -31,10 +31,8 @@ import { queueStore } from "../checks/queue_store.ts"; | ||
| 31 | 31 | import { addLearned } from "../checks/learned.ts"; |
| 32 | 32 | import { batchWords, dropFromLine, enqueue, type GateCandidate, type Queued, type QueueOptions, queueClashes, queueWords, waitingPulls } from "../checks/queue.ts"; |
| 33 | 33 | 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"; | |
| 36 | 35 | import { crewFor as crewForIssue, passedWords, pickFilter, routable } from "./pick_filter.ts"; |
| 37 | import { tickPolish } from "./polish_cap.ts"; | |
| 38 | 36 | import { readConflictLane, readConflictRoom } from "./conflict_room.ts"; |
| 39 | 37 | import { mergeable, resolveOid, treeOf } from "../git/merge.ts"; |
| 40 | 38 | import { CLOSED_NOTE, leftNote, reopenedByPerson, unticked } from "../core/done_on_main.ts"; |
| @@ -48,7 +46,6 @@ import { | ||
| 48 | 46 | crewRole, |
| 49 | 47 | } from "../core/agents.ts"; |
| 50 | 48 | import { pickBuilder, routeIssue } from "./route.ts"; |
| 51 | import { firstAt } from "../core/first.ts"; | |
| 52 | 49 | import { fromAppIssues } from "../core/from_app.ts"; |
| 53 | 50 | import { pickOrder } from "../core/next_up.ts"; |
| 54 | 51 | import { ranksInRepo } from "../core/rank.ts"; |
| @@ -434,38 +431,12 @@ function freeSlots(app: App, repo: Repo, now: Date): number { | ||
| 434 | 431 | return Math.max(0, repoPace(app, repo, now, false).slots - slotted.length); |
| 435 | 432 | } |
| 436 | 433 | |
| 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[] { | |
| 442 | 436 | 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])]); | |
| 445 | 437 | const { allowed } = pickFilter(app, repo, ready, now); |
| 446 | 438 | // 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))); | |
| 469 | 440 | } |
| 470 | 441 | |
| 471 | 442 | export type Drove = "off" | "paused" | "waiting" | "full" | "picked" | "planned" | "released" | "idle"; |
| @@ -506,7 +477,6 @@ async function driveOnce( | ||
| 506 | 477 | ): Promise<Drove> { |
| 507 | 478 | if (repo.autonomy === "off") return "off"; |
| 508 | 479 | tickBets(app, repo, now); |
| 509 | tickPolish(app, repo, now); | |
| 510 | 480 | const status = autonomyStatus(app, repo, now); |
| 511 | 481 | if (status.paused !== null) return "paused"; |
| 512 | 482 | const by = getUserById(app.db, repo.autonomyBy!); |
| @@ -565,7 +535,7 @@ async function driveOnce( | ||
| 565 | 535 | const crewFor = (issue: Issue) => crewForIssue(app, repo, issue.id, crew, now); |
| 566 | 536 | const pickable = await withoutRetired(app, repo, routable(app, repo, ready, crew, now).kept); |
| 567 | 537 | // 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)); | |
| 569 | 539 | if (next !== undefined) { |
| 570 | 540 | taken.add(next.issue.id); |
| 571 | 541 | const routed = await routeIssue(app, repo, next.issue, crewFor(next.issue), inFlight); |
| @@ -1432,8 +1402,6 @@ export async function autonomyTick( | ||
| 1432 | 1402 | slots: status.slots, |
| 1433 | 1403 | // Builders with nothing in flight: what bounds active beside slots and startable (#7006). |
| 1434 | 1404 | idle: idleBuilders(activeNow, crewNow), |
| 1435 | // Small issues in flight of the slots they may hold (#7571). | |
| 1436 | small: smallShareWords(app, current, now), | |
| 1437 | 1405 | ...(governor === undefined ? {} : { governor }), |
| 1438 | 1406 | // Approved conflicted pulls: queued, in flight, and why each other is |
| 1439 | 1407 | // 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 @@ | ||
| 1 | 1 | /** |
| 2 | * The five knobs (#10461): Speed, Weekly budget, Polish share, Autonomy per | |
| 2 | * The knobs (#10461): Speed, Weekly budget, Polish share, Autonomy per | |
| 3 | 3 | * repository, Feedback first. Plain data out: one row each with its name, a |
| 4 | 4 | * line on what it does, the value, a control kind and its live effect. The |
| 5 | 5 | * crew page, the terminal and the MCP `knobs` tool print these same rows; |
| 6 | * `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. | |
| 9 | 10 | */ |
| 10 | 11 | |
| 11 | 12 | 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"; | |
| 13 | 14 | import { labelsForIssues } from "../core/issues.ts"; |
| 14 | 15 | import { getRepoById, type Repo } from "../core/repos.ts"; |
| 15 | 16 | import { listActive } from "../core/runs.ts"; |
| 16 | 17 | 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"; | |
| 18 | 19 | import { budgetEffect } from "./week_read.ts"; |
| 19 | 20 | |
| 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"]; | |
| 21 | 24 | export type AutonomyWord = "off" | "ask" | "go"; |
| 22 | 25 | export const AUTONOMY_WORDS: readonly AutonomyWord[] = ["off", "ask", "go"]; |
| 23 | 26 | |
| @@ -27,7 +30,7 @@ export interface KnobRow { | ||
| 27 | 30 | readonly does: string; |
| 28 | 31 | /** The value as typed back: a number, a percent's number, or one of the words. */ |
| 29 | 32 | readonly value: string; |
| 30 | readonly control: "number" | "percent" | "words" | "fixed"; | |
| 33 | readonly control: "number" | "percent" | "words" | "fixed" | "derived"; | |
| 31 | 34 | readonly min?: number; |
| 32 | 35 | readonly max?: number; |
| 33 | 36 | readonly words?: readonly string[]; |
| @@ -43,11 +46,6 @@ export function autonomyWord(repo: Repo): AutonomyWord { | ||
| 43 | 46 | return !repo.plansNeedNod && repo.decisionsBy !== "me" ? "go" : "ask"; |
| 44 | 47 | } |
| 45 | 48 | |
| 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 | ||
| 51 | 49 | function runsLine(app: App): string { |
| 52 | 50 | const active = listActive(app.db, "agent"); |
| 53 | 51 | const running = active.filter((r) => r.state === "running").length; |
| @@ -91,7 +89,23 @@ export function readKnobs(app: App, now: Date = new Date()): KnobRow[] { | ||
| 91 | 89 | control: "number", |
| 92 | 90 | min: 1, |
| 93 | 91 | 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`, | |
| 95 | 109 | }, |
| 96 | 110 | { |
| 97 | 111 | id: "budget", |
src/crew/pick_filter.ts +1 −1
| @@ -17,7 +17,7 @@ import type { Repo } from "../core/repos.ts"; | ||
| 17 | 17 | import { raisedAt } from "../core/thought_links.ts"; |
| 18 | 18 | import type { Agent, CrewRole } from "../core/agents.ts"; |
| 19 | 19 | import { mayPick } from "./bets.ts"; |
| 20 | import { isPolish, polishSpend, smallIssues } from "./small_share.ts"; | |
| 20 | import { isPolish, polishSpend, smallIssues } from "./polish_share.ts"; | |
| 21 | 21 | import { shunnedBy } from "./stalls.ts"; |
| 22 | 22 | |
| 23 | 23 | /** 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/flow/held.ts −4
| @@ -10,7 +10,6 @@ | ||
| 10 | 10 | |
| 11 | 11 | import type { App } from "../app.ts"; |
| 12 | 12 | import { listAutonomousRepos, type Repo } from "../core/repos.ts"; |
| 13 | import { smallShareWords } from "../crew/autonomy.ts"; | |
| 14 | 13 | import { admits, heldCounts, heldNow, mapHolds, type Held, type HoldName } from "../crew/held.ts"; |
| 15 | 14 | |
| 16 | 15 | export interface HeldRow { |
| @@ -21,8 +20,6 @@ export interface HeldRow { | ||
| 21 | 20 | readonly counts: Readonly<Record<HoldName, number>>; |
| 22 | 21 | /** Issues a hot file holds, with the open pulls on that file, oldest first. */ |
| 23 | 22 | 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; | |
| 26 | 23 | } |
| 27 | 24 | |
| 28 | 25 | /** The most hot-file holds a row lists. */ |
| @@ -53,7 +50,6 @@ export function readHeld(app: App, owner: boolean, now = Date.now()): HeldRow[] | ||
| 53 | 50 | public: repo.visibility === "public", |
| 54 | 51 | startable: held.startable.length, |
| 55 | 52 | counts: heldCounts(held), |
| 56 | small: smallShareWords(app, repo), | |
| 57 | 53 | hot: [...holds].flatMap(([number, h]) => |
| 58 | 54 | h.by === "hot_file" && h.pulls !== undefined && h.pulls.length > 0 |
| 59 | 55 | ? [{ 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"; | ||
| 13 | 13 | import { MAX_SECONDS, sampleStacks } from "../../perf/sample.ts"; |
| 14 | 14 | import { listAccounts, removeAccount, retireAndRemoveAccount, retirementPlan, suspendAccount, unsuspendAccount, verifyAccount } from "../../actions/accounts.ts"; |
| 15 | 15 | 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"; | |
| 17 | 17 | import { changeShield, lastShieldChange, shieldState } from "../../actions/shield.ts"; |
| 18 | 18 | import { inviteLink, list as listInviteLinks, mint as mintInviteLink, revoke as revokeInviteLink } from "../../actions/invites.ts"; |
| 19 | 19 | import { changeLimit, listLimits } from "../../actions/limits.ts"; |
| @@ -196,7 +196,9 @@ export function adminRoutes(router: Router<Handler>): void { | ||
| 196 | 196 | 422, |
| 197 | 197 | changed.error === "no_such_setting" |
| 198 | 198 | ? "no such capacity setting" |
| 199 | : SETTING_WORDS.outOfRange, | |
| 199 | : changed.error === "derived" | |
| 200 | ? DERIVED_WORDS | |
| 201 | : SETTING_WORDS.outOfRange, | |
| 200 | 202 | ); |
| 201 | 203 | }); |
| 202 | 204 |
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"; | |
| 2 | 2 | import { SETTING_WORDS } from "../../../actions/setting_words.ts"; |
| 3 | 3 | import { scopeAllows } from "../../../core/tokens.ts"; |
| 4 | 4 | import { good, bad, type Tool } from "../shared.ts"; |
| @@ -36,7 +36,7 @@ export const tool: Tool = { | ||
| 36 | 36 | if (!scopeAllows(ctx.auth.scope ?? "ro", "admin")) |
| 37 | 37 | return bad("forbidden", "changing the forge's capacity needs an admin-scope token"); |
| 38 | 38 | 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); | |
| 40 | 40 | return good({ |
| 41 | 41 | says: `${key}: ${changed.value.from || "auto"} → ${changed.value.to || "auto"}, from the next pick on.`, |
| 42 | 42 | ...changed.value, |
src/http/mcp/tools/week.ts +1 −1
| @@ -1,5 +1,5 @@ | ||
| 1 | 1 | import { takeProposal } from "../../../actions/week.ts"; |
| 2 | import { polishSpend, polishWords } from "../../../crew/small_share.ts"; | |
| 2 | import { polishSpend, polishWords } from "../../../crew/polish_share.ts"; | |
| 3 | 3 | import { latestRetro, takenOf } from "../../../flow/retro.ts"; |
| 4 | 4 | import { bad, good, type Tool, APP_ARG, planRepo, isAnswer } from "../shared.ts"; |
| 5 | 5 |
src/tui/screens/autonomy.ts +1 −1
| @@ -15,7 +15,7 @@ | ||
| 15 | 15 | */ |
| 16 | 16 | |
| 17 | 17 | 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"; | |
| 19 | 19 | import { seatSlotsSaid, setSeatSlots } from "../../actions/seat_slots.ts"; |
| 20 | 20 | import { checkSeatSlots, seatSlotsOf, seatSlotsWords } from "../../crew/seat_slots.ts"; |
| 21 | 21 | import type { CrewRole } from "../../core/agents.ts"; |
src/tui/screens/capacity.ts +5 −3
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | 8 | 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"; | |
| 10 | 10 | import { SETTING_WORDS } from "../../actions/setting_words.ts"; |
| 11 | 11 | import { CAPACITY, belowCeiling, validCapacity, type CapacityKey } from "../../core/capacity.ts"; |
| 12 | 12 | import type { Ctx, Screen } from "../app/app.ts"; |
| @@ -28,8 +28,10 @@ export function boundsOf(key: CapacityKey): string { | ||
| 28 | 28 | } |
| 29 | 29 | |
| 30 | 30 | /** 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" | |
| 33 | 35 | ? "Set a password or a passkey first: turning the edge off would lock you out" |
| 34 | 36 | : error === "invalid_value" |
| 35 | 37 | ? SETTING_WORDS.outOfRange |
src/tui/screens/flow.ts +1 −2
| @@ -241,7 +241,7 @@ export function openerLines(rows: readonly Opener[], inner: number): string[] { | ||
| 241 | 241 | return rows.length > 5 ? [...lines, `and ${rows.length - 5} more`] : lines; |
| 242 | 242 | } |
| 243 | 243 | |
| 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. */ | |
| 245 | 245 | export function heldLines(rows: readonly HeldRow[], inner: number): string[] { |
| 246 | 246 | if (rows.length === 0) return ["Ready, and why it waits: no repository runs on its own."]; |
| 247 | 247 | return [ |
| @@ -252,7 +252,6 @@ export function heldLines(rows: readonly HeldRow[], inner: number): string[] { | ||
| 252 | 252 | r.repo, |
| 253 | 253 | `startable=${r.startable}`, |
| 254 | 254 | ...HOLDS.filter((h) => r.counts[h] > 0).map((h) => `${h}=${r.counts[h]}`), |
| 255 | `small ${r.small}`, | |
| 256 | 255 | ].join(" "), |
| 257 | 256 | inner, |
| 258 | 257 | ), |
src/tui/screens/knobs.ts +1
| @@ -42,6 +42,7 @@ export function knobsScreen(f: Session, ctx: Ctx): Screen { | ||
| 42 | 42 | if (e.key !== "e") return state.key(e, rows.length); |
| 43 | 43 | const row = rows[state.index]; |
| 44 | 44 | if (row === undefined) return true; |
| 45 | if (row.control === "derived") return ctx.toast(`${row.name} follows Speed.`, "success"), true; | |
| 45 | 46 | if (row.control === "fixed") return ctx.toast(`${row.name} is always on.`, "success"), true; |
| 46 | 47 | later(ctx, () => |
| 47 | 48 | promptScreen(ctx, screen, { |
src/tui/screens/week.ts +1 −1
| @@ -11,7 +11,7 @@ | ||
| 11 | 11 | |
| 12 | 12 | import { takeProposal } from "../../actions/week.ts"; |
| 13 | 13 | 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"; | |
| 15 | 15 | import { latestRetro, takenOf, type Retro } from "../../flow/retro.ts"; |
| 16 | 16 | import type { Ctx, Screen } from "../app/app.ts"; |
| 17 | 17 | import { binding } from "../app/keys.ts"; |
src/web/pages/board.ts −4
| @@ -83,10 +83,8 @@ import { | ||
| 83 | 83 | autonomyStatus, |
| 84 | 84 | holdWords, |
| 85 | 85 | plannerHold, |
| 86 | smallShareOf, | |
| 87 | 86 | startOfTodayUtc, |
| 88 | 87 | } from "../../crew/autonomy.ts"; |
| 89 | import { smallIssuesWords } from "../../crew/small_share.ts"; | |
| 90 | 88 | import { distinct, notesRegion, type Note } from "./board-notes.ts"; |
| 91 | 89 | import type { Ctx } from "../../http/context.ts"; |
| 92 | 90 | import { repoBase, signInFirst } from "../chrome.ts"; |
| @@ -1043,8 +1041,6 @@ function today(ctx: Ctx, scope: BoardScope): Html { | ||
| 1043 | 1041 | <span><b>${shipped}</b> shipped</span>`, |
| 1044 | 1042 | `${perShippedWords(week.get(repo.id))} this week`, |
| 1045 | 1043 | holdLine, |
| 1046 | // Small issues in flight of the slots they may hold (#7571). | |
| 1047 | repo.autonomy === "off" ? null : smallIssuesWords(smallShareOf(ctx.app, repo, now)), | |
| 1048 | 1044 | review, |
| 1049 | 1045 | ], |
| 1050 | 1046 | under: meter( |
src/web/pages/capacity.ts +6 −3
| @@ -6,7 +6,7 @@ | ||
| 6 | 6 | * and each one is listed below the settings: who, when, from what to what. |
| 7 | 7 | */ |
| 8 | 8 | |
| 9 | import { changeCapacity, edgeOffDryRun, listCapacity, type CapacityRow } from "../../actions/capacity.ts"; | |
| 9 | import { DERIVED_WORDS, changeCapacity, edgeOffDryRun, listCapacity, type CapacityRow } from "../../actions/capacity.ts"; | |
| 10 | 10 | import { CAPACITY, belowCeiling, type CapacityKey } from "../../core/capacity.ts"; |
| 11 | 11 | import { bytes } from "../../system/format.ts"; |
| 12 | 12 | import { egressRowsFor } from "../../spawn/process_counters.ts"; |
| @@ -102,10 +102,12 @@ export function capacityPage(ctx: Ctx): Response { | ||
| 102 | 102 | <span class="mono">${s.default === "" ? "auto" : s.default}</span> |
| 103 | 103 | (<span class="mono">${s.env}</span>).` |
| 104 | 104 | : 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.` | |
| 106 | 106 | : html`The boot default, from <span class="mono">${s.env}</span>.`} |
| 107 | 107 | ${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 | |
| 109 | 111 | class="row-fields" |
| 110 | 112 | method="post" |
| 111 | 113 | action="/settings/capacity" |
| @@ -240,6 +242,7 @@ export function capacityPage(ctx: Ctx): Response { | ||
| 240 | 242 | |
| 241 | 243 | const ERRORS: Record<string, string> = { |
| 242 | 244 | no_such_setting: "There is no such setting.", |
| 245 | derived: DERIVED_WORDS, | |
| 243 | 246 | invalid_value: "That value is outside what this setting takes.", |
| 244 | 247 | no_credential: |
| 245 | 248 | "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"; | ||
| 16 | 16 | |
| 17 | 17 | function control(r: KnobRow): Html { |
| 18 | 18 | 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>`; | |
| 19 | 20 | const label = `${r.name}${r.repo === undefined ? "" : ` for ${r.repo}`}`; |
| 20 | 21 | const input = |
| 21 | 22 | r.control === "words" |
| @@ -35,7 +36,7 @@ export function knobsCard(ctx: Ctx): Html { | ||
| 35 | 36 | const rows = readKnobs(ctx.app); |
| 36 | 37 | return html`<section id="knobs"> |
| 37 | 38 | <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> | |
| 39 | 40 | <dl class="status-rows"> |
| 40 | 41 | ${rows.map( |
| 41 | 42 | (r) => html`<div class="status-row" data-knob="${r.id}"> |
| @@ -62,6 +63,9 @@ export async function knobSet(ctx: Ctx): Promise<Response> { | ||
| 62 | 63 | "/settings/crew", |
| 63 | 64 | set.ok |
| 64 | 65 | ? { 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 | }, | |
| 66 | 70 | ); |
| 67 | 71 | } |
src/web/pages/settings.ts +1 −1
| @@ -8,7 +8,7 @@ | ||
| 8 | 8 | * same-origin POST and every one of them says what happened. |
| 9 | 9 | */ |
| 10 | 10 | |
| 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"; | |
| 12 | 12 | import { appLimitWords } from "../../actions/app_limit.ts"; |
| 13 | 13 | import { showProvenance } from "../../actions/provenance.ts"; |
| 14 | 14 | import { getProvenance, parentUrl, sourceLabel } from "../../core/provenance.ts"; |
src/web/pages/week.ts +1 −1
| @@ -5,7 +5,7 @@ | ||
| 5 | 5 | * and nothing else on the page changes anything. |
| 6 | 6 | */ |
| 7 | 7 | import { takeProposal } from "../../actions/week.ts"; |
| 8 | import { polishSpend } from "../../crew/small_share.ts"; | |
| 8 | import { polishSpend } from "../../crew/polish_share.ts"; | |
| 9 | 9 | import { canWrite, type Repo } from "../../core/repos.ts"; |
| 10 | 10 | import { notePhase, takeSessionTime } from "../../core/route_timing.ts"; |
| 11 | 11 | 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"; | ||
| 31 | 31 | import { canAdmin, canSeeOwnerFacts, type Repo } from "../../core/repos.ts"; |
| 32 | 32 | import { seatsOf, type Seat } from "../../crew/seats.ts"; |
| 33 | 33 | import { crewHasCredential } from "../../core/subscriptions.ts"; |
| 34 | import { autonomyStatus, smallShareWords } from "../../crew/autonomy.ts"; | |
| 34 | import { autonomyStatus } from "../../crew/autonomy.ts"; | |
| 35 | 35 | import { governorSeen } from "../../crew/governed.ts"; |
| 36 | 36 | import { crewOffInPreview } from "../../crew/preview.ts"; |
| 37 | 37 | import type { Ctx } from "../../http/context.ts"; |
| @@ -298,8 +298,6 @@ function autonomyCard(ctx: Ctx, repo: Repo, base: string, admin: boolean): Html | ||
| 298 | 298 | </dd> |
| 299 | 299 | <dt>Slots</dt> |
| 300 | 300 | <dd>${status.active} of ${status.slots} in use</dd> |
| 301 | <dt>Small issues</dt> | |
| 302 | <dd>${smallShareWords(ctx.app, repo)} slots</dd> | |
| 303 | 301 | ${governor === undefined |
| 304 | 302 | ? null |
| 305 | 303 | : html`<dt>Governor</dt> |
test/knobs.test.ts +60 −24
| @@ -7,11 +7,12 @@ import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"; | ||
| 7 | 7 | import { mkdtemp, rm } from "node:fs/promises"; |
| 8 | 8 | import { tmpdir } from "node:os"; |
| 9 | 9 | import { join } from "node:path"; |
| 10 | import { changeCapacity } from "../src/actions/capacity.ts"; | |
| 10 | 11 | import { createRepo } from "../src/actions/repos.ts"; |
| 11 | 12 | import { setKnob } from "../src/actions/knobs.ts"; |
| 12 | 13 | import { createApp, type App } from "../src/app.ts"; |
| 13 | 14 | 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"; | |
| 15 | 16 | import { listEvents } from "../src/core/events.ts"; |
| 16 | 17 | import { getRepoById } from "../src/core/repos.ts"; |
| 17 | 18 | import { createUser, type User } from "../src/core/users.ts"; |
| @@ -47,16 +48,16 @@ afterAll(async () => { | ||
| 47 | 48 | await rm(dir, { recursive: true, force: true }); |
| 48 | 49 | }); |
| 49 | 50 | |
| 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", () => { | |
| 51 | 52 | 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"]); | |
| 53 | 54 | for (const r of rows) { |
| 54 | 55 | expect(r.does.length).toBeGreaterThan(0); |
| 55 | 56 | expect(r.effect.length).toBeGreaterThan(0); |
| 56 | 57 | } |
| 57 | 58 | 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); | |
| 60 | 61 | }); |
| 61 | 62 | |
| 62 | 63 | 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 () => { | ||
| 69 | 70 | expect(listEvents(app.db, {}).some((e) => e.kind === "capacity.changed")).toBe(true); |
| 70 | 71 | }); |
| 71 | 72 | |
| 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 | } | |
| 75 | 91 | }); |
| 76 | 92 | |
| 77 | 93 | 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 | ||
| 131 | 147 | expect(queueDepth(app.db)).toBe(1); |
| 132 | 148 | }); |
| 133 | 149 | |
| 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", () => { | |
| 135 | 151 | setCapacity(app.db, "crew.concurrency", "6", admin.id); |
| 136 | 152 | setCapacity(app.db, "pace.floor", "2", admin.id); |
| 137 | 153 | 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); | |
| 141 | 156 | }); |
| 142 | 157 | |
| 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 () => { | |
| 144 | 169 | setCapacity(app.db, "crew.concurrency", "6", admin.id); |
| 145 | 170 | const rows = listCapacity(app, admin); |
| 146 | 171 | 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 | } | |
| 157 | 182 | // The MCP tool prints the same rows, in the same words. |
| 158 | 183 | 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 | } | |
| 160 | 196 | }); |
test/mcp-next-up.test.ts +3 −3
| @@ -106,7 +106,7 @@ describe("next_up over MCP", () => { | ||
| 106 | 106 | }; |
| 107 | 107 | |
| 108 | 108 | 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); | |
| 110 | 110 | |
| 111 | 111 | 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 () => { |
| 112 | 112 | const { repo, numbers } = await orderRepo(); |
| @@ -116,11 +116,11 @@ describe("next_up over MCP", () => { | ||
| 116 | 116 | const before = await listed(); |
| 117 | 117 | expect(before.slice(0, 2)).toEqual([numbers[3]!, numbers[2]!]); |
| 118 | 118 | 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. | |
| 121 | 120 | await tool(rw, { app: "alice/order", issue: numbers[4], to: "top" }); |
| 122 | 121 | const after = await listed(); |
| 123 | 122 | expect(after[0]).toBe(numbers[4]!); |
| 124 | 123 | expect(picked(repo, 0)).toEqual(after); |
| 124 | expect(after).toContain(numbers[4]!); | |
| 125 | 125 | }); |
| 126 | 126 | }); |
test/next-up.test.ts +1 −14
| @@ -261,19 +261,6 @@ describe("the ranked list is bounded", () => { | ||
| 261 | 261 | }); |
| 262 | 262 | }); |
| 263 | 263 | |
| 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 | ||
| 277 | 264 | describe("the picker follows the page (#8826)", () => { |
| 278 | 265 | test("with two ranked and three unranked issues, five ticks start the page's rows top to bottom", async () => { |
| 279 | 266 | const { inSlotOrder } = await import("../src/crew/autonomy.ts"); |
| @@ -305,7 +292,7 @@ describe("the picker follows the page (#8826)", () => { | ||
| 305 | 292 | const ready = readyNow(repoGraph(app.db, repo.id, { sealed: true })).filter( |
| 306 | 293 | (x) => !started.includes(x.issue.number), |
| 307 | 294 | ); |
| 308 | const next = inSlotOrder(app, repo, ready, [], 5)[0]!; | |
| 295 | const next = inSlotOrder(app, repo, ready)[0]!; | |
| 309 | 296 | started.push(next.issue.number); |
| 310 | 297 | } |
| 311 | 298 | expect(started).toEqual(page); |
test/pace-floor.test.ts +8 −5
| @@ -4,13 +4,13 @@ | ||
| 4 | 4 | * pace sentence, and at the reserve's wall everything waits (#8962). |
| 5 | 5 | */ |
| 6 | 6 | |
| 7 | import { afterAll, beforeAll, expect, test } from "bun:test"; | |
| 7 | import { afterAll, afterEach, beforeAll, expect, test } from "bun:test"; | |
| 8 | 8 | import { addToCrew } from "../src/actions/crew.ts"; |
| 9 | 9 | import { assignIssue, openIssue } from "../src/actions/issues.ts"; |
| 10 | 10 | import { commitToBranch, createRepo, updateRepoSettings } from "../src/actions/repos.ts"; |
| 11 | 11 | import { paceFloor, setCapacity } from "../src/core/capacity.ts"; |
| 12 | 12 | 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"; | |
| 14 | 14 | import { noteMeasure } from "../src/core/measure.ts"; |
| 15 | 15 | import { setHeadroom } from "../src/core/standing.ts"; |
| 16 | 16 | import { addSubscription } from "../src/core/subscriptions.ts"; |
| @@ -23,10 +23,13 @@ let standIn: StandInCrew; | ||
| 23 | 23 | |
| 24 | 24 | beforeAll(async () => { |
| 25 | 25 | standIn = await startStandInCrew({ tickMs: 50 }); |
| 26 | setCapacity(standIn.app.db, "pace.floor", "2", standIn.alice.id); | |
| 27 | setCapacity(standIn.app.db, "crew.concurrency", "10", standIn.alice.id); | |
| 26 | setCapacity(standIn.app.db, "crew.concurrency", "4", standIn.alice.id); | |
| 28 | 27 | }, 30_000); |
| 29 | 28 | 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 | }); | |
| 30 | 33 | |
| 31 | 34 | async function until(done: () => boolean, what: string, ms = 20_000): Promise<void> { |
| 32 | 35 | const deadline = Date.now() + ms; |
| @@ -72,7 +75,7 @@ function assigned(repo: Repo, title: string, pick: boolean): string { | ||
| 72 | 75 | return run.id; |
| 73 | 76 | } |
| 74 | 77 | |
| 75 | test("the floor is a config value, 2 by default", () => { | |
| 78 | test("the floor follows Speed: half of 4 is 2", () => { | |
| 76 | 79 | expect(paceFloor(standIn.app.db)).toBe(2); |
| 77 | 80 | }); |
| 78 | 81 |
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/settings-visible.test.ts +4 −2
| @@ -80,10 +80,12 @@ test("every capacity row shows its value and control, hover-free", async () => { | ||
| 80 | 80 | const listed = listCapacity(app, admin); |
| 81 | 81 | if (!listed.ok) throw new Error("no capacity"); |
| 82 | 82 | 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)); | |
| 84 | 86 | expect(row, s.key).toBeDefined(); |
| 85 | 87 | expect(row).toContain(s.label); |
| 86 | expect(row).toContain("Now <span"); | |
| 88 | if (s.source !== "derived") expect(row).toContain("Now <span"); | |
| 87 | 89 | expect(row).not.toContain("row-actions"); |
| 88 | 90 | } |
| 89 | 91 | }); |
test/tui-capacity.test.ts +4 −3
| @@ -134,14 +134,15 @@ describe("tui capacity", () => { | ||
| 134 | 134 | h.stop(); |
| 135 | 135 | }); |
| 136 | 136 | |
| 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"); | |
| 138 | 139 | const h = boot(carol); |
| 139 | 140 | for (let i = 0; i < CAPACITY_KEYS.indexOf("queue.depth"); i++) await h.press("j"); |
| 140 | 141 | await h.press("e"); |
| 141 | 142 | for (let i = 0; i < 4; i++) await h.press("backspace"); |
| 142 | await h.press("4", "enter"); | |
| 143 | await h.press("9", "enter"); | |
| 143 | 144 | await h.press("left", "enter"); |
| 144 | expect(capacityValue(app.db, "queue.depth")).toBe("4"); | |
| 145 | expect(capacityValue(app.db, "queue.depth")).toBe(before); | |
| 145 | 146 | h.stop(); |
| 146 | 147 | }); |
| 147 | 148 |
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 | ||
| 78 | 78 | if (!sub.ok) throw new Error(sub.error); |
| 79 | 79 | setHeadroom(app.db, sub.value.id, 0); |
| 80 | 80 | app.db.query("INSERT INTO subscription_grant (scope_id, agent_id, subscription_id) VALUES (?1, '', ?2)").run(made.value.id, sub.value.id); |
| 81 | 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 | |
| 83 | 82 | const now = Date.now(); |
| 84 | 83 | 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. | |
| 86 | 85 | 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()); |
| 87 | 86 | const on = await updateRepoSettings(app, alice, made.value, { autonomy: "autonomous", autonomyDirection: "Ship.", autonomyCeilingUsd: 1000, autonomySlots: 4, autonomyBy: alice.id }); |
| 88 | 87 | 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 | ||
| 100 | 99 | }; |
| 101 | 100 | const stateOf = (id: string) => getRun(app.db, id)!.state; |
| 102 | 101 | 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); | |
| 106 | 105 | run("review", true); |
| 107 | 106 | run("assigned", true); |
| 108 | 107 | const build = run("push", false); |
| 109 | 108 | const review = run("review", false); |
| 110 | 109 | 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"); | |
| 112 | 111 | expect(stateOf(build)).toBe("queued"); |
| 113 | 112 | expect(stateOf(review)).toBe("queued"); |
| 114 | 113 | // 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 () => { | ||
| 83 | 83 | const a = await seedAdmin(app, { username: "alice", email: "[email protected]", password: "a-long-passphrase" }); |
| 84 | 84 | if (!a.ok) throw new Error("signup failed"); |
| 85 | 85 | alice = a.value; |
| 86 | setCapacity(app.db, "pace.floor", "2", alice.id); | |
| 86 | setCapacity(app.db, "crew.concurrency", "4", alice.id); // pace floor follows Speed: half of 4 is 2 | |
| 87 | 87 | const made = await createRepo(app, alice, { name: "svc", visibility: "private" }); |
| 88 | 88 | if (!made.ok) throw new Error(String(made.error)); |
| 89 | 89 | repo = made.value; |
| @@ -120,7 +120,7 @@ afterAll(async () => { | ||
| 120 | 120 | beforeEach(() => forgetWeek()); |
| 121 | 121 | // A failed expectation must not leave the lowered crew concurrency for later tests. |
| 122 | 122 | afterEach(() => { |
| 123 | changeCapacity(app, alice, "crew.concurrency", null); | |
| 123 | changeCapacity(app, alice, "crew.concurrency", "4"); | |
| 124 | 124 | }); |
| 125 | 125 | |
| 126 | 126 | 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 | ||
| 196 | 196 | }); |
| 197 | 197 | const cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0] ?? ""; |
| 198 | 198 | 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(); | |
| 199 | 201 | expect(await spend()).not.toContain("below its ceiling"); |
| 200 | 202 | expect(changeCapacity(app, alice, "crew.concurrency", "1").ok).toBe(true); |
| 201 | 203 | forgetWeek(); |
new above
offline
Unused
_dbparameter and a function that is now a constant check. Drop_db, and fix the comments above it ("until set on their own").