Skip to content

fix(workspace): make Stop actually cancel the turn, not just kill the process - #656

Open
QuanCheng-QC wants to merge 3 commits into
developfrom
bugfix/workspace-stop-not-taking-effect
Open

fix(workspace): make Stop actually cancel the turn, not just kill the process#656
QuanCheng-QC wants to merge 3 commits into
developfrom
bugfix/workspace-stop-not-taking-effect

Conversation

@QuanCheng-QC

Copy link
Copy Markdown
Collaborator

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.

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.
@vercel

vercel Bot commented Sep 1, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
openagents-workspace Ready Ready Preview Sep 1, 2026 11:43am UTC

Request Review

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant