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).
288 lines
11 KiB
Python
288 lines
11 KiB
Python
"""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",
|
|
]
|