"""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}