Skip to content

cron: load drop-in gate plugins from ~/.nerve/cron/gates/#142

Merged
pufit merged 1 commit into
ClickHouse:mainfrom
polyglotAI-bot:polyglot/cron-gate-plugins
Jun 23, 2026
Merged

cron: load drop-in gate plugins from ~/.nerve/cron/gates/#142
pufit merged 1 commit into
ClickHouse:mainfrom
polyglotAI-bot:polyglot/cron-gate-plugins

Conversation

@polyglotAI-bot

Copy link
Copy Markdown
Contributor

Summary

Make Nerve's cron run-gate system extensible without editing core. Drop a .py file defining a CronGate subclass into ~/.nerve/cron/gates/ and Nerve auto-discovers and registers it at daemon startup, so jobs.yaml can reference it via run_if: [{type: <name>}] exactly like a built-in. No edit to nerve/cron/gates.py's GATE_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 via importlib (no pip / no 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. 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 of CronService.start(), before jobs are parsed (a CronJob builds its gates at construction, so plugin types must be registered first).
  • nerve/config.py — new cron.gate_plugins_dir key (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_REGISTRY itself is untouched.
  • tests/test_cron_gate_plugins.py (new) — 21 tests.

Scope / non-goals

  • A gate receives 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).
  • The loader's "never crash" guarantee covers load/import errors. A gate that imports cleanly but whose from_config raises a non-GateConfigError still propagates through the existing build_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 in docs/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-CronGate file, abstract gate, empty type, _-prefix, non-.py), collisions (built-in wins; first-plugin wins), no-op dirs (missing / empty / file-as-dir / tilde-expansion), and end-to-end via CronJob.run_if.
  • No regressions: tests/test_cron.py, tests/test_cron_gates.py, tests/test_config_resolution.py all green (171 passed together).
  • Pre-PR review (2 subagent rounds): the first flagged a blocking issue — an abstract CronGate subclass would register and then crash startup at job-build time (a TypeError that build_gates does not catch) — plus SystemExit-at-import escaping except Exception. Both fixed and covered by new tests; the re-review confirmed them closed with no regressions.

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 pufit merged commit db94abf into ClickHouse:main Jun 23, 2026
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