MWP-2 — Correctness fallback for _walk_commit_delta (fixes RC-2)
Sub-ticket of the MWP MVP master tracker: muse#58 — https://staging.musehub.ai/gabriel/muse/issues/58 Predecessor: musehub#106 (MWP-1, generation authority) — closed. (Formal cross-issue links are not available yet; this issue and #58 cross-reference by URL.)
Background
muse clone has no dedicated endpoint — it is a full POST /fetch/mpack with
have=[], served from the prebuilt-mpack cache. The fetch path discovers which
commits to ship by calling _walk_commit_delta
(musehub/services/musehub_wire_fetch.py::_walk_commit_delta). That function has
two internal walks:
- Graph fast-path (lines 66–157) — a generation-bounded range scan of
musehub_commit_graphfollowed by a BFS over the parent pointers found in that scan. This is the only live path: line 66 pinsif True:so the path below it never runs. - Legacy DAG walk (lines 159–184) — a
walk_dag_async(musehub/graph/walk.py::walk_dag_async) overMusehubCommit.parent_ids, the authoritative DAG. It is currently dead code behind thatif True:.
The bug (RC-2)
The graph fast-path is correct only if every generation in
musehub_commit_graph is correct. MWP-1 (musehub#106) made generations
authoritative at write time and added a repair routine, but the fetch walk
still has no safety net when a generation is wrong or a graph row is missing
for any reason the write-path didn't cover (a pre-MWP-1 artefact, a backfill gap,
a partial/failed prior push that wrote musehub_commits but not the graph row,
or a future regression).
Concretely, the graph fast-path truncates silently in two ways:
- Range-scan exclusion. The scan is
generation > min_have_gen AND generation <= max_want_gen(_walk_commit_deltalines 109–110).max_want_genis the max generation of thewanttips. If any ancestor of a tip has a generation greater than the tip's (impossible when correct, but exactly what a corrupt low tip generation produces relative to its true ancestors) or a tip's generation is understated, real ancestors fall outside(min_have_gen, max_want_gen]and are never loaded intograph_map. - BFS dead-end on missing rows. The BFS (lines 122–136) reads parents from
graph_map.get(cid, ([], None, 0)). Any commit absent fromgraph_map— whether because the range scan excluded it or because it has no graph row at all — yields the empty-parent default, so the BFS stops there and every ancestor beyond it is dropped from the returned set.
A truncated return set means the assembled mpack is missing commits and their
blobs. The tip-snapshot resolver (wire_fetch_mpack lines 485–491,
ORDER BY generation DESC LIMIT 1) then picks the wrong (older) snapshot, the
mpack is cached against the new tip id, and clone returns HTTP 200 with a
working tree missing the latest commits — the original clone-after-push
symptom, now caused at the read side rather than the write side.
Why MWP-1 is necessary but not sufficient
MWP-1 added a fetch-side guard ([MWP1_13], wire_fetch_mpack lines 512–540):
when want_tip_snap_id (graph max-gen) disagrees with the authoritative
MusehubCommit.snapshot_id, it calls
musehub/services/musehub_wire_push.py::repair_corrupt_commit_generations and
re-queries the tip snapshot. That repairs the persisted graph so the next
clone is fast and correct.
It does not make the current _walk_commit_delta return set complete:
the walk already returned a truncated needed_rows before the guard runs
(needed_rows = await _walk_commit_delta(...) at line 414; the guard is at 512).
Repair fixes generations, but the commit set handed to mpack assembly this
request was computed from the pre-repair graph. MWP-1 masks the symptom for the
tip-snapshot case after a self-heal; MWP-2 makes the walk itself self-correcting
so the current response is always complete, for every detection signal, at
all three call sites, even if repair never runs.
MWP-1 and MWP-2 are complementary and both stay:
- MWP-1 repair — heals the persisted graph (keeps future clones on the fast path).
- MWP-2 fallback — guarantees the current walk result is complete, now.
Goal
_walk_commit_deltanever returns a truncated commit set. When the graph fast-path cannot be trusted, it falls back to the authoritativeMusehubCommit.parent_idsDAG walk and returns the complete ancestor closure ofwantminushave.- The
if True:dead-code guard (line 66) is removed; the legacy DAG walk becomes the live, tested fallback — not dead code. - The fallback is cheap to decide (no extra query in the common case) and bounded when it runs, so steady-state clone latency is unchanged.
- All three call sites (
wire_fetch_presign,wire_fetch_mpack,wire_fetch) benefit from the single seam, with no per-call-site changes required. - A non-skipped regression test proves a clone served entirely by the fallback (graph deliberately corrupted, repair patched out) returns every commit and blob.
Design
Detection — free in the common case
The fast-path BFS already knows whether it touched a commit it could not find in
graph_map. Track one boolean during the existing BFS:
graph_incomplete = bool(_missing_from_graph) # any start tip missing a graph row
...
for cid in frontier_mem:
...
entry = graph_map.get(cid)
if entry is None:
graph_incomplete = True # reachable commit not in the range scan
pids_for_cid, _, _gen = [], None, 0
else:
pids_for_cid, _, _gen = entry
...
graph_incomplete becomes true exactly when the BFS would dead-end: a want
tip with no graph row, or any reachable commit excluded from the range scan
(the corrupt-generation case). No extra query — it is derived from data the
fast-path already loads.
When graph_incomplete is False, the fast-path result is provably the full
closure (every reachable commit had its true parents in graph_map), so we
return it unchanged — zero added cost on the steady-state happy path.
Fallback — authoritative DAG walk
When graph_incomplete is True, log loudly and re-walk from starts over the
authoritative parent pointers:
logger.error(
"[MWP2] graph walk incomplete (missing_starts=%s, dead_end during BFS) — "
"falling back to authoritative MusehubCommit DAG walk; want=%d have=%d",
_missing_from_graph, len(starts), len(have_set),
)
return await _walk_commit_delta_dag(session, starts, have_set)
_walk_commit_delta_dag is the current lines 159–184 promoted to a named helper,
with two fixes:
- Topological ordering (parents-first).
walk_dag_asyncyields tips→roots (children before parents). The client applies commits sequentially and skips any whose parent is not yet applied, so a children-first dict is correct but wasteful. Re-order the collected commits parents-first using Kahn's algorithm over the reachable subgraph ofMusehubCommit.parent_ids— mirror the topo-sort already proven inmusehub/services/musehub_wire_push.py::_resolve_generation_with_backfill. - Bounded walk. Pass
max_nodestowalk_dag_async(a generous cap, e.g. the count ofmusehub_commitsrows, or a fixed ceiling such as 100_000) so a pathological corrupt graph cannot drive an unbounded scan. Log if the cap is hit.
The helper returns the same _CommitDeltaMap shape the fast-path returns
(dict[commit_id -> SimpleNamespace(commit_id, snapshot_id, parent_ids)]), so
downstream code in all three callers is unchanged. snapshot_id and
parent_ids come from the MusehubCommit rows the walk already loads into
_row_cache.
Why detection lives inside _walk_commit_delta, not in wire_fetch_mpack
The tip-snapshot mismatch ([BLOB-DEBUG] / [MWP1_13]) is computed in
wire_fetch_mpack only, after the walk, and only for that one caller.
wire_fetch_presign (line 224) and wire_fetch (line 1127) have no such guard.
Putting detection + fallback inside _walk_commit_delta fixes all three with one
change and guarantees a complete set before any snapshot resolution runs.
Interaction with MWP-1's [MWP1_13] guard
Leave the [MWP1_13] repair in place — it keeps the persisted graph healthy.
After MWP-2, the sequence for a corrupt graph is:
_walk_commit_deltadetects incompleteness → returns the complete set via the DAG fallback (current response correct).wire_fetch_mpack's[MWP1_13]guard still notices the stale tip snapshot → repairs generations (future responses back on the fast path).
No change to [MWP1_13] is required; add an assertion in the E2E test that with
repair patched out, the fallback alone still yields a complete mpack.
Phases (load-bearing; each green before the next)
Phase 0 — Reproduce (red) ✅ COMPLETE
- [x]
MWP2_01Unit: missing interior graph row for C3 in C1←C2←C3←C4 chain; graph has C1(gen=0), C2(gen=1), C4(gen=3) — C3 absent. BFS dead-ends at C3 (empty-parents default), C2/C1 never added to reachable set. RED confirmed (reachable=2, missing C2 and C1). - [x]
MWP2_02Unit: want tip C3 absent from graph in C1←C2←C3 chain. max_want_gen=0 (C3→None→default 0); range scan returns only C1(gen=0); BFS from C3 finds no parents in graph_map. Result: {C3} only. RED confirmed. - [x]
MWP2_03Unit (guard against over-fallback): fully consistent graph — C1(gen=0), C2(gen=1), C3(gen=2); walk_dag_async never called. GREEN.
Phase 0 complete (commit
sha256:4dd2a937f66f8): Both reproduction tests are RED for the right reason — BFS dead-end at missing graph rows, loggingreachable=1andreachable=2respectively. MWP2_03 pins the happy-path contract. No production code changed.
Phase 1 — Promote the DAG walk to a tested helper ✅ COMPLETE
- [x]
MWP2_04Promoted the dead legacy walk (old lines 159–184) to_walk_commit_delta_dag(session, starts, have_set) -> _CommitDeltaMap. Removed theif True:dead-code guard; the fast-path body is now the direct body of_walk_commit_delta. The helper adds Kahn's topo-sort (parents-first) andmax_nodes=100_000bound with a WARNING log if hit. ReturnsMusehubCommitrows directly (avoids the downstream bulk re-fetch). - [x]
MWP2_05_walk_commit_delta_dagover C1←C2←C3 returns {C1,C2,C3} with correctsnapshot_idandparent_idson every row. GREEN. - [x]
MWP2_06Kahn's sort produces parents-first dict order:idx[C1] < idx[C2] < idx[C3]in a linear chain. GREEN.
Phase 1 complete (commit
sha256:d984bd63c5abb):_walk_commit_delta_dagis a tested, callable top-level helper. Theif True:dead-code guard is gone. MWP2_01 and MWP2_02 remain RED — the helper is not yet wired into the fast-path BFS (Phase 2).
Phase 2 — Wire detection + automatic fallback ✅ COMPLETE
- [x]
MWP2_07Addedgraph_incomplete: boolto the fast-path BFS: initialized toany(s not in graph_map for s in starts)(missing start tips), then set toTrueduring BFS whenevergraph_map.get(cid) is None(dead-end interior node). After the BFS,if graph_incomplete:logs[MWP2]at ERROR and returns_walk_commit_delta_dag(session, starts, have_set). Makes MWP2_01 and MWP2_02 GREEN. - [x]
MWP2_08MWP2_03 updated to patch_walk_commit_delta_dagdirectly (was:walk_dag_async). Consistent graph never triggers the fallback. GREEN. - [x]
MWP2_09haveboundary honored under fallback: C1←C2←C3←C4, C3 absent from graph (triggers fallback),want=[C4] have=[C2]→ result is {C3, C4}; C1 and C2 absent. GREEN.
Phase 2 complete (commit
sha256:984cdb57998d2): All 6 Phase 0+1+2 tests GREEN. MWP2_01 and MWP2_02 (the Phase 0 RED gates) are now GREEN — the fallback returns the complete ancestor closure for both truncation scenarios. MWP-1's 13 tests unaffected (19 total GREEN).
Phase 3 — Bounded fallback + stress ✅ COMPLETE
- [x]
MWP2_10Passmax_nodestowalk_dag_asyncinside_walk_commit_delta_dag; log a[MWP2]warning if the cap is reached. Choose the cap ascount(musehub_commits)for the repo, or a fixed ceiling. Implemented:max_nodes=100_000fixed ceiling (Phase 1); Phase 3 adds two assertions — kwarg wired (max_nodes=100_000captured viapatch.object(_walk_mod, "walk_dag_async")) and warning fires whenwalk_dag_asyncyields exactly 100,000 items. Kahn's sort switched tocollections.deque.popleft()so O(n) holds even for bushy graphs. GREEN. - [x]
MWP2_11Stress: a 5,000-commit linear history with no graph rows (forces full DAG fallback) completes, returns all 5,000 commits in parents-first topo order, and stays within the bound. Singleflush()(no commit) keeps SQLAlchemy identity map warm —session.get()is O(1) per adjacency call. Topo-order verified: every parent in result appears before its child. GREEN.
Phase 3 complete: Production fix:
from collections import deque; Kahn's queue now usesdeque.popleft()instead oflist.pop(0)— O(n) guaranteed for all graph shapes. Two new tests GREEN: MWP2_10 (max_nodes kwarg wired + cap warning) and MWP2_11 (5,000-commit stress, full fallback, topo order verified). MWP-1's 13 tests unaffected (21 total GREEN).
Phase 4 — End-to-end regression (the acceptance gate) ✅ COMPLETE
- [x]
MWP2_12E2E viawire_fetch_mpack(..., force_build=True): push C1→C2→C3 (in-memory backend, mirrortest_mwp1_generation_authority.py::_InMemBackend), then corrupt the graph so the fast path truncates (delete/wrong-gen a middle row), then patch outrepair_corrupt_commit_generationsso MWP-1's guard cannot self-heal. Clone withwant=[C3], have=[]. Assert the assembled mpack contains C1, C2, C3 and C3's blob — proving the fallback alone ships a complete clone. This is the acceptance gate. - [x]
MWP2_13E2E: same setup with repair enabled — assert the response is complete and a follow-up_walk_commit_deltaruns on the fast path (graph healed,graph_incomplete == False). Proves MWP-1 + MWP-2 compose. Implemented: first clone fires fallback (complete); INSERT C2's graph row back (simulating repair/re-push); second_walk_commit_deltawith dag patched to raise → no raise → fast path confirmed. GREEN.
Phase 4 complete: Two new E2E tests GREEN.
_InMemBackend+_e2e_pushhelper mirror the MWP1_14 pattern. MWP2_12 asserts the fallback alone (repair patched out) returns {C1, C2, C3} and C3's blob — the acceptance gate. MWP2_13 proves composition: first clone completes via fallback; after graph is healed (C2 reinserted), second_walk_commit_deltauses the fast path (dag helper patched to raise, no exception → fast path confirmed). MWP-1 guard assertion:mock_repair.assert_not_called()verifies the missing-row corruption does not trigger MWP-1's snapshot-mismatch guard (RC-2 ≠ RC-1). 23 total GREEN (10 MWP2 + 13 MWP1).
Acceptance criteria
- [x]
_walk_commit_deltareturns the complete ancestor closure ofwantminushaveeven whenmusehub_commit_graphhas a wrong or missing generation (MWP2_01,MWP2_02,MWP2_12). - [x] A fully consistent graph never pays for the fallback — zero extra queries,
DAG helper not invoked (
MWP2_03,MWP2_08). - [x] The fallback result is in parents-first topological order (
MWP2_06). - [x] The fallback is bounded and survives a full-history corruption without
blowing up (
MWP2_10,MWP2_11). - [x] A clone served entirely by the fallback (repair patched out) returns
every commit and blob (
MWP2_12). Codified as a non-skipped regression test. - [x] The
if True:dead-code guard is gone; the legacy walk is live and tested.
Testing tiers
| Tier | Coverage |
|---|---|
| Unit | detection flag, DAG helper closure, topo ordering, have-boundary |
| Integration | DB-backed corrupt/missing graph rows; fast-path vs fallback selection |
| End-to-end | push → corrupt graph → clone via fallback returns complete mpack |
| Stress | full-history fallback over a 5,000-commit chain, bounded |
| Data integrity | no truncated commit set under any graph corruption |
Out of scope
- Repairing the persisted graph — that is MWP-1's
repair_corrupt_commit_generationsand the[MWP1_13]guard, which remain unchanged. - Job enqueue dedup on back-to-back pushes — MWP-3.
- Prebuild/index ordering — MWP-4.
- Client 503 retry — MWP-5.
- Broad re-enable of the skipped wire suite — MWP-6.
Anchors
musehub/services/musehub_wire_fetch.py::_walk_commit_delta(detection seam; removeif True:at line 66; promote legacy walk lines 159–184)musehub/services/musehub_wire_fetch.py::wire_fetch_mpack(main caller, line 414;[MWP1_13]guard lines 512–540 — leave intact)musehub/services/musehub_wire_fetch.py::wire_fetch_presign(caller, line 224)
✅ Closed — 2026-06-28
All 4 phases delivered, 23 tests GREEN (10 MWP2 + 13 MWP1), all 6 acceptance criteria met. Summary:
- RC-2 fixed:
if True:dead-code guard removed. Fallback is live. - MWP-2 correctness:
_walk_commit_deltanow detects graph incompleteness at both entry points (missing start tip; interior dead-end) and falls back to the authoritativeMusehubCommitDAG walk. - O(n) Kahn's sort:
collections.deque.popleft()replaceslist.pop(0)— guaranteed O(n) for all graph shapes including bushy ones. - Bounded:
max_nodes=100_000hard ceiling wired intowalk_dag_async; warning logged if the cap fires. - E2E acceptance gate (MWP2_12): push C1→C2→C3, delete C2's graph row, patch
out MWP-1 repair, clone — mpack contains all 3 commits and C3's blob. Non-skipped
regression test.
mock_repair.assert_not_called()proves MWP-1 snapshot-mismatch guard does NOT fire for missing-row corruption (RC-2 ≠ RC-1). - Composition proven (MWP2_13): after graph is healed,
_walk_commit_deltauses the fast path — dag helper patched to raise, no exception raised. musehub/services/musehub_wire_fetch.py::wire_fetch(caller, line 1127)musehub/graph/walk.py::walk_dag_async(authoritative DAG walk; acceptsexclude=have_set,max_nodes)musehub/services/musehub_wire_push.py::_resolve_generation_with_backfill(Kahn topo-sort pattern to mirror for parents-first ordering)musehub/services/musehub_wire_push.py::repair_corrupt_commit_generations(MWP-1 repair; patched out inMWP2_12)- New test file:
musehub/tests/test_mwp2_walk_fallback.py(test IDsMWP2_01–MWP2_13)