Skip to content

Cron hot-reload: CronService.reload + POST /api/cron/reload (5/13) - #208

Open
alex-clickhouse wants to merge 1 commit into
config-refactor/04-cron-movefrom
config-refactor/05-cron-reload
Open

Cron hot-reload: CronService.reload + POST /api/cron/reload (5/13)#208
alex-clickhouse wants to merge 1 commit into
config-refactor/04-cron-movefrom
config-refactor/05-cron-reload

Conversation

@alex-clickhouse

@alex-clickhouse alex-clickhouse commented Jul 28, 2026

Copy link
Copy Markdown
Collaborator

What

CronService.reload() and POST /api/cron/reload. Re-reads the merged jobs, re-imports the gate plugins, unschedules the jobs that went away or were disabled, and rebuilds every job that is still enabled.

Every enabled job is rebuilt, not only the ones whose YAML changed. Rebuilding a trigger keeps the next fire time as it was for a crontab job, and for an interval job anchored to a successful run, so for those the work is redundant but free. That leaves one case where it costs something: an interval job that has never succeeded has nothing to anchor to, so each reload restarts its countdown. It's documented rather than worked around.

A gate spec that cannot be built now refuses the job instead of being skipped. An unknown type is the case that matters — it is what a deleted or unimportable gate plugin looks like from the loader — and it takes the job with it: a 400 that refuses the whole reload, or a skipped job with an error at startup. nerve config validate reports an unrecognized type as unverified rather than wrong, because it never loads plugins and so cannot tell a typo from a plugin's own type.

The reload is all-or-nothing. The whole change set is computed, and every trigger built, before the scheduler is touched. That covers the gate registry too: it is process-global and has to be replaced before jobs are rebuilt, because a CronJob builds its gates at construction time — so a reload that is then refused puts it back, rather than leaving a deleted plugin's gate unregistered and the jobs that used it unbuildable.

Reserved job ids (cleanup, wakeup_sweep, and the source: namespace) are enforced at load time.

Why

Config that can only be applied by restarting the daemon is config nobody edits. Restarting drops in-flight sessions, so the cost of a one-line schedule change was high enough to discourage making it.

Refusing a job over its gates is the part worth arguing. A gate is a precondition, so dropping one does not make a job safer — it makes it run more often. "Only when the inbox is busy" becomes "every time", while the config that said otherwise sits there looking correct. Skipping the spec keeps the job alive at the cost of silently inverting what it was for, and hot-reload turns that from a restart-time mistake into an unattended one: a workspace pull that deletes a gate plugin would widen every job using it, on a running daemon, within seconds of the pull. Refusing is the failure that shows up — the reload's 400 names the job, startup logs it, and the schedule that was already running is left alone.

The cost is real and accepted: a plugin that stops importing takes its jobs off the schedule until it is fixed, and one bad gate refuses a reload that may also carry unrelated edits. Deleting a plugin is therefore not a way to switch a gate off; it stops those jobs instead of ungating them, and the docs say so.

Rebuilding unconditionally rather than diffing is a separate trade. The alternative — compare each job against the running one and reschedule only what changed — protects the never-succeeded interval case above and lets the response say which jobs the file changed rather than which were rescheduled. It costs a definition of "changed" that has to be kept in step with CronJob, where a field left out is silently never rescheduled. The only consumer of that distinction is a log line, so updated in the response now means "already had a trigger and was rescheduled", and the docstring and docs/cron.md say so rather than implying a diff.

Reserving the id namespace at load time closes a quieter problem: a user job named cleanup would displace the Nerve-managed one, and the only symptom would be maintenance silently no longer running.

🤖 Generated with Claude Code

@alex-clickhouse alex-clickhouse changed the title Cron hot-reload: CronService.reload + POST /api/cron/reload Cron hot-reload: CronService.reload + POST /api/cron/reload (5/13) Jul 28, 2026
@alex-clickhouse
alex-clickhouse requested a review from Copilot July 28, 2026 10:00

Copilot AI left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds cron hot-reload support to the Nerve daemon, allowing the running scheduler to be updated from on-disk cron config without a restart, while hard-reserving daemon-owned job IDs to prevent user cron definitions from displacing internal jobs or source runners.

Changes:

  • Introduces CronService.reload() plus POST /api/cron/reload, applying scheduler changes by diff (add/remove/update/enable/disable) while preserving interval alignment for unchanged jobs.
  • Enforces reserved cron job IDs (cleanup, wakeup_sweep, and the source: namespace) at load time and surfaces them in CLI reporting.
  • Improves schedule parsing semantics (fractional intervals, and differentiating “not a crontab” vs “invalid crontab”) and adds extensive test coverage + docs for the new behavior.

Reviewed changes

Copilot reviewed 10 out of 10 changed files in this pull request and generated 1 comment.

Show a summary per file
File Description
tests/test_cron.py Adds regression/behavior tests for fractional intervals and invalid crontab handling.
tests/test_cron_reload.py Adds comprehensive reload tests (diff behavior, all-or-nothing safety, reserved IDs, API route behavior).
tests/test_cron_gate_plugins.py Adds tests for gate-plugin hot-reload behavior (replace=True) and propagation into scheduled jobs.
nerve/sources/runner.py Ensures SourceRunner.job_id is always derived under the reserved source: namespace.
nerve/gateway/routes/cron.py Adds POST /api/cron/reload endpoint mapping config errors to HTTP 400.
nerve/cron/service.py Implements reload/diff logic, reserved-id dropping, and new schedule error types.
nerve/cron/jobs.py Adds reserved-id helpers and a strict mode for YAML/job validation used by reload.
nerve/cron/gate_plugins.py Adds hot-reload support via replace=True (unregister + rescan) and warns on vanished gate types.
nerve/cli.py Updates cron doctor/listing output to flag/exclude reserved IDs from enabled counts.
docs/cron.md Documents reload behavior, reserved IDs, strict refusal cases, and interval syntax (including fractions).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread nerve/cron/service.py Outdated
@alex-clickhouse
alex-clickhouse force-pushed the config-refactor/05-cron-reload branch from 7cf5e87 to a3ab221 Compare July 28, 2026 12:11
@alex-clickhouse
alex-clickhouse force-pushed the config-refactor/05-cron-reload branch from a3ab221 to 5616392 Compare July 28, 2026 12:36
@alex-clickhouse
alex-clickhouse force-pushed the config-refactor/05-cron-reload branch from 5616392 to 27094f6 Compare July 28, 2026 13:14
@alex-clickhouse
alex-clickhouse force-pushed the config-refactor/05-cron-reload branch from 27094f6 to 4c15c7e Compare July 28, 2026 14:23
@alex-clickhouse
alex-clickhouse force-pushed the config-refactor/05-cron-reload branch from 4c15c7e to 61ac19d Compare July 28, 2026 14:54
@alex-clickhouse
alex-clickhouse force-pushed the config-refactor/05-cron-reload branch 6 times, most recently from 7d22b98 to df91913 Compare July 29, 2026 09:18
@alex-clickhouse
alex-clickhouse marked this pull request as ready for review July 29, 2026 10:29
@alex-clickhouse
alex-clickhouse force-pushed the config-refactor/05-cron-reload branch from df91913 to 544526c Compare July 29, 2026 10:31
@alex-clickhouse
alex-clickhouse force-pushed the config-refactor/05-cron-reload branch from 544526c to 3229b27 Compare July 30, 2026 08:01
Editing jobs.yaml or system.yaml now applies to the running daemon. The reload
diffs the new job set against the scheduler and adds, removes or reschedules only
what changed, leaving source runners and internal jobs alone. Gate plugins are
re-imported, so an edited or deleted plugin takes effect even for jobs whose YAML
did not change — those keep their timer and have their gate objects swapped, so
editing a gate never resets a schedule.

The reload is all-or-nothing: the whole change set is planned, and every trigger
built, before the scheduler is touched, so a failure leaves every job on its
existing schedule instead of applying half of the change. Reserved job ids are
refused at load time, with the reservation shared with the CLI so one definition
governs both.

Schedule strings are parsed strictly. A 5-field crontab whose fields the
scheduler rejects raised the same ValueError as "this is not a crontab", so
"99 * * * *" fell through to the interval parser and silently became its 2-hour
default. The two cases are now distinct exceptions, answered per call site:
reload refuses the whole change set, startup skips just that job and logs it
(raising would reach a blanket handler and cost every other cron, every source
runner and the reload endpoint), and catch-up will not fire a job startup
declined to schedule. Intervals are matched against the whole string, so "0.5h"
is 1800s rather than the five hours an unanchored scan read out of the digits
next to the unit.

Co-Authored-By: Claude <noreply@anthropic.com>
@alex-clickhouse
alex-clickhouse force-pushed the config-refactor/05-cron-reload branch from 3229b27 to a449d96 Compare July 30, 2026 10:06
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