fix(server): make Stop work while a chat runs background work (#528) - #662
Merged
Conversation
A chat could sit with the spinner and Stop showing forever. Stop did nothing — no error, no frame, no log line. The composer silently queued anything typed instead of sending it, and reloading didn't help (the state is server-authoritative and replays as running). Only a server restart cleared it. Two independent things had to be wrong at once, and both were. **No cancellable identity.** Once a session-mode turn's primary `result` lands, the session can stay open — the reaper holds it while the turn's background work runs — and autonomous re-invocation turns keep arriving on the same stream. `makeBackgroundTurnSink` renders that stretch as one hub turn but never called `setJobId`; it was called at only two of the five turn-start sites, and this was one of the three that missed. So every frame and every `chat:active` carried `jobId: null`, the client's deferred cancel (#196) waited for an id that never arrived, and clicking Stop put nothing on the wire at all — hence silent rather than errored. The sink now mints a synthetic job id and publishes it when the turn opens, as the foreground path does via `onJobCreated`. **Nothing to route to.** `cancel` knew a live turn in `liveSessions` (→ `interrupt()`) and a batch job (→ `cancelJob`). The primary turn's `liveSessions` entry is dropped the moment it returns, so a background-phase id matched neither and fell through to `cancelJob(<synthetic uuid>)` → JobNotFoundError → false, discarded by the WS layer. `interrupt()` was the wrong primitive anyway: it ends an in-flight model turn, and this session is idle holding background work. Cancel now routes these to `fleet.reapChatSession()`, so the stream ends and the existing unwind emits `chat:complete`. Easiest to hit on a subscription usage limit: sub-agents die, the parent's re-invocation turn dies without a Stop hook, and the reaper's `awaitingTasks` (cleared by that turn's `activity`) means no later signal can reap the session. Also covers the originally reported trigger — a model-authored `until` loop whose sentinel never arrives. Requires @herdctl/core >= 5.31.0 (edspencer/herdctl#441), so this must land after that release. Co-Authored-By: Claude <noreply@anthropic.com>
Deploying paddock with
|
| Latest commit: |
bd8c1e3
|
| Status: | ✅ Deploy successful! |
| Preview URL: | https://2d29d7f5.paddock-7u2.pages.dev |
| Branch Preview URL: | https://fix-528-stop-during-backgrou.paddock-7u2.pages.dev |
The reapChatSession/forceReap API this fix depends on shipped in edspencer/herdctl#441, released as core 5.31.0. package.json already asked for it; this refreshes the lockfile off the published tarball, so CI installs the same package the tests were re-verified against rather than a local build. Co-Authored-By: Claude <noreply@anthropic.com>
edspencer
marked this pull request as ready for review
August 4, 2026 18:37
Merged
This was referenced Aug 7, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #528.
Symptom
A chat sits with the spinner and Stop showing forever. Stop does nothing — no error, no frame, no log line. The composer silently queues anything you type instead of sending it, so the chat is fully inert. Reloading doesn't help: the state is server-authoritative and replays as running. Only restarting the server clears it.
Root cause
Two independent things had to be wrong at once, and both were.
1. The background-phase turn had no cancellable identity.
Once a session-mode turn's primary
resultlands, the session can stay open — the reaper holds it while the turn's background work runs — and autonomous re-invocation turns keep arriving on the same stream. Paddock renders that stretch throughmakeBackgroundTurnSinkas one hub turn, shown to the user as running, with a Stop button.The sink opened its turn with
hub.startTurn(...)and never calledsetJobId. That method was being called at only two of the five turn-start sites, and this was one of the three that missed. So every frame and everychat:activecarriedjobId: null; the client's deferred cancel (#196) waits for a jobId that never arrives; clicking Stop put nothing on the wire at all. That's why it failed silently rather than erroring.2. There was nothing for it to route to.
HerdctlService.cancelknew two kinds of id — a live turn inliveSessions(→session.interrupt()) and a batch job (→cancelJob). The primary turn'sliveSessionsentry is deleted the moment it returns, so a background-phase id matched neither and fell through tocancelJob(<synthetic uuid>)→JobNotFoundError→false, which the WS layer discards.interrupt()would have been the wrong primitive even if it could fire: it targets an in-flight model turn, and this session is idle, holding background work.The fix
setJobIdthe moment the turn opens — exactly as the foreground path does viaonJobCreated— and registers it against the session the stream belongs to.cancelgains a middle branch: a registered background-phase id routes tofleet.reapChatSession()(herdctl#441, released in@herdctl/core5.31.0). Ending the session ends the stream, which drives the existingconsumeBackgroundTurns→onDone→chat:complete+turn.end()unwind. The UI unlocks through the path that already works.Why the usage-limit case matters
This is easiest to hit on a subscription usage limit, which is how it was reported: sub-agents die on the shared quota, the parent's re-invocation turn dies too — without firing a Stop hook — and the reaper's
awaitingTasksflag (already cleared by that turn'sactivity) means no laterbackground_tasks_changedcan do anything. Nothing left can reap the session. herdctl#441 covers that state machine in detail.It also covers #528's originally reported trigger: a model-authored
untilloop whose sentinel never arrives, so the background task set never drains.Deliberately not in scope
#528 also proposes (B) rendering the background phase as a distinct non-blocking state so healthy long-lived background work never locks the composer, and wiring up
recovery.limboTimeoutMs. Both are worth doing and both are bigger than this. Flagging one thing found along the way:limboTimeoutMsis parsed, defaulted, and exposed as editable in the Settings UI, but read by nothing — zero consumers inpackages/server/src. Anyone who set it expecting it to catch this got nothing. Worth its own issue.Note that Stop here means end the session, so it does kill healthy in-flight background work too. That's the right semantic for a button labelled Stop, but it's the reason (B) is the real fix for the non-wedged case.
Tests
herdctl-cancel-routing.test.ts(7) — all threecancelbranches, including that a live turn still outranks a reap, and that an unregistered id falls back tocancelJobrather than reaping a reused session.background-turn-cancellable.test.ts(7) — the sink publishes a jobId, registers it against the right session, registers once per stretch, unregisters on stream end (and only once), and opens nothing for a sidechain-only stretch. 5 of these 7 fail without the fix — verified by stashingws-turn.tsand re-running.The full chain is covered by composition rather than one end-to-end test (an SDK-session background phase isn't practical to drive in-process): force-reap →
close()is covered in herdctl#441, stream-end →onDoneby the existingresume-drain.test.ts, andonDone→chat:completehere.Re-verified against the published
@herdctl/core@5.31.0(not a local build): typecheck green, server 1608 passed, web 918 passed, plus the E2E suite.🤖 Generated with Claude Code