Skip to main content

rbnx/cmd/
deploy.rs

1// SPDX-License-Identifier: MulanPSL-2.0
2// `rbnx boot` — bring up the whole robonix stack from a top-level
3// `robonix_manifest.yaml`. (`rbnx boot` is a back-compat alias.)
4//
5// Conventions:
6//   - `system:` Rust binaries (atlas / pilot / executor) are launched with
7//     CLI arguments translated from the manifest block (`--listen`,
8//     `--log`, `--vlm-*`, …). No env-var translation, no YAML config files.
9//   - Package entries (`primitive` / `service`) are launched serially:
10//     spawn → wait for the package to register a provider with a `*/driver`
11//     capability on atlas → call Driver(CMD_INIT, config_json) → wait for
12//     `ok=true`. Only after every primitive's driver returns ok do we move
13//     on to `service:` (which can depend on primitive data being ready).
14//     The package's `config:` block is JSON-encoded and delivered ONLY via
15//     Driver(CMD_INIT)'s config_json field. Omitting Driver is the canonical
16//     way to select the shared lifecycle service. An exact legacy manifest may
17//     use a current shared runtime Driver while it is migrated. A provider
18//     without exactly one lifecycle Driver fails startup.
19//     The provider process never sees a config file or env var.
20//   - `skill:` entries are spawned identically to `service:` — they
21//     need a long-lived process for their MCP tools to be registered
22//     on atlas. The semantic difference (skill = atomic intent
23//     invokable by pilot, service = always-on capability) lives in
24//     the contract namespace (`robonix/skill/*` vs `robonix/service/*`),
25//     not in the lifecycle. The earlier "skill is registered but not
26//     spawned" model lied about what was actually running and forced
27//     manifest authors to put skills like explore in `service:` as a
28//     workaround.
29//
30// Out of scope: crash-restart, health checks beyond Driver(INIT).
31
32use anyhow::{Context, Result};
33use robonix_atlas::client::AtlasClient;
34use robonix_atlas::pb as atlas_pb;
35use robonix_cli::launch::{
36    PackageRuntimeRecord, ProviderRegistrationSnapshot, RegistrationOutcome,
37    contract_id_to_service_name, resolve_runtime_driver_contract, snapshot_provider_ids,
38    terminate_process_group,
39};
40use robonix_cli::output;
41use serde::Deserialize;
42use sha2::{Digest, Sha256};
43use std::collections::{HashMap, HashSet};
44use std::os::fd::RawFd;
45use std::path::{Path, PathBuf};
46use std::process::Stdio;
47use std::time::{Duration, Instant};
48use tokio::io::AsyncBufReadExt;
49use tokio::process::{Child, Command};
50use tokio::signal::unix::{SignalKind, signal};
51use tonic::Request;
52use tonic::transport::Endpoint;
53use uuid::Uuid;
54
55use robonix_scribe as scribe;
56
57use crate::pb::lifecycle::{DriverRequest, DriverResponse};
58
59use super::teardown;
60
61// Driver.srv command discriminators (mirrors lifecycle/srv/Driver.srv).
62const CMD_INIT: u32 = 0;
63const CMD_ACTIVATE: u32 = 1;
64#[allow(dead_code)]
65const CMD_DEACTIVATE: u32 = 2;
66#[allow(dead_code)]
67const CMD_SHUTDOWN: u32 = 3;
68// How long to wait for a freshly spawned package to register its driver
69// capability with atlas before giving up.
70const DRIVER_REGISTER_TIMEOUT: Duration = Duration::from_secs(60);
71// Default Driver(CMD_INIT) deadline. Webots CI can override this with
72// ROBONIX_DRIVER_INIT_TIMEOUT_S for real stacks whose lifecycle bringup may
73// exceed 90s on a cold self-hosted runner.
74const DEFAULT_DRIVER_INIT_TIMEOUT: Duration = Duration::from_secs(90);
75const DEPLOY_CONSUMER_ID: &str = "rbnx-cli/deploy";
76
77/// Single source of truth for which `system:` keys are shipped binaries
78/// rather than packages under `<robonix_source>/system/<key>/`.
79pub(super) const SYSTEM_BUILTINS: &[&str] =
80    &["atlas", "executor", "pilot", "liaison", "soma", "vitals"];
81
82pub(super) fn is_builtin_system(name: &str) -> bool {
83    SYSTEM_BUILTINS.contains(&name)
84}
85
86fn driver_init_timeout() -> Duration {
87    std::env::var("ROBONIX_DRIVER_INIT_TIMEOUT_S")
88        .ok()
89        .and_then(|s| s.parse::<u64>().ok())
90        .filter(|secs| *secs > 0)
91        .map(Duration::from_secs)
92        .unwrap_or(DEFAULT_DRIVER_INIT_TIMEOUT)
93}
94
95// ── Deploy manifest schema (subset used by this orchestrator) ───────────
96
97#[derive(Debug, Clone, Deserialize, Default)]
98struct DeployManifest {
99    #[serde(default)]
100    name: String,
101    #[serde(default)]
102    system: HashMap<String, serde_yaml::Value>,
103    #[serde(default)]
104    primitive: Vec<PackageEntry>,
105    #[serde(default)]
106    service: Vec<PackageEntry>,
107    #[serde(default)]
108    skill: Vec<PackageEntry>,
109}
110
111#[derive(Debug, Clone, Deserialize)]
112struct PackageEntry {
113    /// Package identifier for logs (falls back to the directory basename).
114    #[serde(default)]
115    name: String,
116    /// Local filesystem path (relative to the manifest dir). Mutually
117    /// exclusive with `url`.
118    #[serde(default)]
119    path: Option<String>,
120    /// Git URL for remote packages (e.g. the standalone mapping or nav
121    /// repos too big to ship inside `examples/`). `rbnx boot` clones
122    /// into `<manifest-dir>/rbnx-boot/cache/<name>/` on first run and
123    /// reuses that checkout on subsequent runs. Mutually exclusive with
124    /// `path`.
125    #[serde(default)]
126    url: Option<String>,
127    /// Git branch / tag / commit to check out. Defaults to the default
128    /// branch at clone time. Ignored when `path` is used.
129    #[serde(default)]
130    branch: Option<String>,
131    /// Opaque config block; serialised to JSON and delivered through
132    /// Driver(CMD_INIT). Startup fails if the provider does not declare its
133    /// selected shared or exact compatible legacy lifecycle Driver.
134    #[serde(default)]
135    config: serde_yaml::Value,
136    /// Optional package-manifest filename override. A package may ship
137    /// per-deployment-target manifests (e.g. `package_manifest.yaml` for
138    /// x86+docker, `package_manifest.jetson-native.yaml`,
139    /// `package_manifest.jetson-docker.yaml`), each with its own build/start.
140    /// This selects which one `rbnx build`/`boot` uses for THIS deployment;
141    /// the package-manifest schema itself is unchanged. Defaults to
142    /// `package_manifest.yaml`.
143    #[serde(default)]
144    manifest: Option<String>,
145}
146
147/// Compute a `PackageEntry`'s expected on-disk path. PURE — no I/O,
148/// no logging, no cloning. `path:` entries land at `manifest_dir/path`;
149/// `url:` entries land at `cache_root/<name>` (whether or not it's
150/// been cloned yet). Use `entry_path_exists_on_disk` to check
151/// presence; use the public `cmd::fetch::clone_remote_packages`
152/// (called from `rbnx build`) to actually populate the cache.
153/// Cache directory name for a url-remote package: the git REPO name (last path
154/// segment of the url, minus `.git`), NOT the per-instance provider id.
155///
156/// A single repo can back several providers/instances in one manifest (each
157/// with its own `name`/provider_id); they must share ONE clone. Keying the
158/// cache dir by `name` would clone the same repo once per instance — and the
159/// directory wouldn't reflect what was actually cloned. Key it by the repo.
160///
161/// Compatibility forwarding entry for sibling commands. The implementation
162/// lives in `robonix_cli::manifest` so Soma and rbnx use one rule.
163pub(crate) fn repo_dir_name(url: &str) -> String {
164    robonix_cli::manifest::deploy_repo_dir_name(url)
165}
166
167fn resolve_entry_path(
168    entry: &PackageEntry,
169    cache_root: &Path,
170    manifest_dir: &Path,
171) -> Result<PathBuf> {
172    match (&entry.path, &entry.url) {
173        (Some(p), None) => Ok(manifest_dir.join(p)),
174        (None, Some(url)) => Ok(cache_root.join(repo_dir_name(url))),
175        (Some(_), Some(_)) => {
176            anyhow::bail!("package entry has both `path` and `url`; pick one")
177        }
178        (None, None) => {
179            anyhow::bail!("package entry has neither `path` nor `url`")
180        }
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    fn git(dir: &Path, args: &[&str]) {
189        let ok = std::process::Command::new("git")
190            .args(args)
191            .current_dir(dir)
192            .output()
193            .expect("git")
194            .status
195            .success();
196        assert!(ok, "git {args:?} failed in {}", dir.display());
197    }
198
199    #[test]
200    fn the_lockfile_records_the_commit_and_whether_the_tree_was_edited() {
201        // A deployment pins packages by branch, so the same manifest is not the
202        // same code twice, and a cache cloned once is reused untouched. One was
203        // found three commits behind its branch with uncommitted edits on top
204        // and nothing about the run said so. The lockfile is what says so.
205        let nonce = std::time::SystemTime::now()
206            .duration_since(std::time::UNIX_EPOCH)
207            .expect("system clock")
208            .as_nanos();
209        let temp = std::env::temp_dir().join(format!("rbnx-lock-{}-{nonce}", std::process::id()));
210        let pkg = temp.join("pkg");
211        std::fs::create_dir_all(&pkg).expect("package directory");
212        git(&pkg, &["init", "-q"]);
213        git(&pkg, &["config", "user.email", "test@example.invalid"]);
214        git(&pkg, &["config", "user.name", "test"]);
215        std::fs::write(pkg.join("f"), "one").expect("file");
216        git(&pkg, &["add", "."]);
217        git(&pkg, &["commit", "-qm", "one"]);
218
219        let deploy: DeployManifest =
220            serde_yaml::from_str("primitive:\n  - name: sample\n    path: pkg\n    branch: main\n")
221                .expect("deployment manifest");
222        let cache_root = temp.join("rbnx-boot/cache");
223        let lock = temp.join("rbnx-boot/deployment.lock");
224
225        write_lockfile(&deploy, &cache_root, &temp);
226        let clean = std::fs::read_to_string(&lock).expect("lockfile");
227        assert!(clean.contains("version: 1"), "{clean}");
228        assert!(clean.contains("name: sample"), "{clean}");
229        // what was asked for and what it resolved to are kept apart
230        assert!(clean.contains("original:"), "{clean}");
231        assert!(clean.contains("branch: main"), "{clean}");
232        assert!(clean.contains("locked:"), "{clean}");
233        let head = std::process::Command::new("git")
234            .args(["rev-parse", "HEAD"])
235            .current_dir(&pkg)
236            .output()
237            .expect("git rev-parse");
238        let head = String::from_utf8_lossy(&head.stdout).trim().to_string();
239        assert!(clean.contains(&format!("commit: {head}")), "{clean}");
240        assert!(
241            !clean.contains("dirty"),
242            "a clean checkout must not be flagged: {clean}"
243        );
244
245        // Deterministic: running again with nothing changed must not rewrite it.
246        let before = std::fs::metadata(&lock)
247            .expect("lock metadata")
248            .modified()
249            .ok();
250        write_lockfile(&deploy, &cache_root, &temp);
251        assert_eq!(std::fs::read_to_string(&lock).expect("lockfile"), clean);
252        assert_eq!(
253            std::fs::metadata(&lock)
254                .expect("lock metadata")
255                .modified()
256                .ok(),
257            before,
258            "an unchanged deployment must not rewrite its lock"
259        );
260
261        // An edited checkout is the case the branch name cannot describe. A
262        // flag alone cannot tell two different edits apart, so it carries a
263        // digest of them.
264        std::fs::write(pkg.join("f"), "two").expect("edit");
265        write_lockfile(&deploy, &cache_root, &temp);
266        let dirty = std::fs::read_to_string(&lock).expect("lockfile");
267        assert!(dirty.contains("dirty: true"), "{dirty}");
268        assert!(dirty.contains("dirty_digest: sha256:"), "{dirty}");
269
270        let first_digest = dirty
271            .lines()
272            .find(|l| l.contains("dirty_digest"))
273            .expect("digest line")
274            .trim()
275            .to_string();
276        std::fs::write(pkg.join("f"), "three").expect("second edit");
277        write_lockfile(&deploy, &cache_root, &temp);
278        let dirty2 = std::fs::read_to_string(&lock).expect("lockfile");
279        assert!(
280            !dirty2.contains(&first_digest),
281            "a different edit must not share a digest: {dirty2}"
282        );
283
284        std::fs::remove_dir_all(&temp).expect("remove test directory");
285    }
286
287    #[test]
288    fn boot_prerequisites_build_scene_but_skip_vitals_builtin() {
289        let nonce = std::time::SystemTime::now()
290            .duration_since(std::time::UNIX_EPOCH)
291            .expect("system clock")
292            .as_nanos();
293        let temp = std::env::temp_dir().join(format!(
294            "rbnx-system-prerequisite-{}-{nonce}",
295            std::process::id()
296        ));
297        let scene = temp.join("system/scene");
298        std::fs::create_dir_all(&scene).expect("scene package directory");
299        std::fs::create_dir_all(temp.join("system/vitals")).expect("vitals builtin directory");
300        std::fs::write(
301            scene.join("package_manifest.yaml"),
302            r#"manifestVersion: 1
303package:
304  name: com.robonix.system.scene.test
305  version: 0.1.0
306  description: test system package
307  license: MulanPSL-2.0
308build: mkdir -p rbnx-build && touch rbnx-build/proof
309start: "true"
310stop: "true"
311"#,
312        )
313        .expect("test package manifest");
314
315        let deploy = DeployManifest {
316            system: HashMap::from([
317                ("scene".to_string(), serde_yaml::Value::Null),
318                ("vitals".to_string(), serde_yaml::Value::Null),
319            ]),
320            ..Default::default()
321        };
322        let manifest_dir = temp.join("deployment");
323        let cache_root = manifest_dir.join("rbnx-boot/cache");
324        std::fs::create_dir_all(&cache_root).expect("cache directory");
325
326        check_prerequisites(&deploy, &cache_root, &manifest_dir, Some(&temp))
327            .expect("system-package prerequisite build");
328
329        assert!(scene.join("rbnx-build/proof").is_file());
330        assert!(scene.join("rbnx-build/.rbnx-built").is_file());
331        std::fs::remove_dir_all(temp).expect("remove test directory");
332    }
333
334    #[test]
335    fn soma_always_receives_the_selected_boot_manifest() {
336        use serde_yaml::{Mapping, Value};
337
338        let manifest_dir = PathBuf::from("/tmp/ranger-deploy");
339        let selected = manifest_dir.join("robonix_manifest.arm.yaml");
340        let mut soma = Mapping::new();
341        // A stale local value must not make Soma boot the default profile
342        // after `rbnx boot -f <arm-profile>`.
343        soma.insert(
344            Value::String("deployment_manifest".into()),
345            Value::String("robonix_manifest.yaml".into()),
346        );
347        let mut system = HashMap::from([("soma".to_string(), Value::Mapping(soma))]);
348
349        ensure_soma_defaults(&mut system, &manifest_dir, &selected);
350        let args = system_cli_args("soma", system.get("soma"), None);
351        let manifest_arg = args
352            .windows(2)
353            .find(|pair| pair[0] == "--deployment-manifest")
354            .map(|pair| pair[1].as_str());
355
356        assert_eq!(manifest_arg, Some(selected.to_string_lossy().as_ref()));
357    }
358
359    #[test]
360    fn vitals_receives_typed_manifest_fields() {
361        let cfg: serde_yaml::Value = serde_yaml::from_str(
362            r#"
363listen: 0.0.0.0:50093
364provider_id: vitals
365thresholds_path: config/vitals.yaml
366soma_endpoint: 127.0.0.1:50091
367"#,
368        )
369        .unwrap();
370        let args = system_cli_args("vitals", Some(&cfg), Some("0.0.0.0:50051"));
371
372        for expected in [
373            ["--listen", "0.0.0.0:50093"],
374            ["--atlas", "0.0.0.0:50051"],
375            ["--id", "vitals"],
376            ["--thresholds-path", "config/vitals.yaml"],
377            ["--soma-endpoint", "127.0.0.1:50091"],
378        ] {
379            assert!(
380                args.windows(2)
381                    .any(|pair| pair[0] == expected[0] && pair[1] == expected[1]),
382                "missing {:?} in {:?}",
383                expected,
384                args
385            );
386        }
387    }
388
389    #[test]
390    fn provider_failure_ignores_later_shutdown_noise() {
391        let path = std::env::temp_dir().join(format!(
392            "rbnx-provider-failure-{}.log",
393            uuid::Uuid::new_v4()
394        ));
395        std::fs::write(
396            &path,
397            concat!(
398                "{\"level\":\"info\",\"msg\":\"ready -- awaiting Driver(CMD_INIT)\"}\n",
399                "{\"level\":\"info\",\"msg\":\"[ranger_chassis] state REGISTERED -> ERROR (CAN setup failed: sudo password required)\"}\n",
400                "{\"level\":\"info\",\"msg\":\"shutdown hook completed\"}\n",
401            ),
402        )
403        .unwrap();
404
405        assert_eq!(
406            read_provider_failure(&path).as_deref(),
407            Some("CAN setup failed: sudo password required")
408        );
409        let _ = std::fs::remove_file(path);
410    }
411
412    #[test]
413    fn provider_failure_accepts_error_level_records() {
414        let path = std::env::temp_dir().join(format!(
415            "rbnx-provider-error-level-{}.log",
416            uuid::Uuid::new_v4()
417        ));
418        std::fs::write(
419            &path,
420            "{\"level\":\"error\",\"msg\":\"camera device disconnected\"}\n",
421        )
422        .unwrap();
423
424        assert_eq!(
425            read_provider_failure(&path).as_deref(),
426            Some("camera device disconnected")
427        );
428        let _ = std::fs::remove_file(path);
429    }
430
431    #[test]
432    fn provider_exit_summary_uses_last_structured_message() {
433        let path = std::env::temp_dir().join(format!(
434            "rbnx-provider-exit-summary-{}.log",
435            uuid::Uuid::new_v4()
436        ));
437        std::fs::write(
438            &path,
439            concat!(
440                "{\"level\":\"info\",\"msg\":\"Traceback (most recent call last):\"}\n",
441                "{\"level\":\"info\",\"msg\":\"ImportError: generated contract is missing\"}\n",
442                "{\"level\":\"info\",\"msg\":\"Error: scene process exited with status 1\"}\n",
443            ),
444        )
445        .unwrap();
446
447        assert_eq!(
448            read_provider_exit_summary(&path).as_deref(),
449            Some("Error: scene process exited with status 1")
450        );
451        let _ = std::fs::remove_file(path);
452    }
453}
454
455/// Boot-time prerequisites check:
456///   - any url-remote package whose cache dir doesn't exist → warn,
457///     clone it inline (so the user isn't blocked) and tell them to
458///     run `rbnx build` for proper bring-up.
459///   - any package whose `rbnx-build/.rbnx-built` sentinel is missing
460///     → warn and run its build.sh inline.
461///
462/// Boot's job is to spawn and atlas-register; fetching and building
463/// belong to `rbnx build`. We do the inline remediation here ONLY so
464/// the user isn't stuck after a fresh clone with no build done — the
465/// warnings are deliberately loud so the right path (build first,
466/// then boot) stays visible.
467fn check_prerequisites(
468    deploy: &DeployManifest,
469    cache_root: &Path,
470    manifest_dir: &Path,
471    robonix_source_path: Option<&Path>,
472) -> Result<()> {
473    use std::collections::BTreeMap;
474    // value: (url, branch, manifest_override)
475    let mut needs_clone: BTreeMap<String, (String, Option<String>, Option<String>)> =
476        BTreeMap::new();
477    // value: (pkg_path, manifest_override)
478    let mut needs_build: BTreeMap<String, (PathBuf, Option<String>)> = BTreeMap::new();
479    for entry in deploy
480        .primitive
481        .iter()
482        .chain(deploy.service.iter())
483        .chain(deploy.skill.iter())
484    {
485        let pkg_path = match resolve_entry_path(entry, cache_root, manifest_dir) {
486            Ok(p) => p,
487            Err(_) => continue, // bad manifest entry; later steps will surface it
488        };
489        let name = if entry.name.is_empty() {
490            pkg_path
491                .file_name()
492                .and_then(|n| n.to_str())
493                .unwrap_or("(unnamed)")
494                .to_string()
495        } else {
496            entry.name.clone()
497        };
498        if !pkg_path.exists()
499            && let Some(url) = entry.url.as_ref()
500        {
501            needs_clone.insert(
502                name.clone(),
503                (url.clone(), entry.branch.clone(), entry.manifest.clone()),
504            );
505            continue;
506        }
507        let stamp = pkg_path.join("rbnx-build").join(".rbnx-built");
508        if !stamp.exists() {
509            needs_build.insert(name, (pkg_path, entry.manifest.clone()));
510        }
511    }
512
513    // Non-builtin `system:` entries are packages too.  They are resolved
514    // from the configured Robonix source tree rather than from an explicit
515    // deployment `path:`, so the package loop above cannot see them.  Build
516    // them during prerequisites just like primitive/service/skill packages;
517    // otherwise `rbnx start` performs the build after spawn and the provider
518    // registration timeout can kill a legitimate first build (Scene model
519    // downloads are a common example).
520
521    if let Some(source_root) = robonix_source_path {
522        for name in deploy.system.keys() {
523            if is_builtin_system(name.as_str()) {
524                continue;
525            }
526            let pkg_path = source_root.join("system").join(name);
527            if !pkg_path.exists() {
528                continue; // the later system-package loop reports optional packages
529            }
530            let stamp = pkg_path.join("rbnx-build").join(".rbnx-built");
531            if !stamp.exists() {
532                needs_build.insert(name.clone(), (pkg_path, None));
533            }
534        }
535    }
536    if needs_clone.is_empty() && needs_build.is_empty() {
537        return Ok(());
538    }
539    output::boot_section("prerequisites");
540    for (name, (url, branch, manifest_ov)) in &needs_clone {
541        output::warning(&format!(
542            "{name}: not in cache — `rbnx build` should run before `rbnx boot`. cloning inline."
543        ));
544        let dest = cache_root.join(repo_dir_name(url));
545        std::fs::create_dir_all(cache_root)?;
546        super::run_package::git_clone_with_retry(url, branch.as_deref(), &dest)?;
547        // Newly-cloned package needs a build too.
548        let stamp = dest.join("rbnx-build").join(".rbnx-built");
549        if !stamp.exists() {
550            needs_build.insert(name.clone(), (dest, manifest_ov.clone()));
551        }
552    }
553    for (name, (pkg_path, manifest_ov)) in &needs_build {
554        output::warning(&format!(
555            "{name}: not built — `rbnx build` should run before `rbnx boot`. building inline."
556        ));
557        crate::cmd::build::build_local_package(pkg_path, false, manifest_ov.as_deref())
558            .with_context(|| format!("inline build of {name} at {} failed", pkg_path.display()))?;
559    }
560    Ok(())
561}
562
563/// One package as it was actually found on disk when the deployment ran.
564struct ResolvedPackage {
565    name: String,
566    /// What the manifest asked for.
567    url: Option<String>,
568    path: Option<String>,
569    branch: Option<String>,
570    /// What that resolved to.
571    commit: Option<String>,
572    dirty_digest: Option<String>,
573}
574
575/// `git rev-parse HEAD`, and a digest of the uncommitted changes if any.
576///
577/// Both are read from the checkout, not from the manifest: a manifest only ever
578/// names a branch, and a branch is not a version. Returns `(None, None)` for
579/// anything that is not a git checkout — a `path:` entry into the source tree
580/// is versioned by whatever contains it.
581///
582/// A dirty tree gets a digest rather than a bare flag. `dirty: true` says the
583/// commit is not the whole story; a digest says *which* not-the-whole-story,
584/// so two machines claiming the same lock can be compared. This is the same
585/// device the project's own reproducibility record uses for its overlays.
586fn git_state(dir: &Path) -> (Option<String>, Option<String>) {
587    let run = |args: &[&str]| -> Option<String> {
588        std::process::Command::new("git")
589            .args(args)
590            .current_dir(dir)
591            .output()
592            .ok()
593            .filter(|o| o.status.success())
594            .map(|o| String::from_utf8_lossy(&o.stdout).to_string())
595    };
596    let head = run(&["rev-parse", "HEAD"])
597        .map(|s| s.trim().to_string())
598        .filter(|s| !s.is_empty());
599    if head.is_none() {
600        return (None, None);
601    }
602    // Tracked edits and the list of untracked files, together: either alone
603    // misses a way the checkout can differ from its commit.
604    let status = run(&["status", "--porcelain"]).unwrap_or_default();
605    if status.trim().is_empty() {
606        return (head, None);
607    }
608    let diff = run(&["diff", "HEAD"]).unwrap_or_default();
609    let mut hasher = Sha256::new();
610    hasher.update(status.as_bytes());
611    hasher.update(diff.as_bytes());
612    (head, Some(format!("sha256:{:x}", hasher.finalize())))
613}
614
615/// Write `rbnx-boot/deployment.lock`: what this run actually used.
616///
617/// Shaped after the lockfiles that had to solve this already. From
618/// `flake.lock`, the split between what was asked for and what it resolved to:
619/// a branch is the request, a commit is the answer, and conflating them is the
620/// bug this file exists to expose. From `Cargo.lock` and `package-lock.json`, a
621/// format version, so the file can change without a reader guessing. From all
622/// three, determinism: entries sorted, no timestamp, so the file changes when
623/// the deployment changes and not when it merely ran again — a lock that
624/// rewrites itself on every boot is one nobody reads a diff of.
625///
626/// Written, not enforced. A deployment was found three commits behind its
627/// branch with uncommitted edits on top and no artifact of the run said so.
628/// This is that artifact; refusing to boot on a moved branch is a separate
629/// decision that needs a fetch and a policy.
630fn write_lockfile(deploy: &DeployManifest, cache_root: &Path, manifest_dir: &Path) {
631    let mut rows: Vec<ResolvedPackage> = Vec::new();
632    for entry in deploy
633        .primitive
634        .iter()
635        .chain(deploy.service.iter())
636        .chain(deploy.skill.iter())
637    {
638        let Ok(pkg_path) = resolve_entry_path(entry, cache_root, manifest_dir) else {
639            continue;
640        };
641        if !pkg_path.exists() {
642            continue;
643        }
644        let (commit, dirty_digest) = git_state(&pkg_path);
645        rows.push(ResolvedPackage {
646            name: if entry.name.is_empty() {
647                pkg_path
648                    .file_name()
649                    .and_then(|n| n.to_str())
650                    .unwrap_or("(unnamed)")
651                    .to_string()
652            } else {
653                entry.name.clone()
654            },
655            url: entry.url.clone(),
656            path: entry.path.clone(),
657            branch: entry.branch.clone(),
658            commit,
659            dirty_digest,
660        });
661    }
662    // Deterministic order: the file is meant to be diffed.
663    rows.sort_by(|a, b| a.name.cmp(&b.name));
664
665    for row in rows.iter().filter(|r| r.dirty_digest.is_some()) {
666        output::warning(&format!(
667            "{}: the cached checkout has uncommitted changes — this run is not \
668             reproducible from its branch alone",
669            row.name
670        ));
671    }
672
673    let mut out = String::from(
674        "# Generated by `rbnx boot`. Do not edit.\n\
675         #\n\
676         # `original` is what the manifest asked for, `locked` is what that\n\
677         # resolved to on this machine. A manifest pins packages by branch and a\n\
678         # cache is cloned once, so the two are not the same thing and only this\n\
679         # file records the second.\n\
680         version: 1\n\
681         packages:\n",
682    );
683    for row in &rows {
684        out.push_str(&format!("  - name: {}\n    original:\n", row.name));
685        if let Some(u) = &row.url {
686            out.push_str(&format!("      url: {u}\n"));
687        }
688        if let Some(p) = &row.path {
689            out.push_str(&format!("      path: {p}\n"));
690        }
691        if let Some(b) = &row.branch {
692            out.push_str(&format!("      branch: {b}\n"));
693        }
694        out.push_str("    locked:\n");
695        match &row.commit {
696            Some(c) => out.push_str(&format!("      commit: {c}\n")),
697            None => out.push_str("      commit: null    # not a git checkout\n"),
698        }
699        if let Some(d) = &row.dirty_digest {
700            out.push_str(&format!("      dirty: true\n      dirty_digest: {d}\n"));
701        }
702    }
703
704    let path = manifest_dir.join("rbnx-boot").join("deployment.lock");
705    if let Some(parent) = path.parent() {
706        let _ = std::fs::create_dir_all(parent);
707    }
708    // Only rewrite when something changed, so the file's mtime means something
709    // and a boot that changed nothing leaves no diff.
710    if std::fs::read_to_string(&path).ok().as_deref() == Some(out.as_str()) {
711        return;
712    }
713    if let Err(e) = std::fs::write(&path, out) {
714        output::warning(&format!("could not write {}: {e}", path.display()));
715    }
716}
717
718/// Apply top-level deployment variables, then expand every scalar in the
719/// manifest. Build and boot share this preparation path so package locations,
720/// target-manifest selectors, system settings, and package config all resolve
721/// against the same environment.
722pub(super) fn prepare_manifest(
723    root: serde_yaml::Value,
724    robonix_source_path: Option<&Path>,
725) -> Result<serde_yaml::Value> {
726    let prepared = robonix_cli::manifest::prepare_deployment_manifest(root, robonix_source_path)?;
727    robonix_cli::manifest::validate_deployment_instance_names(&prepared)?;
728    Ok(prepared)
729}
730
731/// Make sure `system.soma` exists in the manifest map as a mapping,
732/// and resolve any relative file paths inside it against `manifest_dir`.
733///
734/// v2 soma has four flat config keys — `atlas_endpoint`, `listen`,
735/// `provider_id`, `robot_yaml` — and rbnx forwards them via CLI.
736///
737/// This helper handles the two "soma implied but not spelled out"
738/// manifest patterns:
739///   * `system.soma:` with no body — a bare tag or null.
740///   * no `system.soma:` at all, but `primitive:` / `skill:` present.
741///
742/// In both cases we promote / insert an empty mapping so the rest of
743/// deploy.rs (system_cli_args, the builtin loop, the stage 2 pipe)
744/// sees a populated entry. Existing operator-supplied values are
745/// NEVER overwritten.
746///
747/// It also normalises the two path-valued fields inside `system.soma`
748/// — `robot_yaml` and `config` — from possibly relative to always
749/// absolute. rbnx passes both straight through to `robonix-soma` as
750/// CLI flags without chdir-ing, and soma itself only resolves paths
751/// relative to its `--config` file's parent dir (not the manifest's
752/// dir), so any relative value written by the operator has to be
753/// pinned here or soma will try to open it from its own cwd — which
754/// on systemd-launched Jetsons is `/`, producing errors like
755/// `read Soma config '/soma_config.local.yaml' … No such file`.
756///
757/// Semantics: if the value is absolute it is left untouched (operator
758/// escape hatch for bind-mounts, /opt paths, etc.); if it's relative
759/// (including bare filenames like `soma.yaml`) it is joined onto the
760/// manifest's own directory. Non-string values are ignored — malformed
761/// manifests will surface the type mismatch at soma CLI parse time
762/// rather than being silently rewritten here.
763///
764/// `robot_yaml` auto-injection: soma refuses to boot without a
765/// `--robot-yaml` — it needs the robot description before it can
766/// spawn any primitive in stage 1. Previously we left this to the
767/// operator, but the failure mode ("missing robot_yaml" → soma exits
768/// → rbnx sits in `wait_for_soma_stage1` for the full 180s timeout
769/// before reporting failure) is disproportionately painful for a
770/// missing default. So: if the operator hasn't set `robot_yaml` and
771/// a file literally named `soma.yaml` sits next to the manifest, we
772/// inject that path. If neither is present, we leave the slot empty
773/// — soma will bail on config parse with a clear error, and the
774/// stage-1 waiter (see `wait_for_soma_stage1`) will surface soma's
775/// early exit instead of waiting for the timeout. Operators who
776/// want a different name still just set `robot_yaml:` explicitly.
777fn ensure_soma_defaults(
778    system: &mut HashMap<String, serde_yaml::Value>,
779    manifest_dir: &Path,
780    manifest_path: &Path,
781) {
782    use serde_yaml::{Mapping, Value};
783    let entry = system
784        .entry("soma".to_string())
785        .or_insert_with(|| Value::Mapping(Mapping::new()));
786    // Promote a non-mapping value (`soma: ~`, `soma: true`, ...) to an
787    // empty mapping so operators who wrote `soma:` with no body get a
788    // usable slot rather than a parse-time surprise.
789    if !entry.is_mapping() {
790        *entry = Value::Mapping(Mapping::new());
791    }
792    let map = entry
793        .as_mapping_mut()
794        .expect("promoted to mapping just above");
795
796    // Soma launches primitive and skill packages itself. It therefore must
797    // read the exact deployment file selected by `rbnx boot -f`, not infer a
798    // sibling default manifest from robot_yaml. This is intentionally owned
799    // by the boot command: a stale manifest-local value must not make rbnx
800    // build one profile while Soma starts another.
801    map.insert(
802        Value::String("deployment_manifest".to_string()),
803        Value::String(manifest_path.to_string_lossy().into_owned()),
804    );
805
806    // Auto-inject `robot_yaml: <manifest_dir>/soma.yaml` when the
807    // operator didn't set it AND the file exists. We check for the
808    // key's presence-and-non-emptiness rather than presence alone so
809    // an unset `${ROBOT_YAML}` (which manifest preparation turns into
810    // "") still triggers the sidecar lookup. Missing sidecar → leave
811    // absent (soma's own config-resolve error is the right signal;
812    // stage-1 waiter surfaces the early exit fast).
813    let robot_yaml_key = Value::String("robot_yaml".to_string());
814    let robot_yaml_missing = match map.get(&robot_yaml_key) {
815        None => true,
816        Some(Value::Null) => true,
817        Some(Value::String(s)) if s.is_empty() => true,
818        _ => false,
819    };
820    if robot_yaml_missing {
821        let sidecar = manifest_dir.join("soma.yaml");
822        if sidecar.is_file() {
823            map.insert(
824                robot_yaml_key,
825                Value::String(sidecar.to_string_lossy().into_owned()),
826            );
827        }
828    }
829
830    for key in ["robot_yaml", "deployment_manifest", "config"] {
831        let k = Value::String(key.to_string());
832        let Some(v) = map.get_mut(&k) else { continue };
833        let Some(s) = v.as_str() else { continue };
834        // Empty string usually means "${SOME_UNSET_VAR}" got expanded
835        // away by manifest preparation. Don't paper over that by turning
836        // it into `manifest_dir/` — leave it empty so soma's own
837        // "read Soma config '' … No such file" error still fires and
838        // the operator gets a signal instead of a mystery success.
839        if s.is_empty() {
840            continue;
841        }
842        let p = Path::new(s);
843        if p.is_absolute() {
844            continue;
845        }
846        let joined = manifest_dir.join(p);
847        *v = Value::String(joined.to_string_lossy().into_owned());
848    }
849}
850
851// ── child-process helpers ───────────────────────────────────────────────
852
853struct Spawned {
854    name: String,
855    /// "system_builtin" | "system_package" | "primitive" | "service"
856    kind: String,
857    child: Child,
858    pid: u32,
859    /// Process group id. Each child is spawned with `process_group(0)` so
860    /// it becomes the leader of a new PGID == its own PID.
861    pgid: u32,
862    provider_id: Option<String>,
863    driver_contract: Option<String>,
864    /// Lifecycle contract selected by this package's exact manifest. Builtin
865    /// system processes are not package-managed and leave this unset.
866    expected_driver_contract: Option<String>,
867    /// True only for an explicit legacy selection, permitting a current shared
868    /// runtime Driver while the manifest is migrated.
869    allow_shared_driver_upgrade: bool,
870    config_json: Option<String>,
871    package_dir: Option<PathBuf>,
872    stop: Option<String>,
873}
874
875fn log_path(log_dir: &Path, name: &str) -> PathBuf {
876    // `name` is the provider_id — the exact Scribe tag, so `<name>.log` is the
877    // real file rbnx should point at. No name mangling.
878    log_dir.join(format!("{name}.log"))
879}
880
881async fn spawn_system_binary(
882    log_dir: &Path,
883    name: &str,
884    bin: &str,
885    args: &[String],
886) -> Result<Spawned> {
887    // Run the installed binary directly.  Stdout / stderr are piped
888    // through Scribe (tag = binary name, e.g. "executor") so nothing
889    // escapes to the terminal.  Structured logs from within the binary
890    // also go through Scribe via the `log` facade auto-init.
891    let mut cmd = Command::new(bin);
892    for a in args {
893        cmd.arg(a);
894    }
895    cmd.stdin(Stdio::null())
896        .stdout(Stdio::piped())
897        .stderr(Stdio::piped())
898        .env("SCRIBE_LOG_DIR", log_dir)
899        .process_group(0);
900    let mut child = cmd.spawn().with_context(|| {
901        format!(
902            "failed to spawn system binary `{bin}` — is it installed (try `make install` from the rust/ workspace)?"
903        )
904    })?;
905    let pid = child
906        .id()
907        .ok_or_else(|| anyhow::anyhow!("spawned `{bin}` but it had no pid"))?;
908
909    // Pipe stdout / stderr into Scribe so raw println!/eprintln! from the
910    // binary are captured alongside its structured logs.
911    let stdout = child.stdout.take().expect("stdout not piped");
912    let stderr = child.stderr.take().expect("stderr not piped");
913    let tag_out = name.to_string();
914    let tag_err = name.to_string();
915    tokio::spawn(async move {
916        let reader = tokio::io::BufReader::new(stdout);
917        let mut lines = reader.lines();
918        while let Ok(Some(line)) = lines.next_line().await {
919            scribe::ingest(&tag_out, &line);
920        }
921    });
922    tokio::spawn(async move {
923        let reader = tokio::io::BufReader::new(stderr);
924        let mut lines = reader.lines();
925        while let Ok(Some(line)) = lines.next_line().await {
926            // stderr is not always errors — Python logging defaults to
927            // stderr for INFO too.  Use `info` to avoid misrepresenting
928            // the actual severity.
929            scribe::ingest(&tag_err, &line);
930        }
931    });
932    // Salient detail per builtin: port + role, redact long flag soup
933    // (--capabilities path lists, --vlm-api-key, …). Full args are
934    // available in the log file; the boot line stays terse so users
935    // can scan the bring-up sequence at a glance.
936    let detail = system_boot_detail(name, args);
937    output::boot_ok(name, &detail);
938    Ok(Spawned {
939        name: name.to_string(),
940        kind: "system_builtin".to_string(),
941        child,
942        pid,
943        pgid: pid,
944        provider_id: None,
945        driver_contract: None,
946        expected_driver_contract: None,
947        allow_shared_driver_upgrade: false,
948        config_json: None,
949        package_dir: None,
950        stop: None,
951    })
952}
953
954/// Fixed fd number rbnx exports as `ROBONIX_SOMA_STAGE_FD` in soma's
955/// environment. Any fd ≥ 3 works — we pick 3 because it's the first
956/// non-stdio slot, which keeps `ls /proc/<soma>/fd` readable at a
957/// glance. Soma reads the trigger line, then closes it.
958const SOMA_STAGE_FD: RawFd = 3;
959
960/// Spawn soma with an inherited pipe on `SOMA_STAGE_FD`. The parent
961/// keeps the write end and later writes `stage2\n` to it (see
962/// `write_stage2_trigger` below). Layered on `spawn_system_binary`'s
963/// stdio+scribe pattern but adds:
964///   * pipe() to create the trigger channel
965///   * pre_exec dup2 to move the child's end onto SOMA_STAGE_FD
966///     (the natural fd from pipe() is unpredictable — some later
967///     lib open() call could grab it — so we pin it to a known
968///     number)
969///   * ROBONIX_SOMA_STAGE_FD env so soma finds it
970///   * close-on-exec cleared on the child fd (dup2 clears it by
971///     default, which is what we want)
972///
973/// Returns the Spawned handle and the parent's write-end File. Drop
974/// the File to close the pipe (soma sees EOF and continues without
975/// stage 2 — matches the `no fd` env-absent path).
976async fn spawn_soma_binary(
977    log_dir: &Path,
978    name: &str,
979    bin: &str,
980    args: &[String],
981) -> Result<(Spawned, std::fs::File)> {
982    use std::os::fd::{AsRawFd, IntoRawFd, OwnedFd};
983
984    // Create the trigger pipe. Parent owns the write end for the
985    // lifetime of the boot; child inherits the read end. We keep
986    // the OwnedFd wrappers so an early error path drops the fds
987    // rather than leaking them.
988    let (read_fd, write_fd): (OwnedFd, OwnedFd) =
989        nix::unistd::pipe().context("pipe() for soma stage-2 trigger")?;
990    let child_raw = read_fd.as_raw_fd();
991
992    // tokio::process::Command is a thin wrapper over std::process,
993    // but pre_exec lives on the std side. We prime the std Command
994    // via .as_std_mut() below.
995    let mut cmd = Command::new(bin);
996    for a in args {
997        cmd.arg(a);
998    }
999    cmd.stdin(Stdio::null())
1000        .stdout(Stdio::piped())
1001        .stderr(Stdio::piped())
1002        .env("SCRIBE_LOG_DIR", log_dir)
1003        .env("ROBONIX_SOMA_STAGE_FD", SOMA_STAGE_FD.to_string())
1004        .process_group(0);
1005
1006    // Hand the child's raw read fd into the closure. We can NOT let
1007    // `read_fd` (the OwnedFd) run its Drop in the parent before the
1008    // child inherits it — that would close the fd. Move ownership
1009    // into the closure and leak/consume it there.
1010    let read_owned = read_fd; // captured
1011    let child_target = SOMA_STAGE_FD;
1012    // Safety: pre_exec runs in the forked child before exec. Only
1013    // async-signal-safe syscalls are permitted; dup2 and close are
1014    // both on that list.
1015    unsafe {
1016        cmd.pre_exec(move || {
1017            // dup2(oldfd, newfd) atomically closes newfd (if open)
1018            // and duplicates oldfd onto it. The new fd has
1019            // CLOEXEC=0 by default, which is what we want (soma
1020            // needs to see it after exec).
1021            let old = read_owned.as_raw_fd();
1022            if old != child_target {
1023                let ret = libc_dup2(old, child_target);
1024                if ret < 0 {
1025                    return Err(std::io::Error::last_os_error());
1026                }
1027                // Original fd number is no longer needed in the
1028                // child; close it so it doesn't linger.
1029                let _ = libc_close(old);
1030            }
1031            Ok(())
1032        });
1033    }
1034
1035    let mut child = cmd.spawn().with_context(|| {
1036        format!(
1037            "failed to spawn system binary `{bin}` — is it installed (try `make install` from the rust/ workspace)?"
1038        )
1039    })?;
1040    let pid = child
1041        .id()
1042        .ok_or_else(|| anyhow::anyhow!("spawned `{bin}` but it had no pid"))?;
1043
1044    // fork() + our pre_exec dup2 have run; the child has its own
1045    // copy on fd 3 and the fork copy of read_owned (whatever number
1046    // pipe() picked). The parent's read_owned has been consumed by
1047    // the closure — the value inside the parent process is a dead
1048    // OwnedFd shell that will drop at the end of pre_exec's scope.
1049    // Nothing left to close on this side; child_raw is only used
1050    // for the debug/log line below.
1051    let _ = child_raw;
1052
1053    // Turn the parent's write-end OwnedFd into a std File so the
1054    // caller can write! into it. into_raw_fd releases ownership;
1055    // File::from_raw_fd takes it back.
1056    let write_raw = write_fd.into_raw_fd();
1057    // Safety: write_raw is a valid, open, owned fd we just released
1058    // from OwnedFd — we're transferring ownership one hop over.
1059    let writer = unsafe { <std::fs::File as std::os::fd::FromRawFd>::from_raw_fd(write_raw) };
1060
1061    // Pipe stdout / stderr into Scribe. (Same pattern as
1062    // spawn_system_binary — kept inline rather than extracted so
1063    // both call sites stay readable.)
1064    let stdout = child.stdout.take().expect("stdout not piped");
1065    let stderr = child.stderr.take().expect("stderr not piped");
1066    let tag_out = name.to_string();
1067    let tag_err = name.to_string();
1068    tokio::spawn(async move {
1069        let reader = tokio::io::BufReader::new(stdout);
1070        let mut lines = reader.lines();
1071        while let Ok(Some(line)) = lines.next_line().await {
1072            scribe::ingest(&tag_out, &line);
1073        }
1074    });
1075    tokio::spawn(async move {
1076        let reader = tokio::io::BufReader::new(stderr);
1077        let mut lines = reader.lines();
1078        while let Ok(Some(line)) = lines.next_line().await {
1079            scribe::ingest(&tag_err, &line);
1080        }
1081    });
1082
1083    let detail = system_boot_detail(name, args);
1084    output::boot_ok(name, &detail);
1085    Ok((
1086        Spawned {
1087            name: name.to_string(),
1088            kind: "system_builtin".to_string(),
1089            child,
1090            pid,
1091            pgid: pid,
1092            provider_id: None,
1093            driver_contract: None,
1094            expected_driver_contract: None,
1095            allow_shared_driver_upgrade: false,
1096            config_json: None,
1097            package_dir: None,
1098            stop: None,
1099        },
1100        writer,
1101    ))
1102}
1103
1104// Local libc thunks to avoid pulling libc as a direct dep — nix
1105// exposes these via `nix::unistd::dup2` / `close`, but we need
1106// async-signal-safety inside pre_exec and can't rely on nix's
1107// wrappers not allocating on the error path. Raw syscalls are the
1108// safe choice.
1109unsafe extern "C" {
1110    fn dup2(oldfd: i32, newfd: i32) -> i32;
1111    fn close(fd: i32) -> i32;
1112}
1113#[inline]
1114fn libc_dup2(oldfd: RawFd, newfd: RawFd) -> i32 {
1115    unsafe { dup2(oldfd, newfd) }
1116}
1117#[inline]
1118fn libc_close(fd: RawFd) -> i32 {
1119    unsafe { close(fd) }
1120}
1121
1122struct PackageSpawnEnv<'a> {
1123    log_dir: &'a Path,
1124    cache_root: &'a Path,
1125    instances_dir: &'a Path,
1126    manifest_dir: &'a Path,
1127    atlas_endpoint: &'a str,
1128}
1129
1130async fn spawn_package(
1131    component: &str,
1132    entry: &PackageEntry,
1133    env: &PackageSpawnEnv<'_>,
1134) -> Result<Spawned> {
1135    let pkg_path = resolve_entry_path(entry, env.cache_root, env.manifest_dir)?;
1136    let pkg_path = pkg_path
1137        .canonicalize()
1138        .with_context(|| format!("package path not found: {}", pkg_path.display()))?;
1139
1140    let name = if entry.name.is_empty() {
1141        pkg_path
1142            .file_name()
1143            .and_then(|n| n.to_str())
1144            .unwrap_or("package")
1145            .to_string()
1146    } else {
1147        entry.name.clone()
1148    };
1149    let package_manifest =
1150        robonix_cli::manifest::detect_and_load(&pkg_path, entry.manifest.as_deref())
1151            .with_context(|| format!("load package manifest for {}", pkg_path.display()))?;
1152    package_manifest.manifest.validate_and_summarize()?;
1153    let explicit_driver_contract = package_manifest
1154        .manifest
1155        .explicit_lifecycle_driver_contract()?;
1156    let allow_shared_driver_upgrade = explicit_driver_contract.is_some_and(|contract| {
1157        contract != robonix_cli::manifest::SHARED_LIFECYCLE_DRIVER_CONTRACT
1158    });
1159    let expected_driver_contract = Some(
1160        package_manifest
1161            .manifest
1162            .selected_lifecycle_driver_contract()?
1163            .to_string(),
1164    );
1165    let stop = package_manifest.manifest.stop.trim().to_string();
1166    let stop = if stop.is_empty() { None } else { Some(stop) };
1167    // Scribe tag + log-file stem = the provider_id (`entry.name`) verbatim.
1168    // provider_id is unique per deploy (atlas enforces it), so no kind prefix
1169    // is needed for disambiguation — `rbnx logs -t <provider_id>` and the file
1170    // `<provider_id>.log` both key on the same name the user wrote.
1171    let log_name = name.clone();
1172
1173    // Write this instance's config to disk for boot's own bookkeeping
1174    // (debugging via `cat <instances>/<name>.json`, post-mortem
1175    // inspection). Boot itself reads `entry.config` in-memory and
1176    // pushes it via Driver(CMD_INIT, config_json) — see call_driver_cmd
1177    // below. The provider process MUST NOT see this path; we do not export
1178    // it as an env var to the spawned `rbnx start`.
1179    let cfg_json = serde_json::to_value(&entry.config).unwrap_or(serde_json::Value::Null);
1180    let cfg_pretty = serde_json::to_string_pretty(&cfg_json).unwrap_or_else(|_| "{}".into());
1181    let cfg_file = env.instances_dir.join(format!("{name}.json"));
1182    std::fs::write(&cfg_file, &cfg_pretty)
1183        .with_context(|| format!("failed to write {}", cfg_file.display()))?;
1184
1185    // Spawn `rbnx start -p <pkg>` via the currently-running rbnx binary
1186    // itself — i.e. argv[0] of the deploy process. This way deploy doesn't
1187    // need a cargo workspace on disk and version-skew is impossible.
1188    // Stdout / stderr are piped through Scribe (tag = log_name, e.g.
1189    // "service_mapping") so boot-time display stays clean.
1190    let rbnx_bin = std::env::current_exe()
1191        .context("could not resolve current rbnx binary path for `start` re-exec")?;
1192    // Per v0.1 layering: do NOT pass the config file path to the
1193    // spawned `rbnx start` (which would propagate to the provider process
1194    // env). rbnx boot itself drives Driver(CMD_INIT, config_json) over
1195    // gRPC after the provider registers (see `call_driver_cmd` below). The
1196    // cfg_file on disk is for boot's own use — we read it back via
1197    // `entry.config` higher in this module — and atlas-side bookkeeping;
1198    // the provider never sees it.
1199    let _ = &cfg_file; // kept for debug / inspection; not exported
1200    // Tell the provider which atlas to register with — derived from the
1201    // manifest's `system.atlas.listen`, NOT the hard default 127.0.0.1:50051.
1202    // Without this an alt-port deploy (e.g. an isolated CI run) leaves every
1203    // provider dialing 50051 and failing to register. A bind-all listen
1204    // (0.0.0.0) is rewritten to a dialable loopback for the provider; an
1205    // in-container driver further overrides this via ROBONIX_SIM_ATLAS.
1206    let provider_atlas = env.atlas_endpoint.replacen("0.0.0.0", "127.0.0.1", 1);
1207    let mut cmd = Command::new(&rbnx_bin);
1208    cmd.arg("start")
1209        .arg("-p")
1210        .arg(pkg_path.as_os_str())
1211        .arg("--endpoint")
1212        .arg(&provider_atlas)
1213        .env("RBNX_INSTANCE_NAME", &name)
1214        .env("RBNX_INVOCATION_CWD", env.manifest_dir)
1215        // `rbnx start` must keep its package shell in this group.  Otherwise
1216        // ProcessManager creates a nested PGID and boot's failure teardown
1217        // kills only the wrapper, leaving the real package process orphaned.
1218        .env("RBNX_DEPLOY_MANAGED", "1")
1219        .env("SCRIBE_LOG_DIR", env.log_dir)
1220        .stdin(Stdio::null())
1221        .stdout(Stdio::piped())
1222        .stderr(Stdio::piped())
1223        .process_group(0);
1224    // Per-deployment-target package manifest selector (deploy entry's
1225    // `manifest:` field) — `rbnx start` loads this file instead of the
1226    // default package_manifest.yaml so the right start path runs.
1227    if let Some(m) = entry.manifest.as_deref() {
1228        cmd.arg("--manifest").arg(m);
1229    }
1230    let mut child = cmd.spawn().with_context(|| {
1231        format!(
1232            "failed to spawn package {name} via `{} start`",
1233            rbnx_bin.display()
1234        )
1235    })?;
1236    let pid = child
1237        .id()
1238        .ok_or_else(|| anyhow::anyhow!("spawned package '{name}' but it had no pid"))?;
1239
1240    // Pipe stdout / stderr into Scribe — tag = provider_id, so the file is
1241    // `<provider_id>.log` (e.g. "mapping.log").
1242    let stdout = child.stdout.take().expect("stdout not piped");
1243    let stderr = child.stderr.take().expect("stderr not piped");
1244    let tag_out = log_name.clone();
1245    let tag_err = log_name.clone();
1246    tokio::spawn(async move {
1247        let reader = tokio::io::BufReader::new(stdout);
1248        let mut lines = reader.lines();
1249        while let Ok(Some(line)) = lines.next_line().await {
1250            scribe::ingest(&tag_out, &line);
1251        }
1252    });
1253    tokio::spawn(async move {
1254        let reader = tokio::io::BufReader::new(stderr);
1255        let mut lines = reader.lines();
1256        while let Ok(Some(line)) = lines.next_line().await {
1257            // stderr is not always errors — Python logging defaults to
1258            // stderr for INFO too.  Use `info` to avoid misrepresenting
1259            // the actual severity.
1260            scribe::ingest(&tag_err, &line);
1261        }
1262    });
1263    // No spawn line here — wait until provider registration and emit one
1264    // boot_ok with the provider_id so each component takes ONE line in the
1265    // boot log instead of three (spawn + waiting + registered).
1266    let kind = match component {
1267        "system" => "system_package",
1268        other => other,
1269    }
1270    .to_string();
1271    Ok(Spawned {
1272        name: log_name,
1273        kind,
1274        child,
1275        pid,
1276        pgid: pid,
1277        provider_id: None,
1278        driver_contract: None,
1279        expected_driver_contract,
1280        allow_shared_driver_upgrade,
1281        config_json: None,
1282        package_dir: Some(pkg_path),
1283        stop,
1284    })
1285}
1286
1287// ── entry point ─────────────────────────────────────────────────────────
1288
1289pub async fn execute(
1290    config: robonix_cli::Config,
1291    manifest_path: PathBuf,
1292    log_dir: Option<PathBuf>,
1293    skip_system: bool,
1294    no_update_check: bool,
1295    verbose: bool,
1296) -> Result<()> {
1297    output::set_boot_verbose(verbose);
1298    let manifest_path = manifest_path
1299        .canonicalize()
1300        .with_context(|| format!("manifest not found: {}", manifest_path.display()))?;
1301    let manifest_dir = manifest_path
1302        .parent()
1303        .context("manifest has no parent directory")?
1304        .to_path_buf();
1305
1306    let raw = std::fs::read_to_string(&manifest_path)
1307        .with_context(|| format!("failed to read {}", manifest_path.display()))?;
1308    let root: serde_yaml::Value = serde_yaml::from_str(&raw)
1309        .with_context(|| format!("failed to parse {}", manifest_path.display()))?;
1310    let root = prepare_manifest(root, config.robonix_source_path.as_deref())
1311        .with_context(|| format!("failed to prepare {}", manifest_path.display()))?;
1312    robonix_cli::manifest::validate_deployment_instance_names(&root).with_context(|| {
1313        format!(
1314            "invalid deployment identities in {}",
1315            manifest_path.display()
1316        )
1317    })?;
1318    let mut deploy: DeployManifest = serde_yaml::from_value(root)
1319        .with_context(|| format!("failed to decode {}", manifest_path.display()))?;
1320    // Banner + boot header FIRST, so the logo/version and what we're booting
1321    // lead the output — before the (possibly slow) remote freshness check.
1322    output::boot_banner();
1323    output::boot_start(
1324        if deploy.name.is_empty() {
1325            "robonix"
1326        } else {
1327            &deploy.name
1328        },
1329        &manifest_path.display().to_string(),
1330    );
1331    // Notice (non-fatal) if any cloned remote provider is behind upstream.
1332    // `--no-update-check` skips the per-package `git fetch` pass entirely.
1333    if !no_update_check {
1334        super::check_remotes::report_outdated(&manifest_path);
1335    }
1336
1337    // soma owns primitive + skill bring-up (see
1338    // docs/soma_two_stage_bringup.md). If the manifest declares ANY
1339    // primitive or skill, we MUST start a soma — otherwise those
1340    // packages are silently never spawned (boot looks "OK" because rbnx
1341    // got through atlas/executor/pilot, but the robot stays dead).
1342    //
1343    // Two manifest patterns we want to keep working without forcing
1344    // every existing deploy to add a `system.soma:` block:
1345    //   1. manifest has primitive/skill, no system.soma at all
1346    //      → inject a default soma block.
1347    //   2. manifest has system.soma but didn't set deployments / didn't
1348    //      set start_packages → fill in sane defaults (deployments =
1349    //      [this manifest's dir], start_packages = true).
1350    //
1351    // Both branches route through `ensure_soma_defaults` so the rest of
1352    // deploy.rs (spawning the soma binary in the builtin loop, sending
1353    // the stage 2 trigger after service: bring-up) just sees a
1354    // populated system.soma entry like any other.
1355    //
1356    // Existing operator-supplied values are NEVER overwritten — this
1357    // hole-fills, it does not override intent.
1358    //
1359    // We also run this whenever `system.soma` was explicitly declared,
1360    // even if there are no primitives/skills that would auto-imply
1361    // soma. Rationale: `ensure_soma_defaults` no longer just fills in
1362    // a mapping shell — it also resolves `robot_yaml` / `config`
1363    // relative paths against manifest_dir. An operator who writes
1364    // `system.soma: { config: soma_config.local.yaml }` on its own
1365    // deserves the same path-normalisation as the auto-injected case.
1366    let soma_declared = deploy.system.contains_key("soma");
1367    let soma_implied = !deploy.primitive.is_empty() || !deploy.skill.is_empty();
1368    if (soma_declared || soma_implied) && !skip_system {
1369        ensure_soma_defaults(&mut deploy.system, &manifest_dir, &manifest_path);
1370    }
1371
1372    let log_dir = log_dir.unwrap_or_else(|| manifest_dir.join("rbnx-boot").join("logs"));
1373    // The CLI prepares and clears this directory before Scribe's first log
1374    // call. Do not remove files here: Scribe may already hold open handles.
1375    std::fs::create_dir_all(&log_dir)
1376        .with_context(|| format!("failed to create log dir {}", log_dir.display()))?;
1377
1378    // SCRIBE_CONSOLE_LEVEL is set in main.rs before any scribe call.
1379    // Set SCRIBE_LOG_DIR so boot-time scribe messages (bootstrap,
1380    // child-process pipe forwarding) land in the deploy log dir rather
1381    // than the default ./logs.
1382    // Safety: called before any child spawns, no concurrent access.
1383    unsafe {
1384        std::env::set_var("SCRIBE_LOG_DIR", log_dir.as_os_str());
1385    }
1386    scribe::info(
1387        "bootstrap",
1388        &format!(
1389            "booting {} from {}",
1390            if deploy.name.is_empty() {
1391                "robonix"
1392            } else {
1393                &deploy.name
1394            },
1395            manifest_path.display()
1396        ),
1397    );
1398
1399    let cache_root = manifest_dir.join("rbnx-boot").join("cache");
1400    let instances_dir = manifest_dir.join("rbnx-boot").join("instances");
1401    std::fs::create_dir_all(&instances_dir)
1402        .with_context(|| format!("failed to create instances dir {}", instances_dir.display()))?;
1403
1404    let mut children: Vec<Spawned> = Vec::new();
1405    let state_path = teardown::state_path(&manifest_dir);
1406    let boot_id = Uuid::new_v4().to_string();
1407    // Every wrapper and provider inherits this marker. Persisted teardown
1408    // verifies it against /proc before signalling a PGID, preventing stale
1409    // state from killing an unrelated process after PID reuse.
1410    unsafe { std::env::set_var("RBNX_BOOT_ID", &boot_id) };
1411    let boot_start_time_ticks = robonix_cli::launch::proc_start_time_ticks(std::process::id());
1412    let started_at_ms = std::time::SystemTime::now()
1413        .duration_since(std::time::UNIX_EPOCH)
1414        .map(|d| d.as_millis() as u64)
1415        .unwrap_or(0);
1416    let atlas_endpoint = deploy
1417        .system
1418        .get("atlas")
1419        .and_then(|v| v.as_mapping())
1420        .and_then(|m| m.get(serde_yaml::Value::String("listen".into())))
1421        .and_then(|v| v.as_str())
1422        .unwrap_or("127.0.0.1:50051")
1423        .to_string();
1424    teardown::write_state(
1425        &state_path,
1426        &teardown::BootState {
1427            manifest_path: manifest_path.display().to_string(),
1428            boot_pid: std::process::id(),
1429            boot_start_time_ticks,
1430            boot_id: boot_id.clone(),
1431            started_at_ms,
1432            atlas_endpoint: atlas_endpoint.clone(),
1433            components: Vec::new(),
1434        },
1435    )?;
1436    super::boot_watchdog::spawn(
1437        &state_path,
1438        std::process::id(),
1439        boot_start_time_ticks,
1440        &boot_id,
1441    )?;
1442    let spawn_env = PackageSpawnEnv {
1443        log_dir: &log_dir,
1444        cache_root: &cache_root,
1445        instances_dir: &instances_dir,
1446        manifest_dir: &manifest_dir,
1447        atlas_endpoint: &atlas_endpoint,
1448    };
1449
1450    // Boot is responsible for spawning + atlas registration ONLY.
1451    // Fetching (git clone of url-remote pkgs) and building are
1452    // `rbnx build`'s job. We just verify both have happened; if
1453    // not, warn loudly and remediate inline so the user isn't
1454    // stuck on a fresh clone.
1455    check_prerequisites(
1456        &deploy,
1457        &cache_root,
1458        &manifest_dir,
1459        config.robonix_source_path.as_deref(),
1460    )?;
1461
1462    // Record what this run is about to use, after the caches are settled and
1463    // before anything is spawned.
1464    write_lockfile(&deploy, &cache_root, &manifest_dir);
1465
1466    // Install the SIGINT/SIGTERM handlers BEFORE bringup begins, not after.
1467    // Bringup takes many seconds (git, spawns, waiting for ACTIVE); a Ctrl-C
1468    // in that window used to hit the default disposition and kill rbnx
1469    // outright, orphaning every child already spawned. Racing the bringup
1470    // future against these streams lets us tear the partial stack down
1471    // instead. (SIGKILL can't be trapped — only SIGINT/SIGTERM.) The same
1472    // streams are reused for the post-boot idle wait further down.
1473    let mut sigint = signal(SignalKind::interrupt())?;
1474    let mut sigterm = signal(SignalKind::terminate())?;
1475
1476    // Owns the parent side of the stage-2 trigger pipe (created inside
1477    // `spawn_soma_binary`, drained by `write_stage2_trigger`). Declared
1478    // outside `bringup` so it survives the async-block scope even
1479    // though we only assign into it from inside.
1480    let mut soma_stage_writer: Option<std::fs::File> = None;
1481
1482    let bringup = async {
1483        if !skip_system {
1484            output::boot_section("system");
1485            // System Rust binaries: launched in atlas → executor → pilot order.
1486            // Each is fed CLI flags translated from `system.<name>:` block.
1487            // executor + pilot inherit `--atlas` from `system.atlas.listen`
1488            // unless they declare their own `atlas:` (rare).
1489            let atlas_listen = deploy
1490                .system
1491                .get("atlas")
1492                .and_then(|v| v.as_mapping())
1493                .and_then(|m| m.get(serde_yaml::Value::String("listen".into())))
1494                .and_then(|v| v.as_str())
1495                .map(str::to_string);
1496            let soma_listen = deploy
1497                .system
1498                .get("soma")
1499                .and_then(|v| v.as_mapping())
1500                .and_then(|m| m.get(serde_yaml::Value::String("listen".into())))
1501                .and_then(|v| v.as_str())
1502                .map(|s| s.replacen("0.0.0.0", "127.0.0.1", 1));
1503            // Atlas's contract registry walks every dir in
1504            // --capabilities at startup. We seed it with:
1505            //   1. <robonix_source>/capabilities — the global tree
1506            //   2. <pkg>/capabilities for every primitive/service/skill
1507            //      package whose source dir is on disk and contains a
1508            //      `capabilities/` subdir
1509            // Roots are merged in order; later wins on duplicate id, so
1510            // a package can re-declare a global contract for itself.
1511            // A manifest-level override `system.atlas.capabilities`
1512            // still wins via system_cli_args (clobbers the auto list).
1513            let mut atlas_caps_roots: Vec<String> = Vec::new();
1514            if let Some(root) = config.robonix_source_path.as_ref() {
1515                atlas_caps_roots.push(root.join("capabilities").to_string_lossy().into_owned());
1516            }
1517            for entry in deploy
1518                .primitive
1519                .iter()
1520                .chain(deploy.service.iter())
1521                .chain(deploy.skill.iter())
1522            {
1523                if let Ok(pkg_path) = resolve_entry_path(entry, &cache_root, &manifest_dir) {
1524                    let providers = pkg_path.join("capabilities");
1525                    if providers.is_dir() {
1526                        atlas_caps_roots.push(providers.to_string_lossy().into_owned());
1527                    }
1528                }
1529            }
1530            let atlas_caps_default: Option<String> = if atlas_caps_roots.is_empty() {
1531                None
1532            } else {
1533                Some(atlas_caps_roots.join(","))
1534            };
1535            let bin_map: &[(&str, &str)] = &[
1536                ("atlas", "robonix-atlas"),
1537                ("executor", "robonix-executor"),
1538                ("soma", "robonix-soma"),
1539                ("vitals", "robonix-vitals"),
1540                ("pilot", "robonix-pilot"),
1541                ("liaison", "robonix-liaison"),
1542            ];
1543            for (name, bin) in bin_map {
1544                if !deploy.system.contains_key(*name) {
1545                    continue;
1546                }
1547                let mut args =
1548                    system_cli_args(name, deploy.system.get(*name), atlas_listen.as_deref());
1549                if *name == "vitals"
1550                    && !args.iter().any(|arg| arg == "--soma-endpoint")
1551                    && let Some(endpoint) = soma_listen.as_ref()
1552                {
1553                    args.push("--soma-endpoint".into());
1554                    args.push(endpoint.clone());
1555                }
1556                if *name == "atlas"
1557                    && !args.iter().any(|a| a == "--capabilities")
1558                    && let Some(p) = atlas_caps_default.as_ref()
1559                {
1560                    args.push("--capabilities".into());
1561                    args.push(p.clone());
1562                }
1563                // Refuse to spawn if the listen port is already taken — without
1564                // this, the spawned binary silently dies on bind() failure but
1565                // boot keeps going against whoever already owns the port (often
1566                // a stale debug-build atlas/executor/etc from a prior aborted
1567                // run). The fallout is mysterious: register_capability hits an
1568                // atlas that doesn't have your takeover/state-push fixes,
1569                // endpoints route to dead orphan gRPC servers, …
1570                if let Some(listen) = system_listen(name, deploy.system.get(*name))
1571                    && let Err(e) = port_is_free(&listen)
1572                {
1573                    output::boot_fail(
1574                        name,
1575                        &format!(
1576                            "listen address '{listen}' is taken: {e:#}. \
1577                                  Stop the running process (try `bash sim/stop.sh` \
1578                                  or `pkill -f robonix-{name}`) and retry."
1579                        ),
1580                    );
1581                    anyhow::bail!(
1582                        "system/{name}: listen address '{listen}' is already in use; \
1583                         refusing to spawn (would shadow the existing process)"
1584                    );
1585                }
1586
1587                // Required-arg validation before spawn. Without this, an empty
1588                // `${VLM_BASE_URL}` (forgot to source the env file) makes pilot
1589                // start, register_capability briefly, then die with `missing
1590                // required field 'vlm.upstream'`. Boot still printed `[ OK ]`
1591                // because we never re-checked. Fail fast at spawn time and tell
1592                // the user exactly what's missing.
1593                if let Err(e) = require_system_args(name, &args) {
1594                    output::boot_fail(name, &e);
1595                    anyhow::bail!("system/{name}: {e}");
1596                }
1597
1598                let sp = if *name == "soma" {
1599                    // soma needs an inherited pipe fd for the stage-2
1600                    // trigger. Everything else uses the plain spawn path.
1601                    let (sp, writer) = spawn_soma_binary(&log_dir, name, bin, &args).await?;
1602                    soma_stage_writer = Some(writer);
1603                    sp
1604                } else {
1605                    spawn_system_binary(&log_dir, name, bin, &args).await?
1606                };
1607                children.push(sp);
1608                persist_state(
1609                    &state_path,
1610                    &manifest_path,
1611                    &atlas_endpoint,
1612                    started_at_ms,
1613                    &children,
1614                );
1615                tokio::time::sleep(std::time::Duration::from_millis(1500)).await;
1616                if *name == "soma" {
1617                    if !deploy.primitive.is_empty() {
1618                        output::boot_section("primitive");
1619                        if output::boot_verbose() {
1620                            for entry in &deploy.primitive {
1621                                output::boot_wait(&entry.name, "waiting for registration");
1622                            }
1623                        }
1624                    }
1625                    let mut stage1_atlas = AtlasClient::connect_with_retry(
1626                        &atlas_endpoint,
1627                        20,
1628                        Duration::from_millis(500),
1629                    )
1630                    .await
1631                    .with_context(|| {
1632                        format!("connect to atlas at '{atlas_endpoint}' for primitive readiness")
1633                    })?;
1634                    // We just pushed the soma `Spawned` above; grab a
1635                    // mutable borrow on its Child so the stage-1 waiter
1636                    // can `try_wait()` per tick. Without this the waiter
1637                    // sits for the full SOMA_STAGE1_TIMEOUT even when
1638                    // soma exited immediately (e.g. `missing robot_yaml`
1639                    // config error). The unwrap is safe: we just pushed.
1640                    let soma_child = &mut children
1641                        .last_mut()
1642                        .expect("soma Spawned pushed above")
1643                        .child;
1644                    wait_for_soma_stage1(
1645                        &mut stage1_atlas,
1646                        &deploy
1647                            .primitive
1648                            .iter()
1649                            .map(|entry| entry.name.clone())
1650                            .collect::<Vec<_>>(),
1651                        soma_child,
1652                        &log_dir,
1653                    )
1654                    .await?;
1655
1656                    // soma just finished stage 1 (all primitives ACTIVE). The
1657                    // next thing the operator sees on this terminal is the
1658                    // remaining builtins (pilot, liaison — whichever come
1659                    // after soma in `bin_map`) followed by the non-builtin
1660                    // `system:` entries loop below (memory / scene / speech
1661                    // / …). Both cohorts are *system services*, NOT
1662                    // primitives, but without a fresh section header they
1663                    // visually chain onto the "primitive" header just above
1664                    // and readers routinely mistake pilot for a primitive
1665                    // — the exact confusion this header exists to prevent.
1666                    //
1667                    // Only emit the header if there's actually something
1668                    // downstream to label. Concretely: at least one builtin
1669                    // ordered after soma in `bin_map` is declared in the
1670                    // manifest, OR the manifest has any non-builtin
1671                    // `system:` key that will run in the loop after this
1672                    // for-loop finishes. Otherwise (e.g. an atlas-executor-
1673                    // soma-only deploy) skip the header — dangling section
1674                    // titles with nothing under them are worse than none.
1675                    let builtin_after_soma = bin_map
1676                        .iter()
1677                        .skip_while(|(n, _)| *n != "soma")
1678                        .skip(1) // drop soma itself
1679                        .any(|(n, _)| deploy.system.contains_key(*n));
1680                    let has_non_builtin_system = deploy
1681                        .system
1682                        .keys()
1683                        .any(|k| !bin_map.iter().any(|(n, _)| n == k));
1684                    if builtin_after_soma || has_non_builtin_system {
1685                        output::boot_section("system");
1686                    }
1687                }
1688            }
1689        } else {
1690            output::sub_step("Skipping system bring-up (--skip-system)");
1691        }
1692
1693        // Connect to atlas once; reuse for every primitive/service init dance.
1694        let mut atlas =
1695            AtlasClient::connect_with_retry(&atlas_endpoint, 20, Duration::from_millis(500))
1696                .await
1697                .with_context(|| {
1698                    format!("connect to atlas at '{atlas_endpoint}' for lifecycle init")
1699                })?;
1700
1701        // Non-builtin `system:` keys (memory / speech / …) are real robonix
1702        // packages — same start/init/register flow as primitive/service, just
1703        // resolved by name against `<robonix_source>/system/<key>/`. Builtin
1704        // Rust binaries (atlas/executor/pilot) were spawned above and skipped
1705        // here. A key whose package directory is missing on disk is warned
1706        // and skipped, not fatal — manifests can declare optional services
1707        // that aren't installed yet (e.g. liaison while it's being ported).
1708        // Best-effort boot: a failure on any non-system-builtin package is
1709        // recorded but does NOT bail the whole bring-up. Goal is to get
1710        // atlas + executor + pilot + liaison up so `rbnx chat` can still
1711        // be poked at even when scene / memory / mapping is broken — the
1712        // alternative (the previous fail-fast model) means a single
1713        // package's milvus lock or sensor-init quirk gates every other
1714        // component the operator wants to test.
1715        //
1716        // System builtins (atlas/executor/pilot/liaison) are still
1717        // bail-on-error: nothing else makes sense without those.
1718        let mut failures: Vec<(String, String, String)> = Vec::new(); // (component, name, err)
1719
1720        if !skip_system {
1721            for (key, value) in &deploy.system {
1722                if is_builtin_system(key) {
1723                    continue;
1724                }
1725                let pkg_dir = match config.robonix_source_path.as_ref() {
1726                    Some(root) => root.join("system").join(key),
1727                    None => {
1728                        output::boot_skip(
1729                            key,
1730                            "robonix_source_path unset (`rbnx setup` from repo root)",
1731                        );
1732                        continue;
1733                    }
1734                };
1735                if !pkg_dir.exists() {
1736                    output::boot_skip(key, "not on disk");
1737                    continue;
1738                }
1739                let (manifest_override, runtime_config) =
1740                    robonix_cli::manifest::split_system_package_config(value)
1741                        .with_context(|| format!("parse system/{key} package selector"))?;
1742                let entry = PackageEntry {
1743                    name: key.clone(),
1744                    path: Some(pkg_dir.to_string_lossy().into_owned()),
1745                    url: None,
1746                    branch: None,
1747                    config: runtime_config,
1748                    manifest: manifest_override,
1749                };
1750                match spawn_and_init("system", &entry, &spawn_env, &mut atlas).await {
1751                    Ok(sp) => {
1752                        children.push(sp);
1753                        persist_state(
1754                            &state_path,
1755                            &manifest_path,
1756                            &atlas_endpoint,
1757                            started_at_ms,
1758                            &children,
1759                        );
1760                    }
1761                    Err(e) => {
1762                        failures.push(("system".to_string(), key.clone(), format!("{e:#}")));
1763                    }
1764                }
1765            }
1766        }
1767
1768        // primitive: handled by soma's stage 1 (kicked off when soma
1769        //   starts up, finishes before soma declares get_yaml/get_urdf).
1770        // skill:     handled by soma's stage 2 (kicked off by the
1771        //   StageTrigger("stage2") we send below, after non-builtin
1772        //   system services and service: entries have finished
1773        //   bring-up — which is the earliest skills can safely talk
1774        //   to executor/pilot/memory/scene from their MCP tools).
1775        // The deploy.primitive / deploy.skill fields stay in the
1776        // manifest schema because soma reads them; rbnx just doesn't
1777        // launch those processes any more.
1778        if !deploy.service.is_empty() {
1779            output::boot_section("service");
1780        }
1781        for e in &deploy.service {
1782            match spawn_and_init("service", e, &spawn_env, &mut atlas).await {
1783                Ok(sp) => {
1784                    children.push(sp);
1785                    persist_state(
1786                        &state_path,
1787                        &manifest_path,
1788                        &atlas_endpoint,
1789                        started_at_ms,
1790                        &children,
1791                    );
1792                }
1793                Err(err) => {
1794                    failures.push(("service".to_string(), e.name.clone(), format!("{err:#}")));
1795                }
1796            }
1797        }
1798
1799        // All system + service bring-up done; fire soma's stage 2
1800        // trigger so it spawns + INITs the skill packages. Best
1801        // effort: a soma that never went ACTIVE (or shut down
1802        // between our checks and this NotifyProvider) is a deploy
1803        // problem the operator already sees in earlier boot output;
1804        // we log and continue rather than tearing everything down
1805        // for the skills that never started.
1806        //
1807        // The section is labelled "skill" (not "stage 2") because
1808        // that's what the operator actually sees launching under the
1809        // header — the "stage 2" name is an internal rbnx↔soma pipe
1810        // protocol detail (see `STAGE2_TRIGGER` in soma/main.rs and
1811        // `write_stage2_trigger` below). Keeping the wire word out
1812        // of the terminal UI avoids operators having to learn our
1813        // two-stage bring-up vocabulary just to read boot output.
1814        if deploy.system.contains_key("soma") && !skip_system {
1815            output::boot_section("skill");
1816            if output::boot_verbose() {
1817                for entry in &deploy.skill {
1818                    output::boot_wait(&entry.name, "waiting for registration");
1819                }
1820            }
1821            if let Err(e) = write_stage2_trigger(&mut soma_stage_writer) {
1822                failures.push((
1823                    "system".to_string(),
1824                    "soma".to_string(),
1825                    format!("start skill packages: {e:#}"),
1826                ));
1827            } else if let Err(e) = wait_for_soma_skills(
1828                &mut atlas,
1829                &deploy
1830                    .skill
1831                    .iter()
1832                    .map(|entry| entry.name.clone())
1833                    .collect::<Vec<_>>(),
1834            )
1835            .await
1836            {
1837                failures.push(("skill".to_string(), "soma".to_string(), format!("{e:#}")));
1838            }
1839        }
1840        Ok(failures)
1841    };
1842
1843    // Race bringup against the signal streams. On a mid-boot signal the
1844    // pinned future is dropped at the end of this scope (cancelling bringup
1845    // at its current await point), which releases its `&mut children` borrow
1846    // so we can tear down whatever was spawned so far.
1847    let mut interrupted_during_boot = false;
1848    let outcome: Result<Vec<(String, String, String)>> = {
1849        tokio::pin!(bringup);
1850        tokio::select! {
1851            o = &mut bringup => o,
1852            _ = sigint.recv() => { interrupted_during_boot = true; Ok(Vec::new()) }
1853            _ = sigterm.recv() => { interrupted_during_boot = true; Ok(Vec::new()) }
1854        }
1855    };
1856
1857    if interrupted_during_boot {
1858        output::action(
1859            "Interrupted",
1860            &format!("tearing down {} partial child(ren)", children.len()),
1861        );
1862        scribe::info(
1863            "bootstrap",
1864            &format!(
1865                "boot interrupted by signal — tearing down {} partial children",
1866                children.len()
1867            ),
1868        );
1869        persist_state(
1870            &state_path,
1871            &manifest_path,
1872            &atlas_endpoint,
1873            started_at_ms,
1874            &children,
1875        );
1876        let providers = component_records(&children);
1877        let complete = teardown::teardown(Some(&atlas_endpoint), &providers, Some(&boot_id)).await;
1878        if !complete {
1879            anyhow::bail!(
1880                "interrupted boot left identity-mismatched process groups; preserving {}",
1881                state_path.display()
1882            );
1883        }
1884        for sp in &mut children {
1885            let _ = sp.child.wait().await;
1886        }
1887        let _ = std::fs::remove_file(&state_path);
1888        return Ok(());
1889    }
1890
1891    let failures = match outcome {
1892        Ok(failures) => failures,
1893        Err(e) => {
1894            output::action("Boot failed", &format!("{e:#}"));
1895            // System-builtin failure is still terminal — no point
1896            // pretending the deploy is usable when atlas itself didn't come
1897            // up. Reap whatever we did spawn before bailing.
1898            persist_state(
1899                &state_path,
1900                &manifest_path,
1901                &atlas_endpoint,
1902                started_at_ms,
1903                &children,
1904            );
1905            let providers = component_records(&children);
1906            let complete =
1907                teardown::teardown(Some(&atlas_endpoint), &providers, Some(&boot_id)).await;
1908            if complete {
1909                let _ = std::fs::remove_file(&state_path);
1910            } else {
1911                return Err(e.context(format!(
1912                    "cleanup refused identity-mismatched process groups; preserving {}",
1913                    state_path.display()
1914                )));
1915            }
1916            return Err(e);
1917        }
1918    };
1919
1920    if !failures.is_empty() {
1921        output::boot_section("failures");
1922        for (component, name, err) in &failures {
1923            // Trim the err to a single line — the full stack already lives
1924            // in the per-package log file we listed in the FAIL line.
1925            let one_line = err.lines().next().unwrap_or(err.as_str());
1926            output::boot_fail(name, &format!("[{component}] {one_line}"));
1927        }
1928        eprintln!();
1929        eprintln!(
1930            "  {} of {} packages failed to start; the rest are running. \
1931             `rbnx caps` to inspect, `rbnx shutdown` to tear down.",
1932            failures.len(),
1933            failures.len() + children.len(),
1934        );
1935    }
1936
1937    output::success(&format!(
1938        "{} component(s) up; logs under {}",
1939        children.len(),
1940        log_dir.display()
1941    ));
1942    if failures.is_empty() {
1943        scribe::info("bootstrap", "all components up — waiting for signal");
1944    } else {
1945        scribe::info(
1946            "bootstrap",
1947            &format!(
1948                "{} component(s) up, {} package(s) failed — waiting for signal",
1949                children.len(),
1950                failures.len()
1951            ),
1952        );
1953    }
1954    output::sub_step("Ctrl-C to tear down (or run `rbnx shutdown` from another shell).");
1955
1956    // Wait for SIGINT / SIGTERM (reusing the streams installed before
1957    // bringup), then shut children down.
1958    tokio::select! {
1959        _ = sigint.recv() => {}
1960        _ = sigterm.recv() => {}
1961    }
1962    output::action("Stopping", &format!("{} child(ren)", children.len()));
1963    scribe::info(
1964        "bootstrap",
1965        &format!(
1966            "shutdown signal received, tearing down {} children",
1967            children.len()
1968        ),
1969    );
1970    let providers = component_records(&children);
1971    let complete = teardown::teardown(Some(&atlas_endpoint), &providers, Some(&boot_id)).await;
1972    if !complete {
1973        anyhow::bail!(
1974            "shutdown refused identity-mismatched process groups; preserving {}",
1975            state_path.display()
1976        );
1977    }
1978    // Best-effort wait so we get clean "exited" lines in our own log.
1979    for sp in &mut children {
1980        let _ = sp.child.wait().await;
1981    }
1982    let _ = std::fs::remove_file(&state_path);
1983    Ok(())
1984}
1985
1986fn component_records(children: &[Spawned]) -> Vec<teardown::ComponentRecord> {
1987    children
1988        .iter()
1989        .map(|s| PackageRuntimeRecord {
1990            name: s.name.clone(),
1991            kind: s.kind.clone(),
1992            pid: s.pid,
1993            pgid: s.pgid,
1994            provider_id: s.provider_id.clone(),
1995            driver_contract: s.driver_contract.clone(),
1996            config_json: s.config_json.clone(),
1997            package_dir: s.package_dir.as_ref().map(|p| p.display().to_string()),
1998            stop: s.stop.clone(),
1999        })
2000        .collect()
2001}
2002
2003fn persist_state(
2004    state_path: &Path,
2005    manifest_path: &Path,
2006    atlas_endpoint: &str,
2007    started_at_ms: u64,
2008    children: &[Spawned],
2009) {
2010    let state = teardown::BootState {
2011        manifest_path: manifest_path.display().to_string(),
2012        boot_pid: std::process::id(),
2013        boot_start_time_ticks: robonix_cli::launch::proc_start_time_ticks(std::process::id()),
2014        boot_id: std::env::var("RBNX_BOOT_ID").unwrap_or_default(),
2015        started_at_ms,
2016        atlas_endpoint: atlas_endpoint.to_string(),
2017        components: component_records(children),
2018    };
2019    if let Err(e) = teardown::write_state(state_path, &state) {
2020        output::sub_step(&format!(
2021            "[boot] warning: failed to persist boot state to {}: {e:#}",
2022            state_path.display()
2023        ));
2024    }
2025}
2026
2027/// Render a one-line "what is this binary doing" string for the boot
2028/// log. Pulls out the high-signal flags (port, vlm model+host) and
2029/// drops noisy ones (--capabilities, --log, raw API keys).
2030/// Per-binary required-arg sanity check, run before spawning.
2031///
2032/// Pilot needs all three VLM fields non-empty. The manifest renders
2033/// `${VLM_BASE_URL}` etc. literally when the env var isn't set, which
2034/// produces `--vlm-upstream ""` — pilot then registers briefly, dies
2035/// with `missing required field 'vlm.upstream'`, and boot reports
2036/// `[ OK ]` because the failure happens after spawn-and-register. Catch
2037/// it here so the user sees a `[FAIL]` line naming the bad keys.
2038fn require_system_args(name: &str, args: &[String]) -> std::result::Result<(), String> {
2039    if name != "pilot" {
2040        return Ok(());
2041    }
2042    let need = [
2043        ("--vlm-upstream", "vlm.upstream / VLM_BASE_URL"),
2044        ("--vlm-api-key", "vlm.api_key / VLM_API_KEY"),
2045        ("--vlm-model", "vlm.model / VLM_MODEL"),
2046    ];
2047    let mut missing: Vec<&str> = Vec::new();
2048    for (flag, label) in need {
2049        let val = args
2050            .iter()
2051            .position(|a| a == flag)
2052            .and_then(|i| args.get(i + 1));
2053        match val {
2054            Some(v) if !v.is_empty() => {}
2055            _ => missing.push(label),
2056        }
2057    }
2058    if missing.is_empty() {
2059        Ok(())
2060    } else {
2061        Err(format!(
2062            "missing required pilot config: {}. Set in manifest under \
2063             system: pilot: vlm: {{...}} or via env (source your .zshrc / \
2064             inline-prepend VLM_BASE_URL=… VLM_API_KEY=… VLM_MODEL=…)",
2065            missing.join(", "),
2066        ))
2067    }
2068}
2069
2070fn system_boot_detail(name: &str, args: &[String]) -> String {
2071    let mut listen: Option<&str> = None;
2072    let mut vlm_upstream: Option<&str> = None;
2073    let mut vlm_model: Option<&str> = None;
2074    let mut i = 0;
2075    while i < args.len() {
2076        let a = args[i].as_str();
2077        let next = args.get(i + 1).map(|s| s.as_str());
2078        match (a, next) {
2079            ("--listen", Some(v)) => {
2080                listen = Some(v);
2081                i += 2;
2082            }
2083            ("--vlm-upstream", Some(v)) => {
2084                vlm_upstream = Some(v);
2085                i += 2;
2086            }
2087            ("--vlm-model", Some(v)) => {
2088                vlm_model = Some(v);
2089                i += 2;
2090            }
2091            _ => {
2092                i += 1;
2093            }
2094        }
2095    }
2096    let port = listen
2097        .and_then(|s| s.rsplit(':').next())
2098        .map(|p| format!(":{p}"))
2099        .unwrap_or_default();
2100    if name == "pilot" {
2101        let host = vlm_upstream
2102            .and_then(|u| {
2103                u.trim_start_matches("https://")
2104                    .trim_start_matches("http://")
2105                    .split('/')
2106                    .next()
2107            })
2108            .unwrap_or("?");
2109        let model = vlm_model.unwrap_or("?");
2110        format!("{port}  vlm={model}@{host}")
2111    } else {
2112        port
2113    }
2114}
2115
2116/// Translate a `system.<name>:` block into CLI args for the corresponding
2117/// Rust binary. Per-binary mapping kept narrow — adding a new flag means
2118/// touching exactly this function plus the binary's clap struct.
2119///
2120/// `atlas_listen` is the value of `system.atlas.listen` (already resolved
2121/// elsewhere). Consumers that don't carry their own `atlas:` field inherit
2122/// from this so the manifest doesn't have to repeat the address. An
2123/// explicit per-block `atlas:` still wins.
2124/// Extract the `host:port` string each system binary will try to bind.
2125/// Used by the pre-spawn port-availability check. Returns None for
2126/// services we don't gate on (or whose listen field is absent — caller
2127/// then doesn't pre-check).
2128fn system_listen(name: &str, cfg: Option<&serde_yaml::Value>) -> Option<String> {
2129    let map = cfg?.as_mapping()?;
2130    let s = map
2131        .get(serde_yaml::Value::String("listen".into()))?
2132        .as_str()?;
2133    let trimmed = s.trim();
2134    if trimmed.is_empty() || !is_builtin_system(name) {
2135        return None;
2136    }
2137    Some(trimmed.to_string())
2138}
2139
2140/// Probe a host:port. Returns `Ok(())` when nothing is listening (we can
2141/// safely bind), `Err` describing the live owner otherwise. This is a
2142/// race-prone pre-check (someone else can grab the port between probe
2143/// and spawn) but in practice the failure mode it catches — a stale
2144/// previous-boot daemon — has been alive for minutes, not seconds, so
2145/// a single connect attempt is enough.
2146fn port_is_free(listen: &str) -> std::result::Result<(), anyhow::Error> {
2147    use std::net::{TcpStream, ToSocketAddrs};
2148    let addrs: Vec<_> = listen
2149        .to_socket_addrs()
2150        .with_context(|| format!("parse listen='{listen}' as socket addr"))?
2151        .collect();
2152    for addr in &addrs {
2153        // 200 ms is enough for a local connect; if a daemon is alive on
2154        // 127.0.0.1 the SYN-ACK is sub-ms.
2155        if TcpStream::connect_timeout(addr, std::time::Duration::from_millis(200)).is_ok() {
2156            return Err(anyhow::anyhow!("something is already listening on {addr}"));
2157        }
2158    }
2159    Ok(())
2160}
2161
2162/// Translate supported `system:` manifest fields into CLI args for built-in binaries.
2163fn system_cli_args(
2164    name: &str,
2165    cfg: Option<&serde_yaml::Value>,
2166    atlas_listen: Option<&str>,
2167) -> Vec<String> {
2168    let mut out = Vec::new();
2169    let map = cfg.and_then(|v| v.as_mapping());
2170
2171    // Pass the component's whole manifest config block as one JSON arg. The
2172    // binary parses the keys it needs (e.g. scribe reads `log` via
2173    // robonix_scribe::init_from_config), so new manifest keys flow through
2174    // without per-key plumbing here. The typed flags below remain for the
2175    // fields binaries still read individually.
2176    if let Some(v) = cfg
2177        && let Ok(json) = serde_json::to_string(v)
2178    {
2179        out.push("--config-json".into());
2180        out.push(json);
2181    }
2182
2183    let s = |k: &str| -> Option<String> {
2184        map.and_then(|m| {
2185            m.get(serde_yaml::Value::String(k.into()))
2186                .and_then(|v| v.as_str())
2187                .map(|s| s.to_string())
2188        })
2189    };
2190    let nested_str = |outer: &str, inner: &str| -> Option<String> {
2191        map.and_then(|m| m.get(serde_yaml::Value::String(outer.into())))
2192            .and_then(|v| v.as_mapping())
2193            .and_then(|m| m.get(serde_yaml::Value::String(inner.into())))
2194            .and_then(|v| v.as_str())
2195            .map(|s| s.to_string())
2196    };
2197    let push_pair = |out: &mut Vec<String>, flag: &str, val: Option<String>| {
2198        if let Some(v) = val {
2199            out.push(flag.into());
2200            out.push(v);
2201        }
2202    };
2203    match name {
2204        "atlas" => {
2205            push_pair(&mut out, "--listen", s("listen"));
2206            push_pair(&mut out, "--log", s("log"));
2207            // Atlas walks `<root>/capabilities/**/*.toml` at startup to
2208            // build the contract registry. Honour an explicit override
2209            // from the manifest, otherwise let atlas fall back to its
2210            // own ROBONIX_SOURCE_PATH-derived default (we don't pass
2211            // --capabilities here from rbnx; deploy.rs sets the env var
2212            // on the spawned process so the default path stays correct
2213            // even when manifests don't mention atlas at all).
2214            push_pair(&mut out, "--capabilities", s("capabilities"));
2215        }
2216        "executor" => {
2217            push_pair(&mut out, "--listen", s("listen"));
2218            push_pair(
2219                &mut out,
2220                "--atlas",
2221                s("atlas").or_else(|| atlas_listen.map(str::to_string)),
2222            );
2223            push_pair(&mut out, "--log", s("log"));
2224        }
2225        "pilot" => {
2226            push_pair(&mut out, "--listen", s("listen"));
2227            push_pair(
2228                &mut out,
2229                "--atlas",
2230                s("atlas").or_else(|| atlas_listen.map(str::to_string)),
2231            );
2232            push_pair(&mut out, "--log", s("log"));
2233            // Embedded VLM block.
2234            push_pair(&mut out, "--vlm-upstream", nested_str("vlm", "upstream"));
2235            push_pair(&mut out, "--vlm-api-key", nested_str("vlm", "api_key"));
2236            push_pair(&mut out, "--vlm-model", nested_str("vlm", "model"));
2237            push_pair(&mut out, "--vlm-format", nested_str("vlm", "api_format"));
2238        }
2239        "liaison" => {
2240            push_pair(&mut out, "--listen", s("listen"));
2241            push_pair(
2242                &mut out,
2243                "--atlas",
2244                s("atlas").or_else(|| atlas_listen.map(str::to_string)),
2245            );
2246            push_pair(&mut out, "--pilot-endpoint", s("pilot_endpoint"));
2247            push_pair(&mut out, "--log", s("log"));
2248        }
2249        "soma" => {
2250            // v2 flat schema: four keys, four CLI flags. rbnx no longer
2251            // passes `--rbnx-bin` (soma calls the on-PATH `rbnx`),
2252            // `--default-robot` / `--deployment` (single robot, deployment
2253            // path derived from --robot-yaml's parent), or
2254            // `--start-packages` (soma always spawns primitives + skills;
2255            // opting out was never actually used). Stage 2 is delivered
2256            // over an inherited pipe fd (see spawn_soma_binary), not
2257            // atlas RPC.
2258            push_pair(&mut out, "--listen", s("listen"));
2259            push_pair(
2260                &mut out,
2261                "--atlas",
2262                s("atlas_endpoint")
2263                    .or_else(|| s("atlas"))
2264                    .or_else(|| atlas_listen.map(str::to_string)),
2265            );
2266            push_pair(&mut out, "--provider-id", s("provider_id"));
2267            push_pair(&mut out, "--robot-yaml", s("robot_yaml"));
2268            push_pair(&mut out, "--deployment-manifest", s("deployment_manifest"));
2269            push_pair(&mut out, "--config", s("config"));
2270            push_pair(&mut out, "--log", s("log"));
2271        }
2272        "vitals" => {
2273            push_pair(&mut out, "--listen", s("listen"));
2274            push_pair(
2275                &mut out,
2276                "--atlas",
2277                s("atlas").or_else(|| atlas_listen.map(str::to_string)),
2278            );
2279            push_pair(&mut out, "--id", s("provider_id").or_else(|| s("id")));
2280            push_pair(&mut out, "--thresholds-path", s("thresholds_path"));
2281            push_pair(&mut out, "--soma-endpoint", s("soma_endpoint"));
2282            push_pair(&mut out, "--config", s("config"));
2283            push_pair(&mut out, "--log", s("log"));
2284        }
2285        _ => {}
2286    }
2287    out
2288}
2289
2290/// Spawn one package and wait for its provider to register with Atlas.
2291///
2292/// The selected shared or explicit legacy Driver is verified before
2293/// INIT/ACTIVATE and receives the entry's config. Omission and explicit shared
2294/// selections stay shared-only; only an exact namespace legacy selection may
2295/// accept an upgraded shared runtime Driver.
2296async fn spawn_and_init(
2297    component: &str,
2298    entry: &PackageEntry,
2299    spawn_env: &PackageSpawnEnv<'_>,
2300    atlas: &mut AtlasClient,
2301) -> Result<Spawned> {
2302    let before = snapshot_provider_ids(atlas)
2303        .await
2304        .with_context(|| format!("[{component}] pre-spawn atlas snapshot"))?;
2305
2306    let mut sp = spawn_package(component, entry, spawn_env).await?;
2307    let pkg_label = sp.name.clone();
2308
2309    // One package = one provider. Atlas may reuse a stable provider id on
2310    // takeover, so registration_id (not id alone) correlates this spawn.
2311
2312    // Once the wrapper is up, every error path below must terminate the
2313    // PGID before bailing — otherwise `?` returns the spawned process to
2314    // a dead Spawned (which itself has no killing Drop), the caller's
2315    // teardown loop never sees it (`children.push(sp)` only runs after
2316    // this fn succeeds), and the orphan keeps holding whatever the
2317    // package opened (e.g. memsearch's milvus DB lock, executor's gRPC
2318    // port, …). Give the provider's SIGTERM handler time to run
2319    // `on_shutdown` before SIGKILL fallback; providers may own ROS children
2320    // in their own process groups that only the handler knows about.
2321    let pgid = sp.pgid;
2322
2323    let registration = match wait_for_registration(
2324        atlas,
2325        &before,
2326        &entry.name,
2327        &pkg_label,
2328        component,
2329        spawn_env.log_dir,
2330        &mut sp.child,
2331    )
2332    .await
2333    {
2334        Ok(v) => v,
2335        Err(e) => {
2336            terminate_process_group(pgid, Duration::from_secs(8)).await;
2337            return Err(e);
2338        }
2339    };
2340    let provider_id = registration.provider_id.clone();
2341    // The exact-id waiter makes this an internal invariant. Keep the check as
2342    // defense in depth so a future launcher refactor cannot deliver config to
2343    // a provider other than the manifest instance.
2344    if provider_id != entry.name {
2345        let log_file = log_path(spawn_env.log_dir, &pkg_label);
2346        output::boot_fail(
2347            short_label(&pkg_label, component),
2348            &format!(
2349                "deployment instance propagation failed: expected manifest name='{}', \
2350                 observed Capability(id='{}'). Log: {}",
2351                entry.name,
2352                provider_id,
2353                log_file.display()
2354            ),
2355        );
2356        terminate_process_group(pgid, Duration::from_secs(8)).await;
2357        anyhow::bail!(
2358            "[{component}/{pkg_label}] deployment identity invariant failed: manifest name='{}' vs Capability(id='{}')",
2359            entry.name,
2360            provider_id,
2361        );
2362    }
2363
2364    sp.provider_id = Some(provider_id.clone());
2365    let expected_driver_contract = sp
2366        .expected_driver_contract
2367        .as_deref()
2368        .expect("package spawns always carry a lifecycle selection");
2369    let driver_contract = match resolve_runtime_driver_contract(
2370        &provider_id,
2371        &registration.provider_namespace,
2372        expected_driver_contract,
2373        &registration.driver_contracts,
2374        sp.allow_shared_driver_upgrade,
2375    ) {
2376        Ok(contract) => contract,
2377        Err(error) => {
2378            let log_file = log_path(spawn_env.log_dir, &pkg_label);
2379            output::boot_fail(
2380                short_label(&pkg_label, component),
2381                &format!("{error}; log {}", log_file.display()),
2382            );
2383            terminate_process_group(pgid, Duration::from_secs(8)).await;
2384            return Err(error).with_context(|| format!("[{component}/{pkg_label}] lifecycle"));
2385        }
2386    };
2387
2388    if driver_contract != expected_driver_contract {
2389        output::warning(&format!(
2390            "provider '{provider_id}' publishes shared lifecycle Driver '{driver_contract}' for legacy manifest selection '{expected_driver_contract}'; remove the legacy Driver declaration to finish migration"
2391        ));
2392    }
2393
2394    let config_json = serde_json::to_string(&entry.config).with_context(|| {
2395        format!(
2396            "[{component}/{pkg_label}] serialize config for deployment instance '{}'",
2397            entry.name
2398        )
2399    })?;
2400    sp.driver_contract = Some(driver_contract.clone());
2401    sp.config_json = Some(config_json.clone());
2402
2403    let display_label = short_label(&pkg_label, component);
2404    let init_state = match with_spinner(
2405        display_label,
2406        "driver(INIT)…",
2407        call_driver_cmd(
2408            atlas,
2409            &provider_id,
2410            &driver_contract,
2411            component,
2412            &pkg_label,
2413            CMD_INIT,
2414            config_json.clone(),
2415        ),
2416    )
2417    .await
2418    {
2419        Ok(v) => v,
2420        Err(e) => {
2421            terminate_process_group(pgid, Duration::from_secs(8)).await;
2422            return Err(e);
2423        }
2424    };
2425
2426    if component == "skill" {
2427        // Skills stop at INACTIVE post-INIT; the executor sends
2428        // CMD_ACTIVATE on first MCP call (lazy-activate).
2429        output::boot_ok(
2430            display_label,
2431            &format!(
2432                "{}  (skill — awaits executor activate)",
2433                init_state.to_uppercase()
2434            ),
2435        );
2436        return Ok(sp);
2437    }
2438
2439    let activate_state = match with_spinner(
2440        display_label,
2441        "driver(ACTIVATE)…",
2442        call_driver_cmd(
2443            atlas,
2444            &provider_id,
2445            &driver_contract,
2446            component,
2447            &pkg_label,
2448            CMD_ACTIVATE,
2449            config_json,
2450        ),
2451    )
2452    .await
2453    {
2454        Ok(v) => v,
2455        Err(e) => {
2456            terminate_process_group(pgid, Duration::from_secs(8)).await;
2457            return Err(e);
2458        }
2459    };
2460    // Boot succeeded: provider walked REGISTERED → INACTIVE → ACTIVE. Show
2461    // only the final state — the two intermediate driver calls already
2462    // got their own spinner lines and OK ticks above. provider_id is the
2463    // leftmost label so we don't repeat it here.
2464    let _ = init_state; // intermediate, only kept for the assertion below
2465    output::boot_ok(display_label, &activate_state.to_uppercase());
2466
2467    Ok(sp)
2468}
2469
2470/// Run `fut` while animating the boot spinner so the user sees the
2471/// `[ ⠙ ] name  msg_prefix N.Ns` line update steadily even when the
2472/// underlying RPC takes a while (Driver(CMD_INIT) for sensor-warm-up
2473/// packages routinely sits at 30+ seconds). Without this the line goes
2474/// silent right after `wait_for_registration` finishes and rbnx looks
2475/// hung between OK lines.
2476async fn with_spinner<F, T>(label: &str, msg_prefix: &str, fut: F) -> T
2477where
2478    F: std::future::Future<Output = T>,
2479{
2480    if output::boot_verbose() {
2481        output::boot_wait(label, msg_prefix);
2482        return fut.await;
2483    }
2484    use std::time::Instant;
2485    let started = Instant::now();
2486    let mut tick = tokio::time::interval(Duration::from_millis(100));
2487    tick.tick().await; // first tick fires immediately; consume so the
2488    // first redraw is delayed by 100 ms (no double-frame at t=0).
2489    tokio::pin!(fut);
2490    let mut frame: usize = 0;
2491    loop {
2492        tokio::select! {
2493            res = &mut fut => return res,
2494            _ = tick.tick() => {
2495                let elapsed = started.elapsed().as_secs_f32();
2496                output::boot_progress(
2497                    label,
2498                    &format!("{msg_prefix} {elapsed:>4.1}s"),
2499                    frame,
2500                );
2501                frame = frame.wrapping_add(1);
2502            }
2503        }
2504    }
2505}
2506
2507async fn wait_for_soma_stage1(
2508    atlas: &mut AtlasClient,
2509    primitive_names: &[String],
2510    soma_child: &mut Child,
2511    log_dir: &Path,
2512) -> Result<()> {
2513    const SPINNER_TICK: Duration = Duration::from_millis(100);
2514    const POLLS_PER_TICK: usize = 5; // poll atlas every 500 ms
2515    const SOMA_STAGE1_TIMEOUT: Duration = Duration::from_secs(180);
2516    const SOMA_GET_YAML_CONTRACT: &str = "robonix/system/soma/get_yaml";
2517
2518    let started = Instant::now();
2519    let deadline = started + SOMA_STAGE1_TIMEOUT;
2520    let mut frame: usize = 0;
2521    let mut observed_states: HashMap<String, i32> = HashMap::new();
2522    let mut active_primitives: HashSet<String> = HashSet::new();
2523    let mut reported_failures: HashSet<String> = HashSet::new();
2524    if output::boot_verbose() {
2525        output::boot_wait("primitive", "waiting for Soma-managed providers");
2526    }
2527    loop {
2528        let elapsed_s = started.elapsed().as_secs_f32();
2529        let detail = if primitive_names.is_empty() {
2530            format!("waiting for Soma gRPC readiness… {elapsed_s:>4.1}s")
2531        } else {
2532            format!(
2533                "starting {} primitive package(s)… {elapsed_s:>4.1}s",
2534                primitive_names.len()
2535            )
2536        };
2537        if output::boot_verbose() {
2538            if frame > 0 && frame.is_multiple_of(50) {
2539                output::boot_note("primitive", &detail);
2540            }
2541        } else {
2542            output::boot_progress("primitive", &detail, frame);
2543        }
2544        // Check every tick whether soma is still alive. If it exited
2545        // (typically: `missing robot_yaml`, `read Soma config`, port
2546        // bind failure), surface that immediately with the tail of
2547        // its own log rather than sitting on this spinner for the
2548        // full SOMA_STAGE1_TIMEOUT (180s) which frustrates operators
2549        // and blocks CI. try_wait is non-blocking; Ok(Some(_)) means
2550        // the child has been reaped and the OS-level status is known.
2551        if let Ok(Some(status)) = soma_child.try_wait() {
2552            let mut provider_failures = Vec::new();
2553            for name in primitive_names {
2554                let log_file = log_dir.join(format!("{name}.log"));
2555                if let Some(cause) = read_provider_failure(&log_file) {
2556                    if reported_failures.insert(name.clone()) {
2557                        output::boot_fail(
2558                            name,
2559                            &format!("ERROR; {cause}; log {}", log_file.display()),
2560                        );
2561                    }
2562                    provider_failures.push((name, cause, log_file));
2563                }
2564            }
2565            if !provider_failures.is_empty() {
2566                let names = provider_failures
2567                    .iter()
2568                    .map(|(name, _, _)| name.as_str())
2569                    .collect::<Vec<_>>()
2570                    .join(", ");
2571                let logs = provider_failures
2572                    .iter()
2573                    .map(|(_, _, path)| path.display().to_string())
2574                    .collect::<Vec<_>>()
2575                    .join(", ");
2576                output::boot_fail(
2577                    "primitive",
2578                    &format!("soma exited after provider failure(s): {names}"),
2579                );
2580                anyhow::bail!(
2581                    "Soma exited with {status:?} after provider failure(s): {names}; logs: {logs}"
2582                );
2583            }
2584
2585            let log_file = log_dir.join("soma.log");
2586            let tail = read_log_tail(&log_file, 20);
2587            output::boot_fail(
2588                "primitive",
2589                &format!(
2590                    "soma exited before becoming ACTIVE (status={status:?}); see {}",
2591                    log_file.display()
2592                ),
2593            );
2594            let hint = if tail.is_empty() {
2595                String::new()
2596            } else {
2597                format!("\n--- soma.log tail ---\n{tail}\n--- end ---")
2598            };
2599            anyhow::bail!(
2600                "soma exited with {status:?} before primitive readiness; \
2601                 log: {}{hint}",
2602                log_file.display()
2603            );
2604        }
2605        if frame.is_multiple_of(POLLS_PER_TICK) {
2606            for name in primitive_names {
2607                let providers = atlas
2608                    .query_capabilities(name, "", atlas_pb::Transport::Unspecified)
2609                    .await
2610                    .with_context(|| format!("poll primitive '{name}' during Soma bring-up"))?;
2611                let Some(provider) = providers.into_iter().find(|provider| provider.id == *name)
2612                else {
2613                    continue;
2614                };
2615                let previous = observed_states.insert(name.clone(), provider.state);
2616                if previous != Some(provider.state) {
2617                    let state = lifecycle_state_label(provider.state);
2618                    if provider.state == atlas_pb::LifecycleState::StateActive as i32 {
2619                        output::boot_ok(name, "ACTIVE");
2620                        active_primitives.insert(name.clone());
2621                    } else if provider.state == atlas_pb::LifecycleState::StateError as i32 {
2622                        let log_file = log_dir.join(format!("{name}.log"));
2623                        let detail = read_provider_failure(&log_file).map_or_else(
2624                            || format!("ERROR; log {}", log_file.display()),
2625                            |cause| format!("ERROR; {cause}; log {}", log_file.display()),
2626                        );
2627                        output::boot_fail(name, &detail);
2628                        reported_failures.insert(name.clone());
2629                    } else if output::boot_verbose() {
2630                        output::boot_note(name, state);
2631                    }
2632                }
2633            }
2634            let providers = atlas
2635                .query_capabilities("soma", SOMA_GET_YAML_CONTRACT, atlas_pb::Transport::Grpc)
2636                .await
2637                .context("wait for Soma primitive readiness")?;
2638            if let Some(soma) = providers.into_iter().find(|p| p.id == "soma")
2639                && soma.state == atlas_pb::LifecycleState::StateActive as i32
2640                && soma_grpc_ready(atlas, SOMA_GET_YAML_CONTRACT).await
2641            {
2642                for name in primitive_names {
2643                    if !active_primitives.contains(name) {
2644                        output::boot_ok(name, "ACTIVE");
2645                    }
2646                }
2647                return Ok(());
2648            }
2649        }
2650        if Instant::now() >= deadline {
2651            output::boot_fail(
2652                "primitive",
2653                &format!(
2654                    "timeout after {:?}; service bring-up needs primitives ACTIVE first",
2655                    SOMA_STAGE1_TIMEOUT
2656                ),
2657            );
2658            anyhow::bail!(
2659                "Soma primitive bring-up did not become ready within {:?}; refusing to start service packages before primitives are ready",
2660                SOMA_STAGE1_TIMEOUT
2661            );
2662        }
2663        tokio::time::sleep(SPINNER_TICK).await;
2664        frame = frame.wrapping_add(1);
2665    }
2666}
2667
2668fn lifecycle_state_label(state: i32) -> &'static str {
2669    if state == atlas_pb::LifecycleState::StateRegistered as i32 {
2670        "REGISTERED"
2671    } else if state == atlas_pb::LifecycleState::StateInactive as i32 {
2672        "INACTIVE"
2673    } else if state == atlas_pb::LifecycleState::StateActive as i32 {
2674        "ACTIVE"
2675    } else if state == atlas_pb::LifecycleState::StateError as i32 {
2676        "ERROR"
2677    } else if state == atlas_pb::LifecycleState::StateTerminated as i32 {
2678        "TERMINATED"
2679    } else {
2680        "STARTING"
2681    }
2682}
2683
2684async fn wait_for_soma_skills(atlas: &mut AtlasClient, skill_names: &[String]) -> Result<()> {
2685    const TIMEOUT: Duration = Duration::from_secs(180);
2686    if skill_names.is_empty() {
2687        return Ok(());
2688    }
2689    let deadline = Instant::now() + TIMEOUT;
2690    let mut observed_states: HashMap<String, i32> = HashMap::new();
2691    let mut ready: HashSet<String> = HashSet::new();
2692    while Instant::now() < deadline {
2693        for name in skill_names {
2694            let providers = atlas
2695                .query_capabilities(name, "", atlas_pb::Transport::Unspecified)
2696                .await
2697                .with_context(|| format!("poll skill '{name}' during soma bring-up"))?;
2698            let Some(provider) = providers.into_iter().find(|provider| provider.id == *name) else {
2699                continue;
2700            };
2701            if observed_states.insert(name.clone(), provider.state) != Some(provider.state) {
2702                let state = lifecycle_state_label(provider.state);
2703                if provider.state == atlas_pb::LifecycleState::StateInactive as i32
2704                    || provider.state == atlas_pb::LifecycleState::StateActive as i32
2705                {
2706                    output::boot_ok(name, state);
2707                    ready.insert(name.clone());
2708                } else if provider.state == atlas_pb::LifecycleState::StateError as i32 {
2709                    output::boot_fail(name, "ERROR; see soma.log and provider log");
2710                    anyhow::bail!("skill '{name}' entered ERROR during Soma bring-up");
2711                } else if output::boot_verbose() {
2712                    output::boot_note(name, state);
2713                }
2714            }
2715        }
2716        if ready.len() == skill_names.len() {
2717            return Ok(());
2718        }
2719        tokio::time::sleep(Duration::from_millis(200)).await;
2720    }
2721    let pending = skill_names
2722        .iter()
2723        .filter(|name| !ready.contains(*name))
2724        .cloned()
2725        .collect::<Vec<_>>();
2726    for name in &pending {
2727        output::boot_fail(
2728            name,
2729            "registration/INIT timeout; see soma.log and provider log",
2730        );
2731    }
2732    anyhow::bail!(
2733        "Soma skill bring-up timed out after {TIMEOUT:?}: {}",
2734        pending.join(", ")
2735    )
2736}
2737
2738/// Read the last `max_lines` lines of a file for embedding into an
2739/// error message. Best-effort: an unreadable/missing file returns an
2740/// empty string rather than an error — the caller already reports the
2741/// path, we just enrich when we can. We read the whole file (soma.log
2742/// is scribe-managed and stays small during boot), split, and take
2743/// the tail — no seek-from-end acrobatics needed for the boot-time
2744/// use case.
2745fn read_log_tail(path: &Path, max_lines: usize) -> String {
2746    let Ok(contents) = std::fs::read_to_string(path) else {
2747        return String::new();
2748    };
2749    let lines: Vec<&str> = contents.lines().collect();
2750    let start = lines.len().saturating_sub(max_lines);
2751    lines[start..].join("\n")
2752}
2753
2754/// Return the provider's actual lifecycle failure rather than whichever
2755/// shutdown record happened to be written last. Scribe records are JSONL;
2756/// providers commonly report lifecycle transitions at info level, so prefer
2757/// `-> ERROR (...)` messages before falling back to an error-level record.
2758fn read_provider_failure(path: &Path) -> Option<String> {
2759    let contents = std::fs::read_to_string(path).ok()?;
2760    let mut error_level_fallback = None;
2761    for line in contents.lines().rev() {
2762        let Ok(record) = serde_json::from_str::<serde_json::Value>(line) else {
2763            continue;
2764        };
2765        let Some(message) = record.get("msg").and_then(|value| value.as_str()) else {
2766            continue;
2767        };
2768        if let Some((_, cause)) = message.split_once(" -> ERROR (") {
2769            return Some(cause.strip_suffix(')').unwrap_or(cause).to_string());
2770        }
2771        if error_level_fallback.is_none()
2772            && record.get("level").and_then(|value| value.as_str()) == Some("error")
2773        {
2774            error_level_fallback = Some(message.to_string());
2775        }
2776    }
2777    error_level_fallback
2778}
2779
2780/// Summarize a package that exited before Atlas registration. Providers often
2781/// forward Python tracebacks through Scribe at info level, so the lifecycle-
2782/// specific parser above may intentionally return None. In that case the last
2783/// structured message is the most useful single-line cause for boot output.
2784fn read_provider_exit_summary(path: &Path) -> Option<String> {
2785    if let Some(cause) = read_provider_failure(path) {
2786        return Some(cause);
2787    }
2788    let contents = std::fs::read_to_string(path).ok()?;
2789    for line in contents.lines().rev() {
2790        if let Ok(record) = serde_json::from_str::<serde_json::Value>(line)
2791            && let Some(message) = record.get("msg").and_then(|value| value.as_str())
2792            && !message.trim().is_empty()
2793        {
2794            return Some(message.trim().to_string());
2795        }
2796        if !line.trim().is_empty() {
2797            return Some(line.trim().to_string());
2798        }
2799    }
2800    None
2801}
2802
2803async fn soma_grpc_ready(atlas: &mut AtlasClient, contract_id: &str) -> bool {
2804    let Ok((channel_id, endpoint, _params)) = atlas
2805        .connect_capability(
2806            DEPLOY_CONSUMER_ID,
2807            "soma",
2808            contract_id,
2809            atlas_pb::Transport::Grpc,
2810        )
2811        .await
2812    else {
2813        return false;
2814    };
2815    let normalized = if endpoint.starts_with("http") {
2816        endpoint
2817    } else {
2818        format!("http://{endpoint}")
2819    };
2820    let ready = match Endpoint::new(normalized.clone()) {
2821        Ok(endpoint) => tokio::time::timeout(Duration::from_secs(1), endpoint.connect())
2822            .await
2823            .is_ok_and(|r| r.is_ok()),
2824        Err(_) => false,
2825    };
2826    let _ = atlas.disconnect_capability(&channel_id).await;
2827    ready
2828}
2829
2830/// Write the `stage2\n` trigger into the pipe rbnx and soma share
2831/// (see `spawn_soma_binary`). This is a one-shot: soma reads the
2832/// line, unblocks its skill-package launcher, and closes its read
2833/// end. rbnx-side we drop the writer here — no reason to hold it
2834/// open, and closing gives soma an immediate EOF on the (very
2835/// unlikely) chance it re-reads.
2836///
2837/// If we don't have a writer (soma wasn't spawned by us, e.g.
2838/// --skip-system), this is a no-op with a warning: someone else
2839/// owns soma's fd and there's nothing rbnx can meaningfully do.
2840fn write_stage2_trigger(writer: &mut Option<std::fs::File>) -> Result<()> {
2841    use std::io::Write;
2842    let Some(mut w) = writer.take() else {
2843        output::boot_skip(
2844            "skill",
2845            "start skipped: no trigger writer (Soma was not spawned by this rbnx)",
2846        );
2847        return Ok(());
2848    };
2849    w.write_all(b"stage2\n")
2850        .context("write 'stage2' to soma stage-trigger pipe")?;
2851    w.flush().context("flush soma stage-trigger pipe")?;
2852    // "written", not "delivered": all we know at this point is that
2853    // the bytes hit the pipe. Actual delivery (soma reads the line,
2854    // spawns skills, and their MCP tools/caps register) is verified
2855    // downstream by the boot-poll cap-wait loop, not here.
2856    Ok(())
2857}
2858
2859/// Issue one Driver(cmd) RPC against a freshly-connected channel, then
2860/// release the channel. Returns the response's `state` string on success;
2861/// bail-errors when ok=false or the RPC itself fails. Used by the boot
2862/// path for both CMD_INIT and CMD_ACTIVATE, with identical timeout / channel
2863/// hygiene.
2864async fn call_driver_cmd(
2865    atlas: &mut AtlasClient,
2866    provider_id: &str,
2867    driver_contract: &str,
2868    component: &str,
2869    pkg_label: &str,
2870    cmd: u32,
2871    config_json: String,
2872) -> Result<String> {
2873    let cmd_name = match cmd {
2874        CMD_INIT => "INIT",
2875        CMD_ACTIVATE => "ACTIVATE",
2876        CMD_DEACTIVATE => "DEACTIVATE",
2877        CMD_SHUTDOWN => "SHUTDOWN",
2878        _ => "?",
2879    };
2880    let (channel_id, endpoint, _params) = atlas
2881        .connect_capability(
2882            DEPLOY_CONSUMER_ID,
2883            provider_id,
2884            driver_contract,
2885            atlas_pb::Transport::Grpc,
2886        )
2887        .await
2888        .with_context(|| {
2889            format!("[{component}/{pkg_label}] ConnectCapability for {driver_contract}")
2890        })?;
2891    let normalized = if endpoint.starts_with("http") {
2892        endpoint
2893    } else {
2894        format!("http://{endpoint}")
2895    };
2896    let result = async {
2897        let driver_timeout = driver_init_timeout();
2898        let channel = Endpoint::new(normalized.clone())
2899            .with_context(|| format!("invalid driver endpoint '{normalized}'"))?
2900            .connect()
2901            .await
2902            .with_context(|| format!("dial driver at '{normalized}'"))?;
2903        let svc_name = contract_id_to_service_name(driver_contract);
2904        let path: tonic::codegen::http::uri::PathAndQuery =
2905            format!("/robonix.contracts.{svc_name}/Driver")
2906                .parse()
2907                .with_context(|| format!("build gRPC path for '{driver_contract}'"))?;
2908        let mut grpc = tonic::client::Grpc::new(channel);
2909        grpc.ready().await.with_context(|| "gRPC ready")?;
2910        let codec: tonic_prost::ProstCodec<DriverRequest, DriverResponse> = Default::default();
2911        let resp = tokio::time::timeout(
2912            driver_timeout,
2913            grpc.unary(
2914                Request::new(DriverRequest {
2915                    command: cmd,
2916                    config_json,
2917                }),
2918                path,
2919                codec,
2920            ),
2921        )
2922        .await
2923        .map_err(|_| {
2924            anyhow::anyhow!(
2925                "Driver(CMD_{cmd_name}) timed out after {}s",
2926                driver_timeout.as_secs()
2927            )
2928        })?
2929        .with_context(|| format!("Driver(CMD_{cmd_name}) RPC failed"))?;
2930        Ok::<_, anyhow::Error>(resp.into_inner())
2931    }
2932    .await;
2933    let _ = atlas.disconnect_capability(&channel_id).await;
2934    let r = result
2935        .map_err(|e| anyhow::anyhow!("[{component}/{pkg_label}] Driver(CMD_{cmd_name}): {e:#}"))?;
2936    if !r.ok {
2937        anyhow::bail!(
2938            "[{component}/{pkg_label}] Driver(CMD_{cmd_name}) returned ok=false (state={}, error={})",
2939            r.state,
2940            r.error
2941        );
2942    }
2943    Ok(r.state)
2944}
2945
2946/// Strip the leading `<component>_` from the boot-log pkg_label.
2947/// `system_memory` → `memory`; `primitive_tiago_chassis` → `tiago_chassis`.
2948/// Keeps boot-output columns narrow (the section header above already
2949/// said which class the entry belongs to).
2950fn short_label<'a>(pkg_label: &'a str, component: &str) -> &'a str {
2951    pkg_label
2952        .strip_prefix(&format!("{component}_"))
2953        .unwrap_or(pkg_label)
2954}
2955
2956/// Poll atlas until a provider NOT in `before` appears. Returns the new
2957/// `provider_id` plus every distinct lifecycle Driver observed after the
2958/// declaration settle window. The caller verifies this list before sending
2959/// config or lifecycle commands.
2960async fn wait_for_registration(
2961    atlas: &mut AtlasClient,
2962    before: &ProviderRegistrationSnapshot,
2963    expected_provider_id: &str,
2964    pkg_label: &str,
2965    component: &str,
2966    log_dir: &Path,
2967    child: &mut Child,
2968) -> Result<RegistrationOutcome> {
2969    if before.contains_key(expected_provider_id) {
2970        anyhow::bail!(
2971            "[{component}/{pkg_label}] deployment instance '{expected_provider_id}' \
2972             was already registered before spawn"
2973        );
2974    }
2975
2976    // Wait for this manifest instance's exact id with a fresh registration
2977    // generation. Unrelated providers can register concurrently and must not
2978    // receive this instance's lifecycle config.
2979    const SPINNER_TICK: Duration = Duration::from_millis(100);
2980    const POLLS_PER_TICK: u32 = 2; // poll atlas every 200 ms
2981    let started = Instant::now();
2982    let deadline = started + DRIVER_REGISTER_TIMEOUT;
2983    let mut frame: usize = 0;
2984    let display_label = short_label(pkg_label, component);
2985    if output::boot_verbose() {
2986        output::boot_wait(display_label, "registering with atlas");
2987    }
2988    loop {
2989        let elapsed_s = started.elapsed().as_secs_f32();
2990        let detail = format!("registering with atlas… {elapsed_s:>4.1}s");
2991        if output::boot_verbose() {
2992            if frame > 0 && frame.is_multiple_of(50) {
2993                output::boot_note(display_label, &detail);
2994            }
2995        } else {
2996            output::boot_progress(display_label, &detail, frame);
2997        }
2998        // A package wrapper that exits before registering can never recover.
2999        // Detect it on every spinner tick instead of waiting out the full
3000        // registration timeout and then continuing with a misleading generic
3001        // timeout. The caller still terminates the package PGID so any child
3002        // processes left behind by a failed start hook are reaped.
3003        match child.try_wait() {
3004            Ok(Some(status)) => {
3005                let log_file = log_path(log_dir, pkg_label);
3006                let cause = read_provider_exit_summary(&log_file)
3007                    .unwrap_or_else(|| "no diagnostic message in provider log".to_string());
3008                output::boot_fail(
3009                    display_label,
3010                    &format!(
3011                        "start process exited ({status}); {cause}; log {}",
3012                        log_file.display()
3013                    ),
3014                );
3015                anyhow::bail!(
3016                    "[{component}/{pkg_label}] start process exited ({status}) before Atlas registration: {cause}. Log: {}",
3017                    log_file.display()
3018                );
3019            }
3020            Ok(None) => {}
3021            Err(error) => {
3022                anyhow::bail!(
3023                    "[{component}/{pkg_label}] inspect start process while waiting for Atlas registration: {error}"
3024                );
3025            }
3026        }
3027        if frame.is_multiple_of(POLLS_PER_TICK as usize) {
3028            let providers = atlas
3029                .query_capabilities("", "", atlas_pb::Transport::Unspecified)
3030                .await
3031                .with_context(|| format!("[{component}/{pkg_label}] poll atlas"))?;
3032            let matched = providers.iter().find(|provider| {
3033                robonix_cli::launch::is_expected_provider_registration(
3034                    provider,
3035                    before,
3036                    expected_provider_id,
3037                )
3038            });
3039            if let Some(first) = matched {
3040                let provider_id = first.id.clone();
3041                let registration_id = first.registration_id.clone();
3042                // RegisterPrimitive/Service/Skill and DeclareCapability are
3043                // two separate RPCs from the package side — Register lands
3044                // first, declares follow within a few hundred ms. Give it
3045                // up to a 1 s settle window so we don't false-fire a missing
3046                // Driver error on a fast poll. Capped by the outer
3047                // `deadline` so we never exceed user-facing timeout.
3048                let settle_until = Instant::now()
3049                    .checked_add(Duration::from_millis(1000))
3050                    .map(|t| t.min(deadline))
3051                    .unwrap_or(deadline);
3052                let mut current: atlas_pb::CapabilityProvider = (*first).clone();
3053                // Consume the complete settle window so a package that
3054                // declares both shared and legacy Drivers cannot hide the
3055                // second declaration behind the first successful poll.
3056                loop {
3057                    if Instant::now() >= settle_until {
3058                        break;
3059                    }
3060                    tokio::time::sleep(Duration::from_millis(100)).await;
3061                    let providers = atlas
3062                        .query_capabilities(&provider_id, "", atlas_pb::Transport::Unspecified)
3063                        .await
3064                        .with_context(|| format!("[{component}/{pkg_label}] re-poll for driver"))?;
3065                    match providers.into_iter().find(|p| p.id == provider_id) {
3066                        Some(p) if p.registration_id == registration_id => current = p,
3067                        Some(p) => {
3068                            let log_file = log_path(log_dir, pkg_label);
3069                            output::boot_fail(
3070                                display_label,
3071                                &format!(
3072                                    "provider '{provider_id}' registration changed during settle — see {}",
3073                                    log_file.display(),
3074                                ),
3075                            );
3076                            anyhow::bail!(
3077                                "[{component}/{pkg_label}] provider '{provider_id}' registration changed during settle ('{registration_id}' -> '{}'). Log: {}",
3078                                p.registration_id,
3079                                log_file.display(),
3080                            );
3081                        }
3082                        None => {
3083                            // Provider vanished between the original match
3084                            // and now (crashed mid-settle, atlas evicted,
3085                            // heartbeat lapsed). Report loudly so downstream
3086                            // boot logic cannot march on against a dead process.
3087                            let log_file = log_path(log_dir, pkg_label);
3088                            output::boot_fail(
3089                                display_label,
3090                                &format!(
3091                                    "provider '{provider_id}' disappeared during settle — see {}",
3092                                    log_file.display()
3093                                ),
3094                            );
3095                            anyhow::bail!(
3096                                "[{component}/{pkg_label}] provider '{provider_id}' \
3097                                 unregistered during settle window. Log: {}",
3098                                log_file.display()
3099                            );
3100                        }
3101                    }
3102                }
3103                let mut driver_contracts = current
3104                    .capabilities
3105                    .iter()
3106                    .filter(|capability| {
3107                        capability.transport == atlas_pb::Transport::Grpc as i32
3108                            && capability.contract_id.ends_with("/driver")
3109                    })
3110                    .map(|capability| capability.contract_id.clone())
3111                    .collect::<Vec<_>>();
3112                driver_contracts.sort();
3113                driver_contracts.dedup();
3114                return Ok(RegistrationOutcome {
3115                    provider_id,
3116                    provider_kind: current.kind,
3117                    provider_namespace: current.namespace,
3118                    registration_id: current.registration_id,
3119                    driver_contracts,
3120                });
3121            }
3122        }
3123        if Instant::now() >= deadline {
3124            let log_file = log_path(log_dir, pkg_label);
3125            output::boot_fail(
3126                display_label,
3127                &format!(
3128                    "registration timeout after {:?}; expected instance '{}' — see {}",
3129                    DRIVER_REGISTER_TIMEOUT,
3130                    expected_provider_id,
3131                    log_file.display()
3132                ),
3133            );
3134            anyhow::bail!(
3135                "[{component}/{pkg_label}] timed out after {:?} — package never registered expected deployment instance '{}' with atlas. Log: {}",
3136                DRIVER_REGISTER_TIMEOUT,
3137                expected_provider_id,
3138                log_file.display()
3139            );
3140        }
3141        tokio::time::sleep(SPINNER_TICK).await;
3142        frame = frame.wrapping_add(1);
3143    }
3144}