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>
This commit is contained in:
Renzo F
2026-07-23 00:06:14 +02:00
committed by GitHub
co-authored by Renn F
parent 1d5a8e846f
commit 7c8453e210
31 changed files with 2288 additions and 65 deletions
@@ -5,6 +5,9 @@ import type { ReactNode } from "react";
import React from "react";
import { Team } from "@/types";
import type { Project } from "@/types";
import { toast } from "sonner";
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
// jsdom has no ResizeObserver; Radix Switch (the always-rendered "Active"
// toggle) measures its thumb via one on mount — mirrors
@@ -117,6 +120,7 @@ function makeProject(overrides: Partial<Project> = {}): Project {
video_engine_enabled: false,
dep_update_command: null,
dep_update_paths: null,
monthly_budget_usd: null,
sandbox_services: null,
sandbox_extensions: null,
workspace_path: null,
@@ -385,3 +389,104 @@ describe("EditProjectDialog — Protected Branches", () => {
expect(call.updates.protected_branches).toEqual(["master", "slave"]);
});
});
describe("EditProjectDialog — Monthly Budget (USD)", () => {
beforeEach(() => {
vi.clearAllMocks();
getCredentialsStatus.mockResolvedValue({ has_credentials: true });
mutateAsync.mockResolvedValue(makeProject());
useUpdateProject.mockReturnValue({ mutateAsync, isPending: false });
});
function openAutonomySection() {
fireEvent.click(
screen.getByRole("button", { name: /Show Autonomous Maintenance/i }),
);
}
// fireEvent.submit(form) rather than clicking the Save button — this
// dialog's Tabs-wrapped form doesn't reliably translate a button click
// into a submit event under jsdom; submitting the form directly is the
// same idiom create-task-dialog.test.tsx already uses.
function submit() {
fireEvent.submit(document.querySelector("form")!);
}
it("pre-fills the stored monthly_budget_usd", async () => {
renderDialog(makeProject({ monthly_budget_usd: 42 }));
await screen.findByRole("button", { name: /Save Changes/i });
openAutonomySection();
expect(screen.getByLabelText(/Monthly Budget/i)).toHaveValue(42);
});
it("rejects 0 with an inline error and does not submit", async () => {
renderDialog(makeProject({ monthly_budget_usd: null }));
await screen.findByRole("button", { name: /Save Changes/i });
openAutonomySection();
fireEvent.change(screen.getByLabelText(/Monthly Budget/i), {
target: { value: "0" },
});
submit();
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith(
expect.stringMatching(/greater than 0/i),
);
});
expect(mutateAsync).not.toHaveBeenCalled();
});
it("rejects a negative budget the same way", async () => {
renderDialog(makeProject({ monthly_budget_usd: null }));
await screen.findByRole("button", { name: /Save Changes/i });
openAutonomySection();
fireEvent.change(screen.getByLabelText(/Monthly Budget/i), {
target: { value: "-5" },
});
submit();
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith(
expect.stringMatching(/greater than 0/i),
);
});
expect(mutateAsync).not.toHaveBeenCalled();
});
it("submits null when cleared (no cap)", async () => {
renderDialog(makeProject({ monthly_budget_usd: 42 }));
await screen.findByRole("button", { name: /Save Changes/i });
openAutonomySection();
fireEvent.change(screen.getByLabelText(/Monthly Budget/i), {
target: { value: "" },
});
submit();
await waitFor(() => expect(mutateAsync).toHaveBeenCalled());
const call = mutateAsync.mock.calls[0][0] as {
updates: { monthly_budget_usd?: number | null };
};
expect(call.updates.monthly_budget_usd).toBeNull();
});
it("submits a positive cap as a number", async () => {
renderDialog(makeProject({ monthly_budget_usd: null }));
await screen.findByRole("button", { name: /Save Changes/i });
openAutonomySection();
fireEvent.change(screen.getByLabelText(/Monthly Budget/i), {
target: { value: "100" },
});
submit();
await waitFor(() => expect(mutateAsync).toHaveBeenCalled());
const call = mutateAsync.mock.calls[0][0] as {
updates: { monthly_budget_usd?: number | null };
};
expect(call.updates.monthly_budget_usd).toBe(100);
});
});
@@ -237,6 +237,9 @@ function EditProjectForm({
const [depUpdatePaths, setDepUpdatePaths] = useState(
(project.dep_update_paths || []).join(", "),
);
const [monthlyBudgetUsd, setMonthlyBudgetUsd] = useState(
project.monthly_budget_usd != null ? String(project.monthly_budget_usd) : "",
);
const sandboxServices = project.sandbox_services || [];
const [sandboxSet, setSandboxSet] = useState<Set<string>>(
new Set(sandboxServices),
@@ -312,6 +315,15 @@ function EditProjectForm({
return;
}
const trimmedBudget = monthlyBudgetUsd.trim();
const parsedBudget = trimmedBudget ? Number(trimmedBudget) : null;
if (trimmedBudget && (Number.isNaN(parsedBudget) || parsedBudget! <= 0)) {
toast.error(
"Monthly budget must be greater than 0 — leave it empty for no cap",
);
return;
}
// Build update payload
const updates: ProjectUpdate = {
name,
@@ -342,6 +354,9 @@ function EditProjectForm({
.map((p) => p.trim())
.filter(Boolean)
: undefined,
// Sent explicitly (never coerced to undefined) so clearing the input
// actually clears the stored cap instead of being dropped.
monthly_budget_usd: parsedBudget,
sandbox_services: [...sandboxSet],
sandbox_extensions: (() => {
const extObj: Record<string, string[]> = {};
@@ -800,6 +815,26 @@ function EditProjectForm({
{showAutonomy && (
<>
<div className="grid gap-2">
<HelpTip label="Calendar-month cap on this project's summed agent-spawn spend; a claim is refused once reached. Requires the task-budgets flag armed fleet-wide (ROBOCO_TASK_BUDGETS_ENABLED). Leave blank for no cap.">
<Label htmlFor="monthly_budget_usd">Monthly Budget (USD)</Label>
</HelpTip>
<Input
id="monthly_budget_usd"
type="number"
min="0.01"
step="0.01"
value={monthlyBudgetUsd}
onChange={(e) => setMonthlyBudgetUsd(e.target.value)}
placeholder="No cap"
/>
<p className="text-xs text-muted-foreground">
Claims are refused once this month&apos;s spend reaches the cap.
Must be greater than 0 a 0 budget would block every claim
immediately. Leave blank for no cap.
</p>
</div>
<div className="flex items-center justify-between">
<HelpTip label="Opens a fix task automatically when this repo's default-branch CI goes red. Also requires the CI-watch engine armed fleet-wide (ROBOCO_CI_WATCH_ENABLED) to actually run.">
<Label htmlFor="ci_watch_enabled">
@@ -176,4 +176,26 @@ describe("FeatureFlagsCard — M42 off-transition confirm + pending-keys Set", (
expect(screen.getByText(/wikilinked Obsidian vault/i)).toBeInTheDocument();
expect(screen.getByText(/board-review drafts/i)).toBeInTheDocument();
});
// task_budgets_enabled joined FLAG_DESCRIPTIONS + FLAG_TOOLTIPS together —
// guard both stay in sync the same way the vault/docs-sync case above does.
it("renders the description and tooltip for task_budgets_enabled", async () => {
getFeatureFlags.mockResolvedValueOnce({
flags: [
{
key: "task_budgets_enabled",
label: "Task/project cost budgets",
enabled: false,
},
],
note: "Changes take effect on the next backend restart.",
});
render(withQueryClient(<FeatureFlagsCard />));
expect(
await screen.findByText(/per-project monthly and per-task cost caps/i),
).toBeInTheDocument();
const label = screen.getByText("Task/project cost budgets");
expect(label.getAttribute("data-state")).toBe("closed");
});
});
@@ -55,6 +55,8 @@ const FLAG_DESCRIPTIONS: Record<string, string> = {
"Enforce a per-project architectural standard (.roboco/conventions.yml): inject the map, attach baseline constraints, and block i_am_done / pr_pass on misplaced definitions or lint suppressions.",
possibilities_matrix_enabled:
"When a task's work is already done (commits + open PR + all acceptance criteria addressed + no open findings), submit it for QA in one i_am_done call instead of 3-6 turns — skips the retroactive plan, journal tracing, and local quality (CI-green proxy) gates. Off by default: the standard path is unchanged until you arm this.",
task_budgets_enabled:
"Enforce per-project monthly and per-task cost caps (USD). A claim is refused once a project's monthly budget is reached; an active task whose own budget (or its task-type default) is breached is stopped and blocked, and you're notified. Set the caps on the project edit dialog and a task's detail page — a project/task with no cap set is unaffected either way.",
rag_auto_update_enabled:
"Keep the knowledge base index refreshed automatically.",
transcript_prune_enabled:
@@ -126,6 +128,8 @@ const FLAG_TOOLTIPS: Record<string, string> = {
conventions_enabled: "Enforces each project's architectural placement rules.",
possibilities_matrix_enabled:
"Fast-paths work that's already been done elsewhere.",
task_budgets_enabled:
"Caps agent spend per project (monthly) and per task.",
rag_auto_update_enabled:
"Keeps the RAG knowledge index automatically refreshed.",
transcript_prune_enabled:
@@ -0,0 +1,185 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { fireEvent, render, screen, waitFor } from "@testing-library/react";
const { mutateAsync } = vi.hoisted(() => ({
mutateAsync: vi.fn().mockResolvedValue(undefined),
}));
vi.mock("@/hooks/use-tasks", () => ({
useUpdateTask: () => ({ mutateAsync, isPending: false }),
}));
vi.mock("sonner", () => ({ toast: { success: vi.fn(), error: vi.fn() } }));
vi.mock("@/components/agents/agent-selector", () => ({
AgentSelector: () => null,
}));
vi.mock("@/components/projects/project-selector", () => ({
ProjectSelector: () => null,
}));
vi.mock("../markdown-editor", () => ({ MarkdownEditor: () => null }));
vi.mock("../acceptance-criteria-editor", () => ({
AcceptanceCriteriaEditor: () => null,
}));
// Collapsible: always render children open, so the Budget field (inside
// Advanced Options) is reachable without simulating the toggle click.
vi.mock("@/components/ui/collapsible", () => ({
Collapsible: ({ children }: { children: React.ReactNode }) => <>{children}</>,
CollapsibleTrigger: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
CollapsibleContent: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
}));
// Select: a native <select> stub, matching create-task-dialog's convention.
vi.mock("@/components/ui/select", () => ({
Select: ({
value,
onValueChange,
children,
}: {
value: string;
onValueChange?: (v: string) => void;
children: React.ReactNode;
}) => (
<select value={value} onChange={(e) => onValueChange?.(e.target.value)}>
{children}
</select>
),
SelectTrigger: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
SelectValue: () => null,
SelectContent: ({ children }: { children: React.ReactNode }) => (
<>{children}</>
),
SelectItem: ({
value,
children,
}: {
value: string;
children: React.ReactNode;
}) => <option value={value}>{children}</option>,
}));
import { EditTaskDialog } from "../edit-task-dialog";
import { mockTasks } from "@/lib/mock-data";
import { toast } from "sonner";
const task = mockTasks[0];
function submit() {
fireEvent.submit(document.querySelector("form")!);
}
function budgetInput(): HTMLInputElement {
return screen.getByPlaceholderText("Task-type default") as HTMLInputElement;
}
describe("EditTaskDialog — Budget (USD) input", () => {
beforeEach(() => {
mutateAsync.mockClear();
});
afterEach(() => {
vi.clearAllMocks();
});
it("renders empty when the task has no budget_usd", () => {
render(
<EditTaskDialog
task={{ ...task, budget_usd: null }}
open={true}
onOpenChange={vi.fn()}
/>,
);
expect(budgetInput().value).toBe("");
});
it("pre-fills the stored budget_usd", () => {
render(
<EditTaskDialog
task={{ ...task, budget_usd: 3.5 }}
open={true}
onOpenChange={vi.fn()}
/>,
);
expect(budgetInput().value).toBe("3.5");
});
it("rejects 0 with an inline error and does not submit", async () => {
render(
<EditTaskDialog
task={{ ...task, budget_usd: null }}
open={true}
onOpenChange={vi.fn()}
/>,
);
fireEvent.change(budgetInput(), { target: { value: "0" } });
submit();
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith(
expect.stringMatching(/greater than 0/i),
);
});
expect(mutateAsync).not.toHaveBeenCalled();
});
it("rejects a negative budget the same way", async () => {
render(
<EditTaskDialog
task={{ ...task, budget_usd: null }}
open={true}
onOpenChange={vi.fn()}
/>,
);
fireEvent.change(budgetInput(), { target: { value: "-1" } });
submit();
await waitFor(() => {
expect(toast.error).toHaveBeenCalledWith(
expect.stringMatching(/greater than 0/i),
);
});
expect(mutateAsync).not.toHaveBeenCalled();
});
it("submits null when left empty (use the task-type default)", async () => {
render(
<EditTaskDialog
task={{ ...task, budget_usd: 3.5 }}
open={true}
onOpenChange={vi.fn()}
/>,
);
fireEvent.change(budgetInput(), { target: { value: "" } });
submit();
await waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(1));
const { updates } = mutateAsync.mock.calls[0][0] as {
updates: Record<string, unknown>;
};
expect(updates.budget_usd).toBeNull();
});
it("submits a positive budget as a number", async () => {
render(
<EditTaskDialog
task={{ ...task, budget_usd: null }}
open={true}
onOpenChange={vi.fn()}
/>,
);
fireEvent.change(budgetInput(), { target: { value: "2.5" } });
submit();
await waitFor(() => expect(mutateAsync).toHaveBeenCalledTimes(1));
const { updates } = mutateAsync.mock.calls[0][0] as {
updates: Record<string, unknown>;
};
expect(updates.budget_usd).toBe(2.5);
});
});
@@ -120,6 +120,9 @@ function EditTaskDialogInner({
? new Date(task.target_date).toISOString().slice(0, 16)
: "",
);
const [budgetUsd, setBudgetUsd] = useState<string>(
task.budget_usd != null ? String(task.budget_usd) : "",
);
const [advancedOpen, setAdvancedOpen] = useState(false);
const updateTask = useUpdateTask();
@@ -143,6 +146,15 @@ function EditTaskDialogInner({
}
setAcError(undefined);
const trimmedBudget = budgetUsd.trim();
const parsedBudget = trimmedBudget ? Number(trimmedBudget) : null;
if (trimmedBudget && (Number.isNaN(parsedBudget) || parsedBudget! <= 0)) {
toast.error(
"Budget must be greater than 0 — leave it empty for the task-type default",
);
return;
}
const trimmedCriteria = acceptanceCriteria
.map((c) => c.trim())
.filter(Boolean);
@@ -164,6 +176,7 @@ function EditTaskDialogInner({
project_id: projectId,
assigned_to: assignedTo,
target_date: targetDate ? new Date(targetDate).toISOString() : null,
budget_usd: parsedBudget,
...(criteriaChanged && { acceptance_criteria: trimmedCriteria }),
},
});
@@ -338,6 +351,26 @@ function EditTaskDialogInner({
/>
</div>
{/* Budget (USD) */}
<div className="space-y-2">
<HelpTip label="Caps this task's own agent-spawn spend; only enforced when the task-budgets feature flag is on. Empty = use the task-type default.">
<Label>Budget (USD)</Label>
</HelpTip>
<Input
type="number"
min="0.01"
step="0.01"
placeholder="Task-type default"
value={budgetUsd}
onChange={(e) => setBudgetUsd(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Must be greater than 0 a 0 budget would block the task
before it spends a cent. Leave blank for the task-type
default.
</p>
</div>
{/* Git Configuration Section */}
<div className="space-y-4 pt-4 border-t">
<div className="flex items-center gap-2 mb-2">
+1
View File
@@ -174,6 +174,7 @@ export const projectsApi = {
video_engine_enabled: false,
dep_update_command: null,
dep_update_paths: null,
monthly_budget_usd: null,
sandbox_services: null,
sandbox_extensions: null,
workspace_path: null,
+7
View File
@@ -224,6 +224,8 @@ export interface Task {
acceptance_criteria: string[];
status: TaskStatus;
priority: number; // 0=P0(highest), 1=P1, 2=P2, 3=P3(lowest)
// Cost cap (ROBOCO_TASK_BUDGETS_ENABLED). null = use the task-type default.
budget_usd?: number | null;
sequence: number; // Order number within siblings
team: Team;
created_by: string;
@@ -1059,6 +1061,9 @@ export interface Project {
video_engine_enabled: boolean;
dep_update_command: string | null;
dep_update_paths: string[] | null;
// Calendar-month cap on summed agent-spawn spend across this project's
// tasks; null = no cap. Only enforced when ROBOCO_TASK_BUDGETS_ENABLED is on.
monthly_budget_usd: number | null;
sandbox_services: string[] | null;
sandbox_extensions: Record<string, string[]> | null;
// Runtime state
@@ -1123,6 +1128,8 @@ export interface ProjectUpdate {
video_engine_enabled?: boolean;
dep_update_command?: string;
dep_update_paths?: string[];
// null clears the cap (no cap); omitted leaves unchanged.
monthly_budget_usd?: number | null;
sandbox_services?: string[];
sandbox_extensions?: Record<string, string[]>;
}