Public: anyone can read this; the crew can change it.
Team settings: how much of each pillar, in one clear place #8690
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/7668-team-settings-how-much-of-each-pillar-in → main 3 commits fork 4 threads · 4 unresolved
21 files
changed +578
−35
docs/design/areas/settings.md +4
| @@ -33,3 +33,7 @@ The terminal's Labels screen (`src/tui/screens/labels.ts`) shows the same counts | ||
| 33 | 33 | |
| 34 | 34 | Settings → Confirms (`src/web/pages/confirms.ts`, #8230) is a `listCard()` of `row()`s that fold (`fold`) open on the call's full |
| 35 | 35 | arguments in a `messageText()` block, with the Yes form beside the row (`after`); no new class. |
| 36 | ||
| 37 | ## Team card: a seat's number | |
| 38 | ||
| 39 | Each seat row (`row`) holds one child row: its title says how much the seat may do at once ("2 builds at once", "Paused"), its meta says what it is doing and spending, and its trail holds a one-line form: a number `input input-sm` (the same small control as the Crew card's role select, in a `row-fields` form) and a small Set button. No new class. At phone width the row wraps as every row does; the form stays on one line because it is a number and a button. |
docs/features/seat-slots.md added +31
| @@ -0,0 +1,31 @@ | ||
| 1 | # How much each seat may do at once (#7668) | |
| 2 | ||
| 3 | Settings › Team shows three seats: planner, builder, reviewer. Each has a number: how much it may do at once. **0 pauses the seat without taking its agent off the crew.** Under each seat is what it is doing now (running, waiting) and today's spend with its share (UTC day). | |
| 4 | ||
| 5 | | Seat | Number | What it bounds | At 0 | | |
| 6 | |---|---|---|---| | |
| 7 | | Planner | 0–1 (`plannerSlots`) | planning passes | the planner is not asked, and the forge does not lend one | | |
| 8 | | Builder | 0–8 (`autonomySlots`) | builds at once | nothing new is picked; what runs is not cut | | |
| 9 | | Reviewer | 0–8 (`reviewerSlots`) | reviews in flight (8, the default, is no cap) | no review is sent; an agent's pull waits for a person, as with no reviewer | | |
| 10 | ||
| 11 | A change takes effect on the next tick and is a `repo.settings_updated` line on the trail, `{ from, to }` per number. A repository admin's call, as the autonomy form is; over the MCP an agent is refused. | |
| 12 | ||
| 13 | **Terminal:** the repository's Autonomy screen shows the three seats; `p`, `s`, `r` set the planner, builder and reviewer, gated like the page (`canAdmin`). | |
| 14 | ||
| 15 | **MCP:** `crew { repo, seat, slots, dryRun? }` plans first (`dryRun` true unless `false`; admin token to apply). `crew { repo }` returns `slots[{ role, slots, max, running, waiting, spentUsd, sharePct }]` beside `seats` and `governor`. | |
| 16 | ||
| 17 | **Web:** `POST /:owner/:name/settings/pillars` with `seat` and `slots`, same-origin, admin only. | |
| 18 | ||
| 19 | ## Review of the crew settings | |
| 20 | ||
| 21 | | Setting | Decision | | |
| 22 | |---|---| | |
| 23 | | Builder slots (the "Slots" field of On its own) | **Folded** into the builder seat's number. The field is gone from that form; the form keeps the value when it is not sent. | | |
| 24 | | Planner on or off by removing it from the crew | **Replaced** by the planner's number; pausing is no longer removal. | | |
| 25 | | Mode (off, assisted, autonomous) | Keep: the one switch for whether the team works alone. | | |
| 26 | | Direction | Keep: what the planner plans against. | | |
| 27 | | Ceiling per day | Keep: the only dollar limit. | | |
| 28 | | Decisions here, Plans need a nod, Outsiders may trigger the crew | Keep: each is who answers, and none holds work silently. | | |
| 29 | | File holds, team dials, subscriptions | Not reviewed here; they are other pages' settings. | | |
| 30 | ||
| 31 | Not done: no other setting was deleted, and the governor's automatic lowering of builder slots is unchanged (it can only go below the number set here, never above). |
src/actions/owner_waits.ts +5 −4
| @@ -15,7 +15,7 @@ import type { Database } from "bun:sqlite"; | ||
| 15 | 15 | |
| 16 | 16 | export type OwnerWait = |
| 17 | 17 | | { kind: "declined"; repo: Repo; issue: Issue; runId: string } |
| 18 | | { kind: "review"; repo: Repo; none: boolean; waiting: number; since: string | null }; | |
| 18 | | { kind: "review"; repo: Repo; none: boolean; paused: boolean; waiting: number; since: string | null }; | |
| 19 | 19 | |
| 20 | 20 | /** The planner declines among these repositories, one per app. */ |
| 21 | 21 | export function declinedWaits(db: Database, repos: readonly Repo[]): OwnerWait[] { |
| @@ -51,10 +51,11 @@ export function reviewWaits(db: Database, repos: readonly Repo[]): OwnerWait[] { | ||
| 51 | 51 | const w = waitsForPerson(db, repo.id, p); |
| 52 | 52 | return w === undefined ? [] : [w]; |
| 53 | 53 | }); |
| 54 | const paused = repo.reviewerSlots === 0; | |
| 54 | 55 | const none = reviewersOf(db, repo.id).length === 0; |
| 55 | if (!none && waits.length === 0) return []; | |
| 56 | if (!none && !paused && waits.length === 0) return []; | |
| 56 | 57 | return [ |
| 57 | { kind: "review", repo, none, waiting: waits.length, since: waits.map((w) => w.since).sort()[0] ?? null }, | |
| 58 | { kind: "review", repo, none, paused, waiting: waits.length, since: waits.map((w) => w.since).sort()[0] ?? null }, | |
| 58 | 59 | ]; |
| 59 | 60 | }); |
| 60 | 61 | } |
| @@ -68,7 +69,7 @@ export function ownerWaits(db: Database, repos: readonly Repo[]): OwnerWait[] { | ||
| 68 | 69 | export function waitLine(w: OwnerWait): string { |
| 69 | 70 | if (w.kind === "declined") |
| 70 | 71 | return `${w.repo.name}: the planner declined the direction and waits for you (issue #${w.issue.number})`; |
| 71 | return `${w.repo.name}: ${w.none ? "no reviewer: agent pulls wait for you" : "agent pulls wait for your review"}${ | |
| 72 | return `${w.repo.name}: ${w.none ? "no reviewer: agent pulls wait for you" : w.paused ? "the reviewer is paused: agent pulls wait for you" : "agent pulls wait for your review"}${ | |
| 72 | 73 | w.waiting === 0 || w.since === null ? "" : ` · ${w.waiting} waiting since ${w.since.slice(0, 10)}` |
| 73 | 74 | }`; |
| 74 | 75 | } |
src/actions/repos.ts +3
| @@ -346,6 +346,9 @@ const AUDITED = [ | ||
| 346 | 346 | "crewOutsiders", |
| 347 | 347 | "decisionsBy", |
| 348 | 348 | "plansNeedNod", |
| 349 | "autonomySlots", | |
| 350 | "plannerSlots", | |
| 351 | "reviewerSlots", | |
| 349 | 352 | ] as const; |
| 350 | 353 | |
| 351 | 354 | export type { SettingsChanged }; |
src/actions/seat_slots.ts added +33
| @@ -0,0 +1,33 @@ | ||
| 1 | /** | |
| 2 | * Set how much one seat (planner, builder, reviewer) may do at once (#7668): | |
| 3 | * the web form and the MCP `crew` tool both come here. A repository admin's | |
| 4 | * call (`updateRepoSettings` decides), as the autonomy form is; the MCP `crew` | |
| 5 | * tool adds its own refusal of an agent. It takes effect on the next tick (the picker reads | |
| 6 | * the repository row) and is on the trail as a `repo.settings_updated` line, | |
| 7 | * from what to what. | |
| 8 | */ | |
| 9 | ||
| 10 | import type { App } from "../app.ts"; | |
| 11 | import type { CrewRole } from "../core/agents.ts"; | |
| 12 | import type { Repo } from "../core/repos.ts"; | |
| 13 | import type { User } from "../core/users.ts"; | |
| 14 | import { err, ok, type Result } from "../kernel/result.ts"; | |
| 15 | import { updateRepoSettings } from "./repos.ts"; | |
| 16 | ||
| 17 | export async function setSeatSlots( | |
| 18 | app: App, | |
| 19 | actor: User | undefined, | |
| 20 | repo: Repo, | |
| 21 | role: CrewRole, | |
| 22 | slots: number, | |
| 23 | ): Promise<Result<Repo, "not_found">> { | |
| 24 | const key = role === "planner" ? "plannerSlots" : role === "reviewer" ? "reviewerSlots" : "autonomySlots"; | |
| 25 | const done = await updateRepoSettings(app, actor, repo, { [key]: slots }); | |
| 26 | return done.ok ? ok(done.value) : err("not_found"); | |
| 27 | } | |
| 28 | ||
| 29 | /** What a person is told. */ | |
| 30 | export function seatSlotsSaid(role: CrewRole, slots: number): string { | |
| 31 | if (slots === 0) return `The ${role} is paused: it stays on the crew and starts nothing until you raise it.`; | |
| 32 | return `The ${role} may do ${String(slots)} at once.`; | |
| 33 | } |
src/core/migration-pins/20261102190000-pillar-slots.pin added +1
| @@ -0,0 +1,1 @@ | ||
| 1 | c91da4f255e4170c8d5810e8839a4f65ed496279ed4da27d43a03639d922abaa |
src/core/migrations/20261102190000-pillar-slots.ts added +8
| @@ -0,0 +1,8 @@ | ||
| 1 | // How much each pillar may do at once (#7668): the planner and the reviewer | |
| 2 | // get a number beside the builders' `autonomy_slots`, and 0 pauses a pillar | |
| 3 | // without taking its agent off the crew. Defaults keep every repository as it | |
| 4 | // was: one planning pass, reviews as many as the builders can open (8). Additive. | |
| 5 | export default ` | |
| 6 | ALTER TABLE repo ADD COLUMN planner_slots INTEGER NOT NULL DEFAULT 1; | |
| 7 | ALTER TABLE repo ADD COLUMN reviewer_slots INTEGER NOT NULL DEFAULT 8; | |
| 8 | `; |
src/core/repos.ts +19 −4
| @@ -84,7 +84,7 @@ export const isAutonomy = (value: unknown): value is Autonomy => | ||
| 84 | 84 | |
| 85 | 85 | /** What the settings page and the terminal say when autonomy input is out of bounds. */ |
| 86 | 86 | export const AUTONOMY_BOUNDS = |
| 87 | "Mode is off, assisted or autonomous; the ceiling is a number of dollars; slots are 1 to 8."; | |
| 87 | "Mode is off, assisted or autonomous; the ceiling is a number of dollars; slots are 0 (paused) to 8."; | |
| 88 | 88 | |
| 89 | 89 | /** What each mode does, in one sentence: the settings page's flash and the terminal's toast. */ |
| 90 | 90 | export const AUTONOMY_SAID: Readonly<Record<Autonomy, string>> = { |
| @@ -104,13 +104,16 @@ export function checkAutonomy( | ||
| 104 | 104 | !Number.isFinite(ceiling) || |
| 105 | 105 | ceiling < 0 || |
| 106 | 106 | !Number.isInteger(slots) || |
| 107 | slots < 1 || | |
| 108 | slots > 8 | |
| 107 | slots < 0 || | |
| 108 | slots > PILLAR_MAX | |
| 109 | 109 | ) |
| 110 | 110 | return { ok: false, message: AUTONOMY_BOUNDS }; |
| 111 | 111 | return { ok: true, mode }; |
| 112 | 112 | } |
| 113 | 113 | |
| 114 | /** The most any pillar may be set to at once. */ | |
| 115 | export const PILLAR_MAX = 8; | |
| 116 | ||
| 114 | 117 | export interface Repo { |
| 115 | 118 | readonly id: string; |
| 116 | 119 | readonly ownerId: string; |
| @@ -140,6 +143,10 @@ export interface Repo { | ||
| 140 | 143 | readonly autonomyDirection: string; |
| 141 | 144 | readonly autonomyCeilingUsd: number; |
| 142 | 145 | readonly autonomySlots: number; |
| 146 | /** Planning passes at once: 0 pauses the planner, 1 is the most. */ | |
| 147 | readonly plannerSlots: number; | |
| 148 | /** Reviews at once: 0 pauses the reviewer. */ | |
| 149 | readonly reviewerSlots: number; | |
| 143 | 150 | readonly autonomyBy: string | null; |
| 144 | 151 | /** |
| 145 | 152 | * "Outsiders may trigger the crew": whether someone who cannot write here |
| @@ -195,6 +202,8 @@ interface RepoRow { | ||
| 195 | 202 | autonomy_direction: string; |
| 196 | 203 | autonomy_ceiling_usd: number; |
| 197 | 204 | autonomy_slots: number; |
| 205 | planner_slots: number; | |
| 206 | reviewer_slots: number; | |
| 198 | 207 | autonomy_by: string | null; |
| 199 | 208 | crew_outsiders: number; |
| 200 | 209 | decisions_by: string; |
| @@ -235,6 +244,8 @@ function toRepo(row: RepoRow): Repo { | ||
| 235 | 244 | autonomyDirection: row.autonomy_direction, |
| 236 | 245 | autonomyCeilingUsd: row.autonomy_ceiling_usd, |
| 237 | 246 | autonomySlots: row.autonomy_slots, |
| 247 | plannerSlots: row.planner_slots, | |
| 248 | reviewerSlots: row.reviewer_slots, | |
| 238 | 249 | autonomyBy: row.autonomy_by, |
| 239 | 250 | crewOutsiders: row.crew_outsiders === 1, |
| 240 | 251 | decisionsBy: isDecisionsBy(row.decisions_by) ? row.decisions_by : "me", |
| @@ -1195,6 +1206,8 @@ export interface RepoSettings { | ||
| 1195 | 1206 | autonomyDirection?: string; |
| 1196 | 1207 | autonomyCeilingUsd?: number; |
| 1197 | 1208 | autonomySlots?: number; |
| 1209 | plannerSlots?: number; | |
| 1210 | reviewerSlots?: number; | |
| 1198 | 1211 | /** Who the forge acts as when it assigns and lands on its own. */ |
| 1199 | 1212 | autonomyBy?: string | null; |
| 1200 | 1213 | /** Whether someone who cannot write here may start or steer a run. */ |
| @@ -1226,7 +1239,7 @@ export function updateRepoSettings( | ||
| 1226 | 1239 | default_branch = ?4, require_review = ?5, require_checks = ?7, |
| 1227 | 1240 | autonomy = ?8, autonomy_direction = ?9, autonomy_ceiling_usd = ?10, |
| 1228 | 1241 | autonomy_slots = ?11, autonomy_by = ?12, crew_outsiders = ?13, decisions_by = ?14, |
| 1229 | plans_need_nod = ?15 | |
| 1242 | plans_need_nod = ?15, planner_slots = ?16, reviewer_slots = ?17 | |
| 1230 | 1243 | WHERE id = ?6`, |
| 1231 | 1244 | ).run( |
| 1232 | 1245 | settings.visibility ?? repo.visibility, |
| @@ -1244,6 +1257,8 @@ export function updateRepoSettings( | ||
| 1244 | 1257 | (settings.crewOutsiders ?? repo.crewOutsiders) ? 1 : 0, |
| 1245 | 1258 | settings.decisionsBy ?? repo.decisionsBy, |
| 1246 | 1259 | (settings.plansNeedNod ?? repo.plansNeedNod) ? 1 : 0, |
| 1260 | settings.plannerSlots ?? repo.plannerSlots, | |
| 1261 | settings.reviewerSlots ?? repo.reviewerSlots, | |
| 1247 | 1262 | ); |
| 1248 | 1263 | forgetListings(db); |
| 1249 | 1264 | return getRepoById(db, repo.id)!; |
src/crew/autonomy.ts +2
| @@ -716,6 +716,8 @@ async function planIfThin( | ||
| 716 | 716 | const limbs = pendingReplans(app.db, repo); |
| 717 | 717 | const reserve = reserveOf(ready, repo.autonomySlots); |
| 718 | 718 | if (!short(reserve) && handed.length === 0 && limbs.length === 0) return false; |
| 719 | // Paused by its pillar's setting (#7668): the planner stays on the crew. | |
| 720 | if (repo.plannerSlots === 0) return notPlanned(repo, "the planner is paused"); | |
| 719 | 721 | if (repo.autonomyDirection.trim() === "") |
| 720 | 722 | return notPlanned(repo, "no direction written"); |
| 721 | 723 | // A direction with no planner would go nowhere; the forge lends one (#7637). |
src/crew/review.ts +17 −2
| @@ -23,7 +23,7 @@ import { crewWithRole, type Agent } from "../core/agents.ts"; | ||
| 23 | 23 | import { getHold, holdsFor, lastReleasesFor } from "../core/holds.ts"; |
| 24 | 24 | import { getIssueById, LIST_LIMIT_MAX } from "../core/issues.ts"; |
| 25 | 25 | import { listPulls, pullDiff, type Pull, pullDir} from "../core/pulls.ts"; |
| 26 | import { getRepoById, type Repo } from "../core/repos.ts"; | |
| 26 | import { getRepoById, PILLAR_MAX, type Repo } from "../core/repos.ts"; | |
| 27 | 27 | import { teamsOfPull, teamVerdicts } from "../core/issue_teams.ts"; |
| 28 | 28 | import { reviewSummariesFor, reviewSummary } from "../core/reviews.ts"; |
| 29 | 29 | import { checksForHead } from "../core/runs.ts"; |
| @@ -55,6 +55,18 @@ export const watchesSecurity = (agent: Agent): boolean => | ||
| 55 | 55 | /** Review runs a design reviewer holds in flight before a `src/web/` pull spills to the least busy. */ |
| 56 | 56 | export const REVIEW_DESIGN_INFLIGHT = 2; |
| 57 | 57 | |
| 58 | /** Review runs in flight (queued or running) on one repository: what the reviewer's slots bound. */ | |
| 59 | export function reviewsInFlight(db: Database, repoId: string): number { | |
| 60 | return ( | |
| 61 | db | |
| 62 | .query<{ n: number }, [string]>( | |
| 63 | `SELECT COUNT(*) AS n FROM run WHERE repo_id = ?1 AND kind = 'agent' | |
| 64 | AND reason = 'review' AND state IN ('queued','running')`, | |
| 65 | ) | |
| 66 | .get(repoId)?.n ?? 0 | |
| 67 | ); | |
| 68 | } | |
| 69 | ||
| 58 | 70 | /** |
| 59 | 71 | * Review runs in flight (queued or running) per reviewer, by user id; an idle |
| 60 | 72 | * reviewer is absent. `publicOnly` counts only runs on public repositories, |
| @@ -436,7 +448,10 @@ export async function sendToReview( | ||
| 436 | 448 | ): Promise<"none" | "done" | "sent" | "wait" | "lapsed" | "held"> { |
| 437 | 449 | if (getHold(app.db, pull.id) !== undefined) return "held"; |
| 438 | 450 | const reviewers = reviewersOf(app.db, repo.id); |
| 439 | if (reviewers.length === 0) return "none"; | |
| 451 | // A paused reviewer reads like an empty seat: the pull waits for a person, and says so. | |
| 452 | if (reviewers.length === 0 || repo.reviewerSlots === 0) return "none"; | |
| 453 | // The reviewer's slots bound every route, the teams' too. The most (PILLAR_MAX) is no cap: the sweep already bounds what it sends. | |
| 454 | if (repo.reviewerSlots < PILLAR_MAX && reviewsInFlight(app.db, repo.id) >= repo.reviewerSlots) return "wait"; | |
| 440 | 455 | if (teamsOfPull(app.db, pull).length > 0) { |
| 441 | 456 | const sent = await sendToTeams(app, repo, reviewers, pull, now); |
| 442 | 457 | if (sent !== undefined) return sent; |
src/crew/seat_slots.ts added +97
| @@ -0,0 +1,97 @@ | ||
| 1 | /** | |
| 2 | * The three pillars — plan, build, review — each with how much it may do at | |
| 3 | * once and what it is doing and spending now (#7668). One place says it: the | |
| 4 | * team card draws it and the MCP `crew` tool returns it, so a page and an | |
| 5 | * agent read the same facts. A pillar set to 0 is paused: its agent stays on | |
| 6 | * the crew, and it starts nothing until the number goes up again. | |
| 7 | */ | |
| 8 | ||
| 9 | import type { Database } from "bun:sqlite"; | |
| 10 | import { isCrewRole, type CrewRole } from "../core/agents.ts"; | |
| 11 | import { PILLAR_MAX, type Repo } from "../core/repos.ts"; | |
| 12 | import { startOfTodayUtc } from "./autonomy.ts"; | |
| 13 | ||
| 14 | export type SeatSlots = { | |
| 15 | role: CrewRole; | |
| 16 | /** What it may do at once; 0 is paused. */ | |
| 17 | slots: number; | |
| 18 | /** The most `slots` can be set to. */ | |
| 19 | max: number; | |
| 20 | /** Runs working now. */ | |
| 21 | running: number; | |
| 22 | /** Runs queued, not yet started. */ | |
| 23 | waiting: number; | |
| 24 | /** Dollars spent by this pillar since UTC midnight. */ | |
| 25 | spentUsd: number; | |
| 26 | /** Its share of today's spend here, in whole percent; 0 when nothing was spent. */ | |
| 27 | sharePct: number; | |
| 28 | }; | |
| 29 | ||
| 30 | export const PILLAR_ROLES: readonly CrewRole[] = ["planner", "builder", "reviewer"]; | |
| 31 | ||
| 32 | /** What a pillar's number counts, as a clause after "may"; one place so a page and the MCP agree. */ | |
| 33 | export const SEAT_UNIT: Record<CrewRole, string> = { | |
| 34 | planner: "planning pass at once", | |
| 35 | builder: "build at once", | |
| 36 | reviewer: "review at once", | |
| 37 | }; | |
| 38 | ||
| 39 | export function slotsOf(repo: Repo, role: CrewRole): number { | |
| 40 | return role === "planner" ? repo.plannerSlots : role === "reviewer" ? repo.reviewerSlots : repo.autonomySlots; | |
| 41 | } | |
| 42 | ||
| 43 | export function maxOf(role: CrewRole): number { | |
| 44 | return role === "planner" ? 1 : PILLAR_MAX; | |
| 45 | } | |
| 46 | ||
| 47 | /** A pillar's slots as a person or an agent typed them: a whole number from 0 to its most, or undefined. */ | |
| 48 | export function checkSeatSlots(role: string, raw: unknown): { role: CrewRole; slots: number } | undefined { | |
| 49 | if (!isCrewRole(role)) return undefined; | |
| 50 | const n = typeof raw === "string" && /^\d{1,2}$/.test(raw.trim()) ? Number(raw) : raw; | |
| 51 | return typeof n === "number" && Number.isInteger(n) && n >= 0 && n <= maxOf(role) ? { role, slots: n } : undefined; | |
| 52 | } | |
| 53 | ||
| 54 | /** The pillars as `repo` has them now. */ | |
| 55 | export function seatSlotsOf(db: Database, repo: Repo, now = new Date()): SeatSlots[] { | |
| 56 | const since = startOfTodayUtc(now).toISOString(); | |
| 57 | // A run belongs to the pillar its agent holds on this crew; one by nobody is a builder's. | |
| 58 | const rows = db | |
| 59 | .query<{ role: string; running: number; waiting: number; spent: number }, [string, string]>( | |
| 60 | `SELECT COALESCE(repo_crew.role, 'builder') AS role, | |
| 61 | SUM(run.state = 'running') AS running, | |
| 62 | SUM(run.state = 'queued') AS waiting, | |
| 63 | SUM(CASE WHEN run.created_at >= ?2 THEN run.cost_usd ELSE 0 END) AS spent | |
| 64 | FROM run LEFT JOIN repo_crew | |
| 65 | ON repo_crew.repo_id = run.repo_id AND repo_crew.user_id = run.actor_id | |
| 66 | WHERE run.repo_id = ?1 AND run.kind = 'agent' | |
| 67 | AND (run.state IN ('queued','running') OR run.created_at >= ?2) | |
| 68 | GROUP BY 1`, | |
| 69 | ) | |
| 70 | .all(repo.id, since); | |
| 71 | const by = new Map(rows.map((r) => [r.role, r])); | |
| 72 | const total = rows.reduce((sum, r) => sum + r.spent, 0); | |
| 73 | return PILLAR_ROLES.map((role) => { | |
| 74 | const r = by.get(role); | |
| 75 | const spentUsd = r?.spent ?? 0; | |
| 76 | return { | |
| 77 | role, | |
| 78 | slots: slotsOf(repo, role), | |
| 79 | max: maxOf(role), | |
| 80 | running: r?.running ?? 0, | |
| 81 | waiting: r?.waiting ?? 0, | |
| 82 | spentUsd, | |
| 83 | sharePct: total > 0 ? Math.round((spentUsd / total) * 100) : 0, | |
| 84 | }; | |
| 85 | }); | |
| 86 | } | |
| 87 | ||
| 88 | /** One plain line on a pillar's state, the same words on the page and over the MCP. */ | |
| 89 | export function seatSlotsWords(p: SeatSlots): string { | |
| 90 | const doing = | |
| 91 | p.slots === 0 | |
| 92 | ? "Paused." | |
| 93 | : p.running === 0 && p.waiting === 0 | |
| 94 | ? "Idle." | |
| 95 | : `${String(p.running)} running${p.waiting > 0 ? `, ${String(p.waiting)} waiting` : ""}.`; | |
| 96 | return `${doing} ${p.spentUsd > 0 ? `$${p.spentUsd.toFixed(2)} today, ${String(p.sharePct)}% of the spend.` : "Nothing spent today."}`; | |
| 97 | } |
src/http/mcp/tools/crew.ts +22 −2
| @@ -6,12 +6,14 @@ import { crewAsSeen, addToCrew, removeFromCrew } from "../../../actions/crew.ts" | ||
| 6 | 6 | import { emailVerified } from "../../../core/verify.ts"; |
| 7 | 7 | import { governorSeen } from "../../../crew/governed.ts"; |
| 8 | 8 | import { crewOffInPreview } from "../../../crew/preview.ts"; |
| 9 | import { setSeatSlots, seatSlotsSaid } from "../../../actions/seat_slots.ts"; | |
| 10 | import { checkSeatSlots, seatSlotsOf } from "../../../crew/seat_slots.ts"; | |
| 9 | 11 | import { seatsOf } from "../../../crew/seats.ts"; |
| 10 | 12 | import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts"; |
| 11 | 13 | |
| 12 | 14 | export const tool: Tool = { |
| 13 | 15 | name: "crew", |
| 14 | doc: "`{ repo, add?, remove?, dryRun? }`: without `add` or `remove`, the repository's crew as Settings › Crew reads it (`username`, `displayName`, `role`, and `config` for those who may see it) and `seats { auto, mode, seats[{ role, by, does, without?, state }] }`, the three seats and which are empty, and `governor { slots, ceiling, held, why }`, the builders the governor allows now and why (`null` while the crew is off or for a reader who cannot write here); `add` or `remove` (an agent's username) says what would change, planned first (`dryRun` is true unless `false`); applying is the repository's admin with an admin token, never an agent; refusals are `no_such_agent`, `not_an_agent`, `unverified`", | |
| 16 | doc: "`{ repo, add?, remove?, dryRun? }`: without `add` or `remove`, the repository's crew as Settings › Crew reads it (`username`, `displayName`, `role`, and `config` for those who may see it) and `seats { auto, mode, seats[{ role, by, does, without?, state }] }`, the three seats and which are empty, `slots[{ role, slots, max, running, waiting, spentUsd, sharePct }]`, how much each may do at once now and today's spend (0 is paused, still on the crew), and `governor { slots, ceiling, held, why }`, the builders the governor allows now and why (`null` while the crew is off or for a reader who cannot write here); `seat` (planner, builder or reviewer) with `slots` (0 to 8, the planner 0 or 1) sets how much that seat may do at once, 0 pausing it, on the trail at once; `add` or `remove` (an agent's username) says what would change, planned first (`dryRun` is true unless `false`); applying is the repository's admin with an admin token, never an agent; refusals are `no_such_agent`, `not_an_agent`, `unverified`", | |
| 15 | 17 | description: "List a repository's crew, or add or remove an agent. Plans first; admin token.", |
| 16 | 18 | inputSchema: { |
| 17 | 19 | type: "object", |
| @@ -19,6 +21,8 @@ export const tool: Tool = { | ||
| 19 | 21 | repo: REPO_ARG, |
| 20 | 22 | add: { type: "string" }, |
| 21 | 23 | remove: { type: "string" }, |
| 24 | seat: { type: "string" }, | |
| 25 | slots: { type: "integer" }, | |
| 22 | 26 | dryRun: { type: "boolean" }, |
| 23 | 27 | }, |
| 24 | 28 | }, |
| @@ -30,10 +34,26 @@ export const tool: Tool = { | ||
| 30 | 34 | const { app } = ctx; |
| 31 | 35 | const add = args["add"]; |
| 32 | 36 | const remove = args["remove"]; |
| 37 | const seat = args["seat"]; | |
| 38 | if (seat !== undefined || args["slots"] !== undefined) { | |
| 39 | if (add !== undefined || remove !== undefined) return bad("invalid", "give `seat` and `slots`, or `add` or `remove`, not both"); | |
| 40 | const checked = typeof seat === "string" ? checkSeatSlots(seat, args["slots"]) : undefined; | |
| 41 | if (checked === undefined) return bad("invalid", "`seat` is planner, builder or reviewer and `slots` a whole number from 0 (paused) to 8, the planner 0 or 1"); | |
| 42 | if (args["dryRun"] !== undefined && typeof args["dryRun"] !== "boolean") return bad("invalid", "`dryRun` is true or false"); | |
| 43 | if (user.kind === "agent") return bad("forbidden", "changing the crew is a person's call, not an agent's"); | |
| 44 | if (!canAdmin(app.db, repo, user)) return bad("forbidden", "only an admin of this repository changes its crew"); | |
| 45 | const was = seatSlotsOf(app.db, repo).find((p) => p.role === checked.role)!.slots; | |
| 46 | if (args["dryRun"] !== false) | |
| 47 | return good({ seat: checked.role, from: was, to: checked.slots, changes: was !== checked.slots, says: `This is the plan; nothing changed. ${seatSlotsSaid(checked.role, checked.slots)} Send dryRun: false to apply.` }); | |
| 48 | if (!scopeAllows(ctx.auth.scope ?? "ro", "admin")) return bad("forbidden", "changing the crew needs an admin token"); | |
| 49 | const done = await setSeatSlots(app, user, repo, checked.role, checked.slots); | |
| 50 | if (!done.ok) return bad("not_found", "no such app or repository (owner/name)"); | |
| 51 | return good({ seat: checked.role, from: was, to: checked.slots, changed: was !== checked.slots, says: seatSlotsSaid(checked.role, checked.slots) }); | |
| 52 | } | |
| 33 | 53 | if (add === undefined && remove === undefined) { |
| 34 | 54 | const seen = crewAsSeen(app, user, repo); |
| 35 | 55 | if (!seen.ok) return bad("not_found", "no such app or repository (owner/name)"); |
| 36 | return good({ crew: seen.value, governor: governorSeen(app, user, repo, new Date()) ?? null, seats: seatsOf(app.db, repo, crewOffInPreview()) }); | |
| 56 | return good({ crew: seen.value, governor: governorSeen(app, user, repo, new Date()) ?? null, seats: seatsOf(app.db, repo, crewOffInPreview()), slots: seatSlotsOf(app.db, repo) }); | |
| 37 | 57 | } |
| 38 | 58 | if ((add !== undefined && typeof add !== "string") || (remove !== undefined && typeof remove !== "string")) |
| 39 | 59 | return bad("invalid", "`add` and `remove` are an agent's username"); |
src/http/server.ts +2
| @@ -353,6 +353,7 @@ import { | ||
| 353 | 353 | crewAdd, |
| 354 | 354 | crewRemove, |
| 355 | 355 | crewRoleUpdate, |
| 356 | pillarUpdate, | |
| 356 | 357 | crewSettingsPage, |
| 357 | 358 | crewSubscriptionsSave, |
| 358 | 359 | } from "../web/pages/crew-settings.ts"; |
| @@ -1298,6 +1299,7 @@ export function buildRouter(): Router<Handler> { | ||
| 1298 | 1299 | "/:owner/:name/settings/crew/:username/role", |
| 1299 | 1300 | crewRoleUpdate, |
| 1300 | 1301 | ); |
| 1302 | router.add("POST", "/:owner/:name/settings/pillars", pillarUpdate); | |
| 1301 | 1303 | router.add("GET", "/:owner/:name/settings/autonomy", autonomySettingsPage); |
| 1302 | 1304 | router.add("POST", "/:owner/:name/settings/autonomy", autonomySettingsUpdate); |
| 1303 | 1305 | router.add("POST", "/:owner/:name/settings/holds", fileHoldUpdate); |
src/tui/screens/autonomy.ts +23 −7
| @@ -4,7 +4,9 @@ | ||
| 4 | 4 | * the repository (`canAdmin`), an agent included, as the page's API lets one: |
| 5 | 5 | * they pick the mode with `m` (a confirm offering Off, Assisted and Autonomous |
| 6 | 6 | * outright, naming what each does and the ceiling that will apply, Cancel |
| 7 | * selected), the ceiling with `c` and the slots with `s`; each saves through | |
| 7 | * selected), the ceiling with `c` and, for each seat of the team, how much it | |
| 8 | * may do at once with `p` (planner), `s` (builder) or `r` (reviewer), 0 pausing | |
| 9 | * it (`setSeatSlots`, the web card's and the MCP's one door); each saves through | |
| 8 | 10 | * `updateRepoSettings` with the fields the page writes and toasts the page's |
| 9 | 11 | * flash (`AUTONOMY_SAID`), and a value out of bounds is refused with the |
| 10 | 12 | * page's sentence (`checkAutonomy`). |
| @@ -12,8 +14,12 @@ | ||
| 12 | 14 | */ |
| 13 | 15 | |
| 14 | 16 | import { updateRepoSettings } from "../../actions/repos.ts"; |
| 17 | import { seatSlotsSaid, setSeatSlots } from "../../actions/seat_slots.ts"; | |
| 18 | import { checkSeatSlots, seatSlotsOf, seatSlotsWords } from "../../crew/seat_slots.ts"; | |
| 19 | import type { CrewRole } from "../../core/agents.ts"; | |
| 15 | 20 | import { |
| 16 | 21 | AUTONOMY, |
| 22 | AUTONOMY_BOUNDS, | |
| 17 | 23 | AUTONOMY_SAID, |
| 18 | 24 | canAdmin, |
| 19 | 25 | checkAutonomy, |
| @@ -96,7 +102,7 @@ export function autonomyScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 96 | 102 | title: "Autonomy", |
| 97 | 103 | get keys() { |
| 98 | 104 | return mayChange() |
| 99 | ? [binding(["m"], "mode"), binding(["c"], "ceiling"), binding(["s"], "slots")] | |
| 105 | ? [binding(["m"], "mode"), binding(["c"], "ceiling"), binding(["p"], "planner"), binding(["s"], "builder"), binding(["r"], "reviewer")] | |
| 100 | 106 | : []; |
| 101 | 107 | }, |
| 102 | 108 | render(area, s) { |
| @@ -110,7 +116,9 @@ export function autonomyScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 110 | 116 | `Mode ${r.autonomy}`, |
| 111 | 117 | ` ${AUTONOMY_SAID[r.autonomy]}`, |
| 112 | 118 | `Ceiling ${dollars(r.autonomyCeilingUsd)} a day`, |
| 113 | `Slots ${r.autonomySlots}`, | |
| 119 | ...seatSlotsOf(f.db, r).map( | |
| 120 | (p) => `${(p.role[0]!.toUpperCase() + p.role.slice(1)).padEnd(9)}${p.slots} at once · ${seatSlotsWords(p)}`, | |
| 121 | ), | |
| 114 | 122 | ]; |
| 115 | 123 | lines.forEach((line, i) => { |
| 116 | 124 | if (i < area.h) |
| @@ -153,10 +161,18 @@ export function autonomyScreen(f: Session, ctx: Ctx, repo: Repo): Screen { | ||
| 153 | 161 | }); |
| 154 | 162 | return true; |
| 155 | 163 | } |
| 156 | if (e.key === "s") { | |
| 157 | ask("Slots", "a whole number, 1 to 8", "1 to 8", String(current().autonomySlots), (raw) => { | |
| 158 | const n = /^\d+$/.test(raw) ? Number(raw) : Number.NaN; | |
| 159 | save({ slots: n }, `Slots are ${n}.`); | |
| 164 | const seat: Partial<Record<string, CrewRole>> = { p: "planner", s: "builder", r: "reviewer" }; | |
| 165 | const role = seat[e.key]; | |
| 166 | if (role !== undefined) { | |
| 167 | const max = role === "planner" ? 1 : 8; | |
| 168 | const slots = seatSlotsOf(f.db, current()).find((p) => p.role === role)!.slots; | |
| 169 | ask(`${role[0]!.toUpperCase()}${role.slice(1)}`, `a whole number, 0 (paused) to ${max}`, `0 to ${max}`, String(slots), (raw) => { | |
| 170 | const checked = checkSeatSlots(role, raw); | |
| 171 | if (checked === undefined) return void ctx.toast(AUTONOMY_BOUNDS, "danger"); | |
| 172 | void setSeatSlots(f.app, f.user, current(), role, checked.slots).then((done) => { | |
| 173 | ctx.toast(done.ok ? seatSlotsSaid(role, checked.slots) : "Only an admin of this repository sets its crew.", done.ok ? "success" : "danger"); | |
| 174 | ctx.invalidate(); | |
| 175 | }); | |
| 160 | 176 | return undefined; |
| 161 | 177 | }); |
| 162 | 178 | return true; |
src/web/pages/board.ts +1 −1
| @@ -435,7 +435,7 @@ function reviewNotes(ctx: Ctx, scope: BoardScope): Note[] { | ||
| 435 | 435 | key: `review:${w.repo.id}`, |
| 436 | 436 | need: true, |
| 437 | 437 | line: html`<a data-need href="/asks" |
| 438 | >${dot("attn")} <b>${w.repo.name}</b>: ${w.none ? "No reviewer: agent pulls wait for you" : "agent pulls wait for your review"}${w.waiting === 0 || w.since === null | |
| 438 | >${dot("attn")} <b>${w.repo.name}</b>: ${w.none ? "No reviewer: agent pulls wait for you" : w.paused ? "The reviewer is paused: agent pulls wait for you" : "agent pulls wait for your review"}${w.waiting === 0 || w.since === null | |
| 439 | 439 | ? null |
| 440 | 440 | : html` · <b>${w.waiting}</b> waiting since ${w.since.slice(0, 10)}`}</a |
| 441 | 441 | >`, |
src/web/pages/crew-settings.ts +24
| @@ -5,6 +5,8 @@ | ||
| 5 | 5 | * form here redirects back to its `#crew` anchor with a flash. |
| 6 | 6 | */ |
| 7 | 7 | |
| 8 | import { seatSlotsSaid, setSeatSlots } from "../../actions/seat_slots.ts"; | |
| 9 | import { checkSeatSlots } from "../../crew/seat_slots.ts"; | |
| 8 | 10 | import { addToCrew, listCrew, removeFromCrew } from "../../actions/crew.ts"; |
| 9 | 11 | import { getAgentByName, isCrewRole, setCrewRole } from "../../core/agents.ts"; |
| 10 | 12 | import type { Ctx } from "../../http/context.ts"; |
| @@ -93,3 +95,25 @@ export async function crewRoleUpdate(ctx: Ctx): Promise<Response> { | ||
| 93 | 95 | message: `${agent.user.username} is a ${role} here.`, |
| 94 | 96 | }); |
| 95 | 97 | } |
| 98 | ||
| 99 | // POST /:owner/:name/settings/pillars | |
| 100 | export async function pillarUpdate(ctx: Ctx): Promise<Response> { | |
| 101 | const gate = await adminForm(ctx); | |
| 102 | if (gate instanceof Response) return gate; | |
| 103 | const { repo, form } = gate; | |
| 104 | const base = repoBase(repo); | |
| 105 | const checked = checkSeatSlots(field(form, "seat"), field(form, "slots")); | |
| 106 | if (checked === undefined) | |
| 107 | return flashTo(ctx, `${base}/settings#team`, { | |
| 108 | tone: "danger", | |
| 109 | message: "A pillar may do a whole number at once, from 0 (paused) to 8; the planner 0 or 1.", | |
| 110 | }); | |
| 111 | const done = await setSeatSlots(ctx.app, ctx.auth.user, repo, checked.role, checked.slots); | |
| 112 | return flashTo( | |
| 113 | ctx, | |
| 114 | `${base}/settings#team`, | |
| 115 | done.ok | |
| 116 | ? { message: seatSlotsSaid(checked.role, checked.slots) } | |
| 117 | : { tone: "danger", message: "That could not be saved." }, | |
| 118 | ); | |
| 119 | } |
src/web/pages/settings.ts +3 −1
| @@ -1430,7 +1430,9 @@ export async function autonomySettingsUpdate(ctx: Ctx): Promise<Response> { | ||
| 1430 | 1430 | const { repo, form } = gate; |
| 1431 | 1431 | const base = repoBase(repo); |
| 1432 | 1432 | const ceiling = Number(field(form, "ceiling")); |
| 1433 | const slots = Number(field(form, "slots")); | |
| 1433 | // The builders' slots are the Builder pillar's number on the team card; this form only keeps them. | |
| 1434 | const slotsField = field(form, "slots"); | |
| 1435 | const slots = slotsField === "" ? repo.autonomySlots : Number(slotsField); | |
| 1434 | 1436 | const checked = checkAutonomy(field(form, "autonomy"), ceiling, slots); |
| 1435 | 1437 | if (!checked.ok) |
| 1436 | 1438 | return flashTo(ctx, `${base}/settings#autonomy`, { |
src/web/studio/crew.ts +21 −9
| @@ -40,6 +40,7 @@ import { html, raw, type Html } from "../html.ts"; | ||
| 40 | 40 | import { icon } from "../icons.ts"; |
| 41 | 41 | import { crewReadiness } from "../pages/crew.ts"; |
| 42 | 42 | import { grantsSection } from "../pages/subscriptions.ts"; |
| 43 | import { seatSlotsOf, seatSlotsWords, SEAT_UNIT } from "../../crew/seat_slots.ts"; | |
| 43 | 44 | import { directionStall } from "../../crew/planner.ts"; |
| 44 | 45 | import { directionMarked } from "./direction.ts"; |
| 45 | 46 | import { fileHoldsCard } from "./file_holds.ts"; |
| @@ -153,22 +154,40 @@ function teamCard( | ||
| 153 | 154 | })} |
| 154 | 155 | </form>`; |
| 155 | 156 | }; |
| 157 | const seatSlots = new Map(seatSlotsOf(ctx.app.db, repo).map((p) => [p.role, p])); | |
| 156 | 158 | const rows = team.seats.map((p) => { |
| 157 | 159 | const fillBy = fill(p); |
| 158 | 160 | const b = SEAT_BADGE[p.state]; |
| 161 | const slots = seatSlots.get(p.role)!; | |
| 162 | const paused = slots.slots === 0; | |
| 163 | const setter = html`<form class="row-fields" method="post" action="${base}/settings/pillars"> | |
| 164 | <input type="hidden" name="seat" value="${p.role}" /> | |
| 165 | <label class="sr-only" for="f-seat-${p.role}">${p.role}: ${SEAT_UNIT[p.role]}</label> | |
| 166 | <input class="input input-sm" id="f-seat-${p.role}" name="slots" type="number" inputmode="numeric" | |
| 167 | min="0" max="${String(slots.max)}" value="${String(slots.slots)}" /> | |
Check this trail at 360px: the paused badge, the number input and Set must wrap without overflow. Give the input a fixed narrow width via the existing token. |
||
| 168 | ${btn("Set", { size: "sm" })} | |
| 169 | </form>`; | |
| 159 | 170 | return row({ |
| 160 | 171 | lead: icon(SEAT_LEAD[p.state]), |
| 161 | 172 | title: p.role[0]!.toUpperCase() + p.role.slice(1), |
| 162 | 173 | meta: html`${p.by.length > 0 ? html`${p.by.map((u, i) => html`${i > 0 ? ", " : ""}${userLink(u)}`)}: ` : null}${p.does}${p.state === "warn" ? ` No ${p.role}: ${p.without}.` : null}`, |
| 163 | trail: html`${badge(b.text, { tone: b.tone })}${fillBy ?? null}`, | |
| 174 | trail: html`${paused ? badge("paused", { tone: "attn" }) : badge(b.text, { tone: b.tone })}${fillBy ?? null}`, | |
| 164 | 175 | data: { seat: p.role, state: p.state }, |
| 176 | children: [ | |
| 177 | row({ | |
| 178 | title: paused ? "Paused" : `${String(slots.slots)} ${SEAT_UNIT[p.role]}`, | |
outdated
Hand-rolled <br/> and span for the state line; use the row component's meta/lead structure. |
||
| 179 | meta: html`${seatSlotsWords(slots)}`, | |
| 180 | ...(admin ? { trail: setter } : {}), | |
| 181 | data: { slots: p.role, at: String(slots.slots) }, | |
| 182 | }), | |
| 183 | ], | |
| 165 | 184 | }); |
| 166 | 185 | }); |
| 167 | 186 | const intro = crewOffInPreview() |
| 168 | 187 | ? "The crew is off in a preview, so nobody here is working." |
| 169 | 188 | : team.auto |
| 170 | 189 | ? `Auto mode is on (${team.mode}): your team works on its own — a planner decides what is next, a builder builds it, a reviewer checks it.` |
| 171 | : "Auto mode is off: your team works only when you ask. Fill the seats you want, then turn auto mode on under On its own."; | |
| 190 | : "Auto mode is off: your team works only when you ask. Fill the seats you want, then turn auto mode on under On its own. Each seat says how much it may do at once; 0 pauses it without taking it off the team."; | |
| 172 | 191 | return card( |
| 173 | 192 | html`<p class="small" id="auto-mode" data-auto="${team.auto ? "on" : "off"}"> |
| 174 | 193 | ${intro} |
| @@ -441,13 +460,6 @@ ${repo.autonomyDirection}</textarea | ||
| 441 | 460 | type: "number", |
| 442 | 461 | hint: `Dollars of agent spend on this ${getAppByRepo(ctx.app.db, repo.id) === undefined ? "repository" : "app"} per UTC day. At the ceiling the crew stops picking until midnight; nothing in flight is cut.`, |
| 443 | 462 | })} |
| 444 | ${field({ | |
| 445 | label: "Slots", | |
| 446 | name: "slots", | |
| 447 | value: String(repo.autonomySlots), | |
| 448 | type: "number", | |
| 449 | hint: "Agent runs at once here. This is the WIP limit.", | |
| 450 | })} | |
| 451 | 463 | <div class="form-actions">${btn("Save", { variant: "primary" })}</div> |
| 452 | 464 | </form>`; |
| 453 | 465 | return card(html`${state}${form}`, { |
test/review-route.test.ts +27 −2
| @@ -13,16 +13,18 @@ import { join } from "node:path"; | ||
| 13 | 13 | import { createApp, type App } from "../src/app.ts"; |
| 14 | 14 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; |
| 15 | 15 | import { openPull } from "../src/actions/pulls.ts"; |
| 16 | import { commitToBranch, createRepo } from "../src/actions/repos.ts"; | |
| 16 | import { commitToBranch, createRepo, updateRepoSettings } from "../src/actions/repos.ts"; | |
| 17 | 17 | import { setCrewRole, type Agent } from "../src/core/agents.ts"; |
| 18 | 18 | import type { Pull } from "../src/core/pulls.ts"; |
| 19 | import type { Repo } from "../src/core/repos.ts"; | |
| 19 | import { getRepoById, type Repo } from "../src/core/repos.ts"; | |
| 20 | 20 | import type { Review } from "../src/core/reviews.ts"; |
| 21 | 21 | import { claimRun, createRun, finishRun, listRuns } from "../src/core/runs.ts"; |
| 22 | 22 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; |
| 23 | 23 | import { |
| 24 | 24 | pickReviewer, |
| 25 | 25 | reviewLoad, |
| 26 | reviewsInFlight, | |
| 27 | sendToReview, | |
| 26 | 28 | REVIEW_LAPSE_MS, |
| 27 | 29 | REVIEW_WAIT_MS, |
| 28 | 30 | reviewStage, |
| @@ -250,6 +252,29 @@ describe("every green pull an agent wrote is sent to review", () => { | ||
| 250 | 252 | expect(await sweepReviews(app)).toBe(0); |
| 251 | 253 | }); |
| 252 | 254 | |
| 255 | test("the reviewer's slots bound the reviews in flight: at the cap a green pull waits, and none sends nothing", async () => { | |
| 256 | const scout = getUserByName(app.db, "scout")!; | |
| 257 | const late = await pullBy(scout, "capped", "src/web/capped.ts"); | |
| 258 | pass(late); | |
| 259 | const fresh = () => getRepoById(app.db, repo.id)!; | |
| 260 | const inFlight = reviewsInFlight(app.db, repo.id); | |
| 261 | expect(inFlight).toBeGreaterThan(0); | |
| 262 | // The cap is what is already out: nothing more goes. | |
| 263 | await updateRepoSettings(app, alice, repo, { reviewerSlots: inFlight }); | |
| 264 | expect(await sendToReview(app, fresh(), late)).toBe("wait"); | |
| 265 | expect(reviewer(late)).toEqual([]); | |
| 266 | // Paused: no review at all, as with no reviewer. | |
| 267 | await updateRepoSettings(app, alice, repo, { reviewerSlots: 0 }); | |
| 268 | expect(await sendToReview(app, fresh(), late)).toBe("none"); | |
| 269 | // One more slot than are out: it is sent. | |
| 270 | await updateRepoSettings(app, alice, repo, { reviewerSlots: inFlight + 1 }); | |
| 271 | expect(await sendToReview(app, fresh(), late)).toBe("sent"); | |
| 272 | expect(reviewer(late)).toHaveLength(1); | |
| 273 | await updateRepoSettings(app, alice, repo, { reviewerSlots: 8 }); | |
| 274 | // Closed, so the sweep below counts what it did before. | |
| 275 | app.db.query("UPDATE issue SET state = 'closed' WHERE id = ?1").run(late.id); | |
| 276 | }); | |
| 277 | ||
| 253 | 278 | test("60 open green agent pulls: the oldest is sent on the first pass, and a pass sends a bounded few", async () => { |
| 254 | 279 | const scout = getUserByName(app.db, "scout")!; |
| 255 | 280 | const pulls: Pull[] = []; |
test/seat-slots.test.ts added +210
| @@ -0,0 +1,210 @@ | ||
| 1 | /** | |
| 2 | * How much each seat may do at once (#7668): the planner and the reviewer | |
| 3 | * have a number beside the builders', 0 pauses a seat without taking it off | |
| 4 | * the crew, the team card shows it with what the seat is doing and spending, | |
| 5 | * a change is audited and takes effect on the next tick, and the MCP `crew` | |
| 6 | * tool sets the same numbers. | |
| 7 | */ | |
| 8 | import { afterAll, beforeAll, expect, setDefaultTimeout, test } from "bun:test"; | |
| 9 | import { mkdtemp, rm } from "node:fs/promises"; | |
| 10 | import { tmpdir } from "node:os"; | |
| 11 | import { join } from "node:path"; | |
| 12 | import { createApp, type App } from "../src/app.ts"; | |
| 13 | import { addToCrew, createAgent } from "../src/actions/crew.ts"; | |
| 14 | import { commitToBranch, createRepo, updateRepoSettings } from "../src/actions/repos.ts"; | |
| 15 | import { setCrewRole } from "../src/core/agents.ts"; | |
| 16 | import { reviewWaits, waitLine } from "../src/actions/owner_waits.ts"; | |
| 17 | import { checkAutonomy, getRepoById, type Repo } from "../src/core/repos.ts"; | |
| 18 | import { claimRun, createRun, finishRun } from "../src/core/runs.ts"; | |
| 19 | import { createToken } from "../src/core/tokens.ts"; | |
| 20 | import { createUser, getUserByName, type User } from "../src/core/users.ts"; | |
| 21 | import { driveRepo } from "../src/crew/autonomy.ts"; | |
| 22 | import { checkSeatSlots, seatSlotsOf, seatSlotsWords } from "../src/crew/seat_slots.ts"; | |
| 23 | import { sendToReview } from "../src/crew/review.ts"; | |
| 24 | import { serve, type Servers } from "../src/http/server.ts"; | |
| 25 | import { quietLogs } from "./helpers/globals.ts"; | |
| 26 | import { mcpTool } from "./helpers/mcp.ts"; | |
| 27 | import { proveAll } from "./helpers/verified.ts"; | |
| 28 | ||
| 29 | quietLogs(); | |
| 30 | setDefaultTimeout(30_000); | |
| 31 | ||
| 32 | const PASSWORD = "a-long-crew-passphrase"; | |
| 33 | let dataDir = ""; | |
| 34 | let app: App; | |
| 35 | let servers: Servers; | |
| 36 | let alice: User; | |
| 37 | let repo: Repo; | |
| 38 | let cookie = ""; | |
| 39 | let admin = ""; | |
| 40 | let agentTok = ""; | |
| 41 | ||
| 42 | const fresh = () => getRepoById(app.db, repo.id)!; | |
| 43 | const ok = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => { | |
| 44 | if (!r.ok) throw new Error(JSON.stringify(r.error)); | |
| 45 | return r.value; | |
| 46 | }; | |
| 47 | const seat = (role: string) => seatSlotsOf(app.db, fresh()).find((s) => s.role === role)!; | |
| 48 | const post = (body: Record<string, string>, who = cookie) => | |
| 49 | fetch(`${servers.url}/alice/yard/settings/pillars`, { | |
| 50 | method: "POST", | |
| 51 | headers: { origin: servers.url, cookie: who }, | |
| 52 | body: new URLSearchParams(body), | |
| 53 | redirect: "manual", | |
| 54 | }); | |
| 55 | const crew = (token: string, args: Record<string, unknown>) => | |
| 56 | mcpTool(servers.url, token, "crew", { repo: "alice/yard", ...args }); | |
| 57 | const trail = () => | |
| 58 | app.db | |
| 59 | .query<{ payload: string }, [string]>("SELECT payload FROM event WHERE kind = 'repo.settings_updated' AND repo_id = ?1 ORDER BY rowid") | |
| 60 | .all(repo.id) | |
| 61 | .map((e) => JSON.parse(e.payload).changed as Record<string, { from: unknown; to: unknown }>); | |
| 62 | ||
| 63 | beforeAll(async () => { | |
| 64 | dataDir = await mkdtemp(join(tmpdir(), "forge-seat-slots-")); | |
| 65 | app = createApp({ dataDir, port: 0 }); | |
| 66 | servers = await serve(app); | |
| 67 | alice = ok(await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: PASSWORD, adminIfFirst: true })); | |
| 68 | proveAll(app); | |
| 69 | for (const name of ["mason", "atlas", "judge"]) ok(await createAgent(app, alice, { username: name })); | |
| 70 | repo = ok(await createRepo(app, alice, { name: "yard", visibility: "public" })); | |
| 71 | ok(await commitToBranch(app, alice, repo, { message: "Start", files: [{ path: "README.md", content: "# yard\n" }] })); | |
| 72 | for (const name of ["mason", "atlas", "judge"]) expect(addToCrew(app, alice, repo, name).ok).toBe(true); | |
| 73 | const id = (n: string) => getUserByName(app.db, n)!.id; | |
| 74 | setCrewRole(app.db, repo.id, id("atlas"), "planner"); | |
| 75 | setCrewRole(app.db, repo.id, id("judge"), "reviewer"); | |
| 76 | ok(await updateRepoSettings(app, alice, repo, { autonomy: "autonomous", autonomyDirection: "Make the yard rounder.", autonomyCeilingUsd: 10, autonomySlots: 2, autonomyBy: alice.id })); | |
| 77 | const res = await fetch(`${servers.url}/signin`, { method: "POST", body: new URLSearchParams({ username: "alice", password: PASSWORD }), redirect: "manual" }); | |
| 78 | cookie = (res.headers.get("set-cookie") ?? "").split(";")[0]!; | |
| 79 | admin = createToken(app.db, alice.id, "admin", "admin").token; | |
| 80 | agentTok = createToken(app.db, id("atlas"), "admin", "admin").token; | |
| 81 | }); | |
| 82 | ||
| 83 | afterAll(async () => { | |
| 84 | servers?.stop(); | |
| 85 | app?.close(); | |
| 86 | await rm(dataDir, { recursive: true, force: true }); | |
| 87 | }); | |
| 88 | ||
| 89 | test("the numbers are whole, 0 to 8, the planner 0 or 1", () => { | |
| 90 | expect(checkSeatSlots("builder", "0")).toEqual({ role: "builder", slots: 0 }); | |
| 91 | expect(checkSeatSlots("reviewer", "8")).toEqual({ role: "reviewer", slots: 8 }); | |
| 92 | expect(checkSeatSlots("planner", 1)).toEqual({ role: "planner", slots: 1 }); | |
| 93 | for (const [role, slots] of [["planner", "2"], ["builder", "9"], ["builder", "-1"], ["builder", "1.5"], ["builder", ""], ["janitor", "1"], ["builder", "1e1"]] as const) | |
| 94 | expect(checkSeatSlots(role, slots)).toBeUndefined(); | |
| 95 | }); | |
| 96 | ||
| 97 | test("each seat shows what it is doing and spending, and its share", () => { | |
| 98 | const run = createRun(app.db, { repoId: repo.id, kind: "agent", actorId: getUserByName(app.db, "mason")!.id, reason: "assigned" }); | |
| 99 | claimRun(app.db, run.id); | |
| 100 | finishRun(app.db, run.id, { state: "ok", summary: "", costUsd: 0.75 }); | |
| 101 | const plan = createRun(app.db, { repoId: repo.id, kind: "agent", actorId: getUserByName(app.db, "atlas")!.id, reason: "assigned" }); | |
| 102 | claimRun(app.db, plan.id); | |
| 103 | finishRun(app.db, plan.id, { state: "ok", summary: "", costUsd: 0.25 }); | |
| 104 | const queued = createRun(app.db, { repoId: repo.id, kind: "agent", actorId: getUserByName(app.db, "mason")!.id, reason: "assigned" }); | |
| 105 | expect(queued.id).toBeTruthy(); | |
| 106 | expect(seat("builder")).toMatchObject({ slots: 2, waiting: 1, spentUsd: 0.75, sharePct: 75 }); | |
| 107 | expect(seat("planner")).toMatchObject({ slots: 1, spentUsd: 0.25, sharePct: 25 }); | |
| 108 | expect(seat("reviewer")).toMatchObject({ running: 0, spentUsd: 0, sharePct: 0 }); | |
| 109 | expect(seatSlotsWords(seat("builder"))).toBe("0 running, 1 waiting. $0.75 today, 75% of the spend."); | |
| 110 | expect(seatSlotsWords(seat("reviewer"))).toBe("Idle. Nothing spent today."); | |
| 111 | app.db.query("UPDATE run SET state = 'cancelled' WHERE state = 'queued'").run(); | |
| 112 | }); | |
| 113 | ||
| 114 | test("the team card has a number for each seat, for an admin only", async () => { | |
| 115 | const html = await (await fetch(`${servers.url}/alice/yard/settings`, { headers: { cookie } })).text(); | |
| 116 | for (const role of ["planner", "builder", "reviewer"]) expect(html).toContain(`id="f-seat-${role}"`); | |
| 117 | expect(html).toContain('data-slots="builder"'); | |
| 118 | expect(html).toContain("$0.75 today, 75% of the spend."); | |
| 119 | expect(html).not.toContain('label: "Slots"'); | |
| 120 | expect(html).not.toContain('name="slots" type="number" min="1"'); | |
| 121 | const guest = await (await fetch(`${servers.url}/alice/yard/settings`)).text(); | |
| 122 | expect(guest).not.toContain("f-seat-planner"); | |
| 123 | }); | |
| 124 | ||
| 125 | test("a paused planner is not asked, and is still on the crew; raising it asks again", async () => { | |
| 126 | const res = await post({ seat: "planner", slots: "0" }); | |
| 127 | expect(res.status).toBe(303); | |
| 128 | expect(fresh().plannerSlots).toBe(0); | |
| 129 | expect(seat("planner").slots).toBe(0); | |
| 130 | expect(await driveRepo(app, fresh())).not.toBe("planned"); | |
| 131 | expect(getUserByName(app.db, "atlas")).toBeDefined(); | |
| 132 | const html = await (await fetch(`${servers.url}/alice/yard/settings`, { headers: { cookie } })).text(); | |
| 133 | expect(html).toContain("Paused"); | |
| 134 | expect((await post({ seat: "planner", slots: "1" })).status).toBe(303); | |
| 135 | expect(await driveRepo(app, fresh())).toBe("planned"); | |
| 136 | }); | |
| 137 | ||
| 138 | test("a paused reviewer sends nothing, so an agent's pull waits for a person", async () => { | |
| 139 | ok(await updateRepoSettings(app, alice, fresh(), { reviewerSlots: 0 })); | |
| 140 | const pull = { id: "none-such" } as never; | |
| 141 | expect(await sendToReview(app, fresh(), pull)).toBe("none"); | |
| 142 | }); | |
| 143 | ||
| 144 | test("a bad number or a stranger changes nothing; each change is on the trail", async () => { | |
| 145 | const before = trail().length; | |
| 146 | await post({ seat: "builder", slots: "99" }); | |
| 147 | await post({ seat: "planner", slots: "2" }); | |
| 148 | const guest = await fetch(`${servers.url}/alice/yard/settings/pillars`, { method: "POST", headers: { origin: servers.url }, body: new URLSearchParams({ seat: "builder", slots: "0" }), redirect: "manual" }); | |
| 149 | expect(guest.status).not.toBe(303); | |
| 150 | const cross = await fetch(`${servers.url}/alice/yard/settings/pillars`, { method: "POST", headers: { origin: "https://evil.example", cookie }, body: new URLSearchParams({ seat: "builder", slots: "0" }), redirect: "manual" }); | |
| 151 | expect(cross.status).toBeGreaterThanOrEqual(400); | |
| 152 | expect(fresh().autonomySlots).toBe(2); | |
outdated
303 is also the flash redirect, so this passes even when checkAutonomy refuses. Assert the autonomy mode/ceiling was saved. |
||
| 153 | expect(trail().length).toBe(before); | |
| 154 | await post({ seat: "builder", slots: "0" }); | |
| 155 | expect(fresh().autonomySlots).toBe(0); | |
| 156 | expect(trail().at(-1)).toEqual({ autonomySlots: { from: 2, to: 0 } }); | |
| 157 | await post({ seat: "builder", slots: "2" }); | |
| 158 | }); | |
| 159 | ||
| 160 | test("a builder set to none starts nothing, and the On its own form keeps the slots it did not send", async () => { | |
| 161 | await post({ seat: "builder", slots: "0" }); | |
| 162 | const form = await fetch(`${servers.url}/alice/yard/settings/autonomy`, { | |
| 163 | method: "POST", | |
| 164 | headers: { origin: servers.url, cookie }, | |
| 165 | body: new URLSearchParams({ autonomy: "assisted", ceiling: "7", direction: "Make the yard rounder." }), | |
| 166 | redirect: "manual", | |
| 167 | }); | |
| 168 | expect(form.status).toBe(303); | |
| 169 | // The form really saved (a refusal is a 303 too): mode and ceiling moved, the 0 stayed. | |
| 170 | expect(fresh()).toMatchObject({ autonomy: "assisted", autonomyCeilingUsd: 7, autonomySlots: 0 }); | |
| 171 | expect(checkAutonomy("assisted", 7, 0).ok).toBe(true); | |
| 172 | expect(await driveRepo(app, fresh())).not.toBe("picked"); | |
| 173 | await post({ seat: "builder", slots: "2" }); | |
| 174 | ok(await updateRepoSettings(app, alice, fresh(), { autonomy: "autonomous", autonomyCeilingUsd: 10 })); | |
| 175 | }); | |
| 176 | ||
| 177 | test("a paused reviewer is said to be paused in what waits for the owner", () => { | |
| 178 | app.db.query("UPDATE repo SET reviewer_slots = 0 WHERE id = ?1").run(repo.id); | |
| 179 | const w = reviewWaits(app.db, [fresh()]); | |
| 180 | expect(w).toHaveLength(1); | |
| 181 | expect(w[0]).toMatchObject({ kind: "review", paused: true }); | |
| 182 | expect(waitLine(w[0]!)).toContain("the reviewer is paused"); | |
| 183 | app.db.query("UPDATE repo SET reviewer_slots = 8 WHERE id = ?1").run(repo.id); | |
| 184 | }); | |
| 185 | ||
| 186 | test("over the MCP: read, plan, apply, and refuse an agent or a bad number", async () => { | |
| 187 | const read = await crew(admin, {}); | |
| 188 | expect(read.value.slots.map((s: { role: string }) => s.role)).toEqual(["planner", "builder", "reviewer"]); | |
| 189 | const plan = await crew(admin, { seat: "reviewer", slots: 3 }); | |
| 190 | expect(plan.value).toMatchObject({ from: 8, to: 3, changes: true }); | |
| 191 | expect(plan.value.says).toContain("nothing changed"); | |
| 192 | expect(fresh().reviewerSlots).toBe(8); | |
| 193 | const done = await crew(admin, { seat: "reviewer", slots: 3, dryRun: false }); | |
| 194 | expect(done.value).toMatchObject({ changed: true, to: 3 }); | |
| 195 | expect(fresh().reviewerSlots).toBe(3); | |
| 196 | expect(trail().at(-1)).toEqual({ reviewerSlots: { from: 8, to: 3 } }); | |
| 197 | expect((await crew(admin, { seat: "planner", slots: 2 })).error).toContain("invalid"); | |
| 198 | expect((await crew(admin, { seat: "reviewer" })).error).toContain("invalid"); | |
| 199 | expect((await crew(admin, { seat: "reviewer", slots: 1, add: "mason" })).error).toContain("invalid"); | |
| 200 | expect((await crew(agentTok, { seat: "reviewer", slots: 1, dryRun: false })).error).toContain("forbidden"); | |
| 201 | expect(fresh().reviewerSlots).toBe(3); | |
| 202 | }); | |
| 203 | ||
| 204 | test("the reviewer's cap counts the reviews in flight on this repository only", async () => { | |
| 205 | const { reviewsInFlight } = await import("../src/crew/review.ts"); | |
| 206 | const before = reviewsInFlight(app.db, repo.id); | |
| 207 | createRun(app.db, { repoId: repo.id, kind: "agent", actorId: getUserByName(app.db, "judge")!.id, reason: "review" }); | |
| 208 | expect(reviewsInFlight(app.db, repo.id)).toBe(before + 1); | |
| 209 | expect(reviewsInFlight(app.db, "other-repo")).toBe(0); | |
| 210 | }); |
test/tui-autonomy.test.ts +25 −3
| @@ -202,6 +202,28 @@ describe("autonomy in the terminal", () => { | ||
| 202 | 202 | h.stop(); |
| 203 | 203 | }); |
| 204 | 204 | |
| 205 | test("p, s and r set the planner, builder and reviewer; 0 pauses and the audit line says from what to what", async () => { | |
| 206 | const h = boot(ada); | |
| 207 | await h.open(); | |
| 208 | expect(h.text()).toContain("Planner"); | |
| 209 | expect(h.text()).toContain("Reviewer"); | |
| 210 | for (const [key, value] of [["p", "0"], ["s", "0"], ["r", "2"]] as const) { | |
| 211 | await h.press(key); | |
| 212 | for (let i = 0; i < 4; i++) await h.press("backspace"); | |
| 213 | await h.type(value); | |
| 214 | await h.press("enter"); | |
| 215 | } | |
| 216 | expect(saved()).toMatchObject({ plannerSlots: 0, autonomySlots: 0, reviewerSlots: 2 }); | |
| 217 | expect(h.text()).toContain("Paused"); | |
| 218 | await h.press("p"); | |
| 219 | for (let i = 0; i < 4; i++) await h.press("backspace"); | |
| 220 | await h.type("2"); | |
| 221 | await h.press("enter"); | |
| 222 | expect(saved().plannerSlots).toBe(0); | |
| 223 | expect(h.text()).toContain(AUTONOMY_BOUNDS); | |
| 224 | h.stop(); | |
| 225 | }); | |
| 226 | ||
| 205 | 227 | test("a ceiling or slots out of range are refused with the page's sentence and nothing is saved", async () => { |
| 206 | 228 | const h = boot(ada); |
| 207 | 229 | await h.open(); |
| @@ -214,7 +236,7 @@ describe("autonomy in the terminal", () => { | ||
| 214 | 236 | expect(h.text()).toContain(AUTONOMY_BOUNDS); |
| 215 | 237 | expect(saved().autonomyCeilingUsd).toBe(before.autonomyCeilingUsd); |
| 216 | 238 | } |
| 217 | for (const bad of ["0", "9", "2.5", "x"]) { | |
| 239 | for (const bad of ["9", "2.5", "x", "-1"]) { | |
| 218 | 240 | await h.press("s"); |
| 219 | 241 | for (let i = 0; i < 4; i++) await h.press("backspace"); |
| 220 | 242 | await h.type(bad); |
| @@ -233,7 +255,7 @@ describe("autonomy in the terminal", () => { | ||
| 233 | 255 | const before = saved(); |
| 234 | 256 | const h = boot(get()); |
| 235 | 257 | await h.open(); |
| 236 | expect(h.text()).not.toContain("Slots"); | |
| 258 | expect(h.text()).not.toContain("Builder"); | |
| 237 | 259 | expect(h.text()).not.toContain("Ceiling"); |
| 238 | 260 | expect(h.text()).not.toContain("Mode "); |
| 239 | 261 | await h.press("m", "left", "enter", "c", "s"); |
| @@ -249,7 +271,7 @@ describe("autonomy in the terminal", () => { | ||
| 249 | 271 | test("an agent who administers the repository changes it, as the page's API lets it", async () => { |
| 250 | 272 | const h = boot(bot); |
| 251 | 273 | await h.open(); |
| 252 | expect(h.text()).toContain("Slots"); | |
| 274 | expect(h.text()).toContain("Builder"); | |
| 253 | 275 | await h.press("s"); |
| 254 | 276 | for (let i = 0; i < 4; i++) await h.press("backspace"); |
| 255 | 277 | await h.type("2"); |
new above
offline
Use maxOf(role) from crew/seat_slots.ts rather than repeating the 1 / 8 limits.