From fc200eff0089cb101c6a1a42c1fea062fb830fed Mon Sep 17 00:00:00 2001 From: "Builder (Nublo Ecomm SL)" Date: Wed, 8 Jul 2026 08:37:24 +0000 Subject: [PATCH 1/2] fix(claude-local, heartbeat): recover from silent session_lost caused by cwd switches MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a claude_local session is persisted in one cwd (e.g. a project workspace) and the next heartbeat wake resolves to a different cwd (e.g. agent home), the CLI cannot resolve `--resume ` because the session .jsonl lives under a different project-encoded directory. The CLI logs `No conversation found with session ID: ` on stderr, silently starts a fresh conversation, and returns exit_code=0 is_error=false. The run is marked succeeded and the persisted session is refreshed to the new session id, but: - the stderr warning is invisible to server-side observability; - the persisted `sessionParams.cwd` is not consulted before the next resume, so the pattern can repeat every time the resolved cwd flips (project ↔ agent_home). Observed at Nublo Ecomm SL: 7 runs across 4 agents in a 2h window (2026-07-06), each burning a Claude CLI invocation before the silent fallback. Root cause was verified by cross-referencing `heartbeat_runs.stderr_excerpt`, `session_id_before`, and the affected agent's run history. This change: 1. parse.ts — adds `isClaudeUnknownSessionErrorFromStreams`, a variant of `isClaudeUnknownSessionError` that also inspects stderr. Needed because the silent-recovery path leaves no signal in the parsed CLI result. 2. execute.ts — after the initial run, when exit=0 and parsed result is not an error, still emits a `[paperclip]` log line if the stderr signature matches. Gives operators a first-class metric without changing retry semantics (the initial run already succeeded). 3. heartbeat.ts (`resolveRuntimeSessionParamsForWorkspace`) — new inverse-of-migration branch: when the resolved workspace is not `project_primary` (i.e. agent_home / task_session) and the persisted session cwd is not the agent's fallback home either, drop the persisted session params. The next run starts fresh explicitly rather than issuing `--resume` against a cwd where the session file does not exist. Tests: - packages/adapters/claude-local/src/server/parse.test.ts: 5 new tests covering the stderr detector, precedence, case-insensitivity, negatives. - server/src/__tests__/heartbeat-workspace-session.test.ts: 2 new tests covering the drop path (project → agent_home) and the safe-noop path (agent_home → agent_home). Full local runs: 38 tests in parse.test.ts pass; 120 tests in heartbeat-workspace-session.test.ts pass. `pnpm --filter @paperclipai/adapter-claude-local run typecheck` and `pnpm --filter @paperclipai/server run typecheck` are clean. --- .../claude-local/src/server/execute.ts | 23 +++++++++ .../claude-local/src/server/parse.test.ts | 45 ++++++++++++++++ .../adapters/claude-local/src/server/parse.ts | 20 ++++++++ .../heartbeat-workspace-session.test.ts | 51 +++++++++++++++++++ server/src/services/heartbeat.ts | 19 +++++++ 5 files changed, 158 insertions(+) diff --git a/packages/adapters/claude-local/src/server/execute.ts b/packages/adapters/claude-local/src/server/execute.ts index 99bf1f1621..88ee2615aa 100644 --- a/packages/adapters/claude-local/src/server/execute.ts +++ b/packages/adapters/claude-local/src/server/execute.ts @@ -54,6 +54,7 @@ import { isClaudeRefusalResult, isClaudeTransientUpstreamError, isClaudeUnknownSessionError, + isClaudeUnknownSessionErrorFromStreams, isClaudePoisonedPreviousMessageIdError, isClaudeImageProcessingError, } from "./parse.js"; @@ -1048,6 +1049,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 557df0c060..588ea658f7 100644 --- a/packages/adapters/claude-local/src/server/parse.test.ts +++ b/packages/adapters/claude-local/src/server/parse.test.ts @@ -7,6 +7,7 @@ import { isClaudePoisonedPreviousMessageIdError, isClaudeRefusalResult, isClaudeUnknownSessionError, + isClaudeUnknownSessionErrorFromStreams, isClaudeImageProcessingError, } from "./parse.js"; @@ -276,6 +277,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 9b279e974d..908ffdfd66 100644 --- a/packages/adapters/claude-local/src/server/parse.ts +++ b/packages/adapters/claude-local/src/server/parse.ts @@ -218,6 +218,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 cde79bf787..83a7149dee 100644 --- a/server/src/__tests__/heartbeat-workspace-session.test.ts +++ b/server/src/__tests__/heartbeat-workspace-session.test.ts @@ -962,6 +962,57 @@ 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(); + }); }); describe("applyPersistedExecutionWorkspaceConfig", () => { diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 759f3a7b64..3101050a8d 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -2487,7 +2487,26 @@ export function resolveRuntimeSessionParamsForWorkspace(input: { warning: null as string | null, }; } + // 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") { + const fallbackAgentHomeCwd = resolveDefaultAgentWorkspaceDir(agentId); + if (path.resolve(previousCwd) !== path.resolve(fallbackAgentHomeCwd)) { + 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, From a3e00b9de53203b5092277a3dd4ba3f7b35ab820 Mon Sep 17 00:00:00 2001 From: Sergio-LPA Date: Sat, 11 Jul 2026 15:29:22 +0000 Subject: [PATCH 2/2] fix(heartbeat): preserve task_session and symlink-equal cwds in inverse-migration branch MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses Greptile review on this PR: - P1 (Task Sessions Lose Continuity): the inverse-of-migration branch in resolveRuntimeSessionParamsForWorkspace compared only against the fallback agent-home cwd, so a valid task_session whose resolved cwd matches the persisted cwd was still dropped and the next heartbeat opened a fresh conversation. Now short-circuits and preserves the session whenever canonicalizeCwdForComparison(previousCwd) === canonicalizeCwdForComparison(resolvedCwd). - P2 (Symlinked Home Sessions Drop): all cwd equality checks in this function now flow through canonicalizeCwdForComparison, which prefers fs.realpathSync and falls back to path.resolve when the path does not exist. A fallback-home session persisted through a symlinked path no longer compares unequal against the physical fallback dir returned by the resolver. Tests: heartbeat-workspace-session gains two new cases — task_session preservation with a real on-disk tmp cwd, and symlink-equal fallback preservation. Refs greptile-apps[bot] review comments on PR #9219. --- .../heartbeat-workspace-session.test.ts | 67 +++++++++++++++++++ server/src/services/heartbeat.ts | 37 +++++++++- 2 files changed, 101 insertions(+), 3 deletions(-) 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,