fold forge_cursor into hyperhive-harness.json + prose comments (mara/argus review)
This commit is contained in:
parent
2549cc51ba
commit
f6d9ca7f99
4 changed files with 153 additions and 146 deletions
|
|
@ -106,6 +106,34 @@ fn harness_json_path() -> PathBuf {
|
|||
crate::paths::state_dir().join(HARNESS_JSON)
|
||||
}
|
||||
|
||||
// Serialises the read-modify-write of `hyperhive-harness.json`. Two
|
||||
// harness tasks touch it in the same process — the turn loop (rate-limit
|
||||
// / needs-login / active-model) and the forge_notify poller (the
|
||||
// delivery-dedupe cursor) — writing disjoint fields, so each writer must
|
||||
// preserve the other's. The lock closes the lost-update window between a
|
||||
// writer's read and its rename.
|
||||
static HARNESS_JSON_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
|
||||
|
||||
/// Read the consolidated state file as a JSON object, or an empty object
|
||||
/// when it is missing / unparseable / not an object.
|
||||
fn read_harness_json() -> serde_json::Value {
|
||||
std::fs::read_to_string(harness_json_path())
|
||||
.ok()
|
||||
.and_then(|raw| serde_json::from_str::<serde_json::Value>(&raw).ok())
|
||||
.filter(serde_json::Value::is_object)
|
||||
.unwrap_or_else(|| serde_json::json!({}))
|
||||
}
|
||||
|
||||
/// Atomically overwrite the state file (`.tmp` + rename) so hive-c0re
|
||||
/// never reads a partial file.
|
||||
fn write_harness_json(v: &serde_json::Value) {
|
||||
let path = harness_json_path();
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
if std::fs::write(&tmp, v.to_string()).is_ok() {
|
||||
let _ = std::fs::rename(&tmp, &path);
|
||||
}
|
||||
}
|
||||
|
||||
fn read_harness_state() -> (bool, bool, Option<String>) {
|
||||
// Try the new consolidated file first.
|
||||
if let Ok(raw) = std::fs::read_to_string(harness_json_path())
|
||||
|
|
@ -133,24 +161,55 @@ fn read_harness_state() -> (bool, bool, Option<String>) {
|
|||
(rate_limited, needs_login, None)
|
||||
}
|
||||
|
||||
/// Write harness state atomically via a `.tmp` + `rename` pair so
|
||||
/// hive-c0re never reads a partial file. Pass `active_model: Some(s)`
|
||||
/// to include the resolved model (as surfaced in the dashboard badge);
|
||||
/// `None` omits the field, which hive-c0re treats as "not yet known".
|
||||
/// Write the turn-loop's harness state fields via a read-modify-write so
|
||||
/// any other writer's fields (e.g. `forge_notify`'s `forge_cursor`) survive.
|
||||
/// Pass `active_model: Some(s)` to update the resolved model (surfaced in
|
||||
/// the dashboard badge); `None` leaves the stored value untouched.
|
||||
fn write_harness_state(rate_limited: bool, needs_login: bool, active_model: Option<&str>) {
|
||||
let path = harness_json_path();
|
||||
let mut json = serde_json::json!({
|
||||
"rate_limited": rate_limited,
|
||||
"needs_login": needs_login,
|
||||
});
|
||||
let _guard = HARNESS_JSON_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let mut v = read_harness_json();
|
||||
v["rate_limited"] = rate_limited.into();
|
||||
v["needs_login"] = needs_login.into();
|
||||
if let Some(model) = active_model {
|
||||
json["active_model"] = serde_json::Value::String(model.to_string());
|
||||
}
|
||||
let body = json.to_string();
|
||||
let tmp = path.with_extension("json.tmp");
|
||||
if std::fs::write(&tmp, &body).is_ok() {
|
||||
let _ = std::fs::rename(&tmp, &path);
|
||||
v["active_model"] = model.into();
|
||||
}
|
||||
write_harness_json(&v);
|
||||
}
|
||||
|
||||
/// Parse the `forge_notify` delivery-dedupe cursor (notification thread id
|
||||
/// -> last-delivered `updated_at`) out of a harness-state JSON value.
|
||||
/// Empty when the field is absent (first boot) or malformed.
|
||||
fn forge_cursor_from_json(v: &serde_json::Value) -> std::collections::HashMap<u64, String> {
|
||||
v.get("forge_cursor")
|
||||
.cloned()
|
||||
.and_then(|c| serde_json::from_value(c).ok())
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
/// Restore the `forge_notify` delivery-dedupe cursor from the consolidated
|
||||
/// state file so a container rebuild/restart doesn't re-deliver the whole
|
||||
/// currently-unread backlog.
|
||||
pub fn read_forge_cursor() -> std::collections::HashMap<u64, String> {
|
||||
forge_cursor_from_json(&read_harness_json())
|
||||
}
|
||||
|
||||
/// Persist the `forge_notify` delivery-dedupe cursor into the consolidated
|
||||
/// state file, read-modify-write under the shared lock so the turn-loop's
|
||||
/// own fields survive. Best-effort: a serialize failure is a no-op.
|
||||
pub fn write_forge_cursor<S: std::hash::BuildHasher>(
|
||||
cursor: &std::collections::HashMap<u64, String, S>,
|
||||
) {
|
||||
let Ok(value) = serde_json::to_value(cursor) else {
|
||||
return;
|
||||
};
|
||||
let _guard = HARNESS_JSON_LOCK
|
||||
.lock()
|
||||
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||
let mut v = read_harness_json();
|
||||
v["forge_cursor"] = value;
|
||||
write_harness_json(&v);
|
||||
}
|
||||
|
||||
fn now_unix() -> i64 {
|
||||
|
|
@ -1145,10 +1204,37 @@ impl Default for Bus {
|
|||
mod tests {
|
||||
use super::{
|
||||
BusEvent, DEFAULT_EFFORT, EFFORT_LEVELS, LiveEvent, StoredEvent, TokenUsage,
|
||||
is_valid_effort,
|
||||
forge_cursor_from_json, is_valid_effort,
|
||||
};
|
||||
use serde_json::json;
|
||||
|
||||
#[test]
|
||||
fn forge_cursor_absent_field_is_empty() {
|
||||
// First boot / a state file that only carries the turn-loop fields:
|
||||
// no cursor yet, so we re-deliver the currently-unread set once.
|
||||
assert!(forge_cursor_from_json(&json!({ "rate_limited": false })).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forge_cursor_malformed_field_is_empty() {
|
||||
// A wrong-typed / corrupt cursor degrades to empty rather than
|
||||
// aborting the poller.
|
||||
assert!(forge_cursor_from_json(&json!({ "forge_cursor": "nonsense" })).is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn forge_cursor_roundtrips_u64_keys() {
|
||||
// serde_json stringifies integer map keys; confirm the u64 thread
|
||||
// ids the cursor is keyed on survive the JSON round-trip.
|
||||
let v = json!({
|
||||
"forge_cursor": { "42": "2026-06-22T16:00:00Z", "99": "2026-06-22T17:30:00Z" }
|
||||
});
|
||||
let cursor = forge_cursor_from_json(&v);
|
||||
assert_eq!(cursor.len(), 2);
|
||||
assert_eq!(cursor.get(&42), Some(&"2026-06-22T16:00:00Z".to_owned()));
|
||||
assert_eq!(cursor.get(&99), Some(&"2026-06-22T17:30:00Z".to_owned()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stored_event_serializes_ts_beside_kind() {
|
||||
// History-row wire shape: `ts` is a flattened sibling of `kind`,
|
||||
|
|
|
|||
Loading…
Reference in a new issue