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 }