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>
83 lines
3.6 KiB
Python
83 lines
3.6 KiB
Python
"""A deferred delivery keeps its original destination and admission identity."""
|
|
import subprocess
|
|
from pathlib import Path
|
|
from unittest.mock import Mock
|
|
|
|
import pytest
|
|
|
|
from cron import bot_chat_delivery as queue
|
|
from cron import scheduler_delivery as delivery
|
|
from hermes_cli.active_sessions import try_acquire_active_session
|
|
from hermes_state import SessionDB
|
|
from tools.bot_live_delivery import read_delivery_result
|
|
|
|
|
|
@pytest.mark.parametrize("recipient", ["cli", "desktop", "renamed"])
|
|
def test_deferred_destination_does_not_follow_root_changes(tmp_path, monkeypatch, recipient):
|
|
source = tmp_path / "source"
|
|
home = tmp_path / "original" / "profiles" / "beta"
|
|
other = tmp_path / "other" / "profiles" / "beta"
|
|
home.mkdir(parents=True)
|
|
other.mkdir(parents=True)
|
|
monkeypatch.setenv("HERMES_HOME", str(source))
|
|
monkeypatch.setattr("hermes_cli.profiles.get_profile_dir", lambda _: home)
|
|
db = SessionDB(db_path=home / "state.db")
|
|
db.create_session(session_id="chat", source="cli")
|
|
db.set_session_title("chat", "Bot Chat")
|
|
lease, refusal = try_acquire_active_session(
|
|
session_id="chat", surface="cli", config={}, registry_home=home)
|
|
assert refusal is None
|
|
job = {"id": "job", "execution_id": "execution"}
|
|
try:
|
|
assert "queued" in delivery._deliver_to_bot_chat(job, "output", "beta")
|
|
key = job["_bot_chat_delivery_receipts"]["bot-chat:beta"]["delivery_id"]
|
|
finally:
|
|
lease.release()
|
|
db.close()
|
|
monkeypatch.setattr("hermes_cli.profiles.get_profile_dir", lambda _: other)
|
|
run = Mock(return_value=subprocess.CompletedProcess([], 0, "", ""))
|
|
monkeypatch.setattr(delivery, "_run_bot_chat_turn", run)
|
|
if recipient == "desktop":
|
|
lease, refusal = try_acquire_active_session(
|
|
session_id="chat", surface="desktop", config={}, registry_home=home,
|
|
metadata={"bot_live_delivery_consumer": True, "live_session_id": "live"})
|
|
assert refusal is None
|
|
elif recipient == "renamed":
|
|
home.rename(home.with_name("renamed"))
|
|
try:
|
|
with monkeypatch.context() as changed:
|
|
changed.setenv("HERMES_HOME", str(tmp_path / "new-source"))
|
|
queue.drain(source / "cron" / "bot_chat_pending")
|
|
queue.drain(source / "cron" / "bot_chat_pending")
|
|
if recipient == "cli":
|
|
assert run.call_count == 1
|
|
argv = run.call_args.args[0]
|
|
assert "-p" not in argv
|
|
assert Path(run.call_args.args[1]["HERMES_HOME"]) == home
|
|
elif recipient == "desktop":
|
|
run.assert_not_called()
|
|
receipt = read_delivery_result(home, key)
|
|
assert receipt is not None and receipt["status"] == "queued"
|
|
assert queue.read_pending(key)["status"] == "transferred"
|
|
else:
|
|
run.assert_not_called()
|
|
assert not home.exists()
|
|
assert queue.read_pending(key)["status"] == "ambiguous"
|
|
assert read_delivery_result(other, key) is None
|
|
finally:
|
|
lease.release()
|
|
|
|
|
|
def test_corrupt_record_is_retained_without_blocking_other_admissions(tmp_path, monkeypatch):
|
|
monkeypatch.setenv("HERMES_HOME", str(tmp_path))
|
|
queue.defer("a" * 64, {"id": "job"}, "first", "", tmp_path)
|
|
broken = tmp_path / "cron" / "bot_chat_pending" / "broken.json"
|
|
broken.write_text("{", encoding="utf-8")
|
|
seen = []
|
|
monkeypatch.setattr(delivery, "_deliver_to_bot_chat", lambda j, c, p, **kw: seen.append(c))
|
|
queue.drain()
|
|
queue.defer("b" * 64, {"id": "next"}, "second", "", tmp_path)
|
|
queue.drain()
|
|
assert seen == ["first", "second"]
|
|
assert broken.read_text(encoding="utf-8") == "{"
|