Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8251223144 | ||
|
|
108f7e17ea | ||
|
|
ff5106a7b4 | ||
|
|
54d28944be | ||
|
|
4555ab4f10 |
4 changed files with 192 additions and 12 deletions
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -1723,6 +1723,7 @@ dependencies = [
|
|||
"schemars",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tracing",
|
||||
"tracing-subscriber",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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`/
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue