feat(engine): workforce engine core — tag protocol, pipelines, optimistic state

Phase 1 of TODO-agent-2: pure logic plus the tables it writes to.

- domain/workforce/pipeline.py: Stage/PipelineKey/Outcome, the four pipelines,
  TRANSITIONS, next_stage, STAGE_ROLE (role+mode), resolve_pipeline with the
  no-reviewer fallback, task-kind defaults, stage -> coarse tasks.status, and
  the TaskPipelineState record (with the optimistic version).
- application/engine/protocol.py: the [[TAG attr="v"]]body[[/TAG]] parser and
  its self-closing form, the per-role/mode allow-list, literal bodies (a
  bracket inside a body is not a tag), typed readers (ASSIGN/CREATE_TASK/
  team changes/KNOWLEDGE/EDIT_FILE blocks) and the per-reply limits
  (3 tasks, 3 team changes, 3 knowledge items).
- application/engine/state.py: advance() with the optimistic compare-and-set
  and park_dependents() so a failed dependency never deadlocks its children.
- db/models_engine.py + migration a2b3c4d5e6f2: task_pipeline_state,
  employee_profiles, engine_turns, gate_results as side tables (tasks and
  employees stay locked). Linear head; alembic check reports no drift.
- repositories/engine.py + 58 new tests (test_engine_protocol,
  test_engine_pipeline) covering every pipeline's happy path, illegal
  transitions, exactly-one-winner concurrent advance, dependency parking and
  all parser edge cases.

Suite: 594 passed, ruff clean, mypy clean.
This commit is contained in:
mjalmoudy94
2026-10-14 15:35:31 +03:30
parent c93b25741e
commit df7da8c75c
11 changed files with 3211 additions and 0 deletions
@@ -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).
Foreign keys to existing tables are written as plain strings, e.g.
``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 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