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

The tick says why each ready issue cannot start: counts per filter, and the planner acts on them #2254

Merged opened this in answer to

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

Merged

mason/forge:mason/2244-the-tick-says-why-each-ready-issue-canno → main 3 commits fork 3 threads · 3 unresolved

  • Merged. mason/2244-the-tick-says-why-each-ready-issue-canno was merged into main as 3886955.

Review

Sign in to review this change.
11 files changed +761 −90
README.md +12
@@ -664,6 +664,18 @@ is full: no pull has merged in 48 h while one was closed unmerged, or more than
664664 words. A comment on the direction issue from someone who may write there lifts
665665 the hold; an issue handed to the planner is still owed its pass.
666666
667 Every tick says why each ready issue a builder cannot start is held, counted
668 under the first filter that holds it (`crew/held.ts`): `held: running=1
669 open_pull=10 stalled=12 run_cap=11` — a run on it, an open pull proposing it,
670 the planner's, stalled, at its run cap, parked twice, covered by a red-main
671 fix, or a setting's finding. `/flow` draws the same per autonomous app as one
672 bar, and the terminal's Flow command prints it as a line per app
673 (`flow/held.ts`, kept 5 s). When nothing is startable while builder slots are free and some of the
674 held are stalled, capped or parked twice, the planner is owed one pass with
675 that breakdown in its brief, to re-plan them into smaller, fresh issues; a
676 full plate and its own unanswered decline do not hold that pass, and the
677 same breakdown does not ask it twice.
678
667679 The forge asks the host for one more binary: `claude`, on `PATH` or at
668680 `FORGE_CLAUDE`, authenticated as the operator (an `ANTHROPIC_API_KEY` in the
669681 forge's environment is handed to runs; the machine's own Claude Code login
docs/FLOW.md +8
@@ -55,3 +55,11 @@ Flow command, a forge admin's like the page, opens a dialog with the
5555 bottleneck sentence, the headline and one line per stage of its last-hour
5656 and 24-hour counts. A sealed issue is counted, its title is
5757 not shown.
58
59 Under the pipeline, `/flow` shows each autonomous repository's ready list as
60 one bar (#2244): what a builder could start beside the count per filter that
61 holds the rest — `crew/held.ts`'s `heldBy`, the same counts the autonomy
62 tick logs as `held=`, read through `flow/held.ts`, which keeps the rows 5 s.
63 The terminal's Flow command prints the same rows, one line per repository
64 (`alice/engine startable=0 stalled=4 run_cap=1`). A granted agent sees
65 public repositories only, on both.
src/crew/autonomy.ts +49 −85
@@ -35,8 +35,6 @@ import {
3535 crewRole,
3636 } from "../core/agents.ts";
3737 import { pickBuilder, routeIssue } from "./route.ts";
38 import { coveredByRedFix } from "./red_fix.ts";
39 import { isSettingFinding } from "./setting_finding.ts";
4038 import { readyNow, repoGraph, type Ranked } from "../core/dependencies.ts";
4139 import {
4240 latestDecision,
@@ -91,13 +89,8 @@ import { reviewSummary } from "../core/reviews.ts";
9189 import { standing } from "../core/standing.ts";
9290 import { pace, type Pace } from "./pacing.ts";
9391 import { BREAKER_NOTE, breakerTripped } from "./breaker.ts";
94 import {
95 capReached,
96 handedOverPulls,
97 runCapOf,
98 SKIP_SUMMARY,
99 skipsReplan,
100 } from "./guard.ts";
92 import { handedOverPulls, SKIP_SUMMARY, skipsReplan } from "./guard.ts";
93 import { atRunCap, type Held, heldBy, heldWords, replannable } from "./held.ts";
10194 import { BY_CREW, DONE_LIKE } from "../core/done_on_main.ts";
10295 import { ALREADY_NOTE, alreadyDoneOids } from "./already.ts";
10396 import { interruptionOf } from "./interrupted.ts";
@@ -115,8 +108,6 @@ import {
115108 directorsAmong,
116109 landsOnItsOwn,
117110 markReaders,
118 proposedWork,
119 speaksForCrew,
120111 } from "./direct.ts";
121112 import { crewOffInPreview } from "./preview.ts";
122113 import { moveRequested } from "./requested.ts";
@@ -364,7 +355,8 @@ export async function driveRepo(
364355 // operator's; at a wall nothing new starts until the reset.
365356 const paced = repoPace(app, repo, now);
366357 if (paced.slots === 0 && slotted.length === 0) return "waiting";
367 const ready = startable(app, repo, readyNow(repoGraph(app.db, repo.id, { sealed: true })), crew, active);
358 const held = heldBy(app, repo, readyNow(repoGraph(app.db, repo.id, { sealed: true })), crew, active);
359 const ready = held.startable;
368360 if (slotted.length >= paced.slots) {
369361 // The builders are busy; the planner can still refill the map, when
370362 // it is thin — counted, never assumed: a full crew with a full map is
@@ -412,17 +404,20 @@ export async function driveRepo(
412404 return "picked";
413405 }
414406
415 // Nothing is ready: the planner is asked, thin or dry, at most every
416 // REPLAN_AFTER_MS.
417 if (await planIfThin(app, repo, by, crew, active, 0, now)) return "planned";
407 // Nothing is startable with slots free: the planner is asked, thin or
408 // dry, at most every REPLAN_AFTER_MS. When ready issues are held by what
409 // re-planning lifts — stalled, capped, parked twice — it is asked with
410 // that breakdown, and neither a full plate nor its own unanswered
411 // decline holds it: nothing else will move them (#2244).
412 const stuck = replannable(held).length > 0 ? held : null;
413 if (await planIfThin(app, repo, by, crew, active, 0, now, stuck)) return "planned";
418414 return "idle";
419415 }
420416
421417 /**
422 * The ready issues a builder may start now: not the direction, not in
423 * flight, not already proposed in an open pull, not held by the planner or
424 * a reviewer, not stalled, capped, parked, covered by a red fix, or a
425 * setting's finding. The tick log counts these beside the raw ready list.
418 * The ready issues a builder may start now: not the direction, and held by
419 * none of `held.ts`'s filters. The tick log counts these beside the raw
420 * ready list, and the held ones per filter.
426421 */
427422 export function startable(
428423 app: App,
@@ -431,24 +426,7 @@ export function startable(
431426 crew: readonly { agent: Agent; role: CrewRole }[],
432427 active: readonly Run[],
433428 ): Ranked[] {
434 const busyIssues = new Set(active.map((r) => r.issueId));
435 const proposed = proposedWork(app.db, repo);
436 // An issue a planner or a reviewer holds is theirs, not a builder's.
437 const planners = new Set(
438 crew.filter((c) => c.role !== "builder").map((c) => c.agent.user.id),
439 );
440 return ready.filter(
441 (r) =>
442 r.issue.id !== repo.directionIssueId &&
443 !busyIssues.has(r.issue.id) &&
444 !proposed.has(r.issue.number) &&
445 (r.issue.assigneeId === null || !planners.has(r.issue.assigneeId)) &&
446 !stalled(app, repo, r.issue) &&
447 !atRunCap(app, repo, r.issue) &&
448 parkedCount(app, repo, r.issue.id) < PARK_STRIKES &&
449 !coveredByRedFix(app.db, repo.id, r.issue) &&
450 !isSettingFinding(app.db, r.issue.id),
451 );
429 return heldBy(app, repo, ready, crew, active).startable;
452430 }
453431
454432 /** The map is thin below this many ready issues; the planner refills it. */
@@ -581,6 +559,7 @@ async function planIfThin(
581559 active: readonly Run[],
582560 ready: number,
583561 now: Date,
562 stuck: Held | null = null,
584563 ): Promise<boolean> {
585564 const handed = [...parkedTwice(app.db, [repo]), ...handedOverPulls(app.db, repo)];
586565 if (ready >= THIN_MAP && handed.length === 0) return false;
@@ -604,19 +583,23 @@ async function planIfThin(
604583 const seen = lastPlan?.created_at ?? "";
605584 const owed = handed.filter((issue) => issue.at > seen);
606585 if (ready >= THIN_MAP && owed.length === 0) return false;
586 // Nothing startable for issues held since its last pass that re-planning
587 // lifts: owed one pass like a hand-over, once (#2244).
588 const unstuck = stuck === null ? [] : replannable(stuck).filter((issue) => heldSince(app, issue, seen));
589 const news = owed.length + unstuck.length;
607590 // The direction is the planner's standing issue, not one issue retried:
608591 // it is held to its own day's share, never the builders' cap (#2143).
609592 if (atRunCap(app, repo, direction))
610593 return notPlanned(repo, "parked: the direction is at its day's planning cap");
611594 // An issue the crew handed over is owed its pass whatever the hold: the
612595 // planner splitting it is how work starts landing again.
613 const hold = owed.length === 0 ? plannerHold(app, repo, now) : null;
596 const hold = news === 0 ? plannerHold(app, repo, now) : null;
614597 if (hold !== null) return held(repo, holdWhy(hold));
615598 if (lastPlan !== null && lastPlan !== undefined) {
616599 // A planner that declined the direction is not asked again until a
617600 // person has said something on it, or the crew hands it an issue.
618601 if (
619 owed.length === 0 &&
602 news === 0 &&
620603 lastPlan.state === "failed" &&
621604 declinedUnanswered(app, repo, direction)
622605 )
@@ -631,7 +614,7 @@ async function planIfThin(
631614 // Nobody spoke and the map has not moved since the last pass: another
632615 // would read its own summary and change nothing (#1097).
633616 if (
634 owed.length === 0 &&
617 news === 0 &&
635618 skipsReplan(app.db, repo, direction, planner, lastPlan.created_at)
636619 )
637620 return notPlanned(repo, SKIP_SUMMARY);
@@ -650,9 +633,11 @@ async function planIfThin(
650633 log.info(
651634 owed.length > 0
652635 ? "parked twice; planner asked"
653 : ready === 0
654 ? "map ran dry; planner asked"
655 : "map is thin; planner asked",
636 : unstuck.length > 0
637 ? "nothing startable; planner asked"
638 : ready === 0
639 ? "map ran dry; planner asked"
640 : "map is thin; planner asked",
656641 {
657642 repo: `${repo.ownerName}/${repo.name}`,
658643 planner: planner.user.username,
@@ -660,43 +645,25 @@ async function planIfThin(
660645 ...(owed.length > 0
661646 ? { parkedTwice: owed.map((issue) => `#${issue.number}`).join(" ") }
662647 : {}),
648 ...(stuck === null ? {} : { held: heldWords(stuck) }),
663649 },
664650 );
665651 return true;
666652 }
667653
668654 /**
669 * An issue a builder already ran on and left without a pull — declined, or
670 * cut short — is not picked again by the clock: running it again blind is
671 * the loop autonomy refuses. It waits for a person's word, which is a
672 * feedback run like any other.
655 * Whether `issue` came to be held after `since` — its newest builder run
656 * ended then, which is what stalls or caps it — so a planning pass that
657 * started before has not seen it.
673658 */
674 function stalled(app: App, repo: Repo, issue: Issue): boolean {
659 function heldSince(app: App, issue: Issue, since: string): boolean {
675660 const last = app.db
676 .query<{ state: string; summary: string; created_at: string }, [string]>(
677 `SELECT state, summary, created_at FROM run WHERE issue_id = ?1 AND kind = 'agent'
678 ORDER BY created_at DESC LIMIT 1`,
661 .query<{ at: string | null }, [string]>(
662 `SELECT MAX(COALESCE(finished_at, created_at)) AS at FROM run
663 WHERE issue_id = ?1 AND kind = 'agent'`,
679664 )
680 .get(issue.id);
681 if (last === null || last === undefined) return false;
682 if (last.state === "queued" || last.state === "running") return true;
683 if (/opened pull #|updated pull #/.test(last.summary)) return false;
684 // Cut short by the provider or a restart: nobody tried it, so the clock
685 // tries again (`interrupted.ts`).
686 if (last.state === "failed" && interruptionOf(last.summary) !== undefined)
687 return false;
688 // A word since then — from someone who may direct the crew, or the
689 // planner's when it re-planned a parked issue — is what un-stalls it. A
690 // reader's comment stays on the thread, and so does one imported from
691 // an origin, whoever connected it (#619).
692 return !app.db
693 .query<{ id: string }, [string, string]>(
694 `SELECT DISTINCT n.author_id AS id FROM comment n
695 WHERE n.issue_id = ?1 AND n.created_at > ?2 AND n.origin_author IS NULL
696 AND NOT (n.body LIKE '${DONE_LIKE}' AND ${BY_CREW})`,
697 )
698 .all(issue.id, last.created_at)
699 .some((r) => speaksForCrew(app.db, repo, getUserById(app.db, r.id)));
665 .get(issue.id)?.at;
666 return typeof last === "string" && last > since;
700667 }
701668
702669 /** Whether the newest run on the issue was a decline and no person has spoken since. */
@@ -799,11 +766,6 @@ export function parkForBreaker(
799766 return true;
800767 }
801768
802 /** Whether `issue` is at its run cap (`guard.ts`): the clock neither picks nor plans it. */
803 export function atRunCap(app: App, repo: Repo, issue: Issue): boolean {
804 return capReached(app.db, repo, issue, runCapOf(repo, issue, app.config));
805 }
806
807769 /** How many times the crew has parked this issue since a person who directs it last spoke (`parks.ts`). */
808770 export function parkedCount(app: App, repo: Repo, issueId: string): number {
809771 return countParks(app.db, repo, issueId);
@@ -1147,19 +1109,21 @@ export async function autonomyTick(
11471109 // never a mystery: the numbers behind "idle" are on the line.
11481110 const status = autonomyStatus(app, current, now);
11491111 const ready = readyNow(repoGraph(app.db, current.id, { sealed: true }));
1112 const held = heldBy(
1113 app,
1114 current,
1115 ready,
1116 listCrewRoles(app.db, current.id),
1117 activeRuns(app, current.id),
1118 );
11501119 log.info("tick", {
11511120 repo: `${repo.ownerName}/${repo.name}`,
11521121 did,
11531122 ready: ready.length,
1154 // What the builders could start after the filters: 47 ready with 0
1155 // startable is every one in a pull, stalled or capped (#2143).
1156 startable: startable(
1157 app,
1158 current,
1159 ready,
1160 listCrewRoles(app.db, current.id),
1161 activeRuns(app, current.id),
1162 ).length,
1123 // What the builders could start after the filters (#2143), and
1124 // what holds the rest, per filter (#2244).
1125 startable: held.startable.length,
1126 held: heldWords(held),
11631127 active: status.active,
11641128 slots: status.slots,
11651129 ...(requested.length === 0
src/crew/brief.ts +37 −1
@@ -187,6 +187,20 @@ export interface TaskInput extends BriefInput {
187187 readonly parkedTwice?: readonly HandedOver[] | undefined;
188188 /** For a planner: the issue deferred work waits behind, if there is one. */
189189 readonly laterGate?: number | undefined;
190 /** For a planner: nothing ready can start, and what holds it (`held.ts`). */
191 readonly stuck?: Stuck | undefined;
192 }
193
194 /** Ready issues no builder can start, as the planner is told of them (#2244). */
195 export interface Stuck {
196 /** The count per filter, as the tick line says it: `open_pull=10 stalled=3`. */
197 readonly held: string;
198 /** The held issues re-planning lifts: stalled or at their run cap. */
199 readonly issues: readonly {
200 readonly number: number;
201 readonly title: string;
202 readonly hold: "stalled" | "run_cap";
203 }[];
190204 }
191205
192206 /** An issue the crew parked twice, as the planner is told of it. */
@@ -219,7 +233,9 @@ function taskBody(input: TaskInput): string {
219233 const { issue } = input;
220234 const out: string[] = [];
221235 const isAgent = (name: string): boolean => name === input.agent.user.username;
222 const handed = parkedTwiceLines(input);
236 const handed = [parkedTwiceLines(input), stuckLines(input)]
237 .filter((lines) => lines.length > 0)
238 .flatMap((lines, n) => (n === 0 ? lines : ["", ...lines]));
223239 const readers = input.readers ?? new Set<string>();
224240 const said = (author: string): string =>
225241 readers.has(author) ? `@${author} ${READER}` : `@${author}`;
@@ -515,6 +531,26 @@ function parkedTwiceLines(input: TaskInput): string[] {
515531 ];
516532 }
517533
534 /**
535 * What a planner is told when no ready issue can start while builders sit
536 * free: the count per filter, and each stalled or capped issue, to re-plan
537 * now rather than wait for a word nobody is going to write.
538 */
539 function stuckLines(input: TaskInput): string[] {
540 const stuck = input.role === "planner" ? input.stuck : undefined;
541 if (stuck === undefined || stuck.issues.length === 0) return [];
542 const issues = `$FORGE_URL/api/v1/repos/${input.repo.ownerName}/${input.repo.name}/issues`;
543 return [
544 `Builders are free and no ready issue can start. What holds them: ${stuck.held}.`,
545 "Only re-planning moves these; do not wait for a person's comment on them:",
546 ...stuck.issues.map(
547 (issue) =>
548 `- #${issue.number} ${issue.title} (${issue.hold === "stalled" ? "stalled: a builder left it without a pull" : "at its run cap for the day"})`,
549 ),
550 `Re-plan each into smaller, fresh issues a builder can finish in one run: open them, then close the old one as split into them — PATCH ${issues}/N with {"state":"closed","reason":"split","into":[A,B]} — or, for one fresh issue, with "into":[M]. Say on the old issue what the fresh ones do differently.`,
551 ];
552 }
553
518554 /**
519555 * The turn after the budget runs out, in the same session: nothing new,
520556 * what exists made to land well, and the description written anyway.
src/crew/held.ts added +184
@@ -0,0 +1,184 @@
1 /**
2 * Why a ready issue cannot start (#2244). The map's ready list is what no
3 * open issue stands in front of; a builder may start only the ones no
4 * filter holds. Each held issue is counted under the first filter that
5 * holds it, so a tick that says `ready=33 startable=0` also says why:
6 * `held: open_pull=10 stalled=12 run_cap=11`.
7 *
8 * The filters, in the order they are asked:
9 * - `running`: a run is in flight on it.
10 * - `open_pull`: an open pull already proposes it (`direct.ts`).
11 * - `planner`: the planner or a reviewer holds it, not a builder.
12 * - `stalled`: a builder ran on it and left without a pull, and nobody
13 * who directs the crew has spoken since.
14 * - `run_cap`: it has used its runs for the day (`guard.ts`).
15 * - `parked_twice`: the crew parked it twice; it is the planner's (`parks.ts`).
16 * - `red_fix`: a fix for a red main already covers it (`red_fix.ts`).
17 * - `setting`: a setting's finding, a person's to answer (`setting_finding.ts`).
18 *
19 * The direction issue is the planner's standing issue and never a
20 * builder's; it is left out, not counted.
21 */
22
23 import type { App } from "../app.ts";
24 import { listCrewRoles, type Agent, type CrewRole } from "../core/agents.ts";
25 import { readyNow, repoGraph, type Ranked } from "../core/dependencies.ts";
26 import { BY_CREW, DONE_LIKE } from "../core/done_on_main.ts";
27 import type { Issue } from "../core/issues.ts";
28 import type { Repo } from "../core/repos.ts";
29 import { listActive, type Run } from "../core/runs.ts";
30 import { getUserById } from "../core/users.ts";
31 import type { Stuck } from "./brief.ts";
32 import { proposedWork, speaksForCrew } from "./direct.ts";
33 import { capReached, runCapOf } from "./guard.ts";
34 import { interruptionOf } from "./interrupted.ts";
35 import { PARK_STRIKES, parkedCount } from "./parks.ts";
36 import { coveredByRedFix } from "./red_fix.ts";
37 import { isSettingFinding } from "./setting_finding.ts";
38
39 export const HOLDS = [
40 "running",
41 "open_pull",
42 "planner",
43 "stalled",
44 "run_cap",
45 "parked_twice",
46 "red_fix",
47 "setting",
48 ] as const;
49
50 export type HoldName = (typeof HOLDS)[number];
51
52 /** The holds the planner can lift by re-planning: the rest wait on work in flight or a person. */
53 export const REPLANNABLE: readonly HoldName[] = ["stalled", "run_cap", "parked_twice"];
54
55 export interface Held {
56 /** The ready issues a builder may start now, in the map's order. */
57 readonly startable: Ranked[];
58 /** Each held issue under the first filter that holds it. */
59 readonly by: Readonly<Record<HoldName, readonly Issue[]>>;
60 }
61
62 /**
63 * An issue a builder already ran on and left without a pull — declined, or
64 * cut short — is not picked again by the clock: running it again blind is
65 * the loop autonomy refuses. It waits for a person's word, which is a
66 * feedback run like any other.
67 */
68 export function stalled(app: App, repo: Repo, issue: Issue): boolean {
69 const last = app.db
70 .query<{ state: string; summary: string; created_at: string }, [string]>(
71 `SELECT state, summary, created_at FROM run WHERE issue_id = ?1 AND kind = 'agent'
72 ORDER BY created_at DESC LIMIT 1`,
73 )
74 .get(issue.id);
75 if (last === null || last === undefined) return false;
76 if (last.state === "queued" || last.state === "running") return true;
77 if (/opened pull #|updated pull #/.test(last.summary)) return false;
78 // Cut short by the provider or a restart: nobody tried it, so the clock
79 // tries again (`interrupted.ts`).
80 if (last.state === "failed" && interruptionOf(last.summary) !== undefined)
81 return false;
82 // A word since then — from someone who may direct the crew, or the
83 // planner's when it re-planned a parked issue — is what un-stalls it. A
84 // reader's comment stays on the thread, and so does one imported from
85 // an origin, whoever connected it (#619).
86 return !app.db
87 .query<{ id: string }, [string, string]>(
88 `SELECT DISTINCT n.author_id AS id FROM comment n
89 WHERE n.issue_id = ?1 AND n.created_at > ?2 AND n.origin_author IS NULL
90 AND NOT (n.body LIKE '${DONE_LIKE}' AND ${BY_CREW})`,
91 )
92 .all(issue.id, last.created_at)
93 .some((r) => speaksForCrew(app.db, repo, getUserById(app.db, r.id)));
94 }
95
96 /** Whether `issue` is at its run cap (`guard.ts`): the clock neither picks nor plans it. */
97 export function atRunCap(app: App, repo: Repo, issue: Issue): boolean {
98 return capReached(app.db, repo, issue, runCapOf(repo, issue, app.config));
99 }
100
101 /** Sort the ready list into what a builder may start and what holds the rest. */
102 export function heldBy(
103 app: App,
104 repo: Repo,
105 ready: readonly Ranked[],
106 crew: readonly { agent: Agent; role: CrewRole }[],
107 active: readonly Run[],
108 ): Held {
109 const busyIssues = new Set(active.map((r) => r.issueId));
110 const proposed = proposedWork(app.db, repo);
111 // An issue a planner or a reviewer holds is theirs, not a builder's.
112 const planners = new Set(
113 crew.filter((c) => c.role !== "builder").map((c) => c.agent.user.id),
114 );
115 const holdOf = (issue: Issue): HoldName | null => {
116 if (busyIssues.has(issue.id)) return "running";
117 if (proposed.has(issue.number)) return "open_pull";
118 if (issue.assigneeId !== null && planners.has(issue.assigneeId)) return "planner";
119 if (stalled(app, repo, issue)) return "stalled";
120 if (atRunCap(app, repo, issue)) return "run_cap";
121 if (parkedCount(app.db, repo, issue.id) >= PARK_STRIKES) return "parked_twice";
122 if (coveredByRedFix(app.db, repo.id, issue)) return "red_fix";
123 if (isSettingFinding(app.db, issue.id)) return "setting";
124 return null;
125 };
126 const by = Object.fromEntries(HOLDS.map((h) => [h, [] as Issue[]])) as Record<
127 HoldName,
128 Issue[]
129 >;
130 const startable: Ranked[] = [];
131 for (const r of ready) {
132 if (r.issue.id === repo.directionIssueId) continue;
133 const hold = holdOf(r.issue);
134 if (hold === null) startable.push(r);
135 else by[hold].push(r.issue);
136 }
137 return { startable, by };
138 }
139
140 /** The count per filter, in `HOLDS` order. */
141 export function heldCounts(held: Held): Record<HoldName, number> {
142 return Object.fromEntries(HOLDS.map((h) => [h, held.by[h].length])) as Record<
143 HoldName,
144 number
145 >;
146 }
147
148 /** The counts as the tick line says them: `open_pull=10 stalled=3`, the empty ones left out. */
149 export function heldWords(held: Held): string {
150 const said = HOLDS.filter((h) => held.by[h].length > 0).map(
151 (h) => `${h}=${held.by[h].length}`,
152 );
153 return said.length === 0 ? "none" : said.join(" ");
154 }
155
156 /** The held issues the planner can lift by re-planning them. */
157 export function replannable(held: Held): Issue[] {
158 return REPLANNABLE.flatMap((h) => held.by[h]);
159 }
160
161 /** The ready list sorted now, from the map, the crew and the runs in flight. */
162 export function heldNow(app: App, repo: Repo): Held {
163 return heldBy(
164 app,
165 repo,
166 readyNow(repoGraph(app.db, repo.id, { sealed: true })),
167 listCrewRoles(app.db, repo.id),
168 listActive(app.db, "agent").filter((r) => r.repoId === repo.id),
169 );
170 }
171
172 /**
173 * What a planning pass is told when nothing ready can start: the counts,
174 * and the stalled and capped issues to re-plan. Parked-twice ones reach it
175 * as hand-overs already (`parks.ts`). Undefined when something can start
176 * or nothing re-planning lifts holds the rest.
177 */
178 export function stuckOf(held: Held): Stuck | undefined {
179 if (held.startable.length > 0) return undefined;
180 const issues = (["stalled", "run_cap"] as const).flatMap((hold) =>
181 held.by[hold].map((issue) => ({ number: issue.number, title: issue.title, hold })),
182 );
183 return issues.length === 0 ? undefined : { held: heldWords(held), issues };
184 }
src/crew/runner.ts +4 −1
@@ -108,6 +108,7 @@ import { clearStaleLocks, lockedOut } from "./locks.ts";
108108 import { interruptedSummary } from "./interrupted.ts";
109109 import { laterGate } from "./later.ts";
110110 import { parkedTwice } from "./parks.ts";
111 import { heldNow, stuckOf } from "./held.ts";
111112 import { noteLimit } from "../core/standing.ts";
112113 import { pauseReason } from "./pacing.ts";
113114 import {
@@ -913,11 +914,13 @@ async function work(
913914 .get(issue.id, run.id, run.createdAt)!.n > 0,
914915 }
915916 : {}),
916 // A planning run is given what the crew handed over (`parks.ts`).
917 // A planning run is given what the crew handed over (`parks.ts`),
918 // and what holds the ready list when nothing on it can start (`held.ts`).
917919 ...(brief.role === "planner" && issue.id === repo.directionIssueId
918920 ? {
919921 parkedTwice: [...parkedTwice(app.db, [repo]), ...handedOverPulls(app.db, repo)],
920922 laterGate: laterGate(app.db, repo.id),
923 stuck: stuckOf(heldNow(app, repo)),
921924 }
922925 : {}),
923926 checkKind:
src/flow/held.ts added +47
@@ -0,0 +1,47 @@
1 /**
2 * Why ready issues cannot start, per autonomous repository (#2244): the
3 * rows /flow draws as bars and the terminal's Flow dialog prints as lines —
4 * `crew/held.ts`'s counts, the same the autonomy tick logs as `held=`.
5 *
6 * Sorting a ready list asks a few queries per issue, so the rows are kept
7 * for a few seconds: a page reloaded, or the dialog opened beside it, reads
8 * them once. The tick never reads this cache; it sorts afresh every time.
9 */
10
11 import type { App } from "../app.ts";
12 import { listAutonomousRepos } from "../core/repos.ts";
13 import { heldCounts, heldNow, type HoldName } from "../crew/held.ts";
14
15 export interface HeldRow {
16 /** `owner/name`. */
17 readonly repo: string;
18 readonly public: boolean;
19 readonly startable: number;
20 readonly counts: Readonly<Record<HoldName, number>>;
21 }
22
23 /** How long the rows are kept. */
24 export const HELD_TTL_MS = 5_000;
25
26 const kept = new WeakMap<App, { at: number; rows: readonly HeldRow[] }>();
27
28 /** Each autonomous repository's ready list, sorted by what holds it; private ones for the owner only. */
29 export function readHeld(app: App, owner: boolean, now = Date.now()): HeldRow[] {
30 let entry = kept.get(app);
31 if (entry === undefined || now - entry.at >= HELD_TTL_MS || now < entry.at) {
32 entry = {
33 at: now,
34 rows: listAutonomousRepos(app.db).map((repo) => {
35 const held = heldNow(app, repo);

The first uncached read sorts every autonomous repository on the request path, about 19 ms measured. A page reload after the 5 s TTL pays it, and so does a stampede of readers. Fine for now. If the repository count grows, refresh the rows from the tick instead.

36 return {
37 repo: `${repo.ownerName}/${repo.name}`,
38 public: repo.visibility === "public",
39 startable: held.startable.length,
40 counts: heldCounts(held),
41 };
42 }),
43 };
44 kept.set(app, entry);
45 }
46 return entry.rows.filter((r) => owner || r.public);
47 }
src/tui/forge.ts +2
@@ -53,6 +53,7 @@ import { domainsScreen } from "./screens/domains.ts";
5353 import { ownerScreen } from "./screens/owner.ts";
5454 import { resilienceLines, resilienceScreen } from "./screens/resilience.ts";
5555 import { flowLines, flowScreen } from "./screens/flow.ts";
56 import { readHeld } from "../flow/held.ts";
5657 import { memoryScreen, systemLines, systemScreen } from "./screens/system.ts";
5758 import { grantable, READS_KEPT, recentReads } from "../core/telemetry.ts";
5859 import { noteRead, setTelemetryGrant, telemetryReader } from "../actions/telemetry.ts";
@@ -267,6 +268,7 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
267268 new Date(),
268269 systemFor(session.app, flowReader).duplicates,
269270 flowReader.kind === "agent" ? { queues: queuesFor(session.app, flowReader) } : undefined,
271 readHeld(session.app, flowReader.kind === "owner"),
270272 )));
271273 },
272274 });
src/tui/screens/flow.ts +28 −3
@@ -6,11 +6,15 @@
66 * MCP `flow` tool read. Under them, each repository's merge queue (#2036):
77 * queued, gating, landed in the last hour (`flow/queue.ts`); then the
88 * conflicted pulls, and the pulls with changes requested waiting on a
9 * builder and on a reviewer (`flow/requested.ts`, #2172). Read-only;
10 * Enter, Esc or q closes it.
9 * builder and on a reviewer (`flow/requested.ts`, #2172); then each
10 * autonomous repository's ready list, startable beside the count per
11 * filter that holds the rest (`flow/held.ts`, #2244), as /flow draws it.
12 * Read-only; Enter, Esc or q closes it.
1113 */
1214
1315 import type { Database } from "bun:sqlite";
16 import { HOLDS } from "../../crew/held.ts";
17 import type { HeldRow } from "../../flow/held.ts";
1418 import { conflictedPulls, conflictedSays } from "../../flow/conflicted.ts";
1519 import { readPipeline, STAGE_LABEL, verdict } from "../../flow/pipeline.ts";
1620 import { readQueues } from "../../flow/queue.ts";
@@ -31,7 +35,9 @@ export function flowLines(
3135 now = new Date(),
3236 dups = live().read().duplicates,
3337 /** What an agent reader may see: its queue lines, conflicted pulls and changes-requested waits come already cut to public. */
34 agent?: { queues: ReturnType<typeof readQueues> },
38 agent?: { queues: ReturnType<typeof readQueues> } | undefined,
39 /** Why ready issues wait, per autonomous repository (`readHeld`, already cut to what the reader may see). */
40 held?: readonly HeldRow[],
3541 ): string[] {
3642 const stages = readPipeline(db, now);
3743 const flow = readThroughput(db, stages.map((s) => s.key), now);
@@ -63,11 +69,30 @@ export function flowLines(
6369 )),
6470 ...(conflicted === null ? [] : ["", ...wrap(conflicted, inner)]),
6571 ...waits,
72 ...(held === undefined ? [] : ["", ...heldLines(held, inner)]),
6673 "",
6774 ...duplicateLines(dups, inner),
6875 ];
6976 }
7077
78 /** One line per autonomous repository: its name, then `startable=N` and each filter holding any. */
79 export function heldLines(rows: readonly HeldRow[], inner: number): string[] {
80 if (rows.length === 0) return ["Ready, and why it waits: no repository runs on its own."];
81 return [
82 "Ready, and why it waits:",
83 ...rows.flatMap((r) =>
84 wrap(
85 [
86 r.repo,
87 `startable=${r.startable}`,
88 ...HOLDS.filter((h) => r.counts[h] > 0).map((h) => `${h}=${r.counts[h]}`),
89 ].join(" "),
90 inner,
91 ),
92 ),
93 ];
94 }
95
7196 export function flowScreen(ctx: Ctx, lines: readonly string[]): Screen {
7297 return {
7398 title: "Flow",
src/web/pages/flow.ts +73
@@ -21,6 +21,11 @@
2121 * Beside the conflicted pulls, the pulls with changes requested (#2172):
2222 * those waiting on a builder apart from those waiting on a reviewer, by
2323 * age — `flow/requested.ts`'s, the rows the tick moves.
24 *
25 * Under the pipeline, why ready issues cannot start (#2244): per autonomous
26 * repository, its ready list as one bar, startable beside each filter that
27 * holds the rest — `flow/held.ts`'s rows, the same the tick line says and
28 * the terminal's Flow command prints.
2429 */
2530
2631 import { noteRead, telemetryReader } from "../../actions/telemetry.ts";
@@ -47,6 +52,8 @@ import {
4752 type StageThroughput,
4853 } from "../../flow/throughput.ts";
4954 import { readQueues, type QueueRow } from "../../flow/queue.ts";
55 import { HOLDS } from "../../crew/held.ts";
56 import { readHeld, type HeldRow } from "../../flow/held.ts";
5057 import type { Ctx } from "../../http/context.ts";
5158 import { live } from "../../system/live.ts";
5259 import { notFoundPage, signInFirst } from "../chrome.ts";
@@ -59,6 +66,18 @@ const css = /* css */ `
5966 .flow-out svg { width: 72px; height: 16px; }
6067 .flow-bar { fill: var(--muted-fg); }
6168 .flow-bar[data-empty] { fill: var(--muted); }
69 .held-bar { width: 100%; height: 12px; display: block; }
70 .held-key { display: inline-flex; align-items: center; gap: 4px; margin-right: 12px; font-variant-numeric: tabular-nums; }
71 .held-key svg { width: 10px; height: 10px; }
72 .held-startable { fill: var(--success); }
73 .held-running { fill: var(--accent); }
74 .held-open_pull { fill: var(--merged); }
75 .held-planner { fill: var(--primary); }
76 .held-stalled { fill: var(--attn); }
77 .held-run_cap { fill: var(--danger); }
78 .held-parked_twice { fill: var(--del-fg); }
79 .held-red_fix { fill: var(--muted-fg); }
80 .held-setting { fill: var(--border-strong); }
6281 `;
6382
6483 /** What `?out=` and `?hour=` ask to list, checked: an unknown stage or hour lists nothing. */
@@ -193,11 +212,65 @@ export function flowPage(ctx: Ctx): Response {
193212 </div>`,
194213 { pad: false },
195214 )}
215 ${heldCard(readHeld(ctx.app, reader.kind === "owner", now))}
196216 ${queueCard(queuesFor(ctx.app, reader, new Date(now)))}
197217 ${listed === null || shown === undefined ? null : outList(shown, listed.hour, now)}`;
198218 return page({ ctx, title: "Flow", body, styleCss: css });
199219 }
200220
221 /** One segment per kind, as wide as its share: a picture only; the counts beside it are the words. */
222 function heldBar(row: HeldRow): Html {
223 const parts: [string, number][] = [
224 ["startable", row.startable],
225 ...HOLDS.map((h): [string, number] => [h, row.counts[h]]),
outdated

heldNow is computed per request for every autonomous repo, with per-issue queries. Cache it briefly or measure it. The same rows must also reach the terminal Flow screen.

226 ];
227 const total = parts.reduce((n, [, c]) => n + c, 0);
228 let x = 0;
229 const rects = parts
230 .filter(([, c]) => c > 0)
231 .map(([kind, c]) => {
232 const w = (c / total) * 100;
233 const rect = html`<rect class="held-${kind}" x="${x.toFixed(2)}" y="0" width="${w.toFixed(2)}" height="12" />`;
234 x += w;
235 return rect;
236 });
237 return html`<svg class="held-bar" viewBox="0 0 100 12" preserveAspectRatio="none" aria-hidden="true" focusable="false">${rects}</svg>`;
238 }
239
240 function heldKey(kind: string, n: number): Html {
241 return html`<span class="held-key small" data-held="${kind}">
242 <svg viewBox="0 0 10 10" aria-hidden="true" focusable="false"><rect class="held-${kind}" width="10" height="10" /></svg>
243 <span class="mono">${kind}=${String(n)}</span>
244 </span>`;
245 }
246
247 /** Why ready issues cannot start, per autonomous repository (#2244). */
248 function heldCard(rows: readonly HeldRow[]): Html {
249 return card(
250 html`<h2>Ready, and why it waits</h2>
251 <p class="muted small">
252 Each ready issue a builder could start, or the first filter that holds
253 it: a run on it, an open pull, the planner, stalled, its run cap,
254 parked twice, a red-main fix, a setting's finding. When nothing can
255 start and builders are free, the planner is asked to re-plan what is
256 stalled or capped.
257 </p>
258 ${rows.length === 0
259 ? html`<p class="muted">No repository runs on its own.</p>`
260 : rows.map(
261 (r) => html`<div data-held-repo="${r.repo}">
262 <p><a href="/${r.repo}/map">${r.repo}</a></p>
263 ${heldBar(r)}
264 <p>
265 ${heldKey("startable", r.startable)}
266 ${HOLDS.filter((h) => r.counts[h] > 0).map((h) => heldKey(h, r.counts[h]))}
267 </p>
268 </div>`,
269 )}`,
270 { id: "held" },
271 );
272 }
273
201274 /** The merge queue, one row per repository with something in it or landed. */
202275 function queueCard(rows: readonly QueueRow[]): Html {
203276 return card(
test/crew-held.test.ts added +317
@@ -0,0 +1,317 @@
1 /**
2 * Why each ready issue cannot start (#2244): an issue held by each filter
3 * is counted under that filter's name, the tick's words say the counts,
4 * and a crew with free slots and nothing startable asks the planner once,
5 * with the breakdown — a full plate does not hold it. /flow and the
6 * terminal's Flow dialog show the same counts, private repositories to the
7 * owner only. Each test makes its own repository and crew.
8 */
9 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
10 import { mkdtemp, rm } from "node:fs/promises";
11 import { tmpdir } from "node:os";
12 import { join } from "node:path";
13 import { createApp, type App } from "../src/app.ts";
14 import { addToCrew, createAgent } from "../src/actions/crew.ts";
15 import { addComment, assignIssue, openIssue } from "../src/actions/issues.ts";
16 import { openPull } from "../src/actions/pulls.ts";
17 import { commitToBranch, createRepo, updateRepoSettings } from "../src/actions/repos.ts";
18 import { getAgentByName, setCrewRole } from "../src/core/agents.ts";
19 import { getIssueById, type Issue } from "../src/core/issues.ts";
20 import { getRepoById, type Repo } from "../src/core/repos.ts";
21 import { claimRun, createRun, finishRun } from "../src/core/runs.ts";
22 import { createUser, getUserByName, type User } from "../src/core/users.ts";
23 import { driveRepo } from "../src/crew/autonomy.ts";
24 import { taskPrompt } from "../src/crew/brief.ts";
25 import { ISSUE_RUN_CAP } from "../src/crew/guard.ts";
26 import { heldCounts, heldNow, heldWords, stuckOf } from "../src/crew/held.ts";
27 import { PARK_NOTE } from "../src/crew/parks.ts";
28 import { keyPart } from "../src/crew/sentinel.ts";
29 import { HELD_TTL_MS, readHeld } from "../src/flow/held.ts";
30 import { serve, type Servers } from "../src/http/server.ts";
31 import { flowLines, heldLines } from "../src/tui/screens/flow.ts";
32
33 const minutes = (n: number) => new Date(Date.now() - n * 60_000).toISOString();
34
35 async function crewed(
36 app: App,
37 alice: User,
38 name: string,
39 visibility: "public" | "private" = "private",
40 ): Promise<{ repo: Repo; atlas: User; mason: User }> {
41 const made = await createRepo(app, alice, { name, visibility });
42 if (!made.ok) throw new Error(String(made.error));
43 for (const agent of ["atlas", "mason"]) {
44 if (getUserByName(app.db, agent) === undefined) {
45 const created = await createAgent(app, alice, { username: agent });
46 if (!created.ok) throw new Error(String(created.error));
47 }
48 expect(addToCrew(app, alice, made.value, agent).ok).toBe(true);
49 }
50 setCrewRole(app.db, made.value.id, getUserByName(app.db, "atlas")!.id, "planner");
51 const set = await updateRepoSettings(app, alice, made.value, {
52 autonomy: "autonomous",
53 autonomyDirection: "Keep the engine small.",
54 autonomyCeilingUsd: 100,
55 autonomySlots: 1,
56 autonomyBy: alice.id,
57 });
58 if (!set.ok) throw new Error(JSON.stringify(set.error));
59 return {
60 repo: getRepoById(app.db, made.value.id)!,
61 atlas: getUserByName(app.db, "atlas")!,
62 mason: getUserByName(app.db, "mason")!,
63 };
64 }
65
66 describe("the tick says why each ready issue cannot start", () => {
67 let dataDir: string;
68 let app: App;
69 let alice: User;
70 let servers: Servers;
71
72 /** A finished agent run on `issue`, `ago` minutes back. */
73 function ran(repo: Repo, issue: Issue, by: User, summary: string, ago = 0, state: "ok" | "failed" = "failed"): void {
74 const run = createRun(app.db, {
75 repoId: repo.id,
76 kind: "agent",
77 issueId: issue.id,
78 actorId: by.id,
79 reason: "assigned",
80 });
81 claimRun(app.db, run.id);
82 finishRun(app.db, run.id, { state, summary, costUsd: 0.1 });
83 const at = minutes(ago);
84 app.db
85 .query("UPDATE run SET created_at = ?2, started_at = ?2, finished_at = ?2 WHERE id = ?1")
86 .run(run.id, at);
87 }
88
89 function issue(repo: Repo, title: string, body = ""): Issue {
90 const opened = openIssue(app, alice, getRepoById(app.db, repo.id)!, { title, body });
91 if (!opened.ok) throw new Error(String(opened.error));
92 return opened.value;
93 }
94
95 beforeAll(async () => {
96 dataDir = await mkdtemp(join(tmpdir(), "forge-held-"));
97 app = createApp({ dataDir, port: 0 });
98 const a = await createUser(app.db, {
99 trusted: true,
100 username: "alice",
101 email: "[email protected]",
102 password: "a-good-passphrase",
103 adminIfFirst: true,
104 });
105 if (!a.ok) throw new Error(a.error);
106 alice = a.value;
107 servers = await serve(app);
108 });
109
110 afterAll(async () => {
111 servers.stop();
112 app.close();
113 await rm(dataDir, { recursive: true, force: true });
114 });
115
116 test("an issue held by each filter is counted under its name", async () => {
117 const { repo, atlas, mason } = await crewed(app, alice, "engine");
118 const free = issue(repo, "Free to start");
119
120 const running = issue(repo, "Running");
121 createRun(app.db, { repoId: repo.id, kind: "agent", issueId: running.id, actorId: mason.id, reason: "assigned" });
122
123 const proposed = issue(repo, "Proposed");
124 const seeded = await commitToBranch(app, alice, repo, {
125 message: "Start",
126 files: [{ path: "README.md", content: "# engine\n" }],
127 });
128 if (!seeded.ok) throw new Error(JSON.stringify(seeded.error));
129 const branch = await commitToBranch(app, alice, repo, {
130 branch: "feature/proposed",
131 base: "main",
132 message: "Proposed",
133 files: [{ path: "proposed.ts", content: "export const n = 1;\n" }],
134 });
135 if (!branch.ok) throw new Error(JSON.stringify(branch.error));
136 const pull = await openPull(app, alice, repo, {
137 title: "Proposed",
138 body: `Closes #${proposed.number}`,
139 baseBranch: "main",
140 headBranch: "feature/proposed",
141 });
142 if (!pull.ok) throw new Error(String(pull.error));
143
144 const planned = issue(repo, "The planner's");
145 expect(assignIssue(app, alice, repo, planned, atlas).ok).toBe(true);
146
147 const stuck = issue(repo, "Stalled");
148 ran(repo, stuck, mason, "declined: the ask is unclear");
149
150 const capped = issue(repo, "Capped");
151 for (let n = 0; n < ISSUE_RUN_CAP; n++) ran(repo, capped, mason, `opened pull #${90 + n}`, 60 - n, "ok");
152
outdated

mason and atlas are only assigned in the previous test, so this test depends on test order. Assign them here.

153 const parked = issue(repo, "Parked twice");
154 addComment(app, mason, repo, parked, `${PARK_NOTE}first`);
155 addComment(app, mason, repo, getIssueById(app.db, parked.id)!, `${PARK_NOTE}second`);
156
157 const quoting = issue(repo, "Quotes the red test", "fails: the widget renders");
158 const fix = issue(repo, "Fix main");
159 const check = createRun(app.db, {
160 repoId: repo.id,
161 kind: "check",
162 env: repo.defaultBranch,
163 commitOid: "a".repeat(40),
164 reason: "push",
165 });
166 claimRun(app.db, check.id);
167 finishRun(app.db, check.id, {
168 state: "failed",
169 summary: "check failed: 1 test failed",
170 checkDetail: { passed: 1, failed: 1, names: ["the widget renders"] },
171 });
172 app.db
173 .query("INSERT INTO sentinel_finding (key, issue_id, evidence, first_seen, last_seen) VALUES (?1, ?2, '', 'x', 'x')")
174 .run(`degraded:${keyPart(`red:${repo.id}`)}`, fix.id);
175
176 const finding = issue(repo, "A setting's finding");
177 app.db
178 .query("INSERT INTO sentinel_finding (key, issue_id, evidence, first_seen, last_seen) VALUES ('setting:x', ?1, '', 'x', 'x')")
179 .run(finding.id);
180
181 const held = heldNow(app, getRepoById(app.db, repo.id)!);
182 const named = (h: keyof typeof held.by) => held.by[h].map((i) => i.number);
183 expect(held.startable.map((r) => r.issue.number).sort()).toEqual([free.number, fix.number].sort());
184 expect(named("running")).toEqual([running.number]);
185 expect(named("open_pull")).toEqual([proposed.number]);
186 expect(named("planner")).toEqual([planned.number]);
187 expect(named("stalled")).toEqual([stuck.number]);
188 expect(named("run_cap")).toEqual([capped.number]);
189 expect(named("parked_twice")).toEqual([parked.number]);
190 expect(named("red_fix")).toEqual([quoting.number]);
191 expect(named("setting")).toEqual([finding.number]);
192 expect(heldCounts(held).open_pull).toBe(1);
193 expect(heldWords(held)).toBe(
194 "running=1 open_pull=1 planner=1 stalled=1 run_cap=1 parked_twice=1 red_fix=1 setting=1",
195 );
196 // Something can start: the planner is not told the crew is stuck.
197 expect(stuckOf(held)).toBeUndefined();
198 });
199
200 test("nothing startable with a slot free asks the planner once, with the breakdown, whatever the plate", async () => {
201 const { repo, atlas, mason } = await crewed(app, alice, "stuck");
202 const fresh = () => getRepoById(app.db, repo.id)!;
203 // The map ran dry: the planner's first pass, an hour ago.
204 expect(await driveRepo(app, fresh())).toBe("planned");
205 const direction = getIssueById(app.db, fresh().directionIssueId!)!;
206 ran(fresh(), direction, atlas, "opened #2", 60, "ok");
207 expect(assignIssue(app, alice, fresh(), getIssueById(app.db, direction.id)!, null).ok).toBe(true);
208 // Then five issues, each stalled by a builder since: more than the
209 // plate holds (slots × 3), and nothing a builder may start.
210 const stalled = ["One", "Two", "Three", "Four"].map((t) => issue(fresh(), t));
211 for (const s of stalled) ran(fresh(), s, mason, "declined: too big", 40);
212 const capped = issue(fresh(), "Five");
213 for (let n = 0; n < ISSUE_RUN_CAP; n++) ran(fresh(), capped, mason, `opened pull #${90 + n}`, 45 - n, "ok");
214 const held = heldNow(app, fresh());
215 expect(held.startable).toHaveLength(0);
216 expect(heldWords(held)).toBe("stalled=4 run_cap=1");
217
218 expect(await driveRepo(app, fresh())).toBe("planned");
219 expect(getIssueById(app.db, direction.id)!.assigneeId).toBe(atlas.id);
220
221 // The pass the crew would start, given the breakdown in its brief.
222 const told = stuckOf(heldNow(app, fresh()));
223 expect(told?.held).toBe("stalled=4 run_cap=1");
224 const brief = taskPrompt({
225 agent: getAgentByName(app.db, "atlas")!,
226 repo: fresh(),
227 issue: getIssueById(app.db, direction.id)!,
228 branch: "atlas/1-direction",
229 forgeUrl: "http://forge.test",
230 isApp: false,
231 resuming: false,
232 role: "planner",
233 reason: "assigned",
234 comments: [],
235 reviews: [],
236 reviewComments: [],
237 since: direction.createdAt,
238 stuck: told,
239 });
240 expect(brief).toContain("What holds them: stalled=4 run_cap=1.");
241 expect(brief).toContain(`- #${stalled[0]!.number} One (stalled: a builder left it without a pull)`);
242 expect(brief).toContain(`- #${capped.number} Five (at its run cap for the day)`);
243 expect(brief).toContain("do not wait for a person's comment");
244 // A builder is never told.
245 expect(taskPrompt({ ...{
246 agent: getAgentByName(app.db, "mason")!,
247 repo: fresh(),
248 issue: stalled[0]!,
249 branch: "mason/1-one",
250 forgeUrl: "http://forge.test",
251 isApp: false,
252 resuming: false,
253 role: "builder",
254 reason: "assigned",
255 comments: [],
256 reviews: [],
257 reviewComments: [],
258 since: direction.createdAt,
259 }, stuck: told })).not.toContain("What holds them");
260
261 // That pass ran and left them as they were: it is not asked again for
262 // the same breakdown, and the full plate holds it as before.
263 ran(fresh(), direction, atlas, "left them", 30, "ok");
264 expect(assignIssue(app, alice, fresh(), getIssueById(app.db, direction.id)!, null).ok).toBe(true);
265 expect(await driveRepo(app, fresh())).toBe("idle");
266 expect(getIssueById(app.db, direction.id)!.assigneeId).toBeNull();
267 });
268
269 test("both doors: /flow and the terminal's Flow dialog show the counts, private ones to the owner only", async () => {
270 const open = await crewed(app, alice, "open-door", "public");
271 const shut = await crewed(app, alice, "shut-door");
272 for (const { repo, mason } of [open, shut]) {
273 ran(repo, issue(repo, "Stalled"), mason, "declined: too big", 10);
274 const capped = issue(repo, "Capped");
275 for (let n = 0; n < ISSUE_RUN_CAP; n++) ran(repo, capped, mason, `opened pull #${90 + n}`, 20 - n, "ok");
276 }
277 const signin = await fetch(`${servers.url}/signin`, {
278 method: "POST",
279 redirect: "manual",
280 headers: { "content-type": "application/x-www-form-urlencoded", origin: servers.url },
281 body: new URLSearchParams({ username: "alice", password: "a-good-passphrase" }),
282 });
283 const cookie = (signin.headers.get("set-cookie") ?? "").split(";")[0] ?? "";
284 const res = await fetch(`${servers.url}/flow`, { headers: { cookie } });
285 expect(res.status).toBe(200);
286 const page = await res.text();
287 expect(page).toContain('id="held"');
288 const row = page.slice(page.indexOf('data-held-repo="alice/open-door"'));
289 expect(row).toContain('class="held-bar"');
290 expect(row).toContain('<span class="mono">startable=0</span>');
291 expect(row).toContain('<span class="mono">stalled=1</span>');
292 expect(row).toContain('<span class="mono">run_cap=1</span>');
293 expect(page).toContain('data-held-repo="alice/shut-door"');
294
295 // The terminal reads the same rows: the owner sees both, an agent
296 // reader the public one alone.
297 const owner = flowLines(app.db, new Date(), [], undefined, readHeld(app, true)).join("\n");
298 expect(owner).toContain("Ready, and why it waits:");
299 expect(owner).toContain("alice/open-door startable=0 stalled=1 run_cap=1");
300 expect(owner).toContain("alice/shut-door startable=0 stalled=1 run_cap=1");
301 const agent = flowLines(app.db, new Date(), [], { queues: [] }, readHeld(app, false)).join("\n");
302 expect(agent).toContain("alice/open-door startable=0 stalled=1 run_cap=1");
303 expect(agent).not.toContain("shut-door");
304 expect(heldLines([], 58)).toEqual(["Ready, and why it waits: no repository runs on its own."]);
305 });
306
307 test("the rows are kept a few seconds, then read afresh", async () => {
308 const { repo, mason } = await crewed(app, alice, "kept");
309 const t0 = Date.now() + 60 * 60_000;
310 const stalled = (now: number) =>
311 readHeld(app, true, now).find((r) => r.repo === "alice/kept")!.counts.stalled;
312 expect(stalled(t0)).toBe(0);
313 ran(repo, issue(repo, "Stalled"), mason, "declined: too big", 5);
314 expect(stalled(t0 + 1_000)).toBe(0);
315 expect(stalled(t0 + HELD_TTL_MS)).toBe(1);
316 });
317 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs