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
+1
View File
@@ -614,6 +614,7 @@ export const ar: TranslationResources = {
reloadingAgent: "وكيل إعادة التحميل...",
reloadedAgent: "وكيل إعادة تحميل",
failedToReloadAgent: "فشل في إعادة تحميل الوكيل",
failedToCloseAgent: "فشل في إغلاق الوكيل",
},
confirmations: {
unsavedTitle: "تغييرات غير محفوظة",
+1
View File
@@ -613,6 +613,7 @@ export const en = {
reloadingAgent: "Reloading agent...",
reloadedAgent: "Reloaded agent",
failedToReloadAgent: "Failed to reload agent",
failedToCloseAgent: "Failed to close agent",
},
confirmations: {
close: "Close",
+1
View File
@@ -619,6 +619,7 @@ export const es: TranslationResources = {
reloadingAgent: "Agente de recarga...",
reloadedAgent: "Agente recargado",
failedToReloadAgent: "No se pudo recargar el agente",
failedToCloseAgent: "No se pudo cerrar el agente",
},
confirmations: {
unsavedTitle: "Cambios sin guardar",
+1
View File
@@ -619,6 +619,7 @@ export const fr: TranslationResources = {
reloadingAgent: "Agent de rechargement...",
reloadedAgent: "Agent rechargé",
failedToReloadAgent: "Échec du rechargement de l'agent",
failedToCloseAgent: "Échec de la fermeture de l'agent",
},
confirmations: {
unsavedTitle: "Modifications non enregistrées",
+1
View File
@@ -619,6 +619,7 @@ export const ja: TranslationResources = {
reloadingAgent: "エージェントを再読み込み中...",
reloadedAgent: "エージェントを再読み込みしました",
failedToReloadAgent: "エージェントの再読み込みに失敗しました",
failedToCloseAgent: "エージェントを閉じられませんでした",
},
confirmations: {
unsavedTitle: "未保存の変更",
+1
View File
@@ -616,6 +616,7 @@ export const ko: TranslationResources = {
reloadingAgent: "에이전트 다시 로드 중...",
reloadedAgent: "에이전트를 다시 로드했습니다",
failedToReloadAgent: "에이전트를 다시 로드하지 못했습니다",
failedToCloseAgent: "에이전트를 닫지 못했습니다",
},
confirmations: {
close: "닫기",
+1
View File
@@ -618,6 +618,7 @@ export const ptBR: TranslationResources = {
reloadingAgent: "Recarregando agente...",
reloadedAgent: "Agente recarregado",
failedToReloadAgent: "Falha ao recarregar agente",
failedToCloseAgent: "Falha ao fechar agente",
},
confirmations: {
unsavedTitle: "Alterações não salvas",
+1
View File
@@ -618,6 +618,7 @@ export const ru: TranslationResources = {
reloadingAgent: "Перезагрузка агента...",
reloadedAgent: "Перезагруженный агент",
failedToReloadAgent: "Не удалось перезагрузить агент",
failedToCloseAgent: "Не удалось закрыть агент",
},
confirmations: {
unsavedTitle: "Несохранённые изменения",
+1
View File
@@ -614,6 +614,7 @@ export const zhCN: TranslationResources = {
reloadingAgent: "正在重新加载 Agent...",
reloadedAgent: "已重新加载 Agent",
failedToReloadAgent: "重新加载 Agent 失败",
failedToCloseAgent: "关闭 Agent 失败",
},
confirmations: {
unsavedTitle: "未保存的更改",
@@ -42,7 +42,8 @@ describe("workspace bulk close helpers", () => {
]);
expect(groups).toEqual({
agentTabs: [{ tabId: "agent_a1", agentId: "a1" }],
archiveAgentTabs: [{ tabId: "agent_a1", agentId: "a1" }],
layoutOnlyAgentTabs: [],
terminalTabs: [{ tabId: "terminal_t1", terminalId: "t1" }],
otherTabs: [
{
@@ -53,6 +54,21 @@ describe("workspace bulk close helpers", () => {
});
});
it("separates subagent tabs from agents that archive on close", () => {
const groups = classifyBulkClosableTabs(
[makeAgentTab("root"), makeAgentTab("child")],
(agentId) => (agentId === "child" ? "layout-only" : "archive"),
);
expect(groups).toMatchObject({
archiveAgentTabs: [{ tabId: "agent_root", agentId: "root" }],
layoutOnlyAgentTabs: [{ tabId: "agent_child", agentId: "child" }],
});
expect(buildBulkCloseConfirmationMessage(groups)).toBe(
"This will archive 1 agent(s) and close 1 tab(s).",
);
});
it("describes mixed destructive bulk close operations in the confirmation copy", () => {
const message = buildBulkCloseConfirmationMessage(
classifyBulkClosableTabs([
@@ -106,6 +122,7 @@ describe("workspace bulk close helpers", () => {
closeWorkspaceTabWithCleanup: (input) => {
cleanupCalls.push(input);
},
closeLayoutOnlyAgent: async () => {},
logLabel: "all tabs",
});
@@ -152,6 +169,7 @@ describe("workspace bulk close helpers", () => {
closeWorkspaceTabWithCleanup: (input) => {
cleanupCalls.push(input);
},
closeLayoutOnlyAgent: async () => {},
warn,
logLabel: "others",
});
@@ -166,4 +184,31 @@ describe("workspace bulk close helpers", () => {
{ tabId: "file_/repo/README.md", target: { kind: "file", path: "/repo/README.md" } },
]);
});
it("closes subagent tabs without sending them to closeItems", async () => {
const groups = classifyBulkClosableTabs(
[makeAgentTab("root"), makeAgentTab("child")],
(agentId) => (agentId === "child" ? "layout-only" : "archive"),
);
const closeItems = vi.fn(async () => ({ agents: [], terminals: [], requestId: "req-1" }));
const preparedSubagents: string[] = [];
const cleanedTabs: string[] = [];
await closeBulkWorkspaceTabs({
groups,
client: { closeItems },
closeTab: async (_tabId, action) => action(),
closeLayoutOnlyAgent: async (agentId) => {
preparedSubagents.push(agentId);
},
closeWorkspaceTabWithCleanup: ({ tabId }) => {
cleanedTabs.push(tabId);
},
logLabel: "others",
});
expect(closeItems).toHaveBeenCalledWith({ agentIds: ["root"], terminalIds: [] });
expect(preparedSubagents).toEqual(["child"]);
expect(cleanedTabs).toEqual(["agent_child", "agent_root"]);
});
});
@@ -3,7 +3,8 @@ import type { WorkspaceTabDescriptor } from "@/screens/workspace/workspace-tabs-
import { i18n } from "@/i18n/i18next";
export interface BulkClosableTabGroups {
agentTabs: Array<{ tabId: string; agentId: string }>;
archiveAgentTabs: Array<{ tabId: string; agentId: string }>;
layoutOnlyAgentTabs: Array<{ tabId: string; agentId: string }>;
terminalTabs: Array<{ tabId: string; terminalId: string }>;
otherTabs: Array<{ tabId: string; target: WorkspaceTabDescriptor["target"] }>;
}
@@ -43,20 +44,30 @@ interface CloseBulkWorkspaceTabsInput {
groups: BulkClosableTabGroups;
closeTab: (tabId: string, action: () => Promise<void>) => Promise<void>;
closeWorkspaceTabWithCleanup: (input: CloseWorkspaceTabWithCleanupInput) => void;
closeLayoutOnlyAgent: (agentId: string) => Promise<void>;
logLabel: string;
warn?: (message: string, payload: object) => void;
}
export function classifyBulkClosableTabs(tabs: WorkspaceTabDescriptor[]): BulkClosableTabGroups {
export function classifyBulkClosableTabs(
tabs: WorkspaceTabDescriptor[],
resolveAgentCloseKind: (agentId: string) => "archive" | "layout-only" = () => "archive",
): BulkClosableTabGroups {
const groups: BulkClosableTabGroups = {
agentTabs: [],
archiveAgentTabs: [],
layoutOnlyAgentTabs: [],
terminalTabs: [],
otherTabs: [],
};
for (const tab of tabs) {
if (tab.target.kind === "agent") {
groups.agentTabs.push({ tabId: tab.tabId, agentId: tab.target.agentId });
const agentTab = { tabId: tab.tabId, agentId: tab.target.agentId };
if (resolveAgentCloseKind(tab.target.agentId) === "layout-only") {
groups.layoutOnlyAgentTabs.push(agentTab);
} else {
groups.archiveAgentTabs.push(agentTab);
}
continue;
}
if (tab.target.kind === "terminal") {
@@ -73,49 +84,73 @@ export function buildBulkCloseConfirmationMessage(
input: BulkClosableTabGroups,
labels: BulkCloseConfirmationLabels = DEFAULT_BULK_CLOSE_CONFIRMATION_LABELS,
): string {
const { agentTabs, terminalTabs, otherTabs } = input;
if (agentTabs.length > 0 && terminalTabs.length > 0 && otherTabs.length > 0) {
const { archiveAgentTabs, layoutOnlyAgentTabs, terminalTabs, otherTabs } = input;
const tabCount = layoutOnlyAgentTabs.length + otherTabs.length;
if (archiveAgentTabs.length > 0 && terminalTabs.length > 0 && tabCount > 0) {
return labels.all({
agents: agentTabs.length,
agents: archiveAgentTabs.length,
terminals: terminalTabs.length,
tabs: otherTabs.length,
tabs: tabCount,
});
}
if (agentTabs.length > 0 && terminalTabs.length > 0) {
if (archiveAgentTabs.length > 0 && terminalTabs.length > 0) {
return labels.agentsAndTerminals({
agents: agentTabs.length,
agents: archiveAgentTabs.length,
terminals: terminalTabs.length,
});
}
if (terminalTabs.length > 0 && otherTabs.length > 0) {
if (terminalTabs.length > 0 && tabCount > 0) {
return labels.terminalsAndTabs({
terminals: terminalTabs.length,
tabs: otherTabs.length,
tabs: tabCount,
});
}
if (agentTabs.length > 0 && otherTabs.length > 0) {
if (archiveAgentTabs.length > 0 && tabCount > 0) {
return labels.agentsAndTabs({
agents: agentTabs.length,
tabs: otherTabs.length,
agents: archiveAgentTabs.length,
tabs: tabCount,
});
}
if (terminalTabs.length > 0) {
return labels.terminals({ terminals: terminalTabs.length });
}
if (otherTabs.length > 0) {
return labels.tabs({ tabs: otherTabs.length });
if (tabCount > 0) {
return labels.tabs({ tabs: tabCount });
}
return labels.agents({ agents: agentTabs.length });
return labels.agents({ agents: archiveAgentTabs.length });
}
export async function closeBulkWorkspaceTabs(input: CloseBulkWorkspaceTabsInput): Promise<void> {
const { client, groups, closeTab, closeWorkspaceTabWithCleanup, logLabel, warn } = input;
const hasDestructiveTabs = groups.agentTabs.length > 0 || groups.terminalTabs.length > 0;
const {
client,
groups,
closeTab,
closeWorkspaceTabWithCleanup,
closeLayoutOnlyAgent,
logLabel,
warn,
} = input;
const hasDestructiveTabs = groups.archiveAgentTabs.length > 0 || groups.terminalTabs.length > 0;
for (const { tabId, agentId } of groups.layoutOnlyAgentTabs) {
await closeTab(tabId, async () => {
try {
await closeLayoutOnlyAgent(agentId);
} catch (error) {
warn?.(`[WorkspaceScreen] Failed to close subagent tab ${logLabel}`, { error, agentId });
return;
}
closeWorkspaceTabWithCleanup({
tabId,
target: { kind: "agent", agentId },
});
});
}
if (hasDestructiveTabs && client) {
void client
.closeItems({
agentIds: groups.agentTabs.map((tab) => tab.agentId),
agentIds: groups.archiveAgentTabs.map((tab) => tab.agentId),
terminalIds: groups.terminalTabs.map((tab) => tab.terminalId),
})
.catch((error) => {
@@ -127,27 +162,27 @@ export async function closeBulkWorkspaceTabs(input: CloseBulkWorkspaceTabsInput)
});
}
for (const { tabId, agentId } of groups.agentTabs) {
void closeTab(tabId, async () => {
closeWorkspaceTabWithCleanup({
tabId,
target: { kind: "agent", agentId },
});
});
}
for (const { tabId, terminalId } of groups.terminalTabs) {
void closeTab(tabId, async () => {
closeWorkspaceTabWithCleanup({
tabId,
target: { kind: "terminal", terminalId },
});
});
}
for (const { tabId, target } of groups.otherTabs) {
void closeTab(tabId, async () => {
closeWorkspaceTabWithCleanup({ tabId, target });
});
}
await Promise.all([
...groups.archiveAgentTabs.map(({ tabId, agentId }) =>
closeTab(tabId, async () => {
closeWorkspaceTabWithCleanup({
tabId,
target: { kind: "agent", agentId },
});
}),
),
...groups.terminalTabs.map(({ tabId, terminalId }) =>
closeTab(tabId, async () => {
closeWorkspaceTabWithCleanup({
tabId,
target: { kind: "terminal", terminalId },
});
}),
),
...groups.otherTabs.map(({ tabId, target }) =>
closeTab(tabId, async () => {
closeWorkspaceTabWithCleanup({ tabId, target });
}),
),
]);
}
@@ -1,4 +1,5 @@
import { LoadingSpinner } from "@/components/ui/loading-spinner";
import { getOpenAgentTabLabel } from "@getpaseo/protocol/agent-labels";
import {
memo,
useCallback,
@@ -66,6 +67,7 @@ import { WorkspaceOpenInEditorButton } from "@/screens/workspace/workspace-open-
import { WorkspaceScriptsButton } from "@/screens/workspace/workspace-scripts-button";
import { ImportSessionSheet } from "@/components/import-session-sheet";
import { useToast } from "@/contexts/toast-context";
import { getOrCreateClientId } from "@/utils/client-id";
import { selectIsFileExplorerOpen, usePanelStore } from "@/stores/panel-store";
import { type ExplorerCheckoutContext } from "@/stores/explorer-checkout-context";
import { traceInstant } from "@/performance/native-trace";
@@ -112,6 +114,7 @@ import { getDesktopHost } from "@/desktop/host";
import { buildProviderCommand } from "@/utils/provider-command-templates";
import { generateDraftId } from "@/stores/draft-keys";
import { resolveWorkspaceRouteId } from "@/utils/workspace-identity";
import { useOpenAgentTabLabels } from "@/subagents/use-open-agent-tab-labels";
import {
WorkspaceTabPresentationResolver,
WorkspaceTabIcon,
@@ -1970,6 +1973,12 @@ function WorkspaceScreenContent({
() => (workspaceLayout ? collectAllTabs(workspaceLayout.root) : EMPTY_UI_TABS),
[workspaceLayout],
);
useOpenAgentTabLabels({
client,
serverId: normalizedServerId,
tabs: uiTabs,
enabled: hasHydratedWorkspaceLayoutStore,
});
useSyncWorkspaceActiveBrowser({
workspaceLayout,
isRouteFocused,
@@ -2651,7 +2660,7 @@ function WorkspaceScreenContent({
const agent =
useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null;
const closePolicy = resolveCloseAgentTabPolicy(agent);
let closePolicy = resolveCloseAgentTabPolicy(agent);
const isRunning = agent?.status === "running";
if (isRunning && closePolicy.kind === "archive-on-close") {
@@ -2667,6 +2676,27 @@ function WorkspaceScreenContent({
}
}
if (closePolicy.kind === "layout-only") {
const sessionClient = useSessionStore.getState().sessions[normalizedServerId]?.client;
if (!sessionClient) {
toast.error(t("common.errors.daemonClientUnavailable"));
return;
}
try {
const clientId = await getOrCreateClientId();
await sessionClient.updateAgent(agentId, {
labels: { [getOpenAgentTabLabel(clientId)]: "false" },
});
const latestAgent =
useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null;
closePolicy = resolveCloseAgentTabPolicy(latestAgent);
} catch (error) {
console.error("[WorkspaceScreen] Failed to close subagent tab", { error, agentId });
toast.error(t("workspace.tabs.toasts.failedToCloseAgent"));
return;
}
}
setHoveredCloseTabKey((current) => (current === tabId ? null : current));
if (persistenceKey) {
closeWorkspaceTabWithCleanup({
@@ -2683,7 +2713,15 @@ function WorkspaceScreenContent({
void archiveAgent({ serverId: normalizedServerId, agentId }).catch(() => {});
});
},
[archiveAgent, closeTab, closeWorkspaceTabWithCleanup, normalizedServerId, persistenceKey, t],
[
archiveAgent,
closeTab,
closeWorkspaceTabWithCleanup,
normalizedServerId,
persistenceKey,
t,
toast,
],
);
const handleClosePassiveTab = useCallback(
@@ -2899,7 +2937,10 @@ function WorkspaceScreenContent({
return;
}
const groups = classifyBulkClosableTabs(tabsToClose);
const groups = classifyBulkClosableTabs(tabsToClose, (agentId) => {
const agent = useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId);
return resolveCloseAgentTabPolicy(agent).kind === "layout-only" ? "layout-only" : "archive";
});
const modifiedCount = tabsToClose.filter(
(tab) =>
getPanelInstanceAttributes({
@@ -2927,6 +2968,20 @@ function WorkspaceScreenContent({
client,
groups,
closeTab,
closeLayoutOnlyAgent: async (agentId) => {
if (!client) {
throw new Error(t("common.errors.daemonClientUnavailable"));
}
const clientId = await getOrCreateClientId();
await client.updateAgent(agentId, {
labels: { [getOpenAgentTabLabel(clientId)]: "false" },
});
const latestAgent =
useSessionStore.getState().sessions[normalizedServerId]?.agents?.get(agentId) ?? null;
if (resolveCloseAgentTabPolicy(latestAgent).kind === "archive-on-close") {
await archiveAgent({ serverId: normalizedServerId, agentId });
}
},
closeWorkspaceTabWithCleanup: (cleanupInput) => {
if (!persistenceKey) {
return;
@@ -2943,6 +2998,7 @@ function WorkspaceScreenContent({
setHoveredCloseTabKey((current) => (current && closedKeys.has(current) ? null : current));
},
[
archiveAgent,
bulkCloseConfirmationLabels,
client,
closeTab,
@@ -0,0 +1,53 @@
import { describe, expect, it } from "vitest";
import type { Agent } from "@/stores/session-store";
import type { WorkspaceTab } from "@/workspace-tabs/model";
import { getAgentTabsNeedingOpenLabel } from "./open-tab-labels";
const label = "paseo.open-agent-tab.client-1";
function tab(agentId: string, createdAt: number): WorkspaceTab {
return { tabId: `agent_${agentId}`, target: { kind: "agent", agentId }, createdAt };
}
function agent(input: { id: string; parentAgentId?: string | null; open?: boolean }): Agent {
return {
id: input.id,
parentAgentId: input.parentAgentId ?? null,
labels: input.open ? { [label]: "true" } : {},
} as Agent;
}
describe("getAgentTabsNeedingOpenLabel", () => {
it("marks every present parented tab, regardless of tab order or focus", () => {
const agents = new Map([
["background-child", agent({ id: "background-child", parentAgentId: "parent" })],
["focused-child", agent({ id: "focused-child", parentAgentId: "parent" })],
["root", agent({ id: "root" })],
]);
expect(
getAgentTabsNeedingOpenLabel({
tabs: [tab("background-child", 1), tab("root", 2), tab("focused-child", 3)],
getAgent: (agentId) => agents.get(agentId),
label,
pendingAgentIds: new Set(),
}),
).toEqual(["background-child", "focused-child"]);
});
it("skips tabs already marked or currently being marked", () => {
const agents = new Map([
["marked", agent({ id: "marked", parentAgentId: "parent", open: true })],
["pending", agent({ id: "pending", parentAgentId: "parent" })],
]);
expect(
getAgentTabsNeedingOpenLabel({
tabs: [tab("marked", 1), tab("pending", 2)],
getAgent: (agentId) => agents.get(agentId),
label,
pendingAgentIds: new Set(["pending"]),
}),
).toEqual([]);
});
});
@@ -0,0 +1,25 @@
import type { Agent } from "@/stores/session-store";
import type { WorkspaceTab } from "@/workspace-tabs/model";
export function getAgentTabsNeedingOpenLabel(input: {
tabs: WorkspaceTab[];
getAgent: (agentId: string) => Agent | null | undefined;
label: string;
pendingAgentIds: ReadonlySet<string>;
}): string[] {
const agentIds = new Set<string>();
for (const tab of input.tabs) {
if (tab.target.kind !== "agent") {
continue;
}
const agent = input.getAgent(tab.target.agentId);
if (
agent?.parentAgentId &&
agent.labels[input.label] !== "true" &&
!input.pendingAgentIds.has(agent.id)
) {
agentIds.add(agent.id);
}
}
return [...agentIds];
}
@@ -0,0 +1,86 @@
import type { DaemonClient } from "@getpaseo/client/internal/daemon-client";
import { getOpenAgentTabLabel } from "@getpaseo/protocol/agent-labels";
import { useEffect, useRef, useState } from "react";
import { useSessionStore } from "@/stores/session-store";
import { getOrCreateClientId } from "@/utils/client-id";
import type { WorkspaceTab } from "@/workspace-tabs/model";
import { getAgentTabsNeedingOpenLabel } from "./open-tab-labels";
function increment(value: number): number {
return value + 1;
}
export function useOpenAgentTabLabels(input: {
client: DaemonClient | null;
serverId: string;
tabs: WorkspaceTab[];
enabled: boolean;
}): void {
const agents = useSessionStore((state) => state.sessions[input.serverId]?.agents ?? null);
const agentDetails = useSessionStore(
(state) => state.sessions[input.serverId]?.agentDetails ?? null,
);
const pendingAgentIdsRef = useRef(new Set<string>());
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const [retryVersion, setRetryVersion] = useState(0);
useEffect(
() => () => {
if (retryTimerRef.current) {
clearTimeout(retryTimerRef.current);
}
},
[],
);
useEffect(() => {
const client = input.client;
if (!client || !input.enabled) {
return;
}
const openAgentIds = new Set(
input.tabs.flatMap((tab) => (tab.target.kind === "agent" ? [tab.target.agentId] : [])),
);
for (const agentId of pendingAgentIdsRef.current) {
if (!openAgentIds.has(agentId)) {
pendingAgentIdsRef.current.delete(agentId);
}
}
if (openAgentIds.size === 0) {
return;
}
void (async () => {
try {
const clientId = await getOrCreateClientId();
const label = getOpenAgentTabLabel(clientId);
const agentIds = getAgentTabsNeedingOpenLabel({
tabs: input.tabs,
getAgent: (agentId) => agents?.get(agentId) ?? agentDetails?.get(agentId),
label,
pendingAgentIds: pendingAgentIdsRef.current,
});
for (const agentId of agentIds) {
pendingAgentIdsRef.current.add(agentId);
try {
await client.updateAgent(agentId, { labels: { [label]: "true" } });
} catch (error) {
console.warn("[OpenAgentTabLabels] Failed to mark open subagent tab", {
error,
agentId,
});
retryTimerRef.current ??= setTimeout(() => {
retryTimerRef.current = null;
setRetryVersion(increment);
}, 2_000);
} finally {
pendingAgentIdsRef.current.delete(agentId);
}
}
} catch (error) {
console.warn("[OpenAgentTabLabels] Failed to resolve client ID", { error });
}
})();
}, [agentDetails, agents, input.client, input.enabled, input.tabs, retryVersion]);
}
@@ -1,7 +1,10 @@
import { describe, expect, test } from "vitest";
import {
getParentAgentIdFromLabels,
getOpenAgentTabLabel,
hasOpenAgentTab,
isDelegatedAgent,
isOpenAgentTabLabel,
PARENT_AGENT_ID_LABEL,
} from "./agent-labels.js";
@@ -18,4 +21,19 @@ describe("agent label policy", () => {
expect(isDelegatedAgent({ labels: { [PARENT_AGENT_ID_LABEL]: " " } })).toBe(false);
expect(isDelegatedAgent({ labels: { [PARENT_AGENT_ID_LABEL]: 42 } })).toBe(false);
});
test("treats any true client-scoped open-tab label as open", () => {
const desktopLabel = getOpenAgentTabLabel("desktop-client");
const mobileLabel = getOpenAgentTabLabel("mobile-client");
expect(hasOpenAgentTab({ [desktopLabel]: "false", [mobileLabel]: "true" })).toBe(true);
expect(hasOpenAgentTab({ [desktopLabel]: "false", [mobileLabel]: "false" })).toBe(false);
expect(hasOpenAgentTab({})).toBe(false);
});
test("recognizes only client-scoped open-tab labels", () => {
expect(isOpenAgentTabLabel(getOpenAgentTabLabel("client-a"))).toBe(true);
expect(isOpenAgentTabLabel("paseo.open-agent-tab")).toBe(false);
expect(isOpenAgentTabLabel("custom.open-agent-tab.client-a")).toBe(false);
});
});
+15
View File
@@ -1,4 +1,13 @@
export const PARENT_AGENT_ID_LABEL = "paseo.parent-agent-id";
const OPEN_AGENT_TAB_LABEL_PREFIX = "paseo.open-agent-tab.";
export function getOpenAgentTabLabel(clientId: string): string {
return `${OPEN_AGENT_TAB_LABEL_PREFIX}${clientId}`;
}
export function isOpenAgentTabLabel(label: string): boolean {
return label.startsWith(OPEN_AGENT_TAB_LABEL_PREFIX);
}
export interface AgentLabelSource {
labels?: Record<string, unknown> | null;
@@ -14,3 +23,9 @@ export function getParentAgentIdFromLabels(labels: Record<string, unknown> | nul
export function isDelegatedAgent(agent: AgentLabelSource): boolean {
return getParentAgentIdFromLabels(agent.labels) !== null;
}
export function hasOpenAgentTab(labels: Record<string, unknown> | null | undefined): boolean {
return Object.entries(labels ?? {}).some(
([label, value]) => isOpenAgentTabLabel(label) && value === "true",
);
}
@@ -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,