Files
hermes-agent/tests/gateway/test_cli_plugin_command_own_socket.py
kshitijk4poor 9ca23c6840 fix(cli): run every /cron form on the gateway that owns the cron store
Symptom: in a classic `hermes chat` session on the shared gateway, /cron
add|edit|pause|resume|run|remove still ran main's handler in the CLI
process, which called the cronjob tool there and wrote the profile's
cron/jobs.json from the client. Under strace, every write to jobs.json
came from the `chat --cli` process and none from the gateway.

Root cause: the one-gateway runtime (#106742) made the gateway the
owner of the profile's runtime state and of the ticker that fires cron
jobs. It left CLICommandsMixin._cron_* in the client, and the
gateway's slash.exec refused /cron for every local session except TUI
reads (2efe028116). That left the cron store with two writers.

Change:
- Move main's /cron handler (overview, list and every write) into
  hermes_cli/cli_cron_lines.py as line builders (cron_command_lines),
  with no other behaviour change. The CLI prints them when it has no
  gateway session. The unused CLICommandsMixin._cron_list goes; its
  test and eval read cron_list_lines directly.
- On the gateway, /cron is an owner command
  (gateway/session_cron_commands.py). Bare /cron and `list` are reads
  for every local session; that includes Desktop- and ACP-created
  sessions, which main refused with unsupported_command. Every other
  form is an admission-fenced control. An unclosed quote is refused as
  invalid_params before pre_command fires. `/cron list -a` keeps the
  list-all alias that the TUI read path accepted.
- A /cron write runs only when it arrives on a connection that is not
  attached to the session: the classic CLI's own command socket
  (GatewayConnection.call_until), which waits with no deadline. A
  connection attached to the session keeps main's unsupported_command.
  That is how Ink, Desktop and ACP send /cron: on their main
  connection, which the gateway answers one request at a time and
  which waits with a deadline, while a /cron run lasts as long as its
  job. The gate is on the requesting connection, not on the surface
  that created the session, so a classic CLI that resumed a TUI-made
  session still writes (as on main), and Ink on a CLI-made session is
  still refused.
- The classic CLI sends /cron to slash.exec
  (gateway_setting_commands.GATEWAY_SETTINGS) with no deadline, on a
  socket of its own (gateway_plugin_commands.call_unbounded, now shared
  with plugin commands; its Ctrl+C handle is renamed
  _gateway_command_wait). Ctrl+C prints the stopped line and
  propagates.
- Regenerate the parity table. test_pre_command_in_gateway pinned the
  old refusal of every classic CLI /cron; it now pins that the accepted
  /cron forms fire pre_command once and a refused one (an unclosed
  quote) fires nothing.

Why: the design contract allows one writer per owned store, and
clients only read and submit. The gateway already serves this
profile's cron store to its ticker and to Desktop (cron.manage). Now
the classic CLI's writes go through the same process.

Refs #134889 (T1)
Refs #139158
2026-10-20 13:08:24 +05:30

161 lines
8.2 KiB
Python

"""A classic CLI plugin command never holds the CLI's own gateway connection (#134889 PLG-7).
The gateway answers one socket's requests one at a time, in arrival order (``tui_gateway/ws.py``
``_dispatch_loop``), and a plugin handler may run for minutes. So ``slash.exec`` for a plugin command
goes over a socket of its own to the same gateway, for the same session: while the handler runs, even
after Ctrl+C stopped the wait, the CLI's reads, prompt admissions and interrupts are answered at once.
Main's CLI ran the handler itself, without occupying any gateway connection.
"""
import os
import signal
import threading
from types import SimpleNamespace
import pytest
from tests.gateway.fixtures.local_recovery_probe import Model, daemon
from tests.gateway.test_cli_plugin_command_in_gateway import (
_SLOW, _dial_running, _no_local_plugins, _plugin_home, _release, _typed_in_thread,
)
from tests.waiting import wait_until
# The command socket's thread (hermes_cli/gateway_connection.py); none may outlive its command.
_COMMAND_THREAD = 'hermes-gateway-command'
def _command_threads():
return [thread for thread in threading.enumerate() if thread.name == _COMMAND_THREAD]
def _cli(monkeypatch, home):
"""``(cli, connection)``: a classic CLI on its own production connection to ``home``."""
import cli as cli_mod
from cli import HermesCLI
from hermes_cli.gateway_connection import GatewayConnection
from hermes_cli.gateway_session_store import GatewaySessionStore
_no_local_plugins(monkeypatch)
monkeypatch.setattr(cli_mod, '_ensure_skill_commands', dict)
monkeypatch.setattr(cli_mod, 'get_skill_bundles', dict)
monkeypatch.setenv('HERMES_HOME', str(home))
conn = GatewayConnection(connect=_dial_running(home))
c = HermesCLI()
c.agent, c._session_db, c._gateway_session_open = None, GatewaySessionStore(conn), False
return c, conn
def _slow_source(tmp_path):
gate, marks = tmp_path / 'gate', tmp_path / 'marks'
os.mkfifo(gate)
marks.mkdir()
return gate, marks, _SLOW.replace('GATE', repr(str(gate))).replace('MARKS', repr(str(marks)))
def _ctrl_c(c):
"""The classic TUI's Ctrl+C binding, with an empty draft."""
from prompt_toolkit.buffer import Buffer
app = SimpleNamespace(current_buffer=Buffer(), invalidate=lambda: None,
exit=lambda: setattr(c, '_should_exit', True))
c._tui_handle_ctrl_c(SimpleNamespace(app=app))
@pytest.mark.platforms("linux")
def test_a_stopped_plugin_command_leaves_the_cli_connection_free(tmp_path, monkeypatch, capsys):
"""Ctrl+C stops the wait while the handler stays blocked in the gateway. Before it is released, the
CLI's connection answers a prompt admission, a receipt read and the interrupt of that turn, in
order. Released, the handler's late reply is never shown and no command socket or pending
request is left."""
from hermes_cli import gateway_plugin_commands
from hermes_cli.gateway_client import GatewayClientError
models, model_post = [], Model.do_POST
def seen_post(handler): # the loopback model server, to hold and release the admitted turn
models.append(handler.server)
model_post(handler)
monkeypatch.setattr(Model, 'do_POST', seen_post)
gate, marks, source = _slow_source(tmp_path)
outcomes = {}
def step(name, method, **params): # the primary connection's ordinary call, its default budget
try:
outcomes[name] = conn.call(method, **params)
except GatewayClientError as exc:
outcomes[name] = exc
return outcomes[name]
with _plugin_home(tmp_path, source) as (root, home, env):
c, conn = _cli(monkeypatch, home)
try:
with daemon(root, home, env, barrier=True, fixture='plugin_mcp_daemon.py') as (proc, _desc):
c._ensure_gateway_session()
sid = c.session_id
assert 'ppslow' in (gateway_plugin_commands.names(c) or {}) # cached: no catalog read below
capsys.readouterr()
stopped = _typed_in_thread(c, '/ppslow held')
wait_until(lambda: (marks / 'started-held').exists(), timeout=30, what='the handler to start')
_ctrl_c(c)
stopped.join(60)
stop_out, stop_alive = capsys.readouterr().out, stopped.is_alive()
try:
receipt = step('submit', 'prompt.submit', session_id=sid, submission_id='held-turn',
text='BLOCK_STARTED')
if isinstance(receipt, dict):
wait_until(lambda: models and models[-1].blocked.is_set(), timeout=60,
what='the admitted turn to reach the model')
read = step('read', 'prompt.receipt', session_id=sid, admission_id=receipt['admission_id'])
if isinstance(read, dict):
step('interrupt', 'session.interrupt', session_id=sid,
execution_generation=read['execution_generation'])
handler_held = not (marks / 'done-held').exists()
finally:
for model in models:
model.release.set()
_release(gate, 'late')
wait_until(lambda: (marks / 'done-held').exists(), timeout=30, what='the handler to finish')
after = c._process_unregistered_slash('/ppquick', '/ppquick'), capsys.readouterr().out
wait_until(lambda: not _command_threads(), timeout=30, what='the command sockets to close')
pending = dict(conn.client.pending) if conn.client is not None else {}
proc.send_signal(signal.SIGINT)
proc.wait(timeout=20)
finally:
conn.close()
assert not stop_alive and not c._should_exit, (stop_alive, c._should_exit)
assert 'stopped waiting' in stop_out and 'keeps running in the gateway' in stop_out, stop_out
# answered while the stopped handler was still blocked in the gateway
assert handler_held, 'the handler finished before the requests were answered'
assert isinstance(outcomes.get('submit'), dict), outcomes
assert outcomes['submit']['status'] in {'queued', 'started'}, outcomes
assert isinstance(outcomes.get('read'), dict) and outcomes['read']['status'] == 'started', outcomes
assert isinstance(outcomes.get('interrupt'), dict), outcomes
assert after[0] is True and 'QUICK_DONE' in after[1] and 'SLOW_DONE' not in after[1], after
assert pending == {}, pending
@pytest.mark.platforms("linux")
def test_a_gateway_hang_up_during_a_plugin_command_ends_the_wait_and_leaves_nothing_pending(
tmp_path, monkeypatch, capsys):
"""The gateway dies while the handler runs: the wait ends with the transport's error (the command
is not sent again), and no command socket, thread or pending request is left."""
_gate, marks, source = _slow_source(tmp_path)
with _plugin_home(tmp_path, source) as (root, home, env):
c, conn = _cli(monkeypatch, home)
try:
with daemon(root, home, env, barrier=True, fixture='plugin_mcp_daemon.py') as (proc, _desc):
c._ensure_gateway_session()
from hermes_cli import gateway_plugin_commands
assert 'ppslow' in (gateway_plugin_commands.names(c) or {})
capsys.readouterr()
waiting = _typed_in_thread(c, '/ppslow hangup')
wait_until(lambda: (marks / 'started-hangup').exists(), timeout=30, what='the handler to start')
proc.kill()
proc.wait(timeout=20)
waiting.join(60)
out, alive = capsys.readouterr().out, waiting.is_alive()
wait_until(lambda: not _command_threads(), timeout=30, what='the command socket to close')
pending = dict(conn.client.pending) if conn.client is not None else {}
finally:
conn.close()
assert not alive, 'the wait outlived the gateway'
assert '/ppslow: Gateway disconnected' in out and 'stopped waiting' not in out, out
assert out.count('/ppslow') == 1 and 'SLOW_DONE' not in out, out
wait = getattr(c, '_gateway_command_wait', None)
assert wait is None and pending == {}, (wait, pending)