"""Moving a task between stages — the only place the stage column changes. Two rules live here rather than in the scheduler: 1. **Optimistic moves.** ``advance`` refuses to guess: it takes the stage the caller believes the task is at, and the repository's compare-and-set either applies the move or raises :class:`~app.core.errors.ConflictError` because another worker got there first. A lost race is a normal outcome, not a crash — the caller re-reads and continues. 2. **Dependents never get stuck.** When a task fails or is cancelled, every task that declared it in ``depends_on`` is parked in ``pending_user_decision`` instead of waiting for a dependency that will never finish (agybot bug M1: a dead task deadlocked its children). The coarse ``tasks.status`` is written by the caller in the same transaction (see ``engine/scheduler.py`` in a later phase): the stage is the engine's truth, the status column is the summary existing APIs keep reading. """ from __future__ import annotations from app.core.errors import ConflictError, NotFoundError from app.domain.workforce.pipeline import ( SIDE_STAGES, TERMINAL_STAGES, Stage, TaskPipelineState, can_transition_stage, ) from app.infrastructure.repositories.engine import ( InvalidTransitionError, PipelineStateRepository, StaleStageError, UnknownTaskError, ) async def advance( states: PipelineStateRepository, *, task_id: str, expected_from: Stage, to: Stage, ) -> TaskPipelineState: """Move one task from ``expected_from`` to ``to``, or fail cleanly. Raises ``ConflictError`` when the transition is illegal or when another worker moved the task first, and ``NotFoundError`` when the task has no engine state row at all. """ if not can_transition_stage(expected_from, to): raise ConflictError( f"گذار نابجا از {expected_from.value} به {to.value}." ) try: return await states.advance(task_id=task_id, expected_from=expected_from, to=to) except UnknownTaskError as exc: raise NotFoundError("این کار وضعیت موتور ندارد.") from exc except InvalidTransitionError as exc: raise ConflictError(str(exc)) from exc except StaleStageError as exc: raise ConflictError( "این کار هم‌اکنون توسط موتور دیگری در حرکت است؛ دوباره تلاش کنید." ) from exc async def park_dependents( states: PipelineStateRepository, *, task_id: str, ) -> list[TaskPipelineState]: """Park every dependent of a failed or cancelled task with a human. A dependency that failed leaves its dependents unable to ever finish, so they move to ``pending_user_decision`` instead of waiting for work that will never happen (agybot bug M1: a dead task deadlocked its children). Dependents that already sit in another side state are left alone — a task a human already paused must not be re-parked. The guard is deliberate: a task that merely *exists* is not a reason to park anything. Only a dependency that is actually ``failed`` or ``cancelled`` strands its children, so the function refuses to act on a healthy task however often a tick calls it. """ state = await states.get(task_id) if state is None or state.stage not in (Stage.FAILED, Stage.CANCELLED): return [] parked: list[TaskPipelineState] = [] for dependent in await states.list_dependents(task_id): # A dependent that already waits on a human (paused, another decision) # is left alone: it is already parked, just elsewhere. if dependent.stage in SIDE_STAGES: continue if not can_transition_stage(dependent.stage, Stage.PENDING_USER_DECISION): continue try: parked.append( await advance( states, task_id=dependent.task_id, expected_from=dependent.stage, to=Stage.PENDING_USER_DECISION, ) ) except ConflictError: # A worker is already moving it; the next tick will see the truth. continue return parked async def is_terminal_state(states: PipelineStateRepository, task_id: str) -> bool: """True when the task's stage can no longer move (done/cancelled/failed).""" state = await states.get(task_id) return state is None or state.stage in TERMINAL_STAGES