Skip to content

feat(buzz): add reaction lifecycle and presence publishing - #99451

Open
KostaGorod wants to merge 7 commits into
NousResearch:mainfrom
KostaGorod:buzz-reaction-lifecycle-upstream
Open

KostaGorod wants to merge 7 commits into
NousResearch:mainfrom
KostaGorod:buzz-reaction-lifecycle-upstream

Conversation

@KostaGorod

@KostaGorod KostaGorod commented Aug 31, 2026

Copy link
Copy Markdown

Follow-up hardening

Exact head d344c5865111a4606c8661b6ff96a30ef4046bae resolves the presence-offline symptom reported during live dogfooding, plus all three non-blocking notes from the automated review:

  • Bound abandoned reaction-lifecycle state with one shared sweeper while protecting active transitions.
  • Simplify the redundant best-effort presence-shutdown exception handler.
  • Keep presence refreshes inside an explicit conservative relay-expiry margin with deterministic cadence tests.

Summary

  • Add the Buzz reaction lifecycle: 👀 → 🧠 → ✅/❌. Buzz has no typing-indicator API, so reactions are the only visible progress surface; this gives the user the same seen → working → done feedback the other messaging platforms have.
  • Serialize reaction transitions per inbound message and prevent stacked lifecycle reactions (remove-before-add keeps at most one lifecycle reaction on the relay; if removal fails, the old reaction stays authoritative instead of stacking).
  • Publish Buzz presence on the adapter's persistent NIP-42-authenticated WebSocket: an online kind-20001 event immediately after authentication, refreshed on a 60 s heartbeat (well inside the relay's 180 s presence lease), and a best-effort offline on graceful shutdown before the socket closes.
  • Reaction and presence failures are best-effort: they never block message processing or gateway shutdown.
  • Config gate: gateway.platforms.buzz.extra.reactions (default true), plus behavioral tests and user documentation (website/docs/user-guide/messaging/buzz.md).

Root cause of the offline-agent symptom

Buzz presence is a relay-side lease. Two bugs combined to make connected agents appear offline:

  1. The adapter never published presence from its persistent connection, so agents were invisible from the start (fixed in the earlier head of this PR by delegating to buzz users set-presence).
  2. That CLI delegation turned out to be self-defeating: every buzz users set-presence invocation opens its own WebSocket, publishes one event, and closes. The relay deregisters the identity when that socket closes, so the freshly-published lease was dropped again moments later. A live relay probe showed a fresh subscriber receiving zero presence records for all five multiplexed profiles — the Desktop's exact "all agents offline" view — while the gateway itself stayed healthy.

The fix publishes kind-20001 online heartbeats on the same long-lived authenticated socket that carries the inbound subscription. The relay keeps the lease alive for as long as that socket stays registered; the 60 s heartbeat only refreshes it. Presence events are built and Schnorr-signed in-process (plugins/platforms/buzz/nostr_auth.py, dependency-free, same event shape the Buzz SDK emits), sent through a per-connection send lock shared with subscription traffic, and re-armed as exactly one heartbeat task per reconnect.

Live relay evidence (old vs fixed)

Read-only probes against the local relay; the AUTH event is client authentication, nothing is published by the probe.

Old code (installed tree 8e4fad97, CLI loop):

probe A (75 s live fan-out): exactly ONE online event per profile (age 0 s at subscribe);
probe B (stored state, 3 rounds × 70 s): all 5 profiles ABSENT — a fresh subscriber
gets EOSE with zero presence records → rendered "all agents offline"

Fixed code (deployed tree 4bb6c0b8, byte-identical patch, 140 s live fan-out):

t+  9.4–11.8 s  all 5 profiles online
t+ 69.5–72.0 s  all 5 profiles online
t+129.7–132.1 s all 5 profiles online
online_event_counts = {"careerops": 3, "coder": 3, "default": 3, "productmanager": 3, "reviewer": 3}

Recurring same-socket heartbeats at ~60 s cadence, each well inside the 180 s lease.

Verification

RED at the pre-fix head (96dd89ac + new tests only): 12 failed / 205 passed across the Buzz websocket/adapter suites — including the duplicate-heartbeat guard test_presence_heartbeat_reconnect_replaces_task_exactly_once ("first connection never heartbeated") and the event-builder contract (build_presence_event). GREEN at ce59b609, re-verified GREEN at the reconciled head d344c5865111a4606c8661b6ff96a30ef4046bae (merge of upstream main @ 63279301bc, see Reconciliation below):

Exact-head test evidence (d344c58)

All via scripts/run_tests.sh (never direct pytest):

  • tests/gateway/test_buzz_websocket.py + tests/gateway/test_buzz_adapter.py217/217 passed (same-socket presence, send-lock serialization, single-heartbeat-per-connection, offline-before-close, no _run_cli presence path, event shape/signature).
  • tests/gateway/test_buzz_reaction_lifecycle.py — 37 passed; test_buzz_authz.py — 11; test_buzz_forum_kinds.py — 6; test_buzz_mention_resolution.py — 19; test_buzz_progress_thread_routing.py — 2; test_buzz_thread_topology.py — 17 → 92/92 passed across all eight Buzz test files (309 tests total).
  • Ruff clean on changed files; py_compile OK; git diff --check clean.
CI evidence (frozen head ce59b60 — superseded by d344c58, pending re-run)

Upstream fork-PR workflows for this PR are gated behind first-time-contributor workflow approval (action_required), so exact-head CI was executed as a same-diff run on the fork: KostaGorod/hermes-agent PR #3 (head ce59b6091652a23d96400ed54f8b156e5c10b28a, base 375ce8eee51b9d76714cb6fd1f200c4c9ef83c4a — identical base/head to this PR).

  • Docker Build, Test, and Publish — success (run 33685954714).
  • CI (run 33685956366) — 14/17 jobs green, including ruff (blocking), ty diff, Windows footguns (blocking), Python e2e, macOS-only tests, supply-chain, OSV, docs, attribution, case-collision, common-ancestor. The 3 non-green jobs (Python tests / Run tests, Windows-only tests, Playwright E2E (Linux)) plus the queued nix job target larger-runner labels (ubuntu-latest-96-core, ubuntu-latest-32-core, windows-latest-32-core) that the fork cannot execute — they will never start there. Verified against the workflow definitions at this head. Local exact-head suites (254/254 + 69/69 via scripts/run_tests.sh) stand in for the full-suite lane.
  • Independent frozen-SHA review: PASS (pullrequestreview-5098189286) — 254/254 + 69/69 re-verified, RED receipts checked, live relay probe re-run (PROBE_PASS).

Open gaps

  • Upstream main advanced past the recorded base; this PR was mergeStateStatus: DIRTY against it Resolved at d344c58651 — see Reconciliation below. GitHub now reports mergeable: MERGEABLE.
  • Fresh exact-head review at d344c58651 is still needed (the prior PASS is commit-bound to ce59b609).
  • The two original feature commits were developed on top of fix(buzz): secondary multiplex profiles must not inherit the default profile's env #98748 (Buzz multiplex env scoping); this PR is the same patch on current main and does not depend on that change.

Reconciliation (2026-09-03)

Upstream main advanced 375ce8eee5 → 63279301bc; the media-cache offload sweep (568b16122d) migrated buzz's media caching to the async seam (cache_media_bytes_async), colliding with this PR's import block in plugins/platforms/buzz/adapter.py. Reconciled without rewriting published history via a normal merge commit:

  • d344c58651 = merge of upstream/main (63279301bc) into the published branch (fast-forward push ce59b60916..d344c58651, no force).
  • Sole conflict: the adapter import block — kept this PR's ProcessingOutcome (reaction lifecycle) alongside upstream's cache_media_bytes_async; the sync cache_media_bytes import is dropped because the sweep already migrated all call sites (adapter.py:2827/3347 use await cache_media_bytes_async(...)). Diff vs upstream/main is purely additive (presence + reactions); upstream's reaction_only path and profile-scoped config reads are untouched.
  • Presence invariants re-verified at the merged head: same-socket kind-20001 via _send_ws send-lock, executor-signed events, single heartbeat per connection with reconnect replace, offline-before-close, NIP-01-correct event shape.
  • All eight Buzz suites + Ruff + py_compile + git diff --check green at d344c58651 (receipts above). git merge-tree upstream/main HEAD confirms a clean merge.
  • No competing presence work in 375ce8eee5..63279301bc (supersession scan: only the media-cache sweep and an off-loop read tweak touched buzz; upstream has no kind-20001/presence code).
  • Fork CI ceiling unchanged: the fork cannot run the 3 larger-runner-label jobs (plus Docker publish lane) — upstream workflows remain action_required until maintainer approval; local exact-head suites stand in.

History

Head Change
ce59b60916 Presence hardening (reviewer PASS pullrequestreview-5098189286; frozen)
d344c58651 Merge upstream/main 63279301bc — media-cache conflict resolved; fresh verification above

Closes #99611

Comparison with related implementations

Reaction lifecycle

For the reaction-lifecycle behavior in #99611, this implementation is more complete and concurrency-correct than the related open alternatives:

Behavior This PR #97610 #79590 / current seen acknowledgment
Receipt 👀 Yes, before dispatch Yes Yes
Working 🧠 Yes No No
Terminal ✅/❌ from processing outcome Yes No No
Expected-cancellation cleanup Yes No No
Multiple in-flight messages in one chat Keyed by (chat_id, message_id) One message_id per chat_id; later input overwrites earlier state No lifecycle state
Serialized remove-before-add transitions Yes No No
Failed removal suppresses replacement Yes No No
Config gate extra.reactions No #79590 gates the existing 👀 only
Focused behavioral tests Real flow, ordering, concurrency, failures, cancellation, shutdown No test files in the PR Existing acknowledgment only

The important correctness difference is message identity. #97610 stores one pending 👀 target per chat, so two messages arriving in the same channel before a reply can overwrite the first target and strand or remove the wrong reaction. This PR maintains an independent state machine per inbound message and completes it from the gateway's actual ProcessingOutcome, rather than treating an outbound send as proof of successful processing.

This comparison is scoped to reactions. #97610 also proposes typing indicators and NIP-AO observer telemetry, which are separate capabilities and are not superseded by this PR.

Presence

The presence half keeps a deliberately narrow shape: one dependency-free event builder + signer (nostr_auth.py), one send lock per connection, one heartbeat task per connection.

  • It publishes protocol-correct self-authored kind-20001 events over the adapter's existing NIP-42-authenticated persistent WebSocket — the same socket the relay uses to decide whether the identity is still connected, so the lease cannot outlive (or lag behind) the connection it represents.
  • Its heartbeat stays within an explicit conservative margin of the documented 180-second relay TTL.
  • Reconnect cancels and settles the old heartbeat before the old socket exits and arms exactly one for the new connection; graceful shutdown publishes offline on the live socket before closure, bounded by a timeout.
  • The complete path was verified live: five multiplexed agents publishing recurring ~60 s same-socket heartbeats, observed continuously across three heartbeat windows.

@alt-glitch alt-glitch added type/feature New feature or request P3 Low — cosmetic, nice to have comp/plugins Plugin system and bundled plugins labels Aug 31, 2026
Signed-off-by: Kosta Gorod <35299380+KostaGorod@users.noreply.github.com>
Buzz presence records expire unless republished — bots with live
websockets still showed offline because no code path ever published
presence (round-trip proven: manual 'buzz users set-presence online'
made the record appear; it vanished within ~13 min without a refresh).

connect() now publishes online and refreshes it every 60s;
disconnect() cancels the refresh first, then publishes offline with a
5s cap so a wedged buzz-cli can never stall shutdown. All presence
calls are best-effort and fail quiet.

Signed-off-by: Kosta Gorod <35299380+KostaGorod@users.noreply.github.com>
The reactions toggle lives in config.yaml at gateway.platforms.buzz.extra.reactions and defaults to true, per the repo rule that behavioral settings are config.yaml-only. The website docs already cover this setting.
@KostaGorod
KostaGorod force-pushed the buzz-reaction-lifecycle-upstream branch from 33f29e2 to f6b770b Compare September 1, 2026 11:16
@Enough1122

Copy link
Copy Markdown
Contributor

AI code review — automated review for reference; please use your judgment.

The lifecycle coordinator is well-engineered: transitions are serialized per message behind a tail-task chain, replacement is remove-gated so reactions never stack on the relay (plugins/platforms/buzz/adapter.py:1238-1266), terminal cleanup pops state in a finally only after later enqueues are rejected, and shutdown cancels in-flight tasks and drains them (plugins/platforms/buzz/adapter.py:1206-1213). The eligibility gate correctly prevents unauthorized senders from earning a 👀 before the central authorization check runs, and the config gate is parsed defensively with a logged fallback. The 572-line test file covers ordering, failure injection on both add and remove, cancellation semantics, dispatch wiring, and disconnect hygiene.

Non-blocking: _reaction_lifecycle entries leak until disconnect if the terminal hook never fires (e.g. processing hangs); bounded in practice but no TTL sweep. Non-blocking: except (asyncio.TimeoutError, TimeoutError, Exception) in _stop_presence (plugins/platforms/buzz/adapter.py:117) — the Exception member makes the tuple redundant. Non-blocking: the presence loop's first refresh waits a full interval after the connect-time publish; if the relay TTL is under ~60s the record could lapse briefly — presumably fine given _PRESENCE_INTERVAL is documented as comfortably before expiry.

Verdict: LGTM

Expire abandoned lifecycle state with one shared sweeper, preserve active transitions, and document the cleanup contract. Clamp presence refreshes to a conservative relay-expiry margin and simplify best-effort shutdown handling.
@KostaGorod

Copy link
Copy Markdown
Author

Independent review — Hermes reviewer agent (Kanban t_13496024, run #5). Commit-bound verdict for exact head 96dd89acb122db4a130d2a9afed4b8e47b972804, base 375ce8eee51b9d76714cb6fd1f200c4c9ef83c4a. Not a GitHub review submission; the PR owner's token is used only to publish this summary. No approval, no merge.

Verdict: PASS at 96dd89acb122db4a130d2a9afed4b8e47b972804 (reviewer-side).

Gate: worktree clean; HEAD == 96dd89ac…; merge-base == base 375ce8eee5; live PR head/base/changed-files match the handoff inventory exactly (4 files). All three non-blocking findings from #99451 (comment) are addressed in the single follow-up commit.

A — Abandoned lifecycle state (plugins/platforms/buzz/adapter.py:1346-1389): one shared sweeper task, started lazily on first entry and self-terminating when the map empties (never a task per message). Sweep is loop-atomic (no awaits between the stale scan and the pops); entries younger than the TTL — including valid in-flight transitions — are never touched; wedged tails are cancelled at the TTL and non-terminal state dropped (the tail's finally only pops terminal entries, so cleanup dropping non-terminal state itself is correct). Disconnect (adapter.py:1256-1272) drains in-flight tasks, then cancels/awaits the sweeper before clearing state. Per-message serialization, remove-gated replacement, terminal ordering, and best-effort failure semantics all re-verified unchanged.

B — Redundant tuple (adapter.py:1081-1085): except (asyncio.TimeoutError, TimeoutError, Exception)except Exception. Equivalent on every supported Python (timeouts are Exception subclasses); CancelledError still propagates — covered by test_stop_presence_swallows_timeouts_but_not_cancellation.

C — Presence margin (adapter.py:247-267, 1046-1072): authoritative 180 s relay expiry assumed conservatively, 120 s margin, cadence clamped by the pure helper _presence_refresh_interval so every refresh (first and repeat) lands ≥ margin before lapse; degenerate margin ≥ expiry falls back to half-expiry. Connect-time online and graceful offline publications preserved. Deterministic cadence tests, no long sleeps.

Independent verification run by the reviewer at this exact head:

  • tests/gateway/test_buzz_adapter.py + tests/gateway/test_buzz_reaction_lifecycle.py: 228/228 passed.
  • Remaining Buzz suites (websocket, authz, forum_kinds, mention_resolution, progress_thread_routing, thread_topology, plugin requirement gate): 79/79 passed.
  • RED reproduced on pre-fix parent 6f084d2857: 4 TestAbandonedLifecycleCleanup failures + 2 presence-cadence failures — matches the implementer's red-first evidence.
  • Full tests/gateway/ lane: 2 failures in tests/gateway/test_wecom_callback.py (AttributeError: 'NoneType' object has no attribute 'fromstring') — reproduced identically at upstream base 375ce8eee5 (missing defusedxml in this environment). Baseline-only, unrelated to this PR.
  • Ruff clean on all changed Python files; git diff --check clean; diff audited — no debug output, no secrets, no stray files. ## Infographic section present with one resolving inline PNG (HTTP 200, image/png).

CI (exact head, reported separately — external gate, not a code verdict): runs 33552516713 (CI), 33552515542 (Docker), 33552515545 (Nix) are terminal action_required pending first-time fork-PR workflow approval by a maintainer. They must run green before merge; nothing here assesses that outcome in advance.

Non-blocking note: there is a one-event-loop-slice window where a TTL-expired terminal tail cancelled by the sweeper pops a key that a duplicate _reaction_begin for the same (chat_id, message_id) recreated between the sweep's pop and the cancelled task's finally. Bounded consequence: that turn's chain stops early (stale emoji — the documented best-effort contract); no stacking, no leak. Not worth blocking given message-id uniqueness in practice.

…cket

The CLI-based presence loop (`buzz-cli users set-presence`) can never
keep Hermes agents visible in Buzz: each invocation runs the CLI as a
fresh process, publishes a kind-20001 event, and closes its socket —
and the relay drops the presence lease with the socket. A client that
subscribes after that sees no presence records at all (verified live
against the relay: read-only REQ for all five profile pubkeys returned
ABSENT across three rounds 70s apart, while a connected subscriber saw
only the single connect-time publish).

Presence now rides the adapter's own long-lived authenticated
WebSocket: a signed kind-20001 `online` event published inline right
after the NIP-42 handshake — before the connection is reported ready —
then refreshed by a heartbeat task at the expiry-aware cadence (every
publish lands at least the configured margin before the relay's 180s
TTL lapses). `offline` is published only on graceful shutdown, on the
still-open socket, before the WebSocket task is cancelled; a transient
disconnect never flaps offline because the relay clears the record
itself and the reconnect's first publish restores it. Signing runs in
the default executor (pure-Python schnorr is CPU-bound, ~50ms/event)
and every frame shares a send lock so the inbound pump is never
stalled.

Two shutdown hazards surfaced while hardening this and are fixed here
as well:

- Cancelling during the initial publish (or inside `wait_for` on the
  NIP-42 handshake) can be silently consumed on Python 3.11 and the
  loop resumes with a pending cancel that is never observed as
  terminal — shutdown then wedges with orphan heartbeat/discovery
  tasks. The loop now re-arms an observed-but-consumed cancellation
  before spawning any child task, and the reconnect loop's generic
  `except Exception` re-raises when the task is cancelling.
- The read-loop `finally` retired the heartbeat but left a window
  where a duplicate heartbeat task could run per connection; the
  reconnect test now pins exactly one heartbeat per connection and
  zero after the loop exits (the duplicate-heartbeat red test at the
  PR base is the first failing test).

Kind-20001 events are rejected by the relay's HTTP bridge, so
presence publishing cannot regress to the CLI path; the adapter
tests pin that presence never spawns a CLI process.

TDD: all new behavior covered by failing tests first (8 websocket +
4 adapter tests red at 96dd89a, green after the fix).

Live verification: read-only relay probes recorded on kanban card
t_5eabc763; post-deploy observation pending user confirmation.
@KostaGorod

Copy link
Copy Markdown
Author

Presence root cause + fix — ce59b609 (what we found, what changed)

Found: the CLI presence loop (buzz-cli users set-presence) can never keep an agent visible in Buzz. Each invocation is a fresh process that publishes one kind-20001 event and closes its socket — and the relay drops the presence lease with the socket. Verified live against our relay: a read-only REQ for all five agent pubkeys returned ABSENT across three probes 70 s apart, while a connected subscriber only ever saw the single connect-time publish.

Fixed:

  • Presence now rides the adapter's persistent authenticated WebSocket: a signed kind-20001 online event is published inline right after the NIP-42 handshake (before the connection is reported ready), refreshed by a heartbeat task ahead of the relay's 180 s TTL. offline is published only on graceful shutdown on the still-open socket, so transient disconnects never flap offline — the relay clears the record itself and the reconnect's first publish restores it.
  • Two shutdown hazards fixed alongside: an observed-but-consumed CancelledError (Python 3.11) could wedge the reconnect loop with orphan heartbeat/discovery tasks; and a window allowed a duplicate heartbeat task per connection. The reconnect test now pins exactly one heartbeat per connection and zero after exit.
  • The relay's HTTP bridge rejects kind-20001, so presence can't silently regress to the CLI path; adapter tests pin that presence never spawns a CLI process.

Verification: 12 new tests (8 websocket + 4 adapter) written red-first at 96dd89ac, green after the fix. Live relay probe logs recorded on our tracking card; post-deploy presence observation in progress and will be reported here.

@KostaGorod KostaGorod left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Frozen-SHA independent review — PASS

Head: ce59b6091652a23d96400ed54f8b156e5c10b28a · Base: 375ce8eee51b9d76714cb6fd1f200c4c9ef83c4a · Reviewed by the reviewer agent profile (read-only on this tree; verdict via COMMENT because the acting account is the PR author).

Freshness gates — all pass

  • Workspace clean (git status --porcelain empty); HEAD = origin/buzz-reaction-lifecycle-upstream = live PR head = ce59b609; merge-base with the recorded base is the base itself.
  • Live PR files = local diff inventory: 6 files (+1724/−19); the fix commit alone touches 5 (+600/−86), scoped to presence + its tests/docs. Reaction-lifecycle behavior unchanged by the fix commit.

Independently re-verified at this head

  • scripts/run_tests.sh tests/gateway/test_buzz_websocket.py tests/gateway/test_buzz_adapter.py tests/gateway/test_buzz_reaction_lifecycle.py254/254 passed; six sibling Buzz suites → 69/69 passed. Ruff clean on all five changed Python files, py_compile OK, git diff --check clean.
  • RED receipts re-checked (/tmp/red_base_result.txt, /tmp/red_reconnect_result.txt): at 96dd89ac + new tests, build_presence_event AttributeError and the duplicate-heartbeat guard failure ("first connection never heartbeated") fail for the expected mechanism reason — TDD discipline confirmed.
  • Live relay probe re-run (read-only subscriber, 140 s): all five profiles published recurring same-socket online heartbeats at ~60 s cadence (2 events each across two windows) — PROBE_PASS, independently reproducing the deployed-tree evidence.

Code findings on the presence lifecycle and shared-socket boundaries

  • Same-socket guarantee: presence publishes only via _send_ws on the connection stored in _ws_connection; every frame on the shared socket (subscription REQs, presence, discovery) goes through the per-adapter send lock. The one remaining raw send is the NIP-42 AUTH frame, sent strictly before any concurrent task exists on that connection. No users set-presence CLI path remains (pinned by tests).
  • Heartbeat lifecycle: first online publishes inline before _ws_ready.set(); heartbeat starts only after successful auth; reconnect's finally cancels and settles the old heartbeat before the old socket exits and arms exactly one for the new connection (test_presence_heartbeat_reconnect_replaces_task_exactly_once drives a real forced-close reconnect and asserts retired-heartbeat quiescence). Transient drops deliberately do not publish offline — the relay clears the lease with the socket; correct anti-flap design.
  • Orderly teardown: disconnect() stops the heartbeat and publishes bounded best-effort offline on the still-open socket before cancelling the WS task (test_graceful_offline_publish_before_socket_close asserts offline-before-__aexit__).
  • Cancellation hardening: the Python-3.11 consumed-cancellation re-arm (loop_task.cancelling() → raise) before spawning child tasks, and the except Exception re-raise when cancelling, are both real hazards with real tests (test_cancel_during_first_presence_does_not_leak_heartbeat also asserts zero stray asyncio tasks). wait_for auth-timeout path cancels and reaps the loop.
  • Event shape: build_presence_event follows NIP-01 serialization exactly (kind 20001, ["status", s] tag, bare-status content, Schnorr sig over the id); signing off-loop in the default executor.

Non-blocking notes

  1. With transport="poll" pinned, the adapter no longer publishes presence at all (the old CLI loop covered both transports). Justified: the CLI path is the defect being removed (its socket-close drops the lease), and the WS transport is the default. Not documented explicitly, but the poll fallback is already described as degraded.
  2. _stop_presence clears _ws_connection before the offline publish; a transient failure there leaves None — consistent with best-effort semantics, no correctness impact.

Residuals (disclosed, not blockers for this verdict)

  • Upstream main advanced past the recorded base; the PR is currently mergeStateStatus: DIRTY (media-cache refactor touched adapter.py — incidental overlap, no competing presence implementation). Reconciliation + fresh verification is follow-up work after this verdict; it does not affect the frozen-SHA mechanics reviewed here.
  • Upstream CI for this PR is action_required (first-time contributor approval); same-diff fork run at the identical head: Docker success, CI 14/17 green — the 3 remaining jobs + nix target larger-runner labels (ubuntu-latest-96-core, ubuntu-latest-32-core, windows-latest-32-core) the fork cannot execute, verified against the workflow definitions at this head. Local exact-head suites stand in for those lanes.

Resolve adapter.py import conflict: keep our ProcessingOutcome (reaction
lifecycle) alongside upstream's cache_media_bytes_async from the media-cache
offload sweep (568b161) — its call sites already migrated to the async
seam, so the sync cache_media_bytes import is dropped.

Presence fix (ce59b60) invariants preserved: same-socket kind-20001
publishing via _send_ws send-lock, executor-signed events, single
heartbeat-per-connection with reconnect replace, offline-before-close.
@KostaGorod

Copy link
Copy Markdown
Author

Found→Fixed→Verified @ d344c58

Found: upstream main advanced 375ce8e6327930; the media-cache offload sweep (568b161) collided with this PR's adapter.py import block → PR was mergeStateStatus: DIRTY.

Fixed: normal merge commit of upstream/main into the published branch (fast-forward push ce59b60..d344c58, no force, history preserved). Sole conflict = import block: kept our ProcessingOutcome (reaction lifecycle) alongside upstream's cache_media_bytes_async — the sweep had already migrated all call sites, so the sync cache_media_bytes import is dropped. Presence fix invariants intact: same-socket kind-20001 via _send_ws send-lock, executor-signed events, single heartbeat per connection with reconnect replace, offline-before-close. Diff vs upstream/main is purely additive.

Verified: all 8 buzz suites via scripts/run_tests.sh — 309/309 passed, 0 failed (websocket 26, adapter 191, reaction-lifecycle 37, authz 11, forum-kinds 6, mention-resolution 19, progress-thread-routing 2, thread-topology 17). Ruff clean, py_compile OK, git diff --check clean, git merge-tree upstream/main HEAD clean. GitHub: mergeable MERGEABLE (no conflicts). Supersession scan of 375ce8e..6327930 found no competing presence work (upstream has no kind-20001 code; only the media-cache sweep + an off-loop read tweak touched buzz). Fresh exact-head review at d344c58 pending — prior PASS is commit-bound to ce59b60.

@KostaGorod KostaGorod left a comment

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fresh independent exact-head review at d344c5865111a4606c8661b6ff96a30ef4046bae (COMMENT-form: GitHub blocks verdict reviews on own PRs). Prior PASS (pullrequestreview-5098189286) was commit-bound to ce59b60916 and is superseded.

Verdict: REVIEW_PASS

1. Merge-delta review (ce59b60916..d344c58651)

  • Merge shape verified: parents ce59b60916 + 63279301bc, merge-base 375ce8eee5; no history rewritten.
  • Only buzz file touched: plugins/platforms/buzz/adapter.py, and the combined diff (--cc) shows the single conflicted hunk was the import block — resolved as claimed: ours' ProcessingOutcome + upstream's cache_media_bytes_async (568b161); sync cache_media_bytes import dropped.
  • Zero remaining sync cache_media_bytes references anywhere under plugins/platforms/buzz/; all call sites async (adapter.py:2823, 3343).
  • Strongest check — PR diff vs upstream main is byte-identical pre-merge (375ce8eee5..ce59b60916) vs post-merge (63279301bc..d344c58651) modulo the one import line, blob-index lines, and hunk-offset shifts. The merge introduced no hidden semantic change to the PR payload.
  • git merge-tree upstream/main HEAD clean (exit 0, no conflicts): future merge is trivial.

2. Presence invariants survived the merge (spot-checked live at this head)

  • Same-socket kind-20001 via _send_ws send-lock: adapter.py:1032–1050; presence and subscription frames serialize on _ws_send_lock.
  • Executor-signed events: build_presence_event runs via run_in_executor in _publish_presence — loop never stalled by the CPU-bound Schnorr sign.
  • Single heartbeat per connection with reconnect replace: spawned once at adapter.py:2317 after the inline first publish; reaped in the per-connection finally (2408–2413); graceful path via _stop_presence in disconnect().
  • Offline-before-close: disconnect() awaits _stop_presence() before _ws_task.cancel() (adapter.py:1246–1254); transient reconnect drops deliberately do not flap offline.
  • NIP-01 event shape: serialization array [0, pubkey, created_at, 20001, tags, content], id = sha256 of serialized, sig = 64-byte Schnorr hex (nostr_auth.py:186–225). Correct.

3. No semantic drift from upstream's media-cache sweep

  • Upstream's adapter change is confined to the media-cache seam (async migration + off-loop read_bytes via asyncio.to_thread, e056777/568b16122d). Presence/reaction code paths reference neither — no interaction surface.

Independent re-verification at this exact head (not trusted from the implementer)

  • All 8 buzz suites via scripts/run_tests.sh: 309/309 passed — websocket 26, adapter 191, reaction-lifecycle 37, authz 11, forum-kinds 6, mention-resolution 19, progress-thread-routing 2, thread-topology 17 (per-file counts match the claim exactly).
  • Ruff clean on changed buzz files; py_compile OK; git diff --check on the PR delta vs upstream clean (two EOF blank-line warnings in photon/CLI tests are inherited from pure upstream 375ce8eee5..63279301bc, not introduced here).
  • GitHub state at review time: head = d344c58651, OPEN, MERGEABLE. No check runs exist at this head — consistent with the stated fork-CI ceiling (14/17 + Docker); local gates are the verification evidence per the PR constraints.

This branch has not been deployed

No deployments
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

comp/plugins Plugin system and bundled plugins P3 Low — cosmetic, nice to have type/feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat(buzz): expose message processing through a reaction lifecycle

3 participants