Config edits apply without a daemon restart - #1203
Merged
Merged
Conversation
Editing config.toml did nothing until the daemon was restarted, and nothing said so. That is an asymmetry inside one directory: the service definitions beside it already apply on save (spec 0173), and the /configure dialog told the operator to restart in ten separate places. Config now applies when saved, on the same terms. A watcher notices a hand edit within a couple of seconds; reload re-derives the running configuration from the config file, the plugin registry, and the plugin manifests, and swaps it in. Re-deriving rather than patching is load-bearing: apply_to_config is additive, so a patched config could never drop a harness contributed by a since-disabled plugin. Router transport is the one exception, and says so out loud. A harness is told the router's port once, in its environment at spawn, and cannot be told it moved (spec 0183) — so a port change, and switching off a router that is already listening, are recorded and reported rather than performed. Switching a stopped router on is applied, since nothing depends on its absence. Everything the router reads per request — profiles, oauth models, published and featured models — is hot. Mechanically: the four things startup derives (the config itself, the plugin runtime, storage's config-derived directories, and the router's settings) each move behind an RwLock<Arc<..>>, mirroring ServiceIngressShared. Accessors return an owned Arc rather than a guard, because a std RwLock guard held across an await makes the enclosing future !Send. Constructor signatures are unchanged, so none of the 63 SessionManager::new or 112 Storage::new call sites move. The TUI grows one segment, shown only when something is waiting on a restart, naming the field and opening the existing restart confirm. A change that applied in full says so transiently and leaves nothing behind. Spec 0190.
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.
Editing
config.tomldid nothing until the daemon was restarted, and nothing said so. That's an asymmetry inside one directory — the service definitions beside it already apply on save (spec 0173) — and the/configuredialog told the operator to restart in ten separate places.Config now applies when saved, on the same terms. Adds spec 0190.
What applies when
[daemon.env], default worktree, orchestrator harness[router] port; switching off a router that is already listeningClasses are published as data (
ConfigField::propagation, an exhaustive match beside the existingServiceField), so adding a config field doesn't compile until it's been given one.Router transport is the exception, and says so
A harness is told the router's port once, in its environment at spawn, and cannot be told it moved (spec 0183). So a port change — and disabling a router that is currently serving — are recorded and reported, never performed. Switching a stopped router on is applied, because nothing depends on its absence.
That last part rests on
Router::start'sif !self.enabled || self.listening.swap(true, ..):||short-circuits, so a router that booted disabled never armed its latch.enabling_a_stopped_router_arms_the_listenerpins it — splitting that guard into twoifs would silently break hot-enable and nothing else would fail.Mechanics
The four things startup derives — the config, the plugin runtime, storage's config-derived directories, and the router's settings — each move behind
RwLock<Arc<..>>, mirroringServiceIngressShared. Accessors return an ownedArc, not a guard: astdguard held across an await makes the enclosing future!Send. Constructor signatures are unchanged, so none of the 63SessionManager::new/ 112Storage::newcall sites move.Reload re-derives rather than patches.
apply_to_configis additive, so a patched config could never drop a harness contributed by a since-disabled plugin —a_harness_from_a_disabled_plugin_is_dropped_on_reloadpins that.Reload is all-or-nothing, which is what makes polling the file safe: a torn write fails to parse, changes nothing, and the next tick picks up the finished file.
TUI
One new segment, shown only when something is waiting on a restart, naming the field and opening the existing restart confirm. A change that applied in full says so transiently and leaves nothing behind. The residue is replayed on subscribe, so it survives a reconnect.
Verification
2640 tests pass, 0 failures. Beyond unit tests, driven end-to-end against an isolated daemon:
router.port, kept servingrestart_required, still servingrestart_required, bound port unmovedGEMINI_API_KEYadded to[daemon.env]→ thegeminiroute target appears without a restart; removed → gone (provesdaemon_env::installmust run beforeroute_profiles(), which probes the environment)🤖 Generated with Claude Code