fix(gateway): crash recovery never redelivers a suppressed reply; tz-safe turn marker

Review follow-ups on the crash-left reply adoption:

- A persisted reply is now judged the way live delivery would have judged
  it. A bare silence marker ([SILENT] / SILENT / NO_REPLY ...) on an
  internal turn, or the reply to a diagnostic wake whose chat policy mutes
  diagnostics (read in the routed profile's scope, as the adapter does), is
  owed nothing: the marker is cleared and nothing is sent or resumed. A
  human turn's bare silence marker becomes the same "returned only a silence
  marker" notice the live path sends. Before, the raw "NO_REPLY" reached the
  user as a "Recovered reply".
- The active-turn marker start is written as aware UTC and compared as
  epoch seconds (startup adoption and recover_interrupted_turns). A naive
  local wall clock read by a process in another zone (DST, container vs
  unit TZ) was hours off: a fresh in-flight turn was dropped as stale, or a
  previous turn's reply could be adopted as this one's. updated_at stays
  naive local for the older binary's recency heuristic; a pre-upgrade naive
  marker still reads as local time.
- Reply timestamps go through coerce_epoch instead of float(), so one odd
  transcript row cannot abort the whole recovery pass.
This commit is contained in:
teknium1
2026-09-23 17:23:28 +00:00
committed by Teknium
parent 360b9697ac
commit 1136f135dd
4 changed files with 143 additions and 43 deletions

View File

@@ -14,7 +14,7 @@ import logging
import os
import signal
import time
from contextlib import suppress
from contextlib import nullcontext, suppress
from contextvars import copy_context
from datetime import datetime
from pathlib import Path
@@ -723,14 +723,12 @@ class GatewayStartupMixin:
return resumed, ledgered
async def _ledger_crash_left_replies(self, max_age_seconds: int) -> int:
"""Hand every marked turn whose final reply was persisted to the delivery ledger and clear
its marker, so the boot sweep delivers the stored reply instead of auto-resume
regenerating it. Without the ledger such turns stay marked and resume."""
"""Settle every marked turn whose final reply was persisted and clear its marker, so
auto-resume does not regenerate it: a reply live delivery would have suppressed is owed
nothing, any other goes to the delivery ledger for the boot sweep. Without the ledger a
presentable reply stays marked and resumes."""
from gateway.delivery_ledger import compute_obligation_id, ledger_enabled, record_crash_left_reply
from gateway.platforms.base import _strip_media_directives
from gateway.run import _sanitize_gateway_final_response
if not await asyncio.to_thread(ledger_enabled):
return 0
ledger_on = await asyncio.to_thread(ledger_enabled)
cutoff = time.time() - max_age_seconds # older markers are cleared, never acted on
with self.session_store._lock: # noqa: SLF001 — snapshot under lock
self.session_store._ensure_loaded_locked() # noqa: SLF001
@@ -742,27 +740,54 @@ class GatewayStartupMixin:
]
ledgered = 0
for key, session_id, token, started_at, origin, profile in marked:
history = await self.async_session_store.load_transcript(session_id)
last = next((m for m in reversed(history) if m.get("role") not in ("session_meta", "system")), None)
if (started_at.timestamp() < cutoff or not last or last.get("role") != "assistant"
or last.get("tool_calls")
or not isinstance(last.get("content"), str)
or float(last.get("timestamp") or 0) < started_at.timestamp()):
continue # the turn never produced its final reply: it resumes
text = _strip_media_directives(
_sanitize_gateway_final_response(origin.platform, last["content"])).strip()
if not text:
started = started_at.timestamp() # aware UTC marker; a pre-upgrade naive one reads as local
if started < cutoff:
continue
await asyncio.to_thread(
record_crash_left_reply,
obligation_id=compute_obligation_id(key, f"crash:{token}", text), session_key=key,
platform=str(getattr(origin.platform, "value", origin.platform)), chat_id=origin.chat_id,
thread_id=origin.thread_id, content=text, since=started_at.timestamp(),
adapter_profile=profile)
if await self.async_session_store.clear_turn_active(key, token):
text = self._crash_left_reply(await self.async_session_store.load_transcript(session_id),
started, origin)
if text is None or (text and not ledger_on):
continue # no final reply to deliver: the turn resumes
if text:
await asyncio.to_thread(
record_crash_left_reply,
obligation_id=compute_obligation_id(key, f"crash:{token}", text), session_key=key,
platform=str(getattr(origin.platform, "value", origin.platform)), chat_id=origin.chat_id,
thread_id=origin.thread_id, content=text, since=started, adapter_profile=profile)
if await self.async_session_store.clear_turn_active(key, token) and text:
ledgered += 1
return ledgered
def _crash_left_reply(self, history: list, started: float, origin) -> Optional[str]:
"""What a crash-left turn owes, judged as live delivery would have: ``None`` when it never
persisted a final reply after *started*; ``""`` when nothing would have been presented (a
silence marker on a machinery turn, a muted diagnostic wake); else the text to send, with a
human turn's bare silence marker replaced by the same notice the live path sends."""
from gateway.platforms.base import _strip_media_directives
from gateway.response_filters import is_intentional_silence_response, is_machinery_display_kind
from gateway.run import _sanitize_gateway_final_response
from gateway.run_turn import _UNEXPECTED_SILENCE_REPLY
from gateway.warning_notifications import diagnostic_turn_muted
from hermes_cli.timefmt import coerce_epoch
visible = [m for m in history if m.get("role") not in ("session_meta", "system")]
last = visible[-1] if visible else {}
if (last.get("role") != "assistant" or last.get("tool_calls") or not isinstance(last.get("content"), str)
or (coerce_epoch(last.get("timestamp")) or 0) < started):
return None
prompt = next((m for m in reversed(visible) if m.get("role") == "user"), {})
machinery = is_machinery_display_kind(prompt.get("display_kind"))
if machinery:
try: # the owning profile's display policy, as the adapter reads it at delivery
scope = self._media_delivery_scope_for_source(origin)
except Exception:
logger.debug("Crash-left reply: no routed scope for %s", origin.chat_id, exc_info=True)
scope = nullcontext()
with scope:
if diagnostic_turn_muted(prompt.get("display_metadata"), origin.platform):
return ""
if is_intentional_silence_response(last["content"]):
return "" if machinery else _UNEXPECTED_SILENCE_REPLY
return _strip_media_directives(_sanitize_gateway_final_response(origin.platform, last["content"])).strip() or None
@staticmethod
def _start_hosted_room_worker_sync():
"""Start the local Group Chat worker without importing the dashboard."""

View File

@@ -4,8 +4,9 @@ from __future__ import annotations
import logging
import os
import time
import uuid
from datetime import datetime, timedelta
from datetime import datetime, timedelta, timezone
from typing import TYPE_CHECKING, Optional
from hermes_state_ids import new_session_id
@@ -117,15 +118,16 @@ class SessionLifecycleMixin:
candidate = entry.to_dict()
candidate["active_turn_token"] = token
candidate["active_turn_started_at"] = _iso(started_at)
if started_at is not None:
touched = _now() if started_at is not None else None
if touched is not None:
# Keeps the legacy 120s startup heuristic working for an older binary during a rolling
# downgrade/upgrade window.
candidate["updated_at"] = started_at.isoformat()
candidate["updated_at"] = touched.isoformat()
self._save_entry(session_key, entry_data=candidate, lock_held=True)
entry.active_turn_token = token
entry.active_turn_started_at = started_at
if started_at is not None:
entry.updated_at = started_at
if touched is not None:
entry.updated_at = touched
def mark_turn_active(self, session_key: str) -> Optional[str]:
"""Persist exact ownership of the running agent turn; returns the opaque token for
@@ -136,7 +138,9 @@ class SessionLifecycleMixin:
entry = self._entry_locked(session_key)
if entry is None:
return None
self._set_turn_marker_locked(session_key, entry, token, _now())
# Aware UTC, unlike the local wall clock elsewhere: the next process compares it with
# epoch transcript timestamps and may run in another zone (DST, container vs unit TZ).
self._set_turn_marker_locked(session_key, entry, token, datetime.now(timezone.utc))
return token
def clear_turn_active(self, session_key: str, token: str) -> bool:
@@ -153,8 +157,7 @@ class SessionLifecycleMixin:
"""Promote crash-left turn markers into ``resume_pending`` (unclean startup only).
Old/invalid markers are cleared without resuming; suspended sessions are never re-armed.
Returns the number of newly promoted sessions."""
now = _now()
max_age = timedelta(seconds=max(0, max_age_seconds))
now, epoch_now = _now(), time.time()
promoted = 0
def _promote(entry: SessionEntry) -> bool:
@@ -162,13 +165,10 @@ class SessionLifecycleMixin:
if not entry.active_turn_token:
return False
started_at = entry.active_turn_started_at
try:
marker_is_stale = started_at is None or (
max_age_seconds > 0 and now - started_at > max_age
)
except TypeError:
# Mixed aware/naive timestamps: clear rather than risk an unsafe old resume.
marker_is_stale = True
# Epoch arithmetic: a pre-upgrade naive marker reads as local time, an aware one exactly.
marker_is_stale = started_at is None or (
max_age_seconds > 0 and epoch_now - started_at.timestamp() > max_age_seconds
)
if not marker_is_stale and not entry.suspended:
if entry.resume_pending:
# A drain-timeout marker is more specific; keep it.