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

Team settings: how much of each pillar, in one clear place #8690

Merged opened this in answer to

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

Merged

scout/forge:scout/7668-team-settings-how-much-of-each-pillar-in → main 3 commits fork 4 threads · 4 unresolved

  • Merged. scout/7668-team-settings-how-much-of-each-pillar-in was merged into main as 006777d.

Review

Sign in to review this change.
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
3333
3434 Settings → Confirms (`src/web/pages/confirms.ts`, #8230) is a `listCard()` of `row()`s that fold (`fold`) open on the call's full
3535 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";
1515
1616 export type OwnerWait =
1717 | { 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 };
1919
2020 /** The planner declines among these repositories, one per app. */
2121 export function declinedWaits(db: Database, repos: readonly Repo[]): OwnerWait[] {
@@ -51,10 +51,11 @@ export function reviewWaits(db: Database, repos: readonly Repo[]): OwnerWait[] {
5151 const w = waitsForPerson(db, repo.id, p);
5252 return w === undefined ? [] : [w];
5353 });
54 const paused = repo.reviewerSlots === 0;
5455 const none = reviewersOf(db, repo.id).length === 0;
55 if (!none && waits.length === 0) return [];
56 if (!none && !paused && waits.length === 0) return [];
5657 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 },
5859 ];
5960 });
6061 }
@@ -68,7 +69,7 @@ export function ownerWaits(db: Database, repos: readonly Repo[]): OwnerWait[] {
6869 export function waitLine(w: OwnerWait): string {
6970 if (w.kind === "declined")
7071 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"}${
7273 w.waiting === 0 || w.since === null ? "" : ` · ${w.waiting} waiting since ${w.since.slice(0, 10)}`
7374 }`;
7475 }
src/actions/repos.ts +3
@@ -346,6 +346,9 @@ const AUDITED = [
346346 "crewOutsiders",
347347 "decisionsBy",
348348 "plansNeedNod",
349 "autonomySlots",
350 "plannerSlots",
351 "reviewerSlots",
349352 ] as const;
350353
351354 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 =>
8484
8585 /** What the settings page and the terminal say when autonomy input is out of bounds. */
8686 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.";
8888
8989 /** What each mode does, in one sentence: the settings page's flash and the terminal's toast. */
9090 export const AUTONOMY_SAID: Readonly<Record<Autonomy, string>> = {
@@ -104,13 +104,16 @@ export function checkAutonomy(
104104 !Number.isFinite(ceiling) ||
105105 ceiling < 0 ||
106106 !Number.isInteger(slots) ||
107 slots < 1 ||
108 slots > 8
107 slots < 0 ||
108 slots > PILLAR_MAX
109109 )
110110 return { ok: false, message: AUTONOMY_BOUNDS };
111111 return { ok: true, mode };
112112 }
113113
114 /** The most any pillar may be set to at once. */
115 export const PILLAR_MAX = 8;
116
114117 export interface Repo {
115118 readonly id: string;
116119 readonly ownerId: string;
@@ -140,6 +143,10 @@ export interface Repo {
140143 readonly autonomyDirection: string;
141144 readonly autonomyCeilingUsd: number;
142145 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;
143150 readonly autonomyBy: string | null;
144151 /**
145152 * "Outsiders may trigger the crew": whether someone who cannot write here
@@ -195,6 +202,8 @@ interface RepoRow {
195202 autonomy_direction: string;
196203 autonomy_ceiling_usd: number;
197204 autonomy_slots: number;
205 planner_slots: number;
206 reviewer_slots: number;
198207 autonomy_by: string | null;
199208 crew_outsiders: number;
200209 decisions_by: string;
@@ -235,6 +244,8 @@ function toRepo(row: RepoRow): Repo {
235244 autonomyDirection: row.autonomy_direction,
236245 autonomyCeilingUsd: row.autonomy_ceiling_usd,
237246 autonomySlots: row.autonomy_slots,
247 plannerSlots: row.planner_slots,
248 reviewerSlots: row.reviewer_slots,
238249 autonomyBy: row.autonomy_by,
239250 crewOutsiders: row.crew_outsiders === 1,
240251 decisionsBy: isDecisionsBy(row.decisions_by) ? row.decisions_by : "me",
@@ -1195,6 +1206,8 @@ export interface RepoSettings {
11951206 autonomyDirection?: string;
11961207 autonomyCeilingUsd?: number;
11971208 autonomySlots?: number;
1209 plannerSlots?: number;
1210 reviewerSlots?: number;
11981211 /** Who the forge acts as when it assigns and lands on its own. */
11991212 autonomyBy?: string | null;
12001213 /** Whether someone who cannot write here may start or steer a run. */
@@ -1226,7 +1239,7 @@ export function updateRepoSettings(
12261239 default_branch = ?4, require_review = ?5, require_checks = ?7,
12271240 autonomy = ?8, autonomy_direction = ?9, autonomy_ceiling_usd = ?10,
12281241 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
12301243 WHERE id = ?6`,
12311244 ).run(
12321245 settings.visibility ?? repo.visibility,
@@ -1244,6 +1257,8 @@ export function updateRepoSettings(
12441257 (settings.crewOutsiders ?? repo.crewOutsiders) ? 1 : 0,
12451258 settings.decisionsBy ?? repo.decisionsBy,
12461259 (settings.plansNeedNod ?? repo.plansNeedNod) ? 1 : 0,
1260 settings.plannerSlots ?? repo.plannerSlots,
1261 settings.reviewerSlots ?? repo.reviewerSlots,
12471262 );
12481263 forgetListings(db);
12491264 return getRepoById(db, repo.id)!;
src/crew/autonomy.ts +2
@@ -716,6 +716,8 @@ async function planIfThin(
716716 const limbs = pendingReplans(app.db, repo);
717717 const reserve = reserveOf(ready, repo.autonomySlots);
718718 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");
719721 if (repo.autonomyDirection.trim() === "")
720722 return notPlanned(repo, "no direction written");
721723 // 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";
2323 import { getHold, holdsFor, lastReleasesFor } from "../core/holds.ts";
2424 import { getIssueById, LIST_LIMIT_MAX } from "../core/issues.ts";
2525 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";
2727 import { teamsOfPull, teamVerdicts } from "../core/issue_teams.ts";
2828 import { reviewSummariesFor, reviewSummary } from "../core/reviews.ts";
2929 import { checksForHead } from "../core/runs.ts";
@@ -55,6 +55,18 @@ export const watchesSecurity = (agent: Agent): boolean =>
5555 /** Review runs a design reviewer holds in flight before a `src/web/` pull spills to the least busy. */
5656 export const REVIEW_DESIGN_INFLIGHT = 2;
5757
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
5870 /**
5971 * Review runs in flight (queued or running) per reviewer, by user id; an idle
6072 * reviewer is absent. `publicOnly` counts only runs on public repositories,
@@ -436,7 +448,10 @@ export async function sendToReview(
436448 ): Promise<"none" | "done" | "sent" | "wait" | "lapsed" | "held"> {
437449 if (getHold(app.db, pull.id) !== undefined) return "held";
438450 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";
440455 if (teamsOfPull(app.db, pull).length > 0) {
441456 const sent = await sendToTeams(app, repo, reviewers, pull, now);
442457 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"
66 import { emailVerified } from "../../../core/verify.ts";
77 import { governorSeen } from "../../../crew/governed.ts";
88 import { crewOffInPreview } from "../../../crew/preview.ts";
9 import { setSeatSlots, seatSlotsSaid } from "../../../actions/seat_slots.ts";
10 import { checkSeatSlots, seatSlotsOf } from "../../../crew/seat_slots.ts";
911 import { seatsOf } from "../../../crew/seats.ts";
1012 import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
1113
1214 export const tool: Tool = {
1315 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`",
1517 description: "List a repository's crew, or add or remove an agent. Plans first; admin token.",
1618 inputSchema: {
1719 type: "object",
@@ -19,6 +21,8 @@ export const tool: Tool = {
1921 repo: REPO_ARG,
2022 add: { type: "string" },
2123 remove: { type: "string" },
24 seat: { type: "string" },
25 slots: { type: "integer" },
2226 dryRun: { type: "boolean" },
2327 },
2428 },
@@ -30,10 +34,26 @@ export const tool: Tool = {
3034 const { app } = ctx;
3135 const add = args["add"];
3236 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 }
3353 if (add === undefined && remove === undefined) {
3454 const seen = crewAsSeen(app, user, repo);
3555 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) });
3757 }
3858 if ((add !== undefined && typeof add !== "string") || (remove !== undefined && typeof remove !== "string"))
3959 return bad("invalid", "`add` and `remove` are an agent's username");
src/http/server.ts +2
@@ -353,6 +353,7 @@ import {
353353 crewAdd,
354354 crewRemove,
355355 crewRoleUpdate,
356 pillarUpdate,
356357 crewSettingsPage,
357358 crewSubscriptionsSave,
358359 } from "../web/pages/crew-settings.ts";
@@ -1298,6 +1299,7 @@ export function buildRouter(): Router<Handler> {
12981299 "/:owner/:name/settings/crew/:username/role",
12991300 crewRoleUpdate,
13001301 );
1302 router.add("POST", "/:owner/:name/settings/pillars", pillarUpdate);
13011303 router.add("GET", "/:owner/:name/settings/autonomy", autonomySettingsPage);
13021304 router.add("POST", "/:owner/:name/settings/autonomy", autonomySettingsUpdate);
13031305 router.add("POST", "/:owner/:name/settings/holds", fileHoldUpdate);
src/tui/screens/autonomy.ts +23 −7
@@ -4,7 +4,9 @@
44 * the repository (`canAdmin`), an agent included, as the page's API lets one:
55 * they pick the mode with `m` (a confirm offering Off, Assisted and Autonomous
66 * 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
810 * `updateRepoSettings` with the fields the page writes and toasts the page's
911 * flash (`AUTONOMY_SAID`), and a value out of bounds is refused with the
1012 * page's sentence (`checkAutonomy`).
@@ -12,8 +14,12 @@
1214 */
1315
1416 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";
1520 import {
1621 AUTONOMY,
22 AUTONOMY_BOUNDS,
1723 AUTONOMY_SAID,
1824 canAdmin,
1925 checkAutonomy,
@@ -96,7 +102,7 @@ export function autonomyScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
96102 title: "Autonomy",
97103 get keys() {
98104 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")]
100106 : [];
101107 },
102108 render(area, s) {
@@ -110,7 +116,9 @@ export function autonomyScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
110116 `Mode ${r.autonomy}`,
111117 ` ${AUTONOMY_SAID[r.autonomy]}`,
112118 `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 ),
114122 ];
115123 lines.forEach((line, i) => {
116124 if (i < area.h)
@@ -153,10 +161,18 @@ export function autonomyScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
153161 });
154162 return true;
155163 }
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) {

Use maxOf(role) from crew/seat_slots.ts rather than repeating the 1 / 8 limits.

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 });
160176 return undefined;
161177 });
162178 return true;
src/web/pages/board.ts +1 −1
@@ -435,7 +435,7 @@ function reviewNotes(ctx: Ctx, scope: BoardScope): Note[] {
435435 key: `review:${w.repo.id}`,
436436 need: true,
437437 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
439439 ? null
440440 : html` · <b>${w.waiting}</b> waiting since ${w.since.slice(0, 10)}`}</a
441441 >`,
src/web/pages/crew-settings.ts +24
@@ -5,6 +5,8 @@
55 * form here redirects back to its `#crew` anchor with a flash.
66 */
77
8 import { seatSlotsSaid, setSeatSlots } from "../../actions/seat_slots.ts";
9 import { checkSeatSlots } from "../../crew/seat_slots.ts";
810 import { addToCrew, listCrew, removeFromCrew } from "../../actions/crew.ts";
911 import { getAgentByName, isCrewRole, setCrewRole } from "../../core/agents.ts";
1012 import type { Ctx } from "../../http/context.ts";
@@ -93,3 +95,25 @@ export async function crewRoleUpdate(ctx: Ctx): Promise<Response> {
9395 message: `${agent.user.username} is a ${role} here.`,
9496 });
9597 }
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> {
14301430 const { repo, form } = gate;
14311431 const base = repoBase(repo);
14321432 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);
14341436 const checked = checkAutonomy(field(form, "autonomy"), ceiling, slots);
14351437 if (!checked.ok)
14361438 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";
4040 import { icon } from "../icons.ts";
4141 import { crewReadiness } from "../pages/crew.ts";
4242 import { grantsSection } from "../pages/subscriptions.ts";
43 import { seatSlotsOf, seatSlotsWords, SEAT_UNIT } from "../../crew/seat_slots.ts";
4344 import { directionStall } from "../../crew/planner.ts";
4445 import { directionMarked } from "./direction.ts";
4546 import { fileHoldsCard } from "./file_holds.ts";
@@ -153,22 +154,40 @@ function teamCard(
153154 })}
154155 </form>`;
155156 };
157 const seatSlots = new Map(seatSlotsOf(ctx.app.db, repo).map((p) => [p.role, p]));
156158 const rows = team.seats.map((p) => {
157159 const fillBy = fill(p);
158160 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>`;
159170 return row({
160171 lead: icon(SEAT_LEAD[p.state]),
161172 title: p.role[0]!.toUpperCase() + p.role.slice(1),
162173 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}`,
164175 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 ],
165184 });
166185 });
167186 const intro = crewOffInPreview()
168187 ? "The crew is off in a preview, so nobody here is working."
169188 : team.auto
170189 ? `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.";
172191 return card(
173192 html`<p class="small" id="auto-mode" data-auto="${team.auto ? "on" : "off"}">
174193 ${intro}
@@ -441,13 +460,6 @@ ${repo.autonomyDirection}</textarea
441460 type: "number",
442461 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.`,
443462 })}
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 })}
451463 <div class="form-actions">${btn("Save", { variant: "primary" })}</div>
452464 </form>`;
453465 return card(html`${state}${form}`, {
test/review-route.test.ts +27 −2
@@ -13,16 +13,18 @@ import { join } from "node:path";
1313 import { createApp, type App } from "../src/app.ts";
1414 import { addToCrew, createAgent } from "../src/actions/crew.ts";
1515 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";
1717 import { setCrewRole, type Agent } from "../src/core/agents.ts";
1818 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";
2020 import type { Review } from "../src/core/reviews.ts";
2121 import { claimRun, createRun, finishRun, listRuns } from "../src/core/runs.ts";
2222 import { createUser, getUserByName, type User } from "../src/core/users.ts";
2323 import {
2424 pickReviewer,
2525 reviewLoad,
26 reviewsInFlight,
27 sendToReview,
2628 REVIEW_LAPSE_MS,
2729 REVIEW_WAIT_MS,
2830 reviewStage,
@@ -250,6 +252,29 @@ describe("every green pull an agent wrote is sent to review", () => {
250252 expect(await sweepReviews(app)).toBe(0);
251253 });
252254
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
253278 test("60 open green agent pulls: the oldest is sent on the first pass, and a pass sends a bounded few", async () => {
254279 const scout = getUserByName(app.db, "scout")!;
255280 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", () => {
202202 h.stop();
203203 });
204204
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
205227 test("a ceiling or slots out of range are refused with the page's sentence and nothing is saved", async () => {
206228 const h = boot(ada);
207229 await h.open();
@@ -214,7 +236,7 @@ describe("autonomy in the terminal", () => {
214236 expect(h.text()).toContain(AUTONOMY_BOUNDS);
215237 expect(saved().autonomyCeilingUsd).toBe(before.autonomyCeilingUsd);
216238 }
217 for (const bad of ["0", "9", "2.5", "x"]) {
239 for (const bad of ["9", "2.5", "x", "-1"]) {
218240 await h.press("s");
219241 for (let i = 0; i < 4; i++) await h.press("backspace");
220242 await h.type(bad);
@@ -233,7 +255,7 @@ describe("autonomy in the terminal", () => {
233255 const before = saved();
234256 const h = boot(get());
235257 await h.open();
236 expect(h.text()).not.toContain("Slots");
258 expect(h.text()).not.toContain("Builder");
237259 expect(h.text()).not.toContain("Ceiling");
238260 expect(h.text()).not.toContain("Mode ");
239261 await h.press("m", "left", "enter", "c", "s");
@@ -249,7 +271,7 @@ describe("autonomy in the terminal", () => {
249271 test("an agent who administers the repository changes it, as the page's API lets it", async () => {
250272 const h = boot(bot);
251273 await h.open();
252 expect(h.text()).toContain("Slots");
274 expect(h.text()).toContain("Builder");
253275 await h.press("s");
254276 for (let i = 0; i < 4; i++) await h.press("backspace");
255277 await h.type("2");

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs