Skip to content

Commit c9f865d

Browse files
authored
fix(batches): resolve batch ids beyond the 100-batch cap (#825)
## Summary - Add `conductor.find_batch_by_prefix` (SQL `LIKE 'prefix%'`, no cap, escapes LIKE metachars) so `cf work batch status/stop/resume/follow` can resolve batches beyond the newest 100 — same fix class as #743/#824 for tasks. - Replace the four `list_batches(limit=100)` + Python `startswith` resolution sites with the uncapped SQL lookup. - Add `len(matching) > 1` → "Multiple batches match" + `typer.Exit(1)` ambiguity guard to all four commands (mutating `stop`/`resume` previously acted silently / warned-then-proceeded), matching the task-side pattern — closes the widened-collision-surface gap the cross-family review flagged. - Regression tests: 6 core tests for `find_batch_by_prefix` + a parametrized `CliRunner` test proving each command rejects an ambiguous prefix. ## Validation - Tests: All passing (10/10 locally + full CI green) - Lint: Clean (ruff + mypy) - Test mutation check: Passed — ambiguity test fails without the guard - Internal review: Completed (advisory, Claude, 2 rounds) - Cross-family review: opencode (zai/glm-5.2) — initial REQUEST_CHANGES (Critical on batch_stop) resolved; GLM CI re-review + Claude follow-up confirm no new defects - Final feedback triage: cutoff 2026-07-20T18:48:39Z, all items triaged (2 non-blocking deferred with justification) - Demo: All acceptance criteria verified with real-CLI outcome evidence on a 156-batch workspace Closes #825
1 parent d793672 commit c9f865d

3 files changed

Lines changed: 222 additions & 15 deletions

File tree

codeframe/cli/app.py

Lines changed: 29 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4017,14 +4017,19 @@ def batch_status(
40174017

40184018
if batch_id:
40194019
# Show specific batch
4020-
# Find by partial ID
4021-
all_batches = conductor.list_batches(workspace, limit=100)
4022-
matching = [b for b in all_batches if b.id.startswith(batch_id)]
4020+
# Find by partial ID (SQL LIKE — no cap, resolves batches beyond 100)
4021+
matching = conductor.find_batch_by_prefix(workspace, batch_id)
40234022

40244023
if not matching:
40254024
console.print(f"[red]Error:[/red] No batch found matching '{batch_id}'")
40264025
raise typer.Exit(1)
40274026

4027+
if len(matching) > 1:
4028+
console.print(f"[red]Error:[/red] Multiple batches match '{batch_id}':")
4029+
for b in matching[:5]:
4030+
console.print(f" {b.id[:8]} ({b.status.value})")
4031+
raise typer.Exit(1)
4032+
40284033
batch = matching[0]
40294034

40304035
# Status color
@@ -4162,14 +4167,19 @@ def batch_stop(
41624167
try:
41634168
workspace = get_workspace(path)
41644169

4165-
# Find by partial ID
4166-
all_batches = conductor.list_batches(workspace, limit=100)
4167-
matching = [b for b in all_batches if b.id.startswith(batch_id)]
4170+
# Find by partial ID (SQL LIKE — no cap, resolves batches beyond 100)
4171+
matching = conductor.find_batch_by_prefix(workspace, batch_id)
41684172

41694173
if not matching:
41704174
console.print(f"[red]Error:[/red] No batch found matching '{batch_id}'")
41714175
raise typer.Exit(1)
41724176

4177+
if len(matching) > 1:
4178+
console.print(f"[red]Error:[/red] Multiple batches match '{batch_id}':")
4179+
for b in matching[:5]:
4180+
console.print(f" {b.id[:8]} ({b.status.value})")
4181+
raise typer.Exit(1)
4182+
41734183
batch = matching[0]
41744184

41754185
if batch.status.value not in ("PENDING", "RUNNING"):
@@ -4231,19 +4241,18 @@ def batch_resume(
42314241
try:
42324242
workspace = get_workspace(path)
42334243

4234-
# Find by partial ID
4235-
all_batches = conductor.list_batches(workspace, limit=100)
4236-
matching = [b for b in all_batches if b.id.startswith(batch_id)]
4244+
# Find by partial ID (SQL LIKE — no cap, resolves batches beyond 100)
4245+
matching = conductor.find_batch_by_prefix(workspace, batch_id)
42374246

42384247
if not matching:
42394248
console.print(f"[red]Error:[/red] No batch found matching '{batch_id}'")
42404249
raise typer.Exit(1)
42414250

42424251
if len(matching) > 1:
4243-
console.print(f"[yellow]Warning:[/yellow] Multiple batches match '{batch_id}':")
4252+
console.print(f"[red]Error:[/red] Multiple batches match '{batch_id}':")
42444253
for b in matching[:5]:
4245-
console.print(f" - {b.id[:8]} ({b.status.value})")
4246-
console.print("Using the most recent match.")
4254+
console.print(f" {b.id[:8]} ({b.status.value})")
4255+
raise typer.Exit(1)
42474256

42484257
batch = matching[0]
42494258

@@ -4430,14 +4439,19 @@ def _update_progress(progress: BatchProgress, event: events.Event) -> None:
44304439
try:
44314440
workspace = get_workspace(path)
44324441

4433-
# Find batch by partial ID
4434-
all_batches = conductor.list_batches(workspace, limit=100)
4435-
matching = [b for b in all_batches if b.id.startswith(batch_id)]
4442+
# Find batch by partial ID (SQL LIKE — no cap, resolves batches beyond 100)
4443+
matching = conductor.find_batch_by_prefix(workspace, batch_id)
44364444

44374445
if not matching:
44384446
console.print(f"[red]Error:[/red] No batch found matching '{batch_id}'")
44394447
raise typer.Exit(1)
44404448

4449+
if len(matching) > 1:
4450+
console.print(f"[red]Error:[/red] Multiple batches match '{batch_id}':")
4451+
for b in matching[:5]:
4452+
console.print(f" {b.id[:8]} ({b.status.value})")
4453+
raise typer.Exit(1)
4454+
44414455
batch = matching[0]
44424456
batch_id_short = batch.id[:8]
44434457

codeframe/core/conductor.py

Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -827,6 +827,48 @@ def list_batches(
827827
return [_row_to_batch(row) for row in rows]
828828

829829

830+
def find_batch_by_prefix(workspace: Workspace, prefix: str) -> list[BatchRun]:
831+
"""Resolve batches whose id starts with ``prefix`` (SQL ``LIKE``, no cap).
832+
833+
The CLI accepts partial batch ids. Doing that in Python over
834+
``list_batches(limit=100)`` meant any batch beyond the cap was unreachable
835+
(#825). This resolves at the SQL layer with no ``LIMIT`` so every matching
836+
batch is addressable regardless of how many batches exist.
837+
838+
Returns all matches (possibly >1) so callers can report ambiguity.
839+
840+
Args:
841+
workspace: Workspace to query
842+
prefix: Batch id prefix (user-typed; may be partial)
843+
844+
Returns:
845+
List of matching BatchRuns, newest first.
846+
"""
847+
# Batch IDs are UUIDs (no _/%), but the prefix is user-typed — escape LIKE
848+
# metachars so a stray wildcard can't over-match the wrong batch.
849+
escaped = prefix.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
850+
conn = get_db_connection(workspace)
851+
try:
852+
cursor = conn.cursor()
853+
cursor.execute(
854+
"""
855+
SELECT id, workspace_id, task_ids, status, strategy, max_parallel,
856+
on_failure, started_at, completed_at, results, engine,
857+
isolation, stall_timeout_s, stall_action, concurrency_by_status,
858+
llm_provider, llm_model
859+
FROM batch_runs
860+
WHERE workspace_id = ? AND id LIKE ? ESCAPE '\\'
861+
ORDER BY started_at DESC
862+
""",
863+
(workspace.id, f"{escaped}%"),
864+
)
865+
rows = cursor.fetchall()
866+
finally:
867+
conn.close()
868+
869+
return [_row_to_batch(row) for row in rows]
870+
871+
830872
def cancel_batch(workspace: Workspace, batch_id: str) -> BatchRun:
831873
"""Cancel a running batch.
832874
Lines changed: 151 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,151 @@
1+
"""Regression tests for #825 — CLI batch resolution truncated at 100.
2+
3+
The four ``cf work batch status/stop/resume/follow`` commands resolved a
4+
partial batch id in Python over ``conductor.list_batches(workspace, limit=100)``,
5+
so any batch beyond the first 100 was unreachable — the same class of bug as
6+
#743 for tasks.
7+
8+
Fix: a SQL ``LIKE 'prefix%'`` lookup (``find_batch_by_prefix``) that bypasses
9+
the cap, mirroring ``tasks.find_by_prefix``.
10+
"""
11+
12+
import uuid
13+
from datetime import datetime, timezone
14+
15+
import pytest
16+
17+
from codeframe.core import conductor
18+
from codeframe.core.conductor import (
19+
BatchRun,
20+
BatchStatus,
21+
OnFailure,
22+
_save_batch,
23+
)
24+
from codeframe.core.workspace import create_or_load_workspace
25+
26+
pytestmark = pytest.mark.v2
27+
28+
29+
def _utc_now() -> datetime:
30+
return datetime.now(timezone.utc)
31+
32+
33+
def _make_batch(workspace, *, status: BatchStatus = BatchStatus.PENDING) -> BatchRun:
34+
"""Insert a minimal BatchRun directly without spawning real workers."""
35+
batch = BatchRun(
36+
id=str(uuid.uuid4()),
37+
workspace_id=workspace.id,
38+
task_ids=[],
39+
status=status,
40+
strategy="serial",
41+
max_parallel=1,
42+
on_failure=OnFailure.CONTINUE,
43+
started_at=_utc_now(),
44+
completed_at=None,
45+
)
46+
_save_batch(workspace, batch)
47+
return batch
48+
49+
50+
@pytest.fixture
51+
def workspace(tmp_path):
52+
return create_or_load_workspace(tmp_path)
53+
54+
55+
@pytest.fixture
56+
def many_batches(workspace):
57+
"""150 batches — more than the old hard limit of 100."""
58+
created = [_make_batch(workspace) for _ in range(150)]
59+
return workspace, created
60+
61+
62+
class TestFindBatchByPrefixResolvesBeyond100:
63+
def test_resolves_batch_beyond_first_100(self, many_batches):
64+
"""AC1: a batch sorted past position 100 is resolvable by prefix."""
65+
workspace, created = many_batches
66+
# list_batches is newest-first; oldest batches (earliest indices) are
67+
# the ones a LIMIT 100 would drop from the top-100 window.
68+
target = created[0]
69+
70+
matches = conductor.find_batch_by_prefix(workspace, target.id[:8])
71+
72+
assert [b.id for b in matches] == [target.id]
73+
74+
def test_exact_full_id_resolves(self, workspace):
75+
"""A full UUID prefix still resolves correctly."""
76+
batch = _make_batch(workspace)
77+
matches = conductor.find_batch_by_prefix(workspace, batch.id)
78+
assert len(matches) == 1
79+
assert matches[0].id == batch.id
80+
81+
def test_no_match_returns_empty(self, workspace):
82+
_make_batch(workspace)
83+
assert conductor.find_batch_by_prefix(workspace, "zzzzzzzz-nope") == []
84+
85+
def test_ambiguous_prefix_returns_multiple(self, workspace):
86+
"""Empty prefix (or shared prefix) returns all matching batches."""
87+
a = _make_batch(workspace)
88+
b = _make_batch(workspace)
89+
# Empty prefix → LIKE '%' → matches everything
90+
matches = conductor.find_batch_by_prefix(workspace, "")
91+
ids = {m.id for m in matches}
92+
assert a.id in ids and b.id in ids
93+
94+
def test_like_metachar_prefix_does_not_over_match(self, workspace):
95+
"""A user-typed prefix with LIKE wildcards must not over-match.
96+
97+
Batch IDs are UUIDs (never contain ``_``/``%``), so a prefix that is
98+
only a wildcard should match nothing rather than every batch.
99+
"""
100+
_make_batch(workspace)
101+
_make_batch(workspace)
102+
assert conductor.find_batch_by_prefix(workspace, "%") == []
103+
assert conductor.find_batch_by_prefix(workspace, "_") == []
104+
105+
def test_backslash_in_prefix_does_not_crash(self, workspace):
106+
"""A backslash in the prefix is escaped and returns empty (no UUID match)."""
107+
_make_batch(workspace)
108+
assert conductor.find_batch_by_prefix(workspace, "\\") == []
109+
110+
111+
class TestCliAmbiguousPrefixIsRejected:
112+
"""Uncapped resolution widens the collision surface, so every batch CLI
113+
command must refuse an ambiguous prefix rather than silently act on the
114+
first match (matching the task-side ``Multiple tasks match`` guard). This
115+
is the merge-blocking finding from PR #872's cross-family review.
116+
"""
117+
118+
def _seed_colliding_pair(self, workspace) -> None:
119+
"""Two batches sharing the prefix ``dupe`` so ``dupe`` matches both."""
120+
for suffix in ("0001", "0002"):
121+
_save_batch(
122+
workspace,
123+
BatchRun(
124+
id=f"dupe{suffix}-1111-1111-1111-111111111111",
125+
workspace_id=workspace.id,
126+
task_ids=[],
127+
status=BatchStatus.PENDING,
128+
strategy="serial",
129+
max_parallel=1,
130+
on_failure=OnFailure.CONTINUE,
131+
started_at=_utc_now(),
132+
completed_at=None,
133+
),
134+
)
135+
136+
@pytest.mark.parametrize("subcommand", ["status", "stop", "resume", "follow"])
137+
def test_ambiguous_prefix_errors_and_exits(self, workspace, subcommand):
138+
from typer.testing import CliRunner
139+
140+
from codeframe.cli.app import app
141+
142+
self._seed_colliding_pair(workspace)
143+
runner = CliRunner(env={"COLUMNS": "200", "NO_COLOR": "1"})
144+
145+
result = runner.invoke(
146+
app,
147+
["work", "batch", subcommand, "dupe", "-w", str(workspace.repo_path)],
148+
)
149+
150+
assert result.exit_code == 1, result.output
151+
assert "Multiple batches match" in result.output

0 commit comments

Comments
 (0)