Commit 916c76092be0484a5a1b4149f58f8ba3f8432711

Authored by bboonnee
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>
docs/development-plan.md
... ... @@ -187,6 +187,7 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。
187 187 - **U-019 [Risk Gate]** `budget_guardrail_config` + `c_budget_check`(人工配置层)
188 188 - 依赖:U-012
189 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 191 - 覆盖:01-M `budget_guardrail_config`;03-C `c_budget_check`;04-P `p_budget_guardrail`;REQ-10
191 192  
192 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 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 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 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 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 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()
... ...