Skip to content

Commit 9678833

Browse files
authored
fix(cli): tasks generate --overwrite must not wipe tasks on gen failure (#725) (#797)
* fix(cli): tasks generate --overwrite must not wipe tasks on gen failure (#725) --overwrite called delete_all() (committed) and only THEN ran generation. A transient LLM failure (rate limit / timeout / bad response) fell to a bare except that just printed the error — the whole task list was already irreversibly gone, no backup (checkpoints only restore statuses, not deleted rows). Generate first (new tasks are appended), then delete the originals only after success. On failure the originals are untouched and any partially-created new tasks are rolled back, so a failed generation is a no-op. Closes #725 * fix/test: guard rollback failure, note O(N^2), cover recursive path (#725 review)
1 parent 03abc38 commit 9678833

2 files changed

Lines changed: 143 additions & 27 deletions

File tree

codeframe/cli/app.py

Lines changed: 54 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -1846,40 +1846,67 @@ def tasks_generate(
18461846
console.print("Add one first: codeframe prd add <file.md>")
18471847
raise typer.Exit(1)
18481848

1849-
# Handle --overwrite
1850-
if overwrite:
1851-
existing = tasks.list_tasks(workspace)
1852-
if existing:
1853-
deleted = tasks.delete_all(workspace)
1854-
console.print(f"[dim]Cleared {deleted} existing tasks[/dim]")
1849+
# --overwrite must not lose the existing tasks if generation fails
1850+
# (#725). Generation persists new tasks alongside the old ones; we only
1851+
# delete the originals *after* it succeeds. On failure the originals are
1852+
# untouched and any partially-created new tasks are rolled back, so a
1853+
# transient LLM error (rate limit / timeout / bad response) is a no-op.
1854+
original_ids = (
1855+
{t.id for t in tasks.list_tasks(workspace)} if overwrite else set()
1856+
)
18551857

18561858
console.print(f"Generating tasks from PRD: [bold]{prd_record.title}[/bold]")
18571859

18581860
if not no_llm:
18591861
from codeframe.cli.validators import require_anthropic_api_key
18601862
require_anthropic_api_key()
18611863

1862-
if recursive:
1863-
console.print(f"[dim]Using recursive decomposition (max depth: {max_depth})...[/dim]")
1864-
1865-
from codeframe.adapters.llm import get_provider
1866-
from codeframe.core.task_tree import generate_task_tree, flatten_task_tree
1867-
1868-
provider = get_provider()
1869-
tree = generate_task_tree(
1870-
provider,
1871-
prd_record.content,
1872-
lineage=[],
1873-
depth=0,
1874-
max_depth=max_depth,
1875-
)
1876-
created = flatten_task_tree(tree, workspace, prd_id=prd_record.id)
1877-
elif no_llm:
1878-
console.print("[dim]Using simple extraction (--no-llm)[/dim]")
1879-
created = tasks.generate_from_prd(workspace, prd_record, use_llm=False)
1880-
else:
1881-
console.print("[dim]Using LLM for task generation...[/dim]")
1882-
created = tasks.generate_from_prd(workspace, prd_record, use_llm=True)
1864+
try:
1865+
if recursive:
1866+
console.print(f"[dim]Using recursive decomposition (max depth: {max_depth})...[/dim]")
1867+
1868+
from codeframe.adapters.llm import get_provider
1869+
from codeframe.core.task_tree import generate_task_tree, flatten_task_tree
1870+
1871+
provider = get_provider()
1872+
tree = generate_task_tree(
1873+
provider,
1874+
prd_record.content,
1875+
lineage=[],
1876+
depth=0,
1877+
max_depth=max_depth,
1878+
)
1879+
created = flatten_task_tree(tree, workspace, prd_id=prd_record.id)
1880+
elif no_llm:
1881+
console.print("[dim]Using simple extraction (--no-llm)[/dim]")
1882+
created = tasks.generate_from_prd(workspace, prd_record, use_llm=False)
1883+
else:
1884+
console.print("[dim]Using LLM for task generation...[/dim]")
1885+
created = tasks.generate_from_prd(workspace, prd_record, use_llm=True)
1886+
except Exception:
1887+
# Roll back any partial new tasks; the originals were never deleted.
1888+
# A failure *during* rollback must not mask the original generation
1889+
# error, so guard it and always re-raise the original.
1890+
if overwrite:
1891+
try:
1892+
for t in tasks.list_tasks(workspace):
1893+
if t.id not in original_ids:
1894+
tasks.delete(workspace, t.id)
1895+
except Exception:
1896+
console.print(
1897+
"[yellow]Warning:[/yellow] could not fully roll back "
1898+
"partially-generated tasks; run `cf tasks list` to check."
1899+
)
1900+
raise
1901+
1902+
# Generation succeeded — now it is safe to clear the old tasks. Each
1903+
# delete() re-scans the workspace to strip dependents (#724), so this is
1904+
# O(N^2); fine for typical task counts, revisit with a bulk delete if a
1905+
# workspace ever holds thousands of tasks.
1906+
if overwrite and original_ids:
1907+
for tid in original_ids:
1908+
tasks.delete(workspace, tid)
1909+
console.print(f"[dim]Cleared {len(original_ids)} existing tasks[/dim]")
18831910

18841911
# Emit event
18851912
emit_for_workspace(
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
"""`cf tasks generate --overwrite` must not lose tasks on a failed gen (#725/P0.14).
2+
3+
Previously --overwrite called delete_all() (committed) and only then ran LLM
4+
generation. A transient LLM failure irreversibly wiped the task list. The fix
5+
generates first and deletes the originals only on success.
6+
"""
7+
8+
from pathlib import Path
9+
from unittest.mock import patch
10+
11+
import pytest
12+
from typer.testing import CliRunner
13+
14+
from codeframe.cli.app import app
15+
from codeframe.core import prd, tasks
16+
from codeframe.core.workspace import create_or_load_workspace
17+
18+
pytestmark = pytest.mark.v2
19+
20+
runner = CliRunner()
21+
22+
23+
@pytest.fixture
24+
def ws_with_prd_and_tasks(tmp_path: Path):
25+
ws = create_or_load_workspace(tmp_path)
26+
prd.store(ws, content="# Demo PRD\n\nBuild a thing.", title="Demo")
27+
tasks.create(ws, title="Existing 1", description="keep me")
28+
tasks.create(ws, title="Existing 2", description="keep me too")
29+
return ws, tmp_path
30+
31+
32+
def test_overwrite_preserves_tasks_when_generation_fails(ws_with_prd_and_tasks):
33+
ws, path = ws_with_prd_and_tasks
34+
before = {t.id for t in tasks.list_tasks(ws)}
35+
assert len(before) == 2
36+
37+
# Simulate a transient generation failure (rate limit / timeout / bad response).
38+
with patch.object(
39+
tasks, "generate_from_prd", side_effect=RuntimeError("LLM timeout")
40+
):
41+
result = runner.invoke(
42+
app,
43+
["tasks", "generate", "--overwrite", "--no-llm", "-w", str(path)],
44+
)
45+
46+
assert result.exit_code != 0 # the failure surfaces
47+
# ...and the pre-existing tasks are intact (not wiped).
48+
after = {t.id for t in tasks.list_tasks(ws)}
49+
assert after == before
50+
51+
52+
def test_overwrite_preserves_tasks_when_recursive_generation_fails(ws_with_prd_and_tasks):
53+
"""The --recursive path (generate_task_tree) shares the same rollback."""
54+
import codeframe.core.task_tree as task_tree
55+
import codeframe.cli.validators as validators
56+
57+
ws, path = ws_with_prd_and_tasks
58+
before = {t.id for t in tasks.list_tasks(ws)}
59+
60+
with patch.object(validators, "require_anthropic_api_key", lambda: None), patch.object(
61+
task_tree, "generate_task_tree", side_effect=RuntimeError("LLM timeout")
62+
):
63+
result = runner.invoke(
64+
app,
65+
["tasks", "generate", "--overwrite", "--recursive", "-w", str(path)],
66+
)
67+
68+
assert result.exit_code != 0
69+
assert {t.id for t in tasks.list_tasks(ws)} == before
70+
71+
72+
def test_overwrite_replaces_tasks_on_success(ws_with_prd_and_tasks):
73+
ws, path = ws_with_prd_and_tasks
74+
before = {t.id for t in tasks.list_tasks(ws)}
75+
76+
def _fake_generate(workspace, prd_record, use_llm=True):
77+
return [tasks.create(workspace, title="Fresh task", description="new")]
78+
79+
with patch.object(tasks, "generate_from_prd", side_effect=_fake_generate):
80+
result = runner.invoke(
81+
app,
82+
["tasks", "generate", "--overwrite", "--no-llm", "-w", str(path)],
83+
)
84+
85+
assert result.exit_code == 0, result.output
86+
titles = {t.title for t in tasks.list_tasks(ws)}
87+
# Originals cleared, only the freshly generated task remains.
88+
assert titles == {"Fresh task"}
89+
assert not (before & {t.id for t in tasks.list_tasks(ws)})

0 commit comments

Comments
 (0)