mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-06 08:24:46 +00:00
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:
@@ -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.
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
|
||||
+16
-20
@@ -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 {
|
||||
Reference in New Issue
Block a user