Merge pull request 'feat(daemon): auto-spawn a Pi agent on startup (Operator Image OOTB)' (#137) from autospawn-pi-on-boot into main
Some checks are pending
CI / rust (push) Waiting to run
CI / markdown (push) Waiting to run
CI / port (push) Waiting to run
CI / agent-jail-pkgs (push) Waiting to run

Reviewed-on: #137
This commit is contained in:
clawdie 2026-06-21 18:12:50 +02:00
commit 2cc613ea70
2 changed files with 118 additions and 0 deletions

View file

@ -74,6 +74,11 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
let socket_handle =
tokio::spawn(async move { socket::serve(socket_state, socket_shutdown).await });
// Auto-spawn one Pi agent if configured (live "Operator Image" OOTB flow).
// Runs after the socket server is up so the spawn registers on the board;
// no-op unless COLIBRI_AUTOSPAWN_PI is set and a DeepSeek key is present.
socket::autospawn_pi_if_configured(&state).await;
// Start the daemon background loop (heartbeat, session rotation, scheduler)
let loop_state = state.clone();
let loop_shutdown = state.shutdown_rx.resubscribe();

View file

@ -399,6 +399,97 @@ async fn cmd_list_sessions(state: &SharedState) -> ColibriResponse {
}))
}
/// Auto-spawn a single Pi agent at daemon startup when configured.
///
/// Enabled with `COLIBRI_AUTOSPAWN_PI` (`YES`/`1`/`true`/`on`). Requires a
/// `DEEPSEEK_API_KEY` in the daemon environment (sourced from `provider.env`) —
/// the host-spawned Pi inherits it. Idempotent: skips when a Pi subprocess is
/// already running, so a daemon restart (e.g. after the operator enters creds
/// and `service colibri_daemon restart` runs) does not stack duplicates.
///
/// The live "Operator Image" is single-agent, so the Pi runs on the host (no
/// jail). Binary and argv are env-tunable so the exact Pi invocation can be
/// adjusted on the image without a rebuild:
/// - `COLIBRI_PI_BINARY` (default `pi`)
/// - `COLIBRI_AUTOSPAWN_PI_ARGS` (default `--mode json`)
pub async fn autospawn_pi_if_configured(state: &SharedState) {
if !env_truthy("COLIBRI_AUTOSPAWN_PI") {
return;
}
if state.config.deepseek_api_key.is_none() {
info!(
"autospawn-pi: DEEPSEEK_API_KEY not set; skipping (operator can add it via Join Hive)"
);
return;
}
let pi_binary = std::env::var("COLIBRI_PI_BINARY")
.ok()
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| "pi".to_string());
// One Pi is enough. Match by binary basename so a restart does not duplicate.
let pi_name = basename(&pi_binary);
let already = state
.agents
.iter()
.any(|e| basename(&e.value().config.binary) == pi_name);
if already {
info!(pi = %pi_name, "autospawn-pi: a Pi agent is already running; skipping");
return;
}
let args: Vec<String> = std::env::var("COLIBRI_AUTOSPAWN_PI_ARGS")
.ok()
.filter(|s| !s.trim().is_empty())
.unwrap_or_else(|| "--mode json".to_string())
.split_whitespace()
.map(str::to_string)
.collect();
info!(binary = %pi_binary, ?args, "autospawn-pi: spawning Pi agent on host (DeepSeek-backed)");
// provider=local → binary is the Pi executable; jail=None → host-spawn.
let resp = cmd_spawn_agent(
state,
"local".to_string(),
pi_binary,
None,
None,
Some(args),
None,
)
.await;
if resp.ok {
info!("autospawn-pi: Pi agent spawned");
} else {
warn!(
error = resp.error.as_deref().unwrap_or("unknown"),
"autospawn-pi: spawn failed (continuing; operator can spawn manually)"
);
}
}
fn basename(path: &str) -> String {
std::path::Path::new(path)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or(path)
.to_string()
}
fn env_truthy(name: &str) -> bool {
std::env::var(name)
.map(|v| {
matches!(
v.trim().to_ascii_lowercase().as_str(),
"1" | "yes" | "true" | "on"
)
})
.unwrap_or(false)
}
async fn cmd_spawn_agent(
state: &SharedState,
provider_str: String,
@ -802,6 +893,28 @@ mod tests {
use crate::{DaemonConfig, DaemonState};
#[test]
fn basename_extracts_file_name() {
assert_eq!(basename("/usr/local/bin/pi"), "pi");
assert_eq!(basename("pi"), "pi");
assert_eq!(basename("/opt/agents/zot"), "zot");
}
#[test]
fn env_truthy_accepts_common_affirmatives() {
let key = "COLIBRI_TEST_AUTOSPAWN_TRUTHY";
for v in ["1", "yes", "YES", "true", "On"] {
std::env::set_var(key, v);
assert!(env_truthy(key), "expected {v} to be truthy");
}
for v in ["0", "no", "", "off", "maybe"] {
std::env::set_var(key, v);
assert!(!env_truthy(key), "expected {v} to be falsey");
}
std::env::remove_var(key);
assert!(!env_truthy(key));
}
fn test_config() -> DaemonConfig {
let data_dir = std::env::temp_dir().join(format!(
"colibri-daemon-socket-test-{}",