Merge pull request 'feat(daemon): auto-spawn a Pi agent on startup (Operator Image OOTB)' (#137) from autospawn-pi-on-boot into main
Reviewed-on: #137
This commit is contained in:
commit
2cc613ea70
2 changed files with 118 additions and 0 deletions
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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-{}",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue