Merge remote-tracking branch 'origin/dev' into dev
This commit is contained in:
@@ -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)
|
||||
@@ -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",
|
||||
]
|
||||
Reference in New Issue
Block a user