Watch
0
0
Fork
You've already forked hyperhive
0

swarm UI: send a message to an agent, without its hive

The agent view in the swarm UI gets a message box. The text reaches the
agent's harness straight from swarm-controller over the swarm queue and
becomes an inbox turn from the operator's authelia username. hive-c0re is
not in the path.

- gateway: `/api/` forwards authelia's `Remote-User`, replacing any the
  client sent.
- swarm-controller: `POST /api/agents/{name}/input` takes that header as
  the sender (401 without it) and sends `{from, body}` as a NATS request
  on `$SWARM.input.<agent>`. No subscriber is a 503.
- swarm-nats-auth: the controller is granted publish on `$SWARM.input.*`;
  no hive or agent grant names the family, so the harness can trust
  `from`.
- hive-agent: a task subscribes to the agent's own input subject and
  pushes each message into the serve loop's existing reminder channel,
  renamed `local_msg_*` since it now carries both.

The harness does not reply to the request: no agent grant covers a reply
subject. The controller therefore answers 202 once the request times out
(2s) and 503 on the server's no-responders status.

Closes #4828
This commit is contained in:
atlas 2026-10-04 03:00:05 +02:00
commit 48ac4ac423
13 changed files with 664 additions and 24 deletions

View file

@ -30,6 +30,7 @@ mod state_entry_watch;
mod stats;
mod swarm_agent_icon;
mod swarm_agent_state;
mod swarm_input;
mod swarm_queue;
mod swarm_term;
mod term_msg;
@ -595,7 +596,10 @@ async fn serve_main<S: Surface>(socket: &Path, poll_ms: u64) -> Result<()> {
) {
tracing::warn!(error = ?e, "legacy db_migrate failed — continuing with fresh/partial state db");
}
let (reminder_tx, reminder_rx) = tokio::sync::mpsc::unbounded_channel();
// Messages made inside this container rather than by the broker:
// reminders and the swarm UI's operator input.
let (local_msg_tx, local_msg_rx) = tokio::sync::mpsc::unbounded_channel();
swarm_input::spawn(local_msg_tx.clone());
let reminder_store = match reminders::Reminders::open(&paths::state_db()) {
Ok(store) => Some(Arc::new(store)),
Err(e) => {
@ -603,7 +607,7 @@ async fn serve_main<S: Surface>(socket: &Path, poll_ms: u64) -> Result<()> {
None
}
};
tokio::spawn(reminder_timer::run(reminder_store.clone(), reminder_tx));
tokio::spawn(reminder_timer::run(reminder_store.clone(), local_msg_tx));
let (todo_wake, todos_store) = spawn_todo_socket(reminder_store.clone(), &bus);
if matches!(initial, LoginState::NeedsLogin) {
login::wait_for_login(
@ -630,7 +634,7 @@ async fn serve_main<S: Surface>(socket: &Path, poll_ms: u64) -> Result<()> {
&files,
todo_wake,
todos_store,
reminder_rx,
local_msg_rx,
interrupted,
)
.await
@ -665,7 +669,7 @@ async fn serve_loop<S: Surface>(
files: &turn::TurnFiles,
todo_wake: Arc<tokio::sync::Notify>,
todos_store: Option<Arc<todos::Todos>>,
mut reminder_rx: tokio::sync::mpsc::UnboundedReceiver<hive_sh4re::inbox::DeliveredMessage>,
mut local_msg_rx: tokio::sync::mpsc::UnboundedReceiver<hive_sh4re::inbox::DeliveredMessage>,
interrupted: Arc<std::sync::atomic::AtomicBool>,
) -> Result<()> {
tracing::info!(socket = %socket.display(), "harness serve");
@ -690,7 +694,7 @@ async fn serve_loop<S: Surface>(
// Nothing here touches the broker: not calling `S::recv_next` is
// exactly the "messages queue unacked, resume drains the
// backlog" semantic, with no fencing and nothing to requeue.
// Reminders (unbounded channel) and todo wakes (a `Notify`
// Local messages (unbounded channel) and todo wakes (a `Notify`
// permit) buffer on their own. The web UI and MCP daemons run as
// separate tasks, so the agent stays inspectable while parked.
//
@ -733,7 +737,7 @@ async fn serve_loop<S: Surface>(
biased;
o = S::recv_next(socket) => o,
() = todo_wake.notified() => RecvOutcome::LocalTodo,
Some(dm) = reminder_rx.recv() => RecvOutcome::Message(dm),
Some(dm) = local_msg_rx.recv() => RecvOutcome::Message(dm),
}
} {
RecvOutcome::Message(first) => first,