fix(tg): cockpit data correctness — real GLM pricing, display timezone, agent activity tracking (#666)

* 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>
This commit is contained in:
Renzo F
2026-07-23 21:09:50 +02:00
committed by GitHub
co-authored by Renn F
parent 0c1d450a05
commit a036c97985
35 changed files with 1364 additions and 191 deletions
@@ -756,11 +756,20 @@ async def test_pr_review_gate_pass_path(
assert claimed is not None
assert str(claimed.status) == Status.AWAITING_PR_REVIEW.value
assert claimed.assigned_to == reviewer.id
# pr_gate_claim (via _qa_or_doc_claim) flips the reviewer's fleet marker.
reviewer_row = await db_session.get(AgentTable, reviewer_id)
assert reviewer_row is not None
assert reviewer_row.status == AgentStatus.ACTIVE
assert reviewer_row.current_task_id == task.id
passed = await svc.pr_pass(reviewer_id, task.id, notes="integration verified")
assert passed is not None
assert str(passed.status) == Status.AWAITING_PM_REVIEW.value
assert passed.assigned_to is None # cleared so the PM-closure dispatch routes
# pr_pass releases the reviewer's fleet marker too.
reviewer_row = await db_session.get(AgentTable, reviewer_id)
assert reviewer_row is not None
assert reviewer_row.current_task_id is None
final = await svc.get(task.id)
assert final is not None
+16 -7
View File
@@ -13,6 +13,7 @@ from fastapi import FastAPI
from httpx import ASGITransport, AsyncClient
from roboco.api.deps import get_agent_context, get_db
from roboco.api.routes.provider import router as provider_router
from roboco.billing.pricing import input_price_per_million
from roboco.db.tables import (
ModelAssignmentTable,
ProviderConfigTable,
@@ -30,11 +31,19 @@ if TYPE_CHECKING:
from sqlalchemy.ext.asyncio import AsyncSession
def _first_model_for_type(provider_type: ModelProvider) -> str:
def _unpriced_model_for_type(provider_type: ModelProvider) -> str:
"""The first `provider_type` catalog entry pricing.py has NOT grounded a
real per-token rate for — tests below want "an unpriced, free-tier
downgrade-safe model" specifically to exercise provider-readiness gating,
not pricing itself, so grounding a real rate for one catalog entry (e.g.
GLM-5.2) must not silently break them by picking that one."""
for entry in MODEL_CATALOG:
if entry.provider_type == provider_type:
if (
entry.provider_type == provider_type
and input_price_per_million(entry.model_name) == 0.0
):
return entry.model_name
raise RuntimeError(f"no catalog entry for {provider_type}")
raise RuntimeError(f"no unpriced catalog entry for {provider_type}")
def _make_app(
@@ -846,12 +855,12 @@ async def test_put_complexity_override_allows_same_tier_as_baseline(
async def test_put_complexity_override_rejects_disabled_provider(
app_client_with_ollama: AsyncClient,
) -> None:
"""qa's baseline (haiku) prices no cheaper than Ollama Cloud (unpriced,
treated as free-tier) so the downgrade-only check passes — but the
"""qa's baseline (haiku) prices no cheaper than an unpriced Ollama Cloud
model (free-tier) so the downgrade-only check passes — but the
OLLAMA_CLOUD provider is disabled (no key set) in this fixture's seeded
state, so the write-time readiness guard rejects it before it can
silently no-op to the legacy Anthropic path at spawn."""
ollama_model = _first_model_for_type(ModelProvider.OLLAMA_CLOUD)
ollama_model = _unpriced_model_for_type(ModelProvider.OLLAMA_CLOUD)
response = await app_client_with_ollama.put(
"/api/providers/complexity-overrides",
json={"role": "qa", "complexity": "low", "model_name": ollama_model},
@@ -875,7 +884,7 @@ async def test_put_complexity_override_warns_on_cross_family_once_provider_ready
json={"api_key": "test-key"},
headers=_HDR_PM,
)
ollama_model = _first_model_for_type(ModelProvider.OLLAMA_CLOUD)
ollama_model = _unpriced_model_for_type(ModelProvider.OLLAMA_CLOUD)
response = await app_client_with_ollama.put(
"/api/providers/complexity-overrides",
json={"role": "qa", "complexity": "low", "model_name": ollama_model},
+183 -6
View File
@@ -989,6 +989,12 @@ async def test_unblock_restores_to_in_progress(
# Owner restored into both fields so the dev dispatcher respawns it.
assert unblocked.assigned_to == task_setup["agent_id"]
assert unblocked.claimed_by == task_setup["agent_id"]
# A real resume with no fresh claim() call — unblock must flip the
# owner's fleet marker itself (mirrors _finalize_claim/_qa_or_doc_claim).
owner_row = await db_session.get(AgentTable, task_setup["agent_id"])
assert owner_row is not None
assert owner_row.status == AgentStatus.ACTIVE
assert owner_row.current_task_id == task.id
@pytest.mark.asyncio
@@ -1021,6 +1027,67 @@ async def test_unblock_keeps_owner_for_give_me_work_claim(
assert unblocked.claimed_by == task_setup["agent_id"]
@pytest.mark.asyncio
async def test_admin_set_status_into_review_queue_releases_agent_marker(
task_setup: dict, db_session: AsyncSession
) -> None:
"""A non-blocked admin override into a review/queue state clears the
stale claimant's active_claimant_id (M19) — it must release that
claimant's fleet marker too, or a dead escalation claim keeps reporting
an agent as active forever."""
svc = task_setup["svc"]
dev_id = task_setup["agent_id"]
task = await svc.create(_req(task_setup))
task.status = TaskStatus.IN_PROGRESS
task.assigned_to = dev_id
task.claimed_by = dev_id
task.active_claimant_id = dev_id
await db_session.flush()
dev_agent = await db_session.get(AgentTable, dev_id)
assert dev_agent is not None
dev_agent.status = AgentStatus.ACTIVE
dev_agent.current_task_id = task.id
await db_session.flush()
out = await svc.admin_set_status(task.id, TaskStatus.AWAITING_QA)
assert out is not None
assert out.active_claimant_id is None
dev_agent = await db_session.get(AgentTable, dev_id)
assert dev_agent is not None
assert dev_agent.current_task_id is None
@pytest.mark.asyncio
async def test_divert_owned_task_to_pool_idles_prior_owner(
task_setup: dict, db_session: AsyncSession
) -> None:
"""_divert_owned_task_to_pool clears ownership entirely — the refused
owner isn't engaged with this task at all anymore, so its fleet marker
must be released (mirrors _force_unclaim_to_pending's reaper release)."""
svc = task_setup["svc"]
owner_id = task_setup["agent_id"]
task = await svc.create(_req(task_setup))
task.status = TaskStatus.IN_PROGRESS
task.assigned_to = owner_id
task.claimed_by = owner_id
await db_session.flush()
owner_agent = await db_session.get(AgentTable, owner_id)
assert owner_agent is not None
owner_agent.status = AgentStatus.ACTIVE
owner_agent.current_task_id = task.id
await db_session.flush()
await svc._divert_owned_task_to_pool(task, note="test diversion")
assert task.status == TaskStatus.PENDING
assert task.assigned_to is None
owner_agent = await db_session.get(AgentTable, owner_id)
assert owner_agent is not None
assert owner_agent.status == AgentStatus.IDLE
assert owner_agent.current_task_id is None
# ---------------------------------------------------------------------------
# QA + completion happy paths
# ---------------------------------------------------------------------------
@@ -1068,13 +1135,21 @@ async def test_pass_qa_clears_active_claimant_for_doc_claim(
task.status = TaskStatus.AWAITING_QA
task.pr_number = 42
task.pr_url = "https://github.com/x/y/pull/42"
task.assigned_to = qa_id
task.claimed_by = qa_id
task.active_claimant_id = qa_id
await db_session.flush()
# qa_claim (via _qa_or_doc_claim) flips the QA agent's fleet marker —
# verify it, then verify pass_qa releases it symmetrically.
qa_claimed = await svc.qa_claim(qa_id, task.id)
assert qa_claimed is not None
qa_agent = await db_session.get(AgentTable, qa_id)
assert qa_agent is not None
assert qa_agent.status == AgentStatus.ACTIVE
assert qa_agent.current_task_id == task.id
passed = await svc.pass_qa(task.id, notes="LGTM", agent_role="qa")
assert passed is not None
assert passed.active_claimant_id is None
qa_agent = await db_session.get(AgentTable, qa_id)
assert qa_agent is not None
assert qa_agent.current_task_id is None
doc = AgentTable(
id=uuid4(),
name="Doc",
@@ -1093,6 +1168,10 @@ async def test_pass_qa_clears_active_claimant_for_doc_claim(
claimed = await svc.doc_claim(doc.id, task.id)
assert claimed is not None
assert to_uuid(claimed.active_claimant_id) == doc.id
doc_agent = await db_session.get(AgentTable, doc.id)
assert doc_agent is not None
assert doc_agent.status == AgentStatus.ACTIVE
assert doc_agent.current_task_id == task.id
@pytest.mark.asyncio
@@ -1106,13 +1185,15 @@ async def test_fail_qa_clears_active_claimant(
qa_id = task_setup["agent_id"]
task = await svc.create(_req(task_setup))
task.status = TaskStatus.AWAITING_QA
task.assigned_to = qa_id
task.claimed_by = qa_id
task.active_claimant_id = qa_id
await db_session.flush()
assert await svc.qa_claim(qa_id, task.id) is not None
failed = await svc.fail_qa(task.id, notes="please fix X")
assert failed is not None
assert failed.active_claimant_id is None
# fail_qa releases the QA agent's fleet marker too.
qa_agent = await db_session.get(AgentTable, qa_id)
assert qa_agent is not None
assert qa_agent.current_task_id is None
# ---------------------------------------------------------------------------
@@ -1368,6 +1449,70 @@ async def test_claim_pending_with_unmet_dependency_returns_none(
assert claimed.status == TaskStatus.CLAIMED
@pytest.mark.asyncio
async def test_claim_sets_agent_active_and_current_task(
task_setup: dict, db_session: AsyncSession
) -> None:
"""_finalize_claim is the one production chokepoint every claim verb
routes through — before this fix, nothing ever wrote agent.status=ACTIVE
or current_task_id, so the fleet/Today-brief breakdown could never show
a real "active" agent or populate "working[]"."""
svc = task_setup["svc"]
task = await svc.create(_req(task_setup))
task.branch_name = "feature/backend/abcd1234"
await db_session.flush()
claimed = await svc.claim(task.id, task_setup["agent_id"])
assert claimed is not None
agent = await db_session.get(AgentTable, task_setup["agent_id"])
assert agent is not None
assert agent.status == AgentStatus.ACTIVE
assert agent.current_task_id == task.id
@pytest.mark.asyncio
async def test_mark_agent_idle_clears_current_task(
task_setup: dict, db_session: AsyncSession
) -> None:
"""Idling an agent must clear current_task_id — otherwise it keeps
reporting the last-claimed task as "currently working" forever, since
nothing else ever clears the column."""
svc = task_setup["svc"]
task = await svc.create(_req(task_setup))
task.branch_name = "feature/backend/abcd1234"
await db_session.flush()
await svc.claim(task.id, task_setup["agent_id"])
await svc.mark_agent_idle(task_setup["agent_id"])
agent = await db_session.get(AgentTable, task_setup["agent_id"])
assert agent is not None
assert agent.status == AgentStatus.IDLE
assert agent.current_task_id is None
@pytest.mark.asyncio
async def test_unclaim_for_agent_clears_current_task(
task_setup: dict, db_session: AsyncSession
) -> None:
"""A voluntary unclaim releases the claim marker on the AGENT too, not
just the task — otherwise the fleet keeps showing the agent as working
on a task it just gave up."""
svc = task_setup["svc"]
task = await svc.create(_req(task_setup))
task.branch_name = "feature/backend/abcd1234"
await db_session.flush()
await svc.claim(task.id, task_setup["agent_id"])
result = await svc.unclaim_for_agent(task.id, task_setup["agent_id"])
assert result is not None
agent = await db_session.get(AgentTable, task_setup["agent_id"])
assert agent is not None
assert agent.current_task_id is None
# ---------------------------------------------------------------------------
# Sequence claim guardrail (CEO directive: sequence is the bar, independent
# of dependency_ids — see _claim_blocked_by_sequence).
@@ -1900,6 +2045,33 @@ async def test_unclaim_for_reaper_resets(
assert refreshed.claimed_by == task_setup["agent_id"]
@pytest.mark.asyncio
async def test_unclaim_for_reaper_idles_the_provably_dead_holder(
task_setup: dict, db_session: AsyncSession
) -> None:
"""The reaper's holder is provably dead (heartbeat past TTL) — it must
stop reporting ACTIVE with a stale current_task_id, or the fleet keeps
showing a dead agent as "working" until the eventual re-claim."""
svc = task_setup["svc"]
agent = await db_session.get(AgentTable, task_setup["agent_id"])
assert agent is not None
task = await svc.create(_req(task_setup))
task.status = TaskStatus.CLAIMED
task.assigned_to = task_setup["agent_id"]
task.claimed_by = task_setup["agent_id"]
task.active_claimant_id = task_setup["agent_id"]
agent.status = AgentStatus.ACTIVE
agent.current_task_id = task.id
await db_session.flush()
await svc.unclaim_for_reaper(task.id)
refreshed_agent = await db_session.get(AgentTable, task_setup["agent_id"])
assert refreshed_agent is not None
assert refreshed_agent.status == AgentStatus.IDLE
assert refreshed_agent.current_task_id is None
# ---------------------------------------------------------------------------
# resume_for_agent
# ---------------------------------------------------------------------------
@@ -2100,6 +2272,11 @@ async def test_pr_gate_claim_allows_first_reviewer_when_pm_owns_root(
assert task.active_claimant_id == reviewer.id
assert task.claimed_by == reviewer.id
assert task.assigned_to == reviewer.id
# pr_gate_claim (via _qa_or_doc_claim) flips the reviewer's fleet marker.
reviewer_row = await db_session.get(AgentTable, reviewer.id)
assert reviewer_row is not None
assert reviewer_row.status == AgentStatus.ACTIVE
assert reviewer_row.current_task_id == task.id
@pytest.mark.asyncio
@@ -2151,6 +2151,11 @@ async def test_docs_complete_advance_clears_stale_documenter_claim(
task.pr_number = 1
task.pr_url = "u"
task.pr_created = True
# Simulate the documenter having genuinely claimed it (what
# _qa_or_doc_claim's own ACTIVE-marking would have set).
documenter_agent = await db_session.get(AgentTable, documenter_id)
assert documenter_agent is not None
documenter_agent.current_task_id = task.id
await db_session.flush()
out = await svc.docs_complete(task.id, doc_notes="documented all flows")
@@ -2162,6 +2167,15 @@ async def test_docs_complete_advance_clears_stale_documenter_claim(
assert out.claimed_by == pm_agent.id
assert out.active_claimant_id == pm_agent.id
assert out.claimed_by != documenter_id
# The outgoing documenter's fleet marker is released...
documenter_agent = await db_session.get(AgentTable, documenter_id)
assert documenter_agent is not None
assert documenter_agent.current_task_id is None
# ...but the PM is NOT marked active by this pre-assignment — the PM
# hasn't actually claimed (spawned) yet, only claim() does that.
pm_row = await db_session.get(AgentTable, pm_agent.id)
assert pm_row is not None
assert pm_row.current_task_id is None
assert out.active_claimant_id != documenter_id
+99 -8
View File
@@ -22,6 +22,7 @@ from roboco.billing.pricing import (
calculate_cost,
calculate_cost_result,
input_price_per_million,
is_ollama_cloud_model,
)
# ---------------------------------------------------------------------------
@@ -68,6 +69,14 @@ _CODEX_OUTPUT = 14.00
_CODEX_CACHE_READ = 0.175
_CODEX_CACHE_WRITE = 1.75
# Z.ai GLM-5.2 — priced non-Anthropic (Ollama Cloud's `glm-5.2:cloud` tag,
# subscription-billed but attributed at the API-equivalent rate). Source:
# https://docs.z.ai/guides/overview/pricing (fetched 2026-07-23).
_GLM_INPUT = 1.40
_GLM_OUTPUT = 4.40
_GLM_CACHE_READ = 0.26
_GLM_CACHE_WRITE = 1.40
# Tolerance for floating-point comparisons
_TOL = 1e-4
@@ -386,6 +395,57 @@ class TestCodexTier:
assert _CODEX_OUTPUT > _CODEX_INPUT
# ---------------------------------------------------------------------------
# GLM-5.2 tier (Ollama Cloud — priced non-Anthropic, grounded in a citable
# published rate; see the module's pricing-table comment for the source).
# ---------------------------------------------------------------------------
class TestGlmTier:
"""glm-5.2:cloud pricing — Ollama Cloud, priced like grok-build/codex."""
def test_input_only(self) -> None:
cost = calculate_cost("glm-5.2:cloud", tokens_input=_M, tokens_output=0)
assert abs(cost - _GLM_INPUT) < _TOL
def test_output_only(self) -> None:
cost = calculate_cost("glm-5.2:cloud", tokens_input=0, tokens_output=_M)
assert abs(cost - _GLM_OUTPUT) < _TOL
def test_cached_input(self) -> None:
cost = calculate_cost(
"glm-5.2:cloud", tokens_input=0, tokens_output=0, tokens_cache_read=_M
)
assert abs(cost - _GLM_CACHE_READ) < _TOL
def test_cache_write(self) -> None:
cost = calculate_cost(
"glm-5.2:cloud", tokens_input=0, tokens_output=0, tokens_cache_write=_M
)
assert abs(cost - _GLM_CACHE_WRITE) < _TOL
def test_all_token_types(self) -> None:
cost = calculate_cost(
"glm-5.2:cloud",
tokens_input=_M,
tokens_output=_M,
tokens_cache_read=_M,
tokens_cache_write=_M,
)
expected = _GLM_INPUT + _GLM_OUTPUT + _GLM_CACHE_READ + _GLM_CACHE_WRITE
assert abs(cost - expected) < _TOL
def test_glm_is_not_treated_as_anthropic(self) -> None:
assert _is_anthropic_model("glm-5.2:cloud") is False
assert calculate_cost("glm-5.2:cloud", tokens_input=_M, tokens_output=0) > 0.0
def test_bare_glm_tag_without_cloud_suffix_still_prices(self) -> None:
"""The fragment match is on 'glm-5.2', independent of the ':cloud'
tag suffix a differently-tagged variant still resolves."""
cost = calculate_cost("glm-5.2", tokens_input=_M, tokens_output=0)
assert abs(cost - _GLM_INPUT) < _TOL
# ---------------------------------------------------------------------------
# Unknown / edge cases — must return 0.0 without raising
# ---------------------------------------------------------------------------
@@ -477,16 +537,23 @@ class TestSubstringMatchPriority:
class TestProviderAwareness:
"""Non-Anthropic models (local Ollama / Ollama Cloud) cost 0.0 per token."""
"""Genuinely-free local Ollama costs 0.0 per token; an ungrounded Ollama
Cloud model also costs 0.0 (we have no rate for it see
``is_ollama_cloud_model`` for the caller-side distinction from "free"). A
GROUNDED Ollama Cloud model (glm-5.2) is priced for real see
``TestGlmTier``."""
def test_ollama_prefixed_model_returns_zero(self) -> None:
"""Self-hosted Ollama models (``ollama/`` prefix) have no API cost."""
cost = calculate_cost("ollama/llama3", tokens_input=_M, tokens_output=_M)
assert cost == _ZERO_COST
def test_ollama_cloud_model_returns_zero(self) -> None:
"""Ollama Cloud (``:cloud`` tag) is subscription-billed, not per token."""
cost = calculate_cost("glm-5.2:cloud", tokens_input=_M, tokens_output=_M)
def test_ungrounded_ollama_cloud_model_returns_zero(self) -> None:
"""An Ollama Cloud (``:cloud`` tag) model with no table entry has no
rate to price from still 0.0 (not "unpriced"; see TestCostResult)."""
cost = calculate_cost(
"some-future-model:cloud", tokens_input=_M, tokens_output=_M
)
assert cost == _ZERO_COST
def test_bare_local_model_returns_zero(self) -> None:
@@ -589,6 +656,25 @@ def test_sonnet5_promo_active_on_or_before_2026_08_31(
)
class TestIsOllamaCloudModel:
"""The ':cloud' tag convention, shared by pricing.py's own attribution
logic and external callers (the TG cockpit's spend label)."""
def test_cloud_tagged_model_is_cloud(self) -> None:
assert is_ollama_cloud_model("glm-5.2:cloud") is True
assert is_ollama_cloud_model("SOME-MODEL:CLOUD") is True
def test_local_model_is_not_cloud(self) -> None:
assert is_ollama_cloud_model("ollama/llama3") is False
assert is_ollama_cloud_model("qwen3-embedding:0.6b") is False
def test_anthropic_model_is_not_cloud(self) -> None:
assert is_ollama_cloud_model("claude-sonnet-5") is False
def test_empty_string_is_not_cloud(self) -> None:
assert is_ollama_cloud_model("") is False
def test_sonnet5_reverts_to_list_rate_after_2026_08_31(
monkeypatch: pytest.MonkeyPatch,
) -> None:
@@ -635,12 +721,17 @@ class TestInputPricePerMillion:
)
def test_unpriced_non_anthropic_model_is_free_tier(self) -> None:
"""A self-hosted / Ollama Cloud model has no per-token rate — treated
as the cheapest possible tier, so it can never be rejected as
"costlier" by the downgrade-only policy."""
assert input_price_per_million("glm-5.2:cloud") == 0.0
"""A self-hosted / ungrounded-Ollama-Cloud model has no per-token
rate treated as the cheapest possible tier, so it can never be
rejected as "costlier" by the downgrade-only policy."""
assert input_price_per_million("some-future-model:cloud") == 0.0
assert input_price_per_million("my-custom-self-hosted-model:7b") == 0.0
def test_grounded_ollama_cloud_model_has_real_rate(self) -> None:
"""GLM-5.2 is now grounded in a real published rate, unlike an
unpriced Ollama Cloud model."""
assert input_price_per_million("glm-5.2:cloud") == _GLM_INPUT
def test_empty_model_returns_zero(self) -> None:
assert input_price_per_million("") == 0.0
@@ -0,0 +1,94 @@
"""display_time — pure display-timezone day bucketing, incl. DST boundaries."""
from __future__ import annotations
from datetime import UTC, date, datetime, timedelta
from roboco.foundation.policy.display_time import (
day_bounds_utc,
local_date,
resolve_zone,
trailing_dates,
)
class TestResolveZone:
def test_known_zone(self) -> None:
assert resolve_zone("Europe/Berlin").key == "Europe/Berlin"
def test_utc_default(self) -> None:
assert resolve_zone("UTC").key == "UTC"
def test_unknown_zone_falls_back_to_utc(self) -> None:
assert resolve_zone("Not/AZone").key == "UTC"
class TestLocalDate:
def test_utc_noop(self) -> None:
instant = datetime(2026, 7, 23, 10, 0, tzinfo=UTC)
assert local_date(instant, "UTC") == date(2026, 7, 23)
def test_gmt_plus_2_evening_utc_is_next_day_local(self) -> None:
"""22:30 UTC on the 22nd is 00:30 the NEXT day in GMT+2 — the exact
'CEO's evening activity lands on the wrong display day' bug."""
instant = datetime(2026, 7, 22, 22, 30, tzinfo=UTC)
assert local_date(instant, "Europe/Berlin") == date(2026, 7, 23)
def test_gmt_plus_2_early_morning_utc_is_prior_day_local(self) -> None:
# No — GMT+2 is AHEAD of UTC, so early UTC morning is still the same
# local day; use a clearly-behind zone for the "prior day" case.
instant = datetime(2026, 7, 23, 2, 0, tzinfo=UTC)
assert local_date(instant, "America/Los_Angeles") == date(2026, 7, 22)
class TestTrailingDates:
def test_seven_days_oldest_to_today(self) -> None:
now = datetime(2026, 7, 23, 12, 0, tzinfo=UTC)
dates = trailing_dates("UTC", 7, now=now)
assert len(dates) == 7 # noqa: PLR2004
assert dates[-1] == date(2026, 7, 23)
assert dates[0] == date(2026, 7, 17)
assert dates == sorted(dates)
def test_timezone_shifts_which_day_is_today(self) -> None:
"""23:00 UTC on the 22nd is already the 23rd in Europe/Berlin."""
now = datetime(2026, 7, 22, 23, 0, tzinfo=UTC)
assert trailing_dates("UTC", 1, now=now) == [date(2026, 7, 22)]
assert trailing_dates("Europe/Berlin", 1, now=now) == [date(2026, 7, 23)]
class TestDayBoundsUtc:
def test_utc_day_is_exactly_24h(self) -> None:
start, end = day_bounds_utc("UTC", date(2026, 7, 23))
assert start == datetime(2026, 7, 23, 0, 0, tzinfo=UTC)
assert end == datetime(2026, 7, 24, 0, 0, tzinfo=UTC)
assert (end - start).total_seconds() == 24 * 3600
def test_gmt_plus_2_day_bounds(self) -> None:
start, end = day_bounds_utc("Europe/Berlin", date(2026, 7, 23))
# Summer time (CEST, UTC+2): local midnight is 22:00 UTC the day before.
assert start == datetime(2026, 7, 22, 22, 0, tzinfo=UTC)
assert end == datetime(2026, 7, 23, 22, 0, tzinfo=UTC)
def test_dst_spring_forward_day_is_23_hours(self) -> None:
"""Europe/Berlin springs forward on the last Sunday of March —
2026-03-29 02:00 CET -> 03:00 CEST so that local day is only 23h
of real UTC time, not 24."""
start, end = day_bounds_utc("Europe/Berlin", date(2026, 3, 29))
assert (end - start).total_seconds() == 23 * 3600
def test_dst_fall_back_day_is_25_hours(self) -> None:
"""Europe/Berlin falls back on the last Sunday of October —
2026-10-25 03:00 CEST -> 02:00 CET so that local day is 25h."""
start, end = day_bounds_utc("Europe/Berlin", date(2026, 10, 25))
assert (end - start).total_seconds() == 25 * 3600
def test_an_instant_at_start_falls_in_this_day(self) -> None:
start, _ = day_bounds_utc("Europe/Berlin", date(2026, 7, 23))
assert local_date(start, "Europe/Berlin") == date(2026, 7, 23)
def test_an_instant_just_before_end_falls_in_this_day(self) -> None:
_, end = day_bounds_utc("Europe/Berlin", date(2026, 7, 23))
assert local_date(end - timedelta(seconds=1), "Europe/Berlin") == date(
2026, 7, 23
)
+38 -1
View File
@@ -8,11 +8,12 @@ agents_config data) and the reaper-safe service write
from __future__ import annotations
from typing import cast
from unittest.mock import AsyncMock, MagicMock
from uuid import UUID, uuid4
import pytest
from roboco.models.base import TaskStatus
from roboco.models.base import AgentStatus, TaskStatus
from roboco.seeds.initial_data import AGENT_UUIDS
from roboco.services.gateway.choreographer._impl import Choreographer
from roboco.services.task import TaskService
@@ -98,6 +99,12 @@ def _build_task(**over: object) -> MagicMock:
def _service() -> TaskService:
session = MagicMock()
session.flush = AsyncMock()
# reassign_active_claim now retargets the agent-side claim marker
# (_retarget_agent_claim), which reads old/new agent rows via
# session.get — default to "no matching row" so tests that don't care
# about the agent side effect stay a no-op there, same as before this
# write existed.
session.get = AsyncMock(return_value=None)
return TaskService(session)
@@ -123,3 +130,33 @@ async def test_reassign_active_claim_refuses_non_active_status() -> None:
svc = _service()
object.__setattr__(svc, "get", AsyncMock(return_value=task))
assert await svc.reassign_active_claim(task.id, uuid4()) is None
@pytest.mark.asyncio
async def test_reassign_active_claim_retargets_agent_active_marker() -> None:
"""The old claimant's ACTIVE/current_task_id marker must move to the new
claimant otherwise the fleet keeps showing the SUPERSEDED agent as
working on this task, and never shows the real new claimant as active."""
old_id, new_id = uuid4(), uuid4()
task = _build_task(status=TaskStatus.IN_PROGRESS, claimed_by=old_id)
old_agent = MagicMock(status=AgentStatus.ACTIVE, current_task_id=task.id)
new_agent = MagicMock(status=AgentStatus.IDLE, current_task_id=None)
svc = _service()
object.__setattr__(svc, "get", AsyncMock(return_value=task))
async def _fake_get(_model: object, agent_id: object) -> object:
if agent_id == old_id:
return old_agent
if agent_id == new_id:
return new_agent
return None
cast("MagicMock", svc.session).get = AsyncMock(side_effect=_fake_get)
result = await svc.reassign_active_claim(task.id, new_id)
assert result is task
assert old_agent.status == AgentStatus.IDLE
assert old_agent.current_task_id is None
assert new_agent.status == AgentStatus.ACTIVE
assert new_agent.current_task_id == task.id
+8 -10
View File
@@ -236,16 +236,16 @@ async def test_handle_breach_skips_a_task_that_already_moved_on() -> None:
# ---------------------------------------------------------------------------
# _task_budget_breach: cap resolution (null -> TaskType default) + spend sum
# _task_budget_breach: explicit-input-only cap (null budget = never a breach)
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_breach_falls_back_to_tasktype_default_when_budget_null() -> None:
"""Cap resolution (task.budget_usd null -> TaskType default) and spend
both delegate to TaskService now (task_spend_usd's own open-session
pricing is covered directly by its shared implementation see
test_project_month_spend_usd_db.py's real-DB open-session case)."""
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(
@@ -262,10 +262,8 @@ async def test_breach_falls_back_to_tasktype_default_when_budget_null() -> None:
):
breach = await orch._task_budget_breach(task_id)
assert breach is not None
cap_usd, spend_usd = breach
assert cap_usd == 1.0 # TASK_TYPE_DEFAULT_BUDGET_USD[DOCUMENTATION]
assert spend_usd == _MOCK_TASK_SPEND_USD
assert breach is None
task_svc.task_spend_usd.assert_not_awaited()
@pytest.mark.asyncio
@@ -201,6 +201,9 @@ async def test_fail_qa_emits_auditor_alert(
"""``fail_qa`` calls the auditor rework producer with QA attribution."""
session = MagicMock()
session.flush = AsyncMock()
# fail_qa releases the QA agent's fleet marker via session.get — default
# to "no matching row" for a test that doesn't care about that side effect.
session.get = AsyncMock(return_value=None)
task = _mock_task(status=TaskStatus.AWAITING_QA)
task.orchestration_markers = {"original_developer": str(uuid4())}
@@ -235,6 +238,9 @@ async def test_pr_fail_emits_auditor_alert(
"""``pr_fail`` calls the auditor rework producer with reviewer attribution."""
session = MagicMock()
session.flush = AsyncMock()
# pr_fail releases the reviewer's fleet marker via session.get — default
# to "no matching row" for a test that doesn't care about that side effect.
session.get = AsyncMock(return_value=None)
reviewer_id = uuid4()
pm_id = uuid4()
task = _mock_task(status=TaskStatus.AWAITING_PR_REVIEW)
@@ -34,6 +34,11 @@ def _bind(svc: TaskService, name: str, value: object) -> None:
def _service() -> TaskService:
session = MagicMock()
session.flush = AsyncMock()
# reassign_active_claim now retargets the agent-side claim marker
# (_retarget_agent_claim), which reads agent rows via session.get —
# default to "no matching row" so tests that don't care about the
# agent side effect stay a no-op there.
session.get = AsyncMock(return_value=None)
return TaskService(session)
@@ -611,6 +616,9 @@ async def test_unblock_with_restore_emits_audit_event() -> None:
``AuditLogTable`` added to the session."""
session = MagicMock()
session.flush = AsyncMock()
# An IN_PROGRESS restore now looks up the restored owner via session.get
# to flip its ACTIVE marker — default to "no matching row".
session.get = AsyncMock(return_value=None)
added: list[object] = []
session.add.side_effect = added.append
svc = TaskService(session)
+10
View File
@@ -253,6 +253,12 @@ async def test_pr_review_claim_and_complete(db_session: AsyncSession) -> None:
assert claimed.active_claimant_id is not None
assert UUID(str(claimed.active_claimant_id)) == reviewer_id
assert claimed.claimed_at is not None
# The external-PR-review claim chokepoint flips the reviewer's fleet
# marker too — otherwise pr_reviewer agents never show as active.
reviewer_row = await db_session.get(AgentTable, reviewer_id)
assert reviewer_row is not None
assert reviewer_row.status == AgentStatus.ACTIVE
assert reviewer_row.current_task_id == task_id
# Re-claiming a non-pending task is a no-op.
assert await svc.pr_review_claim(reviewer_id, task_id) is None
@@ -269,6 +275,10 @@ async def test_pr_review_claim_and_complete(db_session: AsyncSession) -> None:
assert done.claimed_by is None
# Single-claimant lock cleared on completion (the review hand-off is done).
assert done.active_claimant_id is None
# complete_review releases the reviewer's fleet marker too.
reviewer_row = await db_session.get(AgentTable, reviewer_id)
assert reviewer_row is not None
assert reviewer_row.current_task_id is None
# Re-completing a completed task is a no-op.
assert await svc.complete_review(reviewer_id, task_id) is None
+92 -9
View File
@@ -567,12 +567,15 @@ async def test_wire_sibling_collision_dag_notifies_only_for_new_edges() -> None:
@pytest.mark.asyncio
async def test_mark_agent_idle_sets_status_idle() -> None:
agent = MagicMock(id=uuid4(), status=AgentStatus.ACTIVE)
agent = MagicMock(id=uuid4(), status=AgentStatus.ACTIVE, current_task_id=uuid4())
result = MagicMock()
result.scalar_one_or_none.return_value = agent
svc = _service_with(result)
await svc.mark_agent_idle(agent.id)
assert agent.status == AgentStatus.IDLE
# Otherwise the agent keeps reporting its last task as "currently
# working" forever — nothing else ever clears this column.
assert agent.current_task_id is None
# ---------------------------------------------------------------------------
@@ -587,6 +590,10 @@ async def test_qa_claim_sets_assignment_on_awaiting_qa() -> None:
result.scalar_one_or_none.return_value = task
session = MagicMock(flush=AsyncMock())
session.execute = AsyncMock(return_value=result)
# _qa_or_doc_claim now looks up the claiming agent via session.get to
# flip its ACTIVE marker — default to "no matching row" for a test that
# doesn't care about that side effect.
session.get = AsyncMock(return_value=None)
svc = TaskService(session)
qa_id = uuid4()
out = await svc.qa_claim(qa_id, task.id)
@@ -617,6 +624,7 @@ async def test_doc_claim_sets_assignment_on_awaiting_documentation() -> None:
result.scalar_one_or_none.return_value = task
session = MagicMock(flush=AsyncMock())
session.execute = AsyncMock(return_value=result)
session.get = AsyncMock(return_value=None)
svc = TaskService(session)
doc_id = uuid4()
out = await svc.doc_claim(doc_id, task.id)
@@ -677,7 +685,10 @@ async def test_unblock_with_restore_returns_to_pre_block_state() -> None:
blocker_resolver_type=BlockerResolverType.AGENT,
blocker_raised_by=pre_assignee,
)
svc = TaskService(MagicMock(flush=AsyncMock()))
# An IN_PROGRESS restore now looks up the restored owner via session.get
# to flip its ACTIVE marker — default to "no matching row".
session = MagicMock(flush=AsyncMock(), get=AsyncMock(return_value=None))
svc = TaskService(session)
_bind(svc, "get", AsyncMock(return_value=task))
out = await svc.unblock_with_restore(uuid4(), task.id, restore=True)
assert out is task
@@ -721,13 +732,17 @@ async def test_unblock_no_branch_returns_to_pending() -> None:
task = _build_task(
status=TaskStatus.BLOCKED, branch_name=None, blocker_raised_by=raiser
)
svc = TaskService(MagicMock(flush=AsyncMock()))
session = MagicMock(flush=AsyncMock())
svc = TaskService(session)
_bind(svc, "get", AsyncMock(return_value=task))
_bind(svc, "_index_lifecycle_event_background", AsyncMock())
out = await svc.unblock(task.id)
assert out is task
assert task.status == TaskStatus.PENDING
assert task.assigned_to == raiser
# A PENDING restore is NOT a resume — the owner isn't marked active here;
# a fresh claim() is what actually resumes it, so no agent lookup runs.
session.get.assert_not_called()
@pytest.mark.asyncio
@@ -747,7 +762,10 @@ async def test_admin_set_status_out_of_blocked_restores_pre_block_owner() -> Non
pre_block_state="in_progress",
pre_block_assignee=dev,
)
svc = TaskService(MagicMock(flush=AsyncMock()))
# An IN_PROGRESS restore now looks up the restored owner (dev) via
# session.get to flip its ACTIVE marker.
session = MagicMock(flush=AsyncMock(), get=AsyncMock(return_value=None))
svc = TaskService(session)
_bind(svc, "get", AsyncMock(return_value=task))
out = await svc.admin_set_status(task.id, TaskStatus.IN_PROGRESS)
assert out is task
@@ -791,7 +809,10 @@ async def test_admin_set_status_into_review_queue_clears_active_claimant() -> No
claimed_by=dev,
active_claimant_id=dev,
)
svc = TaskService(MagicMock(flush=AsyncMock()))
# Clearing the stale claimant now looks it up via session.get to release
# its ACTIVE marker too.
session = MagicMock(flush=AsyncMock(), get=AsyncMock(return_value=None))
svc = TaskService(session)
_bind(svc, "get", AsyncMock(return_value=task))
out = await svc.admin_set_status(task.id, TaskStatus.AWAITING_QA)
assert out is task
@@ -943,7 +964,10 @@ async def test_admin_set_status_blocked_to_review_state_clears_claim() -> None:
pre_block_state="awaiting_pm_review",
pre_block_assignee=pm,
)
svc = TaskService(MagicMock(flush=AsyncMock()))
# Clearing the stale claim now looks it up via session.get to release
# its ACTIVE marker too.
session = MagicMock(flush=AsyncMock(), get=AsyncMock(return_value=None))
svc = TaskService(session)
_bind(svc, "get", AsyncMock(return_value=task))
out = await svc.admin_set_status(task.id, TaskStatus.AWAITING_PM_REVIEW)
assert out is task
@@ -970,7 +994,10 @@ async def test_admin_set_status_blocked_to_needs_revision_clears_claim() -> None
pre_block_state="awaiting_pm_review",
pre_block_assignee=pm,
)
svc = TaskService(MagicMock(flush=AsyncMock()))
# Clearing the stale claim now looks it up via session.get to release
# its ACTIVE marker too.
session = MagicMock(flush=AsyncMock(), get=AsyncMock(return_value=None))
svc = TaskService(session)
_bind(svc, "get", AsyncMock(return_value=task))
out = await svc.admin_set_status(task.id, TaskStatus.NEEDS_REVISION)
assert out is task
@@ -1085,7 +1112,10 @@ async def test_admin_set_status_pre_block_restore_syncs_active_claimant() -> Non
pre_block_state="in_progress",
pre_block_assignee=dev,
)
svc = TaskService(MagicMock(flush=AsyncMock()))
# An IN_PROGRESS restore now looks up the restored owner (dev) via
# session.get to flip its ACTIVE marker.
session = MagicMock(flush=AsyncMock(), get=AsyncMock(return_value=None))
svc = TaskService(session)
_bind(svc, "get", AsyncMock(return_value=task))
out = await svc.admin_set_status(task.id, TaskStatus.IN_PROGRESS)
assert out is task
@@ -1566,7 +1596,10 @@ async def test_unblock_with_branch_resumes_in_progress() -> None:
branch_name="feature/backend/abc12345",
blocker_raised_by=uuid4(),
)
svc = TaskService(MagicMock(flush=AsyncMock()))
# Resuming IN_PROGRESS now looks up the restored owner via session.get
# to flip its ACTIVE marker.
session = MagicMock(flush=AsyncMock(), get=AsyncMock(return_value=None))
svc = TaskService(session)
_bind(svc, "get", AsyncMock(return_value=task))
_bind(svc, "_index_lifecycle_event_background", AsyncMock())
out = await svc.unblock(task.id)
@@ -1809,6 +1842,56 @@ async def test_finalize_claim_rollback_emits_reversal_audit() -> None:
assert {"from": "claimed", "to": "pending"} in audit_calls
@pytest.mark.asyncio
async def test_finalize_claim_sets_agent_active_then_rolls_back_on_failure() -> None:
"""_finalize_claim must flip agent.status/current_task_id to ACTIVE/this
task BEFORE the branch step runs (previously nothing ever wrote these
fields at all the fleet-status bug), and roll them back to their
pre-claim values on a branch-creation failure, same as the task fields.
"""
session = MagicMock()
session.flush = AsyncMock()
svc = TaskService(session)
task = _build_task(
status=TaskStatus.PENDING,
branch_name=None,
project_id=uuid4(),
product_id=None,
batch_id=None,
parent_task_id=None,
cell_projects=[],
pr_created=False,
pr_number=None,
)
agent = MagicMock(
id=uuid4(),
role=AgentRole.DEVELOPER,
status=AgentStatus.IDLE,
current_task_id=None,
)
_bind(svc, "_emit_status_transition_audit", MagicMock())
captured: dict[str, object] = {}
async def _boom(_task: object, _agent_id: object) -> str:
captured["status"] = agent.status
captured["current_task_id"] = agent.current_task_id
raise RuntimeError("branch boom")
_bind(svc, "_ensure_branch_for_task", _boom)
with pytest.raises(RuntimeError, match="branch boom"):
await svc._finalize_claim(task, agent, agent.id)
# Set to ACTIVE/this-task before the branch step ran...
assert captured["status"] == AgentStatus.ACTIVE
assert captured["current_task_id"] == task.id
# ...and rolled back to the pre-claim values once branch creation failed.
assert agent.status == AgentStatus.IDLE
assert agent.current_task_id is None
@pytest.mark.asyncio
async def test_emit_status_transition_audit_writes_in_session_atomically() -> None:
"""The status-transition audit row is written into the CALLER's session (same
@@ -30,6 +30,11 @@ def _bind(svc: TaskService, name: str, value: object) -> None:
def _service() -> TaskService:
session = MagicMock()
session.flush = AsyncMock()
# reassign_active_claim now retargets the agent-side claim marker
# (_retarget_agent_claim), which reads agent rows via session.get —
# default to "no matching row" so tests that don't care about the
# agent side effect stay a no-op there.
session.get = AsyncMock(return_value=None)
return TaskService(session)
@@ -96,6 +96,33 @@ async def test_run_cycle_syncs_commands_exactly_once(
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_render_status_shares_fleet_derivation_with_render_agents(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""The /status fleet line must come from the SAME by_status breakdown
/agents and the Today brief use (TgCockpitService.fleet) a second,
independent agent-status query previously could (and did) disagree."""
fleet: dict[str, Any] = {
"total": 27,
"by_status": {"active": 3, "idle": 20, "offline": 4},
"working": [],
}
cockpit = MagicMock(fleet=AsyncMock(return_value=fleet))
monkeypatch.setattr(ti, "get_tg_cockpit_service", lambda _s: cockpit)
tasks = MagicMock(
count_by_status=AsyncMock(return_value={"in_progress": 5, "pending": 2})
)
monkeypatch.setattr(ti, "get_task_service", lambda _s: tasks)
text = await _engine()._render_status()
assert "3</b> active" in text
assert "20</b> idle" in text
assert "4</b> offline" in text
assert "in_progress" in text
@pytest.mark.asyncio
async def test_render_agents_lists_working_agents(
monkeypatch: pytest.MonkeyPatch,
@@ -124,12 +151,16 @@ async def test_render_agents_lists_working_agents(
async def test_render_usage_formats_today_summary(
monkeypatch: pytest.MonkeyPatch,
) -> None:
usage = MagicMock(
get_today_summary=AsyncMock(
return_value={"tokens_today": 2_400_000, "cost_today_usd": 18.7}
cockpit = MagicMock(
today_spend=AsyncMock(
return_value={
"tokens_today": 2_400_000,
"cost_today_usd": 18.7,
"subscription_billed": False,
}
)
)
monkeypatch.setattr(ti, "get_usage_service", lambda _s: usage)
monkeypatch.setattr(ti, "get_tg_cockpit_service", lambda _s: cockpit)
text = await _engine()._render_usage()
@@ -137,6 +168,30 @@ async def test_render_usage_formats_today_summary(
assert "2,400,000 tokens" in text
@pytest.mark.asyncio
async def test_render_usage_labels_subscription_billed_spend(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""An untracked-subscription spend day (Ollama Cloud, no grounded rate)
must never render as a bare, misleading '$0.00'."""
cockpit = MagicMock(
today_spend=AsyncMock(
return_value={
"tokens_today": 456_221,
"cost_today_usd": 0.0,
"subscription_billed": True,
}
)
)
monkeypatch.setattr(ti, "get_tg_cockpit_service", lambda _s: cockpit)
text = await _engine()._render_usage()
assert "subscription (untracked)" in text
assert "456,221 tokens" in text
assert "$0.00" not in text
@pytest.mark.asyncio
async def test_render_blocked_sections_and_links(
monkeypatch: pytest.MonkeyPatch,
+131 -1
View File
@@ -9,12 +9,13 @@ assumed.
from __future__ import annotations
from datetime import UTC, date, datetime, timedelta
from typing import TYPE_CHECKING, cast
from uuid import uuid4
import pytest
from roboco.config import settings
from roboco.db.tables import AgentTable, TaskTable
from roboco.db.tables import AgentSpawnSessionTable, AgentTable, TaskTable
from roboco.foundation import identity as _foundation
from roboco.foundation.policy.content import markers
from roboco.models.base import (
@@ -43,6 +44,28 @@ SYSTEM_UUID = _foundation.AGENTS["system"].uuid
CI_WATCH_SOURCE = "ci_watch"
_COST_TOL = 0.005
_TOKENS_FLOOR = 1
def _spawn_session(
*, started_at: datetime, model: str, cost: float, tokens_input: int = 1000
) -> AgentSpawnSessionTable:
return AgentSpawnSessionTable(
id=uuid4(),
agent_slug=f"be-dev-{uuid4().hex[:6]}",
team="backend",
role="developer",
model=model,
task_id=None,
started_at=started_at,
ended_at=started_at + timedelta(minutes=5),
tokens_input=tokens_input,
tokens_output=0,
estimated_cost_usd=cost,
)
# 1 awaiting + 1 blocked + 4 held drafts (release/x/video/roadmap-item).
EXPECTED_NEEDS_YOU_TOTAL = 6
@@ -195,3 +218,110 @@ async def test_today_composes_needs_you_fleet_and_ship(
assert brief["ship"]["open_release_proposal"] is True
assert brief["ship"]["ci_fix_tasks"] == baseline["ship"]["ci_fix_tasks"] + 1
# ---------------------------------------------------------------------------
# Display-timezone bucketing (Issue 2) — "today" is display_timezone-aware,
# not always the server's UTC day. `_session_metrics_by_day` is called
# directly with an explicit historical `days` window so the test is fully
# deterministic (disconnected from the real "now").
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_session_metrics_by_day_buckets_by_display_timezone(
db_session: AsyncSession, monkeypatch: pytest.MonkeyPatch
) -> None:
"""23:30 UTC on the 15th is already 00:30 on the 16th in Europe/Berlin
(winter, CET = UTC+1) the exact 'evening activity lands on the wrong
display day' bug this fix targets."""
started = datetime(2026, 1, 15, 23, 30, tzinfo=UTC)
session_row = _spawn_session(started_at=started, model="claude-sonnet-5", cost=1.23)
db_session.add(session_row)
await db_session.flush()
svc = get_tg_cockpit_service(db_session)
monkeypatch.setattr(settings, "display_timezone", "UTC")
cost_utc, tokens_utc, _ = await svc._session_metrics_by_day([date(2026, 1, 15)])
assert cost_utc.get(date(2026, 1, 15), 0.0) >= _COST_TOL
assert tokens_utc.get(date(2026, 1, 15), 0) >= _TOKENS_FLOOR
monkeypatch.setattr(settings, "display_timezone", "Europe/Berlin")
cost_berlin, tokens_berlin, _ = await svc._session_metrics_by_day(
[date(2026, 1, 16)]
)
assert cost_berlin.get(date(2026, 1, 16), 0.0) >= _COST_TOL
assert tokens_berlin.get(date(2026, 1, 16), 0) >= _TOKENS_FLOOR
# And the SAME row must NOT double-count into the UTC calendar day under
# the Berlin bucketing — the 15th should now come up empty for this row.
cost_berlin_15, _, _ = await svc._session_metrics_by_day([date(2026, 1, 15)])
assert cost_berlin_15.get(date(2026, 1, 15), 0.0) < _COST_TOL
@pytest.mark.asyncio
async def test_window_dates_shifts_with_display_timezone(
monkeypatch: pytest.MonkeyPatch,
) -> None:
"""`_window_dates` (real 'now') must reflect the configured display
timezone, not always UTC."""
svc = get_tg_cockpit_service(cast("AsyncSession", None))
monkeypatch.setattr(settings, "display_timezone", "UTC")
utc_dates = svc._window_dates()
monkeypatch.setattr(settings, "display_timezone", "Pacific/Kiritimati")
# UTC+14 — the furthest-ahead real timezone; "today" there is never
# earlier, and is later whenever UTC hasn't crossed its own midnight yet.
kiritimati_dates = svc._window_dates()
assert kiritimati_dates[-1] >= utc_dates[-1]
# ---------------------------------------------------------------------------
# Ollama Cloud honesty-labeling (Issue 1) — an ungrounded ':cloud' model's $0
# is flagged subscription_billed, never rendered as a bare misleading "$0".
# ---------------------------------------------------------------------------
@pytest.mark.asyncio
async def test_today_spend_flags_ungrounded_ollama_cloud_as_subscription_billed(
db_session: AsyncSession,
) -> None:
now = datetime.now(UTC)
db_session.add(
_spawn_session(started_at=now, model="some-future-model:cloud", cost=0.0)
)
await db_session.flush()
summary = await get_tg_cockpit_service(db_session).today_spend()
assert summary["cost_today_usd"] == pytest.approx(0.0)
assert summary["subscription_billed"] is True
@pytest.mark.asyncio
async def test_today_spend_not_subscription_billed_when_priced(
db_session: AsyncSession,
) -> None:
"""A real per-token cost (even from a priced Ollama Cloud model like
GLM-5.2) is never mislabeled as an untracked subscription figure."""
now = datetime.now(UTC)
db_session.add(_spawn_session(started_at=now, model="glm-5.2:cloud", cost=2.5))
await db_session.flush()
summary = await get_tg_cockpit_service(db_session).today_spend()
assert summary["subscription_billed"] is False
@pytest.mark.asyncio
async def test_today_spend_not_subscription_billed_for_local_ollama(
db_session: AsyncSession,
) -> None:
"""A genuinely-free self-hosted model (no ':cloud' tag) at $0 is just
free, not an untracked subscription."""
now = datetime.now(UTC)
db_session.add(_spawn_session(started_at=now, model="ollama/llama3", cost=0.0))
await db_session.flush()
summary = await get_tg_cockpit_service(db_session).today_spend()
assert summary["subscription_billed"] is False
+21
View File
@@ -200,3 +200,24 @@ def test_resolve_uvicorn_loop_factory_uvloop_returns_new_event_loop() -> None:
assert isinstance(loop, uvloop.Loop)
finally:
loop.close()
# ---------------------------------------------------------------------------
# display_timezone — the TG cockpit's day-bucketing timezone (Issue 2)
# ---------------------------------------------------------------------------
def test_display_timezone_defaults_to_utc() -> None:
"""Default is a no-op for every deployment that doesn't set it."""
assert Settings().display_timezone == "UTC"
def test_display_timezone_accepts_valid_iana_name() -> None:
assert Settings(display_timezone="Europe/Berlin").display_timezone == (
"Europe/Berlin"
)
def test_display_timezone_rejects_unknown_name() -> None:
with pytest.raises(ValidationError):
Settings(display_timezone="Not/AZone")