fix(claude): re-send a prompt the backend consumed without running it - #327
Merged
Conversation
A zero-turn result with NO `<local-command-stderr>` is the unexplained variant: the SDK reports `subtype: "success"`, the model never ran, and nothing says why. The prompt was then simply lost and the user retyped it. Closes #326. Distinct from #233, and the error text already distinguishes them: the provider prints the captured stderr when there is one, and falls back to "No cause was reported by the backend" only when it is null. #233 (and upstream anthropics/claude-code#80223) is a blocked skill-frontmatter expansion — it HAS a cause, and `#tryHandleSkillBlock` parks it for approval. This one has no cause, so nothing parked it and nothing retried it. Both occurrences in local transcripts share one signature: the first prompt after a long idle gap (12h47m and 59m34s), with a fresh `system:init` landing between the prompt and the error ~2s later. Reading: the SDK loop went cold during the idle, the next send rebuilt it, and the first prompt into the rebuilt loop was swallowed. Why the loop goes cold is NOT established — codeoid does not tear it down on idle, and there is no production log trace since provider lifecycle events go to stdout only. The fix does not need that answer. It keys on `num_turns === 0` — a typed field carrying correct data — so it recovers whatever produced the zero turn and does not depend on the upstream reporting defect being fixed. Re-sending is safe BECAUSE it is a zero-turn: no assistant turn ran, so no tool executed and there is no side effect to duplicate. That is what separates this from a normal failed turn, which must never be silently repeated. Scoped tightly: only when no stderr explains it (re-sending a real denial would just be denied again), and only once per turn — the guard resets in runTurn() so each new prompt gets its own single attempt, rather than one per session. A visible info message means the retry is never silent. The machinery already existed: `#lastPushedContent`, `#ensureQueryLoop`, and the re-push pattern from `#retryAfterGrant`. The interception seam in `#translateSDKMessage` already handled the explained zero-turn; this adds the symmetric branch. Five tests cover it, and were confirmed to catch the regression — disabling the branch fails three of them. They assert the re-send, the one-attempt ceiling, that a normal multi-turn result is untouched, that a zero-turn WITH a cause still surfaces that cause rather than being silently retried, and that the guard is per-turn rather than per-session. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
🔮 Oracle Review
🎯 Start Here
src/daemon/providers/claude/index.ts (~15 min) — Logic changes in index.ts
📋 PR Summary
What this PR does: fix(claude): re-send a prompt the backend consumed without running it
🔍 Code Review
Review completed.
Generated by Oracle - Highflame's AI Code Reviewer
Oracle review on #327: recovery logged when it started but not when it stopped. The user always saw the failure either way — falling through emits the real turn_done error — but the operator could not tell "the re-send ran and also came back empty" from "never retried at all". Those point at different problems: a wedged backend versus a one-off swallow. Both early returns now say which one happened, and the existing line reuses the same tag. No test asserts the log text: the behaviour it describes (a second zero-turn surfaces as a turn error) is already covered, and pinning console output would buy nothing but a brittle assertion. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
rsharath
approved these changes
Sep 6, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #326.
The bug
A zero-turn result with no
<local-command-stderr>is the unexplained variant: the SDK reportssubtype: "success", the model never ran, and nothing says why. The prompt was then simply lost — you retype it.Not #233 — and the error text already proves which is which
The provider prints the captured stderr when there is one, and falls back to "No cause was reported by the backend" only when it is
null.#tryHandleSkillBlockparks for approvalEvidence
Both occurrences in local transcripts share one signature — first prompt after a long idle gap, fresh
system:initbetween prompt and error:6f59c413MCP ready, error 2ms latercfe05fcdReading: the loop went cold during the idle, the next send rebuilt it, and the first prompt into the rebuilt loop was swallowed.
Honest limit: why the loop goes cold is not established. codeoid does not tear it down on idle; the memory-index oscillation path is already fixed via
#frozenMemoryIndex; the onlysystemPromptAppend changedlogs on disk are from test runs, since provider lifecycle events go to stdout only. n=2.Why the fix doesn't need that answer
It keys on
num_turns === 0— a typed field carrying correct data — so it recovers whatever produced the zero turn, and works whether or not #80223 is ever fixed.Re-sending is safe because it is a zero-turn: no assistant turn ran, so no tool executed and there is no side effect to duplicate. That is exactly what separates this from a normal failed turn, which must never be silently repeated.
Scoped tightly:
runTurn()so each new prompt gets its own attempt rather than one per session;infomessage, so the retry is never silent.The machinery already existed (
#lastPushedContent,#ensureQueryLoop, the re-push from#retryAfterGrant).#translateSDKMessagealready intercepted the explained zero-turn; this adds the symmetric branch.Tests
Five, in
provider-claude.test.ts, driven through the existing mocked-SDK harness:Confirmed to catch the regression — disabling the branch fails 3 of the 5.
Full suite: 2453 pass / 0 fail; typecheck and biome clean. One run showed
1 fail / 5 errors, which is a bun runner race (Cannot call describe() after the test run has completed) with the same signature as a baseline run taken before this change — not attributable here, and clean on re-run.Follow-up (not in this PR)
Provider lifecycle events (
rebuilding query loop, etc.) are stdout-only. Persisting them into the transcript would let the next occurrence diagnose itself instead of requiring transcript archaeology.🤖 Generated with Claude Code