From 55fa8132b6317358699707261dfd374e1858ed70 Mon Sep 17 00:00:00 2001 From: tizerluo <192086140+tizerluo@users.noreply.github.com> Date: Sat, 3 Oct 2026 13:31:09 -0400 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20=E6=B7=B1=E5=AE=A1=E8=B6=85=E6=97=B6?= =?UTF-8?q?=E6=B2=BB=E7=90=86=20=E2=80=94=20review=20=E5=8D=95=E6=AC=A1?= =?UTF-8?q?=E8=B0=83=E7=94=A8=E9=BB=98=E8=AE=A4=20300=E2=86=921200s=20+=20?= =?UTF-8?q?=E8=B6=85=E6=97=B6=E7=BB=93=E6=9E=84=E5=8C=96=E6=A0=87=E5=9B=A0?= =?UTF-8?q?=20(#54)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 根因 (#54 调研): 未显式设 ZCODE_BRIDGE_REVIEW_TIMEOUT 的调用方 (人工/agent 的 --call 与 MCP 直调) 走 300s 默认预算, 而 depth=deep 复核是多轮读文件+推理循环, 闸门侧实测两仓 21/28 次深审中位时长 305s/409s (57%/71% 超 300s) — 对 deep 档 必然超时, 表现为 'LLM 阶段 300s 超时 exit2/isError 无 verdict'。review-gate 自身显式设 3600 不受默认值影响, 因此只有闸门外的调用方受害。 变更: - _review_timeout() 默认 300 → 1200, docstring 记实测依据与取舍 (挂死子进程 占用 ReviewFileLock 的时长随之变长, 锁等待仍 300s) - _run_zcode_headless 超时分支: structured_output=True 时 result 顶层附 timeout {seconds} — 与 quota_limit/refusal 同型的机器契约, 调用方可据此 区分预算不足与其他失败, 不必解析 isError 文本 - README env 表 + 并发最坏阻塞估算同步 (约 20 分钟 → 约 85 分钟, 注明依据) 测试: RC4 默认值断言更新 300→1200; 新增 TestTimeoutAttribution TA1/TA2 (结构化键存在/普通形状不变)。全套 542 passed, ruff 干净。 --- README.md | 4 +-- packages/mcp-server/zcode-mcp-server | 28 ++++++++++++++---- tests/test_security_review.py | 43 ++++++++++++++++++++++++++-- 3 files changed, 65 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index d7e2c8d..b80c1bb 100644 --- a/README.md +++ b/README.md @@ -266,7 +266,7 @@ ZCODE_BASE_URL=https://api.z.ai/api/anthropic ./packages/mcp-server/zcode-mcp-se | **项目配置拒审**(issue #49 洞三) | 被审目录链(cwd 到 git 根)存在 `zcode.json` / `.zcode/config.json` 时 **fail-closed 拒审**(`isError` + 顶层结构化 `refusal` 键;review-gate 据此按终态告警处理,不当临时失败重试)——该文件的 `mcp.servers` 会被 zcode 启动时直接 spawn。审查子进程 `--cwd` 恒为隔离沙箱(含空 `.git` 阻断配置上溯),被审仓库根目录经 prompt 以绝对路径提供 | `ZCODE_BRIDGE_TRUST_PROJECT_CONFIG=1` 显式放行可信仓库(`0/false/no/off/disabled` 均视为关) | | **provider 错误解析** | 识别 429 / 1302 / `Too Many Requests` / `请求过于频繁` / `retry-after`,区分限流/配额/其他 | — | | **有限重试 + 退避** | 仅对**限流**错误重试(配额/Unauthorized 不重试),退避用 retry-after 或指数退避(`2^n+1`) | `ZCODE_BRIDGE_MAX_RETRIES`(默认 3) | -| **单次调用超时** | review 单次 zcode 调用超时 | `ZCODE_BRIDGE_REVIEW_TIMEOUT`(默认 300s,下限 30s) | +| **单次调用超时** | review 单次 zcode 调用超时 | `ZCODE_BRIDGE_REVIEW_TIMEOUT`(默认 1200s,下限 30s;旧默认 300s 对 depth=deep 普遍不够——闸门实测两仓中位 305s/409s、57%/71% 超 300s,未显式设值的调用方对 deep 档必然超时,见 #54。review-gate 自身显式设 3600,不受默认值影响) | | **code 参数体积上限** | `zcode_review` 的 `code` 参数超过上限即截断,防超大内联代码撑爆调用 | `ZCODE_BRIDGE_CODE_MAX`(默认 500KB) | | **zcode 输出体积上限** | zcode stdout 输出超过上限即截断 | `ZCODE_BRIDGE_MAX_OUTPUT`(默认 10MB) | @@ -299,7 +299,7 @@ ACP bridge 侧另有一个 env(不在上两表,仅 ACP 用):`ZCODE_ACP_D > > 实测备注(2026-08-08,GC-8G):① 独立调用时 mimosa 也会在被扫项目写一个小会话状态文件(`.mimosa/hook-state/sess_*.continue.json`,约 200 字节,无害)——即 bridge 自身的代码路径对被扫目录只读,但 mimosa 引擎会落这个状态文件,说"完全只读"不准确;② 从非登录 shell(systemd unit、cron、`sudo -u` 直调)启动时 PATH 可能不含 `~/.local/bin`,需显式 `export PATH="$HOME/.local/bin:$PATH"` 否则找不到 `zcode`。 > -> 并发与阻塞边界(狗食 review P2-4/P2-5):mimosa 预扫**不在** review 文件锁内(确定性引擎无 LLM 限流问题),只有 zcode 复核阶段持锁——并发扫同一项目时 mimosa 的 hook-state 文件各写各的会话,无冲突。最坏阻塞时长估算:锁等待 300s + 单次调用 `ZCODE_BRIDGE_REVIEW_TIMEOUT`(默认 300s)×(1 + `ZCODE_BRIDGE_MAX_RETRIES` 默认 3)+ 限流退避,极端情况单次 tool 调用可阻塞约 20 分钟;depth=deep 时前面还要再加 mimosa 异步扫描预算(`ZCODE_BRIDGE_MIMOSA_DEEP_TIMEOUT` 默认 900s)。调用方应把 MCP 超时设到相应量级。 +> 并发与阻塞边界(狗食 review P2-4/P2-5):mimosa 预扫**不在** review 文件锁内(确定性引擎无 LLM 限流问题),只有 zcode 复核阶段持锁——并发扫同一项目时 mimosa 的 hook-state 文件各写各的会话,无冲突。最坏阻塞时长估算:锁等待 300s + 单次调用 `ZCODE_BRIDGE_REVIEW_TIMEOUT`(默认 1200s)×(1 + `ZCODE_BRIDGE_MAX_RETRIES` 默认 3)+ 限流退避,极端情况单次 tool 调用可阻塞约 85 分钟;depth=deep 时前面还要再加 mimosa 异步扫描预算(`ZCODE_BRIDGE_MIMOSA_DEEP_TIMEOUT` 默认 900s)。调用方应把 MCP 超时设到相应量级——**该默认值是 #54 后按 deep 实测中位(305s/409s)上调的**,代价是挂死子进程占用文件锁的时长随之变长(锁等待仍 300s)。 ### 聚焦审查 prompt 建议 diff --git a/packages/mcp-server/zcode-mcp-server b/packages/mcp-server/zcode-mcp-server index 5ff9ab0..e1b6640 100755 --- a/packages/mcp-server/zcode-mcp-server +++ b/packages/mcp-server/zcode-mcp-server @@ -1235,8 +1235,9 @@ def _run_zcode_headless(cmd, env, timeout, structured_output=False): 锁与重试语义见 ReviewFileLock / _parse_provider_error 注释 (issue #3)。 structured_output=True (仅 zcode_pr_review 用, #35): 失败时若 stderr 命中 1308 额度错误, result 顶层附 "quota_limit": {"code": 1308, "reset_at": - }; 成功时有 usage 时附 "usage": {"total_tokens": }。其余 - review tool 的 result 形状保持不变。 + }; 子进程超时附 "timeout": {"seconds": } (#54); 成功时有 + usage 时附 "usage": {"total_tokens": }。其余 review tool 的 result + 形状保持不变。 """ # 有限重试配置 (issue #3 子项3c): 仅对 provider 限流错误重试, 退避指数。 # clamp 到 >=0, 防止 ZCODE_BRIDGE_MAX_RETRIES=-1 导致不执行 zcode (Codex P2) @@ -1317,8 +1318,15 @@ def _run_zcode_headless(cmd, env, timeout, structured_output=False): return ok_result except subprocess.TimeoutExpired: - return {"content": [{"type": "text", "text": f"zcode review 超时 ({timeout}s)"}], - "isError": True} + err_result = {"content": [{"type": "text", + "text": f"zcode review 超时 ({timeout}s)"}], + "isError": True} + if structured_output: + # #54: 超时结构化标因 — 与 quota_limit/refusal 同型的机器契约 + # (result 顶层附加键), 让调用方能区分「预算不足/过慢」与 + # 其他失败 (如限流/拒审), 而不必解析 isError 文本。 + err_result["timeout"] = {"seconds": timeout} + return err_result # 理论不可达 (循环内都 return), 兜底 return {"content": [{"type": "text", "text": f"zcode 调用失败: 重试耗尽 {last_err}"}], "isError": True} @@ -1328,8 +1336,16 @@ def _run_zcode_headless(cmd, env, timeout, structured_output=False): def _review_timeout(): - """review 单次 zcode 调用超时 (秒), ZCODE_BRIDGE_REVIEW_TIMEOUT 可配。""" - return max(30, _env_int("ZCODE_BRIDGE_REVIEW_TIMEOUT", 300, maximum=3600)) + """review 单次 zcode 调用超时 (秒), ZCODE_BRIDGE_REVIEW_TIMEOUT 可配。 + + 默认 1200 (#54): 300 对 depth=deep 普遍不够 — deep 复核是多轮「读文件+ + 推理」循环, 实测两仓各 21/28 次闸门深审的中位时长为 305s/409s, 57%/71% + 超 300s; 慢模型 (TTFT 1-3min/请求) 下更甚。300 默认曾让所有未显式设 + ZCODE_BRIDGE_REVIEW_TIMEOUT 的调用方 (人工/agent 的 --call 与 MCP 直调) + 对 deep 档必然超时 (gate 自身不受影响, 见 review-gate 的显式 3600)。 + 注意代价: 挂死子进程占用 ReviewFileLock 的时长随之变长 (锁等待仍 300s)。 + """ + return max(30, _env_int("ZCODE_BRIDGE_REVIEW_TIMEOUT", 1200, maximum=3600)) def _write_temp(text, prefix): diff --git a/tests/test_security_review.py b/tests/test_security_review.py index 2b98d04..e97312a 100644 --- a/tests/test_security_review.py +++ b/tests/test_security_review.py @@ -176,9 +176,9 @@ def test_rc3_prompt_pins_review_only(self): self.assertIn("只读工具", prompt) def test_rc4_timeout_env_configurable(self): - """RC4: 单次超时由 ZCODE_BRIDGE_REVIEW_TIMEOUT 控制 (默认 300)""" + """RC4: 单次超时由 ZCODE_BRIDGE_REVIEW_TIMEOUT 控制 (默认 1200, #54)""" mod = self.mod - self.assertEqual(mod._review_timeout(), 300) + self.assertEqual(mod._review_timeout(), 1200) os.environ["ZCODE_BRIDGE_REVIEW_TIMEOUT"] = "60" self.assertEqual(mod._review_timeout(), 60) os.environ["ZCODE_BRIDGE_REVIEW_TIMEOUT"] = "1" # clamp 下限 30 @@ -242,6 +242,45 @@ def fake_run(cmd, *a, **kw): self.assertLess(len(captured["content"]), 11000, "截断后应在上限附近") +class TestTimeoutAttribution(_EnvGuard): + """#54: 超时路径的结构化标因 — 与 quota_limit/refusal 同型的顶层附加键。""" + + @classmethod + def setUpClass(cls): + cls.mod = _load_mcp_module() + + def _run_with_timeout_exc(self, structured_output): + """patch subprocess.run 抛 TimeoutExpired, 直调 _run_zcode_headless。""" + mod = self.mod + + def fake_run(cmd, *a, **kw): + raise subprocess.TimeoutExpired(cmd, kw.get("timeout", 0)) + + saved = mod.subprocess.run + mod.subprocess.run = fake_run + os.environ["ZCODE_BRIDGE_REVIEW_LOCK"] = "0" + try: + return mod._run_zcode_headless( + ["zcode", "--prompt", "x"], env={}, timeout=5, + structured_output=structured_output) + finally: + mod.subprocess.run = saved + + def test_ta1_timeout_structured_key(self): + """TA1: structured_output=True 时超时结果附 timeout 键""" + result = self._run_with_timeout_exc(structured_output=True) + self.assertTrue(result.get("isError")) + self.assertEqual(result.get("timeout"), {"seconds": 5}) + self.assertIn("超时 (5s)", result["content"][0]["text"]) + + def test_ta2_timeout_plain_shape_unchanged(self): + """TA2: structured_output=False 时 result 形状不变 (无 timeout 键)""" + result = self._run_with_timeout_exc(structured_output=False) + self.assertTrue(result.get("isError")) + self.assertNotIn("timeout", result) + self.assertNotIn("quota_limit", result) + + class TestProjectConfigGuard(_EnvGuard): """issue #49 洞三防护: 被审目录链上的 zcode 项目配置 → 拒审 + 干净工作目录。 From 63c9f58664c463c523966d3a3af2924a122de3d7 Mon Sep 17 00:00:00 2001 From: tizerluo <192086140+tizerluo@users.noreply.github.com> Date: Sat, 3 Oct 2026 14:10:23 -0400 Subject: [PATCH 2/2] =?UTF-8?q?fix:=20R1=20=E5=AE=A1=E6=9F=A5=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20=E2=80=94=20P2=C3=972=20+=20P3=C3=974=20=E5=85=A8?= =?UTF-8?q?=E9=87=87=E7=BA=B3=20(#54)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 按 code-reviewer 审查意见修复 (无 P0/P1): - P2-1 (方向 B, 保守): 锁等待维持 300s 不动, 语义文档化 — 深审常态超 300s 后并发直调的第二个 review 在锁等待耗尽时快速失败 (fail-fast, 调用方稍后重试); 并为锁超时失败补结构化键 lock_timeout {seconds} (与 timeout/refusal 同型机器契约, 仅 structured_output)。docstring 与 主 README 并发段落同步说明。 - P2-2: review-gate 源码注释与子包 README 的「mcp-server 默认 300s」 过期陈述同步为 1200/上限 3600 (本 PR 改默认值时漏改的两处)。 - P3-1: 「对 deep 档必然超时」→「多数超时」(与 57%/71% 实测自洽), mcp-server docstring + 主 README env 表两处。 - P3-2: TA1/TA2 补子进程调用计数断言 ==1, 钉住「超时不进限流重试 循环」(对齐 SQ1 惯例)。 - P3-3: 模块 docstring 覆盖清单补 #54 条目。 - P3-4: timeout/lock_timeout 键的仓内消费方不在本 PR 接线 (gate 失败 分类变更属 #54 后续 degraded verdict 议题), PR 描述记消费计划。 测试: 新增 TA3 (锁超时键 + 锁失败先于 spawn + 与 timeout 键区分); 全套 543 passed, ruff 干净。 --- README.md | 4 +- packages/mcp-server/zcode-mcp-server | 25 ++++++++---- packages/review-gate/README.md | 5 ++- packages/review-gate/zcode-review-gate | 3 +- tests/test_security_review.py | 54 +++++++++++++++++++++++--- 5 files changed, 73 insertions(+), 18 deletions(-) diff --git a/README.md b/README.md index b80c1bb..8feb3df 100644 --- a/README.md +++ b/README.md @@ -266,7 +266,7 @@ ZCODE_BASE_URL=https://api.z.ai/api/anthropic ./packages/mcp-server/zcode-mcp-se | **项目配置拒审**(issue #49 洞三) | 被审目录链(cwd 到 git 根)存在 `zcode.json` / `.zcode/config.json` 时 **fail-closed 拒审**(`isError` + 顶层结构化 `refusal` 键;review-gate 据此按终态告警处理,不当临时失败重试)——该文件的 `mcp.servers` 会被 zcode 启动时直接 spawn。审查子进程 `--cwd` 恒为隔离沙箱(含空 `.git` 阻断配置上溯),被审仓库根目录经 prompt 以绝对路径提供 | `ZCODE_BRIDGE_TRUST_PROJECT_CONFIG=1` 显式放行可信仓库(`0/false/no/off/disabled` 均视为关) | | **provider 错误解析** | 识别 429 / 1302 / `Too Many Requests` / `请求过于频繁` / `retry-after`,区分限流/配额/其他 | — | | **有限重试 + 退避** | 仅对**限流**错误重试(配额/Unauthorized 不重试),退避用 retry-after 或指数退避(`2^n+1`) | `ZCODE_BRIDGE_MAX_RETRIES`(默认 3) | -| **单次调用超时** | review 单次 zcode 调用超时 | `ZCODE_BRIDGE_REVIEW_TIMEOUT`(默认 1200s,下限 30s;旧默认 300s 对 depth=deep 普遍不够——闸门实测两仓中位 305s/409s、57%/71% 超 300s,未显式设值的调用方对 deep 档必然超时,见 #54。review-gate 自身显式设 3600,不受默认值影响) | +| **单次调用超时** | review 单次 zcode 调用超时 | `ZCODE_BRIDGE_REVIEW_TIMEOUT`(默认 1200s,下限 30s;旧默认 300s 对 depth=deep 普遍不够——闸门实测两仓中位 305s/409s、57%/71% 超 300s,未显式设值的调用方对 deep 档多数超时,见 #54。review-gate 自身显式设 3600,不受默认值影响) | | **code 参数体积上限** | `zcode_review` 的 `code` 参数超过上限即截断,防超大内联代码撑爆调用 | `ZCODE_BRIDGE_CODE_MAX`(默认 500KB) | | **zcode 输出体积上限** | zcode stdout 输出超过上限即截断 | `ZCODE_BRIDGE_MAX_OUTPUT`(默认 10MB) | @@ -299,7 +299,7 @@ ACP bridge 侧另有一个 env(不在上两表,仅 ACP 用):`ZCODE_ACP_D > > 实测备注(2026-08-08,GC-8G):① 独立调用时 mimosa 也会在被扫项目写一个小会话状态文件(`.mimosa/hook-state/sess_*.continue.json`,约 200 字节,无害)——即 bridge 自身的代码路径对被扫目录只读,但 mimosa 引擎会落这个状态文件,说"完全只读"不准确;② 从非登录 shell(systemd unit、cron、`sudo -u` 直调)启动时 PATH 可能不含 `~/.local/bin`,需显式 `export PATH="$HOME/.local/bin:$PATH"` 否则找不到 `zcode`。 > -> 并发与阻塞边界(狗食 review P2-4/P2-5):mimosa 预扫**不在** review 文件锁内(确定性引擎无 LLM 限流问题),只有 zcode 复核阶段持锁——并发扫同一项目时 mimosa 的 hook-state 文件各写各的会话,无冲突。最坏阻塞时长估算:锁等待 300s + 单次调用 `ZCODE_BRIDGE_REVIEW_TIMEOUT`(默认 1200s)×(1 + `ZCODE_BRIDGE_MAX_RETRIES` 默认 3)+ 限流退避,极端情况单次 tool 调用可阻塞约 85 分钟;depth=deep 时前面还要再加 mimosa 异步扫描预算(`ZCODE_BRIDGE_MIMOSA_DEEP_TIMEOUT` 默认 900s)。调用方应把 MCP 超时设到相应量级——**该默认值是 #54 后按 deep 实测中位(305s/409s)上调的**,代价是挂死子进程占用文件锁的时长随之变长(锁等待仍 300s)。 +> 并发与阻塞边界(狗食 review P2-4/P2-5):mimosa 预扫**不在** review 文件锁内(确定性引擎无 LLM 限流问题),只有 zcode 复核阶段持锁——并发扫同一项目时 mimosa 的 hook-state 文件各写各的会话,无冲突。最坏阻塞时长估算:锁等待 300s + 单次调用 `ZCODE_BRIDGE_REVIEW_TIMEOUT`(默认 1200s)×(1 + `ZCODE_BRIDGE_MAX_RETRIES` 默认 3)+ 限流退避,极端情况单次 tool 调用可阻塞约 85 分钟;depth=deep 时前面还要再加 mimosa 异步扫描预算(`ZCODE_BRIDGE_MIMOSA_DEEP_TIMEOUT` 默认 900s)。调用方应把 MCP 超时设到相应量级——**该默认值是 #54 后按 deep 实测中位(305s/409s)上调的**,代价是挂死子进程占用文件锁的时长随之变长(锁等待仍 300s)。**注意并发直调语义(#54)**:deep 常态已超 300s,两个并发直调 review 里后到者会在锁等待 300s 耗尽后**快速失败**(结构化键 `lock_timeout`,与 `timeout` 同型),而非排队等前者跑完——调用方应稍后重试。 ### 聚焦审查 prompt 建议 diff --git a/packages/mcp-server/zcode-mcp-server b/packages/mcp-server/zcode-mcp-server index e1b6640..208ffb5 100755 --- a/packages/mcp-server/zcode-mcp-server +++ b/packages/mcp-server/zcode-mcp-server @@ -1235,9 +1235,9 @@ def _run_zcode_headless(cmd, env, timeout, structured_output=False): 锁与重试语义见 ReviewFileLock / _parse_provider_error 注释 (issue #3)。 structured_output=True (仅 zcode_pr_review 用, #35): 失败时若 stderr 命中 1308 额度错误, result 顶层附 "quota_limit": {"code": 1308, "reset_at": - }; 子进程超时附 "timeout": {"seconds": } (#54); 成功时有 - usage 时附 "usage": {"total_tokens": }。其余 review tool 的 result - 形状保持不变。 + }; 子进程超时附 "timeout": {"seconds": }; 锁等待超时附 + "lock_timeout": {"seconds": } (均 #54); 成功时有 usage 时附 + "usage": {"total_tokens": }。其余 review tool 的 result 形状保持不变。 """ # 有限重试配置 (issue #3 子项3c): 仅对 provider 限流错误重试, 退避指数。 # clamp 到 >=0, 防止 ZCODE_BRIDGE_MAX_RETRIES=-1 导致不执行 zcode (Codex P2) @@ -1331,8 +1331,15 @@ def _run_zcode_headless(cmd, env, timeout, structured_output=False): return {"content": [{"type": "text", "text": f"zcode 调用失败: 重试耗尽 {last_err}"}], "isError": True} except TimeoutError as e: - return {"content": [{"type": "text", "text": f"zcode review 获取锁超时: {e}"}], - "isError": True} + err_result = {"content": [{"type": "text", + "text": f"zcode review 获取锁超时: {e}"}], + "isError": True} + if structured_output: + # #54: 与 timeout 键同型的锁等待标因 — 深审常态 >300s 后, 并发 + # 直调的第二个 review 会在锁等待耗尽时快速失败 (fail-fast 排队 + # 而非等待), 调用方据此可区分「稍后重试」与其他失败。 + err_result["lock_timeout"] = {"seconds": 300} + return err_result def _review_timeout(): @@ -1340,10 +1347,12 @@ def _review_timeout(): 默认 1200 (#54): 300 对 depth=deep 普遍不够 — deep 复核是多轮「读文件+ 推理」循环, 实测两仓各 21/28 次闸门深审的中位时长为 305s/409s, 57%/71% - 超 300s; 慢模型 (TTFT 1-3min/请求) 下更甚。300 默认曾让所有未显式设 + 超 300s; 慢模型 (TTFT 1-3min/请求) 下更甚。300 默认曾让未显式设 ZCODE_BRIDGE_REVIEW_TIMEOUT 的调用方 (人工/agent 的 --call 与 MCP 直调) - 对 deep 档必然超时 (gate 自身不受影响, 见 review-gate 的显式 3600)。 - 注意代价: 挂死子进程占用 ReviewFileLock 的时长随之变长 (锁等待仍 300s)。 + 对 deep 档多数超时 (gate 自身不受影响, 见 review-gate 的显式 3600)。 + 注意代价: 挂死/长跑子进程占用 ReviewFileLock 的时长随之变长 (锁等待仍 + 300s) — 深审常态已超 300s, 并发直调的第二个 review 会在锁等待耗尽后 + 快速失败 (结构化键 lock_timeout), 由调用方稍后重试, 而非排队等完。 """ return max(30, _env_int("ZCODE_BRIDGE_REVIEW_TIMEOUT", 1200, maximum=3600)) diff --git a/packages/review-gate/README.md b/packages/review-gate/README.md index 2c33196..3c212a5 100644 --- a/packages/review-gate/README.md +++ b/packages/review-gate/README.md @@ -192,8 +192,9 @@ state 文件(默认 `~/.local/state/zcode-review-gate/state.json`)记录每 deep 档审查耗时长:gate 起审查子进程时已自动透传 `ZCODE_BRIDGE_REVIEW_TIMEOUT=3600`,自身等子进程的总超时再加 120s 留给 -mimosa 扫描与进程收尾(=3720),mcp-server 侧不会以默认 300s 提前掐断 -zcode。 +mimosa 扫描与进程收尾(=3720);mcp-server 未显式设值时的默认是 1200s +(#54 后按 deep 实测中位上调,上限 3600),对长尾 deep 仍偏紧,故 gate +仍显式钉 3600。 ## 限制 diff --git a/packages/review-gate/zcode-review-gate b/packages/review-gate/zcode-review-gate index 6fdbfad..7f01126 100755 --- a/packages/review-gate/zcode-review-gate +++ b/packages/review-gate/zcode-review-gate @@ -654,7 +654,8 @@ def run_review(cfg, clone, base_ref, head_sha): 只做进程调用与结果解包。base 必须带 origin/ 前缀 — mcp-server 的 diff 是 base...head 三点语法 (merge-base 语义), 引用要指向远端跟踪分支。 ZCODE_BRIDGE_REVIEW_TIMEOUT 显式透传 ZCODE_REVIEW_TIMEOUT (3600): - mcp-server 侧 zcode 审查默认 300s 超时, deep 档远远不够; gate 自身 + mcp-server 侧未显式设值时默认 1200s (#54, 上限 3600), 对 deep 长尾 + 仍偏紧, 故 gate 仍显式钉 3600; gate 自身 等子进程的总超时 REVIEW_TIMEOUT 在此基础上再加 120s, 留给 mimosa 扫描与进程收尾, 防 zcode 报告已出却被 gate 提前掐掉。 diff --git a/tests/test_security_review.py b/tests/test_security_review.py index e97312a..de59b64 100644 --- a/tests/test_security_review.py +++ b/tests/test_security_review.py @@ -17,6 +17,9 @@ 与 gate 正则路径对拍)/非 1308 不加键/isError 文本保留 1308 串 (旧 gate 兼容)/usage.totalTokens 透传两态/其他 review tool 形状不变/--call 信封 序列化存活 + - 超时结构化标因 (issue #54): _review_timeout 默认 1200 可配; 子进程超时 + 附 timeout 键/锁等待超时附 lock_timeout 键 (均仅 structured_output); + 超时不进限流重试循环 (调用次数=1); 普通路径 result 形状不变 运行: python3 tests/test_security_review.py 依赖: 仅 Python 标准库 + zcode-mcp-server 模块 @@ -250,35 +253,76 @@ def setUpClass(cls): cls.mod = _load_mcp_module() def _run_with_timeout_exc(self, structured_output): - """patch subprocess.run 抛 TimeoutExpired, 直调 _run_zcode_headless。""" + """patch subprocess.run 抛 TimeoutExpired, 直调 _run_zcode_headless。 + + 返回 (result, 子进程调用次数) — 计数用于钉住「超时不进限流重试循环」 + (对齐 SQ1 对 1308 不重试的钉法)。 + """ mod = self.mod + calls = [] def fake_run(cmd, *a, **kw): + calls.append(cmd) raise subprocess.TimeoutExpired(cmd, kw.get("timeout", 0)) saved = mod.subprocess.run mod.subprocess.run = fake_run os.environ["ZCODE_BRIDGE_REVIEW_LOCK"] = "0" try: - return mod._run_zcode_headless( + result = mod._run_zcode_headless( ["zcode", "--prompt", "x"], env={}, timeout=5, structured_output=structured_output) finally: mod.subprocess.run = saved + return result, len(calls) def test_ta1_timeout_structured_key(self): - """TA1: structured_output=True 时超时结果附 timeout 键""" - result = self._run_with_timeout_exc(structured_output=True) + """TA1: structured_output=True 时超时结果附 timeout 键, 且不重试""" + result, n = self._run_with_timeout_exc(structured_output=True) self.assertTrue(result.get("isError")) self.assertEqual(result.get("timeout"), {"seconds": 5}) self.assertIn("超时 (5s)", result["content"][0]["text"]) + self.assertEqual(n, 1, "超时不得进入限流重试循环") def test_ta2_timeout_plain_shape_unchanged(self): """TA2: structured_output=False 时 result 形状不变 (无 timeout 键)""" - result = self._run_with_timeout_exc(structured_output=False) + result, n = self._run_with_timeout_exc(structured_output=False) self.assertTrue(result.get("isError")) self.assertNotIn("timeout", result) self.assertNotIn("quota_limit", result) + self.assertEqual(n, 1) + + def test_ta3_lock_timeout_structured_key(self): + """TA3: 锁等待超时附 lock_timeout 键 (与 timeout 键语义区分), 不起子进程""" + mod = self.mod + + class _LockTimeout: + def __init__(self, timeout=300): + pass + + def __enter__(self): + raise TimeoutError("锁等待超时 (300s), 可能有并发 review 在跑") + + def __exit__(self, *a): + return False + + def fail_run(*a, **kw): # 锁失败必须在 spawn 之前, 起了即测试失败 + raise AssertionError("锁超时路径不应执行 zcode 子进程") + + saved_lock, saved_run = mod.ReviewFileLock, mod.subprocess.run + mod.ReviewFileLock = _LockTimeout + mod.subprocess.run = fail_run + try: + result = mod._run_zcode_headless( + ["zcode", "--prompt", "x"], env={}, timeout=5, + structured_output=True) + finally: + mod.ReviewFileLock = saved_lock + mod.subprocess.run = saved_run + self.assertTrue(result.get("isError")) + self.assertEqual(result.get("lock_timeout"), {"seconds": 300}) + self.assertNotIn("timeout", result, "锁等待超时不得冒充子进程预算超时") + self.assertIn("锁", result["content"][0]["text"]) class TestProjectConfigGuard(_EnvGuard):