Watch
0
0
Fork
You've already forked hyperhive
0
hyperhive/hive-c0re/src/workers/knowledge.rs
atlas 19cc1b12e2 hive-screen-mcp: bound grim/wtype and VNC calls; hive-c0re: make messages match the code
hive-screen-mcp ran `grim` / `wtype` through an unbounded
`Command::output()` and spoke RFB to neatvnc with no deadline, so a
wedged compositor or a VNC server that accepts and never speaks held the
agent's turn forever. Each subprocess now has a 30s limit and is killed
when it hits it; each RFB exchange has a 10s limit. Both come back to the
agent as the tool's text result, like every other failure in this crate.

hive-c0re operator-facing text that described behaviour the code lacks:
- `hivectl matrix reset-password` printed a "next: hivectl matrix
  create-user" hint that fails for every target (agents are refused, a
  non-agent hits M_USER_IN_USE). The line is gone.
- a failed `nixos-container update` appended the container's journal
  tail, read with `journalctl -M`. Since the job DAG, `update` only runs
  from the `Swap` node on a stopped container, so the read always came
  back empty. The helper is removed; the error still points at the
  build log.
- the matrix sweep comment in main.rs said it re-provisions agent token
  files; `ensure_all` creates no agent accounts or tokens.
- the knowledge-pull comments named a webhook caller that no longer
  exists and claimed a race was "fixed at its source".
- `handle_spawn`'s doc and the `mcp_sockets` module doc named callers of
  `register_agent` / a rollback that do not exist.

Refs #4723
2026-09-27 20:47:06 +02:00

203 lines
9.1 KiB
Rust

//! Hive-wide knowledge repository management.
//!
//! `internal/knowledge` on the forge is cloned to
//! [`LOCAL_DIR`] and bind-mounted read-only into every agent container
//! at `/knowledge`. Agents read documents from it directly and
//! contribute by forking the repo and opening PRs — they never write
//! to the bind-mounted path inside the container.
//!
//! hive-c0re maintains the local clone. It learns that the repository
//! moved from the **swarm controller**, which owns the one Forgejo
//! webhook and addresses an event to each hive over the queue; a
//! periodic pull in `main.rs` provides a fallback cadence.
//!
//! A hive used to register that webhook itself, pointing at its own
//! `/webhook/knowledge`. A webhook has exactly one target URL, so with
//! more than one hive that was last-writer-wins rather than idempotent —
//! every hive but the most recent silently stopped receiving deliveries.
//!
//! The repo itself (public, README-seeded) is the swarm controller's too;
//! this module only clones and pulls it.
use anyhow::{Context, Result};
use crate::coordinator::Coordinator;
use crate::forge::{core_auth_header, forge_git_url};
pub const ORG: &str = "internal";
pub const REPO: &str = "knowledge";
/// Host-side path for the local clone. Also referenced from
/// `lifecycle.rs` (bind-mount source) and the dashboard webhook handler.
/// The literal lives in [`crate::paths`]; re-exported here under the name
/// this module and its consumers have always used.
pub use crate::paths::KNOWLEDGE_DIR as LOCAL_DIR;
/// In-container mount point for the knowledge repo. Bind-mounted
/// read-only from [`LOCAL_DIR`] into every agent container.
pub const CONTAINER_MOUNT: &str = "/knowledge";
/// Clone `internal/knowledge` to [`LOCAL_DIR`] if it is not already a git
/// repository. `core_token` authenticates the HTTPS clone so private repos
/// work. Idempotent — skips if `LOCAL_DIR/.git` exists.
///
/// The swarm controller seeds a README into a freshly created repo. A clone
/// taken before that lands is empty; the next [`pull`] fills it.
///
/// Called once at hive-c0re startup after `forge::ensure_all`.
pub async fn ensure_local_clone(core_token: &str) -> Result<()> {
let git_dir = std::path::Path::new(LOCAL_DIR).join(".git");
if git_dir.exists() {
tracing::debug!("knowledge: local clone already present at {LOCAL_DIR}");
return Ok(());
}
std::fs::create_dir_all(LOCAL_DIR).context("create knowledge local dir")?;
let url = forge_git_url(&format!("{ORG}/{REPO}"));
let out = crate::lifecycle::git_command_authed(&core_auth_header(core_token))
.args(["clone", &url, LOCAL_DIR])
.output()
.await
.context("git clone knowledge repo")?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_owned();
anyhow::bail!("git clone {ORG}/{REPO} failed: {stderr}");
}
tracing::info!("knowledge: cloned {ORG}/{REPO} to {LOCAL_DIR}");
Ok(())
}
/// Current `HEAD` sha of the local clone, best-effort. `None` on any
/// failure (used only to detect whether a pull actually moved `HEAD` —
/// worth skipping the broadcast over, not worth failing the pull for).
async fn head_sha() -> Option<String> {
let out = tokio::process::Command::new("git")
.args(["-C", LOCAL_DIR, "rev-parse", "HEAD"])
.output()
.await
.ok()?;
out.status
.success()
.then(|| String::from_utf8_lossy(&out.stdout).trim().to_owned())
}
/// Pull the latest changes in the local clone. Runs only as a
/// `KnowledgePull` job node (see `job_queue::templates::knowledge_pull`),
/// whose resource keeps two pulls off this working tree at once. Uses
/// `--ff-only` so a force-push to the knowledge repo never wedges the local
/// copy silently.
///
/// When the pull actually moves `HEAD` (a real change, not a no-op),
/// broadcasts a short `git diff --stat` summary to every live agent's
/// inbox via `coord` — inbox-only, no forced wake.
pub async fn pull(coord: &Coordinator) -> Result<()> {
// Sanity: if the clone is missing (e.g. storage was wiped), refuse
// to pull and let the caller decide whether to re-clone.
let git_dir = std::path::Path::new(LOCAL_DIR).join(".git");
if !git_dir.exists() {
anyhow::bail!("knowledge: {LOCAL_DIR}/.git not found — clone first");
}
// Rewrite any credentialed `origin` left by an older clone, which spliced
// `core:<token>@` into the URL and persisted it in `.git/config` — the one
// place this repo stored a token in a named remote, against the rule
// `forge::repos::push_config` states. Harmless to repeat once the URL is
// already clean.
let plain = crate::forge::forge_git_url(&format!("{ORG}/{REPO}"));
let _ = crate::lifecycle::git_command()
.args(["-C", LOCAL_DIR, "remote", "set-url", "origin", &plain])
.output()
.await;
// Discard any local drift before pulling — both tracked (`reset --hard`)
// and untracked (`clean -fd`). Nothing in this codebase writes to
// `LOCAL_DIR` after the initial clone — this working tree exists to mirror
// `origin/main`, not to be edited in place. A tracked file left dirty
// by any other means (a stray manual edit on the host, an interrupted
// prior operation, or two pulls running on this working tree at once)
// would otherwise abort the `--ff-only` merge below with
// "local changes would be overwritten"; an untracked file left behind
// the same ways aborts it with "untracked working tree files would be
// overwritten" instead — same wedge, just `clean`'s failure message
// rather than `reset`'s. Either way it wedges every future pull
// identically until someone notices and resets it by hand. Best-effort:
// a failure here surfaces through the pull's own error below rather
// than needing its own branch.
let _ = crate::lifecycle::git_command()
.args(["-C", LOCAL_DIR, "reset", "--hard", "HEAD"])
.output()
.await;
let _ = crate::lifecycle::git_command()
.args(["-C", LOCAL_DIR, "clean", "-fd"])
.output()
.await;
let before = head_sha().await;
// The repo is public (the swarm controller makes it so), so an
// unauthenticated pull is enough — but authenticate when a token is around,
// which keeps this working if the repo is ever made private again.
let auth = crate::forge::core_token().map(|t| core_auth_header(&t));
let mut cmd = match &auth {
Some(header) => crate::lifecycle::git_command_authed(header),
None => crate::lifecycle::git_command(),
};
// Remote and branch are named explicitly. A bare `git pull` merges
// whatever `branch.<current>.merge` lists, and a clone with more than one
// such entry aborts with "Cannot fast-forward to multiple branches" —
// taking /knowledge out across the hive over local config this daemon
// never writes and cannot see. `origin` exists by construction: the
// `remote set-url` above just set it.
let out = cmd
.args(["-C", LOCAL_DIR, "pull", "--ff-only", "origin", "main"])
.output()
.await
.context("git pull knowledge")?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_owned();
anyhow::bail!("git pull {ORG}/{REPO} failed: {stderr}")
}
let stdout = String::from_utf8_lossy(&out.stdout).trim().to_owned();
tracing::info!(result = %stdout, "knowledge: pull succeeded");
let after = head_sha().await;
if let (Some(before), Some(after)) = (before, after)
&& before != after
{
broadcast_change(coord, &before, &after).await;
}
Ok(())
}
/// Diff `before..after` in the local clone and broadcast a one-line-per-file
/// summary to every live agent. Best-effort — a diff or send failure is
/// logged, never propagated (the pull itself already succeeded).
async fn broadcast_change(coord: &Coordinator, before: &str, after: &str) {
let diff = tokio::process::Command::new("git")
.args([
"-C",
LOCAL_DIR,
"diff",
"--stat",
&format!("{before}..{after}"),
])
.output()
.await;
let stat = match diff {
Ok(out) if out.status.success() => String::from_utf8_lossy(&out.stdout).trim().to_owned(),
Ok(out) => {
let stderr = String::from_utf8_lossy(&out.stderr).trim().to_owned();
tracing::warn!(%stderr, "knowledge: diff --stat failed; broadcasting without it");
String::new()
}
Err(e) => {
tracing::warn!(error = ?e, "knowledge: diff --stat failed; broadcasting without it");
String::new()
}
};
let body = if stat.is_empty() {
"[system] /knowledge updated — see the repo for what changed.".to_owned()
} else {
format!("[system] /knowledge updated:\n{stat}")
};
let errors = coord.broadcast_send(hive_sh4re::manager::SYSTEM_SENDER, &body);
if !errors.is_empty() {
tracing::warn!(?errors, "knowledge: broadcast had per-agent failures");
}
}