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
@@ -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")