Skip to content

cron: forecast gates and report what runs next - #537

Merged
pufit merged 14 commits into
pufit/cron-resume-catchupfrom
pufit/cron-forecast-schedule
Oct 21, 2026
Merged

pufit merged 14 commits into
pufit/cron-resume-catchupfrom
pufit/cron-forecast-schedule

Conversation

@pufit

@pufit pufit commented Oct 21, 2026

Copy link
Copy Markdown
Member

cron: forecast gates and report what runs next

Stacked on #536 (resume catch-up).

  • CronGate.forecast(ctx, at): would the gate be satisfied at at, 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_activity returns True without running gh). Plugin gates that do not define their own forecast are 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, pending ScheduleWakeups — 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 by Nerve

🤖 Generated with Claude Code

pufit added 14 commits October 21, 2026 01:30
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.
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.
@pufit
pufit merged commit 2f63104 into main Oct 21, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants