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
Check · Review · Land · Live done — Landed and live in production.
scout/forge:scout/8707-a-crew-run-s-state-is-worked-out-from-wh → main 8 commits fork
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"; | ||
| 33 | 33 | import { queueAgentRun, taskOf } from "../crew/dispatch.ts"; |
| 34 | 34 | import { err, ok, type Result } from "../kernel/result.ts"; |
| 35 | 35 | import { readRunLog, readRunLogTail, runLogPath } from "../runs/log.ts"; |
| 36 | import { shownStates, type ShownState } from "../runs/state.ts"; | |
| 36 | 37 | import { firstErrorLine } from "../runs/error_line.ts"; |
| 37 | 38 | import { requireReader, requireWriter, type AccessError } from "./access.ts"; |
| 38 | 39 | |
| @@ -171,6 +172,8 @@ export function listRepoRuns( | ||
| 171 | 172 | |
| 172 | 173 | /** A run as the runs API lists it: the agent's name, and the pull or issue it is about. */ |
| 173 | 174 | export type NamedRun = Run & { |
| 175 | /** The state to show, worked out from what the run did (`runs/state.ts`). */ | |
| 176 | readonly shown: ShownState; | |
| 174 | 177 | readonly agent: string | null; |
| 175 | 178 | readonly number: number | null; |
| 176 | 179 | readonly title: string | null; |
| @@ -192,10 +195,12 @@ export function nameRuns(app: App, runs: readonly Run[]): NamedRun[] { | ||
| 192 | 195 | app.db, |
| 193 | 196 | runs.flatMap((r) => (r.issueId === null ? [] : [r.issueId])), |
| 194 | 197 | ); |
| 198 | const shown = shownStates(app.db, runs); | |
| 195 | 199 | return runs.map((r) => { |
| 196 | 200 | const issue = r.issueId === null ? undefined : issues.get(r.issueId); |
| 197 | 201 | return { |
| 198 | 202 | ...r, |
| 203 | shown: shown.get(r.id) ?? "waiting", | |
| 199 | 204 | agent: r.kind === "agent" && r.actorId !== null ? (actors.get(r.actorId)?.username ?? null) : null, |
| 200 | 205 | number: issue?.number ?? null, |
| 201 | 206 | 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 @@ | ||
| 1 | 1 | import { REPO, type Route } from "../shared.ts"; |
| 2 | 2 | |
| 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 | ||
| 112 | 112 | export { route as getApiV1ReposOwnerNameIssuesNumber } from "./getApiV1ReposOwnerNameIssuesNumber.ts"; |
| 113 | 113 | export { route as getApiV1ReposOwnerNameIssuesNumberComments } from "./getApiV1ReposOwnerNameIssuesNumberComments.ts"; |
| 114 | 114 | export { route as getApiV1ReposOwnerNameIssuesNumberDependencies } from "./getApiV1ReposOwnerNameIssuesNumberDependencies.ts"; |
| 115 | export { route as getApiV1ReposOwnerNameIssuesNumberPlan } from "./getApiV1ReposOwnerNameIssuesNumberPlan.ts"; | |
| 115 | 116 | export { route as getApiV1ReposOwnerNameIssuesNumberTeams } from "./getApiV1ReposOwnerNameIssuesNumberTeams.ts"; |
| 116 | 117 | export { route as getApiV1ReposOwnerNameLabels } from "./getApiV1ReposOwnerNameLabels.ts"; |
| 117 | 118 | export { route as getApiV1ReposOwnerNameMap } from "./getApiV1ReposOwnerNameMap.ts"; |
| @@ -308,6 +309,7 @@ export { route as putApiV1ReposOwnerNameAppMembers } from "./putApiV1ReposOwnerN | ||
| 308 | 309 | export { route as putApiV1ReposOwnerNameCrewUsername } from "./putApiV1ReposOwnerNameCrewUsername.ts"; |
| 309 | 310 | export { route as putApiV1ReposOwnerNameIssuesNumberAssignee } from "./putApiV1ReposOwnerNameIssuesNumberAssignee.ts"; |
| 310 | 311 | export { route as putApiV1ReposOwnerNameIssuesNumberLabels } from "./putApiV1ReposOwnerNameIssuesNumberLabels.ts"; |
| 312 | export { route as putApiV1ReposOwnerNameIssuesNumberPlan } from "./putApiV1ReposOwnerNameIssuesNumberPlan.ts"; | |
| 311 | 313 | export { route as putApiV1ReposOwnerNameIssuesNumberTeams } from "./putApiV1ReposOwnerNameIssuesNumberTeams.ts"; |
| 312 | 314 | export { route as putApiV1ReposOwnerNameMilestonesNumberWatch } from "./putApiV1ReposOwnerNameMilestonesNumberWatch.ts"; |
| 313 | 315 | 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 | ||
| 1389 | 1389 | .get(runId); |
| 1390 | 1390 | return row === null || row === undefined || row.error_line === "" ? undefined : { text: row.error_line, at: row.error_at }; |
| 1391 | 1391 | } |
| 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"; | ||
| 106 | 106 | import { weekFor } from "./week_read.ts"; |
| 107 | 107 | import { announceGovernor, governorFor } from "./governed.ts"; |
| 108 | 108 | import { BREAKER_NOTE, breakerTripped } from "./breaker.ts"; |
| 109 | import { REPEAT_NOTE } from "./repeat_note.ts"; | |
| 109 | 110 | import { given, pendingReplans } from "../core/limb_replan.ts"; |
| 110 | 111 | import { handedOverPulls, SKIP_SUMMARY, skipsReplan } from "./guard.ts"; |
| 111 | 112 | import { atRunCap, type Held, heldBy, heldWords, replannable } from "./held.ts"; |
| @@ -991,7 +992,7 @@ export function parkForBreaker( | ||
| 991 | 992 | const last = listComments(app.db, issue.id).at(-1); |
| 992 | 993 | if (last?.body === note) return false; |
| 993 | 994 | 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", { | |
| 995 | 996 | repo: `${repo.ownerName}/${repo.name}`, |
| 996 | 997 | issue: issue.number, |
| 997 | 998 | }); |
src/crew/breaker.ts +4 −3
| @@ -9,6 +9,7 @@ import type { Database } from "bun:sqlite"; | ||
| 9 | 9 | import type { Repo } from "../core/repos.ts"; |
| 10 | 10 | import { CAP_NOTE } from "./guard.ts"; |
| 11 | 11 | import { directorsAmong } from "./direct.ts"; |
| 12 | import { REPEAT_NOTE } from "./repeat_note.ts"; | |
| 12 | 13 | |
| 13 | 14 | export const BREAKER_RUNS = 3; |
| 14 | 15 | export const BREAKER_WINDOW_MS = 60 * 60_000; |
| @@ -51,7 +52,7 @@ export function breakerTripped( | ||
| 51 | 52 | * agent's, not a reader's, not one imported from an origin, not the crew's |
| 52 | 53 | * own parking note (#619). |
| 53 | 54 | */ |
| 54 | function spokenSince( | |
| 55 | export function spokenSince( | |
| 55 | 56 | db: Database, |
| 56 | 57 | repo: Repo, |
| 57 | 58 | issueId: string, |
| @@ -69,7 +70,7 @@ function spokenSince( | ||
| 69 | 70 | } |
| 70 | 71 | |
| 71 | 72 | /** 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; | |
| 73 | 74 | |
| 74 | 75 | /** Open issues whose newest comment is one of `WAITING_NOTES`: the ones that wait for a person. */ |
| 75 | 76 | export function breakerParked( |
| @@ -84,7 +85,7 @@ export function breakerParked( | ||
| 84 | 85 | `SELECT i.id AS issueId, i.repo_id AS repoId, c.created_at AS at |
| 85 | 86 | FROM issue i JOIN comment c ON c.id = (SELECT id FROM comment WHERE issue_id = i.id |
| 86 | 87 | 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(", ")})`, | |
| 88 | 89 | ) |
| 89 | 90 | .all(...repoIds, ...WAITING_NOTES); |
| 90 | 91 | } |
src/crew/brief.ts +5
| @@ -338,6 +338,10 @@ export function taskPrompt(input: TaskInput): string { | ||
| 338 | 338 | const MCP_SIZE_RULE = |
| 339 | 339 | "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."; |
| 340 | 340 | |
| 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 | ||
| 341 | 345 | /** The forge's own repository: its briefs name paths in the forge's tree, not an app scaffold's. */ |
| 342 | 346 | function isForge(repo: { readonly name: string }): boolean { |
| 343 | 347 | return repo.name === "forge"; |
| @@ -418,6 +422,7 @@ function taskBody(input: TaskInput): string { | ||
| 418 | 422 | ); |
| 419 | 423 | if (input.pull === undefined && isForge(input.repo) && namesMcp(issue.title, issue.body)) |
| 420 | 424 | out.push("", MCP_SIZE_RULE); |
| 425 | if (input.pull === undefined && isForge(input.repo)) out.push("", PLAN_RULE); | |
| 421 | 426 | out.push( |
| 422 | 427 | "", |
| 423 | 428 | `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"; | ||
| 71 | 71 | import { detectClaude } from "./claude.ts"; |
| 72 | 72 | import { followDivergence, settleAll, settleOnPush } from "./converge.ts"; |
| 73 | 73 | import { breakerTripped } from "./breaker.ts"; |
| 74 | import { REPEAT_NOTE, repeatTripped } from "./repeat.ts"; | |
| 74 | 75 | import { unstickOwed } from "./stalls.ts"; |
| 75 | 76 | import { |
| 76 | 77 | CAP_NOTE, |
| @@ -416,6 +417,11 @@ export function queueAgentRun( | ||
| 416 | 417 | parkForBreaker(app, repo, issue, agent); |
| 417 | 418 | return undefined; |
| 418 | 419 | } |
| 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 | } | |
| 419 | 425 | // A day's share of runs on one issue without a person's word: a loop, |
| 420 | 426 | // parked once and asked about once (`guard.ts`). A run carrying on after |
| 421 | 427 | // 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"; | ||
| 14 | 14 | import { alarmIdleCrew } from "./alarm_idle_crew.ts"; |
| 15 | 15 | import { noReleaseAlarm } from "./alarm_no_release.ts"; |
| 16 | 16 | import { redMainAlarm } from "./alarm_red_main.ts"; |
| 17 | import { alarmRepeat } from "./alarm_repeat.ts"; | |
| 18 | import { alarmSilent } from "./alarm_silent.ts"; | |
| 17 | 19 | import { alarmStuck } from "./alarm_stuck.ts"; |
| 18 | 20 | |
| 19 | 21 | 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"; | |
| 21 | 23 | readonly says: string; |
| 22 | 24 | readonly since: string; |
| 23 | 25 | /** A same-origin path to the item the alarm names. */ |
| @@ -48,6 +50,10 @@ export function firedAlarms( | ||
| 48 | 50 | out.push({ key: "stuck", says: stuck.says, since: stuck.since!, href: `/${stuck.repo}/pulls/${stuck.number}` }); |
| 49 | 51 | } |
| 50 | 52 | 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}` }); | |
| 51 | 57 | const idle = alarmIdleCrew(db, now, { ceilingUsd: opts.ceilingUsd ?? null }); |
| 52 | 58 | if (idle.fired) out.push({ key: "idle_crew", says: idle.says, since: idle.since!, href: "/boards" }); |
| 53 | 59 | } |
src/http/api.ts +6
| @@ -255,6 +255,7 @@ import { | ||
| 255 | 255 | isTerminal, |
| 256 | 256 | latestRunsForIssues, |
| 257 | 257 | listActiveRuns, |
| 258 | RUN_STATES, | |
| 258 | 259 | type Run, |
| 259 | 260 | } from "../core/runs.ts"; |
| 260 | 261 | import { checkStale } from "../checks/stale.ts"; |
| @@ -3573,6 +3574,10 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 3573 | 3574 | const q = ctx.url.searchParams; |
| 3574 | 3575 | const kind = q.get("kind"); |
| 3575 | 3576 | 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(", ")}`); | |
| 3576 | 3581 | const limit = Math.min(Math.max(Number(q.get("limit")) || 50, 1), 200); |
| 3577 | 3582 | // Already as this reader may see them (`runForReader`): a stranger's |
| 3578 | 3583 | // runs carry no cost, turns, agent session or node. Each row also names |
| @@ -3582,6 +3587,7 @@ export function apiRoutes(router: Router<Handler>): void { | ||
| 3582 | 3587 | const listed = listRepoRuns(ctx.app, ctx.auth.user, repo, { |
| 3583 | 3588 | ...(kind === "deploy" || kind === "agent" ? { kind } : {}), |
| 3584 | 3589 | ...(env !== null && isEnvName(env) ? { env } : {}), |
| 3590 | ...(state === undefined ? {} : { state }), | |
| 3585 | 3591 | ...(q.get("cursor") ? { cursor: q.get("cursor")! } : {}), |
| 3586 | 3592 | limit, |
| 3587 | 3593 | }); |
src/http/api/issues.ts +21
| @@ -17,6 +17,7 @@ import { | ||
| 17 | 17 | plannerCloseRefusal, |
| 18 | 18 | setIssueStateThrough, |
| 19 | 19 | } from "../../actions/issues.ts"; |
| 20 | import { PLAN_SHAPE, readPlan, setPlan } from "../../actions/run_plan.ts"; | |
| 20 | 21 | import { privateRepoForSealed } from "../../actions/repos.ts"; |
| 21 | 22 | import { dependenciesOf } from "../../core/dependencies.ts"; |
| 22 | 23 | import { teamsOfIssue } from "../../core/issue_teams.ts"; |
| @@ -177,6 +178,25 @@ export function issueRoutes(router: Router<Handler>): void { | ||
| 177 | 178 | return json(withLabels(ctx, created.value), 201); |
| 178 | 179 | }); |
| 179 | 180 | |
| 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 | ||
| 180 | 200 | router.add("GET", "/api/v1/repos/:owner/:name/issues/:number", (ctx) => { |
| 181 | 201 | // Numbers are shared with pulls: a pull's number answers as the same |
| 182 | 202 | // shape, marked `kind: "pull"`, with the path where its diff lives (#3299). |
| @@ -212,6 +232,7 @@ export function issueRoutes(router: Router<Handler>): void { | ||
| 212 | 232 | pull: `/api/v1/repos/${repo.ownerName}/${repo.name}/pulls/${issue.number}`, |
| 213 | 233 | } |
| 214 | 234 | : {}), |
| 235 | plan: readPlan(ctx.app, found.repo, ctx.auth.user, issue) ?? null, | |
| 215 | 236 | comments: shown, |
| 216 | 237 | commentsTotal: total, |
| 217 | 238 | 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"; | ||
| 73 | 73 | export { tool as issue } from "./issue.ts"; |
| 74 | 74 | export { tool as issue_teams } from "./issue_teams.ts"; |
| 75 | 75 | export { tool as issues } from "./issues.ts"; |
| 76 | export { tool as keep_plan } from "./keep_plan.ts"; | |
| 76 | 77 | export { tool as labels } from "./labels.ts"; |
| 77 | 78 | export { tool as land_past_gate } from "./land_past_gate.ts"; |
| 78 | 79 | 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 { | ||
| 21 | 21 | } from "node:fs"; |
| 22 | 22 | import { join } from "node:path"; |
| 23 | 23 | import { redactHeld } from "../kernel/log.ts"; |
| 24 | import { noteActivity } from "./activity.ts"; | |
| 24 | 25 | |
| 25 | 26 | export const runsRoot = (dataDir: string): string => join(dataDir, "runs"); |
| 26 | 27 | |
| @@ -84,6 +85,7 @@ export function appendRunLog( | ||
| 84 | 85 | const clean = redactHeld(line.replace(/\r?\n/g, " ")).slice(0, MAX_LINE); |
| 85 | 86 | mkdirSync(runsRoot(dataDir), { recursive: true }); |
| 86 | 87 | appendFileSync(runLogPath(dataDir, runId), `${clean}\n`); |
| 88 | noteActivity(runId); | |
| 87 | 89 | for (const tap of taps) { |
| 88 | 90 | try { |
| 89 | 91 | 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"; | ||
| 36 | 36 | import { teamsOfIssue, TEAMS_MAX } from "../../core/issue_teams.ts"; |
| 37 | 37 | import { listTeams } from "../../core/teams.ts"; |
| 38 | 38 | 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"; | |
| 39 | 43 | import { privateRepoForSealed } from "../../actions/repos.ts"; |
| 40 | 44 | import { confirmIssue } from "../../actions/dependencies.ts"; |
| 41 | 45 | import { isCrew } from "../../core/agents.ts"; |
| @@ -162,6 +166,8 @@ export function issueScreen( | ||
| 162 | 166 | let doneBy: readonly string[] = []; |
| 163 | 167 | /** Closed-as-dropped-or-split blockers nobody has said may be passed. */ |
| 164 | 168 | let held: Issue[] = []; |
| 169 | let plan: IssuePlan | undefined; | |
| 170 | let shown: ReturnType<typeof headline>; | |
| 165 | 171 | /** Which issues this one waits for and holds up; a line when any. */ |
| 166 | 172 | let waits: Waits = { blockedBy: [], blocks: [] }; |
| 167 | 173 | /** What the crew has done here, newest first; a card when there is any. */ |
| @@ -211,6 +217,10 @@ export function issueScreen( | ||
| 211 | 217 | blocks: deps?.blocks.map((i) => i.number) ?? [], |
| 212 | 218 | }; |
| 213 | 219 | 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)!) : []); | |
| 214 | 224 | if (crew.runs.length === 0) focus = "thread"; |
| 215 | 225 | width = 0; |
| 216 | 226 | ctx.invalidate(); |
| @@ -576,15 +586,18 @@ export function issueScreen( | ||
| 576 | 586 | // Then the crew card, only when an agent has worked here. |
| 577 | 587 | const crewH = |
| 578 | 588 | 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; | |
| 579 | 591 | const waitsLine = waitsParts(waits); |
| 580 | 592 | 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", [ | |
| 582 | 594 | len(whereRows(src)), |
| 583 | 595 | len(headH), |
| 584 | 596 | len(held.length === 0 ? 0 : 1), |
| 585 | 597 | len(crewH), |
| 598 | len(planH), | |
| 586 | 599 | 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)))), | |
| 588 | 601 | ]); |
| 589 | 602 | whereStrip(s, strip!, c, f, src); |
| 590 | 603 | const right = head!.x + head!.w; |
| @@ -676,6 +689,13 @@ export function issueScreen( | ||
| 676 | 689 | }); |
| 677 | 690 | } |
| 678 | 691 | |
| 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 | ||
| 679 | 699 | if (width !== body!.w) { |
| 680 | 700 | width = body!.w; |
| 681 | 701 | 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 @@ | ||
| 10 | 10 | |
| 11 | 11 | import { sshCommand } from "../ssh/command.ts"; |
| 12 | 12 | import { mayReadRuns } from "../actions/runs.ts"; |
| 13 | import { runningCount } from "../core/runs.ts"; | |
| 13 | 14 | import { getAppByRepo } from "../core/apps.ts"; |
| 14 | 15 | import { audienceFacts, audienceOf } from "../core/audience.ts"; |
| 15 | 16 | import { activeEdgeToken, edgeLoginUrl, edgeStrangerLogin } from "../http/edge.ts"; |
| @@ -347,14 +348,17 @@ export function scopeHeader( | ||
| 347 | 348 | // (`mayReadRuns`: a private app's are for those who may enter it), and |
| 348 | 349 | // how to open the terminal (the TUI over SSH) for whoever has keys. A |
| 349 | 350 | // 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); | |
| 351 | 353 | items.push({ |
| 352 | 354 | label: "Runs", |
| 353 | 355 | href: `${base}/runs`, |
| 354 | 356 | icon: "activity", |
| 357 | ...(running > 0 ? { count: running } : {}), | |
| 355 | 358 | current: active === "runs", |
| 356 | 359 | shortcut: "g r", |
| 357 | 360 | }); |
| 361 | } | |
| 358 | 362 | // What happened on the repository, as the reader may see it: the events |
| 359 | 363 | // page, for any repository kept here. |
| 360 | 364 | 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"; | ||
| 59 | 59 | import { commentCard } from "../comments.ts"; |
| 60 | 60 | import { backTo, field, readForm } from "../form.ts"; |
| 61 | 61 | import { html, raw, when, type Html } from "../html.ts"; |
| 62 | import { planCard } from "./plan_card.ts"; | |
| 62 | 63 | import { NOT_CHANGED } from "../edit_words.ts"; |
| 63 | 64 | import { flashOrJson, flashTo } from "../layout.ts"; |
| 64 | 65 | import { |
| @@ -397,7 +398,7 @@ export function conversation( | ||
| 397 | 398 | node: html`<div class="timeline" id="conversation"> |
| 398 | 399 | ${opening}${when(options.editAction, (action) => |
| 399 | 400 | editForm(issue, action), |
| 400 | )}${earlier}${shown.map((e) => e.node)} | |
| 401 | )}${planCard(ctx.app.db, issue)}${earlier}${shown.map((e) => e.node)} | |
| 401 | 402 | </div>`, |
| 402 | 403 | lastCommentId: comments.length > 0 ? comments[comments.length - 1]!.id : "", |
| 403 | 404 | }; |
src/web/studio/asks.ts +2 −2
| @@ -1221,8 +1221,8 @@ export function askDismissedBy(db: Database, viewerId: string, issue: Issue): bo | ||
| 1221 | 1221 | issue.kind === "pull" |
| 1222 | 1222 | ? pullCause(db, issue) |
| 1223 | 1223 | : (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)", | |
| 1226 | 1226 | ) |
| 1227 | 1227 | .get(issue.id, ...WAITING_NOTES)?.at ?? issue.createdAt); |
| 1228 | 1228 | return at > cause; |
src/web/studio/lists.ts +2 −1
| @@ -92,7 +92,8 @@ const WORD_FILTERS: readonly WordFilter[] = [ | ||
| 92 | 92 | ]; |
| 93 | 93 | |
| 94 | 94 | 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"; | |
| 96 | 97 | if (word === "done") return "shipped"; |
| 97 | 98 | if (word === "cancelled") return "stopped"; |
| 98 | 99 | return word; |
src/web/studio/words.ts +12 −3
| @@ -8,6 +8,7 @@ | ||
| 8 | 8 | * thing about the same commit. Nothing here knows about a page. |
| 9 | 9 | */ |
| 10 | 10 | |
| 11 | import { shownState } from "../../runs/state.ts"; | |
| 11 | 12 | import type { Database } from "bun:sqlite"; |
| 12 | 13 | import { ancestries, type AncestryCache } from "./ancestry.ts"; |
| 13 | 14 | import { |
| @@ -64,7 +65,9 @@ export type Word = | ||
| 64 | 65 | | "planned" |
| 65 | 66 | | "told" |
| 66 | 67 | | "done" |
| 67 | | "cancelled"; | |
| 68 | | "cancelled" | |
| 69 | | "silent" | |
| 70 | | "needs input"; | |
| 68 | 71 | |
| 69 | 72 | /** |
| 70 | 73 | * Production's history as one page has asked it: the ancestry of each |
| @@ -413,7 +416,13 @@ export async function runWord( | ||
| 413 | 416 | return run.notBefore === null |
| 414 | 417 | ? { word: "waiting", note: "queued" } |
| 415 | 418 | : { 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 | } | |
| 417 | 426 | if (run.state === "failed") |
| 418 | 427 | return { word: "stopped", note: run.summary || run.state }; |
| 419 | 428 | // 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 { | ||
| 534 | 543 | const variant = |
| 535 | 544 | word === "look" || word === "shipped" || word === "done" |
| 536 | 545 | ? "success" |
| 537 | : word === "stopped" | |
| 546 | : word === "stopped" || word === "silent" | |
| 538 | 547 | ? "danger" |
| 539 | 548 | : word === "working" |
| 540 | 549 | ? "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", () => { | ||
| 50 | 50 | ROW_NUMBER() OVER (PARTITION BY i.id ORDER BY c.created_at DESC, c.id DESC) AS rn |
| 51 | 51 | FROM issue i JOIN comment c ON c.issue_id = i.id |
| 52 | 52 | 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)`, | |
| 54 | 54 | ) |
| 55 | 55 | .all(repoId, ...WAITING_NOTES); |
| 56 | 56 | 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", () => { | ||
| 123 | 123 | "access", "account_verify", "app_members", "app_notes", "asks_act", "asks_decide", "asks_proposal", "asks_undecide", |
| 124 | 124 | "assign", "attach_look", "board_pins", "board_write", "branches", "capacity", "valves", "cloud_accounts", "close", "confirm_wait", |
| 125 | 125 | "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", | |
| 127 | 127 | "next_up", "notify", "org_create", "origin_apps", "pins", "plan", "profile", "propose_to_parent_forge", "repo_labels", "reports", |
| 128 | 128 | "restore_drill", "review", "say", "signup", "snapshot_now", "spend", "steer", "sync", "take_back", "take_updates", |
| 129 | 129 | "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( | ||
| 105 | 105 | summary: state === "ok" ? "answered" : "declined: no", |
| 106 | 106 | costUsd: 0.5, |
| 107 | 107 | }); |
| 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); | |
| 108 | 114 | if (reply !== undefined) ok(addComment(app, bot.user, repo, issue, reply)); |
| 109 | 115 | } |
| 110 | 116 |
test/mcp.test.ts +1
| @@ -243,6 +243,7 @@ describe("the forge over MCP", () => { | ||
| 243 | 243 | "secrets", |
| 244 | 244 | "issue_teams", |
| 245 | 245 | "issues", |
| 246 | "keep_plan", | |
| 246 | 247 | "orgs", |
| 247 | 248 | "now", |
| 248 | 249 | "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 | }); |
new above
offline