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>
This commit is contained in:
paseo-bot[bot]
2026-11-04 01:12:53 +00:00
committed by GitHub
parent a880c9619d
commit 064243288b
3 changed files with 118 additions and 20 deletions
+1 -1
View File
@@ -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",
@@ -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);
+25 -19
View File
@@ -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<ResolvedSessionCreateAgentIntent> {
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 }