refactor(cli): unify signal-grace interrupt, kanban connect_closing, one-shot note prepend; drop dead process_loop OSError branch
This commit is contained in:
@@ -5325,12 +5325,7 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin, CLITuiMix
|
||||
# "queue" (Enter queues for next turn), or "steer" (Enter injects
|
||||
# mid-run via /steer, arriving after the next tool call).
|
||||
_bim = str(CLI_CONFIG["display"].get("busy_input_mode", "interrupt")).strip().lower()
|
||||
if _bim == "queue":
|
||||
self.busy_input_mode = "queue"
|
||||
elif _bim == "steer":
|
||||
self.busy_input_mode = "steer"
|
||||
else:
|
||||
self.busy_input_mode = "interrupt"
|
||||
self.busy_input_mode = _bim if _bim in ("queue", "steer") else "interrupt"
|
||||
|
||||
# self.verbose ONLY controls global DEBUG logging (root logger level).
|
||||
# display.tool_progress="verbose" controls tool-call rendering (full args,
|
||||
@@ -5373,16 +5368,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin, CLITuiMix
|
||||
_ump = CLI_CONFIG["display"].get("user_message_preview", {})
|
||||
if not isinstance(_ump, dict):
|
||||
_ump = {}
|
||||
try:
|
||||
_ump_first_lines = int(_ump.get("first_lines", 2))
|
||||
except (TypeError, ValueError):
|
||||
_ump_first_lines = 2
|
||||
try:
|
||||
_ump_last_lines = int(_ump.get("last_lines", 2))
|
||||
except (TypeError, ValueError):
|
||||
_ump_last_lines = 2
|
||||
self.user_message_preview_first_lines = max(1, _ump_first_lines)
|
||||
self.user_message_preview_last_lines = max(0, _ump_last_lines)
|
||||
self.user_message_preview_first_lines = max(1, _int_or(_ump.get("first_lines", 2), 2))
|
||||
self.user_message_preview_last_lines = max(0, _int_or(_ump.get("last_lines", 2), 2))
|
||||
|
||||
# Streaming display state
|
||||
self._stream_buf = "" # Partial line buffer for line-buffered rendering
|
||||
@@ -6928,17 +6915,12 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin, CLITuiMix
|
||||
# messages. Naive ``note + "\n\n" + agent_message`` crashed with
|
||||
# TypeError when an image was attached (agent_message is a list)
|
||||
# and a /model or /reload-skills note was queued for the turn.
|
||||
_msn = getattr(self, '_pending_model_switch_note', None)
|
||||
if _msn:
|
||||
agent_message = _prepend_note_to_message(agent_message, _msn)
|
||||
self._pending_model_switch_note = None
|
||||
# Prepend pending /reload-skills note so the model sees which
|
||||
# skills were added/removed before handling this turn. Same
|
||||
# one-shot queue pattern as the model-switch note above.
|
||||
_srn = getattr(self, '_pending_skills_reload_note', None)
|
||||
if _srn:
|
||||
agent_message = _prepend_note_to_message(agent_message, _srn)
|
||||
self._pending_skills_reload_note = None
|
||||
# Same one-shot queue for the /model note and the /reload-skills note.
|
||||
for _note_attr in ("_pending_model_switch_note", "_pending_skills_reload_note"):
|
||||
_note = getattr(self, _note_attr, None)
|
||||
if _note:
|
||||
agent_message = _prepend_note_to_message(agent_message, _note)
|
||||
setattr(self, _note_attr, None)
|
||||
# Barged mid-speech (VAD or record key)? Tell the model it was
|
||||
# cut off — same one-shot, API-local note channel as above.
|
||||
from tools.tts_streaming import SPEECH_INTERRUPTED_NOTE, take_speech_interrupted
|
||||
@@ -6946,8 +6928,6 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin, CLITuiMix
|
||||
agent_message = _prepend_note_to_message(agent_message, SPEECH_INTERRUPTED_NOTE)
|
||||
_moa_cfg = getattr(self, "_pending_moa_config", None)
|
||||
self._pending_moa_config = None
|
||||
if _moa_cfg is None:
|
||||
_moa_cfg = None
|
||||
# Model/skill notes and voice instructions are API-local. Keep
|
||||
# the original staged input as the durable transcript value so a
|
||||
# close-path marker follows the same dict into turn setup rather
|
||||
@@ -7694,13 +7674,6 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin, CLITuiMix
|
||||
continue
|
||||
logger.warning("process_loop unhandled error (msg may be lost): %s", e)
|
||||
except Exception as e:
|
||||
if isinstance(e, OSError) and getattr(e, "errno", None) == errno.EIO:
|
||||
self._mark_terminal_io_broken("process_loop")
|
||||
logger.warning(
|
||||
"process_loop EIO — freezing UI paints (#81521): %s",
|
||||
e,
|
||||
)
|
||||
continue
|
||||
logger.warning("process_loop unhandled error (msg may be lost): %s", e)
|
||||
|
||||
def _tui_signal_handler(self, signum, frame):
|
||||
@@ -7743,20 +7716,8 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin, CLITuiMix
|
||||
# never arm its own watchdog — leaving a "dead" CLI alive for
|
||||
# minutes (#65998 class). Never raises.
|
||||
_arm_exit_watchdog_on_shutdown_signal()
|
||||
try:
|
||||
_signal_agent = getattr(self, "agent", None)
|
||||
if _signal_agent is not None and getattr(self, "_agent_running", False):
|
||||
request_hard_interrupt(
|
||||
_signal_agent, f"received signal {signum}"
|
||||
)
|
||||
try:
|
||||
_grace = float(os.getenv("HERMES_SIGTERM_GRACE", "1.5"))
|
||||
except (TypeError, ValueError):
|
||||
_grace = 1.5
|
||||
if _grace > 0:
|
||||
time.sleep(_grace)
|
||||
except Exception:
|
||||
pass # never block signal handling
|
||||
if getattr(self, "_agent_running", False):
|
||||
_interrupt_agent_for_signal(getattr(self, "agent", None), signum)
|
||||
# Prefer a clean prompt_toolkit exit over `raise KeyboardInterrupt()`.
|
||||
# Raising KBI from a signal handler unwinds into whatever Python
|
||||
# frame the interpreter happens to be running — typically an
|
||||
@@ -8033,43 +7994,11 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin, CLITuiMix
|
||||
from prompt_toolkit.renderer import _output_screen_diff as _orig_osd
|
||||
|
||||
if not getattr(_pt_renderer, "_hermes_osd_patched", False):
|
||||
def _patched_output_screen_diff(
|
||||
app, output, screen, current_pos, color_depth,
|
||||
previous_screen, last_style, is_done, full_screen,
|
||||
attrs_for_style_string, style_string_has_style,
|
||||
size, previous_width,
|
||||
):
|
||||
"""Wraps pt's _output_screen_diff to suppress the
|
||||
reserve-vertical-space scroll (renderer.py L232-242).
|
||||
|
||||
Strategy: ONLY when previous_screen is non-None and
|
||||
its current height is genuinely smaller than the new
|
||||
screen's height, inflate it to match. This prevents
|
||||
the bottom-cursor-move at L242 without changing any
|
||||
other code path's behavior.
|
||||
|
||||
Critical: do NOT replace a None previous_screen with
|
||||
a fresh Screen() on the happy path — that would skip
|
||||
the proper reset_attributes()+erase_down() at L178-185
|
||||
which fires when previous_screen is None (first-paint /
|
||||
width-change). Without that reset, ANSI styles
|
||||
leak between renders.
|
||||
|
||||
Safety net: if the diff crashes with AttributeError /
|
||||
TypeError (corrupt previous_screen after tmux attach —
|
||||
"'cell' object has no attribute 'char'"), retry once
|
||||
with previous_screen=None so pt takes the first-paint
|
||||
erase path instead of wedging the event loop.
|
||||
"""
|
||||
return _hermes_call_output_screen_diff(
|
||||
_orig_osd,
|
||||
app, output, screen, current_pos, color_depth,
|
||||
previous_screen, last_style, is_done, full_screen,
|
||||
attrs_for_style_string, style_string_has_style,
|
||||
size, previous_width,
|
||||
)
|
||||
|
||||
_pt_renderer._output_screen_diff = _patched_output_screen_diff
|
||||
# Same parameter names as pt's function, so pt's keyword call
|
||||
# site binds unchanged; the guards live in the helper's docstring.
|
||||
_pt_renderer._output_screen_diff = functools.partial(
|
||||
_hermes_call_output_screen_diff, _orig_osd
|
||||
)
|
||||
_pt_renderer._hermes_osd_patched = True
|
||||
except Exception:
|
||||
pass
|
||||
@@ -8329,6 +8258,36 @@ class HermesCLI(CLIAgentSetupMixin, CLICommandsMixin, CLIBillingMixin, CLITuiMix
|
||||
self._release_active_session()
|
||||
|
||||
|
||||
def _int_or(value, default: int) -> int:
|
||||
"""``int(value)``, or ``default`` when it does not parse."""
|
||||
try:
|
||||
return int(value)
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
|
||||
|
||||
def _interrupt_agent_for_signal(agent, signum) -> None:
|
||||
"""Hard-interrupt ``agent`` for a shutdown signal, then sleep the grace window.
|
||||
|
||||
The grace (``HERMES_SIGTERM_GRACE``, default 1.5 s) lets the agent daemon thread
|
||||
see the interrupt on its next poll and ``_kill_process`` the tool's setsid
|
||||
subprocess group before the main thread unwinds — otherwise the child survives
|
||||
as an orphan. ``time.sleep`` releases the GIL so the daemon actually runs.
|
||||
Never raises (signal handler context).
|
||||
"""
|
||||
try:
|
||||
if agent is not None:
|
||||
request_hard_interrupt(agent, f"received signal {signum}")
|
||||
try:
|
||||
_grace = float(os.getenv("HERMES_SIGTERM_GRACE", "1.5"))
|
||||
except (TypeError, ValueError):
|
||||
_grace = 1.5
|
||||
if _grace > 0:
|
||||
time.sleep(_grace)
|
||||
except Exception:
|
||||
pass # never block signal handling
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Main Entry Point
|
||||
# ============================================================================
|
||||
@@ -8361,14 +8320,8 @@ def _run_kanban_goal_loop_q(cli: "HermesCLI", first_response: str) -> None:
|
||||
|
||||
# Resolve goal text from the card (title + body = the acceptance
|
||||
# criteria the judge evaluates against).
|
||||
conn = _kb.connect()
|
||||
try:
|
||||
with _kb.connect_closing() as conn:
|
||||
task = _kb.get_task(conn, task_id)
|
||||
finally:
|
||||
try:
|
||||
conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
if task is None:
|
||||
return
|
||||
|
||||
@@ -8398,29 +8351,12 @@ def _run_kanban_goal_loop_q(cli: "HermesCLI", first_response: str) -> None:
|
||||
return resp or ""
|
||||
|
||||
def _task_status() -> "str | None":
|
||||
c = _kb.connect()
|
||||
try:
|
||||
with _kb.connect_closing() as c:
|
||||
return _kb.goal_run_status(c, task_id, worker_run_id)
|
||||
finally:
|
||||
try:
|
||||
c.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _block(reason: str) -> None:
|
||||
c = _kb.connect()
|
||||
try:
|
||||
_kb.block_task(
|
||||
c,
|
||||
task_id,
|
||||
reason=reason,
|
||||
expected_run_id=worker_run_id,
|
||||
)
|
||||
finally:
|
||||
try:
|
||||
c.close()
|
||||
except Exception:
|
||||
pass
|
||||
with _kb.connect_closing() as c:
|
||||
_kb.block_task(c, task_id, reason=reason, expected_run_id=worker_run_id)
|
||||
|
||||
_run_loop(
|
||||
task_id=task_id,
|
||||
@@ -8542,6 +8478,13 @@ def _route_single_query_images(cli, query, effective_query, single_query_images,
|
||||
except Exception:
|
||||
_img_mode = "text"
|
||||
|
||||
def _text_fallback():
|
||||
# ``_preprocess_images_with_vision`` only knows local files; when
|
||||
# only URLs were supplied keep the original query text intact.
|
||||
if single_query_images:
|
||||
return cli._preprocess_images_with_vision(query, single_query_images, announce=False)
|
||||
return effective_query
|
||||
|
||||
if _img_mode == "native" and _build_parts is not None:
|
||||
try:
|
||||
_parts, _skipped = _build_parts(
|
||||
@@ -8552,26 +8495,11 @@ def _route_single_query_images(cli, query, effective_query, single_query_images,
|
||||
if any(p.get("type") == "image_url" for p in _parts):
|
||||
effective_query = _parts
|
||||
else:
|
||||
# All images unreadable — text fallback.
|
||||
# ``_preprocess_images_with_vision`` only knows
|
||||
# about local files; URLs would be lost there,
|
||||
# so keep the original query text intact when
|
||||
# only URLs were supplied.
|
||||
if single_query_images:
|
||||
effective_query = cli._preprocess_images_with_vision(
|
||||
query, single_query_images, announce=False,
|
||||
)
|
||||
effective_query = _text_fallback() # all images unreadable
|
||||
except Exception:
|
||||
if single_query_images:
|
||||
effective_query = cli._preprocess_images_with_vision(
|
||||
query, single_query_images, announce=False,
|
||||
)
|
||||
elif single_query_images:
|
||||
effective_query = cli._preprocess_images_with_vision(
|
||||
query,
|
||||
single_query_images,
|
||||
announce=False,
|
||||
)
|
||||
effective_query = _text_fallback()
|
||||
else:
|
||||
effective_query = _text_fallback()
|
||||
return effective_query
|
||||
|
||||
|
||||
@@ -8591,14 +8519,8 @@ def _collect_kanban_task_images(single_query_images):
|
||||
from hermes_cli import kanban_db as _kb
|
||||
from agent.image_routing import extract_image_refs as _extract_refs
|
||||
|
||||
_conn = _kb.connect()
|
||||
try:
|
||||
with _kb.connect_closing() as _conn:
|
||||
_task = _kb.get_task(_conn, _kanban_task_id)
|
||||
finally:
|
||||
try:
|
||||
_conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
_body = getattr(_task, "body", "") if _task is not None else ""
|
||||
if _body:
|
||||
_kb_paths, _kb_urls = _extract_refs(_body)
|
||||
@@ -8631,18 +8553,7 @@ def _install_single_query_signal_handlers(cli):
|
||||
# covers wedges in the unwind below that would otherwise leave the
|
||||
# process alive with no watchdog (#65998 class). Never raises.
|
||||
_arm_exit_watchdog_on_shutdown_signal()
|
||||
try:
|
||||
_agent = getattr(cli, "agent", None)
|
||||
if _agent is not None:
|
||||
request_hard_interrupt(_agent, f"received signal {signum}")
|
||||
try:
|
||||
_grace = float(os.getenv("HERMES_SIGTERM_GRACE", "1.5"))
|
||||
except (TypeError, ValueError):
|
||||
_grace = 1.5
|
||||
if _grace > 0:
|
||||
time.sleep(_grace)
|
||||
except Exception:
|
||||
pass # never block signal handling
|
||||
_interrupt_agent_for_signal(getattr(cli, "agent", None), signum)
|
||||
# Kanban worker (#28181): a non-daemon worker thread blocked in
|
||||
# _wait_for_process survives KeyboardInterrupt, so the PID stays alive and
|
||||
# the dispatcher's _pid_alive sees 'running' forever. os._exit(0) instead,
|
||||
|
||||
Reference in New Issue
Block a user