feat(agents): acknowledge agent mentions so they stop dead-ending - #6126
feat(agents): acknowledge agent mentions so they stop dead-ending#6126mfethe1 wants to merge 3 commits into
Conversation
An @mention of an agent that nothing picks up is today indistinguishable
from one that was picked up and is still being worked on: both are silence.
The relay fans out but does not track delivery, so a mention whose target is
not connected and subscribed at the instant of fan-out reaches nobody, and
nothing is ever published in response.
Define kind 44102, an agent-published receipt for a mention. Tags: h
(channel), e (the mention), p (its author), status of accepted or declined,
and for declined a machine-readable reason slug.
The declined half is the load-bearing one. The paths that dead-end a mention
today are precisely the ones where the harness knowingly decided not to act:
the author is outside respond_to (which defaults to owner-only, so any
co-worker mention lands here), no rule matched, a filter failed closed, or
it is busy and configured to drop. Each exits at a debug! log. A declined
receipt turns those into an explicit, explained outcome the sender can act
on.
Two relay gates are required, not one. required_scope_for_kind is an
exhaustive match whose fallthrough is Err("restricted: unknown event kind"),
so without an arm the relay rejects the publish outright -- this is why the
pre-existing kind 43002 KIND_JOB_ACCEPTED has never been publishable, and
why reusing it was rejected in favour of a fresh kind. requires_h_channel_scope
is the gate that routes the write through check_channel_membership; omitting
it would let a non-member inject receipts into any channel.
The kind is deliberately not p-gated: a receipt is channel-visible like the
eyes reaction it accompanies, so co-members and sibling agents can see that a
mention was received. Because the relay cannot verify that a signing key
belongs to an agent, clients must ignore receipts whose author is not a
pubkey the acknowledged message actually mentioned; NIP-MR.md states this as
a requirement.
44102 also joins the channel live-subscription kind set so clients receive
receipts, and the relay metrics bucket so ack volume is not lumped in with
unregistered traffic.
Spec: docs/nips/NIP-MR.md. Named MR (Mention Receipt) rather than MA, which
sat one transposition away from the existing NIP-AM.md in the same directory.
Signed-off-by: Michael Feth <michael@jira-flow.com>
Stacked on the kind-44102 definition.
The harness had exactly one immediate signal that a mention was received: a
fire-and-forget eyes reaction with a 500ms timeout whose failures are
swallowed at debug!. Its own docs call it cosmetic. That makes it unusable as
something a sender can rely on -- on a slow relay a perfectly healthy pickup
is indistinguishable from an agent that is not running at all.
Worse, three paths produced no signal whatsoever, and they are exactly the
paths where the harness knowingly decided not to act:
- the inbound author gate drops the event because the author is outside
respond_to, which defaults to owner-only, so any co-worker mentioning the
agent is dropped here. This is the most likely cause of a dead-ended
mention in practice and it is invisible in every direction.
- no rule matched, or a filter expression failed closed.
- DedupMode::Drop discarded the event because the channel is already in
flight. That path posts no eyes reaction and fires no steer, making it
the most completely silent outcome in the harness.
Publish a kind 44102 receipt at each decision point, carrying accepted or
declined plus a reason slug. A declined receipt is not a failure notice; it
is the agent saying "I received this and I am not going to answer, here is
why", which is what lets the sender act -- ask the owner to widen respond_to
-- instead of waiting on nothing.
Gated on event_mentions(): a receipt fires only for events that actually
p-tag this agent, never for the agent's own output, and never for kind 44102
itself. That last guard matters: a receipt p-tags the author it answers, so
a receipt is itself a mention of that agent, and two sibling agents on
wildcard-kind subscriptions would otherwise acknowledge each other's
acknowledgements indefinitely -- each round a stored event and a model turn,
no human involved, no terminating condition. A test asserts the p-tag
property that makes the loop possible so the guard cannot be quietly dropped.
The mention predicate is extracted from match_event's own require_mention
check rather than rewritten, so an agent acknowledges exactly the events a
require_mention rule treats as mentions; the existing
test_match_event_require_mention covers the refactor.
Publishing is spawned, never awaited, so a receipt cannot add latency to
dispatch or hold the event loop behind a slow relay. It gets a 5s timeout
rather than the reaction's 500ms and warns rather than debugs on failure: a
dropped receipt recreates the dead-end it exists to prevent.
Event construction is split into build_mention_ack_event so the tag shape --
which the desktop matches on -- is testable without a relay.
Note that accepted marks the start of a turn, not its completion: an agent
that accepts and then crashes or hangs publishes no further receipt. That
limitation is recorded in NIP-MR.md and is left to a follow-up.
Verified: this adds 7 tests, all passing. cargo test -p buzz-acp --lib is
752 passed / 24 failed at origin/main and 767 passed / 16 failed here (776
and 783 total respectively -- the 7 new tests account for the difference).
The failures are a flaky subprocess-and-timing set in acp::tests and
pool::tests on this Windows box; the count varies run to run, they reproduce
against a pristine origin/main checkout, and this commit touches none of them.
Signed-off-by: Michael Feth <michael@jira-flow.com>
Stacked on the harness receipt publisher. The harness now publishes a receipt for every mention it sees, which covers every case where an agent is running. It cannot cover the case where nothing is running: the relay fans out without tracking delivery, so if the agent is not connected and subscribed at the instant of fan-out the mention reaches nobody and no receipt is ever published by anyone. Silence is the only observable, and only the sending client is in a position to notice it. Track every agent mention on send, resolve it against incoming kind 44102 receipts, and after 30s report whatever never answered. The message then carries an inline footer naming what happened -- "Ada is not accepting messages from you", "Ada never picked this up, it may be offline" -- instead of sitting there looking delivered. Details worth calling out: Receipts are consumed in useLiveChannelUpdates, which subscribes across every joined channel, not in the per-channel subscription -- that one covers only the channel currently open and is torn down on switch, so consuming there would discard any receipt arriving after the user navigates away and then claim, 30s later, that the agent never picked the message up. Directly above the reply it did send. Registration reads the sent event's own p tags rather than the composer's explicit mentions. Those are the tags an agent actually matches on, and in a DM they include every participant even when the text contains no @ -- so explicit mentions alone would leave DM prompts untracked, which is the case with the strictest gate and the most likely to dead-end. Only agent pubkeys are tracked, via the known-agent set. Mentioning a colleague must never produce "they did not respond within 30 seconds". Registered in onSuccess rather than onMutate because a receipt references the real event id the agent saw, and the optimistic id is local-only. A receipt resolves an entry only when its author is a pubkey the message actually mentioned. Any member can publish a well-formed 44102 -- the relay cannot check agent-ness -- so without that check a third party could suppress someone else's warning. Both pubkeys are normalised, or the check fails silently and genuine receipts look forged. A reply clears tracking outright, on any referenced e tag rather than only the thread parent, because the harness anchors its reply to the thread root. A partial accept suppresses the warning for silent siblings: if a team mention reaches four agents and one takes it, the mention is answered and warning about the other three would be actively misleading. The 30s window is deliberately generous. A managed agent may still be booting when the mention is sent -- the desktop launches it as part of the send flow -- and a premature "nobody picked this up" on an agent that was merely starting is worse than a late one. Rendered through MessageTimeline's messageFooters slot, which existed fully wired with no caller. That keeps the notice clear of MessageRow's hand-written memo comparator, where a new prop would have been silently ignored (AGENTS.md gotcha 7). Store snapshots are content-cached per channel for the same reason: a getter that rebuilds its object or array would re-render forever through useSyncExternalStore. resetPendingMentionAckStore is wired into resetCommunityState per the community-switching contract, and deliberately does not clear its listener set, which would orphan any subscriber outliving the switch. Known limitation, stated in NIP-MR.md: accepted marks the start of a turn, not its completion. An agent that accepts and then crashes or hangs publishes no further receipt, so this does not warn about it. Nothing regresses versus today, but reviewers should not read accepted as a completion guarantee. Verified: 4971 desktop tests pass (17 in the new store), tsc clean, biome clean, check:px-text clean. Signed-off-by: Michael Feth <michael@jira-flow.com>
mfethe1
left a comment
There was a problem hiding this comment.
First-pass review — Ernie (Tier 4). Full diff read at head 76046915 vs base f956e6f (15 files, 1640-line diff + docs/nips/NIP-MR.md). No reviews on file prior; this is first-pass, not a gate ruling — verifier/re-review sits with Winnie (mention-hygiene invariant).
Verdict: technically sound, no correctness blockers. Each "Review notes" claim checked out in the diff:
- Kind + two gates —
44102registered inkind.rs(ALL_KINDS, status/reason consts) andevent.rslabel range44100..=44102.required_scope_for_kind → MessagesWriteandrequires_h_channel_scope → trueare correctly split (the exhaustive-match fallthrough and the membership-routing gate are handled distinctly); the ingest test asserts all four invariants (scope, h-scope, not global-only, not p-gated). - Not p-gated — confirmed. The compensating "client verifies author ∈ mentioned pubkeys" rule is carried in
pendingMentionAckStore.applyMentionAck(both sides normalized) and documented in NIP-MR.md. - Loop guard —
ack_mentionreturns early on kind 44102 and on self-author; the pool test asserts the p-tag property that makes the infinite-ack loop possible. Good. - All-channels consumption — acks are handled in
useLiveChannelUpdates, not the per-channel sub (which is torn down on switch), and applied idempotently. Matches the channel-switch concern from the body.
Findings (nothing blocking):
-
[P3 · compliance — fix before merge] All three commits (
902b8e53,e219f497,76046915) carrySigned-off-by: Michael Fethonly — no humanCo-authored-by. These read as agent-authored (committed undermfethe1per the workspace git-identity convention, commit/PR text is agent-drafted), so theCo-authored-by: Michael Feth <michael@jira-flow.com>trailer is required, not redundant. Add it via rebase/rewrite, ordered beforeSigned-off-by. (Escape hatch: if Michael confirms he hand-wrote these commits himself, the trailer is moot.) -
[FYI · no action]
no-matching-ruleslug is overloaded — it covers both "no rule matched" and "filter expression failed closed". Documented in the body and still actionable indescribeReasoncopy. Flagging only so the overloading reads as conscious, not accidental.
Evidence boundary: I reviewed the diff and metadata only; I did not run the Rust/desktop suites locally at 76046915. Body-reported Rust unit is 767/16 (the 16 failures reproducible pre-existing at origin/main f956e6f); desktop 4971/0 with one useDocumentVisible flake isolated. Postgres+Redis integration and desktop e2e are unrun and need CI. Domain re-review sits with Winnie.
What
Tag an agent and nothing happens. Was it picked up and still thinking, is the agent not running, or has it decided not to talk to you? Today all three look identical, because all three are silence — so the message just dead-ends.
This adds NIP-MR, an agent-published receipt (kind
44102), plus a client-side watchdog for the one case no receipt can cover.Why it dead-ends today
The relay fans out but does not track delivery.
dispatch_persistent_event_innerdoes four things — Redis publish, subscription fan-out, audit enqueue, workflow trigger — and none of them is agent-aware. If the agent's socket is not connected and subscribed at the instant of fan-out, the event is stored for pull queries and pushed to nobody. The ACP harness subscribes withsince = nowand no replay, so the missed window is never recovered.The harness also has its own reasons to stay quiet, and they turn out to be the common ones:
respond_todefaults toowner-only, so a co-worker mentioning the agent is dropped at adebug!evalexprfilter failed closed--dedup=dropthe event is discarded — no 👀, no steerThe first row is the single most likely cause in practice, and it is invisible in every direction — including to the agent's owner.
There is already a 👀 reaction on pickup, but it is explicitly cosmetic: a 500 ms timeout with failures swallowed at
debug!. On a slow relay a healthy pickup is indistinguishable from a dead one, and it is absent entirely on all the decline paths. It cannot be the signal anyone relies on.What this does
A receipt for every mention, including the declines. The harness publishes
44102at the moment it decides what to do —acceptedwhen a turn is coming,declinedplus a reason slug when it knowingly will not act. The declined half is the load-bearing one: every silent path above is a case where the harness made a decision and told nobody. Now it says so, andsender-not-allowedis something the sender can act on ("ask the owner to widenrespond_to") rather than wait on.A client watchdog for "nothing is running." No process means no receipt from anyone, ever — absence is the only signal, and only the sender can observe it. The desktop tracks its own agent mentions and, after 30 s, surfaces whatever never answered as an inline footer on the message.
Review notes
Three commits, one per layer — reviewable commit-by-commit:
feat(core)— kind + both relay gates + specfeat(acp)— the harness publisherfeat(desktop)— client tracking and the footer(Not opened as a stacked PR: the base branches would have to exist in
block/buzz, and these are pushed from a fork.)A few decisions worth a reviewer's attention:
43002.KIND_JOB_ACCEPTEDalready exists and the desktop fully handles it — but as a visible timeline row, a "Job accepted" feed headline, and a notification sound. Reusing it would mean gutting four working handlers of the 43001→43002 job protocol to stop a lightweight receipt from shouting. It is also un-publishable today (absent fromrequired_scope_for_kind, so the relay rejects it), which is worth knowing independently.required_scope_for_kindis exhaustive and falls through toErr("restricted: unknown event kind").requires_h_channel_scopeis the separate gate that routes the write throughcheck_channel_membership— without it a non-member could inject receipts into any channel.p-tags the author it answers, so by the harness's own "someone tagged me" predicate a receipt is a mention. Without a guard, two sibling agents on wildcard-kind subscriptions would acknowledge each other's acknowledgements forever — a stored event and a model turn per round, no human involved, no terminating condition. Guarded, with a test asserting the p-tag property that makes the loop possible.ptags. In a DM every participant is p-tagged, so the harness acks every DM message; registering from explicit@-mentions alone would leave DM prompts — the strictest gate, most likely to dead-end — untracked.Known limitation
acceptedmarks the start of a turn, not its completion. An agent that accepts and then crashes, hangs (up to the 2 h wall-clock cap), or has its work superseded publishes no further receipt, so this does not warn about that. Nothing regresses versus today, but please don't readacceptedas a delivery guarantee. A terminaldroppedreceipt plus a completion deadline is the natural follow-up, and is recorded in the spec.Verification
Windows (MSVC) host.
cargo fmt --all -- --checkcargo clippy -p buzz-core -p buzz-relay -p buzz-acp --libcargo test -p buzz-relay --lib(new kind test)cargo test -p buzz-acp --libpnpm testtsc --noEmitbiome checkpnpm check:px-textOn the
buzz-acpfailures: pre-existing and host-shaped, not from this diff.origin/mainatf956e6fgives 752 passed / 24 failed on the same box with none of this branch's code — verified by running the suite against a pristine checkout. Totals reconcile exactly: 776 at base, 783 here, the 7 new tests accounting for the difference. They are subprocess/timing tests inacp::testsandpool::tests; the failure count varies run to run (16–24), which is what flaky timing looks like. This branch touches none of them.One full desktop run also flaked on
useDocumentVisible.test.mjs("focused polling pauses on blur"), a timing-sensitive test in a file this branch does not touch — 5/5 in isolation, and the re-run was 4971/4971.Integration suites (Postgres + Redis) and desktop e2e were not run locally; CI covers those.
Provenance
The design was checked against the reverted
harness decline gatework — that was a routing gate that decided whether to handle an event and failed closed on broadcast; this only publishes a receipt about a decision the harness already made independently, and changes no dispatch behaviour. It is also distinct from #6090, which signals relay-side fan-out drops under backpressure.Findings from an adversarial review pass are folded into the three commits, including one blocker (receipts being dropped on channel switch) caught before this was opened.