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:
@@ -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
|
||||
Reference in New Issue
Block a user