Skip to content

Commit 9271f25

Browse files
author
Test User
committed
feat(orchestrator): separate LoopEvent for direct dispatch, no mention config required
Add LoopEvent::DirectDispatch variant to cleanly separate direct socket dispatch from webhook dispatch. Direct dispatch now uses exact-name agent lookup without requiring [mentions] config - the semantic gap fix. - Add LoopEvent::DirectDispatch(webhook::WebhookDispatch) variant - Separate mpsc channels for webhook and direct dispatch - handle_direct_dispatch() uses exact-name lookup, no MentionConfig - Route DirectDispatch events through main match and inner coalescing loop - event_only agents can be directly triggered (explicit operator action) - No synthetic gitea_issue from issue_number: 0 for direct dispatch Refs #1875
1 parent 8d55a42 commit 9271f25

1 file changed

Lines changed: 195 additions & 4 deletions

File tree

  • crates/terraphim_orchestrator/src

crates/terraphim_orchestrator/src/lib.rs

Lines changed: 195 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,8 @@ pub mod concurrency;
3838
pub mod config;
3939
pub mod control_plane;
4040
pub mod cost_tracker;
41-
pub mod dispatcher;
4241
pub mod direct_dispatch;
42+
pub mod dispatcher;
4343
pub mod dual_mode;
4444
pub mod error;
4545
pub mod error_signatures;
@@ -131,7 +131,7 @@ use terraphim_types::{FindingSeverity, ReviewFinding};
131131
pub use worktree_guard::{with_worktree_guard, with_worktree_guard_async, WorktreeGuard};
132132

133133
use chrono::Timelike;
134-
use std::collections::HashMap;
134+
use std::collections::{HashMap, HashSet};
135135
use std::path::{Path, PathBuf};
136136
use std::str::FromStr;
137137
use 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

Comments
 (0)