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:
kengraversen
2026-06-10 06:13:20 +02:00
committed by GitHub
parent 0713dfa41f
commit 058381349e
2 changed files with 44 additions and 1 deletions
@@ -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,
),
);
}