agentd/subagent/control.rs
1// SPDX-License-Identifier: Apache-2.0
2//! The subagent side of the control channel. RFC 0005, RFC 0003, RFC 0009.
3//!
4//! Entered when `main` sees `AGENT_SUBAGENT` set. The child:
5//! 1. installs `PR_SET_PDEATHSIG` so a supervisor death collapses it (must be
6//! here — `pre_exec`'s setting is cleared by `execve`);
7//! 2. reads its [`SpawnPayload`] (first control frame) from stdin;
8//! 3. starts a **control reader thread** (separate from the agentic loop) that
9//! answers `Ping` with `Pong` and flips a cancel flag on `Cancel` — so
10//! liveness survives a long in-flight tool/model call (Detector C);
11//! 4. emits `Ready`, connects intelligence + its scoped MCP servers, runs
12//! `agentloop::run_loop`, and sends `Result`/`Failed` back up.
13//!
14//! Wire: stdout carries length-framed [`AgentMsg`] up; stderr carries the
15//! child's JSON telemetry (inherited to the parent). stdin carries
16//! [`ControlMsg`] down.
17
18use crate::agentloop::action::SelfHandler;
19use crate::agentloop::runner::{LoopAbort, LoopInput, Session, run_loop};
20use crate::agentloop::stop::{Outcome, TerminalStatus};
21use crate::config::SwapPolicy;
22use crate::intel::client::{IntelClient, IntelHealthReport};
23use crate::json::frame;
24use crate::mcp::client::McpClient;
25use crate::obs::log::{Comp, Level, LogCtx, Logger};
26use crate::subagent::protocol::{AgentMsg, ControlMsg, IntelActive, SpawnPayload, SwapIntel};
27use crate::supervisor::budget::Budget;
28use std::io::{self, BufReader, Stdin, Stdout};
29use std::sync::atomic::{AtomicBool, Ordering};
30use std::sync::mpsc::{Receiver, RecvTimeoutError, Sender};
31use std::sync::{Arc, Mutex};
32use std::time::{Duration, Instant};
33
34/// The child-local LIVE intelligence handle (RFC 0018 §5.2, the process-boundary
35/// adaptation). A supervisor-side `RwLock<Arc<IntelConfig>>` cannot reach a child
36/// re-exec'd as its own PROCESS, so each child holds its own LIVE config: the
37/// control-reader thread parks a [`SwapIntel`] here on `ControlMsg::SwapIntel`,
38/// and the agentic loop drains it ONCE per turn at the turn boundary (the same
39/// boundary `pause_wait` sits at), rebuilds its [`IntelClient`] from the new
40/// endpoints (fresh health/breaker — §5.2 step 2), and adopts the new model. The
41/// `Mutex<Option<…>>` is the whole seam; the loop never holds it across a turn.
42type PendingSwap = Arc<Mutex<Option<SwapIntel>>>;
43
44pub(crate) type Up = Arc<Mutex<Stdout>>;
45
46/// How long an MCP server's `elicitation/create` waits for a human before the
47/// server is told `cancel`. The gate itself may outlive this — the operator can
48/// still answer it in the TUI — but a server should not hold a request open
49/// indefinitely waiting for someone to walk back to their desk.
50const ELICITATION_TIMEOUT: Duration = Duration::from_secs(300);
51
52/// The in-child self-handler for an agentd 2.0 subagent (RFC 0026 §6). A subagent
53/// is a **flat child of the reactor** — it runs a ReAct loop over its granted
54/// MCP + code tools and reports its result; it has **no** in-child orchestration
55/// self-tools (no nested `subagent.spawn`, `schedule`, `subscribe`, `workflow.*`,
56/// `a2a.delegate`). Delegation is the reactor's job, not a child's. (`finish` is
57/// handled by the loop itself, not the self-handler, so completion is unaffected.)
58struct NoSelfTools;
59impl SelfHandler for NoSelfTools {
60 fn tools(&self) -> Vec<crate::wire::intel::ToolDef> {
61 Vec::new()
62 }
63 fn handle(&mut self, _name: &str, _args: &serde_json::Value) -> Option<(String, bool)> {
64 None
65 }
66}
67
68/// The subagent entry point. Returns the process exit code.
69pub fn run() -> i32 {
70 install_pdeathsig();
71 // If the supervisor already died in the fork/exec window, bail (we'd be
72 // reparented to init / the subreaper).
73 #[cfg(unix)]
74 if unsafe { libc::getppid() } == 1 {
75 return crate::exit::GENERIC;
76 }
77
78 let mut stdin = BufReader::new(io::stdin());
79 let payload = match read_spawn(&mut stdin) {
80 Ok(p) => p,
81 Err(e) => {
82 eprintln!("agentd subagent: bad spawn payload: {e}");
83 return crate::exit::USAGE;
84 }
85 };
86
87 // Outbound extra trust anchor (`--tls-ca`), inherited via the payload:
88 // install process-wide BEFORE the first dial (the intel/MCP/A2A clients
89 // below), exactly as the supervisor did in `main`. The path is public
90 // material (a CA cert); a re-exec child shares the pod fs, so the same path
91 // resolves here. Idempotent when the parent already installed it in THIS
92 // process image (never the case across re-exec, but harmless).
93 #[cfg(feature = "tls")]
94 if let Some(path) = payload.tls_ca.as_deref()
95 && let Err(e) = std::fs::read(path).and_then(|pem| crate::net::tls::install_extra_ca(&pem))
96 {
97 eprintln!("agentd subagent: --tls-ca {path}: {e}");
98 return crate::exit::USAGE;
99 }
100
101 // AAuth [DRAFT] (RFC 0023): install the SAME agent identity the root has, so
102 // this child signs its MCP requests under one tree-wide identity. The key
103 // file is a shared-fs path resolved here (re-exec crossed the process
104 // boundary); a bad key/secret is a startup failure (exit 2).
105 #[cfg(feature = "aauth")]
106 if let Some(settings) = &payload.aauth
107 && let Err(e) = crate::aauth::setup(settings, std::time::Duration::from_secs(30))
108 {
109 eprintln!("agentd subagent: aauth: {e}");
110 return crate::exit::USAGE;
111 }
112
113 let up: Up = Arc::new(Mutex::new(io::stdout()));
114 let log = build_logger(&payload);
115 let cancel = Arc::new(AtomicBool::new(false));
116 // Tree-wide turn-boundary suspension (RFC 0005 §4.3 / RFC 0015 §4.3): the
117 // control thread sets this on `Pause`, clears it on `Resume`; the loop waits
118 // between turns while it is set. `cancel` always wins (see `pause_wait`).
119 let paused = Arc::new(AtomicBool::new(false));
120
121 // For a warm continue-session, the control thread forwards each `Inject`
122 // event to the loop over this channel; a one-shot run never reads it.
123 let (inject_tx, inject_rx) = std::sync::mpsc::channel::<String>();
124
125 // The child-local LIVE intel handle (RFC 0018 §5.2): the control thread parks
126 // a hot-swap here; the loop drains it at the turn boundary. `None` until the
127 // first swap arrives, so the no-swap path never touches the lock past one
128 // cheap empty check per turn.
129 let pending_swap: PendingSwap = Arc::new(Mutex::new(None));
130
131 // agentd 2.0: the reply slots for ToolRequest/BudgetRequest round-trips.
132 let replies = Arc::new(crate::subagent::replies::Replies::new());
133
134 // The control reader runs on its own thread and owns stdin from here on,
135 // so Ping/Pong keeps flowing while the loop is busy — and so Resume/Cancel/
136 // SwapIntel still arrive while the loop is suspended at a turn boundary.
137 spawn_control_thread(
138 stdin,
139 Arc::clone(&up),
140 Arc::clone(&cancel),
141 Arc::clone(&paused),
142 inject_tx,
143 Arc::clone(&pending_swap),
144 Arc::clone(&replies),
145 log.ctx().clone(),
146 );
147
148 // A warm session keeps its `Inject` stream as its turn-input channel; a
149 // one-shot subagent never reads it.
150 let inject_rx = Some(inject_rx);
151
152 send_up(&up, &AgentMsg::Ready);
153 log.info(
154 "loop.start",
155 serde_json::json!({"depth": payload.depth, "warm": payload.warm}),
156 );
157
158 let mut intel = match IntelClient::from_parts(
159 &payload.intelligence.uri,
160 payload.intelligence.token.clone(),
161 ) {
162 Ok(c) => {
163 // RFC 0031: the resolved `intelligence.headers` ride every dial.
164 #[allow(unused_mut)]
165 let mut c = c
166 .with_headers(payload.intelligence.headers.clone())
167 // RFC 0031 §8: select the wire dialect (bedrock ⇒ Converse).
168 .with_dialect(payload.intelligence.dialect.as_deref());
169 // RFC 0031: an `intelligence.auth: {kind: aws}` SigV4-signs the LLM dial.
170 #[cfg(feature = "oauth")]
171 if let Some(aws) = &payload.intelligence.aws_auth
172 && let Ok(s) = crate::auth::aws::SigV4Signer::from_spec(aws, "intelligence")
173 {
174 c = c.with_signer(Some(s as std::sync::Arc<dyn ::mcp::http::RequestSigner>));
175 }
176 // Outbound LLM calls join the run's distributed trace (RFC 0010).
177 c.set_trace_id(payload.telemetry.trace_id.clone());
178 // RFC 0018 §6: bridge this child's intel reachability UP to the
179 // supervisor (which has no LLM of its own) on each all-down transition.
180 install_intel_health_reporter(&mut c, &up);
181 c
182 }
183 Err(e) => {
184 return fail(
185 &up,
186 &log,
187 format!("intel: {e}"),
188 crate::exit::INTEL_UNAVAILABLE,
189 );
190 }
191 };
192
193 let mut servers = Vec::new();
194 for spec in &payload.mcp_servers {
195 // Let this server ask the operator questions: `elicitation/create`
196 // round-trips to the supervisor's `ask_human`, which renders a gate in
197 // every attached client. Declared per-connection so a server only asks
198 // when we can actually deliver the question to a human.
199 let elicit: Arc<dyn ::mcp::inbound::Handler> =
200 Arc::new(crate::mcp::elicit::ElicitationBridge::new(
201 Arc::clone(&up),
202 Arc::clone(&replies),
203 Arc::clone(&cancel),
204 ELICITATION_TIMEOUT,
205 ));
206 let connected = crate::mcp::from_spec(spec, Duration::from_secs(60))
207 .map(|c| c.with_elicitation(elicit))
208 .and_then(|mut c| c.initialize().map(|()| c));
209 match connected {
210 Ok(mut c) => {
211 log.info("mcp.connect", serde_json::json!({"server": spec.name}));
212 // Stamp the run id (retry dedup, RFC 0011) + a W3C traceparent
213 // (distributed tracing, RFC 0010) on every tool call.
214 let mut meta = serde_json::json!({"agent/run_id": payload.telemetry.run_id});
215 if let Some(tid) = &payload.telemetry.trace_id {
216 meta["traceparent"] = crate::obs::trace::outbound_traceparent(tid).into();
217 }
218 c.set_tool_meta(meta);
219 servers.push(c);
220 }
221 Err(e) => {
222 return fail(
223 &up,
224 &log,
225 format!("mcp '{}': {e}", spec.name),
226 crate::exit::MCP_REQUIRED_DOWN,
227 );
228 }
229 }
230 }
231
232 // agentd 2.0 (RFC 0026 §2): a TURN WORKER runs one turn over the supplied
233 // context slice; internal tools round-trip to the supervisor. Same
234 // connections + supervision as a subagent; a different loop.
235 if payload.role == crate::subagent::protocol::Role::Turn {
236 return crate::runtime::worker::run_turn_child(
237 &payload, &intel, &servers, &up, &cancel, &replies, &log,
238 );
239 }
240
241 let mut input = LoopInput {
242 instruction: payload.instruction.clone(),
243 output_contract: payload.output_contract.clone(),
244 seed: payload
245 .context_seed
246 .iter()
247 .map(|m| (m.role.clone(), m.content.clone()))
248 .collect(),
249 model: payload.intelligence.model.clone().unwrap_or_default(),
250 max_steps: payload.limits.max_steps,
251 max_tokens: payload.limits.max_tokens,
252 deadline: Instant::now() + Duration::from_millis(payload.limits.deadline_ms.max(1)),
253 cancel: Some(Arc::clone(&cancel)),
254 };
255
256 // A subagent has no in-child orchestration self-tools (RFC 0026 §6 flat tree).
257 let mut orch = NoSelfTools;
258
259 // A warm continue-session lives across many events; a one-shot runs once.
260 // A warm session is the long-lived loop/reactive shape (RFC 0008), so it gets
261 // the all-down backoff (RFC 0018 §6): a transient host-model roll recovers
262 // without crashing the daemon, rather than exiting 4 like a `once` job.
263 if payload.warm {
264 intel.enable_alldown_backoff(crate::intel::client::AllDownPolicy::default());
265 return run_warm(
266 intel,
267 &servers,
268 &input,
269 &payload,
270 &mut orch,
271 &cancel,
272 &paused,
273 inject_rx
274 .as_ref()
275 .expect("a warm session keeps its inject stream"),
276 &pending_swap,
277 &up,
278 &log,
279 );
280 }
281
282 // One-shot: a single turn. Suspend at the turn boundary (before the turn
283 // starts) if paused; a turn already in progress is never interrupted.
284 pause_wait(&paused, &cancel, &log);
285 // RFC 0018 §5.2 turn-boundary read: a swap that landed before this single
286 // turn started is applied here (rebuild client + adopt model). A swap that
287 // lands DURING the turn is finish-on-old and invisible — a one-shot has no
288 // next turn, so `restart-turn` is moot for it (the run ends after this turn).
289 apply_pending_swap(&pending_swap, &mut intel, &mut input.model, &up, &log);
290 match run_loop(&intel, &servers, &input, &mut orch, &log) {
291 Ok((outcome, usage)) => {
292 let code = crate::exit::once_exit(outcome.status, outcome.partial);
293 // Roll the run's total tokens up to the supervisor BEFORE the terminal
294 // Result, so hierarchical accounting (`agentd_tokens_total`) sees them.
295 // One Usage per run (a one-shot is a single turn) — never cumulative
296 // AND per-turn, so `record_tokens`' fetch_add can't double-count.
297 send_up(&up, &AgentMsg::Usage(usage));
298 send_up(&up, &AgentMsg::Result { outcome });
299 code
300 }
301 Err(LoopAbort::Intel(m)) => fail(
302 &up,
303 &log,
304 format!("intel: {m}"),
305 crate::exit::INTEL_UNAVAILABLE,
306 ),
307 Err(LoopAbort::Mcp(m)) => fail(
308 &up,
309 &log,
310 format!("mcp: {m}"),
311 crate::exit::MCP_REQUIRED_DOWN,
312 ),
313 }
314}
315
316/// Drive a **warm continue-session** (RFC 0008 §spawn-vs-continue): prepare the
317/// session once, then run one turn per delivered event over the *same*
318/// transcript, emitting [`AgentMsg::Turn`] after each. The process and its
319/// conversation stay warm between events until the supervisor cancels it or
320/// closes the control channel, at which point a terminal [`AgentMsg::Result`]
321/// marks closure. Each turn gets a fresh per-event budget (steps/tokens/deadline)
322/// so one reaction can't starve the session.
323#[allow(clippy::too_many_arguments)]
324fn run_warm(
325 mut intel: IntelClient,
326 servers: &[McpClient],
327 input: &LoopInput,
328 payload: &SpawnPayload,
329 orch: &mut NoSelfTools,
330 cancel: &Arc<AtomicBool>,
331 paused: &Arc<AtomicBool>,
332 inject_rx: &Receiver<String>,
333 pending_swap: &PendingSwap,
334 up: &Up,
335 log: &Logger,
336) -> i32 {
337 let mut session = match Session::prepare(servers, input, orch) {
338 Ok(s) => s,
339 Err(LoopAbort::Intel(m)) => {
340 return fail(
341 up,
342 log,
343 format!("intel: {m}"),
344 crate::exit::INTEL_UNAVAILABLE,
345 );
346 }
347 Err(LoopAbort::Mcp(m)) => {
348 return fail(up, log, format!("mcp: {m}"), crate::exit::MCP_REQUIRED_DOWN);
349 }
350 };
351 let limits = &payload.limits;
352 loop {
353 // Turn boundary: if paused (RFC 0005 §4.3 / RFC 0015 §4.3), suspend HERE,
354 // before starting the next turn — never mid-turn. A `Cancel` during pause
355 // wins and proceeds to wind-down (the loop falls through to the cancel
356 // check below). The control thread keeps running, so Resume/Cancel arrive
357 // while we wait. The supervisor reactor and its liveness heartbeat are not
358 // affected — only this child loop suspends.
359 pause_wait(paused, cancel, log);
360 // Live tools refresh (pivot Phase 7 follow-up): an inbound
361 // `notifications/tools/list_changed` on any of THIS child's own MCP
362 // connections re-enumerates the catalogue at this turn boundary, so a
363 // warm session tracks a changing server instead of holding a stale tool
364 // set for its whole life. (Spawned one-shots already re-list per run.)
365 refresh_tools_if_changed(&mut session, orch, servers, log);
366 // RFC 0018 §5.2 turn-boundary read: a hot-swap parked by the control thread
367 // is drained + applied HERE, before the turn — the loop rebuilds its client
368 // (fresh health/breaker) and adopts the new model. The transcript is
369 // UNTOUCHED (§5.3 — no context reset); a turn already running was never
370 // torn (finish-on-old by construction — the swap only lands at this seam).
371 apply_pending_swap_warm(pending_swap, &mut intel, &mut session, up, log);
372 // Snapshot the pre-turn transcript so `restart-turn` (RFC 0018 §5.3) can
373 // discard a turn that completed under a model swap and re-run it on the new
374 // model from this exact state. Cheap (a `usize`); unused under finish-on-old.
375 let pre_turn = session.transcript_len();
376 // One turn over the persistent transcript, bounded by a fresh per-event
377 // budget (a new deadline each turn, so the session isn't globally capped).
378 let deadline = Instant::now() + Duration::from_millis(limits.deadline_ms.max(1));
379 let mut budget = Budget::new(limits.max_steps, limits.max_tokens, deadline);
380 let (outcome, usage) = match session.run_turn(&intel, orch, log, &mut budget, Some(cancel))
381 {
382 Ok(ou) => ou,
383 Err(LoopAbort::Intel(m)) => {
384 return fail(
385 up,
386 log,
387 format!("intel: {m}"),
388 crate::exit::INTEL_UNAVAILABLE,
389 );
390 }
391 Err(LoopAbort::Mcp(m)) => {
392 return fail(up, log, format!("mcp: {m}"), crate::exit::MCP_REQUIRED_DOWN);
393 }
394 };
395 // Cancellation during a turn ends the session (terminal Result below);
396 // any other terminal is just this reaction's turn — the session lives on.
397 if outcome.status == TerminalStatus::Cancelled {
398 break;
399 }
400 // RFC 0018 §5.3 `restart-turn`: a model-changing swap that LANDED while this
401 // turn was in flight discards the turn's result and re-runs it on the new
402 // model from the pre-turn transcript. We never tore the `complete_once` —
403 // the turn finished; we drop its appended messages and loop WITHOUT
404 // consuming a new event. Bounded by the step budget like any turn. The swap
405 // is applied at the top of the loop (the turn-boundary seam), so we only
406 // decide here whether to re-run; an endpoint repoint (model unchanged) is
407 // never a restart (it is always invisible / finish-on-old, §5.1).
408 if restart_turn_pending(pending_swap, session.model()) {
409 session.truncate_transcript(pre_turn);
410 log.info(
411 "intel.swap.restart_turn",
412 serde_json::json!({"discarded_turn": true}),
413 );
414 continue;
415 }
416 // Roll this turn's tokens up to the supervisor BEFORE the Turn event, so
417 // hierarchical accounting (`agentd_tokens_total`) sees each warm turn's
418 // usage. This `usage` is exactly ONE turn's delta (`run_turn` accumulates
419 // per-turn `tok_in`/`tok_out` against a fresh per-event budget), and
420 // `record_tokens` fetch_adds — so one Usage per emitted turn never
421 // double-counts (a cancelled or restart-discarded turn emits no Turn and
422 // no Usage here).
423 send_up(up, &AgentMsg::Usage(usage));
424 send_up(up, &AgentMsg::Turn { outcome });
425 if cancel.load(Ordering::Relaxed) {
426 break;
427 }
428 // Block for the next event (single-consumer, in-order FIFO).
429 match wait_for_inject(inject_rx, cancel) {
430 Some(message) => {
431 log.info(
432 "subagent.inject",
433 serde_json::json!({"bytes": message.len()}),
434 );
435 session.deliver(&message);
436 }
437 None => break, // cancelled, or the supervisor closed the control channel
438 }
439 }
440 // Session closed: a single terminal Result so the supervisor sees closure.
441 let status = if cancel.load(Ordering::Relaxed) {
442 TerminalStatus::Cancelled
443 } else {
444 TerminalStatus::Completed
445 };
446 let code = crate::exit::once_exit(status, false);
447 send_up(
448 up,
449 &AgentMsg::Result {
450 outcome: Outcome {
451 status,
452 partial: false,
453 result: serde_json::Value::Null,
454 scheduled: Vec::new(),
455 subscriptions: Vec::new(),
456 },
457 },
458 );
459 code
460}
461
462/// Block until the next event is injected, the supervisor closes the control
463/// channel (its `Inject` sender drops → `Disconnected`), or a cancel is
464/// requested — polled so cancellation between events stays prompt.
465fn wait_for_inject(rx: &Receiver<String>, cancel: &AtomicBool) -> Option<String> {
466 loop {
467 if cancel.load(Ordering::Relaxed) {
468 return None;
469 }
470 match rx.recv_timeout(Duration::from_millis(200)) {
471 Ok(message) => return Some(message),
472 Err(RecvTimeoutError::Timeout) => {}
473 Err(RecvTimeoutError::Disconnected) => return None,
474 }
475 }
476}
477
478/// Suspend the loop at a turn boundary while `paused` is set (RFC 0005 §4.3 /
479/// RFC 0015 §4.3). Polls at the same cadence as `wait_for_inject` so a `Resume`
480/// (or `Cancel`) lands promptly. `cancel` always wins: a cancel during a pause
481/// returns immediately so the loop proceeds to wind-down. Logs once on enter and
482/// once on leave (debounced — never per poll). The supervisor reactor is NOT
483/// gated by this; only the child's agentic loop suspends, so the liveness
484/// heartbeat keeps ticking (RFC 0015 §4.3).
485fn pause_wait(paused: &AtomicBool, cancel: &AtomicBool, log: &Logger) {
486 if !paused.load(Ordering::Relaxed) || cancel.load(Ordering::Relaxed) {
487 return; // fast path: not paused (or cancel wins) → no log, no wait
488 }
489 log.info("loop.paused", serde_json::json!({}));
490 while paused.load(Ordering::Relaxed) && !cancel.load(Ordering::Relaxed) {
491 std::thread::sleep(Duration::from_millis(50));
492 }
493 log.info("loop.resumed", serde_json::json!({}));
494}
495
496/// Rebuild an [`IntelClient`] from a hot-swap's endpoint list (RFC 0018 §5.2
497/// step 2). A repointed endpoint starts CLOSED — a fresh [`crate::intel::endpoints::EndpointList`]
498/// gives every endpoint a brand-new `HealthRecord`, so NO stale breaker state
499/// carries to a new CID. The run's trace id is re-stamped onto the new client so
500/// outbound calls keep joining the run's distributed trace. Returns `None` (and
501/// logs) if the new list is unparseable, in which case the caller keeps the old
502/// client (a bad swap never tears a working run).
503fn rebuild_intel(swap: &SwapIntel, old: &IntelClient, log: &Logger) -> Option<IntelClient> {
504 match IntelClient::from_parts(&swap.uri, swap.token.clone()) {
505 Ok(mut c) => {
506 c.set_trace_id(old.trace_id().map(str::to_string));
507 // A warm/long-lived loop keeps its all-down backoff across a swap (the
508 // daemon must not start crashing on a transient roll just because it was
509 // repointed). The one-shot path never enabled it, so this is a no-op there.
510 if old.alldown_enabled() {
511 c.enable_alldown_backoff(crate::intel::client::AllDownPolicy::default());
512 }
513 Some(c)
514 }
515 Err(e) => {
516 log.warn(
517 "intel.swap.reject",
518 serde_json::json!({"err": e.to_string()}),
519 );
520 None
521 }
522 }
523}
524
525/// Emit the `intel.swap` event (RFC 0018 §8 / §5) for an applied swap. NO secret
526/// and NO URL ever appear — only the swap KIND (`endpoint`/`model`), the model
527/// names (which are non-secret identifiers), the policy, and whether the endpoint
528/// list changed. The endpoint identity stays transport+index-only, surfaced by
529/// the `agentd://intelligence` resource, never here (RFC 0012 §3.7).
530fn log_swap(
531 log: &Logger,
532 from_model: &str,
533 to_model: &str,
534 endpoint_change: bool,
535 policy: SwapPolicy,
536) {
537 let kind = if from_model != to_model {
538 "model"
539 } else {
540 "endpoint"
541 };
542 log.info(
543 "intel.swap",
544 serde_json::json!({
545 "kind": kind,
546 "model_from": from_model,
547 "model_to": to_model,
548 "endpoint_change": endpoint_change,
549 "policy": policy.as_str(),
550 }),
551 );
552}
553
554/// Apply a parked hot-swap at the ONE-SHOT turn boundary (RFC 0018 §5.2): drain
555/// the pending slot, rebuild the client (fresh health), and adopt the new model
556/// into `model`. A no-op (one cheap empty-lock check) when nothing is pending —
557/// the no-swap path is unchanged. `restart-turn` is moot for a one-shot (it has a
558/// single turn), so the policy only governs the event label here.
559fn apply_pending_swap(
560 pending: &PendingSwap,
561 intel: &mut IntelClient,
562 model: &mut String,
563 up: &Up,
564 log: &Logger,
565) {
566 let Some(swap) = pending.lock().unwrap_or_else(|e| e.into_inner()).take() else {
567 return; // fast path: no swap pending
568 };
569 let from_model = model.clone();
570 let to_model = swap.model.clone().unwrap_or_else(|| from_model.clone());
571 let endpoint_change = match rebuild_intel(&swap, intel, log) {
572 Some(mut c) => {
573 // The rebuilt client has fresh breakers + no reporter — re-install it
574 // so the child keeps bridging reachability up after a repoint (§6).
575 install_intel_health_reporter(&mut c, up);
576 *intel = c;
577 true
578 }
579 None => false,
580 };
581 *model = to_model.clone();
582 log_swap(log, &from_model, &to_model, endpoint_change, swap.policy);
583}
584
585/// Apply a parked hot-swap at a WARM-session turn boundary (RFC 0018 §5.2): drain
586/// the pending slot, rebuild the client (fresh health), and adopt the new model
587/// onto the live [`Session`] (the transcript is UNTOUCHED — §5.3). A no-op when
588/// nothing is pending.
589fn apply_pending_swap_warm(
590 pending: &PendingSwap,
591 intel: &mut IntelClient,
592 session: &mut Session<'_>,
593 up: &Up,
594 log: &Logger,
595) {
596 let Some(swap) = pending.lock().unwrap_or_else(|e| e.into_inner()).take() else {
597 return; // fast path: no swap pending
598 };
599 let from_model = session.model().to_string();
600 let to_model = swap.model.clone().unwrap_or_else(|| from_model.clone());
601 let endpoint_change = match rebuild_intel(&swap, intel, log) {
602 Some(mut c) => {
603 // The rebuilt client has fresh breakers + no reporter — re-install it
604 // so a warm session keeps bridging reachability up after a repoint (§6).
605 install_intel_health_reporter(&mut c, up);
606 *intel = c;
607 true
608 }
609 None => false,
610 };
611 session.set_model(&to_model);
612 log_swap(log, &from_model, &to_model, endpoint_change, swap.policy);
613}
614
615/// Peek (without draining) whether a `restart-turn` swap is parked that would
616/// CHANGE the model from the session's current one (RFC 0018 §5.3). Only a
617/// model-changing `restart-turn` swap that landed WHILE the turn was in flight
618/// warrants discarding + re-running the just-completed turn; an endpoint repoint
619/// (model unchanged) is always finish-on-old / invisible (§5.1), and a
620/// finish-on-old swap is applied at the next boundary without a re-run.
621fn restart_turn_pending(pending: &PendingSwap, current_model: &str) -> bool {
622 let guard = pending.lock().unwrap_or_else(|e| e.into_inner());
623 match guard.as_ref() {
624 Some(swap) if swap.policy == SwapPolicy::RestartTurn => {
625 swap.model.as_deref().is_some_and(|m| m != current_model)
626 }
627 _ => false,
628 }
629}
630
631fn fail(up: &Up, log: &Logger, error: String, code: i32) -> i32 {
632 log.error("loop.error", serde_json::json!({"err": error}));
633 send_up(up, &AgentMsg::Failed { error });
634 code
635}
636
637/// Drain this child's own MCP notification queues at a warm turn boundary; on an
638/// inbound `tools/list_changed`, rebuild the session's tool catalogue live. A
639/// failed re-list is a warning (the old catalogue stays — the next boundary
640/// retries); a warm child holds no subscriptions, so draining here eats nothing
641/// the daemon relies on (subscriptions live on the DAEMON's own connections).
642fn refresh_tools_if_changed(
643 session: &mut Session,
644 orch: &mut NoSelfTools,
645 servers: &[McpClient],
646 log: &Logger,
647) {
648 use crate::wire::mcp::method;
649 let changed = servers.iter().any(|s| {
650 s.drain_notifications()
651 .iter()
652 .any(|n| n.method == method::NOTIFY_TOOLS_LIST_CHANGED)
653 });
654 if !changed {
655 return;
656 }
657 match session.refresh_tools(orch) {
658 Ok(()) => log.info(
659 "mcp.tools_refreshed",
660 serde_json::json!({"tools": session.tools_len()}),
661 ),
662 Err(e) => {
663 let msg = match e {
664 LoopAbort::Intel(m) | LoopAbort::Mcp(m) => m,
665 };
666 log.warn("mcp.tools_refresh_failed", serde_json::json!({"err": msg}));
667 }
668 }
669}
670
671fn read_spawn(reader: &mut BufReader<Stdin>) -> Result<SpawnPayload, String> {
672 let bytes = frame::read_frame(reader)
673 .map_err(|e| e.to_string())?
674 .ok_or_else(|| "stdin closed before spawn payload".to_string())?;
675 match serde_json::from_slice::<ControlMsg>(&bytes).map_err(|e| e.to_string())? {
676 ControlMsg::Spawn(p) => Ok(*p),
677 // Defense-in-depth (unreachable in practice — the supervisor always sends
678 // Spawn first): report only the variant LABEL, never `{other:?}`. A
679 // `SwapIntel`/`Inject` first frame would otherwise Debug-print a plaintext
680 // token / injected instruction to stderr, contradicting "token NEVER logged".
681 other => Err(format!(
682 "first frame was not Spawn (got {})",
683 control_msg_label(&other)
684 )),
685 }
686}
687
688/// The bare variant tag of a [`ControlMsg`] — NO payload (a `SwapIntel`/`Inject`
689/// carries a credential / injected instruction that must never reach a log/stderr).
690fn control_msg_label(msg: &ControlMsg) -> &'static str {
691 match msg {
692 ControlMsg::Spawn(_) => "spawn",
693 ControlMsg::Ping { .. } => "ping",
694 ControlMsg::Pause => "pause",
695 ControlMsg::Resume => "resume",
696 ControlMsg::Cancel { .. } => "cancel",
697 ControlMsg::Inject { .. } => "inject",
698 ControlMsg::SwapIntel(_) => "swap_intel",
699 ControlMsg::ToolResult { .. } => "tool_result",
700 ControlMsg::BudgetGrant { .. } => "budget_grant",
701 }
702}
703
704fn build_logger(payload: &SpawnPayload) -> Logger {
705 let t = &payload.telemetry;
706 let level = Level::parse(&t.log_level).unwrap_or(Level::Info);
707 Logger::new(
708 LogCtx {
709 run_id: t.run_id.clone(),
710 agent_id: t.agent_id.clone(),
711 agent_path: t.agent_path.clone(),
712 comp: Comp::Agent,
713 pid: std::process::id(),
714 trace_id: t.trace_id.clone(),
715 },
716 level,
717 )
718 .with_content(t.log_content)
719}
720
721pub(crate) fn send_up(up: &Up, msg: &AgentMsg) {
722 if let Ok(mut out) = up.lock() {
723 // Best-effort: a dead parent means our writes fail; we don't crash.
724 let _ = frame::write_frame(&mut *out, msg);
725 }
726}
727
728/// Wire the child's intelligence reachability UP to the supervisor (RFC 0018 §6).
729/// The model loop runs in this CHILD process and owns the breaker/failover state;
730/// the supervisor has no LLM and no live view of it. The reporter is edge-triggered
731/// (fires only on an all-down ENTER/EXIT transition) and carries transport+index
732/// ONLY — NEVER a URL/cid/host or credential (RFC 0012 §3.7). Re-installed after a
733/// hot-swap rebuild (the rebuilt client has fresh breakers + no reporter). Cloning
734/// the `up` Arc lets the reporter outlive this fn (it is owned by the new client).
735fn install_intel_health_reporter(intel: &mut IntelClient, up: &Up) {
736 let up = Arc::clone(up);
737 intel.set_health_reporter(Box::new(move |r: IntelHealthReport| {
738 let active = r.active.map(|(index, transport)| IntelActive {
739 index,
740 transport: transport.to_string(),
741 });
742 send_up(
743 &up,
744 &AgentMsg::IntelHealth {
745 all_down: r.all_down,
746 active,
747 },
748 );
749 }));
750}
751
752/// The control reader thread. Owns stdin, answers `Ping` with `Pong`, flips the
753/// cancel flag on `Cancel`, toggles the `paused` flag on `Pause`/`Resume`, and
754/// forwards each `Inject` event to a warm session's loop over `inject_tx`. It
755/// keeps running while the loop is suspended (so `Resume`/`Cancel`/`Ping` still
756/// arrive — the whole point of a separate thread). Exits on EOF (the supervisor
757/// closed the channel) or a read error — which drops `inject_tx`, unblocking a
758/// warm session's wait.
759#[allow(clippy::too_many_arguments)]
760fn spawn_control_thread(
761 mut stdin: BufReader<Stdin>,
762 up: Up,
763 cancel: Arc<AtomicBool>,
764 paused: Arc<AtomicBool>,
765 inject_tx: Sender<String>,
766 pending_swap: PendingSwap,
767 replies: Arc<crate::subagent::replies::Replies>,
768 ctx: LogCtx,
769) {
770 let log = Logger::new(ctx, Level::Debug);
771 std::thread::Builder::new()
772 .name("subagent-control".into())
773 .spawn(move || {
774 // Exits on Ok(None)/Err — the supervisor closed the channel.
775 while let Ok(Some(bytes)) = frame::read_frame(&mut stdin) {
776 match serde_json::from_slice::<ControlMsg>(&bytes) {
777 // agentd 2.0 round-trip answers: park them in the reply slots
778 // the turn worker blocks on (RFC 0026 §2).
779 Ok(ControlMsg::ToolResult {
780 id,
781 result,
782 is_error,
783 }) => {
784 replies.deliver(
785 id,
786 crate::subagent::replies::Reply::Tool { result, is_error },
787 );
788 }
789 Ok(ControlMsg::BudgetGrant {
790 id,
791 ok,
792 wait_ms,
793 model,
794 reason,
795 }) => {
796 replies.deliver(
797 id,
798 crate::subagent::replies::Reply::Budget {
799 ok,
800 wait_ms,
801 model,
802 reason,
803 },
804 );
805 }
806 Ok(ControlMsg::Ping { seq }) => send_up(&up, &AgentMsg::Pong { seq }),
807 Ok(ControlMsg::Cancel { reason }) => {
808 log.info("subagent.cancel", serde_json::json!({"reason": reason}));
809 cancel.store(true, Ordering::Relaxed);
810 }
811 // Turn-boundary suspension (RFC 0005 §4.3 / RFC 0015 §4.3): set
812 // the flag here; the loop suspends at its next boundary. The
813 // loop's `pause_wait` does the enter/leave logging (debounced).
814 Ok(ControlMsg::Pause) => paused.store(true, Ordering::Relaxed),
815 Ok(ControlMsg::Resume) => paused.store(false, Ordering::Relaxed),
816 // Deliver into the warm session; a one-shot run never reads
817 // the receiver, so the send is simply dropped there.
818 Ok(ControlMsg::Inject { message }) => {
819 let _ = inject_tx.send(message);
820 }
821 // Intelligence hot-swap (RFC 0018 §5.2): park the new config in
822 // the child-local LIVE handle. The loop drains + applies it at
823 // its next turn boundary (rebuild client + adopt model); we
824 // never touch the loop's in-flight `complete_once`. A swap that
825 // supersedes a still-unread one simply overwrites it — the loop
826 // only ever cares about the LATEST config (last-write-wins). The
827 // token rides this frame (like Spawn) but is NEVER logged.
828 Ok(ControlMsg::SwapIntel(swap)) => {
829 log.info(
830 "subagent.swap_intel",
831 serde_json::json!({"endpoint_change": true}),
832 );
833 *pending_swap.lock().unwrap_or_else(|e| e.into_inner()) = Some(*swap);
834 }
835 Ok(ControlMsg::Spawn(_)) | Err(_) => { /* unexpected/garbage — ignore */ }
836 }
837 }
838 // The channel closed: wake any turn worker blocked on a reply.
839 replies.close();
840 })
841 .ok();
842}
843
844/// `PR_SET_PDEATHSIG(SIGKILL)`: when the supervisor (our parent) dies, the
845/// kernel sends us SIGKILL — the leaf-up tree collapse (RFC 0003). Must be set
846/// after `execve` (it is cleared across exec), i.e. here in the child's `main`.
847#[cfg(target_os = "linux")]
848fn install_pdeathsig() {
849 unsafe {
850 libc::prctl(
851 libc::PR_SET_PDEATHSIG,
852 libc::SIGKILL as libc::c_ulong,
853 0,
854 0,
855 0,
856 );
857 }
858}
859
860#[cfg(not(target_os = "linux"))]
861fn install_pdeathsig() {
862 // PDEATHSIG is Linux-only; on other Unix the supervisor's kill ladder is
863 // the fallback. (agentd targets Linux for production.)
864}
865
866// The full control path is exercised end to end by the `subagent_spawn`
867// integration test (a real subagent process). The flag-driven turn-boundary
868// suspend logic is unit-tested here directly.
869#[cfg(test)]
870mod tests {
871 use super::*;
872 use crate::obs::log::{Comp, Level, LogCtx, Logger};
873
874 fn test_log() -> Logger {
875 Logger::new(
876 LogCtx {
877 run_id: "r".into(),
878 agent_id: "0".into(),
879 agent_path: "0".into(),
880 comp: Comp::Agent,
881 pid: 0,
882 trace_id: None,
883 },
884 Level::Info,
885 )
886 }
887
888 /// A best-effort upward handle for the swap-apply tests — writes to the real
889 /// stdout (the reporter re-install path is exercised; the framed bytes are
890 /// inert in a unit test, and `send_up` is best-effort by construction).
891 fn test_up() -> Up {
892 Arc::new(Mutex::new(io::stdout()))
893 }
894
895 #[test]
896 fn pause_wait_returns_immediately_when_not_paused() {
897 let paused = AtomicBool::new(false);
898 let cancel = AtomicBool::new(false);
899 let t = Instant::now();
900 pause_wait(&paused, &cancel, &test_log());
901 // No sleep on the fast path.
902 assert!(t.elapsed() < Duration::from_millis(40));
903 }
904
905 #[test]
906 fn pause_wait_cancel_wins_over_pause() {
907 // Paused AND cancelled → cancel wins: return at once (the loop then winds
908 // down at its cancel check). Never blocks.
909 let paused = AtomicBool::new(true);
910 let cancel = AtomicBool::new(true);
911 let t = Instant::now();
912 pause_wait(&paused, &cancel, &test_log());
913 assert!(t.elapsed() < Duration::from_millis(40));
914 }
915
916 #[test]
917 fn pause_wait_suspends_until_resume() {
918 // Paused → block; another thread clears `paused` (a Resume), and the wait
919 // returns. The flag is the whole mechanism — this proves the seam.
920 let paused = Arc::new(AtomicBool::new(true));
921 let cancel = Arc::new(AtomicBool::new(false));
922 let p2 = Arc::clone(&paused);
923 let unblock = std::thread::spawn(move || {
924 std::thread::sleep(Duration::from_millis(120));
925 p2.store(false, Ordering::Relaxed); // Resume
926 });
927 let t = Instant::now();
928 pause_wait(&paused, &cancel, &test_log());
929 // It actually waited for the resume (≥ ~one poll interval), then returned.
930 assert!(t.elapsed() >= Duration::from_millis(80));
931 assert!(!paused.load(Ordering::Relaxed));
932 unblock.join().unwrap();
933 }
934
935 #[test]
936 fn pause_wait_breaks_out_on_cancel_during_pause() {
937 // A cancel that lands WHILE suspended unblocks the wait (cancel always
938 // wins), so a drain during a pause proceeds (RFC 0015 §4.3).
939 let paused = Arc::new(AtomicBool::new(true));
940 let cancel = Arc::new(AtomicBool::new(false));
941 let c2 = Arc::clone(&cancel);
942 let canceller = std::thread::spawn(move || {
943 std::thread::sleep(Duration::from_millis(120));
944 c2.store(true, Ordering::Relaxed); // Cancel during pause
945 });
946 pause_wait(&paused, &cancel, &test_log());
947 assert!(cancel.load(Ordering::Relaxed));
948 assert!(paused.load(Ordering::Relaxed)); // still paused, but cancel broke us out
949 canceller.join().unwrap();
950 }
951
952 fn swap_to(uri: &str, model: Option<&str>, policy: SwapPolicy) -> SwapIntel {
953 SwapIntel {
954 uri: uri.into(),
955 token: None,
956 model: model.map(str::to_string),
957 policy,
958 }
959 }
960
961 #[test]
962 fn apply_pending_swap_rebuilds_client_and_adopts_model() {
963 // RFC 0018 §5.2: a parked swap is drained + applied at the one-shot turn
964 // boundary — the client points at the new endpoint list and the model is
965 // adopted. The new endpoint list starts with FRESH health (every endpoint
966 // CLOSED — `from_parts` builds a new HealthRecord, no stale breaker).
967 let pending: PendingSwap = Arc::new(Mutex::new(None));
968 let mut intel = IntelClient::from_parts("https://old.example", None).unwrap();
969 let mut model = "old-model".to_string();
970 *pending.lock().unwrap() = Some(swap_to(
971 "https://a.example,https://b.example",
972 Some("new-model"),
973 SwapPolicy::FinishOnOld,
974 ));
975 apply_pending_swap(&pending, &mut intel, &mut model, &test_up(), &test_log());
976 assert_eq!(model, "new-model");
977 assert_eq!(
978 intel.endpoint_count(),
979 2,
980 "client repointed to the new list"
981 );
982 // The slot is drained — a second apply is a no-op (no double-swap).
983 assert!(pending.lock().unwrap().is_none());
984 apply_pending_swap(&pending, &mut intel, &mut model, &test_up(), &test_log());
985 assert_eq!(model, "new-model");
986 }
987
988 #[test]
989 fn apply_pending_swap_is_a_noop_when_nothing_pending() {
990 // The no-swap path: the model + endpoint count are byte-for-byte unchanged.
991 let pending: PendingSwap = Arc::new(Mutex::new(None));
992 let mut intel = IntelClient::from_parts("https://only.example", None).unwrap();
993 let mut model = "m".to_string();
994 apply_pending_swap(&pending, &mut intel, &mut model, &test_up(), &test_log());
995 assert_eq!(model, "m");
996 assert_eq!(intel.endpoint_count(), 1);
997 }
998
999 #[test]
1000 fn restart_turn_pending_only_for_model_change_under_restart_policy() {
1001 let pending: PendingSwap = Arc::new(Mutex::new(None));
1002 // No swap pending → never a restart.
1003 assert!(!restart_turn_pending(&pending, "m"));
1004 // A finish-on-old swap (even a model change) → never a restart.
1005 *pending.lock().unwrap() = Some(swap_to(
1006 "https://a.example",
1007 Some("big"),
1008 SwapPolicy::FinishOnOld,
1009 ));
1010 assert!(!restart_turn_pending(&pending, "small"));
1011 // A restart-turn swap that does NOT change the model (endpoint repoint) →
1012 // never a restart (a repoint is always finish-on-old / invisible, §5.1).
1013 *pending.lock().unwrap() = Some(swap_to(
1014 "https://a.example",
1015 Some("small"),
1016 SwapPolicy::RestartTurn,
1017 ));
1018 assert!(!restart_turn_pending(&pending, "small"));
1019 // A restart-turn swap that DOES change the model → a restart.
1020 *pending.lock().unwrap() = Some(swap_to(
1021 "https://a.example",
1022 Some("big"),
1023 SwapPolicy::RestartTurn,
1024 ));
1025 assert!(restart_turn_pending(&pending, "small"));
1026 }
1027
1028 #[test]
1029 fn read_spawn_error_never_echoes_a_swap_intel_token() {
1030 // Defense-in-depth (the info fold-in): a non-Spawn first frame must report
1031 // only the variant LABEL — never `{other:?}`, which would Debug-print a
1032 // plaintext token / injected instruction to stderr ("token NEVER logged").
1033 let swap = ControlMsg::SwapIntel(Box::new(SwapIntel {
1034 uri: "https://secret-host.example/secret-path".into(),
1035 token: Some("super-secret-token".into()),
1036 model: Some("m".into()),
1037 policy: SwapPolicy::FinishOnOld,
1038 }));
1039 let mut buf = Vec::new();
1040 frame::write_frame(&mut buf, &swap).unwrap();
1041 let mut reader = BufReader::new(io::Cursor::new(buf));
1042 // `read_spawn` takes `BufReader<Stdin>`; the label helper is the unit under
1043 // test for the redaction property — drive it directly to avoid a real stdin.
1044 let err = format!(
1045 "first frame was not Spawn (got {})",
1046 control_msg_label(&swap)
1047 );
1048 assert_eq!(err, "first frame was not Spawn (got swap_intel)");
1049 assert!(!err.contains("super-secret-token"), "token leaked: {err}");
1050 assert!(!err.contains("secret-host.example"), "uri leaked: {err}");
1051 // The label helper covers every variant tag, payload-free.
1052 assert_eq!(
1053 control_msg_label(&ControlMsg::Inject {
1054 message: "do bad things".into()
1055 }),
1056 "inject"
1057 );
1058 let _ = &mut reader; // the framed bytes are constructed; the property is the label
1059 }
1060
1061 #[test]
1062 fn bad_swap_list_keeps_the_old_client() {
1063 // RFC 0018 §5.2: an unparseable new list never tears a working run — the
1064 // old client is kept; only the model (a plain string) is still adopted.
1065 let pending: PendingSwap = Arc::new(Mutex::new(None));
1066 let mut intel =
1067 IntelClient::from_parts("https://old.example,https://old2.example", None).unwrap();
1068 let mut model = "old".to_string();
1069 *pending.lock().unwrap() = Some(swap_to("", Some("new"), SwapPolicy::FinishOnOld));
1070 apply_pending_swap(&pending, &mut intel, &mut model, &test_up(), &test_log());
1071 assert_eq!(intel.endpoint_count(), 2, "kept the old 2-endpoint client");
1072 assert_eq!(model, "new");
1073 }
1074}