fix: fork subagents recursed forever in the Claude Code export
A fork subagent's transcript opens with a copy of the parent turn that spawned it, its own Agent call included; folding that call re-entered the same fork until RecursionError, failing every daily sync since 2026-09-24. Skip a spawn call while its own subagent is being expanded, and strip the <fork-boilerplate> preamble. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LbnmGHnFqDjyhPcCg1SEfF
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
b6ce636891
commit
64068bb19b
@@ -6,6 +6,12 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/).
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
- **Every scheduled Claude Code sync since 2026-09-24 died with `RecursionError`.** Claude Code's `fork` subagents write a transcript that opens with a copy of the parent turn that spawned them — the fork's own `Agent` call included. `_extract_messages` folds a subagent inline whenever it meets its spawn call, so it met that copy inside the fork, folded the same fork again, and recursed until Python's limit. One fork anywhere in `~/.claude/projects` was enough to fail the whole provider; seven existed across three sessions, and the Codex half of the run, unaffected, hid the cause behind a generic exit 1.
|
||||||
|
|
||||||
|
The recursive pass now carries the spawn ids being expanded around it and skips a tool_use whose id is among them — the enclosing subagent block already stands for that call. Because the set accumulates, a longer cycle (A spawns B, whose transcript re-spawns A) stops too. The fork's `<fork-boilerplate>` preamble — generic worker rules the harness prepends — is now stripped with the other harness tags, so a fork block opens with its actual directive.
|
||||||
|
|
||||||
|
`TestSubagentFold.test_fork_containing_its_own_spawn_call` reproduces the on-disk shape (a `fork-context-ref` record, the copied spawn call, the boilerplate-wrapped directive) and fails with the original `RecursionError` against the unfixed code. Verified against the real archive: all 85 sessions normalize, the seven forks each render as one subagent block.
|
||||||
|
|
||||||
- **An expired Claude session key reported a raw JSON dump instead of how to fix it.** `_make_request` routed only **401** to the auth handler (`src/providers/base.py`), and claude.ai does not use 401 — an invalid or expired `sessionKey` comes back as `403 permission_error` with `details.error_code = account_session_invalid`. So the one message that names the cookie, its ~30-day lifetime and the DevTools path to refresh it could never fire for Claude. What the user got instead was the generic 4xx path: `HTTP 403 — error: {'type': 'permission_error', 'message': 'Invalid authorization'…}`, which reads like a permissions problem with the account and not like "your key expired, here is how to replace it."
|
- **An expired Claude session key reported a raw JSON dump instead of how to fix it.** `_make_request` routed only **401** to the auth handler (`src/providers/base.py`), and claude.ai does not use 401 — an invalid or expired `sessionKey` comes back as `403 permission_error` with `details.error_code = account_session_invalid`. So the one message that names the cookie, its ~30-day lifetime and the DevTools path to refresh it could never fire for Claude. What the user got instead was the generic 4xx path: `HTTP 403 — error: {'type': 'permission_error', 'message': 'Invalid authorization'…}`, which reads like a permissions problem with the account and not like "your key expired, here is how to replace it."
|
||||||
|
|
||||||
Measured live 2026-09-20 against `GET /api/organizations`: a valid key returns 200, while an expired key, a deliberately malformed key and **no cookie at all** return byte-identical 403s carrying that code — i.e. the API treats a dead session as an absent one. This is the same mistake as the ChatGPT media 403s below: assuming 403 means "forbidden" when the service uses it for "unauthenticated."
|
Measured live 2026-09-20 against `GET /api/organizations`: a valid key returns 200, while an expired key, a deliberately malformed key and **no cookie at all** return byte-identical 403s carrying that code — i.e. the API treats a dead session as an absent one. This is the same mistake as the ChatGPT media 403s below: assuming 403 means "forbidden" when the service uses it for "unauthenticated."
|
||||||
|
|||||||
@@ -118,7 +118,10 @@ _HARNESS_TAG_RE = re.compile(
|
|||||||
r"|<command-args>.*?</command-args>"
|
r"|<command-args>.*?</command-args>"
|
||||||
r"|<command-contents>.*?</command-contents>"
|
r"|<command-contents>.*?</command-contents>"
|
||||||
r"|<local-command-stdout>.*?</local-command-stdout>"
|
r"|<local-command-stdout>.*?</local-command-stdout>"
|
||||||
r"|<system-reminder>.*?</system-reminder>",
|
r"|<system-reminder>.*?</system-reminder>"
|
||||||
|
# A fork subagent's first user turn: generic worker rules ahead of the
|
||||||
|
# fork's actual "Your directive: …", which is kept.
|
||||||
|
r"|<fork-boilerplate>.*?</fork-boilerplate>",
|
||||||
re.DOTALL,
|
re.DOTALL,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -530,6 +533,7 @@ def _extract_messages(
|
|||||||
policy: str,
|
policy: str,
|
||||||
subagents: dict | None = None,
|
subagents: dict | None = None,
|
||||||
include_sidechain: bool = False,
|
include_sidechain: bool = False,
|
||||||
|
expanding: frozenset[str] = frozenset(),
|
||||||
) -> list[dict]:
|
) -> list[dict]:
|
||||||
"""Normalize Claude Code records into messages.
|
"""Normalize Claude Code records into messages.
|
||||||
|
|
||||||
@@ -540,6 +544,13 @@ def _extract_messages(
|
|||||||
those recursive subagent passes (subagent records are flagged
|
those recursive subagent passes (subagent records are flagged
|
||||||
``isSidechain``); the top-level pass keeps skipping sidechain records so a
|
``isSidechain``); the top-level pass keeps skipping sidechain records so a
|
||||||
subagent is never also emitted as a stray top-level turn.
|
subagent is never also emitted as a stray top-level turn.
|
||||||
|
|
||||||
|
``expanding`` holds the spawn ids of the subagents being folded around
|
||||||
|
this pass. A ``fork`` subagent's transcript opens with a copy of the parent
|
||||||
|
turn that spawned it, its own spawn call included; that copy is dropped,
|
||||||
|
since the enclosing subagent block already stands for it. Expanding it
|
||||||
|
again recursed without end (RecursionError, every daily sync from
|
||||||
|
2026-09-24).
|
||||||
"""
|
"""
|
||||||
subagents = subagents or {}
|
subagents = subagents or {}
|
||||||
messages: list[dict] = []
|
messages: list[dict] = []
|
||||||
@@ -620,6 +631,8 @@ def _extract_messages(
|
|||||||
elif item_type == "tool_use":
|
elif item_type == "tool_use":
|
||||||
name = item.get("name") or "tool"
|
name = item.get("name") or "tool"
|
||||||
tool_id = item.get("id")
|
tool_id = item.get("id")
|
||||||
|
if tool_id in expanding:
|
||||||
|
continue
|
||||||
if name in _SUBAGENT_TOOL_NAMES and tool_id in subagents:
|
if name in _SUBAGENT_TOOL_NAMES and tool_id in subagents:
|
||||||
# A Task/Agent spawn: fold its separate transcript inline
|
# A Task/Agent spawn: fold its separate transcript inline
|
||||||
# instead of collapsing it. Its own tool traffic is
|
# instead of collapsing it. Its own tool traffic is
|
||||||
@@ -633,6 +646,7 @@ def _extract_messages(
|
|||||||
policy,
|
policy,
|
||||||
subagents,
|
subagents,
|
||||||
include_sidechain=True,
|
include_sidechain=True,
|
||||||
|
expanding=expanding | {tool_id},
|
||||||
)
|
)
|
||||||
blocks.append(
|
blocks.append(
|
||||||
make_subagent_block(
|
make_subagent_block(
|
||||||
|
|||||||
@@ -370,6 +370,59 @@ class TestSubagentFold:
|
|||||||
]
|
]
|
||||||
assert "stray sidechain" not in top_text
|
assert "stray sidechain" not in top_text
|
||||||
|
|
||||||
|
def test_fork_containing_its_own_spawn_call(self, tmp_path):
|
||||||
|
# The shape Claude Code writes for a `fork` subagent: a context-ref
|
||||||
|
# record, a copy of the parent turn holding the fork's own spawn call,
|
||||||
|
# then the directive behind <fork-boilerplate>. Folding that copied
|
||||||
|
# call recursed until RecursionError.
|
||||||
|
fork_records = [
|
||||||
|
{"type": "fork-context-ref", "agentId": "a1", "parentLastUuid": "u0"},
|
||||||
|
{
|
||||||
|
"type": "assistant", "isSidechain": True,
|
||||||
|
"message": {"role": "assistant", "content": [
|
||||||
|
{"type": "tool_use", "id": "toolu_sub1", "name": "Agent",
|
||||||
|
"input": {"description": "research"}},
|
||||||
|
]},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "user", "isSidechain": True,
|
||||||
|
"timestamp": "2026-05-01T10:00:06.000Z",
|
||||||
|
"message": {"role": "user", "content": [
|
||||||
|
{"type": "tool_result", "tool_use_id": "toolu_sub1",
|
||||||
|
"content": [{"type": "text", "text": "Fork started"}]},
|
||||||
|
{"type": "text", "text":
|
||||||
|
"<fork-boilerplate>\nYou are a worker fork.\n"
|
||||||
|
"</fork-boilerplate>\n\nYour directive: research X"},
|
||||||
|
]},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"type": "assistant", "isSidechain": True,
|
||||||
|
"timestamp": "2026-05-01T10:00:07.000Z",
|
||||||
|
"message": {"role": "assistant", "content": [
|
||||||
|
{"type": "text", "text": "Fork result."},
|
||||||
|
]},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
f = _write_session(tmp_path, _parent_with_task(), name="parent-3")
|
||||||
|
_write_subagent(
|
||||||
|
f, "toolu_sub1", fork_records,
|
||||||
|
agent_type="fork", description="research X",
|
||||||
|
)
|
||||||
|
p = self._provider(tmp_path)
|
||||||
|
p.fetch_all_conversations()
|
||||||
|
conv = p.normalize_conversation(p.get_conversation("parent-3"))
|
||||||
|
|
||||||
|
subs = [
|
||||||
|
b for m in conv["messages"] for b in m["blocks"] if b["type"] == "subagent"
|
||||||
|
]
|
||||||
|
assert len(subs) == 1
|
||||||
|
inner = [bb for m in subs[0]["messages"] for bb in m["blocks"]]
|
||||||
|
assert not any(b["type"] == "subagent" for b in inner)
|
||||||
|
inner_text = [b.get("text") for b in inner]
|
||||||
|
assert "Your directive: research X" in inner_text
|
||||||
|
assert "Fork result." in inner_text
|
||||||
|
assert not any("worker fork" in (t or "") for t in inner_text)
|
||||||
|
|
||||||
|
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
# Repo tags in title (Part C)
|
# Repo tags in title (Part C)
|
||||||
|
|||||||
Reference in New Issue
Block a user