Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
3f42aa1
Add sync.github.mode, pr_watch and the delivery debounce setting
pufit Oct 21, 2026
1b94ac2
Add source records and the subscriptions frame to the channel contract
pufit Oct 21, 2026
589c422
Take hosted GitHub records off the channel streams
pufit Oct 21, 2026
8a00ac2
Drain hosted GitHub records with the GitHub source runner
pufit Oct 21, 2026
b5d68ee
Stop asking for gh when GitHub is hosted
pufit Oct 21, 2026
64305d0
Merge commit 'cb2b66173264b23380cce76b71f04b61c846fa8d' into pufit/ho…
pufit Oct 21, 2026
da9af89
Run gated cron jobs as soon as hosted source records arrive
pufit Oct 21, 2026
3247398
Test the hosted GitHub source from the stream to the cron run
pufit Oct 21, 2026
bddf729
Document the hosted GitHub source and runs on delivery
pufit Oct 21, 2026
556058f
Keep the lint count of the hosted source changes at zero
pufit Oct 21, 2026
2b56277
Cap the delivery debounce where the cron service reads it
pufit Oct 21, 2026
594a1ff
Filter pull request records by the repository rules only
pufit Oct 21, 2026
7416388
Warn that a switched-off hosted Slack holds GitHub records back
pufit Oct 21, 2026
bd3007c
Wait for a run that took the runner before running delivered jobs
pufit Oct 21, 2026
6b40ada
Cancel delivery runs that already started when cron stops
pufit Oct 21, 2026
499a768
Never send a subscriptions frame the gateway would refuse
pufit Oct 21, 2026
57533e9
Pin the glob dialect that the hosted GitHub gateway shares
pufit Oct 21, 2026
cefc4c5
Bound each publish of the subscriptions and log its failures
pufit Oct 21, 2026
faab380
Keep the PR gate on the GitHub mode the process started with
pufit Oct 21, 2026
6e91a59
Test the delivery wiring at startup and the republish on reload
pufit Oct 21, 2026
eb427b1
Keep a messages gate's cursors alive while it checks them
pufit Oct 21, 2026
abe3f3e
Correct the hosted GitHub docs on PR records and switching back
pufit Oct 21, 2026
7e78a3a
Offer the GitHub switch on keyless setups when GitHub is hosted
pufit Oct 21, 2026
0eb0fab
Word the keyless GitHub exception in the route as the checklist does
pufit Oct 21, 2026
85dc69b
Treat a GitHub cursor of digits as no timestamp
pufit Oct 21, 2026
aa95ef9
Build the record of a notification whose enrichment was cancelled
pufit Oct 21, 2026
f012e66
Say how long the gateway keeps hosted records in the docs' words
pufit Oct 21, 2026
414c580
Use the UTC alias in the new cursor and gate code
pufit Oct 21, 2026
aafc90b
Let the first subscriptions frame wait out a stalled stream
pufit Oct 21, 2026
0af2e6a
Keep the GitHub events source off while GitHub is hosted
pufit Oct 21, 2026
ed1c6b2
Hold the GitHub test fixtures to the channel contract's samples
pufit Oct 21, 2026
9003afa
Merge the cron forecasts, pause catch-ups and activity reporting
pufit Oct 21, 2026
d1186c9
Track delivery runs with the other runs of a cron job
pufit Oct 21, 2026
24d561e
Bound the wait on a fire that a delivery run should skip
pufit Oct 21, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions config.example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -297,6 +297,13 @@ sync:
github:
enabled: true
schedule: "*/15 * * * *"
# mode: poll # poll = gh CLI on this machine; hosted = the
# channel gateway polls GitHub and streams the
# records (needs channels.hosted). Restart-only.
# pr_watch: true # hosted only: a record when one of your own
# open PRs changes (state, commit, review, CI,
# comment). Its reason is always "author", and
# only allow_repos/deny_repos apply to it.
# Guardrails (case-insensitive globs; deny wins; non-empty allow is
# fail-closed). allow_* = allowlist (empty = all pass).
# repo_name — the notification's repo full_name ("ClickHouse/nerve").
Expand Down
34 changes: 30 additions & 4 deletions docs/config.md
Original file line number Diff line number Diff line change
Expand Up @@ -242,7 +242,8 @@ A reload is always explicit. Two things cause one:
| Cron jobs (`config/cron/*.yaml`) | ✅ |
| Custom cron gate plugins (new, edited *and* deleted `.py` files) | ✅ the registry is rebuilt from the directory |
| Cron file locations (`cron.jobs_file`, `system_file`, `gate_plugins_dir`) | ✅ re-read from the new config |
| Cron sources: `sync.telegram`, `.gmail`, `.github`, `.github_events`, `.github_repos`, `.message_ttl_days`, and each source's `schedule` | ✅ runners are rebuilt and rescheduled, all or nothing: a `schedule` the scheduler will not take refuses the source reload with the running sources on their old triggers, and reports the error rather than `ok`. **`sync.codex` is not one of these**; see the restart table |
| Cron sources: `sync.telegram`, `.gmail`, `.github`, `.github_events`, `.github_repos`, `.message_ttl_days`, and each source's `schedule` | ✅ runners are rebuilt and rescheduled, all or nothing: a `schedule` the scheduler will not take refuses the source reload with the running sources on their old triggers, and reports the error rather than `ok`. In hosted mode the GitHub filters and `pr_watch` are published to the gateway again. **`sync.codex` and `sync.github.mode` are not among these**; see the restart table |
| `cron.delivery_debounce_seconds` | ✅ read for each delivery |
| MCP servers (`mcp_servers`) | ✅ new sessions get the new set |
| Skills (`skills/`) | ✅ re-scanned |
| `lockdown` | ✅ the write guards and the layer stack both follow |
Expand All @@ -254,7 +255,7 @@ A reload is always explicit. Two things cause one:
| `external_agents.targets` (including each target's `enabled`), `.sync_interval_minutes`, `.conflict_policy` | ✅ from the next sweep, provided at least one target existed at startup (see the restart table) |
| `sessions.sticky_period_minutes` | ✅ |
| `telegram.dm_policy`, `.stream_mode` | ✅ read per update. Tightening `open` to `pairing` takes effect on the next message; `allowed_users` does not follow it (see the restart table) |
| `slack.*` | ✅ `enabled` starts or stops the channel; same-workspace token changes reconnect and roll back on failure; other changes apply to the next event. In hosted mode `enabled: false` pauses intake and notifications until it is `true` again, `source.*` applies to the next event, and `mode` needs a restart (see the restart table) |
| `slack.*` | ✅ `enabled` starts or stops the channel; same-workspace token changes reconnect and roll back on failure; other changes apply to the next event. In hosted mode `enabled: false` pauses intake, the records of a hosted GitHub source included, and notifications until it is `true` again, `source.*` applies to the next event, and `mode` needs a restart (see the restart table) |
| `workflows.*` and `workflows.review_loop.*` — budget caps, concurrency, the warning fraction, iteration and criteria caps, leg engines/models, the verifier sandbox | ✅ read per use, by loops and runs already in flight as well as new ones. The two `enabled` flags and the two loop cadences are the exceptions; see the restart table |
| `provider.*` and the API keys it selects (`aws_region`, `aws_profile`, `aws_access_key_id`, and the effective Anthropic key) | ✅ for sessions started **after** the reload. Each client's environment is built from the live reference when the session is created, by the same seam as `agent.*` below |
| **`agent.*` and `codex.*`**: backend choice and models (`agent.backend`, `agent.cron_model`, `agent.model`, `codex.model`, `codex.cron_model`, `codex.models`), `max_turns`, `agent.effort`/`cron_effort` and `codex.effort_map`, `agent.thinking`, `agent.context_1m*`, `agent.background_agent_permissions`, `agent.agent_teams`, `agent.subagents.*`, idle timeouts, cache TTL, `codex.sandbox`, `.approval_policy`, `.web_search`, `.extra_config`, `.tool_timeout_sec`, `.bin_path`, `.auth`/`.api_key`/`.api_key_env`, `.pricing`, `.min_version`/`.max_version`, `.ultracode.*` | ✅ for sessions and turns **started after** the reload. The engine and both backends resolve these through one live reference, so a key cannot be hot in one and frozen in the other |
Expand Down Expand Up @@ -294,6 +295,7 @@ reload cannot inspect, and are documented here only.
| `mcp_endpoint.*` | fixed when the app was created |
| `mcp_gateway_url` | the catalog reader is built at startup. See [MCP servers in external mode](#mcp-servers-in-external-mode) |
| `slack.mode`, `channels.hosted.*` | the hosted channel, its stream endpoint, and its token verifier are built at startup. A reload reads the file that `gateway_jwks_file` names again, but not a changed path. Changing `mode` to `socket` reports a Slack reload error until a restart, because the hosted channel still holds the `slack` name. Changing it to `hosted` stops the Socket Mode connection at once, and the stream endpoint opens after a restart |
| `sync.github.mode` | the GitHub source, and in hosted mode the stream endpoint and the consumer of the gateway's records, are chosen at startup. Until a restart the source keeps the mode it started with, and so does the `github_pr_activity` gate. The filters and `pr_watch` do follow a reload: in hosted mode the reload publishes them to the gateway again. To go back to `poll`, set `enabled: false` and reload first (see [sources.md](sources.md#hosted-github-source)) |
| `auth.jwt_secret` | pinned at startup for every consumer, web gateway and MCP endpoint alike. A reload that changes or removes it is reported and changes nothing live: removing the key must not reopen the instance, and rotating it must not swap the key under live sessions half-way. The next restart applies it (with the key removed, the secret generated into `nerve.db` takes over) |
| `workflows.enabled`, `workflows.review_loop.enabled` | each service is created at startup and only when its flag is on. Turning one **off** does not stop the service already running, and turning it **on** creates nothing for a reload to reach |
| `workflows.poll_interval_seconds`, `workflows.review_loop.reconcile_interval_seconds` | both loops were handed their interval when they started. Everything else under `workflows.*` is read per use (see the table above) |
Expand Down Expand Up @@ -1319,7 +1321,9 @@ latest accepted event.
reload. The stream endpoint still accepts streams, and events stay in the
gateway's inbox until a reload sets `enabled` to `true`. While it is paused,
Nerve sends no notifications and refuses `send_channel_message`. A turn that
is already running still sends its reply.
is already running still sends its reply. Nerve reads nothing from the
gateway's inbox while Slack is paused, so the records of a hosted GitHub
source wait there too (see below).

Replies, reactions, and `send_channel_message` go to Slack as operations on
a gateway stream that advertises them for the connection. A reply goes on the
Expand Down Expand Up @@ -1360,6 +1364,21 @@ reports this and the local settings that still apply.
hosted channels reads the key file again. Changing `mode` from `socket` to
`hosted` stops the Socket Mode connection at once.

The same streams carry hosted sources. With `sync.github.mode: hosted` the
gateway polls GitHub for the agent and streams the records; the
`channels.hosted` settings above apply unchanged, and they are enough on their
own: Slack need not be hosted too. See
[Hosted GitHub source](sources.md#hosted-github-source).

When Slack is hosted as well, its switch also holds GitHub back. Nerve reads
the events of every hosted provider from one ordered inbox, and it reads
nothing while hosted Slack is switched off (`slack.enabled: false`): the
GitHub records wait at the gateway until Slack is switched on again, and past
the gateway's limit of stored records the oldest are dropped. Keep a hosted
Slack switched on while GitHub is hosted; `nerve doctor` warns when it is off.
This is a known limitation: a read for each provider on its own, which would
keep GitHub records arriving while hosted Slack is off, is a follow-up.

### Setting up the Slack app

Socket Mode means the bot dials out to Slack, so Nerve needs no public URL.
Expand Down Expand Up @@ -1713,7 +1732,14 @@ the channel transport, matching messages are written to a local buffer.

| Key | Type | Default | Description |
|-----|------|---------|-------------|
| `sync.github.schedule` | cron | `*/15 * * * *` | Fetch frequency |
| `sync.github.schedule` | cron | `*/15 * * * *` | Fetch frequency; in hosted mode the fallback cadence of the drain |
| `sync.github.mode` | string | `poll` | `poll`: fetch notifications here with the gh CLI. `hosted`: the channel gateway polls GitHub, applies the guardrails, and streams the records (needs the `channels.hosted` settings, see [Hosted mode](#hosted-mode)). An unknown value falls back to `poll` with a warning. Restart-only. See [sources.md](sources.md#hosted-github-source) |
| `sync.github.pr_watch` | bool | `true` | Hosted mode: also deliver a `github_pr_activity` record whenever one of the agent's own open pull requests changes. Its `reason` is always `author`, and only `allow_repos` and `deny_repos` apply to it (see [sources.md](sources.md#github-pull-request-activity-records)) |

`cron.delivery_debounce_seconds` (int, default `60`) spaces the job runs that a
hosted delivery starts; a negative value reads as `0`, and a value above `600`
waits `600` seconds (`nerve config validate` warns). See
[cron.md](cron.md#runs-on-delivery).

## Memory (memU)

Expand Down
47 changes: 46 additions & 1 deletion docs/cron.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ prompt definition.
| `reminder_mode` | bool | no | Persistent only: send short reminder instead of full prompt on subsequent runs (default: false) |
| `catchup` | bool | no | Fire once if the job missed a run while the server was down (on startup) or while the machine was paused (on resume — see [After a pause](#after-a-pause-sleep-suspend-snapshot-restore)) (default: true) |
| `enabled` | bool | no | Whether the job is active (default: true) |
| `lock` | bool | no | Serialize this job's runs inside the daemon: a web-triggered run waits for the previous one to finish. An overlapping scheduled fire or catch-up run is skipped either way, and `nerve cron <id>` runs outside the lock (default: false) |
| `lock` | bool | no | Serialize this job's runs inside the daemon: a web-triggered run waits for the previous one to finish, and so does a scheduled fire that comes while a run for delivered source records goes on (see [Runs on delivery](#runs-on-delivery)). Any other overlapping scheduled fire or catch-up run is skipped either way, and `nerve cron <id>` runs outside the lock (default: false) |
| `run_if` | list | no | Run gates — preconditions that must all hold for the job to fire. See [Run Gates](#run-gates) |
| `workflow` | map | yes* | Launch a budget-capped [workflow run](workflow-runs.md) instead of a prompt: `{engine, prompt, budget_usd[, title, model, effort, cwd]}`. Takes precedence over `prompt`/`prompt_file`; the cron job only launches the run (fire-and-forget) — the run notifies on its own |

Expand Down Expand Up @@ -253,6 +253,13 @@ run_if:
consumer: inbox
```

Checking the gate keeps the consumer's cursors from expiring: each check renews
the TTL (`sync.consumer_cursor_ttl_days`) of the live cursors it reads. A cursor
expires when nothing reads it for that long, and an expired cursor starts again at
the source's newest message. So without the renewal, a source that stayed quiet
for a few days would lose its first new message to the restart. A cursor that has
already expired still starts again at the newest message, as a new one does.

> **Legacy shorthand.** The older `skip_when_idle: [<sources>]` /
> `idle_consumer: <name>` fields still work — they are translated into an
> equivalent `messages` gate at load time. Prefer `run_if` for new jobs.
Expand Down Expand Up @@ -292,6 +299,15 @@ rather than bot autodetection, because bots are not reliably identifiable here:
direction: an unlisted bot costs one extra wake, whereas a wrong autodetect
would silently drop human feedback.

With `sync.github.mode: hosted` the gate always passes and runs no `gh`: the
channel gateway watches the agent's own open pull requests (`sync.github.pr_watch`)
and delivers a `github_pr_activity` record for each change. A record does not
start a job by itself. It starts a job gated on `messages` with
`sources: [github]` at once (see [Runs on delivery](#runs-on-delivery)), so gate
pull request monitors that way. A job gated on `github_pr_activity` alone is
never started by a delivery, and since the gate always passes, it runs, in full,
on every scheduled tick; `nerve doctor` names the jobs that still use this gate.

### Forecasts

A gate can also say ahead of time what it will decide. `forecast(ctx, at)`
Expand Down Expand Up @@ -673,6 +689,35 @@ Source runners:

See [sources.md](sources.md) for full documentation.

### Runs on delivery

A hosted source — GitHub with `sync.github.mode: hosted`, see
[Hosted GitHub source](sources.md#hosted-github-source) — does not wait for its
schedule. When the channel gateway delivers records, the source's runner runs
at once, after any run of it already in progress (which may have fetched
before the records arrived); deliveries during that drain make it run once
more. Then every enabled job on the schedule (a job whose schedule did not
parse is not) whose gates include a `messages` gate naming that source, or
naming no source, runs once `cron.delivery_debounce_seconds` after the first
delivery of a burst. Later deliveries in that window add nothing: one burst,
one run.

```yaml
cron:
delivery_debounce_seconds: 60 # default; 0 runs the jobs as soon as the records are in, at most 600 applies
```

Such a run goes through the job's gates, `lock` and run log like a scheduled
run, and the schedule listeners hear of it. It waits for a run of the job that
is already in flight. While it runs, a scheduled fire of the job is skipped, as
an overlapping scheduled fire is, or waits for it when the job has `lock`; a
catch-up is skipped, as it is while any run of the job goes on; and a run
started by hand starts as it always does. A catch-up queued before the run does
not run the job again, since the job has run since. The drain counts as a run
of the source's runner in the same way. Jobs without such a gate keep to their
schedule, and the schedules stay the fallback for everything. The setting is
read for each delivery, so a reload applies it.

## Logging

Every cron and source run is logged in the `cron_logs` SQLite table:
Expand Down
Loading
Loading