- Prepaid credit system: org wallet, debit on usage, chat gate, admin credit console - Engine UI: /manager, /tasks, /tasks/[id], /workforce, /dashboard, /traces pages - Direct task creation (HTTP POST /tasks, MCP tasks_create) at pending_manager_review - Pause/resume/cancel/answer actions in engine API and UI - Employee cap (7 per org default), review pipeline guard (active reviewer required) - Scheduler: wide window prevents org starvation; archived employees excluded from cap - Tenant isolation: project-id cross-org 404, engine never uses another tenant's sandbox - Order route fix: manager built with pipeline states + employee profiles - Contract extension: ModelGrantPort.get_org_policy with DefaultOrgPolicy - Engine event types in @agy/contracts (task.step, task.gate, run.log, run.tool, team.changed) - api-engine.ts typed client; api-credit.ts client - Alembic migration c4d5e6f7a8b9: prepaid credit + engine UI tables - 859 tests passed, 1 skipped; ruff/mypy/tsc/eslint/vitest all green Refs: PLAN-v2.md §9 (T1–T43), todo-v2-final.md
125 lines
4.8 KiB
Python
125 lines
4.8 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.application.engine.events import emit_task_step
|
|
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. A move that lands publishes one ``task.step``.
|
|
"""
|
|
if not can_transition_stage(expected_from, to):
|
|
raise ConflictError(
|
|
f"گذار نابجا از {expected_from.value} به {to.value}."
|
|
)
|
|
try:
|
|
moved = 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
|
|
# The publisher never raises, so a broken bus cannot undo the move.
|
|
await emit_task_step(
|
|
task_id=moved.task_id,
|
|
organization_id=moved.organization_id,
|
|
from_stage=expected_from,
|
|
to_stage=to,
|
|
pipeline=moved.pipeline,
|
|
)
|
|
return moved
|
|
|
|
|
|
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
|