codebee 0.1.53 → 0.1.55
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 +17 -0
- package/README.md +3 -4
- package/app/core/board.py +209 -0
- package/app/core/builtin_agent.py +67 -26
- package/app/core/chaptersafe.py +32 -0
- package/app/core/jobs.py +22 -3
- package/app/core/knowledge.py +21 -4
- package/app/core/modelhub.py +33 -4
- package/app/core/pipeline.py +238 -66
- package/app/core/planner.py +27 -13
- package/app/core/runner.py +356 -142
- package/app/core/share_page.py +4 -1
- package/app/core/store.py +68 -28
- package/app/core/zentao.py +26 -6
- package/app/main.py +4 -1
- package/app/ui/app.js +13416 -13324
- package/app/ui/board.css +390 -0
- package/app/ui/board.html +162 -0
- package/app/ui/board.js +305 -0
- package/app/ui/i18n.js +2698 -2635
- package/app/ui/index.html +4 -0
- package/app/ui/style.css +17 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -23,6 +23,23 @@ 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.55(2026-09-23)
|
|
27
|
+
|
|
28
|
+
- 知识库可信度分级:引用核验借鉴——知识条目带 confidence 分级,low 直接丢弃,中高按置信度加权注入
|
|
29
|
+
- 编排执行稳定化 + 模型回退修复:调用链失败时的回退路径更稳
|
|
30
|
+
- 定时任务终态徽章补全:done/failed/cancelled 三分支全可见(失败必须一眼看到)
|
|
31
|
+
- 任务驾驶舱视觉改版;禅道 Bug 扫描过滤 + 去重防重录
|
|
32
|
+
|
|
33
|
+
## v0.1.54(2026-09-23)
|
|
34
|
+
|
|
35
|
+
- 任务驾驶舱大屏:/board.html 独立页 + /api/board 聚合端点——模型/厂商分布、实时思考流、排队滚动,中英双语
|
|
36
|
+
- 连载章节安全落盘:临时文件 + 原子改名,半截稿不再污染章节文件
|
|
37
|
+
- 日志抽屉一键复制全文(报错 JSON 不再手动圈选)
|
|
38
|
+
- 删除/归档乐观清场:SSE 半死时已删/已归档任务不再挂侧栏
|
|
39
|
+
- 卡死的模型调用快速失败转投,不再耗满超时
|
|
40
|
+
- run_end 钩子新增「完成后 AI 复查并自动提交推送」:AI 复查 diff 判 OK 才 commit+push
|
|
41
|
+
- 蜂巢卡显示当前使用模型 + 运行中思考过程行
|
|
42
|
+
|
|
26
43
|
## v0.1.53(2026-09-23)
|
|
27
44
|
|
|
28
45
|
- 换将救活两类「假终态」:上游内容拒答("can't help with this" 一类 refusal)与真超时,现在都会换绑定链上下一个候选再试——此前拒答直接判死整轮任务(链上异上游候选从未被尝试),超时能不能换全凭输出尾段碰巧带 "timeout" 字样;真实案:夜间知识库整理任务被网关 gemini 拒答后整 run 白烧
|
package/README.md
CHANGED
|
@@ -21,11 +21,10 @@ Kimi Code、MiMo Code、Grok Build、Pi、DeepSeek Harness……),提供
|
|
|
21
21
|
不会把你的任务内容交给任何第三方。
|
|
22
22
|
|
|
23
23
|
<!-- relnotes:start -->
|
|
24
|
-
### 最新版更新内容(v0.1.
|
|
24
|
+
### 最新版更新内容(v0.1.55)
|
|
25
25
|
|
|
26
|
-
-
|
|
27
|
-
-
|
|
28
|
-
- 网关模型漂移可见:请求与实际服务的模型不一致时,失败信息明确标注
|
|
26
|
+
- 知识库可信度分级:low 直接丢弃,注入按置信度加权
|
|
27
|
+
- 编排执行稳定化 + 模型回退修复;定时任务终态徽章补全
|
|
29
28
|
<!-- relnotes:end -->
|
|
30
29
|
|
|
31
30
|
---
|
|
@@ -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
|
+
}
|
|
@@ -347,7 +347,7 @@ def _tool_write_file(workdir, args):
|
|
|
347
347
|
return "已写入 %s(%d 字符,UTF-8)" % (rel, len(content))
|
|
348
348
|
|
|
349
349
|
|
|
350
|
-
def _tool_run_command(workdir, args, cancel_event=None):
|
|
350
|
+
def _tool_run_command(workdir, args, cancel_event=None, deadline=None):
|
|
351
351
|
"""本机执行一条 shell 命令,返回 (退出码, 合并输出)。
|
|
352
352
|
|
|
353
353
|
薄适配层:执行/杀树/取消/解码全部复用 runner.run_process(shell_cmd 分支
|
|
@@ -364,7 +364,7 @@ def _tool_run_command(workdir, args, cancel_event=None):
|
|
|
364
364
|
timeout = CMD_DEFAULT_TIMEOUT
|
|
365
365
|
r = runner.run_process(shell_cmd=cmdline, cwd=os.path.abspath(workdir or "."),
|
|
366
366
|
timeout=max(5, min(timeout, CMD_MAX_TIMEOUT)),
|
|
367
|
-
cancel_event=cancel_event)
|
|
367
|
+
deadline=deadline, cancel_event=cancel_event)
|
|
368
368
|
out = runner.clean_cli_text(
|
|
369
369
|
(r.get("stdout") or "") + (("\n" + r["stderr"]) if r.get("stderr") else ""))
|
|
370
370
|
limit = CMD_OUT_HEAD + CMD_OUT_TAIL
|
|
@@ -405,13 +405,13 @@ _TOOL_IMPL = {"list_files": _tool_list_files, "read_file": _tool_read_file,
|
|
|
405
405
|
"write_file": _tool_write_file, "run_command": _tool_run_command}
|
|
406
406
|
|
|
407
407
|
|
|
408
|
-
def _exec_tool(workdir, name, args, cancel_event=None):
|
|
408
|
+
def _exec_tool(workdir, name, args, cancel_event=None, deadline=None):
|
|
409
409
|
fn = _TOOL_IMPL.get(name or "")
|
|
410
410
|
if fn is None:
|
|
411
411
|
return "(未知工具: %s)" % name
|
|
412
412
|
try:
|
|
413
413
|
if name == "run_command":
|
|
414
|
-
return fn(workdir, args or {}, cancel_event=cancel_event)
|
|
414
|
+
return fn(workdir, args or {}, cancel_event=cancel_event, deadline=deadline)
|
|
415
415
|
return fn(workdir, args or {})
|
|
416
416
|
except Exception as e:
|
|
417
417
|
return "工具执行失败: %s" % (e)
|
|
@@ -443,7 +443,20 @@ def _post_json(url, headers, body, allow_private, timeout):
|
|
|
443
443
|
return modelhub._post_json_http(url, headers, body, allow_private, timeout=timeout)
|
|
444
444
|
|
|
445
445
|
|
|
446
|
-
def
|
|
446
|
+
def _call_with_optional_deadline(fn, args, kwargs, deadline):
|
|
447
|
+
"""Keep older test/integration transport shims source-compatible."""
|
|
448
|
+
if deadline is None:
|
|
449
|
+
return fn(*args, **kwargs)
|
|
450
|
+
try:
|
|
451
|
+
return fn(*args, **kwargs, deadline=deadline)
|
|
452
|
+
except TypeError as exc:
|
|
453
|
+
if "unexpected keyword argument 'deadline'" not in str(exc):
|
|
454
|
+
raise
|
|
455
|
+
return fn(*args, **kwargs)
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def _post_interruptible(url, headers, body, allow_private, timeout, cancel_event,
|
|
459
|
+
deadline=None):
|
|
447
460
|
"""可打断的模型调用:cancel_event 置位即刻放弃等待返回。
|
|
448
461
|
|
|
449
462
|
单次生成最长可跑满 timeout,取消不能陪跑到自然结束——HTTP 交给守护
|
|
@@ -459,11 +472,13 @@ def _post_interruptible(url, headers, body, allow_private, timeout, cancel_event
|
|
|
459
472
|
|
|
460
473
|
th = threading.Thread(target=_go, daemon=True)
|
|
461
474
|
th.start()
|
|
462
|
-
if
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
475
|
+
wait_until = deadline if deadline is not None else time.monotonic() + timeout + 10
|
|
476
|
+
while th.is_alive():
|
|
477
|
+
if cancel_event is not None and cancel_event.is_set():
|
|
478
|
+
break
|
|
479
|
+
if time.monotonic() >= wait_until:
|
|
480
|
+
return 0, None, ("任务总时限已到" if deadline is not None else "响应超时")
|
|
481
|
+
th.join(0.5)
|
|
467
482
|
if cancel_event is not None and cancel_event.is_set():
|
|
468
483
|
return 0, None, "已取消"
|
|
469
484
|
return box.get("r") or (0, None, "无响应")
|
|
@@ -641,7 +656,8 @@ def _calls_from_acc(acc):
|
|
|
641
656
|
|
|
642
657
|
|
|
643
658
|
def _post_sse_stream(url, headers, body, allow_private, timeout, proto,
|
|
644
|
-
on_reason=None, on_text=None, on_tick=None, cancel_event=None
|
|
659
|
+
on_reason=None, on_text=None, on_tick=None, cancel_event=None,
|
|
660
|
+
deadline=None):
|
|
645
661
|
"""流式(SSE)模型调用 → dict(status/text/reasoning/calls/usage/error/events)。
|
|
646
662
|
|
|
647
663
|
与 _post_interruptible 同款的可打断语义:读取放守护线程,cancel_event 置位
|
|
@@ -717,11 +733,13 @@ def _post_sse_stream(url, headers, body, allow_private, timeout, proto,
|
|
|
717
733
|
|
|
718
734
|
th = threading.Thread(target=_go, daemon=True)
|
|
719
735
|
th.start()
|
|
720
|
-
if
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
736
|
+
wait_until = deadline if deadline is not None else time.monotonic() + timeout + 10
|
|
737
|
+
while th.is_alive():
|
|
738
|
+
if cancel_event is not None and cancel_event.is_set():
|
|
739
|
+
break
|
|
740
|
+
if time.monotonic() >= wait_until:
|
|
741
|
+
break
|
|
742
|
+
th.join(0.5)
|
|
725
743
|
with lock:
|
|
726
744
|
out = {"status": box["status"], "text": acc["text"],
|
|
727
745
|
"reasoning": acc["reasoning"], "usage": dict(acc["usage"]),
|
|
@@ -731,7 +749,9 @@ def _post_sse_stream(url, headers, body, allow_private, timeout, proto,
|
|
|
731
749
|
out["error"], out["status"] = "已取消", 0
|
|
732
750
|
return out
|
|
733
751
|
if th.is_alive():
|
|
734
|
-
out["error"], out["status"] = "
|
|
752
|
+
out["error"], out["status"] = ("任务总时限已到" if deadline is not None
|
|
753
|
+
else "响应超时"), 0
|
|
754
|
+
out["deadline_exceeded"] = deadline is not None
|
|
735
755
|
return out
|
|
736
756
|
|
|
737
757
|
|
|
@@ -903,7 +923,7 @@ def _norm_usage(usage):
|
|
|
903
923
|
|
|
904
924
|
|
|
905
925
|
def run(bi, prompt, workdir, timeout=180, cancel_event=None, log=None, images=None,
|
|
906
|
-
on_reason=None, on_stream=None, on_activity=None, stream=True):
|
|
926
|
+
on_reason=None, on_stream=None, on_activity=None, stream=True, deadline=None):
|
|
907
927
|
"""跑一次内置智能体(内部自带工具循环直到给出最终回答)。
|
|
908
928
|
|
|
909
929
|
bi: resolve() 的返回;prompt: 本轮完整输入(目标/续轮块由 pipeline 拼);
|
|
@@ -943,6 +963,14 @@ def run(bi, prompt, workdir, timeout=180, cancel_event=None, log=None, images=No
|
|
|
943
963
|
escalated = False # 本轮 run 是否已提额(只提一次,防无限翻倍)
|
|
944
964
|
empty_streak = 0 # 连续零正文轮数(拿到正文/工具即清零)
|
|
945
965
|
|
|
966
|
+
def _deadline_fail():
|
|
967
|
+
return {"ok": False, "text": "", "usage": dict(total_usage),
|
|
968
|
+
"error": "任务总时限已到", "model": model,
|
|
969
|
+
"provider_name": bi["provider_name"],
|
|
970
|
+
"provider_id": bi["provider_id"], "iterations": iters,
|
|
971
|
+
"cost_usd": 0.0, "reasoning": "\n\n".join(r for r in reasons if r)[:THINK_MAX_CHARS],
|
|
972
|
+
"raw": {"timed_out": True, "deadline_exceeded": True}}
|
|
973
|
+
|
|
946
974
|
def _fire(cb, *cb_args):
|
|
947
975
|
if cb is None:
|
|
948
976
|
return
|
|
@@ -968,6 +996,8 @@ def run(bi, prompt, workdir, timeout=180, cancel_event=None, log=None, images=No
|
|
|
968
996
|
"reasoning": "\n\n".join(r for r in reasons if r)[:THINK_MAX_CHARS]}
|
|
969
997
|
|
|
970
998
|
for it in range(1, MAX_TOOL_ITERS + 1):
|
|
999
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
1000
|
+
return _deadline_fail()
|
|
971
1001
|
if cancel_event is not None and cancel_event.is_set():
|
|
972
1002
|
return _fail("已取消")
|
|
973
1003
|
reasons.append("") # 每轮迭代各占一段思考(跨轮拼接时分段,join 时滤空段)
|
|
@@ -981,6 +1011,10 @@ def run(bi, prompt, workdir, timeout=180, cancel_event=None, log=None, images=No
|
|
|
981
1011
|
got = False # 本 KEY 拿到了响应(区别于 HTTP 失败换下家)
|
|
982
1012
|
stripped = False # thinking 对象被网关 400 拒后已摘除重发
|
|
983
1013
|
while True:
|
|
1014
|
+
if deadline is not None and time.monotonic() >= deadline:
|
|
1015
|
+
return _deadline_fail()
|
|
1016
|
+
req_timeout = min(float(timeout), max(0.1, deadline - time.monotonic())) \
|
|
1017
|
+
if deadline is not None else timeout
|
|
984
1018
|
url, headers, body = _build_request(
|
|
985
1019
|
proto, pbase, kk["key"], model, system, msgs,
|
|
986
1020
|
tools_ok, max_tokens=max_tokens)
|
|
@@ -1011,10 +1045,13 @@ def run(bi, prompt, workdir, timeout=180, cancel_event=None, log=None, images=No
|
|
|
1011
1045
|
text, calls, usage = "", [], {}
|
|
1012
1046
|
streamed = False
|
|
1013
1047
|
if sbody is not None:
|
|
1014
|
-
part =
|
|
1015
|
-
|
|
1016
|
-
|
|
1017
|
-
|
|
1048
|
+
part = _call_with_optional_deadline(
|
|
1049
|
+
_post_sse_stream,
|
|
1050
|
+
(url, headers, sbody, allow_private, req_timeout, proto),
|
|
1051
|
+
{"on_reason": _take_reason, "on_text": on_stream,
|
|
1052
|
+
"cancel_event": cancel_event}, deadline)
|
|
1053
|
+
if part.get("deadline_exceeded"):
|
|
1054
|
+
return _deadline_fail()
|
|
1018
1055
|
if part["error"] == "已取消":
|
|
1019
1056
|
return _fail("已取消")
|
|
1020
1057
|
if not part["error"] and part["events"]:
|
|
@@ -1032,9 +1069,12 @@ def run(bi, prompt, workdir, timeout=180, cancel_event=None, log=None, images=No
|
|
|
1032
1069
|
# 供应商稳妥;非流式也失败才走下面的记账/换链。
|
|
1033
1070
|
last_err = part["error"]
|
|
1034
1071
|
if not streamed:
|
|
1035
|
-
status, data, err =
|
|
1036
|
-
|
|
1037
|
-
|
|
1072
|
+
status, data, err = _call_with_optional_deadline(
|
|
1073
|
+
_post_interruptible,
|
|
1074
|
+
(url, headers, body, allow_private, req_timeout, cancel_event),
|
|
1075
|
+
{}, deadline)
|
|
1076
|
+
if err == "任务总时限已到":
|
|
1077
|
+
return _deadline_fail()
|
|
1038
1078
|
if cancel_event is not None and cancel_event.is_set():
|
|
1039
1079
|
# 取消先于一切记账:健康 KEY 不能因被放弃的请求背上冷却
|
|
1040
1080
|
return _fail("已取消")
|
|
@@ -1080,7 +1120,8 @@ def run(bi, prompt, workdir, timeout=180, cancel_event=None, log=None, images=No
|
|
|
1080
1120
|
_fire(on_activity, "请求工具: " + "、".join(c["name"] for c in calls))
|
|
1081
1121
|
results = []
|
|
1082
1122
|
for c in calls:
|
|
1083
|
-
out = _exec_tool(workdir, c["name"], c["args"], cancel_event
|
|
1123
|
+
out = _exec_tool(workdir, c["name"], c["args"], cancel_event,
|
|
1124
|
+
deadline=deadline)
|
|
1084
1125
|
if log:
|
|
1085
1126
|
brief = out if len(out) <= 120 else out[:120] + "…"
|
|
1086
1127
|
log("[工具] %s → %s" % (c["name"], brief.replace("\n", " ⏎ ")))
|
|
@@ -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
|
@@ -19,6 +19,7 @@ from __future__ import annotations
|
|
|
19
19
|
import queue
|
|
20
20
|
import sys
|
|
21
21
|
import threading
|
|
22
|
+
import time
|
|
22
23
|
import traceback
|
|
23
24
|
|
|
24
25
|
# 仅保留给旧测试/诊断代码观察;生产 enqueue 永远不向这里写入。
|
|
@@ -323,6 +324,16 @@ def _requeue_await_slot(job, waits_left=None):
|
|
|
323
324
|
# 带着全新重试额度重新入队,封顶永远打不中)。仍满载则续下一拍。
|
|
324
325
|
with _timer_lock:
|
|
325
326
|
_deferred_timers.pop(run_id, None)
|
|
327
|
+
try:
|
|
328
|
+
from . import store
|
|
329
|
+
run = store.get_run(run_id) or {}
|
|
330
|
+
deadline_at = run.get("deadline_at")
|
|
331
|
+
if deadline_at is not None and float(deadline_at) <= time.time():
|
|
332
|
+
store.update_run(run_id, expected_status="queued", status="timeout",
|
|
333
|
+
ended_at=_now(), error="任务总时限已到(排队等待期间)")
|
|
334
|
+
return
|
|
335
|
+
except (TypeError, ValueError, AttributeError):
|
|
336
|
+
pass
|
|
326
337
|
try:
|
|
327
338
|
started = _try_start_once(job)
|
|
328
339
|
except DuplicateJobError:
|
|
@@ -774,11 +785,19 @@ def requeue_pending(limit=10, max_age_s=None):
|
|
|
774
785
|
|
|
775
786
|
def workers_info():
|
|
776
787
|
with _pool_lock:
|
|
777
|
-
|
|
788
|
+
info = {"target": _target, "alive": _alive,
|
|
778
789
|
"chat_alive": _chat_alive, "chat_pool": CHAT_POOL,
|
|
779
|
-
"queued": 0,
|
|
780
790
|
"available": 0 if _restart_drain else max(0, _target - _alive),
|
|
781
791
|
"mode": "restart-drain" if _restart_drain else "direct"}
|
|
792
|
+
# queued runs are deliberately persisted rather than held in _QUEUE. Report
|
|
793
|
+
# the same source the board renders so capacity waits are observable.
|
|
794
|
+
try:
|
|
795
|
+
from . import store
|
|
796
|
+
info["queued"] = sum(1 for run in store.list_runs(None)
|
|
797
|
+
if run.get("status") == "queued")
|
|
798
|
+
except Exception:
|
|
799
|
+
info["queued"] = 0
|
|
800
|
+
return info
|
|
782
801
|
|
|
783
802
|
|
|
784
803
|
def _drain_test_queue():
|
|
@@ -949,7 +968,7 @@ def _ai_repair(run_id, entry, ev, failed_cmd, orig_log):
|
|
|
949
968
|
.replace("__OS__", _repair_os_label())
|
|
950
969
|
.replace("__ALLOW__", " / ".join(p.strip() for p in _repair_allow())))
|
|
951
970
|
step, log_abs = store.add_step(run_id, "ai-repair", agent["id"], agent.get("label"),
|
|
952
|
-
note="自动诊断修复")
|
|
971
|
+
note="自动诊断修复", model=agent.get("model") or "")
|
|
953
972
|
res = runner.run_agent(agent, prompt, readonly=True, timeout=300,
|
|
954
973
|
cancel_event=ev, log_path=str(log_abs))
|
|
955
974
|
try:
|
package/app/core/knowledge.py
CHANGED
|
@@ -97,7 +97,7 @@ _NEAR_DUP_SIM = 0.8 # 同 scope 标题包含度 ≥0.8 视为同一条(近
|
|
|
97
97
|
|
|
98
98
|
|
|
99
99
|
def upsert_entry(scope, title, body, tags=None, source="", source_file="",
|
|
100
|
-
as_of=None, status="draft"):
|
|
100
|
+
as_of=None, status="draft", confidence=None):
|
|
101
101
|
"""写入/合并一条知识。同 scope 同标题视为同一条(指纹去重):
|
|
102
102
|
- 已有条目是 approved 且新条是 draft:不覆盖正文(人工确认过的内容优先),
|
|
103
103
|
新内容记入 revisions 作为修订候选,seen+1;
|
|
@@ -145,6 +145,8 @@ def upsert_entry(scope, title, body, tags=None, source="", source_file="",
|
|
|
145
145
|
it["tags"] = (it.get("tags") or [])[:8]
|
|
146
146
|
if as_of:
|
|
147
147
|
it["as_of"] = str(as_of)[:10]
|
|
148
|
+
if confidence: # 合并时也可提升/回落置信度(high 覆盖 medium 合理)
|
|
149
|
+
it["confidence"] = confidence
|
|
148
150
|
if source:
|
|
149
151
|
it["source"] = source
|
|
150
152
|
if source_file:
|
|
@@ -156,6 +158,7 @@ def upsert_entry(scope, title, body, tags=None, source="", source_file="",
|
|
|
156
158
|
"tags": tags, "status": status, "enabled": True,
|
|
157
159
|
"source": source, "source_file": source_file,
|
|
158
160
|
"as_of": str(as_of or _now()[:10])[:10],
|
|
161
|
+
"confidence": confidence or "medium",
|
|
159
162
|
"revisions": [], "hits": 0, "seen": 1,
|
|
160
163
|
"created_at": _now(), "updated_at": _now(), "kind": "knowledge"}
|
|
161
164
|
items.append(it)
|
|
@@ -277,10 +280,17 @@ KNOWLEDGE_PROMPT = """你是编排系统的知识管理员。下面是一次任
|
|
|
277
280
|
- fact:回答「是什么」,只含领域事实、外部规则和可验证结论,进入知识库;
|
|
278
281
|
- practice:回答「怎么做」,含流程、操作方法和正向实践,进入经验库;此类必须填写 category。
|
|
279
282
|
「下次要避免什么」这类负面教训由评审复盘流程负责,不要重复提炼。
|
|
283
|
+
|
|
284
|
+
**可信度分级(引用核验借鉴)**:每条 fact 必须给出 confidence:
|
|
285
|
+
- "high":产出材料中有明确来源/数据支撑(引用了来源、给了数字口径);
|
|
286
|
+
- "medium":材料内部自洽但未标来源(模型整理所得);
|
|
287
|
+
- "low":你不确定、可能过时或与材料其他部分矛盾——这类宁可不要,直接丢弃。
|
|
288
|
+
fact 缺 confidence 视为 medium。
|
|
289
|
+
|
|
280
290
|
只输出一个 ```json 代码块,不要输出其他内容。JSON 结构:
|
|
281
|
-
{"entries": [{"kind": "fact 或 practice", "title": "≤20 字的标题", "body": "具体内容(≤200 字,自含上下文,脱离本次任务也能看懂)", "category": "practice 的经验分类", "tags": ["1-3 个检索标签"], "as_of": "YYYY-MM-DD(fact 的事实采集日)"}]}
|
|
291
|
+
{"entries": [{"kind": "fact 或 practice", "title": "≤20 字的标题", "body": "具体内容(≤200 字,自含上下文,脱离本次任务也能看懂)", "category": "practice 的经验分类", "tags": ["1-3 个检索标签"], "as_of": "YYYY-MM-DD(fact 的事实采集日)", "confidence": "high/medium/low(fact 必填)"}]}
|
|
282
292
|
category 只能从以下枚举选择:__CATEGORIES__。
|
|
283
|
-
最多 3
|
|
293
|
+
最多 3 条,只保留有明确复用价值的;low 置信的直接丢弃;产出里没有值得沉淀的就返回空数组。
|
|
284
294
|
|
|
285
295
|
## 任务类型
|
|
286
296
|
__TYPE__
|
|
@@ -381,12 +391,19 @@ def learn_from_run(run_id):
|
|
|
381
391
|
dim="%s %s" % (x["title"], x["body"])):
|
|
382
392
|
n += 1
|
|
383
393
|
continue
|
|
394
|
+
# 可信度分级(引用核验借鉴):low 直接丢弃(宁缺毋滥),
|
|
395
|
+
# 其余归一 high/medium 并随条目落盘;缺失视为 medium
|
|
396
|
+
conf = str(x.get("confidence") or "medium").strip().lower()
|
|
397
|
+
if conf not in ("high", "medium"):
|
|
398
|
+
if conf == "low":
|
|
399
|
+
continue
|
|
400
|
+
conf = "medium"
|
|
384
401
|
as_of = str(x.get("as_of") or "").strip()
|
|
385
402
|
if not re.match(r"^\d{4}-\d{2}-\d{2}$", as_of):
|
|
386
403
|
as_of = _now()[:10]
|
|
387
404
|
if upsert_entry(task.get("type") or "*", x["title"], x["body"],
|
|
388
405
|
tags=x.get("tags"), source=run_id,
|
|
389
|
-
as_of=as_of, status="approved"):
|
|
406
|
+
as_of=as_of, status="approved", confidence=conf):
|
|
390
407
|
n += 1
|
|
391
408
|
except Exception:
|
|
392
409
|
return n
|