codebee 0.1.7 → 0.1.9
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 +20 -0
- package/README.md +236 -252
- package/app/core/catalog.py +24 -5
- package/app/core/errorlog.py +179 -0
- package/app/core/flows.py +6 -2
- package/app/core/gitmod.py +45 -1
- package/app/core/health.py +48 -11
- package/app/core/jobs.py +104 -9
- package/app/core/manager.py +21 -0
- package/app/core/modelhub.py +80 -11
- package/app/core/paths.py +1 -0
- package/app/core/pipeline.py +420 -88
- package/app/core/runner.py +122 -10
- package/app/core/settings.py +9 -2
- package/app/core/step_runner.py +28 -4
- package/app/core/store.py +180 -139
- package/app/core/telemetry.py +291 -0
- package/app/core/token_meter.py +18 -0
- package/app/main.py +162 -16
- package/app/pick_dialog.py +78 -0
- package/app/ui/app.js +1012 -604
- package/app/ui/i18n.js +79 -6
- package/app/ui/index.html +161 -80
- package/app/ui/style.css +838 -67
- package/package.json +1 -1
package/app/core/catalog.py
CHANGED
|
@@ -75,12 +75,13 @@ DEFAULT_CATALOG = [
|
|
|
75
75
|
},
|
|
76
76
|
{
|
|
77
77
|
"id": "aider", "name": "Aider", "cli_group": "installable",
|
|
78
|
-
"note": "Python 系结对编程 CLI;用
|
|
78
|
+
"note": "Python 系结对编程 CLI;用 uv tool 安装(自动备好 3.12 托管解释器),"
|
|
79
|
+
"模型经其配置/环境变量设置",
|
|
79
80
|
"detect": {"cli": "aider"},
|
|
80
81
|
"orch": {"kind": "aider", "command": "aider"},
|
|
81
82
|
"config": {"path": "~/.aider.conf.yml", "format": None, "model_key": None},
|
|
82
|
-
"install": "
|
|
83
|
-
"upgrade": "
|
|
83
|
+
"install": "uv tool install --python 3.12 aider-chat",
|
|
84
|
+
"upgrade": "uv tool upgrade aider-chat",
|
|
84
85
|
"default_enabled": False,
|
|
85
86
|
},
|
|
86
87
|
{
|
|
@@ -224,8 +225,6 @@ CONFIG_PATCH = {
|
|
|
224
225
|
INSTALL_PATCH_NONWIN = {
|
|
225
226
|
"claude-code": ("npm install -g @anthropic-ai/claude-code",
|
|
226
227
|
"npm install -g @anthropic-ai/claude-code@latest"),
|
|
227
|
-
"aider": ("python3 -m pip install -U aider-chat",
|
|
228
|
-
"python3 -m pip install -U aider-chat"),
|
|
229
228
|
}
|
|
230
229
|
|
|
231
230
|
|
|
@@ -269,6 +268,13 @@ def derive_uninstall(cmd):
|
|
|
269
268
|
if pkgs:
|
|
270
269
|
head = m.group(1).replace("pip install", "pip uninstall")
|
|
271
270
|
return "%s -y %s" % (head, pkgs[0])
|
|
271
|
+
m = re.search(r"uv\s+tool\s+install\b(.*)$", c)
|
|
272
|
+
if m:
|
|
273
|
+
# uv tool install 的包名在末尾(--python 3.12 这类 flag 的值不带横杠,
|
|
274
|
+
# 取最后一个非 flag token 才是包名)
|
|
275
|
+
pkgs = [t for t in m.group(1).split() if not t.startswith("-")]
|
|
276
|
+
if pkgs:
|
|
277
|
+
return "uv tool uninstall %s" % pkgs[-1]
|
|
272
278
|
return None
|
|
273
279
|
|
|
274
280
|
|
|
@@ -303,6 +309,19 @@ def _apply_config_patch(entries):
|
|
|
303
309
|
|
|
304
310
|
|
|
305
311
|
def _apply_install_patch(entries):
|
|
312
|
+
# Aider 渠道整体迁移到 uv tool(跨平台、含存量数据幂等修正):anaconda 的
|
|
313
|
+
# py -3.13 装 aider 必挂在 numpy 源码构建(老 setuptools 引用 py3.12 已删除
|
|
314
|
+
# 的 pkgutil.ImpImporter),而多数机器又没有 3.9-3.12 的 pip 解释器;uv 能
|
|
315
|
+
# 自带托管解释器一条命令装好。只迁移仍旧 pip 形态的配置,已是 uv 或用户
|
|
316
|
+
# 自定义的其他命令不动(2026-09-18 本机实装失败定版)。
|
|
317
|
+
aider_uv = ("uv tool install --python 3.12 aider-chat",
|
|
318
|
+
"uv tool upgrade aider-chat")
|
|
319
|
+
for e in entries:
|
|
320
|
+
if e.get("id") != "aider":
|
|
321
|
+
continue
|
|
322
|
+
cur = (e.get("install") or "").strip()
|
|
323
|
+
if "pip" in cur and "install" in cur:
|
|
324
|
+
e["install"], e["upgrade"] = aider_uv
|
|
306
325
|
if sys.platform == "win32":
|
|
307
326
|
return
|
|
308
327
|
for e in entries:
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
# -*- coding: utf-8 -*-
|
|
2
|
+
"""错误台账:把分散在各处的失败(步骤失败/超时/看门狗击杀、CLI 启动失败)
|
|
3
|
+
统一落成结构化记录——「客户遇到了问题我们根本不知道」的第一块拼图。
|
|
4
|
+
|
|
5
|
+
落盘:data/errors/errors-YYYYMM.jsonl(按月分文件,append-only,一行一记录)。
|
|
6
|
+
与 usage.py 同一套纪律:
|
|
7
|
+
- record() 任何异常都吞掉——埋点失败绝不能影响任务执行;
|
|
8
|
+
- 落盘前先脱敏(scrub_text):密钥/令牌/绝对路径/超长文本一概不进台账。
|
|
9
|
+
|
|
10
|
+
隐私红线(上传侧在 telemetry.py 再兜一道底):
|
|
11
|
+
1. API KEY / 凭据绝不入台账;
|
|
12
|
+
2. 任务正文 / 章节内容(用户版权内容)绝不入台账——detail 只允许失败原因摘录;
|
|
13
|
+
3. 绝对路径剥成相对片段。
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import re
|
|
19
|
+
import threading
|
|
20
|
+
import time
|
|
21
|
+
import uuid
|
|
22
|
+
|
|
23
|
+
from . import paths
|
|
24
|
+
|
|
25
|
+
LOCK = threading.RLock()
|
|
26
|
+
|
|
27
|
+
FIELDS = ("id", "ts", "day", "category", "reason", "detail", "provider",
|
|
28
|
+
"model", "tool", "role", "run_id", "task_id", "step",
|
|
29
|
+
"exit_code", "app_version", "os")
|
|
30
|
+
|
|
31
|
+
# detail 摘录上限(字符):失败原因足够,不要变成日志搬运
|
|
32
|
+
DETAIL_LIMIT = 600
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
# ---------------------------------------------------------------- 脱敏
|
|
36
|
+
|
|
37
|
+
# 常见密钥形态:OpenAI 系 sk- 前缀、Bearer 令牌、显式 key/secret/token 赋值
|
|
38
|
+
_KEY_PATTERNS = (
|
|
39
|
+
(re.compile(r"\bsk-[A-Za-z0-9_-]{8,}"), "[key]"),
|
|
40
|
+
(re.compile(r"\b(?:Bearer|bearer)\s+\S+"), "Bearer [key]"),
|
|
41
|
+
# 赋值/JSON 两种形态都要吃:api_key=xxx、api_key: "xxx"(冒号前可有闭引号)
|
|
42
|
+
(re.compile(r"(?i)\b((?:api[_-]?|access[_-]?|secret[_-]?|auth[_-]?)(?:key|token|secret))"
|
|
43
|
+
r"""["']?\s*[:=,,]\s*["']?[A-Za-z0-9._~+/=-]{8,}"""), r"\1[key]"),
|
|
44
|
+
# 裸长十六进制/64 位串(可能是凭据指纹)
|
|
45
|
+
(re.compile(r"\b[0-9a-fA-F]{40,}\b"), "[token]"),
|
|
46
|
+
)
|
|
47
|
+
|
|
48
|
+
# 绝对路径:Windows 盘符路径、UNC、POSIX 家目录——剥掉盘符/用户名只留尾部结构
|
|
49
|
+
_PATH_PATTERNS = (
|
|
50
|
+
(re.compile(r"(?i)\b[A-Z]:\\(?:[^\\/:*?\"<>|\r\n]+\\)*[^\\/:*?\"<>|\r\n]*"),
|
|
51
|
+
lambda m: "[path]" + m.group(0).split("\\")[-1]),
|
|
52
|
+
(re.compile(r"(?i)\b(?:\\\\[^\\\s]+\\[^\s]+)"), "[path]"),
|
|
53
|
+
(re.compile(r"(?:/Users/|/home/|~)[^\s\"':]+"), lambda m: "[path]" + m.group(0).rsplit("/", 1)[-1]),
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def scrub_text(text, limit=DETAIL_LIMIT):
|
|
58
|
+
"""自由文本 → 可安全落台账/上传的摘录:剥密钥、剥绝对路径、截断。
|
|
59
|
+
|
|
60
|
+
顺序有讲究:先剥密钥(可能出现在路径或赋值串里),再剥路径,最后截断。
|
|
61
|
+
任何输入(None/非字符串)都安全。
|
|
62
|
+
"""
|
|
63
|
+
if not isinstance(text, str):
|
|
64
|
+
text = "" if text is None else str(text)
|
|
65
|
+
out = text
|
|
66
|
+
for pat, rep in _KEY_PATTERNS:
|
|
67
|
+
out = pat.sub(rep, out)
|
|
68
|
+
for pat, rep in _PATH_PATTERNS:
|
|
69
|
+
try:
|
|
70
|
+
out = pat.sub(rep, out)
|
|
71
|
+
except Exception:
|
|
72
|
+
pass
|
|
73
|
+
out = out.strip()
|
|
74
|
+
if len(out) > limit:
|
|
75
|
+
out = out[:limit] + "…"
|
|
76
|
+
return out
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _coerce_str(v, limit):
|
|
80
|
+
return str(v or "")[:limit]
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _os_tag():
|
|
84
|
+
import sys
|
|
85
|
+
return {"win32": "windows", "darwin": "macos"}.get(sys.platform, sys.platform)
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _version():
|
|
89
|
+
try:
|
|
90
|
+
from . import selfupdate
|
|
91
|
+
return str(selfupdate.package_version() or "dev")
|
|
92
|
+
except Exception:
|
|
93
|
+
return "dev"
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def record(category="", reason="", detail="", provider="", model="", tool="",
|
|
97
|
+
role="", run_id="", task_id="", step=0, exit_code=None, source=""):
|
|
98
|
+
"""追加一条错误记录。缺省字段留空;任何异常都吞掉(埋点不拖垮业务)。
|
|
99
|
+
|
|
100
|
+
category: step / run / launch / service(枚举放宽,未知值原样入库)
|
|
101
|
+
reason: 失败原因码(error_codes.ErrorCode 的字符串值优先,如 TIMEOUT)
|
|
102
|
+
detail: 失败摘录(内部先过 scrub_text,绝不存原文)
|
|
103
|
+
"""
|
|
104
|
+
try:
|
|
105
|
+
rec = {
|
|
106
|
+
"id": "%s-%s" % (time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
107
|
+
uuid.uuid4().hex[:8]),
|
|
108
|
+
"ts": time.strftime("%Y-%m-%d %H:%M:%S"),
|
|
109
|
+
"day": time.strftime("%Y-%m-%d"),
|
|
110
|
+
"category": _coerce_str(category, 24) or "step",
|
|
111
|
+
"reason": _coerce_str(reason, 40) or "UNKNOWN",
|
|
112
|
+
"detail": scrub_text(detail),
|
|
113
|
+
"provider": _coerce_str(provider, 60),
|
|
114
|
+
"model": _coerce_str(model, 80),
|
|
115
|
+
"tool": _coerce_str(tool, 24),
|
|
116
|
+
"role": _coerce_str(role, 40),
|
|
117
|
+
"run_id": _coerce_str(run_id, 64),
|
|
118
|
+
"task_id": _coerce_str(task_id, 64),
|
|
119
|
+
"step": int(step or 0),
|
|
120
|
+
"exit_code": exit_code,
|
|
121
|
+
"app_version": _version()[:24],
|
|
122
|
+
"os": _os_tag(),
|
|
123
|
+
}
|
|
124
|
+
line = json.dumps(rec, ensure_ascii=False)
|
|
125
|
+
day = rec["day"]
|
|
126
|
+
with LOCK:
|
|
127
|
+
paths.ERRORS_DIR.mkdir(parents=True, exist_ok=True)
|
|
128
|
+
with open(paths.ERRORS_DIR / ("errors-%s.jsonl" % day[:7].replace("-", "")),
|
|
129
|
+
"a", encoding="utf-8") as f:
|
|
130
|
+
f.write(line + "\n")
|
|
131
|
+
except Exception:
|
|
132
|
+
pass
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def iter_records(days=0):
|
|
136
|
+
"""按时间范围读取台账(days=0 表示全部)。返回按写入顺序的记录列表。"""
|
|
137
|
+
out = []
|
|
138
|
+
try:
|
|
139
|
+
files = sorted(paths.ERRORS_DIR.glob("errors-*.jsonl")) if paths.ERRORS_DIR.is_dir() else []
|
|
140
|
+
except Exception:
|
|
141
|
+
return out
|
|
142
|
+
since_day = ""
|
|
143
|
+
if days:
|
|
144
|
+
import datetime
|
|
145
|
+
since_day = (datetime.date.today()
|
|
146
|
+
- datetime.timedelta(days=int(days) - 1)).isoformat()
|
|
147
|
+
for p in files:
|
|
148
|
+
try:
|
|
149
|
+
for line in p.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
150
|
+
line = line.strip()
|
|
151
|
+
if not line.startswith("{"):
|
|
152
|
+
continue
|
|
153
|
+
try:
|
|
154
|
+
r = json.loads(line)
|
|
155
|
+
except Exception:
|
|
156
|
+
continue
|
|
157
|
+
if not isinstance(r, dict):
|
|
158
|
+
continue
|
|
159
|
+
if since_day and str(r.get("day", "")) < since_day:
|
|
160
|
+
continue
|
|
161
|
+
out.append(r)
|
|
162
|
+
except Exception:
|
|
163
|
+
continue
|
|
164
|
+
return out
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def pending_since(cursor, limit=200):
|
|
168
|
+
"""取 cursor(上次上传到的记录 id)之后的记录,最多 limit 条。
|
|
169
|
+
|
|
170
|
+
id 以时间开头、uuid 尾巴保证唯一且字典序≈时间序;返回 (records, new_cursor)。
|
|
171
|
+
"""
|
|
172
|
+
try:
|
|
173
|
+
recs = [r for r in iter_records(0) if str(r.get("id") or "") > str(cursor or "")]
|
|
174
|
+
recs.sort(key=lambda r: str(r.get("id") or ""))
|
|
175
|
+
batch = recs[:max(1, int(limit))]
|
|
176
|
+
new_cursor = str(batch[-1].get("id")) if batch else str(cursor or "")
|
|
177
|
+
return batch, new_cursor
|
|
178
|
+
except Exception:
|
|
179
|
+
return [], str(cursor or "")
|
package/app/core/flows.py
CHANGED
|
@@ -23,7 +23,7 @@ ENGINES = ("code", "review", "direct")
|
|
|
23
23
|
# 引擎默认参数:自定义流程留空时的兜底
|
|
24
24
|
ENGINE_DEFAULTS = {
|
|
25
25
|
"review": {"manuscript": "output.md", "rubric": ["内容", "结构", "表达"],
|
|
26
|
-
"threshold": 7.0, "rounds": 2},
|
|
26
|
+
"threshold": 7.0, "rounds": 2, "best_of": 1},
|
|
27
27
|
"code": {"verify_command": ""},
|
|
28
28
|
# direct:无参数——目标+附件即全部输入,跑完即止
|
|
29
29
|
}
|
|
@@ -118,7 +118,7 @@ _ID_RE = re.compile(r"^[a-z][a-z0-9_-]{0,31}$")
|
|
|
118
118
|
# 自定义流程的 icon 在 upsert_flow 里直接落盘,不走 overrides。
|
|
119
119
|
_EDITABLE = ("name", "goal_hint", "note", "manuscript", "rubric",
|
|
120
120
|
"threshold", "rounds", "serial", "draft_prompt", "critique_prompt",
|
|
121
|
-
"verify_command")
|
|
121
|
+
"verify_command", "best_of")
|
|
122
122
|
|
|
123
123
|
|
|
124
124
|
def _read():
|
|
@@ -279,6 +279,10 @@ def upsert_flow(payload):
|
|
|
279
279
|
flow["rounds"] = max(1, min(5, int(payload.get("rounds") or dflt["rounds"])))
|
|
280
280
|
except Exception:
|
|
281
281
|
flow["rounds"] = dflt["rounds"]
|
|
282
|
+
try:
|
|
283
|
+
flow["best_of"] = max(1, min(3, int(payload.get("best_of") or dflt.get("best_of") or 1)))
|
|
284
|
+
except Exception:
|
|
285
|
+
flow["best_of"] = 1
|
|
282
286
|
serial = _norm_serial(payload.get("serial"))
|
|
283
287
|
if serial:
|
|
284
288
|
flow["serial"] = serial
|
package/app/core/gitmod.py
CHANGED
|
@@ -33,6 +33,11 @@ def _git(workdir, *args, timeout=20):
|
|
|
33
33
|
return r
|
|
34
34
|
|
|
35
35
|
|
|
36
|
+
def _gh(workdir, *args, timeout=60):
|
|
37
|
+
"""GitHub CLI 调用(pr_create 用);与 _git 同构,缺失时由调用方给可读错误。"""
|
|
38
|
+
return runner.run_process(argv=["gh", *args], cwd=str(workdir), timeout=timeout)
|
|
39
|
+
|
|
40
|
+
|
|
36
41
|
def _attach_entry(line):
|
|
37
42
|
"""status --porcelain 的一行是否为任务附件目录下的未跟踪项(不算脏改动)。"""
|
|
38
43
|
if not line.startswith("?? "):
|
|
@@ -837,7 +842,7 @@ def file_diff(workdir, path):
|
|
|
837
842
|
# 写操作白名单:action → 是否需要 confirm(不可恢复类)
|
|
838
843
|
_WB_ACTIONS = {"checkout", "fetch", "pull", "push", "stage", "unstage",
|
|
839
844
|
"discard", "delete", "commit", "stage_all", "unstage_all",
|
|
840
|
-
"stash_push", "stash_pop", "stash_drop"}
|
|
845
|
+
"stash_push", "stash_pop", "stash_drop", "pr_create"}
|
|
841
846
|
|
|
842
847
|
|
|
843
848
|
def _res(r, extra=None):
|
|
@@ -938,6 +943,45 @@ def workbench_op(workdir, action, params=None):
|
|
|
938
943
|
return _res(r)
|
|
939
944
|
return _res(_git(wd, "reset", "-q", timeout=60))
|
|
940
945
|
|
|
946
|
+
if action == "pr_create":
|
|
947
|
+
# 「创建 PR」(借鉴 agent-orchestrator 的 planning→merge 闭环):当前分支
|
|
948
|
+
# 推到 origin 后用 gh CLI 建 PR。gh 未安装/未登录给可读错误,不静默降级。
|
|
949
|
+
if info.get("detached") or not str(info.get("branch") or "").strip():
|
|
950
|
+
return False, "游离 HEAD 状态不能建 PR,请先切换到任务分支", {}
|
|
951
|
+
if not (info.get("remotes") or []):
|
|
952
|
+
return False, "该仓库没有配置远程(git remote),无法创建 PR", {}
|
|
953
|
+
if not _gh(wd, "--version", timeout=15)["ok"]:
|
|
954
|
+
return False, "未安装 gh CLI(https://cli.github.com/):安装并 gh auth login 后可用", {}
|
|
955
|
+
br = info["branch"].strip()
|
|
956
|
+
base = str(params.get("base") or "").strip()
|
|
957
|
+
if not base:
|
|
958
|
+
bset = set(info.get("branches") or []) | set(info.get("remote_branches") or [])
|
|
959
|
+
for cand in ("main", "master"):
|
|
960
|
+
if cand in bset:
|
|
961
|
+
base = cand
|
|
962
|
+
break
|
|
963
|
+
if not base or base == br:
|
|
964
|
+
return False, "无法确定 PR 目标分支(base),请显式指定 base 参数", {}
|
|
965
|
+
title = str(params.get("title") or "").strip()[:120] or ("PR: %s" % br)
|
|
966
|
+
body_text = str(params.get("body") or "").strip()[:2000]
|
|
967
|
+
r_push = _git(wd, "push", "-u", "origin", br, timeout=180)
|
|
968
|
+
if not r_push["ok"]:
|
|
969
|
+
return _res(r_push)
|
|
970
|
+
argv = ["pr", "create", "--base", base, "--head", br, "--title", title]
|
|
971
|
+
if body_text:
|
|
972
|
+
argv += ["--body", body_text]
|
|
973
|
+
else:
|
|
974
|
+
argv += ["--fill"] # 无描述时让 gh 用提交记录自动生成
|
|
975
|
+
r = _gh(wd, *argv, timeout=120)
|
|
976
|
+
ok, err, _d = _res(r)
|
|
977
|
+
if not ok:
|
|
978
|
+
low = (err or "").lower()
|
|
979
|
+
if "gh auth" in low or "authentic" in low or "log in" in low:
|
|
980
|
+
return False, "gh 未登录:请先在本机运行 gh auth login", {}
|
|
981
|
+
return False, (err or "gh pr create 失败")[:300], {}
|
|
982
|
+
lines = [ln.strip() for ln in (r.get("stdout") or "").splitlines() if ln.strip()]
|
|
983
|
+
return True, "", {"url": lines[-1] if lines else "", "branch": br, "base": base}
|
|
984
|
+
|
|
941
985
|
if action == "commit":
|
|
942
986
|
msg = str(params.get("message") or "").strip()[:500]
|
|
943
987
|
if not msg:
|
package/app/core/health.py
CHANGED
|
@@ -66,6 +66,10 @@ def init(data_dir=None):
|
|
|
66
66
|
for k in stale:
|
|
67
67
|
_PROVIDERS.pop(k, None)
|
|
68
68
|
_persist()
|
|
69
|
+
# 供应商已删除的幽灵条目启动即清;运行期新增的幽灵由探针在探活时
|
|
70
|
+
# 发现(gone 标记)随手清——探针循环里不做全量扫描,避免和测试
|
|
71
|
+
# 夹具/运行期建条目赛跑。
|
|
72
|
+
_prune_missing()
|
|
69
73
|
if not _STARTED:
|
|
70
74
|
_STARTED = True
|
|
71
75
|
t = threading.Thread(target=_probe_loop, name="health-probe", daemon=True)
|
|
@@ -251,16 +255,20 @@ def down_names() -> set:
|
|
|
251
255
|
# ---------------------------------------------------------------- 探针
|
|
252
256
|
|
|
253
257
|
def _probe_one(st):
|
|
254
|
-
"""对单个故障 provider
|
|
258
|
+
"""对单个故障 provider 发轻量探活。返回 (ok, gone):
|
|
259
|
+
ok=是否恢复;gone=供应商已从配置里删除(健康记录应随之清掉)。"""
|
|
255
260
|
try:
|
|
256
261
|
from . import modelhub
|
|
257
262
|
pid = st.get("provider_id") or ""
|
|
258
263
|
model = st.get("model") or ""
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
+
plist = modelhub.providers()
|
|
265
|
+
provs = {p.get("name"): p for p in plist}
|
|
266
|
+
prov = provs.get(st.get("name")) or next(
|
|
267
|
+
(p for p in plist if pid and p.get("id") == pid), None)
|
|
268
|
+
if not prov:
|
|
269
|
+
return False, True # 供应商已删除:探不了也不再是「没恢复」
|
|
270
|
+
if not prov.get("api_key"):
|
|
271
|
+
return False, False # 供应商在但没密钥:无法探测,保持状态
|
|
264
272
|
if not model:
|
|
265
273
|
try:
|
|
266
274
|
names = modelhub._enabled_models(prov)
|
|
@@ -268,12 +276,36 @@ def _probe_one(st):
|
|
|
268
276
|
except Exception:
|
|
269
277
|
model = ""
|
|
270
278
|
if not model:
|
|
271
|
-
return False
|
|
279
|
+
return False, False
|
|
272
280
|
res = modelhub.chat(prov["id"], model, "1", max_tokens=8, timeout=20)
|
|
273
|
-
return bool(res.get("ok"))
|
|
281
|
+
return bool(res.get("ok")), False
|
|
274
282
|
except Exception as e:
|
|
275
283
|
log.debug("probe %s error: %s", st.get("name"), e)
|
|
276
|
-
return False
|
|
284
|
+
return False, False
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def _prune_missing():
|
|
288
|
+
"""供应商被删除后清掉它的健康记录。
|
|
289
|
+
|
|
290
|
+
幽灵条目探不活也永远不会恢复,还会每轮把 last_fail_at 刷成「刚刚」,
|
|
291
|
+
健康页看起来像它一直在报错(Z.ai 删掉后仍显示 429 的误伤案,2026-09-18)。
|
|
292
|
+
"""
|
|
293
|
+
try:
|
|
294
|
+
from . import modelhub
|
|
295
|
+
plist = modelhub.providers()
|
|
296
|
+
except Exception:
|
|
297
|
+
return
|
|
298
|
+
ids = {p.get("id") for p in plist}
|
|
299
|
+
names = {p.get("name") for p in plist}
|
|
300
|
+
with _LOCK:
|
|
301
|
+
stale = [n for n, st in _PROVIDERS.items()
|
|
302
|
+
if not ((st.get("provider_id") and st.get("provider_id") in ids)
|
|
303
|
+
or st.get("name") in names)]
|
|
304
|
+
if stale:
|
|
305
|
+
for n in stale:
|
|
306
|
+
_PROVIDERS.pop(n, None)
|
|
307
|
+
_persist()
|
|
308
|
+
log.info("[health] 清除已删除供应商的健康记录:%s", ", ".join(stale))
|
|
277
309
|
|
|
278
310
|
|
|
279
311
|
def _probe_loop():
|
|
@@ -290,8 +322,13 @@ def _probe_loop():
|
|
|
290
322
|
st["probe_next_at"] = _now() + backoff
|
|
291
323
|
st["probe_backoff_idx"] = idx + 1
|
|
292
324
|
for name, st in targets:
|
|
293
|
-
ok = _probe_one(st)
|
|
294
|
-
if
|
|
325
|
+
ok, gone = _probe_one(st)
|
|
326
|
+
if gone:
|
|
327
|
+
with _LOCK:
|
|
328
|
+
if _PROVIDERS.pop(name, None) is not None:
|
|
329
|
+
_persist()
|
|
330
|
+
log.info("[health] %s 已删除,健康记录随之清除", name)
|
|
331
|
+
elif ok:
|
|
295
332
|
report_success(name)
|
|
296
333
|
log.info("[health] 探针确认 %s 已恢复", name)
|
|
297
334
|
else:
|
package/app/core/jobs.py
CHANGED
|
@@ -24,8 +24,8 @@ _seq = 0
|
|
|
24
24
|
AI_REPAIR_PROMPT = """你是环境工程师。在 Windows 上执行下面的安装命令失败了,请诊断原因并给出修正命令。
|
|
25
25
|
只输出一个 ```json 代码块,不要输出其他内容。JSON 结构:
|
|
26
26
|
{"diagnosis": "失败原因(一句话)", "command": "修正后的完整安装命令", "safe": true/false}
|
|
27
|
-
硬性约束:command 只能是本机包管理器的安装命令,前缀必须是
|
|
28
|
-
|
|
27
|
+
硬性约束:command 只能是本机包管理器的安装命令,前缀必须是 __ALLOW__ 之一。
|
|
28
|
+
给不出符合约束的安全命令时,safe 设为 false 且 command 留空。
|
|
29
29
|
|
|
30
30
|
## 失败的命令
|
|
31
31
|
__CMD__
|
|
@@ -36,8 +36,10 @@ __LOG__
|
|
|
36
36
|
## 本机环境
|
|
37
37
|
__ENV__"""
|
|
38
38
|
|
|
39
|
-
# AI
|
|
40
|
-
|
|
39
|
+
# AI 修复命令白名单:只放行包管理器的安装类命令(提示词里的前缀清单由它生成,
|
|
40
|
+
# 两处永远不会漂移)
|
|
41
|
+
AI_REPAIR_ALLOW = ("npm install ", "winget install", "py -3.13 -m pip install",
|
|
42
|
+
"uv tool install")
|
|
41
43
|
|
|
42
44
|
|
|
43
45
|
def _repair_command_allowed(cmd):
|
|
@@ -139,6 +141,20 @@ AUTO_RESUME_DELAY_S = 300 # 自动续跑延迟入队秒数:网关限流/欠
|
|
|
139
141
|
# 立即重排会撞在同一堵墙上把续跑次数烧光(2026-09-17 七猫实测)
|
|
140
142
|
|
|
141
143
|
|
|
144
|
+
def _task_active_run(task_id, exclude_run_id=None):
|
|
145
|
+
"""该任务当前 queued/running 的运行(同任务单飞守卫用);无则 None。"""
|
|
146
|
+
try:
|
|
147
|
+
from . import store
|
|
148
|
+
for r in store.task_runs(task_id):
|
|
149
|
+
if r.get("id") == exclude_run_id:
|
|
150
|
+
continue
|
|
151
|
+
if r.get("status") in ("queued", "running"):
|
|
152
|
+
return r
|
|
153
|
+
except Exception:
|
|
154
|
+
pass
|
|
155
|
+
return None
|
|
156
|
+
|
|
157
|
+
|
|
142
158
|
def _maybe_auto_resume(run_id):
|
|
143
159
|
"""连载任务失败自动续跑:继承已完成章继续,最多 AUTO_RESUME_MAX 次。
|
|
144
160
|
|
|
@@ -157,11 +173,19 @@ def _maybe_auto_resume(run_id):
|
|
|
157
173
|
return False # 用户主动取消的运行绝不自动续跑
|
|
158
174
|
if int(run.get("auto_resumes") or 0) >= AUTO_RESUME_MAX:
|
|
159
175
|
return False
|
|
176
|
+
if _task_active_run(task["id"], exclude_run_id=run_id):
|
|
177
|
+
return False # 同任务已有运行排队/在跑:再排副本只会与之撞车
|
|
160
178
|
ok, err, new_run = store.retry_task(task["id"])
|
|
161
179
|
if not ok or not new_run:
|
|
162
180
|
return False
|
|
181
|
+
# 退避窗口要让用户看得见:把「预定入队时刻」写到 run 上,前端据此显示
|
|
182
|
+
# 「将在 HH:MM 自动续跑」而不是笼统的排队中(run 在建好到入队之间会
|
|
183
|
+
# 以 queued 状态干等 AUTO_RESUME_DELAY_S 秒)。
|
|
184
|
+
import time as _t
|
|
185
|
+
resume_at = _t.strftime("%Y-%m-%d %H:%M:%S",
|
|
186
|
+
_t.localtime(_t.time() + AUTO_RESUME_DELAY_S))
|
|
163
187
|
store.update_run(new_run["id"], auto_resumes=int(run.get("auto_resumes") or 0) + 1,
|
|
164
|
-
auto_resumed_from=run_id)
|
|
188
|
+
auto_resumed_from=run_id, resume_enqueue_at=resume_at)
|
|
165
189
|
|
|
166
190
|
def _enqueue():
|
|
167
191
|
_QUEUE.put({"kind": "orchestration",
|
|
@@ -238,6 +262,54 @@ def resume_interrupted(limit=3):
|
|
|
238
262
|
return n
|
|
239
263
|
|
|
240
264
|
|
|
265
|
+
def _yield_duplicate(run_id):
|
|
266
|
+
"""同任务单飞(worker 出队时把关):系统续跑副本出队时若同任务已有
|
|
267
|
+
运行排队/在跑,取消自己让位——恢复副本与原轮并行跑只会双烧评审。
|
|
268
|
+
用户轮不在此拦(retry_task 建轮时已拒运行中任务)。返回 True 表示
|
|
269
|
+
本运行已落 cancelled,不要执行。"""
|
|
270
|
+
try:
|
|
271
|
+
from . import store
|
|
272
|
+
r0 = store.get_run(run_id)
|
|
273
|
+
if not r0 or not r0.get("auto_resumed_from"):
|
|
274
|
+
return False
|
|
275
|
+
other = _task_active_run(r0.get("task_id"), exclude_run_id=run_id)
|
|
276
|
+
if not other or other.get("kind") != "orchestration":
|
|
277
|
+
return False
|
|
278
|
+
store.update_run(run_id, status="cancelled", ended_at=_now(),
|
|
279
|
+
error="同任务已有运行在跑(%s),续跑副本自动让位" % other.get("id"))
|
|
280
|
+
return True
|
|
281
|
+
except Exception:
|
|
282
|
+
return False
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def requeue_pending(limit=10):
|
|
286
|
+
"""启动补队:队列在内存里,进程一死排队项就没人管了(2026-09-18
|
|
287
|
+
七猫 r-162724 排队僵尸案:续跑副本 created 后服务重启,Timer 随进程
|
|
288
|
+
蒸发,运行永远停在「排队中」)。重启后把近 24h 的遗留 queued 编排运行
|
|
289
|
+
重新入队;同任务已有在跑/排队的不重复补。返回补队条数。"""
|
|
290
|
+
try:
|
|
291
|
+
from . import store
|
|
292
|
+
except Exception:
|
|
293
|
+
return 0
|
|
294
|
+
n = 0
|
|
295
|
+
try:
|
|
296
|
+
for run in store.list_runs(200):
|
|
297
|
+
if n >= limit:
|
|
298
|
+
break
|
|
299
|
+
if run.get("status") != "queued" or run.get("kind") != "orchestration":
|
|
300
|
+
continue
|
|
301
|
+
if not run.get("task_id") or not _recent(run):
|
|
302
|
+
continue
|
|
303
|
+
if _task_active_run(run["task_id"], exclude_run_id=run["id"]):
|
|
304
|
+
continue # 同任务已有更活跃的运行,别再排一份
|
|
305
|
+
_QUEUE.put({"kind": "orchestration", "run_id": run["id"],
|
|
306
|
+
"task_id": run["task_id"]})
|
|
307
|
+
n += 1
|
|
308
|
+
except Exception:
|
|
309
|
+
pass
|
|
310
|
+
return n
|
|
311
|
+
|
|
312
|
+
|
|
241
313
|
def _worker():
|
|
242
314
|
global _alive
|
|
243
315
|
with _pool_lock:
|
|
@@ -262,6 +334,8 @@ def _worker():
|
|
|
262
334
|
r0 = store.get_run(run_id)
|
|
263
335
|
if r0 and r0.get("status") == "cancelled":
|
|
264
336
|
continue # task_done 由 finally 统一收口,不能在此重复
|
|
337
|
+
if job.get("kind") == "orchestration" and _yield_duplicate(run_id):
|
|
338
|
+
continue # 同任务单飞:续跑副本让位(已落 cancelled)
|
|
265
339
|
if job.get("kind") == "orchestration":
|
|
266
340
|
from . import pipeline
|
|
267
341
|
pipeline.execute_run(run_id)
|
|
@@ -314,12 +388,21 @@ def _do_mgmt(job, ev):
|
|
|
314
388
|
if op in ("install", "upgrade", "uninstall"):
|
|
315
389
|
res = manager.run_mgmt_command(entry, op, cancel_event=ev, log_path=str(log_abs))
|
|
316
390
|
ok = res["ok"]
|
|
391
|
+
# 文件占用类失败(Windows 文件锁 EBUSY/EPERM)给人话结论并跳过 AI 修复:
|
|
392
|
+
# 修复智能体面对文件锁只会给出 taskkill 全杀 node 之类白名单必拒的危险
|
|
393
|
+
# 命令,白白烧一轮 300s 诊断(2026-09-18 dsh 同版本重装 EBUSY 案)
|
|
394
|
+
lock_hit = (not ok and op in ("install", "upgrade") and _file_lock_error(res))
|
|
395
|
+
if lock_hit:
|
|
396
|
+
summary = ("失败:安装文件被占用(可能有同名程序在运行,或杀毒软件正在扫描),"
|
|
397
|
+
"请关闭占用该文件的程序后重试。完整输出见日志。")
|
|
398
|
+
else:
|
|
399
|
+
summary = ("完成" if ok else "失败") + (": " + res["error"][:300] if res.get("error") else "")
|
|
317
400
|
store.finish_step(run_id, step["n"],
|
|
318
401
|
"done" if ok else "failed",
|
|
319
|
-
summary=
|
|
402
|
+
summary=summary,
|
|
320
403
|
exit_code=res.get("exit_code"))
|
|
321
404
|
# AI 修复只针对安装类失败;卸载失败多为权限/程序占用,留给用户看日志处理
|
|
322
|
-
if not ok and op in ("install", "upgrade"):
|
|
405
|
+
if not ok and op in ("install", "upgrade") and not lock_hit:
|
|
323
406
|
ok = _ai_repair(run_id, entry, ev, entry.get(op), log_abs)
|
|
324
407
|
elif op == "smoke":
|
|
325
408
|
from . import runner as _r
|
|
@@ -383,6 +466,12 @@ def _do_selfupgrade(job):
|
|
|
383
466
|
summary="CodeBee selfupgrade %s" % ("完成" if res["ok"] else "失败"))
|
|
384
467
|
|
|
385
468
|
|
|
469
|
+
def _file_lock_error(res):
|
|
470
|
+
"""安装/升级失败输出是否为文件占用类错误(npm/pip 的 EBUSY/EPERM 文件锁)。"""
|
|
471
|
+
err = (res or {}).get("error") or ""
|
|
472
|
+
return "EBUSY" in err or "EPERM" in err
|
|
473
|
+
|
|
474
|
+
|
|
386
475
|
def _ai_repair(run_id, entry, ev, failed_cmd, orig_log):
|
|
387
476
|
"""安装失败后的 AI 诊断修复:诊断 → 白名单校验 → 执行 → 复检。"""
|
|
388
477
|
from . import catalog, manager, registry, router, runner, store
|
|
@@ -405,7 +494,8 @@ def _ai_repair(run_id, entry, ev, failed_cmd, orig_log):
|
|
|
405
494
|
]
|
|
406
495
|
prompt = (AI_REPAIR_PROMPT.replace("__CMD__", failed_cmd or "(未知)")
|
|
407
496
|
.replace("__LOG__", log_tail)
|
|
408
|
-
.replace("__ENV__", "\n".join(env_lines))
|
|
497
|
+
.replace("__ENV__", "\n".join(env_lines))
|
|
498
|
+
.replace("__ALLOW__", " / ".join(p.strip() for p in AI_REPAIR_ALLOW)))
|
|
409
499
|
step, log_abs = store.add_step(run_id, "ai-repair", agent["id"], agent.get("label"),
|
|
410
500
|
note="自动诊断修复")
|
|
411
501
|
res = runner.run_agent(agent, prompt, readonly=True, timeout=300,
|
|
@@ -430,7 +520,9 @@ def _ai_repair(run_id, entry, ev, failed_cmd, orig_log):
|
|
|
430
520
|
cmd = str((data or {}).get("command") or "").strip()
|
|
431
521
|
safe = bool((data or {}).get("safe")) and _repair_command_allowed(cmd)
|
|
432
522
|
try:
|
|
433
|
-
|
|
523
|
+
# write_bytes 而非 write_text(...encode()):bytes 传入文本模式 write 会
|
|
524
|
+
# TypeError 且被下面的裸 except 吞掉,诊断三行从未落过日志(2026-09-18 修)
|
|
525
|
+
log_abs.write_bytes(
|
|
434
526
|
("\n[AI 诊断] %s\n[AI 建议] %s\n[白名单] %s\n" %
|
|
435
527
|
(diagnosis, cmd or "(无)", "通过" if safe else "不通过,拒绝自动执行")).encode("utf-8"))
|
|
436
528
|
except Exception:
|
|
@@ -445,6 +537,9 @@ def _ai_repair(run_id, entry, ev, failed_cmd, orig_log):
|
|
|
445
537
|
manager.detect_all(force=True)
|
|
446
538
|
with manager._LOCK:
|
|
447
539
|
manager._STATE["versions"].pop(entry["id"], None)
|
|
540
|
+
if fix["ok"]:
|
|
541
|
+
# 修复安装同样会改变版本结论,徽章缓存一并作废复检
|
|
542
|
+
manager.refresh_update_async(entry)
|
|
448
543
|
installed = manager.detect_entry(entry)["installed"]
|
|
449
544
|
store.finish_step(run_id, step["n"],
|
|
450
545
|
"done" if (fix["ok"] and installed) else "failed",
|
package/app/core/manager.py
CHANGED
|
@@ -1576,10 +1576,31 @@ def run_mgmt_command(entry, op, cancel_event=None, log_path=None):
|
|
|
1576
1576
|
detect_all(force=True)
|
|
1577
1577
|
with _LOCK:
|
|
1578
1578
|
_STATE["versions"].pop(entry["id"], None)
|
|
1579
|
+
if res["ok"]:
|
|
1580
|
+
# 安装/升级成功即作废该条目的更新检查缓存并后台复检:否则 10 分钟 TTL
|
|
1581
|
+
# 内徽章仍显示「有新版本」,诱导同版本重装(重装易撞 EBUSY 文件锁,
|
|
1582
|
+
# 2026-09-18 dsh 案)。
|
|
1583
|
+
refresh_update_async(entry)
|
|
1579
1584
|
return {"ok": res["ok"], "exit_code": res["exit_code"], "command": cmd,
|
|
1580
1585
|
"error": "" if res["ok"] else (res["stderr"][-800:] or "退出码 %s" % res["exit_code"])}
|
|
1581
1586
|
|
|
1582
1587
|
|
|
1588
|
+
def refresh_update_async(entry):
|
|
1589
|
+
"""作废单条目的更新检查缓存,并后台立刻复检一次远端最新版本。
|
|
1590
|
+
|
|
1591
|
+
完成的安装/升级调用它,卡片徽章马上脱离旧结论(unknown → 几秒后
|
|
1592
|
+
「已是最新/有新版本」),不用等 10 分钟缓存过期。npm view 在后台线程
|
|
1593
|
+
跑,不阻塞 mgmt 任务收尾。
|
|
1594
|
+
"""
|
|
1595
|
+
with _LOCK:
|
|
1596
|
+
_UPDATE_CACHE.pop(entry["id"], None)
|
|
1597
|
+
try:
|
|
1598
|
+
threading.Thread(target=check_update, args=(entry,), kwargs={"force": True},
|
|
1599
|
+
name="update-recheck-%s" % entry.get("id"), daemon=True).start()
|
|
1600
|
+
except Exception:
|
|
1601
|
+
pass
|
|
1602
|
+
|
|
1603
|
+
|
|
1583
1604
|
# ---------------------------------------------------------------- 版本检查
|
|
1584
1605
|
|
|
1585
1606
|
_UPDATE_CACHE = {} # agent_id → (ts, {current, latest, updatable, note})
|