hive-c0re: converge when the controller republishes, not only at boot

The wanted-state read was a boot-time DAG node, so a swarm-level change sat
unapplied until the next restart. This watches the hive's own bucket and
converges on each update.

It does not replace the boot read: a watch hears only what is published while
it is listening, so a hive that was down still learns the current declaration
from `pull`. The watch is the fast path, `pull` stays the repair path.

Rides the connection swarm-status already opens, as a third consumer — a
second connect would double the auth-callout traffic and give the two paths
independent reconnect state, which is the reason the deploy-event drain is
spawned there too.

A delete is not a deletion order. `carries_a_declaration` is pure and tested
so that rule is enforced rather than asserted: converging on a withdrawn key
would tear down exactly the agents "absence is not a deletion order" protects.
This commit is contained in:
atlas 2026-09-03 00:36:15 +02:00
commit 5f733493f1
3 changed files with 146 additions and 1 deletions

View file

@ -154,6 +154,27 @@ pub async fn open_read_only(
js.get_key_value(bucket(hive)).await.ok()
}
/// Watch this hive's own declaration for changes.
///
/// **Hive-side.** `None` on the same terms as [`open_read_only`] — no bucket
/// yet — plus one more: a watch is a `JetStream` *consumer*, so it needs a grant
/// a plain `get` does not. A hive missing `CONSUMER.CREATE` on its own stream
/// gets `None` here while `get` keeps working, which is why the caller must
/// treat this as "not watching yet" and retry rather than as a dead end.
///
/// Updates only, deliberately: the boot-time read already has the current
/// value, and a watch that replayed history would re-converge the whole
/// declaration on every reconnect for nothing.
#[cfg(feature = "kv")]
pub async fn watch(
client: &async_nats::Client,
hive: &str,
) -> Option<async_nats::jetstream::kv::Watch> {
// The bucket holds exactly one key, named for the hive — same key
// `open_read_only`'s caller reads, so both paths address one declaration.
open_read_only(client, hive).await?.watch(hive).await.ok()
}
#[cfg(test)]
mod tests {
use super::{AgentState, BUCKET_PREFIX, HiveWanted, bucket};