"""MWP-2 Phase 0 (RED) — _walk_commit_delta correctness fallback. Sub-ticket: musehub#107 — https://staging.musehub.ai/gabriel/musehub/issues/107 Master: muse#58 — https://staging.musehub.ai/gabriel/muse/issues/58 Predecessor: musehub#106 (MWP-1, generation authority) — closed. Root cause (RC-2): ``_walk_commit_delta`` (musehub_wire_fetch.py:49) pins ``if True:`` at line 66, making the authoritative MusehubCommit DAG walk (lines 159–184) dead code. The live path is a generation-bounded range scan: generation > min_have_gen AND generation <= max_want_gen This is correct only when every generation in ``musehub_commit_graph`` is correct. When a generation is wrong or a graph row is missing, the range scan either excludes real ancestors (corrupt low generation causes max_want_gen to understate the range) or the BFS dead-ends (missing graph row yields empty parents from ``graph_map.get(cid, ([], None, 0))``). Either way ``_walk_commit_delta`` silently returns a truncated commit set, the assembled mpack is missing commits and blobs, and clone exits 0 with a stale working tree. MWP-1 (musehub#106) fixed the write side: no new commit is ever written with a wrong generation, and ``repair_corrupt_commit_generations`` heals pre-existing artefacts. MWP-2 fixes the read side: ``_walk_commit_delta`` must detect that its BFS is incomplete and fall back to the authoritative parent-pointer walk, so the *current* response is always complete regardless of graph state. Expected Phase 0 status: - MWP2_01 RED — corrupt generation excludes ancestors; truncated result - MWP2_02 RED — missing graph row dead-ends BFS; missing commits in result - MWP2_03 GREEN — fully consistent graph uses fast path; no fallback triggered The fix (Phases 1–2) adds a ``graph_incomplete`` flag to the BFS and calls a promoted ``_walk_commit_delta_dag`` helper when the flag is set. """ from __future__ import annotations import datetime import logging from unittest.mock import AsyncMock, MagicMock, patch import pytest from sqlalchemy import delete as _sa_delete, select from sqlalchemy.ext.asyncio import AsyncSession from muse.core.ids import hash_snapshot from muse.core.types import blob_id from musehub.db.musehub_repo_models import ( MusehubCommit, MusehubCommitGraph, ) from musehub.services.musehub_wire_fetch import _walk_commit_delta _OWNER = "gabriel" # --------------------------------------------------------------------------- # Helpers — mirrors test_mwp1_generation_authority.py conventions # --------------------------------------------------------------------------- def _cid(seed: str) -> str: """Deterministic sha256: commit id from a seed string.""" return blob_id(f"mwp2-commit-{seed}".encode()) def _sid(seed: str) -> str: """Deterministic snapshot id.""" return hash_snapshot({f"{seed}.txt": blob_id(seed.encode())}) def _now() -> datetime.datetime: return datetime.datetime(2026, 1, 1, tzinfo=datetime.timezone.utc) async def _seed_commit( session: AsyncSession, cid: str, parent_ids: list[str], snap_id: str, ) -> None: """Insert a musehub_commits row (authoritative DAG) with no graph row.""" session.add(MusehubCommit( commit_id=cid, branch="main", message=f"commit {cid[:12]}", author=_OWNER, timestamp=_now(), parent_ids=parent_ids, snapshot_id=snap_id, )) await session.flush() async def _seed_graph( session: AsyncSession, cid: str, parent_ids: list[str], generation: int, snap_id: str, ) -> None: """Insert a musehub_commit_graph row with an explicit (possibly corrupt) generation.""" session.add(MusehubCommitGraph( commit_id=cid, parent_ids=parent_ids, generation=generation, snapshot_id=snap_id, created_at=_now(), )) await session.flush() # --------------------------------------------------------------------------- # MWP2_01 — corrupt generation excludes ancestors from range scan (RED) # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_mwp2_01_corrupt_generation_truncates_walk( db_session: AsyncSession, ) -> None: """RED: a missing interior graph row dead-ends the BFS, dropping ancestors. Chain: C1 <- C2 <- C3 <- C4 (all in musehub_commits, authoritative). Graph: C1(gen=0), C2(gen=1), C4(gen=3) — C3 has NO graph row. want=[C4], have=[]. BFS: C4 is in graph_map (gen=3, parents=[C3]). C3 → graph_map.get(C3) returns default ([], None, 0) → BFS treats C3 as a leaf and stops. C2 and C1 are never added to the reachable set. Result without fix: {C4, C3} — C2 and C1 missing. Result with fix: {C4, C3, C2, C1}. This is the canonical post-MWP-1 RC-2 scenario: the tip has a correct high generation, but an interior ancestor's graph row was never written (a push that wrote musehub_commits but not the graph row, or a pre-backfill gap). MWP2_02 covers the case where the *start tip itself* is absent from the graph. """ c1 = _cid("01-c1") c2 = _cid("01-c2") c3 = _cid("01-c3") c4 = _cid("01-c4") s1, s2, s3, s4 = _sid("01-s1"), _sid("01-s2"), _sid("01-s3"), _sid("01-s4") # Authoritative DAG — all four commits exist in musehub_commits. await _seed_commit(db_session, c1, [], s1) await _seed_commit(db_session, c2, [c1], s2) await _seed_commit(db_session, c3, [c2], s3) await _seed_commit(db_session, c4, [c3], s4) # Graph: C1, C2 present with correct generations; C3 absent (the backfill # gap that produces the BFS dead-end); C4 present with correct generation # relative to the true chain (gen=3). await _seed_graph(db_session, c1, [], 0, s1) await _seed_graph(db_session, c2, [c1], 1, s2) # C3 deliberately NOT seeded in graph. await _seed_graph(db_session, c4, [c3], 3, s4) await db_session.commit() result = await _walk_commit_delta(db_session, want=[c4], have=[]) assert c4 in result, "C4 (want tip) must always be in result" assert c3 in result, ( f"C3 must be in result — it is a direct parent of C4. " f"It is missing because graph_map.get(C3) returned empty parents " f"(C3 has no graph row), so the BFS dead-ended at C3. " f"Got result keys: {sorted(result.keys())}" ) assert c2 in result, ( f"C2 must be in result — ancestor of C4 reachable via C3. " f"Got result keys: {sorted(result.keys())}" ) assert c1 in result, ( f"C1 must be in result — root ancestor of C4. " f"Got result keys: {sorted(result.keys())}" ) # --------------------------------------------------------------------------- # MWP2_02 — missing graph row for the start tip dead-ends BFS (RED) # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_mwp2_02_missing_start_tip_graph_row( db_session: AsyncSession, ) -> None: """RED: when the start tip (a want commit) has no graph row at all, the BFS dead-ends immediately and returns an empty or incomplete set. Setup: musehub_commits: C1(root) <- C2 <- C3 musehub_commit_graph: C1(gen=0), C2(gen=1) — C3 has NO graph row. want=[C3], have=[]. max_want_gen: C3 not in graph → query returns None → max_want_gen = 0. Range scan: gen > -1 AND gen <= 0 → only C1 (gen=0) returned in graph_map. BFS: starts from C3; C3 not in graph_map → treated as having empty parents; BFS adds C3 to reachable but never follows its parents (C2, C1). Result without fix: {C3} only. Missing: C2, C1. Result with fix: {C3, C2, C1}. """ c1 = _cid("02-c1") c2 = _cid("02-c2") c3 = _cid("02-c3") s1, s2, s3 = _sid("02-s1"), _sid("02-s2"), _sid("02-s3") # Authoritative DAG. await _seed_commit(db_session, c1, [], s1) await _seed_commit(db_session, c2, [c1], s2) await _seed_commit(db_session, c3, [c2], s3) # Graph: C1 and C2 present; C3 (the want tip) absent. await _seed_graph(db_session, c1, [], 0, s1) await _seed_graph(db_session, c2, [c1], 1, s2) await db_session.commit() result = await _walk_commit_delta(db_session, want=[c3], have=[]) assert c3 in result, "C3 (want tip) must be in result" assert c2 in result, ( f"C2 must be in result — parent of C3 (the want tip). " f"C3 had no graph row, so max_want_gen=0 and the range scan only " f"returned C1. BFS from C3 found no parents in graph_map. " f"Got result keys: {sorted(result.keys())}" ) assert c1 in result, ( f"C1 must be in result — root ancestor reachable from C3. " f"Got result keys: {sorted(result.keys())}" ) # --------------------------------------------------------------------------- # MWP2_03 — consistent graph uses fast path, no fallback invoked (GREEN) # --------------------------------------------------------------------------- @pytest.mark.asyncio async def test_mwp2_03_consistent_graph_uses_fast_path( db_session: AsyncSession, ) -> None: """GREEN guard: a fully consistent graph must return the complete ancestor closure via the fast path, and ``_walk_commit_delta_dag`` must NOT be called. This test pins the happy-path contract so later phases cannot accidentally make the fast path degrade into always calling the fallback (which would be correct but slow). After Phase 2 the ``_walk_commit_delta_dag`` helper will exist; this test patches it to detect if it is invoked. Before Phase 1 it doesn't exist, so we patch ``musehub.graph.walk.walk_dag_async`` (the underlying function the legacy walk calls) as a proxy — if the fast path is truly live and exclusive, walk_dag_async should never be called during _walk_commit_delta. Note: after Phase 1 promotes _walk_commit_delta_dag to a real named function, this test should be updated to patch that directly. For Phase 0 we patch walk_dag_async as the canary. Setup: musehub_commits: C1(root) <- C2 <- C3 musehub_commit_graph: C1(gen=0), C2(gen=1), C3(gen=2) — fully consistent. want=[C3], have=[]. Expected: result contains C1, C2, C3 and walk_dag_async is not called. """ c1 = _cid("03-c1") c2 = _cid("03-c2") c3 = _cid("03-c3") s1, s2, s3 = _sid("03-s1"), _sid("03-s2"), _sid("03-s3") await _seed_commit(db_session, c1, [], s1) await _seed_commit(db_session, c2, [c1], s2) await _seed_commit(db_session, c3, [c2], s3) await _seed_graph(db_session, c1, [], 0, s1) await _seed_graph(db_session, c2, [c1], 1, s2) await _seed_graph(db_session, c3, [c2], 2, s3) await db_session.commit() with patch( "musehub.graph.walk.walk_dag_async", wraps=None, new_callable=MagicMock, ) as mock_dag_walk: # Make it an AsyncMock that returns an empty async iterator if called. async def _never_called(*args, **kwargs): pytest.fail( "_walk_commit_delta invoked the DAG walk (walk_dag_async) on a " "fully consistent graph — the fast path must be used exclusively." ) mock_dag_walk.side_effect = _never_called result = await _walk_commit_delta(db_session, want=[c3], have=[]) assert c3 in result, f"C3 must be in result; got {sorted(result.keys())}" assert c2 in result, f"C2 must be in result; got {sorted(result.keys())}" assert c1 in result, f"C1 must be in result; got {sorted(result.keys())}"