diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index 12e7646d4..aa3f7bad2 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -54,6 +54,12 @@ Closing a tab on a **subagent** (any agent with `parentAgentId`) is **layout-onl 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. +## 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. Root agents contribute their normal state bucket to their own workspace. Running subagents contribute `running` to their root parent's workspace, 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. + ## The subagents track The collapsible track above the composer in an agent's pane (`packages/app/src/subagents/track.tsx`). Membership rule (`packages/app/src/subagents/select.ts`): diff --git a/packages/server/src/server/workspace-directory.test.ts b/packages/server/src/server/workspace-directory.test.ts index 5456a656f..307888387 100644 --- a/packages/server/src/server/workspace-directory.test.ts +++ b/packages/server/src/server/workspace-directory.test.ts @@ -30,11 +30,24 @@ class WorkspaceStatus { archivedAt: null, }; + private readonly worktreeWorkspace: PersistedWorkspaceRecord = { + workspaceId: "workspace-worktree", + projectId: this.project.projectId, + cwd: "/workspace/project/.paseo/worktrees/feature", + kind: "worktree", + displayName: "feature", + createdAt: NOW, + updatedAt: NOW, + archivedAt: null, + }; + + private readonly workspaces = [this.workspace]; + private readonly agents: AgentSnapshotPayload[] = []; private readonly directory = new WorkspaceDirectory({ logger: createTestLogger(), projectRegistry: { list: async () => [this.project] }, - workspaceRegistry: { list: async () => [this.workspace] }, + workspaceRegistry: { list: async () => this.workspaces }, listAgentPayloads: async () => this.agents, isProviderVisibleToClient: () => true, buildWorkspaceDescriptor: async ({ workspace }) => ({ @@ -71,6 +84,20 @@ class WorkspaceStatus { ); } + hasWorktreeWorkspace(): void { + this.workspaces.push(this.worktreeWorkspace); + } + + hasDelegatedAgentInWorktree(input: AgentState): void { + this.agents.push( + createAgent({ + ...input, + cwd: this.worktreeWorkspace.cwd, + labels: { [PARENT_AGENT_ID_LABEL]: "parent-agent" }, + }), + ); + } + async workspaceStatus(): Promise { const entries = await this.directory.listFetchEntries({ type: "fetch_workspaces_request", @@ -78,6 +105,14 @@ class WorkspaceStatus { }); return entries.entries[0]?.status ?? "done"; } + + async workspaceStatuses(): Promise> { + const entries = await this.directory.listFetchEntries({ + type: "fetch_workspaces_request", + requestId: "workspace-statuses", + }); + return Object.fromEntries(entries.entries.map((entry) => [entry.id, entry.status])); + } } interface AgentState { @@ -150,4 +185,17 @@ describe("WorkspaceDirectory", () => { await expect(workspace.workspaceStatus()).resolves.toBe("running"); }); + + test("running delegated child contributes running to the parent workspace, not its worktree", async () => { + const workspace = new WorkspaceStatus(); + + workspace.hasWorktreeWorkspace(); + workspace.hasRootAgent({ id: "parent-agent", status: "idle" }); + workspace.hasDelegatedAgentInWorktree({ id: "child-agent", status: "running" }); + + await expect(workspace.workspaceStatuses()).resolves.toEqual({ + "workspace-1": "running", + "workspace-worktree": "done", + }); + }); }); diff --git a/packages/server/src/server/workspace-directory.ts b/packages/server/src/server/workspace-directory.ts index 0d37eb256..272bb7d50 100644 --- a/packages/server/src/server/workspace-directory.ts +++ b/packages/server/src/server/workspace-directory.ts @@ -11,7 +11,7 @@ import { deriveAgentStateBucket, getWorkspaceStateBucketPriority, } from "@getpaseo/protocol/agent-state-bucket"; -import { isDelegatedAgent } from "@getpaseo/protocol/agent-labels"; +import { getParentAgentIdFromLabels, isDelegatedAgent } from "@getpaseo/protocol/agent-labels"; import { SortablePager } from "./pagination/sortable-pager.js"; import type { PersistedProjectRecord, PersistedWorkspaceRecord } from "./workspace-registry.js"; import { normalizeWorkspaceId } from "./workspace-registry-model.js"; @@ -181,18 +181,34 @@ export class WorkspaceDirectory { }); } - for (const agent of agents) { - if (agent.archivedAt) { - continue; - } - if (!this.deps.isProviderVisibleToClient(agent.provider)) { - continue; - } + const activeAgents = agents.filter( + (agent) => !agent.archivedAt && this.deps.isProviderVisibleToClient(agent.provider), + ); + 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)) { - continue; + 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 workspaceId = workspaceIdsByDirectory.get(normalizeWorkspaceId(agent.cwd)); + const workspaceId = workspaceIdsByDirectory.get(normalizeWorkspaceId(workspaceAgent.cwd)); if (workspaceId === undefined) { continue; } @@ -201,12 +217,6 @@ export class WorkspaceDirectory { continue; } - const bucket = deriveAgentStateBucket({ - status: agent.status, - pendingPermissionCount: agent.pendingPermissions?.length ?? 0, - requiresAttention: agent.requiresAttention, - attentionReason: agent.attentionReason ?? null, - }); if ( getWorkspaceStateBucketPriority(bucket) < getWorkspaceStateBucketPriority(existing.status) ) { @@ -336,3 +346,27 @@ export class WorkspaceDirectory { }; } } + +function resolveDelegationRootAgent( + agent: AgentSnapshotPayload, + activeAgentsById: ReadonlyMap, +): AgentSnapshotPayload | null { + const seen = new Set([agent.id]); + let current = agent; + + while (true) { + const parentAgentId = getParentAgentIdFromLabels(current.labels); + if (!parentAgentId) { + return current; + } + if (seen.has(parentAgentId)) { + return null; + } + const parent = activeAgentsById.get(parentAgentId); + if (!parent) { + return null; + } + seen.add(parentAgentId); + current = parent; + } +}