Skip to main content

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}