codebee 0.1.6 → 0.1.8
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 +18 -0
- package/README.md +415 -421
- package/app/core/automation.py +30 -5
- package/app/core/catalog.py +45 -3
- 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 +55 -61
- package/app/core/jobs.py +104 -9
- package/app/core/manager.py +101 -11
- package/app/core/modelhub.py +2952 -2896
- package/app/core/paths.py +1 -0
- package/app/core/pipeline.py +425 -99
- package/app/core/remote.py +310 -303
- package/app/core/runner.py +148 -13
- package/app/core/selfupdate.py +47 -16
- package/app/core/settings.py +9 -2
- package/app/core/step_runner.py +28 -4
- package/app/core/store.py +160 -28
- package/app/core/telemetry.py +291 -0
- package/app/core/token_meter.py +18 -0
- package/app/core/usage.py +51 -1
- package/app/main.py +379 -37
- package/app/pick_dialog.py +78 -0
- package/app/ui/app.js +1006 -266
- package/app/ui/i18n.js +107 -7
- package/app/ui/index.html +148 -55
- package/app/ui/style.css +1816 -2
- package/package.json +1 -1
package/app/core/automation.py
CHANGED
|
@@ -299,11 +299,36 @@ def _launch_run(t):
|
|
|
299
299
|
time.strftime("%m-%d %H:%M"))).strip()[:40],
|
|
300
300
|
"goal": t.get("prompt") or "",
|
|
301
301
|
"workdir": (t.get("workdir") or "").strip()}
|
|
302
|
-
task =
|
|
303
|
-
run =
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
302
|
+
task = None
|
|
303
|
+
run = None
|
|
304
|
+
try:
|
|
305
|
+
task = store.create_task(payload)
|
|
306
|
+
run = store.create_run("orchestration", task["title"], task_id=task["id"])
|
|
307
|
+
store.update_task_status(task["id"], "queued")
|
|
308
|
+
jobs.enqueue({"kind": "orchestration", "run_id": run["id"],
|
|
309
|
+
"task_id": task["id"]})
|
|
310
|
+
return run["id"]
|
|
311
|
+
except Exception:
|
|
312
|
+
# A scheduler failure must not leave a task/run that looks queued forever.
|
|
313
|
+
# Keep the public error generic; the detailed traceback stays in the
|
|
314
|
+
# service log and the run record remains useful for diagnostics.
|
|
315
|
+
log.exception("automation: 运行入队失败 task=%s run=%s",
|
|
316
|
+
(task or {}).get("id"), (run or {}).get("id"))
|
|
317
|
+
if run:
|
|
318
|
+
try:
|
|
319
|
+
store.update_run(run["id"], status="failed",
|
|
320
|
+
error="任务入队失败,请稍后重试",
|
|
321
|
+
ended_at=time.strftime("%Y-%m-%d %H:%M:%S"))
|
|
322
|
+
except Exception:
|
|
323
|
+
log.exception("automation: 运行失败收口失败 run=%s", run.get("id"))
|
|
324
|
+
elif task:
|
|
325
|
+
# create_run may fail before a run is available. The task was
|
|
326
|
+
# already persisted by create_task, so mark it failed as well.
|
|
327
|
+
try:
|
|
328
|
+
store.update_task_status(task["id"], "failed")
|
|
329
|
+
except Exception:
|
|
330
|
+
log.exception("automation: 任务失败收口失败 task=%s", task.get("id"))
|
|
331
|
+
raise
|
|
307
332
|
|
|
308
333
|
|
|
309
334
|
def _fire(snapshot, now):
|
package/app/core/catalog.py
CHANGED
|
@@ -10,6 +10,7 @@ from __future__ import annotations
|
|
|
10
10
|
import copy
|
|
11
11
|
import json
|
|
12
12
|
import re
|
|
13
|
+
import sys
|
|
13
14
|
import threading
|
|
14
15
|
|
|
15
16
|
from . import paths
|
|
@@ -74,12 +75,13 @@ DEFAULT_CATALOG = [
|
|
|
74
75
|
},
|
|
75
76
|
{
|
|
76
77
|
"id": "aider", "name": "Aider", "cli_group": "installable",
|
|
77
|
-
"note": "Python 系结对编程 CLI;用
|
|
78
|
+
"note": "Python 系结对编程 CLI;用 uv tool 安装(自动备好 3.12 托管解释器),"
|
|
79
|
+
"模型经其配置/环境变量设置",
|
|
78
80
|
"detect": {"cli": "aider"},
|
|
79
81
|
"orch": {"kind": "aider", "command": "aider"},
|
|
80
82
|
"config": {"path": "~/.aider.conf.yml", "format": None, "model_key": None},
|
|
81
|
-
"install": "
|
|
82
|
-
"upgrade": "
|
|
83
|
+
"install": "uv tool install --python 3.12 aider-chat",
|
|
84
|
+
"upgrade": "uv tool upgrade aider-chat",
|
|
83
85
|
"default_enabled": False,
|
|
84
86
|
},
|
|
85
87
|
{
|
|
@@ -216,6 +218,16 @@ CONFIG_PATCH = {
|
|
|
216
218
|
}
|
|
217
219
|
|
|
218
220
|
|
|
221
|
+
# 安装命令平台修正({id: (install, upgrade)}):仅非 Windows 套用。winget 是
|
|
222
|
+
# Windows 包管理器、py 启动器 Windows 独有,这两类命令在 macOS/Linux 上必失败
|
|
223
|
+
# ——load() 幂等改写为等价的 npm/pip 渠道(与 CONFIG_PATCH 同策略:覆盖无风险,
|
|
224
|
+
# 旧数据不修正的话每次点安装/升级都注定失败)。
|
|
225
|
+
INSTALL_PATCH_NONWIN = {
|
|
226
|
+
"claude-code": ("npm install -g @anthropic-ai/claude-code",
|
|
227
|
+
"npm install -g @anthropic-ai/claude-code@latest"),
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
|
|
219
231
|
def npm_pkg_name(cmd):
|
|
220
232
|
"""从 npm 安装命令里取包名(支持 @scope/name@latest)。
|
|
221
233
|
|
|
@@ -256,6 +268,13 @@ def derive_uninstall(cmd):
|
|
|
256
268
|
if pkgs:
|
|
257
269
|
head = m.group(1).replace("pip install", "pip uninstall")
|
|
258
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]
|
|
259
278
|
return None
|
|
260
279
|
|
|
261
280
|
|
|
@@ -289,6 +308,28 @@ def _apply_config_patch(entries):
|
|
|
289
308
|
e["config"] = dict(patch)
|
|
290
309
|
|
|
291
310
|
|
|
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
|
|
325
|
+
if sys.platform == "win32":
|
|
326
|
+
return
|
|
327
|
+
for e in entries:
|
|
328
|
+
patch = INSTALL_PATCH_NONWIN.get(e.get("id"))
|
|
329
|
+
if patch:
|
|
330
|
+
e["install"], e["upgrade"] = patch
|
|
331
|
+
|
|
332
|
+
|
|
292
333
|
def _merge_new_defaults(entries):
|
|
293
334
|
"""把内置默认里「新增的」条目补进已加载清单(同 id 已存在则原样保留)。
|
|
294
335
|
|
|
@@ -321,6 +362,7 @@ def load(force=False):
|
|
|
321
362
|
_apply_resume_patch(entries)
|
|
322
363
|
_apply_launch_patch(entries)
|
|
323
364
|
_apply_config_patch(entries)
|
|
365
|
+
_apply_install_patch(entries)
|
|
324
366
|
_CACHE["entries"] = entries
|
|
325
367
|
return entries
|
|
326
368
|
|
|
@@ -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
|
@@ -59,6 +59,17 @@ def init(data_dir=None):
|
|
|
59
59
|
_PROVIDERS[name] = st
|
|
60
60
|
except (OSError, json.JSONDecodeError):
|
|
61
61
|
pass
|
|
62
|
+
# 「绑定链·」静态告警胶囊已从产品移除(2026-09-18:普通用户误伤,
|
|
63
|
+
# 用户拍板)——老版本持久化的条目启动即清,别让 0.1.6 的状态阴魂不散。
|
|
64
|
+
stale = [k for k in _PROVIDERS if k.startswith("绑定链·")]
|
|
65
|
+
if stale:
|
|
66
|
+
for k in stale:
|
|
67
|
+
_PROVIDERS.pop(k, None)
|
|
68
|
+
_persist()
|
|
69
|
+
# 供应商已删除的幽灵条目启动即清;运行期新增的幽灵由探针在探活时
|
|
70
|
+
# 发现(gone 标记)随手清——探针循环里不做全量扫描,避免和测试
|
|
71
|
+
# 夹具/运行期建条目赛跑。
|
|
72
|
+
_prune_missing()
|
|
62
73
|
if not _STARTED:
|
|
63
74
|
_STARTED = True
|
|
64
75
|
t = threading.Thread(target=_probe_loop, name="health-probe", daemon=True)
|
|
@@ -159,56 +170,6 @@ def report_failure(provider: str, error: str = "", *, model: str = "",
|
|
|
159
170
|
|
|
160
171
|
# ------------------------------------------------- 静态死链告警(绑定层)
|
|
161
172
|
|
|
162
|
-
def report_binding_dead(key: str, error: str = ""):
|
|
163
|
-
"""静态死链告警:某 CLI 的绑定链在起跑前就已全部失效(厂商停用/删除/无密钥、
|
|
164
|
-
模型停用、协议不匹配)。与运行期 report_failure 的区别:不计数、不进探针,
|
|
165
|
-
立即置 down+告警(尊重静默);绑定恢复后由 report_binding_ok 自动解除。
|
|
166
|
-
伪供应商名带「绑定链·」前缀,不会与真实厂商名相撞。"""
|
|
167
|
-
if not key:
|
|
168
|
-
return
|
|
169
|
-
with _LOCK:
|
|
170
|
-
name = "绑定链·%s" % key
|
|
171
|
-
st = _PROVIDERS.get(name)
|
|
172
|
-
if st is None:
|
|
173
|
-
st = _PROVIDERS.setdefault(name, {
|
|
174
|
-
"name": name, "provider_id": "", "model": "",
|
|
175
|
-
"status": "ok", "consecutive_failures": 0,
|
|
176
|
-
"first_fail_at": 0, "last_fail_at": 0, "last_ok_at": 0,
|
|
177
|
-
"last_error": "", "alerted": False, "silenced": False,
|
|
178
|
-
"silence_until": 0, "probe_next_at": 0, "probe_backoff_idx": 0,
|
|
179
|
-
"recovered_at": 0,
|
|
180
|
-
})
|
|
181
|
-
st["static"] = True
|
|
182
|
-
st["binding_key"] = str(key)
|
|
183
|
-
st["name"] = name
|
|
184
|
-
st["status"] = "down"
|
|
185
|
-
st["last_fail_at"] = _now()
|
|
186
|
-
st["last_error"] = str(error or "")[:300]
|
|
187
|
-
st["probe_next_at"] = 0
|
|
188
|
-
if not _effective_silenced(st) and not st.get("alerted"):
|
|
189
|
-
st["alerted"] = True
|
|
190
|
-
log.warning("[health] ⚠️ 绑定链告警:%s 全部失效(%s)",
|
|
191
|
-
name, st["last_error"][:120])
|
|
192
|
-
_persist()
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
def report_binding_ok(key: str):
|
|
196
|
-
"""绑定链恢复可用:自动解除该 CLI 的静态死链告警(无记录时零开销)。"""
|
|
197
|
-
if not key:
|
|
198
|
-
return
|
|
199
|
-
with _LOCK:
|
|
200
|
-
st = _PROVIDERS.get("绑定链·%s" % key)
|
|
201
|
-
if not st or not st.get("static"):
|
|
202
|
-
return
|
|
203
|
-
st["status"] = "recovered"
|
|
204
|
-
st["alerted"] = False
|
|
205
|
-
st["silenced"] = False
|
|
206
|
-
st["silence_until"] = 0
|
|
207
|
-
st["recovered_at"] = _now()
|
|
208
|
-
log.info("[health] 绑定链 %s 已恢复(静态告警解除)", st["name"])
|
|
209
|
-
_persist()
|
|
210
|
-
|
|
211
|
-
|
|
212
173
|
# ---------------------------------------------------------------- 手动操作
|
|
213
174
|
|
|
214
175
|
def silence(provider: str, minutes: int = 0):
|
|
@@ -294,16 +255,20 @@ def down_names() -> set:
|
|
|
294
255
|
# ---------------------------------------------------------------- 探针
|
|
295
256
|
|
|
296
257
|
def _probe_one(st):
|
|
297
|
-
"""对单个故障 provider
|
|
258
|
+
"""对单个故障 provider 发轻量探活。返回 (ok, gone):
|
|
259
|
+
ok=是否恢复;gone=供应商已从配置里删除(健康记录应随之清掉)。"""
|
|
298
260
|
try:
|
|
299
261
|
from . import modelhub
|
|
300
262
|
pid = st.get("provider_id") or ""
|
|
301
263
|
model = st.get("model") or ""
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
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 # 供应商在但没密钥:无法探测,保持状态
|
|
307
272
|
if not model:
|
|
308
273
|
try:
|
|
309
274
|
names = modelhub._enabled_models(prov)
|
|
@@ -311,12 +276,36 @@ def _probe_one(st):
|
|
|
311
276
|
except Exception:
|
|
312
277
|
model = ""
|
|
313
278
|
if not model:
|
|
314
|
-
return False
|
|
279
|
+
return False, False
|
|
315
280
|
res = modelhub.chat(prov["id"], model, "1", max_tokens=8, timeout=20)
|
|
316
|
-
return bool(res.get("ok"))
|
|
281
|
+
return bool(res.get("ok")), False
|
|
317
282
|
except Exception as e:
|
|
318
283
|
log.debug("probe %s error: %s", st.get("name"), e)
|
|
319
|
-
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))
|
|
320
309
|
|
|
321
310
|
|
|
322
311
|
def _probe_loop():
|
|
@@ -333,8 +322,13 @@ def _probe_loop():
|
|
|
333
322
|
st["probe_next_at"] = _now() + backoff
|
|
334
323
|
st["probe_backoff_idx"] = idx + 1
|
|
335
324
|
for name, st in targets:
|
|
336
|
-
ok = _probe_one(st)
|
|
337
|
-
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:
|
|
338
332
|
report_success(name)
|
|
339
333
|
log.info("[health] 探针确认 %s 已恢复", name)
|
|
340
334
|
else:
|