diff --git a/agent/insights.py b/agent/insights.py index 2e45b222d4152..1f5b9493046f3 100644 --- a/agent/insights.py +++ b/agent/insights.py @@ -99,6 +99,29 @@ class InsightsEngine: """ self.db = db self._conn = db._conn + # INDEXED BY is a hard dependency (SQLite errors on a missing index). + # A read-only open of a state.db written by an older version skips + # schema init and lacks the partial index — probe once and fall back + # to the unpinned variants (identical rows, optimizer-chosen plan). + try: + self._has_assistant_calls_index = bool( + self._conn.execute( + "SELECT 1 FROM sqlite_master WHERE type='index' AND name=?", + (self._MESSAGES_ASSISTANT_CALLS_INDEX,), + ).fetchone() + ) + except sqlite3.Error: + self._has_assistant_calls_index = False + if not self._has_assistant_calls_index: + _strip = f" INDEXED BY {self._MESSAGES_ASSISTANT_CALLS_INDEX}" + self._GET_TOOL_CALLS_WITH_SOURCE = ( + self._GET_TOOL_CALLS_WITH_SOURCE.replace(_strip, "") + ) + self._GET_TOOL_CALLS_ALL = self._GET_TOOL_CALLS_ALL.replace(_strip, "") + self._GET_SKILL_CALLS_WITH_SOURCE = ( + self._GET_SKILL_CALLS_WITH_SOURCE.replace(_strip, "") + ) + self._GET_SKILL_CALLS_ALL = self._GET_SKILL_CALLS_ALL.replace(_strip, "") def generate(self, days: int = 30, source: str = None) -> Dict[str, Any]: """ @@ -200,11 +223,14 @@ class InsightsEngine: # is deterministic on a freshly initialized state.db (before ANALYZE has # run) for BOTH the unfiltered and source-filtered branches — without the # hint the optimizer falls back to ``idx_messages_session_active`` for the - # source-filtered probe and scans each session's non-tool-call rows. Safe - # because the index is declared in ``SCHEMA_SQL`` (created by every - # read-write ``SessionDB._init_schema``); every ``InsightsEngine`` caller - # opens a read-write ``SessionDB`` — read-only attachments (which skip - # schema init) are never used for insights. + # source-filtered probe and scans each session's non-tool-call rows. + # + # The pin is a HARD dependency: SQLite raises ``no such index`` when the + # named index is absent. That happens in practice — the web dashboard's + # usage analytics open the DB ``read_only=True`` (skipping + # ``_init_schema``), so a state.db created by an older writer has no + # partial index yet. ``__init__`` probes for the index once and falls + # back to the unpinned (still-correct, just optimizer-chosen) variants. _MESSAGES_ASSISTANT_CALLS_INDEX = "idx_messages_assistant_calls_by_session" _GET_TOOL_CALLS_WITH_SOURCE = ( "SELECT m.tool_calls" diff --git a/tests/agent/test_insights.py b/tests/agent/test_insights.py index b638be70df4b5..73bce06f4fb41 100644 --- a/tests/agent/test_insights.py +++ b/tests/agent/test_insights.py @@ -452,16 +452,26 @@ class TestInsightsPopulated: assert any(t["tool_name"] == "search_files" for t in tools_cli) assert isinstance(skills, list) and isinstance(skills_cli, list) - def test_indexed_by_requires_the_index_to_exist(self, populated_db): - """INDEXED BY is a hard dependency: if the index were ever missing the - query fails loudly rather than silently degrading. This documents why - every InsightsEngine caller must open a read-write SessionDB.""" + def test_missing_index_falls_back_to_unpinned_queries(self, populated_db): + """INDEXED BY would be a hard error if the index is missing — which + happens on read-only opens of a state.db written by an older version + (web dashboard analytics). The engine must probe and fall back to the + unpinned variants instead of crashing, returning identical rows.""" + engine_pinned = InsightsEngine(populated_db) + tools_before = engine_pinned._get_tool_usage(0.0) + populated_db._conn.execute(f"DROP INDEX IF EXISTS {self._INDEX}") populated_db._conn.commit() - with pytest.raises(sqlite3.OperationalError, match="no such index"): - populated_db._conn.execute( - InsightsEngine._GET_TOOL_CALLS_ALL, (0.0,) - ).fetchall() + + engine = InsightsEngine(populated_db) + assert engine._has_assistant_calls_index is False + assert "INDEXED BY" not in engine._GET_TOOL_CALLS_ALL + tools_after = engine._get_tool_usage(0.0) + assert sorted(t["tool_name"] for t in tools_after) == sorted( + t["tool_name"] for t in tools_before + ) + # And with the index present, the pin stays. + assert "INDEXED BY" in InsightsEngine._GET_TOOL_CALLS_ALL # =========================================================================