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.
- package/CHANGELOG.md +28 -0
- package/README.md +20 -3
- package/app/core/aiflavor.py +63 -9
- package/app/core/attachments.py +117 -4
- package/app/core/dispatch.py +34 -5
- package/app/core/dispatch_log.py +113 -0
- package/app/core/errorlog.py +1 -43
- package/app/core/jobs.py +30 -4
- package/app/core/modelhub.py +2 -0
- package/app/core/paths.py +2 -2
- package/app/core/pipeline.py +252 -12
- package/app/core/portguard.py +111 -0
- package/app/core/portscan.py +188 -0
- package/app/core/redact.py +38 -0
- package/app/core/router.py +58 -14
- package/app/core/runner.py +15 -8
- package/app/core/selfupdate.py +86 -27
- package/app/core/skills.py +37 -19
- package/app/core/store.py +2 -4
- package/app/core/task_compile.py +5 -1
- package/app/core/usage.py +226 -24
- package/app/main.py +82 -9
- package/app/pet.py +34 -11
- package/app/ui/app.js +177 -1
- package/app/ui/i18n.js +36 -0
- package/app/ui/index.html +1144 -1137
- package/app/ui/style.css +34 -0
- package/package.json +1 -1
|
@@ -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
|
package/app/core/router.py
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
114
|
-
|
|
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
|
-
|
|
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
|
|
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):
|
package/app/core/runner.py
CHANGED
|
@@ -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
|
-
"
|
|
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
|
-
|
|
782
|
-
|
|
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
|
package/app/core/selfupdate.py
CHANGED
|
@@ -161,8 +161,21 @@ def check(force=False):
|
|
|
161
161
|
return out
|
|
162
162
|
|
|
163
163
|
|
|
164
|
-
|
|
165
|
-
|
|
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
|
|
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
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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):
|