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,667 @@
|
||||
"""End-to-end: the scheduler walks a task from a queued idea to merged code.
|
||||
|
||||
This is the 4.6 flow test. The repositories, the scheduler, the manager and the
|
||||
turn runner are all real; only the model (scripted), the sandbox (scripted) and
|
||||
git (in-memory ``FakeRepoWorkspace``) are fakes. What it defends:
|
||||
|
||||
* a task advances ``analysis → implementation → test → final review → merge``
|
||||
one stage per tick, and the coarse ``tasks.status`` follows the stage
|
||||
* the platform — never the model — runs git: ``commit``/``merge_into``/``push``
|
||||
were called, the commit author carries the platform address, and the model
|
||||
was never asked to run a command
|
||||
* every gate leaves a ``gate_results`` row (diff guard, no-progress, tests,
|
||||
green)
|
||||
* red tests and a forbidden diff send the work *back* with cycle + 1 instead of
|
||||
merging it
|
||||
* ``analysis_only`` never creates a worktree
|
||||
* an empty wallet pauses the task before a single turn is spent
|
||||
* a transient provider failure is retried, then handed to the manager
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import AsyncIterator
|
||||
from dataclasses import dataclass
|
||||
|
||||
import pytest_asyncio
|
||||
|
||||
from app.application.engine.events import RunFeed
|
||||
from app.application.engine.gates import GateRunner
|
||||
from app.application.engine.manager import ManagerTurns
|
||||
from app.application.engine.runner import TurnRunner
|
||||
from app.application.engine.scheduler import BUDGET_PAUSED_MESSAGE, ENGINE_COMMIT_EMAIL, Scheduler
|
||||
from app.application.ports import CommandResult
|
||||
from app.application.ports.delivery_hooks import NullDeliveryHooks
|
||||
from app.application.ports.repo_workspace import DiffResult, FakeRepoWorkspace, MergeOutcome
|
||||
from app.core.errors import ProviderUnavailableError
|
||||
from app.domain.base import new_id
|
||||
from app.domain.workforce.engine import EmployeeProfile
|
||||
from app.domain.workforce.models import Employee, RoleKey, TaskStatus
|
||||
from app.domain.workforce.pipeline import PipelineKey, Stage, TaskKind, TaskPipelineState
|
||||
from app.infrastructure.db.models import (
|
||||
AiModelRow,
|
||||
AiProviderRow,
|
||||
OrganizationRow,
|
||||
ProjectRow,
|
||||
TaskRow,
|
||||
UserRow,
|
||||
)
|
||||
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.engine import (
|
||||
EmployeeProfileRepository,
|
||||
EngineTurnRepository,
|
||||
GateResultRepository,
|
||||
PipelineStateRepository,
|
||||
)
|
||||
from app.infrastructure.repositories.workforce import (
|
||||
EmployeeRepository,
|
||||
MessageRepository,
|
||||
RunRepository,
|
||||
RunStepRepository,
|
||||
TaskRepository,
|
||||
ThreadRepository,
|
||||
ToolCallRepository,
|
||||
)
|
||||
from tests.engine_fakes import (
|
||||
FakeGrants,
|
||||
FakeSandbox,
|
||||
RecordingMemory,
|
||||
ScriptedModel,
|
||||
ScriptedReply,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _World:
|
||||
organization_id: str
|
||||
user_id: str
|
||||
project_id: str
|
||||
strong_id: str
|
||||
|
||||
|
||||
class _EmptyWallet:
|
||||
"""A wallet with no credit, and no way to top it up in a test."""
|
||||
|
||||
balance = 0.0
|
||||
|
||||
|
||||
class _Wallets:
|
||||
def __init__(self, wallet: _EmptyWallet | None) -> None:
|
||||
self._wallet = wallet
|
||||
|
||||
async def get_for_org(self, organization_id: str):
|
||||
return self._wallet
|
||||
|
||||
|
||||
class _EmptyWalletRepo(_Wallets):
|
||||
def __init__(self) -> None:
|
||||
super().__init__(_EmptyWallet())
|
||||
|
||||
|
||||
@pytest_asyncio.fixture
|
||||
async def world(session_factory, seeded) -> AsyncIterator[_World]:
|
||||
async with session_factory() as session:
|
||||
user = UserRow(email=f"flow-{new_id()[:8]}@example.com", password_hash="x")
|
||||
session.add(user)
|
||||
await session.flush()
|
||||
org = OrganizationRow(
|
||||
name="سازمان جریان", slug=f"flow-{user.id[:6]}", owner_user_id=user.id
|
||||
)
|
||||
session.add(org)
|
||||
await session.flush()
|
||||
project = ProjectRow(
|
||||
id=new_id(),
|
||||
organization_id=org.id,
|
||||
name="app",
|
||||
slug="app",
|
||||
test_command="pytest -q",
|
||||
)
|
||||
session.add(project)
|
||||
provider_id = (await session.execute(AiProviderRow.__table__.select().limit(1))).first().id
|
||||
strong = await AiModelRepository(session).create(
|
||||
AiModelRow(
|
||||
provider_id=provider_id,
|
||||
internal_name=f"strong-{new_id()[:4]}",
|
||||
display_name_for_admin="مدل قوی",
|
||||
capabilities=["text", "code", "reasoning"],
|
||||
input_price_per_1m=0.5,
|
||||
output_price_per_1m=3.0,
|
||||
is_active=True,
|
||||
)
|
||||
)
|
||||
await session.commit()
|
||||
yield _World(
|
||||
organization_id=org.id,
|
||||
user_id=user.id,
|
||||
project_id=project.id,
|
||||
strong_id=strong.id,
|
||||
)
|
||||
|
||||
|
||||
async def _employee(session, world: _World, name: str, role: RoleKey) -> Employee:
|
||||
employee = await EmployeeRepository(session).create(
|
||||
Employee(organization_id=world.organization_id, name=name, role_key=role)
|
||||
)
|
||||
await EmployeeProfileRepository(session).upsert(
|
||||
EmployeeProfile(employee_id=employee.id, ai_model_id=world.strong_id, roles=[role.value])
|
||||
)
|
||||
return employee
|
||||
|
||||
|
||||
async def _team(session, world: _World) -> dict[RoleKey, Employee]:
|
||||
return {
|
||||
RoleKey.MANAGER: await _employee(session, world, "مدیر", RoleKey.MANAGER),
|
||||
RoleKey.PLANNER: await _employee(session, world, "برنامهریز", RoleKey.PLANNER),
|
||||
RoleKey.DEVELOPER: await _employee(session, world, "توسعهدهنده", RoleKey.DEVELOPER),
|
||||
RoleKey.REVIEWER: await _employee(session, world, "بازبین", RoleKey.REVIEWER),
|
||||
RoleKey.TESTER: await _employee(session, world, "آزمونگر", RoleKey.TESTER),
|
||||
}
|
||||
|
||||
|
||||
async def _task(session, world: _World, *, stage: Stage, pipeline=PipelineKey.PLAN_CODE_TEST,
|
||||
kind=TaskKind.FEATURE) -> TaskRow:
|
||||
row = TaskRow(
|
||||
organization_id=world.organization_id,
|
||||
title="کار جریان",
|
||||
project_id=world.project_id,
|
||||
created_by_user_id=world.user_id,
|
||||
)
|
||||
session.add(row)
|
||||
await session.flush()
|
||||
await PipelineStateRepository(session).create(
|
||||
TaskPipelineState(
|
||||
task_id=row.id,
|
||||
organization_id=world.organization_id,
|
||||
project_id=world.project_id,
|
||||
pipeline=pipeline,
|
||||
kind=kind,
|
||||
stage=stage,
|
||||
)
|
||||
)
|
||||
await session.flush()
|
||||
return row
|
||||
|
||||
|
||||
def _scheduler(
|
||||
session,
|
||||
world: _World,
|
||||
*,
|
||||
scripted,
|
||||
sandbox: FakeSandbox,
|
||||
workspace: FakeRepoWorkspace,
|
||||
memory: RecordingMemory,
|
||||
delivery,
|
||||
wallets=None,
|
||||
) -> Scheduler:
|
||||
grants = FakeGrants({world.strong_id: "strong"})
|
||||
runner = TurnRunner(
|
||||
provider=scripted,
|
||||
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=RunFeed(),
|
||||
)
|
||||
manager = ManagerTurns(
|
||||
provider=scripted,
|
||||
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,
|
||||
feed=RunFeed(),
|
||||
)
|
||||
gate_runner = GateRunner(
|
||||
results=GateResultRepository(session),
|
||||
feed=RunFeed(),
|
||||
profiles=AppProfileRepository(session),
|
||||
)
|
||||
|
||||
async def _session_for(_task) -> str:
|
||||
return "sess-1"
|
||||
|
||||
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=workspace,
|
||||
sandbox=sandbox,
|
||||
memory=memory,
|
||||
delivery=delivery,
|
||||
wallets=wallets,
|
||||
sandbox_session_for=_session_for,
|
||||
)
|
||||
|
||||
|
||||
async def _gates(session, task_id: str) -> dict[str, bool]:
|
||||
rows = await GateResultRepository(session).list_for_task(task_id)
|
||||
return {row.gate.value: row.ok for row in rows}
|
||||
|
||||
|
||||
def _calls(workspace: FakeRepoWorkspace, name: str) -> list[dict]:
|
||||
return [kwargs for called, kwargs in workspace.calls if called == name]
|
||||
|
||||
|
||||
async def _run(scheduler: Scheduler, session, ticks: int) -> None:
|
||||
for _ in range(ticks):
|
||||
await scheduler.tick()
|
||||
await session.flush()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# the whole pipeline
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_a_task_runs_from_triage_to_merged_code(world, session_factory):
|
||||
scripted = ScriptedModel(
|
||||
[
|
||||
ScriptedReply(content='[[ASSIGN employee="" pipeline="plan_code_review_test"/]]'),
|
||||
ScriptedReply(content="[[PLAN_SPEC]]پلان کار[[/PLAN_SPEC]]"),
|
||||
ScriptedReply(content="[[WORK_REPORT]]پیادهسازی شد[[/WORK_REPORT]]"),
|
||||
ScriptedReply(content="[[REVIEW_PASS]]قابل قبول است[[/REVIEW_PASS]]"),
|
||||
ScriptedReply(content="[[TEST_PASS]]سبز است[[/TEST_PASS]]"),
|
||||
ScriptedReply(content="[[COMPLETE/]]"),
|
||||
]
|
||||
)
|
||||
sandbox = FakeSandbox()
|
||||
workspace = FakeRepoWorkspace()
|
||||
memory = RecordingMemory()
|
||||
delivery = NullDeliveryHooks()
|
||||
|
||||
async with session_factory() as session:
|
||||
await _team(session, world)
|
||||
row = await _task(session, world, stage=Stage.PENDING_MANAGER_REVIEW)
|
||||
workspace.diffs[row.id] = DiffResult(
|
||||
changed_files=["app/main.py"], text="--- a\n+++ b\n@@\n+x\n"
|
||||
)
|
||||
await session.commit()
|
||||
scheduler = _scheduler(
|
||||
session,
|
||||
world,
|
||||
scripted=scripted,
|
||||
sandbox=sandbox,
|
||||
workspace=workspace,
|
||||
memory=memory,
|
||||
delivery=delivery,
|
||||
)
|
||||
await _run(scheduler, session, 7)
|
||||
state = await PipelineStateRepository(session).get(row.id)
|
||||
task = await TaskRepository(session).get(row.id)
|
||||
gates = await _gates(session, row.id)
|
||||
|
||||
assert state is not None and state.stage is Stage.COMPLETED
|
||||
assert state.branch == f"task/{row.id}"
|
||||
assert task is not None and task.status is TaskStatus.DONE
|
||||
# Five worker turns plus one triage and one final verdict; the merge is the
|
||||
# platform's, so it costs no model call.
|
||||
assert scripted.calls == 6
|
||||
assert gates["diff_guard"] is True
|
||||
assert gates["no_progress"] is True
|
||||
assert gates["tests"] is True
|
||||
assert gates["green"] is True
|
||||
|
||||
commits = _calls(workspace, "commit")
|
||||
assert len(commits) == 1 and ENGINE_COMMIT_EMAIL in commits[0]["author"]
|
||||
assert _calls(workspace, "merge_into")[0]["target"] == "dev"
|
||||
assert _calls(workspace, "push")[0]["branch"] == "dev"
|
||||
assert len(_calls(workspace, "remove_worktree")) == 1
|
||||
assert delivery.pushed and delivery.pushed[0]["source"] == "engine"
|
||||
assert len(memory.finished) == 1
|
||||
|
||||
# The model was never asked to run git: no shell command reached the sandbox
|
||||
# for git, and the only command it saw was the test command.
|
||||
assert all("git" not in call["command"] for call in sandbox.executed)
|
||||
|
||||
|
||||
async def test_the_scheduler_never_runs_git_through_the_model(world, session_factory):
|
||||
scripted = ScriptedModel([ScriptedReply(content="[[WORK_REPORT]]x[[/WORK_REPORT]]")])
|
||||
sandbox = FakeSandbox()
|
||||
workspace = FakeRepoWorkspace()
|
||||
async with session_factory() as session:
|
||||
await _team(session, world)
|
||||
row = await _task(session, world, stage=Stage.PENDING_IMPLEMENTATION)
|
||||
workspace.diffs[row.id] = DiffResult(changed_files=["a.py"], text="d")
|
||||
await session.commit()
|
||||
scheduler = _scheduler(
|
||||
session,
|
||||
world,
|
||||
scripted=scripted,
|
||||
sandbox=sandbox,
|
||||
workspace=workspace,
|
||||
memory=RecordingMemory(),
|
||||
delivery=NullDeliveryHooks(),
|
||||
)
|
||||
await _run(scheduler, session, 1)
|
||||
assert scripted.all_text() # a prompt was built
|
||||
assert all("git" not in call["command"] for call in sandbox.executed)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# gates send work back
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_red_tests_send_the_work_back_then_green_lets_it_through(
|
||||
world, session_factory
|
||||
):
|
||||
scripted = ScriptedModel(
|
||||
[
|
||||
ScriptedReply(content="[[WORK_REPORT]]تلاش یک[[/WORK_REPORT]]"),
|
||||
ScriptedReply(content="[[WORK_REPORT]]تلاش دو[[/WORK_REPORT]]"),
|
||||
]
|
||||
)
|
||||
sandbox = FakeSandbox(
|
||||
commands={
|
||||
"pytest": [
|
||||
CommandResult(exit_code=1, stdout="", stderr="AssertionError"),
|
||||
CommandResult(exit_code=0, stdout="ok"),
|
||||
]
|
||||
}
|
||||
)
|
||||
workspace = FakeRepoWorkspace()
|
||||
async with session_factory() as session:
|
||||
await _team(session, world)
|
||||
row = await _task(session, world, stage=Stage.PENDING_IMPLEMENTATION)
|
||||
workspace.diffs[row.id] = DiffResult(changed_files=["a.py"], text="diff-1")
|
||||
await session.commit()
|
||||
scheduler = _scheduler(
|
||||
session,
|
||||
world,
|
||||
scripted=scripted,
|
||||
sandbox=sandbox,
|
||||
workspace=workspace,
|
||||
memory=RecordingMemory(),
|
||||
delivery=NullDeliveryHooks(),
|
||||
)
|
||||
await _run(scheduler, session, 1)
|
||||
after_red = await PipelineStateRepository(session).get(row.id)
|
||||
gates_red = await _gates(session, row.id)
|
||||
|
||||
# A different diff on the second attempt, or the no-progress gate fires.
|
||||
workspace.diffs[row.id] = DiffResult(changed_files=["a.py", "b.py"], text="diff-2")
|
||||
await _run(scheduler, session, 1)
|
||||
after_green = await PipelineStateRepository(session).get(row.id)
|
||||
gates_green = await _gates(session, row.id)
|
||||
|
||||
assert after_red is not None and after_red.stage is Stage.PENDING_IMPLEMENTATION
|
||||
assert after_red.cycle == 1
|
||||
assert gates_red["tests"] is False
|
||||
assert after_green is not None and after_green.stage is Stage.PENDING_TEST
|
||||
assert gates_green["tests"] is True
|
||||
|
||||
|
||||
async def test_a_forbidden_diff_never_reaches_git(world, session_factory):
|
||||
scripted = ScriptedModel([ScriptedReply(content="[[WORK_REPORT]]x[[/WORK_REPORT]]")])
|
||||
sandbox = FakeSandbox()
|
||||
workspace = FakeRepoWorkspace()
|
||||
async with session_factory() as session:
|
||||
await _team(session, world)
|
||||
row = await _task(session, world, stage=Stage.PENDING_IMPLEMENTATION)
|
||||
workspace.diffs[row.id] = DiffResult(
|
||||
changed_files=[".github/workflows/ci.yml"], text="ci"
|
||||
)
|
||||
await session.commit()
|
||||
scheduler = _scheduler(
|
||||
session,
|
||||
world,
|
||||
scripted=scripted,
|
||||
sandbox=sandbox,
|
||||
workspace=workspace,
|
||||
memory=RecordingMemory(),
|
||||
delivery=NullDeliveryHooks(),
|
||||
)
|
||||
await _run(scheduler, session, 1)
|
||||
state = await PipelineStateRepository(session).get(row.id)
|
||||
gates = await _gates(session, row.id)
|
||||
|
||||
assert state is not None and state.stage is Stage.PENDING_IMPLEMENTATION
|
||||
assert state.cycle == 1
|
||||
assert gates["diff_guard"] is False
|
||||
assert _calls(workspace, "commit") == []
|
||||
|
||||
|
||||
async def test_a_tester_failure_sends_the_work_back(world, session_factory):
|
||||
scripted = ScriptedModel([ScriptedReply(content="[[TEST_FAIL]]قرمز[[/TEST_FAIL]]")])
|
||||
workspace = FakeRepoWorkspace()
|
||||
async with session_factory() as session:
|
||||
await _team(session, world)
|
||||
row = await _task(session, world, stage=Stage.PENDING_TEST)
|
||||
await session.commit()
|
||||
scheduler = _scheduler(
|
||||
session,
|
||||
world,
|
||||
scripted=scripted,
|
||||
sandbox=FakeSandbox(),
|
||||
workspace=workspace,
|
||||
memory=RecordingMemory(),
|
||||
delivery=NullDeliveryHooks(),
|
||||
)
|
||||
await _run(scheduler, session, 1)
|
||||
state = await PipelineStateRepository(session).get(row.id)
|
||||
assert state is not None and state.stage is Stage.PENDING_IMPLEMENTATION
|
||||
assert state.cycle == 1
|
||||
|
||||
|
||||
async def test_a_test_pass_without_green_tests_is_not_believed(world, session_factory):
|
||||
scripted = ScriptedModel([ScriptedReply(content="[[TEST_PASS]]سبز[[/TEST_PASS]]")])
|
||||
workspace = FakeRepoWorkspace()
|
||||
async with session_factory() as session:
|
||||
await _team(session, world)
|
||||
row = await _task(session, world, stage=Stage.PENDING_TEST)
|
||||
await session.commit()
|
||||
scheduler = _scheduler(
|
||||
session,
|
||||
world,
|
||||
scripted=scripted,
|
||||
sandbox=FakeSandbox(),
|
||||
workspace=workspace,
|
||||
memory=RecordingMemory(),
|
||||
delivery=NullDeliveryHooks(),
|
||||
)
|
||||
await _run(scheduler, session, 1)
|
||||
state = await PipelineStateRepository(session).get(row.id)
|
||||
gates = await _gates(session, row.id)
|
||||
assert state is not None and state.stage is Stage.PENDING_IMPLEMENTATION
|
||||
assert gates["green"] is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# analysis, budget, dependencies, failures
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def test_analysis_only_finishes_without_a_worktree(world, session_factory):
|
||||
scripted = ScriptedModel([ScriptedReply(content="[[PLAN_SPEC]]گزارش[[/PLAN_SPEC]]")])
|
||||
workspace = FakeRepoWorkspace()
|
||||
async with session_factory() as session:
|
||||
await _team(session, world)
|
||||
row = await _task(
|
||||
session,
|
||||
world,
|
||||
stage=Stage.PENDING_ANALYSIS,
|
||||
pipeline=PipelineKey.ANALYSIS_ONLY,
|
||||
kind=TaskKind.ANALYSIS,
|
||||
)
|
||||
await session.commit()
|
||||
scheduler = _scheduler(
|
||||
session,
|
||||
world,
|
||||
scripted=scripted,
|
||||
sandbox=FakeSandbox(),
|
||||
workspace=workspace,
|
||||
memory=RecordingMemory(),
|
||||
delivery=NullDeliveryHooks(),
|
||||
)
|
||||
await _run(scheduler, session, 1)
|
||||
state = await PipelineStateRepository(session).get(row.id)
|
||||
task = await TaskRepository(session).get(row.id)
|
||||
|
||||
assert state is not None and state.stage is Stage.COMPLETED
|
||||
assert task is not None and task.status is TaskStatus.DONE
|
||||
assert _calls(workspace, "create_task_worktree") == []
|
||||
assert _calls(workspace, "commit") == []
|
||||
|
||||
|
||||
async def test_an_empty_wallet_pauses_the_task_before_a_turn_is_spent(
|
||||
world, session_factory
|
||||
):
|
||||
scripted = ScriptedModel([ScriptedReply(content="[[ASSIGN/]]")])
|
||||
workspace = FakeRepoWorkspace()
|
||||
async with session_factory() as session:
|
||||
await _team(session, world)
|
||||
row = await _task(session, world, stage=Stage.PENDING_MANAGER_REVIEW)
|
||||
await session.commit()
|
||||
scheduler = _scheduler(
|
||||
session,
|
||||
world,
|
||||
scripted=scripted,
|
||||
sandbox=FakeSandbox(),
|
||||
workspace=workspace,
|
||||
memory=RecordingMemory(),
|
||||
delivery=NullDeliveryHooks(),
|
||||
wallets=_EmptyWalletRepo(),
|
||||
)
|
||||
await _run(scheduler, session, 1)
|
||||
state = await PipelineStateRepository(session).get(row.id)
|
||||
task = await TaskRepository(session).get(row.id)
|
||||
|
||||
assert state is not None and state.stage is Stage.PAUSED
|
||||
assert task is not None and task.status is TaskStatus.WAITING_APPROVAL
|
||||
assert scripted.calls == 0
|
||||
assert BUDGET_PAUSED_MESSAGE
|
||||
|
||||
|
||||
async def test_a_task_waits_for_its_dependencies(world, session_factory):
|
||||
scripted = ScriptedModel(
|
||||
[
|
||||
ScriptedReply(content='[[TEST_PASS waive="مشکل شناختهشده"]]سبز[[/TEST_PASS]]'),
|
||||
ScriptedReply(content="[[WORK_REPORT]]done[[/WORK_REPORT]]"),
|
||||
]
|
||||
)
|
||||
workspace = FakeRepoWorkspace()
|
||||
async with session_factory() as session:
|
||||
await _team(session, world)
|
||||
first = await _task(session, world, stage=Stage.PENDING_TEST)
|
||||
second = await _task(session, world, stage=Stage.PENDING_IMPLEMENTATION)
|
||||
state = await PipelineStateRepository(session).get(second.id)
|
||||
assert state is not None
|
||||
await PipelineStateRepository(session).patch(
|
||||
task_id=second.id, expected_version=state.version, depends_on=[first.id]
|
||||
)
|
||||
workspace.diffs[second.id] = DiffResult(changed_files=["b.py"], text="diff-b")
|
||||
await session.commit()
|
||||
scheduler = _scheduler(
|
||||
session,
|
||||
world,
|
||||
scripted=scripted,
|
||||
sandbox=FakeSandbox(),
|
||||
workspace=workspace,
|
||||
memory=RecordingMemory(),
|
||||
delivery=NullDeliveryHooks(),
|
||||
)
|
||||
await _run(scheduler, session, 1)
|
||||
blocked = await PipelineStateRepository(session).get(second.id)
|
||||
|
||||
# The dependency finished, so the next tick lets the work run.
|
||||
dependency = await PipelineStateRepository(session).get(first.id)
|
||||
assert dependency is not None
|
||||
await PipelineStateRepository(session).patch(
|
||||
task_id=first.id,
|
||||
expected_version=dependency.version,
|
||||
stage=str(Stage.COMPLETED),
|
||||
)
|
||||
await _run(scheduler, session, 1)
|
||||
unblocked = await PipelineStateRepository(session).get(second.id)
|
||||
|
||||
assert blocked is not None and blocked.stage is Stage.PENDING_IMPLEMENTATION
|
||||
assert unblocked is not None and unblocked.stage is Stage.PENDING_TEST
|
||||
|
||||
|
||||
async def test_a_transient_failure_is_retried_then_handed_to_the_manager(
|
||||
world, session_factory
|
||||
):
|
||||
scripted = ScriptedModel(
|
||||
[
|
||||
ScriptedReply(raises=ProviderUnavailableError("429 rate limit")),
|
||||
ScriptedReply(raises=ProviderUnavailableError("429 rate limit")),
|
||||
ScriptedReply(raises=ProviderUnavailableError("429 rate limit")),
|
||||
]
|
||||
)
|
||||
workspace = FakeRepoWorkspace()
|
||||
async with session_factory() as session:
|
||||
await _team(session, world)
|
||||
row = await _task(session, world, stage=Stage.PENDING_IMPLEMENTATION)
|
||||
workspace.diffs[row.id] = DiffResult(changed_files=["a.py"], text="d")
|
||||
await session.commit()
|
||||
scheduler = _scheduler(
|
||||
session,
|
||||
world,
|
||||
scripted=scripted,
|
||||
sandbox=FakeSandbox(),
|
||||
workspace=workspace,
|
||||
memory=RecordingMemory(),
|
||||
delivery=NullDeliveryHooks(),
|
||||
)
|
||||
await _run(scheduler, session, 3)
|
||||
state = await PipelineStateRepository(session).get(row.id)
|
||||
|
||||
assert state is not None and state.stage is Stage.ERROR_REVIEW
|
||||
assert state.retry_count == 2
|
||||
|
||||
|
||||
async def test_a_merge_conflict_sends_the_work_back_for_another_pass(
|
||||
world, session_factory
|
||||
):
|
||||
scripted = ScriptedModel()
|
||||
workspace = FakeRepoWorkspace()
|
||||
workspace.sync_outcomes = [MergeOutcome(ok=False, conflict_files=["app/main.py"])]
|
||||
async with session_factory() as session:
|
||||
await _team(session, world)
|
||||
row = await _task(session, world, stage=Stage.MERGE_REQUEST)
|
||||
workspace.diffs[row.id] = DiffResult(changed_files=["app/main.py"], text="d")
|
||||
await session.commit()
|
||||
scheduler = _scheduler(
|
||||
session,
|
||||
world,
|
||||
scripted=scripted,
|
||||
sandbox=FakeSandbox(),
|
||||
workspace=workspace,
|
||||
memory=RecordingMemory(),
|
||||
delivery=NullDeliveryHooks(),
|
||||
)
|
||||
await _run(scheduler, session, 1)
|
||||
state = await PipelineStateRepository(session).get(row.id)
|
||||
|
||||
assert state is not None and state.stage is Stage.PENDING_IMPLEMENTATION
|
||||
assert state.retry_count == 1 and state.cycle == 1
|
||||
|
||||
Reference in New Issue
Block a user