Commit Graph

2 Commits

Author SHA1 Message Date
teknium1
9d0ab880bc fix: attribute gateway lifecycle stops to their system issuer, not the user
The parent commit only stamped a tool_reason on the watchdog producers, so a
reason-less hard_cancel still minted "explicit stop requested" (a USER reason)
for gateway stop/restart, session eviction while a turn runs, abandoned SSE
runs and TUI/desk shutdown - the exact confusion class of #112647, failing open
toward "user". Pass a system tool_reason at those producers (run_shutdown,
run_agent_cache via the eviction caller only - /stop and /new stay user-owned -
and api_server) so only genuine surface stops book interrupted_by_user.

InterruptScope.cancel now takes tool_reason so the Hermes Console user-cancel
branch is attributed to the user; the console timeout keeps the host slug.

Also repairs the red CI: the cross-process lease tests stub agent.interrupt
with (message, hard_cancel) only, so the new tool_reason kwarg raised TypeError
inside the lease's fallback and the stub never fired. Widen the doubles, and
update the two shutdown mocks that asserted the exact interrupt() call.
2026-09-16 17:48:17 -07:00
Teknium
ac087e6ada fix(console): cancel/timeout interrupts the command's agent and waits for the worker
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>
2026-09-09 10:58:16 -07:00