Watch
0
0
Fork
You've already forked hyperhive
0

drop the applied repo: the hive keeps no copy of an agent's config, only the deploy markers. Refs #5913

This commit is contained in:
damocles 2026-11-01 17:19:41 +01:00
commit 862495530c
4 changed files with 190 additions and 1634 deletions

View file

@ -1,92 +1,8 @@
//! Git shellout helpers for the per-agent applied repo: run
//! `git` with the hive-c0re identity, resolve/plant refs and tags, and
//! fetch proposal commits into the applied repo.
//! Spawning `git` from hive-c0re: the binary lookup and the credential
//! handoff.
use std::path::Path;
use std::process::{Output, Stdio};
use std::time::Duration;
use anyhow::{Context, Result, bail};
use tokio::io::{AsyncRead, AsyncReadExt};
use tokio::process::Command;
/// Deadline for a child that talks to a remote (forge fetch/push): a healthy
/// one takes seconds, so ten minutes only ever fires on a hang.
pub const FETCH_DEADLINE: Duration = Duration::from_mins(10);
/// Deadline for a git child that touches only the local repo.
pub const GIT_LOCAL_DEADLINE: Duration = Duration::from_mins(2);
/// Run `cmd` to completion and capture its output, failing once `deadline`
/// passes. `what` names the command in the error. The child leads its own
/// process group and the whole group is `SIGKILL`ed on timeout, so a
/// grandchild (a `git` or credential helper it spawned) dies with it instead
/// of outliving the lock.
pub async fn run_with_deadline(
cmd: &mut Command,
deadline: Duration,
what: &str,
) -> Result<Output> {
// `kill_on_drop` covers the caller dropping this future; `process_group(0)`
// makes the child's pid the group id the timeout path kills.
let mut child = cmd
.stdin(Stdio::null())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.kill_on_drop(true)
.process_group(0)
.spawn()
.with_context(|| what.to_owned())?;
// Read before waiting: a reaped child has no id.
let pgid = child.id().and_then(|pid| i32::try_from(pid).ok());
let (stdout, stderr) = (child.stdout.take(), child.stderr.take());
let io = async { tokio::join!(child.wait(), drain(stdout), drain(stderr)) };
if let Ok((status, stdout, stderr)) = tokio::time::timeout(deadline, io).await {
return Ok(Output {
status: status.with_context(|| what.to_owned())?,
stdout: stdout.with_context(|| what.to_owned())?,
stderr: stderr.with_context(|| what.to_owned())?,
});
}
if let Some(pgid) = pgid {
// SAFETY: plain `kill(2)`; `-pgid` targets the child's own process
// group, which `process_group(0)` made it lead.
unsafe {
libc::kill(-pgid, libc::SIGKILL);
}
}
child.wait().await.with_context(|| what.to_owned())?;
bail!("{what} timed out after {}s", deadline.as_secs_f64())
}
/// Everything `pipe` yields until EOF.
async fn drain<R: AsyncRead + Unpin>(pipe: Option<R>) -> std::io::Result<Vec<u8>> {
let mut buf = Vec::new();
if let Some(mut p) = pipe {
p.read_to_end(&mut buf).await?;
}
Ok(buf)
}
const GIT_NAME: &str = "c0re";
const GIT_EMAIL: &str = "c0re@hyperhive.local";
pub(super) async fn git_commit(dir: &Path, message: &str) -> Result<()> {
git(
dir,
&[
"-c",
&format!("user.name={GIT_NAME}"),
"-c",
&format!("user.email={GIT_EMAIL}"),
"commit",
"-m",
message,
],
)
.await
}
/// Spawn `git` honoring the `HYPERHIVE_GIT` env var (absolute path baked in
/// by the NixOS module), falling back to bare `git` (PATH lookup) otherwise.
///
@ -121,125 +37,3 @@ pub fn git_command_authed(auth_header: &str) -> Command {
.env("GIT_CONFIG_VALUE_0", auth_header);
cmd
}
/// Run `cmd` as `git <args>` in `dir` within `deadline`, erroring on a
/// non-zero exit.
async fn run(mut cmd: Command, dir: &Path, args: &[&str], deadline: Duration) -> Result<()> {
let what = format!("git {} in {}", args.join(" "), dir.display());
let out = run_with_deadline(cmd.current_dir(dir).args(args), deadline, &what).await?;
if !out.status.success() {
bail!(
"git {} failed ({}): {}",
args.join(" "),
out.status,
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(())
}
pub async fn git(dir: &Path, args: &[&str]) -> Result<()> {
run(git_command(), dir, args, GIT_LOCAL_DEADLINE).await
}
/// [`git`] against an authenticated remote — for the paths that talk to the
/// forge. The credential rides the environment, so `args` (and therefore the
/// error above) stay free of it.
pub async fn git_authed(dir: &Path, args: &[&str], auth_header: &str) -> Result<()> {
run(git_command_authed(auth_header), dir, args, FETCH_DEADLINE).await
}
/// Resolve `refname` (a tag, branch, or sha) in `dir` to its full sha.
pub async fn git_rev_parse(dir: &Path, refname: &str) -> Result<String> {
let out = run_with_deadline(
git_command().current_dir(dir).args(["rev-parse", refname]),
GIT_LOCAL_DEADLINE,
&format!("git rev-parse {refname} in {}", dir.display()),
)
.await?;
if !out.status.success() {
bail!(
"git rev-parse {refname} failed ({}): {}",
out.status,
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
}
/// Plant a lightweight tag at `target`. Errors if the tag already
/// exists — we want loud failures on id reuse, not silent
/// overwrites.
pub async fn git_tag(dir: &Path, name: &str, target: &str) -> Result<()> {
git(dir, &["tag", name, target]).await
}
/// Replace working tree + index with the tree at `target` without
/// moving HEAD. `applied/main` stays pointing at the last known-good
/// `deployed/*` while we let `nixos-container update` evaluate the
/// candidate. On build failure callers reset back to HEAD; on
/// success they fast-forward main to `target`.
pub async fn git_read_tree_reset(dir: &Path, target: &str) -> Result<()> {
git(dir, &["read-tree", "--reset", "-u", target]).await
}
/// Hard-set a ref to `target` with `update-ref`, so it works regardless of
/// where HEAD currently sits. Test fixtures only: production moves refs with
/// [`git_update_ref_cas`].
#[cfg(test)]
pub async fn git_update_ref(dir: &Path, refname: &str, target: &str) -> Result<()> {
git(dir, &["update-ref", refname, target]).await
}
/// Compare-and-swap a ref: move `refname` to `new` only if it currently points
/// at `old`. `git update-ref <ref> <new> <old>` refuses — leaving the ref
/// untouched — when the current value differs. That refusal is the whole
/// difference between "advance this branch" and "overwrite whatever is there".
///
/// Prefer this over a plain `update-ref` whenever the caller already knows the
/// value it believes the ref holds: a plain `update-ref` that raced another
/// writer discards the other writer's commits without a word.
///
/// # Errors
///
/// Returns an error if the ref does not currently point at `old` (the CAS lost)
/// or if the `git` invocation itself fails.
pub async fn git_update_ref_cas(dir: &Path, refname: &str, new: &str, old: &str) -> Result<()> {
git(dir, &["update-ref", refname, new, old]).await
}
/// True when `ancestor` is reachable from `descendant` — i.e. moving a branch
/// from `ancestor` to `descendant` is a genuine fast-forward that discards
/// nothing.
///
/// Not the same question as "did this branch land upstream": a squash-merge
/// rewrites the commit, so `--is-ancestor` correctly answers `false` for a
/// branch whose *contents* were merged. This helper compares two commits with
/// real shared ancestry inside one repo, which is precisely what it decides.
///
/// # Errors
///
/// Returns an error if `git` fails to run or either revision can't be resolved.
/// A clean "no, not an ancestor" is `Ok(false)`, not an error.
pub async fn git_is_ancestor(dir: &Path, ancestor: &str, descendant: &str) -> Result<bool> {
let out = git_command()
.current_dir(dir)
.args(["merge-base", "--is-ancestor", ancestor, descendant])
.output()
.await
.with_context(|| {
format!(
"git merge-base --is-ancestor {ancestor} {descendant} in {}",
dir.display()
)
})?;
match out.status.code() {
Some(0) => Ok(true),
Some(1) => Ok(false),
_ => bail!(
"git merge-base --is-ancestor {ancestor} {descendant} failed ({}): {}",
out.status,
String::from_utf8_lossy(&out.stderr).trim()
),
}
}

View file

@ -7,19 +7,9 @@ mod setup;
#[cfg(test)]
mod tests;
#[cfg(test)]
pub use git::git_update_ref;
#[cfg(test)]
pub use git::run_with_deadline;
pub use git::{
git, git_authed, git_command, git_command_authed, git_is_ancestor, git_read_tree_reset,
git_rev_parse, git_tag, git_update_ref_cas,
};
pub use git::{git_command, git_command_authed};
pub use host_config::write_dropins;
pub use setup::{
ensure_agent_state_subvolume, ensure_claude_dir, ensure_state_dir, initial_flake_nix,
setup_applied,
};
pub use setup::{ensure_agent_state_subvolume, ensure_claude_dir, ensure_state_dir};
use anyhow::{Context, Result, bail};
use tokio::process::Command;
@ -205,8 +195,8 @@ async fn port_collision(self_name: &str) -> Result<Option<String>> {
}
/// First-spawn pre-create provisioning — the job queue's `Provision`
/// node. Fail fast on a port collision, set up the applied
/// repo + state subvolume + claude/notes dirs, then register the new
/// node. Fail fast on a port collision, set up the state
/// subvolume + claude/notes dirs, then register the new
/// agent in the host-side files that follow the agent set
/// ([`crate::agent_set::refresh`]). Does NOT
/// create the container — that's `create_only` / the `Create` node.
@ -218,12 +208,9 @@ pub async fn provision_container(name: &str, paths: &AgentPaths) -> Result<()> {
agent_web_port(name)
);
}
// Read before anything is seeded: an archetype agent gets no config
// repo, and the build takes it from the archetype.
// Read before the build: an archetype agent gets no config repo, and
// the build takes it from the archetype.
crate::archetypes::refresh(name).await?;
if crate::archetypes::of(name)?.is_none() {
setup_applied(&paths.applied, name).await?;
}
ensure_agent_state_subvolume(name).await?;
ensure_claude_dir(&paths.claude)?;
ensure_state_dir(&paths.notes)?;
@ -239,19 +226,7 @@ pub async fn create_only(name: &str, toplevel: &str) -> Result<()> {
}
/// Rebuild-path preamble, run by the job queue's `Prebuild` node: fail fast on
/// a port collision, then make sure the applied repo + state dirs exist.
/// Container untouched.
///
/// `applied` is normally already the right shape here (this runs against a
/// previously-provisioned agent), but a `destroy --purge` removes it
/// (`job_queue::exec::run_purge_state`) — a later revive of the *same*
/// agent name is still a rebuild, not a first-spawn, so it lands here, not
/// in `provision_container`. Recover from `agent-configs/<name>` on the
/// forge when that's the case (forge `main` is the last operator-merged
/// config — see `forge::fetch_config_main_into_applied`'s own doc comment)
/// rather than bailing outright. Deliberately **no template fallback**: a
/// template would silently revive the *wrong* config rather than recovering
/// the right one, which is worse than the clear bail this replaces.
/// a port collision, then make sure the state dirs exist. Container untouched.
pub async fn prepare_rebuild_dirs(name: &str, paths: &AgentPaths) -> Result<()> {
validate(name)?;
if let Some(other) = port_collision(name).await? {
@ -260,20 +235,6 @@ pub async fn prepare_rebuild_dirs(name: &str, paths: &AgentPaths) -> Result<()>
agent_web_port(name)
);
}
// An archetype agent has no applied repo to check or recover.
if crate::archetypes::of(name)?.is_none() {
if !paths.applied.join(".git").exists()
&& !crate::forge::fetch_config_main_into_applied(name).await
{
bail!(
"applied repo at {} is missing its .git directory and could not be reseeded from \
the forge (agent-configs/{name} unreachable, has no `main` — never merged or \
never seeded — or absent); destroy --purge and re-spawn this agent.",
paths.applied.display()
);
}
setup_applied(&paths.applied, name).await?;
}
ensure_agent_state_subvolume(name).await?;
ensure_claude_dir(&paths.claude)?;
ensure_state_dir(&paths.notes)?;

View file

@ -1,90 +1,9 @@
//! First-spawn provisioning: seed the hive-c0re-owned applied repo, and
//! ensure the per-agent state / claude-credentials dirs (btrfs subvolume when
//! available) exist.
//!
//! Seeding is the only write the repo gets from here. Later config changes
//! arrive as forge PRs authored in a clone - nothing edits a working tree in
//! place.
//! First-spawn provisioning: ensure the per-agent state / claude-credentials
//! dirs (btrfs subvolume when available) exist.
use std::path::Path;
use anyhow::{Context, Result, bail};
use super::git::{git, git_commit, git_rev_parse, git_tag};
/// Seed an agent's applied repo on first spawn, preferring the config the
/// forge already has. `agent-configs/<name>` is authored by whoever created
/// the agent (at swarm level the controller, which writes the config before
/// any hive is told to deploy it), so this takes the forge's `main` when
/// there is one and writes a template only when there is not. Two authors for
/// one file is the failure mode being avoided: whichever wrote second would
/// win, and neither knows about the other.
///
/// The template ([`seed_template`]) is the fallback when there is genuinely
/// nothing to take yet: `swarm-controller` creates `agent-configs/<name>`
/// before telling a hive to deploy, but hasn't necessarily pushed `main` to
/// it, or the forge is unreachable, or this hive has no forge read token.
///
/// Seeding is the only write hive-c0re makes here. Later config changes
/// arrive as forge PRs and are fetched from the forge's `main` (see
/// `actions::advance_applied_to_rev`). `flake.nix` is tracked in the seed
/// and rides along on every fetch.
///
/// A repo that is already there is only checked for its shape (a rebuild
/// finds it already seeded): a missing `deployed/0` tag means it predates the
/// meta-flake layout and is an error.
pub async fn setup_applied(applied_dir: &Path, name: &str) -> Result<()> {
std::fs::create_dir_all(applied_dir)
.with_context(|| format!("create {}", applied_dir.display()))?;
if !applied_dir.join(".git").exists() {
if crate::forge::fetch_config_main_into_applied(name).await {
return Ok(());
}
seed_template(applied_dir, name).await?;
git_tag(applied_dir, "deployed/0", "refs/heads/main").await?;
} else if git_rev_parse(applied_dir, "refs/tags/deployed/0")
.await
.is_err()
{
// Pre-overhaul applied repo - no deployed/* tag scheme,
// flake.nix may be untracked, agent.nix possibly authored by
// hive-c0re directly. The startup auto-migration fixes this
// in place; if it didn't run (or got skipped), surface a
// clear error.
bail!(
"applied repo at {} predates the meta-flake layout. \
Restart hive-c0re to let the auto-migration run, or \
destroy --purge {name} and re-spawn.",
applied_dir.display()
);
}
Ok(())
}
/// Author the initial config in place: `agent.nix` (the agent's own module)
/// and `flake.nix` (the boilerplate that lets the meta flake import this repo
/// as an input: meta locks at a specific sha and reads
/// `nixosModules.default`, so `flake.nix` must be in the commit). `flake.nix`
/// isn't meant to be edited, but it's tracked so it can be read.
///
/// The fallback half of [`setup_applied`]: reached only when the forge has
/// no config for this agent to take.
async fn seed_template(dir: &Path, name: &str) -> Result<()> {
let agent_path = dir.join("agent.nix");
if !agent_path.exists() {
std::fs::write(&agent_path, initial_agent_nix(name))
.with_context(|| format!("write {}", agent_path.display()))?;
}
let flake_path = dir.join("flake.nix");
if !flake_path.exists() {
std::fs::write(&flake_path, initial_flake_nix())
.with_context(|| format!("write {}", flake_path.display()))?;
}
git(dir, &["init", "--initial-branch=main"]).await?;
git(dir, &["add", "agent.nix", "flake.nix"]).await?;
git_commit(dir, "hive-c0re init").await
}
use anyhow::{Context, Result};
/// Create the per-agent Claude credentials dir if missing. Mode 0755 — hive-core
/// needs read+execute to list the directory so `claude_has_session` can detect a
@ -170,27 +89,3 @@ pub async fn ensure_agent_state_subvolume(name: &str) -> Result<()> {
.await
.with_context(|| format!("ensure btrfs subvolume for agent {name}"))
}
fn initial_agent_nix(name: &str) -> String {
format!(
"{{ config, pkgs, lib, ... }}:\n{{\n # Per-agent overrides for {name}. This is a regular NixOS module\n # — add packages, services, modules, imports as needed.\n #\n # imports = [ ./extra-module.nix ];\n # environment.systemPackages = with pkgs; [ ];\n}}\n",
)
}
/// Module-only flake exposed by every agent's repo. Consumed by the
/// swarm's `internal/meta` flake as a flake input. The wrapper is
/// intentionally permissive:
///
/// - Manager edits `inputs.* = …` to add other flakes (e.g. an MCP
/// server's own flake) — the lock for those lands in the agent's
/// own `flake.lock` and rolls up into meta's lock transitively.
/// - The outputs block forwards every input (minus `self`) into
/// `agent.nix` as the `flakeInputs` module argument, so the
/// manager just references `flakeInputs.<name>.packages.${pkgs.system}.default`
/// without further plumbing.
///
/// Identity injection (`HIVE_PORT` / `HIVE_LABEL` / dashboard port /
/// git committer) still lives in the meta flake's wrapper.
pub fn initial_flake_nix() -> &'static str {
"{\n description = \"hyperhive agent\";\n inputs = { };\n outputs =\n { self, ... }@inputs:\n {\n nixosModules.default = {\n imports = [ ./agent.nix ];\n _module.args.flakeInputs = builtins.removeAttrs inputs [ \"self\" ];\n };\n };\n}\n"
}

View file

@ -3,48 +3,6 @@
use super::*;
/// Regression test: `setup_applied` must seed both agent.nix and flake.nix
/// in the initial commit, and tag it `deployed/0`. Before commit 5b5a93e
/// flake.nix was missing from the scaffold, requiring manual creation (seen
/// with the damocles agent).
///
/// Exercises the template arm: there is no forge to take a config from in a
/// test (no reader token), so `setup_applied` falls through to
/// `seed_template` — which is the arm this asserts about.
#[tokio::test]
async fn setup_applied_seeds_flake_nix() {
let dir = tempfile::tempdir().expect("tempdir");
let applied = dir.path().join("applied");
setup_applied(&applied, "test-agent")
.await
.expect("setup_applied");
git_rev_parse(&applied, "refs/tags/deployed/0")
.await
.expect("the seed commit is tagged deployed/0");
// Both files must exist on disk.
assert!(applied.join("agent.nix").exists(), "agent.nix missing");
assert!(applied.join("flake.nix").exists(), "flake.nix missing");
// flake.nix must export nixosModules.default (the meta-flake contract).
let flake = std::fs::read_to_string(applied.join("flake.nix")).unwrap();
assert!(
flake.contains("nixosModules.default"),
"flake.nix does not export nixosModules.default"
);
// Both files must be tracked in the initial git commit.
let out = git_command()
.current_dir(&applied)
.args(["show", "--name-only", "--format=", "HEAD"])
.output()
.await
.expect("git show");
let tracked = String::from_utf8_lossy(&out.stdout);
assert!(tracked.contains("agent.nix"), "agent.nix not committed");
assert!(tracked.contains("flake.nix"), "flake.nix not committed");
}
#[test]
fn bridge_gateway_ip_extracts_verbatim_address() {
// HIVE_NETWORK_SUBNET carries the bridge IP verbatim, not the
@ -132,122 +90,6 @@ fn network_isolation_errors_name_the_offending_variable() {
);
}
/// `setup_applied` is idempotent: calling it on an already seeded repo is a
/// no-op (the shape check passes and nothing is written).
#[tokio::test]
async fn setup_applied_idempotent() {
let dir = tempfile::tempdir().expect("tempdir");
let applied = dir.path().join("applied");
setup_applied(&applied, "test-agent")
.await
.expect("first call");
// Second call must not error even though .git already exists.
setup_applied(&applied, "test-agent")
.await
.expect("second call");
// Still one commit.
let out = git_command()
.current_dir(&applied)
.args(["rev-list", "--count", "HEAD"])
.output()
.await
.expect("git rev-list");
let count = String::from_utf8_lossy(&out.stdout).trim().to_owned();
assert_eq!(
count, "1",
"expected exactly one commit after idempotent call"
);
}
/// Build a two-commit repo and return `(dir, repo_path, first, second)`.
async fn two_commit_repo() -> (tempfile::TempDir, std::path::PathBuf, String, String) {
let dir = tempfile::tempdir().expect("tempdir");
let repo = dir.path().join("applied");
setup_applied(&repo, "test-agent")
.await
.expect("setup_applied");
let first = git_rev_parse(&repo, "HEAD").await.expect("rev-parse first");
std::fs::write(repo.join("second.txt"), "second").expect("write second.txt");
git(&repo, &["add", "second.txt"]).await.expect("git add");
git(
&repo,
&[
"-c",
"user.name=test",
"-c",
"user.email=test@example.com",
"commit",
"-m",
"second",
],
)
.await
.expect("git commit");
let second = git_rev_parse(&repo, "HEAD")
.await
.expect("rev-parse second");
assert_ne!(first, second, "second commit did not advance HEAD");
(dir, repo, first, second)
}
/// `git_is_ancestor` answers reachability in both directions. This is the
/// check that decides whether moving a branch is a real fast-forward or a
/// rewind that discards commits.
#[tokio::test]
async fn is_ancestor_distinguishes_direction() {
let (_dir, repo, first, second) = two_commit_repo().await;
assert!(
git_is_ancestor(&repo, &first, &second)
.await
.expect("ancestor check forward"),
"first must be an ancestor of second"
);
assert!(
!git_is_ancestor(&repo, &second, &first)
.await
.expect("ancestor check reverse"),
"second must NOT be an ancestor of first"
);
}
/// Regression test for the deploy path that ate a committed agent config:
/// `git_update_ref_cas` must refuse to move a ref whose current value is not
/// the expected one, and must leave the ref untouched when it refuses. A bare
/// `update-ref` accepts that move and silently drops everything in between.
#[tokio::test]
async fn update_ref_cas_refuses_stale_expectation() {
let (_dir, repo, first, second) = two_commit_repo().await;
// Park a ref at `first`, then CAS it forward with the correct old value.
git_update_ref(&repo, "refs/heads/cas", &first)
.await
.expect("plant ref");
git_update_ref_cas(&repo, "refs/heads/cas", &second, &first)
.await
.expect("CAS with the correct old value must succeed");
assert_eq!(
git_rev_parse(&repo, "refs/heads/cas").await.unwrap(),
second
);
// Now retry with the SAME (now stale) expectation, as a racing deploy
// holding a pre-merge sha would: it must fail rather than rewind.
assert!(
git_update_ref_cas(&repo, "refs/heads/cas", &first, &first)
.await
.is_err(),
"CAS accepted a stale old value"
);
assert_eq!(
git_rev_parse(&repo, "refs/heads/cas").await.unwrap(),
second,
"ref moved even though the CAS failed"
);
}
/// `systemctl is-active` prints the state with a trailing newline, which is
/// the whole reason this parser trims rather than comparing raw.
#[test]
@ -416,60 +258,6 @@ async fn a_systemctl_answer_inside_the_bound_passes_through() {
assert_eq!(got, Some(7));
}
/// A child that outlives the deadline yields an error naming the command and
/// the deadline, and the child and its grandchild are both killed rather than
/// orphaned.
#[tokio::test]
async fn run_with_deadline_times_out_and_kills_the_process_group() {
let dir = tempfile::tempdir().expect("tempdir");
let (shpid, sleeppid) = (dir.path().join("sh.pid"), dir.path().join("sleep.pid"));
let mut cmd = tokio::process::Command::new("sh");
cmd.arg("-c").arg(format!(
"echo $$ > {}; sleep 300 & echo $! > {}; wait",
shpid.display(),
sleeppid.display()
));
let err = run_with_deadline(
&mut cmd,
std::time::Duration::from_millis(500),
"fake fetch",
)
.await
.expect_err("a sleeping child must hit the deadline");
let msg = format!("{err:#}");
assert!(msg.contains("fake fetch timed out after 0.5s"), "{msg}");
for file in [&shpid, &sleeppid] {
// The pid file proves the process existed before the kill.
let pid = std::fs::read_to_string(file)
.expect("process wrote its pid before the deadline")
.trim()
.to_owned();
let proc = std::path::PathBuf::from("/proc").join(&pid);
let mut gone = false;
for _ in 0..100 {
if !proc.exists() {
gone = true;
break;
}
tokio::time::sleep(std::time::Duration::from_millis(50)).await;
}
assert!(gone, "pid {pid} ({}) still running", file.display());
}
}
/// Presence control for the timeout test: a child that finishes in time
/// returns its output.
#[tokio::test]
async fn run_with_deadline_returns_output_of_a_fast_child() {
let mut cmd = tokio::process::Command::new("echo");
cmd.arg("hi");
let out = run_with_deadline(&mut cmd, std::time::Duration::from_secs(30), "echo")
.await
.expect("echo finishes");
assert_eq!(String::from_utf8_lossy(&out.stdout).trim(), "hi");
}
/// The per-host values reach the container as facts: the dashboard port and
/// pinned claude from the hive's config, the OTEL labels and the queue
/// address from the daemon's environment.