Skip to main content

robonix_executor/
main.rs

1// SPDX-License-Identifier: MulanPSL-2.0
2// Author: wheatfox <wheatfox17@icloud.com>
3//
4// robonix-executor — capability-call dispatch runtime.
5// On startup executor:
6//   1. Connects to atlas, registers as `com.robonix.system.executor`.
7//   2. Declares its gRPC Execute and CancelAllPlans capabilities.
8//   3. Declares built-in capabilities under `robonix/system/executor/builtin/<op>`
9//      so pilot's atlas-driven discovery surfaces them to the LLM as plain
10//      capabilities. Calls hitting these contracts short-circuit to in-process
11//      handlers in `dispatch::builtin` — no MCP loopback.
12//   4. Serves Execute on `listen`. Per-call dispatch resolves provider via
13//      `ConnectCapability(provider_id, contract_id, MCP)` on atlas.
14
15mod config;
16mod dispatch;
17mod pb;
18mod plan_runtime;
19mod rtdl_wire;
20mod service;
21
22use anyhow::{Context, Result};
23use clap::Parser;
24use config::{Args, EXECUTOR_NAMESPACE, ExecutorConfig};
25use dispatch::builtin::BUILTINS;
26use pb::contracts::robonix_lifecycle_driver_client::RobonixLifecycleDriverClient;
27use pb::contracts::robonix_lifecycle_driver_server::{
28    RobonixLifecycleDriver, RobonixLifecycleDriverServer,
29};
30use pb::contracts::robonix_system_executor_cancel_all_plans_server::RobonixSystemExecutorCancelAllPlansServer;
31use pb::contracts::robonix_system_executor_control_plan_server::RobonixSystemExecutorControlPlanServer;
32use pb::contracts::robonix_system_executor_execute_server::RobonixSystemExecutorExecuteServer;
33use pb::contracts::robonix_system_executor_get_health_server::RobonixSystemExecutorGetHealthServer;
34use pb::contracts::robonix_system_executor_list_active_plans_server::RobonixSystemExecutorListActivePlansServer;
35use pb::lifecycle::{DriverRequest, DriverResponse};
36use robonix_atlas::client::{self as atlas_client, AtlasClient};
37use robonix_atlas::pb as atlas_pb;
38use robonix_scribe::{info, warn};
39use service::ExecutorServiceImpl;
40use std::time::Duration;
41use tonic::{Request, Response, Status};
42
43const SHARED_DRIVER_CONTRACT: &str = "robonix/lifecycle/driver";
44const CMD_INIT: u32 = 0;
45const CMD_ACTIVATE: u32 = 1;
46const CMD_DEACTIVATE: u32 = 2;
47const CMD_SHUTDOWN: u32 = 3;
48
49#[derive(Clone)]
50struct SystemLifecycleDriver {
51    atlas: AtlasClient,
52    provider_id: String,
53    shutdown_tx: tokio::sync::watch::Sender<bool>,
54}
55
56impl SystemLifecycleDriver {
57    fn new(atlas: AtlasClient, provider_id: String) -> Self {
58        let (shutdown_tx, _) = tokio::sync::watch::channel(false);
59        Self {
60            atlas,
61            provider_id,
62            shutdown_tx,
63        }
64    }
65
66    /// Apply one no-op lifecycle callback and publish its authoritative state
67    /// to Atlas. Unknown commands and rejected Atlas transitions are errors.
68    async fn transition(&self, command: u32) -> Result<&'static str> {
69        let (state, label) = lifecycle_target(command)
70            .ok_or_else(|| anyhow::anyhow!("unknown lifecycle command code {command}"))?;
71        let mut atlas = self.atlas.clone();
72        atlas
73            .set_lifecycle_state(&self.provider_id, state, "")
74            .await
75            .with_context(|| format!("publish lifecycle state for '{}'", self.provider_id))?;
76        if command == CMD_SHUTDOWN {
77            self.shutdown_tx.send_replace(true);
78        }
79        Ok(label)
80    }
81
82    fn subscribe_shutdown(&self) -> tokio::sync::watch::Receiver<bool> {
83        self.shutdown_tx.subscribe()
84    }
85}
86
87#[tonic::async_trait]
88impl RobonixLifecycleDriver for SystemLifecycleDriver {
89    /// Serve the shared Driver RPC and report callback/transition failures in
90    /// the stable DriverResponse envelope used by launchers.
91    async fn driver(
92        &self,
93        request: Request<DriverRequest>,
94    ) -> std::result::Result<Response<DriverResponse>, Status> {
95        let response = match self.transition(request.into_inner().command).await {
96            Ok(state) => DriverResponse {
97                ok: true,
98                state: state.to_string(),
99                error: String::new(),
100            },
101            Err(error) => DriverResponse {
102                ok: false,
103                state: "error".to_string(),
104                error: format!("{error:#}"),
105            },
106        };
107        Ok(Response::new(response))
108    }
109}
110
111fn lifecycle_target(command: u32) -> Option<(atlas_pb::LifecycleState, &'static str)> {
112    match command {
113        CMD_INIT => Some((atlas_pb::LifecycleState::StateInactive, "inactive")),
114        CMD_ACTIVATE => Some((atlas_pb::LifecycleState::StateActive, "active")),
115        CMD_DEACTIVATE => Some((atlas_pb::LifecycleState::StateInactive, "inactive")),
116        CMD_SHUTDOWN => Some((atlas_pb::LifecycleState::StateTerminated, "terminated")),
117        _ => None,
118    }
119}
120
121/// Wait until Driver(CMD_SHUTDOWN) has published TERMINATED. A watch channel
122/// preserves a shutdown sent before this particular waiter starts polling.
123async fn wait_for_driver_shutdown(mut shutdown: tokio::sync::watch::Receiver<bool>) {
124    if *shutdown.borrow() {
125        return;
126    }
127    while shutdown.changed().await.is_ok() {
128        if *shutdown.borrow() {
129            return;
130        }
131    }
132}
133
134/// Return a loopback endpoint for an unspecified bind address so the process
135/// can self-dial without changing the endpoint it advertises through Atlas.
136fn startup_driver_endpoint(listen_addr: std::net::SocketAddr) -> String {
137    let ip = match listen_addr.ip() {
138        std::net::IpAddr::V4(ip) if ip.is_unspecified() => {
139            std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
140        }
141        std::net::IpAddr::V6(ip) if ip.is_unspecified() => {
142            std::net::IpAddr::V6(std::net::Ipv6Addr::LOCALHOST)
143        }
144        ip => ip,
145    };
146    std::net::SocketAddr::new(ip, listen_addr.port()).to_string()
147}
148
149/// Connect to this process's just-spawned Driver endpoint. Retries only the
150/// bounded bind/startup window; a connected endpoint is returned immediately.
151async fn connect_startup_driver(
152    endpoint: &str,
153) -> Result<RobonixLifecycleDriverClient<tonic::transport::Channel>> {
154    let endpoint = if endpoint.starts_with("http") {
155        endpoint.to_string()
156    } else {
157        format!("http://{endpoint}")
158    };
159    let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
160    loop {
161        match RobonixLifecycleDriverClient::connect(endpoint.clone()).await {
162            Ok(client) => return Ok(client),
163            Err(_) if tokio::time::Instant::now() < deadline => {
164                tokio::time::sleep(Duration::from_millis(25)).await;
165            }
166            Err(error) => return Err(error).context("connect startup lifecycle Driver"),
167        }
168    }
169}
170
171/// Invoke one startup lifecycle command through the real generated Driver RPC
172/// and reject an `ok=false` response as a startup failure.
173async fn call_startup_driver(
174    client: &mut RobonixLifecycleDriverClient<tonic::transport::Channel>,
175    command: u32,
176) -> Result<String> {
177    let response = client
178        .driver(DriverRequest {
179            command,
180            config_json: "{}".to_string(),
181        })
182        .await
183        .context("call startup lifecycle Driver")?
184        .into_inner();
185    if !response.ok {
186        anyhow::bail!("startup lifecycle Driver failed: {}", response.error);
187    }
188    Ok(response.state)
189}
190
191#[tokio::main]
192async fn main() -> Result<()> {
193    let parsed = Args::parse();
194    // Apply the manifest's per-component `log:` level (delivered inside
195    // --config-json) to scribe's file sink before the first log line.
196    robonix_scribe::init_from_config("executor", parsed.config_json.as_deref());
197    info!("robonix-executor starting");
198
199    let cfg = ExecutorConfig::resolve(parsed)?;
200
201    info!("connecting to atlas at {}", cfg.atlas_endpoint);
202    let mut atlas =
203        AtlasClient::connect_with_retry(&cfg.atlas_endpoint, 10, Duration::from_secs(2))
204            .await
205            .context("connect to atlas")?;
206
207    atlas
208        .register_service(&cfg.id, EXECUTOR_NAMESPACE, "")
209        .await?;
210    info!("registered as '{}' under '{EXECUTOR_NAMESPACE}'", cfg.id);
211
212    let listen_addr: std::net::SocketAddr = cfg
213        .listen
214        .parse()
215        .with_context(|| format!("invalid executor listen address '{}'", cfg.listen))?;
216    let advertised = startup_driver_endpoint(listen_addr);
217
218    atlas
219        .declare_capability(
220            &cfg.id,
221            SHARED_DRIVER_CONTRACT,
222            atlas_pb::Transport::Grpc,
223            &advertised,
224            atlas_client::grpc_params(
225                "capabilities/lifecycle/driver.v1.toml",
226                "robonix.contracts.RobonixLifecycleDriver",
227                "/robonix.contracts.RobonixLifecycleDriver/Driver",
228            ),
229        )
230        .await
231        .context("declare executor shared lifecycle Driver")?;
232    let lifecycle = SystemLifecycleDriver::new(atlas.clone(), cfg.id.clone());
233
234    // Execute RPC: pilot → executor for plan dispatch.
235    atlas
236        .declare_capability(
237            &cfg.id,
238            "robonix/system/executor/execute",
239            atlas_pb::Transport::Grpc,
240            &advertised,
241            atlas_client::grpc_params(
242                "capabilities/system/executor/execute.v1.toml",
243                "robonix.contracts.RobonixSystemExecutorExecute",
244                "/robonix.contracts.RobonixSystemExecutorExecute/Execute",
245            ),
246        )
247        .await?;
248
249    // Vitals polls this out-of-band endpoint. It is observability metadata,
250    // not an RTDL operation and is never exposed as model-callable work.
251    atlas
252        .declare_capability(
253            &cfg.id,
254            "robonix/system/executor/get_health",
255            atlas_pb::Transport::Grpc,
256            &advertised,
257            atlas_client::grpc_params(
258                "capabilities/system/executor/get_health.toml",
259                "robonix.contracts.RobonixSystemExecutorGetHealth",
260                "/robonix.contracts.RobonixSystemExecutorGetHealth/GetModuleHealth",
261            ),
262        )
263        .await?;
264
265    // Out-of-band RTDL meta operations. These never enter PlanRuntime as a
266    // new plan, so canceling work cannot create a self-referential cancel tree.
267    atlas
268        .declare_capability(
269            &cfg.id,
270            "robonix/system/executor/control_plan",
271            atlas_pb::Transport::Grpc,
272            &advertised,
273            atlas_client::grpc_params(
274                "capabilities/system/executor/control_plan.v1.toml",
275                "robonix.contracts.RobonixSystemExecutorControlPlan",
276                "/robonix.contracts.RobonixSystemExecutorControlPlan/ControlPlan",
277            ),
278        )
279        .await?;
280
281    // Read-only control path for clients and observability. Polling it must not
282    // create an RTDL query plan of its own.
283    atlas
284        .declare_capability(
285            &cfg.id,
286            "robonix/system/executor/list_active_plans",
287            atlas_pb::Transport::Grpc,
288            &advertised,
289            atlas_client::grpc_params(
290                "capabilities/system/executor/list_active_plans.v1.toml",
291                "robonix.contracts.RobonixSystemExecutorListActivePlans",
292                "/robonix.contracts.RobonixSystemExecutorListActivePlans/ListActivePlans",
293            ),
294        )
295        .await?;
296
297    // CancelAllPlans RPC: control path for cancelling every active RTDL plan.
298    atlas
299        .declare_capability(
300            &cfg.id,
301            "robonix/system/executor/cancel_all_plans",
302            atlas_pb::Transport::Grpc,
303            &advertised,
304            atlas_client::grpc_params(
305                "capabilities/system/executor/cancel_all_plans.v1.toml",
306                "robonix.contracts.RobonixSystemExecutorCancelAllPlans",
307                "/robonix.contracts.RobonixSystemExecutorCancelAllPlans/CancelAll",
308            ),
309        )
310        .await?;
311
312    // Built-in capabilities: declared as MCP-transport capabilities so pilot's
313    // catalog discovery sees them like any user MCP provider. The endpoint is a
314    // sentinel — dispatch never dials it; calls hitting these contracts hit
315    // the provider_id == self short-circuit in `dispatch::dispatch`.
316    let builtin_endpoint = format!("internal://{}/builtin", cfg.id);
317    for spec in BUILTINS {
318        let contract_id = format!("{EXECUTOR_NAMESPACE}/builtin/{}", spec.op);
319        atlas
320            .declare_capability_with_description(
321                &cfg.id,
322                &contract_id,
323                atlas_pb::Transport::Mcp,
324                &builtin_endpoint,
325                atlas_client::mcp_params(spec.input_schema_json),
326                spec.description,
327            )
328            .await
329            .with_context(|| format!("declare builtin '{}'", contract_id))?;
330    }
331    info!(
332        "declared executor gRPC capabilities + {} builtin capabilities at {advertised}",
333        BUILTINS.len()
334    );
335
336    let svc = ExecutorServiceImpl::new(atlas.clone(), cfg.id.clone());
337    let server_shutdown = lifecycle.subscribe_shutdown();
338    let server_lifecycle = lifecycle.clone();
339    let mut server_task = tokio::spawn(async move {
340        tonic::transport::Server::builder()
341            .add_service(RobonixLifecycleDriverServer::new(server_lifecycle))
342            .add_service(RobonixSystemExecutorExecuteServer::new(svc.clone()))
343            .add_service(RobonixSystemExecutorCancelAllPlansServer::new(svc.clone()))
344            .add_service(RobonixSystemExecutorControlPlanServer::new(svc.clone()))
345            .add_service(RobonixSystemExecutorListActivePlansServer::new(svc.clone()))
346            .add_service(RobonixSystemExecutorGetHealthServer::new(svc))
347            .serve_with_shutdown(listen_addr, wait_for_driver_shutdown(server_shutdown))
348            .await
349    });
350    let startup_endpoint = startup_driver_endpoint(listen_addr);
351    let mut startup_driver = tokio::select! {
352        client = connect_startup_driver(&startup_endpoint) => client?,
353        result = &mut server_task => {
354            result.context("join executor gRPC server")?
355                .context("executor gRPC server failed before readiness")?;
356            anyhow::bail!("executor gRPC server stopped before readiness");
357        }
358    };
359    call_startup_driver(&mut startup_driver, CMD_INIT)
360        .await
361        .context("initialize executor lifecycle")?;
362    call_startup_driver(&mut startup_driver, CMD_ACTIVATE)
363        .await
364        .context("activate executor lifecycle")?;
365    drop(startup_driver);
366
367    // Atlas evicts providers after ~60s without a heartbeat. Send one every
368    // 20s so we stay registered for the lifetime of the process.
369    {
370        let mut hb = atlas.clone();
371        let provider_id = cfg.id.clone();
372        let shutdown = lifecycle.subscribe_shutdown();
373        tokio::spawn(async move {
374            let mut tick = tokio::time::interval(Duration::from_secs(20));
375            tick.tick().await;
376            let shutdown = wait_for_driver_shutdown(shutdown);
377            tokio::pin!(shutdown);
378            loop {
379                tokio::select! {
380                    _ = &mut shutdown => break,
381                    _ = tick.tick() => {
382                        if let Err(e) = hb.heartbeat(&provider_id).await {
383                            warn!("heartbeat failed: {e:#}");
384                        }
385                    }
386                }
387            }
388        });
389    }
390
391    info!("executor gRPC on {listen_addr}");
392    info!("robonix-executor ready on {listen_addr}");
393
394    server_task
395        .await
396        .context("join executor gRPC server")?
397        .context("executor gRPC server failed")?;
398
399    Ok(())
400}
401
402#[cfg(test)]
403mod tests {
404    use super::*;
405    use pb::contracts::robonix_lifecycle_driver_client::RobonixLifecycleDriverClient;
406    use robonix_atlas::service::{AtlasRegistry, serve_atlas};
407    use std::net::SocketAddr;
408    use std::sync::Arc;
409
410    fn reserve_address() -> SocketAddr {
411        let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("reserve port");
412        listener.local_addr().expect("reserved address")
413    }
414
415    #[test]
416    fn startup_dial_rewrites_unspecified_addresses() {
417        assert_eq!(
418            startup_driver_endpoint("0.0.0.0:51001".parse().unwrap()),
419            "127.0.0.1:51001"
420        );
421        assert_eq!(
422            startup_driver_endpoint("[::]:51001".parse().unwrap()),
423            "[::1]:51001"
424        );
425    }
426
427    /// Dial a just-spawned Driver server, retrying only the short startup race.
428    async fn connect_driver(
429        endpoint: String,
430    ) -> RobonixLifecycleDriverClient<tonic::transport::Channel> {
431        let mut last_error = None;
432        for _ in 0..50 {
433            match RobonixLifecycleDriverClient::connect(endpoint.clone()).await {
434                Ok(client) => return client,
435                Err(error) => last_error = Some(error),
436            }
437            tokio::time::sleep(Duration::from_millis(10)).await;
438        }
439        panic!(
440            "connect Driver: {:#}",
441            last_error.expect("connection error")
442        );
443    }
444
445    /// Exercise the generated Driver client against the real tonic endpoint
446    /// and verify that both RPCs publish their lifecycle states to Atlas.
447    #[tokio::test]
448    async fn shared_driver_rpc_is_callable() {
449        let atlas_addr = reserve_address();
450        let registry = Arc::new(AtlasRegistry::default());
451        let atlas_server = tokio::spawn(serve_atlas(Arc::clone(&registry), atlas_addr));
452        let mut atlas = AtlasClient::connect_with_retry(
453            format!("http://{atlas_addr}"),
454            50,
455            Duration::from_millis(10),
456        )
457        .await
458        .expect("connect test Atlas");
459        let provider_id = "executor-driver-rpc-test";
460        atlas
461            .register_service(provider_id, EXECUTOR_NAMESPACE, "")
462            .await
463            .expect("register test Provider");
464
465        let driver_addr = reserve_address();
466        atlas
467            .declare_capability(
468                provider_id,
469                SHARED_DRIVER_CONTRACT,
470                atlas_pb::Transport::Grpc,
471                &driver_addr.to_string(),
472                atlas_client::grpc_params(
473                    "capabilities/lifecycle/driver.v1.toml",
474                    "robonix.contracts.RobonixLifecycleDriver",
475                    "/robonix.contracts.RobonixLifecycleDriver/Driver",
476                ),
477            )
478            .await
479            .expect("declare shared Driver");
480        let lifecycle = SystemLifecycleDriver::new(atlas.clone(), provider_id.to_string());
481        let server_shutdown = lifecycle.subscribe_shutdown();
482        let driver_server = tokio::spawn(
483            tonic::transport::Server::builder()
484                .add_service(RobonixLifecycleDriverServer::new(lifecycle))
485                .serve_with_shutdown(driver_addr, wait_for_driver_shutdown(server_shutdown)),
486        );
487        let mut client = connect_driver(format!("http://{driver_addr}")).await;
488
489        let init = client
490            .driver(DriverRequest {
491                command: CMD_INIT,
492                config_json: "{}".to_string(),
493            })
494            .await
495            .expect("Driver INIT RPC")
496            .into_inner();
497        assert!(init.ok, "{}", init.error);
498        assert_eq!(init.state, "inactive");
499        let activate = client
500            .driver(DriverRequest {
501                command: CMD_ACTIVATE,
502                config_json: String::new(),
503            })
504            .await
505            .expect("Driver ACTIVATE RPC")
506            .into_inner();
507        assert!(activate.ok, "{}", activate.error);
508        assert_eq!(activate.state, "active");
509
510        let shutdown = client
511            .driver(DriverRequest {
512                command: CMD_SHUTDOWN,
513                config_json: String::new(),
514            })
515            .await
516            .expect("Driver SHUTDOWN RPC")
517            .into_inner();
518        assert!(shutdown.ok, "{}", shutdown.error);
519        assert_eq!(shutdown.state, "terminated");
520        tokio::time::timeout(Duration::from_secs(2), driver_server)
521            .await
522            .expect("Driver server did not stop after SHUTDOWN")
523            .expect("join Driver server")
524            .expect("graceful Driver server shutdown");
525
526        let provider = atlas
527            .query(
528                atlas_pb::Kind::Service,
529                provider_id,
530                "",
531                "",
532                atlas_pb::Transport::Unspecified,
533            )
534            .await
535            .expect("query test Provider")
536            .pop()
537            .expect("test Provider");
538        assert_eq!(
539            provider.state,
540            atlas_pb::LifecycleState::StateTerminated as i32
541        );
542
543        atlas_server.abort();
544    }
545}