codebee 0.1.20 → 0.1.22

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
@@ -321,7 +321,7 @@ def _binding_dead_msg(agent):
321
321
  return modelhub.binding_dead_msg(agent.get("id") or "")
322
322
  except Exception:
323
323
  return ("绑定链全部失效,本步判失败、不回落 CLI 本机默认——"
324
- "请在「CLI 绑定」页为该 CLI 绑定已启用的供应商")
324
+ "请在「模型调度(可选)」页为该 CLI 指定已启用的供应商")
325
325
 
326
326
 
327
327
  def _run_step(run_id, role, agent, prompt, workdir, readonly, ev, timeout=runner.DEFAULT_TIMEOUT, note="", resume=None, images=None, require_tools=False):
@@ -907,12 +907,18 @@ def _run_code(run, task, agents, ev, stats, mode):
907
907
  else:
908
908
  impl, route["implementer"] = router.pick(agents, "implement", "code", stats)
909
909
  if impl is None:
910
- store.update_run(run_id, status="failed", error="没有可用智能体", ended_at=_now())
910
+ store.update_run(run_id, expected_status="running", status="failed",
911
+ error="没有可用智能体", ended_at=_now())
911
912
  return
912
913
 
913
914
  # ---- 规划
914
915
  if mode == "auto":
915
916
  _wait_gate(run_id, ev)
917
+ # 项目记忆注入(借鉴 agentmemory 持久记忆):同工作目录此前代码任务留下的
918
+ # 架构事实,让规划器不再对代码库一无所知
919
+ _pm = _read_project_memory(workdir)
920
+ if _pm:
921
+ task = dict(task, context=((task.get("context") or "") + "\n\n" + _pm)[:8000])
916
922
  plan_step, plan_log = store.add_step(run_id, "plan", impl["id"], impl.get("label"),
917
923
  note=route.get("implementer", ""))
918
924
  plan = planner.make_code_plan(_steered_task(run_id, task),
@@ -961,8 +967,13 @@ def _run_code(run, task, agents, ev, stats, mode):
961
967
  register_default_namespaces()
962
968
  if ss_get("cascade", "enabled"):
963
969
  from . import capability
970
+ mh_data = modelhub._load()
964
971
  agt_b = capability.cascade_reorder(
965
- agt_b, capability.make_tier_lookup(modelhub.providers()))
972
+ agt_b, capability.make_tier_lookup(modelhub.providers()),
973
+ providers=modelhub.providers(),
974
+ pricing=mh_data.get("pricing") or {},
975
+ difficulty=difficulty, task_type=task.get("type") or "code",
976
+ role="implement")
966
977
  except Exception:
967
978
  pass
968
979
  for i, sub in enumerate(subtasks):
@@ -1033,7 +1044,8 @@ def _run_code(run, task, agents, ev, stats, mode):
1033
1044
  res_err = "实现步骤失败(无其他真实 CLI 可换将): %s" % res.get("error")
1034
1045
  else:
1035
1046
  res_err = "实现步骤失败: %s" % res.get("error")
1036
- store.update_run(run_id, status="failed", error=res_err, ended_at=_now())
1047
+ store.update_run(run_id, expected_status="running", status="failed",
1048
+ error=res_err, ended_at=_now())
1037
1049
  return False
1038
1050
 
1039
1051
  def review_and_score():
@@ -1129,7 +1141,21 @@ def _run_code(run, task, agents, ev, stats, mode):
1129
1141
  lines += ["", "## 评审总评", "", review_json.get("summary", ""), ""]
1130
1142
  store.write_report(run_id, "\n".join(lines))
1131
1143
  _write_task_evidence(run_id, task, workdir, _evidence_lines_from_run(run_id, task))
1132
- store.update_run(run_id, status="done", verdict=verdict,
1144
+ # 项目记忆沉淀(借鉴 agentmemory 持久记忆):代码任务成功后提取架构事实
1145
+ # (改动文件/验收结果/修复轮数),追加到 .codebee/project-memory.md——
1146
+ # 同目录后续 code 任务规划前自动注入,让编排者「知道这个代码库的脾气」
1147
+ try:
1148
+ _diff = _git_diff(workdir)
1149
+ _files_touched = sorted(set(re.findall(
1150
+ r"(?:^|\n)diff --git a/(\S+) b/(\S+)", _diff or "")))
1151
+ _touched_str = "、".join(sorted(set(b for _, b in _files_touched)))[:500] if _files_touched else ""
1152
+ _mem_lines = ["改动文件:%s" % (_touched_str or "(无 diff)"),
1153
+ "验收:%s" % ("通过" if verify_pass else "未通过"),
1154
+ "修复轮数:%d" % (len(repairs) - 1)]
1155
+ _write_project_memory(task, workdir, _mem_lines)
1156
+ except Exception:
1157
+ pass
1158
+ store.update_run(run_id, expected_status="running", status="done", verdict=verdict,
1133
1159
  summary="代码任务%s(验证%s / 评审%s%s)" % (
1134
1160
  "通过" if overall_pass else "未通过",
1135
1161
  "通过" if verify_pass else "未通过",
@@ -1297,7 +1323,8 @@ def _run_direct(run, task, agents, ev, stats, mode):
1297
1323
  else:
1298
1324
  impl, route["implementer"] = router.pick(agents, "implement", task["type"], stats)
1299
1325
  if impl is None and bi is None:
1300
- store.update_run(run_id, status="failed", error="没有可用智能体", ended_at=_now())
1326
+ store.update_run(run_id, expected_status="running", status="failed",
1327
+ error="没有可用智能体", ended_at=_now())
1301
1328
  return
1302
1329
  difficulty = task.get("difficulty") or "default"
1303
1330
  step_wd = _resume_workdir(resume_ctx, workdir) if resume_ctx else workdir
@@ -1371,7 +1398,7 @@ def _run_direct(run, task, agents, ev, stats, mode):
1371
1398
  readonly=False, ev=ev, note=note,
1372
1399
  resume=sid or None, images=images)
1373
1400
  if not res["ok"]:
1374
- store.update_run(run_id, status="failed",
1401
+ store.update_run(run_id, expected_status="running", status="failed",
1375
1402
  error="执行失败: %s" % res.get("error"), ended_at=_now())
1376
1403
  return
1377
1404
  turns += 1
@@ -1403,7 +1430,7 @@ def _run_direct(run, task, agents, ev, stats, mode):
1403
1430
  report += ["## 最近一轮输出", "", last_text[-5000:], ""]
1404
1431
  store.write_report(run_id, "\n".join(report))
1405
1432
  _write_task_evidence(run_id, task, workdir, _evidence_lines_from_run(run_id, task))
1406
- store.update_run(run_id, status="done", verdict=verdict,
1433
+ store.update_run(run_id, expected_status="running", status="done", verdict=verdict,
1407
1434
  summary="直连完成(%d 轮):%s" % (turns, last_text[:160]),
1408
1435
  ended_at=_now())
1409
1436
 
@@ -1886,7 +1913,7 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
1886
1913
  # 历史遗留:降级/模板大纲被继承时,真实任务宁可中止重生成,也不按空模板写全书
1887
1914
  if (outline.get("degraded") or outline.get("source") == "template") \
1888
1915
  and impl.get("mode") != "mock":
1889
- store.update_run(run_id, status="failed",
1916
+ store.update_run(run_id, expected_status="running", status="failed",
1890
1917
  error="继承的大纲为降级模板(无真实情节),已中止以重新生成大纲",
1891
1918
  ended_at=_now())
1892
1919
  return
@@ -1909,7 +1936,7 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
1909
1936
  store.finish_step(run_id, outline_step["n"], "failed",
1910
1937
  summary="大纲降级:%s" % (outline.get("degraded_reason") or "编排者不可用"),
1911
1938
  duration_s=None)
1912
- store.update_run(run_id, status="failed",
1939
+ store.update_run(run_id, expected_status="running", status="failed",
1913
1940
  error="%s,已中止以免按空模板写全书"
1914
1941
  % (outline.get("degraded_reason") or "编排者不可用"),
1915
1942
  ended_at=_now())
@@ -2178,7 +2205,7 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2178
2205
  time.sleep(3) # 落盘竞态宽限:CLI 崩溃退出前写的文件可能晚于
2179
2206
  good, txt = _chapter_state() # 退出检查零点几秒才可见(c34 实测)
2180
2207
  if not good:
2181
- store.update_run(run_id, status="failed",
2208
+ store.update_run(run_id, expected_status="running", status="failed",
2182
2209
  error="第 %d 章起草失败: %s" % (i, (res or {}).get("error")), ended_at=_now())
2183
2210
  return
2184
2211
  if not res["ok"]:
@@ -2262,7 +2289,7 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2262
2289
  if scored_variants:
2263
2290
  break
2264
2291
  if not scored_variants:
2265
- store.update_run(run_id, status="failed",
2292
+ store.update_run(run_id, expected_status="running", status="failed",
2266
2293
  error="第 %d 章赛马全部变体起草失败" % i, ended_at=_now())
2267
2294
  return
2268
2295
  scored_variants.sort(key=lambda v: (-v["avg"], v["variant"]))
@@ -2273,7 +2300,7 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2273
2300
  os.replace(os.path.join(workdir, win["file"]),
2274
2301
  os.path.join(workdir, ch_file))
2275
2302
  except OSError as e:
2276
- store.update_run(run_id, status="failed",
2303
+ store.update_run(run_id, expected_status="running", status="failed",
2277
2304
  error="第 %d 章赛马收卷失败: %r" % (i, e), ended_at=_now())
2278
2305
  return
2279
2306
  for v in scored_variants[1:]:
@@ -2321,7 +2348,7 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2321
2348
  if not scored:
2322
2349
  # 「评不上」≠「评了 0 分」:全部评审失败时中止本轮,
2323
2350
  # 让自动续跑换个时机重试,而不是以 0 分误判章稿质量。
2324
- store.update_run(run_id, status="failed",
2351
+ store.update_run(run_id, expected_status="running", status="failed",
2325
2352
  error="第 %d 章评审全部失败(评审模型不可用或输出不可解析),"
2326
2353
  "已中止以免以 0 分误判质量" % i, ended_at=_now())
2327
2354
  return
@@ -2452,7 +2479,7 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2452
2479
  if gscored:
2453
2480
  break
2454
2481
  if not gscored:
2455
- store.update_run(run_id, status="failed",
2482
+ store.update_run(run_id, expected_status="running", status="failed",
2456
2483
  error="全局一致性评审全部失败(评审模型不可用或输出不可解析),"
2457
2484
  "已中止以免把「无法评审」误判为「未达标」。"
2458
2485
  "各章稿件已全部落盘,修复评审链后续跑可直接收尾",
@@ -2621,7 +2648,7 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2621
2648
  lines.append("(无 major 问题)")
2622
2649
  store.write_report(run_id, "\n".join(lines))
2623
2650
  _write_task_evidence(run_id, task, workdir, _evidence_lines_from_run(run_id, task))
2624
- store.update_run(run_id, status="done", verdict=verdict,
2651
+ store.update_run(run_id, expected_status="running", status="done", verdict=verdict,
2625
2652
  summary="连载任务%s(%s,约 %d 字,综合 %.1f)" % (
2626
2653
  "达标" if publishable else "未达标", scope_txt,
2627
2654
  total_words, overall),
@@ -2768,7 +2795,8 @@ def _run_content_review(run, task, agents, ev, stats, mode):
2768
2795
  impl, route["author"] = router.pick(agents, "implement", task_type, stats)
2769
2796
  critics, route["critics"] = router.pick_critics(agents, task_type, stats, impl=impl)
2770
2797
  if impl is None:
2771
- store.update_run(run_id, status="failed", error="没有可用智能体", ended_at=_now())
2798
+ store.update_run(run_id, expected_status="running", status="failed",
2799
+ error="没有可用智能体", ended_at=_now())
2772
2800
  return
2773
2801
  if resume_ctx is not None and mode == "auto":
2774
2802
  critics, route["critics"] = router.pick_critics(
@@ -2847,7 +2875,8 @@ def _run_content_review(run, task, agents, ev, stats, mode):
2847
2875
  resume=resume_ctx["session"] if resume_ctx else None,
2848
2876
  images=_task_images(task, workdir))
2849
2877
  if not draft_res["ok"]:
2850
- store.update_run(run_id, status="failed", error="起草失败: %s" % draft_res.get("error"),
2878
+ store.update_run(run_id, expected_status="running", status="failed",
2879
+ error="起草失败: %s" % draft_res.get("error"),
2851
2880
  ended_at=_now())
2852
2881
  return
2853
2882
 
@@ -2979,7 +3008,7 @@ def _run_content_review(run, task, agents, ev, stats, mode):
2979
3008
  lines += ["", "## 稿件位置", "", "`%s`" % ms_path, ""]
2980
3009
  store.write_report(run_id, "\n".join(lines))
2981
3010
  _write_task_evidence(run_id, task, workdir, _evidence_lines_from_run(run_id, task))
2982
- store.update_run(run_id, status="done", verdict=verdict,
3011
+ store.update_run(run_id, expected_status="running", status="done", verdict=verdict,
2983
3012
  summary="评审任务%s(综合 %.1f)" % ("达标" if publishable else "未达标", overall),
2984
3013
  ended_at=_now())
2985
3014
 
@@ -3024,13 +3053,13 @@ def _run_serial_qa(run, task, agents, ev):
3024
3053
  res = _run_step(run_id, "qa", modelhub.bind_agent(agent, "default"),
3025
3054
  prompt, workdir, readonly=True, ev=ev, timeout=1200)
3026
3055
  if res.get("ok") and (res.get("text") or "").strip():
3027
- store.update_run(run_id, status="done", ended_at=_now(),
3056
+ store.update_run(run_id, expected_status="running", status="done", ended_at=_now(),
3028
3057
  verdict={"qa": True,
3029
3058
  "answered_by": agent.get("id")})
3030
3059
  return
3031
3060
  errors.append("%s:%s" % (agent.get("id"),
3032
3061
  (res.get("error") or "无输出")[:120]))
3033
- store.update_run(run_id, status="failed", ended_at=_now(),
3062
+ store.update_run(run_id, expected_status="running", status="failed", ended_at=_now(),
3034
3063
  error="答疑失败(执行/评审链不可用)——" + ";".join(errors[-3:]))
3035
3064
 
3036
3065
 
@@ -3051,6 +3080,43 @@ def _read_constitution(workdir):
3051
3080
  "与其他要求冲突时以宪章为准)\n\n" + txt + "\n\n")
3052
3081
 
3053
3082
 
3083
+ def _write_project_memory(task, workdir, lines):
3084
+ """项目记忆持久化(借鉴 agentmemory):代码任务成功后把架构事实追加到
3085
+ .codebee/project-memory.md——同目录后续 code 任务规划前自动注入,
3086
+ 让编排者「知道这个代码库的脾气」而非每次从零摸索。失败静默。"""
3087
+ if not lines:
3088
+ return ""
3089
+ try:
3090
+ pm = os.path.join(workdir, ".codebee", "project-memory.md")
3091
+ os.makedirs(os.path.dirname(pm), exist_ok=True)
3092
+ header_needed = not os.path.isfile(pm)
3093
+ with open(pm, "a", encoding="utf-8") as f:
3094
+ if header_needed:
3095
+ f.write("# 项目记忆(每次代码任务完成后自动追加,供后续任务参考)\n\n")
3096
+ f.write("### %s · %s\n" % (task.get("title") or "", _now()))
3097
+ for ln in lines:
3098
+ f.write("- %s\n" % str(ln)[:300])
3099
+ f.write("\n")
3100
+ return pm
3101
+ except Exception:
3102
+ return ""
3103
+
3104
+
3105
+ def _read_project_memory(workdir, cap=4000):
3106
+ """读取项目记忆供规划提示词注入。超出上限截断到最新条目。"""
3107
+ p = os.path.join(workdir or "", ".codebee", "project-memory.md")
3108
+ if not _inside(workdir, p) or not os.path.isfile(p):
3109
+ return ""
3110
+ try:
3111
+ txt = _read_text_any_enc(p)[:cap].strip()
3112
+ except OSError:
3113
+ return ""
3114
+ if not txt:
3115
+ return ""
3116
+ return ("## 项目记忆(此前代码任务在此工作目录留下的架构事实,"
3117
+ "规划时优先参考)\n\n" + txt + "\n\n")
3118
+
3119
+
3054
3120
  def _write_task_spec(task, workdir):
3055
3121
  """任务规格落盘 .codebee/spec.md(借鉴 agent-orchestrator 的 .spec/PROMPT.md 与
3056
3122
  planning-with-files 的文件化计划):任务定义随工作目录留存、随任务分支版本化,
@@ -3201,11 +3267,26 @@ def execute_run(run_id):
3201
3267
  error="排队期间被取消")
3202
3268
  return
3203
3269
  task = store.get_task(run.get("task_id"))
3204
- store.update_run(run_id, status="running", started_at=_now())
3270
+ # 生产 enqueue 已完成 queued→running 认领;测试/兼容调用也可能直接从
3271
+ # queued 进入。按初读状态做 CAS 起跑确认:取消若恰好落在读取之后,当前
3272
+ # 状态已是 cancelled,写入会失败并立即退出,不产生 Git/文件副作用。
3273
+ initial_status = run.get("status")
3274
+ if initial_status not in ("queued", "running"):
3275
+ return
3276
+ if store.update_run(run_id, expected_status=initial_status, status="running",
3277
+ started_at=_now()) is None:
3278
+ return
3205
3279
  if task is None:
3206
- store.update_run(run_id, status="failed", error="找不到任务 %s" % run.get("task_id"),
3207
- ended_at=_now())
3280
+ store.update_run(run_id, expected_status="running", status="failed",
3281
+ error="找不到任务 %s" % run.get("task_id"), ended_at=_now())
3208
3282
  return
3283
+ # 统一任务编译:旧字段继续供各引擎读取,规格作为运行级诊断与调度输入落盘。
3284
+ task_spec = task_compile.compile_task(task)
3285
+ store.update_run(run_id, task_spec=task_spec,
3286
+ task_spec_summary=task_compile.summary(task_spec),
3287
+ difficulty=task_spec["difficulty"])
3288
+ task = dict(task)
3289
+ task["_compiled_spec"] = task_spec
3209
3290
  # 代码版本检出:任务指定了基线版本时,先检出任务分支 tutti/<task-id> 再跑流水线。
3210
3291
  # 显式意图不容静默降级——仓库缺失/脏工作区/引用不存在一律中止运行并报错,
3211
3292
  # 绝不带着用户未提交改动切分支、也不悄悄退回当前 HEAD。
@@ -3215,8 +3296,8 @@ def execute_run(run_id):
3215
3296
  ok, err, gitinfo = gitmod.prepare_checkout(
3216
3297
  task["workdir"], task["git_rev"], task["id"])
3217
3298
  if not ok:
3218
- store.update_run(run_id, status="failed", error="代码版本检出失败:%s" % err,
3219
- ended_at=_now())
3299
+ store.update_run(run_id, expected_status="running", status="failed",
3300
+ error="代码版本检出失败:%s" % err, ended_at=_now())
3220
3301
  return
3221
3302
  git_ctx = gitinfo
3222
3303
  store.update_run(run_id, git=gitinfo)
@@ -3240,6 +3321,16 @@ def execute_run(run_id):
3240
3321
  if extra:
3241
3322
  agents.append(extra)
3242
3323
  stats = history.agent_stats()
3324
+ # 运行级任务画像:所有后续 bind_agent 调用共享同一预置类型,
3325
+ # 模型级联因此覆盖 direct/code/review/serial/translation 等全部引擎。
3326
+ for _agent in agents:
3327
+ if isinstance(_agent, dict):
3328
+ _agent["_dispatch_task_type"] = task.get("type") or "direct"
3329
+ store.update_run(run_id, route_plan={
3330
+ "task": task_spec,
3331
+ "implement": router.route_plan(agents, "implement", task_spec, stats),
3332
+ "review": router.route_plan(agents, "review", task_spec, stats),
3333
+ })
3243
3334
  mode = task.get("mode") or ("manual" if task.get("implementer") else "auto")
3244
3335
  store.update_run(run_id, mode=mode)
3245
3336
  # engine 决定流水线:code=实现/验证/评审/修复;review=起草/多维评审/修订/门禁;
@@ -3272,7 +3363,8 @@ def execute_run(run_id):
3272
3363
  impl, route["author"] = router.pick(agents, "implement", task["type"], stats)
3273
3364
  critics, route["critics"] = router.pick_critics(agents, task["type"], stats, impl=impl)
3274
3365
  if impl is None:
3275
- store.update_run(run_id, status="failed", error="没有可用智能体", ended_at=_now())
3366
+ store.update_run(run_id, expected_status="running", status="failed",
3367
+ error="没有可用智能体", ended_at=_now())
3276
3368
  return
3277
3369
  if resume_ctx is not None and mode == "auto":
3278
3370
  critics, route["critics"] = router.pick_critics(agents, task["type"], stats, impl=impl)
@@ -3282,11 +3374,12 @@ def execute_run(run_id):
3282
3374
  else:
3283
3375
  _run_content_review(run, task, agents, ev, stats, mode)
3284
3376
  except Cancelled:
3285
- store.update_run(run_id, status="cancelled", ended_at=_now())
3377
+ store.update_run(run_id, expected_status="running",
3378
+ status="cancelled", ended_at=_now())
3286
3379
  except Exception as e:
3287
3380
  import traceback
3288
- store.update_run(run_id, status="failed", error=repr(e)[:500],
3289
- ended_at=_now())
3381
+ store.update_run(run_id, expected_status="running", status="failed",
3382
+ error=repr(e)[:500], ended_at=_now())
3290
3383
  try:
3291
3384
  err_path = store.run_dir(run_id) / "error.log"
3292
3385
  if _inside(str(store.run_dir(run_id).parent), str(err_path)):
@@ -2,7 +2,7 @@
2
2
  """智能路由:能力基线 × 历史胜率 × 角色约束 → 选智能体,并给出可解释的理由。"""
3
3
  from __future__ import annotations
4
4
 
5
- from . import history
5
+ from . import dispatch, history
6
6
 
7
7
  # 各类智能体的能力基线(0-100)。真实 CLI 里官方双雄最高。
8
8
  CAPABILITY = {
@@ -10,18 +10,29 @@ 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
- def _binding_bonus(agent_id):
23
+ def _binding_bonus(agent_id, dispatch_mode=False):
17
24
  """绑定链可用性加分/减分:链上有可用条目 +8,解析为空 -25。2026-09-16 实测:
18
25
  静态能力基线让配额烧干的 codex 永远压过健康备用 CLI,绑定空的 CLI 更是连
19
26
  用户配置的模型都没用上——先按「能不能按配置跑起来」校准。2026-09-17 起
20
27
  空链步骤在 pipeline 直接判失败(不再静默回落本机默认),此处只管排序。"""
21
28
  try:
22
29
  from . import modelhub
23
- b = modelhub.resolve_binding(agent_id)
24
- return 8.0 if (b and b.get("call_chain")) else -25.0
30
+ pref = modelhub._binding_for(agent_id)
31
+ configured = bool(modelhub._binding_chain(pref) or pref.get("provider_id"))
32
+ if not configured:
33
+ return 0.0 if dispatch_mode else -25.0
34
+ b = modelhub.resolve_binding(agent_id)
35
+ return 8.0 if (b and b.get("call_chain")) else -25.0
25
36
  except Exception:
26
37
  return 0.0
27
38
 
@@ -39,20 +50,27 @@ def _history_bonus(stats, agent_id, ttype):
39
50
  return round(18.0 * win_rate + min(6.0, runs) - loss_penalty, 1)
40
51
 
41
52
 
42
- def score(agent, role, ttype, stats=None):
53
+ def score(agent, role, ttype, stats=None):
43
54
  """返回 (总分, 理由字符串)。配额惩罚:catalog 里配了
44
55
  quota_tokens_per_hour 的智能体,本小时用量越接近配额分越低
45
56
  (封顶 -45,足以盖过历史加分),满额后仅在没有其他选择时才会被选中。"""
46
- stats = stats or {}
57
+ stats = stats or {}
58
+ ttype = _task_type(ttype)
47
59
  base = CAPABILITY.get(agent.get("kind"), 60)
48
- bb = _binding_bonus(agent.get("id"))
60
+ use_dispatch = bool(agent.get("_dispatch_task_type") or
61
+ agent.get("dispatch_enabled"))
62
+ bb = _binding_bonus(agent.get("id"), dispatch_mode=use_dispatch)
49
63
  btxt = ""
50
64
  if bb > 0:
51
65
  btxt = ",绑定链可用(+%s)" % bb
52
66
  elif bb < 0:
53
67
  btxt = ",绑定链为空:相关步骤将判失败(%s)" % bb
54
68
  hb = _history_bonus(stats, agent.get("id"), ttype)
55
- total = base + bb + hb
69
+ # 保持公开 score() 的历史绝对分值;运行级候选由 pipeline 标记画像后
70
+ # 才启用能力亲和度,避免旧插件/测试调用被新权重悄然改变。
71
+ affinity, affinity_txt = (dispatch.agent_affinity(agent.get("kind"), ttype, role)
72
+ if use_dispatch else (0.0, "兼容模式"))
73
+ total = base + bb + hb + affinity
56
74
  hs = (stats.get(agent.get("id")) or {}).get(ttype)
57
75
  htxt = (",历史 %d/%d 胜(%s)" % (hs["wins"], hs["runs"], "%+.1f" % hb)) if hs else ",无历史记录"
58
76
  quota_txt = ""
@@ -69,10 +87,11 @@ def score(agent, role, ttype, stats=None):
69
87
  if penalty:
70
88
  total += penalty
71
89
  quota_txt = ",本小时 %d/%d tokens(%s)" % (used, quota, penalty)
72
- return total, "能力基线 %d%s%s%s,总分 %s" % (base, btxt, htxt, quota_txt, round(total, 1))
90
+ return total, "能力基线 %d,%s%s%s%s,总分 %s" % (
91
+ base, affinity_txt, btxt, htxt, quota_txt, round(total, 1))
73
92
 
74
93
 
75
- def pick(agents, role, ttype, stats=None, exclude=()):
94
+ def pick(agents, role, ttype, stats=None, exclude=()):
76
95
  """按分选出最优智能体。返回 (agent, 理由) 或 (None, "")。"""
77
96
  stats = stats or history.agent_stats()
78
97
  best, best_reason = None, ""
@@ -84,7 +103,33 @@ def pick(agents, role, ttype, stats=None, exclude=()):
84
103
  best, best_reason = (total, a), reason
85
104
  if best is None:
86
105
  return None, ""
87
- return best[1], best_reason
106
+ return best[1], best_reason
107
+
108
+
109
+ def route_plan(agents, role, task_spec, stats=None, exclude=()):
110
+ """生成可审计的候选排序,供运行详情展示和后续 fallback 使用。"""
111
+ if stats is None:
112
+ 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 [])
115
+ rows = []
116
+ for index, agent in enumerate(pool):
117
+ if agent.get("id") in exclude:
118
+ continue
119
+ candidate = dict(agent)
120
+ if isinstance(task_spec, dict):
121
+ candidate["_dispatch_task_type"] = task_spec.get("type") or "direct"
122
+ candidate["_dispatch_role"] = role
123
+ total, reason = score(candidate, role, task_spec, stats)
124
+ rows.append({"agent_id": agent.get("id") or "",
125
+ "label": agent.get("label") or agent.get("id") or "",
126
+ "kind": agent.get("kind") or "generic",
127
+ "score": round(total, 1), "reason": reason,
128
+ "order": index})
129
+ rows.sort(key=lambda x: (-x["score"], x["order"]))
130
+ return {"role": role, "selected": rows[0]["agent_id"] if rows else "",
131
+ "candidates": rows,
132
+ "fallback": [x["agent_id"] for x in rows[1:]]}
88
133
 
89
134
 
90
135
  def pick_reviewer(agents, impl, ttype, stats=None):
@@ -76,7 +76,8 @@ def _npm_shim_bypass(argv):
76
76
  and str(argv[2]).lower().endswith((".cmd", ".bat")):
77
77
  shim = argv[2]
78
78
  try:
79
- text = open(shim, encoding="utf-8", errors="replace").read()
79
+ with open(shim, encoding="utf-8", errors="replace") as fh:
80
+ text = fh.read()
80
81
  except Exception:
81
82
  return argv
82
83
  m = re.search(r'%_prog%"\s+"?%dp0%(\\[^"\n]+?\.(?:mjs|js))"?', text)
@@ -176,16 +176,15 @@ def apply_upgrade():
176
176
  try:
177
177
  jobs.enqueue({"kind": "selfupgrade", "run_id": run["id"]})
178
178
  except Exception:
179
- # The run is already durable when enqueue fails. Close it explicitly so
180
- # the upgrade panel cannot remain in a misleading queued state.
181
- log.exception("selfupdate: 升级任务入队失败 run=%s", run["id"])
179
+ # run 已持久化;启动失败时显式收口,版本页不能停在误导性的待启动状态。
180
+ log.exception("selfupdate: 升级任务启动失败 run=%s", run["id"])
182
181
  try:
183
182
  store.update_run(run["id"], status="failed",
184
- error="升级任务入队失败,请稍后重试",
183
+ error="升级任务启动失败,本次未排队,请稍后重试",
185
184
  ended_at=time.strftime("%Y-%m-%d %H:%M:%S"))
186
185
  except Exception:
187
186
  log.exception("selfupdate: 升级运行失败收口失败 run=%s", run["id"])
188
- return {"error": "升级任务入队失败,请稍后重试", "run_id": run["id"]}
187
+ return {"error": "升级任务启动失败,本次未排队,请稍后重试", "run_id": run["id"]}
189
188
  return {"run_id": run["id"]}
190
189
 
191
190
 
@@ -210,23 +209,34 @@ def _log_note(log_path, text):
210
209
  pass
211
210
 
212
211
 
213
- def run_upgrade(run_id, log_path):
212
+ def run_upgrade(run_id, log_path, cancel_event=None):
214
213
  """worker 线程里执行升级命令(run/step 生命周期由 jobs 层管)。
215
214
 
216
215
  包目录被其他进程占用(EBUSY/EPERM:打开包目录的资源管理器/终端窗口、
217
216
  杀毒或索引扫描)是升级失败的最常见原因,且多为暂时性——自动重试
218
217
  _RETRY_DELAYS 轮,仍败则给人话结论(原始 npm 输出在步骤日志里可查)。"""
219
- res = {}
220
- for attempt, delay in enumerate((0,) + _RETRY_DELAYS):
221
- if delay:
222
- _log_note(log_path, "目录被占用(EBUSY/EPERM),%d 秒后自动重试(第 %d/%d 次)"
223
- % (delay, attempt, len(_RETRY_DELAYS)))
224
- time.sleep(delay)
225
- res = runner.run_process(
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(
226
232
  argv=_npm_argv("install", "-g", _PKG_NAME + "@latest"),
227
233
  # Windows 上 npm 换版本靠把包目录整体改名(codebee → .codebee-xxx);
228
234
  # cwd 若落在本包内,目录被自身进程占用,rename 必报 EBUSY——钉在包外
229
- cwd=str(Path.home()), timeout=900, log_path=log_path)
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}
230
240
  if res["ok"] or not _locked_error(res):
231
241
  break
232
242
  if res["ok"]:
@@ -21,7 +21,7 @@ _FILE = paths.DATA_DIR / "settings.json"
21
21
  # 运行时出现(空闲 90s 隐身)。
22
22
  # cleanup_enabled / cleanup_retention_days:每日垃圾清理(core/cleanup.py)——
23
23
  # 运行过程日志/发布截图/bak 残留等超期自动清理;retention 为保留天数。
24
- DEFAULTS = {"max_concurrent_jobs": 6, "default_workdir": "", "hooks_token": "",
24
+ DEFAULTS = {"max_concurrent_jobs": 12, "default_workdir": "", "hooks_token": "",
25
25
  "telemetry_errors": True, "publish_daily_cap": 10,
26
26
  "publish_fail_streak": 3, "notify_webhook": "", "notify_base_url": "",
27
27
  "pet_enabled": True, "pet_mode": "always", "pet_skin": "plush",
@@ -29,8 +29,8 @@ DEFAULTS = {"max_concurrent_jobs": 6, "default_workdir": "", "hooks_token": "",
29
29
  # 桌宠形象白名单(与 app/pet.py 的 SKINS 对齐;这里不 import pet 模块,避免
30
30
  # core 反向依赖 app 根目录脚本)
31
31
  PET_SKINS = ("plush", "robot")
32
- # 并发上限 12:worker 只是拉起 CLI 子进程的调度位,跨任务无共享资源;
33
- # 同任务单飞守卫在 jobs 层。默认 6 对齐「多任务并行不排队」的使用预期。
32
+ # 并发保护上限 12:任务有空位即直接启动,满载明确返回忙,不进入等待队列;
33
+ # 同任务单飞守卫在 jobs 层。默认取上限,对齐「默认不排队」的使用预期。
34
34
  MIN_WORKERS, MAX_WORKERS = 1, 12
35
35
 
36
36
 
@@ -390,11 +390,14 @@ def relevance_top(lessons, task, limit):
390
390
  if not probe:
391
391
  return lessons[:limit]
392
392
 
393
- def rank(x):
394
- grams = _text_bigrams(x.get("title")) | _text_bigrams(x.get("content"))
395
- return (-len(probe & grams), x.get("id") or "")
396
-
397
- return sorted(lessons, key=rank)[:limit]
393
+ # 使用反馈闭环(pmb「量化记忆真实帮助」借鉴):被选中次数多的教训排前
394
+ def rank(x):
395
+ grams = _text_bigrams(x.get("title")) | _text_bigrams(x.get("content"))
396
+ overlap = -len(probe & grams)
397
+ lid = x.get("id") or ""
398
+ return (overlap, -int(x.get("hits") or 0), lid)
399
+
400
+ return sorted(lessons, key=rank)[:limit]
398
401
 
399
402
 
400
403
  def block_for(task, scope_override=None, *, stable_order=False):
@@ -407,7 +410,7 @@ def block_for(task, scope_override=None, *, stable_order=False):
407
410
  前缀缓存(同一任务 8 章应看到完全相同的技能块)。内容不变,只稳排序。
408
411
  """
409
412
  scope = scope_override or task.get("type") or "*"
410
- parts, used = [], []
413
+ parts, used, lesson_ids = [], [], []
411
414
 
412
415
  for p in all_packs():
413
416
  if scope not in p["scopes"] and "*" not in p["scopes"]:
@@ -430,9 +433,10 @@ def block_for(task, scope_override=None, *, stable_order=False):
430
433
  if stable_order:
431
434
  lessons.sort(key=lambda x: x.get("id") or "")
432
435
  lines = []
433
- for x in lessons:
434
- lines.append("- **%s**:%s" % (x["title"], x["content"]))
435
- used.append(x["id"])
436
+ for x in lessons:
437
+ lines.append("- **%s**:%s" % (x["title"], x["content"]))
438
+ used.append(x["id"])
439
+ lesson_ids.append(x["id"])
436
440
  parts.append("### 【本项目已沉淀的教训(历史评审反复出现,务必规避)】\n" + "\n".join(lines))
437
441
 
438
442
  if not parts:
@@ -440,8 +444,8 @@ def block_for(task, scope_override=None, *, stable_order=False):
440
444
  text = "## 经验库(写作/工程规范 + 历史教训,必须遵守)\n\n" + "\n\n".join(parts)
441
445
  if len(text) > MAX_INJECT_CHARS:
442
446
  text = text[:MAX_INJECT_CHARS] + "\n…(已截断)"
443
- if used:
444
- bump_hits(used)
447
+ if lesson_ids:
448
+ bump_hits(lesson_ids) # 包 id 不参与教训热度,命中数据只保留一份真源
445
449
  return text, used
446
450
 
447
451