Keep workspaces active while native subagents run (#2777)
* fix(server): include native subagents in workspace activity Provider-native children live outside managed agent snapshots, so background work could outlive an idle parent without keeping the workspace running. Aggregate running child descriptors into the owning workspace and publish workspace updates on child lifecycle changes. * fix(server): serialize workspace status updates Queue overlapping descriptor rebuilds per workspace so older activity snapshots cannot overwrite newer lifecycle state. Preserve batched registry reads for multi-workspace updates. * test(server): update workspace status fixtures Provide the native subagent activity dependency in the shared session fixture and wait on observable git-watch output across the serialized update boundary. * fix(server): drop queued workspace updates on subscription swap --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> Co-authored-by: Mohamed Boudra <boudra.moha@gmail.com>
This commit is contained in:
co-authored by
paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com>
Mohamed Boudra
parent
048b82f2a7
commit
f888bae04d
@@ -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:
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
|
||||
@@ -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<string, Promise<void>>();
|
||||
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<AgentManagerEvent, { type: "agent_stream" }>,
|
||||
serializedEvent: Extract<SessionOutboundMessage, { type: "agent_stream" }>["payload"]["event"],
|
||||
@@ -4662,11 +4694,7 @@ export class Session {
|
||||
|
||||
private async emitWorkspaceUpdatesForWorkspaceIds(
|
||||
workspaceIds: Iterable<string>,
|
||||
options?: {
|
||||
dedupeGitState?: boolean;
|
||||
removedProjectId?: string;
|
||||
optimisticStatus?: WorkspaceDescriptorPayload["status"];
|
||||
},
|
||||
options?: WorkspaceUpdateOptions,
|
||||
): Promise<void> {
|
||||
const subscription = this.workspaceUpdatesSubscription;
|
||||
if (!subscription) {
|
||||
@@ -4678,12 +4706,53 @@ export class Session {
|
||||
return;
|
||||
}
|
||||
|
||||
await this.enqueueWorkspaceUpdates(uniqueWorkspaceIds, subscription, options);
|
||||
}
|
||||
|
||||
private enqueueWorkspaceUpdates(
|
||||
workspaceIds: ReadonlySet<string>,
|
||||
subscription: WorkspaceUpdatesSubscriptionState,
|
||||
options: WorkspaceUpdateOptions | undefined,
|
||||
): Promise<void> {
|
||||
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<string>,
|
||||
subscription: WorkspaceUpdatesSubscriptionState,
|
||||
options: WorkspaceUpdateOptions | undefined,
|
||||
): Promise<void> {
|
||||
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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<void>();
|
||||
const releaseFirstBuild = deferred<void>();
|
||||
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<void>();
|
||||
const releaseFirstBuild = deferred<void>();
|
||||
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-"));
|
||||
|
||||
@@ -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 }) => ({
|
||||
|
||||
@@ -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<PersistedWorkspaceRecord[]>;
|
||||
};
|
||||
listAgentPayloads(): Promise<AgentSnapshotPayload[]>;
|
||||
listProviderSubagentActivity(): Promise<ProviderSubagentWorkspaceActivity[]>;
|
||||
listTerminalActivityContributions(): Promise<
|
||||
Array<{ cwd: string; workspaceId?: string; activity: TerminalActivity | null }>
|
||||
>;
|
||||
@@ -192,13 +204,19 @@ export class WorkspaceDirectory {
|
||||
includeGitData: boolean;
|
||||
workspaceIds?: Iterable<string>;
|
||||
}): Promise<Map<string, WorkspaceDescriptorPayload>> {
|
||||
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<string, WorkspaceDescriptorPayload>;
|
||||
activityEntriesByWorkspaceId: Map<string, WorkspaceBucketTimestampEntry[]>;
|
||||
}): 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<string, WorkspaceDescriptorPayload>,
|
||||
): Map<string, Array<{ bucket: WorkspaceStateBucket; changedAtIso: string }>> {
|
||||
const terminalEntriesByWorkspaceId = new Map<
|
||||
string,
|
||||
Array<{ bucket: WorkspaceStateBucket; changedAtIso: string }>
|
||||
>();
|
||||
): Map<string, WorkspaceBucketTimestampEntry[]> {
|
||||
const activityEntriesByWorkspaceId = new Map<string, WorkspaceBucketTimestampEntry[]>();
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user