From 4426a6b11fd9f1135d2e48076b83430a1531f973 Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Wed, 12 Aug 2026 21:18:55 +0200 Subject: [PATCH] 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 --- docs/agent-lifecycle.md | 33 ++- docs/data-model.md | 2 +- packages/app/src/i18n/resources/ar.ts | 1 + packages/app/src/i18n/resources/en.ts | 1 + packages/app/src/i18n/resources/es.ts | 1 + packages/app/src/i18n/resources/fr.ts | 1 + packages/app/src/i18n/resources/ja.ts | 1 + packages/app/src/i18n/resources/ko.ts | 1 + packages/app/src/i18n/resources/pt-BR.ts | 1 + packages/app/src/i18n/resources/ru.ts | 1 + packages/app/src/i18n/resources/zh-CN.ts | 1 + .../workspace/workspace-bulk-close.test.ts | 47 ++++- .../screens/workspace/workspace-bulk-close.ts | 123 ++++++++---- .../screens/workspace/workspace-screen.tsx | 62 +++++- .../app/src/subagents/open-tab-labels.test.ts | 53 +++++ packages/app/src/subagents/open-tab-labels.ts | 25 +++ .../subagents/use-open-agent-tab-labels.ts | 86 ++++++++ packages/protocol/src/agent-labels.test.ts | 18 ++ packages/protocol/src/agent-labels.ts | 15 ++ .../src/server/agent/agent-manager.test.ts | 189 +++++++++++++++++- .../server/src/server/agent/agent-manager.ts | 104 +++++++++- 21 files changed, 696 insertions(+), 70 deletions(-) create mode 100644 packages/app/src/subagents/open-tab-labels.test.ts create mode 100644 packages/app/src/subagents/open-tab-labels.ts create mode 100644 packages/app/src/subagents/use-open-agent-tab-labels.ts diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index c8cd0e11a..4da30bf0e 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -44,9 +44,21 @@ Agents can launch other agents via the agent-scoped `create_agent` MCP tool. Age - **Subagents** — exist as part of the creating agent's work, appear in that agent's subagent track, and are archived with it. - **Detached agents** — stand on their own after an explicit detach transition, do not appear in the former parent's subagent track, and are not archived with it. +Parent archive detaches a subagent instead of archiving it when either condition holds: + +- The child belongs to another workspace. +- The child is currently open in an agent tab. + +All other children archive with the parent. After the workspace layout hydrates, the client marks +every managed subagent present in its tabs with `paseo.open-agent-tab.=true` through the +generic agent metadata update. This includes background and restored tabs; navigation does not own +the marker. Closing a tab sets that client's label to `false`. Any `true` client label keeps the child +open. Detach clears the parent and every open-tab label. The surviving child therefore becomes a +normal root agent immediately, and closing its still-open tab archives it. + Runtime ownership is resolved from explicit workspace ID and caller context, never from `cwd`. Workspace creation is a separate operation with `local | worktree` isolation; agent creation only selects an existing workspace. -Users can also detach an existing subagent from the subagents track. Detach is deliberately a manual lifecycle gesture, not an agent-facing MCP tool. It removes the `paseo.parent-agent-id` label only: it does not stop, archive, move, or restart the agent. The agent keeps its current `cwd` and `workspaceId`, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive. +Users can also detach an existing subagent from the subagents track. Detach is deliberately a manual lifecycle gesture, not an agent-facing MCP tool. It removes the parent and open-tab lifecycle labels: it does not stop, archive, move, or restart the agent. The agent keeps its current `cwd` and `workspaceId`, leaves the former parent's track, and behaves like a root agent for tab close, workspace activity, and future parent archive. `notifyOnFinish` defaults to `true` for agent-scoped creation and background prompt follow-ups because most delegated work needs to report back to the creating agent. Set it to `false` only for truly fire-and-forget agents or prompts. Permission requests are notification checkpoints, not the end of that subscription. The caller is notified again after a permission response when the child finishes, errors, or requests another permission. @@ -74,7 +86,7 @@ Archiving runs through `AgentManager.archiveAgent` (`packages/server/src/server/ 2. Set `archivedAt` and normalize `lastStatus` away from `running`/`initializing` 3. Notify subscribers 4. Close the runtime (kills the process if still running) -5. **Cascade-archive children** — any agent whose `paseo.parent-agent-id` label matches the archived agent gets archived too, recursively +5. **Resolve children** — detach cross-workspace and open-tab children; cascade-archive the rest recursively Cascade is what keeps subagent fleets from outliving their orchestrator. @@ -109,7 +121,7 @@ These are two distinct concepts that used to be conflated: Closing a tab on a **root agent** still archives — the tab is the agent's home, so closing it means "I'm done with this agent." A confirm dialog protects against archiving a running agent by accident. -Closing a tab on a **subagent** (any agent with `parentAgentId`) is **layout-only**. The agent stays unarchived and stays in its parent's track. The user can re-open the tab from the track at any time. This is implemented in `handleCloseAgentTab` (`packages/app/src/screens/workspace/workspace-screen.tsx`). +Closing a tab on a **subagent** (any agent with `parentAgentId`) is **layout-only**. The app clears the current client's open-tab label before removing the tab. Another client's open tab remains protected. The agent stays unarchived and stays in its parent's track, so a later parent archive cascades to it when no client still has it open. The user can re-open the tab from the track at any time. Single and bulk tab close apply the same policy. The asymmetry is intentional: a subagent's persistent relationship lives in the parent's track. Same-workspace subagents are not auto-opened as tabs; the user opens one from that track when needed. A cross-workspace subagent is also auto-opened as a tab in its own workspace so opening that workspace does not appear empty. It remains in the parent's track until it is actually detached. @@ -155,7 +167,7 @@ Archived Paseo subagents disappear from the track, by design. To remove one from The track header's **Archive finished** action hides finished provider-owned rows in the current app session. Their native sessions and timelines are untouched, and managed Paseo subagents are not archived by this bulk action. If a hidden provider child starts running again, the app brings it back to the track. -To keep the agent alive but remove it from the parent's track, use **detach**. The daemon clears the parent label, emits the normal agent update, and every client reclassifies the agent from subagent to root/sibling from that updated snapshot. +To keep the agent alive but remove it from the parent's track, use **detach**. The daemon clears the relationship lifecycle labels, emits the normal agent update, and every client reclassifies the agent from subagent to root/sibling from that updated snapshot. ## Why this shape @@ -189,11 +201,12 @@ $PASEO_HOME/agents/{cwd-with-dashes}/{agent-id}.json Each agent is a single JSON file. Fields relevant to this doc: -| Field | Type | Meaning | -| --------------------------------- | ------------- | ---------------------------------------------------------------------------------- | -| `id` | `string` | Stable identifier | -| `archivedAt` | `string?` | Soft-delete timestamp (ISO 8601) | -| `labels["paseo.parent-agent-id"]` | `string?` | Parent agent ID, set automatically for agent-scoped creation and removed by detach | -| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` | +| Field | Type | Meaning | +| -------------------------------------------- | ------------- | ---------------------------------------------------------------------------------- | +| `id` | `string` | Stable identifier | +| `archivedAt` | `string?` | Soft-delete timestamp (ISO 8601) | +| `labels["paseo.parent-agent-id"]` | `string?` | Parent agent ID, set automatically for agent-scoped creation and removed by detach | +| `labels["paseo.open-agent-tab."]` | `string?` | `"true"` protects an open tab on that client; detach clears every matching label | +| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` | See [`docs/data-model.md`](./data-model.md) for the full agent record. diff --git a/docs/data-model.md b/docs/data-model.md index 3a29076ea..bc1d8bd17 100644 --- a/docs/data-model.md +++ b/docs/data-model.md @@ -85,7 +85,7 @@ Each agent is stored as a separate JSON file, grouped by project directory. | `lastActivityAt` | `string?` (ISO 8601) | Last activity timestamp | | `lastUserMessageAt` | `string?` (ISO 8601) | Last user message timestamp | | `title` | `string?` | User-visible title | -| `labels` | `Record` | Key-value labels (default `{}`). `paseo.parent-agent-id` is set automatically for agent-scoped creation and removed by detach — see [agent-lifecycle.md](./agent-lifecycle.md) | +| `labels` | `Record` | Key-value labels (default `{}`). Paseo uses `paseo.parent-agent-id` for parentage and client-scoped `paseo.open-agent-tab.*` labels while managed subagent tabs are open — see [agent-lifecycle.md](./agent-lifecycle.md) | | `lastStatus` | `AgentStatus` | One of: `"initializing"`, `"idle"`, `"running"`, `"error"`, `"closed"`. `closed` means the record is resumable but has no live provider runtime; archive remains represented separately by `archivedAt`. | | `lastModeId` | `string?` | Last active mode ID | | `config` | `SerializableConfig?` | Agent session configuration (see below) | diff --git a/packages/app/src/i18n/resources/ar.ts b/packages/app/src/i18n/resources/ar.ts index 1f0d16ec0..b42e61b66 100644 --- a/packages/app/src/i18n/resources/ar.ts +++ b/packages/app/src/i18n/resources/ar.ts @@ -614,6 +614,7 @@ export const ar: TranslationResources = { reloadingAgent: "وكيل إعادة التحميل...", reloadedAgent: "وكيل إعادة تحميل", failedToReloadAgent: "فشل في إعادة تحميل الوكيل", + failedToCloseAgent: "فشل في إغلاق الوكيل", }, confirmations: { unsavedTitle: "تغييرات غير محفوظة", diff --git a/packages/app/src/i18n/resources/en.ts b/packages/app/src/i18n/resources/en.ts index 477051f2e..af43bec2e 100644 --- a/packages/app/src/i18n/resources/en.ts +++ b/packages/app/src/i18n/resources/en.ts @@ -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", diff --git a/packages/app/src/i18n/resources/es.ts b/packages/app/src/i18n/resources/es.ts index 1b32460d3..60e14d81e 100644 --- a/packages/app/src/i18n/resources/es.ts +++ b/packages/app/src/i18n/resources/es.ts @@ -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", diff --git a/packages/app/src/i18n/resources/fr.ts b/packages/app/src/i18n/resources/fr.ts index b6da76799..471de6482 100644 --- a/packages/app/src/i18n/resources/fr.ts +++ b/packages/app/src/i18n/resources/fr.ts @@ -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", diff --git a/packages/app/src/i18n/resources/ja.ts b/packages/app/src/i18n/resources/ja.ts index f10037ef7..57331e14a 100644 --- a/packages/app/src/i18n/resources/ja.ts +++ b/packages/app/src/i18n/resources/ja.ts @@ -619,6 +619,7 @@ export const ja: TranslationResources = { reloadingAgent: "エージェントを再読み込み中...", reloadedAgent: "エージェントを再読み込みしました", failedToReloadAgent: "エージェントの再読み込みに失敗しました", + failedToCloseAgent: "エージェントを閉じられませんでした", }, confirmations: { unsavedTitle: "未保存の変更", diff --git a/packages/app/src/i18n/resources/ko.ts b/packages/app/src/i18n/resources/ko.ts index 66dc58c16..6be87e7b9 100644 --- a/packages/app/src/i18n/resources/ko.ts +++ b/packages/app/src/i18n/resources/ko.ts @@ -616,6 +616,7 @@ export const ko: TranslationResources = { reloadingAgent: "에이전트 다시 로드 중...", reloadedAgent: "에이전트를 다시 로드했습니다", failedToReloadAgent: "에이전트를 다시 로드하지 못했습니다", + failedToCloseAgent: "에이전트를 닫지 못했습니다", }, confirmations: { close: "닫기", diff --git a/packages/app/src/i18n/resources/pt-BR.ts b/packages/app/src/i18n/resources/pt-BR.ts index c87eed9a1..7c05f5e00 100644 --- a/packages/app/src/i18n/resources/pt-BR.ts +++ b/packages/app/src/i18n/resources/pt-BR.ts @@ -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", diff --git a/packages/app/src/i18n/resources/ru.ts b/packages/app/src/i18n/resources/ru.ts index 33d54be3d..e68c7e4e7 100644 --- a/packages/app/src/i18n/resources/ru.ts +++ b/packages/app/src/i18n/resources/ru.ts @@ -618,6 +618,7 @@ export const ru: TranslationResources = { reloadingAgent: "Перезагрузка агента...", reloadedAgent: "Перезагруженный агент", failedToReloadAgent: "Не удалось перезагрузить агент", + failedToCloseAgent: "Не удалось закрыть агент", }, confirmations: { unsavedTitle: "Несохранённые изменения", diff --git a/packages/app/src/i18n/resources/zh-CN.ts b/packages/app/src/i18n/resources/zh-CN.ts index 2283dad92..9c70d7b70 100644 --- a/packages/app/src/i18n/resources/zh-CN.ts +++ b/packages/app/src/i18n/resources/zh-CN.ts @@ -614,6 +614,7 @@ export const zhCN: TranslationResources = { reloadingAgent: "正在重新加载 Agent...", reloadedAgent: "已重新加载 Agent", failedToReloadAgent: "重新加载 Agent 失败", + failedToCloseAgent: "关闭 Agent 失败", }, confirmations: { unsavedTitle: "未保存的更改", diff --git a/packages/app/src/screens/workspace/workspace-bulk-close.test.ts b/packages/app/src/screens/workspace/workspace-bulk-close.test.ts index efa3b333b..4dfe308c1 100644 --- a/packages/app/src/screens/workspace/workspace-bulk-close.test.ts +++ b/packages/app/src/screens/workspace/workspace-bulk-close.test.ts @@ -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"]); + }); }); diff --git a/packages/app/src/screens/workspace/workspace-bulk-close.ts b/packages/app/src/screens/workspace/workspace-bulk-close.ts index fc986de41..8e9cf9dad 100644 --- a/packages/app/src/screens/workspace/workspace-bulk-close.ts +++ b/packages/app/src/screens/workspace/workspace-bulk-close.ts @@ -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) => Promise; closeWorkspaceTabWithCleanup: (input: CloseWorkspaceTabWithCleanupInput) => void; + closeLayoutOnlyAgent: (agentId: string) => Promise; 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 { - 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 }); + }), + ), + ]); } diff --git a/packages/app/src/screens/workspace/workspace-screen.tsx b/packages/app/src/screens/workspace/workspace-screen.tsx index 9b0e718b3..f10e62707 100644 --- a/packages/app/src/screens/workspace/workspace-screen.tsx +++ b/packages/app/src/screens/workspace/workspace-screen.tsx @@ -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, diff --git a/packages/app/src/subagents/open-tab-labels.test.ts b/packages/app/src/subagents/open-tab-labels.test.ts new file mode 100644 index 000000000..61874cd05 --- /dev/null +++ b/packages/app/src/subagents/open-tab-labels.test.ts @@ -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([]); + }); +}); diff --git a/packages/app/src/subagents/open-tab-labels.ts b/packages/app/src/subagents/open-tab-labels.ts new file mode 100644 index 000000000..ca7d8e63d --- /dev/null +++ b/packages/app/src/subagents/open-tab-labels.ts @@ -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[] { + const agentIds = new Set(); + 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]; +} diff --git a/packages/app/src/subagents/use-open-agent-tab-labels.ts b/packages/app/src/subagents/use-open-agent-tab-labels.ts new file mode 100644 index 000000000..ea02fd1b1 --- /dev/null +++ b/packages/app/src/subagents/use-open-agent-tab-labels.ts @@ -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()); + const retryTimerRef = useRef | 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]); +} diff --git a/packages/protocol/src/agent-labels.test.ts b/packages/protocol/src/agent-labels.test.ts index c57db3a09..1f52a652f 100644 --- a/packages/protocol/src/agent-labels.test.ts +++ b/packages/protocol/src/agent-labels.test.ts @@ -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); + }); }); diff --git a/packages/protocol/src/agent-labels.ts b/packages/protocol/src/agent-labels.ts index 3ad40022e..2bf05950d 100644 --- a/packages/protocol/src/agent-labels.ts +++ b/packages/protocol/src/agent-labels.ts @@ -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 | null; @@ -14,3 +23,9 @@ export function getParentAgentIdFromLabels(labels: Record | nul export function isDelegatedAgent(agent: AgentLabelSource): boolean { return getParentAgentIdFromLabels(agent.labels) !== null; } + +export function hasOpenAgentTab(labels: Record | null | undefined): boolean { + return Object.entries(labels ?? {}).some( + ([label, value]) => isOpenAgentTabLabel(label) && value === "true", + ); +} diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 82a8e3e3c..821828f4a 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -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 { promise: Promise; 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 { + 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(); + const releaseMarkerWrite = deferred(); + + class BlockingOpenMarkerStorage extends AgentStorage { + childId: string | null = null; + + override async applySnapshot( + agent: ManagedAgent, + options?: { title?: string | null; internal?: boolean }, + ): Promise { + 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"); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index ea775bbf7..f3db4f385 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -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): 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(); private readonly providerEnabled = new Map(); @@ -631,6 +654,7 @@ export class AgentManager { private readonly backgroundTasks = new Set>(); private readonly agentRegistrationTasks = new Set>(); private readonly inFlightAgentCloses = new Map>(); + private readonly lifecycleMutationTails = new Map>(); 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): Promise { - 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 { @@ -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; }, + ): Promise { + await this.runLifecycleMutation(agentId, () => + this.updateAgentMetadataUnlocked(agentId, updates), + ); + } + + private async updateAgentMetadataUnlocked( + agentId: string, + updates: { + title?: string; + labels?: Record; + }, ): Promise { const liveAgent = this.getAgent(agentId); if (liveAgent) { @@ -1905,6 +1975,24 @@ export class AgentManager { await this.writeStoredMetadata(agentId, updates); } + private async runLifecycleMutation(agentId: string, mutation: () => Promise): Promise { + // 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,