Skip to main content

aft/subc/
mod.rs

1//! subc daemon attach — transport edge.
2//!
3//! When AFT is launched as `aft --subc <connection-file>`, it does NOT run the
4//! standalone NDJSON-over-stdin loop. Instead it connects to a running subc
5//! daemon over loopback TCP, authenticates with the pre-envelope HMAC handshake
6//! (`subc-transport`), then speaks the subc frame protocol (`subc-protocol`):
7//! ModuleHello → HelloAck (register as a tool provider), then a channel-0
8//! control loop (Ping/Pong, RouteBind) plus route-channel tool calls.
9//!
10//! Concurrency: subc routes tool calls through the executor. The tokio
11//! edge never dispatches against `AppContext` inline; per-actor executor lanes
12//! own the reader/mutator epoch, while a writer task serializes outbound frames.
13
14use std::collections::{HashMap, HashSet, VecDeque};
15use std::fmt;
16use std::net::{IpAddr, SocketAddr};
17use std::path::{Path, PathBuf};
18use std::sync::atomic::{AtomicBool, AtomicU64, AtomicUsize, Ordering};
19use std::sync::{Arc, LazyLock};
20use std::time::{Duration, Instant};
21
22use serde::Deserialize;
23use serde_json::{json, Value};
24
25use crate::config::Config;
26use crate::config_resolve::ConfigTier;
27use crate::context::{App, AppContext, ProgressSender, RootHealthSnapshot};
28use crate::executor::{Executor, Lane};
29use crate::jsonc::strip_jsonc;
30use crate::log_ctx;
31use crate::path_identity::ProjectRootId;
32use crate::protocol::{ProgressKind, PushFrame, RawRequest, Response};
33use crate::run_tool_call::{run_tool_call, ToolCallContext, ToolCallOutcome, ToolCallResult};
34use crate::runtime_drain;
35
36use subc_protocol::manifest::{
37    Bindings, Concurrency, ExecutionMode, IdentityBinding, IdentityScope, ModuleManifest,
38    ProviderRole, StorageBinding, StorageKind, StorageScope, Tool, TrustTier,
39};
40use subc_protocol::session::{
41    HealthReport, HealthStatus, ModuleControlRequest, ModuleControlResponse,
42    MODULE_CONTROL_OP_HEALTH_CHECK,
43};
44use subc_protocol::{
45    ErrorBody, Flags, Frame, FrameType, ModuleHelloBody, Principal, Priority, PROTOCOL_VERSION,
46};
47use subc_transport::{authenticate_client, connection_file, read_frame, write_frame};
48use tokio::io::{AsyncRead, AsyncWrite};
49use tokio::net::TcpStream;
50use tokio::sync::{mpsc, oneshot, Notify};
51use tokio::task::JoinHandle;
52
53/// Handshake budget. subc binds-before-spawn, so a reachable daemon authenticates
54/// well within this; an unreachable/socket-stale daemon fails loud rather than
55/// silently downgrading to standalone (the --subc contract).
56const AUTH_DEADLINE: Duration = Duration::from_secs(5);
57
58/// Correlation id for the initial ModuleHello (channel 0).
59const HELLO_CORR: u64 = 1;
60
61/// Per-session in-memory replay cap for must-deliver Push frames. This covers
62/// detach/re-attach while AFT stays alive; cross-restart replay is phased later.
63const PUSH_BUFFER_MAX_PER_KEY: usize = 256;
64
65/// Bounded guard for control-frame sends. If the daemon stops reading and the
66/// writer queue stays full, tear the subc edge down instead of stalling the
67/// route loop indefinitely.
68const CONTROL_SEND_TIMEOUT: Duration = Duration::from_millis(250);
69
70/// Cadence for the loop's deadline-driven drain work (retry-buffer flush,
71/// bg-wake emission, maintenance submission). Checked at the top of every
72/// loop turn so busy select arms cannot starve it.
73const DRAIN_TICK_PERIOD: Duration = Duration::from_millis(250);
74
75const WRITER_QUEUE_CAPACITY: usize = 256;
76
77/// Keep reliable Push bursts from monopolizing the current-thread subc loop;
78/// any remaining must-deliver frames stay queued for the next loop turn.
79const RELIABLE_PUSH_DRAIN_BUDGET: usize = 32;
80
81/// Limit maintenance submissions per tick so background drains cannot delay
82/// control-plane work such as completed RouteBind acknowledgements.
83///
84/// The decomposed maintenance pass charges this budget by Mutating job, not by
85/// root. Set the default burst to 24 so one maintenance pass over eight live
86/// roots fits in a single tick, while follow-up batches still re-enter the
87/// capped queue instead of bypassing the budget.
88const MAINTENANCE_SUBMIT_BUDGET: usize = INITIAL_MAINTENANCE_DRAIN_KINDS.len() * 8;
89const INITIAL_MAINTENANCE_DRAIN_KINDS: [MaintenanceDrainKind; 3] = [
90    MaintenanceDrainKind::Watcher,
91    MaintenanceDrainKind::Lsp,
92    MaintenanceDrainKind::Short,
93];
94#[cfg(test)]
95const INITIAL_MAINTENANCE_JOB_COUNT: usize = INITIAL_MAINTENANCE_DRAIN_KINDS.len();
96
97const RELIABLE_WRITER_RETRY_INITIAL_BACKOFF: Duration = Duration::from_millis(10);
98const RELIABLE_WRITER_RETRY_MAX_BACKOFF: Duration = Duration::from_millis(250);
99
100const DISPATCH_PATH_BIND_WARN_AFTER: Duration = Duration::from_secs(6);
101const ROUTE_BIND_DEADLINE: Duration = Duration::from_secs(12);
102
103/// Small bounded memory of completed task ids used to suppress stale lossy
104/// long-running reminders that arrive after their reliable completion event.
105const COMPLETED_TASK_SUPPRESSION_MAX: usize = 4096;
106
107/// Bash foreground orchestration polls detached tasks with short read-lane jobs.
108/// The sleep between polls is outside the executor so no read or write worker is
109/// pinned while a foreground command is still running.
110const PENDING_POLL_INTERVAL: Duration = Duration::from_millis(100);
111
112/// Host elicitation asks fail closed if the MCP facade does not answer promptly.
113const BASH_ELICITATION_TIMEOUT: Duration = Duration::from_secs(60);
114const BASH_ELICITATION_CREATE_METHOD: &str = "elicitation/create";
115
116type RouteChannel = u32;
117type PushEnvelope = (ProjectRootId, PushFrame);
118type RetryBuffer = HashMap<RouteChannel, VecDeque<(push::ReplayKey, PushFrame)>>;
119mod bash;
120mod health;
121mod manifest;
122mod push;
123mod wire;
124
125use self::health::{
126    build_health_report, warn_slow_pending_binds, DispatchPathMetrics, ResponseTaskGuard,
127};
128use self::manifest::{
129    build_manifest, command_lane, control_flags, control_ops, is_bash_family_tool,
130    is_subc_agent_core_tool, is_subc_native_plumbing_tool,
131};
132pub use self::wire::SubcError;
133use self::wire::{
134    build_error_frame, build_goodbye_frame, build_tool_response_frame, decrement_counted_channel,
135    response_is_fatal_panic, response_message, send_counted_channel, send_frame,
136    send_reliable_writer_frame,
137};
138
139#[derive(Clone)]
140struct PushSenders {
141    lossy_tx: mpsc::Sender<PushEnvelope>,
142    reliable_tx: mpsc::UnboundedSender<PushEnvelope>,
143}
144
145#[derive(Clone)]
146struct PersistentCancelSignal {
147    inner: Arc<PersistentCancelInner>,
148}
149
150struct PersistentCancelInner {
151    cancelled: AtomicBool,
152    notify: Notify,
153}
154
155impl PersistentCancelSignal {
156    fn new() -> Self {
157        Self {
158            inner: Arc::new(PersistentCancelInner {
159                cancelled: AtomicBool::new(false),
160                notify: Notify::new(),
161            }),
162        }
163    }
164
165    fn cancel(&self) {
166        if !self.inner.cancelled.swap(true, Ordering::SeqCst) {
167            self.inner.notify.notify_waiters();
168        }
169    }
170
171    fn is_cancelled(&self) -> bool {
172        self.inner.cancelled.load(Ordering::SeqCst)
173    }
174
175    async fn cancelled(&self) {
176        // `enable()` REGISTERS this waiter before we read the flag, closing the
177        // lost-wakeup window: `notify_waiters()` only wakes already-registered
178        // waiters and stores no permit, so without enable() a `cancel()` firing
179        // between the flag read and `.await` would be missed and the future
180        // would park forever (cancel() fires only once). With enable(), a cancel
181        // racing the flag read still wakes the registered waiter. The loop is a
182        // belt-and-suspenders re-check on spurious wakeups.
183        loop {
184            let notified = self.inner.notify.notified();
185            tokio::pin!(notified);
186            notified.as_mut().enable();
187            if self.is_cancelled() {
188                return;
189            }
190            notified.await;
191        }
192    }
193}
194
195#[derive(Debug, Clone, Copy, PartialEq, Eq)]
196pub(crate) enum BindTrust {
197    FirstParty,
198    Untrusted,
199}
200
201impl BindTrust {
202    fn allows_bash_observation(self) -> bool {
203        matches!(self, Self::FirstParty)
204    }
205
206    fn label(self) -> &'static str {
207        match self {
208            Self::FirstParty => "first_party",
209            Self::Untrusted => "untrusted",
210        }
211    }
212}
213
214pub(super) fn trust_for_principal(principal: &Option<Principal>) -> BindTrust {
215    match principal {
216        Some(Principal::Direct) => BindTrust::FirstParty,
217        Some(Principal::Reserved { module_id })
218            if module_id == "llm-runner" || module_id == "aft" =>
219        {
220            BindTrust::FirstParty
221        }
222        Some(Principal::Reserved { .. }) | Some(Principal::Unverified) | None => {
223            BindTrust::Untrusted
224        }
225    }
226}
227
228fn harness_forces_untrusted(harness: &str) -> bool {
229    harness.starts_with("fed:")
230}
231
232pub(super) fn trust_for_bind(harness: &str, principal: &Option<Principal>) -> BindTrust {
233    if harness_forces_untrusted(harness) {
234        BindTrust::Untrusted
235    } else {
236        trust_for_principal(principal)
237    }
238}
239
240fn principal_label(principal: &Option<Principal>) -> String {
241    match principal {
242        Some(Principal::Direct) => "direct".to_string(),
243        Some(Principal::Reserved { module_id }) => format!("reserved:{module_id}"),
244        Some(Principal::Unverified) => "unverified".to_string(),
245        None => "absent".to_string(),
246    }
247}
248
249#[derive(Debug)]
250/// Per-root route metadata owned by the subc loop. The `active_bash_waits` field
251/// counts detached bash processes that are still being observed for this root.
252/// Any future logic that evicts roots based on idle time must not evict a root
253/// while this count is greater than zero, because a foreground bash response may
254/// still arrive later.
255struct RootMeta {
256    maintenance_pending: bool,
257    maintenance_jobs_in_flight: usize,
258    maintenance_queued_kinds: VecDeque<MaintenanceDrainKind>,
259    maintenance_last_submitted: Option<Instant>,
260    maintenance_poisoned: bool,
261    last_touched: Instant,
262    diagnostics_on_edit: bool,
263    active_bash_waits: usize,
264}
265
266#[derive(Debug)]
267struct PendingBind {
268    bind_root_id: ProjectRootId,
269    inserted_new_actor: bool,
270    cancelled: bool,
271    configure_request_id: String,
272    started_at: Instant,
273    warned_half_deadline: bool,
274    deadline_reported: bool,
275    corr: u64,
276    ver: u8,
277    flags: Flags,
278}
279
280struct RouteBindCompletion {
281    route_channel: u16,
282    identity: RouteIdentity,
283    bind_root_id: ProjectRootId,
284    inserted_new_actor: bool,
285    configure_response: Response,
286    drain_response: Option<Response>,
287    diagnostics_on_edit: bool,
288    ver: u8,
289    corr: u64,
290    flags: Flags,
291}
292
293#[derive(Debug, Clone)]
294struct RouteIdentity {
295    root: ProjectRootId,
296    project_root: PathBuf,
297    harness: String,
298    session: String,
299    trust: BindTrust,
300    consumer_elicitation_capable: bool,
301}
302
303#[derive(Debug, Clone)]
304struct RetainedSessionIdentity {
305    harness: String,
306    trust: BindTrust,
307}
308
309#[derive(Clone, Copy)]
310struct BgSub {
311    corr: u64,
312    ver: u8,
313    flags: Flags,
314}
315
316struct MaintenanceCompletion {
317    root_id: ProjectRootId,
318    response: Response,
319    empty_bg_sessions: Vec<(String, u64)>,
320    requeue_kind: Option<MaintenanceDrainKind>,
321}
322
323#[derive(Clone, Copy, Debug, PartialEq, Eq)]
324enum MaintenanceDrainKind {
325    Watcher,
326    Lsp,
327    Short,
328}
329
330impl MaintenanceDrainKind {
331    fn label(self) -> &'static str {
332        match self {
333            Self::Watcher => "watcher",
334            Self::Lsp => "lsp",
335            Self::Short => "short",
336        }
337    }
338}
339
340#[derive(Debug, Default)]
341struct MaintenanceJobOutcome {
342    empty_bg_sessions: Vec<(String, u64)>,
343    requeue_kind: Option<MaintenanceDrainKind>,
344}
345
346#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
347struct ReverseCorrKey {
348    route: RouteChannel,
349    corr: u64,
350}
351
352struct PendingBashAsk {
353    route_channel: u16,
354    tool_corr: u64,
355    tool_flags: Flags,
356    tool_ver: u8,
357    root: ProjectRootId,
358    project_root: PathBuf,
359    session_id: String,
360    request_id: String,
361    arguments: Value,
362    format_context: crate::subc_format::FormatContext,
363    cancel: bash::BashWaitCancel,
364    grants: Vec<String>,
365    expires_at: Instant,
366}
367
368impl RootMeta {
369    fn new(now: Instant) -> Self {
370        Self {
371            maintenance_pending: false,
372            maintenance_jobs_in_flight: 0,
373            maintenance_queued_kinds: VecDeque::new(),
374            maintenance_last_submitted: None,
375            maintenance_poisoned: false,
376            last_touched: now,
377            diagnostics_on_edit: false,
378            active_bash_waits: 0,
379        }
380    }
381
382    fn touch(&mut self) {
383        self.last_touched = Instant::now();
384    }
385}
386
387fn due_maintenance_jobs(
388    live_roots: &mut HashMap<ProjectRootId, RootMeta>,
389    budget: usize,
390    pending_bind_roots: &HashSet<ProjectRootId>,
391) -> (Vec<(ProjectRootId, MaintenanceDrainKind)>, bool) {
392    let mut jobs = Vec::new();
393    let mut deferred = false;
394    let mut roots = live_roots.keys().cloned().collect::<Vec<_>>();
395    roots.sort_by(|left, right| {
396        let left_last = live_roots
397            .get(left)
398            .and_then(|meta| meta.maintenance_last_submitted);
399        let right_last = live_roots
400            .get(right)
401            .and_then(|meta| meta.maintenance_last_submitted);
402        left_last
403            .cmp(&right_last)
404            .then_with(|| left.as_path().cmp(right.as_path()))
405    });
406
407    for root_id in roots {
408        let Some(meta) = live_roots.get_mut(&root_id) else {
409            continue;
410        };
411        if meta.maintenance_poisoned {
412            continue;
413        }
414
415        if pending_bind_roots.contains(&root_id) {
416            if meta.maintenance_pending || !meta.maintenance_queued_kinds.is_empty() {
417                deferred = true;
418            }
419            continue;
420        }
421
422        if !meta.maintenance_pending {
423            if jobs.len() >= budget {
424                deferred = true;
425                continue;
426            }
427            meta.maintenance_pending = true;
428            meta.maintenance_queued_kinds
429                .extend(INITIAL_MAINTENANCE_DRAIN_KINDS);
430        }
431
432        while let Some(kind) = meta.maintenance_queued_kinds.pop_front() {
433            if jobs.len() >= budget {
434                meta.maintenance_queued_kinds.push_front(kind);
435                deferred = true;
436                break;
437            }
438            meta.maintenance_jobs_in_flight += 1;
439            meta.maintenance_last_submitted = Some(Instant::now());
440            jobs.push((root_id.clone(), kind));
441        }
442
443        meta.maintenance_pending =
444            meta.maintenance_jobs_in_flight > 0 || !meta.maintenance_queued_kinds.is_empty();
445    }
446
447    (jobs, deferred)
448}
449
450fn note_maintenance_completion(
451    meta: &mut RootMeta,
452    requeue_kind: Option<MaintenanceDrainKind>,
453    fatal: bool,
454    defer_requeue: bool,
455) {
456    if fatal {
457        meta.maintenance_poisoned = true;
458    }
459
460    if let Some(kind) = requeue_kind.filter(|_| !meta.maintenance_poisoned && !defer_requeue) {
461        meta.maintenance_queued_kinds.push_back(kind);
462    }
463
464    meta.maintenance_jobs_in_flight = meta.maintenance_jobs_in_flight.saturating_sub(1);
465    meta.maintenance_pending =
466        meta.maintenance_jobs_in_flight > 0 || !meta.maintenance_queued_kinds.is_empty();
467}
468
469fn route_key(channel: u16) -> RouteChannel {
470    RouteChannel::from(channel)
471}
472
473fn bash_elicitation_timeout() -> Duration {
474    if cfg!(debug_assertions) {
475        if let Ok(raw) = std::env::var("AFT_TEST_SUBC_BASH_ELICITATION_TTL_MS") {
476            if let Ok(ms) = raw.parse::<u64>() {
477                if ms > 0 {
478                    return Duration::from_millis(ms);
479                }
480            }
481        }
482    }
483    BASH_ELICITATION_TIMEOUT
484}
485
486fn allocate_reverse_corr(
487    pending_bash_asks: &HashMap<ReverseCorrKey, PendingBashAsk>,
488    route: RouteChannel,
489    next_corr: &mut u64,
490) -> u64 {
491    loop {
492        let corr = *next_corr;
493        *next_corr = (*next_corr).wrapping_add(1).max(1);
494        if !pending_bash_asks.contains_key(&ReverseCorrKey { route, corr }) {
495            return corr;
496        }
497    }
498}
499
500fn bash_permission_kind_label(kind: &crate::bash_permissions::PermissionKind) -> &'static str {
501    match kind {
502        crate::bash_permissions::PermissionKind::ExternalDirectory => "external directory",
503        crate::bash_permissions::PermissionKind::Bash => "bash",
504    }
505}
506
507fn bash_elicitation_patterns(asks: &[crate::bash_permissions::PermissionAsk]) -> Vec<String> {
508    let mut patterns = Vec::new();
509    let mut seen = HashSet::new();
510    for ask in asks {
511        for pattern in ask.patterns.iter().chain(ask.always.iter()) {
512            if seen.insert(pattern.clone()) {
513                patterns.push(pattern.clone());
514            }
515        }
516    }
517    patterns
518}
519
520fn bash_elicitation_message(
521    command: &str,
522    asks: &[crate::bash_permissions::PermissionAsk],
523) -> String {
524    let command = command.split_whitespace().collect::<Vec<_>>().join(" ");
525    let patterns = bash_elicitation_patterns(asks);
526    let pattern_text = if patterns.is_empty() {
527        "no matched permission patterns".to_string()
528    } else {
529        patterns.join(", ")
530    };
531    let ask_kinds = asks
532        .iter()
533        .map(|ask| bash_permission_kind_label(&ask.kind))
534        .collect::<HashSet<_>>()
535        .into_iter()
536        .collect::<Vec<_>>()
537        .join(", ");
538    if ask_kinds.is_empty() {
539        format!("Allow bash command `{command}`? Matched patterns: {pattern_text}")
540    } else {
541        format!("Allow bash command `{command}`? Matched {ask_kinds} patterns: {pattern_text}")
542    }
543}
544
545fn bash_elicitation_request_body(
546    command: &str,
547    asks: &[crate::bash_permissions::PermissionAsk],
548) -> Value {
549    json!({
550        "method": BASH_ELICITATION_CREATE_METHOD,
551        "params": {
552            "mode": "form",
553            "message": bash_elicitation_message(command, asks),
554            "requestedSchema": {
555                "type": "object",
556                "properties": {
557                    "decision": {
558                        "type": "string",
559                        "enum": ["allow", "deny"],
560                        "description": "Choose allow to run this bash command once, or deny to block it."
561                    }
562                },
563                "required": ["decision"],
564                "additionalProperties": false
565            },
566            "_meta": {
567                "aft": {
568                    "tool": "bash",
569                    "command": command,
570                    "asks": asks
571                }
572            }
573        }
574    })
575}
576
577fn build_bash_elicitation_request_frame(
578    ver: u8,
579    channel: u16,
580    corr: u64,
581    flags: Flags,
582    command: &str,
583    asks: &[crate::bash_permissions::PermissionAsk],
584) -> Result<Frame, SubcError> {
585    let body = bash_elicitation_request_body(command, asks);
586    Frame::build_with_version(
587        ver,
588        FrameType::Request,
589        flags,
590        channel,
591        corr,
592        serde_json::to_vec(&body).map_err(SubcError::Json)?,
593    )
594    .map_err(SubcError::FrameBuild)
595}
596
597fn bash_elicitation_reply_is_allow(body: &[u8]) -> bool {
598    let Ok(value) = serde_json::from_slice::<Value>(body) else {
599        return false;
600    };
601    flat_bash_elicitation_reply_is_allow(&value) || mcp_bash_elicitation_reply_is_allow(&value)
602}
603
604fn flat_bash_elicitation_reply_is_allow(value: &Value) -> bool {
605    let Some(object) = value.as_object() else {
606        return false;
607    };
608    object.len() == 1 && object.get("decision").and_then(Value::as_str) == Some("allow")
609}
610
611fn mcp_bash_elicitation_reply_is_allow(value: &Value) -> bool {
612    let Some(object) = value.as_object() else {
613        return false;
614    };
615    if object.len() != 2 || object.get("action").and_then(Value::as_str) != Some("accept") {
616        return false;
617    }
618    let Some(content) = object.get("content").and_then(Value::as_object) else {
619        return false;
620    };
621    content.len() == 1 && content.get("decision").and_then(Value::as_str) == Some("allow")
622}
623
624#[allow(clippy::too_many_arguments)]
625async fn settle_pending_bash_ask_denied(
626    tx: &mpsc::Sender<Frame>,
627    pending: PendingBashAsk,
628    routes: &HashMap<RouteChannel, RouteIdentity>,
629    live_roots: &mut HashMap<ProjectRootId, RootMeta>,
630    route_bash_cancels: &mut HashMap<RouteChannel, bash::RouteBashCancel>,
631    shutdown: &Arc<Notify>,
632    metrics: &DispatchPathMetrics,
633) -> Result<(), SubcError> {
634    let completion = bash::bash_denied_untrusted_completion(
635        pending.route_channel,
636        pending.tool_corr,
637        pending.tool_flags,
638        pending.tool_ver,
639        pending.root,
640        pending.request_id,
641        pending.format_context,
642    );
643    bash::handle_bash_deferred_completion(
644        tx,
645        completion,
646        routes,
647        live_roots,
648        route_bash_cancels,
649        shutdown,
650        metrics,
651    )
652    .await
653}
654
655fn take_pending_bash_asks_for_route(
656    pending_bash_asks: &mut HashMap<ReverseCorrKey, PendingBashAsk>,
657    route: RouteChannel,
658) -> Vec<PendingBashAsk> {
659    let keys = pending_bash_asks
660        .keys()
661        .copied()
662        .filter(|key| key.route == route)
663        .collect::<Vec<_>>();
664    keys.into_iter()
665        .filter_map(|key| pending_bash_asks.remove(&key))
666        .collect()
667}
668
669#[allow(clippy::too_many_arguments)]
670async fn settle_pending_bash_asks_for_route(
671    tx: &mpsc::Sender<Frame>,
672    pending_bash_asks: &mut HashMap<ReverseCorrKey, PendingBashAsk>,
673    route: RouteChannel,
674    routes: &HashMap<RouteChannel, RouteIdentity>,
675    live_roots: &mut HashMap<ProjectRootId, RootMeta>,
676    route_bash_cancels: &mut HashMap<RouteChannel, bash::RouteBashCancel>,
677    shutdown: &Arc<Notify>,
678    metrics: &DispatchPathMetrics,
679) -> Result<(), SubcError> {
680    for pending in take_pending_bash_asks_for_route(pending_bash_asks, route) {
681        settle_pending_bash_ask_denied(
682            tx,
683            pending,
684            routes,
685            live_roots,
686            route_bash_cancels,
687            shutdown,
688            metrics,
689        )
690        .await?;
691    }
692    Ok(())
693}
694
695#[allow(clippy::too_many_arguments)]
696async fn settle_all_pending_bash_asks(
697    tx: &mpsc::Sender<Frame>,
698    pending_bash_asks: &mut HashMap<ReverseCorrKey, PendingBashAsk>,
699    routes: &HashMap<RouteChannel, RouteIdentity>,
700    live_roots: &mut HashMap<ProjectRootId, RootMeta>,
701    route_bash_cancels: &mut HashMap<RouteChannel, bash::RouteBashCancel>,
702    shutdown: &Arc<Notify>,
703    metrics: &DispatchPathMetrics,
704) -> Result<(), SubcError> {
705    let pending = pending_bash_asks
706        .drain()
707        .map(|(_, pending)| pending)
708        .collect::<Vec<_>>();
709    for pending in pending {
710        settle_pending_bash_ask_denied(
711            tx,
712            pending,
713            routes,
714            live_roots,
715            route_bash_cancels,
716            shutdown,
717            metrics,
718        )
719        .await?;
720    }
721    Ok(())
722}
723
724#[allow(clippy::too_many_arguments)]
725async fn expire_pending_bash_asks(
726    tx: &mpsc::Sender<Frame>,
727    pending_bash_asks: &mut HashMap<ReverseCorrKey, PendingBashAsk>,
728    routes: &HashMap<RouteChannel, RouteIdentity>,
729    live_roots: &mut HashMap<ProjectRootId, RootMeta>,
730    route_bash_cancels: &mut HashMap<RouteChannel, bash::RouteBashCancel>,
731    shutdown: &Arc<Notify>,
732    metrics: &DispatchPathMetrics,
733) -> Result<(), SubcError> {
734    let now = Instant::now();
735    let expired = pending_bash_asks
736        .iter()
737        .filter_map(|(key, pending)| (pending.expires_at <= now).then_some(*key))
738        .collect::<Vec<_>>();
739    for key in expired {
740        if let Some(pending) = pending_bash_asks.remove(&key) {
741            log::debug!(
742                "subc attach: bash elicitation request {} on route {} expired fail-closed",
743                key.corr,
744                pending.route_channel
745            );
746            settle_pending_bash_ask_denied(
747                tx,
748                pending,
749                routes,
750                live_roots,
751                route_bash_cancels,
752                shutdown,
753                metrics,
754            )
755            .await?;
756        }
757    }
758    Ok(())
759}
760
761#[allow(clippy::too_many_arguments)]
762async fn handle_bash_elicitation_reply(
763    tx: &mpsc::Sender<Frame>,
764    frame: &Frame,
765    pending_bash_asks: &mut HashMap<ReverseCorrKey, PendingBashAsk>,
766    routes: &HashMap<RouteChannel, RouteIdentity>,
767    live_roots: &mut HashMap<ProjectRootId, RootMeta>,
768    executor: &Arc<Executor>,
769    shutdown: &Arc<Notify>,
770    bash_deferred_tx: &mpsc::Sender<bash::BashDeferredCompletion>,
771    bash_poll_touch_tx: &mpsc::Sender<ProjectRootId>,
772    metrics: &Arc<DispatchPathMetrics>,
773    route_bash_cancels: &mut HashMap<RouteChannel, bash::RouteBashCancel>,
774    dispatch: DispatchFn,
775) -> Result<(), SubcError> {
776    let key = ReverseCorrKey {
777        route: route_key(frame.header.channel),
778        corr: frame.header.corr,
779    };
780    let Some(pending) = pending_bash_asks.remove(&key) else {
781        return Ok(());
782    };
783
784    if frame.header.ty == FrameType::Response && bash_elicitation_reply_is_allow(&frame.body) {
785        if routes.contains_key(&key.route) {
786            bash::submit_deferred_bash(
787                executor,
788                bash_deferred_tx,
789                bash_poll_touch_tx,
790                metrics,
791                dispatch,
792                pending.root,
793                pending.project_root,
794                pending.session_id,
795                pending.request_id,
796                pending.route_channel,
797                pending.tool_corr,
798                pending.tool_flags,
799                pending.tool_ver,
800                pending.arguments,
801                pending.format_context,
802                pending.cancel,
803                BindTrust::Untrusted,
804                Some(pending.grants),
805            );
806            return Ok(());
807        }
808        log::debug!(
809            "subc attach: dropping allowed bash elicitation reply {} for unbound route {}",
810            key.corr,
811            pending.route_channel
812        );
813    }
814
815    settle_pending_bash_ask_denied(
816        tx,
817        pending,
818        routes,
819        live_roots,
820        route_bash_cancels,
821        shutdown,
822        metrics,
823    )
824    .await
825}
826
827#[allow(clippy::too_many_arguments)]
828async fn cancel_pending_bash_ask_for_tool_call(
829    tx: &mpsc::Sender<Frame>,
830    pending_bash_asks: &mut HashMap<ReverseCorrKey, PendingBashAsk>,
831    route: RouteChannel,
832    tool_corr: u64,
833    routes: &HashMap<RouteChannel, RouteIdentity>,
834    live_roots: &mut HashMap<ProjectRootId, RootMeta>,
835    route_bash_cancels: &mut HashMap<RouteChannel, bash::RouteBashCancel>,
836    shutdown: &Arc<Notify>,
837    metrics: &DispatchPathMetrics,
838) -> Result<(), SubcError> {
839    let keys = pending_bash_asks
840        .iter()
841        .filter_map(|(key, pending)| {
842            (key.route == route && pending.tool_corr == tool_corr).then_some(*key)
843        })
844        .collect::<Vec<_>>();
845    for key in keys {
846        if let Some(pending) = pending_bash_asks.remove(&key) {
847            settle_pending_bash_ask_denied(
848                tx,
849                pending,
850                routes,
851                live_roots,
852                route_bash_cancels,
853                shutdown,
854                metrics,
855            )
856            .await?;
857        }
858    }
859    Ok(())
860}
861
862fn remove_root_channel(
863    root_channels: &mut HashMap<ProjectRootId, HashSet<RouteChannel>>,
864    root: &ProjectRootId,
865    channel: RouteChannel,
866) {
867    let remove_root = if let Some(channels) = root_channels.get_mut(root) {
868        channels.remove(&channel);
869        channels.is_empty()
870    } else {
871        false
872    };
873    if remove_root {
874        root_channels.remove(root);
875    }
876}
877
878fn remove_route_channel(
879    routes: &mut HashMap<RouteChannel, RouteIdentity>,
880    root_channels: &mut HashMap<ProjectRootId, HashSet<RouteChannel>>,
881    channel: RouteChannel,
882) -> Option<RouteIdentity> {
883    let removed = routes.remove(&channel);
884    if let Some(identity) = &removed {
885        remove_root_channel(root_channels, &identity.root, channel);
886    }
887    removed
888}
889
890fn insert_route_channel(
891    routes: &mut HashMap<RouteChannel, RouteIdentity>,
892    root_channels: &mut HashMap<ProjectRootId, HashSet<RouteChannel>>,
893    channel: RouteChannel,
894    identity: RouteIdentity,
895) {
896    if let Some(previous) = routes.insert(channel, identity.clone()) {
897        remove_root_channel(root_channels, &previous.root, channel);
898    }
899    root_channels
900        .entry(identity.root.clone())
901        .or_default()
902        .insert(channel);
903}
904
905fn remove_bg_subscription_index(
906    bg_sub_by_session: &mut HashMap<(ProjectRootId, String), RouteChannel>,
907    channel: RouteChannel,
908    identity: Option<&RouteIdentity>,
909) {
910    if let Some(identity) = identity {
911        let key = (identity.root.clone(), identity.session.clone());
912        if bg_sub_by_session.get(&key).copied() == Some(channel) {
913            bg_sub_by_session.remove(&key);
914        }
915    } else {
916        bg_sub_by_session.retain(|_, mapped_channel| *mapped_channel != channel);
917    }
918}
919
920fn end_bg_subscription(
921    writer_tx: &mpsc::Sender<Frame>,
922    metrics: &DispatchPathMetrics,
923    bg_subs: &mut HashMap<RouteChannel, BgSub>,
924    bg_sub_by_session: &mut HashMap<(ProjectRootId, String), RouteChannel>,
925    bg_wake_pending: &mut HashSet<RouteChannel>,
926    channel: RouteChannel,
927    identity: Option<&RouteIdentity>,
928) {
929    if let Some(sub) = bg_subs.get(&channel).copied() {
930        let _ = push::try_send_bg_stream_end(writer_tx, metrics, channel, &sub);
931        bg_subs.remove(&channel);
932        bg_wake_pending.remove(&channel);
933        remove_bg_subscription_index(bg_sub_by_session, channel, identity);
934    }
935}
936
937fn remember_session_identity(
938    session_identity: &mut HashMap<(ProjectRootId, String), RetainedSessionIdentity>,
939    identity: &RouteIdentity,
940) {
941    let key = (identity.root.clone(), identity.session.clone());
942    if matches!(identity.trust, BindTrust::Untrusted)
943        && session_identity
944            .get(&key)
945            .is_some_and(|retained| matches!(retained.trust, BindTrust::FirstParty))
946    {
947        return;
948    }
949
950    // Retained after route Goodbye so reliable session-scoped frames emitted while
951    // the session is detached can still be keyed by the full (root,harness,session)
952    // replay triple. Untrusted binds never overwrite a retained first-party
953    // session identity, because bash completion replay is an observation channel.
954    session_identity.insert(
955        key,
956        RetainedSessionIdentity {
957            harness: identity.harness.clone(),
958            trust: identity.trust,
959        },
960    );
961}
962
963fn replay_key_for_session(
964    session_identity: &HashMap<(ProjectRootId, String), RetainedSessionIdentity>,
965    root: &ProjectRootId,
966    session: &str,
967) -> Option<(push::ReplayKey, BindTrust)> {
968    let retained = session_identity.get(&(root.clone(), session.to_string()))?;
969    Some((
970        push::ReplayKey {
971            root: root.clone(),
972            harness: retained.harness.clone(),
973            session: session.to_string(),
974        },
975        retained.trust,
976    ))
977}
978/// Sync command dispatch, passed in from `main` (the binary owns the command
979/// table). Invoked only inside executor jobs in subc mode.
980pub type DispatchFn = fn(RawRequest, &AppContext) -> Response;
981
982#[derive(Clone, Copy, Debug, PartialEq, Eq)]
983enum ModuleLoopExit {
984    Graceful,
985    SkipSearchFlush,
986}
987
988/// Entry point for `aft --subc <connection-file>`. Synchronous on the outside;
989/// owns an isolated current-thread tokio runtime for the async transport.
990/// Returns `Err` (fail-loud) on any connect/auth/protocol failure — we never
991/// fall back to the standalone loop, to avoid split-brain index state.
992pub fn run_subc_mode(
993    connection_file_path: &Path,
994    ctx: Arc<AppContext>,
995    executor: Arc<Executor>,
996    dispatch: DispatchFn,
997    user_config_path: Option<PathBuf>,
998) -> Result<(), SubcError> {
999    // Production NEVER allows non-manifest tool names on route channels: AFT
1000    // fails closed and does not trust subc to enforce the manifest. The
1001    // test-only harness sets this through `run_subc_mode_for_test`.
1002    run_subc_mode_inner(
1003        connection_file_path,
1004        ctx,
1005        executor,
1006        dispatch,
1007        user_config_path,
1008        false,
1009    )
1010}
1011
1012fn run_subc_mode_inner(
1013    connection_file_path: &Path,
1014    ctx: Arc<AppContext>,
1015    executor: Arc<Executor>,
1016    dispatch: DispatchFn,
1017    user_config_path: Option<PathBuf>,
1018    allow_native_passthrough: bool,
1019) -> Result<(), SubcError> {
1020    let runtime = tokio::runtime::Builder::new_current_thread()
1021        .enable_all()
1022        .build()
1023        .map_err(SubcError::Runtime)?;
1024
1025    let executor_for_loop = Arc::clone(&executor);
1026    let loop_result = runtime.block_on(async move {
1027        let shared_app = ctx.app();
1028        drop(ctx);
1029        let stream = connect_and_authenticate(connection_file_path).await?;
1030        log::info!(
1031            "subc attach: authenticated to daemon via {}",
1032            connection_file_path.display()
1033        );
1034        let (read_half, write_half) = tokio::io::split(stream);
1035        run_module_loop(
1036            read_half,
1037            write_half,
1038            shared_app,
1039            executor_for_loop,
1040            dispatch,
1041            user_config_path,
1042            allow_native_passthrough,
1043        )
1044        .await
1045    });
1046
1047    let actor_contexts = executor.actor_contexts();
1048    if matches!(loop_result, Ok(ModuleLoopExit::Graceful)) {
1049        // EOF/Goodbye teardown flushes each root's in-memory trigram delta.
1050        // Fatal/panic-driven connection teardown skips this best-effort disk work.
1051        flush_actor_search_indexes_on_graceful_shutdown(&actor_contexts);
1052    }
1053    for actor_ctx in &actor_contexts {
1054        actor_ctx.lsp().shutdown_all();
1055        actor_ctx.bash_background().detach();
1056    }
1057
1058    loop_result.map(|_| ())
1059}
1060
1061fn flush_actor_search_indexes_on_graceful_shutdown(actor_contexts: &[Arc<AppContext>]) {
1062    for actor_ctx in actor_contexts {
1063        let _ = actor_ctx.flush_search_index_on_graceful_shutdown();
1064    }
1065}
1066
1067/// Test-only entry that enables the non-manifest native-command passthrough on
1068/// route channels. Integration tests drive synthetic native commands (`glob`,
1069/// `callers`, `subc_test_echo_session`, …) through the executor to exercise
1070/// mechanics; production callers use [`run_subc_mode`], which fails closed.
1071#[doc(hidden)]
1072pub fn run_subc_mode_for_test(
1073    connection_file_path: &Path,
1074    ctx: Arc<AppContext>,
1075    executor: Arc<Executor>,
1076    dispatch: DispatchFn,
1077    user_config_path: Option<PathBuf>,
1078) -> Result<(), SubcError> {
1079    run_subc_mode_inner(
1080        connection_file_path,
1081        ctx,
1082        executor,
1083        dispatch,
1084        user_config_path,
1085        true,
1086    )
1087}
1088
1089/// Read the connection file → resolve the first endpoint → TCP connect → HMAC
1090/// handshake. Mirrors the reference `fake-aft-stub::connect_to_subc`.
1091async fn connect_and_authenticate(connection_file_path: &Path) -> Result<TcpStream, SubcError> {
1092    let conn = connection_file::read(connection_file_path).map_err(|source| {
1093        SubcError::ConnectionFile {
1094            path: connection_file_path.to_path_buf(),
1095            source,
1096        }
1097    })?;
1098
1099    let endpoint = conn
1100        .endpoints
1101        .first()
1102        .ok_or_else(|| SubcError::NoEndpoint {
1103            path: connection_file_path.to_path_buf(),
1104        })?;
1105    let endpoint_label = format!("{}:{}", endpoint.host, endpoint.port);
1106    let ip = endpoint
1107        .host
1108        .parse::<IpAddr>()
1109        .map_err(|_| SubcError::InvalidEndpoint {
1110            path: connection_file_path.to_path_buf(),
1111            endpoint: endpoint_label.clone(),
1112        })?;
1113    let addr = SocketAddr::new(ip, endpoint.port);
1114
1115    let mut stream = TcpStream::connect(addr)
1116        .await
1117        .map_err(|source| SubcError::Connect {
1118            endpoint: endpoint_label.clone(),
1119            source,
1120        })?;
1121
1122    authenticate_client(&mut stream, &conn, AUTH_DEADLINE)
1123        .await
1124        .map_err(|source| SubcError::Auth {
1125            endpoint: endpoint_label,
1126            source,
1127        })?;
1128
1129    Ok(stream)
1130}
1131
1132#[allow(clippy::too_many_arguments)]
1133async fn process_route_bind_completion(
1134    writer_tx: &mpsc::Sender<Frame>,
1135    completion: RouteBindCompletion,
1136    routes: &mut HashMap<RouteChannel, RouteIdentity>,
1137    root_channels: &mut HashMap<ProjectRootId, HashSet<RouteChannel>>,
1138    session_identity: &mut HashMap<(ProjectRootId, String), RetainedSessionIdentity>,
1139    push_buffer: &mut HashMap<push::ReplayKey, VecDeque<PushFrame>>,
1140    live_roots: &mut HashMap<ProjectRootId, RootMeta>,
1141    pending_binds: &mut HashMap<RouteChannel, PendingBind>,
1142    executor: &Arc<Executor>,
1143    shutdown: &Arc<Notify>,
1144    metrics: &DispatchPathMetrics,
1145) -> Result<(), SubcError> {
1146    decrement_counted_channel(&metrics.control_completion_queued);
1147    handle_route_bind_completion(
1148        writer_tx,
1149        completion,
1150        routes,
1151        root_channels,
1152        session_identity,
1153        push_buffer,
1154        live_roots,
1155        pending_binds,
1156        executor,
1157        shutdown,
1158        metrics,
1159    )
1160    .await
1161}
1162
1163#[allow(clippy::too_many_arguments)]
1164async fn drain_pending_route_bind_completions(
1165    control_completion_rx: &mut mpsc::Receiver<RouteBindCompletion>,
1166    writer_tx: &mpsc::Sender<Frame>,
1167    routes: &mut HashMap<RouteChannel, RouteIdentity>,
1168    root_channels: &mut HashMap<ProjectRootId, HashSet<RouteChannel>>,
1169    session_identity: &mut HashMap<(ProjectRootId, String), RetainedSessionIdentity>,
1170    push_buffer: &mut HashMap<push::ReplayKey, VecDeque<PushFrame>>,
1171    live_roots: &mut HashMap<ProjectRootId, RootMeta>,
1172    pending_binds: &mut HashMap<RouteChannel, PendingBind>,
1173    executor: &Arc<Executor>,
1174    shutdown: &Arc<Notify>,
1175    metrics: &DispatchPathMetrics,
1176) -> Result<(), SubcError> {
1177    while let Ok(completion) = control_completion_rx.try_recv() {
1178        process_route_bind_completion(
1179            writer_tx,
1180            completion,
1181            routes,
1182            root_channels,
1183            session_identity,
1184            push_buffer,
1185            live_roots,
1186            pending_binds,
1187            executor,
1188            shutdown,
1189            metrics,
1190        )
1191        .await?;
1192    }
1193    Ok(())
1194}
1195
1196/// ModuleHello → HelloAck → control/route loop. Runs until the daemon closes
1197/// the connection (EOF), sends channel-0 Goodbye, or a fatal mutating executor
1198/// response requests whole-connection teardown.
1199async fn run_module_loop<R, W>(
1200    mut read: R,
1201    mut write: W,
1202    shared_app: Arc<App>,
1203    executor: Arc<Executor>,
1204    dispatch: DispatchFn,
1205    user_config_path: Option<PathBuf>,
1206    allow_native_passthrough: bool,
1207) -> Result<ModuleLoopExit, SubcError>
1208where
1209    R: AsyncRead + Unpin + Send + 'static,
1210    W: AsyncWrite + Unpin + Send + 'static,
1211{
1212    // ModuleHello: register as a tool provider and advertise the supported control-plane operations.
1213    // Echo the one-time launch nonce the daemon injected via SUBC_LAUNCH_NONCE so a
1214    // reserved module_id's HELLO is accepted; absent for non-reserved/self-connect.
1215    let hello = ModuleHelloBody {
1216        manifest: build_manifest(),
1217        protocol_ver: PROTOCOL_VERSION,
1218        control_ops: control_ops(),
1219        launch_nonce: std::env::var("SUBC_LAUNCH_NONCE").ok(),
1220    };
1221    let hello_frame = Frame::build(
1222        FrameType::Hello,
1223        control_flags(),
1224        0,
1225        HELLO_CORR,
1226        serde_json::to_vec(&hello).map_err(SubcError::Json)?,
1227    )
1228    .map_err(SubcError::FrameBuild)?;
1229    write_frame(&mut write, &hello_frame)
1230        .await
1231        .map_err(SubcError::FrameIo)?;
1232
1233    // Expect HelloAck (registered) or a channel-0 Error (manifest/version reject).
1234    match read_frame(&mut read).await.map_err(SubcError::FrameIo)? {
1235        None => return Err(SubcError::ClosedBeforeHelloAck),
1236        Some(frame) => match frame.header.ty {
1237            FrameType::HelloAck => {
1238                log::info!("subc attach: registered (HelloAck received)");
1239            }
1240            FrameType::Error => {
1241                let body = serde_json::from_slice::<ErrorBody>(&frame.body).ok();
1242                return Err(SubcError::HelloRejected { body });
1243            }
1244            other => return Err(SubcError::UnexpectedFrame { ty: other }),
1245        },
1246    }
1247
1248    let dispatch_path_metrics = Arc::new(DispatchPathMetrics::new());
1249    let (writer_tx, writer_rx) = mpsc::channel::<Frame>(WRITER_QUEUE_CAPACITY);
1250    let writer_task = spawn_writer_task(write, writer_rx, Arc::clone(&dispatch_path_metrics));
1251    // `read_frame` is NOT cancellation-safe, so it must never sit directly inside
1252    // the `select!` below: a drain-interval tick (or shutdown) firing while a
1253    // frame is mid-transit would drop the partially-consumed bytes and desync the
1254    // stream (the next read would parse a body byte as a frame header). A
1255    // dedicated reader task owns the socket, reads whole frames sequentially, and
1256    // forwards them over a channel; the loop selects on the cancel-safe `recv()`.
1257    let (reader_tx, mut reader_rx) = mpsc::channel::<Result<Frame, SubcError>>(256);
1258    let reader_task = spawn_reader_task(read, reader_tx);
1259    let shutdown = Arc::new(Notify::new());
1260    // Drain-tick deadline is tracked manually and checked at the TOP of every
1261    // loop turn rather than as an Interval select arm: the select below is
1262    // `biased` (bind completions first), and biased polling means a saturated
1263    // higher arm (sustained lossy push traffic keeps lossy_rx always-ready)
1264    // would starve every arm below it, including a timer arm — leaving
1265    // backpressured reliable frames parked in the retry buffer past their
1266    // delivery deadline. The pre-turn check cannot be starved by arm order;
1267    // the sleep_until arm below only exists to wake an otherwise-idle loop.
1268    let mut next_drain_at = tokio::time::Instant::now() + DRAIN_TICK_PERIOD;
1269    let (maintenance_tx, mut maintenance_rx) = mpsc::channel::<MaintenanceCompletion>(256);
1270    let (bash_deferred_tx, mut bash_deferred_rx) =
1271        mpsc::channel::<bash::BashDeferredCompletion>(256);
1272    let (bash_poll_touch_tx, mut bash_poll_touch_rx) = mpsc::channel::<ProjectRootId>(256);
1273    let (control_completion_tx, mut control_completion_rx) =
1274        mpsc::channel::<RouteBindCompletion>(256);
1275    let (lossy_tx, mut lossy_rx) = mpsc::channel::<PushEnvelope>(1024);
1276    let (reliable_tx, mut reliable_rx) = mpsc::unbounded_channel::<PushEnvelope>();
1277    let push_senders = PushSenders {
1278        lossy_tx,
1279        reliable_tx,
1280    };
1281    let connection_cancel = PersistentCancelSignal::new();
1282    let mut routes: HashMap<RouteChannel, RouteIdentity> = HashMap::new();
1283    let mut bg_subs: HashMap<RouteChannel, BgSub> = HashMap::new();
1284    let mut bg_sub_by_session: HashMap<(ProjectRootId, String), RouteChannel> = HashMap::new();
1285    let mut bg_wake_pending: HashSet<RouteChannel> = HashSet::new();
1286    let mut bg_wake_epoch: HashMap<(ProjectRootId, String), u64> = HashMap::new();
1287    let mut root_channels: HashMap<ProjectRootId, HashSet<RouteChannel>> = HashMap::new();
1288    let mut session_identity: HashMap<(ProjectRootId, String), RetainedSessionIdentity> =
1289        HashMap::new();
1290    let mut push_buffer: HashMap<push::ReplayKey, VecDeque<PushFrame>> = HashMap::new();
1291    let mut retry_buffer: RetryBuffer = HashMap::new();
1292    let mut completed_tasks = push::CompletedTaskIds::default();
1293    let mut live_roots: HashMap<ProjectRootId, RootMeta> = HashMap::new();
1294    let mut pending_binds: HashMap<RouteChannel, PendingBind> = HashMap::new();
1295    let mut pending_bash_asks: HashMap<ReverseCorrKey, PendingBashAsk> = HashMap::new();
1296    let mut next_bash_ask_corr: u64 = 1;
1297    let mut route_bash_cancels: HashMap<RouteChannel, bash::RouteBashCancel> = HashMap::new();
1298
1299    let loop_result: Result<ModuleLoopExit, SubcError> = loop {
1300        dispatch_path_metrics.mark_frame_loop_tick();
1301        if let Err(error) = expire_pending_bash_asks(
1302            &writer_tx,
1303            &mut pending_bash_asks,
1304            &routes,
1305            &mut live_roots,
1306            &mut route_bash_cancels,
1307            &shutdown,
1308            &dispatch_path_metrics,
1309        )
1310        .await
1311        {
1312            break Err(error);
1313        }
1314
1315        // RouteBind completions are control-plane unblockers. Drain any completed
1316        // binds before entering other branch work so Push and maintenance bursts
1317        // can only add one loop-turn of latency.
1318        if let Err(error) = drain_pending_route_bind_completions(
1319            &mut control_completion_rx,
1320            &writer_tx,
1321            &mut routes,
1322            &mut root_channels,
1323            &mut session_identity,
1324            &mut push_buffer,
1325            &mut live_roots,
1326            &mut pending_binds,
1327            &executor,
1328            &shutdown,
1329            &dispatch_path_metrics,
1330        )
1331        .await
1332        {
1333            break Err(error);
1334        }
1335
1336        if tokio::time::Instant::now() >= next_drain_at {
1337            push::emit_bg_event_wakes(
1338                &writer_tx,
1339                &dispatch_path_metrics,
1340                &bg_subs,
1341                &mut bg_wake_pending,
1342            );
1343            warn_slow_pending_binds(&mut pending_binds, &executor);
1344            if let Err(error) =
1345                expire_overdue_route_binds(&writer_tx, &mut pending_binds, &dispatch_path_metrics)
1346                    .await
1347            {
1348                break Err(error);
1349            }
1350
1351            let retried = push::drain_retry_buffers_for_bound_routes(
1352                &writer_tx,
1353                &dispatch_path_metrics,
1354                &routes,
1355                &mut retry_buffer,
1356            );
1357            if retried > 0 {
1358                log::debug!(
1359                    "subc attach: retried {retried} reliable Push frame(s) after writer backpressure"
1360                );
1361            }
1362
1363            let pending_bind_roots = pending_binds
1364                .values()
1365                .map(|pending| pending.bind_root_id.clone())
1366                .collect::<HashSet<_>>();
1367            let (due_jobs, deferred_jobs) = due_maintenance_jobs(
1368                &mut live_roots,
1369                MAINTENANCE_SUBMIT_BUDGET,
1370                &pending_bind_roots,
1371            );
1372            if deferred_jobs {
1373                dispatch_path_metrics
1374                    .maintenance_budget_deferrals
1375                    .fetch_add(1, Ordering::Relaxed);
1376            }
1377            for (root_id, kind) in due_jobs {
1378                let bg_sessions_to_check = if kind == MaintenanceDrainKind::Short {
1379                    bg_sub_by_session
1380                        .iter()
1381                        .filter_map(|((root, session), _)| {
1382                            if root == &root_id {
1383                                Some((
1384                                    session.clone(),
1385                                    bg_wake_epoch
1386                                        .get(&(root_id.clone(), session.clone()))
1387                                        .copied()
1388                                        .unwrap_or(0),
1389                                ))
1390                            } else {
1391                                None
1392                            }
1393                        })
1394                        .collect()
1395                } else {
1396                    Vec::new()
1397                };
1398                submit_maintenance_job(
1399                    &executor,
1400                    root_id,
1401                    kind,
1402                    bg_sessions_to_check,
1403                    &maintenance_tx,
1404                    &dispatch_path_metrics,
1405                );
1406            }
1407            next_drain_at = tokio::time::Instant::now() + DRAIN_TICK_PERIOD;
1408        }
1409
1410        tokio::select! {
1411            biased;
1412            Some(completion) = control_completion_rx.recv() => {
1413                if let Err(error) = process_route_bind_completion(
1414                    &writer_tx,
1415                    completion,
1416                    &mut routes,
1417                    &mut root_channels,
1418                    &mut session_identity,
1419                    &mut push_buffer,
1420                    &mut live_roots,
1421                    &mut pending_binds,
1422                    &executor,
1423                    &shutdown,
1424                    &dispatch_path_metrics,
1425                )
1426                .await
1427                {
1428                    break Err(error);
1429                }
1430            }
1431            _ = shutdown.notified() => {
1432                log::warn!("subc attach: fatal executor response requested teardown");
1433                break Ok(ModuleLoopExit::SkipSearchFlush);
1434            }
1435            maybe_frame = reader_rx.recv() => {
1436                let frame = match maybe_frame {
1437                    None => {
1438                        log::info!("subc attach: daemon closed connection");
1439                        break Ok(ModuleLoopExit::Graceful);
1440                    }
1441                    Some(Err(error)) => break Err(error),
1442                    Some(Ok(frame)) => frame,
1443                };
1444
1445                match frame.header.ty {
1446                    FrameType::Ping if frame.header.channel == 0 => {
1447                        let pong = match Frame::build_with_version(
1448                            frame.header.ver,
1449                            FrameType::Pong,
1450                            frame.header.flags,
1451                            0,
1452                            frame.header.corr,
1453                            Vec::new(),
1454                        ) {
1455                            Ok(pong) => pong,
1456                            Err(error) => break Err(SubcError::FrameBuild(error)),
1457                        };
1458                        if let Err(error) = send_frame(&writer_tx, &dispatch_path_metrics, pong).await {
1459                            break Err(error);
1460                        }
1461                    }
1462                    FrameType::Goodbye if frame.header.channel == 0 => {
1463                        log::info!("subc attach: received channel-0 Goodbye");
1464                        break Ok(ModuleLoopExit::Graceful);
1465                    }
1466                    FrameType::Goodbye => {
1467                        let channel = route_key(frame.header.channel);
1468                        end_bg_subscription(
1469                            &writer_tx,
1470                            &dispatch_path_metrics,
1471                            &mut bg_subs,
1472                            &mut bg_sub_by_session,
1473                            &mut bg_wake_pending,
1474                            channel,
1475                            routes.get(&channel),
1476                        );
1477                        if let Err(error) = settle_pending_bash_asks_for_route(
1478                            &writer_tx,
1479                            &mut pending_bash_asks,
1480                            channel,
1481                            &routes,
1482                            &mut live_roots,
1483                            &mut route_bash_cancels,
1484                            &shutdown,
1485                            &dispatch_path_metrics,
1486                        )
1487                        .await
1488                        {
1489                            break Err(error);
1490                        }
1491                        if let Some(cancel) = route_bash_cancels.remove(&channel) {
1492                            cancel.token.cancel();
1493                        }
1494                        if let Some(pending) = pending_binds.get_mut(&channel) {
1495                            pending.cancelled = true;
1496                            log::debug!(
1497                                "subc attach: cancelled pending RouteBind for route {} on Goodbye",
1498                                frame.header.channel
1499                            );
1500                        }
1501                        let migrated = push::migrate_retry_buffer_to_push_buffer(
1502                            &mut retry_buffer,
1503                            channel,
1504                            &mut push_buffer,
1505                        );
1506                        if let Some(identity) = remove_route_channel(&mut routes, &mut root_channels, channel) {
1507                            if migrated > 0 {
1508                                log::debug!(
1509                                    "subc attach: migrated {migrated} retry-buffered reliable Push frame(s) from route {} into detach replay",
1510                                    frame.header.channel
1511                                );
1512                            }
1513                            if let Some(meta) = live_roots.get_mut(&identity.root) {
1514                                let idle_for = meta.last_touched.elapsed();
1515                                meta.touch();
1516                                log::debug!(
1517                                    "subc attach: route {} torn down for root {} harness {} session {} (last touched {:?} ago)",
1518                                    frame.header.channel,
1519                                    identity.root.as_path().display(),
1520                                    identity.harness,
1521                                    identity.session,
1522                                    idle_for
1523                                );
1524                            } else {
1525                                log::debug!(
1526                                    "subc attach: route {} torn down for root {} harness {} session {}",
1527                                    frame.header.channel,
1528                                    identity.root.as_path().display(),
1529                                    identity.harness,
1530                                    identity.session
1531                                );
1532                            }
1533                        } else {
1534                            if migrated > 0 {
1535                                log::debug!(
1536                                    "subc attach: migrated {migrated} retry-buffered reliable Push frame(s) from unbound route {} into detach replay",
1537                                    frame.header.channel
1538                                );
1539                            }
1540                            log::debug!("subc attach: unbound route {} torn down", frame.header.channel);
1541                        }
1542                    }
1543                    FrameType::Response | FrameType::Error if frame.header.channel != 0 => {
1544                        if let Err(error) = handle_bash_elicitation_reply(
1545                            &writer_tx,
1546                            &frame,
1547                            &mut pending_bash_asks,
1548                            &routes,
1549                            &mut live_roots,
1550                            &executor,
1551                            &shutdown,
1552                            &bash_deferred_tx,
1553                            &bash_poll_touch_tx,
1554                            &dispatch_path_metrics,
1555                            &mut route_bash_cancels,
1556                            dispatch,
1557                        )
1558                        .await
1559                        {
1560                            break Err(error);
1561                        }
1562                    }
1563                    FrameType::Request if frame.header.channel == 0 => {
1564                        if let Err(error) = handle_control_request(
1565                            &writer_tx,
1566                            &frame,
1567                            &shared_app,
1568                            &executor,
1569                            &mut live_roots,
1570                            &mut pending_binds,
1571                            &control_completion_tx,
1572                            &dispatch_path_metrics,
1573                            &push_senders,
1574                            dispatch,
1575                            user_config_path.as_deref(),
1576                        )
1577                        .await
1578                        {
1579                            break Err(error);
1580                        }
1581                    }
1582                    FrameType::Request => {
1583                        if let Err(error) = handle_tool_call(
1584                            &writer_tx,
1585                            &frame,
1586                            &routes,
1587                            &pending_binds,
1588                            &mut live_roots,
1589                            &executor,
1590                            &shutdown,
1591                            &connection_cancel,
1592                            &bash_deferred_tx,
1593                            &bash_poll_touch_tx,
1594                            &dispatch_path_metrics,
1595                            &mut route_bash_cancels,
1596                            &mut pending_bash_asks,
1597                            &mut next_bash_ask_corr,
1598                            &mut bg_subs,
1599                            &mut bg_sub_by_session,
1600                            &mut bg_wake_pending,
1601                            &mut bg_wake_epoch,
1602                            dispatch,
1603                            allow_native_passthrough,
1604                        )
1605                        .await
1606                        {
1607                            break Err(error);
1608                        }
1609                    }
1610                    FrameType::Cancel => {
1611                        let channel = route_key(frame.header.channel);
1612                        if bg_subs.contains_key(&channel) {
1613                            end_bg_subscription(
1614                                &writer_tx,
1615                                &dispatch_path_metrics,
1616                                &mut bg_subs,
1617                                &mut bg_sub_by_session,
1618                                &mut bg_wake_pending,
1619                                channel,
1620                                routes.get(&channel),
1621                            );
1622                        }
1623                        if let Err(error) = cancel_pending_bash_ask_for_tool_call(
1624                            &writer_tx,
1625                            &mut pending_bash_asks,
1626                            channel,
1627                            frame.header.corr,
1628                            &routes,
1629                            &mut live_roots,
1630                            &mut route_bash_cancels,
1631                            &shutdown,
1632                            &dispatch_path_metrics,
1633                        )
1634                        .await
1635                        {
1636                            break Err(error);
1637                        }
1638                    }
1639                    // Incoming push messages are ignored here. Cancel frames only
1640                    // stop pending bash elicitation requests; executor-level
1641                    // cancellation for tool calls that are already running is not
1642                    // implemented.
1643                    _ => {}
1644                }
1645            }
1646            Some((root_id, frame)) = reliable_rx.recv() => {
1647                // Reliable Push frames are FIFO and must-deliver, but draining an
1648                // unbounded burst in one current-thread turn can starve RouteBind
1649                // completions. The budget defers excess frames, never drops them.
1650                let (_, deferred) = push::drain_reliable_push_turn(
1651                    &writer_tx,
1652                    &dispatch_path_metrics,
1653                    &routes,
1654                    &root_channels,
1655                    &session_identity,
1656                    &mut retry_buffer,
1657                    &mut push_buffer,
1658                    &mut completed_tasks,
1659                    &bg_sub_by_session,
1660                    &mut bg_wake_pending,
1661                    &mut bg_wake_epoch,
1662                    &mut reliable_rx,
1663                    Some((root_id, frame)),
1664                );
1665                if deferred {
1666                    tokio::task::yield_now().await;
1667                }
1668            }
1669            Some((root_id, frame)) = lossy_rx.recv() => {
1670                // When both push lanes have work, handle a small reliable slice before lossy work.
1671                // That ordering lets completed task ids suppress stale BashLongRunning frames.
1672                // The slice stays bounded so reliable bursts cannot monopolize this loop turn.
1673                let (_, deferred) = push::drain_reliable_push_turn(
1674                    &writer_tx,
1675                    &dispatch_path_metrics,
1676                    &routes,
1677                    &root_channels,
1678                    &session_identity,
1679                    &mut retry_buffer,
1680                    &mut push_buffer,
1681                    &mut completed_tasks,
1682                    &bg_sub_by_session,
1683                    &mut bg_wake_pending,
1684                    &mut bg_wake_epoch,
1685                    &mut reliable_rx,
1686                    None,
1687                );
1688                if deferred {
1689                    tokio::task::yield_now().await;
1690                }
1691
1692                // Drain the currently queued burst in one loop turn so lossy
1693                // status/progress classes coalesce before reaching subc's shared
1694                // egress queue.
1695                let mut batch = vec![(root_id, frame)];
1696                while let Ok(item) = lossy_rx.try_recv() {
1697                    batch.push(item);
1698                }
1699
1700                for (root, frame) in push::coalesce_push_batch(batch) {
1701                    push::process_lossy_push_frame(
1702                        &writer_tx,
1703                        &dispatch_path_metrics,
1704                        &routes,
1705                        &root_channels,
1706                        &completed_tasks,
1707                        root,
1708                        frame,
1709                    );
1710                }
1711            }
1712            Some(done) = bash_deferred_rx.recv() => {
1713                decrement_counted_channel(&dispatch_path_metrics.bash_deferred_queued);
1714                if let Err(error) = bash::handle_bash_deferred_completion(
1715                    &writer_tx,
1716                    done,
1717                    &routes,
1718                    &mut live_roots,
1719                    &mut route_bash_cancels,
1720                    &shutdown,
1721                    &dispatch_path_metrics,
1722                )
1723                .await
1724                {
1725                    break Err(error);
1726                }
1727            }
1728            Some(root_id) = bash_poll_touch_rx.recv() => {
1729                decrement_counted_channel(&dispatch_path_metrics.bash_poll_touch_queued);
1730                if let Some(meta) = live_roots.get_mut(&root_id) {
1731                    meta.touch();
1732                }
1733            }
1734            Some(completion) = maintenance_rx.recv() => {
1735                decrement_counted_channel(&dispatch_path_metrics.maintenance_queued);
1736                let root_id = completion.root_id.clone();
1737                let response = completion.response;
1738                let response_is_fatal = response_is_fatal_panic(&response);
1739                if let Some(meta) = live_roots.get_mut(&root_id) {
1740                    let defer_requeue = pending_binds
1741                        .values()
1742                        .any(|pending| pending.bind_root_id == root_id);
1743                    note_maintenance_completion(
1744                        meta,
1745                        completion.requeue_kind,
1746                        response_is_fatal,
1747                        defer_requeue,
1748                    );
1749                }
1750                push::clear_stale_bg_wakes_for_empty_sessions(
1751                    &root_id,
1752                    &completion.empty_bg_sessions,
1753                    &bg_sub_by_session,
1754                    &mut bg_wake_pending,
1755                    &bg_wake_epoch,
1756                );
1757                if response_is_fatal {
1758                    if let Some(meta) = live_roots.get_mut(&root_id) {
1759                        meta.maintenance_poisoned = true;
1760                    }
1761                    log::warn!(
1762                        "subc attach: maintenance drain observed a fatal actor; deferring teardown until a route request can receive actor_fatal"
1763                    );
1764                }
1765            }
1766            _ = tokio::time::sleep_until(next_drain_at) => {
1767                // Wakes an otherwise-idle loop so the pre-turn drain check
1768                // above runs on schedule; the drain work itself lives there.
1769            }
1770        }
1771    };
1772
1773    let mut loop_result = loop_result;
1774    if !pending_bash_asks.is_empty() {
1775        let no_routes: HashMap<RouteChannel, RouteIdentity> = HashMap::new();
1776        if let Err(error) = settle_all_pending_bash_asks(
1777            &writer_tx,
1778            &mut pending_bash_asks,
1779            &no_routes,
1780            &mut live_roots,
1781            &mut route_bash_cancels,
1782            &shutdown,
1783            &dispatch_path_metrics,
1784        )
1785        .await
1786        {
1787            loop_result = loop_result.and(Err(error));
1788        }
1789    }
1790
1791    // The reader task may be parked on `read_frame`; abort it (we are done with
1792    // the connection) and flush the writer.
1793    connection_cancel.cancel();
1794    reader_task.abort();
1795    drop(writer_tx);
1796    let writer_result = finish_writer_task(writer_task).await;
1797    loop_result.and_then(|exit| writer_result.map(|_| exit))
1798}
1799
1800fn spawn_writer_task<W>(
1801    mut write: W,
1802    mut rx: mpsc::Receiver<Frame>,
1803    metrics: Arc<DispatchPathMetrics>,
1804) -> JoinHandle<Result<(), subc_transport::FrameIoError>>
1805where
1806    W: AsyncWrite + Unpin + Send + 'static,
1807{
1808    tokio::spawn(async move {
1809        while let Some(frame) = rx.recv().await {
1810            decrement_counted_channel(&metrics.writer_queued);
1811            write_frame(&mut write, &frame).await?;
1812        }
1813        Ok(())
1814    })
1815}
1816
1817/// Owns the read half and reads whole frames sequentially. `read_frame` is not
1818/// cancellation-safe, so it must run here — never inside the main loop's
1819/// `select!` — to keep the inbound stream framed. Each frame (or the terminal
1820/// error / EOF) is forwarded over `tx`; the loop consumes them via cancel-safe
1821/// `recv()`. Exits on EOF (Ok(None)), a read error, or when `tx` is dropped
1822/// (the loop ended and aborted us).
1823fn spawn_reader_task<R>(mut read: R, tx: mpsc::Sender<Result<Frame, SubcError>>) -> JoinHandle<()>
1824where
1825    R: AsyncRead + Unpin + Send + 'static,
1826{
1827    tokio::spawn(async move {
1828        loop {
1829            match read_frame(&mut read).await {
1830                Ok(Some(frame)) => {
1831                    if tx.send(Ok(frame)).await.is_err() {
1832                        return;
1833                    }
1834                }
1835                Ok(None) => {
1836                    // EOF: let the loop observe channel close as "daemon closed".
1837                    return;
1838                }
1839                Err(error) => {
1840                    // A killed daemon surfaces as ConnectionReset (RST) on
1841                    // Windows where Unix delivers a clean EOF (FIN); a
1842                    // mid-teardown daemon can also abort the socket. Both mean
1843                    // "daemon went away", not a wire fault — normalize them to
1844                    // the clean-close path so module exit behavior matches
1845                    // across platforms (same class subc-core fixed in d33d9a71).
1846                    if let subc_transport::FrameIoError::Io(io_error) = &error {
1847                        if matches!(
1848                            io_error.kind(),
1849                            std::io::ErrorKind::ConnectionReset
1850                                | std::io::ErrorKind::ConnectionAborted
1851                        ) {
1852                            log::info!(
1853                                "subc attach: connection reset by daemon; treating as close"
1854                            );
1855                            return;
1856                        }
1857                    }
1858                    let _ = tx.send(Err(SubcError::FrameIo(error))).await;
1859                    return;
1860                }
1861            }
1862        }
1863    })
1864}
1865
1866async fn finish_writer_task(
1867    mut writer_task: JoinHandle<Result<(), subc_transport::FrameIoError>>,
1868) -> Result<(), SubcError> {
1869    match tokio::time::timeout(Duration::from_millis(100), &mut writer_task).await {
1870        Ok(Ok(Ok(()))) => Ok(()),
1871        Ok(Ok(Err(error))) => Err(SubcError::FrameIo(error)),
1872        Ok(Err(error)) => Err(SubcError::WriterJoin(error)),
1873        Err(_) => {
1874            writer_task.abort();
1875            Ok(())
1876        }
1877    }
1878}
1879
1880fn rollback_pending_bind_actor(
1881    executor: &Arc<Executor>,
1882    live_roots: &HashMap<ProjectRootId, RootMeta>,
1883    root_id: &ProjectRootId,
1884    inserted_new_actor: bool,
1885) {
1886    if inserted_new_actor && !live_roots.contains_key(root_id) {
1887        executor.remove_actor(root_id);
1888    }
1889}
1890
1891async fn handle_route_bind_completion(
1892    tx: &mpsc::Sender<Frame>,
1893    completion: RouteBindCompletion,
1894    routes: &mut HashMap<RouteChannel, RouteIdentity>,
1895    root_channels: &mut HashMap<ProjectRootId, HashSet<RouteChannel>>,
1896    session_identity: &mut HashMap<(ProjectRootId, String), RetainedSessionIdentity>,
1897    push_buffer: &mut HashMap<push::ReplayKey, VecDeque<PushFrame>>,
1898    live_roots: &mut HashMap<ProjectRootId, RootMeta>,
1899    pending_binds: &mut HashMap<RouteChannel, PendingBind>,
1900    executor: &Arc<Executor>,
1901    shutdown: &Arc<Notify>,
1902    metrics: &DispatchPathMetrics,
1903) -> Result<(), SubcError> {
1904    let route_id = route_key(completion.route_channel);
1905    let Some(pending) = pending_binds.remove(&route_id) else {
1906        log::warn!(
1907            "subc attach: dropping RouteBind completion for non-pending route {}",
1908            completion.route_channel
1909        );
1910        rollback_pending_bind_actor(
1911            executor,
1912            live_roots,
1913            &completion.bind_root_id,
1914            completion.inserted_new_actor,
1915        );
1916        return Ok(());
1917    };
1918
1919    if pending.bind_root_id != completion.bind_root_id {
1920        log::warn!(
1921            "subc attach: pending RouteBind root mismatch for route {} (pending {} completion {})",
1922            completion.route_channel,
1923            pending.bind_root_id.as_path().display(),
1924            completion.bind_root_id.as_path().display()
1925        );
1926    }
1927
1928    let inserted_new_actor = pending.inserted_new_actor || completion.inserted_new_actor;
1929    if pending.cancelled {
1930        rollback_pending_bind_actor(
1931            executor,
1932            live_roots,
1933            &completion.bind_root_id,
1934            inserted_new_actor,
1935        );
1936        log::debug!(
1937            "subc attach: discarded completed RouteBind for cancelled route {} root {}",
1938            completion.route_channel,
1939            completion.bind_root_id.as_path().display()
1940        );
1941        return Ok(());
1942    }
1943
1944    let failure = if !completion.configure_response.success {
1945        Some((
1946            &completion.configure_response,
1947            "configure failed during route bind",
1948        ))
1949    } else if let Some(drain_response) = completion.drain_response.as_ref() {
1950        if drain_response.success {
1951            None
1952        } else {
1953            Some((
1954                drain_response,
1955                "build-completion drain failed during route bind",
1956            ))
1957        }
1958    } else {
1959        None
1960    };
1961
1962    if let Some((response, fallback)) = failure {
1963        rollback_pending_bind_actor(
1964            executor,
1965            live_roots,
1966            &completion.bind_root_id,
1967            inserted_new_actor,
1968        );
1969        let message = response_message(response, fallback);
1970        let fatal = response_is_fatal_panic(response);
1971        // Preserve typed configure rejections across the bind boundary: a
1972        // malformed fed fingerprint means a federation-module bug or
1973        // fingerprint-format drift, and the fed side matches on the code
1974        // rather than parsing prose.
1975        let error_code = if response.data.get("code").and_then(|c| c.as_str())
1976            == Some("bad_harness_fingerprint")
1977        {
1978            "bad_harness_fingerprint"
1979        } else {
1980            "config_divergence"
1981        };
1982        send_route_bind_error_parts(
1983            tx,
1984            completion.ver,
1985            completion.corr,
1986            completion.flags,
1987            error_code,
1988            &message,
1989            metrics,
1990        )
1991        .await?;
1992        if fatal {
1993            signal_fatal_teardown(
1994                tx,
1995                Some(completion.route_channel),
1996                completion.ver,
1997                completion.corr,
1998                shutdown,
1999                metrics,
2000            )
2001            .await;
2002        }
2003        return Ok(());
2004    }
2005
2006    remember_session_identity(session_identity, &completion.identity);
2007    let replay_key = push::ReplayKey::from_identity(&completion.identity);
2008    let bind_trust = completion.identity.trust;
2009    insert_route_channel(routes, root_channels, route_id, completion.identity);
2010    live_roots
2011        .entry(completion.bind_root_id.clone())
2012        .and_modify(|meta| {
2013            meta.touch();
2014            meta.diagnostics_on_edit = completion.diagnostics_on_edit;
2015            meta.maintenance_poisoned = false;
2016        })
2017        .or_insert_with(|| RootMeta::new(Instant::now()));
2018    if let Some(meta) = live_roots.get_mut(&completion.bind_root_id) {
2019        meta.diagnostics_on_edit = completion.diagnostics_on_edit;
2020        meta.maintenance_poisoned = false;
2021    }
2022
2023    let ack =
2024        serde_json::to_vec(&ModuleControlResponse::RouteBindAck {}).map_err(SubcError::Json)?;
2025    let response = Frame::build_with_version(
2026        completion.ver,
2027        FrameType::Response,
2028        control_flags(),
2029        0,
2030        completion.corr,
2031        ack,
2032    )
2033    .map_err(SubcError::FrameBuild)?;
2034    send_reliable_writer_frame(tx, metrics, response, "RouteBindAck").await?;
2035    let replayed = push::replay_buffered_push_frames(
2036        tx,
2037        metrics,
2038        route_id,
2039        push_buffer,
2040        &replay_key,
2041        bind_trust,
2042    );
2043    if replayed > 0 {
2044        log::debug!(
2045            "subc attach: replayed {} buffered Push frame(s) to route {} root {} harness {} session {}",
2046            replayed,
2047            completion.route_channel,
2048            replay_key.root.as_path().display(),
2049            replay_key.harness,
2050            replay_key.session
2051        );
2052    }
2053    log::info!(
2054        "subc attach: route {} bound to root {}",
2055        completion.route_channel,
2056        completion.bind_root_id.as_path().display()
2057    );
2058    Ok(())
2059}
2060
2061async fn expire_overdue_route_binds(
2062    tx: &mpsc::Sender<Frame>,
2063    pending_binds: &mut HashMap<RouteChannel, PendingBind>,
2064    metrics: &DispatchPathMetrics,
2065) -> Result<(), SubcError> {
2066    let now = Instant::now();
2067    let expired: Vec<_> = pending_binds
2068        .iter()
2069        .filter_map(|(route, pending)| {
2070            let age = now.saturating_duration_since(pending.started_at);
2071            (!pending.deadline_reported && age >= ROUTE_BIND_DEADLINE).then(|| {
2072                (
2073                    *route,
2074                    pending.corr,
2075                    pending.ver,
2076                    pending.flags,
2077                    pending.bind_root_id.clone(),
2078                    pending.configure_request_id.clone(),
2079                    age,
2080                )
2081            })
2082        })
2083        .collect();
2084
2085    for (route, corr, ver, flags, root_id, configure_request_id, age) in expired {
2086        if let Some(pending) = pending_binds.get_mut(&route) {
2087            pending.cancelled = true;
2088            pending.deadline_reported = true;
2089        }
2090        let age_ms = age.as_millis().min(u128::from(u64::MAX)) as u64;
2091        let deadline_ms = ROUTE_BIND_DEADLINE.as_millis();
2092        send_route_bind_error_parts(
2093            tx,
2094            ver,
2095            corr,
2096            flags,
2097            "config_divergence",
2098            &format!("route bind deadline exceeded after {age_ms}ms (deadline {deadline_ms}ms)"),
2099            metrics,
2100        )
2101        .await?;
2102        log::warn!(
2103            "subc attach: route {} bind for root {} exceeded {}ms deadline (configure_request_id={})",
2104            route,
2105            root_id.as_path().display(),
2106            deadline_ms,
2107            configure_request_id
2108        );
2109    }
2110
2111    Ok(())
2112}
2113
2114/// channel-0 control requests: RouteBind plus the cached health probe. RouteBind
2115/// still reconciles the route's RootConfig through the executor's Mutating lane
2116/// and resolves completion on a loop-owned control-completion channel so slow
2117/// configure jobs do not block the transport loop.
2118async fn handle_control_request(
2119    tx: &mpsc::Sender<Frame>,
2120    frame: &Frame,
2121    shared_app: &Arc<App>,
2122    executor: &Arc<Executor>,
2123    live_roots: &mut HashMap<ProjectRootId, RootMeta>,
2124    pending_binds: &mut HashMap<RouteChannel, PendingBind>,
2125    control_completion_tx: &mpsc::Sender<RouteBindCompletion>,
2126    metrics: &Arc<DispatchPathMetrics>,
2127    push_senders: &PushSenders,
2128    dispatch: DispatchFn,
2129    user_config_path: Option<&Path>,
2130) -> Result<(), SubcError> {
2131    let request =
2132        serde_json::from_slice::<ModuleControlRequest>(&frame.body).map_err(SubcError::Json)?;
2133    match request {
2134        ModuleControlRequest::RouteBind {
2135            route_channel,
2136            target: _,
2137            identity,
2138            principal,
2139            consumer_capabilities,
2140        } => {
2141            let route_id = route_key(route_channel);
2142            if pending_binds.contains_key(&route_id) {
2143                return send_route_bind_error(
2144                    tx,
2145                    frame,
2146                    "config_divergence",
2147                    "route bind is already pending for channel",
2148                    metrics,
2149                )
2150                .await;
2151            }
2152
2153            let bind_root_id = match ProjectRootId::from_path(&identity.project_root) {
2154                Ok(root_id) => root_id,
2155                Err(error) => {
2156                    return send_route_bind_error(
2157                        tx,
2158                        frame,
2159                        "config_divergence",
2160                        &format!("invalid route project root: {error}"),
2161                        metrics,
2162                    )
2163                    .await;
2164                }
2165            };
2166
2167            // Reconcile RootConfig: build a configure request from the bind
2168            // identity + forwarded config tiers and run it through the executor.
2169            let request_id = format!("subc-bind-{route_channel}");
2170            let bind_project_root = identity.project_root.clone();
2171            let bind_harness = identity.harness.clone();
2172            let bind_session = identity.session.clone();
2173            let bind_trust = trust_for_bind(&bind_harness, &principal);
2174            // Typed capability DECLARATION (protocol 0.8): the facade stamps it
2175            // from the MCP host's initialize-advertised capabilities. Absent
2176            // means no reverse-request capability — flat deny, fail-closed. A
2177            // consumer over-declaring only earns asks that TTL-deny.
2178            let consumer_elicitation_capable = consumer_capabilities
2179                .as_ref()
2180                .is_some_and(|capabilities| capabilities.iter().any(|c| c == "elicitation"));
2181            log::info!(
2182                "subc attach: route {} harness={} principal={} trust={} elicitation={}",
2183                route_channel,
2184                bind_harness,
2185                principal_label(&principal),
2186                bind_trust.label(),
2187                consumer_elicitation_capable
2188            );
2189
2190            // Config is single-per-project, read by AFT directly from the
2191            // CortexKit config files (user: ~/.config/cortexkit/aft.jsonc,
2192            // project: <root>/.cortexkit/aft.jsonc). Wire-relayed config tiers are
2193            // IGNORED entirely: a front (runner, mcp:*, or fed:*) cannot push config over
2194            // the wire. This is what makes config harness-INDEPENDENT — every
2195            // harness binding a project gets the identical on-disk config, so two
2196            // trust domains sharing the per-root actor can never diverge or
2197            // inherit each other's capabilities (the cross-bind escalation class).
2198            // Wire-relayed config tiers (if the protocol still carries them) are
2199            // ignored entirely; the per-tier trust boundary (user trusted, project
2200            // privileged-dropped) is applied to the FILE tiers in handle_configure.
2201            let local_tiers = crate::subc_config::read_local_cortexkit_config_tiers(
2202                user_config_path,
2203                Path::new(&bind_project_root),
2204            );
2205            let config_tiers: Vec<Value> = local_tiers
2206                .iter()
2207                .map(|t| json!({ "tier": t.tier, "source": t.source, "doc": t.doc }))
2208                .collect();
2209            let diagnostics_on_edit = diagnostics_on_edit_from_tiers(&local_tiers);
2210            let configure_json = json!({
2211                "id": request_id,
2212                "command": "configure",
2213                "project_root": bind_project_root,
2214                "harness": bind_harness,
2215                "session_id": bind_session.clone(),
2216                "config": config_tiers,
2217            });
2218            let configure_req = match serde_json::from_value::<RawRequest>(configure_json) {
2219                Ok(req) => req,
2220                Err(error) => {
2221                    return send_route_bind_error(
2222                        tx,
2223                        frame,
2224                        "config_divergence",
2225                        &format!("failed to build configure request: {error}"),
2226                        metrics,
2227                    )
2228                    .await;
2229                }
2230            };
2231
2232            let route_identity = RouteIdentity {
2233                root: bind_root_id.clone(),
2234                project_root: PathBuf::from(&bind_project_root),
2235                harness: bind_harness.clone(),
2236                session: bind_session.clone(),
2237                trust: bind_trust,
2238                consumer_elicitation_capable,
2239            };
2240            let configure_session = route_identity.session.clone();
2241            let root_was_live = live_roots.contains_key(&bind_root_id);
2242            let inserted_new_actor = if root_was_live {
2243                log::debug!(
2244                    "subc attach: reusing actor for route {} root {}",
2245                    route_channel,
2246                    bind_root_id.as_path().display()
2247                );
2248                false
2249            } else {
2250                let actor_ctx = Arc::new(AppContext::from_app(
2251                    Arc::clone(shared_app),
2252                    Config::default(),
2253                ));
2254                install_bash_compressor(&actor_ctx);
2255                actor_ctx.set_progress_sender(Some(push::progress_sender_for_root(
2256                    push_senders.clone(),
2257                    bind_root_id.clone(),
2258                )));
2259                let inserted =
2260                    executor.register_actor(bind_root_id.clone(), Arc::clone(&actor_ctx));
2261                drop(actor_ctx);
2262                // Do not insert into live_roots until configure succeeds: live_roots
2263                // drives maintenance, and a half-configured new actor must not be
2264                // maintenance-eligible before its route/session identity exists.
2265                log::debug!(
2266                    "subc attach: registered actor for route {} root {}",
2267                    route_channel,
2268                    bind_root_id.as_path().display()
2269                );
2270                inserted
2271            };
2272
2273            let configure_request_id = configure_req.id.clone();
2274            pending_binds.insert(
2275                route_id,
2276                PendingBind {
2277                    bind_root_id: bind_root_id.clone(),
2278                    inserted_new_actor,
2279                    cancelled: false,
2280                    configure_request_id: configure_request_id.clone(),
2281                    started_at: Instant::now(),
2282                    warned_half_deadline: false,
2283                    deadline_reported: false,
2284                    corr: frame.header.corr,
2285                    ver: frame.header.ver,
2286                    flags: frame.header.flags,
2287                },
2288            );
2289            if let Some(meta) = live_roots.get_mut(&bind_root_id) {
2290                meta.maintenance_queued_kinds.clear();
2291                meta.maintenance_pending = meta.maintenance_jobs_in_flight > 0;
2292            }
2293            let configure_rx = executor.submit_async(
2294                bind_root_id.clone(),
2295                Lane::Mutating,
2296                configure_request_id.clone(),
2297                Box::new(move |ctx| {
2298                    log_ctx::with_session(Some(configure_session.clone()), || {
2299                        dispatch(configure_req, ctx)
2300                    })
2301                }),
2302            );
2303
2304            let completion_tx = control_completion_tx.clone();
2305            let completion_executor = Arc::clone(executor);
2306            let completion_identity = route_identity;
2307            let completion_root = bind_root_id.clone();
2308            let completion_route_channel = route_channel;
2309            let completion_ver = frame.header.ver;
2310            let completion_corr = frame.header.corr;
2311            let completion_flags = frame.header.flags;
2312            let completion_metrics = Arc::clone(metrics);
2313            tokio::spawn(async move {
2314                let _response_task = ResponseTaskGuard::new(&completion_metrics);
2315                let configure_response =
2316                    await_executor_response(configure_rx, configure_request_id.clone()).await;
2317                let drain_response = if configure_response.success && !root_was_live {
2318                    let drain_request_id = format!("subc-bind-drain-{completion_route_channel}");
2319                    let drain_response_id = drain_request_id.clone();
2320                    let drain_rx = completion_executor.submit_async(
2321                        completion_root.clone(),
2322                        Lane::Mutating,
2323                        drain_request_id.clone(),
2324                        Box::new(move |ctx| {
2325                            runtime_drain::drain_build_completions(ctx);
2326                            Response::success(drain_response_id, json!({ "drained": true }))
2327                        }),
2328                    );
2329                    Some(await_executor_response(drain_rx, drain_request_id).await)
2330                } else {
2331                    None
2332                };
2333
2334                let completion = RouteBindCompletion {
2335                    route_channel: completion_route_channel,
2336                    identity: completion_identity,
2337                    bind_root_id: completion_root,
2338                    inserted_new_actor,
2339                    configure_response,
2340                    drain_response,
2341                    diagnostics_on_edit,
2342                    ver: completion_ver,
2343                    corr: completion_corr,
2344                    flags: completion_flags,
2345                };
2346                if send_counted_channel(
2347                    &completion_tx,
2348                    &completion_metrics.control_completion_queued,
2349                    completion,
2350                )
2351                .await
2352                .is_err()
2353                {
2354                    log::debug!(
2355                        "subc attach: dropped RouteBind completion for route {} after loop exit",
2356                        completion_route_channel
2357                    );
2358                }
2359            });
2360
2361            Ok(())
2362        }
2363        ModuleControlRequest::HealthCheck {} => {
2364            let report = build_health_report(executor, pending_binds, metrics);
2365            let body = serde_json::to_vec(&ModuleControlResponse::from(report))
2366                .map_err(SubcError::Json)?;
2367            let response = Frame::build_with_version(
2368                frame.header.ver,
2369                FrameType::Response,
2370                frame.header.flags,
2371                0,
2372                frame.header.corr,
2373                body,
2374            )
2375            .map_err(SubcError::FrameBuild)?;
2376            send_frame(tx, metrics, response).await
2377        }
2378    }
2379}
2380
2381fn install_bash_compressor(ctx: &AppContext) {
2382    // Mirrors main.rs per-actor compressor installation for subc-created actors.
2383    let filter_registry_handle = ctx.shared_filter_registry();
2384    let compress_flag = ctx.bash_compress_flag();
2385    ctx.bash_background().set_compressor_with_exit_code(
2386        move |command: &str, output: String, exit_code: Option<i32>| {
2387            if !compress_flag.load(std::sync::atomic::Ordering::Relaxed) {
2388                return crate::compress::CompressionResult::new(output);
2389            }
2390            let registry_guard = match filter_registry_handle.read() {
2391                Ok(g) => g,
2392                Err(poisoned) => poisoned.into_inner(),
2393            };
2394            crate::compress::compress_with_registry_exit_code(
2395                command,
2396                &output,
2397                exit_code,
2398                &registry_guard,
2399            )
2400        },
2401    );
2402}
2403
2404fn diagnostics_on_edit_from_tiers(tiers: &[ConfigTier]) -> bool {
2405    let mut diagnostics_on_edit = false;
2406    for tier in tiers {
2407        if let Some(value) = diagnostics_on_edit_from_doc(&tier.doc) {
2408            diagnostics_on_edit = value;
2409        }
2410    }
2411    diagnostics_on_edit
2412}
2413
2414fn diagnostics_on_edit_from_doc(doc: &str) -> Option<bool> {
2415    let stripped = strip_jsonc(doc);
2416    let value = serde_json::from_str::<Value>(&stripped).ok()?;
2417    value
2418        .get("lsp")
2419        .and_then(Value::as_object)?
2420        .get("diagnostics_on_edit")
2421        .and_then(Value::as_bool)
2422}
2423
2424async fn send_route_bind_error(
2425    tx: &mpsc::Sender<Frame>,
2426    frame: &Frame,
2427    code: &str,
2428    message: &str,
2429    metrics: &DispatchPathMetrics,
2430) -> Result<(), SubcError> {
2431    send_route_bind_error_parts(
2432        tx,
2433        frame.header.ver,
2434        frame.header.corr,
2435        frame.header.flags,
2436        code,
2437        message,
2438        metrics,
2439    )
2440    .await
2441}
2442
2443async fn send_route_bind_error_parts(
2444    tx: &mpsc::Sender<Frame>,
2445    ver: u8,
2446    corr: u64,
2447    flags: Flags,
2448    code: &str,
2449    message: &str,
2450    metrics: &DispatchPathMetrics,
2451) -> Result<(), SubcError> {
2452    let response = build_error_frame(ver, 0, corr, flags, code, message)?;
2453    send_reliable_writer_frame(tx, metrics, response, "RouteBind error").await?;
2454    log::warn!("subc attach: route bind rejected ({code}): {message}");
2455    Ok(())
2456}
2457
2458/// Route-channel tool call: `{name, arguments}` → executor lane → dispatch to
2459/// the sync command core → wrap the structured Response in a CallToolResult
2460/// `{content, isError}`. v1 mapping: the whole `{success, ...}` Response
2461/// serialized into ONE text block; `isError` carries `success == false`.
2462async fn handle_tool_call(
2463    tx: &mpsc::Sender<Frame>,
2464    frame: &Frame,
2465    routes: &HashMap<RouteChannel, RouteIdentity>,
2466    pending_binds: &HashMap<RouteChannel, PendingBind>,
2467    live_roots: &mut HashMap<ProjectRootId, RootMeta>,
2468    executor: &Arc<Executor>,
2469    shutdown: &Arc<Notify>,
2470    connection_cancel: &PersistentCancelSignal,
2471    bash_deferred_tx: &mpsc::Sender<bash::BashDeferredCompletion>,
2472    bash_poll_touch_tx: &mpsc::Sender<ProjectRootId>,
2473    metrics: &Arc<DispatchPathMetrics>,
2474    route_bash_cancels: &mut HashMap<RouteChannel, bash::RouteBashCancel>,
2475    pending_bash_asks: &mut HashMap<ReverseCorrKey, PendingBashAsk>,
2476    next_bash_ask_corr: &mut u64,
2477    bg_subs: &mut HashMap<RouteChannel, BgSub>,
2478    bg_sub_by_session: &mut HashMap<(ProjectRootId, String), RouteChannel>,
2479    bg_wake_pending: &mut HashSet<RouteChannel>,
2480    bg_wake_epoch: &mut HashMap<(ProjectRootId, String), u64>,
2481    dispatch: DispatchFn,
2482    allow_native_passthrough: bool,
2483) -> Result<(), SubcError> {
2484    let route_id = route_key(frame.header.channel);
2485    if pending_binds.contains_key(&route_id) {
2486        let error = build_error_frame(
2487            frame.header.ver,
2488            frame.header.channel,
2489            frame.header.corr,
2490            frame.header.flags,
2491            "route_not_bound",
2492            "route is not bound before tool call",
2493        )?;
2494        return send_reliable_writer_frame(tx, metrics, error, "route_not_bound error").await;
2495    }
2496
2497    let Some(identity) = routes.get(&route_id).cloned() else {
2498        let error = build_error_frame(
2499            frame.header.ver,
2500            frame.header.channel,
2501            frame.header.corr,
2502            frame.header.flags,
2503            "route_not_bound",
2504            "route is not bound before tool call",
2505        )?;
2506        return send_reliable_writer_frame(tx, metrics, error, "route_not_bound error").await;
2507    };
2508    if let Some(meta) = live_roots.get_mut(&identity.root) {
2509        meta.touch();
2510    }
2511
2512    let is_bg_events_subscribe = serde_json::from_slice::<BgEventsProbe>(&frame.body)
2513        .ok()
2514        .and_then(|probe| probe.op)
2515        .as_deref()
2516        == Some("bg_events");
2517    if is_bg_events_subscribe {
2518        if let Some(old_sub) = bg_subs.get(&route_id).copied() {
2519            let _ = push::try_send_bg_stream_end(tx, metrics, route_id, &old_sub);
2520        }
2521        if !identity.trust.allows_bash_observation() {
2522            bg_subs.remove(&route_id);
2523            bg_wake_pending.remove(&route_id);
2524            remove_bg_subscription_index(bg_sub_by_session, route_id, Some(&identity));
2525            return Ok(());
2526        }
2527        bg_subs.insert(
2528            route_id,
2529            BgSub {
2530                corr: frame.header.corr,
2531                ver: frame.header.ver,
2532                flags: frame.header.flags,
2533            },
2534        );
2535        bg_sub_by_session.insert((identity.root.clone(), identity.session.clone()), route_id);
2536        push::arm_bg_wake(
2537            identity.root,
2538            identity.session,
2539            route_id,
2540            bg_wake_pending,
2541            bg_wake_epoch,
2542        );
2543        return Ok(());
2544    }
2545
2546    let call = serde_json::from_slice::<ToolCallRequest>(&frame.body).map_err(SubcError::Json)?;
2547    let bare_name = call.name.clone();
2548    let format_context = crate::subc_format::FormatContext::from_tool_call(
2549        &bare_name,
2550        &call.arguments,
2551        identity.project_root.as_path(),
2552    );
2553
2554    let request_id = format!("subc-{}-{}", frame.header.channel, frame.header.corr);
2555    let bind_trust = identity.trust;
2556    let diagnostics_on_edit = live_roots
2557        .get(&identity.root)
2558        .map(|meta| meta.diagnostics_on_edit)
2559        .unwrap_or(false);
2560
2561    if matches!(bind_trust, BindTrust::Untrusted)
2562        && is_bash_family_tool(&bare_name)
2563        && (bare_name != "bash" || !identity.consumer_elicitation_capable)
2564    {
2565        let response = bash::bash_denied_untrusted_response(request_id.clone());
2566        let text = crate::subc_format::format_response_with_context(
2567            &bare_name,
2568            &response,
2569            &format_context,
2570        );
2571        let result = ToolCallResult { text, response };
2572        let response_frame = build_tool_response_frame(
2573            frame.header.ver,
2574            frame.header.channel,
2575            frame.header.corr,
2576            frame.header.flags,
2577            &result,
2578        )?;
2579        return send_reliable_writer_frame(tx, metrics, response_frame, "tool response").await;
2580    }
2581
2582    // A non-core name is NOT in the tool manifest. AFT fails closed and
2583    // does not trust subc to enforce the manifest: rejecting here is the
2584    // defense-in-depth backstop that prevents a forwarded native command
2585    // (e.g. `configure`, which would reach handle_configure and bypass
2586    // the RouteBind config-trust cap) from ever reaching dispatch. Only
2587    // the integration-test harness (run_subc_mode_for_test) opens this to
2588    // drive synthetic native commands through the executor.
2589    if !is_subc_agent_core_tool(&call.name)
2590        && !is_subc_native_plumbing_tool(&call.name)
2591        && !allow_native_passthrough
2592    {
2593        log::warn!(
2594            "subc tool call: rejecting non-manifest tool name {:?} on route {} (fail-closed)",
2595            call.name,
2596            frame.header.channel
2597        );
2598        let response = Response::error(
2599            request_id.clone(),
2600            "unknown_tool",
2601            format!("tool {:?} is not in the AFT tool manifest", call.name),
2602        );
2603        let text = crate::subc_format::format_response_with_context(
2604            &bare_name,
2605            &response,
2606            &format_context,
2607        );
2608        let result = ToolCallResult { text, response };
2609        let response_frame = build_tool_response_frame(
2610            frame.header.ver,
2611            frame.header.channel,
2612            frame.header.corr,
2613            frame.header.flags,
2614            &result,
2615        )?;
2616        return send_reliable_writer_frame(tx, metrics, response_frame, "tool response").await;
2617    }
2618
2619    if bare_name == "bash" {
2620        if matches!(bind_trust, BindTrust::Untrusted) {
2621            let plan = match bash::prepare_bash_elicitation_plan(
2622                &call.arguments,
2623                identity.project_root.as_path(),
2624            ) {
2625                Ok(plan) => plan,
2626                Err(error) => {
2627                    let response = Response::error(request_id.clone(), error.code, error.message);
2628                    let text = crate::subc_format::format_response_with_context(
2629                        &bare_name,
2630                        &response,
2631                        &format_context,
2632                    );
2633                    let result = ToolCallResult { text, response };
2634                    let response_frame = build_tool_response_frame(
2635                        frame.header.ver,
2636                        frame.header.channel,
2637                        frame.header.corr,
2638                        frame.header.flags,
2639                        &result,
2640                    )?;
2641                    return send_reliable_writer_frame(
2642                        tx,
2643                        metrics,
2644                        response_frame,
2645                        "tool response",
2646                    )
2647                    .await;
2648                }
2649            };
2650
2651            let meta = live_roots
2652                .entry(identity.root.clone())
2653                .or_insert_with(|| RootMeta::new(Instant::now()));
2654            meta.active_bash_waits = meta.active_bash_waits.saturating_add(1);
2655            meta.touch();
2656
2657            let route_cancel =
2658                route_bash_cancels
2659                    .entry(route_id)
2660                    .or_insert_with(|| bash::RouteBashCancel {
2661                        token: PersistentCancelSignal::new(),
2662                        active_waits: 0,
2663                    });
2664            route_cancel.active_waits = route_cancel.active_waits.saturating_add(1);
2665            let cancel = bash::BashWaitCancel {
2666                connection: connection_cancel.clone(),
2667                route: route_cancel.token.clone(),
2668            };
2669
2670            let reverse_corr =
2671                allocate_reverse_corr(pending_bash_asks, route_id, next_bash_ask_corr);
2672            let ask_frame = build_bash_elicitation_request_frame(
2673                frame.header.ver,
2674                frame.header.channel,
2675                reverse_corr,
2676                frame.header.flags,
2677                &plan.command,
2678                &plan.asks,
2679            )?;
2680            pending_bash_asks.insert(
2681                ReverseCorrKey {
2682                    route: route_id,
2683                    corr: reverse_corr,
2684                },
2685                PendingBashAsk {
2686                    route_channel: frame.header.channel,
2687                    tool_corr: frame.header.corr,
2688                    tool_flags: frame.header.flags,
2689                    tool_ver: frame.header.ver,
2690                    root: identity.root,
2691                    project_root: identity.project_root,
2692                    session_id: identity.session,
2693                    request_id,
2694                    arguments: call.arguments,
2695                    format_context,
2696                    cancel,
2697                    grants: plan.grants,
2698                    expires_at: Instant::now() + bash_elicitation_timeout(),
2699                },
2700            );
2701            return send_reliable_writer_frame(tx, metrics, ask_frame, "bash elicitation request")
2702                .await;
2703        }
2704
2705        let meta = live_roots
2706            .entry(identity.root.clone())
2707            .or_insert_with(|| RootMeta::new(Instant::now()));
2708        meta.active_bash_waits = meta.active_bash_waits.saturating_add(1);
2709        meta.touch();
2710
2711        let route_cancel =
2712            route_bash_cancels
2713                .entry(route_id)
2714                .or_insert_with(|| bash::RouteBashCancel {
2715                    token: PersistentCancelSignal::new(),
2716                    active_waits: 0,
2717                });
2718        route_cancel.active_waits = route_cancel.active_waits.saturating_add(1);
2719        let cancel = bash::BashWaitCancel {
2720            connection: connection_cancel.clone(),
2721            route: route_cancel.token.clone(),
2722        };
2723
2724        bash::submit_deferred_bash(
2725            executor,
2726            bash_deferred_tx,
2727            bash_poll_touch_tx,
2728            metrics,
2729            dispatch,
2730            identity.root,
2731            identity.project_root,
2732            identity.session,
2733            request_id,
2734            frame.header.channel,
2735            frame.header.corr,
2736            frame.header.flags,
2737            frame.header.ver,
2738            call.arguments,
2739            format_context,
2740            cancel,
2741            bind_trust,
2742            None,
2743        );
2744        return Ok(());
2745    }
2746
2747    let lane = command_lane(&bare_name);
2748    let tool_call_context = ToolCallContext {
2749        project_root: identity.project_root.clone(),
2750        session_id: Some(identity.session.clone()),
2751        request_id: request_id.clone(),
2752        diagnostics_on_edit,
2753        preview: call.preview,
2754    };
2755    let arguments_for_run = call.arguments.clone();
2756    let bare_name_for_run = bare_name.clone();
2757    let bare_name_for_frame = bare_name.clone();
2758    let bare_name_for_finalize = bare_name.clone();
2759    let session_for_log = identity.session.clone();
2760    let session_for_finalize = identity.session.clone();
2761    let request_id_for_force = request_id.clone();
2762    let format_context_for_frame = format_context;
2763    let (text_tx, text_rx) = oneshot::channel::<String>();
2764    let rx = executor.submit_async(
2765        identity.root,
2766        lane,
2767        request_id.clone(),
2768        Box::new(move |ctx| {
2769            log_ctx::with_session(Some(session_for_log.clone()), || {
2770                let run = || {
2771                    let dispatch_with_finalize = |raw_req: RawRequest, app_ctx: &AppContext| {
2772                        let mut response = dispatch(raw_req, app_ctx);
2773                        crate::response_finalize::finalize_response_with_bg_completions(
2774                            &mut response,
2775                            app_ctx,
2776                            &session_for_finalize,
2777                            &bare_name_for_finalize,
2778                            bind_trust.allows_bash_observation(),
2779                        );
2780                        response
2781                    };
2782                    match run_tool_call(
2783                        &bare_name_for_run,
2784                        &arguments_for_run,
2785                        &tool_call_context,
2786                        ctx,
2787                        &dispatch_with_finalize,
2788                    ) {
2789                        ToolCallOutcome::Unary(result) => {
2790                            let _ = text_tx.send(result.text);
2791                            result.response
2792                        }
2793                    }
2794                };
2795                if matches!(bind_trust, BindTrust::Untrusted) {
2796                    ctx.with_force_restrict(&request_id_for_force, run)
2797                } else {
2798                    run()
2799                }
2800            })
2801        }),
2802    );
2803    let completion_tx = tx.clone();
2804    let completion_shutdown = Arc::clone(shutdown);
2805    let route_channel = frame.header.channel;
2806    let corr = frame.header.corr;
2807    let flags = frame.header.flags;
2808    let ver = frame.header.ver;
2809    let completion_metrics = Arc::clone(metrics);
2810    tokio::spawn(async move {
2811        let _response_task = ResponseTaskGuard::new(&completion_metrics);
2812        let response = await_executor_response(rx, request_id.clone()).await;
2813        let text = text_rx.await.unwrap_or_else(|_| {
2814            crate::subc_format::format_response_with_context(
2815                &bare_name_for_frame,
2816                &response,
2817                &format_context_for_frame,
2818            )
2819        });
2820        let result = ToolCallResult { text, response };
2821        let fatal = response_is_fatal_panic(&result.response);
2822        match build_tool_response_frame(ver, route_channel, corr, flags, &result) {
2823            Ok(response_frame) => {
2824                if let Err(error) = send_reliable_writer_frame(
2825                    &completion_tx,
2826                    &completion_metrics,
2827                    response_frame,
2828                    "tool response",
2829                )
2830                .await
2831                {
2832                    log::warn!("subc attach: failed to queue tool response frame: {error}");
2833                }
2834            }
2835            Err(error) => {
2836                log::error!("subc attach: failed to build tool response frame: {error}");
2837            }
2838        }
2839        if fatal {
2840            signal_fatal_teardown(
2841                &completion_tx,
2842                Some(route_channel),
2843                ver,
2844                corr,
2845                &completion_shutdown,
2846                &completion_metrics,
2847            )
2848            .await;
2849        }
2850    });
2851    Ok(())
2852}
2853
2854fn submit_maintenance_job(
2855    executor: &Arc<Executor>,
2856    root_id: ProjectRootId,
2857    kind: MaintenanceDrainKind,
2858    bg_sessions_to_check: Vec<(String, u64)>,
2859    completion_tx: &mpsc::Sender<MaintenanceCompletion>,
2860    metrics: &Arc<DispatchPathMetrics>,
2861) {
2862    let request_id = format!(
2863        "subc-maintenance-drain-{}-{}",
2864        kind.label(),
2865        root_id.as_path().to_string_lossy()
2866    );
2867    let response_id = request_id.clone();
2868    let completion_root_id = root_id.clone();
2869    let (outcome_tx, outcome_rx) = oneshot::channel::<MaintenanceJobOutcome>();
2870    let rx = executor.submit_maintenance_async(
2871        root_id,
2872        Lane::Mutating,
2873        request_id.clone(),
2874        Box::new(move |ctx| {
2875            let outcome = match kind {
2876                MaintenanceDrainKind::Watcher => {
2877                    let drained = runtime_drain::drain_watcher_events_bounded(
2878                        ctx,
2879                        runtime_drain::WATCHER_EVENT_DRAIN_BATCH_CAP,
2880                    );
2881                    MaintenanceJobOutcome {
2882                        empty_bg_sessions: Vec::new(),
2883                        requeue_kind: drained.has_more.then_some(kind),
2884                    }
2885                }
2886                MaintenanceDrainKind::Lsp => {
2887                    let drained = runtime_drain::drain_lsp_events_bounded(
2888                        ctx,
2889                        runtime_drain::LSP_EVENT_DRAIN_BATCH_CAP,
2890                    );
2891                    MaintenanceJobOutcome {
2892                        empty_bg_sessions: Vec::new(),
2893                        requeue_kind: drained.has_more.then_some(kind),
2894                    }
2895                }
2896                MaintenanceDrainKind::Short => {
2897                    runtime_drain::drain_configure_warning_events(ctx);
2898                    runtime_drain::drain_search_index_events(ctx);
2899                    runtime_drain::drain_callgraph_store_events(ctx);
2900                    runtime_drain::drain_semantic_index_events(ctx);
2901                    runtime_drain::drain_semantic_refresh_events(ctx);
2902                    runtime_drain::drain_inspect_events(ctx);
2903                    let empty_bg_sessions = bg_sessions_to_check
2904                        .into_iter()
2905                        .filter(|(session, _)| {
2906                            !ctx.bash_background()
2907                                .has_completions_for_session(Some(session.as_str()))
2908                        })
2909                        .collect();
2910                    MaintenanceJobOutcome {
2911                        empty_bg_sessions,
2912                        requeue_kind: None,
2913                    }
2914                }
2915            };
2916            let requeued = outcome.requeue_kind.is_some();
2917            let _ = outcome_tx.send(outcome);
2918            Response::success(
2919                response_id,
2920                json!({ "drained": true, "kind": kind.label(), "requeued": requeued }),
2921            )
2922        }),
2923    );
2924    let completion_tx = completion_tx.clone();
2925    let completion_metrics = Arc::clone(metrics);
2926    tokio::spawn(async move {
2927        let _response_task = ResponseTaskGuard::new(&completion_metrics);
2928        let response = await_executor_response(rx, request_id).await;
2929        let outcome = outcome_rx.await.unwrap_or_default();
2930        let _ = send_counted_channel(
2931            &completion_tx,
2932            &completion_metrics.maintenance_queued,
2933            MaintenanceCompletion {
2934                root_id: completion_root_id,
2935                response,
2936                empty_bg_sessions: outcome.empty_bg_sessions,
2937                requeue_kind: outcome.requeue_kind,
2938            },
2939        )
2940        .await;
2941    });
2942}
2943
2944async fn await_executor_response(rx: oneshot::Receiver<Response>, request_id: String) -> Response {
2945    rx.await
2946        .unwrap_or_else(|_| Response::error(request_id, "internal_error", "executor dropped"))
2947}
2948async fn signal_fatal_teardown(
2949    tx: &mpsc::Sender<Frame>,
2950    route_channel: Option<u16>,
2951    ver: u8,
2952    corr: u64,
2953    shutdown: &Arc<Notify>,
2954    metrics: &DispatchPathMetrics,
2955) {
2956    if let Some(route_channel) = route_channel {
2957        if let Ok(frame) = build_goodbye_frame(ver, route_channel, corr) {
2958            if let Err(error) = send_frame(tx, metrics, frame).await {
2959                log::warn!(
2960                    "subc attach: failed to queue fatal route Goodbye for route {route_channel}: {error}"
2961                );
2962            }
2963        }
2964    }
2965    if let Ok(frame) = build_goodbye_frame(ver, 0, 0) {
2966        if let Err(error) = send_frame(tx, metrics, frame).await {
2967            log::warn!("subc attach: failed to queue fatal channel-0 Goodbye: {error}");
2968        }
2969    }
2970    shutdown.notify_one();
2971}
2972#[derive(Deserialize)]
2973struct BgEventsProbe {
2974    op: Option<String>,
2975}
2976
2977#[derive(Debug, Deserialize)]
2978struct ToolCallRequest {
2979    name: String,
2980    #[serde(default)]
2981    arguments: Value,
2982    /// Server-owned preview control (B1c-0): the plugin's mutation flow is
2983    /// preview -> permission ask -> apply. Dropping this field made "preview"
2984    /// calls mutate disk before the permission prompt and the subsequent
2985    /// apply fail with not-found.
2986    #[serde(default)]
2987    preview: bool,
2988}
2989
2990#[cfg(test)]
2991pub(crate) mod test_support {
2992    use super::*;
2993    use crate::bash_background::BgTaskStatus;
2994    use crate::protocol::{
2995        BashCompletedFrame, BashLongRunningFrame, BashPatternMatchFrame, ConfigureWarningsFrame,
2996        ProgressFrame, StatusChangedFrame,
2997    };
2998    use serde_json::json;
2999
3000    pub(super) fn test_root(name: &str) -> (tempfile::TempDir, ProjectRootId) {
3001        let dir = tempfile::Builder::new()
3002            .prefix(name)
3003            .tempdir()
3004            .expect("temp root");
3005        let root = ProjectRootId::from_path(dir.path()).expect("project root id");
3006        (dir, root)
3007    }
3008
3009    pub(super) fn test_ctx() -> Arc<AppContext> {
3010        Arc::new(AppContext::new(
3011            Box::new(crate::parser::TreeSitterProvider::new()),
3012            crate::config::Config::default(),
3013        ))
3014    }
3015
3016    pub(super) fn status_frame(seq: u64) -> PushFrame {
3017        status_frame_with_session(seq, None)
3018    }
3019
3020    pub(super) fn status_frame_with_session(seq: u64, session_id: Option<&str>) -> PushFrame {
3021        PushFrame::StatusChanged(StatusChangedFrame {
3022            frame_type: "status_changed",
3023            session_id: session_id.map(str::to_string),
3024            snapshot: json!({ "seq": seq }),
3025        })
3026    }
3027
3028    pub(super) fn completion_frame(task_id: &str) -> PushFrame {
3029        completion_frame_with_session(task_id, "session-1")
3030    }
3031
3032    pub(super) fn completion_frame_with_session(task_id: &str, session_id: &str) -> PushFrame {
3033        PushFrame::BashCompleted(BashCompletedFrame {
3034            frame_type: "bash_completed",
3035            task_id: task_id.to_string(),
3036            session_id: session_id.to_string(),
3037            status: BgTaskStatus::Completed,
3038            exit_code: Some(0),
3039            command: format!("echo {task_id}"),
3040            output_preview: String::new(),
3041            output_truncated: false,
3042            original_tokens: None,
3043            compressed_tokens: None,
3044            tokens_skipped: false,
3045        })
3046    }
3047
3048    pub(super) fn long_running_frame(task_id: &str, elapsed_ms: u64) -> PushFrame {
3049        long_running_frame_with_session(task_id, "session-1", elapsed_ms)
3050    }
3051
3052    pub(super) fn long_running_frame_with_session(
3053        task_id: &str,
3054        session_id: &str,
3055        elapsed_ms: u64,
3056    ) -> PushFrame {
3057        PushFrame::BashLongRunning(BashLongRunningFrame {
3058            frame_type: "bash_long_running",
3059            task_id: task_id.to_string(),
3060            session_id: session_id.to_string(),
3061            command: format!("sleep {elapsed_ms}"),
3062            elapsed_ms,
3063        })
3064    }
3065
3066    pub(super) fn pattern_match_frame(session_id: &str) -> PushFrame {
3067        PushFrame::BashPatternMatch(BashPatternMatchFrame {
3068            frame_type: "bash_pattern_match",
3069            task_id: "task-pattern".to_string(),
3070            session_id: session_id.to_string(),
3071            watch_id: "watch-1".to_string(),
3072            match_text: "needle".to_string(),
3073            match_offset: 7,
3074            context: "haystack needle".to_string(),
3075            once: true,
3076            reason: "pattern_match",
3077        })
3078    }
3079
3080    pub(super) fn configure_warnings_frame(session_id: Option<&str>) -> PushFrame {
3081        PushFrame::ConfigureWarnings(ConfigureWarningsFrame {
3082            frame_type: "configure_warnings",
3083            session_id: session_id.map(str::to_string),
3084            project_root: "/tmp/subc-test".to_string(),
3085            warnings: Vec::new(),
3086        })
3087    }
3088
3089    pub(super) fn route_identity(root: &ProjectRootId, session_id: &str) -> RouteIdentity {
3090        route_identity_with_trust(root, session_id, BindTrust::FirstParty)
3091    }
3092
3093    pub(super) fn route_identity_with_trust(
3094        root: &ProjectRootId,
3095        session_id: &str,
3096        trust: BindTrust,
3097    ) -> RouteIdentity {
3098        RouteIdentity {
3099            root: root.clone(),
3100            project_root: root.as_path().to_path_buf(),
3101            harness: "opencode".to_string(),
3102            session: session_id.to_string(),
3103            trust,
3104            consumer_elicitation_capable: false,
3105        }
3106    }
3107
3108    pub(super) fn progress_frame(request_id: &str, kind: ProgressKind, chunk: &str) -> PushFrame {
3109        PushFrame::Progress(ProgressFrame::new(request_id, kind, chunk))
3110    }
3111
3112    pub(super) fn status_seq(frame: &PushFrame) -> Option<u64> {
3113        match frame {
3114            PushFrame::StatusChanged(status) => status.snapshot.get("seq").and_then(|v| v.as_u64()),
3115            _ => None,
3116        }
3117    }
3118
3119    pub(super) fn completion_task(frame: &PushFrame) -> Option<&str> {
3120        match frame {
3121            PushFrame::BashCompleted(completion) => Some(completion.task_id.as_str()),
3122            _ => None,
3123        }
3124    }
3125
3126    pub(super) fn push_frame_task_id(frame: &Frame) -> Option<String> {
3127        let body: serde_json::Value = serde_json::from_slice(&frame.body).expect("push body");
3128        body.get("task_id")
3129            .and_then(serde_json::Value::as_str)
3130            .map(str::to_string)
3131    }
3132}
3133
3134#[cfg(test)]
3135mod tests {
3136    use super::test_support::test_root;
3137    use super::*;
3138
3139    fn actor_ctx_with_dirty_search_index(
3140        root: &Path,
3141        storage: &Path,
3142        file_name: &str,
3143        old_contents: &str,
3144        new_contents: &str,
3145    ) -> (Arc<AppContext>, PathBuf, PathBuf) {
3146        let file = root.join(file_name);
3147        std::fs::write(&file, old_contents).expect("write source");
3148        let canonical_root = std::fs::canonicalize(root).expect("canonical root");
3149        let ctx = Arc::new(AppContext::new(
3150            Box::new(crate::parser::TreeSitterProvider::new()),
3151            Config {
3152                project_root: Some(root.to_path_buf()),
3153                storage_dir: Some(storage.to_path_buf()),
3154                ..Config::default()
3155            },
3156        ));
3157        ctx.set_canonical_cache_root(canonical_root.clone());
3158
3159        let cache_dir = crate::search_index::resolve_cache_dir(&canonical_root, Some(storage));
3160        let mut index = crate::search_index::SearchIndex::build(&canonical_root);
3161        let git_head = index.stored_git_head().map(str::to_owned);
3162        index.write_to_disk(&cache_dir, git_head.as_deref());
3163
3164        std::fs::write(&file, new_contents).expect("edit source");
3165        index.update_file(&file);
3166        *ctx.search_index()
3167            .write()
3168            .unwrap_or_else(std::sync::PoisonError::into_inner) = Some(index);
3169        (ctx, canonical_root, cache_dir)
3170    }
3171
3172    #[test]
3173    fn graceful_shutdown_flushes_every_actor_search_index() {
3174        let storage = tempfile::tempdir().expect("storage tempdir");
3175        let (root1_dir, root1) = test_root("shutdown-flush-root-1");
3176        let (root2_dir, root2) = test_root("shutdown-flush-root-2");
3177        let (ctx1, canonical_root1, cache_dir1) = actor_ctx_with_dirty_search_index(
3178            root1_dir.path(),
3179            storage.path(),
3180            "alpha.txt",
3181            "old actor one token\n",
3182            "new actor one token\n",
3183        );
3184        let (ctx2, canonical_root2, cache_dir2) = actor_ctx_with_dirty_search_index(
3185            root2_dir.path(),
3186            storage.path(),
3187            "beta.txt",
3188            "old actor two token\n",
3189            "new actor two token\n",
3190        );
3191
3192        let executor = Executor::new();
3193        assert!(executor.register_actor(root1.clone(), Arc::clone(&ctx1)));
3194        assert!(executor.register_actor(root2.clone(), Arc::clone(&ctx2)));
3195
3196        std::thread::sleep(Duration::from_millis(20));
3197        flush_actor_search_indexes_on_graceful_shutdown(&executor.actor_contexts());
3198
3199        let mut restored1 =
3200            crate::search_index::SearchIndex::read_from_disk(&cache_dir1, &canonical_root1)
3201                .expect("load flushed root one index");
3202        restored1.ready = true;
3203        assert_eq!(
3204            restored1
3205                .grep("new actor one token", true, &[], &[], &canonical_root1, 10)
3206                .matches
3207                .len(),
3208            1,
3209            "graceful subc shutdown should flush the first root's trigram delta"
3210        );
3211
3212        let mut restored2 =
3213            crate::search_index::SearchIndex::read_from_disk(&cache_dir2, &canonical_root2)
3214                .expect("load flushed root two index");
3215        restored2.ready = true;
3216        assert_eq!(
3217            restored2
3218                .grep("new actor two token", true, &[], &[], &canonical_root2, 10)
3219                .matches
3220                .len(),
3221            1,
3222            "graceful subc shutdown should flush every registered root"
3223        );
3224    }
3225
3226    #[test]
3227    fn due_maintenance_jobs_skip_poisoned_roots() {
3228        let (_healthy_dir, healthy_root) = test_root("maintenance-healthy");
3229        let (_poisoned_dir, poisoned_root) = test_root("maintenance-poisoned");
3230        let mut live_roots = HashMap::new();
3231        live_roots.insert(healthy_root.clone(), RootMeta::new(Instant::now()));
3232        let mut poisoned_meta = RootMeta::new(Instant::now());
3233        poisoned_meta.maintenance_poisoned = true;
3234        live_roots.insert(poisoned_root.clone(), poisoned_meta);
3235
3236        let (due, deferred) =
3237            due_maintenance_jobs(&mut live_roots, MAINTENANCE_SUBMIT_BUDGET, &HashSet::new());
3238
3239        assert_eq!(due.len(), INITIAL_MAINTENANCE_JOB_COUNT);
3240        assert!(due.iter().all(|(root, _)| root == &healthy_root));
3241        assert!(!deferred);
3242        assert!(live_roots[&healthy_root].maintenance_pending);
3243        assert_eq!(
3244            live_roots[&healthy_root].maintenance_jobs_in_flight,
3245            INITIAL_MAINTENANCE_JOB_COUNT
3246        );
3247        assert!(!live_roots[&poisoned_root].maintenance_pending);
3248    }
3249
3250    #[test]
3251    fn due_maintenance_jobs_defers_unsubmitted_roots_without_marking_pending() {
3252        let mut live_roots = HashMap::new();
3253        let mut root_ids = Vec::new();
3254        let mut _dirs = Vec::new();
3255        for index in 0..4 {
3256            let (dir, root_id) = test_root(&format!("maintenance-budget-{index}"));
3257            live_roots.insert(root_id.clone(), RootMeta::new(Instant::now()));
3258            root_ids.push(root_id);
3259            _dirs.push(dir);
3260        }
3261
3262        let small_budget = INITIAL_MAINTENANCE_JOB_COUNT + 1;
3263        let (first_due, first_deferred) =
3264            due_maintenance_jobs(&mut live_roots, small_budget, &HashSet::new());
3265
3266        assert_eq!(first_due.len(), small_budget);
3267        assert!(first_deferred);
3268        let first_due_set: HashSet<_> = first_due.into_iter().map(|(root, _)| root).collect();
3269        assert!(first_due_set
3270            .iter()
3271            .all(|root| live_roots[root].maintenance_pending));
3272        assert!(first_due_set
3273            .iter()
3274            .any(|root| !live_roots[root].maintenance_queued_kinds.is_empty()));
3275
3276        let all_roots: HashSet<_> = root_ids.into_iter().collect();
3277        let deferred_roots: HashSet<_> = all_roots.difference(&first_due_set).cloned().collect();
3278        assert!(deferred_roots
3279            .iter()
3280            .all(|root| !live_roots[root].maintenance_pending));
3281    }
3282
3283    #[test]
3284    fn due_maintenance_jobs_defers_pending_bind_roots() {
3285        let (_bind_dir, bind_root) = test_root("maintenance-pending-bind");
3286        let (_healthy_dir, healthy_root) = test_root("maintenance-no-bind");
3287        let mut live_roots = HashMap::new();
3288        live_roots.insert(bind_root.clone(), RootMeta::new(Instant::now()));
3289        live_roots.insert(healthy_root.clone(), RootMeta::new(Instant::now()));
3290        let pending_bind_roots = HashSet::from([bind_root.clone()]);
3291
3292        let (due, deferred) =
3293            due_maintenance_jobs(&mut live_roots, usize::MAX, &pending_bind_roots);
3294
3295        assert_eq!(due.len(), INITIAL_MAINTENANCE_JOB_COUNT);
3296        assert!(due.iter().all(|(root, _)| root == &healthy_root));
3297        assert!(!deferred);
3298        assert!(!live_roots[&bind_root].maintenance_pending);
3299        assert!(live_roots[&bind_root].maintenance_queued_kinds.is_empty());
3300    }
3301
3302    #[test]
3303    fn maintenance_pending_survives_requeue_and_clears_after_final_batch() {
3304        let (_dir, root) = test_root("maintenance-requeue");
3305        let mut live_roots = HashMap::new();
3306        live_roots.insert(root.clone(), RootMeta::new(Instant::now()));
3307        let (due, deferred) = due_maintenance_jobs(&mut live_roots, usize::MAX, &HashSet::new());
3308        assert_eq!(due.len(), INITIAL_MAINTENANCE_JOB_COUNT);
3309        assert!(due.iter().all(|(due_root, _)| due_root == &root));
3310        assert!(!deferred);
3311
3312        let meta = live_roots.get_mut(&root).unwrap();
3313        note_maintenance_completion(meta, Some(MaintenanceDrainKind::Watcher), false, false);
3314        assert!(meta.maintenance_pending);
3315        assert_eq!(
3316            meta.maintenance_jobs_in_flight,
3317            INITIAL_MAINTENANCE_JOB_COUNT - 1
3318        );
3319        assert_eq!(meta.maintenance_queued_kinds.len(), 1);
3320
3321        let (requeued, deferred) = due_maintenance_jobs(&mut live_roots, 1, &HashSet::new());
3322        assert_eq!(
3323            requeued,
3324            vec![(root.clone(), MaintenanceDrainKind::Watcher)]
3325        );
3326        assert!(!deferred);
3327        let meta = live_roots.get_mut(&root).unwrap();
3328        assert_eq!(
3329            meta.maintenance_jobs_in_flight,
3330            INITIAL_MAINTENANCE_JOB_COUNT
3331        );
3332        assert!(meta.maintenance_queued_kinds.is_empty());
3333
3334        for _ in 0..INITIAL_MAINTENANCE_JOB_COUNT {
3335            note_maintenance_completion(meta, None, false, false);
3336        }
3337        assert!(!meta.maintenance_pending);
3338        assert_eq!(meta.maintenance_jobs_in_flight, 0);
3339    }
3340
3341    #[test]
3342    fn maintenance_requeue_drops_while_bind_is_pending() {
3343        let (_dir, root) = test_root("maintenance-bind-requeue");
3344        let mut live_roots = HashMap::new();
3345        live_roots.insert(root.clone(), RootMeta::new(Instant::now()));
3346        let (due, _) = due_maintenance_jobs(&mut live_roots, usize::MAX, &HashSet::new());
3347        assert_eq!(due.len(), INITIAL_MAINTENANCE_JOB_COUNT);
3348
3349        let meta = live_roots.get_mut(&root).unwrap();
3350        note_maintenance_completion(meta, Some(MaintenanceDrainKind::Watcher), false, true);
3351
3352        assert_eq!(
3353            meta.maintenance_jobs_in_flight,
3354            INITIAL_MAINTENANCE_JOB_COUNT - 1
3355        );
3356        assert!(meta.maintenance_queued_kinds.is_empty());
3357        assert!(meta.maintenance_pending);
3358    }
3359
3360    #[test]
3361    fn maintenance_pending_clears_and_poison_stops_requeue_after_fatal() {
3362        let (_dir, root) = test_root("maintenance-fatal");
3363        let mut live_roots = HashMap::new();
3364        live_roots.insert(root.clone(), RootMeta::new(Instant::now()));
3365        let (due, _) = due_maintenance_jobs(&mut live_roots, usize::MAX, &HashSet::new());
3366        assert_eq!(due.len(), INITIAL_MAINTENANCE_JOB_COUNT);
3367
3368        let meta = live_roots.get_mut(&root).unwrap();
3369        note_maintenance_completion(meta, Some(MaintenanceDrainKind::Watcher), true, false);
3370        assert!(meta.maintenance_poisoned);
3371        assert!(meta.maintenance_queued_kinds.is_empty());
3372
3373        for _ in 1..INITIAL_MAINTENANCE_JOB_COUNT {
3374            note_maintenance_completion(meta, None, false, false);
3375        }
3376        assert!(!meta.maintenance_pending);
3377        assert_eq!(meta.maintenance_jobs_in_flight, 0);
3378    }
3379
3380    #[test]
3381    fn trust_for_principal_matrix() {
3382        assert_eq!(
3383            trust_for_principal(&Some(Principal::Direct)),
3384            BindTrust::FirstParty
3385        );
3386        assert_eq!(
3387            trust_for_principal(&Some(Principal::Reserved {
3388                module_id: "llm-runner".to_string(),
3389            })),
3390            BindTrust::FirstParty
3391        );
3392        assert_eq!(
3393            trust_for_principal(&Some(Principal::Reserved {
3394                module_id: "aft".to_string(),
3395            })),
3396            BindTrust::FirstParty
3397        );
3398        assert_eq!(
3399            trust_for_principal(&Some(Principal::Reserved {
3400                module_id: "subc-mcp".to_string(),
3401            })),
3402            BindTrust::Untrusted
3403        );
3404        assert_eq!(
3405            trust_for_principal(&Some(Principal::Reserved {
3406                module_id: "anything-unknown".to_string(),
3407            })),
3408            BindTrust::Untrusted
3409        );
3410        assert_eq!(
3411            trust_for_principal(&Some(Principal::Unverified)),
3412            BindTrust::Untrusted
3413        );
3414        assert_eq!(trust_for_principal(&None), BindTrust::Untrusted);
3415    }
3416
3417    #[test]
3418    fn fed_harness_class_maps_to_untrusted_regardless_of_fingerprint_value() {
3419        let principal = Some(Principal::Direct);
3420        let fingerprint_a = "fed:0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef";
3421        let fingerprint_b = "fed:0123456789abcdef111111111111111111111111111111111111111111111111";
3422
3423        assert_eq!(
3424            trust_for_bind(fingerprint_a, &principal),
3425            BindTrust::Untrusted
3426        );
3427        assert_eq!(
3428            trust_for_bind(fingerprint_b, &principal),
3429            BindTrust::Untrusted
3430        );
3431    }
3432
3433    #[tokio::test]
3434    async fn persistent_cancel_resolves_when_fired_before_await() {
3435        // The lost-wakeup guard: cancel() fires exactly once via notify_waiters()
3436        // (no stored permit). A waiter that registers AFTER the cancel must still
3437        // observe it via the flag; a waiter racing the cancel must still be woken.
3438        let signal = PersistentCancelSignal::new();
3439        signal.cancel();
3440        // Fired before we ever call cancelled() — must return immediately, not park.
3441        tokio::time::timeout(Duration::from_secs(1), signal.cancelled())
3442            .await
3443            .expect("cancelled() must resolve when cancel fired beforehand");
3444
3445        // A fresh signal cancelled concurrently with an in-flight cancelled().
3446        let racing = PersistentCancelSignal::new();
3447        let racing_for_task = racing.clone();
3448        let waiter = tokio::spawn(async move { racing_for_task.cancelled().await });
3449        racing.cancel();
3450        tokio::time::timeout(Duration::from_secs(1), waiter)
3451            .await
3452            .expect("cancelled() must resolve when cancel races the await")
3453            .expect("waiter task panicked");
3454    }
3455}