Skip to content

Critical bug: async subagent completions fork the chat tree onto stale branches, strand user turns on invisible side branches, and can leave a zombie turn that blocks all future completions #298

Description

@Classic298

Two related defects in how background subagent completions are attached to the chat. Both reproduced on 0.9.21 with the installed code, both confirmed against a real chat database after a live incident. Related to #296 (send-side fork) but a different code path: this is the injection side.

What happens

When a background subagent (delegate_task with background) finishes while one of your own turns is still running, its completion is queued as a pending internal message. The queue entry is attached to the last done assistant message at injection time. That message is an ancestor of your still-running turn, not its leaf. The entry is therefore born on a sibling branch that you never created.

When your turn later finishes or pauses, the pending queue is drained: the queued entry becomes a real user message plus a new assistant placeholder on that sibling branch, and the chat's current-message pointer is moved to it. Your own last turn is now on a dead side branch. The UI shows one path, the database contains a fork you did not create, and every follow-up message you send chains onto the fork because the frontend sends the chat's current pointer as the parent.

What I observed in a live chat

Three forks created by three subagent completions, each attached to the last done assistant while my own turn was in flight:

  1. My message sent while a background review subagent ran: it landed as a sibling of a pending round-6 completion, both attached to the round-5 reply. Two children of the same parent, neither created by regenerate or any deliberate fork action.
  2. My next message chained onto the round-6 injection's reply (the frontend used the chat pointer, which the drain had already repointed to the fork). The conversation continued on the fork; my earlier question and its reply sat on a dead branch.
  3. A later completion (round 7) attached to the reply of "my messages are gone" while the answer to my actual follow-up was still running. Same pattern again one round later (round 8): attached to the second-to-last done reply, skipping the branch holding my then-current exchange.

Net effect mid-conversation: replies arrive to messages you cannot see on your current path, your own turns look like they were never answered, and the tree keeps forking with every background completion that lands during a running turn.

The stranding detail

In the live incident one turn's tool call was denied by the auto-reviewer, so the turn paused in the approval-required state: done stays false and content stays empty. The drain fired 45ms later and repointed the chat to the fork, which took the approval prompt with it. The paused turn stayed paused forever. Because the injection path treats any not-done assistant as "chat is active", that zombie now makes every subsequent background completion queue as pending with no reply until some later drain, so one stranding poisons all future injections in that chat. That state is in my database right now: a turn from the incident is still not-done with a pending tool call from hours ago, and the most recent subagent completion is sitting unprocessed because of it.

Why it happens

The injection parent is computed as the last done assistant message. During a running turn that is an ancestor, not the leaf. The queue-drain then always moves the chat pointer to the injected branch, regardless of whether your branch's turn is still live or paused on approval. And the queue-vs-branch decision for completions uses the done flags of messages, never the server-side running-task state of the chat. It is the same root pattern as #296 from the other direction: #296 lets a user message become a fork because a stream died, this lets a system message become a fork because your turn is alive.

Reproduction

I reproduced this with the installed cptr modules against a scratch database: a chat with one done exchange, a second user turn whose assistant placeholder is not done (turn running), then a subagent completion injected while that turn runs. The completion lands as a sibling of the running turn's parent, with pending status:

--- after subagent completion injection (turn 2 still running)
  ... user      parent=<asst1> done=1  'turn two'
  ... assistant parent=<user2> done=0  ''
  ... user      parent=<asst1> done=1 PENDING '[ASYNC SUBAGENT COMPLETE - deleg_repro]'

asst1 now has two children. When turn 2 ends and the queue drains, the pending copy becomes a real internal message on the fork. Nobody created that fork: not regenerate, not an explicit branch, the injection path did it on its own.

The same happens with the turn paused on a denied approval instead of ended: the drain still runs and still repoints the chat pointer, so the approval UI for your branch disappears and the paused turn never resumes.

Consequences

  • Background completions silently create branches the user never asked for.
  • User turns get stranded on invisible branches; the UI shows the conversation continuing normally.
  • A paused approval turn becomes permanently unreachable and, because it never becomes done, every future background completion in that chat piles up unprocessed.
  • In tool-enabled chats the injected branch runs model turns with tool access, so a fork nobody created can edit files while you look at a different branch.
  • The exported chat JSON contains the fork, so the corruption is durable and travels with the chat.

Fix direction

Two independent corrections:

  1. Attach a queued completion to the leaf of the chat's current branch (walk up from the chat's current message id), so it resumes in place after the running turn instead of forking. If the current branch's task is active, the entry belongs behind it, not beside it.
  2. Never move the chat pointer to the injected branch while the current branch has a running or approval-paused task. And consult the server-side task state, not only message done flags, both when deciding whether the completion needs queueing and when deciding whether the chat is idle enough to repoint.

Additionally the startup reconcile pass only heals not-done turns with no running task. It should also detect approval-paused turns whose branch is no longer the chat's current path, mark them done or surface them, otherwise one incident permanently blocks background completions in that chat.

Workaround for now

None that fixes it. Avoid background subagents in tool-heavy chats where turns run long, and if a turn stalls on an approval, cancel it before sending anything. If a chat already has a stranding zombie, the not-done assistant row keeps poisoning completions until it is marked done manually in the database.

Activity

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

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions