fix: guarantee engine replies reach the UI channel and stop same-scope duplicate rows
Project 000, 19:21/20:27: the engine recorded its assistant reply in session_messages, but the post-turn transcript sync never surfaced it in the ui_state channel, and nothing reconciled afterwards — the user watched an empty conversation while the reply sat in the DB. And at 19:13 the same reply was persisted twice in one channel under `<id>` and `<id>::<project>::<channel>`. Two fixes: - _ensure_reply_projected: after the transcript sync, if the session's newest persisted top_level_reply row is absent from the chat store (checked by transcript message id, so nothing user-visible is ever duplicated or leaked), insert and broadcast it directly. Reproduced by test: with the sync disabled the reply previously never reached the channel. - backfill_messages: a live insert racing the backfill snapshot now merges into the existing same-scope row (new _merge_into_same_scope_row, also used by the IntegrityError fallback) instead of minting a `::`-scoped alias id in the message's own channel. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
@@ -299,6 +299,55 @@ class ChatStore:
|
||||
return None
|
||||
return await self._message_scope(str(message_id).strip())
|
||||
|
||||
async def _merge_into_same_scope_row(
|
||||
self,
|
||||
message_id: str,
|
||||
*,
|
||||
channel_id: str,
|
||||
project_id: str,
|
||||
candidate: dict[str, Any],
|
||||
) -> dict[str, Any] | None:
|
||||
"""Merge ``candidate`` into an already-persisted row with the same id/scope.
|
||||
|
||||
Returns the merged row when the update happened (or nothing changed), or
|
||||
None when the row could not be loaded. Backfill and the live insert path
|
||||
can race on the same message id; the duplicate must merge in place, never
|
||||
be re-inserted under a scoped alias id in the same channel.
|
||||
"""
|
||||
cursor = await self._db.execute(
|
||||
"SELECT message_id, channel_id, sender, sender_name, content, "
|
||||
"timestamp, reply_to_id, mentions, metadata "
|
||||
"FROM messages WHERE message_id = ? AND channel_id = ? AND project_id = ?",
|
||||
(message_id, channel_id, project_id),
|
||||
)
|
||||
row = await cursor.fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
existing = self._row_to_message_dict(row)
|
||||
merged = self._merge_duplicate_messages(existing, candidate)
|
||||
if self._message_persisted_equal(existing, merged):
|
||||
return merged
|
||||
merged_timestamp = self._message_timestamp(merged) or time.time()
|
||||
await self._db.execute(
|
||||
"UPDATE messages SET sender = ?, sender_name = ?, content = ?, timestamp = ?, "
|
||||
"reply_to_id = ?, mentions = ?, metadata = ? WHERE message_id = ? AND channel_id = ? AND project_id = ?",
|
||||
(
|
||||
merged["sender"],
|
||||
merged["sender_name"],
|
||||
merged["content"],
|
||||
merged_timestamp,
|
||||
merged.get("reply_to_id"),
|
||||
json.dumps(merged.get("mentions", [])),
|
||||
json.dumps(merged.get("metadata", {})),
|
||||
message_id,
|
||||
channel_id,
|
||||
project_id,
|
||||
),
|
||||
)
|
||||
merged["timestamp"] = merged_timestamp
|
||||
merged["created_at"] = merged_timestamp
|
||||
return merged
|
||||
|
||||
async def _allocate_scoped_message_id(
|
||||
self,
|
||||
message_id: str,
|
||||
@@ -1070,6 +1119,20 @@ class ChatStore:
|
||||
continue
|
||||
|
||||
existing_scope = await self._message_scope(mid)
|
||||
if existing_scope == (channel_id, project_id):
|
||||
# The row appeared after our initial snapshot (a live insert
|
||||
# raced this backfill). Merge in place — never re-insert the
|
||||
# same message under a scoped alias id in its own channel.
|
||||
merged = await self._merge_into_same_scope_row(
|
||||
mid,
|
||||
channel_id=channel_id,
|
||||
project_id=project_id,
|
||||
candidate=normalized_message,
|
||||
)
|
||||
if merged is not None:
|
||||
existing_ids.add(mid)
|
||||
existing_messages.append(merged)
|
||||
continue
|
||||
if existing_scope and existing_scope != (channel_id, project_id):
|
||||
metadata = dict(normalized_message.get("metadata", {}) or {})
|
||||
metadata.setdefault("ui_message_id", mid)
|
||||
@@ -1114,6 +1177,16 @@ class ChatStore:
|
||||
),
|
||||
)
|
||||
except sqlite3.IntegrityError:
|
||||
merged = await self._merge_into_same_scope_row(
|
||||
normalized_message["message_id"],
|
||||
channel_id=channel_id,
|
||||
project_id=project_id,
|
||||
candidate=normalized_message,
|
||||
)
|
||||
if merged is not None:
|
||||
existing_ids.add(normalized_message["message_id"])
|
||||
existing_messages.append(merged)
|
||||
continue
|
||||
metadata = dict(normalized_message.get("metadata", {}) or {})
|
||||
metadata.setdefault("ui_message_id", normalized_message["message_id"])
|
||||
normalized_message["metadata"] = metadata
|
||||
|
||||
@@ -3068,6 +3068,91 @@ class WSHandler:
|
||||
},
|
||||
})
|
||||
|
||||
async def _ensure_reply_projected(
|
||||
self,
|
||||
*,
|
||||
channel_id: str,
|
||||
project_id: str,
|
||||
session_id: str | None,
|
||||
engine: Any | None = None,
|
||||
) -> None:
|
||||
"""Last-resort invariant: the session's newest persisted top-level reply
|
||||
must exist in the UI channel once the turn has unwound.
|
||||
|
||||
The transcript sync is the normal projection path; when it is starved,
|
||||
cancelled, or misses the row (project 000, 2026-07-07 19:21/20:27), the
|
||||
engine has replied but the user sees an empty conversation forever.
|
||||
Detection is by the transcript message id, so an already-projected reply
|
||||
(any channel) is never duplicated.
|
||||
"""
|
||||
if not session_id:
|
||||
return
|
||||
runtime_engine = engine or self.engine
|
||||
store = getattr(runtime_engine, "store", None)
|
||||
if not self._store_is_ready(store):
|
||||
return
|
||||
lister = getattr(store, "list_session_messages", None)
|
||||
parts_loader = getattr(store, "list_session_parts", None)
|
||||
if not callable(lister) or not callable(parts_loader):
|
||||
return
|
||||
try:
|
||||
records = await lister(session_id)
|
||||
except Exception:
|
||||
logger.opt(exception=True).debug("reply projection: failed to list session messages")
|
||||
return
|
||||
latest = None
|
||||
for record in reversed(records or []):
|
||||
if str(getattr(record, "role", "") or "").strip().lower() != "assistant":
|
||||
continue
|
||||
metadata = dict(getattr(record, "metadata", {}) or {})
|
||||
if str(metadata.get("kind", "") or "").strip() != "top_level_reply":
|
||||
continue
|
||||
latest = record
|
||||
break
|
||||
if latest is None:
|
||||
return
|
||||
message_id = str(getattr(latest, "message_id", "") or "").strip()
|
||||
if not message_id:
|
||||
return
|
||||
try:
|
||||
if await self.chat_store.message_scope(message_id) is not None:
|
||||
return
|
||||
except Exception:
|
||||
return
|
||||
try:
|
||||
parts = await parts_loader(session_id, message_id)
|
||||
except Exception:
|
||||
logger.opt(exception=True).debug("reply projection: failed to load reply parts")
|
||||
return
|
||||
text = "\n".join(
|
||||
chunk
|
||||
for part in parts or []
|
||||
if str(getattr(part, "part_type", "") or "") == "text"
|
||||
for chunk in [str(dict(getattr(part, "payload", {}) or {}).get("text", "") or "")]
|
||||
if chunk
|
||||
).strip()
|
||||
if not text:
|
||||
return
|
||||
logger.warning(
|
||||
f"Top-level reply {message_id} missing from UI channel {channel_id} after "
|
||||
"transcript sync; projecting it directly"
|
||||
)
|
||||
reply_metadata = dict(getattr(latest, "metadata", {}) or {})
|
||||
reply_metadata.setdefault("kind", "top_level_reply")
|
||||
reply_metadata.setdefault("source", "engine")
|
||||
reply_metadata.setdefault("ui_message_id", message_id)
|
||||
reply_metadata["reply_projection_fallback"] = True
|
||||
msg = await self.chat_store.insert_message(
|
||||
channel_id=channel_id,
|
||||
sender="assistant",
|
||||
sender_name="OPC",
|
||||
content=text,
|
||||
project_id=project_id,
|
||||
metadata=reply_metadata,
|
||||
message_id=message_id,
|
||||
)
|
||||
await self.broadcast({"type": "session_message", "payload": msg})
|
||||
|
||||
async def _sync_task_transcript_messages(
|
||||
self,
|
||||
task_id: str,
|
||||
@@ -8202,6 +8287,12 @@ class WSHandler:
|
||||
engine=engine,
|
||||
latest_assistant_metadata=checkpoint_meta if checkpoint_meta else None,
|
||||
)
|
||||
await self._ensure_reply_projected(
|
||||
channel_id=channel_id,
|
||||
project_id=pid,
|
||||
session_id=session_id or (str(getattr(task, "session_id", "") or "").strip() if task else None),
|
||||
engine=engine,
|
||||
)
|
||||
|
||||
# ── Status: idle only while the engine left the task active ──
|
||||
store = engine.store
|
||||
|
||||
Reference in New Issue
Block a user