Engine honours org policy and monthly budget, legacy runner skips engine tasks, sandbox session provisioned on demand, project fallback for threads, reviewer fallback, tenant checks on project ids, memory reflection respects grants, expiry worker, MCP pipeline_status, login redirect, sharing settings, e2e smoke. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
706 lines
26 KiB
Python
706 lines
26 KiB
Python
"""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 overdrawn 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 _OverdrawnWallet:
|
|
"""A wallet below zero: the org is overdrawn, so the engine stops before a turn."""
|
|
|
|
balance = -1.0
|
|
|
|
|
|
class _ZeroWallet:
|
|
"""A wallet at exactly zero: the normal starting state of a fresh, post-paid org."""
|
|
|
|
balance = 0.0
|
|
|
|
|
|
class _Wallets:
|
|
def __init__(self, wallet: _OverdrawnWallet | _ZeroWallet | None) -> None:
|
|
self._wallet = wallet
|
|
|
|
async def get_for_org(self, organization_id: str):
|
|
return self._wallet
|
|
|
|
|
|
class _OverdrawnWalletRepo(_Wallets):
|
|
def __init__(self) -> None:
|
|
super().__init__(_OverdrawnWallet())
|
|
|
|
|
|
@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_overdrawn_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=_OverdrawnWalletRepo(),
|
|
)
|
|
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_zero_wallet_does_not_pause_a_fresh_org(world, session_factory):
|
|
"""A 0.0 balance is every org's starting state, so it must not pause the task.
|
|
|
|
This is an intentional behaviour change. The scheduler used to pause on
|
|
``balance <= 0``, which froze every new org before its first turn: usage is
|
|
post-paid and nothing debits a wallet. Only an overdrawn wallet (below zero)
|
|
pauses now. The old test that encoded the previous rule was renamed and
|
|
updated, as PLAN-v2 §7.4 requires for a deliberate behaviour change.
|
|
"""
|
|
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=_Wallets(_ZeroWallet()),
|
|
)
|
|
await _run(scheduler, session, 1)
|
|
state = await PipelineStateRepository(session).get(row.id)
|
|
|
|
assert state is not None and state.stage is not Stage.PAUSED
|
|
assert scripted.calls == 1
|
|
|
|
|
|
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
|
|
|