Broaden hot-reload: unified reload_all + POST /api/config/reload (12/13) - #215
Broaden hot-reload: unified reload_all + POST /api/config/reload (12/13)#215alex-clickhouse wants to merge 8 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
Adds a unified, best-effort hot-reload pathway (reload_all) and exposes it via POST /api/config/reload, then aligns workspace sync and long-lived services to report/handle partial reload failures per subsystem (instead of collapsing outcomes into a single success/failure).
Changes:
- Introduces
nerve.config_reload.reload_all()+reload_failures()and wires a newPOST /api/config/reloadroute to return per-subsystem reload details/errors. - Refactors workspace sync application (
_apply_sync, periodic loop, sync route) to use the unified reload summary and accurately report “partly applied” vs “not applied”. - Updates cron/source reload behavior and agent backend config access so reloads re-point long-lived components; adds tests and updates operator docs accordingly.
Reviewed changes
Copilot reviewed 22 out of 22 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
| tests/test_ultracode_integration.py | Updates backend deps in tests to pass config via callable. |
| tests/test_sync_service.py | Extends sync tests to assert unified reload behavior and partial-failure reporting. |
| tests/test_server_periodic_loops.py | Adds coverage ensuring periodic loops follow config reloads where intended. |
| tests/test_external_agents_sync_service.py | Adds coverage for external agents service adopting reloaded config and interval updates. |
| tests/test_engine.py | Updates backend construction in tests to pass config via callable. |
| tests/test_cron_reload.py | Updates cron reload expectations and adds coverage for rejected (reserved) job IDs. |
| tests/test_config_reload.py | New comprehensive test suite for unified reload behavior and route semantics. |
| tests/test_codex_protocol.py | Updates Codex client deps wiring in tests to use callable config. |
| tests/test_codex_appserver.py | Updates BackendDeps usage in tests to use callable config. |
| tests/test_cache_policy.py | Updates backend construction in tests to pass config via callable. |
| nerve/sync_service.py | Refactors sync application to call unified reload and log/report partial failures correctly. |
| nerve/gateway/server.py | Ensures periodic background loops re-read live config per cycle; wires cron notification service earlier. |
| nerve/gateway/routes/config.py | Adds POST /api/config/reload and enhances /api/config/sync response to include reload summary/errors. |
| nerve/external_agents/sync_service.py | Adds update_config() and ensures sweep interval is read each loop cycle. |
| nerve/cron/service.py | Reports reserved job rejections, adds reload_sources(), and wires source runner notifications on rebuild. |
| nerve/config_reload.py | New unified reload implementation and summary parsing (reload_failures). |
| nerve/agent/engine.py | Makes engine config re-pointable via setter; seeds/re-seeds session manager defaults on reload. |
| nerve/agent/backends/codex/backend.py | Switches to resolving config per read via deps callable (but currently breaks non-callable deps in some callers). |
| nerve/agent/backends/claude.py | Switches to resolving config per read via deps callable. |
| nerve/agent/backends/init.py | Updates BackendDeps to make config a callable and documents why. |
| docs/cron.md | Documents rejected job IDs in cron reload responses. |
| docs/config.md | Adds Hot-Reload documentation and clarifies reload triggers, scope, and restart-only settings. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
e3d20b9 to
dabe2d1
Compare
dabe2d1 to
631a9e9
Compare
631a9e9 to
e6d42c8
Compare
e6d42c8 to
f5bd49a
Compare
f5bd49a to
eec1ffc
Compare
eec1ffc to
87af896
Compare
87af896 to
0b521ba
Compare
705ebc9 to
bc92c18
Compare
bc92c18 to
2d76e6a
Compare
0ca29fd to
e4ed320
Compare
077ee42 to
348b35b
Compare
89cec21 to
f15ffa5
Compare
f15ffa5 to
ed551d2
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 27 out of 27 changed files in this pull request and generated no new comments.
Comments suppressed due to low confidence (2)
nerve/cli.py:1177
- _gateway_url builds URLs by simple string interpolation, which breaks when gateway.host is an IPv6 literal (e.g. "::1"), because RFC 3986 requires IPv6 hosts to be wrapped in brackets. As-is, "http://::1:8900/x" is not a valid URL and httpx will fail to connect. Handle IPv6 literals by bracketing the host (while still rewriting wildcard binds to 127.0.0.1).
scheme = "https" if config.gateway.ssl.enabled else "http"
host = config.gateway.host
if host in _WILDCARD_BINDS:
host = "127.0.0.1"
return f"{scheme}://{host}:{config.gateway.port}{path}"
nerve/sync_service.py:585
- _apply_sync logs an INFO/WARNING about the reload summary, but run_periodic_sync (its main in-process caller) already logs both the apply attempt and detailed WARNINGs for partial application. This results in duplicate log lines for every changed sync, and double-warning spam on partial reloads. Consider making _apply_sync return the summary without logging, and let the caller decide what (and how loudly) to report.
from nerve.config_reload import reload_all, reload_failures
summary = await reload_all(engine, cron_service, config_dir)
if reload_failures(summary):
logger.warning("Applied synced config, with failures: %s", summary)
else:
logger.info("Applied synced config: %s", summary)
return summary
81323a8 to
3094c20
Compare
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 32 out of 32 changed files in this pull request and generated no new comments.
Suppressed comments (2)
nerve/cron/service.py:705
reload_sources()builds itsremovedlist fromself._source_runners, which can include runners that were never scheduled (e.g., when a runner exists but its config section is absent). In that case the response can incorrectly claim a source job was removed even though it was never on the scheduler. Computeold_idsfrom actually-scheduled jobs (or filter byscheduler.get_job(...) is not None) soremovedreflects real scheduler changes.
old_ids = {r.job_id for r in self._source_runners}
for jid in old_ids:
if self.scheduler.get_job(jid) is not None:
self.scheduler.remove_job(jid)
self._source_runners = new_runners
nerve/gateway/routes/config.py:49
- The docstring says
restart_requiredis “a separate field rather than a line in detail”, but the route returns it in both places:detailis the rawreload_all()summary (which may include arestart_requiredkey) and there’s also a top-levelrestart_required. Either drop it fromdetailor adjust the docstring/response contract; keeping it in both is redundant and makes the response shape harder for clients to reason about.
"ok": not failures,
"detail": summary,
"errors": failures,
"restart_required": summary.get("restart_required", ""),
}
3094c20 to
64af012
Compare
64af012 to
5f876d2
Compare
One reload path covers the config object, the services holding their own reference to it, cron jobs, cron sources, MCP servers and skills, behind a single route. Sync applies what it merged through that same path rather than a parallel one, so there is a single definition of what "applied" means. A reload reports what actually happened: ok is derived from what the subsystems returned, and errors names the ones that failed, instead of reporting a success it did not earn. It stays best-effort by design — an invalid settings.yaml must not stop a valid cron edit from being applied — but it says so rather than implying everything landed. Sources are no longer silently dropped, closures no longer capture a stale config object, the engine/backend seam is closed, and the hot-versus-restart-only tables in the docs are completed and corrected against what the code does. Co-Authored-By: Claude <noreply@anthropic.com>
`nerve codex doctor` raised `TypeError: 'NerveConfig' object is not callable` and did nothing else. Same in scripts/codex_smoke.py. Making `config` a callable on BackendDeps changed what every construction site has to hand over. The engine was updated; these two were not. The doctor is the only shipped caller that assembles deps itself, and no test covered it, so the break shipped as a broken command rather than a failure. The other repair Copilot offered — have the backend accept either shape — is the one that costs something. A backend that tolerates a captured config accepts the stale-config bug this commit exists to remove, and accepts it silently, since the tolerant call site reads exactly like the correct one. The test builds the real backend and stubs only preflight, the step that needs the codex binary. Asserting CODEX_HOME appears under tmp_path is what pins config resolution: the directory is created during construction from the config the CLI loaded, so its absence would mean the deps callable returned something else. Co-Authored-By: Claude <noreply@anthropic.com>
The rule is already stated twice above it — the section opens with a config file changing on disk not being a reload, and the trigger list with a reload always being explicit. What the paragraph added was the case for that rule, at length, in a section documenting what reloads. Co-Authored-By: Claude <noreply@anthropic.com>
`nerve reload` refused to run without `auth.jwt_secret`, but that is not what the endpoint requires. `require_auth` runs open when the secret is empty and the instance is unlocked — dev mode — so the command was declining to make a call the gateway would have accepted, and telling the operator to POST it by hand. The whole reason this command exists is that an operator applying a hand edit should not have to do that, and a box with no auth configured is where hand edits are the normal way to change anything. Now the token is sent when there is a secret to sign one with and omitted when there is not, which is what `_gateway_request` already does for start/kill — operations with more reach than a reload. Lockdown is the one case where an empty secret is a dead end, and it stays a refusal: a locked gateway never takes the open path, so nothing that shell sends can be authenticated. The message says that, rather than the old advice to run `nerve init` and POST it manually, neither of which helps there. It also covers the case where the daemon does have a secret from an environment variable this shell is missing, since from here the two are indistinguishable and the fix is the same. `auth.password_hash` is not a second source of a token. It gates the browser login, which is what mints one from it; `require_auth` reads `auth.jwt_secret` alone. So a password does not make this endpoint ask for a token, and there is a test saying so, because "auth is configured" and "the endpoint is authenticated" are not the same statement here. The 401 message now covers both directions of a secret mismatch, since with the token omitted a rejection means the daemon has one this config does not supply. Co-Authored-By: Claude <noreply@anthropic.com>
It was after the restart table, two screens below the list of what reloads, which made "the first table" a back-reference the reader has to resolve. Directly under that table it is "all of that", and the section reads as what reloads followed by what the call reports. Co-Authored-By: Claude <noreply@anthropic.com>
`sync_workspace` returned early when the fetched revision already equalled
HEAD, and callers reloaded only when a pull had merged something. HEAD is
read fresh every cycle and the daemon kept no revision of its own, so
"checked out" and "running" were the same question. They come apart in two
supported ways, both reproduced:
CASE 1: CLI-side fast-forward, then the daemon's periodic loop
CLI sync -> ok=True changed=True 'updated 55d784e8→cfc5f266'
after 3 daemon cycles: reload_all calls = 0
CASE 2: partial reload failure, then later cycles
cycle with a changed pull: reload_all calls = 1
after 3 more cycles: reload_all calls = 1 (failed 'cron' never retried)
Neither is transient. A bare `git pull` in the workspace does case 1 as
readily as the CLI, and case 2 leaves one warning and then silence. Both
docstrings pointed the operator at recoveries that do nothing — the sync
route answers "up to date" and reloads nothing; only `nerve reload` or a
restart worked, and neither said so.
The loop now keeps `applied_rev`, applies when the revision on disk is not
the one it applied, and advances it only when every subsystem took the
config. Loop-local on purpose: a restart reads config from disk anyway, so
the state it would carry across one is the state a restart makes moot.
The same absence of state made a blocked sync invisible. Widening the
dirty-state check to the whole reviewed surface made that matter, because
an agent writing a skill directly is now enough to stop the merge — and the
only evidence was a WARNING repeating every cycle. On a locked box that
silently defeats the deployment.
Each cycle now publishes a SyncState, and entering the blocked state sends
one notification naming the paths and what clears them. On the transition,
not the state: at the default cadence the latter is 1,440 identical
messages a day. Clearing it sends one more, so silence after a fix does not
read as "still broken". `nerve doctor` answers from the shell without the
daemon's state, and `GET /api/config/sync` serves the retained record.
`applied` keeps its meaning for the scripts docs/config.md pointed at; a
new `status` separates "nothing to do" from "on disk, unapplied".
Co-Authored-By: Claude <noreply@anthropic.com>
`docs/config.md` says "The check covers the unconditional entries below"
and `config_reload.py` claims to mirror that table. Six unconditional
rows were uncovered, so a reload reported green while the subsystem went
on running the old value.
`langfuse.enabled` could never have fired: `LangfuseConfig` has no such
attribute, so `_dotted_attr` returned `_UNSET` and the entry was silently
skipped. Deleted, and the three langfuse fields that matter are listed
instead. `auth.jwt_secret` is added — the MCP mount captures it in a
closure while the web path reads it per request, so rotating it left the
two disagreeing about which token is valid.
Two holders are made live rather than listed, which makes the value
correct instead of documenting that it is wrong, and shrinks `_repoint`
the way its own docstring asks:
telegram.dm_policy = 'pairing' (NEW) -> _is_authorized(99999): True (was)
-> _is_authorized(99999): False (now)
`dm_policy` was already read per message, but off a config the channel
captured at construction, so a tightening reported success and admitted
strangers until a restart. `workflows.review_loop.*` was in neither doc
table and warned by nothing, while a lifespan singleton held the
sub-object and read budget figures off it at some twenty sites.
The guard is the point. Every entry must resolve against a default
`NerveConfig` and must have a row in the docs table, in both directions.
It caught `langfuse.enabled` immediately, and it rejected `proxy.host` on
its own: the doc row promises `proxy.*`, and `binary_path`, `auth_dir`,
`api_key` and `log_file` are all baked into the spawned subprocess too.
It also surfaced a leak that predates this change. `restart_required`
interpolated both values into a string that is logged *and* returned by
`POST /api/config/reload`, and `telegram.bot_token` was already on the
list — so rotating it published the old and the new token. Secrets and
whole-section entries now report `changed`.
`nerve reload` builds its URL from the config it just read, so editing
`gateway.port` sends it to a port nothing is listening on. It said "No
daemon answering — a stopped daemon reads config fresh when it starts",
which is the wrong advice when the daemon is running fine on the old one.
Co-Authored-By: Claude <noreply@anthropic.com>
`reload_sources` built its candidates, removed every old source job, and
only then parsed the new triggers — inside a loop that logs and continues
on a bad one. So an invalid edit destroyed a working schedule and put
nothing back:
LOG INFO Removed job source:gmail
LOG ERROR Source 'gmail' will not be scheduled: Invalid crontab '99 * * * *'
LOG INFO Scheduled source: github (hourly)
=== AFTER === source:gmail -> NOT SCHEDULED source:github -> interval[2:00:00]
return: {'sources': ['source:github', 'source:gmail'], 'removed': []}
failures: {} -> route ok = True
gmail was reported under `sources` while absent from the scheduler and
from `removed`, github moved from thirty minutes to two hours because
`_parse_interval` falls back to 7200 for anything it cannot read, and
`POST /api/config/reload` answered `ok: true`.
The docstring claimed build-before-unschedule protected this. It covers
`build_source_runners` raising and nothing else — trigger construction
was entirely after the removal, with no rollback.
Now planned first: every trigger is constructed before any job is
removed, and an unreadable one raises instead of being skipped, using
`_interval_seconds` rather than the 7200 fallback. The response is built
from the pairs actually scheduled. Startup keeps the lenient path, where
skipping is right — one typo must not stop the daemon booting.
The plan/apply split is the shape `_reload_from_disk` in this file
already uses for cron jobs. No new plumbing: `reload_all` turns the raise
into a subsystem error, which `reload_failures` picks up and the route
renders as `ok: false`.
`nerve config validate` would reject both of these inputs, but nothing on
the reload path calls it, so a local edit plus `nerve reload` was the way
in. The docs row promising sources are "rebuilt and rescheduled" said
nothing about what happens when they cannot be.
Co-Authored-By: Claude <noreply@anthropic.com>
5f876d2 to
58b17f8
Compare
What
One reload path —
reload_allandPOST /api/config/reload— covering the process config object, cron jobs, cron sources, MCP servers, skills and gates, with a per-subsystem summary so a partial failure is reported rather than averaged into a success.Long-lived services that captured the config object at construction are re-pointed explicitly. Config that a reload cannot apply is compared against the previous values and reported as
restart_required.Plus
nerve reload, a CLI front end for that endpoint: it calls the local gateway, authenticating withauth.jwt_secretfrom the config it just read, prints the per-subsystem result, and exits non-zero on a partial reload so it can gate a deploy step rather than only informing a human.Why
Before this there were several partial reload paths and no way for a caller to tell which subsystems had taken a change. A reload that reports success while half the process runs the old config is worse than one that fails, because nothing indicates which half is which.
Re-pointing the services is the same problem one level down.
set_configreplaces the module-level object, but a service holding its own reference keeps the old one, so the reload reads as complete while those services stay stale indefinitely.restart_requiredcloses the last gap. Some settings genuinely cannot be applied in place — the gateway socket is already bound, the Telegram bot is already polling — and previously the summary listed only what was applied, so a change to one of those was indistinguishable from no change at all. That became load-bearing oncegateway.host/portmoved into the tracked layer, since the change can now arrive by workspace sync rather than a local edit. It is a separate field fromerrorsand does not affectok: nothing failed, and the reload did everything it can.nerve reloadexists because an operator editing a file on the box should not have to mint a JWT and curl an endpoint to apply it. Now that no config file applies itself when it changes on disk — the cron-directory watcher was dropped in #209 — asking for a reload is the documented action, so it needs to be one word. It reports the same thing the endpoint does, including the restart-required warning, and refuses clearly when there is no daemon to talk to (a stopped daemon reads config fresh at start-up, so there is nothing to reload).reload_allno longer takes areload_cronparameter. It existed so a caller who knew the file watcher would reload cron could skip it; with the watcher gone, a caller has no way to ask for cron jobs and cron sources separately, so a jobs edit and a sources edit can never be applied apart.The docs table records what follows a reload, what waits for the next cron reload, and what still needs a restart.
Added in review
nerve codex doctorwas broken by this PR. MakingBackendDeps.configa callable changed what every construction site has to hand over. The engine was updated; the two callers that assemble deps by hand were not, so the doctor raisedTypeError: 'NerveConfig' object is not callablebefore doing anything at all, andscripts/codex_smoke.pythe same. Fixed by passing a callable rather than by having the backend accept either shape: a backend that tolerates a captured config accepts the stale-config bug this PR exists to remove, and accepts it silently, since the tolerant call site reads exactly like the correct one. The doctor now has a test, which it did not before — that is why this shipped as a broken command instead of a failure.nerve reloadno longer requiresauth.jwt_secret.require_authruns open when the secret is empty and the instance is unlocked, so the command was declining to make a call the gateway would have accepted, and telling the operator to POST it by hand — the thing it exists to avoid, on the boxes where hand edits are the normal way to change anything. The token is now sent when there is a secret to sign one with and omitted when there is not, which is what_gateway_requestalready does for start/kill. Lockdown with no secret stays a refusal, because a locked gateway never takes the open path, and the message says so instead of advisingnerve init.auth.password_hashis not a second source of a token — it gates the browser login, andrequire_authreadsauth.jwt_secretalone — with a test saying so, because "auth is configured" and "the endpoint is authenticated" are not the same statement here.Two docs edits in the same section: the response-shape paragraph moved under the table it describes, and the argument for reloads being explicit is gone, since the rule is stated twice above where it sat.
Three more, from a review of the stack's end state.
Git
HEADwas being used as application state.sync_workspacereturnedearly when the fetched revision equalled
HEAD, and callers reloaded only when apull had merged something — so "checked out" and "running" were the same
question. They come apart two ways, both reproduced: a fast-forward from
nerve config syncor a baregit pullis never applied by the running daemon (threecycles, zero
reload_allcalls), and a partial reload failure is never retried.Neither is transient. Both docstrings pointed the operator at recoveries that do
nothing — the sync route answers "up to date" and reloads nothing; only
nerve reloador a restart worked, and neither said so. The loop now tracks what itapplied and advances that only when every subsystem took the config.
A blocked sync was invisible. Widening the dirty-state check made this matter,
because an agent writing a skill directly is now enough to stop the merge, and the
only evidence was a WARNING repeating every cycle. On a locked box that silently
defeats the deployment. Each cycle now publishes state, entering the blocked state
sends one notification naming the paths and what clears them,
nerve doctoranswers from the shell, and
GET /api/config/syncserves the record.The restart-only report did not cover what it claimed. The docs say "the check
covers the unconditional entries below" and the code claims to mirror that table;
six unconditional rows were uncovered.
langfuse.enabledcould never have fired —no such attribute, so the entry was silently skipped.
telegram.dm_policyandworkflows.review_loop.*are made live rather than listed, which makes thevalue correct instead of documenting that it is wrong: a
dm_policytighteningpreviously reported success and went on admitting strangers until a restart. The
new guard requires every entry to resolve against a default
NerveConfigand tohave a row in the docs table, in both directions; it rejected
proxy.hoston itsown, since four more
proxy.*fields are baked into the spawned subprocess. Italso caught that
restart_requiredinterpolated both values into a string that islogged and returned by the endpoint, with
telegram.bot_tokenalready listed —so rotating it published the old and new token. Secrets now report
changed.Source reload was not transactional. It removed every old source job before
parsing a single new trigger, in a loop that logs and continues on a bad one. An
invalid edit destroyed a working
*/15schedule, moved another source from thirtyminutes to two hours via
_parse_interval's 7200 fallback, reported the missingrunner under
sources, and returnedok: true. Now planned first: every triggeris constructed before any job is removed, and an unreadable one raises. Startup
keeps the lenient path, where skipping is right.
🤖 Generated with Claude Code