diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index a233191dd..683458c13 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -1,6 +1,13 @@ # Changelog ## [Unreleased] +### Changed + +- Removed the four-workflow display cap from the BACKGROUND widget so every qualifying top-level run is rendered. + +### Fixed + +- Quit workflow cards now expire from the BACKGROUND widget after the same recent-run window as finished cards while remaining resumable and discoverable through workflow status; the header count now matches the rendered cards after expiry. ## [0.9.11-alpha.10] - 2026-08-01 diff --git a/packages/coding-agent/docs/workflows.md b/packages/coding-agent/docs/workflows.md index aa6f2e357..7611d9703 100644 --- a/packages/coding-agent/docs/workflows.md +++ b/packages/coding-agent/docs/workflows.md @@ -117,7 +117,7 @@ Named workflow runs execute in the background. By default, after launch expect a For a request with several implementation items, do not turn list order into one serial workflow by default. Triage dependencies first, then launch independent items as a bounded wave of separate top-level runs; see [Task queues and software factories](#task-queues-and-software-factories). -While a workflow is running, the visible below-editor `BACKGROUND` panel advances its elapsed label every second from the moment the run starts; it does not require opening or switching to the orchestrator. Updates repaint the existing mounted panel in place, paused timers stay frozen, and terminal cards retain their short recent-run expiry. +While a workflow is running, the visible below-editor `BACKGROUND` panel advances its elapsed label every second from the moment the run starts; it does not require opening or switching to the orchestrator. Updates repaint the existing mounted panel in place, paused timers stay frozen, the panel renders every qualifying top-level run, and terminal or quit cards retain their short recent-run expiry. Quit cards remain resumable and discoverable with `/workflow status` after they leave the panel. ### Or hand-write the TypeScript diff --git a/packages/workflows/src/shared/store-run-methods.ts b/packages/workflows/src/shared/store-run-methods.ts index 5a6d976b6..94ca797ca 100644 --- a/packages/workflows/src/shared/store-run-methods.ts +++ b/packages/workflows/src/shared/store-run-methods.ts @@ -179,6 +179,7 @@ export function createRunStoreMethods(context: StoreContext): RunStoreMethods { if (!run) return false; if (TERMINAL_STATUSES.has(run.status)) return false; const wasPaused = run.status === "paused"; + const enteringQuit = metadata?.exitReason === "quit" && run.exitReason !== "quit"; if (!wasPaused) { run.status = "paused"; run.pausedAt = pausedAt ?? Date.now(); @@ -186,6 +187,7 @@ export function createRunStoreMethods(context: StoreContext): RunStoreMethods { } if (metadata?.resumable !== undefined) run.resumable = metadata.resumable; if (metadata?.exitReason !== undefined) run.exitReason = metadata.exitReason; + if (enteringQuit) run.quitAt = Date.now(); if (wasPaused && metadata === undefined) return false; context.bumpAndNotify(); return true; @@ -201,6 +203,7 @@ export function createRunStoreMethods(context: StoreContext): RunStoreMethods { run.pausedDurationMs = accumulatePausedDurationMs(run.pausedDurationMs, run.pausedAt, resumedTs); run.resumedAt = resumedTs; run.pausedAt = undefined; + delete run.quitAt; delete run.exitReason; context.bumpAndNotify(); return true; diff --git a/packages/workflows/src/shared/store-types.ts b/packages/workflows/src/shared/store-types.ts index b74db55b8..92a3228b2 100644 --- a/packages/workflows/src/shared/store-types.ts +++ b/packages/workflows/src/shared/store-types.ts @@ -282,6 +282,8 @@ export interface RunSnapshot { pausedDurationMs?: number; /** Timestamp set when a controlled pause begins; cleared on resume. */ pausedAt?: number; + /** Timestamp when the run entered resumable quit state; display-only expiry marker. */ + quitAt?: number; /** Timestamp recorded on the most recent resume from a paused state. */ resumedAt?: number; result?: WorkflowOutputValues; diff --git a/packages/workflows/src/tui/widget.ts b/packages/workflows/src/tui/widget.ts index 327423002..1cf636a6b 100644 --- a/packages/workflows/src/tui/widget.ts +++ b/packages/workflows/src/tui/widget.ts @@ -39,7 +39,6 @@ import type { PiTheme } from "./store-widget-installer.js"; // --------------------------------------------------------------------------- const SHORT_ID_LEN = 6; -const MAX_VISIBLE_RUNS = 4; export const RECENT_ENDED_WINDOW_MS = 30_000; const COLLAPSED_BREAKPOINT_COLS = 80; @@ -62,16 +61,28 @@ export function formatDuration(ms: number): string { // Run classification + selection // --------------------------------------------------------------------------- +function isQuitRun(run: RunSnapshot): boolean { + return run.endedAt === undefined && run.status === "paused" && run.exitReason === "quit"; +} + function isActive(run: RunSnapshot): boolean { - return run.endedAt === undefined; + return run.endedAt === undefined && !isQuitRun(run); } function recentlyEnded(run: RunSnapshot, now: number): boolean { return run.endedAt !== undefined && now - run.endedAt <= RECENT_ENDED_WINDOW_MS; } -function isQuitRun(run: RunSnapshot): boolean { - return run.endedAt === undefined && run.status === "paused" && run.exitReason === "quit"; +/** + * Returns the timestamp from which a quit card's display-only expiry is measured. + * Older snapshots do not have `quitAt`, so retain the bounded legacy fallbacks. + */ +function quitExpiryTimestamp(run: RunSnapshot): number { + return run.quitAt ?? run.pausedAt ?? run.startedAt; +} + +function recentlyQuit(run: RunSnapshot, now: number): boolean { + return isQuitRun(run) && now - quitExpiryTimestamp(run) <= RECENT_ENDED_WINDOW_MS; } interface RunCounts { @@ -123,7 +134,7 @@ function countRuns(runs: readonly RunSnapshot[], allRuns: readonly RunSnapshot[] /** * Returns the next wall-clock boundary that can change the visible widget. * Running elapsed labels tick on exact one-second boundaries; paused runs stay - * frozen. Recently ended cards retain their independent one-shot expiry. + * frozen. Recently ended and quit cards retain their independent one-shot expiry. * Reactive-widget updates the existing mounted component in place, so these * ticks repaint the visible panel without disposing or remounting it. */ @@ -131,9 +142,14 @@ export function nextWidgetRefreshDelayMs(snap: StoreSnapshot, now = Date.now()): const display = selectDisplayRuns(snap, now); if (display.length === 0) return undefined; - const delays: number[] = display - .filter((run) => run.endedAt !== undefined) - .map((run) => Math.max(1, run.endedAt! + RECENT_ENDED_WINDOW_MS - now + 1)); + const delays: number[] = []; + for (const run of display) { + if (run.endedAt !== undefined) { + delays.push(Math.max(1, run.endedAt + RECENT_ENDED_WINDOW_MS - now + 1)); + } else if (isQuitRun(run)) { + delays.push(Math.max(1, quitExpiryTimestamp(run) + RECENT_ENDED_WINDOW_MS - now + 1)); + } + } for (const run of display) { if (run.endedAt !== undefined || effectiveRunStatus(run) !== "running" || run.pausedAt !== undefined) continue; const remainder = elapsedRunMs(run, now) % 1_000; @@ -150,11 +166,10 @@ function selectDisplayRuns(snap: StoreSnapshot, now: number): RunSnapshot[] { // rule `statusRuns`/the `status` action already apply. const all = topLevelWorkflowRuns(snap.runs); const active = all.filter((r) => isActive(r)); - const recent = all.filter((r) => recentlyEnded(r, now)); + const recent = all.filter((r) => recentlyEnded(r, now) || recentlyQuit(r, now)); // Most recently started first within each bucket; active runs precede recent. const sort = (xs: RunSnapshot[]) => [...xs].sort((a, b) => (b.startedAt ?? 0) - (a.startedAt ?? 0)); - const ordered = [...sort(active), ...sort(recent)]; - return ordered.slice(0, MAX_VISIBLE_RUNS); + return [...sort(active), ...sort(recent)]; } // --------------------------------------------------------------------------- @@ -371,7 +386,7 @@ export function buildThemedWidgetLines( const display = selectDisplayRuns(snap, now); if (display.length === 0) return []; - const counts = countRuns(topLevelWorkflowRuns(snap.runs), snap.runs); + const displayCounts = countRuns(display, snap.runs); // Active + recently-ended dominate the badge counts so a finished run // visually persists for a beat before dropping off. const visibleCounts: RunCounts = { @@ -385,7 +400,7 @@ export function buildThemedWidgetLines( blocked: display.filter((r) => effectiveRunStatus(r) === "blocked").length, failed: display.filter((r) => r.endedAt !== undefined && ["failed", "killed"].includes(effectiveRunStatus(r))) .length, - awaiting: counts.awaiting, + awaiting: displayCounts.awaiting, }; const themed = piTheme !== undefined; @@ -396,7 +411,7 @@ export function buildThemedWidgetLines( return [themed ? themedCollapsed(visibleCounts, graphTheme) : plainCollapsed(visibleCounts)]; } - const total = counts.active + counts.paused + counts.quit + counts.done + counts.blocked + counts.failed; + const total = display.length; const subtitle = `${total} run${total === 1 ? "" : "s"}`; const badgeList = countBadges(visibleCounts, graphTheme); diff --git a/test/unit/store.test.ts b/test/unit/store.test.ts index 7ac077def..72e4fc2dd 100644 --- a/test/unit/store.test.ts +++ b/test/unit/store.test.ts @@ -280,6 +280,32 @@ describe("store run pausing", () => { assert.equal(run.resumedAt, 16_000); assert.equal(run.pausedDurationMs, 10_000); }); + test("records quit time separately from paused time and clears it on resume", () => { + const originalNow = Date.now; + let now = 10_000; + Date.now = () => now; + try { + const s = createStore(); + s.recordRunStart({ ...makeRun("quit"), startedAt: 1_000 }); + const pausedAt = now; + assert.equal(s.recordRunPaused("quit", pausedAt), true); + + now += 5_000; + const quitAt = now; + assert.equal(s.recordRunPaused("quit", undefined, { exitReason: "quit", resumable: true }), true); + let run = s.snapshot().runs[0]!; + assert.equal(run.pausedAt, pausedAt); + assert.equal(run.quitAt, quitAt); + assert.equal(run.endedAt, undefined); + + assert.equal(s.recordRunResumed("quit", now + 1_000), true); + run = s.snapshot().runs[0]!; + assert.equal(run.quitAt, undefined); + assert.equal(run.exitReason, undefined); + } finally { + Date.now = originalNow; + } + }); test("recordRunEnd excludes paused time from final duration", () => { const originalNow = Date.now; diff --git a/test/unit/widget-rendering.test.ts b/test/unit/widget-rendering.test.ts index 374637cde..c13444089 100644 --- a/test/unit/widget-rendering.test.ts +++ b/test/unit/widget-rendering.test.ts @@ -15,6 +15,8 @@ import assert from "node:assert/strict"; import { describe, test } from "vitest"; +import { statusRuns } from "../../packages/workflows/src/runs/background/status.js"; +import { createStore } from "../../packages/workflows/src/shared/store.js"; import type { RunSnapshot, StageSnapshot, StoreSnapshot } from "../../packages/workflows/src/shared/store-types.js"; import { hexToAnsi } from "../../packages/workflows/src/tui/color-utils.js"; import { deriveGraphTheme } from "../../packages/workflows/src/tui/graph-theme.js"; @@ -140,6 +142,48 @@ describe("renderWidgetLines — standard form", () => { assert.ok(lines[0]!.includes("BACKGROUND 1 run 1 quit")); assert.ok(joined.includes("quit · resumable via /workflow resume")); }); + test("quit card expires from the widget after the recent window while status stays resumable", () => { + const originalNow = Date.now; + let now = 1_000_000; + Date.now = () => now; + try { + const store = createStore(); + const runId = "quit-after-pause"; + store.recordRunStart(makeRun(runId, "resume-me-later", "running", [], now - RECENT_ENDED_WINDOW_MS * 3)); + const pausedAt = now - RECENT_ENDED_WINDOW_MS * 2; + assert.equal(store.recordRunPaused(runId, pausedAt), true); + + now += RECENT_ENDED_WINDOW_MS / 6; + const quitAt = now; + assert.equal(store.recordRunPaused(runId, undefined, { exitReason: "quit", resumable: true }), true); + + const quitRun = store.snapshot().runs[0]!; + assert.equal(quitRun.status, "paused"); + assert.equal(quitRun.endedAt, undefined); + assert.equal(quitRun.pausedAt, pausedAt, "quitting must not repurpose pausedAt"); + assert.equal(quitRun.quitAt, quitAt, "expiry must start when the run is quit"); + assert.equal(quitRun.resumable, true); + assert.ok( + renderWidgetLines(store.snapshot(), 120) + .map(stripAnsi) + .join("\n") + .includes("quit · resumable via /workflow resume"), + "a newly quit run should render immediately", + ); + + now = quitAt + RECENT_ENDED_WINDOW_MS + 1; + assert.deepEqual(renderWidgetLines(store.snapshot(), 120), [], "expired quit card should disappear"); + + const status = statusRuns({ store }); + assert.deepEqual( + status.map((entry) => [entry.runId, entry.status]), + [[runId, "paused"]], + ); + assert.equal(store.snapshot().runs[0]!.resumable, true, "expiry must not change resumability"); + } finally { + Date.now = originalNow; + } + }); test("running run shows chain mode when multi-stage", () => { const run = makeRun("xyz000aaaa", "deep-research", "running", [ @@ -188,6 +232,20 @@ describe("renderWidgetLines — standard form", () => { const wfOneIdx = lines.findIndex((l) => l.includes("wf-one")); assert.ok(wfTwoIdx < wfOneIdx, "most recently started run renders first"); }); + test("more than four concurrent runs all render without truncation", () => { + const now = Date.now(); + const runs = Array.from({ length: 6 }, (_, index) => + makeRun(`run-${index}-abcdef`, `wf-${index}`, "running", [], now - (6 - index) * 100), + ); + const lines = renderWidgetLines(makeSnap(runs), 120).map(stripAnsi); + const joined = lines.join("\n"); + + assert.ok(lines[0]!.includes("6 runs")); + for (let index = 0; index < runs.length; index++) { + assert.ok(joined.includes(`wf-${index}`), `workflow ${index} should render`); + } + assert.equal(lines.filter((line) => line.includes("single")).length, 6); + }); test("hides nested child workflow runs, showing only the top-level run", () => { const t = Date.now(); @@ -270,6 +328,25 @@ describe("renderWidgetLines — standard form", () => { assert.ok(header.includes("✓ 1 complete"), "completed badge"); assert.ok(header.includes("✗ 1 failed"), "failed badge"); }); + test("expired quit runs do not contribute counts after their cards disappear", () => { + const now = 1_000_000; + const active = makeRun("active-run", "still-running", "running", [], now - 1_000); + const expiredQuit = makeRun("expired-quit", "already-quit", "paused", [], now - RECENT_ENDED_WINDOW_MS * 2); + expiredQuit.pausedAt = now - RECENT_ENDED_WINDOW_MS * 2; + expiredQuit.quitAt = now - RECENT_ENDED_WINDOW_MS - 1; + expiredQuit.exitReason = "quit"; + expiredQuit.resumable = true; + const snap = makeSnap([active, expiredQuit]); + + const wide = renderWidgetLines(snap, 120).map(stripAnsi); + assert.ok(wide.join("\n").includes("still-running")); + assert.ok(wide[0]!.includes("BACKGROUND 1 run"), "wide header total must match its single rendered card"); + assert.ok(!wide[0]!.includes("quit"), "wide quit badge must match rendered cards"); + + const collapsed = renderWidgetLines(snap, 60).map(stripAnsi); + assert.ok(collapsed[0]!.includes("1 background")); + assert.ok(!collapsed[0]!.includes("quit"), "collapsed quit badge must match rendered cards"); + }); test("ctx.exit blocked remains distinct from completed exit statuses", () => { const t = Date.now(); @@ -360,6 +437,20 @@ describe("renderWidgetLines — standard form", () => { const ended = makeRun("r2xxxxxx", "wf-d", "completed", [], now - 20_000, now - 10_000); assert.equal(nextWidgetRefreshDelayMs(makeSnap([offsetActive, ended]), now), 750); }); + test("quit runs schedule the expiry repaint from quitAt", () => { + const now = 1_000_000; + const quitAt = now - RECENT_ENDED_WINDOW_MS / 6; + const quit = makeRun("quit-refresh", "wf-quit", "paused", [], now - RECENT_ENDED_WINDOW_MS * 2); + quit.pausedAt = now - RECENT_ENDED_WINDOW_MS * 2; + quit.quitAt = quitAt; + quit.exitReason = "quit"; + quit.resumable = true; + + assert.equal( + nextWidgetRefreshDelayMs(makeSnap([quit]), now), + RECENT_ENDED_WINDOW_MS - RECENT_ENDED_WINDOW_MS / 6 + 1, + ); + }); test("standard panel scales to the provided terminal width", () => { const width = 120;