Phase 1 of TODO-agent-2: pure logic plus the tables it writes to. - domain/workforce/pipeline.py: Stage/PipelineKey/Outcome, the four pipelines, TRANSITIONS, next_stage, STAGE_ROLE (role+mode), resolve_pipeline with the no-reviewer fallback, task-kind defaults, stage -> coarse tasks.status, and the TaskPipelineState record (with the optimistic version). - application/engine/protocol.py: the [[TAG attr="v"]]body[[/TAG]] parser and its self-closing form, the per-role/mode allow-list, literal bodies (a bracket inside a body is not a tag), typed readers (ASSIGN/CREATE_TASK/ team changes/KNOWLEDGE/EDIT_FILE blocks) and the per-reply limits (3 tasks, 3 team changes, 3 knowledge items). - application/engine/state.py: advance() with the optimistic compare-and-set and park_dependents() so a failed dependency never deadlocks its children. - db/models_engine.py + migration a2b3c4d5e6f2: task_pipeline_state, employee_profiles, engine_turns, gate_results as side tables (tasks and employees stay locked). Linear head; alembic check reports no drift. - repositories/engine.py + 58 new tests (test_engine_protocol, test_engine_pipeline) covering every pipeline's happy path, illegal transitions, exactly-one-winner concurrent advance, dependency parking and all parser edge cases. Suite: 594 passed, ruff clean, mypy clean.
115 lines
4.4 KiB
Python
115 lines
4.4 KiB
Python
"""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
|