diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index cc6f43626..78a985574 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -107,6 +107,8 @@ Agent lifecycle status stays literal: a parent agent is `idle` when its own turn Workspace status is an aggregate activity signal computed **per `workspaceId`**. Ownership is never derived from `cwd` — many workspaces may share one directory, and same-`cwd` siblings do not clump under one status. Root agents and cross-workspace subagents contribute their normal state bucket to their own workspace. Same-workspace descendants contribute `running` to the nearest ancestor in that workspace; their non-running attention, permission, and error states stay in the parent's subagents track. This makes a cross-workspace subagent behave like a detached agent for workspace visibility and status without removing its parent relationship. +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. + ## The subagents track The collapsible track above the composer in an agent's pane (`packages/app/src/subagents/track.tsx`) combines two kinds of children: diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 06fd21a59..50903c5d2 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -2968,6 +2968,12 @@ test("hydrateTimelineFromProvider restores and broadcasts provider children from status: "completed", }), ]); + expect(manager.listProviderSubagentActivity()).toEqual([ + expect.objectContaining({ + id: "restored-child", + parentAgentId: snapshot.id, + }), + ]); expect(events).toContainEqual({ type: "provider_subagent", event: { @@ -5867,6 +5873,7 @@ test("subscribe hides provider subagents of internal parents from global subscri expect(() => manager.fetchProviderSubagentTimeline(internalAgentId, "hidden-child")).toThrow( `Unknown agent '${internalAgentId}'`, ); + expect(manager.listProviderSubagentActivity()).toEqual([]); }); test("subscribe emits state events for internal agents when subscribed by agentId", async () => { diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 2ebe7794f..773bffb84 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -1027,6 +1027,17 @@ export class AgentManager { return this.providerSubagents.list(parentAgentId); } + listProviderSubagentActivity(): ProviderSubagentDescriptor[] { + const publicParentIds = new Set( + Array.from(this.agents.values()) + .filter((agent) => !agent.internal) + .map((agent) => agent.id), + ); + return this.providerSubagents + .listAll() + .filter((subagent) => publicParentIds.has(subagent.parentAgentId)); + } + getProviderSubagent( parentAgentId: string, subagentId: string, diff --git a/packages/server/src/server/agent/provider-subagents/store.ts b/packages/server/src/server/agent/provider-subagents/store.ts index b29d9f6a5..8f2ccc60a 100644 --- a/packages/server/src/server/agent/provider-subagents/store.ts +++ b/packages/server/src/server/agent/provider-subagents/store.ts @@ -134,6 +134,10 @@ export class ProviderSubagentStore { .sort((left, right) => left.createdAt.localeCompare(right.createdAt)); } + listAll(): ProviderSubagentDescriptor[] { + return [...this.descriptors.values()]; + } + get(parentAgentId: string, subagentId: string): ProviderSubagentDescriptor | null { return this.descriptors.get(storeKey(parentAgentId, subagentId)) ?? null; } diff --git a/packages/server/src/server/session.test.ts b/packages/server/src/server/session.test.ts index 567775016..dbcefba8e 100644 --- a/packages/server/src/server/session.test.ts +++ b/packages/server/src/server/session.test.ts @@ -367,6 +367,7 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session { paseoHome: options.paseoHome ?? "/tmp/paseo-home", agentManager: asAgentManager({ listAgents: vi.fn(() => []), + listProviderSubagentActivity: vi.fn(() => []), subscribe: vi.fn(() => () => {}), ...options.agentManager, }), diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 0e2e6e2dc..432d1ff76 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -241,6 +241,11 @@ import { runGitCommand } from "../utils/run-git-command.js"; import { CreateAgentLifecycleDispatch } from "./agent/create-agent-lifecycle-dispatch.js"; import { resolveWorktreeSourceCwd } from "./workspace-source.js"; +type ProviderSubagentManagerEvent = Extract< + AgentManagerEvent, + { type: "provider_subagent" } +>["event"]; + // TODO: Remove once all app store clients are on >=0.1.45 and understand arbitrary provider strings. // Clients before 0.1.45 validate providers with z.enum(["claude", "codex", "opencode"]) and reject // the entire session message if they encounter an unknown provider. @@ -547,6 +552,12 @@ interface ArchivedRecordSnapshot { archivedAt?: string | null; } +interface WorkspaceUpdateOptions { + dedupeGitState?: boolean; + removedProjectId?: string; + optimisticStatus?: WorkspaceDescriptorPayload["status"]; +} + function describeRegistryTransition(record: ArchivedRecordSnapshot | null): RegistryTransition { if (!record) { return "created"; @@ -608,6 +619,7 @@ export class Session { private unsubscribeTerminalWorkspaceContributionEvents: (() => void) | null = null; private readonly agentUpdates: AgentUpdatesService; private workspaceUpdatesSubscription: WorkspaceUpdatesSubscriptionState | null = null; + private readonly workspaceUpdateTails = new Map>(); private clientActivity: { deviceType: "web" | "mobile"; focusedAgentId: string | null; @@ -959,6 +971,7 @@ export class Session { projectRegistry: this.projectRegistry, workspaceRegistry: this.workspaceRegistry, listAgentPayloads: () => this.listAgentPayloads(), + listProviderSubagentActivity: async () => this.agentManager.listProviderSubagentActivity(), listTerminalActivityContributions: () => this.listTerminalActivityContributions(), isProviderVisibleToClient: (provider) => this.isProviderVisibleToClient(provider), buildWorkspaceDescriptor: (input) => this.buildWorkspaceDescriptor(input), @@ -1528,6 +1541,7 @@ export class Session { } if (event.type === "provider_subagent") { + this.emitProviderSubagentWorkspaceUpdate(event.event); if (!this.supports(CLIENT_CAPS.providerSubagents)) { return; } @@ -1629,6 +1643,24 @@ export class Session { ); } + private emitProviderSubagentWorkspaceUpdate(event: ProviderSubagentManagerEvent): void { + if (event.type === "timeline") { + return; + } + const parentAgentId = + event.type === "upsert" ? event.subagent.parentAgentId : event.parentAgentId; + const parent = this.agentManager.getAgent(parentAgentId); + if (!parent?.workspaceId) { + return; + } + void this.emitWorkspaceUpdateForWorkspaceId(parent.workspaceId).catch((error) => { + this.sessionLogger.error( + { err: error, parentAgentId, workspaceId: parent.workspaceId }, + "Failed to emit provider subagent workspace update", + ); + }); + } + private buildAgentStreamPayload( event: Extract, serializedEvent: Extract["payload"]["event"], @@ -4662,11 +4694,7 @@ export class Session { private async emitWorkspaceUpdatesForWorkspaceIds( workspaceIds: Iterable, - options?: { - dedupeGitState?: boolean; - removedProjectId?: string; - optimisticStatus?: WorkspaceDescriptorPayload["status"]; - }, + options?: WorkspaceUpdateOptions, ): Promise { const subscription = this.workspaceUpdatesSubscription; if (!subscription) { @@ -4678,12 +4706,53 @@ export class Session { return; } + await this.enqueueWorkspaceUpdates(uniqueWorkspaceIds, subscription, options); + } + + private enqueueWorkspaceUpdates( + workspaceIds: ReadonlySet, + subscription: WorkspaceUpdatesSubscriptionState, + options: WorkspaceUpdateOptions | undefined, + ): Promise { + const previous = Array.from(workspaceIds, (workspaceId) => + (this.workspaceUpdateTails.get(workspaceId) ?? Promise.resolve()).catch(() => undefined), + ); + const next = Promise.all(previous).then(() => + this.emitWorkspaceUpdateBatch(workspaceIds, subscription, options), + ); + for (const workspaceId of workspaceIds) { + this.workspaceUpdateTails.set(workspaceId, next); + } + + const clearTail = () => { + for (const workspaceId of workspaceIds) { + if (this.workspaceUpdateTails.get(workspaceId) === next) { + this.workspaceUpdateTails.delete(workspaceId); + } + } + }; + void next.then(clearTail, clearTail); + return next; + } + + private async emitWorkspaceUpdateBatch( + workspaceIds: ReadonlySet, + subscription: WorkspaceUpdatesSubscriptionState, + options: WorkspaceUpdateOptions | undefined, + ): Promise { + if (this.workspaceUpdatesSubscription !== subscription) { + return; + } + const descriptorsByWorkspaceId = await this.buildWorkspaceDescriptorMap({ - workspaceIds: uniqueWorkspaceIds, + workspaceIds, includeGitData: true, }); - for (const workspaceId of uniqueWorkspaceIds) { + for (const workspaceId of workspaceIds) { + if (this.workspaceUpdatesSubscription !== subscription) { + return; + } const workspace = descriptorsByWorkspaceId.get(workspaceId); const filteredWorkspace = workspace && this.matchesWorkspaceFilter({ workspace, filter: subscription.filter }) @@ -4706,6 +4775,9 @@ export class Session { if (this.shouldSkipWorkspaceRemoval(lastEmitted, options?.removedProjectId)) { continue; } + if (this.workspaceUpdatesSubscription !== subscription) { + return; + } subscription.lastEmittedByWorkspaceId.delete(workspaceId); this.bufferOrEmitWorkspaceUpdate( subscription, diff --git a/packages/server/src/server/session.workspace-git-watch.test.ts b/packages/server/src/server/session.workspace-git-watch.test.ts index 00f3f780f..c038559fc 100644 --- a/packages/server/src/server/session.workspace-git-watch.test.ts +++ b/packages/server/src/server/session.workspace-git-watch.test.ts @@ -56,6 +56,15 @@ type WorkspaceUpdatePayload = Extract< { type: "workspace_update" } >["payload"]; +function getWorkspaceUpdates( + emitted: Array<{ type: string; payload: unknown }>, +): Array<{ type: "workspace_update"; payload: WorkspaceUpdatePayload }> { + return emitted.filter((message) => message.type === "workspace_update") as Array<{ + type: "workspace_update"; + payload: WorkspaceUpdatePayload; + }>; +} + const REPO_CWD = path.resolve("/tmp/repo"); const REPO_SUBSCRIPTION_REQUEST_ID = `subscription:${REPO_CWD}`; @@ -363,12 +372,9 @@ describe("workspace git watch targets", () => { }), ); - await Promise.resolve(); - await Promise.resolve(); + await vi.waitFor(() => expect(getWorkspaceUpdates(emitted)).toHaveLength(1)); - const workspaceUpdates = emitted.filter( - (message) => message.type === "workspace_update", - ) as Array<{ type: "workspace_update"; payload: WorkspaceUpdatePayload }>; + const workspaceUpdates = getWorkspaceUpdates(emitted); expect(workspaceUpdates).toHaveLength(1); expect(workspaceUpdates[0]?.payload).toMatchObject({ kind: "upsert", diff --git a/packages/server/src/server/session.workspaces.test.ts b/packages/server/src/server/session.workspaces.test.ts index de33dfe2a..8ec48b34a 100644 --- a/packages/server/src/server/session.workspaces.test.ts +++ b/packages/server/src/server/session.workspaces.test.ts @@ -23,6 +23,7 @@ import type { AgentSnapshotPayload, SessionOutboundMessage } from "@getpaseo/pro import type { TerminalManager } from "../terminal/terminal-manager.js"; import { createTerminalManager } from "../terminal/terminal-manager.js"; import { AgentManager, type AgentManagerEvent, type ManagedAgent } from "./agent/agent-manager.js"; +import type { ProviderSubagentDescriptor } from "./agent/provider-subagents/store.js"; import { AgentStorage, type StoredAgentRecord } from "./agent/agent-storage.js"; import type { AgentClient, @@ -573,6 +574,7 @@ function createSessionForWorkspaceTests( const agentManager = asAgentManager({ subscribe: () => () => {}, listAgents: () => [], + listProviderSubagentActivity: () => [], getAgent: () => null, archiveAgent: async () => ({ archivedAt: new Date().toISOString() }), archiveSnapshot: async () => ({}), @@ -7525,6 +7527,83 @@ test("a workspace leaving a filtered subscription after bootstrap emits a remova ]); }); +test("queued workspace updates are dropped when a new workspace subscription replaces the old one", async () => { + const emitted: SessionOutboundMessage[] = []; + const session = asTestSession( + createSessionForWorkspaceTests({ onMessage: (message) => emitted.push(message) }), + ); + const descriptor = { + id: "ws-replaced-subscription", + projectId: "proj-replaced-subscription", + projectDisplayName: "repo", + projectRootPath: REPO_CWD, + workspaceDirectory: REPO_CWD, + projectKind: "git" as const, + workspaceKind: "local_checkout" as const, + name: "repo work", + status: "done" as const, + activityAt: null, + diffStat: null, + }; + let currentDescriptor = descriptor; + let listCallCount = 0; + session.listFetchWorkspacesEntries = async () => { + listCallCount += 1; + return { + entries: listCallCount === 1 ? [descriptor] : [], + emptyProjects: [], + pageInfo: { nextCursor: null, prevCursor: null, hasMore: false }, + }; + }; + + const firstBuildStarted = deferred(); + const releaseFirstBuild = deferred(); + let buildCount = 0; + session.buildWorkspaceDescriptorMap = async () => { + buildCount += 1; + if (buildCount === 1) { + firstBuildStarted.resolve(); + await releaseFirstBuild.promise; + } + return new Map([[currentDescriptor.id, currentDescriptor]]); + }; + + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "req-old-workspaces", + filter: { query: "repo" }, + subscribe: { subscriptionId: "sub-old-workspaces" }, + }); + + emitted.length = 0; + const queuedUpdate = session.emitWorkspaceUpdatesForWorkspaceIds([descriptor.id]); + await firstBuildStarted.promise; + + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "req-new-workspaces", + filter: { query: "other" }, + subscribe: { subscriptionId: "sub-new-workspaces" }, + }); + + emitted.length = 0; + releaseFirstBuild.resolve(); + await queuedUpdate; + await waitForImmediate(); + + expect(filterByType(emitted, "workspace_update")).toEqual([]); + + currentDescriptor = { ...descriptor, name: "other work" }; + await session.emitWorkspaceUpdatesForWorkspaceIds([descriptor.id]); + + expect(filterByType(emitted, "workspace_update")).toEqual([ + { + type: "workspace_update", + payload: { kind: "upsert", workspace: currentDescriptor }, + }, + ]); +}); + const ICON_PNG_1X1 = Buffer.from([ 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a, 0x00, 0x00, 0x00, 0x0d, 0x49, 0x48, 0x44, 0x52, 0x00, 0x00, 0x00, 0x01, 0x00, 0x00, 0x00, 0x01, 0x08, 0x02, 0x00, 0x00, 0x00, @@ -8071,6 +8150,91 @@ async function waitForWorkspaceUpdate( throw new Error(`Timed out waiting for workspace_update: ${description}`); } +test("overlapping workspace rebuilds publish the newest provider subagent status", async () => { + const emitted: SessionOutboundMessage[] = []; + const parent = makeManagedAgent({ + id: "provider-parent", + cwd: REPO_CWD, + workspaceId: "ws-repo-running", + lifecycle: "idle", + updatedAt: "2026-08-01T10:00:00.000Z", + }) as unknown as ManagedAgent; + const providerSubagents: ProviderSubagentDescriptor[] = []; + let listener: ((event: AgentManagerEvent) => void) | null = null; + const session = createSessionForWorkspaceTests({ + onMessage: (message) => emitted.push(message), + agentStorage: { list: async () => [] }, + agentManager: { + subscribe: (nextListener: (event: AgentManagerEvent) => void) => { + listener = nextListener; + return () => {}; + }, + listAgents: () => [parent], + listProviderSubagentActivity: () => providerSubagents, + getAgent: (agentId: string) => (agentId === parent.id ? parent : null), + }, + }); + + await session.handleMessage({ + type: "fetch_workspaces_request", + requestId: "provider-subagent-workspace-status", + subscribe: { subscriptionId: "provider-subagent-workspace-status" }, + }); + const initialWorkspace = findByType(emitted, "fetch_workspaces_response")?.payload.entries[0]; + if (!initialWorkspace) { + throw new Error("Expected initial workspace descriptor"); + } + const firstBuildStarted = deferred(); + const releaseFirstBuild = deferred(); + let buildCount = 0; + session.buildWorkspaceDescriptorMap = async () => { + buildCount += 1; + const status = providerSubagents.some((subagent) => subagent.status === "running") + ? "running" + : "done"; + if (buildCount === 1) { + firstBuildStarted.resolve(); + await releaseFirstBuild.promise; + } + return new Map([[initialWorkspace.id, { ...initialWorkspace, status }]]); + }; + emitted.length = 0; + + const runningSubagent: ProviderSubagentDescriptor = { + id: "native-child", + parentAgentId: parent.id, + provider: "codex", + title: "Native child", + description: null, + status: "running", + createdAt: "2026-08-01T10:01:00.000Z", + updatedAt: "2026-08-01T10:01:00.000Z", + toolCallId: null, + cwd: REPO_CWD, + subtitle: null, + }; + providerSubagents.push(runningSubagent); + listener?.({ type: "provider_subagent", event: { type: "upsert", subagent: runningSubagent } }); + await firstBuildStarted.promise; + + const completedSubagent = { + ...runningSubagent, + status: "completed" as const, + updatedAt: "2026-08-01T10:02:00.000Z", + }; + providerSubagents[0] = completedSubagent; + listener?.({ type: "provider_subagent", event: { type: "upsert", subagent: completedSubagent } }); + await waitForImmediate(); + releaseFirstBuild.resolve(); + await vi.waitFor(() => expect(buildCount).toBe(2)); + await waitForImmediate(); + + const statuses = filterByType(emitted, "workspace_update").flatMap((message) => + message.payload.kind === "upsert" ? [message.payload.workspace.status] : [], + ); + expect(statuses).toEqual(["running", "done"]); +}); + test("title-only terminal change does not build workspace descriptors or emit workspace_update", async () => { const emitted: SessionOutboundMessage[] = []; const cwd = mkdtempSync(path.join(tmpdir(), "paseo-session-title-")); diff --git a/packages/server/src/server/workspace-directory.test.ts b/packages/server/src/server/workspace-directory.test.ts index 17f51b0f4..3f2a150b9 100644 --- a/packages/server/src/server/workspace-directory.test.ts +++ b/packages/server/src/server/workspace-directory.test.ts @@ -5,6 +5,7 @@ import type { AgentSnapshotPayload, WorkspaceDescriptorPayload } from "./message import { WorkspaceDirectory } from "./workspace-directory.js"; import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.js"; import type { TerminalActivity } from "@getpaseo/protocol/terminal-activity"; +import type { ProviderSubagentWorkspaceActivity } from "./workspace-directory.js"; const NOW = "2026-03-01T12:00:00.000Z"; @@ -58,6 +59,7 @@ class WorkspaceStatus { private readonly workspaces = [this.workspace]; private readonly agents: AgentSnapshotPayload[] = []; + private readonly providerSubagents: ProviderSubagentWorkspaceActivity[] = []; private readonly terminals: Array<{ cwd: string; workspaceId?: string; @@ -68,6 +70,7 @@ class WorkspaceStatus { projectRegistry: { list: async () => [this.project] }, workspaceRegistry: { list: async () => this.workspaces }, listAgentPayloads: async () => this.agents, + listProviderSubagentActivity: async () => this.providerSubagents, listTerminalActivityContributions: async () => this.terminals, isProviderVisibleToClient: () => true, buildWorkspaceDescriptor: async ({ workspace }) => ({ @@ -124,6 +127,10 @@ class WorkspaceStatus { ); } + hasProviderSubagent(input: ProviderSubagentWorkspaceActivity): void { + this.providerSubagents.push(input); + } + hasWorktreeWorkspace(): void { this.workspaces.push(this.worktreeWorkspace); } @@ -384,6 +391,40 @@ describe("WorkspaceDirectory", () => { await expect(workspace.workspaceStatus()).resolves.toBe("running"); }); + test("provider subagent follows its cross-workspace parent", async () => { + const workspace = new WorkspaceStatus(); + + workspace.hasWorktreeWorkspace(); + workspace.hasRootAgent({ id: "parent-agent", status: "idle" }); + workspace.hasDelegatedAgentInWorktree({ id: "worktree-child", status: "idle" }); + workspace.hasProviderSubagent({ + parentAgentId: "worktree-child", + status: "running", + updatedAt: "2026-03-01T12:01:00.000Z", + }); + + await expect(workspace.workspaceStatuses()).resolves.toEqual({ + "workspace-1": "done", + "workspace-worktree": "running", + }); + }); + + test("running provider subagent contributes running to its parent workspace", async () => { + const workspace = new WorkspaceStatus(); + + workspace.hasRootAgent({ id: "parent-agent", status: "idle" }); + workspace.hasProviderSubagent({ + parentAgentId: "parent-agent", + status: "running", + updatedAt: "2026-03-01T12:01:00.000Z", + }); + + await expect(workspace.workspaceDescriptor()).resolves.toMatchObject({ + status: "running", + statusEnteredAt: "2026-03-01T12:01:00.000Z", + }); + }); + test("running cross-workspace subagent contributes to its own workspace", async () => { const workspace = new WorkspaceStatus(); @@ -518,6 +559,7 @@ describe("WorkspaceDirectory empty projects", () => { projectRegistry: { list: async () => input.projects }, workspaceRegistry: { list: async () => input.workspaces }, listAgentPayloads: async () => [], + listProviderSubagentActivity: async () => [], listTerminalActivityContributions: async () => [], isProviderVisibleToClient: () => true, buildWorkspaceDescriptor: async ({ workspace }) => ({ diff --git a/packages/server/src/server/workspace-directory.ts b/packages/server/src/server/workspace-directory.ts index 8f6a4943d..a50713e16 100644 --- a/packages/server/src/server/workspace-directory.ts +++ b/packages/server/src/server/workspace-directory.ts @@ -19,6 +19,7 @@ import { deriveTerminalActivityStatusBucket, type TerminalActivity, } from "@getpaseo/protocol/terminal-activity"; +import type { ProviderSubagentDescriptor } from "./agent/provider-subagents/store.js"; const FETCH_WORKSPACES_SORT_KEYS = [ "status_priority", @@ -41,6 +42,11 @@ interface WorkspaceBucketHistoryEntry { enteredAt: string; } +interface WorkspaceBucketTimestampEntry { + bucket: WorkspaceStateBucket; + changedAtIso: string; +} + type FetchWorkspacesRequestMessage = Extract< SessionInboundMessage, { type: "fetch_workspaces_request" } @@ -57,6 +63,11 @@ type WorkspaceProjectDescriptor = FetchWorkspacesResponsePayload["emptyProjects" export type WorkspaceUpdatesFilter = FetchWorkspacesRequestFilter; +export type ProviderSubagentWorkspaceActivity = Pick< + ProviderSubagentDescriptor, + "parentAgentId" | "status" | "updatedAt" +>; + export interface WorkspaceDirectoryDeps { logger: pino.Logger; projectRegistry: { @@ -66,6 +77,7 @@ export interface WorkspaceDirectoryDeps { list(): Promise; }; listAgentPayloads(): Promise; + listProviderSubagentActivity(): Promise; listTerminalActivityContributions(): Promise< Array<{ cwd: string; workspaceId?: string; activity: TerminalActivity | null }> >; @@ -192,13 +204,19 @@ export class WorkspaceDirectory { includeGitData: boolean; workspaceIds?: Iterable; }): Promise> { - const [agents, persistedWorkspaces, persistedProjects, terminalContributions] = - await Promise.all([ - this.deps.listAgentPayloads(), - this.deps.workspaceRegistry.list(), - this.deps.projectRegistry.list(), - this.deps.listTerminalActivityContributions(), - ]); + const [ + agents, + providerSubagentActivity, + persistedWorkspaces, + persistedProjects, + terminalContributions, + ] = await Promise.all([ + this.deps.listAgentPayloads(), + this.deps.listProviderSubagentActivity(), + this.deps.workspaceRegistry.list(), + this.deps.projectRegistry.list(), + this.deps.listTerminalActivityContributions(), + ]); const activeProjects = new Map( persistedProjects @@ -246,10 +264,16 @@ export class WorkspaceDirectory { }); // Terminal activity contributions: working terminal → running bucket. - const terminalEntriesByWorkspaceId = this.applyTerminalContributions( + const activityEntriesByWorkspaceId = this.applyTerminalContributions( terminalContributions, descriptorsByWorkspaceId, ); + this.applyProviderSubagentContributions({ + activeAgents, + providerSubagentActivity, + descriptorsByWorkspaceId, + activityEntriesByWorkspaceId, + }); const contributingAgentsByWorkspaceId = groupAgentsByWorkspaceId( activeAgents, @@ -261,12 +285,12 @@ export class WorkspaceDirectory { const nowIso = new Date().toISOString(); for (const [workspaceId, descriptor] of descriptorsByWorkspaceId) { const contributingAgents = contributingAgentsByWorkspaceId.get(workspaceId) ?? []; - const terminalEntries = terminalEntriesByWorkspaceId.get(workspaceId) ?? []; + const activityEntries = activityEntriesByWorkspaceId.get(workspaceId) ?? []; const result = this.resolveStatusEnteredAt({ workspaceId, winningBucket: descriptor.status, contributingAgents, - terminalEntries, + activityEntries, previous: this.bucketHistoryByWorkspaceId.get(workspaceId) ?? null, workspaceCreatedAt: activeRecordsByWorkspaceId.get(workspaceId)?.createdAt ?? null, nowIso, @@ -282,6 +306,49 @@ export class WorkspaceDirectory { return descriptorsByWorkspaceId; } + private applyProviderSubagentContributions(params: { + activeAgents: AgentSnapshotPayload[]; + providerSubagentActivity: ProviderSubagentWorkspaceActivity[]; + descriptorsByWorkspaceId: Map; + activityEntriesByWorkspaceId: Map; + }): void { + const { + activeAgents, + providerSubagentActivity, + descriptorsByWorkspaceId, + activityEntriesByWorkspaceId, + } = params; + const activeAgentsById = new Map(activeAgents.map((agent) => [agent.id, agent] as const)); + + for (const subagent of providerSubagentActivity) { + if (subagent.status !== "running") { + continue; + } + const parent = activeAgentsById.get(subagent.parentAgentId); + if (!parent) { + continue; + } + const workspaceAgent = resolveWorkspaceRootAgent(parent, activeAgentsById); + const workspaceId = workspaceAgent?.workspaceId; + if (!workspaceId) { + continue; + } + const descriptor = descriptorsByWorkspaceId.get(workspaceId); + if (!descriptor) { + continue; + } + if ( + getWorkspaceStateBucketPriority("running") < + getWorkspaceStateBucketPriority(descriptor.status) + ) { + descriptor.status = "running"; + } + const entries = activityEntriesByWorkspaceId.get(workspaceId) ?? []; + entries.push({ bucket: "running", changedAtIso: subagent.updatedAt }); + activityEntriesByWorkspaceId.set(workspaceId, entries); + } + } + // Aggregate each agent's state bucket into its owning workspace descriptor, // keeping the highest-priority bucket. A record's owner IS its `workspaceId`; // status never fans out to same-cwd siblings. A subagent in another workspace @@ -328,8 +395,8 @@ export class WorkspaceDirectory { } } - // Apply working terminal contributions to descriptor statuses and build a map - // of terminal timestamp entries per workspace for use in `resolveStatusEnteredAt`. + // Apply working terminal contributions to descriptor statuses and seed the + // activity timestamp entries used by `resolveStatusEnteredAt`. // A terminal contributes only to the workspace it carries; same-cwd siblings // are untouched. private applyTerminalContributions( @@ -339,11 +406,8 @@ export class WorkspaceDirectory { activity: TerminalActivity | null; }>, descriptorsByWorkspaceId: Map, - ): Map> { - const terminalEntriesByWorkspaceId = new Map< - string, - Array<{ bucket: WorkspaceStateBucket; changedAtIso: string }> - >(); + ): Map { + const activityEntriesByWorkspaceId = new Map(); for (const { workspaceId, activity } of terminalContributions) { if (!activity || !workspaceId) { continue; @@ -359,21 +423,21 @@ export class WorkspaceDirectory { ) { existing.status = bucket; } - const entries = terminalEntriesByWorkspaceId.get(workspaceId) ?? []; + const entries = activityEntriesByWorkspaceId.get(workspaceId) ?? []; entries.push({ bucket, changedAtIso: new Date(activity.changedAt).toISOString() }); - terminalEntriesByWorkspaceId.set(workspaceId, entries); + activityEntriesByWorkspaceId.set(workspaceId, entries); } - return terminalEntriesByWorkspaceId; + return activityEntriesByWorkspaceId; } - // Aggregate the workspace-level `statusEnteredAt` from its contributing - // agents and terminals. Aggregate semantics: - // - winning bucket = highest-priority across contributing agents and terminals; - // - entry time = best-effort timestamp from agents/terminals in the winning bucket; + // Aggregate the workspace-level `statusEnteredAt` from its activity sources. + // Aggregate semantics: + // - winning bucket = highest-priority across contributing activity; + // - entry time = best-effort timestamp from sources in the winning bucket; // - priority unmasking: when the winning bucket transitions (e.g. a // higher-priority bucket cleared), the new entry time is "now"; // - same-bucket emits reuse the previous entered-at; - // - empty workspaces that never had contributing agents or terminals use + // - empty workspaces that never had contributing activity use // their workspace creation time as their initial `done` entry time. // - when archived agents leave a previously active workspace empty, keep // the previous done timestamp or stamp the transition to done now. @@ -381,7 +445,7 @@ export class WorkspaceDirectory { workspaceId: string; winningBucket: WorkspaceStateBucket; contributingAgents: AgentSnapshotPayload[]; - terminalEntries: Array<{ bucket: WorkspaceStateBucket; changedAtIso: string }>; + activityEntries: WorkspaceBucketTimestampEntry[]; previous: WorkspaceBucketHistoryEntry | null; workspaceCreatedAt: string | null; nowIso: string; @@ -393,13 +457,13 @@ export class WorkspaceDirectory { const { winningBucket, contributingAgents, - terminalEntries, + activityEntries, previous, workspaceCreatedAt, nowIso, } = params; - if (contributingAgents.length === 0 && terminalEntries.length === 0) { + if (contributingAgents.length === 0 && activityEntries.length === 0) { if (!previous) { if (!workspaceCreatedAt) { return { statusEnteredAt: null }; @@ -421,7 +485,7 @@ export class WorkspaceDirectory { if (!previous) { const newestInWinningBucket = this.findNewestTimestampInBucket( contributingAgents, - terminalEntries, + activityEntries, winningBucket, ); const enteredAt = newestInWinningBucket ?? nowIso; @@ -444,14 +508,14 @@ export class WorkspaceDirectory { }; } - // Best-effort newest timestamp across contributing agents and terminal entries + // Best-effort newest timestamp across contributing agents and other activity // whose bucket matches `winningBucket`. For agents, uses: // - `attentionTimestamp` when attention is set (covers attention/failed) // - `updatedAt` as a general fallback for any bucket // Returns `null` if no matching contributor has a parseable timestamp. private findNewestTimestampInBucket( contributingAgents: AgentSnapshotPayload[], - terminalEntries: Array<{ bucket: WorkspaceStateBucket; changedAtIso: string }>, + activityEntries: WorkspaceBucketTimestampEntry[], winningBucket: WorkspaceStateBucket, ): string | null { const agentTimestamps = contributingAgents @@ -475,11 +539,11 @@ export class WorkspaceDirectory { }) .filter((value): value is string => typeof value === "string" && value.length > 0); - const terminalTimestamps = terminalEntries + const activityTimestamps = activityEntries .filter((entry) => entry.bucket === winningBucket) .map((entry) => entry.changedAtIso); - const candidates = [...agentTimestamps, ...terminalTimestamps].sort(); + const candidates = [...agentTimestamps, ...activityTimestamps].sort(); return candidates.at(-1) ?? null; }