Skip to content

Commit 88724af

Browse files
committed
fix(core): six low-severity PRD, discovery and template defects (#961)
1. create_new_version's error path masked the real exception. A bare cursor.execute("ROLLBACK") raises "cannot rollback - no transaction is active" whenever the failure happened before BEGIN took effect, and that replacement propagated instead of the original fault. The rollback is now best-effort and logged; the original always re-raises. 2. Legacy PRDs vanished from `cf prd list`. The chain_id backfill ran only WHERE parent_id IS NULL, so every legacy *child* kept a NULL chain_id — and because SQL's NULL = NULL is never true, list_chains' INNER JOIN dropped those rows entirely, with no error. Two fixes: a recursive-CTE backfill that walks each row to its root (so children join their parent's chain instead of forming spurious one-row chains), re-run on upgrade via SCHEMA_VERSION 4->5; and COALESCE(chain_id, parent_id, id) in list_chains so a read stays correct for anything the migration cannot resolve, such as a child whose parent row is gone. 3. submit_answer validated against a None question. A completed or freshly-loaded session passed self._current_question — None — into _validate_answer, asking the model to judge an answer against no question. Now raises DiscoveryError naming which case it is. 4. reset_discovery without a session_id closed EVERY non-completed session in the workspace, contradicting its own docstring and destroying in-flight work on unrelated PRDs. Now targets only the most recent one. 5. apply_template silently dropped out-of-range dependency indices via `if 0 <= idx < len(created_tasks)`, producing a task graph that looked fine and executed in the wrong order. Indices are now validated BEFORE any task is created, so a bad template raises with the offending index and leaves no half-built set of tasks behind. 6. `cf prd stress-test` had no handler for provider failures. It caught StressTestError but not an auth/network/rate-limit error from the LLM, which escaped as a traceback — on a command whose whole body is a multi-call LLM run. Both LLM calls (the stress test and the ambiguity refine) now report an actionable message and exit 1; the refine handler also states plainly that no answers were saved. Closes #961
1 parent ce8a3b0 commit 88724af

6 files changed

Lines changed: 629 additions & 25 deletions

File tree

codeframe/cli/app.py

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1800,6 +1800,19 @@ def prd_stress_test(
18001800
# the CLI shows a traceback where every other failure here is a red line.
18011801
console.print(f"[red]Error:[/red] {e}")
18021802
raise typer.Exit(1)
1803+
except Exception as e:
1804+
# A provider failure (auth, network, rate limit) is not a StressTestError
1805+
# and used to escape as a raw traceback (#961). This is a multi-call LLM
1806+
# run, so it is one of the likelier ways the command ends.
1807+
console.print(
1808+
f"[red]Error:[/red] PRD stress test failed while calling the LLM "
1809+
f"provider: {e}"
1810+
)
1811+
console.print(
1812+
"[dim]Check your API key and network connection, then retry. "
1813+
"Use --llm-provider/--llm-model to try a different model.[/dim]"
1814+
)
1815+
raise typer.Exit(1)
18031816

18041817
# Show ambiguity report
18051818
if result.ambiguities:
@@ -1828,9 +1841,22 @@ def prd_stress_test(
18281841

18291842
# Update PRD with resolved answers
18301843
console.print("[dim]Updating PRD with resolved ambiguities...[/dim]")
1831-
updated_content = resolve_ambiguities_into_prd(
1832-
record.content, result.ambiguities, provider,
1833-
)
1844+
try:
1845+
updated_content = resolve_ambiguities_into_prd(
1846+
record.content, result.ambiguities, provider,
1847+
)
1848+
except Exception as e:
1849+
# Second LLM call of the command, and it had no handler at all
1850+
# (#961). Failing here must not lose the answers silently, nor
1851+
# print a traceback.
1852+
console.print(
1853+
f"[red]Error:[/red] Could not apply your answers to the PRD: {e}"
1854+
)
1855+
console.print(
1856+
"[dim]Your answers were not saved and no new version was "
1857+
"created. Re-run the command to try again.[/dim]"
1858+
)
1859+
raise typer.Exit(1)
18341860
# resolve_ambiguities_into_prd returns the ORIGINAL content when the
18351861
# LLM rewrite looks truncated. Creating a version from that would
18361862
# discard the answers the user just typed while printing "✓ PRD updated

codeframe/core/prd.py

Lines changed: 30 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
"""
88

99
import json
10+
import logging
1011
import re
1112
import uuid
1213
from dataclasses import dataclass
@@ -16,6 +17,8 @@
1617

1718
from codeframe.core.workspace import Workspace, get_db_connection
1819

20+
logger = logging.getLogger(__name__)
21+
1922

2023
def _utc_now() -> datetime:
2124
"""Get current UTC time as timezone-aware datetime."""
@@ -322,18 +325,28 @@ def list_chains(workspace: Workspace) -> list[PrdRecord]:
322325
conn = get_db_connection(workspace)
323326
cursor = conn.cursor()
324327

325-
# Get the latest version for each unique chain_id
328+
# Get the latest version for each unique chain.
329+
#
330+
# Grouping and joining on COALESCE(chain_id, parent_id, id) rather than
331+
# chain_id alone (#961): a legacy row can still carry a NULL chain_id, and
332+
# SQL's `NULL = NULL` is never true — so the INNER JOIN silently dropped
333+
# those PRDs from the list entirely, with no error. The upgrade path
334+
# backfills them, but this keeps the read correct for anything it misses
335+
# (e.g. a child whose parent row is gone).
326336
cursor.execute(
327337
"""
328338
SELECT p.id, p.workspace_id, p.title, p.content, p.metadata, p.created_at,
329339
p.version, p.parent_id, p.change_summary, p.chain_id
330340
FROM prds p
331341
INNER JOIN (
332-
SELECT chain_id, MAX(version) as max_version
342+
SELECT COALESCE(chain_id, parent_id, id) AS grp,
343+
MAX(version) as max_version
333344
FROM prds
334345
WHERE workspace_id = ?
335-
GROUP BY chain_id
336-
) latest ON p.chain_id = latest.chain_id AND p.version = latest.max_version
346+
GROUP BY COALESCE(chain_id, parent_id, id)
347+
) latest
348+
ON COALESCE(p.chain_id, p.parent_id, p.id) = latest.grp
349+
AND p.version = latest.max_version
337350
WHERE p.workspace_id = ?
338351
ORDER BY p.created_at DESC
339352
""",
@@ -610,7 +623,19 @@ def create_new_version(
610623
chain_id=chain_id,
611624
)
612625
except Exception:
613-
cursor.execute("ROLLBACK")
626+
# The rollback is best-effort cleanup, never the story (#961). A bare
627+
# cursor.execute("ROLLBACK") raises "cannot rollback - no transaction
628+
# is active" whenever the failure happened before BEGIN took effect or
629+
# after the transaction already ended — and that replacement exception
630+
# propagated instead of the real one, hiding the actual fault.
631+
try:
632+
cursor.execute("ROLLBACK")
633+
except Exception:
634+
logger.warning(
635+
"Rollback failed while unwinding create_new_version; the "
636+
"original error is re-raised.",
637+
exc_info=True,
638+
)
614639
raise
615640
finally:
616641
conn.close()

codeframe/core/prd_discovery.py

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -419,12 +419,29 @@ def submit_answer(self, answer_text: str) -> dict[str, Any]:
419419
420420
Raises:
421421
ValidationError: If answer is empty
422+
DiscoveryError: If the session is already complete, or has no
423+
question outstanding
422424
"""
423425
answer_text = answer_text.strip()
424426

425427
if not answer_text:
426428
raise ValidationError("Please provide an answer.")
427429

430+
# There is nothing to answer once discovery has finished (#961). Without
431+
# this, a completed or freshly-loaded session passed self._current_question
432+
# — None — straight into _validate_answer, which then asked the model to
433+
# judge an answer against no question at all.
434+
if self._is_complete:
435+
raise DiscoveryError(
436+
"This discovery session is already complete; there is no "
437+
"question to answer. Start a new session to continue."
438+
)
439+
if not self._current_question:
440+
raise DiscoveryError(
441+
"No question is currently outstanding for this session. "
442+
"Call get_current_question() first."
443+
)
444+
428445
# Validate with AI
429446
validation = self._validate_answer(self._current_question, answer_text)
430447

@@ -1174,12 +1191,21 @@ def reset_discovery(
11741191
(_utc_now().isoformat(), session_id, workspace.id),
11751192
)
11761193
else:
1177-
# Reset most recent non-completed session
1194+
# Reset ONLY the most recent non-completed session (#961). This used to
1195+
# update every row matching `state != 'completed'`, so a reset aimed at
1196+
# the session in front of the user also closed unrelated in-flight
1197+
# sessions belonging to other PRDs — silently destroying that work,
1198+
# and contradicting this function's own docstring.
11781199
cursor.execute(
11791200
"""
11801201
UPDATE discovery_sessions
11811202
SET state = 'completed', updated_at = ?
1182-
WHERE workspace_id = ? AND state != 'completed'
1203+
WHERE id = (
1204+
SELECT id FROM discovery_sessions
1205+
WHERE workspace_id = ? AND state != 'completed'
1206+
ORDER BY updated_at DESC, created_at DESC
1207+
LIMIT 1
1208+
)
11831209
""",
11841210
(_utc_now().isoformat(), workspace.id),
11851211
)

codeframe/core/templates.py

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,22 @@ def apply_template(
181181
issue_number=issue_number,
182182
)
183183

184+
# Validate dependency indices BEFORE creating anything (#961). These used
185+
# to be filtered with `if 0 <= idx < len(created_tasks)`, so a template
186+
# naming a task that does not exist produced a graph that looked fine and
187+
# then executed in the wrong order — silently. Checking up front also means
188+
# a bad template leaves no half-built set of tasks behind.
189+
for position, task_dict in enumerate(task_dicts):
190+
for idx in task_dict.get("depends_on_indices", []) or []:
191+
if not isinstance(idx, int) or not (0 <= idx < len(task_dicts)):
192+
raise ValueError(
193+
f"Template '{template_id}' task #{position} "
194+
f"('{task_dict.get('title', '?')}') declares dependency "
195+
f"index {idx!r}, which is out of range for a template with "
196+
f"{len(task_dicts)} tasks (valid: 0..{len(task_dicts) - 1}). "
197+
"Fix the template's depends_on_indices."
198+
)
199+
184200
# Create tasks using v2 API
185201
created_tasks: list[tuple[tasks.Task, list[int]]] = []
186202
for task_dict in task_dicts:
@@ -195,17 +211,13 @@ def apply_template(
195211
)
196212
created_tasks.append((task, task_dict.get("depends_on_indices", [])))
197213

198-
# Wire up dependencies using indices -> actual task IDs
214+
# Wire up dependencies using indices -> actual task IDs. Every index was
215+
# range-checked above, so no filtering is needed (and none should happen —
216+
# silently dropping one is the defect this replaced).
199217
for task, dep_indices in created_tasks:
200218
if dep_indices:
201-
# Map 0-based indices to actual task IDs
202-
depends_on_ids = [
203-
created_tasks[idx][0].id
204-
for idx in dep_indices
205-
if 0 <= idx < len(created_tasks)
206-
]
207-
if depends_on_ids:
208-
tasks.update_depends_on(workspace, task.id, depends_on_ids)
219+
depends_on_ids = [created_tasks[idx][0].id for idx in dep_indices]
220+
tasks.update_depends_on(workspace, task.id, depends_on_ids)
209221

210222
created_task_ids = [task.id for task, _ in created_tasks]
211223

codeframe/core/workspace.py

Lines changed: 26 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,8 @@ def _utc_now() -> datetime:
3636
# migration path exactly once. Gates #733: steady-state loads skip all DDL.
3737
# 3: batch_runs.config_reloads (#957).
3838
# 4: batch_runs.cloud_timeout_minutes (#959).
39-
SCHEMA_VERSION = 4
39+
# 5: prds.chain_id backfill for legacy child rows (#961).
40+
SCHEMA_VERSION = 5
4041

4142
# Per-workspace config file written by the Settings page (issue #556).
4243
# Owned by the UI layer today; kept here so a future core consumer can
@@ -647,13 +648,32 @@ def _ensure_schema_upgrades(db_path: Path) -> None:
647648
conn.commit()
648649
if "chain_id" not in prd_columns:
649650
cursor.execute("ALTER TABLE prds ADD COLUMN chain_id TEXT")
650-
# Backfill chain_id for existing PRDs (set to their own id if no parent)
651-
cursor.execute("""
652-
UPDATE prds SET chain_id = id
653-
WHERE chain_id IS NULL AND parent_id IS NULL
654-
""")
655651
conn.commit()
656652

653+
# Backfill any PRD still missing a chain_id (#961). This runs on every
654+
# upgrade, not only when the column is first added: the original
655+
# backfill above set chain_id only WHERE parent_id IS NULL, so every
656+
# legacy *child* row kept a NULL chain_id — and because SQL's
657+
# `NULL = NULL` never matches, list_chains' join dropped those PRDs
658+
# from the workspace entirely. The recursive CTE walks each row up to
659+
# its root so children join their parent's chain rather than forming
660+
# spurious one-row chains. Rows whose parent is missing keep their own
661+
# id (COALESCE), which is the best available answer.
662+
cursor.execute("""
663+
WITH RECURSIVE root_of(id, root) AS (
664+
SELECT id, id FROM prds WHERE parent_id IS NULL
665+
UNION ALL
666+
SELECT p.id, r.root
667+
FROM prds p JOIN root_of r ON p.parent_id = r.id
668+
)
669+
UPDATE prds
670+
SET chain_id = COALESCE(
671+
(SELECT root FROM root_of WHERE root_of.id = prds.id), id
672+
)
673+
WHERE chain_id IS NULL
674+
""")
675+
conn.commit()
676+
657677
# Add depends_on column to prds table if it doesn't exist
658678
# Re-check prd_columns as it may have changed
659679
cursor.execute("PRAGMA table_info(prds)")

0 commit comments

Comments
 (0)