Files
smart-org/apps/api/app/application/engine/state.py
T
mjalmoudy94 df7da8c75c feat(engine): workforce engine core — tag protocol, pipelines, optimistic state
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.
2026-10-14 15:35:31 +03:30

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