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.
@@ -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 = store.create_task(payload)
303
- run = store.create_run("orchestration", task["title"], task_id=task["id"])
304
- store.update_task_status(task["id"], "queued")
305
- jobs.enqueue({"kind": "orchestration", "run_id": run["id"], "task_id": task["id"]})
306
- return run["id"]
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):
@@ -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;用 py -3.13 安装,模型经其配置/环境变量设置",
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": "py -3.13 -m pip install -U aider-chat",
82
- "upgrade": "py -3.13 -m pip install -U aider-chat",
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
@@ -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:
@@ -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
- provs = {p.get("name"): p for p in modelhub.providers()}
303
- prov = provs.get(st.get("name")) or (modelhub.providers() and next(
304
- (p for p in modelhub.providers() if p.get("id") == pid), None))
305
- if not prov or not prov.get("api_key"):
306
- return False # 供应商被停用/删除:无法探测,保持状态
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 ok:
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: