Files
smart-org/apps/api/tests/test_engine_flow.py
T
mjalmoudy94andClaude Sonnet 5.5 fef5a9deab feat(integration): wire v2 workstreams together (engine policy/budget, sandbox, tenant checks, order route, docs)
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>
2026-10-14 20:57:56 +03:30

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