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

A crew run's state is worked out from what it did: silence shows, and a plan says where it is #8791

Merged opened this in answer to

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

Merged

scout/forge:scout/8707-a-crew-run-s-state-is-worked-out-from-wh → main 8 commits fork

  • Merged. scout/8707-a-crew-run-s-state-is-worked-out-from-wh was merged into main as 2a96a7f.

Review

Sign in to review this change.
41 files changed +903 −18
docs/features/run-state.md added +10
@@ -0,0 +1,10 @@
1 # A run's state is worked out from what it did (#8707)
2
3 - **One function**: `runState` (`src/runs/state.ts`) names a run *waiting, working, needs input, failed, done* or *silent* from its row, its last sign of life and its plan — never from anything the run says about itself. The runs API rows carry it as `shown`; the runs tab and home badges read it.
4 - **Silent**: a running agent run with no log line and no plan update for 10 minutes. One `silent` flow alarm names the one silent longest (home, /boards, the notifier). Signs of life are kept in memory (`runs/activity.ts`); a run started before this process booted is measured from its start. Because the signs are in this process's memory only, during a roll a run that is logging to the other process, or one logging from before a restart, can read silent falsely; the alarm is then a prompt to look, not a verdict.
5 - **Repeat stop**: more than 3 agent runs on one issue in 10 minutes, whatever each said (`crew/repeat.ts`), parks the issue with one note and raises one `repeat` alarm; nothing starts until a person who directs the crew speaks.
6 - **Plan**: a run keeps up to 12 checklist items on its issue, replaced whole each time: `PUT /api/v1/repos/:owner/:name/issues/:number/plan` `{ items: [{ text, done? }], ask? }`, the MCP tool `keep_plan`, shown on the issue page and in `GET …/issues/:number` as `plan`. `ask` marks the run *needs input* until the next update. Only the agent with a run going on that issue may set its plan (a writer's or another run's call is refused, `forbidden`). Not an event: it is progress, like a log.
7 - **Runs tab count** is `runningCount`: running agent runs and checks, the sum of the pipeline's building, review and checking stages.
8 - `GET …/runs?state=queued|running|ok|failed|cancelled` filters; an unknown state is a 422.
9 - **In the terminal**: the issue screen (`src/tui/screens/issue.ts`, rows from `src/tui/screens/runplan.ts`) shows a *Plan* card above the thread — `[x]`/`[ ]` items and the question as `needs input: …` — read through the same `readPlan` (so gated as the page is), and its title says *silent* or *needs input* when the issue's runs read that way, from `shownStates`.
10 - The runs API works out `shown` for a page of runs with one plan read (`shownStates`), not one per row.
src/actions/run_plan.ts added +50
@@ -0,0 +1,50 @@
1 /**
2 * A crew run's plan on its issue (#8707): the checklist the run replaces
3 * whole each time it learns something, so a person sees where it is without a
4 * log. Only the agent with a run going on the issue may set it. Setting it is also a
5 * sign of life: the run on the issue is seen to act, so it never reads silent
6 * while it is keeping its plan. Not an event: it is progress, like the log,
7 * and nothing is rebuilt from it.
8 */
9
10 import type { App } from "../app.ts";
11 import { type Issue } from "../core/issues.ts";
12 import { getIssuePlan, parseItems, setIssuePlan, type IssuePlan, PLAN_ITEMS_MAX } from "../core/issue_plans.ts";
13 import { canRead, canWrite, onCrew, type Repo } from "../core/repos.ts";
14 import type { User } from "../core/users.ts";
15 import { err, ok, type Result } from "../kernel/result.ts";
16 import { noteActivity } from "../runs/activity.ts";
17
18 export type PlanError = "not_found" | "forbidden" | "invalid";
19
20 export const PLAN_SHAPE = `a plan is a list of up to ${PLAN_ITEMS_MAX} items, each \`{ text, done? }\`, and an optional \`ask\`: what you need a person to answer`;
21
22 export function setPlan(
23 app: App,
24 actor: User | undefined,
25 repo: Repo,
26 issue: Issue,
27 input: { readonly items: unknown; readonly ask?: unknown },
28 ): Result<IssuePlan, PlanError> {
29 if (!canRead(app.db, repo, actor)) return err("not_found");
30 if (actor === undefined || !(canWrite(app.db, repo, actor) || onCrew(app.db, repo, actor)))
31 return err("forbidden");
32 const items = parseItems(input.items);
33 if (items === undefined || (input.ask !== undefined && typeof input.ask !== "string")) return err("invalid");
34 // The run this actor has going on the issue, so the plan says whose it is.
35 const run = app.db
36 .query<{ id: string }, [string, string]>(
37 `SELECT id FROM run WHERE issue_id = ?1 AND actor_id = ?2 AND kind = 'agent' AND state = 'running'
38 ORDER BY id DESC LIMIT 1`,
39 )
40 .get(issue.id, actor.id);
41 // Only the run that is on the issue keeps its plan: a writer or another crew member's call is refused.
42 if (run === null) return err("forbidden");
43 noteActivity(run.id);
44 return ok(setIssuePlan(app.db, issue.id, items, { runId: run.id, ask: (input.ask as string | undefined) ?? "" }));
45 }
46
47 /** The plan as a reader of the issue sees it; undefined when there is none. */
48 export function readPlan(app: App, repo: Repo, actor: User | undefined, issue: Issue): IssuePlan | undefined {
49 return canRead(app.db, repo, actor) ? getIssuePlan(app.db, issue.id) : undefined;
50 }
src/actions/runs.ts +5
@@ -33,6 +33,7 @@ import { abortAgentRun } from "../crew/running.ts";
3333 import { queueAgentRun, taskOf } from "../crew/dispatch.ts";
3434 import { err, ok, type Result } from "../kernel/result.ts";
3535 import { readRunLog, readRunLogTail, runLogPath } from "../runs/log.ts";
36 import { shownStates, type ShownState } from "../runs/state.ts";
3637 import { firstErrorLine } from "../runs/error_line.ts";
3738 import { requireReader, requireWriter, type AccessError } from "./access.ts";
3839
@@ -171,6 +172,8 @@ export function listRepoRuns(
171172
172173 /** A run as the runs API lists it: the agent's name, and the pull or issue it is about. */
173174 export type NamedRun = Run & {
175 /** The state to show, worked out from what the run did (`runs/state.ts`). */
176 readonly shown: ShownState;
174177 readonly agent: string | null;
175178 readonly number: number | null;
176179 readonly title: string | null;
@@ -192,10 +195,12 @@ export function nameRuns(app: App, runs: readonly Run[]): NamedRun[] {
192195 app.db,
193196 runs.flatMap((r) => (r.issueId === null ? [] : [r.issueId])),
194197 );
198 const shown = shownStates(app.db, runs);
195199 return runs.map((r) => {
196200 const issue = r.issueId === null ? undefined : issues.get(r.issueId);
197201 return {
198202 ...r,
203 shown: shown.get(r.id) ?? "waiting",
199204 agent: r.kind === "agent" && r.actorId !== null ? (actors.get(r.actorId)?.username ?? null) : null,
200205 number: issue?.number ?? null,
201206 title: issue?.title ?? null,
src/contract/openapi/routes/getApiV1ReposOwnerNameIssuesNumberPlan.ts added +8
@@ -0,0 +1,8 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "get",
5 path: `${REPO}/issues/{number}/plan`,
6 summary: "Read the plan checklist a crew run keeps on an issue; answers { plan }, null when there is none: { items: [{ text, done }], ask, runId, updatedAt }",
7 auth: false,
8 };
src/contract/openapi/routes/getApiV1ReposOwnerNameRuns.ts +1 −1
@@ -1,3 +1,3 @@
11 import { REPO, type Route } from "../shared.ts";
22
3 export const route: Route = { method: "get", path: `${REPO}/runs`, summary: "List a repository's runs, newest first: { items, next_cursor }; kind deploy or agent, env an environment name, limit 1 to 200 (default 50), cursor a run id; a stranger's runs carry no cost, turns, agent session or node", auth: false, query: { kind: "string", env: "string", limit: "integer", cursor: "string" } };
3 export const route: Route = { method: "get", path: `${REPO}/runs`, summary: "List a repository's runs, newest first: { items, next_cursor }; kind deploy or agent, state queued, running, ok, failed or cancelled (another is a 422), env an environment name, limit 1 to 200 (default 50), cursor a run id; each row carries `shown`, the state worked out from what the run did; a stranger's runs carry no cost, turns, agent session or node", auth: false, query: { kind: "string", state: "string", env: "string", limit: "integer", cursor: "string" } };
src/contract/openapi/routes/index.ts +2
@@ -112,6 +112,7 @@ export { route as getApiV1ReposOwnerNameIssues } from "./getApiV1ReposOwnerNameI
112112 export { route as getApiV1ReposOwnerNameIssuesNumber } from "./getApiV1ReposOwnerNameIssuesNumber.ts";
113113 export { route as getApiV1ReposOwnerNameIssuesNumberComments } from "./getApiV1ReposOwnerNameIssuesNumberComments.ts";
114114 export { route as getApiV1ReposOwnerNameIssuesNumberDependencies } from "./getApiV1ReposOwnerNameIssuesNumberDependencies.ts";
115 export { route as getApiV1ReposOwnerNameIssuesNumberPlan } from "./getApiV1ReposOwnerNameIssuesNumberPlan.ts";
115116 export { route as getApiV1ReposOwnerNameIssuesNumberTeams } from "./getApiV1ReposOwnerNameIssuesNumberTeams.ts";
116117 export { route as getApiV1ReposOwnerNameLabels } from "./getApiV1ReposOwnerNameLabels.ts";
117118 export { route as getApiV1ReposOwnerNameMap } from "./getApiV1ReposOwnerNameMap.ts";
@@ -308,6 +309,7 @@ export { route as putApiV1ReposOwnerNameAppMembers } from "./putApiV1ReposOwnerN
308309 export { route as putApiV1ReposOwnerNameCrewUsername } from "./putApiV1ReposOwnerNameCrewUsername.ts";
309310 export { route as putApiV1ReposOwnerNameIssuesNumberAssignee } from "./putApiV1ReposOwnerNameIssuesNumberAssignee.ts";
310311 export { route as putApiV1ReposOwnerNameIssuesNumberLabels } from "./putApiV1ReposOwnerNameIssuesNumberLabels.ts";
312 export { route as putApiV1ReposOwnerNameIssuesNumberPlan } from "./putApiV1ReposOwnerNameIssuesNumberPlan.ts";
311313 export { route as putApiV1ReposOwnerNameIssuesNumberTeams } from "./putApiV1ReposOwnerNameIssuesNumberTeams.ts";
312314 export { route as putApiV1ReposOwnerNameMilestonesNumberWatch } from "./putApiV1ReposOwnerNameMilestonesNumberWatch.ts";
313315 export { route as putApiV1ReposOwnerNamePullsNumberAssignee } from "./putApiV1ReposOwnerNamePullsNumberAssignee.ts";
src/contract/openapi/routes/putApiV1ReposOwnerNameIssuesNumberPlan.ts added +13
@@ -0,0 +1,13 @@
1 import { REPO, type Route } from "../shared.ts";
2
3 export const route: Route = {
4 method: "put",
5 path: `${REPO}/issues/{number}/plan`,
6 summary: "Replace the issue's plan checklist whole (up to 12 items of { text, done? }, an empty list clears it); `ask` is what the run needs a person to answer and shows the run as needing input; answers { plan }",
7 auth: true,
8 body: { items: "array", ask: "string" },
9 refusals: {
10 invalid: "items is not a list of up to 12 { text, done? }, or ask is not text",
11 forbidden: "the caller is neither a writer nor on the repository's crew",
12 },
13 };
src/core/issue_plans.ts added +85
@@ -0,0 +1,85 @@
1 /**
2 * An issue's plan: the short checklist a crew run keeps so a person sees where
3 * it is without reading a log (#8707). Replaced whole at each update, never
4 * appended to; bounded in count and length, and every field checked before it
5 * is kept. Pure data over the database: no authz, no events.
6 */
7
8 import type { Database } from "bun:sqlite";
9
10 export const PLAN_ITEMS_MAX = 12;
11 export const PLAN_TEXT_MAX = 120;
12 export const PLAN_ASK_MAX = 300;
13
14 export interface PlanItem {
15 readonly text: string;
16 readonly done: boolean;
17 }
18
19 export interface IssuePlan {
20 readonly issueId: string;
21 readonly runId: string | null;
22 readonly items: readonly PlanItem[];
23 /** What the run needs a person to answer; empty when it needs nothing. */
24 readonly ask: string;
25 readonly updatedAt: string;
26 }
27
28 const oneLine = (s: string, max: number): string => s.replace(/\s+/g, " ").trim().slice(0, max);
29
30 /** The items a caller sent, as a checklist; undefined when the shape is wrong. */
31 export function parseItems(raw: unknown): PlanItem[] | undefined {
32 if (!Array.isArray(raw) || raw.length > PLAN_ITEMS_MAX) return undefined;
33 const out: PlanItem[] = [];
34 for (const item of raw) {
35 if (typeof item !== "object" || item === null) return undefined;
36 const { text, done } = item as { text?: unknown; done?: unknown };
37 if (typeof text !== "string" || (done !== undefined && typeof done !== "boolean")) return undefined;
38 const line = oneLine(text, PLAN_TEXT_MAX);
39 if (line === "") return undefined;
40 out.push({ text: line, done: done === true });
41 }
42 return out;
43 }
44
45 /** Replace the issue's plan whole. An empty list clears it. */
46 export function setIssuePlan(
47 db: Database,
48 issueId: string,
49 items: readonly PlanItem[],
50 opts: { readonly runId?: string | null; readonly ask?: string; readonly now?: Date } = {},
51 ): IssuePlan {
52 const at = (opts.now ?? new Date()).toISOString();
53 const ask = oneLine(opts.ask ?? "", PLAN_ASK_MAX);
54 db.query(
55 `INSERT INTO issue_plan (issue_id, run_id, items, ask, updated_at) VALUES (?1, ?2, ?3, ?4, ?5)
56 ON CONFLICT(issue_id) DO UPDATE SET run_id = ?2, items = ?3, ask = ?4, updated_at = ?5`,
57 ).run(issueId, opts.runId ?? null, JSON.stringify(items), ask, at);
58 return { issueId, runId: opts.runId ?? null, items, ask, updatedAt: at };
59 }
60
61 export function getIssuePlan(db: Database, issueId: string): IssuePlan | undefined {
62 const r = db
63 .query<{ run_id: string | null; items: string; ask: string; updated_at: string }, [string]>(
64 "SELECT run_id, items, ask, updated_at FROM issue_plan WHERE issue_id = ?1",
65 )
66 .get(issueId);
67 if (r === null) return undefined;
68 return { issueId, runId: r.run_id, items: JSON.parse(r.items) as PlanItem[], ask: r.ask, updatedAt: r.updated_at };
69 }
70
71 /** The plans of several issues in one read, keyed by issue id. */
72 export function getIssuePlans(db: Database, issueIds: readonly string[]): Map<string, IssuePlan> {
73 const out = new Map<string, IssuePlan>();
74 for (let i = 0; i < issueIds.length; i += 200) {
75 const part = issueIds.slice(i, i + 200);
76 const rows = db
77 .query<{ issue_id: string; run_id: string | null; items: string; ask: string; updated_at: string }, string[]>(
78 `SELECT issue_id, run_id, items, ask, updated_at FROM issue_plan WHERE issue_id IN (${part.map(() => "?").join(",")})`,
79 )
80 .all(...part);
81 for (const r of rows)
82 out.set(r.issue_id, { issueId: r.issue_id, runId: r.run_id, items: JSON.parse(r.items) as PlanItem[], ask: r.ask, updatedAt: r.updated_at });
83 }
84 return out;
85 }
src/core/migration-pins/20261102200000-issue-plan.pin added +1
@@ -0,0 +1,1 @@
1 b4df9e3c87cbe921289cc48dd3c6f38f944387034a44f9b517bcba7b8eb034b3
src/core/migrations/20261102200000-issue-plan.ts added +12
@@ -0,0 +1,12 @@
1 // A crew run's plan checklist on its issue (#8707): one row per issue, replaced
2 // whole at each update. `ask` is what the run needs a person to answer, if
3 // anything. Read by primary key only. Additive.
4 export default `
5 CREATE TABLE issue_plan (
6 issue_id TEXT PRIMARY KEY,
7 run_id TEXT,
8 items TEXT NOT NULL,
9 ask TEXT NOT NULL DEFAULT '',
10 updated_at TEXT NOT NULL
11 );
12 `;
src/core/runs.ts +17
@@ -1389,3 +1389,20 @@ export function getRunErrorLine(db: Database, runId: string): { text: string; at
13891389 .get(runId);
13901390 return row === null || row === undefined || row.error_line === "" ? undefined : { text: row.error_line, at: row.error_at };
13911391 }
1392
1393 /**
1394 * How many runs of a repository are really going: agent runs and checks in
1395 * the `running` state, the pipeline's `building`, `review` and `checking`
1396 * stages added (`flow/pipeline.ts`). Queued, paused and finished runs are
1397 * not running, and a deploy is no work of the crew's.
1398 */
1399 export function runningCount(db: Database, repoId: string): number {
1400 return (
1401 db
1402 .query<{ n: number }, [string]>(
1403 `SELECT COUNT(*) AS n FROM run
1404 WHERE state = 'running' AND repo_id = ?1 AND kind IN ('agent','check')`,
1405 )
1406 .get(repoId)?.n ?? 0
1407 );
1408 }
src/crew/autonomy.ts +2 −1
@@ -106,6 +106,7 @@ import { heldByGateAlone, releaseFromGate } from "./release_later.ts";
106106 import { weekFor } from "./week_read.ts";
107107 import { announceGovernor, governorFor } from "./governed.ts";
108108 import { BREAKER_NOTE, breakerTripped } from "./breaker.ts";
109 import { REPEAT_NOTE } from "./repeat_note.ts";
109110 import { given, pendingReplans } from "../core/limb_replan.ts";
110111 import { handedOverPulls, SKIP_SUMMARY, skipsReplan } from "./guard.ts";
111112 import { atRunCap, type Held, heldBy, heldWords, replannable } from "./held.ts";
@@ -991,7 +992,7 @@ export function parkForBreaker(
991992 const last = listComments(app.db, issue.id).at(-1);
992993 if (last?.body === note) return false;
993994 addComment(app, author, repo, issue, note);
994 log.info(note === BREAKER_NOTE ? "parked by the breaker" : "parked at the run cap", {
995 log.info(note === BREAKER_NOTE ? "parked by the breaker" : note === REPEAT_NOTE ? "parked by the repeat stop" : "parked at the run cap", {
995996 repo: `${repo.ownerName}/${repo.name}`,
996997 issue: issue.number,
997998 });
src/crew/breaker.ts +4 −3
@@ -9,6 +9,7 @@ import type { Database } from "bun:sqlite";
99 import type { Repo } from "../core/repos.ts";
1010 import { CAP_NOTE } from "./guard.ts";
1111 import { directorsAmong } from "./direct.ts";
12 import { REPEAT_NOTE } from "./repeat_note.ts";
1213
1314 export const BREAKER_RUNS = 3;
1415 export const BREAKER_WINDOW_MS = 60 * 60_000;
@@ -51,7 +52,7 @@ export function breakerTripped(
5152 * agent's, not a reader's, not one imported from an origin, not the crew's
5253 * own parking note (#619).
5354 */
54 function spokenSince(
55 export function spokenSince(
5556 db: Database,
5657 repo: Repo,
5758 issueId: string,
@@ -69,7 +70,7 @@ function spokenSince(
6970 }
7071
7172 /** The notes that park an issue until a person speaks: the breaker's and the run cap's. */
72 export const WAITING_NOTES = [BREAKER_NOTE, CAP_NOTE] as const;
73 export const WAITING_NOTES = [BREAKER_NOTE, CAP_NOTE, REPEAT_NOTE] as const;
7374
7475 /** Open issues whose newest comment is one of `WAITING_NOTES`: the ones that wait for a person. */
7576 export function breakerParked(
@@ -84,7 +85,7 @@ export function breakerParked(
8485 `SELECT i.id AS issueId, i.repo_id AS repoId, c.created_at AS at
8586 FROM issue i JOIN comment c ON c.id = (SELECT id FROM comment WHERE issue_id = i.id
8687 ORDER BY created_at DESC, id DESC LIMIT 1)
87 WHERE i.state = 'open' AND i.repo_id IN (${marks}) AND c.body IN (?${repoIds.length + 1}, ?${repoIds.length + 2})`,
88 WHERE i.state = 'open' AND i.repo_id IN (${marks}) AND c.body IN (${WAITING_NOTES.map(() => "?").join(", ")})`,
8889 )
8990 .all(...repoIds, ...WAITING_NOTES);
9091 }
src/crew/brief.ts +5
@@ -338,6 +338,10 @@ export function taskPrompt(input: TaskInput): string {
338338 const MCP_SIZE_RULE =
339339 "This issue touches the MCP. Run `bun test test/mcp-size.test.ts` before pushing; it names the five costliest tools when it fails. Prefer an `action` or `kind` on an existing tool (docs/MCP.md, \"Room for a new tool\") over a new tool, and never raise the ceilings.";
340340
341 /** A plan on the issue is how a person sees where a run is without reading its log; only the forge serves the tool. */
342 const PLAN_RULE =
343 "Keep a short plan on this issue with the `keep_plan` tool (up to 12 items, replaced whole each time): call it once you have read the code and again as items finish, so a person sees where you are without a log.";
344
341345 /** The forge's own repository: its briefs name paths in the forge's tree, not an app scaffold's. */
342346 function isForge(repo: { readonly name: string }): boolean {
343347 return repo.name === "forge";
@@ -418,6 +422,7 @@ function taskBody(input: TaskInput): string {
418422 );
419423 if (input.pull === undefined && isForge(input.repo) && namesMcp(issue.title, issue.body))
420424 out.push("", MCP_SIZE_RULE);
425 if (input.pull === undefined && isForge(input.repo)) out.push("", PLAN_RULE);
421426 out.push(
422427 "",
423428 `Work it on the branch \`${input.branch}\` and push when done.`,
src/crew/dispatch.ts +6
@@ -71,6 +71,7 @@ import { issueOfBranch, type RunReason } from "./brief.ts";
7171 import { detectClaude } from "./claude.ts";
7272 import { followDivergence, settleAll, settleOnPush } from "./converge.ts";
7373 import { breakerTripped } from "./breaker.ts";
74 import { REPEAT_NOTE, repeatTripped } from "./repeat.ts";
7475 import { unstickOwed } from "./stalls.ts";
7576 import {
7677 CAP_NOTE,
@@ -416,6 +417,11 @@ export function queueAgentRun(
416417 parkForBreaker(app, repo, issue, agent);
417418 return undefined;
418419 }
420 // The same issue run again and again within minutes: a loop, parked once.
421 if (repeatTripped(app.db, repo, issue.id)) {
422 parkForBreaker(app, repo, issue, agent, REPEAT_NOTE);
423 return undefined;
424 }
419425 // A day's share of runs on one issue without a person's word: a loop,
420426 // parked once and asked about once (`guard.ts`). A run carrying on after
421427 // a pause or a restart is the same start, never a new one. The direction
src/crew/repeat.ts added +33
@@ -0,0 +1,33 @@
1 /**
2 * The repeat stop (#8707): the same issue run more than `REPEAT_RUNS` times
3 * in `REPEAT_WINDOW_MS` is a loop, whatever each run said — 97 empty pulls
4 * on 2026-11-02 were runs that all "succeeded". It is parked once, with one
5 * note (`REPEAT_NOTE`) that raises one alarm (`flow/alarm_repeat.ts`), and
6 * nothing more starts until a person who directs the crew speaks on it.
7 * Failures in a row are the breaker's (`breaker.ts`); this counts every run the
8 * cap counts (`guard.ts` `capReached`): not a reviewer's, a conflict's, a red
9 * check's, a resumed one, a skipped one or one a restart cut short.
10 */
11
12 import type { Database } from "bun:sqlite";
13 import type { Repo } from "../core/repos.ts";
14 import { spokenSince } from "./breaker.ts";
15
16 export { REPEAT_NOTE } from "./repeat_note.ts";
17 export const REPEAT_RUNS = 3;
18 export const REPEAT_WINDOW_MS = 10 * 60_000;
19
20 /** Whether the issue has had more than `REPEAT_RUNS` agent runs in the window with no person's word since the first. */
21 export function repeatTripped(db: Database, repo: Repo, issueId: string, now = new Date()): boolean {
22 const since = new Date(now.getTime() - REPEAT_WINDOW_MS).toISOString();
23 const runs = db
24 .query<{ created_at: string }, [string, string]>(
25 `SELECT created_at FROM run WHERE issue_id = ?1 AND kind = 'agent' AND created_at > ?2
26 AND attempt = 0 AND reason NOT IN ('review', 'conflict', 'check_failed', 'resolve')
27 AND summary NOT LIKE 'interrupted: %' AND summary NOT LIKE 'skipped: %'
28 ORDER BY created_at LIMIT ${REPEAT_RUNS + 1}`,
29 )
30 .all(issueId, since);
31 if (runs.length <= REPEAT_RUNS) return false;
32 return !spokenSince(db, repo, issueId, runs[0]!.created_at);
33 }
src/crew/repeat_note.ts added +3
@@ -0,0 +1,3 @@
1 /** What a repeating issue is parked with: the map reads it as `parked`, the asks as a question (#8707). */
2 export const REPEAT_NOTE =
3 "Parked by the crew: runs on this issue repeated more than three times in ten minutes, so nothing more starts here until a person says what to try.";
src/flow/alarm_repeat.ts added +39
@@ -0,0 +1,39 @@
1 /**
2 * The "repeating issue" flow alarm (#8707): an issue parked by the repeat stop
3 * (`crew/repeat.ts`) whose note is still its newest word, so no person has
4 * answered. One alarm, naming the one parked longest. Home and /boards draw it.
5 */
6
7 import type { Database } from "bun:sqlite";
8 import { REPEAT_NOTE } from "../crew/repeat_note.ts";
9
10 export interface RepeatAlarm {
11 readonly fired: boolean;
12 readonly since?: string;
13 readonly repo?: string;
14 readonly number?: number;
15 readonly says: string;
16 }
17
18 export function alarmRepeat(db: Database): RepeatAlarm {
19 // Walks the repositories that run themselves, then their open issues by
20 // `issue_repo_kind_state_number`, each one's newest comment by the thread's index.
21 const r = db
22 .query<{ repo: string; number: number; at: string }, [string]>(
23 `SELECT o.username || '/' || r.name AS repo, i.number, c.created_at AS at
24 FROM repo r CROSS JOIN issue i ON i.repo_id = r.id
25 JOIN user o ON o.id = r.owner_id
26 JOIN comment c ON c.id = (SELECT id FROM comment WHERE issue_id = i.id ORDER BY created_at DESC, id DESC LIMIT 1)
27 WHERE r.autonomy != 'off' AND i.state = 'open' AND c.body = ?1
28 ORDER BY c.created_at LIMIT 1`,
29 )
30 .get(REPEAT_NOTE);
31 if (r === null) return { fired: false, says: "No issue is repeating runs." };
32 return {
33 fired: true,
34 since: r.at,
35 repo: r.repo,
36 number: r.number,
37 says: `${r.repo}#${r.number} ran more than three times in ten minutes and was stopped; it waits for a person to say what to try.`,
38 };
39 }
src/flow/alarm_silent.ts added +49
@@ -0,0 +1,49 @@
1 /**
2 * The "silent run" flow alarm (#8707): an agent run that is running and has
3 * shown nothing — no log line, no plan update — for `SILENT_MS`. One alarm,
4 * naming the run silent longest. The run's state is worked out from what it
5 * did (`runs/state.ts`), never what it says.
6 */
7
8 import type { Database } from "bun:sqlite";
9 import { SILENT_MS } from "../runs/state.ts";
10 import { lastActivity } from "../runs/activity.ts";
11
12 export interface SilentAlarm {
13 readonly fired: boolean;
14 readonly since?: string;
15 readonly repo?: string;
16 readonly number?: number;
17 /** The silent run, for a page that links it. */
18 readonly runId?: string;
19 readonly says: string;
20 }
21
22 export function alarmSilent(db: Database, now: Date): SilentAlarm {
23 const running = db
24 .query<
25 { id: string; repo: string; number: number | null; at: string },
26 []
27 >(`SELECT x.id, o.username || '/' || r.name AS repo, i.number, COALESCE(x.started_at, x.created_at) AS at
28 FROM run x JOIN repo r ON r.id = x.repo_id JOIN user o ON o.id = r.owner_id
29 LEFT JOIN issue i ON i.id = x.issue_id
30 WHERE x.state = 'running' AND x.kind = 'agent'`)
31 .all();
32 let oldest: { id: string; repo: string; number: number | null; last: number } | undefined;
33 for (const run of running) {
34 const last = Math.max(Date.parse(run.at), lastActivity(run.id) ?? 0);
35 if (now.getTime() - last <= SILENT_MS) continue;
36 if (oldest === undefined || last < oldest.last) oldest = { ...run, last };
37 }
38 if (oldest === undefined) return { fired: false, says: "No running agent is silent." };
39 const minutes = Math.floor((now.getTime() - oldest.last) / 60_000);
40 const about = oldest.number === null ? oldest.repo : `${oldest.repo}#${oldest.number}`;
41 return {
42 fired: true,
43 since: new Date(oldest.last).toISOString(),
44 repo: oldest.repo,
45 ...(oldest.number === null ? {} : { number: oldest.number }),
46 runId: oldest.id,
47 says: `The run on ${about} has shown nothing for ${minutes} minutes: no log line, no plan update.`,
48 };
49 }
src/flow/alarms.ts +7 −1
@@ -14,10 +14,12 @@ import { ownRepoPath } from "../kernel/own_repo.ts";
1414 import { alarmIdleCrew } from "./alarm_idle_crew.ts";
1515 import { noReleaseAlarm } from "./alarm_no_release.ts";
1616 import { redMainAlarm } from "./alarm_red_main.ts";
17 import { alarmRepeat } from "./alarm_repeat.ts";
18 import { alarmSilent } from "./alarm_silent.ts";
1719 import { alarmStuck } from "./alarm_stuck.ts";
1820
1921 export interface FiredAlarm {
20 readonly key: "landing_stopped" | "red_main" | "stuck" | "idle_crew" | "no_release" | "unreachable";
22 readonly key: "landing_stopped" | "red_main" | "stuck" | "idle_crew" | "no_release" | "silent" | "repeat" | "unreachable";
2123 readonly says: string;
2224 readonly since: string;
2325 /** A same-origin path to the item the alarm names. */
@@ -48,6 +50,10 @@ export function firedAlarms(
4850 out.push({ key: "stuck", says: stuck.says, since: stuck.since!, href: `/${stuck.repo}/pulls/${stuck.number}` });
4951 }
5052 if (!opts.publicOnly) {
53 const silent = alarmSilent(db, now);
54 if (silent.fired) out.push({ key: "silent", says: silent.says, since: silent.since!, href: `/${silent.repo}/runs/${silent.runId}` });
55 const repeat = alarmRepeat(db);
56 if (repeat.fired) out.push({ key: "repeat", says: repeat.says, since: repeat.since!, href: `/${repeat.repo}/issues/${repeat.number}` });
5157 const idle = alarmIdleCrew(db, now, { ceilingUsd: opts.ceilingUsd ?? null });
5258 if (idle.fired) out.push({ key: "idle_crew", says: idle.says, since: idle.since!, href: "/boards" });
5359 }
src/http/api.ts +6
@@ -255,6 +255,7 @@ import {
255255 isTerminal,
256256 latestRunsForIssues,
257257 listActiveRuns,
258 RUN_STATES,
258259 type Run,
259260 } from "../core/runs.ts";
260261 import { checkStale } from "../checks/stale.ts";
@@ -3573,6 +3574,10 @@ export function apiRoutes(router: Router<Handler>): void {
35733574 const q = ctx.url.searchParams;
35743575 const kind = q.get("kind");
35753576 const env = q.get("env");
3577 const wanted = q.get("state");
3578 const state = RUN_STATES.find((s) => s === wanted);
3579 if (wanted !== null && state === undefined)
3580 return apiError("invalid_state", 422, `state is one of ${RUN_STATES.join(", ")}`);
35763581 const limit = Math.min(Math.max(Number(q.get("limit")) || 50, 1), 200);
35773582 // Already as this reader may see them (`runForReader`): a stranger's
35783583 // runs carry no cost, turns, agent session or node. Each row also names
@@ -3582,6 +3587,7 @@ export function apiRoutes(router: Router<Handler>): void {
35823587 const listed = listRepoRuns(ctx.app, ctx.auth.user, repo, {
35833588 ...(kind === "deploy" || kind === "agent" ? { kind } : {}),
35843589 ...(env !== null && isEnvName(env) ? { env } : {}),
3590 ...(state === undefined ? {} : { state }),
35853591 ...(q.get("cursor") ? { cursor: q.get("cursor")! } : {}),
35863592 limit,
35873593 });
src/http/api/issues.ts +21
@@ -17,6 +17,7 @@ import {
1717 plannerCloseRefusal,
1818 setIssueStateThrough,
1919 } from "../../actions/issues.ts";
20 import { PLAN_SHAPE, readPlan, setPlan } from "../../actions/run_plan.ts";
2021 import { privateRepoForSealed } from "../../actions/repos.ts";
2122 import { dependenciesOf } from "../../core/dependencies.ts";
2223 import { teamsOfIssue } from "../../core/issue_teams.ts";
@@ -177,6 +178,25 @@ export function issueRoutes(router: Router<Handler>): void {
177178 return json(withLabels(ctx, created.value), 201);
178179 });
179180
181 // The plan a crew run keeps on its issue: replaced whole at each update (#8707).
182 router.add("GET", "/api/v1/repos/:owner/:name/issues/:number/plan", (ctx) => {
183 notePhase(ctx.request, "session", takeSessionTime(ctx.request));
184 const found = loadIssue(ctx);
185 if ("error" in found) return found.error;
186 return json({ plan: readPlan(ctx.app, found.repo, ctx.auth.user, found.issue) ?? null });
187 });
188 router.add("PUT", "/api/v1/repos/:owner/:name/issues/:number/plan", async (ctx) => {
189 const denied = requireScope(ctx, "rw");
190 if (denied) return denied;
191 const found = loadIssue(ctx);
192 if ("error" in found) return found.error;
193 const input = await body(ctx.request);
194 const set = setPlan(ctx.app, ctx.auth.user, found.repo, found.issue, { items: input.items, ask: input.ask });
195 if (!set.ok)
196 return set.error === "invalid" ? apiError("invalid", 422, PLAN_SHAPE) : originAware(set.error);
197 return json({ plan: set.value });
198 });
199
180200 router.add("GET", "/api/v1/repos/:owner/:name/issues/:number", (ctx) => {
181201 // Numbers are shared with pulls: a pull's number answers as the same
182202 // shape, marked `kind: "pull"`, with the path where its diff lives (#3299).
@@ -212,6 +232,7 @@ export function issueRoutes(router: Router<Handler>): void {
212232 pull: `/api/v1/repos/${repo.ownerName}/${repo.name}/pulls/${issue.number}`,
213233 }
214234 : {}),
235 plan: readPlan(ctx.app, found.repo, ctx.auth.user, issue) ?? null,
215236 comments: shown,
216237 commentsTotal: total,
217238 commentsNext: shown.length > 0 && shown.length < total ? shown[0]!.id : null,
src/http/mcp/tools/index.ts +1
@@ -73,6 +73,7 @@ export { tool as invites } from "./invites.ts";
7373 export { tool as issue } from "./issue.ts";
7474 export { tool as issue_teams } from "./issue_teams.ts";
7575 export { tool as issues } from "./issues.ts";
76 export { tool as keep_plan } from "./keep_plan.ts";
7677 export { tool as labels } from "./labels.ts";
7778 export { tool as land_past_gate } from "./land_past_gate.ts";
7879 export { tool as lift } from "./lift.ts";
src/http/mcp/tools/keep_plan.ts added +38
@@ -0,0 +1,38 @@
1 import { PLAN_SHAPE, setPlan } from "../../../actions/run_plan.ts";
2 import { good, bad, type Tool, REPO_ARG, repoArg, issueArg, isAnswer } from "../shared.ts";
3
4 export const tool: Tool = {
5 name: "keep_plan",
6 doc: "`{ repo, number, items, ask? }`: replace the plan checklist on an issue whole (`items`: up to 12 of `{ text, done? }`; an empty list clears it); `ask` is what you need a person to answer, and shows the run as needing input; the same as `PUT …/issues/:number/plan`; answers the plan",
7 description:
8 "Keep a short plan checklist on your issue, replaced whole each time.",
9 inputSchema: {
10 type: "object",
11 properties: {
12 repo: REPO_ARG,
13 number: { type: "integer", minimum: 1 },
14 items: {
15 type: "array",
16 maxItems: 12,
17 items: {
18 type: "object",
19 properties: { text: { type: "string", maxLength: 120 }, done: { type: "boolean" } },
20 required: ["text"],
21 },
22 },
23 ask: { type: "string", maxLength: 300 },
24 },
25 required: ["repo", "number", "items"],
26 },
27 writes: true,
28 async run(ctx, user, args) {
29 const repo = repoArg(ctx, user, args);
30 if (isAnswer(repo)) return repo;
31 const issue = issueArg(ctx, repo, args, "number");
32 if ("ok" in issue) return issue;
33 const set = setPlan(ctx.app, user, repo, issue, { items: args["items"], ask: args["ask"] });
34 if (!set.ok)
35 return set.error === "invalid" ? bad("invalid", PLAN_SHAPE) : bad(set.error, "this plan is not yours to set");
36 return good({ says: `Plan on #${issue.number}: ${set.value.items.filter((i) => i.done).length} of ${set.value.items.length} done.`, plan: set.value });
37 },
38 };
src/runs/activity.ts added +18
@@ -0,0 +1,18 @@
1 /**
2 * When a run last did something: a line in its log, or a plan update
3 * (#8707). Kept in memory, set where the log is written. A run the forge has
4 * not seen act since it started is measured from its start; a restart cancels
5 * the runs in flight, so nothing needs this to outlive the process.
6 */
7
8 /** Runs remembered: the oldest is dropped past this. */
9 const KEPT = 2000;
10 const lastAt = new Map<string, number>();
11
12 export function noteActivity(runId: string, at: number = Date.now()): void {
13 lastAt.delete(runId);
14 lastAt.set(runId, at);
15 if (lastAt.size > KEPT) lastAt.delete(lastAt.keys().next().value!);
16 }
17
18 export const lastActivity = (runId: string): number | undefined => lastAt.get(runId);
src/runs/log.ts +2
@@ -21,6 +21,7 @@ import {
2121 } from "node:fs";
2222 import { join } from "node:path";
2323 import { redactHeld } from "../kernel/log.ts";
24 import { noteActivity } from "./activity.ts";
2425
2526 export const runsRoot = (dataDir: string): string => join(dataDir, "runs");
2627
@@ -84,6 +85,7 @@ export function appendRunLog(
8485 const clean = redactHeld(line.replace(/\r?\n/g, " ")).slice(0, MAX_LINE);
8586 mkdirSync(runsRoot(dataDir), { recursive: true });
8687 appendFileSync(runLogPath(dataDir, runId), `${clean}\n`);
88 noteActivity(runId);
8789 for (const tap of taps) {
8890 try {
8991 tap(runId, [clean]);
src/runs/state.ts added +81
@@ -0,0 +1,81 @@
1 /**
2 * A run's displayed state, worked out from what it did and never reported by
3 * the run itself (#8707, after Linear's agent sessions). One function: the
4 * row's state, how long since it last acted, and whether its plan asks a
5 * person something.
6 *
7 * waiting queued, not started
8 * working running and heard from lately
9 * needs input its plan asks a person a question
10 * failed ended failed
11 * done ended ok, or cancelled
12 * silent running and nothing seen for `SILENT_MS`
13 */
14
15 import type { Database } from "bun:sqlite";
16 import { getIssuePlan, getIssuePlans } from "../core/issue_plans.ts";
17 import type { Run } from "../core/runs.ts";
18 import { lastActivity } from "./activity.ts";
19
20 export const SILENT_MS = 10 * 60_000;
21
22 export const SHOWN_STATES = ["waiting", "working", "needs input", "failed", "done", "silent"] as const;
23 export type ShownState = (typeof SHOWN_STATES)[number];
24
25 export interface StateFacts {
26 readonly now: Date;
27 /** The run's plan asks a person something. */
28 readonly asking?: boolean;
29 /** When the run last acted (ms); the log's own clock when left out. */
30 readonly lastActivityAt?: number | undefined;
31 }
32
33 /** The ms instant a running run was last seen to act: its log, else its start. */
34 export function activityOf(run: Run, seen?: number | undefined): number {
35 const started = Date.parse(run.startedAt ?? run.createdAt);
36 return Math.max(started, seen ?? lastActivity(run.id) ?? 0);
37 }
38
39 export function runState(run: Run, facts: StateFacts): ShownState {
40 switch (run.state) {
41 case "failed":
42 return "failed";
43 case "ok":
44 case "cancelled":
45 return "done";
46 case "queued":
47 return facts.asking === true ? "needs input" : "waiting";
48 case "running":
49 if (facts.asking === true) return "needs input";
50 return facts.now.getTime() - activityOf(run, facts.lastActivityAt) > SILENT_MS ? "silent" : "working";
51 }
52 }
53
54 /** The state to show for `run`, reading its plan for a question when it is still going. */
55 export function shownState(db: Database, run: Run, now: Date = new Date()): ShownState {
56 const going = run.state === "queued" || run.state === "running";
57 const plan = going && run.kind === "agent" && run.issueId !== null ? getIssuePlan(db, run.issueId) : undefined;
58 return runState(run, { now, asking: plan !== undefined && plan.ask !== "" && plan.runId === run.id });
59 }
60
61 /**
62 * The state to show for each of `runs`, with one read of the plans of the
63 * issues that still-going agent runs are on, not one per row.
64 */
65 export function shownStates(db: Database, runs: readonly Run[], now: Date = new Date()): Map<string, ShownState> {
66 const ids = [
67 ...new Set(
68 runs.flatMap((r) =>
69 (r.state === "queued" || r.state === "running") && r.kind === "agent" && r.issueId !== null ? [r.issueId] : [],
70 ),
71 ),
72 ];
73 const plans = getIssuePlans(db, ids);
74 const out = new Map<string, ShownState>();
75 for (const r of runs) {
76 const plan = r.issueId === null ? undefined : plans.get(r.issueId);
77 const asking = plan !== undefined && plan.ask !== "" && plan.runId === r.id;
78 out.set(r.id, runState(r, { now, asking }));
79 }
80 return out;
81 }
src/tui/screens/issue.ts +22 −2
@@ -36,6 +36,10 @@ import { ISSUE_TEAMS_SAY, nameTeams } from "../../actions/issue_teams.ts";
3636 import { teamsOfIssue, TEAMS_MAX } from "../../core/issue_teams.ts";
3737 import { listTeams } from "../../core/teams.ts";
3838 import { listRepoRuns } from "../../actions/runs.ts";
39 import { readPlan } from "../../actions/run_plan.ts";
40 import type { IssuePlan } from "../../core/issue_plans.ts";
41 import { shownStates } from "../../runs/state.ts";
42 import { headline, planLines, planTitle } from "./runplan.ts";
3943 import { privateRepoForSealed } from "../../actions/repos.ts";
4044 import { confirmIssue } from "../../actions/dependencies.ts";
4145 import { isCrew } from "../../core/agents.ts";
@@ -162,6 +166,8 @@ export function issueScreen(
162166 let doneBy: readonly string[] = [];
163167 /** Closed-as-dropped-or-split blockers nobody has said may be passed. */
164168 let held: Issue[] = [];
169 let plan: IssuePlan | undefined;
170 let shown: ReturnType<typeof headline>;
165171 /** Which issues this one waits for and holds up; a line when any. */
166172 let waits: Waits = { blockedBy: [], blocks: [] };
167173 /** What the crew has done here, newest first; a card when there is any. */
@@ -211,6 +217,10 @@ export function issueScreen(
211217 blocks: deps?.blocks.map((i) => i.number) ?? [],
212218 };
213219 crew.set(runs !== undefined && runs.ok ? runs.value : []);
220 plan =
221 repo === undefined || issueRow === undefined ? undefined : readPlan(f.app, repo, f.user, issueRow);
222 const states = shownStates(f.db, runs !== undefined && runs.ok ? runs.value : []);
223 shown = headline(runs !== undefined && runs.ok ? runs.value.map((r) => states.get(r.id)!) : []);
214224 if (crew.runs.length === 0) focus = "thread";
215225 width = 0;
216226 ctx.invalidate();
@@ -576,15 +586,18 @@ export function issueScreen(
576586 // Then the crew card, only when an agent has worked here.
577587 const crewH =
578588 crew.runs.length === 0 ? 0 : Math.min(crew.runs.length, CREW_ROWS) + 2;
589 const planRows = planLines(plan);
590 const planH = planRows.length === 0 ? 0 : Math.min(planRows.length, CREW_ROWS + 2) + 2;
579591 const waitsLine = waitsParts(waits);
580592 const headH = waitsLine.length === 0 ? 3 : 4;
581 const [strip, head, waitRow, crewRow, body, box] = split(area, "v", [
593 const [strip, head, waitRow, crewRow, planRow, body, box] = split(area, "v", [
582594 len(whereRows(src)),
583595 len(headH),
584596 len(held.length === 0 ? 0 : 1),
585597 len(crewH),
598 len(planH),
586599 fill(1),
587 len(Math.min(foot, Math.max(0, area.h - 4 - headH - crewH - whereRows(src) - (held.length === 0 ? 0 : 1)))),
600 len(Math.min(foot, Math.max(0, area.h - 4 - headH - crewH - planH - whereRows(src) - (held.length === 0 ? 0 : 1)))),
588601 ]);
589602 whereStrip(s, strip!, c, f, src);
590603 const right = head!.x + head!.w;
@@ -676,6 +689,13 @@ export function issueScreen(
676689 });
677690 }
678691
692 if (planH > 0 && plan !== undefined) {
693 const inner = card(s, planRow!, c, { title: planTitle(plan, shown) });
694 planRows.slice(0, inner.h).forEach((row, i) => {
695 s.text(inner.x, inner.y + i, truncate(row, inner.w), style(c.fg, c.card), inner.x + inner.w);
696 });
697 }
698
679699 if (width !== body!.w) {
680700 width = body!.w;
681701 lines = threadLines(entries(), Math.max(20, body!.w - 1), c);
src/tui/screens/runplan.ts added +29
@@ -0,0 +1,29 @@
1 /**
2 * An issue's plan in the terminal (#8707): the checklist a crew run keeps and
3 * the state worked out from what it did, read through the same `readPlan` and
4 * `shownState` as the page, so a person sees where a run is without its log.
5 */
6
7 import type { IssuePlan } from "../../core/issue_plans.ts";
8 import type { ShownState } from "../../runs/state.ts";
9
10 /** The plan card's rows (plain text, truncated by the caller); none when there is no plan. */
11 export function planLines(plan: IssuePlan | undefined): string[] {
12 if (plan === undefined || (plan.items.length === 0 && plan.ask === "")) return [];
13 return [
14 ...plan.items.map((i) => `${i.done ? "[x]" : "[ ]"} ${i.text}`),
15 ...(plan.ask === "" ? [] : [`needs input: ${plan.ask}`]),
16 ];
17 }
18
19 /** The card's title: how far along, and the state when it is one a person should act on. */
20 export function planTitle(plan: IssuePlan, shown: ShownState | undefined): string {
21 const done = plan.items.filter((i) => i.done).length;
22 const says = shown === "silent" || shown === "needs input" ? ` · ${shown}` : "";
23 return `Plan · ${done} of ${plan.items.length} done${says}`;
24 }
25
26 /** The state to lead with for an issue's runs: the one that needs a person, else the newest run's. */
27 export function headline(shown: readonly ShownState[]): ShownState | undefined {
28 return shown.find((s) => s === "needs input") ?? shown.find((s) => s === "silent") ?? shown[0];
29 }
src/web/chrome.ts +5 −1
@@ -10,6 +10,7 @@
1010
1111 import { sshCommand } from "../ssh/command.ts";
1212 import { mayReadRuns } from "../actions/runs.ts";
13 import { runningCount } from "../core/runs.ts";
1314 import { getAppByRepo } from "../core/apps.ts";
1415 import { audienceFacts, audienceOf } from "../core/audience.ts";
1516 import { activeEdgeToken, edgeLoginUrl, edgeStrangerLogin } from "../http/edge.ts";
@@ -347,14 +348,17 @@ export function scopeHeader(
347348 // (`mayReadRuns`: a private app's are for those who may enter it), and
348349 // how to open the terminal (the TUI over SSH) for whoever has keys. A
349350 // repository only browsed from elsewhere has no runs here.
350 if (repo !== undefined && mayReadRuns(ctx.app, ctx.auth.user, repo))
351 if (repo !== undefined && mayReadRuns(ctx.app, ctx.auth.user, repo)) {
352 const running = runningCount(ctx.app.db, repo.id);
351353 items.push({
352354 label: "Runs",
353355 href: `${base}/runs`,
354356 icon: "activity",
357 ...(running > 0 ? { count: running } : {}),
355358 current: active === "runs",
356359 shortcut: "g r",
357360 });
361 }
358362 // What happened on the repository, as the reader may see it: the events
359363 // page, for any repository kept here.
360364 if (repo !== undefined)
src/web/pages/plan_card.ts added +25
@@ -0,0 +1,25 @@
1 /** An issue's plan, where the thread begins (#8707): the crew run's checklist, so a person sees where it is without a log. */
2
3 import { getIssuePlan } from "../../core/issue_plans.ts";
4 import type { Database } from "bun:sqlite";
5 import type { Issue } from "../../core/issues.ts";
6 import { html, type Html } from "../html.ts";
7 import { badge, listCard, row, time } from "../ui.ts";
8
9 /** The card, or nothing when the issue has no plan. A finished item and a question each say so in a word, not only in a mark. */
10 export function planCard(db: Database, issue: Issue): Html | null {
11 const plan = getIssuePlan(db, issue.id);
12 if (plan === undefined || (plan.items.length === 0 && plan.ask === "")) return null;
13 const done = plan.items.filter((i) => i.done).length;
14 const rows = [
15 ...(plan.ask === "" ? [] : [row({ title: plan.ask, trail: badge("Needs input", { tone: "attn" }) })]),
16 ...plan.items.map((i) =>
17 row({ title: i.text, trail: i.done ? badge("done", { tone: "success" }) : badge("to do", { variant: "outline" }) }),
18 ),
19 ];
20 return listCard(rows, {
21 id: "plan",
22 extra: "mt",
23 head: html`<span class="muted small">Plan · ${String(done)} of ${String(plan.items.length)} done · ${time(plan.updatedAt)}</span>`,
24 });
25 }
src/web/pages/thread.ts +2 −1
@@ -59,6 +59,7 @@ import type { Ctx } from "../../http/context.ts";
5959 import { commentCard } from "../comments.ts";
6060 import { backTo, field, readForm } from "../form.ts";
6161 import { html, raw, when, type Html } from "../html.ts";
62 import { planCard } from "./plan_card.ts";
6263 import { NOT_CHANGED } from "../edit_words.ts";
6364 import { flashOrJson, flashTo } from "../layout.ts";
6465 import {
@@ -397,7 +398,7 @@ export function conversation(
397398 node: html`<div class="timeline" id="conversation">
398399 ${opening}${when(options.editAction, (action) =>
399400 editForm(issue, action),
400 )}${earlier}${shown.map((e) => e.node)}
401 )}${planCard(ctx.app.db, issue)}${earlier}${shown.map((e) => e.node)}
401402 </div>`,
402403 lastCommentId: comments.length > 0 ? comments[comments.length - 1]!.id : "",
403404 };
src/web/studio/asks.ts +2 −2
@@ -1221,8 +1221,8 @@ export function askDismissedBy(db: Database, viewerId: string, issue: Issue): bo
12211221 issue.kind === "pull"
12221222 ? pullCause(db, issue)
12231223 : (db
1224 .query<{ at: string | null }, [string, string, string]>(
1225 "SELECT MAX(created_at) AS at FROM comment WHERE issue_id = ?1 AND body IN (?2, ?3)",
1224 .query<{ at: string | null }, [string, string, string, string]>(
1225 "SELECT MAX(created_at) AS at FROM comment WHERE issue_id = ?1 AND body IN (?2, ?3, ?4)",
12261226 )
12271227 .get(issue.id, ...WAITING_NOTES)?.at ?? issue.createdAt);
12281228 return at > cause;
src/web/studio/lists.ts +2 −1
@@ -92,7 +92,8 @@ const WORD_FILTERS: readonly WordFilter[] = [
9292 ];
9393
9494 function listBucket(word: Word): WordFilter {
95 if (word === "waiting" || word === "planned") return "working";
95 if (word === "waiting" || word === "planned" || word === "silent" || word === "needs input")
96 return "working";
9697 if (word === "done") return "shipped";
9798 if (word === "cancelled") return "stopped";
9899 return word;
src/web/studio/words.ts +12 −3
@@ -8,6 +8,7 @@
88 * thing about the same commit. Nothing here knows about a page.
99 */
1010
11 import { shownState } from "../../runs/state.ts";
1112 import type { Database } from "bun:sqlite";
1213 import { ancestries, type AncestryCache } from "./ancestry.ts";
1314 import {
@@ -64,7 +65,9 @@ export type Word =
6465 | "planned"
6566 | "told"
6667 | "done"
67 | "cancelled";
68 | "cancelled"
69 | "silent"
70 | "needs input";
6871
6972 /**
7073 * Production's history as one page has asked it: the ancestry of each
@@ -413,7 +416,13 @@ export async function runWord(
413416 return run.notBefore === null
414417 ? { word: "waiting", note: "queued" }
415418 : { word: "waiting", note: "paused, resumes soon" };
416 if (run.state === "running") return { word: "working", note: "now" };
419 if (run.state === "running") {
420 // Worked out from what the run did, never what it says (`runs/state.ts`).
421 const shown = shownState(ctx.db, run);
422 if (shown === "silent") return { word: "silent", note: "no sign of life" };
423 if (shown === "needs input") return { word: "needs input", note: "waiting on a person" };
424 return { word: "working", note: "now" };
425 }
417426 if (run.state === "failed")
418427 return { word: "stopped", note: run.summary || run.state };
419428 // A cancelled run is not a broken one — superseded by a newer push, its
@@ -534,7 +543,7 @@ export function wordBadge(word: Word, id?: string): Html {
534543 const variant =
535544 word === "look" || word === "shipped" || word === "done"
536545 ? "success"
537 : word === "stopped"
546 : word === "stopped" || word === "silent"
538547 ? "danger"
539548 : word === "working"
540549 ? "accent"
test/brief-plan.test.ts added +23
@@ -0,0 +1,23 @@
1 import { describe, expect, test } from "bun:test";
2 import { taskPrompt, type TaskInput } from "../src/crew/brief.ts";
3
4 const build = (name: string): string =>
5 taskPrompt({
6 agent: { user: { username: "bot", displayName: "" }, brief: "" },
7 repo: { ownerName: "alice", name, defaultBranch: "main" },
8 issue: { number: 1, title: "Fix the footer", body: "It overlaps.", authorName: "alice" },
9 comments: [],
10 reviews: [],
11 reason: "assigned",
12 branch: "bot/1",
13 forgeUrl: "http://forge.test",
14 } as unknown as TaskInput);
15
16 describe("a forge task brief tells the agent to keep a plan", () => {
17 test("the forge's own brief names keep_plan", () => {
18 expect(build("forge")).toContain("`keep_plan`");
19 });
20 test("another repository's brief does not", () => {
21 expect(build("notes")).not.toContain("keep_plan");
22 });
23 });
test/cold-home-queries.test.ts +1 −1
@@ -50,7 +50,7 @@ test("breakerParked: parked by its newest note only, open only", () => {
5050 ROW_NUMBER() OVER (PARTITION BY i.id ORDER BY c.created_at DESC, c.id DESC) AS rn
5151 FROM issue i JOIN comment c ON c.issue_id = i.id
5252 WHERE i.state = 'open' AND i.repo_id IN (?1)
53 ) WHERE rn = 1 AND body IN (?2, ?3)`,
53 ) WHERE rn = 1 AND body IN (?2, ?3, ?4)`,
5454 )
5555 .all(repoId, ...WAITING_NOTES);
5656 const now = breakerParked(f.app.db, [repoId]);
test/conversation-confirm.test.ts +1 −1
@@ -123,7 +123,7 @@ describe("the one list of what asks first", () => {
123123 "access", "account_verify", "app_members", "app_notes", "asks_act", "asks_decide", "asks_proposal", "asks_undecide",
124124 "assign", "attach_look", "board_pins", "board_write", "branches", "capacity", "valves", "cloud_accounts", "close", "confirm_wait",
125125 "crew", "decisions_by", "detach", "edit", "email_verify", "feed_chips", "feed_dismiss", "flag_set", "flag_winner",
126 "follow_mode", "front_page", "hold", "issue_teams", "labels", "lift", "limb", "make_it_yours", "map", "merge", "new_app",
126 "follow_mode", "front_page", "hold", "issue_teams", "keep_plan", "labels", "lift", "limb", "make_it_yours", "map", "merge", "new_app",
127127 "next_up", "notify", "org_create", "origin_apps", "pins", "plan", "profile", "propose_to_parent_forge", "repo_labels", "reports",
128128 "restore_drill", "review", "say", "signup", "snapshot_now", "spend", "steer", "sync", "take_back", "take_updates",
129129 "teams", "tell", "threads", "trail", "upstream", "variant", "wait_for", "watch", "whoami_can", "write_file",
test/crew-guard.test.ts +6
@@ -105,6 +105,12 @@ function ran(
105105 summary: state === "ok" ? "answered" : "declined: no",
106106 costUsd: 0.5,
107107 });
108 // Runs on one issue are spread past the repeat stop's ten minutes (#8707), so
109 // the cap and the breaker, which these tests are about, are what they meet.
110 const before = runsOn(issue).length - 1;
111 app.db
112 .query("UPDATE run SET created_at = ?1 WHERE id = ?2")
113 .run(new Date(Date.now() - before * 11 * 60_000).toISOString(), run.id);
108114 if (reply !== undefined) ok(addComment(app, bot.user, repo, issue, reply));
109115 }
110116
test/mcp.test.ts +1
@@ -243,6 +243,7 @@ describe("the forge over MCP", () => {
243243 "secrets",
244244 "issue_teams",
245245 "issues",
246 "keep_plan",
246247 "orgs",
247248 "now",
248249 "org_create",
test/run-state.test.ts added +253
@@ -0,0 +1,253 @@
1 /**
2 * A crew run's state is worked out from what it did (#8707): one function over
3 * its activity names it waiting, working, needs input, failed, done or silent;
4 * a silent run and a repeating issue each raise one alarm, the repeat stops;
5 * a plan checklist is replaced whole on the issue; the runs tab counts what the
6 * pipeline counts; and the runs API filters by `state`.
7 */
8 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
9 import { mkdtemp, rm } from "node:fs/promises";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { createApp, type App } from "../src/app.ts";
13 import { addToCrew, createAgent } from "../src/actions/crew.ts";
14 import { addComment, openIssue } from "../src/actions/issues.ts";
15 import { createRepo, updateRepoSettings } from "../src/actions/repos.ts";
16 import { setPlan } from "../src/actions/run_plan.ts";
17 import { getAgent, type Agent } from "../src/core/agents.ts";
18 import { getIssuePlan } from "../src/core/issue_plans.ts";
19 import { getIssueById, listComments, type Issue } from "../src/core/issues.ts";
20 import { getRepoById, type Repo } from "../src/core/repos.ts";
21 import { claimRun, createRun, finishRun, getRun, runningCount, type Run } from "../src/core/runs.ts";
22 import { createToken } from "../src/core/tokens.ts";
23 import { createUser, getUserByName, type User } from "../src/core/users.ts";
24 import { queueAgentRun } from "../src/crew/dispatch.ts";
25 import { REPEAT_NOTE, repeatTripped } from "../src/crew/repeat.ts";
26 import { firedAlarms } from "../src/flow/alarms.ts";
27 import { readPipeline } from "../src/flow/pipeline.ts";
28 import { serve, type Servers } from "../src/http/server.ts";
29 import { noteActivity } from "../src/runs/activity.ts";
30 import { runState, SILENT_MS, shownState } from "../src/runs/state.ts";
31 import { quietLogs } from "./helpers/globals.ts";
32
33 quietLogs();
34
35 let dataDir: string;
36 let app: App;
37 let servers: Servers;
38 let alice: User;
39 let mallory: User;
40 let bot: Agent;
41 let repo: Repo;
42 let made = 0;
43
44 const ok = <T>(r: { ok: true; value: T } | { ok: false; error: unknown }): T => {
45 if (!r.ok) throw new Error(JSON.stringify(r.error));
46 return r.value;
47 };
48 const fresh = (): Repo => getRepoById(app.db, repo.id)!;
49 const ago = (ms: number) => new Date(Date.now() - ms).toISOString();
50
51 function held(title: string): Issue {
52 const issue = ok(openIssue(app, alice, fresh(), { title: `${title} ${++made}` }));
53 app.db.query("UPDATE issue SET assignee_id = ?1 WHERE id = ?2").run(bot.user.id, issue.id);
54 return getIssueById(app.db, issue.id)!;
55 }
56
57 /** An agent run of the bot on `issue`, left in `state`, started `startedAgo` ms ago. */
58 function runOn(issue: Issue, state: "queued" | "running" | "ok" | "failed", startedAgo = 0): Run {
59 const run = createRun(app.db, { repoId: repo.id, kind: "agent", issueId: issue.id, actorId: bot.user.id, reason: "assigned" });
60 if (state !== "queued") claimRun(app.db, run.id);
61 if (state === "ok" || state === "failed") finishRun(app.db, run.id, { state, summary: "x", costUsd: 0 });
62 if (startedAgo > 0)
63 app.db.query("UPDATE run SET started_at = ?1, created_at = ?1 WHERE id = ?2").run(ago(startedAgo), run.id);
64 return getRun(app.db, run.id)!;
65 }
66
67 beforeAll(async () => {
68 dataDir = await mkdtemp(join(tmpdir(), "forge-run-state-"));
69 app = createApp({ dataDir, port: 0 });
70 servers = await serve(app);
71 alice = ok(await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true }));
72 mallory = ok(await createUser(app.db, { origin: "seed", trusted: true, username: "mallory", email: "[email protected]", password: "a-good-passphrase" }));
73 ok(await createAgent(app, alice, { username: "bot" }));
74 repo = ok(await createRepo(app, alice, { name: "square", visibility: "public" }));
75 ok(await updateRepoSettings(app, alice, repo, { autonomy: "assisted", autonomyDirection: "keep the square", autonomyBy: alice.id }));
76 expect(addToCrew(app, alice, repo, "bot").ok).toBe(true);
77 bot = getAgent(app.db, getUserByName(app.db, "bot")!.id)!;
78 }, 30_000);
79
80 afterAll(async () => {
81 servers?.stop();
82 app?.close();
83 await rm(dataDir, { recursive: true, force: true });
84 });
85
86 describe("one function names a run's state from what it did", () => {
87 const now = new Date();
88 test("waiting: queued, not started", () => {
89 expect(runState(runOn(held("a"), "queued"), { now })).toBe("waiting");
90 });
91 test("working: running and heard from", () => {
92 expect(runState(runOn(held("b"), "running"), { now })).toBe("working");
93 });
94 test("needs input: its plan asks a person", () => {
95 const issue = held("c");
96 const run = runOn(issue, "running");
97 ok(setPlan(app, bot.user, repo, issue, { items: [{ text: "pick a colour" }], ask: "Which colour?" }));
98 expect(shownState(app.db, getRun(app.db, run.id)!)).toBe("needs input");
99 });
100 test("failed", () => {
101 expect(runState(runOn(held("d"), "failed"), { now })).toBe("failed");
102 });
103 test("done", () => {
104 expect(runState(runOn(held("e"), "ok"), { now })).toBe("done");
105 });
106 test("silent: running, nothing seen for the set time; any sign of life lifts it", () => {
107 const run = runOn(held("f"), "running", SILENT_MS + 60_000);
108 expect(runState(run, { now })).toBe("silent");
109 noteActivity(run.id);
110 expect(runState(run, { now })).toBe("working");
111 });
112 });
113
114 describe("silence raises one alarm", () => {
115 test("two silent runs are one alarm, naming the one silent longest; the shown state is on the API row", async () => {
116 const older = runOn(held("g"), "running", 3 * SILENT_MS);
117 runOn(held("h"), "running", 2 * SILENT_MS);
118 const silent = firedAlarms(app.db, new Date()).filter((a) => a.key === "silent");
119 expect(silent).toHaveLength(1);
120 expect(silent[0]!.href).toEndWith(`/runs/${older.id}`);
121 const token = createToken(app.db, alice.id, "t", "ro").token;
122 const res = await fetch(`${servers.url}/api/v1/repos/alice/square/runs?state=running`, { headers: { authorization: `Bearer ${token}` } });
123 const rows = ((await res.json()) as { items?: { id: string; shown: string }[] }).items ?? [];
124 expect(rows.find((r) => r.id === older.id)?.shown).toBe("silent");
125 });
126 });
127
128 describe("the same issue repeating runs is stopped", () => {
129 test("the 2026-11-02 sequence: runs that each succeeded without a pull, in minutes, trip it once", () => {
130 const issue = held("Empty pulls");
131 for (let n = 0; n < 3; n++) runOn(issue, "ok");
132 expect(repeatTripped(app.db, fresh(), issue.id)).toBe(false);
133 runOn(issue, "ok");
134 expect(repeatTripped(app.db, fresh(), issue.id)).toBe(true);
135 // The next start is refused, the issue parked once, and one alarm stands.
136 expect(queueAgentRun(app, fresh(), issue, bot, "assigned", alice.id)).toBeUndefined();
137 expect(queueAgentRun(app, fresh(), issue, bot, "assigned", alice.id)).toBeUndefined();
138 expect(listComments(app.db, issue.id).filter((c) => c.body === REPEAT_NOTE)).toHaveLength(1);
139 expect(getIssueById(app.db, issue.id)!.assigneeId).toBeNull();
140 expect(firedAlarms(app.db, new Date()).filter((a) => a.key === "repeat")).toHaveLength(1);
141 });
142 test("runs spread beyond ten minutes do not trip it; a reader's word does not lift it, a writer's does", async () => {
143 const slow = held("Slow");
144 for (let n = 0; n < 5; n++) {
145 const run = runOn(slow, "ok");
146 app.db.query("UPDATE run SET created_at = ?1 WHERE id = ?2").run(ago((n + 1) * 5 * 60_000), run.id);
147 }
148 expect(repeatTripped(app.db, fresh(), slow.id)).toBe(false);
149 const issue = held("Loop");
150 for (let n = 0; n < 4; n++) runOn(issue, "ok");
151 await Bun.sleep(5);
152 ok(addComment(app, mallory, repo, issue, "Go on."));
153 expect(repeatTripped(app.db, fresh(), issue.id)).toBe(true);
154 ok(addComment(app, alice, repo, issue, "Try the other way."));
155 expect(repeatTripped(app.db, fresh(), issue.id)).toBe(false);
156 });
157 });
158
159 describe("a plan is replaced whole on its issue", () => {
160 test("the second update leaves only itself; a stranger may not set it; a bad shape is refused", () => {
161 const issue = held("Plan me");
162 const run = runOn(issue, "running");
163 ok(setPlan(app, bot.user, repo, issue, { items: [{ text: "read" }, { text: "write" }] }));
164 expect(getIssuePlan(app.db, issue.id)!.runId).toBe(run.id);
165 ok(setPlan(app, bot.user, repo, issue, { items: [{ text: "read", done: true }] }));
166 expect(getIssuePlan(app.db, issue.id)!.items).toEqual([{ text: "read", done: true }]);
167 expect(setPlan(app, mallory, repo, issue, { items: [] })).toEqual({ ok: false, error: "forbidden" });
168 expect(setPlan(app, bot.user, repo, issue, { items: "nope" })).toEqual({ ok: false, error: "invalid" });
169 expect(setPlan(app, bot.user, repo, issue, { items: Array.from({ length: 13 }, () => ({ text: "x" })) })).toEqual({ ok: false, error: "invalid" });
170 });
171 test("only the agent with a run going on the issue keeps its plan: a writer, or a run on another issue, is refused", () => {
172 const issue = held("Not yours");
173 runOn(held("Elsewhere"), "running");
174 expect(setPlan(app, bot.user, repo, issue, { items: [{ text: "x" }] })).toEqual({ ok: false, error: "forbidden" });
175 expect(setPlan(app, alice, repo, issue, { items: [{ text: "x" }] })).toEqual({ ok: false, error: "forbidden" });
176 expect(getIssuePlan(app.db, issue.id)).toBeUndefined();
177 runOn(issue, "ok");
178 expect(setPlan(app, bot.user, repo, issue, { items: [{ text: "x" }] })).toEqual({ ok: false, error: "forbidden" });
179 });
180 test("it is set over the API and read on the issue and its page", async () => {
181 const issue = held("Plan over HTTP");
182 runOn(issue, "running");
183 const token = createToken(app.db, bot.user.id, "t", "rw").token;
184 const url = `${servers.url}/api/v1/repos/alice/square/issues/${issue.number}/plan`;
185 const put = await fetch(url, { method: "PUT", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, body: JSON.stringify({ items: [{ text: "<b>one</b>", done: true }, { text: "two" }] }) });
186 expect(put.status).toBe(200);
187 const read = (await (await fetch(`${servers.url}/api/v1/repos/alice/square/issues/${issue.number}`, { headers: { authorization: `Bearer ${token}` } })).json()) as { plan: { items: unknown[] } };
188 expect(read.plan.items).toHaveLength(2);
189 const bad = await fetch(url, { method: "PUT", headers: { authorization: `Bearer ${token}`, "content-type": "application/json" }, body: JSON.stringify({ items: [{ done: true }] }) });
190 expect(bad.status).toBe(422);
191 const html = await (await fetch(`${servers.url}/alice/square/issues/${issue.number}`, { headers: { authorization: `Bearer ${token}` } })).text();
192 expect(html).toContain("1 of 2 done");
193 expect(html).not.toContain("<b>one</b>");
194 });
195 });
196
197 describe("the runs tab counts what is really running", () => {
198 test("running agent runs and checks, as the pipeline counts them; queued, paused and finished are not", () => {
199 createRun(app.db, { repoId: repo.id, kind: "agent", reason: "assigned" });
200 const check = createRun(app.db, { repoId: repo.id, kind: "check", reason: "push" });
201 claimRun(app.db, check.id);
202 const deploy = createRun(app.db, { repoId: repo.id, kind: "deploy", env: "production" });
203 claimRun(app.db, deploy.id);
204 const stages = readPipeline(app.db);
205 const piped = stages.filter((s) => ["building", "review", "checking"].includes(s.key)).reduce((n, s) => n + s.active, 0);
206 expect(runningCount(app.db, repo.id)).toBe(piped);
207 expect(piped).toBeGreaterThan(0);
208 });
209 });
210
211 describe("the new reads walk an index", () => {
212 const plan = (sql: string, ...args: string[]) =>
213 app.db.query(`EXPLAIN QUERY PLAN ${sql}`).all(...args).map((r) => (r as { detail: string }).detail);
214 test("the running count, the silent scan and the repeat count", () => {
215 const lines = [
216 ...plan("SELECT COUNT(*) FROM run WHERE state = 'running' AND repo_id = ?1 AND kind IN ('agent','check')", "r"),
217 ...plan("SELECT x.id FROM run x WHERE x.state = 'running' AND x.kind = 'agent'"),
218 ...plan("SELECT created_at FROM run WHERE issue_id = ?1 AND kind = 'agent' AND created_at > ?2 ORDER BY created_at LIMIT 4", "i", "t"),
219 ];
220 for (const line of lines) {
221 expect(line).not.toMatch(/^SCAN (?!.*USING (COVERING )?INDEX)/);
222 }
223 });
224 });
225
226 describe("the runs API filters by state", () => {
227 test("state=failed lists only failed runs; an unknown state is refused, not ignored", async () => {
228 runOn(held("broken"), "failed");
229 const token = createToken(app.db, alice.id, "t", "ro").token;
230 const get = (q: string) => fetch(`${servers.url}/api/v1/repos/alice/square/runs${q}`, { headers: { authorization: `Bearer ${token}` } });
231 const failed = ((await (await get("?state=failed")).json()) as { items: { state: string }[] }).items;
232 expect(failed.length).toBeGreaterThan(0);
233 expect(failed.every((r) => r.state === "failed")).toBe(true);
234 expect((await get("?state=sideways")).status).toBe(422);
235 });
236 });
237
238 describe("the terminal shows the plan and the state the page does", () => {
239 test("rows, title and the state worth leading with", async () => {
240 const { planLines, planTitle, headline } = await import("../src/tui/screens/runplan.ts");
241 const issue = held("Terminal");
242 const run = runOn(issue, "running");
243 ok(setPlan(app, bot.user, repo, issue, { items: [{ text: "read", done: true }, { text: "write" }], ask: "Which colour?" }));
244 const plan = getIssuePlan(app.db, issue.id)!;
245 expect(planLines(plan)).toEqual(["[x] read", "[ ] write", "needs input: Which colour?"]);
246 const { shownStates } = await import("../src/runs/state.ts");
247 const shown = shownStates(app.db, [getRun(app.db, run.id)!]).get(run.id);
248 expect(shown).toBe("needs input");
249 expect(planTitle(plan, shown)).toBe("Plan · 1 of 2 done · needs input");
250 expect(headline(["working", "silent", "done"])).toBe("silent");
251 expect(planLines(undefined)).toEqual([]);
252 });
253 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs