From 058381349ec72d5bbffbb0c227d9fe576d3ce996 Mon Sep 17 00:00:00 2001 From: kengraversen <44776736+kengraversen@users.noreply.github.com> Date: Wed, 10 Jun 2026 06:13:20 +0200 Subject: [PATCH] fix(heartbeat): don't reuse runtime.sessionId across an adapter swap (#4109) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip orchestrates AI agents on pluggable adapters (`claude_local`, `opencode_local`, `codex_local`, …); each adapter wraps an external CLI. > - The heartbeat service stores a session ID per agent and replays it back to the adapter via `--resume` so within-task continuity is preserved. > - Session IDs are adapter-specific in format: claude expects a UUID, opencode emits `ses_…`, etc. They cannot be cross-replayed. > - When the cross-adapter session ID does slip through (operator changes `adapterType`, edge cases in the resume path, foreign-format ID in stored task sessions), the claude CLI hard-fails with a validation error and every subsequent heartbeat loops on the same error until the stored ID is manually cleared. > - Master now ships a canonical-session-ID guard at `heartbeat.ts:8450` (via #5972) that prevents most of this at the source, and `isClaudePoisonedPreviousMessageIdError` recovers from the 400-class API error. > - This PR adds defense-in-depth at the adapter layer: the `--resume requires a valid session ID … not a UUID …` validation error from the claude CLI is now classified as an unknown-session signal, so the existing fresh-session retry recovers instead of hard-failing. ## Linked Issues or Issue Description Refs #5972 — sibling fix on the same cluster (recovers from poisoned `previous_message_id` 400). This PR complements it by handling the CLI-layer `--resume` validation error class. ## What Changed - `packages/adapters/claude-local/src/server/parse.ts` — broaden `isClaudeUnknownSessionError` regex to also match `--resume requires a valid session`, `is not a UUID`, and `does not match any session title`. The existing fresh-session retry at `execute.ts:612-625` now fires for this error class. - `packages/adapters/claude-local/src/server/parse.test.ts` — adds 4 new test cases for `isClaudeUnknownSessionError` covering the legacy and new patterns plus a negative case. **Dropped from the original PR on rebase** (already on master, would conflict): - `server/src/services/heartbeat.ts` runtimeSessionFallback gate — superseded by the stricter `isCanonicalSessionIdForAdapter` check on master (#5972 lineage). - `packages/adapters/claude-local/vitest.config.ts` and `vitest.config.ts` projects entry — both already in master. ## Verification ```sh pnpm --filter @paperclipai/adapter-claude-local vitest run # 19/19 passed (3 files, includes 4 new isClaudeUnknownSessionError cases) ``` Pre-existing failure on `server/src/__tests__/heartbeat-process-recovery.test.ts > queues exactly one retry when the recorded local pid is dead` reproduces on `origin/master` — unrelated to this PR. ## Risks - **Low-to-medium.** The added regex fragments are narrow. `--resume requires a valid session` and `does not match any session title` are unambiguously session-related. `is not a UUID` is more generic; worst case is one extra retry on an unrelated CLI validation error that would also fail on the same root issue. Happy to drop `is not a UUID` if reviewers prefer. - **No DB migration; no schema change; no behavior change when adapter types match (the common path).** ## Model Used - Provider: Anthropic (Claude) - Model: `claude-opus-4-7` (Opus 4.7), 1M context window - Tool: Claude Code CLI with extended thinking + tool use; human review on the rebase and the regex narrowing tradeoffs ## Checklist - [x] I searched the GitHub PR list for similar PRs and confirmed this is not a duplicate (related: #5972 already merged, complementary scope) - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have run tests locally and they pass (19/19 claude-local) - [x] I have added or updated tests where applicable - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge Co-authored-by: Devin Foley Co-authored-by: Paperclip --- .../claude-local/src/server/parse.test.ts | 41 +++++++++++++++++++ .../adapters/claude-local/src/server/parse.ts | 4 +- 2 files changed, 44 insertions(+), 1 deletion(-) diff --git a/packages/adapters/claude-local/src/server/parse.test.ts b/packages/adapters/claude-local/src/server/parse.test.ts index 2c88106f..d8cd65b0 100644 --- a/packages/adapters/claude-local/src/server/parse.test.ts +++ b/packages/adapters/claude-local/src/server/parse.test.ts @@ -3,6 +3,7 @@ import { extractClaudeRetryNotBefore, isClaudeTransientUpstreamError, isClaudePoisonedPreviousMessageIdError, + isClaudeUnknownSessionError, } from "./parse.js"; describe("isClaudeTransientUpstreamError", () => { @@ -144,6 +145,46 @@ describe("isClaudePoisonedPreviousMessageIdError", () => { }); }); +describe("isClaudeUnknownSessionError", () => { + it("detects the legacy 'no conversation found' message", () => { + expect( + isClaudeUnknownSessionError({ + result: "Error: No conversation found with session id 1234", + }), + ).toBe(true); + }); + + it("detects 'session ... not found' style errors", () => { + expect( + isClaudeUnknownSessionError({ + errors: [{ message: "Session abc123 not found" }], + }), + ).toBe(true); + }); + + it("detects '--resume requires a valid session' validation error from non-UUID input", () => { + expect( + isClaudeUnknownSessionError({ + errors: [ + { + message: + 'Error: --resume requires a valid session ID or session title when used with --print. Usage: claude -p --resume . Provided value "ses_268c2d0a5ffemYbEaeG7c86Uvo" is not a UUID and does not match any session title.', + }, + ], + }), + ).toBe(true); + }); + + it("returns false for unrelated error text", () => { + expect( + isClaudeUnknownSessionError({ + result: "Some other failure", + errors: [{ message: "Network timeout" }], + }), + ).toBe(false); + }); +}); + describe("extractClaudeRetryNotBefore", () => { it("parses the 'resets 4pm' hint in its explicit timezone", () => { const now = new Date("2026-04-22T15:15:00.000Z"); diff --git a/packages/adapters/claude-local/src/server/parse.ts b/packages/adapters/claude-local/src/server/parse.ts index 6fee0d84..36d13fd6 100644 --- a/packages/adapters/claude-local/src/server/parse.ts +++ b/packages/adapters/claude-local/src/server/parse.ts @@ -192,7 +192,9 @@ export function isClaudeUnknownSessionError(parsed: Record): bo .filter(Boolean); return allMessages.some((msg) => - /no conversation found with session id|unknown session|session .* not found|not a valid UUID/i.test(msg), + /no conversation found with session id|unknown session|session .* not found|not a valid UUID|--resume requires a valid session|is not a UUID|does not match any session title/i.test( + msg, + ), ); }