codebee 0.1.55 → 0.1.57

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.
@@ -0,0 +1,183 @@
1
+ # -*- coding: utf-8 -*-
2
+ """蜂巢保全:服务无论以何种方式退出,本服务拉起的蜜蜂 CLI 一起带走。
3
+
4
+ 背景(2026-09-23 用户定案):CodeBee 关闭后——无论正常退出、Ctrl+C、崩溃
5
+ 还是被 taskkill 硬杀——在跑的 CLI/蜜蜂(codex/claude/kimi 等)必须一起杀
6
+ 掉,否则孤儿进程把内存吃满。当天 16:50 一次服务重启就留过一只孤儿
7
+ claude.exe(直到管道断裂才饿死)。
8
+
9
+ Windows:Job Object(JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE)。蜜蜂 Popen 一出生
10
+ 就 AssignProcessToJobObject 挂进蜂巢,整棵子进程树自动继承巢内身份;服务进
11
+ 程死亡(哪怕 os._exit 跳过 atexit、被 TerminateProcess 断头)时内核关闭巢柄,
12
+ 负责把巢内进程全部收走——这是唯一能覆盖「任意死法」的机制。
13
+ - 升级重启的新实例(selfupdate.relaunch)不经 run_process、不挂巢,得以
14
+ 存活——否则就地重启等于自杀。桌面蜜蜂、explorer、cmd /k 交互终端同理豁免。
15
+ POSIX(含 Mac):没有 Job Object 也没有 Linux 的 prctl PDEATHSIG,靠登记 +
16
+ atexit 清扫兜底:run_process 的子进程本就 start_new_session(自成进程组,
17
+ pgid==pid),sweep 用 killpg 连树杀。正常退出 / Ctrl+C / SIGTERM/SIGHUP
18
+ (main.py 已把它们转成 SystemExit,atexit 照跑)全覆盖;仅 kill -9 无解
19
+ (任何机制都救不了,属已知边界)。
20
+
21
+ 本模块绝不抛异常:挂巢失败只降级为旧行为(取消/超时收尸仍在),绝不影响
22
+ 蜜蜂正常起跑。不依赖 pywin32,ctypes 直调 kernel32。
23
+ """
24
+ from __future__ import annotations
25
+
26
+ import atexit
27
+ import collections
28
+ import os
29
+ import signal
30
+ import weakref
31
+
32
+ # 近期蜜蜂登记:只保最近若干条防无限增长。Windows 上 Job 是主机制、登记只是
33
+ # atexit 兜底的补充;POSIX 上登记是主机制(sweep 全靠它)。
34
+ _RECENT = collections.deque(maxlen=256)
35
+
36
+
37
+ # ---------------------------------------------------------------- Windows 巢
38
+ if os.name == "nt":
39
+ import ctypes
40
+ from ctypes import wintypes
41
+
42
+ _LIMIT_KILL_ON_JOB_CLOSE = 0x00002000
43
+ _INFO_JOB_OBJECT_EXTENDED_LIMIT = 9
44
+ _STILL_ACTIVE = 259
45
+
46
+ class _IO_COUNTERS(ctypes.Structure):
47
+ _fields_ = [(n, ctypes.c_ulonglong) for n in (
48
+ "ReadOperationCount", "WriteOperationCount", "OtherOperationCount",
49
+ "ReadTransferCount", "WriteTransferCount", "OtherTransferCount")]
50
+
51
+ class _JOBOBJECT_BASIC_LIMIT_INFORMATION(ctypes.Structure):
52
+ _fields_ = [
53
+ ("PerProcessUserTimeLimit", ctypes.c_longlong),
54
+ ("PerJobUserTimeLimit", ctypes.c_longlong),
55
+ ("LimitFlags", wintypes.DWORD),
56
+ ("MinimumWorkingSetSize", ctypes.c_size_t),
57
+ ("MaximumWorkingSetSize", ctypes.c_size_t),
58
+ ("ActiveProcessLimit", wintypes.DWORD),
59
+ ("Affinity", ctypes.c_size_t), # ULONG_PTR
60
+ ("PriorityClass", wintypes.DWORD),
61
+ ("SchedulingClass", wintypes.DWORD),
62
+ ]
63
+
64
+ class _JOBOBJECT_EXTENDED_LIMIT_INFORMATION(ctypes.Structure):
65
+ _fields_ = [
66
+ ("BasicLimitInformation", _JOBOBJECT_BASIC_LIMIT_INFORMATION),
67
+ ("IoInfo", _IO_COUNTERS),
68
+ ("ProcessMemoryLimit", ctypes.c_size_t),
69
+ ("JobMemoryLimit", ctypes.c_size_t),
70
+ ("PeakProcessMemoryUsed", ctypes.c_size_t),
71
+ ("PeakJobMemoryUsed", ctypes.c_size_t),
72
+ ]
73
+
74
+ _kernel32 = ctypes.windll.kernel32
75
+
76
+ class BeeNest:
77
+ """一个 KILL_ON_JOB_CLOSE 巢。生产用模块单例 NEST;测试自建私有巢。"""
78
+
79
+ def __init__(self):
80
+ self._handle = None
81
+ self.assigned = 0
82
+
83
+ def _ensure_job(self):
84
+ if self._handle:
85
+ return self._handle
86
+ h = _kernel32.CreateJobObjectW(None, None)
87
+ if not h:
88
+ return None
89
+ info = _JOBOBJECT_EXTENDED_LIMIT_INFORMATION()
90
+ info.BasicLimitInformation.LimitFlags = _LIMIT_KILL_ON_JOB_CLOSE
91
+ if not _kernel32.SetInformationJobObject(
92
+ h, _INFO_JOB_OBJECT_EXTENDED_LIMIT,
93
+ ctypes.byref(info), ctypes.sizeof(info)):
94
+ _kernel32.CloseHandle(h)
95
+ return None
96
+ self._handle = h
97
+ return h
98
+
99
+ def adopt(self, proc):
100
+ h = self._ensure_job()
101
+ if not h:
102
+ return
103
+ handle = getattr(proc, "_handle", None)
104
+ if not handle:
105
+ return
106
+ # 子进程树(cmd /c 垫片 → claude.exe → 其孙)自动继承巢内身份,
107
+ # 只需挂直系子进程。Win8+ 支持嵌套 Job,外层有别的 Job 也不碍事。
108
+ if _kernel32.AssignProcessToJobObject(h, int(handle)):
109
+ self.assigned += 1
110
+
111
+ def close(self):
112
+ """主动关巢:巢内进程全部被内核收走(等价于服务进程死亡)。"""
113
+ if self._handle:
114
+ try:
115
+ _kernel32.CloseHandle(self._handle)
116
+ except Exception:
117
+ pass
118
+ self._handle = None
119
+
120
+ else:
121
+ class BeeNest:
122
+ """POSIX 占位巢:杀全靠 sweep()(登记 + killpg),close 无事可做。"""
123
+
124
+ def __init__(self):
125
+ self.assigned = 0
126
+
127
+ def adopt(self, proc):
128
+ self.assigned += 1
129
+
130
+ def close(self):
131
+ pass
132
+
133
+
134
+ NEST = BeeNest()
135
+
136
+
137
+ def adopt(proc):
138
+ """把蜜蜂进程挂巢:服务死则蜜蜂亡。绝不抛异常、绝不拖慢起跑。"""
139
+ if proc is None:
140
+ return
141
+ try:
142
+ _RECENT.append((proc.pid, weakref.ref(proc)))
143
+ NEST.adopt(proc)
144
+ except Exception:
145
+ pass
146
+
147
+
148
+ def sweep():
149
+ """退出清扫:登记在册且仍存活的蜜蜂连树杀。
150
+
151
+ POSIX 主机制(atexit 在正常退出/Ctrl+C/SIGTERM→SystemExit 时都会跑);
152
+ Windows 只是 Job 的兜底(Job 建不出来或句柄异常时仍能杀到登记的直系
153
+ 进程,孙进程交给既有 taskkill /T 收尸路径)。Windows 端拿不到 Popen
154
+ 存活证据的条目不盲杀——防 PID 复用误伤无辜进程(反正有 Job 兜底)。
155
+ """
156
+ while _RECENT:
157
+ pid, ref = _RECENT.popleft()
158
+ proc = ref() if ref else None
159
+ if proc is not None:
160
+ try:
161
+ if proc.poll() is not None:
162
+ continue # 已退出,跳过
163
+ except Exception:
164
+ pass
165
+ if os.name == "nt":
166
+ if proc is None:
167
+ continue
168
+ try:
169
+ os.kill(pid, signal.SIGTERM) # Windows 语义 = TerminateProcess
170
+ except Exception:
171
+ pass
172
+ else:
173
+ # run_process 子进程 start_new_session:pgid==pid,killpg 连树杀
174
+ try:
175
+ os.killpg(pid, signal.SIGKILL)
176
+ except Exception:
177
+ try:
178
+ os.kill(pid, signal.SIGKILL)
179
+ except Exception:
180
+ pass
181
+
182
+
183
+ atexit.register(sweep)
@@ -83,12 +83,7 @@ def _append_audit(workdir, chapter, branches, pick):
83
83
  if not workdir:
84
84
  return
85
85
  try:
86
- p = os.path.abspath(os.path.join(str(workdir), ".codebee",
87
- "branch-plans.md"))
88
- root = os.path.abspath(str(workdir))
89
- if root not in p.split(os.sep)[:-len([".codebee", "branch-plans.md"])]:
90
- if not p.startswith(root + os.sep):
91
- return
86
+ p = _audit_path(workdir)
92
87
  os.makedirs(os.path.dirname(p), exist_ok=True)
93
88
  with open(p, "a", encoding="utf-8") as f:
94
89
  if f.tell() == 0:
@@ -103,3 +98,33 @@ def _append_audit(workdir, chapter, branches, pick):
103
98
  f.write("\n")
104
99
  except Exception:
105
100
  pass
101
+
102
+
103
+ def _audit_path(workdir):
104
+ """审计文件路径(越界拒绝:workdir 必须存在的目录)。"""
105
+ from pathlib import Path
106
+ root = Path(workdir).resolve()
107
+ if not root.is_dir():
108
+ raise ValueError("workdir 必须是已存在的目录")
109
+ return root / ".codebee" / "branch-plans.md"
110
+
111
+
112
+ def mark_stale(workdir, chapter):
113
+ """正史重写后旧推演标过期(inkos 借鉴:分支计划随正史失效)。
114
+
115
+ 修订步骤改写章稿后,本章的分支计划节头追加「(已过期,正史已重写)」——
116
+ 防止后续翻看审计时把旧推演当成当前正史的来源。失败静默。"""
117
+ if not workdir:
118
+ return
119
+ try:
120
+ p = _audit_path(workdir)
121
+ if not p.is_file():
122
+ return
123
+ txt = p.read_text(encoding="utf-8", errors="replace")
124
+ header = "## 第 %d 章\n" % chapter
125
+ stale_header = "## 第 %d 章(已过期,正史已重写)\n" % chapter
126
+ if header in txt and stale_header not in txt:
127
+ p.write_text(txt.replace(header, stale_header, 1),
128
+ encoding="utf-8")
129
+ except Exception:
130
+ pass
package/app/core/jobs.py CHANGED
@@ -260,10 +260,10 @@ def wait_for_idle(timeout=10):
260
260
 
261
261
 
262
262
  def begin_restart_drain():
263
- """升级任务已退出执行位且没有用户任务时,原子停止接单。"""
263
+ """升级任务已退出执行位且没有用户任务或 direct 对话时,原子停止接单。"""
264
264
  global _restart_drain
265
265
  with _pool_lock:
266
- if _restart_drain or _alive != 0:
266
+ if _restart_drain or _alive != 0 or _chat_alive != 0:
267
267
  return False
268
268
  _restart_drain = True
269
269
  return True
@@ -344,12 +344,12 @@ def _requeue_await_slot(job, waits_left=None):
344
344
  return
345
345
  if _restart_drain:
346
346
  _close_unstarted(job, "服务正在完成升级重启;本次未排队,请稍后重试",
347
- statuses=("running",))
347
+ statuses=("queued", "running"))
348
348
  elif waits_left > 0:
349
349
  _requeue_await_slot(job, waits_left - 1)
350
350
  else:
351
351
  _close_unstarted(job, "并发位已满,排队等待超时仍未起跑,请稍后重试",
352
- statuses=("running",))
352
+ statuses=("queued", "running"))
353
353
 
354
354
  with _timer_lock:
355
355
  if run_id in _deferred_timers:
@@ -921,7 +921,7 @@ def _do_selfupgrade(job, ev):
921
921
  if ev.is_set() or (store.get_run(run_id) or {}).get("status") != "running":
922
922
  return
923
923
  store.finish_step(run_id, step["n"], "done" if res["ok"] else "failed",
924
- summary="升级完成,点「重启」生效" if res["ok"]
924
+ summary="升级完成,服务将自动重启生效" if res["ok"]
925
925
  else ("升级失败: " + res["error"][:300]),
926
926
  exit_code=res.get("exit_code"))
927
927
  # expected_status 守卫:用户已强制终止的运行保持 cancelled,不被改写
@@ -251,6 +251,11 @@ def block_for(task):
251
251
  stale = _is_stale(x.get("as_of"))
252
252
  mark = ("(事实截至 %s,可能过期,请自行核实时效)" % x.get("as_of") if stale
253
253
  else ("(事实截至 %s)" % x.get("as_of") if x.get("as_of") else ""))
254
+ # 置信标注(引用核验续):medium 不标(默认噪音为零),low 早已
255
+ # 在学习口丢弃——只有 high 显式加冕,模型据以分配采信权重
256
+ conf = str(x.get("confidence") or "medium").strip().lower()
257
+ if conf == "high":
258
+ mark += "[有据]"
254
259
  lines.append("- **%s**%s:%s" % (x["title"], mark, x["body"]))
255
260
  used.append(x["id"])
256
261
  text = "## 知识库(已确认的领域知识,供参考)\n\n" + "\n".join(lines)
@@ -681,6 +681,7 @@ def provider_view():
681
681
  # 读取点(凭据注入/健康探测/取模型列表)无需感知多 KEY 结构。没配 keys 的老数据
682
682
  # 由 _provider_keys 按 api_key 现场合成一条——文件一个字节都不用改。
683
683
  _KEY_COOLDOWN_S = 30 * 60 # 欠费类失败后的冷却时长
684
+ _AUTH_COOLDOWN_S = 30 * 60 # 明确认证拒绝后的冷却时长,可在 UI 手动恢复
684
685
  MAX_PROVIDER_KEYS = 8 # 单厂商 KEY 上限
685
686
  MAX_CHAIN_ATTEMPTS = 8 # 链展开后的尝试上限(模型 × KEY)
686
687
  # 欠费/配额/限流类失败:换 KEY 有意义(同厂商另一账号=另一份额度与并发桶),
@@ -699,6 +700,19 @@ def _quota_error(err):
699
700
  return any(k in e for k in _QUOTA_HINTS)
700
701
 
701
702
 
703
+ _AUTH_HINTS = ("invalid api key", "invalid_api_key", "api key is invalid",
704
+ "incorrect api key", "unauthorized", "unauthorised",
705
+ "authentication failed", "authentication error",
706
+ "authentication_error", "invalid token", "invalid_token",
707
+ "http 401", "status code 401")
708
+
709
+
710
+ def _auth_error(err):
711
+ """明确的凭据认证拒绝;不把一般 403/模型权限错误误判成 KEY 错误。"""
712
+ e = (err or "").lower()
713
+ return any(k in e for k in _AUTH_HINTS)
714
+
715
+
702
716
  def _provider_keys(prov, available_only=False, now=None):
703
717
  """供应商的 KEY 列表,顺序=调用顺序。返回 [{id,key,label,enabled,cooling,...}]。
704
718
 
@@ -826,7 +840,7 @@ def key_op(provider_id, op, key_id="", key="", label="", enabled=None, ids=None)
826
840
 
827
841
 
828
842
  def note_key_error(provider_id, key_id, error=""):
829
- """一次 KEY 级失败回写:欠费类进冷却(后续解析自动跳过 → 切备用 KEY)。"""
843
+ """一次 KEY 级失败回写:欠费或明确认证拒绝进冷却,后续解析切备用 KEY。"""
830
844
  import time as _t
831
845
  if not provider_id or not key_id:
832
846
  return
@@ -842,6 +856,8 @@ def note_key_error(provider_id, key_id, error=""):
842
856
  target["last_fail_at"] = _t.time()
843
857
  if _quota_error(error):
844
858
  target["cool_until"] = _t.time() + _KEY_COOLDOWN_S
859
+ elif _auth_error(error):
860
+ target["cool_until"] = _t.time() + _AUTH_COOLDOWN_S
845
861
  _sync_api_key(prov)
846
862
  _save(data)
847
863