From b5bb81e23fb4025ae6706825a8f738406931c820 Mon Sep 17 00:00:00 2001 From: Michael Feth Date: Wed, 19 Aug 2026 22:11:10 -0400 Subject: [PATCH] fix(acp): report an agent left with no live channel subscription MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The startup deafness check ran *before* the subscribe loop and keyed on the resolved channel filters, so it could only catch "no rule matched any channel". The strictly worse case went unreported: resolve N channels, then fail every single `subscribe_channel` call. Each failure was logged individually, but the aggregate condition — this agent can no longer hear anything — was never stated, and startup went on to publish `online` presence. That matters because the comment on the presence publish already states the intended contract: "Online means the harness can receive work, not merely that its socket is connected." An agent with zero live subscriptions cannot receive work, so the readiness claim was false in exactly the case an owner most needs to know about. Assess reach after the subscribe loop instead, against the subscriptions that actually went out, and distinguish the two causes — they point at different fixes (membership/rules vs. relay). Both messages now carry the subscribe mode and counts rather than being a bare string. This is deliberately diagnostic only. Zero live subscriptions is a legitimate steady state for an agent that is in no channels yet, so it must not fail startup, and it must not withhold the `online` presence that desktop callers wait on as their readiness boundary before sending a first mention. Observed in the field: 46 local harness run logs spanning 17 days contain 9 "agent will sit idle" reports, all from the resolved-zero path. The all-subscribes-failed path is by construction absent from those logs, because it produced no such line. Signed-off-by: Michael Feth --- crates/buzz-acp/src/lib.rs | 97 ++++++++++++++++++++++++++++++++++++-- 1 file changed, 94 insertions(+), 3 deletions(-) diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 1352b31cad..a9f107402f 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -90,6 +90,83 @@ async fn publish_presence( Ok(()) } +/// Why a harness finished startup with no channel subscription it can hear on. +/// +/// The two causes need different copy because they point at different fixes: +/// one is a membership/rule problem, the other a relay problem. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum ChannelReachGap { + /// No rule matched any channel, so no REQ was even attempted. + NothingResolved, + /// Channels resolved, but every REQ failed to reach the relay. + AllSubscribesFailed, +} + +/// Assess whether startup left the harness able to receive channel work. +/// +/// `resolved` counts the channel filters startup intended to subscribe; +/// `live` counts the ones whose REQ actually went out. Returns `None` for the +/// healthy case — at least one live subscription. +/// +/// Why this is measured AFTER the subscribe loop rather than before it: the +/// pre-loop check could only see `resolved`, so an agent that resolved +/// channels and then failed every single `subscribe_channel` call was deaf +/// with nothing logged about it — the failures were reported one-by-one as +/// individual channel warnings, and the aggregate condition ("this agent can +/// no longer hear anything") was never stated. That is the silent case this +/// exists to name. +/// +/// Note this is deliberately only a diagnostic. Zero live subscriptions is a +/// legitimate steady state — an agent in no channels yet — so it must not fail +/// startup, and it must not withhold the `online` presence that desktop +/// callers wait on as their readiness boundary before sending a first mention. +fn assess_channel_reach(resolved: usize, live: usize) -> Option { + if live > 0 { + None + } else if resolved == 0 { + Some(ChannelReachGap::NothingResolved) + } else { + Some(ChannelReachGap::AllSubscribesFailed) + } +} + +#[cfg(test)] +mod channel_reach_tests { + use super::{assess_channel_reach, ChannelReachGap}; + + #[test] + fn one_live_subscription_is_healthy() { + assert_eq!(assess_channel_reach(1, 1), None); + } + + #[test] + fn a_partial_failure_is_still_healthy() { + // Some channels failed, but the agent can still hear on one, so this is + // not deafness — the per-channel warnings already cover the failures. + assert_eq!(assess_channel_reach(3, 1), None); + } + + #[test] + fn nothing_resolved_is_reported() { + assert_eq!( + assess_channel_reach(0, 0), + Some(ChannelReachGap::NothingResolved) + ); + } + + #[test] + fn every_subscribe_failing_is_reported_not_silent() { + // The regression this exists for: the old check ran before the + // subscribe loop and keyed on resolved filters, so resolving three + // channels and then failing all three REQs produced no deafness + // report at all. + assert_eq!( + assess_channel_reach(3, 0), + Some(ChannelReachGap::AllSubscribesFailed) + ); + } +} + fn emit_runtime_lifecycle( observer: Option<&observer::ObserverHandle>, start_nonce: &str, @@ -2120,9 +2197,6 @@ async fn tokio_main() -> Result<()> { }; let channel_filters = config::resolve_channel_filters(&config, &channel_ids, &rules); - if channel_filters.is_empty() { - tracing::warn!("no channel subscriptions resolved — agent will sit idle"); - } let mut subscribed_channel_ids = HashSet::with_capacity(channel_filters.len()); for (channel_id, filter) in &channel_filters { if let Err(e) = relay.subscribe_channel(*channel_id, filter.clone()).await { @@ -2133,6 +2207,23 @@ async fn tokio_main() -> Result<()> { } } + // Deafness is assessed against subscriptions that actually went out, not + // against the filters we hoped to send, so that "resolved N, subscribed 0" + // is reported instead of passing silently. See `assess_channel_reach`. + match assess_channel_reach(channel_filters.len(), subscribed_channel_ids.len()) { + Some(ChannelReachGap::NothingResolved) => tracing::warn!( + subscribe_mode = ?config.subscribe_mode, + rules = rules.len(), + "no channel subscriptions resolved — agent will sit idle" + ), + Some(ChannelReachGap::AllSubscribesFailed) => tracing::warn!( + subscribe_mode = ?config.subscribe_mode, + resolved = channel_filters.len(), + "every channel subscription failed — agent is connected but will sit idle" + ), + None => {} + } + if let Some((observer, publisher, keys, agent_pubkey, owner_pubkey, owner)) = relay_observer_publisher.take() {