fix(server): publish stored child detach updates on parent archive

Keep subscribed clients current when an unloaded child survives its parent archive. Narrow the contributor change to the missing update; archived-parent provenance remains a separate feature.

Co-authored-by: luke <luopeihuan@gmail.com>
This commit is contained in:
paseo-bot[bot]
2026-11-06 04:23:10 +00:00
co-authored by luke
parent 1a1f8e21e2
commit e610834254
5 changed files with 84 additions and 119 deletions
+1 -6
View File
@@ -134,11 +134,7 @@ every managed subagent present in its tabs with `paseo.open-agent-tab.<client-id
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. A detach caused by
parent archive also sets `paseo.archived-parent-agent-id` to the archived parent so boards and
plugins can still show where the survivor came from. That label is provenance only: no subagent
track, archive cascade, tab-close policy or finish notification reads it. An explicit detach does
not set it. Agents detached before this label existed have no record of their former parent.
normal root agent immediately, and closing its still-open tab archives it.
Agent-scoped workspace creation records the caller's workspace as `parentWorkspaceId`.
This is informational ancestry for future workspace grouping. It does not change placement,
@@ -412,7 +408,6 @@ Each agent is a single JSON file. Fields relevant to this doc:
| `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 |
| `labels["paseo.archived-parent-agent-id"]` | `string?` | Former parent whose archive detached this agent; provenance only |
| `lastStatus` | `AgentStatus` | `initializing` / `idle` / `running` / `error` / `closed` |
See [`docs/data-model.md`](./data-model.md) for the full agent record.
-4
View File
@@ -1,10 +1,6 @@
import type { AgentAddress } from "./addresses.js";
export const PARENT_SERVER_ID_LABEL = "paseo.parent-server-id";
export const PARENT_AGENT_ID_LABEL = "paseo.parent-agent-id";
// Provenance only: the parent whose archive detached this agent. Unlike
// PARENT_AGENT_ID_LABEL it carries no lifecycle meaning (no subagent track,
// no archive cascade, no tab-close policy).
export const ARCHIVED_PARENT_AGENT_ID_LABEL = "paseo.archived-parent-agent-id";
const OPEN_AGENT_TAB_LABEL_PREFIX = "paseo.open-agent-tab.";
export function getOpenAgentTabLabel(clientId: string): string {
@@ -6,7 +6,7 @@ import {
} from "./agent-messages/index.js";
import { expect, test, vi, onTestFinished } from "vitest";
import { spawn } from "node:child_process";
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { randomUUID } from "node:crypto";
@@ -24,11 +24,7 @@ import { InMemoryAgentTimelineStore } from "./agent-timeline-store.js";
import { limitAgentTimelineItemContent } from "./agent-timeline-content.js";
import { toAgentPayload } from "./agent-projections.js";
import { projectTimelineRows } from "./timeline-projection.js";
import {
ARCHIVED_PARENT_AGENT_ID_LABEL,
getOpenAgentTabLabel,
PARENT_AGENT_ID_LABEL,
} from "@getpaseo/protocol/agent-labels";
import { getOpenAgentTabLabel, PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import { startAgentRun } from "./agent-prompt.js";
import { StaleProviderSessionError } from "./stale-provider-session-error.js";
import { ensureAgentLoaded, ensureUnarchivedAgentLoaded } from "./agent-loading.js";
@@ -9434,9 +9430,7 @@ test("archiveAgent detaches an open same-workspace child instead of cascading",
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(storedChild?.labels[ARCHIVED_PARENT_AGENT_ID_LABEL]).toBe(parent.id);
expect(manager.getAgent(child.id)?.id).toBe(child.id);
expect(manager.getAgent(child.id)?.labels[ARCHIVED_PARENT_AGENT_ID_LABEL]).toBe(parent.id);
});
test("archiveAgent detaches a cross-workspace child even when its tab is closed", async () => {
@@ -9470,61 +9464,69 @@ test("archiveAgent detaches a cross-workspace child even when its tab is closed"
expect(storedChild?.archivedAt).toBeUndefined();
expect(storedChild?.workspaceId).toBe("workspace-b");
expect(storedChild?.labels[PARENT_AGENT_ID_LABEL]).toBeUndefined();
expect(storedChild?.labels[ARCHIVED_PARENT_AGENT_ID_LABEL]).toBe(parent.id);
});
test("archiveAgent records the archived parent on a stored-only cross-workspace child", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-cascade-stored-cross-workspace-"));
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: "Closed cross-workspace child" },
undefined,
{
workspaceId: "workspace-b",
labels: {
[PARENT_AGENT_ID_LABEL]: parent.id,
[DESKTOP_OPEN_AGENT_TAB_LABEL]: "true",
team: "ios",
test.each([
{ workspaceId: "workspace-b", open: "false" },
{ workspaceId: "workspace-a", open: "true" },
])(
"archiveAgent publishes stored survivor detach updates ($workspaceId, open=$open)",
async ({ workspaceId, open }) => {
const artifacts = join(process.cwd(), ".artifacts");
mkdirSync(artifacts, { recursive: true });
const workdir = mkdtempSync(join(artifacts, "stored-child-detach-"));
onTestFinished(() => rmSync(workdir, { recursive: true, force: true }));
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: "Closed cross-workspace child" },
undefined,
{
workspaceId,
labels: {
[PARENT_AGENT_ID_LABEL]: parent.id,
[DESKTOP_OPEN_AGENT_TAB_LABEL]: open,
team: "ios",
},
},
},
);
await manager.closeAgent(child.id);
// Unfiltered, as a session subscribes: a closed survivor's update must reach every client.
const emittedLabels: Array<Record<string, string>> = [];
const unsubscribe = manager.subscribe(
(event) => {
if (event.type === "agent_state" && event.agent.id === child.id) {
emittedLabels.push(event.agent.labels);
}
},
{ replayState: false },
);
);
await manager.closeAgent(child.id);
// Unfiltered, as a session subscribes: a closed survivor's update must reach every client.
const emittedLabels: Array<Record<string, string>> = [];
const unsubscribe = manager.subscribe(
(event) => {
if (event.type === "agent_state" && event.agent.id === child.id) {
emittedLabels.push(event.agent.labels);
}
},
{ replayState: false },
);
await manager.archiveAgent(parent.id);
await manager.flush();
unsubscribe();
await manager.archiveAgent(parent.id);
await manager.flush();
unsubscribe();
const expectedLabels = { team: "ios", [ARCHIVED_PARENT_AGENT_ID_LABEL]: parent.id };
const storedChild = await storage.get(child.id);
expect(storedChild?.archivedAt).toBeUndefined();
expect(storedChild?.labels).toEqual(expectedLabels);
expect(emittedLabels).toEqual([expectedLabels]);
const expectedLabels = { team: "ios" };
const storedChild = await storage.get(child.id);
expect(storedChild?.archivedAt).toBeUndefined();
expect(storedChild?.labels).toEqual(expectedLabels);
expect(emittedLabels).toEqual([expectedLabels]);
// The provenance label is not parentage: an explicit detach has nothing to sever.
const detached = await manager.detachAgent(child.id);
expect(detached.previousParentAgentId).toBeNull();
expect(detached.record.labels).toEqual(expectedLabels);
});
// The child is already detached; repeating detach has nothing to sever.
const detached = await manager.detachAgent(child.id);
expect(detached.previousParentAgentId).toBeNull();
expect(detached.record.labels).toEqual(expectedLabels);
},
);
test("archiveAgent re-reads a child before deciding whether to cascade", async () => {
const workdir = mkdtempSync(join(tmpdir(), "agent-manager-cascade-fresh-child-"));
@@ -14,7 +14,6 @@ import {
type AgentLifecycleStatus,
} from "@getpaseo/protocol/agent-lifecycle";
import {
ARCHIVED_PARENT_AGENT_ID_LABEL,
getParentAgentIdFromLabels,
hasOpenAgentTab,
isDelegatedAgent,
@@ -760,14 +759,8 @@ function shouldDetachFromArchivedParent(
return isCrossWorkspace || hasOpenAgentTab(child.labels);
}
function detachedAgentLabelPatch(
labels: Record<string, string>,
archivedParentAgentId: string | null,
): AgentLabelPatch {
function detachedAgentLabelPatch(labels: Record<string, string>): AgentLabelPatch {
const patch: AgentLabelPatch = { [PARENT_AGENT_ID_LABEL]: null };
if (archivedParentAgentId) {
patch[ARCHIVED_PARENT_AGENT_ID_LABEL] = archivedParentAgentId;
}
for (const label of Object.keys(labels)) {
if (isOpenAgentTabLabel(label)) {
patch[label] = null;
@@ -2119,7 +2112,10 @@ export class AgentManager {
return;
}
if (shouldDetachFromArchivedParent(parent, currentChild)) {
await this.detachAgentUnlocked(currentChild.id, { parentArchived: true });
const detached = await this.detachAgentUnlocked(currentChild.id);
if (!detached.live) {
this.dispatchStoredAgentState(detached.record);
}
} else if (this.agents.has(currentChild.id)) {
await this.archiveAgentUnlocked(currentChild.id);
} else {
@@ -2386,12 +2382,7 @@ export class AgentManager {
return this.runLifecycleMutation(agentId, () => this.detachAgentUnlocked(agentId));
}
// An explicit detach severs the relationship. A detach caused by the parent's
// archive keeps a provenance-only record of the parent on the survivor.
private async detachAgentUnlocked(
agentId: string,
options: { parentArchived?: boolean } = {},
): Promise<{
private async detachAgentUnlocked(agentId: string): Promise<{
record: StoredAgentRecord;
live: boolean;
previousParentAgentId: string | null;
@@ -2409,13 +2400,7 @@ export class AgentManager {
return { record, live: true, previousParentAgentId: null };
}
const { record } = await this.writeLabels(
agentId,
detachedAgentLabelPatch(
liveAgent.labels,
options.parentArchived ? previousParentAgentId : null,
),
);
const { record } = await this.writeLabels(agentId, detachedAgentLabelPatch(liveAgent.labels));
if (!record) {
throw new Error(`Agent not found in storage after detach: ${agentId}`);
}
@@ -2431,19 +2416,10 @@ export class AgentManager {
return { record, live: false, previousParentAgentId: null };
}
const result = await this.writeLabels(
agentId,
detachedAgentLabelPatch(record.labels, options.parentArchived ? previousParentAgentId : null),
);
const result = await this.writeLabels(agentId, detachedAgentLabelPatch(record.labels));
if (!result.record) {
throw new Error(`Agent not found in storage after detach: ${agentId}`);
}
// No session answers a parent-archive detach, so every subscriber learns of
// a closed survivor here, as a stored-only archive does in markRecordArchived.
// An explicit detach is emitted by the session that requested it.
if (options.parentArchived) {
this.dispatchStoredAgentState(result.record);
}
return { record: result.record, live: false, previousParentAgentId };
}
@@ -1,12 +1,8 @@
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import os from "node:os";
import path from "node:path";
import { expect, test } from "vitest";
import type { SessionOutboundMessage } from "@getpaseo/protocol/messages";
import {
ARCHIVED_PARENT_AGENT_ID_LABEL,
PARENT_AGENT_ID_LABEL,
} from "@getpaseo/protocol/agent-labels";
import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js";
@@ -14,7 +10,7 @@ import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo
const CREATED_AT = "2026-11-04T08:00:00.000Z";
const UPDATED_AT = "2026-11-04T09:00:00.000Z";
test("parent archive records itself on a closed survivor and tells every client; an explicit detach records nothing", async () => {
test("parent archive publishes the detached stored survivor to subscribed clients", async () => {
const fixture = seedFixture();
let daemon: TestPaseoDaemon | null = null;
let observer: DaemonClient | null = null;
@@ -35,24 +31,19 @@ test("parent archive records itself on a closed survivor and tells every client;
});
await observer.fetchAgents({ filter: { includeBackground: true }, subscribe: {} });
// Detached by hand while the parent is live: no provenance, now or after the archive.
// A child already detached by hand remains independent of the parent archive.
await actor.detachAgent("child-manual");
await actor.archiveAgent("parent");
await expect
.poll(() => updates.findLast((update) => update.id === "child-survivor")?.labels)
.toEqual({ [ARCHIVED_PARENT_AGENT_ID_LABEL]: "parent" });
const survivor = await actor.fetchAgent({ agentId: "child-survivor" });
expect(survivor?.agent.archivedAt).toBeNull();
expect(survivor?.agent.labels).toEqual({ [ARCHIVED_PARENT_AGENT_ID_LABEL]: "parent" });
expect(survivor?.agent.labels).toEqual({});
await expect
.poll(() => updates.findLast((update) => update.id === "child-survivor")?.labels)
.toEqual({});
const manual = await actor.fetchAgent({ agentId: "child-manual" });
expect(manual?.agent.archivedAt).toBeNull();
expect(manual?.agent.labels).toEqual({});
expect(
updates.filter(
(update) => update.id === "child-manual" && update.labels[ARCHIVED_PARENT_AGENT_ID_LABEL],
),
).toEqual([]);
} finally {
await observer?.close().catch(() => undefined);
await actor?.close().catch(() => undefined);
@@ -62,9 +53,14 @@ test("parent archive records itself on a closed survivor and tells every client;
});
function seedFixture() {
const parentCwd = mkdtempSync(path.join(os.tmpdir(), "paseo-provenance-parent-"));
const childCwd = mkdtempSync(path.join(os.tmpdir(), "paseo-provenance-child-"));
const paseoHomeRoot = mkdtempSync(path.join(os.tmpdir(), "paseo-provenance-home-"));
const artifacts = path.join(process.cwd(), ".artifacts");
mkdirSync(artifacts, { recursive: true });
const root = mkdtempSync(path.join(artifacts, "archive-detach-"));
const parentCwd = path.join(root, "parent");
const childCwd = path.join(root, "child");
const paseoHomeRoot = path.join(root, "home");
mkdirSync(parentCwd);
mkdirSync(childCwd);
const paseoHome = path.join(paseoHomeRoot, ".paseo");
const projectsDir = path.join(paseoHome, "projects");
const agentsDir = path.join(paseoHome, "agents");
@@ -127,7 +123,7 @@ function seedFixture() {
path.join(agentsDir, "child-manual.json"),
agent("child-manual", childCwd, "ws-child", { [PARENT_AGENT_ID_LABEL]: "parent" }),
);
return { paseoHomeRoot, cleanupPaths: [paseoHomeRoot, parentCwd, childCwd] };
return { paseoHomeRoot, cleanupPaths: [root] };
}
function writeJson(filePath: string, value: unknown): void {