codebee 0.1.50 → 0.1.52

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -23,6 +23,19 @@ README 元数据带回,供老版本在「发现新版本」时展示新版更
23
23
 
24
24
  - **首选 KEY 超限自动切备用了**:同厂商多密钥此前只认欠费类文案——首选 KEY 撞上 429 限流(智谱「并发数超过限制」「每分钟Token数已超过上限」、claude 的 rate_limit_error、codex 的 "429 Too Many Requests" 等)只记一笔错误、不进冷却,每个新步骤仍从超限的首选 KEY 重新烧起。现在限流文案与欠费同口径:进冷却 30 分钟、密钥镜像自动切下一把、链展开跳过冷却中的 KEY(到期或「恢复」按钮一键回首选)。顺带补一处漏账:无显式链时「换模型回退」的链条目不带 KEY 标记,这类重试失败此前完全不记 KEY 账。
25
25
 
26
+ ## v0.1.52(2026-09-22)
27
+
28
+ - 手动指定执行者优先级全面收口:用户点名的 CLI 压过一切自动选择——路由不换将、旁观不抢占、绑定不覆盖;「手动指定执行者」作为一等偏好随任务走
29
+ - 管理页执行者下拉可指定为对话执行者的真实 CLI(与新建任务两级菜单复用同一段逻辑)
30
+ - runner 静默挂死看门狗对「等结束才一次性输出」的 CLI 保留原语义,防误杀
31
+
32
+ ## v0.1.51(2026-09-22)
33
+
34
+ - 智能路由:绑定链可用性细化为「有备胎」维度(解析出的调用链有第二条及以上的任务加分),静态能力基线不再把配额烧干的供应商顶在前面
35
+ - 内置智能体:用户显式指定实现者时全程尊重(不再被自动路由旁观抢占);单次生成输出预算把推理模型的思考 token 也计入
36
+ - runner 错误识别降噪:审计日志错误行探针排除回显提示词里的普通词误判,尾段无错误信号时不强判失败
37
+ - 新增夜班竞品扫描脚本(scripts/borrow_scan_nightly.py)
38
+
26
39
  ## v0.1.50(2026-09-22)
27
40
 
28
41
  - 今日体检修复:i18n 英文字典 22 个重复键清理——其中「 条/字/章」等 7 个值冲突属后值静默覆盖(如「第 N 章」曾被渲染成 "Chapter N chapters"),按使用现场裁定正确值,英文界面文案修复
package/README.md CHANGED
@@ -21,11 +21,10 @@ Kimi Code、MiMo Code、Grok Build、Pi、DeepSeek Harness……),提供
21
21
  不会把你的任务内容交给任何第三方。
22
22
 
23
23
  <!-- relnotes:start -->
24
- ### 最新版更新内容(v0.1.50)
24
+ ### 最新版更新内容(v0.1.52)
25
25
 
26
- - 英文界面文案修复:字典重复键导致的静默覆盖清理(22 处)
27
- - 并行功能收尾一批:自动化偏好透传、思维链可视化、市场启停开关、卷级规划等
28
- - 合并质量巡检全过:导入完整性 / pipeline 修复在位 / 架构无新增违规
26
+ - 手动指定执行者为一等偏好:用户点名的 CLI 压过自动路由,全程不换将、不被抢占
27
+ - 管理页执行者下拉与新建任务菜单统一;静默挂死看门狗防误杀保留
29
28
  <!-- relnotes:end -->
30
29
 
31
30
  ---
@@ -592,7 +592,13 @@ def run_now(tid):
592
592
  t = _TASKS.get(tid)
593
593
  if t is None:
594
594
  return None, None
595
- t["last_run"] = _fmt_dt(now)
595
+ # interval 任务的 last_run 是 next_run 的计算锚点(compute_next_run 按
596
+ # last_run + N 小时向前滚),手动触发写进来会让重启重算把档期整体后移
597
+ # ——2026-09-23 实案:22:28 手动跑一轮 + 23:43 服务重启,本该 23:53
598
+ # 触发的档期被抹成 00:28。interval 只让调度触发推进 last_run;其余
599
+ # kind 的 next_run 由 time/weekday 决定,last_run 仅作展示,照旧写。
600
+ if t.get("kind") != "interval":
601
+ t["last_run"] = _fmt_dt(now)
596
602
  t["run_count"] = int(t.get("run_count") or 0) + 1
597
603
  t["last_status"] = status
598
604
  _save_locked()
@@ -36,6 +36,10 @@ IMAGE_JPEG_BYTES = 3500 * 1024 # 缩放后仍超此大小 → 转 JPEG q85
36
36
  IMAGE_MAX_BYTES = 5 * 1024 * 1024 # 单张最终字节硬上限(超出剔除并日志)
37
37
  THINK_MAX_CHARS = 20000 # 单次生成保留的思考正文上限(防超长思维链撑爆 run.json)
38
38
  STREAM_MAX_CHARS = 20000 # 运行中实时回答的保留上限(与 THINK_MAX_CHARS 同口径)
39
+ BASE_MAX_TOKENS = 8000 # 单次生成输出预算(推理模型的思考 token 也计入此额度)
40
+ MAX_TOKENS_CAP = 16000 # 思考撑爆预算后的提额上限(每轮 run 只提一次)
41
+ THINK_EXHAUST_MIN_CHARS = 1500 # 零正文时思考达到此长度 ≈ 思考占满输出预算
42
+ EMPTY_TEXT_MAX_STREAK = 3 # 连续零正文轮数上限(防同参数盲重试烧穿预算)
39
43
 
40
44
  CMD_DEFAULT_TIMEOUT = 600 # run_command 默认超时(与 CLI 停滞看门狗同口径)
41
45
  CMD_MAX_TIMEOUT = 1800 # 上限:模型传 timeout_sec 超过即钳到这
@@ -777,7 +781,8 @@ def _m_anthropic(m):
777
781
  return {"role": "user", "content": [{"type": "text", "text": m.get("content") or ""}]}
778
782
 
779
783
 
780
- def _build_request(proto, base, use_key, model, system, msgs, with_tools, stream=False):
784
+ def _build_request(proto, base, use_key, model, system, msgs, with_tools, stream=False,
785
+ max_tokens=BASE_MAX_TOKENS):
781
786
  """按协议构造 (url, headers, body)。msgs 为内部统一形状。
782
787
 
783
788
  stream=True 时按协议打开流式:google 换 :streamGenerateContent?alt=sse,
@@ -803,13 +808,13 @@ def _build_request(proto, base, use_key, model, system, msgs, with_tools, stream
803
808
  "parts": parts})
804
809
  body = {"contents": contents,
805
810
  "systemInstruction": {"parts": [{"text": system}]},
806
- "generationConfig": {"maxOutputTokens": 8000}}
811
+ "generationConfig": {"maxOutputTokens": max_tokens}}
807
812
  return url, headers, body
808
813
  path = "/messages" if proto == "anthropic" else "/chat/completions"
809
814
  url = (base + path) if base.endswith("/v1") else (base + "/v1" + path)
810
815
  if proto == "anthropic":
811
816
  headers = {"x-api-key": use_key, "anthropic-version": "2023-06-01"}
812
- body = {"model": model, "max_tokens": 8000, "system": system,
817
+ body = {"model": model, "max_tokens": max_tokens, "system": system,
813
818
  "messages": [_m_anthropic(m) for m in msgs]}
814
819
  if with_tools:
815
820
  body["tools"] = _anthropic_tools()
@@ -825,7 +830,7 @@ def _build_request(proto, base, use_key, model, system, msgs, with_tools, stream
825
830
  msgs_wire.extend(w)
826
831
  else:
827
832
  msgs_wire.append(w)
828
- body = {"model": model, "max_tokens": 8000,
833
+ body = {"model": model, "max_tokens": max_tokens,
829
834
  "messages": [{"role": "system", "content": system}] + msgs_wire}
830
835
  if with_tools:
831
836
  body["tools"] = _openai_tools()
@@ -934,6 +939,9 @@ def run(bi, prompt, workdir, timeout=180, cancel_event=None, log=None, images=No
934
939
  last_err = ""
935
940
  ok = False
936
941
  reasons = [] # 各迭代的思考正文(按迭代顺序拼接,供步骤记录展示)
942
+ max_tokens = BASE_MAX_TOKENS # 思考占满预算时提额重试(跨迭代保持,见空正文分支)
943
+ escalated = False # 本轮 run 是否已提额(只提一次,防无限翻倍)
944
+ empty_streak = 0 # 连续零正文轮数(拿到正文/工具即清零)
937
945
 
938
946
  def _fire(cb, *cb_args):
939
947
  if cb is None:
@@ -964,108 +972,175 @@ def run(bi, prompt, workdir, timeout=180, cancel_event=None, log=None, images=No
964
972
  return _fail("已取消")
965
973
  reasons.append("") # 每轮迭代各占一段思考(跨轮拼接时分段,join 时滤空段)
966
974
  done = False
967
- for proto, pbase in candidates:
968
- keys = modelhub._chain_keys(prov) or [{"key": prov.get("api_key") or "", "id": ""}]
969
- for kk in keys:
970
- url, headers, body = _build_request(proto, pbase, kk["key"], model,
971
- system, msgs, tools_ok)
972
- sbody = None
973
- if stream:
974
- # 流式体单独构造(+stream / include_usage);非流式体留给回落重发
975
- _, _, sbody = _build_request(proto, pbase, kk["key"], model,
976
- system, msgs, tools_ok, stream=True)
977
- effort = str(bi.get("reasoning_effort") or "").strip().lower()
978
- # reasoning_effort 是 OpenAI wire 字段;Anthropic thinking 使用
979
- # 另一套对象结构,向兼容网关硬塞该字段会直接得到 400。
980
- if effort in ("low", "medium", "high") and proto == "openai":
981
- body["reasoning_effort"] = effort
982
- if sbody is not None:
983
- sbody["reasoning_effort"] = effort
984
- text, calls, usage = "", [], {}
985
- streamed = False
986
- if sbody is not None:
987
- part = _post_sse_stream(
988
- url, headers, sbody, allow_private, timeout, proto,
989
- on_reason=_take_reason, on_text=on_stream,
990
- cancel_event=cancel_event)
991
- if part["error"] == "已取消":
992
- return _fail("已取消")
993
- if not part["error"] and part["events"]:
994
- # 网关确实按流回了:正文/工具取增量累加结果
995
- text, calls = part["text"], part["calls"]
996
- usage = part["usage"]
997
- streamed = True
998
- if not reasons or not reasons[-1]:
999
- # 调用方没挂实时回调(无可视化诉求)时思考只在
1000
- # 累加器里:这里兜底收进结果,落库口径一致
1001
- _take_reason(part["reasoning"])
1002
- else:
1003
- # 零事件(网关忽略 stream)或流式这一步失败(不认 stream 参数、
1004
- # 断流等):都退回同一条 wire 的非流式重发——比直接判死这家
1005
- # 供应商稳妥;非流式也失败才走下面的记账/换链。
1006
- last_err = part["error"]
1007
- if not streamed:
1008
- status, data, err = _post_interruptible(url, headers, body,
1009
- allow_private, timeout,
1010
- cancel_event)
1011
- if cancel_event is not None and cancel_event.is_set():
1012
- # 取消先于一切记账:健康 KEY 不能因被放弃的请求背上冷却
1013
- return _fail("已取消")
1014
- if status == 0 or not (200 <= status < 300):
1015
- msg = ""
1016
- if isinstance(data, dict):
1017
- e = data.get("error")
1018
- msg = e.get("message", "") if isinstance(e, dict) else str(e)
1019
- last_err = err or ("HTTP %s %s" % (status, str(msg)[:200]))
975
+ reBump = False # 本轮要提额重试(同一迭代重跑,不烧迭代数)
976
+ stopit = False # 本轮判死(连续零正文),直接终局不再空转
977
+ for _bump in range(2): # 第 2 圈 = 提额后的同轮重试
978
+ for proto, pbase in candidates:
979
+ keys = modelhub._chain_keys(prov) or [{"key": prov.get("api_key") or "", "id": ""}]
980
+ for kk in keys:
981
+ got = False # 本 KEY 拿到了响应(区别于 HTTP 失败换下家)
982
+ stripped = False # thinking 对象被网关 400 拒后已摘除重发
983
+ while True:
984
+ url, headers, body = _build_request(
985
+ proto, pbase, kk["key"], model, system, msgs,
986
+ tools_ok, max_tokens=max_tokens)
987
+ sbody = None
988
+ if stream:
989
+ # 流式体单独构造(+stream / include_usage);非流式体留给回落重发
990
+ _, _, sbody = _build_request(
991
+ proto, pbase, kk["key"], model, system, msgs,
992
+ tools_ok, stream=True, max_tokens=max_tokens)
993
+ effort = str(bi.get("reasoning_effort") or "").strip().lower()
994
+ # reasoning_effort 是 OpenAI wire 字段;Anthropic thinking 使用
995
+ # 另一套对象结构,向兼容网关硬塞该字段会直接得到 400。
996
+ if effort in ("low", "medium", "high") and proto == "openai":
997
+ body["reasoning_effort"] = effort
998
+ if sbody is not None:
999
+ sbody["reasoning_effort"] = effort
1000
+ if effort in ("low", "medium") and proto == "anthropic" \
1001
+ and not stripped:
1002
+ # 「思考程度」此前只对 openai 面生效;GLM 这类推理模型
1003
+ # 在 Anthropic 兼容面上默认全功率思考,能把 max_tokens
1004
+ # 整个烧光(2026-09-22 glm-5.3-flash 47 分钟零正文案)。
1005
+ # low/medium 显式给思考预算,high 留默认(模型自决=放开想)。
1006
+ body["thinking"] = {
1007
+ "type": "enabled",
1008
+ "budget_tokens": 1024 if effort == "low" else 4096}
1009
+ if sbody is not None:
1010
+ sbody["thinking"] = dict(body["thinking"])
1011
+ text, calls, usage = "", [], {}
1012
+ streamed = False
1013
+ if sbody is not None:
1014
+ part = _post_sse_stream(
1015
+ url, headers, sbody, allow_private, timeout, proto,
1016
+ on_reason=_take_reason, on_text=on_stream,
1017
+ cancel_event=cancel_event)
1018
+ if part["error"] == "已取消":
1019
+ return _fail("已取消")
1020
+ if not part["error"] and part["events"]:
1021
+ # 网关确实按流回了:正文/工具取增量累加结果
1022
+ text, calls = part["text"], part["calls"]
1023
+ usage = part["usage"]
1024
+ streamed = True
1025
+ if not reasons or not reasons[-1]:
1026
+ # 调用方没挂实时回调(无可视化诉求)时思考只在
1027
+ # 累加器里:这里兜底收进结果,落库口径一致
1028
+ _take_reason(part["reasoning"])
1029
+ else:
1030
+ # 零事件(网关忽略 stream)或流式这一步失败(不认 stream 参数、
1031
+ # 断流等):都退回同一条 wire 的非流式重发——比直接判死这家
1032
+ # 供应商稳妥;非流式也失败才走下面的记账/换链。
1033
+ last_err = part["error"]
1034
+ if not streamed:
1035
+ status, data, err = _post_interruptible(url, headers, body,
1036
+ allow_private, timeout,
1037
+ cancel_event)
1038
+ if cancel_event is not None and cancel_event.is_set():
1039
+ # 取消先于一切记账:健康 KEY 不能因被放弃的请求背上冷却
1040
+ return _fail("已取消")
1041
+ if status == 0 or not (200 <= status < 300):
1042
+ msg = ""
1043
+ if isinstance(data, dict):
1044
+ e = data.get("error")
1045
+ msg = e.get("message", "") if isinstance(e, dict) else str(e)
1046
+ if status == 400 and isinstance(body.get("thinking"), dict) \
1047
+ and "thinking" in str(msg).lower() and not stripped:
1048
+ # 网关不认 thinking 对象:摘掉同 KEY 立即重发,
1049
+ # 别让「思考程度」偏好把健康 KEY 送进冷却
1050
+ body.pop("thinking", None)
1051
+ if sbody is not None:
1052
+ sbody.pop("thinking", None)
1053
+ stripped = True
1054
+ continue
1055
+ last_err = err or ("HTTP %s %s" % (status, str(msg)[:200]))
1056
+ try:
1057
+ modelhub.note_key_error(bi["provider_id"], kk.get("id") or "", last_err)
1058
+ except Exception:
1059
+ pass
1060
+ break # 换下一个 KEY
1061
+ text, calls, usage = _parse_reply(proto, data)
1062
+ # 非流式响应里的思考字段:补发一次,可视化与流式路径同形
1063
+ _take_reason(_reason_from_message(proto, data))
1020
1064
  try:
1021
- modelhub.note_key_error(bi["provider_id"], kk.get("id") or "", last_err)
1065
+ modelhub.note_key_ok(bi["provider_id"], kk.get("id") or "")
1022
1066
  except Exception:
1023
1067
  pass
1024
- continue
1025
- text, calls, usage = _parse_reply(proto, data)
1026
- # 非流式响应里的思考字段:补发一次,可视化与流式路径同形
1027
- _take_reason(_reason_from_message(proto, data))
1028
- try:
1029
- modelhub.note_key_ok(bi["provider_id"], kk.get("id") or "")
1030
- except Exception:
1031
- pass
1032
- usage = _norm_usage(usage)
1033
- for k in total_usage:
1034
- total_usage[k] += int(usage.get(k) or 0)
1035
- iters = it
1036
- if calls:
1037
- if log:
1038
- log("[迭代 %d] %s 请求工具: %s" % (
1039
- it, model, ", ".join(c["name"] for c in calls)))
1040
- _fire(on_activity, "请求工具: " + "、".join(c["name"] for c in calls))
1041
- results = []
1042
- for c in calls:
1043
- out = _exec_tool(workdir, c["name"], c["args"], cancel_event)
1068
+ usage = _norm_usage(usage)
1069
+ for k in total_usage:
1070
+ total_usage[k] += int(usage.get(k) or 0)
1071
+ iters = it
1072
+ got = True
1073
+ break
1074
+ if not got:
1075
+ continue # 本 KEY 失败,换下一个
1076
+ if calls:
1044
1077
  if log:
1045
- brief = out if len(out) <= 120 else out[:120] + "…"
1046
- log("[工具] %s → %s" % (c["name"], brief.replace("\n", " ⏎ ")))
1047
- _fire(on_activity, "%s %s" % (
1048
- c["name"], str(c["args"].get("path")
1049
- or c["args"].get("command") or "")[:80]))
1050
- results.append((c["id"], out))
1051
- msgs.append({"role": "assistant", "content": text, "tool_calls": calls})
1052
- msgs.append({"role": "tool_results", "tool_results": results})
1053
- done = True
1054
- break
1055
- if (text or "").strip():
1078
+ log("[迭代 %d] %s 请求工具: %s" % (
1079
+ it, model, ", ".join(c["name"] for c in calls)))
1080
+ _fire(on_activity, "请求工具: " + "、".join(c["name"] for c in calls))
1081
+ results = []
1082
+ for c in calls:
1083
+ out = _exec_tool(workdir, c["name"], c["args"], cancel_event)
1084
+ if log:
1085
+ brief = out if len(out) <= 120 else out[:120] + "…"
1086
+ log("[工具] %s → %s" % (c["name"], brief.replace("\n", " ⏎ ")))
1087
+ _fire(on_activity, "%s %s" % (
1088
+ c["name"], str(c["args"].get("path")
1089
+ or c["args"].get("command") or "")[:80]))
1090
+ results.append((c["id"], out))
1091
+ msgs.append({"role": "assistant", "content": text, "tool_calls": calls})
1092
+ msgs.append({"role": "tool_results", "tool_results": results})
1093
+ empty_streak = 0
1094
+ done = True
1095
+ break
1096
+ if (text or "").strip():
1097
+ if log:
1098
+ log("[迭代 %d] 最终回答(%d 字)" % (it, len(text)))
1099
+ ok = True
1100
+ empty_streak = 0
1101
+ done = True
1102
+ break
1103
+ # —— 零正文零工具:分辨「思考占满输出预算」还是「模型真没说话」。
1104
+ # 推理模型的思考计入 max_tokens:想满了流会正常收,正文却是空的。
1105
+ rlen = len(reasons[-1] or "")
1106
+ out_tok = int(usage.get("output") or 0)
1107
+ exhausted = (rlen >= THINK_EXHAUST_MIN_CHARS
1108
+ or out_tok >= int(max_tokens * 0.9))
1109
+ empty_streak += 1
1110
+ if exhausted and not escalated:
1111
+ # 同轮提额重试一次:8K 想不完的 16K 大多就想完了
1112
+ escalated = True
1113
+ old = max_tokens
1114
+ max_tokens = min(max_tokens * 2, MAX_TOKENS_CAP)
1115
+ last_err = ("模型思考占满输出预算(max_tokens=%d,思考 %d 字零正文)"
1116
+ % (old, rlen))
1117
+ if log:
1118
+ log("[第 %d 轮] 思考 %d 字仍无正文/工具 → max_tokens %d→%d 同轮重试"
1119
+ % (it, rlen, old, max_tokens))
1120
+ _fire(on_activity, "思考占满输出预算,提高输出预算重试")
1121
+ reBump = True
1122
+ break
1123
+ if empty_streak >= EMPTY_TEXT_MAX_STREAK:
1124
+ last_err = ("模型思考连续占满输出预算(max_tokens 已提到 %d 仍无正文)"
1125
+ % max_tokens) if exhausted else \
1126
+ ("模型连续 %d 轮零正文零工具" % empty_streak)
1127
+ done = True
1128
+ stopit = True
1129
+ break
1130
+ last_err = "模型未返回文本"
1056
1131
  if log:
1057
- log("[迭代 %d] 最终回答(%d 字)" % (it, len(text)))
1058
- ok = True
1132
+ log("[第 %d 轮] 模型零正文零工具(思考 %d 字),重试" % (it, rlen))
1133
+ _fire(on_activity, "模型零正文,重试")
1059
1134
  done = True
1060
1135
  break
1061
- last_err = "模型未返回文本"
1062
- done = True
1063
- break
1064
- if done:
1065
- break
1136
+ if done or reBump:
1137
+ break
1138
+ if reBump:
1139
+ continue # 同一迭代用新预算重来
1140
+ break # 非提额路径一锤定音
1066
1141
  if not done:
1067
1142
  break # 所有 wire/KEY 都失败
1068
- if ok:
1143
+ if ok or stopit:
1069
1144
  break
1070
1145
  if not ok and not text:
1071
1146
  return _fail(last_err or "工具循环达上限仍无最终回答")
@@ -64,7 +64,10 @@ DEFAULT_CATALOG = [
64
64
  "note": "通义千问编码 CLI(gemini-cli 系);无头走 stdin;绑定凭据经打开注入"
65
65
  "写 settings.json 的 env 段(OPENAI_*,qwen 存在即优先 openai 兼容通道)",
66
66
  "detect": {"cli": "qwen"},
67
- "orch": {"kind": "qwen", "command": "qwen"},
67
+ # stall_timeout_s:静默 15 分钟即杀(2026-09-22 全书总评实案:qwen 吐完
68
+ # 评审内容后卡在 MCP 收尾死锁,输出归零但进程活着,stall=0 时只能干等
69
+ # 2400s 总超时;900s 取「评审模型调用常见上限 5 分钟」的 3 倍,防误杀)
70
+ "orch": {"kind": "qwen", "command": "qwen", "stall_timeout_s": 900},
68
71
  # settings.json 顶层 "model" 已是 legacy(qwen 忽略并告警,新格式是
69
72
  # model.name)——模型落盘由打开注入器写 OPENAI_MODEL env 负责,
70
73
  # 这里不再走 write_model
@@ -154,7 +157,7 @@ DEFAULT_CATALOG = [
154
157
  "note": "DeepSeek 官方 agent harness(dsh,profile 插件架构);无头是「一次性任务」——"
155
158
  "答完即退、无交互后续、不支持会话恢复;任务只走位置参数(超长提示词受 Windows "
156
159
  "命令行上限约 32k 约束);模型写进 ~/.dsh/settings.yaml 的 agent-default-model.model;"
157
- "密钥由运行时自动调度或「模型调度(可选)」注入 DEEPSEEK_API_KEY(优先级最高);端点注入 DEEPSEEK_BASE_URL,"
160
+ "密钥由运行时自动调度或「模型调度(可选)」注入 DEEPSEEK_API_KEY(优先级最高);端点注入 DEEPSEEK_BASE_URL,"
158
161
  "但若 settings.yaml 已固定 llm-deepseek.baseURL,则以它为准(settings 高于 env)",
159
162
  "detect": {"cli": "dsh"},
160
163
  "orch": {"kind": "generic", "command": "dsh",
@@ -316,6 +319,21 @@ def _apply_config_patch(entries):
316
319
  e["config"] = dict(patch)
317
320
 
318
321
 
322
+ def _apply_stall_patch(entries):
323
+ """存量数据幂等补齐:qwencode 补 stall_timeout_s=900(2026-09-22 实案)。
324
+
325
+ data/catalog.json 生成后不会再随版本重写,老清单里的 qwencode 没有
326
+ 停滞看门狗配置(当时按「结束才一次性输出」留 0);实际验证 qwen 评审
327
+ 会先流式吐内容再卡死在 MCP 收尾,静默看门狗能兜住。用户手写过该字段的
328
+ 不覆盖(包括显式写 0 关闭的)。"""
329
+ for e in entries:
330
+ if e.get("id") != "qwencode":
331
+ continue
332
+ orch = e.get("orch")
333
+ if isinstance(orch, dict) and "stall_timeout_s" not in orch:
334
+ orch["stall_timeout_s"] = 900
335
+
336
+
319
337
  def _apply_install_patch(entries):
320
338
  # Aider 渠道整体迁移到 uv tool(跨平台、含存量数据幂等修正):anaconda 的
321
339
  # py -3.13 装 aider 必挂在 numpy 源码构建(老 setuptools 引用 py3.12 已删除
@@ -371,6 +389,7 @@ def load(force=False):
371
389
  _apply_launch_patch(entries)
372
390
  _apply_config_patch(entries)
373
391
  _apply_install_patch(entries)
392
+ _apply_stall_patch(entries)
374
393
  _CACHE["entries"] = entries
375
394
  return entries
376
395