feat(service): apply definitions without a daemon restart - #1119
Merged
Conversation
Definitions were read once at boot, so every edit — over IPC or by hand — did nothing until the daemon restarted. Some of that was worse than a missing feature: paused was consulted only at startup, so pausing a running service left it serving, and rotating a credential left the old one working. Both UIs asserted the restriction in prose rather than reading restart_required, which nothing consumed. A supervisor task now owns every listener. Definitions are read where they are used, so routing, paused, and the approval timeout apply to the next request and a rotated credential applies without touching the socket; a port change rebinds, and pausing or detaching releases the port. Edits made in the config directory are noticed too, since that is a documented hand-editable surface. Reload is all or nothing: a definition that fails to parse leaves the running configuration untouched, which is also what makes watching the directory safe against a file caught mid-write. Stops complete before starts so two channels can exchange ports, and cancelling a listener stops it accepting without interrupting a request that already has an agent turn behind it. Instruction, harness, model, cwd, and sandbox stay next-session. A harness assembles its system prompt at startup and skips its seed prompt on resume, so there is no honest way to re-instruct a live conversation; the propagation classes say so rather than implying otherwise. They are published as shared data so the promise beside a field is the one the daemon keeps, and restart_required is replaced by a report of the socket work actually performed.
The reload loop and the per-request config reads were only verified by hand against a running daemon, so nothing would catch a regression. Adds eight tests over the real reload path — files on disk, a real session manager, real sockets — covering shared-state identity across a reload, all-or-nothing parsing, services appearing and disappearing, binding/moving/releasing a channel, and one service handing a port to another in a single reload. Three more cover the mechanism the listener never sees: a rotated credential, pausing or disabling a channel, and an edit preserving routed sessions and delivery history. Each was checked against a deliberately broken build so it fails for the reason it claims. That found two problems here: The stop-before-start test was vacuous. Reversing the plan's order did not fail it, because a port swap produces two rebinds and no start to order against. The ordering is in fact enforced by the executor's two passes rather than by the plan, so the comment claiming otherwise is corrected and the test now uses a handover, where a stop and a start genuinely coexist. The port-claiming tests raced each other for the same ephemeral port — free_port reports one that was free a moment ago — and failed roughly one run in six. They now serialize.
Contributor
Author
Added regression coverageThe reload loop and the per-request config reads were only verified by hand against a running daemon, so nothing would have caught a regression. Service tests go from 6 to 14. Over the real reload path (files on disk, real
The mechanism the listener never sees:
Each was checked against a deliberately broken build, so it fails for the reason it claims: rebuilding shared state on reload, skipping the That exercise found two problems in this PR:
|
This was referenced Aug 1, 2026
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.
The problem
Service definitions were loaded once at boot, so every edit returned
restart_required: trueand did nothing until the daemon restarted. Parts of that were worse than a missing feature:pausedwas a lie. Consulted only at startup, so pausing a running service left its listener bound and serving.restart_required, which nothing consumed — ~14 hardcoded strings.What applies when
Published as shared data (
PropagationClass/ServiceField) that the daemon and its clients both read, so the promise beside a field is the promise the daemon keeps:Instruction and model stay next-session deliberately. A harness assembles its system prompt at startup, and resuming one skips its seed prompt (
agent.rs,if !persist::is_resume()) so the model isn't charged twice — so respawning re-delivers nothing and there is no honest way to re-instruct a live conversation. The classes say that rather than implying otherwise.Design
A supervisor task (modeled on
remote_supervisor) owns every listener; the registry lives in its stack frame, so reloads serialize by construction. Config is read at the point of use behindRwLock<Arc<ServiceConfig>>, so only a port change touches a socket.ServiceSharedis never rebuilt for a name that already has one — two would race on the same state file and reset the dedup ring, stranding live conversations and admitting duplicate deliveries.restart_required→ServiceApplyResultreporting the socket work actually performed.Also fixed in passing:
persist_statewrote without tmp+rename, so a crash mid-write lost every routed session mapping.Verification
Live daemon, every change made by hand-editing TOML, no restart:
Caught during verification: returning the apply report from
service.deletewould have brokenClient::delete_service, which deserializes into()— deletes keep a null response and still reload.Tests: 6 new in the supervisor (plan ordering, paused/disabled release, deterministic duplicate-port loser, port-release-after-await, propagation coverage).
construct-daemon --lib495,construct-cli --bins1314,construct-protocol1, workspace builds clean. The test asserting the old restart note failed loudly as intended and was updated.Specs: adds
0173-service-definitions-apply-without-restart; amends0163's "V1 does not promise runtime definition reload".Touches
crates/daemon,crates/protocol,crates/cli, web assets → all in theconstructbinary.