From e3710c15931b4c967e3aa3314611cece148c6119 Mon Sep 17 00:00:00 2001 From: Teknium <127238744+teknium1@users.noreply.github.com> Date: Mon, 7 Sep 2026 01:50:04 -0700 Subject: [PATCH] fix(cron): preserve continuity across silent audit ticks Slim redo of #104546 and #104551: scan newest-first, match suppression only before payload separators, and keep error context. Covers wake gates and empty outputs without reading every historical file twice. Co-authored-by: PRATHAMESH75 Co-authored-by: Konstantin Khlopkov --- cron/scheduler_prompt.py | 17 +++++++-- tests/cron/test_continuity_silent_audits.py | 38 +++++++++++++++++++++ website/docs/user-guide/features/cron.md | 2 +- 3 files changed, 53 insertions(+), 4 deletions(-) create mode 100644 tests/cron/test_continuity_silent_audits.py diff --git a/cron/scheduler_prompt.py b/cron/scheduler_prompt.py index 91d673649d..c0e858ea05 100644 --- a/cron/scheduler_prompt.py +++ b/cron/scheduler_prompt.py @@ -91,9 +91,20 @@ def _inject_context_from(job: dict, prompt: str) -> tuple[str, bool]: (output_dir / source_job_id).glob("*.md"), key=lambda f: f.stat().st_mtime, reverse=True, ) - if not output_files: - continue # silent skip — no output yet - latest_output = output_files[0].read_text(encoding="utf-8").strip() + latest_output = "" + for output_file in output_files: + candidate = output_file.read_text(encoding="utf-8").strip() + # Only the run header describes suppression; script/agent payloads can + # quote these markers. Keep error documents useful for recovery context. + header = candidate.split("\n---\n", 1)[0].split("\n## Prompt", 1)[0] + silent_audit = candidate.startswith("# Cron Job:") and any( + line.startswith(("**Status:** no_change", "**Status:** silent", + "Script gate returned `wakeAgent=false`")) + for line in header.splitlines() + ) + if candidate and not silent_audit: + latest_output = candidate + break if len(latest_output) > _MAX_CONTEXT_CHARS: latest_output = ( latest_output[:_MAX_CONTEXT_CHARS] + "\n\n[... output truncated ...]") diff --git a/tests/cron/test_continuity_silent_audits.py b/tests/cron/test_continuity_silent_audits.py new file mode 100644 index 0000000000..351049b79d --- /dev/null +++ b/tests/cron/test_continuity_silent_audits.py @@ -0,0 +1,38 @@ +"""Silent audit records must not replace useful continuity output (#104541).""" +import os + +from cron import jobs +from cron.scheduler_prompt import _inject_context_from + + +def test_silent_audits_preserve_latest_payload(tmp_path): + with jobs.use_cron_store(tmp_path): + directory = jobs.get_cron_output_dir() / "abcdef" + directory.mkdir(parents=True) + header = "# Cron Job: " + "long name " * 80 + "\n\n**Job ID:** abcdef\n**Run Time:** now\n" + payload = header + "**Mode:** no_agent (script)\n\n---\n\n**Status:** silent but useful payload\n" + records = [payload, header + "**Mode:** monitor\n**Status:** no_change (agent run suppressed)\n", + header + "**Mode:** no_agent (script)\n**Status:** silent (empty output)\n", + header + "\nScript gate returned `wakeAgent=false` — agent skipped.\n", ""] + for index, text in enumerate(records): + path = directory / f"{index}.md" + path.write_text(text, encoding="utf-8") + os.utime(path, (index + 1, index + 1)) + for source in ("self", "abcdef"): + prompt, injected = _inject_context_from({"id": "abcdef", "context_from": [source]}, "next") + assert injected and "silent but useful payload" in prompt + assert "agent skipped" not in prompt and "agent run suppressed" not in prompt + assert len(list(directory.glob("*.md"))) == len(records) + + +def test_audit_only_history_is_empty_but_errors_remain_context(tmp_path): + with jobs.use_cron_store(tmp_path): + directory = jobs.get_cron_output_dir() / "abcdef" + directory.mkdir(parents=True) + path = directory / "audit.md" + path.write_text("# Cron Job: monitor\n**Status:** no_change (agent run suppressed)\n", encoding="utf-8") + job = {"id": "abcdef", "context_from": ["self"]} + assert _inject_context_from(job, "next") == ("next", False) + path.write_text("# Cron Job: monitor\n**Status:** monitor source failed\n\nConnection refused\n", encoding="utf-8") + prompt, injected = _inject_context_from(job, "next") + assert injected and "Connection refused" in prompt diff --git a/website/docs/user-guide/features/cron.md b/website/docs/user-guide/features/cron.md index 9d9608cdaa..397d06a5e0 100644 --- a/website/docs/user-guide/features/cron.md +++ b/website/docs/user-guide/features/cron.md @@ -817,7 +817,7 @@ cronjob( ) ``` -The first run has no previous output, so the prompt runs as-is. On later runs the previous output is prepended with continuity framing ("avoid repeating what was already reported"). It combines freely with upstream jobs (`context_from=[""]` plus `continuity=true`), and `continuity=false` on update turns it off while preserving other `context_from` entries. Internally the flag is stored as the reserved `self` entry in `context_from`. +The first run has no previous output, so the prompt runs as-is. Silent monitor ticks (`no_change`), empty output, and `wakeAgent=false` audit records are skipped when selecting context, so a quiet period preserves the latest substantive output. Audit files remain on disk. Error documents remain eligible to give the next run recovery context; this is not a success-only history filter. On later runs the previous output is prepended with continuity framing ("avoid repeating what was already reported"). It combines freely with upstream jobs (`context_from=[""]` plus `continuity=true`), and `continuity=false` on update turns it off while preserving other `context_from` entries. Internally the flag is stored as the reserved `self` entry in `context_from`. From the CLI: `hermes cron create "every 6h" "Scan for news" --continuity`, and `hermes cron edit --continuity` / `--no-continuity` to toggle it on an existing job. The same toggle appears in the dashboard's cron editor and the desktop Bot Mode routine dialog.