Skip to content

Config edits apply without a daemon restart - #1203

Merged
edwin-zvs merged 1 commit into
mainfrom
worktree-config-hot-reload
Aug 4, 2026
Merged

Config edits apply without a daemon restart#1203
edwin-zvs merged 1 commit into
mainfrom
worktree-config-hot-reload

Conversation

@edwin-zvs

Copy link
Copy Markdown
Contributor

Editing config.toml did 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 /configure dialog told the operator to restart in ten separate places.

Config now applies when saved, on the same terms. Adds spec 0190.

What applies when

Change Applies
Adding/removing a harness, its description, template dir, suggestion generation immediately
Model profile, published/featured models, oauth models, usage probe on the next request
Harness binary/args/env, [daemon.env], default worktree, orchestrator harness on new sessions
[router] port; switching off a router that is already listening on restart

Classes are published as data (ConfigField::propagation, an exhaustive match beside the existing ServiceField), 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's if !self.enabled || self.listening.swap(true, ..): || short-circuits, so a router that booted disabled never armed its latch. enabling_a_stopped_router_arms_the_listener pins it — splitting that guard into two ifs 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<..>>, mirroring ServiceIngressShared. Accessors return an owned Arc, not a guard: a std guard held across an await makes the enclosing future !Send. Constructor signatures are unchanged, so none of the 63 SessionManager::new / 112 Storage::new call sites move.

Reload re-derives rather than patches. apply_to_config is 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_reload pins 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:

  • harness hot-added by hand edit → appears and a real session spawns on it; hot-removed → gone; the already-running session keeps working (nothing re-applied to a running conversation)
  • router off→on with no restart — bound a port, wrote router.port, kept serving
  • disabling a listening router → restart_required, still serving
  • port change → restart_required, bound port unmoved
  • malformed config → refused, error reported, daemon still answering, residue kept sticky
  • torn write → 3 refusals, then clean apply on completion
  • GEMINI_API_KEY added to [daemon.env] → the gemini route target appears without a restart; removed → gone (proves daemon_env::install must run before route_profiles(), which probes the environment)

🤖 Generated with Claude Code

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.
@edwin-zvs
edwin-zvs merged commit c9ef622 into main Aug 4, 2026
1 check passed
@edwin-zvs
edwin-zvs deleted the worktree-config-hot-reload branch August 4, 2026 13:10
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.

1 participant