Skip to content

Commit 24a356f

Browse files
committed
fix(core): close the already-DONE TOCTOU in complete_run (#957)
Both PR reviewers independently flagged that the previous fix was check-then- act. `tasks.update_status` does its own fresh read + compare-and-set, so a reconciliation DONE (#1032 runs in a daemon thread) landing between our read and that CAS still raised InvalidTransitionError — and execute_agent's handler still persisted a successful run as FAILED. Narrower window, same bug. Replaced the pre-check with a catch: re-read in the handler and treat "the row is now DONE" as success. That covers the static case and the race in one path. A genuinely illegal transition (the row moved somewhere that is not DONE) still raises, leaving the run RUNNING. Two tests: one flips the row to DONE inside the call, exactly as a losing CAS would see it (fails on the pre-check version); one flips it to BLOCKED and asserts the error still propagates.
1 parent b712e60 commit 24a356f

2 files changed

Lines changed: 78 additions & 9 deletions

File tree

codeframe/core/runtime.py

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,11 @@
1313
from typing import TYPE_CHECKING, Optional
1414

1515
from codeframe.core import engine_stats, events, tasks
16-
from codeframe.core.state_machine import TaskStatus, can_transition
16+
from codeframe.core.state_machine import (
17+
InvalidTransitionError,
18+
TaskStatus,
19+
can_transition,
20+
)
1721
from codeframe.core.workspace import Workspace, get_db_connection
1822

1923
logger = logging.getLogger(__name__)
@@ -362,17 +366,25 @@ def complete_run(
362366
# transition raise here leaves the run RUNNING, which is recoverable.
363367
#
364368
# A task already DONE is the goal state, not a failure: reconciliation
365-
# (#1032) and manual completion both move a task to DONE while its run is
366-
# still active, and DONE -> DONE is a rejected transition. Raising there
367-
# would send execute_agent's handler into fail_run and persist a
368-
# successful run as FAILED.
369-
task = tasks.get(workspace, run.task_id)
370-
if task is None:
371-
raise ValueError(f"Task not found: {run.task_id}")
372-
if task.status != TaskStatus.DONE:
369+
# (#1032, a daemon thread) and manual completion both move a task to DONE
370+
# while its run is still active, and DONE -> DONE is a rejected transition.
371+
# Raising there would send execute_agent's handler into fail_run and
372+
# persist a successful run as FAILED.
373+
#
374+
# Handled by catching rather than pre-checking: a read-then-call guard is
375+
# check-then-act, and update_status does its own fresh read + compare-and-
376+
# set, so a DONE landing in between would still raise. Re-reading in the
377+
# handler is the only way to tell "someone else already finished it"
378+
# (success) from a genuinely illegal transition (still BACKLOG — raise, and
379+
# the run stays RUNNING).
380+
try:
373381
tasks.update_status(
374382
workspace, run.task_id, TaskStatus.DONE, github_autoclose=github_autoclose
375383
)
384+
except InvalidTransitionError:
385+
task = tasks.get(workspace, run.task_id)
386+
if task is None or task.status != TaskStatus.DONE:
387+
raise
376388

377389
now = _utc_now().isoformat()
378390

tests/core/test_orchestration_defects_957.py

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -348,6 +348,63 @@ def test_already_done_task_completes_the_run(self, tmp_path):
348348
assert runtime.get_run(ws, run.id).status == RunStatus.COMPLETED
349349
assert tasks.get(ws, task.id).status == TaskStatus.DONE
350350

351+
def test_task_flipping_to_done_mid_call_still_completes_the_run(self, tmp_path):
352+
"""The race, not just the static case (#957 review round 2).
353+
354+
A read-then-call guard is check-then-act: `update_status` does its own
355+
fresh read + compare-and-set, so a reconciliation DONE landing between
356+
our read and that CAS still raises. Simulated here by flipping the row
357+
to DONE *inside* the call, exactly as the losing CAS would see it.
358+
"""
359+
from codeframe.core import runtime, tasks
360+
from codeframe.core.state_machine import InvalidTransitionError
361+
from codeframe.core.runtime import RunStatus
362+
from codeframe.core.tasks import TaskStatus
363+
from codeframe.core.workspace import create_or_load_workspace
364+
365+
ws = create_or_load_workspace(tmp_path)
366+
task = tasks.create(ws, title="T", description="d")
367+
run = runtime.start_task_run(ws, task.id)
368+
369+
real_update = tasks.update_status
370+
371+
def racing_update(workspace, task_id, new_status, **kwargs):
372+
# Someone else wins the race: the row is DONE before our CAS runs.
373+
real_update(workspace, task_id, TaskStatus.DONE)
374+
raise InvalidTransitionError(TaskStatus.DONE, TaskStatus.DONE)
375+
376+
with patch.object(tasks, "update_status", side_effect=racing_update):
377+
result = runtime.complete_run(ws, run.id)
378+
379+
assert result.status == RunStatus.COMPLETED
380+
assert runtime.get_run(ws, run.id).status == RunStatus.COMPLETED
381+
assert tasks.get(ws, task.id).status == TaskStatus.DONE
382+
383+
def test_genuinely_illegal_transition_still_raises(self, tmp_path):
384+
"""Only DONE is forgiven — a task stuck elsewhere must not pass."""
385+
from codeframe.core import runtime, tasks
386+
from codeframe.core.state_machine import InvalidTransitionError
387+
from codeframe.core.runtime import RunStatus
388+
from codeframe.core.tasks import TaskStatus
389+
from codeframe.core.workspace import create_or_load_workspace
390+
391+
ws = create_or_load_workspace(tmp_path)
392+
task = tasks.create(ws, title="T", description="d")
393+
run = runtime.start_task_run(ws, task.id)
394+
395+
real_update = tasks.update_status
396+
397+
def racing_backwards(workspace, task_id, new_status, **kwargs):
398+
# The row moved somewhere that is NOT the goal state.
399+
real_update(workspace, task_id, TaskStatus.BLOCKED)
400+
raise InvalidTransitionError(TaskStatus.BLOCKED, TaskStatus.DONE)
401+
402+
with patch.object(tasks, "update_status", side_effect=racing_backwards):
403+
with pytest.raises(InvalidTransitionError):
404+
runtime.complete_run(ws, run.id)
405+
406+
assert runtime.get_run(ws, run.id).status == RunStatus.RUNNING
407+
351408
def test_happy_path_still_completes_both(self, tmp_path):
352409
from codeframe.core import runtime, tasks
353410
from codeframe.core.runtime import RunStatus

0 commit comments

Comments
 (0)