The calls run serially before the web UI is served, so a per-call deadline alone left N x 2 min of boot delay (8 min with the default two marketplaces and two plugins, 10 with auto-update) when the fetch path is blackholed. One shared 4 minute budget now bounds the sequence: each call gets min(2 min, what is left), and calls after the budget is spent fail at once with the same warning. Chosen over spawning the install: boot keeps installing plugins before the first turn, as today, and the diff is one Instant threaded through the three call sites. docs/turn-loop/config.md states the 2 minute and 4 minute bounds.
216 lines
8.4 KiB
Rust
216 lines
8.4 KiB
Rust
//! Boot-time `claude plugin install` driver. Reads the list declared
|
|
//! via the `services.hyperhive.agent.claudePlugins` NixOS option (rendered to
|
|
//! `/etc/hyperhive/claude-plugins.json` by the harness module) and
|
|
//! shells out `claude plugin install <spec>` for each entry. Runs once
|
|
//! per harness boot before the turn loop; `claude plugin install`
|
|
//! is expected to be idempotent so reinstalling on each container
|
|
//! recreate is fine. Failures log a warning but do not abort boot —
|
|
//! we'd rather start without a plugin than refuse to serve.
|
|
//!
|
|
//! Before installing, all configured marketplaces are updated so that
|
|
//! plugin specs resolve against current index data. Marketplace update
|
|
//! failures are non-fatal — stale index is better than no install attempt.
|
|
|
|
use std::io;
|
|
use std::process::Output;
|
|
use std::time::{Duration, Instant};
|
|
|
|
use tokio::process::Command;
|
|
|
|
const PLUGINS_PATH: &str = "/etc/hyperhive/claude-plugins.json";
|
|
const MARKETPLACES_PATH: &str = "/etc/hyperhive/claude-marketplaces.json";
|
|
const AUTO_UPDATE_PATH: &str = "/etc/hyperhive/claude-plugins-auto-update.json";
|
|
|
|
/// Per-call deadline for `claude plugin …`; a git clone over a slow link fits, a hung fetch does not.
|
|
const PLUGIN_CALL_TIMEOUT: Duration = Duration::from_mins(2);
|
|
|
|
/// Total budget for the whole serial sequence in [`install_configured`]. The
|
|
/// harness awaits it before serving the web UI, so this is the longest a
|
|
/// blackholed fetch path can delay boot, however many marketplaces and plugins
|
|
/// are configured.
|
|
const PLUGIN_BOOT_BUDGET: Duration = Duration::from_mins(4);
|
|
|
|
/// The deadline for the next call: [`PLUGIN_CALL_TIMEOUT`], cut to what is
|
|
/// left of the boot budget ending at `boot_end` (zero once it is spent).
|
|
fn call_budget(now: Instant, boot_end: Instant) -> Duration {
|
|
PLUGIN_CALL_TIMEOUT.min(boot_end.saturating_duration_since(now))
|
|
}
|
|
|
|
/// Run `claude plugin <args>` under [`call_budget`]. On timeout the child is
|
|
/// killed (`kill_on_drop`) and a `TimedOut` error is returned. Only the
|
|
/// `claude` process is killed; a `git` it forked is not.
|
|
async fn run_claude_plugin(args: &[&str], boot_end: Instant) -> io::Result<Output> {
|
|
let mut cmd = Command::new("claude");
|
|
cmd.arg("plugin").args(args);
|
|
run_bounded(cmd, call_budget(Instant::now(), boot_end)).await
|
|
}
|
|
|
|
/// Run `cmd` to completion within `deadline`; the child is killed when the
|
|
/// deadline passes.
|
|
async fn run_bounded(mut cmd: Command, deadline: Duration) -> io::Result<Output> {
|
|
let call = cmd.kill_on_drop(true).output();
|
|
tokio::time::timeout(deadline, call)
|
|
.await
|
|
.unwrap_or_else(|_| {
|
|
Err(io::Error::new(
|
|
io::ErrorKind::TimedOut,
|
|
format!("no exit within {}s", deadline.as_secs_f32()),
|
|
))
|
|
})
|
|
}
|
|
|
|
/// Add every marketplace from `/etc/hyperhive/claude-marketplaces.json`
|
|
/// via `claude plugin marketplace add <source>`. Idempotent: re-add of
|
|
/// an existing marketplace is treated as success (claude prints an
|
|
/// "already exists" message and exits non-zero on some versions).
|
|
/// Required before any `<plugin>@<marketplace>` install can resolve.
|
|
async fn add_marketplaces(boot_end: Instant) {
|
|
let Ok(raw) = tokio::fs::read_to_string(MARKETPLACES_PATH).await else {
|
|
return;
|
|
};
|
|
let sources: Vec<String> = match serde_json::from_str(&raw) {
|
|
Ok(v) => v,
|
|
Err(e) => {
|
|
tracing::warn!(path = MARKETPLACES_PATH, error = ?e, "claude-marketplaces spec parse failed; skipping");
|
|
return;
|
|
}
|
|
};
|
|
for source in sources {
|
|
match run_claude_plugin(&["marketplace", "add", &source], boot_end).await {
|
|
Ok(out) if out.status.success() => {
|
|
tracing::info!(source = %source, "claude plugin marketplace add ok");
|
|
}
|
|
Ok(out) => {
|
|
let stderr = String::from_utf8_lossy(&out.stderr);
|
|
if stderr.contains("already") {
|
|
tracing::debug!(source = %source, "marketplace already added");
|
|
} else {
|
|
tracing::warn!(
|
|
source = %source,
|
|
status = ?out.status,
|
|
stderr = %stderr,
|
|
"claude plugin marketplace add failed (non-fatal)",
|
|
);
|
|
}
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(source = %source, error = ?e, "claude plugin marketplace add spawn failed or timed out");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Read the `services.hyperhive.agent.claudePluginsAutoUpdate` flag written by the NixOS
|
|
/// module. Defaults to `false` when the file is absent or unparseable.
|
|
async fn auto_update_enabled() -> bool {
|
|
match tokio::fs::read_to_string(AUTO_UPDATE_PATH).await {
|
|
Ok(s) => serde_json::from_str::<bool>(s.trim()).unwrap_or(false),
|
|
Err(_) => false,
|
|
}
|
|
}
|
|
|
|
/// Update all configured plugin marketplaces. Non-fatal — logs a warning
|
|
/// on failure but does not abort the install sequence.
|
|
async fn update_marketplaces(boot_end: Instant) {
|
|
match run_claude_plugin(&["marketplace", "update"], boot_end).await {
|
|
Ok(out) if out.status.success() => {
|
|
tracing::info!("claude plugin marketplace update ok");
|
|
}
|
|
Ok(out) => {
|
|
tracing::warn!(
|
|
status = ?out.status,
|
|
stderr = %String::from_utf8_lossy(&out.stderr),
|
|
"claude plugin marketplace update failed (non-fatal)",
|
|
);
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(error = ?e, "claude plugin marketplace update spawn failed or timed out (non-fatal)");
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Install every plugin in `/etc/hyperhive/claude-plugins.json`.
|
|
/// A failed install is logged and skipped; the harness starts without it.
|
|
pub async fn install_configured() {
|
|
let Ok(raw) = tokio::fs::read_to_string(PLUGINS_PATH).await else {
|
|
return;
|
|
};
|
|
let specs: Vec<String> = match serde_json::from_str(&raw) {
|
|
Ok(v) => v,
|
|
Err(e) => {
|
|
tracing::warn!(path = PLUGINS_PATH, error = ?e, "claude-plugins spec parse failed; skipping");
|
|
return;
|
|
}
|
|
};
|
|
if specs.is_empty() {
|
|
return;
|
|
}
|
|
let boot_end = Instant::now() + PLUGIN_BOOT_BUDGET;
|
|
add_marketplaces(boot_end).await;
|
|
if auto_update_enabled().await {
|
|
update_marketplaces(boot_end).await;
|
|
} else {
|
|
tracing::debug!("claudePluginsAutoUpdate=false, skipping marketplace update");
|
|
}
|
|
for spec in specs {
|
|
match run_claude_plugin(&["install", &spec], boot_end).await {
|
|
Ok(out) if out.status.success() => {
|
|
tracing::info!(spec = %spec, "claude plugin install ok");
|
|
}
|
|
Ok(out) => {
|
|
let stderr = String::from_utf8_lossy(&out.stderr).into_owned();
|
|
tracing::warn!(
|
|
spec = %spec,
|
|
status = ?out.status,
|
|
stderr = %stderr,
|
|
"claude plugin install failed",
|
|
);
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(spec = %spec, error = ?e, "claude plugin install spawn failed or timed out");
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
#[tokio::test]
|
|
async fn run_bounded_times_out_a_hung_child() {
|
|
let mut cmd = Command::new("sleep");
|
|
cmd.arg("30");
|
|
let err = run_bounded(cmd, Duration::from_millis(100))
|
|
.await
|
|
.unwrap_err();
|
|
assert_eq!(err.kind(), io::ErrorKind::TimedOut);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn run_bounded_returns_output_of_a_prompt_child() {
|
|
let cmd = Command::new("true");
|
|
let out = run_bounded(cmd, Duration::from_secs(5)).await;
|
|
assert!(out.unwrap().status.success());
|
|
}
|
|
|
|
#[test]
|
|
fn serial_calls_never_exceed_the_boot_budget() {
|
|
let start = Instant::now();
|
|
let boot_end = start + PLUGIN_BOOT_BUDGET;
|
|
let mut now = start;
|
|
// Worst case: every call hangs until its own deadline.
|
|
for _ in 0..50 {
|
|
now += call_budget(now, boot_end);
|
|
}
|
|
assert_eq!(now - start, PLUGIN_BOOT_BUDGET);
|
|
assert_eq!(call_budget(now, boot_end), Duration::ZERO);
|
|
}
|
|
|
|
#[test]
|
|
fn a_call_gets_the_full_timeout_while_budget_remains() {
|
|
let start = Instant::now();
|
|
let boot_end = start + PLUGIN_BOOT_BUDGET;
|
|
assert_eq!(call_budget(start, boot_end), PLUGIN_CALL_TIMEOUT);
|
|
}
|
|
}
|