refactor: remove hyperhive.role option — there is only one role: agent

This commit is contained in:
damocles 2026-06-04 12:40:24 +02:00 committed by mara
commit 41eb3f806c
23 changed files with 105 additions and 229 deletions

View file

@ -328,11 +328,12 @@ nix/
`services.hyperhive.domain`; gateway serves `services.hyperhive.domain`; gateway serves
`.well-known/matrix/{client,server}` for `.well-known/matrix/{client,server}` for
auto-discovery; federation on, e2ee deferred auto-discovery; federation on, e2ee deferred
templates/harness-base.nix shared harness for all containers; `hyperhive.role` templates/harness-base.nix shared harness for all containers; single
(`"agent"` | `"manager"`) drives service unit + forge `hive-ag3nt` service unit; `hyperhive.model`
defaults; `hyperhive.model` option (HIVE_DEFAULT_MODEL) option (HIVE_DEFAULT_MODEL)
templates/agent-base.nix thin role-setter (`hyperhive.role = "agent"`) templates/agent-base.nix entry-point for sub-agent containers
templates/manager.nix thin role-setter (`hyperhive.role = "manager"`) templates/manager.nix entry-point for ruth; adds forge noise-reduction
defaults (keepSubscriptions=false etc.)
templates/weston-vnc.nix optional `hyperhive.gui.enable` templates/weston-vnc.nix optional `hyperhive.gui.enable`
— weston + VNC backend systemd unit; writes — weston + VNC backend systemd unit; writes
/etc/hyperhive/gui.json (vnc_port + auth) for /etc/hyperhive/gui.json (vnc_port + auth) for

View file

@ -202,46 +202,19 @@ nspawn agent. Open questions, not yet wired:
- Filesystem: share parent's `/state` RW, or a sub-dir? - Filesystem: share parent's `/state` RW, or a sub-dir?
- Identity: distinct broker recipient name, or address the parent? - Identity: distinct broker recipient name, or address the parent?
## Harness systemd unit shape (per-role) ## Harness systemd unit shape
One harness binary (`hive`), one `harness-base.nix` template, two One harness binary (`hive`), one `harness-base.nix` template, one
systemd units depending on `hyperhive.role`: service unit (`systemd.services.hive-ag3nt`) for all agents. There
is no longer a separate manager service name or role distinction in
the harness — privilege differences live server-side in the broker
socket (which tool groups and manager-surface calls each agent
receives).
- `agent-base.nix` (`role = "agent"`) → `systemd.services.hive-ag3nt` `agent-base.nix` and `manager.nix` both import `harness-base.nix`.
- `manager.nix` (`role = "manager"`) → `systemd.services.hive-m1nd` `manager.nix` additionally sets forge defaults to suppress the
subscription/participation firehose so ruth's inbox stays focused
The unit names diverge but the binary is the same. `HIVE_ROLE` env on direct mentions, reviews, and assignments.
var picks the surface at startup (agent vs manager); naming the
units after the historical per-role binaries keeps dashboard log
queries, ExecStartPre paths, and ancestor PR diffs working without a
rename cascade.
### Manager-only defaults
`harness-base.nix` flips these when `hyperhive.role == "manager"`,
via `lib.mkDefault` so any agent can invert if needed:
- `hyperhive.forge.keepSubscriptions = false`
- `hyperhive.forge.skipNotifyReasons = [ "subscribed" "participating" ]`
Skips the subscription / participation firehose so the manager's
inbox only carries direct mentions, reviews, and assignments. Sub-
agents keep the noisier defaults so they see anything aimed at the
repos they're working on.
### Standalone-eval fallbacks
`nixosConfigurations.manager` must build standalone (without the
meta-flake's per-agent flake.nix wrapper). For the manager unit
that means a hardcoded `HIVE_LABEL` env value:
- `HIVE_LABEL = "ruth"` — logical agent name; matches what `meta.rs`
injects at deploy time.
Real deploys never read these — `meta::render_flake` overrides them
via the generated wrapper. They exist so the manager
`nixosConfigurations` evaluates cleanly even outside the meta-flake
boundary.
### Environment variables set on the unit ### Environment variables set on the unit
@ -256,8 +229,6 @@ boundary.
directly on the unit, **not** via `environment.variables`, because directly on the unit, **not** via `environment.variables`, because
the latter only populates `/etc/profile` which systemd services the latter only populates `/etc/profile` which systemd services
don't inherit. don't inherit.
- `HIVE_ROLE = config.hyperhive.role` — picks the binary surface
(agent / manager) at startup.
### `PATH` setup (the wrapper-dir trick) ### `PATH` setup (the wrapper-dir trick)
@ -278,8 +249,7 @@ bit set` regardless of `hyperhive.user.passwordlessSudo`.
### `serviceConfig` highlights ### `serviceConfig` highlights
- `ExecStart = pkgs.hyperhive/bin/hive serve` — single binary, - `ExecStart = pkgs.hyperhive/bin/hive serve` — single binary.
surface picked from `HIVE_ROLE`.
- `Restart = on-failure`, `RestartSec = 2` — keeps the harness - `Restart = on-failure`, `RestartSec = 2` — keeps the harness
resilient across transient crashes without thundering retries. resilient across transient crashes without thundering retries.
- `RuntimeDirectory = "hive-config"``/run/hive-config/` owned by - `RuntimeDirectory = "hive-config"``/run/hive-config/` owned by

View file

@ -5,8 +5,7 @@ claude has access to in return.
## The loop ## The loop
Each agent harness (`hive serve`, role set via `$HIVE_ROLE` — always Each agent harness (`hive serve` — one binary for all agents) runs:
`"agent"`, one binary) runs:
1. Long-poll `Recv` on its socket. The host-side broker 1. Long-poll `Recv` on its socket. The host-side broker
(`broker.rs::recv_blocking_batch`) returns immediately if there's (`broker.rs::recv_blocking_batch`) returns immediately if there's
@ -57,15 +56,11 @@ Each agent harness (`hive serve`, role set via `$HIVE_ROLE` — always
## Harness binary shape ## Harness binary shape
One `hive` binary serves both roles. The split into One `hive` binary for all agents. The earlier split into
`hive-ag3nt` + `hive-m1nd` was collapsed because the privilege `hive-ag3nt` + `hive-m1nd` was collapsed because the privilege
boundary lives server-side at the broker socket boundary lives server-side at the broker socket
(`/run/hive/mcp.sock`): an agent-flavor socket refuses (`/run/hive/mcp.sock`): `ManagerRequest` calls are refused by the
`ManagerRequest` calls regardless of who sends them, so there's no standard agent socket regardless of who sends them.
escalation risk in shipping the same code to both. `main()` reads
`$HIVE_ROLE` (set by `harness-base.nix` from `hyperhive.role`;
defaults to `"agent"` for standalone `nix run` invocations) and
dispatches.
Three subcommands: Three subcommands:

View file

@ -28,10 +28,8 @@ tracing-subscriber.workspace = true
tempfile = "3" tempfile = "3"
[[bin]] [[bin]]
# Unified harness binary: both `agent` and `manager` code paths live # Unified harness binary for all agents. Privilege boundary is
# here; the binary picks its role at startup from `HIVE_ROLE` (set by # enforced server-side at the socket (tool groups / manager surface).
# `harness-base.nix` from `hyperhive.role`). The privilege boundary is # See `docs/turn-loop.md::Harness binary shape`.
# enforced server-side at the socket, so shipping both surfaces in one
# binary is safe. See `docs/turn-loop.md::Harness binary shape`.
name = "hive" name = "hive"
path = "src/bin/hive.rs" path = "src/bin/hive.rs"

View file

@ -198,7 +198,6 @@ trait Surface {
struct AgentSurface; struct AgentSurface;
impl Surface for AgentSurface { impl Surface for AgentSurface {
async fn ack_turn(socket: &Path) { async fn ack_turn(socket: &Path) {
match client::request::<_, AgentResponse>(socket, &AgentRequest::AckTurn).await { match client::request::<_, AgentResponse>(socket, &AgentRequest::AckTurn).await {
Ok(AgentResponse::Ok) => {} Ok(AgentResponse::Ok) => {}

View file

@ -269,13 +269,7 @@ shared closer
// Real template's first agent line — keeps the renderer // Real template's first agent line — keeps the renderer
// honest about the {label} / {operator_pronouns} pair the // honest about the {label} / {operator_pronouns} pair the
// harness already relied on. // harness already relied on.
let rendered = render( let rendered = render(&PRODUCTION_TEMPLATE, "alice", "they/them", None, None);
&PRODUCTION_TEMPLATE,
"alice",
"they/them",
None,
None,
);
assert!(rendered.contains("hyperhive agent `alice`")); assert!(rendered.contains("hyperhive agent `alice`"));
assert!(rendered.contains("**they/them** pronouns")); assert!(rendered.contains("**they/them** pronouns"));
assert!(!rendered.contains("{label}")); assert!(!rendered.contains("{label}"));
@ -285,13 +279,7 @@ shared closer
#[test] #[test]
fn render_no_role_markers_in_output() { fn render_no_role_markers_in_output() {
// No raw role markers should survive into the rendered prompt. // No raw role markers should survive into the rendered prompt.
let rendered = render( let rendered = render(&PRODUCTION_TEMPLATE, "alice", "she/her", None, None);
&PRODUCTION_TEMPLATE,
"alice",
"she/her",
None,
None,
);
assert!(!rendered.contains("<!-- role:")); assert!(!rendered.contains("<!-- role:"));
assert!(!rendered.contains("<!-- /role:")); assert!(!rendered.contains("<!-- /role:"));
// Shared tools appear. // Shared tools appear.
@ -301,13 +289,7 @@ shared closer
#[test] #[test]
fn render_uses_agent_opener() { fn render_uses_agent_opener() {
let rendered = render( let rendered = render(&PRODUCTION_TEMPLATE, "alice", "she/her", None, None);
&PRODUCTION_TEMPLATE,
"alice",
"she/her",
None,
None,
);
assert!(rendered.starts_with("You are hyperhive agent")); assert!(rendered.starts_with("You are hyperhive agent"));
} }
@ -326,13 +308,7 @@ You are hyperhive agent `{label}` (qualified: `{qualified_label}`){hive_identity
#[test] #[test]
fn render_substitutes_hive_identity_when_set() { fn render_substitutes_hive_identity_when_set() {
let rendered = render( let rendered = render(IDENTITY_FIXTURE, "alice", "she/her", Some("pr1ma"), None);
IDENTITY_FIXTURE,
"alice",
"she/her",
Some("pr1ma"),
None,
);
assert!(rendered.contains("on hive `pr1ma`"), "{rendered}"); assert!(rendered.contains("on hive `pr1ma`"), "{rendered}");
// swarm clause stays absent when only hive is set. // swarm clause stays absent when only hive is set.
assert!(!rendered.contains("in swarm")); assert!(!rendered.contains("in swarm"));

View file

@ -62,7 +62,6 @@ struct AppState {
gui_vnc_port: Option<u16>, gui_vnc_port: Option<u16>,
} }
/// Bind the per-container web listener and serve the SPA. /// Bind the per-container web listener and serve the SPA.
/// ///
/// `HIVE_WEB_SOCKET` opt-in selects unix-socket vs TCP binding; the /// `HIVE_WEB_SOCKET` opt-in selects unix-socket vs TCP binding; the
@ -737,7 +736,11 @@ async fn events_history(
// window is "drop buffered events you've already seen in history", // window is "drop buffered events you've already seen in history",
// never "lose an event that fired between the read and the seq." // never "lose an event that fired between the read and the seq."
// On paginated loads (`before` is set) seq is not needed. // On paginated loads (`before` is set) seq is not needed.
let seq = if is_initial { Some(state.bus.current_seq()) } else { None }; let seq = if is_initial {
Some(state.bus.current_seq())
} else {
None
};
let (events, min_id, has_more) = state.bus.history_page(before, limit); let (events, min_id, has_more) = state.bus.history_page(before, limit);
let mut resp = serde_json::json!({ let mut resp = serde_json::json!({

View file

@ -306,17 +306,18 @@ async fn run_task(mut task: TaskFile, socket: &Path) {
let out_path = paths::task_out(&id); let out_path = paths::task_out(&id);
let err_path = paths::task_err(&id); let err_path = paths::task_err(&id);
let (timed_out, exit_code) = match exec_cmd(&task.cmd, &out_path, &err_path, task.timeout_secs).await { let (timed_out, exit_code) =
Ok((code, false)) => (false, Some(code)), match exec_cmd(&task.cmd, &out_path, &err_path, task.timeout_secs).await {
Ok((_, true)) => { Ok((code, false)) => (false, Some(code)),
tracing::warn!(id = %id, "bash_runner: task timed out"); Ok((_, true)) => {
(true, None) tracing::warn!(id = %id, "bash_runner: task timed out");
} (true, None)
Err(e) => { }
tracing::warn!(id = %id, error = ?e, "bash_runner: exec error"); Err(e) => {
(false, None) tracing::warn!(id = %id, error = ?e, "bash_runner: exec error");
} (false, None)
}; }
};
let stdout_tail = tail_file(&out_path, SUMMARY_BYTES); let stdout_tail = tail_file(&out_path, SUMMARY_BYTES);
let stderr_tail = tail_file(&err_path, SUMMARY_BYTES); let stderr_tail = tail_file(&err_path, SUMMARY_BYTES);

View file

@ -547,12 +547,9 @@ async fn run_apply_commit(
// "nixos-container update" label for the whole multi-minute window. // "nixos-container update" label for the whole multi-minute window.
let hive = coord.hive_env(); let hive = coord.hive_env();
let paths = Coordinator::agent_paths(&approval.agent, agent_dir.to_path_buf()); let paths = Coordinator::agent_paths(&approval.agent, agent_dir.to_path_buf());
let build_result = lifecycle::rebuild_no_meta( let build_result = lifecycle::rebuild_no_meta(&approval.agent, &hive, &paths, &|step| {
&approval.agent, coord.set_queue_step(queue_entry_id, step)
&hive, })
&paths,
&|step| coord.set_queue_step(queue_entry_id, step),
)
.await; .await;
match build_result { match build_result {

View file

@ -463,7 +463,7 @@ async fn dispatch(req: &AgentRequest, agent: &str, coord: &Arc<Coordinator>) ->
Err(e) => { Err(e) => {
return AgentResponse::Err { return AgentResponse::Err {
message: format!("list containers failed: {e:#}"), message: format!("list containers failed: {e:#}"),
} };
} }
}; };
// Walk the full topology and collect every descendant. // Walk the full topology and collect every descendant.
@ -497,7 +497,8 @@ async fn dispatch(req: &AgentRequest, agent: &str, coord: &Arc<Coordinator>) ->
}; };
} }
tracing::info!(%agent, %name, "agent: request_init_config for child"); tracing::info!(%agent, %name, "agent: request_init_config for child");
match crate::manager_server::submit_init_config(coord, name, description.clone()).await { match crate::manager_server::submit_init_config(coord, name, description.clone()).await
{
Ok(_id) => AgentResponse::Ok, Ok(_id) => AgentResponse::Ok,
Err(e) => AgentResponse::Err { Err(e) => AgentResponse::Err {
message: format!("{e:#}"), message: format!("{e:#}"),

View file

@ -84,12 +84,9 @@ pub async fn rebuild_agent(
// lifecycle_action; this catches the auto-update scan + any // lifecycle_action; this catches the auto-update scan + any
// other direct caller. // other direct caller.
let guard = coord.transient_guard(name, crate::coordinator::TransientKind::Rebuilding); let guard = coord.transient_guard(name, crate::coordinator::TransientKind::Rebuilding);
let result = lifecycle::rebuild( let result = lifecycle::rebuild(name, &hive, &paths, &|step| {
name, coord.set_queue_step(queue_entry_id, step)
&hive, })
&paths,
&|step| coord.set_queue_step(queue_entry_id, step),
)
.await; .await;
drop(guard); drop(guard);
match &result { match &result {

View file

@ -478,8 +478,7 @@ async fn matrix_reset_password(name: &str) -> Result<()> {
); );
} }
let admin_token = hive_c0re::matrix::read_admin_token()?; let admin_token = hive_c0re::matrix::read_admin_token()?;
let new_password = let new_password = hive_c0re::matrix::random_password().context("generate random password")?;
hive_c0re::matrix::random_password().context("generate random password")?;
let client = reqwest::Client::builder() let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30)) .timeout(std::time::Duration::from_secs(30))
.build() .build()
@ -496,8 +495,7 @@ async fn matrix_reset_password(name: &str) -> Result<()> {
) )
.await .await
.with_context(|| format!("matrix reset-password {name}"))?; .with_context(|| format!("matrix reset-password {name}"))?;
let pw_path = PathBuf::from("/var/lib/hyperhive/matrix-creds") let pw_path = PathBuf::from("/var/lib/hyperhive/matrix-creds").join(format!("{name}-password"));
.join(format!("{name}-password"));
println!("matrix: password for @{name}:{server_name} reset"); println!("matrix: password for @{name}:{server_name} reset");
println!("password persisted at: {}", pw_path.display()); println!("password persisted at: {}", pw_path.display());
println!("next: hivectl matrix create-user {name} # mints a fresh access token"); println!("next: hivectl matrix create-user {name} # mints a fresh access token");

View file

@ -3037,18 +3037,17 @@ struct PushWebhookRepo {
async fn post_webhook_knowledge( async fn post_webhook_knowledge(
axum::extract::Json(payload): axum::extract::Json<PushWebhookPayload>, axum::extract::Json(payload): axum::extract::Json<PushWebhookPayload>,
) -> Response { ) -> Response {
let expected_repo = format!( let expected_repo = format!("{}/{}", crate::knowledge::ORG, crate::knowledge::REPO);
"{}/{}",
crate::knowledge::ORG,
crate::knowledge::REPO
);
let full_name = payload let full_name = payload
.repository .repository
.as_ref() .as_ref()
.and_then(|r| r.full_name.as_deref()) .and_then(|r| r.full_name.as_deref())
.unwrap_or(""); .unwrap_or("");
if full_name != expected_repo { if full_name != expected_repo {
tracing::debug!(full_name, "webhook/knowledge: ignoring push from unexpected repo"); tracing::debug!(
full_name,
"webhook/knowledge: ignoring push from unexpected repo"
);
return (StatusCode::OK, "ignored").into_response(); return (StatusCode::OK, "ignored").into_response();
} }
let git_ref = payload.git_ref.as_deref().unwrap_or(""); let git_ref = payload.git_ref.as_deref().unwrap_or("");

View file

@ -355,7 +355,12 @@ pub(crate) async fn submit_init_config(
} }
let id = coord let id = coord
.approvals .approvals
.submit_kind(name, hive_sh4re::ApprovalKind::InitConfig, "", description.as_deref()) .submit_kind(
name,
hive_sh4re::ApprovalKind::InitConfig,
"",
description.as_deref(),
)
.map_err(|e| anyhow::anyhow!("queue approval row: {e:#}"))?; .map_err(|e| anyhow::anyhow!("queue approval row: {e:#}"))?;
tracing::info!(%id, %name, "init_config approval queued"); tracing::info!(%id, %name, "init_config approval queued");
coord.emit_approval_added(id, name, "init_config", None, None, description); coord.emit_approval_added(id, name, "init_config", None, None, description);

View file

@ -340,9 +340,7 @@ fn backfill_manager_tool_groups(names: &[String]) {
} }
let existing = tool_groups::groups_for(MANAGER_NAME); let existing = tool_groups::groups_for(MANAGER_NAME);
if !existing.is_empty() { if !existing.is_empty() {
tracing::debug!( tracing::debug!("migration: ruth already has explicit tool groups — skipping backfill");
"migration: ruth already has explicit tool groups — skipping backfill"
);
return; return;
} }
let all_groups: Vec<String> = hive_sh4re::ToolGroup::MANAGER_DEFAULT let all_groups: Vec<String> = hive_sh4re::ToolGroup::MANAGER_DEFAULT

View file

@ -202,7 +202,6 @@ fn known_agents(_coord: &Coordinator) -> std::collections::HashSet<String> {
if let Some(name) = raw.strip_prefix(crate::lifecycle::AGENT_PREFIX) { if let Some(name) = raw.strip_prefix(crate::lifecycle::AGENT_PREFIX) {
out.insert(name.to_owned()); out.insert(name.to_owned());
} }
} }
} }
Err(e) => { Err(e) => {
@ -378,7 +377,6 @@ async fn known_agents_async() -> std::collections::HashSet<String> {
if let Some(name) = raw.strip_prefix(crate::lifecycle::AGENT_PREFIX) { if let Some(name) = raw.strip_prefix(crate::lifecycle::AGENT_PREFIX) {
out.insert(name.to_owned()); out.insert(name.to_owned());
} }
} }
} }
Err(e) => { Err(e) => {

View file

@ -82,8 +82,7 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
let agent_dir = coord.ensure_runtime(name)?; let agent_dir = coord.ensure_runtime(name)?;
let hive = coord.hive_env(); let hive = coord.hive_env();
let paths = Coordinator::agent_paths(name, agent_dir); let paths = Coordinator::agent_paths(name, agent_dir);
match lifecycle::spawn(name, &hive, &paths).await match lifecycle::spawn(name, &hive, &paths).await {
{
Ok(()) => { Ok(()) => {
coord.notify_manager(&hive_sh4re::HelperEvent::Spawned { coord.notify_manager(&hive_sh4re::HelperEvent::Spawned {
agent: name.clone(), agent: name.clone(),

View file

@ -54,21 +54,13 @@ pub fn run(client: &Client, args: Args) -> Result<()> {
/// Submit a review event (APPROVED / REQUEST_CHANGES / COMMENT) and print /// Submit a review event (APPROVED / REQUEST_CHANGES / COMMENT) and print
/// a compact summary of the created review. /// a compact summary of the created review.
fn submit_review( fn submit_review(client: &Client, number: u64, event: &str, body: Option<String>) -> Result<()> {
client: &Client,
number: u64,
event: &str,
body: Option<String>,
) -> Result<()> {
let repo = client.repo(); let repo = client.repo();
let payload = json!({ let payload = json!({
"event": event, "event": event,
"body": body.unwrap_or_default(), "body": body.unwrap_or_default(),
}); });
let v = client.post_json( let v = client.post_json(&format!("/repos/{repo}/pulls/{number}/reviews"), &payload)?;
&format!("/repos/{repo}/pulls/{number}/reviews"),
&payload,
)?;
print_json(&json!({ print_json(&json!({
"id": v.get("id"), "id": v.get("id"),
"state": v.get("state"), "state": v.get("state"),
@ -81,7 +73,9 @@ fn submit_review(
/// gracefully. /// gracefully.
fn fetch_inline_comments(client: &Client, repo: &str, pr: u64, review_id: u64) -> Vec<Value> { fn fetch_inline_comments(client: &Client, repo: &str, pr: u64, review_id: u64) -> Vec<Value> {
client client
.get_json(&format!("/repos/{repo}/pulls/{pr}/reviews/{review_id}/comments")) .get_json(&format!(
"/repos/{repo}/pulls/{pr}/reviews/{review_id}/comments"
))
.ok() .ok()
.and_then(|v| v.as_array().cloned()) .and_then(|v| v.as_array().cloned())
.unwrap_or_default() .unwrap_or_default()
@ -100,12 +94,7 @@ fn list_reviews(client: &Client, number: u64) -> Result<()> {
} }
/// JSON output: one object per review, with an inline `comments` array. /// JSON output: one object per review, with an inline `comments` array.
fn list_reviews_json( fn list_reviews_json(client: &Client, repo: &str, number: u64, reviews: &[Value]) -> Result<()> {
client: &Client,
repo: &str,
number: u64,
reviews: &[Value],
) -> Result<()> {
let trimmed: Vec<Value> = reviews let trimmed: Vec<Value> = reviews
.iter() .iter()
.map(|r| { .map(|r| {
@ -140,12 +129,7 @@ fn list_reviews_json(
/// Human-readable output: Markdown-style heading per review, inline /// Human-readable output: Markdown-style heading per review, inline
/// comments as `[path:line] body` (line omitted for PR-level comments). /// comments as `[path:line] body` (line omitted for PR-level comments).
fn list_reviews_text( fn list_reviews_text(client: &Client, repo: &str, number: u64, reviews: &[Value]) -> Result<()> {
client: &Client,
repo: &str,
number: u64,
reviews: &[Value],
) -> Result<()> {
if reviews.is_empty() { if reviews.is_empty() {
println!("(no reviews)"); println!("(no reviews)");
return Ok(()); return Ok(());

View file

@ -936,7 +936,9 @@ impl ToolGroup {
Self::Inbox => { Self::Inbox => {
"get_loose_ends, cancel_loose_end, remind, request_next_turn — self-scheduling" "get_loose_ends, cancel_loose_end, remind, request_next_turn — self-scheduling"
} }
Self::Lifecycle => "kill, start, restart, update, list_containers — container lifecycle (privileged)", Self::Lifecycle => {
"kill, start, restart, update, list_containers — container lifecycle (privileged)"
}
Self::Approvals => { Self::Approvals => {
"request_init_config, request_apply_commit, request_update_meta_inputs — config change flow (privileged)" "request_init_config, request_apply_commit, request_update_meta_inputs — config change flow (privileged)"
} }
@ -946,7 +948,9 @@ impl ToolGroup {
Self::Diagnostics => { Self::Diagnostics => {
"get_logs — read a sub-agent container's systemd journal (privileged)" "get_logs — read a sub-agent container's systemd journal (privileged)"
} }
Self::Execution => "run, status — run shell commands via mcp__bash__run / mcp__bash__status", Self::Execution => {
"run, status — run shell commands via mcp__bash__run / mcp__bash__status"
}
Self::WebTools => "WebFetch, WebSearch — Claude built-in web egress; not MCP tools", Self::WebTools => "WebFetch, WebSearch — Claude built-in web egress; not MCP tools",
} }
} }

View file

@ -197,8 +197,7 @@ in
''; '';
} }
{ {
assertion = assertion = !config.services.hyperhive.forge.enable || config.services.hyperhive.gateway.enable;
!config.services.hyperhive.forge.enable || config.services.hyperhive.gateway.enable;
message = '' message = ''
services.hyperhive.network.isolateContainers = true with services.hyperhive.network.isolateContainers = true with
services.hyperhive.forge.enable = true requires services.hyperhive.forge.enable = true requires

View file

@ -1,11 +1,7 @@
{ ... }: { ... }:
{ {
imports = [ ./harness-base.nix ]; imports = [ ./harness-base.nix ];
# Entry-point for sub-agent containers. Referenced from `flake.nix`
# Sub-agent role: the role-driven `systemd.services.hive-ag3nt` plus
# the default forge notification surface live in `harness-base.nix`.
# This file is the bare entry-point referenced from `flake.nix`
# (`nixosConfigurations.agent-base`) and the meta-flake's # (`nixosConfigurations.agent-base`) and the meta-flake's
# `applied/<name>/flake.nix` for sub-agent containers. # `applied/<name>/flake.nix`.
hyperhive.role = "agent";
} }

View file

@ -19,10 +19,9 @@ let
homeDir = "/home/${userName}"; homeDir = "/home/${userName}";
in in
{ {
# Shared scaffolding for any hyperhive harness container — both # Shared scaffolding for every hyperhive harness container.
# sub-agents (`agent-base.nix`) and the manager (`manager.nix`) extend # `agent-base.nix` and `manager.nix` both import this; all agents
# this. The systemd service that actually runs the harness binary # use the same service unit regardless of which entry-point they came from.
# differs per role and lives in the child module.
# Optional feature modules. Each declares its own `hyperhive.*` # Optional feature modules. Each declares its own `hyperhive.*`
# option(s), default-off, so every agent has them available but # option(s), default-off, so every agent has them available but
@ -86,28 +85,6 @@ in
''; '';
}; };
options.hyperhive.role = lib.mkOption {
type = lib.types.enum [
"agent"
"manager"
];
default = "agent";
example = "manager";
description = ''
Whether this container runs as a sub-agent (`"agent"`, the
default) or as the swarm's manager (`"manager"` both invoke
`hive serve`;
defaults the forge notification surface to mentions-only).
meta.rs flips this to `"manager"` for the manager container
and leaves it at the default for every sub-agent. Agents
and `agent.nix` files don't normally touch this option;
it's exposed so a standalone `nixos-rebuild` against
`nixosConfigurations.manager` keeps working without the
meta-flake wrapper around it.
'';
};
options.hyperhive.model = lib.mkOption { options.hyperhive.model = lib.mkOption {
type = lib.types.str; type = lib.types.str;
default = "haiku"; default = "haiku";
@ -292,9 +269,7 @@ in
Computed: the merged static tree consumed by the harness via Computed: the merged static tree consumed by the harness via
`HIVE_STATIC_DIR`. Composed at evaluation time by copying `HIVE_STATIC_DIR`. Composed at evaluation time by copying
`hyperhive.frontend.dist`'s `agent/` subdir as the base, then `hyperhive.frontend.dist`'s `agent/` subdir as the base, then
layering each `extraFiles` entry on top. Read-only layering each `extraFiles` entry on top. Read-only do not set directly.
consumers (`agent-base.nix`, `manager.nix`) reference this in
their systemd service environment; do not set directly.
''; '';
}; };
@ -1357,18 +1332,6 @@ in
}; };
}; };
# Manager-only forge defaults: subscription/participation
# firehose stays off so the manager's inbox isn't drowned in
# noise. Full rationale + sub-agent contrast:
# docs/agent-hierarchy.md::Manager-only defaults.
hyperhive.forge = lib.mkIf (config.hyperhive.role == "manager") {
keepSubscriptions = lib.mkDefault false;
skipNotifyReasons = lib.mkDefault [
"subscribed"
"participating"
];
};
# Harness systemd unit. Unit shape (PATH wrapper-dir trick, env vars, # Harness systemd unit. Unit shape (PATH wrapper-dir trick, env vars,
# RuntimeDirectory, User=, standalone-eval fallbacks): # RuntimeDirectory, User=, standalone-eval fallbacks):
# docs/agent-hierarchy.md::Harness systemd unit shape. PATH /bin # docs/agent-hierarchy.md::Harness systemd unit shape. PATH /bin
@ -1376,11 +1339,10 @@ in
# appends /bin to every entry. # appends /bin to every entry.
systemd.services.hive-ag3nt = systemd.services.hive-ag3nt =
let let
isManager = config.hyperhive.role == "manager";
binary = "hive"; binary = "hive";
in in
{ {
description = "${binary}${lib.optionalString isManager " manager"} harness"; description = "${binary} harness";
wantedBy = [ "multi-user.target" ]; wantedBy = [ "multi-user.target" ];
after = [ "network.target" ]; after = [ "network.target" ];
# `/run/wrappers` before `/run/current-system/sw` so setuid # `/run/wrappers` before `/run/current-system/sw` so setuid
@ -1395,16 +1357,11 @@ in
HOME = homeDir; HOME = homeDir;
HIVE_STATIC_DIR = "${config.hyperhive.frontend.mergedDist}"; HIVE_STATIC_DIR = "${config.hyperhive.frontend.mergedDist}";
HIVE_ASSETS_DIR = "${pkgs.hyperhive-assets}/share/hyperhive"; HIVE_ASSETS_DIR = "${pkgs.hyperhive-assets}/share/hyperhive";
HIVE_ROLE = config.hyperhive.role; # Unix-socket path for the harness web UI. All agents always bind
# Unix-socket path for the harness web UI. All agents (sub-agents # here; TCP fallback is removed. Path matches
# and manager) always bind here; TCP fallback is removed. Path # `hive_c0re::agent_sockets::socket_path_for(name)` so lifecycle
# matches `hive_c0re::agent_sockets::socket_path_for(name)` so # bind-mounts and gateway upstream config stay in sync.
# lifecycle bind-mounts and gateway upstream config stay in sync.
HIVE_WEB_SOCKET = "/run/hive-agent/${userName}/web.sock"; HIVE_WEB_SOCKET = "/run/hive-agent/${userName}/web.sock";
}
// lib.optionalAttrs isManager {
# Standalone-eval fallback; meta.rs overrides at deploy time.
HIVE_LABEL = "ruth";
}; };
serviceConfig = { serviceConfig = {
ExecStart = "${pkgs.hyperhive}/bin/${binary} serve"; ExecStart = "${pkgs.hyperhive}/bin/${binary} serve";

View file

@ -2,14 +2,15 @@
{ {
imports = [ ./harness-base.nix ]; imports = [ ./harness-base.nix ];
# Manager role: the `systemd.services.hive-ag3nt` unit plus # Entry-point for the privileged root agent (ruth). Referenced from
# the manager-only forge defaults (`keepSubscriptions = false`,
# `skipNotifyReasons = [ "subscribed" "participating" ]`) live in
# `harness-base.nix` under `lib.mkIf (config.hyperhive.role ==
# "manager")`. This file is the bare entry-point referenced from
# `flake.nix` (`nixosConfigurations.ruth`) and the meta-flake's # `flake.nix` (`nixosConfigurations.ruth`) and the meta-flake's
# `applied/ruth/flake.nix`. HIVE_PORT / HIVE_LABEL are injected by # `applied/ruth/flake.nix`. Forge subscription/participation firehose
# the meta-flake at deploy time and have manager-only standalone-eval # stays off so ruth's inbox isn't drowned in noise.
# fallbacks in `harness-base.nix`. hyperhive.forge = {
hyperhive.role = "manager"; keepSubscriptions = false;
skipNotifyReasons = [
"subscribed"
"participating"
];
};
} }