From 064243288b526473f5b0206008795583709af91f Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Wed, 4 Nov 2026 01:12:53 +0000 Subject: [PATCH] Recognize persisted parents when creating child sessions (#8714) * fix(server): recognize persisted parents during child creation Creation only needs the caller's identity and workspace. Read the existing stored record when the parent is unloaded after restart, rather than requiring its provider session to be opened first. * test(server): run persisted caller regressions in integration CI * test(server): retry fixture removal while Windows releases handles * fix(server): keep archived parents ineligible for child creation --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- packages/server/package.json | 2 +- .../persisted-creation-caller.e2e.test.ts | 92 +++++++++++++++++++ packages/server/src/server/session.ts | 44 +++++---- 3 files changed, 118 insertions(+), 20 deletions(-) create mode 100644 packages/server/src/server/daemon-e2e/persisted-creation-caller.e2e.test.ts diff --git a/packages/server/package.json b/packages/server/package.json index 2f527307ae..bba90e58cd 100644 --- a/packages/server/package.json +++ b/packages/server/package.json @@ -90,7 +90,7 @@ "speech:transcribe:local": "tsx scripts/transcribe-local-wav.ts", "test": "npm run test:unit && npm run test:integration", "test:unit": "vitest run --fileParallelism --exclude \"**/*.e2e.test.ts\"", - "test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/create-agent-mode.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/creation-status.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/plugins/usage-sources.e2e.test.ts src/server/plugins/lifecycle.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts src/server/owned-subscriptions.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"", + "test:integration": "vitest run --maxWorkers=1 src/server/git-filename-diffs.e2e.test.ts src/server/daemon-e2e/checkout-diff-content.e2e.test.ts src/server/daemon-e2e/create-agent-mode.e2e.test.ts src/server/daemon-e2e/filesystem.e2e.test.ts src/server/daemon-e2e/file-download.e2e.test.ts src/server/daemon-e2e/cli-logs-follow.e2e.test.ts src/server/creation/creation.e2e.test.ts src/server/daemon-e2e/creation-status.e2e.test.ts src/server/daemon-e2e/persisted-creation-caller.e2e.test.ts src/server/daemon-e2e/terminal-workspace-sdk.e2e.test.ts src/server/daemon-e2e/terminal-worker-lifecycle.e2e.test.ts src/server/workspace-create-worktree-source.e2e.test.ts src/server/daemon-e2e/models.e2e.test.ts src/server/daemon-e2e/live-preferences.e2e.test.ts src/server/daemon-e2e/oversized-message.e2e.test.ts src/server/agent/model-catalog.e2e.test.ts src/server/plugins/plugin-paseo-api.e2e.test.ts src/server/plugins/usage-sources.e2e.test.ts src/server/plugins/lifecycle.e2e.test.ts src/server/connected-hosts/hosts.e2e.test.ts src/server/owned-subscriptions.e2e.test.ts && vitest run --maxWorkers=1 src/server/agent/providers/omp/history.e2e.test.ts src/server/daemon-e2e/git-operations.e2e.test.ts src/server/session.create-agent-worktree-autoarchive.e2e.test.ts src/server/agent/providers/opencode-bridge.local.e2e.test.ts src/server/daemon-e2e/relay-transport.e2e.test.ts -t \"merges into|creates a worktree and auto-archives both|versioned runtime|oversized attachments|connected hosts|password-protected daemon refuses|replays selected OMP history\"", "test:hub-cli-contract": "vitest run --maxWorkers=1 src/server/hub/hub-cli-contract.test.ts", "test:integration:all": "npm run test:e2e", "test:integration:real": "vitest run real.e2e.test.ts", diff --git a/packages/server/src/server/daemon-e2e/persisted-creation-caller.e2e.test.ts b/packages/server/src/server/daemon-e2e/persisted-creation-caller.e2e.test.ts new file mode 100644 index 0000000000..f79abd0c05 --- /dev/null +++ b/packages/server/src/server/daemon-e2e/persisted-creation-caller.e2e.test.ts @@ -0,0 +1,92 @@ +import { execFileSync } from "node:child_process"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; +import path from "node:path"; +import { expect, test } from "vitest"; +import { PARENT_AGENT_ID_LABEL } from "@getpaseo/protocol/agent-labels"; +import { createDaemonTestContext, type DaemonTestContext } from "../test-utils/index.js"; + +test.each(["agent", "workspace"] as const)( + "%s creation recognizes an unopened persisted caller after restart", + async (kind) => { + const artifacts = path.resolve(".artifacts"); + await mkdir(artifacts, { recursive: true }); + const root = await mkdtemp(path.join(artifacts, "persisted-caller-")); + const cwd = path.join(root, "project"); + await mkdir(cwd); + let ctx: DaemonTestContext | undefined; + try { + ctx = await createDaemonTestContext({ + paseoHomeRoot: root, + staticDir: cwd, + cleanup: false, + mcpEnabled: false, + }); + const parent = await ctx.client.createAgent({ provider: "claude", cwd, background: true }); + await ctx.client.sendMessage(parent.id, "Reply DONE."); + expect((await ctx.client.waitForFinish(parent.id, 5_000)).status).toBe("idle"); + await ctx.cleanup(); + ctx = undefined; + ctx = await createDaemonTestContext({ + paseoHomeRoot: root, + staticDir: cwd, + cleanup: false, + mcpEnabled: false, + }); + expect(ctx.daemon.daemon.agentManager.getAgent(parent.id)).toBeNull(); + expect( + (await ctx.client.fetchAgents({ filter: { includeBackground: true } })).entries, + ).toContainEqual( + expect.objectContaining({ agent: expect.objectContaining({ id: parent.id }) }), + ); + + if (kind === "agent") { + const child = await ctx.client.createAgent({ + provider: "claude", + cwd, + callerAgentId: parent.id, + }); + expect(child.workspaceId).toBe(parent.workspaceId); + expect(child.labels[PARENT_AGENT_ID_LABEL]).toBe(parent.id); + } else { + const result = await ctx.client.createWorkspace({ + source: { kind: "directory", path: cwd }, + callerAgentId: parent.id, + }); + expect(result.error).toBeNull(); + expect(result.workspace?.background).toBe(true); + } + expect(ctx.daemon.daemon.agentManager.getAgent(parent.id)).toBeNull(); + } finally { + await ctx?.cleanup(); + await rm(root, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); + } + }, + 60_000, +); + +test("creation still rejects a caller removed by workspace archive", async () => { + const artifacts = path.resolve(".artifacts"); + await mkdir(artifacts, { recursive: true }); + const root = await mkdtemp(path.join(artifacts, "archived-caller-")); + const cwd = path.join(root, "project"); + await mkdir(cwd); + execFileSync("git", ["init", cwd], { stdio: "pipe" }); + const ctx = await createDaemonTestContext({ + paseoHomeRoot: root, + staticDir: cwd, + cleanup: false, + mcpEnabled: false, + }); + try { + const parent = await ctx.client.createAgent({ provider: "claude", cwd }); + const archived = await ctx.client.archiveWorkspace(parent.workspaceId); + expect(archived.error).toBeNull(); + expect(ctx.daemon.daemon.agentManager.getAgent(parent.id)).toBeNull(); + await expect( + ctx.client.createAgent({ provider: "claude", cwd, callerAgentId: parent.id }), + ).rejects.toThrow(`Caller agent ${parent.id} not found`); + } finally { + await ctx.cleanup(); + await rm(root, { recursive: true, force: true, maxRetries: 3, retryDelay: 100 }); + } +}, 60_000); diff --git a/packages/server/src/server/session.ts b/packages/server/src/server/session.ts index 22dd0ae35c..0854ea7edd 100644 --- a/packages/server/src/server/session.ts +++ b/packages/server/src/server/session.ts @@ -124,6 +124,7 @@ import { inheritWorkspaceBackground, resolveCreateAgentIntent, type CreateAgentIntent, + type CreateAgentCaller, } from "./agent/create-agent/intent.js"; import { archiveAgentCommand, @@ -4583,7 +4584,7 @@ export class Session { cwd: config.cwd, target: worktree, firstAgentContext, - ...this.workspaceBackgroundIntent(msg.background, msg.callerAgentId), + ...(await this.workspaceBackgroundIntent(msg.background, msg.callerAgentId)), }); createdWorktreeForCleanup = createdWorktree; const resolvedIntent = await this.resolveSessionCreateAgentIntent({ @@ -4669,20 +4670,13 @@ export class Session { workspacePromptTitle: string | null; }): Promise { const { request, createdWorktree } = input; - const callerAgent = request.callerAgentId - ? this.agentManager.getAgent(request.callerAgentId) - : null; - if (request.callerAgentId && !callerAgent) { - throw new Error(`Caller agent ${request.callerAgentId} not found`); - } + const callerAgent = await this.resolveCreationCaller(request.callerAgentId); let config = request.config; const intent = await resolveCreateAgentIntent({ explicitWorkspaceId: createdWorktree?.workspace.workspaceId ?? request.workspaceId, - caller: callerAgent - ? { id: callerAgent.id, cwd: callerAgent.cwd, workspaceId: callerAgent.workspaceId } - : null, + caller: callerAgent, labels: request.labels, resolveWorkspace: async (workspaceId) => { if (createdWorktree?.workspace.workspaceId === workspaceId) { @@ -6981,18 +6975,30 @@ export class Session { } } - private getCallerAgent(callerAgentId?: string): ManagedAgent | undefined { - if (!callerAgentId) return undefined; - const caller = this.agentManager.getAgent(callerAgentId); + private async resolveCreationCaller( + callerAgentId?: string, + ): Promise<(CreateAgentCaller & { background?: boolean }) | null> { + if (!callerAgentId) return null; + let caller: (CreateAgentCaller & { background?: boolean }) | null = + this.agentManager.getAgent(callerAgentId); + if (!caller) { + const stored = await this.agentStorage.get(callerAgentId); + if (stored && !stored.archivedAt) caller = stored; + } if (!caller) throw new Error(`Caller agent ${callerAgentId} not found`); - return caller; + return { + id: caller.id, + cwd: caller.cwd, + workspaceId: caller.workspaceId, + background: caller.background, + }; } - private workspaceBackgroundIntent( + private async workspaceBackgroundIntent( background: boolean | undefined, callerAgentId: string | undefined, - ): { background?: boolean; callerWorkspaceId?: string } { - const caller = this.getCallerAgent(callerAgentId); + ): Promise<{ background?: boolean; callerWorkspaceId?: string }> { + const caller = await this.resolveCreationCaller(callerAgentId); return { background: inheritWorkspaceBackground(background, caller), callerWorkspaceId: caller?.workspaceId, @@ -7045,7 +7051,7 @@ export class Session { { expectsInitialAgent: Boolean(request.firstAgentContext), workspaceId, - ...this.workspaceBackgroundIntent(request.background, request.callerAgentId), + ...(await this.workspaceBackgroundIntent(request.background, request.callerAgentId)), }, ); await this.syncWorkspaceGitObserverForWorkspace(workspace); @@ -7108,7 +7114,7 @@ export class Session { githubPrNumber: source.githubPrNumber, firstAgentContext: request.firstAgentContext, title: request.title, - ...this.workspaceBackgroundIntent(request.background, request.callerAgentId), + ...(await this.workspaceBackgroundIntent(request.background, request.callerAgentId)), }, source.baseBranch ? { resolveDefaultBranch: async () => source.baseBranch as string }