Watch
0
0
Fork
You've already forked hyperhive
0

agents get a todo when assigned to an issue or PR, rewritten when unassigned and cleared on close

A new swarm service, swarm-notify-router, in its own container on the host
that sets deploy.notify-router.enable (defaulted on by allSwarmServices where
the host has the router's store leaf, like the claude login minter).

- swarm-controller registers instance-wide hooks for `issues` +
  `issue_assign` and `pull_request` + `pull_request_assign`, updates the
  event list of a hook already registered with fewer (leaving its active flag as it is), and keeps each
  verified body unchanged in the SWARM_EVENTS stream on
  $SWARM.events.forge.<X-Forgejo-Event-Type>, deduped by X-Forgejo-Delivery.
  A delivery the stream did not keep answers 503.
- swarm-notify-router drains its durable consumer on that stream, runs its
  flows (source -> match -> todo sink, stored as nodes plus edges and checked
  to be a tree) and queues each op on $SWARM.router.todo.<agent> in the
  work-queue stream ROUTER_TODOS. Built-in flows: assigned, unassigned (same
  key, rewritten) and closed (prefix-clears every holder's router todos). A
  per-issue snapshot in its notify-router-state bucket holds the last
  assignees and the todo holders; only agents in the agent-status bucket get
  ops, and an assignee that was not one is left out of the snapshot so the
  next assign event retries it. After the first event that must be retried,
  the rest of the batch is nakked so redelivery keeps stream order.
- hive-agent creates its own durable consumer todo-<agent> and applies each
  op to its todo store as subsystem `router`, then acks; a refused op stops
  the rest of its batch the same way. The store's (subsystem, key) dedup
  makes a redelivery harmless. New Todos::clear_prefix.
- swarm-nats-auth: a --notify-router-client arm; the controller may publish
  $SWARM.events.forge.*; each agent gets three {agent} templates (extended
  consumer create whose filter the server checks, MSG.NEXT, ACK).
- Credentials: a notify-router store leaf minted by glue-bao-tls, a
  swarm-notify-router bao policy (read on its own queue client secret), an
  authelia machine client, the secret publisher entry, a journal forwarder
  route. No manual step.

hive-forge-notify is unchanged, so agents also still get forge's own
notifications.

Refs #5571
This commit is contained in:
atlas 2026-10-22 03:03:26 +02:00 • committed by mara
commit 0f7b403803
53 changed files with 3587 additions and 96 deletions

View file

@ -0,0 +1,194 @@
//! One event through every flow: the ops to queue and the issue's next
//! snapshot. No I/O, so the whole decision is testable.
use std::collections::BTreeSet;
use swarm_wire::router_todo::Op;
use crate::event::{Delivery, Snapshot, fields, is_assign};
use crate::flow::{Action, Flow};
/// What one event comes to.
#[derive(Debug, PartialEq, Eq)]
pub struct Routed {
/// The ops to queue, one per agent that exists.
pub actions: Vec<Action>,
/// Recipients that are not agents of this swarm, so got nothing.
pub skipped: Vec<String>,
pub next: Snapshot,
}
/// Run `flows` over one delivery of `event_type` on an issue whose snapshot is
/// `snapshot`. `is_agent` says whether a recipient is an agent of this swarm.
pub fn route(
event_type: &str,
delivery: &Delivery,
snapshot: &Snapshot,
flows: &[Flow],
is_agent: impl Fn(&str) -> bool,
) -> Routed {
let fields = fields(event_type, delivery, snapshot);
let mut actions = Vec::new();
let mut skipped = Vec::new();
for action in flows.iter().flat_map(|f| f.evaluate(event_type, &fields)) {
if is_agent(&action.agent) {
actions.push(action);
} else if !skipped.contains(&action.agent) {
skipped.push(action.agent);
}
}
let mut next = snapshot.clone();
if is_assign(event_type) {
// A skipped assignee is left out of the snapshot, so the next assign
// event counts it as added again: an agent assigned before it is in
// the roster gets its todo once it is.
let mut assignees = delivery.assignees.clone();
assignees.retain(|a| !skipped.contains(a));
next.assignees = Some(assignees);
}
let mut cleared = BTreeSet::new();
for action in &actions {
match action.op.op {
Op::Upsert => {
next.holders.insert(action.agent.clone());
}
Op::Clear => {
cleared.insert(action.agent.clone());
}
}
}
next.holders.retain(|h| !cleared.contains(h));
Routed {
actions,
skipped,
next,
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::flow::builtins;
fn delivery(action: &str, assignees: &[&str]) -> Delivery {
Delivery {
action: action.to_owned(),
repo: "o/r".to_owned(),
repo_id: 1,
number: 9,
title: "t".to_owned(),
url: "u".to_owned(),
sender: "mara".to_owned(),
is_pull: false,
assignees: assignees.iter().map(|a| (*a).to_owned()).collect(),
}
}
fn agents(a: &Routed) -> Vec<(&str, Op)> {
a.actions
.iter()
.map(|x| (x.agent.as_str(), x.op.op))
.collect()
}
/// Assign, reassign, close: the three built-ins in the order an issue
/// meets them, carrying the snapshot from one event to the next.
#[test]
fn an_issue_assigned_reassigned_and_closed_queues_and_clears_its_todos() {
let flows = builtins();
let is_agent = |a: &str| a != "mara";
let one = route(
"issue_assign",
&delivery("assigned", &["atlas", "mara"]),
&Snapshot::default(),
&flows,
is_agent,
);
assert_eq!(agents(&one), [("atlas", Op::Upsert)]);
assert_eq!(one.skipped, ["mara"]);
assert_eq!(one.next.holders, BTreeSet::from(["atlas".to_owned()]));
let two = route(
"issue_assign",
&delivery("unassigned", &["mara"]),
&one.next,
&flows,
is_agent,
);
assert_eq!(agents(&two), [("atlas", Op::Upsert)]);
assert!(two.actions[0].op.summary.contains("not assigned"));
let three = route(
"issue_assign",
&delivery("assigned", &["mara", "iris"]),
&two.next,
&flows,
is_agent,
);
assert_eq!(agents(&three), [("iris", Op::Upsert)]);
let closed = route(
"issues",
&delivery("closed", &["mara", "iris"]),
&three.next,
&flows,
is_agent,
);
let mut got = agents(&closed);
got.sort_unstable_by_key(|(a, _)| *a);
assert_eq!(got, [("atlas", Op::Clear), ("iris", Op::Clear)]);
assert!(closed.next.holders.is_empty());
// A close leaves the assignee snapshot as the last assign set it.
assert_eq!(closed.next.assignees, three.next.assignees);
}
/// An agent assigned before it reports status gets nothing then, and its
/// todo on the next assign event, once it is in the roster.
#[test]
fn an_assignee_not_yet_in_the_roster_gets_its_todo_on_the_next_assign() {
let flows = builtins();
let early = route(
"issue_assign",
&delivery("assigned", &["nova"]),
&Snapshot::default(),
&flows,
|_| false,
);
assert!(early.actions.is_empty());
assert_eq!(early.skipped, ["nova"]);
assert_eq!(early.next.assignees, Some(BTreeSet::new()));
let later = route(
"issue_assign",
&delivery("assigned", &["nova", "atlas"]),
&early.next,
&flows,
|_| true,
);
let mut got = agents(&later);
got.sort_unstable_by_key(|(a, _)| *a);
assert_eq!(got, [("atlas", Op::Upsert), ("nova", Op::Upsert)]);
assert_eq!(
later.next.assignees,
Some(BTreeSet::from(["atlas".to_owned(), "nova".to_owned()]))
);
}
#[test]
fn an_event_no_flow_matches_changes_nothing() {
let before = Snapshot {
assignees: Some(BTreeSet::from(["atlas".to_owned()])),
holders: BTreeSet::from(["atlas".to_owned()]),
};
let r = route(
"issue_comment",
&delivery("created", &[]),
&before,
&builtins(),
|_| true,
);
assert!(r.actions.is_empty());
assert_eq!(r.next, before);
}
}