codebee 0.1.5 → 0.1.7

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
@@ -216,6 +217,18 @@ CONFIG_PATCH = {
216
217
  }
217
218
 
218
219
 
220
+ # 安装命令平台修正({id: (install, upgrade)}):仅非 Windows 套用。winget 是
221
+ # Windows 包管理器、py 启动器 Windows 独有,这两类命令在 macOS/Linux 上必失败
222
+ # ——load() 幂等改写为等价的 npm/pip 渠道(与 CONFIG_PATCH 同策略:覆盖无风险,
223
+ # 旧数据不修正的话每次点安装/升级都注定失败)。
224
+ INSTALL_PATCH_NONWIN = {
225
+ "claude-code": ("npm install -g @anthropic-ai/claude-code",
226
+ "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
+ }
230
+
231
+
219
232
  def npm_pkg_name(cmd):
220
233
  """从 npm 安装命令里取包名(支持 @scope/name@latest)。
221
234
 
@@ -289,6 +302,15 @@ def _apply_config_patch(entries):
289
302
  e["config"] = dict(patch)
290
303
 
291
304
 
305
+ def _apply_install_patch(entries):
306
+ if sys.platform == "win32":
307
+ return
308
+ for e in entries:
309
+ patch = INSTALL_PATCH_NONWIN.get(e.get("id"))
310
+ if patch:
311
+ e["install"], e["upgrade"] = patch
312
+
313
+
292
314
  def _merge_new_defaults(entries):
293
315
  """把内置默认里「新增的」条目补进已加载清单(同 id 已存在则原样保留)。
294
316
 
@@ -321,6 +343,7 @@ def load(force=False):
321
343
  _apply_resume_patch(entries)
322
344
  _apply_launch_patch(entries)
323
345
  _apply_config_patch(entries)
346
+ _apply_install_patch(entries)
324
347
  _CACHE["entries"] = entries
325
348
  return entries
326
349
 
@@ -59,6 +59,13 @@ 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()
62
69
  if not _STARTED:
63
70
  _STARTED = True
64
71
  t = threading.Thread(target=_probe_loop, name="health-probe", daemon=True)
@@ -157,6 +164,8 @@ def report_failure(provider: str, error: str = "", *, model: str = "",
157
164
  _persist()
158
165
 
159
166
 
167
+ # ------------------------------------------------- 静态死链告警(绑定层)
168
+
160
169
  # ---------------------------------------------------------------- 手动操作
161
170
 
162
171
  def silence(provider: str, minutes: int = 0):
@@ -215,6 +224,7 @@ def snapshot():
215
224
  "last_error": st.get("last_error") or "",
216
225
  "alerting": alerting,
217
226
  "silenced": silenced,
227
+ "static": bool(st.get("static")),
218
228
  })
219
229
  # 稳定排序:告警中 > 故障中 > 恢复 > 正常
220
230
  rank = {"down": 0, "failing": 1, "recovered": 2, "ok": 3}
@@ -271,7 +281,8 @@ def _probe_loop():
271
281
  try:
272
282
  with _LOCK:
273
283
  targets = [(name, st) for name, st in _PROVIDERS.items()
274
- if st.get("status") in ("failing", "down")
284
+ if not st.get("static") # 静态死链无端点可探,绑定恢复时自动解除
285
+ and st.get("status") in ("failing", "down")
275
286
  and (st.get("probe_next_at") or 0) <= _now()]
276
287
  for name, st in targets:
277
288
  idx = int(st.get("probe_backoff_idx") or 0)
package/app/core/jobs.py CHANGED
@@ -134,7 +134,9 @@ def cancel_event_for(run_id):
134
134
  return ev
135
135
 
136
136
 
137
- AUTO_RESUME_MAX = 2 # 连载任务自动续跑上限(超时/中断后自动接着写,无需人工)
137
+ AUTO_RESUME_MAX = 3 # 连载任务自动续跑上限(超时/中断后自动接着写,无需人工)
138
+ AUTO_RESUME_DELAY_S = 300 # 自动续跑延迟入队秒数:网关限流/欠费窗口通常分钟级,
139
+ # 立即重排会撞在同一堵墙上把续跑次数烧光(2026-09-17 七猫实测)
138
140
 
139
141
 
140
142
  def _maybe_auto_resume(run_id):
@@ -160,7 +162,13 @@ def _maybe_auto_resume(run_id):
160
162
  return False
161
163
  store.update_run(new_run["id"], auto_resumes=int(run.get("auto_resumes") or 0) + 1,
162
164
  auto_resumed_from=run_id)
163
- _QUEUE.put({"kind": "orchestration", "run_id": new_run["id"], "task_id": task["id"]})
165
+
166
+ def _enqueue():
167
+ _QUEUE.put({"kind": "orchestration",
168
+ "run_id": new_run["id"], "task_id": task["id"]})
169
+ t = threading.Timer(AUTO_RESUME_DELAY_S, _enqueue)
170
+ t.daemon = True
171
+ t.start()
164
172
  return True
165
173
  except Exception:
166
174
  return False
@@ -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 是客户端-服务端架构,新
@@ -78,7 +118,11 @@ def sweep_orphan_cli_processes():
78
118
  按「Tutti 调用签名 + 父进程已死」双条件匹配,不误杀用户自己在用的 CLI:
79
119
  opencode:命令行含 opencode + --model(同步写入的 provider 固定 orch)
80
120
  codex:命令行含 codex + --skip-git-repo-check(Tutti 专属 flag 组合)
121
+ kimi:命令行含 kimi-code/dist/main.mjs(node 直启路径,2026-09-17 实测
122
+ 僵尸 kimi 会占住讯飞网关同钥请求队列,堵死后续所有 kimi 调用)
81
123
  claude 不扫(签名与用户手动使用难区分)。返回清扫数量。"""
124
+ if os.name != "nt":
125
+ return _sweep_orphans_posix()
82
126
  ps_exe = os.path.join(os.environ.get("SystemRoot", r"C:\Windows"),
83
127
  "System32", "WindowsPowerShell", "v1.0", "powershell.exe")
84
128
  if not os.path.isfile(ps_exe):
@@ -99,9 +143,7 @@ def sweep_orphan_cli_processes():
99
143
  for i in items:
100
144
  cl = str(i.get("CommandLine") or "").lower()
101
145
  ppid = i.get("ParentProcessId")
102
- is_ours = (("opencode" in cl and "--model" in cl)
103
- or ("codex" in cl and "--skip-git-repo-check" in cl))
104
- 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"):
105
147
  continue
106
148
  try:
107
149
  subprocess.run(["taskkill", "/F", "/T", "/PID", str(i["ProcessId"])],
@@ -165,6 +207,8 @@ def _uwp_version(package_dir):
165
207
 
166
208
 
167
209
  def _exe_version(path):
210
+ if os.name != "nt":
211
+ return None # exe 版本探测是 Windows 专属功能(PowerShell 读 PE 资源)
168
212
  try:
169
213
  r = subprocess.run(
170
214
  ["powershell", "-NoProfile", "-Command",
@@ -188,7 +232,11 @@ def version_of(entry):
188
232
  cli = (entry.get("detect") or {}).get("cli")
189
233
  if cli and det.get("installed"):
190
234
  try:
191
- 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,
192
240
  creationflags=CREATE_NO_WINDOW, timeout=20)
193
241
  out = (r.stdout or b"").decode("utf-8", "replace").strip()
194
242
  if not out:
@@ -832,6 +880,11 @@ def _sync_codex_settings(entry, model, cp):
832
880
  name = cp.get("name", "orch")
833
881
  def q(v):
834
882
  return '"%s"' % str(v).replace("\\", "\\\\").replace('"', '\\"')
883
+ if (cp.get("wire_api") or "responses") == "chat":
884
+ # codex 0.154+ 起 chat wire 被官方移除,写进 config.toml 会让 CLI 连配置
885
+ # 都载入不了(Error loading config.toml)——宁可明确拒绝也不落坏配置。
886
+ return ("供应商只有 chat completions wire,codex 0.154+ 已移除支持,未写入"
887
+ " config.toml——请为 codex 绑定 responses 兼容的供应商")
835
888
  pairs = [("name", q(cp.get("name", name))),
836
889
  ("base_url", q(cp.get("base_url", ""))),
837
890
  ("env_key", q(cp.get("env_key", "ORCH_API_KEY"))),
@@ -1432,9 +1485,13 @@ def launch(entry, open_browser=True):
1432
1485
  spawn_cmd = "%s > %s 2>&1" % (cmd, ('"%s"' % ls) if " " in ls else ls)
1433
1486
  try:
1434
1487
  log_path.parent.mkdir(parents=True, exist_ok=True)
1435
- 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,
1436
1492
  stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL,
1437
- stderr=subprocess.DEVNULL, creationflags=CREATE_NO_WINDOW)
1493
+ stderr=subprocess.DEVNULL, creationflags=CREATE_NO_WINDOW,
1494
+ start_new_session=(os.name != "nt"))
1438
1495
  except Exception as e:
1439
1496
  return {"ok": False, "error": "无法启动服务: %r" % e}
1440
1497
  if open_browser:
@@ -1444,8 +1501,25 @@ def launch(entry, open_browser=True):
1444
1501
  "message": "%s 正在启动,就绪后浏览器会自动打开(%s)%s"
1445
1502
  % (name, bare, (";" + extra) if extra else "")}
1446
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
+
1447
1521
  if sys.platform != "win32":
1448
- return {"ok": False, "error": "终端窗口拉起暂仅支持 Windows"}
1522
+ return {"ok": False, "error": "终端窗口拉起暂仅支持 Windows/macOS"}
1449
1523
  # start 为目标命令新开一个可见终端窗口;cmd /k 让 CLI 退出后窗口保留,
1450
1524
  # 报错不至于一闪而过。外层 cmd 用 CREATE_NO_WINDOW 隐藏。
1451
1525
  argv = ["cmd", "/c", "start", "CodeBee %s" % name, "/D", str(paths.ROOT),
@@ -1536,7 +1610,10 @@ def check_update(entry, force=False):
1536
1610
  cmd = entry.get("install") or entry.get("upgrade") or ""
1537
1611
  pkg = _npm_pkg_name(cmd)
1538
1612
  if pkg:
1539
- r = runner.run_process(argv=["cmd", "/c", "npm", "view", pkg, "version"], timeout=90)
1613
+ # Windows 的 npm 是 .cmd 垫片须经 cmd /c;POSIX 直接跑
1614
+ npm_view = ["cmd", "/c", "npm", "view", pkg, "version"] if os.name == "nt" \
1615
+ else ["npm", "view", pkg, "version"]
1616
+ r = runner.run_process(argv=npm_view, timeout=90)
1540
1617
  latest = ""
1541
1618
  if r["ok"]:
1542
1619
  for line in (r["stdout"] or "").splitlines():
@@ -1550,7 +1627,7 @@ def check_update(entry, force=False):
1550
1627
  result["updatable"] = bool(_ver_tuple(latest) > _ver_tuple(cur_num))
1551
1628
  if not result["updatable"]:
1552
1629
  result["note"] = "已是最新版本"
1553
- elif "winget" in cmd:
1630
+ elif os.name == "nt" and "winget" in cmd:
1554
1631
  m = re.search(r"--id\s+([A-Za-z0-9._-]+)", cmd)
1555
1632
  wid = m.group(1) if m else None
1556
1633
  if not wid: