Skip to main content

forge_workspace/mcp/agents/
facade.rs

1//! The seam between the merged `agents__*` family and the two engines
2//! it drives. Every verb that takes a target resolves it here, so the
3//! routing rule lives in one place rather than at each call site.
4
5use 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
13/// The two engines, both held: a target inside the caller's project is
14/// served by the workers engine and one outside it by the peers
15/// engine. Keeping both is what makes this a dispatcher rather than a
16/// third implementation.
17pub 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    /// The cross-project engine, for listing configured projects and
28    /// reading the caller's own identity.
29    pub fn peers(&self) -> &Arc<dyn WorkspaceFacade> {
30        &self.peers
31    }
32
33    /// The in-project engine, for the caller's own workers and every
34    /// worker lifecycle verb.
35    pub fn workers(&self) -> &Arc<dyn WorkerFacade> {
36        &self.workers
37    }
38
39    /// Hand `wrapped` to `target`'s seat. The engine's own answer is
40    /// whether the message was accepted, not whether it was read, so a
41    /// success says nothing more than that.
42    ///
43    /// Three of the four paths are the ones the two families already
44    /// had, unchanged. The fourth is the reach the merge adds: a
45    /// worker address in another project, which the peers engine
46    /// cannot serve because it resolves a project to that project's
47    /// own agent.
48    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
131/// `label` naming a project's own agent is worker-only addressing: a
132/// lead has no lead above it to talk back to.
133fn 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    /// Two projects: the caller's own (`acme`/`core`) and another
165    /// (`other`/`proj`). The other project has a lead and one worker.
166    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        // `core` is the caller's project key; `proj` names the other
171        // project, which the project-keyed delivery resolves by name.
172        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        // `core` exists under both orgs here, which the config loader would
184        // refuse - it is the pair the dispatcher compares, and this is
185        // that comparison on its own rather than a state forge can reach.
186        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    /// `core` exists twice, under two orgs, so a target that resolves by
226    /// project name alone is distinguishable from one that needs its org.
227    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        // The reach the merge adds: a slot, not a project lead, may be
279        // the target from anywhere.
280        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    /// **A refused delivery tells the caller nothing was delivered.** The
306    /// handler's refusal is not an ambiguous failure; waiting on an answer
307    /// from a project that never took the message is exactly the overclaim
308    /// (#2342).
309    #[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}