diff --git a/docs/agent-lifecycle.md b/docs/agent-lifecycle.md index 75b60eed60..45c54ca18b 100644 --- a/docs/agent-lifecycle.md +++ b/docs/agent-lifecycle.md @@ -134,11 +134,7 @@ 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 712898956e..e2eb008093 100644 --- a/packages/protocol/src/agent-labels.ts +++ b/packages/protocol/src/agent-labels.ts @@ -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 { diff --git a/packages/server/src/server/agent/agent-manager.test.ts b/packages/server/src/server/agent/agent-manager.test.ts index ee1ff510df..372074c659 100644 --- a/packages/server/src/server/agent/agent-manager.test.ts +++ b/packages/server/src/server/agent/agent-manager.test.ts @@ -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> = []; - 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> = []; + 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-")); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 34e318c5b3..7d733febe9 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -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, - archivedParentAgentId: string | null, -): AgentLabelPatch { +function detachedAgentLabelPatch(labels: Record): 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 }; } 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-updates.e2e.test.ts similarity index 81% rename from packages/server/src/server/daemon-e2e/agent-archive-detach-provenance.e2e.test.ts rename to packages/server/src/server/daemon-e2e/agent-archive-detach-updates.e2e.test.ts index 54ee5ef977..f0917a887c 100644 --- a/packages/server/src/server/daemon-e2e/agent-archive-detach-provenance.e2e.test.ts +++ b/packages/server/src/server/daemon-e2e/agent-archive-detach-updates.e2e.test.ts @@ -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 {