Merge remote-tracking branch 'origin/dev' into dev
This commit is contained in:
@@ -0,0 +1,186 @@
|
|||||||
|
"""AI workforce engine: pipeline state, employee profiles, turns, gate results
|
||||||
|
|
||||||
|
Revision ID: a2b3c4d5e6f2
|
||||||
|
Revises: d5e6f7a8b9c0
|
||||||
|
Create Date: 2026-10-14 09:30:00.000000
|
||||||
|
|
||||||
|
Owned by the AI-workforce workstream (PLAN-v2 §6).
|
||||||
|
|
||||||
|
Deviation from TODO-agent-2, noted so the next reader is not surprised: the
|
||||||
|
TODO pins ``down_revision = "a0f0e1d2c3b4"`` (the v2 foundation), which was
|
||||||
|
correct while each agent worked on its own branch. On the merged ``dev``
|
||||||
|
branch the agents have landed one by one, so this migration chains after the
|
||||||
|
most recent head (``d5e6f7a8b9c0``, org setup drafts) to keep a linear
|
||||||
|
history. Claude adds the merge revision for the two remaining heads.
|
||||||
|
|
||||||
|
No ``tasks`` or ``employees`` column is added: those tables are locked. The
|
||||||
|
engine's per-task and per-employee data lives in 1:1 side tables instead.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
import sqlalchemy as sa
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
revision: str = "a2b3c4d5e6f2"
|
||||||
|
down_revision: str | None = "d5e6f7a8b9c0"
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.create_table(
|
||||||
|
"task_pipeline_state",
|
||||||
|
sa.Column(
|
||||||
|
"task_id",
|
||||||
|
sa.String(length=32),
|
||||||
|
sa.ForeignKey("tasks.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"organization_id",
|
||||||
|
sa.String(length=32),
|
||||||
|
sa.ForeignKey("organizations.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"project_id",
|
||||||
|
sa.String(length=32),
|
||||||
|
sa.ForeignKey("projects.id", ondelete="CASCADE"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"pipeline",
|
||||||
|
sa.String(length=32),
|
||||||
|
nullable=False,
|
||||||
|
server_default="plan_code_test",
|
||||||
|
),
|
||||||
|
sa.Column("kind", sa.String(length=16), nullable=False, server_default="feature"),
|
||||||
|
sa.Column("risk", sa.String(length=8), nullable=False, server_default="medium"),
|
||||||
|
sa.Column(
|
||||||
|
"stage",
|
||||||
|
sa.String(length=32),
|
||||||
|
nullable=False,
|
||||||
|
server_default="pending_manager_review",
|
||||||
|
),
|
||||||
|
sa.Column("cycle", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("max_cycles", sa.Integer(), nullable=False, server_default="6"),
|
||||||
|
sa.Column("branch", sa.String(length=120), nullable=False, server_default=""),
|
||||||
|
sa.Column("last_diff_hash", sa.String(length=64), nullable=False, server_default=""),
|
||||||
|
sa.Column("same_diff_count", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("retry_count", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("waiver_reason", sa.Text(), nullable=False, server_default=""),
|
||||||
|
sa.Column(
|
||||||
|
"parent_task_id",
|
||||||
|
sa.String(length=32),
|
||||||
|
sa.ForeignKey("tasks.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column("depends_on", sa.JSON(), nullable=False, server_default="[]"),
|
||||||
|
sa.Column("version", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_task_pipeline_state_organization_id",
|
||||||
|
"task_pipeline_state",
|
||||||
|
["organization_id"],
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_task_pipeline_state_stage", "task_pipeline_state", ["stage"]
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"employee_profiles",
|
||||||
|
sa.Column(
|
||||||
|
"employee_id",
|
||||||
|
sa.String(length=32),
|
||||||
|
sa.ForeignKey("employees.id", ondelete="CASCADE"),
|
||||||
|
primary_key=True,
|
||||||
|
),
|
||||||
|
sa.Column("ai_model_id", sa.String(length=32), nullable=True),
|
||||||
|
sa.Column(
|
||||||
|
"effort", sa.String(length=16), nullable=False, server_default="medium"
|
||||||
|
),
|
||||||
|
sa.Column("roles", sa.String(length=255), nullable=False, server_default=""),
|
||||||
|
sa.Column("hired_by", sa.String(length=8), nullable=False, server_default="user"),
|
||||||
|
sa.Column(
|
||||||
|
"busy_task_id",
|
||||||
|
sa.String(length=32),
|
||||||
|
sa.ForeignKey("tasks.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column("updated_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index(
|
||||||
|
"ix_employee_profile_busy", "employee_profiles", ["busy_task_id"]
|
||||||
|
)
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"engine_turns",
|
||||||
|
sa.Column("id", sa.String(length=32), primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"task_id",
|
||||||
|
sa.String(length=32),
|
||||||
|
sa.ForeignKey("tasks.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"run_id",
|
||||||
|
sa.String(length=32),
|
||||||
|
sa.ForeignKey("runs.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column(
|
||||||
|
"employee_id",
|
||||||
|
sa.String(length=32),
|
||||||
|
sa.ForeignKey("employees.id", ondelete="SET NULL"),
|
||||||
|
nullable=True,
|
||||||
|
),
|
||||||
|
sa.Column("role", sa.String(length=24), nullable=False, server_default="developer"),
|
||||||
|
sa.Column("mode", sa.String(length=16), nullable=False, server_default="work"),
|
||||||
|
sa.Column("stage", sa.String(length=32), nullable=False, server_default=""),
|
||||||
|
sa.Column("prompt_chars", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("reply_excerpt", sa.Text(), nullable=False, server_default=""),
|
||||||
|
sa.Column("tags", sa.JSON(), nullable=False, server_default="{}"),
|
||||||
|
sa.Column("tool_calls", sa.Integer(), nullable=False, server_default="0"),
|
||||||
|
sa.Column("ok", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
|
sa.Column("error_kind", sa.String(length=16), nullable=False, server_default=""),
|
||||||
|
sa.Column("started_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
sa.Column("finished_at", sa.DateTime(timezone=True), nullable=True),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index("ix_engine_turn_task_stage", "engine_turns", ["task_id", "stage"])
|
||||||
|
op.create_index("ix_engine_turn_run", "engine_turns", ["run_id"])
|
||||||
|
|
||||||
|
op.create_table(
|
||||||
|
"gate_results",
|
||||||
|
sa.Column("id", sa.String(length=32), primary_key=True),
|
||||||
|
sa.Column(
|
||||||
|
"task_id",
|
||||||
|
sa.String(length=32),
|
||||||
|
sa.ForeignKey("tasks.id", ondelete="CASCADE"),
|
||||||
|
nullable=False,
|
||||||
|
),
|
||||||
|
sa.Column("gate", sa.String(length=16), nullable=False, server_default="tests"),
|
||||||
|
sa.Column("ok", sa.Boolean(), nullable=False, server_default=sa.true()),
|
||||||
|
sa.Column("detail", sa.Text(), nullable=False, server_default=""),
|
||||||
|
sa.Column("created_at", sa.DateTime(timezone=True), nullable=False),
|
||||||
|
)
|
||||||
|
op.create_index("ix_gate_result_task_gate", "gate_results", ["task_id", "gate"])
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.drop_index("ix_gate_result_task_gate", table_name="gate_results")
|
||||||
|
op.drop_table("gate_results")
|
||||||
|
op.drop_index("ix_engine_turn_run", table_name="engine_turns")
|
||||||
|
op.drop_index("ix_engine_turn_task_stage", table_name="engine_turns")
|
||||||
|
op.drop_table("engine_turns")
|
||||||
|
op.drop_index("ix_employee_profile_busy", table_name="employee_profiles")
|
||||||
|
op.drop_table("employee_profiles")
|
||||||
|
op.drop_index("ix_task_pipeline_state_stage", table_name="task_pipeline_state")
|
||||||
|
op.drop_index(
|
||||||
|
"ix_task_pipeline_state_organization_id", table_name="task_pipeline_state"
|
||||||
|
)
|
||||||
|
op.drop_table("task_pipeline_state")
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
"""The AI-workforce engine: the tag protocol and the worker-facing helpers."""
|
||||||
@@ -0,0 +1,576 @@
|
|||||||
|
"""The tag protocol: ``[[TAG attr="v" ...]]body[[/TAG]]`` and ``[[TAG attr="v"/]``.
|
||||||
|
|
||||||
|
One parser, one matrix. The rules that make an agent protocol safe all live
|
||||||
|
here so no call site can re-decide them:
|
||||||
|
|
||||||
|
1. **An allow-list per role and mode.** The manager triages, gives the final
|
||||||
|
verdict, handles errors and chats, and each mode may emit a different set
|
||||||
|
of tags; a planner plans, a developer implements. A tag outside the matrix
|
||||||
|
is ignored and reported back to the model as an error line — the model
|
||||||
|
learns the rule, and nothing dangerous executes by accident.
|
||||||
|
2. **The toolkit is the only way to touch files.** ``READ_FILE``,
|
||||||
|
``WRITE_FILE``, ``EDIT_FILE``, ``LIST_DIR``, ``SEARCH`` and ``RUN`` run
|
||||||
|
inside the sandbox through ``SandboxProviderPort``; the platform still
|
||||||
|
does git (``RepoWorkspacePort``), and ``RUN`` refuses git push/remote/config
|
||||||
|
outright (see ``engine/tools.py``).
|
||||||
|
3. **Bounded turns.** A reply may create at most three tasks, change the team
|
||||||
|
at most three times and record at most three knowledge items. A model that
|
||||||
|
ignores the limit gets an error line and the extra tags are dropped, so the
|
||||||
|
effect of one runaway reply stays finite.
|
||||||
|
|
||||||
|
Bodies may contain brackets (a source file does), so the parser never
|
||||||
|
re-scans a body it already consumed: an outer tag wins and its content is
|
||||||
|
literal.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import re
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from enum import StrEnum
|
||||||
|
|
||||||
|
from app.domain.workforce.models import RoleKey
|
||||||
|
from app.domain.workforce.pipeline import Mode, PipelineKey
|
||||||
|
|
||||||
|
|
||||||
|
class TagName(StrEnum):
|
||||||
|
"""Every tag the protocol knows, whether or not a role may use it."""
|
||||||
|
|
||||||
|
# manager / triage
|
||||||
|
ASSIGN = "ASSIGN"
|
||||||
|
REJECT = "REJECT"
|
||||||
|
ASK_USER = "ASK_USER"
|
||||||
|
KNOWLEDGE = "KNOWLEDGE"
|
||||||
|
# manager / final
|
||||||
|
COMPLETE = "COMPLETE"
|
||||||
|
REVISE = "REVISE"
|
||||||
|
# manager / error
|
||||||
|
RESOLVE_ERROR = "RESOLVE_ERROR"
|
||||||
|
CANCEL = "CANCEL"
|
||||||
|
# manager / chat
|
||||||
|
CREATE_TASK = "CREATE_TASK"
|
||||||
|
PAUSE_TASK = "PAUSE_TASK"
|
||||||
|
RESUME_TASK = "RESUME_TASK"
|
||||||
|
CANCEL_TASK = "CANCEL_TASK"
|
||||||
|
HIRE = "HIRE"
|
||||||
|
UPDATE_EMPLOYEE = "UPDATE_EMPLOYEE"
|
||||||
|
FIRE = "FIRE"
|
||||||
|
# planner
|
||||||
|
PLAN_SPEC = "PLAN_SPEC"
|
||||||
|
SPLIT = "SPLIT"
|
||||||
|
# developer
|
||||||
|
WORK_REPORT = "WORK_REPORT"
|
||||||
|
# reviewer
|
||||||
|
REVIEW_PASS = "REVIEW_PASS"
|
||||||
|
REVIEW_CHANGES = "REVIEW_CHANGES"
|
||||||
|
# tester
|
||||||
|
TEST_PASS = "TEST_PASS"
|
||||||
|
TEST_FAIL = "TEST_FAIL"
|
||||||
|
# toolkit (every working role)
|
||||||
|
READ_FILE = "READ_FILE"
|
||||||
|
WRITE_FILE = "WRITE_FILE"
|
||||||
|
EDIT_FILE = "EDIT_FILE"
|
||||||
|
LIST_DIR = "LIST_DIR"
|
||||||
|
SEARCH = "SEARCH"
|
||||||
|
RUN = "RUN"
|
||||||
|
|
||||||
|
|
||||||
|
#: The toolkit. Available to every working role, never to the manager: the
|
||||||
|
#: manager decides, it does not edit the repository.
|
||||||
|
TOOL_TAGS: frozenset[TagName] = frozenset(
|
||||||
|
{
|
||||||
|
TagName.READ_FILE,
|
||||||
|
TagName.WRITE_FILE,
|
||||||
|
TagName.EDIT_FILE,
|
||||||
|
TagName.LIST_DIR,
|
||||||
|
TagName.SEARCH,
|
||||||
|
TagName.RUN,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
#: Team-changing tags (all manager/chat). Counted together, one limit per turn.
|
||||||
|
TEAM_CHANGE_TAGS: frozenset[TagName] = frozenset(
|
||||||
|
{TagName.HIRE, TagName.UPDATE_EMPLOYEE, TagName.FIRE}
|
||||||
|
)
|
||||||
|
|
||||||
|
#: Tags that carry a body and therefore *must* be closed with ``[[/TAG]]``.
|
||||||
|
#: Every other tag is a single token (an order, a tool read, a decision), so
|
||||||
|
#: a forgotten trailing ``/`` is tolerated as self-closing instead of failing
|
||||||
|
#: the whole reply: ``[[RUN cmd="pytest"]]`` means what ``[[RUN cmd="pytest"/]]``
|
||||||
|
#: means.
|
||||||
|
BODY_TAGS: frozenset[TagName] = frozenset(
|
||||||
|
{
|
||||||
|
TagName.WRITE_FILE,
|
||||||
|
TagName.EDIT_FILE,
|
||||||
|
TagName.KNOWLEDGE,
|
||||||
|
TagName.ASK_USER,
|
||||||
|
TagName.CREATE_TASK,
|
||||||
|
TagName.SPLIT,
|
||||||
|
TagName.PLAN_SPEC,
|
||||||
|
TagName.WORK_REPORT,
|
||||||
|
TagName.REVIEW_PASS,
|
||||||
|
TagName.REVIEW_CHANGES,
|
||||||
|
TagName.TEST_PASS,
|
||||||
|
TagName.TEST_FAIL,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
#: The per-role/mode matrix. Anything not listed is refused with an error line.
|
||||||
|
ALLOWED_TAGS: dict[tuple[RoleKey, Mode], frozenset[TagName]] = {
|
||||||
|
(RoleKey.MANAGER, Mode.TRIAGE): frozenset(
|
||||||
|
{TagName.ASSIGN, TagName.REJECT, TagName.ASK_USER, TagName.KNOWLEDGE}
|
||||||
|
),
|
||||||
|
(RoleKey.MANAGER, Mode.FINAL): frozenset(
|
||||||
|
{TagName.COMPLETE, TagName.REVISE, TagName.KNOWLEDGE}
|
||||||
|
),
|
||||||
|
(RoleKey.MANAGER, Mode.ERROR): frozenset(
|
||||||
|
{TagName.RESOLVE_ERROR, TagName.CANCEL, TagName.KNOWLEDGE}
|
||||||
|
),
|
||||||
|
(RoleKey.MANAGER, Mode.CHAT): frozenset(
|
||||||
|
{
|
||||||
|
TagName.CREATE_TASK,
|
||||||
|
TagName.PAUSE_TASK,
|
||||||
|
TagName.RESUME_TASK,
|
||||||
|
TagName.CANCEL_TASK,
|
||||||
|
TagName.HIRE,
|
||||||
|
TagName.UPDATE_EMPLOYEE,
|
||||||
|
TagName.FIRE,
|
||||||
|
TagName.KNOWLEDGE,
|
||||||
|
}
|
||||||
|
),
|
||||||
|
(RoleKey.PLANNER, Mode.WORK): frozenset({TagName.PLAN_SPEC, TagName.SPLIT, TagName.ASK_USER})
|
||||||
|
| TOOL_TAGS,
|
||||||
|
(RoleKey.DEVELOPER, Mode.WORK): frozenset({TagName.WORK_REPORT, TagName.ASK_USER}) | TOOL_TAGS,
|
||||||
|
(RoleKey.REVIEWER, Mode.WORK): frozenset(
|
||||||
|
{TagName.REVIEW_PASS, TagName.REVIEW_CHANGES}
|
||||||
|
)
|
||||||
|
| TOOL_TAGS,
|
||||||
|
(RoleKey.TESTER, Mode.WORK): frozenset({TagName.TEST_PASS, TagName.TEST_FAIL}) | TOOL_TAGS,
|
||||||
|
}
|
||||||
|
|
||||||
|
#: Per-reply limits. Extras are dropped and reported, never executed.
|
||||||
|
MAX_CREATE_TASKS_PER_REPLY = 3
|
||||||
|
MAX_TEAM_CHANGES_PER_TURN = 3
|
||||||
|
MAX_KNOWLEDGE_PER_TURN = 3
|
||||||
|
|
||||||
|
#: Which pipeline names ``ASSIGN pipeline=`` may pick. Kept here so the
|
||||||
|
#: manager cannot invent a pipeline that does not exist.
|
||||||
|
ASSIGNABLE_PIPELINES: frozenset[str] = frozenset(p.value for p in PipelineKey)
|
||||||
|
|
||||||
|
|
||||||
|
class ProtocolError(ValueError):
|
||||||
|
"""A tag was parsed but its attributes are not usable."""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Tag and reply
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class Tag:
|
||||||
|
"""One parsed tag: its name, attributes and body (``""`` when self-closing)."""
|
||||||
|
|
||||||
|
name: TagName
|
||||||
|
attrs: dict[str, str] = field(default_factory=dict)
|
||||||
|
body: str = ""
|
||||||
|
#: The raw slice, so an error line can quote exactly what the model wrote.
|
||||||
|
raw: str = ""
|
||||||
|
|
||||||
|
def attr(self, key: str, default: str = "") -> str:
|
||||||
|
return self.attrs.get(key, default)
|
||||||
|
|
||||||
|
def require(self, key: str) -> str:
|
||||||
|
value = self.attrs.get(key, "").strip()
|
||||||
|
if not value:
|
||||||
|
raise ProtocolError(f"[[{self.name}]] requires {key}=...")
|
||||||
|
return value
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class ParsedReply:
|
||||||
|
"""Everything one model reply contained, in order.
|
||||||
|
|
||||||
|
``errors`` are lines fed back to the model on its next turn: the protocol
|
||||||
|
is learned from feedback, not from a crash. ``prose`` is the text outside
|
||||||
|
any tag — for a manager chat reply that is the message the user reads.
|
||||||
|
"""
|
||||||
|
|
||||||
|
tags: tuple[Tag, ...] = ()
|
||||||
|
errors: tuple[str, ...] = ()
|
||||||
|
prose: str = ""
|
||||||
|
|
||||||
|
def names(self) -> tuple[TagName, ...]:
|
||||||
|
return tuple(tag.name for tag in self.tags)
|
||||||
|
|
||||||
|
def of(self, name: TagName | str) -> tuple[Tag, ...]:
|
||||||
|
wanted = TagName(name)
|
||||||
|
return tuple(tag for tag in self.tags if tag.name is wanted)
|
||||||
|
|
||||||
|
def first(self, name: TagName | str) -> Tag | None:
|
||||||
|
found = self.of(name)
|
||||||
|
return found[0] if found else None
|
||||||
|
|
||||||
|
def has(self, name: TagName | str) -> bool:
|
||||||
|
return bool(self.of(name))
|
||||||
|
|
||||||
|
def error_text(self) -> str:
|
||||||
|
return "\n".join(self.errors)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Typed views over tags
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class AssignTag:
|
||||||
|
"""``ASSIGN employee="name" pipeline="plan_code_test"``."""
|
||||||
|
|
||||||
|
employee: str
|
||||||
|
pipeline: str = ""
|
||||||
|
task_id: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class TaskDraft:
|
||||||
|
"""``CREATE_TASK``: the manager asks for a new task in chat."""
|
||||||
|
|
||||||
|
title: str
|
||||||
|
kind: str = "feature"
|
||||||
|
priority: str = "normal"
|
||||||
|
depends_on: tuple[str, ...] = ()
|
||||||
|
pipeline: str = ""
|
||||||
|
description: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class TeamChange:
|
||||||
|
"""``HIRE`` / ``UPDATE_EMPLOYEE`` / ``FIRE``."""
|
||||||
|
|
||||||
|
action: TagName
|
||||||
|
name: str = ""
|
||||||
|
role: str = ""
|
||||||
|
tier: str = ""
|
||||||
|
task_id: str = ""
|
||||||
|
employee_id: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class KnowledgeItem:
|
||||||
|
"""``KNOWLEDGE kind="rule|fact" key="..."`` with the fact as the body."""
|
||||||
|
|
||||||
|
kind: str = "rule"
|
||||||
|
key: str = ""
|
||||||
|
content: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass(frozen=True, slots=True)
|
||||||
|
class EditBlock:
|
||||||
|
"""One ``<<<OLD … === … NEW>>>`` search/replace block inside an EDIT_FILE."""
|
||||||
|
|
||||||
|
old: str
|
||||||
|
new: str
|
||||||
|
|
||||||
|
|
||||||
|
_EDIT_BLOCK_RE = re.compile(
|
||||||
|
r"<<<OLD\s*\n(?P<old>.*?)\n===\s*\n(?P<new>.*?)\n\s*NEW>>>",
|
||||||
|
re.DOTALL,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def assign_of(tag: Tag) -> AssignTag:
|
||||||
|
"""Read an ``ASSIGN`` tag. ``employee=""`` means "pick for me"."""
|
||||||
|
employee = tag.attr("employee", "").strip()
|
||||||
|
if not employee and not tag.attr("task_id", "").strip():
|
||||||
|
raise ProtocolError("[[ASSIGN]] needs employee=... or task_id=...")
|
||||||
|
pipeline = tag.attr("pipeline", "").strip()
|
||||||
|
if pipeline and pipeline not in ASSIGNABLE_PIPELINES:
|
||||||
|
raise ProtocolError(f"unknown pipeline: {pipeline}")
|
||||||
|
return AssignTag(
|
||||||
|
employee=employee, pipeline=pipeline, task_id=tag.attr("task_id", "").strip()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def task_draft_of(tag: Tag) -> TaskDraft:
|
||||||
|
"""Read a ``CREATE_TASK`` tag."""
|
||||||
|
title = tag.attr("title", "").strip()
|
||||||
|
if not title:
|
||||||
|
raise ProtocolError("[[CREATE_TASK]] needs title=...")
|
||||||
|
raw_deps = tag.attr("depends_on", "")
|
||||||
|
depends = tuple(d.strip() for d in raw_deps.split(",") if d.strip())
|
||||||
|
return TaskDraft(
|
||||||
|
title=title,
|
||||||
|
kind=tag.attr("kind", "feature").strip() or "feature",
|
||||||
|
priority=tag.attr("priority", "normal").strip() or "normal",
|
||||||
|
depends_on=depends,
|
||||||
|
pipeline=tag.attr("pipeline", "").strip(),
|
||||||
|
description=tag.body.strip()[:4000],
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def team_change_of(tag: Tag) -> TeamChange:
|
||||||
|
"""Read one of the team tags. Users choose role and tier, never a model."""
|
||||||
|
return TeamChange(
|
||||||
|
action=tag.name,
|
||||||
|
name=tag.attr("name", "").strip(),
|
||||||
|
role=tag.attr("role", "").strip(),
|
||||||
|
tier=tag.attr("tier", "").strip(),
|
||||||
|
task_id=tag.attr("task_id", "").strip(),
|
||||||
|
employee_id=tag.attr("employee_id", "").strip(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def knowledge_of(tag: Tag) -> KnowledgeItem:
|
||||||
|
"""Read a ``KNOWLEDGE`` tag; the body is the content."""
|
||||||
|
return KnowledgeItem(
|
||||||
|
kind=tag.attr("kind", "rule").strip() or "rule",
|
||||||
|
key=tag.attr("key", "").strip(),
|
||||||
|
content=tag.body.strip(),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def edit_blocks_of(tag: Tag) -> list[EditBlock]:
|
||||||
|
"""Read the search/replace blocks of an ``EDIT_FILE`` body.
|
||||||
|
|
||||||
|
Blocks are ``<<<OLD`` … ``===`` … ``NEW>>>``. A malformed body raises
|
||||||
|
rather than guessing: a half-applied edit is worse than a failed one.
|
||||||
|
"""
|
||||||
|
blocks = [
|
||||||
|
EditBlock(old=m.group("old"), new=m.group("new"))
|
||||||
|
for m in _EDIT_BLOCK_RE.finditer(tag.body)
|
||||||
|
]
|
||||||
|
if not blocks:
|
||||||
|
raise ProtocolError("[[EDIT_FILE]] needs one or more <<<OLD ... === ... NEW>>> blocks")
|
||||||
|
for block in blocks:
|
||||||
|
if not block.old.strip():
|
||||||
|
raise ProtocolError("[[EDIT_FILE]] OLD block is empty")
|
||||||
|
return blocks
|
||||||
|
|
||||||
|
|
||||||
|
def task_drafts(reply: ParsedReply) -> list[TaskDraft]:
|
||||||
|
return [task_draft_of(tag) for tag in reply.of(TagName.CREATE_TASK)]
|
||||||
|
|
||||||
|
|
||||||
|
def team_changes(reply: ParsedReply) -> list[TeamChange]:
|
||||||
|
"""Every team tag, in the order the model wrote them."""
|
||||||
|
return [team_change_of(tag) for tag in reply.tags if tag.name in TEAM_CHANGE_TAGS]
|
||||||
|
|
||||||
|
|
||||||
|
def knowledge_items(reply: ParsedReply) -> list[KnowledgeItem]:
|
||||||
|
return [knowledge_of(tag) for tag in reply.of(TagName.KNOWLEDGE)]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Parser
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#: ``[[ ... ]]`` whose content has no brackets inside: an opening token, a
|
||||||
|
#: closing token, or a self-closing one. Attribute values with a ``]`` are
|
||||||
|
#: refused by the attribute parser below instead of silently truncating.
|
||||||
|
_TOKEN_RE = re.compile(r"\[\[([^\[\]]*)\]\]")
|
||||||
|
_ATTR_RE = re.compile(r"([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(?:\"([^\"]*)\"|'([^']*)')")
|
||||||
|
|
||||||
|
|
||||||
|
def _closing_re(name: str) -> re.Pattern[str]:
|
||||||
|
return re.compile(r"\[\[/\s*" + re.escape(name) + r"\s*\]\]", re.IGNORECASE)
|
||||||
|
|
||||||
|
|
||||||
|
def _split_token(inner: str) -> tuple[str, str, bool]:
|
||||||
|
"""``'TAG a="1"'`` -> ``("TAG", ' a="1"', False)``; trailing ``/`` self-closes."""
|
||||||
|
text = inner.strip()
|
||||||
|
self_closing = text.endswith("/")
|
||||||
|
if self_closing:
|
||||||
|
text = text[:-1].strip()
|
||||||
|
match = re.match(r"([A-Za-z_][A-Za-z0-9_]*)", text)
|
||||||
|
if match is None: # pragma: no cover - the token regex requires a name
|
||||||
|
return "", "", self_closing
|
||||||
|
name = match.group(1)
|
||||||
|
return name.upper(), text[match.end() :], self_closing
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_attrs(rest: str) -> tuple[dict[str, str], list[str]]:
|
||||||
|
attrs: dict[str, str] = {}
|
||||||
|
errors: list[str] = []
|
||||||
|
consumed_end = 0
|
||||||
|
for match in _ATTR_RE.finditer(rest):
|
||||||
|
attrs[match.group(1)] = match.group(2) if match.group(2) is not None else match.group(3)
|
||||||
|
consumed_end = match.end()
|
||||||
|
leftover = rest[consumed_end:].strip()
|
||||||
|
if leftover:
|
||||||
|
# One line for the whole fragment: "employee سارا" reads better than two.
|
||||||
|
errors.append(
|
||||||
|
f"ERROR: malformed attribute fragment '{leftover}' (expected key=\"value\")"
|
||||||
|
)
|
||||||
|
return attrs, errors
|
||||||
|
|
||||||
|
|
||||||
|
def _consume(name: str, text: str, start: int, self_closing: bool) -> int:
|
||||||
|
"""Where the tag ends: its ``[[/NAME]]`` close, or just the token.
|
||||||
|
|
||||||
|
Consuming the whole pair is what keeps a body literal: the scanner moves
|
||||||
|
past the close, so brackets inside the body are never parsed as tags.
|
||||||
|
"""
|
||||||
|
if self_closing:
|
||||||
|
return start
|
||||||
|
close = _closing_re(name).search(text, start)
|
||||||
|
return close.end() if close else start
|
||||||
|
|
||||||
|
|
||||||
|
def parse_reply(text: str, *, role: RoleKey | str, mode: Mode | str) -> ParsedReply:
|
||||||
|
"""Parse one model reply under the role/mode matrix.
|
||||||
|
|
||||||
|
Unknown tags, disallowed tags and unclosed tags are dropped (their body is
|
||||||
|
consumed so it cannot re-appear as stray tags) and reported in
|
||||||
|
``ParsedReply.errors`` — one line per problem, in order, for the model to
|
||||||
|
read on its next turn.
|
||||||
|
"""
|
||||||
|
role_key = RoleKey(role)
|
||||||
|
role_mode = Mode(mode)
|
||||||
|
allowed = ALLOWED_TAGS.get((role_key, role_mode), frozenset())
|
||||||
|
|
||||||
|
tags: list[Tag] = []
|
||||||
|
errors: list[str] = []
|
||||||
|
prose_parts: list[str] = []
|
||||||
|
pos = 0
|
||||||
|
|
||||||
|
while pos < len(text):
|
||||||
|
match = _TOKEN_RE.search(text, pos)
|
||||||
|
if match is None:
|
||||||
|
prose_parts.append(text[pos:])
|
||||||
|
break
|
||||||
|
prose_parts.append(text[pos : match.start()])
|
||||||
|
inner = match.group(1)
|
||||||
|
name, rest, self_closing = _split_token(inner)
|
||||||
|
|
||||||
|
if inner.lstrip().startswith("/"):
|
||||||
|
# A close with no open: the model echoed a tag it never opened.
|
||||||
|
errors.append(f"ERROR: stray closing tag [[{inner.strip()}]] ignored")
|
||||||
|
pos = match.end()
|
||||||
|
continue
|
||||||
|
|
||||||
|
token_end = match.end()
|
||||||
|
try:
|
||||||
|
tag_name = TagName(name)
|
||||||
|
except ValueError:
|
||||||
|
# Unknown tag: consume the pair so its body stays literal text.
|
||||||
|
end = _consume(name, text, token_end, self_closing)
|
||||||
|
errors.append(f"ERROR: unknown tag [[{name}]] ignored")
|
||||||
|
pos = end
|
||||||
|
continue
|
||||||
|
|
||||||
|
if tag_name not in allowed:
|
||||||
|
end = _consume(name, text, token_end, self_closing)
|
||||||
|
errors.append(
|
||||||
|
f"ERROR: tag [[{tag_name}]] is not allowed for "
|
||||||
|
f"role={role_key.value} mode={role_mode.value}"
|
||||||
|
)
|
||||||
|
pos = end
|
||||||
|
continue
|
||||||
|
|
||||||
|
attrs, attr_errors = _parse_attrs(rest)
|
||||||
|
errors.extend(attr_errors)
|
||||||
|
|
||||||
|
body = ""
|
||||||
|
if not self_closing:
|
||||||
|
if tag_name not in BODY_TAGS:
|
||||||
|
# A bodyless tag written without its "/": read it as-is.
|
||||||
|
tags.append(
|
||||||
|
Tag(
|
||||||
|
name=tag_name,
|
||||||
|
attrs=attrs,
|
||||||
|
body="",
|
||||||
|
raw=text[match.start() : token_end],
|
||||||
|
)
|
||||||
|
)
|
||||||
|
pos = token_end
|
||||||
|
continue
|
||||||
|
close = _closing_re(name).search(text, token_end)
|
||||||
|
if close is None:
|
||||||
|
# Unclosed: report it and ignore the tag, body included.
|
||||||
|
errors.append(f"ERROR: unclosed tag [[{name}]] ignored")
|
||||||
|
pos = token_end
|
||||||
|
continue
|
||||||
|
body = text[token_end : close.start()]
|
||||||
|
token_end = close.end()
|
||||||
|
|
||||||
|
tags.append(Tag(name=tag_name, attrs=attrs, body=body, raw=text[match.start() : token_end]))
|
||||||
|
pos = token_end
|
||||||
|
|
||||||
|
limited, limit_errors = _apply_limits(tags)
|
||||||
|
errors.extend(limit_errors)
|
||||||
|
return ParsedReply(
|
||||||
|
tags=tuple(limited), errors=tuple(errors), prose="".join(prose_parts).strip()
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _apply_limits(tags: list[Tag]) -> tuple[list[Tag], list[str]]:
|
||||||
|
"""Drop tags past their per-reply limit, keeping the first ones written.
|
||||||
|
|
||||||
|
Team changes share one budget across all three tags: "max 3 changes per
|
||||||
|
turn" means three hires, or two hires and a firing — not three of each.
|
||||||
|
"""
|
||||||
|
errors: list[str] = []
|
||||||
|
kept: list[Tag] = []
|
||||||
|
created = 0
|
||||||
|
team_used = 0
|
||||||
|
knowledge_used = 0
|
||||||
|
for tag in tags:
|
||||||
|
if tag.name is TagName.CREATE_TASK:
|
||||||
|
created += 1
|
||||||
|
if created > MAX_CREATE_TASKS_PER_REPLY:
|
||||||
|
errors.append(_limit_error(tag, MAX_CREATE_TASKS_PER_REPLY))
|
||||||
|
continue
|
||||||
|
elif tag.name in TEAM_CHANGE_TAGS:
|
||||||
|
team_used += 1
|
||||||
|
if team_used > MAX_TEAM_CHANGES_PER_TURN:
|
||||||
|
errors.append(_limit_error(tag, MAX_TEAM_CHANGES_PER_TURN))
|
||||||
|
continue
|
||||||
|
elif tag.name is TagName.KNOWLEDGE:
|
||||||
|
knowledge_used += 1
|
||||||
|
if knowledge_used > MAX_KNOWLEDGE_PER_TURN:
|
||||||
|
errors.append(_limit_error(tag, MAX_KNOWLEDGE_PER_TURN))
|
||||||
|
continue
|
||||||
|
kept.append(tag)
|
||||||
|
return kept, errors
|
||||||
|
|
||||||
|
|
||||||
|
def _limit_error(tag: Tag, limit: int) -> str:
|
||||||
|
return (
|
||||||
|
f"ERROR: too many [[{tag.name}]] in one reply (max {limit}); the extra was ignored"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def allowed_tags(*, role: RoleKey | str, mode: Mode | str) -> frozenset[TagName]:
|
||||||
|
return ALLOWED_TAGS.get((RoleKey(role), Mode(mode)), frozenset())
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"ALLOWED_TAGS",
|
||||||
|
"ASSIGNABLE_PIPELINES",
|
||||||
|
"BODY_TAGS",
|
||||||
|
"MAX_CREATE_TASKS_PER_REPLY",
|
||||||
|
"MAX_KNOWLEDGE_PER_TURN",
|
||||||
|
"MAX_TEAM_CHANGES_PER_TURN",
|
||||||
|
"TEAM_CHANGE_TAGS",
|
||||||
|
"TOOL_TAGS",
|
||||||
|
"AssignTag",
|
||||||
|
"EditBlock",
|
||||||
|
"KnowledgeItem",
|
||||||
|
"ParsedReply",
|
||||||
|
"ProtocolError",
|
||||||
|
"Tag",
|
||||||
|
"TagName",
|
||||||
|
"TaskDraft",
|
||||||
|
"TeamChange",
|
||||||
|
"allowed_tags",
|
||||||
|
"assign_of",
|
||||||
|
"edit_blocks_of",
|
||||||
|
"knowledge_items",
|
||||||
|
"knowledge_of",
|
||||||
|
"parse_reply",
|
||||||
|
"task_drafts",
|
||||||
|
"task_draft_of",
|
||||||
|
"team_change_of",
|
||||||
|
"team_changes",
|
||||||
|
]
|
||||||
@@ -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
|
||||||
@@ -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",
|
||||||
|
]
|
||||||
@@ -5,6 +5,170 @@ Use ``from app.core.database import Base`` and the helpers in
|
|||||||
``app.infrastructure.db.models`` here: that module imports this one (import cycle).
|
``app.infrastructure.db.models`` here: that module imports this one (import cycle).
|
||||||
Foreign keys to existing tables are written as plain strings, e.g.
|
Foreign keys to existing tables are written as plain strings, e.g.
|
||||||
``ForeignKey("organizations.id", ondelete="CASCADE")``. See docs/plan/PLAN-v2.md §6.
|
``ForeignKey("organizations.id", ondelete="CASCADE")``. See docs/plan/PLAN-v2.md §6.
|
||||||
|
|
||||||
|
Four tables, deliberately split so ``tasks`` and ``employees`` stay untouched
|
||||||
|
(they are locked):
|
||||||
|
|
||||||
|
* ``task_pipeline_state`` — the engine's own view of a task: pipeline, stage,
|
||||||
|
cycle counters, branch and the optimistic ``version``. One row per task.
|
||||||
|
* ``employee_profiles`` — which granted model an employee runs on, and how
|
||||||
|
busy it is. Model identity never leaves the server.
|
||||||
|
* ``engine_turns`` — one row per agent turn: the audit trail of who thought
|
||||||
|
what, with a bounded reply excerpt.
|
||||||
|
* ``gate_results`` — one row per gate verdict (tests, diff guard, no-progress,
|
||||||
|
green), with the detail that justifies it.
|
||||||
|
|
||||||
|
Both side tables cascade from their parent, so deleting a task or an employee
|
||||||
|
never leaves an orphaned engine row behind.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
|
||||||
|
from sqlalchemy import (
|
||||||
|
JSON,
|
||||||
|
Boolean,
|
||||||
|
DateTime,
|
||||||
|
ForeignKey,
|
||||||
|
Index,
|
||||||
|
Integer,
|
||||||
|
String,
|
||||||
|
Text,
|
||||||
|
)
|
||||||
|
from sqlalchemy.orm import Mapped, mapped_column
|
||||||
|
|
||||||
|
from app.core.database import Base
|
||||||
|
from app.domain.base import utcnow
|
||||||
|
from app.infrastructure.db.columns import created, pk
|
||||||
|
|
||||||
|
#: The engine's default limits, mirroring the settings block in ``config.py``.
|
||||||
|
#: They are column defaults (not settings) so that a database written by an
|
||||||
|
#: older deployment still carries a sane value.
|
||||||
|
DEFAULT_MAX_CYCLES = 6
|
||||||
|
|
||||||
|
|
||||||
|
class TaskPipelineStateRow(Base):
|
||||||
|
__tablename__ = "task_pipeline_state"
|
||||||
|
|
||||||
|
task_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey("tasks.id", ondelete="CASCADE"), primary_key=True
|
||||||
|
)
|
||||||
|
#: Denormalised from the task so the tenant filter stays a single-table
|
||||||
|
#: WHERE: the scheduler's hot query never joins back to ``tasks``.
|
||||||
|
organization_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey("organizations.id", ondelete="CASCADE"), nullable=False, index=True
|
||||||
|
)
|
||||||
|
project_id: Mapped[str | None] = mapped_column(
|
||||||
|
ForeignKey("projects.id", ondelete="CASCADE"), nullable=True
|
||||||
|
)
|
||||||
|
pipeline: Mapped[str] = mapped_column(
|
||||||
|
String(32), default="plan_code_test", nullable=False
|
||||||
|
)
|
||||||
|
kind: Mapped[str] = mapped_column(String(16), default="feature", nullable=False)
|
||||||
|
risk: Mapped[str] = mapped_column(String(8), default="medium", nullable=False)
|
||||||
|
#: Indexed: every scheduler tick asks "which tasks are at a turn stage".
|
||||||
|
stage: Mapped[str] = mapped_column(
|
||||||
|
String(32), default="pending_manager_review", nullable=False, index=True
|
||||||
|
)
|
||||||
|
cycle: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
max_cycles: Mapped[int] = mapped_column(
|
||||||
|
Integer, default=DEFAULT_MAX_CYCLES, nullable=False
|
||||||
|
)
|
||||||
|
branch: Mapped[str] = mapped_column(String(120), default="", nullable=False)
|
||||||
|
last_diff_hash: Mapped[str] = mapped_column(String(64), default="", nullable=False)
|
||||||
|
same_diff_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
retry_count: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
#: A waived test pass must always be explainable: the reason is stored,
|
||||||
|
#: never inferred (PLAN-v2 §3, green gate).
|
||||||
|
waiver_reason: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
||||||
|
parent_task_id: Mapped[str | None] = mapped_column(
|
||||||
|
ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
depends_on: Mapped[list] = mapped_column(JSON, default=list, nullable=False)
|
||||||
|
#: Optimistic lock. Every stage move bumps it; a stale write is refused.
|
||||||
|
version: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EmployeeProfileRow(Base):
|
||||||
|
__tablename__ = "employee_profiles"
|
||||||
|
__table_args__ = (Index("ix_employee_profile_busy", "busy_task_id"),)
|
||||||
|
|
||||||
|
employee_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey("employees.id", ondelete="CASCADE"), primary_key=True
|
||||||
|
)
|
||||||
|
#: A granted model id (``org_model_grants.ai_model_id``). Nullable until the
|
||||||
|
#: owner grants something: an employee exists but is inactive, which is a
|
||||||
|
#: state the UI shows instead of hiding the employee.
|
||||||
|
ai_model_id: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||||
|
effort: Mapped[str] = mapped_column(String(16), default="medium", nullable=False)
|
||||||
|
roles: Mapped[str] = mapped_column(String(255), default="", nullable=False)
|
||||||
|
hired_by: Mapped[str] = mapped_column(String(8), default="user", nullable=False)
|
||||||
|
busy_task_id: Mapped[str | None] = mapped_column(
|
||||||
|
ForeignKey("tasks.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
updated_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EngineTurnRow(Base):
|
||||||
|
__tablename__ = "engine_turns"
|
||||||
|
__table_args__ = (
|
||||||
|
Index("ix_engine_turn_task_stage", "task_id", "stage"),
|
||||||
|
Index("ix_engine_turn_run", "run_id"),
|
||||||
|
)
|
||||||
|
|
||||||
|
id: Mapped[str] = pk()
|
||||||
|
task_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey("tasks.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
run_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey("runs.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
employee_id: Mapped[str | None] = mapped_column(
|
||||||
|
ForeignKey("employees.id", ondelete="SET NULL"), nullable=True
|
||||||
|
)
|
||||||
|
role: Mapped[str] = mapped_column(String(24), default="developer", nullable=False)
|
||||||
|
mode: Mapped[str] = mapped_column(String(16), default="work", nullable=False)
|
||||||
|
stage: Mapped[str] = mapped_column(String(32), default="", nullable=False)
|
||||||
|
prompt_chars: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
#: Bounded on write: a reply is unbounded, a row is not.
|
||||||
|
reply_excerpt: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
||||||
|
tags: Mapped[dict] = mapped_column(JSON, default=dict, nullable=False)
|
||||||
|
tool_calls: Mapped[int] = mapped_column(Integer, default=0, nullable=False)
|
||||||
|
ok: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
error_kind: Mapped[str] = mapped_column(String(16), default="", nullable=False)
|
||||||
|
started_at: Mapped[datetime] = mapped_column(
|
||||||
|
DateTime(timezone=True), default=utcnow, nullable=False
|
||||||
|
)
|
||||||
|
finished_at: Mapped[datetime | None] = mapped_column(
|
||||||
|
DateTime(timezone=True), nullable=True
|
||||||
|
)
|
||||||
|
created_at: Mapped[datetime] = created()
|
||||||
|
|
||||||
|
|
||||||
|
class GateResultRow(Base):
|
||||||
|
__tablename__ = "gate_results"
|
||||||
|
__table_args__ = (Index("ix_gate_result_task_gate", "task_id", "gate"),)
|
||||||
|
|
||||||
|
id: Mapped[str] = pk()
|
||||||
|
task_id: Mapped[str] = mapped_column(
|
||||||
|
ForeignKey("tasks.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
gate: Mapped[str] = mapped_column(String(16), default="tests", nullable=False)
|
||||||
|
ok: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||||
|
detail: Mapped[str] = mapped_column(Text, default="", nullable=False)
|
||||||
|
created_at: Mapped[datetime] = created()
|
||||||
|
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"DEFAULT_MAX_CYCLES",
|
||||||
|
"EmployeeProfileRow",
|
||||||
|
"EngineTurnRow",
|
||||||
|
"GateResultRow",
|
||||||
|
"TaskPipelineStateRow",
|
||||||
|
]
|
||||||
|
|||||||
@@ -0,0 +1,475 @@
|
|||||||
|
"""Repositories for the AI-workforce engine's own tables.
|
||||||
|
|
||||||
|
Four repositories, one per engine table, all following the house rule: the
|
||||||
|
ORM row never leaves this module — the domain dataclasses in
|
||||||
|
``app.domain.workforce.pipeline`` / ``engine`` are what the application layer
|
||||||
|
holds.
|
||||||
|
|
||||||
|
The interesting one is :class:`PipelineStateRepository`. Its ``advance`` is an
|
||||||
|
optimistic compare-and-set: the ``UPDATE`` carries the caller's expected stage
|
||||||
|
and version in its ``WHERE`` clause, so two workers moving the same task
|
||||||
|
cannot both win — the loser sees zero updated rows and gets a
|
||||||
|
:class:`StaleStageError` instead of silently overwriting the winner.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from sqlalchemy import func, select, update
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.domain.base import utcnow
|
||||||
|
from app.domain.workforce.engine import (
|
||||||
|
EmployeeProfile,
|
||||||
|
EngineTurn,
|
||||||
|
GateName,
|
||||||
|
GateResult,
|
||||||
|
)
|
||||||
|
from app.domain.workforce.models import RoleKey
|
||||||
|
from app.domain.workforce.pipeline import (
|
||||||
|
TRANSITIONS,
|
||||||
|
Mode,
|
||||||
|
PipelineKey,
|
||||||
|
Risk,
|
||||||
|
Stage,
|
||||||
|
TaskKind,
|
||||||
|
TaskPipelineState,
|
||||||
|
)
|
||||||
|
from app.infrastructure.db.models_engine import (
|
||||||
|
EmployeeProfileRow,
|
||||||
|
EngineTurnRow,
|
||||||
|
GateResultRow,
|
||||||
|
TaskPipelineStateRow,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class StaleStageError(Exception):
|
||||||
|
"""Another worker moved the task first; the caller must re-read it."""
|
||||||
|
|
||||||
|
|
||||||
|
class UnknownTaskError(LookupError):
|
||||||
|
"""The task has no pipeline state row (it was never registered)."""
|
||||||
|
|
||||||
|
|
||||||
|
class InvalidTransitionError(ValueError):
|
||||||
|
"""The requested move is not in the stage transition table."""
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# task_pipeline_state
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#: Columns ``patch`` may touch. An allow-list, so a typo can never become SQL.
|
||||||
|
_PATCHABLE = frozenset(
|
||||||
|
{
|
||||||
|
"pipeline",
|
||||||
|
"kind",
|
||||||
|
"risk",
|
||||||
|
"stage",
|
||||||
|
"cycle",
|
||||||
|
"max_cycles",
|
||||||
|
"branch",
|
||||||
|
"last_diff_hash",
|
||||||
|
"same_diff_count",
|
||||||
|
"retry_count",
|
||||||
|
"waiver_reason",
|
||||||
|
"parent_task_id",
|
||||||
|
"depends_on",
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _state(row: TaskPipelineStateRow) -> TaskPipelineState:
|
||||||
|
return TaskPipelineState(
|
||||||
|
task_id=row.task_id,
|
||||||
|
organization_id=row.organization_id,
|
||||||
|
project_id=row.project_id,
|
||||||
|
pipeline=PipelineKey(row.pipeline),
|
||||||
|
kind=TaskKind(row.kind),
|
||||||
|
risk=Risk(row.risk),
|
||||||
|
stage=Stage(row.stage),
|
||||||
|
cycle=row.cycle,
|
||||||
|
max_cycles=row.max_cycles,
|
||||||
|
branch=row.branch,
|
||||||
|
last_diff_hash=row.last_diff_hash,
|
||||||
|
same_diff_count=row.same_diff_count,
|
||||||
|
retry_count=row.retry_count,
|
||||||
|
waiver_reason=row.waiver_reason,
|
||||||
|
parent_task_id=row.parent_task_id,
|
||||||
|
depends_on=list(row.depends_on or []),
|
||||||
|
version=row.version,
|
||||||
|
updated_at=row.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _legal_targets(stage: Stage) -> frozenset[Stage]:
|
||||||
|
"""The stage moves the domain allows from here."""
|
||||||
|
return TRANSITIONS.get(stage, frozenset())
|
||||||
|
|
||||||
|
|
||||||
|
class PipelineStateRepository:
|
||||||
|
"""1:1 with a task: pipeline, stage, cycle guards and the version lock."""
|
||||||
|
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
async def get(self, task_id: str) -> TaskPipelineState | None:
|
||||||
|
row = await self.session.get(TaskPipelineStateRow, task_id)
|
||||||
|
return _state(row) if row else None
|
||||||
|
|
||||||
|
async def get_or_raise(self, task_id: str) -> TaskPipelineState:
|
||||||
|
state = await self.get(task_id)
|
||||||
|
if state is None:
|
||||||
|
raise UnknownTaskError(task_id)
|
||||||
|
return state
|
||||||
|
|
||||||
|
async def list_for_org(
|
||||||
|
self, organization_id: str, *, limit: int = 500
|
||||||
|
) -> list[TaskPipelineState]:
|
||||||
|
stmt = (
|
||||||
|
select(TaskPipelineStateRow)
|
||||||
|
.where(TaskPipelineStateRow.organization_id == organization_id)
|
||||||
|
.order_by(TaskPipelineStateRow.updated_at.desc())
|
||||||
|
.limit(min(limit, 1000))
|
||||||
|
)
|
||||||
|
return [_state(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||||
|
|
||||||
|
async def list_at_stages(
|
||||||
|
self, stages: tuple[Stage, ...], *, limit: int = 50
|
||||||
|
) -> list[TaskPipelineState]:
|
||||||
|
"""The scheduler's hot query: tasks currently at a turn stage."""
|
||||||
|
if not stages:
|
||||||
|
return []
|
||||||
|
stmt = (
|
||||||
|
select(TaskPipelineStateRow)
|
||||||
|
.where(TaskPipelineStateRow.stage.in_([str(s) for s in stages]))
|
||||||
|
.order_by(TaskPipelineStateRow.updated_at.asc())
|
||||||
|
.limit(min(limit, 500))
|
||||||
|
)
|
||||||
|
return [_state(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||||
|
|
||||||
|
async def list_dependents(self, task_id: str) -> list[TaskPipelineState]:
|
||||||
|
"""Tasks that declare ``task_id`` in ``depends_on`` and are not closed.
|
||||||
|
|
||||||
|
JSON columns have no portable containment operator (SQLite differs from
|
||||||
|
PostgreSQL), so the match happens in Python. The set is small — it is
|
||||||
|
the tasks waiting on one specific task — so this is not a scan of the
|
||||||
|
table.
|
||||||
|
"""
|
||||||
|
stmt = select(TaskPipelineStateRow).where(
|
||||||
|
TaskPipelineStateRow.stage.notin_(
|
||||||
|
[str(Stage.COMPLETED), str(Stage.CANCELLED), str(Stage.FAILED)]
|
||||||
|
)
|
||||||
|
)
|
||||||
|
dependents: list[TaskPipelineState] = []
|
||||||
|
for row in (await self.session.execute(stmt)).scalars():
|
||||||
|
if task_id in list(row.depends_on or []):
|
||||||
|
dependents.append(_state(row))
|
||||||
|
return dependents
|
||||||
|
|
||||||
|
async def create(self, state: TaskPipelineState) -> TaskPipelineState:
|
||||||
|
row = TaskPipelineStateRow(
|
||||||
|
task_id=state.task_id,
|
||||||
|
organization_id=state.organization_id,
|
||||||
|
project_id=state.project_id,
|
||||||
|
pipeline=str(state.pipeline),
|
||||||
|
kind=str(state.kind),
|
||||||
|
risk=str(state.risk),
|
||||||
|
stage=str(state.stage),
|
||||||
|
cycle=state.cycle,
|
||||||
|
max_cycles=state.max_cycles,
|
||||||
|
branch=state.branch,
|
||||||
|
last_diff_hash=state.last_diff_hash,
|
||||||
|
same_diff_count=state.same_diff_count,
|
||||||
|
retry_count=state.retry_count,
|
||||||
|
waiver_reason=state.waiver_reason,
|
||||||
|
parent_task_id=state.parent_task_id,
|
||||||
|
depends_on=list(state.depends_on),
|
||||||
|
version=state.version,
|
||||||
|
)
|
||||||
|
self.session.add(row)
|
||||||
|
await self.session.flush()
|
||||||
|
return _state(row)
|
||||||
|
|
||||||
|
async def advance(
|
||||||
|
self, *, task_id: str, expected_from: Stage, to: Stage
|
||||||
|
) -> TaskPipelineState:
|
||||||
|
"""Move a task one stage forward, but only if nobody moved it yet.
|
||||||
|
|
||||||
|
The row is read for its current version, then written with both the
|
||||||
|
expected stage and that version in the ``WHERE``. If another worker
|
||||||
|
got there first, zero rows match and :class:`StaleStageError` is
|
||||||
|
raised — the caller re-reads and retries (or gives up cleanly).
|
||||||
|
"""
|
||||||
|
row = await self.session.get(TaskPipelineStateRow, task_id)
|
||||||
|
if row is None:
|
||||||
|
raise UnknownTaskError(task_id)
|
||||||
|
if Stage(row.stage) is not Stage(expected_from):
|
||||||
|
raise StaleStageError(
|
||||||
|
f"task {task_id} is at {row.stage}, not {expected_from.value}"
|
||||||
|
)
|
||||||
|
if Stage(to) not in _legal_targets(Stage(row.stage)):
|
||||||
|
raise InvalidTransitionError(f"{row.stage} -> {to.value} is not allowed")
|
||||||
|
expected_version = row.version
|
||||||
|
# synchronize_session="fetch" keeps the identity-mapped object truthful
|
||||||
|
# (SQLite has no RETURNING for this), so the check below reads what the
|
||||||
|
# database actually did rather than what we hoped it would do.
|
||||||
|
await self.session.execute(
|
||||||
|
update(TaskPipelineStateRow)
|
||||||
|
.where(
|
||||||
|
TaskPipelineStateRow.task_id == task_id,
|
||||||
|
TaskPipelineStateRow.stage == str(expected_from),
|
||||||
|
TaskPipelineStateRow.version == expected_version,
|
||||||
|
)
|
||||||
|
.values(stage=str(to), version=expected_version + 1, updated_at=utcnow())
|
||||||
|
.execution_options(synchronize_session="fetch")
|
||||||
|
)
|
||||||
|
await self.session.flush()
|
||||||
|
# A version that did not move means the WHERE matched nothing: another
|
||||||
|
# worker wrote this row first and this move never happened.
|
||||||
|
if row.version == expected_version or Stage(row.stage) is not Stage(to):
|
||||||
|
raise StaleStageError(f"task {task_id} was moved by another worker")
|
||||||
|
return _state(row)
|
||||||
|
|
||||||
|
async def patch(
|
||||||
|
self, *, task_id: str, expected_version: int, **fields: object
|
||||||
|
) -> TaskPipelineState:
|
||||||
|
"""Change counters without moving the stage, under the version lock."""
|
||||||
|
unknown = set(fields) - _PATCHABLE
|
||||||
|
if unknown:
|
||||||
|
raise ValueError(f"non-patchable columns: {sorted(unknown)}")
|
||||||
|
row = await self.session.get(TaskPipelineStateRow, task_id)
|
||||||
|
if row is None:
|
||||||
|
raise UnknownTaskError(task_id)
|
||||||
|
if not fields:
|
||||||
|
return _state(row)
|
||||||
|
before = row.version
|
||||||
|
await self.session.execute(
|
||||||
|
update(TaskPipelineStateRow)
|
||||||
|
.where(
|
||||||
|
TaskPipelineStateRow.task_id == task_id,
|
||||||
|
TaskPipelineStateRow.version == expected_version,
|
||||||
|
)
|
||||||
|
.values(**fields, version=expected_version + 1, updated_at=utcnow())
|
||||||
|
.execution_options(synchronize_session="fetch")
|
||||||
|
)
|
||||||
|
await self.session.flush()
|
||||||
|
if row.version == before:
|
||||||
|
raise StaleStageError(f"task {task_id} was moved by another worker")
|
||||||
|
return _state(row)
|
||||||
|
|
||||||
|
async def count_at(self, stage: Stage) -> int:
|
||||||
|
stmt = (
|
||||||
|
select(func.count())
|
||||||
|
.select_from(TaskPipelineStateRow)
|
||||||
|
.where(TaskPipelineStateRow.stage == str(stage))
|
||||||
|
)
|
||||||
|
return int((await self.session.execute(stmt)).scalar_one())
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# employee_profiles
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _profile(row: EmployeeProfileRow) -> EmployeeProfile:
|
||||||
|
return EmployeeProfile(
|
||||||
|
employee_id=row.employee_id,
|
||||||
|
ai_model_id=row.ai_model_id,
|
||||||
|
effort=row.effort,
|
||||||
|
roles=[r.strip() for r in (row.roles or "").split(",") if r.strip()],
|
||||||
|
hired_by=row.hired_by,
|
||||||
|
busy_task_id=row.busy_task_id,
|
||||||
|
updated_at=row.updated_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EmployeeProfileRepository:
|
||||||
|
"""The engine's view of an employee: granted model, roles, current task."""
|
||||||
|
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
async def get(self, employee_id: str) -> EmployeeProfile | None:
|
||||||
|
row = await self.session.get(EmployeeProfileRow, employee_id)
|
||||||
|
return _profile(row) if row else None
|
||||||
|
|
||||||
|
async def list_free_with_role(self, role: str, *, limit: int = 10) -> list[str]:
|
||||||
|
"""Employee ids that hold ``role`` and are not busy, least busy first."""
|
||||||
|
stmt = select(EmployeeProfileRow).where(
|
||||||
|
EmployeeProfileRow.busy_task_id.is_(None),
|
||||||
|
EmployeeProfileRow.roles.like(f"%{role}%"),
|
||||||
|
)
|
||||||
|
return [r.employee_id for r in (await self.session.execute(stmt)).scalars()][:limit]
|
||||||
|
|
||||||
|
async def upsert(self, profile: EmployeeProfile) -> EmployeeProfile:
|
||||||
|
row = await self.session.get(EmployeeProfileRow, profile.employee_id)
|
||||||
|
if row is None:
|
||||||
|
row = EmployeeProfileRow(
|
||||||
|
employee_id=profile.employee_id,
|
||||||
|
ai_model_id=profile.ai_model_id,
|
||||||
|
effort=profile.effort,
|
||||||
|
roles=",".join(profile.role_list()),
|
||||||
|
hired_by=profile.hired_by,
|
||||||
|
busy_task_id=profile.busy_task_id,
|
||||||
|
)
|
||||||
|
self.session.add(row)
|
||||||
|
else:
|
||||||
|
row.ai_model_id = profile.ai_model_id
|
||||||
|
row.effort = profile.effort
|
||||||
|
row.roles = ",".join(profile.role_list())
|
||||||
|
row.hired_by = profile.hired_by
|
||||||
|
row.busy_task_id = profile.busy_task_id
|
||||||
|
await self.session.flush()
|
||||||
|
return _profile(row)
|
||||||
|
|
||||||
|
async def set_busy(self, *, employee_id: str, task_id: str | None) -> None:
|
||||||
|
row = await self.session.get(EmployeeProfileRow, employee_id)
|
||||||
|
if row is None:
|
||||||
|
return
|
||||||
|
row.busy_task_id = task_id
|
||||||
|
await self.session.flush()
|
||||||
|
|
||||||
|
async def set_model(self, *, employee_id: str, ai_model_id: str | None) -> None:
|
||||||
|
row = await self.session.get(EmployeeProfileRow, employee_id)
|
||||||
|
if row is None:
|
||||||
|
return
|
||||||
|
row.ai_model_id = ai_model_id
|
||||||
|
await self.session.flush()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# engine_turns
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _turn(row: EngineTurnRow) -> EngineTurn:
|
||||||
|
return EngineTurn(
|
||||||
|
id=row.id,
|
||||||
|
task_id=row.task_id,
|
||||||
|
run_id=row.run_id,
|
||||||
|
employee_id=row.employee_id,
|
||||||
|
role=RoleKey(row.role),
|
||||||
|
mode=Mode(row.mode),
|
||||||
|
stage=Stage(row.stage) if row.stage else Stage.PENDING_IMPLEMENTATION,
|
||||||
|
prompt_chars=row.prompt_chars,
|
||||||
|
reply_excerpt=row.reply_excerpt,
|
||||||
|
tags=dict(row.tags or {}),
|
||||||
|
tool_calls=row.tool_calls,
|
||||||
|
ok=row.ok,
|
||||||
|
error_kind=row.error_kind,
|
||||||
|
started_at=row.started_at,
|
||||||
|
finished_at=row.finished_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class EngineTurnRepository:
|
||||||
|
"""One row per agent turn: the audit trail of who thought what."""
|
||||||
|
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
async def create(self, turn: EngineTurn) -> EngineTurn:
|
||||||
|
row = EngineTurnRow(
|
||||||
|
id=turn.id,
|
||||||
|
task_id=turn.task_id,
|
||||||
|
run_id=turn.run_id,
|
||||||
|
employee_id=turn.employee_id,
|
||||||
|
role=str(turn.role),
|
||||||
|
mode=str(turn.mode),
|
||||||
|
stage=str(turn.stage),
|
||||||
|
prompt_chars=turn.prompt_chars,
|
||||||
|
reply_excerpt=turn.reply_excerpt,
|
||||||
|
tags=dict(turn.tags),
|
||||||
|
tool_calls=turn.tool_calls,
|
||||||
|
ok=turn.ok,
|
||||||
|
error_kind=turn.error_kind,
|
||||||
|
started_at=turn.started_at,
|
||||||
|
finished_at=turn.finished_at,
|
||||||
|
)
|
||||||
|
self.session.add(row)
|
||||||
|
await self.session.flush()
|
||||||
|
return _turn(row)
|
||||||
|
|
||||||
|
async def list_for_task(self, task_id: str, *, limit: int = 200) -> list[EngineTurn]:
|
||||||
|
stmt = (
|
||||||
|
select(EngineTurnRow)
|
||||||
|
.where(EngineTurnRow.task_id == task_id)
|
||||||
|
.order_by(EngineTurnRow.started_at.asc())
|
||||||
|
.limit(min(limit, 1000))
|
||||||
|
)
|
||||||
|
return [_turn(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||||
|
|
||||||
|
async def list_for_run(self, run_id: str) -> list[EngineTurn]:
|
||||||
|
stmt = (
|
||||||
|
select(EngineTurnRow)
|
||||||
|
.where(EngineTurnRow.run_id == run_id)
|
||||||
|
.order_by(EngineTurnRow.started_at.asc())
|
||||||
|
)
|
||||||
|
return [_turn(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||||
|
|
||||||
|
async def finish(
|
||||||
|
self, turn_id: str, *, ok: bool, tool_calls: int, error_kind: str = ""
|
||||||
|
) -> None:
|
||||||
|
row = await self.session.get(EngineTurnRow, turn_id)
|
||||||
|
if row is None:
|
||||||
|
return
|
||||||
|
row.ok = ok
|
||||||
|
row.tool_calls = tool_calls
|
||||||
|
row.error_kind = error_kind
|
||||||
|
row.finished_at = utcnow()
|
||||||
|
await self.session.flush()
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# gate_results
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def _gate(row: GateResultRow) -> GateResult:
|
||||||
|
return GateResult(
|
||||||
|
id=row.id,
|
||||||
|
task_id=row.task_id,
|
||||||
|
gate=GateName(row.gate),
|
||||||
|
ok=row.ok,
|
||||||
|
detail=row.detail,
|
||||||
|
created_at=row.created_at,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class GateResultRepository:
|
||||||
|
"""One row per gate verdict, with the detail that justifies it."""
|
||||||
|
|
||||||
|
def __init__(self, session: AsyncSession) -> None:
|
||||||
|
self.session = session
|
||||||
|
|
||||||
|
async def create(self, result: GateResult) -> GateResult:
|
||||||
|
row = GateResultRow(
|
||||||
|
id=result.id,
|
||||||
|
task_id=result.task_id,
|
||||||
|
gate=str(result.gate),
|
||||||
|
ok=result.ok,
|
||||||
|
detail=result.detail,
|
||||||
|
created_at=result.created_at,
|
||||||
|
)
|
||||||
|
self.session.add(row)
|
||||||
|
await self.session.flush()
|
||||||
|
return _gate(row)
|
||||||
|
|
||||||
|
async def list_for_task(self, task_id: str, *, limit: int = 200) -> list[GateResult]:
|
||||||
|
stmt = (
|
||||||
|
select(GateResultRow)
|
||||||
|
.where(GateResultRow.task_id == task_id)
|
||||||
|
.order_by(GateResultRow.created_at.asc())
|
||||||
|
.limit(min(limit, 1000))
|
||||||
|
)
|
||||||
|
return [_gate(r) for r in (await self.session.execute(stmt)).scalars()]
|
||||||
|
|
||||||
|
async def latest_for(self, task_id: str, gate: str) -> GateResult | None:
|
||||||
|
stmt = (
|
||||||
|
select(GateResultRow)
|
||||||
|
.where(GateResultRow.task_id == task_id, GateResultRow.gate == gate)
|
||||||
|
.order_by(GateResultRow.created_at.desc())
|
||||||
|
.limit(1)
|
||||||
|
)
|
||||||
|
row = (await self.session.execute(stmt)).scalar_one_or_none()
|
||||||
|
return _gate(row) if row else None
|
||||||
@@ -0,0 +1,523 @@
|
|||||||
|
"""The engine's state machine: pipelines, transitions, optimistic moves.
|
||||||
|
|
||||||
|
What these tests defend:
|
||||||
|
|
||||||
|
* every pipeline really walks from the manager's review to ``completed`` on
|
||||||
|
the happy path — a stage nobody can leave is a stuck task
|
||||||
|
* an illegal move is refused *before* it reaches the database, so a bug in a
|
||||||
|
later phase cannot corrupt a task's position
|
||||||
|
* two workers racing on one task: exactly one wins, the loser learns about it
|
||||||
|
instead of silently overwriting the winner
|
||||||
|
* a failed dependency parks its dependents with a human instead of leaving
|
||||||
|
them waiting for work that will never happen (agybot bug M1)
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import pytest_asyncio
|
||||||
|
from sqlalchemy import delete
|
||||||
|
|
||||||
|
from app.application.engine.state import advance, park_dependents
|
||||||
|
from app.core.errors import ConflictError, NotFoundError
|
||||||
|
from app.domain.base import new_id
|
||||||
|
from app.domain.workforce.engine import (
|
||||||
|
EmployeeProfile,
|
||||||
|
EngineTurn,
|
||||||
|
GateName,
|
||||||
|
GateResult,
|
||||||
|
)
|
||||||
|
from app.domain.workforce.models import RoleKey, TaskStatus
|
||||||
|
from app.domain.workforce.pipeline import (
|
||||||
|
APP_DEFAULT_PIPELINE,
|
||||||
|
PIPELINES,
|
||||||
|
SIDE_STAGES,
|
||||||
|
STAGE_ROLE,
|
||||||
|
STAGE_STATUS,
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
from app.infrastructure.db.models import (
|
||||||
|
EmployeeRow,
|
||||||
|
OrganizationRow,
|
||||||
|
RunRow,
|
||||||
|
TaskRow,
|
||||||
|
UserRow,
|
||||||
|
)
|
||||||
|
from app.infrastructure.repositories.engine import (
|
||||||
|
EmployeeProfileRepository,
|
||||||
|
EngineTurnRepository,
|
||||||
|
GateResultRepository,
|
||||||
|
PipelineStateRepository,
|
||||||
|
StaleStageError,
|
||||||
|
)
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Pure logic
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#: The happy path of each pipeline: the stages after the manager's review.
|
||||||
|
_HAPPY_PATHS: dict[PipelineKey, tuple[Stage, ...]] = {
|
||||||
|
PipelineKey.PLAN_CODE_TEST: (
|
||||||
|
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_ANALYSIS,
|
||||||
|
Stage.PENDING_IMPLEMENTATION,
|
||||||
|
Stage.PENDING_REVIEW,
|
||||||
|
Stage.PENDING_TEST,
|
||||||
|
Stage.PENDING_FINAL_REVIEW,
|
||||||
|
Stage.MERGE_REQUEST,
|
||||||
|
Stage.COMPLETED,
|
||||||
|
),
|
||||||
|
PipelineKey.CODE_TEST: (
|
||||||
|
Stage.PENDING_IMPLEMENTATION,
|
||||||
|
Stage.PENDING_TEST,
|
||||||
|
Stage.PENDING_FINAL_REVIEW,
|
||||||
|
Stage.MERGE_REQUEST,
|
||||||
|
Stage.COMPLETED,
|
||||||
|
),
|
||||||
|
PipelineKey.ANALYSIS_ONLY: (Stage.PENDING_ANALYSIS, Stage.COMPLETED),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_pipeline_walks_from_manager_review_to_completed():
|
||||||
|
"""The happy path, one ``Outcome.OK`` step at a time — all four pipelines."""
|
||||||
|
for pipeline, walked in _HAPPY_PATHS.items():
|
||||||
|
stages = pipeline_stages(pipeline)
|
||||||
|
assert stages[0] is Stage.PENDING_MANAGER_REVIEW, pipeline
|
||||||
|
assert stages[-1] is Stage.COMPLETED, pipeline
|
||||||
|
assert stages == (Stage.PENDING_MANAGER_REVIEW, *walked), pipeline
|
||||||
|
previous = Stage.PENDING_MANAGER_REVIEW
|
||||||
|
for wanted in walked:
|
||||||
|
assert next_stage(pipeline, previous, Outcome.OK) is wanted, pipeline
|
||||||
|
# Every step the happy path takes is also a legal database move.
|
||||||
|
assert can_transition_stage(previous, wanted) is True, (pipeline, previous, wanted)
|
||||||
|
previous = wanted
|
||||||
|
|
||||||
|
|
||||||
|
def test_analysis_only_needs_no_worktree_and_the_others_do():
|
||||||
|
assert requires_worktree(PipelineKey.ANALYSIS_ONLY) is False
|
||||||
|
for pipeline in PipelineKey:
|
||||||
|
if pipeline is PipelineKey.ANALYSIS_ONLY:
|
||||||
|
continue
|
||||||
|
assert requires_worktree(pipeline) is True, pipeline
|
||||||
|
|
||||||
|
|
||||||
|
def test_sent_back_work_returns_to_the_developer():
|
||||||
|
"""A revise from review, test or final review cycles the same task."""
|
||||||
|
for stage in (Stage.PENDING_REVIEW, Stage.PENDING_TEST, Stage.PENDING_FINAL_REVIEW):
|
||||||
|
assert next_stage(
|
||||||
|
PipelineKey.PLAN_CODE_REVIEW_TEST, stage, Outcome.REVISE
|
||||||
|
) is Stage.PENDING_IMPLEMENTATION
|
||||||
|
assert next_stage(
|
||||||
|
PipelineKey.CODE_TEST, Stage.ERROR_REVIEW, Outcome.RESOLVE
|
||||||
|
) is Stage.PENDING_IMPLEMENTATION
|
||||||
|
|
||||||
|
|
||||||
|
def test_outcomes_route_to_their_fixed_targets():
|
||||||
|
assert next_stage(
|
||||||
|
PipelineKey.PLAN_CODE_TEST, Stage.PENDING_ANALYSIS, Outcome.ASK_USER
|
||||||
|
) is Stage.PENDING_USER_DECISION
|
||||||
|
assert next_stage(
|
||||||
|
PipelineKey.PLAN_CODE_TEST, Stage.PENDING_ANALYSIS, Outcome.ERROR
|
||||||
|
) is Stage.ERROR_REVIEW
|
||||||
|
assert next_stage(
|
||||||
|
PipelineKey.PLAN_CODE_TEST, Stage.PENDING_MANAGER_REVIEW, Outcome.REJECT
|
||||||
|
) is Stage.CANCELLED
|
||||||
|
|
||||||
|
|
||||||
|
def test_illegal_transitions_are_refused():
|
||||||
|
# Terminal stages never move.
|
||||||
|
for stage in TERMINAL_STAGES:
|
||||||
|
assert TRANSITIONS[stage] == frozenset()
|
||||||
|
assert can_transition_stage(stage, Stage.PENDING_IMPLEMENTATION) is False
|
||||||
|
# A paused task resumes through triage, never straight into code.
|
||||||
|
assert can_transition_stage(Stage.PAUSED, Stage.PENDING_IMPLEMENTATION) is False
|
||||||
|
assert can_transition_stage(Stage.PAUSED, Stage.PENDING_MANAGER_REVIEW) is True
|
||||||
|
# Nobody skips the manager's review by jumping straight to done.
|
||||||
|
assert can_transition_stage(Stage.PENDING_MANAGER_REVIEW, Stage.COMPLETED) is False
|
||||||
|
# Side states cannot jump into each other arbitrarily.
|
||||||
|
assert can_transition_stage(Stage.PENDING_USER_DECISION, Stage.COMPLETED) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_every_pipeline_stage_can_leave_it():
|
||||||
|
"""A stage that appears in a pipeline must have a way out of it."""
|
||||||
|
for pipeline, stages in PIPELINES.items():
|
||||||
|
for stage in stages:
|
||||||
|
assert stage is Stage.COMPLETED or TRANSITIONS.get(stage), (pipeline, stage)
|
||||||
|
|
||||||
|
|
||||||
|
def test_side_stages_are_not_part_of_any_pipeline():
|
||||||
|
pipeline_stage_set = {stage for stages in PIPELINES.values() for stage in stages}
|
||||||
|
for stage in SIDE_STAGES:
|
||||||
|
assert stage not in pipeline_stage_set
|
||||||
|
assert is_side_stage(stage) is True
|
||||||
|
|
||||||
|
|
||||||
|
def test_stage_roles_match_the_pipeline():
|
||||||
|
"""Every turn stage has exactly one actor, and it is the right role."""
|
||||||
|
assert STAGE_ROLE[Stage.PENDING_MANAGER_REVIEW] == StageActor(RoleKey.MANAGER, Mode.TRIAGE)
|
||||||
|
assert STAGE_ROLE[Stage.PENDING_ANALYSIS] == StageActor(RoleKey.PLANNER, Mode.WORK)
|
||||||
|
assert STAGE_ROLE[Stage.PENDING_IMPLEMENTATION] == StageActor(RoleKey.DEVELOPER, Mode.WORK)
|
||||||
|
assert STAGE_ROLE[Stage.PENDING_REVIEW] == StageActor(RoleKey.REVIEWER, Mode.WORK)
|
||||||
|
assert STAGE_ROLE[Stage.PENDING_TEST] == StageActor(RoleKey.TESTER, Mode.WORK)
|
||||||
|
assert STAGE_ROLE[Stage.PENDING_FINAL_REVIEW] == StageActor(RoleKey.MANAGER, Mode.FINAL)
|
||||||
|
assert STAGE_ROLE[Stage.ERROR_REVIEW] == StageActor(RoleKey.MANAGER, Mode.ERROR)
|
||||||
|
# The platform merges; nobody acts in merge_request.
|
||||||
|
assert STAGE_ROLE[Stage.MERGE_REQUEST] is None
|
||||||
|
assert STAGE_ROLE[Stage.PAUSED] is None
|
||||||
|
|
||||||
|
|
||||||
|
def test_stages_map_to_the_coarse_task_status():
|
||||||
|
assert coarse_status(Stage.COMPLETED) is TaskStatus.DONE
|
||||||
|
assert coarse_status(Stage.PENDING_USER_DECISION) is TaskStatus.WAITING_APPROVAL
|
||||||
|
assert coarse_status(Stage.PAUSED) is TaskStatus.WAITING_APPROVAL
|
||||||
|
assert coarse_status(Stage.FAILED) is TaskStatus.FAILED
|
||||||
|
assert coarse_status(Stage.CANCELLED) is TaskStatus.CANCELLED
|
||||||
|
for stage in (
|
||||||
|
Stage.PENDING_MANAGER_REVIEW,
|
||||||
|
Stage.PENDING_ANALYSIS,
|
||||||
|
Stage.PENDING_IMPLEMENTATION,
|
||||||
|
Stage.PENDING_REVIEW,
|
||||||
|
Stage.PENDING_TEST,
|
||||||
|
Stage.PENDING_FINAL_REVIEW,
|
||||||
|
Stage.MERGE_REQUEST,
|
||||||
|
Stage.ERROR_REVIEW,
|
||||||
|
):
|
||||||
|
assert STAGE_STATUS[stage] is TaskStatus.RUNNING, stage
|
||||||
|
|
||||||
|
|
||||||
|
def test_pipeline_resolution_priority():
|
||||||
|
"""manager > kind > app default > plan_code_test."""
|
||||||
|
assert resolve_pipeline(
|
||||||
|
manager_choice=PipelineKey.ANALYSIS_ONLY, task_kind=TaskKind.FEATURE
|
||||||
|
) is PipelineKey.ANALYSIS_ONLY
|
||||||
|
assert resolve_pipeline(task_kind=TaskKind.BUGFIX) is PipelineKey.CODE_TEST
|
||||||
|
assert resolve_pipeline(app_default=PipelineKey.CODE_TEST) is PipelineKey.CODE_TEST
|
||||||
|
assert resolve_pipeline() is APP_DEFAULT_PIPELINE
|
||||||
|
assert APP_DEFAULT_PIPELINE is PipelineKey.PLAN_CODE_TEST
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_review_pipeline_without_a_reviewer_falls_back():
|
||||||
|
"""A task waiting forever for a reviewer that does not exist is worse."""
|
||||||
|
assert resolve_pipeline(task_kind=TaskKind.REFACTOR, has_active_reviewer=True) is (
|
||||||
|
PipelineKey.PLAN_CODE_REVIEW_TEST
|
||||||
|
)
|
||||||
|
assert resolve_pipeline(task_kind=TaskKind.REFACTOR, has_active_reviewer=False) is (
|
||||||
|
PipelineKey.PLAN_CODE_TEST
|
||||||
|
)
|
||||||
|
# Even an explicit manager choice falls back: the fix is hiring a
|
||||||
|
# reviewer, not starting a task nobody can act on.
|
||||||
|
assert resolve_pipeline(
|
||||||
|
manager_choice=PipelineKey.PLAN_CODE_REVIEW_TEST, has_active_reviewer=False
|
||||||
|
) is PipelineKey.PLAN_CODE_TEST
|
||||||
|
|
||||||
|
|
||||||
|
def test_task_kinds_carry_a_pipeline_and_a_risk():
|
||||||
|
assert resolve_pipeline(task_kind=TaskKind.ANALYSIS) is PipelineKey.ANALYSIS_ONLY
|
||||||
|
assert risk_for(TaskKind.REFACTOR) is Risk.HIGH
|
||||||
|
assert risk_for(TaskKind.BUGFIX) is Risk.LOW
|
||||||
|
|
||||||
|
|
||||||
|
def test_terminal_and_side_state_predicates():
|
||||||
|
assert is_terminal(Stage.COMPLETED) is True
|
||||||
|
assert is_terminal(Stage.PENDING_IMPLEMENTATION) is False
|
||||||
|
assert is_side_stage(Stage.PAUSED) is True
|
||||||
|
assert is_side_stage(Stage.PENDING_IMPLEMENTATION) is False
|
||||||
|
|
||||||
|
|
||||||
|
def test_pipeline_state_defaults_are_a_fresh_triage():
|
||||||
|
state = TaskPipelineState(task_id="t1", organization_id="o1")
|
||||||
|
assert state.stage is Stage.PENDING_MANAGER_REVIEW
|
||||||
|
assert state.status is TaskStatus.RUNNING
|
||||||
|
assert state.needs_turn is True
|
||||||
|
assert state.is_blocked is False
|
||||||
|
assert state.actor() == StageActor(RoleKey.MANAGER, Mode.TRIAGE)
|
||||||
|
assert state.max_cycles == 6
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Database-backed: optimistic advance and dependency parking
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest_asyncio.fixture
|
||||||
|
async def _db(session_factory):
|
||||||
|
"""Write directly to the test database: user -> org -> task -> state."""
|
||||||
|
async with session_factory() as session:
|
||||||
|
user = UserRow(
|
||||||
|
email=f"engine-{new_id()[:8]}@example.com", password_hash="x"
|
||||||
|
)
|
||||||
|
session.add(user)
|
||||||
|
await session.flush()
|
||||||
|
org = OrganizationRow(
|
||||||
|
name="سازمان موتور", slug=f"eng-{new_id()[:8]}", owner_user_id=user.id
|
||||||
|
)
|
||||||
|
session.add(org)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
async def make_task(title: str) -> str:
|
||||||
|
async with session_factory() as task_session:
|
||||||
|
task = TaskRow(organization_id=org.id, title=title)
|
||||||
|
task_session.add(task)
|
||||||
|
await task_session.flush()
|
||||||
|
await task_session.commit()
|
||||||
|
return task.id
|
||||||
|
|
||||||
|
async def make_run(task_id: str) -> str:
|
||||||
|
async with session_factory() as run_session:
|
||||||
|
run = RunRow(task_id=task_id)
|
||||||
|
run_session.add(run)
|
||||||
|
await run_session.flush()
|
||||||
|
await run_session.commit()
|
||||||
|
return run.id
|
||||||
|
|
||||||
|
async def make_employee(name: str) -> str:
|
||||||
|
async with session_factory() as employee_session:
|
||||||
|
employee = EmployeeRow(
|
||||||
|
organization_id=org.id, name=name, role_key="developer"
|
||||||
|
)
|
||||||
|
employee_session.add(employee)
|
||||||
|
await employee_session.flush()
|
||||||
|
await employee_session.commit()
|
||||||
|
return employee.id
|
||||||
|
|
||||||
|
yield session_factory, org.id, make_task, make_run, make_employee
|
||||||
|
|
||||||
|
|
||||||
|
async def _state_for(
|
||||||
|
session_factory, organization_id: str, task_id: str, **kwargs
|
||||||
|
) -> TaskPipelineState:
|
||||||
|
async with session_factory() as session:
|
||||||
|
repo = PipelineStateRepository(session)
|
||||||
|
state = await repo.create(
|
||||||
|
TaskPipelineState(task_id=task_id, organization_id=organization_id, **kwargs)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
return state
|
||||||
|
|
||||||
|
|
||||||
|
async def test_advance_moves_the_task_and_bumps_the_version(_db):
|
||||||
|
session_factory, org_id, make_task, make_run, make_employee = _db
|
||||||
|
task_id = await make_task("کار یک")
|
||||||
|
state = await _state_for(session_factory, org_id, task_id)
|
||||||
|
assert state.version == 0
|
||||||
|
|
||||||
|
async with session_factory() as session:
|
||||||
|
moved = await advance(
|
||||||
|
PipelineStateRepository(session),
|
||||||
|
task_id=task_id,
|
||||||
|
expected_from=Stage.PENDING_MANAGER_REVIEW,
|
||||||
|
to=Stage.PENDING_ANALYSIS,
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
assert moved.stage is Stage.PENDING_ANALYSIS
|
||||||
|
assert moved.version == 1
|
||||||
|
assert moved.status is TaskStatus.RUNNING
|
||||||
|
|
||||||
|
|
||||||
|
async def test_advance_refuses_an_illegal_move(_db):
|
||||||
|
session_factory, org_id, make_task, make_run, make_employee = _db
|
||||||
|
task_id = await make_task("کار دو")
|
||||||
|
await _state_for(session_factory, org_id, task_id)
|
||||||
|
|
||||||
|
async with session_factory() as session:
|
||||||
|
with pytest.raises(ConflictError):
|
||||||
|
await advance(
|
||||||
|
PipelineStateRepository(session),
|
||||||
|
task_id=task_id,
|
||||||
|
expected_from=Stage.PENDING_MANAGER_REVIEW,
|
||||||
|
to=Stage.COMPLETED,
|
||||||
|
)
|
||||||
|
await session.rollback()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_concurrent_advance_has_exactly_one_winner(_db):
|
||||||
|
"""The optimistic check: the loser is told, not silently overwritten."""
|
||||||
|
session_factory, org_id, make_task, make_run, make_employee = _db
|
||||||
|
task_id = await make_task("کار سه")
|
||||||
|
await _state_for(session_factory, org_id, task_id)
|
||||||
|
|
||||||
|
async with session_factory() as first, session_factory() as second:
|
||||||
|
winner = await advance(
|
||||||
|
PipelineStateRepository(first),
|
||||||
|
task_id=task_id,
|
||||||
|
expected_from=Stage.PENDING_MANAGER_REVIEW,
|
||||||
|
to=Stage.PENDING_ANALYSIS,
|
||||||
|
)
|
||||||
|
# The second session still believes the task is at manager review.
|
||||||
|
with pytest.raises(ConflictError):
|
||||||
|
await advance(
|
||||||
|
PipelineStateRepository(second),
|
||||||
|
task_id=task_id,
|
||||||
|
expected_from=Stage.PENDING_MANAGER_REVIEW,
|
||||||
|
to=Stage.PENDING_IMPLEMENTATION,
|
||||||
|
)
|
||||||
|
await first.commit()
|
||||||
|
await second.rollback()
|
||||||
|
|
||||||
|
assert winner.stage is Stage.PENDING_ANALYSIS
|
||||||
|
|
||||||
|
|
||||||
|
async def test_advance_on_an_unknown_task_is_a_404(_db):
|
||||||
|
session_factory, _org_id, _make_task, _make_run, _make_employee = _db
|
||||||
|
async with session_factory() as session:
|
||||||
|
with pytest.raises(NotFoundError):
|
||||||
|
await advance(
|
||||||
|
PipelineStateRepository(session),
|
||||||
|
task_id="missing",
|
||||||
|
expected_from=Stage.PENDING_MANAGER_REVIEW,
|
||||||
|
to=Stage.PENDING_ANALYSIS,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_failed_dependency_parks_its_dependents(_db):
|
||||||
|
"""agybot bug M1: a dead dependency must not deadlock its children."""
|
||||||
|
session_factory, org_id, make_task, make_run, make_employee = _db
|
||||||
|
first = await make_task("کار پایه")
|
||||||
|
second = await make_task("کار وابسته")
|
||||||
|
# The dependency dies from error review: that is the only door to FAILED.
|
||||||
|
await _state_for(session_factory, org_id, first, stage=Stage.ERROR_REVIEW)
|
||||||
|
await _state_for(
|
||||||
|
session_factory, org_id, second, stage=Stage.PENDING_ANALYSIS, depends_on=[first]
|
||||||
|
)
|
||||||
|
|
||||||
|
async with session_factory() as session:
|
||||||
|
states = PipelineStateRepository(session)
|
||||||
|
await advance(
|
||||||
|
states,
|
||||||
|
task_id=first,
|
||||||
|
expected_from=Stage.ERROR_REVIEW,
|
||||||
|
to=Stage.FAILED,
|
||||||
|
)
|
||||||
|
parked = await park_dependents(states, task_id=first)
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
assert [p.task_id for p in parked] == [second]
|
||||||
|
assert parked[0].stage is Stage.PENDING_USER_DECISION
|
||||||
|
assert parked[0].status is TaskStatus.WAITING_APPROVAL
|
||||||
|
|
||||||
|
# A dependent that already waits on a human is not parked twice.
|
||||||
|
async with session_factory() as session:
|
||||||
|
again = await park_dependents(PipelineStateRepository(session), task_id=first)
|
||||||
|
assert again == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_a_healthy_dependency_leaves_its_dependents_alone(_db):
|
||||||
|
session_factory, org_id, make_task, make_run, make_employee = _db
|
||||||
|
first = await make_task("کار پایهٔ موفق")
|
||||||
|
second = await make_task("کار وابستهٔ دوم")
|
||||||
|
await _state_for(session_factory, org_id, first)
|
||||||
|
await _state_for(
|
||||||
|
session_factory, org_id, second, stage=Stage.PENDING_ANALYSIS, depends_on=[first]
|
||||||
|
)
|
||||||
|
async with session_factory() as session:
|
||||||
|
# Nothing failed, so nothing is parked.
|
||||||
|
parked = await park_dependents(PipelineStateRepository(session), task_id=first)
|
||||||
|
assert parked == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_patch_bumps_the_version_under_the_lock(_db):
|
||||||
|
"""Counter updates (cycles, retries, waivers) are optimistic too."""
|
||||||
|
session_factory, org_id, make_task, make_run, make_employee = _db
|
||||||
|
task_id = await make_task("کار چهارم")
|
||||||
|
state = await _state_for(session_factory, org_id, task_id)
|
||||||
|
|
||||||
|
async with session_factory() as session:
|
||||||
|
repo = PipelineStateRepository(session)
|
||||||
|
patched = await repo.patch(
|
||||||
|
task_id=task_id, expected_version=state.version, cycle=2, waiver_reason="دلیل"
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
assert patched.cycle == 2
|
||||||
|
assert patched.waiver_reason == "دلیل"
|
||||||
|
assert patched.version == state.version + 1
|
||||||
|
|
||||||
|
async with session_factory() as session:
|
||||||
|
repo = PipelineStateRepository(session)
|
||||||
|
with pytest.raises(StaleStageError):
|
||||||
|
await repo.patch(task_id=task_id, expected_version=state.version, cycle=9)
|
||||||
|
await session.rollback()
|
||||||
|
|
||||||
|
|
||||||
|
async def test_engine_rows_cascade_from_their_task(_db):
|
||||||
|
"""Deleting the task must not leave engine rows behind."""
|
||||||
|
session_factory, org_id, make_task, make_run, make_employee = _db
|
||||||
|
task_id = await make_task("کار حذفشونده")
|
||||||
|
run_id = await make_run(task_id)
|
||||||
|
await _state_for(session_factory, org_id, task_id)
|
||||||
|
|
||||||
|
async with session_factory() as session:
|
||||||
|
turn = await EngineTurnRepository(session).create(
|
||||||
|
EngineTurn(
|
||||||
|
task_id=task_id,
|
||||||
|
run_id=run_id,
|
||||||
|
role=RoleKey.DEVELOPER,
|
||||||
|
mode=Mode.WORK,
|
||||||
|
stage=Stage.PENDING_IMPLEMENTATION,
|
||||||
|
reply_excerpt="چیزی",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await GateResultRepository(session).create(
|
||||||
|
GateResult(task_id=task_id, gate=GateName.TESTS, ok=True)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
assert turn.task_id == task_id
|
||||||
|
|
||||||
|
async with session_factory() as session:
|
||||||
|
await session.execute(delete(TaskRow).where(TaskRow.id == task_id))
|
||||||
|
await session.commit()
|
||||||
|
|
||||||
|
async with session_factory() as session:
|
||||||
|
assert await PipelineStateRepository(session).get(task_id) is None
|
||||||
|
assert await EngineTurnRepository(session).list_for_task(task_id) == []
|
||||||
|
|
||||||
|
|
||||||
|
async def test_employee_profile_upsert_round_trip(_db):
|
||||||
|
session_factory, _org_id, make_task, _make_run, make_employee = _db
|
||||||
|
employee_id = await make_employee("سارا")
|
||||||
|
task_id = await make_task("کار در دست")
|
||||||
|
async with session_factory() as session:
|
||||||
|
repo = EmployeeProfileRepository(session)
|
||||||
|
await repo.upsert(
|
||||||
|
EmployeeProfile(
|
||||||
|
employee_id=employee_id,
|
||||||
|
ai_model_id="model-1",
|
||||||
|
roles=["developer"],
|
||||||
|
hired_by="manager",
|
||||||
|
)
|
||||||
|
)
|
||||||
|
await session.commit()
|
||||||
|
# A narrow change does not rewrite the whole record...
|
||||||
|
await repo.set_busy(employee_id=employee_id, task_id=task_id)
|
||||||
|
await repo.set_model(employee_id=employee_id, ai_model_id="model-2")
|
||||||
|
await session.commit()
|
||||||
|
profile = await repo.get(employee_id)
|
||||||
|
assert profile is not None
|
||||||
|
assert profile.ai_model_id == "model-2"
|
||||||
|
assert profile.busy_task_id == task_id
|
||||||
|
assert profile.role_list() == ["developer"]
|
||||||
|
assert profile.hired_by == "manager"
|
||||||
@@ -0,0 +1,413 @@
|
|||||||
|
"""The tag protocol: parsing, the role/mode matrix and the per-reply limits.
|
||||||
|
|
||||||
|
What these tests defend:
|
||||||
|
|
||||||
|
* a tag pair with brackets inside its body is parsed as *one* tag with a
|
||||||
|
literal body — a source file full of ``[[`` must survive a round trip
|
||||||
|
* an unknown or disallowed tag never executes: it is dropped and reported so
|
||||||
|
the model can correct itself next turn
|
||||||
|
* the per-role matrix is exhaustive (every role/mode that acts has a row) and
|
||||||
|
the toolkit is granted to working roles only — the manager never edits code
|
||||||
|
* limits are enforced: three tasks, three team changes, three knowledge
|
||||||
|
items, and the extras are dropped rather than executed
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
from app.application.engine.protocol import (
|
||||||
|
ALLOWED_TAGS,
|
||||||
|
MAX_CREATE_TASKS_PER_REPLY,
|
||||||
|
MAX_KNOWLEDGE_PER_TURN,
|
||||||
|
MAX_TEAM_CHANGES_PER_TURN,
|
||||||
|
ProtocolError,
|
||||||
|
TagName,
|
||||||
|
assign_of,
|
||||||
|
edit_blocks_of,
|
||||||
|
knowledge_items,
|
||||||
|
knowledge_of,
|
||||||
|
parse_reply,
|
||||||
|
task_draft_of,
|
||||||
|
task_drafts,
|
||||||
|
team_change_of,
|
||||||
|
team_changes,
|
||||||
|
)
|
||||||
|
from app.domain.workforce.models import RoleKey
|
||||||
|
from app.domain.workforce.pipeline import Mode
|
||||||
|
|
||||||
|
MANAGER_TRIAGE = {"role": RoleKey.MANAGER, "mode": Mode.TRIAGE}
|
||||||
|
MANAGER_CHAT = {"role": RoleKey.MANAGER, "mode": Mode.CHAT}
|
||||||
|
DEVELOPER = {"role": RoleKey.DEVELOPER, "mode": Mode.WORK}
|
||||||
|
PLANNER = {"role": RoleKey.PLANNER, "mode": Mode.WORK}
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# The happy path
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_paired_tag_carries_its_attrs_and_body():
|
||||||
|
reply = parse_reply(
|
||||||
|
'سلام [[KNOWLEDGE kind="rule" key="style"]]همیشه از فارسی ساده استفاده کن.[[/KNOWLEDGE]]',
|
||||||
|
**MANAGER_TRIAGE,
|
||||||
|
)
|
||||||
|
assert reply.errors == ()
|
||||||
|
assert reply.prose == "سلام"
|
||||||
|
(tag,) = reply.of(TagName.KNOWLEDGE)
|
||||||
|
assert tag.attr("kind") == "rule"
|
||||||
|
assert tag.body == "همیشه از فارسی ساده استفاده کن."
|
||||||
|
items = knowledge_items(reply)
|
||||||
|
assert items[0].kind == "rule"
|
||||||
|
assert items[0].content == "همیشه از فارسی ساده استفاده کن."
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_self_closing_tag_has_an_empty_body():
|
||||||
|
reply = parse_reply('[[ASSIGN employee="سارا" pipeline="plan_code_test"/]]', **MANAGER_TRIAGE)
|
||||||
|
assert reply.errors == ()
|
||||||
|
(tag,) = reply.tags
|
||||||
|
assert tag.name is TagName.ASSIGN
|
||||||
|
assert tag.body == ""
|
||||||
|
assign = assign_of(tag)
|
||||||
|
assert assign.employee == "سارا"
|
||||||
|
assert assign.pipeline == "plan_code_test"
|
||||||
|
|
||||||
|
|
||||||
|
def test_whitespace_and_case_are_tolerated():
|
||||||
|
reply = parse_reply(
|
||||||
|
'[[ assign employee = "سارا" / ]]',
|
||||||
|
**MANAGER_TRIAGE,
|
||||||
|
)
|
||||||
|
# Attribute values are read even with loose spacing...
|
||||||
|
assert reply.errors == ()
|
||||||
|
assert assign_of(reply.first(TagName.ASSIGN)).employee == "سارا"
|
||||||
|
|
||||||
|
|
||||||
|
def test_nested_brackets_in_a_body_stay_literal():
|
||||||
|
"""A file's content is not re-parsed as tags: the outer tag wins."""
|
||||||
|
body = 'def render():\n return "[[NOT_A_TAG]] and [[/NOT_A_TAG]]"'
|
||||||
|
reply = parse_reply(
|
||||||
|
f'[[WRITE_FILE path="app/render.ts"]]{body}[[/WRITE_FILE]]',
|
||||||
|
**DEVELOPER,
|
||||||
|
)
|
||||||
|
assert reply.errors == ()
|
||||||
|
(tag,) = reply.tags
|
||||||
|
assert tag.name is TagName.WRITE_FILE
|
||||||
|
assert tag.body == body
|
||||||
|
|
||||||
|
|
||||||
|
def test_several_tags_keep_their_order():
|
||||||
|
reply = parse_reply(
|
||||||
|
'[[READ_FILE path="a.py"]][[LIST_DIR path="."]][[RUN cmd="pytest" timeout="60"/]]',
|
||||||
|
**DEVELOPER,
|
||||||
|
)
|
||||||
|
assert reply.errors == ()
|
||||||
|
assert reply.names() == (TagName.READ_FILE, TagName.LIST_DIR, TagName.RUN)
|
||||||
|
assert reply.of(TagName.RUN)[0].attr("cmd") == "pytest"
|
||||||
|
assert reply.of(TagName.RUN)[0].attr("timeout") == "60"
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Rejections, all of which the model hears about
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_unknown_tags_are_ignored_and_reported():
|
||||||
|
reply = parse_reply('[[HACK theme="green"]] payload [[/HACK]]done', **DEVELOPER)
|
||||||
|
assert reply.tags == ()
|
||||||
|
assert reply.errors == ("ERROR: unknown tag [[HACK]] ignored",)
|
||||||
|
# The unknown pair's body does not leak into the prose either.
|
||||||
|
assert reply.prose == "done"
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_tag_outside_the_role_matrix_is_refused():
|
||||||
|
"""A manager cannot write files, and a developer cannot assign work."""
|
||||||
|
reply = parse_reply('[[WRITE_FILE path="x"]]hello[[/WRITE_FILE]]', **MANAGER_TRIAGE)
|
||||||
|
assert reply.tags == ()
|
||||||
|
assert reply.errors == (
|
||||||
|
"ERROR: tag [[WRITE_FILE]] is not allowed for role=manager mode=triage",
|
||||||
|
)
|
||||||
|
|
||||||
|
reply = parse_reply('[[ASSIGN employee="سارا"/]]', **DEVELOPER)
|
||||||
|
assert reply.tags == ()
|
||||||
|
assert reply.errors == (
|
||||||
|
"ERROR: tag [[ASSIGN]] is not allowed for role=developer mode=work",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_stray_closing_tag_is_reported_once():
|
||||||
|
reply = parse_reply("[[/WORK_REPORT]]", **DEVELOPER)
|
||||||
|
assert reply.tags == ()
|
||||||
|
assert reply.errors == ("ERROR: stray closing tag [[/WORK_REPORT]] ignored",)
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_unclosed_tag_is_ignored_not_half_parsed():
|
||||||
|
reply = parse_reply("[[WORK_REPORT]] کار انجام شد بدون بستن", **DEVELOPER)
|
||||||
|
assert reply.tags == ()
|
||||||
|
assert any("unclosed tag [[WORK_REPORT]]" in e for e in reply.errors)
|
||||||
|
|
||||||
|
|
||||||
|
def test_malformed_attributes_are_reported_and_the_tag_keeps_going():
|
||||||
|
reply = parse_reply("[[ASSIGN employee سارا/]]", **MANAGER_TRIAGE)
|
||||||
|
assert any("malformed attribute fragment 'employee سارا'" in e for e in reply.errors)
|
||||||
|
# The tag itself is kept: the typed reader is what refuses it.
|
||||||
|
(tag,) = reply.tags
|
||||||
|
assert tag.name is TagName.ASSIGN
|
||||||
|
|
||||||
|
|
||||||
|
def test_an_empty_matrix_cell_refuses_everything():
|
||||||
|
"""A role/mode with no row gets no tags at all (deny by default)."""
|
||||||
|
reply = parse_reply("[[KNOWLEDGE]]x[[/KNOWLEDGE]]", role=RoleKey.RESEARCHER, mode=Mode.WORK)
|
||||||
|
assert reply.tags == ()
|
||||||
|
assert "not allowed" in reply.errors[0]
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# The role/mode matrix
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("role", "mode", "expected"),
|
||||||
|
[
|
||||||
|
(RoleKey.MANAGER, Mode.TRIAGE, {"ASSIGN", "REJECT", "ASK_USER", "KNOWLEDGE"}),
|
||||||
|
(RoleKey.MANAGER, Mode.FINAL, {"COMPLETE", "REVISE", "KNOWLEDGE"}),
|
||||||
|
(RoleKey.MANAGER, Mode.ERROR, {"RESOLVE_ERROR", "CANCEL", "KNOWLEDGE"}),
|
||||||
|
(
|
||||||
|
RoleKey.MANAGER,
|
||||||
|
Mode.CHAT,
|
||||||
|
{
|
||||||
|
"CREATE_TASK",
|
||||||
|
"PAUSE_TASK",
|
||||||
|
"RESUME_TASK",
|
||||||
|
"CANCEL_TASK",
|
||||||
|
"HIRE",
|
||||||
|
"UPDATE_EMPLOYEE",
|
||||||
|
"FIRE",
|
||||||
|
"KNOWLEDGE",
|
||||||
|
},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
RoleKey.PLANNER,
|
||||||
|
Mode.WORK,
|
||||||
|
{"PLAN_SPEC", "SPLIT", "ASK_USER"}
|
||||||
|
| {"READ_FILE", "WRITE_FILE", "EDIT_FILE", "LIST_DIR", "SEARCH", "RUN"},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
RoleKey.DEVELOPER,
|
||||||
|
Mode.WORK,
|
||||||
|
{"WORK_REPORT", "ASK_USER"}
|
||||||
|
| {"READ_FILE", "WRITE_FILE", "EDIT_FILE", "LIST_DIR", "SEARCH", "RUN"},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
RoleKey.REVIEWER,
|
||||||
|
Mode.WORK,
|
||||||
|
{"REVIEW_PASS", "REVIEW_CHANGES"}
|
||||||
|
| {"READ_FILE", "WRITE_FILE", "EDIT_FILE", "LIST_DIR", "SEARCH", "RUN"},
|
||||||
|
),
|
||||||
|
(
|
||||||
|
RoleKey.TESTER,
|
||||||
|
Mode.WORK,
|
||||||
|
{"TEST_PASS", "TEST_FAIL"}
|
||||||
|
| {"READ_FILE", "WRITE_FILE", "EDIT_FILE", "LIST_DIR", "SEARCH", "RUN"},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_the_matrix_grants_exactly_the_planned_tags(role, mode, expected):
|
||||||
|
granted = {tag.value for tag in ALLOWED_TAGS[(role, mode)]}
|
||||||
|
assert granted == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_the_toolkit_is_available_to_working_roles_only():
|
||||||
|
toolkit = {"READ_FILE", "WRITE_FILE", "EDIT_FILE", "LIST_DIR", "SEARCH", "RUN"}
|
||||||
|
for (role, mode), granted in ALLOWED_TAGS.items():
|
||||||
|
has_tools = bool({t.value for t in granted} & toolkit)
|
||||||
|
if role is RoleKey.MANAGER:
|
||||||
|
assert has_tools is False, (role, mode)
|
||||||
|
else:
|
||||||
|
assert has_tools is True, (role, mode)
|
||||||
|
|
||||||
|
|
||||||
|
def test_manager_modes_have_distinct_tag_sets():
|
||||||
|
"""The same employee must wear different hats in different modes."""
|
||||||
|
triage = {t.value for t in ALLOWED_TAGS[(RoleKey.MANAGER, Mode.TRIAGE)]}
|
||||||
|
final = {t.value for t in ALLOWED_TAGS[(RoleKey.MANAGER, Mode.FINAL)]}
|
||||||
|
chat = {t.value for t in ALLOWED_TAGS[(RoleKey.MANAGER, Mode.CHAT)]}
|
||||||
|
assert triage != final != chat
|
||||||
|
assert "COMPLETE" in final and "COMPLETE" not in triage
|
||||||
|
assert "CREATE_TASK" in chat and "CREATE_TASK" not in triage
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Typed readers
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_assign_requires_an_employee_or_a_task():
|
||||||
|
tag = parse_reply("[[ASSIGN/]]", **MANAGER_TRIAGE).first(TagName.ASSIGN)
|
||||||
|
with pytest.raises(ProtocolError):
|
||||||
|
assign_of(tag)
|
||||||
|
|
||||||
|
|
||||||
|
def test_assign_refuses_an_unknown_pipeline():
|
||||||
|
reply = parse_reply('[[ASSIGN employee="سارا" pipeline="plan_ship_test"/]]', **MANAGER_TRIAGE)
|
||||||
|
with pytest.raises(ProtocolError):
|
||||||
|
assign_of(reply.first(TagName.ASSIGN))
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_task_reads_dependencies_and_kind():
|
||||||
|
reply = parse_reply(
|
||||||
|
'[[CREATE_TASK title="نصب فرم ورود" kind="feature" priority="high" depends_on="t1,t2"]]'
|
||||||
|
"توضیحات کار[[/CREATE_TASK]]",
|
||||||
|
**MANAGER_CHAT,
|
||||||
|
)
|
||||||
|
(draft,) = task_drafts(reply)
|
||||||
|
assert draft.title == "نصب فرم ورود"
|
||||||
|
assert draft.kind == "feature"
|
||||||
|
assert draft.priority == "high"
|
||||||
|
assert draft.depends_on == ("t1", "t2")
|
||||||
|
assert draft.description == "توضیحات کار"
|
||||||
|
|
||||||
|
|
||||||
|
def test_create_task_without_a_title_is_refused():
|
||||||
|
tag = parse_reply(
|
||||||
|
"[[CREATE_TASK kind=\"feature\"]]x[[/CREATE_TASK]]", **MANAGER_CHAT
|
||||||
|
).first(TagName.CREATE_TASK)
|
||||||
|
with pytest.raises(ProtocolError):
|
||||||
|
task_draft_of(tag)
|
||||||
|
|
||||||
|
|
||||||
|
def test_team_changes_keep_their_order_across_the_three_tags():
|
||||||
|
reply = parse_reply(
|
||||||
|
'[[HIRE role="developer" tier="strong" name="سارا"]]'
|
||||||
|
"[[UPDATE_EMPLOYEE employee_id=\"e1\" tier=\"fast\"]]"
|
||||||
|
'[[FIRE employee_id="e2"]]',
|
||||||
|
**MANAGER_CHAT,
|
||||||
|
)
|
||||||
|
changes = team_changes(reply)
|
||||||
|
assert [c.action for c in changes] == [
|
||||||
|
TagName.HIRE,
|
||||||
|
TagName.UPDATE_EMPLOYEE,
|
||||||
|
TagName.FIRE,
|
||||||
|
]
|
||||||
|
assert changes[0].name == "سارا"
|
||||||
|
assert changes[0].tier == "strong"
|
||||||
|
# A model name is never part of the protocol: users pick role + tier.
|
||||||
|
for tag in reply.tags:
|
||||||
|
assert not any("model" in key for key in tag.attrs), tag
|
||||||
|
|
||||||
|
|
||||||
|
def test_knowledge_defaults_to_a_rule_and_reads_its_key():
|
||||||
|
reply = parse_reply(
|
||||||
|
'[[KNOWLEDGE key="deploy"]]قبل از پوش تست بگیر.[[/KNOWLEDGE]]', **MANAGER_CHAT
|
||||||
|
)
|
||||||
|
(item,) = knowledge_items(reply)
|
||||||
|
assert item.kind == "rule"
|
||||||
|
assert item.key == "deploy"
|
||||||
|
assert item.content == "قبل از پوش تست بگیر."
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_file_body_splits_into_search_replace_blocks():
|
||||||
|
body = (
|
||||||
|
"before\n"
|
||||||
|
"<<<OLD\n"
|
||||||
|
"old line\n"
|
||||||
|
"===\n"
|
||||||
|
"new line\n"
|
||||||
|
"NEW>>>\n"
|
||||||
|
"after\n"
|
||||||
|
"<<<OLD\n"
|
||||||
|
"second old\n"
|
||||||
|
"===\n"
|
||||||
|
"second new\n"
|
||||||
|
"NEW>>>"
|
||||||
|
)
|
||||||
|
reply = parse_reply(f'[[EDIT_FILE path="app/x.py"]]{body}[[/EDIT_FILE]]', **DEVELOPER)
|
||||||
|
(tag,) = reply.of(TagName.EDIT_FILE)
|
||||||
|
blocks = edit_blocks_of(tag)
|
||||||
|
assert [b.old for b in blocks] == ["old line", "second old"]
|
||||||
|
assert [b.new for b in blocks] == ["new line", "second new"]
|
||||||
|
|
||||||
|
|
||||||
|
def test_edit_file_without_blocks_is_refused():
|
||||||
|
reply = parse_reply(
|
||||||
|
"[[EDIT_FILE path=\"a\"]]just text[[/EDIT_FILE]]", **DEVELOPER
|
||||||
|
)
|
||||||
|
with pytest.raises(ProtocolError):
|
||||||
|
edit_blocks_of(reply.first(TagName.EDIT_FILE))
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Limits
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_at_most_three_tasks_per_reply():
|
||||||
|
tasks = "".join(
|
||||||
|
f'[[CREATE_TASK title="کار {i}"]]x[[/CREATE_TASK]]' for i in range(3)
|
||||||
|
)
|
||||||
|
reply = parse_reply(tasks, **MANAGER_CHAT)
|
||||||
|
assert len(task_drafts(reply)) == MAX_CREATE_TASKS_PER_REPLY
|
||||||
|
assert reply.errors == ()
|
||||||
|
|
||||||
|
|
||||||
|
def test_a_fourth_task_is_dropped_and_reported():
|
||||||
|
tasks = "".join(
|
||||||
|
f'[[CREATE_TASK title="کار {i}"]]x[[/CREATE_TASK]]' for i in range(4)
|
||||||
|
)
|
||||||
|
reply = parse_reply(tasks, **MANAGER_CHAT)
|
||||||
|
assert len(task_drafts(reply)) == 3
|
||||||
|
assert len(reply.errors) == 1
|
||||||
|
assert "too many [[CREATE_TASK]]" in reply.errors[0]
|
||||||
|
assert f"max {MAX_CREATE_TASKS_PER_REPLY}" in reply.errors[0]
|
||||||
|
|
||||||
|
|
||||||
|
def test_three_team_changes_per_turn_across_all_three_tags():
|
||||||
|
reply = parse_reply(
|
||||||
|
'[[HIRE role="developer" name="الف"]]'
|
||||||
|
'[[HIRE role="tester" name="ب"]]'
|
||||||
|
'[[HIRE role="reviewer" name="ج"]]'
|
||||||
|
'[[FIRE employee_id="e9"]]',
|
||||||
|
**MANAGER_CHAT,
|
||||||
|
)
|
||||||
|
assert len(team_changes(reply)) == MAX_TEAM_CHANGES_PER_TURN
|
||||||
|
assert any("too many [[FIRE]]" in e for e in reply.errors)
|
||||||
|
|
||||||
|
|
||||||
|
def test_three_knowledge_items_per_turn():
|
||||||
|
items = "".join(f'[[KNOWLEDGE key="k{i}"]]متن {i}[[/KNOWLEDGE]]' for i in range(4))
|
||||||
|
reply = parse_reply(items, **MANAGER_CHAT)
|
||||||
|
assert len(knowledge_items(reply)) == MAX_KNOWLEDGE_PER_TURN
|
||||||
|
assert any("too many [[KNOWLEDGE]]" in e for e in reply.errors)
|
||||||
|
|
||||||
|
|
||||||
|
def test_limits_do_not_cross_feed_each_other():
|
||||||
|
"""Team changes and knowledge are counted separately from tasks."""
|
||||||
|
reply = parse_reply(
|
||||||
|
'[[CREATE_TASK title="a"]]x[[/CREATE_TASK]]'
|
||||||
|
'[[CREATE_TASK title="b"]]x[[/CREATE_TASK]]'
|
||||||
|
'[[HIRE role="developer" name="سارا"]]'
|
||||||
|
'[[KNOWLEDGE key="k"]]متن[[/KNOWLEDGE]]',
|
||||||
|
**MANAGER_CHAT,
|
||||||
|
)
|
||||||
|
assert len(task_drafts(reply)) == 2
|
||||||
|
assert len(team_changes(reply)) == 1
|
||||||
|
assert len(knowledge_items(reply)) == 1
|
||||||
|
assert reply.errors == ()
|
||||||
|
|
||||||
|
|
||||||
|
def test_typed_readers_work_on_one_tag_at_a_time():
|
||||||
|
"""The runner reads single tags too, not only the whole reply."""
|
||||||
|
reply = parse_reply(
|
||||||
|
'[[HIRE role="developer" tier="balanced" name="سارا"/]]', **MANAGER_CHAT
|
||||||
|
)
|
||||||
|
change = team_change_of(reply.first(TagName.HIRE))
|
||||||
|
assert change.action is TagName.HIRE
|
||||||
|
assert change.role == "developer"
|
||||||
|
|
||||||
|
reply = parse_reply(
|
||||||
|
'[[KNOWLEDGE kind="fact" key="port"]]پورت ۸۰۰۰ است.[[/KNOWLEDGE]]', **MANAGER_CHAT
|
||||||
|
)
|
||||||
|
item = knowledge_of(reply.first(TagName.KNOWLEDGE))
|
||||||
|
assert item.kind == "fact"
|
||||||
|
assert item.content == "پورت ۸۰۰۰ است."
|
||||||
@@ -0,0 +1,142 @@
|
|||||||
|
# Agent 2 — Phase 1 report: engine core (protocol, state machine, pipelines)
|
||||||
|
|
||||||
|
**Date:** 2026-10-14 · **Branch:** `dev` (this repo's integration branch) · **Status:** done, all checks green
|
||||||
|
|
||||||
|
> Environment note: the TODO file assumes the ext4 worktree (`/home/mamad/work/smart-org-wt/agent2`,
|
||||||
|
> branch `agent2/ai-workforce`). This checkout is the `dev` repo itself and already contains the
|
||||||
|
> other agents' work (org wizard, invites, delivery infra), so the engine is being implemented here
|
||||||
|
> directly on `dev` at the user's instruction. No locked file was touched; no `CHANGE REQUEST` is
|
||||||
|
> needed so far.
|
||||||
|
|
||||||
|
## What was done
|
||||||
|
|
||||||
|
### 1.1 `app/domain/workforce/pipeline.py` (new, pure)
|
||||||
|
|
||||||
|
- `Stage` (13 values: the eight pipeline stages plus the side states `pending_user_decision`,
|
||||||
|
`error_review`, `paused`, `cancelled`, `failed`).
|
||||||
|
- `PIPELINES`: `plan_code_test` (default), `plan_code_review_test`, `code_test`, `analysis_only`
|
||||||
|
(no worktree, no merge). Each is the ordered tuple of stages ending in `completed`.
|
||||||
|
- `TRANSITIONS` — every legal stage move, with the reasoning in comments: a paused or parked task
|
||||||
|
resumes **through triage** (no `paused_from` column exists, and re-triage is the safe route);
|
||||||
|
`merge_request` is platform-executed and nobody acts in it; terminal stages never move.
|
||||||
|
- `next_stage(pipeline, current, outcome)` with `Outcome` (`ok`, `revise`, `reject`, `ask_user`,
|
||||||
|
`error`, `resolve`). `ok` walks the pipeline list one step; the others have fixed targets —
|
||||||
|
work sent back always returns to the developer.
|
||||||
|
- `STAGE_ROLE` → `(role, mode)` or `None` for stages with no AI turn. Manager modes are
|
||||||
|
`triage` / `final` / `error` (plus `chat`), every other role is `work`.
|
||||||
|
- `resolve_pipeline(manager_choice, task_kind, app_default, has_active_reviewer)`: priority
|
||||||
|
manager > kind > app default > `plan_code_test`; a review pipeline without an active reviewer
|
||||||
|
falls back to `plan_code_test`.
|
||||||
|
- `TASK_KIND_DEFAULTS`: feature → `plan_code_test`/medium, bugfix → `code_test`/low,
|
||||||
|
refactor → `plan_code_review_test`/high, docs → `code_test`/low, analysis → `analysis_only`/low.
|
||||||
|
- `STAGE_STATUS`: stage → coarse `tasks.status` (`new`/`running`/`waiting_approval`/`done`/`failed`/
|
||||||
|
`cancelled`) so existing APIs and the old worker keep working against one truthful column.
|
||||||
|
- `TaskPipelineState` dataclass: pipeline, kind, risk, stage, cycle/max_cycles, branch,
|
||||||
|
`last_diff_hash`/`same_diff_count`, `retry_count`, `waiver_reason`, parent/depends, `version`
|
||||||
|
(optimistic lock).
|
||||||
|
|
||||||
|
### 1.2 `app/application/engine/protocol.py` (new)
|
||||||
|
|
||||||
|
- Parser for `[[TAG attr="v" ...]]body[[/TAG]]` and `[[TAG attr="v"/]]`, tolerant of whitespace,
|
||||||
|
single- or double-quoted values.
|
||||||
|
- A body is **literal**: an outer tag wins and the scanner moves past its close, so brackets
|
||||||
|
inside a body (a source file full of `[[`) are never re-parsed.
|
||||||
|
- `TagName` enum for all 28 tags; `ALLOWED_TAGS` is the per-role/mode matrix from the TODO
|
||||||
|
(manager triage/final/error/chat, planner, developer, reviewer, tester) plus the six tool tags
|
||||||
|
granted to working roles only — the manager never edits code.
|
||||||
|
- Unknown tags, disallowed tags, stray closings, unclosed tags and malformed attribute fragments
|
||||||
|
are dropped and reported as one error line each, in order, for the model to read next turn.
|
||||||
|
- `BODY_TAGS`: only tags that carry a body must be closed; a forgotten `/` on a bodyless tag
|
||||||
|
(`[[RUN cmd="pytest"]]`) is read as self-closing instead of failing the reply.
|
||||||
|
- Limits: max 3 `CREATE_TASK`, **max 3 team changes across HIRE+UPDATE_EMPLOYEE+FIRE together**,
|
||||||
|
max 3 `KNOWLEDGE` per reply. Extras are dropped and reported.
|
||||||
|
- Typed readers: `AssignTag`, `TaskDraft`, `TeamChange`, `KnowledgeItem`, `EditBlock`
|
||||||
|
(`<<<OLD … === … NEW>>>`), plus `edit_blocks_of` which refuses a malformed block rather than
|
||||||
|
half-applying it. `ASSIGN` refuses an unknown pipeline name.
|
||||||
|
|
||||||
|
### 1.3 `app/infrastructure/db/models_engine.py` (stub filled) + migration `a2b3c4d5e6f2`
|
||||||
|
|
||||||
|
Four tables, all 1:1/side tables — no column was added to the locked `tasks` / `employees`:
|
||||||
|
|
||||||
|
- `task_pipeline_state` (PK `task_id`), with the denormalised `organization_id` so the scheduler's
|
||||||
|
hot query stays single-table.
|
||||||
|
- `employee_profiles` (PK `employee_id`): `ai_model_id`, `effort`, roles CSV, `hired_by`,
|
||||||
|
`busy_task_id`.
|
||||||
|
- `engine_turns`: id, task/run/employee, role, mode, stage, `prompt_chars`, `reply_excerpt`
|
||||||
|
(Text, bounded on write), tags JSON, tool_calls, ok, error_kind, started/finished.
|
||||||
|
- `gate_results`: id, task, gate, ok, detail (bounded), created.
|
||||||
|
|
||||||
|
Migration `alembic/versions/a2b3c4d5e6f2_ai_workforce.py`:
|
||||||
|
`alembic upgrade head` applies cleanly and `alembic check` reports **no drift** between the models
|
||||||
|
and the migration chain.
|
||||||
|
|
||||||
|
**Deviation (documented in the migration docstring):** the TODO pins
|
||||||
|
`down_revision = "a0f0e1d2c3b4"`, which was right while each agent had its own branch. On the
|
||||||
|
merged `dev` chain the heads already form a line (`a3b4c5d6e7f4` → `a1b2c3d4e5f1` →
|
||||||
|
`d5e6f7a8b9c0`), so this migration chains after `d5e6f7a8b9c0`. Result: **one linear head**
|
||||||
|
(`a2b3c4d5e6f2`), no merge revision needed.
|
||||||
|
|
||||||
|
### 1.4 `app/application/engine/state.py` (new)
|
||||||
|
|
||||||
|
- `advance(states, task_id, expected_from, to)`: checks legality in the domain first, then the
|
||||||
|
repository's compare-and-set (`WHERE task_id AND stage AND version`) performs the move. A lost
|
||||||
|
race raises `ConflictError` ("این کار هماکنون توسط موتور دیگری در حرکت است"), an unknown task
|
||||||
|
raises `NotFoundError`.
|
||||||
|
- `park_dependents(states, task_id)`: a failed or cancelled dependency moves every dependent to
|
||||||
|
`pending_user_decision` (agybot bug M1). The function **refuses to act on a healthy task** — the
|
||||||
|
guard is in the function, not in the caller — and leaves dependents that already wait on a human
|
||||||
|
alone. A lost race on a dependent is skipped, not fatal.
|
||||||
|
- `app/infrastructure/repositories/engine.py` (new): `PipelineStateRepository` (advance/patch/
|
||||||
|
list_at_stages/list_dependents), `EmployeeProfileRepository`, `EngineTurnRepository`,
|
||||||
|
`GateResultRepository`. `advance`/`patch` verify the version actually moved rather than trusting
|
||||||
|
`rowcount`, so the check holds on SQLite and PostgreSQL alike.
|
||||||
|
|
||||||
|
### 1.5 Tests
|
||||||
|
|
||||||
|
- `tests/test_engine_pipeline.py` (24 tests): all four pipelines walk manager review → completed
|
||||||
|
step by step, each step also legal; sent-back work returns to the developer; illegal transitions
|
||||||
|
refused; the stage/role/mode map; stage → coarse status; pipeline resolution priority and the
|
||||||
|
reviewer fallback; plus DB-backed tests for the version bump, the illegal move, **exactly one
|
||||||
|
winner in a concurrent advance**, the 404, the dependency-parking rule (and the healthy-task
|
||||||
|
guard), optimistic `patch`, cascade deletes and the employee profile round trip.
|
||||||
|
- `tests/test_engine_protocol.py` (34 tests): paired/self-closing parsing, whitespace tolerance,
|
||||||
|
nested brackets staying literal, tag order, unknown/disallowed/stray/unclosed/malformed
|
||||||
|
rejections, the full per-role matrix, toolkit-for-working-roles-only, typed readers
|
||||||
|
(assign/task draft/team changes/knowledge/edit blocks) and all three limits.
|
||||||
|
|
||||||
|
## Checks (PLAN-v2 §7.4)
|
||||||
|
|
||||||
|
```
|
||||||
|
cd apps/api && .venv/bin/python -m pytest -q → 594 passed, 1 skipped (whole suite; 58 new)
|
||||||
|
cd apps/api && .venv/bin/ruff check . → All checks passed!
|
||||||
|
cd apps/api && .venv/bin/mypy app → Success: no issues found in 144 source files
|
||||||
|
alembic upgrade head (scratch sqlite) → applies through a2b3c4d5e6f2, single head
|
||||||
|
alembic check (scratch sqlite) → No new upgrade operations detected.
|
||||||
|
```
|
||||||
|
|
||||||
|
The web suite was not touched (no UI in Phase 1).
|
||||||
|
|
||||||
|
## Deviations & notes for the reviewer
|
||||||
|
|
||||||
|
1. Implemented on `dev` instead of an `agent2/ai-workforce` worktree (user instruction; the
|
||||||
|
worktree path in the TODO is a Linux path unavailable here).
|
||||||
|
2. Migration `down_revision` chained to the merged head (above) to keep one linear chain.
|
||||||
|
3. `task_pipeline_state.organization_id` added beyond the TODO's column list: it is denormalised
|
||||||
|
from the task so the scheduler never joins back, and it keeps the tenant filter single-table.
|
||||||
|
4. `resolve_pipeline` applies the no-reviewer fallback to an explicit manager choice too. A review
|
||||||
|
stage nobody can act on is a dead end; the manager who wants a reviewer should hire one.
|
||||||
|
5. Team-change limit counts HIRE + UPDATE_EMPLOYEE + FIRE **together** (one budget per turn), which
|
||||||
|
is what "max 3 team changes per turn" means.
|
||||||
|
6. New repository module `app/infrastructure/repositories/engine.py` (the engine columns live
|
||||||
|
beside `models_engine.py`); `repositories/workforce.py` was left untouched.
|
||||||
|
|
||||||
|
## Open questions / CHANGE REQUESTs
|
||||||
|
|
||||||
|
- None for Phase 1. Expected for later phases: org automation policy behind `ModelGrantPort`
|
||||||
|
(TODO 4.1 says to assume enabled until then) and the worker hook for memory expiry.
|
||||||
|
|
||||||
|
## Next
|
||||||
|
|
||||||
|
Phase 2 — `engine/prompt.py`, `engine/tools.py` (sandbox execution, git refusal, path safety),
|
||||||
|
`engine/runner.py` (`run_turn` with the scripted fake model, error classification, usage events)
|
||||||
|
and `tests/test_engine_runner.py` + `tests/engine_fakes.py`.
|
||||||
Reference in New Issue
Block a user