diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index d8ee067c6c..1d0b29b018 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -305,9 +305,9 @@ Workspace status is an aggregate activity signal computed **per `workspaceId`**. Running provider-native subagents contribute `running` to the workspace owned by their parent agent. Their completed, failed, and canceled states stay in the parent's subagents track. -A finished workspace can be marked unread after it has been reviewed. The daemon restores -`finished` attention on its newest eligible workspace-root agent without sending a new completion -notification. Opening the workspace clears that attention through the normal focus flow. +A finished or failed workspace can be marked unread after it has been reviewed. The daemon restores +attention on its newest eligible workspace-root agent, `error` for an agent whose lifecycle is +`error` and `finished` otherwise, without sending a new notification. Opening the workspace clears that attention through the normal focus flow. Attention is set by the agent finishing or failing and cleared by the client's `workspace.clear_attention`, which fires when the user reads the chat. Loading an agent's runtime is @@ -316,6 +316,13 @@ untouched. Forging either makes a background resume look like the user read a wo agent worked in it just now, which rewrites the sidebar timestamp permanently — persisted `updatedAt` is what workspace `statusEnteredAt` is re-derived from on the next daemon start. +The `failed` bucket follows unread `error` attention, not lifecycle status. An agent stays `error` +until its next turn starts, so reading the chat dismisses the red dot the same way it dismisses the +green one. For the same reason every new failure raises `error` attention, including a failed reload +that leaves the lifecycle at `error`, and replaces unread `finished` attention; otherwise the failed +agent would show the green dot or none. Subagents track marks are the exception: they follow the +child's lifecycle, because the track is where a failed child stays visible. + ## The subagents track The track is a pill at the foot of an agent's pane (`packages/app/src/subagents/track.tsx`): a count you can read at a glance, and a panel behind it — a popover on wide screens, a sheet on compact ones — holding the rows. It floats over the transcript rather than sitting in a band above the composer, so the timeline scrolls underneath it; `packages/app/src/panels/agent-tracks.tsx` owns that placement, and the pill frame is shared with the task list in `packages/app/src/composer/tracks.tsx`. diff --git a/packages/app/src/panels/provider-subagent-panel.tsx b/packages/app/src/panels/provider-subagent-panel.tsx index 99a38c2f8b..377d5331c8 100644 --- a/packages/app/src/panels/provider-subagent-panel.tsx +++ b/packages/app/src/panels/provider-subagent-panel.tsx @@ -31,7 +31,7 @@ import { import { useTranslation } from "react-i18next"; import type { PendingPermission } from "@/types/shared"; import type { StreamItem } from "@/types/stream"; -import { deriveSidebarStateBucket } from "@/utils/sidebar-agent-state"; +import { deriveSubagentStatusBucket } from "@/subagents"; import { TIMELINE_FETCH_PAGE_SIZE } from "@/timeline/timeline-fetch-policy"; import type { TurnPresentation } from "@/timeline/turn-liveness"; import { buildWorkspaceTabPersistenceKey } from "@/workspace-tabs/model"; @@ -107,10 +107,7 @@ function useProviderSubagentDescriptor( titleState: descriptor ? "ready" : "loading", icon: useProviderIcon(provider, context.serverId), statusBucket: descriptor - ? deriveSidebarStateBucket({ - status: providerSubagentLifecycleStatus(descriptor.status), - requiresAttention: descriptor.status === "failed", - }) + ? deriveSubagentStatusBucket(providerSubagentLifecycleStatus(descriptor.status)) : null, }; } diff --git a/packages/app/src/subagents/index.ts b/packages/app/src/subagents/index.ts index 0b72ccfcf5..cd8ee753fb 100644 --- a/packages/app/src/subagents/index.ts +++ b/packages/app/src/subagents/index.ts @@ -10,3 +10,4 @@ export { type UseArchiveFinishedSubagentsInput, } from "./use-archive-finished"; export { useSubagentSheetTab } from "./sheet-tab"; +export { deriveSubagentStatusBucket } from "./track-presentation"; diff --git a/packages/app/src/subagents/track-presentation.ts b/packages/app/src/subagents/track-presentation.ts index d95082ca39..5a1d671339 100644 --- a/packages/app/src/subagents/track-presentation.ts +++ b/packages/app/src/subagents/track-presentation.ts @@ -1,7 +1,8 @@ import type { TFunction } from "i18next"; import type { ComposerTrackPillSegment } from "@/composer/tracks"; import type { SidebarStateBucket } from "@/utils/sidebar-agent-state"; -import { deriveSidebarStateBucket, STATUS_BUCKET_ORDER } from "@/utils/sidebar-agent-state"; +import type { AgentLifecycleStatus } from "@getpaseo/protocol/agent-lifecycle"; +import { STATUS_BUCKET_ORDER } from "@/utils/sidebar-agent-state"; import type { SubagentRow } from "./select"; import { isFinishedSubagent } from "./archive-finished"; import { providerSubagentLifecycleStatus } from "./provider-store"; @@ -14,6 +15,16 @@ function presentationStatus(row: SubagentRow) { return providerSubagentLifecycleStatus(row.status); } +/** + * A child's mark reports what it is doing, not whether anyone read it: the track is where a + * failed child stays visible after the parent's own attention is cleared. + */ +export function deriveSubagentStatusBucket(status: AgentLifecycleStatus): SidebarStateBucket { + if (status === "error") return "failed"; + if (status === "running") return "running"; + return "done"; +} + export interface SubagentRowPresentationData { key: string; kind: "agent"; @@ -38,10 +49,7 @@ export function buildSubagentRowPresentationData(row: SubagentRow): SubagentRowP label: label ?? "", subtitle: subtitle ?? "", titleState: label ? "ready" : "loading", - statusBucket: deriveSidebarStateBucket({ - status, - requiresAttention: false, - }), + statusBucket: deriveSubagentStatusBucket(status), }; } diff --git a/packages/app/src/utils/workspace-agent-activity.test.ts b/packages/app/src/utils/workspace-agent-activity.test.ts index 66cfcd758c..a539a3881a 100644 --- a/packages/app/src/utils/workspace-agent-activity.test.ts +++ b/packages/app/src/utils/workspace-agent-activity.test.ts @@ -93,6 +93,8 @@ describe("workspace agent activity index", () => { workspaceId: "workspace-error", status: "error", turn: { phase: "idle", cancellationRequestId: null }, + requiresAttention: true, + attentionReason: "error", updatedAt: "2026-01-01T00:00:00.000Z", }), ], diff --git a/packages/protocol/src/agent-state-bucket.test.ts b/packages/protocol/src/agent-state-bucket.test.ts index 3c5d281b39..89d1ef93ab 100644 --- a/packages/protocol/src/agent-state-bucket.test.ts +++ b/packages/protocol/src/agent-state-bucket.test.ts @@ -39,6 +39,28 @@ describe("deriveAgentStateBucket", () => { ).toBe("failed"); }); + it("treats an errored agent whose error was read as done", () => { + expect( + deriveAgentStateBucket({ + status: "error", + pendingPermissionCount: 0, + requiresAttention: false, + attentionReason: null, + }), + ).toBe("done"); + }); + + it("treats an errored agent with unread error attention as failed", () => { + expect( + deriveAgentStateBucket({ + status: "error", + pendingPermissionCount: 0, + requiresAttention: true, + attentionReason: "error", + }), + ).toBe("failed"); + }); + it("treats unread finished agents as attention", () => { expect( deriveAgentStateBucket({ @@ -83,10 +105,21 @@ describe("getAgentStatusPriority", () => { it("prioritizes pending permissions before errors and running agents", () => { const permission = getAgentStatusPriority({ status: "running", pendingPermissionCount: 1 }); expect(permission).toBeLessThan( - getAgentStatusPriority({ status: "error", pendingPermissionCount: 0 }), + getAgentStatusPriority({ + status: "error", + pendingPermissionCount: 0, + requiresAttention: true, + attentionReason: "error", + }), ); expect(permission).toBeLessThan( getAgentStatusPriority({ status: "running", pendingPermissionCount: 0 }), ); }); + + it("ranks an errored agent with a read error alongside completed agents", () => { + expect(getAgentStatusPriority({ status: "error", requiresAttention: false })).toBe( + getAgentStatusPriority({ status: "idle" }), + ); + }); }); diff --git a/packages/protocol/src/agent-state-bucket.ts b/packages/protocol/src/agent-state-bucket.ts index 2973152f1d..746d70a4e2 100644 --- a/packages/protocol/src/agent-state-bucket.ts +++ b/packages/protocol/src/agent-state-bucket.ts @@ -19,11 +19,17 @@ const WORKSPACE_STATE_BUCKET_PRIORITY = { done: 4, } as const satisfies Record; +// An error is a notification like a finish: once the user has seen it, the agent stops showing as +// failed even though its lifecycle stays "error" until the next turn starts. +function hasUnreadError(input: AgentStateBucketInput): boolean { + return input.requiresAttention === true && input.attentionReason === "error"; +} + export function deriveAgentStateBucket(input: AgentStateBucketInput): WorkspaceStateBucket { if ((input.pendingPermissionCount ?? 0) > 0 || input.attentionReason === "permission") { return "needs_input"; } - if (input.status === "error" || input.attentionReason === "error") { + if (hasUnreadError(input)) { return "failed"; } if (input.status === "running") { @@ -43,7 +49,7 @@ export function getAgentStatusPriority(input: AgentStateBucketInput): number { if ((input.pendingPermissionCount ?? 0) > 0 || input.attentionReason === "permission") { return 0; } - if (input.status === "error" || input.attentionReason === "error") { + if (hasUnreadError(input)) { return 1; } if (input.status === "running") { diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 57e5b0df20..6c7396d657 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -2801,6 +2801,53 @@ test("closing during reload leaves the replacement closed", async () => { } }); +test("every failed reload raises error attention, replacing unread finished attention", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-error-attention-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + const client = new HeldReloadCloseClient(); + const attentionReasons: Array<"finished" | "error" | "permission"> = []; + const manager = new AgentManager({ + clients: { codex: client }, + registry: storage, + logger, + rescueTimeouts: { reloadSessionCloseMs: 10 }, + onAgentAttention: ({ reason }) => attentionReasons.push(reason), + }); + try { + const created = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + await manager.markAgentUnread(created.id); + expect(manager.getAgent(created.id)?.attention).toMatchObject({ + attentionReason: "finished", + }); + + await expect(manager.reloadAgentSession(created.id)).rejects.toThrow("Timed out closing"); + + expect(manager.getAgent(created.id)?.lifecycle).toBe("error"); + expect(manager.getAgent(created.id)?.attention).toMatchObject({ + requiresAttention: true, + attentionReason: "error", + }); + expect(attentionReasons).toEqual(["error"]); + + await manager.clearAgentAttention(created.id); + await expect(manager.reloadAgentSession(created.id)).rejects.toThrow("Timed out closing"); + expect(manager.getAgent(created.id)?.attention).toMatchObject({ + requiresAttention: true, + attentionReason: "error", + }); + expect(attentionReasons).toEqual(["error", "error"]); + + client.finishClosing(); + await manager.closeAgent(created.id); + } finally { + client.finishClosing(); + await storage.flush(); + rmSync(workdir, { recursive: true, force: true }); + } +}); + test("retrying a timed-out reload waits for the original close to finish", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-reload-late-close-")); const storage = new AgentStorage(join(workdir, "agents"), logger); @@ -5807,6 +5854,52 @@ test("archiveSnapshot dispatches archived state for stored-only agents", async ( expect(last.lifecycle).toBe("closed"); }); +test("markAgentUnread restores error attention on a read errored agent", async () => { + const workdir = mkdtempSync(join(tmpdir(), "agent-manager-mark-unread-error-")); + const storage = new AgentStorage(join(workdir, "agents"), logger); + class ErrorClient extends TestAgentClient { + readonly session = new TestAgentSession({ provider: "codex", cwd: workdir }); + + override async createSession(): Promise { + return this.session; + } + } + const client = new ErrorClient(); + const manager = new AgentManager({ clients: { codex: client }, registry: storage, logger }); + onTestFinished(async () => { + await manager.closeAgent(agent.id); + await manager.flush(); + rmSync(workdir, { recursive: true, force: true }); + }); + const agent = await manager.createAgent({ provider: "codex", cwd: workdir }, undefined, { + workspaceId: undefined, + }); + const running = waitForAgentLifecycle(manager, agent.id, "running"); + client.session.pushEvent({ type: "turn_started", provider: "codex", turnId: "failed-turn" }); + await running; + const failed = waitForAgentLifecycle(manager, agent.id, "error"); + client.session.pushEvent({ + type: "turn_failed", + provider: "codex", + turnId: "failed-turn", + error: "Selected model is at capacity.", + }); + await failed; + await manager.clearAgentAttention(agent.id); + + await manager.markAgentUnread(agent.id); + + expect(manager.getAgent(agent.id)?.attention).toMatchObject({ + requiresAttention: true, + attentionReason: "error", + }); + expect(await storage.get(agent.id)).toMatchObject({ + lastStatus: "error", + requiresAttention: true, + attentionReason: "error", + }); +}); + test("markAgentUnread dispatches stored attention to every subscriber", async () => { const workdir = mkdtempSync(join(tmpdir(), "agent-manager-mark-unread-dispatch-")); const storage = new AgentStorage(join(workdir, "agents"), logger); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 387d76082d..fea3eed438 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -411,6 +411,13 @@ function raisesAttention(agent: Pick): return !agent.internal && !agent.background; } +/** The attention a read agent gets back when marked unread; null while it is still working. */ +function unreadAttentionReason(status: AgentLifecycleStatus): "finished" | "error" | null { + if (status === "idle" || status === "closed") return "finished"; + if (status === "error") return "error"; + return null; +} + function resolveInitialAttention(input: AttentionState | undefined): AttentionState { if (input == null || !input.requiresAttention) { return { requiresAttention: false }; @@ -1776,6 +1783,7 @@ export class AgentManager { } else if (this.agents.get(agentId) === existing) { existing.lifecycle = "error"; existing.lastError = error instanceof Error ? error.message : String(error); + this.raiseErrorAttention(existing); this.emitState(existing); } throw error; @@ -2433,16 +2441,16 @@ export class AgentManager { async markAgentUnread(agentId: string): Promise { const liveAgent = this.agents.get(agentId); if (liveAgent) { - const isFinished = liveAgent.lifecycle === "idle"; + const attentionReason = unreadAttentionReason(liveAgent.lifecycle); const hasPendingPermissions = liveAgent.pendingPermissions.size > 0; const canMarkUnread = - isFinished && !liveAgent.attention.requiresAttention && !hasPendingPermissions; + attentionReason && !liveAgent.attention.requiresAttention && !hasPendingPermissions; if (!canMarkUnread) { - throw new Error(`Agent is no longer finished and read: ${agentId}`); + throw new Error(`Agent is no longer settled and read: ${agentId}`); } liveAgent.attention = { requiresAttention: true, - attentionReason: "finished", + attentionReason, attentionTimestamp: new Date(), }; await this.persistSnapshot(liveAgent); @@ -2452,18 +2460,18 @@ export class AgentManager { const registry = this.requireRegistry(); const record = await registry.get(agentId); - const hasFinishedStatus = record?.lastStatus === "idle" || record?.lastStatus === "closed"; + const attentionReason = record?.lastStatus ? unreadAttentionReason(record.lastStatus) : null; const canMarkUnread = record && !record.internal && !record.archivedAt && !record.requiresAttention; - if (!canMarkUnread || !hasFinishedStatus) { - throw new Error(`Agent is no longer finished and read: ${agentId}`); + if (!canMarkUnread || !attentionReason) { + throw new Error(`Agent is no longer settled and read: ${agentId}`); } const updatedAt = this.nextStoredUpdatedAt(record); const nextRecord: StoredAgentRecord = { ...record, updatedAt, requiresAttention: true, - attentionReason: "finished", + attentionReason, attentionTimestamp: updatedAt, }; await registry.upsert(nextRecord); @@ -5300,12 +5308,15 @@ export class AgentManager { return; } - // Skip if already requires attention + if (previousStatus !== "error" && currentStatus === "error") { + this.raiseErrorAttention(agent); + return; + } + if (agent.attention.requiresAttention) { return; } - // Check if agent transitioned from running to idle (finished) if (previousStatus === "running" && currentStatus === "idle") { agent.attention = { requiresAttention: true, @@ -5313,19 +5324,25 @@ export class AgentManager { attentionTimestamp: new Date(), }; this.broadcastAgentAttention(agent, "finished"); - return; } + } - // Check if agent entered error state - if (previousStatus !== "error" && currentStatus === "error") { - agent.attention = { - requiresAttention: true, - attentionReason: "error", - attentionTimestamp: new Date(), - }; - this.broadcastAgentAttention(agent, "error"); + // The failed state is only shown while error attention is unread, so every new failure raises + // it, including one that keeps the lifecycle at "error". An error outranks an unread finish, + // which would otherwise make the failure look green. + private raiseErrorAttention(agent: ManagedAgent): void { + if (!raisesAttention(agent)) { return; } + if (agent.attention.requiresAttention && agent.attention.attentionReason !== "finished") { + return; + } + agent.attention = { + requiresAttention: true, + attentionReason: "error", + attentionTimestamp: new Date(), + }; + this.broadcastAgentAttention(agent, "error"); } private enqueueBackgroundPersist(agent: ManagedAgent): void { diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 4b260f8550..9ce3fbbcb4 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -7955,7 +7955,10 @@ export class Session { .filter((agent) => !agent.archivedAt && !agent.background) .filter((agent) => agent.workspaceId === workspace.workspaceId) .filter((agent) => resolveWorkspaceRootAgent(agent, agentsById)?.id === agent.id) - .filter((agent) => agent.status === "idle" || agent.status === "closed") + .filter( + (agent) => + agent.status === "idle" || agent.status === "closed" || agent.status === "error", + ) .filter((agent) => agent.requiresAttention !== true) .filter((agent) => (agent.pendingPermissions?.length ?? 0) === 0) .sort( @@ -7964,7 +7967,7 @@ export class Session { ); const candidate = candidates[0]; if (!candidate) { - throw new Error(`Workspace has no finished agent to mark unread: ${workspaceId}`); + throw new Error(`Workspace has no read agent to mark unread: ${workspaceId}`); } await this.agentManager.markAgentUnread(candidate.id); diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index 6b1db34449..928131ab3a 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -2123,7 +2123,53 @@ test("workspace mark unread selects a finished cross-workspace child with scoped } }); -test("workspace mark unread rejects workspaces without a finished root agent", async () => { +test("workspace mark unread marks a read errored root agent", async () => { + const emitted: SessionOutboundMessage[] = []; + const workspace = createPersistedWorkspaceRecord({ + workspaceId: REPO_CWD, + projectId: REPO_CWD, + cwd: REPO_CWD, + kind: "directory", + displayName: "repo", + createdAt: "2026-03-30T15:00:00.000Z", + updatedAt: "2026-03-30T15:00:00.000Z", + }); + const markedAgentIds: string[] = []; + const session = createSessionForWorkspaceTests({ + onMessage: (message) => emitted.push(message), + agentManager: { + markAgentUnread: async (agentId: string) => { + markedAgentIds.push(agentId); + }, + }, + }); + session.workspaceRegistry.get = async (id: string) => + id === workspace.workspaceId ? workspace : null; + session.listAgentPayloads = async () => [ + makeAgent({ + id: "errored-agent", + cwd: REPO_CWD, + workspaceId: workspace.workspaceId, + status: "error", + updatedAt: "2026-03-30T16:00:00.000Z", + requiresAttention: false, + }), + ]; + + await session.handleMessage({ + type: "workspace.mark_unread.request", + workspaceId: workspace.workspaceId, + requestId: "req-mark-unread", + }); + + expect(markedAgentIds).toEqual(["errored-agent"]); + expect(findByType(emitted, "workspace.mark_unread.response").payload).toMatchObject({ + markedAgentId: "errored-agent", + success: true, + }); +}); + +test("workspace mark unread rejects workspaces without a read root agent", async () => { const emitted: SessionOutboundMessage[] = []; const workspace = createPersistedWorkspaceRecord({ workspaceId: REPO_CWD, @@ -2150,7 +2196,7 @@ test("workspace mark unread rejects workspaces without a finished root agent", a workspaceId: workspace.workspaceId, markedAgentId: null, success: false, - error: `Workspace has no finished agent to mark unread: ${workspace.workspaceId}`, + error: `Workspace has no read agent to mark unread: ${workspace.workspaceId}`, }); }); diff --git a/packages/server/src/server/workspace-directory.test.ts b/packages/server/src/server/workspace-directory.test.ts index ab2ead15aa..85e8aeb600 100644 --- a/packages/server/src/server/workspace-directory.test.ts +++ b/packages/server/src/server/workspace-directory.test.ts @@ -343,6 +343,21 @@ describe("WorkspaceDirectory", () => { await expect(workspace.workspaceStatus()).resolves.toBe("running"); }); + test("an errored root agent shows failed only until its error is read", async () => { + const unread = new WorkspaceStatus(); + unread.hasRootAgent({ + id: "root-agent", + status: "error", + requiresAttention: true, + attentionReason: "error", + }); + await expect(unread.workspaceStatus()).resolves.toBe("failed"); + + const read = new WorkspaceStatus(); + read.hasRootAgent({ id: "root-agent", status: "error", requiresAttention: false }); + await expect(read.workspaceStatus()).resolves.toBe("done"); + }); + test("a running background agent does not make its workspace running", async () => { const workspace = new WorkspaceStatus();