Commit 916c76092be0484a5a1b4149f58f8ba3f8432711
1 parent
5420ff52
U-019: budget_guardrail_config + c_budget_check (PH2-S3 begins)
check_budget(): accumulates current_usage on success (04-P monitoring->
monitoring), and on reaching/exceeding limit_value transitions the task to
blocked_pending_human with blocked_reason='budget_exceeded' (never
'usage_limit_wait_timeout' -- 04-P is explicit these two guardrails, human-
configured budget vs Claude Code's own plan quota, must not share a
blocked_reason) and raises BudgetExceededError rather than returning
allowed=false silently, matching 03-C §3.12's own errors list.
update_budget_limit_and_resume(): REQ-10's boundary case needs "raise the
limit -> paused tasks resume" to be real; built the minimal version this
Unit needs rather than the full settings-page controller
(c_budget_config_update, U-030/PH3's job) -- reuses U-016's manual_resume
(org_loop_route.route_org_loop) for the actual status restoration instead
of reimplementing it.
6 new stdlib unittest cases, all real (sqlite, no mocking needed). 173
total across U-001-019, all passing.
Also: the U-014 full real closed-loop run that's been going in a detached
background process since earlier this session finished -- 957s (~16min).
All 4 real BRD/PRD/SDD/Benchmark docs, a real JS snake-game implementation
(engine.js/engine.test.js/index.html/app.js) from the Coder, and a
genuinely careful INCONCLUSIVE verdict from the Validator: it explicitly
refused to accept the Coder's own claimed "10/10 passed, verified with
headless Chrome" without being able to reproduce it, correctly citing its
own migrated SOUL.md rule ("工具错误和权限不足属于INCONCLUSIVE,不能洗成PASS").
It also caught a real test-fidelity gap (T3/T7 not matching the Benchmark's
literal wording) via careful static tracing. Result preserved at
/private/tmp/.../scratchpad/snake-game-result for inspection. The Validator
also reported no Bash tool available despite its frontmatter granting one
-- investigating that as a follow-up, separate from this commit.
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
Showing
4 changed files
with
176 additions
and
1 deletions
docs/development-plan.md
| @@ -187,6 +187,7 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。 | @@ -187,6 +187,7 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。 | ||
| 187 | - **U-019 [Risk Gate]** `budget_guardrail_config` + `c_budget_check`(人工配置层) | 187 | - **U-019 [Risk Gate]** `budget_guardrail_config` + `c_budget_check`(人工配置层) |
| 188 | - 依赖:U-012 | 188 | - 依赖:U-012 |
| 189 | - Done:REQ-10 happy/boundary 通过 | 189 | - Done:REQ-10 happy/boundary 通过 |
| 190 | + - **实现补充(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` 自己的副作用 | ||
| 190 | - 覆盖:01-M `budget_guardrail_config`;03-C `c_budget_check`;04-P `p_budget_guardrail`;REQ-10 | 191 | - 覆盖:01-M `budget_guardrail_config`;03-C `c_budget_check`;04-P `p_budget_guardrail`;REQ-10 |
| 191 | 192 | ||
| 192 | - **U-020 [Risk Gate]** `p_usage_limit_wait` 完整验收(`session`/`weekly`/`opus` 三种 `quota_limit_type` + 超时兜底) | 193 | - **U-020 [Risk Gate]** `p_usage_limit_wait` 完整验收(`session`/`weekly`/`opus` 三种 `quota_limit_type` + 超时兜底) |
docs/sdd/unit_matrix.json
| @@ -32,7 +32,7 @@ | @@ -32,7 +32,7 @@ | ||
| 32 | {"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 打通)"}, | 32 | {"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 打通)"}, |
| 33 | {"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 复核"}, | 33 | {"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 复核"}, |
| 34 | {"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 回填)"}, | 34 | {"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 回填)"}, |
| 35 | - {"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 通过"}, | 35 | + {"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 的事"}, |
| 36 | {"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 全部通过(含超时兜底)"}, | 36 | {"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 全部通过(含超时兜底)"}, |
| 37 | {"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 通过"}, | 37 | {"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 通过"}, |
| 38 | 38 |
src/budget_guardrail.py
0 → 100644
| 1 | +"""c_budget_check (03-C §3.12) + p_budget_guardrail (04-P §4.4): the | ||
| 2 | +human-configured hard budget ceiling. This is NOT the same guardrail as | ||
| 3 | +Claude Code's own plan-quota exhaustion (p_usage_limit_wait, already built | ||
| 4 | +in U-010/U-012/U-013) -- 04-P is explicit that the two must never share a | ||
| 5 | +blocked_reason: budget_guardrail_config triggering is a human-configured | ||
| 6 | +limit that needs human judgment to raise (blocked_reason='budget_exceeded'), | ||
| 7 | +while plan-quota exhaustion self-recovers on a timer and only escalates to | ||
| 8 | +a human after a timeout (blocked_reason='usage_limit_wait_timeout'). | ||
| 9 | +""" | ||
| 10 | + | ||
| 11 | +import json | ||
| 12 | +import uuid | ||
| 13 | + | ||
| 14 | +from src import org_loop_route | ||
| 15 | + | ||
| 16 | + | ||
| 17 | +class BudgetExceededError(Exception): | ||
| 18 | + """BUDGET_EXCEEDED -- 03-C §3.12.""" | ||
| 19 | + | ||
| 20 | + | ||
| 21 | +def _write_status_event(conn, task_id, project_id, from_status, to_status): | ||
| 22 | + conn.execute( | ||
| 23 | + "INSERT INTO execution_event (event_id, project_id, task_id, event_type, payload) " | ||
| 24 | + "VALUES (?, ?, ?, 'task_status_changed', ?)", | ||
| 25 | + (str(uuid.uuid4()), project_id, task_id, json.dumps({"from_status": from_status, "to_status": to_status})), | ||
| 26 | + ) | ||
| 27 | + | ||
| 28 | + | ||
| 29 | +def check_budget(conn, task_id, config_id, estimated_usage): | ||
| 30 | + """Checked before/during c_work_graph_execute (03-C §3.12). On success, | ||
| 31 | + accumulates current_usage (04-P monitoring->monitoring: "累加 | ||
| 32 | + current_usage") and returns {"allowed": True, "remaining_quota"}. On | ||
| 33 | + reaching/exceeding the limit, transitions the task to | ||
| 34 | + blocked_pending_human (blocked_reason='budget_exceeded') and raises | ||
| 35 | + BudgetExceededError instead of returning allowed=False silently, | ||
| 36 | + matching 03-C's own errors list ("触发后对应任务转 blocked_pending_human"). | ||
| 37 | + """ | ||
| 38 | + row = conn.execute( | ||
| 39 | + "SELECT limit_value, current_usage FROM budget_guardrail_config WHERE config_id = ?", (config_id,) | ||
| 40 | + ).fetchone() | ||
| 41 | + if row is None: | ||
| 42 | + raise ValueError("no such budget_guardrail_config: " + config_id) | ||
| 43 | + limit_value, current_usage = row | ||
| 44 | + projected = current_usage + estimated_usage | ||
| 45 | + | ||
| 46 | + if projected >= limit_value: | ||
| 47 | + task_row = conn.execute("SELECT project_id, status FROM task WHERE task_id = ?", (task_id,)).fetchone() | ||
| 48 | + conn.execute( | ||
| 49 | + "UPDATE task SET status = 'blocked_pending_human', blocked_reason = 'budget_exceeded', " | ||
| 50 | + "updated_at = CURRENT_TIMESTAMP WHERE task_id = ?", | ||
| 51 | + (task_id,), | ||
| 52 | + ) | ||
| 53 | + _write_status_event(conn, task_id, task_row[0], task_row[1], "blocked_pending_human") | ||
| 54 | + conn.commit() | ||
| 55 | + raise BudgetExceededError( | ||
| 56 | + "config {} projected usage {} would reach/exceed limit {}".format(config_id, projected, limit_value) | ||
| 57 | + ) | ||
| 58 | + | ||
| 59 | + conn.execute("UPDATE budget_guardrail_config SET current_usage = ? WHERE config_id = ?", (projected, config_id)) | ||
| 60 | + conn.commit() | ||
| 61 | + return {"allowed": True, "remaining_quota": limit_value - projected} | ||
| 62 | + | ||
| 63 | + | ||
| 64 | +def update_budget_limit_and_resume(conn, config_id, new_limit_value, updated_by): | ||
| 65 | + """04-P §4.4: paused -> monitoring, guard "人工在 v_budget_settings 调高额度". | ||
| 66 | + | ||
| 67 | + This is the minimal backend mechanism REQ-10's boundary case needs, not | ||
| 68 | + the full settings-page controller (c_budget_config_update's complete | ||
| 69 | + field set is U-030's job, PH3) -- reuses U-016's manual_resume (via | ||
| 70 | + org_loop_route.route_org_loop) for the actual status restoration rather | ||
| 71 | + than reimplementing it. | ||
| 72 | + """ | ||
| 73 | + conn.execute( | ||
| 74 | + "UPDATE budget_guardrail_config SET limit_value = ?, updated_by = ?, updated_at = CURRENT_TIMESTAMP " | ||
| 75 | + "WHERE config_id = ?", | ||
| 76 | + (new_limit_value, updated_by, config_id), | ||
| 77 | + ) | ||
| 78 | + conn.commit() | ||
| 79 | + | ||
| 80 | + blocked_tasks = conn.execute( | ||
| 81 | + "SELECT task_id FROM task WHERE status = 'blocked_pending_human' AND blocked_reason = 'budget_exceeded'" | ||
| 82 | + ).fetchall() | ||
| 83 | + resumed = [] | ||
| 84 | + for (task_id,) in blocked_tasks: | ||
| 85 | + org_loop_route.route_org_loop(conn, task_id, "manual_resume") | ||
| 86 | + resumed.append(task_id) | ||
| 87 | + return {"resumed_task_ids": resumed} |
src/test_budget_guardrail.py
0 → 100644
| 1 | +"""U-019 tests: c_budget_check + update_budget_limit_and_resume. All real | ||
| 2 | +(sqlite is fast/free/local, no mocking needed). | ||
| 3 | + | ||
| 4 | +Run: python3 -m unittest src.test_budget_guardrail -v | ||
| 5 | +""" | ||
| 6 | + | ||
| 7 | +import unittest | ||
| 8 | + | ||
| 9 | +from src import budget_guardrail, schema | ||
| 10 | + | ||
| 11 | + | ||
| 12 | +class BudgetGuardrailTestBase(unittest.TestCase): | ||
| 13 | + def setUp(self): | ||
| 14 | + self.conn = schema.connect(":memory:") | ||
| 15 | + schema.create_schema(self.conn) | ||
| 16 | + self.conn.execute( | ||
| 17 | + "INSERT INTO project (project_id, name, target_folder_path, has_git_repo, status) " | ||
| 18 | + "VALUES ('proj-1', 'P', '/tmp/proj-1', 1, 'active')" | ||
| 19 | + ) | ||
| 20 | + self.conn.execute( | ||
| 21 | + "INSERT INTO task (task_id, project_id, goal_text, task_type, status, assigned_org_node) " | ||
| 22 | + "VALUES ('task-1', 'proj-1', 'goal', 'goal', 'coding', 'coder')" | ||
| 23 | + ) | ||
| 24 | + self.conn.execute( | ||
| 25 | + "INSERT INTO budget_guardrail_config (config_id, scope, limit_type, limit_value, current_usage, " | ||
| 26 | + "updated_by) VALUES ('default', 'global', 'token_count', 100, 0, 'test')" | ||
| 27 | + ) | ||
| 28 | + self.conn.commit() | ||
| 29 | + | ||
| 30 | + def tearDown(self): | ||
| 31 | + self.conn.close() | ||
| 32 | + | ||
| 33 | + | ||
| 34 | +class CheckBudgetTest(BudgetGuardrailTestBase): | ||
| 35 | + def test_under_limit_allowed_and_accumulates(self): | ||
| 36 | + result = budget_guardrail.check_budget(self.conn, "task-1", "default", 30) | ||
| 37 | + self.assertEqual(result, {"allowed": True, "remaining_quota": 70}) | ||
| 38 | + usage = self.conn.execute("SELECT current_usage FROM budget_guardrail_config WHERE config_id = 'default'").fetchone()[0] | ||
| 39 | + self.assertEqual(usage, 30) | ||
| 40 | + | ||
| 41 | + def test_reaching_limit_blocks_task_and_raises(self): | ||
| 42 | + # REQ-10 happy: small limit, run past it -> blocked_pending_human, | ||
| 43 | + # blocked_reason='budget_exceeded' | ||
| 44 | + with self.assertRaises(budget_guardrail.BudgetExceededError): | ||
| 45 | + budget_guardrail.check_budget(self.conn, "task-1", "default", 150) | ||
| 46 | + row = self.conn.execute("SELECT status, blocked_reason FROM task WHERE task_id = 'task-1'").fetchone() | ||
| 47 | + self.assertEqual(row, ("blocked_pending_human", "budget_exceeded")) | ||
| 48 | + | ||
| 49 | + def test_exceeded_check_does_not_accumulate_usage(self): | ||
| 50 | + with self.assertRaises(budget_guardrail.BudgetExceededError): | ||
| 51 | + budget_guardrail.check_budget(self.conn, "task-1", "default", 150) | ||
| 52 | + usage = self.conn.execute("SELECT current_usage FROM budget_guardrail_config WHERE config_id = 'default'").fetchone()[0] | ||
| 53 | + self.assertEqual(usage, 0) | ||
| 54 | + | ||
| 55 | + def test_blocked_reason_distinct_from_usage_limit_wait_timeout(self): | ||
| 56 | + # REQ-10 failure: the two guardrails must never share a blocked_reason | ||
| 57 | + with self.assertRaises(budget_guardrail.BudgetExceededError): | ||
| 58 | + budget_guardrail.check_budget(self.conn, "task-1", "default", 150) | ||
| 59 | + blocked_reason = self.conn.execute("SELECT blocked_reason FROM task WHERE task_id = 'task-1'").fetchone()[0] | ||
| 60 | + self.assertNotEqual(blocked_reason, "usage_limit_wait_timeout") | ||
| 61 | + self.assertEqual(blocked_reason, "budget_exceeded") | ||
| 62 | + | ||
| 63 | + | ||
| 64 | +class ResumeAfterLimitRaiseTest(BudgetGuardrailTestBase): | ||
| 65 | + def test_raising_limit_resumes_budget_blocked_tasks(self): | ||
| 66 | + with self.assertRaises(budget_guardrail.BudgetExceededError): | ||
| 67 | + budget_guardrail.check_budget(self.conn, "task-1", "default", 150) | ||
| 68 | + | ||
| 69 | + result = budget_guardrail.update_budget_limit_and_resume(self.conn, "default", 1000, "admin") | ||
| 70 | + self.assertEqual(result["resumed_task_ids"], ["task-1"]) | ||
| 71 | + row = self.conn.execute("SELECT status, blocked_reason FROM task WHERE task_id = 'task-1'").fetchone() | ||
| 72 | + self.assertEqual(row, ("coding", None)) # restored to pre-blocked status ('coding') | ||
| 73 | + | ||
| 74 | + def test_does_not_resume_tasks_blocked_for_other_reasons(self): | ||
| 75 | + self.conn.execute( | ||
| 76 | + "UPDATE task SET status = 'blocked_pending_human', blocked_reason = 'loop_limit_reached' " | ||
| 77 | + "WHERE task_id = 'task-1'" | ||
| 78 | + ) | ||
| 79 | + self.conn.commit() | ||
| 80 | + result = budget_guardrail.update_budget_limit_and_resume(self.conn, "default", 1000, "admin") | ||
| 81 | + self.assertEqual(result["resumed_task_ids"], []) | ||
| 82 | + status = self.conn.execute("SELECT status FROM task WHERE task_id = 'task-1'").fetchone()[0] | ||
| 83 | + self.assertEqual(status, "blocked_pending_human") | ||
| 84 | + | ||
| 85 | + | ||
| 86 | +if __name__ == "__main__": | ||
| 87 | + unittest.main() |