From 916c76092be0484a5a1b4149f58f8ba3f8432711 Mon Sep 17 00:00:00 2001 From: bboonnee Date: Fri, 31 Jul 2026 16:24:16 +0800 Subject: [PATCH] U-019: budget_guardrail_config + c_budget_check (PH2-S3 begins) --- docs/development-plan.md | 1 + docs/sdd/unit_matrix.json | 2 +- src/budget_guardrail.py | 87 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ src/test_budget_guardrail.py | 87 +++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 176 insertions(+), 1 deletion(-) create mode 100644 src/budget_guardrail.py create mode 100644 src/test_budget_guardrail.py diff --git a/docs/development-plan.md b/docs/development-plan.md index f5dce7a..984ca66 100644 --- a/docs/development-plan.md +++ b/docs/development-plan.md @@ -187,6 +187,7 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。 - **U-019 [Risk Gate]** `budget_guardrail_config` + `c_budget_check`(人工配置层) - 依赖:U-012 - Done:REQ-10 happy/boundary 通过 + - **实现补充(2026-07-31)**:boundary 用例(调高额度后 `paused` 态任务恢复调度)需要的"改配置+恢复调度"能力,本 Unit 只建了最小可用版本 `update_budget_limit_and_resume`——完整设置页控制器 `c_budget_config_update`(含完整字段校验、UI)是 U-030 的事;这里直接复用 U-016 的 `manual_resume`(`org_loop_route.route_org_loop`)做实际的状态恢复,没有重新实现一遍。`c_budget_check` 超限时直接抛 `BudgetExceededError` 并把 `blocked_reason` 设成 `budget_exceeded`(不是返回 `allowed=false` 让调用方自己处理),对齐 03-C 原文"触发后对应任务转 blocked_pending_human"这句话描述的是 `c_budget_check` 自己的副作用 - 覆盖:01-M `budget_guardrail_config`;03-C `c_budget_check`;04-P `p_budget_guardrail`;REQ-10 - **U-020 [Risk Gate]** `p_usage_limit_wait` 完整验收(`session`/`weekly`/`opus` 三种 `quota_limit_type` + 超时兜底) diff --git a/docs/sdd/unit_matrix.json b/docs/sdd/unit_matrix.json index 65cf7c9..09751d2 100644 --- a/docs/sdd/unit_matrix.json +++ b/docs/sdd/unit_matrix.json @@ -32,7 +32,7 @@ {"id": "U-016", "phase": "PH2", "sub_phase": "PH2-S1", "name": "c_org_loop_route + fast_lane/full_replan(含 close_task_done,见 development-plan.md 2026-07-31 实现补充)", "classification": "Risk Gate", "depends_on": ["U-015"], "coverage": {"model": [], "view": [], "controller": ["c_org_loop_route"], "process": [], "requirement": ["REQ-05", "REQ-09"]}, "done": "REQ-05/REQ-09 全部用例通过(含 3 轮上限 → blocked_pending_human;validating→done 通过新增 close_task_done 打通)"}, {"id": "U-017", "phase": "PH2", "sub_phase": "PH2-S1", "name": "人为植入 bug 完整闭环验证(见 development-plan.md 2026-07-31 实现补充)", "classification": "Risk Gate", "depends_on": ["U-016"], "coverage": {"model": [], "view": [], "controller": [], "process": [], "requirement": []}, "done": "架构文档 §11 验收标准第 4 条达成:run_fix_loop 串联 U-015 判定 + U-016 路由 + 真实 Coder 修复 + 真实 Validator 复核"}, {"id": "U-018", "phase": "PH2", "sub_phase": "PH2-S2", "name": "c_skill_update + c_skill_rollback(见 development-plan.md 2026-07-31 实现补充)", "classification": "Risk Gate", "depends_on": ["U-015"], "coverage": {"model": ["skill", "skill_iteration"], "view": [], "controller": ["c_skill_update", "c_skill_rollback"], "process": [], "requirement": ["REQ-08"]}, "done": "REQ-08 全部通过;顺带解决了 U-015 的 skill_defect 缺口(bug_record.skill_iteration_suggestion_id 由 update_skill 回填)"}, - {"id": "U-019", "phase": "PH2", "sub_phase": "PH2-S3", "name": "budget_guardrail_config + c_budget_check", "classification": "Risk Gate", "depends_on": ["U-012"], "coverage": {"model": ["budget_guardrail_config"], "view": [], "controller": ["c_budget_check"], "process": ["p_budget_guardrail"], "requirement": ["REQ-10"]}, "done": "REQ-10 happy/boundary 通过"}, + {"id": "U-019", "phase": "PH2", "sub_phase": "PH2-S3", "name": "budget_guardrail_config + c_budget_check(见 development-plan.md 2026-07-31 实现补充)", "classification": "Risk Gate", "depends_on": ["U-012"], "coverage": {"model": ["budget_guardrail_config"], "view": [], "controller": ["c_budget_check"], "process": ["p_budget_guardrail"], "requirement": ["REQ-10"]}, "done": "REQ-10 happy/boundary 通过;boundary 复用 U-016 manual_resume,完整 c_budget_config_update UI 是 U-030 的事"}, {"id": "U-020", "phase": "PH2", "sub_phase": "PH2-S3", "name": "p_usage_limit_wait 完整验收", "classification": "Risk Gate", "depends_on": ["U-012"], "coverage": {"model": [], "view": [], "controller": [], "process": ["p_usage_limit_wait"], "requirement": ["REQ-14"]}, "done": "REQ-14 全部通过(含超时兜底)"}, {"id": "U-021", "phase": "PH2", "sub_phase": "PH2-S4", "name": "Graph Engine 持久化 + 断点恢复", "classification": "Risk Gate", "depends_on": ["U-012", "U-013"], "coverage": {"model": ["work_graph_run", "work_node_execution"], "view": [], "controller": [], "process": ["p_work_graph_execution"], "requirement": ["REQ-06"]}, "done": "REQ-06 happy/boundary/failure 通过"}, diff --git a/src/budget_guardrail.py b/src/budget_guardrail.py new file mode 100644 index 0000000..eb37839 --- /dev/null +++ b/src/budget_guardrail.py @@ -0,0 +1,87 @@ +"""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 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} diff --git a/src/test_budget_guardrail.py b/src/test_budget_guardrail.py new file mode 100644 index 0000000..7014f22 --- /dev/null +++ b/src/test_budget_guardrail.py @@ -0,0 +1,87 @@ +"""U-019 tests: c_budget_check + update_budget_limit_and_resume. All real +(sqlite is fast/free/local, no mocking needed). + +Run: python3 -m unittest src.test_budget_guardrail -v +""" + +import unittest + +from src import budget_guardrail, schema + + +class BudgetGuardrailTestBase(unittest.TestCase): + def setUp(self): + self.conn = schema.connect(":memory:") + schema.create_schema(self.conn) + self.conn.execute( + "INSERT INTO project (project_id, name, target_folder_path, has_git_repo, status) " + "VALUES ('proj-1', 'P', '/tmp/proj-1', 1, 'active')" + ) + self.conn.execute( + "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node) " + "VALUES ('task-1', 'proj-1', 'goal', 'goal', 'coding', 'coder')" + ) + self.conn.execute( + "INSERT INTO budget_guardrail_config (config_id, scope, limit_type, limit_value, current_usage, " + "updated_by) VALUES ('default', 'global', 'token_count', 100, 0, 'test')" + ) + self.conn.commit() + + def tearDown(self): + self.conn.close() + + +class CheckBudgetTest(BudgetGuardrailTestBase): + def test_under_limit_allowed_and_accumulates(self): + result = budget_guardrail.check_budget(self.conn, "task-1", "default", 30) + self.assertEqual(result, {"allowed": True, "remaining_quota": 70}) + usage = self.conn.execute("SELECT current_usage FROM budget_guardrail_config WHERE config_id = 'default'").fetchone()[0] + self.assertEqual(usage, 30) + + def test_reaching_limit_blocks_task_and_raises(self): + # REQ-10 happy: small limit, run past it -> blocked_pending_human, + # blocked_reason='budget_exceeded' + with self.assertRaises(budget_guardrail.BudgetExceededError): + budget_guardrail.check_budget(self.conn, "task-1", "default", 150) + row = self.conn.execute("SELECT status, blocked_reason FROM task WHERE task_id = 'task-1'").fetchone() + self.assertEqual(row, ("blocked_pending_human", "budget_exceeded")) + + def test_exceeded_check_does_not_accumulate_usage(self): + with self.assertRaises(budget_guardrail.BudgetExceededError): + budget_guardrail.check_budget(self.conn, "task-1", "default", 150) + usage = self.conn.execute("SELECT current_usage FROM budget_guardrail_config WHERE config_id = 'default'").fetchone()[0] + self.assertEqual(usage, 0) + + def test_blocked_reason_distinct_from_usage_limit_wait_timeout(self): + # REQ-10 failure: the two guardrails must never share a blocked_reason + with self.assertRaises(budget_guardrail.BudgetExceededError): + budget_guardrail.check_budget(self.conn, "task-1", "default", 150) + blocked_reason = self.conn.execute("SELECT blocked_reason FROM task WHERE task_id = 'task-1'").fetchone()[0] + self.assertNotEqual(blocked_reason, "usage_limit_wait_timeout") + self.assertEqual(blocked_reason, "budget_exceeded") + + +class ResumeAfterLimitRaiseTest(BudgetGuardrailTestBase): + def test_raising_limit_resumes_budget_blocked_tasks(self): + with self.assertRaises(budget_guardrail.BudgetExceededError): + budget_guardrail.check_budget(self.conn, "task-1", "default", 150) + + result = budget_guardrail.update_budget_limit_and_resume(self.conn, "default", 1000, "admin") + self.assertEqual(result["resumed_task_ids"], ["task-1"]) + row = self.conn.execute("SELECT status, blocked_reason FROM task WHERE task_id = 'task-1'").fetchone() + self.assertEqual(row, ("coding", None)) # restored to pre-blocked status ('coding') + + def test_does_not_resume_tasks_blocked_for_other_reasons(self): + self.conn.execute( + "UPDATE task SET status = 'blocked_pending_human', blocked_reason = 'loop_limit_reached' " + "WHERE task_id = 'task-1'" + ) + self.conn.commit() + result = budget_guardrail.update_budget_limit_and_resume(self.conn, "default", 1000, "admin") + self.assertEqual(result["resumed_task_ids"], []) + status = self.conn.execute("SELECT status FROM task WHERE task_id = 'task-1'").fetchone()[0] + self.assertEqual(status, "blocked_pending_human") + + +if __name__ == "__main__": + unittest.main() -- libgit2 0.21.4