Merge remote-tracking branch 'origin/dev' into dev

This commit is contained in:
mjalmoudy94
2026-10-14 15:51:44 +03:30
11 changed files with 3211 additions and 0 deletions
+123
View File
@@ -0,0 +1,123 @@
"""Domain records for the AI-workforce engine.
Pure data: the rows the engine writes about itself (one turn, one gate, one
employee's model binding) plus the error vocabulary the runner classifies
with. Kept separate from ``pipeline.py`` because that module is the state
machine and this one is the ledger the state machine leaves behind.
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from enum import StrEnum
from app.domain.base import new_id, utcnow
from app.domain.workforce.models import RoleKey
from app.domain.workforce.pipeline import Mode, Stage
class ErrorKind(StrEnum):
"""How a failed model call should be treated.
``rate_limit``, ``unavailable``, ``network`` and ``timeout`` are
transient: the platform retries later instead of burning a cycle. The
others are the caller's problem — a bad key never fixes itself.
"""
RATE_LIMIT = "rate_limit"
UNAVAILABLE = "unavailable"
NETWORK = "network"
TIMEOUT = "timeout"
AUTH = "auth"
MODEL = "model"
PROTOCOL = "protocol"
BUDGET = "budget"
TRANSIENT_ERROR_KINDS: frozenset[ErrorKind] = frozenset(
{
ErrorKind.RATE_LIMIT,
ErrorKind.UNAVAILABLE,
ErrorKind.NETWORK,
ErrorKind.TIMEOUT,
}
)
#: Retries a transient failure gets before the task goes to error review.
MAX_TRANSIENT_RETRIES = 2
def is_transient(kind: ErrorKind | str) -> bool:
return ErrorKind(kind) in TRANSIENT_ERROR_KINDS
class GateName(StrEnum):
"""The gates that decide whether work may move forward."""
TESTS = "tests"
DIFF_GUARD = "diff_guard"
NO_PROGRESS = "no_progress"
GREEN = "green"
LINT = "lint"
@dataclass(slots=True, kw_only=True)
class EmployeeProfile:
"""The engine's per-employee record: which model it runs on, and its roles.
A 1:1 side table for the same reason as ``TaskPipelineState`` — the
``employees`` table is locked. Model identity lives here and never
crosses an API boundary: users pick a role and a neutral tier.
"""
employee_id: str
ai_model_id: str | None = None
effort: str = "medium"
#: CSV of role keys the employee may be hired for (its own role plus extras).
roles: list[str] = field(default_factory=list)
hired_by: str = "user" # user | manager
busy_task_id: str | None = None
updated_at: datetime = field(default_factory=utcnow)
def role_list(self) -> list[str]:
return [r.strip() for r in self.roles if r.strip()]
@dataclass(slots=True, kw_only=True)
class EngineTurn:
"""One employee turn: who ran, what came back, what it cost.
``reply_excerpt`` is bounded (4,000 chars) on purpose — the full reply
can be enormous, and this table exists to answer "what happened", not to
archive the conversation.
"""
task_id: str
run_id: str
employee_id: str | None = None
role: RoleKey = RoleKey.DEVELOPER
mode: Mode = Mode.WORK
stage: Stage = Stage.PENDING_IMPLEMENTATION
prompt_chars: int = 0
reply_excerpt: str = ""
#: tag name -> count, so the UI can show "3 files edited" without a join.
tags: dict = field(default_factory=dict)
tool_calls: int = 0
ok: bool = True
error_kind: str = ""
id: str = field(default_factory=new_id)
started_at: datetime = field(default_factory=utcnow)
finished_at: datetime | None = None
@dataclass(slots=True, kw_only=True)
class GateResult:
"""One gate verdict, with the detail a human needs to trust it."""
task_id: str
gate: GateName
ok: bool
detail: str = ""
id: str = field(default_factory=new_id)
created_at: datetime = field(default_factory=utcnow)
+494
View File
@@ -0,0 +1,494 @@
"""Dynamic task pipelines: stages, transitions and the manager's choices.
Pure logic: no I/O, no framework, no database. Everything here is a decision
the engine makes *before* it touches a row, which is what makes the state
machine testable without a session.
The vocabulary comes from agybot (concepts only, never its code): one task
walks an ordered list of stages, every stage needs exactly one role in one
mode, and the manager decides which pipeline a task gets. The differences
that matter here are that every move is optimistic (``version`` in
``TaskPipelineState``) and that a failed or cancelled dependency parks its
dependents instead of leaving them stuck forever (agybot bug M1).
"""
from __future__ import annotations
from dataclasses import dataclass, field
from datetime import datetime
from enum import StrEnum
from typing import NamedTuple
from app.domain.base import utcnow
from app.domain.workforce.models import RoleKey, TaskStatus
class Stage(StrEnum):
"""Where a task is in its pipeline.
The first eight values are pipeline stages (they appear in
:data:`PIPELINES`); the rest are side states a task can be pushed into
from anywhere, which is why they are not part of any pipeline list.
"""
PENDING_MANAGER_REVIEW = "pending_manager_review"
PENDING_ANALYSIS = "pending_analysis"
PENDING_IMPLEMENTATION = "pending_implementation"
PENDING_REVIEW = "pending_review"
PENDING_TEST = "pending_test"
PENDING_FINAL_REVIEW = "pending_final_review"
MERGE_REQUEST = "merge_request"
COMPLETED = "completed"
PENDING_USER_DECISION = "pending_user_decision"
ERROR_REVIEW = "error_review"
PAUSED = "paused"
CANCELLED = "cancelled"
FAILED = "failed"
#: Side states and terminals: a task in one of these is not walking a pipeline.
SIDE_STAGES: frozenset[Stage] = frozenset(
{
Stage.PENDING_USER_DECISION,
Stage.ERROR_REVIEW,
Stage.PAUSED,
Stage.CANCELLED,
Stage.FAILED,
}
)
TERMINAL_STAGES: frozenset[Stage] = frozenset(
{Stage.COMPLETED, Stage.CANCELLED, Stage.FAILED}
)
class PipelineKey(StrEnum):
"""The four pipelines. Names match the plan (agybot's, minus its extras)."""
PLAN_CODE_TEST = "plan_code_test"
PLAN_CODE_REVIEW_TEST = "plan_code_review_test"
CODE_TEST = "code_test"
ANALYSIS_ONLY = "analysis_only"
class TaskKind(StrEnum):
FEATURE = "feature"
BUGFIX = "bugfix"
REFACTOR = "refactor"
DOCS = "docs"
ANALYSIS = "analysis"
class Risk(StrEnum):
LOW = "low"
MEDIUM = "medium"
HIGH = "high"
class Mode(StrEnum):
"""The hat a role wears in one turn.
Only the manager has more than one: the same employee triages, gives the
final verdict and handles errors, and each mode allows a different tag set
(see ``engine/protocol.py``). ``work`` is what every other role uses.
"""
TRIAGE = "triage"
FINAL = "final"
ERROR = "error"
CHAT = "chat"
WORK = "work"
class Outcome(StrEnum):
"""The verdict of a finished turn, as the pipeline understands it."""
#: The turn produced its tag: move to the next pipeline stage.
OK = "ok"
#: Reviewer/tester/manager sent the work back: cycle again.
REVISE = "revise"
#: The manager rejected the task outright.
REJECT = "reject"
#: The turn needs a human answer before it can continue.
ASK_USER = "ask_user"
#: Something failed hard enough for the manager's error turn.
ERROR = "error"
#: The error turn decided to retry with a hint.
RESOLVE = "resolve"
#: Ordered stages per pipeline. ``completed`` is part of every list so that
#: "advance past the last stage" is just an index lookup, not a special case.
PIPELINES: dict[PipelineKey, tuple[Stage, ...]] = {
PipelineKey.PLAN_CODE_TEST: (
Stage.PENDING_MANAGER_REVIEW,
Stage.PENDING_ANALYSIS,
Stage.PENDING_IMPLEMENTATION,
Stage.PENDING_TEST,
Stage.PENDING_FINAL_REVIEW,
Stage.MERGE_REQUEST,
Stage.COMPLETED,
),
PipelineKey.PLAN_CODE_REVIEW_TEST: (
Stage.PENDING_MANAGER_REVIEW,
Stage.PENDING_ANALYSIS,
Stage.PENDING_IMPLEMENTATION,
Stage.PENDING_REVIEW,
Stage.PENDING_TEST,
Stage.PENDING_FINAL_REVIEW,
Stage.MERGE_REQUEST,
Stage.COMPLETED,
),
PipelineKey.CODE_TEST: (
Stage.PENDING_MANAGER_REVIEW,
Stage.PENDING_IMPLEMENTATION,
Stage.PENDING_TEST,
Stage.PENDING_FINAL_REVIEW,
Stage.MERGE_REQUEST,
Stage.COMPLETED,
),
#: No worktree, no merge: the planner answers and the task is done.
PipelineKey.ANALYSIS_ONLY: (
Stage.PENDING_MANAGER_REVIEW,
Stage.PENDING_ANALYSIS,
Stage.COMPLETED,
),
}
#: The pipeline used when nobody expressed a preference.
APP_DEFAULT_PIPELINE: PipelineKey = PipelineKey.PLAN_CODE_TEST
#: Default pipeline and risk per task kind. The manager's choice wins over both.
TASK_KIND_DEFAULTS: dict[TaskKind, tuple[PipelineKey, Risk]] = {
TaskKind.FEATURE: (PipelineKey.PLAN_CODE_TEST, Risk.MEDIUM),
TaskKind.BUGFIX: (PipelineKey.CODE_TEST, Risk.LOW),
TaskKind.REFACTOR: (PipelineKey.PLAN_CODE_REVIEW_TEST, Risk.HIGH),
TaskKind.DOCS: (PipelineKey.CODE_TEST, Risk.LOW),
TaskKind.ANALYSIS: (PipelineKey.ANALYSIS_ONLY, Risk.LOW),
}
#: Legal stage moves. Anything not listed here is a bug in the caller, and
#: ``engine/state.py`` refuses it before it reaches the database.
TRANSITIONS: dict[Stage, frozenset[Stage]] = {
Stage.PENDING_MANAGER_REVIEW: frozenset(
{
Stage.PENDING_ANALYSIS,
Stage.PENDING_IMPLEMENTATION,
Stage.PENDING_USER_DECISION,
Stage.ERROR_REVIEW,
Stage.PAUSED,
Stage.CANCELLED,
}
),
Stage.PENDING_ANALYSIS: frozenset(
{
Stage.PENDING_IMPLEMENTATION,
# analysis_only ends here; the planner's answer *is* the deliverable.
Stage.COMPLETED,
Stage.PENDING_USER_DECISION,
Stage.ERROR_REVIEW,
Stage.PAUSED,
Stage.CANCELLED,
}
),
Stage.PENDING_IMPLEMENTATION: frozenset(
{
Stage.PENDING_REVIEW,
Stage.PENDING_TEST,
Stage.PENDING_FINAL_REVIEW,
Stage.PENDING_USER_DECISION,
Stage.ERROR_REVIEW,
Stage.PAUSED,
Stage.CANCELLED,
}
),
Stage.PENDING_REVIEW: frozenset(
{
Stage.PENDING_TEST,
Stage.PENDING_IMPLEMENTATION,
Stage.PENDING_USER_DECISION,
Stage.ERROR_REVIEW,
Stage.PAUSED,
Stage.CANCELLED,
}
),
Stage.PENDING_TEST: frozenset(
{
Stage.PENDING_FINAL_REVIEW,
Stage.PENDING_IMPLEMENTATION,
Stage.PENDING_USER_DECISION,
Stage.ERROR_REVIEW,
Stage.PAUSED,
Stage.CANCELLED,
}
),
Stage.PENDING_FINAL_REVIEW: frozenset(
{
Stage.MERGE_REQUEST,
Stage.PENDING_IMPLEMENTATION,
Stage.PENDING_USER_DECISION,
Stage.ERROR_REVIEW,
Stage.PAUSED,
Stage.CANCELLED,
}
),
#: merge_request is executed by the platform, never by an AI turn: git
#: lives behind RepoWorkspacePort (PLAN-v2 D6).
Stage.MERGE_REQUEST: frozenset(
{
Stage.COMPLETED,
Stage.PENDING_IMPLEMENTATION,
Stage.ERROR_REVIEW,
Stage.PAUSED,
Stage.CANCELLED,
}
),
Stage.PENDING_USER_DECISION: frozenset(
{
# The user answered, so the task goes back to triage: a manager
# decides the pipeline again with the new information.
Stage.PENDING_MANAGER_REVIEW,
Stage.ERROR_REVIEW,
Stage.PAUSED,
Stage.CANCELLED,
}
),
Stage.ERROR_REVIEW: frozenset(
{
Stage.PENDING_ANALYSIS,
Stage.PENDING_IMPLEMENTATION,
Stage.PENDING_USER_DECISION,
Stage.FAILED,
Stage.PAUSED,
Stage.CANCELLED,
}
),
#: A paused task resumes through triage as well: the manager re-reads the
# task before spending more budget on it.
Stage.PAUSED: frozenset(
{Stage.PENDING_MANAGER_REVIEW, Stage.ERROR_REVIEW, Stage.CANCELLED}
),
Stage.COMPLETED: frozenset(),
Stage.CANCELLED: frozenset(),
Stage.FAILED: frozenset(),
}
class StageActor(NamedTuple):
"""Who acts in a stage: a role and the mode it must use."""
role: RoleKey
mode: Mode
#: stage -> (role, mode). ``None`` means "no AI turn acts here": the stage is
#: either terminal, platform-executed (merge_request) or waiting on a human.
STAGE_ROLE: dict[Stage, StageActor | None] = {
Stage.PENDING_MANAGER_REVIEW: StageActor(RoleKey.MANAGER, Mode.TRIAGE),
Stage.PENDING_ANALYSIS: StageActor(RoleKey.PLANNER, Mode.WORK),
Stage.PENDING_IMPLEMENTATION: StageActor(RoleKey.DEVELOPER, Mode.WORK),
Stage.PENDING_REVIEW: StageActor(RoleKey.REVIEWER, Mode.WORK),
Stage.PENDING_TEST: StageActor(RoleKey.TESTER, Mode.WORK),
Stage.PENDING_FINAL_REVIEW: StageActor(RoleKey.MANAGER, Mode.FINAL),
Stage.MERGE_REQUEST: None,
Stage.COMPLETED: None,
Stage.PENDING_USER_DECISION: None,
Stage.ERROR_REVIEW: StageActor(RoleKey.MANAGER, Mode.ERROR),
Stage.PAUSED: None,
Stage.CANCELLED: None,
Stage.FAILED: None,
}
#: The coarse ``tasks.status`` each stage maps onto. Existing APIs and the old
#: worker read that column, so the engine keeps it truthful at every move
#: instead of introducing a second source of truth.
STAGE_STATUS: dict[Stage, TaskStatus] = {
Stage.PENDING_MANAGER_REVIEW: TaskStatus.RUNNING,
Stage.PENDING_ANALYSIS: TaskStatus.RUNNING,
Stage.PENDING_IMPLEMENTATION: TaskStatus.RUNNING,
Stage.PENDING_REVIEW: TaskStatus.RUNNING,
Stage.PENDING_TEST: TaskStatus.RUNNING,
Stage.PENDING_FINAL_REVIEW: TaskStatus.RUNNING,
Stage.MERGE_REQUEST: TaskStatus.RUNNING,
Stage.COMPLETED: TaskStatus.DONE,
Stage.PENDING_USER_DECISION: TaskStatus.WAITING_APPROVAL,
Stage.ERROR_REVIEW: TaskStatus.RUNNING,
Stage.PAUSED: TaskStatus.WAITING_APPROVAL,
Stage.CANCELLED: TaskStatus.CANCELLED,
Stage.FAILED: TaskStatus.FAILED,
}
#: Fixed targets for outcomes that are not "the next stage": work that is sent
#: back always returns to the developer, because that is the role that can
#: actually change the diff.
_OUTCOME_TARGET: dict[Outcome, Stage] = {
Outcome.REVISE: Stage.PENDING_IMPLEMENTATION,
Outcome.RESOLVE: Stage.PENDING_IMPLEMENTATION,
Outcome.ASK_USER: Stage.PENDING_USER_DECISION,
Outcome.ERROR: Stage.ERROR_REVIEW,
Outcome.REJECT: Stage.CANCELLED,
}
def is_terminal(stage: Stage) -> bool:
return stage in TERMINAL_STAGES
def is_side_stage(stage: Stage) -> bool:
"""True when the stage is not part of any pipeline's ordered list."""
return stage in SIDE_STAGES
def can_transition_stage(current: Stage, target: Stage) -> bool:
return target in TRANSITIONS.get(current, frozenset())
def pipeline_stages(pipeline: PipelineKey | str) -> tuple[Stage, ...]:
"""The ordered stages of a pipeline (including the terminal)."""
return PIPELINES[PipelineKey(pipeline)]
def requires_worktree(pipeline: PipelineKey | str) -> bool:
"""``analysis_only`` never creates a worktree and never merges."""
return PipelineKey(pipeline) is not PipelineKey.ANALYSIS_ONLY
def next_stage(pipeline: PipelineKey | str, current: Stage | str, outcome: Outcome | str) -> Stage:
"""The stage a task moves to after a turn ends.
``Outcome.OK`` walks the pipeline list one step forward (past the last
stage there is nothing left, which only happens for a malformed call);
every other outcome has a fixed target. The result is *not* checked
against :data:`TRANSITIONS` here — callers go through
``engine/state.py``, which refuses illegal moves.
"""
stage = Stage(current)
verdict = Outcome(outcome)
if verdict is Outcome.OK:
stages = PIPELINES[PipelineKey(pipeline)]
index = stages.index(stage)
return stages[index + 1] if index + 1 < len(stages) else stage
try:
return _OUTCOME_TARGET[verdict]
except KeyError as exc: # pragma: no cover - the enum is closed
raise ValueError(f"نتیجهٔ ناشناخته برای مرحله: {verdict}") from exc
def resolve_pipeline(
*,
manager_choice: PipelineKey | str | None = None,
task_kind: TaskKind | str | None = None,
app_default: PipelineKey | str | None = None,
has_active_reviewer: bool = True,
) -> PipelineKey:
"""Decide which pipeline a task gets.
Priority: the manager's explicit choice, then the task kind's default,
then the app profile's default, then ``plan_code_test``.
A review pipeline without an active reviewer falls back to
``plan_code_test`` — including an explicit manager choice, because a
review stage nobody can act on is a dead end. The manager who really
wants a reviewer should hire one (``HIRE role="reviewer"``), not start a
task that will wait forever.
"""
chosen: PipelineKey | None = None
if manager_choice:
chosen = PipelineKey(manager_choice)
elif task_kind:
chosen = TASK_KIND_DEFAULTS[TaskKind(task_kind)][0]
elif app_default:
chosen = PipelineKey(app_default)
if chosen is None:
chosen = APP_DEFAULT_PIPELINE
if chosen is PipelineKey.PLAN_CODE_REVIEW_TEST and not has_active_reviewer:
return PipelineKey.PLAN_CODE_TEST
return chosen
def risk_for(task_kind: TaskKind | str) -> Risk:
"""The risk a task kind carries when the manager did not set one."""
return TASK_KIND_DEFAULTS[TaskKind(task_kind)][1]
def coarse_status(stage: Stage | str) -> TaskStatus:
return STAGE_STATUS[Stage(stage)]
@dataclass(slots=True, kw_only=True)
class TaskPipelineState:
"""The engine's per-task record: pipeline, position and loop guards.
Lives in a 1:1 side table (``task_pipeline_state``) because ``tasks`` is
a locked table: the engine must not add columns to it.
``version`` is the optimistic lock. ``advance`` only writes when the
row still has the version the caller read, so two workers moving the
same task cannot both win.
"""
task_id: str
organization_id: str
project_id: str | None = None
pipeline: PipelineKey = APP_DEFAULT_PIPELINE
kind: TaskKind = TaskKind.FEATURE
risk: Risk = Risk.MEDIUM
stage: Stage = Stage.PENDING_MANAGER_REVIEW
cycle: int = 0
max_cycles: int = 6
branch: str = ""
#: Hash of the last diff, for the no-progress gate.
last_diff_hash: str = ""
same_diff_count: int = 0
retry_count: int = 0
waiver_reason: str = ""
parent_task_id: str | None = None
depends_on: list[str] = field(default_factory=list)
version: int = 0
updated_at: datetime = field(default_factory=utcnow)
@property
def status(self) -> TaskStatus:
return coarse_status(self.stage)
@property
def needs_turn(self) -> bool:
"""True when the stage is waiting for an AI turn."""
actor = STAGE_ROLE.get(self.stage)
return actor is not None and not is_terminal(self.stage)
@property
def is_blocked(self) -> bool:
"""True while the task waits on a human (decision, pause or budget)."""
return self.stage in SIDE_STAGES and not is_terminal(self.stage)
def actor(self) -> StageActor | None:
return STAGE_ROLE.get(self.stage)
__all__ = [
"APP_DEFAULT_PIPELINE",
"PIPELINES",
"SIDE_STAGES",
"STAGE_ROLE",
"STAGE_STATUS",
"TASK_KIND_DEFAULTS",
"TERMINAL_STAGES",
"TRANSITIONS",
"Mode",
"Outcome",
"PipelineKey",
"Risk",
"Stage",
"StageActor",
"TaskKind",
"TaskPipelineState",
"can_transition_stage",
"coarse_status",
"is_side_stage",
"is_terminal",
"next_stage",
"pipeline_stages",
"requires_worktree",
"resolve_pipeline",
"risk_for",
]