diff --git a/docs/agent-lifecycle/config-changes.md b/docs/agent-lifecycle/config-changes.md index 4293247f..ea41dd98 100644 --- a/docs/agent-lifecycle/config-changes.md +++ b/docs/agent-lifecycle/config-changes.md @@ -47,15 +47,15 @@ No MCP tool exists for config changes: opening the PR IS the request. Otherwise it fetches the config repo's `main`, requires the commit to descend from `applied/main`, fast-forwards `applied/main` to it, and queues a rebuild. A failure before the rebuild (the fetch, or a commit - that doesn't descend) deploys nothing and posts the error as a comment - on the merged PR. An agent with no container on that hive yet ignores + that doesn't descend) deploys nothing and logs `swarm events: rev not + applied; nothing queued` at error, which fires the Grafana alert + `config rev refused`. An agent with no container on that hive yet ignores the commit; its first deploy builds what it seeds. This path runs **no eval-verify**. A merged config that doesn't evaluate or build fails the rebuild, and `applied/main` stays at that commit, so the agent's rebuilds keep failing until a fix merges. A -failed rebuild shows on the hive like any other and isn't commented on -the PR. A merge whose webhook delivery never reaches the controller +failed rebuild shows on the hive like any other. A merge whose webhook delivery never reaches the controller deploys nothing. Creating a brand-new agent is swarm-level (`swarmctl agent create`, diff --git a/docs/integrations/forge.md b/docs/integrations/forge.md index d7186857..ad4555c4 100644 --- a/docs/integrations/forge.md +++ b/docs/integrations/forge.md @@ -77,12 +77,8 @@ forge container: - reseeding a purged `applied` repo, and the meta-update cascade's fast-forward of `applied/main` - seeding `proposed` from `agent-configs/` on first deploy -- finding the PR that merged a config commit the hive refused to deploy - cloning and pulling `/knowledge` -Posting the refusal as a comment on that PR still needs the `core` token, so -only the forge host posts it. - Roll a change to any of this out store host first (its granter writes the controller's grant on `swarm/hives/+/forge/token`), then the controller host, then the hives. A hive that starts before its token exists reads with the diff --git a/hive-c0re/src/actions.rs b/hive-c0re/src/actions.rs index ea3f0548..cc3c074e 100644 --- a/hive-c0re/src/actions.rs +++ b/hive-c0re/src/actions.rs @@ -84,28 +84,6 @@ async fn advance_applied_main( Ok(RevAdvance::Advanced) } -/// Post why the merged commit `rev` of `agent`'s config repo was not deployed -/// onto the PR whose merge produced it. Best-effort: a failure here is logged -/// only. -pub(crate) async fn post_rev_deploy_failure_to_pr(agent: &str, rev: &str, err: &anyhow::Error) { - let repo = crate::forge::config_repo(agent); - let pr = match crate::forge::merged_pr_for_commit(&repo, rev).await { - Ok(pr) => pr, - Err(e) => { - tracing::warn!(%agent, %rev, error = %e, "find the PR that merged a failed deploy's rev failed"); - return; - } - }; - let body = format!( - "## ⚠️ config deploy failed\n\n\ - The merged commit `{rev}` was not deployed:\n\n\ - ```\n{err:#}\n```" - ); - if let Err(e) = crate::forge::post_pr_comment(&repo, pr, &body).await { - tracing::warn!(%agent, %pr, error = ?e, "post rev deploy-failure comment to PR failed"); - } -} - /// Fast-forward `applied/main` to `target` and sync the working tree. /// /// Compare-and-swap, not a bare set: `main` must still be `expected_main`, the diff --git a/hive-c0re/src/forge/mod.rs b/hive-c0re/src/forge/mod.rs index 23c47949..266fdb56 100644 --- a/hive-c0re/src/forge/mod.rs +++ b/hive-c0re/src/forge/mod.rs @@ -5,13 +5,11 @@ //! `internal/docs`, are swarm-controller's. Full design: //! `docs/integrations/forge.md`. -mod pr_comment; mod reader; mod reconcile; mod repos; mod users; -pub use pr_comment::{config_repo, merged_pr_for_commit, post_pr_comment}; pub use reader::reader_token; pub use reconcile::{fetch_forge_main, reconcile_config_apply, reconcile_config_status}; pub use repos::{ @@ -462,13 +460,13 @@ mod tests { async fn a_request_the_forge_never_answers_errors_at_the_deadline() { let started = tokio::time::Instant::now(); let err = bounded( - "post PR comment", + "pending request", std::future::pending::>(), ) .await .expect_err("a pending request must hit the deadline"); assert_eq!(started.elapsed(), API_TIMEOUT); - assert!(err.to_string().contains("post PR comment"), "{err}"); + assert!(err.to_string().contains("pending request"), "{err}"); } /// The request's own result passes through for the caller to classify. diff --git a/hive-c0re/src/forge/pr_comment.rs b/hive-c0re/src/forge/pr_comment.rs deleted file mode 100644 index 3891b95c..00000000 --- a/hive-c0re/src/forge/pr_comment.rs +++ /dev/null @@ -1,60 +0,0 @@ -//! Commenting on an agent's config PRs as the core forge user — how a hive -//! reports a merged config commit it refused to deploy back onto the PR that -//! merged it. Finding that PR only reads, so it uses [`reader_token`]. - -use anyhow::{Context, Result}; - -use super::{CONFIG_ORG, api, bounded, core_token, reader_token}; - -/// Full `owner/name` path of an agent's config repo on the forge. -pub fn config_repo(agent: &str) -> String { - format!("{CONFIG_ORG}/{agent}") -} - -/// The number of the merged PR that put commit `sha` on `repo`'s base branch. -/// -/// # Errors -/// No read token, malformed repo, no such PR, or transport/API failure. -pub async fn merged_pr_for_commit(repo: &str, sha: &str) -> Result { - let reader = reader_token().await.context("no forge read token")?; - let (owner, name) = repo - .split_once('/') - .with_context(|| format!("forge repo `{repo}` is not owner/name"))?; - let pull = bounded( - "GET pull request of commit", - api(reader.value())?.repo_get_commit_pull_request(owner, name, sha), - ) - .await? - .context("GET pull request of commit")?; - pull.number - .and_then(|n| u64::try_from(n).ok()) - .with_context(|| format!("pull request of {sha} in {repo} carries no number")) -} - -/// Post a comment to PR (= issue) `pr` on `repo` as the core forge user. -/// PRs are issues in Forgejo, so the PR number is the issue index. -/// -/// # Errors -/// Absent core token, malformed repo, or transport/API failure. -pub async fn post_pr_comment(repo: &str, pr: u64, body: &str) -> Result<()> { - let token = core_token().context("forge core token absent")?; - let (owner, name) = repo - .split_once('/') - .with_context(|| format!("forge repo `{repo}` is not owner/name"))?; - let index = i64::try_from(pr).with_context(|| format!("PR index {pr} overflows i64"))?; - bounded( - "post PR comment", - api(&token)?.issue_create_comment( - owner, - name, - index, - forgejo_api::structs::CreateIssueCommentOption { - body: body.to_owned(), - updated_at: None, - }, - ), - ) - .await? - .context("post PR comment")?; - Ok(()) -} diff --git a/hive-c0re/src/forge/reader.rs b/hive-c0re/src/forge/reader.rs index 72ddff36..c977ce98 100644 --- a/hive-c0re/src/forge/reader.rs +++ b/hive-c0re/src/forge/reader.rs @@ -30,11 +30,6 @@ impl std::fmt::Debug for ReaderToken { } impl ReaderToken { - /// The token itself, for an API client. - pub fn value(&self) -> &str { - &self.value - } - /// The `http.extraHeader` value for git; see [`basic_auth_header`]. pub fn auth_header(&self) -> String { basic_auth_header(&self.login, &self.value) @@ -121,14 +116,14 @@ mod tests { #[test] fn the_store_wins_over_the_core_file() { let t = pick(Some(stored()), Some("from-the-file".to_owned())).expect("a token"); - assert_eq!(t.value(), "from-the-store"); + assert_eq!(t.value, "from-the-store"); assert_eq!(t.login, "hive-alpha"); } #[test] fn the_core_file_is_the_fallback() { let t = pick(None, Some("from-the-file".to_owned())).expect("a token"); - assert_eq!(t.value(), "from-the-file"); + assert_eq!(t.value, "from-the-file"); assert_eq!(t.login, CORE_LOGIN); } diff --git a/hive-c0re/src/forge/users.rs b/hive-c0re/src/forge/users.rs index ba1b3ded..4f514e61 100644 --- a/hive-c0re/src/forge/users.rs +++ b/hive-c0re/src/forge/users.rs @@ -256,7 +256,7 @@ async fn check_core_token(token: &str) -> CoreTokenCheck { /// Ensure the bootstrap `core` admin user + a token at /// `CORE_TOKEN_PATH`. The token is what hive-c0re uses for forgejo -/// API calls (meta-repo push, collaborator grants, PR comments). +/// API calls (meta-repo push, collaborator grants). /// Returns the token. /// /// Idempotent, but validity-aware: when a token file is already present diff --git a/hive-c0re/src/swarm_status.rs b/hive-c0re/src/swarm_status.rs index d3feb0cf..dee3ef13 100644 --- a/hive-c0re/src/swarm_status.rs +++ b/hive-c0re/src/swarm_status.rs @@ -318,8 +318,9 @@ async fn handle_deploy_request( /// rebuild with it. The exception is `hivectl forge reconcile-config`, which /// does not deploy, so a rev it put in place is skipped too. /// -/// A refusal is commented on the PR that merged `rev`. A failed rebuild is -/// not: it surfaces where every rebuild failure does, on this hive. +/// The refusal's message is matched verbatim by the `hh-config-rev-refused` +/// alert rule in `nix/host-modules/swarm-grafana.nix`; change both together. +/// A failed rebuild surfaces where every rebuild failure does, on this hive. async fn apply_rev(agent: &str, rev: &str) -> bool { match crate::actions::advance_applied_to_rev(agent, rev).await { Ok(crate::actions::RevAdvance::Advanced) => true, @@ -328,8 +329,7 @@ async fn apply_rev(agent: &str, rev: &str) -> bool { false } Err(e) => { - tracing::warn!(%agent, %rev, error = %format!("{e:#}"), "swarm events: rev not applied; nothing queued"); - crate::actions::post_rev_deploy_failure_to_pr(agent, rev, &e).await; + tracing::error!(%agent, %rev, error = %format!("{e:#}"), "swarm events: rev not applied; nothing queued"); false } } diff --git a/nix/host-modules/swarm-grafana.nix b/nix/host-modules/swarm-grafana.nix index 28291551..c0fd3c5e 100644 --- a/nix/host-modules/swarm-grafana.nix +++ b/nix/host-modules/swarm-grafana.nix @@ -1003,6 +1003,20 @@ in for = "15m"; summary = "swarm-controller has failed the agent credential renewal pass on consecutive ticks"; }) + # Logged once per refused deploy request, by + # `apply_rev` in hive-c0re's swarm_status.rs; the window is + # how long the rule stays firing after it. Host journal + # records carry the tracing fields as `F_` and no + # `hive` attribute, so the machine is `_HOSTNAME`. The unit + # filter keeps out agent logs that quote the message. + (logCountRule { + uid = "hh-config-rev-refused"; + title = "config rev refused"; + windowSeconds = 900; + expr = ''_time:15m _SYSTEMD_UNIT:="hive-c0re.service" _msg:"swarm events: rev not applied; nothing queued" | stats by (_HOSTNAME, F_AGENT, F_REV) count() as refusals''; + for = "0s"; + summary = "{{ $labels._HOSTNAME }} refused to deploy merged config commit {{ $labels.F_REV }} of agent {{ $labels.F_AGENT }}; the reason is the `F_ERROR` field of the hive-c0re log line"; + }) ] # One rule per declared hive rather than one grouped by # `hive`, so a hive that sends nothing at all still has