feat(engine): Phase 4 — scheduler tick, gate chain, platform git, budget

The AI workforce engine's driver. One tick moves every ready task one step:
a turn, a gate, or a platform merge. The AI never runs git.

- scheduler.py: tick() over TURN_STAGES/MERGE_STAGES; selection caps
  (per-org 3, global 8); optimistic advance so two workers cannot both win;
  worktree created for code pipelines, released on completion/cancel/failure
  (kept during error_review); budget pause before any turn is spent; merge
  conflict back-off; transient-failure retries then the manager's error turn.
- gates.py: diff guard (empty/.git/CI/symlink/oversize), no-progress hash,
  tests in the sandbox, green gate that only trusts a green tests row or an
  explicit waiver. Every verdict is a gate_results row + a task.gate event.
  Fixed forbidden_paths: lstrip("./") stripped all leading dots, hiding
  .git and .github from the guard.
- container_engine.build_scheduler + worker drives the engine tick.
- ManagerOutcome.error_kind; TaskSpendRepository.spend_for_task.
- New settings engine_max_concurrent_turns, engine_max_turns_per_org,
  engine_tests_timeout_seconds, engine_max_merge_conflicts,
  engine_max_task_credits (+ .env.example).
- tests: test_engine_gates.py (13), test_engine_flow.py (11).

Checks: pytest 746 passed/1 skipped, ruff clean, mypy clean (160 files).
This commit is contained in:
mjalmoudy94
2026-10-14 19:02:58 +03:30
parent 581f703910
commit 36564e3f2b
13 changed files with 2563 additions and 67 deletions
@@ -34,6 +34,7 @@ from app.domain.workforce.pipeline import (
TaskKind,
TaskPipelineState,
)
from app.infrastructure.db.models import RunRow, UsageEventRow
from app.infrastructure.db.models_engine import (
EmployeeProfileRow,
EngineTurnRow,
@@ -485,3 +486,22 @@ class GateResultRepository:
)
row = (await self.session.execute(stmt)).scalar_one_or_none()
return _gate(row) if row else None
class TaskSpendRepository:
"""Per-task spend, read from the usage ledger (never written here).
The scheduler's budget guard needs "how much has this task cost so far",
which the ledger can answer by joining its events to the task's runs. The
ledger stays the source of truth; this only reads it.
"""
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def spend_for_task(self, task_id: str) -> float:
run_ids = select(RunRow.id).where(RunRow.task_id == task_id)
stmt = select(func.coalesce(func.sum(UsageEventRow.total_price), 0.0)).where(
UsageEventRow.run_id.in_(run_ids)
)
return float((await self.session.execute(stmt)).scalar_one())