fix(storage): quarantine real cycles in the live session_links resolver - #3643
Conversation
Problem: session_links.status/.method were declared vocabulary (TopologyEdgeStatus) with no live writer -- the sole production writer (write_parsed_session_to_archive -> _resolve_session_graph / _resolve_outbound_session_links) resolved every matching parent edge unconditionally, relying only on _refresh_session_projection's seen-set short-circuit to avoid infinite recursion on a genuine cycle. A real cycle-detection + quarantine engine existed only in storage/sqlite/queries/session_links.py, which has zero production callers (test-only, exercised solely by tests/unit/insights/test_topology_cycle_rejection.py). method is already populated as of #3390 (2026-07-30); status stayed NULL on every row because nothing detected or recorded a cycle in the live path. Solution: port the dead engine's cycle-walk + quarantine algorithm (_would_create_cycle / _quarantine_link) into storage/sqlite/archive_tiers/write.py as sync helpers (_would_create_cycle / _quarantine_session_link), and wire them into both live resolution entry points: _resolve_outbound_session_links (a session resolving its own parent reference) and the inbound-child loop inside _resolve_session_graph (a session resolving pending children that reference it). A candidate edge whose resolution would close a loop in sessions.parent_session_id is now quarantined with a JSON evidence document (reason/cycle_path/detected_at_ms) instead of resolved, and its parent_session_id projection stays NULL -- composition degrades visibly (the session surfaces as its own root) rather than silently entering the cycle. Both entry points now filter on `status IS NULL` in addition to `resolved_dst_session_id IS NULL` so a quarantined edge is never reconsidered on a later write (idempotent). Added tests/unit/storage/test_topology_cycle_quarantine_live.py, reproducing a genuine cross-ingest two-node cycle, a self-referential edge, and a non-cyclic diamond-DAG control case through the real write_parsed_session_to_archive path (not the dead async engine). Verified red without the write.py change (2 of 3 new tests failed with `assert None == 'quarantined'`), green with it. Not in this commit's scope (left for follow-up, see PR body): retargeting tests/unit/insights/test_topology_cycle_rejection.py and the session_links-dependent slice of tests/unit/storage/test_delegations_view.py off the dead async engine, and deleting storage/sqlite/queries/session_links.py. Both still pass unchanged since this change only adds behavior to the live path and does not touch the dead module. Verification: devtools test tests/unit/storage/test_topology_cycle_quarantine_live.py tests/unit/storage/test_lineage_normalization.py tests/unit/storage/test_session_topology.py tests/unit/storage/test_topology_edges.py tests/unit/storage/test_archive_tiers_write.py tests/unit/storage/test_bulk_fts_prefix_reextract.py -- 142 passed. ruff check/format --check and mypy --strict clean on both touched files. Ref polylogue-4ts.10
|
Warning Review limit reached
Next review available in: 25 minutes Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: ASSERTIVE Plan: Pro Plus Run ID: 📒 Files selected for processing (2)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…3655) ## Summary Completes the remaining polylogue-4ts.10 scope left as follow-up by #3643: deletes the dead async cycle-detection/quarantine engine in `storage/sqlite/queries/session_links.py` (zero production callers since #3643 ported the equivalent logic into the live writer), and retargets the three wrong-oracle tests that exercised only that dead engine at the real production write path (`write_parsed_session_to_archive`). ## Problem #3643 ported cycle detection + quarantine into the live `session_links` writer (`write.py`'s `_resolve_outbound_session_links` / `_resolve_session_graph`), but left `storage/sqlite/queries/session_links.py`'s equivalent async engine (`upsert_session_links`, `resolve_session_links_for_session`, `resolve_unresolved_links_for_child`, `_would_create_cycle`, `_quarantine_link`, `count_quarantined_session_links`) in place with zero production callers. Three tests exercised only that dead engine while implicitly or explicitly claiming to certify production behavior: - `tests/unit/insights/test_topology_cycle_rejection.py` (a dedicated 621-line file bootstrapping a raw sqlite3 connection and calling the dead engine directly). - `test_delegation_direction_matches_real_link_resolver` in `test_delegations_view.py`, whose docstring explicitly claimed to drive "the ACTUAL production write path" via the dead engine's functions. - `test_session_link_resolver_quarantines_cycle` in the property write-path state machine, which manually inserted a `session_links` row and called the dead engine's `resolve_unresolved_links_for_child` to simulate a cycle. This is exactly the wrong-oracle pattern flagged by the 2026-08-03 structural audit referenced on the bead: tests certifying behavior production code cannot exhibit. ## Solution - Deleted the dead write engine from `queries/session_links.py`, keeping only `list_session_links_for_session` (a genuine production read path reached via `query_store_archive.py`). - Deleted `tests/unit/insights/test_topology_cycle_rejection.py` outright: its scenarios (two-node cycle, self-loop, diamond DAG) are already covered against the live path by `test_topology_cycle_quarantine_live.py` (landed with #3643). - Retargeted `test_delegation_direction_matches_real_link_resolver` to drive `write_parsed_session_to_archive` directly instead of the dead engine's `upsert_session_links`/`resolve_session_links_for_session`. Removed the now-unused `_AsyncSqliteAdapter`/`_AsyncCursorAdapter` shim built specifically to run the dead engine. - Retargeted `test_session_link_resolver_quarantines_cycle` to reproduce the cycle via a real re-ingest through `write_parsed_session_to_archive` (matching the pattern in `test_topology_cycle_quarantine_live.py`) instead of manually inserting a row and calling the dead engine. - Removed now-unused imports (`aiosqlite`, `configure_connection`, `asyncio` where no longer needed). ### AC status (polylogue-4ts.10) - AC1 (every write carries `TopologyEdgeStatus`/`method`, no empty-status writes): satisfied by #3643, unaffected by this PR. - AC2 (existing rows converge via ordinary derived-tier rebuild, no bespoke backfill): unaffected, no migration added. - AC3 (a reader can filter edges by status, composition degrades visibly on a non-resolved parent): satisfied. `list_session_links_for_session` already returns `status`; downstream consumers already read it -- `storage/insights/topology/derivation.py` excludes quarantined edges from unresolved-edge composition, and `insights/transforms.py` threads `child_link_status` (including `"quarantined"`) onto `SubagentReport`. Composition already degrades visibly: a quarantined edge's child never gets `sessions.parent_session_id` set, so it surfaces as its own root rather than silently composing through the rejected parent. No new reader surface was needed beyond confirming these existing paths. - AC4 (live re-measure shows zero empty status/method rows): explicitly out of scope for this PR -- requires rebuilding the production index (`polylogue ops reset --index && polylogued run`), a separate operator action, not a code change. ## Verification - `devtools test tests/unit/storage/test_delegations_view.py tests/unit/storage/test_topology_cycle_quarantine_live.py tests/unit/storage/test_unread_wire_batch_v46.py tests/unit/storage/test_archive_tiers_write.py tests/property/test_write_path_state_machine.py` -- 120 passed - `devtools verify --quick` -- exit 0 (ruff format/check, mypy --strict, `render all --check`, all lab policy gates) Ref polylogue-4ts.10 --------- Co-authored-by: Claude <noreply@anthropic.com>
Summary
Ports cycle detection + quarantine into the sole production writer of
session_linksrows, so a genuine cycle in the parent chain is now detected and recorded with evidence instead of silently relying on recursion-guard short-circuits.Problem
session_links.status/.methodare declared vocabulary (TopologyEdgeStatus), but a live-archive measurement (2026-07-28, index v43, 18,871 sessions) found every one of 9,179 topology edges had emptystatus/method.methodis already populated as of #3390 (2026-07-30, landed before this bead's structural audit);statusstayed NULL on every row because nothing in the live write path ever detects or records a cycle.A 2026-08-03 structural audit (
polylogue-structural-audit-2026-08-03.html) found the real gap is worse than an unwritten column: a full cycle-detection + quarantine engine already exists atstorage/sqlite/queries/session_links.py(_would_create_cycle,_quarantine_link,status='quarantined'+ evidence JSON) but has zero production callers — it is exercised only bytests/unit/insights/test_topology_cycle_rejection.py, a test-only surface certifying behavior production code cannot exhibit. The live writer (write_parsed_session_to_archive->_resolve_session_graph/_resolve_outbound_session_links,storage/sqlite/archive_tiers/write.py) resolves every matching parent edge unconditionally; a real cycle is only ever avoided by_refresh_session_projection's seen-set short-circuit and_composed_db_signatures' visited-set truncation, which silently pick an arbitrary root/branch point on a real cycle instead of persisting evidence of the rejected edge, making root/branch projections order-dependent and arbitrary.Given
polylogue-5dfualready narrowedTopologyEdgeStatusto justREPAIRED/QUARANTINED(NULL = ordinary resolved/unresolved, not "missing data"), the correct fix is not "every row must carry a non-NULL status" — it's "when a cycle actually occurs, the closing edge must be quarantined with evidence, and this must happen in the live path, not only in dead test-only code."Solution
_would_create_cycle(walkssessions.parent_session_idupward from a proposed parent, bounded by a 1024-step budget, matching the dead engine's algorithm) and_quarantine_link(_quarantine_session_linkhere) as synchronoussqlite3-based helpers instorage/sqlite/archive_tiers/write.py, next to the functions they protect._resolve_outbound_session_links(a session resolving its own parent reference) now evaluates each unresolved candidate individually instead of one blanket UPDATE, cycle-checking before resolving._resolve_session_graph(a session resolving pending children that reference it as their parent) does the same per child.status='quarantined',evidence_jsonrecording{reason, cycle_path, detected_at_ms}) instead of resolved; itsparent_session_idprojection stays NULL, so composition/ancestry walks degrade visibly (the session surfaces as its own root) rather than silently entering the cycle.status IS NULL(in addition toresolved_dst_session_id IS NULL), so a quarantined edge is never reconsidered on a later write — idempotent by construction.Alternatives rejected
tests/unit/insights/test_topology_cycle_rejection.py(621 lines) and thesession_links-dependent slice oftests/unit/storage/test_delegations_view.pyoff the dead async engine, and deletingstorage/sqlite/queries/session_links.py, is explicitly not done in this PR. That's a large, separable retarget-and-delete step (both files currently pass, unchanged, because this PR only adds behavior to the live path and does not touch the dead module) and is called out as follow-up scope below rather than rushed into this PR.Verification
tests/unit/storage/test_topology_cycle_quarantine_live.py— reproduces a genuine cross-ingest two-node cycle (A lands root, B lands as A's child and resolves immediately, A is then re-ingested claiming B as its parent), a self-referential edge, and a non-cyclic diamond-DAG control case (B and C both point at D — a legitimate shared parent, must resolve cleanly, not be mistaken for a cycle), all through the realwrite_parsed_session_to_archivepath.write.pychange: 2 of 3 new tests failed withassert None == 'quarantined'(stashed the fix out, reran, restored it).devtools test tests/unit/storage/test_topology_cycle_quarantine_live.py tests/unit/storage/test_lineage_normalization.py tests/unit/storage/test_session_topology.py tests/unit/storage/test_topology_edges.py tests/unit/storage/test_archive_tiers_write.py tests/unit/storage/test_bulk_fts_prefix_reextract.py— 142 passed.ruff check/ruff format --check/mypy --strictclean on both touched files.devtools verify --quick— exit 0.Acceptance criteria (polylogue-4ts.10)
polylogue-5dfu's prior narrowing:methodis satisfied (populated since feat(archive): index v46 wire-evidence batch, free-threaded-only runtime, parse-failure recovery #3390);statuslegitimately stays NULL for ordinary resolved/unresolved edges (that's the narrowed vocabulary's intent) and is now populated (quarantined) exactly when a cycle is detected, which is the actual gap this bead's own structural-audit note identified.index.db) behavior change with no migration; apolylogue ops reset --index && polylogued runnaturally re-derives status/method for every row through the now-fixed live path.parent_session_idstays NULL, so it surfaces as its own root); a dedicated reader/insight surface for quarantined-edge counts is deferred (the dead engine'scount_quarantined_session_linksis one candidate to port, left for the follow-up retarget work below)./realm/db/polyloguefrom this worktree); the mechanism this AC depends on is now live, so a follow-up index rebuild + re-measure is the natural next step, not additional code here.Remaining polylogue-4ts.10 scope (follow-up)
tests/unit/insights/test_topology_cycle_rejection.pyand thesession_links-dependent parts oftests/unit/storage/test_delegations_view.pyat the live path (this PR's new test file demonstrates the pattern), then deletestorage/sqlite/queries/session_links.py(dead code, zero production callers) once nothing references it.Ref polylogue-4ts.10