diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index 2e2a2a2a1..2358b7e4d 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -61,13 +61,13 @@ Closing a tab on a **root agent** still archives — the tab is the agent's home Closing a tab on a **subagent** (any agent with `parentAgentId`) is **layout-only**. The agent stays unarchived and stays in its parent's track. The user can re-open the tab from the track at any time. This is implemented in `handleCloseAgentTab` (`packages/app/src/screens/workspace/workspace-screen.tsx`). -The asymmetry is intentional: a subagent's home is the parent's track, not the tab. Tabs are ephemeral viewing slots; the track is the persistent record of the parent's children. +The asymmetry is intentional: a subagent's persistent relationship lives in the parent's track. Same-workspace subagents are not auto-opened as tabs; the user opens one from that track when needed. A cross-workspace subagent is also auto-opened as a tab in its own workspace so opening that workspace does not appear empty. It remains in the parent's track until it is actually detached. ## Workspace activity Agent lifecycle status stays literal: a parent agent is `idle` when its own turn is idle, even if a child is running. -Workspace status is an aggregate activity signal computed **per `workspaceId`**: a workspace's status reflects only records whose `workspaceId === workspace.id`. Ownership is never derived from `cwd` — many workspaces may share one directory, and same-`cwd` siblings do not clump under one status. A root agent contributes its normal state bucket to its owning workspace only. Running subagents contribute `running` to their root parent's owning workspace (by the parent agent's `workspaceId`), not to the subagent's current `cwd` or worktree. Non-running subagent attention, permission, and error states stay in the parent's subagents track and do not escalate the workspace bucket. +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. ## The subagents track diff --git a/packages/app/e2e/helpers/subagents.ts b/packages/app/e2e/helpers/subagents.ts index 46a227e66..b70797329 100644 --- a/packages/app/e2e/helpers/subagents.ts +++ b/packages/app/e2e/helpers/subagents.ts @@ -14,6 +14,17 @@ export interface SeededSubagentPair { workspaceId: string; } +export interface SeededCrossWorkspaceSubagentPair { + parent: { + id: string; + workspaceId: string; + }; + child: { + id: string; + workspaceId: string; + }; +} + export async function seedParentWithSubagent( workspace: Pick, input: { parentTitle: string; childTitle: string }, @@ -51,6 +62,60 @@ export async function seedParentWithSubagent( }; } +export async function seedParentWithCrossWorkspaceSubagent( + workspace: Pick, + input: { parentTitle: string; childTitle: string }, +): Promise { + const parent = await workspace.client.createAgent({ + provider: "mock", + cwd: workspace.repoPath, + workspaceId: workspace.workspaceId, + title: input.parentTitle, + modeId: "load-test", + model: "ten-second-stream", + }); + const createdWorkspace = await workspace.client.createWorkspace({ + source: { + kind: "directory", + path: workspace.repoPath, + projectId: workspace.projectId, + }, + title: "Subagent workspace", + }); + if (!createdWorkspace.workspace) { + throw new Error(createdWorkspace.error ?? "Failed to create subagent workspace"); + } + + const child = await workspace.client.createAgent({ + provider: "mock", + cwd: workspace.repoPath, + workspaceId: createdWorkspace.workspace.id, + title: input.childTitle, + modeId: "load-test", + model: "five-minute-stream", + initialPrompt: "stay running", + labels: { + [PARENT_AGENT_ID_LABEL]: parent.id, + }, + }); + await workspace.client.waitForAgentUpsert( + child.id, + (snapshot) => snapshot.status === "running", + 15_000, + ); + + return { + parent: { + id: parent.id, + workspaceId: workspace.workspaceId, + }, + child: { + id: child.id, + workspaceId: createdWorkspace.workspace.id, + }, + }; +} + export async function openSubagentsTrack(page: Page): Promise { await page.getByTestId("subagents-track-header").click(); } diff --git a/packages/app/e2e/workspace-model-regressions.spec.ts b/packages/app/e2e/workspace-model-regressions.spec.ts index 20501ed15..5a8554d5a 100644 --- a/packages/app/e2e/workspace-model-regressions.spec.ts +++ b/packages/app/e2e/workspace-model-regressions.spec.ts @@ -1,4 +1,5 @@ import { test, expect } from "./fixtures"; +import { expectWorkspaceTabVisible } from "./helpers/archive-tab"; import { expectComposerEditable, expectComposerVisible, submitMessage } from "./helpers/composer"; import { clickNewChat, gotoWorkspace } from "./helpers/launcher"; import { @@ -12,6 +13,11 @@ import { } from "./helpers/new-workspace"; import { getServerId } from "./helpers/server-id"; import { seedWorkspace, type SeededWorkspace } from "./helpers/seed-client"; +import { + expectSubagentRowVisible, + openSubagentsTrack, + seedParentWithCrossWorkspaceSubagent, +} from "./helpers/subagents"; import { expectWorkspaceHeader, waitForSidebarHydration } from "./helpers/workspace-ui"; import { getVisibleWorkspaceAgentTabIds } from "./helpers/workspace-tabs"; @@ -527,4 +533,39 @@ test.describe("Workspace model regressions", () => { await seeded.cleanup(); } }); + + test("cross-workspace subagent opens in its workspace and keeps its parent relationship", async ({ + page, + }) => { + const serverId = getServerId(); + const seeded = await seedWorkspace({ repoPrefix: "workspace-cross-subagent-" }); + + try { + const agents = await seedParentWithCrossWorkspaceSubagent(seeded, { + parentTitle: "Parent agent", + childTitle: "Cross-workspace subagent", + }); + + await gotoWorkspace(page, agents.child.workspaceId); + await waitForSidebarHydration(page); + await expectWorkspaceTabVisible(page, agents.child.id); + + const parentRowTestId = `sidebar-workspace-row-${serverId}:${agents.parent.workspaceId}`; + const childRowTestId = `sidebar-workspace-row-${serverId}:${agents.child.workspaceId}`; + await expectWorkspaceRowHasOnlyIndicator(page, { + rowTestId: childRowTestId, + indicator: "running", + }); + await expectWorkspaceRowHasOnlyIndicator(page, { + rowTestId: parentRowTestId, + indicator: "done", + }); + + await gotoWorkspace(page, agents.parent.workspaceId); + await openSubagentsTrack(page); + await expectSubagentRowVisible(page, agents.child.id); + } finally { + await seeded.cleanup(); + } + }); }); diff --git a/packages/app/src/stores/workspace-subagents-integration.test.ts b/packages/app/src/stores/workspace-subagents-integration.test.ts index 7bcb376ab..d3838dad8 100644 --- a/packages/app/src/stores/workspace-subagents-integration.test.ts +++ b/packages/app/src/stores/workspace-subagents-integration.test.ts @@ -215,4 +215,38 @@ describe("workspace subagents integration", () => { ), ).toEqual([]); }); + + it("auto-opens a cross-workspace child while retaining it in the parent section", () => { + const workspaceKey = buildWorkspaceTabPersistenceKey({ + serverId: SERVER_ID, + workspaceId: WORKSPACE_ID, + }); + expect(workspaceKey).toBeTruthy(); + + const parent = makeAgent({ + id: "parent-agent", + workspaceId: "ws-parent", + title: "Parent agent", + }); + const child = makeAgent({ + id: "child-agent", + parentAgentId: parent.id, + title: "Cross-workspace child", + }); + + initializeAgents([parent, child]); + reconcileWorkspaceTabs(workspaceKey!, deriveVisibilityFromSession()); + + expect(getWorkspaceTabIds(workspaceKey!)).toEqual(["agent_child-agent"]); + expect( + selectSubagentsForParent( + useSessionStore.getState(), + { + serverId: SERVER_ID, + parentAgentId: parent.id, + }, + new Set(), + ).map((row) => row.id), + ).toEqual([child.id]); + }); }); diff --git a/packages/app/src/subagents/auto-open-tab-policy.ts b/packages/app/src/subagents/auto-open-tab-policy.ts deleted file mode 100644 index be11bd2d2..000000000 --- a/packages/app/src/subagents/auto-open-tab-policy.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { Agent } from "@/stores/session-store"; - -export function shouldAutoOpenAgentTab(agent: Pick): boolean { - return !agent.parentAgentId; -} diff --git a/packages/app/src/subagents/index.ts b/packages/app/src/subagents/index.ts index a30e9d5e9..c71969b95 100644 --- a/packages/app/src/subagents/index.ts +++ b/packages/app/src/subagents/index.ts @@ -3,4 +3,4 @@ export { selectSubagentsForParent, useSubagentsForParent } from "./select"; export { useArchiveSubagent, type UseArchiveSubagentInput } from "./use-archive-subagent"; export { useDetachSubagent, type UseDetachSubagentInput } from "./use-detach-subagent"; export { resolveCloseAgentTabPolicy, type CloseAgentTabPolicy } from "./close-tab-policy"; -export { shouldAutoOpenAgentTab } from "./auto-open-tab-policy"; +export { isWorkspaceRootAgent } from "./workspace-root-policy"; diff --git a/packages/app/src/subagents/policies.ts b/packages/app/src/subagents/policies.ts index b8a29d07d..c255aa756 100644 --- a/packages/app/src/subagents/policies.ts +++ b/packages/app/src/subagents/policies.ts @@ -6,4 +6,4 @@ // UI surface. Use `@/subagents/policies` only when the caller is // non-RN infrastructure code; otherwise prefer `@/subagents`. export { resolveCloseAgentTabPolicy, type CloseAgentTabPolicy } from "./close-tab-policy"; -export { shouldAutoOpenAgentTab } from "./auto-open-tab-policy"; +export { isWorkspaceRootAgent } from "./workspace-root-policy"; diff --git a/packages/app/src/subagents/workspace-root-policy.ts b/packages/app/src/subagents/workspace-root-policy.ts new file mode 100644 index 000000000..8594c0c7a --- /dev/null +++ b/packages/app/src/subagents/workspace-root-policy.ts @@ -0,0 +1,17 @@ +import type { Agent } from "@/stores/session-store"; +import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity"; + +type WorkspaceAgent = Pick; + +export function isWorkspaceRootAgent( + agent: WorkspaceAgent, + parentAgent: Pick | undefined, +): boolean { + if (!agent.parentAgentId) { + return true; + } + + const workspaceId = normalizeWorkspaceOpaqueId(agent.workspaceId); + const parentWorkspaceId = normalizeWorkspaceOpaqueId(parentAgent?.workspaceId); + return Boolean(workspaceId && parentWorkspaceId && workspaceId !== parentWorkspaceId); +} diff --git a/packages/app/src/utils/workspace-agent-activity.test.ts b/packages/app/src/utils/workspace-agent-activity.test.ts index 72c1fe869..7cea82191 100644 --- a/packages/app/src/utils/workspace-agent-activity.test.ts +++ b/packages/app/src/utils/workspace-agent-activity.test.ts @@ -155,6 +155,52 @@ describe("workspace agent activity index", () => { }); }); + it("treats a cross-workspace subagent as activity in its own workspace", () => { + const index = buildWorkspaceAgentActivityIndex( + new Map([ + [ + "parent", + agent({ + id: "parent", + workspaceId: "workspace-a", + updatedAt: "2026-06-01T10:00:00.000Z", + }), + ], + [ + "child", + agent({ + id: "child", + workspaceId: "workspace-b", + status: "running", + updatedAt: "2026-06-01T10:03:00.000Z", + parentAgentId: "parent", + }), + ], + ]), + ); + + expect(index).toEqual( + new Map([ + [ + "workspace-a", + { + agentId: "parent", + status: "done", + enteredAt: new Date("2026-06-01T10:00:00.000Z"), + }, + ], + [ + "workspace-b", + { + agentId: "child", + status: "running", + enteredAt: new Date("2026-06-01T10:03:00.000Z"), + }, + ], + ]), + ); + }); + it("preserves the activity index while the same agent remains in the same status", () => { const previous = buildWorkspaceAgentActivityIndex( new Map([ diff --git a/packages/app/src/utils/workspace-agent-activity.ts b/packages/app/src/utils/workspace-agent-activity.ts index 4f671025f..3e4355ee3 100644 --- a/packages/app/src/utils/workspace-agent-activity.ts +++ b/packages/app/src/utils/workspace-agent-activity.ts @@ -1,4 +1,5 @@ import type { Agent, WorkspaceDescriptor } from "@/stores/session-store"; +import { isWorkspaceRootAgent } from "@/subagents/policies"; import { deriveSidebarStateBucket } from "./sidebar-agent-state"; export interface WorkspaceAgentActivity { @@ -15,7 +16,8 @@ export function buildWorkspaceAgentActivityIndex( const latestActivityAtByWorkspaceId = new Map(); for (const agent of agents.values()) { - if (agent.archivedAt || agent.parentAgentId || !agent.workspaceId) { + const parentAgent = agent.parentAgentId ? agents.get(agent.parentAgentId) : undefined; + if (agent.archivedAt || !agent.workspaceId || !isWorkspaceRootAgent(agent, parentAgent)) { continue; } diff --git a/packages/app/src/workspace-tabs/agent-visibility.test.ts b/packages/app/src/workspace-tabs/agent-visibility.test.ts index dec15997f..33b30f80d 100644 --- a/packages/app/src/workspace-tabs/agent-visibility.test.ts +++ b/packages/app/src/workspace-tabs/agent-visibility.test.ts @@ -131,6 +131,32 @@ describe("workspace agent visibility", () => { expect(result.knownAgentIds).toEqual(new Set(["child-agent", "parent-agent"])); }); + it("auto-opens a subagent whose parent belongs to another workspace", () => { + const parent = makeAgent({ + id: "parent-agent", + cwd: "/repo", + workspaceId: "ws-parent", + }); + const child = makeAgent({ + id: "child-agent", + cwd: "/repo/worktree", + workspaceId: WORKSPACE_ID, + parentAgentId: parent.id, + }); + + const result = deriveWorkspaceAgentVisibility({ + sessionAgents: new Map([ + [parent.id, parent], + [child.id, child], + ]), + workspaceId: WORKSPACE_ID, + }); + + expect(result.activeAgentIds).toEqual(new Set(["child-agent"])); + expect(result.autoOpenAgentIds).toEqual(new Set(["child-agent"])); + expect(result.knownAgentIds).toEqual(new Set(["child-agent"])); + }); + it("keeps archived agents out of activeAgentIds but present in knownAgentIds", () => { const visible = makeAgent({ id: "visible-agent", diff --git a/packages/app/src/workspace-tabs/agent-visibility.ts b/packages/app/src/workspace-tabs/agent-visibility.ts index 268034279..f6ff8dbed 100644 --- a/packages/app/src/workspace-tabs/agent-visibility.ts +++ b/packages/app/src/workspace-tabs/agent-visibility.ts @@ -1,6 +1,6 @@ import type { Agent } from "@/stores/session-store"; import type { WorkspaceTabSnapshot } from "@/stores/workspace-layout-actions"; -import { shouldAutoOpenAgentTab } from "@/subagents/policies"; +import { isWorkspaceRootAgent } from "@/subagents/policies"; import { normalizeWorkspaceOpaqueId } from "@/utils/workspace-identity"; export interface WorkspaceAgentVisibility { @@ -31,6 +31,10 @@ export function deriveWorkspaceAgentVisibility(input: { const activeAgentIds = new Set(); const autoOpenAgentIds = new Set(); const knownAgentIds = new Set(); + const agentsById = new Map([ + ...(agentDetails?.entries() ?? []), + ...(sessionAgents?.entries() ?? []), + ]); for (const agent of sessionAgents?.values() ?? []) { if (!agentBelongsToWorkspace(agent, workspaceId)) { continue; @@ -38,7 +42,8 @@ export function deriveWorkspaceAgentVisibility(input: { knownAgentIds.add(agent.id); if (!agent.archivedAt) { activeAgentIds.add(agent.id); - if (shouldAutoOpenAgentTab(agent)) { + const parentAgent = agent.parentAgentId ? agentsById.get(agent.parentAgentId) : undefined; + if (isWorkspaceRootAgent(agent, parentAgent)) { autoOpenAgentIds.add(agent.id); } } diff --git a/packages/server/src/server/workspace-directory.test.ts b/packages/server/src/server/workspace-directory.test.ts index 46e9ccfa8..ae46e80e0 100644 --- a/packages/server/src/server/workspace-directory.test.ts +++ b/packages/server/src/server/workspace-directory.test.ts @@ -375,7 +375,16 @@ describe("WorkspaceDirectory", () => { }); }); - test("running delegated child contributes running to the parent workspace, not its worktree", async () => { + test("running same-workspace subagent contributes running to its parent workspace", async () => { + const workspace = new WorkspaceStatus(); + + workspace.hasRootAgent({ id: "parent-agent", status: "idle" }); + workspace.hasDelegatedAgent({ id: "child-agent", status: "running" }); + + await expect(workspace.workspaceStatus()).resolves.toBe("running"); + }); + + test("running cross-workspace subagent contributes to its own workspace", async () => { const workspace = new WorkspaceStatus(); workspace.hasWorktreeWorkspace(); @@ -383,8 +392,25 @@ describe("WorkspaceDirectory", () => { workspace.hasDelegatedAgentInWorktree({ id: "child-agent", status: "running" }); await expect(workspace.workspaceStatuses()).resolves.toEqual({ - "workspace-1": "running", - "workspace-worktree": "done", + "workspace-1": "done", + "workspace-worktree": "running", + }); + }); + + test("cross-workspace subagent contributes its full status bucket to its own workspace", async () => { + const workspace = new WorkspaceStatus(); + + workspace.hasWorktreeWorkspace(); + workspace.hasRootAgent({ id: "parent-agent", status: "idle" }); + workspace.hasDelegatedAgentInWorktree({ + id: "child-agent", + status: "idle", + pendingPermissionCount: 1, + }); + + await expect(workspace.workspaceStatuses()).resolves.toEqual({ + "workspace-1": "done", + "workspace-worktree": "needs_input", }); }); diff --git a/packages/server/src/server/workspace-directory.ts b/packages/server/src/server/workspace-directory.ts index cc9007818..6e2193227 100644 --- a/packages/server/src/server/workspace-directory.ts +++ b/packages/server/src/server/workspace-directory.ts @@ -11,7 +11,7 @@ import { getWorkspaceStateBucketPriority, type WorkspaceStateBucket, } from "@getpaseo/protocol/agent-state-bucket"; -import { getParentAgentIdFromLabels, isDelegatedAgent } from "@getpaseo/protocol/agent-labels"; +import { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels"; import { SortablePager } from "./pagination/sortable-pager.js"; import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.js"; import { resolveProjectDisplayName } from "./workspace-registry.js"; @@ -273,8 +273,9 @@ export class WorkspaceDirectory { // 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. Delegated agents contribute to - // their delegation root's workspace; their own status is ignored unless running. + // status never fans out to same-cwd siblings. A subagent in another workspace + // is a root for that workspace. Same-workspace descendants contribute only + // running activity to the nearest ancestor in that workspace. private applyAgentBucketContributions(params: { activeAgents: AgentSnapshotPayload[]; descriptorsByWorkspaceId: Map; @@ -283,26 +284,22 @@ export class WorkspaceDirectory { const activeAgentsById = new Map(activeAgents.map((agent) => [agent.id, agent] as const)); for (const agent of activeAgents) { - let workspaceAgent = agent; - let bucket: WorkspaceDescriptorPayload["status"]; - if (isDelegatedAgent(agent)) { - if (agent.status !== "running") { - continue; - } - const parentAgent = resolveDelegationRootAgent(agent, activeAgentsById); - if (!parentAgent) { - continue; - } - workspaceAgent = parentAgent; - bucket = "running"; - } else { - bucket = deriveAgentStateBucket({ - status: agent.status, - pendingPermissionCount: agent.pendingPermissions?.length ?? 0, - requiresAttention: agent.requiresAttention, - attentionReason: agent.attentionReason ?? null, - }); + const workspaceAgent = resolveWorkspaceRootAgent(agent, activeAgentsById); + if (!workspaceAgent) { + continue; } + const isWorkspaceRoot = workspaceAgent.id === agent.id; + if (!isWorkspaceRoot && agent.status !== "running") { + continue; + } + const bucket = isWorkspaceRoot + ? deriveAgentStateBucket({ + status: agent.status, + pendingPermissionCount: agent.pendingPermissions?.length ?? 0, + requiresAttention: agent.requiresAttention, + attentionReason: agent.attentionReason ?? null, + }) + : "running"; const workspaceId = workspaceAgent.workspaceId; if (!workspaceId) { @@ -619,7 +616,7 @@ function groupAgentsByWorkspaceId( return byWorkspaceId; } -function resolveDelegationRootAgent( +function resolveWorkspaceRootAgent( agent: AgentSnapshotPayload, activeAgentsById: ReadonlyMap, ): AgentSnapshotPayload | null { @@ -638,6 +635,9 @@ function resolveDelegationRootAgent( if (!parent) { return null; } + if (parent.workspaceId !== current.workspaceId) { + return current; + } seen.add(parentAgentId); current = parent; }