fix(server): retain archived parent provenance on detach

This commit is contained in:
luke
2026-11-06 04:23:10 +00:00
committed by paseo-bot[bot]
parent 5abfdf7107
commit 1a1f8e21e2
5 changed files with 238 additions and 7 deletions
+6 -1
View File
@@ -134,7 +134,11 @@ 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.
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.
Agent-scoped workspace creation records the caller's workspace as `parentWorkspaceId`.
This is informational ancestry for future workspace grouping. It does not change placement,
@@ -408,6 +412,7 @@ 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,6 +1,10 @@
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 {
@@ -24,7 +24,11 @@ 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 { getOpenAgentTabLabel, PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels";
import {
ARCHIVED_PARENT_AGENT_ID_LABEL,
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";
@@ -9430,7 +9434,9 @@ 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 () => {
@@ -9464,6 +9470,60 @@ 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",
},
},
);
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();
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]);
// 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);
});
test("archiveAgent re-reads a child before deciding whether to cascade", async () => {
@@ -14,6 +14,7 @@ import {
type AgentLifecycleStatus,
} from "@getpaseo/protocol/agent-lifecycle";
import {
ARCHIVED_PARENT_AGENT_ID_LABEL,
getParentAgentIdFromLabels,
hasOpenAgentTab,
isDelegatedAgent,
@@ -759,8 +760,14 @@ function shouldDetachFromArchivedParent(
return isCrossWorkspace || hasOpenAgentTab(child.labels);
}
function detachedAgentLabelPatch(labels: Record<string, string>): AgentLabelPatch {
function detachedAgentLabelPatch(
labels: Record<string, string>,
archivedParentAgentId: string | null,
): 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;
@@ -2112,7 +2119,7 @@ export class AgentManager {
return;
}
if (shouldDetachFromArchivedParent(parent, currentChild)) {
await this.detachAgentUnlocked(currentChild.id);
await this.detachAgentUnlocked(currentChild.id, { parentArchived: true });
} else if (this.agents.has(currentChild.id)) {
await this.archiveAgentUnlocked(currentChild.id);
} else {
@@ -2379,7 +2386,12 @@ export class AgentManager {
return this.runLifecycleMutation(agentId, () => this.detachAgentUnlocked(agentId));
}
private async detachAgentUnlocked(agentId: string): Promise<{
// 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<{
record: StoredAgentRecord;
live: boolean;
previousParentAgentId: string | null;
@@ -2397,7 +2409,13 @@ export class AgentManager {
return { record, live: true, previousParentAgentId: null };
}
const { record } = await this.writeLabels(agentId, detachedAgentLabelPatch(liveAgent.labels));
const { record } = await this.writeLabels(
agentId,
detachedAgentLabelPatch(
liveAgent.labels,
options.parentArchived ? previousParentAgentId : null,
),
);
if (!record) {
throw new Error(`Agent not found in storage after detach: ${agentId}`);
}
@@ -2413,10 +2431,19 @@ export class AgentManager {
return { record, live: false, previousParentAgentId: null };
}
const result = await this.writeLabels(agentId, detachedAgentLabelPatch(record.labels));
const result = await this.writeLabels(
agentId,
detachedAgentLabelPatch(record.labels, options.parentArchived ? previousParentAgentId : null),
);
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 };
}
@@ -0,0 +1,135 @@
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 { DaemonClient } from "../test-utils/daemon-client.js";
import { createTestPaseoDaemon, type TestPaseoDaemon } from "../test-utils/paseo-daemon.js";
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 () => {
const fixture = seedFixture();
let daemon: TestPaseoDaemon | null = null;
let observer: DaemonClient | null = null;
let actor: DaemonClient | null = null;
try {
daemon = await createTestPaseoDaemon({ paseoHomeRoot: fixture.paseoHomeRoot, cleanup: false });
const url = `ws://127.0.0.1:${daemon.port}/ws`;
observer = new DaemonClient({ url });
actor = new DaemonClient({ url });
await observer.connect();
await actor.connect();
const updates: Array<{ id: string; labels: Record<string, string> }> = [];
observer.subscribeRawMessages((message: SessionOutboundMessage) => {
if (message.type === "agent_update" && message.payload.kind === "upsert") {
updates.push({ id: message.payload.agent.id, labels: message.payload.agent.labels });
}
});
await observer.fetchAgents({ filter: { includeBackground: true }, subscribe: {} });
// Detached by hand while the parent is live: no provenance, now or after the 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" });
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);
await daemon?.close().catch(() => undefined);
for (const target of fixture.cleanupPaths) rmSync(target, { recursive: true, force: true });
}
});
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 paseoHome = path.join(paseoHomeRoot, ".paseo");
const projectsDir = path.join(paseoHome, "projects");
const agentsDir = path.join(paseoHome, "agents");
mkdirSync(projectsDir, { recursive: true });
mkdirSync(agentsDir, { recursive: true });
const project = (projectId: string, rootPath: string) => ({
projectId,
rootPath,
kind: "non_git",
displayName: projectId,
customName: null,
createdAt: CREATED_AT,
updatedAt: UPDATED_AT,
archivedAt: null,
});
const workspace = (workspaceId: string, projectId: string, cwd: string) => ({
workspaceId,
projectId,
cwd,
kind: "directory",
displayName: workspaceId,
title: null,
branch: null,
baseBranch: null,
createdAt: CREATED_AT,
updatedAt: UPDATED_AT,
archivedAt: null,
});
writeJson(path.join(projectsDir, "projects.json"), [
project("proj-parent", parentCwd),
project("proj-child", childCwd),
]);
writeJson(path.join(projectsDir, "workspaces.json"), [
workspace("ws-parent", "proj-parent", parentCwd),
workspace("ws-child", "proj-child", childCwd),
]);
const agent = (id: string, cwd: string, workspaceId: string, labels: Record<string, string>) => ({
id,
provider: "codex",
cwd,
workspaceId,
createdAt: CREATED_AT,
updatedAt: UPDATED_AT,
lastActivityAt: UPDATED_AT,
lastUserMessageAt: null,
title: id,
labels,
lastStatus: "closed",
lastModeId: "full-access",
config: null,
persistence: null,
});
// Both children live in another workspace, so the parent's archive detaches rather than archives them.
writeJson(path.join(agentsDir, "parent.json"), agent("parent", parentCwd, "ws-parent", {}));
writeJson(
path.join(agentsDir, "child-survivor.json"),
agent("child-survivor", childCwd, "ws-child", { [PARENT_AGENT_ID_LABEL]: "parent" }),
);
writeJson(
path.join(agentsDir, "child-manual.json"),
agent("child-manual", childCwd, "ws-child", { [PARENT_AGENT_ID_LABEL]: "parent" }),
);
return { paseoHomeRoot, cleanupPaths: [paseoHomeRoot, parentCwd, childCwd] };
}
function writeJson(filePath: string, value: unknown): void {
writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`);
}