diff --git a/services/hermes/deployment.yaml b/services/hermes/deployment.yaml index 2dc71e668..288d07486 100644 --- a/services/hermes/deployment.yaml +++ b/services/hermes/deployment.yaml @@ -224,6 +224,8 @@ spec: /opt/hermes/.venv/bin/python /opt/coordinator/migrate_api_session_lineage.py env: - {name: HERMES_API_DEFAULT_PARENT_SESSION_ID, value: automated-triage} + - name: HERMES_API_DEFAULT_PARENT_MATCH_PREFIXES + value: "A static-analysis finding, not a build failure.||Use $triage-titan-test-failures." securityContext: allowPrivilegeEscalation: false runAsUser: 10000 @@ -309,8 +311,8 @@ spec: value: triage - name: HERMES_API_DEFAULT_PARENT_SESSION_ID value: automated-triage - - name: HERMES_API_DEFAULT_PARENT_MATCH_PREFIX - value: A static-analysis finding, not a build failure. + - name: HERMES_API_DEFAULT_PARENT_MATCH_PREFIXES + value: "A static-analysis finding, not a build failure.||Use $triage-titan-test-failures." # Claude subscription OAuth token (sk-ant-oat01...). The anthropic # provider accepts ANTHROPIC_API_KEY, ANTHROPIC_TOKEN, or this, in # that order; an OAuth token is not an API key, so it must arrive diff --git a/services/hermes/scripts/migrate_api_session_lineage.py b/services/hermes/scripts/migrate_api_session_lineage.py index 4fe8ff7d6..d40538b0c 100644 --- a/services/hermes/scripts/migrate_api_session_lineage.py +++ b/services/hermes/scripts/migrate_api_session_lineage.py @@ -27,12 +27,15 @@ LEGACY_ORPHANED_SMOKE_SESSIONS = { } TRIAGE_PARENT = "automated-triage" TRIAGE_PARENT_TITLE = "Automated triage" -TRIAGE_MESSAGE_PREFIX = "A static-analysis finding, not a build failure." +TRIAGE_MESSAGE_PREFIXES = ( + "A static-analysis finding, not a build failure.", + "Use $triage-titan-test-failures.", +) def _triage_title(message: str, session_id: str) -> str: """Create a concise label from Ariadne's stable incident contract.""" - match = re.search(r"for incident ([^\s]+)\.", message) + match = re.search(r"(?:for|Analyze) incident ([^\s.]+)(?:\.|\s)", message) if not match: return f"Automated triage run · {session_id[-8:]}" parts = match.group(1).split("/") @@ -40,6 +43,11 @@ def _triage_title(message: str, session_id: str) -> str: return f"Sonar · {label} · {session_id[-8:]}" +def _is_automated_triage(message: str) -> bool: + """Recognize only the stable Ariadne automation contracts.""" + return message.startswith(TRIAGE_MESSAGE_PREFIXES) + + def migrate_triage_group(connection: sqlite3.Connection) -> int: """Nest only recognized automated Sonar runs under one durable parent.""" rows = connection.execute( @@ -51,11 +59,10 @@ def migrate_triage_group(connection: sqlite3.Connection) -> int: AND s.parent_session_id IS NULL AND s.archived = 0 AND m.role = 'user' - AND m.content LIKE ? ORDER BY s.started_at - """, - (f"{TRIAGE_MESSAGE_PREFIX}%",), + """ ).fetchall() + rows = [row for row in rows if _is_automated_triage(str(row[2] or ""))] if not rows: return 0 started_at = min(float(row[1] or time.time()) for row in rows) diff --git a/services/hermes/scripts/patch_api_server_sessions.py b/services/hermes/scripts/patch_api_server_sessions.py index fefb6eace..b6d3af08f 100644 --- a/services/hermes/scripts/patch_api_server_sessions.py +++ b/services/hermes/scripts/patch_api_server_sessions.py @@ -65,8 +65,12 @@ RUNS_AFTER = ''' run_id = f"run_{uuid.uuid4().hex}" "X-Hermes-Parent-Session-Id" ) default_parent = os.environ.get("HERMES_API_DEFAULT_PARENT_SESSION_ID", "").strip() - default_prefix = os.environ.get("HERMES_API_DEFAULT_PARENT_MATCH_PREFIX", "").strip() - if not raw_parent and default_parent and default_prefix and user_message.startswith(default_prefix): + default_prefixes = tuple( + item.strip() + for item in os.environ.get("HERMES_API_DEFAULT_PARENT_MATCH_PREFIXES", "").split("||") + if item.strip() + ) + if not raw_parent and default_parent and default_prefixes and user_message.startswith(default_prefixes): raw_parent = default_parent parent_session_id = str(raw_parent).strip() if raw_parent else None @@ -92,7 +96,7 @@ RUNS_AFTER = ''' run_id = f"run_{uuid.uuid4().hex}" system_prompt=instructions if isinstance(instructions, str) else None, parent_session_id=parent_session_id, ) - incident = re.search(r"for incident ([^\\s]+)\\.", user_message) + incident = re.search(r"(?:for|Analyze) incident ([^\\s.]+)(?:\\.|\\s)", user_message) if incident and parent_session_id == default_parent: parts = incident.group(1).split("/") label = " · ".join(parts[1:3]) if len(parts) >= 3 else incident.group(1) diff --git a/testing/tests/test_hermes_chat_quality.py b/testing/tests/test_hermes_chat_quality.py index 475cdf1a5..ceae60776 100644 --- a/testing/tests/test_hermes_chat_quality.py +++ b/testing/tests/test_hermes_chat_quality.py @@ -997,8 +997,8 @@ def test_api_session_patch_accepts_parent_lineage(tmp_path: Path): assert "X-Hermes-Parent-Session-Id" in patched assert "parent_session_id=parent_session_id" in patched assert "Parent session not found" in patched - assert "HERMES_API_DEFAULT_PARENT_MATCH_PREFIX" in patched - assert "user_message.startswith(default_prefix)" in patched + assert "HERMES_API_DEFAULT_PARENT_MATCH_PREFIXES" in patched + assert "user_message.startswith(default_prefixes)" in patched def test_legacy_api_sessions_are_nested_idempotently(tmp_path: Path): @@ -1074,21 +1074,26 @@ def test_automated_triage_sessions_are_grouped_without_touching_interactive_runs ) connection.executemany( "INSERT INTO sessions (id, source, started_at) VALUES (?, 'api_server', ?)", - (("triage-run", 1.0), ("interactive-run", 2.0)), + (("triage-run", 1.0), ("jenkins-run", 1.5), ("interactive-run", 2.0)), ) connection.executemany( "INSERT INTO messages (session_id, role, content) VALUES (?, 'user', ?)", ( ( "triage-run", - module.TRIAGE_MESSAGE_PREFIX + module.TRIAGE_MESSAGE_PREFIXES[0] + " Fix for incident sonar/bstein_home/python:S2208/finding-key.", ), + ( + "jenkins-run", + module.TRIAGE_MESSAGE_PREFIXES[1] + + "\nAnalyze incident soteria/291 for the Jenkins job soteria.", + ), ("interactive-run", "Please explain this alert to me."), ), ) - assert module.migrate(database, group_triage=True) == 1 + assert module.migrate(database, group_triage=True) == 2 assert module.migrate(database, group_triage=True) == 0 with sqlite3.connect(database) as connection: parent = connection.execute( @@ -1100,12 +1105,19 @@ def test_automated_triage_sessions_are_grouped_without_touching_interactive_runs interactive = connection.execute( "SELECT parent_session_id FROM sessions WHERE id = 'interactive-run'" ).fetchone() + jenkins = connection.execute( + "SELECT parent_session_id, title FROM sessions WHERE id = 'jenkins-run'" + ).fetchone() assert parent == (module.TRIAGE_PARENT_TITLE,) assert triage == ( module.TRIAGE_PARENT, "Sonar · bstein_home · python:S2208 · iage-run", ) assert interactive == (None,) + assert jenkins == ( + module.TRIAGE_PARENT, + "Sonar · soteria/291 · kins-run", + ) def test_switchyard_brokers_and_native_claude_lane_use_the_right_images():