diff --git a/CHANGELOG.md b/CHANGELOG.md index fe3d732e..2cd65d0b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,7 +5,9 @@ ## [v0.50.251] — 2026-04-30 ### Fixed -- **Sidebar lineage collapse now works for WebUI JSON sessions, not just imported gateway rows** — PR #1358 (v0.50.249) added the client-side lineage-collapse helper but `/api/sessions` only included `_lineage_root_id` for gateway-imported rows. WebUI JSON sessions (the common case) had no grouping key, so cross-surface continuation chains (CLI-close → WebUI continuation, or compression chains within WebUI) still rendered as separate sidebar rows. Now `/api/sessions` reads `parent_session_id` and `end_reason` from `state.db.sessions` for every WebUI session id in the sidebar payload, walks the parent chain when `end_reason in {'compression', 'cli_close'}`, and exposes `_lineage_root_id` + `_compression_segment_count`. Cycle-detected via a `seen` set; depth-bounded to 20 hops to cap pathological data. **Pre-release fix:** swapped the original full-table-scan (`SELECT id, parent_session_id, end_reason FROM sessions`) for a parameterized `WHERE id IN (...)` query that hits PRIMARY KEY + `idx_sessions_parent` — ~50× faster at 1000 rows, scales linearly. **Pre-release fix:** suppressed orphan `parent_session_id` references (parent row pruned/deleted) so the frontend's lineage helper doesn't cluster orphans into never-collapsing single-row groups. (`api/agent_sessions.py`, `api/models.py`, `tests/test_session_lineage_metadata_api.py`, `tests/test_pr1370_lineage_metadata_perf_and_orphan.py`, `tests/test_gateway_sync.py`) @dso2ng — PR #1370 +- **Sidebar lineage collapse now works for WebUI JSON sessions, not just imported gateway rows** — PR #1358 (v0.50.249) added the client-side lineage-collapse helper but `/api/sessions` only included `_lineage_root_id` for gateway-imported rows. WebUI JSON sessions (the common case) had no grouping key, so cross-surface continuation chains (CLI-close → WebUI continuation, or compression chains within WebUI) still rendered as separate sidebar rows. Now `/api/sessions` reads `parent_session_id` and `end_reason` from `state.db.sessions` for every WebUI session id in the sidebar payload, walks the parent chain when `end_reason in {'compression', 'cli_close'}`, and exposes `_lineage_root_id` + `_compression_segment_count`. Cycle-detected via a `seen` set; depth-bounded to 20 hops to cap pathological data. **Pre-release fix:** swapped the original full-table-scan for a parameterized `WHERE id IN (...)` query that hits PRIMARY KEY + `idx_sessions_parent` — ~50× faster at 1000 rows, scales linearly. **Pre-release fix:** chunked IN clause to 500 vars to stay under SQLITE_MAX_VARIABLE_NUMBER on older sqlite (Python 3.9 ships sqlite 3.31 with default limit 999) — without this a power user with 2000+ sessions in the sidebar would hit `OperationalError: too many SQL variables`, the silent except-wrapper would swallow it, and lineage collapse would never work for them. **Pre-release fix:** tightened `parent_session_id` exposure — only emitted when the parent's `end_reason` is `compression` or `cli_close` (not for `user_stop`/etc), since the frontend's `_sessionLineageKey` falls through to `parent_session_id` and would incorrectly collapse two children of a non-continuation parent into a single row. (`api/agent_sessions.py`, `api/models.py`, `tests/test_session_lineage_metadata_api.py`, `tests/test_pr1370_lineage_metadata_perf_and_orphan.py`, `tests/test_gateway_sync.py`) @dso2ng — PR #1370 +- **Manual cron runs persist output and metadata like scheduled runs** — manual WebUI cron runs called `cron.scheduler.run_job(job)` and then only cleared the in-memory running flag. The job's output was dropped (never written via `save_job_output`) and `last_run_at` / `last_status` were never updated. Now the manual-run wrapper (`_run_cron_tracked`) matches the scheduled-cron path at `cron/scheduler.py:1334-1364` exactly: saves output, marks the job complete, treats empty `final_response` as a soft failure (with the same error string), and records failures via `mark_job_run(False, str(e))`. (`api/routes.py`, `tests/test_cron_manual_run_persistence.py`) @NocGeek — PR #1372 (split out from the held #1352 per pre-release feedback) + ## [v0.50.250] — 2026-04-30 diff --git a/api/agent_sessions.py b/api/agent_sessions.py index a6222ff6..b98e5c87 100644 --- a/api/agent_sessions.py +++ b/api/agent_sessions.py @@ -291,6 +291,15 @@ def read_session_lineage_metadata(db_path: Path, session_ids: list[str] | set[st # Fetch the wanted ids first, then chase parent_session_id chains # in batches until no new ids appear. Each batch hits PRIMARY KEY # so it's effectively O(N) lookups. + # + # IN-clause is chunked to 500 to stay under SQLITE_MAX_VARIABLE_NUMBER + # on older sqlite (Python 3.9 ships sqlite 3.31 which defaults to 999; + # newer Python ships sqlite 3.32+ at 32766). On a power user with + # 2000+ sessions in the sidebar, an unchunked first hop would raise + # `OperationalError: too many SQL variables`, get swallowed by the + # except below, and silently disable lineage collapse forever. + # (Opus pre-release review of v0.50.251, SHOULD-FIX 2.) + IN_CHUNK = 500 rows: dict[str, dict] = {} to_fetch = set(wanted) # Cap walk depth to bound worst-case query count. Real lineage @@ -299,15 +308,17 @@ def read_session_lineage_metadata(db_path: Path, session_ids: list[str] | set[st for _hop in range(20): if not to_fetch: break - placeholders = ','.join('?' * len(to_fetch)) fetch_list = list(to_fetch) to_fetch = set() - cur.execute( - f"SELECT id, parent_session_id, end_reason FROM sessions WHERE id IN ({placeholders})", - fetch_list, - ) - for row in cur.fetchall(): - rows[row['id']] = dict(row) + for i in range(0, len(fetch_list), IN_CHUNK): + chunk = fetch_list[i:i + IN_CHUNK] + placeholders = ','.join('?' * len(chunk)) + cur.execute( + f"SELECT id, parent_session_id, end_reason FROM sessions WHERE id IN ({placeholders})", + chunk, + ) + for row in cur.fetchall(): + rows[row['id']] = dict(row) # Queue up parents we haven't fetched yet. for sid in fetch_list: parent_id = rows.get(sid, {}).get('parent_session_id') @@ -323,13 +334,19 @@ def read_session_lineage_metadata(db_path: Path, session_ids: list[str] | set[st continue parent_id = row.get('parent_session_id') - # Only expose parent_session_id when the parent actually exists in - # state.db. Orphan references (parent row was pruned/deleted) used to - # leak through and the frontend would treat them as a sidebar - # grouping key (#1358's _sessionLineageKey falls through to - # parent_session_id when _lineage_root_id is missing). Caught during - # pre-release review of v0.50.251. - if parent_id and parent_id in rows: + # Only expose parent_session_id when: + # 1) the parent actually exists in state.db (orphan refs would + # otherwise leak through and the frontend would treat them as + # sidebar grouping keys via #1358's _sessionLineageKey + # fall-through) + # 2) the parent's end_reason is one of {compression, cli_close} — + # i.e. only TRUE continuations. Without this, two distinct + # WebUI sessions sharing a `user_stop` parent would get + # collapsed into a single sidebar row by #1358's helper + # (it groups by parent_session_id as the third-fallback key). + # (Opus pre-release review of v0.50.251, SHOULD-FIX 1.) + parent_row = rows.get(parent_id) if parent_id else None + if parent_row and parent_row.get('end_reason') in {'compression', 'cli_close'}: metadata.setdefault(sid, {})['parent_session_id'] = parent_id root_id = sid diff --git a/tests/test_pr1370_lineage_metadata_perf_and_orphan.py b/tests/test_pr1370_lineage_metadata_perf_and_orphan.py index e605664f..fd1f20d5 100644 --- a/tests/test_pr1370_lineage_metadata_perf_and_orphan.py +++ b/tests/test_pr1370_lineage_metadata_perf_and_orphan.py @@ -180,10 +180,97 @@ def test_chain_walk_returns_correct_root_for_real_compression_chain(tmp_path): assert result["tip"]["parent_session_id"] == "seg3" +def test_in_clause_chunked_for_large_session_set(tmp_path, monkeypatch): + """The first hop must chunk the IN clause into batches of <= 500. + Without chunking, a power user with 2000+ sessions in the sidebar would + trigger SQLITE_MAX_VARIABLE_NUMBER on Python 3.9's sqlite 3.31 (default + limit 999), the OperationalError gets swallowed by the except wrapper, + and lineage collapse silently disables forever for that user. + (Opus pre-release review of v0.50.251, SHOULD-FIX 2.) + """ + from api import agent_sessions + + db = tmp_path / "state.db" + conn = _make_db(db) + # 1500 unrelated rows + for i in range(1500): + _insert(conn, f"session_{i:04d}") + conn.close() + + # Track each query's IN clause variable count + in_counts = [] + real_connect = sqlite3.connect + + class _TrackingConn: + def __init__(self, *args, **kw): + self._real = real_connect(*args, **kw) + def cursor(self): + return _TrackingCursor(self._real.cursor()) + def __enter__(self): return self + def __exit__(self, *a): return self._real.__exit__(*a) + @property + def row_factory(self): return self._real.row_factory + @row_factory.setter + def row_factory(self, v): self._real.row_factory = v + + class _TrackingCursor: + def __init__(self, real): self._real = real + def execute(self, sql, *args): + if "IN (" in sql: + in_counts.append(sql.count("?")) + return self._real.execute(sql, *args) + def fetchall(self): return self._real.fetchall() + def fetchone(self): return self._real.fetchone() + + monkeypatch.setattr(sqlite3, "connect", _TrackingConn) + + # Request 1500 ids (the full set we inserted) + wanted = [f"session_{i:04d}" for i in range(1500)] + agent_sessions.read_session_lineage_metadata(db, wanted) + + assert in_counts, "Expected at least one IN-clause query" + over_limit = [n for n in in_counts if n > 999] + assert not over_limit, ( + f"IN clause must be chunked to <= 999 vars to stay under SQLite default " + f"limit (chunked to 500 in the implementation). Found queries with " + f"{over_limit} variables — would raise OperationalError on older sqlite." + ) + + +def test_two_children_sharing_non_continuation_parent_not_collapsed(tmp_path): + """The contract Opus flagged: when two distinct WebUI sessions share the + same parent and that parent's end_reason is NOT compression/cli_close + (e.g. 'user_stop'), neither child should expose parent_session_id. + The frontend's #1358 lineage helper would otherwise group them under + the parent's id key and incorrectly collapse the rows. + """ + from api.agent_sessions import read_session_lineage_metadata + + db = tmp_path / "state.db" + conn = _make_db(db) + _insert(conn, "shared_parent", end_reason="user_stop") + _insert(conn, "child_a", parent="shared_parent", end_reason=None) + _insert(conn, "child_b", parent="shared_parent", end_reason=None) + conn.close() + + result = read_session_lineage_metadata(db, ["child_a", "child_b"]) + # Neither child should expose parent_session_id (would let frontend + # group them under the same key and collapse one) + for sid in ["child_a", "child_b"]: + entry = result.get(sid, {}) + assert "parent_session_id" not in entry, ( + f"{sid} leaked parent_session_id despite parent being non-continuation" + ) + + def test_non_compression_parent_does_not_extend_lineage(tmp_path): """If parent's end_reason is something OTHER than 'compression' or 'cli_close' (e.g. 'user_stop', 'session_reset', 'cron_complete'), - the chain stops at that boundary.""" + the chain stops at that boundary AND parent_session_id is not exposed + (tightened in v0.50.251 per Opus SHOULD-FIX 1 — exposing the parent + ref would let the frontend group two children sharing the same + non-continuation parent into a single sidebar row). + """ from api.agent_sessions import read_session_lineage_metadata db = tmp_path / "state.db" @@ -193,8 +280,11 @@ def test_non_compression_parent_does_not_extend_lineage(tmp_path): conn.close() result = read_session_lineage_metadata(db, ["child"]) - # parent_session_id should still be exposed (parent exists) - assert result["child"]["parent_session_id"] == "parent" - # But _lineage_root_id should NOT be set — chain doesn't span the boundary - assert "_lineage_root_id" not in result["child"] - assert "_compression_segment_count" not in result["child"] + # parent_session_id must NOT be exposed (parent's end_reason is non-continuation) + assert "child" not in result or "parent_session_id" not in result["child"], ( + "parent_session_id leaked through despite parent's end_reason being " + "'user_stop' — would cause incorrect sidebar collapse." + ) + # _lineage_root_id should NOT be set — chain doesn't span the boundary + assert "child" not in result or "_lineage_root_id" not in result["child"] + assert "child" not in result or "_compression_segment_count" not in result["child"] diff --git a/tests/test_session_lineage_metadata_api.py b/tests/test_session_lineage_metadata_api.py index fdf04b6d..8d1d282f 100644 --- a/tests/test_session_lineage_metadata_api.py +++ b/tests/test_session_lineage_metadata_api.py @@ -121,7 +121,12 @@ def test_non_compression_state_db_parent_does_not_create_sidebar_lineage(_isolat rows = {row["session_id"]: row for row in all_sessions()} - assert rows["lineage_api_plain_child"].get("parent_session_id") == "lineage_api_plain_parent" + # parent_session_id must NOT be exposed when the parent's end_reason + # is not a continuation (compression / cli_close). The frontend's + # _sessionLineageKey would otherwise group two children sharing a + # `user_stop` parent under the same key — incorrect collapse. + # (Tightened in v0.50.251 per Opus SHOULD-FIX 1.) + assert "parent_session_id" not in rows["lineage_api_plain_child"] assert "_lineage_root_id" not in rows["lineage_api_plain_child"] finally: conn.close()