codebee 0.1.21 → 0.1.23

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.
@@ -19,7 +19,7 @@ import re
19
19
  import threading
20
20
  import time
21
21
 
22
- from . import aiflavor, catalog, history, jobs, knowledge, manager, modelhub, mocks, paihang, planner, registry, router, runner, skills, store, usage
22
+ from . import aiflavor, catalog, history, jobs, knowledge, manager, modelhub, mocks, paihang, planner, registry, router, runner, skills, store, task_compile, usage
23
23
  from . import builtin_agent
24
24
  from . import diagnostics
25
25
  from . import paths as paths_mod
@@ -883,6 +883,29 @@ def _code_bestof(run, task, impl, difficulty, ev):
883
883
  pass
884
884
 
885
885
 
886
+ def _record_actual_route(run_id, task, agents, stats, implementer,
887
+ implementers=(), reviewer=None, critics=(), implement_reason="",
888
+ review_reason="", direct=False):
889
+ """把引擎实际选中的执行者/评审者回写到可解释路由计划。"""
890
+ spec = task.get("_compiled_spec") or task_compile.compile_task(task)
891
+ impl_plan = router.route_plan(
892
+ agents, "implement", spec, stats, selected=implementer,
893
+ participants=implementers,
894
+ selection_reason=implement_reason)
895
+ if direct:
896
+ review_plan = {"role": "review", "selected": "", "participants": [],
897
+ "selection_reason": "", "candidates": [], "fallback": []}
898
+ else:
899
+ review_group = list(critics or ())
900
+ actual_reviewer = reviewer or (review_group[0] if review_group else None)
901
+ review_plan = router.route_plan(
902
+ agents, "review", spec, stats, selected=actual_reviewer,
903
+ participants=review_group, selection_reason=review_reason)
904
+ store.update_run(run_id, route_plan={
905
+ "task": spec, "implement": impl_plan, "review": review_plan,
906
+ })
907
+
908
+
886
909
  def _run_code(run, task, agents, ev, stats, mode):
887
910
  run_id = run["id"]
888
911
  workdir = task["workdir"]
@@ -946,6 +969,9 @@ def _run_code(run, task, agents, ev, stats, mode):
946
969
  reviewer, route["reviewer"] = router.pick_reviewer(agents, impl, "code", stats)
947
970
  else:
948
971
  reviewer, route["reviewer"] = _pick_reviewer_legacy(agents, impl)
972
+ _record_actual_route(run_id, task, agents, stats, impl, reviewer=reviewer,
973
+ implement_reason=route.get("implementer", ""),
974
+ review_reason=route.get("reviewer", ""))
949
975
  store.update_run(run_id, route=route)
950
976
 
951
977
  def implement_all(impl_agent, prefix_note):
@@ -1038,6 +1064,11 @@ def _run_code(run, task, agents, ev, stats, mode):
1038
1064
  ok2, res = _run_one(other)
1039
1065
  if ok2:
1040
1066
  store.update_run(run_id, error="", route_note=note)
1067
+ _record_actual_route(
1068
+ run_id, task, agents, stats, other,
1069
+ implementers=[other], reviewer=reviewer,
1070
+ implement_reason=note,
1071
+ review_reason=route.get("reviewer", ""))
1041
1072
  return True
1042
1073
  res_err = "%s;换将后仍失败:%s" % (note, (res.get("error") or "")[:200])
1043
1074
  else:
@@ -1074,6 +1105,13 @@ def _run_code(run, task, agents, ev, stats, mode):
1074
1105
  note="自动修复第 %d 轮" % round_no,
1075
1106
  resume=resume_ctx["session"] if resume_ctx else impl_sid[0],
1076
1107
  require_tools=True)
1108
+ if res["ok"]:
1109
+ _record_actual_route(
1110
+ run_id, task, agents, stats, impl,
1111
+ implementers=[impl], reviewer=reviewer,
1112
+ implement_reason="修复轮由 %s 完成" %
1113
+ (impl.get("label") or impl.get("id")),
1114
+ review_reason=route.get("reviewer", ""))
1077
1115
  if impl.get("mode") == "mock" and res["ok"]:
1078
1116
  pass # mock 不产生真实变更
1079
1117
  review_json, verify_pass, verify_ran = review_and_score()
@@ -1097,6 +1135,11 @@ def _run_code(run, task, agents, ev, stats, mode):
1097
1135
  impl["id"], other["id"], round_no + 1, other_reason)
1098
1136
  store.update_run(run_id, error="")
1099
1137
  impl = other
1138
+ _record_actual_route(
1139
+ run_id, task, agents, stats, impl,
1140
+ implementers=[impl], reviewer=reviewer,
1141
+ implement_reason=note,
1142
+ review_reason=route.get("reviewer", ""))
1100
1143
  switched = True
1101
1144
  round_no += 1
1102
1145
  continue
@@ -1326,6 +1369,13 @@ def _run_direct(run, task, agents, ev, stats, mode):
1326
1369
  store.update_run(run_id, expected_status="running", status="failed",
1327
1370
  error="没有可用智能体", ended_at=_now())
1328
1371
  return
1372
+ actual_impl = impl or {
1373
+ "id": "builtin:%s:%s" % (bi.get("provider_id") or "provider", bi.get("model") or "model"),
1374
+ "label": "CodeBee · %s" % (bi.get("model") or bi.get("provider_name") or "内置模型"),
1375
+ "kind": "builtin",
1376
+ }
1377
+ _record_actual_route(run_id, task, agents, stats, actual_impl,
1378
+ implement_reason=route.get("implementer", ""), direct=True)
1329
1379
  difficulty = task.get("difficulty") or "default"
1330
1380
  step_wd = _resume_workdir(resume_ctx, workdir) if resume_ctx else workdir
1331
1381
  store.update_run(run_id, route=route, difficulty=difficulty)
@@ -1860,6 +1910,31 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
1860
1910
  """连载流水线:大纲 → 逐章起草/评审/修订 → 全局一致性评审 → 合并成书。"""
1861
1911
  import json as _json
1862
1912
  run_id = run["id"]
1913
+ actual_implementers = [impl]
1914
+ actual_critics = list(critics)
1915
+ current_impl = [impl]
1916
+ current_impl_reason = [route.get("author", "")]
1917
+ current_review_reason = [route.get("critics", "")]
1918
+
1919
+ def refresh_actual_route(primary_impl=None, implement_reason=None,
1920
+ review_reason=None):
1921
+ if primary_impl is not None:
1922
+ current_impl[0] = primary_impl
1923
+ if implement_reason is not None:
1924
+ current_impl_reason[0] = implement_reason
1925
+ if review_reason is not None:
1926
+ current_review_reason[0] = review_reason
1927
+ _record_actual_route(
1928
+ run_id, task, agents, stats, current_impl[0],
1929
+ implementers=actual_implementers, critics=actual_critics,
1930
+ implement_reason=current_impl_reason[0],
1931
+ review_reason=current_review_reason[0])
1932
+
1933
+ def remember_agent(bucket, agent):
1934
+ if agent and not any(x.get("id") == agent.get("id") for x in bucket):
1935
+ bucket.append(agent)
1936
+
1937
+ refresh_actual_route(impl)
1863
1938
  workdir = task["workdir"]
1864
1939
  # 续会话步骤的 CLI 启动目录(稿件读写仍用 workdir)
1865
1940
  step_wd = _resume_workdir(resume_ctx, workdir) if resume_ctx else workdir
@@ -2051,6 +2126,10 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2051
2126
  and cj.get("scores"):
2052
2127
  cj_map[spare["id"]] = cj
2053
2128
  scored += 1
2129
+ remember_agent(actual_critics, spare)
2130
+ refresh_actual_route(
2131
+ review_reason="章节评审补位:%s" %
2132
+ (spare.get("label") or spare.get("id")))
2054
2133
  issues_all.extend({"chapter": i, **it}
2055
2134
  for it in (cj.get("issues") or [])[:6])
2056
2135
  break
@@ -2135,6 +2214,10 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2135
2214
  good = False
2136
2215
  txt = ""
2137
2216
  use_prompt = prompt
2217
+ chapter_impl = impl
2218
+ chapter_reason = route.get("author", "")
2219
+ last_attempt_impl = impl
2220
+ last_attempt_reason = chapter_reason
2138
2221
  for draft_attempt in range(3):
2139
2222
  if draft_attempt:
2140
2223
  # 30s / 60s 退避;ev.wait 睡等可被取消即刻唤醒
@@ -2185,6 +2268,9 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2185
2268
  if not (other and other.get("mode") == "real"):
2186
2269
  break
2187
2270
  tried.add(other["id"])
2271
+ last_attempt_impl = other
2272
+ last_attempt_reason = "章节起草换将:%s" % (
2273
+ other_reason or other.get("label") or other.get("id"))
2188
2274
  res = _run_step(run_id, "draft-c%d" % i,
2189
2275
  modelhub.bind_agent(other, difficulty), use_prompt,
2190
2276
  step_wd, readonly=False, ev=ev, timeout=2400,
@@ -2201,13 +2287,20 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2201
2287
  pass
2202
2288
  if good:
2203
2289
  draft_sid = "" # 换将作者无本任会话,revise 另起
2290
+ chapter_impl = last_attempt_impl
2291
+ chapter_reason = last_attempt_reason
2204
2292
  if not good:
2205
2293
  time.sleep(3) # 落盘竞态宽限:CLI 崩溃退出前写的文件可能晚于
2206
2294
  good, txt = _chapter_state() # 退出检查零点几秒才可见(c34 实测)
2295
+ if good:
2296
+ chapter_impl = last_attempt_impl
2297
+ chapter_reason = last_attempt_reason
2207
2298
  if not good:
2208
2299
  store.update_run(run_id, expected_status="running", status="failed",
2209
2300
  error="第 %d 章起草失败: %s" % (i, (res or {}).get("error")), ended_at=_now())
2210
2301
  return
2302
+ remember_agent(actual_implementers, chapter_impl)
2303
+ refresh_actual_route(chapter_impl, implement_reason=chapter_reason)
2211
2304
  if not res["ok"]:
2212
2305
  # 成品是文件不是退出码:CLI 超时但章稿已完整落盘(终章长文实测
2213
2306
  # 反复出现——文件写完、收尾声明没等到)就送评审门把关,别整章作废
@@ -2294,6 +2387,13 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2294
2387
  return
2295
2388
  scored_variants.sort(key=lambda v: (-v["avg"], v["variant"]))
2296
2389
  win = scored_variants[0]
2390
+ win_agent = next((a for a in pool if a.get("id") == win["agent"]), None)
2391
+ if win_agent is not None:
2392
+ remember_agent(actual_implementers, win_agent)
2393
+ refresh_actual_route(
2394
+ win_agent,
2395
+ implement_reason="同章多稿赛马胜出:%s" %
2396
+ (win_agent.get("label") or win_agent.get("id")))
2297
2397
  # 收敛:赢家转正,败稿删除;胜者评审结果直接作为第 1 轮(不重评)
2298
2398
  if win["file"] != ch_file:
2299
2399
  try:
@@ -2401,6 +2501,10 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2401
2501
  _run_step(run_id, "revise-c%d" % i, modelhub.bind_agent(impl, difficulty), prompt,
2402
2502
  step_wd, readonly=False, ev=ev, timeout=2400,
2403
2503
  resume=resume_ctx["session"] if resume_ctx else draft_sid)
2504
+ remember_agent(actual_implementers, impl)
2505
+ refresh_actual_route(
2506
+ impl, implement_reason="章节修订:%s" %
2507
+ (impl.get("label") or impl.get("id")))
2404
2508
  _check_cancel(ev)
2405
2509
  chapter_scores.append({"chapter": i, "title": ch["title"], "means": means,
2406
2510
  "passed": bool(means) and all(v >= threshold_ch for v in means.values()),
@@ -2477,6 +2581,10 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2477
2581
  gmeans_acc.setdefault(d, []).extend(xs)
2478
2582
  gscored += sc
2479
2583
  if gscored:
2584
+ remember_agent(actual_critics, spare)
2585
+ refresh_actual_route(
2586
+ review_reason="全局评审补位:%s" %
2587
+ (spare.get("label") or spare.get("id")))
2480
2588
  break
2481
2589
  if not gscored:
2482
2590
  store.update_run(run_id, expected_status="running", status="failed",
@@ -2526,6 +2634,10 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2526
2634
  resume=resume_ctx["session"] if resume_ctx else None)
2527
2635
  if not res["ok"]:
2528
2636
  continue
2637
+ remember_agent(actual_implementers, impl)
2638
+ refresh_actual_route(
2639
+ impl, implement_reason="全局打磨:%s" %
2640
+ (impl.get("label") or impl.get("id")))
2529
2641
  # 重评该章
2530
2642
  cj_by_agent = {}
2531
2643
  for agent in critics:
@@ -2798,9 +2910,16 @@ def _run_content_review(run, task, agents, ev, stats, mode):
2798
2910
  store.update_run(run_id, expected_status="running", status="failed",
2799
2911
  error="没有可用智能体", ended_at=_now())
2800
2912
  return
2801
- if resume_ctx is not None and mode == "auto":
2802
- critics, route["critics"] = router.pick_critics(
2803
- agents, task.get("type") or "novel", stats, impl=impl)
2913
+ if resume_ctx is not None:
2914
+ if mode == "auto":
2915
+ critics, route["critics"] = router.pick_critics(
2916
+ agents, task.get("type") or "novel", stats, impl=impl)
2917
+ else:
2918
+ critics = _pick_critics_manual(agents, task)
2919
+
2920
+ _record_actual_route(run_id, task, agents, stats, impl, critics=critics,
2921
+ implement_reason=route.get("author", ""),
2922
+ review_reason=route.get("critics", ""))
2804
2923
 
2805
2924
  # ---- 规划(小说为模板计划)
2806
2925
  _wait_gate(run_id, ev)
@@ -3280,6 +3399,18 @@ def execute_run(run_id):
3280
3399
  store.update_run(run_id, expected_status="running", status="failed",
3281
3400
  error="找不到任务 %s" % run.get("task_id"), ended_at=_now())
3282
3401
  return
3402
+ # 统一任务编译:旧字段继续供各引擎读取,规格作为运行级诊断与调度输入落盘。
3403
+ task_spec = task_compile.compile_task(task)
3404
+ store.update_run(run_id, task_spec=task_spec,
3405
+ task_spec_summary=task_compile.summary(task_spec),
3406
+ difficulty=task_spec["difficulty"])
3407
+ task = dict(task)
3408
+ task["_compiled_spec"] = task_spec
3409
+ # 运行内统一使用编译后的难度;store 中历史任务常带 difficulty=auto,
3410
+ # 不能让这个兼容值覆盖 easy/default/hard 的模型调度决策。
3411
+ task["difficulty"] = task_spec["difficulty"]
3412
+ # 同理,历史任务可能保存非法/过期 engine;执行以编译后的流程引擎为准。
3413
+ task["engine"] = task_spec["engine"]
3283
3414
  # 代码版本检出:任务指定了基线版本时,先检出任务分支 tutti/<task-id> 再跑流水线。
3284
3415
  # 显式意图不容静默降级——仓库缺失/脏工作区/引用不存在一律中止运行并报错,
3285
3416
  # 绝不带着用户未提交改动切分支、也不悄悄退回当前 HEAD。
@@ -3319,6 +3450,11 @@ def execute_run(run_id):
3319
3450
  for _agent in agents:
3320
3451
  if isinstance(_agent, dict):
3321
3452
  _agent["_dispatch_task_type"] = task.get("type") or "direct"
3453
+ store.update_run(run_id, route_plan={
3454
+ "task": task_spec,
3455
+ "implement": router.route_plan(agents, "implement", task_spec, stats),
3456
+ "review": router.route_plan(agents, "review", task_spec, stats),
3457
+ })
3322
3458
  mode = task.get("mode") or ("manual" if task.get("implementer") else "auto")
3323
3459
  store.update_run(run_id, mode=mode)
3324
3460
  # engine 决定流水线:code=实现/验证/评审/修复;review=起草/多维评审/修订/门禁;
@@ -3354,8 +3490,12 @@ def execute_run(run_id):
3354
3490
  store.update_run(run_id, expected_status="running", status="failed",
3355
3491
  error="没有可用智能体", ended_at=_now())
3356
3492
  return
3357
- if resume_ctx is not None and mode == "auto":
3358
- critics, route["critics"] = router.pick_critics(agents, task["type"], stats, impl=impl)
3493
+ if resume_ctx is not None:
3494
+ if mode == "auto":
3495
+ critics, route["critics"] = router.pick_critics(
3496
+ agents, task["type"], stats, impl=impl)
3497
+ else:
3498
+ critics = _pick_critics_manual(agents, task)
3359
3499
  if task.get("serial"):
3360
3500
  _run_serial_review(run, task, agents, ev, stats, mode,
3361
3501
  critics, impl, route, resume_ctx, difficulty)
@@ -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
@@ -10,7 +10,14 @@ CAPABILITY = {
10
10
  "aider": 70, "openclaw": 60, "generic": 55, "mock": 10,
11
11
  }
12
12
 
13
- MAX_REPAIR_ROUNDS = 2 # 自动修复循环上限
13
+ MAX_REPAIR_ROUNDS = 2 # 自动修复循环上限
14
+
15
+
16
+ def _task_type(ttype):
17
+ """兼容旧调用的字符串与新任务规格对象。"""
18
+ if isinstance(ttype, dict):
19
+ return str(ttype.get("type") or ttype.get("dimension") or "direct")
20
+ return str(ttype or "direct")
14
21
 
15
22
 
16
23
  def _binding_bonus(agent_id, dispatch_mode=False):
@@ -43,11 +50,12 @@ def _history_bonus(stats, agent_id, ttype):
43
50
  return round(18.0 * win_rate + min(6.0, runs) - loss_penalty, 1)
44
51
 
45
52
 
46
- def score(agent, role, ttype, stats=None):
53
+ def score(agent, role, ttype, stats=None):
47
54
  """返回 (总分, 理由字符串)。配额惩罚:catalog 里配了
48
55
  quota_tokens_per_hour 的智能体,本小时用量越接近配额分越低
49
56
  (封顶 -45,足以盖过历史加分),满额后仅在没有其他选择时才会被选中。"""
50
- stats = stats or {}
57
+ stats = stats or {}
58
+ ttype = _task_type(ttype)
51
59
  base = CAPABILITY.get(agent.get("kind"), 60)
52
60
  use_dispatch = bool(agent.get("_dispatch_task_type") or
53
61
  agent.get("dispatch_enabled"))
@@ -83,7 +91,7 @@ def score(agent, role, ttype, stats=None):
83
91
  base, affinity_txt, btxt, htxt, quota_txt, round(total, 1))
84
92
 
85
93
 
86
- def pick(agents, role, ttype, stats=None, exclude=()):
94
+ def pick(agents, role, ttype, stats=None, exclude=()):
87
95
  """按分选出最优智能体。返回 (agent, 理由) 或 (None, "")。"""
88
96
  stats = stats or history.agent_stats()
89
97
  best, best_reason = None, ""
@@ -95,7 +103,51 @@ def pick(agents, role, ttype, stats=None, exclude=()):
95
103
  best, best_reason = (total, a), reason
96
104
  if best is None:
97
105
  return None, ""
98
- return best[1], best_reason
106
+ return best[1], best_reason
107
+
108
+
109
+ def route_plan(agents, role, task_spec, stats=None, exclude=(), selected=None,
110
+ participants=(), selection_reason=""):
111
+ """生成可审计的候选排序,并可用实际选路覆盖评分预选结果。"""
112
+ if stats is None:
113
+ stats = history.agent_stats()
114
+ # 与 pick 保持同一候选池;绑定/健康扣分仍由 score 和 modelhub 负责,
115
+ # 诊断不能悄悄排除实际可能被选中的 mock 或备用 CLI。
116
+ pool = list(agents or [])
117
+ rows = []
118
+ for index, agent in enumerate(pool):
119
+ if agent.get("id") in exclude:
120
+ continue
121
+ candidate = dict(agent)
122
+ if isinstance(task_spec, dict):
123
+ candidate["_dispatch_task_type"] = task_spec.get("type") or "direct"
124
+ candidate["_dispatch_role"] = role
125
+ total, reason = score(candidate, role, task_spec, stats)
126
+ rows.append({"agent_id": agent.get("id") or "",
127
+ "label": agent.get("label") or agent.get("id") or "",
128
+ "kind": agent.get("kind") or "generic",
129
+ "score": round(total, 1), "reason": reason,
130
+ "order": index})
131
+ rows.sort(key=lambda x: (-x["score"], x["order"]))
132
+ selected_id = (selected or {}).get("id") if isinstance(selected, dict) else ""
133
+ if selected_id and not any(x["agent_id"] == selected_id for x in rows):
134
+ rows.append({"agent_id": selected_id,
135
+ "label": selected.get("label") or selected_id,
136
+ "kind": selected.get("kind") or "builtin",
137
+ "score": 0.0, "reason": selection_reason or "实际选路",
138
+ "order": len(rows)})
139
+ chosen = selected_id or (rows[0]["agent_id"] if rows else "")
140
+ participant_ids = []
141
+ for agent in participants or ():
142
+ agent_id = agent.get("id") if isinstance(agent, dict) else str(agent or "")
143
+ if agent_id and agent_id not in participant_ids:
144
+ participant_ids.append(agent_id)
145
+ active_ids = participant_ids or ([chosen] if chosen else [])
146
+ return {"role": role, "selected": chosen, "participants": participant_ids,
147
+ "selection_reason": selection_reason,
148
+ "candidates": rows,
149
+ "fallback": [x["agent_id"] for x in rows
150
+ if x["agent_id"] not in active_ids]}
99
151
 
100
152
 
101
153
  def pick_reviewer(agents, impl, ttype, stats=None):