From 64068bb19b2de4835d7489d5a797548ae1c42d5f Mon Sep 17 00:00:00 2001 From: JesseMarkowitz Date: Mon, 5 Oct 2026 07:14:16 -0400 Subject: [PATCH] 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 preamble. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01LbnmGHnFqDjyhPcCg1SEfF --- CHANGELOG.md | 6 ++++ src/providers/claude_code.py | 16 ++++++++++- tests/test_claude_code.py | 53 ++++++++++++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 9e896e3..53191ce 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,12 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). ## [Unreleased] ### 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 `` 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." 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." diff --git a/src/providers/claude_code.py b/src/providers/claude_code.py index 36fe315..c97d233 100644 --- a/src/providers/claude_code.py +++ b/src/providers/claude_code.py @@ -118,7 +118,10 @@ _HARNESS_TAG_RE = re.compile( r"|.*?" r"|.*?" r"|.*?" - r"|.*?", + r"|.*?" + # A fork subagent's first user turn: generic worker rules ahead of the + # fork's actual "Your directive: …", which is kept. + r"|.*?", re.DOTALL, ) @@ -530,6 +533,7 @@ def _extract_messages( policy: str, subagents: dict | None = None, include_sidechain: bool = False, + expanding: frozenset[str] = frozenset(), ) -> list[dict]: """Normalize Claude Code records into messages. @@ -540,6 +544,13 @@ def _extract_messages( those recursive subagent passes (subagent records are flagged ``isSidechain``); the top-level pass keeps skipping sidechain records so a 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 {} messages: list[dict] = [] @@ -620,6 +631,8 @@ def _extract_messages( elif item_type == "tool_use": name = item.get("name") or "tool" tool_id = item.get("id") + if tool_id in expanding: + continue if name in _SUBAGENT_TOOL_NAMES and tool_id in subagents: # A Task/Agent spawn: fold its separate transcript inline # instead of collapsing it. Its own tool traffic is @@ -633,6 +646,7 @@ def _extract_messages( policy, subagents, include_sidechain=True, + expanding=expanding | {tool_id}, ) blocks.append( make_subagent_block( diff --git a/tests/test_claude_code.py b/tests/test_claude_code.py index 6226b69..08fbc3d 100644 --- a/tests/test_claude_code.py +++ b/tests/test_claude_code.py @@ -370,6 +370,59 @@ class TestSubagentFold: ] 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 . 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": + "\nYou are a worker fork.\n" + "\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)