ac087e6ada
Hermes Console ran each command on a ThreadPoolExecutor and cancelled only the asyncio waiter. A command that forks an AIAgent (`curator run --consolidate`) kept its worker thread and its in-flight provider request alive after the prompt said "cancelled" — a llama.cpp generation kept decoding for 30+ minutes and held the inference slot (#106179). Root cause: the host owning the thread never knew about the agent created deep inside the synchronous command, so it could not call the existing cooperative `interrupt()` path that closes the request sockets. Fix: `agent/interrupt_scope.py` gives the host an `InterruptScope`; the console binds it around the worker (ContextVar), and every `AIAgent.run_conversation()` registers itself with the bound scope for the turn. On cancel, timeout and disconnect the console calls `scope.cancel()` (hard-interrupts every registered agent; an agent registering after the cancel is interrupted on entry so the cancel cannot lose the race with a turn that has not started) and awaits the worker Future with a 10s bound before reporting cancelled/timeout. Queued-but-unstarted work is dropped via `Future.cancel()` alone. Live repro (fake OpenAI-compatible provider blocking like llama.cpp, real /api/console, real curator dispatch, real AIAgent + direct request path): origin/main at the "cancelled" frame -> request_exited=false, worker_exited=false; with this change -> both true, provider saw the peer close, prompt reported cancelled 0.27s after the frame. Salvage of #106197 by @kyssta-exe (executor-future handle, cancel/timeout/ disconnect propagation) and #106320 by @Xixiartemis (deterministic lifecycle regression: terminal(cancelled) => no owned request or worker remains live; interrupt-on-late-registration). Both rebuilt slimmer: #106197 keyed its fallback on Future.cancel() returning False, but the handle it held was run_in_executor's asyncio wrapper, whose cancel() returns True while the thread keeps running, so its thread-name abort registry was never consulted; #106320's command-scoped ownership model is folded into one small module hooked at the turn facade instead of a per-caller `bind_agent`. Co-authored-by: kyssta-exe <218078013+kyssta-exe@users.noreply.github.com> Co-authored-by: Xixiartemis <182932319+Xixiartemis@users.noreply.github.com>