- Prepaid credit system: org wallet, debit on usage, chat gate, admin credit console - Engine UI: /manager, /tasks, /tasks/[id], /workforce, /dashboard, /traces pages - Direct task creation (HTTP POST /tasks, MCP tasks_create) at pending_manager_review - Pause/resume/cancel/answer actions in engine API and UI - Employee cap (7 per org default), review pipeline guard (active reviewer required) - Scheduler: wide window prevents org starvation; archived employees excluded from cap - Tenant isolation: project-id cross-org 404, engine never uses another tenant's sandbox - Order route fix: manager built with pipeline states + employee profiles - Contract extension: ModelGrantPort.get_org_policy with DefaultOrgPolicy - Engine event types in @agy/contracts (task.step, task.gate, run.log, run.tool, team.changed) - api-engine.ts typed client; api-credit.ts client - Alembic migration c4d5e6f7a8b9: prepaid credit + engine UI tables - 859 tests passed, 1 skipped; ruff/mypy/tsc/eslint/vitest all green Refs: PLAN-v2.md §9 (T1–T43), todo-v2-final.md
763 lines
28 KiB
Python
763 lines
28 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 empty, overdrawn or missing wallet pauses the task before a single turn is
|
|
spent (prepaid billing, decision D-B)
|
|
* 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: prepaid, so no credit is left."""
|
|
|
|
balance = 0.0
|
|
|
|
|
|
class _FundedWallet:
|
|
"""A wallet with credit left: the org may spend a turn."""
|
|
|
|
balance = 5.0
|
|
|
|
|
|
class _Wallets:
|
|
def __init__(self, wallet: _OverdrawnWallet | _ZeroWallet | _FundedWallet | 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_pauses_the_task_before_a_turn_is_spent(world, session_factory):
|
|
"""A zero balance is no credit left: the task pauses before a turn is spent.
|
|
|
|
Prepaid billing (decision D-B) reverses the post-paid rule of the earlier plan
|
|
(its T3), under which a zero balance was every new org's normal state and did
|
|
not pause. Now the platform owner credits an org before any work runs.
|
|
"""
|
|
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)
|
|
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
|
|
|
|
|
|
async def test_a_missing_wallet_pauses_the_task(world, session_factory):
|
|
"""No wallet row at all counts as zero credit (prepaid): the task pauses."""
|
|
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(None),
|
|
)
|
|
await _run(scheduler, session, 1)
|
|
state = await PipelineStateRepository(session).get(row.id)
|
|
|
|
assert state is not None and state.stage is Stage.PAUSED
|
|
assert scripted.calls == 0
|
|
|
|
|
|
async def test_a_funded_wallet_does_not_pause(world, session_factory):
|
|
"""Credit left means the org may spend a turn: the task is not paused."""
|
|
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(_FundedWallet()),
|
|
)
|
|
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
|
|
|