Files
hermes-agent/tests/cron/test_bot_chat_cli_home.py
teknium1 4ce832eeb9 fix(cron): bot-chat delivery cap bounds the bot's turn, not its exit linger
A cron job delivering to Bot Chat booked "timed out after 600s" for turns
that finished in seconds: cron/scheduler_delivery.py::_deliver_to_bot_chat
waited for the `hermes chat -Q` child to EXIT, but when the bot's turn had
messaged a teammate (message_agent -> notify_on_complete runner) the child
then runs the one-shot exit linger, bounded by
terminal.oneshot_completion_wait_seconds (default 600) — the same default as
cron.bot_chat_delivery_timeout_seconds. The cap counts from the claim, the
linger only starts when the turn ends, so the cap expired first on every
such delivery, booked a completed turn as a timeout, held the job's fire
fence for the full cap, and killed the child mid-linger — tearing down the
reply the linger exists to protect (#90879).

Ordering the two bounds cannot fix it (the turn has no duration bound; the
linger has its own contract), so the cap stops competing with the linger:

- hermes_cli/quiet_single_query.py: the -Q child accepts a per-process
  report path (HERMES_QUIET_TURN_REPORT_FILE), popped before the turn like
  HERMES_TURN_AUTHOR so nothing the turn spawns inherits it; cli.py writes
  {pid, exit_code, error} there the moment the turn ends, BEFORE the linger.
- cron: _run_bot_chat_turn polls the child and that report under the cap.
  Report present -> the delivery is booked from it (real exit code and
  stream tails when the child exits within a short grace) and the still-
  lingering child is left running, drained and reaped by a daemon thread.
  No report by the cap -> the turn never ended: killed and booked as a
  timeout, exactly as before. The linger itself is untouched.

Live repro (real _deliver_to_bot_chat, real `hermes chat -Q` against a
loopback provider whose turn spawns `sleep 90` with notify_on_complete,
cap 45s): base books the timeout at 45.7s for a turn that ended at +5s and
kills the child; fixed head books success at 11.2s, the child lingers, the
teammate follow-up turn runs at +97s and the child exits on its own.

Supersedes #113649's cap = delivery + linger (the thread shows a headroom
only moves the race and lengthens the fence hold); analysis credit to the
reporter and the thread's independent verification.

Fixes #113608

Co-authored-by: KoNit-K <konit.block@protonmail.com>
2026-09-18 10:29:13 -07:00

80 lines
3.2 KiB
Python

"""The unowned CLI lane executes only at the home used for owner discovery."""
import json
from pathlib import Path
import subprocess
import sys
from unittest.mock import Mock
import pytest
from cron import scheduler_delivery as delivery
from hermes_constants import reset_hermes_home_override, set_hermes_home_override
@pytest.mark.parametrize("profile", ["beta", "default", ""])
def test_cli_keeps_discovered_home_when_launch_selection_changes(tmp_path, monkeypatch, profile):
root = tmp_path / "custom"
home = root / "profiles" / "beta" if profile == "beta" else root
home.mkdir(parents=True)
other = root / "profiles" / "other"
other.mkdir(parents=True)
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("HERMES_HOME", str(root))
token = set_hermes_home_override(str(root))
real_run = subprocess.run
seen = []
def discover(target):
assert target == home
(root / "active_profile").write_text("other", encoding="utf-8")
return None
def run(argv, env, report_path, timeout):
# Exercise the actual startup resolver with the production child env/flags.
code = ('import json,sys; sys.argv=["hermes"]+json.loads(sys.argv[1]); '
'import hermes_cli.main; from hermes_constants import get_hermes_home; '
'print(json.dumps(str(get_hermes_home())))')
# Everything after the launcher (binary or ``python -m hermes_cli.main``) is the CLI argv.
cli_argv = argv[3:] if argv[1:3] == ["-m", "hermes_cli.main"] else argv[1:]
result = real_run([sys.executable, "-c", code, json.dumps(cli_argv)],
env=env, capture_output=True, text=True, timeout=30)
assert result.returncode == 0, result.stderr
seen.append(Path(json.loads(result.stdout.strip().splitlines()[-1])))
return subprocess.CompletedProcess(argv, 0, "", "")
monkeypatch.setattr("tools.bot_live_delivery.find_canonical_live_owner", discover)
monkeypatch.setattr(delivery, "_run_bot_chat_turn", run)
try:
assert delivery._deliver_to_bot_chat({"id": "job"}, "output", profile) is None
assert seen == [home]
assert not (tmp_path / ".hermes").exists()
finally:
reset_hermes_home_override(token)
@pytest.mark.parametrize("removed_during_discovery", [False, True])
def test_missing_destination_never_launches_or_recreates(tmp_path, monkeypatch, removed_during_discovery):
root = tmp_path / "custom"
root.mkdir()
home = root / "profiles" / "beta"
if removed_during_discovery:
home.mkdir(parents=True)
monkeypatch.setenv("HERMES_HOME", str(root))
token = set_hermes_home_override(str(root))
run = Mock(return_value=subprocess.CompletedProcess([], 0, "", ""))
def discover(target):
if home.exists():
home.rmdir()
return None
monkeypatch.setattr("tools.bot_live_delivery.find_canonical_live_owner", discover)
monkeypatch.setattr(delivery, "_run_bot_chat_turn", run)
try:
error = delivery._deliver_to_bot_chat({"id": "job"}, "output", "beta")
assert error is not None and str(home) in error
run.assert_not_called()
assert not home.exists()
finally:
reset_hermes_home_override(token)