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