Skip to main content

robonix_executor/dispatch/
mod.rs

1// SPDX-License-Identifier: MulanPSL-2.0
2// Author: wheatfox <wheatfox17@icloud.com>
3//
4// dispatch/mod.rs — route a CapabilityCall to its provider.
5//
6// Two paths:
7//   1. provider_id == executor's own provider_id → run an in-process builtin
8//      (file ops / shell). The contract_id leaf names the operation.
9//   2. else → ConnectCapability(provider_id, contract_id, MCP) on atlas →
10//      MCP call to the returned endpoint → DisconnectCapability.
11//
12// Skills sit at INACTIVE after `rbnx boot`. Right before dispatching
13// to one, the executor sends Driver(CMD_ACTIVATE) on its `*/driver`
14// capability to flip it to ACTIVE — that's where the skill actually
15// allocates its hot resources (frontier loop / nav subscribers / VLA
16// worker / …). We track which skill provider_ids we've already activated in
17// this executor process; subsequent calls skip the CMD_ACTIVATE RPC to
18// keep latency flat (sticky policy). A future eviction algorithm
19// (EXECUTOR_EVICTION_POLICY=deactivate) will drive CMD_DEACTIVATE.
20//
21// The grpc dispatch helper exists for future non-MCP contracts but is not
22// on the LLM-callable path today.
23
24pub mod async_poll;
25pub mod async_registry;
26pub mod builtin;
27pub mod grpc;
28pub mod mcp;
29
30use std::collections::HashSet;
31use std::sync::Mutex;
32use std::time::Duration;
33
34use anyhow::{Context, Result};
35use tonic::Request;
36use tonic::transport::Endpoint;
37
38use crate::pb::lifecycle::{DriverRequest, DriverResponse};
39use crate::pb::pilot::{CapabilityCall, CapabilityCallResult};
40use crate::plan_runtime::PlanRuntime;
41use robonix_atlas::client::AtlasClient;
42use robonix_atlas::pb as atlas_pb;
43use robonix_scribe::{debug, info};
44
45const CMD_ACTIVATE: u32 = 1;
46const DRIVER_ACTIVATE_TIMEOUT: Duration = Duration::from_secs(60);
47const DEPLOY_CONSUMER_ID: &str = "com.robonix.executor.skill_activate";
48
49static ACTIVATED: Mutex<Option<HashSet<String>>> = Mutex::new(None);
50
51fn mark_activated(provider_id: &str) -> bool {
52    let mut g = ACTIVATED.lock().expect("ACTIVATED poisoned");
53    let set = g.get_or_insert_with(HashSet::new);
54    set.insert(provider_id.to_string())
55}
56
57fn already_activated(provider_id: &str) -> bool {
58    let g = ACTIVATED.lock().expect("ACTIVATED poisoned");
59    g.as_ref().is_some_and(|s| s.contains(provider_id))
60}
61
62fn is_skill_namespace(ns: &str) -> bool {
63    let mut parts = ns.split('/').filter(|p| !p.is_empty());
64    let first = parts.next();
65    let second = parts.next();
66    matches!(first, Some("robonix")) && matches!(second, Some("skill"))
67        || matches!(first, Some("skill"))
68}
69
70/// Dispatch a single CapabilityCall and return its result.
71///
72/// `self_provider_id` is the executor's own provider_id (used to short-circuit
73/// builtins that target this process). `atlas` is used to ConnectCapability
74/// for any external provider call; the channel is released as soon as the call
75/// finishes.
76pub async fn dispatch(
77    call: &CapabilityCall,
78    self_provider_id: &str,
79    atlas: &mut AtlasClient,
80    runtime: &PlanRuntime,
81    plan_id: &str,
82) -> CapabilityCallResult {
83    dispatch_with_timeout(call, self_provider_id, atlas, runtime, plan_id, None).await
84}
85
86/// Dispatch one call with an optional MCP deadline while always releasing its
87/// Atlas channel. Verification uses this to bound a verifier that stops
88/// responding without leaking the ConnectCapability record.
89pub async fn dispatch_with_timeout(
90    call: &CapabilityCall,
91    self_provider_id: &str,
92    atlas: &mut AtlasClient,
93    runtime: &PlanRuntime,
94    plan_id: &str,
95    timeout: Option<Duration>,
96) -> CapabilityCallResult {
97    if call.provider_id == self_provider_id {
98        return builtin::execute(call, runtime, self_provider_id, atlas, plan_id).await;
99    }
100
101    if let Err(e) = ensure_skill_active(atlas, &call.provider_id).await {
102        return error_result(call, format!("Driver(CMD_ACTIVATE) failed: {e:#}"));
103    }
104
105    let (channel_id, endpoint, _params) = match atlas
106        .connect_capability(
107            self_provider_id,
108            &call.provider_id,
109            &call.contract_id,
110            atlas_pb::Transport::Mcp,
111        )
112        .await
113    {
114        Ok(triple) => triple,
115        Err(e) => {
116            return error_result(call, format!("ConnectCapability failed: {e:#}"));
117        }
118    };
119
120    let result = match timeout {
121        Some(duration) => match tokio::time::timeout(duration, mcp::execute(call, &endpoint)).await
122        {
123            Ok(result) => result,
124            Err(_) => error_result(
125                call,
126                format!("MCP call timed out after {}s", duration.as_secs()),
127            ),
128        },
129        None => mcp::execute(call, &endpoint).await,
130    };
131
132    let _ = atlas.disconnect_capability(&channel_id).await;
133    result
134}
135
136/// If `provider_id` is a skill that hasn't been activated in this process
137/// yet, resolve its `*/driver` capability and send Driver(CMD_ACTIVATE).
138/// No-op for primitives, services, system providers, skills already in
139/// ACTIVE (per atlas), and skills already activated in this executor
140/// process (sticky cache).
141async fn ensure_skill_active(atlas: &mut AtlasClient, provider_id: &str) -> Result<()> {
142    if already_activated(provider_id) {
143        debug!("[skill-activate] {provider_id}: already activated, skipping CMD_ACTIVATE");
144        return Ok(());
145    }
146    let providers = atlas
147        .query_capabilities(provider_id, "", atlas_pb::Transport::Unspecified)
148        .await
149        .with_context(|| format!("query_capabilities({provider_id})"))?;
150    let Some(provider) = providers.into_iter().next() else {
151        info!(
152            "[skill-activate] {provider_id}: not in atlas, letting connect_capability surface the error"
153        );
154        return Ok(());
155    };
156    if !is_skill_namespace(&provider.namespace) {
157        debug!(
158            "[skill-activate] {provider_id} (ns={}): not a skill, no CMD_ACTIVATE",
159            provider.namespace
160        );
161        return Ok(());
162    }
163    if provider.state == atlas_pb::LifecycleState::StateActive as i32 {
164        info!("[skill-activate] {provider_id}: already ACTIVE per atlas, marking sticky");
165        mark_activated(provider_id);
166        return Ok(());
167    }
168    if provider.state != atlas_pb::LifecycleState::StateInactive as i32 {
169        let state = lifecycle_state_label(provider.state);
170        anyhow::bail!(
171            "skill {} is {}; automatic activation is only valid from INACTIVE. The executor \
172             will not repeat CMD_ACTIVATE after an activation error; recover or restart the \
173             provider lifecycle first",
174            provider_id,
175            state
176        );
177    }
178    info!(
179        "[skill-activate] {provider_id} (ns={}, state={}): sending Driver(CMD_ACTIVATE)",
180        provider.namespace, provider.state
181    );
182    let driver_contract = provider
183        .capabilities
184        .iter()
185        .find(|c| c.contract_id.ends_with("/driver"))
186        .map(|c| c.contract_id.clone())
187        .ok_or_else(|| anyhow::anyhow!("skill {provider_id} has no */driver capability"))?;
188    let svc_name = contract_id_to_service_name(&driver_contract);
189    let (channel_id, endpoint, _) = atlas
190        .connect_capability(
191            DEPLOY_CONSUMER_ID,
192            provider_id,
193            &driver_contract,
194            atlas_pb::Transport::Grpc,
195        )
196        .await
197        .with_context(|| format!("ConnectCapability({driver_contract})"))?;
198    let normalized = if endpoint.starts_with("http") {
199        endpoint
200    } else {
201        format!("http://{endpoint}")
202    };
203    let result = async {
204        let channel = Endpoint::new(normalized.clone())
205            .with_context(|| format!("invalid driver endpoint '{normalized}'"))?
206            .connect()
207            .await
208            .with_context(|| format!("dial driver at '{normalized}'"))?;
209        let path: tonic::codegen::http::uri::PathAndQuery =
210            format!("/robonix.contracts.{svc_name}/Driver")
211                .parse()
212                .with_context(|| format!("build gRPC path for '{driver_contract}'"))?;
213        let mut grpc = tonic::client::Grpc::new(channel);
214        grpc.ready().await.with_context(|| "gRPC ready")?;
215        let codec: tonic_prost::ProstCodec<DriverRequest, DriverResponse> = Default::default();
216        let resp = tokio::time::timeout(
217            DRIVER_ACTIVATE_TIMEOUT,
218            grpc.unary(
219                Request::new(DriverRequest {
220                    command: CMD_ACTIVATE,
221                    config_json: String::new(),
222                }),
223                path,
224                codec,
225            ),
226        )
227        .await
228        .map_err(|_| {
229            anyhow::anyhow!("Driver(CMD_ACTIVATE) timed out after {DRIVER_ACTIVATE_TIMEOUT:?}")
230        })?
231        .with_context(|| "Driver(CMD_ACTIVATE) RPC failed")?;
232        Ok::<_, anyhow::Error>(resp.into_inner())
233    }
234    .await;
235    let _ = atlas.disconnect_capability(&channel_id).await;
236    let r = result?;
237    if !r.ok {
238        anyhow::bail!(
239            "Driver(CMD_ACTIVATE) returned ok=false (state={}, error={})",
240            r.state,
241            r.error
242        );
243    }
244    mark_activated(provider_id);
245    Ok(())
246}
247
248fn lifecycle_state_label(state: i32) -> &'static str {
249    if state == atlas_pb::LifecycleState::StateRegistered as i32 {
250        "REGISTERED"
251    } else if state == atlas_pb::LifecycleState::StateInactive as i32 {
252        "INACTIVE"
253    } else if state == atlas_pb::LifecycleState::StateActive as i32 {
254        "ACTIVE"
255    } else if state == atlas_pb::LifecycleState::StateError as i32 {
256        "ERROR"
257    } else if state == atlas_pb::LifecycleState::StateTerminated as i32 {
258        "TERMINATED"
259    } else {
260        "UNSPECIFIED"
261    }
262}
263
264/// Mirrors robonix_codegen::contract_gen::contract_id_to_service_name.
265/// `robonix/skill/explore/driver` → `RobonixSkillExploreDriver`. Uniform
266/// PascalCase per `/`-segment, no prefix stripping.
267fn contract_id_to_service_name(id: &str) -> String {
268    id.split('/')
269        .filter(|x| !x.is_empty())
270        .map(|seg| {
271            seg.split('_')
272                .filter(|p| !p.is_empty())
273                .map(|p| {
274                    let mut c = p.chars();
275                    match c.next() {
276                        Some(f) => f
277                            .to_uppercase()
278                            .chain(c.flat_map(char::to_lowercase))
279                            .collect::<String>(),
280                        None => String::new(),
281                    }
282                })
283                .collect::<String>()
284        })
285        .collect()
286}
287
288pub(crate) fn error_result(call: &CapabilityCall, msg: String) -> CapabilityCallResult {
289    CapabilityCallResult {
290        call_id: call.call_id.clone(),
291        provider_id: call.provider_id.clone(),
292        contract_id: call.contract_id.clone(),
293        success: false,
294        output: String::new(),
295        error: msg,
296    }
297}
298
299#[cfg(test)]
300mod tests {
301    use super::lifecycle_state_label;
302    use robonix_atlas::pb::LifecycleState;
303
304    #[test]
305    fn lifecycle_state_labels_are_actionable() {
306        assert_eq!(
307            lifecycle_state_label(LifecycleState::StateInactive as i32),
308            "INACTIVE"
309        );
310        assert_eq!(
311            lifecycle_state_label(LifecycleState::StateError as i32),
312            "ERROR"
313        );
314        assert_eq!(
315            lifecycle_state_label(LifecycleState::StateTerminated as i32),
316            "TERMINATED"
317        );
318    }
319}