From 290306fd161eaaafc652a2c4e5ba8eec340185c1 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Wed, 23 Sep 2026 02:45:28 +0200 Subject: [PATCH] Read an archived agent's logs after its worktree is removed (#5229) * Read an archived agent's history without its working directory Loading an archived agent for history rejected when its working directory no longer existed, so `paseo logs ` and `get_agent_activity` failed with "Working directory does not exist" once the workspace archive removed the worktree the agent ran in. Resuming a persisted session already distinguishes an interactive resume from a history load, but residency was settled after the session config was prepared, so the directory check ran for both. Settle residency first and require the directory only for a session that will actually run in it. * Assert the archived transcript is readable, not just loadable * Let archived timeline reads succeed without resurrecting the workspace The auto-archive test asserted that history reads for an archived agent in a removed worktree are rejected. That was the old behavior, not the invariant its comment describes. Assert the invariant instead: the reads succeed, the removed worktree stays removed, no workspace observation comes back, and the next agent lifecycle is unaffected. * Archive the agent only after its turn is finalized The regression test dispatched a run and archived immediately. Dispatch resolves before the provider appends the reply to its history, so the transcript the test reads back could be written after the archive, and the read could find nothing. Wait for the turn to be finalized first. The provider appends the assistant message before it emits turn_completed, so once the turn is finalized the transcript is already on disk. --------- Co-authored-by: paseo-bot[bot] <266920839+paseo-bot[bot]@users.noreply.github.com> --- .../src/server/agent/agent-loading.test.ts | 61 ++++++++++++++- .../server/src/server/agent/agent-manager.ts | 74 ++++++++++++------- .../src/server/agent/agent-sdk-types.ts | 5 +- ...ate-agent-worktree-autoarchive.e2e.test.ts | 9 ++- 4 files changed, 117 insertions(+), 32 deletions(-) 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.",