/** * Team Budget Gate * * Evaluates a team's `Team.budget` (`maxTokensPerDay`, `maxUsdPerMonth`, * `alertThreshold`) against the live token ledger in * {@link TokenUsageService}. The budget was stored and injected into agent * prompts but never evaluated anywhere — this service is the single * evaluator, consulted by: * * - `WorkItemDispatchSubscriber.dispatchTo` — skip pushing a brief to an * agent whose team is over budget. * - `TaskPoolService.claimFromPool` — refuse the claim with * `reason: 'team_budget_exceeded'`. * * and it is the single notifier: crossing `alertThreshold` (warn) or 100 % * (blocked) publishes one `team:budget_exceeded` event per team per UTC day * per level and enqueues an owner-facing `[BUDGET]` message to the * orchestrator queue (`source: 'system_event'`). * * Cost: one `getTeams()` + one ledger scan per member session, cached per * team for {@link TEAM_BUDGET_CACHE_TTL_MS} so the claim/dispatch hot paths * stay cheap. * * @module services/budget/team-budget-gate.service */ import { type ComponentLogger } from '../core/logger.service.js'; import type { Team } from '../../types/index.js'; import type { EventBusService } from '../event-bus/event-bus.service.js'; import { MESSAGE_SOURCES } from '../../constants.js'; /** How long a team's computed usage is reused before re-scanning the ledger. */ export declare const TEAM_BUDGET_CACHE_TTL_MS = 60000; /** Refusal reason surfaced by the claim / dispatch gates. */ export declare const TEAM_BUDGET_EXCEEDED_REASON: "team_budget_exceeded"; /** Envelope prefix for the owner-facing queue message. */ export declare const BUDGET_ENVELOPE_PREFIX = "[BUDGET]"; /** Conversation id stamped on `[BUDGET]` queue messages. */ export declare const BUDGET_CONVERSATION_ID = "system_budget"; /** Default alert threshold (percent) when `Team.budget.alertThreshold` is unset. */ export declare const DEFAULT_ALERT_THRESHOLD_PCT = 80; /** Budget consumption level. */ export type TeamBudgetLevel = 'ok' | 'warn' | 'blocked'; /** Computed usage for a team over the budget windows. */ export interface TeamBudgetUsage { /** Input + output tokens consumed by the team's sessions since UTC midnight. */ tokensToday: number; /** USD spent by the team's sessions since the first of the UTC month. */ usdThisMonth: number; /** Configured daily token cap (undefined = none). */ maxTokensPerDay?: number; /** Configured monthly USD cap (undefined = none). */ maxUsdPerMonth?: number; /** Percent of the daily token cap used (undefined when no cap). */ tokenPct?: number; /** Percent of the monthly USD cap used (undefined when no cap). */ usdPct?: number; /** Member sessions the usage was aggregated over. */ sessions: string[]; } /** Result of {@link TeamBudgetGateService.check}. */ export interface TeamBudgetCheck { /** `false` when any configured cap is at/over 100 %. */ allowed: boolean; /** Set when `allowed === false`. */ reason?: typeof TEAM_BUDGET_EXCEEDED_REASON; /** Human-readable explanation of the level. */ detail?: string; level: TeamBudgetLevel; usage: TeamBudgetUsage; teamId: string; teamName: string; } /** Ledger surface the gate needs (see {@link TokenUsageService.getSessionUsageSince}). */ export interface BudgetUsageLedger { getSessionUsageSince(sessionName: string, since: Date, until?: Date): { inputTokens: number; outputTokens: number; cost: number; }; } /** Minimal MessageQueueService surface for the `[BUDGET]` notification. */ export interface BudgetMessageQueueLike { enqueue(input: { content: string; conversationId: string; source: typeof MESSAGE_SOURCES.SYSTEM_EVENT; targetSession?: string; sourceMetadata?: Record; }): unknown; } /** Injectable dependencies (production defaults resolve the singletons). */ export interface TeamBudgetGateDependencies { getTeams?: () => Promise; ledger?: BudgetUsageLedger; now?: () => Date; logger?: ComponentLogger; } /** Thrown by `claimFromPool` when the claiming agent's team is over budget. */ export declare class TeamBudgetExceededError extends Error { readonly reason: "team_budget_exceeded"; readonly check: TeamBudgetCheck; constructor(check: TeamBudgetCheck); } /** * Evaluates and enforces per-team token / USD budgets. See module docs. */ export declare class TeamBudgetGateService { private static instance; private readonly logger; private readonly getTeams; private readonly ledger; private readonly now; private readonly cache; /** Teams snapshot shared by check()/checkForSession(), same TTL as the per-team cache. */ private teamsSnapshot; /** `::` notifications already sent (FIFO-bounded). */ private readonly notified; private eventBus; private messageQueue; constructor(deps?: TeamBudgetGateDependencies); /** Singleton accessor (production). Tests construct directly. */ static getInstance(): TeamBudgetGateService; /** Drop the singleton (tests). */ static resetInstance(): void; /** * Wire the notification legs. Either may be null; the gate still * enforces without them. * * @param deps - EventBus for `team:budget_exceeded`, queue for `[BUDGET]` */ setNotifiers(deps: { eventBus?: EventBusService | null; messageQueue?: BudgetMessageQueueLike | null; }): void; /** Forget cached usage + teams snapshot (tests / after a budget edit). */ invalidate(teamId?: string): void; /** Teams list, re-read at most once per {@link TEAM_BUDGET_CACHE_TTL_MS}. */ private loadTeams; /** * Check a team's budget. Cached per team for {@link TEAM_BUDGET_CACHE_TTL_MS}. * * @param teamId - Team to evaluate * @returns The check; `allowed: true` when the team is unknown or has no caps */ check(teamId: string): Promise; /** * Check the budget of the team that owns `sessionName`. Sessions that map * to no team (orchestrator, unknown) are always allowed. * * @param sessionName - Agent PTY session name * @returns The team's check, or an allowed check when unresolvable */ checkForSession(sessionName: string): Promise; private allowedFor; /** Aggregate the team's sessions over the two windows and grade the result. */ private evaluate; /** * Publish `team:budget_exceeded` + enqueue `[BUDGET]` once per team per * level per UTC day. `warn` and `blocked` are separate keys so a team * that crosses the alert line and later the hard cap gets both notices. */ private notify; /** Owner-facing one-liner. */ private formatEnvelope; } //# sourceMappingURL=team-budget-gate.service.d.ts.map