cron: load drop-in gate plugins from ~/.nerve/cron/gates/#142
Merged
Conversation
Custom cron run-gates previously required editing GATE_REGISTRY in
nerve/cron/gates.py — an additive but still upstream edit that diverges
on every pull. This adds a one-time, generic, upstreamable plugin loader:
drop a .py file defining a CronGate subclass into the gate-plugins
directory and Nerve auto-discovers and registers it at startup, so
jobs.yaml can reference it via run_if: [{type: <name>}] exactly like a
built-in. No edit to gates.py's registry → custom gates never conflict
on an upstream pull.
- nerve/cron/gate_plugins.py (new): load_gate_plugins(dir) scans the
directory, imports each file via importlib (no pip/package), and
registers every CronGate subclass with a non-empty type. Fail-safe —
a file that fails to import (syntax error, module-level raise, even a
stray sys.exit()), defines an abstract gate, or collides on type is
logged and skipped; it can never crash daemon startup. Built-in wins
on a type collision; among plugins the first by filename wins.
- nerve/cron/service.py: load plugins at the top of CronService.start(),
before jobs are parsed (CronJob builds its gates at construction).
- nerve/config.py: new cron.gate_plugins_dir key (default
~/.nerve/cron/gates).
- docs/cron.md: "Custom gate plugins (drop-in)" section, including the
DB-only-context note and a trust note (files here execute at startup —
same trust model as config.yaml / MCP servers / cron prompts).
- nerve/cron/gates.py: one-line docstring pointer to the mechanism; the
GATE_REGISTRY itself is untouched.
- tests/test_cron_gate_plugins.py (new, 21 tests).
A gate still receives GateContext{job_id, db} (DB-only) — enough for an
age- or count-based gate. A liveness/session-registry based gate would
need the context widened and is out of scope for this loader.
pufit
approved these changes
Jun 23, 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.
Summary
Make Nerve's cron run-gate system extensible without editing core. Drop a
.pyfile defining aCronGatesubclass into~/.nerve/cron/gates/and Nerve auto-discovers and registers it at daemon startup, sojobs.yamlcan reference it viarun_if: [{type: <name>}]exactly like a built-in. No edit tonerve/cron/gates.py'sGATE_REGISTRY→ custom gates never conflict on an upstream pull, and the loader itself is generic/upstreamable.What's here
nerve/cron/gate_plugins.py(new) —load_gate_plugins(dir): scans the directory, imports each file viaimportlib(no pip / no package), and registers everyCronGatesubclass with a non-emptytype. Fail-safe: a file that fails to import (syntax error, module-levelraise, even a straysys.exit()), defines an abstract gate, or collides ontypeis logged and skipped — it can never crash daemon startup. A built-in wins on a type collision; among plugins the first by filename wins. Files prefixed_and non-files are skipped.nerve/cron/service.py— loads plugins at the top ofCronService.start(), before jobs are parsed (aCronJobbuilds its gates at construction, so plugin types must be registered first).nerve/config.py— newcron.gate_plugins_dirkey (default~/.nerve/cron/gates). Existing configs parse unchanged.docs/cron.md— "Custom gate plugins (drop-in)" section, including the DB-only-context note and a trust note.nerve/cron/gates.py— one-line docstring pointer to the mechanism;GATE_REGISTRYitself is untouched.tests/test_cron_gate_plugins.py(new) — 21 tests.Scope / non-goals
GateContext{job_id, db}(DB-only) — enough for age/count based gates. A liveness / session-registry gate would need the context widened; that is out of scope for this loader (a separate change).from_configraises a non-GateConfigErrorstill propagates through the existingbuild_gates(unchanged here) — the same contract built-in gates already follow. Left as-is to avoid masking real bugs.Trust model
Files in the gate-plugins directory are imported (executed) at daemon startup — the same trust model as
config.yaml, configured MCP servers, and cron prompt files (all user-controlled code/config the daemon already loads). Documented indocs/cron.md.Test plan
tests/test_cron_gate_plugins.py— 21 tests: happy path (register + build + evaluate; multi-gate file), fail-safe isolation (broken syntax, module-level raise,sys.exit()at import, no-CronGatefile, abstract gate, emptytype,_-prefix, non-.py), collisions (built-in wins; first-plugin wins), no-op dirs (missing / empty / file-as-dir / tilde-expansion), and end-to-end viaCronJob.run_if.tests/test_cron.py,tests/test_cron_gates.py,tests/test_config_resolution.pyall green (171 passed together).CronGatesubclass would register and then crash startup at job-build time (aTypeErrorthatbuild_gatesdoes not catch) — plusSystemExit-at-import escapingexcept Exception. Both fixed and covered by new tests; the re-review confirmed them closed with no regressions.