Files
paperclip/docs/adapters/claude-local.md
T
Danial Jawaid 8ee3987d12 adapter-claude-local: recover from poisoned previous_message_id 400 (detect + clearSession) (#5972)
## Thinking Path

> - Paperclip's `claude_local` adapter persists Claude Code session
jsonls under `~/.claude/projects/…/{sessionId}.jsonl` and resumes them
on the next heartbeat
> - When Claude Code injects `<synthetic>` placeholder assistant
messages (after rate-limit, max-turn exhaustion, or transient-upstream
failures) those placeholders get UUID-format `message.id`s rather than
`msg_…`-format ids
> - On the next `--resume`, Claude Code passes that UUID as
`previous_message_id` and Anthropic's API rejects it with a 400:
``diagnostics.previous_message_id: must be the `id` from a prior
/v1/messages response (starts with `msg_`)``
> - The adapter had a session-rotation fallback only for "unknown
session" errors, so the poisoned session was `--resume`-d indefinitely
and the agent flipped between `idle` and `error` every heartbeat
> - Even worse, the *result* event of the failing run still carried a
`session_id`, and the adapter was persisting that id into the
issue-scoped session store (`agentTaskSessions`). So even after we
detected the 400, every subsequent continuation re-loaded the same
poisoned id and hit the same 400 again — the issue was permanently
stranded
> - We observed this on multiple agents in our deployment; the only
manual fix was to rename the `.jsonl`, which is not a viable long-term
workaround
> - This PR detects the 400, runs the same session-rotation fallback the
unknown-session path uses **and** stops persisting the poisoned id, so
the next attempt starts genuinely fresh

## Linked Issues or Issue Description

No external GitHub issue is linked. Describing the problem inline
following the bug-report template:

**What happened:** `claude_local` agents flipped between `idle` and
`error` on every heartbeat because the persisted session jsonl carried a
synthetic UUID `previous_message_id` (from `<synthetic>` assistant
placeholders injected after rate-limit/max-turn/upstream errors).
Anthropic's API rejected every `--resume` with a 400:
``diagnostics.previous_message_id: must be the `id` from a prior
/v1/messages response (starts with `msg_`)``.

**Expected behavior:** When the persisted session is poisoned and
unrecoverable, the adapter should rotate to a fresh session — the same
fallback path already used for unknown-session errors — and stop
re-persisting the poisoned `session_id`.

**Actual behavior:** The session-rotation fallback only matched the
"unknown session" pattern, so the poisoned session was `--resume`-d
forever. The result event of the failing run still carried `session_id`,
which was being persisted into `agentTaskSessions`, so every subsequent
continuation reloaded the same poisoned id and hit the same 400.

**Reproduction:** Inject any flow that causes Claude Code to emit a
`<synthetic>` placeholder (rate-limit, max-turn exhaustion, transient
upstream failure). The next `--resume` will fail with the 400 and the
agent will not self-recover.

**Scope of fix:** Add a `previous_message_id` 400 detector; route it
through the existing unknown-session fallback; drop the poisoned
`sessionId` and emit `clearSession: true` so the heartbeat service wipes
the persisted row; best-effort delete the local poisoned `.jsonl`.

## What Changed

Two commits:

1. **`adapter-claude-local: auto-rotate session on previous_message_id
400 (synthetic-msg poisoning)`** — detector + execute-time rotation
2. **`adapter-claude-local: guard against persisting poisoned
sessionId`** — validate-before-persist + `clearSession`

Combined diff:

- `parse.ts`: new `isClaudePoisonedPreviousMessageIdError(parsed)`
matching ``/diagnostics\.previous_message_id.*starts with `msg_`/i``
against `parsed.result` and `extractClaudeErrorMessages(parsed)`
- `parse.ts`: `isClaudeTransientUpstreamError()` excludes the new error
from transient classification so it isn't masked as retryable upstream
noise
- `execute.ts`: expand the resume-fallback branch so it triggers on both
`isClaudeUnknownSessionError` and the new
`isClaudePoisonedPreviousMessageIdError`, with a distinct log line
(`"returned a poisoned message-id"` vs `"is unavailable"`)
- `execute.ts`: for local (non-remote) execution targets, best-effort
delete the poisoned `~/.claude/projects/.../{sessionId}.jsonl` before
retrying so the file can't be accidentally resumed by an out-of-band
caller. The `fs.unlink` and follow-up log call are in separate try/catch
blocks so a closed log stream cannot mask a successful unlink (and vice
versa)
- `execute.ts` / `toAdapterResult`: when a result carries the poisoned
400, **drop** `sessionId`/`sessionParams`/`sessionDisplayId` (return
`null`) and emit `clearSession: true` so the heartbeat service's
`resolveNextSessionState` wipes the persisted row. The result also
surfaces `errorCode: "claude_poisoned_previous_message_id"` for
observability
- `docs/adapters/claude-local.md`: runbook entry — symptom,
auto-recovery flow, on-call checklist
- Tests:
- 4 new `parse.test.ts` cases covering positive detection in `result`
and `errors[]`, negative cases, and non-transient classification
- 3 new `claude-local-execute.test.ts` cases: (a) fresh run reports the
poisoned error → sessionId dropped + `clearSession: true`; (b) recovery
retry also reports the poisoned error → same guards apply; (c)
session-rotation success on retry

## Verification

```bash
pnpm --filter @paperclipai/adapter-claude-local exec vitest run src/server/parse.test.ts
pnpm --filter @paperclipai/server exec vitest run src/__tests__/claude-local-execute.test.ts
```

Both suites green locally. This patch is also currently running as a
hot-patch over the published `2026.513.0` adapter on the reporting
deployment — sessions that previously looped indefinitely now
self-recover on the first heartbeat after the 400 surfaces.

## Risks

- Low risk. The detector is conservative (regex over `result` +
`errors[]` only) and the rotation reuses the existing unknown-session
fallback path
- The local-only `fs.unlink` of the poisoned `.jsonl` is wrapped in
`try/catch` and ignored on failure — strictly an optimization; the
server-side session clear is the authoritative reset
- Remote execution targets (`executionTargetIsRemote`) skip the disk
cleanup because the file lives on a remote host that we can't safely
reach from the adapter
- The `clearSession: true` + nulled session fields path is a no-op on
healthy runs; it only fires when the new detector matches, so existing
successful continuations are unaffected
- No DB schema changes, no public API changes, no new dependencies

## Model Used

- Provider: Anthropic Claude
- Model: `claude-opus-4-7` (Opus 4.7)
- Context window: 1M
- Capabilities: extended reasoning, tool use, code execution
- Role: implemented the detector, expanded the fallback branch, added
the persist-guard + `clearSession`, wrote the unit + integration tests,
validated locally, and applied the equivalent hot-patch to the deployed
`2026.513.0` install while this PR is in review

## Checklist

- [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 searched GitHub for similar or duplicate PRs and linked
them — closed #2295, #2361, #3572, #5438 as duplicates of this canonical
fix; complementary fixes #4838 (heartbeat_timer reset) and #4932 (gemini
context-overflow rotation) target different code paths
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] If this change affects the UI, I have included before/after
screenshots — N/A, adapter-only change
- [x] I have updated relevant documentation
(`docs/adapters/claude-local.md` runbook entry)
- [x] I have considered and documented any risks above
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Danial Jawaid <danial.jawaid@gmail.com>
Co-authored-by: Paperclip <noreply@paperclip.ing>
Co-authored-by: Devin Foley <devin@paperclip.ing>
2026-06-09 15:45:47 -07:00

5.0 KiB

title, summary
title summary
Claude Local Claude Code local adapter setup and configuration

The claude_local adapter runs Anthropic's Claude Code CLI locally. It supports session persistence, skills injection, and structured output parsing.

Prerequisites

  • Claude Code CLI installed (claude command available)
  • ANTHROPIC_API_KEY set in the environment or agent config

Configuration Fields

Field Type Required Description
cwd string Yes Working directory for the agent process (absolute path; created automatically if missing when permissions allow)
model string No Claude model to use (e.g. claude-opus-4-6)
promptTemplate string No Prompt used for all runs
env object No Environment variables (supports secret refs)
timeoutSec number No Process timeout (0 = no timeout)
graceSec number No Grace period before force-kill
maxTurnsPerRun number No Max agentic turns per heartbeat (defaults to 300)
dangerouslySkipPermissions boolean No Skip permission prompts (default: true); required for headless runs where interactive approval is impossible

Prompt Templates

Templates support {{variable}} substitution:

Variable Value
{{agentId}} Agent's ID
{{companyId}} Company ID
{{runId}} Current run ID
{{agent.name}} Agent's name
{{company.name}} Company name

Session Persistence

The adapter persists Claude Code session IDs between heartbeats. On the next wake, it resumes the existing conversation so the agent retains full context.

Session resume is cwd-aware: if the agent's working directory changed since the last run, a fresh session starts instead.

If resume fails with an unknown session error, the adapter automatically retries with a fresh session.

Poisoned previous_message_id (recovery)

Symptom in logs / issue thread:

API Error: 400 diagnostics.previous_message_id: must be the `id` from a prior /v1/messages response (starts with `msg_`)

What it means: the on-disk Claude Code transcript JSONL for that session contains a malformed (non-msg_-prefixed) previous_message_id. Anthropic's /v1/messages rejects every resume attempt against that transcript with a deterministic 400. Without guards, Paperclip would re-persist the same poisoned session id and the issue is stranded permanently — see RED-976 / RED-978.

What the adapter does automatically:

  1. Auto-rotate on resume. If a --resume attempt returns this 400, the adapter retries once with a fresh session, deletes the poisoned <session>.jsonl from the local Claude config dir (best effort), and uses the fresh session id going forward.
  2. Validate-before-persist. A result that carries this 400 never gets its session_id written back to the task session store, even if Claude Code emits one in the result event. The adapter returns sessionId: null, sessionParams: null, and errorCode: "claude_poisoned_previous_message_id".
  3. Clear-on-error. The adapter sets clearSession: true on the result, which causes the heartbeat service to drop any persisted session row for that issue (clearTaskSessions). The next continuation starts from a clean slate.

On-call checklist if you see this in production:

  • Confirm errorCode is claude_poisoned_previous_message_id in the run row — that means the guards fired correctly and the issue auto-recovers on the next heartbeat.
  • If the same issue still loops after one heartbeat, check that agentTaskSessions for that (agentId, taskKey) was cleared. If not, the adapter return value was lost (e.g. a malformed run finalization) — escalate; do not manually edit the row, file a child issue with the run id.
  • For remote execution targets (sandbox/SSH), the poisoned JSONL is on the remote and the adapter only logs the cleanup intent. The fresh-session retry still succeeds because it uses a new session id, and the server-side clearSession: true is authoritative regardless of remote disk state.

Skills Injection

The adapter creates a temporary directory with symlinks to Paperclip skills and passes it via --add-dir. This makes skills discoverable without polluting the agent's working directory.

For manual local CLI usage outside heartbeat runs (for example running as claudecoder directly), use:

pnpm paperclipai agent local-cli claudecoder --company-id <company-id>

This installs Paperclip skills in ~/.claude/skills, creates an agent API key, and prints shell exports to run as that agent.

Environment Test

Use the "Test Environment" button in the UI to validate the adapter config. It checks:

  • Claude CLI is installed and accessible
  • Working directory is absolute and available (auto-created if missing and permitted)
  • API key/auth mode hints (ANTHROPIC_API_KEY vs subscription login)
  • A live hello probe (claude --print - --output-format stream-json --verbose with prompt Respond with hello.) to verify CLI readiness