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] 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,