Fix flaky message deletion: exact lane match in outbox history + message_id fallback on delete
CI / Kotlin tests (android host + desktop) (push) Successful in 7m4s
CI / Gateway plugin tests (push) Failing after 8m46s

A flat-lane history (thread_id=None) returned frames from ALL threads, so
auto-threaded messages leaked into the flat lane on restart. Deleting them
from the flat lane then sent thread_id=None, which matched nothing in
outbox.delete_message/message_info (exact lane match) -> removed=0, no
session-store purge, and the messages resurrected from the outbox on the
next app restart.

- history: exact lane match (flat lane shows only flat-lane frames, per
  docs/06 §6.3)
- delete_message / message_info: fall back to the unique message_id (uuid4)
  when the exact lane matches nothing, so deletes with a stale/missing
  thread_id still remove the frames and the purge finds its row
This commit is contained in:
ARIA committed 2026-08-23 11:13:51 +02:00
1 parent ca622d3a39
commit a4e4a4ea63
1 file changed
+39 -9
+39 -9
View File
@@ -35,6 +35,14 @@ _PRUNE_INTERVAL_S = 3600.0
DEFAULT_MAX_ROWS = 5000 DEFAULT_MAX_ROWS = 5000
def _frame_thread_id(frame: dict[str, Any]) -> str | None:
"""A frame's lane: its ``thread_id``, normalized (absent/blank -> None)."""
tid = frame.get("thread_id")
if not isinstance(tid, str) or not tid.strip():
return None
return tid
class Outbox: class Outbox:
"""Persistent outbox under ``get_hermes_home()/"iris"``. """Persistent outbox under ``get_hermes_home()/"iris"``.
@@ -197,7 +205,12 @@ class Outbox:
continue continue
if not isinstance(frame, dict): if not isinstance(frame, dict):
continue continue
if thread_id is not None and frame.get("thread_id") != thread_id: # Exact lane match: a flat-lane history (thread_id=None) must NOT
# include frames that belong to a thread, and vice versa. (The old
# loose filter let auto-threaded messages leak into the flat lane
# on restart, where a delete sent with thread_id=None then matched
# nothing in the outbox and the messages "resurrected" later.)
if _frame_thread_id(frame) != thread_id:
continue continue
ftype = frame.get("type") ftype = frame.get("type")
payload = frame.get("payload") payload = frame.get("payload")
@@ -289,6 +302,11 @@ class Outbox:
standalone ``message`` frame when present, else the ``message.stop`` standalone ``message`` frame when present, else the ``message.stop``
frame of a streamed reply -- or ``None`` when the message is not in the frame of a streamed reply -- or ``None`` when the message is not in the
outbox (e.g. already pruned by retention). outbox (e.g. already pruned by retention).
The lane is matched exactly first; when that finds nothing the lookup
falls back to the ``message_id`` alone (it is a unique uuid4), so a
stale/missing ``thread_id`` on the request still resolves the row.
(``lane=None`` in the scan means "any lane".)
""" """
if not message_id: if not message_id:
return None return None
@@ -296,6 +314,8 @@ class Outbox:
rows = self._conn.execute( rows = self._conn.execute(
"SELECT frame FROM outbox WHERE chat_id = ?", (chat_id,) "SELECT frame FROM outbox WHERE chat_id = ?", (chat_id,)
).fetchall() ).fetchall()
def scan(lane: str | None) -> dict[str, Any] | None:
msg_frame: dict[str, Any] | None = None msg_frame: dict[str, Any] | None = None
stop_frame: dict[str, Any] | None = None stop_frame: dict[str, Any] | None = None
for r in rows: for r in rows:
@@ -305,7 +325,7 @@ class Outbox:
continue continue
if not isinstance(frame, dict): if not isinstance(frame, dict):
continue continue
if thread_id is not None and frame.get("thread_id") != thread_id: if lane is not None and _frame_thread_id(frame) != lane:
continue continue
payload = frame.get("payload") payload = frame.get("payload")
if not isinstance(payload, dict) or payload.get("message_id") != message_id: if not isinstance(payload, dict) or payload.get("message_id") != message_id:
@@ -325,6 +345,8 @@ class Outbox:
} }
return msg_frame or stop_frame return msg_frame or stop_frame
return scan(thread_id) or scan(None)
def delete_message( def delete_message(
self, self,
chat_id: str, chat_id: str,
@@ -337,10 +359,13 @@ class Outbox:
``message.update`` / ``message.stop`` / ``media.offer`` / ``message.update`` / ``message.stop`` / ``media.offer`` /
``commentary``); all of them are removed so neither ``history`` nor a ``commentary``); all of them are removed so neither ``history`` nor a
``sync`` replay can resurrect the message. The delete is scoped to the ``sync`` replay can resurrect the message. The delete is scoped to the
exact lane: a flat-lane delete (``thread_id=None``) matches only frames exact lane first: a flat-lane delete (``thread_id=None``) matches only
with no ``thread_id``, and a thread delete matches only that thread's frames with no ``thread_id``, and a thread delete matches only that
frames (a ``message_id`` is unique to one lane, so this is a safety thread's frames. When the exact lane matches nothing, the delete falls
net, not a filter that drops real frames). Returns the number of rows back to the ``message_id`` alone (it is a unique uuid4, so it cannot
hit the wrong message) — this keeps deletes working when the request's
lane is stale or missing (e.g. a message the app cached in the flat
lane that the gateway auto-threaded). Returns the number of rows
removed (0 when the message is not in the outbox — e.g. already pruned removed (0 when the message is not in the outbox — e.g. already pruned
by retention). by retention).
""" """
@@ -350,7 +375,9 @@ class Outbox:
rows = self._conn.execute( rows = self._conn.execute(
"SELECT cursor, frame FROM outbox WHERE chat_id = ?", (chat_id,) "SELECT cursor, frame FROM outbox WHERE chat_id = ?", (chat_id,)
).fetchall() ).fetchall()
cursors: list[int] = []
def cursors_for(lane: str | None) -> list[int]:
out: list[int] = []
for r in rows: for r in rows:
try: try:
frame = json.loads(r["frame"]) frame = json.loads(r["frame"])
@@ -358,11 +385,14 @@ class Outbox:
continue continue
if not isinstance(frame, dict): if not isinstance(frame, dict):
continue continue
if frame.get("thread_id") != thread_id: if lane is not None and _frame_thread_id(frame) != lane:
continue continue
payload = frame.get("payload") payload = frame.get("payload")
if isinstance(payload, dict) and payload.get("message_id") == message_id: if isinstance(payload, dict) and payload.get("message_id") == message_id:
cursors.append(int(r["cursor"])) out.append(int(r["cursor"]))
return out
cursors = cursors_for(thread_id) or cursors_for(None)
if not cursors: if not cursors:
return 0 return 0
# One bound-parameter delete per cursor (a message spans only a few # One bound-parameter delete per cursor (a message spans only a few