diff --git a/codeframe/cli/app.py b/codeframe/cli/app.py index d576bb61..08bf434b 100644 --- a/codeframe/cli/app.py +++ b/codeframe/cli/app.py @@ -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: @@ -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] @@ -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) @@ -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) @@ -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}'") @@ -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}'") @@ -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}'") @@ -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}'") @@ -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}'") @@ -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}'") @@ -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}'") @@ -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}'") @@ -3778,7 +3773,8 @@ 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 @@ -3786,7 +3782,7 @@ def batch_run( 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 @@ -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) diff --git a/codeframe/core/tasks.py b/codeframe/core/tasks.py index 742e159c..459308cc 100644 --- a/codeframe/core/tasks.py +++ b/codeframe/core/tasks.py @@ -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]: @@ -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() diff --git a/tests/core/test_tasks_truncation.py b/tests/core/test_tasks_truncation.py new file mode 100644 index 00000000..dc51de61 --- /dev/null +++ b/tests/core/test_tasks_truncation.py @@ -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