Skip to main content

aft/
subc.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, 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};
28use crate::executor::{Executor, Lane};
29use crate::log_ctx;
30use crate::path_identity::ProjectRootId;
31use crate::protocol::{ProgressKind, PushFrame, RawRequest, Response};
32use crate::run_tool_call::{run_tool_call, ToolCallContext, ToolCallOutcome, ToolCallResult};
33use crate::runtime_drain;
34
35use subc_protocol::manifest::{
36    Bindings, Concurrency, ExecutionMode, IdentityBinding, IdentityScope, ModuleManifest,
37    ProviderRole, StorageBinding, StorageKind, StorageScope, Tool, TrustTier,
38};
39use subc_protocol::session::{ModuleControlRequest, ModuleControlResponse};
40use subc_protocol::{
41    ErrorBody, Flags, Frame, FrameType, ModuleHelloBody, Principal, Priority, PROTOCOL_VERSION,
42};
43use subc_transport::{authenticate_client, connection_file, read_frame, write_frame};
44use tokio::io::{AsyncRead, AsyncWrite};
45use tokio::net::TcpStream;
46use tokio::sync::{mpsc, oneshot, Notify};
47use tokio::task::JoinHandle;
48
49/// Handshake budget. subc binds-before-spawn, so a reachable daemon authenticates
50/// well within this; an unreachable/socket-stale daemon fails loud rather than
51/// silently downgrading to standalone (the --subc contract).
52const AUTH_DEADLINE: Duration = Duration::from_secs(5);
53
54/// Correlation id for the initial ModuleHello (channel 0).
55const HELLO_CORR: u64 = 1;
56
57/// Per-session in-memory replay cap for must-deliver Push frames. This covers
58/// detach/re-attach while AFT stays alive; cross-restart replay is phased later.
59const PUSH_BUFFER_MAX_PER_KEY: usize = 256;
60
61/// Bounded guard for control-frame sends. If the daemon stops reading and the
62/// writer queue stays full, tear the subc edge down instead of stalling the
63/// route loop indefinitely.
64const CONTROL_SEND_TIMEOUT: Duration = Duration::from_millis(250);
65
66/// Small bounded memory of completed task ids used to suppress stale lossy
67/// long-running reminders that arrive after their reliable completion event.
68const COMPLETED_TASK_SUPPRESSION_MAX: usize = 4096;
69
70/// Bash foreground orchestration polls detached tasks with short read-lane jobs.
71/// The sleep between polls is outside the executor so no read or write worker is
72/// pinned while a foreground command is still running.
73const PENDING_POLL_INTERVAL: Duration = Duration::from_millis(100);
74
75type RouteChannel = u32;
76type PushEnvelope = (ProjectRootId, PushFrame);
77type RetryBuffer = HashMap<RouteChannel, VecDeque<(ReplayKey, PushFrame)>>;
78
79#[derive(Clone)]
80struct PushSenders {
81    lossy_tx: mpsc::Sender<PushEnvelope>,
82    reliable_tx: mpsc::UnboundedSender<PushEnvelope>,
83}
84
85#[derive(Clone)]
86struct PersistentCancelSignal {
87    inner: Arc<PersistentCancelInner>,
88}
89
90struct PersistentCancelInner {
91    cancelled: AtomicBool,
92    notify: Notify,
93}
94
95impl PersistentCancelSignal {
96    fn new() -> Self {
97        Self {
98            inner: Arc::new(PersistentCancelInner {
99                cancelled: AtomicBool::new(false),
100                notify: Notify::new(),
101            }),
102        }
103    }
104
105    fn cancel(&self) {
106        if !self.inner.cancelled.swap(true, Ordering::SeqCst) {
107            self.inner.notify.notify_waiters();
108        }
109    }
110
111    fn is_cancelled(&self) -> bool {
112        self.inner.cancelled.load(Ordering::SeqCst)
113    }
114
115    async fn cancelled(&self) {
116        // `enable()` REGISTERS this waiter before we read the flag, closing the
117        // lost-wakeup window: `notify_waiters()` only wakes already-registered
118        // waiters and stores no permit, so without enable() a `cancel()` firing
119        // between the flag read and `.await` would be missed and the future
120        // would park forever (cancel() fires only once). With enable(), a cancel
121        // racing the flag read still wakes the registered waiter. The loop is a
122        // belt-and-suspenders re-check on spurious wakeups.
123        loop {
124            let notified = self.inner.notify.notified();
125            tokio::pin!(notified);
126            notified.as_mut().enable();
127            if self.is_cancelled() {
128                return;
129            }
130            notified.await;
131        }
132    }
133}
134
135#[derive(Clone)]
136struct BashWaitCancel {
137    connection: PersistentCancelSignal,
138    route: PersistentCancelSignal,
139}
140
141impl BashWaitCancel {
142    async fn cancelled(&self) {
143        tokio::select! {
144            _ = self.connection.cancelled() => {}
145            _ = self.route.cancelled() => {}
146        }
147    }
148}
149
150struct RouteBashCancel {
151    token: PersistentCancelSignal,
152    active_waits: usize,
153}
154
155struct BashDeferredCompletion {
156    channel: u16,
157    corr: u64,
158    flags: Flags,
159    ver: u8,
160    root: ProjectRootId,
161    request_id: String,
162    result: Option<ToolCallResult>,
163    fatal: bool,
164}
165
166#[derive(Debug, Clone, Copy, PartialEq, Eq)]
167pub(crate) enum BindTrust {
168    FirstParty,
169    Untrusted,
170}
171
172impl BindTrust {
173    fn allows_bash_observation(self) -> bool {
174        matches!(self, Self::FirstParty)
175    }
176
177    fn label(self) -> &'static str {
178        match self {
179            Self::FirstParty => "first_party",
180            Self::Untrusted => "untrusted",
181        }
182    }
183}
184
185pub(crate) fn trust_for_principal(principal: &Option<Principal>) -> BindTrust {
186    match principal {
187        Some(Principal::Direct) => BindTrust::FirstParty,
188        Some(Principal::Reserved { module_id })
189            if module_id == "llm-runner" || module_id == "aft" =>
190        {
191            BindTrust::FirstParty
192        }
193        Some(Principal::Reserved { .. }) | Some(Principal::Unverified) | None => {
194            BindTrust::Untrusted
195        }
196    }
197}
198
199fn principal_label(principal: &Option<Principal>) -> String {
200    match principal {
201        Some(Principal::Direct) => "direct".to_string(),
202        Some(Principal::Reserved { module_id }) => format!("reserved:{module_id}"),
203        Some(Principal::Unverified) => "unverified".to_string(),
204        None => "absent".to_string(),
205    }
206}
207
208#[derive(Debug)]
209/// Per-root route metadata owned by the subc loop. The `active_bash_waits` field
210/// counts detached bash processes that are still being observed for this root.
211/// Any future logic that evicts roots based on idle time must not evict a root
212/// while this count is greater than zero, because a foreground bash response may
213/// still arrive later.
214struct RootMeta {
215    maintenance_pending: bool,
216    last_touched: Instant,
217    diagnostics_on_edit: bool,
218    active_bash_waits: usize,
219}
220
221#[derive(Debug)]
222struct PendingBind {
223    bind_root_id: ProjectRootId,
224    inserted_new_actor: bool,
225    cancelled: bool,
226}
227
228struct RouteBindCompletion {
229    route_channel: u16,
230    identity: RouteIdentity,
231    bind_root_id: ProjectRootId,
232    inserted_new_actor: bool,
233    configure_response: Response,
234    drain_response: Option<Response>,
235    diagnostics_on_edit: bool,
236    ver: u8,
237    corr: u64,
238    flags: Flags,
239}
240
241#[derive(Debug, Clone)]
242struct RouteIdentity {
243    root: ProjectRootId,
244    project_root: PathBuf,
245    harness: String,
246    session: String,
247    trust: BindTrust,
248}
249
250#[derive(Debug, Clone)]
251struct RetainedSessionIdentity {
252    harness: String,
253    trust: BindTrust,
254}
255
256#[derive(Clone, Copy)]
257struct BgSub {
258    corr: u64,
259    ver: u8,
260    flags: Flags,
261}
262
263struct MaintenanceCompletion {
264    root_id: ProjectRootId,
265    response: Response,
266    empty_bg_sessions: Vec<(String, u64)>,
267}
268
269#[derive(Debug, Clone, PartialEq, Eq, Hash)]
270struct ReplayKey {
271    root: ProjectRootId,
272    harness: String,
273    session: String,
274}
275
276impl ReplayKey {
277    fn from_identity(identity: &RouteIdentity) -> Self {
278        Self {
279            root: identity.root.clone(),
280            harness: identity.harness.clone(),
281            session: identity.session.clone(),
282        }
283    }
284}
285
286#[derive(Debug, Default)]
287struct CompletedTaskIds {
288    order: VecDeque<String>,
289    set: HashSet<String>,
290}
291
292impl CompletedTaskIds {
293    fn remember(&mut self, task_id: &str) {
294        if self.set.contains(task_id) {
295            return;
296        }
297        if self.order.len() >= COMPLETED_TASK_SUPPRESSION_MAX {
298            if let Some(evicted) = self.order.pop_front() {
299                self.set.remove(&evicted);
300            }
301        }
302        let task_id = task_id.to_string();
303        self.order.push_back(task_id.clone());
304        self.set.insert(task_id);
305    }
306
307    fn contains(&self, task_id: &str) -> bool {
308        self.set.contains(task_id)
309    }
310}
311
312impl RootMeta {
313    fn new(now: Instant) -> Self {
314        Self {
315            maintenance_pending: false,
316            last_touched: now,
317            diagnostics_on_edit: false,
318            active_bash_waits: 0,
319        }
320    }
321
322    fn touch(&mut self) {
323        self.last_touched = Instant::now();
324    }
325}
326
327fn route_key(channel: u16) -> RouteChannel {
328    RouteChannel::from(channel)
329}
330
331fn remove_root_channel(
332    root_channels: &mut HashMap<ProjectRootId, HashSet<RouteChannel>>,
333    root: &ProjectRootId,
334    channel: RouteChannel,
335) {
336    let remove_root = if let Some(channels) = root_channels.get_mut(root) {
337        channels.remove(&channel);
338        channels.is_empty()
339    } else {
340        false
341    };
342    if remove_root {
343        root_channels.remove(root);
344    }
345}
346
347fn remove_route_channel(
348    routes: &mut HashMap<RouteChannel, RouteIdentity>,
349    root_channels: &mut HashMap<ProjectRootId, HashSet<RouteChannel>>,
350    channel: RouteChannel,
351) -> Option<RouteIdentity> {
352    let removed = routes.remove(&channel);
353    if let Some(identity) = &removed {
354        remove_root_channel(root_channels, &identity.root, channel);
355    }
356    removed
357}
358
359fn insert_route_channel(
360    routes: &mut HashMap<RouteChannel, RouteIdentity>,
361    root_channels: &mut HashMap<ProjectRootId, HashSet<RouteChannel>>,
362    channel: RouteChannel,
363    identity: RouteIdentity,
364) {
365    if let Some(previous) = routes.insert(channel, identity.clone()) {
366        remove_root_channel(root_channels, &previous.root, channel);
367    }
368    root_channels
369        .entry(identity.root.clone())
370        .or_default()
371        .insert(channel);
372}
373
374fn remove_bg_subscription_index(
375    bg_sub_by_session: &mut HashMap<(ProjectRootId, String), RouteChannel>,
376    channel: RouteChannel,
377    identity: Option<&RouteIdentity>,
378) {
379    if let Some(identity) = identity {
380        let key = (identity.root.clone(), identity.session.clone());
381        if bg_sub_by_session.get(&key).copied() == Some(channel) {
382            bg_sub_by_session.remove(&key);
383        }
384    } else {
385        bg_sub_by_session.retain(|_, mapped_channel| *mapped_channel != channel);
386    }
387}
388
389fn end_bg_subscription(
390    writer_tx: &mpsc::Sender<Frame>,
391    bg_subs: &mut HashMap<RouteChannel, BgSub>,
392    bg_sub_by_session: &mut HashMap<(ProjectRootId, String), RouteChannel>,
393    bg_wake_pending: &mut HashSet<RouteChannel>,
394    channel: RouteChannel,
395    identity: Option<&RouteIdentity>,
396) {
397    if let Some(sub) = bg_subs.get(&channel).copied() {
398        let _ = try_send_bg_stream_end(writer_tx, channel, &sub);
399        bg_subs.remove(&channel);
400        bg_wake_pending.remove(&channel);
401        remove_bg_subscription_index(bg_sub_by_session, channel, identity);
402    }
403}
404
405fn remember_session_identity(
406    session_identity: &mut HashMap<(ProjectRootId, String), RetainedSessionIdentity>,
407    identity: &RouteIdentity,
408) {
409    let key = (identity.root.clone(), identity.session.clone());
410    if matches!(identity.trust, BindTrust::Untrusted)
411        && session_identity
412            .get(&key)
413            .is_some_and(|retained| matches!(retained.trust, BindTrust::FirstParty))
414    {
415        return;
416    }
417
418    // Retained after route Goodbye so reliable session-scoped frames emitted while
419    // the session is detached can still be keyed by the full (root,harness,session)
420    // replay triple. Untrusted binds never overwrite a retained first-party
421    // session identity, because bash completion replay is an observation channel.
422    session_identity.insert(
423        key,
424        RetainedSessionIdentity {
425            harness: identity.harness.clone(),
426            trust: identity.trust,
427        },
428    );
429}
430
431fn replay_key_for_session(
432    session_identity: &HashMap<(ProjectRootId, String), RetainedSessionIdentity>,
433    root: &ProjectRootId,
434    session: &str,
435) -> Option<(ReplayKey, BindTrust)> {
436    let retained = session_identity.get(&(root.clone(), session.to_string()))?;
437    Some((
438        ReplayKey {
439            root: root.clone(),
440            harness: retained.harness.clone(),
441            session: session.to_string(),
442        },
443        retained.trust,
444    ))
445}
446
447fn frame_session(frame: &PushFrame) -> Option<&str> {
448    match frame {
449        PushFrame::BashCompleted(completed) => Some(completed.session_id.as_str()),
450        PushFrame::BashLongRunning(long_running) => Some(long_running.session_id.as_str()),
451        PushFrame::BashPatternMatch(pattern_match) => Some(pattern_match.session_id.as_str()),
452        PushFrame::ConfigureWarnings(warnings) => warnings.session_id.as_deref(),
453        PushFrame::StatusChanged(status) => status.session_id.as_deref(),
454        PushFrame::Progress(_) => None,
455    }
456}
457
458fn frame_is_reliable(frame: &PushFrame) -> bool {
459    matches!(
460        frame,
461        PushFrame::BashCompleted(_)
462            | PushFrame::BashPatternMatch(_)
463            | PushFrame::ConfigureWarnings(_)
464    )
465}
466
467fn frame_is_bash_observation(frame: &PushFrame) -> bool {
468    matches!(
469        frame,
470        PushFrame::BashCompleted(_)
471            | PushFrame::BashLongRunning(_)
472            | PushFrame::BashPatternMatch(_)
473    )
474}
475
476fn completed_task_id(frame: &PushFrame) -> Option<&str> {
477    match frame {
478        PushFrame::BashCompleted(completed) => Some(completed.task_id.as_str()),
479        _ => None,
480    }
481}
482
483fn completed_bg_session_key(
484    root: &ProjectRootId,
485    frame: &PushFrame,
486) -> Option<(ProjectRootId, String)> {
487    match frame {
488        PushFrame::BashCompleted(completed) => Some((root.clone(), completed.session_id.clone())),
489        _ => None,
490    }
491}
492
493fn long_running_task_id(frame: &PushFrame) -> Option<&str> {
494    match frame {
495        PushFrame::BashLongRunning(long_running) => Some(long_running.task_id.as_str()),
496        _ => None,
497    }
498}
499
500fn should_drop_lossy_push(completed_tasks: &CompletedTaskIds, frame: &PushFrame) -> bool {
501    long_running_task_id(frame).is_some_and(|task_id| completed_tasks.contains(task_id))
502}
503
504fn progress_sender_for_root(push_senders: PushSenders, root_id: ProjectRootId) -> ProgressSender {
505    Arc::new(Box::new(move |frame: PushFrame| {
506        // Emitters can run on executor workers, maintenance jobs, watcher drains,
507        // semantic refresh workers, or bg-bash watchdog threads. Never block any
508        // of them on subc routing/backpressure: reliable frames take an
509        // unbounded non-blocking lane; lossy frames stay bounded and coalesced.
510        if frame_is_reliable(&frame) {
511            let _ = push_senders.reliable_tx.send((root_id.clone(), frame));
512        } else {
513            let _ = push_senders.lossy_tx.try_send((root_id.clone(), frame));
514        }
515    }))
516}
517
518#[derive(Debug, Clone, PartialEq, Eq, Hash)]
519enum LossyProgressKind {
520    Stdout,
521    Stderr,
522}
523
524impl From<&ProgressKind> for LossyProgressKind {
525    fn from(kind: &ProgressKind) -> Self {
526        match kind {
527            ProgressKind::Stdout => Self::Stdout,
528            ProgressKind::Stderr => Self::Stderr,
529        }
530    }
531}
532
533#[derive(Debug, Clone, PartialEq, Eq, Hash)]
534enum LossyPushKey {
535    Progress {
536        request_id: String,
537        kind: LossyProgressKind,
538    },
539    StatusChanged,
540    BashLongRunning {
541        task_id: String,
542    },
543}
544
545fn lossy_push_key(frame: &PushFrame) -> Option<LossyPushKey> {
546    match frame {
547        PushFrame::Progress(progress) => Some(LossyPushKey::Progress {
548            request_id: progress.request_id.clone(),
549            kind: LossyProgressKind::from(&progress.kind),
550        }),
551        PushFrame::StatusChanged(_) => Some(LossyPushKey::StatusChanged),
552        PushFrame::BashLongRunning(long_running) => Some(LossyPushKey::BashLongRunning {
553            task_id: long_running.task_id.clone(),
554        }),
555        PushFrame::BashCompleted(_)
556        | PushFrame::BashPatternMatch(_)
557        | PushFrame::ConfigureWarnings(_) => None,
558    }
559}
560
561fn coalesce_push_batch(batch: Vec<(ProjectRootId, PushFrame)>) -> Vec<(ProjectRootId, PushFrame)> {
562    let mut slots: Vec<Option<(ProjectRootId, PushFrame)>> = Vec::with_capacity(batch.len());
563    let mut latest_lossy: HashMap<(ProjectRootId, LossyPushKey), usize> = HashMap::new();
564
565    for (root, frame) in batch {
566        if let Some(lossy_key) = lossy_push_key(&frame) {
567            let map_key = (root.clone(), lossy_key);
568            if let Some(previous_index) = latest_lossy.insert(map_key, slots.len()) {
569                slots[previous_index] = None;
570            }
571        }
572        slots.push(Some((root, frame)));
573    }
574
575    slots.into_iter().flatten().collect()
576}
577
578#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
579struct FanOutResult {
580    /// Channels matching the frame's project/session scope. Reliable Push frames
581    /// that match a channel but hit writer backpressure are held in retry_buffer
582    /// instead of being mistaken for detach replay.
583    matched_channels: usize,
584    /// Frames accepted by the writer queue immediately. Lossy frames that are not
585    /// accepted are dropped; reliable frames are retried on transient backpressure.
586    sent_frames: usize,
587}
588
589#[derive(Debug, Clone, Copy, PartialEq, Eq)]
590enum PushSendOutcome {
591    Sent,
592    Backpressure,
593    PermanentFailure,
594}
595
596fn try_send_push_body(
597    writer_tx: &mpsc::Sender<Frame>,
598    channel: RouteChannel,
599    body: &[u8],
600) -> PushSendOutcome {
601    let Ok(route_channel) = u16::try_from(channel) else {
602        log::warn!("subc attach: invalid route channel {channel} for Push fan-out");
603        return PushSendOutcome::PermanentFailure;
604    };
605    let push_frame = match Frame::build_with_version(
606        PROTOCOL_VERSION,
607        FrameType::Push,
608        control_flags(),
609        route_channel,
610        0,
611        body.to_vec(),
612    ) {
613        Ok(frame) => frame,
614        Err(error) => {
615            log::warn!("subc attach: failed to build Push frame: {error}");
616            return PushSendOutcome::PermanentFailure;
617        }
618    };
619    match writer_tx.try_send(push_frame) {
620        Ok(()) => PushSendOutcome::Sent,
621        Err(mpsc::error::TrySendError::Full(_)) => PushSendOutcome::Backpressure,
622        Err(mpsc::error::TrySendError::Closed(_)) => {
623            log::warn!("subc attach: writer closed while sending Push frame");
624            PushSendOutcome::PermanentFailure
625        }
626    }
627}
628
629fn try_send_push_frame(
630    writer_tx: &mpsc::Sender<Frame>,
631    channel: RouteChannel,
632    frame: &PushFrame,
633) -> PushSendOutcome {
634    let body = match serde_json::to_vec(frame) {
635        Ok(body) => body,
636        Err(error) => {
637            log::warn!("subc attach: failed to serialize PushFrame: {error}");
638            return PushSendOutcome::PermanentFailure;
639        }
640    };
641    try_send_push_body(writer_tx, channel, &body)
642}
643
644fn try_send_bg_stream_frame(
645    writer_tx: &mpsc::Sender<Frame>,
646    channel: RouteChannel,
647    sub: &BgSub,
648    ty: FrameType,
649    body: Vec<u8>,
650) -> PushSendOutcome {
651    let Ok(route_channel) = u16::try_from(channel) else {
652        log::warn!("subc attach: invalid route channel {channel} for bg_events stream");
653        return PushSendOutcome::PermanentFailure;
654    };
655    let frame =
656        match Frame::build_with_version(sub.ver, ty, sub.flags, route_channel, sub.corr, body) {
657            Ok(frame) => frame,
658            Err(error) => {
659                log::warn!("subc attach: failed to build bg_events stream frame: {error}");
660                return PushSendOutcome::PermanentFailure;
661            }
662        };
663    match writer_tx.try_send(frame) {
664        Ok(()) => PushSendOutcome::Sent,
665        Err(mpsc::error::TrySendError::Full(_)) => PushSendOutcome::Backpressure,
666        Err(mpsc::error::TrySendError::Closed(_)) => {
667            log::warn!("subc attach: writer closed while sending bg_events stream frame");
668            PushSendOutcome::PermanentFailure
669        }
670    }
671}
672
673fn try_send_bg_stream_data(
674    writer_tx: &mpsc::Sender<Frame>,
675    channel: RouteChannel,
676    sub: &BgSub,
677) -> PushSendOutcome {
678    let body = match serde_json::to_vec(&json!({ "op": "bg_events" })) {
679        Ok(body) => body,
680        Err(error) => {
681            log::warn!("subc attach: failed to serialize bg_events stream payload: {error}");
682            return PushSendOutcome::PermanentFailure;
683        }
684    };
685    try_send_bg_stream_frame(writer_tx, channel, sub, FrameType::StreamData, body)
686}
687
688fn try_send_bg_stream_end(
689    writer_tx: &mpsc::Sender<Frame>,
690    channel: RouteChannel,
691    sub: &BgSub,
692) -> PushSendOutcome {
693    try_send_bg_stream_frame(writer_tx, channel, sub, FrameType::StreamEnd, Vec::new())
694}
695
696fn emit_bg_event_wakes(
697    writer_tx: &mpsc::Sender<Frame>,
698    bg_subs: &HashMap<RouteChannel, BgSub>,
699    bg_wake_pending: &mut HashSet<RouteChannel>,
700) {
701    let pending_channels: Vec<RouteChannel> = bg_wake_pending.iter().copied().collect();
702    let mut stale_channels = Vec::new();
703    for channel in pending_channels {
704        if let Some(sub) = bg_subs.get(&channel) {
705            let _ = try_send_bg_stream_data(writer_tx, channel, sub);
706        } else {
707            stale_channels.push(channel);
708        }
709    }
710    for channel in stale_channels {
711        bg_wake_pending.remove(&channel);
712    }
713}
714
715/// Always bump the epoch for (root, session) when arming a wake on `channel`,
716/// even if the channel was already present in the pending set. This ensures
717/// that later maintenance logic holding an older epoch value cannot suppress a
718/// wake that was armed after the maintenance snapshot was taken.
719fn arm_bg_wake(
720    root: ProjectRootId,
721    session: String,
722    channel: RouteChannel,
723    bg_wake_pending: &mut HashSet<RouteChannel>,
724    bg_wake_epoch: &mut HashMap<(ProjectRootId, String), u64>,
725) {
726    *bg_wake_epoch.entry((root, session)).or_default() += 1;
727    bg_wake_pending.insert(channel);
728}
729
730fn clear_stale_bg_wakes_for_empty_sessions(
731    root_id: &ProjectRootId,
732    empty_bg_sessions: &[(String, u64)],
733    bg_sub_by_session: &HashMap<(ProjectRootId, String), RouteChannel>,
734    bg_wake_pending: &mut HashSet<RouteChannel>,
735    bg_wake_epoch: &HashMap<(ProjectRootId, String), u64>,
736) {
737    for (session, epoch_at_submit) in empty_bg_sessions {
738        let key = (root_id.clone(), session.clone());
739        if bg_wake_epoch.get(&key).copied() == Some(*epoch_at_submit) {
740            if let Some(channel) = bg_sub_by_session.get(&key).copied() {
741                bg_wake_pending.remove(&channel);
742            }
743        }
744    }
745}
746
747fn bounded_push_back<T>(queue: &mut VecDeque<T>, item: T) {
748    if queue.len() >= PUSH_BUFFER_MAX_PER_KEY {
749        queue.pop_front();
750    }
751    queue.push_back(item);
752}
753
754fn buffer_push_frame(
755    push_buffer: &mut HashMap<ReplayKey, VecDeque<PushFrame>>,
756    key: ReplayKey,
757    frame: PushFrame,
758) {
759    bounded_push_back(push_buffer.entry(key).or_default(), frame);
760}
761
762fn buffer_retry_frame(
763    retry_buffer: &mut RetryBuffer,
764    channel: RouteChannel,
765    key: ReplayKey,
766    frame: PushFrame,
767) {
768    bounded_push_back(retry_buffer.entry(channel).or_default(), (key, frame));
769}
770
771fn migrate_retry_buffer_to_push_buffer(
772    retry_buffer: &mut RetryBuffer,
773    channel: RouteChannel,
774    push_buffer: &mut HashMap<ReplayKey, VecDeque<PushFrame>>,
775) -> usize {
776    let Some(frames) = retry_buffer.remove(&channel) else {
777        return 0;
778    };
779    let migrated = frames.len();
780    for (key, frame) in frames {
781        buffer_push_frame(push_buffer, key, frame);
782    }
783    migrated
784}
785
786fn replay_buffered_push_frames(
787    writer_tx: &mpsc::Sender<Frame>,
788    channel: RouteChannel,
789    push_buffer: &mut HashMap<ReplayKey, VecDeque<PushFrame>>,
790    key: &ReplayKey,
791    trust: BindTrust,
792) -> usize {
793    let mut sent = 0;
794    let remove_empty;
795
796    {
797        let Some(queue) = push_buffer.get_mut(key) else {
798            return 0;
799        };
800
801        while let Some(frame) = queue.pop_front() {
802            if frame_is_bash_observation(&frame) && !trust.allows_bash_observation() {
803                continue;
804            }
805            match try_send_push_frame(writer_tx, channel, &frame) {
806                PushSendOutcome::Sent => sent += 1,
807                PushSendOutcome::Backpressure => {
808                    queue.push_front(frame);
809                    break;
810                }
811                PushSendOutcome::PermanentFailure => {
812                    log::warn!(
813                        "subc attach: dropping buffered reliable Push for root {} harness {} session {} after permanent send failure",
814                        key.root.as_path().display(),
815                        key.harness,
816                        key.session
817                    );
818                }
819            }
820        }
821
822        remove_empty = queue.is_empty();
823    }
824
825    if remove_empty {
826        push_buffer.remove(key);
827    }
828
829    sent
830}
831
832fn drain_retry_buffer_for_channel(
833    writer_tx: &mpsc::Sender<Frame>,
834    channel: RouteChannel,
835    retry_buffer: &mut RetryBuffer,
836) -> usize {
837    let mut sent = 0;
838    let remove_empty;
839
840    {
841        let Some(queue) = retry_buffer.get_mut(&channel) else {
842            return 0;
843        };
844
845        while let Some((key, frame)) = queue.pop_front() {
846            match try_send_push_frame(writer_tx, channel, &frame) {
847                PushSendOutcome::Sent => sent += 1,
848                PushSendOutcome::Backpressure => {
849                    queue.push_front((key, frame));
850                    break;
851                }
852                PushSendOutcome::PermanentFailure => {
853                    log::warn!(
854                        "subc attach: dropping retry-buffered reliable Push for route {channel} root {} harness {} session {} after permanent send failure",
855                        key.root.as_path().display(),
856                        key.harness,
857                        key.session
858                    );
859                }
860            }
861        }
862
863        remove_empty = queue.is_empty();
864    }
865
866    if remove_empty {
867        retry_buffer.remove(&channel);
868    }
869
870    sent
871}
872
873fn drain_retry_buffers_for_bound_routes(
874    writer_tx: &mpsc::Sender<Frame>,
875    routes: &HashMap<RouteChannel, RouteIdentity>,
876    retry_buffer: &mut RetryBuffer,
877) -> usize {
878    let channels: Vec<RouteChannel> = routes.keys().copied().collect();
879    channels
880        .into_iter()
881        .map(|channel| drain_retry_buffer_for_channel(writer_tx, channel, retry_buffer))
882        .sum()
883}
884
885fn matching_route_channels(
886    routes: &HashMap<RouteChannel, RouteIdentity>,
887    root_channels: &HashMap<ProjectRootId, HashSet<RouteChannel>>,
888    root: &ProjectRootId,
889    frame: &PushFrame,
890) -> Vec<RouteChannel> {
891    let Some(channels) = root_channels.get(root) else {
892        return Vec::new();
893    };
894
895    let session = frame_session(frame);
896    let bash_observation = frame_is_bash_observation(frame);
897    channels
898        .iter()
899        .copied()
900        .filter(|channel| {
901            let Some(identity) = routes.get(channel) else {
902                return !bash_observation && session.is_none();
903            };
904            if bash_observation && !identity.trust.allows_bash_observation() {
905                return false;
906            }
907            match session {
908                Some(session) => identity.session == session,
909                None => true,
910            }
911        })
912        .collect()
913}
914
915fn buffer_detached_reliable_push_frame(
916    push_buffer: &mut HashMap<ReplayKey, VecDeque<PushFrame>>,
917    session_identity: &HashMap<(ProjectRootId, String), RetainedSessionIdentity>,
918    root: &ProjectRootId,
919    frame: &PushFrame,
920) {
921    let Some(session) = frame_session(frame) else {
922        log::warn!(
923            "subc attach: dropping reliable project-scoped Push for root {} because no route is bound",
924            root.as_path().display()
925        );
926        return;
927    };
928
929    if let Some((key, trust)) = replay_key_for_session(session_identity, root, session) {
930        if frame_is_bash_observation(frame) && !trust.allows_bash_observation() {
931            return;
932        }
933        buffer_push_frame(push_buffer, key, frame.clone());
934    } else {
935        log::warn!(
936            "subc attach: dropping reliable Push for root {} session {} because no retained harness identity is known",
937            root.as_path().display(),
938            session
939        );
940    }
941}
942
943fn fan_out_lossy_push_frame(
944    writer_tx: &mpsc::Sender<Frame>,
945    routes: &HashMap<RouteChannel, RouteIdentity>,
946    root_channels: &HashMap<ProjectRootId, HashSet<RouteChannel>>,
947    root: &ProjectRootId,
948    frame: &PushFrame,
949) -> FanOutResult {
950    let matching_channels = matching_route_channels(routes, root_channels, root, frame);
951    let matched_channels = matching_channels.len();
952    if matched_channels == 0 {
953        return FanOutResult::default();
954    }
955
956    let body = match serde_json::to_vec(frame) {
957        Ok(body) => body,
958        Err(error) => {
959            log::warn!("subc attach: failed to serialize PushFrame for fan-out: {error}");
960            return FanOutResult {
961                matched_channels,
962                sent_frames: 0,
963            };
964        }
965    };
966
967    let sent_frames = matching_channels
968        .into_iter()
969        .filter(|&channel| {
970            matches!(
971                try_send_push_body(writer_tx, channel, &body),
972                PushSendOutcome::Sent
973            )
974        })
975        .count();
976
977    FanOutResult {
978        matched_channels,
979        sent_frames,
980    }
981}
982
983fn fan_out_reliable_push_frame(
984    writer_tx: &mpsc::Sender<Frame>,
985    routes: &HashMap<RouteChannel, RouteIdentity>,
986    root_channels: &HashMap<ProjectRootId, HashSet<RouteChannel>>,
987    session_identity: &HashMap<(ProjectRootId, String), RetainedSessionIdentity>,
988    retry_buffer: &mut RetryBuffer,
989    push_buffer: &mut HashMap<ReplayKey, VecDeque<PushFrame>>,
990    root: &ProjectRootId,
991    frame: &PushFrame,
992) -> FanOutResult {
993    let matching_channels = matching_route_channels(routes, root_channels, root, frame);
994    let matched_channels = matching_channels.len();
995    if matched_channels == 0 {
996        buffer_detached_reliable_push_frame(push_buffer, session_identity, root, frame);
997        return FanOutResult::default();
998    }
999
1000    let mut sent_frames = 0;
1001    for channel in matching_channels {
1002        let Some(identity) = routes.get(&channel) else {
1003            log::warn!(
1004                "subc attach: dropping reliable Push for stale route channel {channel} with no route identity"
1005            );
1006            continue;
1007        };
1008        let key = ReplayKey::from_identity(identity);
1009
1010        if retry_buffer
1011            .get(&channel)
1012            .is_some_and(|queue| !queue.is_empty())
1013        {
1014            buffer_retry_frame(retry_buffer, channel, key, frame.clone());
1015            continue;
1016        }
1017
1018        match try_send_push_frame(writer_tx, channel, frame) {
1019            PushSendOutcome::Sent => sent_frames += 1,
1020            PushSendOutcome::Backpressure => {
1021                buffer_retry_frame(retry_buffer, channel, key, frame.clone());
1022            }
1023            PushSendOutcome::PermanentFailure => {
1024                log::warn!(
1025                    "subc attach: dropping reliable Push for route {channel} root {} harness {} session {} after permanent send failure",
1026                    key.root.as_path().display(),
1027                    key.harness,
1028                    key.session
1029                );
1030            }
1031        }
1032    }
1033
1034    FanOutResult {
1035        matched_channels,
1036        sent_frames,
1037    }
1038}
1039
1040fn process_reliable_push_frame(
1041    writer_tx: &mpsc::Sender<Frame>,
1042    routes: &HashMap<RouteChannel, RouteIdentity>,
1043    root_channels: &HashMap<ProjectRootId, HashSet<RouteChannel>>,
1044    session_identity: &HashMap<(ProjectRootId, String), RetainedSessionIdentity>,
1045    retry_buffer: &mut RetryBuffer,
1046    push_buffer: &mut HashMap<ReplayKey, VecDeque<PushFrame>>,
1047    completed_tasks: &mut CompletedTaskIds,
1048    root: ProjectRootId,
1049    frame: PushFrame,
1050) -> Option<(ProjectRootId, String)> {
1051    let completed_bg_session = completed_bg_session_key(&root, &frame);
1052    if let Some(task_id) = completed_task_id(&frame) {
1053        completed_tasks.remember(task_id);
1054    }
1055    let _ = fan_out_reliable_push_frame(
1056        writer_tx,
1057        routes,
1058        root_channels,
1059        session_identity,
1060        retry_buffer,
1061        push_buffer,
1062        &root,
1063        &frame,
1064    );
1065    completed_bg_session
1066}
1067
1068fn process_lossy_push_frame(
1069    writer_tx: &mpsc::Sender<Frame>,
1070    routes: &HashMap<RouteChannel, RouteIdentity>,
1071    root_channels: &HashMap<ProjectRootId, HashSet<RouteChannel>>,
1072    completed_tasks: &CompletedTaskIds,
1073    root: ProjectRootId,
1074    frame: PushFrame,
1075) {
1076    if should_drop_lossy_push(completed_tasks, &frame) {
1077        if let Some(task_id) = long_running_task_id(&frame) {
1078            log::debug!(
1079                "subc attach: dropping stale BashLongRunning Push for completed task {task_id}"
1080            );
1081        }
1082        return;
1083    }
1084
1085    let _ = fan_out_lossy_push_frame(writer_tx, routes, root_channels, &root, &frame);
1086}
1087
1088/// Sync command dispatch, passed in from `main` (the binary owns the command
1089/// table). Invoked only inside executor jobs in subc mode.
1090pub type DispatchFn = fn(RawRequest, &AppContext) -> Response;
1091
1092/// Entry point for `aft --subc <connection-file>`. Synchronous on the outside;
1093/// owns an isolated current-thread tokio runtime for the async transport.
1094/// Returns `Err` (fail-loud) on any connect/auth/protocol failure — we never
1095/// fall back to the standalone loop, to avoid split-brain index state.
1096pub fn run_subc_mode(
1097    connection_file_path: &Path,
1098    ctx: Arc<AppContext>,
1099    executor: Arc<Executor>,
1100    dispatch: DispatchFn,
1101    user_config_path: Option<PathBuf>,
1102) -> Result<(), SubcError> {
1103    // Production NEVER allows non-manifest tool names on route channels: AFT
1104    // fails closed and does not trust subc to enforce the manifest. The
1105    // test-only harness sets this through `run_subc_mode_for_test`.
1106    run_subc_mode_inner(
1107        connection_file_path,
1108        ctx,
1109        executor,
1110        dispatch,
1111        user_config_path,
1112        false,
1113    )
1114}
1115
1116fn run_subc_mode_inner(
1117    connection_file_path: &Path,
1118    ctx: Arc<AppContext>,
1119    executor: Arc<Executor>,
1120    dispatch: DispatchFn,
1121    user_config_path: Option<PathBuf>,
1122    allow_native_passthrough: bool,
1123) -> Result<(), SubcError> {
1124    let runtime = tokio::runtime::Builder::new_current_thread()
1125        .enable_all()
1126        .build()
1127        .map_err(SubcError::Runtime)?;
1128
1129    let executor_for_loop = Arc::clone(&executor);
1130    let loop_result = runtime.block_on(async move {
1131        let shared_app = ctx.app();
1132        drop(ctx);
1133        let stream = connect_and_authenticate(connection_file_path).await?;
1134        log::info!(
1135            "subc attach: authenticated to daemon via {}",
1136            connection_file_path.display()
1137        );
1138        let (read_half, write_half) = tokio::io::split(stream);
1139        run_module_loop(
1140            read_half,
1141            write_half,
1142            shared_app,
1143            executor_for_loop,
1144            dispatch,
1145            user_config_path,
1146            allow_native_passthrough,
1147        )
1148        .await
1149    });
1150
1151    for actor_ctx in executor.actor_contexts() {
1152        actor_ctx.lsp().shutdown_all();
1153        actor_ctx.bash_background().detach();
1154    }
1155
1156    loop_result
1157}
1158
1159/// Test-only entry that enables the non-manifest native-command passthrough on
1160/// route channels. Integration tests drive synthetic native commands (`glob`,
1161/// `callers`, `subc_test_echo_session`, …) through the executor to exercise
1162/// mechanics; production callers use [`run_subc_mode`], which fails closed.
1163#[doc(hidden)]
1164pub fn run_subc_mode_for_test(
1165    connection_file_path: &Path,
1166    ctx: Arc<AppContext>,
1167    executor: Arc<Executor>,
1168    dispatch: DispatchFn,
1169    user_config_path: Option<PathBuf>,
1170) -> Result<(), SubcError> {
1171    run_subc_mode_inner(
1172        connection_file_path,
1173        ctx,
1174        executor,
1175        dispatch,
1176        user_config_path,
1177        true,
1178    )
1179}
1180
1181/// Read the connection file → resolve the first endpoint → TCP connect → HMAC
1182/// handshake. Mirrors the reference `fake-aft-stub::connect_to_subc`.
1183async fn connect_and_authenticate(connection_file_path: &Path) -> Result<TcpStream, SubcError> {
1184    let conn = connection_file::read(connection_file_path).map_err(|source| {
1185        SubcError::ConnectionFile {
1186            path: connection_file_path.to_path_buf(),
1187            source,
1188        }
1189    })?;
1190
1191    let endpoint = conn
1192        .endpoints
1193        .first()
1194        .ok_or_else(|| SubcError::NoEndpoint {
1195            path: connection_file_path.to_path_buf(),
1196        })?;
1197    let endpoint_label = format!("{}:{}", endpoint.host, endpoint.port);
1198    let ip = endpoint
1199        .host
1200        .parse::<IpAddr>()
1201        .map_err(|_| SubcError::InvalidEndpoint {
1202            path: connection_file_path.to_path_buf(),
1203            endpoint: endpoint_label.clone(),
1204        })?;
1205    let addr = SocketAddr::new(ip, endpoint.port);
1206
1207    let mut stream = TcpStream::connect(addr)
1208        .await
1209        .map_err(|source| SubcError::Connect {
1210            endpoint: endpoint_label.clone(),
1211            source,
1212        })?;
1213
1214    authenticate_client(&mut stream, &conn, AUTH_DEADLINE)
1215        .await
1216        .map_err(|source| SubcError::Auth {
1217            endpoint: endpoint_label,
1218            source,
1219        })?;
1220
1221    Ok(stream)
1222}
1223
1224/// ModuleHello → HelloAck → control/route loop. Runs until the daemon closes
1225/// the connection (EOF), sends channel-0 Goodbye, or a fatal mutating executor
1226/// response requests whole-connection teardown.
1227async fn run_module_loop<R, W>(
1228    mut read: R,
1229    mut write: W,
1230    shared_app: Arc<App>,
1231    executor: Arc<Executor>,
1232    dispatch: DispatchFn,
1233    user_config_path: Option<PathBuf>,
1234    allow_native_passthrough: bool,
1235) -> Result<(), SubcError>
1236where
1237    R: AsyncRead + Unpin + Send + 'static,
1238    W: AsyncWrite + Unpin + Send + 'static,
1239{
1240    // ModuleHello: register as a tool provider. control_ops:None = full baseline.
1241    // Echo the one-time launch nonce the daemon injected via SUBC_LAUNCH_NONCE so a
1242    // reserved module_id's HELLO is accepted; absent for non-reserved/self-connect.
1243    let hello = ModuleHelloBody {
1244        manifest: build_manifest(),
1245        protocol_ver: PROTOCOL_VERSION,
1246        control_ops: None,
1247        launch_nonce: std::env::var("SUBC_LAUNCH_NONCE").ok(),
1248    };
1249    let hello_frame = Frame::build(
1250        FrameType::Hello,
1251        control_flags(),
1252        0,
1253        HELLO_CORR,
1254        serde_json::to_vec(&hello).map_err(SubcError::Json)?,
1255    )
1256    .map_err(SubcError::FrameBuild)?;
1257    write_frame(&mut write, &hello_frame)
1258        .await
1259        .map_err(SubcError::FrameIo)?;
1260
1261    // Expect HelloAck (registered) or a channel-0 Error (manifest/version reject).
1262    match read_frame(&mut read).await.map_err(SubcError::FrameIo)? {
1263        None => return Err(SubcError::ClosedBeforeHelloAck),
1264        Some(frame) => match frame.header.ty {
1265            FrameType::HelloAck => {
1266                log::info!("subc attach: registered (HelloAck received)");
1267            }
1268            FrameType::Error => {
1269                let body = serde_json::from_slice::<ErrorBody>(&frame.body).ok();
1270                return Err(SubcError::HelloRejected { body });
1271            }
1272            other => return Err(SubcError::UnexpectedFrame { ty: other }),
1273        },
1274    }
1275
1276    let (writer_tx, writer_rx) = mpsc::channel::<Frame>(256);
1277    let writer_task = spawn_writer_task(write, writer_rx);
1278    // `read_frame` is NOT cancellation-safe, so it must never sit directly inside
1279    // the `select!` below: a drain-interval tick (or shutdown) firing while a
1280    // frame is mid-transit would drop the partially-consumed bytes and desync the
1281    // stream (the next read would parse a body byte as a frame header). A
1282    // dedicated reader task owns the socket, reads whole frames sequentially, and
1283    // forwards them over a channel; the loop selects on the cancel-safe `recv()`.
1284    let (reader_tx, mut reader_rx) = mpsc::channel::<Result<Frame, SubcError>>(256);
1285    let reader_task = spawn_reader_task(read, reader_tx);
1286    let shutdown = Arc::new(Notify::new());
1287    let mut drain_interval = tokio::time::interval(Duration::from_millis(250));
1288    let (maintenance_tx, mut maintenance_rx) = mpsc::channel::<MaintenanceCompletion>(256);
1289    let (bash_deferred_tx, mut bash_deferred_rx) = mpsc::channel::<BashDeferredCompletion>(256);
1290    let (bash_poll_touch_tx, mut bash_poll_touch_rx) = mpsc::channel::<ProjectRootId>(256);
1291    let (control_completion_tx, mut control_completion_rx) =
1292        mpsc::channel::<RouteBindCompletion>(256);
1293    let (lossy_tx, mut lossy_rx) = mpsc::channel::<PushEnvelope>(1024);
1294    let (reliable_tx, mut reliable_rx) = mpsc::unbounded_channel::<PushEnvelope>();
1295    let push_senders = PushSenders {
1296        lossy_tx,
1297        reliable_tx,
1298    };
1299    let connection_cancel = PersistentCancelSignal::new();
1300    let mut routes: HashMap<RouteChannel, RouteIdentity> = HashMap::new();
1301    let mut bg_subs: HashMap<RouteChannel, BgSub> = HashMap::new();
1302    let mut bg_sub_by_session: HashMap<(ProjectRootId, String), RouteChannel> = HashMap::new();
1303    let mut bg_wake_pending: HashSet<RouteChannel> = HashSet::new();
1304    let mut bg_wake_epoch: HashMap<(ProjectRootId, String), u64> = HashMap::new();
1305    let mut root_channels: HashMap<ProjectRootId, HashSet<RouteChannel>> = HashMap::new();
1306    let mut session_identity: HashMap<(ProjectRootId, String), RetainedSessionIdentity> =
1307        HashMap::new();
1308    let mut push_buffer: HashMap<ReplayKey, VecDeque<PushFrame>> = HashMap::new();
1309    let mut retry_buffer: RetryBuffer = HashMap::new();
1310    let mut completed_tasks = CompletedTaskIds::default();
1311    let mut live_roots: HashMap<ProjectRootId, RootMeta> = HashMap::new();
1312    let mut pending_binds: HashMap<RouteChannel, PendingBind> = HashMap::new();
1313    let mut route_bash_cancels: HashMap<RouteChannel, RouteBashCancel> = HashMap::new();
1314
1315    let loop_result: Result<(), SubcError> = loop {
1316        tokio::select! {
1317            _ = shutdown.notified() => {
1318                log::warn!("subc attach: fatal executor response requested teardown");
1319                break Ok(());
1320            }
1321            maybe_frame = reader_rx.recv() => {
1322                let frame = match maybe_frame {
1323                    None => {
1324                        log::info!("subc attach: daemon closed connection");
1325                        break Ok(());
1326                    }
1327                    Some(Err(error)) => break Err(error),
1328                    Some(Ok(frame)) => frame,
1329                };
1330
1331                match frame.header.ty {
1332                    FrameType::Ping if frame.header.channel == 0 => {
1333                        let pong = match Frame::build_with_version(
1334                            frame.header.ver,
1335                            FrameType::Pong,
1336                            frame.header.flags,
1337                            0,
1338                            frame.header.corr,
1339                            Vec::new(),
1340                        ) {
1341                            Ok(pong) => pong,
1342                            Err(error) => break Err(SubcError::FrameBuild(error)),
1343                        };
1344                        if let Err(error) = send_frame(&writer_tx, pong).await {
1345                            break Err(error);
1346                        }
1347                    }
1348                    FrameType::Goodbye if frame.header.channel == 0 => {
1349                        log::info!("subc attach: received channel-0 Goodbye");
1350                        break Ok(());
1351                    }
1352                    FrameType::Goodbye => {
1353                        let channel = route_key(frame.header.channel);
1354                        end_bg_subscription(
1355                            &writer_tx,
1356                            &mut bg_subs,
1357                            &mut bg_sub_by_session,
1358                            &mut bg_wake_pending,
1359                            channel,
1360                            routes.get(&channel),
1361                        );
1362                        if let Some(cancel) = route_bash_cancels.remove(&channel) {
1363                            cancel.token.cancel();
1364                        }
1365                        if let Some(pending) = pending_binds.get_mut(&channel) {
1366                            pending.cancelled = true;
1367                            log::debug!(
1368                                "subc attach: cancelled pending RouteBind for route {} on Goodbye",
1369                                frame.header.channel
1370                            );
1371                        }
1372                        let migrated = migrate_retry_buffer_to_push_buffer(
1373                            &mut retry_buffer,
1374                            channel,
1375                            &mut push_buffer,
1376                        );
1377                        if let Some(identity) = remove_route_channel(&mut routes, &mut root_channels, channel) {
1378                            if migrated > 0 {
1379                                log::debug!(
1380                                    "subc attach: migrated {migrated} retry-buffered reliable Push frame(s) from route {} into detach replay",
1381                                    frame.header.channel
1382                                );
1383                            }
1384                            if let Some(meta) = live_roots.get_mut(&identity.root) {
1385                                let idle_for = meta.last_touched.elapsed();
1386                                meta.touch();
1387                                log::debug!(
1388                                    "subc attach: route {} torn down for root {} harness {} session {} (last touched {:?} ago)",
1389                                    frame.header.channel,
1390                                    identity.root.as_path().display(),
1391                                    identity.harness,
1392                                    identity.session,
1393                                    idle_for
1394                                );
1395                            } else {
1396                                log::debug!(
1397                                    "subc attach: route {} torn down for root {} harness {} session {}",
1398                                    frame.header.channel,
1399                                    identity.root.as_path().display(),
1400                                    identity.harness,
1401                                    identity.session
1402                                );
1403                            }
1404                        } else {
1405                            if migrated > 0 {
1406                                log::debug!(
1407                                    "subc attach: migrated {migrated} retry-buffered reliable Push frame(s) from unbound route {} into detach replay",
1408                                    frame.header.channel
1409                                );
1410                            }
1411                            log::debug!("subc attach: unbound route {} torn down", frame.header.channel);
1412                        }
1413                    }
1414                    FrameType::Request if frame.header.channel == 0 => {
1415                        if let Err(error) = handle_control_request(
1416                            &writer_tx,
1417                            &frame,
1418                            &shared_app,
1419                            &executor,
1420                            &mut live_roots,
1421                            &mut pending_binds,
1422                            &control_completion_tx,
1423                            &push_senders,
1424                            dispatch,
1425                            user_config_path.as_deref(),
1426                        )
1427                        .await
1428                        {
1429                            break Err(error);
1430                        }
1431                    }
1432                    FrameType::Request => {
1433                        if let Err(error) = handle_tool_call(
1434                            &writer_tx,
1435                            &frame,
1436                            &routes,
1437                            &pending_binds,
1438                            &mut live_roots,
1439                            &executor,
1440                            &shutdown,
1441                            &connection_cancel,
1442                            &bash_deferred_tx,
1443                            &bash_poll_touch_tx,
1444                            &mut route_bash_cancels,
1445                            &mut bg_subs,
1446                            &mut bg_sub_by_session,
1447                            &mut bg_wake_pending,
1448                            &mut bg_wake_epoch,
1449                            dispatch,
1450                            allow_native_passthrough,
1451                        )
1452                        .await
1453                        {
1454                            break Err(error);
1455                        }
1456                    }
1457                    FrameType::Cancel => {
1458                        let channel = route_key(frame.header.channel);
1459                        if bg_subs.contains_key(&channel) {
1460                            end_bg_subscription(
1461                                &writer_tx,
1462                                &mut bg_subs,
1463                                &mut bg_sub_by_session,
1464                                &mut bg_wake_pending,
1465                                channel,
1466                                routes.get(&channel),
1467                            );
1468                        }
1469                    }
1470                    // Push/etc. are not handled on ingress. In-flight tool-call
1471                    // cancellation is not implemented, so non-bg_events Cancels
1472                    // and unrelated frame types are ignored rather than acted on.
1473                    _ => {}
1474                }
1475            }
1476            Some((root_id, frame)) = reliable_rx.recv() => {
1477                // Drain reliable frames in FIFO order. They are intentionally not
1478                // coalesced: completion, pattern-match, and warning frames are
1479                // must-deliver events.
1480                let mut batch = vec![(root_id, frame)];
1481                while let Ok(item) = reliable_rx.try_recv() {
1482                    batch.push(item);
1483                }
1484
1485                for (root, frame) in batch {
1486                    if let Some((root, session)) = process_reliable_push_frame(
1487                        &writer_tx,
1488                        &routes,
1489                        &root_channels,
1490                        &session_identity,
1491                        &mut retry_buffer,
1492                        &mut push_buffer,
1493                        &mut completed_tasks,
1494                        root,
1495                        frame,
1496                    ) {
1497                        if let Some(channel) = bg_sub_by_session
1498                            .get(&(root.clone(), session.clone()))
1499                            .copied()
1500                        {
1501                            arm_bg_wake(
1502                                root,
1503                                session,
1504                                channel,
1505                                &mut bg_wake_pending,
1506                                &mut bg_wake_epoch,
1507                            );
1508                        }
1509                    }
1510                }
1511            }
1512            Some((root_id, frame)) = lossy_rx.recv() => {
1513                // If both lanes are ready, process any already-queued reliable
1514                // completions first so a following stale BashLongRunning frame can
1515                // be suppressed even if select! happened to wake on the lossy lane.
1516                while let Ok((reliable_root, reliable_frame)) = reliable_rx.try_recv() {
1517                    if let Some((root, session)) = process_reliable_push_frame(
1518                        &writer_tx,
1519                        &routes,
1520                        &root_channels,
1521                        &session_identity,
1522                        &mut retry_buffer,
1523                        &mut push_buffer,
1524                        &mut completed_tasks,
1525                        reliable_root,
1526                        reliable_frame,
1527                    ) {
1528                        if let Some(channel) = bg_sub_by_session
1529                            .get(&(root.clone(), session.clone()))
1530                            .copied()
1531                        {
1532                            arm_bg_wake(
1533                                root,
1534                                session,
1535                                channel,
1536                                &mut bg_wake_pending,
1537                                &mut bg_wake_epoch,
1538                            );
1539                        }
1540                    }
1541                }
1542
1543                // Drain the currently queued burst in one loop turn so lossy
1544                // status/progress classes coalesce before reaching subc's shared
1545                // egress queue.
1546                let mut batch = vec![(root_id, frame)];
1547                while let Ok(item) = lossy_rx.try_recv() {
1548                    batch.push(item);
1549                }
1550
1551                for (root, frame) in coalesce_push_batch(batch) {
1552                    process_lossy_push_frame(
1553                        &writer_tx,
1554                        &routes,
1555                        &root_channels,
1556                        &completed_tasks,
1557                        root,
1558                        frame,
1559                    );
1560                }
1561            }
1562            Some(completion) = control_completion_rx.recv() => {
1563                if let Err(error) = handle_route_bind_completion(
1564                    &writer_tx,
1565                    completion,
1566                    &mut routes,
1567                    &mut root_channels,
1568                    &mut session_identity,
1569                    &mut push_buffer,
1570                    &mut live_roots,
1571                    &mut pending_binds,
1572                    &executor,
1573                    &shutdown,
1574                )
1575                .await
1576                {
1577                    break Err(error);
1578                }
1579            }
1580            Some(done) = bash_deferred_rx.recv() => {
1581                if let Err(error) = handle_bash_deferred_completion(
1582                    &writer_tx,
1583                    done,
1584                    &routes,
1585                    &mut live_roots,
1586                    &mut route_bash_cancels,
1587                    &shutdown,
1588                )
1589                .await
1590                {
1591                    break Err(error);
1592                }
1593            }
1594            Some(root_id) = bash_poll_touch_rx.recv() => {
1595                if let Some(meta) = live_roots.get_mut(&root_id) {
1596                    meta.touch();
1597                }
1598            }
1599            Some(completion) = maintenance_rx.recv() => {
1600                let root_id = completion.root_id;
1601                let response = completion.response;
1602                if let Some(meta) = live_roots.get_mut(&root_id) {
1603                    meta.maintenance_pending = false;
1604                }
1605                clear_stale_bg_wakes_for_empty_sessions(
1606                    &root_id,
1607                    &completion.empty_bg_sessions,
1608                    &bg_sub_by_session,
1609                    &mut bg_wake_pending,
1610                    &bg_wake_epoch,
1611                );
1612                if response_is_fatal_panic(&response) {
1613                    signal_fatal_teardown(&writer_tx, None, PROTOCOL_VERSION, 0, &shutdown).await;
1614                }
1615            }
1616            _ = drain_interval.tick() => {
1617                emit_bg_event_wakes(&writer_tx, &bg_subs, &mut bg_wake_pending);
1618
1619                let retried = drain_retry_buffers_for_bound_routes(
1620                    &writer_tx,
1621                    &routes,
1622                    &mut retry_buffer,
1623                );
1624                if retried > 0 {
1625                    log::debug!(
1626                        "subc attach: retried {retried} reliable Push frame(s) after writer backpressure"
1627                    );
1628                }
1629
1630                let due_roots: Vec<ProjectRootId> = live_roots
1631                    .iter_mut()
1632                    .filter_map(|(root_id, meta)| {
1633                        if meta.maintenance_pending {
1634                            None
1635                        } else {
1636                            meta.maintenance_pending = true;
1637                            Some(root_id.clone())
1638                        }
1639                    })
1640                    .collect();
1641                for root_id in due_roots {
1642                    let bg_sessions_to_check: Vec<(String, u64)> = bg_sub_by_session
1643                        .iter()
1644                        .filter_map(|((root, session), _)| {
1645                            if root == &root_id {
1646                                Some((
1647                                    session.clone(),
1648                                    bg_wake_epoch
1649                                        .get(&(root_id.clone(), session.clone()))
1650                                        .copied()
1651                                        .unwrap_or(0),
1652                                ))
1653                            } else {
1654                                None
1655                            }
1656                        })
1657                        .collect();
1658                    submit_maintenance_drain(
1659                        &executor,
1660                        root_id,
1661                        bg_sessions_to_check,
1662                        &maintenance_tx,
1663                    );
1664                }
1665            }
1666        }
1667    };
1668
1669    // The reader task may be parked on `read_frame`; abort it (we are done with
1670    // the connection) and flush the writer.
1671    connection_cancel.cancel();
1672    reader_task.abort();
1673    drop(writer_tx);
1674    let writer_result = finish_writer_task(writer_task).await;
1675    loop_result.and(writer_result)
1676}
1677
1678fn spawn_writer_task<W>(
1679    mut write: W,
1680    mut rx: mpsc::Receiver<Frame>,
1681) -> JoinHandle<Result<(), subc_transport::FrameIoError>>
1682where
1683    W: AsyncWrite + Unpin + Send + 'static,
1684{
1685    tokio::spawn(async move {
1686        while let Some(frame) = rx.recv().await {
1687            write_frame(&mut write, &frame).await?;
1688        }
1689        Ok(())
1690    })
1691}
1692
1693/// Owns the read half and reads whole frames sequentially. `read_frame` is not
1694/// cancellation-safe, so it must run here — never inside the main loop's
1695/// `select!` — to keep the inbound stream framed. Each frame (or the terminal
1696/// error / EOF) is forwarded over `tx`; the loop consumes them via cancel-safe
1697/// `recv()`. Exits on EOF (Ok(None)), a read error, or when `tx` is dropped
1698/// (the loop ended and aborted us).
1699fn spawn_reader_task<R>(mut read: R, tx: mpsc::Sender<Result<Frame, SubcError>>) -> JoinHandle<()>
1700where
1701    R: AsyncRead + Unpin + Send + 'static,
1702{
1703    tokio::spawn(async move {
1704        loop {
1705            match read_frame(&mut read).await {
1706                Ok(Some(frame)) => {
1707                    if tx.send(Ok(frame)).await.is_err() {
1708                        return;
1709                    }
1710                }
1711                Ok(None) => {
1712                    // EOF: let the loop observe channel close as "daemon closed".
1713                    return;
1714                }
1715                Err(error) => {
1716                    let _ = tx.send(Err(SubcError::FrameIo(error))).await;
1717                    return;
1718                }
1719            }
1720        }
1721    })
1722}
1723
1724async fn finish_writer_task(
1725    mut writer_task: JoinHandle<Result<(), subc_transport::FrameIoError>>,
1726) -> Result<(), SubcError> {
1727    match tokio::time::timeout(Duration::from_millis(100), &mut writer_task).await {
1728        Ok(Ok(Ok(()))) => Ok(()),
1729        Ok(Ok(Err(error))) => Err(SubcError::FrameIo(error)),
1730        Ok(Err(error)) => Err(SubcError::WriterJoin(error)),
1731        Err(_) => {
1732            writer_task.abort();
1733            Ok(())
1734        }
1735    }
1736}
1737
1738async fn send_frame(tx: &mpsc::Sender<Frame>, frame: Frame) -> Result<(), SubcError> {
1739    match tokio::time::timeout(CONTROL_SEND_TIMEOUT, tx.send(frame)).await {
1740        Ok(Ok(())) => Ok(()),
1741        Ok(Err(_)) => Err(SubcError::WriterClosed),
1742        Err(_) => Err(SubcError::WriterBackpressureTimeout),
1743    }
1744}
1745
1746fn rollback_pending_bind_actor(
1747    executor: &Arc<Executor>,
1748    live_roots: &HashMap<ProjectRootId, RootMeta>,
1749    root_id: &ProjectRootId,
1750    inserted_new_actor: bool,
1751) {
1752    if inserted_new_actor && !live_roots.contains_key(root_id) {
1753        executor.remove_actor(root_id);
1754    }
1755}
1756
1757async fn handle_route_bind_completion(
1758    tx: &mpsc::Sender<Frame>,
1759    completion: RouteBindCompletion,
1760    routes: &mut HashMap<RouteChannel, RouteIdentity>,
1761    root_channels: &mut HashMap<ProjectRootId, HashSet<RouteChannel>>,
1762    session_identity: &mut HashMap<(ProjectRootId, String), RetainedSessionIdentity>,
1763    push_buffer: &mut HashMap<ReplayKey, VecDeque<PushFrame>>,
1764    live_roots: &mut HashMap<ProjectRootId, RootMeta>,
1765    pending_binds: &mut HashMap<RouteChannel, PendingBind>,
1766    executor: &Arc<Executor>,
1767    shutdown: &Arc<Notify>,
1768) -> Result<(), SubcError> {
1769    let route_id = route_key(completion.route_channel);
1770    let Some(pending) = pending_binds.remove(&route_id) else {
1771        log::warn!(
1772            "subc attach: dropping RouteBind completion for non-pending route {}",
1773            completion.route_channel
1774        );
1775        rollback_pending_bind_actor(
1776            executor,
1777            live_roots,
1778            &completion.bind_root_id,
1779            completion.inserted_new_actor,
1780        );
1781        return Ok(());
1782    };
1783
1784    if pending.bind_root_id != completion.bind_root_id {
1785        log::warn!(
1786            "subc attach: pending RouteBind root mismatch for route {} (pending {} completion {})",
1787            completion.route_channel,
1788            pending.bind_root_id.as_path().display(),
1789            completion.bind_root_id.as_path().display()
1790        );
1791    }
1792
1793    let inserted_new_actor = pending.inserted_new_actor || completion.inserted_new_actor;
1794    if pending.cancelled {
1795        rollback_pending_bind_actor(
1796            executor,
1797            live_roots,
1798            &completion.bind_root_id,
1799            inserted_new_actor,
1800        );
1801        log::debug!(
1802            "subc attach: discarded completed RouteBind for cancelled route {} root {}",
1803            completion.route_channel,
1804            completion.bind_root_id.as_path().display()
1805        );
1806        return Ok(());
1807    }
1808
1809    let failure = if !completion.configure_response.success {
1810        Some((
1811            &completion.configure_response,
1812            "configure failed during route bind",
1813        ))
1814    } else if let Some(drain_response) = completion.drain_response.as_ref() {
1815        if drain_response.success {
1816            None
1817        } else {
1818            Some((
1819                drain_response,
1820                "build-completion drain failed during route bind",
1821            ))
1822        }
1823    } else {
1824        None
1825    };
1826
1827    if let Some((response, fallback)) = failure {
1828        rollback_pending_bind_actor(
1829            executor,
1830            live_roots,
1831            &completion.bind_root_id,
1832            inserted_new_actor,
1833        );
1834        let message = response_message(response, fallback);
1835        let fatal = response_is_fatal_panic(response);
1836        send_route_bind_error_parts(
1837            tx,
1838            completion.ver,
1839            completion.corr,
1840            completion.flags,
1841            "config_divergence",
1842            &message,
1843        )
1844        .await?;
1845        if fatal {
1846            signal_fatal_teardown(
1847                tx,
1848                Some(completion.route_channel),
1849                completion.ver,
1850                completion.corr,
1851                shutdown,
1852            )
1853            .await;
1854        }
1855        return Ok(());
1856    }
1857
1858    remember_session_identity(session_identity, &completion.identity);
1859    let replay_key = ReplayKey::from_identity(&completion.identity);
1860    let bind_trust = completion.identity.trust;
1861    insert_route_channel(routes, root_channels, route_id, completion.identity);
1862    live_roots
1863        .entry(completion.bind_root_id.clone())
1864        .and_modify(|meta| {
1865            meta.touch();
1866            meta.diagnostics_on_edit = completion.diagnostics_on_edit;
1867        })
1868        .or_insert_with(|| RootMeta::new(Instant::now()));
1869    if let Some(meta) = live_roots.get_mut(&completion.bind_root_id) {
1870        meta.diagnostics_on_edit = completion.diagnostics_on_edit;
1871    }
1872
1873    let ack =
1874        serde_json::to_vec(&ModuleControlResponse::RouteBindAck {}).map_err(SubcError::Json)?;
1875    let response = Frame::build_with_version(
1876        completion.ver,
1877        FrameType::Response,
1878        control_flags(),
1879        0,
1880        completion.corr,
1881        ack,
1882    )
1883    .map_err(SubcError::FrameBuild)?;
1884    send_frame(tx, response).await?;
1885    let replayed = replay_buffered_push_frames(tx, route_id, push_buffer, &replay_key, bind_trust);
1886    if replayed > 0 {
1887        log::debug!(
1888            "subc attach: replayed {} buffered Push frame(s) to route {} root {} harness {} session {}",
1889            replayed,
1890            completion.route_channel,
1891            replay_key.root.as_path().display(),
1892            replay_key.harness,
1893            replay_key.session
1894        );
1895    }
1896    log::info!(
1897        "subc attach: route {} bound to root {}",
1898        completion.route_channel,
1899        completion.bind_root_id.as_path().display()
1900    );
1901    Ok(())
1902}
1903
1904/// channel-0 control request — currently only RouteBind. Reconciles the route's
1905/// RootConfig through the executor's Mutating lane and resolves completion on a
1906/// loop-owned control-completion channel so slow configure jobs do not block the
1907/// transport loop.
1908async fn handle_control_request(
1909    tx: &mpsc::Sender<Frame>,
1910    frame: &Frame,
1911    shared_app: &Arc<App>,
1912    executor: &Arc<Executor>,
1913    live_roots: &mut HashMap<ProjectRootId, RootMeta>,
1914    pending_binds: &mut HashMap<RouteChannel, PendingBind>,
1915    control_completion_tx: &mpsc::Sender<RouteBindCompletion>,
1916    push_senders: &PushSenders,
1917    dispatch: DispatchFn,
1918    user_config_path: Option<&Path>,
1919) -> Result<(), SubcError> {
1920    let request =
1921        serde_json::from_slice::<ModuleControlRequest>(&frame.body).map_err(SubcError::Json)?;
1922    match request {
1923        ModuleControlRequest::RouteBind {
1924            route_channel,
1925            target: _,
1926            identity,
1927            principal,
1928        } => {
1929            let route_id = route_key(route_channel);
1930            if pending_binds.contains_key(&route_id) {
1931                return send_route_bind_error(
1932                    tx,
1933                    frame,
1934                    "config_divergence",
1935                    "route bind is already pending for channel",
1936                )
1937                .await;
1938            }
1939
1940            let bind_root_id = match ProjectRootId::from_path(&identity.project_root) {
1941                Ok(root_id) => root_id,
1942                Err(error) => {
1943                    return send_route_bind_error(
1944                        tx,
1945                        frame,
1946                        "config_divergence",
1947                        &format!("invalid route project root: {error}"),
1948                    )
1949                    .await;
1950                }
1951            };
1952
1953            // Reconcile RootConfig: build a configure request from the bind
1954            // identity + forwarded config tiers and run it through the executor.
1955            let request_id = format!("subc-bind-{route_channel}");
1956            let bind_project_root = identity.project_root.clone();
1957            let bind_harness = identity.harness.clone();
1958            let bind_session = identity.session.clone();
1959            let bind_trust = trust_for_principal(&principal);
1960            log::info!(
1961                "subc attach: route {} principal={} trust={}",
1962                route_channel,
1963                principal_label(&principal),
1964                bind_trust.label()
1965            );
1966
1967            // Config is single-per-project, read by AFT directly from the
1968            // CortexKit config files (user: ~/.config/cortexkit/aft.jsonc,
1969            // project: <root>/.cortexkit/aft.jsonc). Wire-relayed config tiers are
1970            // IGNORED entirely: a front (runner or mcp:*) cannot push config over
1971            // the wire. This is what makes config harness-INDEPENDENT — every
1972            // harness binding a project gets the identical on-disk config, so two
1973            // trust domains sharing the per-root actor can never diverge or
1974            // inherit each other's capabilities (the cross-bind escalation class).
1975            // Wire-relayed config tiers (if the protocol still carries them) are
1976            // ignored entirely; the per-tier trust boundary (user trusted, project
1977            // privileged-dropped) is applied to the FILE tiers in handle_configure.
1978            let local_tiers = crate::subc_config::read_local_cortexkit_config_tiers(
1979                user_config_path,
1980                Path::new(&bind_project_root),
1981            );
1982            let config_tiers: Vec<Value> = local_tiers
1983                .iter()
1984                .map(|t| json!({ "tier": t.tier, "source": t.source, "doc": t.doc }))
1985                .collect();
1986            let diagnostics_on_edit = diagnostics_on_edit_from_tiers(&local_tiers);
1987            let configure_json = json!({
1988                "id": request_id,
1989                "command": "configure",
1990                "project_root": bind_project_root,
1991                "harness": bind_harness,
1992                "session_id": bind_session.clone(),
1993                "config": config_tiers,
1994            });
1995            let configure_req = match serde_json::from_value::<RawRequest>(configure_json) {
1996                Ok(req) => req,
1997                Err(error) => {
1998                    return send_route_bind_error(
1999                        tx,
2000                        frame,
2001                        "config_divergence",
2002                        &format!("failed to build configure request: {error}"),
2003                    )
2004                    .await;
2005                }
2006            };
2007
2008            let route_identity = RouteIdentity {
2009                root: bind_root_id.clone(),
2010                project_root: PathBuf::from(&bind_project_root),
2011                harness: bind_harness.clone(),
2012                session: bind_session.clone(),
2013                trust: bind_trust,
2014            };
2015            let configure_session = route_identity.session.clone();
2016            let root_was_live = live_roots.contains_key(&bind_root_id);
2017            let inserted_new_actor = if root_was_live {
2018                log::debug!(
2019                    "subc attach: reusing actor for route {} root {}",
2020                    route_channel,
2021                    bind_root_id.as_path().display()
2022                );
2023                false
2024            } else {
2025                let actor_ctx = Arc::new(AppContext::from_app(
2026                    Arc::clone(shared_app),
2027                    Config::default(),
2028                ));
2029                install_bash_compressor(&actor_ctx);
2030                actor_ctx.set_progress_sender(Some(progress_sender_for_root(
2031                    push_senders.clone(),
2032                    bind_root_id.clone(),
2033                )));
2034                let inserted =
2035                    executor.register_actor(bind_root_id.clone(), Arc::clone(&actor_ctx));
2036                drop(actor_ctx);
2037                // Do not insert into live_roots until configure succeeds: live_roots
2038                // drives maintenance, and a half-configured new actor must not be
2039                // maintenance-eligible before its route/session identity exists.
2040                log::debug!(
2041                    "subc attach: registered actor for route {} root {}",
2042                    route_channel,
2043                    bind_root_id.as_path().display()
2044                );
2045                inserted
2046            };
2047
2048            pending_binds.insert(
2049                route_id,
2050                PendingBind {
2051                    bind_root_id: bind_root_id.clone(),
2052                    inserted_new_actor,
2053                    cancelled: false,
2054                },
2055            );
2056
2057            let configure_request_id = configure_req.id.clone();
2058            let configure_rx = executor.submit_async(
2059                bind_root_id.clone(),
2060                Lane::Mutating,
2061                configure_request_id.clone(),
2062                Box::new(move |ctx| {
2063                    log_ctx::with_session(Some(configure_session.clone()), || {
2064                        dispatch(configure_req, ctx)
2065                    })
2066                }),
2067            );
2068
2069            let completion_tx = control_completion_tx.clone();
2070            let completion_executor = Arc::clone(executor);
2071            let completion_identity = route_identity;
2072            let completion_root = bind_root_id.clone();
2073            let completion_route_channel = route_channel;
2074            let completion_ver = frame.header.ver;
2075            let completion_corr = frame.header.corr;
2076            let completion_flags = frame.header.flags;
2077            tokio::spawn(async move {
2078                let configure_response =
2079                    await_executor_response(configure_rx, configure_request_id.clone()).await;
2080                let drain_response = if configure_response.success && !root_was_live {
2081                    let drain_request_id = format!("subc-bind-drain-{completion_route_channel}");
2082                    let drain_response_id = drain_request_id.clone();
2083                    let drain_rx = completion_executor.submit_async(
2084                        completion_root.clone(),
2085                        Lane::Mutating,
2086                        drain_request_id.clone(),
2087                        Box::new(move |ctx| {
2088                            runtime_drain::drain_build_completions(ctx);
2089                            Response::success(drain_response_id, json!({ "drained": true }))
2090                        }),
2091                    );
2092                    Some(await_executor_response(drain_rx, drain_request_id).await)
2093                } else {
2094                    None
2095                };
2096
2097                let completion = RouteBindCompletion {
2098                    route_channel: completion_route_channel,
2099                    identity: completion_identity,
2100                    bind_root_id: completion_root,
2101                    inserted_new_actor,
2102                    configure_response,
2103                    drain_response,
2104                    diagnostics_on_edit,
2105                    ver: completion_ver,
2106                    corr: completion_corr,
2107                    flags: completion_flags,
2108                };
2109                if completion_tx.send(completion).await.is_err() {
2110                    log::debug!(
2111                        "subc attach: dropped RouteBind completion for route {} after loop exit",
2112                        completion_route_channel
2113                    );
2114                }
2115            });
2116
2117            Ok(())
2118        }
2119    }
2120}
2121
2122fn install_bash_compressor(ctx: &AppContext) {
2123    // Mirrors main.rs per-actor compressor installation for subc-created actors.
2124    let filter_registry_handle = ctx.shared_filter_registry();
2125    let compress_flag = ctx.bash_compress_flag();
2126    ctx.bash_background().set_compressor_with_exit_code(
2127        move |command: &str, output: String, exit_code: Option<i32>| {
2128            if !compress_flag.load(std::sync::atomic::Ordering::Relaxed) {
2129                return crate::compress::CompressionResult::new(output);
2130            }
2131            let registry_guard = match filter_registry_handle.read() {
2132                Ok(g) => g,
2133                Err(poisoned) => poisoned.into_inner(),
2134            };
2135            crate::compress::compress_with_registry_exit_code(
2136                command,
2137                &output,
2138                exit_code,
2139                &registry_guard,
2140            )
2141        },
2142    );
2143}
2144
2145fn diagnostics_on_edit_from_tiers(tiers: &[ConfigTier]) -> bool {
2146    let mut diagnostics_on_edit = false;
2147    for tier in tiers {
2148        if let Some(value) = diagnostics_on_edit_from_doc(&tier.doc) {
2149            diagnostics_on_edit = value;
2150        }
2151    }
2152    diagnostics_on_edit
2153}
2154
2155fn diagnostics_on_edit_from_doc(doc: &str) -> Option<bool> {
2156    let stripped = strip_jsonc_for_subc(doc);
2157    let value = serde_json::from_str::<Value>(&stripped).ok()?;
2158    value
2159        .get("lsp")
2160        .and_then(Value::as_object)?
2161        .get("diagnostics_on_edit")
2162        .and_then(Value::as_bool)
2163}
2164
2165fn strip_jsonc_for_subc(source: &str) -> String {
2166    strip_trailing_commas_for_subc(&strip_jsonc_comments_for_subc(source))
2167}
2168
2169fn strip_jsonc_comments_for_subc(source: &str) -> String {
2170    let mut output = String::with_capacity(source.len());
2171    let mut chars = source.chars().peekable();
2172    let mut in_string = false;
2173    let mut escaped = false;
2174
2175    while let Some(ch) = chars.next() {
2176        if in_string {
2177            output.push(ch);
2178            if escaped {
2179                escaped = false;
2180            } else if ch == '\\' {
2181                escaped = true;
2182            } else if ch == '"' {
2183                in_string = false;
2184            }
2185            continue;
2186        }
2187
2188        if ch == '"' {
2189            in_string = true;
2190            output.push(ch);
2191            continue;
2192        }
2193
2194        if ch == '/' {
2195            match chars.peek().copied() {
2196                Some('/') => {
2197                    chars.next();
2198                    for next in chars.by_ref() {
2199                        if next == '\n' {
2200                            output.push('\n');
2201                            break;
2202                        }
2203                    }
2204                }
2205                Some('*') => {
2206                    chars.next();
2207                    let mut previous = '\0';
2208                    for next in chars.by_ref() {
2209                        if next == '\n' {
2210                            output.push('\n');
2211                        }
2212                        if previous == '*' && next == '/' {
2213                            break;
2214                        }
2215                        previous = next;
2216                    }
2217                }
2218                _ => output.push(ch),
2219            }
2220            continue;
2221        }
2222
2223        output.push(ch);
2224    }
2225
2226    output
2227}
2228
2229fn strip_trailing_commas_for_subc(source: &str) -> String {
2230    let chars = source.chars().collect::<Vec<_>>();
2231    let mut output = String::with_capacity(source.len());
2232    let mut index = 0usize;
2233    let mut in_string = false;
2234    let mut escaped = false;
2235
2236    while index < chars.len() {
2237        let ch = chars[index];
2238        if in_string {
2239            output.push(ch);
2240            if escaped {
2241                escaped = false;
2242            } else if ch == '\\' {
2243                escaped = true;
2244            } else if ch == '"' {
2245                in_string = false;
2246            }
2247            index += 1;
2248            continue;
2249        }
2250
2251        if ch == '"' {
2252            in_string = true;
2253            output.push(ch);
2254            index += 1;
2255            continue;
2256        }
2257
2258        if ch == ',' {
2259            let mut next = index + 1;
2260            while next < chars.len() && chars[next].is_whitespace() {
2261                next += 1;
2262            }
2263            if next < chars.len() && matches!(chars[next], '}' | ']') {
2264                index += 1;
2265                continue;
2266            }
2267        }
2268
2269        output.push(ch);
2270        index += 1;
2271    }
2272
2273    output
2274}
2275
2276async fn send_route_bind_error(
2277    tx: &mpsc::Sender<Frame>,
2278    frame: &Frame,
2279    code: &str,
2280    message: &str,
2281) -> Result<(), SubcError> {
2282    send_route_bind_error_parts(
2283        tx,
2284        frame.header.ver,
2285        frame.header.corr,
2286        frame.header.flags,
2287        code,
2288        message,
2289    )
2290    .await
2291}
2292
2293async fn send_route_bind_error_parts(
2294    tx: &mpsc::Sender<Frame>,
2295    ver: u8,
2296    corr: u64,
2297    flags: Flags,
2298    code: &str,
2299    message: &str,
2300) -> Result<(), SubcError> {
2301    let response = build_error_frame(ver, 0, corr, flags, code, message)?;
2302    send_frame(tx, response).await?;
2303    log::warn!("subc attach: route bind rejected ({code}): {message}");
2304    Ok(())
2305}
2306
2307/// Route-channel tool call: `{name, arguments}` → executor lane → dispatch to
2308/// the sync command core → wrap the structured Response in a CallToolResult
2309/// `{content, isError}`. v1 mapping: the whole `{success, ...}` Response
2310/// serialized into ONE text block; `isError` carries `success == false`.
2311async fn handle_tool_call(
2312    tx: &mpsc::Sender<Frame>,
2313    frame: &Frame,
2314    routes: &HashMap<RouteChannel, RouteIdentity>,
2315    pending_binds: &HashMap<RouteChannel, PendingBind>,
2316    live_roots: &mut HashMap<ProjectRootId, RootMeta>,
2317    executor: &Arc<Executor>,
2318    shutdown: &Arc<Notify>,
2319    connection_cancel: &PersistentCancelSignal,
2320    bash_deferred_tx: &mpsc::Sender<BashDeferredCompletion>,
2321    bash_poll_touch_tx: &mpsc::Sender<ProjectRootId>,
2322    route_bash_cancels: &mut HashMap<RouteChannel, RouteBashCancel>,
2323    bg_subs: &mut HashMap<RouteChannel, BgSub>,
2324    bg_sub_by_session: &mut HashMap<(ProjectRootId, String), RouteChannel>,
2325    bg_wake_pending: &mut HashSet<RouteChannel>,
2326    bg_wake_epoch: &mut HashMap<(ProjectRootId, String), u64>,
2327    dispatch: DispatchFn,
2328    allow_native_passthrough: bool,
2329) -> Result<(), SubcError> {
2330    let route_id = route_key(frame.header.channel);
2331    if pending_binds.contains_key(&route_id) {
2332        let error = build_error_frame(
2333            frame.header.ver,
2334            frame.header.channel,
2335            frame.header.corr,
2336            frame.header.flags,
2337            "route_not_bound",
2338            "route is not bound before tool call",
2339        )?;
2340        return send_frame(tx, error).await;
2341    }
2342
2343    let Some(identity) = routes.get(&route_id).cloned() else {
2344        let error = build_error_frame(
2345            frame.header.ver,
2346            frame.header.channel,
2347            frame.header.corr,
2348            frame.header.flags,
2349            "route_not_bound",
2350            "route is not bound before tool call",
2351        )?;
2352        return send_frame(tx, error).await;
2353    };
2354    if let Some(meta) = live_roots.get_mut(&identity.root) {
2355        meta.touch();
2356    }
2357
2358    let is_bg_events_subscribe = serde_json::from_slice::<BgEventsProbe>(&frame.body)
2359        .ok()
2360        .and_then(|probe| probe.op)
2361        .as_deref()
2362        == Some("bg_events");
2363    if is_bg_events_subscribe {
2364        if let Some(old_sub) = bg_subs.get(&route_id).copied() {
2365            let _ = try_send_bg_stream_end(tx, route_id, &old_sub);
2366        }
2367        if !identity.trust.allows_bash_observation() {
2368            bg_subs.remove(&route_id);
2369            bg_wake_pending.remove(&route_id);
2370            remove_bg_subscription_index(bg_sub_by_session, route_id, Some(&identity));
2371            return Ok(());
2372        }
2373        bg_subs.insert(
2374            route_id,
2375            BgSub {
2376                corr: frame.header.corr,
2377                ver: frame.header.ver,
2378                flags: frame.header.flags,
2379            },
2380        );
2381        bg_sub_by_session.insert((identity.root.clone(), identity.session.clone()), route_id);
2382        arm_bg_wake(
2383            identity.root,
2384            identity.session,
2385            route_id,
2386            bg_wake_pending,
2387            bg_wake_epoch,
2388        );
2389        return Ok(());
2390    }
2391
2392    let call = serde_json::from_slice::<ToolCallRequest>(&frame.body).map_err(SubcError::Json)?;
2393    let bare_name = call.name.clone();
2394    let format_context = crate::subc_format::FormatContext::from_tool_call(
2395        &bare_name,
2396        &call.arguments,
2397        identity.project_root.as_path(),
2398    );
2399
2400    let request_id = format!("subc-{}-{}", frame.header.channel, frame.header.corr);
2401    let bind_trust = identity.trust;
2402    let diagnostics_on_edit = live_roots
2403        .get(&identity.root)
2404        .map(|meta| meta.diagnostics_on_edit)
2405        .unwrap_or(false);
2406
2407    if matches!(bind_trust, BindTrust::Untrusted) && is_bash_family_tool(&bare_name) {
2408        let response = bash_denied_untrusted_response(request_id.clone());
2409        let text = crate::subc_format::format_response_with_context(
2410            &bare_name,
2411            &response,
2412            &format_context,
2413        );
2414        let result = ToolCallResult { text, response };
2415        let response_frame = build_tool_response_frame(
2416            frame.header.ver,
2417            frame.header.channel,
2418            frame.header.corr,
2419            frame.header.flags,
2420            &result,
2421        )?;
2422        return send_frame(tx, response_frame).await;
2423    }
2424
2425    // A non-core name is NOT in the tool manifest. AFT fails closed and
2426    // does not trust subc to enforce the manifest: rejecting here is the
2427    // defense-in-depth backstop that prevents a forwarded native command
2428    // (e.g. `configure`, which would reach handle_configure and bypass
2429    // the RouteBind config-trust cap) from ever reaching dispatch. Only
2430    // the integration-test harness (run_subc_mode_for_test) opens this to
2431    // drive synthetic native commands through the executor.
2432    if !is_subc_agent_core_tool(&call.name)
2433        && !is_subc_native_plumbing_tool(&call.name)
2434        && !allow_native_passthrough
2435    {
2436        log::warn!(
2437            "subc tool call: rejecting non-manifest tool name {:?} on route {} (fail-closed)",
2438            call.name,
2439            frame.header.channel
2440        );
2441        let response = Response::error(
2442            request_id.clone(),
2443            "unknown_tool",
2444            format!("tool {:?} is not in the AFT tool manifest", call.name),
2445        );
2446        let text = crate::subc_format::format_response_with_context(
2447            &bare_name,
2448            &response,
2449            &format_context,
2450        );
2451        let result = ToolCallResult { text, response };
2452        let response_frame = build_tool_response_frame(
2453            frame.header.ver,
2454            frame.header.channel,
2455            frame.header.corr,
2456            frame.header.flags,
2457            &result,
2458        )?;
2459        return send_frame(tx, response_frame).await;
2460    }
2461
2462    if bare_name == "bash" {
2463        let meta = live_roots
2464            .entry(identity.root.clone())
2465            .or_insert_with(|| RootMeta::new(Instant::now()));
2466        meta.active_bash_waits = meta.active_bash_waits.saturating_add(1);
2467        meta.touch();
2468
2469        let route_cancel = route_bash_cancels
2470            .entry(route_id)
2471            .or_insert_with(|| RouteBashCancel {
2472                token: PersistentCancelSignal::new(),
2473                active_waits: 0,
2474            });
2475        route_cancel.active_waits = route_cancel.active_waits.saturating_add(1);
2476        let cancel = BashWaitCancel {
2477            connection: connection_cancel.clone(),
2478            route: route_cancel.token.clone(),
2479        };
2480
2481        submit_deferred_bash(
2482            executor,
2483            bash_deferred_tx,
2484            bash_poll_touch_tx,
2485            dispatch,
2486            identity.root,
2487            identity.project_root,
2488            identity.session,
2489            request_id,
2490            frame.header.channel,
2491            frame.header.corr,
2492            frame.header.flags,
2493            frame.header.ver,
2494            call.arguments,
2495            format_context,
2496            cancel,
2497            bind_trust,
2498        );
2499        return Ok(());
2500    }
2501
2502    let lane = command_lane(&bare_name);
2503    let tool_call_context = ToolCallContext {
2504        project_root: identity.project_root.clone(),
2505        session_id: Some(identity.session.clone()),
2506        request_id: request_id.clone(),
2507        diagnostics_on_edit,
2508        preview: false,
2509    };
2510    let arguments_for_run = call.arguments.clone();
2511    let bare_name_for_run = bare_name.clone();
2512    let bare_name_for_frame = bare_name.clone();
2513    let bare_name_for_finalize = bare_name.clone();
2514    let session_for_log = identity.session.clone();
2515    let session_for_finalize = identity.session.clone();
2516    let request_id_for_force = request_id.clone();
2517    let format_context_for_frame = format_context;
2518    let (text_tx, text_rx) = oneshot::channel::<String>();
2519    let rx = executor.submit_async(
2520        identity.root,
2521        lane,
2522        request_id.clone(),
2523        Box::new(move |ctx| {
2524            log_ctx::with_session(Some(session_for_log.clone()), || {
2525                let run = || {
2526                    let dispatch_with_finalize = |raw_req: RawRequest, app_ctx: &AppContext| {
2527                        let mut response = dispatch(raw_req, app_ctx);
2528                        crate::response_finalize::finalize_response_with_bg_completions(
2529                            &mut response,
2530                            app_ctx,
2531                            &session_for_finalize,
2532                            &bare_name_for_finalize,
2533                            bind_trust.allows_bash_observation(),
2534                        );
2535                        response
2536                    };
2537                    match run_tool_call(
2538                        &bare_name_for_run,
2539                        &arguments_for_run,
2540                        &tool_call_context,
2541                        ctx,
2542                        &dispatch_with_finalize,
2543                    ) {
2544                        ToolCallOutcome::Unary(result) => {
2545                            let _ = text_tx.send(result.text);
2546                            result.response
2547                        }
2548                    }
2549                };
2550                if matches!(bind_trust, BindTrust::Untrusted) {
2551                    ctx.with_force_restrict(&request_id_for_force, run)
2552                } else {
2553                    run()
2554                }
2555            })
2556        }),
2557    );
2558    let completion_tx = tx.clone();
2559    let completion_shutdown = Arc::clone(shutdown);
2560    let route_channel = frame.header.channel;
2561    let corr = frame.header.corr;
2562    let flags = frame.header.flags;
2563    let ver = frame.header.ver;
2564    tokio::spawn(async move {
2565        let response = await_executor_response(rx, request_id.clone()).await;
2566        let text = text_rx.await.unwrap_or_else(|_| {
2567            crate::subc_format::format_response_with_context(
2568                &bare_name_for_frame,
2569                &response,
2570                &format_context_for_frame,
2571            )
2572        });
2573        let result = ToolCallResult { text, response };
2574        let fatal = response_is_fatal_panic(&result.response);
2575        match build_tool_response_frame(ver, route_channel, corr, flags, &result) {
2576            Ok(response_frame) => {
2577                let _ = completion_tx.send(response_frame).await;
2578            }
2579            Err(error) => {
2580                log::error!("subc attach: failed to build tool response frame: {error}");
2581            }
2582        }
2583        if fatal {
2584            signal_fatal_teardown(
2585                &completion_tx,
2586                Some(route_channel),
2587                ver,
2588                corr,
2589                &completion_shutdown,
2590            )
2591            .await;
2592        }
2593    });
2594    Ok(())
2595}
2596
2597#[derive(Clone, Copy, Debug, Default)]
2598struct BashTranslatedSettings {
2599    background: bool,
2600    pty: bool,
2601    wait: bool,
2602    block_to_completion: bool,
2603    timeout: Option<u64>,
2604}
2605
2606enum BashSpawnControl {
2607    Immediate,
2608    Foreground {
2609        task_id: String,
2610        session_id: String,
2611        project_root: Option<PathBuf>,
2612        storage_dir: PathBuf,
2613        deadline: Instant,
2614        block_to_completion: bool,
2615        timeout: Option<u64>,
2616        wait_window_ms: u64,
2617    },
2618}
2619
2620enum BashPollControl {
2621    Done,
2622    Promote,
2623    Wait,
2624}
2625
2626fn bash_settings_from_translated(args: &serde_json::Map<String, Value>) -> BashTranslatedSettings {
2627    BashTranslatedSettings {
2628        background: args
2629            .get("background")
2630            .and_then(Value::as_bool)
2631            .unwrap_or(false),
2632        pty: args.get("pty").and_then(Value::as_bool).unwrap_or(false),
2633        wait: args.get("wait").and_then(Value::as_bool).unwrap_or(false),
2634        block_to_completion: args
2635            .get("block_to_completion")
2636            .and_then(Value::as_bool)
2637            .unwrap_or(false),
2638        timeout: args.get("timeout").and_then(Value::as_u64),
2639    }
2640}
2641
2642fn finalized_bash_result(
2643    mut response: Response,
2644    ctx: &AppContext,
2645    session_id: &str,
2646    format_context: &crate::subc_format::FormatContext,
2647    allow_bg_completions: bool,
2648) -> ToolCallResult {
2649    crate::response_finalize::finalize_response_with_bg_completions(
2650        &mut response,
2651        ctx,
2652        session_id,
2653        "bash",
2654        allow_bg_completions,
2655    );
2656    bash_result_from_response(response, format_context)
2657}
2658
2659fn bash_result_from_response(
2660    response: Response,
2661    format_context: &crate::subc_format::FormatContext,
2662) -> ToolCallResult {
2663    let text = crate::subc_format::format_response_with_context("bash", &response, format_context);
2664    ToolCallResult { text, response }
2665}
2666
2667fn bash_background_launch_response(request_id: &str, task_id: &str, is_pty: bool) -> Response {
2668    Response::success(
2669        request_id,
2670        json!({
2671            "output": crate::commands::bash_orchestrate::format_background_launch(task_id, is_pty),
2672            "task_id": task_id,
2673            "status": "running",
2674            "mode": if is_pty { "pty" } else { "pipes" },
2675        }),
2676    )
2677}
2678
2679fn finish_bash_spawn_immediate(
2680    response: Response,
2681    ctx: &AppContext,
2682    session_id: &str,
2683    format_context: &crate::subc_format::FormatContext,
2684    text_tx: &mut Option<oneshot::Sender<String>>,
2685    control_tx: &mut Option<oneshot::Sender<BashSpawnControl>>,
2686    allow_bg_completions: bool,
2687) -> Response {
2688    let result = finalized_bash_result(
2689        response,
2690        ctx,
2691        session_id,
2692        format_context,
2693        allow_bg_completions,
2694    );
2695    let ToolCallResult { text, response } = result;
2696    if let Some(tx) = text_tx.take() {
2697        let _ = tx.send(text);
2698    }
2699    if let Some(tx) = control_tx.take() {
2700        let _ = tx.send(BashSpawnControl::Immediate);
2701    }
2702    response
2703}
2704
2705fn finish_bash_poll_done(
2706    response: Response,
2707    ctx: &AppContext,
2708    session_id: &str,
2709    format_context: &crate::subc_format::FormatContext,
2710    text_tx: &mut Option<oneshot::Sender<String>>,
2711    control_tx: &mut Option<oneshot::Sender<BashPollControl>>,
2712) -> Response {
2713    let result = finalized_bash_result(response, ctx, session_id, format_context, true);
2714    let ToolCallResult { text, response } = result;
2715    if let Some(tx) = text_tx.take() {
2716        let _ = tx.send(text);
2717    }
2718    if let Some(tx) = control_tx.take() {
2719        let _ = tx.send(BashPollControl::Done);
2720    }
2721    response
2722}
2723
2724#[allow(clippy::too_many_arguments)]
2725fn submit_deferred_bash(
2726    executor: &Arc<Executor>,
2727    completion_tx: &mpsc::Sender<BashDeferredCompletion>,
2728    poll_touch_tx: &mpsc::Sender<ProjectRootId>,
2729    dispatch: DispatchFn,
2730    root: ProjectRootId,
2731    project_root: PathBuf,
2732    session_id: String,
2733    request_id: String,
2734    route_channel: u16,
2735    corr: u64,
2736    flags: Flags,
2737    ver: u8,
2738    arguments: Value,
2739    format_context: crate::subc_format::FormatContext,
2740    cancel: BashWaitCancel,
2741    bind_trust: BindTrust,
2742) {
2743    let (spawn_control_tx, spawn_control_rx) = oneshot::channel::<BashSpawnControl>();
2744    let (spawn_text_tx, spawn_text_rx) = oneshot::channel::<String>();
2745    let root_for_spawn = root.clone();
2746    let request_id_for_spawn = request_id.clone();
2747    let session_for_spawn = session_id.clone();
2748    let project_root_for_spawn = project_root.clone();
2749    let format_context_for_spawn = format_context.clone();
2750    let spawn_rx = executor.submit_async(
2751        root_for_spawn,
2752        Lane::Mutating,
2753        request_id.clone(),
2754        Box::new(move |ctx| {
2755            log_ctx::with_session(Some(session_for_spawn.clone()), || {
2756                let mut spawn_text_tx = Some(spawn_text_tx);
2757                let mut spawn_control_tx = Some(spawn_control_tx);
2758
2759                if matches!(bind_trust, BindTrust::Untrusted) {
2760                    let response = bash_denied_untrusted_response(request_id_for_spawn.clone());
2761                    return finish_bash_spawn_immediate(
2762                        response,
2763                        ctx,
2764                        &session_for_spawn,
2765                        &format_context_for_spawn,
2766                        &mut spawn_text_tx,
2767                        &mut spawn_control_tx,
2768                        false,
2769                    );
2770                }
2771
2772                let translated = match crate::subc_translate::subc_translate(
2773                    "bash",
2774                    &arguments,
2775                    &project_root_for_spawn,
2776                ) {
2777                    Ok(translated) => translated,
2778                    Err(error) => {
2779                        let response = Response::error(
2780                            request_id_for_spawn.clone(),
2781                            error.code,
2782                            error.message,
2783                        );
2784                        return finish_bash_spawn_immediate(
2785                            response,
2786                            ctx,
2787                            &session_for_spawn,
2788                            &format_context_for_spawn,
2789                            &mut spawn_text_tx,
2790                            &mut spawn_control_tx,
2791                            true,
2792                        );
2793                    }
2794                };
2795                let settings = bash_settings_from_translated(&translated.args);
2796                let raw_req = RawRequest {
2797                    id: request_id_for_spawn.clone(),
2798                    command: "bash".to_string(),
2799                    lsp_hints: None,
2800                    session_id: Some(session_for_spawn.clone()),
2801                    params: Value::Object(translated.args),
2802                };
2803                let response = dispatch(raw_req, ctx);
2804                if !response.success {
2805                    return finish_bash_spawn_immediate(
2806                        response,
2807                        ctx,
2808                        &session_for_spawn,
2809                        &format_context_for_spawn,
2810                        &mut spawn_text_tx,
2811                        &mut spawn_control_tx,
2812                        true,
2813                    );
2814                }
2815
2816                let Some(task_id) = response
2817                    .data
2818                    .get("task_id")
2819                    .and_then(Value::as_str)
2820                    .map(str::to_string)
2821                else {
2822                    return finish_bash_spawn_immediate(
2823                        response,
2824                        ctx,
2825                        &session_for_spawn,
2826                        &format_context_for_spawn,
2827                        &mut spawn_text_tx,
2828                        &mut spawn_control_tx,
2829                        true,
2830                    );
2831                };
2832                if response.data.get("status").and_then(Value::as_str) != Some("running") {
2833                    return finish_bash_spawn_immediate(
2834                        response,
2835                        ctx,
2836                        &session_for_spawn,
2837                        &format_context_for_spawn,
2838                        &mut spawn_text_tx,
2839                        &mut spawn_control_tx,
2840                        true,
2841                    );
2842                }
2843
2844                let mode = response
2845                    .data
2846                    .get("mode")
2847                    .and_then(Value::as_str)
2848                    .unwrap_or("pipes");
2849                let is_pty = mode == "pty" || settings.pty;
2850                if is_pty || settings.background {
2851                    let response =
2852                        bash_background_launch_response(&request_id_for_spawn, &task_id, is_pty);
2853                    return finish_bash_spawn_immediate(
2854                        response,
2855                        ctx,
2856                        &session_for_spawn,
2857                        &format_context_for_spawn,
2858                        &mut spawn_text_tx,
2859                        &mut spawn_control_tx,
2860                        true,
2861                    );
2862                }
2863
2864                let wait_window_ms =
2865                    crate::commands::bash_orchestrate::select_foreground_wait_window_ms(
2866                        ctx.config().foreground_wait_window_ms,
2867                        settings.timeout,
2868                        settings.wait,
2869                    );
2870                let deadline = Instant::now() + Duration::from_millis(wait_window_ms);
2871                let storage_dir =
2872                    crate::bash_background::storage_dir(ctx.config().storage_dir.as_deref());
2873                let project_root = ctx.config().project_root.clone();
2874                if let Some(tx) = spawn_control_tx.take() {
2875                    let _ = tx.send(BashSpawnControl::Foreground {
2876                        task_id,
2877                        session_id: session_for_spawn.clone(),
2878                        project_root,
2879                        storage_dir,
2880                        deadline,
2881                        block_to_completion: settings.block_to_completion || settings.wait,
2882                        timeout: settings.timeout,
2883                        wait_window_ms,
2884                    });
2885                }
2886                response
2887            })
2888        }),
2889    );
2890
2891    let executor = Arc::clone(executor);
2892    let completion_tx = completion_tx.clone();
2893    let poll_touch_tx = poll_touch_tx.clone();
2894    let root_for_task = root.clone();
2895    tokio::spawn(async move {
2896        let spawn_response = await_executor_response(spawn_rx, request_id.clone()).await;
2897        let spawn_control = spawn_control_rx.await;
2898        match spawn_control {
2899            Ok(BashSpawnControl::Immediate) => {
2900                let text = spawn_text_rx.await.unwrap_or_else(|_| {
2901                    crate::subc_format::format_response_with_context(
2902                        "bash",
2903                        &spawn_response,
2904                        &format_context,
2905                    )
2906                });
2907                let result = ToolCallResult {
2908                    text,
2909                    response: spawn_response,
2910                };
2911                let fatal = response_is_fatal_panic(&result.response);
2912                send_bash_deferred_completion(
2913                    &completion_tx,
2914                    route_channel,
2915                    corr,
2916                    flags,
2917                    ver,
2918                    root_for_task,
2919                    request_id,
2920                    Some(result),
2921                    fatal,
2922                )
2923                .await;
2924            }
2925            Ok(BashSpawnControl::Foreground {
2926                task_id,
2927                session_id,
2928                project_root,
2929                storage_dir,
2930                deadline,
2931                block_to_completion,
2932                timeout,
2933                wait_window_ms,
2934            }) => {
2935                run_deferred_bash_wait(
2936                    executor,
2937                    completion_tx,
2938                    poll_touch_tx,
2939                    route_channel,
2940                    corr,
2941                    flags,
2942                    ver,
2943                    root_for_task,
2944                    request_id,
2945                    task_id,
2946                    session_id,
2947                    project_root,
2948                    storage_dir,
2949                    deadline,
2950                    block_to_completion,
2951                    timeout,
2952                    wait_window_ms,
2953                    format_context,
2954                    cancel,
2955                )
2956                .await;
2957            }
2958            Err(_) => {
2959                let result = bash_result_from_response(spawn_response, &format_context);
2960                let fatal = response_is_fatal_panic(&result.response);
2961                send_bash_deferred_completion(
2962                    &completion_tx,
2963                    route_channel,
2964                    corr,
2965                    flags,
2966                    ver,
2967                    root_for_task,
2968                    request_id,
2969                    Some(result),
2970                    fatal,
2971                )
2972                .await;
2973            }
2974        }
2975    });
2976}
2977
2978#[allow(clippy::too_many_arguments)]
2979async fn run_deferred_bash_wait(
2980    executor: Arc<Executor>,
2981    completion_tx: mpsc::Sender<BashDeferredCompletion>,
2982    poll_touch_tx: mpsc::Sender<ProjectRootId>,
2983    route_channel: u16,
2984    corr: u64,
2985    flags: Flags,
2986    ver: u8,
2987    root: ProjectRootId,
2988    request_id: String,
2989    task_id: String,
2990    session_id: String,
2991    project_root: Option<PathBuf>,
2992    storage_dir: PathBuf,
2993    deadline: Instant,
2994    block_to_completion: bool,
2995    timeout: Option<u64>,
2996    wait_window_ms: u64,
2997    format_context: crate::subc_format::FormatContext,
2998    cancel: BashWaitCancel,
2999) {
3000    loop {
3001        tokio::select! {
3002            _ = cancel.cancelled() => {
3003                send_bash_deferred_completion(
3004                    &completion_tx,
3005                    route_channel,
3006                    corr,
3007                    flags,
3008                    ver,
3009                    root,
3010                    request_id,
3011                    None,
3012                    false,
3013                )
3014                .await;
3015                break;
3016            }
3017            _ = tokio::time::sleep(PENDING_POLL_INTERVAL) => {
3018                let (poll_control_tx, poll_control_rx) = oneshot::channel::<BashPollControl>();
3019                let (poll_text_tx, poll_text_rx) = oneshot::channel::<String>();
3020                let root_for_poll = root.clone();
3021                let request_id_for_poll = request_id.clone();
3022                let task_id_for_poll = task_id.clone();
3023                let session_for_poll = session_id.clone();
3024                let storage_for_poll = storage_dir.clone();
3025                let project_root_for_poll = project_root.clone();
3026                let format_context_for_poll = format_context.clone();
3027                let poll_rx = executor.submit_async(
3028                    root_for_poll,
3029                    Lane::PureRead,
3030                    request_id.clone(),
3031                    Box::new(move |ctx| {
3032                        log_ctx::with_session(Some(session_for_poll.clone()), || {
3033                            let mut poll_text_tx = Some(poll_text_tx);
3034                            let mut poll_control_tx = Some(poll_control_tx);
3035
3036                            let Some(snapshot) = crate::commands::bash_orchestrate::poll_bash_status(
3037                                ctx,
3038                                &task_id_for_poll,
3039                                &session_for_poll,
3040                                project_root_for_poll.as_deref(),
3041                                &storage_for_poll,
3042                                crate::bash_background::output::RUNNING_OUTPUT_PREVIEW_BYTES,
3043                            ) else {
3044                                return finish_bash_poll_done(
3045                                    crate::commands::bash_orchestrate::task_not_found_response(
3046                                        &request_id_for_poll,
3047                                        &task_id_for_poll,
3048                                    ),
3049                                    ctx,
3050                                    &session_for_poll,
3051                                    &format_context_for_poll,
3052                                    &mut poll_text_tx,
3053                                    &mut poll_control_tx,
3054                                );
3055                            };
3056
3057                            match crate::commands::bash_orchestrate::decide_bash_step(
3058                                snapshot,
3059                                deadline,
3060                                block_to_completion,
3061                                Instant::now(),
3062                                &request_id_for_poll,
3063                            ) {
3064                                crate::commands::bash_orchestrate::BashStep::Done(response) => {
3065                                    finish_bash_poll_done(
3066                                        response,
3067                                        ctx,
3068                                        &session_for_poll,
3069                                        &format_context_for_poll,
3070                                        &mut poll_text_tx,
3071                                        &mut poll_control_tx,
3072                                    )
3073                                }
3074                                crate::commands::bash_orchestrate::BashStep::Promote => {
3075                                    if let Some(tx) = poll_control_tx.take() {
3076                                        let _ = tx.send(BashPollControl::Promote);
3077                                    }
3078                                    Response::success(
3079                                        request_id_for_poll,
3080                                        json!({ "subc_bash_step": "promote" }),
3081                                    )
3082                                }
3083                                crate::commands::bash_orchestrate::BashStep::Wait => {
3084                                    if let Some(tx) = poll_control_tx.take() {
3085                                        let _ = tx.send(BashPollControl::Wait);
3086                                    }
3087                                    Response::success(
3088                                        request_id_for_poll,
3089                                        json!({ "subc_bash_step": "wait" }),
3090                                    )
3091                                }
3092                            }
3093                        })
3094                    }),
3095                );
3096                let poll_response = await_executor_response(poll_rx, request_id.clone()).await;
3097                let _ = poll_touch_tx.send(root.clone()).await;
3098                match poll_control_rx.await.unwrap_or(BashPollControl::Done) {
3099                    BashPollControl::Done => {
3100                        let text = poll_text_rx.await.unwrap_or_else(|_| {
3101                            crate::subc_format::format_response_with_context(
3102                                "bash",
3103                                &poll_response,
3104                                &format_context,
3105                            )
3106                        });
3107                        let result = ToolCallResult {
3108                            text,
3109                            response: poll_response,
3110                        };
3111                        let fatal = response_is_fatal_panic(&result.response);
3112                        send_bash_deferred_completion(
3113                            &completion_tx,
3114                            route_channel,
3115                            corr,
3116                            flags,
3117                            ver,
3118                            root,
3119                            request_id,
3120                            Some(result),
3121                            fatal,
3122                        )
3123                        .await;
3124                        break;
3125                    }
3126                    BashPollControl::Promote => {
3127                        let result = submit_bash_promote(
3128                            &executor,
3129                            root.clone(),
3130                            request_id.clone(),
3131                            task_id.clone(),
3132                            session_id.clone(),
3133                            timeout,
3134                            wait_window_ms,
3135                            format_context.clone(),
3136                        )
3137                        .await;
3138                        let fatal = response_is_fatal_panic(&result.response);
3139                        send_bash_deferred_completion(
3140                            &completion_tx,
3141                            route_channel,
3142                            corr,
3143                            flags,
3144                            ver,
3145                            root,
3146                            request_id,
3147                            Some(result),
3148                            fatal,
3149                        )
3150                        .await;
3151                        break;
3152                    }
3153                    BashPollControl::Wait => {}
3154                }
3155            }
3156        }
3157    }
3158}
3159
3160async fn submit_bash_promote(
3161    executor: &Arc<Executor>,
3162    root: ProjectRootId,
3163    request_id: String,
3164    task_id: String,
3165    session_id: String,
3166    timeout: Option<u64>,
3167    wait_window_ms: u64,
3168    format_context: crate::subc_format::FormatContext,
3169) -> ToolCallResult {
3170    let (text_tx, text_rx) = oneshot::channel::<String>();
3171    let request_id_for_promote = request_id.clone();
3172    let task_id_for_promote = task_id.clone();
3173    let session_for_promote = session_id.clone();
3174    let format_context_for_promote = format_context.clone();
3175    let promote_rx = executor.submit_async(
3176        root,
3177        Lane::Mutating,
3178        request_id.clone(),
3179        Box::new(move |ctx| {
3180            log_ctx::with_session(Some(session_for_promote.clone()), || {
3181                let response = if let Some(value) =
3182                    std::env::var_os("AFT_TEST_FORCE_SUBC_BASH_PROMOTE_ERROR")
3183                {
3184                    if value.to_string_lossy() == "panic" {
3185                        panic!("forced subc bash promote panic");
3186                    }
3187                    Response::error(
3188                        &request_id_for_promote,
3189                        "execution_failed",
3190                        "forced subc bash promote failure",
3191                    )
3192                } else {
3193                    crate::commands::bash_orchestrate::promote_bash(
3194                        ctx,
3195                        &task_id_for_promote,
3196                        &session_for_promote,
3197                        ctx.config().project_root.as_deref(),
3198                        timeout,
3199                        wait_window_ms,
3200                        &request_id_for_promote,
3201                    )
3202                };
3203                let result = finalized_bash_result(
3204                    response,
3205                    ctx,
3206                    &session_for_promote,
3207                    &format_context_for_promote,
3208                    true,
3209                );
3210                let ToolCallResult { text, response } = result;
3211                let _ = text_tx.send(text);
3212                response
3213            })
3214        }),
3215    );
3216    let response = await_executor_response(promote_rx, request_id).await;
3217    let text = text_rx.await.unwrap_or_else(|_| {
3218        crate::subc_format::format_response_with_context("bash", &response, &format_context)
3219    });
3220    ToolCallResult { text, response }
3221}
3222
3223#[allow(clippy::too_many_arguments)]
3224async fn send_bash_deferred_completion(
3225    completion_tx: &mpsc::Sender<BashDeferredCompletion>,
3226    channel: u16,
3227    corr: u64,
3228    flags: Flags,
3229    ver: u8,
3230    root: ProjectRootId,
3231    request_id: String,
3232    result: Option<ToolCallResult>,
3233    fatal: bool,
3234) {
3235    let _ = completion_tx
3236        .send(BashDeferredCompletion {
3237            channel,
3238            corr,
3239            flags,
3240            ver,
3241            root,
3242            request_id,
3243            result,
3244            fatal,
3245        })
3246        .await;
3247}
3248
3249async fn handle_bash_deferred_completion(
3250    tx: &mpsc::Sender<Frame>,
3251    done: BashDeferredCompletion,
3252    routes: &HashMap<RouteChannel, RouteIdentity>,
3253    live_roots: &mut HashMap<ProjectRootId, RootMeta>,
3254    route_bash_cancels: &mut HashMap<RouteChannel, RouteBashCancel>,
3255    shutdown: &Arc<Notify>,
3256) -> Result<(), SubcError> {
3257    if let Some(meta) = live_roots.get_mut(&done.root) {
3258        meta.active_bash_waits = meta.active_bash_waits.saturating_sub(1);
3259        meta.touch();
3260    }
3261    let route_id = route_key(done.channel);
3262    let remove_route_cancel = if let Some(cancel) = route_bash_cancels.get_mut(&route_id) {
3263        cancel.active_waits = cancel.active_waits.saturating_sub(1);
3264        cancel.active_waits == 0
3265    } else {
3266        false
3267    };
3268    if remove_route_cancel {
3269        route_bash_cancels.remove(&route_id);
3270    }
3271
3272    if let Some(result) = done.result {
3273        if routes.contains_key(&route_id) {
3274            let frame =
3275                build_tool_response_frame(done.ver, done.channel, done.corr, done.flags, &result)?;
3276            send_frame(tx, frame).await?;
3277        } else {
3278            log::debug!(
3279                "subc attach: dropping deferred bash response {} for unbound route {}",
3280                done.request_id,
3281                done.channel
3282            );
3283        }
3284    } else {
3285        log::debug!(
3286            "subc attach: deferred bash wait {} cancelled before delivery on route {}",
3287            done.request_id,
3288            done.channel
3289        );
3290    }
3291
3292    if done.fatal {
3293        signal_fatal_teardown(tx, Some(done.channel), done.ver, done.corr, shutdown).await;
3294    }
3295    Ok(())
3296}
3297fn submit_maintenance_drain(
3298    executor: &Arc<Executor>,
3299    root_id: ProjectRootId,
3300    bg_sessions_to_check: Vec<(String, u64)>,
3301    completion_tx: &mpsc::Sender<MaintenanceCompletion>,
3302) {
3303    let request_id = format!(
3304        "subc-maintenance-drain-{}",
3305        root_id.as_path().to_string_lossy()
3306    );
3307    let response_id = request_id.clone();
3308    let completion_root_id = root_id.clone();
3309    let (empty_bg_sessions_tx, empty_bg_sessions_rx) = oneshot::channel::<Vec<(String, u64)>>();
3310    let rx = executor.submit_async(
3311        root_id,
3312        Lane::Mutating,
3313        request_id.clone(),
3314        Box::new(move |ctx| {
3315            runtime_drain::drain_configure_warning_events(ctx);
3316            runtime_drain::drain_search_index_events(ctx);
3317            runtime_drain::drain_callgraph_store_events(ctx);
3318            runtime_drain::drain_semantic_index_events(ctx);
3319            runtime_drain::drain_semantic_refresh_events(ctx);
3320            runtime_drain::drain_inspect_events(ctx);
3321            runtime_drain::drain_watcher_events(ctx);
3322            runtime_drain::drain_lsp_events(ctx);
3323            let empty_bg_sessions = bg_sessions_to_check
3324                .into_iter()
3325                .filter(|(session, _)| {
3326                    !ctx.bash_background()
3327                        .has_completions_for_session(Some(session.as_str()))
3328                })
3329                .collect();
3330            let _ = empty_bg_sessions_tx.send(empty_bg_sessions);
3331            Response::success(response_id, json!({ "drained": true }))
3332        }),
3333    );
3334    let completion_tx = completion_tx.clone();
3335    tokio::spawn(async move {
3336        let response = await_executor_response(rx, request_id).await;
3337        let empty_bg_sessions = empty_bg_sessions_rx.await.unwrap_or_default();
3338        let _ = completion_tx
3339            .send(MaintenanceCompletion {
3340                root_id: completion_root_id,
3341                response,
3342                empty_bg_sessions,
3343            })
3344            .await;
3345    });
3346}
3347
3348async fn await_executor_response(rx: oneshot::Receiver<Response>, request_id: String) -> Response {
3349    rx.await
3350        .unwrap_or_else(|_| Response::error(request_id, "internal_error", "executor dropped"))
3351}
3352
3353/// Flatten a tool-call `Response` + server-rendered `text` into the SAME flat
3354/// object the standalone NDJSON `tool_call` command puts on the wire:
3355/// `{id, success, ...data, text}` (Response flattens `data` to the top level —
3356/// protocol.rs — and `response_with_text` merges `text` in). Mirrors
3357/// `commands::tool_call::response_with_text` exactly, including its non-object
3358/// `data` fallback (data replaced by `{text}`), so the subc `structuredContent`
3359/// is byte-identical to the standalone response body. Built field-by-field
3360/// rather than via `serde_json::to_value(response)` because `#[serde(flatten)]`
3361/// of a non-object `data` would error.
3362fn flat_tool_response(response: &crate::protocol::Response, text: &str) -> Value {
3363    let mut obj = serde_json::Map::new();
3364    obj.insert("id".to_string(), Value::String(response.id.clone()));
3365    obj.insert("success".to_string(), Value::Bool(response.success));
3366    if let Some(data) = response.data.as_object() {
3367        for (key, value) in data {
3368            obj.insert(key.clone(), value.clone());
3369        }
3370    }
3371    obj.insert("text".to_string(), Value::String(text.to_string()));
3372    Value::Object(obj)
3373}
3374
3375fn build_tool_response_frame(
3376    ver: u8,
3377    route_channel: u16,
3378    corr: u64,
3379    flags: Flags,
3380    result: &ToolCallResult,
3381) -> Result<Frame, SubcError> {
3382    let is_error = !result.response.success;
3383    // `content`/`isError` is the MCP-native surface a GENERIC host reads (and a
3384    // generic host ignores `structuredContent`, per the MCP spec). The
3385    // FIRST-PARTY AFT plugin instead reads `structuredContent`, which carries
3386    // the full flat standalone shape ({id, success, ...data, text}) so every
3387    // structured sidecar the plugin drives UI from — status_bar, bg_completions
3388    // (in-band drain), preview_diff, code, message, attachments — survives the
3389    // route. subc relays the body byte-for-byte, so this reaches the plugin
3390    // unchanged. SubcTransport.toolCall re-lifts `structuredContent` straight to
3391    // the flat ToolCallResult, so nothing downstream of the transport differs
3392    // from the NDJSON path.
3393    let payload = json!({
3394        "content": [{ "type": "text", "text": result.text.as_str() }],
3395        "isError": is_error,
3396        "structuredContent": flat_tool_response(&result.response, &result.text),
3397    });
3398    let body = serde_json::to_vec(&payload).map_err(SubcError::Json)?;
3399
3400    Frame::build_with_version(ver, FrameType::Response, flags, route_channel, corr, body)
3401        .map_err(SubcError::FrameBuild)
3402}
3403
3404fn build_error_frame(
3405    ver: u8,
3406    channel: u16,
3407    corr: u64,
3408    flags: Flags,
3409    code: &str,
3410    message: &str,
3411) -> Result<Frame, SubcError> {
3412    let body = serde_json::to_vec(&ErrorBody {
3413        code: code.to_string(),
3414        message: message.to_string(),
3415    })
3416    .map_err(SubcError::Json)?;
3417    Frame::build_with_version(ver, FrameType::Error, flags, channel, corr, body)
3418        .map_err(SubcError::FrameBuild)
3419}
3420
3421fn build_goodbye_frame(ver: u8, channel: u16, corr: u64) -> Result<Frame, SubcError> {
3422    Frame::build_with_version(
3423        ver,
3424        FrameType::Goodbye,
3425        control_flags(),
3426        channel,
3427        corr,
3428        Vec::new(),
3429    )
3430    .map_err(SubcError::FrameBuild)
3431}
3432
3433async fn signal_fatal_teardown(
3434    tx: &mpsc::Sender<Frame>,
3435    route_channel: Option<u16>,
3436    ver: u8,
3437    corr: u64,
3438    shutdown: &Arc<Notify>,
3439) {
3440    if let Some(route_channel) = route_channel {
3441        if let Ok(frame) = build_goodbye_frame(ver, route_channel, corr) {
3442            if let Err(error) = send_frame(tx, frame).await {
3443                log::warn!(
3444                    "subc attach: failed to queue fatal route Goodbye for route {route_channel}: {error}"
3445                );
3446            }
3447        }
3448    }
3449    if let Ok(frame) = build_goodbye_frame(ver, 0, 0) {
3450        if let Err(error) = send_frame(tx, frame).await {
3451            log::warn!("subc attach: failed to queue fatal channel-0 Goodbye: {error}");
3452        }
3453    }
3454    shutdown.notify_one();
3455}
3456
3457fn response_message(response: &Response, fallback: &str) -> String {
3458    response
3459        .data
3460        .get("message")
3461        .and_then(Value::as_str)
3462        .map(ToOwned::to_owned)
3463        .unwrap_or_else(|| fallback.to_string())
3464}
3465
3466fn response_is_fatal_panic(response: &Response) -> bool {
3467    !response.success && response.data.get("code").and_then(Value::as_str) == Some("actor_fatal")
3468}
3469
3470fn bash_denied_untrusted_response(request_id: impl Into<String>) -> Response {
3471    Response::error(
3472        request_id.into(),
3473        "bash_denied_untrusted",
3474        "remote/MCP-facade binds cannot run shell commands",
3475    )
3476}
3477
3478fn is_bash_family_tool(name: &str) -> bool {
3479    name == "bash" || name.starts_with("bash_")
3480}
3481
3482fn is_subc_agent_core_tool(name: &str) -> bool {
3483    matches!(
3484        name,
3485        "status"
3486            | "bash"
3487            | "read"
3488            | "write"
3489            | "edit"
3490            | "apply_patch"
3491            | "grep"
3492            | "glob"
3493            | "search"
3494            | "outline"
3495            | "zoom"
3496            | "inspect"
3497            | "callgraph"
3498            | "conflicts"
3499            | "ast_search"
3500            | "ast_replace"
3501            | "delete"
3502            | "move"
3503            | "import"
3504            | "refactor"
3505            | "safety"
3506    )
3507}
3508
3509/// Internal bg-completion plumbing commands the harness consumer (NOT the agent)
3510/// invokes over a bound route to drain and acknowledge background-bash
3511/// completions for its session. These are NOT agent-facing tools — they carry no
3512/// agent surface and never reach the model — so they're not in the manifest /
3513/// `is_subc_agent_core_tool`, but the plugin's bg-notification drain/ack path
3514/// (bg-notifications.ts: `bridge.send("bash_drain_completions"|"bash_ack_completions")`)
3515/// must reach dispatch over subc, otherwise an idle agent can never drain a
3516/// completion the wake lane nudges it about.
3517///
3518/// This is a DELIBERATELY TIGHT allowlist (exactly these two names), kept
3519/// separate from the agent core-tool gate so it cannot widen the fail-closed
3520/// backstop in `handle_tool_call`. Both are session-scoped (the bind session is
3521/// reinjected by `run_tool_call`, overriding any body `session_id`) and touch
3522/// only the per-session completion registry — they carry NO config/trust surface,
3523/// so admitting them does not reopen the `configure`-bypass hole the gate exists
3524/// to close. Lanes are already assigned: `bash_drain_completions` = PureRead,
3525/// `bash_ack_completions` = Mutating (see `command_lane`).
3526fn is_subc_native_plumbing_tool(name: &str) -> bool {
3527    matches!(name, "bash_drain_completions" | "bash_ack_completions")
3528}
3529
3530fn command_lane(command: &str) -> Lane {
3531    match command {
3532        "ping"
3533        | "version"
3534        | "echo"
3535        | "bash_drain_completions"
3536        | "bash_regex_match"
3537        | "db_get_state"
3538        | "db_get_host_state"
3539        | "read"
3540        | "undo_preview"
3541        | "edit_history"
3542        | "checkpoint_paths"
3543        | "list_checkpoints"
3544        | "conflicts"
3545        | "glob"
3546        | "grep"
3547        | "git_conflicts"
3548        | "ast_search" => Lane::PureRead,
3549
3550        // Lazy reads mutate parser/terminal/url caches on a miss, but are still
3551        // classified onto the reader pool; install races are handled at the
3552        // individual cache sites.
3553        "bash_status" | "outline" | "zoom" => Lane::PureRead,
3554
3555        "status"
3556        | "inspect"
3557        | "lsp_diagnostics"
3558        | "lsp_inspect"
3559        | "lsp_hover"
3560        | "lsp_goto_definition"
3561        | "lsp_find_references"
3562        | "lsp_prepare_rename" => Lane::SerialLspStatus,
3563
3564        "semantic_search" | "search" | "callgraph" | "callers" | "impact" | "call_tree"
3565        | "trace_to" | "trace_to_symbol" | "trace_data" | "inspect_tier2_run" => Lane::HeavyInit,
3566
3567        "bash"
3568        | "bash_ack_completions"
3569        | "bash_notify"
3570        | "bash_unnotify"
3571        | "bash_promote"
3572        | "bash_kill"
3573        | "bash_write"
3574        | "db_set_state"
3575        | "db_set_host_state"
3576        | "undo"
3577        | "checkpoint"
3578        | "restore_checkpoint"
3579        | "write"
3580        | "delete_file"
3581        | "move_file"
3582        | "edit"
3583        | "edit_symbol"
3584        | "edit_match"
3585        | "batch"
3586        | "add_import"
3587        | "remove_import"
3588        | "organize_imports"
3589        | "configure"
3590        | "move_symbol"
3591        | "extract_function"
3592        | "inline_symbol"
3593        | "ast_replace"
3594        | "lsp_rename"
3595        | "list_filters"
3596        | "trust_filter_project"
3597        | "untrust_filter_project"
3598        | "snapshot" => Lane::Mutating,
3599
3600        _ => Lane::Mutating,
3601    }
3602}
3603
3604#[derive(Deserialize)]
3605struct BgEventsProbe {
3606    op: Option<String>,
3607}
3608
3609#[derive(Debug, Deserialize)]
3610struct ToolCallRequest {
3611    name: String,
3612    #[serde(default)]
3613    arguments: Value,
3614}
3615
3616static SUBC_TOOL_SCHEMAS: LazyLock<serde_json::Map<String, Value>> = LazyLock::new(|| {
3617    serde_json::from_str(include_str!("subc_tool_schemas.json"))
3618        .unwrap_or_else(|e| panic!("subc_tool_schemas.json: {e}"))
3619});
3620
3621fn tool_schema(name: &str) -> Value {
3622    SUBC_TOOL_SCHEMAS.get(name).cloned().unwrap_or_else(|| {
3623        log::warn!(
3624            "subc build_manifest: missing embedded schema for tool {name:?}; using placeholder"
3625        );
3626        json!({ "type": "object" })
3627    })
3628}
3629
3630/// AFT's subc-mode capability manifest. It uses bare internal tool names
3631/// because the gateway adds any `aft_` prefix for agent-facing displays; AFT
3632/// schedules concurrent calls itself; the gateway runs AFT directly without a
3633/// sandbox. The manifest lists every tool an agent can call over subc.
3634fn build_manifest() -> ModuleManifest {
3635    let tool = |name: &str, execution_mode: ExecutionMode| Tool {
3636        name: name.to_string(),
3637        execution_mode,
3638        schema: tool_schema(name),
3639    };
3640    // execution_mode keys on externally-observable side effects, NOT internal
3641    // ctx mutation: the readers warm AFT's own index/cache/symbol artifacts
3642    // (internal), not the user's workspace, so they are Pure. Bash is Mutating
3643    // because spawning a detached process changes external state, and edit/write
3644    // produce observable file writes. Unfenceable stays unused here because AFT
3645    // schedules bash internally and releases the Mutating worker after spawn.
3646    ModuleManifest {
3647        module_id: "aft".to_string(),
3648        module_version: env!("CARGO_PKG_VERSION").to_string(),
3649        protocol_ver: PROTOCOL_VERSION,
3650        trust_tier: TrustTier::FirstParty,
3651        provides: vec![ProviderRole::ToolProvider {
3652            tools: vec![
3653                tool("status", ExecutionMode::Pure),
3654                tool("bash", ExecutionMode::Mutating),
3655                tool("read", ExecutionMode::Pure),
3656                tool("write", ExecutionMode::Mutating),
3657                tool("edit", ExecutionMode::Mutating),
3658                tool("apply_patch", ExecutionMode::Mutating),
3659                tool("grep", ExecutionMode::Pure),
3660                tool("glob", ExecutionMode::Pure),
3661                tool("search", ExecutionMode::Pure),
3662                tool("outline", ExecutionMode::Pure),
3663                tool("zoom", ExecutionMode::Pure),
3664                tool("inspect", ExecutionMode::Pure),
3665                tool("callgraph", ExecutionMode::Pure),
3666                tool("conflicts", ExecutionMode::Pure),
3667                tool("ast_search", ExecutionMode::Pure),
3668                tool("ast_replace", ExecutionMode::Mutating),
3669                tool("delete", ExecutionMode::Mutating),
3670                tool("move", ExecutionMode::Mutating),
3671                tool("import", ExecutionMode::Mutating),
3672                tool("refactor", ExecutionMode::Mutating),
3673                tool("safety", ExecutionMode::Mutating),
3674            ],
3675            identity_scope: vec![IdentityScope::Session, IdentityScope::Project],
3676            concurrency: Concurrency::ModuleManaged,
3677            emits_push: true,
3678            sub_supervises: true,
3679        }],
3680        consumes: Vec::new(),
3681        scheduled_tasks: Vec::new(),
3682        bindings: Bindings {
3683            storage: StorageBinding {
3684                kind: StorageKind::Sqlite,
3685                scope: StorageScope::Project,
3686                owns_schema: true,
3687            },
3688            vault_grants: Vec::new(),
3689            identity: IdentityBinding {
3690                requires: vec![IdentityScope::Project],
3691                optional: vec![IdentityScope::Session],
3692            },
3693        },
3694    }
3695}
3696
3697fn control_flags() -> Flags {
3698    Flags::new(false, Priority::Passive, false)
3699}
3700
3701#[cfg(test)]
3702mod tests {
3703    use super::*;
3704    use crate::bash_background::BgTaskStatus;
3705    use crate::protocol::{
3706        BashCompletedFrame, BashLongRunningFrame, BashPatternMatchFrame, ConfigureWarningsFrame,
3707        ProgressFrame, StatusChangedFrame,
3708    };
3709    use serde_json::json;
3710
3711    fn test_root(name: &str) -> (tempfile::TempDir, ProjectRootId) {
3712        let dir = tempfile::Builder::new()
3713            .prefix(name)
3714            .tempdir()
3715            .expect("temp root");
3716        let root = ProjectRootId::from_path(dir.path()).expect("project root id");
3717        (dir, root)
3718    }
3719
3720    fn status_frame(seq: u64) -> PushFrame {
3721        status_frame_with_session(seq, None)
3722    }
3723
3724    fn status_frame_with_session(seq: u64, session_id: Option<&str>) -> PushFrame {
3725        PushFrame::StatusChanged(StatusChangedFrame {
3726            frame_type: "status_changed",
3727            session_id: session_id.map(str::to_string),
3728            snapshot: json!({ "seq": seq }),
3729        })
3730    }
3731
3732    fn completion_frame(task_id: &str) -> PushFrame {
3733        completion_frame_with_session(task_id, "session-1")
3734    }
3735
3736    fn completion_frame_with_session(task_id: &str, session_id: &str) -> PushFrame {
3737        PushFrame::BashCompleted(BashCompletedFrame {
3738            frame_type: "bash_completed",
3739            task_id: task_id.to_string(),
3740            session_id: session_id.to_string(),
3741            status: BgTaskStatus::Completed,
3742            exit_code: Some(0),
3743            command: format!("echo {task_id}"),
3744            output_preview: String::new(),
3745            output_truncated: false,
3746            original_tokens: None,
3747            compressed_tokens: None,
3748            tokens_skipped: false,
3749        })
3750    }
3751
3752    fn long_running_frame(task_id: &str, elapsed_ms: u64) -> PushFrame {
3753        long_running_frame_with_session(task_id, "session-1", elapsed_ms)
3754    }
3755
3756    fn long_running_frame_with_session(
3757        task_id: &str,
3758        session_id: &str,
3759        elapsed_ms: u64,
3760    ) -> PushFrame {
3761        PushFrame::BashLongRunning(BashLongRunningFrame {
3762            frame_type: "bash_long_running",
3763            task_id: task_id.to_string(),
3764            session_id: session_id.to_string(),
3765            command: format!("sleep {elapsed_ms}"),
3766            elapsed_ms,
3767        })
3768    }
3769
3770    fn pattern_match_frame(session_id: &str) -> PushFrame {
3771        PushFrame::BashPatternMatch(BashPatternMatchFrame {
3772            frame_type: "bash_pattern_match",
3773            task_id: "task-pattern".to_string(),
3774            session_id: session_id.to_string(),
3775            watch_id: "watch-1".to_string(),
3776            match_text: "needle".to_string(),
3777            match_offset: 7,
3778            context: "haystack needle".to_string(),
3779            once: true,
3780            reason: "pattern_match",
3781        })
3782    }
3783
3784    fn configure_warnings_frame(session_id: Option<&str>) -> PushFrame {
3785        PushFrame::ConfigureWarnings(ConfigureWarningsFrame {
3786            frame_type: "configure_warnings",
3787            session_id: session_id.map(str::to_string),
3788            project_root: "/tmp/subc-test".to_string(),
3789            source_file_count: 0,
3790            warnings: Vec::new(),
3791        })
3792    }
3793
3794    fn route_identity(root: &ProjectRootId, session_id: &str) -> RouteIdentity {
3795        route_identity_with_trust(root, session_id, BindTrust::FirstParty)
3796    }
3797
3798    fn route_identity_with_trust(
3799        root: &ProjectRootId,
3800        session_id: &str,
3801        trust: BindTrust,
3802    ) -> RouteIdentity {
3803        RouteIdentity {
3804            root: root.clone(),
3805            project_root: root.as_path().to_path_buf(),
3806            harness: "opencode".to_string(),
3807            session: session_id.to_string(),
3808            trust,
3809        }
3810    }
3811
3812    fn progress_frame(request_id: &str, kind: ProgressKind, chunk: &str) -> PushFrame {
3813        PushFrame::Progress(ProgressFrame::new(request_id, kind, chunk))
3814    }
3815
3816    fn status_seq(frame: &PushFrame) -> Option<u64> {
3817        match frame {
3818            PushFrame::StatusChanged(status) => status.snapshot.get("seq").and_then(|v| v.as_u64()),
3819            _ => None,
3820        }
3821    }
3822
3823    fn completion_task(frame: &PushFrame) -> Option<&str> {
3824        match frame {
3825            PushFrame::BashCompleted(completion) => Some(completion.task_id.as_str()),
3826            _ => None,
3827        }
3828    }
3829
3830    fn push_frame_task_id(frame: &Frame) -> Option<String> {
3831        let body: serde_json::Value = serde_json::from_slice(&frame.body).expect("push body");
3832        body.get("task_id")
3833            .and_then(serde_json::Value::as_str)
3834            .map(str::to_string)
3835    }
3836
3837    #[test]
3838    fn trust_for_principal_matrix() {
3839        assert_eq!(
3840            trust_for_principal(&Some(Principal::Direct)),
3841            BindTrust::FirstParty
3842        );
3843        assert_eq!(
3844            trust_for_principal(&Some(Principal::Reserved {
3845                module_id: "llm-runner".to_string(),
3846            })),
3847            BindTrust::FirstParty
3848        );
3849        assert_eq!(
3850            trust_for_principal(&Some(Principal::Reserved {
3851                module_id: "aft".to_string(),
3852            })),
3853            BindTrust::FirstParty
3854        );
3855        assert_eq!(
3856            trust_for_principal(&Some(Principal::Reserved {
3857                module_id: "subc-mcp".to_string(),
3858            })),
3859            BindTrust::Untrusted
3860        );
3861        assert_eq!(
3862            trust_for_principal(&Some(Principal::Reserved {
3863                module_id: "anything-unknown".to_string(),
3864            })),
3865            BindTrust::Untrusted
3866        );
3867        assert_eq!(
3868            trust_for_principal(&Some(Principal::Unverified)),
3869            BindTrust::Untrusted
3870        );
3871        assert_eq!(trust_for_principal(&None), BindTrust::Untrusted);
3872    }
3873
3874    #[test]
3875    fn frame_classification_matches_push_delivery_contract() {
3876        let completion = completion_frame_with_session("done", "session-a");
3877        assert_eq!(frame_session(&completion), Some("session-a"));
3878        assert!(frame_is_reliable(&completion));
3879
3880        let long_running = long_running_frame_with_session("long", "session-b", 42);
3881        assert_eq!(frame_session(&long_running), Some("session-b"));
3882        assert!(!frame_is_reliable(&long_running));
3883
3884        let pattern_match = pattern_match_frame("session-c");
3885        assert_eq!(frame_session(&pattern_match), Some("session-c"));
3886        assert!(frame_is_reliable(&pattern_match));
3887
3888        let tagged_warnings = configure_warnings_frame(Some("session-d"));
3889        assert_eq!(frame_session(&tagged_warnings), Some("session-d"));
3890        assert!(frame_is_reliable(&tagged_warnings));
3891
3892        let untagged_warnings = configure_warnings_frame(None);
3893        assert_eq!(frame_session(&untagged_warnings), None);
3894        assert!(frame_is_reliable(&untagged_warnings));
3895
3896        let tagged_status = status_frame_with_session(1, Some("session-e"));
3897        assert_eq!(frame_session(&tagged_status), Some("session-e"));
3898        assert!(!frame_is_reliable(&tagged_status));
3899
3900        let project_status = status_frame(2);
3901        assert_eq!(frame_session(&project_status), None);
3902        assert!(!frame_is_reliable(&project_status));
3903
3904        let progress = progress_frame("request-1", ProgressKind::Stdout, "chunk");
3905        assert_eq!(frame_session(&progress), None);
3906        assert!(!frame_is_reliable(&progress));
3907    }
3908
3909    #[test]
3910    fn fan_out_push_frame_routes_session_scoped_and_project_scoped_frames() {
3911        let (_root_dir, root) = test_root("subc-session-routing-root");
3912        let (writer_tx, mut writer_rx) = mpsc::channel::<Frame>(8);
3913        let identity1 = route_identity(&root, "session-1");
3914        let identity2 = route_identity(&root, "session-2");
3915        let mut routes = HashMap::new();
3916        routes.insert(route_key(1), identity1.clone());
3917        routes.insert(route_key(2), identity2.clone());
3918        let mut root_channels = HashMap::new();
3919        root_channels.insert(root.clone(), HashSet::from([route_key(1), route_key(2)]));
3920        let mut session_identity = HashMap::new();
3921        remember_session_identity(&mut session_identity, &identity1);
3922        remember_session_identity(&mut session_identity, &identity2);
3923        let mut retry_buffer = HashMap::new();
3924        let mut push_buffer = HashMap::new();
3925
3926        let session_result = fan_out_reliable_push_frame(
3927            &writer_tx,
3928            &routes,
3929            &root_channels,
3930            &session_identity,
3931            &mut retry_buffer,
3932            &mut push_buffer,
3933            &root,
3934            &completion_frame_with_session("session-only", "session-1"),
3935        );
3936        assert_eq!(
3937            session_result,
3938            FanOutResult {
3939                matched_channels: 1,
3940                sent_frames: 1,
3941            }
3942        );
3943        assert!(retry_buffer.is_empty());
3944        assert!(push_buffer.is_empty());
3945        let session_push = writer_rx.try_recv().expect("session push queued");
3946        assert_eq!(session_push.header.ty, FrameType::Push);
3947        assert_eq!(session_push.header.channel, 1);
3948        assert!(
3949            writer_rx.try_recv().is_err(),
3950            "session-scoped frame must not broadcast to sibling sessions"
3951        );
3952
3953        let project_result =
3954            fan_out_lossy_push_frame(&writer_tx, &routes, &root_channels, &root, &status_frame(9));
3955        assert_eq!(
3956            project_result,
3957            FanOutResult {
3958                matched_channels: 2,
3959                sent_frames: 2,
3960            }
3961        );
3962        let project_channels: HashSet<_> = [
3963            writer_rx
3964                .try_recv()
3965                .expect("first project push")
3966                .header
3967                .channel,
3968            writer_rx
3969                .try_recv()
3970                .expect("second project push")
3971                .header
3972                .channel,
3973        ]
3974        .into_iter()
3975        .collect();
3976        assert_eq!(project_channels, HashSet::from([1, 2]));
3977        assert!(writer_rx.try_recv().is_err());
3978    }
3979
3980    #[test]
3981    fn push_buffer_drops_oldest_per_replay_key() {
3982        let (_root_dir, root) = test_root("subc-buffer-bound-root");
3983        let key = ReplayKey {
3984            root,
3985            harness: "opencode".to_string(),
3986            session: "session-1".to_string(),
3987        };
3988        let mut push_buffer = HashMap::new();
3989        let total = PUSH_BUFFER_MAX_PER_KEY + 3;
3990
3991        for index in 0..total {
3992            buffer_push_frame(
3993                &mut push_buffer,
3994                key.clone(),
3995                completion_frame(&format!("task-{index}")),
3996            );
3997        }
3998
3999        let buffered = push_buffer.get(&key).expect("buffer entry");
4000        assert_eq!(buffered.len(), PUSH_BUFFER_MAX_PER_KEY);
4001        let tasks: Vec<String> = buffered
4002            .iter()
4003            .filter_map(completion_task)
4004            .map(str::to_string)
4005            .collect();
4006        assert_eq!(tasks.first().map(String::as_str), Some("task-3"));
4007        assert_eq!(
4008            tasks.last().map(String::as_str),
4009            Some(format!("task-{}", total - 1).as_str())
4010        );
4011    }
4012
4013    #[test]
4014    fn replay_buffered_push_frames_drains_to_bound_channel() {
4015        let (_root_dir, root) = test_root("subc-buffer-replay-root");
4016        let key = ReplayKey {
4017            root,
4018            harness: "opencode".to_string(),
4019            session: "session-1".to_string(),
4020        };
4021        let (writer_tx, mut writer_rx) = mpsc::channel::<Frame>(4);
4022        let mut push_buffer = HashMap::new();
4023        buffer_push_frame(&mut push_buffer, key.clone(), completion_frame("task-a"));
4024        buffer_push_frame(&mut push_buffer, key.clone(), completion_frame("task-b"));
4025
4026        let replayed = replay_buffered_push_frames(
4027            &writer_tx,
4028            route_key(3),
4029            &mut push_buffer,
4030            &key,
4031            BindTrust::FirstParty,
4032        );
4033
4034        assert_eq!(replayed, 2);
4035        assert!(!push_buffer.contains_key(&key));
4036        for expected_task in ["task-a", "task-b"] {
4037            let frame = writer_rx.try_recv().expect("replayed push");
4038            assert_eq!(frame.header.ty, FrameType::Push);
4039            assert_eq!(frame.header.channel, 3);
4040            let body: serde_json::Value = serde_json::from_slice(&frame.body).expect("push body");
4041            assert_eq!(body["task_id"].as_str(), Some(expected_task));
4042        }
4043        assert!(writer_rx.try_recv().is_err());
4044    }
4045
4046    #[test]
4047    fn replay_buffered_push_frames_skips_bash_for_untrusted_route() {
4048        let (_root_dir, root) = test_root("subc-buffer-replay-untrusted-root");
4049        let key = ReplayKey {
4050            root,
4051            harness: "mcp".to_string(),
4052            session: "session-1".to_string(),
4053        };
4054        let (writer_tx, mut writer_rx) = mpsc::channel::<Frame>(4);
4055        let mut push_buffer = HashMap::new();
4056        buffer_push_frame(&mut push_buffer, key.clone(), completion_frame("task-a"));
4057
4058        let replayed = replay_buffered_push_frames(
4059            &writer_tx,
4060            route_key(3),
4061            &mut push_buffer,
4062            &key,
4063            BindTrust::Untrusted,
4064        );
4065
4066        assert_eq!(replayed, 0);
4067        assert!(!push_buffer.contains_key(&key));
4068        assert!(writer_rx.try_recv().is_err());
4069    }
4070
4071    #[test]
4072    fn coalesce_push_batch_collapses_lossy_and_preserves_reliable_fifo() {
4073        let (_root_dir, root) = test_root("subc-coalesce-root");
4074        let (_other_dir, other_root) = test_root("subc-coalesce-other");
4075
4076        let output = coalesce_push_batch(vec![
4077            (root.clone(), status_frame(1)),
4078            (root.clone(), completion_frame("task-1")),
4079            (root.clone(), status_frame(2)),
4080            (root.clone(), completion_frame("task-2")),
4081            (root.clone(), long_running_frame("long-task", 100)),
4082            (root.clone(), long_running_frame("long-task", 200)),
4083            (other_root.clone(), status_frame(9)),
4084        ]);
4085
4086        let completion_tasks: Vec<_> = output
4087            .iter()
4088            .filter_map(|(_, frame)| completion_task(frame))
4089            .collect();
4090        assert_eq!(completion_tasks, vec!["task-1", "task-2"]);
4091
4092        let root_statuses: Vec<_> = output
4093            .iter()
4094            .filter(|(output_root, _)| output_root == &root)
4095            .filter_map(|(_, frame)| status_seq(frame))
4096            .collect();
4097        assert_eq!(root_statuses, vec![2]);
4098
4099        let other_statuses: Vec<_> = output
4100            .iter()
4101            .filter(|(output_root, _)| output_root == &other_root)
4102            .filter_map(|(_, frame)| status_seq(frame))
4103            .collect();
4104        assert_eq!(other_statuses, vec![9]);
4105
4106        let long_running_elapsed: Vec<_> = output
4107            .iter()
4108            .filter_map(|(_, frame)| match frame {
4109                PushFrame::BashLongRunning(long_running) => Some(long_running.elapsed_ms),
4110                _ => None,
4111            })
4112            .collect();
4113        assert_eq!(long_running_elapsed, vec![200]);
4114    }
4115
4116    #[test]
4117    fn coalesce_push_batch_keeps_progress_stream_keys_separate() {
4118        let (_root_dir, root) = test_root("subc-progress-coalesce-root");
4119
4120        let output = coalesce_push_batch(vec![
4121            (
4122                root.clone(),
4123                progress_frame("request-1", ProgressKind::Stdout, "old stdout"),
4124            ),
4125            (
4126                root.clone(),
4127                progress_frame("request-1", ProgressKind::Stderr, "stderr"),
4128            ),
4129            (
4130                root.clone(),
4131                progress_frame("request-2", ProgressKind::Stdout, "other stdout"),
4132            ),
4133            (
4134                root.clone(),
4135                progress_frame("request-1", ProgressKind::Stdout, "new stdout"),
4136            ),
4137        ]);
4138
4139        let progress: Vec<_> = output
4140            .iter()
4141            .filter_map(|(_, frame)| match frame {
4142                PushFrame::Progress(progress) => Some((
4143                    progress.request_id.as_str(),
4144                    match progress.kind {
4145                        ProgressKind::Stdout => "stdout",
4146                        ProgressKind::Stderr => "stderr",
4147                    },
4148                    progress.chunk.as_str(),
4149                )),
4150                _ => None,
4151            })
4152            .collect();
4153
4154        assert_eq!(
4155            progress,
4156            vec![
4157                ("request-1", "stderr", "stderr"),
4158                ("request-2", "stdout", "other stdout"),
4159                ("request-1", "stdout", "new stdout"),
4160            ]
4161        );
4162    }
4163
4164    #[test]
4165    fn progress_sender_keeps_reliable_off_saturated_lossy_funnel_without_blocking() {
4166        let (_root_dir, root) = test_root("subc-push-full-root");
4167        let (lossy_tx, mut lossy_rx) = mpsc::channel::<PushEnvelope>(1);
4168        let (reliable_tx, mut reliable_rx) = mpsc::unbounded_channel::<PushEnvelope>();
4169        let sender = progress_sender_for_root(
4170            PushSenders {
4171                lossy_tx,
4172                reliable_tx,
4173            },
4174            root.clone(),
4175        );
4176
4177        let started = Instant::now();
4178        sender(status_frame(1));
4179        sender(status_frame(2));
4180        sender(completion_frame("reliable-after-lossy-full"));
4181        assert!(
4182            started.elapsed() < Duration::from_millis(50),
4183            "saturated push sender must return immediately"
4184        );
4185
4186        let (received_root, received_frame) =
4187            lossy_rx.try_recv().expect("first lossy frame queued");
4188        assert_eq!(received_root, root);
4189        assert_eq!(status_seq(&received_frame), Some(1));
4190        assert!(
4191            lossy_rx.try_recv().is_err(),
4192            "second lossy frame should be dropped"
4193        );
4194
4195        let (reliable_root, reliable_frame) = reliable_rx
4196            .try_recv()
4197            .expect("reliable frame bypasses lossy backpressure");
4198        assert_eq!(reliable_root, root);
4199        assert_eq!(
4200            completion_task(&reliable_frame),
4201            Some("reliable-after-lossy-full")
4202        );
4203        assert!(reliable_rx.try_recv().is_err());
4204    }
4205
4206    #[test]
4207    fn fan_out_lossy_push_frame_drops_when_writer_is_full_without_blocking() {
4208        let (_root_dir, root) = test_root("subc-writer-full-root");
4209        let (writer_tx, mut writer_rx) = mpsc::channel::<Frame>(1);
4210        writer_tx
4211            .try_send(Frame::build(FrameType::Ping, control_flags(), 0, 1, Vec::new()).unwrap())
4212            .expect("prefill writer queue");
4213
4214        let mut root_channels = HashMap::new();
4215        root_channels.insert(root.clone(), HashSet::from([route_key(7)]));
4216
4217        let routes = HashMap::new();
4218        let started = Instant::now();
4219        let result =
4220            fan_out_lossy_push_frame(&writer_tx, &routes, &root_channels, &root, &status_frame(1));
4221        assert!(
4222            started.elapsed() < Duration::from_millis(50),
4223            "saturated writer fan-out must return immediately"
4224        );
4225        assert_eq!(
4226            result,
4227            FanOutResult {
4228                matched_channels: 1,
4229                sent_frames: 0,
4230            }
4231        );
4232
4233        let queued = writer_rx
4234            .try_recv()
4235            .expect("prefilled frame remains queued");
4236        assert_eq!(queued.header.ty, FrameType::Ping);
4237        assert!(
4238            writer_rx.try_recv().is_err(),
4239            "push should be dropped on full writer"
4240        );
4241    }
4242
4243    #[test]
4244    fn reliable_push_backpressure_buffers_and_retries_on_tick() {
4245        let (_root_dir, root) = test_root("subc-retry-buffer-root");
4246        let identity = route_identity(&root, "session-1");
4247        let key = ReplayKey::from_identity(&identity);
4248        let mut routes = HashMap::new();
4249        routes.insert(route_key(9), identity.clone());
4250        let mut root_channels = HashMap::new();
4251        root_channels.insert(root.clone(), HashSet::from([route_key(9)]));
4252        let mut session_identity = HashMap::new();
4253        remember_session_identity(&mut session_identity, &identity);
4254        let mut retry_buffer = HashMap::new();
4255        let mut push_buffer = HashMap::new();
4256        let (writer_tx, mut writer_rx) = mpsc::channel::<Frame>(1);
4257        writer_tx
4258            .try_send(Frame::build(FrameType::Ping, control_flags(), 0, 1, Vec::new()).unwrap())
4259            .expect("prefill writer queue");
4260
4261        let result = fan_out_reliable_push_frame(
4262            &writer_tx,
4263            &routes,
4264            &root_channels,
4265            &session_identity,
4266            &mut retry_buffer,
4267            &mut push_buffer,
4268            &root,
4269            &completion_frame("retry-task"),
4270        );
4271
4272        assert_eq!(
4273            result,
4274            FanOutResult {
4275                matched_channels: 1,
4276                sent_frames: 0,
4277            }
4278        );
4279        assert!(push_buffer.is_empty());
4280        assert_eq!(retry_buffer.get(&route_key(9)).map(VecDeque::len), Some(1));
4281        assert_eq!(&retry_buffer[&route_key(9)][0].0, &key);
4282
4283        let queued = writer_rx.try_recv().expect("prefilled frame");
4284        assert_eq!(queued.header.ty, FrameType::Ping);
4285        assert_eq!(
4286            drain_retry_buffer_for_channel(&writer_tx, route_key(9), &mut retry_buffer),
4287            1
4288        );
4289        let retried = writer_rx.try_recv().expect("retried reliable push");
4290        assert_eq!(retried.header.ty, FrameType::Push);
4291        assert_eq!(retried.header.channel, 9);
4292        assert_eq!(push_frame_task_id(&retried).as_deref(), Some("retry-task"));
4293        assert!(!retry_buffer.contains_key(&route_key(9)));
4294    }
4295
4296    #[test]
4297    fn reliable_push_fifo_gates_new_frames_behind_retry_buffer() {
4298        let (_root_dir, root) = test_root("subc-retry-fifo-root");
4299        let identity = route_identity(&root, "session-1");
4300        let mut routes = HashMap::new();
4301        routes.insert(route_key(9), identity.clone());
4302        let mut root_channels = HashMap::new();
4303        root_channels.insert(root.clone(), HashSet::from([route_key(9)]));
4304        let mut session_identity = HashMap::new();
4305        remember_session_identity(&mut session_identity, &identity);
4306        let mut retry_buffer = HashMap::new();
4307        let mut push_buffer = HashMap::new();
4308        let (writer_tx, mut writer_rx) = mpsc::channel::<Frame>(1);
4309        writer_tx
4310            .try_send(Frame::build(FrameType::Ping, control_flags(), 0, 1, Vec::new()).unwrap())
4311            .expect("prefill writer queue");
4312
4313        let first = completion_frame("fifo-1");
4314        let second = completion_frame("fifo-2");
4315        let _ = fan_out_reliable_push_frame(
4316            &writer_tx,
4317            &routes,
4318            &root_channels,
4319            &session_identity,
4320            &mut retry_buffer,
4321            &mut push_buffer,
4322            &root,
4323            &first,
4324        );
4325        let queued = writer_rx.try_recv().expect("free writer capacity");
4326        assert_eq!(queued.header.ty, FrameType::Ping);
4327
4328        let _ = fan_out_reliable_push_frame(
4329            &writer_tx,
4330            &routes,
4331            &root_channels,
4332            &session_identity,
4333            &mut retry_buffer,
4334            &mut push_buffer,
4335            &root,
4336            &second,
4337        );
4338        assert!(
4339            writer_rx.try_recv().is_err(),
4340            "second reliable frame must not bypass pending retry frame"
4341        );
4342        let queued_tasks: Vec<_> = retry_buffer[&route_key(9)]
4343            .iter()
4344            .filter_map(|(_, frame)| completion_task(frame))
4345            .collect();
4346        assert_eq!(queued_tasks, vec!["fifo-1", "fifo-2"]);
4347
4348        assert_eq!(
4349            drain_retry_buffer_for_channel(&writer_tx, route_key(9), &mut retry_buffer),
4350            1
4351        );
4352        let first_sent = writer_rx.try_recv().expect("first reliable push");
4353        assert_eq!(push_frame_task_id(&first_sent).as_deref(), Some("fifo-1"));
4354        assert_eq!(
4355            drain_retry_buffer_for_channel(&writer_tx, route_key(9), &mut retry_buffer),
4356            1
4357        );
4358        let second_sent = writer_rx.try_recv().expect("second reliable push");
4359        assert_eq!(push_frame_task_id(&second_sent).as_deref(), Some("fifo-2"));
4360        assert!(!retry_buffer.contains_key(&route_key(9)));
4361    }
4362
4363    #[test]
4364    fn replay_buffered_push_frames_drains_incrementally_on_backpressure() {
4365        let (_root_dir, root) = test_root("subc-incremental-replay-root");
4366        let key = ReplayKey {
4367            root,
4368            harness: "opencode".to_string(),
4369            session: "session-1".to_string(),
4370        };
4371        let (writer_tx, mut writer_rx) = mpsc::channel::<Frame>(2);
4372        writer_tx
4373            .try_send(Frame::build(FrameType::Ping, control_flags(), 0, 1, Vec::new()).unwrap())
4374            .expect("prefill writer queue");
4375        let mut push_buffer = HashMap::new();
4376        for task in ["replay-1", "replay-2", "replay-3"] {
4377            buffer_push_frame(&mut push_buffer, key.clone(), completion_frame(task));
4378        }
4379
4380        assert_eq!(
4381            replay_buffered_push_frames(
4382                &writer_tx,
4383                route_key(4),
4384                &mut push_buffer,
4385                &key,
4386                BindTrust::FirstParty
4387            ),
4388            1
4389        );
4390        assert_eq!(push_buffer.get(&key).map(VecDeque::len), Some(2));
4391        let remaining: Vec<_> = push_buffer[&key]
4392            .iter()
4393            .filter_map(completion_task)
4394            .collect();
4395        assert_eq!(remaining, vec!["replay-2", "replay-3"]);
4396
4397        let queued = writer_rx.try_recv().expect("prefilled frame");
4398        assert_eq!(queued.header.ty, FrameType::Ping);
4399        let first = writer_rx.try_recv().expect("first replayed push");
4400        assert_eq!(push_frame_task_id(&first).as_deref(), Some("replay-1"));
4401
4402        assert_eq!(
4403            replay_buffered_push_frames(
4404                &writer_tx,
4405                route_key(4),
4406                &mut push_buffer,
4407                &key,
4408                BindTrust::FirstParty
4409            ),
4410            2
4411        );
4412        let second = writer_rx.try_recv().expect("second replayed push");
4413        let third = writer_rx.try_recv().expect("third replayed push");
4414        assert_eq!(push_frame_task_id(&second).as_deref(), Some("replay-2"));
4415        assert_eq!(push_frame_task_id(&third).as_deref(), Some("replay-3"));
4416        assert!(!push_buffer.contains_key(&key));
4417    }
4418
4419    #[test]
4420    fn goodbye_migrates_retry_buffer_into_detach_replay() {
4421        let (_root_dir, root) = test_root("subc-goodbye-migration-root");
4422        let key = ReplayKey {
4423            root,
4424            harness: "opencode".to_string(),
4425            session: "session-1".to_string(),
4426        };
4427        let mut retry_buffer = HashMap::new();
4428        buffer_retry_frame(
4429            &mut retry_buffer,
4430            route_key(5),
4431            key.clone(),
4432            completion_frame("migrated-task"),
4433        );
4434        let mut push_buffer = HashMap::new();
4435
4436        assert_eq!(
4437            migrate_retry_buffer_to_push_buffer(&mut retry_buffer, route_key(5), &mut push_buffer),
4438            1
4439        );
4440
4441        assert!(!retry_buffer.contains_key(&route_key(5)));
4442        assert_eq!(push_buffer.get(&key).map(VecDeque::len), Some(1));
4443        assert_eq!(
4444            completion_task(&push_buffer[&key][0]),
4445            Some("migrated-task")
4446        );
4447    }
4448
4449    #[test]
4450    fn permanent_push_send_failure_is_dropped_not_retried_forever() {
4451        let (_root_dir, root) = test_root("subc-permanent-failure-root");
4452        let key = ReplayKey {
4453            root,
4454            harness: "opencode".to_string(),
4455            session: "session-1".to_string(),
4456        };
4457        let (writer_tx, writer_rx) = mpsc::channel::<Frame>(1);
4458        drop(writer_rx);
4459
4460        let mut push_buffer = HashMap::new();
4461        buffer_push_frame(
4462            &mut push_buffer,
4463            key.clone(),
4464            completion_frame("closed-replay"),
4465        );
4466        assert_eq!(
4467            replay_buffered_push_frames(
4468                &writer_tx,
4469                route_key(4),
4470                &mut push_buffer,
4471                &key,
4472                BindTrust::FirstParty
4473            ),
4474            0
4475        );
4476        assert!(!push_buffer.contains_key(&key));
4477
4478        let mut retry_buffer = HashMap::new();
4479        buffer_retry_frame(
4480            &mut retry_buffer,
4481            route_key(4),
4482            key,
4483            completion_frame("closed-retry"),
4484        );
4485        assert_eq!(
4486            drain_retry_buffer_for_channel(&writer_tx, route_key(4), &mut retry_buffer),
4487            0
4488        );
4489        assert!(!retry_buffer.contains_key(&route_key(4)));
4490    }
4491
4492    #[test]
4493    fn completed_task_suppresses_stale_long_running_lossy_push() {
4494        let mut completed_tasks = CompletedTaskIds::default();
4495        assert!(!should_drop_lossy_push(
4496            &completed_tasks,
4497            &long_running_frame("stale-task", 100)
4498        ));
4499
4500        completed_tasks.remember("stale-task");
4501
4502        assert!(should_drop_lossy_push(
4503            &completed_tasks,
4504            &long_running_frame("stale-task", 200)
4505        ));
4506        assert!(!should_drop_lossy_push(
4507            &completed_tasks,
4508            &long_running_frame("other-task", 200)
4509        ));
4510    }
4511
4512    #[test]
4513    fn arm_bg_wake_bumps_epoch_even_when_channel_is_already_pending() {
4514        let (_root_dir, root) = test_root("subc-bg-wake-epoch-root");
4515        let session = "session-1".to_string();
4516        let key = (root.clone(), session.clone());
4517        let channel = route_key(7);
4518        let mut bg_wake_pending = HashSet::from([channel]);
4519        let mut bg_wake_epoch = HashMap::from([(key.clone(), 41_u64)]);
4520
4521        arm_bg_wake(
4522            root,
4523            session,
4524            channel,
4525            &mut bg_wake_pending,
4526            &mut bg_wake_epoch,
4527        );
4528
4529        assert_eq!(bg_wake_pending, HashSet::from([channel]));
4530        assert_eq!(bg_wake_epoch.get(&key).copied(), Some(42));
4531    }
4532
4533    #[test]
4534    fn stale_maintenance_epoch_does_not_clear_newer_bg_wake() {
4535        let (_root_dir, root) = test_root("subc-bg-wake-stale-root");
4536        let session = "session-1".to_string();
4537        let key = (root.clone(), session.clone());
4538        let channel = route_key(8);
4539        let mut bg_sub_by_session = HashMap::new();
4540        bg_sub_by_session.insert(key.clone(), channel);
4541        let mut bg_wake_pending = HashSet::new();
4542        let mut bg_wake_epoch = HashMap::new();
4543
4544        arm_bg_wake(
4545            root.clone(),
4546            session.clone(),
4547            channel,
4548            &mut bg_wake_pending,
4549            &mut bg_wake_epoch,
4550        );
4551        let epoch_at_submit = bg_wake_epoch[&key];
4552        arm_bg_wake(
4553            root.clone(),
4554            session.clone(),
4555            channel,
4556            &mut bg_wake_pending,
4557            &mut bg_wake_epoch,
4558        );
4559
4560        clear_stale_bg_wakes_for_empty_sessions(
4561            &root,
4562            &[(session, epoch_at_submit)],
4563            &bg_sub_by_session,
4564            &mut bg_wake_pending,
4565            &bg_wake_epoch,
4566        );
4567
4568        assert!(bg_wake_pending.contains(&channel));
4569        assert_eq!(bg_wake_epoch.get(&key).copied(), Some(epoch_at_submit + 1));
4570    }
4571
4572    #[test]
4573    fn matching_maintenance_epoch_clears_genuinely_stale_bg_wake() {
4574        let (_root_dir, root) = test_root("subc-bg-wake-clear-root");
4575        let session = "session-1".to_string();
4576        let key = (root.clone(), session.clone());
4577        let channel = route_key(9);
4578        let mut bg_sub_by_session = HashMap::new();
4579        bg_sub_by_session.insert(key.clone(), channel);
4580        let mut bg_wake_pending = HashSet::new();
4581        let mut bg_wake_epoch = HashMap::new();
4582
4583        arm_bg_wake(
4584            root.clone(),
4585            session.clone(),
4586            channel,
4587            &mut bg_wake_pending,
4588            &mut bg_wake_epoch,
4589        );
4590        let epoch_at_submit = bg_wake_epoch[&key];
4591
4592        clear_stale_bg_wakes_for_empty_sessions(
4593            &root,
4594            &[(session, epoch_at_submit)],
4595            &bg_sub_by_session,
4596            &mut bg_wake_pending,
4597            &bg_wake_epoch,
4598        );
4599
4600        assert!(!bg_wake_pending.contains(&channel));
4601    }
4602
4603    #[test]
4604    fn response_is_fatal_panic_only_matches_panic_exclusive_code() {
4605        let tool_error = Response::error("request-1", "internal_error", "ordinary tool error");
4606        let panic_error = Response::error("request-2", "actor_fatal", "mutating panic");
4607
4608        assert!(!response_is_fatal_panic(&tool_error));
4609        assert!(response_is_fatal_panic(&panic_error));
4610    }
4611
4612    #[tokio::test]
4613    async fn persistent_cancel_resolves_when_fired_before_await() {
4614        // The lost-wakeup guard: cancel() fires exactly once via notify_waiters()
4615        // (no stored permit). A waiter that registers AFTER the cancel must still
4616        // observe it via the flag; a waiter racing the cancel must still be woken.
4617        let signal = PersistentCancelSignal::new();
4618        signal.cancel();
4619        // Fired before we ever call cancelled() — must return immediately, not park.
4620        tokio::time::timeout(Duration::from_secs(1), signal.cancelled())
4621            .await
4622            .expect("cancelled() must resolve when cancel fired beforehand");
4623
4624        // A fresh signal cancelled concurrently with an in-flight cancelled().
4625        let racing = PersistentCancelSignal::new();
4626        let racing_for_task = racing.clone();
4627        let waiter = tokio::spawn(async move { racing_for_task.cancelled().await });
4628        racing.cancel();
4629        tokio::time::timeout(Duration::from_secs(1), waiter)
4630            .await
4631            .expect("cancelled() must resolve when cancel races the await")
4632            .expect("waiter task panicked");
4633    }
4634
4635    #[tokio::test]
4636    async fn control_send_times_out_when_writer_queue_remains_full() {
4637        let (writer_tx, _writer_rx) = mpsc::channel::<Frame>(1);
4638        writer_tx
4639            .try_send(Frame::build(FrameType::Ping, control_flags(), 0, 1, Vec::new()).unwrap())
4640            .expect("prefill writer queue");
4641        let started = Instant::now();
4642
4643        let result = send_frame(
4644            &writer_tx,
4645            Frame::build(FrameType::Pong, control_flags(), 0, 2, Vec::new()).unwrap(),
4646        )
4647        .await;
4648
4649        assert!(matches!(result, Err(SubcError::WriterBackpressureTimeout)));
4650        assert!(
4651            started.elapsed() < Duration::from_secs(2),
4652            "control send guard should be bounded"
4653        );
4654    }
4655
4656    const CORE_TOOLS: [&str; 21] = [
4657        "status",
4658        "bash",
4659        "read",
4660        "write",
4661        "edit",
4662        "apply_patch",
4663        "grep",
4664        "glob",
4665        "search",
4666        "outline",
4667        "zoom",
4668        "inspect",
4669        "callgraph",
4670        "conflicts",
4671        "ast_search",
4672        "ast_replace",
4673        "delete",
4674        "move",
4675        "import",
4676        "refactor",
4677        "safety",
4678    ];
4679
4680    fn is_bare_placeholder_schema(schema: &Value) -> bool {
4681        schema == &json!({ "type": "object" })
4682    }
4683
4684    #[test]
4685    fn build_manifest_serves_embedded_tool_schemas() {
4686        let manifest = build_manifest();
4687        let tools = match manifest.provides.first() {
4688            Some(ProviderRole::ToolProvider { tools, .. }) => tools,
4689            _ => panic!("expected ToolProvider"),
4690        };
4691        let by_name: HashMap<&str, &Tool> = tools.iter().map(|t| (t.name.as_str(), t)).collect();
4692        for name in CORE_TOOLS {
4693            let tool = by_name
4694                .get(name)
4695                .unwrap_or_else(|| panic!("missing tool {name}"));
4696            assert!(
4697                !is_bare_placeholder_schema(&tool.schema),
4698                "{name} must not use bare placeholder schema"
4699            );
4700            assert_eq!(
4701                tool.schema.get("type").and_then(|v| v.as_str()),
4702                Some("object"),
4703                "{name} schema must be an object"
4704            );
4705        }
4706
4707        let read = by_name["read"]
4708            .schema
4709            .get("properties")
4710            .and_then(|p| p.as_object());
4711        let read_props = read.expect("read schema properties");
4712        assert!(
4713            read_props.contains_key("filePath"),
4714            "read schema must expose filePath"
4715        );
4716
4717        let status = &by_name["status"].schema;
4718        assert_eq!(
4719            status.get("properties").and_then(|v| v.as_object()),
4720            Some(&serde_json::Map::new()),
4721            "status schema must have empty properties"
4722        );
4723        assert_eq!(
4724            status.get("additionalProperties").and_then(|v| v.as_bool()),
4725            Some(false),
4726            "status schema must forbid additionalProperties"
4727        );
4728    }
4729
4730    #[test]
4731    fn build_manifest_classifies_execution_mode_by_observable_effect() {
4732        let manifest = build_manifest();
4733        let tools = match manifest.provides.first() {
4734            Some(ProviderRole::ToolProvider { tools, .. }) => tools,
4735            _ => panic!("expected ToolProvider"),
4736        };
4737        let by_name: HashMap<&str, &Tool> = tools.iter().map(|t| (t.name.as_str(), t)).collect();
4738
4739        // Readers warm AFT's own index/cache/symbol artifacts (internal ctx
4740        // mutation), not the user's observable workspace, so they are Pure.
4741        for name in [
4742            "status",
4743            "read",
4744            "grep",
4745            "glob",
4746            "search",
4747            "outline",
4748            "zoom",
4749            "inspect",
4750            "callgraph",
4751            "conflicts",
4752            "ast_search",
4753        ] {
4754            assert_eq!(
4755                by_name[name].execution_mode,
4756                ExecutionMode::Pure,
4757                "{name} produces no observable side effect and must be Pure"
4758            );
4759        }
4760        // Mutating tools can write files, change safety state, or spawn processes.
4761        for name in [
4762            "bash",
4763            "write",
4764            "edit",
4765            "apply_patch",
4766            "ast_replace",
4767            "delete",
4768            "move",
4769            "import",
4770            "refactor",
4771            "safety",
4772        ] {
4773            assert_eq!(
4774                by_name[name].execution_mode,
4775                ExecutionMode::Mutating,
4776                "{name} writes files and must be Mutating"
4777            );
4778        }
4779    }
4780
4781    #[test]
4782    fn subc_agent_lanes_classify_new_read_tools() {
4783        assert_eq!(command_lane("callgraph"), Lane::HeavyInit);
4784        assert_eq!(command_lane("conflicts"), Lane::PureRead);
4785    }
4786
4787    #[test]
4788    fn native_plumbing_allowlist_admits_exactly_drain_and_ack() {
4789        // BC2: the route gate admits a name when it's an agent core tool OR a
4790        // native plumbing command. These two carry no agent surface and no
4791        // config/trust surface, so they're admitted to dispatch over a bound
4792        // route while everything else (notably `configure`) stays fail-closed.
4793        assert!(is_subc_native_plumbing_tool("bash_drain_completions"));
4794        assert!(is_subc_native_plumbing_tool("bash_ack_completions"));
4795
4796        // The allowlist is TIGHT — it must not admit the config-bypass vector
4797        // the fail-closed gate exists to block, nor any other native command.
4798        assert!(!is_subc_native_plumbing_tool("configure"));
4799        assert!(!is_subc_native_plumbing_tool("bash"));
4800        assert!(!is_subc_native_plumbing_tool("bash_kill"));
4801        assert!(!is_subc_native_plumbing_tool("db_set_state"));
4802        assert!(!is_subc_native_plumbing_tool("undo"));
4803
4804        // The plumbing commands are NOT agent-facing tools — they must stay out
4805        // of the manifest gate so they never reach the model surface.
4806        assert!(!is_subc_agent_core_tool("bash_drain_completions"));
4807        assert!(!is_subc_agent_core_tool("bash_ack_completions"));
4808
4809        // Lanes are already assigned (pre-existing): drain reads, ack mutates.
4810        assert_eq!(command_lane("bash_drain_completions"), Lane::PureRead);
4811        assert_eq!(command_lane("bash_ack_completions"), Lane::Mutating);
4812    }
4813
4814    #[test]
4815    fn tool_response_frame_carries_flat_standalone_shape_in_structured_content() {
4816        use crate::protocol::Response;
4817
4818        // A response with sidecars the FIRST-PARTY plugin drives UI from
4819        // (status_bar, bg_completions, code) plus a normal result field.
4820        let response = Response::success(
4821            "req-7",
4822            json!({
4823                "complete": true,
4824                "matches": 3,
4825                "status_bar": { "errors": 0, "warnings": 1 },
4826                "bg_completions": [{ "task_id": "bash-abc" }],
4827            }),
4828        );
4829        let result = ToolCallResult {
4830            text: "rendered text".to_string(),
4831            response,
4832        };
4833
4834        // The flat shape must equal the standalone NDJSON `tool_call` body:
4835        // {id, success, ...data, text}. Build the standalone expectation the
4836        // same way commands::tool_call::response_with_text does.
4837        let expected_flat = json!({
4838            "id": "req-7",
4839            "success": true,
4840            "complete": true,
4841            "matches": 3,
4842            "status_bar": { "errors": 0, "warnings": 1 },
4843            "bg_completions": [{ "task_id": "bash-abc" }],
4844            "text": "rendered text",
4845        });
4846        assert_eq!(
4847            flat_tool_response(&result.response, &result.text),
4848            expected_flat,
4849            "structuredContent must be byte-identical to the standalone flat response"
4850        );
4851
4852        // The frame body carries the MCP surface for generic hosts AND the flat
4853        // sidecar shape under structuredContent for the first-party plugin.
4854        let frame =
4855            build_tool_response_frame(PROTOCOL_VERSION, 1, 42, control_flags(), &result).unwrap();
4856        let body: Value = serde_json::from_slice(&frame.body).unwrap();
4857        assert_eq!(body["isError"], json!(false));
4858        assert_eq!(body["content"][0]["type"], json!("text"));
4859        assert_eq!(body["content"][0]["text"], json!("rendered text"));
4860        assert_eq!(body["structuredContent"], expected_flat);
4861
4862        // A failed response flips isError and still carries the flat shape
4863        // (with success:false + code) for the plugin's error path.
4864        let err = Response::error_with_data(
4865            "req-8",
4866            "ambiguous_match",
4867            "too many matches",
4868            json!({ "candidates": ["a", "b"] }),
4869        );
4870        let err_result = ToolCallResult {
4871            text: "error text".to_string(),
4872            response: err,
4873        };
4874        let err_frame =
4875            build_tool_response_frame(PROTOCOL_VERSION, 1, 43, control_flags(), &err_result)
4876                .unwrap();
4877        let err_body: Value = serde_json::from_slice(&err_frame.body).unwrap();
4878        assert_eq!(err_body["isError"], json!(true));
4879        assert_eq!(err_body["structuredContent"]["success"], json!(false));
4880        assert_eq!(
4881            err_body["structuredContent"]["code"],
4882            json!("ambiguous_match")
4883        );
4884        assert_eq!(
4885            err_body["structuredContent"]["candidates"],
4886            json!(["a", "b"])
4887        );
4888        assert_eq!(err_body["structuredContent"]["text"], json!("error text"));
4889    }
4890}
4891
4892#[derive(Debug)]
4893pub enum SubcError {
4894    Runtime(std::io::Error),
4895    ConnectionFile {
4896        path: PathBuf,
4897        source: subc_transport::ConnectionFileError,
4898    },
4899    NoEndpoint {
4900        path: PathBuf,
4901    },
4902    InvalidEndpoint {
4903        path: PathBuf,
4904        endpoint: String,
4905    },
4906    Connect {
4907        endpoint: String,
4908        source: std::io::Error,
4909    },
4910    Auth {
4911        endpoint: String,
4912        source: subc_transport::AuthError,
4913    },
4914    FrameIo(subc_transport::FrameIoError),
4915    FrameBuild(subc_protocol::FrameBuildError),
4916    WriterClosed,
4917    WriterBackpressureTimeout,
4918    WriterJoin(tokio::task::JoinError),
4919    Json(serde_json::Error),
4920    ClosedBeforeHelloAck,
4921    HelloRejected {
4922        body: Option<ErrorBody>,
4923    },
4924    UnexpectedFrame {
4925        ty: FrameType,
4926    },
4927}
4928
4929impl fmt::Display for SubcError {
4930    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4931        match self {
4932            Self::Runtime(e) => write!(f, "failed to build subc tokio runtime: {e}"),
4933            Self::ConnectionFile { path, source } => {
4934                write!(f, "failed to read subc connection file {path:?}: {source}")
4935            }
4936            Self::NoEndpoint { path } => {
4937                write!(f, "subc connection file {path:?} has no endpoints")
4938            }
4939            Self::InvalidEndpoint { path, endpoint } => {
4940                write!(
4941                    f,
4942                    "subc connection file {path:?} has invalid endpoint {endpoint}"
4943                )
4944            }
4945            Self::Connect { endpoint, source } => {
4946                write!(f, "failed to connect to subc endpoint {endpoint}: {source}")
4947            }
4948            Self::Auth { endpoint, source } => {
4949                write!(
4950                    f,
4951                    "failed to authenticate to subc endpoint {endpoint}: {source}"
4952                )
4953            }
4954            Self::FrameIo(e) => write!(f, "subc frame I/O error: {e}"),
4955            Self::FrameBuild(e) => write!(f, "subc frame build error: {e}"),
4956            Self::WriterClosed => write!(f, "subc writer task closed"),
4957            Self::WriterBackpressureTimeout => write!(
4958                f,
4959                "subc writer task stayed backpressured while sending a control frame"
4960            ),
4961            Self::WriterJoin(e) => write!(f, "subc writer task join error: {e}"),
4962            Self::Json(e) => write!(f, "subc JSON error: {e}"),
4963            Self::ClosedBeforeHelloAck => {
4964                write!(f, "subc daemon closed the connection before HelloAck")
4965            }
4966            Self::HelloRejected { body } => match body {
4967                Some(b) => write!(f, "subc rejected ModuleHello: {} ({})", b.code, b.message),
4968                None => write!(f, "subc rejected ModuleHello (unparseable error body)"),
4969            },
4970            Self::UnexpectedFrame { ty } => {
4971                write!(f, "subc sent unexpected frame in place of HelloAck: {ty:?}")
4972            }
4973        }
4974    }
4975}
4976
4977impl std::error::Error for SubcError {}