Wakeup-resumed turns intermittently serialize first tool call as literal <invoke> XML in text; with end_turn no retry is injected and ScheduleWakeup loops die silently
Version: Claude Code 2.1.174, macOS. Models observed: Claude Opus 4.8 (the affected session). Intermittent.
Summary
I run a long-lived autonomous polling loop (a worker session that ends every turn with ScheduleWakeup, per the documented scheduled-tasks pattern). Over a single ~24h session the loop silently died 4 times (gaps of 9h15m, 2h26m, 1h22m, 2h06m), each requiring a human message to recover. Transcript forensics on the session JSONL shows the scheduler is NOT at fault — a scheduled_task_fire event is present at every scheduled time, 4/4.
The actual failure: the wakeup-resumed turn intermittently emits its FIRST tool call into the text channel as literal XML — a single text content block like:
{"type":"text","text":"court\n<invoke name=\"Bash\">\n<parameter name=\"command\">ls .../STOP 2>/dev/null && ...</parameter>..."}
(note the stray court token prefix) — instead of a structured tool_use block. No tool runs.
The outcome then depends entirely on stop_reason:
| stop_reason on the malformed turn | host behavior | outcome (observed) |
|---|---|---|
| tool_use | host injects "Your tool call was malformed and could not be parsed. Please retry." | self-heals — 4/4 occurrences recovered |
| end_turn | host sees a clean text-only completion; no retry injected | silent loop death — 4/4 occurrences killed the loop |
Same malformed-XML artifact in all 8 turns; the only difference is the stop reason. So the host already HAS the right recovery (the malformed-call retry) — it just isn't triggered when the mis-serialized call coincides with end_turn.
Repro profile (intermittent — no deterministic repro)
- Long-lived session (~24h, ~10 multi-hour work units between idle stretches), autonomous loop via
ScheduleWakeup(now + 20 min)idle ticks. - 8 mis-serialized first-actions out of dozens of resumed turns; 4 coincided with
end_turnand died. - NOT correlated with compaction: the session's only compaction event postdates the last drop's recovery (0/4 drops follow one).
- Healthy resumed ticks show
first content part = tool_use, text=''.
Expected
Either (a) resumed turns never serialize tool calls into the text channel, or (b) the malformed-call retry also fires when a text-only end_turn completion contains <invoke name=-shaped content — i.e., the end_turn variant gets the same recovery the tool_use variant already gets.
Impact
Any autonomous-loop pattern built on ScheduleWakeup (the documented scheduled-tasks/worker pattern) silently halts until a human notices — in our case up to 9h15m. Idempotent disk state made recovery free, but detection was purely human.
Workarounds we use
- External "safety tick": a cron/second-terminal process that re-enters the loop independently of the in-session turn (idempotent state machine makes redundant entry safe).
- Candidate in-band: a Stop hook that blocks
end_turncompletions whose text contains<invoke name=and asks for a retry — mirrors the host's owntool_use-variant recovery.
Transcript excerpts (timestamps, event subtypes, the 8 malformed turns with stop reasons) available — extracted from ~/.claude/projects/<project>/e33217e6-*.jsonl. Happy to share sanitized excerpts.
Relationship to known issues (added after filing)
The underlying mis-serialization is the known claude-opus-4-8 boundary-token corruption family — #49747 (suspected root), #66888, #63870, #66809 (stray short-word token + literal <invoke> XML in the text channel). This report is NOT re-reporting that artifact. What it adds:
- The autonomous-loop death mode. When the malformed call is the FIRST action of a ScheduleWakeup-resumed turn and coincides with
stop_reason: end_turn, the loop dies silently — observed gaps up to 9h15m. Prior reports in the family describe no-ops or false progress in attended sessions; an unattended loop has no human in the turn to notice. - Empirical evidence for the client-side fix #63870 hypothesized. In the same session, the identical artifact with
stop_reason: tool_usetriggered the harness's existing "tool call was malformed, please retry" injection and self-healed 4/4 times; withend_turnit was treated as a clean text completion and died 4/4 times. So extending the existing retry toend_turncompletions whose text contains<invoke name=is a complete client-side mitigation for the loop-death consequence, independent of the model-side fix.
Per #66888's cross-model data (Sonnet 4.6: zero failures), this also matches our experience: all 4 drops were an Opus 4.8 session; our Sonnet/Haiku loop sessions never exhibited it.
This issue has 2 comments on GitHub. Read the full discussion on GitHub ↗