fix(workspace): make Stop actually cancel the turn, not just kill the process - #656
Open
QuanCheng-QC wants to merge 3 commits into
Open
fix(workspace): make Stop actually cancel the turn, not just kill the process#656QuanCheng-QC wants to merge 3 commits into
QuanCheng-QC wants to merge 3 commits into
Conversation
Stop killed the running subprocess and nothing else, so a message posted just before the click started a fresh run one poll later and the button looked inert. Adapters now record the stop control event's own server timestamp as a per-channel watermark and drop any message posted at or before it, both when a message is dispatched and when the channel worker drains its queue. The watermark is never cleared by a newer message passing, because a message queued before the stop is only seen at drain time, after the newer one has already gone through. A stop that had nothing to kill used to post nothing at all, which left the UI's button disabled at "Stopping..." forever since it only clears on a non-status message and the background poll skips the open thread. Every stop path now acknowledges, deduplicated on the current watermark so a burst of dropped messages posts one notice while a genuinely newer stop can still announce itself. Claude's per-channel branch was keyed on that channel having a live process, so a stop naming an idle channel fell through to the stop-everything branch and killed unrelated threads. It is now scoped to the named channel whether or not anything is running there. The frontend falls back to broadcasting a stop when a thread's participant list is not available yet, which was previously a silent no-op, and is only safe now that a channel-named stop stays in its channel. Each Stop click takes a generation and its timers act only while they still own the session, so a first click's give-up timer can no longer clear a second click's state. Sessions whose stop is never acknowledged go back to active rather than merely losing the latch, since the button renders on active-or-stopping and clearing alone just hid it. OpenClaw never tracked its CLI child, so Stop was completely inert there. It now registers the process per channel, releases it by identity so a late exit cannot unregister its successor, spawns into its own process group so tool subprocesses go down with it, tears children down on daemon shutdown, and suppresses both the error and the empty-response reply that a user stop would otherwise produce. Both copies of the workspace context are updated; packages/go/web carried a byte-identical stopAllAgents.
Four problems, all confirmed against the code before fixing. The broadcast fallback for a missing participant list is removed. It was only safe if every adapter kept a channel-named stop inside that channel, and most do not — aider, amp, gemini, codex, mini, antigravity, llm-direct and hermes ignore the channel entirely, so telling an uninvolved agent to stop would have killed its work in some other thread. When no participant can be resolved the latch is released right away instead, which is what the stuck button needed in the first place. Cursor, Copilot, Cline and Kimi carried the same shape Claude did, where the per-channel branch was gated on that channel having a live process and a stop naming an idle channel fell through to stop-everything. They are now scoped the same way. Copilot keeps announcing via a status rather than a response, which its own test pins deliberately; the wording still carries "stopped", which is what the UI matches on. A stop could still slip through the window between the dispatch-time check and the actual spawn, because handling a message first awaits auto-title and a status ping. A stop landing there found no process to kill, so it only posted a notice and the run started anyway, ending with the full answer posted after "Execution stopped by user." OpenClaw, Claude and Pi now re-check immediately before starting work, and OpenClaw checks again before posting a result. Any non-status message used to confirm a stop, so the background poll could see the user's own just-sent message and clear the latch, cancelling the retry and give-up timers with nothing actually stopped. Only an agent message confirms now. The predicate is extracted so it can be tested. Watermark eviction by count could discard the mark still holding back a queued message, which contradicts the guarantee that a watermark outlives a passing message. Pruning is now driven by what the poll cursor has provably moved past, and only when nothing at or below the mark is still queued, so it stays bounded without being able to forget a live mark. Notice bookkeeping is pruned alongside it rather than growing forever.
Pruning missed the message a channel is currently handling. Once handed to the worker it is no longer queued, so another channel's newer message could advance the cursor floor and drop the mark that message's own pre-spawn check depends on, and the run the user had cancelled started anyway. A busy channel now keeps its watermark, and the worker prunes again after it clears the busy flag. Minimal repro went from watermark 0 and stopped false to watermark 1000 and stopped true. Kimi's direct-API fallback was still global. With no CLI for the named channel it delegates to the direct adapter, whose in-flight request set carried no channel at all, so one thread's Stop destroyed every thread's request. Requests are now tagged with the channel that owns them and a named stop only destroys that channel's. This fixes every agent running in direct mode, not just Kimi. The four adapters scoped last round could still reach their CLI after a stop, through the same window already closed for Claude, Pi and OpenClaw — a stop arriving during auto-title or the status ping finds nothing to kill. Each now re-checks at the head of its attempt loop, which covers both the first spawn and the stale-session retry. The direct adapter checks before its API call and before posting a result. With no agent to send a stop to, the UI was left half-changed: the session had already been pulled out of active and its preview frozen at "Stopping...", so the thread lost its Stop button and never got it back. Targets are now resolved before any of that state is touched, so this case leaves the UI exactly as it was.
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
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.
What this fixes
The Stop button in a workspace thread does not stop anything. Reported internally; corroborated from the reporter's own ~/.openagents/daemon.log (2026-08-31, agent xiaoying-bot, channel channel-36ef9d84).
This is not one bug. It looks like one thing to a user — the button does nothing — but the causes are spread across the adapters and the frontend.
How to reproduce
The path most users hit, on any agent type:
Send a message in a thread.
Press Stop before the agent has polled that message — roughly a 0–5 second window.
Watch the agent start working anyway.
The window is not narrow. The frontend shows the Stop button the instant a message is sent, from an optimistic loading placeholder, while an idle adapter polls for messages every 2–5s and for control events every 2s. Between the button appearing and the agent receiving the message, the stop always arrives first.
Second path (before this change, on claude/cursor/copilot/cline/kimi): open two threads, leave A running and B idle, press Stop in B — A dies too.
Third path: on an openclaw agent the Stop button never worked at all, because that adapter never implemented the stop control action.
Before
08:11:56.059 Stopping process for channel=channel-36ef9d84
08:11:56.612 Persistent process exited: code=143
08:11:57.099 Processing message from ...: 请你彼此协作完成一篇公众号... <- starts anyway
08:11:57.987 Spawned persistent process for channel-36ef9d84 (attempt 1)
08:12:00.065 Stopping process for channel=channel-36ef9d84 <- frontend's blind 3s retry
What actually stopped it was the frontend's blind retry three seconds later, not the user's click. A stop with nothing running was completely silent, so stoppingSessionIds had nothing to clear it and no timeout — the button became a disabled "Stopping…" and never came back.
After
STOP_RECEIVED channel=session-6f3d75ab
[adapter] Dropping message posted before Stop in session-6f3d75ab
RUN_STARTED count = 0
The mechanism is a server-clock watermark, checked in four places: at dispatch, when the worker drains its queue, immediately before the CLI is spawned, and before a result is posted. The middle two matter — between dispatch and spawn an adapter still awaits auto-title and a status ping, and a stop landing in that window finds no process to kill.
Blast radius
Nine adapters changed, but the interception and acknowledgement live in base.js, so all 22 adapters gain the guarantee that a stop is not undone by an already-posted message. Both frontends changed (packages/go/web was a byte-identical copy). Backend untouched — no schema, API, or event-format changes.
One behaviour change worth calling out: Stop now discards a message that was posted but had not started running, not just the in-flight process. Matches Esc in Claude Code, and a discarded message always produces a visible acknowledgement — nothing is swallowed.
Known gap, deliberately out of scope: seven adapters (aider, amp, antigravity, codex, gemini, hermes, mini) still ignore payload.channel and stop every channel. Pre-existing; this PR does not widen it — the frontend deliberately does not broadcast a stop when the participant list is unavailable, precisely because of those adapters.