diff --git a/README.md b/README.md index d7e2c8d..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`(默认 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)。**注意并发直调语义(#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 5ff9ab0..208ffb5 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": }; 锁等待超时附 + "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) @@ -1317,19 +1318,43 @@ 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} 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(): - """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) — 深审常态已超 300s, 并发直调的第二个 review 会在锁等待耗尽后 + 快速失败 (结构化键 lock_timeout), 由调用方稍后重试, 而非排队等完。 + """ + return max(30, _env_int("ZCODE_BRIDGE_REVIEW_TIMEOUT", 1200, maximum=3600)) def _write_temp(text, prefix): 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 2b98d04..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 模块 @@ -176,9 +179,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 +245,86 @@ 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。 + + 返回 (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: + 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, 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, 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): """issue #49 洞三防护: 被审目录链上的 zcode 项目配置 → 拒审 + 干净工作目录。