1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
// SPDX-License-Identifier: AGPL-3.0-only
//! **Hot reload** of the configuration: SIGHUP or `lifecycle.watch_config`
//! re-merges the files and re-validates. The reload is all-or-nothing — if any
//! restart-only path changed the whole reload is refused as
//! `restart_required` and the running configuration stays, so the daemon never
//! ends up half on one configuration and half on another.
//!
//! The reloadable partition applies at the loop's quiesce boundary. The flat
//! child tree makes most of it trivial: every turn worker is spawned fresh
//! from the live settings, so a new intelligence endpoint, model, instruction,
//! budget, tool override or workflow definition takes effect for the next unit
//! of work without touching the units already in flight. Live workflow runs
//! keep the definition they started with, pinned by hash, so a run cannot
//! change shape halfway through.
use super::reactor::Runtime;
use crate::config::v2 as cfg;
use crate::governor::Governor;
use crate::registry::{Registry, ServerTools};
use crate::state::now_ms;
use serde_json::json;
use std::sync::Arc;
use std::time::Duration;
impl Runtime {
/// SIGHUP / watcher: reload, diff, apply (or refuse).
pub(crate) fn on_reload_requested(&mut self) {
let trigger = if crate::signals::take_reload_was_watch() {
"watch"
} else {
"sighup"
};
crate::signals::set_reloading(true);
let outcome = self.reload_inner();
crate::signals::set_reloading(false);
// Audit the reload: reconfiguring a running daemon is an operator
// action, and a refused reload is as worth recording as an applied one.
let (label, atarget) = match &outcome {
Ok(changed) => ("applied", json!({"trigger": trigger, "changed": changed})),
Err(ReloadRefused::Invalid(errs)) => {
("invalid", json!({"trigger": trigger, "errors": errs}))
}
Err(ReloadRefused::RestartRequired(paths)) => (
"restart_required",
json!({"trigger": trigger, "paths": paths}),
),
};
self.audit(crate::runtime::audit::AuditEvent {
action: "config.reload",
target: atarget,
outcome: label,
principal: Some("operator"),
role: Some("operator"),
request_id: None,
});
match outcome {
Ok(changed) => {
self.log.info(
"config.reloaded",
json!({"trigger": trigger, "changed": changed}),
);
crate::obs::metrics::record_config_reload("applied");
let mut generation = 0;
self.durable.manifest_update(|m| {
generation = m.lifecycle["config_generation"].as_u64().unwrap_or(0) + 1;
m.lifecycle["config_generation"] = json!(generation);
m.lifecycle["config_reloaded_at"] = json!(now_ms());
});
crate::obs::metrics::set_config_generation(generation);
}
Err(ReloadRefused::Invalid(errs)) => {
for e in &errs {
self.log.warn(
"config.reload.invalid",
json!({"trigger": trigger, "error": e}),
);
}
crate::obs::metrics::record_config_reload("invalid");
}
Err(ReloadRefused::RestartRequired(paths)) => {
self.log.warn(
"config.reload.restart_required",
json!({"trigger": trigger, "paths": paths}),
);
crate::obs::metrics::record_config_reload("restart_required");
}
}
}
fn reload_inner(&mut self) -> Result<Vec<&'static str>, ReloadRefused> {
let (loaded, _ask) = cfg::load(&self.args, &self.env)
.map_err(|e| ReloadRefused::Invalid(vec![format!("{e:?}")]))?;
let restart = cfg::restart_only_diff(&self.settings_doc, &loaded.doc);
if !restart.is_empty() {
return Err(ReloadRefused::RestartRequired(restart));
}
for w in &loaded.warnings {
self.log.warn("config.warning", json!({"warning": w}));
}
let new = loaded.settings;
let old = std::mem::replace(&mut self.settings, new.clone());
self.settings_doc = loaded.doc;
let mut changed = Vec::new();
// Intelligence is hot-swappable: workers in flight keep dialing the
// endpoint they were spawned with, the next spawned worker uses this.
if old.intelligence.endpoints != new.intelligence.endpoints
|| old.intelligence.model != new.intelligence.model
|| old.intelligence.token != new.intelligence.token
|| old.intelligence.token_file != new.intelligence.token_file
{
self.intel_uri = new.intelligence.endpoint_list().unwrap_or_default();
self.model = new.intelligence.model.clone().unwrap_or_default();
let env = self.env.clone();
let envmap = move |k: &str| env.iter().find(|(n, _)| n == k).map(|(_, v)| v.clone());
match super::resolve_intel_token(&new, &envmap) {
Ok(t) => self.intel_token = t,
Err(e) => self.log.warn("config.reload.token", json!({"err": e})),
}
changed.push("intelligence");
}
// Budgets: new windows, counters carried over.
if old.intelligence.budget != new.intelligence.budget {
let counters = self.governor.to_value();
let mut g = Governor::new(&new.intelligence.budget);
g.restore(&counters, now_ms());
self.governor = g;
changed.push("intelligence.budget");
}
// Instruction (static text; a resource instruction re-subscribes).
if old.agent.instruction != new.agent.instruction {
match new.agent.instruction.clone() {
Some(t) if cfg::looks_like_resource_uri(&t) => {
if let Err(e) = self.subscribe_instruction(&t) {
self.log
.warn("instruction.subscribe.fail", json!({"uri": t, "err": e}));
}
}
Some(t) => {
self.instruction = super::reactor::Instruction {
text: t,
source: "static",
uri: None,
server: None,
version: self.instruction.version + 1,
};
}
None => {
self.instruction = super::reactor::Instruction {
text: String::new(),
source: "static",
uri: None,
server: None,
version: self.instruction.version + 1,
};
}
}
if new
.agent
.wake_on()
.contains(&cfg::WakeEvent::InstructionUpdated)
{
self.note_root("instruction.updated: the configuration changed the instruction; re-read it with instruction.read".into());
}
changed.push("agent.instruction");
}
if old.agent.preflight != new.agent.preflight
|| old.agent.wake_on != new.agent.wake_on
|| old.agent.tools != new.agent.tools
|| old.agent.max_parallel_turns != new.agent.max_parallel_turns
|| old.agent.on_workflow_finished != new.agent.on_workflow_finished
|| old.agent.conversation_budget != new.agent.conversation_budget
{
changed.push("agent");
}
// MCP servers: connect added, drop removed (re-handshake).
if old.mcp != new.mcp {
let keep: Vec<String> = new.mcp.servers.iter().map(|s| s.name.clone()).collect();
let removed: Vec<String> = self
.mcp
.keys()
.filter(|k| !keep.contains(k))
.cloned()
.collect();
for r in &removed {
self.mcp.remove(r);
self.mcp_specs.remove(r);
self.skills.forget_server(r);
self.log
.info("mcp.disconnect", json!({"server": r, "reason": "reload"}));
}
let timeout = new
.mcp
.default_timeout
.map(|d| d.0)
.unwrap_or(Duration::from_secs(60));
for s in &new.mcp.servers {
let spec = match s.to_spec() {
Ok(sp) => sp,
Err(e) => {
self.log
.warn("mcp.spec.invalid", json!({"server": s.name, "err": e}));
continue;
}
};
let same = self.mcp_specs.get(&s.name).is_some_and(|old| {
old.endpoint == spec.endpoint
&& old.headers == spec.headers
&& old.aauth == spec.aauth
});
if same && self.mcp.contains_key(&s.name) {
self.mcp_specs.insert(s.name.clone(), spec);
continue;
}
match crate::mcp::from_spec(&spec, s.timeout.map(|d| d.0).unwrap_or(timeout))
.and_then(|mut c| c.initialize().map(|()| c))
{
Ok(mut c) => {
c.set_tool_meta(
json!({"agent/run_id": self.run_id, "agent/instance": self.instance}),
);
self.log
.info("mcp.connect", json!({"server": s.name, "reason": "reload"}));
self.mcp.insert(s.name.clone(), Arc::new(c));
}
Err(e) => self.log.warn(
"mcp.connect.fail",
json!({"server": s.name, "err": e.to_string()}),
),
}
self.mcp_specs.insert(s.name.clone(), spec);
}
changed.push("mcp");
}
// Registry (overrides/disabled/tools) — always rebuilt when tools/mcp/knowledge/search changed.
if old.tools != new.tools
|| old.mcp != new.mcp
|| old.knowledge != new.knowledge
|| old.search != new.search
{
let server_tools: Vec<ServerTools> = new
.mcp
.servers
.iter()
.filter_map(|s| {
let c = self.mcp.get(&s.name)?;
Some(ServerTools {
name: s.name.clone(),
ns: s.ns.clone(),
tags: self
.mcp_specs
.get(&s.name)
.map(|sp| sp.tags.clone())
.unwrap_or_default(),
tools: c.list_tools().unwrap_or_default(),
})
})
.collect();
match Registry::build(&new, &server_tools) {
Ok(r) => {
self.registry = r;
changed.push("tools");
}
Err(errs) => {
// The rebuild failed, so the daemon stays on the tool
// configuration it is already running: put back the tools
// settings to match the registry that is still installed,
// or the two would disagree about what is callable.
self.settings.tools = old.tools.clone();
return Err(ReloadRefused::Invalid(errs));
}
}
}
// Skills sources — the config section, or the instruction's inline
// `:::skill` definitions (they live on `agent`, but they land in this
// catalogue).
if old.skills != new.skills || old.agent.inline_skills != new.agent.inline_skills {
let mut cat = crate::context::skills::Catalogue::new(
new.skills
.reference_prefix
.as_deref()
.unwrap_or(crate::context::skills::DEFAULT_PREFIX),
new.skills.max_bytes.unwrap_or(32_768) as usize,
);
for src in &new.skills.sources {
if let Some(c) = self.mcp.get(&src.server) {
let mode = match src.discover {
cfg::Discover::Prompts => crate::context::skills::Discover::Prompts,
cfg::Discover::Resources => crate::context::skills::Discover::Resources,
cfg::Discover::Auto => crate::context::skills::Discover::Auto,
};
cat.discover(&**c, mode, src.filter.as_deref());
}
}
if let Some(dir) = &new.skills.dir {
cat.add_dir(std::path::Path::new(dir));
}
cat.add_inline(&new.agent.inline_skills);
self.skills = cat;
changed.push("skills");
}
// Workflows: reload definitions. Retirement (runtime::retire) gives
// every old version the same exit — unsubscribe what nothing else
// wants, pin for live runs, apply its own `unload:` policy — whether
// it was removed outright or replaced by a new hash.
if old.workflows != new.workflows {
let previous = std::mem::take(&mut self.workflows);
if let Err(errs) = self.load_workflows() {
self.workflows = previous; // the running set stays authoritative
return Err(ReloadRefused::Invalid(errs));
}
for (name, wf) in &previous {
let survives = self
.workflows
.get(name)
.is_some_and(|new_wf| new_wf.hash == wf.hash);
if survives {
continue;
}
let reason = if self.workflows.contains_key(name) {
"replaced"
} else {
"removed"
};
self.retire_workflow(wf, reason);
}
self.arm_workflows();
changed.push("workflows");
}
if old.limits != new.limits
|| old.lifecycle.idle_grace != new.lifecycle.idle_grace
|| old.observability.log_level != new.observability.log_level
|| old.observability.log_content != new.observability.log_content
|| old.memory != new.memory
|| old.context != new.context
{
changed.push("limits/lifecycle/observability/memory/context");
}
// Principals: rebuild the rules and swap them into the live listener.
//
// The rules compile into a `Resolver` (glob patterns, resolved bearer
// secrets), which is why this was restart-only until now: the listener
// held one built at startup. A rebuild that FAILS — an unresolvable
// `{{secret:…}}`, a malformed matcher — must not take the listener's
// working rules away, so the old resolver stays and the reload says so
// rather than falling open on an empty rule set.
#[cfg(feature = "a2a")]
if old.a2a.principals != new.a2a.principals
&& let Some(bridge) = &self.a2a_bridge
{
let env = self.env.clone();
let envmap = move |k: &str| env.iter().find(|(n, _)| n == k).map(|(_, v)| v.clone());
match crate::a2a::Resolver::build(&new.a2a, &envmap) {
Ok(r) => {
bridge.set_resolver(r);
changed.push("a2a.principals");
}
Err(e) => {
self.log.warn(
"config.reload.principals",
json!({"err": e, "kept": "the principal rules in force before this reload"}),
);
}
}
}
// Webhook routes: rebuild from the (already reloaded) workflows and the
// current `default_auth`, and install them.
//
// This runs unconditionally rather than under a `webhooks != webhooks`
// guard, because a route's identity is spread across TWO sections: its
// auth can come from `webhooks.default_auth` while the node itself
// lives in `workflows[]`. Gating on either alone reintroduces exactly
// the silent no-op this replaces. Rebuilding is cheap and carries live
// per-route state across.
#[cfg(feature = "a2a")]
if let Some(handler) = self.webhook_handler.clone() {
let nodes = self.webhook_nodes();
let env = self.env.clone();
let envmap = move |k: &str| env.iter().find(|(n, _)| n == k).map(|(_, v)| v.clone());
match handler.reload_routes(nodes, &self.settings.webhooks, &envmap) {
Ok(paths) => {
if old.webhooks != new.webhooks || old.workflows != new.workflows {
self.log.info(
"webhooks.routes",
json!({"routes": paths, "reason": "reload"}),
);
changed.push("webhooks");
}
}
// A bad route definition leaves the SERVING table in place —
// the listener keeps working under the rules it had.
Err(e) => self.log.warn(
"config.reload.webhooks",
json!({"err": e, "kept": "the routes in force before this reload"}),
),
}
}
// `interface.debug` also lives as an atomic on the feed (it is
// runtime-settable through `config.set`), so a reload has to move BOTH
// or the two disagree: the settings gate would pass while the feed
// still filtered debug frames out.
#[cfg(feature = "a2a")]
if old.interface.debug != new.interface.debug
&& let Some(feed) = &self.a2a_feed
{
feed.set_debug(new.interface.debug);
changed.push("interface.debug");
}
// The browser CORS allowlist: replaced in the live listener.
//
// `interface.origins` was neither rebuilt nor restart-only, so removing
// an origin to revoke a web client reported success and revoked
// nothing. The list has no external source to re-read, so unlike the
// principals and the routes it is simply swapped.
#[cfg(feature = "a2a")]
if old.interface.origins != new.interface.origins
&& let Some(origins) = &self.a2a_origins
{
*origins.write().unwrap_or_else(|e| e.into_inner()) = new.interface.origins.clone();
changed.push("interface.origins");
}
if changed.is_empty() {
changed.push("nothing");
}
Ok(changed)
}
}
/// Why a reload did not apply.
enum ReloadRefused {
Invalid(Vec<String>),
RestartRequired(Vec<String>),
}