Repository navigation
cron: forecast gates and report what runs next - #537
Merged
Merged
Conversation
A gate can now say ahead of time what it will decide: CronGate.forecast answers from what is known now, without side effects and without the network. By default it evaluates the gate, which is exact for gates over local state; the messages gate reads its cursors instead of starting one, and github_pr_activity forecasts a run because only a poll could tell. forecast_gates combines them like evaluate_gates, failing open the same way. CronService.next_scheduled() returns the earliest work the instance will start on its own within a horizon (7 days by default): the next run of each cron job whose gates forecast a run, of each source runner, and the earliest pending session wakeup, never cleanup or the wakeup sweep. schedule_report() adds each job's forecast for its next run. Schedule listeners are told after start, reload and source reload, after every job or source run (gate skips included), on wakeup claims, on fires the scheduler drops or skips, and when triggers move past a pause.
GET /api/cron/schedule returns CronService.schedule_report(): the next
tick the instance will act on by itself, and for each job its next run
with what its gates forecast for it. `nerve cron next` prints the same
from the running daemon: the next tick, then a table of jobs, next runs
and forecasts ("run", or "skip" with the reason).
The job card gains a Forecast column next to Next Run: "run", "skip —" with the reason the gates give, or a dash when the job has no next run. The store loads /api/cron/schedule alongside the job list and keeps the list when the schedule cannot be read.
docs/cron.md explains forecast(ctx, at) for gate authors (the default, the messages and github_pr_activity behaviour, when to override), the new "What runs next" section (next tick, per-job forecasts, listeners) and `nerve cron next`; docs/api.md describes GET /api/cron/schedule. The gate-plugin module docstring and its example show a forecast override.
…edule # Conflicts: # nerve/cron/service.py
A run that the shutdown cancels still ends in the run wrapper, which tells the schedule listeners. That happens after stop() has waited for the listener tasks, so an async listener could be started that nobody would wait for or cancel. Listeners are now told nothing once stop() has begun. An async listener that fails is logged with its traceback, as a plain one already was.
A gate that does not define forecast() is forecast by calling its is_satisfied() check, and forecasts are asked for on every schedule read: each Cron page load, each nerve cron next. A plugin gate whose check saves state, as one that compares with a stored fingerprint does, would have what it is waiting for used up by a page load, and the run that comes later would skip. The default stays, so the contract is now said loudly: the loader logs a warning for each plugin gate that keeps the default forecast, and the plugin docs and the loader's docstring say that such a gate's check must be side-effect-free and stay off the network, or the gate must override forecast().
Only each job's next run was forecast. A gate whose answer changes with time, such as an age threshold, that skips the next run but would let a later one through made the job count for nothing within the horizon, so the next tick could be reported much later than the work. When a job's next run is forecast to skip, up to 8 more of its fire times within the horizon are now tried, and the first one forecast to happen counts for the next tick. Only the gates that define their own forecast are asked again. A gate on the default forecast answers from what it sees now whatever the time, so it is asked once per read: its check never runs again for a later time, and if it says no, no later run is tried. The per-job forecast still describes the next run.
The messages gate's forecast read each consumer cursor as it is now. A cursor that is live now but expires before the run is started again at the source's newest message when the run comes, so that source has nothing unread and the check skips; the forecast still said the job would run, which reported work too early and showed a wrong forecast. The cursor is now read as of the run's time: peek_consumer_cursor() takes the moment to judge expiry at, and the forecast passes it.
A gate that raises while forecasting was logged as a warning on every schedule read, and the schedule is read on each Cron page load, each nerve cron next and by anything that keeps the next tick current, so one broken gate became steady noise in the log. It is now a warning at most once per job every ten minutes, and DEBUG in between.
POST /api/sources/{name}/sync and /api/sources/sync-all run a source
runner directly, outside the cron service's run wrapper, so the new
messages they bring in reached no schedule listener, although they can
change what a messages gate forecasts. Both now call the new public
CronService.notify_schedule_changed() once their runs are over.
The docs also no longer say a forecast over local state is exact
because only scheduled work changes it. They name the changes that are
not announced, a wakeup recorded during a turn and tasks created or
moved from a chat, the web UI or the API, and say to refresh a kept
answer periodically.
next_scheduled(now=...) given a naive datetime failed with a TypeError from deep inside, comparing it with the scheduler's aware run times. It now refuses one up front with a ValueError that says so, and its docstring and the docs say the time must be timezone-aware. They also say that the tick can lie a little in the past: a session wakeup that came due but has not fired yet, because the sweep runs every 20 seconds and waits while its session is in a turn, is reported at the time it came due.
The Cron page loaded the job list and the schedule together and showed nothing until both had answered. Every job's gates are asked for their forecasts on the server, and the request had no time limit, so one slow gate could hold up the whole page, which never depended on gate code before. The jobs now show as soon as the list arrives, keeping the forecasts they had until fresh ones come in. A schedule request still unanswered after 10 seconds is aborted and the jobs show no forecast, and the answers to a load that a newer one has replaced are ignored.
A scheduled fire that comes while the job's catch-up run is still going is skipped, and the run wrappers tell the schedule listeners all the same, as they do for every run that ends. Cover both wrappers, the cron job's and the source runner's. Build the naive time in test_now_must_be_timezone_aware explicitly, so no datetime lint flags a time that is passed without a zone on purpose.
alex-clickhouse
approved these changes
Oct 21, 2026
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
cron: forecast gates and report what runs next
Stacked on #536 (resume catch-up).
CronGate.forecast(ctx, at): would the gate be satisfied atat, judged from what is known now. Default =is_satisfied(right for gates over local state, which cannot change on its own); must be side-effect-free; a gate that persists state or reaches the network overrides it (github_pr_activityreturns True without runninggh). Plugin gates that do not define their ownforecastare warned about at load time.CronService.next_scheduled(): the earliest upcoming tick that will actually run — enabled cron jobs whose gates do not forecast a skip (walking a few later fire times for gates with their own forecast), source runners, pendingScheduleWakeups — within a 7-day horizon; internal housekeeping jobs excluded.schedule_report()and schedule-change listeners (start, reload, every run end incl. gate skips, wakeup claims, manual source syncs).GET /api/cron/schedule,nerve cron next, and a "Forecast" column on the cron page (loaded separately from the job list, with a timeout).docs/cron.md: Forecasts, What runs next.Tests: 37 new (+ 9 vitest cases for the Forecast column); full suite 6,710 passed, 39 skipped; vitest 72 files / 998 passed; web lint unchanged, ruff one diagnostic fewer.
🤖 Generated with Claude Code