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 <prathamesh290504@gmail.com> Co-authored-by: Konstantin Khlopkov <konstantin.khlopkov93@gmail.com>
This commit is contained in:
@@ -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 ...]")
|
||||
|
||||
@@ -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
|
||||
@@ -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=["<other_job_id>"]` 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=["<other_job_id>"]` 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 <job_id> --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.
|
||||
|
||||
|
||||
Reference in New Issue
Block a user