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 +10 -0
- package/README.md +4 -4
- package/app/core/board.py +209 -0
- package/app/core/chaptersafe.py +32 -0
- package/app/core/jobs.py +1 -1
- package/app/core/pipeline.py +95 -5
- package/app/core/runner.py +82 -70
- package/app/core/share_page.py +4 -1
- package/app/core/store.py +4 -1
- package/app/main.py +4 -1
- package/app/ui/app.js +13403 -13321
- package/app/ui/board.css +292 -0
- package/app/ui/board.html +84 -0
- package/app/ui/board.js +259 -0
- package/app/ui/i18n.js +24 -0
- package/app/ui/index.html +2 -0
- package/app/ui/style.css +17 -0
- package/package.json +1 -1
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.
|
|
24
|
+
### 最新版更新内容(v0.1.54)
|
|
25
25
|
|
|
26
|
-
-
|
|
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:
|
package/app/core/pipeline.py
CHANGED
|
@@ -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
|
-
|
|
2112
|
-
|
|
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"),
|
package/app/core/runner.py
CHANGED
|
@@ -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
|
|
778
|
-
|
|
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
|
|
921
|
-
"""
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
926
|
-
|
|
927
|
-
|
|
928
|
-
|
|
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.
|
|
997
|
-
while time.
|
|
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.
|
|
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
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
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
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
|
|
1393
|
-
|
|
1394
|
-
|
|
1395
|
-
|
|
1396
|
-
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
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
|
-
|
|
1531
|
-
|
|
1532
|
-
|
|
1533
|
-
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
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
|
-
#
|
|
1566
|
+
# 同一上游的模型连续两次超时就收手——
|
|
1567
|
+
# 链上候选绑同一上游时换将=换壳不换命(2026-09-22 429 集群教训;
|
|
1568
|
+
# 2026-09-23 夜班实案同一慢上游连烧 3×20 分钟全超时零产出),最多
|
|
1569
|
+
# 两个模型预算后转向其他上游。其余瞬态/拒答仍按链长全部尝试:各家病根
|
|
1557
1570
|
# 不同,每个候选都值得一次机会。
|
|
1558
|
-
if raw.get("timed_out"):
|
|
1559
|
-
|
|
1560
|
-
if
|
|
1561
|
-
|
|
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
|
|