Files
hermes-agent/cron/scheduler_bookkeeping.py
Teknium 249a90c037 fix(cron): a stopped run stays stopped in every ledger reader
Review round on cron cancel / missed-run policy:

- Receipt recovery, the external-worker waiter and finish_completed_run
  read only completed/failed as "the firer booked this attempt". A
  cancelled agent run was replayed (job marked failed, repeat spent,
  failure notice sent) and a cancelled detached worker was reported as a
  worker that died without terminalizing. One FIRER_FINISHED_STATES set
  now drives all four readers.
- live_inflight_execution skips the due scan's `missed` rows, so a
  missed note booked while a long run is live no longer hides that run
  from the dashboard.
- Stopping a ladder re-run passes ladder_rung, like every other terminal
  path, so it does not spend a second repeat slot.
- A run whose claim vanished because the job was removed (marker
  written, claim dropped) books `cancelled` with the reason at the
  first claim check and on the post-run claim-loss path, not
  "Fire claim ownership lost".
- request_cancel publishes a private temp file with link(), so
  overlapping requests never share a temp path or overwrite the first
  reason.
- Monitoring: cron_health knows `cancelled`/`missed` as terminal
  statuses, and the shared cron-run metric reports `cancelled` instead
  of `failed`.
- Dashboard history: a cancelled agent run's session row carries
  "CANCELLED · <reason>".
- A capped missed count keeps its "counting stopped at 1000" note after
  the catch-up branch books one fewer than it counted.
2026-10-30 05:54:35 -07:00

130 lines
6.6 KiB
Python

"""Terminal bookkeeping phases shared by scheduler execution paths."""
from typing import Optional
EMPTY_RESPONSE_ERROR = "Agent completed but produced empty response (model error, timeout, or misconfiguration)"
def fail_empty_response(d, final_response):
"""A "successful" run with a blank answer is a soft failure (never ``ok``): the ordinary tail and
receipt recovery both book it, so a recovered whitespace answer cannot turn the job green."""
if d.success and not str(final_response or "").strip():
d.success = False
d.error = EMPTY_RESPONSE_ERROR
def finish_interrupted_run(job, execution_id, delivery_error):
"""Shutdown already advanced this run; update a failed notice without spending another fire."""
from cron import scheduler
logger = scheduler.logger
if delivery_error:
try:
from cron.jobs import update_job
update_job(job['id'], {'last_delivery_error': delivery_error})
except Exception as exc:
logger.debug('Failed recording delivery_error for interrupted job %s: %s', job['id'], exc, exc_info=True)
scheduler.finish_execution(execution_id, success=False,
error='Interrupted by gateway shutdown before terminal completion.')
def _classify_delivery_outcome(
*, delivery_error, should_deliver: bool, unresolved_origin: bool,
normalized_deliver: str, incident_acked: bool, success: bool,
delivery_queued=None, notification_suppressed: bool = False,
) -> str:
if delivery_error:
return "failed"
if should_deliver and delivery_queued:
return "queued"
if notification_suppressed:
return "suppressed"
if should_deliver and unresolved_origin:
return "not_configured"
if should_deliver and normalized_deliver != "local":
return "delivered"
if incident_acked and not success:
# Failure ping withheld for a known signature: operator acked it, or it was already
# alerted inside the reminder cooldown (vs. plain "suppressed").
return "suppressed_acked"
return "suppressed"
def finish_completed_run(d, fire_owner: Optional[str], execution_id: str, *, recovered=False) -> bool:
"""mark_job_run (owner-fenced) + execution ledger row for a run that reached delivery.
``d`` is the scheduler's ``_RunDelivery``. Store and ledger writers are read off the
``cron.scheduler`` facade, which is where tests and the receipt-recovery path patch them."""
from cron import scheduler
job = d.job
if not d.should_deliver and job.get("last_delivery_queued"):
from cron.jobs import update_job
update_job(job["id"], {"last_delivery_queued": None})
job["last_delivery_queued"] = None
mark_kwargs: dict = {"delivery_error": d.delivery_error}
from cron.scheduler_authority import journal_path
journal = journal_path(job['id'], execution_id)
if not job.get('no_agent'):
mark_kwargs['execution_id'] = execution_id
if not d.success and job.pop("_model_unreachable", False):
# Never-reached-the-model failure: schedule the Cowork-style bounded re-run
# (cron/unreachable_retry.py) inside the same fenced store write.
mark_kwargs["model_unreachable"] = True
from cron.unreachable_retry import is_retry_run
if is_retry_run(job):
# A re-run of an occurrence that already counted: must not spend another repeat slot.
mark_kwargs["ladder_rung"] = True
_hold_s = job.pop("_quota_hold_seconds", None)
if not d.success and _hold_s:
# Provider window closed for a known duration: park past it (cron/quota_hold.py, #89376).
mark_kwargs["quota_hold_seconds"] = _hold_s
mark_kwargs["recover_consumed_fire"] = bool(job.get("_scheduled_instant"))
if d.success and not d.delivery_error and d.should_deliver and job.get("last_delivery_queued"):
mark_kwargs["status"] = "delivery_queued"
if fire_owner is not None:
mark_kwargs["expected_fire_owner"] = fire_owner
if d.blocked_config:
mark_kwargs["status"] = "blocked_config"
# A run that removed its own record has nothing left to mark; the delivery above is its result.
marked = scheduler.self_removal_delivery_allowed(job["id"]) or scheduler.mark_job_run(
job["id"], d.success, d.error, **mark_kwargs)
if fire_owner is not None and not marked:
scheduler.finish_execution(
execution_id, success=False,
error="Fire claim ownership lost before terminal completion.")
return True
delivery_outcome = _classify_delivery_outcome(
delivery_error=d.delivery_error,
delivery_queued=job.get("last_delivery_queued"),
notification_suppressed=bool(job.get("_notification_all_targets_suppressed")),
should_deliver=d.should_deliver,
unresolved_origin=d.unresolved_origin,
# Read the lane the notice was actually routed through (failure_deliver on failure).
normalized_deliver=scheduler._normalize_deliver_value(
scheduler._delivery_lane_value(job, for_failure=not d.success)),
incident_acked=d.incident_acked,
success=d.success,
)
from cron.delivery_outcome import settle_quietly, settled_outcome
if delivery_outcome == "queued":
# A drain that already finished this send (cron/delivery_outcome.py) is the real outcome.
delivery_outcome = settled_outcome(execution_id) or "queued"
if delivery_outcome in ("delivered", "not_configured") and not d.success:
# Failure ping left the process (or had a configured target): mark the incident alerted.
scheduler._mark_incident_alerted(d.failure_incident_id)
from functools import partial
from cron.executions import FIRER_FINISHED_STATES, get_execution, recover_receipted_execution
finish = partial(recover_receipted_execution, job_id=job['id']) if recovered else scheduler.finish_execution
finished = finish(
execution_id, success=d.success, error=d.error, delivery_outcome=delivery_outcome)
if recovered and finished is None:
current = get_execution(execution_id)
# A live foreign firer still owns this row. Keep its only recovery link until settlement.
# Legacy direct calls may have no execution row; an already-finished row is also safe.
if current is not None and current['status'] not in FIRER_FINISHED_STATES:
return False
if job.get("last_delivery_queued"):
# A drain that settled before this run's own bookkeeping landed found nothing to fence on.
settle_quietly(job["id"], execution_id)
journal.unlink(missing_ok=True)
return True