Why the Stop button took several clicks before the task actually stopped
Post-mortem of task 709ebce3… (“use one browser profile in web_use_tool.py”), 5 August 2026,
04:43:58 – 05:01:32. Written from the daemon log ~/.kiss/kiss-web-stderr.log, the event database
~/.kiss/sorcar.db, and the source at HEAD.
Short answer. Nothing was broken in the Stop wiring. The task could not react to Stop because the whole task tree was parked inside one Anthropic request that produced no output for 178 seconds, and Sorcar only notices a stop request at the two moments it emits something: when it prints an event, or when a streamed token arrives. During those 178 seconds nothing printed, so the stop flag was set but unread.
Two design details turned that into “the button is dead”. First, the fallback that is supposed to force the issue —
raising KeyboardInterrupt in the task thread — cannot fire while that thread is blocked in C code, and the
parent task was blocked in C code, waiting inside ThreadPoolExecutor for its run_parallel
children. Second, the Stop button gives no acknowledgement whatsoever, so a stop that is merely pending looks
exactly like a stop that was never received — which is why clicking again seemed like the reasonable thing to do.
The click that “worked” was simply the moment the stalled stream finally delivered its first token.
What actually happened, minute by minute
| Time | Event | Source |
|---|---|---|
| 04:43:58.156 | Task starts on tab a6c6a911… | log 454410; task_history.start_ts |
| 04:45:12.24 | run_parallel fans out into two sub-agents | parent event seq 91; log 454427–454430 |
| 04:54:43.46 | Sub-agent “explore the repository” calls finish() and exits cleanly | log 454635; child events seq 378–380 |
| 04:58:30.63 | Sub-agent “web research” finishes a short Bash and begins step 67 | child events seq 519–521 (its last events) |
| 04:58:34.50 | Anthropic returns response headers for step 67 | log 454675 |
| 178 seconds of complete silence — no log line, no event row, no token. The user clicks Stop, more than once. | ||
| 05:01:32.50 | The stream finally yields an event → token_callback → stop flag is read → KeyboardInterrupt → “Task stopped by user” | log 454676; parent events seq 92–93 |
| ~05:01:34.5 | (the stream-stall watchdog would have aborted the request anyway, 2 s later) | anthropic_model.py:32, timeout 180 s |
The database agrees: the web-research sub-agent’s very last recorded event is sequence 521 at 04:58:30, and its
row ends as Task interrupted, while the parent’s row ends as Task stopped by user — a label
task_runner.py:1277-1312 only produces when a real user stop was in effect and the daemon was not
shutting down.
How Stop is supposed to work
The polite path
_stop_task (task_runner.py:1385) sets a threading.Event. That event is read in
exactly two places — JsonPrinter._check_stop() called from print()
(json_printer.py:760) and from token_callback() (json_printer.py:915) — plus a
process-group killer that watches it during Bash commands (useful_tools.py:409-427). If the agent
is neither printing, nor streaming tokens, nor running a shell command, the flag is simply not looked at.
The forceful path
One second later a watchdog thread calls PyThreadState_SetAsyncExc to raise
KeyboardInterrupt inside the task thread, retrying after 5 s and again after 5 s
(task_runner.py:1531-1572). CPython only delivers such an exception at a bytecode boundary. A thread
blocked in a socket read or in a lock acquisition is not executing bytecode, so the exception waits — silently —
until the blocking call returns.
Why run_parallel made it worse
After the fan-out at 04:45, the parent did nothing of its own. It sat in
with ThreadPoolExecutor(max_workers=max_workers) as pool:
results = list(pool.map(_run_single, enumerate(tasks)))
# chat_sorcar_agent.py:761-762
which blocks in a lock inside Future.result(), and whose __exit__ then calls
shutdown(wait=True) and joins every worker. So the parent could neither poll the flag (it never prints
during a fan-out) nor take the injected interrupt (it is inside C). It could only end when its slowest child returned —
and that child was the one waiting on the silent stream.
Was the flag really set early, or did only the last click land?
Both stories end with the task dying at 05:01:32.50, so the logs cannot timestamp the clicks — _stop_task
writes nothing at INFO level. The evidence still favours “an early click landed and was invisible”:
- The interrupt could only be raised at the first
print()ortoken_callback()after 178 seconds of silence. For the “only the last click landed” story to hold, that click would have to fall within a few milliseconds of the stream waking up — roughly a one-in-a-hundred-thousand coincidence inside a 178-second window. - A correctly targeted click is fast. Measured on this code: 20 milliseconds from click to task death while sub-agents are streaming (scenario A below). Nothing in the delivery path is slow.
That said, two mis-targeting traps exist and are worth knowing, because they are silent and would produce the same symptom:
- Stop follows the visible tab, not the task.
main.js:6833sends{tabId: activeTabId}. If you are looking at a sub-agent’s tab when you click, only that sub-agent is stopped — deliberately so (test_subagent_only_stop_and_inject.py); the parent task keeps running. - A click on a tab whose task already ended is discarded without a word.
_stop_tasklooks for the tab’s stop event, falls back to_find_source_tab_for_viewer, and if that returns nothing it simply returns. The only trace is alogger.debugline that is not enabled.
Reproduction
The four cases below were run against the real production classes — VSCodeServer._stop_task,
the _RunningAgentState registry, JsonPrinter and
ChatSorcarAgent._run_tasks_parallel — with only the leaf agent bodies standing in for LLM calls.
| Scenario | Result |
|---|---|
| A — click on the task-owner tab while sub-agents stream | stops in 0.02 s, KeyboardInterrupt |
| B — click while a sub-agent tab is active | parent unaffected; its stop flag is never set; only that child stops |
| C — click on a viewer tab whose sub-task already finished | silently discarded; no flag set, nothing logged |
| E — click on the owner tab while a child sits in a 4 s blocking call | stop lands after 4.03 s — exactly when the call returned; the forced interrupt could not shorten it |
Scenario E is the incident in miniature: substitute 178 seconds for 4, and the observed behaviour is reproduced exactly.
What would remove the surprise
None of this requires a redesign; it is mostly about telling the user what happened.
- Acknowledge the click. The button only toggles visibility (
main.js:5958). Having it switch to a “Stopping…” state on the first click, and log the stop server-side at INFO, would have made the pending stop visible and made this post-mortem unnecessary. - Say what a click will hit. On a sub-agent tab the control is stopping one sub-agent, not the task; that distinction is invisible today.
- Never drop a stop in silence. When
_stop_taskcannot resolve a tab it should log, and tell the frontend, rather than return. - Make the fan-out interruptible. Waiting on futures with a timeout in a loop that re-checks the stop
flag — instead of
list(pool.map(...))inside awithblock — would let the parent report a stop without waiting for the slowest child. - Let the model layer see the stop flag. The Anthropic stall watchdog
(
anthropic_model.py:37-90) already knows how to abort a silent stream; if it also woke on the task's stop event, a stop during a quiet request would take effect in under a second instead of up to three minutes.