Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
b438a89
fix(gateway): replay cap-dropped transcript spool files in drop order…
briandevans Aug 12, 2026
9f77071
fix(gateway): preserve structured transcript fields when recovering s…
briandevans Aug 12, 2026
70488cc
fix(gateway): stop a session's spool replay after the first failed ap…
briandevans Aug 12, 2026
be3f1c8
test(gateway): cover spool replay ordering across a restart
briandevans Aug 12, 2026
19e53d5
test(gateway): cover field fidelity and failure handling in spool rec…
briandevans Aug 12, 2026
c918adf
fix(gateway): fall back to the payload clock only when a timestamp is…
briandevans Aug 12, 2026
a2c0e19
fix(gateway): the live and restart transcript writers share one row b…
jonpol01 Sep 26, 2026
0bd036f
fix(gateway): an out-of-range spool ts or seq no longer aborts recovery
jonpol01 Sep 26, 2026
e2154ae
fix(gateway): catch only read and decode errors when ordering spool f…
unsupportedpastels Oct 6, 2026
36f27f2
fix(gateway): drain boot-held-back spool files before the session's n…
unsupportedpastels Oct 6, 2026
d4d5a8e
fix(gateway): bound decoded payload memory during restart recovery
unsupportedpastels Oct 6, 2026
4d7d3c0
fix(gateway): order shutdown slot heads before same-second overflow
unsupportedpastels Oct 6, 2026
02938cb
fix(gateway): keep live transcript rows queued while older spool file…
unsupportedpastels Oct 6, 2026
7a54b7f
fix(gateway): replay the pending spool before boot resume turns and q…
unsupportedpastels Oct 6, 2026
376d2e1
fix(gateway): keep the spool replay error classifiable when it blocks…
unsupportedpastels Oct 6, 2026
ab8d368
fix(gateway): stop one malformed spooled row from stalling its session
unsupportedpastels Oct 6, 2026
575b4dd
fix(gateway): quarantine only rows sqlite cannot bind
unsupportedpastels Oct 6, 2026
0b9659c
fix(gateway): never replay a written spool row whose file cannot be d…
unsupportedpastels Oct 6, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 0 additions & 36 deletions gateway/run.py
Original file line number Diff line number Diff line change
Expand Up @@ -1651,35 +1651,6 @@ def _multiplex_profile_homes(config: object) -> list[tuple[str, "Path"]]:
return list(profiles_to_serve(multiplex=True))


def _recover_pending_flushes(runner) -> int:
"""Replay every ``pending_messages`` spool this gateway owns into state.db; return the count.

``_get_flush_dir`` follows the active HERMES_HOME, so a routed turn on a multiplexed gateway spools
its stalled transcript backlog under ``profiles/<name>/`` and the runtime drain forgets it on
restart. After the launch home, replay each served profile inside its own home so the default
store ``recover_pending_to_db`` opens is that profile's state.db (#123584).
"""
from gateway.shutdown_flush import recover_pending_to_db
from hermes_constants import get_hermes_home, reset_hermes_home_override, set_hermes_home_override

resolver = runner.session_store.resolve_session_id_for_key
recovered = recover_pending_to_db(session_resolver=resolver)
if not getattr(runner.config, "multiplex_profiles", False):
return recovered
launch_home = Path(get_hermes_home()).resolve()
for name, home in _multiplex_profile_homes(runner.config):
if Path(home).resolve() == launch_home or not (Path(home) / "pending_messages").is_dir():
continue
token = set_hermes_home_override(str(home))
try:
recovered += recover_pending_to_db(session_resolver=resolver)
except Exception: # one profile's unreadable spool must not strand the others'
logger.warning("Pending-message recovery failed for profile %s", name, exc_info=True)
finally:
reset_hermes_home_override(token)
return recovered


def _cron_tick_profile_homes(config: object) -> list[tuple[str, "Path"]]:
"""Profile homes the in-process ticker visits: the served set PLUS the process-active
profile: ``profiles_to_serve`` lists default + every live named profile, but a ``--profile
Expand Down Expand Up @@ -5968,13 +5939,6 @@ def _start_keepalive() -> None:
if not success:
_shutdown_gateway_health_export(runner)
return False

def _recover_pending() -> None:
recovered = _recover_pending_flushes(runner)
if recovered:
logger.info("Recovered %d pending message(s) from shutdown flush", recovered)

_best_effort(_recover_pending)
if runner.should_exit_cleanly:
_shutdown_gateway_health_export(runner)
if runner.exit_reason:
Expand Down
6 changes: 6 additions & 0 deletions gateway/run_startup.py
Original file line number Diff line number Diff line change
Expand Up @@ -1508,6 +1508,7 @@ async def _start_post_connect_services(self, connected_count: int) -> None:
async def _start_finish_wiring(self, connected_count: int) -> None:
"""Post-connect wiring: services, boot notifications, startup restore, recovered watchers."""
from gateway.run import _planned_restart_notification_pending, _restart_notification_pending
from gateway.shutdown_flush import recover_gateway_pending
await self._start_post_connect_services(connected_count)
# Let fresh adapters settle before lifecycle sends (helps Discord thread deliveries).
if connected_count > 0:
Expand All @@ -1523,6 +1524,11 @@ async def _start_finish_wiring(self, connected_count: int) -> None:
await self._await_startup_boot_sends(
planned_restart_notification_pending=_planned_restart_notification_pending(),
)
# Replay the previous run's pending spool before resume turns and queued inbound write live
# rows: only this pass tells the store which sessions have spooled rows, and a live row
# written first would get a lower row id than them for good.
with _log_suppressed(logging.WARNING, "Pending-message recovery failed: %s"):
recover_gateway_pending(self)
# Auto-resume restart-interrupted sessions (ledger-answered ones were cleared above); a failed
# auto-resume stays visible on the next user message.
self._schedule_resume_pending_sessions()
Expand Down
124 changes: 91 additions & 33 deletions gateway/session_transcript.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
from __future__ import annotations

import contextlib
import json
import logging
import threading
import time
Expand Down Expand Up @@ -41,6 +42,55 @@ def _spool_dropped(session_id: str, message: Dict[str, Any]):
"reasoning", "reasoning_content", "reasoning_details", "codex_reasoning_items",
"codex_message_items",
)
# Message keys bound straight to a TEXT column. A spooled message is JSON from disk, and a dict or list
# here makes sqlite refuse the whole row.
_BOUND_TEXT_KEYS = ("role", "tool_name", "tool_call_id", "reasoning", "reasoning_content", "platform_message_id")


def _bindable_text(value: Any) -> Any:
"""*value* when sqlite can bind it as is, else its JSON text (``str`` if that fails too). An int
outside sqlite's signed 64-bit range raises OverflowError even for a TEXT column."""
if value is None or isinstance(value, (str, float)) or (
isinstance(value, int) and -2**63 <= value < 2**63):
return value
try:
return json.dumps(value, default=str)
except (TypeError, ValueError):
return str(value)


def transcript_append_kwargs(session_id: str, message: Dict[str, Any], *, fallback_ts: Any = None) -> Dict[str, Any]:
"""``SessionDB.append_message`` kwargs for one transcript row. The live writer and the restart
replay of the transcript spool (gateway/shutdown_flush.py) both build rows here, so a replayed
message lands as the row the live drain would have written. Fields are listed rather than
splatted: a spooled message is arbitrary JSON from disk, and an unexpected key would raise and
abort the replay. *fallback_ts* applies only to a message with no timestamp at all, because a
truthiness test would rewrite epoch 0. Values bound straight to a TEXT column are made bindable
(:func:`_bindable_text`), so one malformed field cannot make the row unwritable."""
is_assistant = message.get("role") == "assistant"
timestamp = message.get("timestamp")
kwargs = {
"session_id": session_id,
"role": message.get("role", "unknown"),
"content": message.get("content"),
"tool_name": message.get("tool_name"),
"tool_calls": message.get("tool_calls"),
"tool_call_id": message.get("tool_call_id"),
**{k: message.get(k) if is_assistant else None for k in _ASSISTANT_ONLY_KEYS},
"platform_message_id": message.get("platform_message_id") or message.get("message_id"),
"observed": bool(message.get("observed")),
"timestamp": fallback_ts if timestamp is None else timestamp,
# Exact bytes sent to the API (prompt-cache-stable replay); must survive every
# persistence path or the next replay diverges.
"api_content": extract_api_content_sidecar(message),
# Presentation typing ("internal_notification" for self-injected async-delegation/background
# notification turns, #82888). DB-only; stripped from provider-bound payloads.
"display_kind": message.get("display_kind"),
"display_metadata": message.get("display_metadata"),
}
for key in _BOUND_TEXT_KEYS:
kwargs[key] = _bindable_text(kwargs[key])
return kwargs


class SessionTranscriptMixin:
Expand Down Expand Up @@ -244,10 +294,15 @@ def _ack_head() -> bool:

# DB write outside the retry lock so other sessions can append.
while True:
# Spooled backlog (cap eviction or a stalled session) is older than ``msg``: replay it
# first so recovery keeps transcript order; a still-dead DB just fails both.
self._drain_spooled_drops(session_id)
spool_exc = None
try:
# Spooled backlog (cap eviction, a stalled session, a boot hold-back) is older than
# ``msg``: replay it first. While any of it stays on disk ``msg`` stays queued, since
# writing it now would give it a lower row id than that backlog for good. The replay's
# own error is raised so the repair/divert handling below still classifies it.
spool_exc = self._drain_spooled_drops(session_id)
if spool_exc is not None:
raise spool_exc
self._append_transcript_message(session_id, msg)
except Exception as exc:
from hermes_state import StateDbCorruptError, StateDbReplacedError
Expand Down Expand Up @@ -289,6 +344,8 @@ def _ack_head() -> bool:
"no unique live child; not retrying", session_id)
return
if self._is_fts_corruption_error(exc) and self._rebuild_fts_once():
if spool_exc is not None:
continue # repaired: drain the older spool again before ``msg``
try:
self._append_transcript_message(session_id, msg)
except Exception as retry_exc:
Expand Down Expand Up @@ -334,33 +391,54 @@ def _spool_stalled_backlog(self, session_id: str, queue_session_id: str) -> int:
break
spooled += 1
if spooled:
self._lazy("_spooled_drop_sessions", set).add(session_id)
with self._transcript_retry_lock:
self._lazy("_spooled_drop_sessions", set).add(session_id)
del pending[:spooled]
if not pending:
self._dirty_transcripts.pop(queue_session_id, None)
return spooled

def _drain_spooled_drops(self, session_id: str) -> None:
"""Replay cap-dropped spooled transcript messages after DB recovery. Best-effort: replay
failures keep the spool files for the next successful flush; nothing here may raise."""
def _drain_spooled_drops(self, session_id: str) -> Exception | None:
"""Replay cap-dropped spooled transcript messages after DB recovery; return the replay
failure while some of them are still on disk, else None. Best-effort: replay failures keep
the spool files for the next successful flush; nothing here may raise."""
spooled_sessions = getattr(self, "_spooled_drop_sessions", None)
if not spooled_sessions or session_id not in spooled_sessions:
return
return None
failures: list[Exception] = []

def replay(message: Dict[str, Any]) -> None:
try:
self._append_transcript_message(session_id, message)
except Exception as exc:
failures.append(exc)
raise

try:
from gateway.shutdown_flush import drain_transcript_spool
# Inside an outage the append that follows logs/escalates the same failure; the
# replay attempt is only the order-preserving probe, so its failure stays at DEBUG.
with self._transcript_retry_lock:
known_failing = bool(self._transcript_append_failures.get(session_id))
_replayed, remaining = drain_transcript_spool(
session_id, lambda message: self._append_transcript_message(session_id, message),
db_known_failing=known_failing,
)
session_id, replay, db_known_failing=known_failing)
if not remaining:
spooled_sessions.discard(session_id)
with self._transcript_retry_lock:
spooled_sessions.discard(session_id)
return None
return failures[-1] if failures else RuntimeError(
f"older spooled transcript rows for {session_id} still pending")
except Exception as exc:
logger.warning("Failed to drain transcript spool for %s: %s", session_id, exc)
return None
Comment on lines 431 to +433

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Old spool row repeats

If a spool row is saved but its file cannot be deleted, _drain_spooled_drops returns None after logging the error. The live writer then saves a newer row while the old file remains. On the next restart, the old row can be saved again, leaving a duplicate out of order. Keep the live row queued when file cleanup fails.

Prompt To Fix With AI
This is a comment left during a code review.
Path: gateway/session_transcript.py
Line: 431-433

Comment:
**Old spool row repeats**

If a spool row is saved but its file cannot be deleted, `_drain_spooled_drops` returns `None` after logging the error. The live writer then saves a newer row while the old file remains. On the next restart, the old row can be saved again, leaving a duplicate out of order. Keep the live row queued when file cleanup fails.

---

For each issue above, determine whether it is valid and should be fixed. If so, fix it directly.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Partly fixed in 0b9659c7887. Within a running process, a spool file whose row was written but could not be deleted is no longer replayed again, and replay carries on with the files after it, so the live row no longer overtakes them. Covered by test_a_replayed_file_that_cannot_be_deleted_is_not_replayed_again and test_boot_recovery_counts_and_never_repeats_a_file_it_could_not_delete.

The restart duplicate you describe remains: nothing on disk records that the row was written, so the next start writes it once more. We are keeping fail-open behaviour rather than holding newer messages back, and the error log names the file to delete by hand. Keeping the live row queued would not prevent the restart duplicate either.


def mark_spooled_drop_sessions(self, session_ids) -> None:
"""Have each session's next transcript write drain the on-disk spool first. Boot recovery marks
the sessions it held back, so their next live row cannot land ahead of those files."""
if not session_ids:
return
with self._transcript_retry_lock:
self._lazy("_spooled_drop_sessions", set).update(session_ids)

def _append_transcript_message(self, session_id: str, message: Dict[str, Any]) -> None:
"""Write one transcript row. Caller handles retry queuing."""
Expand All @@ -370,27 +448,7 @@ def _append_transcript_message(self, session_id: str, message: Dict[str, Any]) -
# into the ambient store.
raise RuntimeError(
f"no owning session store for {session_id}; deferring transcript write")
is_assistant = message.get("role") == "assistant"
_db.append_message(
session_id=session_id,
role=message.get("role", "unknown"),
content=message.get("content"),
tool_name=message.get("tool_name"),
tool_calls=message.get("tool_calls"),
tool_call_id=message.get("tool_call_id"),
**{k: message.get(k) if is_assistant else None for k in _ASSISTANT_ONLY_KEYS},
platform_message_id=(message.get("platform_message_id") or message.get("message_id")),
observed=bool(message.get("observed")),
timestamp=message.get("timestamp"),
# Exact bytes sent to the API (prompt-cache-stable replay); must survive every
# persistence path or the next replay diverges.
api_content=extract_api_content_sidecar(message),
# Presentation typing (e.g. "internal_notification"); DB-only.
# "internal_notification" for self-injected async-delegation/background notification turns,
# #82888). DB-only; stripped from provider-bound payloads.
display_kind=message.get("display_kind"),
display_metadata=message.get("display_metadata"),
)
_db.append_message(**transcript_append_kwargs(session_id, message))

@staticmethod
def _is_fts_corruption_error(exc: Exception) -> bool:
Expand Down
Loading
Loading