diff --git a/cli.py b/cli.py index 1287d90ce0..21144c3f48 100644 --- a/cli.py +++ b/cli.py @@ -4153,10 +4153,13 @@ def _run_quiet_single_query(cli, effective_query, emitter=None): from agent.turn_author import take_turn_author_from_env from hermes_cli.quiet_single_query import ( adopt_unanswered_turn, bind_quiet_session_key, continue_quiet_notify_completions, - quiet_notify_linger_seconds, + quiet_notify_linger_seconds, take_turn_report_path, write_turn_report, ) author = take_turn_author_from_env() + # A spawner that bounds only the turn (cron Bot Chat lane) learns the outcome from this + # report, written before the linger below; popped so tool subprocesses do not inherit it. + turn_report_path = take_turn_report_path() # A dispatcher's re-run of a failed bot delivery resumes the DM row its first attempt persisted. adopt_unanswered_turn(cli, effective_query) author_kwargs = {"turn_author": author} if author is not None and _accepts_keyword(cli.agent.run_conversation, "turn_author") else {} @@ -4174,6 +4177,12 @@ def _run_quiet_single_query(cli, effective_query, emitter=None): # The exit line below reports session_id to stderr for automation wrappers; # without this sync it would point at the ended parent after compression. _sync_cli_session_id_from_agent(cli) + # The turn is over and persisted: the one-shot exit linger that follows protects nested + # notify_on_complete replies and is NOT part of the spawner's delivery (#113608). + write_turn_report( + turn_report_path, exit_code=_single_query_exit_code(result), + error=str(result.get("error") or "") if isinstance(result, dict) else "agent turn did not run", + ) if isinstance(result, dict) and not result.get("failed"): history = result.get("messages") or cli.conversation_history diff --git a/cron/scheduler_delivery.py b/cron/scheduler_delivery.py index 4f8e2451c7..82c968a532 100644 --- a/cron/scheduler_delivery.py +++ b/cron/scheduler_delivery.py @@ -17,6 +17,8 @@ import os import shutil import subprocess import sys +import threading +import time from dataclasses import dataclass from typing import Any, List, Optional @@ -694,6 +696,48 @@ _BOT_CHAT_STDERR_TAIL = 500 # stdout is the model's answer; only a short tail is persisted (jobs.json / ledger). _BOT_CHAT_STDOUT_TAIL = 200 _BOT_CHAT_BANNER_PREFIXES = ("Resumed session", "session_id:") +# After the child reports its turn, a child with nothing to linger for exits at once; give it +# that long so its real exit code and stream tails are booked instead of the report's summary. +_BOT_CHAT_EXIT_GRACE_SECONDS = 2.0 + + +def _run_bot_chat_turn(argv: list, env: dict, report_path: str, timeout: float) -> subprocess.CompletedProcess: + """Run one ``hermes chat -Q`` delivery child; the cap bounds the TURN, not the process. + + The child records its turn outcome at *report_path* (``hermes_cli.quiet_single_query``) + the moment the turn ends, then runs the one-shot exit linger for nested + ``notify_on_complete`` replies — bounded by ``terminal.oneshot_completion_wait_seconds``, + whose default equals this lane's cap, so waiting for process exit booked every delivered + turn that left a reply pending as a timeout and killed the linger (#113608). Once the + report exists the delivery is booked from it and the still-lingering child is left + running (a daemon thread drains and reaps it); only a turn that never ends is killed. + """ + from hermes_cli.quiet_single_query import read_turn_report + + proc = subprocess.Popen( + argv, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True, + env=env, creationflags=windows_hide_flags()) + streams: dict = {} + + def _drain() -> None: + streams["out"], streams["err"] = proc.communicate() + + drain = threading.Thread(target=_drain, name=f"bot-chat-delivery-{proc.pid}", daemon=True) + drain.start() + deadline = time.monotonic() + timeout + report = None + while True: + drain.join(timeout=0.25 if report is None else _BOT_CHAT_EXIT_GRACE_SECONDS) + if not drain.is_alive(): + return subprocess.CompletedProcess(argv, proc.returncode, streams.get("out", ""), streams.get("err", "")) + if report is not None: + # Turn over, child still lingering for a nested reply: not this lane's wait. + return subprocess.CompletedProcess(argv, int(report["exit_code"]), "", report.get("error") or "") + report = read_turn_report(report_path, proc.pid) + if report is None and time.monotonic() >= deadline: + proc.kill() + drain.join(timeout=5.0) + raise subprocess.TimeoutExpired(argv, timeout) def _format_failure_streams(result) -> str: @@ -870,9 +914,10 @@ def _deliver_to_bot_chat(job: dict, content: str, profile: str, *, deferred: Opt "chat", "--in", "~", "-c", "Bot Chat", "--create-if-missing", "-Q", "--query-file", query_file, ] - result = subprocess.run( - argv, capture_output=True, text=True, timeout=_get_bot_chat_delivery_timeout(), env=env, - creationflags=windows_hide_flags()) + from hermes_cli.quiet_single_query import TURN_REPORT_FILE_ENV + report_file = f"{query_file}.turn.json" + env[TURN_REPORT_FILE_ENV] = report_file + result = _run_bot_chat_turn(argv, env, report_file, _get_bot_chat_delivery_timeout()) if result.returncode != 0: tail = _format_failure_streams(result) logger.warning( @@ -899,8 +944,9 @@ def _deliver_to_bot_chat(job: dict, content: str, profile: str, *, deferred: Opt "The result is saved; run `hermes cron runs` to see it, or `hermes doctor` if this keeps happening") finally: if query_file: - with contextlib.suppress(OSError): - os.unlink(query_file) + for path in (query_file, f"{query_file}.turn.json"): + with contextlib.suppress(OSError): + os.unlink(path) def _normalize_deliver_value(deliver) -> str: diff --git a/hermes_cli/quiet_single_query.py b/hermes_cli/quiet_single_query.py index 736f671f8e..751ee28a4b 100644 --- a/hermes_cli/quiet_single_query.py +++ b/hermes_cli/quiet_single_query.py @@ -11,6 +11,7 @@ was never injected as a follow-up. from __future__ import annotations import contextlib +import json import os import time from typing import Any, Callable, MutableMapping @@ -18,6 +19,44 @@ from typing import Any, Callable, MutableMapping # Nested A→B→C is one extra turn; this caps a runaway message_agent chain. _MAX_QUIET_NOTIFY_ROUNDS = 8 +# A spawner that bounds only the TURN (the cron Bot Chat lane) hands the quiet child a report +# path here. The child records the turn's outcome there the moment the turn ends, BEFORE the +# one-shot exit linger, so the spawner can book the delivery and stop waiting while the linger +# keeps protecting nested ``notify_on_complete`` replies. Popped before the turn runs (same +# contract as HERMES_TURN_AUTHOR): nothing the turn spawns inherits it, and a nested one-shot +# never writes over its host's report — the record also carries the writer's pid. +TURN_REPORT_FILE_ENV = "HERMES_QUIET_TURN_REPORT_FILE" + + +def take_turn_report_path(environ: MutableMapping[str, str] = os.environ) -> str | None: + """Read and remove the spawner's turn-report path so subprocesses started during the turn do not inherit it.""" + return environ.pop(TURN_REPORT_FILE_ENV, None) or None + + +def write_turn_report(path: str | None, *, exit_code: int, error: str = "") -> None: + """Atomically record ``{pid, exit_code, error}`` at *path*; a no-op without a path. Never raises: + the report is the spawner's convenience, the turn itself is already persisted.""" + if not path: + return + record = {"pid": os.getpid(), "exit_code": int(exit_code), "error": str(error or "")} + with contextlib.suppress(Exception): + tmp = f"{path}.{os.getpid()}.tmp" + with open(tmp, "w", encoding="utf-8") as fh: + json.dump(record, fh) + os.replace(tmp, path) + + +def read_turn_report(path: str, pid: int) -> dict | None: + """The child's turn report, or None while absent, unreadable, or written by another process.""" + try: + with open(path, encoding="utf-8") as fh: + record = json.load(fh) + except (OSError, ValueError): + return None + if not isinstance(record, dict) or record.get("pid") != pid: + return None + return record + @contextlib.contextmanager def bind_quiet_session_key(session_id: str): diff --git a/tests/cron/test_bot_chat_cli_home.py b/tests/cron/test_bot_chat_cli_home.py index dadcf5f923..dcbd7a8806 100644 --- a/tests/cron/test_bot_chat_cli_home.py +++ b/tests/cron/test_bot_chat_cli_home.py @@ -29,7 +29,7 @@ def test_cli_keeps_discovered_home_when_launch_selection_changes(tmp_path, monke (root / "active_profile").write_text("other", encoding="utf-8") return None - def run(argv, **kwargs): + 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; ' @@ -37,13 +37,13 @@ def test_cli_keeps_discovered_home_when_launch_selection_changes(tmp_path, monke # 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=kwargs["env"], capture_output=True, text=True, timeout=30) + 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.subprocess, "run", run) + monkeypatch.setattr(delivery, "_run_bot_chat_turn", run) try: assert delivery._deliver_to_bot_chat({"id": "job"}, "output", profile) is None assert seen == [home] @@ -69,7 +69,7 @@ def test_missing_destination_never_launches_or_recreates(tmp_path, monkeypatch, return None monkeypatch.setattr("tools.bot_live_delivery.find_canonical_live_owner", discover) - monkeypatch.setattr(delivery.subprocess, "run", run) + 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 diff --git a/tests/cron/test_bot_chat_pending.py b/tests/cron/test_bot_chat_pending.py index caa87c7ab0..4498a66b06 100644 --- a/tests/cron/test_bot_chat_pending.py +++ b/tests/cron/test_bot_chat_pending.py @@ -21,7 +21,7 @@ def test_cli_owner_deferral_and_attempt_fence(tmp_path, monkeypatch, error): lease, refusal = try_acquire_active_session(session_id="chat", surface="cli", config={}, registry_home=tmp_path) assert refusal is None and lease is not None run = Mock(side_effect=error, return_value=subprocess.CompletedProcess([], 0, "", "")) - monkeypatch.setattr(delivery.subprocess, "run", run) + monkeypatch.setattr(delivery, "_run_bot_chat_turn", run) monkeypatch.setattr(delivery.shutil, "which", lambda _: "/bin/hermes") job = {"id": "job", "execution_id": "execution"} try: @@ -69,12 +69,12 @@ def test_delivery_exception_retains_attempt_and_continues_siblings(tmp_path, mon raise PermissionError("target traversal denied after discovery") return original_is_dir(self) - def run(*args, **kwargs): - calls.append(kwargs["env"]["HERMES_HOME"]) + def run(argv, env, report_path, timeout): + calls.append(env["HERMES_HOME"]) return subprocess.CompletedProcess([], 0, "", "") monkeypatch.setattr(importlib.util, "find_spec", resolve_cli) - monkeypatch.setattr(delivery.subprocess, "run", run) + monkeypatch.setattr(delivery, "_run_bot_chat_turn", run) monkeypatch.setattr(Path, "is_dir", is_dir) queue.drain() assert queue.read_pending("b" * 64)["status"] == "ambiguous" @@ -114,7 +114,7 @@ def test_policy_change_settles_diagnostic_without_waiting_for_cli_owner(tmp_path lease, refusal = try_acquire_active_session(session_id="chat", surface="cli", config={}, registry_home=tmp_path) assert refusal is None run = Mock() - monkeypatch.setattr(delivery.subprocess, "run", run) + monkeypatch.setattr(delivery, "_run_bot_chat_turn", run) job = {"id": "failure", "execution_id": "run"} try: assert "queued" in delivery._deliver_to_bot_chat(job, "diagnostic", "", for_failure=True) diff --git a/tests/cron/test_bot_chat_pending_identity.py b/tests/cron/test_bot_chat_pending_identity.py index bdb044fdb8..41483d54ed 100644 --- a/tests/cron/test_bot_chat_pending_identity.py +++ b/tests/cron/test_bot_chat_pending_identity.py @@ -36,7 +36,7 @@ def test_deferred_destination_does_not_follow_root_changes(tmp_path, monkeypatch db.close() monkeypatch.setattr("hermes_cli.profiles.get_profile_dir", lambda _: other) run = Mock(return_value=subprocess.CompletedProcess([], 0, "", "")) - monkeypatch.setattr(delivery.subprocess, "run", run) + 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, @@ -53,7 +53,7 @@ def test_deferred_destination_does_not_follow_root_changes(tmp_path, monkeypatch assert run.call_count == 1 argv = run.call_args.args[0] assert "-p" not in argv - assert Path(run.call_args.kwargs["env"]["HERMES_HOME"]) == home + assert Path(run.call_args.args[1]["HERMES_HOME"]) == home elif recipient == "desktop": run.assert_not_called() receipt = read_delivery_result(home, key) diff --git a/tests/cron/test_bot_chat_warning_replay.py b/tests/cron/test_bot_chat_warning_replay.py index f4805461a1..8c126009d4 100644 --- a/tests/cron/test_bot_chat_warning_replay.py +++ b/tests/cron/test_bot_chat_warning_replay.py @@ -19,7 +19,7 @@ def owner(tmp_path, monkeypatch): db.set_session_title("chat", "Bot Chat") leases = [] run = Mock(side_effect=AssertionError("must not launch a second owner")) - monkeypatch.setattr(delivery.subprocess, "run", run) + monkeypatch.setattr(delivery, "_run_bot_chat_turn", run) def acquire(live): lease, refusal = try_acquire_active_session( diff --git a/tests/cron/test_cron_bot_chat_delivery.py b/tests/cron/test_cron_bot_chat_delivery.py index 10db89d725..bf80f9632e 100644 --- a/tests/cron/test_cron_bot_chat_delivery.py +++ b/tests/cron/test_cron_bot_chat_delivery.py @@ -6,8 +6,11 @@ preflight exemption, create-time validation, the subprocess delivery lane, and the delivery-targets listing used by UI pickers. """ +import os import subprocess import sys +import textwrap +import time from unittest import mock import pytest @@ -22,6 +25,7 @@ from cron.scheduler_delivery import ( parse_bot_chat_deliver_token, ) from cron.scheduler_preflight import _preflight_check_delivery +from hermes_cli.quiet_single_query import TURN_REPORT_FILE_ENV, write_turn_report # ── token parsing ──────────────────────────────────────────────────────────── @@ -124,12 +128,11 @@ def test_deliver_runs_canonical_bot_chat_lane(): chat --in ~ -c "Bot Chat" --create-if-missing -Q --query-file .""" calls = {} - def fake_run(argv, **kwargs): - calls["argv"] = argv - calls["kwargs"] = kwargs + def fake_run(argv, env, report_path, timeout): + calls["argv"], calls["env"], calls["report_path"] = argv, env, report_path return _completed() - with mock.patch.object(sched.subprocess, "run", side_effect=fake_run), \ + with mock.patch.object(sched_delivery, "_run_bot_chat_turn", side_effect=fake_run), \ mock.patch.object(sched_delivery.shutil, "which", return_value="/usr/bin/hermes"): err = _deliver_to_bot_chat({"id": "j1", "name": "Daily digest"}, "the output", "") @@ -145,11 +148,13 @@ def test_deliver_runs_canonical_bot_chat_lane(): assert "--query-file" in argv # Message rides a temp file, never inline argv (quote/expansion safety). assert not any("the output" in str(a) for a in argv) + # The child reports its turn outcome here so the cap bounds the turn, not the exit linger. + assert calls["env"][TURN_REPORT_FILE_ENV] == calls["report_path"] def test_deliver_failure_returns_error_string(): with mock.patch.object( - sched.subprocess, "run", return_value=_completed(returncode=1, stderr="boom") + sched_delivery, "_run_bot_chat_turn", return_value=_completed(returncode=1, stderr="boom") ), mock.patch.object(sched_delivery.shutil, "which", return_value="/usr/bin/hermes"): err = _deliver_to_bot_chat({"id": "j1", "name": "n"}, "out", "") assert err is not None @@ -160,7 +165,7 @@ def test_deliver_failure_reports_both_streams_labeled(): """A failed turn must keep stderr AND stdout, labeled — ``stderr or stdout`` discarded half the signal (#104056).""" with mock.patch.object( - sched.subprocess, "run", + sched_delivery, "_run_bot_chat_turn", return_value=_completed(returncode=1, stdout="banner out", stderr="boom-err"), ), mock.patch.object(sched_delivery.shutil, "which", return_value="/usr/bin/hermes"): err = _deliver_to_bot_chat({"id": "j1", "name": "n"}, "out", "") @@ -176,7 +181,7 @@ def test_deliver_failure_banner_only_stdout_names_exit_code_not_banner(): banner = ('↻ Resumed session 20260905_121420_8084c7 "Bot Chat" (1 user message, 1 total messages)' '\n\nsession_id: 20260905_121420_8084c7') with mock.patch.object( - sched.subprocess, "run", + sched_delivery, "_run_bot_chat_turn", return_value=_completed(returncode=1, stdout=banner, stderr=""), ), mock.patch.object(sched_delivery.shutil, "which", return_value="/usr/bin/hermes"): err = _deliver_to_bot_chat({"id": "j1", "name": "n"}, "out", "") @@ -192,7 +197,7 @@ def test_deliver_failure_persisted_stdout_tail_is_short_and_redacted(): answer on stdout is capped to a short tail and secrets are scrubbed.""" answer = "x" * 5000 + "\nToken: sk-ant-api03-" + "A" * 80 + " done" with mock.patch.object( - sched.subprocess, "run", + sched_delivery, "_run_bot_chat_turn", return_value=_completed(returncode=1, stdout=answer, stderr="boom-err"), ), mock.patch.object(sched_delivery.shutil, "which", return_value="/usr/bin/hermes"): err = _deliver_to_bot_chat({"id": "j1", "name": "n"}, "out", "") @@ -204,7 +209,7 @@ def test_deliver_failure_persisted_stdout_tail_is_short_and_redacted(): def test_deliver_timeout_returns_error_string(): with mock.patch.object( - sched.subprocess, "run", + sched_delivery, "_run_bot_chat_turn", side_effect=subprocess.TimeoutExpired(cmd="hermes", timeout=600), ), mock.patch.object(sched_delivery.shutil, "which", return_value="/usr/bin/hermes"): err = _deliver_to_bot_chat({"id": "j1", "name": "n"}, "out", "") @@ -216,13 +221,13 @@ def test_deliver_message_carries_cron_attribution(tmp_path): """The injected turn must self-identify as scheduled output, not the user.""" captured = {} - def fake_run(argv, **kwargs): + def fake_run(argv, env, report_path, timeout): qf = argv[argv.index("--query-file") + 1] with open(qf, encoding="utf-8") as fh: captured["message"] = fh.read() return _completed() - with mock.patch.object(sched.subprocess, "run", side_effect=fake_run), \ + with mock.patch.object(sched_delivery, "_run_bot_chat_turn", side_effect=fake_run), \ mock.patch.object(sched_delivery.shutil, "which", return_value="/usr/bin/hermes"): _deliver_to_bot_chat({"id": "j1", "name": "Daily digest"}, "the payload", "") @@ -231,6 +236,55 @@ def test_deliver_message_carries_cron_attribution(tmp_path): assert "the payload" in captured["message"] +_REPO_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(sched_delivery.__file__))) + + +def _child_env() -> dict: + """The stand-in child imports ``hermes_cli`` from this checkout, like the real ``-m hermes_cli.main``.""" + return {**os.environ, "PYTHONPATH": os.pathsep.join(p for p in (_REPO_ROOT, os.environ.get("PYTHONPATH")) if p)} + + +def test_turn_report_books_the_delivery_while_the_child_still_lingers(tmp_path): + """The cap bounds the TURN: a child that reported its turn and then lingers for a nested + notify_on_complete reply (bounded by oneshot_completion_wait_seconds, default == the cap) is + booked from the report promptly and is NOT killed (#113608).""" + report = tmp_path / "turn.json" + child = textwrap.dedent(""" + import os, time + from hermes_cli.quiet_single_query import TURN_REPORT_FILE_ENV, write_turn_report + write_turn_report(os.environ.pop(TURN_REPORT_FILE_ENV), exit_code=0) + time.sleep(30) + """) + procs, real_popen = [], subprocess.Popen + + def spy(*args, **kwargs): + procs.append(real_popen(*args, **kwargs)) + return procs[-1] + + started = time.monotonic() + try: + with mock.patch.object(sched_delivery.subprocess, "Popen", side_effect=spy): + result = sched_delivery._run_bot_chat_turn( + [sys.executable, "-c", child], {**_child_env(), TURN_REPORT_FILE_ENV: str(report)}, str(report), timeout=10) + elapsed = time.monotonic() - started + assert result.returncode == 0 + assert elapsed < 8, elapsed + assert procs[0].poll() is None, "the lingering child must survive the booking" + finally: + for proc in procs: + proc.kill() + proc.wait(timeout=10) + + +def test_turn_that_never_ends_is_still_killed_at_the_cap(tmp_path): + """Control: with no turn report the cap stays the guard it always was.""" + started = time.monotonic() + with pytest.raises(subprocess.TimeoutExpired): + sched_delivery._run_bot_chat_turn( + [sys.executable, "-c", "import time; time.sleep(30)"], _child_env(), str(tmp_path / "turn.json"), timeout=1) + assert time.monotonic() - started < 8 + + # ── delivery-targets listing (UI pickers) ──────────────────────────────────── def test_delivery_targets_include_local_profiles(): diff --git a/tests/cron/test_cron_live_bot_delivery.py b/tests/cron/test_cron_live_bot_delivery.py index 70fbd6ccc7..574cb6915e 100644 --- a/tests/cron/test_cron_live_bot_delivery.py +++ b/tests/cron/test_cron_live_bot_delivery.py @@ -11,7 +11,7 @@ def test_live_delivery_retry_keeps_receipt_across_owner_loss(tmp_path, monkeypat source = tmp_path / "custom-home" monkeypatch.setenv("HERMES_HOME", str(source)) subprocess_run = Mock(side_effect=AssertionError("live owner must not spawn CLI")) - monkeypatch.setattr(delivery.subprocess, "run", subprocess_run) + monkeypatch.setattr(delivery, "_run_bot_chat_turn", subprocess_run) from hermes_cli.profiles import get_profile_dir for profile, home in [("", source), ("research", get_profile_dir("research"))]: @@ -52,7 +52,7 @@ def test_result_records_pending_until_terminal_receipt(tmp_path, monkeypatch): monkeypatch.setattr(mailbox, "find_canonical_live_owner", lambda home: owner) monkeypatch.setattr(delivery._sched, "load_config", lambda: {}) monkeypatch.setattr(config, "load_gateway_config", lambda: None) - monkeypatch.setattr(delivery.subprocess, "run", Mock(side_effect=AssertionError("CLI"))) + monkeypatch.setattr(delivery, "_run_bot_chat_turn", Mock(side_effect=AssertionError("CLI"))) updates = [] monkeypatch.setattr(jobs, "update_job", lambda key, values: updates.append(values)) job = dict(id="digest", execution_id="run", deliver="bot-chat") diff --git a/tests/hermes_cli/test_quiet_single_query.py b/tests/hermes_cli/test_quiet_single_query.py index aafba31029..a4b838ee78 100644 --- a/tests/hermes_cli/test_quiet_single_query.py +++ b/tests/hermes_cli/test_quiet_single_query.py @@ -76,3 +76,36 @@ def test_rerun_adopts_the_dm_behind_the_failed_attempts_tool_scaffolding(monkeyp agent, seen = _quiet_turn(monkeypatch, [{"role": "assistant", "content": "earlier"}, tail, *scaffolding], "1") assert seen["history"] == [{"role": "assistant", "content": "earlier"}] assert agent._pending_cli_user_message is tail and tail[_DB_PERSISTED_MARKER] is True + + +def test_turn_report_is_written_before_the_exit_linger_and_the_path_is_not_inherited(monkeypatch, tmp_path): + """A spawner that bounds only the turn (cron Bot Chat lane, #113608) reads the outcome from + HERMES_QUIET_TURN_REPORT_FILE: written the moment the turn ends — before the one-shot exit + linger — stamped with this pid, and the variable is popped before the turn spawns anything.""" + from hermes_cli import quiet_single_query as qsq + + monkeypatch.delenv("HERMES_KANBAN_GOAL_MODE", raising=False) + monkeypatch.delenv("HERMES_KANBAN_TASK", raising=False) + report = tmp_path / "turn.json" + monkeypatch.setenv(qsq.TURN_REPORT_FILE_ENV, str(report)) + seen = {} + + def run_conversation(**kwargs): + seen["env_during_turn"] = os.environ.get(qsq.TURN_REPORT_FILE_ENV) + seen["report_during_turn"] = report.exists() + return {"final_response": "ok"} + + def linger(*args, **kwargs): + seen["report_at_linger"] = qsq.read_turn_report(str(report), os.getpid()) + return {"waited": [], "completed": [], "timed_out": []} + + monkeypatch.setattr("tools.process_registry.process_registry.wait_for_pending_completions", linger) + agent = SimpleNamespace(run_conversation=run_conversation, session_id="s-1") + try: + cli._run_quiet_single_query(SimpleNamespace(agent=agent, conversation_history=[], session_id="s-1"), "hello") + except SystemExit as exc: + assert exc.code == 0 + assert seen["env_during_turn"] is None and seen["report_during_turn"] is False + assert seen["report_at_linger"] == {"pid": os.getpid(), "exit_code": 0, "error": ""} + # Another process's record is not this child's report. + assert qsq.read_turn_report(str(report), os.getpid() + 1) is None diff --git a/website/docs/user-guide/features/cron.md b/website/docs/user-guide/features/cron.md index 65248eaf68..6dda6a73fe 100644 --- a/website/docs/user-guide/features/cron.md +++ b/website/docs/user-guide/features/cron.md @@ -846,6 +846,8 @@ cron: A timed-out delivery is recorded in `last_delivery_error`; the bot's turn may still complete on its own. +The cap bounds the bot's **turn** only. When that turn messages a teammate (`message_agent`), the delivery process stays alive afterwards — bounded by `terminal.oneshot_completion_wait_seconds` — so the teammate's reply can land in the Bot Chat; that wait is not part of the delivery and is never counted against, or cut short by, this cap. + ## No-agent mode (script-only jobs) For recurring jobs that don't need LLM reasoning — classic watchdogs, disk/memory alerts, heartbeats, CI pings — pass `no_agent=True` at creation time. The scheduler runs your script on schedule and delivers its stdout directly, skipping the agent entirely: