codebee 0.1.22 → 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.
package/CHANGELOG.md CHANGED
@@ -6,6 +6,18 @@ README 元数据带回,供老版本在「发现新版本」时展示新版更
6
6
 
7
7
  ## 未发布
8
8
 
9
+ ## v0.1.23(2026-09-21)
10
+
11
+ - 端口占用诊断(借鉴 leftopen):启动时端口被占自动指认占用者(PID/进程/所属项目,不再让用户手跑 netstat+tasklist);设置页新增「端口占用」面板——扫描本机全部监听端口(仅本机/本服务徽章、项目归属列),可对非自身进程发送温和关闭信号(SIGTERM 语义,关前重验 PID,系统进程与 CodeBee 服务自身拒绝关闭)
12
+ - 经验库预算纪律:通配(wildcard)技能包单包注入限额 2400 字、项目教训保底注入——此前 39 个通配包全文注入会吃光 9000 字上限,把本机沉淀的评审教训整段挤掉;定向命中的规范包(番茄/七猫签约标准等)不受单包限额
13
+ - AI 味检测新增叙事架构层(借鉴 sepia/StoryScope 研究:AI 小说 93.2% 靠叙事架构特征检出,人工改措辞后几乎不降):确定性统计顿悟说教/情绪身体化/成长式收束(只扫结尾 600 字)三类架构级指纹,命中即随评审下发情节结构追问
14
+ - 任务画像与调度统一:运行详情新增「任务画像与调度」审计——任务画像与实际选路来自同一次编译结果,难度/能力需求/降级链一目了然
15
+
16
+ ## v0.1.22(2026-09-21)
17
+
18
+ - README 功能导览大扩容:新增知识库、自动化、帮助中心、作品信息图文介绍
19
+ - 近期亮点回顾:语音识别词表纠偏、调研报告「结论先行」硬约束、任务默认立即启动
20
+
9
21
  ## v0.1.21(2026-09-21)
10
22
 
11
23
  - 任务默认直接启动,不再进入容量等待队列;达到并发保护上限立即失败并明确提示稍后重试,历史 queued 记录启动时自动接管或收口。
package/README.md CHANGED
@@ -21,10 +21,11 @@ Kimi Code、MiMo Code、Grok Build、Pi、DeepSeek Harness……),提供
21
21
  不会把你的任务内容交给任何第三方。
22
22
 
23
23
  <!-- relnotes:start -->
24
- ### 最新版更新内容(v0.1.22
24
+ ### 最新版更新内容(v0.1.23
25
25
 
26
- - README 功能导览大扩容:新增知识库、自动化、帮助中心、作品信息图文介绍
27
- - 近期亮点回顾:语音识别词表纠偏、调研报告「结论先行」硬约束、任务默认立即启动
26
+ - 端口占用诊断:启动失败自动指认占用者(PID/进程/项目归属);设置页可扫描本机全部监听端口并温和关闭(系统进程与 CodeBee 自身拒关)
27
+ - 经验库预算纪律:通配技能包单包限额、项目教训保底注入——再多的通配包也挤不掉你沉淀的教训
28
+ - AI 味检测新增叙事架构层:顿悟说教/情绪身体化/成长式收束等措辞改写不掉的架构级指纹,随评审下发情节结构追问
28
29
  <!-- relnotes:end -->
29
30
 
30
31
  ---
@@ -20,6 +20,22 @@ AI_PHRASES = (
20
20
  # 密度告警线(每千字命中次数):超过即提示评审官重点关注
21
21
  ALERT_PER_KILO = 8.0
22
22
 
23
+ # 叙事架构层信号(借鉴 sepia 2.7k★/StoryScope 研究 2026:AI 小说 93.2% 靠
24
+ # 叙事架构特征检出,人工改写措辞后检出率仅从 95.5% 降到 93.9%——措辞层
25
+ # 改不掉的架构级指纹才是真破绽)。三类可确定性检测的架构信号:
26
+ NARRATIVE_TELLS = {
27
+ "顿悟说教": ("终于明白", "这才明白", "明白了,", "意识到,自己", "懂得了",
28
+ "原来,成长", "原来,生活", "原来,所谓"),
29
+ "情绪身体化": ("心脏猛地", "指尖冰凉", "指尖发凉", "喉咙发紧", "喉头发紧",
30
+ "胃里一阵", "胃部一阵", "后背一凉", "血液仿佛", "呼吸一滞"),
31
+ "成长式收束": ("释然", "和解", "放下了", "接纳了", "与自己和解", "轻轻松了口气",
32
+ "内心归于平静"),
33
+ }
34
+ # 架构信号告警线比措辞层低:这些表达在好小说里本就该稀缺
35
+ NARRATIVE_ALERT_PER_KILO = 2.0
36
+ # 「成长式收束」只在结尾才构成架构指纹(中段出现多半是剧情词),只扫尾部
37
+ ENDING_SCAN_CHARS = 600
38
+
23
39
 
24
40
  def analyze(text):
25
41
  """统计套话命中。返回 {hits: {短语: 次数}, per_kilo: 每千字密度, alert: bool}。"""
@@ -38,14 +54,52 @@ def analyze(text):
38
54
  return {"hits": hits, "per_kilo": per_kilo, "alert": per_kilo >= ALERT_PER_KILO}
39
55
 
40
56
 
57
+ def narrative_analyze(text):
58
+ """统计叙事架构层信号。返回 {cats: {类: 次数}, per_kilo, alert, ending_hits}。
59
+
60
+ - 前两类全篇统计;「成长式收束」只统计末尾 ENDING_SCAN_CHARS 字
61
+ (中段的「和解/放下」是剧情词,结尾的才是成长式收束指纹)。
62
+ - per_kilo 为三类合计密度;alert 判据:合计 ≥ 告警线 或 收束类命中 ≥ 2。
63
+ """
64
+ text = text or ""
65
+ total = len(text)
66
+ cats, ending_hits = {}, 0
67
+ if total:
68
+ tail = text[-ENDING_SCAN_CHARS:]
69
+ for cat, phrases in NARRATIVE_TELLS.items():
70
+ n = 0
71
+ for p in phrases:
72
+ if cat == "成长式收束":
73
+ n += tail.count(p)
74
+ else:
75
+ n += text.count(p)
76
+ if n:
77
+ cats[cat] = n
78
+ ending_hits = cats.get("成长式收束", 0)
79
+ per_kilo = round(sum(cats.values()) * 1000.0 / total, 2) if (total and cats) else 0.0
80
+ alert = bool(cats) and (per_kilo >= NARRATIVE_ALERT_PER_KILO or ending_hits >= 2)
81
+ return {"cats": cats, "per_kilo": per_kilo, "alert": alert, "ending_hits": ending_hits}
82
+
83
+
41
84
  def report_line(text):
42
- """生成注入评审提示词的一行报告;无命中返回空串。"""
85
+ """生成注入评审提示词的报告行(措辞层 + 叙事架构层);全部无命中返回空串。"""
86
+ lines = []
43
87
  r = analyze(text)
44
- if not r["hits"]:
45
- return ""
46
- top = "".join("「%s」×%d" % (k, v)
47
- for k, v in sorted(r["hits"].items(), key=lambda x: -x[1])[:8])
48
- line = "- [AI味检测] 确定性统计:套话密度 %.1f/千字(%s)。" % (r["per_kilo"], top)
49
- if r["alert"]:
50
- line += "密度超过告警线 %.0f/千字,请重点评审译制腔与套话问题。" % ALERT_PER_KILO
51
- return line
88
+ if r["hits"]:
89
+ top = "".join("「%s」×%d" % (k, v)
90
+ for k, v in sorted(r["hits"].items(), key=lambda x: -x[1])[:8])
91
+ line = "- [AI味检测] 确定性统计:套话密度 %.1f/千字(%s)。" % (r["per_kilo"], top)
92
+ if r["alert"]:
93
+ line += "密度超过告警线 %.0f/千字,请重点评审译制腔与套话问题。" % ALERT_PER_KILO
94
+ lines.append(line)
95
+ nr = narrative_analyze(text)
96
+ if nr["cats"]:
97
+ top = "、".join("「%s」×%d" % (k, v) for k, v in nr["cats"].items())
98
+ line = ("- [叙事架构信号] 确定性统计(措辞改写不掉的架构级指纹):"
99
+ "%s,合计 %.1f/千字。" % (top, nr["per_kilo"]))
100
+ if nr["alert"]:
101
+ line += ("出现架构级 AI 指纹(顿悟说教/情绪只写身体反应/成长式收束),"
102
+ "请评审情节结构:主题是否被叙述者直接说破、情绪是否只有身体描写、"
103
+ "结尾是否靠主角想通收束。")
104
+ lines.append(line)
105
+ return "\n".join(lines)
@@ -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)
@@ -3287,6 +3406,11 @@ def execute_run(run_id):
3287
3406
  difficulty=task_spec["difficulty"])
3288
3407
  task = dict(task)
3289
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"]
3290
3414
  # 代码版本检出:任务指定了基线版本时,先检出任务分支 tutti/<task-id> 再跑流水线。
3291
3415
  # 显式意图不容静默降级——仓库缺失/脏工作区/引用不存在一律中止运行并报错,
3292
3416
  # 绝不带着用户未提交改动切分支、也不悄悄退回当前 HEAD。
@@ -3366,8 +3490,12 @@ def execute_run(run_id):
3366
3490
  store.update_run(run_id, expected_status="running", status="failed",
3367
3491
  error="没有可用智能体", ended_at=_now())
3368
3492
  return
3369
- if resume_ctx is not None and mode == "auto":
3370
- 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)
3371
3499
  if task.get("serial"):
3372
3500
  _run_serial_review(run, task, agents, ev, stats, mode,
3373
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
@@ -106,12 +106,14 @@ def pick(agents, role, ttype, stats=None, exclude=()):
106
106
  return best[1], best_reason
107
107
 
108
108
 
109
- def route_plan(agents, role, task_spec, stats=None, exclude=()):
110
- """生成可审计的候选排序,供运行详情展示和后续 fallback 使用。"""
109
+ def route_plan(agents, role, task_spec, stats=None, exclude=(), selected=None,
110
+ participants=(), selection_reason=""):
111
+ """生成可审计的候选排序,并可用实际选路覆盖评分预选结果。"""
111
112
  if stats is None:
112
113
  stats = history.agent_stats()
113
- real = [a for a in agents or [] if a.get("mode") == "real"]
114
- pool = real if real else list(agents or [])
114
+ # pick 保持同一候选池;绑定/健康扣分仍由 score modelhub 负责,
115
+ # 诊断不能悄悄排除实际可能被选中的 mock 或备用 CLI。
116
+ pool = list(agents or [])
115
117
  rows = []
116
118
  for index, agent in enumerate(pool):
117
119
  if agent.get("id") in exclude:
@@ -127,9 +129,25 @@ def route_plan(agents, role, task_spec, stats=None, exclude=()):
127
129
  "score": round(total, 1), "reason": reason,
128
130
  "order": index})
129
131
  rows.sort(key=lambda x: (-x["score"], x["order"]))
130
- return {"role": role, "selected": rows[0]["agent_id"] if rows else "",
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,
131
148
  "candidates": rows,
132
- "fallback": [x["agent_id"] for x in rows[1:]]}
149
+ "fallback": [x["agent_id"] for x in rows
150
+ if x["agent_id"] not in active_ids]}
133
151
 
134
152
 
135
153
  def pick_reviewer(agents, impl, ttype, stats=None):