diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index 1d3863eb47..b2406d96ce 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -71,6 +71,7 @@ import { isClaudeRefusalResult, isClaudeTransientUpstreamError, isClaudeUnknownSessionError, + isClaudeUnknownSessionErrorFromStreams, isClaudePoisonedPreviousMessageIdError, isClaudeImageProcessingError, isClaudeModelNotFoundError, @@ -1308,6 +1309,28 @@ export async function execute(ctx: AdapterExecutionContext): Promise` + // on stderr, and silently started a fresh conversation. Exit code is 0 + // and parsed result looks successful, so the classic parsed-only detector + // (below) does not fire. Log an observability line so operators can + // quantify the churn without waiting for the retry-fresh path to trigger. + if ( + sessionId && + !initial.proc.timedOut && + (initial.proc.exitCode ?? 0) === 0 && + !(initial.parsed && initial.parsed["is_error"] === true) && + isClaudeUnknownSessionErrorFromStreams({ parsed: null, stderr: initial.proc.stderr ?? "" }) + ) { + await onLog( + "stdout", + `[paperclip] Claude CLI could not resume session "${sessionId}" for the current cwd; ` + + `initial call silently fell back to a fresh conversation. Persisted session state will ` + + `refresh to the new session id.\n`, + ); + } const sessionErrorKind = sessionId && !initial.proc.timedOut && diff --git a/packages/adapters/claude-local/src/server/parse.test.ts b/packages/adapters/claude-local/src/server/parse.test.ts index 5b291d4056..0ce1a0e63f 100644 --- a/packages/adapters/claude-local/src/server/parse.test.ts +++ b/packages/adapters/claude-local/src/server/parse.test.ts @@ -9,6 +9,7 @@ import { isClaudePoisonedPreviousMessageIdError, isClaudeRefusalResult, isClaudeUnknownSessionError, + isClaudeUnknownSessionErrorFromStreams, isClaudeImageProcessingError, isClaudeModelNotFoundError, } from "./parse.js"; @@ -422,6 +423,50 @@ describe("isClaudeUnknownSessionError", () => { }); }); +describe("isClaudeUnknownSessionErrorFromStreams", () => { + it("detects the silent-recovery stderr warning when parsed result looks successful", () => { + expect( + isClaudeUnknownSessionErrorFromStreams({ + parsed: { subtype: "success", is_error: false, result: "ok" }, + stderr: "No conversation found with session ID: 4da9bae3-bb6f-4e5e-b639-b24a3742724a", + }), + ).toBe(true); + }); + + it("delegates to isClaudeUnknownSessionError when the parsed payload carries the message", () => { + expect( + isClaudeUnknownSessionErrorFromStreams({ + parsed: { result: "Error: No conversation found with session id 1234" }, + stderr: "", + }), + ).toBe(true); + }); + + it("is case-insensitive on stderr", () => { + expect( + isClaudeUnknownSessionErrorFromStreams({ + parsed: null, + stderr: "no conversation found with session id abc", + }), + ).toBe(true); + }); + + it("returns false for unrelated stderr noise", () => { + expect( + isClaudeUnknownSessionErrorFromStreams({ + parsed: null, + stderr: "Warning: shell exited with signal SIGTERM", + }), + ).toBe(false); + }); + + it("returns false when both parsed and stderr are empty", () => { + expect( + isClaudeUnknownSessionErrorFromStreams({ parsed: null, stderr: "" }), + ).toBe(false); + }); +}); + describe("isClaudeImageProcessingError", () => { it("detects the 'Could not process image' 400 error in the result field", () => { expect( diff --git a/packages/adapters/claude-local/src/server/parse.ts b/packages/adapters/claude-local/src/server/parse.ts index 3e636ac2cf..668171aa6c 100644 --- a/packages/adapters/claude-local/src/server/parse.ts +++ b/packages/adapters/claude-local/src/server/parse.ts @@ -320,6 +320,26 @@ export function isClaudeUnknownSessionError(parsed: Record): bo ); } +/** + * Extended detector that also inspects stderr for the CLI's silent-recovery + * variant: when Claude Code CLI cannot resolve `--resume ` in the current + * cwd (e.g. the session file lives under a different project-encoded directory + * because cwd changed between runs), it prints `No conversation found with + * session ID: ` on stderr, silently starts a fresh conversation, and + * still exits with code 0 and `is_error:false`. The plain + * `isClaudeUnknownSessionError` only inspects parsed result/errors, which are + * empty in that silent-recovery path. + */ +export function isClaudeUnknownSessionErrorFromStreams(input: { + parsed?: Record | null; + stderr?: string | null; +}): boolean { + if (input.parsed && isClaudeUnknownSessionError(input.parsed)) return true; + const stderr = (input.stderr ?? "").trim(); + if (!stderr) return false; + return /no conversation found with session id/i.test(stderr); +} + export function isClaudePoisonedPreviousMessageIdError(parsed: Record): boolean { const resultText = asString(parsed.result, "").trim(); const allMessages = [resultText, ...extractClaudeErrorMessages(parsed)] diff --git a/server/src/__tests__/heartbeat-workspace-session.test.ts b/server/src/__tests__/heartbeat-workspace-session.test.ts index 73a9c1f1af..59df73c411 100644 --- a/server/src/__tests__/heartbeat-workspace-session.test.ts +++ b/server/src/__tests__/heartbeat-workspace-session.test.ts @@ -1258,6 +1258,124 @@ describe("resolveRuntimeSessionParamsForWorkspace", () => { }); expect(result.warning).toBeNull(); }); + + it("drops persisted session when a project-workspace session is being resumed from agent home", () => { + const agentId = "agent-123"; + const fallbackCwd = resolveDefaultAgentWorkspaceDir(agentId); + + const result = resolveRuntimeSessionParamsForWorkspace({ + agentId, + previousSessionParams: { + sessionId: "stale-project-session", + cwd: "/tmp/some-project-cwd", + workspaceId: "workspace-1", + }, + resolvedWorkspace: buildResolvedWorkspace({ + cwd: fallbackCwd, + source: "agent_home", + projectId: null, + workspaceId: null, + }), + }); + + expect(result.sessionParams).toBeNull(); + expect(result.warning).toContain("stale-project-session"); + expect(result.warning).toContain("fresh session"); + }); + + it("preserves persisted session when resuming into agent home from the same fallback cwd", () => { + const agentId = "agent-123"; + const fallbackCwd = resolveDefaultAgentWorkspaceDir(agentId); + + const result = resolveRuntimeSessionParamsForWorkspace({ + agentId, + previousSessionParams: { + sessionId: "session-1", + cwd: fallbackCwd, + workspaceId: null, + }, + resolvedWorkspace: buildResolvedWorkspace({ + cwd: fallbackCwd, + source: "agent_home", + projectId: null, + workspaceId: null, + }), + }); + + expect(result.sessionParams).toEqual({ + sessionId: "session-1", + cwd: fallbackCwd, + workspaceId: null, + }); + 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 3de2209b3c..c548d5cee1 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -47,6 +47,7 @@ import { import { agentService } from "./agents.js"; import { normalizeLegacyRunnerProvider } from "@paperclipai/adapter-utils"; 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"; @@ -5316,6 +5317,21 @@ export function parseSessionCompactionPolicy( .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; @@ -5332,7 +5348,40 @@ 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 + // agent's last useful run happened inside a project). The CLI encodes the + // session file under the cwd it was created in, so a `--resume ` in + // agent_home cannot find the jsonl and silently starts a fresh session. + // Drop the persisted session id here so the run starts fresh explicitly + // 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); + const canonicalFallbackCwd = canonicalizeCwdForComparison(fallbackAgentHomeCwd); + if (canonicalPreviousCwd !== canonicalFallbackCwd) { + return { + sessionParams: null, + warning: + `Persisted session "${previousSessionId}" was captured in workspace "${previousCwd}" ` + + `but the current run resolves to "${resolvedWorkspace.cwd}" (source=${resolvedWorkspace.source}). ` + + `Starting a fresh session to avoid a stale --resume against a project-encoded cwd.`, + }; + } return { sessionParams: previousSessionParams, warning: null as string | null, @@ -5346,13 +5395,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,