fix(buzz): keep progress heartbeats and background-delegation completions in the originating thread - #80651
Conversation
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.
…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.
|
I reproduced the async-delegation half of this PR on current Reproduction
Your
Implementation-wise, Local verification of that shape:
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. |
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 Nostretag — 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)_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-casesslack/mattermost, so for Buzz it fell through to_progress_reply_to = Noneunconditionally.{"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_notificationalready readsevt["thread_id"]/evt["message_id"]to anchor that synthetic message correctly — butasync_delegation.pynever populated either field.dispatch_async_delegation/_batchcapturedsession_keyfor routing but nothing for reply-threading, so every completion re-entered as a disconnected, unanchored message.terminal_tool.py'snotify_on_complete/watch_patternspath already solves this correctly for background processes (capturesHERMES_SESSION_THREAD_ID/HERMES_SESSION_MESSAGE_IDviaget_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 thetask_jsonpersistence allowlist so it survives intorecover_abandoned_delegations().Test plan
python3 -m py_compile gateway/run.py tools/async_delegation.pyrecover_abandoned_delegations()🤖 Generated with Claude Code