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
|
|
@ -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/<name>/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/<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
|
||||
`GET /icon` serves. It goes into the `agent-icons` KV bucket under the key
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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<AgentRow, "needs_login">): boolean {
|
||||
return row.needs_login === true;
|
||||
}
|
||||
|
||||
// Same tone pairing as HivesPage — one freshness enum shared by both
|
||||
|
|
|
|||
|
|
@ -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) ? (
|
||||
<Badge
|
||||
tone="warning"
|
||||
value="login needed"
|
||||
title="the agent's claude has no session; its turns wait until it is logged in"
|
||||
/>
|
||||
) : null}
|
||||
<span
|
||||
class="ui-agent-card-wanted"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
19
frontend/packages/swarm-ui/test/agent-types.test.mjs
Normal file
19
frontend/packages/swarm-ui/test/agent-types.test.mjs
Normal 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);
|
||||
});
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
53
swarm-queue-client/src/agent_login.rs
Normal file
53
swarm-queue-client/src/agent_login.rs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue