codebee 0.1.53 → 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.
package/CHANGELOG.md CHANGED
@@ -23,6 +23,16 @@ 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.54(2026-09-23)
27
+
28
+ - 任务驾驶舱大屏:/board.html 独立页 + /api/board 聚合端点——模型/厂商分布、实时思考流、排队滚动,中英双语
29
+ - 连载章节安全落盘:临时文件 + 原子改名,半截稿不再污染章节文件
30
+ - 日志抽屉一键复制全文(报错 JSON 不再手动圈选)
31
+ - 删除/归档乐观清场:SSE 半死时已删/已归档任务不再挂侧栏
32
+ - 卡死的模型调用快速失败转投,不再耗满超时
33
+ - run_end 钩子新增「完成后 AI 复查并自动提交推送」:AI 复查 diff 判 OK 才 commit+push
34
+ - 蜂巢卡显示当前使用模型 + 运行中思考过程行
35
+
26
36
  ## v0.1.53(2026-09-23)
27
37
 
28
38
  - 换将救活两类「假终态」:上游内容拒答("can't help with this" 一类 refusal)与真超时,现在都会换绑定链上下一个候选再试——此前拒答直接判死整轮任务(链上异上游候选从未被尝试),超时能不能换全凭输出尾段碰巧带 "timeout" 字样;真实案:夜间知识库整理任务被网关 gemini 拒答后整 run 白烧
package/README.md CHANGED
@@ -21,11 +21,11 @@ Kimi Code、MiMo Code、Grok Build、Pi、DeepSeek Harness……),提供
21
21
  不会把你的任务内容交给任何第三方。
22
22
 
23
23
  <!-- relnotes:start -->
24
- ### 最新版更新内容(v0.1.53)
24
+ ### 最新版更新内容(v0.1.54)
25
25
 
26
- - 换将救活两类「假终态」:上游内容拒答(refusal)与真超时都会换链上下一个候选再试,不再整轮白烧
27
- - 同一慢上游连烧止损:连续两次相同超时即收手;取消绝不降级
28
- - 网关模型漂移可见:请求与实际服务的模型不一致时,失败信息明确标注
26
+ - 新增任务驾驶舱大屏(/board.html):模型/厂商分布、实时思考流、排队滚动
27
+ - 章节原子落盘;日志一键复制;删除/归档乐观清场;卡死调用快速转投
28
+ - run_end 钩子支持「AI 复查后自动提交推送」;蜂巢卡显示当前模型与思考流
29
29
  <!-- relnotes:end -->
30
30
 
31
31
  ---
@@ -0,0 +1,209 @@
1
+ # -*- coding: utf-8 -*-
2
+ """任务驾驶舱(/board 大屏)的轻量聚合数据源。
3
+
4
+ 大屏 4 秒轮询:不能拉 /api/state 全量(MB 级),也不能每次全量扫 usage
5
+ 台账(summary 全历史读入),所以这里只取大屏要看的字段,并把 usage 聚合
6
+ 缓存 8 秒。只读 store/usage/jobs/health,不落新盘。
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import time
11
+
12
+ _CACHE = {"ts": 0.0, "data": None}
13
+ _TTL = 8.0
14
+
15
+ _TAIL_MAX = 150 # 跑马灯尾行截断
16
+ _TAIL_LINES = 6 # 实时流窗口行数
17
+ _TAIL_LINE_CHARS = 56 # 实时流单行截断(卡内 11px 等宽不换行的安全宽度)
18
+ _RECENT_LIMIT = 10 # 最近完成/失败条数
19
+ _RUN_WINDOW = 300 # 今日计数扫描的运行窗口(id 字典序=时间序)
20
+
21
+
22
+ def payload():
23
+ now = time.time()
24
+ if _CACHE["data"] is not None and now - _CACHE["ts"] < _TTL:
25
+ return _CACHE["data"]
26
+ data = _build()
27
+ _CACHE["ts"] = now
28
+ _CACHE["data"] = data
29
+ return data
30
+
31
+
32
+ def _tail(step):
33
+ """步骤实时流(正文优先,其次思考)的最后一行,给大屏跑马灯。"""
34
+ for key in ("stream", "thinking"):
35
+ v = step.get(key)
36
+ if isinstance(v, str) and v.strip():
37
+ lines = [ln.strip() for ln in v.strip().splitlines() if ln.strip()]
38
+ if lines:
39
+ return lines[-1][:_TAIL_MAX]
40
+ return ""
41
+
42
+
43
+ def _live_block(step):
44
+ """步骤实时流窗口:尾部多行(正文优先,其次思考),供大屏看它在想什么。
45
+
46
+ 返回 (来源标签 code, 文本);标签是 code(output/thinking),由前端 i18n
47
+ 翻译——大屏要做多语言,后端不下发中文。都没有返回 ("", "")。
48
+ """
49
+ for key, code in (("stream", "output"), ("thinking", "thinking")):
50
+ v = step.get(key)
51
+ if not (isinstance(v, str) and v.strip()):
52
+ continue
53
+ lines = [ln.strip()[:_TAIL_LINE_CHARS] for ln in v.strip().splitlines()]
54
+ lines = [ln for ln in lines if ln]
55
+ if lines:
56
+ return code, "\n".join(lines[-_TAIL_LINES:])
57
+ return "", ""
58
+
59
+
60
+ def _model_provider_map():
61
+ """模型名 → 厂商名(enabled 优先,同模型多厂商取 priority 最靠前的)。"""
62
+ try:
63
+ from . import modelhub
64
+ rows = modelhub.models_view()
65
+ except Exception:
66
+ return {}
67
+ best = {}
68
+ for r in rows:
69
+ name = r.get("name")
70
+ if not name or not r.get("enabled", False):
71
+ continue
72
+ pri = r.get("priority", 0)
73
+ cur = best.get(name)
74
+ if cur is None or pri < cur[0]:
75
+ best[name] = (pri, r.get("provider_name") or r.get("provider_id") or "")
76
+ return {k: v[1] for k, v in best.items()}
77
+
78
+
79
+ def _parse_ts(s):
80
+ try:
81
+ return time.mktime(time.strptime(str(s), "%Y-%m-%d %H:%M:%S"))
82
+ except (ValueError, TypeError):
83
+ return 0.0
84
+
85
+
86
+ def _duration_s(run):
87
+ """运行耗时(秒):ended-started,缺 started 用 created 兜底。"""
88
+ end = _parse_ts(run.get("ended_at"))
89
+ start = _parse_ts(run.get("started_at")) or _parse_ts(run.get("created_at"))
90
+ if end > 0 and start > 0 and end >= start:
91
+ return int(end - start)
92
+ return None
93
+
94
+
95
+ def _step_view(steps, prov_map):
96
+ """进度概要:完成数 + 当前运行中步骤(含模型/厂商/实时流)。"""
97
+ done = sum(1 for s in steps if s.get("status") == "done")
98
+ cur = next((s for s in steps if s.get("status") == "running"), None)
99
+ cur_v = None
100
+ if cur:
101
+ live_label, live_text = _live_block(cur)
102
+ model = str(cur.get("model") or "")[:60]
103
+ cur_v = {"n": cur.get("n"), "role": cur.get("role") or "",
104
+ "agent": cur.get("agent_label") or cur.get("agent") or "",
105
+ "summary": (cur.get("summary") or "")[:80],
106
+ "tail": _tail(cur),
107
+ "model": model, "provider": prov_map.get(model, "") if model else "",
108
+ "live_label": live_label, "live_text": live_text}
109
+ return done, len(steps), cur_v
110
+
111
+
112
+ def _build():
113
+ from . import flows, health, jobs, store, usage
114
+
115
+ flow_map = {}
116
+ try:
117
+ for f in flows.list_flows():
118
+ flow_map[f.get("id")] = f
119
+ except Exception:
120
+ pass
121
+
122
+ prov_map = _model_provider_map()
123
+ today = time.strftime("%Y-%m-%d")
124
+ tasks = store.list_tasks(200, archived=False)
125
+ latest = store.latest_run_by_task()
126
+
127
+ running, queued = [], []
128
+ for t in tasks:
129
+ tid = t.get("id")
130
+ run = latest.get(tid)
131
+ status = t.get("status")
132
+ if status == "running" and run and run.get("status") == "running":
133
+ done, total, cur = _step_view(run.get("steps") or [], prov_map)
134
+ running.append({
135
+ "task_id": tid, "run_id": run.get("id"),
136
+ "title": t.get("title") or "", "goal": (t.get("goal") or "")[:120],
137
+ "type": t.get("type") or "",
138
+ "type_name": (flow_map.get(t.get("type")) or {}).get("name") or (t.get("type") or ""),
139
+ "status": status,
140
+ "started_at": run.get("started_at") or run.get("created_at") or "",
141
+ "steps_done": done, "steps_total": total,
142
+ "current": cur,
143
+ "run_tokens": run.get("tokens") or 0,
144
+ "run_cost_usd": round(float(run.get("cost_usd") or 0.0), 4),
145
+ "estimate_s": run.get("estimated_duration_s"),
146
+ })
147
+ elif status == "queued":
148
+ queued.append({
149
+ "task_id": tid, "run_id": (run or {}).get("id"),
150
+ "title": t.get("title") or "",
151
+ "type_name": (flow_map.get(t.get("type")) or {}).get("name") or (t.get("type") or ""),
152
+ "created_at": (run or {}).get("created_at") or "",
153
+ })
154
+ running.sort(key=lambda c: _parse_ts(c["started_at"]))
155
+ queued.sort(key=lambda c: _parse_ts(c["created_at"]))
156
+
157
+ runs = store.list_runs(_RUN_WINDOW)
158
+ today_done = today_failed = 0
159
+ recent = []
160
+ for r in runs:
161
+ st = r.get("status")
162
+ ended = str(r.get("ended_at") or "")
163
+ if st in ("done", "failed", "cancelled") and ended.startswith(today):
164
+ if st == "done":
165
+ today_done += 1
166
+ elif st == "failed":
167
+ today_failed += 1
168
+ if len(recent) < _RECENT_LIMIT and st in ("done", "failed", "cancelled") and ended:
169
+ recent.append({
170
+ "run_id": r.get("id"), "task_id": r.get("task_id"),
171
+ "title": r.get("title") or "", "status": st,
172
+ "ended_at": ended, "duration_s": _duration_s(r),
173
+ "error": (r.get("error") or "")[:80],
174
+ })
175
+
176
+ trend, models, usage_today = [], [], {"tokens": 0, "calls": 0, "cost_usd": 0.0}
177
+ try:
178
+ s = usage.summary(days=7)
179
+ trend = [{"day": d.get("day"), "tokens": d.get("tokens") or 0}
180
+ for d in (s.get("by_day") or [])]
181
+ for row in (s.get("by_model") or [])[:5]:
182
+ if not row.get("tokens"):
183
+ continue
184
+ models.append({"model": row.get("key") or "?",
185
+ "tokens": row.get("tokens") or 0,
186
+ "calls": row.get("calls") or 0})
187
+ for d in s.get("by_day") or []:
188
+ if str(d.get("day")) == today:
189
+ usage_today = {"tokens": d.get("tokens") or 0,
190
+ "calls": d.get("calls") or 0,
191
+ "cost_usd": round(float(d.get("cost_usd") or 0.0), 3)}
192
+ break
193
+ except Exception:
194
+ pass
195
+
196
+ return {
197
+ "now": time.strftime("%Y-%m-%d %H:%M:%S"),
198
+ "today": today,
199
+ "running": running,
200
+ "queued": queued,
201
+ "today_done": today_done,
202
+ "today_failed": today_failed,
203
+ "recent": recent,
204
+ "trend": trend,
205
+ "models": models,
206
+ "usage_today": usage_today,
207
+ "pool": jobs.workers_info(),
208
+ "health": health.snapshot().get("providers") or [],
209
+ }
@@ -0,0 +1,32 @@
1
+ # -*- coding: utf-8 -*-
2
+ """章节安全落盘(tmp+原子改名,inkos 安全章节工作区借鉴)。
3
+
4
+ 写一半崩溃/磁盘满不会留下半章正文冒充成稿——断点续跑按「文件够长」
5
+ 判定,半文件会被误当合法稿复用;.tmp 写完 os.replace 原子落位,
6
+ 失败清理残渣后原样抛出。路径守卫用 pathlib resolve+parents 惯用法
7
+ (同 _ms_io/_inside 口径)。pipeline._write_chapter 委托此函数。"""
8
+ from __future__ import annotations
9
+
10
+ from pathlib import Path
11
+
12
+
13
+ def atomic_write_chapter(workdir, i, text):
14
+ """第 i 章安全落盘:tmp 写入 → 原子改名。返回最终路径。"""
15
+ root = Path(workdir).resolve()
16
+ p = (root / ("chapter-%02d.md" % int(i))).resolve()
17
+ # 守卫:目标必须在 workdir 之内且不得是 workdir 本身(".." 作 workdir
18
+ # 时 resolve 到父目录,p 仍是其中一章——但那不是调用方的工作目录,
19
+ # 一并拒绝:workdir 必须是已存在的目录)
20
+ if root not in p.parents or not root.is_dir():
21
+ raise ValueError("章节路径越界: chapter-%02d.md" % int(i))
22
+ tmp = Path(str(p) + ".tmp")
23
+ try:
24
+ tmp.write_text(str(text), encoding="utf-8", errors="replace")
25
+ tmp.replace(p)
26
+ except BaseException:
27
+ try:
28
+ tmp.unlink(missing_ok=True)
29
+ except OSError:
30
+ pass
31
+ raise
32
+ return p
package/app/core/jobs.py CHANGED
@@ -949,7 +949,7 @@ def _ai_repair(run_id, entry, ev, failed_cmd, orig_log):
949
949
  .replace("__OS__", _repair_os_label())
950
950
  .replace("__ALLOW__", " / ".join(p.strip() for p in _repair_allow())))
951
951
  step, log_abs = store.add_step(run_id, "ai-repair", agent["id"], agent.get("label"),
952
- note="自动诊断修复")
952
+ note="自动诊断修复", model=agent.get("model") or "")
953
953
  res = runner.run_agent(agent, prompt, readonly=True, timeout=300,
954
954
  cancel_event=ev, log_path=str(log_abs))
955
955
  try:
@@ -19,7 +19,7 @@ import re
19
19
  import threading
20
20
  import time
21
21
 
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
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):
@@ -3491,6 +3560,27 @@ def _run_content_review(run, task, agents, ev, stats, mode):
3491
3560
  ev=ev, note=draft_note,
3492
3561
  resume=resume_ctx["session"] if resume_ctx else None,
3493
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)
3494
3584
  if not draft_res["ok"]:
3495
3585
  store.update_run(run_id, expected_status="running", status="failed",
3496
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():
@@ -917,21 +918,15 @@ def _refusal_error(err):
917
918
  return any(k in err for k in _REFUSAL)
918
919
 
919
920
 
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]
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"
935
930
 
936
931
 
937
932
  def _claude_drift_note(att_model, parsed):
@@ -984,7 +979,7 @@ def _augment_error_from_log(out, log_path, limit=300):
984
979
  out["error"] = (out["error"] + ";日志错误行: " + hits[-1][:limit])[-800:]
985
980
 
986
981
 
987
- def _grace_wait(cancel_event, seconds, log_path=None, why=""):
982
+ def _grace_wait(cancel_event, seconds, log_path=None, why=""):
988
983
  """链尾限流宽限等待:小片睡眠随时响应取消。返回 False=已取消(别再重试)。"""
989
984
  if log_path:
990
985
  try:
@@ -993,11 +988,11 @@ def _grace_wait(cancel_event, seconds, log_path=None, why=""):
993
988
  % (why or "上游限流(429)", seconds))
994
989
  except Exception:
995
990
  pass
996
- end = time.time() + seconds
997
- while time.time() < end:
998
- if cancel_event is not None and cancel_event.is_set():
999
- return False
1000
- 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())))
1001
996
  return cancel_event is None or not cancel_event.is_set()
1002
997
 
1003
998
 
@@ -1310,14 +1305,16 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
1310
1305
 
1311
1306
  模型尝试顺序来自 _resolve_attempts:跨厂商链(每条独立 env)或
1312
1307
  主模型 + 降级备选;瞬态/配额/超时/上游拒答换下一条,取消与解析
1313
- 失败不降级;超时连续两次同签名即止损(同一慢上游换壳无用)。
1308
+ 失败不降级;同一上游超时两次即跳过该上游剩余模型。
1314
1309
 
1315
- 5E:catalog `orch.timeout_ms`(毫秒)优先于 caller 传入的 timeout。
1310
+ 5E:catalog `orch.timeout_ms`(毫秒)优先于 caller 传入的 timeout;
1311
+ 但单个模型/上游调用硬封顶 60 秒,超时即试链上下一家。
1316
1312
  """
1317
1313
  # 5E:catalog orch.timeout_ms 优先
1318
1314
  orch_timeout_ms = (agent.get("orch") or {}).get("timeout_ms")
1319
- if orch_timeout_ms:
1320
- 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)
1321
1318
  kind = agent.get("kind", "generic")
1322
1319
  if kind == "aider":
1323
1320
  repo_issue = _git_repo_issue(workdir)
@@ -1363,9 +1360,14 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
1363
1360
  attempts = _resolve_attempts(agent)
1364
1361
  out = None
1365
1362
  grace_left = RATE_LIMIT_GRACE_N # 链尾限流宽限预算:整链撞 429 时原地等一个窗口再试一次
1366
- prev_sig = "" # 上一次尝试的失败签名:同因连撞止损用
1367
- for ai, att in enumerate(attempts):
1368
- 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)
1369
1371
  env.update(att["env"])
1370
1372
  if kind == "codex":
1371
1373
  # 网关自定义模型名(如 [opencode]xxx)是合法 API 模型名不能改,但方括号
@@ -1379,25 +1381,32 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
1379
1381
  elif att["from_chain"] and "codex_provider" in eff_agent:
1380
1382
  # 链内条目未注入供应商时不能沿用上一条(可能是另一家厂商)的 -c 覆盖
1381
1383
  del eff_agent["codex_provider"]
1382
- for attempt in range(2): # claude 偶发空响应(0 token)自动重试一次
1383
- argv, stdin_text, prompt_eff, tmp_files = _build_call(
1384
- eff_agent, kind, sid, readonly,
1385
- att["model"], prompt,
1386
- images=images if kind == "codex" else None,
1387
- workdir=workdir)
1388
- try:
1389
- repeat_guard = (("Reconnecting... waiting for network", 5)
1390
- if kind == "codex" else None)
1391
- res = run_process(argv=argv, stdin_text=stdin_text, cwd=workdir, env=env,
1392
- timeout=timeout, cancel_event=cancel_event, log_path=log_path,
1393
- stall_timeout=stall_t, repeat_abort=repeat_guard)
1394
- finally:
1395
- # 超长指令临时文件:CLI 进程已结束(管道已收),即刻清场不污染工作目录
1396
- for tf in tmp_files:
1397
- try:
1398
- os.remove(tf)
1399
- except OSError:
1400
- 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
1401
1410
  out = {"ok": res["ok"], "text": "", "json": None, "cost_usd": 0.0,
1402
1411
  "tokens": 0, "usage": None, "error": "", "error_code": "",
1403
1412
  "sid": "", "raw": res, "kind": kind, "model": att["model"],
@@ -1525,17 +1534,21 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
1525
1534
  if not out["text"]:
1526
1535
  out["error_code"] = _classify_failure(res, kind=kind, empty_output=True)
1527
1536
  break
1528
- _report_key(att, out)
1529
- if out["ok"] or ai == len(attempts) - 1:
1530
- if (not out["ok"] and grace_left > 0 and _rate_limited(out.get("error"))
1531
- and not (cancel_event is not None and cancel_event.is_set())):
1532
- # 链尾限流宽限:enumerate 活列表——把链尾这条 append 回去,
1533
- # 下一轮迭代就是「原地重试一次」;再撞限流时预算已耗尽照常判死。
1534
- grace_left -= 1
1535
- if _grace_wait(cancel_event, RATE_LIMIT_GRACE_S, log_path=log_path):
1536
- attempts.append(att)
1537
- continue
1538
- 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
1539
1552
  # 换将闸门:什么失败值得烧链上下一个候选。
1540
1553
  # · 瞬态/配额(文本特征,含 CLI 内部 api_retry 的 429/5xx 字样)照旧;
1541
1554
  # · 超时:显式可换(看 res 标志位)——慢上游换快候选可能救回。
@@ -1550,16 +1563,15 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
1550
1563
  or _refusal_error(out.get("error"))
1551
1564
  or raw.get("timed_out"))):
1552
1565
  return out # 取消/解析失败等真终态不降级
1553
- # 超时同因连撞止损:头部签名(超时/输出停滞)连续相同就收手——
1554
- # 链上候选绑同一上游时换将=换壳不换命(2026-09-22 429 集群教训;
1555
- # 2026-09-23 夜班实案同一慢上游连烧 3×20 分钟全超时零产出),最坏
1556
- # 情况从 N×步超时压到 2×。其余瞬态/拒答仍按链长全部尝试:各家病根
1566
+ # 同一上游的模型连续两次超时就收手——
1567
+ # 链上候选绑同一上游时换将=换壳不换命(2026-09-22 429 集群教训;
1568
+ # 2026-09-23 夜班实案同一慢上游连烧 3×20 分钟全超时零产出),最多
1569
+ # 两个模型预算后转向其他上游。其余瞬态/拒答仍按链长全部尝试:各家病根
1557
1570
  # 不同,每个候选都值得一次机会。
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
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)
1563
1575
  return out
1564
1576
 
1565
1577