codebee 0.1.22 → 0.1.24

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,111 @@
1
+ # -*- coding: utf-8 -*-
2
+ """启动端口清场(用户拍板 2026-09-21):端口被占时先清场再启动。
3
+
4
+ - **自家旧实例**(命令行含本包 main.py 完整路径,或 npm 包内 app/main.py)
5
+ → 杀树(TerminateProcess 直杀)——升级/重启最常见的占用者就是没退干净
6
+ 的 CodeBee 自己;控制台进程对温和信号(WM_CLOSE)无反应,温和关不掉
7
+ 正是用户「旧进程太难杀」的痛点,所以自家实例直接强杀。
8
+ - **别人的进程** → 只报告占用者,不发送任何信号;启动流程不得替用户
9
+ 关闭可能正在开发中的服务。
10
+ - 系统/自身进程拒关(portscan 内置护栏)。
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import logging
15
+ import os
16
+ import shlex
17
+ import subprocess
18
+
19
+ from . import portscan, runner
20
+
21
+ log = logging.getLogger(__name__)
22
+
23
+ PS_EXE = "C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe"
24
+
25
+
26
+ def proc_cmdline(pid):
27
+ """读进程命令行;失败返回空串。仅用于确认旧 CodeBee 实例身份。"""
28
+ try:
29
+ pid = int(pid)
30
+ except (TypeError, ValueError):
31
+ return ""
32
+ if pid <= 0:
33
+ return ""
34
+ if os.name != "nt":
35
+ try:
36
+ with open("/proc/%d/cmdline" % pid, "rb") as fh:
37
+ return fh.read().decode("utf-8", "replace").replace("\x00", " ").strip()
38
+ except Exception:
39
+ return ""
40
+ try:
41
+ r = subprocess.run(
42
+ [PS_EXE, "-NoProfile", "-Command",
43
+ "(Get-CimInstance Win32_Process -Filter 'ProcessId = %d').CommandLine" % pid],
44
+ capture_output=True, timeout=10)
45
+ return r.stdout.decode("utf-8", "replace").strip() if r.returncode == 0 else ""
46
+ except Exception:
47
+ return ""
48
+
49
+
50
+ def _command_tokens(cmdline):
51
+ try:
52
+ return [token.strip().strip('"').strip("'")
53
+ for token in shlex.split(str(cmdline or ""), posix=False)]
54
+ except (TypeError, ValueError):
55
+ return []
56
+
57
+
58
+ def is_own_instance(cmdline, main_script, port=None):
59
+ """按启动器、脚本独立参数和端口精确确认 CodeBee 实例。"""
60
+ tokens = _command_tokens(cmdline)
61
+ if len(tokens) < 2:
62
+ return False
63
+ launcher = os.path.splitext(os.path.basename(tokens[0]))[0].lower()
64
+ if launcher not in ("python", "python3", "py"):
65
+ return False
66
+ script = ""
67
+ for token in tokens[1:]:
68
+ if token.startswith("-"):
69
+ continue
70
+ script = token
71
+ break
72
+ if not script or not os.path.isabs(script):
73
+ return False
74
+ actual = os.path.normcase(os.path.normpath(os.path.abspath(script)))
75
+ expected = os.path.normcase(os.path.normpath(os.path.abspath(str(main_script))))
76
+ packaged = actual.replace("\\", "/").lower().endswith(
77
+ "/node_modules/codebee/app/main.py")
78
+ if actual != expected and not packaged:
79
+ return False
80
+ if port is None:
81
+ return True
82
+ try:
83
+ wanted = str(int(port))
84
+ except (TypeError, ValueError):
85
+ return False
86
+ explicit_port = None
87
+ for i, token in enumerate(tokens):
88
+ if token == "--port" and i + 1 < len(tokens):
89
+ explicit_port = tokens[i + 1]
90
+ elif token.startswith("--port="):
91
+ explicit_port = token.split("=", 1)[1]
92
+ # argparse 默认端口可不出现在旧实例参数中;非默认端口必须显式相符。
93
+ return ((explicit_port == wanted) if explicit_port is not None
94
+ else wanted == "8765")
95
+
96
+
97
+ def clear_stale_port(port, main_script):
98
+ """清掉占用端口的进程。返回 (是否清掉, 人话说明)。"""
99
+ holders = [p for p in portscan.listening_ports() if p.get("port") == int(port)]
100
+ if not holders:
101
+ return True, ""
102
+ pid = int(holders[0].get("pid") or 0)
103
+ if pid <= 4 or pid == os.getpid():
104
+ return False, "占用者是系统进程/自身,拒绝清理"
105
+ if is_own_instance(proc_cmdline(pid), main_script, port=port):
106
+ try:
107
+ runner._kill_tree(pid)
108
+ return True, "已结束旧实例 PID %d" % pid
109
+ except Exception as e:
110
+ return False, "旧实例 PID %d 清理失败 %s" % (pid, str(e)[:80])
111
+ return False, "占用者非 CodeBee(PID %d),未自动关闭" % pid
@@ -0,0 +1,188 @@
1
+ # -*- coding: utf-8 -*-
2
+ """端口/进程扫描与项目归属推断(借鉴 leftopen 38★)。
3
+
4
+ 三个机制:
5
+ - **端口→进程→项目归属推断**:netstat/ss 拿端口→PID,再从进程 CWD 向上走找
6
+ .git/package.json 等项目根——知道该进程属于哪个项目/用户
7
+ - **本地 vs LAN 区分**:127.0.0.1 与 0.0.0.0/LAN 的安全边界
8
+ - **温和关闭**:SIGTERM only(Windows taskkill /PID 不带 /F),关闭前重验 PID
9
+
10
+ 跨平台(Windows netstat + PowerShell / POSIX ss + /proc)纯标准库。
11
+ """
12
+ from __future__ import annotations
13
+
14
+ import json
15
+ import logging
16
+ import os
17
+ import re
18
+ import subprocess
19
+
20
+ log = logging.getLogger(__name__)
21
+
22
+ _PROJECT_MARKERS = (".git", "package.json", "pyproject.toml", "Cargo.toml",
23
+ "go.mod", "pom.xml", "build.gradle", ".codebee")
24
+ _SYSTEM_PROCS = {"system", "idle", "kernel", "svchost", "launchd", "init",
25
+ "systemd", "sshd", "explorer", "finder", "windowserver"}
26
+
27
+
28
+ def _project_from_cwd(cwd):
29
+ """从 CWD 向上走到项目根(含标志文件的最深目录名)。"""
30
+ if not cwd:
31
+ return ""
32
+ cur = os.path.abspath(cwd)
33
+ origin = cur
34
+ home = os.path.abspath(os.path.expanduser("~"))
35
+ while cur and cur != os.path.dirname(cur):
36
+ # 家目录及以上散落的标志文件(package.json 等)是环境噪音不是项目;
37
+ # 只有进程就跑在家目录本身时才认它为归属。
38
+ if cur == home and cur != origin:
39
+ return ""
40
+ for marker in _PROJECT_MARKERS:
41
+ if os.path.exists(os.path.join(cur, marker)):
42
+ return os.path.basename(cur)
43
+ cur = os.path.dirname(cur)
44
+ return ""
45
+
46
+
47
+ def _proc_detail(pid):
48
+ """POSIX:读 /proc/<pid>/cwd 和 comm 获取进程详情。"""
49
+ detail = {"name": "", "project": ""}
50
+ try:
51
+ cwd = os.readlink("/proc/%d/cwd" % pid)
52
+ with open("/proc/%d/comm" % pid) as f:
53
+ detail["name"] = f.read().strip()
54
+ proj = _project_from_cwd(cwd)
55
+ if proj:
56
+ detail["project"] = proj
57
+ except (OSError, PermissionError):
58
+ pass
59
+ return detail
60
+
61
+
62
+ def _parse_ss(output):
63
+ """解析 ss/netstat -tlnp 输出为端口条目列表。"""
64
+ ports, pid_map = [], {}
65
+ for ln in output.splitlines():
66
+ m = re.search(r":(\d{4,5})\s", ln)
67
+ if not m:
68
+ continue
69
+ port = int(m.group(1))
70
+ pm = re.search(r"pid=(\d+)", ln)
71
+ pid = int(pm.group(1)) if pm else 0
72
+ local_only = "127.0.0.1" in ln or "[::1]" in ln or "localhost" in ln
73
+ if pid and pid not in pid_map:
74
+ pid_map[pid] = _proc_detail(pid)
75
+ ports.append({"port": port, "pid": pid, "local_only": local_only,
76
+ "process": pid_map.get(pid, {}).get("name", ""),
77
+ "project": pid_map.get(pid, {}).get("project", "")})
78
+ dedup = {}
79
+ for p in ports:
80
+ key = p["port"]
81
+ if key not in dedup or (p["pid"] and not dedup[key]["pid"]):
82
+ dedup[key] = p
83
+ return sorted(dedup.values(), key=lambda x: x["port"])
84
+
85
+
86
+ def _ports_linux():
87
+ """Linux/macOS:ss 优先,netstat 兜底。"""
88
+ try:
89
+ proc = subprocess.run(["ss", "-tlnp"], capture_output=True, timeout=15)
90
+ if proc.returncode == 0 and proc.stdout:
91
+ return _parse_ss(proc.stdout.decode("utf-8", "replace"))
92
+ except (OSError, subprocess.TimeoutExpired):
93
+ pass
94
+ try:
95
+ proc = subprocess.run(["netstat", "-tlnp"], capture_output=True, timeout=15)
96
+ if proc.returncode == 0 and proc.stdout:
97
+ return _parse_ss(proc.stdout.decode("utf-8", "replace"))
98
+ except (OSError, subprocess.TimeoutExpired):
99
+ pass
100
+ return []
101
+
102
+
103
+ def _ports_windows(with_names=True):
104
+ """Windows:netstat -ano -p TCP 拿端口,PowerShell 一次性补进程名。"""
105
+ try:
106
+ proc = subprocess.run(
107
+ ["C:\\Windows\\System32\\netstat.exe", "-ano", "-p", "TCP"],
108
+ capture_output=True, timeout=15)
109
+ except (OSError, subprocess.TimeoutExpired):
110
+ return []
111
+ ports = []
112
+ for ln in proc.stdout.decode("utf-8", "replace").splitlines():
113
+ parts = ln.split()
114
+ if len(parts) < 5 or parts[0] != "TCP" or parts[3] != "LISTENING":
115
+ continue
116
+ local = parts[1]
117
+ pid_str = parts[4]
118
+ if not pid_str.isdigit():
119
+ continue
120
+ addr, _, port_str = local.rpartition(":")
121
+ if not port_str.isdigit():
122
+ continue
123
+ ports.append({"port": int(port_str), "pid": int(pid_str),
124
+ "local_only": addr in ("127.0.0.1", "[::1]", "::1"),
125
+ "process": "", "project": ""})
126
+ if with_names and ports:
127
+ try:
128
+ pn = subprocess.run(
129
+ ["C:\\Windows\\System32\\WindowsPowerShell\\v1.0\\powershell.exe",
130
+ "-NoProfile", "-Command",
131
+ "Get-Process | Select-Object Id,ProcessName | ConvertTo-Json -Compress"],
132
+ capture_output=True, timeout=15)
133
+ procs = json.loads(pn.stdout.decode("utf-8", "replace"))
134
+ if isinstance(procs, dict):
135
+ procs = [procs]
136
+ name_map = {p.get("Id"): p.get("ProcessName", "") for p in procs}
137
+ for p in ports:
138
+ p["process"] = name_map.get(p["pid"], "")
139
+ except Exception:
140
+ pass
141
+ dedup = {}
142
+ for p in ports:
143
+ dedup.setdefault(p["port"], p)
144
+ return sorted(dedup.values(), key=lambda x: x["port"])
145
+
146
+
147
+ def listening_ports(with_names=True):
148
+ """扫描本机所有 LISTEN 端口,返回 [{port, pid, process, project, local_only}]。
149
+
150
+ project 从进程 CWD 推断项目根目录名(POSIX /proc 可得,Windows 留空)。
151
+ with_names=False:跳过进程名/归属补全(Windows 下省掉 PowerShell 一次
152
+ 起跳,重验场景用),process/project 恒为空串。
153
+ 结果按端口号排序,重复端口去重(保留有 PID 信息的条目)。"""
154
+ if os.name == "nt":
155
+ return _ports_windows(with_names)
156
+ return _ports_linux()
157
+
158
+
159
+ def close_port(port):
160
+ """温和关闭端口上的进程。返回 (ok, message)。关闭前重验 PID 绑定。"""
161
+ for p in listening_ports():
162
+ if p["port"] != port:
163
+ continue
164
+ pid = p.get("pid", 0)
165
+ if not pid or pid <= 4:
166
+ return False, "系统进程,不关闭"
167
+ if pid == os.getpid():
168
+ return False, "不能关闭自身服务进程"
169
+ if p.get("process", "").lower() in _SYSTEM_PROCS:
170
+ return False, "系统服务,不关闭"
171
+ # 重验 PID 仍在监听该端口(防 PID 复用竞态);轻量扫描省掉 PowerShell
172
+ still = any(pp["port"] == port and pp["pid"] == pid
173
+ for pp in listening_ports(with_names=False))
174
+ if not still:
175
+ return False, "PID %d 已不在端口 %d 上监听(竞态)" % (pid, port)
176
+ if os.name == "posix":
177
+ try:
178
+ os.kill(pid, 15) # SIGTERM
179
+ return True, "已发送 SIGTERM(PID %d)" % pid
180
+ except OSError as e:
181
+ return False, str(e)[:160]
182
+ try:
183
+ subprocess.run(["taskkill", "/PID", str(pid)],
184
+ timeout=10, capture_output=True)
185
+ return True, "已发送关闭信号(PID %d)" % pid
186
+ except Exception as e:
187
+ return False, str(e)[:160]
188
+ return False, "端口 %d 未找到监听进程" % port
@@ -0,0 +1,38 @@
1
+ # -*- coding: utf-8 -*-
2
+ """自由文本脱敏基础函数;不依赖业务模块,供各类本地台账复用。"""
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+
8
+ _KEY_PATTERNS = (
9
+ (re.compile(r"\bsk-[A-Za-z0-9_-]{8,}"), "[key]"),
10
+ (re.compile(r"\b(?:Bearer|bearer)\s+\S+"), "Bearer [key]"),
11
+ (re.compile(r"(?i)\b((?:api[_-]?|access[_-]?|secret[_-]?|auth[_-]?)(?:key|token|secret))"
12
+ r"""["']?\s*[:=,,]\s*["']?[A-Za-z0-9._~+/=-]{8,}"""), r"\1[key]"),
13
+ (re.compile(r"\b[0-9a-fA-F]{40,}\b"), "[token]"),
14
+ )
15
+
16
+ _PATH_PATTERNS = (
17
+ (re.compile(r"(?i)\b[A-Z]:\\(?:[^\\/:*?\"<>|\r\n]+\\)*[^\\/:*?\"<>|\r\n]*"),
18
+ lambda m: "[path]" + m.group(0).split("\\")[-1]),
19
+ (re.compile(r"(?i)\b(?:\\\\[^\\\s]+\\[^\s]+)"), "[path]"),
20
+ (re.compile(r"(?:/Users/|/home/|~)[^\s\"':]+"),
21
+ lambda m: "[path]" + m.group(0).rsplit("/", 1)[-1]),
22
+ )
23
+
24
+
25
+ def scrub_text(text, limit=600):
26
+ """剥离常见密钥、令牌和绝对路径,再按字符上限截断。"""
27
+ if not isinstance(text, str):
28
+ text = "" if text is None else str(text)
29
+ out = text
30
+ for pattern, replacement in _KEY_PATTERNS:
31
+ out = pattern.sub(replacement, out)
32
+ for pattern, replacement in _PATH_PATTERNS:
33
+ try:
34
+ out = pattern.sub(replacement, out)
35
+ except Exception:
36
+ pass
37
+ out = out.strip()
38
+ return out[:limit] + "…" if len(out) > limit else out
@@ -37,7 +37,7 @@ def _binding_bonus(agent_id, dispatch_mode=False):
37
37
  return 0.0
38
38
 
39
39
 
40
- def _history_bonus(stats, agent_id, ttype):
40
+ def _history_bonus(stats, agent_id, ttype):
41
41
  s = (stats.get(agent_id) or {}).get(ttype) or {}
42
42
  if not s.get("runs"):
43
43
  return 0.0
@@ -47,9 +47,34 @@ def _history_bonus(stats, agent_id, ttype):
47
47
  # 偶发失败把智能体埋了)。0/3 全败 = -18:常挂的 CLI 必须排到无历史的新
48
48
  # 面孔之后(2026-09-17 前败率不扣分,0/3 还拿 +6 经验分,比没跑过还高)。
49
49
  loss_penalty = 24.0 * (1.0 - win_rate) * min(1.0, runs / 3.0)
50
- return round(18.0 * win_rate + min(6.0, runs) - loss_penalty, 1)
51
-
52
-
50
+ return round(18.0 * win_rate + min(6.0, runs) - loss_penalty, 1)
51
+
52
+
53
+ def _online_bonus(agent, role, ttype):
54
+ """近期真实运行信号:成功率、P95 延迟和均价只作软加减分。"""
55
+ try:
56
+ from . import usage
57
+ metrics = usage.routing_stats(task_type=ttype, role=role,
58
+ agent=agent.get("id") or "")
59
+ samples = int(metrics.get("samples") or 0)
60
+ if not samples:
61
+ return 0.0, ""
62
+ rate = float(metrics.get("success_rate") or 0.0)
63
+ success_score = max(-8.0, min(8.0, (rate - 0.75) * 24.0))
64
+ p95 = max(0.0, float(metrics.get("p95_duration_s") or 0.0))
65
+ latency_score = -min(6.0, max(0.0, (p95 - 45.0) / 10.0))
66
+ cost = max(0.0, float(metrics.get("avg_cost_usd") or 0.0))
67
+ cost_score = -min(4.0, max(0.0, (cost - 0.01) / 0.01))
68
+ total = round(success_score + latency_score + cost_score, 1)
69
+ success_samples = int(metrics.get("success_samples") or samples)
70
+ reason = (",在线 %d/%d 验收成功(%+.1f),P95 %.1fs(%+.1f),均价 $%.4f(%+.1f)"
71
+ % (int(metrics.get("successes") or 0), success_samples,
72
+ success_score, p95, latency_score, cost, cost_score))
73
+ return total, reason
74
+ except Exception:
75
+ return 0.0, ""
76
+
77
+
53
78
  def score(agent, role, ttype, stats=None):
54
79
  """返回 (总分, 理由字符串)。配额惩罚:catalog 里配了
55
80
  quota_tokens_per_hour 的智能体,本小时用量越接近配额分越低
@@ -65,12 +90,13 @@ def score(agent, role, ttype, stats=None):
65
90
  btxt = ",绑定链可用(+%s)" % bb
66
91
  elif bb < 0:
67
92
  btxt = ",绑定链为空:相关步骤将判失败(%s)" % bb
68
- hb = _history_bonus(stats, agent.get("id"), ttype)
93
+ hb = _history_bonus(stats, agent.get("id"), ttype)
94
+ online, online_txt = _online_bonus(agent, role, ttype)
69
95
  # 保持公开 score() 的历史绝对分值;运行级候选由 pipeline 标记画像后
70
96
  # 才启用能力亲和度,避免旧插件/测试调用被新权重悄然改变。
71
97
  affinity, affinity_txt = (dispatch.agent_affinity(agent.get("kind"), ttype, role)
72
98
  if use_dispatch else (0.0, "兼容模式"))
73
- total = base + bb + hb + affinity
99
+ total = base + bb + hb + affinity + online
74
100
  hs = (stats.get(agent.get("id")) or {}).get(ttype)
75
101
  htxt = (",历史 %d/%d 胜(%s)" % (hs["wins"], hs["runs"], "%+.1f" % hb)) if hs else ",无历史记录"
76
102
  quota_txt = ""
@@ -87,8 +113,8 @@ def score(agent, role, ttype, stats=None):
87
113
  if penalty:
88
114
  total += penalty
89
115
  quota_txt = ",本小时 %d/%d tokens(%s)" % (used, quota, penalty)
90
- return total, "能力基线 %d,%s%s%s%s,总分 %s" % (
91
- base, affinity_txt, btxt, htxt, quota_txt, round(total, 1))
116
+ return total, "能力基线 %d,%s%s%s%s%s,总分 %s" % (
117
+ base, affinity_txt, btxt, htxt, online_txt, quota_txt, round(total, 1))
92
118
 
93
119
 
94
120
  def pick(agents, role, ttype, stats=None, exclude=()):
@@ -106,12 +132,14 @@ def pick(agents, role, ttype, stats=None, exclude=()):
106
132
  return best[1], best_reason
107
133
 
108
134
 
109
- def route_plan(agents, role, task_spec, stats=None, exclude=()):
110
- """生成可审计的候选排序,供运行详情展示和后续 fallback 使用。"""
135
+ def route_plan(agents, role, task_spec, stats=None, exclude=(), selected=None,
136
+ participants=(), selection_reason=""):
137
+ """生成可审计的候选排序,并可用实际选路覆盖评分预选结果。"""
111
138
  if stats is None:
112
139
  stats = history.agent_stats()
113
- real = [a for a in agents or [] if a.get("mode") == "real"]
114
- pool = real if real else list(agents or [])
140
+ # pick 保持同一候选池;绑定/健康扣分仍由 score modelhub 负责,
141
+ # 诊断不能悄悄排除实际可能被选中的 mock 或备用 CLI。
142
+ pool = list(agents or [])
115
143
  rows = []
116
144
  for index, agent in enumerate(pool):
117
145
  if agent.get("id") in exclude:
@@ -127,9 +155,25 @@ def route_plan(agents, role, task_spec, stats=None, exclude=()):
127
155
  "score": round(total, 1), "reason": reason,
128
156
  "order": index})
129
157
  rows.sort(key=lambda x: (-x["score"], x["order"]))
130
- return {"role": role, "selected": rows[0]["agent_id"] if rows else "",
158
+ selected_id = (selected or {}).get("id") if isinstance(selected, dict) else ""
159
+ if selected_id and not any(x["agent_id"] == selected_id for x in rows):
160
+ rows.append({"agent_id": selected_id,
161
+ "label": selected.get("label") or selected_id,
162
+ "kind": selected.get("kind") or "builtin",
163
+ "score": 0.0, "reason": selection_reason or "实际选路",
164
+ "order": len(rows)})
165
+ chosen = selected_id or (rows[0]["agent_id"] if rows else "")
166
+ participant_ids = []
167
+ for agent in participants or ():
168
+ agent_id = agent.get("id") if isinstance(agent, dict) else str(agent or "")
169
+ if agent_id and agent_id not in participant_ids:
170
+ participant_ids.append(agent_id)
171
+ active_ids = participant_ids or ([chosen] if chosen else [])
172
+ return {"role": role, "selected": chosen, "participants": participant_ids,
173
+ "selection_reason": selection_reason,
131
174
  "candidates": rows,
132
- "fallback": [x["agent_id"] for x in rows[1:]]}
175
+ "fallback": [x["agent_id"] for x in rows
176
+ if x["agent_id"] not in active_ids]}
133
177
 
134
178
 
135
179
  def pick_reviewer(agents, impl, ttype, stats=None):
@@ -768,9 +768,10 @@ def _resolve_attempts(agent):
768
768
  """
769
769
  chain = agent.get("call_chain") or []
770
770
  if chain:
771
- return [{"model": (e.get("model") or "").strip() or None,
772
- "env": dict(e.get("env") or {}),
773
- "from_chain": True,
771
+ return [{"model": (e.get("model") or "").strip() or None,
772
+ "env": dict(e.get("env") or {}),
773
+ "provider": e.get("provider") or {},
774
+ "from_chain": True,
774
775
  "own_cp": "codex_provider" in e,
775
776
  "codex_provider": e.get("codex_provider"),
776
777
  "provider_id": e.get("provider_id") or "",
@@ -778,8 +779,12 @@ def _resolve_attempts(agent):
778
779
  base_model = agent.get("model")
779
780
  fb = [m for m in (agent.get("model_fallbacks") or []) if m and m != base_model]
780
781
  models_to_try = ([base_model] if base_model else []) + fb
781
- return [{"model": m or None, "env": {}, "from_chain": False, "own_cp": False,
782
- "codex_provider": None, "provider_id": "", "key_id": ""}
782
+ provider = agent.get("provider") or {}
783
+ return [{"model": m or None, "env": {}, "provider": provider,
784
+ "from_chain": False, "own_cp": False,
785
+ "codex_provider": None,
786
+ "provider_id": provider.get("id") if isinstance(provider, dict) else "",
787
+ "key_id": ""}
783
788
  for m in (models_to_try or [None])[:3]]
784
789
 
785
790
 
@@ -1056,9 +1061,11 @@ def run_agent(agent, prompt, workdir=None, readonly=True,
1056
1061
  os.remove(tf)
1057
1062
  except OSError:
1058
1063
  pass
1059
- out = {"ok": res["ok"], "text": "", "json": None, "cost_usd": 0.0,
1060
- "tokens": 0, "usage": None, "error": "", "error_code": "",
1061
- "sid": "", "raw": res, "kind": kind, "model": att["model"]}
1064
+ out = {"ok": res["ok"], "text": "", "json": None, "cost_usd": 0.0,
1065
+ "tokens": 0, "usage": None, "error": "", "error_code": "",
1066
+ "sid": "", "raw": res, "kind": kind, "model": att["model"],
1067
+ "provider_id": att.get("provider_id") or "",
1068
+ "provider": att.get("provider") or {}}
1062
1069
  if not res["ok"]:
1063
1070
  # stderr 与 stdout 都要进错误串:codex 把 "Reading prompt from
1064
1071
  # stdin..." 打在 stderr,真正的配额/限流错误全在 stdout 的 JSONL
@@ -161,8 +161,21 @@ def check(force=False):
161
161
  return out
162
162
 
163
163
 
164
- def apply_upgrade():
165
- """发起升级:建 mgmt run 异步跑 npm install -g @latest。返回 {run_id} 或 {error}。"""
164
+ _PENDING_PORT = None # apply_upgrade 记下的服务端口,升级成功后自动重启用
165
+
166
+
167
+ def apply_upgrade(port=None):
168
+ """发起升级:建 mgmt run 异步跑 npm install -g @latest。返回 {run_id} 或 {error}。
169
+
170
+ port=服务端口:升级成功且版本真变时会自动就地重启(用户拍板 2026-09-21:
171
+ 升级完不该再要求手动点「重启服务生效」——旧进程滞留是 unknown api/界面
172
+ 闪烁/老宠物一类「升级了没生效」事故的总根子)。拿不到端口或运行环境不
173
+ 具备时自动跳过,回落版本页的手动重启按钮。"""
174
+ global _PENDING_PORT
175
+ try:
176
+ _PENDING_PORT = int(port) if port else None
177
+ except (TypeError, ValueError):
178
+ _PENDING_PORT = None
166
179
  if install_mode() != "npm":
167
180
  return {"error": "当前安装方式不支持自动升级(见版本页说明)"}
168
181
  from . import store, jobs
@@ -176,15 +189,15 @@ def apply_upgrade():
176
189
  try:
177
190
  jobs.enqueue({"kind": "selfupgrade", "run_id": run["id"]})
178
191
  except Exception:
179
- # run 已持久化;启动失败时显式收口,版本页不能停在误导性的待启动状态。
180
- log.exception("selfupdate: 升级任务启动失败 run=%s", run["id"])
192
+ # run 已持久化;启动失败时显式收口,版本页不能停在误导性的待启动状态。
193
+ log.exception("selfupdate: 升级任务启动失败 run=%s", run["id"])
181
194
  try:
182
195
  store.update_run(run["id"], status="failed",
183
- error="升级任务启动失败,本次未排队,请稍后重试",
196
+ error="升级任务启动失败,本次未排队,请稍后重试",
184
197
  ended_at=time.strftime("%Y-%m-%d %H:%M:%S"))
185
198
  except Exception:
186
199
  log.exception("selfupdate: 升级运行失败收口失败 run=%s", run["id"])
187
- return {"error": "升级任务启动失败,本次未排队,请稍后重试", "run_id": run["id"]}
200
+ return {"error": "升级任务启动失败,本次未排队,请稍后重试", "run_id": run["id"]}
188
201
  return {"run_id": run["id"]}
189
202
 
190
203
 
@@ -209,39 +222,85 @@ def _log_note(log_path, text):
209
222
  pass
210
223
 
211
224
 
212
- def run_upgrade(run_id, log_path, cancel_event=None):
225
+ def _maybe_auto_relaunch(old_pkg, log_path):
226
+ """升级成功后的自动重启(三道守卫,任一不满足就回落手动按钮):
227
+ ①知道服务端口(apply_upgrade 传入);②版本真的变了(同版本重装不折腾);
228
+ ③没有用户任务在跑(jobs._alive 只剩本升级任务自己)——正在干活的任务
229
+ 不能被升级重启打断,此时留给用户挑自己合适的时间手动重启。"""
230
+ import threading
231
+ from . import jobs
232
+ if not _PENDING_PORT:
233
+ _log_note(log_path, "未记录服务端口,跳过自动重启——请在版本页手动重启生效")
234
+ return
235
+ new_pkg = package_version()
236
+ if not old_pkg or new_pkg == old_pkg:
237
+ _log_note(log_path, "版本未变化(%s),无需重启" % (new_pkg or "?"))
238
+ return
239
+ if getattr(jobs, "_alive", 0) > 1:
240
+ _log_note(log_path, "检测到还有 %d 个任务在运行,不自动重启——"
241
+ "完成后请在版本页手动点「重启服务生效」" % (jobs._alive - 1))
242
+ return
243
+ port = _PENDING_PORT
244
+
245
+ def _go():
246
+ drain_started = False
247
+ try:
248
+ time.sleep(3.0) # 留出日志收尾/浏览器看到「升级完成」的窗口
249
+ drain_started = jobs.begin_restart_drain()
250
+ if not drain_started:
251
+ _log_note(log_path, "延时窗口内有新任务进入,不自动重启——"
252
+ "完成后请在版本页手动点「重启服务生效」")
253
+ return
254
+ _log_note(log_path, "自动重启服务以应用新版本 %s …" % new_pkg)
255
+ if relaunch(port):
256
+ self_quit()
257
+ except Exception:
258
+ log.exception("selfupdate: 自动重启失败,请在版本页手动重启")
259
+ finally:
260
+ # 正常 self_quit 会直接结束进程;若拉起失败、异常或测试替身返回,必须
261
+ # 释放停止接单闸,避免当前实例永久拒绝新任务。
262
+ if drain_started:
263
+ jobs.cancel_restart_drain()
264
+ threading.Thread(target=_go, name="selfupdate-relaunch",
265
+ daemon=True).start()
266
+
267
+
268
+ def run_upgrade(run_id, log_path, cancel_event=None):
213
269
  """worker 线程里执行升级命令(run/step 生命周期由 jobs 层管)。
214
270
 
215
271
  包目录被其他进程占用(EBUSY/EPERM:打开包目录的资源管理器/终端窗口、
216
272
  杀毒或索引扫描)是升级失败的最常见原因,且多为暂时性——自动重试
217
- _RETRY_DELAYS 轮,仍败则给人话结论(原始 npm 输出在步骤日志里可查)。"""
218
- res = {}
219
- for attempt, delay in enumerate((0,) + _RETRY_DELAYS):
220
- if cancel_event is not None and cancel_event.is_set():
221
- return {"ok": False, "exit_code": None, "error": "用户主动取消",
222
- "cancelled": True}
223
- if delay:
224
- _log_note(log_path, "目录被占用(EBUSY/EPERM),%d 秒后自动重试(第 %d/%d 次)"
225
- % (delay, attempt, len(_RETRY_DELAYS)))
226
- if cancel_event is not None and cancel_event.wait(delay):
227
- return {"ok": False, "exit_code": None, "error": "用户主动取消",
228
- "cancelled": True}
229
- if cancel_event is None:
230
- time.sleep(delay)
231
- res = runner.run_process(
273
+ _RETRY_DELAYS 轮,仍败则给人话结论(原始 npm 输出在步骤日志里可查)。
274
+ 成功且版本真变时自动重启服务(_maybe_auto_relaunch,守卫见其 docstring)。"""
275
+ old_pkg = package_version()
276
+ res = {}
277
+ for attempt, delay in enumerate((0,) + _RETRY_DELAYS):
278
+ if cancel_event is not None and cancel_event.is_set():
279
+ return {"ok": False, "exit_code": None, "error": "用户主动取消",
280
+ "cancelled": True}
281
+ if delay:
282
+ _log_note(log_path, "目录被占用(EBUSY/EPERM),%d 秒后自动重试(第 %d/%d 次)"
283
+ % (delay, attempt, len(_RETRY_DELAYS)))
284
+ if cancel_event is not None and cancel_event.wait(delay):
285
+ return {"ok": False, "exit_code": None, "error": "用户主动取消",
286
+ "cancelled": True}
287
+ if cancel_event is None:
288
+ time.sleep(delay)
289
+ res = runner.run_process(
232
290
  argv=_npm_argv("install", "-g", _PKG_NAME + "@latest"),
233
291
  # Windows 上 npm 换版本靠把包目录整体改名(codebee → .codebee-xxx);
234
292
  # cwd 若落在本包内,目录被自身进程占用,rename 必报 EBUSY——钉在包外
235
- cwd=str(Path.home()), timeout=900, log_path=log_path,
236
- cancel_event=cancel_event)
237
- if res.get("cancelled"):
238
- return {"ok": False, "exit_code": res.get("exit_code"),
239
- "error": "用户主动取消", "cancelled": True}
293
+ cwd=str(Path.home()), timeout=900, log_path=log_path,
294
+ cancel_event=cancel_event)
295
+ if res.get("cancelled"):
296
+ return {"ok": False, "exit_code": res.get("exit_code"),
297
+ "error": "用户主动取消", "cancelled": True}
240
298
  if res["ok"] or not _locked_error(res):
241
299
  break
242
300
  if res["ok"]:
243
301
  with _LOCK: # 装完即过期查新缓存,重启后自然拿到新版本
244
302
  _CHECK_CACHE["result"] = None
303
+ _maybe_auto_relaunch(old_pkg, log_path)
245
304
  return {"ok": True, "exit_code": res["exit_code"], "error": ""}
246
305
  stderr = res["stderr"] or ""
247
306
  if _locked_error(res):