opentui(phase3): launcher integration — HERMES_TUI_ENGINE dual-engine

hermes --tui launches the native OpenTUI engine (Bun) when
HERMES_TUI_ENGINE=opentui (env) or display.tui_engine=opentui (config);
Ink stays the default and the shipping path is untouched.

- _resolve_tui_engine() (env > config > ink); refuses opentui on
  Windows/Termux (no Bun) -> falls back to ink with a notice.
- _make_opentui_argv() -> [bun, src/entry.real.tsx] (no build step).
- _bun_bin() with HERMES_BUN override.
- Branch at top of _make_tui_argv BEFORE _ensure_tui_node (Bun-only host
  must not bootstrap Node).
- Gate _launch_tui NODE_OPTIONS/--max-old-space-size on engine==ink (Bun
  is JSC; the V8 flag errors/ignores).

Verified end-to-end via tmux: real hermes --tui -> Bun -> OpenTUI ->
real Python gateway streamed a real reply. No-flag default still ink.
This commit is contained in:
alt-glitch
2026-06-08 11:11:54 +00:00
parent 24f74eb888
commit 2bd9c9b881
741 changed files with 17733 additions and 79889 deletions
+21 -333
View File
@@ -226,212 +226,6 @@ def _log_wal_fallback_once(db_label: str, exc: Exception) -> None:
exc,
)
# ---------------------------------------------------------------------------
# Malformed-schema recovery
# ---------------------------------------------------------------------------
# A distinct, nastier failure class than a malformed FTS *inverted index*:
# the ``sqlite_master`` schema table itself becomes inconsistent — most
# commonly a DUPLICATE object definition, e.g. two ``CREATE VIRTUAL TABLE
# messages_fts`` rows. SQLite parses the entire schema while preparing the
# FIRST statement on a connection, so on this class *every* statement raises
# before it runs — including ``PRAGMA journal_mode`` (which is why this trips
# in ``apply_wal_with_fallback`` during ``SessionDB.__init__``, long before
# ``_init_schema`` is reached) and even ``PRAGMA integrity_check`` and a plain
# ``DROP TABLE``. The only operations that still work are
# ``PRAGMA writable_schema=ON`` plus direct ``sqlite_master`` surgery.
#
# Symptom users hit (Desktop/Dashboard show "no sessions" while 200+ JSON
# files sit on disk):
# sqlite3.DatabaseError: malformed database schema (messages_fts) -
# table messages_fts already exists
#
# The canonical ``sessions`` / ``messages`` data is intact in these cases —
# only the derived schema is broken — so recovery preserves all transcripts
# and merely rebuilds the FTS layer.
_MALFORMED_SCHEMA_MARKERS = (
"malformed database schema",
"database disk image is malformed",
)
# Process-global guard so auto-repair is attempted at most once per DB path
# per process (prevents repair loops and serialises concurrent web_server /
# gateway opens against the same malformed file).
_repair_attempted_paths: set[str] = set()
_repair_attempt_lock = threading.Lock()
def is_malformed_db_error(exc: BaseException) -> bool:
"""True if *exc* is a SQLite 'malformed schema / disk image' error.
These are the corruption classes where the schema fails to parse, so
targeted ``sqlite_master`` surgery (not an ordinary FTS rebuild) is the
only recovery path.
"""
if not isinstance(exc, sqlite3.DatabaseError):
return False
return any(marker in str(exc).lower() for marker in _MALFORMED_SCHEMA_MARKERS)
def _claim_repair_attempt(db_path: Path) -> bool:
"""Claim the one-shot repair attempt for *db_path* in this process.
Returns True for the first caller, False afterwards. Keeps a malformed
DB from triggering an unbounded repair/reopen loop and stops concurrent
callers from racing surgery on the same file.
"""
key = str(db_path)
with _repair_attempt_lock:
if key in _repair_attempted_paths:
return False
_repair_attempted_paths.add(key)
return True
def _backup_db_file(db_path: Path) -> Optional[Path]:
"""Copy a (possibly malformed) DB file to a timestamped backup beside it.
Raw file copy on purpose: the DB won't open cleanly, so we preserve the
bytes exactly for forensics / manual restore. WAL and SHM sidecars are
copied too when present. Returns the backup path, or None on failure.
"""
import datetime
import shutil
stamp = datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
backup_path = db_path.with_name(f"{db_path.name}.malformed-backup-{stamp}")
try:
shutil.copy2(db_path, backup_path)
for suffix in ("-wal", "-shm"):
sidecar = db_path.with_name(db_path.name + suffix)
if sidecar.exists():
shutil.copy2(sidecar, backup_path.with_name(backup_path.name + suffix))
return backup_path
except Exception as exc: # pragma: no cover - best effort
logger.warning("Could not back up malformed DB %s: %s", db_path, exc)
return None
def _db_opens_cleanly(db_path: Path) -> Optional[str]:
"""Probe a DB on a fresh connection. Returns None if healthy, else a reason.
Runs the same first-statement (``PRAGMA journal_mode``) that trips the
malformed-schema parse, then ``PRAGMA integrity_check`` and a canonical
``sessions`` read.
"""
conn = sqlite3.connect(str(db_path), isolation_level=None)
try:
conn.execute("PRAGMA journal_mode").fetchone()
rows = conn.execute("PRAGMA integrity_check").fetchall()
problems = [str(r[0]) for r in rows if r and str(r[0]).lower() != "ok"]
if problems:
return "; ".join(problems[:3])
conn.execute("SELECT COUNT(*) FROM sessions").fetchone()
return None
except sqlite3.DatabaseError as exc:
return str(exc)
finally:
conn.close()
def repair_state_db_schema(db_path: Path, *, backup: bool = True) -> Dict[str, Any]:
"""Repair a state.db whose ``sqlite_master`` schema is malformed.
Handles the "duplicate object definition" / malformed-schema class where
even ``PRAGMA`` statements fail. Tries least-destructive recovery first
and escalates:
1. **De-duplicate** ``sqlite_master`` (keep the lowest rowid per
``type``/``name``). Fixes the canonical "table X already exists"
case and PRESERVES the existing FTS index intact.
2. **Drop the FTS schema** (every ``messages_fts*`` object) + ``VACUUM``.
The next ``SessionDB()`` open rebuilds the FTS indexes from the
canonical ``messages`` table.
Canonical ``sessions`` / ``messages`` rows are never modified. A
timestamped raw backup is taken first unless ``backup=False``.
Returns a report dict: ``{repaired: bool, strategy: str|None,
backup_path: str|None, error: str|None}``.
"""
report: Dict[str, Any] = {
"repaired": False,
"strategy": None,
"backup_path": None,
"error": None,
}
db_path = Path(db_path)
if not db_path.exists():
report["error"] = f"{db_path} does not exist"
return report
if backup:
bpath = _backup_db_file(db_path)
report["backup_path"] = str(bpath) if bpath else None
# ── Strategy 1: de-duplicate sqlite_master (keeps FTS index) ──
try:
conn = sqlite3.connect(str(db_path), isolation_level=None)
try:
conn.execute("PRAGMA writable_schema=ON")
dupes = conn.execute(
"SELECT type, name, COUNT(*) AS c, MIN(rowid) AS keep "
"FROM sqlite_master GROUP BY type, name HAVING c > 1"
).fetchall()
for type_, name, _count, keep in dupes:
conn.execute(
"DELETE FROM sqlite_master "
"WHERE type IS ? AND name IS ? AND rowid <> ?",
(type_, name, keep),
)
conn.execute("PRAGMA writable_schema=OFF")
conn.commit()
finally:
conn.close()
if _db_opens_cleanly(db_path) is None:
report["repaired"] = True
report["strategy"] = "dedup_schema"
logger.warning(
"state.db schema repaired by de-duplicating sqlite_master "
"(FTS index preserved): %s", db_path
)
return report
except sqlite3.DatabaseError as exc:
logger.warning("state.db dedup repair pass failed: %s", exc)
# ── Strategy 2: drop all FTS schema, VACUUM, rebuild on next open ──
try:
conn = sqlite3.connect(str(db_path), isolation_level=None)
try:
conn.execute("PRAGMA writable_schema=ON")
conn.execute("DELETE FROM sqlite_master WHERE name LIKE 'messages_fts%'")
conn.execute("PRAGMA writable_schema=OFF")
conn.commit()
conn.execute("VACUUM")
finally:
conn.close()
reason = _db_opens_cleanly(db_path)
if reason is None:
report["repaired"] = True
report["strategy"] = "drop_fts_rebuild"
logger.warning(
"state.db schema repaired by dropping FTS schema; indexes "
"will rebuild from messages on next open: %s", db_path
)
return report
report["error"] = reason
except sqlite3.DatabaseError as exc:
report["error"] = str(exc)
if not report["repaired"]:
logger.error(
"state.db schema repair could not recover %s automatically "
"(backup: %s); manual restore from backup may be required.",
db_path, report["backup_path"],
)
return report
SCHEMA_SQL = """
CREATE TABLE IF NOT EXISTS schema_version (
version INTEGER NOT NULL
@@ -611,7 +405,6 @@ class SessionDB:
self._write_count = 0
self._fts_enabled = False
self._fts_unavailable_warned = False
self._conn = None
try:
if read_only:
# Read-only attach for cross-profile aggregation: SELECT-only,
@@ -633,50 +426,23 @@ class SessionDB:
return
self.db_path.parent.mkdir(parents=True, exist_ok=True)
self._conn = sqlite3.connect(
str(self.db_path),
check_same_thread=False,
# Short timeout — application-level retry with random jitter
# handles contention instead of sitting in SQLite's internal
# busy handler for up to 30s.
timeout=1.0,
# auto-starts transactions on DML, which conflicts with our
# explicit BEGIN IMMEDIATE. None = we manage transactions
# ourselves.
isolation_level=None,
)
self._conn.row_factory = sqlite3.Row
apply_wal_with_fallback(self._conn, db_label="state.db")
self._conn.execute("PRAGMA foreign_keys=ON")
def _connect_and_init():
self._conn = sqlite3.connect(
str(self.db_path),
check_same_thread=False,
# Short timeout — application-level retry with random
# jitter handles contention instead of sitting in
# SQLite's internal busy handler for up to 30s.
timeout=1.0,
# auto-starts transactions on DML, which conflicts with
# our explicit BEGIN IMMEDIATE. None = we manage
# transactions ourselves.
isolation_level=None,
)
self._conn.row_factory = sqlite3.Row
apply_wal_with_fallback(self._conn, db_label="state.db")
self._conn.execute("PRAGMA foreign_keys=ON")
self._init_schema()
try:
_connect_and_init()
except sqlite3.DatabaseError as exc:
# The malformed-schema class (e.g. a duplicate sqlite_master
# row for messages_fts) fails on the very first statement —
# before _init_schema can run — so it can't be caught at the
# FTS-rebuild layer. Recover by repairing sqlite_master in
# place (backup first; canonical sessions/messages preserved),
# then reopen once. This is what lets Desktop/Dashboard
# self-heal instead of silently showing "no sessions".
if not is_malformed_db_error(exc) or not _claim_repair_attempt(self.db_path):
raise
logger.error(
"state.db schema is malformed (%s) — attempting automatic "
"repair (a backup copy is made first).", exc,
)
try:
if self._conn is not None:
self._conn.close()
except Exception:
pass
report = repair_state_db_schema(self.db_path)
if not report.get("repaired"):
raise
_connect_and_init()
self._init_schema()
except Exception as exc:
# Capture the cause so /resume and friends can surface WHY the
# session DB is unavailable instead of a bare "Session database
@@ -1715,51 +1481,15 @@ class SessionDB:
"""Archive or unarchive a session.
Archived sessions are hidden from the default session list but keep all
their messages — this is a soft hide, not a delete. For compression
chains, archive the whole logical conversation. Desktop lists compression
roots projected forward to their latest continuation; updating only the
displayed tip lets the still-unarchived root resurrect it on refresh.
Returns True when at least one row was updated.
their messages — this is a soft hide, not a delete. Returns True when a
row was updated.
"""
def _do(conn):
cursor = conn.execute(
"""
WITH RECURSIVE
ancestors(id) AS (
SELECT ?
UNION
SELECT parent.id
FROM ancestors a
JOIN sessions child ON child.id = a.id
JOIN sessions parent ON parent.id = child.parent_session_id
WHERE parent.end_reason = 'compression'
AND child.started_at >= parent.ended_at
),
descendants(id) AS (
SELECT ?
UNION
SELECT child.id
FROM descendants d
JOIN sessions parent ON parent.id = d.id
JOIN sessions child ON child.parent_session_id = parent.id
WHERE parent.end_reason = 'compression'
AND child.started_at >= parent.ended_at
),
lineage(id) AS (
SELECT id FROM ancestors
UNION
SELECT id FROM descendants
)
UPDATE sessions
SET archived = ?
WHERE id IN (SELECT id FROM lineage)
""",
(session_id, session_id, 1 if archived else 0),
"UPDATE sessions SET archived = ? WHERE id = ?",
(1 if archived else 0, session_id),
)
rowcount = cursor.rowcount
if rowcount is None or rowcount < 0:
rowcount = conn.execute("SELECT changes()").fetchone()[0]
return rowcount
return cursor.rowcount
rowcount = self._execute_write(_do)
return rowcount > 0
@@ -3694,48 +3424,6 @@ class SessionDB:
self._remove_session_files(sessions_dir, session_id)
return deleted
def delete_session_if_empty(
self,
session_id: str,
sessions_dir: Optional[Path] = None,
) -> bool:
"""Delete *session_id* only when it never gained resumable content.
A session is considered empty when it has no messages and no
user-assigned title. Used by CLI exit / session-rotation paths so
immediately-started-and-quit sessions don't pile up in ``/resume``
and ``hermes sessions list`` output. (Pattern ported from
google-gemini/gemini-cli#27770.)
The emptiness check and delete run in one transaction, so a message
flushed concurrently by another writer can't be lost. Sessions with
children (delegate subagent runs) are preserved — a parent that
spawned work is not "empty" even if its own transcript never
flushed. Returns True if the session was deleted.
"""
def _do(conn):
cursor = conn.execute(
"""
DELETE FROM sessions
WHERE id = ?
AND title IS NULL
AND NOT EXISTS (
SELECT 1 FROM messages WHERE messages.session_id = sessions.id
)
AND NOT EXISTS (
SELECT 1 FROM sessions child
WHERE child.parent_session_id = sessions.id
)
""",
(session_id,),
)
return cursor.rowcount > 0
deleted = self._execute_write(_do)
if deleted:
self._remove_session_files(sessions_dir, session_id)
return bool(deleted)
def delete_sessions(
self,
session_ids: List[str],