diff --git a/hive-c0re/src/dashboard/state_snapshot.rs b/hive-c0re/src/dashboard/state_snapshot.rs index 2970821a..ed691d56 100644 --- a/hive-c0re/src/dashboard/state_snapshot.rs +++ b/hive-c0re/src/dashboard/state_snapshot.rs @@ -56,9 +56,9 @@ pub(super) struct StateSnapshot { /// survive after a destroy-without-purge. The operator can re-spawn /// with the same name to resume, or PURG3 to wipe them. tombstones: Vec, - /// Agents whose FNV-1a hashed web UI port collides with at least - /// one other. Operator resolves by renaming. The dashboard renders - /// a banner at the top listing each cluster. + /// Sub-agents whose FNV-1a hashed web UI port collides with at + /// least one other agent. Operator resolves by renaming. The + /// dashboard renders a banner at the top listing each cluster. port_conflicts: Vec, /// Inputs in `meta/flake.lock` the operator can selectively /// `nix flake update`. Hyperhive first, then `agent-` rows. @@ -342,13 +342,11 @@ pub(super) async fn api_state( }) } -/// Group containers by their assigned web UI port; clusters with more -/// than one member are port-hash collisions the operator resolves by -/// renaming an agent. -/// -/// Every container's port comes from [`crate::lifecycle::agent_web_port`], -/// which hashes the name into one range for all of them — so the manager -/// is a candidate for a collision exactly like any sub-agent. +/// Group live containers by their assigned web UI port; clusters with +/// more than one member are port-hash collisions the operator needs +/// to resolve by renaming. Manager (fixed at 8000) and sub-agents +/// (8100..8999) can't collide with each other — collisions are +/// strictly between sub-agents. fn build_port_conflicts(containers: &[ContainerView]) -> Vec { let mut by_port: std::collections::BTreeMap> = std::collections::BTreeMap::new(); @@ -714,100 +712,3 @@ pub(super) async fn dashboard_stream( }); Sse::new(stream).keep_alive(KeepAlive::default()) } - -#[cfg(test)] -mod tests { - use super::{ContainerView, build_port_conflicts}; - - fn cv(name: &str, port: u16, running: bool) -> ContainerView { - ContainerView { - name: name.to_owned(), - container: format!("h-{name}"), - port, - running, - failed: false, - needs_update: false, - needs_login: false, - deployed_sha: None, - parent: None, - active_model: None, - paused: false, - cpu_quota: "200%".to_owned(), - memory_max: "4G".to_owned(), - } - } - - #[test] - fn distinct_ports_produce_no_conflicts() { - let cs = [cv("alice", 8101, true), cv("bob", 8102, true)]; - assert!(build_port_conflicts(&cs).is_empty()); - } - - #[test] - fn a_lone_container_does_not_conflict_with_itself() { - assert!(build_port_conflicts(&[cv("alice", 8101, true)]).is_empty()); - } - - #[test] - fn two_agents_on_one_port_are_reported_together() { - let cs = [cv("alice", 8101, true), cv("bob", 8101, true)]; - let got = build_port_conflicts(&cs); - assert_eq!(got.len(), 1); - assert_eq!(got[0].port, 8101); - assert_eq!(got[0].agents, ["alice", "bob"]); - } - - /// The manager takes its port from the same hash as everyone else, so - /// it collides like any other name. A doc comment here used to claim it - /// sat at a fixed 8000 and could not — no such special case exists. - #[test] - fn the_manager_can_collide_with_a_sub_agent() { - let cs = [cv("manager", 8101, true), cv("alice", 8101, true)]; - let got = build_port_conflicts(&cs); - assert_eq!(got.len(), 1); - assert_eq!(got[0].agents, ["alice", "manager"]); - } - - /// A stopped agent keeps its hashed port, so the clash is real the - /// moment it starts. Reporting it while it is down is the point. - #[test] - fn a_stopped_container_still_claims_its_port() { - let cs = [cv("alice", 8101, true), cv("bob", 8101, false)]; - assert_eq!(build_port_conflicts(&cs).len(), 1); - } - - #[test] - fn names_are_sorted_within_a_conflict() { - let cs = [cv("zoe", 8101, true), cv("adam", 8101, true)]; - assert_eq!(build_port_conflicts(&cs)[0].agents, ["adam", "zoe"]); - } - - #[test] - fn more_than_two_agents_land_in_one_cluster() { - let cs = [ - cv("c", 8101, true), - cv("a", 8101, true), - cv("b", 8101, true), - ]; - let got = build_port_conflicts(&cs); - assert_eq!(got.len(), 1, "one cluster, not one row per pair"); - assert_eq!(got[0].agents, ["a", "b", "c"]); - } - - #[test] - fn separate_clusters_are_ordered_by_port() { - let cs = [ - cv("d", 8300, true), - cv("c", 8300, true), - cv("b", 8200, true), - cv("a", 8200, true), - ]; - let ports: Vec = build_port_conflicts(&cs).iter().map(|p| p.port).collect(); - assert_eq!(ports, [8200, 8300]); - } - - #[test] - fn no_containers_is_not_a_conflict() { - assert!(build_port_conflicts(&[]).is_empty()); - } -} diff --git a/hive-c0re/src/lifecycle/mod.rs b/hive-c0re/src/lifecycle/mod.rs index 761b457a..087a71ae 100644 --- a/hive-c0re/src/lifecycle/mod.rs +++ b/hive-c0re/src/lifecycle/mod.rs @@ -61,9 +61,9 @@ pub fn container_claude_mount(name: &str) -> String { /// willing to lose (other agents may delete them). pub const CONTAINER_SHARED_MOUNT: &str = "/shared"; -/// Web UI port range, shared by every agent including the manager. -/// Deterministic from the agent's name (FNV-1a hash mod range size), so the -/// dashboard can compute the same port without asking hive-c0re. +/// Sub-agent web UI port range. Deterministic from the agent's name (FNV-1a +/// hash mod range size), so the dashboard can compute the same port without +/// asking hive-c0re. const WEB_PORT_BASE: u16 = 8100; const WEB_PORT_RANGE: u16 = 900;