pingdotgg / pingdotgg/t3code

[Bug]: Claude provider switch hangs on stale session shutdown and accepts late events

Open
#4,944 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
TypeScript
Stars
23k
Forks
5.9k
Avg merge
11h 14m
Merged PRs (30d)
357

Description

Before submitting

  • I searched existing issues and did not find a duplicate.
  • I included enough detail to reproduce or investigate the problem.

Area

apps/server

Steps to reproduce

  1. Configure two enabled Claude provider instances, A and B, that use the same
    claudeAgent driver and compatible continuation identity.
  2. Start a thread on A and keep its native Claude query active.
  3. Make A's upstream return a retryable gateway error long enough for the
    Claude runtime to remain alive and retrying.
  4. Select B in the same thread and send a continuation message.
  5. Observe that B's adapter starts, but no provider turn or model request is
    sent to B while T3 waits for A to stop. A second message remains pending.
  6. If A emits a late session lifecycle event during this interval, observe
    that the projected thread session can switch back from B to A.

A deterministic regression test should use a fake A adapter whose
stopSession() does not resolve, plus a B adapter that starts immediately. A
separate test can emit an A session.state.changed event after replacement has
started.

Expected behavior

Provider replacement should be bounded and atomic. T3 should either complete
the switch and send the pending turn to B once, or fail it promptly with an
actionable error. Events from the replaced A session must not overwrite the
new session projection, and T3 must not leave both provider sessions active for
the same thread indefinitely.

Actual behavior

The switch can hang before sendTurn even though the target adapter has
already started. In one observed v0.0.30 trace:

adapter(B).startSession                         2 ms  success
ProviderService.startSession(B)          732,359 ms
  stopStaleSessionsForThread             732,354 ms
    adapter(A).stopSession                732,354 ms
      ClaudeAdapter.stopSessionInternal   732,354 ms
ProviderService.sendTurn(B)         not called until A shutdown unblocked

The first model request through B occurred only after the stale A runtime was
terminated, approximately 12 minutes after B had started. During the blocked
interval, late lifecycle events from A were still accepted and projected the
thread session back to A, while the selected provider remained B. The pending
message therefore appeared to do nothing.

The external gateway failure is only the trigger. The T3 failure is that the
replacement transaction waits without a deadline and accepts lifecycle events
from the provider instance being replaced.

This behavior is still present on current main (00da6b5):

  • ProviderService.startSession
    starts B, then awaits every stale adapter's stopSession() without a timeout,
    and only afterward persists the B binding.
  • ClaudeAdapter.stopSessionInternal
    synchronously awaits context.query.close() without a timeout or forced
    termination fallback.
  • ProviderRuntimeIngestion
    applies session lifecycle events using the source instance id, but does not
    reject an event whose instance is no longer the current binding or switch
    generation.

Suggested regression coverage:

  1. A stale adapter whose stopSession() never resolves cannot block a provider
    switch indefinitely.
  2. A late lifecycle event from A cannot overwrite B after replacement begins
    or commits.
  3. The pending turn is delivered to B exactly once, and the failed/replaced B
    session is cleaned up if the switch cannot commit.

Impact

Blocks work completely

Version or commit

T3 Code v0.0.30; reproduced in source on main @ 00da6b5

Environment

macOS, T3 Desktop v0.0.30, Claude Code 2.1.220, two compatible claudeAgent
instances behind a loopback inference gateway

Logs or stack traces

# Sanitized trace timing; provider names, thread ids, paths, and process ids removed.
startSession(B) inner adapter start:       2.096 ms  Success
startSession(B) outer provider operation:  732358.985 ms
stopStaleSessionsForThread:                732353.915 ms
stopSession(A):                            732353.660 ms
stopSessionInternal(A):                    732353.636 ms
sendTurn(B):                               began only after the stale close returned

No request to B's model endpoint was recorded during the blocked interval.
After A was stopped, T3 completed the existing switch, sent the already-pending
turn through B, and received successful responses without changing the native
resume cursor.

Related but distinct issues:

  • #4766 covers losing the native resume cursor when switching an already
    stopped compatible Claude thread.
  • #4930 covers a historical error being rendered again after a successful
    compact.
  • #2007 covers provider processes left alive after thread archival.
  • #2365 and #3797 discuss cross-provider compatibility and transcript handoff,
    not a same-driver switch blocked in stale-session shutdown.

Screenshots, recordings, or supporting files

None. The timing trace above is sufficient to identify the blocking span.

Workaround

Fully stop the old provider runtime before retrying the switch, or start a new
thread directly on B and hand off the required context. Restarting T3 also
clears the stuck runtime, but interrupts other active provider sessions.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with ProviderService.startSession in apps/server/src/provider/Layers/ProviderService.ts, then trace shutdown in ClaudeAdapter.stopSessionInternal and event filtering in ProviderRuntimeIngestion. Add deterministic coverage for a never-resolving stale stop and a late event from the replaced instance. Done means switching to B is bounded, the pending turn reaches B once, and A cannot overwrite B's session projection.

Written by the indexing model from the issue text.

Assessment

Tech stack
typescript
Domain
backend
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Quiet
Clarity
Clearly specified
Newbie friendliness
45/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.