fix(heartbeat): don't reuse runtime.sessionId across an adapter swap (#4109)
## 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 <devin@paperclip.ing> Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -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 <session-id|title>. 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");
|
||||
|
||||
@@ -192,7 +192,9 @@ export function isClaudeUnknownSessionError(parsed: Record<string, unknown>): 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,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user