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,