orchestrata_ultimate.knowledge_graph.conversation_consumer
Reference › orchestrata_ultimate › knowledge_graph › conversation_consumer
The consumer for conversation capture (SPEC-021, US-021-007): it drains a Redis queue of conversation turns into a dedicated conversation graph. That graph has its own endpoint and is neither the operational orchkg database nor the shared memgraph semantic graph; resolve_conversation_endpoint returns its URI and database from CONVERSATION_KG_URI and CONVERSATION_KG_DATABASE, and resolve_conversation_redis_endpoint returns the queue's Redis from CONVERSATION_REDIS_HOST, CONVERSATION_REDIS_PORT and CONVERSATION_REDIS_DB. Both raise RuntimeError when a key is unset or blank, and there is no fallback to the ambient REDIS_* or profile-driven KG keys (REQ-021-069). create_conversation_redis and create_conversation_graph_repository build the clients from those endpoints.
The design follows an ontology-before-import ruling and avoids three defects of the original plugin. ConversationTurnMessage.parse validates schema version 2, which requires a project; anything without it, including every older message, raises ConversationMessageError and is quarantined, never guessed. RedisQueueSource implements the QueueSource protocol with an atomic LMOVE claim from the queue to a processing list, ack after a successful write, requeue_processing for crash recovery, quarantine and depth. TurnGraphMapper.to_statements is a pure mapper from a message to Cypher statements that MERGE Conversation and ConversationTurn nodes on project-qualified, producer-deterministic ids from canonical_schema; turn order is the turn_seq property, so redelivery is a safe no-op. ConversationQueueConsumer.flush requeues orphaned in-flight messages, drains the queue and returns a FlushReport (depth before, synced, quarantined, failed). It never raises (REQ-021-070): a failed write leaves the message in the processing list, counted as failed, and the next flush redelivers it. The kernel CLI and the conversation tools import it.
At a glance
| Source | src/orchestrata_ultimate/knowledge_graph/conversation_consumer.py |
| Lines of code | 712 |
| Classes | 7 |
| Functions | 4 |
| Imports (internal) | orchestrata_ultimate.core.base.traces, orchestrata_ultimate.core.graph.config, orchestrata_ultimate.core.graph.interfaces, orchestrata_ultimate.core.graph.repository, orchestrata_ultimate.core.redis_factory, orchestrata_ultimate.core.tracing.tracer, orchestrata_ultimate.knowledge_graph, orchestrata_ultimate.knowledge_graph.canonical_schema |
| Imported by | orchestrata_ultimate.kernel.cli_main, orchestrata_ultimate.tools.conversation_tools, scripts.run_conversation_consumer |
| Tests that import it | tests/kernel/test_cli_main_conversation_boot.py, tests/knowledge_graph/test_conversation_consumer.py, tests/scripts/test_claude_turn_capture.py, tests/scripts/test_irc_conversation_capture.py, tests/tools/test_conversation_tools.py |
Class diagram
Dependency diagram
API
Classes
ConversationMessageError
Bases: ValueError · Decorators: traces(spec='021', story='US-021-007', reqs=['REQ-021-063']) · Line 260
A queued message failed :meth:ConversationTurnMessage.parse validation.
| Method | Signature | Summary |
|---|---|---|
__init__ |
__init__(reason: str, raw: str) -> None |
ConversationTurnMessage (dataclass)
Decorators: dataclass(frozen=True) · Line 275
One parsed, validated queue message (message schema v2).
| Attribute | Type | Default |
|---|---|---|
schema |
int |
|
project |
str |
|
conversation_id |
str |
|
session_seq |
int |
|
turn_id |
str |
|
turn_seq |
int |
|
role |
str |
|
content |
str |
|
timestamp |
str |
|
platform |
str |
|
tool_call_ids |
tuple[str, ...] |
() |
tool_names |
tuple[str, ...] |
() |
| Method | Signature | Summary |
|---|---|---|
parse classmethod |
parse(raw: str) -> ConversationTurnMessage |
Parse + validate one raw queue message. |
QueueSource (protocol)
Bases: Protocol · Decorators: runtime_checkable · Line 381
Reliable-queue seam the consumer drives (LMOVE claim/ack/requeue).
| Method | Signature | Summary |
|---|---|---|
claim |
claim() -> str | None |
Atomically move one raw message queue -> processing; None when empty. |
ack |
ack(raw: str) -> None |
Remove raw from the processing list after a successful write. |
requeue_processing |
requeue_processing() -> int |
Crash recovery: drain the processing list back onto the queue. |
quarantine |
quarantine(raw: str, reason: str) -> None |
Park an invalid/legacy message on the quarantine list. |
depth |
depth() -> int |
Pending message count on the main queue. |
RedisQueueSource
Line 414
Real Redis reliable-queue implementation of :class:QueueSource.
| Method | Signature | Summary |
|---|---|---|
__init__ |
__init__(redis_client: redis.Redis, *, queue_key: str = 'orchestrata:conversation:queue', tracer: Tracer | None = None) -> None |
Args: redis_client: An already-constructed sync Redis client (DI — no env reads happen in this class; the boot seam builds the client via core.redis_factory.create_sync_redis). queue_key: The… |
claim |
claim() -> str | None |
|
ack |
ack(raw: str) -> None |
|
requeue_processing |
requeue_processing() -> int |
|
quarantine |
quarantine(raw: str, reason: str) -> None |
|
depth |
depth() -> int |
TurnGraphMapper
Line 494
Pure ConversationTurnMessage -> [(cypher, params), ...] mapper.
| Method | Signature | Summary |
|---|---|---|
__init__ |
__init__(tracer: Tracer | None = None) -> None |
|
to_statements |
to_statements(msg: ConversationTurnMessage) -> list[tuple[str, dict[str, Any]]] |
FlushReport (dataclass)
Decorators: traces(spec='021', story='US-021-007', reqs=['REQ-021-217']), dataclass(frozen=True) · Line 609
Result of one :meth:ConversationQueueConsumer.flush call.
| Attribute | Type | Default |
|---|---|---|
depth_before |
int |
|
synced |
int |
0 |
quarantined |
int |
0 |
failed |
int |
0 |
| Method | Signature | Summary |
|---|---|---|
to_summary |
to_summary() -> str |
The conv_sync tool's output line. |
ConversationQueueConsumer
Line 626
Drains the conversation queue into the graph — the SPEC-021 consumer.
| Method | Signature | Summary |
|---|---|---|
__init__ |
__init__(source: QueueSource, repo: SyncGraphRepository, mapper: TurnGraphMapper | None = None, tracer: Tracer | None = None) -> None |
|
flush |
flush() -> FlushReport |
Requeue any orphaned in-flight messages, then drain the queue. |
depth |
depth() -> int |
Pending message count (delegates to the source). |
Functions
| Function | Signature | Summary |
|---|---|---|
resolve_conversation_redis_endpoint traces |
resolve_conversation_redis_endpoint(env: Mapping[str, str] | None = None) -> tuple[str, int, int] |
Return (host, port, db) of the conversation queue's Redis, or raise. |
create_conversation_redis traces |
create_conversation_redis(env: Mapping[str, str] | None = None) -> redis.Redis |
Build the sync Redis client for the conversation queue, pinned to its own endpoint keys (raises when unset — see :func:resolve_conversation_redis_endpoint). |
resolve_conversation_endpoint traces |
resolve_conversation_endpoint(env: Mapping[str, str] | None = None) -> tuple[str, str] |
Return (uri, database) for the CONVERSATION graph, or raise. |
create_conversation_graph_repository traces |
create_conversation_graph_repository(env: Mapping[str, str] | None = None) -> SyncGraphRepository |
Build the graph repository for the CONVERSATION graph, pinned to its own endpoint keys. |
Source
"""Conversation-capture queue consumer (SPEC-021, US-021-007).
Ontology-before-import (user ruling: never dump raw nodes — see
``plans/contracts/spec-021-ontology.contract.json``, the settled design
authority for this module). The OpenCode plugin's original implementation
(``orchestrata/open-code/plugins/orchestrata.ts``) had three defects this
module deliberately does NOT reproduce (deviations D1/D2/D3 in the contract):
1. **No project field** — the v1 push shape carried no project identity,
which is unmappable under the multi-project KG rule (100+ projects share
one graph; a bare ``session_id`` like ``"opencode-227"`` collides across
every one of them). Message v2 (:class:`ConversationTurnMessage`) REQUIRES
``project``; anything without it (including every v1 message) is
quarantined, never guessed.
2. **Bare LPOP + CREATE** — a crash between pop and graph write silently lost
a turn, and a re-consumed message minted a *new* node (flush-time
enumerate index, not a stable id). This module claims via LMOVE
(queue -> processing, atomic) and only ever MERGEs on a producer-
deterministic id, so redelivery after a crash is a safe no-op.
3. **Pre-grouping by session_seq** — unnecessary once every message carries
its own full conversation identity; each message maps independently and
order-independently (grouping falls out of MERGE).
Identity: :func:`~orchestrata_ultimate.knowledge_graph.canonical_schema.conversation_entity_id`
and ``conversation_turn_entity_id`` project-qualify both new labels
(``Conversation``, ``ConversationTurn``) per the multi-project KG ruling —
see ``canonical_schema.py``'s Tier I section. Turn ORDER is the ``turn_seq``
property (a producer-side Redis INCR), not a linked list: list-pointer edges
are not MERGE-idempotent under out-of-order/duplicate redelivery, property
ordering is.
Never raises out of :meth:`ConversationQueueConsumer.flush` (REQ-021-070,
ERR-008): a graph-write failure leaves the claimed message in the
``processing`` list, counted as ``failed`` — the NEXT ``flush()`` call
requeues it (:meth:`QueueSource.requeue_processing`) and, being an
idempotent MERGE, safely redelivers it.
"""
from __future__ import annotations
import json
import logging
import os
from collections.abc import Mapping
from dataclasses import dataclass
from typing import TYPE_CHECKING, Any, Protocol, cast, runtime_checkable
from orchestrata_ultimate.core.base.traces import traces
from orchestrata_ultimate.core.tracing.tracer import Tracer
from orchestrata_ultimate.knowledge_graph import canonical_schema as cs
if TYPE_CHECKING:
import redis
from orchestrata_ultimate.core.graph.interfaces import SyncGraphRepository
_logger = logging.getLogger(__name__)
#: The conversation graph's OWN endpoint keys. Readers and writers must both
#: resolve through these and nothing else — see
#: :func:`create_conversation_graph_repository`.
CONVERSATION_KG_URI_KEY = "CONVERSATION_KG_URI"
CONVERSATION_KG_DATABASE_KEY = "CONVERSATION_KG_DATABASE"
#: The conversation pipeline's OWN Redis endpoint keys (the queue half of the
#: pipeline, as ``CONVERSATION_KG_*`` is the graph half). Producers and
#: consumers resolve the queue's Redis through these and NEVER through the
#: ambient ``REDIS_*`` keys — see :func:`resolve_conversation_redis_endpoint`.
CONVERSATION_REDIS_HOST_KEY = "CONVERSATION_REDIS_HOST"
CONVERSATION_REDIS_PORT_KEY = "CONVERSATION_REDIS_PORT"
CONVERSATION_REDIS_DB_KEY = "CONVERSATION_REDIS_DB"
@traces(spec="021", story="US-021-007", reqs=["REQ-021-069"])
def resolve_conversation_redis_endpoint(
env: Mapping[str, str] | None = None,
) -> tuple[str, int, int]:
"""Return ``(host, port, db)`` of the conversation queue's Redis, or raise.
Producers (Claude hook, kimi/IRC) and consumers (daemon, kernel boot) used
to build Redis from the ambient ``REDIS_HOST``/``REDIS_PORT``/``REDIS_DB``,
so a producer launched from a shell with a different ``REDIS_*`` than the
systemd daemon wrote turns into a queue nobody drained. The dedicated keys
live in the secrets-only ``.env`` files and are deliberately absent from
every ``env/*.env`` overlay. There is NO fallback to ``REDIS_*``.
Raises:
RuntimeError: When any key is unset/blank, or port/db is not an int.
"""
environ = os.environ if env is None else env
values = {
key: (environ.get(key) or "").strip()
for key in (
CONVERSATION_REDIS_HOST_KEY,
CONVERSATION_REDIS_PORT_KEY,
CONVERSATION_REDIS_DB_KEY,
)
}
missing = [key for key, value in values.items() if not value]
if missing:
raise RuntimeError(
f"{' and '.join(missing)} not set — the conversation queue has its "
"own Redis endpoint and must never be resolved through the ambient "
"REDIS_HOST/REDIS_PORT/REDIS_DB keys. Set them in the secrets-only "
".env (they are deliberately absent from every env/*.env profile "
"overlay)."
)
try:
port = int(values[CONVERSATION_REDIS_PORT_KEY])
db = int(values[CONVERSATION_REDIS_DB_KEY])
except ValueError as exc:
raise RuntimeError(
f"{CONVERSATION_REDIS_PORT_KEY}/{CONVERSATION_REDIS_DB_KEY} must be "
f"integers: {exc}"
) from exc
return values[CONVERSATION_REDIS_HOST_KEY], port, db
@traces(spec="021", story="US-021-007", reqs=["REQ-021-069"])
def create_conversation_redis(env: Mapping[str, str] | None = None) -> redis.Redis:
"""Build the sync Redis client for the conversation queue, pinned to its
own endpoint keys (raises when unset — see
:func:`resolve_conversation_redis_endpoint`)."""
from orchestrata_ultimate.core.redis_factory import create_sync_redis
host, port, db = resolve_conversation_redis_endpoint(env)
return create_sync_redis(host=host, port=port, db=db)
@traces(spec="021", story="US-021-007", reqs=["REQ-021-069"])
def resolve_conversation_endpoint(
env: Mapping[str, str] | None = None,
) -> tuple[str, str]:
"""Return ``(uri, database)`` for the CONVERSATION graph, or raise.
Split out from :func:`create_conversation_graph_repository` so the
endpoint CONTRACT is verifiable without constructing a driver: the
repository exposes no public accessor for its config (``BoltSyncRepository``
keeps it in ``_config``), and a test that reaches into private state to
prove which server a writer talks to is testing the wrong thing. This
function is pure — mapping in, tuple out — so the rule can be pinned
directly, and no test needs to open a connection to prod to check it.
Args:
env: Environment mapping; defaults to ``os.environ`` read at CALL time
(REQ-021-069), which is also the injection seam tests use instead
of monkeypatch.
Returns:
The conversation graph's ``(uri, database)``.
Raises:
RuntimeError: When either key is unset or blank.
"""
environ = os.environ if env is None else env
uri = (environ.get(CONVERSATION_KG_URI_KEY) or "").strip()
database = (environ.get(CONVERSATION_KG_DATABASE_KEY) or "").strip()
missing = [
key
for key, value in (
(CONVERSATION_KG_URI_KEY, uri),
(CONVERSATION_KG_DATABASE_KEY, database),
)
if not value
]
if missing:
raise RuntimeError(
f"{' and '.join(missing)} not set — the conversation graph has its "
"own endpoint and must never be resolved through the "
"profile-driven KG_ROUTING_URI/KG_URI keys, which a dev/test "
"overlay redirects. Set them in the secrets-only .env (they are "
"deliberately absent from every env/*.env profile overlay)."
)
return uri, database
@traces(spec="021", story="US-021-007", reqs=["REQ-021-069"])
def create_conversation_graph_repository(
env: Mapping[str, str] | None = None,
) -> SyncGraphRepository:
"""Build the graph repository for the CONVERSATION graph, pinned to its own
endpoint keys.
The conversation graph is cross-project operational data that lives on the
prod cluster, and it deliberately does NOT follow
``ORCHESTRATA_ENV_PROFILE``: a dev/test overlay must never redirect a
writer away from where every reader looks. ``~/.claude/tools/
conversation_recall.py`` (the only sanctioned reader) already enforces
that by resolving ``CONVERSATION_KG_URI`` / ``CONVERSATION_KG_DATABASE``
and raising when they are unset.
The WRITERS did not. Both the standing daemon
(``scripts/run_conversation_consumer.py``) and the kernel boot flush
(``kernel/cli_main.py::_boot_conversation_consumer``) called a bare
``create_graph_repository()``, whose ``GraphRepositoryConfig.from_env()``
defers to ``core.graph_factory`` — which reads the GENERIC
``KG_ROUTING_URI`` / ``KG_URI`` at driver-construction time. Those keys
ARE profile-driven. So the endpoint was pinned by construction on the read
side and by ambient-environment convention on the write side.
The convention holds under systemd (the unit sets
``ORCHESTRATA_ENV_PROFILE=prod`` over the prod checkout) and breaks in any
developer shell: direnv layers ``env/test.env``, which exports
``KG_ROUTING_URI=bolt://10.53.2.10:7687``, and the daemon's own
``load_dotenv(..., override=False)`` lets that exported value win over
``.env``. Running the consumer from such a shell writes conversation turns
into the TEST graph while every reader keeps looking at prod — the history
silently splits and nothing fails.
Resolving the conversation endpoint here makes the wrong endpoint
unreachable rather than merely discouraged. Unset keys RAISE (RUL-003's
inverse rule for required infra endpoints: no literal fallback, not even
localhost) — a conversation writer with no configured endpoint must stop,
not invent one.
Args:
env: Environment mapping; defaults to ``os.environ`` read at CALL time
(REQ-021-069), which is also the injection seam tests use instead
of monkeypatch.
Returns:
A ``SyncGraphRepository`` bound to the conversation graph.
Raises:
RuntimeError: When either key is unset or blank.
"""
from orchestrata_ultimate.core.graph.config import GraphRepositoryConfig
from orchestrata_ultimate.core.graph.repository import create_graph_repository
uri, database = resolve_conversation_endpoint(env)
return create_graph_repository(GraphRepositoryConfig(uri=uri, database=database))
#: Message schema version this consumer accepts (REQ-021-063). Anything else
#: — including every v1 message the original plugin ever produced — is
#: quarantined, never guessed at.
_SCHEMA_VERSION = 2
#: Max stored turn content length (REQ-021-068). The producer is expected to
#: truncate too; this is the re-enforcement the contract requires.
_MAX_CONTENT_LEN = 2000
_VALID_ROLES = frozenset({"user", "assistant"})
_REQUIRED_STR_FIELDS = (
"project",
"conversation_id",
"turn_id",
"role",
"content",
"timestamp",
"platform",
)
_REQUIRED_INT_FIELDS = ("session_seq", "turn_seq")
@traces(spec="021", story="US-021-007", reqs=["REQ-021-063"])
class ConversationMessageError(ValueError):
"""A queued message failed :meth:`ConversationTurnMessage.parse` validation.
Carries both the machine-readable ``reason`` (logged and, verbatim,
quarantined alongside the raw message) and the original ``raw`` string —
the message is never lost, only parked (REQ-021-063).
"""
def __init__(self, reason: str, raw: str) -> None:
self.reason = reason
self.raw = raw
super().__init__(reason)
@dataclass(frozen=True)
class ConversationTurnMessage:
"""One parsed, validated queue message (message schema v2).
See ``plans/contracts/spec-021-ontology.contract.json``
``queue_message_schema.message_v2`` for the wire shape this parses.
"""
schema: int
project: str
conversation_id: str
session_seq: int
turn_id: str
turn_seq: int
role: str
content: str
timestamp: str
platform: str
tool_call_ids: tuple[str, ...] = ()
# Tool NAMES, positionally parallel to ``tool_call_ids``.
#
# Ids alone answer "did anything run"; names answer "was it work". A turn
# that only read files is not the same event as one that edited them, and
# that distinction is the whole reason per-turn tool capture exists — it
# is the only non-opinion answer to "should this turn have started a
# pipeline?" (plans/nlq-research-2026-08.md R1/R2).
#
# Optional and positional rather than a dict: a producer that knows ids but
# not names stays valid, and ``tool_name`` matches the property existing
# ToolCall nodes already carry.
tool_names: tuple[str, ...] = ()
@classmethod
@traces(spec="021", story="US-021-007", reqs=["REQ-021-063", "REQ-021-068"])
def parse(cls, raw: str) -> ConversationTurnMessage:
"""Parse + validate one raw queue message.
Raises:
ConversationMessageError: On malformed JSON, an unsupported/
missing ``schema`` (including every v1 message — it carries
no ``schema`` field at all), a missing/blank required field,
an invalid ``project`` (blank or containing ``:``), an
invalid ``role``, or malformed ``tool_call_ids``. ``content``
is truncated to :data:`_MAX_CONTENT_LEN`, never rejected for
being long.
"""
try:
data = json.loads(raw)
except (json.JSONDecodeError, TypeError, ValueError) as exc:
raise ConversationMessageError("invalid-json", raw) from exc
if not isinstance(data, dict):
raise ConversationMessageError("invalid-json", raw)
if data.get("schema") != _SCHEMA_VERSION:
raise ConversationMessageError("unsupported-schema", raw)
values: dict[str, Any] = {}
for name in _REQUIRED_STR_FIELDS:
value = data.get(name)
if not isinstance(value, str) or not value.strip():
raise ConversationMessageError(f"missing-field:{name}", raw)
values[name] = value
for name in _REQUIRED_INT_FIELDS:
value = data.get(name)
if not isinstance(value, int) or isinstance(value, bool):
raise ConversationMessageError(f"missing-field:{name}", raw)
values[name] = value
if ":" in values["project"]:
raise ConversationMessageError("invalid-project", raw)
if values["role"] not in _VALID_ROLES:
raise ConversationMessageError("invalid-role", raw)
tool_call_ids_raw = data.get("tool_call_ids", [])
if not isinstance(tool_call_ids_raw, list) or not all(
isinstance(item, str) for item in tool_call_ids_raw
):
raise ConversationMessageError("invalid-tool-call-ids", raw)
tool_names_raw = data.get("tool_names", [])
if not isinstance(tool_names_raw, list) or not all(
isinstance(item, str) for item in tool_names_raw
):
raise ConversationMessageError("invalid-tool-names", raw)
# Names are positional against ids, so a length mismatch would silently
# mislabel every call after the first gap. Reject rather than zip-
# truncate: a producer that cannot supply names sends none at all.
if tool_names_raw and len(tool_names_raw) != len(tool_call_ids_raw):
raise ConversationMessageError("tool-names-length-mismatch", raw)
return cls(
schema=_SCHEMA_VERSION,
project=values["project"],
conversation_id=values["conversation_id"],
session_seq=values["session_seq"],
turn_id=values["turn_id"],
turn_seq=values["turn_seq"],
role=values["role"],
content=values["content"][:_MAX_CONTENT_LEN],
timestamp=values["timestamp"],
platform=values["platform"],
tool_call_ids=tuple(tool_call_ids_raw),
tool_names=tuple(tool_names_raw),
)
@runtime_checkable
class QueueSource(Protocol):
"""Reliable-queue seam the consumer drives (LMOVE claim/ack/requeue).
A real Redis-backed implementation (:class:`RedisQueueSource`) is the
only production adapter; the Protocol exists so tests/future backends
can swap it without inheritance.
"""
def claim(self) -> str | None:
"""Atomically move one raw message queue -> processing; ``None`` when empty."""
... # pragma: no cover - protocol method body is not executable
def ack(self, raw: str) -> None:
"""Remove ``raw`` from the processing list after a successful write."""
... # pragma: no cover - protocol method body is not executable
def requeue_processing(self) -> int:
"""Crash recovery: drain the processing list back onto the queue.
Returns:
The number of messages requeued.
"""
... # pragma: no cover - protocol method body is not executable
def quarantine(self, raw: str, reason: str) -> None:
"""Park an invalid/legacy message on the quarantine list."""
... # pragma: no cover - protocol method body is not executable
def depth(self) -> int:
"""Pending message count on the main queue."""
... # pragma: no cover - protocol method body is not executable
class RedisQueueSource:
"""Real Redis reliable-queue implementation of :class:`QueueSource`.
Replaces the original plugin's bare ``LPOP`` loop (REQ-021-062) with an
``LMOVE queue -> processing`` claim: a crash between claim and graph
write no longer loses the turn — it sits in ``processing`` until the
next :meth:`requeue_processing` call puts it back on the main queue for
redelivery.
"""
def __init__(
self,
redis_client: redis.Redis,
*,
queue_key: str = "orchestrata:conversation:queue",
tracer: Tracer | None = None,
) -> None:
"""
Args:
redis_client: An already-constructed sync Redis client (DI — no
env reads happen in this class; the boot seam builds the
client via ``core.redis_factory.create_sync_redis``).
queue_key: The main queue key. ``processing``/``quarantine`` keys
are derived from it (stripping a trailing ``:queue`` suffix
when present, else appending directly) so a test-scoped
``queue_key`` gets its own isolated processing/quarantine
lists too.
tracer: Injected :class:`Tracer`; defaults to the process-wide
singleton.
"""
self._redis = redis_client
self._queue_key = queue_key
base = (
queue_key[: -len(":queue")] if queue_key.endswith(":queue") else queue_key
)
self._processing_key = f"{base}:processing"
self._quarantine_key = f"{base}:quarantine"
self._tracer = tracer if tracer is not None else Tracer.default()
@traces(spec="021", story="US-021-007", reqs=["REQ-021-062"])
def claim(self) -> str | None:
with self._tracer.span("RedisQueueSource.claim"):
result = self._redis.lmove(
self._queue_key, self._processing_key, "RIGHT", "LEFT"
)
return cast("str | None", result)
@traces(spec="021", story="US-021-007", reqs=["REQ-021-062"])
def ack(self, raw: str) -> None:
with self._tracer.span("RedisQueueSource.ack"):
self._redis.lrem(self._processing_key, 1, raw)
@traces(spec="021", story="US-021-007", reqs=["REQ-021-062"])
def requeue_processing(self) -> int:
with self._tracer.span("RedisQueueSource.requeue_processing"):
count = 0
while True:
moved = cast(
"str | None",
self._redis.lmove(
self._processing_key, self._queue_key, "RIGHT", "LEFT"
),
)
if moved is None:
break
count += 1
return count
@traces(spec="021", story="US-021-007", reqs=["REQ-021-063"])
def quarantine(self, raw: str, reason: str) -> None:
with self._tracer.span("RedisQueueSource.quarantine"):
_logger.warning("quarantining conversation message: %s", reason)
self._redis.rpush(self._quarantine_key, raw)
@traces(spec="021", story="US-021-007", reqs=["REQ-021-060"])
def depth(self) -> int:
with self._tracer.span("RedisQueueSource.depth"):
return cast(int, self._redis.llen(self._queue_key))
class TurnGraphMapper:
"""Pure ``ConversationTurnMessage -> [(cypher, params), ...]`` mapper.
Emits the ordered MERGE statements of
``consumer_mapping_table`` (contract JSON) — no I/O. Every identity
write is MERGE, never CREATE; endpoints of ``TRIGGERED`` (one per
``tool_call_ids`` entry) are appended last and skipped entirely when the
message carries none.
"""
def __init__(self, tracer: Tracer | None = None) -> None:
self._tracer = tracer if tracer is not None else Tracer.default()
@traces(
spec="021",
story="US-021-007",
reqs=["REQ-021-065", "REQ-021-066", "REQ-021-067"],
)
def to_statements(
self, msg: ConversationTurnMessage
) -> list[tuple[str, dict[str, Any]]]:
with self._tracer.span("TurnGraphMapper.to_statements"):
conv_id = cs.conversation_entity_id(msg.project, msg.conversation_id)
turn_qid = cs.conversation_turn_entity_id(msg.project, msg.turn_id)
# D4 (contract): session-CLI Session.session_id VALUE shape,
# settled here via the graph_audit lazy-MERGE precedent.
session_qid = f"{msg.project}:session:{msg.session_seq}"
statements: list[tuple[str, dict[str, Any]]] = [
(
"MERGE (p:Project {slug: $project})",
{"project": msg.project},
),
(
"MERGE (c:Conversation {id: $conv_id}) "
"ON CREATE SET c.natural_id=$conv_natural, c.project=$project, "
"c.session_seq=$session_seq, c.platform=$platform, "
"c.started_at=$ts "
"SET c.last_turn_at=$ts",
{
"conv_id": conv_id,
"conv_natural": msg.conversation_id,
"project": msg.project,
"session_seq": msg.session_seq,
"platform": msg.platform,
"ts": msg.timestamp,
},
),
(
f"MATCH (p:Project {{slug: $project}}) "
f"MATCH (c:Conversation {{id: $conv_id}}) "
f"MERGE (p)-[:{cs.REL_HAS_CONVERSATION}]->(c)",
{"project": msg.project, "conv_id": conv_id},
),
(
"MERGE (s:Session {session_id: $session_qid}) "
"ON CREATE SET s.seq=$session_seq, s.project=$project "
"WITH s MATCH (c:Conversation {id: $conv_id}) "
"MERGE (c)-[:IN_SESSION]->(s)",
{
"session_qid": session_qid,
"session_seq": msg.session_seq,
"project": msg.project,
"conv_id": conv_id,
},
),
(
"MERGE (t:ConversationTurn {id: $turn_qid}) "
"ON CREATE SET t.turn_id=$turn_id, t.turn_seq=$turn_seq, "
"t.role=$role, t.content=$content, t.timestamp=$ts, "
"t.platform=$platform, t.project=$project "
f"WITH t MATCH (c:Conversation {{id: $conv_id}}) "
f"MERGE (c)-[:{cs.REL_HAS_TURN}]->(t)",
{
"turn_qid": turn_qid,
"turn_id": msg.turn_id,
"turn_seq": msg.turn_seq,
"role": msg.role,
"content": msg.content,
"ts": msg.timestamp,
"platform": msg.platform,
"project": msg.project,
"conv_id": conv_id,
},
),
]
for index, tc_id in enumerate(msg.tool_call_ids):
# `tool_name` (not `name`) is the property ToolCall nodes
# written by the kernel already carry, so conversation-sourced
# calls stay queryable by the same shape. SET, not ON CREATE
# SET: a ToolCall may be MERGEd first by the kernel path with
# no name, and arriving late with the name should fill it in.
statements.append(
(
"MERGE (tc:ToolCall {id: $tc_id}) "
"SET tc.tool_call_id=$tc_id"
+ (", tc.tool_name=$tool_name " if msg.tool_names else " ")
+ "WITH tc MATCH (t:ConversationTurn {id: $turn_qid}) "
"MERGE (t)-[:TRIGGERED]->(tc)",
{
"tc_id": tc_id,
"turn_qid": turn_qid,
**(
{"tool_name": msg.tool_names[index]}
if msg.tool_names
else {}
),
},
)
)
return statements
@traces(spec="021", story="US-021-007", reqs=["REQ-021-217"])
@dataclass(frozen=True)
class FlushReport:
"""Result of one :meth:`ConversationQueueConsumer.flush` call."""
depth_before: int
synced: int = 0
quarantined: int = 0
failed: int = 0
@traces(spec="021", story="US-021-007", reqs=["REQ-021-217"])
def to_summary(self) -> str:
"""The ``conv_sync`` tool's output line."""
return (
f"Flushed {self.synced} conversation turns to graph "
f"({self.quarantined} quarantined, {self.failed} failed)"
)
class ConversationQueueConsumer:
"""Drains the conversation queue into the graph — the SPEC-021 consumer.
Composes a :class:`QueueSource`, a ``SyncGraphRepository``, and a
:class:`TurnGraphMapper` (pure DI — no env/infra construction inside,
REQ-021-069). :meth:`flush` never raises (REQ-021-070): a graph-write
failure leaves the claimed message in ``processing`` for the next
``flush()`` to requeue and safely re-MERGE.
"""
def __init__(
self,
source: QueueSource,
repo: SyncGraphRepository,
mapper: TurnGraphMapper | None = None,
tracer: Tracer | None = None,
) -> None:
self._source = source
self._repo = repo
self._tracer = tracer if tracer is not None else Tracer.default()
self._mapper = mapper if mapper is not None else TurnGraphMapper(self._tracer)
@traces(
spec="021",
story="US-021-007",
reqs=["REQ-021-061", "REQ-021-062", "REQ-021-063", "REQ-021-070"],
)
def flush(self) -> FlushReport:
"""Requeue any orphaned in-flight messages, then drain the queue.
Loop: claim -> parse -> map -> write each statement -> ack.
``ConversationMessageError`` -> quarantine + ack (never retried,
never lost). A graph-write exception leaves the message claimed in
``processing`` (retried by the next call's requeue) and is counted
as ``failed`` — this method itself never raises.
"""
with self._tracer.span("ConversationQueueConsumer.flush"):
self._source.requeue_processing()
depth_before = self._source.depth()
synced = 0
quarantined = 0
failed = 0
while True:
raw = self._source.claim()
if raw is None:
break
try:
msg = ConversationTurnMessage.parse(raw)
except ConversationMessageError as exc:
self._source.quarantine(raw, exc.reason)
self._source.ack(raw)
quarantined += 1
continue
try:
for cypher, params in self._mapper.to_statements(msg):
self._repo.execute_write(cypher, params)
except Exception as exc:
_logger.error(
"conversation graph write failed (left in processing "
"for next flush): %s",
exc,
)
failed += 1
continue
self._source.ack(raw)
synced += 1
report = FlushReport(
depth_before=depth_before,
synced=synced,
quarantined=quarantined,
failed=failed,
)
if depth_before or synced or quarantined or failed:
_logger.info(
"conversation flush: depth_before=%d synced=%d "
"quarantined=%d failed=%d",
depth_before,
synced,
quarantined,
failed,
)
return report
@traces(spec="021", story="US-021-007", reqs=["REQ-021-060", "REQ-021-216"])
def depth(self) -> int:
"""Pending message count (delegates to the source)."""
return self._source.depth()