1use 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
49const AUTH_DEADLINE: Duration = Duration::from_secs(5);
53
54const HELLO_CORR: u64 = 1;
56
57const PUSH_BUFFER_MAX_PER_KEY: usize = 256;
60
61const CONTROL_SEND_TIMEOUT: Duration = Duration::from_millis(250);
65
66const COMPLETED_TASK_SUPPRESSION_MAX: usize = 4096;
69
70const 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 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)]
209struct 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 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 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 matched_channels: usize,
584 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
715fn 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
1088pub type DispatchFn = fn(RawRequest, &AppContext) -> Response;
1091
1092pub 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 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#[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
1181async 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
1224async 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 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 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 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 _ => {}
1474 }
1475 }
1476 Some((root_id, frame)) = reliable_rx.recv() => {
1477 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 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 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 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
1693fn 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 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
1904async 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 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 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 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 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 ®istry_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
2307async 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 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 block_to_completion: bool,
2602 timeout: Option<u64>,
2603}
2604
2605enum BashSpawnControl {
2606 Immediate,
2607 Foreground {
2608 task_id: String,
2609 session_id: String,
2610 project_root: Option<PathBuf>,
2611 storage_dir: PathBuf,
2612 deadline: Instant,
2613 block_to_completion: bool,
2614 timeout: Option<u64>,
2615 wait_window_ms: u64,
2616 },
2617}
2618
2619enum BashPollControl {
2620 Done,
2621 Promote,
2622 Wait,
2623}
2624
2625fn bash_settings_from_translated(args: &serde_json::Map<String, Value>) -> BashTranslatedSettings {
2626 BashTranslatedSettings {
2627 background: args
2628 .get("background")
2629 .and_then(Value::as_bool)
2630 .unwrap_or(false),
2631 pty: args.get("pty").and_then(Value::as_bool).unwrap_or(false),
2632 block_to_completion: args
2633 .get("block_to_completion")
2634 .and_then(Value::as_bool)
2635 .unwrap_or(false),
2636 timeout: args.get("timeout").and_then(Value::as_u64),
2637 }
2638}
2639
2640fn finalized_bash_result(
2641 mut response: Response,
2642 ctx: &AppContext,
2643 session_id: &str,
2644 format_context: &crate::subc_format::FormatContext,
2645 allow_bg_completions: bool,
2646) -> ToolCallResult {
2647 crate::response_finalize::finalize_response_with_bg_completions(
2648 &mut response,
2649 ctx,
2650 session_id,
2651 "bash",
2652 allow_bg_completions,
2653 );
2654 bash_result_from_response(response, format_context)
2655}
2656
2657fn bash_result_from_response(
2658 response: Response,
2659 format_context: &crate::subc_format::FormatContext,
2660) -> ToolCallResult {
2661 let text = crate::subc_format::format_response_with_context("bash", &response, format_context);
2662 ToolCallResult { text, response }
2663}
2664
2665fn bash_background_launch_response(request_id: &str, task_id: &str, is_pty: bool) -> Response {
2666 Response::success(
2667 request_id,
2668 json!({
2669 "output": crate::commands::bash_orchestrate::format_background_launch(task_id, is_pty),
2670 "task_id": task_id,
2671 "status": "running",
2672 "mode": if is_pty { "pty" } else { "pipes" },
2673 }),
2674 )
2675}
2676
2677fn finish_bash_spawn_immediate(
2678 response: Response,
2679 ctx: &AppContext,
2680 session_id: &str,
2681 format_context: &crate::subc_format::FormatContext,
2682 text_tx: &mut Option<oneshot::Sender<String>>,
2683 control_tx: &mut Option<oneshot::Sender<BashSpawnControl>>,
2684 allow_bg_completions: bool,
2685) -> Response {
2686 let result = finalized_bash_result(
2687 response,
2688 ctx,
2689 session_id,
2690 format_context,
2691 allow_bg_completions,
2692 );
2693 let ToolCallResult { text, response } = result;
2694 if let Some(tx) = text_tx.take() {
2695 let _ = tx.send(text);
2696 }
2697 if let Some(tx) = control_tx.take() {
2698 let _ = tx.send(BashSpawnControl::Immediate);
2699 }
2700 response
2701}
2702
2703fn finish_bash_poll_done(
2704 response: Response,
2705 ctx: &AppContext,
2706 session_id: &str,
2707 format_context: &crate::subc_format::FormatContext,
2708 text_tx: &mut Option<oneshot::Sender<String>>,
2709 control_tx: &mut Option<oneshot::Sender<BashPollControl>>,
2710) -> Response {
2711 let result = finalized_bash_result(response, ctx, session_id, format_context, true);
2712 let ToolCallResult { text, response } = result;
2713 if let Some(tx) = text_tx.take() {
2714 let _ = tx.send(text);
2715 }
2716 if let Some(tx) = control_tx.take() {
2717 let _ = tx.send(BashPollControl::Done);
2718 }
2719 response
2720}
2721
2722#[allow(clippy::too_many_arguments)]
2723fn submit_deferred_bash(
2724 executor: &Arc<Executor>,
2725 completion_tx: &mpsc::Sender<BashDeferredCompletion>,
2726 poll_touch_tx: &mpsc::Sender<ProjectRootId>,
2727 dispatch: DispatchFn,
2728 root: ProjectRootId,
2729 project_root: PathBuf,
2730 session_id: String,
2731 request_id: String,
2732 route_channel: u16,
2733 corr: u64,
2734 flags: Flags,
2735 ver: u8,
2736 arguments: Value,
2737 format_context: crate::subc_format::FormatContext,
2738 cancel: BashWaitCancel,
2739 bind_trust: BindTrust,
2740) {
2741 let (spawn_control_tx, spawn_control_rx) = oneshot::channel::<BashSpawnControl>();
2742 let (spawn_text_tx, spawn_text_rx) = oneshot::channel::<String>();
2743 let root_for_spawn = root.clone();
2744 let request_id_for_spawn = request_id.clone();
2745 let session_for_spawn = session_id.clone();
2746 let project_root_for_spawn = project_root.clone();
2747 let format_context_for_spawn = format_context.clone();
2748 let spawn_rx = executor.submit_async(
2749 root_for_spawn,
2750 Lane::Mutating,
2751 request_id.clone(),
2752 Box::new(move |ctx| {
2753 log_ctx::with_session(Some(session_for_spawn.clone()), || {
2754 let mut spawn_text_tx = Some(spawn_text_tx);
2755 let mut spawn_control_tx = Some(spawn_control_tx);
2756
2757 if matches!(bind_trust, BindTrust::Untrusted) {
2758 let response = bash_denied_untrusted_response(request_id_for_spawn.clone());
2759 return finish_bash_spawn_immediate(
2760 response,
2761 ctx,
2762 &session_for_spawn,
2763 &format_context_for_spawn,
2764 &mut spawn_text_tx,
2765 &mut spawn_control_tx,
2766 false,
2767 );
2768 }
2769
2770 let translated = match crate::subc_translate::subc_translate(
2771 "bash",
2772 &arguments,
2773 &project_root_for_spawn,
2774 ) {
2775 Ok(translated) => translated,
2776 Err(error) => {
2777 let response = Response::error(
2778 request_id_for_spawn.clone(),
2779 error.code,
2780 error.message,
2781 );
2782 return finish_bash_spawn_immediate(
2783 response,
2784 ctx,
2785 &session_for_spawn,
2786 &format_context_for_spawn,
2787 &mut spawn_text_tx,
2788 &mut spawn_control_tx,
2789 true,
2790 );
2791 }
2792 };
2793 let settings = bash_settings_from_translated(&translated.args);
2794 let raw_req = RawRequest {
2795 id: request_id_for_spawn.clone(),
2796 command: "bash".to_string(),
2797 lsp_hints: None,
2798 session_id: Some(session_for_spawn.clone()),
2799 params: Value::Object(translated.args),
2800 };
2801 let response = dispatch(raw_req, ctx);
2802 if !response.success {
2803 return finish_bash_spawn_immediate(
2804 response,
2805 ctx,
2806 &session_for_spawn,
2807 &format_context_for_spawn,
2808 &mut spawn_text_tx,
2809 &mut spawn_control_tx,
2810 true,
2811 );
2812 }
2813
2814 let Some(task_id) = response
2815 .data
2816 .get("task_id")
2817 .and_then(Value::as_str)
2818 .map(str::to_string)
2819 else {
2820 return finish_bash_spawn_immediate(
2821 response,
2822 ctx,
2823 &session_for_spawn,
2824 &format_context_for_spawn,
2825 &mut spawn_text_tx,
2826 &mut spawn_control_tx,
2827 true,
2828 );
2829 };
2830 if response.data.get("status").and_then(Value::as_str) != Some("running") {
2831 return finish_bash_spawn_immediate(
2832 response,
2833 ctx,
2834 &session_for_spawn,
2835 &format_context_for_spawn,
2836 &mut spawn_text_tx,
2837 &mut spawn_control_tx,
2838 true,
2839 );
2840 }
2841
2842 let mode = response
2843 .data
2844 .get("mode")
2845 .and_then(Value::as_str)
2846 .unwrap_or("pipes");
2847 let is_pty = mode == "pty" || settings.pty;
2848 if is_pty || settings.background {
2849 let response =
2850 bash_background_launch_response(&request_id_for_spawn, &task_id, is_pty);
2851 return finish_bash_spawn_immediate(
2852 response,
2853 ctx,
2854 &session_for_spawn,
2855 &format_context_for_spawn,
2856 &mut spawn_text_tx,
2857 &mut spawn_control_tx,
2858 true,
2859 );
2860 }
2861
2862 let wait_window_ms =
2863 crate::commands::bash_orchestrate::resolve_foreground_wait_window_ms(
2864 ctx.config().foreground_wait_window_ms,
2865 );
2866 let deadline = Instant::now() + Duration::from_millis(wait_window_ms);
2867 let storage_dir =
2868 crate::bash_background::storage_dir(ctx.config().storage_dir.as_deref());
2869 let project_root = ctx.config().project_root.clone();
2870 if let Some(tx) = spawn_control_tx.take() {
2871 let _ = tx.send(BashSpawnControl::Foreground {
2872 task_id,
2873 session_id: session_for_spawn.clone(),
2874 project_root,
2875 storage_dir,
2876 deadline,
2877 block_to_completion: settings.block_to_completion,
2878 timeout: settings.timeout,
2879 wait_window_ms,
2880 });
2881 }
2882 response
2883 })
2884 }),
2885 );
2886
2887 let executor = Arc::clone(executor);
2888 let completion_tx = completion_tx.clone();
2889 let poll_touch_tx = poll_touch_tx.clone();
2890 let root_for_task = root.clone();
2891 tokio::spawn(async move {
2892 let spawn_response = await_executor_response(spawn_rx, request_id.clone()).await;
2893 let spawn_control = spawn_control_rx.await;
2894 match spawn_control {
2895 Ok(BashSpawnControl::Immediate) => {
2896 let text = spawn_text_rx.await.unwrap_or_else(|_| {
2897 crate::subc_format::format_response_with_context(
2898 "bash",
2899 &spawn_response,
2900 &format_context,
2901 )
2902 });
2903 let result = ToolCallResult {
2904 text,
2905 response: spawn_response,
2906 };
2907 let fatal = response_is_fatal_panic(&result.response);
2908 send_bash_deferred_completion(
2909 &completion_tx,
2910 route_channel,
2911 corr,
2912 flags,
2913 ver,
2914 root_for_task,
2915 request_id,
2916 Some(result),
2917 fatal,
2918 )
2919 .await;
2920 }
2921 Ok(BashSpawnControl::Foreground {
2922 task_id,
2923 session_id,
2924 project_root,
2925 storage_dir,
2926 deadline,
2927 block_to_completion,
2928 timeout,
2929 wait_window_ms,
2930 }) => {
2931 run_deferred_bash_wait(
2932 executor,
2933 completion_tx,
2934 poll_touch_tx,
2935 route_channel,
2936 corr,
2937 flags,
2938 ver,
2939 root_for_task,
2940 request_id,
2941 task_id,
2942 session_id,
2943 project_root,
2944 storage_dir,
2945 deadline,
2946 block_to_completion,
2947 timeout,
2948 wait_window_ms,
2949 format_context,
2950 cancel,
2951 )
2952 .await;
2953 }
2954 Err(_) => {
2955 let result = bash_result_from_response(spawn_response, &format_context);
2956 let fatal = response_is_fatal_panic(&result.response);
2957 send_bash_deferred_completion(
2958 &completion_tx,
2959 route_channel,
2960 corr,
2961 flags,
2962 ver,
2963 root_for_task,
2964 request_id,
2965 Some(result),
2966 fatal,
2967 )
2968 .await;
2969 }
2970 }
2971 });
2972}
2973
2974#[allow(clippy::too_many_arguments)]
2975async fn run_deferred_bash_wait(
2976 executor: Arc<Executor>,
2977 completion_tx: mpsc::Sender<BashDeferredCompletion>,
2978 poll_touch_tx: mpsc::Sender<ProjectRootId>,
2979 route_channel: u16,
2980 corr: u64,
2981 flags: Flags,
2982 ver: u8,
2983 root: ProjectRootId,
2984 request_id: String,
2985 task_id: String,
2986 session_id: String,
2987 project_root: Option<PathBuf>,
2988 storage_dir: PathBuf,
2989 deadline: Instant,
2990 block_to_completion: bool,
2991 timeout: Option<u64>,
2992 wait_window_ms: u64,
2993 format_context: crate::subc_format::FormatContext,
2994 cancel: BashWaitCancel,
2995) {
2996 loop {
2997 tokio::select! {
2998 _ = cancel.cancelled() => {
2999 send_bash_deferred_completion(
3000 &completion_tx,
3001 route_channel,
3002 corr,
3003 flags,
3004 ver,
3005 root,
3006 request_id,
3007 None,
3008 false,
3009 )
3010 .await;
3011 break;
3012 }
3013 _ = tokio::time::sleep(PENDING_POLL_INTERVAL) => {
3014 let (poll_control_tx, poll_control_rx) = oneshot::channel::<BashPollControl>();
3015 let (poll_text_tx, poll_text_rx) = oneshot::channel::<String>();
3016 let root_for_poll = root.clone();
3017 let request_id_for_poll = request_id.clone();
3018 let task_id_for_poll = task_id.clone();
3019 let session_for_poll = session_id.clone();
3020 let storage_for_poll = storage_dir.clone();
3021 let project_root_for_poll = project_root.clone();
3022 let format_context_for_poll = format_context.clone();
3023 let poll_rx = executor.submit_async(
3024 root_for_poll,
3025 Lane::PureRead,
3026 request_id.clone(),
3027 Box::new(move |ctx| {
3028 log_ctx::with_session(Some(session_for_poll.clone()), || {
3029 let mut poll_text_tx = Some(poll_text_tx);
3030 let mut poll_control_tx = Some(poll_control_tx);
3031
3032 let Some(snapshot) = crate::commands::bash_orchestrate::poll_bash_status(
3033 ctx,
3034 &task_id_for_poll,
3035 &session_for_poll,
3036 project_root_for_poll.as_deref(),
3037 &storage_for_poll,
3038 crate::bash_background::output::RUNNING_OUTPUT_PREVIEW_BYTES,
3039 ) else {
3040 return finish_bash_poll_done(
3041 crate::commands::bash_orchestrate::task_not_found_response(
3042 &request_id_for_poll,
3043 &task_id_for_poll,
3044 ),
3045 ctx,
3046 &session_for_poll,
3047 &format_context_for_poll,
3048 &mut poll_text_tx,
3049 &mut poll_control_tx,
3050 );
3051 };
3052
3053 match crate::commands::bash_orchestrate::decide_bash_step(
3054 snapshot,
3055 deadline,
3056 block_to_completion,
3057 Instant::now(),
3058 &request_id_for_poll,
3059 ) {
3060 crate::commands::bash_orchestrate::BashStep::Done(response) => {
3061 finish_bash_poll_done(
3062 response,
3063 ctx,
3064 &session_for_poll,
3065 &format_context_for_poll,
3066 &mut poll_text_tx,
3067 &mut poll_control_tx,
3068 )
3069 }
3070 crate::commands::bash_orchestrate::BashStep::Promote => {
3071 if let Some(tx) = poll_control_tx.take() {
3072 let _ = tx.send(BashPollControl::Promote);
3073 }
3074 Response::success(
3075 request_id_for_poll,
3076 json!({ "subc_bash_step": "promote" }),
3077 )
3078 }
3079 crate::commands::bash_orchestrate::BashStep::Wait => {
3080 if let Some(tx) = poll_control_tx.take() {
3081 let _ = tx.send(BashPollControl::Wait);
3082 }
3083 Response::success(
3084 request_id_for_poll,
3085 json!({ "subc_bash_step": "wait" }),
3086 )
3087 }
3088 }
3089 })
3090 }),
3091 );
3092 let poll_response = await_executor_response(poll_rx, request_id.clone()).await;
3093 let _ = poll_touch_tx.send(root.clone()).await;
3094 match poll_control_rx.await.unwrap_or(BashPollControl::Done) {
3095 BashPollControl::Done => {
3096 let text = poll_text_rx.await.unwrap_or_else(|_| {
3097 crate::subc_format::format_response_with_context(
3098 "bash",
3099 &poll_response,
3100 &format_context,
3101 )
3102 });
3103 let result = ToolCallResult {
3104 text,
3105 response: poll_response,
3106 };
3107 let fatal = response_is_fatal_panic(&result.response);
3108 send_bash_deferred_completion(
3109 &completion_tx,
3110 route_channel,
3111 corr,
3112 flags,
3113 ver,
3114 root,
3115 request_id,
3116 Some(result),
3117 fatal,
3118 )
3119 .await;
3120 break;
3121 }
3122 BashPollControl::Promote => {
3123 let result = submit_bash_promote(
3124 &executor,
3125 root.clone(),
3126 request_id.clone(),
3127 task_id.clone(),
3128 session_id.clone(),
3129 timeout,
3130 wait_window_ms,
3131 format_context.clone(),
3132 )
3133 .await;
3134 let fatal = response_is_fatal_panic(&result.response);
3135 send_bash_deferred_completion(
3136 &completion_tx,
3137 route_channel,
3138 corr,
3139 flags,
3140 ver,
3141 root,
3142 request_id,
3143 Some(result),
3144 fatal,
3145 )
3146 .await;
3147 break;
3148 }
3149 BashPollControl::Wait => {}
3150 }
3151 }
3152 }
3153 }
3154}
3155
3156async fn submit_bash_promote(
3157 executor: &Arc<Executor>,
3158 root: ProjectRootId,
3159 request_id: String,
3160 task_id: String,
3161 session_id: String,
3162 timeout: Option<u64>,
3163 wait_window_ms: u64,
3164 format_context: crate::subc_format::FormatContext,
3165) -> ToolCallResult {
3166 let (text_tx, text_rx) = oneshot::channel::<String>();
3167 let request_id_for_promote = request_id.clone();
3168 let task_id_for_promote = task_id.clone();
3169 let session_for_promote = session_id.clone();
3170 let format_context_for_promote = format_context.clone();
3171 let promote_rx = executor.submit_async(
3172 root,
3173 Lane::Mutating,
3174 request_id.clone(),
3175 Box::new(move |ctx| {
3176 log_ctx::with_session(Some(session_for_promote.clone()), || {
3177 let response = if let Some(value) =
3178 std::env::var_os("AFT_TEST_FORCE_SUBC_BASH_PROMOTE_ERROR")
3179 {
3180 if value.to_string_lossy() == "panic" {
3181 panic!("forced subc bash promote panic");
3182 }
3183 Response::error(
3184 &request_id_for_promote,
3185 "execution_failed",
3186 "forced subc bash promote failure",
3187 )
3188 } else {
3189 crate::commands::bash_orchestrate::promote_bash(
3190 ctx,
3191 &task_id_for_promote,
3192 &session_for_promote,
3193 ctx.config().project_root.as_deref(),
3194 timeout,
3195 wait_window_ms,
3196 &request_id_for_promote,
3197 )
3198 };
3199 let result = finalized_bash_result(
3200 response,
3201 ctx,
3202 &session_for_promote,
3203 &format_context_for_promote,
3204 true,
3205 );
3206 let ToolCallResult { text, response } = result;
3207 let _ = text_tx.send(text);
3208 response
3209 })
3210 }),
3211 );
3212 let response = await_executor_response(promote_rx, request_id).await;
3213 let text = text_rx.await.unwrap_or_else(|_| {
3214 crate::subc_format::format_response_with_context("bash", &response, &format_context)
3215 });
3216 ToolCallResult { text, response }
3217}
3218
3219#[allow(clippy::too_many_arguments)]
3220async fn send_bash_deferred_completion(
3221 completion_tx: &mpsc::Sender<BashDeferredCompletion>,
3222 channel: u16,
3223 corr: u64,
3224 flags: Flags,
3225 ver: u8,
3226 root: ProjectRootId,
3227 request_id: String,
3228 result: Option<ToolCallResult>,
3229 fatal: bool,
3230) {
3231 let _ = completion_tx
3232 .send(BashDeferredCompletion {
3233 channel,
3234 corr,
3235 flags,
3236 ver,
3237 root,
3238 request_id,
3239 result,
3240 fatal,
3241 })
3242 .await;
3243}
3244
3245async fn handle_bash_deferred_completion(
3246 tx: &mpsc::Sender<Frame>,
3247 done: BashDeferredCompletion,
3248 routes: &HashMap<RouteChannel, RouteIdentity>,
3249 live_roots: &mut HashMap<ProjectRootId, RootMeta>,
3250 route_bash_cancels: &mut HashMap<RouteChannel, RouteBashCancel>,
3251 shutdown: &Arc<Notify>,
3252) -> Result<(), SubcError> {
3253 if let Some(meta) = live_roots.get_mut(&done.root) {
3254 meta.active_bash_waits = meta.active_bash_waits.saturating_sub(1);
3255 meta.touch();
3256 }
3257 let route_id = route_key(done.channel);
3258 let remove_route_cancel = if let Some(cancel) = route_bash_cancels.get_mut(&route_id) {
3259 cancel.active_waits = cancel.active_waits.saturating_sub(1);
3260 cancel.active_waits == 0
3261 } else {
3262 false
3263 };
3264 if remove_route_cancel {
3265 route_bash_cancels.remove(&route_id);
3266 }
3267
3268 if let Some(result) = done.result {
3269 if routes.contains_key(&route_id) {
3270 let frame =
3271 build_tool_response_frame(done.ver, done.channel, done.corr, done.flags, &result)?;
3272 send_frame(tx, frame).await?;
3273 } else {
3274 log::debug!(
3275 "subc attach: dropping deferred bash response {} for unbound route {}",
3276 done.request_id,
3277 done.channel
3278 );
3279 }
3280 } else {
3281 log::debug!(
3282 "subc attach: deferred bash wait {} cancelled before delivery on route {}",
3283 done.request_id,
3284 done.channel
3285 );
3286 }
3287
3288 if done.fatal {
3289 signal_fatal_teardown(tx, Some(done.channel), done.ver, done.corr, shutdown).await;
3290 }
3291 Ok(())
3292}
3293fn submit_maintenance_drain(
3294 executor: &Arc<Executor>,
3295 root_id: ProjectRootId,
3296 bg_sessions_to_check: Vec<(String, u64)>,
3297 completion_tx: &mpsc::Sender<MaintenanceCompletion>,
3298) {
3299 let request_id = format!(
3300 "subc-maintenance-drain-{}",
3301 root_id.as_path().to_string_lossy()
3302 );
3303 let response_id = request_id.clone();
3304 let completion_root_id = root_id.clone();
3305 let (empty_bg_sessions_tx, empty_bg_sessions_rx) = oneshot::channel::<Vec<(String, u64)>>();
3306 let rx = executor.submit_async(
3307 root_id,
3308 Lane::Mutating,
3309 request_id.clone(),
3310 Box::new(move |ctx| {
3311 runtime_drain::drain_configure_warning_events(ctx);
3312 runtime_drain::drain_search_index_events(ctx);
3313 runtime_drain::drain_callgraph_store_events(ctx);
3314 runtime_drain::drain_semantic_index_events(ctx);
3315 runtime_drain::drain_semantic_refresh_events(ctx);
3316 runtime_drain::drain_inspect_events(ctx);
3317 runtime_drain::drain_watcher_events(ctx);
3318 runtime_drain::drain_lsp_events(ctx);
3319 let empty_bg_sessions = bg_sessions_to_check
3320 .into_iter()
3321 .filter(|(session, _)| {
3322 !ctx.bash_background()
3323 .has_completions_for_session(Some(session.as_str()))
3324 })
3325 .collect();
3326 let _ = empty_bg_sessions_tx.send(empty_bg_sessions);
3327 Response::success(response_id, json!({ "drained": true }))
3328 }),
3329 );
3330 let completion_tx = completion_tx.clone();
3331 tokio::spawn(async move {
3332 let response = await_executor_response(rx, request_id).await;
3333 let empty_bg_sessions = empty_bg_sessions_rx.await.unwrap_or_default();
3334 let _ = completion_tx
3335 .send(MaintenanceCompletion {
3336 root_id: completion_root_id,
3337 response,
3338 empty_bg_sessions,
3339 })
3340 .await;
3341 });
3342}
3343
3344async fn await_executor_response(rx: oneshot::Receiver<Response>, request_id: String) -> Response {
3345 rx.await
3346 .unwrap_or_else(|_| Response::error(request_id, "internal_error", "executor dropped"))
3347}
3348
3349fn flat_tool_response(response: &crate::protocol::Response, text: &str) -> Value {
3359 let mut obj = serde_json::Map::new();
3360 obj.insert("id".to_string(), Value::String(response.id.clone()));
3361 obj.insert("success".to_string(), Value::Bool(response.success));
3362 if let Some(data) = response.data.as_object() {
3363 for (key, value) in data {
3364 obj.insert(key.clone(), value.clone());
3365 }
3366 }
3367 obj.insert("text".to_string(), Value::String(text.to_string()));
3368 Value::Object(obj)
3369}
3370
3371fn build_tool_response_frame(
3372 ver: u8,
3373 route_channel: u16,
3374 corr: u64,
3375 flags: Flags,
3376 result: &ToolCallResult,
3377) -> Result<Frame, SubcError> {
3378 let is_error = !result.response.success;
3379 let payload = json!({
3390 "content": [{ "type": "text", "text": result.text.as_str() }],
3391 "isError": is_error,
3392 "structuredContent": flat_tool_response(&result.response, &result.text),
3393 });
3394 let body = serde_json::to_vec(&payload).map_err(SubcError::Json)?;
3395
3396 Frame::build_with_version(ver, FrameType::Response, flags, route_channel, corr, body)
3397 .map_err(SubcError::FrameBuild)
3398}
3399
3400fn build_error_frame(
3401 ver: u8,
3402 channel: u16,
3403 corr: u64,
3404 flags: Flags,
3405 code: &str,
3406 message: &str,
3407) -> Result<Frame, SubcError> {
3408 let body = serde_json::to_vec(&ErrorBody {
3409 code: code.to_string(),
3410 message: message.to_string(),
3411 })
3412 .map_err(SubcError::Json)?;
3413 Frame::build_with_version(ver, FrameType::Error, flags, channel, corr, body)
3414 .map_err(SubcError::FrameBuild)
3415}
3416
3417fn build_goodbye_frame(ver: u8, channel: u16, corr: u64) -> Result<Frame, SubcError> {
3418 Frame::build_with_version(
3419 ver,
3420 FrameType::Goodbye,
3421 control_flags(),
3422 channel,
3423 corr,
3424 Vec::new(),
3425 )
3426 .map_err(SubcError::FrameBuild)
3427}
3428
3429async fn signal_fatal_teardown(
3430 tx: &mpsc::Sender<Frame>,
3431 route_channel: Option<u16>,
3432 ver: u8,
3433 corr: u64,
3434 shutdown: &Arc<Notify>,
3435) {
3436 if let Some(route_channel) = route_channel {
3437 if let Ok(frame) = build_goodbye_frame(ver, route_channel, corr) {
3438 if let Err(error) = send_frame(tx, frame).await {
3439 log::warn!(
3440 "subc attach: failed to queue fatal route Goodbye for route {route_channel}: {error}"
3441 );
3442 }
3443 }
3444 }
3445 if let Ok(frame) = build_goodbye_frame(ver, 0, 0) {
3446 if let Err(error) = send_frame(tx, frame).await {
3447 log::warn!("subc attach: failed to queue fatal channel-0 Goodbye: {error}");
3448 }
3449 }
3450 shutdown.notify_one();
3451}
3452
3453fn response_message(response: &Response, fallback: &str) -> String {
3454 response
3455 .data
3456 .get("message")
3457 .and_then(Value::as_str)
3458 .map(ToOwned::to_owned)
3459 .unwrap_or_else(|| fallback.to_string())
3460}
3461
3462fn response_is_fatal_panic(response: &Response) -> bool {
3463 !response.success && response.data.get("code").and_then(Value::as_str) == Some("actor_fatal")
3464}
3465
3466fn bash_denied_untrusted_response(request_id: impl Into<String>) -> Response {
3467 Response::error(
3468 request_id.into(),
3469 "bash_denied_untrusted",
3470 "remote/MCP-facade binds cannot run shell commands",
3471 )
3472}
3473
3474fn is_bash_family_tool(name: &str) -> bool {
3475 name == "bash" || name.starts_with("bash_")
3476}
3477
3478fn is_subc_agent_core_tool(name: &str) -> bool {
3479 matches!(
3480 name,
3481 "status"
3482 | "bash"
3483 | "read"
3484 | "write"
3485 | "edit"
3486 | "apply_patch"
3487 | "grep"
3488 | "glob"
3489 | "search"
3490 | "outline"
3491 | "zoom"
3492 | "inspect"
3493 | "callgraph"
3494 | "conflicts"
3495 | "ast_search"
3496 | "ast_replace"
3497 | "delete"
3498 | "move"
3499 | "import"
3500 | "refactor"
3501 | "safety"
3502 )
3503}
3504
3505fn is_subc_native_plumbing_tool(name: &str) -> bool {
3523 matches!(name, "bash_drain_completions" | "bash_ack_completions")
3524}
3525
3526fn command_lane(command: &str) -> Lane {
3527 match command {
3528 "ping"
3529 | "version"
3530 | "echo"
3531 | "bash_drain_completions"
3532 | "bash_regex_match"
3533 | "db_get_state"
3534 | "db_get_host_state"
3535 | "read"
3536 | "undo_preview"
3537 | "edit_history"
3538 | "checkpoint_paths"
3539 | "list_checkpoints"
3540 | "conflicts"
3541 | "glob"
3542 | "grep"
3543 | "git_conflicts"
3544 | "ast_search" => Lane::PureRead,
3545
3546 "bash_status" | "outline" | "zoom" => Lane::PureRead,
3550
3551 "status"
3552 | "inspect"
3553 | "lsp_diagnostics"
3554 | "lsp_inspect"
3555 | "lsp_hover"
3556 | "lsp_goto_definition"
3557 | "lsp_find_references"
3558 | "lsp_prepare_rename" => Lane::SerialLspStatus,
3559
3560 "semantic_search" | "search" | "callgraph" | "callers" | "impact" | "call_tree"
3561 | "trace_to" | "trace_to_symbol" | "trace_data" | "inspect_tier2_run" => Lane::HeavyInit,
3562
3563 "bash"
3564 | "bash_ack_completions"
3565 | "bash_notify"
3566 | "bash_unnotify"
3567 | "bash_promote"
3568 | "bash_kill"
3569 | "bash_write"
3570 | "db_set_state"
3571 | "db_set_host_state"
3572 | "undo"
3573 | "checkpoint"
3574 | "restore_checkpoint"
3575 | "write"
3576 | "delete_file"
3577 | "move_file"
3578 | "edit"
3579 | "edit_symbol"
3580 | "edit_match"
3581 | "batch"
3582 | "add_import"
3583 | "remove_import"
3584 | "organize_imports"
3585 | "configure"
3586 | "move_symbol"
3587 | "extract_function"
3588 | "inline_symbol"
3589 | "ast_replace"
3590 | "lsp_rename"
3591 | "list_filters"
3592 | "trust_filter_project"
3593 | "untrust_filter_project"
3594 | "snapshot" => Lane::Mutating,
3595
3596 _ => Lane::Mutating,
3597 }
3598}
3599
3600#[derive(Deserialize)]
3601struct BgEventsProbe {
3602 op: Option<String>,
3603}
3604
3605#[derive(Debug, Deserialize)]
3606struct ToolCallRequest {
3607 name: String,
3608 #[serde(default)]
3609 arguments: Value,
3610}
3611
3612static SUBC_TOOL_SCHEMAS: LazyLock<serde_json::Map<String, Value>> = LazyLock::new(|| {
3613 serde_json::from_str(include_str!("subc_tool_schemas.json"))
3614 .unwrap_or_else(|e| panic!("subc_tool_schemas.json: {e}"))
3615});
3616
3617fn tool_schema(name: &str) -> Value {
3618 SUBC_TOOL_SCHEMAS.get(name).cloned().unwrap_or_else(|| {
3619 log::warn!(
3620 "subc build_manifest: missing embedded schema for tool {name:?}; using placeholder"
3621 );
3622 json!({ "type": "object" })
3623 })
3624}
3625
3626fn build_manifest() -> ModuleManifest {
3631 let tool = |name: &str, execution_mode: ExecutionMode| Tool {
3632 name: name.to_string(),
3633 execution_mode,
3634 schema: tool_schema(name),
3635 };
3636 ModuleManifest {
3643 module_id: "aft".to_string(),
3644 module_version: env!("CARGO_PKG_VERSION").to_string(),
3645 protocol_ver: PROTOCOL_VERSION,
3646 trust_tier: TrustTier::FirstParty,
3647 provides: vec![ProviderRole::ToolProvider {
3648 tools: vec![
3649 tool("status", ExecutionMode::Pure),
3650 tool("bash", ExecutionMode::Mutating),
3651 tool("read", ExecutionMode::Pure),
3652 tool("write", ExecutionMode::Mutating),
3653 tool("edit", ExecutionMode::Mutating),
3654 tool("apply_patch", ExecutionMode::Mutating),
3655 tool("grep", ExecutionMode::Pure),
3656 tool("glob", ExecutionMode::Pure),
3657 tool("search", ExecutionMode::Pure),
3658 tool("outline", ExecutionMode::Pure),
3659 tool("zoom", ExecutionMode::Pure),
3660 tool("inspect", ExecutionMode::Pure),
3661 tool("callgraph", ExecutionMode::Pure),
3662 tool("conflicts", ExecutionMode::Pure),
3663 tool("ast_search", ExecutionMode::Pure),
3664 tool("ast_replace", ExecutionMode::Mutating),
3665 tool("delete", ExecutionMode::Mutating),
3666 tool("move", ExecutionMode::Mutating),
3667 tool("import", ExecutionMode::Mutating),
3668 tool("refactor", ExecutionMode::Mutating),
3669 tool("safety", ExecutionMode::Mutating),
3670 ],
3671 identity_scope: vec![IdentityScope::Session, IdentityScope::Project],
3672 concurrency: Concurrency::ModuleManaged,
3673 emits_push: true,
3674 sub_supervises: true,
3675 }],
3676 consumes: Vec::new(),
3677 scheduled_tasks: Vec::new(),
3678 bindings: Bindings {
3679 storage: StorageBinding {
3680 kind: StorageKind::Sqlite,
3681 scope: StorageScope::Project,
3682 owns_schema: true,
3683 },
3684 vault_grants: Vec::new(),
3685 identity: IdentityBinding {
3686 requires: vec![IdentityScope::Project],
3687 optional: vec![IdentityScope::Session],
3688 },
3689 },
3690 }
3691}
3692
3693fn control_flags() -> Flags {
3694 Flags::new(false, Priority::Passive, false)
3695}
3696
3697#[cfg(test)]
3698mod tests {
3699 use super::*;
3700 use crate::bash_background::BgTaskStatus;
3701 use crate::protocol::{
3702 BashCompletedFrame, BashLongRunningFrame, BashPatternMatchFrame, ConfigureWarningsFrame,
3703 ProgressFrame, StatusChangedFrame,
3704 };
3705 use serde_json::json;
3706
3707 fn test_root(name: &str) -> (tempfile::TempDir, ProjectRootId) {
3708 let dir = tempfile::Builder::new()
3709 .prefix(name)
3710 .tempdir()
3711 .expect("temp root");
3712 let root = ProjectRootId::from_path(dir.path()).expect("project root id");
3713 (dir, root)
3714 }
3715
3716 fn status_frame(seq: u64) -> PushFrame {
3717 status_frame_with_session(seq, None)
3718 }
3719
3720 fn status_frame_with_session(seq: u64, session_id: Option<&str>) -> PushFrame {
3721 PushFrame::StatusChanged(StatusChangedFrame {
3722 frame_type: "status_changed",
3723 session_id: session_id.map(str::to_string),
3724 snapshot: json!({ "seq": seq }),
3725 })
3726 }
3727
3728 fn completion_frame(task_id: &str) -> PushFrame {
3729 completion_frame_with_session(task_id, "session-1")
3730 }
3731
3732 fn completion_frame_with_session(task_id: &str, session_id: &str) -> PushFrame {
3733 PushFrame::BashCompleted(BashCompletedFrame {
3734 frame_type: "bash_completed",
3735 task_id: task_id.to_string(),
3736 session_id: session_id.to_string(),
3737 status: BgTaskStatus::Completed,
3738 exit_code: Some(0),
3739 command: format!("echo {task_id}"),
3740 output_preview: String::new(),
3741 output_truncated: false,
3742 original_tokens: None,
3743 compressed_tokens: None,
3744 tokens_skipped: false,
3745 })
3746 }
3747
3748 fn long_running_frame(task_id: &str, elapsed_ms: u64) -> PushFrame {
3749 long_running_frame_with_session(task_id, "session-1", elapsed_ms)
3750 }
3751
3752 fn long_running_frame_with_session(
3753 task_id: &str,
3754 session_id: &str,
3755 elapsed_ms: u64,
3756 ) -> PushFrame {
3757 PushFrame::BashLongRunning(BashLongRunningFrame {
3758 frame_type: "bash_long_running",
3759 task_id: task_id.to_string(),
3760 session_id: session_id.to_string(),
3761 command: format!("sleep {elapsed_ms}"),
3762 elapsed_ms,
3763 })
3764 }
3765
3766 fn pattern_match_frame(session_id: &str) -> PushFrame {
3767 PushFrame::BashPatternMatch(BashPatternMatchFrame {
3768 frame_type: "bash_pattern_match",
3769 task_id: "task-pattern".to_string(),
3770 session_id: session_id.to_string(),
3771 watch_id: "watch-1".to_string(),
3772 match_text: "needle".to_string(),
3773 match_offset: 7,
3774 context: "haystack needle".to_string(),
3775 once: true,
3776 reason: "pattern_match",
3777 })
3778 }
3779
3780 fn configure_warnings_frame(session_id: Option<&str>) -> PushFrame {
3781 PushFrame::ConfigureWarnings(ConfigureWarningsFrame {
3782 frame_type: "configure_warnings",
3783 session_id: session_id.map(str::to_string),
3784 project_root: "/tmp/subc-test".to_string(),
3785 source_file_count: 0,
3786 warnings: Vec::new(),
3787 })
3788 }
3789
3790 fn route_identity(root: &ProjectRootId, session_id: &str) -> RouteIdentity {
3791 route_identity_with_trust(root, session_id, BindTrust::FirstParty)
3792 }
3793
3794 fn route_identity_with_trust(
3795 root: &ProjectRootId,
3796 session_id: &str,
3797 trust: BindTrust,
3798 ) -> RouteIdentity {
3799 RouteIdentity {
3800 root: root.clone(),
3801 project_root: root.as_path().to_path_buf(),
3802 harness: "opencode".to_string(),
3803 session: session_id.to_string(),
3804 trust,
3805 }
3806 }
3807
3808 fn progress_frame(request_id: &str, kind: ProgressKind, chunk: &str) -> PushFrame {
3809 PushFrame::Progress(ProgressFrame::new(request_id, kind, chunk))
3810 }
3811
3812 fn status_seq(frame: &PushFrame) -> Option<u64> {
3813 match frame {
3814 PushFrame::StatusChanged(status) => status.snapshot.get("seq").and_then(|v| v.as_u64()),
3815 _ => None,
3816 }
3817 }
3818
3819 fn completion_task(frame: &PushFrame) -> Option<&str> {
3820 match frame {
3821 PushFrame::BashCompleted(completion) => Some(completion.task_id.as_str()),
3822 _ => None,
3823 }
3824 }
3825
3826 fn push_frame_task_id(frame: &Frame) -> Option<String> {
3827 let body: serde_json::Value = serde_json::from_slice(&frame.body).expect("push body");
3828 body.get("task_id")
3829 .and_then(serde_json::Value::as_str)
3830 .map(str::to_string)
3831 }
3832
3833 #[test]
3834 fn trust_for_principal_matrix() {
3835 assert_eq!(
3836 trust_for_principal(&Some(Principal::Direct)),
3837 BindTrust::FirstParty
3838 );
3839 assert_eq!(
3840 trust_for_principal(&Some(Principal::Reserved {
3841 module_id: "llm-runner".to_string(),
3842 })),
3843 BindTrust::FirstParty
3844 );
3845 assert_eq!(
3846 trust_for_principal(&Some(Principal::Reserved {
3847 module_id: "aft".to_string(),
3848 })),
3849 BindTrust::FirstParty
3850 );
3851 assert_eq!(
3852 trust_for_principal(&Some(Principal::Reserved {
3853 module_id: "subc-mcp".to_string(),
3854 })),
3855 BindTrust::Untrusted
3856 );
3857 assert_eq!(
3858 trust_for_principal(&Some(Principal::Reserved {
3859 module_id: "anything-unknown".to_string(),
3860 })),
3861 BindTrust::Untrusted
3862 );
3863 assert_eq!(
3864 trust_for_principal(&Some(Principal::Unverified)),
3865 BindTrust::Untrusted
3866 );
3867 assert_eq!(trust_for_principal(&None), BindTrust::Untrusted);
3868 }
3869
3870 #[test]
3871 fn frame_classification_matches_push_delivery_contract() {
3872 let completion = completion_frame_with_session("done", "session-a");
3873 assert_eq!(frame_session(&completion), Some("session-a"));
3874 assert!(frame_is_reliable(&completion));
3875
3876 let long_running = long_running_frame_with_session("long", "session-b", 42);
3877 assert_eq!(frame_session(&long_running), Some("session-b"));
3878 assert!(!frame_is_reliable(&long_running));
3879
3880 let pattern_match = pattern_match_frame("session-c");
3881 assert_eq!(frame_session(&pattern_match), Some("session-c"));
3882 assert!(frame_is_reliable(&pattern_match));
3883
3884 let tagged_warnings = configure_warnings_frame(Some("session-d"));
3885 assert_eq!(frame_session(&tagged_warnings), Some("session-d"));
3886 assert!(frame_is_reliable(&tagged_warnings));
3887
3888 let untagged_warnings = configure_warnings_frame(None);
3889 assert_eq!(frame_session(&untagged_warnings), None);
3890 assert!(frame_is_reliable(&untagged_warnings));
3891
3892 let tagged_status = status_frame_with_session(1, Some("session-e"));
3893 assert_eq!(frame_session(&tagged_status), Some("session-e"));
3894 assert!(!frame_is_reliable(&tagged_status));
3895
3896 let project_status = status_frame(2);
3897 assert_eq!(frame_session(&project_status), None);
3898 assert!(!frame_is_reliable(&project_status));
3899
3900 let progress = progress_frame("request-1", ProgressKind::Stdout, "chunk");
3901 assert_eq!(frame_session(&progress), None);
3902 assert!(!frame_is_reliable(&progress));
3903 }
3904
3905 #[test]
3906 fn fan_out_push_frame_routes_session_scoped_and_project_scoped_frames() {
3907 let (_root_dir, root) = test_root("subc-session-routing-root");
3908 let (writer_tx, mut writer_rx) = mpsc::channel::<Frame>(8);
3909 let identity1 = route_identity(&root, "session-1");
3910 let identity2 = route_identity(&root, "session-2");
3911 let mut routes = HashMap::new();
3912 routes.insert(route_key(1), identity1.clone());
3913 routes.insert(route_key(2), identity2.clone());
3914 let mut root_channels = HashMap::new();
3915 root_channels.insert(root.clone(), HashSet::from([route_key(1), route_key(2)]));
3916 let mut session_identity = HashMap::new();
3917 remember_session_identity(&mut session_identity, &identity1);
3918 remember_session_identity(&mut session_identity, &identity2);
3919 let mut retry_buffer = HashMap::new();
3920 let mut push_buffer = HashMap::new();
3921
3922 let session_result = fan_out_reliable_push_frame(
3923 &writer_tx,
3924 &routes,
3925 &root_channels,
3926 &session_identity,
3927 &mut retry_buffer,
3928 &mut push_buffer,
3929 &root,
3930 &completion_frame_with_session("session-only", "session-1"),
3931 );
3932 assert_eq!(
3933 session_result,
3934 FanOutResult {
3935 matched_channels: 1,
3936 sent_frames: 1,
3937 }
3938 );
3939 assert!(retry_buffer.is_empty());
3940 assert!(push_buffer.is_empty());
3941 let session_push = writer_rx.try_recv().expect("session push queued");
3942 assert_eq!(session_push.header.ty, FrameType::Push);
3943 assert_eq!(session_push.header.channel, 1);
3944 assert!(
3945 writer_rx.try_recv().is_err(),
3946 "session-scoped frame must not broadcast to sibling sessions"
3947 );
3948
3949 let project_result =
3950 fan_out_lossy_push_frame(&writer_tx, &routes, &root_channels, &root, &status_frame(9));
3951 assert_eq!(
3952 project_result,
3953 FanOutResult {
3954 matched_channels: 2,
3955 sent_frames: 2,
3956 }
3957 );
3958 let project_channels: HashSet<_> = [
3959 writer_rx
3960 .try_recv()
3961 .expect("first project push")
3962 .header
3963 .channel,
3964 writer_rx
3965 .try_recv()
3966 .expect("second project push")
3967 .header
3968 .channel,
3969 ]
3970 .into_iter()
3971 .collect();
3972 assert_eq!(project_channels, HashSet::from([1, 2]));
3973 assert!(writer_rx.try_recv().is_err());
3974 }
3975
3976 #[test]
3977 fn push_buffer_drops_oldest_per_replay_key() {
3978 let (_root_dir, root) = test_root("subc-buffer-bound-root");
3979 let key = ReplayKey {
3980 root,
3981 harness: "opencode".to_string(),
3982 session: "session-1".to_string(),
3983 };
3984 let mut push_buffer = HashMap::new();
3985 let total = PUSH_BUFFER_MAX_PER_KEY + 3;
3986
3987 for index in 0..total {
3988 buffer_push_frame(
3989 &mut push_buffer,
3990 key.clone(),
3991 completion_frame(&format!("task-{index}")),
3992 );
3993 }
3994
3995 let buffered = push_buffer.get(&key).expect("buffer entry");
3996 assert_eq!(buffered.len(), PUSH_BUFFER_MAX_PER_KEY);
3997 let tasks: Vec<String> = buffered
3998 .iter()
3999 .filter_map(completion_task)
4000 .map(str::to_string)
4001 .collect();
4002 assert_eq!(tasks.first().map(String::as_str), Some("task-3"));
4003 assert_eq!(
4004 tasks.last().map(String::as_str),
4005 Some(format!("task-{}", total - 1).as_str())
4006 );
4007 }
4008
4009 #[test]
4010 fn replay_buffered_push_frames_drains_to_bound_channel() {
4011 let (_root_dir, root) = test_root("subc-buffer-replay-root");
4012 let key = ReplayKey {
4013 root,
4014 harness: "opencode".to_string(),
4015 session: "session-1".to_string(),
4016 };
4017 let (writer_tx, mut writer_rx) = mpsc::channel::<Frame>(4);
4018 let mut push_buffer = HashMap::new();
4019 buffer_push_frame(&mut push_buffer, key.clone(), completion_frame("task-a"));
4020 buffer_push_frame(&mut push_buffer, key.clone(), completion_frame("task-b"));
4021
4022 let replayed = replay_buffered_push_frames(
4023 &writer_tx,
4024 route_key(3),
4025 &mut push_buffer,
4026 &key,
4027 BindTrust::FirstParty,
4028 );
4029
4030 assert_eq!(replayed, 2);
4031 assert!(!push_buffer.contains_key(&key));
4032 for expected_task in ["task-a", "task-b"] {
4033 let frame = writer_rx.try_recv().expect("replayed push");
4034 assert_eq!(frame.header.ty, FrameType::Push);
4035 assert_eq!(frame.header.channel, 3);
4036 let body: serde_json::Value = serde_json::from_slice(&frame.body).expect("push body");
4037 assert_eq!(body["task_id"].as_str(), Some(expected_task));
4038 }
4039 assert!(writer_rx.try_recv().is_err());
4040 }
4041
4042 #[test]
4043 fn replay_buffered_push_frames_skips_bash_for_untrusted_route() {
4044 let (_root_dir, root) = test_root("subc-buffer-replay-untrusted-root");
4045 let key = ReplayKey {
4046 root,
4047 harness: "mcp".to_string(),
4048 session: "session-1".to_string(),
4049 };
4050 let (writer_tx, mut writer_rx) = mpsc::channel::<Frame>(4);
4051 let mut push_buffer = HashMap::new();
4052 buffer_push_frame(&mut push_buffer, key.clone(), completion_frame("task-a"));
4053
4054 let replayed = replay_buffered_push_frames(
4055 &writer_tx,
4056 route_key(3),
4057 &mut push_buffer,
4058 &key,
4059 BindTrust::Untrusted,
4060 );
4061
4062 assert_eq!(replayed, 0);
4063 assert!(!push_buffer.contains_key(&key));
4064 assert!(writer_rx.try_recv().is_err());
4065 }
4066
4067 #[test]
4068 fn coalesce_push_batch_collapses_lossy_and_preserves_reliable_fifo() {
4069 let (_root_dir, root) = test_root("subc-coalesce-root");
4070 let (_other_dir, other_root) = test_root("subc-coalesce-other");
4071
4072 let output = coalesce_push_batch(vec![
4073 (root.clone(), status_frame(1)),
4074 (root.clone(), completion_frame("task-1")),
4075 (root.clone(), status_frame(2)),
4076 (root.clone(), completion_frame("task-2")),
4077 (root.clone(), long_running_frame("long-task", 100)),
4078 (root.clone(), long_running_frame("long-task", 200)),
4079 (other_root.clone(), status_frame(9)),
4080 ]);
4081
4082 let completion_tasks: Vec<_> = output
4083 .iter()
4084 .filter_map(|(_, frame)| completion_task(frame))
4085 .collect();
4086 assert_eq!(completion_tasks, vec!["task-1", "task-2"]);
4087
4088 let root_statuses: Vec<_> = output
4089 .iter()
4090 .filter(|(output_root, _)| output_root == &root)
4091 .filter_map(|(_, frame)| status_seq(frame))
4092 .collect();
4093 assert_eq!(root_statuses, vec![2]);
4094
4095 let other_statuses: Vec<_> = output
4096 .iter()
4097 .filter(|(output_root, _)| output_root == &other_root)
4098 .filter_map(|(_, frame)| status_seq(frame))
4099 .collect();
4100 assert_eq!(other_statuses, vec![9]);
4101
4102 let long_running_elapsed: Vec<_> = output
4103 .iter()
4104 .filter_map(|(_, frame)| match frame {
4105 PushFrame::BashLongRunning(long_running) => Some(long_running.elapsed_ms),
4106 _ => None,
4107 })
4108 .collect();
4109 assert_eq!(long_running_elapsed, vec![200]);
4110 }
4111
4112 #[test]
4113 fn coalesce_push_batch_keeps_progress_stream_keys_separate() {
4114 let (_root_dir, root) = test_root("subc-progress-coalesce-root");
4115
4116 let output = coalesce_push_batch(vec![
4117 (
4118 root.clone(),
4119 progress_frame("request-1", ProgressKind::Stdout, "old stdout"),
4120 ),
4121 (
4122 root.clone(),
4123 progress_frame("request-1", ProgressKind::Stderr, "stderr"),
4124 ),
4125 (
4126 root.clone(),
4127 progress_frame("request-2", ProgressKind::Stdout, "other stdout"),
4128 ),
4129 (
4130 root.clone(),
4131 progress_frame("request-1", ProgressKind::Stdout, "new stdout"),
4132 ),
4133 ]);
4134
4135 let progress: Vec<_> = output
4136 .iter()
4137 .filter_map(|(_, frame)| match frame {
4138 PushFrame::Progress(progress) => Some((
4139 progress.request_id.as_str(),
4140 match progress.kind {
4141 ProgressKind::Stdout => "stdout",
4142 ProgressKind::Stderr => "stderr",
4143 },
4144 progress.chunk.as_str(),
4145 )),
4146 _ => None,
4147 })
4148 .collect();
4149
4150 assert_eq!(
4151 progress,
4152 vec![
4153 ("request-1", "stderr", "stderr"),
4154 ("request-2", "stdout", "other stdout"),
4155 ("request-1", "stdout", "new stdout"),
4156 ]
4157 );
4158 }
4159
4160 #[test]
4161 fn progress_sender_keeps_reliable_off_saturated_lossy_funnel_without_blocking() {
4162 let (_root_dir, root) = test_root("subc-push-full-root");
4163 let (lossy_tx, mut lossy_rx) = mpsc::channel::<PushEnvelope>(1);
4164 let (reliable_tx, mut reliable_rx) = mpsc::unbounded_channel::<PushEnvelope>();
4165 let sender = progress_sender_for_root(
4166 PushSenders {
4167 lossy_tx,
4168 reliable_tx,
4169 },
4170 root.clone(),
4171 );
4172
4173 let started = Instant::now();
4174 sender(status_frame(1));
4175 sender(status_frame(2));
4176 sender(completion_frame("reliable-after-lossy-full"));
4177 assert!(
4178 started.elapsed() < Duration::from_millis(50),
4179 "saturated push sender must return immediately"
4180 );
4181
4182 let (received_root, received_frame) =
4183 lossy_rx.try_recv().expect("first lossy frame queued");
4184 assert_eq!(received_root, root);
4185 assert_eq!(status_seq(&received_frame), Some(1));
4186 assert!(
4187 lossy_rx.try_recv().is_err(),
4188 "second lossy frame should be dropped"
4189 );
4190
4191 let (reliable_root, reliable_frame) = reliable_rx
4192 .try_recv()
4193 .expect("reliable frame bypasses lossy backpressure");
4194 assert_eq!(reliable_root, root);
4195 assert_eq!(
4196 completion_task(&reliable_frame),
4197 Some("reliable-after-lossy-full")
4198 );
4199 assert!(reliable_rx.try_recv().is_err());
4200 }
4201
4202 #[test]
4203 fn fan_out_lossy_push_frame_drops_when_writer_is_full_without_blocking() {
4204 let (_root_dir, root) = test_root("subc-writer-full-root");
4205 let (writer_tx, mut writer_rx) = mpsc::channel::<Frame>(1);
4206 writer_tx
4207 .try_send(Frame::build(FrameType::Ping, control_flags(), 0, 1, Vec::new()).unwrap())
4208 .expect("prefill writer queue");
4209
4210 let mut root_channels = HashMap::new();
4211 root_channels.insert(root.clone(), HashSet::from([route_key(7)]));
4212
4213 let routes = HashMap::new();
4214 let started = Instant::now();
4215 let result =
4216 fan_out_lossy_push_frame(&writer_tx, &routes, &root_channels, &root, &status_frame(1));
4217 assert!(
4218 started.elapsed() < Duration::from_millis(50),
4219 "saturated writer fan-out must return immediately"
4220 );
4221 assert_eq!(
4222 result,
4223 FanOutResult {
4224 matched_channels: 1,
4225 sent_frames: 0,
4226 }
4227 );
4228
4229 let queued = writer_rx
4230 .try_recv()
4231 .expect("prefilled frame remains queued");
4232 assert_eq!(queued.header.ty, FrameType::Ping);
4233 assert!(
4234 writer_rx.try_recv().is_err(),
4235 "push should be dropped on full writer"
4236 );
4237 }
4238
4239 #[test]
4240 fn reliable_push_backpressure_buffers_and_retries_on_tick() {
4241 let (_root_dir, root) = test_root("subc-retry-buffer-root");
4242 let identity = route_identity(&root, "session-1");
4243 let key = ReplayKey::from_identity(&identity);
4244 let mut routes = HashMap::new();
4245 routes.insert(route_key(9), identity.clone());
4246 let mut root_channels = HashMap::new();
4247 root_channels.insert(root.clone(), HashSet::from([route_key(9)]));
4248 let mut session_identity = HashMap::new();
4249 remember_session_identity(&mut session_identity, &identity);
4250 let mut retry_buffer = HashMap::new();
4251 let mut push_buffer = HashMap::new();
4252 let (writer_tx, mut writer_rx) = mpsc::channel::<Frame>(1);
4253 writer_tx
4254 .try_send(Frame::build(FrameType::Ping, control_flags(), 0, 1, Vec::new()).unwrap())
4255 .expect("prefill writer queue");
4256
4257 let result = fan_out_reliable_push_frame(
4258 &writer_tx,
4259 &routes,
4260 &root_channels,
4261 &session_identity,
4262 &mut retry_buffer,
4263 &mut push_buffer,
4264 &root,
4265 &completion_frame("retry-task"),
4266 );
4267
4268 assert_eq!(
4269 result,
4270 FanOutResult {
4271 matched_channels: 1,
4272 sent_frames: 0,
4273 }
4274 );
4275 assert!(push_buffer.is_empty());
4276 assert_eq!(retry_buffer.get(&route_key(9)).map(VecDeque::len), Some(1));
4277 assert_eq!(&retry_buffer[&route_key(9)][0].0, &key);
4278
4279 let queued = writer_rx.try_recv().expect("prefilled frame");
4280 assert_eq!(queued.header.ty, FrameType::Ping);
4281 assert_eq!(
4282 drain_retry_buffer_for_channel(&writer_tx, route_key(9), &mut retry_buffer),
4283 1
4284 );
4285 let retried = writer_rx.try_recv().expect("retried reliable push");
4286 assert_eq!(retried.header.ty, FrameType::Push);
4287 assert_eq!(retried.header.channel, 9);
4288 assert_eq!(push_frame_task_id(&retried).as_deref(), Some("retry-task"));
4289 assert!(!retry_buffer.contains_key(&route_key(9)));
4290 }
4291
4292 #[test]
4293 fn reliable_push_fifo_gates_new_frames_behind_retry_buffer() {
4294 let (_root_dir, root) = test_root("subc-retry-fifo-root");
4295 let identity = route_identity(&root, "session-1");
4296 let mut routes = HashMap::new();
4297 routes.insert(route_key(9), identity.clone());
4298 let mut root_channels = HashMap::new();
4299 root_channels.insert(root.clone(), HashSet::from([route_key(9)]));
4300 let mut session_identity = HashMap::new();
4301 remember_session_identity(&mut session_identity, &identity);
4302 let mut retry_buffer = HashMap::new();
4303 let mut push_buffer = HashMap::new();
4304 let (writer_tx, mut writer_rx) = mpsc::channel::<Frame>(1);
4305 writer_tx
4306 .try_send(Frame::build(FrameType::Ping, control_flags(), 0, 1, Vec::new()).unwrap())
4307 .expect("prefill writer queue");
4308
4309 let first = completion_frame("fifo-1");
4310 let second = completion_frame("fifo-2");
4311 let _ = fan_out_reliable_push_frame(
4312 &writer_tx,
4313 &routes,
4314 &root_channels,
4315 &session_identity,
4316 &mut retry_buffer,
4317 &mut push_buffer,
4318 &root,
4319 &first,
4320 );
4321 let queued = writer_rx.try_recv().expect("free writer capacity");
4322 assert_eq!(queued.header.ty, FrameType::Ping);
4323
4324 let _ = fan_out_reliable_push_frame(
4325 &writer_tx,
4326 &routes,
4327 &root_channels,
4328 &session_identity,
4329 &mut retry_buffer,
4330 &mut push_buffer,
4331 &root,
4332 &second,
4333 );
4334 assert!(
4335 writer_rx.try_recv().is_err(),
4336 "second reliable frame must not bypass pending retry frame"
4337 );
4338 let queued_tasks: Vec<_> = retry_buffer[&route_key(9)]
4339 .iter()
4340 .filter_map(|(_, frame)| completion_task(frame))
4341 .collect();
4342 assert_eq!(queued_tasks, vec!["fifo-1", "fifo-2"]);
4343
4344 assert_eq!(
4345 drain_retry_buffer_for_channel(&writer_tx, route_key(9), &mut retry_buffer),
4346 1
4347 );
4348 let first_sent = writer_rx.try_recv().expect("first reliable push");
4349 assert_eq!(push_frame_task_id(&first_sent).as_deref(), Some("fifo-1"));
4350 assert_eq!(
4351 drain_retry_buffer_for_channel(&writer_tx, route_key(9), &mut retry_buffer),
4352 1
4353 );
4354 let second_sent = writer_rx.try_recv().expect("second reliable push");
4355 assert_eq!(push_frame_task_id(&second_sent).as_deref(), Some("fifo-2"));
4356 assert!(!retry_buffer.contains_key(&route_key(9)));
4357 }
4358
4359 #[test]
4360 fn replay_buffered_push_frames_drains_incrementally_on_backpressure() {
4361 let (_root_dir, root) = test_root("subc-incremental-replay-root");
4362 let key = ReplayKey {
4363 root,
4364 harness: "opencode".to_string(),
4365 session: "session-1".to_string(),
4366 };
4367 let (writer_tx, mut writer_rx) = mpsc::channel::<Frame>(2);
4368 writer_tx
4369 .try_send(Frame::build(FrameType::Ping, control_flags(), 0, 1, Vec::new()).unwrap())
4370 .expect("prefill writer queue");
4371 let mut push_buffer = HashMap::new();
4372 for task in ["replay-1", "replay-2", "replay-3"] {
4373 buffer_push_frame(&mut push_buffer, key.clone(), completion_frame(task));
4374 }
4375
4376 assert_eq!(
4377 replay_buffered_push_frames(
4378 &writer_tx,
4379 route_key(4),
4380 &mut push_buffer,
4381 &key,
4382 BindTrust::FirstParty
4383 ),
4384 1
4385 );
4386 assert_eq!(push_buffer.get(&key).map(VecDeque::len), Some(2));
4387 let remaining: Vec<_> = push_buffer[&key]
4388 .iter()
4389 .filter_map(completion_task)
4390 .collect();
4391 assert_eq!(remaining, vec!["replay-2", "replay-3"]);
4392
4393 let queued = writer_rx.try_recv().expect("prefilled frame");
4394 assert_eq!(queued.header.ty, FrameType::Ping);
4395 let first = writer_rx.try_recv().expect("first replayed push");
4396 assert_eq!(push_frame_task_id(&first).as_deref(), Some("replay-1"));
4397
4398 assert_eq!(
4399 replay_buffered_push_frames(
4400 &writer_tx,
4401 route_key(4),
4402 &mut push_buffer,
4403 &key,
4404 BindTrust::FirstParty
4405 ),
4406 2
4407 );
4408 let second = writer_rx.try_recv().expect("second replayed push");
4409 let third = writer_rx.try_recv().expect("third replayed push");
4410 assert_eq!(push_frame_task_id(&second).as_deref(), Some("replay-2"));
4411 assert_eq!(push_frame_task_id(&third).as_deref(), Some("replay-3"));
4412 assert!(!push_buffer.contains_key(&key));
4413 }
4414
4415 #[test]
4416 fn goodbye_migrates_retry_buffer_into_detach_replay() {
4417 let (_root_dir, root) = test_root("subc-goodbye-migration-root");
4418 let key = ReplayKey {
4419 root,
4420 harness: "opencode".to_string(),
4421 session: "session-1".to_string(),
4422 };
4423 let mut retry_buffer = HashMap::new();
4424 buffer_retry_frame(
4425 &mut retry_buffer,
4426 route_key(5),
4427 key.clone(),
4428 completion_frame("migrated-task"),
4429 );
4430 let mut push_buffer = HashMap::new();
4431
4432 assert_eq!(
4433 migrate_retry_buffer_to_push_buffer(&mut retry_buffer, route_key(5), &mut push_buffer),
4434 1
4435 );
4436
4437 assert!(!retry_buffer.contains_key(&route_key(5)));
4438 assert_eq!(push_buffer.get(&key).map(VecDeque::len), Some(1));
4439 assert_eq!(
4440 completion_task(&push_buffer[&key][0]),
4441 Some("migrated-task")
4442 );
4443 }
4444
4445 #[test]
4446 fn permanent_push_send_failure_is_dropped_not_retried_forever() {
4447 let (_root_dir, root) = test_root("subc-permanent-failure-root");
4448 let key = ReplayKey {
4449 root,
4450 harness: "opencode".to_string(),
4451 session: "session-1".to_string(),
4452 };
4453 let (writer_tx, writer_rx) = mpsc::channel::<Frame>(1);
4454 drop(writer_rx);
4455
4456 let mut push_buffer = HashMap::new();
4457 buffer_push_frame(
4458 &mut push_buffer,
4459 key.clone(),
4460 completion_frame("closed-replay"),
4461 );
4462 assert_eq!(
4463 replay_buffered_push_frames(
4464 &writer_tx,
4465 route_key(4),
4466 &mut push_buffer,
4467 &key,
4468 BindTrust::FirstParty
4469 ),
4470 0
4471 );
4472 assert!(!push_buffer.contains_key(&key));
4473
4474 let mut retry_buffer = HashMap::new();
4475 buffer_retry_frame(
4476 &mut retry_buffer,
4477 route_key(4),
4478 key,
4479 completion_frame("closed-retry"),
4480 );
4481 assert_eq!(
4482 drain_retry_buffer_for_channel(&writer_tx, route_key(4), &mut retry_buffer),
4483 0
4484 );
4485 assert!(!retry_buffer.contains_key(&route_key(4)));
4486 }
4487
4488 #[test]
4489 fn completed_task_suppresses_stale_long_running_lossy_push() {
4490 let mut completed_tasks = CompletedTaskIds::default();
4491 assert!(!should_drop_lossy_push(
4492 &completed_tasks,
4493 &long_running_frame("stale-task", 100)
4494 ));
4495
4496 completed_tasks.remember("stale-task");
4497
4498 assert!(should_drop_lossy_push(
4499 &completed_tasks,
4500 &long_running_frame("stale-task", 200)
4501 ));
4502 assert!(!should_drop_lossy_push(
4503 &completed_tasks,
4504 &long_running_frame("other-task", 200)
4505 ));
4506 }
4507
4508 #[test]
4509 fn arm_bg_wake_bumps_epoch_even_when_channel_is_already_pending() {
4510 let (_root_dir, root) = test_root("subc-bg-wake-epoch-root");
4511 let session = "session-1".to_string();
4512 let key = (root.clone(), session.clone());
4513 let channel = route_key(7);
4514 let mut bg_wake_pending = HashSet::from([channel]);
4515 let mut bg_wake_epoch = HashMap::from([(key.clone(), 41_u64)]);
4516
4517 arm_bg_wake(
4518 root,
4519 session,
4520 channel,
4521 &mut bg_wake_pending,
4522 &mut bg_wake_epoch,
4523 );
4524
4525 assert_eq!(bg_wake_pending, HashSet::from([channel]));
4526 assert_eq!(bg_wake_epoch.get(&key).copied(), Some(42));
4527 }
4528
4529 #[test]
4530 fn stale_maintenance_epoch_does_not_clear_newer_bg_wake() {
4531 let (_root_dir, root) = test_root("subc-bg-wake-stale-root");
4532 let session = "session-1".to_string();
4533 let key = (root.clone(), session.clone());
4534 let channel = route_key(8);
4535 let mut bg_sub_by_session = HashMap::new();
4536 bg_sub_by_session.insert(key.clone(), channel);
4537 let mut bg_wake_pending = HashSet::new();
4538 let mut bg_wake_epoch = HashMap::new();
4539
4540 arm_bg_wake(
4541 root.clone(),
4542 session.clone(),
4543 channel,
4544 &mut bg_wake_pending,
4545 &mut bg_wake_epoch,
4546 );
4547 let epoch_at_submit = bg_wake_epoch[&key];
4548 arm_bg_wake(
4549 root.clone(),
4550 session.clone(),
4551 channel,
4552 &mut bg_wake_pending,
4553 &mut bg_wake_epoch,
4554 );
4555
4556 clear_stale_bg_wakes_for_empty_sessions(
4557 &root,
4558 &[(session, epoch_at_submit)],
4559 &bg_sub_by_session,
4560 &mut bg_wake_pending,
4561 &bg_wake_epoch,
4562 );
4563
4564 assert!(bg_wake_pending.contains(&channel));
4565 assert_eq!(bg_wake_epoch.get(&key).copied(), Some(epoch_at_submit + 1));
4566 }
4567
4568 #[test]
4569 fn matching_maintenance_epoch_clears_genuinely_stale_bg_wake() {
4570 let (_root_dir, root) = test_root("subc-bg-wake-clear-root");
4571 let session = "session-1".to_string();
4572 let key = (root.clone(), session.clone());
4573 let channel = route_key(9);
4574 let mut bg_sub_by_session = HashMap::new();
4575 bg_sub_by_session.insert(key.clone(), channel);
4576 let mut bg_wake_pending = HashSet::new();
4577 let mut bg_wake_epoch = HashMap::new();
4578
4579 arm_bg_wake(
4580 root.clone(),
4581 session.clone(),
4582 channel,
4583 &mut bg_wake_pending,
4584 &mut bg_wake_epoch,
4585 );
4586 let epoch_at_submit = bg_wake_epoch[&key];
4587
4588 clear_stale_bg_wakes_for_empty_sessions(
4589 &root,
4590 &[(session, epoch_at_submit)],
4591 &bg_sub_by_session,
4592 &mut bg_wake_pending,
4593 &bg_wake_epoch,
4594 );
4595
4596 assert!(!bg_wake_pending.contains(&channel));
4597 }
4598
4599 #[test]
4600 fn response_is_fatal_panic_only_matches_panic_exclusive_code() {
4601 let tool_error = Response::error("request-1", "internal_error", "ordinary tool error");
4602 let panic_error = Response::error("request-2", "actor_fatal", "mutating panic");
4603
4604 assert!(!response_is_fatal_panic(&tool_error));
4605 assert!(response_is_fatal_panic(&panic_error));
4606 }
4607
4608 #[tokio::test]
4609 async fn persistent_cancel_resolves_when_fired_before_await() {
4610 let signal = PersistentCancelSignal::new();
4614 signal.cancel();
4615 tokio::time::timeout(Duration::from_secs(1), signal.cancelled())
4617 .await
4618 .expect("cancelled() must resolve when cancel fired beforehand");
4619
4620 let racing = PersistentCancelSignal::new();
4622 let racing_for_task = racing.clone();
4623 let waiter = tokio::spawn(async move { racing_for_task.cancelled().await });
4624 racing.cancel();
4625 tokio::time::timeout(Duration::from_secs(1), waiter)
4626 .await
4627 .expect("cancelled() must resolve when cancel races the await")
4628 .expect("waiter task panicked");
4629 }
4630
4631 #[tokio::test]
4632 async fn control_send_times_out_when_writer_queue_remains_full() {
4633 let (writer_tx, _writer_rx) = mpsc::channel::<Frame>(1);
4634 writer_tx
4635 .try_send(Frame::build(FrameType::Ping, control_flags(), 0, 1, Vec::new()).unwrap())
4636 .expect("prefill writer queue");
4637 let started = Instant::now();
4638
4639 let result = send_frame(
4640 &writer_tx,
4641 Frame::build(FrameType::Pong, control_flags(), 0, 2, Vec::new()).unwrap(),
4642 )
4643 .await;
4644
4645 assert!(matches!(result, Err(SubcError::WriterBackpressureTimeout)));
4646 assert!(
4647 started.elapsed() < Duration::from_secs(2),
4648 "control send guard should be bounded"
4649 );
4650 }
4651
4652 const CORE_TOOLS: [&str; 21] = [
4653 "status",
4654 "bash",
4655 "read",
4656 "write",
4657 "edit",
4658 "apply_patch",
4659 "grep",
4660 "glob",
4661 "search",
4662 "outline",
4663 "zoom",
4664 "inspect",
4665 "callgraph",
4666 "conflicts",
4667 "ast_search",
4668 "ast_replace",
4669 "delete",
4670 "move",
4671 "import",
4672 "refactor",
4673 "safety",
4674 ];
4675
4676 fn is_bare_placeholder_schema(schema: &Value) -> bool {
4677 schema == &json!({ "type": "object" })
4678 }
4679
4680 #[test]
4681 fn build_manifest_serves_embedded_tool_schemas() {
4682 let manifest = build_manifest();
4683 let tools = match manifest.provides.first() {
4684 Some(ProviderRole::ToolProvider { tools, .. }) => tools,
4685 _ => panic!("expected ToolProvider"),
4686 };
4687 let by_name: HashMap<&str, &Tool> = tools.iter().map(|t| (t.name.as_str(), t)).collect();
4688 for name in CORE_TOOLS {
4689 let tool = by_name
4690 .get(name)
4691 .unwrap_or_else(|| panic!("missing tool {name}"));
4692 assert!(
4693 !is_bare_placeholder_schema(&tool.schema),
4694 "{name} must not use bare placeholder schema"
4695 );
4696 assert_eq!(
4697 tool.schema.get("type").and_then(|v| v.as_str()),
4698 Some("object"),
4699 "{name} schema must be an object"
4700 );
4701 }
4702
4703 let read = by_name["read"]
4704 .schema
4705 .get("properties")
4706 .and_then(|p| p.as_object());
4707 let read_props = read.expect("read schema properties");
4708 assert!(
4709 read_props.contains_key("filePath"),
4710 "read schema must expose filePath"
4711 );
4712
4713 let status = &by_name["status"].schema;
4714 assert_eq!(
4715 status.get("properties").and_then(|v| v.as_object()),
4716 Some(&serde_json::Map::new()),
4717 "status schema must have empty properties"
4718 );
4719 assert_eq!(
4720 status.get("additionalProperties").and_then(|v| v.as_bool()),
4721 Some(false),
4722 "status schema must forbid additionalProperties"
4723 );
4724 }
4725
4726 #[test]
4727 fn build_manifest_classifies_execution_mode_by_observable_effect() {
4728 let manifest = build_manifest();
4729 let tools = match manifest.provides.first() {
4730 Some(ProviderRole::ToolProvider { tools, .. }) => tools,
4731 _ => panic!("expected ToolProvider"),
4732 };
4733 let by_name: HashMap<&str, &Tool> = tools.iter().map(|t| (t.name.as_str(), t)).collect();
4734
4735 for name in [
4738 "status",
4739 "read",
4740 "grep",
4741 "glob",
4742 "search",
4743 "outline",
4744 "zoom",
4745 "inspect",
4746 "callgraph",
4747 "conflicts",
4748 "ast_search",
4749 ] {
4750 assert_eq!(
4751 by_name[name].execution_mode,
4752 ExecutionMode::Pure,
4753 "{name} produces no observable side effect and must be Pure"
4754 );
4755 }
4756 for name in [
4758 "bash",
4759 "write",
4760 "edit",
4761 "apply_patch",
4762 "ast_replace",
4763 "delete",
4764 "move",
4765 "import",
4766 "refactor",
4767 "safety",
4768 ] {
4769 assert_eq!(
4770 by_name[name].execution_mode,
4771 ExecutionMode::Mutating,
4772 "{name} writes files and must be Mutating"
4773 );
4774 }
4775 }
4776
4777 #[test]
4778 fn subc_agent_lanes_classify_new_read_tools() {
4779 assert_eq!(command_lane("callgraph"), Lane::HeavyInit);
4780 assert_eq!(command_lane("conflicts"), Lane::PureRead);
4781 }
4782
4783 #[test]
4784 fn native_plumbing_allowlist_admits_exactly_drain_and_ack() {
4785 assert!(is_subc_native_plumbing_tool("bash_drain_completions"));
4790 assert!(is_subc_native_plumbing_tool("bash_ack_completions"));
4791
4792 assert!(!is_subc_native_plumbing_tool("configure"));
4795 assert!(!is_subc_native_plumbing_tool("bash"));
4796 assert!(!is_subc_native_plumbing_tool("bash_kill"));
4797 assert!(!is_subc_native_plumbing_tool("db_set_state"));
4798 assert!(!is_subc_native_plumbing_tool("undo"));
4799
4800 assert!(!is_subc_agent_core_tool("bash_drain_completions"));
4803 assert!(!is_subc_agent_core_tool("bash_ack_completions"));
4804
4805 assert_eq!(command_lane("bash_drain_completions"), Lane::PureRead);
4807 assert_eq!(command_lane("bash_ack_completions"), Lane::Mutating);
4808 }
4809
4810 #[test]
4811 fn tool_response_frame_carries_flat_standalone_shape_in_structured_content() {
4812 use crate::protocol::Response;
4813
4814 let response = Response::success(
4817 "req-7",
4818 json!({
4819 "complete": true,
4820 "matches": 3,
4821 "status_bar": { "errors": 0, "warnings": 1 },
4822 "bg_completions": [{ "task_id": "bash-abc" }],
4823 }),
4824 );
4825 let result = ToolCallResult {
4826 text: "rendered text".to_string(),
4827 response,
4828 };
4829
4830 let expected_flat = json!({
4834 "id": "req-7",
4835 "success": true,
4836 "complete": true,
4837 "matches": 3,
4838 "status_bar": { "errors": 0, "warnings": 1 },
4839 "bg_completions": [{ "task_id": "bash-abc" }],
4840 "text": "rendered text",
4841 });
4842 assert_eq!(
4843 flat_tool_response(&result.response, &result.text),
4844 expected_flat,
4845 "structuredContent must be byte-identical to the standalone flat response"
4846 );
4847
4848 let frame =
4851 build_tool_response_frame(PROTOCOL_VERSION, 1, 42, control_flags(), &result).unwrap();
4852 let body: Value = serde_json::from_slice(&frame.body).unwrap();
4853 assert_eq!(body["isError"], json!(false));
4854 assert_eq!(body["content"][0]["type"], json!("text"));
4855 assert_eq!(body["content"][0]["text"], json!("rendered text"));
4856 assert_eq!(body["structuredContent"], expected_flat);
4857
4858 let err = Response::error_with_data(
4861 "req-8",
4862 "ambiguous_match",
4863 "too many matches",
4864 json!({ "candidates": ["a", "b"] }),
4865 );
4866 let err_result = ToolCallResult {
4867 text: "error text".to_string(),
4868 response: err,
4869 };
4870 let err_frame =
4871 build_tool_response_frame(PROTOCOL_VERSION, 1, 43, control_flags(), &err_result)
4872 .unwrap();
4873 let err_body: Value = serde_json::from_slice(&err_frame.body).unwrap();
4874 assert_eq!(err_body["isError"], json!(true));
4875 assert_eq!(err_body["structuredContent"]["success"], json!(false));
4876 assert_eq!(
4877 err_body["structuredContent"]["code"],
4878 json!("ambiguous_match")
4879 );
4880 assert_eq!(
4881 err_body["structuredContent"]["candidates"],
4882 json!(["a", "b"])
4883 );
4884 assert_eq!(err_body["structuredContent"]["text"], json!("error text"));
4885 }
4886}
4887
4888#[derive(Debug)]
4889pub enum SubcError {
4890 Runtime(std::io::Error),
4891 ConnectionFile {
4892 path: PathBuf,
4893 source: subc_transport::ConnectionFileError,
4894 },
4895 NoEndpoint {
4896 path: PathBuf,
4897 },
4898 InvalidEndpoint {
4899 path: PathBuf,
4900 endpoint: String,
4901 },
4902 Connect {
4903 endpoint: String,
4904 source: std::io::Error,
4905 },
4906 Auth {
4907 endpoint: String,
4908 source: subc_transport::AuthError,
4909 },
4910 FrameIo(subc_transport::FrameIoError),
4911 FrameBuild(subc_protocol::FrameBuildError),
4912 WriterClosed,
4913 WriterBackpressureTimeout,
4914 WriterJoin(tokio::task::JoinError),
4915 Json(serde_json::Error),
4916 ClosedBeforeHelloAck,
4917 HelloRejected {
4918 body: Option<ErrorBody>,
4919 },
4920 UnexpectedFrame {
4921 ty: FrameType,
4922 },
4923}
4924
4925impl fmt::Display for SubcError {
4926 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
4927 match self {
4928 Self::Runtime(e) => write!(f, "failed to build subc tokio runtime: {e}"),
4929 Self::ConnectionFile { path, source } => {
4930 write!(f, "failed to read subc connection file {path:?}: {source}")
4931 }
4932 Self::NoEndpoint { path } => {
4933 write!(f, "subc connection file {path:?} has no endpoints")
4934 }
4935 Self::InvalidEndpoint { path, endpoint } => {
4936 write!(
4937 f,
4938 "subc connection file {path:?} has invalid endpoint {endpoint}"
4939 )
4940 }
4941 Self::Connect { endpoint, source } => {
4942 write!(f, "failed to connect to subc endpoint {endpoint}: {source}")
4943 }
4944 Self::Auth { endpoint, source } => {
4945 write!(
4946 f,
4947 "failed to authenticate to subc endpoint {endpoint}: {source}"
4948 )
4949 }
4950 Self::FrameIo(e) => write!(f, "subc frame I/O error: {e}"),
4951 Self::FrameBuild(e) => write!(f, "subc frame build error: {e}"),
4952 Self::WriterClosed => write!(f, "subc writer task closed"),
4953 Self::WriterBackpressureTimeout => write!(
4954 f,
4955 "subc writer task stayed backpressured while sending a control frame"
4956 ),
4957 Self::WriterJoin(e) => write!(f, "subc writer task join error: {e}"),
4958 Self::Json(e) => write!(f, "subc JSON error: {e}"),
4959 Self::ClosedBeforeHelloAck => {
4960 write!(f, "subc daemon closed the connection before HelloAck")
4961 }
4962 Self::HelloRejected { body } => match body {
4963 Some(b) => write!(f, "subc rejected ModuleHello: {} ({})", b.code, b.message),
4964 None => write!(f, "subc rejected ModuleHello (unparseable error body)"),
4965 },
4966 Self::UnexpectedFrame { ty } => {
4967 write!(f, "subc sent unexpected frame in place of HelloAck: {ty:?}")
4968 }
4969 }
4970 }
4971}
4972
4973impl std::error::Error for SubcError {}