Detach subagents without archiving them (#1612)

* Add detach action for subagents

* Translate detached subagent connection error

* Address subagent detach review feedback

* Fix subagent integration test import

* Focus detached subagents as tabs
This commit is contained in:
Mohamed Boudra
2026-06-19 17:19:05 +08:00
committed by GitHub
parent cda66ae5f3
commit 7af92120fe
33 changed files with 1359 additions and 94 deletions
@@ -1847,18 +1847,23 @@ test("updateAgentMetadata bumps updatedAt for stored agents", async () => {
});
await manager.closeAgent(snapshot.id);
const before = await storage.get(snapshot.id);
expect(before).not.toBeNull();
const closed = await storage.get(snapshot.id);
expect(closed).not.toBeNull();
const before = { ...closed!, labels: { surface: "mobile" } };
await storage.upsert(before);
expect(manager.getAgent(snapshot.id)).toBeNull();
const upsertSpy = vi.spyOn(storage, "upsert");
await manager.updateAgentMetadata(snapshot.id, {
title: "Stored title",
labels: { role: "worker" },
});
expect(upsertSpy).toHaveBeenCalledTimes(1);
const after = await storage.get(snapshot.id);
expect(after?.title).toBe("Stored title");
expect(after?.labels).toEqual({ role: "worker" });
expect(after?.labels).toEqual({ surface: "mobile", role: "worker" });
expect(Date.parse(after!.updatedAt)).toBeGreaterThan(Date.parse(before!.updatedAt));
});
@@ -2002,6 +2007,134 @@ test("setLabels merges and persists labels", async () => {
});
});
test("detachAgent removes only the parent label from a live agent and emits state", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-detach-live-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
const manager = new AgentManager({
clients: {
codex: new TestAgentClient(),
},
registry: storage,
logger,
});
const parent = await manager.createAgent({
provider: "codex",
cwd: workdir,
title: "Parent",
});
const child = await manager.createAgent(
{
provider: "codex",
cwd: workdir,
title: "Child",
},
undefined,
{
labels: {
[PARENT_AGENT_ID_LABEL]: parent.id,
team: "infra",
},
},
);
const emittedLabels: Array<Record<string, string>> = [];
const unsubscribe = manager.subscribe(
(event) => {
if (event.type === "agent_state" && event.agent.id === child.id) {
emittedLabels.push(event.agent.labels);
}
},
{ agentId: child.id, replayState: false },
);
const result = await manager.detachAgent(child.id);
await manager.flush();
unsubscribe();
expect(result.previousParentAgentId).toBe(parent.id);
expect(result.live).toBe(true);
expect(result.record.labels).toEqual({ team: "infra" });
expect(manager.getAgent(child.id)?.labels).toEqual({ team: "infra" });
expect((await storage.get(child.id))?.labels).toEqual({ team: "infra" });
expect(emittedLabels).toContainEqual({ team: "infra" });
});
test("detachAgent removes the parent label from a stored-only agent", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-detach-stored-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
const manager = new AgentManager({
clients: {
codex: new TestAgentClient(),
},
registry: storage,
logger,
});
const parent = await manager.createAgent({
provider: "codex",
cwd: workdir,
title: "Parent",
});
const child = await manager.createAgent(
{
provider: "codex",
cwd: workdir,
title: "Stored child",
},
undefined,
{
labels: {
[PARENT_AGENT_ID_LABEL]: parent.id,
role: "reviewer",
},
},
);
await manager.closeAgent(child.id);
const result = await manager.detachAgent(child.id);
expect(result.previousParentAgentId).toBe(parent.id);
expect(result.live).toBe(false);
expect(result.record.labels).toEqual({ role: "reviewer" });
expect((await storage.get(child.id))?.labels).toEqual({ role: "reviewer" });
});
test("archiveAgent does not cascade to a detached former child", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-detach-cascade-"));
const storagePath = join(workdir, "agents");
const storage = new AgentStorage(storagePath, logger);
const manager = new AgentManager({
clients: {
codex: new TestAgentClient(),
},
registry: storage,
logger,
});
const parent = await manager.createAgent({
provider: "codex",
cwd: workdir,
title: "Parent",
});
const child = await manager.createAgent(
{
provider: "codex",
cwd: workdir,
title: "Child",
},
undefined,
{ labels: { [PARENT_AGENT_ID_LABEL]: parent.id } },
);
await manager.detachAgent(child.id);
await manager.archiveAgent(parent.id);
expect((await storage.get(parent.id))?.archivedAt).toEqual(expect.any(String));
expect((await storage.get(child.id))?.archivedAt).toBeFalsy();
});
test("runAgent persists finished attention and idle status without an external snapshot subscriber", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-finished-attention-"));
const storagePath = join(workdir, "agents");
+112 -21
View File
@@ -5,7 +5,11 @@ import {
AGENT_LIFECYCLE_STATUSES,
type AgentLifecycleStatus,
} from "@getpaseo/protocol/agent-lifecycle";
import { isDelegatedAgent, PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import {
getParentAgentIdFromLabels,
isDelegatedAgent,
PARENT_AGENT_ID_LABEL,
} from "@getpaseo/protocol/agent-labels";
import type { Logger } from "pino";
import { z } from "zod";
import type { TerminalManager } from "../../terminal/terminal-manager.js";
@@ -345,6 +349,17 @@ type ActiveManagedAgent =
| ManagedAgentError;
type LiveManagedAgent = ActiveManagedAgent;
type AgentLabelPatch = Record<string, string | null>;
interface WriteLabelsResult {
record: StoredAgentRecord | null;
live: boolean;
}
interface AgentMetadataPatch {
title?: string;
labels?: AgentLabelPatch;
}
const SYSTEM_ERROR_PREFIX = "[System Error]";
@@ -403,6 +418,21 @@ function validateAgentId(agentId: string, source: string): string {
return result.data;
}
function applyLabelPatch(
labels: Record<string, string>,
patch: AgentLabelPatch,
): Record<string, string> {
const nextLabels = { ...labels };
for (const [key, value] of Object.entries(patch)) {
if (value === null) {
delete nextLabels[key];
} else {
nextLabels[key] = value;
}
}
return nextLabels;
}
function buildExplicitTimelineSeedForRegister(
now: Date,
options:
@@ -1151,9 +1181,8 @@ export class AgentManager {
// Children created via the MCP `create_agent` tool carry the parent-agent-id
// label pointing back at the caller. Archiving the parent cascades to those
// children so subagent fleets don't outlive their orchestrator. Handoff agents
// launched the same way are caught by this cascade — see docs/agent-lifecycle.md
// for the accepted limitation.
// children so subagent fleets don't outlive their orchestrator. Detached
// handoff agents omit this label, so they stand outside the cascade.
private async cascadeArchiveChildren(parentAgentId: string): Promise<void> {
const registry = this.registry;
if (!registry) {
@@ -1333,10 +1362,83 @@ export class AgentManager {
async setLabels(agentId: string, labels: Record<string, string>): Promise<void> {
const agent = this.requireAgent(agentId);
agent.labels = { ...agent.labels, ...labels };
this.touchUpdatedAt(agent);
await this.persistSnapshot(agent);
this.emitState(agent, { persist: false });
await this.writeLabels(agent.id, labels);
}
private async writeLabels(agentId: string, patch: AgentLabelPatch): Promise<WriteLabelsResult> {
const liveAgent = this.agents.get(agentId);
if (liveAgent) {
liveAgent.labels = applyLabelPatch(liveAgent.labels, patch);
this.touchUpdatedAt(liveAgent);
await this.persistSnapshot(liveAgent);
this.emitState(liveAgent, { persist: false });
const record = this.registry ? await this.registry.get(agentId) : null;
return { record, live: true };
}
const nextRecord = await this.writeStoredMetadata(agentId, { labels: patch });
return { record: nextRecord, live: false };
}
private async writeStoredMetadata(
agentId: string,
patch: AgentMetadataPatch,
): Promise<StoredAgentRecord> {
const registry = this.requireRegistry();
const record = await registry.get(agentId);
if (!record) {
throw new Error(`Agent not found: ${agentId}`);
}
const nextRecord = {
...record,
...(patch.title ? { title: patch.title } : {}),
...(patch.labels ? { labels: applyLabelPatch(record.labels, patch.labels) } : {}),
updatedAt: this.nextStoredUpdatedAt(record),
};
await registry.upsert(nextRecord);
return nextRecord;
}
async detachAgent(agentId: string): Promise<{
record: StoredAgentRecord;
live: boolean;
previousParentAgentId: string | null;
}> {
const registry = this.requireRegistry();
const liveAgent = this.agents.get(agentId);
if (liveAgent) {
const previousParentAgentId = getParentAgentIdFromLabels(liveAgent.labels);
if (!previousParentAgentId) {
await this.persistSnapshot(liveAgent);
const record = await registry.get(agentId);
if (!record) {
throw new Error(`Agent not found in storage after detach: ${agentId}`);
}
return { record, live: true, previousParentAgentId: null };
}
const { record } = await this.writeLabels(agentId, { [PARENT_AGENT_ID_LABEL]: null });
if (!record) {
throw new Error(`Agent not found in storage after detach: ${agentId}`);
}
return { record, live: true, previousParentAgentId };
}
const record = await registry.get(agentId);
if (!record) {
throw new Error(`Agent not found: ${agentId}`);
}
const previousParentAgentId = getParentAgentIdFromLabels(record.labels);
if (!previousParentAgentId) {
return { record, live: false, previousParentAgentId: null };
}
const result = await this.writeLabels(agentId, { [PARENT_AGENT_ID_LABEL]: null });
if (!result.record) {
throw new Error(`Agent not found in storage after detach: ${agentId}`);
}
return { record: result.record, live: false, previousParentAgentId };
}
notifyAgentState(agentId: string): void {
@@ -1433,23 +1535,12 @@ export class AgentManager {
await this.setTitle(agentId, updates.title);
}
if (updates.labels) {
await this.setLabels(agentId, updates.labels);
await this.writeLabels(agentId, updates.labels);
}
return;
}
const registry = this.requireRegistry();
const existing = await registry.get(agentId);
if (!existing) {
throw new Error(`Agent not found: ${agentId}`);
}
await registry.upsert({
...existing,
...(updates.title ? { title: updates.title } : {}),
...(updates.labels ? { labels: { ...existing.labels, ...updates.labels } } : {}),
updatedAt: this.nextStoredUpdatedAt(existing),
});
await this.writeStoredMetadata(agentId, updates);
}
async runAgent(
@@ -1,10 +1,12 @@
import { describe, expect, test } from "vitest";
import { getParentAgentIdFromLabels, PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import { createTestLogger } from "../../test-utils/test-logger.js";
import type { StoredAgentRecord } from "./agent-storage.js";
import {
archiveAgentCommand,
cancelAgentRunCommand,
detachAgentCommand,
setAgentModeCommand,
updateAgentCommand,
type LifecycleAgentSnapshot,
@@ -39,6 +41,7 @@ class FakeLifecycleAgentManager implements LifecycleAgentManager {
readonly labelUpdates: Array<{ agentId: string; labels: Record<string, string> }> = [];
readonly notifiedAgentIds: string[] = [];
readonly modeUpdates: Array<{ agentId: string; modeId: string }> = [];
readonly detachedAgentIds: string[] = [];
inFlightAgentIds = new Set<string>();
constructor(private readonly storage: FakeLifecycleAgentStorage) {}
@@ -94,6 +97,39 @@ class FakeLifecycleAgentManager implements LifecycleAgentManager {
this.labelUpdates.push({ agentId, labels });
}
async detachAgent(agentId: string): Promise<{
record: StoredAgentRecord;
live: boolean;
previousParentAgentId: string | null;
}> {
this.detachedAgentIds.push(agentId);
const existing = this.storage.records.get(agentId);
if (!existing) {
throw new Error(`Agent not found: ${agentId}`);
}
const previousParentAgentId = getParentAgentIdFromLabels(existing.labels);
if (!previousParentAgentId) {
return {
record: existing,
live: this.liveAgents.has(agentId),
previousParentAgentId: null,
};
}
const labels = { ...existing.labels };
delete labels[PARENT_AGENT_ID_LABEL];
const record = {
...existing,
labels,
updatedAt: "2026-05-10T10:30:00.000Z",
};
this.storage.records.set(agentId, record);
return {
record,
live: this.liveAgents.has(agentId),
previousParentAgentId,
};
}
notifyAgentState(agentId: string): void {
this.notifiedAgentIds.push(agentId);
}
@@ -206,6 +242,44 @@ describe("agent lifecycle commands", () => {
]);
});
test("detaches an agent by clearing only the parent relationship", async () => {
const storage = new FakeLifecycleAgentStorage();
storage.records.set("agent-1", {
...storedAgent("agent-1"),
labels: {
[PARENT_AGENT_ID_LABEL]: "parent-agent",
team: "infra",
},
});
const manager = new FakeLifecycleAgentManager(storage);
await expect(detachAgentCommand({ agentManager: manager }, "agent-1")).resolves.toEqual({
agentId: "agent-1",
live: false,
previousParentAgentId: "parent-agent",
record: {
...storedAgent("agent-1"),
labels: { team: "infra" },
updatedAt: "2026-05-10T10:30:00.000Z",
},
});
expect(manager.detachedAgentIds).toEqual(["agent-1"]);
});
test("detach is accepted when the agent is already detached", async () => {
const storage = new FakeLifecycleAgentStorage();
storage.records.set("agent-1", storedAgent("agent-1"));
const manager = new FakeLifecycleAgentManager(storage);
await expect(detachAgentCommand({ agentManager: manager }, "agent-1")).resolves.toEqual({
agentId: "agent-1",
live: false,
previousParentAgentId: null,
record: storedAgent("agent-1"),
});
});
test("sets an agent mode and returns the accepted mode", async () => {
const storage = new FakeLifecycleAgentStorage();
const manager = new FakeLifecycleAgentManager(storage);
@@ -14,6 +14,11 @@ export interface LifecycleAgentManager {
archiveSnapshot(agentId: string, archivedAt: string): Promise<StoredAgentRecord>;
closeAgent(agentId: string): Promise<void>;
setLabels(agentId: string, labels: Record<string, string>): Promise<void>;
detachAgent(agentId: string): Promise<{
record: StoredAgentRecord;
live: boolean;
previousParentAgentId: string | null;
}>;
notifyAgentState(agentId: string): void;
setAgentMode(agentId: string, modeId: string): Promise<void>;
updateAgentMetadata(
@@ -161,6 +166,24 @@ export async function updateAgentCommand(
};
}
export interface DetachAgentResult {
agentId: string;
record: StoredAgentRecord;
live: boolean;
previousParentAgentId: string | null;
}
export async function detachAgentCommand(
dependencies: Pick<AgentLifecycleCommandDependencies, "agentManager">,
agentId: string,
): Promise<DetachAgentResult> {
const result = await dependencies.agentManager.detachAgent(agentId);
return {
agentId,
...result,
};
}
export async function setAgentModeCommand(
dependencies: Pick<AgentLifecycleCommandDependencies, "agentManager">,
input: {
+143
View File
@@ -6,6 +6,7 @@ import { join, resolve as resolvePath } from "path";
import pino from "pino";
import { afterEach, describe, expect, test, vi } from "vitest";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import type { WorkspaceDescriptorPayload } from "@getpaseo/protocol/messages";
import {
decodeFileTransferFrame,
@@ -13,6 +14,7 @@ import {
} from "@getpaseo/protocol/binary-frames/index";
import { Session } from "./session.js";
import { StructuredAgentFallbackError } from "./agent/agent-response-loop.js";
import type { StoredAgentRecord } from "./agent/agent-storage.js";
import type { ProviderSnapshotManager } from "./agent/provider-snapshot-manager.js";
import type { SessionOptions } from "./session.js";
import type {
@@ -199,6 +201,8 @@ vi.mock("./worktree-bootstrap.js", async (importOriginal) => {
});
interface SessionForTestOptions {
agentManager?: { [K in keyof SessionOptions["agentManager"]]?: unknown };
agentStorage?: { [K in keyof SessionOptions["agentStorage"]]?: unknown };
github?: Partial<GitHubService>;
checkoutDiffManager?: { scheduleRefreshForCwd: ReturnType<typeof vi.fn> };
workspaceGitService?: {
@@ -264,9 +268,11 @@ function createSessionForTest(options: SessionForTestOptions = {}): Session {
agentManager: asAgentManager({
listAgents: vi.fn(() => []),
subscribe: vi.fn(() => () => {}),
...options.agentManager,
}),
agentStorage: asAgentStorage({
list: vi.fn().mockResolvedValue([]),
...options.agentStorage,
}),
projectRegistry: options.projectRegistry ?? {
list: vi.fn().mockResolvedValue([]),
@@ -596,6 +602,143 @@ describe("file explorer binary responses", () => {
});
});
function createStoredAgentRecord(
overrides: Pick<StoredAgentRecord, "id" | "cwd"> & Partial<StoredAgentRecord>,
): StoredAgentRecord {
return {
id: overrides.id,
provider: overrides.provider ?? "codex",
cwd: overrides.cwd,
workspaceId: overrides.workspaceId,
createdAt: overrides.createdAt ?? "2026-01-01T00:00:00.000Z",
updatedAt: overrides.updatedAt ?? "2026-01-01T00:00:00.000Z",
lastUserMessageAt: overrides.lastUserMessageAt ?? null,
title: overrides.title ?? null,
labels: overrides.labels ?? {},
lastStatus: overrides.lastStatus ?? "idle",
lastModeId: overrides.lastModeId ?? null,
config: overrides.config ?? null,
runtimeInfo: overrides.runtimeInfo,
features: overrides.features,
persistence: overrides.persistence ?? null,
lastError: overrides.lastError,
requiresAttention: overrides.requiresAttention,
attentionReason: overrides.attentionReason,
attentionTimestamp: overrides.attentionTimestamp,
internal: overrides.internal,
archivedAt: overrides.archivedAt ?? null,
};
}
describe("agent detach RPC", () => {
test("detaches a stored subagent and emits the updated standalone agent", async () => {
const messages: unknown[] = [];
const childBefore = createStoredAgentRecord({
id: "child-agent",
cwd: "/tmp/child",
workspaceId: "workspace-child",
title: "Child",
labels: {
[PARENT_AGENT_ID_LABEL]: "parent-agent",
topic: "handoff",
},
});
const childAfter = createStoredAgentRecord({
...childBefore,
updatedAt: "2026-01-01T00:00:01.000Z",
labels: { topic: "handoff" },
});
const workspace = {
workspaceId: "workspace-child",
projectId: "project-child",
cwd: "/tmp/child",
kind: "worktree" as const,
displayName: "Child workspace",
title: null,
branch: "child",
baseBranch: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
archivedAt: null,
};
const project = {
projectId: "project-child",
rootPath: "/tmp/child",
kind: "git" as const,
displayName: "Project",
customName: null,
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
archivedAt: null,
};
const detachAgent = vi.fn().mockResolvedValue({
record: childAfter,
live: false,
previousParentAgentId: "parent-agent",
});
const getAgent = vi.fn(() => null);
const session = createSessionForTest({
messages,
agentManager: {
getAgent,
detachAgent,
},
agentStorage: {
list: vi.fn().mockResolvedValue([]),
get: vi.fn().mockResolvedValue(null),
},
workspaceRegistry: {
get: vi.fn().mockResolvedValue(workspace),
list: vi.fn().mockResolvedValue([workspace]),
},
projectRegistry: {
get: vi.fn().mockResolvedValue(project),
list: vi.fn().mockResolvedValue([project]),
},
});
await session.handleMessage({
type: "fetch_agents_request",
requestId: "subscribe-agents",
subscribe: { subscriptionId: "agents-sub" },
});
messages.splice(0);
await session.handleMessage({
type: "agent.detach.request",
agentId: childBefore.id,
requestId: "detach-1",
});
expect(detachAgent).toHaveBeenCalledWith("child-agent");
expect(messages).toContainEqual({
type: "agent_update",
payload: {
kind: "upsert",
agent: expect.objectContaining({
id: "child-agent",
labels: { topic: "handoff" },
workspaceId: "workspace-child",
}),
project: expect.objectContaining({
projectKey: "project-child",
workspaceName: "Child workspace",
}),
},
});
expect(messages).toContainEqual({
type: "agent.detach.response",
payload: {
requestId: "detach-1",
agentId: "child-agent",
accepted: true,
error: null,
},
});
});
});
function createProjectRecord(rootPath: string, archivedAt: string | null = null) {
return {
projectId: `project:${rootPath}`,
+132 -29
View File
@@ -92,6 +92,7 @@ import type { WorkspaceScriptRuntimeStore } from "./workspace-script-runtime-sto
import type { DaemonConfigStore } from "./daemon-config-store.js";
import { getErrorMessage, getErrorMessageOr } from "@getpaseo/protocol/error-utils";
import { getAgentStatusPriority } from "@getpaseo/protocol/agent-state-bucket";
import { getParentAgentIdFromLabels } from "@getpaseo/protocol/agent-labels";
import type {
WorkspaceGitRuntimeSnapshot,
WorkspaceGitService,
@@ -112,6 +113,7 @@ import {
archiveAgentCommand,
cancelAgentRunCommand,
closeAgentCommand,
detachAgentCommand,
setAgentModeCommand,
updateAgentCommand,
} from "./agent/lifecycle-command.js";
@@ -1621,6 +1623,45 @@ export class Session {
});
}
private async emitStoredAgentUpdate(record: StoredAgentRecord): Promise<AgentSnapshotPayload> {
const payload = this.buildStoredAgentPayload(record);
const subscription = this.agentUpdatesSubscription;
if (!subscription) {
return payload;
}
const project = payload.workspaceId
? await this.buildProjectPlacementForWorkspaceId(payload.workspaceId)
: null;
if (!project) {
this.bufferOrEmitAgentUpdate(subscription, {
kind: "remove",
agentId: payload.id,
});
return payload;
}
const matches = this.matchesAgentFilter({
agent: payload,
project,
filter: subscription.filter,
});
this.bufferOrEmitAgentUpdate(
subscription,
matches
? {
kind: "upsert",
agent: payload,
project,
}
: {
kind: "remove",
agentId: payload.id,
},
);
return payload;
}
private flushBootstrappedAgentUpdates(options?: {
snapshotUpdatedAtByAgentId?: Map<string, number>;
}): void {
@@ -1816,6 +1857,7 @@ export class Session {
const promise =
this.dispatchVoiceAndControlMessage(msg) ??
this.dispatchAgentRewindMessage(msg) ??
this.dispatchAgentRelationshipMessage(msg) ??
this.dispatchAgentLifecycleMessage(msg) ??
this.dispatchAgentConfigMessage(msg) ??
this.dispatchCheckoutMessage(msg) ??
@@ -1886,6 +1928,15 @@ export class Session {
}
}
private dispatchAgentRelationshipMessage(msg: SessionInboundMessage): Promise<void> | undefined {
switch (msg.type) {
case "agent.detach.request":
return this.handleDetachAgentRequest(msg.agentId, msg.requestId);
default:
return undefined;
}
}
private async handleDictationStreamStart(
msg: Extract<SessionInboundMessage, { type: "dictation_stream_start" }>,
): Promise<void> {
@@ -2454,35 +2505,7 @@ export class Session {
);
if (this.agentUpdatesSubscription) {
const payload = this.buildStoredAgentPayload(archivedRecord);
const project = payload.workspaceId
? await this.buildProjectPlacementForWorkspaceId(payload.workspaceId)
: null;
if (project) {
const matches = this.matchesAgentFilter({
agent: payload,
project,
filter: this.agentUpdatesSubscription.filter,
});
this.bufferOrEmitAgentUpdate(
this.agentUpdatesSubscription,
matches
? {
kind: "upsert",
agent: payload,
project,
}
: {
kind: "remove",
agentId,
},
);
} else {
this.bufferOrEmitAgentUpdate(this.agentUpdatesSubscription, {
kind: "remove",
agentId,
});
}
const payload = await this.emitStoredAgentUpdate(archivedRecord);
if (payload.workspaceId) {
await this.emitWorkspaceUpdateForWorkspaceId(payload.workspaceId);
}
@@ -2491,6 +2514,59 @@ export class Session {
return { agentId, archivedAt };
}
private async handleDetachAgentRequest(agentId: string, requestId: string): Promise<void> {
this.sessionLogger.info({ agentId, requestId }, "Detaching agent from parent");
try {
const result = await detachAgentCommand({ agentManager: this.agentManager }, agentId);
const affectedWorkspaceIds = new Set<string>();
if (!result.live) {
const payload = await this.emitStoredAgentUpdate(result.record);
if (payload.workspaceId) {
affectedWorkspaceIds.add(payload.workspaceId);
}
} else if (result.record.workspaceId) {
affectedWorkspaceIds.add(result.record.workspaceId);
}
if (result.previousParentAgentId) {
const rootWorkspaceId = await this.resolveDelegationRootWorkspaceId(
result.previousParentAgentId,
);
if (rootWorkspaceId) {
affectedWorkspaceIds.add(rootWorkspaceId);
}
}
await this.emitWorkspaceUpdatesForWorkspaceIds(affectedWorkspaceIds, {
skipReconcile: true,
});
this.emit({
type: "agent.detach.response",
payload: {
requestId,
agentId,
accepted: true,
error: null,
},
});
} catch (error) {
const message = getErrorMessageOr(error, "Failed to detach agent");
this.sessionLogger.error({ err: error, agentId, requestId }, "Failed to detach agent");
this.emit({
type: "agent.detach.response",
payload: {
requestId,
agentId,
accepted: false,
error: message,
},
});
}
}
private async handleCloseItemsRequest(msg: CloseItemsRequest): Promise<void> {
const archiveResults = await Promise.allSettled(
msg.agentIds.map((agentId) => this.archiveAgentForClose(agentId)),
@@ -6554,6 +6630,33 @@ export class Session {
return this.isProviderVisibleToClient(payload.provider) ? payload : null;
}
private async resolveDelegationRootWorkspaceId(agentId: string): Promise<string | null> {
const seen = new Set<string>();
let currentAgentId = agentId;
while (true) {
if (seen.has(currentAgentId)) {
return null;
}
seen.add(currentAgentId);
const live = this.agentManager.getAgent(currentAgentId);
const source = live ?? (await this.agentStorage.get(currentAgentId));
if (!source) {
return null;
}
if ("archivedAt" in source && source.archivedAt) {
return null;
}
const parentAgentId = getParentAgentIdFromLabels(source.labels);
if (!parentAgentId) {
return source.workspaceId ?? null;
}
currentAgentId = parentAgentId;
}
}
private async buildActiveProjectPlacementsByWorkspaceId(): Promise<
Map<string, ProjectPlacementPayload>
> {
@@ -1161,6 +1161,8 @@ export class VoiceAssistantWebSocketServer {
projectRemove: true,
// COMPAT(worktreeRestore): added in v0.1.97, drop the gate when floor >= v0.1.97
worktreeRestore: true,
// COMPAT(agentDetach): added in v0.1.98, remove gate after 2026-12-19 once daemon floor >= v0.1.98.
agentDetach: true,
},
};
}
@@ -139,6 +139,16 @@ class WorkspaceStatus {
);
}
hasDetachedAgentInWorktree(input: AgentState): void {
this.agents.push(
createAgent({
...input,
cwd: this.worktreeWorkspace.cwd,
workspaceId: this.worktreeWorkspace.workspaceId,
}),
);
}
async workspaceStatus(): Promise<WorkspaceDescriptorPayload["status"]> {
const entries = await this.directory.listFetchEntries({
type: "fetch_workspaces_request",
@@ -378,6 +388,19 @@ describe("WorkspaceDirectory", () => {
});
});
test("running detached child contributes running to its own workspace", async () => {
const workspace = new WorkspaceStatus();
workspace.hasWorktreeWorkspace();
workspace.hasRootAgent({ id: "parent-agent", status: "idle" });
workspace.hasDetachedAgentInWorktree({ id: "child-agent", status: "running" });
await expect(workspace.workspaceStatuses()).resolves.toEqual({
"workspace-1": "done",
"workspace-worktree": "running",
});
});
test("working terminal contributes running status, beating done", async () => {
const workspace = new WorkspaceStatus();
const changedAt = new Date(NOW).getTime();