fix(cron): harden execution attempt ledger

This commit is contained in:
teknium1 2026-07-12 23:50:37 -07:00 committed by Teknium
parent d9dd05b69d
commit abc22cdf1a
12 changed files with 387 additions and 203 deletions

View file

@ -1,222 +1,228 @@
"""Durable audit ledger for cron execution attempts.
"""Profile-local durable audit ledger for cron execution attempts.
The ledger records what is known about each attempt; it is not a retry queue.
An attempt left claimed or running when a scheduler process restarts is marked
``unknown`` because the new process cannot prove whether its side effects ran.
Interrupted attempts become ``unknown`` only after their exact owner process is
proved gone. Terminal states are immutable.
"""
from __future__ import annotations
import contextlib
import copy
import json
import os
import tempfile
import sqlite3
import threading
import uuid
from pathlib import Path
from typing import Any, Dict, List, Optional
try:
import fcntl
except ImportError: # pragma: no cover - non-Unix
fcntl = None
try:
import msvcrt
except ImportError: # pragma: no cover - non-Windows
msvcrt = None
from hermes_constants import get_hermes_home
from hermes_time import now as _hermes_now
from utils import atomic_replace
EXECUTIONS_FILE = get_hermes_home().resolve() / "cron" / "executions.json"
EXECUTIONS_LOCK_FILE = get_hermes_home().resolve() / "cron" / ".executions.lock"
EXECUTIONS_FILE = get_hermes_home().resolve() / "cron" / "executions.db"
MAX_TERMINAL_EXECUTIONS = 1000
_TERMINAL_STATES = ("completed", "failed", "unknown")
_lock = threading.RLock()
_lock_state = threading.local()
_PROCESS_ID = uuid.uuid4().hex
@contextlib.contextmanager
def _ledger_lock():
depth = getattr(_lock_state, "depth", 0)
if depth:
_lock_state.depth = depth + 1
try:
yield
finally:
_lock_state.depth -= 1
return
with _lock:
EXECUTIONS_LOCK_FILE.parent.mkdir(parents=True, exist_ok=True)
lock_file = open(EXECUTIONS_LOCK_FILE, "a+", encoding="utf-8")
try:
if fcntl:
fcntl.flock(lock_file, fcntl.LOCK_EX)
elif msvcrt: # pragma: no cover - Windows
lock_file.seek(0)
if not lock_file.read(1):
lock_file.write("0")
lock_file.flush()
lock_file.seek(0)
msvcrt.locking(lock_file.fileno(), msvcrt.LK_LOCK, 1)
_lock_state.depth = 1
yield
finally:
_lock_state.depth = 0
if fcntl:
fcntl.flock(lock_file, fcntl.LOCK_UN)
elif msvcrt: # pragma: no cover - Windows
lock_file.seek(0)
msvcrt.locking(lock_file.fileno(), msvcrt.LK_UNLCK, 1)
lock_file.close()
def _load_unlocked() -> List[Dict[str, Any]]:
if not EXECUTIONS_FILE.exists():
return []
try:
data = json.loads(EXECUTIONS_FILE.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError):
return []
records = data.get("executions", []) if isinstance(data, dict) else []
return records if isinstance(records, list) else []
def _save_unlocked(records: List[Dict[str, Any]]) -> None:
def _connect() -> sqlite3.Connection:
EXECUTIONS_FILE.parent.mkdir(parents=True, exist_ok=True)
fd, tmp_name = tempfile.mkstemp(
dir=str(EXECUTIONS_FILE.parent), prefix=".executions_", suffix=".tmp"
conn = sqlite3.connect(EXECUTIONS_FILE, timeout=5)
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA busy_timeout=5000")
conn.execute("PRAGMA journal_mode=WAL")
conn.execute("PRAGMA synchronous=FULL")
conn.execute(
"""CREATE TABLE IF NOT EXISTS executions (
id TEXT PRIMARY KEY,
job_id TEXT NOT NULL,
source TEXT NOT NULL,
process_id TEXT NOT NULL,
pid INTEGER NOT NULL,
process_started_at INTEGER,
status TEXT NOT NULL CHECK(status IN
('claimed','running','completed','failed','unknown')),
claimed_at TEXT NOT NULL,
started_at TEXT,
finished_at TEXT,
error TEXT
)"""
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_executions_job_claimed "
"ON executions(job_id, claimed_at DESC, id DESC)"
)
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_executions_status_claimed "
"ON executions(status, claimed_at DESC, id DESC)"
)
return conn
def _record(row: Optional[sqlite3.Row]) -> Optional[Dict[str, Any]]:
return dict(row) if row is not None else None
def _process_start_time(pid: int) -> Optional[int]:
try:
with os.fdopen(fd, "w", encoding="utf-8") as handle:
json.dump({"version": 1, "executions": records}, handle, indent=2)
handle.write("\n")
handle.flush()
os.fsync(handle.fileno())
atomic_replace(tmp_name, EXECUTIONS_FILE)
try:
os.chmod(EXECUTIONS_FILE, 0o600)
except OSError:
pass
from gateway.status import get_process_start_time
return get_process_start_time(pid)
except Exception:
try:
os.unlink(tmp_name)
except OSError:
pass
raise
return None
def _owner_is_live(pid: int, started_at: Optional[int]) -> bool:
try:
from gateway.status import _pid_exists
if not _pid_exists(pid):
return False
except Exception:
return True # fail safe: inability to prove death must not rewrite state
if started_at is None:
return pid == os.getpid()
current = _process_start_time(pid)
return current is not None and current == started_at
def _prune_unlocked(conn: sqlite3.Connection) -> None:
limit = max(0, int(MAX_TERMINAL_EXECUTIONS))
conn.execute(
"""DELETE FROM executions WHERE id IN (
SELECT id FROM executions
WHERE status IN ('completed','failed','unknown')
ORDER BY claimed_at DESC, id DESC LIMIT -1 OFFSET ?
)""",
(limit,),
)
def create_execution(job_id: str, *, source: str) -> Dict[str, Any]:
"""Persist a claimed attempt before it is submitted for execution."""
"""Persist a claimed attempt before executor/provider dispatch."""
now = _hermes_now().isoformat()
record: Dict[str, Any] = {
"id": uuid.uuid4().hex,
"job_id": str(job_id),
"source": str(source),
"process_id": _PROCESS_ID,
"pid": os.getpid(),
"status": "claimed",
"claimed_at": now,
"started_at": None,
"finished_at": None,
"error": None,
}
with _ledger_lock():
records = _load_unlocked()
records.append(record)
_save_unlocked(records)
return copy.deepcopy(record)
def _transition(execution_id: str, status: str, **updates: Any) -> Optional[Dict[str, Any]]:
with _ledger_lock():
records = _load_unlocked()
for record in records:
if record.get("id") != execution_id:
continue
record["status"] = status
record.update(updates)
_save_unlocked(records)
return copy.deepcopy(record)
return None
execution_id = uuid.uuid4().hex
pid = os.getpid()
with _lock, _connect() as conn:
conn.execute(
"""INSERT INTO executions
(id, job_id, source, process_id, pid, process_started_at,
status, claimed_at)
VALUES (?, ?, ?, ?, ?, ?, 'claimed', ?)""",
(execution_id, str(job_id), str(source), _PROCESS_ID, pid,
_process_start_time(pid), now),
)
row = conn.execute(
"SELECT * FROM executions WHERE id=?", (execution_id,)
).fetchone()
return _record(row) # type: ignore[return-value]
def mark_execution_running(execution_id: str) -> Optional[Dict[str, Any]]:
return _transition(
execution_id,
"running",
started_at=_hermes_now().isoformat(),
)
"""Transition one claimed attempt to running exactly once."""
now = _hermes_now().isoformat()
with _lock, _connect() as conn:
cur = conn.execute(
"""UPDATE executions SET status='running', started_at=?
WHERE id=? AND status='claimed'""",
(now, execution_id),
)
if cur.rowcount != 1:
return None
return _record(conn.execute(
"SELECT * FROM executions WHERE id=?", (execution_id,)
).fetchone())
def finish_execution(
execution_id: str,
*,
success: bool,
error: Optional[str] = None,
execution_id: str, *, success: bool, error: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
return _transition(
execution_id,
"completed" if success else "failed",
finished_at=_hermes_now().isoformat(),
error=None if success else (str(error) if error else "unknown failure"),
)
"""Write a terminal result once; terminal attempts cannot be rewritten."""
now = _hermes_now().isoformat()
status = "completed" if success else "failed"
detail = None if success else (str(error) if error else "unknown failure")
with _lock, _connect() as conn:
cur = conn.execute(
"""UPDATE executions SET status=?, finished_at=?, error=?
WHERE id=? AND status IN ('claimed','running')""",
(status, now, detail, execution_id),
)
if cur.rowcount != 1:
return None
_prune_unlocked(conn)
return _record(conn.execute(
"SELECT * FROM executions WHERE id=?", (execution_id,)
).fetchone())
def recover_interrupted_executions() -> int:
"""Classify prior in-flight attempts as unknown; never enqueue a retry."""
"""Mark provably abandoned attempts unknown without scheduling retries."""
now = _hermes_now().isoformat()
changed = 0
def _owner_is_live(record: Dict[str, Any]) -> bool:
try:
owner_pid = int(record.get("pid"))
except (TypeError, ValueError):
return False
if owner_pid <= 0:
return False
if owner_pid == os.getpid():
return True
try:
os.kill(owner_pid, 0) # windows-footgun: ok -- liveness probe
return True
except OSError:
return False
with _ledger_lock():
records = _load_unlocked()
for record in records:
if record.get("status") not in {"claimed", "running"}:
with _lock, _connect() as conn:
rows = conn.execute(
"""SELECT id, process_id, pid, process_started_at FROM executions
WHERE status IN ('claimed','running')"""
).fetchall()
for row in rows:
if row["process_id"] == _PROCESS_ID:
continue
# Multiple scheduler surfaces can start in one process. Their
# startup recovery must not relabel work this same process is
# currently executing. A real restart imports this module in a new
# process and therefore has a distinct process id.
if record.get("process_id") == _PROCESS_ID or _owner_is_live(record):
if _owner_is_live(int(row["pid"]), row["process_started_at"]):
continue
record["status"] = "unknown"
record["finished_at"] = now
record["error"] = (
"Scheduler restarted before this execution reached a durable "
"terminal state; whether side effects ran is unknown."
cur = conn.execute(
"""UPDATE executions SET status='unknown', finished_at=?, error=?
WHERE id=? AND status IN ('claimed','running')""",
(now,
"Scheduler restarted after this execution's owner exited before a durable "
"terminal state; whether side effects ran is unknown.",
row["id"]),
)
changed += 1
changed += cur.rowcount
if changed:
_save_unlocked(records)
_prune_unlocked(conn)
return changed
def list_executions(*, job_id: Optional[str] = None) -> List[Dict[str, Any]]:
with _ledger_lock():
records = copy.deepcopy(_load_unlocked())
def list_executions(
*, job_id: Optional[str] = None, limit: int = 50,
before_claimed_at: Optional[str] = None,
) -> List[Dict[str, Any]]:
"""Return indexed, newest-first execution history with cursor pagination."""
clauses: List[str] = []
params: List[Any] = []
if job_id is not None:
records = [record for record in records if record.get("job_id") == job_id]
records.sort(key=lambda record: str(record.get("claimed_at", "")), reverse=True)
return records
clauses.append("job_id=?")
params.append(str(job_id))
if before_claimed_at is not None:
clauses.append("claimed_at < ?")
params.append(str(before_claimed_at))
where = " WHERE " + " AND ".join(clauses) if clauses else ""
params.append(max(1, min(int(limit), 500)))
with _lock, _connect() as conn:
rows = conn.execute(
"SELECT * FROM executions" + where
+ " ORDER BY claimed_at DESC, id DESC LIMIT ?",
params,
).fetchall()
return [dict(row) for row in rows]
def latest_execution(job_id: str) -> Optional[Dict[str, Any]]:
records = list_executions(job_id=job_id)
return records[0] if records else None
rows = list_executions(job_id=job_id, limit=1)
return rows[0] if rows else None
def latest_executions(job_ids: List[str]) -> Dict[str, Dict[str, Any]]:
"""Load latest execution for many jobs in one indexed query."""
clean = [str(job_id) for job_id in dict.fromkeys(job_ids) if job_id]
if not clean:
return {}
placeholders = ",".join("?" for _ in clean)
with _lock, _connect() as conn:
rows = conn.execute(
f"""SELECT e.* FROM executions e
WHERE e.job_id IN ({placeholders})
AND e.id=(SELECT e2.id FROM executions e2
WHERE e2.job_id=e.job_id
ORDER BY e2.claimed_at DESC, e2.id DESC LIMIT 1)""",
clean,
).fetchall()
return {row["job_id"]: dict(row) for row in rows}

View file

@ -455,14 +455,6 @@ def _normalize_job_record(job: Dict[str, Any]) -> Dict[str, Any]:
state = "scheduled" if normalized.get("enabled", True) else "paused"
normalized["state"] = state
# Expose the independent latest attempt through existing job detail/list
# readers without coupling execution history into schedule persistence.
try:
from cron.executions import latest_execution
normalized["latest_execution"] = latest_execution(job_id)
except Exception:
normalized["latest_execution"] = None
return normalized
@ -1324,6 +1316,14 @@ def list_jobs(include_disabled: bool = False) -> List[Dict[str, Any]]:
jobs = [_normalize_job_record(j) for j in load_jobs()]
if not include_disabled:
jobs = [j for j in jobs if j.get("enabled", True)]
try:
from cron.executions import latest_executions
latest = latest_executions([job.get("id", "") for job in jobs])
except Exception:
latest = {}
for job in jobs:
job["latest_execution"] = latest.get(job.get("id", ""))
return jobs

View file

@ -3946,23 +3946,28 @@ def tick(
try:
return pool.submit(_run_and_release)
except RuntimeError as submit_err:
except Exception as submit_err:
with _running_lock:
_running_job_ids.discard(job_id)
finish_execution(
execution["id"],
success=False,
error=f"Executor dispatch failed: {submit_err}",
)
# Interpreter began finalizing between the guard above and the
# submit — release the in-flight claim we just took and skip.
if _interpreter_shutting_down(submit_err):
with _running_lock:
_running_job_ids.discard(job_id)
finish_execution(
execution["id"],
success=False,
error="Interpreter shutdown prevented executor dispatch.",
)
if isinstance(submit_err, RuntimeError) and _interpreter_shutting_down(submit_err):
logger.warning(
"Job '%s' not dispatched — interpreter is shutting down",
job.get("name", job_id),
)
return None
raise
logger.error(
"Job '%s' not dispatched: %s",
job.get("name", job_id),
submit_err,
)
return None
# Sequential pass for env-mutating (workdir) jobs.
# Queued to a persistent single-thread pool so they run one at a time

View file

@ -82,6 +82,12 @@ class CronScheduler(ABC):
Built-in: no-op (it re-reads jobs.json on every tick)."""
return None
def recover_interrupted(self) -> int:
"""Run profile-local attempt recovery for every provider lifecycle."""
from cron.executions import recover_interrupted_executions
return recover_interrupted_executions()
def fire_due(self, job_id: str, *, adapters: Any = None, loop: Any = None) -> bool:
"""Run a single job NOW via the shared orchestrator. Called by the
inbound fire webhook when an external scheduler signals a job is due.
@ -171,11 +177,10 @@ class InProcessCronScheduler(CronScheduler):
import logging
from cron.scheduler import tick as cron_tick
from cron.jobs import record_ticker_heartbeat
from cron.executions import recover_interrupted_executions
logger = logging.getLogger("cron.scheduler_provider")
logger.info("In-process cron scheduler started (interval=%ds)", interval)
recovered = recover_interrupted_executions()
recovered = self.recover_interrupted()
if recovered:
logger.warning(
"Marked %d interrupted cron execution(s) unknown after restart",

View file

@ -769,6 +769,7 @@ _QUICK_STATE_FILES = (
".env",
"auth.json",
"cron/jobs.json",
"cron/executions.db",
"gateway_state.json",
"channel_directory.json",
"channel_aliases.json",

View file

@ -196,6 +196,24 @@ def cron_tick():
tick(verbose=True)
def cron_runs(job_id: Optional[str] = None, limit: int = 20):
"""Show indexed durable cron execution history."""
from cron.executions import list_executions
records = list_executions(job_id=job_id, limit=limit)
if not records:
print("No cron execution attempts recorded.")
return
for record in records:
print(
f"{record.get('id', '?')} {record.get('status', '?'):<9} "
f"job={record.get('job_id', '?')} source={record.get('source', '?')} "
f"{record.get('claimed_at', '?')}"
)
if record.get("error"):
print(f" {record['error']}")
def cron_status():
"""Show cron execution status."""
from cron.jobs import list_jobs
@ -440,6 +458,10 @@ def cron_command(args):
cron_tick()
return 0
if subcmd in {"runs", "history"}:
cron_runs(getattr(args, "job_id", None), getattr(args, "limit", 20))
return 0
if subcmd in {"create", "add"}:
return cron_create(args)
@ -459,5 +481,5 @@ def cron_command(args):
return _job_action("remove", args.job_id, "Removed")
print(f"Unknown cron command: {subcmd}")
print("Usage: hermes cron [list|create|edit|pause|resume|run|remove|status|tick]")
print("Usage: hermes cron [list|create|edit|pause|resume|run|remove|status|runs|tick]")
sys.exit(1)

View file

@ -156,6 +156,12 @@ def build_cron_parser(subparsers, *, cmd_cron: Callable) -> None:
# cron status
cron_subparsers.add_parser("status", help="Check if cron scheduler is running")
cron_runs = cron_subparsers.add_parser(
"runs", aliases=["history"], help="Show durable execution attempts"
)
cron_runs.add_argument("job_id", nargs="?", help="Optional job ID filter")
cron_runs.add_argument("--limit", type=int, default=20, help="Rows to show (1-500)")
# cron tick (mostly for debugging)
cron_tick = cron_subparsers.add_parser("tick", help="Run due jobs once and exit")
add_accept_hooks_flag(cron_tick)

View file

@ -109,8 +109,7 @@ class ChronosCronScheduler(CronScheduler):
# A new provider lifecycle cannot prove what an interrupted prior
# process did. Classify those attempts unknown for audit only; do not
# requeue them here.
from cron.executions import recover_interrupted_executions
recover_interrupted_executions()
self.recover_interrupted()
try:
self.reconcile()
except Exception as e:

View file

@ -4,6 +4,7 @@ from __future__ import annotations
import json
import os
import sqlite3
import subprocess
import sys
from pathlib import Path
@ -12,8 +13,7 @@ from pathlib import Path
def _point_ledger(monkeypatch, tmp_path):
import cron.executions as executions
monkeypatch.setattr(executions, "EXECUTIONS_FILE", tmp_path / "cron" / "executions.json")
monkeypatch.setattr(executions, "EXECUTIONS_LOCK_FILE", tmp_path / "cron" / ".executions.lock")
monkeypatch.setattr(executions, "EXECUTIONS_FILE", tmp_path / "cron" / "executions.db")
return executions
@ -39,6 +39,78 @@ def test_execution_transitions_are_durable(monkeypatch, tmp_path):
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)
@ -61,15 +133,29 @@ def test_recovery_does_not_mark_live_process_execution_unknown(monkeypatch, tmp_
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")
records = json.loads(executions.EXECUTIONS_FILE.read_text())["executions"]
records[0]["process_id"] = "another-import"
records[0]["pid"] = os.getpid()
executions.EXECUTIONS_FILE.write_text(json.dumps({"version": 1, "executions": records}))
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"
@ -121,6 +207,36 @@ def test_restart_marks_interrupted_execution_unknown_without_requeue(tmp_path):
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

View file

@ -25,8 +25,8 @@ def _build():
def test_cron_subactions_present():
parser = _build()
for action in ("list", "create", "edit", "pause", "resume", "run", "remove", "status", "tick"):
ns = parser.parse_args(["cron", action] if action in ("list", "status", "tick")
for action in ("list", "create", "edit", "pause", "resume", "run", "remove", "status", "runs", "tick"):
ns = parser.parse_args(["cron", action] if action in ("list", "status", "runs", "tick")
else ["cron", action, "jobid"] if action in ("pause", "resume", "run", "remove", "edit")
else ["cron", "create", "30m"])
assert ns.command == "cron"
@ -42,6 +42,10 @@ def test_cron_aliases():
for alias in ("rm", "delete"):
ns = parser.parse_args(["cron", alias, "jid"])
assert ns.cron_command == alias
ns = parser.parse_args(["cron", "history", "jid", "--limit", "7"])
assert ns.cron_command == "history"
assert ns.job_id == "jid"
assert ns.limit == 7
def test_cron_create_options():

View file

@ -225,6 +225,20 @@ On each tick Hermes:
A file lock at `~/.hermes/cron/.tick.lock` prevents overlapping scheduler ticks from double-running the same job batch.
### Execution history
Hermes records each claimed cron attempt in the profile-local
`~/.hermes/cron/executions.db` before executor or provider dispatch. Attempts
move through `claimed`, `running`, and one immutable terminal state:
`completed`, `failed`, or `unknown`. After restart, Hermes marks an abandoned
attempt `unknown` only when the original PID and process-start fingerprint prove
that its owner is gone. Unknown attempts are audit records and are never
automatically rerun.
Inspect recent attempts with `hermes cron runs [job-id] --limit 20` (alias:
`history`). Terminal history is bounded; active attempts are never pruned. The
ledger is included in quick backups.
## Delivery options
When scheduling jobs, you specify where the output goes:

View file

@ -244,7 +244,13 @@ hermes cron status
6. 投递最终响应
7. 更新运行元数据和下次调度时间
`~/.hermes/cron/.tick.lock` 处的文件锁防止重叠的调度器 tick 重复运行同一批任务。
`~/.hermes/cron/.tick.lock` 文件锁可防止重叠的调度器 tick 重复运行同一批任务。
### 执行历史
Hermes 会在执行器或调度提供程序分派之前,将每次已领取的 cron 尝试记录到当前 profile 的 `~/.hermes/cron/executions.db`。尝试会依次进入 `claimed``running`,然后进入不可变的终态:`completed``failed``unknown`。重启后,只有原 PID 与进程启动时间指纹能够证明所有者已经消失时Hermes 才会将遗留尝试标记为 `unknown`。未知尝试仅用于审计,绝不会自动重跑。
使用 `hermes cron runs [job-id] --limit 20`(别名:`history`)查看最近的尝试。终态历史有界,活动尝试不会被清理;快速备份也包含该账本。
## 投递选项