mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 08:24:46 +00:00
fix(server): retain archived parent provenance on detach
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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`);
|
||||
}
|
||||
Reference in New Issue
Block a user