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

While "flow only" is on, only `flow` issues start builds, and assigning any other issue queues it #6970

Merged opened this

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

Merged

scout/forge:scout/6393-while-flow-only-is-on-only-flow-issues-s → main 10 commits fork 8 threads · 8 unresolved

  • Merged. scout/6393-while-flow-only-is-on-only-flow-issues-s was merged into main as bb6af37.

Review

Sign in to review this change.
13 files changed +267 −7
README.md +1 −1
@@ -375,7 +375,7 @@ This page is the front door. Everything else is a file of its own under
375375 - [`docs/WEBSPEC-PREVIEWS.md`](docs/WEBSPEC-PREVIEWS.md) — the website specification run over every preview, report-only.
376376 - [`docs/CREW.md`](docs/CREW.md) — the crew: agents, how a run works, review, live triage, the merge queue, spend, roles and invites, the sandbox, passports, and the direction a vision pull will write.
377377 - [`docs/SENTINEL.md`](docs/SENTINEL.md) — the forge hunting its own faults; [`docs/HUNT.md`](docs/HUNT.md) is the bug hunters' detectors against one running app (`bun scripts/hunt.ts <origin>`).
378 - Flow only: a repository's admin (a person, never an agent) sets one switch from Settings → On its own, `PATCH /api/v1/repos/:owner/:name` with `flowOnly`, MCP `repo_settings`, or `w` on the terminal's About card (`I`); turning it on makes the `flow` label. While it is on, the repository header and its admin's home say so in one line (the line links to the setting for an admin only; on the home it appears under "Show everything", not the lean apps-first view). Nothing obeys it yet.
378 - Flow only: a repository's admin (a person, never an agent) sets one switch from Settings → On its own, `PATCH /api/v1/repos/:owner/:name` with `flowOnly`, MCP `repo_settings`, or `w` on the terminal's About card (`I`); turning it on makes the `flow` label. While it is on, the repository header and its admin's home say so in one line (the line links to the setting for an admin only; on the home it appears under "Show everything", not the lean apps-first view). While it is on, builders start only `flow` issues; assigning any other issue is recorded but starts no run and reads "queued: flow only" (web, terminal, API and MCP `issue`) until it gets the `flow` label or the switch goes off, and then it dispatches. Rebase, re-check, red-fix and review runs always go.
379379 - Outsiders directing the crew: the terminal's About card (`I`) has a Crew row saying whether people who cannot write may direct the crew (the web page's words). A repository admin who is a person presses `d` to see the plan, then enter to confirm (esc changes nothing); it calls `setCrewOutsiders`, as MCP `outsiders` does. The key is `d`, not `o`, because `o` is the fork's upstream offer on the same card and `d` never clashes with it; a writer who is not an admin and an agent are not offered `d`.
380380 - [`docs/FLOW.md`](docs/FLOW.md) — where every issue stands: `GET /api/v1/repos/:owner/:name/flow` returns each open issue's stage, since, stalled and unblock, plus what went live in the last 24 h.
381381 - Flow reads: `bun scripts/flow-report.ts [--db <path>] [--public] [--json]` prints one line per read; see [`docs/FLOW.md`](docs/FLOW.md).
src/actions/issues.ts +5
@@ -676,6 +676,11 @@ export function setIssueLabels(
676676 actorId: writer.value.id,
677677 payload: { number: issue.number, labels: resolved.map((l) => l.name) },
678678 });
679 // FLOW ONLY held this assignment; the `flow` label lets it go now.
680 // Loaded at the call: the crew's guard reaches this module through the brief,
681 // so a static import of the dispatcher would close a cycle at boot.
outdated

Medium: this runs the global boot sweep (100 rows across every repository, with an event subquery per row) on every label edit, and it can dispatch waiting assignments in other repositories. Queue just this issue instead: queueAgentRun(app, repo, issue, agent, "assigned", lastAssigner), and only when it now carries flow and has no agent run yet.

682 if (issue.assigneeIsAgent && repo.flowOnly)
683 (require("../crew/flow_label.ts") as typeof import("../crew/flow_label.ts")).dispatchFlowLabelled(app, repo, issue);

Non-blocking: an inline require inside an action is the only one in src/ outside the migration loader. It hides the dependency from tsc's import graph and from anyone reading the imports. A cleaner way to break the cycle is to let the caller dispatch: the label route, the MCP labels tool and the terminal call dispatchFlowLabelled after setIssueLabels returns ok. Or setIssueLabels could take an onLabelled hook. Fine to land as is; worth a follow-up.

679684 return ok(labelsFor(app.db, issue.id));
680685 }
681686
src/actions/repos.ts +4 −3
@@ -22,6 +22,8 @@ import {
2222 updateLabel as writeLabel,
2323 type Label,
2424 } from "../core/issues.ts";
25 import { sweepAssigned } from "../crew/dispatch.ts";
26 import { FLOW_LABEL } from "../crew/flow_only.ts";
2527 import { dropMirror } from "../crew/mirror.ts";
2628 import { accessAllows, getOrg, orgAccess } from "../core/orgs.ts";
2729 import {
@@ -334,9 +336,6 @@ function moveArchive(app: App, repoId: string, archived: boolean): Promise<boole
334336 });
335337 }
336338
337 /** The label FLOW ONLY puts in a repository. */
338 const FLOW_LABEL = "flow";
339
340339 /**
341340 * Change settings. A new default branch must exist; git HEAD is moved first
outdated

The const was moved to flow_only.ts but its doc comment stayed. It now sits above nothing. Delete it.

342341 * and the row follows (git is truth, the index is derived from it).
@@ -378,6 +377,8 @@ export async function updateRepoSettings(
378377 // FLOW ONLY names its label (the label it makes is not in `changed`, below): on, the repository has a `flow` to put on work.
379378 if (settings.flowOnly === true && getLabel(app.db, repo.id, FLOW_LABEL) === undefined)
380379 insertLabel(app.db, repo.id, FLOW_LABEL, "d29922");
380 // Switched off: the assignments it held dispatch now.
381 if (settings.flowOnly === false && repo.flowOnly) sweepAssigned(app);

sweepAssigned covers every repository, not just this one. That is acceptable because it is what boot does, but a sweep scoped to this repository would be tighter. Non-blocking.

381382 const moved =
382383 archived === undefined ? false : await moveArchive(app, repo.id, archived);
383384 const updated = getRepoById(app.db, repo.id);
src/crew/dispatch.ts +9
@@ -8,6 +8,7 @@
88 * time. A missed wake-up costs a tick, never a run.
99 */
1010
11 import { flowOnlyHolds } from "./flow_only.ts";
1112 import { NO_MERGE } from "../checks/merged.ts";
1213 import { markHeard } from "../core/issues.ts";
1314 import { capacityCount, noteBooted } from "../core/capacity.ts";
@@ -381,6 +382,14 @@ export function queueAgentRun(
381382 });
382383 return undefined;
383384 }
385 // FLOW ONLY: a builder's assignment on an issue not labelled `flow` waits
386 // (recorded, not dispatched) until the label comes or the switch goes off.
387 if (
388 reason === "assigned" &&
389 crewRole(app.db, repo.id, agent.user.id) === "builder" &&
390 flowOnlyHolds(app.db, repo, issue)
391 )
392 return undefined;
384393 // Three failed or declined runs in an hour: parked, until a person speaks.
385394 if (breakerTripped(app.db, repo, issue.id)) {
386395 parkForBreaker(app, repo, issue, agent);
src/crew/flow_label.ts added +29
@@ -0,0 +1,29 @@
1 /** The `flow` label's dispatch, apart from flow_only.ts so dispatch.ts can import that without a cycle. */
2
3 import type { App } from "../app.ts";
4 import { getAgent } from "../core/agents.ts";
5 import { getIssueById, type Issue } from "../core/issues.ts";
6 import type { Repo } from "../core/repos.ts";
7 import { queueAgentRun } from "./dispatch.ts";
8 import { flowOnlyHolds, hadAgentRun } from "./flow_only.ts";
9
10 /**
11 * The `flow` label just reached an issue the switch was holding: start its
12 * run, this issue only. The run is paid by whoever last assigned it, not by
13 * the one who labelled, so the guest rule holds as it does in the sweep.
14 */
15 export function dispatchFlowLabelled(app: App, repo: Repo, issue: Issue): void {
16 const now = getIssueById(app.db, issue.id);
17 if (now === undefined || now.state !== "open" || now.assigneeId === null) return;
18 if (flowOnlyHolds(app.db, repo, now)) return;
19 if (hadAgentRun(app.db, now.id)) return;
20 const agent = getAgent(app.db, now.assigneeId);
21 if (agent === undefined) return;
22 const assigner = app.db
23 .query<{ actor_id: string | null }, [string, string, number]>(
24 `SELECT actor_id FROM event WHERE repo_id = ?1 AND kind = ?2
25 AND json_extract(payload, '$.number') = ?3 ORDER BY id DESC LIMIT 1`,
26 )
27 .get(repo.id, `${now.kind}.assigned`, now.number);
28 queueAgentRun(app, repo, now, agent, "assigned", assigner?.actor_id ?? undefined);
29 }
src/crew/flow_only.ts added +37
@@ -0,0 +1,37 @@
1 /**
2 * FLOW ONLY at the dispatcher: while a repository's switch is on, a builder
3 * starts only issues labelled `flow`. A planner, a reviewer and the
4 * direction issue are not builds, and a pull is never one: rebase, re-check,
5 * red-fix and review runs go as ever. An assignment on a held issue is
6 * recorded and waits; switching off sweeps it out (`sweepAssigned`).
7 */
8
9 import type { Database } from "bun:sqlite";
10 import { labelsFor, type Issue } from "../core/issues.ts";
11 import type { Repo } from "../core/repos.ts";
12
13 export const FLOW_LABEL = "flow";
14
15 /** Whether the switch keeps a builder off `issue` now. */
16 export function flowOnlyHolds(db: Database, repo: Repo, issue: Issue): boolean {
17 if (!repo.flowOnly || issue.kind !== "issue") return false;
18 if (issue.id === repo.directionIssueId) return false;
19 return !labelsFor(db, issue.id).some((l) => l.name.toLowerCase() === FLOW_LABEL);
20 }
21
22 /** The line an assigned issue wears while the switch holds its run; null when nothing is held. */
23 export function flowOnlyQueued(db: Database, repo: Repo, issue: Issue): string | null {
24 if (issue.state !== "open" || !issue.assigneeIsAgent) return null;
25 if (!flowOnlyHolds(db, repo, issue)) return null;
26 return hadAgentRun(db, issue.id) ? null : "queued: flow only";
27 }
28
29 /** Whether any agent run was ever started on the issue. */
outdated

This count and the one in dispatchFlowLabelled are the same query. Make them one helper. Non-blocking.

30 export function hadAgentRun(db: Database, issueId: string): boolean {
outdated

Non-blocking: the same "has any agent run" COUNT appears here and in dispatchFlowLabelled. The guard also means a reassigned issue with an earlier run never reads "queued" and is not dispatched when labelled flow. It waits for the switch-off sweep, and that sweep skips it too. Worth one shared helper and a follow-up if reassignment under flow-only matters.

31 const ran = db
32 .query<{ n: number }, [string]>(
33 "SELECT COUNT(*) AS n FROM run WHERE issue_id = ?1 AND kind = 'agent'",
34 )
35 .get(issueId);
36 return ran !== null && ran.n > 0;
37 }
src/crew/held.ts +6
@@ -14,6 +14,8 @@
1414 * planner has not answered (`core/limb_replan.ts`).
1515 * - `done_on_main`: the crew found it done on the base branch and waits for
1616 * a person to close it (`done_on_main.ts`); nothing is left to plan.
17 * - `flow_only`: the repository's FLOW ONLY switch is on and the issue is not
18 * labelled `flow` (`flow_only.ts`).
1719 * - `stalled`: a builder ran on it and left without a pull, and nobody
1820 * who directs the crew has spoken since. The next tick parks it in the
1921 * crew's name, which frees it (`stalls.ts`).
@@ -41,6 +43,7 @@ import { getUserById } from "../core/users.ts";
4143 import type { Stuck } from "./brief.ts";
4244 import { proposedWork, speaksForCrew } from "./direct.ts";
4345 import { capReached, runCapOf } from "./guard.ts";
46 import { flowOnlyHolds } from "./flow_only.ts";
4447 import { interruptionOf } from "./interrupted.ts";
4548 import { PARK_STRIKES, parkedCount } from "./parks.ts";
4649 import { heldFiles, type FileHold } from "./file_holds.ts";
@@ -53,6 +56,7 @@ export const HOLDS = [
5356 "planner",
5457 "with_planner",
5558 "done_on_main",
59 "flow_only",
5660 "stalled",
5761 "run_cap",
5862 "parked_twice",
@@ -143,6 +147,7 @@ export function heldBy(
143147 if (issue.assigneeId !== null && planners.has(issue.assigneeId)) return "planner";
144148 if (handed.has(issue.id)) return "with_planner";
145149 if (alreadyDone(app.db, [issue.id]).has(issue.id)) return "done_on_main";
150 if (flowOnlyHolds(app.db, repo, issue)) return "flow_only";
146151 if (stalled(app, repo, issue)) return "stalled";
147152 if (atRunCap(app, repo, issue)) return "run_cap";
148153 if (parkedCount(app.db, repo, issue.id) >= PARK_STRIKES) return "parked_twice";
@@ -227,6 +232,7 @@ const HOLD_WORDS: Readonly<Record<HoldName, string>> = {
227232 planner: "the planner or a reviewer holds it",
228233 with_planner: "its limb waits on the planner",
229234 done_on_main: "found done on the base branch; waits for a person to close it",
235 flow_only: "flow only is on and it is not labelled flow",
230236 stalled: "a builder ran on it and left without a pull",
231237 run_cap: "it has used its runs for the day",
232238 parked_twice: "parked twice; it is the planner's",
src/http/api.ts +5
@@ -8,6 +8,7 @@
88 * back to continue); errors are `{ error, message }`.
99 */
1010
11 import { flowOnlyQueued } from "../crew/flow_only.ts";
1112 import { homeTruth } from "../system/home_truth.ts";
1213 import { upstreamFacts } from "../actions/upstream_follow.ts";
1314 import { FAMILY_WORDS } from "../actions/family_words.ts";
@@ -492,9 +493,13 @@ function respondFleet(error: FleetError): Response {
492493 }
493494
494495 export function withLabels<T extends Issue>(ctx: Ctx, issue: T) {
496 const repo = getRepoById(ctx.app.db, issue.repoId);
497 const queued = repo === undefined ? null : flowOnlyQueued(ctx.app.db, repo, issue);
495498 return {
496499 ...readerMarked(ctx, issue.repoId, [issue])[0]!,
497500 labels: labelsFor(ctx.app.db, issue.id),
501 // FLOW ONLY holds an assigned run: say so, so an assigner knows nothing started.
502 ...(queued === null ? {} : { queued }),
498503 };
499504 }
500505
src/http/mcp/tools/issue.ts +3
@@ -3,6 +3,7 @@ import { countThread, EMBEDDED_COMMENTS, getIssue, labelsFor, listComments } fro
33 import { isCrew } from "../../../core/agents.ts";
44 import { dependenciesOf } from "../../../core/dependencies.ts";
55 import { getUsersByIds } from "../../../core/users.ts";
6 import { flowOnlyQueued } from "../../../crew/flow_only.ts";
67 import { markReaders } from "../../../crew/direct.ts";
78 import { good, bad, type Tool, REPO_ARG, repoArg, isAnswer } from "../shared.ts";
89
@@ -53,6 +54,7 @@ export const tool: Tool = {
5354 const seen = (i: typeof issue) => mayView(db, repo, i, user);
5455 const flag = (x: { untrusted?: string }) =>
5556 x.untrusted === undefined ? {} : { untrusted: true };
57 const queued = flowOnlyQueued(db, repo, issue);
5658 const [self] = mark([issue]);
5759 const answer = good({
5860 number: issue.number,
@@ -65,6 +67,7 @@ export const tool: Tool = {
6567 ...flag(self!),
6668 labels: labelsFor(db, issue.id).map((l) => l.name),
6769 assignee: issue.assigneeName,
70 ...(queued === null ? {} : { queued }),
6871 comments: comments.map((c) => ({
6972 author: c.authorName,
7073 authorIsAgent: authors.get(c.authorId)?.kind === "agent",
src/tui/screens/assign.ts +12
@@ -15,6 +15,8 @@
1515 import { listCrew } from "../../actions/crew.ts";
1616 import type { Agent } from "../../core/agents.ts";
1717 import { assignIssue } from "../../actions/issues.ts";
18 import { flowOnlyQueued } from "../../crew/flow_only.ts";
19 import type { Database } from "bun:sqlite";
1820 import type { Issue } from "../../core/issues.ts";
1921 import { canRead, type Repo } from "../../core/repos.ts";
2022 import { getUserById, listPeople, type User } from "../../core/users.ts";
@@ -56,6 +58,16 @@ export function assignedText(issue: Issue | undefined, set: IconSet): string {
5658 : ` · assigned to ${issue.assigneeName} (agent)`;
5759 }
5860
61 /** ` · queued: flow only` while FLOW ONLY holds an assigned agent's run; empty otherwise. */
62 export function queuedText(
63 db: Database,
64 repo: Repo | undefined,
65 issue: Issue | undefined,
66 ): string {
67 const held = repo === undefined || issue === undefined ? null : flowOnlyQueued(db, repo, issue);
68 return held === null ? "" : ` · ${held}`;
69 }
70
5971 /** How many people the picker reads before it stops; `listPeople`'s ceiling. */
6072 const PEOPLE_LIMIT = 200;
6173
src/tui/screens/issue.ts +2 −2
@@ -59,7 +59,7 @@ import {
5959 promptScreen,
6060 runAsync,
6161 } from "./common.ts";
62 import { assignPicker, assignedText } from "./assign.ts";
62 import { assignPicker, assignedText, queuedText } from "./assign.ts";
6363 import { liveBindings, liveKey } from "./mirror.ts";
6464 import { RunList, openRunLog } from "./runlist.ts";
6565 import { Composer, threadLines, type ThreadEntry } from "./thread.ts";
@@ -428,7 +428,7 @@ export function issueScreen(
428428 x,
429429 head!.y + 1,
430430 truncate(
431 ` ${issue.state} · opened by ${who(issue.authorName, issue.originAuthor)} ${ago(issue.createdAt)} · ${plural(detail.comments.length, "comment")}${assignedText(issueRow, ctx.icons)}${doneText(doneBy, mayWrite())}${pictureText()}`,
431 ` ${issue.state} · opened by ${who(issue.authorName, issue.originAuthor)} ${ago(issue.createdAt)} · ${plural(detail.comments.length, "comment")}${assignedText(issueRow, ctx.icons)}${queuedText(f.db, repo, issueRow)}${doneText(doneBy, mayWrite())}${pictureText()}`,
432432 Math.max(0, right - x),
433433 ),
434434 style(c.mutedFg, c.bg),
src/web/pages/thread.ts +3 −1
@@ -73,6 +73,7 @@ import {
7373 } from "../ui.ts";
7474 import { loadRepo, notFoundPage, repoBase, signInFirst } from "../chrome.ts";
7575 import { studioRecord, tellHref } from "../studio/nouns.ts";
76 import { flowOnlyQueued } from "../../crew/flow_only.ts";
7677 import { interruptionsOn } from "../../crew/interrupted.ts";
7778
7879 /**
@@ -723,6 +724,7 @@ export function assigneeCard(
723724 action: string,
724725 ): Html {
725726 const mayEdit = canWrite(ctx.app.db, repo, ctx.auth.user);
727 const held = flowOnlyQueued(ctx.app.db, repo, issue);

This state exists only in the page. Expose it to the tty and the API/MCP issue view too, and add a test.

726728 const current =
727729 issue.assigneeName === null
728730 ? html`<p class="muted small">Nobody yet.</p>`
@@ -733,7 +735,7 @@ export function assigneeCard(
733735 issue.assigneeName,
734736 ),
735737 })}
736 </p>`;
738 </p>${held === null ? null : html`<p class="muted small" id="flow-only-queued">${held}</p>`}`;

No test reaches this line. Add a page GET that asserts id="flow-only-queued" shows while held and is gone once flow is labelled.

737739 if (!mayEdit)
738740 return card(current, {
739741 head: html`${icon("user")}
test/flow-only-dispatch.test.ts added +151
@@ -0,0 +1,151 @@
1 /**
2 * FLOW ONLY stops builds, not just words: a builder starts only `flow`
3 * issues while the switch is on, an assignment on any other waits and reads
4 * "queued: flow only", and switching off dispatches it.
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 { assignIssue, openIssue, setIssueLabels as labelIssue } from "../src/actions/issues.ts";
13 import { createRepo, updateRepoSettings } from "../src/actions/repos.ts";
14 import { getAgent } from "../src/core/agents.ts";
15 import { getLabel, setIssueLabels, type Issue } from "../src/core/issues.ts";
16 import { getRepoById, type Repo } from "../src/core/repos.ts";
17 import { listActive } from "../src/core/runs.ts";
18 import { createUser, getUserByName, type User } from "../src/core/users.ts";
19 import { heldNow } from "../src/crew/held.ts";
20 import { flowOnlyQueued } from "../src/crew/flow_only.ts";
21 import { createSession } from "../src/core/sessions.ts";
22 import { createToken } from "../src/core/tokens.ts";
23 import { serve, type Servers } from "../src/http/server.ts";
24 import { queuedText } from "../src/tui/screens/assign.ts";
25 import { queueAgentRun, sweepAssigned } from "../src/crew/dispatch.ts";
26
27 describe("flow only at the dispatcher", () => {
28 let dataDir: string;
29 let app: App;
30 let alice: User;
31 let repo: Repo;
32 let bot: User;
33 let servers: Servers;
34
35 const fresh = () => getRepoById(app.db, repo.id)!;
36 const open = (title: string): Issue => {
37 const o = openIssue(app, alice, fresh(), { title, body: "" });
38 if (!o.ok) throw new Error(String(o.error));
39 return o.value;
40 };
41 const runsOn = (issue: Issue) =>
42 listActive(app.db, "agent").filter((r) => r.issueId === issue.id).length;
43 const assign = (issue: Issue): Issue => {
44 const a = assignIssue(app, alice, fresh(), issue, bot);
45 if (!a.ok) throw new Error(String(a.error));
46 return a.value;
47 };
48 const flow = async (on: boolean) => {
49 const set = await updateRepoSettings(app, alice, fresh(), { flowOnly: on });
50 if (!set.ok) throw new Error(JSON.stringify(set.error));
51 };
52
53 beforeAll(async () => {
54 dataDir = await mkdtemp(join(tmpdir(), "forge-flow-only-dispatch-"));
55 app = createApp({ dataDir, port: 0 });
56 const a = await createUser(app.db, {
57 origin: "seed",
58 trusted: true,
59 username: "alice",
60 email: "[email protected]",
61 password: "a-good-passphrase",
62 adminIfFirst: true,
63 });
64 if (!a.ok) throw new Error(a.error);
65 alice = a.value;
66 servers = await serve(app);
67 const made = await createRepo(app, alice, { name: "svc", visibility: "private" });
68 if (!made.ok) throw new Error(String(made.error));
69 repo = made.value;
70 const created = await createAgent(app, alice, { username: "bot" });
71 if (!created.ok) throw new Error(String(created.error));
72 expect(addToCrew(app, alice, repo, "bot").ok).toBe(true);
73 bot = getUserByName(app.db, "bot")!;
74 });
75
76 afterAll(async () => {
77 servers.stop();
78 app.close();
79 await rm(dataDir, { recursive: true, force: true });
80 });
81
82 test("a non-flow assignment waits while on, goes when off; a flow issue and a rebase run go on", async () => {
83 await flow(true);
84 const plain = assign(open("Plain"));
85 sweepAssigned(app);
86 expect(runsOn(plain)).toBe(0);
87 expect(flowOnlyQueued(app.db, fresh(), plain)).toBe("queued: flow only");
88 expect(heldNow(app, fresh()).by.flow_only.map((i) => i.id)).toContain(plain.id);
89
90 const labelled = open("Labelled");
91 const label = getLabel(app.db, repo.id, "flow")!;
92 setIssueLabels(app.db, labelled.id, [label.id]);
93 const flowing = assign(labelled);
94 sweepAssigned(app);
95 expect(runsOn(flowing)).toBe(1);
96 expect(flowOnlyQueued(app.db, fresh(), flowing)).toBeNull();
97 expect(heldNow(app, fresh()).by.flow_only.map((i) => i.id)).not.toContain(labelled.id);
98
99 // A rebase run on the held issue is not a build.
100 const agent = getAgent(app.db, bot.id)!;
101 const other = open("Other");
102 expect(queueAgentRun(app, fresh(), other, agent, "conflict")).toBeDefined();
103
104 await flow(false);
105 expect(runsOn(plain)).toBe(1);
106 expect(flowOnlyQueued(app.db, fresh(), plain)).toBeNull();
107 });
108
109 test("the held state shows on the API, the terminal and the MCP, and the flow label dispatches it", async () => {
110 await flow(true);
111 const held = assign(open("Waits"));
112 sweepAssigned(app);
113 expect(queuedText(app.db, fresh(), held)).toBe(" · queued: flow only");
114
115 const bearer = { authorization: `Bearer ${createToken(app.db, alice.id, "t", "admin").token}` };
116 const api = (await (await fetch(`${servers.url}/api/v1/repos/alice/svc/issues/${held.number}`, { headers: bearer })).json()) as any;
117 expect(api.queued).toBe("queued: flow only");
118 const res = await fetch(`${servers.url}/mcp`, {
119 method: "POST",
120 headers: { ...bearer, "content-type": "application/json", accept: "application/json, text/event-stream" },
121 body: JSON.stringify({
122 jsonrpc: "2.0",
123 id: 1,
124 method: "tools/call",
125 params: { name: "issue", arguments: { repo: "alice/svc", number: held.number } },
126 }),
127 });
128 expect(JSON.parse(((await res.json()) as any).result.content[0].text).queued).toBe("queued: flow only");
129
130 const page = (await (await fetch(`${servers.url}/alice/svc/issues/${held.number}`, { headers: { cookie: `forge_session=${createSession(app.db, alice.id).token}` } })).text());
131 expect(page).toContain('id="flow-only-queued"');
132
133 // An assigned, never-run issue elsewhere waits for its own reasons; a label here must not start it.
134 const made = await createRepo(app, alice, { name: "other", visibility: "private" });
135 if (!made.ok) throw new Error(String(made.error));
136 expect(addToCrew(app, alice, made.value, "bot").ok).toBe(true);
137 const o = openIssue(app, alice, made.value, { title: "Elsewhere", body: "" });
138 if (!o.ok) throw new Error(String(o.error));
139 const elsewhere = assignIssue(app, alice, made.value, o.value, bot);
140 expect(elsewhere.ok).toBe(true);
141
142 // Labelling it `flow` lets it go now, with the switch still on.
143 const labelled = labelIssue(app, alice, fresh(), held, ["flow"]);
144 expect(labelled.ok).toBe(true);
145 expect(runsOn(held)).toBe(1);
146 expect(runsOn(o.value)).toBe(0);
147 expect(queuedText(app.db, fresh(), held)).toBe("");
148 const after = await (await fetch(`${servers.url}/alice/svc/issues/${held.number}`, { headers: { cookie: `forge_session=${createSession(app.db, alice.id).token}` } })).text();
149 expect(after).not.toContain('id="flow-only-queued"');
150 });
151 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs