codebee 0.1.23 → 0.1.25
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 +26 -0
- package/README.md +23 -4
- package/app/core/attachments.py +174 -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 +92 -28
- package/app/core/portguard.py +111 -0
- package/app/core/portscan.py +188 -188
- package/app/core/redact.py +38 -0
- package/app/core/router.py +34 -8
- package/app/core/runner.py +15 -8
- package/app/core/selfupdate.py +86 -27
- package/app/core/store.py +35 -15
- package/app/core/usage.py +226 -24
- package/app/main.py +54 -22
- package/app/pet.py +34 -11
- package/app/ui/app.js +55 -3
- package/app/ui/i18n.js +2 -0
- package/app/ui/index.html +1 -0
- package/package.json +1 -1
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):
|
package/app/core/store.py
CHANGED
|
@@ -246,10 +246,8 @@ def create_task(payload):
|
|
|
246
246
|
raise ValueError("附件落盘失败: %s" % e)
|
|
247
247
|
if items:
|
|
248
248
|
task["attachments"] = items
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
if blk and "## 附件材料" not in task["context"]:
|
|
252
|
-
task["context"] = (task["context"] + blk).strip()
|
|
249
|
+
task["context"] = att_mod.merge_context(
|
|
250
|
+
task["context"], items, workdir=str(wd))
|
|
253
251
|
with LOCK:
|
|
254
252
|
_TASKS[task["id"]] = task
|
|
255
253
|
_save_json(paths.TASKS_DIR / (task["id"] + ".json"), task)
|
|
@@ -684,11 +682,33 @@ def update_run(run_id, expected_status=None, **fields):
|
|
|
684
682
|
return run
|
|
685
683
|
|
|
686
684
|
|
|
687
|
-
def run_dir(run_id):
|
|
688
|
-
return paths.RUNS_DIR / run_id
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
def
|
|
685
|
+
def run_dir(run_id):
|
|
686
|
+
return paths.RUNS_DIR / run_id
|
|
687
|
+
|
|
688
|
+
|
|
689
|
+
def _schedule_run_dir_cleanup(run_id):
|
|
690
|
+
"""把运行目录快速移出可见路径,再后台删除大日志目录。
|
|
691
|
+
|
|
692
|
+
删除任务/运行记录不能让 HTTP 请求同步递归扫描数千个日志文件。
|
|
693
|
+
同盘 rename 是 O(1),原路径立即消失;后台失败也不会影响内存状态。
|
|
694
|
+
"""
|
|
695
|
+
source = paths.RUNS_DIR / str(run_id)
|
|
696
|
+
if not source.exists():
|
|
697
|
+
return
|
|
698
|
+
target = paths.RUNS_DIR / (".deleting-%s-%s" % (run_id, secrets.token_hex(4)))
|
|
699
|
+
try:
|
|
700
|
+
source.rename(target)
|
|
701
|
+
except OSError:
|
|
702
|
+
target = source
|
|
703
|
+
|
|
704
|
+
def _remove():
|
|
705
|
+
shutil.rmtree(target, ignore_errors=True)
|
|
706
|
+
|
|
707
|
+
threading.Thread(target=_remove, name="codebee-delete-%s" % run_id[:12],
|
|
708
|
+
daemon=True).start()
|
|
709
|
+
|
|
710
|
+
|
|
711
|
+
def delete_run(run_id):
|
|
692
712
|
"""删除一条运行记录(内存 + 磁盘目录)。返回 (ok, 错误信息)。"""
|
|
693
713
|
if not _valid_id(run_id):
|
|
694
714
|
return False, "非法的记录 ID"
|
|
@@ -699,7 +719,7 @@ def delete_run(run_id):
|
|
|
699
719
|
if run.get("status") in ("queued", "running"):
|
|
700
720
|
return False, "运行中的记录不能删除,请先取消"
|
|
701
721
|
del _RUNS[run_id]
|
|
702
|
-
|
|
722
|
+
_schedule_run_dir_cleanup(run_id)
|
|
703
723
|
bump_state()
|
|
704
724
|
return True, ""
|
|
705
725
|
|
|
@@ -1032,7 +1052,7 @@ def delete_runs(run_ids):
|
|
|
1032
1052
|
skipped += 1
|
|
1033
1053
|
continue
|
|
1034
1054
|
del _RUNS[rid]
|
|
1035
|
-
|
|
1055
|
+
_schedule_run_dir_cleanup(rid)
|
|
1036
1056
|
deleted += 1
|
|
1037
1057
|
if deleted:
|
|
1038
1058
|
bump_state()
|
|
@@ -1051,7 +1071,7 @@ def clear_runs():
|
|
|
1051
1071
|
for rid in targets:
|
|
1052
1072
|
del _RUNS[rid]
|
|
1053
1073
|
for rid in targets:
|
|
1054
|
-
|
|
1074
|
+
_schedule_run_dir_cleanup(rid)
|
|
1055
1075
|
bump_state()
|
|
1056
1076
|
return len(targets), skipped
|
|
1057
1077
|
|
|
@@ -1090,9 +1110,9 @@ def delete_task(task_id):
|
|
|
1090
1110
|
del _TASKS[task_id]
|
|
1091
1111
|
for rid in run_ids:
|
|
1092
1112
|
_RUNS.pop(rid, None)
|
|
1093
|
-
for rid in run_ids:
|
|
1094
|
-
|
|
1095
|
-
(paths.TASKS_DIR / (task_id + ".json")).unlink(missing_ok=True)
|
|
1113
|
+
for rid in run_ids:
|
|
1114
|
+
_schedule_run_dir_cleanup(rid)
|
|
1115
|
+
(paths.TASKS_DIR / (task_id + ".json")).unlink(missing_ok=True)
|
|
1096
1116
|
bump_state()
|
|
1097
1117
|
return True, ""
|
|
1098
1118
|
|
package/app/core/usage.py
CHANGED
|
@@ -10,8 +10,9 @@
|
|
|
10
10
|
"""
|
|
11
11
|
from __future__ import annotations
|
|
12
12
|
|
|
13
|
-
import json
|
|
14
|
-
import
|
|
13
|
+
import json
|
|
14
|
+
import math
|
|
15
|
+
import threading
|
|
15
16
|
import time
|
|
16
17
|
|
|
17
18
|
from . import paths
|
|
@@ -32,8 +33,12 @@ FIELDS = ("ts", "day", "run_id", "step", "task_id", "task_type", "role", "agent"
|
|
|
32
33
|
"input", "output", "cached", "reasoning", "total", "cost_usd", "source")
|
|
33
34
|
|
|
34
35
|
|
|
35
|
-
def _month_file(day):
|
|
36
|
-
return paths.USAGE_DIR / ("usage-%s.jsonl" % day[:7].replace("-", ""))
|
|
36
|
+
def _month_file(day):
|
|
37
|
+
return paths.USAGE_DIR / ("usage-%s.jsonl" % day[:7].replace("-", ""))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _quality_file(day):
|
|
41
|
+
return paths.USAGE_DIR / ("routing-quality-%s.jsonl" % day[:7].replace("-", ""))
|
|
37
42
|
|
|
38
43
|
|
|
39
44
|
def _parse_int(v):
|
|
@@ -93,10 +98,13 @@ def record(source="", run_id="", task_id="", task_type="", role="", step=0,
|
|
|
93
98
|
rec.update({"input": inp, "output": out, "cached": cach,
|
|
94
99
|
"reasoning": reas, "total": total})
|
|
95
100
|
line = json.dumps(rec, ensure_ascii=False)
|
|
96
|
-
with LOCK:
|
|
97
|
-
paths.USAGE_DIR.mkdir(parents=True, exist_ok=True)
|
|
98
|
-
with open(_month_file(rec["day"]), "a", encoding="utf-8") as f:
|
|
99
|
-
f.write(line + "\n")
|
|
101
|
+
with LOCK:
|
|
102
|
+
paths.USAGE_DIR.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
with open(_month_file(rec["day"]), "a", encoding="utf-8") as f:
|
|
104
|
+
f.write(line + "\n")
|
|
105
|
+
_ROUTING_CACHE["data"].clear()
|
|
106
|
+
_ROUTING_RECORDS_CACHE.clear()
|
|
107
|
+
_HOURLY_CACHE["ts"] = 0.0
|
|
100
108
|
except Exception:
|
|
101
109
|
pass
|
|
102
110
|
|
|
@@ -196,8 +204,11 @@ def _tool_of(agent_id):
|
|
|
196
204
|
return a or "unknown"
|
|
197
205
|
|
|
198
206
|
|
|
199
|
-
_HOURLY_CACHE = {"ts": 0.0, "val": {}}
|
|
200
|
-
_HOURLY_TTL = 60.0 # 秒:路由调用频繁但台账追加低频,60s 缓存足够新鲜
|
|
207
|
+
_HOURLY_CACHE = {"ts": 0.0, "val": {}}
|
|
208
|
+
_HOURLY_TTL = 60.0 # 秒:路由调用频繁但台账追加低频,60s 缓存足够新鲜
|
|
209
|
+
_ROUTING_CACHE = {"data": {}}
|
|
210
|
+
_ROUTING_RECORDS_CACHE = {}
|
|
211
|
+
_ROUTING_TTL = 15.0
|
|
201
212
|
|
|
202
213
|
|
|
203
214
|
def agent_tokens_recent(agent, hours=1):
|
|
@@ -224,7 +235,7 @@ def agent_tokens_recent(agent, hours=1):
|
|
|
224
235
|
return 0
|
|
225
236
|
|
|
226
237
|
|
|
227
|
-
def _iter_records(days):
|
|
238
|
+
def _iter_records(days):
|
|
228
239
|
"""按时间范围读取台账(days=0 表示全部)。返回按写入顺序的记录列表。"""
|
|
229
240
|
out = []
|
|
230
241
|
try:
|
|
@@ -232,11 +243,14 @@ def _iter_records(days):
|
|
|
232
243
|
except Exception:
|
|
233
244
|
return out
|
|
234
245
|
since_day = ""
|
|
235
|
-
if days:
|
|
236
|
-
import datetime
|
|
237
|
-
d = (datetime.date.today() - datetime.timedelta(days=int(days) - 1)).isoformat()
|
|
238
|
-
since_day = d
|
|
239
|
-
|
|
246
|
+
if days:
|
|
247
|
+
import datetime
|
|
248
|
+
d = (datetime.date.today() - datetime.timedelta(days=int(days) - 1)).isoformat()
|
|
249
|
+
since_day = d
|
|
250
|
+
since_month = since_day[:7].replace("-", "") if since_day else ""
|
|
251
|
+
for p in files:
|
|
252
|
+
if since_month and p.stem.rsplit("-", 1)[-1] < since_month:
|
|
253
|
+
continue
|
|
240
254
|
try:
|
|
241
255
|
for line in p.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
242
256
|
line = line.strip()
|
|
@@ -253,14 +267,202 @@ def _iter_records(days):
|
|
|
253
267
|
out.append(r)
|
|
254
268
|
except Exception:
|
|
255
269
|
continue
|
|
256
|
-
return out
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
def
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
270
|
+
return out
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
def _iter_quality_records(days):
|
|
274
|
+
"""读取独立质量台账;不混入用量统计的调用数、token 和成本。"""
|
|
275
|
+
out = []
|
|
276
|
+
try:
|
|
277
|
+
files = sorted(paths.USAGE_DIR.glob("routing-quality-*.jsonl")) \
|
|
278
|
+
if paths.USAGE_DIR.is_dir() else []
|
|
279
|
+
except Exception:
|
|
280
|
+
return out
|
|
281
|
+
since_day = ""
|
|
282
|
+
if days:
|
|
283
|
+
import datetime
|
|
284
|
+
since_day = (datetime.date.today()
|
|
285
|
+
- datetime.timedelta(days=int(days) - 1)).isoformat()
|
|
286
|
+
since_month = since_day[:7].replace("-", "") if since_day else ""
|
|
287
|
+
for path in files:
|
|
288
|
+
if since_month and path.stem.rsplit("-", 1)[-1] < since_month:
|
|
289
|
+
continue
|
|
290
|
+
try:
|
|
291
|
+
for line in path.read_text(encoding="utf-8", errors="replace").splitlines():
|
|
292
|
+
try:
|
|
293
|
+
row = json.loads(line)
|
|
294
|
+
except Exception:
|
|
295
|
+
continue
|
|
296
|
+
if isinstance(row, dict) and (not since_day or row.get("day", "") >= since_day):
|
|
297
|
+
out.append(row)
|
|
298
|
+
except Exception:
|
|
299
|
+
continue
|
|
300
|
+
return out
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _routing_records(days):
|
|
304
|
+
"""同一轮候选评分共享一次台账解析,避免每个候选重复扫全盘。"""
|
|
305
|
+
key = (str(paths.USAGE_DIR), int(days))
|
|
306
|
+
now = time.time()
|
|
307
|
+
with LOCK:
|
|
308
|
+
cached = _ROUTING_RECORDS_CACHE.get(key)
|
|
309
|
+
if cached and now - cached[0] <= _ROUTING_TTL:
|
|
310
|
+
return list(cached[1]), list(cached[2])
|
|
311
|
+
calls, quality = _iter_records(days), _iter_quality_records(days)
|
|
312
|
+
with LOCK:
|
|
313
|
+
_ROUTING_RECORDS_CACHE[key] = (now, list(calls), list(quality))
|
|
314
|
+
return calls, quality
|
|
315
|
+
|
|
316
|
+
|
|
317
|
+
def record_quality_for_run(run_id, quality_ok, agent=""):
|
|
318
|
+
"""把验收结论归因到最终实际产出者,不重复计入用量。"""
|
|
319
|
+
try:
|
|
320
|
+
run_id = str(run_id or "")[:64]
|
|
321
|
+
if not run_id:
|
|
322
|
+
return 0
|
|
323
|
+
calls = [r for r in _iter_records(2)
|
|
324
|
+
if r.get("run_id") == run_id and r.get("ok") is True]
|
|
325
|
+
prefixes = ("implement", "fix", "draft", "revise", "polish", "direct", "author")
|
|
326
|
+
calls = [r for r in calls if str(r.get("role") or "").lower().startswith(prefixes)]
|
|
327
|
+
agent = str(agent or "")[:40]
|
|
328
|
+
unique = {}
|
|
329
|
+
for row in calls:
|
|
330
|
+
key = tuple(str(row.get(k) or "") for k in
|
|
331
|
+
("task_id", "task_type", "role", "agent", "model", "provider"))
|
|
332
|
+
unique[key] = row
|
|
333
|
+
if not unique:
|
|
334
|
+
return 0
|
|
335
|
+
existing = {(r.get("run_id"), r.get("role"), r.get("agent"),
|
|
336
|
+
r.get("model"), r.get("provider"))
|
|
337
|
+
for r in _iter_quality_records(2)}
|
|
338
|
+
day = time.strftime("%Y-%m-%d")
|
|
339
|
+
rows = []
|
|
340
|
+
for row in unique.values():
|
|
341
|
+
identity = (run_id, row.get("role"), row.get("agent"),
|
|
342
|
+
row.get("model"), row.get("provider"))
|
|
343
|
+
if identity in existing:
|
|
344
|
+
continue
|
|
345
|
+
# 成功调用不等于产出通过。换将前的实现者即使传输成功,也已被质量
|
|
346
|
+
# 门淘汰,必须留下负样本;最终实际产出者才继承本次验收结论。
|
|
347
|
+
row_quality_ok = bool(quality_ok)
|
|
348
|
+
if agent and str(row.get("agent") or "") != agent:
|
|
349
|
+
row_quality_ok = False
|
|
350
|
+
rows.append({"ts": time.strftime("%Y-%m-%d %H:%M:%S"), "day": day,
|
|
351
|
+
"run_id": run_id, "task_id": row.get("task_id") or "",
|
|
352
|
+
"task_type": row.get("task_type") or "unknown",
|
|
353
|
+
"role": row.get("role") or "unknown",
|
|
354
|
+
"agent": row.get("agent") or "unknown",
|
|
355
|
+
"model": row.get("model") or "(默认)",
|
|
356
|
+
"provider": row.get("provider") or "",
|
|
357
|
+
"quality_ok": row_quality_ok})
|
|
358
|
+
if not rows:
|
|
359
|
+
return 0
|
|
360
|
+
with LOCK:
|
|
361
|
+
paths.USAGE_DIR.mkdir(parents=True, exist_ok=True)
|
|
362
|
+
with open(_quality_file(day), "a", encoding="utf-8") as handle:
|
|
363
|
+
for row in rows:
|
|
364
|
+
handle.write(json.dumps(row, ensure_ascii=False) + "\n")
|
|
365
|
+
_ROUTING_CACHE["data"].clear()
|
|
366
|
+
_ROUTING_RECORDS_CACHE.clear()
|
|
367
|
+
return len(rows)
|
|
368
|
+
except Exception:
|
|
369
|
+
return 0
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _num(r, key):
|
|
373
|
+
return _parse_int(r.get(key))
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def _percentile(values, percentile):
|
|
377
|
+
"""线性插值百分位,空样本返回 0。"""
|
|
378
|
+
if not values:
|
|
379
|
+
return 0.0
|
|
380
|
+
ordered = sorted(float(v) for v in values)
|
|
381
|
+
if len(ordered) == 1:
|
|
382
|
+
return round(ordered[0], 2)
|
|
383
|
+
pos = (len(ordered) - 1) * float(percentile)
|
|
384
|
+
lo = int(math.floor(pos))
|
|
385
|
+
hi = int(math.ceil(pos))
|
|
386
|
+
if lo == hi:
|
|
387
|
+
return round(ordered[lo], 2)
|
|
388
|
+
return round(ordered[lo] + (ordered[hi] - ordered[lo]) * (pos - lo), 2)
|
|
389
|
+
|
|
390
|
+
|
|
391
|
+
def routing_stats(task_type="", role="", agent="", model="", provider="",
|
|
392
|
+
days=90, min_samples=3):
|
|
393
|
+
"""返回路由用的近期真实运行指标。
|
|
394
|
+
|
|
395
|
+
只读取脱敏用量台账;精确维度样本不足时依次回退到任务+角色、任务、
|
|
396
|
+
全局,成功率使用 Beta(3, 1) 平滑,避免单次失败永久压低新候选。
|
|
397
|
+
"""
|
|
398
|
+
try:
|
|
399
|
+
task_type = str(task_type or "")[:32]
|
|
400
|
+
role = str(role or "")[:40]
|
|
401
|
+
agent = str(agent or "")[:40]
|
|
402
|
+
model = str(model or "")[:80]
|
|
403
|
+
provider = str(provider or "")[:60]
|
|
404
|
+
days = max(0, int(days))
|
|
405
|
+
min_samples = max(1, int(min_samples))
|
|
406
|
+
except (TypeError, ValueError):
|
|
407
|
+
days, min_samples = 90, 3
|
|
408
|
+
# DATA_DIR 可在测试、多实例或运行时配置切换;纳入缓存键,避免跨目录
|
|
409
|
+
# 复用另一套台账的线上指标。
|
|
410
|
+
key = (str(paths.DATA_DIR), task_type, role, agent, model, provider,
|
|
411
|
+
days, min_samples)
|
|
412
|
+
now = time.time()
|
|
413
|
+
with LOCK:
|
|
414
|
+
cached = _ROUTING_CACHE["data"].get(key)
|
|
415
|
+
if cached and now - cached[0] <= _ROUTING_TTL:
|
|
416
|
+
return dict(cached[1])
|
|
417
|
+
records, quality_records = _routing_records(days)
|
|
418
|
+
|
|
419
|
+
def matches(record, filters):
|
|
420
|
+
return all(str(record.get(field) or "") == value
|
|
421
|
+
for field, value in filters.items() if value)
|
|
422
|
+
|
|
423
|
+
# 先保留所有已提供的维度;后续逐层放宽,确保模型和 CLI 都能共享一套查询。
|
|
424
|
+
exact = {"task_type": task_type, "role": role, "agent": agent,
|
|
425
|
+
"model": model, "provider": provider}
|
|
426
|
+
fallbacks = [(exact, "exact")]
|
|
427
|
+
task_role = {"task_type": task_type, "role": role}
|
|
428
|
+
if task_role != exact:
|
|
429
|
+
fallbacks.append((task_role, "task-role"))
|
|
430
|
+
if task_type:
|
|
431
|
+
fallbacks.append(({"task_type": task_type}, "task"))
|
|
432
|
+
fallbacks.append(({}, "global"))
|
|
433
|
+
selected, label = [], "global"
|
|
434
|
+
for filters, candidate_label in fallbacks:
|
|
435
|
+
candidate = [r for r in records if matches(r, filters)]
|
|
436
|
+
if len(candidate) >= min_samples or (candidate_label == "global" and candidate):
|
|
437
|
+
selected, label = candidate, candidate_label
|
|
438
|
+
break
|
|
439
|
+
chosen_filters = next((filters for filters, candidate_label in fallbacks
|
|
440
|
+
if candidate_label == label), {})
|
|
441
|
+
quality_selected = [r for r in quality_records if matches(r, chosen_filters)]
|
|
442
|
+
success_rows = quality_selected or selected
|
|
443
|
+
success_key = "quality_ok" if quality_selected else "ok"
|
|
444
|
+
successes = sum(1 for r in success_rows if bool(r.get(success_key)))
|
|
445
|
+
durations = [max(0.0, _parse_float(r.get("duration_s"))) for r in selected]
|
|
446
|
+
costs = [max(0.0, _parse_float(r.get("cost_usd"))) for r in selected]
|
|
447
|
+
samples = len(selected)
|
|
448
|
+
success_samples = len(success_rows)
|
|
449
|
+
result = {
|
|
450
|
+
"samples": samples,
|
|
451
|
+
"quality_samples": len(quality_selected),
|
|
452
|
+
"success_samples": success_samples,
|
|
453
|
+
"successes": successes,
|
|
454
|
+
"success_rate": round((successes + 3.0) / (success_samples + 4.0), 4),
|
|
455
|
+
"p50_duration_s": _percentile(durations, 0.50),
|
|
456
|
+
"p95_duration_s": _percentile(durations, 0.95),
|
|
457
|
+
"avg_cost_usd": round(sum(costs) / samples, 6) if samples else 0.0,
|
|
458
|
+
"fallback": label,
|
|
459
|
+
}
|
|
460
|
+
with LOCK:
|
|
461
|
+
_ROUTING_CACHE["data"][key] = (now, dict(result))
|
|
462
|
+
return result
|
|
463
|
+
|
|
464
|
+
|
|
465
|
+
def _group(records, key):
|
|
264
466
|
"""按 key 聚合:{name: {calls, ok, tokens, input, output, cached, cache_rate, cost_usd, duration_s}}。"""
|
|
265
467
|
groups = {}
|
|
266
468
|
for r in records:
|
package/app/main.py
CHANGED
|
@@ -284,6 +284,18 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
284
284
|
except ValueError:
|
|
285
285
|
edays = 90
|
|
286
286
|
return self._json(200, usage.estimate(task_type=ttype, days=edays))
|
|
287
|
+
if path == "/api/dispatch/replay":
|
|
288
|
+
from core import dispatch_log
|
|
289
|
+
q = parse_qs(urlparse(self.path).query)
|
|
290
|
+
try:
|
|
291
|
+
limit = max(1, min(1000, int((q.get("limit") or ["100"])[0])))
|
|
292
|
+
except (TypeError, ValueError):
|
|
293
|
+
limit = 100
|
|
294
|
+
events = dispatch_log.replay(
|
|
295
|
+
run_id=(q.get("run_id") or [""])[0],
|
|
296
|
+
task_type=(q.get("task_type") or [""])[0],
|
|
297
|
+
limit=limit)
|
|
298
|
+
return self._json(200, {"events": events, "count": len(events)})
|
|
287
299
|
if path == "/api/diagnostics/bundle":
|
|
288
300
|
# 诊断包(zip):脱敏错误台账 + 用量台账 + 环境元信息,供用户贴 Issue
|
|
289
301
|
from core import telemetry
|
|
@@ -741,14 +753,13 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
741
753
|
if path == "/api/selfupdate/apply":
|
|
742
754
|
from core import selfupdate
|
|
743
755
|
try:
|
|
744
|
-
res = selfupdate.apply_upgrade()
|
|
756
|
+
res = selfupdate.apply_upgrade(PORT)
|
|
745
757
|
except Exception:
|
|
746
758
|
log.exception("自更新任务创建失败")
|
|
747
759
|
return self._json(503, {"error": "升级任务创建失败,请稍后重试"})
|
|
748
760
|
return self._json(400, res) if res.get("error") else self._json(200, dict(res, ok=True))
|
|
749
761
|
if path == "/api/selfupdate/restart":
|
|
750
762
|
from core import selfupdate
|
|
751
|
-
global PORT
|
|
752
763
|
if not selfupdate.relaunch(PORT):
|
|
753
764
|
return self._json(400, {"error": "重启参数非法"})
|
|
754
765
|
def _bye():
|
|
@@ -2066,8 +2077,9 @@ class Handler(BaseHTTPRequestHandler):
|
|
|
2066
2077
|
"每个问题给出 2-4 个最常见的选项。只输出 JSON 数组,不要输出其他内容:\n"
|
|
2067
2078
|
'[{"q": "问题", "options": ["选项1", "选项2"]}]' % (ttype, goal[:200]))
|
|
2068
2079
|
try:
|
|
2080
|
+
# 澄清是可选增强,不能占住创建请求;主流程有自己的执行超时。
|
|
2069
2081
|
res = builtin_agent.run(bi, prompt, os.getcwd() if hasattr(os, "getcwd") else ".",
|
|
2070
|
-
timeout=
|
|
2082
|
+
timeout=8)
|
|
2071
2083
|
import json as _json
|
|
2072
2084
|
arr = None
|
|
2073
2085
|
text = (res.get("text") or "").strip()
|
|
@@ -2435,27 +2447,47 @@ def main():
|
|
|
2435
2447
|
# 分发,表现为"时好时坏");加独占锁后双起在这里干净失败并指路。
|
|
2436
2448
|
# (此处不能局部 import os:会让 os 变 main() 的局部名,后面 2127 行
|
|
2437
2449
|
# 的 os.name 直接 UnboundLocalError——顶部已有全局导入,直接用)
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
# 端口占用自动指认(借鉴 leftopen 38★):直接报出 PID/进程/项目归属,
|
|
2444
|
-
# 用户不用再手跑 netstat+tasklist 两连。识别不出时回落上面的手工指路。
|
|
2450
|
+
# 端口占用自动清场(用户拍板 2026-09-21):升级/重启最常见的占用者是
|
|
2451
|
+
# 没退干净的 CodeBee 自家旧实例——先杀再起,别让用户手动 netstat+taskkill。
|
|
2452
|
+
# 判定(main.py 完整路径/npm 打包路径)与清场在 core.portguard。
|
|
2453
|
+
from core import portscan as _ps, portguard as _pg
|
|
2454
|
+
_hint = ""
|
|
2445
2455
|
try:
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
if _h.get("port") != args.port:
|
|
2449
|
-
continue
|
|
2450
|
-
_who = _h.get("process") or "未知进程"
|
|
2451
|
-
_proj = (",项目 %s" % _h["project"]) if _h.get("project") else ""
|
|
2452
|
-
hint = ("占用者:PID %d(%s%s);旧进程杀掉或换 --port 重启"
|
|
2453
|
-
% (_h.get("pid") or 0, _who, _proj))
|
|
2454
|
-
break
|
|
2456
|
+
_cleared, _hint = _pg.clear_stale_port(
|
|
2457
|
+
args.port, paths.APP_DIR / "main.py")
|
|
2455
2458
|
except Exception:
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
+
_cleared, _hint = False, ""
|
|
2460
|
+
if _cleared:
|
|
2461
|
+
try:
|
|
2462
|
+
print("[CodeBee] 端口 %d 被旧实例占用,已自动清场,正在重新绑定…"
|
|
2463
|
+
% args.port, flush=True)
|
|
2464
|
+
time.sleep(1.0) # 让被清进程的监听 socket 完全释放
|
|
2465
|
+
httpd = ThreadedServer((args.host, args.port), Handler)
|
|
2466
|
+
except OSError:
|
|
2467
|
+
httpd = None
|
|
2468
|
+
else:
|
|
2469
|
+
httpd = None
|
|
2470
|
+
if httpd is None:
|
|
2471
|
+
# 清场没成:带占用者指认退出(识别不出回落手工排查提示)
|
|
2472
|
+
hint = ""
|
|
2473
|
+
if os.name == "nt":
|
|
2474
|
+
hint = ("(Windows 排查:netstat -ano | findstr :%d 找到 PID,"
|
|
2475
|
+
"tasklist /FI \"PID eq <PID>\" 看是谁;旧进程杀掉或换 --port)"
|
|
2476
|
+
% args.port)
|
|
2477
|
+
try:
|
|
2478
|
+
for _h in _ps.listening_ports():
|
|
2479
|
+
if _h.get("port") != args.port:
|
|
2480
|
+
continue
|
|
2481
|
+
_who = _h.get("process") or "未知进程"
|
|
2482
|
+
_proj = (",项目 %s" % _h["project"]) if _h.get("project") else ""
|
|
2483
|
+
hint = ("占用者:PID %d(%s%s)%s"
|
|
2484
|
+
% (_h.get("pid") or 0, _who, _proj,
|
|
2485
|
+
(";" + _hint) if _hint else ";旧进程杀掉或换 --port 重启"))
|
|
2486
|
+
break
|
|
2487
|
+
except Exception:
|
|
2488
|
+
pass
|
|
2489
|
+
raise SystemExit("[CodeBee] 端口 %d 已被占用,无法启动:%s %s"
|
|
2490
|
+
% (args.port, e, hint))
|
|
2459
2491
|
|
|
2460
2492
|
def _announce_public(url):
|
|
2461
2493
|
print("[CodeBee] 公网 %s/?token=%s" % (url, tok))
|