hive-agent: the swarm queue connection is required; retry, then exit
An agent's harness connects to the swarm queue with the agent's own credential or not at all. The first connect retries after 5s, doubling (7 attempts, about five minutes), with a warning per failed attempt; when every attempt fails the harness logs an error and exits 1. Missing coordinates (HIVE_AGENT_NATS_URL, or the secret store's BAO_ADDR / HIVE_AGENT_NAME / certificate) fail the boot outright. Previously the first connect's outcome was memoized for the life of the process: a store 403 or queue refusal at boot left the agent on the hive's shared client, or disconnected, until someone restarted it, and an agent with no queue coordinates ran on without one. Both paths are gone, with the hive-scoped subjects that only the shared client used. The harness unit now restarts after 30s instead of 2s, so a swarm-wide outage doesn't restart every agent every 2s, and has no start limit, so it keeps coming back however long the outage lasts. The hive's shared client credential is still imported into the harness unit for swarm-logs; the two HIVE_AGENT_OIDC_*_FILE variables that pointed the harness at it are dropped, as is the unset HIVE_AGENT_OIDC_CA_FILE override. With the fallback gone, the pieces #4944 added only for it go too: swarm_input's skip-on-shared-client branch and swarm-queue-client's connect_with_inbox_prefix. swarm-nats-auth's grant for the hive's shared agent client is unchanged. Closes #4909
This commit is contained in:
parent
147b9e0a23
commit
235bc481b3
16 changed files with 392 additions and 964 deletions
|
|
@ -24,10 +24,9 @@
|
|||
//! alternative is a KV bucket written on every turn-state flip, and the
|
||||
//! swarm already has one of those at the resolution it can afford.
|
||||
//!
|
||||
//! **Best-effort in every direction.** The turn loop and the web UI must not
|
||||
//! notice whether the queue exists, 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.
|
||||
//! **Best-effort per transition.** The turn loop and the web UI never wait on
|
||||
//! 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 serde::Serialize;
|
||||
use tokio::sync::broadcast;
|
||||
|
|
@ -35,7 +34,6 @@ use tokio::sync::broadcast;
|
|||
use swarm_queue_client::wanted::AgentState;
|
||||
|
||||
use crate::events::{Bus, BusEvent, LiveEvent, TurnState};
|
||||
use crate::swarm_queue::{Connection, Presented};
|
||||
use crate::term_msg::iso8601_utc;
|
||||
|
||||
/// Subject family carrying agent turn-state headers, the swarm-wide
|
||||
|
|
@ -44,18 +42,6 @@ use crate::term_msg::iso8601_utc;
|
|||
/// agent without filtering the swarm's whole header traffic.
|
||||
const SUBJECT_PREFIX: &str = "$SWARM.agent-state";
|
||||
|
||||
/// What the queue's client id looks like either side of the hive's own name.
|
||||
///
|
||||
/// These mirror the auth-callout responder's `--hive-client-prefix` and
|
||||
/// `--agent-client-suffix`, which is where the authoritative pair lives: the
|
||||
/// responder parses the hive back out of the presented client id and grants
|
||||
/// exactly the configured subjects for *that* hive. The agent is not told
|
||||
/// those flags, so it restates their defaults — a deployment that retunes
|
||||
/// either one has to change them here too, and the symptom of not doing so
|
||||
/// is every publish refused rather than a wrong subject being accepted.
|
||||
const CLIENT_ID_PREFIX: &str = "hive-";
|
||||
const CLIENT_ID_SUFFIX: &str = "-agent";
|
||||
|
||||
/// How much room to leave under the announced limit for everything the
|
||||
/// publish adds around the payload — subject, headers, protocol framing.
|
||||
/// Same margin and same reasoning as `swarm_term::HEADROOM`.
|
||||
|
|
@ -108,25 +94,6 @@ struct AgentStateMsg {
|
|||
cost_usage: Option<hive_claude::TokenUsage>,
|
||||
}
|
||||
|
||||
/// The hive a queue client id names.
|
||||
///
|
||||
/// The hive in the subject has to be the string the **responder** parses out
|
||||
/// of this same client id, because that is what its grant is built from. The
|
||||
/// harness knows a hive display name too, from a different source and with
|
||||
/// no rule tying the two together — deriving the subject from that one
|
||||
/// instead produces a publish the broker refuses, which surfaces as a header
|
||||
/// that is simply never populated.
|
||||
///
|
||||
/// `None` for an id that does not have the expected shape: a publisher that
|
||||
/// guessed at a subject would be asking for a grant it cannot have, so the
|
||||
/// caller disables itself instead.
|
||||
fn hive_from_client_id(client_id: &str) -> Option<&str> {
|
||||
let hive = client_id
|
||||
.strip_prefix(CLIENT_ID_PREFIX)?
|
||||
.strip_suffix(CLIENT_ID_SUFFIX)?;
|
||||
(!hive.is_empty()).then_some(hive)
|
||||
}
|
||||
|
||||
/// What this process can honestly report about the agent-state axis.
|
||||
///
|
||||
/// Only two of [`AgentState`]'s four variants are knowable from inside the
|
||||
|
|
@ -172,37 +139,13 @@ fn snapshot(bus: &Bus) -> AgentStateMsg {
|
|||
}
|
||||
}
|
||||
|
||||
/// The subject this agent's header goes to under the credential it connected
|
||||
/// with, or `None` when a hive client id names no hive.
|
||||
fn subject(presented: &Presented, agent: &str) -> Option<String> {
|
||||
match presented {
|
||||
Presented::Agent => Some(format!("{SUBJECT_PREFIX}.{agent}")),
|
||||
Presented::Hive { client_id } => {
|
||||
let Some(hive) = hive_from_client_id(client_id) else {
|
||||
tracing::warn!(
|
||||
%client_id,
|
||||
expected = format!("{CLIENT_ID_PREFIX}<hive>{CLIENT_ID_SUFFIX}"),
|
||||
"queue client id does not name a hive; not publishing turn state upward"
|
||||
);
|
||||
return None;
|
||||
};
|
||||
Some(format!("{SUBJECT_PREFIX}.{hive}.{agent}"))
|
||||
}
|
||||
}
|
||||
/// The subject this agent's header goes to.
|
||||
fn subject(agent: &str) -> String {
|
||||
format!("{SUBJECT_PREFIX}.{agent}")
|
||||
}
|
||||
|
||||
/// Start the publish task, if this agent has a queue credential and a label
|
||||
/// to name its subject with.
|
||||
///
|
||||
/// Returns without spawning in every other case — no queue or no label — each
|
||||
/// of which is a legal state for an agent rather than an error, and each
|
||||
/// logged once here rather than per transition.
|
||||
/// Start the publish task, if this agent has a label to name its subject with.
|
||||
pub fn spawn(bus: &Bus) {
|
||||
if !crate::swarm_queue::configured() {
|
||||
// `swarm_queue::init` already said why at boot; repeating it here
|
||||
// would be the same fact logged twice.
|
||||
return;
|
||||
}
|
||||
let agent = crate::identity::label();
|
||||
if agent.is_empty() {
|
||||
tracing::warn!("this agent has no label; not publishing turn state upward");
|
||||
|
|
@ -230,12 +173,8 @@ pub fn spawn(bus: &Bus) {
|
|||
/// any added later, funnels into the comparison and costs nothing when it
|
||||
/// changes nothing.
|
||||
async fn run(mut rx: broadcast::Receiver<BusEvent>, bus: Bus, agent: String) {
|
||||
let Some(Connection { client, presented }) = crate::swarm_queue::client().await else {
|
||||
return;
|
||||
};
|
||||
let Some(subject) = subject(&presented, &agent) else {
|
||||
return;
|
||||
};
|
||||
let client = crate::swarm_queue::client().await;
|
||||
let subject = subject(&agent);
|
||||
tracing::info!(subject, "publishing agent turn state to the swarm queue");
|
||||
|
||||
// The last payload actually sent, so a rebuild that changed nothing is
|
||||
|
|
@ -321,7 +260,7 @@ async fn publish(
|
|||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{AgentStateMsg, Presented, hive_from_client_id, subject};
|
||||
use super::{AgentStateMsg, subject};
|
||||
use crate::events::TurnState;
|
||||
use swarm_queue_client::wanted::AgentState;
|
||||
|
||||
|
|
@ -367,63 +306,13 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_client_id_names_the_hive_between_the_prefix_and_the_suffix() {
|
||||
assert_eq!(hive_from_client_id("hive-alpha-agent"), Some("alpha"));
|
||||
// A hive whose own name contains the suffix still resolves: only the
|
||||
// trailing one is stripped, so the responder and this agree.
|
||||
assert_eq!(
|
||||
hive_from_client_id("hive-alpha-agent-agent"),
|
||||
Some("alpha-agent")
|
||||
);
|
||||
}
|
||||
|
||||
/// Each way an id can fail to name a hive. A guessed subject would be
|
||||
/// refused by the broker, and a refusal reaches an operator as a header
|
||||
/// that never fills in rather than as an error, so none of these may
|
||||
/// fall back.
|
||||
#[test]
|
||||
fn an_id_of_another_shape_names_no_hive() {
|
||||
for id in [
|
||||
// The hive's own id, not an agent's.
|
||||
"hive-alpha",
|
||||
// Missing the prefix the responder keys on.
|
||||
"alpha-agent",
|
||||
// Prefix and suffix but nothing between them: the subject would
|
||||
// be `$SWARM.agent-state..<agent>`, whose empty token matches no
|
||||
// grant.
|
||||
"hive--agent",
|
||||
// Prefix and suffix overlapping with no hive at all.
|
||||
"hive-agent",
|
||||
"",
|
||||
] {
|
||||
assert_eq!(hive_from_client_id(id), None, "{id} must name no hive");
|
||||
}
|
||||
}
|
||||
|
||||
/// The subject the swarm side subscribes to, built end to end from the
|
||||
/// two things that identify this publisher. `swarm-controller`'s relay
|
||||
/// The subject the swarm side subscribes to. `swarm-controller`'s relay
|
||||
/// spells the same prefix in its own constant and the two never meet, so
|
||||
/// this pins the half that lives here.
|
||||
/// this pins the half that lives here. The agent's own credential is
|
||||
/// granted the subject naming the agent alone, with no hive in it.
|
||||
#[test]
|
||||
fn the_subject_is_the_prefix_then_the_hive_then_the_agent() {
|
||||
let presented = Presented::Hive {
|
||||
client_id: "hive-alpha-agent".to_owned(),
|
||||
};
|
||||
assert_eq!(
|
||||
subject(&presented, "mara").as_deref(),
|
||||
Some("$SWARM.agent-state.alpha.mara")
|
||||
);
|
||||
}
|
||||
|
||||
/// An agent that connected with its own credential publishes on the
|
||||
/// subject that credential is granted, which names no hive.
|
||||
#[test]
|
||||
fn an_agent_on_its_own_credential_publishes_on_its_hive_free_subject() {
|
||||
assert_eq!(
|
||||
subject(&Presented::Agent, "mara").as_deref(),
|
||||
Some("$SWARM.agent-state.mara")
|
||||
);
|
||||
fn the_subject_is_the_prefix_then_the_agent() {
|
||||
assert_eq!(subject("mara"), "$SWARM.agent-state.mara");
|
||||
}
|
||||
|
||||
/// The published contract, asserted on the JSON a subscriber parses
|
||||
|
|
|
|||
Loading…
Reference in a new issue