Watch
0
0
Fork
You've already forked hyperhive
0

a hive-side pause holds against the swarm's declared state until an operator resumes

the harness writes the reason into the pause marker when it pauses itself and reports it as paused_by_hive in its turn-state header. swarm-controller declares such an agent paused (only if declared up) and shows the reason on the agents status row and the swarm ui card.
This commit is contained in:
atlas 2026-11-09 23:09:14 +01:00 • committed by mara
commit e5c55257f8

View file

@ -35,6 +35,7 @@ use tokio::time::{Interval, MissedTickBehavior};
use swarm_wire::agent_inference::Effective;
use swarm_wire::agent_login::Login;
use swarm_wire::agent_pause::HivePause;
use swarm_wire::hive_status::{Capabilities, TOLERANT_PERMISSIONS};
use swarm_wire::wanted::AgentState;
@ -101,6 +102,10 @@ struct AgentStateMsg {
/// `needs_login`, from this process's own [`LoginState`], so always set.
#[serde(flatten)]
login: Login,
/// `paused_by_hive`, the reason in the pause marker when this harness wrote
/// it itself; absent otherwise.
#[serde(flatten)]
hive_pause: HivePause,
/// What this harness can do, so swarm-controller grants an agent a
/// permission only when the harness running it can decode it.
#[serde(flatten)]
@ -136,6 +141,20 @@ fn observed_state() -> AgentState {
}
}
/// Longest reason reported; the marker is a file the agent can write to.
const PAUSE_REASON_MAX_CHARS: usize = 200;
/// The header's pause-by-harness field: the pause marker's content, when it has
/// any. The operator's pause writes an empty marker, so it reports nothing.
fn hive_pause() -> HivePause {
let reason = std::fs::read_to_string(crate::paths::paused_marker()).unwrap_or_default();
let reason = reason.trim();
HivePause {
paused_by_hive: (!reason.is_empty())
.then(|| reason.chars().take(PAUSE_REASON_MAX_CHARS).collect()),
}
}
/// The header's login fields for `state`.
fn login(state: LoginState) -> Login {
Login {
@ -170,6 +189,7 @@ fn snapshot(bus: &Bus, login_state: &Mutex<LoginState>) -> AgentStateMsg {
ctx_usage: bus.last_ctx_usage(),
cost_usage: bus.last_cost_usage(),
login: login(*login_state.lock().unwrap_or_else(PoisonError::into_inner)),
hive_pause: hive_pause(),
capabilities: capabilities(),
}
}
@ -346,13 +366,14 @@ async fn publish(
#[cfg(test)]
mod tests {
use super::{
AgentStateMsg, REFRESH_INTERVAL, Trigger, capabilities, due, login, next_trigger,
refresh_timer, snapshot,
AgentStateMsg, PAUSE_REASON_MAX_CHARS, REFRESH_INTERVAL, Trigger, capabilities, due,
hive_pause, login, next_trigger, refresh_timer, snapshot,
};
use crate::events::{BusEvent, LiveEvent, TurnState};
use crate::login::LoginState;
use std::sync::Mutex;
use swarm_wire::agent_inference::{Effective, Source};
use swarm_wire::agent_pause::HivePause;
use swarm_wire::hive_status::{Capabilities, TOLERANT_PERMISSIONS};
use swarm_wire::wanted::AgentState;
use tokio::sync::broadcast;
@ -403,6 +424,9 @@ mod tests {
"cache_creation_input_tokens": 88,
}))),
login: login(LoginState::NeedsLogin),
hive_pause: HivePause {
paused_by_hive: Some("skipped get_loose_ends".to_owned()),
},
capabilities: capabilities(),
}
}
@ -432,6 +456,7 @@ mod tests {
"model",
"model_source",
"needs_login",
"paused_by_hive",
"resolved_model",
"turn_state",
"turn_state_since",
@ -523,6 +548,33 @@ mod tests {
}
}
/// The marker's content is the reason; an empty marker (an operator's pause) and
/// no marker report nothing, and swarm-controller reads it back with the wire type.
#[test]
fn the_pause_marker_content_is_the_reported_reason() {
crate::paths::with_harness_dir(|| {
assert_eq!(hive_pause().paused_by_hive, None, "no marker");
std::fs::write(crate::paths::paused_marker(), "").unwrap();
assert_eq!(hive_pause().paused_by_hive, None, "operator pause");
std::fs::write(crate::paths::paused_marker(), "x".repeat(500)).unwrap();
assert_eq!(
hive_pause().paused_by_hive.unwrap().len(),
PAUSE_REASON_MAX_CHARS
);
std::fs::write(crate::paths::paused_marker(), "skipped get_loose_ends\n").unwrap();
let bytes = serde_json::to_vec(&AgentStateMsg {
hive_pause: hive_pause(),
..msg()
})
.expect("serialises");
let decoded: HivePause = serde_json::from_slice(&bytes).expect("decodes");
assert_eq!(
decoded.paused_by_hive.as_deref(),
Some("skipped get_loose_ends")
);
});
}
/// swarm-controller reads the harness's capabilities back out of a header.
#[test]
fn the_header_advertises_tolerant_permissions() {