[Bug]: Turn interrupt on a stopped session settles nothing, leaving a dangling active turn and a permanently failing stop
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
Summary
When a turn interrupt reaches a thread whose provider session is missing or stopped, processTurnInterruptRequested appends a provider.turn.interrupt.failed activity with detail "No active provider session is bound to this thread." and then returns without settling anything. If the stopped session still carries an activeTurnId, that dangling turn survives the stop, so the thread stays in the state that made stop fail and every later stop fails identically.
The same condition is handled gracefully one function away. processSessionStopRequested skips the provider call when the session is already gone and still settles the thread:
// ProviderCommandReactor.ts - processSessionStopRequested
thread.session && thread.session.status !== "stopped"
? providerService.stopSession({ threadId: thread.id })
: Effect.void,
// ...onSuccess:
setThreadSession({ ... status: "stopped", activeTurnId: null, ... })
processTurnInterruptRequested takes the opposite branch for the same state:
// ProviderCommandReactor.ts - processTurnInterruptRequested
const session = thread.session;
if (!session || session.status === "stopped") {
return yield* appendProviderFailureActivity({
kind: "provider.turn.interrupt.failed",
detail: "No active provider session is bound to this thread.",
...
});
}
So "stop session" is idempotent and self-healing, while "stop generation" is a dead end.
The codebase already treats "stopped session holding an activeTurnId" as invalid and nulls it in three other places: processSessionStopRequested's success path, recoverInterruptFailure in this same function, and settleAsError in reconcileProviderSessions. The reconcile is the only one that repairs an orphaned session, and it runs only during server startup (runStartupPhase("provider-sessions.reconcile", ...)), so a session orphaned while the server keeps running is never repaired on demand.
Steps to reproduce
- Start a turn on a thread so the session projection is
runningwithactive_turn_idset. - Make the provider session go away without the turn being settled: the provider child process exits or restarts, or the session is otherwise orphaned while the server stays up. The session projection becomes
stopped(or the binding disappears) whileactive_turn_idstill points at the turn. - Trigger a turn interrupt for that thread.
- A
provider.turn.interrupt.failedactivity is appended with"No active provider session is bound to this thread.", and nothing is settled. Repeat step 3 and the identical failure is appended again, indefinitely.
Note on reaching step 3 from the UI: the main Stop button is gated on a live session, so it cannot fire in this state:
// ChatView.logic.ts
if (phase !== "running" || thread?.session?.status !== "running") return null;
Two paths are not gated that way:
- Stop background work (
handleStopBackgroundWorkinChatView.tsx) dispatchesbuildThreadTurnInterruptInput(activeThread)with no session-status check, so it reaches this branch whenever background liveness is non-null. - Client/server divergence: the client's session state arrives over the event stream, so a client still showing
runningafter the server has moved the session tostoppedwill dispatch the interrupt and hit the guard.
Expected behavior
A stop on a thread whose provider session is already gone should settle the thread rather than fail into the same state, matching processSessionStopRequested. Concretely: clear the dangling activeTurnId so the thread is no longer holding a turn that can never be interrupted, and so a second stop is not required to produce the same failure.
This is the "secondary guard" suggested at the end of #4713: refusing to hold active_turn_id on a turn that can no longer be acted on makes the desync self-correcting.
Actual behavior
The failure activity is appended and nothing else happens. The dangling activeTurnId survives, the thread remains in the state that caused the failure, and there is no in-app action that repairs it. The existing repair path (reconcileProviderSessions) only runs at server startup, so in practice the user restarts the app.
Impact
Major degradation or frequent failure
Version or commit
Observed on desktop 0.0.40. Code references above are against main @ 9d4bb550.
Environment
T3 Code desktop 0.0.40 on Windows 11, provider Claude Code (claude-opus-5). Reported symptom: pressing stop/close on a thread whose underlying Claude Code process had restarted produced No active provider session is bound to this thread.
Logs or stack traces
provider.turn.interrupt.failed
summary: "Provider turn interrupt failed"
detail: "No active provider session is bound to this thread."
Workaround
Restart the app. reconcileProviderSessions then settles the orphaned session with activeTurnId: null and lastError: "Provider session did not survive a server restart. Send a new message to continue.". #4713 also documents SIGTERM on the matching provider child as a workaround for its variant.
What I verified and what I did not
Verified by reading main @ 9d4bb550: the asymmetry between the two stop handlers, the three other sites that null activeTurnId, that the reconcile is startup-only, and that the background-work stop path has no session-status gate.
Verified empirically against main @ 9d4bb550 with a test in ProviderCommandReactor.test.ts that seeds a stopped session holding activeTurnId and dispatches thread.turn.interrupt. On unmodified main it fails with the dangling turn surviving the stop:
- Expected
+ Received
{
- "activeTurnId": null,
+ "activeTurnId": "turn-dangling",
"status": "stopped",
}
Not verified: the exact projection row state at the moment of the reported failure. The thread was on another machine and its state.sqlite was not available, so I cannot confirm whether that specific thread had a non-null active_turn_id. The detection query in #4713 is the right way to confirm the dangling state:
SELECT s.thread_id, s.status, s.active_turn_id, tu.state, tu.completed_at
FROM projection_thread_sessions s
JOIN projection_turns tu ON tu.turn_id = s.active_turn_id
WHERE tu.state IN ('interrupted','completed','error');
Related
- #4713 - related but distinct. There the session is stuck
runningwith a terminal turn, so the guard does not fire and stop is a silent no-op. Here the session isstoppedor absent, the guard does fire, and stop reports an error while still settling nothing. Same class of desync, different branch and different symptom; fixing one does not fix the other. - #4524 / #4621 - why this failure is surfaced to the user at all rather than swallowed.
- #5454 and open PR #10586 - the pending user-input half of the same "session died underneath a pending interaction" family. #10586 does not touch the interrupt path.
Contributor guide
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
Research direction
Start in apps/server's ProviderCommandReactor.ts, comparing processTurnInterruptRequested with processSessionStopRequested and the existing activeTurnId repair paths. Run the reproduced case in ProviderCommandReactor.test.ts, which seeds a stopped session with an activeTurnId. Done means an interrupt for a missing or stopped session settles the thread and clears the dangling activeTurnId.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- typescript
- Domain
- backend
- Issue type
- Bug
- Difficulty
- 3/5
- Estimated time
- 1-2 days
- Activity status
- Active
- Clarity
- Clearly specified
- Newbie friendliness
- 76/100