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

A host-interrupted crew run is queued again up to five times with backoff, the count kept as data #11732

Merged opened this

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

Merged

torque/forge:torque/11693-a-host-interrupted-crew-run-is-queued-ag → main 5 commits fork 2 threads · 2 unresolved

  • Merged. torque/11693-a-host-interrupted-crew-run-is-queued-ag was merged into main as a280065.

Review

Sign in to review this change.
15 files changed +175 −96
docs/features/interrupted-out.md added +8
@@ -0,0 +1,8 @@
1 # Interrupted out
2
3 When the host cuts a crew run off five times (its lease lapses), each time the task is queued again after a wait of 15 s, doubling to 4 min; the count is the run's `interruptions` column. The sixth time the run is marked failed with "the host stopped this run five times; not tried again". A graceful stop for a restart neither counts nor waits. Runs failed under the older "stopped twice by its forge restarting" text still show. Home names each such run (last 24 hours, up to five, repositories you may write) in one line with **Try again**; so does the terminal.
4
5 - **Web**: Home, under the held line. Try again posts (same origin, signed in) to `/:owner/:name/runs/:id/requeue`, which queues the run's task again and returns to Home. Writers with the crew ability only; others are refused.
6 - **Terminal**: the palette's "Stopped runs". `j`/`k` move, `Enter` or `r` tries again. The screen reads the database on every draw, so a retry on either door shows on the other.
7 - **Gone when**: a newer agent run of the same issue exists, the run is over a day old, or you cannot write the repository.
8 - Both doors read `interruptedOutRuns` (`src/runs/interrupted_out.ts`) and write through `requeueRepoRun`. The forge may refuse a retry (one already waiting, the day's ceiling); it says so.
docs/features/stopped-twice.md deleted −8
@@ -1,8 +0,0 @@
1 # Stopped twice
2
3 When the forge restarts under a crew run twice, the run is marked failed with "stopped twice by its forge restarting; not tried again". Home names each such run (last 24 hours, up to five, repositories you may write) in one line with **Try again**; so does the terminal.
4
5 - **Web**: Home, under the held line. Try again posts (same origin, signed in) to `/:owner/:name/runs/:id/requeue`, which queues the run's task again and returns to Home. Writers with the crew ability only; others are refused.
6 - **Terminal**: the palette's "Stopped runs". `j`/`k` move, `Enter` or `r` tries again. The screen reads the database on every draw, so a retry on either door shows on the other.
7 - **Gone when**: a newer agent run of the same issue exists, the run is over a day old, or you cannot write the repository.
8 - Both doors read `stoppedTwiceRuns` (`src/runs/stopped_twice.ts`) and write through `requeueRepoRun`. The forge may refuse a retry (one already waiting, the day's ceiling); it says so.
src/core/migration-pins/20261112130000-run-interruptions.pin added +1
@@ -0,0 +1,1 @@
1 bfdb3d075979d9c2b9612d7bc755bb9b1496235488462eea76aa6aa4666e1bbb
src/core/migrations/20261112130000-run-interruptions.ts added +7
@@ -0,0 +1,7 @@
1 // How many times the host cut a run's task off and it was queued again (#11693): a count kept as
2 // data, so no one parses a summary to learn it. Each queued-again run carries its parent's count + 1.
3 // New column with a default. Additive. No backfill: a run in flight at deploy time starts from 0 and
4 // so gets at most one more try than the limit, which is harmless.
5 export default `
6 ALTER TABLE run ADD COLUMN interruptions INTEGER NOT NULL DEFAULT 0;
7 `;
src/core/runs.ts +9 −2
@@ -78,6 +78,8 @@ export interface Run {
7878 readonly notBefore: string | null;
7979 /** How many times this task has been paused and queued again. */
8080 readonly attempt: number;
81 /** How many times the host cut this task off and it was queued again (#11693). */
82 readonly interruptions: number;
8183 /** When the process working this run last said it still was. */
8284 readonly heartbeatAt: string | null;
8385 /** For an agent run: how many turns the model took. */
@@ -125,6 +127,7 @@ interface RunRow {
125127 cost_usd: number;
126128 not_before: string | null;
127129 attempt: number;
130 interruptions: number;
128131 heartbeat_at: string | null;
129132 turns: number;
130133 context_tokens: number;
@@ -159,6 +162,7 @@ const toRun = (r: RunRow): Run => ({
159162 costUsd: r.cost_usd,
160163 notBefore: r.not_before,
161164 attempt: r.attempt,
165 interruptions: r.interruptions,
162166 heartbeatAt: r.heartbeat_at,
163167 turns: r.turns,
164168 contextTokens: r.context_tokens,
@@ -190,6 +194,8 @@ export interface CreateRunInput {
190194 /** For a run queued again after a pause: when it may start, and the count. */
191195 notBefore?: string | undefined;
192196 attempt?: number | undefined;
197 /** How many times the host interrupted this task before (#11693). */
198 interruptions?: number | undefined;
193199 /** What the card says while it waits. */
194200 summary?: string | undefined;
195201 /** The variant group the run belongs to, when it is one of several tries. */
@@ -200,8 +206,8 @@ export function createRun(db: Database, input: CreateRunInput): Run {
200206 const runId = id();
201207 db.query(
202208 `INSERT INTO run (id, repo_id, kind, issue_id, env, commit_oid, branch, actor_id, reason,
203 not_before, attempt, summary, variant_group)
204 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)`,
209 not_before, attempt, summary, variant_group, interruptions)
210 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13, ?14)`,
205211 ).run(
206212 runId,
207213 input.repoId,
@@ -216,6 +222,7 @@ export function createRun(db: Database, input: CreateRunInput): Run {
216222 input.attempt ?? 0,
217223 input.summary ?? "",
218224 input.variantGroup ?? null,
225 input.interruptions ?? 0,
219226 );
220227 return getRun(db, runId)!;
221228 }
src/crew/dispatch.ts +34 −17
@@ -360,7 +360,7 @@ export async function startCrew(
360360 for (const runId of inFlight.keys()) abortAgentRun(runId);
361361 await Promise.all(inFlight.values());
362362 for (const run of interrupted)
363 requeue(app, run, "stopped for a restart; queued again");
363 requeue(app, run, "stopped for a restart; queued again", false);
364364 handles.delete(app);
365365 },
366366 };
@@ -458,7 +458,7 @@ export function queueAgentRun(
458458 agent: Agent,
459459 reason: RunReason,
460460 actorId?: string,
461 again?: { notBefore: string; attempt: number; summary: string },
461 again?: { notBefore: string; attempt: number; summary: string; interruptions?: number },
462462 variantGroup?: string,
463463 retry = false,
464464 /** The run is this agent's own on the issue: another agent's run does not stand in its way (team reviews). */
@@ -1385,14 +1385,27 @@ function forgetWorkdir(app: App, repo: Repo, pull: Pull): void {
13851385 });
13861386 }
13871387
1388 /** What a run says when a restart cut it off a second time in a row. */
1389 export const STOPPED_TWICE = "stopped twice by its forge restarting; not tried again";
1388 /** What runs said before the count was kept (#11583): still read as given up on. */
1389 export const STOPPED_TWICE_LEGACY = "stopped twice by its forge restarting; not tried again";
1390 /** What a run says when the host cut it off every time it was tried. */
1391 export const INTERRUPTED_OUT = "the host stopped this run five times; not tried again";
1392 /** How many interruptions a task takes before it is given up (#11693). */
1393 export const MAX_INTERRUPTIONS = 5;
1394 const BACKOFF_FIRST_MS = 15_000;
1395 const BACKOFF_MAX_MS = 4 * 60_000;
1396
1397 /** The wait before the nth queued-again try (n from 1): 15 s, doubling, at most 4 min. */
1398 export function interruptionBackoffMs(n: number): number {
1399 return Math.min(BACKOFF_MAX_MS, BACKOFF_FIRST_MS * 2 ** Math.max(0, n - 1));
1400 }
13901401
13911402 /**
13921403 * A run a dead forge left `running` was cut off with it: its lease has
13931404 * lapsed. Each ends here as cancelled, and the same task goes back on the
1394 * queue under the same reason — the run that starts reads the whole
1395 * conversation, so nothing a person said is lost. A run whose lease is
1405 * queue after a backoff — the run that starts reads the whole
1406 * conversation, so nothing a person said is lost. The interruptions are
1407 * counted on the run (`interruptions`); after `MAX_INTERRUPTIONS` the task
1408 * fails with `INTERRUPTED_OUT` and Try again. A run whose lease is
13961409 * fresh is being worked by a forge still alive beside this one — the old
13971410 * process of a release, finishing — and is left to it: that forge queues
13981411 * the task again itself when it stops. Runs at boot and on every tick: a
@@ -1401,19 +1414,22 @@ export const STOPPED_TWICE = "stopped twice by its forge restarting; not tried a
14011414 export function requeueInterrupted(app: App): void {
14021415 for (const run of listActive(app.db, "agent")) {
14031416 if (run.state !== "running" || !leaseLapsed(run, STALE_AFTER_MS)) continue;
1404 // Queued by an interruption and cut off again: a forge stopped every
1405 // 90 s would loop it until its credit is gone. Fail it, saying why.
1406 if ((run.summary ?? "").startsWith(REQUEUED_SUMMARY)) {
1407 finishRun(app.db, run.id, { state: "failed", summary: STOPPED_TWICE });
1408 log.info("run failed, stopped twice", { run: run.id });
1409 continue;
1410 }
1411 requeue(app, run, REQUEUED_SUMMARY);
1417 requeue(app, run, REQUEUED_SUMMARY, true);
14121418 }
14131419 }
14141420
1415 /** End a run that cannot go on, and put its task back on the queue. */
1416 function requeue(app: App, run: Run, why: string): void {
1421 /**
1422 * End a run that cannot go on, and put its task back on the queue. Only a
1423 * run the host cut off (`interrupted`) spends the budget and waits; a
1424 * graceful stop for a restart carries the count over and starts at once.
1425 */
1426 export function requeue(app: App, run: Run, why: string, interrupted: boolean, now = Date.now()): void {
1427 const interruptions = run.interruptions + (interrupted ? 1 : 0);
1428 if (interrupted && interruptions > MAX_INTERRUPTIONS) {
1429 finishRun(app.db, run.id, { state: "failed", summary: INTERRUPTED_OUT });
1430 log.info("run given up after interruptions", { run: run.id, interruptions: run.interruptions });
1431 return;
1432 }
14171433 cancelRun(app.db, run.id, why);
14181434 const task = taskOf(app, run);
14191435 if (task === undefined) return;
@@ -1427,8 +1443,9 @@ function requeue(app: App, run: Run, why: string): void {
14271443 task.reason,
14281444 undefined,
14291445 {
1430 notBefore: new Date().toISOString(),
1446 notBefore: new Date(now + (interrupted ? interruptionBackoffMs(interruptions) : 0)).toISOString(),
14311447 attempt: run.attempt + 1,
1448 interruptions,
14321449 summary: `${why}; carries on where it stopped`,
14331450 },
14341451 );
src/runs/stopped_twice.ts → src/runs/interrupted_out.ts renamed +9 −8
@@ -1,17 +1,17 @@
11 /**
2 * Runs the forge's restarts stopped twice (#11583), as plain data: the last
2 * Runs the forge's host interruptions gave up on (#11583, #11693), as plain data: the last
33 * 24 hours, newest first, one per issue, none once a newer run of that issue
44 * exists (Try again made one). Home's line and the terminal screen share it.
55 */
66
77 import type { Database } from "bun:sqlite";
88 import type { Repo } from "../core/repos.ts";
9 import { STOPPED_TWICE } from "../crew/dispatch.ts";
9 import { INTERRUPTED_OUT, STOPPED_TWICE_LEGACY } from "../crew/dispatch.ts";
1010
1111 const WINDOW_MS = 24 * 60 * 60 * 1000;
1212 export const MAX_STOPPED = 5;
1313
14 export interface StoppedTwice {
14 export interface InterruptedOut {
1515 runId: string;
1616 repo: Repo;
1717 number: number;
@@ -25,23 +25,24 @@ interface Row {
2525 repo_id: string;
2626 number: number;
2727 title: string;
28 summary: string;
2829 }
2930
3031 /** One bounded query over the repositories given (the ones this person may write). */
31 export function stoppedTwiceRuns(db: Database, writable: readonly Repo[], now = Date.now()): StoppedTwice[] {
32 export function interruptedOutRuns(db: Database, writable: readonly Repo[], now = Date.now()): InterruptedOut[] {
3233 const byId = new Map<string, Repo>(writable.map((r) => [r.id, r]));
3334 if (byId.size === 0) return [];
3435 const since = new Date(now - WINDOW_MS).toISOString();
3536 const marks = [...byId.keys()].map(() => "?").join(",");
3637 return db
3738 .query<Row, (string | number)[]>(
38 `SELECT r.id, r.repo_id, i.number, i.title
39 `SELECT r.id, r.repo_id, i.number, i.title, r.summary
3940 FROM run r JOIN issue i ON i.id = r.issue_id
40 WHERE r.kind = 'agent' AND r.state = 'failed' AND r.summary = ?
41 WHERE r.kind = 'agent' AND r.state = 'failed' AND r.summary IN (?, ?)
4142 AND r.finished_at >= ? AND r.repo_id IN (${marks})
4243 AND NOT EXISTS (SELECT 1 FROM run n WHERE n.issue_id = r.issue_id AND n.kind = 'agent' AND n.id > r.id)
4344 ORDER BY r.finished_at DESC LIMIT ${MAX_STOPPED}`,
4445 )
45 .all(STOPPED_TWICE, since, ...byId.keys())
46 .map((row) => ({ runId: row.id, repo: byId.get(row.repo_id)!, number: row.number, title: row.title, reason: STOPPED_TWICE }));
46 .all(INTERRUPTED_OUT, STOPPED_TWICE_LEGACY, since, ...byId.keys())
47 .map((row) => ({ runId: row.id, repo: byId.get(row.repo_id)!, number: row.number, title: row.title, reason: row.summary }));
4748 }
src/tui/forge.ts +2 −2
@@ -18,7 +18,7 @@ import { sourceFor } from "../view/source.ts";
1818 import { fleetCommand } from "./screens/fleet.ts";
1919 import { dataCommand } from "./screens/data.ts";
2020 import { packsCommand } from "./screens/packs.ts";
21 import { stoppedTwiceCommand } from "./screens/stopped_twice.ts";
21 import { interruptedOutCommand } from "./screens/interrupted_out.ts";
2222 import { getForgeCommand } from "./screens/getforge.ts";
2323 import { startForgeCommand } from "./screens/startforge.ts";
2424 import { pricingCommand } from "./screens/pricing.ts";
@@ -250,7 +250,7 @@ export function forgeCommands(session: Session): (ctx: Ctx) => Command[] {
250250 run: () => openOnWeb(session, ctx, "/explore.atom", "The explore feed"),
251251 },
252252 packsCommand(session, ctx),
253 stoppedTwiceCommand(session, ctx),
253 interruptedOutCommand(session, ctx),
254254 getForgeCommand(session, ctx),
255255 ...startForgeCommand(session, ctx),
256256 pricingCommand(session, ctx),
src/tui/screens/stopped_twice.ts → src/tui/screens/interrupted_out.ts renamed +10 −10
@@ -1,6 +1,6 @@
11 /**
2 * Runs the forge's restarts stopped twice, in the terminal (#11583): the same
3 * lines Home prints on the web, from `stoppedTwiceRuns`, read on every draw so
2 * Runs the host stopped until it was given up on, in the terminal (#11583): the same
3 * lines Home prints on the web, from `interruptedOutRuns`, read on every draw so
44 * a Try again made on the page shows here at once. Enter (or r) queues the run
55 * again through `requeueRepoRun`, the web route's own door, which announces the
66 * new run so web viewers see it live. Gated like the page: only repositories
@@ -10,16 +10,16 @@
1010 import { requeueRepoRun } from "../../actions/runs.ts";
1111 import { listReposWritableTo } from "../../core/repos.ts";
1212 import { words } from "../../fleet/verbs.ts";
13 import { stoppedTwiceRuns } from "../../runs/stopped_twice.ts";
13 import { interruptedOutRuns } from "../../runs/interrupted_out.ts";
1414 import type { Ctx, Screen } from "../app/app.ts";
1515 import { binding } from "../app/keys.ts";
1616 import type { Session } from "../session.ts";
1717 import { style } from "../term/style.ts";
1818 import { truncate } from "../term/width.ts";
1919
20 export function stoppedTwiceScreen(session: Session, ctx: Ctx): Screen {
20 export function interruptedOutScreen(session: Session, ctx: Ctx): Screen {
2121 let at = 0;
22 const rows = () => stoppedTwiceRuns(session.app.db, listReposWritableTo(session.app.db, session.user, { withoutArchived: true, limit: 200 }));
22 const rows = () => interruptedOutRuns(session.app.db, listReposWritableTo(session.app.db, session.user, { withoutArchived: true, limit: 200 }));
2323 return {
2424 title: "Stopped runs",
2525 keys: [binding(["j", "k"], "move"), binding(["enter", "r"], "try again"), binding(["esc"], "back")],
@@ -27,7 +27,7 @@ export function stoppedTwiceScreen(session: Session, ctx: Ctx): Screen {
2727 const c = ctx.theme.c;
2828 const list = rows();
2929 at = Math.min(at, Math.max(0, list.length - 1));
30 if (list.length === 0) s.text(area.x + 1, area.y, "No run was stopped twice by restarts in the last day.", style(c.mutedFg, c.bg), area.x + area.w);
30 if (list.length === 0) s.text(area.x + 1, area.y, "No run was stopped by the host in the last day.", style(c.mutedFg, c.bg), area.x + area.w);
3131 list.forEach((r, i) => {
3232 if (i >= area.h) return;
3333 const text = `${i === at ? "> " : " "}${r.repo.name}#${r.number} ${r.title}: ${r.reason}`;
@@ -53,9 +53,9 @@ export function stoppedTwiceScreen(session: Session, ctx: Ctx): Screen {
5353 };
5454 }
5555
56 export const stoppedTwiceCommand = (session: Session, ctx: Ctx) => ({
57 id: "stopped-twice",
56 export const interruptedOutCommand = (session: Session, ctx: Ctx) => ({
57 id: "interrupted-out",
5858 title: "Stopped runs",
59 hint: "runs restarts stopped twice, with Try again",
60 run: () => ctx.push(stoppedTwiceScreen(session, ctx)),
59 hint: "runs the host stopped five times, with Try again",
60 run: () => ctx.push(interruptedOutScreen(session, ctx)),
6161 });
src/web/studio/home.ts +2 −2
@@ -72,7 +72,7 @@ import { needsYouSection } from "./asks_page.ts";
7272 import { flowCards } from "./flow_card.ts";
7373 import { composer, feedJs, feedNote, feedRegion, registerWorkerJs } from "./feed.ts";
7474 import { heldLine } from "./held.ts";
75 import { stoppedTwiceLine } from "./stopped_twice.ts";
75 import { interruptedOutLine } from "./interrupted_out.ts";
7676 import { appChips, movingRegion, tellBox } from "./living.ts";
7777 import { alarmLines, cachedAlarms } from "./alarm_lines.ts";
7878 import { kpiRow, waitingLine } from "./kpi_row.ts";
@@ -357,7 +357,7 @@ export async function studioHome(
357357 const waiting = await homeRegion(ctx.app.db, ctx.request, user.id, "waiting", homeVariant(ctx.url), access, draws.waiting);
358358 const held = timePhase(ctx.request, "held", () => heldLine(ctx, user));
359359 const mine = writableAppsOf(ctx, user).repos;
360 const stopped = timePhase(ctx.request, "stopped", () => stoppedTwiceLine(ctx, mine));
360 const stopped = timePhase(ctx.request, "stopped", () => interruptedOutLine(ctx, mine));
361361 const alarms = user.isAdmin
362362 ? alarmLines(cachedAlarms(ctx.app.db, new Date(), forgeCeiling(ctx.app.config), ctx.app.config.dataDir))
363363 : null;
src/web/studio/stopped_twice.ts → src/web/studio/interrupted_out.ts renamed +6 −6
@@ -1,5 +1,5 @@
11 /**
2 * Home names a run the forge's restarts stopped twice (#11583): one plain
2 * Home names a run the host interrupted until it was given up on (#11583): one plain
33 * line per such run in the last 24 hours — its issue, the reason in the
44 * words the run carries, and Try again, which posts to the run's re-queue
55 * route. One bounded query; no line when there is none, and none once a
@@ -8,16 +8,16 @@
88
99 import type { Repo } from "../../core/repos.ts";
1010 import type { Ctx } from "../../http/context.ts";
11 import { stoppedTwiceRuns } from "../../runs/stopped_twice.ts";
11 import { interruptedOutRuns } from "../../runs/interrupted_out.ts";
1212 import { repoBase } from "../chrome.ts";
1313 import { html, type Html } from "../html.ts";
1414 import { btn } from "./../ui.ts";
1515
16 /** The lines for runs stopped twice that this person may try again; nothing where none is. */
17 export function stoppedTwiceLine(ctx: Ctx, writable: readonly Repo[], now = Date.now()): Html | null {
18 const rows = stoppedTwiceRuns(ctx.app.db, writable, now);
16 /** The lines for runs given up after interruptions that this person may try again; nothing where none is. */
17 export function interruptedOutLine(ctx: Ctx, writable: readonly Repo[], now = Date.now()): Html | null {
18 const rows = interruptedOutRuns(ctx.app.db, writable, now);
1919 if (rows.length === 0) return null;
20 return html`<section class="stack stack-tight" id="stopped-twice" aria-label="Stopped runs">
20 return html`<section class="stack stack-tight" id="interrupted-out" aria-label="Stopped runs">
2121 ${rows.map((row) => {
2222 const repo = row.repo;
2323 return html`<form class="bar small" method="post" action="${repoBase(repo)}/runs/${row.runId}/requeue">
test/crew-stopped-twice.test.ts → test/crew-interrupted-out.test.ts renamed +43 −11
@@ -1,4 +1,6 @@
11 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2 import type { Database } from "bun:sqlite";
3 import { listActive, type RunKind } from "../src/core/runs.ts";
24 import { mkdtemp, rm } from "node:fs/promises";
35 import { tmpdir } from "node:os";
46 import { join } from "node:path";
@@ -6,15 +8,18 @@ import { addToCrew, createAgent } from "../src/actions/crew.ts";
68 import { openIssue } from "../src/actions/issues.ts";
79 import { createRepo } from "../src/actions/repos.ts";
810 import { createApp, type App } from "../src/app.ts";
9 import { claimRun, createRun, getRun, listQueued } from "../src/core/runs.ts";
11 import { claimRun, createRun, getRun } from "../src/core/runs.ts";
1012 import { createUser } from "../src/core/users.ts";
11 import { requeueInterrupted } from "../src/crew/dispatch.ts";
13 import { INTERRUPTED_OUT, MAX_INTERRUPTIONS, interruptionBackoffMs, requeue, requeueInterrupted } from "../src/crew/dispatch.ts";
1214 import { daySpent } from "../src/core/account_ceiling.ts";
1315 import { noteRunProgress } from "../src/core/runs.ts";
1416 import { recordUsage, usageTotals } from "../src/core/usage.ts";
1517 import { STALE_AFTER_MS } from "../src/runs/lease.ts";
1618 import type { User } from "../src/core/users.ts";
1719
20 /** Every queued run of one kind, those still waiting out a backoff included. */
21 const queuedIncludingWaiting = (db: Database, kind: RunKind) => listActive(db, kind).filter((r) => r.state === "queued");
22
1823 describe("a run a restart cuts off", () => {
1924 let dataDir: string;
2025 let app: App;
@@ -23,7 +28,7 @@ describe("a run a restart cuts off", () => {
2328 let n = 0;
2429
2530 beforeAll(async () => {
26 dataDir = await mkdtemp(join(tmpdir(), "forge-stopped-twice-"));
31 dataDir = await mkdtemp(join(tmpdir(), "forge-interrupted-out-"));
2732 app = createApp({ dataDir, port: 0 });
2833 const a = await createUser(app.db, {
2934 origin: "seed", trusted: true, username: "alice",
@@ -60,8 +65,7 @@ describe("a run a restart cuts off", () => {
6065 .query("UPDATE run SET heartbeat_at = ?2 WHERE id = ?1")
6166 .run(id, new Date(Date.now() - STALE_AFTER_MS - 1_000).toISOString());
6267 };
63 const forIssue = (issueId: string) =>
64 listQueued(app.db, "agent").filter((r) => r.issueId === issueId);
68 const forIssue = (issueId: string) => queuedIncludingWaiting(app.db, "agent").filter((r) => r.issueId === issueId);
6569
6670 test("once: cancelled and queued again", async () => {
6771 const { issue, run } = await setup();
@@ -73,16 +77,44 @@ describe("a run a restart cuts off", () => {
7377 expect(again[0]!.summary).toStartWith("interrupted by a restart; queued again");
7478 });
7579
76 test("twice in a row: failed with its reason, nothing queued", async () => {
80 test("each interruption is counted as data and waits 15 s doubling to 4 min", async () => {
81 expect([1, 2, 3, 4, 5, 6, 9].map(interruptionBackoffMs)).toEqual([15_000, 30_000, 60_000, 120_000, 240_000, 240_000, 240_000]);
7782 const { issue, run } = await setup();
83 let cur = run;
84 for (let i = 1; i <= MAX_INTERRUPTIONS; i++) {
85 lapse(cur.id);
86 const lo = Date.now();
87 requeueInterrupted(app);
88 const hi = Date.now();
89 expect(getRun(app.db, cur.id)!.state).toBe("cancelled");
90 cur = forIssue(issue.id)[0]!;
91 expect(cur.interruptions).toBe(i);
92 const due = Date.parse(cur.notBefore!);
93 expect(due).toBeGreaterThanOrEqual(lo + interruptionBackoffMs(i));
94 expect(due).toBeLessThanOrEqual(hi + interruptionBackoffMs(i));
95 }
96 });
97
98 test("a graceful stop leaves the count and the wait alone, even past the budget", async () => {
99 const { issue, run } = await setup();
100 app.db.query("UPDATE run SET interruptions = ?2 WHERE id = ?1").run(run.id, MAX_INTERRUPTIONS);
101 const now = Date.parse("2026-01-01T00:00:00.000Z");
102 requeue(app, getRun(app.db, run.id)!, "stopped for a restart; queued again", false, now);
103 expect(getRun(app.db, run.id)!.state).toBe("cancelled");
104 const again = forIssue(issue.id);
105 expect(again).toHaveLength(1);
106 expect(again[0]!.interruptions).toBe(MAX_INTERRUPTIONS);
107 expect(again[0]!.notBefore).toBe(new Date(now).toISOString());
108 });
109
110 test("the sixth interruption fails the run with its reason, nothing queued", async () => {
111 const { issue, run } = await setup();
112 app.db.query("UPDATE run SET interruptions = ?2 WHERE id = ?1").run(run.id, MAX_INTERRUPTIONS);
78113 lapse(run.id);
79114 requeueInterrupted(app);
80 const second = forIssue(issue.id)[0]!;
81 lapse(second.id);
82 requeueInterrupted(app);
83 const after = getRun(app.db, second.id)!;
115 const after = getRun(app.db, run.id)!;
84116 expect(after.state).toBe("failed");
85 expect(after.summary).toBe("stopped twice by its forge restarting; not tried again");
117 expect(after.summary).toBe(INTERRUPTED_OUT);
86118 expect(forIssue(issue.id)).toEqual([]);
87119 });
88120
test/crew-interrupted.test.ts +14 −8
@@ -1,4 +1,6 @@
11 import { afterAll, beforeAll, describe, expect, test } from "bun:test";
2 import type { Database } from "bun:sqlite";
3 import { listActive, type RunKind } from "../src/core/runs.ts";
24 import { chmodSync, existsSync, readdirSync, rmSync, utimesSync, writeFileSync } from "node:fs";
35 import { mkdtemp, rm } from "node:fs/promises";
46 import { tmpdir } from "node:os";
@@ -13,7 +15,6 @@ import {
1315 claimRun,
1416 createRun,
1517 getRun,
16 listQueued,
1718 listRuns,
1819 } from "../src/core/runs.ts";
1920 import { listPulls } from "../src/core/pulls.ts";
@@ -25,11 +26,16 @@ import { createUser, type User } from "../src/core/users.ts";
2526 import { startCrew, type CrewHandle } from "../src/crew/dispatch.ts";
2627 import { serve, type Servers } from "../src/http/server.ts";
2728
29 /** Every queued run of one kind, those still waiting out a backoff included. */
30 const queuedIncludingWaiting = (db: Database, kind: RunKind) => listActive(db, kind).filter((r) => r.state === "queued");
31
2832 /** The crew's tick, injected short: every wait below is for the next tick (#11053). */
2933 const TICK_MS = 50;
3034 /** How often a test looks again at a run it waits on. */
3135 const POLL_MS = 20;
3236
37 const waiting = (app: App) => queuedIncludingWaiting(app.db, "agent");
38
3339 describe("a run the last forge left running", () => {
3440 let dataDir: string;
3541 let app: App;
@@ -159,7 +165,7 @@ describe("a run the last forge left running", () => {
159165 const after = getRun(app.db, orphan.id)!;
160166 expect(after.state).toBe("cancelled");
161167 expect(after.summary).toContain("interrupted by a restart");
162 const queued = listQueued(app.db, "agent").filter(
168 const queued = waiting(app).filter(
163169 (r) => r.issueId === issue.id && r.state === "queued",
164170 );
165171 expect(queued).toHaveLength(1);
@@ -206,7 +212,7 @@ describe("a run the last forge left running", () => {
206212 const after = getRun(app.db, orphan.id)!;
207213 expect(after.state).toBe("cancelled");
208214 expect(after.summary).toContain("interrupted by a restart");
209 const queued = listQueued(app.db, "agent").filter(
215 const queued = waiting(app).filter(
210216 (r) => r.issueId === issue.id && r.state === "queued",
211217 );
212218 expect(queued).toHaveLength(1);
@@ -276,7 +282,7 @@ sleep 120
276282
277283 const ended = getRun(app.db, queuedRun.id)!;
278284 expect(ended.state === "cancelled" || ended.state === "failed").toBe(true);
279 const again = listQueued(app.db, "agent").filter(
285 const again = waiting(app).filter(
280286 (r) => r.issueId === issue.id && r.state === "queued",
281287 );
282288 expect(again).toHaveLength(1);
@@ -339,7 +345,7 @@ sleep 120
339345
340346 const ended = getRun(app.db, queuedRun.id)!;
341347 expect(ended.state === "cancelled" || ended.state === "failed").toBe(true);
342 const again = listQueued(app.db, "agent").filter((r) => r.issueId === issue.id && r.state === "queued");
348 const again = waiting(app).filter((r) => r.issueId === issue.id && r.state === "queued");
343349 expect(again).toHaveLength(1);
344350 expect(again[0]!.summary).toContain("carries on");
345351 }, 40_000);
@@ -409,7 +415,7 @@ echo '{"type":"result","subtype":"success","is_error":false,"result":"Done while
409415 expect(ended.state).toBe("ok");
410416 expect(ended.summary).toMatch(/^opened pull #\d+/);
411417 expect(
412 listQueued(app.db, "agent").filter((r) => r.issueId === issue.id),
418 waiting(app).filter((r) => r.issueId === issue.id),
413419 ).toHaveLength(0);
414420 }, 40_000);
415421
@@ -650,7 +656,7 @@ describe("an issue handed to someone else", () => {
650656 const talk = addComment(app, alice, repo, issue, "I have this one");
651657 expect(talk.ok).toBe(true);
652658 expect(
653 listQueued(app.db, "agent").some((r) => r.issueId === issue.id),
659 waiting(app).some((r) => r.issueId === issue.id),
654660 ).toBe(false);
655661 });
656662
@@ -660,7 +666,7 @@ describe("an issue handed to someone else", () => {
660666 claimRun(app.db, run.id);
661667 expect(assignIssue(app, alice, repo, issue, other).ok).toBe(true);
662668 expect(getRun(app.db, run.id)?.state).toBe("cancelled");
663 const queued = listQueued(app.db, "agent").filter(
669 const queued = waiting(app).filter(
664670 (r) => r.issueId === issue.id,
665671 );
666672 expect(queued).toHaveLength(1);
test/home-stopped-twice.test.ts → test/home-interrupted-out.test.ts renamed +26 −18
@@ -1,4 +1,4 @@
1 /** Home names a run stopped twice by restarts, with Try again (#11583). */
1 /** Home names a run given up after interruptions, with Try again (#11583). */
22 import { afterAll, beforeAll, expect, test } from "bun:test";
33 import { mkdtemp, rm } from "node:fs/promises";
44 import { tmpdir } from "node:os";
@@ -6,10 +6,10 @@ import { join } from "node:path";
66 import { createApp, type App } from "../src/app.ts";
77 import { newApp } from "../src/actions/apps.ts";
88 import { addToCrew, createAgent } from "../src/actions/crew.ts";
9 import { stoppedTwiceRuns } from "../src/runs/stopped_twice.ts";
10 import { stoppedTwiceScreen } from "../src/tui/screens/stopped_twice.ts";
9 import { interruptedOutRuns } from "../src/runs/interrupted_out.ts";
10 import { interruptedOutScreen } from "../src/tui/screens/interrupted_out.ts";
1111 import { getRepo } from "../src/core/repos.ts";
12 import { STOPPED_TWICE } from "../src/crew/dispatch.ts";
12 import { INTERRUPTED_OUT, STOPPED_TWICE_LEGACY } from "../src/crew/dispatch.ts";
1313 import { createIssue } from "../src/core/issues.ts";
1414 import { createSession } from "../src/core/sessions.ts";
1515 import { createUser, type User } from "../src/core/users.ts";
@@ -37,7 +37,7 @@ function failedRun(title: string, summary: string, finishedAt: string) {
3737 }
3838
3939 beforeAll(async () => {
40 dataDir = await mkdtemp(join(tmpdir(), "forge-stopped-twice-"));
40 dataDir = await mkdtemp(join(tmpdir(), "forge-interrupted-out-"));
4141 app = createApp({ dataDir, port: 0 });
4242 servers = await serve(app);
4343 const r = await createUser(app.db, { origin: "seed", trusted: true, username: "alice", email: "[email protected]", password: "a-good-passphrase", adminIfFirst: true });
@@ -61,18 +61,18 @@ afterAll(async () => {
6161 });
6262
6363 test("no line when no run was stopped twice", async () => {
64 expect(await home()).not.toContain("stopped-twice");
64 expect(await home()).not.toContain("interrupted-out");
6565 });
6666
6767 test("a run stopped twice lately shows one line with its reason and Try again; old, other, or retried runs show none", async () => {
68 failedRun("Old one", STOPPED_TWICE, new Date(Date.now() - 25 * 3600_000).toISOString());
68 failedRun("Old one", INTERRUPTED_OUT, new Date(Date.now() - 25 * 3600_000).toISOString());
6969 failedRun("Other failure", "tests failed", new Date().toISOString());
70 const again = failedRun("Already retried", STOPPED_TWICE, new Date(Date.now() - 1000).toISOString());
70 const again = failedRun("Already retried", INTERRUPTED_OUT, new Date(Date.now() - 1000).toISOString());
7171 app.db.query("INSERT INTO run (id, repo_id, kind, issue_id, commit_oid, state) VALUES ('zzzzz-newer', ?, 'agent', ?, 'c', 'queued')").run(repoId, again.issue.id);
72 const hit = failedRun("Fix <b>the</b> thing", STOPPED_TWICE, new Date().toISOString());
72 const hit = failedRun("Fix <b>the</b> thing", INTERRUPTED_OUT, new Date().toISOString());
7373 const page = await home();
74 expect(page).toContain('id="stopped-twice"');
75 expect(page).toContain(STOPPED_TWICE);
74 expect(page).toContain('id="interrupted-out"');
75 expect(page).toContain(INTERRUPTED_OUT);
7676 expect(page).toContain(`action="/alice/shop/runs/${hit.id}/requeue"`);
7777 expect(page).toContain("Try again");
7878 expect(page).toContain("Fix &lt;b&gt;the&lt;/b&gt; thing");
@@ -80,15 +80,23 @@ test("a run stopped twice lately shows one line with its reason and Try again; o
8080 expect(page.match(/\/requeue"/g)?.length).toBe(1);
8181 });
8282
83 test("a run failed under the old \"stopped twice\" text still shows", async () => {
84 const hit = failedRun("Old words", STOPPED_TWICE_LEGACY, new Date().toISOString());
85 const repo = getRepo(app.db, "alice", "shop")!;
86 const row = interruptedOutRuns(app.db, [repo]).find((r) => r.runId === hit.id);
87 expect(row?.reason).toBe(STOPPED_TWICE_LEGACY);
88 expect(await home()).toContain(`/runs/${hit.id}/requeue`);
89 });
90
8391 test("a stranger sees no line", async () => {
8492 const r = await createUser(app.db, { origin: "seed", trusted: true, username: "bob", email: "[email protected]", password: "a-good-passphrase" });
8593 if (!r.ok) throw new Error(r.error);
8694 const bob = `forge_session=${createSession(app.db, r.value.id).token}`;
87 expect(await (await fetch(`${servers.url}/`, { headers: { cookie: bob } })).text()).not.toContain("stopped-twice");
95 expect(await (await fetch(`${servers.url}/`, { headers: { cookie: bob } })).text()).not.toContain("interrupted-out");
8896 });
8997
9098 test("the Try again route refuses a cross-origin post", async () => {
91 const run = app.db.query<{ id: string }, []>("SELECT id FROM run WHERE summary LIKE 'stopped twice%' ORDER BY id DESC LIMIT 1").get()!;
99 const run = app.db.query<{ id: string }, []>("SELECT id FROM run WHERE summary LIKE 'the host stopped%' ORDER BY id DESC LIMIT 1").get()!;
92100 const res = await fetch(`${servers.url}/alice/shop/runs/${run.id}/requeue`, {
93101 method: "POST",
94102 headers: { cookie, origin: "https://evil.example", "content-type": "application/x-www-form-urlencoded" },
@@ -108,7 +116,7 @@ const post = (path: string, who: string) =>
108116 const runsOf = (issueId: string) => app.db.query<{ n: number }, [string]>("SELECT count(*) AS n FROM run WHERE issue_id = ? AND kind = 'agent'").get(issueId)!.n;
109117
110118 test("a signed-in non-writer's same-origin post queues nothing", async () => {
111 const hit = failedRun("Not for bob", STOPPED_TWICE, new Date().toISOString());
119 const hit = failedRun("Not for bob", INTERRUPTED_OUT, new Date().toISOString());
112120 const r = await createUser(app.db, { origin: "seed", trusted: true, username: "carol", email: "[email protected]", password: "a-good-passphrase" });
113121 if (!r.ok) throw new Error(r.error);
114122 const carol = `forge_session=${createSession(app.db, r.value.id).token}`;
@@ -119,7 +127,7 @@ test("a signed-in non-writer's same-origin post queues nothing", async () => {
119127 });
120128
121129 test("a writer's same-origin post queues a new run and the line goes away", async () => {
122 const hit = failedRun("Retry me", STOPPED_TWICE, new Date().toISOString());
130 const hit = failedRun("Retry me", INTERRUPTED_OUT, new Date().toISOString());
123131 expect(await home()).toContain(`/runs/${hit.id}/requeue`);
124132 const res = await post(`/alice/shop/runs/${hit.id}/requeue`, cookie);
125133 expect(res.status).toBe(303);
@@ -128,12 +136,12 @@ test("a writer's same-origin post queues a new run and the line goes away", asyn
128136 });
129137
130138 test("the terminal screen lists the same runs and Enter tries one again", async () => {
131 const hit = failedRun("Retry from terminal", STOPPED_TWICE, new Date().toISOString());
139 const hit = failedRun("Retry from terminal", INTERRUPTED_OUT, new Date().toISOString());
132140 const repo = getRepo(app.db, "alice", "shop")!;
133 expect(stoppedTwiceRuns(app.db, [repo]).some((r) => r.runId === hit.id)).toBe(true);
141 expect(interruptedOutRuns(app.db, [repo]).some((r) => r.runId === hit.id)).toBe(true);
134142 const toasts: string[] = [];
135143 const ctx = { theme: { c: {} }, pop() {}, push() {}, invalidate() {}, toast: (t: string) => toasts.push(t) };
136 const screen = stoppedTwiceScreen({ app, user: alice } as never, ctx as never);
144 const screen = interruptedOutScreen({ app, user: alice } as never, ctx as never);
137145 // Newest first: the run just made is the first row.
138146 expect(screen.key!({ key: "enter", ctrl: false, alt: false } as never)).toBe(true);
139147 expect(toasts[0]).toContain("again");
test/palette-roster.test.ts +4 −4
@@ -51,7 +51,7 @@ test("a site-admin person is offered the whole roster", () => {
5151 ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"],
5252 ["mirror-packs", "Mirror the packs' upstreams", "bring each pack's upstream projects in as read-only repositories"],
5353 ["pack-repos", "Pack repositories", "which repositories /packs lists"],
54 ["stopped-twice", "Stopped runs", "runs restarts stopped twice, with Try again"],
54 ["interrupted-out", "Stopped runs", "runs the host stopped five times, with Try again"],
5555 ["get-a-forge", "Get your own forge", "the plans, where to get a hosted forge, how to run one yourself"],
5656 ["pricing", "Get a forge (plans)", "pick a plan, name it, pay at the checkout address"],
5757 ["data", "Data", "the SQLite files you may read as a tree; / filters, : asks in SQL, y yanks a row"],
@@ -208,7 +208,7 @@ test("a site-admin agent is offered the roster without the person-only entries",
208208 ["explore", "Explore", "on the web"],
209209 ["explore-feed", "Explore feed", "Atom address"],
210210 ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"],
211 ["stopped-twice", "Stopped runs", "runs restarts stopped twice, with Try again"],
211 ["interrupted-out", "Stopped runs", "runs the host stopped five times, with Try again"],
212212 ["get-a-forge", "Get your own forge", "the plans, where to get a hosted forge, how to run one yourself"],
213213 ["pricing", "Get a forge (plans)", "pick a plan, name it, pay at the checkout address"],
214214 ["data", "Data", "the SQLite files you may read as a tree; / filters, : asks in SQL, y yanks a row"],
@@ -296,7 +296,7 @@ test("a member person is offered the signed-in roster without the admin entries"
296296 ["explore", "Explore", "on the web"],
297297 ["explore-feed", "Explore feed", "Atom address"],
298298 ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"],
299 ["stopped-twice", "Stopped runs", "runs restarts stopped twice, with Try again"],
299 ["interrupted-out", "Stopped runs", "runs the host stopped five times, with Try again"],
300300 ["get-a-forge", "Get your own forge", "the plans, where to get a hosted forge, how to run one yourself"],
301301 ["pricing", "Get a forge (plans)", "pick a plan, name it, pay at the checkout address"],
302302 ["fleet", "Fleet rows", "the rows a fleet query line selects"],
@@ -379,7 +379,7 @@ test("signed out, the palette offers sign-in and the public entries", () => {
379379 ["explore", "Explore", "on the web"],
380380 ["explore-feed", "Explore feed", "Atom address"],
381381 ["packs", "Forge packs", "premade forges to deploy, and how to help build their apps"],
382 ["stopped-twice", "Stopped runs", "runs restarts stopped twice, with Try again"],
382 ["interrupted-out", "Stopped runs", "runs the host stopped five times, with Try again"],
383383 ["get-a-forge", "Get your own forge", "the plans, where to get a hosted forge, how to run one yourself"],
384384 ["start-forge", "Start my forge", "a free forge of your own; you choose a username and password next"],
385385 ["pricing", "Get a forge (plans)", "pick a plan, name it, pay at the checkout address"],

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs