diff --git a/src/renderer/components/thread/ChatPane/parts/items/ActiveSubAgentTile.tsx b/src/renderer/components/thread/ChatPane/parts/items/ActiveSubAgentTile.tsx
index 2a74d11b..32a99bee 100644
--- a/src/renderer/components/thread/ChatPane/parts/items/ActiveSubAgentTile.tsx
+++ b/src/renderer/components/thread/ChatPane/parts/items/ActiveSubAgentTile.tsx
@@ -12,8 +12,12 @@ import {
selectActiveSubAgentParentItemIds,
} from "../../chatPaneSelectors";
import { getRuntimeItemPayload } from "@/renderer/state/slices/runtimeEventSlice";
-import type { ProjectLocation, ToolCallPayload, WorkflowRun } from "@/shared/contracts";
-import { isLiveWorkflowRunStatus } from "@/shared/contracts";
+import {
+ isWorkflowRunLive,
+ type ProjectLocation,
+ type ToolCallPayload,
+ type WorkflowRun,
+} from "@/shared/contracts";
import { deriveToolDisplay, isWorkflowTool } from "./toolDisplay";
import { PixelLoader } from "@/renderer/components/common/PixelLoader";
import { formatTokenCount } from "@/renderer/components/thread/formatTokenCount";
@@ -132,8 +136,15 @@ function ActiveSubAgentRow({
// in that gap, otherwise the composer dock shows ✓ while the chat row
// still says "starting…".
const workflowIsBackground = workflow !== null && !!workflow.manifestPath;
- const workflowIsTerminal = workflowRun !== null && !isLiveWorkflowRunStatus(workflowRun.status);
- const workflowIsLive = workflowIsBackground && !workflowIsTerminal;
+ // A detached background workflow only keeps running while THIS app session's
+ // process is alive. Opening a thread whose workflow was launched in a prior
+ // session (or before a restart) must not show it as live - that process is
+ // gone, even if the manifest is still pinned "running" on disk. `observedLive`
+ // is set only on items that streamed in live this session, so it tells us
+ // whether this session is the one that launched the workflow.
+ const workflowOwnedThisSession = item?.observedLive === true;
+ const workflowIsTerminal = workflowRun !== null && !isWorkflowRunLive(workflowRun);
+ const workflowIsLive = workflowIsBackground && workflowOwnedThisSession && !workflowIsTerminal;
// Auto-dismiss workflows once their manifest reports a terminal status. We
// intentionally leave the row visible for one render cycle so the user
@@ -155,6 +166,9 @@ function ActiveSubAgentRow({
const liveWorkflowTranscriptDir = workflow?.transcriptDir;
useEffect(() => {
if (!liveWorkflowManifestPath || !projectLocation) return;
+ // Never light the thread spinner for a workflow this session didn't launch
+ // (replayed from history on thread open) - it's already dead.
+ if (!workflowOwnedThisSession) return;
if (workflowIsTerminal) {
markWorkflowTerminal(threadId, itemId);
return;
@@ -170,6 +184,7 @@ function ActiveSubAgentRow({
liveWorkflowManifestPath,
liveWorkflowTranscriptDir,
projectLocation,
+ workflowOwnedThisSession,
workflowIsTerminal,
threadId,
itemId,
diff --git a/src/renderer/components/thread/ChatPane/parts/items/SubAgentToolCall.tsx b/src/renderer/components/thread/ChatPane/parts/items/SubAgentToolCall.tsx
index 3a4ab9c1..ab95514d 100644
--- a/src/renderer/components/thread/ChatPane/parts/items/SubAgentToolCall.tsx
+++ b/src/renderer/components/thread/ChatPane/parts/items/SubAgentToolCall.tsx
@@ -4,11 +4,7 @@ import { msg } from "@lingui/core/macro";
import { Trans, useLingui } from "@lingui/react/macro";
import type { TranslateFn } from "@/renderer/i18n/i18n";
import { Bot, ChevronDown, ChevronRight, CircleAlert, type LucideIcon } from "lucide-react";
-import {
- isLiveWorkflowRunStatus,
- type ToolCallPayload,
- type WorkflowRun,
-} from "@/shared/contracts";
+import { isWorkflowRunLive, type ToolCallPayload, type WorkflowRun } from "@/shared/contracts";
import { PixelLoader } from "@/renderer/components/common";
import { useAppStore } from "@/renderer/state/appStore";
import {
@@ -76,9 +72,14 @@ export const SubAgentToolCall = memo(function SubAgentToolCall({
// a missing manifest file (ENOENT) is normal in the first ~second after
// launch and must NOT collapse the row to "done".
const workflowIsBackground = workflow !== null && !!workflow.manifestPath;
+ // Only a workflow launched in THIS session can still be live; a row replayed
+ // from a prior session's transcript on thread open renders with its final
+ // manifest stats, never a live spinner (its detached process is gone).
+ const workflowOwnedThisSession = item.observedLive === true;
const workflowIsLive =
workflowIsBackground &&
- (workflowRun.run === null || isLiveWorkflowRunStatus(workflowRun.run.status));
+ workflowOwnedThisSession &&
+ (workflowRun.run === null || isWorkflowRunLive(workflowRun.run));
const status = resolveStatus(
item,
payload,
@@ -196,7 +197,7 @@ function resolveStatus(
// see N/N agents instead of "0 steps" when the manifest data is in.
if (workflowRun) {
return {
- rightLabel: ,
+ rightLabel: ,
rightLabelClassName: "!text-[color:var(--muted)]",
};
}
@@ -271,7 +272,7 @@ function resolveStatus(
};
}
-function WorkflowRunStats({ run }: { run: WorkflowRun }) {
+function WorkflowRunStats({ run, live }: { run: WorkflowRun; live: boolean }) {
const { t } = useLingui();
const completed = countDoneAgents(run);
// Workflow runtime only records agents in `workflowProgress` once they
@@ -281,7 +282,9 @@ function WorkflowRunStats({ run }: { run: WorkflowRun }) {
// an empty `0/0 agents` while the workflow is genuinely running.
const trackedAgents = sumTrackedAgents(run);
const total = Math.max(run.agentCount, trackedAgents);
- const isLive = isLiveWorkflowRunStatus(run.status);
+ // Liveness is decided by the caller (session ownership + manifest status),
+ // not the manifest alone, so a replayed dead run shows stats without a spinner.
+ const isLive = live;
const parts: string[] = [];
if (total > 0) {
parts.push(`${completed}/${total} agents`);
diff --git a/src/renderer/state/slices/runtimeEventSlice.test.ts b/src/renderer/state/slices/runtimeEventSlice.test.ts
index b63002ee..72e0ee9e 100644
--- a/src/renderer/state/slices/runtimeEventSlice.test.ts
+++ b/src/renderer/state/slices/runtimeEventSlice.test.ts
@@ -4,6 +4,7 @@ import type { RuntimeEvent } from "@/shared/contracts";
import {
createRuntimeEventSlice,
subscribeRuntimePersistenceDirtyThreads,
+ type RuntimeChatItem,
type RuntimeEventSlice,
} from "./runtimeEventSlice";
@@ -608,4 +609,25 @@ describe("runtimeEventSlice.applyRuntimeEvent", () => {
{ startedAt: 20, endedAt: 30, anchorItemId: "live" },
]);
});
+
+ it("flags live-streamed items as observedLive for session-scoped liveness", () => {
+ apply("t1", {
+ type: "item.started",
+ threadId: "t1",
+ itemId: "i1",
+ itemType: "tool_call",
+ });
+ expect(store.getState().runtimeItemsByIdByThread["t1"]?.["i1"]?.observedLive).toBe(true);
+ });
+
+ it("does not flag DB-hydrated items as observedLive (replayed on thread open)", () => {
+ const seeded: RuntimeChatItem = {
+ id: "i1",
+ type: "tool_call",
+ state: "completed",
+ streams: {},
+ };
+ store.getState().hydrateThreadRuntimeItems("t1", [seeded]);
+ expect(store.getState().runtimeItemsByIdByThread["t1"]?.["i1"]?.observedLive).toBeUndefined();
+ });
});
diff --git a/src/renderer/state/slices/runtimeEventSlice.ts b/src/renderer/state/slices/runtimeEventSlice.ts
index 012ac735..c2e62951 100644
--- a/src/renderer/state/slices/runtimeEventSlice.ts
+++ b/src/renderer/state/slices/runtimeEventSlice.ts
@@ -65,6 +65,16 @@ export interface RuntimeChatItem {
* under their parent row instead of listing them as top-level entries.
*/
parentItemId?: string;
+ /**
+ * True when this item was first seen via the LIVE event stream in the current
+ * app session (`item.started`), as opposed to being seeded from the DB on
+ * thread open (`hydrateThreadRuntimeItems`). Scopes background-workflow
+ * liveness: a detached workflow only keeps running if THIS session launched
+ * it, so a row replayed from a prior session's transcript must never light
+ * the working spinner (manifest status can't tell - a crashed run stays
+ * pinned "running" on disk). Never persisted; absent on DB-hydrated items.
+ */
+ observedLive?: boolean;
}
export interface OpenRuntimeRequest {
@@ -611,6 +621,7 @@ function applyRuntimeEventToRuntimeState(
state: "started",
payload: event.payload,
streams: {},
+ observedLive: true,
...(event.parentItemId ? { parentItemId: event.parentItemId } : {}),
};
return {
diff --git a/src/renderer/state/threadLiveWorkflowStore.test.ts b/src/renderer/state/threadLiveWorkflowStore.test.ts
index cf1a69fe..6a65a671 100644
--- a/src/renderer/state/threadLiveWorkflowStore.test.ts
+++ b/src/renderer/state/threadLiveWorkflowStore.test.ts
@@ -1,5 +1,9 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
-import type { ProjectLocation } from "@/shared/contracts";
+import {
+ WORKFLOW_STALE_PROGRESS_MS,
+ type ProjectLocation,
+ type WorkflowRun,
+} from "@/shared/contracts";
const { workflowGetRun } = vi.hoisted(() => ({
workflowGetRun: vi.fn<(payload: unknown) => Promise<{ run: unknown }>>(),
@@ -18,6 +22,22 @@ const isLive = (threadId: string) =>
const running = { run: { status: "running", phases: [], unphasedAgents: [], agentCount: 0 } };
const completed = { run: { status: "completed", phases: [], unphasedAgents: [], agentCount: 0 } };
+function runningRun(lastProgressAt: number): WorkflowRun {
+ return {
+ runId: "wf-test",
+ status: "running",
+ startTime: lastProgressAt,
+ agentCount: 1,
+ phases: [
+ {
+ title: "Run",
+ agents: [{ agentId: "agent-1", label: "agent-1", state: "running", lastProgressAt }],
+ },
+ ],
+ unphasedAgents: [],
+ };
+}
+
beforeEach(() => {
workflowGetRun.mockReset();
workflowGetRun.mockResolvedValue({ run: null });
@@ -55,6 +75,7 @@ describe("threadLiveWorkflowStore", () => {
describe("manifest poller", () => {
beforeEach(() => {
vi.useFakeTimers();
+ vi.setSystemTime(new Date("2026-06-01T12:00:00.000Z"));
});
afterEach(() => {
vi.useRealTimers();
@@ -109,5 +130,16 @@ describe("threadLiveWorkflowStore", () => {
await vi.advanceTimersByTimeAsync(POLL_MS + 1);
expect(isLive("live")).toBe(false);
});
+
+ it("clears a running manifest when its own progress is stale", async () => {
+ workflowGetRun.mockResolvedValue({
+ run: runningRun(Date.now() - WORKFLOW_STALE_PROGRESS_MS - 1),
+ });
+ register({ threadId: "stale", itemId: "i", manifestPath: "/stale.json", location });
+ expect(isLive("stale")).toBe(true);
+
+ await vi.advanceTimersByTimeAsync(POLL_MS + 1);
+ expect(isLive("stale")).toBe(false);
+ });
});
});
diff --git a/src/renderer/state/threadLiveWorkflowStore.ts b/src/renderer/state/threadLiveWorkflowStore.ts
index dd655c5e..49ef0bf4 100644
--- a/src/renderer/state/threadLiveWorkflowStore.ts
+++ b/src/renderer/state/threadLiveWorkflowStore.ts
@@ -1,6 +1,10 @@
import { create } from "zustand";
import { shallow } from "zustand/shallow";
-import { isLiveWorkflowRunStatus, type ProjectLocation } from "@/shared/contracts";
+import {
+ WORKFLOW_STALE_PROGRESS_MS,
+ isWorkflowRunLive,
+ type ProjectLocation,
+} from "@/shared/contracts";
import { readBridge } from "@/renderer/bridge";
/**
@@ -34,7 +38,7 @@ const LAUNCH_DEADLINE_MS = 10 * 60_000;
// Backstop for a workflow whose manifest was seen but never reaches a terminal
// status (e.g. the runtime crashed mid-run leaving it pinned "running"). Long
// enough not to cut off legitimately long orchestrations.
-const MAX_ENTRY_AGE_MS = 3 * 60 * 60 * 1000;
+const MAX_ENTRY_AGE_MS = WORKFLOW_STALE_PROGRESS_MS;
interface LiveWorkflowEntry {
threadId: string;
@@ -131,9 +135,10 @@ export const useThreadLiveWorkflowStore = create((set,
if (!current) return;
if (result.run) {
current.manifestSeen = true;
- // Drop on terminal status, or as an ultimate backstop for a manifest
- // that never reaches one.
- if (!isLiveWorkflowRunStatus(result.run.status) || isExpired(current)) {
+ // Drop once the manifest reports a terminal status, or its `running`
+ // status has gone stale - a crashed runtime that never wrote a terminal
+ // manifest (see isWorkflowRunLive).
+ if (!isWorkflowRunLive(result.run)) {
removeEntry(key);
}
return;
diff --git a/src/renderer/state/workflowRunStore.ts b/src/renderer/state/workflowRunStore.ts
index b923403b..d939a137 100644
--- a/src/renderer/state/workflowRunStore.ts
+++ b/src/renderer/state/workflowRunStore.ts
@@ -1,9 +1,5 @@
import { create } from "zustand";
-import {
- isLiveWorkflowRunStatus,
- type ProjectLocation,
- type WorkflowRun,
-} from "@/shared/contracts";
+import { isWorkflowRunLive, type ProjectLocation, type WorkflowRun } from "@/shared/contracts";
import { readBridge } from "@/renderer/bridge";
/**
@@ -84,7 +80,7 @@ export const useWorkflowRunStore = create((set, get) => {
// Keep polling at the active cadence rather than backing off; the
// file usually shows up within a couple of seconds of launch.
setEntry(itemId, { run: result.run, loading: false, error: null });
- const isLive = !result.run || isLiveWorkflowRunStatus(result.run.status);
+ const isLive = !result.run || isWorkflowRunLive(result.run);
if (isLive) {
poller.timer = setTimeout(() => void tick(itemId), ACTIVE_POLL_MS);
} else {
diff --git a/src/shared/contracts/workflowTranscript.test.ts b/src/shared/contracts/workflowTranscript.test.ts
new file mode 100644
index 00000000..38852f64
--- /dev/null
+++ b/src/shared/contracts/workflowTranscript.test.ts
@@ -0,0 +1,73 @@
+import { describe, expect, it } from "vitest";
+import {
+ WORKFLOW_STALE_PROGRESS_MS,
+ isWorkflowRunLive,
+ type WorkflowRun,
+} from "./workflowTranscript";
+
+function run(overrides: Partial = {}): WorkflowRun {
+ return {
+ runId: "wf-test",
+ status: "running",
+ agentCount: 0,
+ phases: [],
+ unphasedAgents: [],
+ ...overrides,
+ };
+}
+
+describe("isWorkflowRunLive", () => {
+ it("treats terminal statuses as not live", () => {
+ expect(isWorkflowRunLive(run({ status: "completed" }))).toBe(false);
+ });
+
+ it("uses workflow progress timestamps to reject stale running manifests", () => {
+ const now = Date.parse("2026-06-01T12:00:00.000Z");
+ expect(
+ isWorkflowRunLive(
+ run({
+ startTime: now - WORKFLOW_STALE_PROGRESS_MS - 1,
+ phases: [
+ {
+ title: "Run",
+ agents: [
+ {
+ agentId: "agent-1",
+ label: "agent-1",
+ state: "running",
+ lastProgressAt: now - WORKFLOW_STALE_PROGRESS_MS - 1,
+ },
+ ],
+ },
+ ],
+ }),
+ { now },
+ ),
+ ).toBe(false);
+ });
+
+ it("keeps old runs live when they have recent progress", () => {
+ const now = Date.parse("2026-06-01T12:00:00.000Z");
+ expect(
+ isWorkflowRunLive(
+ run({
+ startTime: now - 10 * WORKFLOW_STALE_PROGRESS_MS,
+ phases: [
+ {
+ title: "Run",
+ agents: [
+ {
+ agentId: "agent-1",
+ label: "agent-1",
+ state: "running",
+ lastProgressAt: now - 60_000,
+ },
+ ],
+ },
+ ],
+ }),
+ { now },
+ ),
+ ).toBe(true);
+ });
+});
diff --git a/src/shared/contracts/workflowTranscript.ts b/src/shared/contracts/workflowTranscript.ts
index e11d409d..08690b0b 100644
--- a/src/shared/contracts/workflowTranscript.ts
+++ b/src/shared/contracts/workflowTranscript.ts
@@ -84,13 +84,60 @@ export const workflowRunSchema = z.object({
});
export type WorkflowRun = z.infer;
+export const WORKFLOW_STALE_PROGRESS_MS = 3 * 60 * 60 * 1000;
+
/**
- * A workflow run is "live" while its manifest reports `running` - or `unknown`,
- * the pre-manifest / can't-parse state that precedes the first on-disk write.
- * Terminal states are `completed` / `failed` / `cancelled`. Shared so the
- * per-item dock poller, the chat row, and the per-thread live-workflow tracker
- * all agree on what counts as still in flight.
+ * A workflow run reports a "live" status while its manifest says `running` - or
+ * `unknown`, the pre-manifest / can't-parse state that precedes the first
+ * on-disk write. Terminal states are `completed` / `failed` / `cancelled`.
+ * Internal: callers should use `isWorkflowRunLive`, which also rejects stale
+ * `running` manifests left behind by a crashed runtime.
*/
-export function isLiveWorkflowRunStatus(status: WorkflowRunStatus): boolean {
+function isLiveWorkflowRunStatus(status: WorkflowRunStatus): boolean {
return status === "running" || status === "unknown";
}
+
+/**
+ * Most recent timestamp at which the run shows any sign of activity: the latest
+ * agent queued/started/progress time, the run start, or its computed end. Single
+ * pass with no intermediate arrays, since this runs on the manifest poll path.
+ * Every timestamp is a zod-validated int, so no NaN/Infinity guarding is needed.
+ */
+function getWorkflowRunLastActivityAt(run: WorkflowRun): number | undefined {
+ let last = -Infinity;
+ const consider = (time: number | undefined): void => {
+ if (time !== undefined && time > last) last = time;
+ };
+ for (const phase of run.phases) {
+ for (const agent of phase.agents) {
+ consider(agent.queuedAt);
+ consider(agent.startedAt);
+ consider(agent.lastProgressAt);
+ }
+ }
+ for (const agent of run.unphasedAgents) {
+ consider(agent.queuedAt);
+ consider(agent.startedAt);
+ consider(agent.lastProgressAt);
+ }
+ consider(run.startTime);
+ if (run.startTime !== undefined && run.durationMs !== undefined) {
+ consider(run.startTime + run.durationMs);
+ }
+ return last === -Infinity ? undefined : last;
+}
+
+/**
+ * Status-only liveness is not enough: if a workflow process dies before writing
+ * a terminal manifest, the persisted status remains `running` forever. Treat a
+ * `running` manifest with no activity for `WORKFLOW_STALE_PROGRESS_MS` as dead.
+ */
+export function isWorkflowRunLive(run: WorkflowRun, options: { now?: number } = {}): boolean {
+ if (!isLiveWorkflowRunStatus(run.status)) return false;
+
+ const lastActivityAt = getWorkflowRunLastActivityAt(run);
+ if (lastActivityAt === undefined) return true;
+
+ const now = options.now ?? Date.now();
+ return now - lastActivityAt <= WORKFLOW_STALE_PROGRESS_MS;
+}