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>
This commit is contained in:
@@ -90,6 +90,72 @@ type CapturePayload = {
|
||||
appendedSystemPromptFileContents?: string | null;
|
||||
};
|
||||
|
||||
async function writePoisonedMessageIdClaudeCommand(commandPath: string): Promise<void> {
|
||||
const script = `#!/usr/bin/env node
|
||||
const fs = require("node:fs");
|
||||
|
||||
const capturePath = process.env.PAPERCLIP_TEST_CAPTURE_PATH;
|
||||
const statePath = process.env.PAPERCLIP_TEST_STATE_PATH;
|
||||
const payload = {
|
||||
argv: process.argv.slice(2),
|
||||
prompt: fs.readFileSync(0, "utf8"),
|
||||
};
|
||||
if (capturePath) {
|
||||
const entries = fs.existsSync(capturePath) ? JSON.parse(fs.readFileSync(capturePath, "utf8")) : [];
|
||||
entries.push(payload);
|
||||
fs.writeFileSync(capturePath, JSON.stringify(entries), "utf8");
|
||||
}
|
||||
const resumed = process.argv.includes("--resume");
|
||||
const shouldFailResume = resumed && statePath && !fs.existsSync(statePath);
|
||||
if (shouldFailResume) {
|
||||
fs.writeFileSync(statePath, "retried", "utf8");
|
||||
console.log(JSON.stringify({
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
session_id: "claude-session-poisoned",
|
||||
is_error: true,
|
||||
result: "API Error: 400 diagnostics.previous_message_id: must be the \`id\` from a prior /v1/messages response (starts with \`msg_\`)",
|
||||
}));
|
||||
process.exit(1);
|
||||
}
|
||||
console.log(JSON.stringify({ type: "system", subtype: "init", session_id: "claude-session-new", model: "claude-sonnet" }));
|
||||
console.log(JSON.stringify({ type: "assistant", session_id: "claude-session-new", message: { content: [{ type: "text", text: "hello" }] } }));
|
||||
console.log(JSON.stringify({ type: "result", session_id: "claude-session-new", result: "hello", usage: { input_tokens: 1, cache_read_input_tokens: 0, output_tokens: 1 } }));
|
||||
`;
|
||||
await fs.writeFile(commandPath, script, "utf8");
|
||||
await fs.chmod(commandPath, 0o755);
|
||||
}
|
||||
|
||||
async function writeAlwaysPoisonedMessageIdClaudeCommand(commandPath: string): Promise<void> {
|
||||
const script = `#!/usr/bin/env node
|
||||
const fs = require("node:fs");
|
||||
|
||||
const capturePath = process.env.PAPERCLIP_TEST_CAPTURE_PATH;
|
||||
const payload = {
|
||||
argv: process.argv.slice(2),
|
||||
prompt: fs.readFileSync(0, "utf8"),
|
||||
};
|
||||
if (capturePath) {
|
||||
const entries = fs.existsSync(capturePath) ? JSON.parse(fs.readFileSync(capturePath, "utf8")) : [];
|
||||
entries.push(payload);
|
||||
fs.writeFileSync(capturePath, JSON.stringify(entries), "utf8");
|
||||
}
|
||||
// Both --resume and fresh attempts emit the poisoned previous_message_id result.
|
||||
// The fresh attempt still carries a session_id in the result; the adapter must
|
||||
// NOT persist it, otherwise the next continuation re-resumes a known-bad transcript.
|
||||
console.log(JSON.stringify({
|
||||
type: "result",
|
||||
subtype: "success",
|
||||
session_id: "claude-session-poisoned-fresh",
|
||||
is_error: true,
|
||||
result: "API Error: 400 diagnostics.previous_message_id: must be the \`id\` from a prior /v1/messages response (starts with \`msg_\`)",
|
||||
}));
|
||||
process.exit(1);
|
||||
`;
|
||||
await fs.writeFile(commandPath, script, "utf8");
|
||||
await fs.chmod(commandPath, 0o755);
|
||||
}
|
||||
|
||||
async function writeRetryThenSucceedClaudeCommand(commandPath: string): Promise<void> {
|
||||
const script = `#!/usr/bin/env node
|
||||
const fs = require("node:fs");
|
||||
@@ -1116,4 +1182,136 @@ describe("claude execute", () => {
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("auto-rotates session on previous_message_id 400 (synthetic-msg poisoning) and succeeds on retry", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-exec-poisoned-msgid-"));
|
||||
const { workspace, commandPath, capturePath, statePath, restore } = await setupExecuteEnv(root, {
|
||||
commandWriter: writePoisonedMessageIdClaudeCommand,
|
||||
});
|
||||
const logs: string[] = [];
|
||||
try {
|
||||
const result = await execute({
|
||||
runId: "run-poisoned-msgid",
|
||||
agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} },
|
||||
runtime: { sessionId: "claude-session-poisoned", sessionParams: null, sessionDisplayId: null, taskKey: null },
|
||||
config: {
|
||||
command: commandPath,
|
||||
cwd: workspace,
|
||||
env: {
|
||||
PAPERCLIP_TEST_CAPTURE_PATH: capturePath,
|
||||
PAPERCLIP_TEST_STATE_PATH: statePath,
|
||||
},
|
||||
promptTemplate: "Do work.",
|
||||
},
|
||||
context: {},
|
||||
authToken: "tok",
|
||||
onLog: async (_stream, chunk) => { logs.push(chunk); },
|
||||
});
|
||||
|
||||
const captured: Array<{ argv: string[] }> = JSON.parse(await fs.readFile(capturePath, "utf-8"));
|
||||
// First attempt resumes, second attempt starts fresh
|
||||
expect(captured).toHaveLength(2);
|
||||
expect(captured[0]?.argv).toContain("--resume");
|
||||
expect(captured[1]?.argv).not.toContain("--resume");
|
||||
// Result comes from the fresh retry
|
||||
expect(result.sessionId).toBe("claude-session-new");
|
||||
expect(result.errorCode).toBeNull();
|
||||
// Adapter logged the fallback reason
|
||||
expect(logs.some((l) => l.includes("poisoned message-id"))).toBe(true);
|
||||
} finally {
|
||||
restore();
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Regression for RED-978: the adapter must not persist a sessionId from a
|
||||
* run that ended with a poisoned previous_message_id error. Otherwise the
|
||||
* next continuation auto-resumes a known-bad transcript and Anthropic
|
||||
* /v1/messages returns 400 again, permanently stranding the issue.
|
||||
*/
|
||||
it("drops sessionId and forces clearSession when a fresh run reports a poisoned previous_message_id", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-exec-poisoned-fresh-"));
|
||||
const { workspace, commandPath, capturePath, restore } = await setupExecuteEnv(root, {
|
||||
commandWriter: writeAlwaysPoisonedMessageIdClaudeCommand,
|
||||
});
|
||||
try {
|
||||
const result = await execute({
|
||||
runId: "run-poisoned-fresh",
|
||||
agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} },
|
||||
runtime: { sessionId: null, sessionParams: null, sessionDisplayId: null, taskKey: null },
|
||||
config: {
|
||||
command: commandPath,
|
||||
cwd: workspace,
|
||||
env: { PAPERCLIP_TEST_CAPTURE_PATH: capturePath },
|
||||
promptTemplate: "Do work.",
|
||||
},
|
||||
context: {},
|
||||
authToken: "tok",
|
||||
onLog: async () => {},
|
||||
});
|
||||
|
||||
// The fake CLI emits a session_id in its poisoned result; the adapter
|
||||
// must not propagate it. The server uses clearSession=true to wipe
|
||||
// any previously-persisted session state for this issue/task.
|
||||
expect(result.sessionId).toBeNull();
|
||||
expect(result.sessionParams).toBeNull();
|
||||
expect(result.sessionDisplayId).toBeNull();
|
||||
expect(result.clearSession).toBe(true);
|
||||
expect(result.errorCode).toBe("claude_poisoned_previous_message_id");
|
||||
expect(result.errorMessage ?? "").toContain("previous_message_id");
|
||||
} finally {
|
||||
restore();
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Regression for RED-978: if the auto-retry after a poisoned resume *also*
|
||||
* fails with a poisoned previous_message_id, the adapter must still emit
|
||||
* clearSession=true so the next heartbeat starts from a clean transcript.
|
||||
* Before this fix, the retry result's session_id ("claude-session-poisoned-fresh")
|
||||
* was persisted and every subsequent continuation hit the same 400 again.
|
||||
*/
|
||||
it("forces clearSession when the recovery retry also reports a poisoned previous_message_id", async () => {
|
||||
const root = await fs.mkdtemp(path.join(os.tmpdir(), "paperclip-claude-exec-poisoned-retry-"));
|
||||
const { workspace, commandPath, capturePath, restore } = await setupExecuteEnv(root, {
|
||||
commandWriter: writeAlwaysPoisonedMessageIdClaudeCommand,
|
||||
});
|
||||
try {
|
||||
const result = await execute({
|
||||
runId: "run-poisoned-retry",
|
||||
agent: { id: "agent-1", companyId: "co-1", name: "Test", adapterType: "claude_local", adapterConfig: {} },
|
||||
runtime: {
|
||||
sessionId: "claude-session-already-poisoned",
|
||||
sessionParams: null,
|
||||
sessionDisplayId: null,
|
||||
taskKey: null,
|
||||
},
|
||||
config: {
|
||||
command: commandPath,
|
||||
cwd: workspace,
|
||||
env: { PAPERCLIP_TEST_CAPTURE_PATH: capturePath },
|
||||
promptTemplate: "Do work.",
|
||||
},
|
||||
context: {},
|
||||
authToken: "tok",
|
||||
onLog: async () => {},
|
||||
});
|
||||
|
||||
const captured: Array<{ argv: string[] }> = JSON.parse(await fs.readFile(capturePath, "utf-8"));
|
||||
// Resume attempt + fresh recovery attempt, both poisoned.
|
||||
expect(captured).toHaveLength(2);
|
||||
expect(captured[0]?.argv).toContain("--resume");
|
||||
expect(captured[1]?.argv).not.toContain("--resume");
|
||||
// Crucially: do NOT persist the retry's reported sessionId.
|
||||
expect(result.sessionId).toBeNull();
|
||||
expect(result.sessionParams).toBeNull();
|
||||
expect(result.clearSession).toBe(true);
|
||||
expect(result.errorCode).toBe("claude_poisoned_previous_message_id");
|
||||
} finally {
|
||||
restore();
|
||||
await fs.rm(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user