Watch
0
0
Fork
You've already forked hyperhive
0

Make agent creation swarm-only and refuse a name placed on another hive

swarm-controller's POST /api/agents now refuses (409) a name the swarm
has already placed on a different hive: a non-Destroyed declaration in
that hive's wanted state, or a SetAgentWanted node still queued for it.
The same name on the same hive is that agent being re-created and goes
through. A wanted state that cannot be read refuses (503/500) instead of
reading as "placed nowhere". Creations are serialised from that read to
the graph insert so two concurrent creations of one name cannot both
pass.

Hive-level creation is removed: hivectl `agent create` / `request-create`,
HostRequest::Spawn / RequestSpawn, the dashboard POST /api/request-spawn
route, and ApprovalKind::Spawn with its approve/resolve arms and the
approval-carrying `templates::spawn`. The swarm path (deploy request or
wanted-state sweep -> queue_first_deploy -> templates::first_deploy) used
none of them. Old `spawn` approval rows are skipped by collect_lenient,
as `init_config` rows were in a3b672d1.

policy.rs's comment on agent_object_name stated swarm-wide name
uniqueness as a fact; it now says where it is enforced and what that
check cannot see.

Refs #4396
This commit is contained in:
atlas 2026-09-29 15:11:57 +02:00
commit 5785c0024c
35 changed files with 376 additions and 434 deletions

View file

@ -109,20 +109,6 @@ async fn write_response(
async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
let result: anyhow::Result<HostResponse> = async {
Ok(match req {
HostRequest::Spawn { name } => handle_spawn(&coord, name.as_str()).await?,
HostRequest::RequestSpawn { name } => {
tracing::info!(%name, "request_spawn");
let id = coord.approvals.submit_kind(
name.as_str(),
hive_sh4re::approvals::ApprovalKind::Spawn,
"",
None,
"operator",
None,
)?;
tracing::info!(%id, %name, "spawn approval queued");
HostResponse::success()
}
HostRequest::Kill { name } => submit_single(&coord, name.as_str(), Verb::Kill).await,
HostRequest::Restart { name } => {
submit_single(&coord, name.as_str(), Verb::Restart).await
@ -278,49 +264,6 @@ async fn dispatch(req: &HostRequest, coord: Arc<Coordinator>) -> HostResponse {
}
}
/// Create + start the container for `name` and bind its MCP listener. On a
/// failed spawn nothing was registered: post a swarm notice and return the error.
async fn handle_spawn(coord: &Arc<Coordinator>, name: &str) -> Result<HostResponse> {
tracing::info!(%name, "spawn");
let agent_dir = crate::paths::agent_runtime_dir(name);
let hive = coord.hive_env();
let paths = Coordinator::agent_paths(name, agent_dir)?;
// lifecycle::spawn creates the runtime dir internally before start.
// MCP listener registration is event-driven: bind immediately on
// success so the harness can connect on its first turn without
// waiting for any poll interval.
match lifecycle::spawn(name, &hive, &paths).await {
Ok(()) => {
if let Err(e) = coord.power.set(name, crate::power::Wanted::Up) {
tracing::warn!(%name, error = ?e, "agent_power: set wanted=up failed");
}
// Bind the MCP listener now that the container is starting up.
// The harness connects to this socket on its first turn.
coord.register_agent(name)?;
crate::swarm_notices::notify(
"core",
Some(format!("spawned:{name}")),
format!("agent '{name}' spawned"),
None,
)
.await;
}
Err(e) => {
// Spawn failed: register_agent was never called, so there is
// nothing to unregister. Notify the swarm and propagate.
crate::swarm_notices::notify(
"core",
Some(format!("spawned:{name}")),
format!("agent '{name}' spawn FAILED: {e:#}"),
None,
)
.await;
return Err(e);
}
}
Ok(HostResponse::success())
}
/// `hivectl pause|resume` / the dashboard toggle: write or remove the
/// agent's pause marker.
///