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;
/// 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
/// this client protects — provisioned by `hive-c0re::forge::repos`
/// 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.
const OPERATORS_TEAM: &str = "operators";
/// The org owning per-agent config repos, and where the `pull_request` hook
/// lives. Same value as `hive-c0re::forge::CONFIG_ORG` — one forge, one org.
/// The org owning per-agent config repos — where this daemon creates them,
/// 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
/// 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
/// 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";
/// 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.
async fn ensure_org_repo(&self, name: &str) -> Result<()> {
match self
.api
.create_org_repo(AGENTS_ORG, Self::repo_option(name, true))
.create_org_repo(CONFIG_ORG, Self::repo_option(name, true))
.await
{
Ok(_) => {
tracing::info!(%name, "swarm forge: created repo in {AGENTS_ORG}");
tracing::info!(%name, "swarm forge: created repo in {CONFIG_ORG}");
Ok(())
}
Err(e) if is_already_exists(&e) => {
tracing::debug!(%name, "swarm forge: repo already exists");
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
/// permission (forgejo answers 204 either way; a 201 from older
/// server versions is tolerated defensively).
@ -159,7 +161,7 @@ impl Client {
let res = self
.api
.repo_add_collaborator(
AGENTS_ORG,
CONFIG_ORG,
repo,
user,
AddCollaboratorOption {
@ -172,7 +174,7 @@ impl Client {
Err(ForgejoError::UnexpectedStatusCode(s)) if s == StatusCode::CREATED => {}
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");
@ -190,7 +192,7 @@ impl Client {
/// only treats it as success if the rule is actually present — a
/// 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`
/// on its own startup sweep, not re-provisioned here) — if it
/// doesn't yet, this fails loudly rather than silently leaving the
@ -226,7 +228,7 @@ impl Client {
};
let Err(create_err) = self
.api
.repo_create_branch_protection(AGENTS_ORG, repo, rule)
.repo_create_branch_protection(CONFIG_ORG, repo, rule)
.await
else {
tracing::info!(%repo, "swarm forge: applied operator branch protection");
@ -234,7 +236,7 @@ impl Client {
};
match self
.api
.repo_get_branch_protection(AGENTS_ORG, repo, "main")
.repo_get_branch_protection(CONFIG_ORG, repo, "main")
.await
{
Ok(_) => {
@ -245,13 +247,13 @@ impl Client {
Ok(())
}
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 `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.
/// Branch protection is folded in here rather than a separate node: it
/// 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> {
self.ensure_org_repo(repo).await?;
self.apply_operator_branch_protection(repo).await?;
tracing::info!(%repo, "swarm forge: created repo in {AGENTS_ORG} with operator merge gate");
Ok(format!("{AGENTS_ORG}/{repo}"))
tracing::info!(%repo, "swarm forge: created repo in {CONFIG_ORG} with operator merge gate");
Ok(format!("{CONFIG_ORG}/{repo}"))
}
/// Add `agent` as a **write** collaborator on `repo` (can push + open
@ -313,7 +315,7 @@ impl Client {
];
self.api
.repo_change_files(
AGENTS_ORG,
CONFIG_ORG,
repo,
ChangeFilesOptions {
author: None,
@ -328,7 +330,7 @@ impl Client {
},
)
.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");
Ok(())
}
@ -340,12 +342,12 @@ impl Client {
async fn file_exists(&self, repo: &str, path: &str) -> Result<bool> {
match self
.api
.repo_get_contents(AGENTS_ORG, repo, path, RepoGetContentsQuery::default())
.repo_get_contents(CONFIG_ORG, repo, path, RepoGetContentsQuery::default())
.await
{
Ok(_) => Ok(true),
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 {
/// Ensure `agent` exists as an authelia subject at the swarm level.
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`.
CreateRepo { agent: String },
/// 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
/// 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
/// convention `hive-c0re::forge` already uses for its own single-hive
/// `CreateRepo` path.