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:
parent
c335bc0962
commit
afeb0f9794
19 changed files with 365 additions and 15 deletions
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Reference in a new issue