From 4555ab4f10abf94506a176bfa0a648154791ce81 Mon Sep 17 00:00:00 2001 From: damocles Date: Fri, 11 Sep 2026 18:34:22 +0200 Subject: [PATCH 1/5] hive-bash-mcp: sweep harness/bash-tasks/ of files older than 30d at startup fixes #2855 --- Cargo.lock | 1 + hive-bash-mcp/Cargo.toml | 3 + hive-bash-mcp/src/runner.rs | 177 ++++++++++++++++++++++++++++++++++++ 3 files changed, 181 insertions(+) diff --git a/Cargo.lock b/Cargo.lock index bda9c2d3..70987333 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1723,6 +1723,7 @@ dependencies = [ "schemars", "serde", "serde_json", + "tempfile", "tokio", "tracing", "tracing-subscriber", diff --git a/hive-bash-mcp/Cargo.toml b/hive-bash-mcp/Cargo.toml index 0c4f36eb..696ee5ee 100644 --- a/hive-bash-mcp/Cargo.toml +++ b/hive-bash-mcp/Cargo.toml @@ -26,6 +26,9 @@ tokio.workspace = true tracing.workspace = true tracing-subscriber.workspace = true +[dev-dependencies] +tempfile = "3" + # `hive-bash-daemon` — long-running per-agent bash task runner. Spawns # `sh -c` subprocesses, monitors completion, writes task state files # under harness/bash-tasks/, and serves the MCP tools (`run`/`status`/ diff --git a/hive-bash-mcp/src/runner.rs b/hive-bash-mcp/src/runner.rs index 8e7d609f..a13d141d 100644 --- a/hive-bash-mcp/src/runner.rs +++ b/hive-bash-mcp/src/runner.rs @@ -18,6 +18,12 @@ //! (the process died with the previous daemon). The todo is still updated to //! its interrupted-done summary so the agent is not silently blocked. //! +//! At daemon startup, before that interrupted-marking pass, any file under +//! `tasks_dir()` older than 30 days is deleted outright — see +//! [`sweep_old_tasks`]. Nothing swept this directory before, and it had +//! grown without bound as a result, repeatedly holding stale credentials +//! in captured output. +//! //! The runner kills the child process on timeout — `tokio::process::Child::drop()` //! does not kill children, so we explicitly call `child.kill().await`. @@ -436,6 +442,10 @@ async fn run_loop(socket: PathBuf) { if let Err(e) = std::fs::create_dir_all(paths::tasks_dir()) { tracing::warn!(error = ?e, "bash_runner: create tasks dir failed"); } + let removed = sweep_old_tasks(&paths::tasks_dir(), SystemTime::now() - RETENTION); + if removed > 0 { + tracing::info!(removed, "bash_runner: swept old task files at startup"); + } mark_interrupted(&socket).await; let claimed: Arc>> = Arc::new(Mutex::new(HashSet::new())); @@ -446,6 +456,58 @@ async fn run_loop(socket: PathBuf) { } } +/// How long a task's files are kept before [`sweep_old_tasks`] removes +/// them. `harness/bash-tasks/` grew without bound (and had repeatedly +/// held stale credentials in captured output) because nothing ever swept +/// it. Mara: "build automatic cleanup at daemon start to clear older +/// than 30d" — a flat age cutoff, no count-based retention knob. +const RETENTION: Duration = Duration::from_hours(720); + +/// Delete every regular file directly under `dir` whose mtime is older +/// than `cutoff`. Runs once, at daemon startup (`run_loop`), before +/// [`mark_interrupted`] — an ancient still-`running` task is swept away +/// outright rather than given a fresh "interrupted" todo nobody's going +/// to act on 30 days later. +/// +/// Whole-directory, extension-agnostic rather than `.json`-id-driven like +/// [`mark_interrupted`]/[`poll_once`]: a task's `.json`/`.out`/`.err` +/// triple is written together at completion (the same instant, well +/// inside the 30-day granularity this cares about), so sweeping +/// file-by-file on its own mtime can't split a still-referenced group — +/// and this shape also reaps orphaned `.out`/`.err` (a crash between +/// writing output and the final `.json` write) and stray `.json.tmp` +/// scratch files (`write_task`'s tmp+rename can leave one behind on a +/// crash mid-rename) that an id-driven walk would miss entirely. +/// Directories are skipped (`tasks_dir()` holds none today, but a future +/// entry shouldn't be treated as sweepable file content). Best-effort: a +/// per-entry `remove_file` failure is logged and the pass continues +/// rather than aborting on the first one. Returns the count actually +/// removed. +fn sweep_old_tasks(dir: &Path, cutoff: SystemTime) -> usize { + let Ok(rd) = std::fs::read_dir(dir) else { + return 0; + }; + let mut removed = 0; + for entry in rd.flatten() { + let Ok(meta) = entry.metadata() else { continue }; + if !meta.is_file() { + continue; + } + let Ok(mtime) = meta.modified() else { continue }; + if mtime >= cutoff { + continue; + } + let path = entry.path(); + match std::fs::remove_file(&path) { + Ok(()) => removed += 1, + Err(e) => { + tracing::warn!(path = %path.display(), error = ?e, "bash_runner: sweep remove failed"); + } + } + } + removed +} + /// On boot, flip any `running` tasks to `interrupted` and fire a wake. async fn mark_interrupted(socket: &Path) { let Ok(rd) = std::fs::read_dir(paths::tasks_dir()) else { @@ -967,4 +1029,119 @@ mod tests { assert!(!body.contains(".out"), "no stdout ⇒ no stdout pointer"); }); } + + // sweep_old_tasks: a flat, whole-directory age sweep. + // `cutoff` is passed in explicitly (mirroring `hive-agent`'s + // `has_fresh_credentials(dir, since, ..)` pattern) so these don't need + // to fake a real 30-day-old file — `set_modified` moves a fresh + // tempfile's mtime directly, no sleeping required. + + #[test] + fn sweep_removes_only_files_older_than_cutoff() { + use super::sweep_old_tasks; + use std::time::{Duration, SystemTime}; + let dir = tempfile::tempdir().unwrap(); + let old = dir.path().join("old.json"); + let fresh = dir.path().join("fresh.json"); + std::fs::write(&old, b"{}").unwrap(); + std::fs::write(&fresh, b"{}").unwrap(); + let now = SystemTime::now(); + std::fs::File::open(&old) + .unwrap() + .set_modified(now - Duration::from_hours(744)) // 31 days + .unwrap(); + std::fs::File::open(&fresh) + .unwrap() + .set_modified(now) + .unwrap(); + + let removed = sweep_old_tasks(dir.path(), now - Duration::from_hours(720)); // 30 days + + assert_eq!(removed, 1); + assert!(!old.exists(), "the 31-day-old file must be gone"); + assert!(fresh.exists(), "today's file must survive"); + } + + #[test] + fn sweep_keeps_a_file_exactly_at_the_cutoff() { + // Boundary: `mtime >= cutoff` is kept, matching `sweep_old_tasks`'s + // doc — a task's mtime is never used to justify deleting it on the + // very instant it crosses the line, only once it's strictly older. + use super::sweep_old_tasks; + use std::time::SystemTime; + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("boundary.json"); + std::fs::write(&path, b"{}").unwrap(); + let cutoff = SystemTime::now(); + std::fs::File::open(&path) + .unwrap() + .set_modified(cutoff) + .unwrap(); + + let removed = sweep_old_tasks(dir.path(), cutoff); + + assert_eq!(removed, 0); + assert!(path.exists()); + } + + #[test] + fn sweep_is_extension_agnostic_and_reaps_orphans_and_tmp_files() { + // Whole-directory by design (see the doc comment): an orphaned + // `.out` with no `.json` (crash mid-task) and a stray `.json.tmp` + // (crash mid-rename in `write_task`) are exactly the garbage an + // id-driven walk like `mark_interrupted`'s would miss. + use super::sweep_old_tasks; + use std::time::{Duration, SystemTime}; + let dir = tempfile::tempdir().unwrap(); + let orphan_out = dir.path().join("orphan.out"); + let stray_tmp = dir.path().join("abc123.json.tmp"); + std::fs::write(&orphan_out, b"stdout").unwrap(); + std::fs::write(&stray_tmp, b"{}").unwrap(); + let past = SystemTime::now() - Duration::from_hours(1440); // 60 days + std::fs::File::open(&orphan_out) + .unwrap() + .set_modified(past) + .unwrap(); + std::fs::File::open(&stray_tmp) + .unwrap() + .set_modified(past) + .unwrap(); + + let removed = sweep_old_tasks(dir.path(), SystemTime::now()); + + assert_eq!(removed, 2); + assert!(!orphan_out.exists()); + assert!(!stray_tmp.exists()); + } + + #[test] + fn sweep_skips_subdirectories() { + // Defensive: `tasks_dir()` holds no directories today, but a + // future entry must not be treated as sweepable file content + // (`remove_file` on a directory just errors and is swallowed — + // this pins the *intent*, not merely today's accidental safety). + use super::sweep_old_tasks; + use std::time::{Duration, SystemTime}; + let dir = tempfile::tempdir().unwrap(); + let sub = dir.path().join("a-subdir"); + std::fs::create_dir(&sub).unwrap(); + + let removed = sweep_old_tasks(dir.path(), SystemTime::now() + Duration::from_secs(1)); + + assert_eq!(removed, 0); + assert!(sub.exists()); + } + + #[test] + fn sweep_missing_dir_is_a_noop() { + // Defensive, same shape as `has_fresh_credentials_missing_dir_is_false` + // in `hive-agent`: a not-yet-created tasks dir must not panic. + use super::sweep_old_tasks; + use std::time::SystemTime; + let missing = tempfile::tempdir() + .unwrap() + .path() + .join("never-created-subdir"); + assert_eq!(sweep_old_tasks(&missing, SystemTime::now()), 0); + } } From 54d28944be6a4be1b2247b351ae0c6bbeb85d3c0 Mon Sep 17 00:00:00 2001 From: damocles Date: Fri, 11 Sep 2026 18:59:43 +0200 Subject: [PATCH 2/5] hive-bash-mcp: fix module doc's intra-doc link to a private item addresses CI failure on #4249 --- hive-bash-mcp/src/runner.rs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/hive-bash-mcp/src/runner.rs b/hive-bash-mcp/src/runner.rs index a13d141d..5a0e6e06 100644 --- a/hive-bash-mcp/src/runner.rs +++ b/hive-bash-mcp/src/runner.rs @@ -20,9 +20,10 @@ //! //! At daemon startup, before that interrupted-marking pass, any file under //! `tasks_dir()` older than 30 days is deleted outright — see -//! [`sweep_old_tasks`]. Nothing swept this directory before, and it had -//! grown without bound as a result, repeatedly holding stale credentials -//! in captured output. +//! `sweep_old_tasks` (a plain backtick, not a `[...]` link: it's private, +//! and this module doc is public-facing). Nothing swept this directory +//! before, and it had grown without bound, repeatedly holding stale +//! credentials in captured output. //! //! The runner kills the child process on timeout — `tokio::process::Child::drop()` //! does not kill children, so we explicitly call `child.kill().await`. From ff5106a7b447f018eae757bbb556a2ae29260a9b Mon Sep 17 00:00:00 2001 From: damocles Date: Fri, 11 Sep 2026 19:09:40 +0200 Subject: [PATCH 3/5] hive-bash-mcp: stop naming a private fn in the module's public doc per mara review on #4249 --- hive-bash-mcp/src/runner.rs | 8 +++----- 1 file changed, 3 insertions(+), 5 deletions(-) diff --git a/hive-bash-mcp/src/runner.rs b/hive-bash-mcp/src/runner.rs index 5a0e6e06..00879352 100644 --- a/hive-bash-mcp/src/runner.rs +++ b/hive-bash-mcp/src/runner.rs @@ -19,11 +19,9 @@ //! its interrupted-done summary so the agent is not silently blocked. //! //! At daemon startup, before that interrupted-marking pass, any file under -//! `tasks_dir()` older than 30 days is deleted outright — see -//! `sweep_old_tasks` (a plain backtick, not a `[...]` link: it's private, -//! and this module doc is public-facing). Nothing swept this directory -//! before, and it had grown without bound, repeatedly holding stale -//! credentials in captured output. +//! `tasks_dir()` older than 30 days is deleted outright. Nothing swept +//! this directory before, and it had grown without bound, repeatedly +//! holding stale credentials in captured output. //! //! The runner kills the child process on timeout — `tokio::process::Child::drop()` //! does not kill children, so we explicitly call `child.kill().await`. From 108f7e17ea111b064516e43c7549e85c5a78a177 Mon Sep 17 00:00:00 2001 From: atlas Date: Fri, 11 Sep 2026 13:26:45 +0200 Subject: [PATCH 4/5] docs/setup: name the section a cross-reference points at, not its number MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `c1b7be11` inserted `### 3 · Secret store` and renumbered every heading below it — SSO 3→4, UI 4→5, Matrix 5→6, Spawn 6→7, Host commands 7→8 — without touching a single cross-reference. Five were left pointing one section short: line 25 "see step 6" -> §6 Matrix, meant §7 Spawn sub-agents line 30 "in step 6" -> §6 Matrix, meant §7 Spawn sub-agents line 35 "see step 3" -> §3 Secret store, meant §4 Swarm SSO line 217 "(step 3)" -> §3 Secret store, meant §4 Swarm SSO line 246 "see step 3" -> §3 Secret store, meant §4 Swarm SSO Both `step 3` targets are account-creation instructions and §3 is the secret store, which is `only when deploy.bao` — so an operator without a store follows the pointer into a section that does not apply and finds no `swarmctl` in it. Renumbering to 4/7 would rot on the next insertion. These name the section instead, the form `approvals.md:87` and `dashboard.md:906` already use. The Matrix block's `# 5a.`–`# 5d.` comments lose their prefixes for the same reason: they numbered themselves against that section's old position, and the page's other code blocks carry unnumbered comments anyway. Closes #4212. --- docs/getting-started/setup.md | 24 ++++++++++++------------ 1 file changed, 12 insertions(+), 12 deletions(-) diff --git a/docs/getting-started/setup.md b/docs/getting-started/setup.md index 7443e2ee..9d7998d3 100644 --- a/docs/getting-started/setup.md +++ b/docs/getting-started/setup.md @@ -22,17 +22,17 @@ operator has to place, and where. ```bash # Provision (or refresh) ruth's own forge account — do this first. Ruth's -# bootstrap bypasses the normal spawn-approval flow (see step 6), so unlike -# every other agent it does not get its forge account auto-provisioned — -# this manual step is still load-bearing. +# bootstrap bypasses the normal spawn-approval flow ("Spawn sub-agents" +# below), so unlike every other agent it does not get its forge account +# auto-provisioned — this manual step is still load-bearing. hivectl forge create-user ruth -# Sub-agents spawned later (via the approval flow in step 6) get their -# forge accounts auto-provisioned — nothing to run here for them. +# Sub-agents spawned later (via the approval flow in "Spawn sub-agents") +# get their forge accounts auto-provisioned — nothing to run here for them. ``` Swarm SSO creates the human operator's own forge account instead of -a manual `hivectl` step — see step 3 (`swarmctl user add`). +a manual `hivectl` step — see *Swarm SSO* below (`swarmctl user add`). ### 2 · Gateway (HTTP Basic auth) @@ -214,7 +214,7 @@ Nothing to run — it's served on the swarm apex (`https:///`) as soon as the host rebuilds. Two things decide whether you can actually open it: -- **You are in `admins`** (step 3). The gateway asks authelia whether +- **You are in `admins`** (*Swarm SSO* above). The gateway asks authelia whether you have a session; the rule that makes it mean _operator_ wants the group. Without it you log in and still get bounced. - **The name resolves to this host.** it's published to the hive's own @@ -227,23 +227,23 @@ control: [`swarm/ui.md`](../swarm/ui.md). ### 6 · Matrix ```bash -# 5a. Ensure the hive-internal admin account exists first +# Ensure the hive-internal admin account exists first hivectl matrix sync-admin -# 5b. Provision ruth's own matrix account — same bootstrap-bypass reasoning +# Provision ruth's own matrix account — same bootstrap-bypass reasoning # as forge above, still a required manual step. hivectl matrix create-user ruth -# 5c. Invite the operator to the hive Space (and optionally to rooms) +# Invite the operator to the hive Space (and optionally to rooms) hivectl matrix invite mara hivectl matrix invite @mara:yourserver --room '#hive-chat:yourserver' -# 5d. Promote the operator to homeserver admin if needed +# Promote the operator to homeserver admin if needed hivectl matrix promote-user mara ``` Swarm SSO creates the human operator's own matrix account instead of -a manual `hivectl` step — see step 3 (`swarmctl user add`). +a manual `hivectl` step — see *Swarm SSO* above (`swarmctl user add`). ### 7 · Spawn sub-agents From 8251223144ee6ad50cb703faed752a31300243c8 Mon Sep 17 00:00:00 2001 From: atlas Date: Fri, 11 Sep 2026 19:26:38 +0200 Subject: [PATCH 5/5] docs/setup: use the emphasis form treefmt's prettier normalises to The three cross-references this branch rewrote used `*Swarm SSO*`; prettier emits `_Swarm SSO_`, so checks.formatting went red on an otherwise content-only change. Verified: nix fmt is now idempotent on this tree (0 changed); the three scripts/check-*.sh lints exit 0 with the change staged. --- docs/getting-started/setup.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/getting-started/setup.md b/docs/getting-started/setup.md index 9d7998d3..0bb74dcd 100644 --- a/docs/getting-started/setup.md +++ b/docs/getting-started/setup.md @@ -32,7 +32,7 @@ hivectl forge create-user ruth ``` Swarm SSO creates the human operator's own forge account instead of -a manual `hivectl` step — see *Swarm SSO* below (`swarmctl user add`). +a manual `hivectl` step — see _Swarm SSO_ below (`swarmctl user add`). ### 2 · Gateway (HTTP Basic auth) @@ -214,7 +214,7 @@ Nothing to run — it's served on the swarm apex (`https:///`) as soon as the host rebuilds. Two things decide whether you can actually open it: -- **You are in `admins`** (*Swarm SSO* above). The gateway asks authelia whether +- **You are in `admins`** (_Swarm SSO_ above). The gateway asks authelia whether you have a session; the rule that makes it mean _operator_ wants the group. Without it you log in and still get bounced. - **The name resolves to this host.** it's published to the hive's own @@ -243,7 +243,7 @@ hivectl matrix promote-user mara ``` Swarm SSO creates the human operator's own matrix account instead of -a manual `hivectl` step — see *Swarm SSO* above (`swarmctl user add`). +a manual `hivectl` step — see _Swarm SSO_ above (`swarmctl user add`). ### 7 · Spawn sub-agents