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:
+18
-5
@@ -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.<client-id>=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
|
||||
|
||||
@@ -190,10 +202,11 @@ $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 |
|
||||
| `labels["paseo.open-agent-tab.<client-id>"]` | `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.
|
||||
|
||||
+1
-1
@@ -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<string, string>` | 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<string, string>` | 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) |
|
||||
|
||||
@@ -614,6 +614,7 @@ export const ar: TranslationResources = {
|
||||
reloadingAgent: "وكيل إعادة التحميل...",
|
||||
reloadedAgent: "وكيل إعادة تحميل",
|
||||
failedToReloadAgent: "فشل في إعادة تحميل الوكيل",
|
||||
failedToCloseAgent: "فشل في إغلاق الوكيل",
|
||||
},
|
||||
confirmations: {
|
||||
unsavedTitle: "تغييرات غير محفوظة",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -619,6 +619,7 @@ export const ja: TranslationResources = {
|
||||
reloadingAgent: "エージェントを再読み込み中...",
|
||||
reloadedAgent: "エージェントを再読み込みしました",
|
||||
failedToReloadAgent: "エージェントの再読み込みに失敗しました",
|
||||
failedToCloseAgent: "エージェントを閉じられませんでした",
|
||||
},
|
||||
confirmations: {
|
||||
unsavedTitle: "未保存の変更",
|
||||
|
||||
@@ -616,6 +616,7 @@ export const ko: TranslationResources = {
|
||||
reloadingAgent: "에이전트 다시 로드 중...",
|
||||
reloadedAgent: "에이전트를 다시 로드했습니다",
|
||||
failedToReloadAgent: "에이전트를 다시 로드하지 못했습니다",
|
||||
failedToCloseAgent: "에이전트를 닫지 못했습니다",
|
||||
},
|
||||
confirmations: {
|
||||
close: "닫기",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -618,6 +618,7 @@ export const ru: TranslationResources = {
|
||||
reloadingAgent: "Перезагрузка агента...",
|
||||
reloadedAgent: "Перезагруженный агент",
|
||||
failedToReloadAgent: "Не удалось перезагрузить агент",
|
||||
failedToCloseAgent: "Не удалось закрыть агент",
|
||||
},
|
||||
confirmations: {
|
||||
unsavedTitle: "Несохранённые изменения",
|
||||
|
||||
@@ -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 () => {
|
||||
await Promise.all([
|
||||
...groups.archiveAgentTabs.map(({ tabId, agentId }) =>
|
||||
closeTab(tabId, async () => {
|
||||
closeWorkspaceTabWithCleanup({
|
||||
tabId,
|
||||
target: { kind: "agent", agentId },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
for (const { tabId, terminalId } of groups.terminalTabs) {
|
||||
void closeTab(tabId, async () => {
|
||||
}),
|
||||
),
|
||||
...groups.terminalTabs.map(({ tabId, terminalId }) =>
|
||||
closeTab(tabId, async () => {
|
||||
closeWorkspaceTabWithCleanup({
|
||||
tabId,
|
||||
target: { kind: "terminal", terminalId },
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
for (const { tabId, target } of groups.otherTabs) {
|
||||
void closeTab(tabId, async () => {
|
||||
}),
|
||||
),
|
||||
...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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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> {
|
||||
await this.runLifecycleMutation(agentId, async () => {
|
||||
const agent = this.requireAgent(agentId);
|
||||
await this.writeLabels(agent.id, labels);
|
||||
});
|
||||
}
|
||||
|
||||
private async writeLabels(agentId: string, patch: AgentLabelPatch): Promise<WriteLabelsResult> {
|
||||
@@ -1754,6 +1804,14 @@ export class AgentManager {
|
||||
record: StoredAgentRecord;
|
||||
live: boolean;
|
||||
previousParentAgentId: string | null;
|
||||
}> {
|
||||
return this.runLifecycleMutation(agentId, () => this.detachAgentUnlocked(agentId));
|
||||
}
|
||||
|
||||
private async detachAgentUnlocked(agentId: string): Promise<{
|
||||
record: StoredAgentRecord;
|
||||
live: boolean;
|
||||
previousParentAgentId: string | null;
|
||||
}> {
|
||||
const registry = this.requireRegistry();
|
||||
const liveAgent = this.agents.get(agentId);
|
||||
@@ -1768,7 +1826,7 @@ export class AgentManager {
|
||||
return { record, live: true, previousParentAgentId: null };
|
||||
}
|
||||
|
||||
const { record } = await this.writeLabels(agentId, { [PARENT_AGENT_ID_LABEL]: null });
|
||||
const { record } = await this.writeLabels(agentId, detachedAgentLabelPatch(liveAgent.labels));
|
||||
if (!record) {
|
||||
throw new Error(`Agent not found in storage after detach: ${agentId}`);
|
||||
}
|
||||
@@ -1784,7 +1842,7 @@ export class AgentManager {
|
||||
return { record, live: false, previousParentAgentId: null };
|
||||
}
|
||||
|
||||
const result = await this.writeLabels(agentId, { [PARENT_AGENT_ID_LABEL]: null });
|
||||
const result = await this.writeLabels(agentId, detachedAgentLabelPatch(record.labels));
|
||||
if (!result.record) {
|
||||
throw new Error(`Agent not found in storage after detach: ${agentId}`);
|
||||
}
|
||||
@@ -1890,6 +1948,18 @@ export class AgentManager {
|
||||
title?: string;
|
||||
labels?: Record<string, string>;
|
||||
},
|
||||
): Promise<void> {
|
||||
await this.runLifecycleMutation(agentId, () =>
|
||||
this.updateAgentMetadataUnlocked(agentId, updates),
|
||||
);
|
||||
}
|
||||
|
||||
private async updateAgentMetadataUnlocked(
|
||||
agentId: string,
|
||||
updates: {
|
||||
title?: string;
|
||||
labels?: Record<string, string>;
|
||||
},
|
||||
): Promise<void> {
|
||||
const liveAgent = this.getAgent(agentId);
|
||||
if (liveAgent) {
|
||||
@@ -1905,6 +1975,24 @@ export class AgentManager {
|
||||
await this.writeStoredMetadata(agentId, updates);
|
||||
}
|
||||
|
||||
private async runLifecycleMutation<T>(agentId: string, mutation: () => Promise<T>): Promise<T> {
|
||||
// Parent cascade classifies a child inside the same lane used by open-tab
|
||||
// label writes, so a received ownership update cannot be overtaken.
|
||||
const previous = this.lifecycleMutationTails.get(agentId) ?? Promise.resolve();
|
||||
const result = previous.catch(() => undefined).then(mutation);
|
||||
const tail = result.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
this.lifecycleMutationTails.set(agentId, tail);
|
||||
void tail.finally(() => {
|
||||
if (this.lifecycleMutationTails.get(agentId) === tail) {
|
||||
this.lifecycleMutationTails.delete(agentId);
|
||||
}
|
||||
});
|
||||
return result;
|
||||
}
|
||||
|
||||
async runAgent(
|
||||
agentId: string,
|
||||
prompt: AgentPromptInput,
|
||||
|
||||
Reference in New Issue
Block a user