Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d275b50177 | ||
|
|
acaa0eb895 | ||
|
|
c35f566d15 |
2 changed files with 35 additions and 96 deletions
|
|
@ -574,7 +574,35 @@
|
|||
|
||||
// ─── state polling ──────────────────────────────────────────────────────
|
||||
let pollTimer = null;
|
||||
// Sections whose innerHTML gets blown away on each refresh. If the
|
||||
// operator is typing in one of them, skip the refresh — the next
|
||||
// tick (or a manual action) will pick it up after they blur.
|
||||
const MANAGED_SECTION_IDS = [
|
||||
'containers-section',
|
||||
'tombstones-section',
|
||||
'questions-section',
|
||||
'inbox-section',
|
||||
'approvals-section',
|
||||
];
|
||||
function operatorIsTyping() {
|
||||
const el_ = document.activeElement;
|
||||
if (!el_ || el_ === document.body) return false;
|
||||
const tag = el_.tagName;
|
||||
if (tag !== 'INPUT' && tag !== 'TEXTAREA' && tag !== 'SELECT') return false;
|
||||
return MANAGED_SECTION_IDS.some((id) => {
|
||||
const sect = document.getElementById(id);
|
||||
return sect && sect.contains(el_);
|
||||
});
|
||||
}
|
||||
async function refreshState() {
|
||||
// Don't yank the form out from under the operator. Try again
|
||||
// shortly on the next tick; eventually they'll blur and the
|
||||
// refresh lands.
|
||||
if (operatorIsTyping()) {
|
||||
if (pollTimer) clearTimeout(pollTimer);
|
||||
pollTimer = setTimeout(refreshState, 2000);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const resp = await fetch('/api/state');
|
||||
if (!resp.ok) throw new Error('http ' + resp.status);
|
||||
|
|
|
|||
|
|
@ -45,65 +45,18 @@ const WEB_PORT_RANGE: u16 = 900;
|
|||
const DEFAULT_MEMORY_MAX: &str = "2G";
|
||||
const DEFAULT_CPU_QUOTA: &str = "50%";
|
||||
|
||||
/// Returns the per-agent web UI port. Manager is fixed at `MANAGER_PORT`.
|
||||
/// For sub-agents the port is sticky once chosen:
|
||||
///
|
||||
/// - **Port file present** (`state_root/port`): use it. End of story.
|
||||
/// - **Port file absent, applied flake present**: this is a legacy
|
||||
/// agent whose container is already bound to the bare
|
||||
/// `port_hash(name)`. Don't probe; just migrate by writing that
|
||||
/// value to the port file. The container stays where it is and
|
||||
/// subsequent renders agree with it.
|
||||
/// - **Port file absent, no applied flake**: this is a fresh spawn.
|
||||
/// Probe forward from `port_hash(name)` to skip any port another
|
||||
/// sub-agent has already claimed (via port file or legacy hash).
|
||||
/// Write the chosen port back.
|
||||
/// Per-agent web UI port. Manager is fixed at `MANAGER_PORT`; every
|
||||
/// sub-agent is `WEB_PORT_BASE + FNV-1a(name) % WEB_PORT_RANGE`,
|
||||
/// pure and reproducible from just the name. Collisions are
|
||||
/// possible (birthday paradox at ~30 agents); the operator resolves
|
||||
/// them by renaming an agent (different hash → different port).
|
||||
/// Stable across hosts, restarts, and dashboard renders — no
|
||||
/// state-file dance.
|
||||
#[must_use]
|
||||
pub fn agent_web_port(name: &str) -> u16 {
|
||||
if name == MANAGER_NAME {
|
||||
return MANAGER_PORT;
|
||||
}
|
||||
let state_root = crate::coordinator::Coordinator::agent_state_root(name);
|
||||
let port_file = state_root.join("port");
|
||||
if let Ok(s) = std::fs::read_to_string(&port_file)
|
||||
&& let Ok(port) = s.trim().parse::<u16>()
|
||||
&& (WEB_PORT_BASE..WEB_PORT_BASE + WEB_PORT_RANGE).contains(&port)
|
||||
{
|
||||
return port;
|
||||
}
|
||||
let applied_exists = crate::coordinator::Coordinator::agent_applied_dir(name).exists();
|
||||
let chosen = if applied_exists {
|
||||
// Legacy agent — container already running on the hashed
|
||||
// port. Don't move it; just persist the value so future
|
||||
// calls bypass this path.
|
||||
port_hash(name)
|
||||
} else {
|
||||
let taken = scan_taken_ports(name);
|
||||
let start = port_hash(name);
|
||||
let mut port = start;
|
||||
for _ in 0..WEB_PORT_RANGE {
|
||||
if !taken.contains(&port) {
|
||||
break;
|
||||
}
|
||||
port = next_port(port);
|
||||
if port == start {
|
||||
// Range fully exhausted (very unlikely — 900 slots) —
|
||||
// give up and use the hashed value; collisions are
|
||||
// surfaced as bind errors by the harness retry loop.
|
||||
tracing::warn!(%name, "agent_web_port: range exhausted, returning hash");
|
||||
break;
|
||||
}
|
||||
}
|
||||
port
|
||||
};
|
||||
let _ = std::fs::create_dir_all(&state_root);
|
||||
if let Err(e) = std::fs::write(&port_file, format!("{chosen}\n")) {
|
||||
tracing::warn!(error = ?e, file = %port_file.display(), "persisting agent port failed");
|
||||
}
|
||||
chosen
|
||||
}
|
||||
|
||||
fn port_hash(name: &str) -> u16 {
|
||||
let mut hash: u32 = 2_166_136_261;
|
||||
for b in name.bytes() {
|
||||
hash ^= u32::from(b);
|
||||
|
|
@ -113,48 +66,6 @@ fn port_hash(name: &str) -> u16 {
|
|||
WEB_PORT_BASE + u16::try_from(hash % u32::from(WEB_PORT_RANGE)).unwrap_or(0)
|
||||
}
|
||||
|
||||
fn next_port(port: u16) -> u16 {
|
||||
let p = port + 1;
|
||||
if p >= WEB_PORT_BASE + WEB_PORT_RANGE {
|
||||
WEB_PORT_BASE
|
||||
} else {
|
||||
p
|
||||
}
|
||||
}
|
||||
|
||||
/// Scan every other agent's effective web UI port: prefer the
|
||||
/// persisted `port` file when present, fall back to the hashed
|
||||
/// value for legacy agents that pre-date the port-file scheme. The
|
||||
/// latter is important on existing deployments — without it, a new
|
||||
/// agent's collision check wouldn't see incumbents that haven't
|
||||
/// written their port file yet, and we'd re-emit the same
|
||||
/// collision the operator just hit.
|
||||
fn scan_taken_ports(name: &str) -> std::collections::HashSet<u16> {
|
||||
let mut out = std::collections::HashSet::new();
|
||||
let Ok(rd) = std::fs::read_dir("/var/lib/hyperhive/agents") else {
|
||||
return out;
|
||||
};
|
||||
for entry in rd.flatten() {
|
||||
let Ok(file_name) = entry.file_name().into_string() else {
|
||||
continue;
|
||||
};
|
||||
if file_name == name || file_name == MANAGER_NAME {
|
||||
continue;
|
||||
}
|
||||
let pf = entry.path().join("port");
|
||||
if let Ok(s) = std::fs::read_to_string(&pf)
|
||||
&& let Ok(port) = s.trim().parse::<u16>()
|
||||
{
|
||||
out.insert(port);
|
||||
} else {
|
||||
// Legacy: no port file yet → its effective port is the
|
||||
// bare hash. Treat as taken so we don't collide with it.
|
||||
out.insert(port_hash(&file_name));
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[must_use]
|
||||
pub fn container_name(name: &str) -> String {
|
||||
if name == MANAGER_NAME {
|
||||
|
|
|
|||
Loading…
Reference in a new issue