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

@ -446,6 +446,7 @@ pub(crate) mod tests {
agent_status: None,
agent_icons: None,
reported_inference: None,
agent_login: None,
jobq: Arc::new(std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new(
hive_jobq::Graph::new(),
hive_jobq::resources::ResourceTable::new(),

View file

@ -0,0 +1,148 @@
//! Whether each agent last said its claude needs a login, for
//! `GET /api/agents/status`'s `needs_login`.
//!
//! The flag comes from the agent's own turn-state header
//! (`$SWARM.agent-state.<agent>`), not from its hive. Agents publish a header
//! when it changes and re-send it once a minute, and the queue keeps none, so
//! an agent has no entry for up to a minute after this daemon starts — the
//! same gap [`crate::inference::Reported`] has on the same subject.
use std::collections::HashMap;
use std::sync::{Arc, Mutex, PoisonError};
use std::time::Duration;
use futures_util::StreamExt as _;
use swarm_queue_client::agent_login::Login;
/// The latest login flag each agent reported, by agent.
#[derive(Debug, Default)]
pub(crate) struct Reported {
latest: Mutex<HashMap<String, bool>>,
}
impl Reported {
/// Start recording every agent's login flag from `client`.
pub(crate) fn spawn(client: async_nats::Client) -> Arc<Self> {
let reported = Arc::new(Self::default());
let recording = Arc::clone(&reported);
tokio::spawn(async move { recording.follow(&client).await });
reported
}
/// Subscribe to every agent's header, and subscribe again if the
/// subscription ends.
async fn follow(&self, client: &async_nats::Client) {
const RETRY: Duration = Duration::from_secs(5);
let subject = swarm_queue_client::agent_state_subject_wildcard();
loop {
match client.subscribe(subject.clone()).await {
Ok(mut headers) => {
tracing::info!(%subject, "agent login: following agent turn-state headers");
while let Some(msg) = headers.next().await {
self.record(&msg.subject, &msg.payload);
}
tracing::warn!(%subject, "agent login: header subscription ended");
}
Err(e) => tracing::warn!(%subject, error = %e, "agent login: subscribe failed"),
}
tokio::time::sleep(RETRY).await;
}
}
/// Keep one header's login flag. A header without it, from an agent that
/// does not report it, removes what was kept.
fn record(&self, subject: &str, payload: &[u8]) {
let Some(agent) = swarm_queue_client::agent_from_state_subject(subject) else {
return;
};
let mut latest = self.latest.lock().unwrap_or_else(PoisonError::into_inner);
match serde_json::from_slice::<Login>(payload) {
Ok(Login {
needs_login: Some(needs_login),
}) => {
latest.insert(agent.to_owned(), needs_login);
}
Ok(Login { needs_login: None }) => {
latest.remove(agent);
}
Err(e) => {
tracing::debug!(%agent, error = %e, "agent login: header did not decode");
latest.remove(agent);
}
}
}
/// `agent`'s latest flag; `None` when no header since this daemon
/// started has carried one.
pub(crate) fn get(&self, agent: &str) -> Option<bool> {
self.latest
.lock()
.unwrap_or_else(PoisonError::into_inner)
.get(agent)
.copied()
}
}
#[cfg(test)]
mod tests {
use super::Reported;
fn header(needs_login: Option<bool>) -> Vec<u8> {
let mut header = serde_json::json!({
"turn_state": "idle",
"turn_state_since": "2026-09-13T12:35:03Z",
"agent_state": "up",
"model": "sonnet",
"effort": "high",
"model_source": "default",
"effort_source": "default",
"resolved_model": null,
"context_window_tokens": 200_000,
"ctx_usage": null,
"cost_usage": null,
});
if let Some(needs_login) = needs_login {
header["needs_login"] = needs_login.into();
}
serde_json::to_vec(&header).unwrap()
}
#[test]
fn a_header_without_the_flag_reports_nothing() {
let reported = Reported::default();
let subject = swarm_queue_client::agent_state_subject("iris");
reported.record(&subject, &header(None));
assert_eq!(reported.get("iris"), None);
}
#[test]
fn the_latest_header_wins_and_one_without_the_flag_clears_it() {
let reported = Reported::default();
let subject = swarm_queue_client::agent_state_subject("iris");
reported.record(&subject, &header(Some(true)));
assert_eq!(reported.get("iris"), Some(true));
reported.record(&subject, &header(Some(false)));
assert_eq!(reported.get("iris"), Some(false));
reported.record(&subject, &header(Some(true)));
reported.record(&subject, &header(None));
assert_eq!(reported.get("iris"), None);
reported.record(&subject, &header(Some(true)));
reported.record(&subject, b"not json");
assert_eq!(reported.get("iris"), None);
}
#[test]
fn a_header_is_kept_under_the_agent_its_subject_names() {
let reported = Reported::default();
reported.record(
&swarm_queue_client::agent_state_subject("iris"),
&header(Some(true)),
);
reported.record(
&swarm_queue_client::term_subject("atlas"),
&header(Some(true)),
);
assert_eq!(reported.get("iris"), Some(true));
assert_eq!(reported.get("atlas"), None);
}
}

View file

@ -40,8 +40,8 @@ use crate::AppState;
turn-state header, JSON as hive-agent published it (opaque to this relay) — \
`turn_state`, `turn_state_since` (ISO 8601 UTC), `agent_state`, `model`, `effort`, \
`model_source`, `effort_source`, `resolved_model`, `context_window_tokens`, \
`ctx_usage`, `cost_usage`. Sent on transition and re-sent once a minute, live, \
no replay",
`ctx_usage`, `cost_usage`, `needs_login`. Sent on transition and re-sent once a \
minute, live, no replay",
body = String, content_type = "text/event-stream"),
(status = 400, description = "the agent name is not shaped like an identifier \
(problem+json)", body = String),

View file

@ -65,6 +65,13 @@ pub struct AgentStatusRow {
/// here would blur that boundary for the same reason `config_pr`
/// doesn't merge forge state in directly.
pub wanted: Option<String>,
/// Whether the agent's latest turn-state header said its claude needs a
/// login. `None` when no header since swarm-controller started carried
/// the flag. Same rule as `config_pr`: always `None` out of this reader,
/// filled in by the `GET /api/agents/status` handler from
/// `AppState::agent_login` — this reader sees the hive's bucket, and the
/// flag comes from the agent itself.
pub needs_login: Option<bool>,
}
/// A snapshot as retained by the bucket, with the hive it was published
@ -186,6 +193,7 @@ fn render(
snapshot: None,
config_pr: None,
wanted: None,
needs_login: None,
},
})
.collect();
@ -230,6 +238,7 @@ fn row(
snapshot: offered.payload.clone(),
config_pr: None,
wanted: None,
needs_login: None,
}
}

View file

@ -153,6 +153,7 @@ mod tests {
agent_status: None,
agent_icons: None,
reported_inference: None,
agent_login: None,
jobq: std::sync::Arc::new(std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new(
hive_jobq::Graph::new(),
hive_jobq::resources::ResourceTable::new(),

View file

@ -123,6 +123,7 @@ mod tests {
agent_status: None,
agent_icons: None,
reported_inference: None,
agent_login: None,
jobq: std::sync::Arc::new(std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new(
hive_jobq::Graph::new(),
hive_jobq::resources::ResourceTable::new(),

View file

@ -958,6 +958,7 @@ pub(crate) mod tests {
agent_status: None,
agent_icons: None,
reported_inference: None,
agent_login: None,
jobq: std::sync::Arc::new(std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new(
hive_jobq::Graph::new(),
hive_jobq::resources::ResourceTable::new(),

View file

@ -45,6 +45,7 @@ mod agent_icon;
mod agent_identity;
mod agent_input;
mod agent_limits;
mod agent_login;
mod agent_renewal;
mod agent_state_stream;
mod agent_status;
@ -894,6 +895,10 @@ struct AppState {
/// headers on `status`'s connection. `None` in exactly the state
/// `status` is.
reported_inference: Option<Arc<inference::Reported>>,
/// Whether each agent last said it needs a login, from its turn-state
/// headers on `status`'s connection. `None` in exactly the state
/// `status` is.
agent_login: Option<Arc<agent_login::Reported>>,
/// The swarm-level job graph, wrapped in its
/// [`hive_jobq::scheduler::Scheduler`] now that something drives it
/// (`spawn_jobq_worker`) — the graph alone was enough for the
@ -1260,6 +1265,12 @@ fn reported_inference(
Some(inference::Reported::spawn(status?.queue_client()))
}
/// Whether each agent last said it needs a login, read on the status
/// reader's connection — same rationale as [`wanted_writer`].
fn agent_login(status: Option<&Arc<status::StatusReader>>) -> Option<Arc<agent_login::Reported>> {
Some(agent_login::Reported::spawn(status?.queue_client()))
}
/// Same "log and carry on" shape as the queue/bridge/forge wiring in `main`.
/// A controller that cannot hold a webhook secret still serves every other
/// route; the webhook endpoint answers 503, which is the honest answer
@ -1671,6 +1682,11 @@ async fn get_agents_status(
});
}
}
if let Some(login) = state.agent_login.as_ref() {
for row in &mut rows {
row.needs_login = login.get(&row.name);
}
}
Ok(Json(rows))
}
Err(e) => {
@ -3334,6 +3350,7 @@ async fn main() -> Result<()> {
agent_status: agent_status_reader(status.as_ref()),
agent_icons: agent_icon_reader(status.as_ref()),
reported_inference: reported_inference(status.as_ref()),
agent_login: agent_login(status.as_ref()),
status,
jobq,
webhook_secret,
@ -3534,6 +3551,7 @@ mod tests {
agent_status: None,
agent_icons: None,
reported_inference: None,
agent_login: None,
jobq: std::sync::Arc::clone(&sched),
webhook_secret: None,
config_prs: std::sync::Arc::default(),

View file

@ -570,6 +570,7 @@ mod tests {
agent_status: None,
agent_icons: None,
reported_inference: None,
agent_login: None,
jobq: std::sync::Arc::new(std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new(
hive_jobq::Graph::new(),
hive_jobq::resources::ResourceTable::new(),