From c4fbbaff7788d2e37805210ec77da58959cbd85a Mon Sep 17 00:00:00 2001 From: frankbria Date: Mon, 6 Jul 2026 20:09:35 -0700 Subject: [PATCH 1/2] fix(tasks): resolve/list tasks beyond the 100-task cap (#743) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLI resolved id-prefixes in Python over list_tasks (capped at 100, priority-ordered), so any task past the cap was unreachable by cf work start/stop/resume/diagnose, invisible in 'tasks list', and skipped by bulk 'tasks set --all'. - core: add find_by_prefix() — SQL 'id LIKE prefix% ESCAPE' lookup with no LIMIT (user-typed prefix escaped so a stray wildcard can't over-match). Route all 10 CLI prefix-resolution sites through it. - core: list_tasks(limit=None) omits the LIMIT clause; default stays 100 for API pagination. 'tasks list' and bulk 'set --all' pass limit=None. Closes #743 --- codeframe/cli/app.py | 37 +++++------- codeframe/core/tasks.py | 67 ++++++++++++++++++--- tests/core/test_tasks_truncation.py | 92 +++++++++++++++++++++++++++++ 3 files changed, 166 insertions(+), 30 deletions(-) create mode 100644 tests/core/test_tasks_truncation.py diff --git a/codeframe/cli/app.py b/codeframe/cli/app.py index d576bb61..8af782a0 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) @@ -2263,8 +2265,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 +2434,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 +2567,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 +2623,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 +2831,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 +2979,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 +3086,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 +3164,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}'") @@ -3798,10 +3792,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..ffd5323d --- /dev/null +++ b/tests/core/test_tasks_truncation.py @@ -0,0 +1,92 @@ +"""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") + shared = a.id[:1] + matches = tasks.find_by_prefix(workspace, shared) + ids = {t.id for t in matches} + assert a.id in ids and (b.id in ids or b.id[0] != shared) + + 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 From 8af15545a1135a0332e2930128d0523593bd5e93 Mon Sep 17 00:00:00 2001 From: frankbria Date: Mon, 6 Jul 2026 20:23:35 -0700 Subject: [PATCH 2/2] fix(tasks): extend #743 to batch-run + delete-all count; harden test Address review findings on PR #824: - batch run --all-ready/--all-blocked: pass limit=None so batches include READY/BLOCKED tasks beyond the 100 cap (was reintroducing the #743 truncation for batch execution). - tasks delete --all: count the confirm prompt with limit=None so it matches the uncapped delete_all() (avoids undercounting before a destructive op). - test: make the ambiguity test deterministic (empty prefix matches all) instead of relying on a ~1/16 first-hex-char collision. --- codeframe/cli/app.py | 10 ++++++---- tests/core/test_tasks_truncation.py | 7 ++++--- 2 files changed, 10 insertions(+), 7 deletions(-) diff --git a/codeframe/cli/app.py b/codeframe/cli/app.py index 8af782a0..08bf434b 100644 --- a/codeframe/cli/app.py +++ b/codeframe/cli/app.py @@ -2246,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) @@ -3772,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 @@ -3780,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 diff --git a/tests/core/test_tasks_truncation.py b/tests/core/test_tasks_truncation.py index ffd5323d..dc51de61 100644 --- a/tests/core/test_tasks_truncation.py +++ b/tests/core/test_tasks_truncation.py @@ -50,10 +50,11 @@ 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") - shared = a.id[:1] - matches = tasks.find_by_prefix(workspace, shared) + # 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 or b.id[0] != shared) + 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")