Skip to main content

agentmux_srv/backend/reactive/
handler.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4
5use std::collections::HashMap;
6use std::sync::{Mutex, OnceLock};
7use std::time::{Duration, Instant};
8
9use super::sanitize::{format_injected_message, is_sensitive_message, sanitize_message, validate_agent_id, wrap_jekt_message};
10use super::types::*;
11use super::{audit_hash, now_unix_millis, AUDIT_LOG_MAX, RATE_LIMIT_MAX};
12
13// ---- Rate Limiter ----
14
15pub(super) struct RateLimiter {
16    tokens: u32,
17    max_tokens: u32,
18    last_refill: Instant,
19}
20
21impl RateLimiter {
22    pub(super) fn new(max_tokens: u32) -> Self {
23        Self {
24            tokens: max_tokens,
25            max_tokens,
26            last_refill: Instant::now(),
27        }
28    }
29
30    pub(super) fn check(&mut self) -> bool {
31        let now = Instant::now();
32        let elapsed = now.duration_since(self.last_refill);
33        if elapsed >= Duration::from_secs(1) {
34            self.tokens = self.max_tokens;
35            self.last_refill = now;
36        }
37        if self.tokens > 0 {
38            self.tokens -= 1;
39            true
40        } else {
41            false
42        }
43    }
44}
45
46// ---- Supervisor nudge-ceiling tracking ----
47
48/// Per-target-agent consecutive-nudge tracking for the Warden Supervisor
49/// guardrail. Keyed on the target agent's lowercased name in
50/// `Handler::nudge_counters`.
51struct NudgeCounterState {
52    /// The target's `AgentRegistration::registration_nonce` as of the last
53    /// nudge — a respawn (new nonce) resets the counter, since "consecutive"
54    /// only makes sense within one continuous run. Real nonces are ≥ 1
55    /// (persistent-controller spawns only — `persistent::next_registration_
56    /// nonce`); PTY/shell and HTTP-register paths always register with 0
57    /// ("not recorded"), so nonce alone can't detect a respawn for them —
58    /// `block_id` below covers that case instead.
59    registration_nonce: u64,
60    /// The target's block id as of the last nudge. A PTY/shell-registered
61    /// agent that's closed and relaunched gets a fresh block id (a new
62    /// pane), which nonce (always 0 for these paths) can't see — comparing
63    /// block_id catches that respawn case reagent flagged as a P1 gap
64    /// (nonce-only reset never fires for PTY agents). Doesn't help detect a
65    /// respawn that reuses the same pane/block id (e.g. a one-shot
66    /// SubprocessController that re-registers every turn in-place) — that
67    /// case still relies on `NUDGE_COOLDOWN_RESET_MS` alone, same as before.
68    block_id: String,
69    count: u32,
70    last_nudge_at_ms: u64,
71}
72
73/// Max consecutive auto-continue nudges a Supervisor may send to the same
74/// target agent (within one registration / cooldown window) before
75/// `record_supervisor_decision` refuses and forces a decline instead. Bounds
76/// a runaway auto-continue loop — see
77/// docs/analysis/ANALYSIS_WARDEN_AUTO_CONTROLLER_CONTINUATION_WATCHER_2026_08_12.md.
78pub(super) const MAX_CONSECUTIVE_AUTO_CONTINUES: u32 = 5;
79
80/// Gap since the last nudge after which the consecutive counter resets, on
81/// the theory that a long-idle target has effectively started a new work
82/// session even without a fresh `registration_nonce`.
83pub(super) const NUDGE_COOLDOWN_RESET_MS: u64 = 30 * 60 * 1000;
84
85/// The one, fixed message a `SupervisorAction::Nudge` ever delivers.
86/// Deliberately not a parameter — see the doc on `record_supervisor_decision`'s
87/// `Nudge` arm for why a free-form message defeats the guardrail this
88/// exists for.
89pub(super) const NUDGE_MESSAGE: &str = "Continue the task you were already doing.";
90
91// ---- Handler ----
92
93/// Core reactive messaging handler.
94///
95/// Manages agent registrations, rate limiting, message injection,
96/// and audit logging.
97pub struct Handler {
98    // ---- Identity M2 (SPEC_AGENT_IDENTITY_CARRIED_NOT_DERIVED_2026_09_23.md
99    // §4.4): identity is the key, names are typed bindings. ----
100    //
101    /// The key, when known: the agent's UID (`db_agents.id`) → its block.
102    /// One-to-one — a UID registering on a new block evicts its old one
103    /// (a respawn). Empty for blocks the server has no row for (quick-launch
104    /// panes, PTY shells), which are then reachable by name only.
105    uid_to_block: HashMap<String, String>,
106    block_to_uid: HashMap<String, String>,
107    /// Lowercased name → every block bound to it. A name may hold several
108    /// blocks for the first time: two agents whose names collide both stay
109    /// registered (today one silently evicts the other), and delivery to
110    /// that name is refused with the candidates instead of guessed (§5.2).
111    name_to_blocks: HashMap<String, Vec<String>>,
112    /// A block's own names, typed — see [`NameBindings`].
113    block_names: HashMap<String, NameBindings>,
114    /// Keyed by block id (was: by lowercased name). `agent_id` in the record
115    /// is the block's *display* binding.
116    agent_info: HashMap<String, AgentRegistration>,
117    input_sender: Option<InputSender>,
118    /// Controller-aware delivery for non-PTY agents (persistent stream-json / ACP).
119    /// When set, it is tried before the PTY keystroke path so messages reach (and
120    /// steer) agents that have no terminal. See `set_message_sender`.
121    message_sender: Option<MessageSender>,
122    /// Queries a resolved delivery target's own live identity before
123    /// delivering, as a check independent of this handler's own
124    /// `uid_to_block` / `name_to_blocks` registry. See
125    /// `AgentIdentityConfirmer`'s doc comment and
126    /// `set_agent_identity_confirmer`.
127    agent_identity_confirmer: Option<AgentIdentityConfirmer>,
128    /// Same shape as `agent_identity_confirmer`, but backed by
129    /// `Controller::stable_agent_id()` (the frozen `AGENTMUX_AGENT_ID`)
130    /// instead of `Controller::agent_id()` (the live, renameable display
131    /// name). Checked as an alternative match in `inject_message_inner`'s
132    /// #2695 identity check — a jekt addressed to the stable name would
133    /// otherwise always fail once the display binding has moved on
134    /// post-rename. See `NameBindings::stable`.
135    stable_agent_identity_confirmer: Option<AgentIdentityConfirmer>,
136    /// Identity M2: backed by `Controller::stable_agent_uid()`. Consulted
137    /// ONLY when the target resolved by UID, and `None` is "unverifiable"
138    /// (delivery proceeds) — never combined with the two name confirmers
139    /// above. A registered-but-unspawned persistent agent has a live name
140    /// (`set_agent_id` is called without a spawn) but no UID yet; running a
141    /// UID target through the name check would reject it as a mismatch
142    /// before the start-on-delivery fall-through could run (spec §4.4.3,
143    /// revision 4.0's P0).
144    uid_identity_confirmer: Option<AgentIdentityConfirmer>,
145    /// Identity M2: is this block still alive (has a controller)? The
146    /// garbage collector that replaces name eviction (spec §4.4.4 Q9):
147    /// when a name resolves to several blocks, the dead ones are swept
148    /// before ambiguity is decided. `None` (tests) treats every block as
149    /// live.
150    block_liveness: Option<BlockLivenessProbe>,
151    audit_log: Vec<AuditLogEntry>,
152    /// The UID of the delivery or Supervisor decision in flight
153    /// (`InjectionRequest::audit_source_uid`), copied onto every audit entry
154    /// it writes. Set and restored around one `inject_message_inner` or
155    /// `record_supervisor_decision` call, under this handler's mutex, so no
156    /// other delivery can see it.
157    delivery_source_uid: String,
158    /// The WAN verdict of the delivery in flight (W3-S audit), copied onto
159    /// its audit entries. Same set-and-restore discipline as
160    /// `delivery_source_uid`.
161    delivery_wan: Option<super::types::WanAudit>,
162    /// The LAN-claimed UID of the delivery in flight (identity M4d-6), same
163    /// set-and-restore discipline.
164    delivery_lan_claimed_uid: String,
165    rate_limiter: RateLimiter,
166    include_source_in_message: bool,
167    /// Warden Supervisor consecutive-nudge ceiling state, keyed on the
168    /// target agent's lowercased name. In-memory only (same lifecycle as
169    /// `audit_log`) — not persisted across a restart.
170    nudge_counters: HashMap<String, NudgeCounterState>,
171}
172
173/// A block's names, typed (identity M2, spec §4.4.2).
174///
175/// `display` is the name the block was most recently registered under —
176/// the Register-tail and the HTTP presence path supply it every time — and
177/// is **replaced** on re-registration, so a retired name does not linger and
178/// make a later agent ambiguous. `stable` is the `AGENTMUX_AGENT_ID` parked
179/// at spawn (what the alias map used to hold) and is **kept** across display
180/// changes: it is the value GitHub PR-body tags embed precisely because it
181/// does not change on rename (`INCIDENT_2026_09_09_JEKT_STABLE_ID_ALIAS.md`).
182#[derive(Debug, Clone, PartialEq, Eq)]
183pub struct NameBindings {
184    pub display: String,
185    pub stable: Option<String>,
186}
187
188impl NameBindings {
189    /// Every lowercased key this block is bound under.
190    fn keys(&self) -> Vec<String> {
191        let mut keys = vec![self.display.to_lowercase()];
192        if let Some(s) = &self.stable {
193            let k = s.to_lowercase();
194            if !keys.contains(&k) {
195                keys.push(k);
196            }
197        }
198        keys
199    }
200}
201
202/// What a by-name unregister did (identity M2, spec §4.4.4 Q7). A name held
203/// by several live blocks is refused, never silently no-op'd as success.
204#[derive(Debug, Clone)]
205pub enum UnregisterOutcome {
206    Removed {
207        block_id: String,
208        /// Every name the block was bound under, so the caller can tear
209        /// down name-keyed side state (file registry, cloud subscription)
210        /// for each of them, not just the display name.
211        names: Vec<String>,
212    },
213    NotFound,
214    Ambiguous(Vec<AgentRegistration>),
215}
216
217/// What a read-only name lookup found (identity M2) — see
218/// [`Handler::lookup_by_name`].
219#[derive(Debug, Clone)]
220pub enum LookupOutcome {
221    One(AgentRegistration),
222    Ambiguous(Vec<AgentRegistration>),
223    NotFound,
224}
225
226/// How `inject_message_inner` resolved its target (spec §4.4.3).
227enum TargetResolution {
228    Uid(String),
229    Name(String),
230    Ambiguous(Vec<AgentRegistration>),
231    NotFound,
232}
233
234/// Human-readable candidate list for an ambiguity refusal — the §5.2 shape:
235/// enough for a model to retry by UID and for a human to act on.
236fn describe_candidates(candidates: &[AgentRegistration]) -> String {
237    candidates
238        .iter()
239        .map(|c| {
240            format!(
241                "uid={} block={} name={}",
242                c.uid.as_deref().unwrap_or("none"),
243                c.block_id,
244                c.agent_id
245            )
246        })
247        .collect::<Vec<_>>()
248        .join("; ")
249}
250
251impl Handler {
252    /// Create a new handler without an input sender.
253    /// Call `set_input_sender` before injecting messages.
254    pub fn new() -> Self {
255        Self {
256            uid_to_block: HashMap::new(),
257            block_to_uid: HashMap::new(),
258            name_to_blocks: HashMap::new(),
259            block_names: HashMap::new(),
260            agent_info: HashMap::new(),
261            input_sender: None,
262            message_sender: None,
263            agent_identity_confirmer: None,
264            stable_agent_identity_confirmer: None,
265            uid_identity_confirmer: None,
266            block_liveness: None,
267            audit_log: Vec::with_capacity(AUDIT_LOG_MAX),
268            delivery_source_uid: String::new(),
269            delivery_wan: None,
270            delivery_lan_claimed_uid: String::new(),
271            rate_limiter: RateLimiter::new(RATE_LIMIT_MAX),
272            include_source_in_message: false,
273            nudge_counters: HashMap::new(),
274        }
275    }
276
277    /// Set the input sender function for message injection.
278    pub fn set_input_sender(&mut self, sender: InputSender) {
279        self.input_sender = Some(sender);
280    }
281
282    /// Set the controller-aware message sender. When present, `inject_message`
283    /// tries it first: persistent stream-json and ACP agents receive a structured
284    /// message on their live channel (mid-turn steering); PTY-based agents report
285    /// back so injection falls through to the keystroke path.
286    pub fn set_message_sender(&mut self, sender: MessageSender) {
287        self.message_sender = Some(sender);
288    }
289
290    /// Set the agent-identity confirmer used by `inject_message_inner` to
291    /// double-check a resolved delivery target against its own live,
292    /// spawn-time-captured identity before delivering. Optional — if never
293    /// set, delivery proceeds exactly as before this check existed (fail-open).
294    pub fn set_agent_identity_confirmer(&mut self, confirmer: AgentIdentityConfirmer) {
295        self.agent_identity_confirmer = Some(confirmer);
296    }
297
298    /// See `stable_agent_identity_confirmer`'s field doc comment.
299    pub fn set_stable_agent_identity_confirmer(&mut self, confirmer: AgentIdentityConfirmer) {
300        self.stable_agent_identity_confirmer = Some(confirmer);
301    }
302
303    /// See `uid_identity_confirmer`'s field doc comment (identity M2).
304    pub fn set_uid_identity_confirmer(&mut self, confirmer: AgentIdentityConfirmer) {
305        self.uid_identity_confirmer = Some(confirmer);
306    }
307
308    /// See `block_liveness`'s field doc comment (identity M2).
309    pub fn set_block_liveness(&mut self, probe: BlockLivenessProbe) {
310        self.block_liveness = Some(probe);
311    }
312
313    /// Set whether to include source agent prefix in injected messages.
314    #[allow(dead_code)]
315    pub fn set_include_source(&mut self, include: bool) {
316        self.include_source_in_message = include;
317    }
318
319    /// Register an agent with a block.
320    /// Name-only registration. Every production site now goes through
321    /// [`register_agent_full`] with its UID and counter site; this is the
322    /// test-facing form (and the shape the pre-M2 API had).
323    #[cfg_attr(not(test), allow(dead_code))]
324    pub fn register_agent(
325        &mut self,
326        agent_id: &str,
327        block_id: &str,
328        tab_id: Option<&str>,
329    ) -> Result<(), String> {
330        self.register_agent_full(
331            agent_id,
332            block_id,
333            tab_id,
334            0,
335            None,
336            None,
337            "registration.no_uid.unspecified",
338        )
339    }
340
341    /// [`register_agent`], recording the registering persistent-controller
342    /// spawn's process-wide registration nonce
343    /// (`AgentRegistration::registration_nonce`) so its own exit-handler
344    /// can later compare-and-remove ([`unregister_block_if_nonce`])
345    /// instead of blindly wiping a fallback respawn's fresh registration
346    /// (issue #2363).
347    ///
348    /// `alias`, if given, becomes the block's *stable* name binding
349    /// ([`NameBindings::stable`]): kept when a later call replaces the
350    /// display binding, so a jekt addressed to `AGENTMUX_AGENT_ID` still
351    /// resolves after the display name has moved on post-rename.
352    #[cfg_attr(not(test), allow(dead_code))]
353    pub fn register_agent_with_nonce(
354        &mut self,
355        agent_id: &str,
356        block_id: &str,
357        tab_id: Option<&str>,
358        registration_nonce: u64,
359        alias: Option<&str>,
360    ) -> Result<(), String> {
361        self.register_agent_full(
362            agent_id,
363            block_id,
364            tab_id,
365            registration_nonce,
366            alias,
367            None,
368            "registration.no_uid.unspecified",
369        )
370    }
371
372    /// The full registration (identity M2, spec §4.4.2): `display` is the
373    /// block's display binding, `stable` its stable binding (set or kept),
374    /// `uid` its identity when the caller knows it, and `no_uid_counter` the
375    /// §9.2 counter to bump when it does not.
376    ///
377    /// **Eviction is by identity, never by name:**
378    /// - `uid` already registered on another block → that block is evicted
379    ///   entirely (a respawn).
380    /// - a name already held by another block is left alone **unless**
381    ///   neither block has a UID — that case keeps today's name eviction
382    ///   exactly, and is counted.
383    /// - a block that has a UID keeps it when re-registered without one
384    ///   (sticky — a transient row miss on the presence path must not drop a
385    ///   live block out of `uid_to_block`); a block without one is upgraded
386    ///   in place when a later registration supplies one.
387    // Eight parameters because the three identity facets (display, stable,
388    // uid) plus the counter site are each optional independently; bundling
389    // them would move the same eight names into a struct literal at every
390    // call site without making any of them clearer.
391    #[allow(clippy::too_many_arguments)]
392    pub fn register_agent_full(
393        &mut self,
394        display: &str,
395        block_id: &str,
396        tab_id: Option<&str>,
397        registration_nonce: u64,
398        stable: Option<&str>,
399        uid: Option<&str>,
400        no_uid_counter: &'static str,
401    ) -> Result<(), String> {
402        // A display name that isn't a valid id ("Loap #3") binds by route name.
403        let display = &super::sanitize::registrable_id(display).ok_or_else(|| format!("invalid agent ID: {display}"))?;
404        let stable = stable.filter(|s| validate_agent_id(s));
405        let uid = uid
406            .map(str::trim)
407            .filter(|u| !u.is_empty() && validate_agent_id(u));
408
409        let mut evicted_block: Option<String> = None;
410        let mut evicted_agent: Option<String> = None;
411
412        // 1. Identity eviction: this UID on a different block is a respawn.
413        if let Some(uid) = uid {
414            if let Some(old) = self.uid_to_block.get(uid).cloned() {
415                if old != block_id {
416                    evicted_agent = self.block_names.get(&old).map(|n| n.display.clone());
417                    self.remove_block_bindings(&old);
418                    evicted_block = Some(old);
419                }
420            }
421        }
422
423        // 2. This block's effective UID: supplied, else whatever it already
424        //    had (sticky). A block that changes UID — rare: a reconfigured
425        //    pane, or a pane reused for a different agent whose spawn env
426        //    now carries the new row's id — drops its OLD `uid_to_block`
427        //    entry here, otherwise a delivery addressed to the old UID would
428        //    still resolve to this block and then fail the UID confirmer as
429        //    a spurious mismatch instead of a clean not-found (ReAgent P2 on
430        //    PR #3560).
431        let effective_uid: Option<String> = uid
432            .map(str::to_string)
433            .or_else(|| self.block_to_uid.get(block_id).cloned());
434        if let (Some(new_uid), Some(old_uid)) = (uid, self.block_to_uid.get(block_id).cloned()) {
435            if old_uid != new_uid {
436                tracing::info!(
437                    block_id = %block_id,
438                    old_uid = %old_uid,
439                    new_uid = %new_uid,
440                    "reactive: block re-registered under a different uid — dropping the old uid mapping (identity M2)"
441                );
442                if self
443                    .uid_to_block
444                    .get(&old_uid)
445                    .is_some_and(|b| b == block_id)
446                {
447                    self.uid_to_block.remove(&old_uid);
448                }
449            }
450        }
451        if effective_uid.is_none() {
452            crate::backend::agent_resolve::record_uid_fallback(no_uid_counter);
453        }
454
455        // 3. Name eviction — only when neither side has a UID (today's
456        //    semantics, counted). Two identified agents sharing a name both
457        //    stay; delivery to that name is then refused with candidates.
458        let prev = self.block_names.get(block_id).cloned();
459        let new_keys: Vec<String> = {
460            let mut k = vec![display.to_lowercase()];
461            if let Some(s) = stable {
462                let s = s.to_lowercase();
463                if !k.contains(&s) {
464                    k.push(s);
465                }
466            }
467            k
468        };
469        if effective_uid.is_none() {
470            for key in &new_keys {
471                let others: Vec<String> = self
472                    .name_to_blocks
473                    .get(key)
474                    .map(|v| v.iter().filter(|b| b.as_str() != block_id).cloned().collect())
475                    .unwrap_or_default();
476                for other in others {
477                    if self.block_to_uid.contains_key(&other) {
478                        continue;
479                    }
480                    if evicted_agent.is_none() {
481                        evicted_agent = self.block_names.get(&other).map(|n| n.display.clone());
482                    }
483                    self.remove_block_bindings(&other);
484                    if evicted_block.is_none() {
485                        evicted_block = Some(other);
486                    }
487                    crate::backend::agent_resolve::record_uid_fallback("registration.name_evicted_no_uid");
488                }
489            }
490        }
491
492        // 4. Replace the display binding; set or keep the stable one.
493        let stable_final: Option<String> = stable
494            .map(str::to_string)
495            .or_else(|| prev.as_ref().and_then(|p| p.stable.clone()));
496        if let Some(prev) = &prev {
497            for key in prev.keys() {
498                self.unbind_name(&key, block_id);
499            }
500            if prev.display.to_lowercase() != display.to_lowercase() && evicted_agent.is_none() {
501                evicted_agent = Some(prev.display.clone());
502            }
503        }
504        let bindings = NameBindings {
505            display: display.to_string(),
506            stable: stable_final,
507        };
508        for key in bindings.keys() {
509            self.bind_name(&key, block_id);
510        }
511        self.block_names.insert(block_id.to_string(), bindings);
512        if let Some(u) = &effective_uid {
513            self.uid_to_block.insert(u.clone(), block_id.to_string());
514            self.block_to_uid.insert(block_id.to_string(), u.clone());
515        }
516
517        let now = now_unix_millis();
518        self.agent_info.insert(
519            block_id.to_string(),
520            AgentRegistration {
521                agent_id: display.to_string(),
522                block_id: block_id.to_string(),
523                tab_id: tab_id.map(|s| s.to_string()),
524                registered_at: now,
525                last_seen: now,
526                registration_nonce,
527                uid: effective_uid,
528            },
529        );
530
531        self.log_audit_registration(
532            "register",
533            display,
534            block_id,
535            evicted_block.as_deref(),
536            evicted_agent.as_deref(),
537        );
538        super::held_jekt_wake().notify_one();
539
540        Ok(())
541    }
542
543    fn bind_name(&mut self, key: &str, block_id: &str) {
544        let blocks = self.name_to_blocks.entry(key.to_string()).or_default();
545        if !blocks.iter().any(|b| b == block_id) {
546            blocks.push(block_id.to_string());
547        }
548    }
549
550    fn unbind_name(&mut self, key: &str, block_id: &str) {
551        if let Some(blocks) = self.name_to_blocks.get_mut(key) {
552            blocks.retain(|b| b != block_id);
553            if blocks.is_empty() {
554                self.name_to_blocks.remove(key);
555            }
556        }
557    }
558
559    /// Drop every trace of `block_id` — names, UID, record. No audit entry;
560    /// callers log with the context they have.
561    fn remove_block_bindings(&mut self, block_id: &str) {
562        if let Some(names) = self.block_names.remove(block_id) {
563            for key in names.keys() {
564                self.unbind_name(&key, block_id);
565            }
566        }
567        if let Some(uid) = self.block_to_uid.remove(block_id) {
568            if self.uid_to_block.get(&uid).is_some_and(|b| b == block_id) {
569                self.uid_to_block.remove(&uid);
570            }
571        }
572        self.agent_info.remove(block_id);
573    }
574
575    /// Blocks bound to `key` (lowercased name) that are still alive, per the
576    /// liveness probe. Read-only: does not sweep. `None` probe = all live.
577    fn live_blocks_for_key(&self, key: &str) -> Vec<String> {
578        let Some(blocks) = self.name_to_blocks.get(key) else {
579            return Vec::new();
580        };
581        match &self.block_liveness {
582            Some(probe) => blocks.iter().filter(|b| probe(b)).cloned().collect(),
583            None => blocks.clone(),
584        }
585    }
586
587    /// Does any live block hold `name` (case-insensitive, like the cloud
588    /// subscription's keys)? An exit's own nonce check can't answer this:
589    /// it reports `false` both when a newer spawn owns the registration and
590    /// when nobody holds it any more.
591    pub fn has_live_name(&self, name: &str) -> bool {
592        !self.live_blocks_for_key(&name.to_lowercase()).is_empty()
593    }
594
595    /// Is `block_id` alive per the liveness probe? `None` probe = live.
596    fn is_live(&self, block_id: &str) -> bool {
597        self.block_liveness
598            .as_ref()
599            .is_none_or(|probe| probe(block_id))
600    }
601
602    /// Drop a registration whose block has no controller (spec §4.4.4 Q9).
603    fn sweep_dead(&mut self, block_id: &str) {
604        let display_name = self
605            .block_names
606            .get(block_id)
607            .map(|n| n.display.clone())
608            .unwrap_or_default();
609        tracing::info!(
610            block_id = %block_id,
611            name = %display_name,
612            "reactive: sweeping dead registration while resolving a contested name (identity M2)"
613        );
614        self.remove_block_bindings(block_id);
615        self.log_audit_registration("swept", &display_name, block_id, None, None);
616        crate::backend::agent_resolve::record_uid_fallback("registry.swept_dead");
617    }
618
619    /// Read-only name resolution shared by [`get_agent`], [`lookup_by_name`]
620    /// and [`peek_target`]: `Ok(Some(block))` for a UID hit or a name held
621    /// by exactly one live block, `Ok(None)` for nothing, `Err(blocks)` for
622    /// a name several live blocks hold. Liveness is consulted **only when
623    /// the name is contested** (spec §4.4.4 Q9): an uncontested registration
624    /// is never second-guessed, so a block that is momentarily between
625    /// controllers (a `resync_controller` replace, or a frontend register
626    /// that beats the resync) is not answered as "not found" — which would
627    /// forward a purely local miss to other instances.
628    fn read_resolve(&self, target: &str) -> Result<Option<String>, Vec<String>> {
629        if let Some(block) = self
630            .uid_to_block
631            .get(target)
632            .or_else(|| self.uid_to_block.get(&target.to_lowercase()))
633        {
634            return Ok(Some(block.clone()));
635        }
636        let key = target.to_lowercase();
637        let blocks = self.name_to_blocks.get(&key).cloned().unwrap_or_default();
638        match blocks.len() {
639            0 => Ok(None),
640            1 => Ok(Some(blocks[0].clone())),
641            _ => {
642                let live: Vec<String> = blocks.into_iter().filter(|b| self.is_live(b)).collect();
643                match live.len() {
644                    0 => Ok(None),
645                    1 => Ok(Some(live[0].clone())),
646                    _ => Err(live),
647                }
648            }
649        }
650    }
651
652    /// Resolve a delivery target (spec §4.4.3): UID first, then a name held
653    /// by exactly one live block — sweeping dead candidates only when the
654    /// name is contested (§4.4.4 Q9) — refusing an ambiguous name with its
655    /// candidates rather than guessing. Counts what it did (§9.2).
656    fn resolve_target(&mut self, target: &str) -> TargetResolution {
657        if let Some(block) = self
658            .uid_to_block
659            .get(target)
660            .or_else(|| self.uid_to_block.get(&target.to_lowercase()))
661        {
662            return TargetResolution::Uid(block.clone());
663        }
664        let key = target.to_lowercase();
665        let blocks = self.name_to_blocks.get(&key).cloned().unwrap_or_default();
666        if blocks.is_empty() {
667            return TargetResolution::NotFound;
668        }
669        let live: Vec<String> = if blocks.len() == 1 {
670            blocks
671        } else {
672            let mut live = Vec::with_capacity(blocks.len());
673            for block in blocks {
674                if self.is_live(&block) {
675                    live.push(block);
676                } else {
677                    self.sweep_dead(&block);
678                }
679            }
680            live
681        };
682        match live.len() {
683            0 => TargetResolution::NotFound,
684            1 => {
685                crate::backend::agent_resolve::record_uid_fallback("delivery.resolved_by_name");
686                TargetResolution::Name(live.into_iter().next().unwrap())
687            }
688            _ => {
689                crate::backend::agent_resolve::record_uid_fallback("delivery.ambiguous");
690                let candidates = live
691                    .iter()
692                    .filter_map(|b| self.agent_info.get(b).cloned())
693                    .collect();
694                TargetResolution::Ambiguous(candidates)
695            }
696        }
697    }
698
699    /// The block a target would resolve to, without counting or sweeping —
700    /// for callers that resolve *before* handing off to `inject_message_inner`,
701    /// which resolves (and counts) for real. `None` for unknown or ambiguous.
702    fn peek_target(&self, target: &str) -> Option<String> {
703        self.read_resolve(target).ok().flatten()
704    }
705
706    /// Name lookup that tells an ambiguous name apart from an unknown one
707    /// (identity M2) — for callers that must refuse with candidates rather
708    /// than fall through to cross-instance forwarding (`GetAgentTranscript`).
709    pub fn lookup_by_name(&self, name: &str) -> LookupOutcome {
710        match self.read_resolve(name) {
711            Ok(Some(block)) => match self.agent_info.get(&block) {
712                Some(reg) => LookupOutcome::One(reg.clone()),
713                None => LookupOutcome::NotFound,
714            },
715            Ok(None) => LookupOutcome::NotFound,
716            Err(blocks) => {
717                crate::backend::agent_resolve::record_uid_fallback("lookup.ambiguous_name");
718                LookupOutcome::Ambiguous(
719                    blocks
720                        .iter()
721                        .filter_map(|b| self.agent_info.get(b).cloned())
722                        .collect(),
723                )
724            }
725        }
726    }
727
728    /// Unregister by name (identity M2, spec §4.4.4 Q7). One live block →
729    /// removed. Several → nothing is removed and the candidates are
730    /// returned; the caller must not report success. None → `NotFound`.
731    pub fn unregister_agent(&mut self, agent_id: &str) -> UnregisterOutcome {
732        let key = super::sanitize::route_name(agent_id).unwrap_or_default().to_lowercase();
733        let live = self.live_blocks_for_key(&key);
734        match live.len() {
735            0 => UnregisterOutcome::NotFound,
736            1 => {
737                let block_id = live[0].clone();
738                let names = self.unregister_block(&block_id);
739                UnregisterOutcome::Removed { block_id, names }
740            }
741            _ => {
742                crate::backend::agent_resolve::record_uid_fallback(
743                    "unregister.ambiguous_without_block",
744                );
745                let candidates = live
746                    .iter()
747                    .filter_map(|b| self.agent_info.get(b).cloned())
748                    .collect::<Vec<_>>();
749                tracing::warn!(
750                    name = %agent_id,
751                    candidates = %describe_candidates(&candidates),
752                    "reactive: refusing to unregister an ambiguous name without a block id"
753                );
754                UnregisterOutcome::Ambiguous(candidates)
755            }
756        }
757    }
758
759    /// Unregister by block ID. Returns every name the block was bound under
760    /// (empty if it was not registered) so name-keyed side state can be
761    /// torn down for each.
762    pub fn unregister_block(&mut self, block_id: &str) -> Vec<String> {
763        let Some(names) = self.block_names.get(block_id).cloned() else {
764            return Vec::new();
765        };
766        let display = names.display.clone();
767        self.remove_block_bindings(block_id);
768        self.log_audit_registration("unregister", &display, block_id, None, None);
769        let mut out = vec![display];
770        if let Some(s) = names.stable {
771            if !out.iter().any(|n| n.eq_ignore_ascii_case(&s)) {
772                out.push(s);
773            }
774        }
775        out
776    }
777
778    /// Unregister by block ID **only if** the current registration was
779    /// written by the spawn with `expected_nonce` — a
780    /// compare-and-remove for persistent-controller exit-handlers (issue
781    /// #2363: the handler's `is_current_generation` gate is read once,
782    /// while a fallback respawn re-registers on a parallel task; an
783    /// unconditional [`unregister_block`] here could wipe the NEW spawn's
784    /// registration, leaving the live agent invisible to Tier-1 delivery
785    /// with nothing left to re-register it). The nonce is process-wide
786    /// unique, so the guard also holds across controller replacement
787    /// (`resync_controller` — codex P1 on PR #2500), where a
788    /// controller-local generation would restart at 1 and collide. Runs
789    /// atomically under the handler's own lock (via the outer wrapper).
790    /// A registration with no recorded nonce (0 — HTTP/PTY paths) is
791    /// never removed by this variant: leaving a stale entry to the TTL
792    /// sweep is strictly safer than deleting a live one.
793    ///
794    /// Returns true if the registration was ours and was removed.
795    pub fn unregister_block_if_nonce(&mut self, block_id: &str, expected_nonce: u64) -> bool {
796        let Some(info) = self.agent_info.get(block_id) else {
797            return false;
798        };
799        let matches = expected_nonce != 0 && info.registration_nonce == expected_nonce;
800        if !matches {
801            tracing::info!(
802                block_id = %block_id,
803                expected_nonce = expected_nonce,
804                "reactive: registration changed hands since this spawn registered — skipping unregister"
805            );
806            return false;
807        }
808        self.unregister_block(block_id);
809        true
810    }
811
812    /// Update the last_seen timestamp for an agent.
813    #[allow(dead_code)]
814    pub fn update_last_seen(&mut self, agent_id: &str) {
815        let now = now_unix_millis();
816        for block in self.live_blocks_for_key(&agent_id.to_lowercase()) {
817            if let Some(info) = self.agent_info.get_mut(&block) {
818                info.last_seen = now;
819            }
820        }
821    }
822
823    /// Get agent registration by agent ID — a UID, or a name held by
824    /// exactly one live block. A name held by several is `None`, counted
825    /// (`lookup.ambiguous_name`): callers that authorize on it fail closed,
826    /// and callers that display on it show nothing rather than the wrong one.
827    pub fn get_agent(&self, agent_id: &str) -> Option<&AgentRegistration> {
828        match self.read_resolve(agent_id) {
829            Ok(Some(block)) => self.agent_info.get(&block),
830            Ok(None) => None,
831            Err(_) => {
832                crate::backend::agent_resolve::record_uid_fallback("lookup.ambiguous_name");
833                None
834            }
835        }
836    }
837
838    /// Whether `uid` has a live registration — a lookup with no side effect
839    /// (no counter, no rate-limit token, no audit entry), for the held-jekt
840    /// replay's presence check.
841    pub fn has_uid_registration(&self, uid: &str) -> bool {
842        self.uid_to_block.contains_key(uid)
843    }
844
845    /// The block `uid` is registered on, with no side effect — for the
846    /// held-jekt replay's quiet sign-in check.
847    pub fn block_for_uid(&self, uid: &str) -> Option<String> {
848        self.uid_to_block.get(uid).cloned()
849    }
850
851    /// Deliver a held jekt (durable jekt spec §2.3): the ordinary delivery
852    /// path, audited as `held_delivered` (or `held_retry` on failure).
853    pub fn inject_held(&mut self, req: InjectionRequest) -> InjectionResponse {
854        self.inject_message_inner(req, Some("held_delivered"), Some("held_retry"), None)
855    }
856
857    /// Get agent registration by block ID.
858    #[allow(dead_code)]
859    pub fn get_agent_by_block(&self, block_id: &str) -> Option<&AgentRegistration> {
860        self.agent_info.get(block_id)
861    }
862
863    /// Every name `block_id` is bound under (display first, then stable).
864    /// Production teardown gets the same list back from [`unregister_block`];
865    /// this read-only form is what registration subscribes for cloud delivery.
866    pub fn names_for_block(&self, block_id: &str) -> Vec<String> {
867        let Some(names) = self.block_names.get(block_id) else {
868            return Vec::new();
869        };
870        let mut out = vec![names.display.clone()];
871        if let Some(s) = &names.stable {
872            if !out.iter().any(|n| n.eq_ignore_ascii_case(s)) {
873                out.push(s.clone());
874            }
875        }
876        out
877    }
878
879    /// List all registered agents.
880    pub fn list_agents(&self) -> Vec<AgentRegistration> {
881        self.agent_info.values().cloned().collect()
882    }
883
884    /// Inject a message into an agent's terminal.
885    ///
886    /// Sends `message\r` as a single payload (required for text display),
887    /// then spawns 3 delayed `\r` sends at 200ms intervals as separate
888    /// PTY writes to ensure submission. See `docs/specs/jekt-inject-timing.md`.
889    pub fn inject_message(&mut self, req: InjectionRequest) -> InjectionResponse {
890        self.inject_message_inner(req, None, None, None)
891    }
892
893    /// Shared delivery path behind both `inject_message` (ordinary jekt,
894    /// every `outcome`/`reason` param always `None`) and
895    /// `record_supervisor_decision`'s `Nudge` arm (`outcome_on_success:
896    /// Some("nudge_sent")`, `outcome_on_failure: Some("nudge_failed")`,
897    /// `reason` the Supervisor's stated reasoning) — every audit-log write
898    /// below carries these through instead of the two call sites
899    /// duplicating sanitize/deliver logic. Two separate outcome params
900    /// (rather than one applied uniformly) so a failed delivery is audited
901    /// distinctly from a successful one (reagentx P2 on PR #2557 — the
902    /// Supervisor UI's decision feed must not show "nudged" for a delivery
903    /// that actually failed).
904    fn inject_message_inner(
905        &mut self,
906        mut req: InjectionRequest,
907        outcome_on_success: Option<&str>,
908        outcome_on_failure: Option<&str>,
909        reason: Option<&str>,
910    ) -> InjectionResponse {
911        let uid = std::mem::take(&mut req.audit_source_uid);
912        let outer = std::mem::replace(&mut self.delivery_source_uid, uid);
913        let outer_wan = std::mem::replace(&mut self.delivery_wan, super::types::WanAudit::from_request(&req));
914        let outer_lan = std::mem::replace(&mut self.delivery_lan_claimed_uid, std::mem::take(&mut req.lan_claimed_uid));
915        let resp = self.deliver_audited(req, outcome_on_success, outcome_on_failure, reason);
916        // Restore, not clear: a Supervisor nudge runs this inside its own
917        // decision, whose UID stays set for the rest of it.
918        self.delivery_source_uid = outer;
919        self.delivery_wan = outer_wan;
920        self.delivery_lan_claimed_uid = outer_lan;
921        resp
922    }
923
924    /// The body of [`Self::inject_message_inner`], with the sender's UID in
925    /// `delivery_source_uid` for every audit entry it writes.
926    fn deliver_audited(
927        &mut self,
928        mut req: InjectionRequest,
929        outcome_on_success: Option<&str>,
930        outcome_on_failure: Option<&str>,
931        reason: Option<&str>,
932    ) -> InjectionResponse {
933        let now = now_unix_millis();
934
935        // Only the trusted production key makes a verified reagent sender.
936        // The verifiers already apply this; enforce it here too for any
937        // caller that sets `reagent_verified` directly.
938        if req.reagent_verified == Some(true)
939            && !req
940                .reagent_key_id
941                .as_deref()
942                .is_some_and(agentmux_common::jekt_sign::is_reagent_trusted_signing_key)
943        {
944            req.reagent_verified = Some(false);
945        }
946
947        // Generate request ID if missing
948        if req.request_id.is_none() || req.request_id.as_deref() == Some("") {
949            req.request_id = Some(uuid::Uuid::new_v4().to_string());
950        }
951        let request_id = req.request_id.clone().unwrap_or_default();
952
953        // Rate limit check
954        if !self.rate_limiter.check() {
955            return InjectionResponse {
956                deferred: None,
957                success: false,
958                request_id,
959                block_id: None,
960                error: Some("rate limit exceeded".to_string()),
961                timestamp: now,
962                effective_tier: None,
963                requires_stop: None,
964                channel_verified: None,
965            };
966        }
967
968        // Validate agent ID; a display name as the target means its route name.
969        req.target_agent = super::sanitize::route_name(&req.target_agent).unwrap_or_default();
970        if !validate_agent_id(&req.target_agent) {
971            return InjectionResponse {
972                deferred: None,
973                success: false,
974                request_id,
975                block_id: None,
976                error: Some(format!("invalid agent ID: {}", req.target_agent)),
977                timestamp: now,
978                effective_tier: None,
979                requires_stop: None,
980                channel_verified: None,
981            };
982        }
983
984        // Sanitize message
985        let sanitized = sanitize_message(&req.message);
986
987        // Resolve the target (identity M2, spec §4.4.3): UID first, then a
988        // name held by exactly one live block. A name held by several is
989        // refused WITH the candidates — the only place a collision is
990        // visible is the only place it can be resolved (§5.2). The error
991        // string deliberately does not begin with "agent not found": that
992        // prefix is what `handle_reactive_inject` forwards to other
993        // instances, and a local collision must not be forwarded.
994        let (block_id, resolved_by_uid) = match self.resolve_target(&req.target_agent) {
995            TargetResolution::Uid(b) => (b, true),
996            TargetResolution::Name(b) => (b, false),
997            TargetResolution::Ambiguous(candidates) => {
998                let err = format!(
999                    "ambiguous agent name: '{}' is held by {} live agents — address one by uid: {}",
1000                    req.target_agent,
1001                    candidates.len(),
1002                    describe_candidates(&candidates)
1003                );
1004                self.log_audit(
1005                    req.source_agent.as_deref(),
1006                    &req.target_agent,
1007                    "",
1008                    &sanitized,
1009                    false,
1010                    Some(&err),
1011                    &request_id,
1012                    outcome_on_failure,
1013                    reason,
1014                );
1015                return InjectionResponse {
1016                    deferred: None,
1017                    success: false,
1018                    request_id,
1019                    block_id: None,
1020                    error: Some(err),
1021                    timestamp: now,
1022                    effective_tier: None,
1023                    requires_stop: None,
1024                    channel_verified: None,
1025                };
1026            }
1027            TargetResolution::NotFound => {
1028                let err = format!("agent not found: {}", req.target_agent);
1029                self.log_audit(
1030                    req.source_agent.as_deref(),
1031                    &req.target_agent,
1032                    "",
1033                    &sanitized,
1034                    false,
1035                    Some(&err),
1036                    &request_id,
1037                    outcome_on_failure,
1038                    reason,
1039                );
1040                return InjectionResponse {
1041                    deferred: None,
1042                    success: false,
1043                    request_id,
1044                    block_id: None,
1045                    error: Some(err),
1046                    timestamp: now,
1047                    effective_tier: None,
1048                    requires_stop: None,
1049                    channel_verified: None,
1050                };
1051            }
1052        };
1053
1054        // Identity M2: a target that resolved by UID is confirmed by UID
1055        // only (`uid_identity_confirmer`); `None` is unverifiable and
1056        // delivery proceeds — see that field's doc comment for the
1057        // registered-but-unspawned case this protects. The name checks
1058        // below apply only to targets that resolved by name.
1059        if resolved_by_uid {
1060            let own_uid = self
1061                .uid_identity_confirmer
1062                .as_ref()
1063                .and_then(|c| c(&block_id));
1064            if let Some(own_uid) = own_uid {
1065                if !own_uid.eq_ignore_ascii_case(&req.target_agent) {
1066                    let err = format!(
1067                        "identity mismatch: block {} resolved for uid '{}' but its own uid is '{}'",
1068                        block_id, req.target_agent, own_uid
1069                    );
1070                    tracing::error!(
1071                        target = %req.target_agent,
1072                        block_id = %block_id,
1073                        own_uid = %own_uid,
1074                        "reactive inject: recipient uid mismatch — rejecting delivery"
1075                    );
1076                    self.log_audit(
1077                        req.source_agent.as_deref(),
1078                        &req.target_agent,
1079                        &block_id,
1080                        &sanitized,
1081                        false,
1082                        Some(&err),
1083                        &request_id,
1084                        Some("identity-mismatch"),
1085                        reason,
1086                    );
1087                    return InjectionResponse {
1088                        deferred: None,
1089                        success: false,
1090                        request_id,
1091                        block_id: Some(block_id),
1092                        error: Some(err),
1093                        timestamp: now,
1094                        effective_tier: None,
1095                        requires_stop: None,
1096                        channel_verified: None,
1097                    };
1098                }
1099            }
1100        }
1101
1102        // Recipient-identity check (issue #2695): before delivering, compare
1103        // the resolved block's own live, spawn-time-captured identity
1104        // (queried via `agent_identity_confirmer`, which goes straight to
1105        // the controller — independent of this handler's own agent_to_block
1106        // map) against who this jekt is addressed to. Checking agent_to_block
1107        // against itself would be a tautology and catch nothing; this is a
1108        // genuine second, independently-written source of truth that can
1109        // drift from the registry (e.g. a stale registry entry survived a
1110        // respawn, or a resync overwrote registration without updating the
1111        // block's own field).
1112        //
1113        // `None` (no confirmer configured, or this controller type doesn't
1114        // implement `agent_id()`) is "unverifiable," not "confirmed absent" —
1115        // delivery proceeds exactly as before this check existed. Matches
1116        // this codebase's existing jekt trust philosophy elsewhere (see the
1117        // TRUST=/ESCALATE= rules): only an ACTIVE mismatch is a red flag,
1118        // mere absence of proof is not.
1119        //
1120        // Checked against BOTH the live identity (`agent_identity_confirmer`)
1121        // and the stable one (`stable_agent_identity_confirmer`) — a target
1122        // resolved via the alias registry is legitimately the stable ID, not
1123        // the live display name, and would otherwise always fail this check
1124        // once the primary key has moved on post-rename. Either source
1125        // reporting a match is sufficient; both reporting `None` is
1126        // "unverifiable" exactly as before.
1127        if !resolved_by_uid
1128            && (self.agent_identity_confirmer.is_some()
1129                || self.stable_agent_identity_confirmer.is_some())
1130        {
1131            let live_agent_id = self
1132                .agent_identity_confirmer
1133                .as_ref()
1134                .and_then(|c| c(&block_id));
1135            let stable_agent_id = self
1136                .stable_agent_identity_confirmer
1137                .as_ref()
1138                .and_then(|c| c(&block_id));
1139            // Both `None` is "unverifiable" (see above) — only proceed to
1140            // the check below if at least one source reported an identity.
1141            if live_agent_id.is_some() || stable_agent_id.is_some() {
1142                let target_lower = req.target_agent.to_lowercase();
1143                let matches = |id: &Option<String>| {
1144                    id.as_deref().is_some_and(|id| id.to_lowercase() == target_lower)
1145                };
1146                if !matches(&live_agent_id) && !matches(&stable_agent_id) {
1147                    let err = format!(
1148                        "identity mismatch: block {} resolved for target '{}' but its own identity is '{}'",
1149                        block_id,
1150                        req.target_agent,
1151                        live_agent_id
1152                            .as_deref()
1153                            .or(stable_agent_id.as_deref())
1154                            .unwrap_or("<unknown>")
1155                    );
1156                    tracing::error!(
1157                        target = %req.target_agent,
1158                        block_id = %block_id,
1159                        live_agent_id = ?live_agent_id,
1160                        stable_agent_id = ?stable_agent_id,
1161                        "reactive inject: recipient identity mismatch — rejecting delivery"
1162                    );
1163                    self.log_audit(
1164                        req.source_agent.as_deref(),
1165                        &req.target_agent,
1166                        &block_id,
1167                        &sanitized,
1168                        false,
1169                        Some(&err),
1170                        &request_id,
1171                        Some("identity-mismatch"),
1172                        reason,
1173                    );
1174                    return InjectionResponse {
1175                        deferred: None,
1176                        success: false,
1177                        request_id,
1178                        block_id: Some(block_id),
1179                        error: Some(err),
1180                        timestamp: now,
1181                        effective_tier: None,
1182                        requires_stop: None,
1183                        channel_verified: None,
1184                    };
1185                }
1186            }
1187        }
1188
1189        // Determine effective jekt tier.
1190        // Escalation rules (spec §5.2, extended by
1191        // SPEC_JEKT_TRUST_LAYER_COMPLETION_2026_08_13.md §2.2, by
1192        // SPEC_JEKT_REAGENT_TRUST_RELAXATION_2026_08_14.md §1 for the
1193        // network-tier exception below, and NARROWED by
1194        // SPEC_JEKT_SENSITIVE_TIER_NARROWING_2026_08_15.md — repo-owner-
1195        // confirmed directly in a live conversation, not a jekt/muxbus claim):
1196        //   1. WAN or LAN delivery, sender's identity check ACTIVELY FAILED
1197        //      (a reagent_sig was present but didn't verify — SIG=invalid) →
1198        //      always SENSITIVE, regardless of declared tier or keyword
1199        //      content. A real red flag: someone tried to forge a signature.
1200        //      Mere ABSENCE of any signature attempt (no reagent_sig sent at
1201        //      all, or one verified only under the known-exposed dev key) is
1202        //      NOT this case — that's rule 5's default, same as any other
1203        //      self-declared sender. LAN never carries a signature attempt
1204        //      at all, so it's never eligible for this rule either — see
1205        //      rules 3/4 for what still catches a malicious LAN jekt.
1206        //   1b. WAN delivery, sender verified via reagent's pinned Ed25519
1207        //      key (`reagent_verified == Some(true)`) → NOT forced to
1208        //      SENSITIVE by delivery tier alone. As of the 2026-08-15
1209        //      narrowing this is NOT a distinct check anymore — it's simply
1210        //      rule 1 not matching (`Some(true)` isn't `Some(false)`).
1211        //      `Some(true)` only ever means the trusted `reagent-v1` key:
1212        //      the verifiers use `verify_trusted_reagent_jekt`, and
1213        //      `deliver_audited` downgrades any other key to `Some(false)`.
1214        //      Rules 3/4 below still apply on top: a verified reagent
1215        //      message that declares SENSITIVE or matches the keyword scan
1216        //      still escalates.
1217        //   2. Host delivery, sender identity checkable but signature missing
1218        //      or wrong → always SENSITIVE (host-tier senders can now be
1219        //      verified when the claimed source_agent has a signing key —
1220        //      see `sig_verified`'s doc comment on `InjectionRequest` for
1221        //      exactly when this applies vs. is skipped). Same "an active
1222        //      verification FAILURE is the red flag, not mere absence of
1223        //      one" logic as rule 1 — this rule was already scoped that way.
1224        //   3. Declared SENSITIVE (any tier) → SENSITIVE.
1225        //   4. Keyword match (any tier) → SENSITIVE.
1226        //   5. Otherwise → use declared tier (default: coord). This is now
1227        //      reachable by ordinary unverified LAN/WAN traffic with clean
1228        //      content — `TRUST` in the marker is UNCHANGED by this
1229        //      narrowing (still reads `network-claimed`, still exactly as
1230        //      forgeable as ever); only whether that lack of proof alone
1231        //      is sufficient grounds to interrupt the human has changed.
1232        let declared_tier = req.jekt_tier.as_ref();
1233        let delivery_tier = req.delivery_tier.as_deref().unwrap_or("host");
1234        let is_network_tier = delivery_tier == "wan" || delivery_tier == "lan";
1235        // A reagent_sig that was PRESENT but didn't verify — someone tried to
1236        // forge it. `reagent_verified` is WAN-only by construction
1237        // (`sync_agent_reactive`/`verify_reagent_signature` never compute it
1238        // off the WAN tier, so it's always `None` for LAN — reagent is a
1239        // WAN-only service sender, this never applied to LAN). A signature
1240        // that verified only under a key other than the trusted production
1241        // key IS this case (it arrives as `Some(false)`, see
1242        // `deliver_audited`); absence of a signature attempt (`None`) falls
1243        // through to rule 5 like any other self-declared sender.
1244        let is_network_tier_sig_invalid = is_network_tier && req.reagent_verified == Some(false);
1245        // A lan_sig that was PRESENT, whose claimed sender's public key WAS
1246        // found, but didn't cryptographically verify — a specific agent's
1247        // identity was actively forged, not merely unproven. Scoped to LAN
1248        // only, same reasoning as reagent's WAN scoping above — see
1249        // docs/specs/SPEC_JEKT_LAN_TIER_SIGNING_2026_08_15.md §2.4/§2.5.
1250        let is_lan_sig_invalid = delivery_tier == "lan" && req.lan_verified == Some(false);
1251        let is_unverified_sender = req.sig_verified == Some(false);
1252        // W3-S (SPEC_WAN_JEKT_VERIFICATION_2026_09_24.md §2.6): a same-account
1253        // agent's WAN signature that actively failed (envelope, certificate
1254        // chain, record match, signature or replay), or one from an instance
1255        // its owner has revoked — the key is known to be out of its owner's
1256        // control — is forced sensitive, like `SIG=invalid`.
1257        let wan_instance_status = req.wan_instance.as_ref().map(|i| i.status);
1258        let is_wan_sig_invalid = delivery_tier == "wan"
1259            && (req.wan_verified == Some(false)
1260                || (req.wan_verified == Some(true)
1261                    && wan_instance_status == Some(super::types::WanInstanceStatus::Revoked)));
1262        // SPEC_JEKT_TRANSCRIPT_REQUEST_TIER_RULES_2026_08_22.md rule 1: a
1263        // transcript_request (muxspect Phase B/C's LAN/WAN conversation-
1264        // visibility protocol) is forced sensitive unconditionally, on any
1265        // tier, regardless of trust — the existing credential/destructive-
1266        // keyword scan (is_sensitive_message) doesn't catch a content-
1267        // disclosure *request* at all. `req.is_transcript_request` is
1268        // server-computed in `handle_reactive_inject` by re-parsing
1269        // `message` itself (never attacker-settable — see its own doc
1270        // comment), so this can't be spoofed by a client claiming/denying it.
1271        let is_sensitive = is_network_tier_sig_invalid
1272            || is_lan_sig_invalid
1273            || is_wan_sig_invalid
1274            || is_unverified_sender
1275            || matches!(declared_tier, Some(super::types::JektTier::Sensitive))
1276            || is_sensitive_message(&sanitized)
1277            || req.is_transcript_request;
1278        let effective_tier = if is_sensitive { "sensitive" } else {
1279            declared_tier.map_or("coord", |t| match t {
1280                super::types::JektTier::Info => "info",
1281                super::types::JektTier::Coord => "coord",
1282                super::types::JektTier::Sensitive => "sensitive",
1283            })
1284        };
1285        // Whether TIER=sensitive should actually STOP the receiving agent and
1286        // require human confirmation, vs. just carry the tag for visual
1287        // indication (SPEC_JEKT_SENSITIVE_TIER_VERIFIED_SENDER_NO_STOP_2026_08_17.md,
1288        // repo-owner-confirmed directly in a live conversation, same channel
1289        // this policy's own STOP rule already treats as authoritative).
1290        //
1291        // Cryptographic proof of identity is exactly what the STOP rule was
1292        // protecting against the ABSENCE of (the spoofed-jekt-then-spoofed-
1293        // muxbus-confirmation incident this whole tiering system exists to
1294        // stop). Once a sender is actually verified — `sig_verified`,
1295        // `reagent_verified`, or `lan_verified` all being `Some(true)` — that
1296        // specific attack is no longer possible for this message, regardless
1297        // of WHY it was marked sensitive (self-declared, or a keyword match
1298        // on content a genuinely-signed sender is allowed to legitimately
1299        // discuss, e.g. a code review of credential-handling code).
1300        //
1301        // This can never accidentally cover an active-forgery case: the three
1302        // rules above that force `is_sensitive` from a signature actively
1303        // failing (`is_network_tier_sig_invalid`, `is_lan_sig_invalid`,
1304        // `is_unverified_sender`) are each keyed on the SAME field this
1305        // checks for `Some(true)` reading `Some(false)` instead — the two can
1306        // never both be true for the same field at once, so a message that
1307        // reaches STOP-required via one of those three rules is, by
1308        // construction, never simultaneously "verified" on that same tier.
1309        //
1310        // `channel_verified` (SPEC_JEKT_CROSS_CHANNEL_TRUST_2026_09_02.md
1311        // §D3, Phase B) joins the list on the same terms: a cross-channel
1312        // signature that verified against the sender's published public
1313        // key is proof of exactly who sent it. Its `Some(false)` is NOT yet
1314        // among the forcing rules above — Phase C, deliberately held back
1315        // until published keys have propagated (spec §6/§10) — so today
1316        // this field can only ever relax, never escalate.
1317        //
1318        // `wan_verified` (W3-S §2.6, amended 2026-09-26) joins for any
1319        // verified same-account install that isn't revoked — `approved` or
1320        // `new`. The signature proves which install of this account sent it.
1321        // Until #3881 every agent held the account login and could mint an
1322        // install, so only a human-approved one relaxed anything; the
1323        // operator chose to trust same-account installs once agents no
1324        // longer hold it (a same-user process reading the srv's stored login
1325        // from disk is the accepted residual, spec open question 5).
1326        // `revoked` is forcing, above; a verdict with no instance proves no
1327        // install and relaxes nothing.
1328        let is_cryptographically_verified = req.sig_verified == Some(true)
1329            || req.reagent_verified == Some(true)
1330            || req.lan_verified == Some(true)
1331            || req.channel_verified == Some(true)
1332            || (delivery_tier == "wan"
1333                && req.wan_verified == Some(true)
1334                && matches!(
1335                    wan_instance_status,
1336                    Some(super::types::WanInstanceStatus::Approved | super::types::WanInstanceStatus::New)
1337                ));
1338        // SPEC_JEKT_TRANSCRIPT_REQUEST_TIER_RULES_2026_08_22.md rule 2: the
1339        // ONE named exception to the verified-sender relaxation above. A
1340        // transcript_request's ESCALATE=required is not relaxed by a
1341        // verified sender when the RESPONDING agent's own
1342        // conversation_visibility is `ask` (or `trusted_peers` with a
1343        // non-allow-listed requester) — `req.transcript_request_escalate_forced`,
1344        // resolved server-side by `resolve_transcript_request_tier_fields`
1345        // against that agent's own setting, called from both delivery paths
1346        // that reach `Handler` (`handle_reactive_inject` for
1347        // host/cross-channel/LAN, `muxbus::cloud_subscriber::sync_agent_reactive`
1348        // for WAN) — meaningless/always-false unless `is_transcript_request`
1349        // is also true. A valid signature answers
1350        // *who is asking*, not *whether this content should be disclosed* —
1351        // identity proof alone doesn't relax that question, unlike every
1352        // other TIER=sensitive case above.
1353        let requires_stop = is_sensitive
1354            && (!is_cryptographically_verified || req.transcript_request_escalate_forced);
1355        let priority = req.priority.as_deref().unwrap_or("normal");
1356
1357        // Wrap in JEKT marker block (structured tag + human-readable header).
1358        // Note: `req.sig_verified` (three-state) is passed through as-is for
1359        // the marker's TRUST label — `is_unverified_sender` above only
1360        // captures the `Some(false)` case (the one that forces SENSITIVE);
1361        // the marker itself also needs to distinguish `None` ("never
1362        // checked," e.g. a Slack-bridge message) from `Some(true)`
1363        // ("actually verified") rather than collapsing both to the same
1364        // label — see `wrap_jekt_message`'s doc comment.
1365        let wrapped = wrap_jekt_message(
1366            &sanitized,
1367            req.source_agent.as_deref(),
1368            &req.target_agent,
1369            effective_tier,
1370            delivery_tier,
1371            req.sig_verified,
1372            req.reagent_verified,
1373            req.lan_verified,
1374            req.channel_verified,
1375            req.wan_instance.as_ref().filter(|_| req.wan_verified == Some(true)),
1376            requires_stop,
1377            &request_id,
1378            priority,
1379            req.held_sent_at_ms,
1380        );
1381
1382        // Legacy source-prefix format preserved for PTY controllers that don't
1383        // parse the JEKT block — the wrap_jekt_message output already includes
1384        // the source in the human-readable header so format_injected_message
1385        // is called with include_source=false here.
1386        let final_msg = format_injected_message(
1387            &wrapped,
1388            req.source_agent.as_deref(),
1389            false,
1390        );
1391
1392        // Controller-aware delivery (SPEC_AGENT_CONTROL_PROTOCOL §6 / Phase 3).
1393        // Persistent (stream-json) and ACP agents have no PTY — their inbox is a
1394        // structured channel (live stdin NDJSON / `session/prompt`). Delivering there
1395        // also lands the message mid-turn (steering) instead of waiting for idle.
1396        // PTY-based shell/term agents report back so we fall through to keystrokes.
1397        if let Some(ref deliver) = self.message_sender {
1398            match deliver(&block_id, &final_msg) {
1399                Ok(outcome @ (SenderDelivery::Delivered | SenderDelivery::Deferred)) => {
1400                    let deferred = outcome == SenderDelivery::Deferred;
1401                    tracing::info!(
1402                        target_agent = %req.target_agent,
1403                        block_id = %block_id,
1404                        deferred,
1405                        "inject: structured delivery to non-PTY controller"
1406                    );
1407                    self.log_audit(
1408                        req.source_agent.as_deref(),
1409                        &req.target_agent,
1410                        &block_id,
1411                        &sanitized,
1412                        true,
1413                        None,
1414                        &request_id,
1415                        outcome_on_success,
1416                        reason,
1417                    );
1418                    return InjectionResponse {
1419                        success: true,
1420                        request_id,
1421                        block_id: Some(block_id),
1422                        error: None,
1423                        timestamp: now,
1424                        effective_tier: Some(effective_tier.to_string()),
1425                        requires_stop: Some(requires_stop),
1426                        channel_verified: req.channel_verified,
1427                        deferred: Some(deferred),
1428                    };
1429                }
1430                Ok(SenderDelivery::Pty) => {
1431                    // PTY-based controller — fall through to keystroke injection.
1432                }
1433                Err(e) => {
1434                    // Structured controller but delivery failed (e.g. persistent
1435                    // process not running). Do NOT fall back to PTY keystrokes — the
1436                    // persistent controller rejects raw input. Surface the error.
1437                    tracing::warn!(
1438                        target_agent = %req.target_agent,
1439                        block_id = %block_id,
1440                        error = %e,
1441                        "inject: structured delivery failed"
1442                    );
1443                    self.log_audit(
1444                        req.source_agent.as_deref(),
1445                        &req.target_agent,
1446                        &block_id,
1447                        &sanitized,
1448                        false,
1449                        Some(&e),
1450                        &request_id,
1451                        outcome_on_failure,
1452                        reason,
1453                    );
1454                    return InjectionResponse {
1455                        deferred: None,
1456                        success: false,
1457                        request_id,
1458                        block_id: Some(block_id),
1459                        error: Some(e),
1460                        timestamp: now,
1461                        effective_tier: Some(effective_tier.to_string()),
1462                        requires_stop: Some(requires_stop),
1463                        channel_verified: req.channel_verified,
1464                    };
1465                }
1466            }
1467        }
1468
1469        // Send message via input sender
1470        let sender = match &self.input_sender {
1471            Some(s) => s.clone(),
1472            None => {
1473                let err = "input sender not configured".to_string();
1474                self.log_audit(
1475                    req.source_agent.as_deref(),
1476                    &req.target_agent,
1477                    &block_id,
1478                    &sanitized,
1479                    false,
1480                    Some(&err),
1481                    &request_id,
1482                    outcome_on_failure,
1483                    reason,
1484                );
1485                return InjectionResponse {
1486                    deferred: None,
1487                    success: false,
1488                    request_id,
1489                    block_id: Some(block_id),
1490                    error: Some(err),
1491                    timestamp: now,
1492                    effective_tier: Some(effective_tier.to_string()),
1493                    requires_stop: Some(requires_stop),
1494                    channel_verified: req.channel_verified,
1495                };
1496            }
1497        };
1498
1499        // Jekt inject sequence (see docs/specs/jekt-inject-timing.md):
1500        // 1. \r to clear any partial input on the line
1501        // 2. message\r as single payload (proven to display text — v0.31.122/125)
1502        // 3. Three delayed \r at 200ms intervals as separate PTY writes to submit
1503        let _ = sender(&block_id, b"\r");
1504        let payload = format!("{}\r", final_msg);
1505        tracing::info!(
1506            target_agent = %req.target_agent,
1507            block_id = %block_id,
1508            msg_len = payload.len(),
1509            "inject: sending payload to PTY"
1510        );
1511        if let Err(e) = sender(&block_id, payload.as_bytes()) {
1512            tracing::error!(
1513                target_agent = %req.target_agent,
1514                block_id = %block_id,
1515                error = %e,
1516                "inject: sender failed"
1517            );
1518            self.log_audit(
1519                req.source_agent.as_deref(),
1520                &req.target_agent,
1521                &block_id,
1522                &sanitized,
1523                false,
1524                Some(&e),
1525                &request_id,
1526                outcome_on_failure,
1527                reason,
1528            );
1529            return InjectionResponse {
1530                deferred: None,
1531                success: false,
1532                request_id,
1533                block_id: Some(block_id),
1534                error: Some(e),
1535                timestamp: now,
1536                effective_tier: Some(effective_tier.to_string()),
1537                requires_stop: Some(requires_stop),
1538                channel_verified: req.channel_verified,
1539            };
1540        }
1541
1542        // Spawn 3 delayed \r sends as separate PTY events to ensure submission.
1543        let sender_enter = sender.clone();
1544        let block_id_enter = block_id.clone();
1545        tokio::spawn(async move {
1546            tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1547            let _ = sender_enter(&block_id_enter, b"\r");
1548            tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1549            let _ = sender_enter(&block_id_enter, b"\r");
1550            tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1551            let _ = sender_enter(&block_id_enter, b"\r");
1552        });
1553
1554        // Success
1555        self.log_audit(
1556            req.source_agent.as_deref(),
1557            &req.target_agent,
1558            &block_id,
1559            &sanitized,
1560            true,
1561            None,
1562            &request_id,
1563            outcome_on_success,
1564            reason,
1565        );
1566
1567        InjectionResponse {
1568            deferred: None,
1569            success: true,
1570            request_id,
1571            block_id: Some(block_id),
1572            error: None,
1573            timestamp: now,
1574            effective_tier: Some(effective_tier.to_string()),
1575            requires_stop: Some(requires_stop),
1576            channel_verified: req.channel_verified,
1577        }
1578    }
1579
1580    /// Deliver one of srv's own `[AgentMux]` system notes to `target_agent`
1581    /// (`muxbus::delivery_status`). Not a jekt: no marker block, tier or
1582    /// trust label, because nothing but srv wrote the text, and no jekt or
1583    /// forwarded text can carry the marker (`sanitize::neutralize_markers`).
1584    /// Routed like a jekt: the controller's structured channel, else
1585    /// keystrokes. Audited as `system_note` from `agentmux`.
1586    pub fn deliver_system_note(&mut self, target_agent: &str, text: &str) -> Result<(), String> {
1587        let block_id = match self.resolve_target(target_agent) {
1588            TargetResolution::Uid(b) | TargetResolution::Name(b) => b,
1589            TargetResolution::Ambiguous(candidates) => {
1590                return Err(format!("ambiguous agent name: '{target_agent}' is held by {} live agents", candidates.len()));
1591            }
1592            TargetResolution::NotFound => return Err(format!("agent not found: {target_agent}")),
1593        };
1594        let result = self.send_to_block(&block_id, text);
1595        let request_id = uuid::Uuid::new_v4().to_string();
1596        self.log_audit(
1597            Some("agentmux"),
1598            target_agent,
1599            &block_id,
1600            text,
1601            result.is_ok(),
1602            result.as_ref().err().map(String::as_str),
1603            &request_id,
1604            Some("system_note"),
1605            None,
1606        );
1607        result
1608    }
1609
1610    /// `text` to `block_id` the way a jekt goes: structured when the
1611    /// controller takes it, else typed and submitted.
1612    fn send_to_block(&self, block_id: &str, text: &str) -> Result<(), String> {
1613        if let Some(deliver) = &self.message_sender {
1614            match deliver(block_id, text)? {
1615                SenderDelivery::Delivered | SenderDelivery::Deferred => return Ok(()),
1616                SenderDelivery::Pty => {}
1617            }
1618        }
1619        let sender = self.input_sender.clone().ok_or_else(|| "input sender not configured".to_string())?;
1620        let _ = sender(block_id, b"\r");
1621        sender(block_id, format!("{text}\r").as_bytes())?;
1622        if tokio::runtime::Handle::try_current().is_ok() {
1623            let block_id = block_id.to_string();
1624            tokio::spawn(async move {
1625                for _ in 0..3 {
1626                    tokio::time::sleep(std::time::Duration::from_millis(200)).await;
1627                    let _ = sender(&block_id, b"\r");
1628                }
1629            });
1630        }
1631        Ok(())
1632    }
1633
1634    /// Record a Warden Supervisor watcher agent's decision about
1635    /// `target_agent`. A `Nudge` is delivered through the same path
1636    /// `inject_message` uses (`inject_message_inner`) and audited with
1637    /// `outcome: "nudge_sent"`; a `Decline` sends nothing and is audited
1638    /// directly with `outcome: "nudge_declined"`.
1639    ///
1640    /// Enforces the consecutive-nudge ceiling
1641    /// (`MAX_CONSECUTIVE_AUTO_CONTINUES`): if this nudge would exceed it,
1642    /// the decision is forced to a decline (audited with
1643    /// `outcome: "nudge_declined"`, `reason: "consecutive-nudge ceiling
1644    /// reached"`) and `Err` is returned so the calling agent's MCP
1645    /// tool-call result surfaces the refusal directly — a signal for it to
1646    /// stop nudging and escalate to a human via an ordinary jekt instead of
1647    /// retrying. The counter resets when the target's `registration_nonce`
1648    /// or `block_id` changes (a respawn — "consecutive" only makes sense
1649    /// within one continuous run; see `NudgeCounterState`'s field docs for
1650    /// why both signals are needed) or after `NUDGE_COOLDOWN_RESET_MS` of
1651    /// inactivity.
1652    ///
1653    /// Does NOT check the target's `auto_continue_enabled` opt-in itself —
1654    /// `Handler` has no `Store` access by design (this module doesn't
1655    /// depend on `backend::storage`). That gate lives at the HTTP boundary,
1656    /// in `handle_reactive_supervisor_decision`
1657    /// (`server/reactive.rs`), which has `AppState::mstore`. Any other
1658    /// caller of this method directly is responsible for its own
1659    /// entitlement check first.
1660    pub fn record_supervisor_decision(
1661        &mut self,
1662        target_agent: &str,
1663        action: SupervisorAction,
1664        reason: &str,
1665        request_id: &str,
1666        source_agent: Option<&str>,
1667        source_uid: &str,
1668    ) -> Result<InjectionResponse, String> {
1669        // Every entry this decision writes — a decline, a nudge refused at
1670        // the ceiling, the nudge's own delivery — carries the Supervisor's
1671        // UID (ReAgent P1 on #3608: the ceiling refusal used to miss it).
1672        let outer = std::mem::replace(&mut self.delivery_source_uid, source_uid.to_string());
1673        let result =
1674            self.decide_audited(target_agent, action, reason, request_id, source_agent, source_uid);
1675        self.delivery_source_uid = outer;
1676        result
1677    }
1678
1679    /// The body of [`Self::record_supervisor_decision`], with the
1680    /// Supervisor's UID in `delivery_source_uid`.
1681    fn decide_audited(
1682        &mut self,
1683        target_agent: &str,
1684        action: SupervisorAction,
1685        reason: &str,
1686        request_id: &str,
1687        source_agent: Option<&str>,
1688        source_uid: &str,
1689    ) -> Result<InjectionResponse, String> {
1690        let now = now_unix_millis();
1691        let target_key = target_agent.to_lowercase();
1692        // Same resolution as delivery (identity M2, §10 constraint 4 — this
1693        // is the site #3520 converted the others around and missed). An
1694        // ambiguous or unknown target leaves `block_id` empty exactly as an
1695        // unknown one did before.
1696        // Peek, don't resolve: `inject_message_inner` below resolves (and
1697        // counts, and sweeps) for real on the Nudge path; resolving here too
1698        // would double-bump the §9.2 counters. An ambiguous or unknown target
1699        // leaves `block_id` empty exactly as an unknown one did before.
1700        let block_id = self.peek_target(target_agent).unwrap_or_default();
1701
1702        match action {
1703            SupervisorAction::Decline => {
1704                self.log_audit(
1705                    source_agent,
1706                    target_agent,
1707                    &block_id,
1708                    "",
1709                    true,
1710                    None,
1711                    request_id,
1712                    Some("nudge_declined"),
1713                    Some(reason),
1714                );
1715                Ok(InjectionResponse {
1716                    deferred: None,
1717                    success: true,
1718                    request_id: request_id.to_string(),
1719                    block_id: if block_id.is_empty() { None } else { Some(block_id) },
1720                    error: None,
1721                    timestamp: now,
1722                    effective_tier: None,
1723                    requires_stop: None,
1724                    channel_verified: None,
1725                })
1726            }
1727            SupervisorAction::Nudge => {
1728                // Bound `nudge_counters`' growth (reagentx P2 on PR #2557):
1729                // an entry idle past the cooldown window is about to be
1730                // treated as stale on next use anyway, so dropping it here
1731                // is behavior-neutral — just reclaims memory instead of
1732                // accumulating one entry per distinct target agent forever.
1733                self.nudge_counters
1734                    .retain(|_, v| now.saturating_sub(v.last_nudge_at_ms) <= NUDGE_COOLDOWN_RESET_MS);
1735
1736                let current_nonce = self
1737                    .agent_info
1738                    .get(&block_id)
1739                    .map(|info| info.registration_nonce)
1740                    .unwrap_or(0);
1741
1742                // Scoped borrow: check/reset staleness and read the
1743                // pre-delivery count, then drop the borrow before calling
1744                // `inject_message_inner` (which needs `&mut self` too).
1745                let count_before = {
1746                    let entry = self.nudge_counters.entry(target_key.clone()).or_insert(NudgeCounterState {
1747                        registration_nonce: current_nonce,
1748                        block_id: block_id.clone(),
1749                        count: 0,
1750                        last_nudge_at_ms: 0,
1751                    });
1752                    // registration_nonce only distinguishes a respawn for
1753                    // persistent-controller agents (real nonces, ≥ 1);
1754                    // PTY/shell/HTTP-register paths always register with 0,
1755                    // so block_id is the fallback signal for those
1756                    // (reagentx P1 on PR #2557 — nonce alone never fired
1757                    // for them, leaving a respawned PTY agent stuck behind
1758                    // its prior run's exhausted ceiling for up to the full
1759                    // cooldown window).
1760                    let stale = entry.registration_nonce != current_nonce
1761                        || entry.block_id != block_id
1762                        || now.saturating_sub(entry.last_nudge_at_ms) > NUDGE_COOLDOWN_RESET_MS;
1763                    if stale {
1764                        entry.registration_nonce = current_nonce;
1765                        entry.block_id = block_id.clone();
1766                        entry.count = 0;
1767                    }
1768                    entry.count
1769                };
1770
1771                if count_before >= MAX_CONSECUTIVE_AUTO_CONTINUES {
1772                    let ceiling_reason = "consecutive-nudge ceiling reached".to_string();
1773                    self.log_audit(
1774                        source_agent,
1775                        target_agent,
1776                        &block_id,
1777                        "",
1778                        true,
1779                        None,
1780                        request_id,
1781                        Some("nudge_declined"),
1782                        Some(&ceiling_reason),
1783                    );
1784                    return Err(ceiling_reason);
1785                }
1786
1787                // Fixed, narrow continuation template — deliberately NOT
1788                // free-form text composed by the calling Supervisor agent.
1789                // ANALYSIS_WARDEN_AUTO_CONTROLLER_CONTINUATION_WATCHER_
1790                // 2026_08_12.md §4.3: "The nudge text should be a fixed,
1791                // narrow template... not a free-form instruction the
1792                // watcher composes per-situation — this is the direct
1793                // mitigation for consent-chain degradation." (reagentx P1
1794                // on PR #2557 — SupervisorNudge used to accept arbitrary
1795                // `message` text.) `reason` (the Supervisor's own
1796                // reasoning) still travels separately, into the audit log
1797                // only — never into what's delivered to the target.
1798                let req = InjectionRequest {
1799                    target_agent: target_agent.to_string(),
1800                    message: NUDGE_MESSAGE.to_string(),
1801                    source_agent: source_agent.map(|s| s.to_string()),
1802                    request_id: Some(request_id.to_string()),
1803                    priority: Some("normal".to_string()),
1804                    jekt_tier: Some(super::types::JektTier::Coord),
1805                    delivery_tier: Some("host".to_string()),
1806                    forward_hops: 0,
1807                    audit_source_uid: source_uid.to_string(),
1808                    ..Default::default()
1809                };
1810                let resp = self.inject_message_inner(
1811                    req,
1812                    Some("nudge_sent"),
1813                    Some("nudge_failed"),
1814                    Some(reason),
1815                );
1816
1817                // Only a successful delivery consumes the ceiling
1818                // (reagentx P2 on PR #2557) — a rate-limited/unavailable-
1819                // controller failure shouldn't cost the Supervisor one of
1820                // its 5 consecutive attempts for this target.
1821                if resp.success {
1822                    if let Some(entry) = self.nudge_counters.get_mut(&target_key) {
1823                        entry.count += 1;
1824                        entry.last_nudge_at_ms = now;
1825                    }
1826                }
1827
1828                Ok(resp)
1829            }
1830        }
1831    }
1832
1833    /// Get audit log entries, most recent first.
1834    pub fn get_audit_log(&self, limit: usize) -> Vec<AuditLogEntry> {
1835        let start = if self.audit_log.len() > limit {
1836            self.audit_log.len() - limit
1837        } else {
1838            0
1839        };
1840        let mut entries: Vec<_> = self.audit_log[start..].to_vec();
1841        entries.reverse();
1842        entries
1843    }
1844
1845    /// Add an entry to the audit ring buffer. `outcome`/`reason` are `None`
1846    /// for every ordinary jekt injection (all current call sites) — only
1847    /// Warden Supervisor decisions (see `record_supervisor_decision`) ever
1848    /// set them.
1849    #[allow(clippy::too_many_arguments)]
1850    pub(super) fn log_audit(
1851        &mut self,
1852        source_agent: Option<&str>,
1853        target_agent: &str,
1854        block_id: &str,
1855        message: &str,
1856        success: bool,
1857        error_message: Option<&str>,
1858        request_id: &str,
1859        outcome: Option<&str>,
1860        reason: Option<&str>,
1861    ) {
1862        let entry = AuditLogEntry {
1863            timestamp: now_unix_millis(),
1864            source_agent: source_agent.map(|s| s.to_string()),
1865            target_agent: target_agent.to_string(),
1866            block_id: block_id.to_string(),
1867            message_hash: audit_hash(message),
1868            message_length: message.len(),
1869            success,
1870            error_message: error_message.map(|s| s.to_string()),
1871            request_id: request_id.to_string(),
1872            outcome: outcome.map(|s| s.to_string()),
1873            reason: reason.map(|s| s.to_string()),
1874            event_kind: "delivery".to_string(),
1875            evicted_block: None,
1876            evicted_agent: None,
1877            audit_source_uid: self.delivery_source_uid.clone(),
1878            lan_claimed_uid: self.delivery_lan_claimed_uid.clone(),
1879            wan: self.delivery_wan.clone(),
1880        };
1881
1882        if self.audit_log.len() >= AUDIT_LOG_MAX {
1883            self.audit_log.remove(0);
1884        }
1885        self.audit_log.push(entry);
1886    }
1887
1888    /// Record a registration/eviction event in the audit log — the
1889    /// counterpart to [`log_audit`] (which only ever recorded delivery
1890    /// attempts). `evicted_block`/`evicted_agent` capture whichever prior
1891    /// mapping a "register" call displaced, so a same-host identity
1892    /// collision (two panes racing to register the same agent_id) is
1893    /// reconstructable after the fact instead of leaving no trace.
1894    pub(super) fn log_audit_registration(
1895        &mut self,
1896        event_kind: &str,
1897        agent_id: &str,
1898        block_id: &str,
1899        evicted_block: Option<&str>,
1900        evicted_agent: Option<&str>,
1901    ) {
1902        let entry = AuditLogEntry {
1903            timestamp: now_unix_millis(),
1904            source_agent: None,
1905            target_agent: agent_id.to_string(),
1906            block_id: block_id.to_string(),
1907            message_hash: String::new(),
1908            message_length: 0,
1909            success: true,
1910            error_message: None,
1911            request_id: String::new(),
1912            outcome: None,
1913            reason: None,
1914            event_kind: event_kind.to_string(),
1915            evicted_block: evicted_block.map(|s| s.to_string()),
1916            evicted_agent: evicted_agent.map(|s| s.to_string()),
1917            audit_source_uid: String::new(),
1918            lan_claimed_uid: String::new(),
1919            wan: None,
1920        };
1921
1922        if self.audit_log.len() >= AUDIT_LOG_MAX {
1923            self.audit_log.remove(0);
1924        }
1925        self.audit_log.push(entry);
1926    }
1927}
1928
1929impl Default for Handler {
1930    fn default() -> Self {
1931        Self::new()
1932    }
1933}
1934
1935// ---- Thread-safe wrapper ----
1936
1937/// Thread-safe wrapper around Handler.
1938pub struct ReactiveHandler {
1939    inner: Mutex<Handler>,
1940    /// Called with the target block_id after a successful delivery whose
1941    /// marker says `ESCALATE=required` — the notification Router turns it
1942    /// into a "needs your review" OS notification (fixed text, no content).
1943    /// A list, not a slot: this handler is process-global, and each Router
1944    /// (one per broker/AppState) registers its own hook and ignores blocks
1945    /// that aren't in its own store — so a second AppState can never steal
1946    /// another's notifications (ReAgent P1 on #3654).
1947    needs_review_hooks: std::sync::RwLock<Vec<NeedsReviewHook>>,
1948}
1949
1950/// See `ReactiveHandler::add_needs_review_hook`.
1951pub type NeedsReviewHook = std::sync::Arc<dyn Fn(&str) + Send + Sync>;
1952
1953impl ReactiveHandler {
1954    pub fn new() -> Self {
1955        Self {
1956            inner: Mutex::new(Handler::new()),
1957            needs_review_hooks: std::sync::RwLock::new(Vec::new()),
1958        }
1959    }
1960
1961    /// Register a needs-review hook. Must not block or call back into this
1962    /// handler; hooks run after the inner lock is released.
1963    pub fn add_needs_review_hook(&self, f: NeedsReviewHook) {
1964        self.needs_review_hooks.write().unwrap_or_else(|e| e.into_inner()).push(f);
1965    }
1966
1967    fn after_delivery(&self, resp: &InjectionResponse) {
1968        if !(resp.success && resp.requires_stop == Some(true)) {
1969            return;
1970        }
1971        let Some(block_id) = resp.block_id.as_deref() else { return };
1972        let hooks = self.needs_review_hooks.read().unwrap_or_else(|e| e.into_inner()).clone();
1973        for h in hooks {
1974            h(block_id);
1975        }
1976    }
1977
1978    pub fn set_input_sender(&self, sender: InputSender) {
1979        self.inner.lock().unwrap().set_input_sender(sender);
1980    }
1981
1982    pub fn set_message_sender(&self, sender: MessageSender) {
1983        self.inner.lock().unwrap().set_message_sender(sender);
1984    }
1985
1986    pub fn set_agent_identity_confirmer(&self, confirmer: AgentIdentityConfirmer) {
1987        self.inner
1988            .lock()
1989            .unwrap()
1990            .set_agent_identity_confirmer(confirmer);
1991    }
1992
1993    /// See the inner [`Handler::set_stable_agent_identity_confirmer`].
1994    pub fn set_stable_agent_identity_confirmer(&self, confirmer: AgentIdentityConfirmer) {
1995        self.inner
1996            .lock()
1997            .unwrap()
1998            .set_stable_agent_identity_confirmer(confirmer);
1999    }
2000
2001    /// See the inner [`Handler::set_uid_identity_confirmer`] (identity M2).
2002    pub fn set_uid_identity_confirmer(&self, confirmer: AgentIdentityConfirmer) {
2003        self.inner
2004            .lock()
2005            .unwrap()
2006            .set_uid_identity_confirmer(confirmer);
2007    }
2008
2009    /// See the inner [`Handler::set_block_liveness`] (identity M2).
2010    pub fn set_block_liveness(&self, probe: BlockLivenessProbe) {
2011        self.inner.lock().unwrap().set_block_liveness(probe);
2012    }
2013
2014    /// See the inner [`Handler::register_agent_full`] (identity M2).
2015    #[allow(clippy::too_many_arguments)]
2016    pub fn register_agent_full(
2017        &self,
2018        display: &str,
2019        block_id: &str,
2020        tab_id: Option<&str>,
2021        registration_nonce: u64,
2022        stable: Option<&str>,
2023        uid: Option<&str>,
2024        no_uid_counter: &'static str,
2025    ) -> Result<(), String> {
2026        self.inner.lock().unwrap().register_agent_full(
2027            display,
2028            block_id,
2029            tab_id,
2030            registration_nonce,
2031            stable,
2032            uid,
2033            no_uid_counter,
2034        )
2035    }
2036
2037    /// [`try_register_agent_with_nonce`]'s full-identity form — same
2038    /// non-blocking, bounded-retry lock discipline, for the spawn path.
2039    #[allow(clippy::too_many_arguments)]
2040    pub fn try_register_agent_full(
2041        &self,
2042        display: &str,
2043        block_id: &str,
2044        tab_id: Option<&str>,
2045        registration_nonce: u64,
2046        stable: Option<&str>,
2047        uid: Option<&str>,
2048        no_uid_counter: &'static str,
2049    ) -> Result<(), String> {
2050        let mut guard = self.try_lock_bounded()?;
2051        guard.register_agent_full(
2052            display,
2053            block_id,
2054            tab_id,
2055            registration_nonce,
2056            stable,
2057            uid,
2058            no_uid_counter,
2059        )
2060    }
2061
2062    /// Every name `block_id` is bound under — see [`Handler::names_for_block`].
2063    pub fn names_for_block(&self, block_id: &str) -> Vec<String> {
2064        self.inner.lock().unwrap().names_for_block(block_id)
2065    }
2066
2067    /// Bounded `try_lock`, shared by the spawn-path entry points — see
2068    /// [`try_register_agent_with_nonce`]'s doc comment for the reasoning.
2069    fn try_lock_bounded(&self) -> Result<std::sync::MutexGuard<'_, Handler>, String> {
2070        const MAX_ATTEMPTS: u32 = 5;
2071        const RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(2);
2072        for attempt in 1..=MAX_ATTEMPTS {
2073            match self.inner.try_lock() {
2074                Ok(guard) => return Ok(guard),
2075                Err(std::sync::TryLockError::WouldBlock) => {
2076                    if attempt == MAX_ATTEMPTS {
2077                        return Err(
2078                            "reactive handler lock busy after retrying — skipping \
2079                             registration (either a same-thread reentrant call, \
2080                             redundant by construction on that path, or contention \
2081                             that outlasted the retry budget; see \
2082                             INCIDENT_2026_09_07_BACKEND_UPTIME_TIMER_FROZEN.md)"
2083                                .to_string(),
2084                        );
2085                    }
2086                    std::thread::sleep(RETRY_DELAY);
2087                }
2088                Err(std::sync::TryLockError::Poisoned(e)) => {
2089                    return Err(format!("reactive handler mutex poisoned: {e}"));
2090                }
2091            }
2092        }
2093        unreachable!("loop always returns on its final attempt");
2094    }
2095
2096    #[allow(dead_code)]
2097    pub fn set_include_source(&self, include: bool) {
2098        self.inner.lock().unwrap().set_include_source(include);
2099    }
2100
2101    /// Test-facing name-only form — see [`Handler::register_agent`].
2102    #[cfg_attr(not(test), allow(dead_code))]
2103    pub fn register_agent(
2104        &self,
2105        agent_id: &str,
2106        block_id: &str,
2107        tab_id: Option<&str>,
2108    ) -> Result<(), String> {
2109        self.inner
2110            .lock()
2111            .unwrap()
2112            .register_agent(agent_id, block_id, tab_id)
2113    }
2114
2115    /// See the inner [`Handler::register_agent_with_nonce`].
2116    #[cfg_attr(not(test), allow(dead_code))]
2117    pub fn register_agent_with_nonce(
2118        &self,
2119        agent_id: &str,
2120        block_id: &str,
2121        tab_id: Option<&str>,
2122        registration_nonce: u64,
2123        alias: Option<&str>,
2124    ) -> Result<(), String> {
2125        self.inner
2126            .lock()
2127            .unwrap()
2128            .register_agent_with_nonce(agent_id, block_id, tab_id, registration_nonce, alias)
2129    }
2130
2131    /// Like [`register_agent_with_nonce`], but never blocks indefinitely: if
2132    /// the lock is still held after a short bounded retry
2133    /// (`try_lock_bounded`), this returns an error instead of
2134    /// waiting for it. Test-only; the spawn path uses the full-identity
2135    /// form, [`Self::try_register_agent_full`], which shares this
2136    /// reasoning.
2137    ///
2138    /// The case that matters is this exact call running on the same OS
2139    /// thread as an in-flight `inject_message` — the reactive-delivery spawn
2140    /// fallback (`bootstrap/delivery.rs`'s `install_agent_turn_delivery`) runs a
2141    /// respawn synchronously on the injecting thread via `block_in_place`,
2142    /// and that respawn's own auto-registration
2143    /// (`PersistentSubprocessController::spawn_process`) used to re-lock this
2144    /// same non-reentrant `Mutex<Handler>` two modules away from the
2145    /// call site `TurnRegistration::Skip` actually guards — permanently
2146    /// deadlocking the reactive handler process-wide (every persistent
2147    /// agent's first message after an idle period, or after any srv
2148    /// restart). See `docs/incident/INCIDENT_2026_09_07_BACKEND_UPTIME_TIMER_FROZEN.md`.
2149    ///
2150    /// Skipping in that case loses nothing: `inject_message` only reaches
2151    /// the spawn fallback after resolving `target_agent` through
2152    /// `resolve_target` (the `uid_to_block` / `name_to_blocks` registry),
2153    /// so this exact agent/block pair is registered BY
2154    /// CONSTRUCTION before this call is ever reached on that path — the
2155    /// same "redundant, not just deadlock-prone" reasoning documented on
2156    /// `TurnRegistration::Skip` for the sibling re-lock it guards.
2157    ///
2158    /// Genuine cross-thread contention is a real, separate risk this
2159    /// method does not get to treat as harmless: `respawn_once_for_
2160    /// leftover_queue` and other internal callers reach `spawn_process`
2161    /// directly, with no `run_agent_turn` `Register`-tail behind them to
2162    /// repair a skip — a losing race there can leave a live respawned
2163    /// agent unregistered and unreachable until a human intervenes via
2164    /// the UI (#3084). The retry loop in `try_lock_bounded`
2165    /// exists for exactly that case: a same-thread reentrant call can
2166    /// never succeed no matter how many times it retries (nothing on
2167    /// that call stack can release the lock), but ordinary cross-thread
2168    /// contention — a few HashMap reads under the same lock elsewhere,
2169    /// typically microseconds — very likely clears within the retry
2170    /// budget. See the comment in this method's body for the full reasoning.
2171    ///
2172    /// A skip's caller (`spawn_process`) is also responsible for not
2173    /// trusting `registration_nonce` as this spawn's exit-time cleanup
2174    /// key when the call returns `Err` here — the registration this
2175    /// skip left in place (if any) still belongs to whichever nonce is
2176    /// actually on record, not to this spawn's own unwritten one. See
2177    /// `spawn_process`'s handling of `try_register_agent_full`'s `Err` arm.
2178    #[cfg_attr(not(test), allow(dead_code))]
2179    pub fn try_register_agent_with_nonce(
2180        &self,
2181        agent_id: &str,
2182        block_id: &str,
2183        tab_id: Option<&str>,
2184        registration_nonce: u64,
2185        alias: Option<&str>,
2186    ) -> Result<(), String> {
2187        // Bounded retry, not a single attempt (#3084): the spawn-path
2188        // registration (`PersistentSubprocessController::spawn_process`'s
2189        // auto-registration, via `try_register_agent_full`, which shares
2190        // `try_lock_bounded`) is shared by every caller that spawns a
2191        // process — including `respawn_once_for_leftover_queue`, which
2192        // calls `spawn_process` directly with no `run_agent_turn`
2193        // `Register`-tail to repair a skip. A single `try_lock` cannot
2194        // tell "the SAME thread already holds this lock" (the reentrant
2195        // deadlock this method exists to avoid — retrying never helps,
2196        // nothing on this call stack can release it) apart from "a
2197        // DIFFERENT thread holds it briefly for an unrelated op" (e.g.
2198        // `list_agents()` — a few HashMap reads, typically microseconds).
2199        // Retrying costs the reentrant case a few bounded, wasted
2200        // milliseconds and still correctly returns `Err`; it costs the
2201        // ordinary-contention case nothing in the near-universal case
2202        // where the next attempt lands after the brief holder is done —
2203        // "bounded stalling beats silent loss", the same tradeoff
2204        // `bootstrap/delivery.rs`'s `block_in_place` fallback already makes.
2205        let mut guard = self.try_lock_bounded()?;
2206        guard.register_agent_with_nonce(agent_id, block_id, tab_id, registration_nonce, alias)
2207    }
2208
2209    /// See the inner [`Handler::unregister_agent`] — by name, refusing an
2210    /// ambiguous one (identity M2).
2211    pub fn unregister_agent(&self, agent_id: &str) -> UnregisterOutcome {
2212        self.inner.lock().unwrap().unregister_agent(agent_id)
2213    }
2214
2215    /// See the inner [`Handler::unregister_block`]. Returns the names the
2216    /// block was bound under.
2217    pub fn unregister_block(&self, block_id: &str) -> Vec<String> {
2218        self.inner.lock().unwrap().unregister_block(block_id)
2219    }
2220
2221    /// See the inner [`Handler::unregister_block_if_nonce`] — atomic
2222    /// compare-and-remove under the handler lock (issue #2363).
2223    pub fn unregister_block_if_nonce(&self, block_id: &str, expected_nonce: u64) -> bool {
2224        self.inner
2225            .lock()
2226            .unwrap()
2227            .unregister_block_if_nonce(block_id, expected_nonce)
2228    }
2229
2230    #[allow(dead_code)]
2231    pub fn update_last_seen(&self, agent_id: &str) {
2232        self.inner.lock().unwrap().update_last_seen(agent_id);
2233    }
2234
2235    pub fn get_agent(&self, agent_id: &str) -> Option<AgentRegistration> {
2236        self.inner.lock().unwrap().get_agent(agent_id).cloned()
2237    }
2238
2239    /// See the inner [`Handler::has_live_name`].
2240    pub fn has_live_name(&self, name: &str) -> bool {
2241        self.inner.lock().unwrap().has_live_name(name)
2242    }
2243
2244    /// See the inner [`Handler::lookup_by_name`] (identity M2).
2245    pub fn lookup_by_name(&self, name: &str) -> LookupOutcome {
2246        self.inner.lock().unwrap().lookup_by_name(name)
2247    }
2248
2249    /// Used by `server/app_api/fleet.rs`'s ordinary (non-reentrant) request
2250    /// handlers — no longer dead code, but NOT safe to call from
2251    /// `spawn_process`'s skip-arm; see [`try_get_agent_by_block`] for why.
2252    pub fn get_agent_by_block(&self, block_id: &str) -> Option<AgentRegistration> {
2253        self.inner
2254            .lock()
2255            .unwrap()
2256            .get_agent_by_block(block_id)
2257            .cloned()
2258    }
2259
2260    /// Non-blocking counterpart to [`get_agent_by_block`], for the one
2261    /// caller that can be running on a thread already holding this lock:
2262    /// `spawn_process`'s handling of a skipped registration
2263    /// (`try_register_agent_with_nonce` returning `Err`).
2264    ///
2265    /// reagent P0 on PR #3084 (caught twice — the fix for the P1 nonce-leak
2266    /// finding introduced this exact regression on its first attempt): that
2267    /// fix originally called the plain, blocking [`get_agent_by_block`]
2268    /// from inside the skip arm. In the routine reentrant case — the same
2269    /// thread already holds `self.inner` via an in-flight `inject_message`
2270    /// — that blocking `.lock()` reproduces INCIDENT_2026_09_07's exact
2271    /// permanent self-deadlock, on the very thread `try_register_agent_
2272    /// with_nonce`'s own retry-then-fail was designed to protect. Same
2273    /// bounded-retry reasoning as that method: a same-thread reentrant call
2274    /// can never succeed (nothing on this call stack releases the lock) and
2275    /// correctly returns `None` after exhausting the budget — the caller's
2276    /// nonce-leak mitigation simply doesn't apply in that sub-case, which is
2277    /// a strict improvement on the pre-this-PR baseline (no cleanup, but no
2278    /// deadlock either), not a regression. Ordinary cross-thread contention
2279    /// gets a real chance to succeed, same as the sibling method.
2280    pub fn try_get_agent_by_block(&self, block_id: &str) -> Option<AgentRegistration> {
2281        const MAX_ATTEMPTS: u32 = 5;
2282        const RETRY_DELAY: std::time::Duration = std::time::Duration::from_millis(2);
2283        for attempt in 1..=MAX_ATTEMPTS {
2284            match self.inner.try_lock() {
2285                Ok(guard) => return guard.get_agent_by_block(block_id).cloned(),
2286                Err(std::sync::TryLockError::WouldBlock) => {
2287                    if attempt == MAX_ATTEMPTS {
2288                        return None;
2289                    }
2290                    std::thread::sleep(RETRY_DELAY);
2291                }
2292                Err(std::sync::TryLockError::Poisoned(_)) => return None,
2293            }
2294        }
2295        unreachable!("loop always returns on its final attempt");
2296    }
2297
2298    pub fn list_agents(&self) -> Vec<AgentRegistration> {
2299        self.inner.lock().unwrap().list_agents()
2300    }
2301
2302    pub fn inject_message(&self, req: InjectionRequest) -> InjectionResponse {
2303        let resp = self.inner.lock().unwrap().inject_message(req);
2304        self.after_delivery(&resp);
2305        resp
2306    }
2307
2308    /// See [`Handler::deliver_system_note`].
2309    pub fn deliver_system_note(&self, target_agent: &str, text: &str) -> Result<(), String> {
2310        self.inner.lock().unwrap().deliver_system_note(target_agent, text)
2311    }
2312
2313    /// See [`Handler::inject_held`].
2314    pub fn inject_held(&self, req: InjectionRequest) -> InjectionResponse {
2315        let resp = self.inner.lock().unwrap().inject_held(req);
2316        self.after_delivery(&resp);
2317        resp
2318    }
2319
2320    /// See [`Handler::has_uid_registration`].
2321    pub fn has_uid_registration(&self, uid: &str) -> bool {
2322        self.inner.lock().unwrap().has_uid_registration(uid)
2323    }
2324
2325    /// See [`Handler::block_for_uid`].
2326    pub fn block_for_uid(&self, uid: &str) -> Option<String> {
2327        self.inner.lock().unwrap().block_for_uid(uid)
2328    }
2329
2330    pub fn get_audit_log(&self, limit: usize) -> Vec<AuditLogEntry> {
2331        self.inner.lock().unwrap().get_audit_log(limit)
2332    }
2333
2334    /// Records an action that isn't a jekt injection (fleet bulk-stop,
2335    /// pane-lifecycle close/etc. — `crates/srv/src/server/app_api/{fleet,
2336    /// pane}.rs`) into the SAME audit ring buffer, so it shows up in
2337    /// Warden's Audit tab exactly like an ordinary injection
2338    /// (`SPEC_MULTI_AGENT_FLEET_CONTROL_2026_08_20.md` §6 — fleet actions get
2339    /// visibility there without Warden owning any new code). `action` fills
2340    /// the slot `log_audit` normally uses for the injected message text
2341    /// (e.g. `"fleet.bulk-stop"`, `"pane.close"`); `target_agent` is the
2342    /// resolved agent name for `block_id` when known, else `block_id` itself
2343    /// (an unregistered/already-stopped block has no agent to name).
2344    ///
2345    /// `reason`: an optional caller-supplied free-text note (e.g.
2346    /// `ClosePane`'s `reason` field, SPEC_AGENT_PANE_LIFECYCLE_CONTROL_2026_09_10.md
2347    /// §5.2). `None` for every pre-existing caller (`FleetBulkStop` has no
2348    /// such field today) — purely additive, no behavior change for them.
2349    #[allow(clippy::too_many_arguments)]
2350    pub fn log_fleet_action_audit(
2351        &self,
2352        source_agent: Option<&str>,
2353        target_agent: &str,
2354        block_id: &str,
2355        action: &str,
2356        success: bool,
2357        error_message: Option<&str>,
2358        request_id: &str,
2359        reason: Option<&str>,
2360    ) {
2361        self.inner.lock().unwrap().log_audit(
2362            source_agent,
2363            target_agent,
2364            block_id,
2365            action,
2366            success,
2367            error_message,
2368            request_id,
2369            None,
2370            reason,
2371        );
2372    }
2373
2374    /// See the inner [`Handler::record_supervisor_decision`].
2375    pub fn record_supervisor_decision(
2376        &self,
2377        target_agent: &str,
2378        action: SupervisorAction,
2379        reason: &str,
2380        request_id: &str,
2381        source_agent: Option<&str>,
2382        source_uid: &str,
2383    ) -> Result<InjectionResponse, String> {
2384        self.inner
2385            .lock()
2386            .unwrap()
2387            .record_supervisor_decision(target_agent, action, reason, request_id, source_agent, source_uid)
2388    }
2389}
2390
2391impl Default for ReactiveHandler {
2392    fn default() -> Self {
2393        Self::new()
2394    }
2395}
2396
2397/// Global reactive handler singleton.
2398static GLOBAL_HANDLER: OnceLock<ReactiveHandler> = OnceLock::new();
2399
2400/// Get or initialize the global reactive handler.
2401pub fn get_global_handler() -> &'static ReactiveHandler {
2402    GLOBAL_HANDLER.get_or_init(ReactiveHandler::new)
2403}
2404
2405#[cfg(test)]
2406mod needs_review_hook_tests {
2407    use super::*;
2408
2409    fn resp(success: bool, requires_stop: Option<bool>, block: Option<&str>) -> InjectionResponse {
2410        InjectionResponse {
2411            deferred: None,
2412            success,
2413            request_id: "r".into(),
2414            block_id: block.map(Into::into),
2415            error: None,
2416            timestamp: 0,
2417            effective_tier: None,
2418            requires_stop,
2419            channel_verified: None,
2420        }
2421    }
2422
2423    #[test]
2424    fn hook_fires_only_for_successful_escalate_required_deliveries() {
2425        let h = ReactiveHandler::new();
2426        let hits = std::sync::Arc::new(std::sync::Mutex::new(Vec::<String>::new()));
2427        let h2 = hits.clone();
2428        h.add_needs_review_hook(std::sync::Arc::new(move |b: &str| h2.lock().unwrap().push(b.to_string())));
2429        h.after_delivery(&resp(true, Some(true), Some("b1")));
2430        h.after_delivery(&resp(true, Some(false), Some("b2"))); // ESCALATE=none
2431        h.after_delivery(&resp(true, None, Some("b3")));        // not sensitive
2432        h.after_delivery(&resp(false, Some(true), Some("b4"))); // delivery failed
2433        h.after_delivery(&resp(true, Some(true), None));        // no block
2434        assert_eq!(*hits.lock().unwrap(), vec!["b1".to_string()]);
2435    }
2436
2437    #[test]
2438    fn every_registered_hook_is_called() {
2439        let h = ReactiveHandler::new();
2440        let hits = std::sync::Arc::new(std::sync::Mutex::new(0u32));
2441        for _ in 0..2 {
2442            let h2 = hits.clone();
2443            h.add_needs_review_hook(std::sync::Arc::new(move |_: &str| *h2.lock().unwrap() += 1));
2444        }
2445        h.after_delivery(&resp(true, Some(true), Some("b1")));
2446        assert_eq!(*hits.lock().unwrap(), 2, "a second router must not replace the first");
2447    }
2448}