Watch
0
0
Fork
You've already forked hyperhive
0

swarm UI: an agent that needs a Claude login shows it on its card

The agent's turn-state header ($SWARM.agent-state.<agent>) gains
`needs_login`, set by hive-agent from its own LoginState, so the flag
reaches the swarm without hive-c0re (mara on #5268: "id prefer the agent
container sending the info it needs login without hive").

- swarm-queue-client: `agent_login::Login`, the shared wire type, beside
  `agent_inference::Effective`. `needs_login: Option<bool>` with
  `#[serde(default)]`: a header from an older agent decodes as `None`
  (unknown), never as "needs login" or "logged in".
- hive-agent: the header publisher reads the shared LoginState cell.
  Every writer of that cell emits a status on the bus afterwards, so a
  login change goes out on the next rebuild, not the 60 s refresh.
- swarm-controller: `agent_login::Reported` follows every agent's header
  and keeps the latest flag; `GET /api/agents/status` merges it into each
  row as `needs_login` (null when unknown).
- swarm-ui: AgentCard shows a warning-tone "login needed" badge when the
  row's `needs_login` is true.

Refs #5268
This commit is contained in:
atlas 2026-10-14 19:32:05 +02:00
commit afeb0f9794
19 changed files with 365 additions and 15 deletions

View file

@ -490,7 +490,7 @@ async fn serve_main<S: Surface>(socket: &Path, poll_ms: u64) -> Result<()> {
// Same reason, one subject over: the swarm's header bar wants the turn
// state from this agent's first transition, not from whenever a reader
// first asked.
swarm_agent_state::spawn(&bus);
swarm_agent_state::spawn(&bus, login_state.clone());
// Once per start: a new icon only arrives with a new harness process.
swarm_agent_icon::spawn();
// The model and effort the swarm sets for this agent, over its local ones.

View file

@ -26,6 +26,7 @@
//! the queue, so the bus receiver is the only thing this task blocks on; a
//! failed publish is a log line and the next transition is still attempted.
use std::sync::{Arc, Mutex, PoisonError};
use std::time::Duration;
use serde::Serialize;
@ -33,9 +34,11 @@ use tokio::sync::broadcast;
use tokio::time::{Interval, MissedTickBehavior};
use swarm_queue_client::agent_inference::Effective;
use swarm_queue_client::agent_login::Login;
use swarm_queue_client::wanted::AgentState;
use crate::events::{Bus, BusEvent, LiveEvent, TurnState};
use crate::login::LoginState;
use crate::term_msg::iso8601_utc;
/// How much room to leave under the announced limit for everything the
@ -94,6 +97,9 @@ struct AgentStateMsg {
/// Cumulative token usage across the most recent turn's inferences, the
/// cost signal. `None` until the first turn ends.
cost_usage: Option<hive_claude::TokenUsage>,
/// `needs_login`, from this process's own [`LoginState`], so always set.
#[serde(flatten)]
login: Login,
}
/// What this process can honestly report about the agent-state axis.
@ -125,8 +131,16 @@ fn observed_state() -> AgentState {
}
}
/// Build the current header from the bus plus the pause marker.
fn snapshot(bus: &Bus) -> AgentStateMsg {
/// The header's login fields for `state`.
fn login(state: LoginState) -> Login {
Login {
needs_login: Some(state == LoginState::NeedsLogin),
}
}
/// Build the current header from the bus, the login state and the pause
/// marker.
fn snapshot(bus: &Bus, login_state: &Mutex<LoginState>) -> AgentStateMsg {
let (turn_state, since_unix) = bus.state_snapshot();
let model = bus.model();
AgentStateMsg {
@ -143,17 +157,21 @@ fn snapshot(bus: &Bus) -> AgentStateMsg {
resolved_model: bus.last_resolved_model(),
ctx_usage: bus.last_ctx_usage(),
cost_usage: bus.last_cost_usage(),
login: login(*login_state.lock().unwrap_or_else(PoisonError::into_inner)),
}
}
/// Start the publish task, if this agent has a label to name its subject with.
pub fn spawn(bus: &Bus) {
///
/// Every writer of `login_state` emits a status on `bus` after the write, so
/// a login change goes out as an ordinary rebuild.
pub fn spawn(bus: &Bus, login_state: Arc<Mutex<LoginState>>) {
let agent = crate::identity::label();
if agent.is_empty() {
tracing::warn!("this agent has no label; not publishing turn state upward");
return;
}
tokio::spawn(run(bus.subscribe(), bus.clone(), agent));
tokio::spawn(run(bus.subscribe(), bus.clone(), login_state, agent));
}
/// Watch the bus and publish whenever the header actually changed.
@ -176,7 +194,12 @@ pub fn spawn(bus: &Bus) {
/// changes nothing.
///
/// A [`Trigger::Refresh`] re-sends the header even when it is unchanged.
async fn run(mut rx: broadcast::Receiver<BusEvent>, bus: Bus, agent: String) {
async fn run(
mut rx: broadcast::Receiver<BusEvent>,
bus: Bus,
login_state: Arc<Mutex<LoginState>>,
agent: String,
) {
let client = crate::swarm_queue::client().await;
let subject = swarm_queue_client::agent_state_subject(&agent);
tracing::info!(subject, "publishing agent turn state to the swarm queue");
@ -186,7 +209,8 @@ async fn run(mut rx: broadcast::Receiver<BusEvent>, bus: Bus, agent: String) {
let mut last: Option<Vec<u8>> = None;
let mut refresh = refresh_timer();
while let Some(trigger) = next_trigger(&mut rx, &mut refresh).await {
publish(&client, &subject, &snapshot(&bus), &trigger, &mut last).await;
let msg = snapshot(&bus, &login_state);
publish(&client, &subject, &msg, &trigger, &mut last).await;
}
}
@ -307,8 +331,12 @@ async fn publish(
#[cfg(test)]
mod tests {
use super::{AgentStateMsg, REFRESH_INTERVAL, Trigger, due, next_trigger, refresh_timer};
use super::{
AgentStateMsg, REFRESH_INTERVAL, Trigger, due, login, next_trigger, refresh_timer, snapshot,
};
use crate::events::{BusEvent, LiveEvent, TurnState};
use crate::login::LoginState;
use std::sync::Mutex;
use swarm_queue_client::agent_inference::{Effective, Source};
use swarm_queue_client::wanted::AgentState;
use tokio::sync::broadcast;
@ -358,6 +386,7 @@ mod tests {
"cache_read_input_tokens": 77,
"cache_creation_input_tokens": 88,
}))),
login: login(LoginState::NeedsLogin),
}
}
@ -384,6 +413,7 @@ mod tests {
"effort_source",
"model",
"model_source",
"needs_login",
"resolved_model",
"turn_state",
"turn_state_since",
@ -456,6 +486,44 @@ mod tests {
assert_eq!(value["agent_state"], serde_json::json!("up"));
}
/// swarm-controller reads the login flag back out of a header.
#[test]
fn the_header_decodes_as_its_login_flag() {
for state in [LoginState::NeedsLogin, LoginState::Online] {
let bytes = serde_json::to_vec(&AgentStateMsg {
login: login(state),
..msg()
})
.expect("serialises");
let decoded: swarm_queue_client::agent_login::Login =
serde_json::from_slice(&bytes).expect("decodes");
assert_eq!(
decoded.needs_login,
Some(state == LoginState::NeedsLogin),
"{state:?}"
);
}
}
/// A login landing, or a logout, changes the header the next bus event
/// rebuilds, so it goes out without waiting for a refresh.
#[test]
fn a_login_state_change_is_published_on_the_next_event() {
let (before, after) = crate::paths::with_harness_dir(|| {
let bus = crate::events::Bus::new();
let login_state = Mutex::new(LoginState::NeedsLogin);
let before = serde_json::to_vec(&snapshot(&bus, &login_state)).expect("serialises");
*login_state.lock().unwrap() = LoginState::Online;
let after = serde_json::to_vec(&snapshot(&bus, &login_state)).expect("serialises");
(before, after)
});
let mut last = Some(before.clone());
assert!(!due(&Trigger::Event, &mut last, &before));
assert!(due(&Trigger::Event, &mut last, &after));
let after: serde_json::Value = serde_json::from_slice(&after).expect("valid json");
assert_eq!(after["needs_login"], serde_json::json!(false));
}
fn event(event: LiveEvent) -> BusEvent {
BusEvent {
seq: 0,