Skip to main content

agentd/subagent/
control.rs

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