diff --git a/packages/server/src/server/agent/agent-loading.test.ts b/packages/server/src/server/agent/agent-loading.test.ts index 9158202979..d75fa8739a 100644 --- a/packages/server/src/server/agent/agent-loading.test.ts +++ b/packages/server/src/server/agent/agent-loading.test.ts @@ -1,4 +1,4 @@ -import { mkdtemp, rm } from "node:fs/promises"; +import { mkdir, mkdtemp, rm } from "node:fs/promises"; import { tmpdir } from "node:os"; import path from "node:path"; import { expect, test } from "vitest"; @@ -6,6 +6,7 @@ import { expect, test } from "vitest"; import { createTestLogger } from "../../test-utils/test-logger.js"; import { AgentManager } from "./agent-manager.js"; import { ensureAgentLoaded } from "./agent-loading.js"; +import { startAgentRun } from "./agent-prompt.js"; import { AgentStorage } from "./agent-storage.js"; import type { AgentClient, @@ -140,3 +141,61 @@ test("resuming a stored agent keeps its unread flag and its last-activity time", await rm(root, { recursive: true, force: true }); } }); + +test("loads an archived agent's history after its working directory is removed", async () => { + const root = await mkdtemp(path.join(tmpdir(), "agent-loading-missing-cwd-")); + const worktree = path.join(root, "managed-worktree"); + await mkdir(worktree, { recursive: true }); + const logger = createTestLogger(); + const storage = new AgentStorage(path.join(root, "agents"), logger); + const manager = new AgentManager({ + clients: createTestAgentClients(), + registry: storage, + logger, + }); + + const agentId = "00000000-0000-4000-8000-000000000501"; + + try { + const agent = await manager.createAgent({ provider: "codex", cwd: worktree }, agentId, { + workspaceId: "workspace-worktree", + }); + await startAgentRun(manager, agent.id, "what did you change", logger, {}); + // Dispatching a run does not finish it: the provider appends the reply to its + // history afterwards, and the turn is finalized only once that append lands. + // Archive after the turn is finalized so the transcript this test reads back is + // already on disk when the worktree goes away. + const finished = await manager.waitForAgentEvent(agent.id); + expect(finished.status).toBe("idle"); + await manager.archiveAgent(agent.id); + await manager.closeAgent(agent.id); + await manager.flush(); + await storage.flush(); + + // Archiving the workspace removes the worktree it owned. The agent's history is + // persisted and reading it must not depend on that directory still being there. + await rm(worktree, { recursive: true, force: true }); + + const loaded = await ensureAgentLoaded(agentId, { + agentManager: manager, + agentStorage: storage, + logger, + }); + + expect(loaded.id).toBe(agentId); + // The transcript is replayed from the provider's persisted history, so the reply the + // agent gave before the worktree went away is still readable. + const replies = manager + .getTimeline(agentId) + .filter((item) => item.type === "assistant_message"); + expect(replies.length).toBeGreaterThan(0); + expect(replies.every((item) => item.type === "assistant_message" && item.text.length > 0)).toBe( + true, + ); + } finally { + await manager.closeAgent(agentId).catch(() => undefined); + await manager.flush().catch(() => undefined); + await storage.flush().catch(() => undefined); + await rm(root, { recursive: true, force: true }); + } +}); diff --git a/packages/server/src/server/agent/agent-manager.ts b/packages/server/src/server/agent/agent-manager.ts index 31ea9af5fd..c80d257a3e 100644 --- a/packages/server/src/server/agent/agent-manager.ts +++ b/packages/server/src/server/agent/agent-manager.ts @@ -27,6 +27,7 @@ import { type AgentCapabilityFlags, type AgentClient, type AgentCreateSessionOptions, + type AgentResumePurpose, type AgentResumeSessionOptions, type AgentFeature, type AgentLaunchContext, @@ -139,6 +140,28 @@ export type AgentRunCancellationResult = | { status: "settled" } | { status: "refused" }; +/** A session that will run in a directory needs that directory to be there. */ +async function assertUsableWorkingDirectory(cwd: string): Promise { + try { + const stats = await stat(cwd); + if (!stats.isDirectory()) { + throw new Error(`Working directory is not a directory: ${cwd}`); + } + } catch (error) { + if ( + error instanceof Error && + "code" in error && + (error as NodeJS.ErrnoException).code === "ENOENT" + ) { + throw new Error(`Working directory does not exist: ${cwd}`, { cause: error }); + } + if (error instanceof Error) { + throw error; + } + throw new Error(`Failed to access working directory: ${cwd}`, { cause: error }); + } +} + interface PreparedSessionConfig { storedConfig: AgentSessionConfig; launchConfig: AgentSessionConfig; @@ -148,6 +171,8 @@ interface PreparedSessionConfig { interface NormalizeConfigOptions { resolveDefaultModel?: boolean; env?: Record; + /** Defaults to interactive. A history load reads persisted state and runs nothing. */ + purpose?: AgentResumePurpose; } interface TimeoutOptions { @@ -1229,7 +1254,7 @@ export class AgentManager { const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig( config, resolvedAgentId, - options?.env, + { env: options?.env }, ); this.requireEnabledProvider(storedConfig.provider); const client = await this.requireAvailableClient({ @@ -1331,17 +1356,21 @@ export class AgentManager { ...overrides, provider: handle.provider, } as AgentSessionConfig; - const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig( - mergedConfig, - resolvedAgentId, - ); - // Decide residency from durable state inside the lifecycle lane. A loader may - // have read the record before a queued archive or restore completed. + // have read the record before a queued archive or restore completed. Residency is + // settled before the config is prepared, because a history load reads an archived + // agent whose working directory may be gone. const record = this.registry ? await this.registry.get(resolvedAgentId) : null; const currentResumeOptions = record ? { purpose: record.archivedAt ? ("history" as const) : ("interactive" as const) } : resumeOptions; + const purpose = currentResumeOptions?.purpose ?? "interactive"; + + const { storedConfig, launchConfig, paseoToolPolicy } = await this.prepareSessionConfig( + mergedConfig, + resolvedAgentId, + { purpose }, + ); const client = this.requireClient(handle.provider); const available = await client.isAvailable(); if (!available) { @@ -1358,7 +1387,7 @@ export class AgentManager { undefined, { reason: "resume", - purpose: currentResumeOptions?.purpose ?? "interactive", + purpose, workspaceId: options?.workspaceId ?? null, }, ); @@ -4999,23 +5028,11 @@ export class AgentManager { // Always resolve cwd to absolute path for consistent history file lookup if (normalized.cwd) { normalized.cwd = resolve(normalized.cwd); - try { - const cwdStats = await stat(normalized.cwd); - if (!cwdStats.isDirectory()) { - throw new Error(`Working directory is not a directory: ${normalized.cwd}`); - } - } catch (error) { - if ( - error instanceof Error && - "code" in error && - (error as NodeJS.ErrnoException).code === "ENOENT" - ) { - throw new Error(`Working directory does not exist: ${normalized.cwd}`, { cause: error }); - } - if (error instanceof Error) { - throw error; - } - throw new Error(`Failed to access working directory: ${normalized.cwd}`, { cause: error }); + // Only a session that will run in the directory needs it to still be there. Reading + // an archived agent's history runs nothing, and must survive the worktree it ran in + // being removed when its workspace was archived. + if (options.purpose !== "history") { + await assertUsableWorkingDirectory(normalized.cwd); } } @@ -5088,9 +5105,12 @@ export class AgentManager { private async prepareSessionConfig( config: AgentSessionConfig, agentId: string, - env?: Record, + options: { env?: Record; purpose?: AgentResumePurpose } = {}, ): Promise { - const storedConfig = await this.normalizeConfig(stripInternalPaseoMcpServer(config), { env }); + const storedConfig = await this.normalizeConfig(stripInternalPaseoMcpServer(config), { + env: options.env, + purpose: options.purpose, + }); const paseoToolPolicy = this.paseoToolsEnabled ? this.resolvePaseoToolPolicy(storedConfig.provider) : { enabled: false }; diff --git a/packages/server/src/server/agent/agent-sdk-types.ts b/packages/server/src/server/agent/agent-sdk-types.ts index 90e30047be..13a8b7de1c 100644 --- a/packages/server/src/server/agent/agent-sdk-types.ts +++ b/packages/server/src/server/agent/agent-sdk-types.ts @@ -643,10 +643,13 @@ export interface AgentCreateSessionOptions { persistSession?: boolean; } +/** What a resumed session is for: driving the agent, or reading what it already did. */ +export type AgentResumePurpose = "interactive" | "history"; + /** Runtime-only intent for a persisted-session resume. Never persist this option. */ export interface AgentResumeSessionOptions { /** Defaults to interactive. History loading may be read-only for archived native sessions. */ - purpose?: "interactive" | "history"; + purpose?: AgentResumePurpose; } /** diff --git a/packages/server/src/server/session.create-agent-worktree-autoarchive.e2e.test.ts b/packages/server/src/server/session.create-agent-worktree-autoarchive.e2e.test.ts index 94e45d7452..aa6773d6df 100644 --- a/packages/server/src/server/session.create-agent-worktree-autoarchive.e2e.test.ts +++ b/packages/server/src/server/session.create-agent-worktree-autoarchive.e2e.test.ts @@ -154,12 +154,15 @@ test("create_agent_request creates a worktree and auto-archives both after the f // last-reference worktree directory is gone. await expectAgentAbsentFromActiveList(created.id); await expect.poll(() => existsSync(created.cwd), { timeout: 10000, interval: 100 }).toBe(false); - // Archived tabs can continue asking for history. These reads must not recreate - // the removed workspace observation or compromise the next agent lifecycle. + // Archived tabs can continue asking for history, and those reads are served from + // persisted state. They must not recreate the removed worktree or its workspace + // observation, and must not compromise the next agent lifecycle. const staleTimelineReads = await Promise.allSettled( Array.from({ length: 10 }, () => ctx.client.fetchAgentTimeline(created.id, { limit: 20 })), ); - expect(staleTimelineReads.every((result) => result.status === "rejected")).toBe(true); + expect(staleTimelineReads.every((result) => result.status === "fulfilled")).toBe(true); + expect(existsSync(created.cwd)).toBe(false); + expect((await ctx.client.fetchWorkspaces()).entries).toHaveLength(0); const subsequent = await ctx.client.createAgent({ config: { ...getFullAccessConfig("codex"), cwd: repoDir }, initialPrompt: "Say done.",