mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-29 18:46:59 +00:00
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>
159 lines
5.1 KiB
Python
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}
|