codebee 0.1.52 → 0.1.54

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.
@@ -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, chaptersafe, 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
@@ -331,7 +331,8 @@ def _run_step(run_id, role, agent, prompt, workdir, readonly, ev, timeout=runner
331
331
  if dead_binding:
332
332
  note = ((note + ";") if note else "") + "⚠ " + dead_msg
333
333
  step, log_abs = store.add_step(run_id, role, agent["id"],
334
- agent.get("label", agent["id"]), note=note)
334
+ agent.get("label", agent["id"]), note=note,
335
+ model=agent.get("model") or "")
335
336
  start = time.time()
336
337
  if dead_binding:
337
338
  from .error_codes import ErrorCode
@@ -393,7 +394,8 @@ def _run_builtin_step(run_id, role, bi, prompt, workdir, ev, note="", images=Non
393
394
  followups=True 时从回答末尾解析「建议追问」块(直连对话专用协议):
394
395
  剥离出结构化列表落步骤记录,正文保持干净。"""
395
396
  _wait_gate(run_id, ev)
396
- step, log_abs = store.add_step(run_id, role, "builtin", "CodeBee", note=note)
397
+ step, log_abs = store.add_step(run_id, role, "builtin", "CodeBee", note=note,
398
+ model=bi.get("model") or "")
397
399
  start = time.time()
398
400
  agent_pseudo = {"id": "builtin", "label": "CodeBee", "kind": "builtin", "mode": "real",
399
401
  "provider": {"id": bi.get("provider_id") or "",
@@ -940,6 +942,72 @@ def _record_dispatch_completed(run_id, task, result, verify_pass=None, review_pa
940
942
  pass
941
943
 
942
944
 
945
+ def _retry_content_draft_with_cli(run_id, task, agents, impl, difficulty, mode,
946
+ stats, prompt, workdir, step_wd, ev, resume_ctx,
947
+ draft_note, draft_res):
948
+ """Auto content tasks may try another configured CLI after a draft failure.
949
+
950
+ Model/provider failover has already been exhausted inside run_agent. Keep
951
+ manual execution, user cancellation and resumed sessions pinned to the
952
+ user's explicit choice.
953
+ """
954
+ raw = draft_res.get("raw") or {}
955
+ if (mode == "manual" or impl.get("mode") != "real" or resume_ctx
956
+ or raw.get("cancelled") or draft_res.get("error_code") == "CANCELLED"):
957
+ return draft_res, impl, draft_note
958
+
959
+ tried = {impl.get("id")}
960
+ failures = ["%s:%s" % (impl.get("id") or "CLI",
961
+ (draft_res.get("error") or "起草失败")[:180])]
962
+ dead_upstreams = []
963
+ first_upstreams = router.agent_upstreams(impl.get("id"))
964
+ if first_upstreams:
965
+ dead_upstreams.append(first_upstreams)
966
+ current = impl
967
+ last_impl = impl
968
+ last_result = draft_res
969
+ last_note = draft_note
970
+
971
+ while True:
972
+ _check_cancel(ev)
973
+ candidate, reason = router.pick_switch_candidate(
974
+ agents, "implement", task.get("type") or "novel", stats,
975
+ exclude=tried, dead_upstreams=dead_upstreams)
976
+ if candidate is None or candidate.get("id") in tried:
977
+ break
978
+ tried.add(candidate.get("id"))
979
+ if candidate.get("mode") != "real":
980
+ continue
981
+
982
+ why = (last_result.get("error") or "起草失败")[:180]
983
+ switch_note = ("起草自动换 CLI:%s → %s(%s);前序失败:%s" % (
984
+ current.get("id") or "CLI", candidate.get("id") or "CLI",
985
+ reason or "自动路由", why))
986
+ attempt_note = (draft_note + ";" if draft_note else "") + switch_note
987
+ bound = modelhub.bind_agent(candidate, difficulty)
988
+ last_result = _run_step(
989
+ run_id, "draft", bound, prompt, step_wd, readonly=False, ev=ev,
990
+ note=attempt_note, images=_task_images(task, workdir))
991
+ last_impl = candidate
992
+ last_note = attempt_note
993
+ if last_result.get("ok"):
994
+ return last_result, candidate, attempt_note
995
+
996
+ failures.append("%s:%s" % (candidate.get("id") or "CLI",
997
+ (last_result.get("error") or "起草失败")[:180]))
998
+ if (last_result.get("raw") or {}).get("cancelled"):
999
+ break
1000
+ ups = router.agent_upstreams(candidate.get("id"))
1001
+ if ups:
1002
+ dead_upstreams.append(ups)
1003
+ current = candidate
1004
+
1005
+ if last_impl is not impl:
1006
+ last_result = dict(last_result)
1007
+ last_result["error"] = "CLI 自动切换均失败:%s" % ";".join(failures[-4:])
1008
+ return last_result, last_impl, last_note
1009
+
1010
+
943
1011
  def _run_code(run, task, agents, ev, stats, mode):
944
1012
  run_id = run["id"]
945
1013
  workdir = task["workdir"]
@@ -2108,8 +2176,9 @@ def _wc(text):
2108
2176
 
2109
2177
 
2110
2178
  def _write_chapter(workdir, i, text):
2111
- with _chapter_io(workdir, i, "w") as f:
2112
- f.write(text)
2179
+ # 安全落盘委托(inkos 安全章节工作区借鉴):tmp+原子改名,写一半
2180
+ # 崩溃不留半章冒充成稿;路径守卫在 chaptersafe(resolve+parents)
2181
+ chaptersafe.atomic_write_chapter(workdir, i, text)
2113
2182
 
2114
2183
 
2115
2184
  def _all_ge(scores, threshold):
@@ -2556,6 +2625,20 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2556
2625
  scope = ("本章 = 大纲第 %d 章" % i) if start == 1 else (
2557
2626
  "本批为第 %d–%d 章,下列按全书章号列出各章要点" % (start, end))
2558
2627
 
2628
+ # 多线剧情推演(inkos 借鉴 2026-09-23):serial.branches>=2 时
2629
+ # 写章前一次编排者调用生成 N 条分支节拍、自荐择优,选中分支替换
2630
+ # 本章 BEATS/HOOK(计划级赛马,省 prose 级开销);失败静默走原大纲。
2631
+ branch_beats = branch_hook = None
2632
+ try:
2633
+ n_branch = max(1, min(3, int(serial.get("branches") or 1)))
2634
+ if n_branch >= 2:
2635
+ got = branching.plan_branches(
2636
+ run_id, task, i, task["goal"], outline_txt, prev, n_branch)
2637
+ if got:
2638
+ branch_beats, branch_hook = got
2639
+ except Exception:
2640
+ branch_beats = None
2641
+
2559
2642
  def _draft_prompt(vfile):
2560
2643
  return (SERIAL_CHAPTER_PROMPT
2561
2644
  .replace("__SKILLS__", sk_block)
@@ -2567,8 +2650,8 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2567
2650
  .replace("__PREV__", prev)
2568
2651
  .replace("__LEDGER__", ledger_txt)
2569
2652
  .replace("__TITLE__", ch["title"])
2570
- .replace("__BEATS__", ch["beats"] or "按大纲推进")
2571
- .replace("__HOOK__", ch.get("hook") or "留下悬念")
2653
+ .replace("__BEATS__", branch_beats or ch["beats"] or "按大纲推进")
2654
+ .replace("__HOOK__", branch_hook or ch.get("hook") or "留下悬念")
2572
2655
  .replace("__WORDS__", str(wpc)))
2573
2656
 
2574
2657
  n_variants = max(1, min(3, int(serial.get("variants") or 1)))
@@ -3477,6 +3560,27 @@ def _run_content_review(run, task, agents, ev, stats, mode):
3477
3560
  ev=ev, note=draft_note,
3478
3561
  resume=resume_ctx["session"] if resume_ctx else None,
3479
3562
  images=_task_images(task, workdir))
3563
+ if not draft_res["ok"]:
3564
+ draft_res, actual_impl, fallback_note = _retry_content_draft_with_cli(
3565
+ run_id, task, agents, impl, difficulty, mode, stats,
3566
+ _draft_prompt_for(ms_name), workdir, step_wd, ev, resume_ctx,
3567
+ draft_note, draft_res)
3568
+ if actual_impl.get("id") != impl.get("id"):
3569
+ impl = actual_impl
3570
+ draft_note = fallback_note
3571
+ route["author"] = fallback_note
3572
+ if mode != "manual":
3573
+ critic_pool, route["critics"] = router.pick_critics(
3574
+ agents, task.get("type") or "novel", stats, impl=impl)
3575
+ critics = critic_pool[:workflow["reviewers"]]
3576
+ plan = planner.make_novel_plan(_steered_task(run_id, task), impl, critics)
3577
+ store.update_run(run_id, route=route, plan=plan)
3578
+ _record_actual_route(
3579
+ run_id, task, agents, stats, impl, critics=critics,
3580
+ implement_reason=route.get("author", ""),
3581
+ review_reason=route.get("critics", ""))
3582
+ if draft_res.get("raw", {}).get("cancelled"):
3583
+ _check_cancel(ev)
3480
3584
  if not draft_res["ok"]:
3481
3585
  store.update_run(run_id, expected_status="running", status="failed",
3482
3586
  error="起草失败: %s" % draft_res.get("error"),
@@ -28,7 +28,8 @@ from .error_codes import ErrorCode
28
28
  # 非 Windows 必须置 0:POSIX 的 Popen 对非零 creationflags 直接抛 ValueError,
29
29
  # 置 0 则两边通用(remote.py 同款守卫)。
30
30
  CREATE_NO_WINDOW = 0x08000000 if os.name == "nt" else 0
31
- DEFAULT_TIMEOUT = 1200 # 单步 20 分钟
31
+ DEFAULT_TIMEOUT = 1200 # 单步 20 分钟
32
+ MAX_ATTEMPT_TIMEOUT_S = 60 # 单模型上游最多等 1 分钟,随后换模型/供应商
32
33
 
33
34
  _BASH_CANDIDATES = [
34
35
  r"D:\Git\usr\bin\bash.exe",
@@ -774,8 +775,8 @@ def _codex_fail_msg(stdout):
774
775
  2026-09-16 实测:配额/限流只出现在 error 与 turn.failed 事件里,旧解析器
775
776
  两者都丢——进程退出码非 0 时错误串里只剩 "Reading prompt from stdin...",
776
777
  _quota_error/_transient_error 判不出可降级,健康后继模型从未被尝试。
777
- Reconnecting... 是 CLI 内部重试噪音(可能自愈),不取;turn.failed 是
778
- 终态优先于裸 error(后者取最后一条兜底)。
778
+ Reconnecting... 是 CLI 内部重试噪音(可能自愈),不取作终态错误;重复
779
+ 重连由进程看门狗提前中断。turn.failed 优先于裸 error(后者取最后一条兜底)。
779
780
  """
780
781
  terminal, last_err = "", ""
781
782
  for line in (stdout or "").splitlines():
@@ -823,6 +824,10 @@ def _parse_claude_json(stdout):
823
824
  int(u.get("cache_creation_input_tokens") or 0)
824
825
  usage = {"input": inp, "output": out, "cached": cached, "reasoning": 0,
825
826
  "total": inp + out + cached}
827
+ # 网关实服模型:请求 deepseek-v4.1-flash 却被网关映射成 gemini-3.8-flash
828
+ # 之类(2026-09-23 夜班实案)——用量统计记错名、失败排查看错病根,都
829
+ # 靠 result 行的 modelUsage 对出来
830
+ served = sorted({str(k) for k in ((data.get("modelUsage") or {}) or {})})
826
831
  return {
827
832
  "text": data.get("result") or "",
828
833
  "cost_usd": data.get("total_cost_usd") or 0.0,
@@ -831,6 +836,7 @@ def _parse_claude_json(stdout):
831
836
  "is_error": bool(data.get("is_error")),
832
837
  # §07 T1.1:claude -p 返回本次会话 id,供 --resume 复用
833
838
  "sid": str(data.get("session_id") or ""),
839
+ "served_models": served,
834
840
  }
835
841
 
836
842
 
@@ -898,6 +904,49 @@ RATE_LIMIT_GRACE_S = 45 # 宽限等待时长:限流窗口通常按分钟
898
904
  RATE_LIMIT_GRACE_N = 1 # 每步宽限次数:只兜一次,防限流长拖整轮时间
899
905
 
900
906
 
907
+ # 上游内容拒答(refusal):claude CLI 把 refusal stop_reason 渲染成
908
+ # "API Error: <model> can't help with this. Start a new session to continue."
909
+ # 这是网关后面那个模型对本次会话内容的过滤决定,不是任务终态——换一家
910
+ # 异上游候选常常就能跑通。2026-09-23 夜班实案:doc 起草被 gemini 拒答后
911
+ # 判成非瞬态整链早死,链上云知声候选根本没被尝试。
912
+ _REFUSAL = ("can't help with this", "couldn't help with this",
913
+ 'stop_reason":"refusal"', "content policy", "content filtering")
914
+
915
+
916
+ def _refusal_error(err):
917
+ err = (err or "").lower()
918
+ return any(k in err for k in _REFUSAL)
919
+
920
+
921
+ def _attempt_upstream(att):
922
+ """返回尝试项的上游身份;同一 host 换模型/供应商别名不重复烧超时。"""
923
+ provider = (att or {}).get("provider") or {}
924
+ base_url = str(provider.get("base_url") or "").strip()
925
+ match = re.match(r"^[a-zA-Z][a-zA-Z0-9+.-]*://([^/?#]+)", base_url)
926
+ if match:
927
+ return match.group(1).lower()
928
+ provider_id = str((att or {}).get("provider_id") or "").strip()
929
+ return "provider:" + provider_id if provider_id else "unknown"
930
+
931
+
932
+ def _claude_drift_note(att_model, parsed):
933
+ """请求模型 vs 网关实服模型不一致时的注记;一致或无从判断返回空。"""
934
+ served = (parsed or {}).get("served_models") or []
935
+ if not served or not att_model or served == [att_model]:
936
+ return ""
937
+ return "[模型漂移] 请求 %s,上游实服 %s" % (att_model, ",".join(served))
938
+
939
+
940
+ def _log_note(log_path, text):
941
+ if not log_path or not text:
942
+ return
943
+ try:
944
+ with open(log_path, "a", encoding="utf-8") as fh:
945
+ fh.write("\n[%s]\n" % text)
946
+ except OSError:
947
+ pass
948
+
949
+
901
950
  def _rate_limited(err):
902
951
  err = (err or "").lower()
903
952
  return any(k in err for k in _RATE_LIMIT)
@@ -930,7 +979,7 @@ def _augment_error_from_log(out, log_path, limit=300):
930
979
  out["error"] = (out["error"] + ";日志错误行: " + hits[-1][:limit])[-800:]
931
980
 
932
981
 
933
- def _grace_wait(cancel_event, seconds, log_path=None, why=""):
982
+ def _grace_wait(cancel_event, seconds, log_path=None, why=""):
934
983
  """链尾限流宽限等待:小片睡眠随时响应取消。返回 False=已取消(别再重试)。"""
935
984
  if log_path:
936
985
  try:
@@ -939,11 +988,11 @@ def _grace_wait(cancel_event, seconds, log_path=None, why=""):
939
988
  % (why or "上游限流(429)", seconds))
940
989
  except Exception:
941
990
  pass
942
- end = time.time() + seconds
943
- while time.time() < end:
944
- if cancel_event is not None and cancel_event.is_set():
945
- return False
946
- time.sleep(min(3, max(0.5, end - time.time())))
991
+ end = time.monotonic() + seconds
992
+ while time.monotonic() < end:
993
+ if cancel_event is not None and cancel_event.is_set():
994
+ return False
995
+ time.sleep(min(3, max(0.5, end - time.monotonic())))
947
996
  return cancel_event is None or not cancel_event.is_set()
948
997
 
949
998
 
@@ -1255,14 +1304,17 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
1255
1304
  防模型纯口头谎报「环境受限」蒙混过关(2026-09-17 mo-so 实测)。
1256
1305
 
1257
1306
  模型尝试顺序来自 _resolve_attempts:跨厂商链(每条独立 env)或
1258
- 主模型 + 降级备选;瞬态错误才换下一条,取消/超时/解析失败不降级。
1307
+ 主模型 + 降级备选;瞬态/配额/超时/上游拒答换下一条,取消与解析
1308
+ 失败不降级;同一上游超时两次即跳过该上游剩余模型。
1259
1309
 
1260
- 5E:catalog `orch.timeout_ms`(毫秒)优先于 caller 传入的 timeout。
1310
+ 5E:catalog `orch.timeout_ms`(毫秒)优先于 caller 传入的 timeout;
1311
+ 但单个模型/上游调用硬封顶 60 秒,超时即试链上下一家。
1261
1312
  """
1262
1313
  # 5E:catalog orch.timeout_ms 优先
1263
1314
  orch_timeout_ms = (agent.get("orch") or {}).get("timeout_ms")
1264
- if orch_timeout_ms:
1265
- timeout = float(orch_timeout_ms) / 1000.0
1315
+ if orch_timeout_ms:
1316
+ timeout = float(orch_timeout_ms) / 1000.0
1317
+ timeout = min(float(timeout), MAX_ATTEMPT_TIMEOUT_S)
1266
1318
  kind = agent.get("kind", "generic")
1267
1319
  if kind == "aider":
1268
1320
  repo_issue = _git_repo_issue(workdir)
@@ -1308,8 +1360,14 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
1308
1360
  attempts = _resolve_attempts(agent)
1309
1361
  out = None
1310
1362
  grace_left = RATE_LIMIT_GRACE_N # 链尾限流宽限预算:整链撞 429 时原地等一个窗口再试一次
1311
- for ai, att in enumerate(attempts):
1312
- env = dict(base_env)
1363
+ timed_out_upstreams = {} # 同一上游两次超时后跳过剩余模型
1364
+ skipped_upstreams = set()
1365
+ for ai, att in enumerate(attempts):
1366
+ upstream = _attempt_upstream(att)
1367
+ if upstream in skipped_upstreams:
1368
+ continue
1369
+ model_deadline = att.get("_deadline") or (time.monotonic() + timeout)
1370
+ env = dict(base_env)
1313
1371
  env.update(att["env"])
1314
1372
  if kind == "codex":
1315
1373
  # 网关自定义模型名(如 [opencode]xxx)是合法 API 模型名不能改,但方括号
@@ -1323,25 +1381,32 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
1323
1381
  elif att["from_chain"] and "codex_provider" in eff_agent:
1324
1382
  # 链内条目未注入供应商时不能沿用上一条(可能是另一家厂商)的 -c 覆盖
1325
1383
  del eff_agent["codex_provider"]
1326
- for attempt in range(2): # claude 偶发空响应(0 token)自动重试一次
1327
- argv, stdin_text, prompt_eff, tmp_files = _build_call(
1328
- eff_agent, kind, sid, readonly,
1329
- att["model"], prompt,
1330
- images=images if kind == "codex" else None,
1331
- workdir=workdir)
1332
- try:
1333
- repeat_guard = (("Reconnecting... waiting for network", 5)
1334
- if kind == "codex" else None)
1335
- res = run_process(argv=argv, stdin_text=stdin_text, cwd=workdir, env=env,
1336
- timeout=timeout, cancel_event=cancel_event, log_path=log_path,
1337
- stall_timeout=stall_t, repeat_abort=repeat_guard)
1338
- finally:
1339
- # 超长指令临时文件:CLI 进程已结束(管道已收),即刻清场不污染工作目录
1340
- for tf in tmp_files:
1341
- try:
1342
- os.remove(tf)
1343
- except OSError:
1344
- pass
1384
+ for attempt in range(2): # claude 偶发空响应(0 token)自动重试一次
1385
+ remaining = model_deadline - time.monotonic()
1386
+ if remaining <= 0:
1387
+ res = {"ok": False, "exit_code": None, "stdout": "", "stderr": "",
1388
+ "duration": timeout, "cancelled": False, "timed_out": True,
1389
+ "stalled": False}
1390
+ else:
1391
+ argv, stdin_text, prompt_eff, tmp_files = _build_call(
1392
+ eff_agent, kind, sid, readonly,
1393
+ att["model"], prompt,
1394
+ images=images if kind == "codex" else None,
1395
+ workdir=workdir)
1396
+ try:
1397
+ repeat_guard = (("Reconnecting...", 2)
1398
+ if kind == "codex" else None)
1399
+ res = run_process(argv=argv, stdin_text=stdin_text, cwd=workdir, env=env,
1400
+ timeout=min(timeout, remaining), cancel_event=cancel_event,
1401
+ log_path=log_path, stall_timeout=stall_t,
1402
+ repeat_abort=repeat_guard)
1403
+ finally:
1404
+ # 超长指令临时文件:CLI 进程已结束(管道已收),即刻清场不污染工作目录
1405
+ for tf in tmp_files:
1406
+ try:
1407
+ os.remove(tf)
1408
+ except OSError:
1409
+ pass
1345
1410
  out = {"ok": res["ok"], "text": "", "json": None, "cost_usd": 0.0,
1346
1411
  "tokens": 0, "usage": None, "error": "", "error_code": "",
1347
1412
  "sid": "", "raw": res, "kind": kind, "model": att["model"],
@@ -1370,6 +1435,10 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
1370
1435
  parsed_err = _parse_claude_json(res["stdout"] or "")
1371
1436
  if parsed_err and parsed_err.get("is_error") and parsed_err.get("text"):
1372
1437
  out["error"] = "claude 返回 is_error: " + parsed_err["text"][:500]
1438
+ drift = _claude_drift_note(att.get("model"), parsed_err)
1439
+ if drift:
1440
+ out["error"] += ";" + drift
1441
+ _log_note(log_path, drift)
1373
1442
  if kind == "codex" and att.get("provider_id"):
1374
1443
  el = out["error"].lower()
1375
1444
  if (("wire_api" in el and "no longer supported" in el)
@@ -1442,9 +1511,14 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
1442
1511
  out["tokens"] = parsed["tokens"]
1443
1512
  out["usage"] = parsed["usage"]
1444
1513
  out["sid"] = parsed.get("sid") or "" # §07 T1.1
1514
+ drift = _claude_drift_note(att.get("model"), parsed)
1515
+ if drift:
1516
+ _log_note(log_path, drift)
1445
1517
  if parsed["is_error"]:
1446
1518
  out["ok"] = False
1447
1519
  out["error"] = "claude 返回 is_error: " + parsed["text"][:500]
1520
+ if drift:
1521
+ out["error"] += ";" + drift
1448
1522
  out["error_code"] = _classify_failure(res, kind="claude", parsed=parsed)
1449
1523
  break
1450
1524
  if not out["text"] and attempt == 0:
@@ -1460,21 +1534,44 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
1460
1534
  if not out["text"]:
1461
1535
  out["error_code"] = _classify_failure(res, kind=kind, empty_output=True)
1462
1536
  break
1463
- _report_key(att, out)
1464
- if out["ok"] or ai == len(attempts) - 1:
1465
- if (not out["ok"] and grace_left > 0 and _rate_limited(out.get("error"))
1466
- and not (cancel_event is not None and cancel_event.is_set())):
1467
- # 链尾限流宽限:enumerate 活列表——把链尾这条 append 回去,
1468
- # 下一轮迭代就是「原地重试一次」;再撞限流时预算已耗尽照常判死。
1469
- grace_left -= 1
1470
- if _grace_wait(cancel_event, RATE_LIMIT_GRACE_S, log_path=log_path):
1471
- attempts.append(att)
1472
- continue
1473
- return out
1474
- # 瞬态网络错误 → 换下一条;欠费/配额耗尽同样换(可能是同厂商的备用 KEY,
1475
- # 也可能是另一家厂商)——账单断了死磕同一把 KEY 没有任何意义。
1476
- if not (_transient_error(out.get("error")) or _quota_error(out.get("error"))):
1477
- return out # 非瞬态(取消/超时/解析失败)不降级
1537
+ _report_key(att, out)
1538
+ if out["ok"] or ai == len(attempts) - 1:
1539
+ remaining = model_deadline - time.monotonic()
1540
+ if (not out["ok"] and grace_left > 0 and _rate_limited(out.get("error"))
1541
+ and remaining > 2
1542
+ and not (cancel_event is not None and cancel_event.is_set())):
1543
+ # 链尾限流宽限:enumerate 活列表——把链尾这条 append 回去,
1544
+ # 下一轮迭代就是「原地重试一次」;再撞限流时预算已耗尽照常判死。
1545
+ grace_left -= 1
1546
+ grace_s = min(RATE_LIMIT_GRACE_S, remaining - 1)
1547
+ if _grace_wait(cancel_event, grace_s, log_path=log_path):
1548
+ retry = dict(att, _deadline=model_deadline)
1549
+ attempts.append(retry)
1550
+ continue
1551
+ return out
1552
+ # 换将闸门:什么失败值得烧链上下一个候选。
1553
+ # · 瞬态/配额(文本特征,含 CLI 内部 api_retry 的 429/5xx 字样)照旧;
1554
+ # · 超时:显式可换(看 res 标志位)——慢上游换快候选可能救回。
1555
+ # 2026-09-23 夜班实案:旧逻辑里超时能否换将全赌输出尾部碰巧含有
1556
+ # "timeout" 字样(当时是步骤里读到的代码 timeout=900 撞的表)。
1557
+ # · 上游内容拒答(refusal):是那家模型的过滤决定,不是任务终态,
1558
+ # 换异上游候选常常就活;判死则整 run 白烧(同案:gemini 拒答后
1559
+ # 链上云知声候选从未被尝试)。
1560
+ raw = out.get("raw") or {}
1561
+ if (raw.get("cancelled") # 取消是用户意志,绝不降级(尾段可能带瞬态字样)
1562
+ or not (_transient_error(out.get("error")) or _quota_error(out.get("error"))
1563
+ or _refusal_error(out.get("error"))
1564
+ or raw.get("timed_out"))):
1565
+ return out # 取消/解析失败等真终态不降级
1566
+ # 同一上游的模型连续两次超时就收手——
1567
+ # 链上候选绑同一上游时换将=换壳不换命(2026-09-22 429 集群教训;
1568
+ # 2026-09-23 夜班实案同一慢上游连烧 3×20 分钟全超时零产出),最多
1569
+ # 两个模型预算后转向其他上游。其余瞬态/拒答仍按链长全部尝试:各家病根
1570
+ # 不同,每个候选都值得一次机会。
1571
+ if raw.get("timed_out"):
1572
+ timed_out_upstreams[upstream] = timed_out_upstreams.get(upstream, 0) + 1
1573
+ if timed_out_upstreams[upstream] >= 2:
1574
+ skipped_upstreams.add(upstream)
1478
1575
  return out
1479
1576
 
1480
1577
 
@@ -87,12 +87,15 @@ def render_share_html(run, task, report_text):
87
87
 
88
88
  steps_rows = ""
89
89
  for s in run.get("steps") or []:
90
+ who = s.get("agent_label") or s.get("agent") or ""
91
+ if s.get("model"):
92
+ who += " · " + str(s["model"])
90
93
  steps_rows += (
91
94
  '<div class="step"><span class="n">%02d</span>'
92
95
  '<span class="role">%s</span><span class="who">%s</span>'
93
96
  '<span class="sum">%s</span><span class="st %s">%s</span></div>'
94
97
  % (s.get("n") or 0, _esc(s.get("role") or ""),
95
- _esc(s.get("agent_label") or s.get("agent") or ""),
98
+ _esc(who),
96
99
  _esc((s.get("summary") or "")[:220]), _esc(s.get("status") or ""),
97
100
  _esc(s.get("status") or "")))
98
101
 
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
  # 确定性推导——续写批次沿用同一份卷表/每卷章数即自动接上同一卷,
@@ -1528,7 +1537,7 @@ def update_task_params(task_id, patch):
1528
1537
  return True, ""
1529
1538
 
1530
1539
 
1531
- def add_step(run_id, role, agent_id, agent_label, note=""):
1540
+ def add_step(run_id, role, agent_id, agent_label, note="", model=""):
1532
1541
  with LOCK:
1533
1542
  run = _RUNS.get(run_id)
1534
1543
  if not run:
@@ -1537,6 +1546,9 @@ def add_step(run_id, role, agent_id, agent_label, note=""):
1537
1546
  step = {
1538
1547
  "n": n, "role": role, "agent": agent_id, "agent_label": agent_label,
1539
1548
  "note": note,
1549
+ # 起跑即记解析到的模型:运行中蜂巢卡/步骤列表就能看到「在用哪个模型」,
1550
+ # 不必等 finish_step 用实际结果覆盖(换将成功后以实际为准)
1551
+ "model": str(model or "")[:80],
1540
1552
  "status": "running", "started_at": time.strftime("%H:%M:%S"),
1541
1553
  "ended_at": None, "duration_s": None, "exit_code": None,
1542
1554
  "summary": "", "log": None, "cost_usd": 0.0, "tokens": 0,
package/app/main.py CHANGED
@@ -30,7 +30,7 @@ if os.environ.get("TUTTI_ISOLATE_HOME", "").strip() == "1":
30
30
  os.environ["USERPROFILE"] = _fake_home
31
31
  os.environ["HOME"] = _fake_home
32
32
 
33
- from core import automation, catalog, flows, jobs, manager, market, market_remote, preview, registry, remote, settings, store
33
+ from core import automation, board, catalog, flows, jobs, manager, market, market_remote, preview, registry, remote, settings, store
34
34
  from core import paths
35
35
  from core import health
36
36
  import pick_dialog
@@ -235,6 +235,9 @@ class Handler(BaseHTTPRequestHandler):
235
235
  if path == "/api/health":
236
236
  from core import health
237
237
  return self._json(200, health.snapshot())
238
+ if path == "/api/board":
239
+ # 任务驾驶舱大屏的轻量聚合(KB 级;别让它去拉 /api/state 全量)
240
+ return self._json(200, board.payload())
238
241
  if path == "/api/connect":
239
242
  # 供设置页「手机连接」弹框生成二维码;远程打开需令牌,天然受保护
240
243
  return self._json(200, {"urls": remote.build_connect_urls(PORT),