mirror of
https://github.com/getpaseo/paseo.git
synced 2026-07-29 12:01:31 +00:00
Show delegated agent activity on parent workspaces
This commit is contained in:
@@ -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`):
|
||||
|
||||
@@ -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<WorkspaceDescriptorPayload["status"]> {
|
||||
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<Record<string, WorkspaceDescriptorPayload["status"]>> {
|
||||
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",
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<string, AgentSnapshotPayload>,
|
||||
): AgentSnapshotPayload | null {
|
||||
const seen = new Set<string>([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;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user