codebee 0.1.48 → 0.1.50

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.
@@ -6,6 +6,7 @@
6
6
  """
7
7
  from __future__ import annotations
8
8
 
9
+ import hashlib
9
10
  import json
10
11
  import os
11
12
  import re
@@ -15,6 +16,7 @@ import signal
15
16
  import socket
16
17
  import subprocess
17
18
  import sys
19
+ import tempfile
18
20
  import threading
19
21
  import time
20
22
  import webbrowser
@@ -46,6 +48,31 @@ def _expand(p):
46
48
  return os.path.abspath(os.path.expanduser(os.path.expandvars(p)))
47
49
 
48
50
 
51
+ def _under(path, root):
52
+ try:
53
+ return os.path.commonpath([os.path.realpath(path), root]) == root
54
+ except ValueError:
55
+ return False
56
+
57
+
58
+ def tmp_data_no_home_write():
59
+ """测试实例防毒闸:TUTTI_DATA 指向系统临时目录、且进程主目录仍是真实
60
+ 主目录时,一切对真实 CLI 配置的写入都应拒绝。
61
+
62
+ 一次性测试服务的 models.json 里全是 a.test/sk-test 夹具,忘设假 HOME 的
63
+ 测试一旦触发 launch/自愈/autobind 同步,就会把夹具毒进真实
64
+ ~/.claude/settings.json(2026-09-22 实案:claude 步骤全体 ENOTFOUND,
65
+ 下一次测试又覆盖回来,形成「越测越坏」循环)。已设假 HOME 的测试不受
66
+ 影响——假 HOME 本身就在临时目录下。
67
+ """
68
+ td = (os.environ.get("TUTTI_DATA") or "").strip()
69
+ if not td:
70
+ return False
71
+ tmp = tempfile.gettempdir()
72
+ home = os.path.abspath(os.path.expanduser("~"))
73
+ return _under(td, tmp) and not _under(home, tmp)
74
+
75
+
49
76
  def _safe_config_path(raw):
50
77
  """展开并校验配置路径:必须在用户主目录内(防穿越)。"""
51
78
  if not raw:
@@ -521,6 +548,9 @@ def read_model(entry):
521
548
  def write_model(entry, model):
522
549
  """写入默认模型(改动前自动备份 .bak)。支持 toml-line / toml-section /
523
550
  json / json-path / jsonc / yaml-line。"""
551
+ if tmp_data_no_home_write():
552
+ return {"ok": False,
553
+ "error": "测试数据目录(TUTTI_DATA 在临时目录)不写真实 CLI 配置,已拦截"}
524
554
  path = _config_path(entry)
525
555
  cfg = entry.get("config") or {}
526
556
  fmt = cfg.get("format")
@@ -750,6 +780,8 @@ def _sync_dsh_settings(entry, model, base_url):
750
780
  端点不同步不行——settings 优先级高于 env,密钥会发给旧端点;
751
781
  models 列表不同步不行——dsh web 的模型下拉只列它,缺条目就选不中。
752
782
  返回错误串或 None。"""
783
+ if tmp_data_no_home_write():
784
+ return "测试数据目录(TUTTI_DATA 在临时目录)不写真实 CLI 配置,已拦截"
753
785
  path = _config_path(entry)
754
786
  if not path:
755
787
  return "dsh 配置路径无效"
@@ -891,6 +923,8 @@ def _sync_codex_settings(entry, model, cp):
891
923
  _codex_provider_args 同构,但落 config 文件。返回错误串或 None。"""
892
924
  if _is_dead_endpoint(cp.get("base_url")):
893
925
  return "供应商端点 %r 是测试/保留地址,拒绝写入 config.toml" % (cp.get("base_url"),)
926
+ if tmp_data_no_home_write():
927
+ return "测试数据目录(TUTTI_DATA 在临时目录)不写真实 CLI 配置,已拦截"
894
928
  path = _config_path(entry)
895
929
  if not path:
896
930
  return "codex 配置路径无效"
@@ -1120,6 +1154,8 @@ def _sync_settings_env(path, updates, remove_keys=()):
1120
1154
  """把键值对写进目标 settings.json 的 env 段(claude/qwen 等同构:交互 TUI
1121
1155
  启动时把该段合并进进程环境)。只动 env 相关键,其余内容保留;坏 JSON 中止
1122
1156
  不覆盖;改动前 .bak。返回错误串或 None。path 必须已过主目录围栏校验。"""
1157
+ if tmp_data_no_home_write():
1158
+ return "测试数据目录(TUTTI_DATA 在临时目录)不写真实 CLI 配置,已拦截"
1123
1159
  try:
1124
1160
  if os.path.isfile(path):
1125
1161
  text = _read_text(path)
@@ -1213,6 +1249,8 @@ def _sync_opencode_settings(entry, model, prov):
1213
1249
  没 key)。写入所有已存在的候选文件(避免新文件遮蔽旧文件的读取优先级),
1214
1250
  全不存在时建 catalog 登记的那个。纯 JSON 走整体读改写;带注释的 JSONC 走
1215
1251
  文本级就地 patch(保留注释)。返回错误串或 None。"""
1252
+ if tmp_data_no_home_write():
1253
+ return "测试数据目录(TUTTI_DATA 在临时目录)不写真实 CLI 配置,已拦截"
1216
1254
  npm = "@ai-sdk/anthropic" if prov.get("protocol") == "anthropic" else "@ai-sdk/openai-compatible"
1217
1255
  base = prov.get("base_url") or ""
1218
1256
  if prov.get("protocol") == "anthropic" and not base.rstrip("/").endswith("/v1"):
@@ -1323,7 +1361,9 @@ def _sync_kimi_settings(entry, model, prov):
1323
1361
  yolo/defaultPermissionMode(camelCase)+ [providers.<id>](type/apiKey/
1324
1362
  baseUrl)+ [models.<别名>](provider/model/maxContextSize 必填)。无人值守
1325
1363
  要 yolo——否则工具调用逐个要审批,headless 全被拒。文本级托管块(标记注释
1326
- 之间)幂等重写,用户自有内容保留在外。"""
1364
+ 之间)幂等重写,用户自有内容保留在外。返回错误串或 None。"""
1365
+ if tmp_data_no_home_write():
1366
+ return "测试数据目录(TUTTI_DATA 在临时目录)不写真实 CLI 配置,已拦截"
1327
1367
  import re as _re
1328
1368
  top_keys = ("defaultProvider", "defaultModel", "yolo", "defaultPermissionMode")
1329
1369
  targets = [os.path.abspath(os.path.expanduser("~/.kimi-code/config.toml"))]
@@ -1357,10 +1397,23 @@ def _sync_kimi_settings(entry, model, prov):
1357
1397
  return ";".join(errs) or None
1358
1398
 
1359
1399
 
1400
+ def _claude_sync_enabled():
1401
+ """「直写 claude 配置」开关(settings 的 claude_config_sync,默认开)。
1402
+ 关闭 = ~/.claude/settings.json 归用户手动管理(cc-switch 等),打开/运行前
1403
+ 防线一律不落盘;编排步骤经 runner 一次性 --settings 注入绑定链,不受影响。"""
1404
+ try:
1405
+ from . import settings
1406
+ return settings.load().get("claude_config_sync", True)
1407
+ except Exception:
1408
+ return True
1409
+
1410
+
1360
1411
  def sync_cli_config_now(agent_id):
1361
1412
  """运行期自愈:CLI 本体没配置(如 kimi「No model configured」)→ 立即把
1362
1413
  绑定注入其自家配置,换将/下轮即可用。返回给日志的备注(空=无事发生)。"""
1363
1414
  try:
1415
+ if agent_id == "claude-code" and not _claude_sync_enabled():
1416
+ return "按设置跳过直写 ~/.claude 配置(编排步骤经一次性 --settings 注入,不受影响)"
1364
1417
  from . import modelhub
1365
1418
  entry = next((a for a in catalog.load() if a.get("id") == agent_id), None)
1366
1419
  if not entry:
@@ -1397,6 +1450,8 @@ def _sync_agent_injection(entry, binding):
1397
1450
  spec = _AGENT_INJECTORS.get(entry["id"])
1398
1451
  if spec:
1399
1452
  protocols, injector = spec
1453
+ if entry["id"] == "claude-code" and not _claude_sync_enabled():
1454
+ return "按设置跳过直写 ~/.claude 配置(交互 TUI 保持手动管理;编排步骤经一次性 --settings 注入不受影响)"
1400
1455
  pick, note = modelhub.launch_pick(entry["id"], protocols)
1401
1456
  if pick:
1402
1457
  prov = pick["provider"]
@@ -1466,7 +1521,19 @@ def _sync_launch_model(entry, binding):
1466
1521
  # (draft-c12 等),CLI 身份在 kind 里;目录型智能体的 id 就是条目 id。
1467
1522
  _KIND_ENTRY = {"codex": "codex-cli", "claude": "claude-code",
1468
1523
  "qwen": "qwencode", "opencode": "opencode", "aider": "aider"}
1469
- _RUNTIME_SYNC = {"fps": {}, "lock": threading.Lock()}
1524
+ _RUNTIME_SYNC = {"fps": {}, "files": {}, "lock": threading.Lock()}
1525
+
1526
+
1527
+ def _runtime_cfg_hash(entry):
1528
+ """CLI 自家配置文件的内容指纹;文件不存在返回 None。"""
1529
+ try:
1530
+ p = _safe_config_path((entry.get("config") or {}).get("path"))
1531
+ if p and os.path.isfile(p):
1532
+ with open(p, "rb") as fh:
1533
+ return hashlib.sha256(fh.read()).hexdigest()
1534
+ except Exception:
1535
+ pass
1536
+ return None
1470
1537
 
1471
1538
 
1472
1539
  def sync_runtime_config(agent):
@@ -1509,10 +1576,16 @@ def sync_runtime_config(agent):
1509
1576
  fp = (model, repr(sorted(env.items())),
1510
1577
  repr(binding.get("codex_provider") or "")[:300])
1511
1578
  with _RUNTIME_SYNC["lock"]:
1512
- if _RUNTIME_SYNC["fps"].get(entry["id"]) == fp:
1579
+ if (_RUNTIME_SYNC["fps"].get(entry["id"]) == fp
1580
+ and _RUNTIME_SYNC["files"].get(entry["id"]) == _runtime_cfg_hash(entry)):
1513
1581
  return
1582
+ # 绑定没变≠文件没变:指纹命中但文件被外部改写(测试毒写/手工编辑
1583
+ # 托管段)时必须按绑定重写——2026-09-22 kimi 实案:测试夹具毒入
1584
+ # ~/.kimi-code/config.toml(x.example 不可解析),换将步骤因指纹
1585
+ # 命中跳过重写,kimi 带毒配置静默退出码 1
1514
1586
  _sync_launch_model(entry, binding)
1515
1587
  _RUNTIME_SYNC["fps"][entry["id"]] = fp
1588
+ _RUNTIME_SYNC["files"][entry["id"]] = _runtime_cfg_hash(entry)
1516
1589
  except Exception:
1517
1590
  pass
1518
1591
 
@@ -448,6 +448,30 @@ def _drop_model_refs(data, prov, name):
448
448
  _sync_chain_refs(b)
449
449
 
450
450
 
451
+ def _prune_binding_chains(data, prov_ids=(), pairs=()):
452
+ """停用即出调度:把指向已停用供应商/模型的条目从所有绑定链里剔除。
453
+
454
+ 与删除的清引用(_drop_model_refs / providers_op delete)不同,停用可逆,
455
+ 只摘链上死条目,不动主供应商 provider_id 与厂商默认模型——重新启用后
456
+ 原样恢复。链上剔除不回填:调度页所见即所绑,不靠运行时降级默默跳过
457
+ 占位死条目(2026-09-22 用户拍板:禁用就直接去掉,自动就行)。
458
+ """
459
+ ids = {i for i in (prov_ids or ()) if i}
460
+ prs = {(p, m) for p, m in (pairs or ()) if p and m}
461
+ if not ids and not prs:
462
+ return
463
+ for b in (data.get("bindings") or {}).values():
464
+ chain = b.get("chain")
465
+ if not chain:
466
+ continue
467
+ kept = [c for c in chain
468
+ if (c.get("provider_id") or "") not in ids
469
+ and (c.get("provider_id") or "", c.get("model") or "") not in prs]
470
+ if len(kept) != len(chain):
471
+ b["chain"] = kept
472
+ _sync_chain_refs(b)
473
+
474
+
451
475
  _MODEL_OPS = ("enable", "disable", "delete", "restore")
452
476
 
453
477
 
@@ -475,6 +499,7 @@ def _apply_model_op(data, prov, models, name, op):
475
499
  if not target.get("enabled", True):
476
500
  return False, ""
477
501
  target["enabled"] = False
502
+ _prune_binding_chains(data, pairs=[(prov.get("id"), name)])
478
503
  elif op == "delete":
479
504
  if target.get("hidden"):
480
505
  return False, ""
@@ -494,6 +519,8 @@ def model_ops(provider_id, names, op):
494
519
 
495
520
  先整体校验再落盘:任一名不存在、或 enable/disable 选中了已删除的模型,
496
521
  整个请求都不生效,避免只改一半。全部改动合并为一次写盘。
522
+ disable 即出调度:命中的 (供应商, 模型) 条目自动从所有绑定链剔除,
523
+ 不回填;delete 走 _drop_model_refs 连默认模型引用一起清。
497
524
  restore 与可见模型混选时,可见项直接跳过(不算失败),便于「恢复所选」。
498
525
  """
499
526
  names = list(dict.fromkeys(n for n in (names or []) if n))
@@ -656,9 +683,13 @@ def provider_view():
656
683
  _KEY_COOLDOWN_S = 30 * 60 # 欠费类失败后的冷却时长
657
684
  MAX_PROVIDER_KEYS = 8 # 单厂商 KEY 上限
658
685
  MAX_CHAIN_ATTEMPTS = 8 # 链展开后的尝试上限(模型 × KEY)
659
- # 欠费/配额类失败:换 KEY 有意义(同厂商另一账号还能用),与瞬态网络错误分开记
686
+ # 欠费/配额/限流类失败:换 KEY 有意义(同厂商另一账号=另一份额度与并发桶),
687
+ # 与瞬态网络错误分开记。429 限流文案一并纳入——只记错不冷却会让每个新步骤都
688
+ # 从超限的首选 KEY 重新烧起(2026-09-22 首选超限不切备用实案)。
660
689
  _QUOTA_HINTS = ("insufficient", "quota", "balance", "credit", "billing", "arrears",
661
- "payment required", "402", "欠费", "余额", "额度", "exceeded")
690
+ "payment required", "402", "欠费", "余额", "额度", "exceeded",
691
+ "too many requests", "rate limit", "rate_limit", "http 429",
692
+ "并发", "超过限")
662
693
 
663
694
 
664
695
  def _quota_error(err):
@@ -938,8 +969,9 @@ def upsert_provider(entry):
938
969
  def providers_op(ids, op):
939
970
  """批量供应商操作(enable | disable | delete | duplicate)。返回 (改动数, 错误)。
940
971
 
941
- 停用只影响 Tutti 编排时的运行时解析(resolve_binding 返回空 → 回落 CLI 默认),
942
- 不清除配置,也不影响绑定引用,随时可再启用。
972
+ 停用即出调度:绑定链里它的条目自动剔除(_prune_binding_chains),配置与
973
+ 模型列表保留,随时可再启用;已剔除的链条目不回填。运行时解析本来就会
974
+ 跳过停用供应商,这里把落盘链一并清掉,调度页所见即所绑。
943
975
  duplicate 复制一条(同地址同密钥的第二个账号/新网关),副本不带绑定引用。
944
976
  """
945
977
  ids = list(dict.fromkeys(i for i in (ids or []) if i))
@@ -998,6 +1030,8 @@ def providers_op(ids, op):
998
1030
  p["enabled"] = want
999
1031
  changed += 1
1000
1032
  if changed:
1033
+ if not want:
1034
+ _prune_binding_chains(data, prov_ids=ids)
1001
1035
  # 启用置顶:本次选中的启用者排最前,其余启用跟随,停用的殿后;
1002
1036
  # 排序稳定,各块内部保持原有顺序。落盘重排,列表页看到的就是它。
1003
1037
  sel = set(ids)
@@ -2298,11 +2332,13 @@ def resolve_binding(agent_kind_or_id, difficulty="default"):
2298
2332
  entries.append(_chain_entry_env(prov, model, target=agent_kind_or_id,
2299
2333
  endpoint=ep, key=kk["key"],
2300
2334
  key_id=kk.get("id") or "", provider_id=pid))
2335
+ mk = (chain_keys or [{}])[0] # 回退模型沿用首选可用 KEY:失败也要能记账冷却
2301
2336
  for n in fallbacks:
2302
2337
  if len(entries) >= MAX_CHAIN_ATTEMPTS:
2303
2338
  break
2304
2339
  entries.append(_chain_entry_env(prov, n, target=agent_kind_or_id,
2305
- endpoint=ep, provider_id=pid))
2340
+ endpoint=ep, key=mk.get("key") or "",
2341
+ key_id=mk.get("id") or "", provider_id=pid))
2306
2342
  head = entries[0]
2307
2343
  out = {"model": model, "env": head["env"], "provider": prov,
2308
2344
  "model_fallbacks": fallbacks,
@@ -2406,6 +2442,18 @@ def launch_pick(agent_id, protocols):
2406
2442
  protocols = tuple(p for p in (protocols or ()) if p)
2407
2443
  b = bindings().get(agent_id) or {}
2408
2444
  chain = _binding_chain(b)
2445
+ if not chain and str(b.get("provider_id") or "").strip():
2446
+ # 只锁定了供应商、没排链也没选模型的绑定:与 resolve_binding 的无链分支
2447
+ # 同语义补一条,打开前才有凭据可注入。否则这类绑定永远「未指定供应商」,
2448
+ # CLI 自家配置里的残留(a.test 占位案)等不到自愈覆盖——2026-09-22
2449
+ # claude ENOTFOUND 四连败实测,settings.json 毒端点靠它长期存活。
2450
+ _pid = str(b.get("provider_id")).strip()
2451
+ _prov = {p.get("id"): p for p in providers()}.get(_pid)
2452
+ _model = str(b.get("model") or "").strip()
2453
+ if _prov and not _model:
2454
+ _names = [m["name"] for m in _enabled_models(_prov)]
2455
+ _model = _prov.get("model") or (_names[0] if _names else "")
2456
+ chain = [{"provider_id": _pid, "model": _model}]
2409
2457
  provs = {p.get("id"): p for p in providers()}
2410
2458
  disabled, mismatch = [], False
2411
2459
  for item in chain:
@@ -419,7 +419,25 @@ def _run_builtin_step(run_id, role, bi, prompt, workdir, ev, note="", images=Non
419
419
  def _log(line):
420
420
  lines.append(str(line))
421
421
 
422
- res = builtin_agent.run(bi, prompt, workdir, cancel_event=ev, log=_log, images=images)
422
+ # 思考过程可视化(2026-09-22 用户诉求):模型的思维链/正文/工具活动边收边
423
+ # 落进步骤记录(store.stream_step 内部节流),对话时间线 2s 轮询即能读到
424
+ # 「它正在想什么」——此前整轮只有三点打字动画。
425
+ live = {"thinking": "", "text": ""}
426
+
427
+ def _on_reason(chunk):
428
+ live["thinking"] += chunk or ""
429
+ store.stream_step(run_id, step["n"], thinking=live["thinking"])
430
+
431
+ def _on_stream(acc):
432
+ live["text"] = acc or ""
433
+ store.stream_step(run_id, step["n"], text=live["text"])
434
+
435
+ def _on_activity(line):
436
+ store.stream_step(run_id, step["n"], activity=str(line or "")[:200])
437
+
438
+ res = builtin_agent.run(bi, prompt, workdir, cancel_event=ev, log=_log, images=images,
439
+ on_reason=_on_reason, on_stream=_on_stream,
440
+ on_activity=_on_activity)
423
441
  if followups and res.get("ok"):
424
442
  clean, fups = _parse_followups(res.get("text") or "")
425
443
  if fups:
@@ -427,6 +445,10 @@ def _run_builtin_step(run_id, role, bi, prompt, workdir, ev, note="", images=Non
427
445
  res["followups"] = fups
428
446
  if log_abs:
429
447
  try:
448
+ # 思考过程同时进步骤日志(日志抽屉与对话气泡同源,便于事后复盘)
449
+ think = (res.get("reasoning") or "").strip()
450
+ if think:
451
+ lines.append("[思考过程]\n" + think)
430
452
  log_abs.write_text("\n".join(lines) + "\n", encoding="utf-8")
431
453
  except Exception:
432
454
  pass
@@ -550,7 +572,9 @@ def _finish_step_result(run_id, step, res, role, agent, start):
550
572
  # 智能体的最终回答(runner 已从 JSONL 事件流里抽出 agent_message)。
551
573
  # 对话视图直读这个;日志文件是全量事件流,塞进气泡就成了「看日志」。
552
574
  output=(res.get("text") or ""),
553
- followups=res.get("followups"))
575
+ followups=res.get("followups"),
576
+ # 思考过程(内置智能体流式抓取):落进步骤记录,对话气泡折叠展示
577
+ thinking=res.get("reasoning") or None)
554
578
  # 错误台账:失败/超时各记一条结构化记录(遥测与诊断包的数据源)。
555
579
  # 用户主动取消不入账——那不是产品问题;detail 只存脱敏后的失败摘录。
556
580
  if status in ("failed", "timeout"):
@@ -1807,45 +1831,11 @@ def _plot_modules(workdir):
1807
1831
  "鼓励化用,不要照抄原句)\n\n" + txt)
1808
1832
 
1809
1833
 
1810
- def _chain_volume_names(task):
1811
- """沿 serial.continues 链收集各批次大纲里的卷名/卷弧光(旧 → 新,后者覆盖)。
1812
- 续写批次合并成书时要给全书(含此前各批)的卷都写上标题,那些卷的命名记在
1813
- 更早的批次任务上,本批 run 的 outline 里没有。失败静默返回 {}。"""
1814
- from . import store as _store
1815
- names, seen, cur = {}, set(), task
1816
- chain = []
1817
- while cur and len(chain) < 20:
1818
- tid = str(cur.get("id") or "")
1819
- if not tid or tid in seen:
1820
- break
1821
- seen.add(tid)
1822
- chain.append(cur)
1823
- nxt = str((cur.get("serial") or {}).get("continues") or "")
1824
- cur = _store.get_task(nxt) if nxt else None
1825
- for t in reversed(chain): # 旧 → 新:新批次的命名覆盖旧批次
1826
- try:
1827
- for r in _store.task_runs(t["id"]):
1828
- o = r.get("outline") or {}
1829
- if isinstance(o.get("volumes"), dict):
1830
- names.update(o["volumes"])
1831
- except Exception:
1832
- continue
1833
- return names
1834
-
1835
-
1836
1834
  def _book_volume_plan(task, outline, upto):
1837
- """本书的卷规划表:显式卷表/每卷章数(serial)→ 规划表 → 合并卷名
1838
- (本批大纲 + 沿链更早批次)→ 保证覆盖到第 upto 章。
1839
- 不分卷返回 [];调用方据此决定是否插卷标题/卷末约束。"""
1840
- serial = task.get("serial") or {}
1841
- per = volumes.norm_per(serial.get("volume_chapters"))
1842
- plan = volumes.build_plan(serial.get("volumes"), per, upto=upto)
1843
- if not plan:
1844
- return []
1845
- named = dict(_chain_volume_names(task))
1846
- if isinstance(outline, dict):
1847
- named.update(outline.get("volumes") or {})
1848
- return volumes.merge_titles(plan, named)
1835
+ """本书的卷规划表(卷边界+卷名+卷弧光)。实现见 planner.book_volume_plan
1836
+ ——放在 planner 是因为它要沿 serial.continues 链读历史 run 的卷名,属
1837
+ 「大纲/命名」职责;此处保留薄封装便于连载流程阅读。"""
1838
+ return planner.book_volume_plan(task, outline, upto)
1849
1839
 
1850
1840
 
1851
1841
  LEDGER_FILE = os.path.join(".codebee", "resource-ledger.md")
@@ -2194,6 +2184,9 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2194
2184
  kb = knowledge.block_for(task)
2195
2185
  if kb:
2196
2186
  tpl = tpl.replace("## 待评审稿件", "%s\n\n## 待评审稿件" % kb, 1)
2187
+ # 确定性检测(AI 味/叙事架构/节奏)此前只挂在单稿件评审上,连载逐章
2188
+ # 从未拿到——逐章节奏与钩子恰恰最需要这条参考线(命中才追加,不扣分)
2189
+ tpl = aiflavor.inject_into_prompt(tpl, text, task.get("type"))
2197
2190
  return tpl.replace("__DIMKEYS__", dimkey).replace(
2198
2191
  "__MANUSCRIPT__", text or "(稿件为空!)")
2199
2192
 
@@ -2354,50 +2347,92 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2354
2347
 
2355
2348
  def run_critique(text, rnd, note_extra="", critic_sids=None, event_check=""):
2356
2349
  """一轮多维评审:返回 (cj_by_agent, scored)。变体赛马与主循环共用。
2357
- event_check:本章大纲核对块(逐项目标审稿),随评审下发并回收标记块。"""
2350
+ event_check:本章大纲核对块(逐项目标审稿),随评审下发并回收标记块。
2351
+
2352
+ 多评审并发跑(2026-09-22):评审占运行总时长的六成以上,而各评审之间
2353
+ 互不依赖(同一份稿件、各自视角),串行等待纯属浪费——每位评审的墙上
2354
+ 时间直接叠加。与「同章多稿赛马」「best-of 候选」同一套线程范式(那两处
2355
+ 早已多线程调用 _run_step)。共享结构的写入全部收敛到 join 之后按
2356
+ critics 原顺序合并,保证与串行版逐字节同结果(ev_check 取第一份非空、
2357
+ issues/ledger 的先后顺序都不变),只在耗时上取并行收益。"""
2358
2358
  cj_map, sids = {}, dict(critic_sids or {})
2359
2359
  scored = 0 # 真正给出分数的评审数;失败/不可解析不得当成 0 分计入
2360
- for agent in critics:
2361
- role = "critique-c%d" % i
2360
+ role = "critique-c%d" % i
2361
+ results = {}
2362
+ # 绑定解析提前到主线程:bind_agent 会把链首同步进 CLI 自家配置(写盘),
2363
+ # 多线程同时调它有写盘竞争。解析结果随线程参数传入,线程内只做调用。
2364
+ bound = [modelhub.bind_agent(a, difficulty) for a in critics]
2365
+
2366
+ def _critique_one(idx, agent):
2367
+ """单评审执行体(线程内只做调用与解析,不改共享结构)。
2368
+
2369
+ 异常不吞:记进 results 由主线程按原顺序重抛——串行版的
2370
+ Cancelled/管道异常语义(上抛打断本 run)必须原样保留。"""
2371
+ try:
2372
+ _critique_one_inner(idx, agent)
2373
+ except BaseException as exc: # noqa: BLE001(含 Cancelled)
2374
+ results[idx] = {"error": exc}
2375
+
2376
+ def _critique_one_inner(idx, agent):
2362
2377
  if agent.get("mode") == "mock":
2363
2378
  step, log_abs = store.add_step(run_id, role, agent["id"], agent.get("label"))
2364
2379
  time.sleep(0.15)
2365
2380
  cj = mocks.critique(agent["id"], rnd, dims, threshold_ch)
2366
- scored += 1
2367
2381
  store.finish_step(run_id, step["n"], "done",
2368
2382
  summary="均分 %.1f:%s" % (
2369
2383
  sum(cj["scores"].values()) / max(1, len(dims)),
2370
2384
  cj["summary"]),
2371
2385
  duration_s=0.15)
2372
- else:
2373
- lens = _critic_lens(critics, agent)
2374
- res = _run_step(run_id, role, modelhub.bind_agent(agent, difficulty),
2375
- crit_prompt_for(
2376
- text,
2377
- note=("小说第 %d 章" % i) + (
2378
- "|你的专属评审视角:%s(其他评审会覆盖其余视角,"
2379
- "请深挖你的镜头,但所有维度仍需打分)" % lens)
2380
- if lens else "",
2381
- event_check=event_check) + note_extra,
2382
- workdir, readonly=True, ev=ev,
2383
- resume=sids.get(agent["id"]))
2384
- cj = _critique_json(res, dims)
2385
- if cj.get("scores"):
2386
- scored += 1
2387
- # 逐项目标审稿 + 资源账本:解析评审回复里的标记块(借鉴
2388
- # AI-Novel-Writer 逐项核对/角色资源账本)。多评审取第一份
2389
- # 非空即可,聚合时已在前两处赋值处去重。
2390
- evl = _parse_tagged_lines(res.get("text"), "event_check")
2391
- if evl and not ev_check_lines[0]:
2392
- ev_check_lines[0] = evl
2393
- ledger_lines.extend(_parse_tagged_lines(res.get("text"), "ledger"))
2386
+ results[idx] = {"agent": agent, "cj": cj, "evl": [],
2387
+ "ledger": [], "sid": None}
2388
+ return
2389
+ lens = _critic_lens(critics, agent)
2390
+ res = _run_step(run_id, role, bound[idx],
2391
+ crit_prompt_for(
2392
+ text,
2393
+ note=("小说第 %d 章" % i) + (
2394
+ "|你的专属评审视角:%s(其他评审会覆盖其余视角,"
2395
+ "请深挖你的镜头,但所有维度仍需打分)" % lens)
2396
+ if lens else "",
2397
+ event_check=event_check) + note_extra,
2398
+ workdir, readonly=True, ev=ev,
2399
+ resume=sids.get(agent["id"]))
2400
+ results[idx] = {
2401
+ "agent": agent, "cj": _critique_json(res, dims),
2402
+ # 逐项目标审稿 + 资源账本:标记块先各存各的,join 后按序合并
2403
+ "evl": _parse_tagged_lines(res.get("text"), "event_check"),
2404
+ "ledger": _parse_tagged_lines(res.get("text"), "ledger"),
2394
2405
  # §07 T1.1:记录该评审的会话 id(第 2 轮复用)
2395
- csid = _resume_sid(agent, res.get("sid"))
2396
- if csid:
2397
- sids[agent["id"]] = csid
2406
+ "sid": _resume_sid(agent, res.get("sid")),
2407
+ }
2408
+
2409
+ threads = []
2410
+ for idx, agent in enumerate(critics):
2411
+ th = threading.Thread(target=_critique_one, args=(idx, agent),
2412
+ name="crit-%s-c%d-%d" % (run_id, i, idx), daemon=True)
2413
+ threads.append(th)
2414
+ th.start()
2415
+ for th in threads:
2416
+ th.join(3000)
2417
+ _check_cancel(ev)
2418
+
2419
+ # join 后按原顺序合并(确定性:与串行版结果一致)
2420
+ for idx in range(len(critics)):
2421
+ r = results.get(idx)
2422
+ if r is None:
2423
+ continue # 该评审线程超时未回,按「没出分」处理
2424
+ if "error" in r:
2425
+ raise r["error"] # 还原串行版的异常上抛语义
2426
+ agent, cj = r["agent"], r["cj"]
2427
+ if cj.get("scores"):
2428
+ scored += 1
2429
+ if r["evl"] and not ev_check_lines[0]:
2430
+ ev_check_lines[0] = r["evl"]
2431
+ ledger_lines.extend(r["ledger"])
2432
+ if r["sid"]:
2433
+ sids[agent["id"]] = r["sid"]
2398
2434
  cj_map[agent["id"]] = cj
2399
2435
  issues_all.extend({"chapter": i, **it} for it in (cj.get("issues") or [])[:6])
2400
- _check_cancel(ev)
2401
2436
 
2402
2437
  # 评审者级 fallback:名单内评审全挂(网关抖动/CLI 故障)时,
2403
2438
  # 从其它已启用真实智能体补位至多 2 个(排除 mock 与已试过的),
@@ -2512,6 +2547,8 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2512
2547
  chapter_reason = route.get("author", "")
2513
2548
  last_attempt_impl = impl
2514
2549
  last_attempt_reason = chapter_reason
2550
+ prev_prompt = None # 上一次实际下发的提示词
2551
+ prev_timed_out = False # 上一次是否「超时/停滞」收场
2515
2552
  for draft_attempt in range(3):
2516
2553
  if draft_attempt:
2517
2554
  # 30s / 60s 退避;ev.wait 睡等可被取消即刻唤醒
@@ -2527,11 +2564,20 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2527
2564
  # 30KB 对讯飞必挂)
2528
2565
  sk2, bible2, _note = _shrink_context_block(sk_block, bible, budget=12000)
2529
2566
  use_prompt = prompt.replace(sk_block, sk2).replace(bible, bible2)
2567
+ # 超时重试必须有新变量才值得做(2026-09-22):提示词与上次逐字节
2568
+ # 相同、上次又是超时/停滞收场时,重试只是把同一个超时再烧一遍
2569
+ # (2400s × N)。此时直接跳出交替换将——换 CLI/模型才是新机会。
2570
+ # 注意保留「缩上下文重试」:提示词真的变小了(缩块生效)就照试,
2571
+ # 那是针对「提示词过大挂起」的有效降级。
2572
+ if prev_timed_out and use_prompt == prev_prompt:
2573
+ break
2530
2574
  res = _run_step(run_id, "draft-c%d" % i, modelhub.bind_agent(impl, difficulty), use_prompt,
2531
2575
  step_wd, readonly=False, ev=ev, timeout=2400,
2532
2576
  resume=resume_ctx["session"] if resume_ctx else None,
2533
2577
  images=_task_images(task, workdir),
2534
2578
  note=("起草重试 %d/2(网关限流退避)" % draft_attempt) if draft_attempt else "")
2579
+ prev_prompt = use_prompt
2580
+ prev_timed_out = bool((res.get("raw") or {}).get("timed_out"))
2535
2581
  good, txt = _chapter_state()
2536
2582
  if good:
2537
2583
  break
@@ -2852,9 +2898,23 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2852
2898
  global_issues = []
2853
2899
 
2854
2900
  def run_global_round(agent_list):
2855
- """一轮全局评审:返回 (出分评审数, 按维累计分)。失败/不可解析不得当成低分计入。"""
2901
+ """一轮全局评审:返回 (出分评审数, 按维累计分)。失败/不可解析不得当成低分计入。
2902
+
2903
+ 多评审并发(2026-09-22,同章级评审并发):各自读同一份全书文本、互不依赖,
2904
+ 串行只是把等待时间叠起来。共享结构仍在 join 后按原顺序合并,结果与串行一致。"""
2856
2905
  gmeans_acc, scored = {}, 0
2857
- for agent in agent_list:
2906
+ results = {}
2907
+ # 绑定解析提前到主线程(线程内不做会写盘的 bind_agent)
2908
+ bound = [modelhub.bind_agent(a, difficulty) for a in agent_list]
2909
+
2910
+ def _global_one(idx, agent):
2911
+ """异常不吞:记进 results 由主线程按原顺序重抛(保留串行语义)。"""
2912
+ try:
2913
+ _global_one_inner(idx, agent)
2914
+ except BaseException as exc: # noqa: BLE001(含 Cancelled)
2915
+ results[idx] = {"error": exc}
2916
+
2917
+ def _global_one_inner(idx, agent):
2858
2918
  if agent.get("mode") == "mock":
2859
2919
  step, _ = store.add_step(run_id, "global-critique", agent["id"],
2860
2920
  agent.get("label"))
@@ -2863,25 +2923,41 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2863
2923
  "issues": [], "summary": "(mock)全书结构完整,达到可签约水平"}
2864
2924
  store.finish_step(run_id, step["n"], "done", summary="均分 8.0:(mock)全书达标",
2865
2925
  duration_s=0.15)
2926
+ results[idx] = gj
2927
+ return
2928
+ gtpl = SERIAL_GLOBAL_PROMPT
2929
+ if bible:
2930
+ gtpl = gtpl.replace("## 全书目标", bible + "\n\n## 全书目标", 1)
2931
+ res = _run_step(run_id, "global-critique", bound[idx],
2932
+ (gtpl.replace("__DIMKEYS__", dimkey)
2933
+ .replace("__GOAL__", task["goal"])
2934
+ .replace("__MANUSCRIPT__", full_text[:60000])),
2935
+ workdir, readonly=True, ev=ev, timeout=2400)
2936
+ results[idx] = _critique_json(res, dims)
2937
+
2938
+ threads = []
2939
+ for idx, agent in enumerate(agent_list):
2940
+ th = threading.Thread(target=_global_one, args=(idx, agent),
2941
+ name="gcrit-%s-%d" % (run_id, idx), daemon=True)
2942
+ threads.append(th)
2943
+ th.start()
2944
+ for th in threads:
2945
+ th.join(3000)
2946
+ _check_cancel(ev)
2947
+
2948
+ for idx in range(len(agent_list)):
2949
+ gj = results.get(idx)
2950
+ if gj is None:
2951
+ continue
2952
+ if "error" in gj:
2953
+ raise gj["error"] # 还原串行版的异常上抛语义
2954
+ if gj.get("scores"):
2866
2955
  scored += 1
2867
- else:
2868
- gtpl = SERIAL_GLOBAL_PROMPT
2869
- if bible:
2870
- gtpl = gtpl.replace("## 全书目标", bible + "\n\n## 全书目标", 1)
2871
- res = _run_step(run_id, "global-critique", modelhub.bind_agent(agent, difficulty),
2872
- (gtpl.replace("__DIMKEYS__", dimkey)
2873
- .replace("__GOAL__", task["goal"])
2874
- .replace("__MANUSCRIPT__", full_text[:60000])),
2875
- workdir, readonly=True, ev=ev, timeout=2400)
2876
- gj = _critique_json(res, dims)
2877
- if gj.get("scores"):
2878
- scored += 1
2879
2956
  global_issues.extend({"chapter": "全书", **it} for it in (gj.get("issues") or [])[:8])
2880
2957
  for d in dims:
2881
2958
  v = gj.get("scores", {}).get(d)
2882
2959
  if v is not None:
2883
2960
  gmeans_acc.setdefault(d, []).append(float(v))
2884
- _check_cancel(ev)
2885
2961
  return scored, gmeans_acc
2886
2962
 
2887
2963
  gscored, gmeans_acc = run_global_round(critics)
@@ -3376,9 +3452,7 @@ def _run_content_review(run, task, agents, ev, stats, mode):
3376
3452
  crit_prompt += "\n\n## 原始任务背景与附件参考\n" + task["context"]
3377
3453
  # AI 味确定性检测(借鉴 oh-story 去AI味):客观参考线随评审下发,
3378
3454
  # 命中才追加——评审官结合上下文判断是否真问题,脚本不直接扣分
3379
- _aiflavor_line = aiflavor.report_line(manuscript)
3380
- if _aiflavor_line:
3381
- crit_prompt += "\n\n## 确定性检测结果(供评审参考)\n" + _aiflavor_line
3455
+ crit_prompt = aiflavor.inject_into_prompt(crit_prompt, manuscript, task.get("type"))
3382
3456
  for agent in critics:
3383
3457
  role = "critique-r%d" % r
3384
3458
  if agent.get("mode") == "mock":