1use std::sync::Arc;
6
7use crate::SessionSlot;
8use crate::mcp::agents::target::{AgentTarget, LEAD_LABEL};
9use crate::mcp::peers::facade::{DeliverError, WorkspaceFacade};
10use crate::mcp::peers::types::WrappedPrompt;
11use crate::mcp::workers::facade::{WorkerDeliverError, WorkerFacade, WorkerLeadDeliverError};
12
13pub struct AgentDispatcher {
18 peers: Arc<dyn WorkspaceFacade>,
19 workers: Arc<dyn WorkerFacade>,
20}
21
22impl AgentDispatcher {
23 pub fn new(peers: Arc<dyn WorkspaceFacade>, workers: Arc<dyn WorkerFacade>) -> Self {
24 Self { peers, workers }
25 }
26
27 pub fn peers(&self) -> &Arc<dyn WorkspaceFacade> {
30 &self.peers
31 }
32
33 pub fn workers(&self) -> &Arc<dyn WorkerFacade> {
36 &self.workers
37 }
38
39 pub async fn deliver(
49 &self,
50 caller: &SessionSlot,
51 target: &AgentTarget,
52 wrapped: WrappedPrompt,
53 ) -> Result<(), String> {
54 let own_project = target.org() == caller.org() && target.project() == caller.project();
55 if own_project {
56 return self.deliver_within_own_project(caller, target, wrapped).await;
57 }
58 if target.label() == LEAD_LABEL {
59 return match self.peers.deliver_peer_prompt(caller, target.project(), wrapped) {
60 Ok(()) => Ok(()),
61 Err(DeliverError::UnknownTarget { .. }) => Err(format!(
62 "project '{}' is no longer reachable; call agents__list to see who you can \
63 reach.",
64 target.project(),
65 )),
66 Err(DeliverError::DispatchFailed { message }) => Err(format!(
67 "project '{}' did not take the message ({message}). Nothing was delivered, \
68 so do not wait on an answer from it.",
69 target.project(),
70 )),
71 };
72 }
73 match self
74 .workers
75 .deliver_worker_prompt_to_project(
76 caller,
77 target.org(),
78 target.project(),
79 target.label(),
80 wrapped,
81 )
82 .await
83 {
84 Ok(_) => Ok(()),
85 Err(err) => Err(worker_deliver_message(target, &err)),
86 }
87 }
88
89 async fn deliver_within_own_project(
90 &self,
91 caller: &SessionSlot,
92 target: &AgentTarget,
93 wrapped: WrappedPrompt,
94 ) -> Result<(), String> {
95 if target.label() == LEAD_LABEL {
96 return match self.workers.deliver_prompt_to_lead(caller, wrapped).await {
97 Ok(_) => Ok(()),
98 Err(err) => Err(lead_deliver_message(&err)),
99 };
100 }
101 match self.workers.deliver_worker_prompt(caller, target.label(), wrapped).await {
102 Ok(_) => Ok(()),
103 Err(err) => Err(worker_deliver_message(target, &err)),
104 }
105 }
106}
107
108fn worker_deliver_message(target: &AgentTarget, err: &WorkerDeliverError) -> String {
109 match err {
110 WorkerDeliverError::UnknownLabel { project_key, label } => format!(
111 "worker '{label}' is not available (no live worker by that label in '{project_key}', \
112 the project you addressed as '{}'/'{}'). Call agents__list for the project's live \
113 workers.",
114 target.org(),
115 target.project(),
116 ),
117 WorkerDeliverError::NotLive { project_key, label } => format!(
118 "worker '{label}' in '{project_key}' is not live - its row is kept because it \
119 crashed or was closed, so the message did not land. Nothing was delivered; \
120 respawn the label (agents__spawn with resume_session) if you need it back.",
121 ),
122 WorkerDeliverError::DispatchFailed { message } => format!(
123 "worker '{}' did not take the message ({message}). Nothing was delivered, so do not \
124 wait on an answer from it; check agents__list and send again if the label is still \
125 there.",
126 target.label(),
127 ),
128 }
129}
130
131fn lead_deliver_message(err: &WorkerLeadDeliverError) -> String {
134 match err {
135 WorkerLeadDeliverError::UnknownCaller => {
136 "could not resolve caller to a known session (forge bug)".to_owned()
137 }
138 WorkerLeadDeliverError::LeadCallerHasNoLead => {
139 "the label 'lead' addresses your own project's agent, which is worker-only: this \
140 session IS that agent, so it has nothing above it. Address another project by its \
141 org and project name instead."
142 .to_owned()
143 }
144 WorkerLeadDeliverError::LeadGone => {
145 "your lead is not available (its session closed since this worker was spawned)."
146 .to_owned()
147 }
148 WorkerLeadDeliverError::DispatchFailed { message } => format!(
149 "your lead's session did not take the message ({message}). Nothing was delivered, so \
150 do not wait on an answer; send again if the lead is still up."
151 ),
152 }
153}
154
155#[cfg(test)]
156mod tests {
157 use super::*;
158 use crate::ProjectKey;
159 use crate::mcp::peers::facade::MockWorkspaceFacade;
160 use crate::mcp::peers::types::{MessageId, PeerLiveness, PeerStatus, WrappedKind};
161 use crate::mcp::workers::facade::{CallerProject, MockWorkerFacade};
162 use forge_primitives::{WorkerLiveness, WorkerStatus};
163
164 fn host() -> (Arc<MockWorkspaceFacade>, Arc<MockWorkerFacade>, AgentDispatcher) {
167 let peers = Arc::new(MockWorkspaceFacade::new());
168 peers.peers.lock().extend([configured("acme", "core"), configured("other", "proj")]);
169 let workers = Arc::new(MockWorkerFacade::new());
170 workers.workers.lock().insert("core".to_owned(), vec![worker("acme", "core", "w1")]);
173 workers.workers.lock().insert("proj".to_owned(), vec![worker("other", "proj", "w1")]);
174 let dispatcher = AgentDispatcher::new(
175 Arc::clone(&peers) as Arc<dyn WorkspaceFacade>,
176 Arc::clone(&workers) as Arc<dyn WorkerFacade>,
177 );
178 (peers, workers, dispatcher)
179 }
180
181 #[tokio::test]
182 async fn a_project_name_the_caller_shares_with_another_org_is_not_its_own_project() {
183 let (peers, _workers, dispatcher) = host();
187 let caller = SessionSlot::lead("acme", "core");
188 dispatcher
189 .deliver(&caller, &target("other", "core", None), message())
190 .await
191 .expect("core is configured under other");
192 assert_eq!(peers.deliver_calls.lock().len(), 1, "the peers engine carried it");
193 }
194
195 fn configured(org: &str, name: &str) -> PeerStatus {
196 PeerStatus {
197 name: name.to_owned(),
198 org: org.to_owned(),
199 path: std::path::PathBuf::from(format!("/tmp/{name}")),
200 status: PeerLiveness::Running,
201 spawned_at: None,
202 }
203 }
204
205 fn worker(org: &str, project: &str, label: &str) -> WorkerStatus {
206 WorkerStatus {
207 label: label.to_owned(),
208 charter: format!("{label}'s charter"),
209 status: WorkerLiveness::Running,
210 session_id: format!("{label}-session"),
211 slot: SessionSlot::worker(org, project, label),
212 spawned_at: std::time::SystemTime::UNIX_EPOCH,
213 spawned_by: SessionSlot::lead(org, project),
214 diagnostic: None,
215 heal_attempt: None,
216 heal_next_at: None,
217 activity: None,
218 }
219 }
220
221 fn target(org: &str, project: &str, label: Option<&str>) -> AgentTarget {
222 AgentTarget::parse(&configured_projects(), org, project, label).expect("configured project")
223 }
224
225 fn configured_projects() -> Vec<PeerStatus> {
228 vec![configured("acme", "core"), configured("other", "proj"), configured("other", "core")]
229 }
230
231 fn message() -> WrappedPrompt {
232 WrappedPrompt {
233 id: MessageId::mint(),
234 kind: WrappedKind::Message,
235 sender_name: "lead".to_owned(),
236 sender_org: "Personal".to_owned(),
237 body: "hi".to_owned(),
238 }
239 }
240
241 fn caller_in_own_project(workers: &MockWorkerFacade) -> SessionSlot {
242 let slot = SessionSlot::lead("acme", "core");
243 workers.callers.lock().insert(
244 slot.clone(),
245 CallerProject { project_key: ProjectKey::new("core"), is_lead: true },
246 );
247 slot
248 }
249
250 #[tokio::test]
251 async fn a_worker_in_the_callers_own_project_goes_through_the_workers_engine() {
252 let (peers, workers, dispatcher) = host();
253 let caller = caller_in_own_project(&workers);
254 dispatcher
255 .deliver(&caller, &target("acme", "core", Some("w1")), message())
256 .await
257 .expect("w1 is live in the caller's project");
258
259 assert_eq!(workers.deliver_calls.lock().len(), 1, "the workers engine carried it");
260 assert!(peers.deliver_calls.lock().is_empty(), "the peers engine was not used");
261 }
262
263 #[tokio::test]
264 async fn another_projects_agent_goes_through_the_peers_engine() {
265 let (peers, workers, dispatcher) = host();
266 let caller = caller_in_own_project(&workers);
267 dispatcher
268 .deliver(&caller, &target("other", "proj", None), message())
269 .await
270 .expect("proj is a configured project");
271
272 assert_eq!(peers.deliver_calls.lock().len(), 1, "the peers engine carried it");
273 assert!(workers.deliver_calls.lock().is_empty(), "the workers engine was not used");
274 }
275
276 #[tokio::test]
277 async fn a_named_worker_in_another_project_goes_through_the_workers_engine() {
278 let (peers, workers, dispatcher) = host();
281 let caller = caller_in_own_project(&workers);
282 dispatcher
283 .deliver(&caller, &target("other", "proj", Some("w1")), message())
284 .await
285 .expect("w1 is live in proj");
286
287 let calls = workers.deliver_to_project_calls.lock();
288 assert_eq!(calls.len(), 1, "the workers engine carried it, keyed to the target project");
289 assert_eq!(calls[0].org, "other", "addressed by the target's org");
290 assert_eq!(calls[0].project, "proj", "addressed by the target's project");
291 assert!(peers.deliver_calls.lock().is_empty(), "the peers engine was not used");
292 }
293
294 #[tokio::test]
295 async fn a_label_that_is_not_live_is_refused_rather_than_dropped() {
296 let (_peers, workers, dispatcher) = host();
297 let caller = caller_in_own_project(&workers);
298 let err = dispatcher
299 .deliver(&caller, &target("other", "proj", Some("ghost")), message())
300 .await
301 .expect_err("no live worker by that label");
302 assert!(err.contains("ghost"), "the refusal names the label: {err}");
303 }
304
305 #[tokio::test]
310 async fn a_refused_delivery_says_nothing_was_delivered() {
311 let (peers, workers, dispatcher) = host();
312 let caller = caller_in_own_project(&workers);
313 *peers.force_deliver_error.lock() = Some(DeliverError::DispatchFailed {
314 message: "the target's running session did not take the message (closed)".to_owned(),
315 });
316 let err = dispatcher
317 .deliver(&caller, &target("other", "proj", None), message())
318 .await
319 .expect_err("the handler refused the delivery");
320 assert!(
321 err.contains("Nothing was delivered, so do not wait on an answer from it."),
322 "the caller-facing sentence says the message went nowhere: {err}",
323 );
324 }
325}