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