Watch
0
0
Fork
You've already forked hyperhive
0

Compare commits

..
Author SHA1 Message Date
atlas
8251223144 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.
2026-09-11 19:30:17 +02:00
atlas
108f7e17ea docs/setup: name the section a cross-reference points at, not its number
`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.
2026-09-11 19:30:17 +02:00
damocles
ff5106a7b4 hive-bash-mcp: stop naming a private fn in the module's public doc
per mara review on #4249
2026-09-11 19:25:33 +02:00
damocles
54d28944be hive-bash-mcp: fix module doc's intra-doc link to a private item
addresses CI failure on #4249
2026-09-11 19:25:33 +02:00
damocles
4555ab4f10 hive-bash-mcp: sweep harness/bash-tasks/ of files older than 30d at startup
fixes #2855
2026-09-11 19:25:33 +02:00
4 changed files with 192 additions and 12 deletions

1
Cargo.lock generated
View file

@ -1723,6 +1723,7 @@ dependencies = [
"schemars",
"serde",
"serde_json",
"tempfile",
"tokio",
"tracing",
"tracing-subscriber",

View file

@ -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://<swarm.domain>/`) 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

View file

@ -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`/

View file

@ -18,6 +18,11 @@
//! (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. 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`.
@ -436,6 +441,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<Mutex<HashSet<String>>> = Arc::new(Mutex::new(HashSet::new()));
@ -446,6 +455,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 +1028,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);
}
}