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:
parent
555391fed7
commit
fc200eff00
|
|
@ -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 &&
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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", () => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in New Issue