@@ -38,8 +38,8 @@ pub mod concurrency;
3838pub mod config;
3939pub mod control_plane;
4040pub mod cost_tracker;
41- pub mod dispatcher;
4241pub mod direct_dispatch;
42+ pub mod dispatcher;
4343pub mod dual_mode;
4444pub mod error;
4545pub mod error_signatures;
@@ -131,7 +131,7 @@ use terraphim_types::{FindingSeverity, ReviewFinding};
131131pub use worktree_guard:: { with_worktree_guard, with_worktree_guard_async, WorktreeGuard } ;
132132
133133use chrono:: Timelike ;
134- use std:: collections:: HashMap ;
134+ use std:: collections:: { HashMap , HashSet } ;
135135use std:: path:: { Path , PathBuf } ;
136136use std:: str:: FromStr ;
137137use std:: time:: { Duration , Instant } ;
@@ -1250,10 +1250,30 @@ impl AgentOrchestrator {
12501250 "safety agents spawned, entering reconciliation loop"
12511251 ) ;
12521252
1253+ // Webhook and direct dispatch use separate channels so the bridge tasks
1254+ // can emit distinct LoopEvent variants without needing to tag messages.
1255+ let webhook_dispatch_rx = if self . config . webhook . is_some ( ) {
1256+ let ( tx, rx) = tokio:: sync:: mpsc:: channel ( 64 ) ;
1257+ self . webhook_dispatch_rx = Some ( rx) ;
1258+ Some ( tx)
1259+ } else {
1260+ self . webhook_dispatch_rx = None ;
1261+ None
1262+ } ;
1263+
1264+ let direct_dispatch_rx = if self . config . direct_dispatch . is_some ( ) {
1265+ let ( tx, rx) = tokio:: sync:: mpsc:: channel ( 64 ) ;
1266+ Some ( ( tx, rx) )
1267+ } else {
1268+ None
1269+ } ;
1270+
12531271 // Start webhook server if configured
12541272 if let Some ( ref webhook_cfg) = self . config . webhook {
1255- let ( dispatch_tx, dispatch_rx) = tokio:: sync:: mpsc:: channel ( 64 ) ;
1256- self . webhook_dispatch_rx = Some ( dispatch_rx) ;
1273+ let dispatch_tx = webhook_dispatch_rx
1274+ . as_ref ( )
1275+ . expect ( "webhook dispatch channel is initialised when webhook is configured" )
1276+ . clone ( ) ;
12571277
12581278 let agent_names: Vec < String > =
12591279 self . config . agents . iter ( ) . map ( |a| a. name . clone ( ) ) . collect ( ) ;
@@ -1293,11 +1313,34 @@ impl AgentOrchestrator {
12931313 } ) ;
12941314 }
12951315
1316+ let direct_dispatch_rx = if let Some ( ref direct_cfg) = self . config . direct_dispatch {
1317+ let ( direct_tx, direct_rx) = direct_dispatch_rx. expect (
1318+ "direct dispatch channel is initialised when direct_dispatch is configured" ,
1319+ ) ;
1320+ let agent_names: HashSet < String > = self
1321+ . config
1322+ . agents
1323+ . iter ( )
1324+ . map ( |agent| agent. name . clone ( ) )
1325+ . collect ( ) ;
1326+
1327+ direct_dispatch:: start_direct_dispatch_listener (
1328+ direct_cfg. socket_path . clone ( ) ,
1329+ direct_tx,
1330+ agent_names,
1331+ ) ;
1332+
1333+ Some ( direct_rx)
1334+ } else {
1335+ None
1336+ } ;
1337+
12961338 enum LoopEvent {
12971339 Tick ,
12981340 Schedule ( ScheduleEvent ) ,
12991341 DriftAlert ( DriftAlert ) ,
13001342 Webhook ( webhook:: WebhookDispatch ) ,
1343+ DirectDispatch ( webhook:: WebhookDispatch ) ,
13011344 }
13021345
13031346 let tick_interval = self . config . tick_interval_secs ;
@@ -1367,6 +1410,23 @@ impl AgentOrchestrator {
13671410 } ) ;
13681411 }
13691412
1413+ if let Some ( direct_rx) = direct_dispatch_rx {
1414+ let dd_tx = loop_tx. clone ( ) ;
1415+ tokio:: spawn ( async move {
1416+ let mut rx = direct_rx;
1417+ while let Some ( dispatch) = rx. recv ( ) . await {
1418+ if dd_tx
1419+ . lock ( )
1420+ . unwrap ( )
1421+ . send ( LoopEvent :: DirectDispatch ( dispatch) )
1422+ . is_err ( )
1423+ {
1424+ break ;
1425+ }
1426+ }
1427+ } ) ;
1428+ }
1429+
13701430 let reconcile_timeout = Duration :: from_secs ( self . config . tick_interval_secs . max ( 30 ) * 3 ) ;
13711431
13721432 loop {
@@ -1382,6 +1442,9 @@ impl AgentOrchestrator {
13821442 self . mark_webhook_comment_processed ( comment_id) . await ;
13831443 let _ = loop_tx. lock ( ) . unwrap ( ) . send ( LoopEvent :: Tick ) ;
13841444 }
1445+ Ok ( LoopEvent :: DirectDispatch ( dispatch) ) => {
1446+ self . handle_direct_dispatch ( dispatch) . await ;
1447+ }
13851448 Ok ( LoopEvent :: Schedule ( event) ) => {
13861449 self . handle_schedule_event ( event) . await ;
13871450 }
@@ -1396,6 +1459,9 @@ impl AgentOrchestrator {
13961459 self . handle_webhook_dispatch ( dispatch) . await ;
13971460 self . mark_webhook_comment_processed ( comment_id) . await ;
13981461 }
1462+ Ok ( LoopEvent :: DirectDispatch ( dispatch) ) => {
1463+ self . handle_direct_dispatch ( dispatch) . await ;
1464+ }
13991465 Ok ( LoopEvent :: Schedule ( event) ) => {
14001466 self . handle_schedule_event ( event) . await ;
14011467 }
@@ -3835,6 +3901,43 @@ impl AgentOrchestrator {
38353901 }
38363902 }
38373903
3904+ async fn handle_direct_dispatch ( & mut self , dispatch : webhook:: WebhookDispatch ) {
3905+ match dispatch {
3906+ webhook:: WebhookDispatch :: SpawnAgent {
3907+ agent_name,
3908+ context,
3909+ ..
3910+ } => {
3911+ let def = match self . config . agents . iter ( ) . find ( |a| a. name == agent_name) {
3912+ Some ( def) => def,
3913+ None => {
3914+ warn ! ( agent = %agent_name, "direct dispatch: agent not found in config" ) ;
3915+ return ;
3916+ }
3917+ } ;
3918+
3919+ if !def. enabled {
3920+ info ! ( agent = %agent_name, "direct dispatch rejected: agent is disabled" ) ;
3921+ return ;
3922+ }
3923+
3924+ let mut direct_def = def. clone ( ) ;
3925+ if !context. is_empty ( ) {
3926+ direct_def. task =
3927+ format ! ( "{}\n \n [direct dispatch context]\n {}" , def. task, context) ;
3928+ }
3929+
3930+ info ! ( agent = %agent_name, "direct dispatch: spawning agent" ) ;
3931+ if let Err ( e) = self . spawn_agent ( & direct_def) . await {
3932+ error ! ( agent = %agent_name, error = %e, "direct dispatch: failed to spawn agent" ) ;
3933+ }
3934+ }
3935+ other => {
3936+ warn ! ( dispatch = ?other, "direct dispatch ignored unsupported dispatch type" ) ;
3937+ }
3938+ }
3939+ }
3940+
38383941 /// Uses repo-wide comments endpoint with `since` cursor. On first run
38393942 /// (no persisted cursor), cursor is set to `now` to skip all historical
38403943 /// mentions — preventing the mention replay storm.
@@ -8312,6 +8415,94 @@ mod tests {
83128415 assert ! ( orch. shutdown_requested) ;
83138416 }
83148417
8418+ #[ cfg( unix) ]
8419+ #[ tokio:: test]
8420+ async fn test_direct_dispatch_config_starts_socket_listener ( ) {
8421+ use std:: os:: unix:: fs:: FileTypeExt ;
8422+
8423+ let temp = TempDir :: new ( ) . unwrap ( ) ;
8424+ let socket_path = temp. path ( ) . join ( "direct-dispatch.sock" ) ;
8425+ let mut config = test_config ( ) ;
8426+ config. agents . clear ( ) ;
8427+ config. direct_dispatch = Some ( crate :: config:: DirectDispatchConfig {
8428+ socket_path : socket_path. clone ( ) ,
8429+ } ) ;
8430+
8431+ let mut orch = AgentOrchestrator :: new ( config) . unwrap ( ) ;
8432+ orch. shutdown ( ) ;
8433+ orch. run ( ) . await . unwrap ( ) ;
8434+
8435+ let mut socket_created = false ;
8436+ for _ in 0 ..50 {
8437+ if std:: fs:: symlink_metadata ( & socket_path)
8438+ . map ( |metadata| metadata. file_type ( ) . is_socket ( ) )
8439+ . unwrap_or ( false )
8440+ {
8441+ socket_created = true ;
8442+ break ;
8443+ }
8444+ tokio:: task:: yield_now ( ) . await ;
8445+ }
8446+
8447+ assert ! (
8448+ socket_created,
8449+ "direct dispatch listener did not create socket at {}" ,
8450+ socket_path. display( )
8451+ ) ;
8452+ }
8453+
8454+ #[ tokio:: test]
8455+ async fn test_handle_direct_dispatch_spawns_agent_without_mentions ( ) {
8456+ let mut config = test_config ( ) ;
8457+ config. agents = vec ! [ AgentDefinition {
8458+ name: "echo-agent" . to_string( ) ,
8459+ layer: AgentLayer :: Core ,
8460+ cli_tool: "echo" . to_string( ) ,
8461+ task: "echo hello" . to_string( ) ,
8462+ schedule: None ,
8463+ model: None ,
8464+ capabilities: vec![ "echo" . to_string( ) ] ,
8465+ max_memory_bytes: None ,
8466+ budget_monthly_cents: None ,
8467+ provider: None ,
8468+ persona: None ,
8469+ terraphim_role: None ,
8470+ skill_chain: vec![ ] ,
8471+ sfia_skills: vec![ ] ,
8472+ fallback_provider: None ,
8473+ fallback_model: None ,
8474+ grace_period_secs: None ,
8475+ max_cpu_seconds: None ,
8476+ pre_check: None ,
8477+ gitea_issue: None ,
8478+ event_only: false ,
8479+ evolution_enabled: false ,
8480+ rlm_enabled: None ,
8481+ bypass_kg_routing: false ,
8482+ enabled: true ,
8483+ project: None ,
8484+ } ] ;
8485+ config. mentions = None ;
8486+
8487+ let mut orch = AgentOrchestrator :: new ( config) . unwrap ( ) ;
8488+
8489+ let dispatch = webhook:: WebhookDispatch :: SpawnAgent {
8490+ agent_name : "echo-agent" . to_string ( ) ,
8491+ detected_project : None ,
8492+ issue_number : 0 ,
8493+ comment_id : 0 ,
8494+ context : "test context" . to_string ( ) ,
8495+ } ;
8496+
8497+ orch. handle_direct_dispatch ( dispatch) . await ;
8498+
8499+ assert ! (
8500+ orch. active_agents. contains_key( "echo-agent" ) ,
8501+ "direct dispatch must spawn agent even without mentions config; active_agents: {:?}" ,
8502+ orch. active_agents. keys( ) . collect:: <Vec <_>>( )
8503+ ) ;
8504+ }
8505+
83158506 #[ tokio:: test]
83168507 async fn test_orchestrator_compound_review_manual ( ) {
83178508 // Use empty groups to avoid git worktree operations during test.
0 commit comments