Skip to content

Commit 2312eb3

Browse files
committed
fix(core): PRD version integrity — no-op refinements and duplicate versions (#960)
1. The CLI reported success for a discarded refinement. resolve_ambiguities_into_prd returns the ORIGINAL content when the LLM rewrite looks truncated. prd_v2 guards that with a 502, but the CLI called create_new_version unconditionally and printed "✓ PRD updated to version N" — so the answers the user had just typed were thrown away while the tool claimed to have applied them. The CLI now detects the unchanged content, explains why (likely truncated model output), creates no version, and exits 1. Parity with prd_v2. 2. create_new_version claimed atomic increment but derived the number from the PARENT row, so two refines against the same parent both produced parent_version + 1 and get_version returned an arbitrary one of the duplicates. It now takes MAX(version) across the whole chain inside the existing BEGIN IMMEDIATE transaction — that lock already serialises writers, so a concurrent second refine blocks until the first commits and then reads the number it used. Branching off an older version also no longer reuses a number that a later version already took. A UNIQUE(workspace_id, chain_id, version) index was considered and deliberately not added: any workspace that already hit this bug holds duplicate rows, so CREATE UNIQUE INDEX would throw during schema upgrade and leave the workspace unopenable. That needs a repair migration, which is a larger change than this issue. BEGIN IMMEDIATE + MAX() is the acceptance criterion's other branch and carries no such hazard. Tests include a real two-thread concurrent double-refine asserting N+1 and N+2. Closes #960
1 parent 6c2e16a commit 2312eb3

3 files changed

Lines changed: 239 additions & 1 deletion

File tree

codeframe/cli/app.py

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1831,6 +1831,19 @@ def prd_stress_test(
18311831
updated_content = resolve_ambiguities_into_prd(
18321832
record.content, result.ambiguities, provider,
18331833
)
1834+
# resolve_ambiguities_into_prd returns the ORIGINAL content when the
1835+
# LLM rewrite looks truncated. Creating a version from that would
1836+
# discard the answers the user just typed while printing "✓ PRD updated
1837+
# to version N" — reporting success for a no-op (#960). prd_v2 already
1838+
# surfaces this as a 502; this is the CLI's half of that parity.
1839+
if updated_content == record.content:
1840+
console.print(
1841+
"[red]Error:[/red] PRD refinement produced no changes. The model "
1842+
"returned no usable output (it may have been truncated), so your "
1843+
"answers were not applied and no new version was created. "
1844+
"Please try again."
1845+
)
1846+
raise typer.Exit(1)
18341847
new_record = prd_module.create_new_version(
18351848
workspace, record.id, updated_content,
18361849
f"Stress-test: resolved {len([a for a in result.ambiguities if a.resolved_answer])} ambiguities",

codeframe/core/prd.py

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -554,11 +554,27 @@ def create_new_version(
554554

555555
prd_id = str(uuid.uuid4())
556556
now = _utc_now().isoformat()
557-
new_version = parent_version + 1
558557

559558
# Copy chain_id from parent (maintains version grouping)
560559
chain_id = parent_chain_id or parent_prd_id
561560

561+
# Number from MAX(version) across the CHAIN, inside this transaction —
562+
# not from the parent row (#960). Deriving it from the parent meant two
563+
# refines against the same parent both produced parent_version + 1, and
564+
# get_version then returned an arbitrary one of the duplicates. The
565+
# BEGIN IMMEDIATE above takes a RESERVED lock, so a concurrent writer
566+
# blocks here until we commit and then reads the number we just used.
567+
cursor.execute(
568+
"""
569+
SELECT MAX(version) FROM prds
570+
WHERE workspace_id = ? AND (chain_id = ? OR id = ?)
571+
""",
572+
(workspace.id, chain_id, chain_id),
573+
)
574+
max_row = cursor.fetchone()
575+
highest = max_row[0] if max_row and max_row[0] is not None else parent_version
576+
new_version = max(highest, parent_version) + 1
577+
562578
cursor.execute(
563579
"""
564580
INSERT INTO prds
Lines changed: 209 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,209 @@
1+
"""PRD version integrity (issue #960).
2+
3+
Two defects:
4+
5+
1. ``resolve_ambiguities_into_prd`` returns the ORIGINAL content when the LLM
6+
rewrite looks truncated. ``prd_v2`` guards that with a 502, but the CLI
7+
called ``create_new_version`` unconditionally and printed
8+
"✓ PRD updated to version N" — so the user's typed answers were discarded
9+
while the tool reported success.
10+
2. ``create_new_version`` claimed atomic increment but derived the new number
11+
from the *parent row*, with no uniqueness constraint. Two refines against the
12+
same parent produced two children numbered alike, and ``get_version``
13+
returned an arbitrary one.
14+
"""
15+
16+
from __future__ import annotations
17+
18+
import threading
19+
20+
import pytest
21+
22+
pytestmark = pytest.mark.v2
23+
24+
25+
# ─────────────────────────────────────────────────────────────────────────────
26+
# 2. Version numbers are unique within a chain
27+
# ─────────────────────────────────────────────────────────────────────────────
28+
29+
30+
class TestVersionNumbersAreUnique:
31+
def _seed(self, tmp_path):
32+
from codeframe.core import prd
33+
from codeframe.core.workspace import create_or_load_workspace
34+
35+
ws = create_or_load_workspace(tmp_path)
36+
root = prd.store(ws, content="# v1 original content", title="P")
37+
return ws, root
38+
39+
def test_sequential_refines_increment(self, tmp_path):
40+
from codeframe.core import prd
41+
42+
ws, root = self._seed(tmp_path)
43+
v2 = prd.create_new_version(ws, root.id, "# v2", "second")
44+
v3 = prd.create_new_version(ws, v2.id, "# v3", "third")
45+
46+
assert (v2.version, v3.version) == (2, 3)
47+
48+
def test_two_refines_against_the_same_parent_do_not_collide(self, tmp_path):
49+
"""The core defect: both children derived parent_version + 1."""
50+
from codeframe.core import prd
51+
52+
ws, root = self._seed(tmp_path)
53+
a = prd.create_new_version(ws, root.id, "# branch a", "a")
54+
b = prd.create_new_version(ws, root.id, "# branch b", "b")
55+
56+
assert a.version != b.version, "both children took the same version number"
57+
assert {a.version, b.version} == {2, 3}
58+
59+
def test_get_version_is_unambiguous_after_two_refines(self, tmp_path):
60+
from codeframe.core import prd
61+
62+
ws, root = self._seed(tmp_path)
63+
prd.create_new_version(ws, root.id, "# branch a", "a")
64+
prd.create_new_version(ws, root.id, "# branch b", "b")
65+
66+
versions = prd.get_versions(ws, root.id)
67+
numbers = [v.version for v in versions]
68+
assert len(numbers) == len(set(numbers)), f"duplicate versions: {numbers}"
69+
# Every number resolves to exactly the record carrying it.
70+
for v in versions:
71+
assert prd.get_version(ws, root.id, v.version).id == v.id
72+
73+
def test_concurrent_double_refine_produces_n_plus_1_and_n_plus_2(self, tmp_path):
74+
"""The acceptance criterion, run for real on two threads."""
75+
from codeframe.core import prd
76+
77+
ws, root = self._seed(tmp_path)
78+
results: list = []
79+
errors: list = []
80+
barrier = threading.Barrier(2)
81+
82+
def refine(tag: str):
83+
try:
84+
barrier.wait(timeout=10)
85+
results.append(prd.create_new_version(ws, root.id, f"# {tag}", tag))
86+
except Exception as exc: # pragma: no cover - surfaced below
87+
errors.append(exc)
88+
89+
threads = [threading.Thread(target=refine, args=(t,)) for t in ("a", "b")]
90+
for t in threads:
91+
t.start()
92+
for t in threads:
93+
t.join(timeout=30)
94+
95+
assert not errors, f"refine raised: {errors}"
96+
assert len(results) == 2
97+
assert sorted(r.version for r in results) == [2, 3]
98+
99+
def test_version_is_max_of_chain_not_of_parent(self, tmp_path):
100+
"""Refining an OLD version must not reuse a number already taken."""
101+
from codeframe.core import prd
102+
103+
ws, root = self._seed(tmp_path)
104+
v2 = prd.create_new_version(ws, root.id, "# v2", "b")
105+
v3 = prd.create_new_version(ws, v2.id, "# v3", "c")
106+
assert v3.version == 3
107+
108+
# Branch off the ROOT again: 2 and 3 are taken, so this must be 4.
109+
branched = prd.create_new_version(ws, root.id, "# branch", "d")
110+
assert branched.version == 4
111+
assert branched.parent_id == root.id, "parent linkage must be preserved"
112+
113+
def test_missing_parent_still_returns_none(self, tmp_path):
114+
from codeframe.core import prd
115+
from codeframe.core.workspace import create_or_load_workspace
116+
117+
ws = create_or_load_workspace(tmp_path)
118+
assert prd.create_new_version(ws, "no-such-id", "x", "y") is None
119+
120+
121+
# ─────────────────────────────────────────────────────────────────────────────
122+
# 1. The CLI refine path reports failure instead of a phantom version
123+
# ─────────────────────────────────────────────────────────────────────────────
124+
125+
126+
class TestCliRefineDetectsNoOp:
127+
def test_cli_checks_for_unchanged_content_before_versioning(self):
128+
"""Parity with prd_v2, which returns 502 on an unchanged rewrite."""
129+
import inspect
130+
131+
from codeframe.cli import app as cli_app
132+
133+
source = inspect.getsource(cli_app.prd_stress_test)
134+
refine = source.split("resolve_ambiguities_into_prd(", 1)
135+
assert len(refine) == 2, "expected the refine call in prd stress-test"
136+
after = refine[1]
137+
create_at = after.find("create_new_version")
138+
assert create_at != -1, "expected create_new_version after the refine"
139+
# The guard must sit between the two. Match the comparison itself, not
140+
# a bare "record.content" — that also appears in the refine call's own
141+
# argument list, which made an earlier version of this test vacuous.
142+
between = after[:create_at]
143+
assert "updated_content == record.content" in between, (
144+
"no unchanged-content guard between refine and create_new_version"
145+
)
146+
147+
def test_unchanged_rewrite_creates_no_version_and_exits_nonzero(self, tmp_path):
148+
from unittest.mock import patch
149+
150+
from typer.testing import CliRunner
151+
152+
from codeframe.core import prd
153+
from codeframe.core.prd_stress_test import (
154+
Ambiguity,
155+
Classification,
156+
DecompositionNode,
157+
StressTestResult,
158+
)
159+
from codeframe.core.workspace import create_or_load_workspace
160+
161+
ws = create_or_load_workspace(tmp_path)
162+
original = "# Original PRD\n\nSome content that will not change."
163+
record = prd.store(ws, content=original, title="P")
164+
165+
amb = Ambiguity(
166+
id="a1",
167+
source_node_title="Node",
168+
label="Which database?",
169+
questions=["Postgres or SQLite?"],
170+
recommendation="",
171+
)
172+
fake_result = StressTestResult(
173+
prd_title="P",
174+
tree=[
175+
DecompositionNode(
176+
id="n1", title="Goal", description="d",
177+
classification=Classification.ATOMIC,
178+
children=[], lineage=[], depth=0,
179+
)
180+
],
181+
ambiguities=[amb],
182+
tech_spec_markdown="# Spec",
183+
ambiguity_report="",
184+
)
185+
186+
from codeframe.cli import app as cli_app
187+
188+
with patch.object(cli_app, "console"), \
189+
patch("codeframe.core.prd_stress_test.stress_test_prd", return_value=fake_result), \
190+
patch("codeframe.core.llm_resolution.create_provider"), \
191+
patch("codeframe.cli.validators.require_api_key_for_provider"), \
192+
patch(
193+
"codeframe.core.prd_stress_test.resolve_ambiguities_into_prd",
194+
return_value=original, # the truncated-rewrite fallback
195+
):
196+
runner = CliRunner()
197+
result = runner.invoke(
198+
cli_app.app,
199+
["prd", "stress-test", "--interactive", "--workspace", str(tmp_path)],
200+
input="my answer\n",
201+
)
202+
203+
# Whatever the exact exit path, no phantom version may exist.
204+
versions = prd.get_versions(ws, record.id)
205+
assert len(versions) == 1, (
206+
f"a version was created from an unchanged rewrite: "
207+
f"{[v.version for v in versions]}"
208+
)
209+
assert result.exit_code != 0, "an unusable refinement must not report success"

0 commit comments

Comments
 (0)