From 1c10ede9c5fcf8586785ea6a3ec594c8a72519f8 Mon Sep 17 00:00:00 2001 From: Serhii Vecherenko Date: Sun, 21 Jun 2026 20:55:26 -0700 Subject: [PATCH] fix(workflow): scope background-workflow liveness to current session Prevent replayed workflow rows from showing a live spinner when their detached runtime process is gone (prior session or after a restart). - Add observedLive flag on runtime items, set only for live-streamed item.started events and never persisted/hydrated from the DB - Replace isLiveWorkflowRunStatus with isWorkflowRunLive, which also rejects stale `running` manifests left by a crashed runtime - Gate chat row and tile spinners on session-owned liveness - Tie manifest poller backstop to WORKFLOW_STALE_PROGRESS_MS - Add tests for isWorkflowRunLive and observedLive flagging --- .../parts/items/ActiveSubAgentTile.tsx | 23 +++++- .../ChatPane/parts/items/SubAgentToolCall.tsx | 21 +++--- .../state/slices/runtimeEventSlice.test.ts | 22 ++++++ .../state/slices/runtimeEventSlice.ts | 11 +++ .../state/threadLiveWorkflowStore.test.ts | 34 ++++++++- src/renderer/state/threadLiveWorkflowStore.ts | 15 ++-- src/renderer/state/workflowRunStore.ts | 8 +- .../contracts/workflowTranscript.test.ts | 73 +++++++++++++++++++ src/shared/contracts/workflowTranscript.ts | 59 +++++++++++++-- 9 files changed, 235 insertions(+), 31 deletions(-) create mode 100644 src/shared/contracts/workflowTranscript.test.ts 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; +}