Files
hermes-agent/tests/cron/test_execution_progress_stamp.py
Teknium ed2dd0b35c fix(cron): live-owner stale-claim reclaim measures silence, not run length
The #115692 reclaim released any running attempt whose claim was older than
max(3 x HERMES_CRON_TIMEOUT, script timeout, 2 h), even when the owner was
healthy and busy. Every cron job that legitimately ran longer than that was
marked `unknown` mid-run, its fire claim yanked from under it and the run
aborted with "lost its durable fire claim ownership" (two weekly jobs on one
install in a single day).

The run monitor now stamps `progress_at` on the execution row every 60 s
while the agent reports recent activity, and the sweep measures the stale
bound from that stamp (claim age for legacy rows). A wedged worker stops
stamping and is still reclaimed once the bound passes.

Shape: the stamper and the inactivity watchdog loop (which reads the same
idle signal) live in a new topical sibling cron/scheduler_liveness.py, so
cron/scheduler.py shrinks (4457 -> 4436 lines) instead of growing.
2026-10-07 06:29:09 -07:00

78 lines
2.8 KiB
Python

"""The run monitor stamps ``progress_at`` on the execution row while the agent is active.
The live-owner stale-claim sweep (#115692) reclaims a running row once it has been silent
for the derived bound. Before this stamp existed the bound was measured from ``claimed_at``,
so any healthy job that simply ran longer than ~2 h was reclaimed mid-run, its fire claim
yanked and the run aborted with "lost its durable fire claim ownership".
"""
from __future__ import annotations
import threading
import pytest
import cron.executions as executions_mod
from cron import scheduler
from cron.executions import _transaction, create_execution, mark_execution_running
@pytest.fixture(autouse=True)
def _ledger(monkeypatch, tmp_path):
monkeypatch.setattr(executions_mod, "EXECUTIONS_FILE", tmp_path / "cron" / "executions.db")
def _progress_at(execution_id: str):
with _transaction() as conn:
return conn.execute(
"SELECT progress_at FROM executions WHERE id=?", (execution_id,)
).fetchone()["progress_at"]
def test_active_run_stamps_progress_and_idle_run_does_not(monkeypatch):
stamp_every = 0.05
monkeypatch.setattr(scheduler, "_RUN_CLAIM_HEARTBEAT_SECONDS", stamp_every)
monkeypatch.setattr(scheduler, "_cron_inactivity_seconds", lambda: 3600.0)
# The monitor polls concurrent.futures.wait(timeout=_POLL_INTERVAL); shorten the local
# constant so the stamp cadence is observable within the test.
monkeypatch.setattr(
scheduler.concurrent.futures, "wait",
lambda fs, timeout=None: scheduler.concurrent.futures._base.wait(fs, timeout=0.01),
)
finish = threading.Event()
class Agent:
def __init__(self, idle):
self._idle = idle
def get_activity_summary(self):
return {"seconds_since_activity": self._idle}
def run_conversation(self, *a, **k):
finish.wait(2)
return {"final_response": "ok"}
def interrupt(self, *a, **k):
finish.set()
def run(idle_seconds):
finish.clear()
execution = create_execution("job-progress", source="cron")
mark_execution_running(execution["id"])
job = {"id": "job-progress", "execution_id": execution["id"], "schedule": {"kind": "every"}}
timer = threading.Timer(0.4, finish.set)
timer.start()
try:
scheduler._run_agent_with_watchdog(
Agent(idle_seconds), "probe", job, "job-progress", "job-progress", "task", None)
finally:
timer.cancel()
return _progress_at(execution["id"])
# Active agent (idle 0s): the row is stamped.
assert run(idle_seconds=0.0) is not None
# An agent that reports idleness past the stamp cadence is silent in the ledger too: a wedged
# worker must not keep refreshing its own liveness.
assert run(idle_seconds=stamp_every * 10) is None