codebee 0.1.5 → 0.1.6

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
@@ -4,6 +4,14 @@ CodeBee 的用户可感知变更记录。发布新版时:最上面加一节,
4
4
  `<!-- relnotes:start -->…<!-- relnotes:end -->` 段(那段会被 `npm view` 的
5
5
  README 元数据带回,供老版本在「发现新版本」时展示新版更新内容)。
6
6
 
7
+ ## v0.1.6(2026-09-18)
8
+
9
+ - 连载韧性:网关限流/欠费时章稿失败先退避重试同作者(保住连载风格一致),重试穷尽才判死整 run;自动续跑改为延迟 5 分钟入队、上限提升到 3 次
10
+ - 修复:任务分支从游离 HEAD 检出时「切回原分支」原地空转的假成功;游离基线的合并现在显式拒绝并说明
11
+ - kimi 接入:僵尸 kimi 进程自动检测清理;CodeBee 托管配置按 TOML 语义渲染
12
+ - 修复:generic 类 CLI 经 npm .cmd 垫片时提示词被 cmd 重解析截烂(改 node 直启)
13
+ - 界面:新增蜜蜂图标,蜂巢工作台与皮肤视觉精修
14
+
7
15
  ## v0.1.5(2026-09-16)
8
16
 
9
17
  - 更新提示升级:发现新版本时直接展示「新版本更新内容」,升级后首次打开自动弹一次「本次更新内容」
package/README.md CHANGED
@@ -21,12 +21,11 @@
21
21
  零第三方依赖:纯 Python 标准库(3.8+),本地 Web 界面,数据全部落盘可回放。
22
22
 
23
23
  <!-- relnotes:start -->
24
- ### 最新版更新内容(v0.1.5
24
+ ### 最新版更新内容(v0.1.6
25
25
 
26
- - 发现新版本时直接展示「新版本更新内容」;升级后首次打开自动弹「本次更新内容」
27
- - 修复部分安全软件环境下启动卡在「正在启动任务队列」(启动期不再创建线程)
28
- - 启动逐步骤显示进度与耗时;卡超 20 秒自动打印卡点堆栈,截图即可排障
29
- - 完整更新日志见 CHANGELOG.md
26
+ - 连载更抗限流:章稿失败先退避重试同作者,自动续跑延迟 5 分钟、上限 3 次
27
+ - 修复任务分支游离 HEAD 的假成功切换与合并;kimi 僵尸进程检测与配置渲染修复
28
+ - 修复 generic CLI 经 npm 垫片时提示词被截烂;界面新增蜜蜂图标与视觉精修
30
29
  <!-- relnotes:end -->
31
30
 
32
31
  ## 作者侧命令行(可选)
@@ -157,6 +157,58 @@ def report_failure(provider: str, error: str = "", *, model: str = "",
157
157
  _persist()
158
158
 
159
159
 
160
+ # ------------------------------------------------- 静态死链告警(绑定层)
161
+
162
+ def report_binding_dead(key: str, error: str = ""):
163
+ """静态死链告警:某 CLI 的绑定链在起跑前就已全部失效(厂商停用/删除/无密钥、
164
+ 模型停用、协议不匹配)。与运行期 report_failure 的区别:不计数、不进探针,
165
+ 立即置 down+告警(尊重静默);绑定恢复后由 report_binding_ok 自动解除。
166
+ 伪供应商名带「绑定链·」前缀,不会与真实厂商名相撞。"""
167
+ if not key:
168
+ return
169
+ with _LOCK:
170
+ name = "绑定链·%s" % key
171
+ st = _PROVIDERS.get(name)
172
+ if st is None:
173
+ st = _PROVIDERS.setdefault(name, {
174
+ "name": name, "provider_id": "", "model": "",
175
+ "status": "ok", "consecutive_failures": 0,
176
+ "first_fail_at": 0, "last_fail_at": 0, "last_ok_at": 0,
177
+ "last_error": "", "alerted": False, "silenced": False,
178
+ "silence_until": 0, "probe_next_at": 0, "probe_backoff_idx": 0,
179
+ "recovered_at": 0,
180
+ })
181
+ st["static"] = True
182
+ st["binding_key"] = str(key)
183
+ st["name"] = name
184
+ st["status"] = "down"
185
+ st["last_fail_at"] = _now()
186
+ st["last_error"] = str(error or "")[:300]
187
+ st["probe_next_at"] = 0
188
+ if not _effective_silenced(st) and not st.get("alerted"):
189
+ st["alerted"] = True
190
+ log.warning("[health] ⚠️ 绑定链告警:%s 全部失效(%s)",
191
+ name, st["last_error"][:120])
192
+ _persist()
193
+
194
+
195
+ def report_binding_ok(key: str):
196
+ """绑定链恢复可用:自动解除该 CLI 的静态死链告警(无记录时零开销)。"""
197
+ if not key:
198
+ return
199
+ with _LOCK:
200
+ st = _PROVIDERS.get("绑定链·%s" % key)
201
+ if not st or not st.get("static"):
202
+ return
203
+ st["status"] = "recovered"
204
+ st["alerted"] = False
205
+ st["silenced"] = False
206
+ st["silence_until"] = 0
207
+ st["recovered_at"] = _now()
208
+ log.info("[health] 绑定链 %s 已恢复(静态告警解除)", st["name"])
209
+ _persist()
210
+
211
+
160
212
  # ---------------------------------------------------------------- 手动操作
161
213
 
162
214
  def silence(provider: str, minutes: int = 0):
@@ -215,6 +267,7 @@ def snapshot():
215
267
  "last_error": st.get("last_error") or "",
216
268
  "alerting": alerting,
217
269
  "silenced": silenced,
270
+ "static": bool(st.get("static")),
218
271
  })
219
272
  # 稳定排序:告警中 > 故障中 > 恢复 > 正常
220
273
  rank = {"down": 0, "failing": 1, "recovered": 2, "ok": 3}
@@ -271,7 +324,8 @@ def _probe_loop():
271
324
  try:
272
325
  with _LOCK:
273
326
  targets = [(name, st) for name, st in _PROVIDERS.items()
274
- if st.get("status") in ("failing", "down")
327
+ if not st.get("static") # 静态死链无端点可探,绑定恢复时自动解除
328
+ and st.get("status") in ("failing", "down")
275
329
  and (st.get("probe_next_at") or 0) <= _now()]
276
330
  for name, st in targets:
277
331
  idx = int(st.get("probe_backoff_idx") or 0)
package/app/core/jobs.py CHANGED
@@ -134,7 +134,9 @@ def cancel_event_for(run_id):
134
134
  return ev
135
135
 
136
136
 
137
- AUTO_RESUME_MAX = 2 # 连载任务自动续跑上限(超时/中断后自动接着写,无需人工)
137
+ AUTO_RESUME_MAX = 3 # 连载任务自动续跑上限(超时/中断后自动接着写,无需人工)
138
+ AUTO_RESUME_DELAY_S = 300 # 自动续跑延迟入队秒数:网关限流/欠费窗口通常分钟级,
139
+ # 立即重排会撞在同一堵墙上把续跑次数烧光(2026-09-17 七猫实测)
138
140
 
139
141
 
140
142
  def _maybe_auto_resume(run_id):
@@ -160,7 +162,13 @@ def _maybe_auto_resume(run_id):
160
162
  return False
161
163
  store.update_run(new_run["id"], auto_resumes=int(run.get("auto_resumes") or 0) + 1,
162
164
  auto_resumed_from=run_id)
163
- _QUEUE.put({"kind": "orchestration", "run_id": new_run["id"], "task_id": task["id"]})
165
+
166
+ def _enqueue():
167
+ _QUEUE.put({"kind": "orchestration",
168
+ "run_id": new_run["id"], "task_id": task["id"]})
169
+ t = threading.Timer(AUTO_RESUME_DELAY_S, _enqueue)
170
+ t.daemon = True
171
+ t.start()
164
172
  return True
165
173
  except Exception:
166
174
  return False
@@ -78,6 +78,8 @@ def sweep_orphan_cli_processes():
78
78
  按「Tutti 调用签名 + 父进程已死」双条件匹配,不误杀用户自己在用的 CLI:
79
79
  opencode:命令行含 opencode + --model(同步写入的 provider 固定 orch)
80
80
  codex:命令行含 codex + --skip-git-repo-check(Tutti 专属 flag 组合)
81
+ kimi:命令行含 kimi-code/dist/main.mjs(node 直启路径,2026-09-17 实测
82
+ 僵尸 kimi 会占住讯飞网关同钥请求队列,堵死后续所有 kimi 调用)
81
83
  claude 不扫(签名与用户手动使用难区分)。返回清扫数量。"""
82
84
  ps_exe = os.path.join(os.environ.get("SystemRoot", r"C:\Windows"),
83
85
  "System32", "WindowsPowerShell", "v1.0", "powershell.exe")
@@ -100,7 +102,8 @@ def sweep_orphan_cli_processes():
100
102
  cl = str(i.get("CommandLine") or "").lower()
101
103
  ppid = i.get("ParentProcessId")
102
104
  is_ours = (("opencode" in cl and "--model" in cl)
103
- or ("codex" in cl and "--skip-git-repo-check" in cl))
105
+ or ("codex" in cl and "--skip-git-repo-check" in cl)
106
+ or ("kimi-code" in cl and "main.mjs" in cl))
104
107
  if not is_ours or ppid in live or not i.get("ProcessId"):
105
108
  continue
106
109
  try:
@@ -832,6 +835,11 @@ def _sync_codex_settings(entry, model, cp):
832
835
  name = cp.get("name", "orch")
833
836
  def q(v):
834
837
  return '"%s"' % str(v).replace("\\", "\\\\").replace('"', '\\"')
838
+ if (cp.get("wire_api") or "responses") == "chat":
839
+ # codex 0.154+ 起 chat wire 被官方移除,写进 config.toml 会让 CLI 连配置
840
+ # 都载入不了(Error loading config.toml)——宁可明确拒绝也不落坏配置。
841
+ return ("供应商只有 chat completions wire,codex 0.154+ 已移除支持,未写入"
842
+ " config.toml——请为 codex 绑定 responses 兼容的供应商")
835
843
  pairs = [("name", q(cp.get("name", name))),
836
844
  ("base_url", q(cp.get("base_url", ""))),
837
845
  ("env_key", q(cp.get("env_key", "ORCH_API_KEY"))),
@@ -454,6 +454,7 @@ def model_ops(provider_id, names, op):
454
454
  if changed:
455
455
  _promote(models, newly_enabled) # 启用的置顶,停用的退到启用块之后
456
456
  _save(data)
457
+ sync_binding_alerts() # 模型启停/恢复 → 绑定链死活立即刷新告警
457
458
  return changed, ""
458
459
 
459
460
 
@@ -473,6 +474,7 @@ def _restore_all(provider_id):
473
474
  m["enabled"] = True
474
475
  _promote(models, names) # 恢复 = 重新启用:同样置顶
475
476
  _save(data)
477
+ sync_binding_alerts()
476
478
  return None
477
479
 
478
480
 
@@ -925,6 +927,8 @@ def providers_op(ids, op):
925
927
  not (p.get("id") in sel and p.get("enabled", True)),
926
928
  not bool(p.get("enabled", True))))
927
929
  _save(data)
930
+ if changed or op == "delete":
931
+ sync_binding_alerts() # 厂商启停/删除 → 绑定链死活立即刷新告警
928
932
  return changed, ("" if changed else "所选供应商已是目标状态")
929
933
 
930
934
 
@@ -1967,6 +1971,56 @@ def _protocol_candidates(prov):
1967
1971
  if (caps.get(p) or {}).get("base")]
1968
1972
 
1969
1973
 
1974
+ def bindable_protocols(agent_kind_or_id):
1975
+ """该 CLI 可绑定的 wire 协议(与 resolve_binding 的 allowed 一致)。
1976
+ 供死链告警/失败文案解释「为什么绑不上」:claude 只认 anthropic,
1977
+ codex/dsh 只认 openai,其余开放双协议(含 wire 适配)。"""
1978
+ if _deepseek_env_target(agent_kind_or_id) or agent_kind_or_id in ("codex-cli", "codex"):
1979
+ return ("openai",)
1980
+ if agent_kind_or_id in ("claude-code", "claude"):
1981
+ return ("anthropic",)
1982
+ return tuple(_BINDABLE_PROTOCOLS)
1983
+
1984
+
1985
+ def binding_dead_msg(cli_id):
1986
+ """死链失败/告警文案(pipeline 死链闸门与本模块 sync 共用):说明为什么
1987
+ 不回落本机默认 + 该 CLI 需要什么协议的供应商。"""
1988
+ try:
1989
+ protos = bindable_protocols(cli_id)
1990
+ except Exception:
1991
+ protos = ()
1992
+ hint = ("该 CLI 仅接受 %s 协议的已启用供应商;" % "、".join(protos)) if protos else ""
1993
+ return ("绑定链全部失效(链上供应商已停用/删除/无密钥,或模型已停用),"
1994
+ "本步判失败、不回落 CLI 本机默认——%s请在「CLI 绑定」页为该 CLI 绑定已启用的供应商" % hint)
1995
+
1996
+
1997
+ def sync_binding_alerts():
1998
+ """厂商/模型启停、删除、恢复后立即重评估各 CLI 绑定链的静态死链告警:
1999
+ 恢复的当场解除、新死的当场亮起——不必等下一次步骤执行才发现
2000
+ (2026-09-17 起与死链硬失败闸门配套)。幂等:health 侧静默/恢复语义不变。"""
2001
+ from . import health
2002
+ try:
2003
+ binds = bindings()
2004
+ except Exception:
2005
+ return
2006
+ for cli_id in sorted(binds.keys()):
2007
+ b = binds.get(cli_id) or {}
2008
+ if not _binding_chain(b):
2009
+ continue # 没配过链的 CLI 不归静态告警管(执行层闸门在跑时兜)
2010
+ try:
2011
+ r = resolve_binding(cli_id)
2012
+ ok = bool(r and r.get("call_chain"))
2013
+ except Exception:
2014
+ ok = False
2015
+ try:
2016
+ if ok:
2017
+ health.report_binding_ok(cli_id)
2018
+ else:
2019
+ health.report_binding_dead(cli_id, binding_dead_msg(cli_id))
2020
+ except Exception:
2021
+ pass # 告警是尽力而为的旁路:persist 失败(如目录不可用)不拖累操作本身
2022
+
2023
+
1970
2024
  def resolve_binding(agent_kind_or_id, difficulty="default"):
1971
2025
  """返回 {env:{}, model:..., model_fallbacks:[...], codex_provider:..., call_chain:[...]} 或 None。
1972
2026
 
@@ -1981,17 +2035,11 @@ def resolve_binding(agent_kind_or_id, difficulty="default"):
1981
2035
  provs = {p.get("id"): p for p in providers()}
1982
2036
  routing = bool(b.get("difficulty_routing"))
1983
2037
  tier = difficulty if difficulty in ("easy", "hard") else None
1984
- # dsh DEEPSEEK_* env,端点必须是 OpenAI 兼容的 /chat/completions,
1985
- # anthropic 协议的网关注进去也调不通,直接判为不可绑定。
1986
- allowed = ("openai",) if _deepseek_env_target(agent_kind_or_id) else _BINDABLE_PROTOCOLS
1987
- # 2026-09-15 连载验收实测:CLI 与供应商协议必须匹配——codex 只吃 openai wire
1988
- # (codex_provider 机制),claude 只吃 anthropic wire。混着注入会产生
1989
- # 「codex 拿到 ANTHROPIC_* env 却缺 ORCH_API_KEY」这类必然失败的组合
1990
- # (症状:Missing environment variable: ORCH_API_KEY)。
1991
- if agent_kind_or_id in ("codex-cli", "codex"):
1992
- allowed = ("openai",)
1993
- elif agent_kind_or_id in ("claude-code", "claude"):
1994
- allowed = ("anthropic",)
2038
+ # 协议必须匹配(bindable_protocols):dsh 只吃 OpenAI 兼容端点,codex 只吃
2039
+ # openai wire(codex_provider 机制),claude 只吃 anthropic wire——混着注入
2040
+ # 会产生「codex 拿到 ANTHROPIC_* env 却缺 ORCH_API_KEY」这类必然失败的组合
2041
+ # 2026-09-15 连载验收实测,症状:Missing environment variable: ORCH_API_KEY)。
2042
+ allowed = bindable_protocols(agent_kind_or_id)
1995
2043
 
1996
2044
  if chain:
1997
2045
  entries = []
@@ -2018,8 +2066,10 @@ def resolve_binding(agent_kind_or_id, difficulty="default"):
2018
2066
  continue # 原生协议与适配过的 wire 都不匹配:跳过
2019
2067
  if prov.get("name") in down_set:
2020
2068
  continue # 健康监测判定 down:跳过,省掉无效等待
2021
- if _is_codex_target(agent_kind_or_id) and codex_wire_blocked(prov):
2022
- continue # codex 撞过该供应商 wire 不兼容(chat-only):冷却中自动绕开
2069
+ if _is_codex_target(agent_kind_or_id) and (ep[2] == "chat" or codex_wire_blocked(prov)):
2070
+ continue # codex 0.154+ 只讲 responses wirechat-only 供应商在起跑前
2071
+ # 就剔除(此前撞了才冷却 30 分钟,每轮白烧一次注定失败的
2072
+ # 尝试——2026-09-17 续4 连载 c35 实测)
2023
2073
  if model and not _model_bindable(prov, model):
2024
2074
  continue # 模型被停用/删除:该条跳过(2026-09-15 告警弹框「禁用该模型」)
2025
2075
  # 多 KEY:同一厂商按 KEY 展开成多条,顺序即调用顺序。欠费的 KEY 被
@@ -2055,11 +2105,12 @@ def resolve_binding(agent_kind_or_id, difficulty="default"):
2055
2105
  prov = provs.get(pid)
2056
2106
  if not prov or not prov.get("enabled", True) or not prov.get("api_key"):
2057
2107
  return None
2058
- if _is_codex_target(agent_kind_or_id) and codex_wire_blocked(prov):
2059
- return None # codex wire 不兼容冷却中:解析为空 → 路由绑定分自动转负
2060
2108
  ep = _entry_endpoint(prov, allowed)
2061
2109
  if not ep:
2062
2110
  return None # google 只登记;dsh 只接受 OpenAI 兼容端点;未适配的不硬塞
2111
+ if _is_codex_target(agent_kind_or_id) and (ep[2] == "chat" or codex_wire_blocked(prov)):
2112
+ return None # codex 0.154+ 只讲 responses:chat-only 供应商直接判不可绑
2113
+ # (解析为空 → 死链闸门/路由降权接手,不浪费 CLI 尝试)
2063
2114
  names = [m["name"] for m in _enabled_models(prov)]
2064
2115
  model = prov.get("model_" + tier) or "" if (routing and tier) else ""
2065
2116
  if not model:
@@ -273,18 +273,51 @@ def _wait_gate(run_id, ev):
273
273
  time.sleep(1.0)
274
274
 
275
275
 
276
+ def _binding_dead_msg(agent):
277
+ """死链失败文案:委托 modelhub 单一真源(告警 sync 共用同一文案)。"""
278
+ try:
279
+ from . import modelhub
280
+ return modelhub.binding_dead_msg(agent.get("id") or "")
281
+ except Exception:
282
+ return ("绑定链全部失效,本步判失败、不回落 CLI 本机默认——"
283
+ "请在「CLI 绑定」页为该 CLI 绑定已启用的供应商")
284
+
285
+
276
286
  def _run_step(run_id, role, agent, prompt, workdir, readonly, ev, timeout=runner.DEFAULT_TIMEOUT, note="", resume=None, images=None, require_tools=False):
277
287
  """执行一个智能体步骤并记录。返回 runner 统一结果。"""
278
288
  _wait_gate(run_id, ev)
279
- # 绑定解析为空 CLI 将回落本机默认配置(用户配置的模型/供应商全部不生效)。
280
- # 2026-09-16 实测:这种状态下烧干配额的本机默认供应商被静默使用,用户以为
281
- # 在用自己配的模型。首次出现时在步骤备注里醒目标出。
282
- if agent.get("mode") == "real" and not (agent.get("call_chain") or agent.get("env")):
283
- note = ((note + "") if note else "") + \
284
- "⚠ 未解析到绑定链,本步回落 CLI 本机默认配置(请在模型接入页检查该 CLI 的供应商绑定)"
289
+ # 绑定解析为空:旧语义是回落 CLI 本机默认继续跑,2026-09-16 实测这种状态
290
+ # 会静默烧本机默认供应商的配额(用户以为在用自己配的模型)。2026-09-17
291
+ # 改为「告警 + 本步直接判失败」——宁可失败不静默降级;auto 流程实现步的
292
+ # 既有换将会接手健康 CLI,真没有可用 CLI 时运行以明确的绑定错误收场。
293
+ dead_binding = (agent.get("mode") == "real"
294
+ and not (agent.get("call_chain") or agent.get("env")))
295
+ dead_msg = _binding_dead_msg(agent) if dead_binding else ""
296
+ if dead_binding:
297
+ try:
298
+ from . import health
299
+ health.report_binding_dead(agent["id"], dead_msg)
300
+ except Exception:
301
+ pass
302
+ note = ((note + ";") if note else "") + "⚠ " + dead_msg
303
+ elif agent.get("mode") == "real":
304
+ try: # 绑定恢复:自动解除该 CLI 的静态死链告警
305
+ from . import health
306
+ health.report_binding_ok(agent["id"])
307
+ except Exception:
308
+ pass
285
309
  step, log_abs = store.add_step(run_id, role, agent["id"],
286
310
  agent.get("label", agent["id"]), note=note)
287
311
  start = time.time()
312
+ if dead_binding:
313
+ from .error_codes import ErrorCode
314
+ res = {"ok": False, "text": "", "json": None, "cost_usd": 0.0,
315
+ "tokens": 0, "usage": None, "error": dead_msg,
316
+ "error_code": ErrorCode.ENV_BLOCK,
317
+ "raw": {"exit_code": None}, "kind": agent.get("kind", "generic"),
318
+ "model": agent.get("model")}
319
+ _finish_step_result(run_id, step, res, role, agent, start)
320
+ return res
288
321
  if agent.get("mode") == "mock":
289
322
  time.sleep(0.3)
290
323
  res = {"ok": True, "text": "[mock] %s" % prompt[:80], "json": None,
@@ -1601,12 +1634,19 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
1601
1634
  res = None
1602
1635
  good = False
1603
1636
  txt = ""
1637
+ use_prompt = prompt
1604
1638
  for draft_attempt in range(3):
1605
1639
  if draft_attempt:
1606
1640
  if ev is not None and ev.is_set():
1607
1641
  break
1608
1642
  time.sleep(30 * draft_attempt) # 30s / 60s 退避
1609
- res = _run_step(run_id, "draft-c%d" % i, modelhub.bind_agent(impl, difficulty), prompt,
1643
+ if draft_attempt and len(prompt) > 12000 and sk_block and sk_block in prompt:
1644
+ # 长提示词在容量受限通道(讯飞托管 35B 等)上会挂起/秒拒
1645
+ # ——降级重试:经验库块截到 4K 字,保留大纲/前情/本章要点
1646
+ # (2026-09-17 七猫实测:全量 30KB 对讯飞必挂)
1647
+ use_prompt = prompt.replace(
1648
+ sk_block, sk_block[:4000] + "\n\n(经验库已因通道容量限制精简)")
1649
+ res = _run_step(run_id, "draft-c%d" % i, modelhub.bind_agent(impl, difficulty), use_prompt,
1610
1650
  step_wd, readonly=False, ev=ev, timeout=2400,
1611
1651
  resume=resume_ctx["session"] if resume_ctx else None,
1612
1652
  images=_task_images(task, workdir),
@@ -1626,15 +1666,23 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
1626
1666
  break
1627
1667
  if ev is not None and ev.is_set():
1628
1668
  break
1629
- # 同作者重试穷尽 → 起草换将(连载不断档优先,风格差异交评审门与
1630
- # 后续 revise 拉回)——与代码流程换将同款语义(2026-09-17 c34 实测)
1669
+ # 同作者重试穷尽 → 起草换将:按路由分序逐个试备选(最多 2 个,
1670
+ # 只试一个会让第二名没机会——2026-09-17 c35 实测 opencode 顶在
1671
+ # 前面,能干活的 kimi 永远轮不上)。连载不断档优先,风格差异交
1672
+ # 评审门与后续 revise 拉回。
1631
1673
  if not good:
1632
- other, other_reason = router.pick(
1633
- agents, "implement", task.get("type") or "serial", None,
1634
- exclude={impl["id"], "mock-a", "mock-b"})
1635
- if other and other.get("mode") == "real":
1674
+ tried = {impl["id"], "mock-a", "mock-b"}
1675
+ for _alt in range(2):
1676
+ if good or (ev is not None and ev.is_set()):
1677
+ break
1678
+ other, other_reason = router.pick(
1679
+ agents, "implement", task.get("type") or "serial", None,
1680
+ exclude=tried)
1681
+ if not (other and other.get("mode") == "real"):
1682
+ break
1683
+ tried.add(other["id"])
1636
1684
  res = _run_step(run_id, "draft-c%d" % i,
1637
- modelhub.bind_agent(other, difficulty), prompt,
1685
+ modelhub.bind_agent(other, difficulty), use_prompt,
1638
1686
  step_wd, readonly=False, ev=ev, timeout=2400,
1639
1687
  images=_task_images(task, workdir),
1640
1688
  note="起草换将 %s → %s:%s" % (
@@ -1649,6 +1697,9 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
1649
1697
  pass
1650
1698
  if good:
1651
1699
  draft_sid = "" # 换将作者无本任会话,revise 另起
1700
+ if not good:
1701
+ time.sleep(3) # 落盘竞态宽限:CLI 崩溃退出前写的文件可能晚于
1702
+ good, txt = _chapter_state() # 退出检查零点几秒才可见(c34 实测)
1652
1703
  if not good:
1653
1704
  store.update_run(run_id, status="failed",
1654
1705
  error="第 %d 章起草失败: %s" % (i, (res or {}).get("error")), ended_at=_now())
@@ -1668,53 +1719,68 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
1668
1719
  # 均分最高者为正稿。变体写隔离文件 chapter-XX-vK.md,赢家改名、
1669
1720
  # 败稿删除;变体 0 = 本任作者(revise 会话沿用),其余取跨族优先的
1670
1721
  # 其他真实智能体,不足时同作者开新会话凑数。
1671
- pool = [impl]
1672
- others = [a for a in agents if a.get("mode") == "real" and a["id"] != impl["id"]]
1673
- others.sort(key=lambda a: 0 if a.get("kind") != impl.get("kind") else 1)
1674
- pool += others[:n_variants - 1]
1675
- while len(pool) < n_variants:
1676
- pool.append(impl) # 不够就同作者再开一路(新会话天然出不同稿)
1677
- results = {}
1678
-
1679
- def _draft_one(kk, agent):
1680
- vfile = "chapter-%02d-v%d.md" % (i, kk)
1681
- r = _run_step(run_id, "draft-c%d-v%d" % (i, kk),
1682
- modelhub.bind_agent(agent, difficulty),
1683
- _draft_prompt(vfile), step_wd, readonly=False, ev=ev,
1684
- timeout=2400,
1685
- # 赛马只在全新起草时启用(无续会话),每路都是新会话
1686
- images=_task_images(task, workdir),
1687
- note="赛马变体 %d/%d(%s)" % (kk + 1, len(pool), agent.get("id")))
1688
- results[kk] = (vfile, agent, r)
1689
-
1690
- threads = []
1691
- for kk, agent in enumerate(pool):
1692
- th = threading.Thread(target=_draft_one, args=(kk, agent),
1693
- name="race-%s-c%d-v%d" % (run_id, i, kk), daemon=True)
1694
- threads.append(th)
1695
- th.start()
1696
- for th in threads:
1697
- th.join(3000)
1698
- _check_cancel(ev)
1699
-
1700
1722
  scored_variants = []
1701
- for kk in range(len(pool)):
1702
- vfile, agent, r = results.get(kk, (None, None, None))
1703
- if vfile is None:
1704
- continue
1705
- txt = _read_variant(workdir, i, kk)
1706
- ok_text = txt and _wc(txt) >= int(wpc * 0.6)
1707
- if r is not None and not r["ok"] and not ok_text:
1708
- continue # 这一路彻底失败(无成品也不够长)
1709
- if not ok_text:
1710
- continue
1711
- cj_map, sc, sids2 = run_critique(
1712
- txt, 1, note_extra="(本稿为同章赛马变体 %d/%d,只评这一份)" % (kk + 1, len(pool)))
1713
- m = means_of(cj_map)
1714
- avg = round(sum(m.values()) / max(1, len(m)), 2) if m else 0.0
1715
- scored_variants.append({"variant": kk, "agent": agent.get("id"),
1716
- "file": vfile, "means": m, "avg": avg,
1717
- "cj": cj_map, "scored": sc, "sids": sids2})
1723
+ for race_round in range(2):
1724
+ # 全变体失败(网关突发限流)→ 60s 退避重赛一轮,别一章判死
1725
+ if race_round:
1726
+ if ev is not None and ev.is_set():
1727
+ break
1728
+ time.sleep(60)
1729
+ for kk in range(n_variants):
1730
+ # 清上一轮残稿:防陈旧半成品被本轮评分误认成新成品
1731
+ try:
1732
+ os.remove(os.path.join(workdir, "chapter-%02d-v%d.md" % (i, kk)))
1733
+ except OSError:
1734
+ pass
1735
+ pool = [impl]
1736
+ others = [a for a in agents if a.get("mode") == "real" and a["id"] != impl["id"]]
1737
+ others.sort(key=lambda a: 0 if a.get("kind") != impl.get("kind") else 1)
1738
+ pool += others[:n_variants - 1]
1739
+ while len(pool) < n_variants:
1740
+ pool.append(impl) # 不够就同作者再开一路(新会话天然出不同稿)
1741
+ results = {}
1742
+
1743
+ def _draft_one(kk, agent):
1744
+ vfile = "chapter-%02d-v%d.md" % (i, kk)
1745
+ r = _run_step(run_id, "draft-c%d-v%d" % (i, kk),
1746
+ modelhub.bind_agent(agent, difficulty),
1747
+ _draft_prompt(vfile), step_wd, readonly=False, ev=ev,
1748
+ timeout=2400,
1749
+ # 赛马只在全新起草时启用(无续会话),每路都是新会话
1750
+ images=_task_images(task, workdir),
1751
+ note="赛马变体 %d/%d(%s)" % (kk + 1, len(pool), agent.get("id")))
1752
+ results[kk] = (vfile, agent, r)
1753
+
1754
+ threads = []
1755
+ for kk, agent in enumerate(pool):
1756
+ th = threading.Thread(target=_draft_one, args=(kk, agent),
1757
+ name="race-%s-c%d-v%d" % (run_id, i, kk), daemon=True)
1758
+ threads.append(th)
1759
+ th.start()
1760
+ for th in threads:
1761
+ th.join(3000)
1762
+ _check_cancel(ev)
1763
+
1764
+ scored_variants = []
1765
+ for kk in range(len(pool)):
1766
+ vfile, agent, r = results.get(kk, (None, None, None))
1767
+ if vfile is None:
1768
+ continue
1769
+ txt = _read_variant(workdir, i, kk)
1770
+ ok_text = txt and _wc(txt) >= int(wpc * 0.6)
1771
+ if r is not None and not r["ok"] and not ok_text:
1772
+ continue # 这一路彻底失败(无成品也不够长)
1773
+ if not ok_text:
1774
+ continue
1775
+ cj_map, sc, sids2 = run_critique(
1776
+ txt, 1, note_extra="(本稿为同章赛马变体 %d/%d,只评这一份)" % (kk + 1, len(pool)))
1777
+ m = means_of(cj_map)
1778
+ avg = round(sum(m.values()) / max(1, len(m)), 2) if m else 0.0
1779
+ scored_variants.append({"variant": kk, "agent": agent.get("id"),
1780
+ "file": vfile, "means": m, "avg": avg,
1781
+ "cj": cj_map, "scored": sc, "sids": sids2})
1782
+ if scored_variants:
1783
+ break
1718
1784
  if not scored_variants:
1719
1785
  store.update_run(run_id, status="failed",
1720
1786
  error="第 %d 章赛马全部变体起草失败" % i, ended_at=_now())
@@ -66,6 +66,10 @@ def _build_agent(entry):
66
66
  # 小时级 token 配额(可选,0/缺省=不限):路由时对本小时用量超标的
67
67
  # 智能体降权(munder-difflin 式配额感知),订阅型 CLI 不至于被单任务打爆
68
68
  "quota_tokens_per_hour": int(orch.get("quota_tokens_per_hour") or 0),
69
+ # 整包透传 orch:per-agent 运行时开关(timeout_ms 5E、stall_timeout_s
70
+ # 看门狗等)都在 runner 侧读取——此前只挑字段,catalog 上配的
71
+ # timeout_ms/stall 根本流不到 runner(2026-09-17 连载 c35 实测)
72
+ "orch": orch,
69
73
  }
70
74
 
71
75
 
@@ -14,9 +14,10 @@ MAX_REPAIR_ROUNDS = 2 # 自动修复循环上限
14
14
 
15
15
 
16
16
  def _binding_bonus(agent_id):
17
- """绑定链可用性加分/减分:链上有可用条目 +8,解析为空(回落 CLI 本机默认)
18
- -25。2026-09-16 实测:静态能力基线让配额烧干的 codex 永远压过健康备用 CLI
19
- 绑定空的 CLI 更是连用户配置的模型都没用上——先按「能不能按配置跑起来」校准。"""
17
+ """绑定链可用性加分/减分:链上有可用条目 +8,解析为空 -25。2026-09-16 实测:
18
+ 静态能力基线让配额烧干的 codex 永远压过健康备用 CLI,绑定空的 CLI 更是连
19
+ 用户配置的模型都没用上——先按「能不能按配置跑起来」校准。2026-09-17
20
+ 空链步骤在 pipeline 直接判失败(不再静默回落本机默认),此处只管排序。"""
20
21
  try:
21
22
  from . import modelhub
22
23
  b = modelhub.resolve_binding(agent_id)
@@ -49,7 +50,7 @@ def score(agent, role, ttype, stats=None):
49
50
  if bb > 0:
50
51
  btxt = ",绑定链可用(+%s)" % bb
51
52
  elif bb < 0:
52
- btxt = ",绑定链为空将回落 CLI 本机默认配置(%s)" % bb
53
+ btxt = ",绑定链为空:相关步骤将判失败(%s)" % bb
53
54
  hb = _history_bonus(stats, agent.get("id"), ttype)
54
55
  total = base + bb + hb
55
56
  hs = (stats.get(agent.get("id")) or {}).get(ttype)
@@ -824,9 +824,18 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
824
824
  if orch_timeout_ms:
825
825
  timeout = float(orch_timeout_ms) / 1000.0
826
826
  kind = agent.get("kind", "generic")
827
- stall_t = (_stall_timeout("TUTTI_CODEX_STALL_TIMEOUT", 600) if kind == "codex"
828
- else _stall_timeout("TUTTI_CLAUDE_STALL_TIMEOUT", 600)
829
- if kind == "claude" else 0)
827
+ if kind == "codex":
828
+ stall_t = _stall_timeout("TUTTI_CODEX_STALL_TIMEOUT", 600)
829
+ elif kind == "claude":
830
+ stall_t = _stall_timeout("TUTTI_CLAUDE_STALL_TIMEOUT", 600)
831
+ else:
832
+ # 数据驱动:catalog orch.stall_timeout_s——给 kimi 这类边写边吐进度行的
833
+ # CLI 配置后,静默挂死 10 分钟即杀(不必耗满总超时);qwen/mimo 这类
834
+ # 结束才一次性输出的留 0(开了必误杀),靠总超时兜底
835
+ try:
836
+ stall_t = max(0, int((agent.get("orch") or {}).get("stall_timeout_s") or 0))
837
+ except Exception:
838
+ stall_t = 0
830
839
  # 5G:approval NEVER 一线(无人值守不静默降级)
831
840
  ok, reason = _check_approval(agent)
832
841
  if not ok:
@@ -76,7 +76,8 @@ def _npm_meta():
76
76
  """npm view <pkg> --json;返回 (latest, relnotes, err)。
77
77
 
78
78
  relnotes 来自 registry 元数据里的 README(与查新同一条 npm 通道,不依赖
79
- GitHub 连通性),展示「新版本更新内容」用。"""
79
+ GitHub 连通性),展示「新版本更新内容」用。部分 npm 版本 --json 不带
80
+ readme 字段,此时回退到 `npm view <pkg> readme` 纯文本再提取。"""
80
81
  r = runner.run_process(
81
82
  argv=["cmd", "/c", "npm", "view", _PKG_NAME, "--json"], timeout=60)
82
83
  if not r["ok"]:
@@ -92,6 +93,11 @@ def _npm_meta():
92
93
  if not ver: # 旧 npm --json 失败时退回纯文本解析
93
94
  m = re.search(r"\d+\.\d+\.\d+[\w.\-]*", r["stdout"] or "")
94
95
  ver = m.group(0) if m else ""
96
+ if ver and not notes:
97
+ r2 = runner.run_process(
98
+ argv=["cmd", "/c", "npm", "view", _PKG_NAME, "readme"], timeout=60)
99
+ if r2["ok"]:
100
+ notes = _relnotes(r2["stdout"] or "")
95
101
  return ver, notes, ("" if ver else "npm 输出无法解析")
96
102
 
97
103