diff --git a/hive-c0re/src/dashboard/state_snapshot.rs b/hive-c0re/src/dashboard/state_snapshot.rs index ed691d56..2970821a 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, - /// 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. + /// 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. port_conflicts: Vec, /// Inputs in `meta/flake.lock` the operator can selectively /// `nix flake update`. Hyperhive first, then `agent-` rows. @@ -342,11 +342,13 @@ pub(super) async fn api_state( }) } -/// 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. +/// 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. fn build_port_conflicts(containers: &[ContainerView]) -> Vec { let mut by_port: std::collections::BTreeMap> = std::collections::BTreeMap::new(); @@ -712,3 +714,100 @@ 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 087a71ae..761b457a 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"; -/// 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. +/// 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. const WEB_PORT_BASE: u16 = 8100; const WEB_PORT_RANGE: u16 = 900;