fix(claude-local, heartbeat): recover from silent session_lost caused by cwd switches

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 <uuid>` because the session .jsonl lives under a
different project-encoded directory. The CLI logs
`No conversation found with session ID: <uuid>` 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.
This commit is contained in:
Builder (Nublo Ecomm SL) 2026-07-08 08:37:24 +00:00
parent 555391fed7
commit fc200eff00
5 changed files with 158 additions and 0 deletions

View File

@ -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<AdapterExec
try {
const initial = await runAttempt(sessionId ?? null);
// Silent-recovery variant: CLI could not resume the requested session for
// this cwd (e.g. the session file lives under a different project-encoded
// directory because the run's resolved workspace flipped since the session
// was persisted), emitted `No conversation found with session ID: <uuid>`
// 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 &&

View File

@ -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(

View File

@ -218,6 +218,26 @@ export function isClaudeUnknownSessionError(parsed: Record<string, unknown>): bo
);
}
/**
* Extended detector that also inspects stderr for the CLI's silent-recovery
* variant: when Claude Code CLI cannot resolve `--resume <uuid>` 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: <uuid>` 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<string, unknown> | 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<string, unknown>): boolean {
const resultText = asString(parsed.result, "").trim();
const allMessages = [resultText, ...extractClaudeErrorMessages(parsed)]

View File

@ -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", () => {

View File

@ -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 <uuid>` 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,