Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
83 commits
Select commit Hold shift + click to select a range
27d53e2
feat(m2): add ConversationWorker for supabase-backed conversations
claude Apr 13, 2026
201c789
feat(m2): route realtime WebSocket through HTTPS proxy when configured
claude Apr 13, 2026
95ebc00
feat(m2): handle resume-only followups + add coverage for all event t…
claude Apr 13, 2026
7b7f36f
feat(m2): wire per-conversation Presence + fix cluster Presence config
claude Apr 13, 2026
370c913
refactor(m2): tune defaults, gate polling on realtime, simplify DAL
claude Apr 13, 2026
97643a3
refactor(m2): align with updated plan (assignee param, global seq, RP…
claude Apr 13, 2026
4afa6d2
fix(m2): pass _account_id (now first param) to all RPC calls
claude Apr 13, 2026
c6ba742
refactor(m2): five small correctness/robustness fixes
claude Apr 13, 2026
71cf001
refactor(m2): seven small code-quality fixes
claude Apr 13, 2026
e3323ac
refactor(m2): periodic JWT refresh + document shutdown strategy
claude Apr 13, 2026
d45ad7e
feat(m2): add queued state, update_conversation_status, max_concurrent=5
claude Apr 14, 2026
421a106
Merge remote-tracking branch 'origin/master' into claude/implement-m2…
claude Apr 14, 2026
3a13d96
feat(m2): advertise supports_realtime_conversations in HolmesStatus
claude Apr 14, 2026
60b4577
fix(m2): guard against None session in realtime connect, fix arg order
claude Apr 14, 2026
029c6ae
feat(m2): post error events to ConversationEvents before failing
claude Apr 14, 2026
c207a83
test(m2): add conversation worker integration test suite
claude Apr 14, 2026
707a59f
feat(m2): compact prior events on ai_answer_end and approval_required
claude Apr 14, 2026
0d1be7c
fix(m2): sanitize error events, guard dispatch during shutdown
claude Apr 14, 2026
30a1bc9
Merge remote-tracking branch 'origin/master' into claude/implement-m2…
claude Apr 14, 2026
01bd215
fix(m2): fixture cleanup, replace sleep with polling, dynamic stress NUM
claude Apr 14, 2026
67c0ad8
fix(m2): add dispatch lock to serialize dispatch with shutdown
claude Apr 14, 2026
61896bb
fix(m2): don't drop events on flush failure, retry on None seq
claude Apr 14, 2026
9a9dd14
refactor(m2): _terminal_to_status returns 'completed' for approval
claude Apr 14, 2026
9794256
fix(m2): sticky compact flag, consume fails on unsaved events, retry …
claude Apr 14, 2026
79685c0
fix(m2): skip final flush after ConversationReassignedError
claude Apr 14, 2026
ada3703
Merge branch 'master' into claude/implement-m2-holmes-bzoqT
naomi-robusta Apr 15, 2026
c483972
feat(m2): race-safe presence, topic helpers, config knobs, flush cleanup
claude Apr 15, 2026
b78538b
Merge remote-tracking branch 'origin/claude/implement-m2-holmes-bzoqT…
claude Apr 15, 2026
0cea647
refactor(m2): include account_id in conversation presence topic
claude Apr 15, 2026
c6b2dfc
fix(m2): prune presence map on leave, gate conv subscribe on SUBSCRIBED
claude Apr 15, 2026
64950d4
Merge remote-tracking branch 'origin/master' into claude/implement-m2…
claude Apr 15, 2026
648041b
refactor(m2): single per-account presence channel with debounced tracks
claude Apr 15, 2026
7718f6d
fix(m2): split presence and pg-changes channels + retry transient DAL…
claude Apr 15, 2026
2e16992
refactor(m2): per-account pg-changes topic, drop cluster liveness
claude Apr 16, 2026
4fbf31e
Merge remote-tracking branch 'origin/master' into claude/implement-m2…
claude Apr 16, 2026
2f5b476
style(m2): move in-function imports to module top in test_realtime_ma…
claude Apr 16, 2026
50e8271
fix(m2): fix proxy URL builder for missing port/password, strengthen …
claude Apr 16, 2026
7c0aad3
refactor(m2): only advertise presence when conversations are active
claude Apr 16, 2026
b4a0aed
fix(m2): log suppressed on_new_pending exceptions at debug level
claude Apr 16, 2026
66162e5
fix(m2): atomic check-and-pop in leave_conversation_presence
claude Apr 16, 2026
2ff2398
refactor(m2): remove all presence code, keep only pg-changes
claude Apr 16, 2026
9c65f0e
style: replace EN DASH with hyphen-minus in test docstring
claude Apr 16, 2026
2f64ce1
Merge remote-tracking branch 'origin/master' into claude/implement-m2…
claude Apr 16, 2026
86760d7
feat(m2): support both pgchanges and broadcast for conversation submi…
claude Apr 16, 2026
c235758
feat(m2): flip env var to CONVERSATION_WORKER_USE_REALTIME_BROADCAST,
claude Apr 16, 2026
323e80d
fix(m2): reuse persistent WS for broadcast in integration tests
claude Apr 16, 2026
dbb1881
fix(m2): log teardown exceptions with tracebacks instead of silencing
claude Apr 16, 2026
ac71d38
fix(m2): wake async sleep on stop, requeue transient failures, move i…
claude Apr 16, 2026
1a626f0
Merge branch 'master' into claude/implement-m2-holmes-bzoqT
naomi-robusta Apr 20, 2026
aa109ea
rename broadcast event to pending_conversations
claude Apr 20, 2026
5e837a2
Merge branch 'claude/implement-m2-holmes-bzoqT' of http://127.0.0.1:5…
claude Apr 20, 2026
59abcd7
comment cleanup
naomi-robusta Apr 20, 2026
0e405ba
Merge branch 'claude/implement-m2-holmes-bzoqT' of github.com:HolmesG…
naomi-robusta Apr 20, 2026
8290710
use broadcast by default
naomi-robusta Apr 20, 2026
3d2e5f8
fix stale env var name in realtime_manager docstring
claude Apr 21, 2026
cffabbc
Merge branch 'claude/implement-m2-holmes-bzoqT' of http://127.0.0.1:3…
claude Apr 21, 2026
57feb19
update docstring: broadcast is now the default subscription mode
claude Apr 21, 2026
fb6335f
drop keyword-only marker on RealtimeManager.__init__
claude Apr 21, 2026
5dc75ed
close realtime client cleanly on stop() and connect failure
claude Apr 21, 2026
b0112c7
fix realtime URL double-suffix, broadcast setup error handling, shutd…
claude Apr 21, 2026
ca2d467
Merge remote-tracking branch 'origin/master' into claude/implement-m2…
claude Apr 21, 2026
279409f
add test_max_concurrent_never_exceeded + review fixes
claude Apr 21, 2026
c9dae88
add broadcast health check diagnostic script
claude Apr 21, 2026
872d9da
detect channel closure and auto-reconnect realtime subscription
claude Apr 21, 2026
cb56698
force sign_in on reconnect instead of relying on get_session auto-ref…
claude Apr 21, 2026
a9fa337
fix reconnect storm, subscribe waiter, client leak, task drop
claude Apr 23, 2026
4c25b64
retry initial connect with backoff instead of exiting thread
claude Apr 23, 2026
a84bec0
rename misleading test: test_failed → test_successful_has_no_error
claude Apr 23, 2026
f607043
revert
naomi-robusta Apr 23, 2026
6dabd0f
cleanup
naomi-robusta Apr 23, 2026
4477a13
cleanup
naomi-robusta Apr 23, 2026
d5d50d9
promote poll/reconnect constants to env vars, remove unused env vars
claude Apr 23, 2026
60b11e2
Merge branch 'claude/implement-m2-holmes-bzoqT' of http://127.0.0.1:3…
claude Apr 23, 2026
db0046a
support frontend_tools, response_format, behavior_controls in user_me…
claude Apr 23, 2026
8f9fd63
simplify ConversationTask: store raw user_message_data instead of mir…
claude Apr 23, 2026
f791dfc
guard against processing already-answered user_message
claude Apr 23, 2026
58d7fe6
Merge remote-tracking branch 'origin/master' into claude/implement-m2…
claude Apr 23, 2026
afd11cd
fix reconnect busy-loop, orphaned queued tasks, stale comment, inline…
claude Apr 23, 2026
910fa57
cleanup
naomi-robusta Apr 23, 2026
aa508f1
merge origin/master: resolve conflicts keeping both sides
claude Apr 27, 2026
52a1aca
Merge branch 'claude/implement-m2-holmes-bzoqT' of http://127.0.0.1:2…
claude Apr 27, 2026
8755b3d
fix: get_runbook_catalog renamed to get_skill_catalog in Skills PR
claude Apr 28, 2026
0ea081c
Merge branch 'master' into claude/implement-m2-holmes-bzoqT
naomi-robusta Apr 28, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file modified build_with_arm.sh
100644 → 100755
Empty file.
39 changes: 39 additions & 0 deletions holmes/common/env_vars.py
Original file line number Diff line number Diff line change
Expand Up @@ -188,3 +188,42 @@ def load_bool(env_var, default: Optional[bool]) -> Optional[bool]:
HOLMES_TOOL_RESULT_STORAGE_PATH = os.environ.get(
"HOLMES_TOOL_RESULT_STORAGE_PATH", os.path.join(tempfile.gettempdir(), ".holmes")
)

# Conversation Worker (M2)
ENABLE_CONVERSATION_WORKER = load_bool("ENABLE_CONVERSATION_WORKER", False)
Comment thread
moshemorad marked this conversation as resolved.
CONVERSATION_WORKER_MAX_CONCURRENT = int(
os.environ.get("CONVERSATION_WORKER_MAX_CONCURRENT", 5)
)
# Only used when realtime is disabled or disconnected. When realtime is enabled
# and connected, Holmes relies on Postgres Changes notifications and does not
# poll.
CONVERSATION_WORKER_POLL_INTERVAL_SECONDS_WITHOUT_REALTIME = int(
os.environ.get("CONVERSATION_WORKER_POLL_INTERVAL_SECONDS_WITHOUT_REALTIME", 60)
)
Comment thread
naomi-robusta marked this conversation as resolved.
# Safety-net poll interval when realtime IS connected. Supabase Realtime
# has at-most-once delivery, so this caps the maximum latency for a missed
# broadcast/pgchanges notification.
CONVERSATION_WORKER_POLL_INTERVAL_SECONDS_WITH_REALTIME = int(
os.environ.get("CONVERSATION_WORKER_POLL_INTERVAL_SECONDS_WITH_REALTIME", 300)
)
CONVERSATION_WORKER_EVENT_BATCH_INTERVAL_SECONDS = float(
os.environ.get("CONVERSATION_WORKER_EVENT_BATCH_INTERVAL_SECONDS", 1.0)
)
CONVERSATION_WORKER_REALTIME_RECONNECT_MAX_SECONDS = int(
os.environ.get("CONVERSATION_WORKER_REALTIME_RECONNECT_MAX_SECONDS", 120)
)
CONVERSATION_WORKER_REALTIME_ENABLED = load_bool(
"CONVERSATION_WORKER_REALTIME_ENABLED", True
)
CONVERSATION_WORKER_AUTH_REFRESH_INTERVAL_SECONDS = float(
os.environ.get("CONVERSATION_WORKER_AUTH_REFRESH_INTERVAL_SECONDS", 60)
)
# When True (default), Holmes subscribes to a Broadcast channel
# (holmes:submit:{account_id}:{cluster_id}) to detect new pending
# conversations — the initiator (Frontend/Relay) must send a broadcast
# after creating the conversation. Avoids WAL replication overhead at scale.
# When False, Holmes subscribes to Postgres Changes on the
# Conversations table instead (no initiator action needed beyond the RPC).
CONVERSATION_WORKER_USE_REALTIME_BROADCAST = load_bool(
"CONVERSATION_WORKER_USE_REALTIME_BROADCAST", True
)
3 changes: 3 additions & 0 deletions holmes/core/conversations_worker/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
from holmes.core.conversations_worker.worker import ConversationWorker

__all__ = ["ConversationWorker"]
192 changes: 192 additions & 0 deletions holmes/core/conversations_worker/event_publisher.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,192 @@
import logging
import threading
import time
from datetime import datetime, timezone
from typing import Any, Dict, Generator, List, Optional, TYPE_CHECKING

from holmes.core.conversations_worker.models import ConversationReassignedError
from holmes.utils.stream import StreamEvents, StreamMessage

if TYPE_CHECKING:
from holmes.core.supabase_dal import SupabaseDal


# Events that end a turn and must be flushed immediately. The publisher
# reports the last one it saw back to the worker so the conversation status
# can be set appropriately.
_TERMINAL_EVENTS = {
StreamEvents.ANSWER_END,
StreamEvents.APPROVAL_REQUIRED,
StreamEvents.ERROR,
}

# Events that should cause an immediate flush. Terminal events end a turn;
# CONVERSATION_HISTORY_COMPACTED isn't terminal but carries the same
# "history snapshot + prior events superseded" semantics so it's flushed +
# compacted with the same logic.
_FLUSH_IMMEDIATELY_EVENTS = _TERMINAL_EVENTS | {
StreamEvents.CONVERSATION_HISTORY_COMPACTED
}

# Events whose `messages` array carries the full conversation history
# snapshot — all prior events are superseded and should be marked compacted.
_COMPACT_ON_FLUSH_EVENTS = {
Comment thread
naomi-robusta marked this conversation as resolved.
StreamEvents.ANSWER_END,
StreamEvents.APPROVAL_REQUIRED,
StreamEvents.CONVERSATION_HISTORY_COMPACTED,
}


class ConversationEventPublisher:
"""
Consumes StreamMessage events from call_stream() and batches them
into ConversationEvents rows in Supabase.
"""

def __init__(
self,
dal: "SupabaseDal",
conversation_id: str,
assignee: str,
request_sequence: int,
batch_interval_seconds: float = 1.0,
):
self.dal = dal
self.conversation_id = conversation_id
self.assignee = assignee
self.request_sequence = request_sequence
self.batch_interval_seconds = batch_interval_seconds

self._pending_events: List[Dict[str, Any]] = []
self._last_flush_time: float = time.monotonic()
self._last_retry_time: float = 0.0
self._lock = threading.Lock()

self._last_terminal_event: Optional[StreamEvents] = None

# Sticky compact flag: set when a compact flush is attempted but the
# DAL returns None. Ensures the compact intent is preserved across
# retries and the final drain.
self._pending_compact: bool = False

def consume(
self,
stream: Generator[StreamMessage, None, None],
) -> Optional[StreamEvents]:
"""
Drain the stream generator, batching events and writing them to the DB.
Returns the terminal StreamEvents value observed, or None if the stream ended
without a terminal event.
Raises ConversationReassignedError if the conversation was reassigned mid-stream.
"""
reassigned = False
try:
for message in stream:
self._append_event(message)
if message.event in _TERMINAL_EVENTS:
self._last_terminal_event = message.event
# Flush on terminal events immediately, or when interval elapses
if message.event in _FLUSH_IMMEDIATELY_EVENTS:
# ai_answer_end / approval_required / compacted carry a
# full conversation history snapshot in their messages
# array, so all prior events are superseded → compact.
if message.event in _COMPACT_ON_FLUSH_EVENTS:
self._pending_compact = True
self._flush()
Comment thread
naomi-robusta marked this conversation as resolved.
elif (
Comment thread
naomi-robusta marked this conversation as resolved.
time.monotonic() - self._last_flush_time
>= self.batch_interval_seconds
and time.monotonic() - self._last_retry_time
>= self.batch_interval_seconds
):
self._flush()
except ConversationReassignedError:
reassigned = True
raise
finally:
# Final drain of any remaining events — skip if the conversation
# was reassigned, since our assignee/sequence are stale and writing
# would either fail or race with the new owner.
if not reassigned:
self._flush()

Comment thread
coderabbitai[bot] marked this conversation as resolved.
# If events remain unsaved after the stream is fully consumed, the
# terminal batch was lost (repeated None returns). Surface this to the
# caller so the conversation is marked failed rather than completed.
with self._lock:
remaining = len(self._pending_events)
if remaining > 0:
logging.error(
"consume() finished with %d unsaved events for conversation %s",
remaining,
self.conversation_id,
)
return None

Comment thread
naomi-robusta marked this conversation as resolved.
return self._last_terminal_event

def _append_event(self, message: StreamMessage) -> None:
with self._lock:
self._pending_events.append(
{
"event": message.event.value,
"data": message.data,
"ts": datetime.now(timezone.utc).isoformat(),
}
)

def _flush(self) -> None:
with self._lock:
if not self._pending_events:
return
# Snapshot but don't clear yet — only clear after a successful post.
events_to_flush = list(self._pending_events)
compact = self._pending_compact

try:
Comment thread
naomi-robusta marked this conversation as resolved.
seq = self.dal.post_conversation_events(
conversation_id=self.conversation_id,
assignee=self.assignee,
request_sequence=self.request_sequence,
events=events_to_flush,
compact=compact,
)
except ConversationReassignedError:
raise
except Exception as e:
# The RPCs prefix mismatch errors (status / assignee / request_sequence)
# with "MISMATCH " — promote those to ConversationReassignedError so
# the worker can exit the processing loop cleanly.
if "mismatch" in str(e).lower():
raise ConversationReassignedError(str(e)) from e
raise

if seq is None:
# DAL returned None (disabled or unexpected empty response).
# Keep events and compact flag in memory so the next flush retries.
# Update _last_retry_time to throttle retries independently of
# normal flush timing.
self._last_retry_time = time.monotonic()
logging.warning(
"post_conversation_events returned None for conversation %s — "
"events retained for retry (%d events, compact=%s)",
self.conversation_id,
len(events_to_flush),
compact,
)
return

# Success — remove the flushed events and clear the compact flag.
# New events may have been appended while the RPC was in flight,
# so we remove only the count we just posted.
with self._lock:
del self._pending_events[: len(events_to_flush)]
self._pending_compact = False
self._last_flush_time = time.monotonic()
logging.debug(
"Posted %d events to conversation %s (seq=%s, compact=%s)",
len(events_to_flush),
self.conversation_id,
seq,
compact,
)
39 changes: 39 additions & 0 deletions holmes/core/conversations_worker/models.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
from enum import Enum
from typing import Any, Dict, List, Optional

from pydantic import BaseModel, Field


class ConversationStatus(str, Enum):
PENDING = "pending"
QUEUED = "queued"
RUNNING = "running"
COMPLETED = "completed"
FAILED = "failed"
STOPPED = "stopped"


class ConversationTask(BaseModel):
"""A claimed conversation ready for processing."""

conversation_id: str
account_id: str
cluster_id: str
origin: str
request_sequence: int
metadata: Dict[str, Any] = Field(default_factory=dict)
title: Optional[str] = None

# Raw data from the latest user_message event. Used to construct
# ChatRequest without duplicating every field.
user_message_data: Dict[str, Any] = Field(default_factory=dict)
Comment thread
naomi-robusta marked this conversation as resolved.

# Reconstructed from prior terminal events (ai_answer_end / approval_required).
conversation_history: Optional[List[Dict[str, Any]]] = None


class ConversationReassignedError(Exception):
"""Raised when the conversation's assignee/request_sequence no longer matches ours."""


EVENT_USER_MESSAGE = "user_message"
Comment thread
naomi-robusta marked this conversation as resolved.
Loading
Loading