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.
@@ -75,12 +75,13 @@ DEFAULT_CATALOG = [
75
75
  },
76
76
  {
77
77
  "id": "aider", "name": "Aider", "cli_group": "installable",
78
- "note": "Python 系结对编程 CLI;用 py -3.13 安装,模型经其配置/环境变量设置",
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": "py -3.13 -m pip install -U aider-chat",
83
- "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",
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
@@ -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:
@@ -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
- provs = {p.get("name"): p for p in modelhub.providers()}
260
- prov = provs.get(st.get("name")) or (modelhub.providers() and next(
261
- (p for p in modelhub.providers() if p.get("id") == pid), None))
262
- if not prov or not prov.get("api_key"):
263
- 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 # 供应商在但没密钥:无法探测,保持状态
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 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:
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 只能是本机包管理器的安装命令,前缀必须是 npm install / winget install /
28
- py -3.13 -m pip install 之一。给不出符合约束的安全命令时,safe 设为 false 且 command 留空。
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
- AI_REPAIR_ALLOW = ("npm install ", "winget install", "py -3.13 -m pip install")
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=("完成" if ok else "失败") + (": " + res["error"][:300] if res.get("error") else ""),
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
- log_abs.write_text(
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",
@@ -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})