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.
- package/CHANGELOG.md +183 -171
- package/README.md +32 -6
- package/app/core/aiflavor.py +63 -9
- package/app/core/knowledge.py +19 -7
- package/app/core/pipeline.py +146 -6
- package/app/core/portscan.py +188 -0
- package/app/core/router.py +57 -5
- package/app/core/skills.py +26 -47
- package/app/core/task_compile.py +87 -0
- package/app/main.py +42 -0
- package/app/pet.py +125 -28
- package/app/ui/app.js +264 -18
- package/app/ui/i18n.js +35 -0
- package/app/ui/index.html +1143 -1137
- package/app/ui/style.css +64 -0
- package/package.json +1 -1
package/app/core/pipeline.py
CHANGED
|
@@ -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
|
|
2802
|
-
|
|
2803
|
-
|
|
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
|
|
3358
|
-
|
|
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
|
package/app/core/router.py
CHANGED
|
@@ -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):
|