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

POST …/plan answers which opened issues will not start yet and why: a hot file, a pin or an open pull #7435

Merged opened this

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

Merged

scout/forge:scout/7427-post-plan-answers-which-opened-issues-wi → main 2 commits fork 5 threads · 5 unresolved

  • Merged. scout/7427-post-plan-answers-which-opened-issues-wi was merged into main as e766275.

Review

Sign in to review this change.
5 files changed +172 −5
docs/PLANNING.md +7 −2
@@ -198,8 +198,13 @@ action as the MCP `plan` tool (`openPlan`): the same items, the same
198198 refusals, all or nothing. An item names its `pillar`, or is `later: true`
199199 with `why`; one that says neither is refused with `no_pillar` and nothing is
200200 opened, so the planner reads the refusal, names the pillar or defers the
201 item, and posts again. The answer is `{ opened, ready, later, gate }`. A
202 repository with no umbrella issues has no pillars, and plans as it always
201 item, and posts again. The answer is `{ opened, ready, held, later, gate }`. `held`
202 lists each opened issue that is ready but would not start now, as `{ key, number, by,
outdated

held carries any hold in HOLDS (e.g. flow_only, red_fix), not just hot, pinned or open-pull. Name the field's real range and wrap the line to the file's width.

203 detail }`: `by` is one of the map's hold names (`HOLDS` in
204 `src/crew/held.ts`) and `detail` its sentence. The usual ones are `hot_file`
205 (a file on the issue's `Files:` line is hot, or an admin holds it); an open
206 pull cannot hold an issue opened a moment ago. The planner can rewrite
207 what is held in the same run. A repository with no umbrella issues has no pillars, and plans as it always
203208 did.
204209
205210 **An issue opened outside a plan.** A builder that finds its task too big
src/actions/plan.ts +12 −1
@@ -13,6 +13,7 @@
1313 import { sealedScope } from "../core/sealed.ts";
1414 import type { App } from "../app.ts";
1515 import { isCrew } from "../core/agents.ts";
16 import { mapHeldFor, type HoldName } from "../crew/held.ts";
1617 import { addDependency, repoGraph, readyNow } from "../core/dependencies.ts";
1718 import { findPillar, pillarsOf, type Pillar } from "../core/pillars.ts";
1819 import {
@@ -154,6 +155,8 @@ export interface Planned {
154155 readonly ready: number[];
155156 /** Numbers of the issues this plan put behind the Later gate. */
156157 readonly later: number[];
158 /** Opened issues that are ready but would not start now, with the map's words for why. */
159 readonly held: { key: string; number: number; by: HoldName; detail: string }[];
outdated

Type by as HoldName (from src/crew/held.ts), not string, so callers know the closed set.

157160 /** The Later gate's number, when the plan put something behind it. */
158161 readonly gate?: number;
159162 }
@@ -437,9 +440,17 @@ export function openPlan(
437440 const deferred = items
438441 .filter((item) => item.later !== undefined)
439442 .map((item) => number.get(item.key)!);
443 const scope = sealedScope(app.db, repo, actor);
444 const graph = repoGraph(app.db, repo.id, scope);
445 const { holds } = mapHeldFor(app, repo, graph, scope, new Set(opened.map((o) => o.number)));
outdated

This asks heldBy about the whole ready list on every plan, including triage's one-issue plans, and then keeps only the opened issues. Filter readyNow(graph) down to the opened numbers before the holds are worked out (non-blocking, but it is the cheap fix).

446 const held = opened.flatMap((o) => {
447 const hold = holds.get(o.number);
448 return hold === undefined ? [] : [{ ...o, by: hold.by, detail: hold.detail }];
449 });
440450 return ok({
441451 opened,
442 ready: readyNow(repoGraph(app.db, repo.id, sealedScope(app.db, repo, actor))).map((r) => r.issue.number),
452 ready: readyNow(graph).map((r) => r.issue.number),
453 held,
443454 later: deferred,
444455 ...(deferred.length > 0 && gate ? { gate: gate.number } : {}),
445456 });
src/crew/held.ts +4 −1
@@ -284,12 +284,15 @@ export function mapHeldFor(
284284 repo: Repo,
285285 graph: Graph,
286286 scope: { sealed?: true; sealedBy?: string },
287 /** Only these issue numbers are worked out: the cost grows with them, not with the ready list. */
288 only?: ReadonlySet<number>,
287289 ): ReturnType<typeof mapHolds> {
288290 const admit = admits(app, repo, scope);
289291 const active = listActive(app.db, "agent").filter((r) => r.repoId === repo.id);
292 const ready = readyNow(graph).filter((r) => only === undefined || only.has(r.issue.number));
290293 return mapHolds(
291294 repo,
292 heldBy(app, repo, readyNow(graph), listCrewRoles(app.db, repo.id), active, admit),
295 heldBy(app, repo, ready, listCrewRoles(app.db, repo.id), active, admit),
293296 admit,
294297 );
295298 }
src/http/mcp/tools/plan.ts +2 −1
@@ -66,7 +66,7 @@ export const tool: Tool = {
6666 }
6767 const planned = openPlan(ctx.app, user, repo, items.value);
6868 if (!planned.ok) return bad(planned.error.error, planned.error.message);
69 const { opened, ready, later, gate } = planned.value;
69 const { opened, ready, held, later, gate } = planned.value;
7070 const waits =
7171 gate === undefined
7272 ? ""
@@ -75,6 +75,7 @@ export const tool: Tool = {
7575 says: `Opened ${opened.length} issue${opened.length === 1 ? "" : "s"}: ${opened.map((o) => `${o.key} is #${o.number}`).join(", ")}.${waits}`,
7676 opened,
7777 ready,
78 held,

Untested: removing this line keeps every test green. Add a test that calls the MCP plan tool with a hot-file item and asserts the reply's held.

7879 later,
7980 });
8081 },
test/plan-held.test.ts added +147
@@ -0,0 +1,147 @@
1 /**
2 * A plan's answer says which opened issues will not start yet, and why, in
3 * the map's own words (#7427): an issue naming a hot file is held, one that
4 * names no hot file is not.
5 */
6
7 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
8 import { mkdtemp, rm } from "node:fs/promises";
9 import { tmpdir } from "node:os";
10 import { join } from "node:path";
11 import { createApp, type App } from "../src/app.ts";
12 import { updateRepoSettings } from "../src/core/repos.ts";
13 import { createRepo } from "../src/actions/repos.ts";
14 import { openPlan } from "../src/actions/plan.ts";
15 import { holdFile } from "../src/crew/file_holds.ts";
16 import { createToken } from "../src/core/tokens.ts";
17 import { serve, type Servers } from "../src/http/server.ts";
18 import { rememberPullFiles, HOT_PULLS } from "../src/crew/hot_files.ts";
19 import { createUser, type User } from "../src/core/users.ts";
20
21 describe("a plan's answer lists the issues that are held", () => {
22 let dataDir: string;
23 let app: App;
24 let alice: User;
25 let servers: Servers;
26 let rw = "";
27
28 beforeAll(async () => {
29 dataDir = await mkdtemp(join(tmpdir(), "forge-plan-held-"));
30 app = createApp({ dataDir, port: 0 });
31 const a = await createUser(app.db, {
32 origin: "seed",
33 trusted: true,
34 username: "alice",
35 email: "[email protected]",
36 password: "a-good-passphrase",
37 adminIfFirst: true,
38 });
39 if (!a.ok) throw new Error(a.error);
40 alice = a.value;
41 rw = createToken(app.db, alice.id, "rw", "rw").token;
42 servers = await serve(app);
43 });
44
45 afterAll(async () => {
46 servers.stop();
47 app.close();
outdated

Add the pinned case beside this one: an admin hold on a file (file_holds.ts), an item naming it, and expect detail: "<path> is held by an admin". It is its own branch in mapHolds, and nothing covers it here.

48 await rm(dataDir, { recursive: true, force: true });
49 });
50
51 test("only the item naming a hot file is held, with the map's sentence", async () => {
52 const made = await createRepo(app, alice, { name: "held", visibility: "private" });
53 if (!made.ok) throw new Error(String(made.error));
54 const repo = made.value;
55 rememberPullFiles(
56 repo.id,
57 Array.from({ length: HOT_PULLS }, (_, i) => ({ number: 100 + i, files: ["src/busy.ts"] })),
58 );
59 const planned = openPlan(app, alice, repo, [
60 { key: "hot", title: "Rework src/busy.ts", body: "Files: src/busy.ts", blockedBy: [] },
61 { key: "cold", title: "Rework src/quiet.ts", body: "Files: src/quiet.ts", blockedBy: [] },
62 ]);
63 if (!planned.ok) throw new Error(planned.error.message);
64 const { opened, held, ready } = planned.value;
65 const number = (key: string) => opened.find((o) => o.key === key)!.number;
66 expect(held).toEqual([
67 {
68 key: "hot",
69 number: number("hot"),
70 by: "hot_file",
71 detail: `src/busy.ts is in ${HOT_PULLS} open pulls`,
72 },
73 ]);
74 expect(ready).toContain(number("cold"));
75 });
76
77 const heat = (repoId: string) =>
78 rememberPullFiles(
79 repoId,
80 Array.from({ length: HOT_PULLS }, (_, i) => ({ number: 100 + i, files: ["src/busy.ts"] })),
81 );
82 const items = [
83 { key: "hot", title: "Rework src/busy.ts", body: "Files: src/busy.ts" },
84 { key: "cold", title: "Rework src/quiet.ts", body: "Files: src/quiet.ts" },
85 ];
86 const quiet = async (name: string) => {
87 const made = await createRepo(app, alice, { name, visibility: "private" });
88 if (!made.ok) throw new Error(String(made.error));
89 return updateRepoSettings(app.db, made.value, { plansNeedNod: false });
90 };
91
92 test("a file an admin holds holds the item naming it", async () => {
93 const repo = await quiet("pinned");
94 expect(holdFile(app.db, repo.id, "src/pinned.ts", alice.id).ok).toBe(true);
95 const planned = openPlan(app, alice, repo, [
96 { key: "pin", title: "Rework src/pinned.ts", body: "", blockedBy: [] },
97 { key: "free", title: "Rework src/free.ts", body: "", blockedBy: [] },
98 ]);
99 if (!planned.ok) throw new Error(planned.error.message);
100 expect(planned.value.held).toEqual([
101 {
102 key: "pin",
103 number: planned.value.opened[0]!.number,
104 by: "hot_file",
105 detail: "src/pinned.ts is held by an admin",
106 },
107 ]);
108 });
109
110 test("POST …/plan answers with held", async () => {
111 const repo = await quiet("route");
112 heat(repo.id);
113 const res = await fetch(`${servers.url}/api/v1/repos/alice/route/plan`, {
114 method: "POST",
115 headers: { authorization: `Bearer ${rw}`, "content-type": "application/json" },
116 body: JSON.stringify({ issues: items }),
117 });
118 expect(res.status).toBe(201);
119 const body = (await res.json()) as any;
120 expect(body.held.map((h: any) => [h.key, h.by, h.detail])).toEqual([
121 ["hot", "hot_file", `src/busy.ts is in ${HOT_PULLS} open pulls`],
122 ]);
123 });
124
125 test("the MCP plan tool answers with held", async () => {
126 const repo = await quiet("tool");
127 heat(repo.id);
128 const res = await fetch(`${servers.url}/mcp`, {
129 method: "POST",
130 headers: {
131 "content-type": "application/json",
132 accept: "application/json, text/event-stream",
133 authorization: `Bearer ${rw}`,
134 },
135 body: JSON.stringify({
136 jsonrpc: "2.0",
137 id: 1,
138 method: "tools/call",
139 params: { name: "plan", arguments: { app: "alice/tool", issues: items } },
140 }),
141 });
142 const out = JSON.parse(((await res.json()) as any).result.content[0].text);
143 expect(out.held.map((h: any) => [h.key, h.by, h.detail])).toEqual([
144 ["hot", "hot_file", `src/busy.ts is in ${HOT_PULLS} open pulls`],
145 ]);
146 });
147 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs