budget_guardrail.py 4.64 KB
"""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}