fix(#3529): create agent config repos in agent-configs, not agents

WIP — compiles per an earlier build, but the verifying build/test run was
cut short by a graceful stop. Re-run gate.sh before pushing.

The controller created every agent config repo, its collaborator entry,
its branch protection and its seeded agent.nix/flake.nix in the agents
org. Config repos live in agent-configs, which is where hive-c0re
reconciles, merges and mirrors them — so a repo created in agents is
invisible to all of those, and nothing errors, because both orgs exist
and both accept a repo.

Root cause was a doc comment asserting something false: AGENTS_ORG
claimed to be the same org hive-c0re uses for its config-repo path. It is
not — hive-c0re's agents org is the namespace repos an AGENT ASKS FOR
land in, and its config repos use agent-configs. The whole flow inherited
the wrong premise from that sentence.

The merge gate survives the move: hive-c0re provisions the operators team
in both orgs, with a comment recording that missing the agent-configs
copy once left every config repo unprotected.
This commit is contained in:
atlas 2026-08-19 18:41:49 +02:00 committed by mara
commit 36810ae598
2 changed files with 34 additions and 32 deletions

View file

@ -28,16 +28,6 @@ use std::collections::BTreeMap;
use crate::webhook::DeliveryKind; use crate::webhook::DeliveryKind;
/// The forge org that owns agent repos. Same org `hive-c0re::forge`
/// already uses for its own single-hive `CreateRepo` path — this is
/// the same forge instance, not a separate one, so the same org.
///
/// There is no per-agent repo-vs-agent naming convention to maintain here:
/// agent names are unique and every agent's repo lives in this one org, so
/// the agent name IS the repo name at every call site (dropped the
/// `agent_repo` identity function this used to go through, per review).
pub const AGENTS_ORG: &str = "agents";
/// The `operators` team, whitelisted for the merge gate on every repo /// The `operators` team, whitelisted for the merge gate on every repo
/// this client protects — provisioned by `hive-c0re::forge::repos` /// this client protects — provisioned by `hive-c0re::forge::repos`
/// already (`ensure_operators_team`), not re-provisioned here. If that /// already (`ensure_operators_team`), not re-provisioned here. If that
@ -47,13 +37,25 @@ pub const AGENTS_ORG: &str = "agents";
/// [`Client::create_repo`]'s doc comment. /// [`Client::create_repo`]'s doc comment.
const OPERATORS_TEAM: &str = "operators"; const OPERATORS_TEAM: &str = "operators";
/// The org owning per-agent config repos, and where the `pull_request` hook /// The org owning per-agent config repos — where this daemon creates them,
/// lives. Same value as `hive-c0re::forge::CONFIG_ORG` — one forge, one org. /// seeds them, and where the `pull_request` hook lives. Same value as
/// `hive-c0re::forge::CONFIG_ORG` — one forge, one org.
/// ///
/// ⚠️ Duplicated across the crate boundary (this crate deliberately does not /// ⚠️ Duplicated across the crate boundary (this crate deliberately does not
/// depend on `hive-c0re`), so nothing makes the two fail together. The /// depend on `hive-c0re`), so nothing makes the two fail together. The
/// failure mode if they drift is quiet: the hook is created on an org that /// failure mode if they drift is quiet: the hook is created on an org that
/// exists, forgejo reports it healthy, and it simply never fires. /// exists, forgejo reports it healthy, and it simply never fires.
///
/// ⚠️ **Not `agents`.** That org exists and is a different thing: it is the
/// namespace repos an *agent asks for* land in, per
/// `hive-c0re::forge::AGENTS_ORG`. Agent **config** repos have always lived
/// here, which is where `hive-c0re` reconciles, merges and mirrors them — a
/// config repo created in `agents` is invisible to every one of those paths,
/// and nothing errors, because both orgs exist and both accept a repo.
///
/// The merge gate survives the distinction: `hive-c0re` provisions the
/// `operators` team in **both** orgs precisely so branch protection can be
/// applied in either.
const CONFIG_ORG: &str = "agent-configs"; const CONFIG_ORG: &str = "agent-configs";
/// The hive-wide knowledge repo, where the `push` hook lives. Same values as /// The hive-wide knowledge repo, where the `push` hook lives. Same values as
@ -126,27 +128,27 @@ impl Client {
} }
} }
/// Create `name` inside [`AGENTS_ORG`]. Idempotent: an existing repo /// Create `name` inside [`CONFIG_ORG`]. Idempotent: an existing repo
/// (409/422) is folded into success. /// (409/422) is folded into success.
async fn ensure_org_repo(&self, name: &str) -> Result<()> { async fn ensure_org_repo(&self, name: &str) -> Result<()> {
match self match self
.api .api
.create_org_repo(AGENTS_ORG, Self::repo_option(name, true)) .create_org_repo(CONFIG_ORG, Self::repo_option(name, true))
.await .await
{ {
Ok(_) => { Ok(_) => {
tracing::info!(%name, "swarm forge: created repo in {AGENTS_ORG}"); tracing::info!(%name, "swarm forge: created repo in {CONFIG_ORG}");
Ok(()) Ok(())
} }
Err(e) if is_already_exists(&e) => { Err(e) if is_already_exists(&e) => {
tracing::debug!(%name, "swarm forge: repo already exists"); tracing::debug!(%name, "swarm forge: repo already exists");
Ok(()) Ok(())
} }
Err(e) => Err(e).with_context(|| format!("create repo {AGENTS_ORG}/{name}")), Err(e) => Err(e).with_context(|| format!("create repo {CONFIG_ORG}/{name}")),
} }
} }
/// Add `user` as a collaborator on `AGENTS_ORG/repo` at `permission`. /// Add `user` as a collaborator on `CONFIG_ORG/repo` at `permission`.
/// Idempotent: re-adding an existing collaborator just updates its /// Idempotent: re-adding an existing collaborator just updates its
/// permission (forgejo answers 204 either way; a 201 from older /// permission (forgejo answers 204 either way; a 201 from older
/// server versions is tolerated defensively). /// server versions is tolerated defensively).
@ -159,7 +161,7 @@ impl Client {
let res = self let res = self
.api .api
.repo_add_collaborator( .repo_add_collaborator(
AGENTS_ORG, CONFIG_ORG,
repo, repo,
user, user,
AddCollaboratorOption { AddCollaboratorOption {
@ -172,7 +174,7 @@ impl Client {
Err(ForgejoError::UnexpectedStatusCode(s)) if s == StatusCode::CREATED => {} Err(ForgejoError::UnexpectedStatusCode(s)) if s == StatusCode::CREATED => {}
Err(e) => { Err(e) => {
return Err(e) return Err(e)
.with_context(|| format!("add collaborator {user} to {AGENTS_ORG}/{repo}")); .with_context(|| format!("add collaborator {user} to {CONFIG_ORG}/{repo}"));
} }
} }
tracing::debug!(%repo, %user, ?permission, "swarm forge: collaborator set"); tracing::debug!(%repo, %user, ?permission, "swarm forge: collaborator set");
@ -190,7 +192,7 @@ impl Client {
/// only treats it as success if the rule is actually present — a /// only treats it as success if the rule is actually present — a
/// fail-open merge gate is a security bug, not a shrug. /// fail-open merge gate is a security bug, not a shrug.
/// ///
/// Assumes [`OPERATORS_TEAM`] already exists in [`AGENTS_ORG`] /// Assumes [`OPERATORS_TEAM`] already exists in [`CONFIG_ORG`]
/// (provisioned by `hive-c0re::forge::repos::ensure_operators_team` /// (provisioned by `hive-c0re::forge::repos::ensure_operators_team`
/// on its own startup sweep, not re-provisioned here) — if it /// on its own startup sweep, not re-provisioned here) — if it
/// doesn't yet, this fails loudly rather than silently leaving the /// doesn't yet, this fails loudly rather than silently leaving the
@ -226,7 +228,7 @@ impl Client {
}; };
let Err(create_err) = self let Err(create_err) = self
.api .api
.repo_create_branch_protection(AGENTS_ORG, repo, rule) .repo_create_branch_protection(CONFIG_ORG, repo, rule)
.await .await
else { else {
tracing::info!(%repo, "swarm forge: applied operator branch protection"); tracing::info!(%repo, "swarm forge: applied operator branch protection");
@ -234,7 +236,7 @@ impl Client {
}; };
match self match self
.api .api
.repo_get_branch_protection(AGENTS_ORG, repo, "main") .repo_get_branch_protection(CONFIG_ORG, repo, "main")
.await .await
{ {
Ok(_) => { Ok(_) => {
@ -245,13 +247,13 @@ impl Client {
Ok(()) Ok(())
} }
Err(check_err) => anyhow::bail!( Err(check_err) => anyhow::bail!(
"branch protection for {AGENTS_ORG}/{repo} not applied: create failed \ "branch protection for {CONFIG_ORG}/{repo} not applied: create failed \
({create_err}); GET main rule failed ({check_err}), no `main` rule present" ({create_err}); GET main rule failed ({check_err}), no `main` rule present"
), ),
} }
} }
/// Create `repo` in [`AGENTS_ORG`] and apply the operator merge gate to /// Create `repo` in [`CONFIG_ORG`] and apply the operator merge gate to
/// its default branch — the whole job of the `CreateRepo` node. /// its default branch — the whole job of the `CreateRepo` node.
/// Branch protection is folded in here rather than a separate node: it /// Branch protection is folded in here rather than a separate node: it
/// has no independent retry value apart from the repo existing (there /// has no independent retry value apart from the repo existing (there
@ -262,8 +264,8 @@ impl Client {
pub async fn create_repo(&self, repo: &str) -> Result<String> { pub async fn create_repo(&self, repo: &str) -> Result<String> {
self.ensure_org_repo(repo).await?; self.ensure_org_repo(repo).await?;
self.apply_operator_branch_protection(repo).await?; self.apply_operator_branch_protection(repo).await?;
tracing::info!(%repo, "swarm forge: created repo in {AGENTS_ORG} with operator merge gate"); tracing::info!(%repo, "swarm forge: created repo in {CONFIG_ORG} with operator merge gate");
Ok(format!("{AGENTS_ORG}/{repo}")) Ok(format!("{CONFIG_ORG}/{repo}"))
} }
/// Add `agent` as a **write** collaborator on `repo` (can push + open /// Add `agent` as a **write** collaborator on `repo` (can push + open
@ -313,7 +315,7 @@ impl Client {
]; ];
self.api self.api
.repo_change_files( .repo_change_files(
AGENTS_ORG, CONFIG_ORG,
repo, repo,
ChangeFilesOptions { ChangeFilesOptions {
author: None, author: None,
@ -328,7 +330,7 @@ impl Client {
}, },
) )
.await .await
.with_context(|| format!("seed config files in {AGENTS_ORG}/{repo}"))?; .with_context(|| format!("seed config files in {CONFIG_ORG}/{repo}"))?;
tracing::info!(%repo, %agent, "swarm forge: seeded agent.nix + flake.nix"); tracing::info!(%repo, %agent, "swarm forge: seeded agent.nix + flake.nix");
Ok(()) Ok(())
} }
@ -340,12 +342,12 @@ impl Client {
async fn file_exists(&self, repo: &str, path: &str) -> Result<bool> { async fn file_exists(&self, repo: &str, path: &str) -> Result<bool> {
match self match self
.api .api
.repo_get_contents(AGENTS_ORG, repo, path, RepoGetContentsQuery::default()) .repo_get_contents(CONFIG_ORG, repo, path, RepoGetContentsQuery::default())
.await .await
{ {
Ok(_) => Ok(true), Ok(_) => Ok(true),
Err(ForgejoError::UnexpectedStatusCode(s)) if s == StatusCode::NOT_FOUND => Ok(false), Err(ForgejoError::UnexpectedStatusCode(s)) if s == StatusCode::NOT_FOUND => Ok(false),
Err(e) => Err(e).with_context(|| format!("check for {path} in {AGENTS_ORG}/{repo}")), Err(e) => Err(e).with_context(|| format!("check for {path} in {CONFIG_ORG}/{repo}")),
} }
} }

View file

@ -56,7 +56,7 @@ mod webhook;
enum SwarmNodeKind { enum SwarmNodeKind {
/// Ensure `agent` exists as an authelia subject at the swarm level. /// Ensure `agent` exists as an authelia subject at the swarm level.
CreateIdentity { agent: String }, CreateIdentity { agent: String },
/// Create the agent's repo in `forge::AGENTS_ORG` with the operator /// Create the agent's repo in `forge::CONFIG_ORG` with the operator
/// merge gate on its default branch. See `forge::Client::create_repo`. /// merge gate on its default branch. See `forge::Client::create_repo`.
CreateRepo { agent: String }, CreateRepo { agent: String },
/// Add `agent` as a write collaborator on its own repo. See /// Add `agent` as a write collaborator on its own repo. See
@ -555,7 +555,7 @@ async fn get_hives_status(
} }
/// Body of `POST /api/agents` — the agent name to create, and the hive the /// Body of `POST /api/agents` — the agent name to create, and the hive the
/// creation is aimed at. The repo name inside `forge::AGENTS_ORG` is the /// creation is aimed at. The repo name inside `forge::CONFIG_ORG` is the
/// same string as `name`: one repo per agent, named after it, same /// same string as `name`: one repo per agent, named after it, same
/// convention `hive-c0re::forge` already uses for its own single-hive /// convention `hive-c0re::forge` already uses for its own single-hive
/// `CreateRepo` path. /// `CreateRepo` path.