mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-01 12:02:05 +00:00
fix(cli): clear input-blocking overlays when interrupting a running agent
Some checks failed
CI / Detect affected areas (push) Has been cancelled
CI / OSV scan (push) Has been cancelled
Deploy Site / deploy-vercel (push) Has been cancelled
Deploy Site / deploy-docs (push) Has been cancelled
CI / Python tests (push) Has been cancelled
CI / Python lints (push) Has been cancelled
CI / TypeScript (push) Has been cancelled
CI / Docs Site (push) Has been cancelled
CI / Deny unrelated histories (push) Has been cancelled
CI / Check contributors (push) Has been cancelled
CI / Check uv.lock (push) Has been cancelled
CI / Lint Docker scripts (push) Has been cancelled
CI / Build&Test Docker image (push) Has been cancelled
CI / Supply-chain scan (push) Has been cancelled
CI / All required checks pass (push) Has been cancelled
CI / CI timing report (push) Has been cancelled
Some checks failed
CI / Detect affected areas (push) Has been cancelled
CI / OSV scan (push) Has been cancelled
Deploy Site / deploy-vercel (push) Has been cancelled
Deploy Site / deploy-docs (push) Has been cancelled
CI / Python tests (push) Has been cancelled
CI / Python lints (push) Has been cancelled
CI / TypeScript (push) Has been cancelled
CI / Docs Site (push) Has been cancelled
CI / Deny unrelated histories (push) Has been cancelled
CI / Check contributors (push) Has been cancelled
CI / Check uv.lock (push) Has been cancelled
CI / Lint Docker scripts (push) Has been cancelled
CI / Build&Test Docker image (push) Has been cancelled
CI / Supply-chain scan (push) Has been cancelled
CI / All required checks pass (push) Has been cancelled
CI / CI timing report (push) Has been cancelled
Interrupting the agent while an approval/clarify/sudo/secret prompt is up left the overlay state dict set with no thread servicing it. The prompt's worker thread is torn down on interrupt, but read_only (gated on _command_running) plus the keypress filter kept the CLI input locked until the prompt's own timeout expired — the terminal appeared frozen. Drain and clear all four input-blocking overlays on interrupt via a single helper (_clear_active_overlays_for_interrupt): approval -> deny, clarify/sudo/secret -> cancel, each guarded so a dead queue can't block the others; sudo restores the pre-modal draft. Wired into all three interrupt paths — new-message interrupt, Ctrl+C, and Ctrl+Q. Blocking overlays now clear AND fall through so one keypress both clears a stale overlay and interrupts a still-running agent; the /model picker and slash-confirm foreground prompts keep their cancel-and-return behavior. Closes #13618.
This commit is contained in:
parent
8e6fd4cfa6
commit
c969090878
3 changed files with 182 additions and 60 deletions
148
cli.py
148
cli.py
|
|
@ -11550,6 +11550,51 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
def _clear_active_overlays_for_interrupt(self) -> None:
|
||||
"""Drain and clear every input-blocking overlay left by an interrupted agent.
|
||||
|
||||
approval/clarify/sudo/secret prompts each block a worker thread on a
|
||||
``response_queue.get()``. When the agent is interrupted the worker
|
||||
thread is torn down, but the overlay's state dict stays set — leaving
|
||||
the CLI input gated (``read_only`` condition + keypress filter) with no
|
||||
thread servicing the prompt. The result is a frozen terminal until the
|
||||
prompt's own timeout expires. Push a terminal value onto each queue so
|
||||
any still-blocked thread unblocks cleanly, then nil the state out and
|
||||
restore the user's pre-modal draft (#14026).
|
||||
|
||||
Safe default per prompt: approval -> "deny", clarify/sudo/secret ->
|
||||
cancel (None / empty). Each step is wrapped so a dead queue can't
|
||||
prevent clearing the others.
|
||||
"""
|
||||
if self._approval_state:
|
||||
try:
|
||||
self._approval_state["response_queue"].put("deny")
|
||||
except Exception:
|
||||
pass
|
||||
self._approval_state = None
|
||||
if self._clarify_state:
|
||||
try:
|
||||
self._clarify_state["response_queue"].put(
|
||||
"The user cancelled. Use your best judgement to proceed."
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
self._clarify_state = None
|
||||
self._clarify_freetext = False
|
||||
if self._sudo_state:
|
||||
try:
|
||||
self._sudo_state["response_queue"].put("")
|
||||
except Exception:
|
||||
pass
|
||||
self._sudo_state = None
|
||||
self._sudo_deadline = 0
|
||||
self._restore_modal_input_snapshot()
|
||||
if self._secret_state:
|
||||
try:
|
||||
self._cancel_secret_capture()
|
||||
except Exception:
|
||||
self._secret_state = None
|
||||
|
||||
def _submit_secret_response(self, value: str) -> None:
|
||||
if not self._secret_state:
|
||||
return
|
||||
|
|
@ -11926,6 +11971,12 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
|||
if stop_event is not None:
|
||||
stop_event.set()
|
||||
self.agent.interrupt(interrupt_msg)
|
||||
# Clear any active overlay states the interrupted agent
|
||||
# left behind. approval/clarify/sudo/secret prompts gate
|
||||
# input (read_only condition + keypress filter) until
|
||||
# explicitly reset — without this the CLI freezes after
|
||||
# an interrupt until the prompt's own timeout expires (#14026).
|
||||
self._clear_active_overlays_for_interrupt()
|
||||
# Debug: log to file (stdout may be devnull from redirect_stdout)
|
||||
try:
|
||||
_dbg = _hermes_home / "interrupt_debug.log"
|
||||
|
|
@ -13253,50 +13304,42 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
|||
event.app.invalidate()
|
||||
return
|
||||
|
||||
# Cancel sudo prompt
|
||||
if self._sudo_state:
|
||||
self._sudo_state["response_queue"].put("")
|
||||
self._sudo_state = None
|
||||
event.app.invalidate()
|
||||
return
|
||||
|
||||
# Cancel secret prompt
|
||||
if self._secret_state:
|
||||
self._cancel_secret_capture()
|
||||
event.app.current_buffer.reset()
|
||||
event.app.invalidate()
|
||||
return
|
||||
|
||||
# Cancel approval prompt (deny)
|
||||
if self._approval_state:
|
||||
self._approval_state["response_queue"].put("deny")
|
||||
self._approval_state = None
|
||||
event.app.invalidate()
|
||||
return
|
||||
|
||||
# Cancel slash confirmation prompt
|
||||
# Cancel slash confirmation prompt (foreground UI, not an
|
||||
# agent-blocking overlay — cancel and stop here).
|
||||
if self._slash_confirm_state:
|
||||
self._submit_slash_confirm_response("cancel")
|
||||
event.app.current_buffer.reset()
|
||||
event.app.invalidate()
|
||||
return
|
||||
|
||||
# Cancel /model picker
|
||||
# Cancel /model picker (foreground UI — cancel and stop here).
|
||||
if self._model_picker_state:
|
||||
self._close_model_picker()
|
||||
event.app.current_buffer.reset()
|
||||
event.app.invalidate()
|
||||
return
|
||||
|
||||
# Cancel clarify prompt
|
||||
if self._clarify_state:
|
||||
self._clarify_state["response_queue"].put(
|
||||
"The user cancelled. Use your best judgement to proceed."
|
||||
)
|
||||
self._clarify_state = None
|
||||
self._clarify_freetext = False
|
||||
# Clear all agent-blocking overlays (approval/clarify/sudo/secret)
|
||||
# in one shot. We do NOT return after clearing — we fall through so
|
||||
# that if the agent is also running we fire the interrupt on the same
|
||||
# Ctrl+C press. This fixes the case where a stale/orphaned overlay
|
||||
# (left behind by a previous interrupt) consumes the press without
|
||||
# ever reaching the agent-interrupt branch, leaving the chat frozen
|
||||
# (#14026).
|
||||
_overlay_cleared = bool(
|
||||
self._sudo_state
|
||||
or self._secret_state
|
||||
or self._approval_state
|
||||
or self._clarify_state
|
||||
)
|
||||
if _overlay_cleared:
|
||||
self._clear_active_overlays_for_interrupt()
|
||||
event.app.current_buffer.reset()
|
||||
event.app.invalidate()
|
||||
|
||||
# If we only cleared overlays and the agent is NOT running, stop here
|
||||
# (don't fall through to the interrupt/exit path).
|
||||
if _overlay_cleared and not (self._agent_running and self.agent):
|
||||
return
|
||||
|
||||
if self._agent_running and self.agent:
|
||||
|
|
@ -13353,50 +13396,35 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin):
|
|||
event.app.invalidate()
|
||||
return
|
||||
|
||||
# Cancel sudo prompt
|
||||
if self._sudo_state:
|
||||
self._sudo_state["response_queue"].put("")
|
||||
self._sudo_state = None
|
||||
event.app.invalidate()
|
||||
return
|
||||
|
||||
# Cancel secret prompt
|
||||
if self._secret_state:
|
||||
self._cancel_secret_capture()
|
||||
event.app.current_buffer.reset()
|
||||
event.app.invalidate()
|
||||
return
|
||||
|
||||
# Cancel approval prompt (deny)
|
||||
if self._approval_state:
|
||||
self._approval_state["response_queue"].put("deny")
|
||||
self._approval_state = None
|
||||
event.app.invalidate()
|
||||
return
|
||||
|
||||
# Cancel slash confirmation prompt
|
||||
# Cancel slash confirmation prompt (foreground UI — cancel and stop).
|
||||
if self._slash_confirm_state:
|
||||
self._submit_slash_confirm_response("cancel")
|
||||
event.app.current_buffer.reset()
|
||||
event.app.invalidate()
|
||||
return
|
||||
|
||||
# Cancel /model picker
|
||||
# Cancel /model picker (foreground UI — cancel and stop).
|
||||
if self._model_picker_state:
|
||||
self._close_model_picker()
|
||||
event.app.current_buffer.reset()
|
||||
event.app.invalidate()
|
||||
return
|
||||
|
||||
# Cancel clarify prompt
|
||||
if self._clarify_state:
|
||||
self._clarify_state["response_queue"].put(
|
||||
"The user cancelled. Use your best judgement to proceed."
|
||||
)
|
||||
self._clarify_state = None
|
||||
self._clarify_freetext = False
|
||||
# Clear all agent-blocking overlays in one shot, then fall through to
|
||||
# the agent-interrupt branch so a single Ctrl+Q both clears a stale
|
||||
# overlay and interrupts a still-running agent (#14026).
|
||||
_overlay_cleared = bool(
|
||||
self._sudo_state
|
||||
or self._secret_state
|
||||
or self._approval_state
|
||||
or self._clarify_state
|
||||
)
|
||||
if _overlay_cleared:
|
||||
self._clear_active_overlays_for_interrupt()
|
||||
event.app.current_buffer.reset()
|
||||
event.app.invalidate()
|
||||
|
||||
if _overlay_cleared and not (self._agent_running and self.agent):
|
||||
return
|
||||
|
||||
if self._agent_running and self.agent:
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@ AUTHOR_MAP = {
|
|||
"znding04@gmail.com": "znding04", # PR #15487 salvage (distinguish OpenRouter upstream 429 from account 429; upstream_rate_limit failover reason)
|
||||
"zkowkmdx@sharklasers.com": "nnnet", # PR #25142 salvage (stop STT-failure chatter poisoning the LLM prompt; drop hardcoded English notice)
|
||||
"vladimsmirnoff33@gmail.com": "londo161", # PR #15795 salvage (redact status --all API keys; tolerate dict/str compression message shape)
|
||||
"neo.assistant2026@gmail.com": "neo-2026", # PR #14026 salvage (clear input-blocking overlays on interrupt so the CLI doesn't freeze; #13618)
|
||||
"cypher@augmentl.com": "Nickperillo", # PR #8008 salvage (Discord channel-name matching + flush pending sends on shutdown)
|
||||
"tenoryang@outlook.com": "MarioYounger", # PR #9028 salvage (bash/sh heredoc approval, NFKC homograph fold, execute_code CREDS/BEARER/APIKEY env filter)
|
||||
"peet.wannasarnmetha@gmail.com": "peetwan", # PR #51841 salvage (loopback ws-ping tuning + token-frame coalescing + loop heartbeat; #48445/#50005)
|
||||
|
|
|
|||
|
|
@ -644,3 +644,96 @@ class TestPersistPromptSummary:
|
|||
assert "Clarify" in summary
|
||||
assert "Pick a path?" in summary
|
||||
assert "B" in summary
|
||||
|
||||
|
||||
class TestClearOverlaysForInterrupt:
|
||||
"""Regression tests for #14026 — interrupting a running agent must clear
|
||||
every input-blocking overlay (approval/clarify/sudo/secret) so the CLI
|
||||
isn't left frozen with no thread servicing the prompt."""
|
||||
|
||||
def _make_cli(self):
|
||||
cli = _make_cli_stub()
|
||||
# Attributes the helper touches that the base stub doesn't set.
|
||||
cli._clarify_state = None
|
||||
cli._clarify_freetext = False
|
||||
cli._secret_state = None
|
||||
cli._secret_deadline = 0
|
||||
cli._paint_now = MagicMock()
|
||||
return cli
|
||||
|
||||
def test_clears_all_four_overlays_and_unblocks_queues(self):
|
||||
cli = self._make_cli()
|
||||
approval_q = queue.Queue()
|
||||
clarify_q = queue.Queue()
|
||||
sudo_q = queue.Queue()
|
||||
secret_q = queue.Queue()
|
||||
cli._approval_state = {"response_queue": approval_q}
|
||||
cli._clarify_state = {"response_queue": clarify_q}
|
||||
cli._clarify_freetext = True
|
||||
cli._sudo_state = {"response_queue": sudo_q, "timeout": 60}
|
||||
cli._sudo_deadline = 99999.0
|
||||
cli._secret_state = {"response_queue": secret_q, "var_name": "X"}
|
||||
|
||||
cli._clear_active_overlays_for_interrupt()
|
||||
|
||||
# All states nilled out.
|
||||
assert cli._approval_state is None
|
||||
assert cli._clarify_state is None
|
||||
assert cli._clarify_freetext is False
|
||||
assert cli._sudo_state is None
|
||||
assert cli._sudo_deadline == 0
|
||||
assert cli._secret_state is None
|
||||
|
||||
# Each blocked thread would have received a terminal value.
|
||||
assert approval_q.get_nowait() == "deny"
|
||||
assert clarify_q.get_nowait() # cancellation sentinel string
|
||||
assert sudo_q.get_nowait() == ""
|
||||
assert secret_q.get_nowait() == ""
|
||||
|
||||
def test_noop_when_no_overlays_active(self):
|
||||
cli = self._make_cli()
|
||||
cli._clear_active_overlays_for_interrupt()
|
||||
assert cli._approval_state is None
|
||||
assert cli._clarify_state is None
|
||||
assert cli._sudo_state is None
|
||||
assert cli._secret_state is None
|
||||
|
||||
def test_dead_queue_does_not_block_clearing_others(self):
|
||||
"""A queue that raises on put() must not prevent the remaining
|
||||
overlays from being cleared."""
|
||||
cli = self._make_cli()
|
||||
|
||||
class _DeadQueue:
|
||||
def put(self, *_a, **_k):
|
||||
raise RuntimeError("queue gone")
|
||||
|
||||
clarify_q = queue.Queue()
|
||||
cli._approval_state = {"response_queue": _DeadQueue()}
|
||||
cli._clarify_state = {"response_queue": clarify_q}
|
||||
|
||||
cli._clear_active_overlays_for_interrupt()
|
||||
|
||||
assert cli._approval_state is None # cleared despite dead queue
|
||||
assert cli._clarify_state is None
|
||||
assert clarify_q.get_nowait()
|
||||
|
||||
def test_interrupt_unblocks_thread_blocked_on_approval(self):
|
||||
"""End-to-end: a worker blocked on the approval queue unblocks when the
|
||||
interrupt helper drains it."""
|
||||
cli = self._make_cli()
|
||||
approval_q = queue.Queue()
|
||||
cli._approval_state = {"response_queue": approval_q}
|
||||
result = {}
|
||||
|
||||
def _worker():
|
||||
result["value"] = approval_q.get(timeout=2)
|
||||
|
||||
t = threading.Thread(target=_worker, daemon=True)
|
||||
t.start()
|
||||
time.sleep(0.05)
|
||||
cli._clear_active_overlays_for_interrupt()
|
||||
t.join(timeout=2)
|
||||
|
||||
assert not t.is_alive(), "worker thread never unblocked"
|
||||
assert result["value"] == "deny"
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue