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>
80 lines
3.2 KiB
Python
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)
|