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