codebee 0.1.73 → 0.1.74
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 +6 -0
- package/README.md +3 -3
- package/app/core/analytics.py +38 -0
- package/app/core/bookmeta.py +0 -14
- package/app/core/contracts.py +255 -0
- package/app/core/error_codes.py +9 -0
- package/app/core/health.py +14 -1
- package/app/core/jobs.py +50 -0
- package/app/core/manager.py +38 -14
- package/app/core/modelhub.py +104 -9
- package/app/core/openapi.py +154 -0
- package/app/core/pipeline.py +41 -0
- package/app/core/presence.py +77 -0
- package/app/core/publish/auto.py +11 -0
- package/app/core/retrieval.py +137 -0
- package/app/core/runner.py +85 -2
- package/app/core/store.py +72 -0
- package/app/core/story_tracking.py +241 -0
- package/app/main.py +181 -20
- package/app/mcp_server.py +75 -6
- package/app/ui/app.js +129 -31
- package/app/ui/i18n.js +19 -5
- package/app/ui/index.html +5 -0
- package/app/ui/style.css +10 -0
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -38,6 +38,12 @@ README 元数据带回,供老版本在「发现新版本」时展示新版更
|
|
|
38
38
|
- 调度层加固:绑定链上的「纯模型条目」(只配模型名、没挂供应商)起跑前即被点名——所有 CLI 起跑日志新增凭据模式审计行(绑定链注入/无注入/本机默认,密钥值绝不入日志),缺凭据+登录闸门类错误按 CLI 附修复指引;不再只有 claude 有这层观测(2026-09-25 Mac claude 实案的三层加固通用化)。
|
|
39
39
|
- **Mac 桌宠不再是「白板蜜蜂」**:macOS 的 Tk 没有逐像素窗口透明(`-transparentcolor` 是 Windows 专属),蜜蜂的矩形底板整个露出来还带系统边框(实拍=一块白板)。现在非 Windows 平台把底板画成深色卡片(对齐悬停清单面板色),桌宠呈「桌面小组件」形态;睡觉/失联的状态滤镜改为只作用于蜂体(原先整帧压暗靠键色噪点兜底,卡片底兜不回来会浮出一块更暗的小框)。Windows 逐像素透明不受影响。
|
|
40
40
|
|
|
41
|
+
## v0.1.74(2026-09-30)
|
|
42
|
+
|
|
43
|
+
- 新增 `TOOL_LAUNCH` 错误码:本地 CLI 启动失败(找不到可执行文件)与供应商故障彻底区分——此前这类失败被记成供应商健康故障,把健康的 Bigmodel 评测作废、整链判死(claude 垫片被清实案)
|
|
44
|
+
- runner:Popen 异常统一包裹前缀「启动失败」零误判识别,先于一切上游分类
|
|
45
|
+
- 评测/重探间隔与失败探针刷新调优
|
|
46
|
+
|
|
41
47
|
## v0.1.73(2026-09-30)
|
|
42
48
|
|
|
43
49
|
- **建书假「已建书」三修**(我的马甲藏不住实案):建书未取得 book_id 先按书名对账补回、仍空则**不落台账**(空条目曾让界面亮「已建书」);校准遇空 book_id 先对账、找不回明报「可能尚未建成」,不再盲跑作品列表页误报「可能改版」;七猫补齐对账通道(resolve_book_id)
|
package/README.md
CHANGED
|
@@ -33,10 +33,10 @@ Trae Agent……),提供
|
|
|
33
33
|
---
|
|
34
34
|
|
|
35
35
|
<!-- relnotes:start -->
|
|
36
|
-
### 最新版更新内容(v0.1.
|
|
36
|
+
### 最新版更新内容(v0.1.74)
|
|
37
37
|
|
|
38
|
-
-
|
|
39
|
-
-
|
|
38
|
+
- 新增 TOOL_LAUNCH 错误码:本地 CLI 启动失败与供应商故障彻底区分,不再误作废健康评测
|
|
39
|
+
- 评测/重探间隔与失败探针刷新调优
|
|
40
40
|
<!-- relnotes:end -->
|
|
41
41
|
|
|
42
42
|
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
"""Task cost/quality rollups for operator views."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from collections import defaultdict
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def summary(store):
|
|
8
|
+
by_task = defaultdict(lambda: {"runs": 0, "done": 0, "failed": 0,
|
|
9
|
+
"tokens": 0, "cost_usd": 0.0, "quality_sum": 0.0,
|
|
10
|
+
"quality_count": 0})
|
|
11
|
+
for run in store.list_runs(limit=None):
|
|
12
|
+
tid = run.get("task_id") or "_unowned"
|
|
13
|
+
row = by_task[tid]
|
|
14
|
+
row["runs"] += 1
|
|
15
|
+
row["done"] += int(run.get("status") == "done")
|
|
16
|
+
row["failed"] += int(run.get("status") == "failed")
|
|
17
|
+
row["tokens"] += int(run.get("tokens") or 0)
|
|
18
|
+
row["cost_usd"] += float(run.get("cost_usd") or 0)
|
|
19
|
+
verdict = run.get("verdict") or {}
|
|
20
|
+
quality = verdict.get("overall")
|
|
21
|
+
if isinstance(quality, (int, float)):
|
|
22
|
+
row["quality_sum"] += float(quality)
|
|
23
|
+
row["quality_count"] += 1
|
|
24
|
+
rows = []
|
|
25
|
+
for task_id, row in by_task.items():
|
|
26
|
+
item = dict(row)
|
|
27
|
+
item["task_id"] = task_id
|
|
28
|
+
item["cost_usd"] = round(item["cost_usd"], 6)
|
|
29
|
+
item["quality_avg"] = round(item.pop("quality_sum") / item.pop("quality_count"), 2) \
|
|
30
|
+
if item["quality_count"] else None
|
|
31
|
+
item.pop("quality_count", None)
|
|
32
|
+
rows.append(item)
|
|
33
|
+
rows.sort(key=lambda x: (-x["cost_usd"], x["task_id"]))
|
|
34
|
+
return {"tasks": rows, "totals": {
|
|
35
|
+
"runs": sum(x["runs"] for x in rows),
|
|
36
|
+
"tokens": sum(x["tokens"] for x in rows),
|
|
37
|
+
"cost_usd": round(sum(x["cost_usd"] for x in rows), 6),
|
|
38
|
+
}}
|
package/app/core/bookmeta.py
CHANGED
|
@@ -31,20 +31,6 @@ def template_only():
|
|
|
31
31
|
return str(os.environ.get("TUTTI_BOOKMETA_TEMPLATE_ONLY") or "").strip() in ("1", "true", "yes")
|
|
32
32
|
|
|
33
33
|
|
|
34
|
-
def needs_book_meta(task):
|
|
35
|
-
"""这本书是否需要开书资料:连载首批(start_chapter <= 1)才需要。
|
|
36
|
-
|
|
37
|
-
开书资料属于「这本书」而不是某一批章节——续写批次沿用第一批的书名/简介/
|
|
38
|
-
标签即可,重复生成只会覆盖成同内容的近义版本。非连载任务一律不需要。"""
|
|
39
|
-
serial = (task or {}).get("serial")
|
|
40
|
-
if not isinstance(serial, dict):
|
|
41
|
-
return False
|
|
42
|
-
try:
|
|
43
|
-
return int(serial.get("start_chapter") or 1) <= 1
|
|
44
|
-
except (TypeError, ValueError):
|
|
45
|
-
return True
|
|
46
|
-
|
|
47
|
-
|
|
48
34
|
# 各平台建书表单的字段展示顺序(前端卡片与 Markdown 归档共用)。
|
|
49
35
|
# 番茄官方表单(2026-09 实抓):签约模式=连载模式/完本模式;阅读标签=主分类(必选1)
|
|
50
36
|
# +主题/角色/情节(各≤2);内容标签=情节(≤4)/情感(≤2)/人设(≤4)/世界观(≤1)。
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
"""Durable task contracts, evidence, approvals and completion receipts."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import re
|
|
6
|
+
import threading
|
|
7
|
+
import time
|
|
8
|
+
import uuid
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from . import paths
|
|
12
|
+
|
|
13
|
+
_DIR = paths.DATA_DIR / "contracts"
|
|
14
|
+
_LOCK = threading.RLock()
|
|
15
|
+
_ID_RE = re.compile(r"^[A-Za-z][0-9A-Za-z_-]{0,99}$")
|
|
16
|
+
_CRITERIA_MAX = 40
|
|
17
|
+
_EVIDENCE_MAX = 300
|
|
18
|
+
_MISSING = object()
|
|
19
|
+
_CASE_KEYWORDS = {
|
|
20
|
+
"correctness": ("正确", "验证", "测试", "质量", "correct", "test", "verify"),
|
|
21
|
+
"performance": ("性能", "耗时", "成本", "token", "performance", "latency", "cost"),
|
|
22
|
+
"boundary": ("边界", "极端", "boundary", "edge"),
|
|
23
|
+
"exception": ("异常", "失败处理", "容错", "exception", "error", "failure"),
|
|
24
|
+
"readability": ("可读", "文档", "表达", "readability", "documentation"),
|
|
25
|
+
"security": ("安全", "权限", "注入", "security", "auth", "injection"),
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _valid_id(task_id):
|
|
30
|
+
return bool(_ID_RE.fullmatch(str(task_id or "")))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _path(task_id):
|
|
34
|
+
if not _valid_id(task_id):
|
|
35
|
+
raise ValueError("任务 ID 无效")
|
|
36
|
+
return _DIR / (str(task_id) + ".json")
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _now():
|
|
40
|
+
return time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _save(item):
|
|
44
|
+
p = _path(item["task_id"])
|
|
45
|
+
p.parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
tmp = p.with_suffix(".tmp")
|
|
47
|
+
tmp.write_text(json.dumps(item, ensure_ascii=False, indent=2), encoding="utf-8")
|
|
48
|
+
tmp.replace(p)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def _load(task_id):
|
|
52
|
+
try:
|
|
53
|
+
value = json.loads(_path(task_id).read_text(encoding="utf-8"))
|
|
54
|
+
return value if isinstance(value, dict) else None
|
|
55
|
+
except (OSError, ValueError):
|
|
56
|
+
return None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _actor(value):
|
|
60
|
+
return str(value or "local").strip()[:120] or "local"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def create(task_id, payload=None):
|
|
64
|
+
"""Create or update a task's contract, preserving evidence and audit history."""
|
|
65
|
+
payload = payload if isinstance(payload, dict) else {}
|
|
66
|
+
raw_criteria = payload.get("acceptance_criteria", _MISSING)
|
|
67
|
+
if raw_criteria is not _MISSING and not isinstance(raw_criteria, list):
|
|
68
|
+
raise ValueError("acceptance_criteria 必须是列表")
|
|
69
|
+
clean = []
|
|
70
|
+
for value in (raw_criteria if raw_criteria is not _MISSING else [])[:_CRITERIA_MAX]:
|
|
71
|
+
text = str(value or "").strip()[:500]
|
|
72
|
+
if text and text not in clean:
|
|
73
|
+
clean.append(text)
|
|
74
|
+
with _LOCK:
|
|
75
|
+
item = _load(task_id) or {
|
|
76
|
+
"version": 1, "task_id": str(task_id), "created_at": _now(),
|
|
77
|
+
"acceptance_criteria": [], "evidence": [], "approval_required": False,
|
|
78
|
+
"approval": None, "blocked_reason": "", "completion_receipt": None,
|
|
79
|
+
"audit": [], "status": "open",
|
|
80
|
+
}
|
|
81
|
+
# Partial updates must not erase an existing contract field merely
|
|
82
|
+
# because the caller omitted it (MCP/UI frequently send sparse patches).
|
|
83
|
+
if raw_criteria is not _MISSING:
|
|
84
|
+
item["acceptance_criteria"] = clean
|
|
85
|
+
if "approval_required" in payload:
|
|
86
|
+
approval_required = payload.get("approval_required")
|
|
87
|
+
if not isinstance(approval_required, bool):
|
|
88
|
+
raise ValueError("approval_required 必须是布尔值")
|
|
89
|
+
item["approval_required"] = approval_required
|
|
90
|
+
item["updated_at"] = _now()
|
|
91
|
+
item["audit"].append({"at": item["updated_at"], "actor": _actor(payload.get("actor")),
|
|
92
|
+
"action": "contract_updated",
|
|
93
|
+
"criteria_count": len(item.get("acceptance_criteria") or [])})
|
|
94
|
+
_save(item)
|
|
95
|
+
return item
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def get(task_id):
|
|
99
|
+
with _LOCK:
|
|
100
|
+
return _load(task_id)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def add_evidence(task_id, evidence, actor="local"):
|
|
104
|
+
if not isinstance(evidence, dict):
|
|
105
|
+
raise ValueError("证据必须是对象")
|
|
106
|
+
status = str(evidence.get("status") or "unknown").lower()
|
|
107
|
+
if status not in ("passed", "failed", "unknown", "not_evaluated"):
|
|
108
|
+
raise ValueError("证据状态无效")
|
|
109
|
+
with _LOCK:
|
|
110
|
+
item = _load(task_id)
|
|
111
|
+
if not item:
|
|
112
|
+
item = create(task_id, {})
|
|
113
|
+
row = {"id": "ev-%s" % uuid.uuid4().hex[:20],
|
|
114
|
+
"criterion": str(evidence.get("criterion") or "")[:500], "status": status,
|
|
115
|
+
"summary": str(evidence.get("summary") or "")[:2000],
|
|
116
|
+
"source": str(evidence.get("source") or "manual")[:80], "at": _now(),
|
|
117
|
+
"actor": _actor(actor)}
|
|
118
|
+
item["evidence"].append(row)
|
|
119
|
+
item["evidence"] = item["evidence"][-_EVIDENCE_MAX:]
|
|
120
|
+
item["audit"].append({"at": row["at"], "actor": row["actor"],
|
|
121
|
+
"action": "evidence_added", "evidence_id": row["id"]})
|
|
122
|
+
item["updated_at"] = row["at"]
|
|
123
|
+
_save(item)
|
|
124
|
+
return item
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def record_acceptance_cases(task_id, cases, actor="system"):
|
|
128
|
+
"""Map acceptance-matrix cases onto matching user criteria.
|
|
129
|
+
|
|
130
|
+
Matching is deliberately conservative: only criteria containing a known
|
|
131
|
+
case keyword (or the explicit ``acceptance:<case>`` form) are linked. Any
|
|
132
|
+
unmatched criterion remains unverified rather than being guessed green.
|
|
133
|
+
"""
|
|
134
|
+
if not isinstance(cases, list):
|
|
135
|
+
return get(task_id)
|
|
136
|
+
item = get(task_id)
|
|
137
|
+
if not item:
|
|
138
|
+
return None
|
|
139
|
+
criteria = item.get("acceptance_criteria") or []
|
|
140
|
+
for case in cases:
|
|
141
|
+
if not isinstance(case, dict):
|
|
142
|
+
continue
|
|
143
|
+
name = str(case.get("case") or "").strip().lower()
|
|
144
|
+
status = str(case.get("status") or "unknown").lower()
|
|
145
|
+
if name not in _CASE_KEYWORDS or status not in ("passed", "failed", "unknown", "not_evaluated"):
|
|
146
|
+
continue
|
|
147
|
+
for criterion in criteria:
|
|
148
|
+
text = str(criterion or "").strip()
|
|
149
|
+
low = text.lower()
|
|
150
|
+
explicit = low == "acceptance:" + name
|
|
151
|
+
matched = explicit or any(keyword.lower() in low for keyword in _CASE_KEYWORDS[name])
|
|
152
|
+
if not matched:
|
|
153
|
+
continue
|
|
154
|
+
add_evidence(task_id, {"criterion": text, "status": status,
|
|
155
|
+
"summary": case.get("reason") or
|
|
156
|
+
"acceptance matrix: " + name,
|
|
157
|
+
"source": "acceptance"}, actor=actor)
|
|
158
|
+
return get(task_id)
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def _decision(task_id, status, actor, note):
|
|
162
|
+
with _LOCK:
|
|
163
|
+
item = _load(task_id)
|
|
164
|
+
if not item:
|
|
165
|
+
return None, "任务契约不存在"
|
|
166
|
+
decision = {"status": status, "actor": _actor(actor),
|
|
167
|
+
"note": str(note or "").strip()[:1000], "at": _now()}
|
|
168
|
+
item["approval"] = decision
|
|
169
|
+
item["status"] = "approved" if status == "approved" else "open"
|
|
170
|
+
item["audit"].append(dict(decision, action=status))
|
|
171
|
+
item["updated_at"] = decision["at"]
|
|
172
|
+
_save(item)
|
|
173
|
+
return item, None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def approve(task_id, actor="local", note=""):
|
|
177
|
+
return _decision(task_id, "approved", actor, note)
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
def reject(task_id, actor="local", note=""):
|
|
181
|
+
if not str(note or "").strip():
|
|
182
|
+
return None, "拒绝审批必须说明原因"
|
|
183
|
+
return _decision(task_id, "rejected", actor, note)
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def set_blocked(task_id, reason, actor="local"):
|
|
187
|
+
reason = str(reason or "").strip()[:1000]
|
|
188
|
+
if not reason:
|
|
189
|
+
return None, "Blocked 状态必须填写原因"
|
|
190
|
+
with _LOCK:
|
|
191
|
+
item = _load(task_id)
|
|
192
|
+
if not item:
|
|
193
|
+
return None, "任务契约不存在"
|
|
194
|
+
item["status"] = "blocked"
|
|
195
|
+
item["blocked_reason"] = reason
|
|
196
|
+
item["updated_at"] = _now()
|
|
197
|
+
item["audit"].append({"at": item["updated_at"], "actor": _actor(actor),
|
|
198
|
+
"action": "blocked", "reason": reason})
|
|
199
|
+
_save(item)
|
|
200
|
+
return item, None
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def release_allowed(task_id):
|
|
204
|
+
item = get(task_id)
|
|
205
|
+
# Task creation persists the approval bit redundantly for backwards
|
|
206
|
+
# compatibility. Combine both sources with OR so a damaged/tampered
|
|
207
|
+
# sidecar cannot downgrade an approval-protected task to releasable.
|
|
208
|
+
try:
|
|
209
|
+
from . import store
|
|
210
|
+
task = store.get_task(task_id) or {}
|
|
211
|
+
except Exception:
|
|
212
|
+
task = {}
|
|
213
|
+
requires = bool((item or {}).get("approval_required")) or bool(task.get("approval_required"))
|
|
214
|
+
if not requires:
|
|
215
|
+
return True
|
|
216
|
+
if item is None:
|
|
217
|
+
return False
|
|
218
|
+
approval = item.get("approval") or {}
|
|
219
|
+
return approval.get("status") == "approved"
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def complete(task_id, run):
|
|
223
|
+
if not isinstance(run, dict) or run.get("status") not in ("done", "failed", "cancelled", "timeout"):
|
|
224
|
+
return None, "完成回执只能关联终态运行"
|
|
225
|
+
with _LOCK:
|
|
226
|
+
item = _load(task_id)
|
|
227
|
+
if not item:
|
|
228
|
+
item = create(task_id, {})
|
|
229
|
+
criteria = item.get("acceptance_criteria") or []
|
|
230
|
+
evidence = item.get("evidence") or []
|
|
231
|
+
latest = {}
|
|
232
|
+
for row in evidence:
|
|
233
|
+
if row.get("criterion"):
|
|
234
|
+
latest[row["criterion"]] = row
|
|
235
|
+
checked = [latest.get(c) for c in criteria]
|
|
236
|
+
receipt = {"version": 1, "task_id": str(task_id), "run_id": run.get("id"),
|
|
237
|
+
"run_status": run.get("status"), "criteria_total": len(criteria),
|
|
238
|
+
"criteria_passed": sum(1 for row in checked if row and row.get("status") == "passed"),
|
|
239
|
+
"criteria_failed": sum(1 for row in checked if row and row.get("status") == "failed"),
|
|
240
|
+
"criteria_unverified": sum(1 for row in checked if not row or row.get("status") not in ("passed", "failed")),
|
|
241
|
+
"evidence_ids": [row.get("id") for row in checked if row],
|
|
242
|
+
"cost_usd": float(run.get("cost_usd") or 0), "tokens": int(run.get("tokens") or 0),
|
|
243
|
+
"completed_at": _now()}
|
|
244
|
+
# Completion is idempotent for the same run. A late duplicate status
|
|
245
|
+
# write must not manufacture a second audit event or mutate the
|
|
246
|
+
# original cost/token receipt.
|
|
247
|
+
previous = item.get("completion_receipt") or {}
|
|
248
|
+
if previous.get("run_id") == receipt["run_id"]:
|
|
249
|
+
return previous, None
|
|
250
|
+
item["completion_receipt"] = receipt
|
|
251
|
+
item["audit"].append({"at": receipt["completed_at"], "actor": "system",
|
|
252
|
+
"action": "completion_receipt_created", "run_id": run.get("id")})
|
|
253
|
+
item["updated_at"] = receipt["completed_at"]
|
|
254
|
+
_save(item)
|
|
255
|
+
return receipt, None
|
package/app/core/error_codes.py
CHANGED
|
@@ -37,6 +37,7 @@ class ErrorCode(str, Enum):
|
|
|
37
37
|
RATE_LIMIT = "RATE_LIMIT" # 短时速率/并发限制(429)
|
|
38
38
|
LOCAL_STATE = "LOCAL_STATE" # 本地 CLI 状态/锁冲突
|
|
39
39
|
CONTEXT_OVERFLOW = "CONTEXT_OVERFLOW" # 触发压缩(与 MAX_TOKENS 不同)
|
|
40
|
+
TOOL_LAUNCH = "TOOL_LAUNCH" # 本地 CLI 启动失败(找不到可执行文件)
|
|
40
41
|
|
|
41
42
|
# === ok ===
|
|
42
43
|
# "" 表示成功,不设枚举值
|
|
@@ -91,6 +92,11 @@ _NETWORK = ("stream disconnected", "stream closed", "connection reset", "connect
|
|
|
91
92
|
"输出停滞")
|
|
92
93
|
_LOCAL_STATE = ("database is locked", "database locked", "sqlite busy",
|
|
93
94
|
"unable to open database file")
|
|
95
|
+
# run_process 对 Popen 异常的统一包裹前缀("启动失败: %r")。供应商侧任何
|
|
96
|
+
# 报错都不会长这个样子,所以单靠前缀即可零误判地识别「本地工具链起不来」。
|
|
97
|
+
# 2026-09-30 claude 垫片被清案:这类失败被记成供应商健康故障,把健康的
|
|
98
|
+
# Bigmodel 评测作废、整链判死——必须先于一切上游分类识别。
|
|
99
|
+
_TOOL_LAUNCH = ("启动失败",)
|
|
94
100
|
_TRANSIENT_HINTS = ("stream closed before response.completed", "output stall", "timed out",
|
|
95
101
|
"timeout", "output stalled", "ENOTFOUND", "Reconnecting...",
|
|
96
102
|
"initialize", "[1211]")
|
|
@@ -116,6 +122,8 @@ def classify_error_text(error):
|
|
|
116
122
|
text = str(error or "").lower()
|
|
117
123
|
if not text:
|
|
118
124
|
return None
|
|
125
|
+
if any(marker in text for marker in _TOOL_LAUNCH):
|
|
126
|
+
return ErrorCode.TOOL_LAUNCH
|
|
119
127
|
if re.search(r"(?:http(?:/\d(?:\.\d)?)?\s*|status(?:\s+code)?\s*[:=]?\s*)401\b", text):
|
|
120
128
|
return ErrorCode.AUTH
|
|
121
129
|
# An explicit HTTP 403 is authoritative even when the provider describes the
|
|
@@ -196,6 +204,7 @@ _SAFE_ERROR_SUMMARIES = {
|
|
|
196
204
|
ErrorCode.RATE_LIMIT: "上游触发速率或并发限制",
|
|
197
205
|
ErrorCode.LOCAL_STATE: "CLI 本地状态或数据库锁冲突",
|
|
198
206
|
ErrorCode.CONTEXT_OVERFLOW: "输入上下文超出模型容量",
|
|
207
|
+
ErrorCode.TOOL_LAUNCH: "本地 CLI 启动失败(可执行文件不存在,与供应商无关)",
|
|
199
208
|
}
|
|
200
209
|
|
|
201
210
|
|
package/app/core/health.py
CHANGED
|
@@ -143,9 +143,22 @@ def report_success(provider: str):
|
|
|
143
143
|
|
|
144
144
|
def report_failure(provider: str, error: str = "", *, model: str = "",
|
|
145
145
|
provider_id: str = ""):
|
|
146
|
-
"""一次真实失败调用。provider 用展示名(与 usage 台账一致)。
|
|
146
|
+
"""一次真实失败调用。provider 用展示名(与 usage 台账一致)。
|
|
147
|
+
|
|
148
|
+
本地 CLI 启动失败(TOOL_LAUNCH:找不到可执行文件等)与供应商无关,
|
|
149
|
+
记进来会把健康供应商的评测结论作废、链被解析层过滤成死链——
|
|
150
|
+
2026-09-30 claude 垫片被清案:Bigmodel 全程健康却被连续启动失败
|
|
151
|
+
拖进 down。只打日志提醒修本地环境,不进健康台账。"""
|
|
147
152
|
if not provider:
|
|
148
153
|
return
|
|
154
|
+
try:
|
|
155
|
+
from .error_codes import ErrorCode, classify_error_text
|
|
156
|
+
if classify_error_text(error) == ErrorCode.TOOL_LAUNCH:
|
|
157
|
+
log.warning("[health] 本地 CLI 启动失败(与供应商 %s 无关,不记健康故障):%s",
|
|
158
|
+
provider, str(error or "")[:160])
|
|
159
|
+
return
|
|
160
|
+
except Exception:
|
|
161
|
+
pass
|
|
149
162
|
|
|
150
163
|
# A failed live call invalidates capability evidence for this upstream.
|
|
151
164
|
# Keep this hook lazy and best-effort: health telemetry must never make a
|
package/app/core/jobs.py
CHANGED
|
@@ -422,6 +422,56 @@ def _schedule_enqueue(job, delay_s):
|
|
|
422
422
|
return True
|
|
423
423
|
|
|
424
424
|
|
|
425
|
+
def enqueue_now(run_id):
|
|
426
|
+
"""立即重试:退避窗口内的续跑副本跳过等待马上入队(详情页按钮触发)。
|
|
427
|
+
|
|
428
|
+
返回 (ok, err)。先摘到点 Timer 再直接 enqueue——CAS 认领保证与 Timer
|
|
429
|
+
竞态时只有一方生效,Timer 抢先起跑按目的已达成处理。resume_enqueue_at
|
|
430
|
+
同步清空:副本一旦入队/起跑,UI 不该再显示「将于 HH:MM 自动续跑」;
|
|
431
|
+
满载转排队后由补跑心跳接管,巡检也把它当普通排队记录。跳过退避是
|
|
432
|
+
用户的主动选择(等效于手动重试),不受「别烧退避窗口」的巡检约束。"""
|
|
433
|
+
from . import store
|
|
434
|
+
run = store.get_run(run_id)
|
|
435
|
+
if not run:
|
|
436
|
+
return False, "运行不存在"
|
|
437
|
+
if run.get("status") != "queued":
|
|
438
|
+
return False, "该运行已不在排队等待中"
|
|
439
|
+
if not str(run.get("resume_enqueue_at") or ""):
|
|
440
|
+
return False, "该运行不在自动续跑等待中"
|
|
441
|
+
if str(run.get("kind") or "orchestration") != "orchestration" or not run.get("task_id"):
|
|
442
|
+
return False, "该运行不支持立即重试"
|
|
443
|
+
resume_at = str(run.get("resume_enqueue_at") or "")
|
|
444
|
+
with _timer_lock:
|
|
445
|
+
old = _deferred_timers.pop(run_id, None)
|
|
446
|
+
if old is not None:
|
|
447
|
+
old.cancel()
|
|
448
|
+
if store.update_run(run_id, resume_enqueue_at="") is None:
|
|
449
|
+
if old is not None:
|
|
450
|
+
_schedule_enqueue({"kind": "orchestration", "run_id": run_id,
|
|
451
|
+
"task_id": run.get("task_id")}, 1.0)
|
|
452
|
+
return False, "运行状态已变化,请刷新后重试"
|
|
453
|
+
try:
|
|
454
|
+
enqueue({"kind": "orchestration", "run_id": run_id,
|
|
455
|
+
"task_id": run.get("task_id")})
|
|
456
|
+
except DuplicateJobError:
|
|
457
|
+
pass # 到点 Timer 抢先起跑/运行已被取消接管:无需再排
|
|
458
|
+
except Exception as exc:
|
|
459
|
+
# A non-duplicate enqueue failure must not strand the run with the
|
|
460
|
+
# backoff marker cleared and its original timer removed.
|
|
461
|
+
current = store.get_run(run_id)
|
|
462
|
+
if current and current.get("status") == "queued":
|
|
463
|
+
store.update_run(run_id, resume_enqueue_at=resume_at)
|
|
464
|
+
try:
|
|
465
|
+
import time as _time
|
|
466
|
+
due = _time.mktime(_time.strptime(resume_at, "%Y-%m-%d %H:%M:%S"))
|
|
467
|
+
_schedule_enqueue({"kind": "orchestration", "run_id": run_id,
|
|
468
|
+
"task_id": run.get("task_id")}, max(0.0, due - _time.time()))
|
|
469
|
+
except Exception:
|
|
470
|
+
pass
|
|
471
|
+
return False, "立即重试失败,已恢复自动续跑:%s" % str(exc)[:160]
|
|
472
|
+
return True, ""
|
|
473
|
+
|
|
474
|
+
|
|
425
475
|
def restore_deferred_resumes(limit=None, now=None):
|
|
426
476
|
"""重建重启前的自动续跑退避 Timer;返回成功恢复的数量。"""
|
|
427
477
|
import time as _t
|
package/app/core/manager.py
CHANGED
|
@@ -589,6 +589,30 @@ def read_model(entry):
|
|
|
589
589
|
return None
|
|
590
590
|
|
|
591
591
|
|
|
592
|
+
def _atomic_write_bytes(path, data):
|
|
593
|
+
"""CLI 自家配置写盘统一走临时文件 + os.replace 原子替换。
|
|
594
|
+
|
|
595
|
+
就地 write_bytes 有「截断→重写」窗口:并行评审爆发期多个 CLI 同时被
|
|
596
|
+
拉起(bind_agent 先同步配置再 spawn),正在启动的 CLI 恰好读配置就会
|
|
597
|
+
读到半截文件——opencode 对残缺 jsonc 的反应正是打一行横幅就退出码 1
|
|
598
|
+
(2026-09-30 14:56 实案)。os.replace 原地换名,读者要么见旧要么见新。
|
|
599
|
+
"""
|
|
600
|
+
path = str(path)
|
|
601
|
+
d = os.path.dirname(path) or "."
|
|
602
|
+
fd, tmp = tempfile.mkstemp(prefix=os.path.basename(path) + ".",
|
|
603
|
+
suffix=".tmp", dir=d)
|
|
604
|
+
try:
|
|
605
|
+
with os.fdopen(fd, "wb") as fh:
|
|
606
|
+
fh.write(data)
|
|
607
|
+
os.replace(tmp, path)
|
|
608
|
+
except Exception:
|
|
609
|
+
try:
|
|
610
|
+
os.remove(tmp)
|
|
611
|
+
except OSError:
|
|
612
|
+
pass
|
|
613
|
+
raise
|
|
614
|
+
|
|
615
|
+
|
|
592
616
|
def write_model(entry, model):
|
|
593
617
|
"""写入默认模型(改动前自动备份 .bak)。支持 toml-line / toml-section /
|
|
594
618
|
json / json-path / jsonc / yaml-line。"""
|
|
@@ -623,7 +647,7 @@ def write_model(entry, model):
|
|
|
623
647
|
# settings.json、openclaw 缺失即安全默认——官方文档明确「缺失即
|
|
624
648
|
# 内置默认」);它正是用户层覆盖的落点,缺了按需创建
|
|
625
649
|
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
|
626
|
-
|
|
650
|
+
_atomic_write_bytes(path,b"{}" if fmt in ("json", "json-path", "jsonc") else b"")
|
|
627
651
|
if fmt == "jsonc":
|
|
628
652
|
# mimo(mimocode.jsonc)有注释,整体重解析会丢注释——复用
|
|
629
653
|
# _jsonc_set 做就地片段改写(只动目标键,其余原样保留)
|
|
@@ -634,7 +658,7 @@ def write_model(entry, model):
|
|
|
634
658
|
if not ok:
|
|
635
659
|
return {"ok": False,
|
|
636
660
|
"error": "jsonc 结构异常,未能就地写入 %s(已避免覆盖)" % path}
|
|
637
|
-
|
|
661
|
+
_atomic_write_bytes(path,new_text.encode("utf-8"))
|
|
638
662
|
elif fmt == "toml-line":
|
|
639
663
|
text = _read_text(path)
|
|
640
664
|
new_line = 'model = "%s"' % model
|
|
@@ -642,17 +666,17 @@ def write_model(entry, model):
|
|
|
642
666
|
text = re.sub(r'(?m)^\s*model\s*=\s*"[^"]*"', new_line, text)
|
|
643
667
|
else:
|
|
644
668
|
text = text.rstrip("\n") + "\n" + new_line + "\n"
|
|
645
|
-
|
|
669
|
+
_atomic_write_bytes(path,text.encode("utf-8"))
|
|
646
670
|
elif fmt == "toml-section":
|
|
647
671
|
text = _read_text(path, preserve_newlines=True)
|
|
648
672
|
text = _toml_write_value(text, *_dotted_key(cfg), value=model)
|
|
649
|
-
|
|
673
|
+
_atomic_write_bytes(path,text.encode("utf-8"))
|
|
650
674
|
elif fmt == "yaml-line":
|
|
651
675
|
# newline="" 关掉通用换行转换:文本层面看不出 \r\n 就会被静默改写成 LF,
|
|
652
676
|
# 用户的 Windows 配置不该因为写个模型名而整篇换行符被替换
|
|
653
677
|
text = _read_text(path, preserve_newlines=True)
|
|
654
678
|
text = _yaml_write_value(text, *_yaml_model_path(cfg), value=model)
|
|
655
|
-
|
|
679
|
+
_atomic_write_bytes(path,text.encode("utf-8"))
|
|
656
680
|
elif fmt == "json-path":
|
|
657
681
|
# openclaw(agents.defaults.model.primary):默认模型藏在嵌套对象里,
|
|
658
682
|
# 且 openclaw 对未知顶层键直接拒绝启动——绝不能写顶层 "model"
|
|
@@ -662,7 +686,7 @@ def write_model(entry, model):
|
|
|
662
686
|
except Exception:
|
|
663
687
|
return {"ok": False, "error": "配置文件不是合法 JSON,已中止(避免覆盖)"}
|
|
664
688
|
_json_path_set(data, (cfg.get("model_key") or "model").split("."), model)
|
|
665
|
-
|
|
689
|
+
_atomic_write_bytes(path,
|
|
666
690
|
json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8"))
|
|
667
691
|
else:
|
|
668
692
|
try:
|
|
@@ -673,7 +697,7 @@ def write_model(entry, model):
|
|
|
673
697
|
except Exception:
|
|
674
698
|
return {"ok": False, "error": "配置文件不是合法 JSON,已中止(避免覆盖)"}
|
|
675
699
|
data["model"] = model
|
|
676
|
-
|
|
700
|
+
_atomic_write_bytes(path,
|
|
677
701
|
json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8"))
|
|
678
702
|
return {"ok": True, "model": read_model(entry)}
|
|
679
703
|
except Exception as e:
|
|
@@ -835,7 +859,7 @@ def _sync_dsh_settings(entry, model, base_url):
|
|
|
835
859
|
if text2 != text:
|
|
836
860
|
if os.path.isfile(path):
|
|
837
861
|
shutil.copyfile(path, path + ".bak")
|
|
838
|
-
|
|
862
|
+
_atomic_write_bytes(path,text2.encode("utf-8"))
|
|
839
863
|
return None
|
|
840
864
|
except Exception as e:
|
|
841
865
|
return repr(e)
|
|
@@ -992,7 +1016,7 @@ def _sync_codex_settings(entry, model, cp):
|
|
|
992
1016
|
if text2 != text:
|
|
993
1017
|
if os.path.isfile(path):
|
|
994
1018
|
shutil.copyfile(path, path + ".bak")
|
|
995
|
-
|
|
1019
|
+
_atomic_write_bytes(path,text2.encode("utf-8"))
|
|
996
1020
|
return None
|
|
997
1021
|
except Exception as e:
|
|
998
1022
|
return repr(e)
|
|
@@ -1209,7 +1233,7 @@ def _sync_settings_env(path, updates, remove_keys=()):
|
|
|
1209
1233
|
data["env"] = env
|
|
1210
1234
|
if os.path.isfile(path):
|
|
1211
1235
|
shutil.copyfile(path, path + ".bak")
|
|
1212
|
-
|
|
1236
|
+
_atomic_write_bytes(path,json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8"))
|
|
1213
1237
|
return None
|
|
1214
1238
|
except Exception as e:
|
|
1215
1239
|
return repr(e)
|
|
@@ -1321,7 +1345,7 @@ def _sync_mimo_settings(entry, model, prov):
|
|
|
1321
1345
|
json.dumps("codebee/" + model))
|
|
1322
1346
|
if not ok:
|
|
1323
1347
|
return "mimocode.jsonc 结构异常,未能写入 model(已避免覆盖)"
|
|
1324
|
-
|
|
1348
|
+
_atomic_write_bytes(path,new_text.encode("utf-8"))
|
|
1325
1349
|
return None
|
|
1326
1350
|
except OSError as e:
|
|
1327
1351
|
return "写入 mimocode.jsonc 失败:%s" % str(e)[:80]
|
|
@@ -1401,7 +1425,7 @@ def _sync_opencode_settings(entry, model, prov):
|
|
|
1401
1425
|
json.dumps(top_perms))
|
|
1402
1426
|
if os.path.isfile(path):
|
|
1403
1427
|
shutil.copyfile(path, path + ".bak")
|
|
1404
|
-
|
|
1428
|
+
_atomic_write_bytes(path,new_text.encode("utf-8"))
|
|
1405
1429
|
except Exception as e:
|
|
1406
1430
|
errs.append("%s: %r" % (path, e))
|
|
1407
1431
|
return ";".join(errs) or None
|
|
@@ -1475,7 +1499,7 @@ def _sync_kimi_settings(entry, model, prov):
|
|
|
1475
1499
|
new_text = top + "\n" + body + "\n" + tables
|
|
1476
1500
|
else:
|
|
1477
1501
|
new_text = (body + "\n" if body else "") + managed
|
|
1478
|
-
|
|
1502
|
+
_atomic_write_bytes(path,new_text.encode("utf-8"))
|
|
1479
1503
|
except Exception as e:
|
|
1480
1504
|
errs.append("%s: %r" % (path, e))
|
|
1481
1505
|
return ";".join(errs) or None
|
|
@@ -1516,7 +1540,7 @@ def _pi_write_json(path, mutate):
|
|
|
1516
1540
|
if os.path.isfile(path):
|
|
1517
1541
|
shutil.copyfile(path, path + ".bak")
|
|
1518
1542
|
Path(path).parent.mkdir(parents=True, exist_ok=True)
|
|
1519
|
-
|
|
1543
|
+
_atomic_write_bytes(path,
|
|
1520
1544
|
json.dumps(data, ensure_ascii=False, indent=2).encode("utf-8"))
|
|
1521
1545
|
return None
|
|
1522
1546
|
|