diff --git a/server/src/__tests__/heartbeat-workspace-session.test.ts b/server/src/__tests__/heartbeat-workspace-session.test.ts index 83a7149dee..bf2ae5e722 100644 --- a/server/src/__tests__/heartbeat-workspace-session.test.ts +++ b/server/src/__tests__/heartbeat-workspace-session.test.ts @@ -1013,6 +1013,73 @@ describe("resolveRuntimeSessionParamsForWorkspace", () => { }); expect(result.warning).toBeNull(); }); + + it("preserves a task_session when the resolved cwd matches the persisted cwd", async () => { + const agentId = "agent-123"; + // Use a real on-disk cwd so realpath resolution succeeds; the interesting + // case is that the task_session's cwd is neither project_primary nor the + // fallback agent home. The previous implementation dropped it wholesale. + const taskSessionCwd = await fs.mkdtemp(path.join(os.tmpdir(), "pcp-tasksess-")); + try { + const result = resolveRuntimeSessionParamsForWorkspace({ + agentId, + previousSessionParams: { + sessionId: "task-session-abc", + cwd: taskSessionCwd, + workspaceId: "workspace-9", + }, + resolvedWorkspace: buildResolvedWorkspace({ + cwd: taskSessionCwd, + source: "task_session", + projectId: null, + workspaceId: "workspace-9", + }), + }); + + expect(result.sessionParams).toEqual({ + sessionId: "task-session-abc", + cwd: taskSessionCwd, + workspaceId: "workspace-9", + }); + expect(result.warning).toBeNull(); + } finally { + await fs.rm(taskSessionCwd, { recursive: true, force: true }); + } + }); + + it("preserves a fallback-home session that was persisted via a symlink to the real fallback dir", async () => { + const agentId = "agent-symlink"; + const realFallbackCwd = resolveDefaultAgentWorkspaceDir(agentId); + await fs.mkdir(realFallbackCwd, { recursive: true }); + const symlinkDir = await fs.mkdtemp(path.join(os.tmpdir(), "pcp-symlink-")); + const symlinkPath = path.join(symlinkDir, "fallback-alias"); + await fs.symlink(realFallbackCwd, symlinkPath); + try { + const result = resolveRuntimeSessionParamsForWorkspace({ + agentId, + previousSessionParams: { + sessionId: "session-symlink", + cwd: symlinkPath, // persisted through symlinked path + workspaceId: null, + }, + resolvedWorkspace: buildResolvedWorkspace({ + cwd: realFallbackCwd, // resolver hands back the real fallback path + source: "agent_home", + projectId: null, + workspaceId: null, + }), + }); + + expect(result.sessionParams).toEqual({ + sessionId: "session-symlink", + cwd: symlinkPath, + workspaceId: null, + }); + expect(result.warning).toBeNull(); + } finally { + await fs.rm(symlinkDir, { recursive: true, force: true }); + } + }); }); describe("applyPersistedExecutionWorkspaceConfig", () => { diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 3101050a8d..6e9e165e44 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -1,4 +1,5 @@ import fs from "node:fs/promises"; +import { realpathSync } from "node:fs"; import path from "node:path"; import { execFile as execFileCallback } from "node:child_process"; import { promisify } from "node:util"; @@ -2473,6 +2474,21 @@ export function parseSessionCompactionPolicy(agent: typeof agents.$inferSelect): return resolveSessionCompactionPolicy(agent.adapterType, agent.runtimeConfig).policy; } +// Canonicalize a cwd for equality comparison across symlinked paths. The CLI +// encodes session files under the physical directory it saw, so a persisted +// cwd captured through a symlink must compare equal to the same physical +// directory expressed as its real path. Falls back to `path.resolve` when the +// path does not exist on disk (which is legitimate in unit tests and for +// prospective/planned cwds). +function canonicalizeCwdForComparison(target: string): string { + const normalized = path.resolve(target); + try { + return realpathSync(normalized); + } catch { + return normalized; + } +} + export function resolveRuntimeSessionParamsForWorkspace(input: { agentId: string; previousSessionParams: Record | null; @@ -2487,6 +2503,8 @@ export function resolveRuntimeSessionParamsForWorkspace(input: { warning: null as string | null, }; } + const canonicalPreviousCwd = canonicalizeCwdForComparison(previousCwd); + const canonicalResolvedCwd = canonicalizeCwdForComparison(resolvedWorkspace.cwd); // Inverse-of-migration case: the persisted session was captured in a // project workspace but the current run resolves to the agent's home // workspace (e.g. a timer/heartbeat wake with no active issue while the @@ -2497,8 +2515,20 @@ export function resolveRuntimeSessionParamsForWorkspace(input: { // and the state stays coherent (rather than accumulating stderr warnings // while the CLI silently self-heals). if (resolvedWorkspace.source !== "project_primary") { + // Safe to resume when the resolved cwd matches the persisted cwd — the + // session jsonl exists under the same directory. Covers the `task_session` + // source where `resolveWorkspaceForRun` deliberately reuses the persisted + // session's cwd, and any `agent_home` resolution that lands on the exact + // same directory that hosted the session. + if (canonicalPreviousCwd === canonicalResolvedCwd) { + return { + sessionParams: previousSessionParams, + warning: null as string | null, + }; + } const fallbackAgentHomeCwd = resolveDefaultAgentWorkspaceDir(agentId); - if (path.resolve(previousCwd) !== path.resolve(fallbackAgentHomeCwd)) { + const canonicalFallbackCwd = canonicalizeCwdForComparison(fallbackAgentHomeCwd); + if (canonicalPreviousCwd !== canonicalFallbackCwd) { return { sessionParams: null, warning: @@ -2520,13 +2550,14 @@ export function resolveRuntimeSessionParamsForWorkspace(input: { }; } const fallbackAgentHomeCwd = resolveDefaultAgentWorkspaceDir(agentId); - if (path.resolve(previousCwd) !== path.resolve(fallbackAgentHomeCwd)) { + const canonicalFallbackCwd = canonicalizeCwdForComparison(fallbackAgentHomeCwd); + if (canonicalPreviousCwd !== canonicalFallbackCwd) { return { sessionParams: previousSessionParams, warning: null as string | null, }; } - if (path.resolve(projectCwd) === path.resolve(previousCwd)) { + if (canonicalizeCwdForComparison(projectCwd) === canonicalPreviousCwd) { return { sessionParams: previousSessionParams, warning: null as string | null,