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

Lifting an issue from Later makes it startable, and the tick says why any ready issue isn't #8926

Merged opened this in answer to

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

Merged

mason/forge:mason/8924-lifting-an-issue-from-later-makes-it-sta → main 4 commits fork 4 threads · 4 unresolved

  • Merged. mason/8924-lifting-an-issue-from-later-makes-it-sta was merged into main as 97d2980.

Review

Sign in to review this change.
14 files changed +601 −54
docs/features/next-up-why.md added +7
@@ -0,0 +1,7 @@
1 # Next up says why a ready issue is not started (#8924)
2
3 - **A lift places the issue.** Taking an issue from behind the Later gate (the Lift button, the `wait_for` tool with `stop`, the pacer's release) leaves it under a pillar: the one it already serves, else the first, whose umbrella then waits for it. The one place is `stopWaiting` (`src/actions/gate.ts`), which `liftHold` and `wait_for` stop share, via `servePillar` (`src/core/pillar_pick.ts`), so neither route skips it; taking the `Later` label off by hand is not a lift, for the label puts the gate back.
4 - **No silent case.** Every ready issue a builder will not start carries one reason on Next up (web, terminal, MCP `next_up` as `why`): what `held.ts` holds (a run, a pull, a cap, a hot file, a stall), or what the picker passes over (`src/crew/pick_filter.ts`): `polish_share`, `no_bet`, `no_builder`. The tick line counts the passes.
5 - Not explained, because they need git: issues retired as already done on the base are closed with a note on the tick, not listed.
6
7 `notStartable` (`src/crew/not_startable.ts`) joins the two; the page reads the scope once. Tested in `test/not-startable.test.ts` and `test/lift-startable.test.ts`.
src/actions/gate.ts +21 −1
@@ -11,6 +11,7 @@ import type { App } from "../app.ts";
1111 import { closedByReview, gateHolding, gateOf } from "../core/gate_hold.ts";
1212 import type { Issue, Label } from "../core/issues.ts";
1313 import { isStructural, LATER, pillarsOf } from "../core/pillars.ts";
14 import { servePillar } from "../core/pillar_pick.ts";
1415 import type { Repo } from "../core/repos.ts";
1516 import type { User } from "../core/users.ts";
1617 import { err, ok, type Result } from "../kernel/result.ts";
@@ -24,6 +25,25 @@ import { setIssueState } from "./issues.ts";
2425 export const hasLater = (labels: readonly Pick<Label, "name">[]): boolean =>
2526 labels.some((l) => l.name.toLowerCase() === LATER.toLowerCase());
2627
28 /**
29 * The issue waits for `blocker` no more. When `blocker` is the Later gate
30 * that is a lift, and a lift means startable: the issue is left under a
31 * pillar, the unlinker's word on it (#8924). Taking the `Later` label off
32 * by hand is not this: its label puts the gate back.
33 */
34 export function stopWaiting(
35 app: App,
36 actor: User | undefined,
37 repo: Repo,
38 issue: Issue,
39 blocker: Issue,
40 ): Result<void, AccessError> {
41 const done = unlinkIssue(app, actor, repo, issue, blocker);
42 if (done.ok && actor !== undefined && pillarsOf(app.db, repo).gate?.id === blocker.id)
43 servePillar(app.db, repo, issue, actor.id);
44 return done;
45 }
46
2747 /**
2848 * Let the issue go from behind the gate: it waits for the gate no more, and
2949 * its `Later` label goes with it. One the gate review closed is reopened as
@@ -41,7 +61,7 @@ export function liftHold(
4161 const revive = closedByReview(app.db, issue);
4262 const gate = revive ? gateOf(app.db, repo, issue) : gateHolding(app.db, repo, issue);
4363 if (gate === undefined) return err("not_held");
44 const lifted = unlinkIssue(app, actor, repo, issue, gate);
64 const lifted = stopWaiting(app, actor, repo, issue, gate);
4565 if (!lifted.ok) return lifted;
4666 if (!revive) return ok(issue);
4767 const reopened = setIssueState(app, actor, repo, issue, "open");
src/core/next_up.ts +8 −4
@@ -7,7 +7,7 @@
77 */
88
99 import type { Database } from "bun:sqlite";
10 import { type Graph, reachOf, readyNow, repoGraph } from "./dependencies.ts";
10 import { type Graph, type Ranked, reachOf, readyNow, repoGraph } from "./dependencies.ts";
1111 import type { Issue } from "./issues.ts";
1212 import { pillarsOf, servedBy } from "./pillars.ts";
1313 import type { Repo } from "./repos.ts";
@@ -37,7 +37,7 @@ export interface NextRow {
3737 readonly place: number | null;
3838 /** Who is on it: the crew member running it, else its assignee. */
3939 readonly onIt: string | null;
40 /** In one line, why it cannot start yet; null when it can. */
40 /** In one line, why it cannot start yet; null when it can. A ready row has one whenever a builder will not start it. */
4141 readonly why: string | null;
4242 }
4343
@@ -108,10 +108,14 @@ export function nextUp(
108108 db: Database,
109109 repo: Repo,
110110 scope: { sealed?: true; sealedBy?: string } = { sealed: true },
111 /** Why a ready issue is not startable, by issue id (`crew/not_startable.ts`): a page that has the app passes it. */
112 notStartable: (ready: readonly Ranked[]) => ReadonlyMap<string, string> = () => new Map(),
111113 ): NextUp {
112114 const graph = repoGraph(db, repo.id, { ...scope, lean: true });
113115 const ranks = graph.ranked ?? new Map<string, number>();
114 const ready = readyNow(graph).map((r) => r.issue);
116 const readyRanked = readyNow(graph);
117 const ready = readyRanked.map((r) => r.issue);
118 const unstartable = notStartable(readyRanked);
115119 const readyIds = new Set(ready.map((i) => i.id));
116120 // Who is on an issue: the crew running it right now, else whoever it is assigned to.
117121 const crew = new Map<string, string>();
@@ -126,7 +130,7 @@ export function nextUp(
126130 ...roleOf(holds.get(issue.id)?.size ?? 0, serves.get(issue.id), ranks.has(issue.id)),
127131 place: ranks.get(issue.id) ?? null,
128132 onIt: crew.get(issue.id) ?? issue.assigneeName,
129 why: readyIds.has(issue.id) ? null : whyNot(graph, issue),
133 why: readyIds.has(issue.id) ? unstartable.get(issue.id) ?? null : whyNot(graph, issue),
130134 });
131135 const blocked = graph.issues.filter((i) => i.state === "open" && ranks.has(i.id) && !readyIds.has(i.id));
132136 const rows = pickOrder(ready, ranks, (i) => i.id, blocked).map(rowOf);
src/core/pillar_pick.ts added +30
@@ -0,0 +1,30 @@
1 /**
2 * Choosing a pillar for an issue let out from behind the Later gate (#8924).
3 * The gate edge was also what a person's link meant: take it away and an
4 * issue a reader opened is nobody's again (`unadoptedAmong`), and one that
5 * serves no pillar says nothing about what it is for. So a lift, by hand or
6 * by the pacer, leaves the issue under a pillar: the one it already serves,
7 * else the first, whose umbrella then waits for it, linked by whoever lifted.
8 */
9
10 import type { Database } from "bun:sqlite";
11 import { addDependency } from "./dependencies.ts";
12 import type { Issue } from "./issues.ts";
13 import { isStructural, LATER, pillarsOf, servedBy } from "./pillars.ts";
14 import type { Repo } from "./repos.ts";
15
16 /**
17 * Put `issue` under a pillar unless one already holds it. Returns the
18 * pillar's name when it linked, else undefined: it served one, the gate
19 * still held it, or the repository has no pillars to serve.
20 */
21 export function servePillar(db: Database, repo: Repo, issue: Issue, actorId: string): string | undefined {
22 const set = pillarsOf(db, repo);
23 const pillar = set.pillars[0];
24 if (pillar === undefined || issue.kind !== "issue" || isStructural(set, issue.id)) return undefined;
25 const serves = servedBy(db, set).get(issue.id);
26 // `Later` is the gate or the Later umbrella: neither is a pillar.
27 if (serves !== undefined && serves !== LATER) return undefined;
28 const linked = addDependency(db, pillar.umbrella, issue, actorId);
29 return linked.ok ? pillar.name : undefined;
30 }
src/crew/autonomy.ts +9 −40
@@ -31,11 +31,9 @@ import { addLearned } from "../checks/learned.ts";
3131 import { batchWords, dropFromLine, enqueue, type GateCandidate, type Queued, type QueueOptions, queueClashes, queueWords, waitingPulls } from "../checks/queue.ts";
3232 import { checksFor } from "../checks/service.ts";
3333 import { queueBatch, queueDepth, smallShare } from "../core/capacity.ts";
34 import { isPolish, polishSpend, shareWords, slotOrder, smallCap, smallIssues, smallOfIds } from "./small_share.ts";
35 import { backedBetOf, betsInUse } from "../core/bets.ts";
36 import { mayPick } from "./bets.ts";
34 import { shareWords, slotOrder, smallCap, smallIssues, smallOfIds } from "./small_share.ts";
35 import { crewFor as crewForIssue, passedWords, pickFilter, routable } from "./pick_filter.ts";
3736 import { tickPolish } from "./polish_cap.ts";
38 import { raisedAt } from "../core/thought_links.ts";
3937 import { readConflictLane, readConflictRoom } from "./conflict_room.ts";
4038 import { mergeable, resolveOid, treeOf } from "../git/merge.ts";
4139 import { CLOSED_NOTE, leftNote, reopenedByPerson, unticked } from "../core/done_on_main.ts";
@@ -73,7 +71,6 @@ import {
7371 listComments,
7472 MAX_TITLE,
7573 type Issue,
76 labelsForIssues,
7774 } from "../core/issues.ts";
7875 import {
7976 getPull,
@@ -117,7 +114,7 @@ import { given, pendingReplans } from "../core/limb_replan.ts";
117114 import { handedOverPulls, SKIP_SUMMARY, skipsReplan } from "./guard.ts";
118115 import { atRunCap, type Held, heldBy, heldWords, replannable } from "./held.ts";
119116 import { holdHot, hotFiles, hotWords, learnPullFiles } from "./hot_files.ts";
120 import { landedUnder, NO_PULL, shunnedBy, stallReason, unstickOwed } from "./stalls.ts";
117 import { landedUnder, NO_PULL, stallReason, unstickOwed } from "./stalls.ts";
121118 import { alreadyDone, BY_CREW, DONE_LIKE } from "../core/done_on_main.ts";
122119 import { repoDir } from "../git/repos.ts";
123120 import { ALREADY_NOTE, alreadyDoneOids } from "./already.ts";
@@ -392,7 +389,7 @@ function releaseForPace(app: App, repo: Repo, by: User, slotted: number, startab
392389 const week = weekFor(app, repo, now, { idleSlots: Math.max(0, repo.autonomySlots - slotted), startable, gated: gated.length });
393390 const next = gated[0];
394391 if (week === null || !week.release || next === undefined) return undefined;
395 const [number] = releaseFromGate(app.db, repo.id, [next.number]);
392 const [number] = releaseFromGate(app.db, repo.id, [next.number], by.id);
396393 if (number === undefined) return undefined;
397394 const why = `Released from Later to use the week's subscription: ${week.sentence}`;
398395 log.info("released", { repo: `${repo.ownerName}/${repo.name}`, issue: number, why });
@@ -414,30 +411,9 @@ export function inSlotOrder(app: App, repo: Repo, ready: readonly Ranked[], slot
414411 const small = smallIssues(app.db, issues);
415412 const first = firstAt(app.db, issues.map((i) => i.id));
416413 const ranked = ranksInRepo(app.db, repo.id);
417 const raised = raisedAt(app.db, issues.map((i) => i.id));
418414 // Issues picked earlier in this tick hold their share before their runs show (#8729).
419415 const running = smallOfIds(app.db, [...new Set([...slotted.flatMap((r) => (r.issueId === null ? [] : [r.issueId])), ...picked])]);
420 // Once the owner has backed a bet, builders pick from backed bets, the owner's raised issues and the small lane (#8713).
421 const bets = betsInUse(app.db, repo.id) ? backedBetOf(app.db, repo.id, issues.map((i) => i.id)) : undefined;
422 // Polish (labelled `polish`, or in the small-fixes lane) stops once it has taken its share of the week (#8853);
423 // what the owner raised, ranked or put first is never held back.
424 const polishLabels = labelsForIssues(app.db, issues.map((i) => i.id));
425 const polishOpen = polishSpend(app.db, repo.id, repo.polishShare, now).under;
426 const heldBack = (r: Ranked): boolean =>
427 !polishOpen &&
428 !raised.has(r.issue.id) && !first.has(r.issue.id) && !ranked.has(r.issue.id) &&
429 isPolish(r.issue.title, (polishLabels.get(r.issue.id) ?? []).map((l) => l.name));
430 const pickable = ready.filter((r) => !heldBack(r));
431 const allowed =
432 bets === undefined
433 ? pickable
434 : pickable.filter((r) =>
435 mayPick({
436 raised: raised.has(r.issue.id) || first.has(r.issue.id),
437 small: small.has(r.issue.id),
438 bet: bets.get(r.issue.id),
439 }),
440 );
416 const { allowed } = pickFilter(app, repo, ready, now);
441417 // The order is the one Next up shows (#8826): ranked by place, then the ready order.
442418 return slotOrder(
443419 pickOrder(allowed, ranked, (r) => r.issue.id),
@@ -554,17 +530,8 @@ async function driveOnce(
554530 inFlight.set(r.actorId, (inFlight.get(r.actorId) ?? 0) + 1);
555531 // A builder that stalled an issue within the hour leaves it to the
556532 // others; one no builder may take now is skipped, not waited on (#4073).
557 const crewFor = (issue: Issue) => {
558 const shunned = shunnedBy(app.db, repo, issue.id, now);
559 return crew.filter((c) => c.role !== "builder" || !shunned.has(c.agent.user.id));
560 };
561 const pickable = (
562 await withoutRetired(
563 app,
564 repo,
565 ready.filter((r) => crewFor(r.issue).some((c) => c.role === "builder")),
566 )
567 );
533 const crewFor = (issue: Issue) => crewForIssue(app, repo, issue.id, crew, now);
534 const pickable = await withoutRetired(app, repo, routable(app, repo, ready, crew, now).kept);
568535 // The roadmap first, and small issues held to their share of the slots (#7571).
569536 const next = await nextOf(app, repo, inSlotOrder(app, repo, pickable, slotted, paced.slots, taken, now));
570537 if (next !== undefined) {
@@ -1403,6 +1370,8 @@ export async function autonomyTick(
14031370 // The standing reserve the planner keeps: startable/target (#3237).
14041371 reserve: reserveWords(reserveOf(held.startable.length, status.slots)),
14051372 held: heldWords(held),
1373 // What the picker passes over among the startable, so no ready issue goes unexplained (#8924).
1374 passed: passedWords(pickFilter(app, current, held.startable, now).passed),
14061375 // The files 5 or more open pulls touch, which no new build starts on (#5934).
14071376 ...(held.by.hot_file.length === 0 ? {} : { hot: hotWords(hotFiles(current.id)) }),
14081377 // Stalled issues this tick parked and freed for another builder (#4073).
src/crew/not_startable.ts added +40
@@ -0,0 +1,40 @@
1 /**
2 * Why a ready issue is not startable, one reason each (#8924): what
3 * `held.ts` holds (a run, a pull, a cap, a hot file) and what the picker
4 * passes over (`pick_filter.ts`: polish's share, the owner's bets, no builder
5 willing). No ready

Non-blocking: this comment line lost its ' * ' prefix and wraps badly; reflow the paragraph.

6 * issue goes unexplained: Next up prints these beside the tick line's counts.
7 */
8
9 import type { App } from "../app.ts";
10 import type { Ranked } from "../core/dependencies.ts";
11 import type { Repo } from "../core/repos.ts";
12 import { listActive } from "../core/runs.ts";
13 import { listCrewRoles } from "../core/agents.ts";
14 import { PASSED_WORDS, pickFilter, routable } from "./pick_filter.ts";
15 import { admits, heldBy, mapHolds } from "./held.ts";
16
17 /** Each ready issue a builder will not start now, by issue id, with its one reason. */
18 export function notStartable(
19 app: App,
20 repo: Repo,
21 ready: readonly Ranked[],
22 scope: { sealed?: true; sealedBy?: string },
23 ): Map<string, string> {
24 const active = listActive(app.db, "agent").filter((r) => r.repoId === repo.id);
25 const admit = admits(app, repo, scope);
26 const crew = listCrewRoles(app.db, repo.id);
27 const held = heldBy(app, repo, ready, crew, active, admit);
28 const out = new Map<string, string>();
29 const { holds } = mapHolds(repo, held, admit);
30 for (const { issue } of ready) {
31 if (issue.id === repo.directionIssueId) out.set(issue.id, "the planner's standing direction, never a builder's");
32 const hold = holds.get(issue.number);
outdated

Only holds and pickFilter passes are covered. Builder-routing passes (missing or shunned builder) still leave a ready issue with no reason. Add them here or in pickFilter. Without that, box 2 is not met.

33 if (hold !== undefined) out.set(issue.id, hold.detail);
34 }
35 const now = new Date();
36 const routed = routable(app, repo, held.startable, crew, now);
37 const { passed } = pickFilter(app, repo, routed.kept, now);
38 for (const [id, why] of [...routed.passed, ...passed]) out.set(id, PASSED_WORDS[why]);
39 return out;
40 }
src/crew/pick_filter.ts added +114
@@ -0,0 +1,114 @@
1 /**
2 * What the picker passes over, and why (#8924). A startable ready issue is
3 * passed over for one of four reasons: polish has taken its share of the
4 * week, the owner's bets do not cover it, no builder may take it now, or
5 * (held, not passed) `held.ts` holds it. Small and free of the tick, so
6 * Next up on the web, in the terminal and over MCP names the reason without
7 * loading the autonomy loop.
8 */
9
10 import type { App } from "../app.ts";
11 import { backedBetOf, betsInUse } from "../core/bets.ts";
12 import type { Ranked } from "../core/dependencies.ts";
13 import { firstAt } from "../core/first.ts";
14 import { labelsForIssues } from "../core/issues.ts";
15 import { ranksInRepo } from "../core/rank.ts";
16 import type { Repo } from "../core/repos.ts";
17 import { raisedAt } from "../core/thought_links.ts";
18 import type { Agent, CrewRole } from "../core/agents.ts";
19 import { mayPick } from "./bets.ts";
20 import { isPolish, polishSpend, smallIssues } from "./small_share.ts";
21 import { shunnedBy } from "./stalls.ts";
22
23 /** Why the picker passes a startable issue over. */
24 export type PassedOver = "polish_share" | "no_bet" | "no_builder";
25
26 /** The words for each, as Next up and the tick line say them. */
27 export const PASSED_WORDS: Readonly<Record<PassedOver, string>> = {
28 polish_share: "polish has taken its share of the week",
29 no_bet: "it is in no backed bet, and was not raised, put first or small",
30 no_builder: "no builder may take it now: each is shunned from it or there is none",
31 };
32
33 /**
34 * The builders that may take this issue: a builder that stalled it within
35 * the hour leaves it to the others (#4073).
36 */
37 export function crewFor<C extends { readonly agent: Agent; readonly role: CrewRole }>(
38 app: App,
39 repo: Repo,
40 issueId: string,
41 crew: readonly C[],
42 now: Date,
43 ): C[] {
44 const shunned = shunnedBy(app.db, repo, issueId, now);
45 return crew.filter((c) => c.role !== "builder" || !shunned.has(c.agent.user.id));
46 }
47
48 /** The ready issues no builder may take now; the others stay in order. */
49 export function routable<C extends { readonly agent: Agent; readonly role: CrewRole }>(
50 app: App,
51 repo: Repo,
52 ready: readonly Ranked[],
53 crew: readonly C[],
54 now: Date,
55 ): { kept: Ranked[]; passed: Map<string, PassedOver> } {
56 const passed = new Map<string, PassedOver>();
57 const kept = ready.filter((r) => {
58 if (crewFor(app, repo, r.issue.id, crew, now).some((c) => c.role === "builder")) return true;
59 passed.set(r.issue.id, "no_builder");
60 return false;
61 });
62 return { kept, passed };
63 }
64
65 /**
66 * What the picker takes from `ready` and what it passes over, each with its
67 * one reason (#8924): polish (labelled `polish`, or in the small-fixes lane)
68 * stops once it has taken its share of the week (#8853); once the owner has
69 * backed a bet, builders pick from backed bets, the owner's raised issues
70 * and the small lane (#8713). What the owner raised, ranked or put first is
71 * never held back by polish.
72 */
73 export function pickFilter(
74 app: App,
75 repo: Repo,
76 ready: readonly Ranked[],
77 now: Date = new Date(),
78 ): { allowed: Ranked[]; passed: Map<string, PassedOver> } {
79 const issues = ready.map((r) => r.issue);
80 const small = smallIssues(app.db, issues);
81 const first = firstAt(app.db, issues.map((i) => i.id));
82 const ranked = ranksInRepo(app.db, repo.id);
83 const raised = raisedAt(app.db, issues.map((i) => i.id));
84 const bets = betsInUse(app.db, repo.id) ? backedBetOf(app.db, repo.id, issues.map((i) => i.id)) : undefined;
85 const polishLabels = labelsForIssues(app.db, issues.map((i) => i.id));
86 const polishOpen = polishSpend(app.db, repo.id, repo.polishShare, now).under;
87 const passed = new Map<string, PassedOver>();
88 const allowed = ready.filter((r) => {
89 const id = r.issue.id;
90 if (
91 !polishOpen &&
92 !raised.has(id) && !first.has(id) && !ranked.has(id) &&
93 isPolish(r.issue.title, (polishLabels.get(id) ?? []).map((l) => l.name))
94 ) {
95 passed.set(id, "polish_share");
96 return false;
97 }
98 if (bets !== undefined && !mayPick({ raised: raised.has(id) || first.has(id), small: small.has(id), bet: bets.get(id) })) {
99 passed.set(id, "no_bet");
100 return false;
101 }
102 return true;
103 });
104 return { allowed, passed };
105 }
106
107 /** The tick line's words for what the picker passed over: `no_bet=3 polish_share=1`, or `none`. */
108 export function passedWords(passed: ReadonlyMap<string, PassedOver>): string {
109 const said = (Object.keys(PASSED_WORDS) as PassedOver[])
110 .map((name) => [name, [...passed.values()].filter((p) => p === name).length] as const)
111 .filter(([, n]) => n > 0)
112 .map(([name, n]) => `${name}=${n}`);
113 return said.length === 0 ? "none" : said.join(" ");
114 }
src/crew/release_later.ts +13 −4
@@ -12,6 +12,9 @@
1212
1313 import type { Database } from "bun:sqlite";
1414 import { removeDependency } from "../core/dependencies.ts";
15 import { getIssueById } from "../core/issues.ts";
16 import { servePillar } from "../core/pillar_pick.ts";
17 import { getRepoById } from "../core/repos.ts";
1518 import { laterGate } from "./later.ts";
1619
1720 export interface Held {
@@ -45,11 +48,11 @@ export function heldByGateAlone(db: Database, repoId: string): Held[] {
4548 }
4649
4750 /** Delete the gate edge of each issue named, all in one transaction (a failure releases none); returns the numbers released. */
48 export function releaseFromGate(db: Database, repoId: string, numbers: readonly number[]): number[] {
49 return db.transaction(() => release(db, repoId, numbers)).immediate();
51 export function releaseFromGate(db: Database, repoId: string, numbers: readonly number[], actorId?: string): number[] {
52 return db.transaction(() => release(db, repoId, numbers, actorId)).immediate();
5053 }
5154
52 function release(db: Database, repoId: string, numbers: readonly number[]): number[] {
55 function release(db: Database, repoId: string, numbers: readonly number[], actorId?: string): number[] {
5356 const number = laterGate(db, repoId);
5457 if (number === undefined) return [];
5558 const gate = db
@@ -60,7 +63,13 @@ function release(db: Database, repoId: string, numbers: readonly number[]): numb
6063 if (gate === null) return [];
6164 const want = new Set(numbers);
6265 const out: number[] = [];
66 const repo = actorId === undefined ? undefined : getRepoById(db, repoId);
6367 for (const held of heldByGateAlone(db, repoId))
64 if (want.has(held.number) && removeDependency(db, held.id, gate.id)) out.push(held.number);
68 if (want.has(held.number) && removeDependency(db, held.id, gate.id)) {
69 out.push(held.number);
70 // Released means startable: under a pillar, in the releaser's name (#8924).
71 const issue = repo === undefined ? undefined : getIssueById(db, held.id);
72 if (repo !== undefined && issue !== undefined && actorId !== undefined) servePillar(db, repo, issue, actorId);
73 }
6574 return out;
6675 }
src/http/mcp/tools/next_up.ts +3 −1
@@ -1,5 +1,6 @@
11 import { placeOf, rankIssue } from "../../../actions/next.ts";
22 import { nextUp, TOP, type NextRow } from "../../../core/next_up.ts";
3 import { notStartable } from "../../../crew/not_startable.ts";
34 import { weekView, type WeekView } from "../../../flow/week.ts";
45 import { sealedScope } from "../../../core/sealed.ts";
56 import { bad, good, type Tool, APP_ARG, planRepo, isAnswer, issueArg } from "../shared.ts";
@@ -48,7 +49,8 @@ export const tool: Tool = {
4849 if (!moved.ok)
4950 return bad(moved.error.kind === "not_found" ? "no_such_issue" : moved.error.kind, moved.error.message);
5051 }
51 const next = nextUp(ctx.app.db, repo, sealedScope(ctx.app.db, repo, user));
52 const scope = sealedScope(ctx.app.db, repo, user);
53 const next = nextUp(ctx.app.db, repo, scope, (ready) => notStartable(ctx.app, repo, ready, scope));
5254 const all = args["all"] === true;
5355 const week = weekView(ctx.app.db, repo.id, next.rows, new Date());
5456 return good({
src/http/mcp/tools/wait_for.ts +3 −2
@@ -1,5 +1,6 @@
11 import { THREAD_SAYS } from "../../../actions/thread_words.ts";
2 import { dependencyWords, linkIssue, unlinkIssue } from "../../../actions/dependencies.ts";
2 import { dependencyWords, linkIssue } from "../../../actions/dependencies.ts";
3 import { stopWaiting } from "../../../actions/gate.ts";
34 import { good, bad, type Tool, APP_ARG, planRepo, issueArg, isAnswer } from "../shared.ts";
45
56 export const tool: Tool = {
@@ -28,7 +29,7 @@ export const tool: Tool = {
2829 if ("ok" in blocker) return blocker;
2930 const stop = args["stop"] === true;
3031 const done = stop
31 ? unlinkIssue(ctx.app, user, repo, issue, blocker)
32 ? stopWaiting(ctx.app, user, repo, issue, blocker)
3233 : linkIssue(ctx.app, user, repo, issue, blocker);
3334 if (!done.ok)
3435 return bad(
src/tui/screens/next.ts +3 −1
@@ -15,6 +15,7 @@ import { rankedWords, rankIssue, stepTo, type Place, placeOf } from "../../actio
1515 import { nextUp, type NextRow } from "../../core/next_up.ts";
1616 import { canRead, canWrite, type Repo } from "../../core/repos.ts";
1717 import { sealedScope } from "../../core/sealed.ts";
18 import { notStartable } from "../../crew/not_startable.ts";
1819 import { rangeWords, weekView, type WeekView } from "../../flow/week.ts";
1920 import type { Ctx, Screen } from "../app/app.ts";
2021 import { binding } from "../app/keys.ts";
@@ -45,7 +46,8 @@ export function nextScreen(f: Session, ctx: Ctx, repo: Repo): Screen {
4546 away = 0;
4647 week = undefined;
4748 } else {
48 const next = nextUp(f.db, repo, sealedScope(f.db, repo, f.user));
49 const scope = sealedScope(f.db, repo, f.user);
50 const next = nextUp(f.db, repo, scope, (ready) => notStartable(f.app, repo, ready, scope));
4951 rows = next.rows;
5052 away = next.notNowTotal;
5153 week = weekView(f.db, repo.id, rows, new Date());
src/web/pages/next.ts +3 −1
@@ -11,6 +11,7 @@ import { nextUp, TOP, type NextRow } from "../../core/next_up.ts";
1111 import { canWrite, type Repo } from "../../core/repos.ts";
1212 import { notePhase, takeSessionTime } from "../../core/route_timing.ts";
1313 import { sealedScope } from "../../core/sealed.ts";
14 import { notStartable } from "../../crew/not_startable.ts";
1415 import { CUT_TOP_WORDS, cutWords, FIT_COUNT_JS, rangeWords, rowCosts, weekView, type Cut, type Priced } from "../../flow/week.ts";
1516 import type { Ctx } from "../../http/context.ts";
1617 import { crumbsFor, loadRepo, notFoundPage, repoBase, repoHeader, repoPalette, signInFirst } from "../chrome.ts";
@@ -169,7 +170,8 @@ export function nextPage(ctx: Ctx): Response {
169170 const base = repoBase(repo);
170171 const all = ctx.url.searchParams.get("all") === "1";
171172 const writer = ctx.auth.user !== undefined && ctx.auth.user.kind !== "agent" && canWrite(ctx.app.db, repo, ctx.auth.user);
172 const next = nextUp(ctx.app.db, repo, sealedScope(ctx.app.db, repo, ctx.auth.user));
173 const scope = sealedScope(ctx.app.db, repo, ctx.auth.user);
174 const next = nextUp(ctx.app.db, repo, scope, (ready) => notStartable(ctx.app, repo, ready, scope));
outdated

sealedScope is computed twice per request, and the heavy notStartable now runs on every render. Compute the scope once and check the page latency.

173175 const shown = all ? next.rows : next.rows.slice(0, TOP);
174176 const rest = next.rows.length - shown.length;
175177 const back = all ? `${base}/next?all=1` : `${base}/next`;
test/lift-startable.test.ts added +135
@@ -0,0 +1,135 @@
1 /**
2 * Lifting an issue from Later makes it startable (#8924): the one action
3 * leaves it reachable by the picker, under a pillar when it named none, and
4 * the tick and Next up say why any ready issue is not startable.
5 */
6 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
7 import { mkdtemp, rm } from "node:fs/promises";
8 import { tmpdir } from "node:os";
9 import { join } from "node:path";
10 import { createApp, type App } from "../src/app.ts";
11 import { addToCrew, createAgent } from "../src/actions/crew.ts";
12 import { linkIssue } from "../src/actions/dependencies.ts";
13 import { liftHold } from "../src/actions/gate.ts";
14 import { openIssue } from "../src/actions/issues.ts";
15 import { commitToBranch, createRepo, updateRepoSettings } from "../src/actions/repos.ts";
16 import { readyNow, repoGraph } from "../src/core/dependencies.ts";
17 import { getIssueById, type Issue } from "../src/core/issues.ts";
18 import { pillarsOf, servedBy } from "../src/core/pillars.ts";
19 import { getRepoById, type Repo } from "../src/core/repos.ts";
20 import { createUser, type User } from "../src/core/users.ts";
21 import { nextUp } from "../src/core/next_up.ts";
22 import { createRun } from "../src/core/runs.ts";
23 import { notStartable } from "../src/crew/not_startable.ts";
24 import { driveRepo } from "../src/crew/autonomy.ts";
25 import { passedWords } from "../src/crew/pick_filter.ts";
26 import { heldNow } from "../src/crew/held.ts";
27 import { releaseFromGate } from "../src/crew/release_later.ts";
28
29 let dataDir: string;
30 let app: App;
31 let alice: User;
32 let bob: User;
33 let repo: Repo;
34 let gate: Issue;
35
36 const fresh = () => getRepoById(app.db, repo.id)!;
37 const current = (i: Issue) => getIssueById(app.db, i.id)!;
38 const open = (who: User, title: string): Issue => {
39 const made = openIssue(app, who, fresh(), { title });
40 if (!made.ok) throw new Error(String(made.error));
41 return made.value;
42 };
43 const readyNumbers = () => readyNow(repoGraph(app.db, repo.id, { sealed: true })).map((r) => r.issue.number);
44
45 beforeAll(async () => {
46 dataDir = await mkdtemp(join(tmpdir(), "forge-lift-startable-"));
47 app = createApp({ dataDir, port: 0 });
48 const a = await createUser(app.db, {
49 origin: "seed", trusted: true, username: "alice", email: "[email protected]",
50 password: "a-good-passphrase", adminIfFirst: true,
51 });
52 if (!a.ok) throw new Error(a.error);
53 alice = a.value;
54 const b = await createUser(app.db, {
55 origin: "seed", trusted: true, username: "bob", email: "[email protected]",
56 password: "a-good-passphrase",
57 });
58 if (!b.ok) throw new Error(b.error);
59 bob = b.value;
60 const made = await createRepo(app, alice, { name: "notes", visibility: "public" });
61 if (!made.ok) throw new Error(String(made.error));
62 repo = made.value;
63 const first = await commitToBranch(app, alice, repo, {
64 message: "Start",
65 files: [{ path: "package.json", content: `{"name":"notes"}\n` }],
66 });
67 if (!first.ok) throw new Error(JSON.stringify(first.error));
68 const agent = await createAgent(app, alice, { username: "b1" });
69 if (!agent.ok) throw new Error(String(agent.error));
70 expect(addToCrew(app, alice, repo, "b1").ok).toBe(true);
71 const on = await updateRepoSettings(app, alice, repo, {
72 autonomy: "autonomous",
73 autonomyDirection: "Keep the notes tidy.",
74 autonomyCeilingUsd: 10,
75 autonomySlots: 1,
76 autonomyBy: alice.id,
77 });
78 expect(on.ok).toBe(true);
79 open(alice, "P0 Notes: keep notes");
80 gate = open(alice, "Later gate: nothing behind this starts until the owner opens it");
81 });
82
83 afterAll(async () => {
84 app.close();
85 await rm(dataDir, { recursive: true, force: true });
86 });
87
88 describe("a lift reaches the picker", () => {
89 test("an issue with no pillar, lifted, is under a pillar and picked on the next tick", async () => {
90 const issue = open(alice, "Rename a note");
91 expect(linkIssue(app, alice, fresh(), issue, gate).ok).toBe(true);
92 expect(readyNumbers()).not.toContain(issue.number);
93 expect(servedBy(app.db, pillarsOf(app.db, fresh())).get(issue.id)).toBe("Later");
94
95 const lifted = liftHold(app, alice, fresh(), current(issue));
96 expect(lifted.ok).toBe(true);
97 expect(servedBy(app.db, pillarsOf(app.db, fresh())).get(issue.id)).toBe("P0 Notes");
98 expect(readyNumbers()).toContain(issue.number);
99 expect(await driveRepo(app, fresh())).toBe("picked");
100 expect(current(issue).assigneeId).not.toBeNull();
101 });
102
103 test("a reader's issue the lift would leave unadopted is still ready", () => {
104 const issue = open(bob, "Export as text");
105 expect(linkIssue(app, alice, fresh(), issue, gate).ok).toBe(true);
106 expect(liftHold(app, alice, fresh(), current(issue)).ok).toBe(true);
107 expect(readyNumbers()).toContain(issue.number);
108 });
109
110 test("the pacer's release does the same", () => {
111 const issue = open(alice, "Pin the title");
112 expect(linkIssue(app, alice, fresh(), issue, gate).ok).toBe(true);
113 expect(releaseFromGate(app.db, repo.id, [issue.number], alice.id)).toEqual([issue.number]);
114 expect(servedBy(app.db, pillarsOf(app.db, fresh())).get(issue.id)).toBe("P0 Notes");
115 expect(readyNumbers()).toContain(issue.number);
116 expect(heldNow(app, fresh()).startable.map((r) => r.issue.number)).toContain(issue.number);
117 });
118 });
119
120 describe("no ready issue goes unexplained", () => {
121 test("Next up gives a ready issue a builder will not start its one reason", () => {
122 const busy = open(alice, "Sort notes by date");
123 const free = open(alice, "Tag notes");
124 createRun(app.db, { repoId: repo.id, kind: "agent", issueId: busy.id, actorId: alice.id, reason: "assigned" });
125 const scope = { sealed: true as const };
126 const rows = nextUp(app.db, fresh(), scope, (ready) => notStartable(app, fresh(), ready, scope)).rows;
127 expect(rows.find((r) => r.issue.id === busy.id)!.why).toBe("a run is in flight on it");
128 expect(rows.find((r) => r.issue.id === free.id)!.why).toBeNull();
129 });
130
131 test("the tick line counts what the picker passes over, and says none when it passes nothing", () => {
outdated

This only tests the string formatter. Add tests that make pickFilter return no_bet and polish_share and that Next up shows those reasons. Add one for the wait_for stop path too.

132 expect(passedWords(new Map())).toBe("none");
133 expect(passedWords(new Map([["a", "no_bet"], ["b", "no_bet"], ["c", "polish_share"]]))).toBe("polish_share=1 no_bet=2");
134 });
135 });
test/not-startable.test.ts added +212
@@ -0,0 +1,212 @@
1 /**
2 * No ready issue goes unexplained (#8924): the picker's three passes (polish's
3 * share, the owner's bets, a builder shunned from the issue) each name their
4 * reason, and Next up says it on the web, in the terminal and over MCP. The
5 * `wait_for` stop on the Later gate leaves the issue under a pillar.
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 { addToCrew, createAgent } from "../src/actions/crew.ts";
13 import { linkIssue } from "../src/actions/dependencies.ts";
14 import { openIssue, setIssueLabels } from "../src/actions/issues.ts";
15 import { commitToBranch, createLabel, createRepo, updateRepoSettings } from "../src/actions/repos.ts";
16 import { createApp as insertApp } from "../src/core/apps.ts";
17 import { insertBet, markBacked } from "../src/core/bets.ts";
18 import { listCrewRoles } from "../src/core/agents.ts";
19 import { readyNow, repoGraph } from "../src/core/dependencies.ts";
20 import { getIssueById, type Issue } from "../src/core/issues.ts";
21 import { nextUp } from "../src/core/next_up.ts";
22 import { pillarsOf, servedBy } from "../src/core/pillars.ts";
23 import { getRepoById, type Repo } from "../src/core/repos.ts";
24 import { createRun } from "../src/core/runs.ts";
25 import { createToken } from "../src/core/tokens.ts";
26 import { createUser, getUserByName, type User } from "../src/core/users.ts";
27 import { notStartable } from "../src/crew/not_startable.ts";
28 import { PASSED_WORDS, pickFilter, routable } from "../src/crew/pick_filter.ts";
29 import { serve, type Servers } from "../src/http/server.ts";
30 import { createForgeApp } from "../src/tui/forge.ts";
31 import { headlessTerminal } from "../src/tui/term/terminal.ts";
32 import { quietLogs } from "./helpers/globals.ts";
33 import { expectWork, requestStatements } from "./helpers/work.ts";
34 import { settleApp } from "./helpers/tui.ts";
35
36 quietLogs();
37
38 let dataDir: string;
39 let app: App;
40 let alice: User;
41 let repo: Repo;
42 let servers: Servers;
43 let token: string;
44 let polish: Issue;
45 let shunned: Issue;
46 let plain: Issue;
47 let id = 1;
48 const STALLED = "a builder ran on it and left without a pull";
49
50 const fresh = () => getRepoById(app.db, repo.id)!;
51 const open = (title: string): Issue => {
52 const made = openIssue(app, alice, fresh(), { title });
53 if (!made.ok) throw new Error(String(made.error));
54 return made.value;
55 };
56 const ready = () => readyNow(repoGraph(app.db, repo.id, { sealed: true }));
57 const reasons = () => notStartable(app, fresh(), ready(), { sealed: true });
58 const rowWhy = (n: number) => nextUp(app.db, fresh(), { sealed: true }, (r) => notStartable(app, fresh(), r, { sealed: true })).rows.find((r) => r.issue.number === n)?.why;
59
60 /** One MCP tool call as alice; the parsed answer. */
61 async function call(name: string, args: unknown): Promise<{ isError: boolean; value: any }> {
62 const res = await fetch(`${servers.url}/mcp`, {
63 method: "POST",
64 headers: { "content-type": "application/json", accept: "application/json, text/event-stream", authorization: `Bearer ${token}` },
65 body: JSON.stringify({ jsonrpc: "2.0", id: id++, method: "tools/call", params: { name, arguments: args } }),
66 });
67 const body = (await res.json()) as any;
68 const text: string = body.result.content[0].text;
69 return { isError: body.result.isError === true, value: body.result.isError === true ? text : JSON.parse(text) };
70 }
71
72 beforeAll(async () => {
73 dataDir = await mkdtemp(join(tmpdir(), "forge-not-startable-"));
74 app = createApp({ dataDir, port: 0 });
75 const a = await createUser(app.db, {
76 origin: "seed", trusted: true, username: "alice", email: "[email protected]",
77 password: "a-good-passphrase", adminIfFirst: true,
78 });
79 if (!a.ok) throw new Error(a.error);
80 alice = a.value;
81 const made = await createRepo(app, alice, { name: "notes", visibility: "public" });
82 if (!made.ok) throw new Error(String(made.error));
83 repo = made.value;
84 const first = await commitToBranch(app, alice, repo, {
85 message: "Start", files: [{ path: "package.json", content: `{"name":"notes"}\n` }],
86 });
87 if (!first.ok) throw new Error(JSON.stringify(first.error));
88 const agent = await createAgent(app, alice, { username: "b1" });
89 if (!agent.ok) throw new Error(String(agent.error));
90 expect(addToCrew(app, alice, repo, "b1").ok).toBe(true);
91 expect((await updateRepoSettings(app, alice, repo, {
92 autonomy: "autonomous", autonomyDirection: "Keep the notes tidy.", autonomyCeilingUsd: 10, autonomySlots: 1, autonomyBy: alice.id,
93 })).ok).toBe(true);
94 insertApp(app.db, { repoId: repo.id, slug: "notes", access: "private" });
95 open("P0 Notes: keep notes");
96 createLabel(app, alice, fresh(), "polish", "888888");
97 polish = open("Nudge the margins");
98 expect(setIssueLabels(app, alice, fresh(), polish, ["polish"]).ok).toBe(true);
99 shunned = open("Fix the sync");
100 plain = open("Tag notes");
101 servers = await serve(app);
102 token = createToken(app.db, alice.id, "rw", "rw").token;
103 });
104
105 afterAll(async () => {
106 servers.stop();
107 app.close();
108 await rm(dataDir, { recursive: true, force: true });
109 });
110
111 describe("the picker names why it passes a ready issue over", () => {
112 test("polish over its share is passed over as polish_share, and only polish", () => {
113 const old = open("Tidy the footer");
114 expect(setIssueLabels(app, alice, fresh(), old, ["polish"]).ok).toBe(true);
115 app.db.query("UPDATE issue SET state = 'closed', close_reason = 'done' WHERE id = ?1").run(old.id);
116 const run = createRun(app.db, { repoId: repo.id, kind: "agent", issueId: old.id, actorId: alice.id, reason: "assigned" });
117 app.db.query("UPDATE run SET state = 'ok', cost_usd = 3, started_at = ?2 WHERE id = ?1").run(run.id, new Date().toISOString());
118 const { allowed, passed } = pickFilter(app, fresh(), ready());
119 expect(passed.get(polish.id)).toBe("polish_share");
120 expect(passed.has(plain.id)).toBe(false);
121 expect(allowed.map((r) => r.issue.id)).toContain(plain.id);
122 expect(reasons().get(polish.id)).toBe(PASSED_WORDS.polish_share);
123 });
124
125 test("a builder shunned from an issue is held with its reason, and one with no builder to take it is no_builder", () => {
126 const crew = listCrewRoles(app.db, repo.id);
127 const b1 = crew.find((c) => c.agent.user.username === "b1")!;
128 const run = createRun(app.db, { repoId: repo.id, kind: "agent", issueId: shunned.id, actorId: b1.agent.user.id, reason: "assigned" });
129 app.db.query("UPDATE run SET state = 'ok', summary = 'looked around', finished_at = ?2 WHERE id = ?1").run(run.id, new Date().toISOString());
130 expect(reasons().get(shunned.id)).toBe(STALLED);
131 const none = routable(app, fresh(), ready(), crew.filter((c) => c.role !== "builder"), new Date());
132 expect(none.kept).toEqual([]);
133 expect(none.passed.get(plain.id)).toBe("no_builder");
134 expect(reasons().has(plain.id)).toBe(false);
135 });
136
137 test("once a bet is backed, an issue in no bet is passed over as no_bet", () => {
138 expect(pickFilter(app, fresh(), ready()).passed.has(plain.id)).toBe(false);
139 const bet = insertBet(app.db, { repoId: repo.id, name: "Tidy", journeys: "", appetite: 5, leadId: null, issueIds: [polish.id] }, new Date());
140 markBacked(app.db, bet.id, 5, "owner", new Date());
141 expect(pickFilter(app, fresh(), ready()).passed.get(plain.id)).toBe("no_bet");
142 expect(reasons().get(plain.id)).toBe(PASSED_WORDS.no_bet);
143 });
144 });
145
146 describe("Next up shows the reason on every surface", () => {
147 test("core rows carry it", () => {
148 expect(rowWhy(plain.number)).toBe(PASSED_WORDS.no_bet);
149 expect(rowWhy(shunned.number)).toBe(STALLED);
150 });
151
152 test("the web page prints it", async () => {
153 const res = await fetch(`${servers.url}/alice/notes/next`);
154 expect(res.status).toBe(200);
155 const page = await res.text();
156 expect(page).toContain(PASSED_WORDS.no_bet);
157 expect(page).toContain(STALLED);
158 });
159
160 test("the page, reasons and all, costs a bounded number of statements", async () => {
161 await fetch(`${servers.url}/alice/notes/next`);
162 const n = await requestStatements(app.db, `${servers.url}/alice/notes/next`);
163 expectWork(n, { max: 120 });
164 });
165
166 test("the MCP tool carries it as why", async () => {
167 const answer = await call("next_up", { app: "alice/notes", all: true });
168 const next = answer.value.next as { issue: number; why: string | null }[];
169 expect(next.find((r) => r.issue === plain.number)?.why).toBe(PASSED_WORDS.no_bet);
170 expect(next.find((r) => r.issue === shunned.number)?.why).toBe(STALLED);
171 });
172
173 test("the terminal prints it", async () => {
174 const terminal = headlessTerminal(160, 40);
175 const { app: tui } = createForgeApp(app, { sshUsername: "t", remoteAddress: "127.0.0.1", user: getUserByName(app.db, "alice") }, { terminal, exitHooks: false });
176 tui.start();
177 const type = async (text: string) => {
178 for (const ch of text) tui.dispatch({ type: "key", key: ch, ctrl: false, alt: false, shift: false });
179 await settleApp(tui);
180 };
181 const enter = async () => {
182 tui.dispatch({ type: "key", key: "enter", ctrl: false, alt: false, shift: false });
183 await settleApp(tui);
184 };
185 await settleApp(tui);
186 tui.dispatch({ type: "key", key: "k", ctrl: true, alt: false, shift: false });
187 await settleApp(tui);
188 await type("Next up in a repository");
189 await enter();
190 await type("alice/notes");
191 await enter();
192 const text = tui.screen.toText();
193 tui.stop();
194 expect(text).toContain("Tag notes");
195 expect(text).toContain("it is in no backed bet");
196 });
197 });
198
199 describe("wait_for stop on the Later gate", () => {
200 test("leaves the issue under a pillar and ready", async () => {
201 const gate = open("Later gate: nothing behind this starts until the owner opens it");
202 const held = open("Rename a note");
203 expect(linkIssue(app, alice, fresh(), held, gate).ok).toBe(true);
204 expect(servedBy(app.db, pillarsOf(app.db, fresh())).get(held.id)).toBe("Later");
205 expect(ready().map((r) => r.issue.number)).not.toContain(held.number);
206 const stopped = await call("wait_for", { app: "alice/notes", issue: held.number, blockedBy: gate.number, stop: true });
207 expect(stopped.isError).toBe(false);
208 expect(getIssueById(app.db, held.id)!.state).toBe("open");
209 expect(servedBy(app.db, pillarsOf(app.db, fresh())).get(held.id)).toBe("P0 Notes");
210 expect(ready().map((r) => r.issue.number)).toContain(held.number);
211 });
212 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs