codebee 0.1.20 → 0.1.21

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.
@@ -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,10 +3267,18 @@ 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
3209
3283
  # 代码版本检出:任务指定了基线版本时,先检出任务分支 tutti/<task-id> 再跑流水线。
3210
3284
  # 显式意图不容静默降级——仓库缺失/脏工作区/引用不存在一律中止运行并报错,
@@ -3215,8 +3289,8 @@ def execute_run(run_id):
3215
3289
  ok, err, gitinfo = gitmod.prepare_checkout(
3216
3290
  task["workdir"], task["git_rev"], task["id"])
3217
3291
  if not ok:
3218
- store.update_run(run_id, status="failed", error="代码版本检出失败:%s" % err,
3219
- ended_at=_now())
3292
+ store.update_run(run_id, expected_status="running", status="failed",
3293
+ error="代码版本检出失败:%s" % err, ended_at=_now())
3220
3294
  return
3221
3295
  git_ctx = gitinfo
3222
3296
  store.update_run(run_id, git=gitinfo)
@@ -3240,6 +3314,11 @@ def execute_run(run_id):
3240
3314
  if extra:
3241
3315
  agents.append(extra)
3242
3316
  stats = history.agent_stats()
3317
+ # 运行级任务画像:所有后续 bind_agent 调用共享同一预置类型,
3318
+ # 模型级联因此覆盖 direct/code/review/serial/translation 等全部引擎。
3319
+ for _agent in agents:
3320
+ if isinstance(_agent, dict):
3321
+ _agent["_dispatch_task_type"] = task.get("type") or "direct"
3243
3322
  mode = task.get("mode") or ("manual" if task.get("implementer") else "auto")
3244
3323
  store.update_run(run_id, mode=mode)
3245
3324
  # engine 决定流水线:code=实现/验证/评审/修复;review=起草/多维评审/修订/门禁;
@@ -3272,7 +3351,8 @@ def execute_run(run_id):
3272
3351
  impl, route["author"] = router.pick(agents, "implement", task["type"], stats)
3273
3352
  critics, route["critics"] = router.pick_critics(agents, task["type"], stats, impl=impl)
3274
3353
  if impl is None:
3275
- store.update_run(run_id, status="failed", error="没有可用智能体", ended_at=_now())
3354
+ store.update_run(run_id, expected_status="running", status="failed",
3355
+ error="没有可用智能体", ended_at=_now())
3276
3356
  return
3277
3357
  if resume_ctx is not None and mode == "auto":
3278
3358
  critics, route["critics"] = router.pick_critics(agents, task["type"], stats, impl=impl)
@@ -3282,11 +3362,12 @@ def execute_run(run_id):
3282
3362
  else:
3283
3363
  _run_content_review(run, task, agents, ev, stats, mode)
3284
3364
  except Cancelled:
3285
- store.update_run(run_id, status="cancelled", ended_at=_now())
3365
+ store.update_run(run_id, expected_status="running",
3366
+ status="cancelled", ended_at=_now())
3286
3367
  except Exception as e:
3287
3368
  import traceback
3288
- store.update_run(run_id, status="failed", error=repr(e)[:500],
3289
- ended_at=_now())
3369
+ store.update_run(run_id, expected_status="running", status="failed",
3370
+ error=repr(e)[:500], ended_at=_now())
3290
3371
  try:
3291
3372
  err_path = store.run_dir(run_id) / "error.log"
3292
3373
  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 = {
@@ -13,15 +13,19 @@ CAPABILITY = {
13
13
  MAX_REPAIR_ROUNDS = 2 # 自动修复循环上限
14
14
 
15
15
 
16
- def _binding_bonus(agent_id):
16
+ def _binding_bonus(agent_id, dispatch_mode=False):
17
17
  """绑定链可用性加分/减分:链上有可用条目 +8,解析为空 -25。2026-09-16 实测:
18
18
  静态能力基线让配额烧干的 codex 永远压过健康备用 CLI,绑定空的 CLI 更是连
19
19
  用户配置的模型都没用上——先按「能不能按配置跑起来」校准。2026-09-17 起
20
20
  空链步骤在 pipeline 直接判失败(不再静默回落本机默认),此处只管排序。"""
21
21
  try:
22
22
  from . import modelhub
23
- b = modelhub.resolve_binding(agent_id)
24
- return 8.0 if (b and b.get("call_chain")) else -25.0
23
+ pref = modelhub._binding_for(agent_id)
24
+ configured = bool(modelhub._binding_chain(pref) or pref.get("provider_id"))
25
+ if not configured:
26
+ return 0.0 if dispatch_mode else -25.0
27
+ b = modelhub.resolve_binding(agent_id)
28
+ return 8.0 if (b and b.get("call_chain")) else -25.0
25
29
  except Exception:
26
30
  return 0.0
27
31
 
@@ -45,14 +49,20 @@ def score(agent, role, ttype, stats=None):
45
49
  (封顶 -45,足以盖过历史加分),满额后仅在没有其他选择时才会被选中。"""
46
50
  stats = stats or {}
47
51
  base = CAPABILITY.get(agent.get("kind"), 60)
48
- bb = _binding_bonus(agent.get("id"))
52
+ use_dispatch = bool(agent.get("_dispatch_task_type") or
53
+ agent.get("dispatch_enabled"))
54
+ bb = _binding_bonus(agent.get("id"), dispatch_mode=use_dispatch)
49
55
  btxt = ""
50
56
  if bb > 0:
51
57
  btxt = ",绑定链可用(+%s)" % bb
52
58
  elif bb < 0:
53
59
  btxt = ",绑定链为空:相关步骤将判失败(%s)" % bb
54
60
  hb = _history_bonus(stats, agent.get("id"), ttype)
55
- total = base + bb + hb
61
+ # 保持公开 score() 的历史绝对分值;运行级候选由 pipeline 标记画像后
62
+ # 才启用能力亲和度,避免旧插件/测试调用被新权重悄然改变。
63
+ affinity, affinity_txt = (dispatch.agent_affinity(agent.get("kind"), ttype, role)
64
+ if use_dispatch else (0.0, "兼容模式"))
65
+ total = base + bb + hb + affinity
56
66
  hs = (stats.get(agent.get("id")) or {}).get(ttype)
57
67
  htxt = (",历史 %d/%d 胜(%s)" % (hs["wins"], hs["runs"], "%+.1f" % hb)) if hs else ",无历史记录"
58
68
  quota_txt = ""
@@ -69,7 +79,8 @@ def score(agent, role, ttype, stats=None):
69
79
  if penalty:
70
80
  total += penalty
71
81
  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))
82
+ return total, "能力基线 %d,%s%s%s%s,总分 %s" % (
83
+ base, affinity_txt, btxt, htxt, quota_txt, round(total, 1))
73
84
 
74
85
 
75
86
  def pick(agents, role, ttype, stats=None, exclude=()):
@@ -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
 
@@ -394,9 +394,50 @@ def relevance_top(lessons, task, limit):
394
394
  grams = _text_bigrams(x.get("title")) | _text_bigrams(x.get("content"))
395
395
  return (-len(probe & grams), x.get("id") or "")
396
396
 
397
+ # 使用反馈闭环(pmb「量化记忆真实帮助」借鉴):被选中次数多的教训排前
398
+ hits = _load_hits()
399
+ def rank(x):
400
+ grams = _text_bigrams(x.get("title")) | _text_bigrams(x.get("content"))
401
+ overlap = -len(probe & grams)
402
+ lid = x.get("id") or ""
403
+ return (overlap, -hits.get(lid, 0), lid)
404
+
397
405
  return sorted(lessons, key=rank)[:limit]
398
406
 
399
407
 
408
+ def _hits_path():
409
+ return paths.DATA_DIR / "skill_hits.json"
410
+
411
+
412
+ def _load_hits():
413
+ """读取教训使用计数({lesson_id: 次数})。文件缺失/损坏返回空 dict。"""
414
+ try:
415
+ p = _hits_path()
416
+ if p.is_file():
417
+ import json
418
+ return json.loads(p.read_text(encoding="utf-8"))
419
+ except Exception:
420
+ pass
421
+ return {}
422
+
423
+
424
+ def _bump_hits(ids):
425
+ """注入后递增使用计数并持久化(fire-and-forget,失败静默)。"""
426
+ try:
427
+ import json
428
+ p = _hits_path()
429
+ hits = _load_hits()
430
+ for lid in ids:
431
+ if lid:
432
+ hits[lid] = hits.get(lid, 0) + 1
433
+ p.parent.mkdir(parents=True, exist_ok=True)
434
+ tmp = p.with_suffix(".tmp")
435
+ tmp.write_text(json.dumps(hits, ensure_ascii=False), encoding="utf-8")
436
+ tmp.replace(p)
437
+ except Exception:
438
+ pass
439
+
440
+
400
441
  def block_for(task, scope_override=None, *, stable_order=False):
401
442
  """生成注入提示词的经验块。命中即计数。返回 (文本, 命中的 id 列表)。
402
443
 
@@ -440,6 +481,8 @@ def block_for(task, scope_override=None, *, stable_order=False):
440
481
  text = "## 经验库(写作/工程规范 + 历史教训,必须遵守)\n\n" + "\n\n".join(parts)
441
482
  if len(text) > MAX_INJECT_CHARS:
442
483
  text = text[:MAX_INJECT_CHARS] + "\n…(已截断)"
484
+ if used:
485
+ _bump_hits(used) # 使用反馈闭环:被选中的教训递增计数,下次排序升权
443
486
  if used:
444
487
  bump_hits(used)
445
488
  return text, used
package/app/core/store.py CHANGED
@@ -448,11 +448,16 @@ def load_all():
448
448
  # 错误串(`[91m[1mError:`、U+FFFD 乱码墙),读盘时统一洗一遍——
449
449
  # 老运行不必等重跑才干净(2026-09-20「咋还有乱码」实测)
450
450
  _sanitize_run_text(r)
451
- # 队列不跨进程持久化:磁盘上仍是 queued/running 的运行必是上次进程中断的残骸
452
- if r.get("status") in ("queued", "running"):
453
- r["status"] = "failed"
454
- r["error"] = r.get("error") or "服务重启中断,可重试"
455
- _save_json(p, r)
451
+ # 直接执行线程不跨进程:running 与无截止时间的 queued 是上次
452
+ # 进程中断残骸。带 resume_enqueue_at queued 是有意的自动续跑
453
+ # 退避,保留给 jobs.restore_deferred_resumes 重建 Timer。
454
+ interrupted = (r.get("status") == "running" or
455
+ (r.get("status") == "queued" and
456
+ not r.get("resume_enqueue_at")))
457
+ if interrupted:
458
+ r["status"] = "failed"
459
+ r["error"] = r.get("error") or "服务重启中断,可重试"
460
+ _save_json(p, r)
456
461
  _RUNS[r["id"]] = r
457
462
  except Exception:
458
463
  pass
@@ -507,10 +512,11 @@ def get_run(run_id):
507
512
  return _RUNS.get(run_id)
508
513
 
509
514
 
510
- def list_runs(limit=60):
511
- with LOCK:
512
- ids = sorted(_RUNS.keys(), reverse=True)
513
- return [_RUNS[i] for i in ids[:limit]]
515
+ def list_runs(limit=60):
516
+ with LOCK:
517
+ ids = sorted(_RUNS.keys(), reverse=True)
518
+ selected = ids if limit is None else ids[:limit]
519
+ return [_RUNS[i] for i in selected]
514
520
 
515
521
 
516
522
  def latest_run_by_task():
@@ -635,17 +641,17 @@ def task_side(task_id):
635
641
  }
636
642
 
637
643
 
638
- def update_run(run_id, expected_status=None, **fields):
644
+ def update_run(run_id, expected_status=None, **fields):
639
645
  """更新运行字段。expected_status 非 None 时做 CAS(§2C):
640
646
  当前状态不等于 expected_status 则拒绝写入并返回 None,
641
647
  防止陈旧执行方(被取消的 worker、崩溃恢复前的旧线程)覆盖新状态
642
648
  ——防御模式「异步状态不是同步状态」。不传则保持原行为。"""
643
649
  with LOCK:
644
650
  run = _RUNS.get(run_id)
645
- if not run:
646
- return None
647
- if expected_status is not None and run.get("status") != expected_status:
648
- return None
651
+ if not run:
652
+ return None
653
+ if expected_status is not None and run.get("status") != expected_status:
654
+ return None
649
655
  for k in ("error", "summary"):
650
656
  if isinstance(fields.get(k), str):
651
657
  # 错误串多是我们自己拼的 CLI 尾巴(含 ANSI/覆写/乱码墙):