"""c_budget_check (03-C §3.12) + p_budget_guardrail (04-P §4.4): the human-configured hard budget ceiling. This is NOT the same guardrail as Claude Code's own plan-quota exhaustion (p_usage_limit_wait, already built in U-010/U-012/U-013) -- 04-P is explicit that the two must never share a blocked_reason: budget_guardrail_config triggering is a human-configured limit that needs human judgment to raise (blocked_reason='budget_exceeded'), while plan-quota exhaustion self-recovers on a timer and only escalates to a human after a timeout (blocked_reason='usage_limit_wait_timeout'). """ import json import uuid from src import org_loop_route _VALID_SCOPES = ("global", "per_task", "per_day") _VALID_LIMIT_TYPES = ("token_count", "call_count") class BudgetExceededError(Exception): """BUDGET_EXCEEDED -- 03-C §3.12.""" class InvalidParamError(Exception): """INVALID_PARAM -- 03-C §3.13: limit_value<=0, or scope/limit_type not one of budget_guardrail_config's own enum values (checked here rather than left to surface as a raw sqlite CHECK-constraint IntegrityError -- same reasoning as U-023's bug_record/task_id existence checks).""" def _write_status_event(conn, task_id, project_id, from_status, to_status): conn.execute( "INSERT INTO execution_event (event_id, project_id, task_id, event_type, payload) " "VALUES (?, ?, ?, 'task_status_changed', ?)", (str(uuid.uuid4()), project_id, task_id, json.dumps({"from_status": from_status, "to_status": to_status})), ) def list_budget_configs(conn): """Read-only snapshot for v_dashboard's budget bar (02-V §2.2: "预算用量 条:引用 budget_guardrail_config.current_usage/limit_value") -- 03-C only documents a PUT for this table (§3.13, U-030's settings-page scope); a read path is needed for the dashboard and didn't exist anywhere, so it's added here rather than invented ad hoc in the HTTP layer.""" rows = conn.execute( "SELECT config_id, scope, limit_type, limit_value, current_usage, updated_at " "FROM budget_guardrail_config ORDER BY config_id" ).fetchall() return [ { "config_id": r[0], "scope": r[1], "limit_type": r[2], "limit_value": r[3], "current_usage": r[4], "updated_at": r[5], } for r in rows ] def check_budget(conn, task_id, config_id, estimated_usage): """Checked before/during c_work_graph_execute (03-C §3.12). On success, accumulates current_usage (04-P monitoring->monitoring: "累加 current_usage") and returns {"allowed": True, "remaining_quota"}. On reaching/exceeding the limit, transitions the task to blocked_pending_human (blocked_reason='budget_exceeded') and raises BudgetExceededError instead of returning allowed=False silently, matching 03-C's own errors list ("触发后对应任务转 blocked_pending_human"). """ row = conn.execute( "SELECT limit_value, current_usage FROM budget_guardrail_config WHERE config_id = ?", (config_id,) ).fetchone() if row is None: raise ValueError("no such budget_guardrail_config: " + config_id) limit_value, current_usage = row projected = current_usage + estimated_usage if projected >= limit_value: task_row = conn.execute("SELECT project_id, status FROM task WHERE task_id = ?", (task_id,)).fetchone() conn.execute( "UPDATE task SET status = 'blocked_pending_human', blocked_reason = 'budget_exceeded', " "updated_at = CURRENT_TIMESTAMP WHERE task_id = ?", (task_id,), ) _write_status_event(conn, task_id, task_row[0], task_row[1], "blocked_pending_human") conn.commit() raise BudgetExceededError( "config {} projected usage {} would reach/exceed limit {}".format(config_id, projected, limit_value) ) conn.execute("UPDATE budget_guardrail_config SET current_usage = ? WHERE config_id = ?", (projected, config_id)) conn.commit() return {"allowed": True, "remaining_quota": limit_value - projected} def _resume_budget_blocked_tasks(conn): blocked_tasks = conn.execute( "SELECT task_id FROM task WHERE status = 'blocked_pending_human' AND blocked_reason = 'budget_exceeded'" ).fetchall() resumed = [] for (task_id,) in blocked_tasks: org_loop_route.route_org_loop(conn, task_id, "manual_resume") resumed.append(task_id) return resumed def update_budget_limit_and_resume(conn, config_id, new_limit_value, updated_by): """04-P §4.4: paused -> monitoring, guard "人工在 v_budget_settings 调高额度". This is the minimal mechanism REQ-10's boundary case needs -- updates an *existing* row's limit_value only, silently no-ops if config_id doesn't exist yet. upsert_budget_config() (U-030) is the fuller v_budget_settings controller that also handles first-time creation. """ conn.execute( "UPDATE budget_guardrail_config SET limit_value = ?, updated_by = ?, updated_at = CURRENT_TIMESTAMP " "WHERE config_id = ?", (new_limit_value, updated_by, config_id), ) conn.commit() return {"resumed_task_ids": _resume_budget_blocked_tasks(conn)} def upsert_budget_config(conn, scope, limit_type, limit_value, updated_by, config_id="default"): """c_budget_config_update (03-C §3.13): v_budget_settings' submit_save. Creates the row on first use (current_usage starts at 0) or updates an existing one (current_usage is preserved -- this isn't a reset, just a limit/scope/type change). Raising limit_value past current_usage is exactly REQ-10's "人工在 v_budget_settings 调高额度" resume trigger (04-P §4.4), so this also resumes any budget_exceeded blocked tasks -- same as update_budget_limit_and_resume, just reachable from a fresh config too, not only an existing row.""" if scope not in _VALID_SCOPES: raise InvalidParamError("scope must be one of {}, got {!r}".format(_VALID_SCOPES, scope)) if limit_type not in _VALID_LIMIT_TYPES: raise InvalidParamError("limit_type must be one of {}, got {!r}".format(_VALID_LIMIT_TYPES, limit_type)) if limit_value <= 0: raise InvalidParamError("limit_value must be > 0, got {!r}".format(limit_value)) exists = conn.execute( "SELECT 1 FROM budget_guardrail_config WHERE config_id = ?", (config_id,) ).fetchone() is not None if exists: conn.execute( "UPDATE budget_guardrail_config SET scope = ?, limit_type = ?, limit_value = ?, updated_by = ?, " "updated_at = CURRENT_TIMESTAMP WHERE config_id = ?", (scope, limit_type, limit_value, updated_by, config_id), ) else: conn.execute( "INSERT INTO budget_guardrail_config (config_id, scope, limit_type, limit_value, current_usage, " "updated_by) VALUES (?, ?, ?, ?, 0, ?)", (config_id, scope, limit_type, limit_value, updated_by), ) conn.commit() resumed = _resume_budget_blocked_tasks(conn) row = conn.execute( "SELECT config_id, scope, limit_type, limit_value, current_usage, updated_at " "FROM budget_guardrail_config WHERE config_id = ?", (config_id,) ).fetchone() return { "config": { "config_id": row[0], "scope": row[1], "limit_type": row[2], "limit_value": row[3], "current_usage": row[4], "updated_at": row[5], }, "resumed_task_ids": resumed, }