Fork recursion guard self-poisons the dispatching agent: first fork succeeds, all later forks blocked (v2.1.220) — 430M tokens burned in failed retries
Summary
The fork recursion guard described in #68233 is still present in v2.1.220 (that issue was auto-closed as stale, not fixed), and it has a second trigger that is worse than the documented one: it is self-inflicted.
A fork's own boilerplate lands in the dispatching agent's message history. Because the guard blind-substring-matches <fork-boilerplate across all user messages, the first successful fork permanently poisons its own parent. Every subsequent Agent({subagent_type:"fork"}) from that agent fails with:
Fork is not available inside a forked worker. Complete your task directly using your tools.
No user-supplied text, skill doc, or pasted transcript is required — normal, correct use of fork is sufficient to break fork for the rest of that agent's life.
The signature is exact: first fork succeeds, every later fork is blocked.
Because the resulting error reads as transient, agents retry it indefinitely. In the session documented below that burned 430 million tokens across 1,273 API calls — 37.9% of the session — and moved the account's usage-limit gauge by roughly ten percentage points in ninety minutes, with no work produced. See [Impact](#impact-on-token-usage-and-subscription-limits).
Evidence
From one real session (v2.1.220). Twenty top-level general-purpose subagents each split a set of large minified JS bundles into byte ranges and forked one worker per range. Ten of them hit the guard; the timestamp of their first successful fork is identical to the timestamp <fork-boilerplate first appears in their own user-message history:
agent 1st forkOK 1st boilerplate 1st BLOCK
--- poisoned (1 fork ok, then every fork blocked) ---
a0a9c89e4e0869 13:23:14 13:23:14 13:23:32
ab495cab686a84 13:23:06 13:23:06 13:23:25
a2db8c4ba742e1 13:23:32 13:23:32 13:23:52
a90591b6e7832a 13:23:23 13:23:23 13:24:03
a1fbb3eac5b947 13:23:56 13:23:56 13:24:12
ab07f69a489504 13:24:58 13:24:58 13:25:15
ab88cd804e18e0 13:25:12 13:25:12 13:26:03
a9e5b894fa2ca4 13:26:26 13:26:26 13:27:13
af43b66b7a9c74 13:49:31 13:49:31 13:51:07
aa8fffac7b311e 13:49:44 13:49:44 13:51:26
--- not poisoned (forked repeatedly, never blocked) ---
aa46dc18a042a2 13:22:49 None None
a41c8026e147f7 13:25:00 None None
af987b274b4782 13:48:40 None None
aedecccdd01c66 13:22:49 13:39:47 None <- boilerplate arrived AFTER its forks
abe1f81dda5706 13:25:00 13:35:40 None <- same
The control group is the useful half: agents whose boilerplate arrived after they were done forking were never blocked. That is the guard's substring match and nothing else.
Session totals: 334 nested fork dispatches — 141 succeeded, 130 blocked by this guard, 63 hit the (correct, clearly-worded) concurrency limit. Same call, same depth, same context, different outcome depending purely on whether the agent had forked before.
Impact on token usage and subscription limits
This is the part that matters. The guard does not fail cheaply — it converts a bounded fan-out into an unbounded retry loop against the most expensive thing in the product: a large inherited context.
The error text says Complete your task directly using your tools, but it is returned to an agent that successfully forked seconds earlier. The model therefore reads it as transient and retries. The guard is sticky, so every retry fails identically, and each retry is a full API turn that re-reads the agent's entire 300K–900K-token context.
Measured from the session above:
| | tokens | notes |
|---|---:|---|
| Whole session (98 min, 5,732 API calls) | 1,135,083,200 | 89.2% of it cache_read — context re-reads, not new work |
| Consumed after the guard first misfired, in the 10 poisoned agents | 430,303,575 | 37.9% of the session, across 1,273 API calls |
| ↳ of which cache_read | 381,784,378 | pure re-entry of context to retry a call that can never succeed |
| ↳ of which cache_creation | 46,302,620 | |
| ↳ new input + output | 2,216,577 | 0.5% — almost none of this spend produced work |
Weighted at standard multipliers (cache_read 0.1×, cache_creation 1.25×, output 5×), the post-misfire window is 102.9M input-token-equivalents — 34.1% of the entire session's billable weight, or roughly $309 at Sonnet rates for retries that were guaranteed to fail.
Effect on the usage limit: the operator noticed this only because their Claude Code usage-limit indicator jumped from ~10% to ~20% in about ninety minutes with no corresponding work completed. This single guard misfire consumed on the order of ten percentage points of a subscription usage limit, silently — there is no surfaced signal that an agent has entered a permanently-failing retry loop, and subagent token consumption is not visible from the parent session at all. The user's first indication was the limit gauge moving.
For scale: this one 98-minute session was 72% of that account's total token consumption across the preceding fourteen hours, and the hour containing the misfires was ~9× the surrounding hourly baseline.
Amplifying factor: of 366 dispatches in the session tree, only 100 were distinct tasks — 73% were retries of an already-dispatched task, one attempted 17 times.
Two changes would each independently cap the blast radius, regardless of whether the guard itself is fixed:
- Wording. The concurrency-limit error already gets this right — it ends with
Do not retry.The fork-guard error does not, and should, because the condition is permanent for that agent. This is a one-line change that would have prevented essentially all 430M tokens. - Loop protection. Nothing dedups or backs off identical repeated dispatches, so any sticky error becomes an unbounded spend loop. A repeated-identical-failure circuit breaker would bound this class of bug generally, not just this instance.
Reproduction
1. Start a top-level session.
2. Dispatch: Agent({subagent_type:"fork", description:"a", prompt:"echo one"})
-> succeeds
3. Dispatch: Agent({subagent_type:"fork", description:"b", prompt:"echo two"})
-> "Fork is not available inside a forked worker."
Step 2 is the only setup required. Confirmed on v2.1.220, macOS 15 (Darwin 25.5.0).
Root cause
#68233 already published the decompiled guard, and it explains this case exactly:
// z7H = "fork-boilerplate"
function $m_(messages) {
return messages.some(m => {
if (m.type !== "user") return false;
const content = m.message.content;
if (!Array.isArray(content)) return false;
return content.some(c => c.type === "text" && c.text.includes(`<${z7H}`));
});
}
if (z.options.querySource === `agent:builtin:${Zx.agentType}` || $m_(z.messages)) {
throw new gTH("Fork is not available inside a forked worker. Complete your task directly using your tools.");
}
$m_ scans every user message in the dispatching agent's history. The boilerplate injected into a child fork is visible in the parent's transcript, so the parent matches its own child's marker.
The querySource === "agent:builtin:fork" check on the left of the || is correct and sufficient on its own. The substring fallback is what breaks, and it now has two known false-positive paths:
- #68233: any user text that literally mentions the tag (skill docs, memory, pasted transcripts).
- this issue: a fork's own boilerplate, reflected into its parent — no user text needed.
Suggested fix
Same as #68233, plus one:
- Drop the substring fallback and trust
querySource, which is definitive. - If a structural check is still wanted, match only the first sidechain user turn of a fork rather than every user message.
- Scope the marker per-agent (
<fork-boilerplate agent="<id>">) so a parent cannot match a child's marker. - Independently: change the guard's error text to
Do not retry., matching the concurrency-limit error, so a sticky failure does not read as a transient one.
Environment
- Claude Code v2.1.220
- macOS 15 (Darwin 25.5.0), arm64
- Subagent model: Sonnet; orchestrator: Opus
CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTSat default (20)