Skip to main content

robonix_pilot/
main.rs

1// SPDX-License-Identifier: MulanPSL-2.0
2// Author: wheatfox <wheatfox17@icloud.com>
3//
4// robonix-pilot — reasoning, planning, and session management.
5//
6// Run standalone (manual smoke testing):
7//   robonix-pilot --vlm-upstream https://api.openai.com/v1 \
8//                 --vlm-api-key sk-... \
9//                 --vlm-model gpt-5.5
10//   # atlas defaults to 127.0.0.1:50051; everything else has sane defaults.
11//
12// Run under rbnx:
13//   ROBONIX_ATLAS_ENDPOINT=… ROBONIX_CONFIG_PATH=/run/robonix/pilot.yaml robonix-pilot
14//
15// Either way, on startup pilot:
16//   1. Connects to atlas, registers its capability, declares the
17//      `robonix/system/pilot` gRPC capability.
18//   2. Constructs an embedded LLM client from the resolved VLM config.
19//   3. Serves RobonixSystemPilot on `listen`. Executor address is discovered
20//      through atlas at every Stream RPC, not configured statically.
21
22mod config;
23mod discovery;
24mod history;
25mod memory;
26mod pb;
27mod planner;
28mod service;
29mod soma_context;
30mod state_context;
31mod vlm;
32
33use anyhow::{Context, Result};
34use clap::Parser;
35use config::{Args, PILOT_NAMESPACE, PilotConfig};
36use pb::contracts::robonix_lifecycle_driver_client::RobonixLifecycleDriverClient;
37use pb::contracts::robonix_lifecycle_driver_server::{
38    RobonixLifecycleDriver, RobonixLifecycleDriverServer,
39};
40use pb::contracts::robonix_system_pilot_get_health_server::RobonixSystemPilotGetHealthServer;
41use pb::contracts::robonix_system_pilot_server::RobonixSystemPilotServer;
42use pb::lifecycle::{DriverRequest, DriverResponse};
43use robonix_atlas::client::{self as atlas_client, AtlasClient};
44use robonix_atlas::pb as atlas_pb;
45use robonix_scribe::{info, warn};
46use service::PilotServiceImpl;
47use std::time::Duration;
48use tonic::{Request, Response, Status};
49
50const SHARED_DRIVER_CONTRACT: &str = "robonix/lifecycle/driver";
51const CMD_INIT: u32 = 0;
52const CMD_ACTIVATE: u32 = 1;
53const CMD_DEACTIVATE: u32 = 2;
54const CMD_SHUTDOWN: u32 = 3;
55
56#[derive(Clone)]
57struct SystemLifecycleDriver {
58    atlas: AtlasClient,
59    provider_id: String,
60    shutdown_tx: tokio::sync::watch::Sender<bool>,
61}
62
63impl SystemLifecycleDriver {
64    fn new(atlas: AtlasClient, provider_id: String) -> Self {
65        let (shutdown_tx, _) = tokio::sync::watch::channel(false);
66        Self {
67            atlas,
68            provider_id,
69            shutdown_tx,
70        }
71    }
72
73    /// Apply one no-op lifecycle callback and publish its authoritative state
74    /// to Atlas. Unknown commands and rejected Atlas transitions are errors.
75    async fn transition(&self, command: u32) -> Result<&'static str> {
76        let (state, label) = lifecycle_target(command)
77            .ok_or_else(|| anyhow::anyhow!("unknown lifecycle command code {command}"))?;
78        let mut atlas = self.atlas.clone();
79        atlas
80            .set_lifecycle_state(&self.provider_id, state, "")
81            .await
82            .with_context(|| format!("publish lifecycle state for '{}'", self.provider_id))?;
83        if command == CMD_SHUTDOWN {
84            self.shutdown_tx.send_replace(true);
85        }
86        Ok(label)
87    }
88
89    fn subscribe_shutdown(&self) -> tokio::sync::watch::Receiver<bool> {
90        self.shutdown_tx.subscribe()
91    }
92}
93
94#[tonic::async_trait]
95impl RobonixLifecycleDriver for SystemLifecycleDriver {
96    /// Serve the shared Driver RPC and report callback/transition failures in
97    /// the stable DriverResponse envelope used by launchers.
98    async fn driver(
99        &self,
100        request: Request<DriverRequest>,
101    ) -> std::result::Result<Response<DriverResponse>, Status> {
102        let response = match self.transition(request.into_inner().command).await {
103            Ok(state) => DriverResponse {
104                ok: true,
105                state: state.to_string(),
106                error: String::new(),
107            },
108            Err(error) => DriverResponse {
109                ok: false,
110                state: "error".to_string(),
111                error: format!("{error:#}"),
112            },
113        };
114        Ok(Response::new(response))
115    }
116}
117
118fn lifecycle_target(command: u32) -> Option<(atlas_pb::LifecycleState, &'static str)> {
119    match command {
120        CMD_INIT => Some((atlas_pb::LifecycleState::StateInactive, "inactive")),
121        CMD_ACTIVATE => Some((atlas_pb::LifecycleState::StateActive, "active")),
122        CMD_DEACTIVATE => Some((atlas_pb::LifecycleState::StateInactive, "inactive")),
123        CMD_SHUTDOWN => Some((atlas_pb::LifecycleState::StateTerminated, "terminated")),
124        _ => None,
125    }
126}
127
128/// Wait until Driver(CMD_SHUTDOWN) has published TERMINATED. A watch channel
129/// preserves a shutdown sent before this particular waiter starts polling.
130async fn wait_for_driver_shutdown(mut shutdown: tokio::sync::watch::Receiver<bool>) {
131    if *shutdown.borrow() {
132        return;
133    }
134    while shutdown.changed().await.is_ok() {
135        if *shutdown.borrow() {
136            return;
137        }
138    }
139}
140
141/// Return a loopback endpoint for an unspecified bind address so the process
142/// can self-dial without changing the endpoint it advertises through Atlas.
143fn startup_driver_endpoint(listen_addr: std::net::SocketAddr) -> String {
144    let ip = match listen_addr.ip() {
145        std::net::IpAddr::V4(ip) if ip.is_unspecified() => {
146            std::net::IpAddr::V4(std::net::Ipv4Addr::LOCALHOST)
147        }
148        std::net::IpAddr::V6(ip) if ip.is_unspecified() => {
149            std::net::IpAddr::V6(std::net::Ipv6Addr::LOCALHOST)
150        }
151        ip => ip,
152    };
153    std::net::SocketAddr::new(ip, listen_addr.port()).to_string()
154}
155
156/// Connect to this process's just-spawned Driver endpoint. Retries only the
157/// bounded bind/startup window; a connected endpoint is returned immediately.
158async fn connect_startup_driver(
159    endpoint: &str,
160) -> Result<RobonixLifecycleDriverClient<tonic::transport::Channel>> {
161    let endpoint = if endpoint.starts_with("http") {
162        endpoint.to_string()
163    } else {
164        format!("http://{endpoint}")
165    };
166    let deadline = tokio::time::Instant::now() + Duration::from_secs(5);
167    loop {
168        match RobonixLifecycleDriverClient::connect(endpoint.clone()).await {
169            Ok(client) => return Ok(client),
170            Err(_) if tokio::time::Instant::now() < deadline => {
171                tokio::time::sleep(Duration::from_millis(25)).await;
172            }
173            Err(error) => return Err(error).context("connect startup lifecycle Driver"),
174        }
175    }
176}
177
178/// Invoke one startup lifecycle command through the real generated Driver RPC
179/// and reject an `ok=false` response as a startup failure.
180async fn call_startup_driver(
181    client: &mut RobonixLifecycleDriverClient<tonic::transport::Channel>,
182    command: u32,
183) -> Result<String> {
184    let response = client
185        .driver(DriverRequest {
186            command,
187            config_json: "{}".to_string(),
188        })
189        .await
190        .context("call startup lifecycle Driver")?
191        .into_inner();
192    if !response.ok {
193        anyhow::bail!("startup lifecycle Driver failed: {}", response.error);
194    }
195    Ok(response.state)
196}
197
198#[tokio::main]
199async fn main() -> Result<()> {
200    let parsed = Args::parse();
201    // Apply the manifest's per-component `log:` level (delivered inside
202    // --config-json) to scribe's file sink before the first log line.
203    robonix_scribe::init_from_config("pilot", parsed.config_json.as_deref());
204    info!("robonix-pilot starting");
205
206    let cfg = PilotConfig::resolve(parsed)?;
207
208    info!("connecting to atlas at {}", cfg.atlas_endpoint);
209    let mut atlas =
210        AtlasClient::connect_with_retry(&cfg.atlas_endpoint, 10, Duration::from_secs(2))
211            .await
212            .context("connect to atlas")?;
213
214    atlas.register_service(&cfg.id, PILOT_NAMESPACE, "").await?;
215    info!("registered as '{}' under '{PILOT_NAMESPACE}'", cfg.id);
216
217    let soma_prompt_block = match soma_context::fetch_system_prompt_block(&mut atlas, &cfg.id).await
218    {
219        Ok(Some(block)) => {
220            info!("loaded Soma body context into Pilot system prompt");
221            block
222        }
223        Ok(None) => String::new(),
224        Err(e) => {
225            warn!("Soma body context load failed; continuing without it: {e:#}");
226            String::new()
227        }
228    };
229
230    let listen_addr: std::net::SocketAddr = cfg
231        .listen
232        .parse()
233        .with_context(|| format!("invalid pilot listen address '{}'", cfg.listen))?;
234    // Advertise a concrete loopback for wildcard binds. Consumers cannot dial
235    // 0.0.0.0 or [::], and the address family must match the listening socket.
236    let advertised = startup_driver_endpoint(listen_addr);
237    atlas
238        .declare_capability(
239            &cfg.id,
240            SHARED_DRIVER_CONTRACT,
241            atlas_pb::Transport::Grpc,
242            &advertised,
243            atlas_client::grpc_params(
244                "capabilities/lifecycle/driver.v1.toml",
245                "robonix.contracts.RobonixLifecycleDriver",
246                "/robonix.contracts.RobonixLifecycleDriver/Driver",
247            ),
248        )
249        .await
250        .context("declare Pilot shared lifecycle Driver")?;
251    let lifecycle = SystemLifecycleDriver::new(atlas.clone(), cfg.id.clone());
252    atlas
253        .declare_capability(
254            &cfg.id,
255            "robonix/system/pilot",
256            atlas_pb::Transport::Grpc,
257            &advertised,
258            atlas_client::grpc_params(
259                "capabilities/system/pilot.v1.toml",
260                "robonix.contracts.RobonixSystemPilot",
261                "/robonix.contracts.RobonixSystemPilot/SubmitTask",
262            ),
263        )
264        .await?;
265    info!("declared RobonixSystemPilot gRPC at {advertised}");
266
267    atlas
268        .declare_capability(
269            &cfg.id,
270            "robonix/system/pilot/get_health",
271            atlas_pb::Transport::Grpc,
272            &advertised,
273            atlas_client::grpc_params(
274                "capabilities/system/pilot/get_health.toml",
275                "robonix.contracts.RobonixSystemPilotGetHealth",
276                "/robonix.contracts.RobonixSystemPilotGetHealth/GetModuleHealth",
277            ),
278        )
279        .await?;
280    info!("declared RobonixSystemPilotGetHealth gRPC at {advertised}");
281
282    let vlm = vlm::VlmClient::new(&cfg.vlm);
283    info!(
284        "VLM upstream='{}' model='{}'",
285        cfg.vlm.upstream, cfg.vlm.model
286    );
287
288    let svc = PilotServiceImpl::new(atlas.clone(), cfg.id.clone(), vlm, soma_prompt_block);
289    let server_shutdown = lifecycle.subscribe_shutdown();
290    let server_lifecycle = lifecycle.clone();
291    let mut server_task = tokio::spawn(async move {
292        tonic::transport::Server::builder()
293            .add_service(RobonixLifecycleDriverServer::new(server_lifecycle))
294            .add_service(RobonixSystemPilotServer::new(svc.clone()))
295            .add_service(RobonixSystemPilotGetHealthServer::new(svc))
296            .serve_with_shutdown(listen_addr, wait_for_driver_shutdown(server_shutdown))
297            .await
298    });
299    let startup_endpoint = startup_driver_endpoint(listen_addr);
300    let mut startup_driver = tokio::select! {
301        client = connect_startup_driver(&startup_endpoint) => client?,
302        result = &mut server_task => {
303            result.context("join Pilot gRPC server")?
304                .context("Pilot gRPC server failed before readiness")?;
305            anyhow::bail!("Pilot gRPC server stopped before readiness");
306        }
307    };
308    call_startup_driver(&mut startup_driver, CMD_INIT)
309        .await
310        .context("initialize Pilot lifecycle")?;
311    call_startup_driver(&mut startup_driver, CMD_ACTIVATE)
312        .await
313        .context("activate Pilot lifecycle")?;
314    drop(startup_driver);
315
316    // Atlas evicts providers after ~60s without a heartbeat. Send one every
317    // 20s so we stay registered for the lifetime of the process.
318    {
319        let mut hb = atlas.clone();
320        let provider_id = cfg.id.clone();
321        let shutdown = lifecycle.subscribe_shutdown();
322        tokio::spawn(async move {
323            let mut tick = tokio::time::interval(Duration::from_secs(20));
324            tick.tick().await; // first tick fires immediately; skip
325            let shutdown = wait_for_driver_shutdown(shutdown);
326            tokio::pin!(shutdown);
327            loop {
328                tokio::select! {
329                    _ = &mut shutdown => break,
330                    _ = tick.tick() => {
331                        if let Err(e) = hb.heartbeat(&provider_id).await {
332                            warn!("heartbeat failed: {e:#}");
333                        }
334                    }
335                }
336            }
337        });
338    }
339
340    info!("RobonixSystemPilot gRPC on {listen_addr}");
341    info!("robonix-pilot ready on {listen_addr}");
342
343    server_task
344        .await
345        .context("join Pilot gRPC server")?
346        .context("pilot gRPC server failed")?;
347
348    Ok(())
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354    use pb::contracts::robonix_lifecycle_driver_client::RobonixLifecycleDriverClient;
355    use robonix_atlas::service::{AtlasRegistry, serve_atlas};
356    use std::net::SocketAddr;
357    use std::sync::Arc;
358
359    fn reserve_address() -> SocketAddr {
360        let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("reserve port");
361        listener.local_addr().expect("reserved address")
362    }
363
364    #[test]
365    fn startup_dial_rewrites_unspecified_addresses() {
366        assert_eq!(
367            startup_driver_endpoint("0.0.0.0:51001".parse().unwrap()),
368            "127.0.0.1:51001"
369        );
370        assert_eq!(
371            startup_driver_endpoint("[::]:51001".parse().unwrap()),
372            "[::1]:51001"
373        );
374    }
375
376    /// Dial a just-spawned Driver server, retrying only the short startup race.
377    async fn connect_driver(
378        endpoint: String,
379    ) -> RobonixLifecycleDriverClient<tonic::transport::Channel> {
380        let mut last_error = None;
381        for _ in 0..50 {
382            match RobonixLifecycleDriverClient::connect(endpoint.clone()).await {
383                Ok(client) => return client,
384                Err(error) => last_error = Some(error),
385            }
386            tokio::time::sleep(Duration::from_millis(10)).await;
387        }
388        panic!(
389            "connect Driver: {:#}",
390            last_error.expect("connection error")
391        );
392    }
393
394    /// Exercise the generated Driver client against the real tonic endpoint
395    /// and verify that both RPCs publish their lifecycle states to Atlas.
396    #[tokio::test]
397    async fn shared_driver_rpc_is_callable() {
398        let atlas_addr = reserve_address();
399        let registry = Arc::new(AtlasRegistry::default());
400        let atlas_server = tokio::spawn(serve_atlas(Arc::clone(&registry), atlas_addr));
401        let mut atlas = AtlasClient::connect_with_retry(
402            format!("http://{atlas_addr}"),
403            50,
404            Duration::from_millis(10),
405        )
406        .await
407        .expect("connect test Atlas");
408        let provider_id = "pilot-driver-rpc-test";
409        atlas
410            .register_service(provider_id, PILOT_NAMESPACE, "")
411            .await
412            .expect("register test Provider");
413
414        let driver_addr = reserve_address();
415        atlas
416            .declare_capability(
417                provider_id,
418                SHARED_DRIVER_CONTRACT,
419                atlas_pb::Transport::Grpc,
420                &driver_addr.to_string(),
421                atlas_client::grpc_params(
422                    "capabilities/lifecycle/driver.v1.toml",
423                    "robonix.contracts.RobonixLifecycleDriver",
424                    "/robonix.contracts.RobonixLifecycleDriver/Driver",
425                ),
426            )
427            .await
428            .expect("declare shared Driver");
429        let lifecycle = SystemLifecycleDriver::new(atlas.clone(), provider_id.to_string());
430        let server_shutdown = lifecycle.subscribe_shutdown();
431        let driver_server = tokio::spawn(
432            tonic::transport::Server::builder()
433                .add_service(RobonixLifecycleDriverServer::new(lifecycle))
434                .serve_with_shutdown(driver_addr, wait_for_driver_shutdown(server_shutdown)),
435        );
436        let mut client = connect_driver(format!("http://{driver_addr}")).await;
437
438        let init = client
439            .driver(DriverRequest {
440                command: CMD_INIT,
441                config_json: "{}".to_string(),
442            })
443            .await
444            .expect("Driver INIT RPC")
445            .into_inner();
446        assert!(init.ok, "{}", init.error);
447        assert_eq!(init.state, "inactive");
448        let activate = client
449            .driver(DriverRequest {
450                command: CMD_ACTIVATE,
451                config_json: String::new(),
452            })
453            .await
454            .expect("Driver ACTIVATE RPC")
455            .into_inner();
456        assert!(activate.ok, "{}", activate.error);
457        assert_eq!(activate.state, "active");
458
459        let shutdown = client
460            .driver(DriverRequest {
461                command: CMD_SHUTDOWN,
462                config_json: String::new(),
463            })
464            .await
465            .expect("Driver SHUTDOWN RPC")
466            .into_inner();
467        assert!(shutdown.ok, "{}", shutdown.error);
468        assert_eq!(shutdown.state, "terminated");
469        tokio::time::timeout(Duration::from_secs(2), driver_server)
470            .await
471            .expect("Driver server did not stop after SHUTDOWN")
472            .expect("join Driver server")
473            .expect("graceful Driver server shutdown");
474
475        let provider = atlas
476            .query(
477                atlas_pb::Kind::Service,
478                provider_id,
479                "",
480                "",
481                atlas_pb::Transport::Unspecified,
482            )
483            .await
484            .expect("query test Provider")
485            .pop()
486            .expect("test Provider");
487        assert_eq!(
488            provider.state,
489            atlas_pb::LifecycleState::StateTerminated as i32
490        );
491
492        atlas_server.abort();
493    }
494}