Watch
0
0
Fork
You've already forked hyperhive
0

hive-c0re: stop mirroring deploy tags to the forge (drop push_config)

Per the operator's ruling on #4851 (option (a)): the hive holds no
write identity on the forge, so the `push_config` mirror of an applied
repo's tags and `main` to `agent-configs/<name>` is removed, along with
its `run_config_push` helper and its call in `sync_agent`.

Docs and comments that described the mirror now say that forge `main`
advances only by an operator's merge.

Refs #4851
This commit is contained in:
atlas 2026-10-07 19:40:16 +02:00
commit 456a4511ab
6 changed files with 33 additions and 137 deletions

View file

@ -207,14 +207,13 @@ the submitter pushes again (or closes it) to retry.
### Forge mirror
The bundled `hive-forge` container runs on the swarm's forge host
(`deploy.forgejo.enable`, see [`../swarm/services.md`](../swarm/services.md)),
and hive-c0re mirrors every agent's applied repo into a
private `agent-configs` Forgejo org. `forge::push_config(<name>)` pushes
every tag, then `applied/main`, to `agent-configs/<name>` on every
startup sweep and every rebuild. Forge `main` is branch-protected, so
the forge routinely refuses a push of an established `main`, which
hive-c0re expects. Pushes are best-effort — a missing or stopped forge never
blocks a deploy.
(`deploy.forgejo.enable`, see [`../swarm/services.md`](../swarm/services.md)).
Every agent has a repo in the private `agent-configs` Forgejo org, created
and seeded by swarm-controller. hive-c0re pushes nothing to it: the
applied repo's tags (`deployed/0`) exist only locally, and forge `main`
advances only by an operator's merge. hive-c0re
reads from it — reseeding a purged `applied`, catching `applied` up, and
seeding `proposed` at first spawn.
Each agent is a **write collaborator on its own** `agent-configs/<name>`
repo — so it can push a branch and open a config PR — but not a member
@ -222,8 +221,8 @@ of any other agent's, so it can't reach another agent's config through
the forge. Branch protection keeps the agent off the `main` push
allowlist and allowlists merging to the `operators` team only, so an
agent can't fast-forward its own config or self-merge its PR (see
[Config changes](#config-changes) above). hive-c0re passes the tokenised
push URL inline to `git push`, never writing it into
[Config changes](#config-changes) above). hive-c0re passes the credential to
each `git` call inline, never writing it into
`applied/<n>/.git/config`; that repo is RO-bind-mounted into the root
agent, and a stored token would leak core's admin credential to an
agent.

View file

@ -70,9 +70,7 @@ Two things live in the `agent-configs` Forgejo organization:
`main` directly nor self-merge. An operator's merge in the Forgejo UI
deploys the merged commit (see
[config-changes.md § Config changes](../agent-lifecycle/config-changes.md#config-changes)).
hive-c0re never force-pushes: the `push_config` mirror pushes the
add-only status tags and `main` without force, and treats a refused
`main` push as expected.
hive-c0re pushes nothing to this org; it only reads from it.
Repos stay private, so an agent can't read another
agent's config. (Agents remain read-only collaborators on `core/meta`.)
hive-c0re also references this repo as the agent's **persistent meta

View file

@ -13,7 +13,7 @@ pub use pr_comment::{config_repo, merged_pr_for_commit, post_pr_comment};
pub use reconcile::{fetch_forge_main, reconcile_config_apply, reconcile_config_status};
pub use repos::{
clone_config_into_proposed, ensure_meta_remote, ensure_repo, fast_forward_applied_main,
fetch_config_main_into_applied, meta_read_access, push_config, push_meta, shared_docs_access,
fetch_config_main_into_applied, meta_read_access, push_meta, shared_docs_access,
};
pub use users::core_token;
@ -89,8 +89,7 @@ pub(crate) fn core_auth_header(token: &str) -> String {
/// swarm-controller's: merge and approval allowlisted to the `operators` team,
/// so the agent can't push `main` or self-merge, and an operator's merge
/// deploys through swarm-controller. The repos remain private, so an agent
/// still can't reach *another* agent's config. hive-c0re never force-pushes;
/// the `push_config` mirror runs best-effort.
/// still can't reach *another* agent's config. hive-c0re only reads from it.
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
@ -180,9 +179,9 @@ pub(crate) fn api(token: &str) -> Result<Forgejo> {
Forgejo::new(Auth::Token(token), url).context("build forgejo api client")
}
/// Per-agent forge sync: mirror the agent's config repo, grant read access
/// to `core/meta`, and wire the `meta` remote in its proposed repo. All operations are idempotent; failures
/// are logged as warnings but don't abort the caller.
/// Per-agent forge sync: grant read access to `core/meta`, and wire the
/// `meta` remote in its proposed repo. All operations are idempotent;
/// failures are logged as warnings but don't abort the caller.
///
/// `core_token` is `core_token()` — passed in so callers that already
/// fetched it don't re-read the file. Pass `None` to skip the
@ -196,17 +195,9 @@ pub(crate) fn api(token: &str) -> Result<Forgejo> {
/// relies on the journal `warn!` lines alone (a rebuild is its own retry).
pub async fn sync_agent(name: &str, core_token: Option<&str>) -> bool {
// The agent's forge account, token and `agent-configs/<name>` repo are
// swarm-controller's; this hive creates none of them and only pushes
// swarm-controller's; this hive creates none of them and pushes nothing
// to the repo.
let mut ok = true;
// Mirror the agent's applied config repo into agent-configs.
// push_config catches any drift since the last run — e.g. the
// startup migration just relocated `deployed/0`, or a deploy landed
// while the forge was down.
if let Err(e) = push_config(name).await {
tracing::warn!(%name, error = ?e, "forge: push_config failed");
ok = false;
}
// Grant read-only access to core/meta and wire the `meta` remote
// into the proposed repo so agents can fetch their deployment context.
if let Some(token) = core_token

View file

@ -1,6 +1,5 @@
//! Repo + org plumbing on the local Forgejo: the meta + shared-docs +
//! knowledge repos, per-agent config-repo
//! mirroring (`push_config` / `push_meta`), collaborator grants,
//! knowledge repos, meta mirroring (`push_meta`), collaborator grants,
//! pull-mirrors, and branch-protection rules. The typed API-client
//! constructor + org-name constants live in the module root (`super`).
@ -107,8 +106,7 @@ pub async fn push_meta(dir: &Path) -> Result<()> {
// operator directive, hive-c0re must not force-push anywhere. If
// the remote is somehow ahead (e.g. split-brain or manual push) we
// raise a dashboard warning and leave the remote intact rather than
// silently erasing its history — consistent with `push_config`'s
// non-fast-forward handling.
// silently erasing its history.
let url = forge_git_url("core/meta");
let out = crate::lifecycle::git_command_authed(&core_auth_header(&token))
.current_dir(dir)
@ -216,91 +214,19 @@ pub async fn ensure_meta_remote(name: &str) -> Result<()> {
}
}
/// Mirror agent `name`'s applied config repo — every status tag
/// (`proposal` / `approved` / `building` / `deployed` / `failed` /
/// `denied`) plus a best-effort `main` — to `agent-configs/<name>` on
/// the local forge. Best-effort: returns Err which callers log + ignore.
/// No-op when the forge isn't seeded or the applied repo doesn't exist.
///
/// Call this after every hive-c0re mutation of an applied repo's refs
/// so the forge copy always reflects what core actually did.
///
/// **Two separate pushes, not one.** The status tags are id-suffixed
/// (`deployed/<id>`, …) and add-only, so they must always land. `main`,
/// by contrast, is branch-protected and advances through an operator's
/// merge on the forge — so a mirror push of an already-established `main`
/// is routinely rejected (protected-branch, or non-fast-forward). Git's pre-receive
/// hook is all-or-nothing: bundling both refspecs in one push means that
/// `main` reject declines the whole push, dropping the tags too.
/// So we push the tags on their own first, then attempt `main`
/// separately and swallow the expected reject. The `main` push still
/// matters for the initial seed of a fresh (empty) config repo, where it
/// creates `main`. Operator-driven divergence fix: the `hivectl forge
/// reconcile-config` command.
///
/// The tokenised URL is passed straight to `git push` and deliberately
/// never stored as a named remote: the applied repo is bind-mounted
/// READ-ONLY into the manager container (`/applied`), so a token in
/// `.git/config` would leak core's admin credential to an agent.
pub async fn push_config(name: &str) -> Result<()> {
let Some(token) = core_token() else {
return Ok(());
};
let dir = crate::paths::applied_dir(name);
if !dir.join(".git").exists() {
return Ok(());
}
let url = forge_git_url(&format!("{CONFIG_ORG}/{name}"));
let auth = core_auth_header(&token);
// Tags first, in their own push, so they land regardless of main's fate.
let out = run_config_push(&dir, &url, &auth, "refs/tags/*:refs/tags/*").await?;
if !out.status.success() {
anyhow::bail!(
"git push tags {CONFIG_ORG}/{name} failed ({}): {}",
out.status,
String::from_utf8_lossy(&out.stderr).trim()
);
}
// Then main on its own — a protected-branch / non-ff reject of an
// established main is expected (an operator's forge merge advances it);
// only the initial empty-repo seed actually advances it here.
let out = run_config_push(&dir, &url, &auth, "refs/heads/main:refs/heads/main").await?;
if !out.status.success() {
let stderr = String::from_utf8_lossy(&out.stderr);
if stderr.contains("non-fast-forward")
|| stderr.contains("protected branch")
|| stderr.contains("pre-receive hook declined")
{
tracing::info!(
%name,
"forge: mirror push of main skipped — forge main is protected \
and advances by an operator merge (expected); tags mirrored"
);
return Ok(());
}
anyhow::bail!(
"git push main {CONFIG_ORG}/{name} failed ({}): {}",
out.status,
stderr.trim()
);
}
tracing::info!(%name, "forge: mirrored applied config to agent-configs");
Ok(())
}
/// Reseed a revived agent's `applied` repo — missing its `.git` entirely,
/// e.g. after `destroy --purge` — by fetching `agent-configs/<name>`'s
/// `main` from the forge, [`push_config`]'s mirror target. Unlike
/// `proposed` (seeded once at first spawn and never touched again — see
/// `lifecycle::setup_proposed`'s own doc comment), the forge mirror is
/// kept current after every deploy, so this reflects the agent's actual
/// last-deployed state rather than a stale creation-time snapshot.
/// `main` from the forge. Unlike `proposed` (seeded once at first spawn and
/// never touched again — see `lifecycle::setup_proposed`'s own doc
/// comment), forge `main` advances with every operator-merged config PR, so
/// this reflects the last merged config rather than a stale creation-time
/// snapshot. A config deployed through hive-c0re without a forge merge never
/// reaches it.
///
/// Best-effort, mirroring [`push_config`]'s own shape: returns `false`
/// (not an error) rather than bailing when the forge is absent, the core
/// token isn't minted yet, `applied` already has a `.git` (nothing to
/// do), or any git step fails — callers fall back to a less-current
/// source or the original hard error. Only `true` once `applied` is
/// Best-effort: returns `false` (not an error) rather than bailing when the
/// forge is absent, the core token isn't minted yet, `applied` already has a
/// `.git` (nothing to do), or any git step fails — callers fall back to a
/// less-current source or the original hard error. Only `true` once `applied` is
/// actually seeded and tagged `deployed/0`, mirroring first-spawn's own
/// `setup_applied` seed shape exactly.
pub async fn fetch_config_main_into_applied(name: &str) -> bool {
@ -486,23 +412,6 @@ pub async fn clone_config_into_proposed(dir: &Path, name: &str) -> bool {
}
}
/// Run a single `git push <url> <refspec>` in the applied repo `dir` and
/// return the raw output for the caller to classify. Split out so
/// [`push_config`] can push tags and `main` as independent pushes.
async fn run_config_push(
dir: &std::path::Path,
url: &str,
auth: &str,
refspec: &str,
) -> Result<std::process::Output> {
crate::lifecycle::git_command_authed(auth)
.current_dir(dir)
.args(["push", url, refspec])
.output()
.await
.context("invoke git push agent-configs")
}
/// Add `user` as a collaborator on `owner/repo` at `permission`.
/// Idempotent: adding an existing collaborator just updates its
/// permission (Forgejo answers 204 either way; a 201 from older

View file

@ -274,9 +274,9 @@ pub async fn create_container(name: &str, hive: &HiveEnv, paths: &AgentPaths) ->
/// (`job_queue::exec::run_purge_state`) — a later revive of the *same*
/// agent name is still a rebuild, not a first-spawn, so it lands here, not
/// in `provision_container`. Recover from `agent-configs/<name>` on the
/// forge when that's the case (kept current after every deploy by
/// `forge::push_config` — see `forge::fetch_config_main_into_applied`'s own
/// doc comment) rather than bailing outright. Deliberately **no fallback to
/// forge when that's the case (forge `main` is the last operator-merged
/// config — see `forge::fetch_config_main_into_applied`'s own doc comment)
/// rather than bailing outright. Deliberately **no fallback to
/// `proposed`**: that repo is seeded once at first spawn and never touched
/// again (`setup_proposed`'s own doc comment), so it can be arbitrarily
/// stale for a long-lived agent — restoring from it would silently revive

View file

@ -98,9 +98,8 @@ pub async fn pull(coord: &Coordinator) -> Result<()> {
}
// Rewrite any credentialed `origin` left by an older clone, which spliced
// `core:<token>@` into the URL and persisted it in `.git/config` — the one
// place this repo stored a token in a named remote, against the rule
// `forge::repos::push_config` states. Harmless to repeat once the URL is
// already clean.
// place this repo stored a token in a named remote. Harmless to repeat
// once the URL is already clean.
let plain = crate::forge::forge_git_url(&format!("{ORG}/{REPO}"));
let _ = crate::lifecycle::git_command()
.args(["-C", LOCAL_DIR, "remote", "set-url", "origin", &plain])