"""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 class BudgetExceededError(Exception): """BUDGET_EXCEEDED -- 03-C §3.12.""" 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 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 backend mechanism REQ-10's boundary case needs, not the full settings-page controller (c_budget_config_update's complete field set is U-030's job, PH3) -- reuses U-016's manual_resume (via org_loop_route.route_org_loop) for the actual status restoration rather than reimplementing it. """ 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() 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_task_ids": resumed}