Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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

Expand Down
2 changes: 1 addition & 1 deletion packages/coding-agent/docs/workflows.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions packages/workflows/src/shared/store-run-methods.ts
Original file line number Diff line number Diff line change
Expand Up @@ -179,13 +179,15 @@ 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();
run.resumedAt = undefined;
}
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;
Expand All @@ -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;
Expand Down
2 changes: 2 additions & 0 deletions packages/workflows/src/shared/store-types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
43 changes: 29 additions & 14 deletions packages/workflows/src/tui/widget.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand All @@ -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 {
Expand Down Expand Up @@ -123,17 +134,22 @@ 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.
*/
export function nextWidgetRefreshDelayMs(snap: StoreSnapshot, now = Date.now()): number | undefined {
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;
Expand All @@ -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)];
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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 = {
Expand All @@ -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;
Expand All @@ -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);
Expand Down
26 changes: 26 additions & 0 deletions test/unit/store.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
91 changes: 91 additions & 0 deletions test/unit/widget-rendering.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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", [
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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;
Expand Down
Loading