Compare commits

...
Author SHA1 Message Date
müde
d275b50177 dashboard: don't yank the form away while operator is typing
every refreshState tick does root.innerHTML = '' across the managed
sections, which destroys any focused input. detect the case before
re-rendering: if document.activeElement is an INPUT / TEXTAREA /
SELECT inside one of the managed sections, skip this tick and try
again in 2s. eventually the operator blurs and the refresh lands.

managed section ids: containers / tombstones / questions / inbox /
approvals. msgflow + message-flow SSE rows don't have inputs so
they're not affected.
2026-05-15 21:19:01 +02:00
müde
acaa0eb895 agent_web_port: back to pure hash, drop port-file dance
operator's call: probing-forward + state-file machinery is more
brittle than the bug it tried to fix. revert to the original
deterministic FNV-1a hash mod 900. collisions are real but rare;
operator resolves by renaming (different name → different hash) and
rebuilding. no per-agent port file, no scan, no migration path,
nothing to drift out of sync with the running container.

existing port files on disk are silently ignored — operator
rebuilds affected agents to regenerate flakes from the deterministic
hash.
2026-05-15 21:17:31 +02:00
müde
c35f566d15 agent_web_port: actually resolve legacy collisions
previous attempt was wrong: the legacy branch returned port_hash
unconditionally, so two legacies hashing to the same port both
wrote that port and the collision persisted (test still trying to
bind coder's port).

new rule: always probe forward from port_hash, with scan_taken_ports
parameterised by include_implicit_hashes:

- legacy migration (applied dir exists, no port file): pass false.
  scan only counts other agents' port files. first-queried legacy
  claims its hash; subsequent colliders see the first's port file
  and probe forward. we don't know which legacy originally won
  the bind race, so first-write-wins; the loser was already
  crash-looping anyway and gets a fresh port to rebuild to.

- fresh spawn (no applied dir): pass true. counts port files AND
  implicit hashes for not-yet-migrated legacies, so a new spawn
  doesn't race with an unmigrated peer.

migration note for affected users: agents whose port file says
something other than their hashed port may have been corrupted by
the previous fix. Hit ↻ R3BU1LD on the offender to regenerate the
flake (uses the current port file) and the container will bind
the right port on restart.
2026-05-15 21:13:17 +02:00
2 changed files with 35 additions and 96 deletions

View file

@ -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);

View file

@ -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 {