codebee 0.1.52 → 0.1.53

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,12 @@ 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.53(2026-09-23)
27
+
28
+ - 换将救活两类「假终态」:上游内容拒答("can't help with this" 一类 refusal)与真超时,现在都会换绑定链上下一个候选再试——此前拒答直接判死整轮任务(链上异上游候选从未被尝试),超时能不能换全凭输出尾段碰巧带 "timeout" 字样;真实案:夜间知识库整理任务被网关 gemini 拒答后整 run 白烧
29
+ - 同一慢上游连烧止损:链上候选连续两次相同超时即收手(换将=换壳不换命),最坏从 N×单步超时压到 2×;取消显式列为终态绝不降级
30
+ - 网关模型漂移可见:请求 A 模型被网关实际映射成 B 模型时(真实案:请求 deepseek-v4.1-flash 实服 gemini-3.8-flash),失败信息与步骤日志明确标注「请求 X,上游实服 Y」,排查看对病根
31
+
26
32
  ## v0.1.52(2026-09-22)
27
33
 
28
34
  - 手动指定执行者优先级全面收口:用户点名的 CLI 压过一切自动选择——路由不换将、旁观不抢占、绑定不覆盖;「手动指定执行者」作为一等偏好随任务走
package/README.md CHANGED
@@ -21,10 +21,11 @@ Kimi Code、MiMo Code、Grok Build、Pi、DeepSeek Harness……),提供
21
21
  不会把你的任务内容交给任何第三方。
22
22
 
23
23
  <!-- relnotes:start -->
24
- ### 最新版更新内容(v0.1.52)
24
+ ### 最新版更新内容(v0.1.53)
25
25
 
26
- - 手动指定执行者为一等偏好:用户点名的 CLI 压过自动路由,全程不换将、不被抢占
27
- - 管理页执行者下拉与新建任务菜单统一;静默挂死看门狗防误杀保留
26
+ - 换将救活两类「假终态」:上游内容拒答(refusal)与真超时都会换链上下一个候选再试,不再整轮白烧
27
+ - 同一慢上游连烧止损:连续两次相同超时即收手;取消绝不降级
28
+ - 网关模型漂移可见:请求与实际服务的模型不一致时,失败信息明确标注
28
29
  <!-- relnotes:end -->
29
30
 
30
31
  ---
@@ -65,7 +65,7 @@ _DEFAULTS = {"id": "", "name": "", "prompt": "", "workdir": "", "kind": "", "tim
65
65
  "mode": "auto", "thinking": "standard",
66
66
  "direct_provider_id": "", "direct_model": "",
67
67
  "enabled": False, "created_at": "", "last_run": "", "next_run": "",
68
- "run_count": 0, "last_status": ""}
68
+ "run_count": 0, "last_status": "", "last_run_id": ""}
69
69
 
70
70
  # 允许通过 update() 修改的字段(id/created_at/run_count 等运行痕迹不可改)
71
71
  _UPDATABLE = ("name", "prompt", "workdir", "kind", "time", "interval_hours",
@@ -386,8 +386,9 @@ def _fire(snapshot, now):
386
386
  """触发一个到期任务:拉起运行并推进 last_run/next_run/run_count/last_status。
387
387
  once 触发完自动停用。拉起失败只记 error,next_run 照常推进(下个周期重试)。"""
388
388
  status = "started"
389
+ run_id = ""
389
390
  try:
390
- _launch_run(snapshot)
391
+ run_id = _launch_run(snapshot) or ""
391
392
  except Exception as e:
392
393
  log.warning("automation: 定时触发 %s 失败: %r", snapshot.get("id"), e)
393
394
  status = "error"
@@ -398,6 +399,8 @@ def _fire(snapshot, now):
398
399
  cur["last_run"] = _fmt_dt(now)
399
400
  cur["run_count"] = int(cur.get("run_count") or 0) + 1
400
401
  cur["last_status"] = status
402
+ if run_id:
403
+ cur["last_run_id"] = run_id
401
404
  if cur.get("kind") == "once":
402
405
  cur["enabled"] = False
403
406
  cur["next_run"] = ""
@@ -408,6 +411,49 @@ def _fire(snapshot, now):
408
411
 
409
412
  # ---------------------------------------------------------------- 调度线程
410
413
 
414
+ def _default_run_status(rid):
415
+ """按 run_id 查运行状态(终态回写用);查不到返回 None。"""
416
+ from . import store
417
+ try:
418
+ r = store.get_run(rid)
419
+ except Exception:
420
+ return None
421
+ return (r or {}).get("status") or None
422
+
423
+
424
+ # 测试可替换的查态钩子
425
+ _RUN_STATUS = _default_run_status
426
+
427
+ _RUN_TERMINAL = ("done", "failed", "cancelled")
428
+
429
+
430
+ def _reconcile_statuses():
431
+ """把 last_status 停在 started 的任务对账到真实运行终态。此前触发后永不
432
+ 回写,任务卡片永远显示「运行中」(2026-09-23 实案:三个任务全部如此)。
433
+ store 查不到或还没到终态就保持原样,下一轮 tick 再看。"""
434
+ with _LOCK:
435
+ pending = {tid: t.get("last_run_id") for tid, t in _TASKS.items()
436
+ if t.get("last_run_id") and t.get("last_status") == "started"}
437
+ if not pending:
438
+ return
439
+ settled = {}
440
+ for tid, rid in pending.items():
441
+ st = _RUN_STATUS(rid)
442
+ if st in _RUN_TERMINAL:
443
+ settled[tid] = st
444
+ if not settled:
445
+ return
446
+ with _LOCK:
447
+ dirty = False
448
+ for tid, st in settled.items():
449
+ t = _TASKS.get(tid)
450
+ if (t and t.get("last_run_id") == pending[tid]
451
+ and t.get("last_status") == "started"):
452
+ t["last_status"] = st
453
+ dirty = True
454
+ if dirty:
455
+ _save_locked()
456
+
411
457
  def _tick():
412
458
  """扫描一遍到期任务并逐个触发。单任务异常只跳过该条,绝不上抛。"""
413
459
  now = datetime.now()
@@ -429,6 +475,11 @@ def _tick():
429
475
  _fire(dict(t), now)
430
476
  except Exception:
431
477
  log.exception("automation: tick 处理任务 %s 异常,已跳过", tid)
478
+ # 运行终态对账:last_status 停在 started 的任务查一次真实终态(见函数注)。
479
+ try:
480
+ _reconcile_statuses()
481
+ except Exception:
482
+ log.debug("automation: 运行终态对账跳过", exc_info=True)
432
483
  # 定时发布联动(P2.5):任务级 auto_publish 标记到点触发批量发布。
433
484
  # 发布逻辑全在 publish/auto(护栏/幂等/单飞),这里只当调度宿主。
434
485
  try:
@@ -581,10 +632,11 @@ def run_now(tid):
581
632
  now = datetime.now()
582
633
  run_id = ""
583
634
  status = "started"
635
+ run_id = ""
584
636
  try:
585
637
  with _LOCK:
586
638
  snapshot = dict(_TASKS[tid])
587
- run_id = _launch_run(snapshot)
639
+ run_id = _launch_run(snapshot) or ""
588
640
  except Exception as e:
589
641
  log.warning("automation: 手动触发 %s 失败: %r", tid, e)
590
642
  status = "error"
@@ -601,6 +653,8 @@ def run_now(tid):
601
653
  t["last_run"] = _fmt_dt(now)
602
654
  t["run_count"] = int(t.get("run_count") or 0) + 1
603
655
  t["last_status"] = status
656
+ if run_id:
657
+ t["last_run_id"] = run_id
604
658
  _save_locked()
605
659
  return dict(t), run_id
606
660
 
@@ -0,0 +1,105 @@
1
+ # -*- coding: utf-8 -*-
2
+ """多线剧情推演(inkos 借鉴 2026-09-23):写章前一次调用生成 2-3 条分支
3
+ 节拍计划,自带推荐择优,选中分支注入本章 BEATS;全部分支追加审计文件
4
+ .codebee/branch-plans.md 供回看。计划级赛马——比 prose 级 Best-of-N 省
5
+ 一个数量级的 token。"""
6
+ from __future__ import annotations
7
+
8
+ import logging
9
+ import os
10
+
11
+ log = logging.getLogger(__name__)
12
+
13
+ BRANCH_PLAN_PROMPT = """你是网文剧情策划。基于全书设定与上一章结尾,为下一章生成 __N__ 条**彼此方向不同**的剧情分支计划。
14
+
15
+ 要求:
16
+ - 每条分支给出:本章节拍(50-120 字:事件/冲突/推进)+ 章末钩子(一句话)+ 一句取舍理由(为什么选/不选它)
17
+ - 分支之间要有真实的方向差异(不同冲突来源/不同人物决定/不同信息揭示),不是同一件事换措辞
18
+ - 你是策划也是评委:综合大纲意图、前情、人物动机,推荐其中最有力的一条
19
+
20
+ 只输出一个 ```json 代码块:
21
+ {"branches": [{"beats": "…", "hook": "…", "why": "…"}, …], "pick": 0}
22
+ pick = 推荐分支的下标(0 起)。
23
+
24
+ ## 全书目标
25
+ __GOAL__
26
+
27
+ ## 大纲上下文(本章前后)
28
+ __OUTLINE__
29
+
30
+ ## 上一章结尾
31
+ __PREV__
32
+ """
33
+
34
+
35
+ def plan_branches(run_id, task, i, goal, outline_txt, prev, n, *, log_path=None):
36
+ """生成并择优一条分支计划。返回 (beats, hook) 或 None(失败/未启用走原大纲)。
37
+
38
+ 编排者一次调用产 N 条+自荐 pick;失败静默回落原大纲节拍(推演是增强
39
+ 不是闸门)。全部分支连同 pick 追加 .codebee/branch-plans.md 审计。"""
40
+ if n < 2:
41
+ return None
42
+ from . import modelhub, runner
43
+ if n < 2:
44
+ return None
45
+ orch = modelhub.resolve_orchestrator()
46
+ if not orch:
47
+ return None
48
+ prov, model = orch
49
+ prompt = (BRANCH_PLAN_PROMPT
50
+ .replace("__N__", str(n))
51
+ .replace("__GOAL__", goal[:600])
52
+ .replace("__OUTLINE__", outline_txt[:3000])
53
+ .replace("__PREV__", (prev or "(开篇)")[-1200:]))
54
+ res = modelhub.chat(prov["id"], model, prompt, max_tokens=4000, timeout=300)
55
+ if not res.get("ok"):
56
+ return None
57
+ data = runner.extract_json(res.get("text") or "")
58
+ branches = (data or {}).get("branches") if isinstance(data, dict) else None
59
+ if not isinstance(branches, list):
60
+ return None
61
+ usable = [b for b in branches
62
+ if isinstance(b, dict) and str(b.get("beats") or "").strip()]
63
+ if len(usable) < 2:
64
+ return None
65
+ try:
66
+ pick = int(data.get("pick") or 0)
67
+ except (TypeError, ValueError):
68
+ pick = 0
69
+ pick = max(0, min(pick, len(usable) - 1))
70
+ chosen = usable[pick]
71
+ try:
72
+ from . import store as _store
73
+ wd = _store.run_workdir(run_id) or ""
74
+ except Exception:
75
+ wd = ""
76
+ _append_audit(wd, i, usable, pick)
77
+ return (str(chosen["beats"]).strip()[:300],
78
+ str(chosen.get("hook") or "").strip()[:80] or None)
79
+
80
+
81
+ def _append_audit(workdir, chapter, branches, pick):
82
+ """全部分支追加 .codebee/branch-plans.md(失败静默——审计是增强)。"""
83
+ if not workdir:
84
+ return
85
+ try:
86
+ p = os.path.abspath(os.path.join(str(workdir), ".codebee",
87
+ "branch-plans.md"))
88
+ root = os.path.abspath(str(workdir))
89
+ if root not in p.split(os.sep)[:-len([".codebee", "branch-plans.md"])]:
90
+ if not p.startswith(root + os.sep):
91
+ return
92
+ os.makedirs(os.path.dirname(p), exist_ok=True)
93
+ with open(p, "a", encoding="utf-8") as f:
94
+ if f.tell() == 0:
95
+ f.write("# 多线剧情推演(每章分支计划与择优,供回看)\n\n")
96
+ f.write("## 第 %d 章\n" % chapter)
97
+ for k, b in enumerate(branches):
98
+ mark = "✅ " if k == pick else ""
99
+ f.write("- %s%s(钩子:%s)——%s\n" % (
100
+ mark, str(b.get("beats") or "")[:160],
101
+ str(b.get("hook") or "")[:60],
102
+ str(b.get("why") or "")[:120]))
103
+ f.write("\n")
104
+ except Exception:
105
+ pass
@@ -19,7 +19,7 @@ import re
19
19
  import threading
20
20
  import time
21
21
 
22
- from . import aiflavor, attachments, catalog, dispatch_log, history, hooks, jobs, knowledge, manager, modelhub, mocks, paihang, planner, registry, router, runner, skills, store, task_compile, usage, volumes
22
+ from . import aiflavor, attachments, branching, catalog, dispatch_log, history, hooks, jobs, knowledge, manager, modelhub, mocks, paihang, planner, registry, router, runner, skills, store, task_compile, usage, volumes
23
23
  from . import builtin_agent
24
24
  from . import diagnostics
25
25
  from . import paths as paths_mod
@@ -2556,6 +2556,20 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2556
2556
  scope = ("本章 = 大纲第 %d 章" % i) if start == 1 else (
2557
2557
  "本批为第 %d–%d 章,下列按全书章号列出各章要点" % (start, end))
2558
2558
 
2559
+ # 多线剧情推演(inkos 借鉴 2026-09-23):serial.branches>=2 时
2560
+ # 写章前一次编排者调用生成 N 条分支节拍、自荐择优,选中分支替换
2561
+ # 本章 BEATS/HOOK(计划级赛马,省 prose 级开销);失败静默走原大纲。
2562
+ branch_beats = branch_hook = None
2563
+ try:
2564
+ n_branch = max(1, min(3, int(serial.get("branches") or 1)))
2565
+ if n_branch >= 2:
2566
+ got = branching.plan_branches(
2567
+ run_id, task, i, task["goal"], outline_txt, prev, n_branch)
2568
+ if got:
2569
+ branch_beats, branch_hook = got
2570
+ except Exception:
2571
+ branch_beats = None
2572
+
2559
2573
  def _draft_prompt(vfile):
2560
2574
  return (SERIAL_CHAPTER_PROMPT
2561
2575
  .replace("__SKILLS__", sk_block)
@@ -2567,8 +2581,8 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2567
2581
  .replace("__PREV__", prev)
2568
2582
  .replace("__LEDGER__", ledger_txt)
2569
2583
  .replace("__TITLE__", ch["title"])
2570
- .replace("__BEATS__", ch["beats"] or "按大纲推进")
2571
- .replace("__HOOK__", ch.get("hook") or "留下悬念")
2584
+ .replace("__BEATS__", branch_beats or ch["beats"] or "按大纲推进")
2585
+ .replace("__HOOK__", branch_hook or ch.get("hook") or "留下悬念")
2572
2586
  .replace("__WORDS__", str(wpc)))
2573
2587
 
2574
2588
  n_variants = max(1, min(3, int(serial.get("variants") or 1)))
@@ -823,6 +823,10 @@ def _parse_claude_json(stdout):
823
823
  int(u.get("cache_creation_input_tokens") or 0)
824
824
  usage = {"input": inp, "output": out, "cached": cached, "reasoning": 0,
825
825
  "total": inp + out + cached}
826
+ # 网关实服模型:请求 deepseek-v4.1-flash 却被网关映射成 gemini-3.8-flash
827
+ # 之类(2026-09-23 夜班实案)——用量统计记错名、失败排查看错病根,都
828
+ # 靠 result 行的 modelUsage 对出来
829
+ served = sorted({str(k) for k in ((data.get("modelUsage") or {}) or {})})
826
830
  return {
827
831
  "text": data.get("result") or "",
828
832
  "cost_usd": data.get("total_cost_usd") or 0.0,
@@ -831,6 +835,7 @@ def _parse_claude_json(stdout):
831
835
  "is_error": bool(data.get("is_error")),
832
836
  # §07 T1.1:claude -p 返回本次会话 id,供 --resume 复用
833
837
  "sid": str(data.get("session_id") or ""),
838
+ "served_models": served,
834
839
  }
835
840
 
836
841
 
@@ -898,6 +903,55 @@ RATE_LIMIT_GRACE_S = 45 # 宽限等待时长:限流窗口通常按分钟
898
903
  RATE_LIMIT_GRACE_N = 1 # 每步宽限次数:只兜一次,防限流长拖整轮时间
899
904
 
900
905
 
906
+ # 上游内容拒答(refusal):claude CLI 把 refusal stop_reason 渲染成
907
+ # "API Error: <model> can't help with this. Start a new session to continue."
908
+ # 这是网关后面那个模型对本次会话内容的过滤决定,不是任务终态——换一家
909
+ # 异上游候选常常就能跑通。2026-09-23 夜班实案:doc 起草被 gemini 拒答后
910
+ # 判成非瞬态整链早死,链上云知声候选根本没被尝试。
911
+ _REFUSAL = ("can't help with this", "couldn't help with this",
912
+ 'stop_reason":"refusal"', "content policy", "content filtering")
913
+
914
+
915
+ def _refusal_error(err):
916
+ err = (err or "").lower()
917
+ return any(k in err for k in _REFUSAL)
918
+
919
+
920
+ def _fail_sig(err):
921
+ """错误串的病根特征(剥掉输出尾段/数字/会话 id 等易变片段),供换将
922
+ 循环做同因连撞止损。与 jobs._err_signature 同思路的轻量版,避免 core
923
+ 模块环。超时错误的尾段是每次被杀时刻的输出快照,必然不同,签名只取
924
+ 头部(;分隔符之前)。"""
925
+ s = str(err or "")
926
+ for sep in (";stderr/stdout", ";日志错误行"):
927
+ i = s.find(sep)
928
+ if i > 0:
929
+ s = s[:i]
930
+ break
931
+ s = re.sub(r"[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}",
932
+ "<sid>", s)
933
+ s = re.sub(r"\d+", "N", s)
934
+ return re.sub(r"\s+", " ", s).strip()[:200]
935
+
936
+
937
+ def _claude_drift_note(att_model, parsed):
938
+ """请求模型 vs 网关实服模型不一致时的注记;一致或无从判断返回空。"""
939
+ served = (parsed or {}).get("served_models") or []
940
+ if not served or not att_model or served == [att_model]:
941
+ return ""
942
+ return "[模型漂移] 请求 %s,上游实服 %s" % (att_model, ",".join(served))
943
+
944
+
945
+ def _log_note(log_path, text):
946
+ if not log_path or not text:
947
+ return
948
+ try:
949
+ with open(log_path, "a", encoding="utf-8") as fh:
950
+ fh.write("\n[%s]\n" % text)
951
+ except OSError:
952
+ pass
953
+
954
+
901
955
  def _rate_limited(err):
902
956
  err = (err or "").lower()
903
957
  return any(k in err for k in _RATE_LIMIT)
@@ -1255,7 +1309,8 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
1255
1309
  防模型纯口头谎报「环境受限」蒙混过关(2026-09-17 mo-so 实测)。
1256
1310
 
1257
1311
  模型尝试顺序来自 _resolve_attempts:跨厂商链(每条独立 env)或
1258
- 主模型 + 降级备选;瞬态错误才换下一条,取消/超时/解析失败不降级。
1312
+ 主模型 + 降级备选;瞬态/配额/超时/上游拒答换下一条,取消与解析
1313
+ 失败不降级;超时连续两次同签名即止损(同一慢上游换壳无用)。
1259
1314
 
1260
1315
  5E:catalog `orch.timeout_ms`(毫秒)优先于 caller 传入的 timeout。
1261
1316
  """
@@ -1308,6 +1363,7 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
1308
1363
  attempts = _resolve_attempts(agent)
1309
1364
  out = None
1310
1365
  grace_left = RATE_LIMIT_GRACE_N # 链尾限流宽限预算:整链撞 429 时原地等一个窗口再试一次
1366
+ prev_sig = "" # 上一次尝试的失败签名:同因连撞止损用
1311
1367
  for ai, att in enumerate(attempts):
1312
1368
  env = dict(base_env)
1313
1369
  env.update(att["env"])
@@ -1370,6 +1426,10 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
1370
1426
  parsed_err = _parse_claude_json(res["stdout"] or "")
1371
1427
  if parsed_err and parsed_err.get("is_error") and parsed_err.get("text"):
1372
1428
  out["error"] = "claude 返回 is_error: " + parsed_err["text"][:500]
1429
+ drift = _claude_drift_note(att.get("model"), parsed_err)
1430
+ if drift:
1431
+ out["error"] += ";" + drift
1432
+ _log_note(log_path, drift)
1373
1433
  if kind == "codex" and att.get("provider_id"):
1374
1434
  el = out["error"].lower()
1375
1435
  if (("wire_api" in el and "no longer supported" in el)
@@ -1442,9 +1502,14 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
1442
1502
  out["tokens"] = parsed["tokens"]
1443
1503
  out["usage"] = parsed["usage"]
1444
1504
  out["sid"] = parsed.get("sid") or "" # §07 T1.1
1505
+ drift = _claude_drift_note(att.get("model"), parsed)
1506
+ if drift:
1507
+ _log_note(log_path, drift)
1445
1508
  if parsed["is_error"]:
1446
1509
  out["ok"] = False
1447
1510
  out["error"] = "claude 返回 is_error: " + parsed["text"][:500]
1511
+ if drift:
1512
+ out["error"] += ";" + drift
1448
1513
  out["error_code"] = _classify_failure(res, kind="claude", parsed=parsed)
1449
1514
  break
1450
1515
  if not out["text"] and attempt == 0:
@@ -1471,10 +1536,30 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
1471
1536
  attempts.append(att)
1472
1537
  continue
1473
1538
  return out
1474
- # 瞬态网络错误 → 换下一条;欠费/配额耗尽同样换(可能是同厂商的备用 KEY,
1475
- # 也可能是另一家厂商)——账单断了死磕同一把 KEY 没有任何意义。
1476
- if not (_transient_error(out.get("error")) or _quota_error(out.get("error"))):
1477
- return out # 非瞬态(取消/超时/解析失败)不降级
1539
+ # 换将闸门:什么失败值得烧链上下一个候选。
1540
+ # · 瞬态/配额(文本特征,含 CLI 内部 api_retry 的 429/5xx 字样)照旧;
1541
+ # · 超时:显式可换(看 res 标志位)——慢上游换快候选可能救回。
1542
+ # 2026-09-23 夜班实案:旧逻辑里超时能否换将全赌输出尾部碰巧含有
1543
+ # "timeout" 字样(当时是步骤里读到的代码 timeout=900 撞的表)。
1544
+ # · 上游内容拒答(refusal):是那家模型的过滤决定,不是任务终态,
1545
+ # 换异上游候选常常就活;判死则整 run 白烧(同案:gemini 拒答后
1546
+ # 链上云知声候选从未被尝试)。
1547
+ raw = out.get("raw") or {}
1548
+ if (raw.get("cancelled") # 取消是用户意志,绝不降级(尾段可能带瞬态字样)
1549
+ or not (_transient_error(out.get("error")) or _quota_error(out.get("error"))
1550
+ or _refusal_error(out.get("error"))
1551
+ or raw.get("timed_out"))):
1552
+ return out # 取消/解析失败等真终态不降级
1553
+ # 超时同因连撞止损:头部签名(超时/输出停滞)连续相同就收手——
1554
+ # 链上候选绑同一上游时换将=换壳不换命(2026-09-22 429 集群教训;
1555
+ # 2026-09-23 夜班实案同一慢上游连烧 3×20 分钟全超时零产出),最坏
1556
+ # 情况从 N×步超时压到 2×。其余瞬态/拒答仍按链长全部尝试:各家病根
1557
+ # 不同,每个候选都值得一次机会。
1558
+ if raw.get("timed_out"):
1559
+ sig = _fail_sig(out.get("error"))
1560
+ if sig and sig == prev_sig:
1561
+ return out
1562
+ prev_sig = sig
1478
1563
  return out
1479
1564
 
1480
1565
 
package/app/core/store.py CHANGED
@@ -223,6 +223,15 @@ def create_task(payload):
223
223
  v = 1
224
224
  if v > 1:
225
225
  s["variants"] = v
226
+ # 多线剧情推演(inkos 借鉴 2026-09-23):2-3 = 写章前生成
227
+ # N 条分支节拍计划择优注入(计划级赛马,省 prose 级开销);
228
+ # 越界钳到 3(与 variants 同口径:上限仍可用,1=不启用不落键)
229
+ try:
230
+ br = max(1, min(3, int(serial.get("branches") or 1)))
231
+ except Exception:
232
+ br = 1
233
+ if br > 1:
234
+ s["branches"] = br
226
235
  # 分卷:显式卷表优先(新建任务时用户明确给出的分卷信息),
227
236
  # 其次每卷章数等长切卷。卷边界一律由 volumes 模块从「章号」
228
237
  # 确定性推导——续写批次沿用同一份卷表/每卷章数即自动接上同一卷,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codebee",
3
- "version": "0.1.52",
3
+ "version": "0.1.53",
4
4
  "description": "CodeBee · 多智能体编排台 — 统一编排本机 AI 编码 CLI(Codex / Claude Code / QwenCode / OpenCode / Aider…):目标→拆解→路由→执行→验证→跨厂商评审→修复→报告。纯 Python 标准库实现,本地 Web UI。",
5
5
  "license": "MIT",
6
6
  "bin": {