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:
Mohamed Boudra
2026-08-12 21:18:55 +02:00
committed by GitHub
parent fc1cb82e02
commit 4426a6b11f
21 changed files with 696 additions and 70 deletions
@@ -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,