Skip to content

Commit a979b35

Browse files
authored
fix(conductor): make batch cancellation authoritative (#726) (#798)
cancel_batch/stop_batch wrote status=CANCELLED, but a still-running worker's whole-row 'INSERT OR REPLACE ... batch.status' (RUNNING) overwrote it; the loop re-read RUNNING and kept launching tasks, spending tokens. _save_batch now refuses to resurrect a persisted CANCELLED batch to a live status (preserve_terminal_cancel, default on): it leaves the terminal record untouched and flips the in-memory batch.status to CANCELLED so the execution loop's existing get_batch check breaks. resume_batch passes preserve_terminal_cancel=False for its intentional CANCELLED->RUNNING re-run. Closes #726
1 parent 9678833 commit a979b35

2 files changed

Lines changed: 80 additions & 3 deletions

File tree

codeframe/core/conductor.py

Lines changed: 29 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -992,10 +992,11 @@ def resume_batch(
992992
if on_event:
993993
on_event("batch_resumed", {"batch_id": batch_id, "task_count": len(tasks_to_run)})
994994

995-
# Update status to running
995+
# Update status to running. This is the one intentional CANCELLED->RUNNING
996+
# transition, so bypass the terminal-cancel guard (#726).
996997
batch.status = BatchStatus.RUNNING
997998
batch.completed_at = None # Clear completed_at since we're running again
998-
_save_batch(workspace, batch)
999+
_save_batch(workspace, batch, preserve_terminal_cancel=False)
9991000

10001001
# Execute the tasks
10011002
_execute_serial_resume(workspace, batch, tasks_to_run, on_event)
@@ -2185,11 +2186,22 @@ def _execute_task_subprocess(
21852186
return RunStatus.FAILED.value
21862187

21872188

2188-
def _save_batch(workspace: Workspace, batch: BatchRun) -> None:
2189+
def _save_batch(
2190+
workspace: Workspace,
2191+
batch: BatchRun,
2192+
preserve_terminal_cancel: bool = True,
2193+
) -> None:
21892194
"""Save or update a batch record in the database.
21902195
21912196
Uses a thread lock to prevent SQLite "database is locked" errors
21922197
when multiple workers try to update batch status concurrently.
2198+
2199+
When ``preserve_terminal_cancel`` is True (the default), a whole-row write
2200+
that would resurrect a persisted CANCELLED batch back to a live status is
2201+
refused (#726): a task-completion save from a still-running worker must not
2202+
undo a concurrent cancel. The in-memory ``batch.status`` is also flipped to
2203+
CANCELLED so the execution loop stops launching tasks. ``resume_batch``
2204+
passes ``preserve_terminal_cancel=False`` to intentionally re-run.
21932205
"""
21942206
task_ids_json = json.dumps(batch.task_ids)
21952207
results_json = json.dumps(batch.results) if batch.results else None
@@ -2208,6 +2220,20 @@ def _save_batch(workspace: Workspace, batch: BatchRun) -> None:
22082220
except Exception:
22092221
pass # Column already exists
22102222

2223+
# Cancellation is authoritative (#726): don't let a whole-row write
2224+
# from a still-running worker resurrect a batch a concurrent
2225+
# cancel_batch/stop_batch already marked CANCELLED.
2226+
if preserve_terminal_cancel and batch.status != BatchStatus.CANCELLED:
2227+
cursor.execute(
2228+
"SELECT status FROM batch_runs WHERE id = ?", (batch.id,)
2229+
)
2230+
existing = cursor.fetchone()
2231+
if existing and existing[0] == BatchStatus.CANCELLED.value:
2232+
# Reflect the cancel into the in-memory batch so the loop
2233+
# stops, and leave the persisted terminal record untouched.
2234+
batch.status = BatchStatus.CANCELLED
2235+
return
2236+
22112237
cursor.execute(
22122238
"""
22132239
INSERT OR REPLACE INTO batch_runs

tests/core/test_conductor.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -305,6 +305,57 @@ def test_cancel_pending_batch(self, workspace_with_tasks):
305305
assert cancelled.completed_at is not None
306306

307307

308+
class TestCancellationIsAuthoritative:
309+
"""#726 / P0.15: a concurrent cancel must not be resurrected by a
310+
still-running worker's whole-row save."""
311+
312+
def _running_batch(self, workspace, task_ids):
313+
from datetime import datetime, timezone
314+
315+
batch = BatchRun(
316+
id="test-cancel-race",
317+
workspace_id=workspace.id,
318+
task_ids=task_ids,
319+
status=BatchStatus.RUNNING,
320+
strategy="serial",
321+
max_parallel=4,
322+
on_failure=OnFailure.CONTINUE,
323+
started_at=datetime.now(timezone.utc),
324+
completed_at=None,
325+
results={},
326+
)
327+
_save_batch(workspace, batch)
328+
return batch
329+
330+
def test_worker_save_does_not_resurrect_cancelled(self, workspace_with_tasks):
331+
workspace, task_list = workspace_with_tasks
332+
worker_view = self._running_batch(workspace, [task_list[0].id])
333+
334+
# A concurrent cancel marks the persisted batch CANCELLED.
335+
cancel_batch(workspace, worker_view.id)
336+
337+
# The still-running worker finishes a task and saves the whole row with
338+
# its stale RUNNING in-memory status.
339+
worker_view.status = BatchStatus.RUNNING
340+
worker_view.results = {task_list[0].id: "COMPLETED"}
341+
_save_batch(workspace, worker_view)
342+
343+
# Persisted status stays CANCELLED (not resurrected), and the in-memory
344+
# view is flipped so the execution loop will stop.
345+
assert get_batch(workspace, worker_view.id).status == BatchStatus.CANCELLED
346+
assert worker_view.status == BatchStatus.CANCELLED
347+
348+
def test_resume_can_still_transition_cancelled_to_running(self, workspace_with_tasks):
349+
workspace, task_list = workspace_with_tasks
350+
batch = self._running_batch(workspace, [task_list[0].id])
351+
cancel_batch(workspace, batch.id)
352+
353+
# resume_batch bypasses the guard (preserve_terminal_cancel=False).
354+
batch.status = BatchStatus.RUNNING
355+
_save_batch(workspace, batch, preserve_terminal_cancel=False)
356+
assert get_batch(workspace, batch.id).status == BatchStatus.RUNNING
357+
358+
308359
class TestStopBatch:
309360
"""Tests for stop_batch function."""
310361

0 commit comments

Comments
 (0)