Files
roboco/alembic/versions/080_task_project_budgets.py
T
7c8453e210 feat(budgets): per-task and per-project cost budgets (flag-gated) (#654)
* fix(notifications): exponential backoff + CAS claim for expired-unacked re-escalation

The sweep re-escalated every expired unacked ack-required notification
on every ~60s tick, forever — the live incident: 3 fresh blocker
escalations + Telegram DMs per minute from a static stale pile. Now
each notification carries reescalation_count / last_reescalated_at /
reescalation_delivered_count (migration 079): first fire at expiry,
then doubling intervals from 1h capped at 24h, hard stop after
ROBOCO_NOTIFICATION_MAX_REESCALATIONS (default 5) with one permanent
log carrying attempts-vs-delivered so 'seen and ignored' is
distinguishable from 'route never worked'. The due/wait/capped decision
is a pure function in foundation/policy/communications.py.

Per adversarial review, the attempt slot is claimed by compare-and-set
(UPDATE ... WHERE reescalation_count = :n) BEFORE delivery — the
previous draft leaned on the 60s dedup window, which never engages for
BLOCKER_ESCALATION (_LOOP_PRONE_TYPES excludes it), so concurrent
sweeps would have double-delivered. A lost claim skips delivery
outright. Legacy rows read as count=0 and keep today's first-fire
semantics. 61 tests incl. a two-session CAS race and a real alembic
upgrade/downgrade round trip.

* feat(budgets): per-task and per-project cost budgets (flag-gated)

tasks.budget_usd + projects.monthly_budget_usd (migration 080, chained
on 079; adds ix_agent_spawn_sessions_task_id since both enforcement
seams filter on bare task_id). Behind ROBOCO_TASK_BUDGETS_ENABLED
(default off, feature-flags card) — verifiably inert when off.

Claim-time: a project-month-spend guard applies to WORK-STARTING claims
only (i_will_work_on / i_will_plan) — per adversarial review, review/
doc/gate/inbound-PR claims are exempt so in-flight work can always
finish reviewing and merging at cap. Spend counts closed sessions'
estimated_cost_usd PLUS open sessions priced live from token snapshots
(the original closed-only sum read parallel long sessions as $0).

Sweep-side: the existing budget sweep also prices the active task's
spend vs budget_usd (TaskType defaults when null); on breach the task
is BLOCKED (HUMAN resolver, budget marker) BEFORE the graceful stop so
the unclaim no-ops and the dispatcher never respawns onto it, and the
CEO notification names both recovery steps. unblock on a budget-blocked
task re-checks live spend and refuses while still over — no silent
re-breach loop. Panel: budget inputs in both dialogs (0 rejected — a
zero budget silently blocks everything), spend logic consolidated in
TaskService.task_spend_usd. 42 new tests incl. a real-DB spend-query
suite and a two-tick non-refire sweep test.

---------

Co-authored-by: Renn F <rennf93@users.noreply.github.com>
2026-07-23 00:06:14 +02:00

60 lines
2.2 KiB
Python

"""Per-task and per-project cost budgets (feature-flagged, default off).
``tasks.budget_usd`` caps one task's own accumulated agent-spawn spend;
``projects.monthly_budget_usd`` caps a project's calendar-month spend across
all its tasks. Both nullable and additive — null means "no cap" and is a pure
no-op regardless of ``ROBOCO_TASK_BUDGETS_ENABLED`` (the flag itself gates
whether the caps are ever consulted at all). Mirrors the ``ci_watch_enabled``
per-project opt-in shape (migration 048): the column exists unconditionally,
the feature flag decides whether anything reads it.
``ix_agent_spawn_sessions_task_id`` rides along: both the claim-time monthly-
spend query (``TaskService.project_month_spend_usd``) and the orchestrator's
per-tick task-spend sweep (``_task_spend_usd``) filter this table by
``task_id`` with no existing index — an unbounded per-row scan every minute
once the flag is armed. Added here rather than a separate migration since it
exists to serve these same two new read paths.
Revision ID: 080_task_project_budgets
Revises: 079_notification_backoff
Create Date: 2026-07-22
NOTE: down_revision was re-chained from 078_project_codegen_command to
079_notification_backoff (PR #652 landed first at integration) — the
079_task_project_budgets -> 080_task_project_budgets rename + re-chain is
exactly the "may be re-chained at integration" case flagged in the original
docstring, not a fork of the tree.
"""
from __future__ import annotations
import sqlalchemy as sa
from alembic import op
revision = "080_task_project_budgets"
down_revision = "079_notification_backoff"
branch_labels: dict[str, str] | None = None
depends_on: dict[str, str] | None = None
def upgrade() -> None:
op.add_column(
"tasks",
sa.Column("budget_usd", sa.Float(), nullable=True),
)
op.add_column(
"projects",
sa.Column("monthly_budget_usd", sa.Float(), nullable=True),
)
op.create_index(
"ix_agent_spawn_sessions_task_id",
"agent_spawn_sessions",
["task_id"],
)
def downgrade() -> None:
op.drop_index("ix_agent_spawn_sessions_task_id", table_name="agent_spawn_sessions")
op.drop_column("projects", "monthly_budget_usd")
op.drop_column("tasks", "budget_usd")