hermes-agent/tui_gateway/turn_marker.py
Brooklyn Nicholson 8d8d1d61fe feat(desktop): crash-survivable in-flight turn journal
The renderer's session-state cache is memory-only and the backend's
inflight snapshot dies with the backend process, so nothing survived a
full app or machine death mid-turn: reopening the session showed the
transcript up to the last committed turn and silently dropped everything
the crashed turn had streamed.

While a turn runs, the visible tail (user prompt + streamed assistant
rows, tool calls included) is now journaled to localStorage — throttled
off the delta-flush hot path, bounded (24 entries / 7 days), cleared the
moment the turn settles. Session resume folds the journaled tail back
onto the restored transcript. When the backend also has a live text-only
inflight projection for the same turn, the journal overlays its richer
structure onto that row (longer text wins, base row id kept so live
deltas keep landing) instead of treating it as caught up — the ordering
defect that dropped locally recorded tool progress in the original PR.

Co-authored-by: Omar Baradei <omar@kostudios.io>
2026-07-24 23:31:55 -05:00

159 lines
5.1 KiB
Python

"""Durable interrupted-turn markers for the desktop/TUI auto-continue path.
A running turn's progress lives only in process memory (the agent flushes to
SQLite at turn end, not mid-turn), so an app/backend/machine death mid-turn
leaves no durable trace of the interrupted prompt. This sidecar is that
trace: a marker is written when a turn starts running and cleared when the
turn concludes — success, handled error, or interrupt all clear it, so only
a process death leaves one behind. ``session.resume`` reads the marker to
decide whether to auto-continue the interrupted turn (see
``_maybe_schedule_auto_continue`` in ``tui_gateway/server.py``).
Markers are stored per ``HERMES_HOME`` (callers pass the session's home so
profile sessions keep their state in their own profile directory) and the
file is bounded: writes prune entries older than ``_MAX_AGE_SECS`` and cap
the total count, so an unlucky streak of crashes can't grow it unboundedly.
Every function is best-effort by design — marker bookkeeping must never
break a turn — so I/O errors degrade to "no marker" instead of raising.
"""
from __future__ import annotations
import json
import logging
import os
import tempfile
import threading
import time
from pathlib import Path
from typing import Any
logger = logging.getLogger(__name__)
_MARKER_DIR = "desktop"
_MARKER_FILE = "interrupted_turns.json"
_MAX_AGE_SECS = 24 * 3600
_MAX_ENTRIES = 32
# Enough to re-submit any realistic prompt; guards the sidecar against a
# pathological multi-megabyte paste being journaled on every turn.
_MAX_PROMPT_CHARS = 64_000
_lock = threading.Lock()
def _marker_path(home: Path | str) -> Path:
return Path(home) / _MARKER_DIR / _MARKER_FILE
def _load(path: Path) -> dict[str, dict]:
try:
with open(path, encoding="utf-8") as f:
data = json.load(f)
except FileNotFoundError:
return {}
except Exception:
logger.debug("unreadable turn-marker file %s; starting fresh", path, exc_info=True)
return {}
if not isinstance(data, dict):
return {}
return {k: v for k, v in data.items() if isinstance(v, dict)}
def _prune(entries: dict[str, dict], now: float) -> dict[str, dict]:
fresh = {
key: entry
for key, entry in entries.items()
if now - float(entry.get("started_at") or 0) <= _MAX_AGE_SECS
}
if len(fresh) <= _MAX_ENTRIES:
return fresh
newest = sorted(
fresh.items(),
key=lambda item: float(item[1].get("started_at") or 0),
reverse=True,
)[:_MAX_ENTRIES]
return dict(newest)
def _store(path: Path, entries: dict[str, dict]) -> None:
if not entries:
path.unlink(missing_ok=True)
return
path.parent.mkdir(parents=True, exist_ok=True)
fd, tmp = tempfile.mkstemp(dir=path.parent, prefix=".turn-marker-")
try:
with os.fdopen(fd, "w", encoding="utf-8") as f:
json.dump(entries, f)
os.replace(tmp, path)
except Exception:
try:
os.unlink(tmp)
except OSError:
pass
raise
def record_turn_start(
home: Path | str, session_key: str, prompt: str, *, attempts: int = 0
) -> None:
"""Persist the marker for a turn that is about to run.
``attempts`` counts how many auto-continues led to this run: 0 for a
user-initiated turn, N for the Nth automatic re-run — the crash-loop
breaker reads it back on the next resume.
"""
if not session_key or not prompt:
return
now = time.time()
entry = {
"attempts": max(0, int(attempts)),
"prompt": prompt[:_MAX_PROMPT_CHARS],
"started_at": now,
}
try:
with _lock:
path = _marker_path(home)
entries = _prune(_load(path), now)
entries[session_key] = entry
_store(path, entries)
except Exception:
logger.debug("failed to record turn marker for %s", session_key, exc_info=True)
def clear_turn_marker(home: Path | str, session_key: str) -> None:
"""Remove the marker once its turn concluded (any outcome the client saw)."""
if not session_key:
return
try:
with _lock:
path = _marker_path(home)
entries = _load(path)
if session_key not in entries:
return
del entries[session_key]
_store(path, entries)
except Exception:
logger.debug("failed to clear turn marker for %s", session_key, exc_info=True)
def read_turn_marker(home: Path | str, session_key: str) -> dict[str, Any] | None:
"""The marker left by a turn that never concluded, or None."""
if not session_key:
return None
try:
with _lock:
entry = _load(_marker_path(home)).get(session_key)
except Exception:
return None
if not isinstance(entry, dict):
return None
prompt = str(entry.get("prompt") or "")
if not prompt.strip():
return None
try:
started_at = float(entry.get("started_at") or 0)
attempts = max(0, int(entry.get("attempts") or 0))
except (TypeError, ValueError):
return None
return {"attempts": attempts, "prompt": prompt, "started_at": started_at}