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
+287
View File
@@ -0,0 +1,287 @@
"""The gate chain that decides whether finished work may move forward.
A developer's ``WORK_REPORT`` is a claim, not evidence. Four gates check it:
* **diff guard** — the diff is real, is not empty for a code task, and touches
nothing the platform forbids (``.git``, CI configuration, symlinks that point
outside the repo, files over a megabyte).
* **no-progress** — the same diff twice in a row means the agent is spinning;
the task goes to the manager instead of looping forever.
* **tests** — the app profile's test command runs in the sandbox. Red sends the
work back to the developer with the failure tail; green lets it continue.
* **green** — the tester's ``TEST_PASS`` is only believed when the tests gate is
green, or when the tester explicitly *waives* it with a reason. A waiver is
always stored, never inferred (PLAN-v2 §3).
Every verdict is a ``gate_results`` row **and** a ``task.gate`` event, because a
gate that only exists in a log is a gate nobody can audit.
"""
from __future__ import annotations
import hashlib
from dataclasses import dataclass
from app.application.engine.events import RunFeed
from app.application.ports.sandbox_provider import SandboxProviderPort
from app.core.config import settings
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.repositories.app_profile import AppProfileRepository
from app.infrastructure.repositories.engine import GateResultRepository
#: Path prefixes a diff may never touch: git internals and CI configuration.
CI_PATHS: tuple[str, ...] = (".github/", ".gitlab-ci.yml", ".gitea/", ".circleci/")
#: Fail a diff that carries a file larger than this (one megabyte).
MAX_FILE_BYTES = 1_000_000
#: How much of the test output is stored and shown. A traceback is at the end.
TEST_OUTPUT_CHARS = 8_000
def diff_hash(text: str) -> str:
"""A stable fingerprint of a diff, for the no-progress gate."""
return hashlib.sha256(text.encode("utf-8", "replace")).hexdigest()
def forbidden_paths(changed_files: list[str]) -> list[str]:
"""Changed files the diff guard refuses, in the plan's own words."""
bad: list[str] = []
for path in changed_files:
normalised = path.strip()
# Strip a leading "./" *prefix*, not the set {".", "/"}: ``lstrip("./")``
# would turn ".github/ci.yml" into "github/ci.yml" and hide it from the
# guard entirely.
if normalised.startswith("./"):
normalised = normalised[2:]
if normalised.startswith(".git/") or normalised == ".git":
bad.append(path)
continue
if any(normalised == ci.rstrip("/") or normalised.startswith(ci) for ci in CI_PATHS):
bad.append(path)
return bad
@dataclass(slots=True, kw_only=True)
class GateOutcome:
"""One gate verdict, and whether it sends the task back to the developer."""
gate: GateName
ok: bool
detail: str = ""
#: True when the caller must move the task back to implementation.
sends_back: bool = False
class GateRunner:
"""Runs the gates and leaves a row + an event behind for each verdict."""
def __init__(
self,
*,
results: GateResultRepository,
feed: RunFeed,
profiles: AppProfileRepository | None = None,
) -> None:
self.results = results
self.feed = feed
self.profiles = profiles
# ------------------------------------------------------------------ #
async def _record(self, *, task: Task, outcome: GateOutcome) -> GateOutcome:
await self.results.create(
GateResult(
task_id=task.id,
gate=outcome.gate,
ok=outcome.ok,
detail=outcome.detail,
)
)
await self.feed.gate(
task_id=task.id,
organization_id=task.organization_id,
gate=str(outcome.gate),
ok=outcome.ok,
detail=outcome.detail,
)
return outcome
async def record(self, *, task: Task, outcome: GateOutcome) -> GateOutcome:
"""Write a verdict the caller already reasoned about.
The scheduler records merge conflicts through this instead of the
gate-specific helpers, because a conflict is a diff-guard verdict that
comes from git rather than from hashing the diff.
"""
return await self._record(task=task, outcome=outcome)
# -- diff guard --------------------------------------------------------- #
async def diff_guard(self, *, task: Task, diff, code_task: bool) -> GateOutcome:
"""Refuse a diff that is empty, forbidden or unreasonably large."""
problems: list[str] = []
if diff.is_empty and code_task:
problems.append("تفاوت خالی است: هیچ تغییری ثبت نشده.")
forbidden = forbidden_paths(list(diff.changed_files))
if forbidden:
problems.append(
"تغییر در مسیرهای ممنوع: " + ", ".join(sorted(forbidden))
)
if diff.escaping_symlinks:
problems.append(
"پیوندهای نمادین به بیرون مخزن: " + ", ".join(diff.escaping_symlinks)
)
if diff.largest_file_bytes > MAX_FILE_BYTES:
problems.append(
f"فایلی بزرگ‌تر از حد مجاز: {diff.largest_file_bytes} بایت."
)
detail = "\n".join(problems)
return await self._record(
task=task,
outcome=GateOutcome(
gate=GateName.DIFF_GUARD,
ok=not problems,
detail=detail or "تفاوت قابل قبول است.",
sends_back=bool(problems),
),
)
# -- no-progress -------------------------------------------------------- #
async def no_progress(
self, *, task: Task, previous_hash: str, same_count: int, diff_text: str
) -> tuple[GateOutcome, str, int]:
"""Hash the diff; the same hash twice in a row means no progress.
Returns the verdict and the new ``(hash, same_count)`` the caller must
persist, so the decision and the counter live in one place.
"""
current = diff_hash(diff_text)
count = same_count + 1 if current and current == previous_hash else 1
stuck = count >= 2 and bool(current)
outcome = await self._record(
task=task,
outcome=GateOutcome(
gate=GateName.NO_PROGRESS,
ok=not stuck,
detail=(
"دو تفاوت یکسان پشت سر هم: پیشرفتی حاصل نشده."
if stuck
else "پیشرفت تازه‌ای ثبت شد."
),
sends_back=stuck,
),
)
return outcome, current, count
# -- tests -------------------------------------------------------------- #
async def test_command_for(self, task: Task) -> str:
"""The app profile's test command, or ``""`` when there is none."""
if self.profiles is None or not task.project_id:
return ""
profile = await self.profiles.get(
organization_id=task.organization_id, project_id=task.project_id
)
return (profile.test_command or "").strip() if profile else ""
async def tests(
self, *, task: Task, sandbox: SandboxProviderPort, session_external_id: str
) -> GateOutcome:
"""Run the app profile's tests in the sandbox: red or green."""
command = await self.test_command_for(task)
if not command:
return await self._record(
task=task,
outcome=GateOutcome(
gate=GateName.TESTS,
ok=True,
detail="دستور تستی برای این برنامه تعریف نشده است.",
),
)
if not session_external_id:
return await self._record(
task=task,
outcome=GateOutcome(
gate=GateName.TESTS,
ok=False,
detail="نشست اجرا در دسترس نیست؛ تست‌ها اجرا نشدند.",
sends_back=True,
),
)
result = await sandbox.run_command(
session_external_id=session_external_id,
command=command,
timeout_seconds=settings.engine_tests_timeout_seconds,
max_output_bytes=TEST_OUTPUT_CHARS,
)
ok = result.exit_code == 0
output = (result.stdout + ("\n" + result.stderr if result.stderr else "")).strip()
tail = output[-TEST_OUTPUT_CHARS:]
detail = (
f"تست‌ها سبز شدند (کد خروج {result.exit_code})."
if ok
else f"تست‌ها قرمز شدند (کد خروج {result.exit_code}).\n{tail}"
)
return await self._record(
task=task,
outcome=GateOutcome(
gate=GateName.TESTS, ok=ok, detail=detail, sends_back=not ok
),
)
# -- green -------------------------------------------------------------- #
async def green(
self, *, task: Task, waived: bool, waiver_reason: str
) -> GateOutcome:
"""Accept a ``TEST_PASS`` only with a green tests gate, or a waiver."""
passed = await self.results.latest_for(task.id, str(GateName.TESTS))
tests_ok = passed.ok if passed is not None else False
reason = waiver_reason.strip()
if tests_ok:
return await self._record(
task=task,
outcome=GateOutcome(
gate=GateName.GREEN, ok=True, detail="دروازهٔ تست سبز است."
),
)
if waived and reason:
return await self._record(
task=task,
outcome=GateOutcome(
gate=GateName.GREEN,
ok=True,
detail=f"با اعلام صرف‌نظر: {reason}",
),
)
return await self._record(
task=task,
outcome=GateOutcome(
gate=GateName.GREEN,
ok=False,
detail="تست‌ها سبز نیستند و صرف‌نظری با دلیل ثبت نشده است.",
sends_back=True,
),
)
# -- helpers the scheduler uses ----------------------------------------- #
@staticmethod
def code_task(kind: TaskKind | str) -> bool:
"""``analysis`` produces prose, not a diff: an empty diff is fine."""
return TaskKind(kind) is not TaskKind.ANALYSIS
__all__ = [
"CI_PATHS",
"MAX_FILE_BYTES",
"TEST_OUTPUT_CHARS",
"GateOutcome",
"GateRunner",
"diff_hash",
"forbidden_paths",
]