diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index 45c54ca18b..75b60eed60 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -134,7 +134,11 @@ every managed subagent present in its tabs with `paseo.open-agent-tab."]` | `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. diff --git a/packages/protocol/src/agent-labels.ts b/packages/protocol/src/agent-labels.ts index e2eb008093..712898956e 100644 --- a/packages/protocol/src/agent-labels.ts +++ b/packages/protocol/src/agent-labels.ts @@ -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 { diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index 6c7396d657..ee1ff510df 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -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> = []; + 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 () => { diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index fea3eed438..34e318c5b3 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -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): AgentLabelPatch { +function detachedAgentLabelPatch( + labels: Record, + 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 }; } diff --git a/packages/server/src/server/daemon-e2e/agent-archive-detach-provenance.e2e.test.ts b/packages/server/src/server/daemon-e2e/agent-archive-detach-provenance.e2e.test.ts new file mode 100644 index 0000000000..54ee5ef977 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/agent-archive-detach-provenance.e2e.test.ts @@ -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 }> = []; + 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) => ({ + 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`); +}