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
+198 -1
View File
@@ -8,9 +8,18 @@ model" note land in the same transaction as the organization.
from __future__ import annotations
from typing import TYPE_CHECKING
from sqlalchemy.ext.asyncio import AsyncSession
from app.application.ports.workforce_bootstrap import WorkforceBootstrapPort
from app.domain.sandbox.models import SessionStatus
from app.domain.workforce.models import Task
if TYPE_CHECKING:
from app.application.engine.manager import ManagerTurns
from app.application.engine.runner import TurnRunner
from app.application.engine.scheduler import Scheduler
class _EngineWorkforceBootstrap:
@@ -70,4 +79,192 @@ def build_workforce_bootstrap(session: AsyncSession) -> WorkforceBootstrapPort:
return _EngineWorkforceBootstrap(session)
__all__ = ["build_workforce_bootstrap"]
# ---------------------------------------------------------------------------
# the scheduler
# ---------------------------------------------------------------------------
class _EngineSandboxSessions:
"""The sandbox session the engine's turns run in.
The engine does not *create* sandboxes: a workspace and a running session
belong to the project and are started through the sandbox flow. The engine
only reuses the active session of the task's project. When there is none it
returns ``""``, and the tests gate treats that as red and sends the work
back — never "merge untested because there was no container".
"""
#: A session in one of these states can still run a command.
ACTIVE = frozenset(
{
SessionStatus.STARTING,
SessionStatus.RUNNING,
SessionStatus.PAUSED,
}
)
def __init__(self, session: AsyncSession) -> None:
self.session = session
async def for_task(self, task: Task) -> str:
from app.infrastructure.repositories.sandbox import (
SandboxSessionRepository,
WorkspaceRepository,
)
if not task.project_id:
return ""
workspaces = await WorkspaceRepository(self.session).list_for_project(
task.project_id
)
sessions = SandboxSessionRepository(self.session)
for workspace in workspaces:
for sandbox_session in await sessions.list_for_workspace(workspace.id):
if (
sandbox_session.status in self.ACTIVE
and sandbox_session.external_session_id
):
return sandbox_session.external_session_id
return ""
def _build_manager(
session: AsyncSession,
*,
runner: TurnRunner,
provider,
grants,
memory,
) -> ManagerTurns:
from app.application.engine.manager import ManagerTurns
from app.infrastructure.repositories.ai_control import (
AiModelRepository,
ModelCredentialRepository,
RateCardRepository,
)
from app.infrastructure.repositories.audit import UsageLedgerRepository
from app.infrastructure.repositories.engine import (
EmployeeProfileRepository,
PipelineStateRepository,
)
from app.infrastructure.repositories.workforce import (
EmployeeRepository,
MessageRepository,
TaskRepository,
ThreadRepository,
)
return ManagerTurns(
provider=provider,
employees=EmployeeRepository(session),
messages=MessageRepository(session),
tasks=TaskRepository(session),
threads=ThreadRepository(session),
models=AiModelRepository(session),
credentials=ModelCredentialRepository(session),
rate_cards=RateCardRepository(session),
ledger=UsageLedgerRepository(session),
grants=grants,
memory=memory,
profiles=EmployeeProfileRepository(session),
states=PipelineStateRepository(session),
runner=runner,
)
def build_scheduler(session: AsyncSession) -> Scheduler:
"""The engine's driver, assembled on the caller's session.
Same rule as the bootstrap: ``container.py`` is frozen, so the engine wires
itself here. Every port the scheduler touches is built from the session or
from the existing composition helpers, so the worker and the API cannot
disagree about which adapter backs a port.
"""
from app.application.container import get_model_provider
from app.application.container_delivery import (
build_delivery_hooks,
build_repo_workspace,
)
from app.application.container_org import build_memory, build_model_grants
from app.application.engine.events import RunFeed
from app.application.engine.gates import GateRunner
from app.application.engine.runner import TurnRunner
from app.application.engine.scheduler import Scheduler
from app.infrastructure.adapters.registry import get_sandbox_provider
from app.infrastructure.repositories.ai_control import (
AiModelRepository,
ModelCredentialRepository,
RateCardRepository,
)
from app.infrastructure.repositories.app_profile import AppProfileRepository
from app.infrastructure.repositories.audit import UsageLedgerRepository
from app.infrastructure.repositories.billing import CreditWalletRepository
from app.infrastructure.repositories.engine import (
EmployeeProfileRepository,
EngineTurnRepository,
GateResultRepository,
PipelineStateRepository,
TaskSpendRepository,
)
from app.infrastructure.repositories.workforce import (
EmployeeRepository,
RunRepository,
RunStepRepository,
TaskRepository,
ToolCallRepository,
)
provider = get_model_provider()
sandbox = get_sandbox_provider()
grants = build_model_grants(session)
memory = build_memory(session)
feed = RunFeed()
runner = TurnRunner(
provider=provider,
models=AiModelRepository(session),
credentials=ModelCredentialRepository(session),
rate_cards=RateCardRepository(session),
ledger=UsageLedgerRepository(session),
grants=grants,
sandbox=sandbox,
memory=memory,
turns=EngineTurnRepository(session),
run_steps=RunStepRepository(session),
tool_calls=ToolCallRepository(session),
runs=RunRepository(session),
states=PipelineStateRepository(session),
feed=feed,
)
manager = _build_manager(
session, runner=runner, provider=provider, grants=grants, memory=memory
)
gate_runner = GateRunner(
results=GateResultRepository(session),
feed=feed,
profiles=AppProfileRepository(session),
)
sandbox_sessions = _EngineSandboxSessions(session)
return Scheduler(
states=PipelineStateRepository(session),
tasks=TaskRepository(session),
employees=EmployeeRepository(session),
profiles=EmployeeProfileRepository(session),
runs=RunRepository(session),
turns=EngineTurnRepository(session),
gates=GateResultRepository(session),
manager=manager,
runner=runner,
gate_runner=gate_runner,
repo_workspace=build_repo_workspace(session),
sandbox=sandbox,
memory=memory,
delivery=build_delivery_hooks(session),
wallets=CreditWalletRepository(session),
spend=TaskSpendRepository(session),
sandbox_session_for=sandbox_sessions.for_task,
)
__all__ = ["build_scheduler", "build_workforce_bootstrap"]