From 93c7454bf555e09fce3b5908a48ef2adf229f00a Mon Sep 17 00:00:00 2001 From: atlas Date: Sun, 30 Aug 2026 22:36:19 +0200 Subject: [PATCH 1/7] swarm: name the deploy event and grant the controller its publish MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The subject and its payload live in `swarm-queue-client` for the reason the knowledge event's already does: three crates have to agree on the string, and the one that agrees hardest — the auth-callout responder, which decides whether the publish is permitted at all — speaks neither `jetstream` nor `kv`. Swarm-wide rather than a `$SWARM.deploy.` family. That family would look like isolation and provide none: this responder scopes publish only, leaving `sub` unrestricted, so a hive could subscribe to another's subject as easily as to its own. Until `sub` is scoped the split costs a wider grant and buys nothing, so the addressing goes in the payload and each hive filters on its own name. Unlike the knowledge event the message is addressed, so it carries a payload — a trigger, never the config. The hive already tracks the agent's config repo; desired state on the wire would make this a second source of truth for something git owns, and a hive that missed a message would be wrong rather than late. Both test arms mirrored from the knowledge event. The negative one matters more here: a forged knowledge event makes a hive re-read a repo, a forged deploy event makes it rebuild and restart a named agent. --- swarm-nats-auth/src/policy.rs | 39 +++++++++++++++++++++++++++++++++++ swarm-queue-client/src/lib.rs | 34 ++++++++++++++++++++++++++++++ 2 files changed, 73 insertions(+) diff --git a/swarm-nats-auth/src/policy.rs b/swarm-nats-auth/src/policy.rs index 8c1fea29..43cfff04 100644 --- a/swarm-nats-auth/src/policy.rs +++ b/swarm-nats-auth/src/policy.rs @@ -278,6 +278,12 @@ impl Policy { // about a change, with nothing in the controller's log to say a // permission was the reason. swarm_queue_client::KNOWLEDGE_SUBJECT.to_owned(), + // The deploy event, same shape and the same failure mode as the + // knowledge event above — one literal subject, one writer. Also + // deliberately not a per-hive family: this responder scopes + // publish only, so a per-hive subject would not stop a hive + // reading another's. See the const's own doc. + swarm_queue_client::DEPLOY_SUBJECT.to_owned(), ]); subjects } @@ -373,6 +379,39 @@ mod tests { ); } + #[test] + fn a_reader_may_publish_the_deploy_event() { + let p = policy().permissions("swarm-controller").expect("a reader"); + assert!( + p.publish + .contains(&swarm_queue_client::DEPLOY_SUBJECT.to_owned()), + "the controller is the only publisher of this event; without the \ + grant its publish is refused, and a refusal arrives as a timeout" + ); + } + + #[test] + fn a_hive_may_not_publish_the_deploy_event_to_anyone_including_itself() { + // Same arm as the knowledge event's, and it matters more here: a forged + // knowledge event makes a hive re-read a repo, while a forged deploy + // event makes it rebuild and restart a named agent. One shared subject + // means a single forged message reaches every hive in the swarm. + // + // Note this is the half `sub` scoping would not fix even once it lands: + // reading another hive's deploy message is a confidentiality question, + // *sending* one is this. + let p = policy() + .permissions("hive-alpha") + .expect("a hive is admitted"); + assert!( + !p.publish + .iter() + .any(|s| s == swarm_queue_client::DEPLOY_SUBJECT), + "a hive must not publish the deploy event: {:?}", + p.publish + ); + } + #[test] fn a_hive_grant_never_includes_the_jetstream_wildcard() { // `$JS.API.>` also covers `$JS.API.STREAM.DELETE.KV_hive-status`, with diff --git a/swarm-queue-client/src/lib.rs b/swarm-queue-client/src/lib.rs index c7a9143f..a9e839ee 100644 --- a/swarm-queue-client/src/lib.rs +++ b/swarm-queue-client/src/lib.rs @@ -173,6 +173,40 @@ pub mod status; /// permitted at all — speaks neither `jetstream` nor `kv`. pub const KNOWLEDGE_SUBJECT: &str = "$SWARM.knowledge"; +/// The subject the swarm controller publishes on to ask a hive to rebuild one +/// of its agents. Same shape as [`KNOWLEDGE_SUBJECT`]: one writer, every hive +/// subscribes, and it lives here for the same three-crate reason. +/// +/// # Swarm-wide, not per-hive, and that is deliberate +/// +/// A `$SWARM.deploy.` family would *look* like isolation and provide +/// none: the auth-callout responder scopes **publish** only, leaving `sub` +/// unrestricted, so any hive could subscribe to another's subject just as +/// easily as to its own. Until `sub` is scoped, the per-hive split costs a +/// wider grant and buys nothing — so the addressing lives in the payload and +/// each hive filters on its own name, exactly as the knowledge event fans out. +pub const DEPLOY_SUBJECT: &str = "$SWARM.deploy"; + +/// What a [`DEPLOY_SUBJECT`] message carries. +/// +/// The knowledge event has no payload — every hive does the same thing on +/// receipt. This one is addressed, so both ends have to agree on the fields, +/// which is why the type lives beside the subject rather than in whichever +/// crate happened to need it first. +/// +/// ⚠️ **A trigger, not the config.** The hive already tracks the agent's +/// config repo; putting desired state on the wire would make this message a +/// second source of truth for something git already owns, and a hive that +/// missed a message would then be wrong rather than merely late. +#[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct DeployRequest { + /// Which hive should act. Every hive receives the message; the one whose + /// own name this matches is the one that rebuilds. + pub hive: String, + /// The agent to rebuild, as the swarm knows it. + pub agent: String, +} + /// The hive-notices stream, shared by the hive that publishes and /// whatever eventually consumes it. Behind the `notices` feature, same /// reason `status` is behind `kv` — see the module doc. From 38ccef987e56891130d01f6b036564862739a3b9 Mon Sep 17 00:00:00 2001 From: atlas Date: Sun, 30 Aug 2026 22:43:31 +0200 Subject: [PATCH 2/7] swarm-controller: a node kind that asks a hive to deploy an agent MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `TriggerDeploy` is the first `SwarmNodeKind` whose effect leaves this host, so it is also the first to need the queue connection: `WorkerDeps` grows a `queue` handle, cloned from the status reader whose own doc says the connection living there is an accident of construction order rather than a claim that events are a kind of status. `publish_deploy` publishes and then flushes before reporting `Done`. `publish` only hands the message to the client's write buffer, so a node that reported success on that alone would be claiming a delivery it has no evidence for — the ordering `webhook::announce_knowledge_change` already documents. The `WireNode::data` or-pattern did the job it was written for: its comment says a new variant should fail to compile there rather than silently render as an agent name, and `TriggerDeploy` is the first node about an agent *and a hive*. A catch-all would have dropped the hive from the viewer with nothing to notice it. --- swarm-controller/src/main.rs | 74 +++++++++++++++++++++++++++++++++--- 1 file changed, 69 insertions(+), 5 deletions(-) diff --git a/swarm-controller/src/main.rs b/swarm-controller/src/main.rs index 1b4477f6..1ad2af1d 100644 --- a/swarm-controller/src/main.rs +++ b/swarm-controller/src/main.rs @@ -86,6 +86,16 @@ enum SwarmNodeKind { /// swarm routes a deploy message to — it belongs on the node that /// sends that message, not on this one. InitAgentConfigRepo { agent: String }, + /// Tell `hive` to rebuild `agent`, by publishing on the swarm's deploy + /// subject. The one node kind whose effect leaves this host. + /// + /// Carries the hive, unlike every variant above — this is the node + /// `InitAgentConfigRepo`'s doc points at when it says the hive belongs + /// on the node that sends the deploy message. The subject is swarm-wide + /// and the addressing rides in the payload; see + /// `swarm_queue_client::DEPLOY_SUBJECT` for why it is not a per-hive + /// family. + TriggerDeploy { hive: String, agent: String }, } impl hive_jobq_wire::WireNode for SwarmNodeKind { @@ -96,15 +106,17 @@ impl hive_jobq_wire::WireNode for SwarmNodeKind { SwarmNodeKind::CreateForgeUser { .. } => "create_forge_user".to_owned(), SwarmNodeKind::AddRepoMember { .. } => "add_repo_member".to_owned(), SwarmNodeKind::InitAgentConfigRepo { .. } => "init_agent_config_repo".to_owned(), + SwarmNodeKind::TriggerDeploy { .. } => "trigger_deploy".to_owned(), } } fn data(&self, _id: hive_jobq_wire::WireId) -> serde_json::Value { - // Every node in this graph is about exactly one agent, so they all - // render the same and `label()` is what distinguishes them. Spelled - // out as an or-pattern rather than a catch-all on purpose: a fifth - // variant then fails to compile here instead of silently rendering - // as an agent name. + // Spelled out as an or-pattern rather than a catch-all on purpose: a + // new variant then fails to compile here instead of silently + // rendering as an agent name. That gate has now fired once — + // `TriggerDeploy` is the first node that is about an agent *and a + // hive*, and a catch-all would have dropped the hive from the + // viewer without anyone noticing. match self { SwarmNodeKind::CreateIdentity { agent } | SwarmNodeKind::CreateRepo { agent } @@ -113,6 +125,9 @@ impl hive_jobq_wire::WireNode for SwarmNodeKind { | SwarmNodeKind::InitAgentConfigRepo { agent } => { serde_json::json!({ "agent": agent }) } + SwarmNodeKind::TriggerDeploy { hive, agent } => { + serde_json::json!({ "agent": agent, "hive": hive }) + } } } } @@ -140,6 +155,13 @@ impl hive_jobq_wire::WireResource for SwarmResourceKind { struct WorkerDeps { auth: Option>, forge: Option>, + /// The swarm queue connection, for nodes whose effect is a published + /// event rather than an API call. A handle rather than the status + /// reader it is cloned from: a node that announces a deploy has no + /// business reading hive status, and `status.rs`'s own doc says the + /// connection living there is an accident of construction order, not a + /// claim that events are a kind of status. + queue: Option, } /// Run a claimed node's actual work. Mirrors `hive-c0re/src/job_queue/ @@ -226,10 +248,51 @@ async fn run_swarm_node( Err(e) => Outcome::Failed(format!("{e:#}")), }, }, + SwarmNodeKind::TriggerDeploy { hive, agent } => match deps.queue { + None => Outcome::Failed( + "no swarm queue is configured on this host, so no hive can be told to deploy" + .to_owned(), + ), + Some(client) => publish_deploy(&client, &hive, &agent).await, + }, }; (builder, outcome) } +/// Publish one deploy request and report whether it left this process. +/// +/// The flush is not belt-and-braces: `publish` hands the message to the +/// client's write buffer and returns, so a node that reported `Done` on +/// that alone would be claiming a delivery it has no evidence for — the +/// same ordering `webhook::announce_knowledge_change` documents. +async fn publish_deploy( + client: &async_nats::Client, + hive: &str, + agent: &str, +) -> hive_jobq::scheduler::Outcome { + use hive_jobq::scheduler::Outcome; + + let subject = swarm_queue_client::DEPLOY_SUBJECT; + let request = swarm_queue_client::DeployRequest { + hive: hive.to_owned(), + agent: agent.to_owned(), + }; + let payload = match serde_json::to_vec(&request) { + Ok(payload) => payload, + Err(e) => return Outcome::Failed(format!("encoding the deploy request failed: {e}")), + }; + if let Err(e) = client.publish(subject, payload.into()).await { + return Outcome::Failed(format!("publishing to {subject} failed: {e}")); + } + if let Err(e) = client.flush().await { + return Outcome::Failed(format!( + "flushing the deploy event to {subject} failed: {e}" + )); + } + tracing::info!(%subject, %hive, %agent, "swarm jobq: deploy requested"); + Outcome::Done +} + /// Spawn the swarm-level job-graph scheduler loop. Mirrors `hive-c0re/src/ /// job_queue/scheduler.rs::run_worker`'s shape: claim one runnable node, /// spawn the future that runs + completes it, loop again immediately if @@ -1151,6 +1214,7 @@ async fn main() -> Result<()> { let deps = WorkerDeps { auth: auth.clone(), forge: forge_client.clone(), + queue: status.as_ref().map(|s| s.queue_client()), }; let jobq = Arc::new(Mutex::new(hive_jobq::scheduler::Scheduler::new( From d4eb62434d1de78c48d6918edfb3a269f38c81ef Mon Sep 17 00:00:00 2001 From: atlas Date: Sun, 30 Aug 2026 22:51:46 +0200 Subject: [PATCH 3/7] swarm-controller: creating an agent now asks its hive to deploy it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `TriggerDeploy` had no producer — a node kind nothing enqueues is dead code, and a publisher with no caller proves as little as a check nobody runs. It goes last in the creation chain, after `InitAgentConfigRepo` rather than merely after the repo exists: the hive deploys by reading that repo, so a deploy asked for any earlier would find nothing to build. That edge is what makes creating an agent at swarm level actually put it on a hive instead of leaving a provisioned name nobody runs. The `hive` it carries is the string this handler already parsed as an `Ident` and matched against the roster, so the node cannot name a hive this swarm does not have. --- swarm-controller/src/main.rs | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/swarm-controller/src/main.rs b/swarm-controller/src/main.rs index 1ad2af1d..d7657dfb 100644 --- a/swarm-controller/src/main.rs +++ b/swarm-controller/src/main.rs @@ -914,9 +914,22 @@ async fn create_agent( }) .after_ok(create_repo) .after_ok(create_forge_user); - let _init_config = b - .node(SwarmNodeKind::InitAgentConfigRepo { agent }) + let init_config = b + .node(SwarmNodeKind::InitAgentConfigRepo { + agent: agent.clone(), + }) .after_ok(create_repo); + // Last, and specifically after the config repo is seeded: the + // hive deploys by reading that repo, so a deploy asked for any + // earlier would find nothing to build. This is the edge that + // makes creating an agent at swarm level actually put it on a + // hive, rather than leaving a provisioned name nobody runs. + let _trigger_deploy = b + .node(SwarmNodeKind::TriggerDeploy { + hive: hive.clone(), + agent, + }) + .after_ok(init_config); vec![create_identity.guid()] }) .map_err(|e| { @@ -1529,6 +1542,7 @@ mod tests { let deps = WorkerDeps { auth: None, forge: None, + queue: None, }; let runner = hive_jobq::scheduler::Scheduler::claim_next(&sched, move |id, kind, builder| { @@ -1580,6 +1594,7 @@ mod tests { let deps = WorkerDeps { auth: None, forge: None, + queue: None, }; let runner = hive_jobq::scheduler::Scheduler::claim_next(&sched, move |id, kind, builder| { From 7519d9b904c0ca2e48a328a1a7be206d968e3f79 Mon Sep 17 00:00:00 2001 From: atlas Date: Sun, 30 Aug 2026 22:51:55 +0200 Subject: [PATCH 4/7] hive-c0re: act on a deploy request addressed to this hive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The second subject on the connection this task already drains. Every hive receives every message — that is what a swarm-wide subject buys — so not being the addressee is the ordinary case and logs at `debug`. A payload that will not decode is not: both ends share one type, so a decode failure means they disagree about it. The rebuild is the same insert the operator's own `rebuild` verb makes, relock and all. "Deploy this agent" already means something here, and a swarm-triggered rebuild that quietly did something narrower would be a second definition of the word. ⚠️ The at-most-once argument in this function's docs does NOT transfer to the new subject, and the docs now say so. A missed knowledge event is repaired by the pull this daemon does at startup regardless; a missed deploy event has no second path — nothing else would ever tell this hive to build that agent. Closing that is the hive-side reconcile loop the issue's other half calls for; until it exists this is a nudge with no safety net. Not papered over with `JetStream` here: durability on one subject would look like a fix while the desired state still lived only in a message. Swept the surrounding prose rather than only the lines I touched. Two sections had gone quietly false: the summary said this listens on "the knowledge-event subject" and named one event, and a whole section argued "there is no payload, and that is deliberate" — true of the knowledge event and the opposite of true for a deploy request, which is addressed and has nowhere but the body to say so. --- hive-c0re/src/swarm_status.rs | 108 +++++++++++++++++++++++++++------- 1 file changed, 86 insertions(+), 22 deletions(-) diff --git a/hive-c0re/src/swarm_status.rs b/hive-c0re/src/swarm_status.rs index 6f71fc03..714d4850 100644 --- a/hive-c0re/src/swarm_status.rs +++ b/hive-c0re/src/swarm_status.rs @@ -88,7 +88,12 @@ pub fn spawn( // paths independent reconnect state, so one could be serving while // the other was still down. `async_nats::Client` is a handle, so the // clone is cheap. - tokio::spawn(drain_swarm_events(client.clone(), coord, shutdown.clone())); + tokio::spawn(drain_swarm_events( + client.clone(), + coord, + hive.clone(), + shutdown.clone(), + )); let mut health = SweepHealth::new("swarm_status_publish", "warn", FAILURES_BEFORE_BANNER); loop { @@ -127,39 +132,40 @@ pub fn spawn( }); } -/// Listen on the swarm's knowledge-event subject and act on what arrives. +/// Listen on the swarm's event subjects and act on what arrives. /// /// The controller decides *what a forge delivery means* and addresses the -/// result here; this end does not know a forge exists. Today the one event is -/// **the knowledge repository changed**, and the response is the pull this -/// daemon already runs at boot. +/// result here; this end does not know a forge exists. Two events today: +/// **the knowledge repository changed**, answered by the pull this daemon +/// already runs at boot, and **deploy this agent**, answered by the same +/// rebuild insert the operator's own verb makes. /// -/// # There is no payload, and that is deliberate +/// Only the deploy event carries a payload, because only it is *addressed*: +/// a swarm-wide subject leaves the addressing nowhere to live but the body. +/// Even then it is a trigger — which hive, which agent — never the config, +/// which git owns. /// -/// The event carries nothing. The webhook handler this replaces read two -/// fields from Forgejo and used neither — both were filters — then ran -/// `git pull`, which re-derives everything from the repository. So it is an -/// edge trigger, and reading a body here would invent a contract nobody owes. +/// # A missed message costs the two events very differently /// -/// # What a missed message costs +/// Core NATS, so delivery is at-most-once. Harmless for knowledge: this +/// daemon pulls at startup regardless, and the webhook it replaced was lost +/// identically when a hive was down. /// -/// Core NATS, so delivery is at-most-once: a hive that is down when the -/// controller publishes never hears it, and its knowledge stays as of its last -/// pull until it next boots. **That is not a regression** — a webhook delivery -/// to a hive that is down is lost identically, and this daemon pulls at startup -/// regardless. `JetStream` would require this end to *publish* to -/// `$JS.API.CONSUMER.CREATE.`, which the callout policy does not grant, -/// so durability would cost a grant on both sides to remove a failure the boot -/// pull already covers. +/// ⚠️ **The deploy event has no such second path** — nothing else would ever +/// tell this hive to build that agent. Its backstop is the hive-side reconcile +/// loop ("hives pull and self-update"); until that exists this is a nudge with +/// no safety net. Not papered over with `JetStream`: durability on one subject +/// looks like a fix while the desired state still lives only in a message. /// /// ⚠️ **A refused subscription is indistinguishable from a quiet one.** NATS /// reports an authorization violation asynchronously on the connection, not as /// an error from `subscribe`, so this task cannot tell "no events published" -/// from "not allowed to hear them". If a hive stops picking up knowledge -/// changes, the server log is the thing that knows why — nothing here will say. +/// from "not allowed to hear them". If a hive stops picking up either event, +/// the server log is the thing that knows why — nothing here will say. async fn drain_swarm_events( client: async_nats::Client, coord: std::sync::Arc, + hive: String, mut shutdown: tokio::sync::watch::Receiver, ) { // One subject for the whole swarm, so this hive's own name never enters @@ -179,7 +185,18 @@ async fn drain_swarm_events( return; } }; - tracing::info!(%subject, "swarm events: listening"); + let deploy_subject = swarm_queue_client::DEPLOY_SUBJECT; + let mut deploy_sub = match client.subscribe(deploy_subject).await { + Ok(sub) => sub, + Err(e) => { + tracing::warn!( + subject = %deploy_subject, error = %e, + "swarm events: subscribe failed; this hive will not hear deploy requests" + ); + return; + } + }; + tracing::info!(%subject, %deploy_subject, "swarm events: listening"); loop { tokio::select! { @@ -197,6 +214,13 @@ async fn drain_swarm_events( tracing::warn!(error = ?e, "swarm events: knowledge pull failed"); } } + msg = deploy_sub.next() => { + let Some(msg) = msg else { + tracing::warn!(subject = %deploy_subject, "swarm events: deploy subscription closed"); + return; + }; + handle_deploy_request(&coord, &hive, &msg.payload); + } _ = shutdown.changed() => { tracing::info!("swarm events: shutdown signal received"); return; @@ -205,6 +229,46 @@ async fn drain_swarm_events( } } +/// Act on one deploy request, if it is addressed to this hive. +/// +/// Every hive receives every message — that is what the swarm-wide subject +/// buys — so *not* being the addressee is the ordinary case and logs at +/// `debug`, not `warn`. A malformed payload is different: the controller and +/// this end share one type, so a decode failure means they disagree about it, +/// and that is worth saying out loud. +fn handle_deploy_request( + coord: &std::sync::Arc, + hive: &str, + payload: &[u8], +) { + let request: swarm_queue_client::DeployRequest = match serde_json::from_slice(payload) { + Ok(request) => request, + Err(e) => { + tracing::warn!(error = %e, "swarm events: undecodable deploy request"); + return; + } + }; + if request.hive != hive { + tracing::debug!(target_hive = %request.hive, agent = %request.agent, "swarm events: deploy for another hive"); + return; + } + // The same insert the operator's own `rebuild` verb makes, relock and + // all: "deploy this agent" means here exactly what it already meant, and + // a swarm-triggered rebuild that quietly did something narrower would be + // a second definition of the word. + let agent = request.agent; + let inserted = coord + .job_queue + .insert_job(|b| crate::job_queue::templates::rebuild(b, &agent, true)); + match inserted { + Ok(_) => { + tracing::info!(%agent, "swarm events: deploy requested by the swarm, rebuild queued"); + coord.emit_rebuild_queue_snapshot(); + } + Err(e) => tracing::warn!(%agent, error = %e, "swarm events: queueing the deploy failed"), + } +} + /// Offer one snapshot: this hive's current readiness, under its own key. async fn publish(client: &async_nats::Client, hive: &str) -> Result<()> { // An unconnected client does not fail a JetStream request, it hangs From b004ba3dc5475bbdb7709e76d2a457931c014dc7 Mon Sep 17 00:00:00 2001 From: atlas Date: Sun, 30 Aug 2026 23:07:30 +0200 Subject: [PATCH 5/7] swarm: split the deploy subject per hive MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per mara on the PR: *"split by hive. its not a security thing, just so hives dont get messages they dont care about."* She agreed with the finding and still wanted the split, which is the part worth recording. I measured that a per-hive subject gives no confidentiality — `sub` is unrestricted, so a hive that wanted another's messages could subscribe to them — and concluded it bought nothing. "Nothing" is a claim over every axis and I had checked one. The axis I never priced: every hive in the swarm being woken by every other hive's deploys. So `deploy_subject(hive)` replaces the single literal, and the payload drops `hive` to carry only the agent — the subject names the hive, and two places stating one fact are free to disagree. The hive subscribes to its own subject and no longer filters. The grant is a wildcard rather than a subject per hive because the responder has no roster: it cannot enumerate hives, and a grant that had to track one would be a second place to get the list wrong — the same argument `hive_name`'s doc makes about admission. The negative test gets stronger rather than merely adapted. Splitting the family makes "another hive's subject" and "its own" separate strings for the first time, so it now asserts a hive reaches neither, nor the wildcard. --- hive-c0re/src/swarm_status.rs | 34 +++++++++--------------- swarm-controller/src/main.rs | 12 ++++----- swarm-nats-auth/src/policy.rs | 50 ++++++++++++++++++++--------------- swarm-queue-client/src/lib.rs | 46 ++++++++++++++++++-------------- 4 files changed, 72 insertions(+), 70 deletions(-) diff --git a/hive-c0re/src/swarm_status.rs b/hive-c0re/src/swarm_status.rs index 714d4850..e7d43c87 100644 --- a/hive-c0re/src/swarm_status.rs +++ b/hive-c0re/src/swarm_status.rs @@ -140,9 +140,9 @@ pub fn spawn( /// already runs at boot, and **deploy this agent**, answered by the same /// rebuild insert the operator's own verb makes. /// -/// Only the deploy event carries a payload, because only it is *addressed*: -/// a swarm-wide subject leaves the addressing nowhere to live but the body. -/// Even then it is a trigger — which hive, which agent — never the config, +/// Only the deploy event carries a payload, and only the agent name: its +/// subject already names the hive, so this one listens on its own rather than +/// filtering a swarm-wide feed. Even then it is a trigger, never the config, /// which git owns. /// /// # A missed message costs the two events very differently @@ -185,8 +185,10 @@ async fn drain_swarm_events( return; } }; - let deploy_subject = swarm_queue_client::DEPLOY_SUBJECT; - let mut deploy_sub = match client.subscribe(deploy_subject).await { + // This hive's own deploy subject, not a swarm-wide one: the controller + // addresses each hive, so nothing arrives here that is not for us. + let deploy_subject = swarm_queue_client::deploy_subject(&hive); + let mut deploy_sub = match client.subscribe(deploy_subject.clone()).await { Ok(sub) => sub, Err(e) => { tracing::warn!( @@ -219,7 +221,7 @@ async fn drain_swarm_events( tracing::warn!(subject = %deploy_subject, "swarm events: deploy subscription closed"); return; }; - handle_deploy_request(&coord, &hive, &msg.payload); + handle_deploy_request(&coord, &msg.payload); } _ = shutdown.changed() => { tracing::info!("swarm events: shutdown signal received"); @@ -229,18 +231,12 @@ async fn drain_swarm_events( } } -/// Act on one deploy request, if it is addressed to this hive. +/// Act on one deploy request. Everything that arrives on this hive's own +/// deploy subject is for this hive, so there is nothing to filter. /// -/// Every hive receives every message — that is what the swarm-wide subject -/// buys — so *not* being the addressee is the ordinary case and logs at -/// `debug`, not `warn`. A malformed payload is different: the controller and -/// this end share one type, so a decode failure means they disagree about it, -/// and that is worth saying out loud. -fn handle_deploy_request( - coord: &std::sync::Arc, - hive: &str, - payload: &[u8], -) { +/// A payload that will not decode is worth a `warn`: the controller and this +/// end share one type, so a decode failure means they disagree about it. +fn handle_deploy_request(coord: &std::sync::Arc, payload: &[u8]) { let request: swarm_queue_client::DeployRequest = match serde_json::from_slice(payload) { Ok(request) => request, Err(e) => { @@ -248,10 +244,6 @@ fn handle_deploy_request( return; } }; - if request.hive != hive { - tracing::debug!(target_hive = %request.hive, agent = %request.agent, "swarm events: deploy for another hive"); - return; - } // The same insert the operator's own `rebuild` verb makes, relock and // all: "deploy this agent" means here exactly what it already meant, and // a swarm-triggered rebuild that quietly did something narrower would be diff --git a/swarm-controller/src/main.rs b/swarm-controller/src/main.rs index d7657dfb..7a82c0ef 100644 --- a/swarm-controller/src/main.rs +++ b/swarm-controller/src/main.rs @@ -91,10 +91,8 @@ enum SwarmNodeKind { /// /// Carries the hive, unlike every variant above — this is the node /// `InitAgentConfigRepo`'s doc points at when it says the hive belongs - /// on the node that sends the deploy message. The subject is swarm-wide - /// and the addressing rides in the payload; see - /// `swarm_queue_client::DEPLOY_SUBJECT` for why it is not a per-hive - /// family. + /// on the node that sends the deploy message. It names the subject the + /// message goes to, one per hive, so no other hive is woken by it. TriggerDeploy { hive: String, agent: String }, } @@ -272,16 +270,16 @@ async fn publish_deploy( ) -> hive_jobq::scheduler::Outcome { use hive_jobq::scheduler::Outcome; - let subject = swarm_queue_client::DEPLOY_SUBJECT; + // One subject per hive, so the other hives are never woken by this. + let subject = swarm_queue_client::deploy_subject(hive); let request = swarm_queue_client::DeployRequest { - hive: hive.to_owned(), agent: agent.to_owned(), }; let payload = match serde_json::to_vec(&request) { Ok(payload) => payload, Err(e) => return Outcome::Failed(format!("encoding the deploy request failed: {e}")), }; - if let Err(e) = client.publish(subject, payload.into()).await { + if let Err(e) = client.publish(subject.clone(), payload.into()).await { return Outcome::Failed(format!("publishing to {subject} failed: {e}")); } if let Err(e) = client.flush().await { diff --git a/swarm-nats-auth/src/policy.rs b/swarm-nats-auth/src/policy.rs index 43cfff04..ae5900c3 100644 --- a/swarm-nats-auth/src/policy.rs +++ b/swarm-nats-auth/src/policy.rs @@ -278,12 +278,14 @@ impl Policy { // about a change, with nothing in the controller's log to say a // permission was the reason. swarm_queue_client::KNOWLEDGE_SUBJECT.to_owned(), - // The deploy event, same shape and the same failure mode as the - // knowledge event above — one literal subject, one writer. Also - // deliberately not a per-hive family: this responder scopes - // publish only, so a per-hive subject would not stop a hive - // reading another's. See the const's own doc. - swarm_queue_client::DEPLOY_SUBJECT.to_owned(), + // The deploy events: one subject per hive, so a hive is not woken + // by a deploy meant for another. Granted as a wildcard because + // this responder has no roster — it cannot enumerate hives, and a + // grant that had to track one would be a second place to get the + // list wrong (see `hive_name`'s doc for the same argument about + // admission). Same failure mode as the knowledge event above: a + // refused publish reaches the client as a timeout. + swarm_queue_client::DEPLOY_SUBJECT_WILDCARD.to_owned(), ]); subjects } @@ -380,36 +382,40 @@ mod tests { } #[test] - fn a_reader_may_publish_the_deploy_event() { + fn a_reader_may_publish_a_deploy_event_to_any_hive() { let p = policy().permissions("swarm-controller").expect("a reader"); assert!( p.publish - .contains(&swarm_queue_client::DEPLOY_SUBJECT.to_owned()), - "the controller is the only publisher of this event; without the \ + .contains(&swarm_queue_client::DEPLOY_SUBJECT_WILDCARD.to_owned()), + "the controller is the only publisher of these events; without the \ grant its publish is refused, and a refusal arrives as a timeout" ); } #[test] - fn a_hive_may_not_publish_the_deploy_event_to_anyone_including_itself() { + fn a_hive_may_not_publish_a_deploy_event_to_anyone_including_itself() { // Same arm as the knowledge event's, and it matters more here: a forged // knowledge event makes a hive re-read a repo, while a forged deploy - // event makes it rebuild and restart a named agent. One shared subject - // means a single forged message reaches every hive in the swarm. + // event makes it rebuild and restart a named agent. // - // Note this is the half `sub` scoping would not fix even once it lands: - // reading another hive's deploy message is a confidentiality question, - // *sending* one is this. + // Both directions asserted, because splitting the subject per hive + // makes them separate strings for the first time: a hive must reach + // neither another hive's deploy subject nor its own. Nothing in the + // grant should mention this family at all. let p = policy() .permissions("hive-alpha") .expect("a hive is admitted"); - assert!( - !p.publish - .iter() - .any(|s| s == swarm_queue_client::DEPLOY_SUBJECT), - "a hive must not publish the deploy event: {:?}", - p.publish - ); + for forbidden in [ + swarm_queue_client::deploy_subject("beta"), + swarm_queue_client::deploy_subject("alpha"), + swarm_queue_client::DEPLOY_SUBJECT_WILDCARD.to_owned(), + ] { + assert!( + !p.publish.contains(&forbidden), + "a hive must not publish {forbidden}: {:?}", + p.publish + ); + } } #[test] diff --git a/swarm-queue-client/src/lib.rs b/swarm-queue-client/src/lib.rs index a9e839ee..0d561af8 100644 --- a/swarm-queue-client/src/lib.rs +++ b/swarm-queue-client/src/lib.rs @@ -173,26 +173,35 @@ pub mod status; /// permitted at all — speaks neither `jetstream` nor `kv`. pub const KNOWLEDGE_SUBJECT: &str = "$SWARM.knowledge"; -/// The subject the swarm controller publishes on to ask a hive to rebuild one -/// of its agents. Same shape as [`KNOWLEDGE_SUBJECT`]: one writer, every hive -/// subscribes, and it lives here for the same three-crate reason. +/// The subject the swarm controller publishes on to ask `hive` to rebuild one +/// of its agents. Lives here for the same three-crate reason as +/// [`KNOWLEDGE_SUBJECT`], but unlike it this is a **family, one subject per +/// hive** — a hive subscribes to its own and is never woken by a deploy meant +/// for someone else. /// -/// # Swarm-wide, not per-hive, and that is deliberate -/// -/// A `$SWARM.deploy.` family would *look* like isolation and provide -/// none: the auth-callout responder scopes **publish** only, leaving `sub` -/// unrestricted, so any hive could subscribe to another's subject just as -/// easily as to its own. Until `sub` is scoped, the per-hive split costs a -/// wider grant and buys nothing — so the addressing lives in the payload and -/// each hive filters on its own name, exactly as the knowledge event fans out. -pub const DEPLOY_SUBJECT: &str = "$SWARM.deploy"; +/// ⚠️ **That split is about noise, not confidentiality.** The auth-callout +/// responder scopes *publish* only and leaves `sub` unrestricted, so a hive +/// that wanted another's messages could still subscribe to them. What the +/// family buys is that it does not receive them by default. +#[must_use] +pub fn deploy_subject(hive: &str) -> String { + format!("{DEPLOY_SUBJECT_PREFIX}.{hive}") +} -/// What a [`DEPLOY_SUBJECT`] message carries. +/// The publish grant covering every [`deploy_subject`], for the one client +/// that may send them. A wildcard rather than a subject per hive because the +/// responder has no roster — it cannot enumerate hives, and a grant it had to +/// keep in step with one would be a second place to get the list wrong. +pub const DEPLOY_SUBJECT_WILDCARD: &str = "$SWARM.deploy.*"; + +/// Shared by [`deploy_subject`] and [`DEPLOY_SUBJECT_WILDCARD`] so the two +/// cannot drift into naming different families. +const DEPLOY_SUBJECT_PREFIX: &str = "$SWARM.deploy"; + +/// What a [`deploy_subject`] message carries. /// -/// The knowledge event has no payload — every hive does the same thing on -/// receipt. This one is addressed, so both ends have to agree on the fields, -/// which is why the type lives beside the subject rather than in whichever -/// crate happened to need it first. +/// Only the agent: the subject already names the hive, and repeating it here +/// would be two places stating one fact, free to disagree. /// /// ⚠️ **A trigger, not the config.** The hive already tracks the agent's /// config repo; putting desired state on the wire would make this message a @@ -200,9 +209,6 @@ pub const DEPLOY_SUBJECT: &str = "$SWARM.deploy"; /// missed a message would then be wrong rather than merely late. #[derive(Debug, Clone, PartialEq, Eq, serde::Serialize, serde::Deserialize)] pub struct DeployRequest { - /// Which hive should act. Every hive receives the message; the one whose - /// own name this matches is the one that rebuilds. - pub hive: String, /// The agent to rebuild, as the swarm knows it. pub agent: String, } From bfae9aa51afe0c756759842e87dd6e005e3753b9 Mon Sep 17 00:00:00 2001 From: atlas Date: Sun, 30 Aug 2026 23:30:14 +0200 Subject: [PATCH 6/7] hive-c0re: a swarm deploy for an unknown agent provisions it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Per mara on the PR: the issue is about a *new* agent, there is no approval because the operator clicked create at swarm level, and most of what a hive does on create is already done by the controller. `spawn_nodes` splits out of `spawn` the way `rebuild_nodes` already splits out of `rebuild`: two callers want the same four nodes and disagree only about what closes them. `first_deploy` is that subgraph with no approval tail, and the absence is the point — that tail exists because an operator used to approve the spawn at the hive, and asking again after they clicked create at swarm level asks the same person the same question twice. The handler's predicate is "does a container exist", not "is one running". `agents_for_meta_listing` is `nixos-container list`, so a stopped agent still counts. `Coordinator::list_agents` looks like the right check and is the registered-MCP-socket set — a stopped agent is absent from it, and this would then try to create over an existing container. Enumeration failure drops the request rather than guessing: without the list this cannot tell first deploy from rebuild, and guessing "new" is the destructive direction. Still missing, and the reason this is not the whole change: the hive seeds its own config repo with `git init` instead of cloning the one the controller already created. --- hive-c0re/src/job_queue/templates.rs | 23 ++++++++++++- hive-c0re/src/swarm_status.rs | 51 ++++++++++++++++++++++------ 2 files changed, 63 insertions(+), 11 deletions(-) diff --git a/hive-c0re/src/job_queue/templates.rs b/hive-c0re/src/job_queue/templates.rs index 94d7366a..881b52b6 100644 --- a/hive-c0re/src/job_queue/templates.rs +++ b/hive-c0re/src/job_queue/templates.rs @@ -452,6 +452,15 @@ pub fn approval_deploy(builder: &JobBuilder, agent: &str, approval_id: i64) { /// `AfterAny` onto `Provision` — the DAG's only other group-root, so its roll-up /// already carries the whole cascade. pub fn spawn(builder: &JobBuilder, agent: &str, approval_id: i64) { + let provision = spawn_nodes(builder, agent); + resolve_approval_tails(builder, approval_id, provision); +} + +/// The spawn subgraph with no tail, returning its group root. +/// +/// Split out for the same reason [`rebuild_nodes`] is: two callers want the +/// same four nodes and disagree only about what closes them. +pub(crate) fn spawn_nodes<'a>(builder: &'a JobBuilder, agent: &str) -> Handle<'a> { let a = || agent.to_owned(); let provision = builder .node(NodeKind::Provision { agent: a() }) @@ -470,8 +479,20 @@ pub fn spawn(builder: &JobBuilder, agent: &str, approval_id: i64) { .needs(Resource::Agent(a())) .part_of(create) .after_ok(dropin); + provision +} - resolve_approval_tails(builder, approval_id, provision); +/// First deploy of an agent this hive has never seen, asked for by the swarm. +/// +/// [`spawn`] without the approval tail, and the absence is the point rather +/// than an omission: that flow exists because an operator used to approve the +/// spawn *at the hive*. When the swarm asks, the operator has already clicked +/// create at swarm level — the deploy request carries that authorisation, and a +/// second gate here would be asking the same person the same question twice. +/// +/// Returns the group root so a caller can wait on the whole subtree. +pub fn first_deploy(builder: &JobBuilder, agent: &str) -> Vec { + vec![spawn_nodes(builder, agent).guid()] } /// Teardown: `Stop` → `DestroyContainer` → (`PurgeState`) → `DestroyBookkeeping`. diff --git a/hive-c0re/src/swarm_status.rs b/hive-c0re/src/swarm_status.rs index e7d43c87..4f17bb1c 100644 --- a/hive-c0re/src/swarm_status.rs +++ b/hive-c0re/src/swarm_status.rs @@ -221,7 +221,7 @@ async fn drain_swarm_events( tracing::warn!(subject = %deploy_subject, "swarm events: deploy subscription closed"); return; }; - handle_deploy_request(&coord, &msg.payload); + handle_deploy_request(&coord, &msg.payload).await; } _ = shutdown.changed() => { tracing::info!("swarm events: shutdown signal received"); @@ -236,7 +236,10 @@ async fn drain_swarm_events( /// /// A payload that will not decode is worth a `warn`: the controller and this /// end share one type, so a decode failure means they disagree about it. -fn handle_deploy_request(coord: &std::sync::Arc, payload: &[u8]) { +async fn handle_deploy_request( + coord: &std::sync::Arc, + payload: &[u8], +) { let request: swarm_queue_client::DeployRequest = match serde_json::from_slice(payload) { Ok(request) => request, Err(e) => { @@ -244,17 +247,45 @@ fn handle_deploy_request(coord: &std::sync::Arc return; } }; - // The same insert the operator's own `rebuild` verb makes, relock and - // all: "deploy this agent" means here exactly what it already meant, and - // a swarm-triggered rebuild that quietly did something narrower would be - // a second definition of the word. let agent = request.agent; - let inserted = coord - .job_queue - .insert_job(|b| crate::job_queue::templates::rebuild(b, &agent, true)); + + // Which of the two meanings this request has is decided here, and the + // predicate is "does a container exist", not "is one running": + // `agents_for_meta_listing` is `nixos-container list`, so a stopped agent + // still counts. `Coordinator::list_agents` would *look* right and is the + // registered-MCP-socket set — a stopped agent is absent from it, and this + // would then try to create a container that already exists. + let known = match crate::lifecycle::agents_for_meta_listing().await { + Ok(agents) => agents.iter().any(|spec| spec.name == agent), + Err(e) => { + // Fail closed: without the list this cannot tell first deploy from + // rebuild, and guessing wrong in the "new" direction tries to + // create over an existing container. + tracing::warn!(%agent, error = ?e, "swarm events: cannot enumerate agents; deploy dropped"); + return; + } + }; + + let inserted = if known { + // The same insert the operator's own `rebuild` verb makes, relock and + // all: "deploy this agent" means here exactly what it already meant, and + // a swarm-triggered rebuild that quietly did something narrower would be + // a second definition of the word. + coord + .job_queue + .insert_job(|b| crate::job_queue::templates::rebuild(b, &agent, true)) + } else { + // First deploy. The swarm has already created the identity, the forge + // repo and its config; what is left is hive-local, and there is no + // approval to wait on because the operator's click at swarm level is + // the authorisation. + coord + .job_queue + .insert_job(|b| crate::job_queue::templates::first_deploy(b, &agent)) + }; match inserted { Ok(_) => { - tracing::info!(%agent, "swarm events: deploy requested by the swarm, rebuild queued"); + tracing::info!(%agent, known, "swarm events: deploy requested by the swarm, queued"); coord.emit_rebuild_queue_snapshot(); } Err(e) => tracing::warn!(%agent, error = %e, "swarm events: queueing the deploy failed"), From ccf9951e5dc4ad599a8944a0da48ab44093dbc5c Mon Sep 17 00:00:00 2001 From: atlas Date: Sun, 30 Aug 2026 23:46:19 +0200 Subject: [PATCH 7/7] hive-c0re: seed an agent's proposed config from the forge when it exists The swarm writes agent-configs/ when it creates an agent, before any hive is told to deploy it. setup_proposed authored a second copy of those same bytes locally, so an agent's initial config had two sources of truth, each unaware of the other and free to disagree. It now clones that repo and falls back to the template only when there is nothing there to take. Preferred-source rather than a new-path-only variant because provision_container is the Provision node for the swarm deploy and the approval flow both, and cannot tell them apart. The approval flow creates agent-configs/ only after the first spawn (forge_after_first_spawn), so it finds nothing and lands on the template: the fallback becomes unreachable when hive-level create is removed, rather than becoming something someone has to find and delete. clone, not the neighbouring init+fetch. A failed fetch leaves an empty .git behind, and that .git is exactly the byte setup_proposed reads to decide whether seeding is still needed, so the fallback would have seen a seeded repo. git removes a directory it created when a clone fails. --branch main also makes an empty repo fail cleanly instead of cloning to an unborn HEAD that would look seeded. --- hive-c0re/src/forge/mod.rs | 6 ++-- hive-c0re/src/forge/repos.rs | 62 ++++++++++++++++++++++++++++++++ hive-c0re/src/lifecycle/setup.rs | 56 +++++++++++++++++++---------- hive-c0re/src/lifecycle/tests.rs | 4 +++ swarm-controller/src/forge.rs | 17 +++++---- 5 files changed, 118 insertions(+), 27 deletions(-) diff --git a/hive-c0re/src/forge/mod.rs b/hive-c0re/src/forge/mod.rs index 5dfdb81e..67b8ad73 100644 --- a/hive-c0re/src/forge/mod.rs +++ b/hive-c0re/src/forge/mod.rs @@ -17,9 +17,9 @@ pub use pr_merge::{ }; pub use reconcile::{reconcile_config_apply, reconcile_config_status}; pub use repos::{ - create_agent_repo, ensure_config_repo, ensure_knowledge_repo, ensure_meta_remote, ensure_repo, - ensure_shared_docs_repo, fetch_config_main_into_applied, meta_read_access, push_config, - push_meta, shared_docs_access, + clone_config_into_proposed, create_agent_repo, ensure_config_repo, ensure_knowledge_repo, + ensure_meta_remote, ensure_repo, ensure_shared_docs_repo, fetch_config_main_into_applied, + meta_read_access, push_config, push_meta, shared_docs_access, }; pub use users::{core_token, ensure_user_for, provision_user_token}; diff --git a/hive-c0re/src/forge/repos.rs b/hive-c0re/src/forge/repos.rs index ed80387c..74a7252e 100644 --- a/hive-c0re/src/forge/repos.rs +++ b/hive-c0re/src/forge/repos.rs @@ -593,6 +593,68 @@ pub async fn fetch_config_main_into_applied(name: &str) -> bool { true } +/// Seed an agent's `proposed` repo from `agent-configs/` on the forge. +/// +/// When the swarm creates an agent it writes that agent's config to the forge +/// before any hive is told to deploy it, so the hive's job here is to **take** +/// that config rather than author a second one beside it. The local template +/// is the fallback, not the default. +/// +/// Best-effort like [`fetch_config_main_into_applied`], and `false` is an +/// ordinary answer rather than an error: the forge is absent, the core token +/// is not minted yet, or `agent-configs/` has no `main` — the last of +/// which is the *normal* case for the hive-level create flow, where the repo +/// is only created after the first spawn (`actions::forge_after_first_spawn`). +/// +/// `clone` rather than `init` + `fetch`: git removes a directory it created +/// when the clone fails, and cloning into an existing empty dir leaves no +/// `.git` behind either — so the caller's "does this repo already exist" +/// check reads exactly the same before and after a failed attempt. +pub async fn clone_config_into_proposed(dir: &Path, name: &str) -> bool { + if !is_present().await { + return false; + } + let Some(token) = core_token() else { + return false; + }; + let url = forge_git_url(&format!("{CONFIG_ORG}/{name}")); + // `--branch main` fails outright against a repo with no commits, so an + // empty repo reads as "nothing to take" instead of cloning to an unborn + // HEAD that would then look like a seeded checkout. + let out = crate::lifecycle::git_command_authed(&core_auth_header(&token)) + .args([ + "clone", + "--branch", + "main", + &url, + &dir.display().to_string(), + ]) + .output() + .await; + match out { + Ok(o) if o.status.success() => { + tracing::info!(%name, "forge: seeded proposed repo from agent-configs main"); + true + } + Ok(o) => { + // `info`, not `warn`: on the hive-level create flow every first + // spawn lands here and nothing is wrong. Logged at all because + // "where did this agent's config come from" is worth answering + // from the journal, and proposed is seeded exactly once. + tracing::info!( + %name, + stderr = %String::from_utf8_lossy(&o.stderr).trim(), + "forge: no config to clone for this agent; seeding the template" + ); + false + } + Err(e) => { + tracing::warn!(%name, error = ?e, "forge: proposed clone failed to run"); + false + } + } +} + /// Run a single `git push ` 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. diff --git a/hive-c0re/src/lifecycle/setup.rs b/hive-c0re/src/lifecycle/setup.rs index e61b8950..b695c23d 100644 --- a/hive-c0re/src/lifecycle/setup.rs +++ b/hive-c0re/src/lifecycle/setup.rs @@ -14,12 +14,18 @@ use super::git::{ git, git_command, git_commit, git_read_tree_reset, git_rev_parse, git_root_commit, git_tag, }; -/// Initialize an agent's config repo. Seeds two tracked files: -/// `agent.nix` (the agent's own module) and `flake.nix` (the -/// boilerplate that lets the meta flake import this repo as an input — -/// meta locks at a specific sha and reads `nixosModules.default`, so -/// `flake.nix` must be in the commit). `flake.nix` isn't meant to be -/// edited, but it's tracked so it can be read. +/// Initialize an agent's config repo, preferring the one the forge +/// already has. `agent-configs/` is authored by whoever created +/// the agent — at swarm level that is the controller, which writes the +/// config before any hive is told to deploy it — so this **clones** +/// when there is something to clone and seeds a template only when +/// there is not. Two authors for one file is the failure mode being +/// avoided: whichever wrote second would win, and neither knows about +/// the other. +/// +/// The template ([`seed_template`]) is what the hive-level create flow +/// still lands on, since that flow creates the forge repo *after* the +/// first spawn. /// /// **Seeding is the whole of hive-c0re's write.** Changes to the repo /// arrive as PRs from a clone, via the forge, like any other code @@ -34,19 +40,9 @@ pub async fn setup_proposed(proposed_dir: &Path, name: &str) -> Result<()> { if fresh { std::fs::create_dir_all(proposed_dir) .with_context(|| format!("create {}", proposed_dir.display()))?; - let agent_path = proposed_dir.join("agent.nix"); - if !agent_path.exists() { - std::fs::write(&agent_path, initial_agent_nix(name)) - .with_context(|| format!("write {}", agent_path.display()))?; + if !crate::forge::clone_config_into_proposed(proposed_dir, name).await { + seed_template(proposed_dir, name).await?; } - let flake_path = proposed_dir.join("flake.nix"); - if !flake_path.exists() { - std::fs::write(&flake_path, initial_flake_nix()) - .with_context(|| format!("write {}", flake_path.display()))?; - } - git(proposed_dir, &["init", "--initial-branch=main"]).await?; - git(proposed_dir, &["add", "agent.nix", "flake.nix"]).await?; - git_commit(proposed_dir, "hive-c0re init").await?; } // Idempotently wire the `applied` remote — purely for the // manager's ergonomics. The URL is the path inside the manager @@ -56,6 +52,30 @@ pub async fn setup_proposed(proposed_dir: &Path, name: &str) -> Result<()> { ensure_applied_remote(proposed_dir, name).await } +/// Author the initial config in place: `agent.nix` (the agent's own module) +/// and `flake.nix` (the boilerplate that lets the meta flake import this repo +/// as an input — meta locks at a specific sha and reads +/// `nixosModules.default`, so `flake.nix` must be in the commit). `flake.nix` +/// isn't meant to be edited, but it's tracked so it can be read. +/// +/// The fallback half of [`setup_proposed`]: reached only when the forge has +/// no config for this agent to take. +async fn seed_template(proposed_dir: &Path, name: &str) -> Result<()> { + let agent_path = proposed_dir.join("agent.nix"); + if !agent_path.exists() { + std::fs::write(&agent_path, initial_agent_nix(name)) + .with_context(|| format!("write {}", agent_path.display()))?; + } + let flake_path = proposed_dir.join("flake.nix"); + if !flake_path.exists() { + std::fs::write(&flake_path, initial_flake_nix()) + .with_context(|| format!("write {}", flake_path.display()))?; + } + git(proposed_dir, &["init", "--initial-branch=main"]).await?; + git(proposed_dir, &["add", "agent.nix", "flake.nix"]).await?; + git_commit(proposed_dir, "hive-c0re init").await +} + async fn ensure_applied_remote(proposed_dir: &Path, name: &str) -> Result<()> { let want = format!("/applied/{name}/.git"); let existing = git_command() diff --git a/hive-c0re/src/lifecycle/tests.rs b/hive-c0re/src/lifecycle/tests.rs index 81e925eb..c9734efc 100644 --- a/hive-c0re/src/lifecycle/tests.rs +++ b/hive-c0re/src/lifecycle/tests.rs @@ -6,6 +6,10 @@ use super::*; /// Regression test: `setup_proposed` must seed both agent.nix and flake.nix /// in the initial commit. Before commit 5b5a93e flake.nix was missing from /// the scaffold, requiring manual creation (seen with the damocles agent). +/// +/// Exercises the template arm: there is no forge to clone from in a test +/// (`forge::is_present` needs the priv socket), so `setup_proposed` falls +/// through to `seed_template` — which is the arm this asserts about. #[tokio::test] async fn setup_proposed_seeds_flake_nix() { let dir = tempfile::tempdir().expect("tempdir"); diff --git a/swarm-controller/src/forge.rs b/swarm-controller/src/forge.rs index 74f38b25..ca4271a9 100644 --- a/swarm-controller/src/forge.rs +++ b/swarm-controller/src/forge.rs @@ -380,12 +380,17 @@ impl Client { /// Seed `repo` with the two files every agent config repo needs: /// `agent.nix` (the agent's own module) and `flake.nix` (the /// boilerplate that lets the meta flake import this repo as a flake - /// input) — same content `hive-c0re::lifecycle::setup::setup_proposed` - /// writes at the per-hive level, committed here in one atomic - /// `repo_change_files` call instead of a local `git commit` (this - /// process has no working tree to commit from — it only ever talks to - /// the forge over HTTP). The whole job of the `InitAgentConfigRepo` - /// node. + /// input) — committed here in one atomic `repo_change_files` call + /// instead of a local `git commit` (this process has no working tree to + /// commit from — it only ever talks to the forge over HTTP). The whole + /// job of the `InitAgentConfigRepo` node. + /// + /// **This is the agent's config, not a copy of it.** A hive told to + /// deploy the agent clones this repo + /// (`hive-c0re::forge::clone_config_into_proposed`) rather than writing + /// the same files again locally; the byte-identical template in + /// `hive-c0re::lifecycle::setup::seed_template` is only reached when no + /// repo exists here to clone. /// /// Idempotent by construction rather than by catching a conflict: an /// unconditional `repo_change_files` against an already-seeded repo