From 6fd7b9bbba0dc31f887228e9f56b41ec9394918f Mon Sep 17 00:00:00 2001 From: jenkins Date: Sat, 15 Aug 2026 14:20:12 -0300 Subject: [PATCH] hermes: close stale API worker sessions --- .../scripts/migrate_api_session_lineage.py | 23 +++++++++++ testing/tests/test_hermes_chat_quality.py | 40 +++++++++++++++++++ 2 files changed, 63 insertions(+) diff --git a/services/hermes/scripts/migrate_api_session_lineage.py b/services/hermes/scripts/migrate_api_session_lineage.py index 1e921add..bdb0fb9a 100644 --- a/services/hermes/scripts/migrate_api_session_lineage.py +++ b/services/hermes/scripts/migrate_api_session_lineage.py @@ -37,6 +37,28 @@ TRIAGE_MESSAGE_PREFIXES = ( ) +def close_stale_api_workers(connection: sqlite3.Connection) -> int: + """Close parent-linked API runs that survived a prior gateway lifetime.""" + columns = { + str(row[1]) + for row in connection.execute("PRAGMA table_info(sessions)").fetchall() + } + if not {"ended_at", "end_reason"}.issubset(columns): + return 0 + cursor = connection.execute( + """ + UPDATE sessions + SET ended_at = ?, + end_reason = 'api_run_recovered_stale' + WHERE source = 'api_server' + AND parent_session_id IS NOT NULL + AND ended_at IS NULL + """, + (time.time(),), + ) + return cursor.rowcount + + def _triage_title(message: str, session_id: str) -> str: """Create a concise label from Ariadne's stable incident contract.""" match = re.search(r"(?:for|Analyze) incident ([^\s.]+)(?:\.|\s)", message) @@ -98,6 +120,7 @@ def migrate(path: Path = STATE_DB, *, group_triage: bool = False) -> int: return 0 changed = 0 with sqlite3.connect(path) as connection: + changed += close_stale_api_workers(connection) parent = connection.execute( "SELECT id FROM sessions WHERE id = ?", (LEGACY_CASSANDRA_PARENT,) ).fetchone() diff --git a/testing/tests/test_hermes_chat_quality.py b/testing/tests/test_hermes_chat_quality.py index 0d96d9c8..16537a1d 100644 --- a/testing/tests/test_hermes_chat_quality.py +++ b/testing/tests/test_hermes_chat_quality.py @@ -1186,6 +1186,46 @@ def test_automated_triage_sessions_are_grouped_without_touching_interactive_runs ) +def test_stale_parent_linked_api_workers_close_on_startup(tmp_path: Path): + """A previous gateway lifetime cannot leave phantom active workers.""" + module_path = HERMES / "scripts" / "migrate_api_session_lineage.py" + spec = importlib.util.spec_from_file_location("close_stale_api_workers", module_path) + assert spec and spec.loader + module = importlib.util.module_from_spec(spec) + spec.loader.exec_module(module) + database = tmp_path / "state.db" + with sqlite3.connect(database) as connection: + connection.execute( + "CREATE TABLE sessions (id TEXT PRIMARY KEY, source TEXT, " + "parent_session_id TEXT, title TEXT, started_at REAL, ended_at REAL, " + "end_reason TEXT, archived INTEGER DEFAULT 0)" + ) + connection.executemany( + "INSERT INTO sessions " + "(id, source, parent_session_id, started_at, ended_at, end_reason) " + "VALUES (?, ?, ?, 1, ?, ?)", + ( + ("stale", "api_server", "parent", None, None), + ("root", "api_server", None, None, None), + ("finished", "api_server", "parent", 2.0, "api_run_completed"), + ("interactive", "tui", "parent", None, None), + ), + ) + + assert module.migrate(database) == 1 + assert module.migrate(database) == 0 + with sqlite3.connect(database) as connection: + rows = connection.execute( + "SELECT id, ended_at, end_reason FROM sessions ORDER BY id" + ).fetchall() + by_id = {row[0]: row[1:] for row in rows} + assert by_id["stale"][0] is not None + assert by_id["stale"][1] == "api_run_recovered_stale" + assert by_id["root"] == (None, None) + assert by_id["finished"] == (2.0, "api_run_completed") + assert by_id["interactive"] == (None, None) + + def test_switchyard_brokers_and_native_claude_lane_use_the_right_images(): """Thin brokers stay small while native Claude runs beside owner auth.""" dockerfile = (ROOT / "dockerfiles" / "Dockerfile.hermes-switchyard-brokers").read_text()