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:
@@ -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");
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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}`,
|
||||
|
||||
@@ -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();
|
||||
|
||||
Reference in New Issue
Block a user