diff --git a/CHANGELOG.md b/CHANGELOG.md index bb2ec56..5ba02d1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,6 +21,14 @@ All notable changes to vouch are documented here. Format follows - console Dashboard view: 12-month activity calendar, last-30-days bars, hour-of-week heatmap, top actors and event mix, driven by `kb.activity`. +### Fixed +- `compile_kb()` could file two page proposals for the same title when the + LLM's batch drafted the same topic (or same slug, e.g. "Retry Policy" vs + "retry policy") twice — `taken_names` was only seeded from on-disk pages + and pending proposals, never updated as drafts were accepted within the + batch. Approving the second proposal would silently route through + `update_page()` and overwrite the first. (#439) + ## [1.2.2] — 2026-07-07 ### Packaging diff --git a/src/vouch/compile.py b/src/vouch/compile.py index 9f4acc1..db1a168 100644 --- a/src/vouch/compile.py +++ b/src/vouch/compile.py @@ -312,6 +312,12 @@ def compile_kb( report.dropped.append({"title": title, "reason": problem}) continue survivors.append((draft, title)) + # Fix #439: fold accepted title/slug into taken_names immediately so + # a later draft in the same batch cannot collide with a just-accepted + # one. Without this, two drafts with the same title both pass + # _draft_problem's collision guard and land as duplicate proposals. + taken_names.add(title.lower()) + taken_names.add(_slugify(title)) # phase 2: wikilinks resolve against existing pages + the *surviving* # batch, to a fixpoint — dropping a draft may dangle a link in another, diff --git a/tests/test_compile.py b/tests/test_compile.py index 64137a0..e1e6c9d 100644 --- a/tests/test_compile.py +++ b/tests/test_compile.py @@ -417,3 +417,75 @@ def test_compile_logs_attributed_audit_event( events = [e for e in audit_mod.read_events(store.kb_dir) if e.event == "compile.run"] assert len(events) == 1 + + +def test_same_batch_duplicate_title_drops_second( + store: KBStore, tmp_path: Path, +) -> None: + """Regression for #439: when the LLM returns two drafts with the same + title in a single batch, the second must be dropped as a collision rather + than both being filed as proposals (which would cause a duplicate-proposal + crash on the second approve). + + Before the fix, taken_names was only seeded from already-on-disk pages + and pending proposals — it was never updated as drafts were accepted in + the phase-1 loop, so a sibling draft with the same title slipped through + _draft_problem's collision guard.""" + c1 = _approved_claim(store, "a fact about retries") + c2 = _approved_claim(store, "a second fact about retries") + cmd = _stub_llm(tmp_path, [ + { + "title": "Retry Policy", + "type": "decision", + "body": f"First version [claim: {c1}]", + "claims": [c1], + }, + { + "title": "Retry Policy", + "type": "decision", + "body": f"Duplicate version [claim: {c2}]", + "claims": [c2], + }, + ]) + report = compile_kb(store, config=_cfg(cmd)) + + # Only the first draft should be proposed; the second is a same-batch collision. + assert len(report.proposed) == 1 + assert report.proposed[0]["title"] == "Retry Policy" + assert len(report.dropped) == 1 + assert report.dropped[0]["title"] == "Retry Policy" + assert "already exists or is pending review" in report.dropped[0]["reason"] + + # Confirm only one pending proposal was filed. + pending = store.list_proposals(ProposalStatus.PENDING) + assert len(pending) == 1 + assert pending[0].payload["title"] == "Retry Policy" + + +def test_same_batch_duplicate_slug_drops_second( + store: KBStore, tmp_path: Path, +) -> None: + """Regression for #439: titles that slugify to the same id (e.g. 'Retry + Policy' and 'retry-policy') must also be caught as same-batch collisions, + since approve() uses the slug as the on-disk page id.""" + c1 = _approved_claim(store, "a fact about auth") + c2 = _approved_claim(store, "another fact about auth") + cmd = _stub_llm(tmp_path, [ + { + "title": "Auth Flow", + "type": "workflow", + "body": f"First [claim: {c1}]", + "claims": [c1], + }, + { + "title": "auth flow", # same slug as above + "type": "workflow", + "body": f"Duplicate slug [claim: {c2}]", + "claims": [c2], + }, + ]) + report = compile_kb(store, config=_cfg(cmd)) + + assert len(report.proposed) == 1 + assert len(report.dropped) == 1 + assert "already exists or is pending review" in report.dropped[0]["reason"]