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).
217 lines
7.4 KiB
Python
217 lines
7.4 KiB
Python
"""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
|