Watch
0
0
Fork
You've already forked hyperhive
0

hive-agent: cap the whole plugin install sequence, not just each call

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.
This commit is contained in:
atlas 2026-10-16 09:58:04 +02:00 • committed by mara
commit aa6ca8764a
2 changed files with 52 additions and 13 deletions

View file

@ -202,7 +202,12 @@ services.hyperhive.agent.claudePluginsAutoUpdate = false; # defaul
- **`claudePlugins`** — list of plugin specs passed to - **`claudePlugins`** — list of plugin specs passed to
`claude plugin install <spec>`. The harness installs each spec on `claude plugin install <spec>`. The harness installs each spec on
every boot, assuming `install` is idempotent; failures log a warning every boot, assuming `install` is idempotent; failures log a warning
but don't abort boot. Defaults to Anthropic's `skill-creator` (so every but don't abort boot. Boot waits for the
marketplace adds, the optional update and the installs (serial
`claude plugin` calls): each call is killed and logged if it has not
exited within 2 minutes, and the whole sequence is capped at 4 minutes,
after which the remaining calls fail at once with the same warning and the
web UI starts. Defaults to Anthropic's `skill-creator` (so every
agent can author, refine, and evaluate its own skills) plus agent can author, refine, and evaluate its own skills) plus
hyperhive's own `base` plugin — skills that apply to every agent hyperhive's own `base` plugin — skills that apply to every agent
regardless of role (currently just `state-hygiene`) — all without regardless of role (currently just `state-hygiene`) — all without

View file

@ -13,7 +13,7 @@
use std::io; use std::io;
use std::process::Output; use std::process::Output;
use std::time::Duration; use std::time::{Duration, Instant};
use tokio::process::Command; use tokio::process::Command;
@ -24,12 +24,25 @@ 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. /// 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); const PLUGIN_CALL_TIMEOUT: Duration = Duration::from_mins(2);
/// Run `claude plugin <args>` under [`PLUGIN_CALL_TIMEOUT`]. On timeout the /// Total budget for the whole serial sequence in [`install_configured`]. The
/// child is killed (`kill_on_drop`) and a `TimedOut` error is returned. /// harness awaits it before serving the web UI, so this is the longest a
async fn run_claude_plugin(args: &[&str]) -> io::Result<Output> { /// 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"); let mut cmd = Command::new("claude");
cmd.arg("plugin").args(args); cmd.arg("plugin").args(args);
run_bounded(cmd, PLUGIN_CALL_TIMEOUT).await run_bounded(cmd, call_budget(Instant::now(), boot_end)).await
} }
/// Run `cmd` to completion within `deadline`; the child is killed when the /// Run `cmd` to completion within `deadline`; the child is killed when the
@ -51,7 +64,7 @@ async fn run_bounded(mut cmd: Command, deadline: Duration) -> io::Result<Output>
/// an existing marketplace is treated as success (claude prints an /// an existing marketplace is treated as success (claude prints an
/// "already exists" message and exits non-zero on some versions). /// "already exists" message and exits non-zero on some versions).
/// Required before any `<plugin>@<marketplace>` install can resolve. /// Required before any `<plugin>@<marketplace>` install can resolve.
async fn add_marketplaces() { async fn add_marketplaces(boot_end: Instant) {
let Ok(raw) = tokio::fs::read_to_string(MARKETPLACES_PATH).await else { let Ok(raw) = tokio::fs::read_to_string(MARKETPLACES_PATH).await else {
return; return;
}; };
@ -63,7 +76,7 @@ async fn add_marketplaces() {
} }
}; };
for source in sources { for source in sources {
match run_claude_plugin(&["marketplace", "add", &source]).await { match run_claude_plugin(&["marketplace", "add", &source], boot_end).await {
Ok(out) if out.status.success() => { Ok(out) if out.status.success() => {
tracing::info!(source = %source, "claude plugin marketplace add ok"); tracing::info!(source = %source, "claude plugin marketplace add ok");
} }
@ -98,8 +111,8 @@ async fn auto_update_enabled() -> bool {
/// Update all configured plugin marketplaces. Non-fatal — logs a warning /// Update all configured plugin marketplaces. Non-fatal — logs a warning
/// on failure but does not abort the install sequence. /// on failure but does not abort the install sequence.
async fn update_marketplaces() { async fn update_marketplaces(boot_end: Instant) {
match run_claude_plugin(&["marketplace", "update"]).await { match run_claude_plugin(&["marketplace", "update"], boot_end).await {
Ok(out) if out.status.success() => { Ok(out) if out.status.success() => {
tracing::info!("claude plugin marketplace update ok"); tracing::info!("claude plugin marketplace update ok");
} }
@ -132,14 +145,15 @@ pub async fn install_configured() {
if specs.is_empty() { if specs.is_empty() {
return; return;
} }
add_marketplaces().await; let boot_end = Instant::now() + PLUGIN_BOOT_BUDGET;
add_marketplaces(boot_end).await;
if auto_update_enabled().await { if auto_update_enabled().await {
update_marketplaces().await; update_marketplaces(boot_end).await;
} else { } else {
tracing::debug!("claudePluginsAutoUpdate=false, skipping marketplace update"); tracing::debug!("claudePluginsAutoUpdate=false, skipping marketplace update");
} }
for spec in specs { for spec in specs {
match run_claude_plugin(&["install", &spec]).await { match run_claude_plugin(&["install", &spec], boot_end).await {
Ok(out) if out.status.success() => { Ok(out) if out.status.success() => {
tracing::info!(spec = %spec, "claude plugin install ok"); tracing::info!(spec = %spec, "claude plugin install ok");
} }
@ -179,4 +193,24 @@ mod tests {
let out = run_bounded(cmd, Duration::from_secs(5)).await; let out = run_bounded(cmd, Duration::from_secs(5)).await;
assert!(out.unwrap().status.success()); 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);
}
} }