mirror of
https://github.com/rennf93/roboco.git
synced 2026-08-03 07:23:24 +02:00
* fix(tg): cockpit data correctness — real GLM pricing, display timezone, agent activity tracking
Three root causes behind the Mini App/bot showing wrong numbers:
Pricing: glm-5.2 gets a grounded per-token rate (z.ai published pricing,
$1.40/$4.40/$0.26 per 1M, source+date in the table comment) so a GLM
fleet day stops reporting $0.00 for half a million tokens; ungrounded
Ollama-Cloud models render "subscription (untracked)" instead of a bare
zero (is_ollama_cloud_model, consumed directly by the cockpit). Side
effect, intended and documented: honestly-priced GLM now trips the
downgrade-only comparator for new qa/documenter complexity pins.
Display timezone: the cockpit bucketed days in UTC for a GMT+2 operator.
New pure foundation module display_time (resolve_zone/local_date/
trailing_dates/day_bounds_utc, DST-correct with tests for the 23h/25h
days) + ROBOCO_DISPLAY_TIMEZONE (IANA-validated, default UTC); the
cockpit's spend/velocity series bucket raw session/completion rows by
the display zone. The UTC-keyed rollup table and the main dashboard are
deliberately untouched.
Agent activity: AgentTable.status was never set to ACTIVE and
current_task_id was never written anywhere — "active: 0, working: []"
was structurally permanent. Every claim path now marks the claimant
ACTIVE with rollback symmetry (_finalize_claim for dev/PM claims,
_qa_or_doc_claim for QA/doc/PR-gate claims, pr_review_claim for external
review) and every release path clears it (pass/fail QA, pr_pass/pr_fail,
complete_review, advance-to-PM-review, reaper unclaim, voluntary
unclaim, reassign retarget, pool divert, admin transitions, unblock
restore-to-in-progress). The bot's /status shares the cockpit's fleet
derivation so the two surfaces can't disagree. Known ceiling, commented:
one current_task_id column shows a multi-root coordinator PM's most
recent claim only.
Drill: sonnet develop -> sonnet adversarial (refuted the original
chokepoint coverage claim; QA/doc/reviewer paths were unwired) ->
correction round (wired them all + restored a dropped assertion, deleted
a dead helper and the dead subscription_billed field) -> review.
* fix(db): post_update on AgentTable.current_task breaks the flush cycle
agents.current_task_id and tasks.assigned_to reference each other, so a
flush touching both rows — every claim now marks its agent ACTIVE — is
an instance-level circular dependency SQLAlchemy cannot topologically
sort. The e2e smoke's full verb paths (12 tests) hit it; the unit and
integration suites never flush both dirty rows with relationships
loaded. post_update emits the FK as a second UPDATE, the canonical fix
for mutually-referencing rows.
* fix(budgets): enforce only explicitly-set budgets — no per-TaskType defaults
The per-TaskType default cap table blocked an unbudgeted coordination
root one opus planning turn in ($1.50 PLANNING default vs. real
coordination spend) — a false positive by design the moment the fleet
runs a priced model. Budgets are now explicit-input only:
effective_task_budget_usd returns None for an unset budget_usd, the
budget sweep skips enforcement (and never prices spend) on None, and
the unblock re-check passes on None so clearing the budget field is
itself a valid resolution. The project monthly cap stays as the
explicit-input fleet-wide backstop. Panel copy tells the truth
("No cap" placeholder; empty = uncapped), and the TaskType default
table plus its resolver are deleted.
---------
Co-authored-by: Renn F <rennf93@users.noreply.github.com>
339 lines
12 KiB
Python
339 lines
12 KiB
Python
"""Task-level $ budget sweep (ROBOCO_TASK_BUDGETS_ENABLED).
|
|
|
|
`_sweep_budget_exceeded` gains a second trigger alongside the existing
|
|
tool-call halt: when the flag is on and an active task's own $ budget is
|
|
breached, the task is BLOCKED + the CEO notified (`_handle_task_budget_breach`)
|
|
BEFORE the agent is gracefully stopped — never a mid-verb kill, and never left
|
|
to bounce through `pending` for an instant re-claim.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from contextlib import asynccontextmanager
|
|
from typing import Any
|
|
from unittest.mock import AsyncMock, MagicMock, patch
|
|
|
|
import pytest
|
|
from roboco.config import settings
|
|
from roboco.models.base import BlockerResolverType, TaskStatus, TaskType
|
|
from roboco.runtime.orchestrator import AgentOrchestrator, AgentState
|
|
|
|
_MOCK_TASK_SPEND_USD = 3.0
|
|
|
|
|
|
def _make_orchestrator() -> AgentOrchestrator:
|
|
with patch.object(AgentOrchestrator, "__init__", return_value=None):
|
|
orch = AgentOrchestrator.__new__(AgentOrchestrator)
|
|
orch._instances = {}
|
|
orch._lock = MagicMock()
|
|
return orch
|
|
|
|
|
|
def _instance(task_id: str | None) -> MagicMock:
|
|
inst = MagicMock()
|
|
inst.state = AgentState.ACTIVE
|
|
inst.container_id = "deadbeef1234"
|
|
inst.current_task_id = task_id
|
|
inst.error_count = 0
|
|
inst.config = MagicMock(git_context=None)
|
|
return inst
|
|
|
|
|
|
def _db_ctx(db: Any) -> Any:
|
|
@asynccontextmanager
|
|
async def _ctx() -> Any:
|
|
yield db
|
|
|
|
return _ctx
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_task_budget_breach_blocks_before_graceful_stop(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
orch = _make_orchestrator()
|
|
task_id = "11111111-1111-1111-1111-111111111111"
|
|
orch._instances = {"be-dev-1": _instance(task_id)}
|
|
monkeypatch.setattr(settings, "task_budgets_enabled", True)
|
|
|
|
with (
|
|
patch.object(
|
|
AgentOrchestrator, "_fetch_budget_status", AsyncMock(return_value=None)
|
|
),
|
|
patch.object(orch, "_task_budget_breach", AsyncMock(return_value=(5.0, 7.5))),
|
|
patch.object(orch, "_handle_task_budget_breach", AsyncMock()) as handle_mock,
|
|
patch.object(orch, "stop_agent", AsyncMock()) as stop_mock,
|
|
):
|
|
await orch._sweep_budget_exceeded()
|
|
|
|
# Block + notify runs, and runs BEFORE stop_agent (never a mid-verb kill —
|
|
# graceful=True, and the task is already blocked by the time the agent dies).
|
|
handle_mock.assert_awaited_once_with(task_id, cap_usd=5.0, spend_usd=7.5)
|
|
stop_mock.assert_awaited_once_with(
|
|
"be-dev-1",
|
|
graceful=True,
|
|
release_claim=True,
|
|
stop_reason="budget_exceeded_task",
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_flag_off_never_checks_task_budget(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
orch = _make_orchestrator()
|
|
task_id = "11111111-1111-1111-1111-111111111111"
|
|
orch._instances = {"be-dev-1": _instance(task_id)}
|
|
monkeypatch.setattr(settings, "task_budgets_enabled", False)
|
|
|
|
with (
|
|
patch.object(
|
|
AgentOrchestrator, "_fetch_budget_status", AsyncMock(return_value=None)
|
|
),
|
|
patch.object(orch, "_task_budget_breach", AsyncMock()) as breach_mock,
|
|
patch.object(orch, "stop_agent", AsyncMock()) as stop_mock,
|
|
):
|
|
await orch._sweep_budget_exceeded()
|
|
|
|
breach_mock.assert_not_awaited()
|
|
stop_mock.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_under_budget_is_a_no_op(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
orch = _make_orchestrator()
|
|
task_id = "11111111-1111-1111-1111-111111111111"
|
|
orch._instances = {"be-dev-1": _instance(task_id)}
|
|
monkeypatch.setattr(settings, "task_budgets_enabled", True)
|
|
|
|
with (
|
|
patch.object(
|
|
AgentOrchestrator, "_fetch_budget_status", AsyncMock(return_value=None)
|
|
),
|
|
patch.object(orch, "_task_budget_breach", AsyncMock(return_value=None)),
|
|
patch.object(orch, "_handle_task_budget_breach", AsyncMock()) as handle_mock,
|
|
patch.object(orch, "stop_agent", AsyncMock()) as stop_mock,
|
|
):
|
|
await orch._sweep_budget_exceeded()
|
|
|
|
handle_mock.assert_not_awaited()
|
|
stop_mock.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_tool_call_halt_path_is_unchanged(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""The pre-existing tool-call halt trigger still fires with its own
|
|
stop_reason, independent of the new $ budget path."""
|
|
orch = _make_orchestrator()
|
|
orch._instances = {"be-dev-1": _instance(None)}
|
|
monkeypatch.setattr(settings, "task_budgets_enabled", False)
|
|
|
|
halt_status = {"halt": True, "total": 301, "halt_threshold": 300}
|
|
with (
|
|
patch.object(
|
|
AgentOrchestrator,
|
|
"_fetch_budget_status",
|
|
AsyncMock(return_value=halt_status),
|
|
),
|
|
patch.object(orch, "stop_agent", AsyncMock()) as stop_mock,
|
|
):
|
|
await orch._sweep_budget_exceeded()
|
|
|
|
stop_mock.assert_awaited_once_with(
|
|
"be-dev-1", graceful=True, release_claim=True, stop_reason="budget_sweep"
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_no_task_id_skips_task_budget_check(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""A taskless spawn (current_task_id=None) never reaches the $ budget
|
|
check even with the flag on."""
|
|
orch = _make_orchestrator()
|
|
orch._instances = {"be-dev-1": _instance(None)}
|
|
monkeypatch.setattr(settings, "task_budgets_enabled", True)
|
|
|
|
with (
|
|
patch.object(
|
|
AgentOrchestrator, "_fetch_budget_status", AsyncMock(return_value=None)
|
|
),
|
|
patch.object(orch, "_task_budget_breach", AsyncMock()) as breach_mock,
|
|
patch.object(orch, "stop_agent", AsyncMock()) as stop_mock,
|
|
):
|
|
await orch._sweep_budget_exceeded()
|
|
|
|
breach_mock.assert_not_awaited()
|
|
stop_mock.assert_not_awaited()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _handle_task_budget_breach: the block + notify write itself
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_breach_blocks_task_and_notifies_ceo() -> None:
|
|
orch = _make_orchestrator()
|
|
task_id = "22222222-2222-2222-2222-222222222222"
|
|
task = MagicMock(status=TaskStatus.IN_PROGRESS)
|
|
db = MagicMock()
|
|
|
|
task_svc = MagicMock()
|
|
task_svc.get = AsyncMock(return_value=task)
|
|
task_svc.admin_set_status = AsyncMock()
|
|
delivery = MagicMock()
|
|
delivery.notify_ceo_of_budget_breach = AsyncMock()
|
|
|
|
with (
|
|
patch("roboco.db.base.get_db_context", _db_ctx(db)),
|
|
patch("roboco.services.task.TaskService", return_value=task_svc),
|
|
patch(
|
|
"roboco.services.notification_delivery.get_notification_delivery_service",
|
|
return_value=delivery,
|
|
),
|
|
):
|
|
await orch._handle_task_budget_breach(task_id, cap_usd=5.0, spend_usd=8.0)
|
|
|
|
assert task.blocker_resolver_type == BlockerResolverType.HUMAN
|
|
task_svc.admin_set_status.assert_awaited_once()
|
|
args, _kwargs = task_svc.admin_set_status.call_args
|
|
assert args[1] == TaskStatus.BLOCKED
|
|
delivery.notify_ceo_of_budget_breach.assert_awaited_once_with(
|
|
task=task, task_id=args[0], cap_usd=5.0, spend_usd=8.0
|
|
)
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_handle_breach_skips_a_task_that_already_moved_on() -> None:
|
|
"""A stale re-check racing the task's own progress (e.g. it completed
|
|
between the read and the write) must not block/notify."""
|
|
orch = _make_orchestrator()
|
|
task_id = "33333333-3333-3333-3333-333333333333"
|
|
task = MagicMock(status=TaskStatus.COMPLETED)
|
|
db = MagicMock()
|
|
|
|
task_svc = MagicMock()
|
|
task_svc.get = AsyncMock(return_value=task)
|
|
task_svc.admin_set_status = AsyncMock()
|
|
delivery = MagicMock()
|
|
delivery.notify_ceo_of_budget_breach = AsyncMock()
|
|
|
|
with (
|
|
patch("roboco.db.base.get_db_context", _db_ctx(db)),
|
|
patch("roboco.services.task.TaskService", return_value=task_svc),
|
|
patch(
|
|
"roboco.services.notification_delivery.get_notification_delivery_service",
|
|
return_value=delivery,
|
|
),
|
|
):
|
|
await orch._handle_task_budget_breach(task_id, cap_usd=5.0, spend_usd=8.0)
|
|
|
|
task_svc.admin_set_status.assert_not_awaited()
|
|
delivery.notify_ceo_of_budget_breach.assert_not_awaited()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# _task_budget_breach: explicit-input-only cap (null budget = never a breach)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_null_budget_is_never_a_breach() -> None:
|
|
"""Budgets enforce only when explicitly set: a task with no budget_usd is
|
|
uncapped, regardless of spend — the spend query is never even issued (the
|
|
old per-TaskType default table blocked a default-budget coordination root
|
|
one opus planning turn in)."""
|
|
orch = _make_orchestrator()
|
|
task_id = "44444444-4444-4444-4444-444444444444"
|
|
task = MagicMock(
|
|
status=TaskStatus.IN_PROGRESS, task_type=TaskType.DOCUMENTATION, budget_usd=None
|
|
)
|
|
task_svc = MagicMock()
|
|
task_svc.get = AsyncMock(return_value=task)
|
|
task_svc.task_spend_usd = AsyncMock(return_value=_MOCK_TASK_SPEND_USD)
|
|
db = MagicMock()
|
|
|
|
with (
|
|
patch("roboco.db.base.get_db_context", _db_ctx(db)),
|
|
patch("roboco.services.task.TaskService", return_value=task_svc),
|
|
):
|
|
breach = await orch._task_budget_breach(task_id)
|
|
|
|
assert breach is None
|
|
task_svc.task_spend_usd.assert_not_awaited()
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_breach_none_when_task_left_claimed_or_in_progress() -> None:
|
|
"""A stale re-check (the task already reached e.g. awaiting_qa) is not a
|
|
breach — the spend query is never even issued."""
|
|
orch = _make_orchestrator()
|
|
task_id = "55555555-5555-5555-5555-555555555555"
|
|
task = MagicMock(
|
|
status=TaskStatus.AWAITING_QA, task_type=TaskType.CODE, budget_usd=1.0
|
|
)
|
|
task_svc = MagicMock()
|
|
task_svc.get = AsyncMock(return_value=task)
|
|
task_svc.task_spend_usd = AsyncMock()
|
|
db = MagicMock()
|
|
|
|
with (
|
|
patch("roboco.db.base.get_db_context", _db_ctx(db)),
|
|
patch("roboco.services.task.TaskService", return_value=task_svc),
|
|
):
|
|
breach = await orch._task_budget_breach(task_id)
|
|
|
|
assert breach is None
|
|
task_svc.task_spend_usd.assert_not_awaited()
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Repeated ticks: a blocked task must not re-fire the breach handling.
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
async def test_repeated_ticks_do_not_refire_once_blocked(
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
) -> None:
|
|
"""Two consecutive _sweep_budget_exceeded ticks against the same still-
|
|
registered instance: tick 1 detects the breach and blocks the task; by
|
|
tick 2 the task is BLOCKED (no longer CLAIMED/IN_PROGRESS), so
|
|
_task_budget_breach's own status guard returns None — the sweep never
|
|
re-blocks / re-notifies / re-stops a task that's already been handled."""
|
|
orch = _make_orchestrator()
|
|
task_id = "77777777-7777-7777-7777-777777777777"
|
|
orch._instances = {"be-dev-1": _instance(task_id)}
|
|
monkeypatch.setattr(settings, "task_budgets_enabled", True)
|
|
|
|
in_progress_task = MagicMock(
|
|
status=TaskStatus.IN_PROGRESS, task_type=TaskType.CODE, budget_usd=5.0
|
|
)
|
|
# Simulates the task having been transitioned to BLOCKED by tick 1's
|
|
# (mocked-out) _handle_task_budget_breach before tick 2 re-checks it.
|
|
blocked_task = MagicMock(
|
|
status=TaskStatus.BLOCKED, task_type=TaskType.CODE, budget_usd=5.0
|
|
)
|
|
task_svc = MagicMock()
|
|
task_svc.get = AsyncMock(side_effect=[in_progress_task, blocked_task])
|
|
task_svc.task_spend_usd = AsyncMock(return_value=7.0)
|
|
db = MagicMock()
|
|
|
|
with (
|
|
patch.object(
|
|
AgentOrchestrator, "_fetch_budget_status", AsyncMock(return_value=None)
|
|
),
|
|
patch("roboco.db.base.get_db_context", _db_ctx(db)),
|
|
patch("roboco.services.task.TaskService", return_value=task_svc),
|
|
patch.object(orch, "_handle_task_budget_breach", AsyncMock()) as handle_mock,
|
|
patch.object(orch, "stop_agent", AsyncMock()) as stop_mock,
|
|
):
|
|
await orch._sweep_budget_exceeded() # tick 1: breach detected
|
|
await orch._sweep_budget_exceeded() # tick 2: already blocked
|
|
|
|
handle_mock.assert_awaited_once()
|
|
stop_mock.assert_awaited_once()
|