[Bug]: Stop does not lift the background-work guard: provider/model switch stays refused ("press Stop" is a dead end)

Open
#35,240 0 comments 0 reactions 0 assignees View on GitHub

Maintainers usually reply within 1 day

Nobody has claimed this yet.

Assessment

Difficulty
4/5
Estimated time
3-5 days
Newbie friendliness
48/100
Issue type
Bug
Clarity
Clearly specified
Activity status
Active
Tech stack
electron, sqlite, typescript
Domain
backend, desktop

Research direction

Start in apps/server, where the issue identifies sessionHasBackgroundWork, startNextQueuedRun, the direct-send sessionsToDetach path, and the Claude query-replace guard; trace how thread.stop and run.interrupt update session state. Compare the adapter's in-memory background-work probes with derivePendingBackgroundWork, then reproduce a refused provider or model switch after Stop. Done means an explicit Stop lets the switch proceed despite stale adapter state, with the failure path diagnosable.

Written by the indexing model from the issue text.

Description

Before submitting
  • I searched existing issues and found related ones (#32296, #16073, #25715) but not this one: here Stop itself never lifts the refusal, so the message that says "press Stop" is a dead end.
  • I included enough detail to investigate the problem.
Area

apps/server

Environment

T3 Code (Beta) 0.1.9 desktop (Electron 44), Linux x86_64. Claude Agent and Pi providers, switched back and forth inside one long thread.

What happens

After a thread has used background work (background shell commands, an Agent subagent, long run_in_background commands), sending a message that changes provider or model is refused with:

The previous provider is still running background agents or commands, and switching would end them. Wait for them to finish, or press Stop, then send the message again.

(or, for a model/setting change inside a Claude session: Claude is still running background agents or commands, and this model or setting change would end them...)

Everything the agent started has long finished, nothing is running, and pressing Stop (button, thread.stop, or run.interrupt) changes nothing: the same refusal comes back on every retry. It only went away when the stuck provider session row was set to stopped directly in statev2.sqlite (the guard skips sessions whose status is stopped/error), which is how I got unstuck the first time. A server restart presumably clears it too, since the flag lives in memory (not tested).

I could not capture which in-memory condition stays set: the refusal is a dispatch error and is not written to the logs or to orchestration_v2_command_receipts, so it is invisible afterwards. That is worth fixing on its own.

Why Stop cannot help (from reading the 0.1.9 server bundle, apps/server/dist/runtimeLayer-*.mjs)

Two independent sources of truth:

  1. The refusal comes from sessionHasBackgroundWork(session, providerThreads), called from startNextQueuedRun (which then holds the queue) and from the direct-send path (sessionsToDetach, which refuses). It asks the adapter's in-memory state: runtime.hasPendingBackgroundWork.
    • Claude: any non-empty entry in pendingBackgroundTasksByNativeThread, any sessionSubagentsByTaskId entry still running, or any buffered user/assistant/result frame in wakeBuffers.
    • The non-Claude adapter whose probe is owesWork (I did not confirm which driver exactly; it was the one used by my second provider): running shells, image jobs, wakes.length > 0, reports.size > 0, a background call, or a busy subagent.
    • Any failure of the probe is treated as busy: probe.pipe(catchCause(() => succeed(true))).
  2. Stop (dispatchThreadStop / dispatchRunInterrupt) only reconciles the projection: it derives pending work with derivePendingBackgroundWork over turn items and runs and marks those settled. It never touches the adapter's roster, wake buffers, reports, or wakes.

So when the adapter flag is stale (or the probe throws) while the projection is already settled, Stop has nothing to settle and returns, and the guard keeps refusing. The error text tells the user to press Stop, which cannot work in this state.

Expected behavior

An explicit Stop should be authoritative for the thread: after it, a provider or model switch proceeds and the old session is detached (which ends whatever is really still running, as the message warns). Stale in-memory state must not survive an explicit Stop.

Suggested fix

Smallest change I tested: remember the explicit Stop per thread and let it satisfy the guard for a short window. Local patch against 0.1.9 (module-level helper plus one check at each of the three guards):

const stopAck = new Map();
const STOP_ACK_TTL_MS = 10 * 60 * 1000;
function markStopped(threadId) { stopAck.set(threadId, Date.now()); }
function stopRecent(threadId) {
  const at = stopAck.get(threadId);
  if (at === undefined) return false;
  if (Date.now() - at > STOP_ACK_TTL_MS) { stopAck.delete(threadId); return false; }
  return true;
}

// in the command switch, only for the user-facing commands (not internal callers of dispatchRunInterrupt):
case "run.interrupt": markStopped(command.threadId); /* ... */
case "thread.stop":   markStopped(command.threadId); /* ... */

// guards:
//  startNextQueuedRun:   if (!stopRecent(threadId)        && (yield* sessionHasBackgroundWork(session, ...))) busySessionIds.add(session.id)
//  direct send:          if (!stopRecent(command.threadId) && (yield* sessionHasBackgroundWork(detaching, ...))) return yield* new OrchestratorDispatchError(...)
//  Claude query replace: ... && !existing.stopping && !stopRecent(turnInput.threadId) && (yield* liveProcessRunsBackgroundWork(existing))

A more thorough fix would have Stop clear the adapter state too (roster, wake buffers, pi reports/wakes), and the probe should not fail closed: a throwing hasPendingBackgroundWork should be logged and not treated as "busy forever". Also please persist or log the dispatch error so a stuck guard can be diagnosed after the fact.

Not verified
  • I could not reproduce deterministically. It showed up twice in one long thread with heavy use of background commands and several provider switches (Claude Agent <-> Pi).
  • The patch is installed locally; I have not yet seen a second occurrence with it, so I can only say it should remove the dead end, not that it fixes the leak that leaves the flag set.

Related: #32296 (same guard refuses a direct send that the queued path accepts), #16073, #25715.

Dominant language
TypeScript
Stars
24.8k
Forks
6.4k
Avg merge
4h 16m
Merged PRs (30d)
352

Getting set up

Open in Codespaces

Starts the project's dev container in your browser, under your own GitHub account.

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.

More from pingdotgg/t3code

All issues in pingdotgg/t3code

Similar issues

More TypeScript issues

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.