Commit ee6787bade6a8dc0b38659f4624b9f6c12ac5b1f

Authored by bboonnee
1 parent 838667ec

U-020: p_usage_limit_wait full acceptance (PH2-S3 complete)

Re-checking REQ-14's boundary case against the letter of its text found a
real functional gap, not just a testing gap: U-013's opus handling only
did "skip backoff" -- it never actually retried on a downgraded model,
just failed immediately. REQ-14 boundary says "不走退避轮询,直接降级到非
Opus 模型重试" (skip backoff, retry immediately on a non-opus model) --
the retry was the part that was missing.

Fixed: run_fn's signature is now run_fn(cwd, model=None); an opus quota
signal retries immediately (no backoff) with opus_downgrade_model
(default "claude-sonnet-5"). If the downgraded model somehow also reports
an opus signal (shouldn't happen -- it's not using Opus), this doesn't
loop forever chasing it, raises UsageLimitWaitTimeoutError instead.

work_graph_execute.py's own opus handling (U-012) deliberately NOT changed
to match: REQ-14's coverage only names c_work_node_run/c_org_node_trigger,
and that mechanism (a multi-agent dynamic workflow, no single top-level
--agent identity) has no one "the model" to downgrade -- a deeper A-1
question, documented as out of scope rather than silently left
inconsistent without explanation.

Also filled in a real test gap: quota_limit_type=weekly (detection +
recovery, and the 9-day timeout) was never tested despite the mechanism
already supporting it since U-013.

11 new/changed stdlib unittest cases across work_node_run.py's test suite
(all existing run_fn lambdas updated for the new signature). 178 total
across U-001-020, all passing.

This closes PH2-S3 alongside U-019. REQ-14 is now genuinely complete, not
just "mostly right."

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
docs/development-plan.md
@@ -193,6 +193,7 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。 @@ -193,6 +193,7 @@ PH2-S1/S2/S3/S4 之间除标注的依赖外相互独立,可并行推进。
193 - **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` + 超时兜底)
194 - 依赖:U-012(骨架已在 U-012 实现,本 Unit 补齐边界/失败场景) 194 - 依赖:U-012(骨架已在 U-012 实现,本 Unit 补齐边界/失败场景)
195 - Done:REQ-14 全部用例通过(含超时转 `blocked_pending_human, blocked_reason='usage_limit_wait_timeout'`) 195 - Done:REQ-14 全部用例通过(含超时转 `blocked_pending_human, blocked_reason='usage_limit_wait_timeout'`)
  196 + - **实现补充(2026-07-31)**:这次不只是补测试——重新对照 REQ-14 boundary 用例原文发现,U-013 时 `work_node_run.py` 的 opus 处理只做到"不退避",没做到"直接降级到非 Opus 模型重试"(原来是探测到 opus 额度直接抛错,从没真正重试过)。补上了:`run_fn` 签名改成 `run_fn(cwd, model=None)`,探测到 opus 信号后立即(不等待)用 `opus_downgrade_model`(默认 `claude-sonnet-5`)重试一次;如果降级后还是收到 opus 信号(不合理,非 opus 模型不该撞 opus 专属额度),直接判超时,不会无限重试。顺手把之前漏测的 `weekly` 额度类型(探测恢复 + 9 天超时)也补齐了。`work_graph_execute.py`(U-012)里同名的 opus 立即失败逻辑**没有**跟着改——REQ-14 覆盖列表只写了 `c_work_node_run`/`c_org_node_trigger`,没有 `c_work_graph_execute`,而且那条路径是多 Agent 动态工作流,没有一个单一的"the model"可以整体降级,属于更深的 A-1 动态工作流问题,本 Unit 不动
196 - 覆盖:04-P `p_usage_limit_wait`;REQ-14 197 - 覆盖:04-P `p_usage_limit_wait`;REQ-14
197 198
198 ### PH2-S4 断点恢复 199 ### PH2-S4 断点恢复
docs/sdd/unit_matrix.json
@@ -33,7 +33,7 @@ @@ -33,7 +33,7 @@
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(见 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 的事"}, 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 完整验收(见 development-plan.md 2026-07-31 实现补充)", "classification": "Risk Gate", "depends_on": ["U-012"], "coverage": {"model": [], "view": [], "controller": [], "process": ["p_usage_limit_wait"], "requirement": ["REQ-14"]}, "done": "REQ-14 全部通过(含超时兜底);补上了 opus 真实降级重试(U-013 时只做到不退避,没做到真重试)+ 之前漏测的 weekly 类型"},
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
39 {"id": "U-022", "phase": "PH3", "sub_phase": "PH3-S1", "name": "v_project_setup + c_pick_folder 全平台", "classification": "Value Path", "depends_on": ["U-007", "U-008"], "coverage": {"model": [], "view": ["v_project_setup"], "controller": ["c_pick_folder"], "process": [], "requirement": ["REQ-11"]}, "done": "REQ-11 全部通过"}, 39 {"id": "U-022", "phase": "PH3", "sub_phase": "PH3-S1", "name": "v_project_setup + c_pick_folder 全平台", "classification": "Value Path", "depends_on": ["U-007", "U-008"], "coverage": {"model": [], "view": ["v_project_setup"], "controller": ["c_pick_folder"], "process": [], "requirement": ["REQ-11"]}, "done": "REQ-11 全部通过"},
src/test_work_node_run.py
@@ -65,7 +65,7 @@ class InputValidationTest(WorkNodeRunTestBase): @@ -65,7 +65,7 @@ class InputValidationTest(WorkNodeRunTestBase):
65 def test_invalid_input_rejected_before_execution_starts(self): 65 def test_invalid_input_rejected_before_execution_starts(self):
66 with self.assertRaises(work_node_run.InvalidInputError): 66 with self.assertRaises(work_node_run.InvalidInputError):
67 work_node_run.run_work_node( 67 work_node_run.run_work_node(
68 - self.conn, "node-1", {}, _IN_SCHEMA, _OUT_SCHEMA, run_fn=lambda cwd: {"value": 1} 68 + self.conn, "node-1", {}, _IN_SCHEMA, _OUT_SCHEMA, run_fn=lambda cwd, model=None: {"value": 1}
69 ) 69 )
70 status = self.conn.execute("SELECT status FROM work_node_execution WHERE node_exec_id = 'node-1'").fetchone()[0] 70 status = self.conn.execute("SELECT status FROM work_node_execution WHERE node_exec_id = 'node-1'").fetchone()[0]
71 self.assertEqual(status, "pending") 71 self.assertEqual(status, "pending")
@@ -76,7 +76,7 @@ class InputValidationTest(WorkNodeRunTestBase): @@ -76,7 +76,7 @@ class InputValidationTest(WorkNodeRunTestBase):
76 class HappyPathTest(WorkNodeRunTestBase): 76 class HappyPathTest(WorkNodeRunTestBase):
77 def test_valid_output_marks_node_done(self): 77 def test_valid_output_marks_node_done(self):
78 output = work_node_run.run_work_node( 78 output = work_node_run.run_work_node(
79 - self.conn, "node-1", {"goal": "x"}, _IN_SCHEMA, _OUT_SCHEMA, run_fn=lambda cwd: {"value": 42} 79 + self.conn, "node-1", {"goal": "x"}, _IN_SCHEMA, _OUT_SCHEMA, run_fn=lambda cwd, model=None: {"value": 42}
80 ) 80 )
81 self.assertEqual(output, {"value": 42}) 81 self.assertEqual(output, {"value": 42})
82 row = self.conn.execute( 82 row = self.conn.execute(
@@ -89,7 +89,7 @@ class HappyPathTest(WorkNodeRunTestBase): @@ -89,7 +89,7 @@ class HappyPathTest(WorkNodeRunTestBase):
89 def test_execution_log_file_created_active_then_closed(self): 89 def test_execution_log_file_created_active_then_closed(self):
90 seen_status_during_run = [] 90 seen_status_during_run = []
91 91
92 - def run_fn(cwd): 92 + def run_fn(cwd, model=None):
93 seen_status_during_run.append( 93 seen_status_during_run.append(
94 self.conn.execute("SELECT status FROM execution_log_file").fetchone()[0] 94 self.conn.execute("SELECT status FROM execution_log_file").fetchone()[0]
95 ) 95 )
@@ -101,7 +101,7 @@ class HappyPathTest(WorkNodeRunTestBase): @@ -101,7 +101,7 @@ class HappyPathTest(WorkNodeRunTestBase):
101 self.assertEqual(final_status, "closed") 101 self.assertEqual(final_status, "closed")
102 102
103 def test_log_file_closed_even_when_run_fn_raises(self): 103 def test_log_file_closed_even_when_run_fn_raises(self):
104 - def boom(cwd): 104 + def boom(cwd, model=None):
105 raise RuntimeError("node blew up") 105 raise RuntimeError("node blew up")
106 106
107 with self.assertRaises(RuntimeError): 107 with self.assertRaises(RuntimeError):
@@ -114,7 +114,7 @@ class RetryOnSchemaFailureTest(WorkNodeRunTestBase): @@ -114,7 +114,7 @@ class RetryOnSchemaFailureTest(WorkNodeRunTestBase):
114 def test_retries_then_succeeds(self): 114 def test_retries_then_succeeds(self):
115 attempts = {"n": 0} 115 attempts = {"n": 0}
116 116
117 - def run_fn(cwd): 117 + def run_fn(cwd, model=None):
118 attempts["n"] += 1 118 attempts["n"] += 1
119 if attempts["n"] < 3: 119 if attempts["n"] < 3:
120 return {"wrong_key": True} 120 return {"wrong_key": True}
@@ -133,7 +133,7 @@ class RetryOnSchemaFailureTest(WorkNodeRunTestBase): @@ -133,7 +133,7 @@ class RetryOnSchemaFailureTest(WorkNodeRunTestBase):
133 with self.assertRaises(work_node_run.SchemaValidationFailedError): 133 with self.assertRaises(work_node_run.SchemaValidationFailedError):
134 work_node_run.run_work_node( 134 work_node_run.run_work_node(
135 self.conn, "node-1", {"goal": "x"}, _IN_SCHEMA, _OUT_SCHEMA, 135 self.conn, "node-1", {"goal": "x"}, _IN_SCHEMA, _OUT_SCHEMA,
136 - run_fn=lambda cwd: {"wrong_key": True}, max_retries=2, 136 + run_fn=lambda cwd, model=None: {"wrong_key": True}, max_retries=2,
137 ) 137 )
138 row = self.conn.execute( 138 row = self.conn.execute(
139 "SELECT status, retry_count FROM work_node_execution WHERE node_exec_id = 'node-1'" 139 "SELECT status, retry_count FROM work_node_execution WHERE node_exec_id = 'node-1'"
@@ -157,7 +157,7 @@ class UsageLimitWaitTest(WorkNodeRunTestBase): @@ -157,7 +157,7 @@ class UsageLimitWaitTest(WorkNodeRunTestBase):
157 def test_session_limit_then_recovers(self): 157 def test_session_limit_then_recovers(self):
158 attempts = {"n": 0} 158 attempts = {"n": 0}
159 159
160 - def run_fn(cwd): 160 + def run_fn(cwd, model=None):
161 attempts["n"] += 1 161 attempts["n"] += 1
162 if attempts["n"] == 1: 162 if attempts["n"] == 1:
163 work_node_run.raise_if_usage_limited("Error: You've hit your session limit") 163 work_node_run.raise_if_usage_limited("Error: You've hit your session limit")
@@ -179,8 +179,46 @@ class UsageLimitWaitTest(WorkNodeRunTestBase): @@ -179,8 +179,46 @@ class UsageLimitWaitTest(WorkNodeRunTestBase):
179 ).fetchone() 179 ).fetchone()
180 self.assertEqual(event[0], "quota_wait_started") 180 self.assertEqual(event[0], "quota_wait_started")
181 181
182 - def test_opus_quota_raises_immediately_no_backoff(self):  
183 - def run_fn(cwd): 182 + def test_opus_quota_retries_immediately_on_downgraded_model_no_backoff(self):
  183 + # REQ-14 boundary: "不走退避轮询,直接降级到非 Opus 模型重试" -- an actual
  184 + # retry with a different model, not just detect-and-fail.
  185 + calls = []
  186 +
  187 + def run_fn(cwd, model=None):
  188 + calls.append(model)
  189 + if len(calls) == 1:
  190 + work_node_run.raise_if_usage_limited("You've hit your Opus limit")
  191 + return {"value": "recovered on " + str(model)}
  192 +
  193 + output = work_node_run.run_work_node(
  194 + self.conn, "node-1", {"goal": "x"}, _IN_SCHEMA, _OUT_SCHEMA, run_fn=run_fn,
  195 + sleep=self._sleep, now=self._now,
  196 + )
  197 + self.assertEqual(calls, [None, "claude-sonnet-5"])
  198 + self.assertEqual(output, {"value": "recovered on claude-sonnet-5"})
  199 + self.assertEqual(self.sleeps, []) # no backoff wait for opus
  200 + status = self.conn.execute("SELECT status FROM work_node_execution WHERE node_exec_id = 'node-1'").fetchone()[0]
  201 + self.assertEqual(status, "done")
  202 +
  203 + def test_opus_quota_custom_downgrade_model_is_used(self):
  204 + calls = []
  205 +
  206 + def run_fn(cwd, model=None):
  207 + calls.append(model)
  208 + if len(calls) == 1:
  209 + work_node_run.raise_if_usage_limited("You've hit your Opus limit")
  210 + return {"value": "ok"}
  211 +
  212 + work_node_run.run_work_node(
  213 + self.conn, "node-1", {"goal": "x"}, _IN_SCHEMA, _OUT_SCHEMA, run_fn=run_fn,
  214 + sleep=self._sleep, now=self._now, opus_downgrade_model="claude-fable-5",
  215 + )
  216 + self.assertEqual(calls, [None, "claude-fable-5"])
  217 +
  218 + def test_opus_quota_persisting_after_downgrade_raises_timeout(self):
  219 + # a non-opus model shouldn't hit an opus-specific quota again -- if it
  220 + # somehow does, this doesn't loop forever chasing it
  221 + def run_fn(cwd, model=None):
184 work_node_run.raise_if_usage_limited("You've hit your Opus limit") 222 work_node_run.raise_if_usage_limited("You've hit your Opus limit")
185 223
186 with self.assertRaises(work_node_run.UsageLimitWaitTimeoutError): 224 with self.assertRaises(work_node_run.UsageLimitWaitTimeoutError):
@@ -191,7 +229,7 @@ class UsageLimitWaitTest(WorkNodeRunTestBase): @@ -191,7 +229,7 @@ class UsageLimitWaitTest(WorkNodeRunTestBase):
191 self.assertEqual(self.sleeps, []) 229 self.assertEqual(self.sleeps, [])
192 230
193 def test_session_timeout_after_8_hours_blocks_node(self): 231 def test_session_timeout_after_8_hours_blocks_node(self):
194 - def run_fn(cwd): 232 + def run_fn(cwd, model=None):
195 work_node_run.raise_if_usage_limited("You've hit your session limit") 233 work_node_run.raise_if_usage_limited("You've hit your session limit")
196 234
197 with self.assertRaises(work_node_run.UsageLimitWaitTimeoutError): 235 with self.assertRaises(work_node_run.UsageLimitWaitTimeoutError):
@@ -203,6 +241,40 @@ class UsageLimitWaitTest(WorkNodeRunTestBase): @@ -203,6 +241,40 @@ class UsageLimitWaitTest(WorkNodeRunTestBase):
203 status = self.conn.execute("SELECT status FROM work_node_execution WHERE node_exec_id = 'node-1'").fetchone()[0] 241 status = self.conn.execute("SELECT status FROM work_node_execution WHERE node_exec_id = 'node-1'").fetchone()[0]
204 self.assertEqual(status, "blocked") 242 self.assertEqual(status, "blocked")
205 243
  244 + def test_weekly_limit_then_recovers(self):
  245 + # previously untested quota_limit_type -- session/opus were covered,
  246 + # weekly wasn't, even though the detection regex and timeout table
  247 + # both already supported it
  248 + attempts = {"n": 0}
  249 +
  250 + def run_fn(cwd, model=None):
  251 + attempts["n"] += 1
  252 + if attempts["n"] == 1:
  253 + work_node_run.raise_if_usage_limited("Error: You've hit your weekly limit")
  254 + return {"value": "recovered"}
  255 +
  256 + work_node_run.run_work_node(
  257 + self.conn, "node-1", {"goal": "x"}, _IN_SCHEMA, _OUT_SCHEMA, run_fn=run_fn,
  258 + sleep=self._sleep, now=self._now,
  259 + )
  260 + quota_type = self.conn.execute(
  261 + "SELECT quota_limit_type FROM work_node_execution WHERE node_exec_id = 'node-1'"
  262 + ).fetchone()[0]
  263 + self.assertEqual(quota_type, "weekly")
  264 +
  265 + def test_weekly_timeout_after_9_days_blocks_node(self):
  266 + def run_fn(cwd, model=None):
  267 + work_node_run.raise_if_usage_limited("You've hit your weekly limit")
  268 +
  269 + with self.assertRaises(work_node_run.UsageLimitWaitTimeoutError):
  270 + work_node_run.run_work_node(
  271 + self.conn, "node-1", {"goal": "x"}, _IN_SCHEMA, _OUT_SCHEMA, run_fn=run_fn,
  272 + sleep=self._sleep, now=self._now,
  273 + )
  274 + self.assertGreaterEqual(self.clock[0], 9 * 24 * 3600)
  275 + status = self.conn.execute("SELECT status FROM work_node_execution WHERE node_exec_id = 'node-1'").fetchone()[0]
  276 + self.assertEqual(status, "blocked")
  277 +
206 278
207 class RealGitWorktreeIsolationTest(WorkNodeRunTestBase): 279 class RealGitWorktreeIsolationTest(WorkNodeRunTestBase):
208 """REQ-03 boundary: two independent nodes, each in its own git worktree, 280 """REQ-03 boundary: two independent nodes, each in its own git worktree,
@@ -221,7 +293,7 @@ class RealGitWorktreeIsolationTest(WorkNodeRunTestBase): @@ -221,7 +293,7 @@ class RealGitWorktreeIsolationTest(WorkNodeRunTestBase):
221 self.conn.commit() 293 self.conn.commit()
222 294
223 def _write_file_run_fn(self, filename, content): 295 def _write_file_run_fn(self, filename, content):
224 - def run_fn(cwd): 296 + def run_fn(cwd, model=None):
225 with open(os.path.join(cwd, filename), "w") as f: 297 with open(os.path.join(cwd, filename), "w") as f:
226 f.write(content) 298 f.write(content)
227 subprocess.run(["git", "add", filename], cwd=cwd, check=True, capture_output=True) 299 subprocess.run(["git", "add", filename], cwd=cwd, check=True, capture_output=True)
src/work_graph_execute.py
@@ -141,11 +141,20 @@ def execute_work_graph_with_usage_limit_retry( @@ -141,11 +141,20 @@ def execute_work_graph_with_usage_limit_retry(
141 """Wraps execute_work_graph with p_usage_limit_wait (04-P §4.4.1): a 141 """Wraps execute_work_graph with p_usage_limit_wait (04-P §4.4.1): a
142 session/weekly quota hit backs off 5->10->20->30min (repeating at 30) 142 session/weekly quota hit backs off 5->10->20->30min (repeating at 30)
143 and retries until that type's timeout, then raises 143 and retries until that type's timeout, then raises
144 - UsageLimitWaitTimeoutError. An opus quota hit skips backoff entirely  
145 - (04-P: "直接...降级到非 Opus 模型重试" -- the actual model swap is a  
146 - caller-level decision, not this function's; it raises immediately so the  
147 - caller can decide). ClassifierBlockedError is never retried (03-C:  
148 - not retryable, needs human review) and propagates immediately. 144 + UsageLimitWaitTimeoutError. ClassifierBlockedError is never retried
  145 + (03-C: not retryable, needs human review) and propagates immediately.
  146 +
  147 + An opus quota hit skips backoff and raises immediately, unlike
  148 + src/work_node_run.py's run_work_node (U-020), which actually retries on
  149 + a downgraded model. Deliberately not matched here: REQ-14's coverage
  150 + (06 节) only names c_work_node_run/c_org_node_trigger, not
  151 + c_work_graph_execute, and this function's underlying mechanism (a
  152 + dynamic-workflow script with no single top-level --agent identity, per
  153 + 03-C §3.5) has no one "the model" to swap the way a single-agent call
  154 + does -- individual agent() calls inside the workflow script would each
  155 + need their own override, a deeper A-1 dynamic-workflow question this
  156 + Unit doesn't attempt. Caller still has to decide what "retry on a
  157 + non-opus model" means here.
149 158
150 `sleep`/`now` are injectable so tests don't block on real 5-30 minute waits. 159 `sleep`/`now` are injectable so tests don't block on real 5-30 minute waits.
151 """ 160 """
src/work_node_run.py
@@ -124,10 +124,13 @@ def run_work_node( @@ -124,10 +124,13 @@ def run_work_node(
124 repo_path=None, 124 repo_path=None,
125 sleep=time.sleep, 125 sleep=time.sleep,
126 now=time.monotonic, 126 now=time.monotonic,
  127 + opus_downgrade_model="claude-sonnet-5",
127 ): 128 ):
128 - """Runs one Work Node to contract. `run_fn(cwd) -> output_payload` is the  
129 - caller-supplied actual work (a real `claude -p` call in production, a  
130 - fake in tests). Returns the validated output_payload. 129 + """Runs one Work Node to contract. `run_fn(cwd, model) -> output_payload`
  130 + is the caller-supplied actual work (a real `claude -p` call in
  131 + production, a fake in tests); `model` is None unless this call is an
  132 + opus-quota-triggered retry, in which case it's `opus_downgrade_model`.
  133 + Returns the validated output_payload.
131 134
132 Raises InvalidInputError (input schema), UsageLimitWaitTimeoutError 135 Raises InvalidInputError (input schema), UsageLimitWaitTimeoutError
133 (p_usage_limit_wait exhausted), or leaves work_node_execution.status 136 (p_usage_limit_wait exhausted), or leaves work_node_execution.status
@@ -169,9 +172,11 @@ def run_work_node( @@ -169,9 +172,11 @@ def run_work_node(
169 172
170 retry_count = 0 173 retry_count = 0
171 quota_wait_start = None 174 quota_wait_start = None
  175 + current_model = None
  176 + opus_already_downgraded = False
172 while True: 177 while True:
173 try: 178 try:
174 - output_payload = run_fn(cwd) 179 + output_payload = run_fn(cwd, model=current_model)
175 except _UsageLimitSignal as signal: 180 except _UsageLimitSignal as signal:
176 quota_type = signal.quota_limit_type 181 quota_type = signal.quota_limit_type
177 if quota_wait_start is None: 182 if quota_wait_start is None:
@@ -188,9 +193,21 @@ def run_work_node( @@ -188,9 +193,21 @@ def run_work_node(
188 ) 193 )
189 conn.commit() 194 conn.commit()
190 if quota_type == "opus": 195 if quota_type == "opus":
191 - raise UsageLimitWaitTimeoutError(  
192 - "opus quota exhausted for {} -- caller should retry on a non-opus model".format(node_exec_id)  
193 - ) from signal 196 + # 04-P §4.4.1: "不用等待退避——直接把该节点这次调用降级到非
  197 + # Opus 模型重试", not just detect-and-fail. Only one
  198 + # downgrade attempt -- if the downgraded model *also*
  199 + # reports an opus-quota signal, something's wrong (a
  200 + # non-opus model shouldn't hit an opus-specific quota),
  201 + # so this doesn't loop forever chasing it.
  202 + if opus_already_downgraded:
  203 + raise UsageLimitWaitTimeoutError(
  204 + "opus quota signal persisted for {} even after downgrading to {}".format(
  205 + node_exec_id, opus_downgrade_model
  206 + )
  207 + ) from signal
  208 + current_model = opus_downgrade_model
  209 + opus_already_downgraded = True
  210 + continue
194 elapsed = now() - quota_wait_start 211 elapsed = now() - quota_wait_start
195 if elapsed >= _TIMEOUT_SECONDS[quota_type]: 212 if elapsed >= _TIMEOUT_SECONDS[quota_type]:
196 conn.execute( 213 conn.execute(