feat(integration): prepaid billing, engine UI, credits console, direct tasks, v2 final
- 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
This commit is contained in:
@@ -14,7 +14,8 @@ git (in-memory ``FakeRepoWorkspace``) are fakes. What it defends:
|
||||
* 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
|
||||
* 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
|
||||
"""
|
||||
|
||||
@@ -92,13 +93,19 @@ class _OverdrawnWallet:
|
||||
|
||||
|
||||
class _ZeroWallet:
|
||||
"""A wallet at exactly zero: the normal starting state of a fresh, post-paid org."""
|
||||
"""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 | None) -> None:
|
||||
def __init__(self, wallet: _OverdrawnWallet | _ZeroWallet | _FundedWallet | None) -> None:
|
||||
self._wallet = wallet
|
||||
|
||||
async def get_for_org(self, organization_id: str):
|
||||
@@ -567,14 +574,12 @@ async def test_an_overdrawn_wallet_pauses_the_task_before_a_turn_is_spent(
|
||||
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.
|
||||
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.
|
||||
|
||||
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.
|
||||
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()
|
||||
@@ -594,6 +599,58 @@ async def test_a_zero_wallet_does_not_pause_a_fresh_org(world, session_factory):
|
||||
)
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user