Preserve opened subagents when parents are archived (#3279)
* fix(subagents): preserve opened children on parent archive Opening a managed child represents user ownership only while that client's tab remains open. Track that intent per client, detach surviving children during parent archive, and keep single and bulk tab-close behavior consistent. * fix(subagents): mark generic tab openings Centralize managed child tab ownership in agent navigation so list, command-center, deep-link, notification, and subagent-track openings all mark ownership before creating the tab. Use stable localized errors for lifecycle failures. * fix(subagents): classify cold tab openings Fetch uncached agents before deciding whether tab ownership must be recorded, so notification, desktop, and cold-route openings cannot bypass parent archive protection. * fix(app): translate navigation fallback errors * fix(navigation): allow offline workspace intents * refactor(app): derive subagent ownership from open tabs * fix(server): serialize subagent lifecycle ownership
This commit is contained in:
@@ -15,7 +15,7 @@ import {
|
||||
} from "./agent-manager.js";
|
||||
import { AgentStorage } from "./agent-storage.js";
|
||||
import { toAgentPayload } from "./agent-projections.js";
|
||||
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||
import { getOpenAgentTabLabel, PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
|
||||
import { formatSystemNotificationPrompt, startAgentRun } from "./agent-prompt.js";
|
||||
import { ensureAgentLoaded, ensureUnarchivedAgentLoaded } from "./agent-loading.js";
|
||||
import type { StoredAgentRecord } from "./agent-storage.js";
|
||||
@@ -41,6 +41,9 @@ import type {
|
||||
import type { PaseoToolCatalog } from "./tools/types.js";
|
||||
import type { ProviderDefinition } from "./provider-registry.js";
|
||||
|
||||
const DESKTOP_OPEN_AGENT_TAB_LABEL = getOpenAgentTabLabel("desktop-client");
|
||||
const MOBILE_OPEN_AGENT_TAB_LABEL = getOpenAgentTabLabel("mobile-client");
|
||||
|
||||
interface Deferred<T> {
|
||||
promise: Promise<T>;
|
||||
resolve: (value: T) => void;
|
||||
@@ -3590,7 +3593,7 @@ test("setLabels merges and persists labels", async () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("detachAgent removes only the parent label from a live agent and emits state", async () => {
|
||||
test("detachAgent removes relationship lifecycle labels 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);
|
||||
@@ -3621,6 +3624,8 @@ test("detachAgent removes only the parent label from a live agent and emits stat
|
||||
{
|
||||
labels: {
|
||||
[PARENT_AGENT_ID_LABEL]: parent.id,
|
||||
[DESKTOP_OPEN_AGENT_TAB_LABEL]: "true",
|
||||
[MOBILE_OPEN_AGENT_TAB_LABEL]: "false",
|
||||
team: "infra",
|
||||
},
|
||||
workspaceId: undefined,
|
||||
@@ -3648,7 +3653,7 @@ test("detachAgent removes only the parent label from a live agent and emits stat
|
||||
expect(emittedLabels).toContainEqual({ team: "infra" });
|
||||
});
|
||||
|
||||
test("detachAgent removes the parent label from a stored-only agent", async () => {
|
||||
test("detachAgent removes relationship lifecycle labels 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);
|
||||
@@ -3679,6 +3684,8 @@ test("detachAgent removes the parent label from a stored-only agent", async () =
|
||||
{
|
||||
labels: {
|
||||
[PARENT_AGENT_ID_LABEL]: parent.id,
|
||||
[DESKTOP_OPEN_AGENT_TAB_LABEL]: "true",
|
||||
[MOBILE_OPEN_AGENT_TAB_LABEL]: "false",
|
||||
role: "reviewer",
|
||||
},
|
||||
workspaceId: undefined,
|
||||
@@ -6724,6 +6731,182 @@ test("archiveAgent cascade archives in-memory children with the full archive con
|
||||
expect(storedUnrelated?.archivedAt).toBeUndefined();
|
||||
});
|
||||
|
||||
test("archiveAgent detaches an open same-workspace child instead of cascading", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-cascade-open-child-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new TestAgentClient() },
|
||||
registry: storage,
|
||||
logger,
|
||||
});
|
||||
const parent = await manager.createAgent(
|
||||
{ provider: "codex", cwd: workdir, title: "Parent" },
|
||||
undefined,
|
||||
{ workspaceId: "workspace-a" },
|
||||
);
|
||||
const child = await manager.createAgent(
|
||||
{ provider: "codex", cwd: workdir, title: "Open child" },
|
||||
undefined,
|
||||
{
|
||||
workspaceId: "workspace-a",
|
||||
labels: {
|
||||
[PARENT_AGENT_ID_LABEL]: parent.id,
|
||||
[DESKTOP_OPEN_AGENT_TAB_LABEL]: "false",
|
||||
[MOBILE_OPEN_AGENT_TAB_LABEL]: "true",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await manager.archiveAgent(parent.id);
|
||||
|
||||
const storedChild = await storage.get(child.id);
|
||||
expect(storedChild?.archivedAt).toBeUndefined();
|
||||
expect(storedChild?.labels[PARENT_AGENT_ID_LABEL]).toBeUndefined();
|
||||
expect(storedChild?.labels[DESKTOP_OPEN_AGENT_TAB_LABEL]).toBeUndefined();
|
||||
expect(storedChild?.labels[MOBILE_OPEN_AGENT_TAB_LABEL]).toBeUndefined();
|
||||
expect(manager.getAgent(child.id)?.id).toBe(child.id);
|
||||
});
|
||||
|
||||
test("archiveAgent detaches a cross-workspace child even when its tab is closed", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-cascade-cross-workspace-child-"));
|
||||
const storage = new AgentStorage(join(workdir, "agents"), logger);
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new TestAgentClient() },
|
||||
registry: storage,
|
||||
logger,
|
||||
});
|
||||
const parent = await manager.createAgent(
|
||||
{ provider: "codex", cwd: workdir, title: "Parent" },
|
||||
undefined,
|
||||
{ workspaceId: "workspace-a" },
|
||||
);
|
||||
const child = await manager.createAgent(
|
||||
{ provider: "codex", cwd: workdir, title: "Cross-workspace child" },
|
||||
undefined,
|
||||
{
|
||||
workspaceId: "workspace-b",
|
||||
labels: {
|
||||
[PARENT_AGENT_ID_LABEL]: parent.id,
|
||||
[DESKTOP_OPEN_AGENT_TAB_LABEL]: "false",
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
await manager.archiveAgent(parent.id);
|
||||
|
||||
const storedChild = await storage.get(child.id);
|
||||
expect(storedChild?.archivedAt).toBeUndefined();
|
||||
expect(storedChild?.workspaceId).toBe("workspace-b");
|
||||
expect(storedChild?.labels[PARENT_AGENT_ID_LABEL]).toBeUndefined();
|
||||
});
|
||||
|
||||
test("archiveAgent re-reads a child before deciding whether to cascade", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-cascade-fresh-child-"));
|
||||
|
||||
class ChildOpensAfterCascadeListStorage extends AgentStorage {
|
||||
childId: string | null = null;
|
||||
|
||||
override async list(): Promise<StoredAgentRecord[]> {
|
||||
const records = await super.list();
|
||||
const parentIsArchived = records.some(
|
||||
(record) => record.id !== this.childId && Boolean(record.archivedAt),
|
||||
);
|
||||
const staleChild = records.find((record) => record.id === this.childId);
|
||||
if (parentIsArchived && staleChild) {
|
||||
await super.upsert({
|
||||
...staleChild,
|
||||
labels: { ...staleChild.labels, [MOBILE_OPEN_AGENT_TAB_LABEL]: "true" },
|
||||
});
|
||||
}
|
||||
return records;
|
||||
}
|
||||
}
|
||||
|
||||
const storage = new ChildOpensAfterCascadeListStorage(join(workdir, "agents"), logger);
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new TestAgentClient() },
|
||||
registry: storage,
|
||||
logger,
|
||||
});
|
||||
const parent = await manager.createAgent(
|
||||
{ provider: "codex", cwd: workdir, title: "Parent" },
|
||||
undefined,
|
||||
{ workspaceId: "workspace-a" },
|
||||
);
|
||||
const child = await manager.createAgent(
|
||||
{ provider: "codex", cwd: workdir, title: "Late-open child" },
|
||||
undefined,
|
||||
{
|
||||
workspaceId: "workspace-a",
|
||||
labels: {
|
||||
[PARENT_AGENT_ID_LABEL]: parent.id,
|
||||
[MOBILE_OPEN_AGENT_TAB_LABEL]: "false",
|
||||
},
|
||||
},
|
||||
);
|
||||
storage.childId = child.id;
|
||||
|
||||
await manager.archiveAgent(parent.id);
|
||||
|
||||
const storedChild = await storage.get(child.id);
|
||||
expect(storedChild?.archivedAt).toBeUndefined();
|
||||
expect(storedChild?.labels[PARENT_AGENT_ID_LABEL]).toBeUndefined();
|
||||
});
|
||||
|
||||
test("archiveAgent cannot overtake a received child open-tab update", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-cascade-open-race-"));
|
||||
const markerWriteStarted = deferred<void>();
|
||||
const releaseMarkerWrite = deferred<void>();
|
||||
|
||||
class BlockingOpenMarkerStorage extends AgentStorage {
|
||||
childId: string | null = null;
|
||||
|
||||
override async applySnapshot(
|
||||
agent: ManagedAgent,
|
||||
options?: { title?: string | null; internal?: boolean },
|
||||
): Promise<void> {
|
||||
if (agent.id === this.childId && agent.labels[MOBILE_OPEN_AGENT_TAB_LABEL] === "true") {
|
||||
markerWriteStarted.resolve();
|
||||
await releaseMarkerWrite.promise;
|
||||
}
|
||||
await super.applySnapshot(agent, options);
|
||||
}
|
||||
}
|
||||
|
||||
const storage = new BlockingOpenMarkerStorage(join(workdir, "agents"), logger);
|
||||
const manager = new AgentManager({
|
||||
clients: { codex: new TestAgentClient() },
|
||||
registry: storage,
|
||||
logger,
|
||||
});
|
||||
const parent = await manager.createAgent(
|
||||
{ provider: "codex", cwd: workdir, title: "Parent" },
|
||||
undefined,
|
||||
{ workspaceId: "workspace-a" },
|
||||
);
|
||||
const child = await manager.createAgent(
|
||||
{ provider: "codex", cwd: workdir, title: "Opening child" },
|
||||
undefined,
|
||||
{
|
||||
workspaceId: "workspace-a",
|
||||
labels: { [PARENT_AGENT_ID_LABEL]: parent.id },
|
||||
},
|
||||
);
|
||||
storage.childId = child.id;
|
||||
|
||||
const markOpen = manager.updateAgentMetadata(child.id, {
|
||||
labels: { [MOBILE_OPEN_AGENT_TAB_LABEL]: "true" },
|
||||
});
|
||||
await markerWriteStarted.promise;
|
||||
const archiveParent = manager.archiveAgent(parent.id);
|
||||
releaseMarkerWrite.resolve();
|
||||
await Promise.all([markOpen, archiveParent]);
|
||||
|
||||
const storedChild = await storage.get(child.id);
|
||||
expect(storedChild?.archivedAt).toBeUndefined();
|
||||
expect(storedChild?.labels[PARENT_AGENT_ID_LABEL]).toBeUndefined();
|
||||
});
|
||||
|
||||
test("archiveAgent cascade closes a running child runtime", async () => {
|
||||
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-cascade-running-child-"));
|
||||
const storagePath = join(workdir, "agents");
|
||||
|
||||
@@ -7,7 +7,9 @@ import {
|
||||
} from "@getpaseo/protocol/agent-lifecycle";
|
||||
import {
|
||||
getParentAgentIdFromLabels,
|
||||
hasOpenAgentTab,
|
||||
isDelegatedAgent,
|
||||
isOpenAgentTabLabel,
|
||||
PARENT_AGENT_ID_LABEL,
|
||||
} from "@getpaseo/protocol/agent-labels";
|
||||
import type { Logger } from "pino";
|
||||
@@ -613,6 +615,27 @@ function getFirstUserMessageTextFromRows(rows: readonly AgentTimelineRow[]): str
|
||||
return null;
|
||||
}
|
||||
|
||||
function shouldDetachFromArchivedParent(
|
||||
parent: StoredAgentRecord,
|
||||
child: StoredAgentRecord,
|
||||
): boolean {
|
||||
const isCrossWorkspace =
|
||||
parent.workspaceId !== undefined &&
|
||||
child.workspaceId !== undefined &&
|
||||
parent.workspaceId !== child.workspaceId;
|
||||
return isCrossWorkspace || hasOpenAgentTab(child.labels);
|
||||
}
|
||||
|
||||
function detachedAgentLabelPatch(labels: Record<string, string>): AgentLabelPatch {
|
||||
const patch: AgentLabelPatch = { [PARENT_AGENT_ID_LABEL]: null };
|
||||
for (const label of Object.keys(labels)) {
|
||||
if (isOpenAgentTabLabel(label)) {
|
||||
patch[label] = null;
|
||||
}
|
||||
}
|
||||
return patch;
|
||||
}
|
||||
|
||||
export class AgentManager {
|
||||
private readonly clients = new Map<AgentProvider, AgentClient>();
|
||||
private readonly providerEnabled = new Map<AgentProvider, boolean>();
|
||||
@@ -631,6 +654,7 @@ export class AgentManager {
|
||||
private readonly backgroundTasks = new Set<Promise<void>>();
|
||||
private readonly agentRegistrationTasks = new Set<Promise<void>>();
|
||||
private readonly inFlightAgentCloses = new Map<string, Promise<void>>();
|
||||
private readonly lifecycleMutationTails = new Map<string, Promise<void>>();
|
||||
private readonly agentStreamCoalescer: AgentStreamCoalescer;
|
||||
private mcpBaseUrl: string | null;
|
||||
private readonly mcpAuthToken: string | null;
|
||||
@@ -1494,6 +1518,10 @@ export class AgentManager {
|
||||
}
|
||||
|
||||
async archiveAgent(agentId: string): Promise<{ archivedAt: string }> {
|
||||
return this.runLifecycleMutation(agentId, () => this.archiveAgentUnlocked(agentId));
|
||||
}
|
||||
|
||||
private async archiveAgentUnlocked(agentId: string): Promise<{ archivedAt: string }> {
|
||||
const agent = this.requireAgent(agentId);
|
||||
if (!this.registry) {
|
||||
throw new Error("Agent storage is not configured");
|
||||
@@ -1527,6 +1555,10 @@ export class AgentManager {
|
||||
return;
|
||||
}
|
||||
const records = await registry.list();
|
||||
const parent = records.find((record) => record.id === parentAgentId);
|
||||
if (!parent) {
|
||||
throw new Error(`Archived parent ${parentAgentId} not found in storage`);
|
||||
}
|
||||
for (const record of records) {
|
||||
if (record.archivedAt) {
|
||||
continue;
|
||||
@@ -1534,11 +1566,27 @@ export class AgentManager {
|
||||
if (record.labels?.[PARENT_AGENT_ID_LABEL] !== parentAgentId) {
|
||||
continue;
|
||||
}
|
||||
if (this.agents.has(record.id)) {
|
||||
await this.archiveAgent(record.id);
|
||||
} else {
|
||||
await this.archiveSnapshot(record.id, new Date().toISOString());
|
||||
const child = await registry.get(record.id);
|
||||
if (!child || child.archivedAt || child.labels?.[PARENT_AGENT_ID_LABEL] !== parentAgentId) {
|
||||
continue;
|
||||
}
|
||||
await this.runLifecycleMutation(child.id, async () => {
|
||||
const currentChild = await registry.get(child.id);
|
||||
if (
|
||||
!currentChild ||
|
||||
currentChild.archivedAt ||
|
||||
currentChild.labels?.[PARENT_AGENT_ID_LABEL] !== parentAgentId
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (shouldDetachFromArchivedParent(parent, currentChild)) {
|
||||
await this.detachAgentUnlocked(currentChild.id);
|
||||
} else if (this.agents.has(currentChild.id)) {
|
||||
await this.archiveAgentUnlocked(currentChild.id);
|
||||
} else {
|
||||
await this.archiveSnapshot(currentChild.id, new Date().toISOString());
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1711,8 +1759,10 @@ export class AgentManager {
|
||||
}
|
||||
|
||||
async setLabels(agentId: string, labels: Record<string, string>): Promise<void> {
|
||||
const agent = this.requireAgent(agentId);
|
||||
await this.writeLabels(agent.id, labels);
|
||||
await this.runLifecycleMutation(agentId, async () => {
|
||||
const agent = this.requireAgent(agentId);
|
||||
await this.writeLabels(agent.id, labels);
|
||||
});
|
||||
}
|
||||
|
||||
private async writeLabels(agentId: string, patch: AgentLabelPatch): Promise<WriteLabelsResult> {
|
||||
@@ -1754,6 +1804,14 @@ export class AgentManager {
|
||||
record: StoredAgentRecord;
|
||||
live: boolean;
|
||||
previousParentAgentId: string | null;
|
||||
}> {
|
||||
return this.runLifecycleMutation(agentId, () => this.detachAgentUnlocked(agentId));
|
||||
}
|
||||
|
||||
private async detachAgentUnlocked(agentId: string): Promise<{
|
||||
record: StoredAgentRecord;
|
||||
live: boolean;
|
||||
previousParentAgentId: string | null;
|
||||
}> {
|
||||
const registry = this.requireRegistry();
|
||||
const liveAgent = this.agents.get(agentId);
|
||||
@@ -1768,7 +1826,7 @@ export class AgentManager {
|
||||
return { record, live: true, previousParentAgentId: null };
|
||||
}
|
||||
|
||||
const { record } = await this.writeLabels(agentId, { [PARENT_AGENT_ID_LABEL]: null });
|
||||
const { record } = await this.writeLabels(agentId, detachedAgentLabelPatch(liveAgent.labels));
|
||||
if (!record) {
|
||||
throw new Error(`Agent not found in storage after detach: ${agentId}`);
|
||||
}
|
||||
@@ -1784,7 +1842,7 @@ export class AgentManager {
|
||||
return { record, live: false, previousParentAgentId: null };
|
||||
}
|
||||
|
||||
const result = await this.writeLabels(agentId, { [PARENT_AGENT_ID_LABEL]: null });
|
||||
const result = await this.writeLabels(agentId, detachedAgentLabelPatch(record.labels));
|
||||
if (!result.record) {
|
||||
throw new Error(`Agent not found in storage after detach: ${agentId}`);
|
||||
}
|
||||
@@ -1890,6 +1948,18 @@ export class AgentManager {
|
||||
title?: string;
|
||||
labels?: Record<string, string>;
|
||||
},
|
||||
): Promise<void> {
|
||||
await this.runLifecycleMutation(agentId, () =>
|
||||
this.updateAgentMetadataUnlocked(agentId, updates),
|
||||
);
|
||||
}
|
||||
|
||||
private async updateAgentMetadataUnlocked(
|
||||
agentId: string,
|
||||
updates: {
|
||||
title?: string;
|
||||
labels?: Record<string, string>;
|
||||
},
|
||||
): Promise<void> {
|
||||
const liveAgent = this.getAgent(agentId);
|
||||
if (liveAgent) {
|
||||
@@ -1905,6 +1975,24 @@ export class AgentManager {
|
||||
await this.writeStoredMetadata(agentId, updates);
|
||||
}
|
||||
|
||||
private async runLifecycleMutation<T>(agentId: string, mutation: () => Promise<T>): Promise<T> {
|
||||
// Parent cascade classifies a child inside the same lane used by open-tab
|
||||
// label writes, so a received ownership update cannot be overtaken.
|
||||
const previous = this.lifecycleMutationTails.get(agentId) ?? Promise.resolve();
|
||||
const result = previous.catch(() => undefined).then(mutation);
|
||||
const tail = result.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
this.lifecycleMutationTails.set(agentId, tail);
|
||||
void tail.finally(() => {
|
||||
if (this.lifecycleMutationTails.get(agentId) === tail) {
|
||||
this.lifecycleMutationTails.delete(agentId);
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
async runAgent(
|
||||
agentId: string,
|
||||
prompt: AgentPromptInput,
|
||||
|
||||
Reference in New Issue
Block a user