Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 21 additions & 26 deletions codeframe/cli/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -1993,7 +1993,8 @@ def tasks_list(
if status:
status_filter = parse_status(status)

task_list = tasks.list_tasks(workspace, status=status_filter)
# limit=None so tasks beyond the default cap are listed/counted (#743)
task_list = tasks.list_tasks(workspace, status=status_filter, limit=None)

if not task_list:
if status_filter:
Expand Down Expand Up @@ -2127,11 +2128,12 @@ def tasks_set(
actual_task_id = task_id

new_status = parse_status(actual_value)
all_workspace_tasks = tasks.list_tasks(workspace)

# Determine which tasks to update
if all_tasks_flag:
# Bulk update mode
# Bulk update mode — limit=None so tasks beyond the default cap are
# not silently skipped (#743)
all_workspace_tasks = tasks.list_tasks(workspace, limit=None)
if from_status:
filter_status = parse_status(from_status)
matching = [t for t in all_workspace_tasks if t.status == filter_status]
Expand All @@ -2145,7 +2147,7 @@ def tasks_set(
raise typer.Exit(0)
elif actual_task_id:
# Single task mode
matching = [t for t in all_workspace_tasks if t.id.startswith(actual_task_id)]
matching = tasks.find_by_prefix(workspace, actual_task_id)
if not matching:
console.print(f"[red]Error:[/red] No task found matching '{actual_task_id}'")
raise typer.Exit(1)
Expand Down Expand Up @@ -2244,8 +2246,9 @@ def tasks_delete(
workspace = get_workspace(workspace_path)

if all_tasks_flag:
# Delete all tasks
task_list = tasks.list_tasks(workspace)
# Delete all tasks — limit=None so the confirm count matches the
# uncapped delete_all() below (#743)
task_list = tasks.list_tasks(workspace, limit=None)
if not task_list:
console.print("[yellow]No tasks to delete[/yellow]")
raise typer.Exit(0)
Expand All @@ -2263,8 +2266,7 @@ def tasks_delete(

elif task_id:
# Delete single task
all_tasks = tasks.list_tasks(workspace)
matching = [t for t in all_tasks if t.id.startswith(task_id)]
matching = tasks.find_by_prefix(workspace, task_id)

if not matching:
console.print(f"[red]Error:[/red] No task found matching '{task_id}'")
Expand Down Expand Up @@ -2433,8 +2435,7 @@ def work_start(
workspace = get_workspace(path)

# Find task by partial ID
all_tasks = tasks_module.list_tasks(workspace)
matching = [t for t in all_tasks if t.id.startswith(task_id)]
matching = tasks_module.find_by_prefix(workspace, task_id)

if not matching:
console.print(f"[red]Error:[/red] No task found matching '{task_id}'")
Expand Down Expand Up @@ -2567,8 +2568,7 @@ def work_resume(
workspace = get_workspace(path)

# Find task by partial ID
all_tasks = tasks_module.list_tasks(workspace)
matching = [t for t in all_tasks if t.id.startswith(task_id)]
matching = tasks_module.find_by_prefix(workspace, task_id)

if not matching:
console.print(f"[red]Error:[/red] No task found matching '{task_id}'")
Expand Down Expand Up @@ -2624,8 +2624,7 @@ def work_stop(
workspace = get_workspace(path)

# Find task by partial ID
all_tasks = tasks_module.list_tasks(workspace)
matching = [t for t in all_tasks if t.id.startswith(task_id)]
matching = tasks_module.find_by_prefix(workspace, task_id)

if not matching:
console.print(f"[red]Error:[/red] No task found matching '{task_id}'")
Expand Down Expand Up @@ -2833,8 +2832,7 @@ def work_diagnose(
workspace = get_workspace(path)

# Find task by partial ID
all_tasks = tasks_module.list_tasks(workspace)
matching = [t for t in all_tasks if t.id.startswith(task_id)]
matching = tasks_module.find_by_prefix(workspace, task_id)

if not matching:
console.print(f"[red]Error:[/red] No task found matching '{task_id}'")
Expand Down Expand Up @@ -2982,8 +2980,7 @@ def work_retry(
workspace = get_workspace(path)

# Find task by partial ID
all_tasks = tasks_module.list_tasks(workspace)
matching = [t for t in all_tasks if t.id.startswith(task_id)]
matching = tasks_module.find_by_prefix(workspace, task_id)

if not matching:
console.print(f"[red]Error:[/red] No task found matching '{task_id}'")
Expand Down Expand Up @@ -3090,8 +3087,7 @@ def work_update_description(
workspace = get_workspace(path)

# Find task by partial ID
all_tasks = tasks_module.list_tasks(workspace)
matching = [t for t in all_tasks if t.id.startswith(task_id)]
matching = tasks_module.find_by_prefix(workspace, task_id)

if not matching:
console.print(f"[red]Error:[/red] No task found matching '{task_id}'")
Expand Down Expand Up @@ -3169,8 +3165,7 @@ def work_follow(
workspace = get_workspace(path)

# Find task by partial ID
all_tasks = tasks_module.list_tasks(workspace)
matching = [t for t in all_tasks if t.id.startswith(task_id)]
matching = tasks_module.find_by_prefix(workspace, task_id)

if not matching:
console.print(f"[red]Error:[/red] No task found matching '{task_id}'")
Expand Down Expand Up @@ -3778,15 +3773,16 @@ def batch_run(

# Determine which tasks to execute
if all_ready:
ready_tasks = tasks_module.list_tasks(workspace, status=TaskStatus.READY)
# limit=None so batches include READY tasks beyond the cap (#743)
ready_tasks = tasks_module.list_tasks(workspace, status=TaskStatus.READY, limit=None)
if not ready_tasks:
console.print("[yellow]No READY tasks found[/yellow]")
return
ids_to_execute = [t.id for t in ready_tasks]
console.print(f"Found {len(ids_to_execute)} READY tasks")
elif all_blocked:
from codeframe.core import runtime
blocked_tasks = tasks_module.list_tasks(workspace, status=TaskStatus.BLOCKED)
blocked_tasks = tasks_module.list_tasks(workspace, status=TaskStatus.BLOCKED, limit=None)
if not blocked_tasks:
console.print("[yellow]No BLOCKED tasks found[/yellow]")
return
Expand All @@ -3798,10 +3794,9 @@ def batch_run(
runtime.reset_blocked_run(workspace, task_id)
elif task_ids:
# Resolve partial IDs
all_tasks = tasks_module.list_tasks(workspace)
ids_to_execute = []
for partial_id in task_ids:
matching = [t for t in all_tasks if t.id.startswith(partial_id)]
matching = tasks_module.find_by_prefix(workspace, partial_id)
if not matching:
console.print(f"[red]Error:[/red] No task found matching '{partial_id}'")
raise typer.Exit(1)
Expand Down
67 changes: 59 additions & 8 deletions codeframe/core/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -190,6 +190,46 @@ def get(workspace: Workspace, task_id: str) -> Optional[Task]:
return _row_to_task(row)


def find_by_prefix(workspace: Workspace, prefix: str) -> list[Task]:
"""Resolve tasks whose id starts with ``prefix`` (SQL ``LIKE``, no cap).

The CLI accepts partial task ids. Doing that in Python over ``list_tasks``
truncated matching to the first 100 (priority-ordered) tasks, so any task
beyond the cap was unreachable (#743). This resolves at the SQL layer with
no ``LIMIT`` so every matching task is addressable.

Returns all matches (possibly >1) so callers can report ambiguity.

Args:
workspace: Workspace to query
prefix: Task id prefix (user-typed; may be partial)

Returns:
List of matching Tasks, ordered like ``list_tasks``.
"""
# ponytail: task ids are UUIDs (no _/% ), but the prefix is user-typed —
# escape LIKE metachars so a stray wildcard can't over-match and resolve
# the wrong task before a status change.
escaped = prefix.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
conn = get_db_connection(workspace)
try:
cursor = conn.cursor()
cursor.execute(
"""
SELECT id, workspace_id, prd_id, title, description, status, priority, depends_on, estimated_hours, complexity_score, uncertainty_level, created_at, updated_at, github_issue_number, parent_id, lineage, is_leaf, hierarchical_id, requirement_ids, external_url, auto_close_github_issue
FROM tasks
WHERE workspace_id = ? AND id LIKE ? ESCAPE '\\'
ORDER BY priority ASC, created_at ASC
""",
(workspace.id, f"{escaped}%"),
)
rows = cursor.fetchall()
finally:
conn.close()

return [_row_to_task(row) for row in rows]


def get_by_external_url(
workspace: Workspace, external_url: str
) -> Optional[Task]:
Expand Down Expand Up @@ -277,43 +317,54 @@ def update_auto_close(
def list_tasks(
workspace: Workspace,
status: Optional[TaskStatus] = None,
limit: int = 100,
limit: Optional[int] = 100,
) -> list[Task]:
"""List tasks in a workspace.

Args:
workspace: Workspace to query
status: Optional status filter
limit: Maximum tasks to return
limit: Maximum tasks to return. ``None`` returns every task (uncapped)
so listing/bulk ops can address tasks beyond the default cap (#743).

Returns:
List of Tasks
"""
# ponytail: default stays 100 for backward-compat (API pagination relies on
# it); pass limit=None to opt into an uncapped list.
limit_clause = "" if limit is None else "LIMIT ?"

conn = get_db_connection(workspace)
try:
cursor = conn.cursor()

if status:
params: tuple = (workspace.id, status.value)
if limit is not None:
params += (limit,)
cursor.execute(
"""
f"""
SELECT id, workspace_id, prd_id, title, description, status, priority, depends_on, estimated_hours, complexity_score, uncertainty_level, created_at, updated_at, github_issue_number, parent_id, lineage, is_leaf, hierarchical_id, requirement_ids, external_url, auto_close_github_issue
FROM tasks
WHERE workspace_id = ? AND status = ?
ORDER BY priority ASC, created_at ASC
LIMIT ?
{limit_clause}
""",
(workspace.id, status.value, limit),
params,
)
else:
params = (workspace.id,)
if limit is not None:
params += (limit,)
cursor.execute(
"""
f"""
SELECT id, workspace_id, prd_id, title, description, status, priority, depends_on, estimated_hours, complexity_score, uncertainty_level, created_at, updated_at, github_issue_number, parent_id, lineage, is_leaf, hierarchical_id, requirement_ids, external_url, auto_close_github_issue
FROM tasks
WHERE workspace_id = ?
ORDER BY priority ASC, created_at ASC
LIMIT ?
{limit_clause}
""",
(workspace.id, limit),
params,
)

rows = cursor.fetchall()
Expand Down
93 changes: 93 additions & 0 deletions tests/core/test_tasks_truncation.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
"""Regression tests for #743 — CLI task resolution/listing truncated at 100.

`list_tasks` defaulted to ``limit=100`` and the CLI resolved id-prefixes in
Python over that truncated, ``priority ASC`` ordered list, so any task beyond
the first 100 was unreachable by ``cf work start/stop/resume/diagnose``,
invisible in ``tasks list``, and skipped by bulk updates.

Fix: a SQL ``LIKE 'prefix%'`` lookup (`find_by_prefix`) that bypasses the cap
for resolution, and an opt-in unbounded ``limit=None`` for listing/bulk ops.
"""

import pytest

from codeframe.core import tasks
from codeframe.core.workspace import create_or_load_workspace

pytestmark = pytest.mark.v2


@pytest.fixture
def workspace(tmp_path):
return create_or_load_workspace(tmp_path)


@pytest.fixture
def many_tasks(workspace):
"""150 tasks with ascending priority so the 101st+ sort *after* the cap.

``list_tasks`` orders by ``priority ASC``; giving each task a distinct,
increasing priority guarantees the last-created tasks are exactly the ones
a ``LIMIT 100`` would drop.
"""
created = [
tasks.create(workspace, title=f"task {i}", priority=i) for i in range(150)
]
return workspace, created


class TestResolutionBypassesLimit:
def test_find_by_prefix_resolves_task_beyond_first_100(self, many_tasks):
"""AC1: a task sorted past position 100 is still resolvable by prefix."""
workspace, created = many_tasks
target = created[149] # highest priority → last in ORDER BY, dropped by cap

matches = tasks.find_by_prefix(workspace, target.id[:8])

assert [t.id for t in matches] == [target.id]

def test_find_by_prefix_returns_all_ambiguous_matches(self, workspace):
"""Ambiguity detection still works — callers report >1 match."""
a = tasks.create(workspace, title="a")
b = tasks.create(workspace, title="b")
# Empty prefix matches every id (LIKE '%'), so ambiguity is guaranteed
# rather than left to a ~1/16 first-hex-char collision.
matches = tasks.find_by_prefix(workspace, "")
ids = {t.id for t in matches}
assert a.id in ids and b.id in ids

def test_find_by_prefix_no_match_returns_empty(self, workspace):
tasks.create(workspace, title="x")
assert tasks.find_by_prefix(workspace, "zzzzzzzz-nope") == []

def test_find_by_prefix_escapes_like_wildcards(self, workspace):
"""A user-typed prefix containing LIKE metachars must not over-match.

Task IDs are UUIDs (never contain ``_``/``%``), so a prefix with a raw
wildcard should match nothing rather than every task.
"""
tasks.create(workspace, title="one")
tasks.create(workspace, title="two")
assert tasks.find_by_prefix(workspace, "%") == []
assert tasks.find_by_prefix(workspace, "_") == []


class TestListingRespectsLimit:
def test_default_limit_still_caps_at_100(self, many_tasks):
"""Backward-compat: the default cap is unchanged (API pagination relies on it)."""
workspace, _ = many_tasks
assert len(tasks.list_tasks(workspace)) == 100

def test_limit_none_returns_all_tasks(self, many_tasks):
"""AC2: unbounded listing sees every task so counts/bulk ops are complete."""
workspace, created = many_tasks
listed = tasks.list_tasks(workspace, limit=None)
assert len(listed) == len(created) == 150

def test_limit_none_with_status_filter(self, workspace):
for i in range(120):
tasks.create(workspace, title=f"t{i}", priority=i)
from codeframe.core.state_machine import TaskStatus

listed = tasks.list_tasks(workspace, status=TaskStatus.BACKLOG, limit=None)
assert len(listed) == 120
Loading