Files
roboco/roboco/foundation/policy/display_time.py
T
a036c97985 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>
2026-07-23 21:09:50 +02:00

54 lines
2.2 KiB
Python

"""Display-timezone day bucketing — pure, DB-free.
DB storage stays UTC canonical everywhere; these functions only decide which
calendar day (in the operator's configured ``display_timezone``) a UTC
instant belongs to, for read-side "today" / trailing-window aggregation
(currently the Telegram cockpit's Today brief + bot commands). No writes, no
ORM, no settings import — callers pass the configured tz name in.
"""
from __future__ import annotations
from datetime import UTC, date, datetime, timedelta
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
def resolve_zone(tz_name: str) -> ZoneInfo:
"""``ZoneInfo`` for ``tz_name``, falling back to UTC on an unknown name.
Settings validation already rejects a bad name at load time; this is a
defensive fallback for a value that reached here some other way (e.g. a
stale env read before validation ran) rather than a 500.
"""
try:
return ZoneInfo(tz_name)
except (ZoneInfoNotFoundError, ValueError):
return ZoneInfo("UTC")
def local_date(instant: datetime, tz_name: str) -> date:
"""The calendar date ``instant`` (any tz-aware datetime) falls on in
``tz_name``."""
return instant.astimezone(resolve_zone(tz_name)).date()
def trailing_dates(
tz_name: str, days: int, *, now: datetime | None = None
) -> list[date]:
"""The last ``days`` calendar dates in ``tz_name``, oldest -> today
(inclusive of today)."""
today = local_date(now or datetime.now(UTC), tz_name)
return [today - timedelta(days=n) for n in reversed(range(days))]
def day_bounds_utc(tz_name: str, day: date) -> tuple[datetime, datetime]:
"""UTC ``[start, end)`` instants spanning ``day``'s midnight-to-midnight
window in ``tz_name`` — correct across a DST transition day (a spring-
forward day is a real 23h UTC span, fall-back a real 25h one) because
``ZoneInfo`` re-resolves the offset from the wall-clock fields at
``.astimezone()`` time rather than freezing it at construction."""
zone = resolve_zone(tz_name)
start_local = datetime(day.year, day.month, day.day, tzinfo=zone)
end_local = start_local + timedelta(days=1)
return start_local.astimezone(UTC), end_local.astimezone(UTC)