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:
LZH-YS1998
2026-07-07 22:59:53 +08:00
parent 3a2c935025
commit 818189a1cc
4 changed files with 365 additions and 0 deletions
+91
View File
@@ -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