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/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",
@@ -9,7 +9,9 @@ from __future__ import annotations
9
9
  import json
10
10
  import os
11
11
  import re
12
+ import shlex
12
13
  import shutil
14
+ import signal
13
15
  import socket
14
16
  import subprocess
15
17
  import sys
@@ -21,7 +23,8 @@ from pathlib import Path
21
23
 
22
24
  from . import catalog, paths, runner
23
25
 
24
- CREATE_NO_WINDOW = 0x08000000
26
+ # Windows 置 0:POSIX 的 Popen 对非零 creationflags 抛 ValueError(runner 同款守卫)
27
+ CREATE_NO_WINDOW = 0x08000000 if os.name == "nt" else 0
25
28
  VERSION_TTL = 300 # 版本缓存 5 分钟
26
29
 
27
30
  _LOCK = threading.RLock()
@@ -69,6 +72,43 @@ def detect_entry(entry):
69
72
  return {"installed": False, "detail": ""}
70
73
 
71
74
 
75
+ def _orphan_signature(cl):
76
+ """「Tutti 专属调用签名」判定(两个平台的清扫共用)。"""
77
+ return (("opencode" in cl and "--model" in cl)
78
+ or ("codex" in cl and "--skip-git-repo-check" in cl)
79
+ or ("kimi-code" in cl and "main.mjs" in cl))
80
+
81
+
82
+ def _sweep_orphans_posix():
83
+ """POSIX 版清扫:ps 一次拉全量,签名同 Windows。孤儿判据不同——POSIX 的
84
+ 孤儿进程会被内核过继给 1 号进程(launchd/init),Windows 则保留死掉的
85
+ ppid,所以这里认 ppid==1;用户手动在终端里跑的同名 CLI 父进程是活着的
86
+ shell(ppid!=1),不会被误杀。"""
87
+ try:
88
+ r = subprocess.run(["ps", "-eo", "pid=,ppid=,command="],
89
+ capture_output=True, timeout=60)
90
+ killed = 0
91
+ for ln in r.stdout.decode("utf-8", "replace").splitlines():
92
+ parts = ln.strip().split(None, 2)
93
+ if len(parts) != 3 or not parts[0].isdigit() or not parts[1].isdigit():
94
+ continue
95
+ pid, ppid, cmd = int(parts[0]), int(parts[1]), parts[2]
96
+ if pid == 1 or ppid != 1 or not _orphan_signature(cmd.lower()):
97
+ continue
98
+ try:
99
+ os.killpg(pid, signal.SIGKILL) # 服务 spawn 用了 start_new_session,pgid==pid
100
+ killed += 1
101
+ except Exception:
102
+ try:
103
+ os.kill(pid, signal.SIGKILL)
104
+ killed += 1
105
+ except Exception:
106
+ pass
107
+ return killed
108
+ except Exception:
109
+ return 0
110
+
111
+
72
112
  def sweep_orphan_cli_processes():
73
113
  """启动清扫:服务重启会孤儿化正在跑的 CLI 孙进程(外部只杀服务 PID,不带
74
114
  /T),僵尸 opencode 更会劫持后续会话——opencode 是客户端-服务端架构,新
@@ -81,6 +121,8 @@ def sweep_orphan_cli_processes():
81
121
  kimi:命令行含 kimi-code/dist/main.mjs(node 直启路径,2026-09-17 实测
82
122
  僵尸 kimi 会占住讯飞网关同钥请求队列,堵死后续所有 kimi 调用)
83
123
  claude 不扫(签名与用户手动使用难区分)。返回清扫数量。"""
124
+ if os.name != "nt":
125
+ return _sweep_orphans_posix()
84
126
  ps_exe = os.path.join(os.environ.get("SystemRoot", r"C:\Windows"),
85
127
  "System32", "WindowsPowerShell", "v1.0", "powershell.exe")
86
128
  if not os.path.isfile(ps_exe):
@@ -101,10 +143,7 @@ def sweep_orphan_cli_processes():
101
143
  for i in items:
102
144
  cl = str(i.get("CommandLine") or "").lower()
103
145
  ppid = i.get("ParentProcessId")
104
- is_ours = (("opencode" in cl and "--model" in cl)
105
- or ("codex" in cl and "--skip-git-repo-check" in cl)
106
- or ("kimi-code" in cl and "main.mjs" in cl))
107
- if not is_ours or ppid in live or not i.get("ProcessId"):
146
+ if not _orphan_signature(cl) or ppid in live or not i.get("ProcessId"):
108
147
  continue
109
148
  try:
110
149
  subprocess.run(["taskkill", "/F", "/T", "/PID", str(i["ProcessId"])],
@@ -168,6 +207,8 @@ def _uwp_version(package_dir):
168
207
 
169
208
 
170
209
  def _exe_version(path):
210
+ if os.name != "nt":
211
+ return None # exe 版本探测是 Windows 专属功能(PowerShell 读 PE 资源)
171
212
  try:
172
213
  r = subprocess.run(
173
214
  ["powershell", "-NoProfile", "-Command",
@@ -191,7 +232,11 @@ def version_of(entry):
191
232
  cli = (entry.get("detect") or {}).get("cli")
192
233
  if cli and det.get("installed"):
193
234
  try:
194
- r = subprocess.run(["cmd", "/c", cli, "--version"], capture_output=True,
235
+ # Windows 下 CLI 可能是 npm .cmd 垫片,须经 cmd /c 才能直接点名跑;
236
+ # POSIX 没有垫片,符号链接直接跑即可
237
+ probe = ["cmd", "/c", cli, "--version"] if os.name == "nt" \
238
+ else [cli, "--version"]
239
+ r = subprocess.run(probe, capture_output=True,
195
240
  creationflags=CREATE_NO_WINDOW, timeout=20)
196
241
  out = (r.stdout or b"").decode("utf-8", "replace").strip()
197
242
  if not out:
@@ -1440,9 +1485,13 @@ def launch(entry, open_browser=True):
1440
1485
  spawn_cmd = "%s > %s 2>&1" % (cmd, ('"%s"' % ls) if " " in ls else ls)
1441
1486
  try:
1442
1487
  log_path.parent.mkdir(parents=True, exist_ok=True)
1443
- subprocess.Popen(["cmd", "/c", spawn_cmd], cwd=str(paths.ROOT), env=env,
1488
+ # 重定向语法 sh cmd 通用,只换解释器;start_new_session 让子服务
1489
+ # 脱离本服务进程组(杀树/退出互不牵连)
1490
+ shell = ["cmd", "/c"] if os.name == "nt" else ["/bin/sh", "-c"]
1491
+ subprocess.Popen(shell + [spawn_cmd], cwd=str(paths.ROOT), env=env,
1444
1492
  stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
1445
- stderr=subprocess.DEVNULL, creationflags=CREATE_NO_WINDOW)
1493
+ stderr=subprocess.DEVNULL, creationflags=CREATE_NO_WINDOW,
1494
+ start_new_session=(os.name != "nt"))
1446
1495
  except Exception as e:
1447
1496
  return {"ok": False, "error": "无法启动服务: %r" % e}
1448
1497
  if open_browser:
@@ -1452,8 +1501,25 @@ def launch(entry, open_browser=True):
1452
1501
  "message": "%s 正在启动,就绪后浏览器会自动打开(%s)%s"
1453
1502
  % (name, bare, (";" + extra) if extra else "")}
1454
1503
 
1504
+ if sys.platform == "darwin":
1505
+ # Terminal.app 新开窗口跑交互 TUI;do script 的命令串常驻窗口,等价
1506
+ # Windows 的 cmd /k(CLI 退出后窗口保留,报错不至于一闪而过)。
1507
+ # 两层转义各管各的:shlex.quote 管 shell 层(cd 路径的引号),
1508
+ # 反斜杠/双引号替换管 AppleScript 字符串层。
1509
+ shell_cmd = "cd %s && %s" % (shlex.quote(str(paths.ROOT)), cmd)
1510
+ asc = 'tell application "Terminal" to do script ' + \
1511
+ '"' + shell_cmd.replace("\\", "\\\\").replace('"', '\\"') + '"'
1512
+ try:
1513
+ subprocess.Popen(["osascript", "-e", asc], cwd=str(paths.ROOT), env=env,
1514
+ stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
1515
+ stderr=subprocess.DEVNULL)
1516
+ except Exception as e:
1517
+ return {"ok": False, "error": "无法打开终端窗口: %r" % e}
1518
+ return {"ok": True, "kind": "console", "message": "已在新的终端窗口打开 %s%s"
1519
+ % (name, ("(" + extra + ")") if extra else "")}
1520
+
1455
1521
  if sys.platform != "win32":
1456
- return {"ok": False, "error": "终端窗口拉起暂仅支持 Windows"}
1522
+ return {"ok": False, "error": "终端窗口拉起暂仅支持 Windows/macOS"}
1457
1523
  # start 为目标命令新开一个可见终端窗口;cmd /k 让 CLI 退出后窗口保留,
1458
1524
  # 报错不至于一闪而过。外层 cmd 用 CREATE_NO_WINDOW 隐藏。
1459
1525
  argv = ["cmd", "/c", "start", "CodeBee %s" % name, "/D", str(paths.ROOT),
@@ -1510,10 +1576,31 @@ def run_mgmt_command(entry, op, cancel_event=None, log_path=None):
1510
1576
  detect_all(force=True)
1511
1577
  with _LOCK:
1512
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)
1513
1584
  return {"ok": res["ok"], "exit_code": res["exit_code"], "command": cmd,
1514
1585
  "error": "" if res["ok"] else (res["stderr"][-800:] or "退出码 %s" % res["exit_code"])}
1515
1586
 
1516
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
+
1517
1604
  # ---------------------------------------------------------------- 版本检查
1518
1605
 
1519
1606
  _UPDATE_CACHE = {} # agent_id → (ts, {current, latest, updatable, note})
@@ -1544,7 +1631,10 @@ def check_update(entry, force=False):
1544
1631
  cmd = entry.get("install") or entry.get("upgrade") or ""
1545
1632
  pkg = _npm_pkg_name(cmd)
1546
1633
  if pkg:
1547
- r = runner.run_process(argv=["cmd", "/c", "npm", "view", pkg, "version"], timeout=90)
1634
+ # Windows 的 npm 是 .cmd 垫片须经 cmd /c;POSIX 直接跑
1635
+ npm_view = ["cmd", "/c", "npm", "view", pkg, "version"] if os.name == "nt" \
1636
+ else ["npm", "view", pkg, "version"]
1637
+ r = runner.run_process(argv=npm_view, timeout=90)
1548
1638
  latest = ""
1549
1639
  if r["ok"]:
1550
1640
  for line in (r["stdout"] or "").splitlines():
@@ -1558,7 +1648,7 @@ def check_update(entry, force=False):
1558
1648
  result["updatable"] = bool(_ver_tuple(latest) > _ver_tuple(cur_num))
1559
1649
  if not result["updatable"]:
1560
1650
  result["note"] = "已是最新版本"
1561
- elif "winget" in cmd:
1651
+ elif os.name == "nt" and "winget" in cmd:
1562
1652
  m = re.search(r"--id\s+([A-Za-z0-9._-]+)", cmd)
1563
1653
  wid = m.group(1) if m else None
1564
1654
  if not wid: