From 97edd6baac63d8a5a90d8f56eef9fd0ea01d966b Mon Sep 17 00:00:00 2001 From: atlas Date: Sat, 11 Jul 2026 09:55:05 +0200 Subject: [PATCH 1/9] =?UTF-8?q?feat(#2346):=20request=5Fmerge=5Fconfig=5Fp?= =?UTF-8?q?r=20=E2=80=94=20submission=20path=20for=20the=20PR-based=20conf?= =?UTF-8?q?ig=20flow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit MergeConfigPr approvals had a fully-implemented approve handler (run_merge_config_pr, ff_push_to_main, mark_pr_merged) and dashboard display, but no way to submit one. An agent with the `approvals` tool group calling request_merge_config_pr(agent, pr_number) is the missing piece. What this adds: - RequestMergeConfigPr variant in hive-sh4re AgentRequest + ToolGroup::Approvals - submit_merge_config_pr: fetches PR head sha (the drift-gate reviewed sha), queues a MergeConfigPr row, sets fetched_sha, emits approval_added with pr_number so the dashboard card links to the forge PR - handle_request_merge_config_pr: topology (require_descendant) + tool-group (require_group(approvals)) guards before submit - socket_server/mod.rs: dispatch arm for RequestMergeConfigPr - hive-ag3nt MCP tool: request_merge_config_pr with full description - docs/tools/lifecycle.md: documents the new tool + boundary table row Unlike submit_apply_commit, no flake pre-flight at submission time (eval- verify happens at approval time inside run_merge_config_pr, same as the rest of the merge pipeline). Applied repo must already exist (guard added with a clear error message pointing at request_apply_commit for first-spawn). --- docs/tools/lifecycle.md | 23 ++++- hive-ag3nt/src/mcp/args.rs | 11 +++ hive-ag3nt/src/mcp/mod.rs | 45 ++++++++- .../src/socket_server/config_approvals.rs | 94 ++++++++++++++++++- hive-c0re/src/socket_server/mod.rs | 20 +++- hive-sh4re/src/lib.rs | 17 +++- 6 files changed, 201 insertions(+), 9 deletions(-) diff --git a/docs/tools/lifecycle.md b/docs/tools/lifecycle.md index 5ad65e21..2f8490fd 100644 --- a/docs/tools/lifecycle.md +++ b/docs/tools/lifecycle.md @@ -64,6 +64,24 @@ pinned commit. rejected — the approval pins the exact commit). `agent` must be a direct child. Topology-enforced. +### `request_merge_config_pr(agent, pr_number, description?)` + +Submit an open PR on the agent's `agent-configs/` forge repo for +operator review and merge. The PR-based config flow's counterpart to +`request_apply_commit`: instead of pinning a commit sha from the proposed +repo, the submitter references an already-open forge PR. + +hive-c0re fetches the PR head sha at submission time (the "reviewed" sha); +on operator approval it re-checks for drift, eval-verifies the commit, +fast-forwards the forge repo's `main` to the reviewed sha, marks the PR +merged, and rebuilds the agent container. If the PR head moves between +submission and approval the approve handler aborts — the submitter must +re-submit. + +`agent` must be in the caller's subtree. The agent must already be fully +provisioned (applied repo present); this tool is not for first-spawn. +Requires the `approvals` tool group. + ### `request_update_meta_inputs(inputs?, description?)` Queue an approval to run `nix flake update [inputs...]` on the meta @@ -81,8 +99,9 @@ agents after the approval resolves. | `kill` / `start` / `restart` / `update` | No | Direct children | | `list_containers` | No | All descendants | | `request_init_config` | Yes (InitConfig) | New direct child only | -| `request_apply_commit` | Yes (ApplyCommit) | Direct children | -| `request_update_meta_inputs` | Yes (MetaUpdate) | Meta flake (global) | +| `request_apply_commit` | Yes (ApplyCommit) | Direct children | +| `request_merge_config_pr` | Yes (MergeConfigPr) | Descendants | +| `request_update_meta_inputs` | Yes (MetaUpdate) | Meta flake (global) | ## See also diff --git a/hive-ag3nt/src/mcp/args.rs b/hive-ag3nt/src/mcp/args.rs index 007504cf..f860f4a6 100644 --- a/hive-ag3nt/src/mcp/args.rs +++ b/hive-ag3nt/src/mcp/args.rs @@ -222,6 +222,17 @@ pub struct RequestApplyCommitArgs { pub description: Option, } +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct RequestMergeConfigPrArgs { + /// Logical agent name whose `agent-configs/` forge repo holds the PR. + pub agent: String, + /// Open PR index on the `agent-configs/` repo. + pub pr_number: u64, + /// Optional description shown on the dashboard approval card. + #[serde(default)] + pub description: Option, +} + #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct UpdateMetaInputsArgs { /// Flake input names to update (e.g. `["bitburner-agent", "nixpkgs"]`). diff --git a/hive-ag3nt/src/mcp/mod.rs b/hive-ag3nt/src/mcp/mod.rs index 563ce71f..aa802296 100644 --- a/hive-ag3nt/src/mcp/mod.rs +++ b/hive-ag3nt/src/mcp/mod.rs @@ -29,8 +29,8 @@ pub use args::{ AckUntilArgs, AgentGetLooseEndsArgs, AnswerArgs, AskArgs, CancelLooseEndArgs, CancelScheduleArgs, CreateRepoArgs, EditScheduleArgs, FireScheduleNowArgs, GetAgentMetaArgs, GetHostJournalArgs, GetLogsArgs, KillArgs, RecvArgs, RemindArgs, RequestApplyCommitArgs, - RequestInitConfigArgs, RequestSchedulePromptArgs, RestartArgs, SendArgs, SetStatusArgs, - StartArgs, UpdateArgs, UpdateMetaInputsArgs, + RequestInitConfigArgs, RequestMergeConfigPrArgs, RequestSchedulePromptArgs, RestartArgs, + SendArgs, SetStatusArgs, StartArgs, UpdateArgs, UpdateMetaInputsArgs, }; pub use render::{ IDLE_WAIT_HINT, REDELIVERY_HINT, annotate_retries, format_ack, format_agent_meta, format_recv, @@ -757,6 +757,47 @@ impl AgentServer { .await } + // IMPORTANT: this tool is only available when the `approvals` tool group + // is configured for the agent. hive-c0re enforces both the tool-group check + // and topology: the target must be in the caller's subtree. + #[tool( + description = "Submit an open forge PR on `agent-configs/` for the operator \ + to review and merge into the agent's running config. Requires the `approvals` \ + tool group. `agent` must be in this agent's subtree. `pr_number` is the PR \ + index on the `agent-configs/` repo. hive-c0re fetches the PR head sha \ + at submission time (the drift-gate sha); if the PR head moves before the \ + operator approves, the approve handler aborts without making any changes — the \ + submitter must re-submit. On approval hive-c0re eval-verifies the head, \ + fast-forwards the forge repo's `main`, marks the PR merged, and rebuilds the \ + agent container." + )] + async fn request_merge_config_pr( + &self, + Parameters(args): Parameters, + ) -> String { + let log = format!("{args:?}"); + let agent = args.agent.clone(); + let pr_number = args.pr_number; + run_tool_envelope("request_merge_config_pr", log, async move { + let (resp, retries) = self + .dispatch(hive_sh4re::Request::RequestMergeConfigPr { + agent: args.agent, + pr_number: args.pr_number, + description: args.description, + }) + .await; + annotate_retries( + format_ack( + resp, + "request_merge_config_pr", + format!("merge_config_pr approval queued for {agent} PR #{pr_number}"), + ), + retries, + ) + }) + .await + } + // IMPORTANT: this tool is only available when the `lifecycle` tool group // is granted to this agent. hive-c0re enforces the topology check // server-side: the call is rejected unless `name` is a direct child. diff --git a/hive-c0re/src/socket_server/config_approvals.rs b/hive-c0re/src/socket_server/config_approvals.rs index 492322b9..1516a4cf 100644 --- a/hive-c0re/src/socket_server/config_approvals.rs +++ b/hive-c0re/src/socket_server/config_approvals.rs @@ -1,7 +1,8 @@ //! Config-approval request handlers: `RequestInitConfig` / -//! `RequestApplyCommit` / `RequestUpdateMetaInputs`, plus the shared -//! submit helpers (`submit_init_config` / `submit_apply_commit`) and the -//! commit-sha shape check (`validate_commit_ref`). +//! `RequestApplyCommit` / `RequestMergeConfigPr` / `RequestUpdateMetaInputs`, +//! plus the shared submit helpers (`submit_init_config` / `submit_apply_commit` +//! / `submit_merge_config_pr`) and the commit-sha shape check +//! (`validate_commit_ref`). use std::sync::Arc; @@ -105,6 +106,93 @@ pub(super) fn handle_request_update_meta_inputs( AgentResponse::Ok } +/// `RequestMergeConfigPr` — queue a `MergeConfigPr` approval for a PR on an +/// agent's `agent-configs/` forge repo. The target must be in the +/// caller's subtree. hive-c0re fetches the PR head sha at submission time; +/// that sha is stored as `fetched_sha` and forms the drift gate in the +/// approve handler: if the PR head moves between submission and approval, +/// the approve handler aborts without making any changes. +pub(super) async fn handle_request_merge_config_pr( + coord: &Arc, + agent: &str, + target_agent: &str, + pr_number: u64, + description: Option<&str>, +) -> AgentResponse { + if let Some(err) = super::require_descendant(agent, target_agent, "request_merge_config_pr for") + { + return err; + } + tracing::info!(%agent, %target_agent, %pr_number, "request_merge_config_pr"); + match submit_merge_config_pr(coord, target_agent, pr_number, description, agent).await { + Ok(id) => { + tracing::info!(%id, %target_agent, %pr_number, "merge_config_pr approval queued"); + AgentResponse::Ok + } + Err(e) => AgentResponse::Err { + message: format!("{e:#}"), + }, + } +} + +/// Submit-time half of the PR-merge flow: fetch the PR head sha from the +/// forge, queue the approval row, and emit the `approval_added` event so the +/// dashboard shows the pending card immediately. +/// +/// The PR head sha is stored as `fetched_sha` on the approval row — the +/// "reviewed sha" the approve handler (`run_merge_config_pr`) drift-gates +/// against before doing anything irreversible. Unlike `submit_apply_commit` +/// this does NOT fetch the commit into the applied repo at submission time +/// (that happens inside the approve handler, step 2, after the drift check). +/// No flake pre-flight either — eval-verify happens at approval time too. +async fn submit_merge_config_pr( + coord: &Arc, + agent: &str, + pr_number: u64, + description: Option<&str>, + submitter: &str, +) -> anyhow::Result { + let applied_dir = crate::paths::applied_dir(agent); + if !applied_dir.join(".git").exists() { + anyhow::bail!( + "applied repo missing for agent '{agent}' (expected at {}) — \ + merge_config_pr requires the agent to be fully provisioned; \ + use request_apply_commit for the first config deploy", + applied_dir.display() + ); + } + let repo = crate::forge::config_repo(agent); + // Fetch the current PR head sha — becomes the "reviewed" sha. + let sha = crate::forge::pr_head_sha(&repo, pr_number) + .await + .map_err(|e| anyhow::anyhow!("fetch PR head sha for {agent} PR #{pr_number}: {e}"))?; + let id = coord + .approvals + .submit_kind( + agent, + hive_sh4re::ApprovalKind::MergeConfigPr, + &pr_number.to_string(), + description, + submitter, + ) + .map_err(|e| anyhow::anyhow!("queue merge_config_pr approval row: {e:#}"))?; + coord + .approvals + .set_fetched_sha(id, &sha) + .map_err(|e| anyhow::anyhow!("persist fetched_sha: {e:#}"))?; + let sha_short = sha[..sha.len().min(12)].to_owned(); + coord.emit_approval_added(crate::coordinator::ApprovalAdded { + id, + agent, + approval_kind: "merge_config_pr", + sha_short: Some(sha_short), + diff: None, // diff is not pre-computed; the dashboard fetches it on demand + description: description.map(str::to_owned), + pr_number: Some(pr_number), + }); + Ok(id) +} + /// `request_apply_commit` takes a commit SHA only — not a branch or /// tag name. A branch is mutable; pinning the proposal to a concrete /// sha keeps "what the manager asked to deploy" unambiguous and means diff --git a/hive-c0re/src/socket_server/mod.rs b/hive-c0re/src/socket_server/mod.rs index e88e6de8..fb3723da 100644 --- a/hive-c0re/src/socket_server/mod.rs +++ b/hive-c0re/src/socket_server/mod.rs @@ -29,7 +29,8 @@ pub(crate) use schedules::filter_ghost_schedule_targets; pub use schedules::schedule_to_wire_public; use config_approvals::{ - handle_request_apply_commit, handle_request_init_config, handle_request_update_meta_inputs, + handle_request_apply_commit, handle_request_init_config, handle_request_merge_config_pr, + handle_request_update_meta_inputs, }; use lifecycle_handlers::{ handle_kill, handle_list_descendants, handle_restart, handle_start, handle_update, @@ -578,6 +579,23 @@ async fn dispatch(req: &AgentRequest, agent: &str, coord: &Arc) -> ) .await } + AgentRequest::RequestMergeConfigPr { + agent: target_agent, + pr_number, + description, + } => { + if let Some(err) = require_group(agent, "approvals", "request merge_config_pr") { + return err; + } + handle_request_merge_config_pr( + coord, + agent, + target_agent, + *pr_number, + description.as_deref(), + ) + .await + } // Agent-state queries: own subtree is free; other agents + the // hive-wide `"*"` sweep require `QueryAgentState`. AgentRequest::GetLooseEnds { agent: target } => { diff --git a/hive-sh4re/src/lib.rs b/hive-sh4re/src/lib.rs index dd95e010..4c0ca7a8 100644 --- a/hive-sh4re/src/lib.rs +++ b/hive-sh4re/src/lib.rs @@ -788,6 +788,20 @@ pub enum Request { #[serde(default, skip_serializing_if = "Option::is_none")] description: Option, }, + /// *(privileged)* Submit a forge config PR for the operator to review and + /// merge. `agent` is the child whose `agent-configs/` forge repo + /// holds the PR; `pr_number` is the open PR index on that repo. + /// hive-c0re fetches the PR head sha at submission time (the "reviewed" + /// sha for the drift gate) and queues a `MergeConfigPr` approval. On + /// approval, hive-c0re re-verifies the head hasn't drifted, eval-verifies + /// the commit, fast-forwards the forge repo's `main`, marks the PR merged, + /// and rebuilds the agent container. + RequestMergeConfigPr { + agent: String, + pr_number: u64, + #[serde(default, skip_serializing_if = "Option::is_none")] + description: Option, + }, /// *(privileged)* Fetch recent journal lines for a sub-agent container. GetLogs { agent: String, @@ -1201,6 +1215,7 @@ impl ToolGroup { Self::Approvals => &[ "request_init_config", "request_apply_commit", + "request_merge_config_pr", "request_update_meta_inputs", ], Self::Scheduling => &[ @@ -1303,7 +1318,7 @@ impl ToolGroup { "kill, start, restart, update, list_containers — container lifecycle (privileged)" } Self::Approvals => { - "request_init_config, request_apply_commit, request_update_meta_inputs — config change flow (privileged)" + "request_init_config, request_apply_commit, request_merge_config_pr, request_update_meta_inputs — config change flow (privileged)" } Self::Scheduling => { "request_schedule_prompt and related — operator-visible scheduled prompts (privileged)" From d39d05b0b3620cd44f5f23d9c7e9fe19ee13cf31 Mon Sep 17 00:00:00 2001 From: atlas Date: Sat, 11 Jul 2026 09:59:24 +0200 Subject: [PATCH 2/9] docs(#2346): add request_merge_config_pr to conventions.md tool-group table + agent-hierarchy.md Per damocles review comment: conventions.md:328 (canonical group-membership table) and agent-hierarchy.md (privilege scope table) were missing the new tool entry. Both now match ToolGroup::Approvals slice. --- docs/agent-hierarchy.md | 1 + docs/conventions.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/docs/agent-hierarchy.md b/docs/agent-hierarchy.md index 038a52c3..3243afa6 100644 --- a/docs/agent-hierarchy.md +++ b/docs/agent-hierarchy.md @@ -99,6 +99,7 @@ Once enforcement lands the rules collapse into: | `kill` / `start` / `restart` / `update` (any descendant) | any ancestor | | `request_init_config` (spawn a new child) | any agent, child added under self | | `request_apply_commit` (any descendant's config) | any ancestor | +| `request_merge_config_pr` (any descendant's forge config PR) | any ancestor | | `get_logs` (any descendant) | any ancestor | | moderate questions / reminders (cancel any open thread of a descendant) | any ancestor | | `send` / `recv` routing | parent ↔ same-parent siblings ↔ self ↔ descendants; explicit allow-list for anyone else | diff --git a/docs/conventions.md b/docs/conventions.md index 2c43c4af..9cf7e7be 100644 --- a/docs/conventions.md +++ b/docs/conventions.md @@ -325,7 +325,7 @@ binary flavor. | `inbox` | `get_loose_ends`, `cancel_loose_end`, `remind`, `request_next_turn` | | `execution` | vestigial — `mcp__bash__run` / `mcp__bash__status` are always available unconditionally via `extraMcpServers`; this group's entries expand to non-existent `mcp__hyperhive__run` / `mcp__hyperhive__status` and have no effect. See `docs/tools/bash.md`. | | `lifecycle` | `kill`, `start`, `restart`, `update` *(privileged)* | -| `approvals` | `request_init_config`, `request_apply_commit`, `request_update_meta_inputs` *(privileged)* | +| `approvals` | `request_init_config`, `request_apply_commit`, `request_merge_config_pr`, `request_update_meta_inputs` *(privileged)* | | `scheduling` | `request_schedule_prompt`, `fire_schedule_now`, `cancel_schedule`, `edit_schedule`, `list_schedules` *(privileged)* | | `diagnostics` | `get_logs` *(privileged)* | From 96eda4ed6b3a88f6a5629fc8a823137ac33634d5 Mon Sep 17 00:00:00 2001 From: atlas Date: Sat, 11 Jul 2026 10:27:20 +0200 Subject: [PATCH 3/9] fix(#2375): pr_is_open state check at submission + atomic fetched_sha INSERT MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two hardening items from argus's review of #2374: 1. PR state check at submission: - Add `pr_is_open(repo, pr)` to forge/pr_merge.rs using `repo_get_pull_request` + `StateType` — early error if the PR is already closed or merged instead of queuing a card that fails later - Call it in `submit_merge_config_pr` before fetching the head sha 2. Atomic fetched_sha INSERT: - Add `fetched_sha: Option<&str>` to `Approvals::submit_kind` so the sha can be included in the INSERT rather than a follow-up UPDATE - MergeConfigPr already knows the sha before inserting the row (pr_head_sha runs first) → pass `Some(&sha)`, drop the separate `set_fetched_sha` call → truly atomic - ApplyCommit still needs two writes (sha resolved by git_fetch_to_tag after the row exists) → pass `None`, `set_fetched_sha` unchanged - All other callers (InitConfig, Spawn, UpdateMetaInputs, SchedulePrompt) pass `None` — no behavioural change - Add `fetched_sha_in_insert_is_readable_via_get` test covering the MergeConfigPr path --- hive-c0re/src/dashboard/misc_api.rs | 1 + hive-c0re/src/forge/mod.rs | 2 +- hive-c0re/src/forge/pr_merge.rs | 26 +++++++- hive-c0re/src/server.rs | 1 + .../src/socket_server/config_approvals.rs | 22 +++++-- hive-c0re/src/socket_server/schedules.rs | 1 + hive-c0re/src/stores/approvals.rs | 64 ++++++++++++++++--- 7 files changed, 102 insertions(+), 15 deletions(-) diff --git a/hive-c0re/src/dashboard/misc_api.rs b/hive-c0re/src/dashboard/misc_api.rs index d4c8d175..eb8fcadf 100644 --- a/hive-c0re/src/dashboard/misc_api.rs +++ b/hive-c0re/src/dashboard/misc_api.rs @@ -196,6 +196,7 @@ pub(super) async fn post_request_spawn( "", None, "operator", + None, ) { Ok(id) => { tracing::info!(%id, %name, "operator: spawn approval queued via dashboard"); diff --git a/hive-c0re/src/forge/mod.rs b/hive-c0re/src/forge/mod.rs index 167e631f..78da1236 100644 --- a/hive-c0re/src/forge/mod.rs +++ b/hive-c0re/src/forge/mod.rs @@ -10,7 +10,7 @@ mod users; pub use pr_merge::{ ForgeMergeError, config_repo, fetch_pr_head_into_applied, ff_push_to_main, mark_pr_merged, - pr_head_sha, + pr_head_sha, pr_is_open, }; pub use repos::{ create_agent_repo, ensure_config_repo, ensure_knowledge_repo, ensure_meta_remote, ensure_repo, diff --git a/hive-c0re/src/forge/pr_merge.rs b/hive-c0re/src/forge/pr_merge.rs index ca268140..865cce5c 100644 --- a/hive-c0re/src/forge/pr_merge.rs +++ b/hive-c0re/src/forge/pr_merge.rs @@ -5,7 +5,7 @@ use anyhow::Context; use forgejo_api::ForgejoError; -use forgejo_api::structs::{MergePullRequestOption, MergePullRequestOptionDo}; +use forgejo_api::structs::{MergePullRequestOption, MergePullRequestOptionDo, StateType}; use super::{CONFIG_ORG, api, core_token, forge_git_url}; @@ -112,6 +112,30 @@ pub async fn pr_head_sha(repo: &str, pr: u64) -> Result Ok(sha.to_string()) } +/// Check whether PR `pr` on `repo` is still open. Returns `Ok(true)` if +/// open, `Ok(false)` if closed or merged, or an error on transport failure. +/// +/// Called at submission time to give an early, actionable error rather than +/// queuing an approval card that will fail later in the approve handler. +/// +/// # Errors +/// `Other` on transport failure or a missing/malformed PR response. +pub async fn pr_is_open(repo: &str, pr: u64) -> Result { + let token = core_token() + .ok_or_else(|| ForgeMergeError::Other(anyhow::anyhow!("forge core token absent")))?; + let (owner, name) = repo.split_once('/').ok_or_else(|| { + ForgeMergeError::Other(anyhow::anyhow!("forge repo `{repo}` is not owner/name")) + })?; + let index = i64::try_from(pr) + .map_err(|_| ForgeMergeError::Other(anyhow::anyhow!("PR index {pr} overflows i64")))?; + let client = api(&token).map_err(ForgeMergeError::Other)?; + let pull = client + .repo_get_pull_request(owner, name, index) + .await + .map_err(|e| ForgeMergeError::Other(anyhow::Error::from(e).context("GET pull request")))?; + Ok(pull.state == Some(StateType::Open)) +} + /// Full `owner/name` path of an agent's config repo on the forge — the /// `agent-configs` org mirror that the PR-merge flow reads + fast-forwards. pub fn config_repo(agent: &str) -> String { diff --git a/hive-c0re/src/server.rs b/hive-c0re/src/server.rs index f43da1db..ed7e589a 100644 --- a/hive-c0re/src/server.rs +++ b/hive-c0re/src/server.rs @@ -86,6 +86,7 @@ async fn dispatch(req: &HostRequest, coord: Arc) -> HostResponse { "", None, "operator", + None, )?; tracing::info!(%id, %name, "spawn approval queued"); HostResponse::success() diff --git a/hive-c0re/src/socket_server/config_approvals.rs b/hive-c0re/src/socket_server/config_approvals.rs index 1516a4cf..76ede8a0 100644 --- a/hive-c0re/src/socket_server/config_approvals.rs +++ b/hive-c0re/src/socket_server/config_approvals.rs @@ -83,6 +83,7 @@ pub(super) fn handle_request_update_meta_inputs( &commit_ref, description, requester, + None, ) .map_err(|e| anyhow::anyhow!("{e:#}")) { @@ -162,7 +163,21 @@ async fn submit_merge_config_pr( ); } let repo = crate::forge::config_repo(agent); + // Verify the PR is still open before queueing an approval that would + // fail at approve time anyway (a closed/merged PR has no live head ref + // for the drift gate to compare against). + if !crate::forge::pr_is_open(&repo, pr_number) + .await + .map_err(|e| anyhow::anyhow!("check PR state for {agent} PR #{pr_number}: {e}"))? + { + anyhow::bail!( + "PR #{pr_number} on {repo} is closed or already merged — \ + request_merge_config_pr requires an open PR" + ); + } // Fetch the current PR head sha — becomes the "reviewed" sha. + // Submitted together with the approval row (atomic single INSERT) so a + // crash between submit and set_fetched_sha cannot leave a stranded row. let sha = crate::forge::pr_head_sha(&repo, pr_number) .await .map_err(|e| anyhow::anyhow!("fetch PR head sha for {agent} PR #{pr_number}: {e}"))?; @@ -174,12 +189,9 @@ async fn submit_merge_config_pr( &pr_number.to_string(), description, submitter, + Some(&sha), // atomic: sha inserted with the row, not in a separate UPDATE ) .map_err(|e| anyhow::anyhow!("queue merge_config_pr approval row: {e:#}"))?; - coord - .approvals - .set_fetched_sha(id, &sha) - .map_err(|e| anyhow::anyhow!("persist fetched_sha: {e:#}"))?; let sha_short = sha[..sha.len().min(12)].to_owned(); coord.emit_approval_added(crate::coordinator::ApprovalAdded { id, @@ -249,6 +261,7 @@ pub(crate) fn submit_init_config( // parent); it's also the submitter the approval events route // back to. No declared parent = operator-initiated path. parent.unwrap_or("operator"), + None, // no sha for InitConfig ) .map_err(|e| anyhow::anyhow!("queue approval row: {e:#}"))?; tracing::info!(%id, %name, "init_config approval queued"); @@ -309,6 +322,7 @@ pub(crate) async fn submit_apply_commit( commit_ref, description, submitter, + None, // sha resolved after git_fetch_to_tag below; set via set_fetched_sha ) .map_err(|e| anyhow::anyhow!("queue approval row: {e:#}"))?; let tag = format!("proposal/{id}"); diff --git a/hive-c0re/src/socket_server/schedules.rs b/hive-c0re/src/socket_server/schedules.rs index a8aa0783..92f9416a 100644 --- a/hive-c0re/src/socket_server/schedules.rs +++ b/hive-c0re/src/socket_server/schedules.rs @@ -62,6 +62,7 @@ pub(super) fn handle_request_schedule_prompt( &commit_ref, payload.description.as_deref(), requester, + None, ) { Ok(id) => id, Err(e) => { diff --git a/hive-c0re/src/stores/approvals.rs b/hive-c0re/src/stores/approvals.rs index 338c8f13..cb7e8ce3 100644 --- a/hive-c0re/src/stores/approvals.rs +++ b/hive-c0re/src/stores/approvals.rs @@ -70,6 +70,12 @@ impl Approvals { }) } + /// Insert a new pending approval row. `fetched_sha` may be supplied + /// when the sha is already known at submission time (e.g. `MergeConfigPr` + /// fetches the PR head before inserting), making the insert + sha-set + /// atomic. Pass `None` when the sha is resolved after insertion (e.g. + /// `ApplyCommit`'s `git_fetch_to_tag` step) and call [`set_fetched_sha`] + /// separately. pub fn submit_kind( &self, agent: &str, @@ -77,19 +83,22 @@ impl Approvals { commit_ref: &str, description: Option<&str>, submitter: &str, + fetched_sha: Option<&str>, ) -> Result { let conn = self.conn.lock().unwrap(); conn.execute( "INSERT INTO approvals - (agent, kind, commit_ref, requested_at, status, description, submitter) - VALUES (?1, ?2, ?3, ?4, 'pending', ?5, ?6)", + (agent, kind, commit_ref, requested_at, status, description, submitter, + fetched_sha) + VALUES (?1, ?2, ?3, ?4, 'pending', ?5, ?6, ?7)", params![ agent, kind.as_str(), commit_ref, now_unix(), description, - submitter + submitter, + fetched_sha, ], )?; Ok(conn.last_insert_rowid()) @@ -415,6 +424,7 @@ mod tests { "", Some("scaffold"), "bitburner", + None, ) .expect("submit init_config"); let pending = db @@ -428,11 +438,11 @@ mod tests { #[test] fn mixed_kinds_all_listed() { let (_dir, _path, db) = open_temp(); - db.submit_kind("a", ApprovalKind::ApplyCommit, "deadbeef", None, "a") + db.submit_kind("a", ApprovalKind::ApplyCommit, "deadbeef", None, "a", None) .unwrap(); - db.submit_kind("b", ApprovalKind::Spawn, "", None, "b") + db.submit_kind("b", ApprovalKind::Spawn, "", None, "b", None) .unwrap(); - db.submit_kind("c", ApprovalKind::InitConfig, "", None, "c") + db.submit_kind("c", ApprovalKind::InitConfig, "", None, "c", None) .unwrap(); let pending = db.pending().expect("pending"); assert_eq!(pending.len(), 3, "all three kinds must be visible"); @@ -451,6 +461,7 @@ mod tests { "cafef00d", Some("test"), "bitburner", + None, ) .unwrap(); let row = db.mark_cancelled(id, "manager").expect("cancel"); @@ -470,7 +481,7 @@ mod tests { // final — re-cancelling errors instead of silently overwriting. let (_dir, _path, db) = open_temp(); let id = db - .submit_kind("a", ApprovalKind::Spawn, "deadbeef", None, "a") + .submit_kind("a", ApprovalKind::Spawn, "deadbeef", None, "a", None) .unwrap(); db.mark_cancelled(id, "manager").expect("first cancel"); let err = db @@ -485,7 +496,14 @@ mod tests { // whole list — collect_lenient skips it instead of failing. let (_dir, path, db) = open_temp(); let good = db - .submit_kind("good", ApprovalKind::ApplyCommit, "cafe", None, "good") + .submit_kind( + "good", + ApprovalKind::ApplyCommit, + "cafe", + None, + "good", + None, + ) .unwrap(); let raw = Connection::open(&path).unwrap(); raw.execute( @@ -508,7 +526,14 @@ mod tests { // fall back to the root agent. let (_dir, path, db) = open_temp(); let id = db - .submit_kind("child", ApprovalKind::ApplyCommit, "cafe", None, "parent") + .submit_kind( + "child", + ApprovalKind::ApplyCommit, + "cafe", + None, + "parent", + None, + ) .unwrap(); assert_eq!(db.submitter_of(id).unwrap().as_deref(), Some("parent")); @@ -522,4 +547,25 @@ mod tests { let legacy_id = raw.last_insert_rowid(); assert_eq!(db.submitter_of(legacy_id).unwrap(), None); } + + #[test] + fn fetched_sha_in_insert_is_readable_via_get() { + // `submit_kind` with `Some(sha)` must store it atomically in the + // INSERT — the `get()` row must reflect it without a separate + // `set_fetched_sha` call. This is the MergeConfigPr path. + let (_dir, _path, db) = open_temp(); + let sha = "abc1234567890abc1234567890abc1234567890ab"; + let id = db + .submit_kind( + "janet", + ApprovalKind::MergeConfigPr, + "42", + None, + "ruth", + Some(sha), + ) + .unwrap(); + let row = db.get(id).unwrap().expect("row must exist"); + assert_eq!(row.fetched_sha.as_deref(), Some(sha)); + } } From d5a81f91958a6b5b83d106b31e26d59c433e3042 Mon Sep 17 00:00:00 2001 From: atlas Date: Sat, 11 Jul 2026 10:50:24 +0200 Subject: [PATCH 4/9] feat(#2377): forge-webhook-triggered config-PR merge flow MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace the request_merge_config_pr MCP tool with a Forgejo pull_request webhook on the agent-configs org. Agents now open a config PR normally; hive-c0re auto-queues the MergeConfigPr approval from the webhook event — no extra tool call needed. Changes: - dashboard/webhook.rs: add POST /webhook/config-pr handler - parses Forgejo pull_request payload (opened/synchronize) - strips agent-configs/ prefix to extract agent name - calls submit_merge_config_pr → queues dashboard approval card - always 200 to prevent Forgejo retries; errors logged at warn - dashboard/mod.rs: wire /webhook/config-pr route - forge/mod.rs: add ensure_config_pr_webhook() — idempotent org-level hook registration on agent-configs at startup; CONFIG_ORG now pub(crate) for webhook handler - main.rs: call ensure_config_pr_webhook alongside knowledge webhook - socket_server/config_approvals.rs: drop handle_request_merge_config_pr; make submit_merge_config_pr pub(crate) for webhook handler - socket_server/mod.rs: re-export submit_merge_config_pr; drop dispatch arm - hive-sh4re/src/lib.rs: remove AgentRequest::RequestMergeConfigPr wire type; drop from ToolGroup::Approvals tool list - hive-ag3nt/src/mcp/: drop request_merge_config_pr tool + args struct - docs: update approvals.md (webhook trigger), conventions.md (tool group), agent-hierarchy.md, tools/lifecycle.md Hardening from #2375-merge-config-pr-hardening branch preserved: - pr_is_open check at queue time (rejects closed/merged PRs) - atomic fetched_sha INSERT via submit_kind(fetched_sha: Some(&sha)) Approve-handler machinery unchanged (run_merge_config_pr, ff_push_to_main, fetch_pr_head_into_applied, mark_pr_merged). --- docs/agent-hierarchy.md | 1 - docs/approvals.md | 23 +-- docs/conventions.md | 2 +- docs/tools/lifecycle.md | 19 --- hive-ag3nt/src/mcp/args.rs | 11 -- hive-ag3nt/src/mcp/mod.rs | 45 +----- hive-c0re/src/dashboard/mod.rs | 3 +- hive-c0re/src/dashboard/webhook.rs | 150 +++++++++++++++++- hive-c0re/src/forge/mod.rs | 69 +++++++- hive-c0re/src/main.rs | 17 +- .../src/socket_server/config_approvals.rs | 40 +---- hive-c0re/src/socket_server/mod.rs | 21 +-- hive-sh4re/src/lib.rs | 17 +- libnull.rlib | Bin 0 -> 5442 bytes 14 files changed, 255 insertions(+), 163 deletions(-) create mode 100644 libnull.rlib diff --git a/docs/agent-hierarchy.md b/docs/agent-hierarchy.md index 3243afa6..038a52c3 100644 --- a/docs/agent-hierarchy.md +++ b/docs/agent-hierarchy.md @@ -99,7 +99,6 @@ Once enforcement lands the rules collapse into: | `kill` / `start` / `restart` / `update` (any descendant) | any ancestor | | `request_init_config` (spawn a new child) | any agent, child added under self | | `request_apply_commit` (any descendant's config) | any ancestor | -| `request_merge_config_pr` (any descendant's forge config PR) | any ancestor | | `get_logs` (any descendant) | any ancestor | | moderate questions / reminders (cancel any open thread of a descendant) | any ancestor | | `send` / `recv` routing | parent ↔ same-parent siblings ↔ self ↔ descendants; explicit allow-list for anyone else | diff --git a/docs/approvals.md b/docs/approvals.md index b730db34..dfb6c486 100644 --- a/docs/approvals.md +++ b/docs/approvals.md @@ -111,15 +111,20 @@ kind-specific payload carrier. row (only `ApplyCommit` populates it). See the End-to-end flow above. - `MergeConfigPr` — the PR-based config flow's counterpart to - `ApplyCommit`. `commit_ref` stores the **PR number** (decimal), - and `fetched_sha` is the PR **head sha the operator reviewed**. - On approve, `run_merge_config_pr` re-reads the live PR head and - aborts if it drifted from `fetched_sha` (re-review), then fetches - that head into the applied repo, eval-verifies it, fast-forwards - the forge config repo's `main` to it (the merge), marks the PR - merged (best-effort — `main` is already there), and runs the same - shared deploy tail as `ApplyCommit` (`deploy_applied_target`). - Never a first spawn. + `ApplyCommit`. Triggered automatically: when an agent opens (or + force-pushes) a PR on its `agent-configs/` forge repo, + hive-c0re's `/webhook/config-pr` endpoint receives the Forgejo + pull_request event and queues this approval row. No MCP tool call + needed — the forge PR IS the request. `commit_ref` stores the + **PR number** (decimal), and `fetched_sha` is the PR **head sha + at queue time** (the "reviewed" sha). On approve, + `run_merge_config_pr` re-reads the live PR head and aborts if it + drifted from `fetched_sha` (submitter must push again to + re-trigger), then fetches that head into the applied repo, + eval-verifies it, fast-forwards the forge config repo's `main` to + it (the merge), marks the PR merged (best-effort — `main` is + already there), and runs the same shared deploy tail as + `ApplyCommit` (`deploy_applied_target`). Never a first spawn. - `Spawn` — direct container creation under the default `agent.nix` template. `commit_ref` is empty. Submitted via `HostRequest::RequestSpawn` (operator-gated, the diff --git a/docs/conventions.md b/docs/conventions.md index 9cf7e7be..2c43c4af 100644 --- a/docs/conventions.md +++ b/docs/conventions.md @@ -325,7 +325,7 @@ binary flavor. | `inbox` | `get_loose_ends`, `cancel_loose_end`, `remind`, `request_next_turn` | | `execution` | vestigial — `mcp__bash__run` / `mcp__bash__status` are always available unconditionally via `extraMcpServers`; this group's entries expand to non-existent `mcp__hyperhive__run` / `mcp__hyperhive__status` and have no effect. See `docs/tools/bash.md`. | | `lifecycle` | `kill`, `start`, `restart`, `update` *(privileged)* | -| `approvals` | `request_init_config`, `request_apply_commit`, `request_merge_config_pr`, `request_update_meta_inputs` *(privileged)* | +| `approvals` | `request_init_config`, `request_apply_commit`, `request_update_meta_inputs` *(privileged)* | | `scheduling` | `request_schedule_prompt`, `fire_schedule_now`, `cancel_schedule`, `edit_schedule`, `list_schedules` *(privileged)* | | `diagnostics` | `get_logs` *(privileged)* | diff --git a/docs/tools/lifecycle.md b/docs/tools/lifecycle.md index 2f8490fd..16fe257a 100644 --- a/docs/tools/lifecycle.md +++ b/docs/tools/lifecycle.md @@ -64,24 +64,6 @@ pinned commit. rejected — the approval pins the exact commit). `agent` must be a direct child. Topology-enforced. -### `request_merge_config_pr(agent, pr_number, description?)` - -Submit an open PR on the agent's `agent-configs/` forge repo for -operator review and merge. The PR-based config flow's counterpart to -`request_apply_commit`: instead of pinning a commit sha from the proposed -repo, the submitter references an already-open forge PR. - -hive-c0re fetches the PR head sha at submission time (the "reviewed" sha); -on operator approval it re-checks for drift, eval-verifies the commit, -fast-forwards the forge repo's `main` to the reviewed sha, marks the PR -merged, and rebuilds the agent container. If the PR head moves between -submission and approval the approve handler aborts — the submitter must -re-submit. - -`agent` must be in the caller's subtree. The agent must already be fully -provisioned (applied repo present); this tool is not for first-spawn. -Requires the `approvals` tool group. - ### `request_update_meta_inputs(inputs?, description?)` Queue an approval to run `nix flake update [inputs...]` on the meta @@ -100,7 +82,6 @@ agents after the approval resolves. | `list_containers` | No | All descendants | | `request_init_config` | Yes (InitConfig) | New direct child only | | `request_apply_commit` | Yes (ApplyCommit) | Direct children | -| `request_merge_config_pr` | Yes (MergeConfigPr) | Descendants | | `request_update_meta_inputs` | Yes (MetaUpdate) | Meta flake (global) | ## See also diff --git a/hive-ag3nt/src/mcp/args.rs b/hive-ag3nt/src/mcp/args.rs index f860f4a6..007504cf 100644 --- a/hive-ag3nt/src/mcp/args.rs +++ b/hive-ag3nt/src/mcp/args.rs @@ -222,17 +222,6 @@ pub struct RequestApplyCommitArgs { pub description: Option, } -#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] -pub struct RequestMergeConfigPrArgs { - /// Logical agent name whose `agent-configs/` forge repo holds the PR. - pub agent: String, - /// Open PR index on the `agent-configs/` repo. - pub pr_number: u64, - /// Optional description shown on the dashboard approval card. - #[serde(default)] - pub description: Option, -} - #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct UpdateMetaInputsArgs { /// Flake input names to update (e.g. `["bitburner-agent", "nixpkgs"]`). diff --git a/hive-ag3nt/src/mcp/mod.rs b/hive-ag3nt/src/mcp/mod.rs index aa802296..563ce71f 100644 --- a/hive-ag3nt/src/mcp/mod.rs +++ b/hive-ag3nt/src/mcp/mod.rs @@ -29,8 +29,8 @@ pub use args::{ AckUntilArgs, AgentGetLooseEndsArgs, AnswerArgs, AskArgs, CancelLooseEndArgs, CancelScheduleArgs, CreateRepoArgs, EditScheduleArgs, FireScheduleNowArgs, GetAgentMetaArgs, GetHostJournalArgs, GetLogsArgs, KillArgs, RecvArgs, RemindArgs, RequestApplyCommitArgs, - RequestInitConfigArgs, RequestMergeConfigPrArgs, RequestSchedulePromptArgs, RestartArgs, - SendArgs, SetStatusArgs, StartArgs, UpdateArgs, UpdateMetaInputsArgs, + RequestInitConfigArgs, RequestSchedulePromptArgs, RestartArgs, SendArgs, SetStatusArgs, + StartArgs, UpdateArgs, UpdateMetaInputsArgs, }; pub use render::{ IDLE_WAIT_HINT, REDELIVERY_HINT, annotate_retries, format_ack, format_agent_meta, format_recv, @@ -757,47 +757,6 @@ impl AgentServer { .await } - // IMPORTANT: this tool is only available when the `approvals` tool group - // is configured for the agent. hive-c0re enforces both the tool-group check - // and topology: the target must be in the caller's subtree. - #[tool( - description = "Submit an open forge PR on `agent-configs/` for the operator \ - to review and merge into the agent's running config. Requires the `approvals` \ - tool group. `agent` must be in this agent's subtree. `pr_number` is the PR \ - index on the `agent-configs/` repo. hive-c0re fetches the PR head sha \ - at submission time (the drift-gate sha); if the PR head moves before the \ - operator approves, the approve handler aborts without making any changes — the \ - submitter must re-submit. On approval hive-c0re eval-verifies the head, \ - fast-forwards the forge repo's `main`, marks the PR merged, and rebuilds the \ - agent container." - )] - async fn request_merge_config_pr( - &self, - Parameters(args): Parameters, - ) -> String { - let log = format!("{args:?}"); - let agent = args.agent.clone(); - let pr_number = args.pr_number; - run_tool_envelope("request_merge_config_pr", log, async move { - let (resp, retries) = self - .dispatch(hive_sh4re::Request::RequestMergeConfigPr { - agent: args.agent, - pr_number: args.pr_number, - description: args.description, - }) - .await; - annotate_retries( - format_ack( - resp, - "request_merge_config_pr", - format!("merge_config_pr approval queued for {agent} PR #{pr_number}"), - ), - retries, - ) - }) - .await - } - // IMPORTANT: this tool is only available when the `lifecycle` tool group // is granted to this agent. hive-c0re enforces the topology check // server-side: the call is rejected unless `name` is a direct child. diff --git a/hive-c0re/src/dashboard/mod.rs b/hive-c0re/src/dashboard/mod.rs index 60facd1f..dd2ab2ca 100644 --- a/hive-c0re/src/dashboard/mod.rs +++ b/hive-c0re/src/dashboard/mod.rs @@ -160,9 +160,10 @@ pub async fn serve(port: u16, coord: Arc) -> Result<()> { post(schedules::post_rebuild_queue_cancel), ) .route("/webhook/knowledge", post(webhook::post_webhook_knowledge)) + .route("/webhook/config-pr", post(webhook::post_webhook_config_pr)) // Backend routes — the frontend calls these `/api/` paths. The // transitional bare top-level aliases were removed once the - // frontend migrated. `/webhook/knowledge` keeps its own prefix + // frontend migrated. `/webhook/*` keeps its own prefix // (forge-driven, not the SPA). .route("/api/approve/{id}", post(approvals::post_approve)) .route("/api/deny/{id}", post(approvals::post_deny)) diff --git a/hive-c0re/src/dashboard/webhook.rs b/hive-c0re/src/dashboard/webhook.rs index a10a4328..35afc781 100644 --- a/hive-c0re/src/dashboard/webhook.rs +++ b/hive-c0re/src/dashboard/webhook.rs @@ -1,15 +1,27 @@ -//! Forgejo push-webhook endpoint for the `internal/knowledge` repo. +//! Forgejo webhook endpoints. //! -//! Loopback-only; on a push to `main` of the knowledge repo it triggers a -//! read-only `git pull` on the local clone so agents see up-to-date -//! documents on their next turn. +//! - **`/webhook/knowledge`** — push events on `internal/knowledge` trigger a +//! `git pull` on the local clone so agents see up-to-date docs. +//! - **`/webhook/config-pr`** — pull_request events on any `agent-configs/*` +//! repo queue a [`hive_sh4re::ApprovalKind::MergeConfigPr`] approval row +//! so the operator can review + approve the merge from the dashboard. +//! +//! Both endpoints are loopback-only (the axum listener binds +//! `127.0.0.1:`) and have no signature verification (the risk is low: +//! loopback access implies host compromise already, and the config-PR path +//! still requires the operator to approve on the dashboard). use axum::{ + extract::State, http::StatusCode, response::{IntoResponse, Response}, }; use serde::Deserialize; +use super::AppState; + +// ── knowledge webhook ────────────────────────────────────────────────────────── + /// Minimal Forgejo push-webhook payload — only the fields we care about. #[derive(Deserialize)] pub(super) struct PushWebhookPayload { @@ -62,3 +74,133 @@ pub(super) async fn post_webhook_knowledge( }); (StatusCode::OK, "ok").into_response() } + +// ── config-PR webhook ────────────────────────────────────────────────────────── + +/// Minimal Forgejo pull_request-webhook payload. +/// +/// Forgejo fires this for actions: `opened`, `closed`, `reopened`, +/// `synchronize`, `assigned`, `unassigned`, `label_updated`, +/// `label_cleared`, `milestoned`, `demilestoned`, `review_requested`, +/// `review_request_removed`, `auto_merge_enabled`, `auto_merge_disabled`. +/// We only act on `opened` and `synchronize`. +#[derive(Deserialize)] +pub(super) struct PrWebhookPayload { + /// What triggered this event (`opened`, `closed`, `synchronize`, …). + action: Option, + /// PR index on the repo. + number: Option, + pull_request: Option, + repository: Option, +} + +#[derive(Deserialize)] +struct PrWebhookPr { + head: Option, +} + +#[derive(Deserialize)] +struct PrWebhookHead { + sha: Option, +} + +#[derive(Deserialize)] +struct PrWebhookRepo { + full_name: Option, +} + +/// POST `/webhook/config-pr` — Forgejo pull_request webhook for +/// `agent-configs/*` repos. +/// +/// On `opened` or `synchronize` for an `agent-configs/` PR: +/// fetches the current PR head sha, queues a `MergeConfigPr` approval row, +/// and emits the `ApprovalAdded` event so the dashboard card appears +/// immediately. +/// +/// All other actions (closed, label changes, etc.) are silently ignored — +/// the operator can deny a pending approval if the PR is later closed. +/// +/// Always returns HTTP 200 (even on queue failure) so Forgejo does not +/// retry the delivery. Failures are logged at `warn` level. +/// +/// Expected Forgejo webhook configuration: +/// - URL: `http://127.0.0.1:/webhook/config-pr` +/// - Content type: `application/json` +/// - Events: "Pull Request" only +/// - Organisation: `agent-configs` (org-level hook covers all config repos) +/// +/// hive-c0re registers this hook automatically at startup via +/// [`crate::forge::ensure_config_pr_webhook`]. +pub(super) async fn post_webhook_config_pr( + State(state): State, + axum::extract::Json(payload): axum::extract::Json, +) -> Response { + let action = payload.action.as_deref().unwrap_or(""); + // Only act on newly-opened or force-updated PRs. + if action != "opened" && action != "synchronize" { + tracing::debug!(action, "webhook/config-pr: ignoring action"); + return (StatusCode::OK, "ignored").into_response(); + } + + let full_name = payload + .repository + .as_ref() + .and_then(|r| r.full_name.as_deref()) + .unwrap_or(""); + + // Expect `agent-configs/`. + let agent = match full_name.strip_prefix(&format!("{}/", crate::forge::CONFIG_ORG)) { + Some(name) if !name.is_empty() && !name.contains('/') => name, + _ => { + tracing::debug!( + full_name, + "webhook/config-pr: ignoring non-config-repo event" + ); + return (StatusCode::OK, "ignored").into_response(); + } + }; + + let pr_number = match payload.number { + Some(n) if n > 0 => n, + _ => { + tracing::warn!(full_name, "webhook/config-pr: missing or zero PR number"); + return (StatusCode::OK, "ignored").into_response(); + } + }; + + // The payload already carries the head sha — use it as an early hint for + // logging, but the canonical sha comes from `submit_merge_config_pr`'s + // fresh forge API call so we don't trust a potentially-stale payload sha. + let payload_sha = payload + .pull_request + .as_ref() + .and_then(|pr| pr.head.as_ref()) + .and_then(|h| h.sha.as_deref()) + .unwrap_or(""); + + tracing::info!( + %full_name, %agent, %pr_number, %payload_sha, %action, + "webhook/config-pr: queuing MergeConfigPr approval" + ); + + // Queue the approval. The description surfaces the action and PR number + // on the dashboard card so the operator has context without opening the + // forge PR. + let description = format!("PR #{pr_number} on {full_name} ({action})"); + if let Err(e) = crate::socket_server::submit_merge_config_pr( + &state.coord, + agent, + pr_number, + Some(&description), + "forge", // submitter — identifies the webhook path in the audit trail + ) + .await + { + tracing::warn!( + %agent, %pr_number, error = ?e, + "webhook/config-pr: failed to queue MergeConfigPr approval" + ); + } + + (StatusCode::OK, "ok").into_response() +} diff --git a/hive-c0re/src/forge/mod.rs b/hive-c0re/src/forge/mod.rs index 78da1236..3a98c5f4 100644 --- a/hive-c0re/src/forge/mod.rs +++ b/hive-c0re/src/forge/mod.rs @@ -69,7 +69,7 @@ pub(crate) fn forge_git_url(token: &str, repo: &str) -> String { /// reach *another* agent's config. `main` is fast-forward-only — hive-c0re /// never force-pushes; the `push_config` mirror runs best-effort until the /// PR-merge flow retires it. -const CONFIG_ORG: &str = "agent-configs"; +pub(crate) const CONFIG_ORG: &str = "agent-configs"; /// Forgejo org hosting the operator-curated shared docs/skills repo /// that every agent gets read-only access to. Agents use it as a /// common reference without the operator having to bake content into @@ -294,3 +294,70 @@ pub async fn ensure_all() { sync_agent(name, core_token.as_deref()).await; } } + +/// Ensure a Forgejo pull_request org-webhook for `agent-configs` exists and +/// points at hive-c0re's `/webhook/config-pr` endpoint. Idempotent — lists +/// existing hooks first and skips creation when one is already targeting the +/// correct URL. `dashboard_port` is the TCP port hive-c0re's dashboard listens +/// on (default 7000); the webhook URL is +/// `http://127.0.0.1:/webhook/config-pr`. +/// +/// An org-level hook covers every repo in `agent-configs` automatically, +/// so no per-repo setup is needed as new agents are provisioned. +/// +/// Called at startup alongside `knowledge::ensure_webhook`. No-op when the +/// core token is absent (forge not yet provisioned). +pub async fn ensure_config_pr_webhook(core_token: &str, dashboard_port: u16) -> Result<()> { + use forgejo_api::structs::{CreateHookOption, CreateHookOptionConfig, CreateHookOptionType}; + use std::collections::BTreeMap; + + const HTTP_TIMEOUT: std::time::Duration = std::time::Duration::from_secs(10); + + let target_url = format!("http://127.0.0.1:{dashboard_port}/webhook/config-pr"); + let client = api(core_token)?; + + // List existing org hooks — skip creation if ours is already there. + // Best-effort: a listing failure falls through to the create attempt. + let listed = tokio::time::timeout(HTTP_TIMEOUT, client.org_list_hooks(CONFIG_ORG).all()) + .await + .map_err(anyhow::Error::from) + .and_then(|r| r.map_err(anyhow::Error::from)); + match listed { + Ok(hooks) => { + let already_exists = hooks.iter().any(|h| { + h.config + .as_ref() + .and_then(|c| c.get("url")) + .map(String::as_str) + == Some(target_url.as_str()) + }); + if already_exists { + tracing::debug!(%target_url, "forge: config-pr webhook already configured"); + return Ok(()); + } + } + Err(e) => { + tracing::debug!(error = %e, "forge: listing config-pr hooks failed; attempting create"); + } + } + + let hook = CreateHookOption { + active: Some(true), + authorization_header: None, + branch_filter: None, + config: CreateHookOptionConfig { + content_type: "json".to_owned(), + url: Url::parse(&target_url).context("parse config-pr webhook target url")?, + additional: BTreeMap::new(), + }, + events: Some(vec!["pull_request".to_owned()]), + r#type: CreateHookOptionType::Forgejo, + }; + tokio::time::timeout(HTTP_TIMEOUT, client.org_create_hook(CONFIG_ORG, hook)) + .await + .map_err(anyhow::Error::from) + .and_then(|r| r.map_err(anyhow::Error::from)) + .with_context(|| format!("create config-pr webhook on org {CONFIG_ORG}"))?; + tracing::info!(%target_url, "forge: config-pr webhook created on org {CONFIG_ORG}"); + Ok(()) +} diff --git a/hive-c0re/src/main.rs b/hive-c0re/src/main.rs index 8efa4fcc..07421b5b 100644 --- a/hive-c0re/src/main.rs +++ b/hive-c0re/src/main.rs @@ -303,17 +303,22 @@ async fn cmd_serve( tokio::spawn(async move { forge::ensure_all().await; }); - // Knowledge webhook setup: ensure the Forgejo push webhook for - // `internal/knowledge` exists so `pull()` fires on merge. Runs - // after forge::ensure_all so the core token + repo are present. + // Webhook setup: ensure Forgejo webhooks are registered for both + // `internal/knowledge` (push → git pull) and the `agent-configs` org + // (pull_request → queue MergeConfigPr approval). Both run after + // forge::ensure_all so the core token + repos + org are present. // No-op when the core token or forge are absent. let webhook_port = dashboard_port; tokio::spawn(async move { - if let Some(token) = forge::core_token() - && let Err(e) = knowledge::ensure_webhook(&token, webhook_port).await - { + let Some(token) = forge::core_token() else { + return; + }; + if let Err(e) = knowledge::ensure_webhook(&token, webhook_port).await { tracing::warn!(error = ?e, "knowledge: ensure_webhook failed"); } + if let Err(e) = forge::ensure_config_pr_webhook(&token, webhook_port).await { + tracing::warn!(error = ?e, "forge: ensure_config_pr_webhook failed"); + } }); // Knowledge periodic pull: hourly fallback in case the webhook is // missed (e.g. hive-c0re was down during a push). First fires at diff --git a/hive-c0re/src/socket_server/config_approvals.rs b/hive-c0re/src/socket_server/config_approvals.rs index 76ede8a0..37bdc2e7 100644 --- a/hive-c0re/src/socket_server/config_approvals.rs +++ b/hive-c0re/src/socket_server/config_approvals.rs @@ -1,8 +1,13 @@ //! Config-approval request handlers: `RequestInitConfig` / -//! `RequestApplyCommit` / `RequestMergeConfigPr` / `RequestUpdateMetaInputs`, +//! `RequestApplyCommit` / `RequestUpdateMetaInputs`, //! plus the shared submit helpers (`submit_init_config` / `submit_apply_commit` //! / `submit_merge_config_pr`) and the commit-sha shape check //! (`validate_commit_ref`). +//! +//! `submit_merge_config_pr` is called from the dashboard webhook handler +//! (`dashboard::webhook`) — agents no longer need an MCP tool for this; +//! opening a config PR on `agent-configs/` is enough to trigger +//! hive-c0re's webhook-driven queue path. use std::sync::Arc; @@ -107,35 +112,6 @@ pub(super) fn handle_request_update_meta_inputs( AgentResponse::Ok } -/// `RequestMergeConfigPr` — queue a `MergeConfigPr` approval for a PR on an -/// agent's `agent-configs/` forge repo. The target must be in the -/// caller's subtree. hive-c0re fetches the PR head sha at submission time; -/// that sha is stored as `fetched_sha` and forms the drift gate in the -/// approve handler: if the PR head moves between submission and approval, -/// the approve handler aborts without making any changes. -pub(super) async fn handle_request_merge_config_pr( - coord: &Arc, - agent: &str, - target_agent: &str, - pr_number: u64, - description: Option<&str>, -) -> AgentResponse { - if let Some(err) = super::require_descendant(agent, target_agent, "request_merge_config_pr for") - { - return err; - } - tracing::info!(%agent, %target_agent, %pr_number, "request_merge_config_pr"); - match submit_merge_config_pr(coord, target_agent, pr_number, description, agent).await { - Ok(id) => { - tracing::info!(%id, %target_agent, %pr_number, "merge_config_pr approval queued"); - AgentResponse::Ok - } - Err(e) => AgentResponse::Err { - message: format!("{e:#}"), - }, - } -} - /// Submit-time half of the PR-merge flow: fetch the PR head sha from the /// forge, queue the approval row, and emit the `approval_added` event so the /// dashboard shows the pending card immediately. @@ -146,7 +122,7 @@ pub(super) async fn handle_request_merge_config_pr( /// this does NOT fetch the commit into the applied repo at submission time /// (that happens inside the approve handler, step 2, after the drift check). /// No flake pre-flight either — eval-verify happens at approval time too. -async fn submit_merge_config_pr( +pub(crate) async fn submit_merge_config_pr( coord: &Arc, agent: &str, pr_number: u64, @@ -172,7 +148,7 @@ async fn submit_merge_config_pr( { anyhow::bail!( "PR #{pr_number} on {repo} is closed or already merged — \ - request_merge_config_pr requires an open PR" + merge_config_pr requires an open PR" ); } // Fetch the current PR head sha — becomes the "reviewed" sha. diff --git a/hive-c0re/src/socket_server/mod.rs b/hive-c0re/src/socket_server/mod.rs index fb3723da..9f0b78e6 100644 --- a/hive-c0re/src/socket_server/mod.rs +++ b/hive-c0re/src/socket_server/mod.rs @@ -25,12 +25,12 @@ mod lifecycle_handlers; mod reminders; mod schedules; +pub(crate) use config_approvals::{submit_init_config, submit_merge_config_pr}; pub(crate) use schedules::filter_ghost_schedule_targets; pub use schedules::schedule_to_wire_public; use config_approvals::{ - handle_request_apply_commit, handle_request_init_config, handle_request_merge_config_pr, - handle_request_update_meta_inputs, + handle_request_apply_commit, handle_request_init_config, handle_request_update_meta_inputs, }; use lifecycle_handlers::{ handle_kill, handle_list_descendants, handle_restart, handle_start, handle_update, @@ -579,23 +579,6 @@ async fn dispatch(req: &AgentRequest, agent: &str, coord: &Arc) -> ) .await } - AgentRequest::RequestMergeConfigPr { - agent: target_agent, - pr_number, - description, - } => { - if let Some(err) = require_group(agent, "approvals", "request merge_config_pr") { - return err; - } - handle_request_merge_config_pr( - coord, - agent, - target_agent, - *pr_number, - description.as_deref(), - ) - .await - } // Agent-state queries: own subtree is free; other agents + the // hive-wide `"*"` sweep require `QueryAgentState`. AgentRequest::GetLooseEnds { agent: target } => { diff --git a/hive-sh4re/src/lib.rs b/hive-sh4re/src/lib.rs index 4c0ca7a8..dd95e010 100644 --- a/hive-sh4re/src/lib.rs +++ b/hive-sh4re/src/lib.rs @@ -788,20 +788,6 @@ pub enum Request { #[serde(default, skip_serializing_if = "Option::is_none")] description: Option, }, - /// *(privileged)* Submit a forge config PR for the operator to review and - /// merge. `agent` is the child whose `agent-configs/` forge repo - /// holds the PR; `pr_number` is the open PR index on that repo. - /// hive-c0re fetches the PR head sha at submission time (the "reviewed" - /// sha for the drift gate) and queues a `MergeConfigPr` approval. On - /// approval, hive-c0re re-verifies the head hasn't drifted, eval-verifies - /// the commit, fast-forwards the forge repo's `main`, marks the PR merged, - /// and rebuilds the agent container. - RequestMergeConfigPr { - agent: String, - pr_number: u64, - #[serde(default, skip_serializing_if = "Option::is_none")] - description: Option, - }, /// *(privileged)* Fetch recent journal lines for a sub-agent container. GetLogs { agent: String, @@ -1215,7 +1201,6 @@ impl ToolGroup { Self::Approvals => &[ "request_init_config", "request_apply_commit", - "request_merge_config_pr", "request_update_meta_inputs", ], Self::Scheduling => &[ @@ -1318,7 +1303,7 @@ impl ToolGroup { "kill, start, restart, update, list_containers — container lifecycle (privileged)" } Self::Approvals => { - "request_init_config, request_apply_commit, request_merge_config_pr, request_update_meta_inputs — config change flow (privileged)" + "request_init_config, request_apply_commit, request_update_meta_inputs — config change flow (privileged)" } Self::Scheduling => { "request_schedule_prompt and related — operator-visible scheduled prompts (privileged)" diff --git a/libnull.rlib b/libnull.rlib new file mode 100644 index 0000000000000000000000000000000000000000..a6c2cdcfcb983f046bfa6f83da250d6b7aab7a38 GIT binary patch literal 5442 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 H6i77y_?zfR literal 0 HcmV?d00001 From c4d239145535fd3d860546a8838deea0c7245513 Mon Sep 17 00:00:00 2001 From: atlas Date: Sat, 11 Jul 2026 10:54:33 +0200 Subject: [PATCH 5/9] fix(review): drop libnull.rlib artifact + add Errors doc to ensure_config_pr_webhook - Remove libnull.rlib accidentally committed in previous push (cargo check --no-default-features side-effect); add *.rlib to .gitignore to prevent recurrence - Add # Errors section to ensure_config_pr_webhook doc comment (argus: missing on pub async fn returning Result<()>) --- .gitignore | 1 + hive-c0re/src/forge/mod.rs | 12 ++++++++++++ libnull.rlib | Bin 5442 -> 0 bytes 3 files changed, 13 insertions(+) delete mode 100644 libnull.rlib diff --git a/.gitignore b/.gitignore index a4afea36..b7ce58d6 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,4 @@ /result-* /.tmp /.claude/settings.local.json +*.rlib diff --git a/hive-c0re/src/forge/mod.rs b/hive-c0re/src/forge/mod.rs index 3a98c5f4..b7c6c770 100644 --- a/hive-c0re/src/forge/mod.rs +++ b/hive-c0re/src/forge/mod.rs @@ -307,6 +307,18 @@ pub async fn ensure_all() { /// /// Called at startup alongside `knowledge::ensure_webhook`. No-op when the /// core token is absent (forge not yet provisioned). +/// +/// # Errors +/// +/// Returns an error if: +/// - `dashboard_port` produces a URL that `url::Url::parse` rejects (should +/// never happen for a valid port number). +/// - The Forgejo `org_create_hook` API call fails (transport error, auth +/// failure, or the `agent-configs` org does not exist). +/// - The HTTP call times out (10 s limit). +/// +/// Listing failures are treated as best-effort: they fall through to the +/// create attempt rather than surfacing an error. pub async fn ensure_config_pr_webhook(core_token: &str, dashboard_port: u16) -> Result<()> { use forgejo_api::structs::{CreateHookOption, CreateHookOptionConfig, CreateHookOptionType}; use std::collections::BTreeMap; diff --git a/libnull.rlib b/libnull.rlib deleted file mode 100644 index a6c2cdcfcb983f046bfa6f83da250d6b7aab7a38..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 5442 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 H6i77y_?zfR From 5e1863d2315ec4449ffdb43d33ab7eb2bfad7b1d Mon Sep 17 00:00:00 2001 From: atlas Date: Sat, 11 Jul 2026 10:59:33 +0200 Subject: [PATCH 6/9] fix(#2377): org_list_hooks returns Vec, use .send() not .all() MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit org_list_hooks response type is Vec (no pagination headers), so .all() (which is impl'd for (H, Vec) paginated responses) does not compile. Switch to .send() — the non-paginated call path. repo_list_hooks (used in workers/knowledge.rs) returns (H, Vec) and correctly uses .all(); the org variant is different. --- hive-c0re/src/forge/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hive-c0re/src/forge/mod.rs b/hive-c0re/src/forge/mod.rs index b7c6c770..92f1c4cb 100644 --- a/hive-c0re/src/forge/mod.rs +++ b/hive-c0re/src/forge/mod.rs @@ -330,7 +330,7 @@ pub async fn ensure_config_pr_webhook(core_token: &str, dashboard_port: u16) -> // List existing org hooks — skip creation if ours is already there. // Best-effort: a listing failure falls through to the create attempt. - let listed = tokio::time::timeout(HTTP_TIMEOUT, client.org_list_hooks(CONFIG_ORG).all()) + let listed = tokio::time::timeout(HTTP_TIMEOUT, client.org_list_hooks(CONFIG_ORG).send()) .await .map_err(anyhow::Error::from) .and_then(|r| r.map_err(anyhow::Error::from)); From 18e7c406b0aa4ee769351e580c6c9adccb8c4bfa Mon Sep 17 00:00:00 2001 From: atlas Date: Sat, 11 Jul 2026 11:05:42 +0200 Subject: [PATCH 7/9] fix(#2377): doc_markdown + too_many_lines clippy lints - Backtick-quote `pull_request` in doc comments (4x doc_markdown) - Add #[allow(clippy::too_many_lines)] to server::dispatch (101/100; +1 line from submit_kind fetched_sha param in 5dd0a36f) --- hive-c0re/src/dashboard/webhook.rs | 6 +++--- hive-c0re/src/forge/mod.rs | 2 +- hive-c0re/src/server.rs | 1 + 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/hive-c0re/src/dashboard/webhook.rs b/hive-c0re/src/dashboard/webhook.rs index 35afc781..c5024d5c 100644 --- a/hive-c0re/src/dashboard/webhook.rs +++ b/hive-c0re/src/dashboard/webhook.rs @@ -2,7 +2,7 @@ //! //! - **`/webhook/knowledge`** — push events on `internal/knowledge` trigger a //! `git pull` on the local clone so agents see up-to-date docs. -//! - **`/webhook/config-pr`** — pull_request events on any `agent-configs/*` +//! - **`/webhook/config-pr`** — `pull_request` events on any `agent-configs/*` //! repo queue a [`hive_sh4re::ApprovalKind::MergeConfigPr`] approval row //! so the operator can review + approve the merge from the dashboard. //! @@ -77,7 +77,7 @@ pub(super) async fn post_webhook_knowledge( // ── config-PR webhook ────────────────────────────────────────────────────────── -/// Minimal Forgejo pull_request-webhook payload. +/// Minimal Forgejo `pull_request`-webhook payload. /// /// Forgejo fires this for actions: `opened`, `closed`, `reopened`, /// `synchronize`, `assigned`, `unassigned`, `label_updated`, @@ -109,7 +109,7 @@ struct PrWebhookRepo { full_name: Option, } -/// POST `/webhook/config-pr` — Forgejo pull_request webhook for +/// POST `/webhook/config-pr` — Forgejo `pull_request` webhook for /// `agent-configs/*` repos. /// /// On `opened` or `synchronize` for an `agent-configs/` PR: diff --git a/hive-c0re/src/forge/mod.rs b/hive-c0re/src/forge/mod.rs index 92f1c4cb..112dc5d1 100644 --- a/hive-c0re/src/forge/mod.rs +++ b/hive-c0re/src/forge/mod.rs @@ -295,7 +295,7 @@ pub async fn ensure_all() { } } -/// Ensure a Forgejo pull_request org-webhook for `agent-configs` exists and +/// Ensure a Forgejo `pull_request` org-webhook for `agent-configs` exists and /// points at hive-c0re's `/webhook/config-pr` endpoint. Idempotent — lists /// existing hooks first and skips creation when one is already targeting the /// correct URL. `dashboard_port` is the TCP port hive-c0re's dashboard listens diff --git a/hive-c0re/src/server.rs b/hive-c0re/src/server.rs index ed7e589a..f98f9312 100644 --- a/hive-c0re/src/server.rs +++ b/hive-c0re/src/server.rs @@ -74,6 +74,7 @@ async fn handle(stream: UnixStream, coord: Arc) -> Result<()> { } } +#[allow(clippy::too_many_lines)] async fn dispatch(req: &HostRequest, coord: Arc) -> HostResponse { let result: anyhow::Result = async { Ok(match req { From 949fcf2f1698df7a5ec1ae117a24e427d689083b Mon Sep 17 00:00:00 2001 From: atlas Date: Sat, 11 Jul 2026 11:11:09 +0200 Subject: [PATCH 8/9] fix(#2377): remove unused submit_init_config re-export from socket_server submit_init_config is only called within config_approvals.rs itself; the pub(crate) re-export on mod.rs was unused (clippy unused_imports). --- hive-c0re/src/socket_server/mod.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/hive-c0re/src/socket_server/mod.rs b/hive-c0re/src/socket_server/mod.rs index 9f0b78e6..c71ae5e0 100644 --- a/hive-c0re/src/socket_server/mod.rs +++ b/hive-c0re/src/socket_server/mod.rs @@ -25,7 +25,7 @@ mod lifecycle_handlers; mod reminders; mod schedules; -pub(crate) use config_approvals::{submit_init_config, submit_merge_config_pr}; +pub(crate) use config_approvals::submit_merge_config_pr; pub(crate) use schedules::filter_ghost_schedule_targets; pub use schedules::schedule_to_wire_public; From ad0752822a3571ca46b42a34887fe18a305aeecd Mon Sep 17 00:00:00 2001 From: atlas Date: Sat, 11 Jul 2026 11:51:01 +0200 Subject: [PATCH 9/9] =?UTF-8?q?fix(#2377):=20extract=20handle=5Fagent=5Fst?= =?UTF-8?q?atus=20=E2=80=94=20drop=20clippy::too=5Fmany=5Flines=20allow?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dispatch was 101 lines (1 over limit) due to the AgentStatus arm. Extract it to a dedicated handle_agent_status helper to bring dispatch under the 100-line lint limit without the allow attribute. Per mara review comment on PR #2379. --- hive-c0re/src/server.rs | 36 +++++++++++++++++++----------------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/hive-c0re/src/server.rs b/hive-c0re/src/server.rs index f98f9312..0320467d 100644 --- a/hive-c0re/src/server.rs +++ b/hive-c0re/src/server.rs @@ -74,7 +74,6 @@ async fn handle(stream: UnixStream, coord: Arc) -> Result<()> { } } -#[allow(clippy::too_many_lines)] async fn dispatch(req: &HostRequest, coord: Arc) -> HostResponse { let result: anyhow::Result = async { Ok(match req { @@ -147,22 +146,7 @@ async fn dispatch(req: &HostRequest, coord: Arc) -> HostResponse { HostResponse::dags(dags) } HostRequest::List => HostResponse::list(lifecycle::list().await?), - HostRequest::AgentStatus => { - let rows = crate::container_view::build_all(&coord) - .await - .into_iter() - .map(|v| hive_sh4re::AgentStatusRow { - name: v.name, - running: v.running, - needs_update: v.needs_update, - needs_login: v.needs_login, - deployed_sha: v.deployed_sha, - pending_reminders: v.pending_reminders, - parent: v.parent, - }) - .collect(); - HostResponse::agent_statuses(rows) - } + HostRequest::AgentStatus => handle_agent_status(&coord).await, // The hive domain + per-surface public URLs are injected into // c0re's service env by hive-c0re.nix; surface them so the // operator CLI can fill in this hive's own identity (the @@ -243,6 +227,24 @@ async fn handle_spawn(coord: &Arc, name: &str) -> Result) -> HostResponse { + let rows = crate::container_view::build_all(coord) + .await + .into_iter() + .map(|v| hive_sh4re::AgentStatusRow { + name: v.name, + running: v.running, + needs_update: v.needs_update, + needs_login: v.needs_login, + deployed_sha: v.deployed_sha, + pending_reminders: v.pending_reminders, + parent: v.parent, + }) + .collect(); + HostResponse::agent_statuses(rows) +} + /// Single-agent queue verbs the admin socket exposes. Each submits the /// matching DAG (persisting the `wanted` intent, serializing on the /// agent's lease, with the transient/crash-watch suppression the old