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.
@@ -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):
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
- blk = att_mod.context_block(items)
250
- # 复制清单场景下 context 已含附件块(随旧任务沿用),别重复追加
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 delete_run(run_id):
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
- shutil.rmtree(paths.RUNS_DIR / run_id, ignore_errors=True)
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
- shutil.rmtree(paths.RUNS_DIR / rid, ignore_errors=True)
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
- shutil.rmtree(paths.RUNS_DIR / rid, ignore_errors=True)
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
- shutil.rmtree(paths.RUNS_DIR / rid, ignore_errors=True)
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 threading
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
- for p in files:
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 _num(r, key):
260
- return _parse_int(r.get(key))
261
-
262
-
263
- def _group(records, key):
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=60)
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
- hint = ""
2439
- if os.name == "nt":
2440
- hint = ("(Windows 排查:netstat -ano | findstr :%d 找到 PID,"
2441
- "tasklist /FI \"PID eq <PID>\" 看是谁;旧进程杀掉或换 --port)"
2442
- % args.port)
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
- from core import portscan as _ps
2447
- for _h in _ps.listening_ports():
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
- pass
2457
- raise SystemExit("[CodeBee] 端口 %d 已被占用,无法启动:%s %s"
2458
- % (args.port, e, hint))
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))