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
|
|
@ -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(),
|
||||
|
|
|
|||
148
swarm-controller/src/agent_login.rs
Normal file
148
swarm-controller/src/agent_login.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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),
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Reference in a new issue