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

@ -83,11 +83,6 @@ pub(super) fn gc_orphans(coord: &Coordinator, approvals: Vec<Approval>) -> Vec<A
approvals
.into_iter()
.filter(|a| {
// A Spawn approval is for a not-yet-existent agent; the proposed
// dir is supposed to be missing, so absence is not orphanhood.
if a.kind == hive_sh4re::approvals::ApprovalKind::Spawn {
return true;
}
if Coordinator::agent_proposed_dir(&a.agent).exists() {
true
} else {

View file

@ -232,56 +232,3 @@ pub(super) async fn post_op_send(
// no full-state refresh in between.
(axum::http::StatusCode::OK, "ok").into_response()
}
#[derive(Deserialize, ToSchema)]
pub(super) struct RequestSpawnForm {
name: String,
}
/// Queue a spawn approval for `name`.
#[utoipa::path(
post,
path = "/api/request-spawn",
request_body(content = RequestSpawnForm, content_type = "application/x-www-form-urlencoded"),
responses(
(status = 200, description = "spawn approval queued", body = String),
(status = 500, description = "missing name, or the approval submit failed"),
),
tag = "misc_api"
)]
pub(super) async fn post_request_spawn(
State(state): State<AppState>,
Form(form): Form<RequestSpawnForm>,
) -> Response {
let name = form.name.trim().to_owned();
if name.is_empty() {
return error_response("spawn: `name` required");
}
match state.coord.approvals.submit_kind(
&name,
hive_sh4re::approvals::ApprovalKind::Spawn,
"",
None,
"operator",
None,
) {
Ok(id) => {
tracing::info!(%id, %name, "operator: spawn approval queued via dashboard");
// Phase 5b: notify the dashboard event channel so live
// subscribers can append the row without a snapshot
// refetch. Spawn approvals carry no sha.
state
.coord
.emit_approval_added(crate::coordinator::ApprovalAdded {
id,
agent: &name,
approval_kind: "spawn",
sha_short: None,
description: None,
pr_number: None,
});
(StatusCode::OK, "ok").into_response()
}
Err(e) => error_response(&format!("request-spawn {name} failed: {e:#}")),
}
}

View file

@ -149,7 +149,6 @@ pub async fn serve(
.routes(routes!(misc_api::api_stats_hive))
.routes(routes!(misc_api::api_container_resources))
.routes(routes!(misc_api::post_mark_all_read))
.routes(routes!(misc_api::post_request_spawn))
.routes(routes!(misc_api::post_op_send))
.routes(routes!(build_logs::get_build_logs_all))
.routes(routes!(build_logs::get_build_log_for_node))
@ -386,7 +385,6 @@ mod router_build_probe {
.routes(routes!(misc_api::api_stats_hive))
.routes(routes!(misc_api::api_container_resources))
.routes(routes!(misc_api::post_mark_all_read))
.routes(routes!(misc_api::post_request_spawn))
.routes(routes!(misc_api::post_op_send))
.routes(routes!(build_logs::get_build_logs_all))
.routes(routes!(build_logs::get_build_log_for_node))

View file

@ -140,7 +140,7 @@ struct ApprovalHistoryView {
agent: String,
kind: &'static str,
/// First 12 chars of the canonical sha (preferred) or
/// manager-supplied ref. None for resolved spawn approvals.
/// manager-supplied ref. None when the approval carries neither.
sha_short: Option<String>,
/// `approved` / `denied` / `failed`.
status: &'static str,
@ -404,7 +404,7 @@ fn build_transient_views(
}
/// Render each pending approval into its dashboard view (short sha for
/// `MergeConfigPr`, just the name for `Spawn`).
/// `MergeConfigPr`).
/// Project a resolved sqlite row into the lean shape the dashboard
/// history tab consumes — no `diff_html` (rendering 30 of them
/// per /api/state poll would mean 30 git diffs per refresh).
@ -439,16 +439,6 @@ fn build_approval_views(approvals: Vec<Approval>) -> Vec<ApprovalView> {
let mut out = Vec::with_capacity(approvals.len());
for a in approvals {
out.push(match a.kind {
hive_sh4re::approvals::ApprovalKind::Spawn => ApprovalView {
id: a.id,
agent: a.agent.to_string(),
kind: "spawn",
sha_short: None,
description: a.description,
pr_number: None,
commit_ref: None,
requested_at: a.requested_at,
},
hive_sh4re::approvals::ApprovalKind::UpdateMetaInputs => ApprovalView {
id: a.id,
agent: a.agent.to_string(),

View file

@ -31,7 +31,7 @@ pub struct TombstoneView {
}
/// State-dir names that don't appear in the live container list. Each
/// one surfaces in the dashboard as a row with R3V1V3 + PURG3 actions.
/// one surfaces in the dashboard as a row with a PURG3 action.
///
/// ⚠️ **This lists every agent whose container is absent, not only destroyed
/// ones** — a mid-spawn agent (state dir seeded by `Provision`, container not