1mod 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 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 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
128async 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
141fn 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
156async 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
178async 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 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 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 {
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; 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 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 #[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(®istry), 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}