mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-21 16:18:55 +00:00
321 lines
12 KiB
Python
321 lines
12 KiB
Python
"""Durable cron execution-ledger behavior."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
import os
|
|
import sqlite3
|
|
import subprocess
|
|
import sys
|
|
from pathlib import Path
|
|
|
|
|
|
def _point_ledger(monkeypatch, tmp_path):
|
|
import cron.executions as executions
|
|
|
|
monkeypatch.setattr(executions, "EXECUTIONS_FILE", tmp_path / "cron" / "executions.db")
|
|
return executions
|
|
|
|
|
|
def test_execution_transitions_are_durable(monkeypatch, tmp_path):
|
|
executions = _point_ledger(monkeypatch, tmp_path)
|
|
|
|
claimed = executions.create_execution("job-1", source="builtin")
|
|
assert claimed["status"] == "claimed"
|
|
assert claimed["claimed_at"]
|
|
assert claimed["started_at"] is None
|
|
assert claimed["finished_at"] is None
|
|
|
|
running = executions.mark_execution_running(claimed["id"])
|
|
assert running["status"] == "running"
|
|
assert running["started_at"]
|
|
|
|
completed = executions.finish_execution(claimed["id"], success=True)
|
|
assert completed["status"] == "completed"
|
|
assert completed["finished_at"]
|
|
assert completed["error"] is None
|
|
|
|
persisted = executions.list_executions(job_id="job-1")
|
|
assert persisted == [completed]
|
|
|
|
|
|
def test_terminal_execution_cannot_be_rewritten(monkeypatch, tmp_path):
|
|
executions = _point_ledger(monkeypatch, tmp_path)
|
|
record = executions.create_execution("immutable", source="builtin")
|
|
executions.mark_execution_running(record["id"])
|
|
executions.finish_execution(record["id"], success=True)
|
|
|
|
assert executions.finish_execution(
|
|
record["id"], success=False, error="late writer"
|
|
) is None
|
|
assert executions.latest_execution("immutable")["status"] == "completed"
|
|
|
|
|
|
def test_retention_bounds_terminal_history_but_preserves_inflight(monkeypatch, tmp_path):
|
|
executions = _point_ledger(monkeypatch, tmp_path)
|
|
monkeypatch.setattr(executions, "MAX_TERMINAL_EXECUTIONS", 3)
|
|
inflight = executions.create_execution("live", source="builtin")
|
|
executions.mark_execution_running(inflight["id"])
|
|
for index in range(8):
|
|
row = executions.create_execution(f"done-{index}", source="builtin")
|
|
executions.finish_execution(row["id"], success=True)
|
|
|
|
records = executions.list_executions(limit=100)
|
|
assert len([row for row in records if row["status"] == "completed"]) == 3
|
|
assert executions.latest_execution("live")["status"] == "running"
|
|
|
|
|
|
def test_corrupt_store_fails_closed_without_overwrite(monkeypatch, tmp_path):
|
|
executions = _point_ledger(monkeypatch, tmp_path)
|
|
executions.EXECUTIONS_FILE.parent.mkdir(parents=True)
|
|
executions.EXECUTIONS_FILE.write_bytes(b"not a sqlite database")
|
|
|
|
with __import__("pytest").raises(sqlite3.DatabaseError):
|
|
executions.create_execution("new", source="builtin")
|
|
assert executions.EXECUTIONS_FILE.read_bytes() == b"not a sqlite database"
|
|
|
|
|
|
def test_execution_history_is_paginated(monkeypatch, tmp_path):
|
|
executions = _point_ledger(monkeypatch, tmp_path)
|
|
ids = []
|
|
for _index in range(5):
|
|
row = executions.create_execution("paged", source="builtin")
|
|
executions.finish_execution(row["id"], success=True)
|
|
ids.append(row["id"])
|
|
|
|
first = executions.list_executions(job_id="paged", limit=2)
|
|
second = executions.list_executions(
|
|
job_id="paged", limit=2, before_claimed_at=first[-1]["claimed_at"]
|
|
)
|
|
assert [row["id"] for row in first] == list(reversed(ids))[:2]
|
|
assert set(row["id"] for row in first).isdisjoint(row["id"] for row in second)
|
|
|
|
|
|
def test_cron_runs_cli_prints_execution_history(monkeypatch, tmp_path, capsys):
|
|
executions = _point_ledger(monkeypatch, tmp_path)
|
|
row = executions.create_execution("cli-job", source="builtin")
|
|
executions.finish_execution(row["id"], success=False, error="boom")
|
|
from hermes_cli.cron import cron_runs
|
|
|
|
cron_runs("cli-job", limit=10)
|
|
|
|
output = capsys.readouterr().out
|
|
assert row["id"] in output
|
|
assert "failed" in output
|
|
assert "boom" in output
|
|
|
|
|
|
def test_quick_backup_includes_execution_ledger():
|
|
from hermes_cli.backup import _QUICK_STATE_FILES
|
|
|
|
assert "cron/executions.db" in _QUICK_STATE_FILES
|
|
|
|
|
|
def test_failed_execution_keeps_error(monkeypatch, tmp_path):
|
|
executions = _point_ledger(monkeypatch, tmp_path)
|
|
|
|
record = executions.create_execution("job-2", source="external")
|
|
failed = executions.finish_execution(record["id"], success=False, error="provider exploded")
|
|
|
|
assert failed["status"] == "failed"
|
|
assert failed["error"] == "provider exploded"
|
|
|
|
|
|
def test_recovery_does_not_mark_live_process_execution_unknown(monkeypatch, tmp_path):
|
|
executions = _point_ledger(monkeypatch, tmp_path)
|
|
record = executions.create_execution("still-live", source="builtin")
|
|
executions.mark_execution_running(record["id"])
|
|
|
|
assert executions.recover_interrupted_executions() == 0
|
|
assert executions.latest_execution("still-live")["status"] == "running"
|
|
|
|
|
|
def test_recovery_does_not_mark_other_live_owner_unknown(monkeypatch, tmp_path):
|
|
executions = _point_ledger(monkeypatch, tmp_path)
|
|
record = executions.create_execution("other-live", source="builtin")
|
|
with sqlite3.connect(executions.EXECUTIONS_FILE) as conn:
|
|
conn.execute(
|
|
"UPDATE executions SET process_id=?, pid=? WHERE id=?",
|
|
("another-import", os.getpid(), record["id"]),
|
|
)
|
|
|
|
assert executions.recover_interrupted_executions() == 0
|
|
assert executions.latest_execution("other-live")["status"] == "claimed"
|
|
|
|
|
|
def test_recovery_rejects_recycled_pid(monkeypatch, tmp_path):
|
|
executions = _point_ledger(monkeypatch, tmp_path)
|
|
record = executions.create_execution("recycled", source="builtin")
|
|
with sqlite3.connect(executions.EXECUTIONS_FILE) as conn:
|
|
conn.execute(
|
|
"UPDATE executions SET process_id=?, process_started_at=? WHERE id=?",
|
|
("old-import", -1, record["id"]),
|
|
)
|
|
|
|
assert executions.recover_interrupted_executions() == 1
|
|
assert executions.latest_execution("recycled")["status"] == "unknown"
|
|
|
|
|
|
def test_restart_marks_interrupted_execution_unknown_without_requeue(tmp_path):
|
|
"""Real temp-HERMES_HOME subprocess restart: in-flight is audit-only unknown."""
|
|
home = tmp_path / "home"
|
|
repo = Path(__file__).resolve().parents[2]
|
|
env = os.environ.copy()
|
|
env["HERMES_HOME"] = str(home)
|
|
env["PYTHONPATH"] = str(repo)
|
|
|
|
create = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
"-c",
|
|
"from cron.executions import create_execution, mark_execution_running; "
|
|
"r=create_execution('restart-job', source='builtin'); "
|
|
"mark_execution_running(r['id']); print(r['id'])",
|
|
],
|
|
cwd=repo,
|
|
env=env,
|
|
text=True,
|
|
capture_output=True,
|
|
check=True,
|
|
)
|
|
execution_id = create.stdout.strip()
|
|
|
|
recover = subprocess.run(
|
|
[
|
|
sys.executable,
|
|
"-c",
|
|
"import json; from cron.executions import recover_interrupted_executions, list_executions; "
|
|
"print(recover_interrupted_executions()); "
|
|
"print(json.dumps(list_executions(job_id='restart-job'))) ",
|
|
],
|
|
cwd=repo,
|
|
env=env,
|
|
text=True,
|
|
capture_output=True,
|
|
check=True,
|
|
)
|
|
lines = recover.stdout.strip().splitlines()
|
|
assert lines[0] == "1"
|
|
records = json.loads(lines[1])
|
|
assert len(records) == 1
|
|
assert records[0]["id"] == execution_id
|
|
assert records[0]["status"] == "unknown"
|
|
assert records[0]["finished_at"]
|
|
assert "restart" in records[0]["error"].lower()
|
|
# Recovery only classifies the old attempt. It must not manufacture a new
|
|
# claimed record (which would imply an automatic retry).
|
|
assert [r["status"] for r in records] == ["unknown"]
|
|
|
|
|
|
def test_generic_submit_failure_finishes_attempt_and_releases_guard(monkeypatch):
|
|
import cron.scheduler as scheduler
|
|
|
|
class BrokenPool:
|
|
def submit(self, _callable):
|
|
raise ValueError("executor rejected")
|
|
|
|
finished = []
|
|
monkeypatch.setattr(
|
|
scheduler, "create_execution",
|
|
lambda *_args, **_kwargs: {"id": "exec-submit-fail"},
|
|
)
|
|
monkeypatch.setattr(
|
|
scheduler, "finish_execution",
|
|
lambda execution_id, **kwargs: finished.append((execution_id, kwargs)),
|
|
)
|
|
monkeypatch.setattr(scheduler, "get_due_jobs", lambda: [{"id": "submit-fail"}])
|
|
monkeypatch.setattr(scheduler, "advance_next_run", lambda _job_id: None)
|
|
monkeypatch.setattr(scheduler, "_get_parallel_pool", lambda _workers: BrokenPool())
|
|
|
|
assert scheduler.tick(verbose=False, sync=False) == 0
|
|
assert finished == [
|
|
("exec-submit-fail", {
|
|
"success": False,
|
|
"error": "Executor dispatch failed: executor rejected",
|
|
})
|
|
]
|
|
assert "submit-fail" not in scheduler.get_running_job_ids()
|
|
|
|
|
|
def test_run_one_job_records_running_then_terminal(monkeypatch):
|
|
import cron.scheduler as scheduler
|
|
|
|
events = []
|
|
monkeypatch.setattr(
|
|
scheduler,
|
|
"mark_execution_running",
|
|
lambda execution_id: events.append(("running", execution_id)),
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(
|
|
scheduler,
|
|
"finish_execution",
|
|
lambda execution_id, **kwargs: events.append(("finish", execution_id, kwargs)),
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr(scheduler, "claim_dispatch", lambda _job_id: True)
|
|
monkeypatch.setattr(
|
|
scheduler,
|
|
"run_job",
|
|
lambda job, *, defer_agent_teardown=None: (True, "output", "response", None),
|
|
)
|
|
monkeypatch.setattr(scheduler, "save_job_output", lambda *_args: None)
|
|
monkeypatch.setattr(scheduler, "_deliver_result", lambda *_args, **_kwargs: None)
|
|
monkeypatch.setattr(scheduler, "mark_job_run", lambda *_args, **_kwargs: None)
|
|
|
|
assert scheduler.run_one_job({"id": "job-3", "execution_id": "exec-3"}) is True
|
|
assert events[0] == ("running", "exec-3")
|
|
assert events[-1][0:2] == ("finish", "exec-3")
|
|
assert events[-1][2]["success"] is True
|
|
|
|
|
|
def test_provider_start_recovers_interrupted_records_before_tick(monkeypatch):
|
|
import cron.scheduler_provider as provider
|
|
|
|
events = []
|
|
stop = __import__("threading").Event()
|
|
stop.set()
|
|
monkeypatch.setattr(
|
|
"cron.executions.recover_interrupted_executions",
|
|
lambda: events.append("recover") or 0,
|
|
raising=False,
|
|
)
|
|
monkeypatch.setattr("cron.jobs.record_ticker_heartbeat", lambda **_kwargs: events.append("heartbeat"))
|
|
|
|
provider.InProcessCronScheduler().start(stop, interval=1)
|
|
|
|
assert events[:2] == ["recover", "heartbeat"]
|
|
|
|
|
|
def test_external_provider_start_recovers_interrupted_records(monkeypatch):
|
|
from plugins.cron_providers.chronos import ChronosCronScheduler
|
|
|
|
provider = ChronosCronScheduler()
|
|
provider._client = type("Client", (), {"arm": lambda self, **kwargs: None})()
|
|
events = []
|
|
monkeypatch.setattr(
|
|
"cron.executions.recover_interrupted_executions",
|
|
lambda: events.append("recover") or 0,
|
|
)
|
|
monkeypatch.setattr(provider, "reconcile", lambda: events.append("reconcile"))
|
|
|
|
provider.start(__import__("threading").Event())
|
|
|
|
assert events == ["recover", "reconcile"]
|
|
|
|
|
|
def test_job_listing_exposes_latest_execution(monkeypatch, tmp_path):
|
|
import cron.jobs as jobs
|
|
|
|
monkeypatch.setattr(jobs, "CRON_DIR", tmp_path / "cron")
|
|
monkeypatch.setattr(jobs, "JOBS_FILE", tmp_path / "cron" / "jobs.json")
|
|
monkeypatch.setattr(jobs, "OUTPUT_DIR", tmp_path / "cron" / "output")
|
|
executions = _point_ledger(monkeypatch, tmp_path)
|
|
|
|
job = jobs.create_job(prompt="audit me", schedule="every 1h", name="audit")
|
|
record = executions.create_execution(job["id"], source="builtin")
|
|
executions.mark_execution_running(record["id"])
|
|
|
|
listed = jobs.list_jobs(include_disabled=True)
|
|
assert listed[0]["latest_execution"]["id"] == record["id"]
|
|
assert listed[0]["latest_execution"]["status"] == "running"
|