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

@ -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 which model and effort (`model`, `effort`, where each comes from as
`model_source` and `effort_source`, and the resolved id the last turn `model_source` and `effort_source`, and the resolved id the last turn
actually ran on), the actually ran on), the
context budget and the last turn's context and cost token blocks, and context budget and the last turn's context and cost token blocks,
`agent_state`. `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 `agent_state` reuses the swarm's own wanted-state vocabulary
(`up`/`offline`/`paused`/`destroyed`) so a reader can compare what an agent _is_ (`up`/`offline`/`paused`/`destroyed`) so a reader can compare what an agent _is_
@ -251,7 +253,11 @@ Swarm-side, `GET /api/agents/<name>/state/stream` relays that subject as SSE,
resolving the agent's hive at request time exactly as the terminal stream does. resolving the agent's hive at request time exactly as the terminal stream does.
The payload passes through opaquely. Separately, the controller keeps each The payload passes through opaquely. Separately, the controller keeps each
agent's latest `model`, `effort` and their sources for agent's latest `model`, `effort` and their sources for
`GET /api/agents/<name>/inference` ([`inference.md`](inference.md)). `GET /api/agents/<name>/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 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 `GET /icon` serves. It goes into the `agent-icons` KV bucket under the key

View file

@ -22,7 +22,9 @@ domain, covers host-level detail for one hive.
Everything it shows comes from [`swarm-controller`](../../swarm-controller/README.md). Everything it shows comes from [`swarm-controller`](../../swarm-controller/README.md).
An agent created here or with `swarmctl agent create` starts `paused`; set it 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 ### Agent and subagent terminals

View file

@ -39,6 +39,15 @@ export interface AgentRow {
snapshot: AgentStatusSnapshot | null; snapshot: AgentStatusSnapshot | null;
config_pr: ConfigPrStatus | null; config_pr: ConfigPrStatus | null;
wanted: Wanted; 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<AgentRow, "needs_login">): boolean {
return row.needs_login === true;
} }
// Same tone pairing as HivesPage — one freshness enum shared by both // Same tone pairing as HivesPage — one freshness enum shared by both

View file

@ -17,7 +17,7 @@ import type { ProblemDetails } from "@hive/shared/api-error.js";
import { Card } from "../../ui/card/Card.js"; import { Card } from "../../ui/card/Card.js";
import { RelativeTime } from "../../ui/relative-time/RelativeTime.js"; import { RelativeTime } from "../../ui/relative-time/RelativeTime.js";
import { AgentIcon } from "../AgentIcon.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 { WantedMenu } from "./WantedMenu.js";
import "./AgentCard.css"; import "./AgentCard.css";
@ -71,6 +71,13 @@ export function AgentCard({
</> </>
} }
/> />
{showsLoginNeeded(row) ? (
<Badge
tone="warning"
value="login needed"
title="the agent's claude has no session; its turns wait until it is logged in"
/>
) : null}
<span <span
class="ui-agent-card-wanted" class="ui-agent-card-wanted"
onClick={(e) => e.stopPropagation()} onClick={(e) => e.stopPropagation()}

View file

@ -46,6 +46,8 @@ export interface AgentStateHeader {
context_window_tokens: number; context_window_tokens: number;
ctx_usage: TokenUsage | null; ctx_usage: TokenUsage | null;
cost_usage: TokenUsage | null; cost_usage: TokenUsage | null;
// Absent from an agent that predates the field.
needs_login?: boolean;
} }
export interface UseSwarmAgentStateStreamResult { export interface UseSwarmAgentStateStreamResult {

View file

@ -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);
});

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 // 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 // state from this agent's first transition, not from whenever a reader
// first asked. // 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. // Once per start: a new icon only arrives with a new harness process.
swarm_agent_icon::spawn(); swarm_agent_icon::spawn();
// The model and effort the swarm sets for this agent, over its local ones. // 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 //! 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. //! failed publish is a log line and the next transition is still attempted.
use std::sync::{Arc, Mutex, PoisonError};
use std::time::Duration; use std::time::Duration;
use serde::Serialize; use serde::Serialize;
@ -33,9 +34,11 @@ use tokio::sync::broadcast;
use tokio::time::{Interval, MissedTickBehavior}; use tokio::time::{Interval, MissedTickBehavior};
use swarm_queue_client::agent_inference::Effective; use swarm_queue_client::agent_inference::Effective;
use swarm_queue_client::agent_login::Login;
use swarm_queue_client::wanted::AgentState; use swarm_queue_client::wanted::AgentState;
use crate::events::{Bus, BusEvent, LiveEvent, TurnState}; use crate::events::{Bus, BusEvent, LiveEvent, TurnState};
use crate::login::LoginState;
use crate::term_msg::iso8601_utc; use crate::term_msg::iso8601_utc;
/// How much room to leave under the announced limit for everything the /// 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 /// Cumulative token usage across the most recent turn's inferences, the
/// cost signal. `None` until the first turn ends. /// cost signal. `None` until the first turn ends.
cost_usage: Option<hive_claude::TokenUsage>, 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. /// 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. /// The header's login fields for `state`.
fn snapshot(bus: &Bus) -> AgentStateMsg { 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 (turn_state, since_unix) = bus.state_snapshot();
let model = bus.model(); let model = bus.model();
AgentStateMsg { AgentStateMsg {
@ -143,17 +157,21 @@ fn snapshot(bus: &Bus) -> AgentStateMsg {
resolved_model: bus.last_resolved_model(), resolved_model: bus.last_resolved_model(),
ctx_usage: bus.last_ctx_usage(), ctx_usage: bus.last_ctx_usage(),
cost_usage: bus.last_cost_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. /// 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(); let agent = crate::identity::label();
if agent.is_empty() { if agent.is_empty() {
tracing::warn!("this agent has no label; not publishing turn state upward"); tracing::warn!("this agent has no label; not publishing turn state upward");
return; 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. /// Watch the bus and publish whenever the header actually changed.
@ -176,7 +194,12 @@ pub fn spawn(bus: &Bus) {
/// changes nothing. /// changes nothing.
/// ///
/// A [`Trigger::Refresh`] re-sends the header even when it is unchanged. /// 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 client = crate::swarm_queue::client().await;
let subject = swarm_queue_client::agent_state_subject(&agent); let subject = swarm_queue_client::agent_state_subject(&agent);
tracing::info!(subject, "publishing agent turn state to the swarm queue"); 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 last: Option<Vec<u8>> = None;
let mut refresh = refresh_timer(); let mut refresh = refresh_timer();
while let Some(trigger) = next_trigger(&mut rx, &mut refresh).await { 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)] #[cfg(test)]
mod tests { 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::events::{BusEvent, LiveEvent, TurnState};
use crate::login::LoginState;
use std::sync::Mutex;
use swarm_queue_client::agent_inference::{Effective, Source}; use swarm_queue_client::agent_inference::{Effective, Source};
use swarm_queue_client::wanted::AgentState; use swarm_queue_client::wanted::AgentState;
use tokio::sync::broadcast; use tokio::sync::broadcast;
@ -358,6 +386,7 @@ mod tests {
"cache_read_input_tokens": 77, "cache_read_input_tokens": 77,
"cache_creation_input_tokens": 88, "cache_creation_input_tokens": 88,
}))), }))),
login: login(LoginState::NeedsLogin),
} }
} }
@ -384,6 +413,7 @@ mod tests {
"effort_source", "effort_source",
"model", "model",
"model_source", "model_source",
"needs_login",
"resolved_model", "resolved_model",
"turn_state", "turn_state",
"turn_state_since", "turn_state_since",
@ -456,6 +486,44 @@ mod tests {
assert_eq!(value["agent_state"], serde_json::json!("up")); 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 { fn event(event: LiveEvent) -> BusEvent {
BusEvent { BusEvent {
seq: 0, seq: 0,

View file

@ -446,6 +446,7 @@ pub(crate) mod tests {
agent_status: None, agent_status: None,
agent_icons: None, agent_icons: None,
reported_inference: None, reported_inference: None,
agent_login: None,
jobq: Arc::new(std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new( jobq: Arc::new(std::sync::Mutex::new(hive_jobq::scheduler::Scheduler::new(
hive_jobq::Graph::new(), hive_jobq::Graph::new(),
hive_jobq::resources::ResourceTable::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 header, JSON as hive-agent published it (opaque to this relay) — \
`turn_state`, `turn_state_since` (ISO 8601 UTC), `agent_state`, `model`, `effort`, \ `turn_state`, `turn_state_since` (ISO 8601 UTC), `agent_state`, `model`, `effort`, \
`model_source`, `effort_source`, `resolved_model`, `context_window_tokens`, \ `model_source`, `effort_source`, `resolved_model`, `context_window_tokens`, \
`ctx_usage`, `cost_usage`. Sent on transition and re-sent once a minute, live, \ `ctx_usage`, `cost_usage`, `needs_login`. Sent on transition and re-sent once a \
no replay", minute, live, no replay",
body = String, content_type = "text/event-stream"), body = String, content_type = "text/event-stream"),
(status = 400, description = "the agent name is not shaped like an identifier \ (status = 400, description = "the agent name is not shaped like an identifier \
(problem+json)", body = String), (problem+json)", body = String),

View file

@ -65,6 +65,13 @@ pub struct AgentStatusRow {
/// here would blur that boundary for the same reason `config_pr` /// here would blur that boundary for the same reason `config_pr`
/// doesn't merge forge state in directly. /// doesn't merge forge state in directly.
pub wanted: Option<String>, 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 /// A snapshot as retained by the bucket, with the hive it was published
@ -186,6 +193,7 @@ fn render(
snapshot: None, snapshot: None,
config_pr: None, config_pr: None,
wanted: None, wanted: None,
needs_login: None,
}, },
}) })
.collect(); .collect();
@ -230,6 +238,7 @@ fn row(
snapshot: offered.payload.clone(), snapshot: offered.payload.clone(),
config_pr: None, config_pr: None,
wanted: None, wanted: None,
needs_login: None,
} }
} }

View file

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

View file

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

View file

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

View file

@ -45,6 +45,7 @@ mod agent_icon;
mod agent_identity; mod agent_identity;
mod agent_input; mod agent_input;
mod agent_limits; mod agent_limits;
mod agent_login;
mod agent_renewal; mod agent_renewal;
mod agent_state_stream; mod agent_state_stream;
mod agent_status; mod agent_status;
@ -894,6 +895,10 @@ struct AppState {
/// headers on `status`'s connection. `None` in exactly the state /// headers on `status`'s connection. `None` in exactly the state
/// `status` is. /// `status` is.
reported_inference: Option<Arc<inference::Reported>>, 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 /// The swarm-level job graph, wrapped in its
/// [`hive_jobq::scheduler::Scheduler`] now that something drives it /// [`hive_jobq::scheduler::Scheduler`] now that something drives it
/// (`spawn_jobq_worker`) — the graph alone was enough for the /// (`spawn_jobq_worker`) — the graph alone was enough for the
@ -1260,6 +1265,12 @@ fn reported_inference(
Some(inference::Reported::spawn(status?.queue_client())) 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`. /// 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 /// A controller that cannot hold a webhook secret still serves every other
/// route; the webhook endpoint answers 503, which is the honest answer /// 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)) Ok(Json(rows))
} }
Err(e) => { Err(e) => {
@ -3334,6 +3350,7 @@ async fn main() -> Result<()> {
agent_status: agent_status_reader(status.as_ref()), agent_status: agent_status_reader(status.as_ref()),
agent_icons: agent_icon_reader(status.as_ref()), agent_icons: agent_icon_reader(status.as_ref()),
reported_inference: reported_inference(status.as_ref()), reported_inference: reported_inference(status.as_ref()),
agent_login: agent_login(status.as_ref()),
status, status,
jobq, jobq,
webhook_secret, webhook_secret,
@ -3534,6 +3551,7 @@ mod tests {
agent_status: None, agent_status: None,
agent_icons: None, agent_icons: None,
reported_inference: None, reported_inference: None,
agent_login: None,
jobq: std::sync::Arc::clone(&sched), jobq: std::sync::Arc::clone(&sched),
webhook_secret: None, webhook_secret: None,
config_prs: std::sync::Arc::default(), config_prs: std::sync::Arc::default(),

View file

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

View file

@ -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<bool>,
}
#[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);
}
}
}

View file

@ -221,6 +221,10 @@ pub mod agent_inference;
/// writing needs `kv` and reading needs `agent-limits`. /// writing needs `kv` and reading needs `agent-limits`.
pub mod 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")] #[cfg(feature = "kv")]
mod kv; mod kv;