From afeb0f979400cd1a11b74eab9a519312b0f707d1 Mon Sep 17 00:00:00 2001 From: atlas Date: Wed, 14 Oct 2026 19:32:05 +0200 Subject: [PATCH] swarm UI: an agent that needs a Claude login shows it on its card The agent's turn-state header ($SWARM.agent-state.) 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` 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 --- docs/swarm/README.md | 12 +- docs/swarm/ui.md | 4 +- .../packages/swarm-ui/src/agent/AgentTypes.ts | 9 ++ .../swarm-ui/src/agent/roster/AgentCard.tsx | 9 +- .../terminal/useSwarmAgentStateStream.ts | 2 + .../swarm-ui/test/agent-types.test.mjs | 19 +++ hive-agent/src/main.rs | 2 +- hive-agent/src/swarm_agent_state.rs | 82 +++++++++- swarm-controller/src/agent_limits.rs | 1 + swarm-controller/src/agent_login.rs | 148 ++++++++++++++++++ swarm-controller/src/agent_state_stream.rs | 4 +- swarm-controller/src/agent_status.rs | 9 ++ swarm-controller/src/forge_account.rs | 1 + swarm-controller/src/github_account.rs | 1 + swarm-controller/src/linked_accounts.rs | 1 + swarm-controller/src/main.rs | 18 +++ swarm-controller/src/matrix_account.rs | 1 + swarm-queue-client/src/agent_login.rs | 53 +++++++ swarm-queue-client/src/lib.rs | 4 + 19 files changed, 365 insertions(+), 15 deletions(-) create mode 100644 frontend/packages/swarm-ui/test/agent-types.test.mjs create mode 100644 swarm-controller/src/agent_login.rs create mode 100644 swarm-queue-client/src/agent_login.rs diff --git a/docs/swarm/README.md b/docs/swarm/README.md index 7deba5bc..eb61c462 100644 --- a/docs/swarm/README.md +++ b/docs/swarm/README.md @@ -225,8 +225,10 @@ is doing (`turn_state`, plus `turn_state_since` as an ISO 8601 UTC stamp), which model and effort (`model`, `effort`, where each comes from as `model_source` and `effort_source`, and the resolved id the last turn actually ran on), the -context budget and the last turn's context and cost token blocks, and -`agent_state`. +context budget and the last turn's context and cost token blocks, +`agent_state`, and `needs_login`: `true` while the agent's claude has no +session and its turns wait for a login. The harness sets it from its own login +state, so it reaches the swarm without going through the hive. `agent_state` reuses the swarm's own wanted-state vocabulary (`up`/`offline`/`paused`/`destroyed`) so a reader can compare what an agent _is_ @@ -251,7 +253,11 @@ Swarm-side, `GET /api/agents//state/stream` relays that subject as SSE, resolving the agent's hive at request time exactly as the terminal stream does. The payload passes through opaquely. Separately, the controller keeps each agent's latest `model`, `effort` and their sources for -`GET /api/agents//inference` ([`inference.md`](inference.md)). +`GET /api/agents//inference` ([`inference.md`](inference.md)), and each +agent's latest `needs_login` for the `needs_login` field of +`GET /api/agents/status`. That field is `null` until a header carrying the flag +arrives, so for up to a minute after the controller starts, and for an agent +whose harness doesn't send it. The third thing an agent publishes is its **icon**, the same SVG its own `GET /icon` serves. It goes into the `agent-icons` KV bucket under the key diff --git a/docs/swarm/ui.md b/docs/swarm/ui.md index 51f30877..01d53168 100644 --- a/docs/swarm/ui.md +++ b/docs/swarm/ui.md @@ -22,7 +22,9 @@ domain, covers host-level detail for one hive. Everything it shows comes from [`swarm-controller`](../../swarm-controller/README.md). An agent created here or with `swarmctl agent create` starts `paused`; set it -`up` from its card. +`up` from its card. A card shows **login needed** when the agent's own +turn-state header says its claude has no session. Log it in from the agent's +own web UI. ### Agent and subagent terminals diff --git a/frontend/packages/swarm-ui/src/agent/AgentTypes.ts b/frontend/packages/swarm-ui/src/agent/AgentTypes.ts index e11f87c5..0f78b82a 100644 --- a/frontend/packages/swarm-ui/src/agent/AgentTypes.ts +++ b/frontend/packages/swarm-ui/src/agent/AgentTypes.ts @@ -39,6 +39,15 @@ export interface AgentRow { snapshot: AgentStatusSnapshot | null; config_pr: ConfigPrStatus | null; wanted: Wanted; + // What the agent itself last published about its claude login. `null`: + // no header since swarm-controller started, or an agent that does not + // send the flag — not known, so not "needs login". + needs_login: boolean | null; +} + +// Only an agent that said so gets the badge; `null` shows nothing. +export function showsLoginNeeded(row: Pick): boolean { + return row.needs_login === true; } // Same tone pairing as HivesPage — one freshness enum shared by both diff --git a/frontend/packages/swarm-ui/src/agent/roster/AgentCard.tsx b/frontend/packages/swarm-ui/src/agent/roster/AgentCard.tsx index c66d244b..23dbe3e4 100644 --- a/frontend/packages/swarm-ui/src/agent/roster/AgentCard.tsx +++ b/frontend/packages/swarm-ui/src/agent/roster/AgentCard.tsx @@ -17,7 +17,7 @@ import type { ProblemDetails } from "@hive/shared/api-error.js"; import { Card } from "../../ui/card/Card.js"; import { RelativeTime } from "../../ui/relative-time/RelativeTime.js"; import { AgentIcon } from "../AgentIcon.js"; -import { FRESHNESS, type AgentRow } from "../AgentTypes.js"; +import { FRESHNESS, showsLoginNeeded, type AgentRow } from "../AgentTypes.js"; import { WantedMenu } from "./WantedMenu.js"; import "./AgentCard.css"; @@ -71,6 +71,13 @@ export function AgentCard({ } /> + {showsLoginNeeded(row) ? ( + + ) : null} e.stopPropagation()} diff --git a/frontend/packages/swarm-ui/src/agent/terminal/useSwarmAgentStateStream.ts b/frontend/packages/swarm-ui/src/agent/terminal/useSwarmAgentStateStream.ts index a8f8d026..92661c91 100644 --- a/frontend/packages/swarm-ui/src/agent/terminal/useSwarmAgentStateStream.ts +++ b/frontend/packages/swarm-ui/src/agent/terminal/useSwarmAgentStateStream.ts @@ -46,6 +46,8 @@ export interface AgentStateHeader { context_window_tokens: number; ctx_usage: TokenUsage | null; cost_usage: TokenUsage | null; + // Absent from an agent that predates the field. + needs_login?: boolean; } export interface UseSwarmAgentStateStreamResult { diff --git a/frontend/packages/swarm-ui/test/agent-types.test.mjs b/frontend/packages/swarm-ui/test/agent-types.test.mjs new file mode 100644 index 00000000..758d8dc6 --- /dev/null +++ b/frontend/packages/swarm-ui/test/agent-types.test.mjs @@ -0,0 +1,19 @@ +// The roster's "login needed" badge rule. Run by `npm test`, which Node runs +// with its built-in test runner and type stripping, so the `.ts` module is +// imported as it is. +import assert from "node:assert/strict"; +import { test } from "node:test"; +import { showsLoginNeeded } from "../src/agent/AgentTypes.ts"; + +test("an agent that says it needs a login gets the badge", () => { + assert.equal(showsLoginNeeded({ needs_login: true }), true); +}); + +test("an agent that is logged in gets none", () => { + assert.equal(showsLoginNeeded({ needs_login: false }), false); +}); + +test("an agent that has not said gets none", () => { + assert.equal(showsLoginNeeded({ needs_login: null }), false); + assert.equal(showsLoginNeeded({}), false); +}); diff --git a/hive-agent/src/main.rs b/hive-agent/src/main.rs index fed0a357..6f605d4f 100644 --- a/hive-agent/src/main.rs +++ b/hive-agent/src/main.rs @@ -490,7 +490,7 @@ async fn serve_main(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. diff --git a/hive-agent/src/swarm_agent_state.rs b/hive-agent/src/swarm_agent_state.rs index 880592bc..152768d5 100644 --- a/hive-agent/src/swarm_agent_state.rs +++ b/hive-agent/src/swarm_agent_state.rs @@ -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, + /// `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) -> 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>) { 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, bus: Bus, agent: String) { +async fn run( + mut rx: broadcast::Receiver, + bus: Bus, + login_state: Arc>, + 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, bus: Bus, agent: String) { let mut last: Option> = 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, diff --git a/swarm-controller/src/agent_limits.rs b/swarm-controller/src/agent_limits.rs index 868f822f..e72a1f04 100644 --- a/swarm-controller/src/agent_limits.rs +++ b/swarm-controller/src/agent_limits.rs @@ -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(), diff --git a/swarm-controller/src/agent_login.rs b/swarm-controller/src/agent_login.rs new file mode 100644 index 00000000..db38a2ec --- /dev/null +++ b/swarm-controller/src/agent_login.rs @@ -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.`), 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>, +} + +impl Reported { + /// Start recording every agent's login flag from `client`. + pub(crate) fn spawn(client: async_nats::Client) -> Arc { + 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::(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 { + self.latest + .lock() + .unwrap_or_else(PoisonError::into_inner) + .get(agent) + .copied() + } +} + +#[cfg(test)] +mod tests { + use super::Reported; + + fn header(needs_login: Option) -> Vec { + 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); + } +} diff --git a/swarm-controller/src/agent_state_stream.rs b/swarm-controller/src/agent_state_stream.rs index 97893e79..29a48fce 100644 --- a/swarm-controller/src/agent_state_stream.rs +++ b/swarm-controller/src/agent_state_stream.rs @@ -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), diff --git a/swarm-controller/src/agent_status.rs b/swarm-controller/src/agent_status.rs index ee355206..5d51c336 100644 --- a/swarm-controller/src/agent_status.rs +++ b/swarm-controller/src/agent_status.rs @@ -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, + /// 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, } /// 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, } } diff --git a/swarm-controller/src/forge_account.rs b/swarm-controller/src/forge_account.rs index f639fa76..fb379ba3 100644 --- a/swarm-controller/src/forge_account.rs +++ b/swarm-controller/src/forge_account.rs @@ -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(), diff --git a/swarm-controller/src/github_account.rs b/swarm-controller/src/github_account.rs index 79f98e7d..b54e8b1e 100644 --- a/swarm-controller/src/github_account.rs +++ b/swarm-controller/src/github_account.rs @@ -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(), diff --git a/swarm-controller/src/linked_accounts.rs b/swarm-controller/src/linked_accounts.rs index 78cd4f08..7ec5905b 100644 --- a/swarm-controller/src/linked_accounts.rs +++ b/swarm-controller/src/linked_accounts.rs @@ -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(), diff --git a/swarm-controller/src/main.rs b/swarm-controller/src/main.rs index a8b05110..23d22559 100644 --- a/swarm-controller/src/main.rs +++ b/swarm-controller/src/main.rs @@ -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>, + /// 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>, /// 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>) -> Option> { + 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(), diff --git a/swarm-controller/src/matrix_account.rs b/swarm-controller/src/matrix_account.rs index 0b891c20..571b9189 100644 --- a/swarm-controller/src/matrix_account.rs +++ b/swarm-controller/src/matrix_account.rs @@ -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(), diff --git a/swarm-queue-client/src/agent_login.rs b/swarm-queue-client/src/agent_login.rs new file mode 100644 index 00000000..7a2d4cd5 --- /dev/null +++ b/swarm-queue-client/src/agent_login.rs @@ -0,0 +1,53 @@ +//! Whether an agent's claude is waiting for a login, as the agent itself +//! reports it. +//! +//! Part of the turn-state header the agent publishes on +//! [`crate::agent_state_subject`], beside [`crate::agent_inference::Effective`]; +//! swarm-controller decodes it out of that header with this same type. + +/// The login fields of an agent's turn-state header. +#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, serde::Serialize, serde::Deserialize)] +pub struct Login { + /// `true` while the agent has no claude session and its turn loop waits + /// for one, including while a login is in progress. `None` from an agent + /// whose header has no such field, which says nothing about its login. + #[serde(default)] + pub needs_login: Option, +} + +#[cfg(test)] +mod tests { + use super::Login; + + #[test] + fn a_header_without_the_field_decodes_as_unknown() { + let 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, + }); + let login: Login = serde_json::from_value(header).expect("an old header decodes"); + assert_eq!(login.needs_login, None); + } + + #[test] + fn the_flag_round_trips_both_ways() { + for needs_login in [true, false] { + let sent = Login { + needs_login: Some(needs_login), + }; + let value = serde_json::to_value(sent).expect("serialises"); + assert_eq!(value["needs_login"], serde_json::json!(needs_login)); + let decoded: Login = serde_json::from_value(value).expect("decodes"); + assert_eq!(decoded, sent); + } + } +} diff --git a/swarm-queue-client/src/lib.rs b/swarm-queue-client/src/lib.rs index 90d09f4f..bb932c50 100644 --- a/swarm-queue-client/src/lib.rs +++ b/swarm-queue-client/src/lib.rs @@ -221,6 +221,10 @@ pub mod agent_inference; /// writing needs `kv` and reading needs `agent-limits`. pub mod agent_limits; +/// The login flag in an agent's turn-state header. No bucket: it travels +/// on [`agent_state_subject`] only. +pub mod agent_login; + #[cfg(feature = "kv")] mod kv;