Compare commits
7 changed files with 78 additions and 175 deletions
|
|
@ -43,7 +43,7 @@ hive-c0re/ host daemon + sibling operator CLI (lib + 2 bins)
|
||||||
src/agent_sockets.rs writes `/var/lib/hyperhive/agent-sockets.json` on
|
src/agent_sockets.rs writes `/var/lib/hyperhive/agent-sockets.json` on
|
||||||
meta sync; gateway reads name→socket-path
|
meta sync; gateway reads name→socket-path
|
||||||
map for unix-domain per-agent UI binding.
|
map for unix-domain per-agent UI binding.
|
||||||
Entries are filtered by a `hyperhive-socket-bound` marker file
|
Entries are filtered by a `.bound` marker file
|
||||||
the harness drops next to its socket after a
|
the harness drops next to its socket after a
|
||||||
successful `bind()` — pre-bind agents stay out
|
successful `bind()` — pre-bind agents stay out
|
||||||
of the map so the gateway never races a
|
of the map so the gateway never races a
|
||||||
|
|
|
||||||
|
|
@ -84,22 +84,21 @@ unix-domain socket as each agent opts in. The mechanism:
|
||||||
harness's `unlink + bind(2)` cycle on socket replace. Per-agent
|
harness's `unlink + bind(2)` cycle on socket replace. Per-agent
|
||||||
subdir keeps each agent's container blind to siblings' sockets.
|
subdir keeps each agent's container blind to siblings' sockets.
|
||||||
3. **Marker gate**. After successful `bind_unix`, the harness drops
|
3. **Marker gate**. After successful `bind_unix`, the harness drops
|
||||||
`<dir>/hyperhive-socket-bound` next to the socket. c0re's
|
`<dir>/.bound` next to the socket. c0re's `agent_sockets::write`
|
||||||
`agent_sockets::write` filters its JSON map by marker presence —
|
filters its JSON map by marker presence — only agents whose
|
||||||
only agents whose harness has actually bound the socket appear there.
|
harness has actually bound the socket appear there. Without this
|
||||||
Without this filter, the gateway would `proxy_pass` to a non-existent
|
filter, the gateway would `proxy_pass` to a non-existent socket
|
||||||
socket for every sub-agent that hasn't opted in yet. (Legacy name
|
for every sub-agent that hasn't opted in yet.
|
||||||
`.bound` also accepted during the transition window.)
|
|
||||||
4. **Gateway side**. `gateway_nginx::write` generates
|
4. **Gateway side**. `gateway_nginx::write` generates
|
||||||
`/var/lib/hyperhive/agents.conf` — a plain nginx include file with
|
`/var/lib/hyperhive/agents.conf` — a plain nginx include file with
|
||||||
one `location /agent/<name>/` block per agent. UDS upstream
|
one `location /agent/<name>/` block per agent. UDS upstream
|
||||||
(`http://unix:/run/hive-agent/<name>/web.sock:/`) when
|
(`http://unix:/run/hive-agent/<name>/web.sock:/`) when `.bound`
|
||||||
`hyperhive-socket-bound` marker present; TCP loopback otherwise.
|
marker present; TCP loopback otherwise. The gateway container
|
||||||
The gateway container bind-mounts `/var/lib/hyperhive/` at
|
bind-mounts `/var/lib/hyperhive/` at `/run/hive-state/`; nginx
|
||||||
`/run/hive-state/`; nginx includes `/run/hive-state/agents.conf`.
|
includes `/run/hive-state/agents.conf`. A systemd path unit
|
||||||
A systemd path unit (`hive-gateway-agents-conf.path`) inside the
|
(`hive-gateway-agents-conf.path`) inside the container watches the
|
||||||
container watches the file and fires `nginx -s reload` on every
|
file and fires `nginx -s reload` on every atomic rename from c0re
|
||||||
atomic rename from c0re — no `nixos-rebuild` needed.
|
— no `nixos-rebuild` needed (#869).
|
||||||
|
|
||||||
c0re regenerates `agents.conf` (and fires the path unit → reload) on
|
c0re regenerates `agents.conf` (and fires the path unit → reload) on
|
||||||
two triggers: every topology change (new/removed agents) and every
|
two triggers: every topology change (new/removed agents) and every
|
||||||
|
|
|
||||||
|
|
@ -104,28 +104,17 @@ the turn loop continue.
|
||||||
No host-side vacuum yet — tracked separately. Target retention
|
No host-side vacuum yet — tracked separately. Target retention
|
||||||
~90 days, age-only sweep like events_vacuum.
|
~90 days, age-only sweep like events_vacuum.
|
||||||
|
|
||||||
### `/state/hyperhive-harness.json` (per agent)
|
### `/state/hyperhive-rate-limited` (per agent)
|
||||||
|
|
||||||
Consolidated harness state file written atomically (`.tmp` + rename) by
|
Sentinel file written by `Bus::emit_status("rate_limited")` when the
|
||||||
`Bus::emit_status` whenever rate-limited or login-failed flags change.
|
harness detects a 429 / rate-limit response from the Claude API, and
|
||||||
Shape:
|
removed when the retry sleep expires (any subsequent status emit
|
||||||
|
clears it). The file's presence is checked by hive-c0re's
|
||||||
```json
|
`container_view::is_rate_limited` on each `build_all` sweep (~10s) to
|
||||||
{ "rate_limited": false, "needs_login": false }
|
populate `ContainerView.rate_limited` for the dashboard. Survives a
|
||||||
```
|
harness restart (the Bus reads it back at boot and restores the flag),
|
||||||
|
so the badge remains accurate if hive-c0re restarts while the harness
|
||||||
- `rate_limited` — set when the harness detects a 429 from the Claude
|
is mid-sleep.
|
||||||
API; cleared by any subsequent status emit. Drives
|
|
||||||
`ContainerView.rate_limited` on the dashboard.
|
|
||||||
- `needs_login` — set when a turn hits 401 (expired OAuth credentials);
|
|
||||||
cleared by `"online"` status (re-auth completed). Drives the
|
|
||||||
`needs_login` flag alongside the `claude_has_session` check.
|
|
||||||
|
|
||||||
hive-c0re reads this file on each `build_all` sweep (~10s) via
|
|
||||||
`container_view::read_harness_flags`. Falls back to the legacy individual
|
|
||||||
sentinel files (`hyperhive-rate-limited`, `hyperhive-needs-login`) if the
|
|
||||||
JSON is absent, so existing containers keep working through the transition
|
|
||||||
window before their next rebuild.
|
|
||||||
|
|
||||||
### `/state/hyperhive-model` (per agent)
|
### `/state/hyperhive-model` (per agent)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -56,59 +56,6 @@ fn persist_model(name: &str) -> std::io::Result<()> {
|
||||||
std::fs::write(path, format!("{name}\n"))
|
std::fs::write(path, format!("{name}\n"))
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
// Consolidated harness state file
|
|
||||||
// ---------------------------------------------------------------------------
|
|
||||||
//
|
|
||||||
// `hyperhive-harness.json` replaces the two legacy boolean sentinel files
|
|
||||||
// (`hyperhive-rate-limited`, `hyperhive-needs-login`) that grew organically
|
|
||||||
// and had no shared schema. A single JSON file is self-documenting, atomic
|
|
||||||
// to write, and cheaper for hive-c0re to read on each sweep (one fopen vs
|
|
||||||
// two stat calls). See `docs/persistence.md::Harness state files`.
|
|
||||||
//
|
|
||||||
// Legacy sentinel files written by older harness builds are still honoured
|
|
||||||
// by `read_harness_state` so in-place upgrades don't lose state (the new
|
|
||||||
// harness re-normalises on first write). Old files are not deleted — they
|
|
||||||
// expire naturally when the state dir is purged. `hive-c0re::container_view`
|
|
||||||
// also checks the legacy paths as a fallback during the transition window.
|
|
||||||
|
|
||||||
const HARNESS_JSON: &str = "hyperhive-harness.json";
|
|
||||||
|
|
||||||
fn harness_json_path() -> PathBuf {
|
|
||||||
crate::paths::state_dir().join(HARNESS_JSON)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_harness_state() -> (bool, bool) {
|
|
||||||
// Try the new consolidated file first.
|
|
||||||
if let Ok(raw) = std::fs::read_to_string(harness_json_path()) {
|
|
||||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw) {
|
|
||||||
let rate_limited = v.get("rate_limited").and_then(|x| x.as_bool()).unwrap_or(false);
|
|
||||||
let needs_login = v.get("needs_login").and_then(|x| x.as_bool()).unwrap_or(false);
|
|
||||||
return (rate_limited, needs_login);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Fall back to legacy sentinel files written by older harness builds.
|
|
||||||
let state_dir = crate::paths::state_dir();
|
|
||||||
let rate_limited = state_dir.join("hyperhive-rate-limited").exists();
|
|
||||||
let needs_login = state_dir.join("hyperhive-needs-login").exists();
|
|
||||||
(rate_limited, needs_login)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Write harness state atomically via a `.tmp` + `rename` pair so
|
|
||||||
/// hive-c0re never reads a partial file.
|
|
||||||
fn write_harness_state(rate_limited: bool, needs_login: bool) {
|
|
||||||
let path = harness_json_path();
|
|
||||||
let body = serde_json::json!({
|
|
||||||
"rate_limited": rate_limited,
|
|
||||||
"needs_login": needs_login,
|
|
||||||
})
|
|
||||||
.to_string();
|
|
||||||
let tmp = path.with_extension("json.tmp");
|
|
||||||
if std::fs::write(&tmp, &body).is_ok() {
|
|
||||||
let _ = std::fs::rename(&tmp, &path);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn now_unix() -> i64 {
|
fn now_unix() -> i64 {
|
||||||
std::time::SystemTime::now()
|
std::time::SystemTime::now()
|
||||||
.duration_since(std::time::UNIX_EPOCH)
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
|
@ -509,10 +456,11 @@ impl Bus {
|
||||||
|| load_model().unwrap_or_else(|| DEFAULT_MODEL.to_owned()),
|
|| load_model().unwrap_or_else(|| DEFAULT_MODEL.to_owned()),
|
||||||
str::to_owned,
|
str::to_owned,
|
||||||
);
|
);
|
||||||
// Restore rate_limited (and needs_login) from the consolidated
|
// Restore rate_limited from the sentinel file — if the harness
|
||||||
// harness state file so the dashboard shows the correct status
|
// crashed while parked, we should still show the right status on
|
||||||
// on cold load if the harness crashed while parked.
|
// cold load until the next turn clears it.
|
||||||
let (was_rate_limited, _was_needs_login) = read_harness_state();
|
let sentinel = crate::paths::state_dir().join("hyperhive-rate-limited");
|
||||||
|
let was_rate_limited = sentinel.exists();
|
||||||
Self {
|
Self {
|
||||||
tx: Arc::new(tx),
|
tx: Arc::new(tx),
|
||||||
event_seq: Arc::new(AtomicU64::new(0)),
|
event_seq: Arc::new(AtomicU64::new(0)),
|
||||||
|
|
@ -740,46 +688,34 @@ impl Bus {
|
||||||
/// `Arc<Mutex<LoginState>>` should also call this so the web UI
|
/// `Arc<Mutex<LoginState>>` should also call this so the web UI
|
||||||
/// drops its periodic /api/state poll while a turn loop is running.
|
/// drops its periodic /api/state poll while a turn loop is running.
|
||||||
///
|
///
|
||||||
/// `hyperhive-harness.json` persists across harness restarts so the
|
/// Sentinel files survive harness restart so the host-side dashboard
|
||||||
/// host-side dashboard can render the status without a live socket call:
|
/// can render the status without a live socket call:
|
||||||
/// - `"rate_limited"` sets `rate_limited: true` in the JSON.
|
/// - `"rate_limited"` writes `{state_dir}/hyperhive-rate-limited`
|
||||||
/// - `"needs_login_idle"` sets `needs_login: true` in the JSON.
|
/// (cleared by any other status).
|
||||||
/// - `"online"` clears both fields — the agent is healthy again.
|
/// - `"needs_login_idle"` writes `{state_dir}/hyperhive-needs-login`
|
||||||
/// - Other statuses clear `rate_limited` only; `needs_login` is sticky
|
/// so a 401-triggered re-auth flag persists across harness restart.
|
||||||
/// until `"online"` (re-auth completed successfully).
|
/// The web UI's `/login` POST handler clears it via
|
||||||
///
|
/// `clear_needs_login_sentinel` once the operator re-auths.
|
||||||
/// Writes are atomic (`.tmp` + `rename`) so hive-c0re never reads a
|
/// - `"online"` clears both sentinels — the agent is healthy again.
|
||||||
/// partial file during its ~10s sweep.
|
|
||||||
pub fn emit_status(&self, status: impl Into<String>) {
|
pub fn emit_status(&self, status: impl Into<String>) {
|
||||||
let status = status.into();
|
let status = status.into();
|
||||||
let new_rate_limited = status == "rate_limited";
|
let rate_limited_path = crate::paths::state_dir().join("hyperhive-rate-limited");
|
||||||
if new_rate_limited {
|
let needs_login_path = crate::paths::state_dir().join("hyperhive-needs-login");
|
||||||
|
if status == "rate_limited" {
|
||||||
self.rate_limited.store(true, Ordering::Relaxed);
|
self.rate_limited.store(true, Ordering::Relaxed);
|
||||||
|
let _ = std::fs::write(&rate_limited_path, b"");
|
||||||
} else {
|
} else {
|
||||||
self.rate_limited.store(false, Ordering::Relaxed);
|
self.rate_limited.store(false, Ordering::Relaxed);
|
||||||
|
let _ = std::fs::remove_file(&rate_limited_path);
|
||||||
}
|
}
|
||||||
// Read the current persisted needs_login so we don't flip it on
|
if status == "needs_login_idle" {
|
||||||
// statuses that shouldn't touch it (e.g. `needs_login_in_progress`
|
let _ = std::fs::write(&needs_login_path, b"");
|
||||||
// is a transient mid-flow status; only `needs_login_idle` and
|
|
||||||
// `online` should change the persistent flag).
|
|
||||||
//
|
|
||||||
// Known non-atomicity: this is a read-modify-write. Two concurrent
|
|
||||||
// `emit_status` calls could clobber each other's `needs_login`
|
|
||||||
// change if they raced between the read and the write. In practice
|
|
||||||
// this is safe: the turn loop is sequential and the login flow
|
|
||||||
// (`needs_login_idle` / `online`) only fires outside of active
|
|
||||||
// turns, so the two callers never overlap. Documented rather than
|
|
||||||
// locked because adding a Mutex here would be overkill for the
|
|
||||||
// actual call pattern.
|
|
||||||
let (_, current_needs_login) = read_harness_state();
|
|
||||||
let new_needs_login = if status == "needs_login_idle" {
|
|
||||||
true
|
|
||||||
} else if status == "online" {
|
} else if status == "online" {
|
||||||
false
|
// Re-auth completed (or manual flip back to online) — drop
|
||||||
} else {
|
// the sentinel. `needs_login_in_progress` is a transient
|
||||||
current_needs_login
|
// mid-flow status and shouldn't clear yet.
|
||||||
};
|
let _ = std::fs::remove_file(&needs_login_path);
|
||||||
write_harness_state(new_rate_limited, new_needs_login);
|
}
|
||||||
self.emit(LiveEvent::StatusChanged { status });
|
self.emit(LiveEvent::StatusChanged { status });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -194,15 +194,15 @@ fn bind_unix(path: &Path) -> Result<tokio::net::UnixListener> {
|
||||||
use std::os::unix::fs::PermissionsExt;
|
use std::os::unix::fs::PermissionsExt;
|
||||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o660))
|
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o660))
|
||||||
.with_context(|| format!("set perms on {}", path.display()))?;
|
.with_context(|| format!("set perms on {}", path.display()))?;
|
||||||
// Best-effort ready marker: failed write isn't fatal (the harness
|
// Best-effort .bound marker: failed write isn't fatal (the harness
|
||||||
// still binds + serves), it just means the gateway side keeps the
|
// still binds + serves), it just means the gateway side keeps the
|
||||||
// TCP upstream for one more sync tick.
|
// TCP upstream for one more sync tick.
|
||||||
if let Some(parent) = path.parent() {
|
if let Some(parent) = path.parent() {
|
||||||
let marker = parent.join("hyperhive-socket-bound");
|
let marker = parent.join(".bound");
|
||||||
if let Err(e) = std::fs::write(&marker, b"") {
|
if let Err(e) = std::fs::write(&marker, b"") {
|
||||||
tracing::warn!(
|
tracing::warn!(
|
||||||
marker = %marker.display(), error = %e,
|
marker = %marker.display(), error = %e,
|
||||||
"failed to write hyperhive-socket-bound marker — gateway may keep TCP upstream"
|
"failed to write .bound marker — gateway may keep TCP upstream"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,8 @@
|
||||||
//! excluded from the map (manager UI routes via the dashboard
|
//! excluded from the map (manager UI routes via the dashboard
|
||||||
//! upstream, not per-agent `/agent/<name>/`).
|
//! upstream, not per-agent `/agent/<name>/`).
|
||||||
//!
|
//!
|
||||||
//! Full mechanism — per-agent subdir bind-mount, `hyperhive-socket-bound`
|
//! Full mechanism — per-agent subdir bind-mount, `.bound` marker
|
||||||
//! marker gate, gateway UDS upstream, transition vs `agent-ports.json`,
|
//! gate, gateway UDS upstream, transition vs `agent-ports.json`,
|
||||||
//! 10s poll loop: `docs/gateway.md::Per-agent unix-socket upstream`.
|
//! 10s poll loop: `docs/gateway.md::Per-agent unix-socket upstream`.
|
||||||
|
|
||||||
use std::collections::BTreeMap;
|
use std::collections::BTreeMap;
|
||||||
|
|
@ -36,13 +36,7 @@ pub const SOCKET_FILENAME: &str = "web.sock";
|
||||||
/// publish the unix upstream for this agent yet". Without this gate
|
/// publish the unix upstream for this agent yet". Without this gate
|
||||||
/// the gateway would `proxy_pass` to a non-existent socket for every
|
/// the gateway would `proxy_pass` to a non-existent socket for every
|
||||||
/// sub-agent that hasn't flipped the option yet.
|
/// sub-agent that hasn't flipped the option yet.
|
||||||
///
|
pub const READY_MARKER: &str = ".bound";
|
||||||
/// Renamed from `.bound` (legacy) to match the `hyperhive-` prefix
|
|
||||||
/// convention for all harness-written state files (#838). `build_map`
|
|
||||||
/// checks both names during the transition window so existing containers
|
|
||||||
/// don't lose gateway routing before their next rebuild.
|
|
||||||
pub const READY_MARKER: &str = "hyperhive-socket-bound";
|
|
||||||
const READY_MARKER_LEGACY: &str = ".bound";
|
|
||||||
|
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn host_sockets_path() -> PathBuf {
|
pub fn host_sockets_path() -> PathBuf {
|
||||||
|
|
@ -78,27 +72,18 @@ pub fn socket_path_for(name: &str) -> PathBuf {
|
||||||
/// `proxy_pass` to a non-existent socket for every sub-agent that
|
/// `proxy_pass` to a non-existent socket for every sub-agent that
|
||||||
/// hasn't yet flipped `hyperhive.web.useUnixSocket = true`.
|
/// hasn't yet flipped `hyperhive.web.useUnixSocket = true`.
|
||||||
///
|
///
|
||||||
/// Accepts either the new `hyperhive-socket-bound` marker or the legacy
|
|
||||||
/// `.bound` marker so existing containers keep their gateway routing
|
|
||||||
/// through the transition window (before their next rebuild writes the
|
|
||||||
/// new marker name).
|
|
||||||
///
|
|
||||||
/// `BTreeMap` keeps the JSON output sorted by key so a re-emit
|
/// `BTreeMap` keeps the JSON output sorted by key so a re-emit
|
||||||
/// without churn produces byte-identical output — same idempotency
|
/// without churn produces byte-identical output — same idempotency
|
||||||
/// shape `agent_ports::write` relies on.
|
/// shape `agent_ports::write` relies on.
|
||||||
#[must_use]
|
#[must_use]
|
||||||
pub fn build_map(names: &[String]) -> BTreeMap<String, PathBuf> {
|
pub fn build_map(names: &[String]) -> BTreeMap<String, PathBuf> {
|
||||||
build_map_with(names, |name| {
|
build_map_with(names, |name| ready_marker_for(name).exists())
|
||||||
ready_marker_for(name).exists()
|
|
||||||
|| agent_dir_for(name).join(READY_MARKER_LEGACY).exists()
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Body of `build_map` with the ready-check parameterised. Tests
|
/// Body of `build_map` with the ready-check parameterised. Tests
|
||||||
/// pass a predicate they control (no real filesystem access).
|
/// pass a predicate they control (no real filesystem access).
|
||||||
/// Production callers go through `build_map` which wires the
|
/// Production callers go through `build_map` which wires the
|
||||||
/// predicate to the on-disk `hyperhive-socket-bound` (or legacy
|
/// predicate to the on-disk `.bound` marker check.
|
||||||
/// `.bound`) marker check.
|
|
||||||
fn build_map_with<F>(names: &[String], is_ready: F) -> BTreeMap<String, PathBuf>
|
fn build_map_with<F>(names: &[String], is_ready: F) -> BTreeMap<String, PathBuf>
|
||||||
where
|
where
|
||||||
F: Fn(&str) -> bool,
|
F: Fn(&str) -> bool,
|
||||||
|
|
@ -111,7 +96,7 @@ where
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Path to the `hyperhive-socket-bound` marker file the harness writes
|
/// Path to the per-agent `.bound` marker file the harness writes
|
||||||
/// after a successful `bind_unix`. Lives next to `web.sock` in the
|
/// after a successful `bind_unix`. Lives next to `web.sock` in the
|
||||||
/// per-agent subdir so it's covered by the same bind-mount and same
|
/// per-agent subdir so it's covered by the same bind-mount and same
|
||||||
/// per-agent isolation as the socket itself.
|
/// per-agent isolation as the socket itself.
|
||||||
|
|
@ -286,9 +271,10 @@ mod tests {
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn build_map_filters_by_ready_predicate() {
|
fn build_map_filters_by_ready_predicate() {
|
||||||
// Only ready agents (with `hyperhive-socket-bound` marker) get
|
// The new gate: only ready agents (with `.bound` marker) get
|
||||||
// published. Pin the behaviour so a future refactor that drops
|
// published. Pin the behaviour so a future refactor that
|
||||||
// the filter surfaces here, not as a 502-spew in the gateway.
|
// drops the filter surfaces here, not as a 502-spew in the
|
||||||
|
// gateway.
|
||||||
let names: Vec<String> = ["iris", "argus", "atlas"]
|
let names: Vec<String> = ["iris", "argus", "atlas"]
|
||||||
.iter()
|
.iter()
|
||||||
.map(|s| (*s).to_owned())
|
.map(|s| (*s).to_owned())
|
||||||
|
|
@ -310,7 +296,7 @@ mod tests {
|
||||||
let marker = ready_marker_for("iris");
|
let marker = ready_marker_for("iris");
|
||||||
let socket = socket_path_for("iris");
|
let socket = socket_path_for("iris");
|
||||||
assert_eq!(marker.parent(), socket.parent());
|
assert_eq!(marker.parent(), socket.parent());
|
||||||
assert_eq!(marker, Path::new("/run/hive-agent/iris/hyperhive-socket-bound"));
|
assert_eq!(marker, Path::new("/run/hive-agent/iris/.bound"));
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
|
|
|
||||||
|
|
@ -211,31 +211,24 @@ fn read_dashboard_links(name: &str) -> Vec<DashboardLink> {
|
||||||
serde_json::from_str::<Vec<DashboardLink>>(&text).unwrap_or_default()
|
serde_json::from_str::<Vec<DashboardLink>>(&text).unwrap_or_default()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read `rate_limited` + `needs_login` from the consolidated
|
/// Returns true if the agent's harness is currently parked after an API
|
||||||
/// `hyperhive-harness.json`. Falls back to the legacy individual
|
/// rate-limit response. Detected via the sentinel file written by
|
||||||
/// sentinel files written by older harness builds so in-place upgrades
|
/// `hive_ag3nt::events::Bus::emit_status("rate_limited")`.
|
||||||
/// don't lose state during the transition window.
|
|
||||||
fn read_harness_flags(name: &str) -> (bool, bool) {
|
|
||||||
let dir = Coordinator::agent_notes_dir(name);
|
|
||||||
if let Ok(raw) = std::fs::read_to_string(dir.join("hyperhive-harness.json")) {
|
|
||||||
if let Ok(v) = serde_json::from_str::<serde_json::Value>(&raw) {
|
|
||||||
let rl = v.get("rate_limited").and_then(|x| x.as_bool()).unwrap_or(false);
|
|
||||||
let nl = v.get("needs_login").and_then(|x| x.as_bool()).unwrap_or(false);
|
|
||||||
return (rl, nl);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
// Legacy fallback: presence of individual sentinel files.
|
|
||||||
let rate_limited = dir.join("hyperhive-rate-limited").exists();
|
|
||||||
let needs_login = dir.join("hyperhive-needs-login").exists();
|
|
||||||
(rate_limited, needs_login)
|
|
||||||
}
|
|
||||||
|
|
||||||
fn is_rate_limited(name: &str) -> bool {
|
fn is_rate_limited(name: &str) -> bool {
|
||||||
read_harness_flags(name).0
|
Coordinator::agent_notes_dir(name)
|
||||||
|
.join("hyperhive-rate-limited")
|
||||||
|
.exists()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// True when the harness wrote `{state_dir}/hyperhive-needs-login`
|
||||||
|
/// after a 401 mid-turn. Lets the dashboard surface `needs_login` for
|
||||||
|
/// agents whose `/root/.claude/` dir still exists (so
|
||||||
|
/// `claude_has_session` returns true) but whose OAuth credentials
|
||||||
|
/// inside it have actually expired.
|
||||||
fn auth_failed_sentinel(name: &str) -> bool {
|
fn auth_failed_sentinel(name: &str) -> bool {
|
||||||
read_harness_flags(name).1
|
Coordinator::agent_notes_dir(name)
|
||||||
|
.join("hyperhive-needs-login")
|
||||||
|
.exists()
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Read the agent's free-text status and the Unix timestamp when it was last set
|
/// Read the agent's free-text status and the Unix timestamp when it was last set
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue