refactor(#838): consolidate harness state files into hyperhive-harness.json
This commit is contained in:
parent
f6b3145349
commit
fce1f49f6a
7 changed files with 166 additions and 78 deletions
|
|
@ -56,6 +56,59 @@ fn persist_model(name: &str) -> std::io::Result<()> {
|
|||
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 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
|
|
@ -456,11 +509,10 @@ impl Bus {
|
|||
|| load_model().unwrap_or_else(|| DEFAULT_MODEL.to_owned()),
|
||||
str::to_owned,
|
||||
);
|
||||
// Restore rate_limited from the sentinel file — if the harness
|
||||
// crashed while parked, we should still show the right status on
|
||||
// cold load until the next turn clears it.
|
||||
let sentinel = crate::paths::state_dir().join("hyperhive-rate-limited");
|
||||
let was_rate_limited = sentinel.exists();
|
||||
// Restore rate_limited (and needs_login) from the consolidated
|
||||
// harness state file so the dashboard shows the correct status
|
||||
// on cold load if the harness crashed while parked.
|
||||
let (was_rate_limited, _was_needs_login) = read_harness_state();
|
||||
Self {
|
||||
tx: Arc::new(tx),
|
||||
event_seq: Arc::new(AtomicU64::new(0)),
|
||||
|
|
@ -688,34 +740,37 @@ impl Bus {
|
|||
/// `Arc<Mutex<LoginState>>` should also call this so the web UI
|
||||
/// drops its periodic /api/state poll while a turn loop is running.
|
||||
///
|
||||
/// Sentinel files survive harness restart so the host-side dashboard
|
||||
/// can render the status without a live socket call:
|
||||
/// - `"rate_limited"` writes `{state_dir}/hyperhive-rate-limited`
|
||||
/// (cleared by any other status).
|
||||
/// - `"needs_login_idle"` writes `{state_dir}/hyperhive-needs-login`
|
||||
/// so a 401-triggered re-auth flag persists across harness restart.
|
||||
/// The web UI's `/login` POST handler clears it via
|
||||
/// `clear_needs_login_sentinel` once the operator re-auths.
|
||||
/// - `"online"` clears both sentinels — the agent is healthy again.
|
||||
/// `hyperhive-harness.json` persists across harness restarts so the
|
||||
/// host-side dashboard can render the status without a live socket call:
|
||||
/// - `"rate_limited"` sets `rate_limited: true` in the JSON.
|
||||
/// - `"needs_login_idle"` sets `needs_login: true` in the JSON.
|
||||
/// - `"online"` clears both fields — the agent is healthy again.
|
||||
/// - Other statuses clear `rate_limited` only; `needs_login` is sticky
|
||||
/// until `"online"` (re-auth completed successfully).
|
||||
///
|
||||
/// Writes are atomic (`.tmp` + `rename`) so hive-c0re never reads a
|
||||
/// partial file during its ~10s sweep.
|
||||
pub fn emit_status(&self, status: impl Into<String>) {
|
||||
let status = status.into();
|
||||
let rate_limited_path = crate::paths::state_dir().join("hyperhive-rate-limited");
|
||||
let needs_login_path = crate::paths::state_dir().join("hyperhive-needs-login");
|
||||
if status == "rate_limited" {
|
||||
let new_rate_limited = status == "rate_limited";
|
||||
if new_rate_limited {
|
||||
self.rate_limited.store(true, Ordering::Relaxed);
|
||||
let _ = std::fs::write(&rate_limited_path, b"");
|
||||
} else {
|
||||
self.rate_limited.store(false, Ordering::Relaxed);
|
||||
let _ = std::fs::remove_file(&rate_limited_path);
|
||||
}
|
||||
if status == "needs_login_idle" {
|
||||
let _ = std::fs::write(&needs_login_path, b"");
|
||||
// Read the current persisted needs_login so we don't flip it on
|
||||
// statuses that shouldn't touch it (e.g. `needs_login_in_progress`
|
||||
// is a transient mid-flow status; only `needs_login_idle` and
|
||||
// `online` should change the persistent flag).
|
||||
let (_, current_needs_login) = read_harness_state();
|
||||
let new_needs_login = if status == "needs_login_idle" {
|
||||
true
|
||||
} else if status == "online" {
|
||||
// Re-auth completed (or manual flip back to online) — drop
|
||||
// the sentinel. `needs_login_in_progress` is a transient
|
||||
// mid-flow status and shouldn't clear yet.
|
||||
let _ = std::fs::remove_file(&needs_login_path);
|
||||
}
|
||||
false
|
||||
} else {
|
||||
current_needs_login
|
||||
};
|
||||
write_harness_state(new_rate_limited, new_needs_login);
|
||||
self.emit(LiveEvent::StatusChanged { status });
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -194,15 +194,15 @@ fn bind_unix(path: &Path) -> Result<tokio::net::UnixListener> {
|
|||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o660))
|
||||
.with_context(|| format!("set perms on {}", path.display()))?;
|
||||
// Best-effort .bound marker: failed write isn't fatal (the harness
|
||||
// Best-effort ready marker: failed write isn't fatal (the harness
|
||||
// still binds + serves), it just means the gateway side keeps the
|
||||
// TCP upstream for one more sync tick.
|
||||
if let Some(parent) = path.parent() {
|
||||
let marker = parent.join(".bound");
|
||||
let marker = parent.join("hyperhive-socket-bound");
|
||||
if let Err(e) = std::fs::write(&marker, b"") {
|
||||
tracing::warn!(
|
||||
marker = %marker.display(), error = %e,
|
||||
"failed to write .bound marker — gateway may keep TCP upstream"
|
||||
"failed to write hyperhive-socket-bound marker — gateway may keep TCP upstream"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue