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

Offen
#35,240 0 Kommentare 0 Reaktionen 0 zugewiesene Personen Auf GitHub ansehen

Maintainer antworten meist innerhalb von 1 Tag

Dieses Issue hat noch niemand übernommen.

Bewertung

Schwierigkeit
4/5
Geschätzter Aufwand
3-5 Tage
Anfängerfreundlichkeit
48/100
Issue-Typ
Bug
Klarheit
Klar beschrieben
Aktivitätsstatus
Aktiv
Tech-Stack
electron, sqlite, typescript
Bereich
backend, desktop

Rechercherichtung

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.

Vom Indexierungsmodell aus dem Issue-Text verfasst.

Beschreibung

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.

Vorherrschende Sprache
TypeScript
Sterne
24.8k
Forks
6.4k
Ø Merge
4 Std. 16 Min.
Gemergte PRs (30 T.)
352

Entwicklungsumgebung

In Codespaces öffnen

Startet den Dev-Container des Projekts im Browser, mit Ihrem eigenen GitHub-Konto.

Erste Schritte

  1. Lesen Sie das ganze Issue und danach den Beitragsleitfaden des Projekts.
  2. Schreiben Sie ins Issue, dass Sie es übernehmen — das erspart doppelte Arbeit.
  3. Forken Sie das Repository und arbeiten Sie in einem Branch.
  4. Öffnen Sie einen Pull Request, der die Issue-Nummer nennt.

Mehr aus pingdotgg/t3code

Alle Issues in pingdotgg/t3code

Ähnliche Issues

Weitere Issues zu TypeScript

Neue Issues direkt in Ihr Postfach

Eine kurze Übersicht über anfängerfreundliche GitHub-Issues.