Skip to content

Session resume after 403 retry breaks parentUuid chain, losing entire conversation history #37437

Closed as not planned

Description

@Loong0x00

Bug Description

When an API 403 error triggers automatic retries, the retry mechanism re-sends the user message with an incorrect parentUuid, pointing to a subagent's progress message instead of the main conversation chain. Upon session resume, the conversation tree is rebuilt from this broken branch, causing all prior conversation history to be permanently lost from context.

This is not context compression — the JSONL file retains the full history, but the model receives none of it because the parent chain is severed.

Reproduction Steps

  1. Have a long-running conversation session with subagents (progress messages present)
  2. Send a message that triggers a 403 API error ({"error":{"type":"forbidden","message":"Request not allowed"}})
  3. Claude Code automatically retries the same message multiple times
  4. Close the terminal window and reopen / resume the session
  5. The model now only sees the last message — all prior history is gone

Root Cause Analysis

From the JSONL file (a6de1f0c-178d-4085-9fd0-0f7abf13cc70.jsonl, ~53K lines):

First send (correct parent):

L53699 [system/turn_duration] uuid=9ef20ec5-101..
L53701 [user] parentUuid=9ef20ec5-101.. uuid=de92f26a-e76..  ← correct, points to main chain
L53702 [assistant] 403 error

Retries (broken parent):

L53703-53734 [progress] ← subagent progress messages, chain ends at uuid=558d5e73-82e..
L53736 [user] parentUuid=558d5e73-82e.. ← WRONG! Points to progress msg, not main chain
L53737 [assistant] 403 error
...
L53745 [user] parentUuid=558d5e73-82e.. ← same broken parent, retry #3
L53746 [assistant] 403 error
...
L53754 [user] parentUuid=558d5e73-82e.. ← retry #4, finally succeeds
L53756 [assistant] normal response

The retries all use parentUuid=558d5e73-82e.. which is a progress message from a subagent sidechain, not the main conversation's turn_duration system message (9ef20ec5-101..).

When the session is resumed, the conversation tree is walked from L53754 backwards via parentUuid, which goes into the progress sidechain and never reaches the actual conversation history.

Expected Behavior

  • Retries after 403 should preserve the original parentUuid from the first (failed) attempt
  • The parent should always point to the main conversation chain, never to progress/sidechain messages
  • Session resume should not lose conversation history due to retry-induced parent chain corruption

Impact

  • Severity: Data loss — entire conversation context silently disappears
  • User-visible: After resume, the model has no memory of prior conversation (not compression, complete loss)
  • JSONL intact: The full history exists in the JSONL file but is unreachable via the broken parent chain

Environment

  • Claude Code CLI (latest as of 2026-03-22)
  • Model: claude-opus-4-6
  • OS: Arch Linux
  • Terminal: Ghostty

Activity

  1. Loong0x00 commented on Mar 22, 2026

    @Loong0x00
    Author

    Additional Finding: No subagent was active when this occurred

    Upon further investigation, the user did not have any subagents running at the time of the bug. There was only a single main conversation window.

    The progress messages (L53703-53734) that corrupted the parentUuid chain were delayed writes from the previous conversation turn's tool calls — they were flushed into the JSONL after the turn_duration system message (L53699) had already marked that turn as complete.

    Revised timeline:

    1. L53699 — turn_duration written, marking the previous turn as "done"
    2. L53701 — User sends new message (correct parentUuid pointing to L53699)
    3. L53702 — API returns 403
    4. L53703-53734 — Previous turn's tool call results (progress type, containing web search results, file reads, etc.) are asynchronously flushed into the JSONL after their parent turn already ended
    5. L53736 — Retry picks up the last JSONL entry (a stale progress message) as parentUuid → chain broken

    Implications

    This means the bug is not limited to users who explicitly use subagents. Any conversation where internal tool calls (which are implemented as subagents under the hood) have async I/O can trigger this. The turn_duration marker does not await all pending writes before being emitted, so there is a window where stale progress messages can land between the turn boundary and the next user message.

    This significantly increases the blast radius — it can affect any session that hits a 403 during retry while prior tool-call results are still being flushed.

  2. github-actions commented on Mar 22, 2026

    @github-actions

    Found 3 possible duplicate issues:

    1. [BUG] Session resume loses 80%+ of conversation — progress entries create parasitic forks in parentUuid chain #35024
    2. Session resume loses conversation messages when SubAgent progress chain overtakes main conversation chain #33651
    3. Conversation history missing on resume (except last message) #24304

    This issue will be automatically closed as a duplicate in 3 days.

    • If your issue is a duplicate, please close it and 👍 the existing issue instead
    • To prevent auto-closure, add a comment or 👎 this comment

    🤖 Generated with Claude Code

  3. Loong0x00 commented on Mar 22, 2026

    @Loong0x00
    Author

    This is not a duplicate of the linked issues, though it shares the same root cause family.

    This issue adds a distinct trigger and deeper root cause analysis:

    1. 403 API error retry — the retries pick up the wrong parentUuid (last JSONL entry instead of the original parent), which none of the other issues document
    2. No subagent was active — the stale progress messages came from async-flushed tool call results from a previous turn, proving this affects all sessions with tool use, not just subagent sessions
    3. The turn_duration marker doesn't await pending writes — this is the architectural root cause that enables all variants of this bug

    The fix for all four issues is the same: the parentUuid chain walker needs to either (a) treat progress entries as sidechain, or (b) retry logic must preserve the original parentUuid. Both should probably be done.

    Requesting this not be auto-closed as duplicate — the trigger path and root cause detail here are complementary to the existing reports.

  4. Loong0x00 commented on Mar 22, 2026

    @Loong0x00
    Author

    Source Code Analysis — Root Cause Found

    Decompiled the CC binary (2.1.81). The bug is in the tip-finding function used during session resume.

    The tip selector (ug$ in minified source):

    function findTip(messages, filter) {
      let tip, maxTime = -Infinity;
      for (let msg of messages) {
        if (!filter(msg)) continue;
        let t = Date.parse(msg.timestamp);
        if (t > maxTime) { maxTime = t; tip = msg; }
      }
      return tip;
    }

    This selects the message with the latest timestamp that passes the filter.

    The call site in giH (current session loader):

    let tip = findTip(messages.values(), (msg) => !msg.isSidechain);

    Filter: !isSidechain. That's it.

    The problem:

    progress messages have isSidechain: false, so they pass the filter. When progress messages are written asynchronously (e.g., from tool calls in the previous turn), their timestamps are later than the last real conversation message. The tip selector picks the progress message as the chain tip, then walkChain traces back via parentUuid through the progress branch, and the entire main conversation chain becomes unreachable.

    The fix is trivial:

    // Option A: filter by message type
    let tip = findTip(messages.values(), (msg) => 
      !msg.isSidechain && (msg.type === "user" || msg.type === "assistant")
    );
    
    // Option B: mark progress as sidechain when writing
    progressEntry.isSidechain = true;

    Either fix is a one-line change. Both should probably be applied.

    Why this wasn't caught:

    The SH_ (resume from file) and Yb (session list) code paths use leafUuids set for filtering, which may exclude progress messages depending on how leafUuids is populated. But giH (the live session loader) uses only the !isSidechain check — meaning this bug specifically affects live session resume (close window → reopen), not cold resume from file.

    This bug affects every session that has tool calls and gets resumed, not just subagent sessions or 403 retry scenarios. The 403 in my case just made the timing window wider, making it easier to trigger.

    This is the same root cause behind #35024, #33651, and #24304. One-line fix, open for 6+ weeks.

  5. junaidtitan commented on Mar 22, 2026

    @junaidtitan

    We open-sourced cozempic which addresses part of this — it uses atomic writes (write→fsync→replace) so session files can't be partially corrupted during resume, and it never touches uuid/parentUuid fields so the conversation DAG stays intact. Feedback welcome.

  6. github-actions commented on Apr 21, 2026

    @github-actions

    Closing for now — inactive for too long. Please open a new issue if this is still relevant.

  7. github-actions commented on Apr 30, 2026

    @github-actions

    This issue has been automatically locked since it was closed and has not had any activity for 7 days. If you're experiencing a similar issue, please file a new issue and reference this one if it's relevant.

  8. locked as resolved and limited conversation to collaborators on Apr 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:corebugSomething isn't workingdata-losshas reproHas detailed reproduction stepsplatform:linuxIssue specifically occurs on LinuxstaleIssue is inactive

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions