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
+73
View File
@@ -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
+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