Skip to content

claude-code: a /clear turn never settles — the thread stays active until some later turn completes #1431

Description

@jerrison

bb 0.37.0 · provider claude-code · Claude Code CLI 2.1.226 · macOS 26.5.2.
Also seen on 0.36.0 and 0.35.1 / CLI 2.1.225. Source references are against
b5d89f3.
The CLI probe below was run on 2.1.229.

What I was trying to do

Send /clear to a thread and have the thread go back to idle afterwards, so a
script could clear a worker thread and carry on.

What happens

The clear itself succeeds, but the thread never leaves active. It sits there
indefinitely, showing as working when nothing is running. The turn produces no
turn/started and no turn/completed:

11  turn/completed         <- the previous, ordinary turn
12  client/turn/requested  {"source":"tell","input":[{"type":"text","text":"/clear"}]}
13  provider/unhandled     {"rawType":"sdk/conversation_reset", …}
14  thread/identity        {"providerThreadId":"3886608c-…"}
                           <- nothing further; status stays "active"

It settles only when something else closes the turn. An ordinary later turn
does it. So does any unrelated interrupt, which is the part that makes this hard
to see: whether one arrives is chance.

What I expected

status back to idle once the CLI reports the run finished — and it does
report it. Driving the CLI directly shows the whole sequence:

echo '{"type":"user","message":{"role":"user","content":"/clear"}}' \
  | claude --print --input-format stream-json --output-format stream-json --verbose
conversation_reset  {}
system              {"subtype":"init"}
result              {"subtype":"success","num_turns":0,"is_error":false,"result":""}

It is a zero-work run — the command resolves before any model call — but the
provider still closes it.

Impact

bb thread wait --status idle never returns, so any script that clears a thread
and waits will hang. It exits 2 with Error: Timed out waiting for thread <id> to reach status idle. if you bound it, and blocks for the default 1200s if you do
not. Anything keyed on the idle transition does not fire for these turns either.

It also costs an interactive user the whole thread, under a message that names
the wrong cause. One of my threads held a turn open for 14h 26m after a
/clear, and the next event was not a settle:

2206  Aug 11 17:35:43  client/turn/requested       {"input":[{"text":"/clear"}]}
2207  Aug 11 17:35:44  provider/unhandled          {"rawType":"sdk/conversation_reset", …}
2208  Aug 11 17:35:44  thread/identity             {"providerThreadId":"c7ccfbd2-…"}
                                                   <- nothing for 14h 26m
2209  Aug 12 08:02:10  system/error                {"code":"thread_command_failed",
                                                    "message":"Thread interrupted because
                                                     the host daemon disconnected",
                                                    "detail":"Please retry the thread to continue."}
2210  Aug 12 08:02:10  system/thread/interrupted   {"reason":"host-daemon-restarted"}

Three things that adds:

  • The error names the wrong cause. The daemon restart did not break the
    thread. It is what finally closed the turn the /clear left open 14 hours
    earlier, and it reported itself as the cause. A user reading "the host daemon
    disconnected" goes looking at their network or their machine sleeping. I only
    found the real cause by reading the event log.
  • A queued message never drains. I typed hi five seconds after the clear.
    bb queued it correctly, behind a turn it believed was running. Queued messages
    drain on turn completion, and this turn ended as an interrupt, so the queue
    held. bb thread queue delete was the way out — findable only once you know
    the queue is what holds it.
  • It survived an upgrade. That clear was on 0.36.0. bb replaced itself with
    0.37.0 six hours before the daemon restart and the open turn came through
    untouched.

The same thread settled an identical /clear four days earlier, but only by
luck: a trailing interrupt from a stop I had already requested happened to land
one second later. That thread has now taken three clears, across 0.35.1, 0.36.0
and 0.37.0, and settled none of them itself.

Why it happens

That result reaches bb and is dropped.
packages/agent-runtime/src/claude-code/translate-message.ts gates the whole
result handler on there already being a turn to complete:

    case "result": {
      // …
      if (state.currentTurnId) {
        // …
        events.push({ type: "turn/completed", /* … */ });
        args.turnState.finishTurn({ state, threadId: stateKey });
      }
      break;
    }

currentTurnId is set by ensureTurnStarted, which the adapter calls when it
translates an assistant message, a stream event, a tool call, or a compaction
status. A /clear produces none of those, so the one message that would have
closed the turn takes the empty branch.

From there the domain behaves exactly as written. In
packages/domain/src/thread-lifecycle.ts, active has three exits:

  active: {
    "run.succeeded": "idle",
    "run.failed": "error",
    "stop.requested": "stopping",
  },

turn/completed is what becomes run.succeeded
(apps/server/src/internal/turn-completed-events.ts). None was emitted, so
there is no transition to take, and the table's own note applies: "Absent cell
= the event is a no-op in that status."
Nothing reconciles it later either —
system/provider-turn-watchdog is marked "Legacy persisted watchdog
diagnostic; retained for read/decode/render only, with no current producer"

(packages/domain/src/thread-events.ts).

The same missing onTurnStart explains the queue symptom, since accepted input
is drained there. The thread above carries four turn/input/accepted events and
not one for either of its clears.

Reproduction

bb thread spawn --project <id> --model claude-haiku-4-5-20251001 \
  --prompt "Reply with only READY"
bb thread wait <thread-id> --status idle

bb thread tell <thread-id> "/clear"
bb thread wait <thread-id> --status idle --timeout 60
# -> times out; `bb thread show <thread-id>` reports "active" indefinitely

bb thread tell <thread-id> "hello"       # any ordinary turn settles it
bb thread stop <thread-id>               # or this: same effect, no model call

I also have a scripted case that asserts the absence of turn/started and
turn/completed rather than just the status, and bounds its wait so it fails
fast against a bb that has fixed this. It lives in a plugin of mine that is not
public yet; happy to share it if that is useful.

A fix

A PR follows this issue. It settles the turn the way the synthetic no-response
message already does one case earlier in the same file, gated on
pendingAcceptedUserMessages so that a result whose turn has already closed
still emits nothing. @bb/agent-runtime is green at 918 tests on b5d89f3, and
the new test fails without the change.

Metadata

Metadata

Assignees

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