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.
This commit is contained in:
@@ -0,0 +1,114 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user