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

Re-plan a limb lands from pull #2561's branch, with sealed titles tested, a bounded query and the refusal for non-admins #2588

Merged opened this

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

Merged

scout/forge:scout/2585-re-plan-a-limb-lands-from-pull-2561-s-br → main 7 commits fork 4 threads · 4 unresolved

  • Merged. scout/2585-re-plan-a-limb-lands-from-pull-2561-s-br was merged into main as c830d36.

Review

Sign in to review this change.
22 files changed +1234 −31
README.md +4 −1
@@ -300,7 +300,10 @@ repository's limbs in the order the crew takes ready work (`enter` opens a
300300 limb's own limbs). A person who administers the repository presses `r` to
301301 Raise or `l` to Lower the limb under the cursor: a preview of the new order
302302 and of the top of the ready list first, then `enter` to apply (`esc` backs
303 out), then `u` to undo the last move. It is the same fleet operation the web
303 out), then `u` to undo the last move. `p` hands the limb to the planner to
304 re-plan as fewer, larger pieces: it lists what the planner will be given, then
305 `enter` confirms; the row reads *with the planner*, and `p` takes it back
306 until the planner starts. It is the same fleet operation the web
304307 milestones page uses, and a move is heard by that page when it is open.

After this insertion, "It is the same fleet operation the web milestones page uses" now follows the re-plan sentence. Re-plan is explicitly not a fleet operation (docs/PLANNING.md says so). Move this sentence back so it follows Raise/Lower/undo, or name the move, so a reader doesn't look for a receipt to undo a hand-over.

305308 Anyone else sees the order and no keys to change it; a guest sees only what
306309 the web shows them. Whoever may see owner facts also gets two lines under
docs/PLANNING.md +22 −2
@@ -325,13 +325,33 @@ one undo.
325325 the ready order changes. It is one fleet row, the verb `place`; undo puts
326326 the place back. Through the API and the MCP `limb` tool (`action` raise or
327327 lower, `under` when the limb sits in several places), dry-run by default.
328 - **Re-plan** (#761) hands the limb to the planner to come back as fewer,
329 larger pieces. The preview lists every open issue the planner will be
330 given. Pressing it writes the hand-over (`limb_replan`, with the issues
331 it was given) and owes the planner one pass, even with a full map, even
332 after it declined the direction, and without waiting out the hour since
333 its last pass. Its task names the limb and lists the issues. Until the
334 planner has answered — a planning run begun after the hand-over has
335 ended — the limb reads _With the planner_ on its milestone and the crew
336 picks nothing beneath it (the `with_planner` hold). The planner may close
337 an issue beneath it only as `split` into open issues made since the
338 hand-over, as for a twice-parked issue; it may not drop one, and the
339 hand-over grants nothing outside the limb. A person may **take it back**
340 until a planning run has begun. Afterwards the milestone lists which old
341 issue closed into which. Not a fleet operation: it writes no issue row,
342 so there is no receipt to undo; taking it back is the undo.
343 In the terminal, `p` on the limbs screen (Milestones in a repository)
344 previews what the planner is given and hands the limb over on confirm; the
345 row then reads _with the planner_, and `p` takes it back while allowed.
346 Through the API and the MCP `limb` tool as `action` `replan` or
347 `takeback`, dry-run by default (the dry run is the list of issues).
328348
329349 An umbrella or the gate is the plan's own and is not filed. A leaf beneath
330350 a limb that is not deferred cannot be deferred alone: defer the limb. A
331 candidate to trim offers Defer first. Handing one to the planner is #761.
351 candidate to trim offers Defer first.
332352
333353 The same is `POST /api/v1/repos/:owner/:name/milestones/:number/limb`
334 `{ "action": "defer" | "resume" | "close" | "raise" | "lower", "reason"?, "pillar"?, "under"?, "dryRun"? }`
354 `{ "action": "defer" | "resume" | "close" | "raise" | "lower" | "replan" | "takeback", "reason"?, "pillar"?, "under"?, "dryRun"? }`
335355 and the MCP's `limb`, both a dry run unless `dryRun` is false. Applying and
336356 undoing take an admin token of a person who administers the repository; an
337357 agent may look and never apply.
src/actions/issues.ts +8 −2
@@ -55,6 +55,7 @@ import {
5555 unadoptedAmong,
5656 } from "../crew/direct.ts";
5757 import { laterGate } from "../crew/later.ts";
58 import { replanSince } from "../core/limb_replan.ts";
5859 import { handedOver } from "../crew/parks.ts";
5960 import { alreadyDone, CLOSED_NOTE } from "../core/done_on_main.ts";
6061 import { listReviews } from "../core/reviews.ts";
@@ -511,17 +512,22 @@ function handedToPlanner(
511512 // Replacements must be newer than the hand-over when the crew handed the
512513 // issue over, and newer than the issue itself when the planner re-plans it
513514 // on its own pass.
514 const since = handedOver(app.db, repo, issue.id) ?? {
515 // A limb a person handed over (`core/limb_replan.ts`): newer than that
516 // hand-over, and only as a split — a person's issue beneath it is not dropped.
517 const limbSince = replanSince(app.db, repo, issue);
518 const own = handedOver(app.db, repo, issue.id) ?? {
515519 at: issue.createdAt,
516520 id: issue.id,
517521 };
522 const since = limbSince !== undefined && limbSince > own.at ? { at: limbSince, id: "" } : own;
518523 // Done on main: only when commits already name the issue, as the close
519524 // note keeps them.
520525 if (closing.reason === undefined || closing.reason === "done")
521526 return (alreadyDone(app.db, [issue.id]).get(issue.id) ?? []).length > 0;
522527 // Dropped only when no person's ask stands in it — not one the planner
523528 // wrote to replace a person's, however many times it was split since.
524 if (closing.reason === "dropped") return !standsForPerson(app.db, issue);
529 if (closing.reason === "dropped")
530 return limbSince === undefined && !standsForPerson(app.db, issue);
525531 if (closing.reason !== "split") return false;
526532 const into = closing.into ?? [];
527533 if (into.length === 0) return false;
src/actions/limb_replan.ts added +168
@@ -0,0 +1,168 @@
1 /**
2 * Hand a limb to the planner (#761): `replan` writes the hand-over, `takeback`
3 * withdraws it before the planner has been given it. Both are a person's
4 * who administers the repository; anyone who can read may see the preview,
5 * which lists every issue the planner will be given. The planner's own
6 * grant to close what is beneath is in `actions/issues.ts`.
7 */
8
9 import type { App } from "../app.ts";
10 import { listCrewRoles } from "../core/agents.ts";
11 import {
12 answeredFor,
13 given,
14 openBeneath,
15 pendingFor,
16 recordReplan,
17 recordTakenBack,
18 splitsOf,
19 type Split,
20 } from "../core/limb_replan.ts";
21 import { canAdmin, canRead, type Repo } from "../core/repos.ts";
22 import type { User } from "../core/users.ts";
23 import { err, ok, type Result } from "../kernel/result.ts";
24 import { getIssueById } from "../core/issues.ts";
25 import type { FleetError } from "./fleet.ts";
26 import type { LimbIssue } from "./limbs.ts";
27
28 export const REPLAN_ACTIONS = ["replan", "takeback"] as const;
29 export type ReplanAction = (typeof REPLAN_ACTIONS)[number];
30
31 export const isReplanAction = (value: unknown): value is ReplanAction =>
32 (REPLAN_ACTIONS as readonly unknown[]).includes(value);
33
34 export interface ReplanPreview {
35 readonly limb: LimbIssue;
36 /** Every open issue the planner will be given: the limb's own, the limb first. */
37 readonly issues: readonly LimbIssue[];
38 /** Whether it is with the planner already, and whether it can still be taken back. */
39 readonly withPlanner: boolean;
40 readonly takeBack: boolean;
41 /** Why it cannot be handed over now, when it cannot. */
42 readonly blocked?: string;
43 }
44
45 export interface ReplanDone {
46 readonly done: "replan" | "takeback";
47 readonly limb: LimbIssue;
48 readonly issues: readonly LimbIssue[];
49 }
50
51 const fail = (code: FleetError["code"], message: string) =>
52 err<FleetError>({ code, message });
53
54 const brief = (i: { number: number; title: string }): LimbIssue => ({
55 number: i.number,
56 title: i.title,
57 });
58
59 function find(
60 app: App,
61 actor: User | undefined,
62 repo: Repo,
63 number: number,
64 ): Result<ReplanPreview, FleetError> {
65 if (!actor) return fail("unauthorized", "sign in to hand a limb to the planner");
66 if (!canRead(app.db, repo, actor)) return fail("not_found", "no such milestone here");
67 const issues = openBeneath(app.db, repo, number);
68 const limb = issues.find((i) => i.number === number);
69 if (limb === undefined) return fail("not_found", "no such milestone here");
70 const pending = pendingFor(app.db, repo, limb.id);
71 const blocked =
72 mayAct(app, actor, repo)?.message ??
73 (pending !== undefined
74 ? "it is with the planner already"
75 : repo.autonomy === "off"
76 ? "the crew is not running here, so there is no planner to ask"
77 : !listCrewRoles(app.db, repo.id).some((c) => c.role === "planner")
78 ? "there is no planner on the crew"
79 : issues.length < 2
80 ? "nothing is beneath it to re-plan"
81 : undefined);
82 return ok({
83 limb: brief(limb),
84 issues: issues.map(brief),
85 withPlanner: pending !== undefined,
86 takeBack: pending !== undefined && !given(app.db, repo, pending.at),
87 ...(blocked === undefined ? {} : { blocked }),
88 });
89 }
90
91 /** The dry run: every issue the planner will be given, and nothing written. */
92 export function previewReplan(
93 app: App,
94 actor: User | undefined,
95 repo: Repo,
96 number: number,
97 ): Result<ReplanPreview, FleetError> {
98 return find(app, actor, repo, number);
99 }
100
101 function mayAct(app: App, actor: User, repo: Repo): FleetError | undefined {
102 if (actor.kind === "agent")
103 return { code: "forbidden", message: "handing a limb to the planner is a person's call" };
104 if (!canAdmin(app.db, repo, actor))
105 return { code: "forbidden", message: "only a person who administers this app hands a limb over" };
106 return undefined;
107 }
108
109 /** Hand the limb over: the planner's next pass is owed it, once. */
110 export function replanLimb(
111 app: App,
112 actor: User | undefined,
113 repo: Repo,
114 number: number,
115 ): Result<ReplanDone, FleetError> {
116 const seen = find(app, actor, repo, number);
117 if (!seen.ok) return seen;
118 const refused = mayAct(app, actor!, repo);
119 if (refused) return err(refused);
120 if (seen.value.blocked !== undefined) return fail("bad_action", seen.value.blocked);
121 const issues = openBeneath(app.db, repo, number);
122 const limb = issues.find((i) => i.number === number)!;
123 recordReplan(app.db, repo, limb, actor!.id, issues);
124 app.bus.append({
125 kind: "limb.replan",
126 repoId: repo.id,
127 actorId: actor!.id,
128 payload: { number, issues: issues.map((i) => i.number) },
129 });
130 return ok({ done: "replan", limb: brief(limb), issues: issues.map(brief) });
131 }
132
133 /** Take it back, before the planner has been given it. */
134 export function takeBackLimb(
135 app: App,
136 actor: User | undefined,
137 repo: Repo,
138 number: number,
139 ): Result<ReplanDone, FleetError> {
140 const seen = find(app, actor, repo, number);
141 if (!seen.ok) return seen;
142 const refused = mayAct(app, actor!, repo);
143 if (refused) return err(refused);
144 const limb = openBeneath(app.db, repo, number).find((i) => i.number === number)!;
145 const pending = pendingFor(app.db, repo, limb.id);
146 if (pending === undefined) return fail("bad_action", "it is not with the planner");
147 if (!seen.value.takeBack)
148 return fail("bad_action", "the planner has it in hand already");
149 recordTakenBack(app.db, pending.id);
150 app.bus.append({
151 kind: "limb.replan_taken_back",
152 repoId: repo.id,
153 actorId: actor!.id,
154 payload: { number },
155 });
156 return ok({ done: "takeback", limb: brief(limb), issues: seen.value.issues });
157 }
158
159 /** What the planner's last answer on this limb changed: each old issue and what replaced it. */
160 export function replanResult(
161 app: App,
162 repo: Repo,
163 limbId: string,
164 ): { readonly at: string; readonly splits: readonly Split[] } | undefined {
165 if (getIssueById(app.db, limbId) === undefined) return undefined;
166 const r = answeredFor(app.db, repo, limbId);
167 return r === undefined ? undefined : { at: r.at, splits: splitsOf(app.db, repo, r) };
168 }
src/core/limb_replan.ts added +230
@@ -0,0 +1,230 @@
1 /**
2 * A limb handed to the planner (#761). A person presses Re-plan on a
3 * milestone: the limb and every open issue beneath it are written down, and
4 * the planner's next pass is owed them, once. The hand-over is pending until
5 * a planning run that was given it has ended, or the person takes it back
6 * before any run starts. While it is pending nothing beneath the limb is
7 * picked by the crew (`crew/held.ts`), and the planner may close what is
8 * beneath it only as split into what it opens after the hand-over
9 * (`actions/issues.ts`). After, the rows say which issue became which.
10 *
11 * Plain reads and writes over two tables; who may is the action's.
12 */
13
14 import type { Database } from "bun:sqlite";
15 import { id } from "../kernel/id.ts";
16 import { transact } from "../kernel/db.ts";
17 import { getIssueById, type Issue } from "./issues.ts";
18 import { readPlan } from "./milestones.ts";
19 import type { Repo } from "./repos.ts";
20
21 export interface Replan {
22 readonly id: string;
23 readonly limbId: string;
24 readonly byUser: string;
25 readonly at: string;
26 readonly takenBackAt: string | null;
27 }
28
29 interface Row {
30 id: string;
31 limb_issue_id: string;
32 by_user: string;
33 at: string;
34 taken_back_at: string | null;
35 }
36
37 const of = (r: Row): Replan => ({
38 id: r.id,
39 limbId: r.limb_issue_id,
40 byUser: r.by_user,
41 at: r.at,
42 takenBackAt: r.taken_back_at,
43 });
44
45 /** Whether a planning run was started after `at`, a cancelled one aside: the planner has been given it. */
46 export function given(db: Database, repo: Repo, at: string): boolean {
47 if (repo.directionIssueId === null) return false;
48 return (
49 db
50 .query<{ n: number }, [string, string]>(
51 `SELECT COUNT(*) AS n FROM run
52 WHERE issue_id = ?1 AND kind = 'agent' AND state != 'cancelled'
outdated

High: this counts cancelled runs as "given", but LAST_ANSWER below does not count them as answered. After a cancel the hand-over is pending and cannot be taken back, and planIfThin's r.at > seen never owes it again, so the limb is held from the crew forever. Add AND state != 'cancelled' here, and have planIfThin use !given(...) to decide what is owed.

53 AND created_at > ?2`,
54 )
55 .get(repo.directionIssueId, at)?.n ?? 0
56 ) > 0;
57 }
58
59 /**
60 * The start of the last finished planning run: a hand-over made after it has
61 * not been answered, one made before it has. One bound in SQL, no per-row query.
62 */
63 const LAST_ANSWER = `COALESCE((SELECT MAX(created_at) FROM run
64 WHERE issue_id = ?2 AND kind = 'agent' AND state IN ('ok','failed')), '')`;
65
66 /** The hand-overs the planner has not answered and nobody took back, newest first. */
67 export function pendingReplans(db: Database, repo: Repo): Replan[] {
68 return db
69 .query<Row, [string, string | null]>(
70 `SELECT * FROM limb_replan
71 WHERE repo_id = ?1 AND taken_back_at IS NULL AND at >= ${LAST_ANSWER}
72 ORDER BY at DESC, id DESC`,
73 )
74 .all(repo.id, repo.directionIssueId)
75 .map(of);
76 }
77
78 /** The pending hand-over of this limb, if there is one. */
79 export function pendingFor(
80 db: Database,
81 repo: Repo,
82 limbId: string,
83 ): Replan | undefined {
84 return pendingReplans(db, repo).find((r) => r.limbId === limbId);
85 }
86
87 /** The newest hand-over of this limb the planner answered. */
88 export function answeredFor(
89 db: Database,
90 repo: Repo,
91 limbId: string,
92 ): Replan | undefined {
93 const row = db
94 .query<Row, [string, string | null, string]>(
95 `SELECT * FROM limb_replan
96 WHERE repo_id = ?1 AND limb_issue_id = ?3 AND taken_back_at IS NULL
97 AND at < ${LAST_ANSWER}
98 ORDER BY at DESC, id DESC LIMIT 1`,
99 )
100 .get(repo.id, repo.directionIssueId, limbId);
101 return row === null ? undefined : of(row);
102 }
103
104 /** The issues a hand-over was given, as they were then. */
105 export function givenIssueIds(db: Database, replanId: string): string[] {
106 return db
107 .query<{ issue_id: string }, [string]>(
108 `SELECT issue_id FROM limb_replan_issue WHERE replan_id = ?1`,
109 )
110 .all(replanId)
111 .map((r) => r.issue_id);
112 }
113
114 /**
115 * The open issues of a limb now, the limb included, in plan order: what
116 * only it needs, so what something outside also waits for stays out.
117 */
118 export function openBeneath(
119 db: Database,
120 repo: Repo,
121 limbNumber: number,
122 ): Issue[] {
123 return (
124 readPlan(db, repo)
125 .limb(limbNumber)
126 ?.own.filter((i) => i.state === "open" && i.kind === "issue") ?? []
127 );
128 }
129
130 /** Write the hand-over down: the limb, and each issue it was given. */
131 export function recordReplan(
132 db: Database,
133 repo: Repo,
134 limb: Issue,
135 byUser: string,
136 issues: readonly Issue[],
137 ): Replan {
138 const rid = id();
139 transact(db, () => {
140 db.query(
141 `INSERT INTO limb_replan (id, repo_id, limb_issue_id, by_user) VALUES (?1, ?2, ?3, ?4)`,
142 ).run(rid, repo.id, limb.id, byUser);
143 for (const issue of issues)
144 db.query(
145 `INSERT INTO limb_replan_issue (replan_id, issue_id) VALUES (?1, ?2)`,
146 ).run(rid, issue.id);
147 });
148 return of(db.query<Row, [string]>(`SELECT * FROM limb_replan WHERE id = ?1`).get(rid)!);
149 }
150
151 /** Take it back: it is no longer pending. */
152 export function recordTakenBack(db: Database, replanId: string): void {
153 db.query(
154 `UPDATE limb_replan SET taken_back_at = strftime('%Y-%m-%dT%H:%M:%fZ','now')
155 WHERE id = ?1 AND taken_back_at IS NULL`,
156 ).run(replanId);
157 }
158
159 /** What a pending hand-over covers now: what it was given, and what is beneath the limb today. */
160 function covered(db: Database, repo: Repo, r: Replan): Set<string> {
161 const ids = new Set(givenIssueIds(db, r.id));
162 const limb = getIssueById(db, r.limbId);
163 if (limb !== undefined)
164 for (const i of openBeneath(db, repo, limb.number)) ids.add(i.id);
165 return ids;
166 }
167
168 /** Every issue the crew may not pick because its limb is with the planner. */
169 export function withPlanner(db: Database, repo: Repo): Set<string> {
170 const out = new Set<string>();
171 for (const r of pendingReplans(db, repo))
172 for (const i of covered(db, repo, r)) out.add(i);
173 return out;
174 }
175
176 /**
177 * When a limb this issue sits beneath was handed over, while that is
178 * pending: the planner may split the issue only into what is made after.
179 * Nothing for an issue no pending hand-over covers.
180 */
181 export function replanSince(
182 db: Database,
183 repo: Repo,
184 issue: Issue,
185 ): string | undefined {
186 let since: string | undefined;
187 for (const r of pendingReplans(db, repo)) {
188 if (!covered(db, repo, r).has(issue.id)) continue;
189 if (since === undefined || r.at > since) since = r.at;
190 }
191 return since;
192 }
193
194 /** An issue the planner closed as split, and what it became. */
195 export interface Split {
196 readonly number: number;
197 readonly title: string;
198 readonly into: readonly { readonly number: number; readonly title: string }[];
199 }
200
201 /** What a hand-over changed: each issue it was given that closed as split, into which. */
202 export function splitsOf(db: Database, repo: Repo, r: Replan): Split[] {
203 const out: Split[] = [];
204 for (const issueId of givenIssueIds(db, r.id)) {
205 const issue = getIssueById(db, issueId);
206 // A sealed issue is never named to a reader.
207 if (issue === undefined || issue.sealed || issue.state !== "closed" || issue.closeReason !== "split")
208 continue;
209 const into = db
210 .query<{ parts: string }, [string, number]>(
211 `SELECT json_extract(payload, '$.into') AS parts FROM event
212 WHERE repo_id = ?1 AND kind = 'issue.closed'
213 AND json_extract(payload, '$.reason') = 'split'
214 AND json_extract(payload, '$.number') = ?2
215 ORDER BY id DESC LIMIT 1`,
216 )
217 .get(repo.id, issue.number);
218 const numbers: unknown = into === null || into === undefined ? [] : JSON.parse(into.parts);
219 const parts = (Array.isArray(numbers) ? numbers : []).flatMap((n) => {
220 const part = typeof n === "number" ? db
221 .query<{ number: number; title: string }, [string, number]>(
222 `SELECT number, title FROM issue WHERE repo_id = ?1 AND number = ?2 AND sealed = 0`,
223 )
224 .get(repo.id, n) : null;
225 return part === null ? [] : [part];
226 });
227 out.push({ number: issue.number, title: issue.title, into: parts });
228 }
229 return out.sort((a, b) => a.number - b.number);
230 }
src/core/migrations/20261018110000-limb-replan.ts added +18
@@ -0,0 +1,18 @@
1 // A person hands a limb to the planner to re-plan as fewer, larger pieces
2 // (#761): one row for the hand-over, one for each open issue it was given.
3 export default `
4 CREATE TABLE limb_replan (
5 id TEXT PRIMARY KEY,
6 repo_id TEXT NOT NULL REFERENCES repo(id) ON DELETE CASCADE,
7 limb_issue_id TEXT NOT NULL REFERENCES issue(id) ON DELETE CASCADE,
8 by_user TEXT NOT NULL,
9 at TEXT NOT NULL DEFAULT (strftime('%Y-%m-%dT%H:%M:%fZ','now')),
10 taken_back_at TEXT
11 );
12 CREATE INDEX limb_replan_repo ON limb_replan(repo_id, at);
13 CREATE TABLE limb_replan_issue (
14 replan_id TEXT NOT NULL REFERENCES limb_replan(id) ON DELETE CASCADE,
15 issue_id TEXT NOT NULL REFERENCES issue(id) ON DELETE CASCADE,
16 PRIMARY KEY (replan_id, issue_id)
17 );
18 `;
src/crew/autonomy.ts +14 −5
@@ -89,6 +89,7 @@ import { reviewSummary } from "../core/reviews.ts";
8989 import { standing } from "../core/standing.ts";
9090 import { pace, type Pace } from "./pacing.ts";
9191 import { BREAKER_NOTE, breakerTripped } from "./breaker.ts";
92 import { given, pendingReplans } from "../core/limb_replan.ts";
9293 import { handedOverPulls, SKIP_SUMMARY, skipsReplan } from "./guard.ts";
9394 import { atRunCap, type Held, heldBy, heldWords, replannable } from "./held.ts";
9495 import { BY_CREW, DONE_LIKE } from "../core/done_on_main.ts";
@@ -562,7 +563,8 @@ async function planIfThin(
562563 stuck: Held | null = null,
563564 ): Promise<boolean> {
564565 const handed = [...parkedTwice(app.db, [repo]), ...handedOverPulls(app.db, repo)];
565 if (ready >= THIN_MAP && handed.length === 0) return false;
566 const limbs = pendingReplans(app.db, repo);
567 if (ready >= THIN_MAP && handed.length === 0 && limbs.length === 0) return false;
566568 const planner = crew.find((c) => c.role === "planner")?.agent;
567569 if (planner === undefined) return notPlanned(repo, "no planner on the crew");
568570 if (repo.autonomyDirection.trim() === "")
@@ -582,11 +584,14 @@ async function planIfThin(
582584 // The hand-overs the planner has not been given: newer than its last pass.
583585 const seen = lastPlan?.created_at ?? "";
584586 const owed = handed.filter((issue) => issue.at > seen);
585 if (ready >= THIN_MAP && owed.length === 0) return false;
587 // A limb a person handed over is owed one pass, whatever the map and
588 // whatever the planner said of the direction before (#761).
589 const limbsOwed = limbs.filter((r) => !given(app.db, repo, r.at));
outdated

seen is the newest run on the direction in any state, a cancelled one included. A limb handed over before a run that was later cancelled is never owed again. Use limbs.filter((r) => !given(app.db, repo, r.at)), with given leaving out cancelled runs.

590 if (ready >= THIN_MAP && owed.length === 0 && limbsOwed.length === 0) return false;
586591 // Nothing startable for issues held since its last pass that re-planning
587592 // lifts: owed one pass like a hand-over, once (#2244).
588593 const unstuck = stuck === null ? [] : replannable(stuck).filter((issue) => heldSince(app, issue, seen));
589 const news = owed.length + unstuck.length;
594 const news = owed.length + unstuck.length + limbsOwed.length;
590595 // The direction is the planner's standing issue, not one issue retried:
591596 // it is held to its own day's share, never the builders' cap (#2143).
592597 if (atRunCap(app, repo, direction))
@@ -606,9 +611,11 @@ async function planIfThin(
606611 return notPlanned(repo, "the planner declined; waiting for a person");
607612 if (lastPlan.state === "queued" || lastPlan.state === "running")
608613 return notPlanned(repo, "a planning run is in flight");
614 // A person's hand-over is not made to wait for the last pass's hour.
609615 if (
616 limbsOwed.length === 0 &&
610617 now.getTime() - new Date(lastPlan.created_at).getTime() <
611 REPLAN_AFTER_MS
618 REPLAN_AFTER_MS
612619 )
613620 return notPlanned(repo, "planned recently");
614621 // Nobody spoke and the map has not moved since the last pass: another
@@ -631,7 +638,9 @@ async function planIfThin(
631638 if (!assigned.ok)
632639 return notPlanned(repo, `could not assign: ${String(assigned.error)}`);
633640 log.info(
634 owed.length > 0
641 limbsOwed.length > 0
642 ? "a limb was handed over; planner asked"
643 : owed.length > 0
635644 ? "parked twice; planner asked"
636645 : unstuck.length > 0
637646 ? "nothing startable; planner asked"
src/crew/brief.ts +31 −1
@@ -186,6 +186,8 @@ export interface TaskInput extends BriefInput {
186186 readonly looks?: readonly string[] | undefined;
187187 /** For a planner: the issues the crew parked twice, with why. */
188188 readonly parkedTwice?: readonly HandedOver[] | undefined;
189 /** For a planner: the limbs a person handed over to re-plan as fewer, larger pieces. */
190 readonly limbs?: readonly HandedLimb[] | undefined;
189191 /** For a planner: the issue deferred work waits behind, if there is one. */
190192 readonly laterGate?: number | undefined;
191193 /** For a planner: nothing ready can start, and what holds it (`held.ts`). */
@@ -204,6 +206,13 @@ export interface Stuck {
204206 }[];
205207 }
206208
209 /** A limb a person handed to the planner, with every open issue beneath it. */
210 export interface HandedLimb {
211 readonly number: number;
212 readonly title: string;
213 readonly issues: readonly { readonly number: number; readonly title: string }[];
214 }
215
207216 /** An issue the crew parked twice, as the planner is told of it. */
208217 export interface HandedOver {
209218 readonly number: number;
@@ -234,7 +243,7 @@ function taskBody(input: TaskInput): string {
234243 const { issue } = input;
235244 const out: string[] = [];
236245 const isAgent = (name: string): boolean => name === input.agent.user.username;
237 const handed = [parkedTwiceLines(input), stuckLines(input)]
246 const handed = [parkedTwiceLines(input), limbLines(input), stuckLines(input)]
238247 .filter((lines) => lines.length > 0)
239248 .flatMap((lines, n) => (n === 0 ? lines : ["", ...lines]));
240249 const readers = input.readers ?? new Set<string>();
@@ -533,6 +542,27 @@ function parkedTwiceLines(input: TaskInput): string[] {
533542 ];
534543 }
535544
545 /**
546 * What a planner is told of a limb a person handed it: the limb and every
547 * open issue beneath it, to come back as fewer, larger issues. Each old
548 * issue is closed as split into the new ones, never dropped — the person
549 * asked for them. Nothing outside the limb is touched.
550 */
551 function limbLines(input: TaskInput): string[] {
552 const limbs = input.role === "planner" ? (input.limbs ?? []) : [];
553 if (limbs.length === 0) return [];
554 const issues = `$FORGE_URL/api/v1/repos/${input.repo.ownerName}/${input.repo.name}/issues`;
555 return [
556 "A person handed you these limbs to re-plan as fewer, larger pieces. Nothing beneath one is picked by the crew until you have answered:",
557 ...limbs.flatMap((limb) => [
558 `- Limb #${limb.number} ${limb.title}, ${limb.issues.length} open issue${limb.issues.length === 1 ? "" : "s"}:`,
559 ...limb.issues.map((i) => ` #${i.number} ${i.title}`),
560 ]),
561 `Open the larger issues first, each saying what done looks like. Then close every old issue of the limb as split into them — PATCH ${issues}/N with {"state":"closed","reason":"split","into":[A,B]}. Only open issues made since the hand-over are accepted, and nothing outside the limb is yours to close this way. Do not drop an issue of the limb: a person asked for it, so what it asked for must be in a larger piece.`,
562 `If other issues wait for one you close, make them wait for what replaces it: DELETE ${issues}/D/dependencies/N, then POST ${issues}/D/dependencies with {"blockedBy":M}. Make the new issues wait for the limb's issue, so they stay beneath it.`,
563 ];
564 }
565
536566 /**
537567 * What a planner is told when no ready issue can start while builders sit
538568 * free: the count per filter, and each stalled or capped issue, to re-plan
src/crew/handed_limbs.ts added +22
@@ -0,0 +1,22 @@
1 /**
2 * The limbs a person handed to the planner (#761), as its task is told of
3 * them: each with every open issue beneath it. Only those it has not yet
4 * answered (`core/limb_replan.ts`).
5 */
6
7 import type { App } from "../app.ts";
8 import { getIssueById } from "../core/issues.ts";
9 import { openBeneath, pendingReplans } from "../core/limb_replan.ts";
10 import type { Repo } from "../core/repos.ts";
11 import type { HandedLimb } from "./brief.ts";
12
13 export function handedLimbs(app: App, repo: Repo): HandedLimb[] {
14 return pendingReplans(app.db, repo).flatMap((r) => {
15 const limb = getIssueById(app.db, r.limbId);
16 if (limb === undefined) return [];
17 const beneath = openBeneath(app.db, repo, limb.number).filter((i) => i.id !== limb.id);
18 return beneath.length === 0
19 ? []
20 : [{ number: limb.number, title: limb.title, issues: beneath.map((i) => ({ number: i.number, title: i.title })) }];
21 });
22 }
src/crew/held.ts +6
@@ -9,6 +9,8 @@
99 * - `running`: a run is in flight on it.
1010 * - `open_pull`: an open pull already proposes it (`direct.ts`).
1111 * - `planner`: the planner or a reviewer holds it, not a builder.
12 * - `with_planner`: its limb was handed to the planner by a person and the
13 * planner has not answered (`core/limb_replan.ts`).
1214 * - `done_on_main`: the crew found it done on the base branch and waits for
1315 * a person to close it (`done_on_main.ts`); nothing is left to plan.
1416 * - `stalled`: a builder ran on it and left without a pull, and nobody
@@ -27,6 +29,7 @@ import { listCrewRoles, type Agent, type CrewRole } from "../core/agents.ts";
2729 import { readyNow, repoGraph, type Ranked } from "../core/dependencies.ts";
2830 import { alreadyDone, BY_CREW, DONE_LIKE } from "../core/done_on_main.ts";
2931 import type { Issue } from "../core/issues.ts";
32 import { withPlanner } from "../core/limb_replan.ts";
3033 import type { Repo } from "../core/repos.ts";
3134 import { listActive, type Run } from "../core/runs.ts";
3235 import { getUserById } from "../core/users.ts";
@@ -42,6 +45,7 @@ export const HOLDS = [
4245 "running",
4346 "open_pull",
4447 "planner",
48 "with_planner",
4549 "done_on_main",
4650 "stalled",
4751 "run_cap",
@@ -115,10 +119,12 @@ export function heldBy(
115119 const planners = new Set(
116120 crew.filter((c) => c.role !== "builder").map((c) => c.agent.user.id),
117121 );
122 const handed = withPlanner(app.db, repo);
118123 const holdOf = (issue: Issue): HoldName | null => {
119124 if (busyIssues.has(issue.id)) return "running";
120125 if (proposed.has(issue.number)) return "open_pull";
121126 if (issue.assigneeId !== null && planners.has(issue.assigneeId)) return "planner";
127 if (handed.has(issue.id)) return "with_planner";
122128 if (alreadyDone(app.db, [issue.id]).has(issue.id)) return "done_on_main";
123129 if (stalled(app, repo, issue)) return "stalled";
124130 if (atRunCap(app, repo, issue)) return "run_cap";
src/crew/runner.ts +2
@@ -108,6 +108,7 @@ import {
108108 import { clearStaleLocks, lockedOut } from "./locks.ts";
109109 import { interruptedSummary } from "./interrupted.ts";
110110 import { laterGate } from "./later.ts";
111 import { handedLimbs } from "./handed_limbs.ts";
111112 import { parkedTwice } from "./parks.ts";
112113 import { heldNow, stuckOf } from "./held.ts";
113114 import { noteLimit } from "../core/standing.ts";
@@ -974,6 +975,7 @@ async function work(
974975 ...(brief.role === "planner" && issue.id === repo.directionIssueId
975976 ? {
976977 parkedTwice: [...parkedTwice(app.db, [repo]), ...handedOverPulls(app.db, repo)],
978 limbs: handedLimbs(app, repo),
977979 laterGate: laterGate(app.db, repo.id),
978980 stuck: stuckOf(heldNow(app, repo)),
979981 }
src/http/api.ts +18 −1
@@ -313,6 +313,12 @@ import {
313313 milestonesJson,
314314 } from "../web/pages/milestones.ts";
315315 import { applyLimb, isLimbAction, previewLimb } from "../actions/limbs.ts";
316 import {
317 isReplanAction,
318 previewReplan,
319 replanLimb,
320 takeBackLimb,
321 } from "../actions/limb_replan.ts";
316322 import { appsRuntime, envsFor, type AppsRuntime } from "../apps/runtime.ts";
317323 import { isEnvName } from "../apps/data.ts";
318324 import { getAppByRepo } from "../core/apps.ts";
@@ -3157,8 +3163,19 @@ export function apiRoutes(router: Router<Handler>): void {
31573163 const repo = loadRepo(ctx);
31583164 const number = positiveInt(ctx.params.number);
31593165 if (!repo || number === undefined) return notFound();
3166 if (isReplanAction(input.action)) {
3167 if (dryRun) {
3168 const plan = previewReplan(ctx.app, ctx.auth.user, repo, number);
3169 return plan.ok ? json({ dryRun: true, ...plan.value }) : respondFleet(plan.error);
3170 }
3171 const done =
3172 input.action === "replan"
3173 ? replanLimb(ctx.app, ctx.auth.user, repo, number)
3174 : takeBackLimb(ctx.app, ctx.auth.user, repo, number);
3175 return done.ok ? json({ dryRun: false, ...done.value }, 201) : respondFleet(done.error);
3176 }
31603177 if (!isLimbAction(input.action))
3161 return apiError("bad_action", 422, "action is defer, resume, close, raise or lower");
3178 return apiError("bad_action", 422, "action is defer, resume, close, raise, lower, replan or takeback");
31623179 const request = {
31633180 action: input.action,
31643181 ...(typeof input.reason === "string" ? { reason: input.reason } : {}),
src/http/mcp.ts +25 −3
@@ -132,6 +132,12 @@ import { setArchived, setCrewOutsiders, setDecisionsBy } from "../actions/repos.
132132 import { mapJson, marksFor } from "../web/pages/map.ts";
133133 import { applyFleetIds, previewFleetIds, undoFleetOp } from "../actions/fleet.ts";
134134 import { applyLimb, isLimbAction, previewLimb } from "../actions/limbs.ts";
135 import {
136 isReplanAction,
137 previewReplan,
138 replanLimb,
139 takeBackLimb,
140 } from "../actions/limb_replan.ts";
135141 import {
136142 milestoneData,
137143 milestonesJson,
@@ -2076,7 +2082,7 @@ const TOOLS: readonly Tool[] = [
20762082 {
20772083 name: "limb",
20782084 description:
2079 "One action on a whole limb of the plan — a milestone and everything beneath it that only it needs. `defer` moves the limb behind the Later gate, so the crew picks none of it; `resume` brings it back, under `pillar` when you name one; `close` closes every open issue of the limb as not doing, with `reason` said on each; `raise` and `lower` move it one place among its siblings — `under` says whose limbs when it is a limb of several, or `top` — and the crew takes ready work from the higher limb first; the dry run answers with the new `order` and the top of the ready list `before` and `after`. `undo` reverses an `operation` by its id. A dry run unless `dryRun` is false: it answers with every row before and after, what the action takes along (`taken`) and what stays because something outside the limb still waits for it (`kept`), and changes nothing. Applying and undoing take an admin token of a person who administers the app, and answer with the receipt.",
2085 "One action on a whole limb of the plan — a milestone and everything beneath it that only it needs. `defer` moves the limb behind the Later gate, so the crew picks none of it; `resume` brings it back, under `pillar` when you name one; `close` closes every open issue of the limb as not doing, with `reason` said on each; `raise` and `lower` move it one place among its siblings — `under` says whose limbs when it is a limb of several, or `top` — and the crew takes ready work from the higher limb first; the dry run answers with the new `order` and the top of the ready list `before` and `after`. `replan` hands the limb to the planner to re-plan as fewer, larger pieces: the dry run lists every open issue it will be given, applying owes the planner's next pass once and holds the limb from the crew until it answers, and `takeback` withdraws it before the planner starts. `undo` reverses an `operation` by its id. A dry run unless `dryRun` is false: it answers with every row before and after, what the action takes along (`taken`) and what stays because something outside the limb still waits for it (`kept`), and changes nothing. Applying and undoing take an admin token of a person who administers the app, and answer with the receipt.",
20802086 inputSchema: {
20812087 type: "object",
20822088 properties: {
@@ -2086,7 +2092,7 @@ const TOOLS: readonly Tool[] = [
20862092 minimum: 1,
20872093 description: "The limb: the number of its issue.",
20882094 },
2089 action: { type: "string", enum: ["defer", "resume", "close", "raise", "lower", "undo"] },
2095 action: { type: "string", enum: ["defer", "resume", "close", "raise", "lower", "replan", "takeback", "undo"] },
20902096 reason: {
20912097 type: "string",
20922098 description: "For close: why it is not being done, in one sentence.",
@@ -2129,13 +2135,29 @@ const TOOLS: readonly Tool[] = [
21292135 return undone.ok ? good(undone.value) : refused(undone.error);
21302136 }
21312137 const number = args["issue"];
2138 if (
2139 isReplanAction(args["action"]) &&
2140 typeof number === "number" &&
2141 Number.isInteger(number) &&
2142 number > 0
2143 ) {
2144 if (dry) {
2145 const plan = previewReplan(ctx.app, user, repo, number);
2146 return plan.ok ? good({ dryRun: true, ...plan.value }) : refused(plan.error);
2147 }
2148 const done =
2149 args["action"] === "replan"
2150 ? replanLimb(ctx.app, user, repo, number)
2151 : takeBackLimb(ctx.app, user, repo, number);
2152 return done.ok ? good({ dryRun: false, ...done.value }) : refused(done.error);
2153 }
21322154 if (
21332155 !isLimbAction(args["action"]) ||
21342156 typeof number !== "number" ||
21352157 !Number.isInteger(number) ||
21362158 number < 1
21372159 )
2138 return bad("invalid", "give the limb's issue number and an action: defer, resume, close, raise, lower or undo");
2160 return bad("invalid", "give the limb's issue number and an action: defer, resume, close, raise, lower, replan, takeback or undo");
21392161 const request = {
21402162 action: args["action"],
21412163 ...(typeof args["reason"] === "string" ? { reason: args["reason"] } : {}),
src/kernel/plural.ts added +8
@@ -0,0 +1,8 @@
1 /**
2 * "1 comment", "0 comments". A count and its noun are one phrase, so they are
3 * formatted in one place — the alternative is `1 commits` in whichever screen
4 * was written last, which is precisely what happened before this existed.
5 */
6 export function plural(n: number, word: string, many = `${word}s`): string {
7 return `${n} ${n === 1 ? word : many}`;
8 }
src/tui/screens/common.ts +1 −8
@@ -1035,11 +1035,4 @@ export function ago(iso: string): string {
10351035 return rel === "now" ? "just now" : `${rel} ago`;
10361036 }
10371037
1038 /**
1039 * "1 comment", "0 comments". A count and its noun are one phrase, so they are
1040 * formatted in one place — the alternative is `1 commits` in whichever screen
1041 * was written last, which is precisely what happened before this existed.
1042 */
1043 export function plural(n: number, word: string, many = `${word}s`): string {
1044 return `${n} ${n === 1 ? word : many}`;
1045 }
1038 export { plural } from "../../kernel/plural.ts";
src/tui/screens/limbs.ts +65 −2
@@ -12,6 +12,11 @@
1212 * facts (`canSeeOwnerFacts`, as on the page) also gets, under the list, what
1313 * the limb under the cursor cost and landed, and its costliest issues.
1414 *
15 * `p` hands the limb under the cursor to the planner to re-plan as fewer,
16 * larger pieces (#761): `previewReplan` lists what it will be given, then
17 * `replanLimb` on confirm. The row reads "with the planner" until it
18 * answers, and `p` then takes it back while that is still allowed.
19 *
1520 * `enter` on a limb that has limbs of its own opens them, so a milestone's
1621 * sub-limbs can be moved among themselves (the `under` of the action).
1722 * Subscribed to the bus while on the stack, so a move made on the web, or in
@@ -19,6 +24,8 @@
1924 */
2025
2126 import { undoFleetOp } from "../../actions/fleet.ts";
27 import { previewReplan, replanLimb, takeBackLimb } from "../../actions/limb_replan.ts";
28 import { pendingReplans } from "../../core/limb_replan.ts";
2229 import { applyLimb, previewLimb, type LimbPreview } from "../../actions/limbs.ts";
2330 import { canAdmin, canRead, canSeeOwnerFacts, type Repo } from "../../core/repos.ts";
2431 import { readPlan, type Limb, type Milestone } from "../../core/milestones.ts";
@@ -40,7 +47,7 @@ import {
4047 } from "./common.ts";
4148
4249 /** What reorders or reshapes the limbs: a move, or an issue opening or closing. */
43 const LIVE_KINDS = ["limb.moved", "issue.opened", "issue.closed", "issue.reopened"];
50 const LIVE_KINDS = ["limb.moved", "limb.replan", "limb.replan_taken_back", "issue.opened", "issue.closed", "issue.reopened"];
4451
4552 /** How many lines of the new order and of the ready list a preview shows. */
4653 const SHOWN = 6;
@@ -104,6 +111,8 @@ export function limbsScreen(
104111 let spends = new Map<number, NonNullable<Milestone["spend"]>>();
105112 /** The receipt of the last move made here, which `u` reverses once. */
106113 let last: string | undefined;
114 /** The limbs with the planner, by issue id (#761). */
115 let withPlanner = new Set<string>();
107116 // Read at use, not at open: a repository's admin can change under a session.
108117 const may = (): boolean =>
109118 f.user !== undefined && f.user.kind === "user" && canAdmin(f.db, repo, f.user);
@@ -117,6 +126,7 @@ export function limbsScreen(
117126 return;
118127 }
119128 limbs = limbsOf(f, repo, group) ?? [];
129 withPlanner = new Set(pendingReplans(f.db, repo).map((r) => r.limbId));
120130 spends = new Map();
121131 if (canSeeOwnerFacts(f.db, repo, f.user)) {
122132 const plan = readPlan(f.db, repo, { spend: true });
@@ -175,6 +185,57 @@ export function limbsScreen(
175185 );
176186 };
177187
188 /** Re-plan: hand the limb to the planner after reading what it is given; or take it back. */
189 const replan = (): void => {
190 const limb = current();
191 if (limb === undefined || !may()) return;
192 const number = limb.issue.number;
193 const seen = previewReplan(f.app, f.user, repo, number);
194 if (!seen.ok) {
195 ctx.toast(seen.error.message, "danger");
196 return;
197 }
198 const p = seen.value;
199 if (p.withPlanner && !p.takeBack) {
200 ctx.toast("the planner has it in hand already", "danger");
201 return;
202 }
203 if (!p.withPlanner && p.blocked !== undefined) {
204 ctx.toast(p.blocked, "danger");
205 return;
206 }
207 const taking = p.withPlanner;
208 const body = [
209 taking
210 ? "It is with the planner; nothing beneath it is picked. Take it back before the planner starts:"
211 : `The planner will be given ${plural(p.issues.length, "issue")}, to close as split into fewer, larger ones:`,
212 ...p.issues.slice(0, SHOWN).map((i) => ` ${line(i.number, i.title)}`),
213 ...(p.issues.length > SHOWN ? [` … ${p.issues.length - SHOWN} more`] : []),
214 ];
215 ctx.push(
216 confirmScreen(ctx, screen, {
217 title: `${taking ? "Take back" : "Re-plan"} “${limb.name}”?`,
218 body,
219 actions: [
220 { label: taking ? "Take it back" : "Hand to the planner", variant: "default" },
221 { label: "Cancel", variant: "ghost" },
222 ],
223 onChoose: (choice) => {
224 if (choice !== 0) return;
225 const done = taking
226 ? takeBackLimb(f.app, f.user, repo, number)
227 : replanLimb(f.app, f.user, repo, number);
228 if (!done.ok) {
229 ctx.toast(done.error.message, "danger");
230 return;
231 }
232 reload(number);
233 ctx.toast(taking ? "taken back" : "with the planner", "success");
234 },
235 }),
236 );
237 };
238
178239 const undo = (): void => {
179240 const id = last;
180241 if (id === undefined || !may()) return;
@@ -202,7 +263,7 @@ export function limbsScreen(
202263 const rows = (): ListRow[] =>
203264 limbs.map((l, n) => ({
204265 title: `${n + 1}. ${line(l.issue.number, l.name)}`,
205 secondary: l.deferred ? "deferred" : "",
266 secondary: withPlanner.has(l.issue.id) ? "with the planner" : l.deferred ? "deferred" : "",
206267 meta: `${l.weight.done}/${l.weight.total} done`,
207268 }));
208269
@@ -215,6 +276,7 @@ export function limbsScreen(
215276 binding(["enter"], "open"),
216277 binding(["r"], "raise", { disabled: !moves }),
217278 binding(["l"], "lower", { disabled: !moves }),
279 binding(["p"], withPlanner.has(current()?.issue.id ?? "") ? "take back" : "re-plan", { disabled: !may() || current() === undefined }),
218280 binding(["u"], "undo", { disabled: !may() || last === undefined }),
219281 ];
220282 },
@@ -246,6 +308,7 @@ export function limbsScreen(
246308 if (e.key === "enter") open();
247309 else if (e.key === "r") move("raise");
248310 else if (e.key === "l") move("lower");
311 else if (e.key === "p") replan();
249312 else if (e.key === "u") undo();
250313 else return false;
251314 return true;
src/web/live.ts +1 −1
@@ -302,7 +302,7 @@ export const liveClientJs = /* js */ `
302302 'label.created', 'label.updated', 'label.deleted',
303303 'run.queued', 'run.started', 'run.finished', 'app.serving',
304304 'app.created', 'app.updated', 'app.deleted', 'app.stopped',
305 'limb.moved', 'issue.assigned', 'pull.assigned', 'crew.added', 'crew.removed',
305 'limb.moved', 'limb.replan', 'limb.replan_taken_back', 'issue.assigned', 'pull.assigned', 'crew.added', 'crew.removed',
306306 ];
307307 var extra = (cfg.getAttribute('data-repos') || '').split(' ').filter(Boolean);
308308 var watching = [repo].concat(extra).filter(function (r, i, all) {
src/web/pages/limb.ts +4
@@ -6,6 +6,7 @@
66 * take it back once.
77 */
88
9 import { isReplanAction } from "../../actions/limb_replan.ts";
910 import { getFleetOp, undoFleetOp } from "../../actions/fleet.ts";
1011 import {
1112 applyLimb,
@@ -32,6 +33,7 @@ import { field, readForm } from "../form.ts";
3233 import { html, when, type Html } from "../html.ts";
3334 import { page, redirect } from "../layout.ts";
3435 import { btn } from "../ui.ts";
36 import { replanPreviewPage, replanSubmit } from "./limb_replan.ts";
3537 import { milestoneHref, milestonesCss } from "./milestones.ts";
3638
3739 const WORDS: Record<LimbAction, { ask: string; does: string; go: string }> = {
@@ -166,6 +168,7 @@ export function limbPreviewPage(ctx: Ctx): Response {
166168 if (!ctx.auth.user) return signInFirst(ctx);
167169 const number = Number(ctx.params.number);
168170 const action = ctx.url.searchParams.get("action");
171 if (action === "replan") return replanPreviewPage(ctx, repo, number);
169172 if (!Number.isInteger(number) || !isLimbAction(action))
170173 return notFoundPage(ctx);
171174 const request = requestOf(action, (n) => ctx.url.searchParams.get(n) ?? "");
@@ -350,6 +353,7 @@ export async function limbSubmit(ctx: Ctx): Promise<Response> {
350353 if (form instanceof Response) return form;
351354 const number = Number(ctx.params.number);
352355 const action = field(form, "action");
356 if (isReplanAction(action)) return replanSubmit(ctx, repo, number, action);
353357 if (!Number.isInteger(number) || !isLimbAction(action))
354358 return notFoundPage(ctx);
355359 const base = repoBase(repo);
src/web/pages/limb_replan.ts added +106
@@ -0,0 +1,106 @@
1 /**
2 * Handing a limb to the planner from its milestone (#761): the preview that
3 * lists every issue the planner will be given, the confirm, and taking it
4 * back. Over `actions/limb_replan.ts`; the same pattern as `limb.ts`.
5 */
6
7 import {
8 previewReplan,
9 replanLimb,
10 takeBackLimb,
11 type ReplanAction,
12 } from "../../actions/limb_replan.ts";
13 import type { Repo } from "../../core/repos.ts";
14 import type { Ctx } from "../../http/context.ts";
15 import {
16 crumbsFor,
17 notFoundPage,
18 repoBase,
19 repoHeader,
20 repoPalette,
21 } from "../chrome.ts";
22 import { html } from "../html.ts";
23 import { page, redirect } from "../layout.ts";
24 import { plural } from "../../kernel/plural.ts";
outdated

Low: a web page now imports from the TUI layer. Move plural to a shared module.

25 import { btn } from "../ui.ts";
26 import { milestoneHref, milestonesCss } from "./milestones.ts";
27
28 /** GET /:owner/:name/milestones/:number/limb?action=replan — what the planner will be given. */
29 export function replanPreviewPage(ctx: Ctx, repo: Repo, number: number): Response {
30 const plan = previewReplan(ctx.app, ctx.auth.user, repo, number);
31 if (!plan.ok) return notFoundPage(ctx);
32 const base = repoBase(repo);
33 const p = plan.value;
34 const back = milestoneHref(base, number);
35 return page({
36 ctx,
37 title: `Re-plan ${p.limb.title} · ${repo.name}`,
38 crumbs: crumbsFor(repo),
39 palette: repoPalette(ctx, repo),
40 styleCss: milestonesCss,
41 body: html`${repoHeader(ctx, repo, "map")}
42 <div class="ms-top mt">
43 <p class="small muted"><a href="${back}">${p.limb.title}</a></p>
44 <h1>Re-plan “${p.limb.title}”?</h1>
45 <p>
46 The planner's next pass is given this limb to re-plan as fewer,
47 larger pieces. Each issue below is closed as split into what
48 replaces it; none is dropped. The crew picks nothing beneath the
49 limb until the planner has answered, and you can take it back
50 before it starts.
51 </p>
52 </div>
53 <section class="ms-section" id="replan-issues">
54 <h2>${plural(p.issues.length, "issue")} the planner will be given</h2>
55 <ul class="ms-rows">
56 ${p.issues.map(
57 (i) =>
58 html`<li>
59 <a href="${base}/issues/${i.number}">${i.title}</a>
60 <span class="muted small">#${i.number}</span>
61 </li>`,
62 )}
63 </ul>
64 </section>
65 ${p.blocked !== undefined
66 ? html`<p class="mt" id="replan-refused">
67 Not now: ${p.blocked}. <a href="${back}">Back</a>
68 </p>`
69 : html`<form method="post" action="${back}/limb" class="bar mt">
70 <input type="hidden" name="action" value="replan" />
71 ${btn("Hand to the planner", { variant: "primary" })}
72 ${btn("Back", { href: back, variant: "ghost" })}
73 </form>`}`,
74 });
75 }
76
77 /** POST /:owner/:name/milestones/:number/limb with action replan or takeback, its form already read. */
78 export function replanSubmit(
79 ctx: Ctx,
80 repo: Repo,
81 number: number,
82 action: ReplanAction,
83 ): Response {
84 if (!Number.isInteger(number)) return notFoundPage(ctx);
85 const done =
86 action === "replan"
87 ? replanLimb(ctx.app, ctx.auth.user, repo, number)
88 : takeBackLimb(ctx.app, ctx.auth.user, repo, number);
89 if (!done.ok && done.error.code === "not_found") return notFoundPage(ctx);
90 const back = milestoneHref(repoBase(repo), number);
91 if (done.ok) return redirect(back);
92 // Said, not hidden: the press did nothing, and why.
93 return page({
94 ctx,
95 title: `Not done · ${repo.name}`,
96 crumbs: crumbsFor(repo),
97 palette: repoPalette(ctx, repo),
98 styleCss: milestonesCss,
99 body: html`${repoHeader(ctx, repo, "map")}
100 <div class="ms-top mt">
101 <h1>${action === "replan" ? "Not handed over" : "Not taken back"}</h1>
102 <p id="replan-refused">Nothing changed: ${done.error.message}.</p>
103 <p><a href="${back}">Back</a></p>
104 </div>`,
105 }, 409);
106 }
src/web/pages/milestones.ts +65 −4
@@ -34,6 +34,8 @@ import {
3434 import { canAdmin, canSeeOwnerFacts, type Repo } from "../../core/repos.ts";
3535 import { isWatching } from "../../core/watches.ts";
3636 import { watchMilestone } from "../../actions/watches.ts";
37 import { replanResult } from "../../actions/limb_replan.ts";
38 import { given, pendingFor } from "../../core/limb_replan.ts";
3739 import type { Ctx } from "../../http/context.ts";
3840 import { askToLiveFor } from "../ask_to_live.ts";
3941 import {
@@ -59,7 +61,7 @@ export const milestoneHref = (base: string, number: number): string =>
5961 export const limbHref = (
6062 base: string,
6163 number: number,
62 action: LimbAction,
64 action: LimbAction | "replan",
6365 under?: string,
6466 ): string =>
6567 `${milestoneHref(base, number)}/limb?action=${action}${under === undefined ? "" : `&under=${under}`}`;
@@ -120,11 +122,13 @@ function limbActions(
120122 furniture: boolean,
121123 lead: Html | null,
122124 move: Html | null,
125 replan: Html | null,
123126 ): Html {
124127 const number = m.issue.number;
125128 return html`<p class="bar ms-actions" id="limb-actions">
126129 ${lead}
127130 ${move}
131 ${replan}
128132 ${furniture
129133 ? null
130134 : m.deferred
@@ -242,6 +246,58 @@ function folded(rows: Html[], what: string, shown: number): Html {
242246 </details>`;
243247 }
244248
249 /**
250 * Where a limb stands with the planner (#761): "with the planner" while it
251 * waits, with a way to take it back until the planner has been given it;
252 * otherwise Re-plan, for whoever administers the repository.
253 */
254 function replanControl(
255 ctx: Ctx,
256 repo: Repo,
257 m: Milestone,
258 admin: boolean,
259 ): Html | null {
260 const base = repoBase(repo);
261 const pending = pendingFor(ctx.app.db, repo, m.issue.id);
262 if (pending === undefined)
263 return admin && m.weight.open > 1
264 ? btn("Re-plan", {
265 href: limbHref(base, m.issue.number, "replan"),
266 size: "sm",
267 title: "Hand this limb to the planner to re-plan as fewer, larger pieces",
268 })
269 : null;
270 return html`<span class="ms-planner" id="ms-with-planner"
271 ><b>With the planner</b>
272 <span class="muted small">Nothing beneath it is picked until it answers.</span></span
273 >${admin && !given(ctx.app.db, repo, pending.at)
274 ? html`<form method="post" action="${milestoneHref(base, m.issue.number)}/limb">
275 <input type="hidden" name="action" value="takeback" />
276 ${btn("Take it back", { size: "sm", variant: "ghost" })}
277 </form>`
278 : null}`;
279 }
280
281 /** What the planner changed on this limb, once it has answered: each old issue and what replaced it. */
282 function replanChanged(ctx: Ctx, repo: Repo, m: Milestone): Html | null {
283 const base = repoBase(repo);
284 const result = replanResult(ctx.app, repo, m.issue.id);
285 if (result === undefined || result.splits.length === 0) return null;
286 const link = (n: { number: number; title: string }): Html =>
287 html`<a href="${base}/issues/${n.number}">${n.title}</a>
288 <span class="muted small">#${n.number}</span>`;
289 return html`<section class="ms-section" id="ms-replanned">
290 <h2>Re-planned ${time(result.at)}</h2>
291 <ul class="ms-rows">
292 ${result.splits.map(
293 (s) =>
294 html`<li>${link(s)} <span class="muted small">closed as split into</span>
295 ${s.into.map((p, n) => html`${n === 0 ? "" : ", "}${link(p)}`)}</li>`,
296 )}
297 </ul>
298 </section>`;
299 }
300
245301 /**
246302 * Watch, or stop: one button, for a person who is signed in. Watching puts
247303 * the milestone's progress in their feed and tells their phone once, when
@@ -490,17 +546,20 @@ export function milestonePage(ctx: Ctx): Response {
490546 const move = moverFor(ctx.app.db, repo, viewer, plan.read);
491547 // Its own place: among the limbs of whichever milestone holds it first.
492548 const seat = move?.homeOf(milestone.issue);
493 const actions = canAdmin(ctx.app.db, repo, ctx.auth.user)
549 const admin = canAdmin(ctx.app.db, repo, ctx.auth.user);
550 const replan = replanControl(ctx, repo, milestone, admin);
551 const actions = admin
494552 ? limbActions(
495553 repoBase(repo),
496554 milestone,
497555 isStructural(pillarsOf(ctx.app.db, repo), milestone.issue.id),
498556 watch,
499557 seat === undefined ? null : (move?.(milestone.issue, seat) ?? null),
558 replan,
500559 )
501 : watch === null
560 : watch === null && replan === null
502561 ? null
503 : html`<p class="bar ms-actions">${watch}</p>`;
562 : html`<p class="bar ms-actions">${watch}${replan}</p>`;
504563 return page({
505564 ctx,
506565 title: `${milestone.name} · ${repo.name}`,
@@ -508,6 +567,7 @@ export function milestonePage(ctx: Ctx): Response {
508567 palette: repoPalette(ctx, repo),
509568 body: html`${repoHeader(ctx, repo, "map")}
510569 ${milestoneBody(repo, milestone, owner, actions, move)}
570 ${replanChanged(ctx, repo, milestone)}
511571 ${liveMount({ repo: repoBase(repo) })}`,
512572 styleCss: milestonesCss,
513573 scriptJs: liveClientJs,
@@ -621,6 +681,7 @@ export const milestonesCss = /* css */ `
621681 .ms-top h1, .ms-top p { margin: 0; }
622682 .ms-head { display: grid; grid-template-columns: minmax(0, 1fr); gap: 4px; }
623683 .ms-actions { margin-top: 4px; }
684 .ms-planner { display: inline-flex; flex-wrap: wrap; gap: 4px 8px; align-items: baseline; }
624685 .ms-move { grid-column: 1 / -1; display: flex; gap: 14px; }
625686 .ms-section { margin-top: 22px; }
626687 .ms-section h2 { font-size: 13px; margin: 0 0 6px; }
test/limb-replan.test.ts added +363
@@ -0,0 +1,363 @@
1 /**
2 * A limb handed to the planner (#761). The refusals come first: a reader, a
3 * crew agent and an `ro` token cannot hand a limb over, and the hand-over
4 * grants the planner nothing outside the limb. Then the hand-over itself:
5 * owed once, even with a full map and after a decline; the brief lists the
6 * limb; nothing beneath is picked until the planner answers; the planner
7 * closes only as split into what is new; a person takes it back before it
8 * runs; the milestone shows the change.
9 */
10 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
11 import { mkdtemp, rm } from "node:fs/promises";
12 import { tmpdir } from "node:os";
13 import { join } from "node:path";
14 import { createApp, type App } from "../src/app.ts";
15 import { addToCrew, createAgent } from "../src/actions/crew.ts";
16 import { linkIssue } from "../src/actions/dependencies.ts";
17 import { openIssue, setIssueState } from "../src/actions/issues.ts";
18 import { previewReplan, replanLimb, takeBackLimb } from "../src/actions/limb_replan.ts";
19 import { createRepo, updateRepoSettings } from "../src/actions/repos.ts";
20 import { setCrewRole } from "../src/core/agents.ts";
21 import { readyNow, repoGraph } from "../src/core/dependencies.ts";
22 import { getIssueById, type Issue } from "../src/core/issues.ts";
23 import { pendingReplans, replanSince } from "../src/core/limb_replan.ts";
24 import { getRepoById, type Repo } from "../src/core/repos.ts";
25 import { cancelRun, claimRun, createRun, finishRun } from "../src/core/runs.ts";
26 import { createToken } from "../src/core/tokens.ts";
27 import { createUser, getUserByName, type User } from "../src/core/users.ts";
28 import { taskPrompt } from "../src/crew/brief.ts";
29 import { driveRepo, ensureDirectionIssue } from "../src/crew/autonomy.ts";
30 import { heldNow } from "../src/crew/held.ts";
31 import { handedLimbs } from "../src/crew/handed_limbs.ts";
32 import { serve, type Servers } from "../src/http/server.ts";
33
34 describe("a limb handed to the planner", () => {
35 let dataDir: string;
36 let app: App;
37 let servers: Servers;
38 let base: string;
39 let alice: User;
40 let bob: User;
41 let mason: User;
42 let atlas: User;
43 let repo: Repo;
44 let limb: Issue;
45 let leaves: Issue[];
46 let outside: Issue;
47 let direction: Issue;
48 const cookies: Record<string, string> = {};
49
50 const fresh = () => getRepoById(app.db, repo.id)!;
51 const current = (i: Issue) => getIssueById(app.db, i.id)!;
52 const open = (title: string, by: User = alice): Issue => {
53 const made = openIssue(app, by, fresh(), { title });
54 if (!made.ok) throw new Error(String(made.error));
55 return made.value;
56 };
57 const tick = () => Bun.sleep(5);
58 /** A planning pass on the direction: queued, claimed, ended. */
59 const planned = (state: "ok" | "failed", summary: string) => {
60 const run = createRun(app.db, {
61 repoId: repo.id,
62 kind: "agent",
63 issueId: direction.id,
64 actorId: atlas.id,
65 reason: "assigned",
66 });
67 claimRun(app.db, run.id);
68 finishRun(app.db, run.id, { state, summary });
69 return run;
70 };
71 const tokenOf = (user: User, scope: "ro" | "rw" | "admin") =>
72 createToken(app.db, user.id, scope, scope).token;
73 const post = (token: string, path: string, body: unknown) =>
74 fetch(`${base}${path}`, {
75 method: "POST",
76 headers: { authorization: `Bearer ${token}`, "content-type": "application/json" },
77 body: JSON.stringify(body),
78 });
79 const page = (who: string, path: string) =>
80 fetch(`${base}${path}`, { headers: { cookie: cookies[who]! }, redirect: "manual" });
81 const submit = (who: string, path: string, form: Record<string, string>) =>
82 fetch(`${base}${path}`, {
83 method: "POST",
84 headers: { cookie: cookies[who]!, origin: base },
85 body: new URLSearchParams(form),
86 redirect: "manual",
87 });
88 const brief = (limbs: ReturnType<typeof handedLimbs>) =>
89 taskPrompt({
90 agent: { user: atlas } as never,
91 repo: fresh(),
92 issue: current(direction),
93 branch: "atlas/1-direction",
94 forgeUrl: base,
95 isApp: false,
96 resuming: false,
97 role: "planner",
98 reason: "assigned",
99 comments: [],
100 reviews: [],
101 reviewComments: [],
102 since: direction.createdAt,
103 limbs,
104 });
105
106 beforeAll(async () => {
107 dataDir = await mkdtemp(join(tmpdir(), "forge-limb-replan-"));
108 app = createApp({ dataDir, port: 0 });
109 servers = await serve(app);
110 base = servers.url;
111 for (const [username, adminIfFirst] of [["alice", true], ["bob", false]] as const) {
112 const made = await createUser(app.db, {
113 origin: "seed",
114 trusted: true,
115 username,
116 email: `${username}@forge.local`,
117 password: "a-good-passphrase",
118 adminIfFirst,
119 });
120 if (!made.ok) throw new Error(made.error);
121 }
122 alice = getUserByName(app.db, "alice")!;
123 bob = getUserByName(app.db, "bob")!;
124 for (const name of ["alice", "bob"])
125 cookies[name] = (
126 (
127 await fetch(`${base}/signin`, {
128 method: "POST",
129 body: new URLSearchParams({ username: name, password: "a-good-passphrase" }),
130 redirect: "manual",
131 })
132 ).headers.get("set-cookie") ?? ""
133 ).split(";")[0]!;
134 const made = await createRepo(app, alice, { name: "engine", visibility: "public" });
135 if (!made.ok) throw new Error(String(made.error));
136 repo = made.value;
137 for (const name of ["atlas", "mason"]) {
138 const agent = await createAgent(app, alice, { username: name });
139 if (!agent.ok) throw new Error(String(agent.error));
140 expect(addToCrew(app, alice, repo, name).ok).toBe(true);
141 }
142 atlas = getUserByName(app.db, "atlas")!;
143 mason = getUserByName(app.db, "mason")!;
144 setCrewRole(app.db, repo.id, atlas.id, "planner");
145 const set = await updateRepoSettings(app, alice, repo, {
146 autonomy: "autonomous",
147 autonomyDirection: "Keep the engine small.",
148 autonomyCeilingUsd: 100,
149 autonomyBy: alice.id,
150 });
151 if (!set.ok) throw new Error(JSON.stringify(set.error));
152 // The limb waits for four small leaves; four more issues fill the map.
153 limb = open("Search");
154 leaves = ["Index titles", "Index bodies", "Rank hits", "Highlight hits"].map((t) => open(t));
155 for (const leaf of leaves) linkIssue(app, alice, fresh(), current(limb), current(leaf));
156 outside = open("Unrelated work");
157 for (const t of ["Filler one", "Filler two", "Filler three"]) open(t);
158 direction = ensureDirectionIssue(app, alice, fresh())!;
159 });
160
161 afterAll(async () => {
162 servers.stop();
163 app.close();
164 await rm(dataDir, { recursive: true, force: true });
165 });
166
167 test("a reader, a crew agent and an ro token cannot hand a limb over", async () => {
168 for (const who of [bob, mason, atlas, undefined]) {
169 const refused = replanLimb(app, who, fresh(), limb.number);
170 expect(refused.ok).toBe(false);
171 }
172 expect(takeBackLimb(app, bob, fresh(), limb.number).ok).toBe(false);
173 expect(pendingReplans(app.db, fresh())).toEqual([]);
174 const path = `/api/v1/repos/alice/engine/milestones/${limb.number}/limb`;
175 // A read token plans and changes nothing, even when it asks to apply.
176 expect((await post(tokenOf(alice, "ro"), path, { action: "replan", dryRun: false })).status).toBe(403);
177 expect((await post(tokenOf(alice, "rw"), path, { action: "replan", dryRun: false })).status).toBe(403);
178 const dry = await post(tokenOf(alice, "ro"), path, { action: "replan" });
179 expect(dry.status).toBe(200);
180 const body = (await dry.json()) as { dryRun: boolean; issues: { number: number }[] };
181 expect(body.dryRun).toBe(true);
182 expect(body.issues.map((i) => i.number).sort()).toEqual(
183 [limb, ...leaves].map((i) => i.number).sort(),
184 );
185 const rpc = await post(tokenOf(alice, "ro"), "/mcp", {
186 jsonrpc: "2.0",
187 id: 1,
188 method: "tools/call",
189 params: {
190 name: "limb",
191 arguments: { app: "alice/engine", issue: limb.number, action: "replan", dryRun: false },
192 },
193 });
194 expect(JSON.stringify(await rpc.json())).toContain("forbidden");
195 // A reader's button and form do nothing.
196 expect((await submit("bob", `/alice/engine/milestones/${limb.number}/limb`, { action: "replan" })).status).toBe(409);
197 const said = await submit("bob", `/alice/engine/milestones/${limb.number}/limb`, { action: "replan" });
198 expect(await said.text()).toContain("only a person who administers this app");
199 expect(pendingReplans(app.db, fresh())).toEqual([]);
200 expect(await (await page("bob", `/alice/engine/milestones/${limb.number}`)).text()).not.toContain("Re-plan");
201 });
202
203 test("the preview lists every issue the planner will be given, and writes nothing", async () => {
204 const seen = previewReplan(app, bob, fresh(), limb.number);
205 expect(seen.ok && seen.value.issues.map((i) => i.number)).toEqual(
206 expect.arrayContaining([limb, ...leaves].map((i) => i.number)),
207 );
208 expect(seen.ok && seen.value.issues.some((i) => i.number === outside.number)).toBe(false);
209 const html = await (await page("alice", `/alice/engine/milestones/${limb.number}/limb?action=replan`)).text();
210 for (const leaf of leaves) expect(html).toContain(leaf.title);
211 expect(html).not.toContain("Unrelated work");
212 expect(html).toContain("Hand to the planner");
213 expect(await (await page("alice", `/alice/engine/milestones/${limb.number}`)).text()).toContain("Re-plan");
214 expect(pendingReplans(app.db, fresh())).toEqual([]);
215 });
216
217 test("owed once, with a full map and after a decline; the brief lists the limb", async () => {
218 // The planner declined the direction and nobody has answered; the map is full.
219 planned("failed", "declined: nothing to add");
220 await tick();
221 expect(readyNow(repoGraph(app.db, repo.id, { sealed: true })).length).toBeGreaterThanOrEqual(3);
222 const done = replanLimb(app, alice, fresh(), limb.number);
223 expect(done.ok).toBe(true);
224 expect(replanLimb(app, alice, fresh(), limb.number).ok).toBe(false);
225 await driveRepo(app, fresh());
226 expect(current(direction).assigneeId).toBe(atlas.id);
227 const told = brief(handedLimbs(app, fresh()));
228 expect(told).toContain(`Limb #${limb.number} Search, 4 open issues`);
229 for (const leaf of leaves) expect(told).toContain(`#${leaf.number} ${leaf.title}`);
230 expect(told).not.toContain("Unrelated work");
231 expect(told).toContain('"reason":"split"');
232 expect(brief([])).not.toContain("A person handed you these limbs");
233 // The pass is owed once: with its run begun, the next tick does not ask again.
234 planned("ok", "re-planning");
235 expect(pendingReplans(app.db, fresh())).toEqual([]);
236 });
237
238 test("until the planner answers nothing beneath the limb is picked, and the page says so", async () => {
239 await tick();
240 const second = replanLimb(app, alice, fresh(), limb.number);
241 expect(second.ok).toBe(true);
242 const startable = () => heldNow(app, fresh()).startable.map((r) => r.issue.number);
243 for (const leaf of leaves) expect(startable()).not.toContain(leaf.number);
244 expect(heldNow(app, fresh()).by.with_planner.length).toBe(4);
245 expect(startable()).toContain(outside.number);
246 const html = await (await page("bob", `/alice/engine/milestones/${limb.number}`)).text();
247 expect(html).toContain("With the planner");
248 expect(html).not.toContain("Re-plan");
249 // A reader cannot take it back; a person can, before the planner runs.
250 expect(takeBackLimb(app, bob, fresh(), limb.number).ok).toBe(false);
251 expect(await (await page("alice", `/alice/engine/milestones/${limb.number}`)).text()).toContain("Take it back");
252 expect(takeBackLimb(app, alice, fresh(), limb.number).ok).toBe(true);
253 expect(pendingReplans(app.db, fresh())).toEqual([]);
254 expect(startable()).toContain(leaves[0]!.number);
255 expect(takeBackLimb(app, alice, fresh(), limb.number).ok).toBe(false);
256 });
257
258 test("the planner may close beneath the limb only as split into what is new; nothing outside", async () => {
259 await tick();
260 const old = open("Made before the hand-over", atlas);
261 await tick();
262 expect(replanLimb(app, alice, fresh(), limb.number).ok).toBe(true);
263 await tick();
264 const [a, b] = leaves.map((l) => current(l));
265 expect(replanSince(app.db, fresh(), a!)).toBeDefined();
266 expect(replanSince(app.db, fresh(), current(outside))).toBeUndefined();
267 const close = (i: Issue, closing: Parameters<typeof setIssueState>[5]) =>
268 setIssueState(app, atlas, fresh(), current(i), "closed", closing);
269 // Not dropped, not into an older issue, not without a piece.
270 expect(close(a!, { reason: "dropped" }).ok).toBe(false);
271 expect(close(a!, { reason: "split", into: [old.number] }).ok).toBe(false);
272 expect(close(a!, { reason: "split", into: [] }).ok).toBe(false);
273 expect(current(a!).state).toBe("open");
274 // A person's issue outside the limb is not the planner's to drop.
275 expect(close(outside, { reason: "dropped" }).ok).toBe(false);
276 expect(current(outside).state).toBe("open");
277 const larger = open("Search over titles and bodies", atlas);
278 expect(close(a!, { reason: "split", into: [larger.number] }).ok).toBe(true);
279 expect(close(b!, { reason: "split", into: [larger.number] }).ok).toBe(true);
280 // Once it has answered, the grant is gone.
281 planned("ok", "re-planned");
282 expect(replanSince(app.db, fresh(), current(leaves[2]!))).toBeUndefined();
283 expect(pendingReplans(app.db, fresh())).toEqual([]);
284 });
285
286 test("afterwards the milestone shows which issues were closed into which", async () => {
287 const html = await (await page("bob", `/alice/engine/milestones/${limb.number}`)).text();
288 expect(html).toContain("Re-planned");
289 expect(html).toContain("closed as split into");
290 expect(html).toContain(leaves[0]!.title);
291 expect(html).toContain("Search over titles and bodies");
292 expect(html).not.toContain("With the planner");
293 });
294
295 test("a reader and a guest never see a sealed report's title on the milestone page", async () => {
296 const path = `/alice/engine/milestones/${limb.number}`;
297 const seen = async (who: string | undefined) =>
298 (await fetch(`${base}${path}`, { headers: who ? { cookie: cookies[who]! } : {} })).text();
299 const seal = (i: Issue) => app.db.query("UPDATE issue SET sealed = 1 WHERE id = ?1").run(i.id);
300 // Sealed while it is split from the limb: the old issue is not named.
301 seal(leaves[1]!);
302 for (const who of ["bob", undefined]) {
303 const html = await seen(who);
304 expect(html).toContain("closed as split into");
305 expect(html).not.toContain(leaves[1]!.title);
306 }
307 // Sealed as the piece it was split into: the new issue is not named.
308 const larger = app.db
309 .query<{ id: string }, [string]>("SELECT id FROM issue WHERE repo_id = ?1 AND title = 'Search over titles and bodies'")
310 .get(repo.id)!;
311 app.db.query("UPDATE issue SET sealed = 1 WHERE id = ?1").run(larger.id);
312 for (const who of ["bob", undefined]) expect(await seen(who)).not.toContain("Search over titles and bodies");
313 });
314
315 test("a viewer who cannot administer sees the refusal in place of the button", async () => {
316 const html = await (await page("bob", `/alice/engine/milestones/${limb.number}/limb?action=replan`)).text();
317 expect(html).toContain("only a person who administers this app");
318 expect(html).not.toContain("Hand to the planner");
319 const seen = previewReplan(app, bob, fresh(), limb.number);
320 expect(seen.ok && seen.value.blocked).toContain("administers");
321 });
322
323 test("the preview says 1 issue, not 1 issues", async () => {
324 const solo = open("Solo limb");
325 const html = await (await page("alice", `/alice/engine/milestones/${solo.number}/limb?action=replan`)).text();
326 expect(html).toContain("1 issue the planner");
327 expect(html).not.toContain("1 issues");
328 });
329
330 test("a take-back is refused once the planner has been given the limb", async () => {
331 await tick();
332 expect(replanLimb(app, alice, fresh(), limb.number).ok).toBe(true);
333 await tick();
334 createRun(app.db, { repoId: repo.id, kind: "agent", issueId: direction.id, actorId: atlas.id, reason: "assigned" });
335 const seen = previewReplan(app, alice, fresh(), limb.number);
336 expect(seen.ok && seen.value.takeBack).toBe(false);
337 expect(takeBackLimb(app, alice, fresh(), limb.number).ok).toBe(false);
338 });
339
340 test("a planning run that is cancelled leaves the limb takeable back and owed again", async () => {
341 const runs = () =>
342 app.db
343 .query<{ id: string; state: string }, [string]>(
344 "SELECT id, state FROM run WHERE issue_id = ?1 AND kind = 'agent'",
345 )
346 .all(direction.id);
347 expect(pendingReplans(app.db, fresh()).length).toBe(1);
348 for (const r of runs()) if (r.state === "queued" || r.state === "running") cancelRun(app.db, r.id);
349 const seen = previewReplan(app, alice, fresh(), limb.number);
350 expect(seen.ok && seen.value.takeBack).toBe(true);
351 expect(pendingReplans(app.db, fresh()).length).toBe(1);
352 const asked = () =>
353 app.db
354 .query<{ n: number }, [string, number]>(
355 "SELECT COUNT(*) AS n FROM event WHERE repo_id = ?1 AND kind = 'issue.assigned' AND json_extract(payload, '$.number') = ?2",
356 )
357 .get(repo.id, direction.number)!.n;
358 const before = asked();
359 // One action a tick: the planner is asked within a few.
360 for (let n = 0; n < 4 && asked() === before; n++) await driveRepo(app, fresh());
361 expect(asked()).toBeGreaterThan(before);
362 });
363 });
test/tui-limbs.test.ts +53 −1
@@ -14,7 +14,10 @@ import { tmpdir } from "node:os";
1414 import { join } from "node:path";
1515 import { linkIssue } from "../src/actions/dependencies.ts";
1616 import { openIssue } from "../src/actions/issues.ts";
17 import { createRepo } from "../src/actions/repos.ts";
17 import { addToCrew, createAgent } from "../src/actions/crew.ts";
18 import { createRepo, updateRepoSettings } from "../src/actions/repos.ts";
19 import { setCrewRole } from "../src/core/agents.ts";
20 import { pendingReplans } from "../src/core/limb_replan.ts";
1821 import { createApp, type App as Forge } from "../src/app.ts";
1922 import { listEvents } from "../src/core/events.ts";
2023 import { getIssueById, type Issue } from "../src/core/issues.ts";
@@ -248,5 +251,54 @@ describe("raise and lower a limb in the terminal", () => {
248251 const body = await res.text();
249252 expect(body).toContain('id="live"');
250253 expect(body).toContain("limb.moved");
254 // A hand-over to the planner, or taking it back, reaches an open page too.
255 expect(body).toMatch(/limb\.replan[^_]/);
256 expect(body).toContain("limb.replan_taken_back");
257 });
258
259 test("p hands a limb to the planner after a preview, shows it with the planner, and takes it back; a reader has no key", async () => {
260 const made = await createRepo(forge, alice, { name: "plan", visibility: "public" });
261 if (!made.ok) throw new Error("repo");
262 const r = made.value;
263 const agent = await createAgent(forge, alice, { username: "atlas" });
264 if (!agent.ok) throw new Error(String(agent.error));
265 addToCrew(forge, alice, r, "atlas");
266 setCrewRole(forge.db, r.id, getUserByName(forge.db, "atlas")!.id, "planner");
267 await updateRepoSettings(forge, alice, r, {
268 autonomy: "autonomous",
269 autonomyDirection: "Keep it small.",
270 autonomyCeilingUsd: 10,
271 autonomyBy: alice.id,
272 });
273 const search = open(r, alice, "Search everything");
274 for (const t of ["Index titles", "Index bodies"]) link(r, search, open(r, alice, t));
275 const pending = () => pendingReplans(forge.db, getRepoById(forge.db, r.id)!).length;
276
277 const reader = boot(bob);
278 await reader.toMilestones("alice/plan");
279 await reader.press("p");
280 expect(reader.text()).not.toContain("Hand to the planner");
281 expect(pending()).toBe(0);
282 reader.stop();
283
284 const h = boot(alice);
285 await h.toMilestones("alice/plan");
286 await h.press("p");
287 // The preview lists what the planner will be given; nothing is written yet.
288 expect(h.text()).toContain("Index titles");
289 expect(h.text()).toContain("Hand to the planner");
290 expect(pending()).toBe(0);
291 await h.press("esc");
292 expect(pending()).toBe(0);
293 await h.press("p", "enter");
294 expect(pending()).toBe(1);
295 expect(h.text()).toContain("with the planner");
296 // Take it back while the planner has not started.
297 await h.press("p");
298 expect(h.text()).toContain("Take it back");
299 await h.press("enter");
300 expect(pending()).toBe(0);
301 expect(h.text()).not.toContain("with the planner");
302 h.stop();
251303 });
252304 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs