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
+216
View File
@@ -0,0 +1,216 @@
"""The gate chain: what blocks work, and what leaves a record behind.
Every gate is tested through the *real* repository, because a gate that writes
no ``gate_results`` row is a gate nobody can audit. The sandbox is the only
fake: no command ever leaves the test process.
"""
from __future__ import annotations
from collections.abc import AsyncIterator
import pytest_asyncio
from app.application.engine.events import RunFeed
from app.application.engine.gates import (
MAX_FILE_BYTES,
GateRunner,
diff_hash,
forbidden_paths,
)
from app.application.ports import CommandResult
from app.application.ports.repo_workspace import DiffResult
from app.domain.base import new_id
from app.domain.workforce.engine import GateName, GateResult
from app.domain.workforce.models import Task
from app.domain.workforce.pipeline import TaskKind
from app.infrastructure.db.models import OrganizationRow, ProjectRow, TaskRow, UserRow
from app.infrastructure.repositories.app_profile import AppProfileRepository
from app.infrastructure.repositories.engine import GateResultRepository
from tests.engine_fakes import FakeSandbox
@pytest_asyncio.fixture
async def task(session_factory, seeded) -> AsyncIterator[Task]:
async with session_factory() as session:
user = UserRow(email=f"g-{new_id()[:8]}@example.com", password_hash="x")
session.add(user)
await session.flush()
org = OrganizationRow(
name="سازمان", slug=f"g-{user.id[:6]}", owner_user_id=user.id
)
session.add(org)
await session.flush()
row = TaskRow(organization_id=org.id, title="کار دِرِوازه")
session.add(row)
await session.flush()
await session.commit()
yield Task(id=row.id, organization_id=org.id, title=row.title)
@pytest_asyncio.fixture
async def runner(session_factory, task: Task) -> AsyncIterator[GateRunner]:
async with session_factory() as session:
yield GateRunner(
results=GateResultRepository(session),
feed=RunFeed(),
profiles=AppProfileRepository(session),
)
# ---------------------------------------------------------------------------
# pure helpers
# ---------------------------------------------------------------------------
def test_diff_hash_is_stable_and_sensitive_to_content():
assert diff_hash("same") == diff_hash("same")
assert diff_hash("same") != diff_hash("same!")
def test_forbidden_paths_flag_git_internals_and_ci():
changed = [".git/config", ".github/workflows/ci.yml", ".gitlab-ci.yml", "app/main.py"]
assert forbidden_paths(changed) == [
".git/config",
".github/workflows/ci.yml",
".gitlab-ci.yml",
]
def test_forbidden_paths_allow_ordinary_source_files():
assert forbidden_paths(["app/main.py", "README.md", "tests/test_app.py"]) == []
def test_code_task_excludes_analysis():
assert GateRunner.code_task(TaskKind.FEATURE) is True
assert GateRunner.code_task(TaskKind.ANALYSIS) is False
# ---------------------------------------------------------------------------
# diff guard
# ---------------------------------------------------------------------------
async def test_diff_guard_fails_an_empty_code_diff(runner, task, session_factory):
outcome = await runner.diff_guard(task=task, diff=DiffResult(), code_task=True)
assert outcome.ok is False
assert outcome.sends_back is True
async with session_factory() as session:
row = await GateResultRepository(session).latest_for(
task.id, str(GateName.DIFF_GUARD)
)
assert row is not None and row.ok is False
async def test_diff_guard_allows_an_empty_analysis_diff(runner, task):
outcome = await runner.diff_guard(task=task, diff=DiffResult(), code_task=False)
assert outcome.ok is True
async def test_diff_guard_flags_forbidden_paths_symlinks_and_big_files(runner, task):
diff = DiffResult(
changed_files=["app/main.py", ".github/workflows/ci.yml"],
escaping_symlinks=["link-to-outside"],
largest_file_bytes=MAX_FILE_BYTES + 1,
)
outcome = await runner.diff_guard(task=task, diff=diff, code_task=True)
assert outcome.ok is False
assert outcome.sends_back is True
assert ".github" in outcome.detail
# ---------------------------------------------------------------------------
# no progress
# ---------------------------------------------------------------------------
async def test_no_progress_blocks_the_same_diff_twice(runner, task):
first, fingerprint, count = await runner.no_progress(
task=task, previous_hash="", same_count=0, diff_text="--- a\n+++ b\n"
)
assert first.ok is True and count == 1
second, fingerprint2, count2 = await runner.no_progress(
task=task,
previous_hash=fingerprint,
same_count=count,
diff_text="--- a\n+++ b\n",
)
assert second.ok is False and second.sends_back is True
assert fingerprint2 == fingerprint and count2 == 2
async def test_no_progress_accepts_a_new_diff(runner, task):
_, first_hash, _ = await runner.no_progress(
task=task, previous_hash="", same_count=0, diff_text="a"
)
outcome, _, count = await runner.no_progress(
task=task, previous_hash=first_hash, same_count=3, diff_text="b"
)
assert outcome.ok is True and count == 1
# ---------------------------------------------------------------------------
# tests gate
# ---------------------------------------------------------------------------
async def test_tests_gate_is_green_when_no_command_is_defined(runner, task):
outcome = await runner.tests(task=task, sandbox=FakeSandbox(), session_external_id="s")
assert outcome.ok is True
async def test_tests_gate_reports_red_with_the_failure_tail(
runner, task, session_factory
):
async with session_factory() as session:
project = ProjectRow(
id=new_id(),
organization_id=task.organization_id,
name="app",
slug="app",
test_command="pytest -q",
)
session.add(project)
await session.commit()
project_id = project.id
task.project_id = project_id
sandbox = FakeSandbox(
commands={
"pytest": [
CommandResult(exit_code=1, stdout="", stderr="AssertionError: boom")
]
}
)
outcome = await runner.tests(task=task, sandbox=sandbox, session_external_id="s")
assert outcome.ok is False and outcome.sends_back is True
assert "AssertionError" in outcome.detail
# ---------------------------------------------------------------------------
# green gate
# ---------------------------------------------------------------------------
async def test_green_gate_needs_a_passing_tests_row_or_a_waiver(runner, task):
blocked = await runner.green(task=task, waived=False, waiver_reason="")
assert blocked.ok is False and blocked.sends_back is True
waived = await runner.green(task=task, waived=True, waiver_reason="known flake")
assert waived.ok is True
async def test_green_gate_is_green_once_tests_pass(runner, task, session_factory):
async with session_factory() as session:
await GateResultRepository(session).create(
GateResult(
task_id=task.id,
gate=GateName.TESTS,
ok=True,
detail="green",
)
)
await session.commit()
outcome = await runner.green(task=task, waived=False, waiver_reason="")
assert outcome.ok is True