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

04:43:58 04:45:12 04:54:43 04:58:34 05:01:32 parent task — after 04:45 it only waits for its children child 1 “explore the repo” — finished normally child 2 “web research” — stalled from 04:58:34 178 s with no event of any kind Stop clicked here — flag set, nobody reads it ✕ ✕ ✕ first token arrives → stop is seen → task ends
The task spent its last three minutes waiting for the first token of a single model request. Sorcar checks the stop flag when it prints or when a token arrives; neither happened in that window.
TimeEventSource
04:43:58.156Task starts on tab a6c6a911…log 454410; task_history.start_ts
04:45:12.24run_parallel fans out into two sub-agentsparent event seq 91; log 454427–454430
04:54:43.46Sub-agent “explore the repository” calls finish() and exits cleanlylog 454635; child events seq 378–380
04:58:30.63Sub-agent “web research” finishes a short Bash and begins step 67child events seq 519–521 (its last events)
04:58:34.50Anthropic returns response headers for step 67log 454675
178 seconds of complete silence — no log line, no event row, no token. The user clicks Stop, more than once.
05:01:32.50The 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

Stop button sends the ACTIVE tab id _stop_task(tabId) task_runner.py:1385 1. set the cooperative stop flag read only inside print() and token_callback() 2. watchdog: raise KeyboardInterrupt in the thread only takes effect between Python bytecodes During the incident both were disarmed: nothing printed for 178 s, and the parent thread sat in ThreadPoolExecutor.join() — inside C, unreachable
Stop has a polite path and a forceful path. The polite path needs the agent to speak; the forceful path needs the agent to be executing Python. A model call that has gone quiet satisfies neither.

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”:

That said, two mis-targeting traps exist and are worth knowing, because they are silent and would produce the same symptom:

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.

ScenarioResult
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.

  1. 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.
  2. 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.
  3. Never drop a stop in silence. When _stop_task cannot resolve a tab it should log, and tell the frontend, rather than return.
  4. 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 a with block — would let the parent report a stop without waiting for the slowest child.
  5. 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.