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