codebee 0.1.19 → 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):
@@ -502,6 +502,7 @@ def _spawn_step(session_run_id, role, agent, prompt, workdir, readonly, ev,
502
502
  "error_code": ErrorCode.ENV_BLOCK, "sid": "",
503
503
  "raw": {"exit_code": None}, "kind": agent.get("kind", "generic"),
504
504
  "model": agent.get("model")}
505
+ usage_recorded = False
505
506
  if _compaction_enabled() and not resume:
506
507
  # Phase 2(1D):撑爆 → 压缩 → 守门重试;同时把 usage 累进 token_meter(1C)
507
508
  session = _get_session(session_run_id)
@@ -511,6 +512,7 @@ def _spawn_step(session_run_id, role, agent, prompt, workdir, readonly, ev,
511
512
  images=images, require_tools=require_tools)
512
513
 
513
514
  def _call(p, **kw):
515
+ nonlocal usage_recorded
514
516
  # 模型可见即已记录(§1A 不变量):入参/出参先落 session 日志
515
517
  session.append("user_message", {"content": p, "role": role},
516
518
  turn_id=str(step["n"]))
@@ -523,6 +525,7 @@ def _spawn_step(session_run_id, role, agent, prompt, workdir, readonly, ev,
523
525
  from .token_meter import token_meter
524
526
  token_meter.accumulate(session_run_id, r.get("usage"),
525
527
  model=r.get("model") or "")
528
+ usage_recorded = True
526
529
  except Exception:
527
530
  pass
528
531
  return r
@@ -534,6 +537,15 @@ def _spawn_step(session_run_id, role, agent, prompt, workdir, readonly, ev,
534
537
  res = runner.run_agent(agent, prompt, workdir=workdir, readonly=readonly,
535
538
  timeout=timeout, cancel_event=ev, log_path=str(log_abs),
536
539
  resume=resume, images=images, require_tools=require_tools)
540
+ # 默认关闭压缩和 resume 都走直通分支,也必须把真实 usage 送进预算表;否则
541
+ # 下一步永远看到 used=0,max_tokens_per_run 只是一个无效设置。
542
+ if not usage_recorded:
543
+ try:
544
+ from .token_meter import token_meter
545
+ token_meter.accumulate(session_run_id, res.get("usage"),
546
+ model=res.get("model") or "")
547
+ except Exception:
548
+ pass
537
549
  return res
538
550
 
539
551
 
@@ -895,12 +907,18 @@ def _run_code(run, task, agents, ev, stats, mode):
895
907
  else:
896
908
  impl, route["implementer"] = router.pick(agents, "implement", "code", stats)
897
909
  if impl is None:
898
- 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())
899
912
  return
900
913
 
901
914
  # ---- 规划
902
915
  if mode == "auto":
903
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])
904
922
  plan_step, plan_log = store.add_step(run_id, "plan", impl["id"], impl.get("label"),
905
923
  note=route.get("implementer", ""))
906
924
  plan = planner.make_code_plan(_steered_task(run_id, task),
@@ -949,8 +967,13 @@ def _run_code(run, task, agents, ev, stats, mode):
949
967
  register_default_namespaces()
950
968
  if ss_get("cascade", "enabled"):
951
969
  from . import capability
970
+ mh_data = modelhub._load()
952
971
  agt_b = capability.cascade_reorder(
953
- 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")
954
977
  except Exception:
955
978
  pass
956
979
  for i, sub in enumerate(subtasks):
@@ -1021,7 +1044,8 @@ def _run_code(run, task, agents, ev, stats, mode):
1021
1044
  res_err = "实现步骤失败(无其他真实 CLI 可换将): %s" % res.get("error")
1022
1045
  else:
1023
1046
  res_err = "实现步骤失败: %s" % res.get("error")
1024
- 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())
1025
1049
  return False
1026
1050
 
1027
1051
  def review_and_score():
@@ -1117,7 +1141,21 @@ def _run_code(run, task, agents, ev, stats, mode):
1117
1141
  lines += ["", "## 评审总评", "", review_json.get("summary", ""), ""]
1118
1142
  store.write_report(run_id, "\n".join(lines))
1119
1143
  _write_task_evidence(run_id, task, workdir, _evidence_lines_from_run(run_id, task))
1120
- 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,
1121
1159
  summary="代码任务%s(验证%s / 评审%s%s)" % (
1122
1160
  "通过" if overall_pass else "未通过",
1123
1161
  "通过" if verify_pass else "未通过",
@@ -1285,7 +1323,8 @@ def _run_direct(run, task, agents, ev, stats, mode):
1285
1323
  else:
1286
1324
  impl, route["implementer"] = router.pick(agents, "implement", task["type"], stats)
1287
1325
  if impl is None and bi is None:
1288
- 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())
1289
1328
  return
1290
1329
  difficulty = task.get("difficulty") or "default"
1291
1330
  step_wd = _resume_workdir(resume_ctx, workdir) if resume_ctx else workdir
@@ -1359,7 +1398,7 @@ def _run_direct(run, task, agents, ev, stats, mode):
1359
1398
  readonly=False, ev=ev, note=note,
1360
1399
  resume=sid or None, images=images)
1361
1400
  if not res["ok"]:
1362
- store.update_run(run_id, status="failed",
1401
+ store.update_run(run_id, expected_status="running", status="failed",
1363
1402
  error="执行失败: %s" % res.get("error"), ended_at=_now())
1364
1403
  return
1365
1404
  turns += 1
@@ -1391,7 +1430,7 @@ def _run_direct(run, task, agents, ev, stats, mode):
1391
1430
  report += ["## 最近一轮输出", "", last_text[-5000:], ""]
1392
1431
  store.write_report(run_id, "\n".join(report))
1393
1432
  _write_task_evidence(run_id, task, workdir, _evidence_lines_from_run(run_id, task))
1394
- store.update_run(run_id, status="done", verdict=verdict,
1433
+ store.update_run(run_id, expected_status="running", status="done", verdict=verdict,
1395
1434
  summary="直连完成(%d 轮):%s" % (turns, last_text[:160]),
1396
1435
  ended_at=_now())
1397
1436
 
@@ -1410,7 +1449,7 @@ def _pick_reviewer_legacy(agents, impl):
1410
1449
 
1411
1450
  # ---------------------------------------------------------------- review 引擎(小说/文档/翻译/调研…通用)
1412
1451
 
1413
- NOVEL_DRAFT_PROMPT = """你是一名专业作者。请在当前工作目录中撰写/修订稿件文件:`__FILE__`(直接写入该文件)。文件必须以 UTF-8 编码保存(PowerShell 写文件显式加 -Encoding UTF8,禁止依赖默认编码)。
1452
+ NOVEL_DRAFT_PROMPT = """你是__ROLE__。请在当前工作目录中撰写/修订稿件文件:`__FILE__`(直接写入该文件)。文件必须以 UTF-8 编码保存(PowerShell 写文件显式加 -Encoding UTF8,禁止依赖默认编码)。
1414
1453
 
1415
1454
  ## 写作任务
1416
1455
  __GOAL__
@@ -1426,6 +1465,69 @@ __RUBRIC__
1426
1465
  - 只修改 `__FILE__` 这一个文件;保持 Markdown 结构。
1427
1466
  - 完成后用 3 句话说明本轮写了什么。"""
1428
1467
 
1468
+ # review 引擎共用执行骨架,但交付物不能只靠 rubric 猜格式。每个内置类型给出
1469
+ # 最小成品契约,起草和修订都注入;自定义流程继续使用通用回退,避免强加结构。
1470
+ CONTENT_DELIVERY_CONTRACTS = {
1471
+ "novel": ("小说作者", [
1472
+ "遵守用户给定的题材、篇幅、视角和风格;未给出的核心设定不要擅自扩张。",
1473
+ "用场景、行动和对话推进冲突,人物动机与前后因果保持一致。",
1474
+ ]),
1475
+ "article": ("平台内容主编", [
1476
+ "标题、开头钩子、正文层级和结尾行动建议要适配目标平台与读者。",
1477
+ "事实、数据和引语不得编造;缺少来源时明确标注待核实。",
1478
+ ]),
1479
+ "video_script": ("短视频编导", [
1480
+ "按镜头或时间段写清画面、口播、字幕/音效与预计时长,前 3 秒给出钩子。",
1481
+ "每个画面都应可实际拍摄或制作,结尾给出自然的互动或转化动作。",
1482
+ ]),
1483
+ "doc": ("技术文档编辑", [
1484
+ "先明确读者、目的和前置条件,再按可执行步骤组织正文。",
1485
+ "命令、参数、示例与限制必须一致;无法确认的内容明确标注。",
1486
+ ]),
1487
+ "translation": ("专业译者与审校", [
1488
+ "忠实保留原文含义、语气、数字、专名、占位符、链接和 Markdown 结构,不增译或漏译。",
1489
+ "术语译法全文一致;歧义或无法确认的专名保留原文并加简短译注。",
1490
+ ]),
1491
+ "research": ("研究分析师", [
1492
+ "围绕决策问题组织证据、对比、结论与可执行建议,避免资料堆砌。",
1493
+ "结论必须能回溯到来源;证据不足处明确写出不确定性和验证办法。",
1494
+ ]),
1495
+ "speech": ("演讲撰稿人", [
1496
+ "按场合、听众和时长控制篇幅,使用适合现场说出的短句与自然转场。",
1497
+ "开场建立关系,主体围绕一个核心信息展开,结尾给出清晰收束或号召。",
1498
+ ]),
1499
+ "weekly_report": ("业务汇报顾问", [
1500
+ "按成果与影响、关键数据、问题阻塞、下步行动(负责人/时间)组织内容。",
1501
+ "只使用用户提供或可核验的数据;缺失数字保留待补项,不虚构业绩。",
1502
+ ]),
1503
+ "email": ("商务沟通顾问", [
1504
+ "包含明确主题、称呼、来意、必要背景、请求/下一步和得体落款。",
1505
+ "语气匹配双方关系;日期、承诺、附件与联系人不得凭空补造。",
1506
+ ]),
1507
+ "tech_proposal": ("解决方案架构师", [
1508
+ "覆盖现状与目标、约束、候选方案对比、推荐架构、实施阶段、风险与回滚、验收指标。",
1509
+ "区分已知事实、假设和待验证项;成本收益给出计算口径而非虚构数字。",
1510
+ ]),
1511
+ "resume": ("招聘与简历顾问", [
1512
+ "围绕目标岗位提炼真实经历,用行动、结果和技能关键词表达岗位匹配度。",
1513
+ "不得虚构经历、公司、学历、指标或技术栈;缺少量化数据时保留待补提示。",
1514
+ ]),
1515
+ }
1516
+
1517
+
1518
+ def _content_role(task):
1519
+ """返回内置类型的专业角色;自定义 review 流程使用中性角色。"""
1520
+ spec = CONTENT_DELIVERY_CONTRACTS.get(str(task.get("type") or ""))
1521
+ return spec[0] if spec else "内容交付专家"
1522
+
1523
+
1524
+ def _content_contract(task):
1525
+ """把类型成品约束渲染为稳定提示块;无内置契约时不额外注入。"""
1526
+ spec = CONTENT_DELIVERY_CONTRACTS.get(str(task.get("type") or ""))
1527
+ if not spec:
1528
+ return ""
1529
+ return "\n\n## 本类型交付约束\n" + "\n".join("- " + item for item in spec[1])
1530
+
1429
1531
  # 调研报告类稿件的追加要求(借鉴 gpt-researcher 迭代深研):有网络/读文件工具时
1430
1532
  # 多源交叉验证,单源结论降权——调研的可信度来自证据链而非文采
1431
1533
  RESEARCH_APPENDIX = """
@@ -1438,7 +1540,7 @@ RESEARCH_APPENDIX = """
1438
1540
  - 结构硬性要求:报告第一段必须是「**核心结论**」三行以内的要点摘要(结论先行),
1439
1541
  之后才展开分层论证 → 风险与局限(说明哪些结论证据不足)。"""
1440
1542
 
1441
- NOVEL_REVISE_PROMPT = """你是一名专业作者。请根据下方汇总评审意见修订稿件文件:`__FILE__`(直接写入该文件)。文件必须以 UTF-8 编码保存(PowerShell 写文件显式加 -Encoding UTF8,禁止依赖默认编码)。
1543
+ NOVEL_REVISE_PROMPT = """你是__ROLE__。请根据下方汇总评审意见修订稿件文件:`__FILE__`(直接写入该文件)。文件必须以 UTF-8 编码保存(PowerShell 写文件显式加 -Encoding UTF8,禁止依赖默认编码)。
1442
1544
 
1443
1545
  ## 原始写作任务
1444
1546
  __GOAL__
@@ -1811,7 +1913,7 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
1811
1913
  # 历史遗留:降级/模板大纲被继承时,真实任务宁可中止重生成,也不按空模板写全书
1812
1914
  if (outline.get("degraded") or outline.get("source") == "template") \
1813
1915
  and impl.get("mode") != "mock":
1814
- store.update_run(run_id, status="failed",
1916
+ store.update_run(run_id, expected_status="running", status="failed",
1815
1917
  error="继承的大纲为降级模板(无真实情节),已中止以重新生成大纲",
1816
1918
  ended_at=_now())
1817
1919
  return
@@ -1834,7 +1936,7 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
1834
1936
  store.finish_step(run_id, outline_step["n"], "failed",
1835
1937
  summary="大纲降级:%s" % (outline.get("degraded_reason") or "编排者不可用"),
1836
1938
  duration_s=None)
1837
- store.update_run(run_id, status="failed",
1939
+ store.update_run(run_id, expected_status="running", status="failed",
1838
1940
  error="%s,已中止以免按空模板写全书"
1839
1941
  % (outline.get("degraded_reason") or "编排者不可用"),
1840
1942
  ended_at=_now())
@@ -2103,7 +2205,7 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2103
2205
  time.sleep(3) # 落盘竞态宽限:CLI 崩溃退出前写的文件可能晚于
2104
2206
  good, txt = _chapter_state() # 退出检查零点几秒才可见(c34 实测)
2105
2207
  if not good:
2106
- store.update_run(run_id, status="failed",
2208
+ store.update_run(run_id, expected_status="running", status="failed",
2107
2209
  error="第 %d 章起草失败: %s" % (i, (res or {}).get("error")), ended_at=_now())
2108
2210
  return
2109
2211
  if not res["ok"]:
@@ -2187,7 +2289,7 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2187
2289
  if scored_variants:
2188
2290
  break
2189
2291
  if not scored_variants:
2190
- store.update_run(run_id, status="failed",
2292
+ store.update_run(run_id, expected_status="running", status="failed",
2191
2293
  error="第 %d 章赛马全部变体起草失败" % i, ended_at=_now())
2192
2294
  return
2193
2295
  scored_variants.sort(key=lambda v: (-v["avg"], v["variant"]))
@@ -2198,7 +2300,7 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2198
2300
  os.replace(os.path.join(workdir, win["file"]),
2199
2301
  os.path.join(workdir, ch_file))
2200
2302
  except OSError as e:
2201
- store.update_run(run_id, status="failed",
2303
+ store.update_run(run_id, expected_status="running", status="failed",
2202
2304
  error="第 %d 章赛马收卷失败: %r" % (i, e), ended_at=_now())
2203
2305
  return
2204
2306
  for v in scored_variants[1:]:
@@ -2246,7 +2348,7 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2246
2348
  if not scored:
2247
2349
  # 「评不上」≠「评了 0 分」:全部评审失败时中止本轮,
2248
2350
  # 让自动续跑换个时机重试,而不是以 0 分误判章稿质量。
2249
- store.update_run(run_id, status="failed",
2351
+ store.update_run(run_id, expected_status="running", status="failed",
2250
2352
  error="第 %d 章评审全部失败(评审模型不可用或输出不可解析),"
2251
2353
  "已中止以免以 0 分误判质量" % i, ended_at=_now())
2252
2354
  return
@@ -2377,7 +2479,7 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2377
2479
  if gscored:
2378
2480
  break
2379
2481
  if not gscored:
2380
- store.update_run(run_id, status="failed",
2482
+ store.update_run(run_id, expected_status="running", status="failed",
2381
2483
  error="全局一致性评审全部失败(评审模型不可用或输出不可解析),"
2382
2484
  "已中止以免把「无法评审」误判为「未达标」。"
2383
2485
  "各章稿件已全部落盘,修复评审链后续跑可直接收尾",
@@ -2546,7 +2648,7 @@ def _run_serial_review(run, task, agents, ev, stats, mode, critics, impl, route,
2546
2648
  lines.append("(无 major 问题)")
2547
2649
  store.write_report(run_id, "\n".join(lines))
2548
2650
  _write_task_evidence(run_id, task, workdir, _evidence_lines_from_run(run_id, task))
2549
- store.update_run(run_id, status="done", verdict=verdict,
2651
+ store.update_run(run_id, expected_status="running", status="done", verdict=verdict,
2550
2652
  summary="连载任务%s(%s,约 %d 字,综合 %.1f)" % (
2551
2653
  "达标" if publishable else "未达标", scope_txt,
2552
2654
  total_words, overall),
@@ -2689,13 +2791,16 @@ def _run_content_review(run, task, agents, ev, stats, mode):
2689
2791
  impl, _ = _pick_implementer(agents, task.get("implementer"))
2690
2792
  critics = _pick_critics_manual(agents, task)
2691
2793
  else:
2692
- impl, route["author"] = router.pick(agents, "implement", "novel", stats)
2693
- critics, route["critics"] = router.pick_critics(agents, "novel", stats, impl=impl)
2794
+ task_type = task.get("type") or "novel"
2795
+ impl, route["author"] = router.pick(agents, "implement", task_type, stats)
2796
+ critics, route["critics"] = router.pick_critics(agents, task_type, stats, impl=impl)
2694
2797
  if impl is None:
2695
- 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())
2696
2800
  return
2697
2801
  if resume_ctx is not None and mode == "auto":
2698
- critics, route["critics"] = router.pick_critics(agents, "novel", stats, impl=impl)
2802
+ critics, route["critics"] = router.pick_critics(
2803
+ agents, task.get("type") or "novel", stats, impl=impl)
2699
2804
 
2700
2805
  # ---- 规划(小说为模板计划)
2701
2806
  _wait_gate(run_id, ev)
@@ -2739,6 +2844,7 @@ def _run_content_review(run, task, agents, ev, stats, mode):
2739
2844
 
2740
2845
  def _draft_prompt_for(vfile):
2741
2846
  p = (_tpl(task, "draft_prompt", NOVEL_DRAFT_PROMPT).replace("__FILE__", vfile)
2847
+ .replace("__ROLE__", _content_role(task))
2742
2848
  .replace("__GOAL__", task["goal"])
2743
2849
  .replace("__CONTEXT__", task.get("context") or "(无)")
2744
2850
  .replace("__RUBRIC__", "、".join(dims) if dims else "(按流程默认维度)"))
@@ -2748,6 +2854,7 @@ def _run_content_review(run, task, agents, ev, stats, mode):
2748
2854
  if is_research:
2749
2855
  # 调研报告追加证据链要求(gpt-researcher 借鉴)
2750
2856
  p += RESEARCH_APPENDIX
2857
+ p += _content_contract(task)
2751
2858
  return p
2752
2859
 
2753
2860
  best_of = max(1, min(3, int(task.get("best_of") or 1)))
@@ -2768,7 +2875,8 @@ def _run_content_review(run, task, agents, ev, stats, mode):
2768
2875
  resume=resume_ctx["session"] if resume_ctx else None,
2769
2876
  images=_task_images(task, workdir))
2770
2877
  if not draft_res["ok"]:
2771
- 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"),
2772
2880
  ended_at=_now())
2773
2881
  return
2774
2882
 
@@ -2847,8 +2955,10 @@ def _run_content_review(run, task, agents, ev, stats, mode):
2847
2955
  pass
2848
2956
  else:
2849
2957
  prompt = (NOVEL_REVISE_PROMPT.replace("__FILE__", ms_name)
2958
+ .replace("__ROLE__", _content_role(task))
2850
2959
  .replace("__GOAL__", task["goal"])
2851
2960
  .replace("__CRITIQUE__", "\n".join(crit_lines)))
2961
+ prompt += _content_contract(task)
2852
2962
  _run_step(run_id, "revise-r%d" % r, modelhub.bind_agent(impl, difficulty), prompt,
2853
2963
  workdir, readonly=False, ev=ev,
2854
2964
  resume=resume_ctx["session"] if resume_ctx else None)
@@ -2898,7 +3008,7 @@ def _run_content_review(run, task, agents, ev, stats, mode):
2898
3008
  lines += ["", "## 稿件位置", "", "`%s`" % ms_path, ""]
2899
3009
  store.write_report(run_id, "\n".join(lines))
2900
3010
  _write_task_evidence(run_id, task, workdir, _evidence_lines_from_run(run_id, task))
2901
- store.update_run(run_id, status="done", verdict=verdict,
3011
+ store.update_run(run_id, expected_status="running", status="done", verdict=verdict,
2902
3012
  summary="评审任务%s(综合 %.1f)" % ("达标" if publishable else "未达标", overall),
2903
3013
  ended_at=_now())
2904
3014
 
@@ -2943,13 +3053,13 @@ def _run_serial_qa(run, task, agents, ev):
2943
3053
  res = _run_step(run_id, "qa", modelhub.bind_agent(agent, "default"),
2944
3054
  prompt, workdir, readonly=True, ev=ev, timeout=1200)
2945
3055
  if res.get("ok") and (res.get("text") or "").strip():
2946
- store.update_run(run_id, status="done", ended_at=_now(),
3056
+ store.update_run(run_id, expected_status="running", status="done", ended_at=_now(),
2947
3057
  verdict={"qa": True,
2948
3058
  "answered_by": agent.get("id")})
2949
3059
  return
2950
3060
  errors.append("%s:%s" % (agent.get("id"),
2951
3061
  (res.get("error") or "无输出")[:120]))
2952
- store.update_run(run_id, status="failed", ended_at=_now(),
3062
+ store.update_run(run_id, expected_status="running", status="failed", ended_at=_now(),
2953
3063
  error="答疑失败(执行/评审链不可用)——" + ";".join(errors[-3:]))
2954
3064
 
2955
3065
 
@@ -2970,6 +3080,43 @@ def _read_constitution(workdir):
2970
3080
  "与其他要求冲突时以宪章为准)\n\n" + txt + "\n\n")
2971
3081
 
2972
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
+
2973
3120
  def _write_task_spec(task, workdir):
2974
3121
  """任务规格落盘 .codebee/spec.md(借鉴 agent-orchestrator 的 .spec/PROMPT.md 与
2975
3122
  planning-with-files 的文件化计划):任务定义随工作目录留存、随任务分支版本化,
@@ -3120,10 +3267,18 @@ def execute_run(run_id):
3120
3267
  error="排队期间被取消")
3121
3268
  return
3122
3269
  task = store.get_task(run.get("task_id"))
3123
- 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
3124
3279
  if task is None:
3125
- store.update_run(run_id, status="failed", error="找不到任务 %s" % run.get("task_id"),
3126
- ended_at=_now())
3280
+ store.update_run(run_id, expected_status="running", status="failed",
3281
+ error="找不到任务 %s" % run.get("task_id"), ended_at=_now())
3127
3282
  return
3128
3283
  # 代码版本检出:任务指定了基线版本时,先检出任务分支 tutti/<task-id> 再跑流水线。
3129
3284
  # 显式意图不容静默降级——仓库缺失/脏工作区/引用不存在一律中止运行并报错,
@@ -3134,8 +3289,8 @@ def execute_run(run_id):
3134
3289
  ok, err, gitinfo = gitmod.prepare_checkout(
3135
3290
  task["workdir"], task["git_rev"], task["id"])
3136
3291
  if not ok:
3137
- store.update_run(run_id, status="failed", error="代码版本检出失败:%s" % err,
3138
- ended_at=_now())
3292
+ store.update_run(run_id, expected_status="running", status="failed",
3293
+ error="代码版本检出失败:%s" % err, ended_at=_now())
3139
3294
  return
3140
3295
  git_ctx = gitinfo
3141
3296
  store.update_run(run_id, git=gitinfo)
@@ -3159,6 +3314,11 @@ def execute_run(run_id):
3159
3314
  if extra:
3160
3315
  agents.append(extra)
3161
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"
3162
3322
  mode = task.get("mode") or ("manual" if task.get("implementer") else "auto")
3163
3323
  store.update_run(run_id, mode=mode)
3164
3324
  # engine 决定流水线:code=实现/验证/评审/修复;review=起草/多维评审/修订/门禁;
@@ -3191,7 +3351,8 @@ def execute_run(run_id):
3191
3351
  impl, route["author"] = router.pick(agents, "implement", task["type"], stats)
3192
3352
  critics, route["critics"] = router.pick_critics(agents, task["type"], stats, impl=impl)
3193
3353
  if impl is None:
3194
- 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())
3195
3356
  return
3196
3357
  if resume_ctx is not None and mode == "auto":
3197
3358
  critics, route["critics"] = router.pick_critics(agents, task["type"], stats, impl=impl)
@@ -3201,11 +3362,12 @@ def execute_run(run_id):
3201
3362
  else:
3202
3363
  _run_content_review(run, task, agents, ev, stats, mode)
3203
3364
  except Cancelled:
3204
- 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())
3205
3367
  except Exception as e:
3206
3368
  import traceback
3207
- store.update_run(run_id, status="failed", error=repr(e)[:500],
3208
- ended_at=_now())
3369
+ store.update_run(run_id, expected_status="running", status="failed",
3370
+ error=repr(e)[:500], ended_at=_now())
3209
3371
  try:
3210
3372
  err_path = store.run_dir(run_id) / "error.log"
3211
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"]: