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
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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
Expand All @@ -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;
Expand All @@ -170,6 +184,7 @@ function ActiveSubAgentRow({
liveWorkflowManifestPath,
liveWorkflowTranscriptDir,
projectLocation,
workflowOwnedThisSession,
workflowIsTerminal,
threadId,
itemId,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -196,7 +197,7 @@ function resolveStatus(
// see N/N agents instead of "0 steps" when the manifest data is in.
if (workflowRun) {
return {
rightLabel: <WorkflowRunStats run={workflowRun} />,
rightLabel: <WorkflowRunStats run={workflowRun} live={workflowIsLive} />,
rightLabelClassName: "!text-[color:var(--muted)]",
};
}
Expand Down Expand Up @@ -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
Expand All @@ -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`);
Expand Down
22 changes: 22 additions & 0 deletions src/renderer/state/slices/runtimeEventSlice.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { RuntimeEvent } from "@/shared/contracts";
import {
createRuntimeEventSlice,
subscribeRuntimePersistenceDirtyThreads,
type RuntimeChatItem,
type RuntimeEventSlice,
} from "./runtimeEventSlice";

Expand Down Expand Up @@ -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();
});
});
11 changes: 11 additions & 0 deletions src/renderer/state/slices/runtimeEventSlice.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -611,6 +621,7 @@ function applyRuntimeEventToRuntimeState(
state: "started",
payload: event.payload,
streams: {},
observedLive: true,
...(event.parentItemId ? { parentItemId: event.parentItemId } : {}),
};
return {
Expand Down
34 changes: 33 additions & 1 deletion src/renderer/state/threadLiveWorkflowStore.test.ts
Original file line number Diff line number Diff line change
@@ -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 }>>(),
Expand All @@ -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 });
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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);
});
});
});
15 changes: 10 additions & 5 deletions src/renderer/state/threadLiveWorkflowStore.ts
Original file line number Diff line number Diff line change
@@ -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";

/**
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -131,9 +135,10 @@ export const useThreadLiveWorkflowStore = create<ThreadLiveWorkflowStore>((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;
Expand Down
8 changes: 2 additions & 6 deletions src/renderer/state/workflowRunStore.ts
Original file line number Diff line number Diff line change
@@ -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";

/**
Expand Down Expand Up @@ -84,7 +80,7 @@ export const useWorkflowRunStore = create<WorkflowRunStore>((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 {
Expand Down
73 changes: 73 additions & 0 deletions src/shared/contracts/workflowTranscript.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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);
});
});
Loading