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

@ -193,6 +193,7 @@ let
"swarm-bao-builder-policy"
"swarm-bao-ci-metrics-policy"
"swarm-bao-claude-login-policy"
"swarm-bao-notify-router-policy"
];
# Comment lines dropped first: both the HCL and the scripts explain
@ -1834,6 +1835,32 @@ let
in
lib.hasInfix "/claude-login.pem ]" s && lib.hasInfix "swarm-claude-login \"\" clientAuth" s;
}
{
# The router reads its own queue client secret and nothing else.
# The claude login minter's grant is the control that `grantsIn` reads
# a second path where there is one.
name = "the notification router's grant is read on its own queue client secret alone";
ok =
let
s = baoGrantHere.systemd.services.swarm-bao-notify-router-policy.script;
grants = grantsIn s;
minter = grantsIn baoGrantHere.systemd.services.swarm-bao-claude-login-policy.script;
in
(grantFor grants "secret/data/swarm/services/swarm-notify-router/oidc/client").caps == [ "read" ]
&& lib.length grants == 1
&& lib.length minter == 2
&& lib.hasInfix "auth/cert/certs/swarm-notify-router" s
&& lib.hasInfix "allowed_common_names=swarm-notify-router" s
&& lib.hasInfix "token_policies=swarm-notify-router" s;
}
{
name = "the PKI unit signs the notification router's leaf under its own subject";
ok =
let
s = baoGrantHere.systemd.services.swarm-bao-pki.script;
in
lib.hasInfix "/notify-router.pem ]" s && lib.hasInfix "swarm-notify-router \"\" clientAuth" s;
}
{
name = "the PKI unit signs the CI runner's leaf under its own subject";
ok =

View file

@ -76,7 +76,7 @@ let
# The same swarm's IdP host, for the arm that the two agree.
autheliaColocated = shared.autheliaOnly;
# The other three queue clients with a container of their own, each forwarding
# The other four queue clients with a container of their own, each forwarding
# its journal as the queue's does. One shared host: the switch that turns on
# every swarm service, which other suites already evaluate.
servicesHost = shared.swarmServicesWithController;
@ -99,6 +99,12 @@ let
credential = "review-classifier-bao";
port = 8894;
}
{
machine = "swarm-notify-router";
name = "notify-router";
credential = "notify-router-bao";
port = 8902;
}
];
forwarderFetchDir = "/run/credentials/swarm-journal-forwarder-oidc.service";
@ -483,7 +489,7 @@ let
# The whole per-agent grant, compared as a list rather than by infix: a
# wider subject added beside these (`$$JS.API.>`, another stream's name)
# would pass every presence check above.
name = "a verified agent's grant is exactly its own subjects, and its JetStream API subjects read its own schedule, inference and permissions keys";
name = "a verified agent's grant is exactly its own subjects, and its JetStream API subjects read its own schedule, inference and permissions keys and drain its own router todo consumer";
ok =
let
args = lib.splitString " " (responderOf natsQueueOnly).serviceConfig.ExecStart;
@ -506,6 +512,9 @@ let
"'$$JS.API.DIRECT.GET.KV_agent-inference.$$KV.agent-inference.{agent}'"
"'$$JS.API.DIRECT.GET.KV_agent-permissions.$$KV.agent-permissions.{agent}'"
"'$$JS.API.DIRECT.GET.KV_agents-md-initial.$$KV.agents-md-initial.{agent}'"
"'$$JS.API.CONSUMER.CREATE.ROUTER_TODOS.todo-{agent}.$$SWARM.router.todo.{agent}'"
"'$$JS.API.CONSUMER.MSG.NEXT.ROUTER_TODOS.todo-{agent}'"
"'$$JS.ACK.ROUTER_TODOS.todo-{agent}.>'"
];
}
{
@ -776,10 +785,10 @@ let
c != null && c.audience == [ "swarm-nats" ] && c.accessTokenSignedResponseAlg == "RS256";
}
{
# The far end for the three: a receiver, authenticator, pipeline entry and
# The far end for the four: a receiver, authenticator, pipeline entry and
# gateway route each, checking that service's client as audience, beside
# the queue's, on a collector host that runs none of them.
name = "the swarm collector admits the builder, claude login and classifier forwarders on receivers of their own";
name = "the swarm collector admits the builder, claude login, classifier and notification router forwarders on receivers of their own";
ok =
let
h = shared.baoWithCollector;
@ -797,13 +806,15 @@ let
&& lib.elem "otlp/${svc.machine}" s.service.pipelines."logs/${otel.producerName}".receivers
&& h.services.nginx.virtualHosts.${otel.domain}.locations ? "/${svc.machine}/"
) forwarderServices
# Four producers' receivers on the collector's host, none sharing a port.
&& lib.length (lib.unique (ports ++ [ otel.serviceProducers.swarm-nats.port ])) == 4;
# Their receivers and the queue's on the collector's host, none sharing a port.
&&
lib.length (lib.unique (ports ++ [ otel.serviceProducers.swarm-nats.port ]))
== lib.length forwarderServices + 1;
}
{
# The token each forwarder mints must name its own client as audience and
# be a JWT, or the receiver refuses it.
name = "the builder, claude login and classifier clients are registered with their own audience and a signed access token";
name = "the builder, claude login, classifier and notification router clients are registered with their own audience and a signed access token";
ok =
let
clients = shared.autheliaOnly.services.hyperhive.swarm.authelia.oidc.clients;

View file

@ -279,6 +279,28 @@ let
&& !(swarmServicesBaoElsewhere.containers ? swarm-claude-login)
&& swarmServicesBaoElsewhere.services.hyperhive.deploy.nats.enable;
}
{
# Same shape as the minter's: a static user in its own container. `nats`
# is the control that the switch is on at all.
name = "hosting the swarm's shared services runs the notification router in its container as a static user";
ok =
let
c = swarmServicesHere.containers.swarm-notify-router.config;
sc = c.systemd.services.swarm-notify-router.serviceConfig;
in
swarmServicesHere.services.hyperhive.deploy.notify-router.enable
&& c.users.users.swarm-notify-router.isSystemUser
&& sc.User == "swarm-notify-router"
&& !(sc.DynamicUser or false)
&& swarmServicesHere.services.hyperhive.deploy.nats.enable;
}
{
name = "the notification router stays off where the shared services are hosted without the store";
ok =
!swarmServicesBaoElsewhere.services.hyperhive.deploy.notify-router.enable
&& !(swarmServicesBaoElsewhere.containers ? swarm-notify-router)
&& swarmServicesBaoElsewhere.services.hyperhive.deploy.nats.enable;
}
{
# The CLI is the one the agents run, not the container's own nixpkgs'.
# Unpinned is the control: it falls back to the container's own `claude-code`.
@ -587,7 +609,7 @@ let
# that lost a container can't pass by comparing fewer of them.
name = "every service container restates its host-side privateNetwork";
ok =
lib.length (lib.attrNames serviceContainers) == 13
lib.length (lib.attrNames serviceContainers) == 14
&& lib.all (
c: c.privateNetwork == (c.config.services.hyperhive.swarmContainer.privateNetwork or null)
) (lib.attrValues serviceContainers);