diff --git a/cron/jobs.py b/cron/jobs.py index f560f381c5..e2bfa0c97a 100644 --- a/cron/jobs.py +++ b/cron/jobs.py @@ -2158,11 +2158,31 @@ def pause_job(job_id: str, reason: Optional[str] = None) -> Optional[Dict[str, A def resume_job(job_id: str) -> Optional[Dict[str, Any]]: - """Resume a paused job and compute the next future run from now. Accepts a job ID or name.""" + """Resume a paused job. Accepts a job ID or name. + + A recurring job paused across one of its slots must not lose that slot silently: the stored + ``next_run_at`` (already past) survives resume as the due instant, and the ordinary late / + catch-up / ``cron.catch_up_missed`` policy in the due scan decides what happens to it — one + fire or a logged skip, never a silent re-anchor past it (#113603). One-shots and future + instants recompute from now as before. + """ job = resolve_job_ref(job_id) if not job: return None - next_run_at = compute_next_run(job["schedule"]) + stored_next = job.get("next_run_at") + stored_dt = _parse_aware(stored_next) if stored_next else None + if ( + job["schedule"].get("kind") in {"cron", "interval"} + and stored_dt is not None + and stored_dt <= _hermes_now() + ): + next_run_at = stored_next + logger.info( + "Job '%s' resumed with occurrence %s that elapsed while paused kept due; the next " + "tick fires it (late/catch-up) or logs the skip.", + job.get("name", job["id"]), stored_next) + else: + next_run_at = compute_next_run(job["schedule"]) if next_run_at is None and job["schedule"].get("kind") == "once": run_at = job["schedule"].get("run_at", "unknown") raise ValueError( diff --git a/website/docs/developer-guide/cron-internals.md b/website/docs/developer-guide/cron-internals.md index ba7aaa9574..bb75df169f 100644 --- a/website/docs/developer-guide/cron-internals.md +++ b/website/docs/developer-guide/cron-internals.md @@ -155,8 +155,13 @@ dropped silently. The mechanics, in the order the due scan applies them with a logged reason when the operator set `cron.catch_up_missed: false` (planned downtime). One-shots past their 120 s grace are retired with a diagnostic, never resurrected. -6. **Paused / disabled / terminal jobs never catch up**; the due scan drops them - before any of the above, and pause/resume clears any pending slot. +6. **Paused / disabled / terminal jobs never fire**; the due scan drops them + before any of the above, and pause/resume clears any pending slot. A + recurring occurrence that came due *while paused* is not lost, though: + `resume_job` keeps a past stored `next_run_at` as the due instant instead of + re-anchoring from now (and logs that it did), so the first tick after + resume applies rules 3–5 to it — one late/catch-up run, or a logged skip. + One-shots and future instants recompute from now on resume. The same store fields drive every topology: a standalone `hermes -p X gateway run` and a profile served by the default multiplexer (`_start_multiplex` ticks diff --git a/website/docs/reference/cli-commands.md b/website/docs/reference/cli-commands.md index 19a6456bfb..ef7b28eb21 100644 --- a/website/docs/reference/cli-commands.md +++ b/website/docs/reference/cli-commands.md @@ -694,7 +694,7 @@ hermes cron `. | | `edit` | Update a job's schedule, prompt, name, delivery, repeat count, or attached skills. Supports `--clear-skills`, `--add-skill`, and `--remove-skill`, plus `--reasoning-effort` (empty string clears the pin). | | `pause` | Pause a job without deleting it. | -| `resume` | Resume a paused job and compute its next future run. | +| `resume` | Resume a paused job. A recurring slot that came due while paused stays due (one catch-up run or a logged skip on the next tick); otherwise the next future run is computed. | | `run` | Trigger a job on the next scheduler tick. | | `remove` | Delete a scheduled job. | | `status` | Check whether the cron scheduler is running. | diff --git a/website/docs/user-guide/features/cron.md b/website/docs/user-guide/features/cron.md index 1e8282aad5..9b60da6954 100644 --- a/website/docs/user-guide/features/cron.md +++ b/website/docs/user-guide/features/cron.md @@ -264,7 +264,7 @@ hermes cron tick What they do: - `pause` — keep the job but stop scheduling it -- `resume` — re-enable the job and compute the next future run +- `resume` — re-enable the job. A recurring job whose slot came due while it was paused keeps that slot due, so the next tick fires one catch-up run (or logs the skip when `cron.catch_up_missed: false`) instead of silently jumping to the next occurrence; otherwise the next future run is computed - `run` — trigger the job on the next scheduler tick - `remove` — delete it entirely - `edit` — modify schedule, prompt, delivery, etc.