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}