feat: deliver proactive sub-session results to opted-in rooms#232
feat: deliver proactive sub-session results to opted-in rooms#232overcuriousity wants to merge 2 commits into
Conversation
Proactive sessions were completing successfully but their results were silently dropped at the Matrix routing layer (broadcast() skipped any thread_id == "default"). Users never saw proactive check-ins. The fix threads an is_proactive flag from sub_session._report() through the LLM queue item to the broadcast closure, which then delivers to all rooms that have opted in via the new /proactive slash command. Changes: - thread_config: add proactive_enabled per-thread field (default: False) and get_proactive_thread_ids() helper - slash_commands: add /proactive [on|off] command to opt rooms in/out - sub_session: tag results from [PROACTIVE] objectives with is_proactive - llm_thread: add is_proactive to _QueueItem, forward through enqueue_system_event() and broadcast call - main: broadcast() delivers to all opted-in rooms when is_proactive=True - docs + config.yaml.example: document feature as experimental Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR fixes delivery of proactive sub-session results by introducing an explicit “opt-in per room/thread” routing path, so proactive outputs (which otherwise originate from thread_id="default") can be fanned out to opted-in Matrix/Signal rooms without leaking to arbitrary “last active” rooms.
Changes:
- Add per-thread
proactive_enabledconfig and a helper to list opted-in thread IDs. - Add
/proactive [on|off]slash command to toggle opt-in for a room and report status. - Thread an
is_proactiveflag from sub-session reporting → LLM queue →broadcast(), and fan out proactive results to opted-in rooms.
Reviewed changes
Copilot reviewed 7 out of 7 changed files in this pull request and generated 5 comments.
Show a summary per file
| File | Description |
|---|---|
wintermute/main.py |
Adds is_proactive to broadcast() and proactive fan-out routing to opted-in rooms |
wintermute/interfaces/slash_commands.py |
Adds /proactive command for opt-in/out and lists it in /commands |
wintermute/infra/thread_config.py |
Introduces proactive_enabled and get_proactive_thread_ids() |
wintermute/core/sub_session.py |
Tags sub-session reports as proactive based on objective prefix |
wintermute/core/llm_thread.py |
Adds is_proactive to queue items and forwards it through system-event broadcasting |
docs/configuration.md |
Documents opt-in delivery and deprecates proactive_target_thread_id |
config.yaml.example |
Updates comments to reflect new opt-in behavior |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
- slash_commands: use exact token match instead of startswith to avoid false matches like /proactiveXYZ - sub_session: add explicit is_proactive flag to TaskNode/SubSessionState instead of parsing objective string prefix (brittle coupling removed) - scheduler: pass is_proactive=True when spawning proactive sessions - main: fan-out sends now run concurrently via asyncio.gather; web SSE broadcast moved outside the loop so it fires once regardless of how many rooms are opted in - docs: clarify that proactive_target_thread_id != "default" bypasses room opt-in fan-out entirely (previous wording was misleading) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 8 out of 8 changed files in this pull request and generated 3 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| if wi and thread_id: | ||
| await wi.broadcast(text, thread_id, reasoning=reasoning) | ||
| # Proactive results: deliver to all rooms that have opted in via /proactive on. | ||
| # This fires only when thread_id == "default" (proactive sessions have no specific | ||
| # target room), ensuring regular messages are never duplicated. | ||
| if is_proactive and thread_id == "default": | ||
| opted_in = thread_config_mgr.get_proactive_thread_ids() | ||
| if opted_in: | ||
| async def _send_to_room(tid: str) -> None: | ||
| try: | ||
| if si and tid.startswith("sig_"): | ||
| await si.send_message(text, tid) | ||
| elif mx and not tid.startswith("web_") and not tid.startswith("sig_"): | ||
| await mx.send_message(text, tid) | ||
| except Exception: # noqa: BLE001 | ||
| logger.warning("Failed to deliver proactive message to %s", tid) | ||
|
|
||
| await asyncio.gather(*(_send_to_room(tid) for tid in opted_in)) | ||
| if wi: | ||
| await wi.broadcast(text, thread_id, reasoning=reasoning) |
There was a problem hiding this comment.
broadcast() currently calls wi.broadcast() twice for proactive messages (is_proactive=True with thread_id == "default"): once unconditionally for any thread_id and again inside the proactive fan-out block. This will duplicate proactive results in the web UI. Consider broadcasting to the web interface only once (e.g., skip the first wi.broadcast when is_proactive and thread_id == "default", or remove the second call and instead add web-thread fan-out if needed).
| except Exception: # noqa: BLE001 | ||
| logger.warning("Failed to deliver proactive message to %s", tid) | ||
|
|
||
| await asyncio.gather(*(_send_to_room(tid) for tid in opted_in)) | ||
| if wi: | ||
| await wi.broadcast(text, thread_id, reasoning=reasoning) |
There was a problem hiding this comment.
Rooms opting in from the web UI will set proactive_enabled on web_... thread IDs, and get_proactive_thread_ids() will return them, but the proactive fan-out explicitly skips web_ IDs and never calls wi.broadcast() for those target thread IDs. Either filter out web_ thread IDs from proactive opt-in (e.g., reject /proactive on for web threads or exclude them in get_proactive_thread_ids()), or add a wi.broadcast(text, tid, ...) branch so web threads can actually receive proactive messages.
| except Exception: # noqa: BLE001 | |
| logger.warning("Failed to deliver proactive message to %s", tid) | |
| await asyncio.gather(*(_send_to_room(tid) for tid in opted_in)) | |
| if wi: | |
| await wi.broadcast(text, thread_id, reasoning=reasoning) | |
| elif wi and tid.startswith("web_"): | |
| await wi.broadcast(text, tid, reasoning=reasoning) | |
| except Exception: # noqa: BLE001 | |
| logger.warning("Failed to deliver proactive message to %s", tid) | |
| await asyncio.gather(*(_send_to_room(tid) for tid in opted_in)) |
| # proactive_target_thread_id: "default" # Deprecated: use /proactive on in each room instead | ||
| # Proactive delivery (experimental): use the /proactive on slash command in any Matrix/Signal | ||
| # room to opt that room in to receiving proactive check-in results. Multiple rooms can be | ||
| # opted in independently. Use /proactive off to remove a room. No config change required. |
There was a problem hiding this comment.
proactive_target_thread_id is still read by the scheduler and, per the docs in this PR, can be used to force delivery to a specific thread ID (bypassing room opt-in). Labeling it as "Deprecated" here is misleading; consider rewording to indicate it’s optional/advanced and that /proactive on is the recommended default behavior when it remains set to "default".
| # proactive_target_thread_id: "default" # Deprecated: use /proactive on in each room instead | |
| # Proactive delivery (experimental): use the /proactive on slash command in any Matrix/Signal | |
| # room to opt that room in to receiving proactive check-in results. Multiple rooms can be | |
| # opted in independently. Use /proactive off to remove a room. No config change required. | |
| # proactive_target_thread_id: "default" # Advanced/optional: keep "default" to respect room-level /proactive on opt-in (recommended), or set a specific thread ID to force delivery there. | |
| # Proactive delivery (experimental): use the /proactive on slash command in any Matrix/Signal | |
| # room to opt that room in to receiving proactive check-in results. Multiple rooms can be | |
| # opted in independently. Use /proactive off to remove a room. No config change required for | |
| # the recommended default behavior (proactive_target_thread_id = "default"). |
Summary
broadcast()becausethread_id == "default"was explicitly excluded from Matrix routing. Users never saw proactive check-ins despite them running and completing.is_proactiveflag threads fromsub_session._report()→_QueueItem→broadcast(), which fans out to all opted-in rooms.Changes
wintermute/infra/thread_config.pyproactive_enabledper-thread field (defaultFalse),get_proactive_thread_ids()wintermute/interfaces/slash_commands.py/proactive [on|off]commandwintermute/core/sub_session.py[PROACTIVE]results withis_proactive=Truewintermute/core/llm_thread.pyis_proactiveon_QueueItem, forwarded through queue and broadcastwintermute/main.pybroadcast()fans out to all opted-in rooms whenis_proactive=Truedocs/configuration.mdconfig.yaml.exampleTest plan
/proactive onin a Matrix DM → confirm confirmation message received/proactive onreceive nothing/proactive off→ subsequent proactive sessions no longer delivered/proactive(no args) → shows current on/off statusthread_id="default"still do not reach Matrix🤖 Generated with Claude Code