Skip to content

fix(buzz): keep progress heartbeats and background-delegation completions in the originating thread - #80651

Open
rbaset5 wants to merge 2 commits into
NousResearch:mainfrom
rbaset5:fix/buzz-progress-heartbeat-thread-anchor
Open

fix(buzz): keep progress heartbeats and background-delegation completions in the originating thread#80651
rbaset5 wants to merge 2 commits into
NousResearch:mainfrom
rbaset5:fix/buzz-progress-heartbeat-thread-anchor

Conversation

@rbaset5

@rbaset5 rbaset5 commented Aug 6, 2026

Copy link
Copy Markdown

Summary

Two related fixes so a Buzz conversation stays in one thread instead of fragmenting mid-run. Buzz has no channel-level thread_id — every reply is anchored per-message via a Nostr e tag — so any code path that forgets to carry the triggering message's id forward starts a brand-new, disconnected thread instead of continuing the existing one.

1. Progress heartbeat (gateway/run.py)

  • The status-callback path (_status_thread_metadata) already special-cases Buzz correctly. The progress-heartbeat path (send_progress_messages, the periodic "Working — N min" updates) had no equivalent branch — _resolve_progress_thread_id() only special-cases slack/mattermost, so for Buzz it fell through to _progress_reply_to = None unconditionally.
  • Fix: added the same Buzz branch ({"thread_id": event_message_id}) already used by the sibling status-callback wiring a few hundred lines below.

2. Background delegation completions (tools/async_delegation.py)

  • delegate_task(background=true) re-enters the chat as a synthetic message once the child finishes. gateway._inject_watch_notification already reads evt["thread_id"]/evt["message_id"] to anchor that synthetic message correctly — but async_delegation.py never populated either field. dispatch_async_delegation/_batch captured session_key for routing but nothing for reply-threading, so every completion re-entered as a disconnected, unanchored message.
  • terminal_tool.py's notify_on_complete/watch_patterns path already solves this correctly for background processes (captures HERMES_SESSION_THREAD_ID/HERMES_SESSION_MESSAGE_ID via get_session_env() on the parent thread before the contextvar-less worker spawns). This mirrors that same capture for both delegation dispatch paths, threads the values through _push_completion_event/_push_batch_completion_event, and — since a gateway restart is exactly the kind of event that abandons an in-flight delegation — also adds the anchor to the task_json persistence allowlist so it survives into recover_abandoned_delegations().

Test plan

  • python3 -m py_compile gateway/run.py tools/async_delegation.py
  • Restarted 7 local Hermes gateway profiles (Buzz platform) on the patched code, including one profile that had an in-flight background delegation abandoned by the restart — its recovered completion correctly carried the anchor through recover_abandoned_delegations()
  • Maintainers: would appreciate a run against a live Buzz relay with a long tool-calling turn that also dispatches a background delegation, to confirm both the heartbeat and the delegation-completion messages stay anchored to the originating thread end-to-end

🤖 Generated with Claude Code

Buzz has no channel-level thread_id — every reply is anchored per-message
via a Nostr "e" reply tag. The status-callback path already special-cased
this (_status_thread_metadata = {"thread_id": event_message_id}), but the
progress-heartbeat path (send_progress_messages, the periodic
"Working — N min" updates) had no equivalent branch: for platform=="buzz"
it fell through to _progress_reply_to=None / _progress_metadata=None
unconditionally, since _resolve_progress_thread_id() only special-cases
slack/mattermost and Buzz has no source.thread_id to key off of.

Effect: on a long-running turn, the first and final messages of a Buzz
run would thread correctly, but every heartbeat tick in between posted as
a new top-level message, splitting one conversation into many separate
threads mid-run.

Mirrors the existing buzz branch in the sibling status-callback wiring
a few hundred lines below (same file), just applied to the
progress-heartbeat metadata construction.
@alt-glitch alt-glitch added type/bug Something isn't working comp/gateway Gateway runner, session dispatch, delivery P3 Low — cosmetic, nice to have needs-decision Awaiting maintainer decision before any implementation sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages labels Aug 7, 2026
@alt-glitch

Copy link
Copy Markdown
Collaborator

This was generated by AI during triage.

Related to #77080: both keep Buzz progress updates anchored to the triggering message, but this patch uses explicit Buzz metadata branches while #77080 fixes the resolver-level fallback. Please select or consolidate the approach.

…letions

Background delegations (delegate_task(background=true)) re-enter the chat
as a synthetic message once the child finishes (tools/async_delegation.py
-> process_registry.completion_queue -> gateway._inject_watch_notification).
That injection already reads evt["thread_id"]/evt["message_id"] to anchor
the synthetic message to the right place, but async_delegation.py never
populated either field — dispatch_async_delegation/_batch captured
session_key for routing but nothing for reply-threading, so every
completion re-entered as a disconnected, unanchored message.

terminal_tool.py's notify_on_complete/watch_patterns path already solves
this correctly for background processes: it captures
HERMES_SESSION_THREAD_ID/HERMES_SESSION_MESSAGE_ID via get_session_env() on
the parent thread at dispatch time (before the contextvar-less worker
thread spawns) and carries them on the watcher entry. This mirrors that
same capture for both async delegation dispatch paths (single + batch) and
threads the values through:

  - dispatch_async_delegation / dispatch_async_delegation_batch: capture
    HERMES_SESSION_THREAD_ID / HERMES_SESSION_MESSAGE_ID into the record
  - _push_completion_event / _push_batch_completion_event: include them as
    evt["thread_id"] / evt["message_id"]
  - _persist_dispatch: added to the task_json allowlist so the anchor
    survives into recover_abandoned_delegations() if the owning gateway
    process dies/restarts before the child finishes (a live in-flight
    delegation is exactly what a gateway restart abandons)
  - recover_abandoned_delegations: reads the anchor back out of task_json
    for the crash-recovery reconstruction path

Effect on platforms with per-message reply semantics (Buzz's Nostr "e"
tags): a background delegation's completion previously always broke out
into a new, disconnected thread. It now chains back into the thread the
delegation was dispatched from, matching how a normal foreground turn
already behaves.
@rbaset5 rbaset5 changed the title fix(gateway): anchor Buzz progress heartbeats to the triggering message fix(buzz): keep progress heartbeats and background-delegation completions in the originating thread Aug 7, 2026
@allanjeng

Copy link
Copy Markdown

I reproduced the async-delegation half of this PR on current main through a Telegram DM-topic route.

Reproduction

  1. A delegation is dispatched from inbound message 8221 while the persisted SessionSource.message_id still points at the older session origin 8220.
  2. tools/async_delegation.py captures the session key but not HERMES_SESSION_MESSAGE_ID, so the completion event reaches the gateway with message_id=None.
  3. The synthetic completion turn therefore loses the spawning-turn anchor; Telegram reply metadata can fall back to the stale source origin and render telegram_reply_to_message_id=8220 even though the delegation was spawned by 8221.

Your origin_message_id plumbing fixes that core omission across single dispatch, batch dispatch, and abandoned-delegation recovery. Two gaps are worth addressing before merge:

  1. Prevent cross-prompt completion coalescing. GatewayRunner._async_delegation_group_key() does not include message_id. Two same-tick completions from different user prompts in the same session/thread currently coalesce, and the consolidated turn uses only the primary event's anchor. I reproduced this with events anchored to 8221 and 8222: the current key is identical and only one synthetic turn is injected. Adding message_id to the grouping key keeps same-prompt siblings coalesced while preserving separate reply anchors across prompts.
  2. Add regression coverage for the full lifecycle. The PR currently has no tests for the async changes. Useful boundaries are:
    • dispatch captures HERMES_SESSION_MESSAGE_ID for both single and batch APIs;
    • task_json -> recover_abandoned_delegations() -> event_json preserves the anchor;
    • synthetic re-entry uses event anchor 8221 instead of stale source origin 8220;
    • events from the same session but different message_id values do not coalesce.

Implementation-wise, async_delegation.py already has _capture_routing_origin() and repeated routing-field propagation for scope_id / user_id / user_name. Extending that existing mechanism with thread_id / message_id avoids duplicating capture blocks in both dispatch functions and keeps live completion plus recovery paths on one field list.

Local verification of that shape:

  • focused async/gateway routing suite: 143 passed
  • sabotage checks: removing message capture reproduces the stale-anchor failure; removing message_id from the coalescing key reproduces one-turn collapse
  • Ruff, byte compilation, and git diff --check: passed

This also overlaps #68234 on the Feishu batch boundary. Consolidating the generic async lifecycle here would let the platform-specific PRs stay focused on adapter behavior.

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

Labels

comp/gateway Gateway runner, session dispatch, delivery needs-decision Awaiting maintainer decision before any implementation P3 Low — cosmetic, nice to have sweeper:risk-message-delivery Sweeper risk: may drop, duplicate, misroute, or suppress messages type/bug Something isn't working

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants