codebee 0.1.69 → 0.1.70

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.
Files changed (57) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +4 -3
  3. package/app/browser-final-1440.png +0 -0
  4. package/app/browser-final-390.png +0 -0
  5. package/app/browser-google-fallback.png +0 -0
  6. package/app/browser-google-test.png +0 -0
  7. package/app/browser-workbench-1440.png +0 -0
  8. package/app/browser-workbench-390.png +0 -0
  9. package/app/browser-workspace-google.png +0 -0
  10. package/app/browser-workspace-smoke.png +0 -0
  11. package/app/browser-workspace.png +0 -0
  12. package/app/core/audit.py +30 -0
  13. package/app/core/automation.py +107 -12
  14. package/app/core/benchstore.py +95 -0
  15. package/app/core/bookmeta.py +9 -4
  16. package/app/core/builtin_agent.py +472 -33
  17. package/app/core/catalog.py +7 -1
  18. package/app/core/dispatch.py +20 -3
  19. package/app/core/error_codes.py +11 -2
  20. package/app/core/evalbench.py +720 -0
  21. package/app/core/flows.py +228 -3
  22. package/app/core/jobs.py +22 -1
  23. package/app/core/manager.py +21 -0
  24. package/app/core/market_remote.py +10 -0
  25. package/app/core/mcp_client.py +287 -0
  26. package/app/core/modelhub.py +185 -2
  27. package/app/core/notify.py +118 -23
  28. package/app/core/novel_quality.py +189 -0
  29. package/app/core/paths.py +4 -0
  30. package/app/core/pipeline.py +426 -28
  31. package/app/core/planner.py +243 -22
  32. package/app/core/plugins.py +525 -0
  33. package/app/core/publish/browser.py +129 -13
  34. package/app/core/publish/fanqie.py +6 -0
  35. package/app/core/publish/flow.py +154 -5
  36. package/app/core/publish/flows-fanqie-calibrated.json +11 -7
  37. package/app/core/publish/ledger.py +29 -3
  38. package/app/core/publish/manager.py +134 -2
  39. package/app/core/publish/qimao.py +5 -0
  40. package/app/core/remotesync.py +121 -0
  41. package/app/core/runner.py +80 -5
  42. package/app/core/settings.py +39 -2
  43. package/app/core/settings_schema.py +8 -0
  44. package/app/core/store.py +30 -8
  45. package/app/core/usage.py +187 -14
  46. package/app/core/zentao.py +153 -4
  47. package/app/current-browser.png +0 -0
  48. package/app/main.py +279 -15
  49. package/app/mcp_server.py +175 -0
  50. package/app/pet.py +82 -21
  51. package/app/plugins/code-review-helper/.codex-plugin/plugin.json +18 -0
  52. package/app/plugins/code-review-helper/skills/review.md +16 -0
  53. package/app/ui/app.js +1380 -73
  54. package/app/ui/i18n.js +248 -6
  55. package/app/ui/index.html +277 -13
  56. package/app/ui/style.css +786 -0
  57. package/package.json +2 -1
package/CHANGELOG.md CHANGED
@@ -4,6 +4,43 @@ CodeBee 的用户可感知变更记录。发布新版时:最上面加一节,
4
4
  `<!-- relnotes:start -->…<!-- relnotes:end -->` 段(那段会被 `npm view` 的
5
5
  README 元数据带回,供老版本在「发现新版本」时展示新版更新内容)。
6
6
 
7
+ ## 未发布
8
+
9
+ - **内置执行智能体的工具循环恢复正常**:修复工具请求构造中错误的参数名,避免 Anthropic/OpenAI 工具循环、图片请求、reasoning wire 和 direct 路线首轮直接报 `NameError`;流式请求也会保留 `create_task` 规格并把任务创建器传到工具执行。Windows npm wrapper 下的 Gemini CLI 测试同时兼容 `gemini.js` 实际 argv。
10
+ - **七猫建书「每个类型下至少选择1个标签」治了**:标签弹层此前按「先点组名切换、再全弹层搜文本点芯片」点选——第 10 个标签(背景组「都市」)在滚动竞态下被平台静默吞掉仍报成功,背景组空选,确定被平台打回;且建书拿不到 book_id 时步骤日志整段丢弃,无从查起(2026-09-28 实案:9/10 中、终局假成功)。现在:弹层有分组结构时改为**组区块内精确定位芯片**(四组选项本就同屏渲染、芯片点击恒落在其所在组,组名导航退役),点完即回读该组选中集校验、不中就重新定位重试;收尾全组对账——多选的再点一次剔除(芯片是开关)、缺的补点,两轮不齐报错带组名+差集;弹层组名与预期对不上(平台改了标签目录)当场点名报错,不再回退全局乱点。顺带三处基建:Edge 147 起 launcher 进程秒退后调试端口继续等满窗口(此前误报「浏览器已在运行的会话」);wmic 已被 Win11 24H2 移除——进程扫描落 PowerShell CIM 回退、浏览器关闭按 profile 补刀防孤儿实例锁档案;建书 book_id 落空时步骤日志照常入台账。
11
+ - **番茄建书 23 战 1 胜的盲点点击治了**:自动化点网页按钮以前是「定位到就派发」,落点被遮罩挡住、浏览器窗口被恢复成怪尺寸(921×920 方窗实案)都会步步报成功、最后在很远的步骤炸出莫须有的错(「找不到都市脑洞」,实情是标签弹层根本没开)。现在:浏览器窗口几何钉死 + 接管老实例时视口异常告警;每次真实点击前用 `elementFromPoint` 验证落点真是目标,被挡/出视口当场按死因报错;签约模式/目标读者 radio 点完必须验到选中态,验不中自动重点;标签弹层点开后等待弹层真实存在才继续(盲 sleep 退役);点击目标找不到时,报错自带「定位范围内可见项清单」——弹层没开和分类表改名一眼可分。
12
+ - **默认保存路径落进 CodeBee 自己的数据目录**:内置默认从「数据目录同级」(Windows 上是 `AppData\Roaming\workspace`,平时根本不存在、点开就报错)收进数据目录内(`%APPDATA%\CodeBee\workspace`,macOS/Linux 为 `~/.codebee/workspace`),启动即自动创建,「打开目录」/选目录不再撞空路径。已在旧位置的任务不受影响,新任务默认落新位置;设置里自定义过路径的也不动。
13
+ - **调研报告会审查来源、敢下「经得起反例」的结论了**:调研起草契约新增两条硬要求——来源先审后用(营销软文/SEO 内容农场/利益相关方自我背书一律降权,引用点明立场,追溯更原始出处);结论经得起反例(关键结论主动找反对证据,找不到才可下强结论,并自问「什么证据能推翻它」写进风险与局限)。此前只有多源交叉验证与冲突裁决,来源本身的立场与可信度不设防——深研 agent 会被检索结果里欺骗性构造的内容操纵(Cornell Tech/MisKnow-Agent/ARGUS 2026 研究簇)。
14
+ - **翻译长文不再「译名随缘漂移」**:翻译起草契约新增「术语表先行」——动笔前先通读全文提炼术语表(专有名词/领域术语逐条「源文 → 译名」)放在稿件开头,全篇译名以表为准不随上下文漂移(andrewyng/translation-agent 三步法的 glossary 机制);此前术语一致性只靠评审维度事后抓,长文译名不统一要等修订轮返工。
15
+ - **作品信息一键生成认得两种章节命名了**:素材收集只认 `chapter-001.md`(三位),连载流水线实际落盘 `chapter-01.md`(两位)——成稿明明在手却报「该任务还没有小说大纲、正文或故事圣经」(2026-09-28 马甲书实案,戍边骑奴同病)。现在三位/两位/一位命名都认。
16
+ - **删除任务从此留案底(审计台账),连载链前序任务禁删**:「删除任务」会连运行记录一起物理清除且无回收站,出过连载根任务被删、事后无人能回答「谁删的、何时删的」的实案(2026-09-28,半夜无人操作时被程序化批量删除)。现在每次删除都在 `data/audit/task_delete-YYYYMM.jsonl` 落一行:任务 ID/标题/类型/工作目录、连带删除的运行清单、发起方(本机标 `local`,远程标来源 IP)。按月分文件、正序追加,与用量台账同款纪律。同时加一道硬闸:被后续任务 `serial.continues` 依赖的连载前序任务拒绝物理删除(提示改用「归档」),抽不掉连载链的断点续跑地基;链尾任务照常可删。日常清理请优先用「归档」(可随时找回),删除留给确定不要的。
17
+ - **花费有了真金白银的账,还能提前预警**:模型列表每行新增「价」按钮,可手标单价(¥/百万 tokens,输入/输出两档;手标按供应商隔离,未标价回落导入价表,都没有就不记账——宁缺毋滥)。台账从此自动按 tokens×单价折算每笔费用(界面显示全部改为 ¥);花费预算上限改 ¥ 口径,到日/月上限 50%、80% 时各推一条预警到你的推送通道(达 100% 才熔断停止后续步骤)。
18
+ - **任务跑完能主动找到你了(个人推送通道)**:群机器人之外新增四条推到私人手机的通知通道——Bark(iOS)、ntfy(自托管友好)、Server酱(微信)、Telegram Bot,设置页编排设置里一键配置并可「发送测试」。多通道同时发送、单通道失败互不影响;只配个人通道也能收到任务收尾摘要;任务跑完若有分支等人拍板,推送会点名「⚠ 任务分支待裁决」。
19
+ - **模型评测基准台上线**:固定样题(章节写作/小 bug 修复/结构化摘要)让候选模型各跑一遍,编排者供应商当裁判按固定 rubric 打分出能力榜;修 bug 题带客观验证(代码真跑断言,正常值会算但边界没修过不了关);裁判与候选同供应商时如实标注「同族评审」。榜单实测分(14 天新鲜度)已作为软信号参与模型链排序与推荐选模——只动排序,健康/冷却/协议等硬约束照常一票否决。
20
+ - **评测台可以跑你自己的题了**:支持添加自定义样题(评审维度/题面自定,最多 12 道),评测、逐题对比、定时回归全线生效——自有验收基准的持续回归;样题也能出分享码给别人(ID 冲突自动换号)。
21
+ - **评测台防模型漂移:定时回归**:设置间隔天数后,到期自动重跑候选榜(启用供应商的启用模型,最多 8 个),结果自动推送到你的推送通道;逐题对比表一眼看出谁在写作强、谁在修 bug 翻车(⚠ 客观验证未过 / ✗ 生成失败 / − 未得分)。
22
+ - **任务类型(流程)可分享、有版本历史**:每行「导出」生成分享码,对方「导入分享码」先解析预览再确认落盘(覆盖冲突会点名);每次保存/导入覆盖前的版本自动留档(每流程最多 10 份),「历史」弹框可一键恢复,每版按任务实际战绩对账(任务数/通过率/均分)——改提示词前先看现在这版跑得怎么样。
23
+ - **直连编排者不再「盲规划」**:代码任务规划前自动扫描工作目录(文件清单+README/package.json 等头部摘要),注入规划提示词——API 直连的编排者没有读文件工具,以前拆解全凭想象编路径,现在贴着真实代码库拆。
24
+ - **内置智能体接入 MCP 工具生态**:设置里配置 MCP 服务器(stdio 形态,最多 4 个)后,直连对话/直接执行的工具循环会合并服务器提供的工具(`mcp__服务器__工具`)。
25
+ - **CodeBee 自己也能被别的 AI 调度了**:新增 MCP 服务器形态(`app/mcp_server.py`),在 Claude Code / ZCode / Cursor 等客户端里挂载后,可直接创建任务、查进度、看用量与评测榜单——仅本机 stdio,无网络监听。
26
+ - **备份包可以推到远程了**:备份页配置 WebDAV / S3 兼容 PUT 端点后,每次导出成功自动上传(curl 流式上传,GB 级备份包不占内存),带「测试连接」——`data/` 不在 git 里、磁盘一坏全没的痛点就此终结。
27
+ - **台账一键导出 CSV**:用量页按当前时间范围导出明细(UTF-8 BOM,Excel 直开)。
28
+
29
+ - **代码任务评审深度随变更量级分级**:评审提示词按 diff 规模自动附加指引——小改动(<40 行)走快速评审(聚焦正确性与验收是否真实覆盖改动,不展开风格类与假设性建议,省 token 也省评审员凑字数);大变更(≥600 行)先逐 hunk 概览建立全貌,再对高风险区(安全/并发/数据与迁移/公共 API 契约)逐行深看,minor 问题汇总一条不逐条展开。中等规模维持默认深度;拿不到 diff 时不给任何指引(不误导评审员放松)。纯提示词分级,不改过审判定语义。
30
+ - **智能体目录「一键升级全部」**:头部新增一键按钮,把所有「已安装且配了升级命令」的 CLI 条目批量送进升级队列,每条沿用单条升级的就地面板与日志跟踪;更新检查结论为「已是最新」的自动跳过,检查不出结果的渠道(unknown/unsupported)也一并尝试(升级命令幂等,宁可空跑不漏升)。同条目去重闸与单条升级共用——已在跑管理操作的条目跳过并在 toast 里点名,不会双开同名全局 npm。只配安装没配升级命令的条目不进批量(与单条一致不出「升级」按钮)。
31
+ - **内置智能体补齐四大基础工具(P0)**:处理真实代码库的短板逐个补上——①局部编辑 `edit_file`(精确查找替换,默认要求命中唯一、可 replace_all,CRLF 文件保持换行风格);②追加 `append_file`(bytes 级追加,日志/记录不必整份重写);③文件管理 `fs_manage`(删除/移动/重命名/复制,目标已存在一律拒绝覆盖);④全文搜索 `search_content`(grep 式正则,命中带「路径:行号:」,自动跳过二进制与 node_modules 等依赖目录)+ `find_files`(文件名通配)。同时 `read_file` 支持 `offset`/`lines` 按行号分段读取——超 64KB 的大文件不再只有「头尾保留、中段省略」一条路。所有新工具沿用工作目录锁死守卫。顺带修掉一个真 bug:Windows 下写文件默认把换行符 `\n` 翻译成 `\r\n`,文件本就含 CRLF 时被二次翻译成 `\r\r\n` 写坏(edit_file/write_file 均已禁翻译,写什么落什么)。
32
+ - 调度层加固:绑定链上的「纯模型条目」(只配模型名、没挂供应商)起跑前即被点名——所有 CLI 起跑日志新增凭据模式审计行(绑定链注入/无注入/本机默认,密钥值绝不入日志),缺凭据+登录闸门类错误按 CLI 附修复指引;不再只有 claude 有这层观测(2026-09-25 Mac claude 实案的三层加固通用化)。
33
+ - **Mac 桌宠不再是「白板蜜蜂」**:macOS 的 Tk 没有逐像素窗口透明(`-transparentcolor` 是 Windows 专属),蜜蜂的矩形底板整个露出来还带系统边框(实拍=一块白板)。现在非 Windows 平台把底板画成深色卡片(对齐悬停清单面板色),桌宠呈「桌面小组件」形态;睡觉/失联的状态滤镜改为只作用于蜂体(原先整帧压暗靠键色噪点兜底,卡片底兜不回来会浮出一块更暗的小框)。Windows 逐像素透明不受影响。
34
+
35
+ ## v0.1.70(2026-09-28)
36
+
37
+ - **发布链三连修**:七猫建书「盲点点击」四连闭环(elementFromPoint 守卫/弹层等待/报错自证)、标签弹层组区块点选+终态对账(第 10 击假成功实案)、已发章数按平台实况自动校准
38
+ - **连载链加固一批**:签约质量链四件套(文风锚/开篇闸门/爆点贯通/2 万字检查点)、签约链内容重放防覆盖、章节文件名两位编号识别、删除任务审计台账 + 连载根任务禁删、超时自动续跑 + 总时限可改
39
+ - **评测台扩展**:自定义样题 + 样题分享码导入、定时回归 + 逐题对比
40
+ - **可观测性**:运行步骤分发追踪、台账明细一键导出 CSV、金额记账闭环(单价标定 + 自动计价 + 花费预警)
41
+ - **基础设施**:CodeBee MCP 服务器、备份远程通道、待裁决进推送、流程版本历史(提示词/参数可回滚)、评审深度按变更量级分级、智谱七天上限 429 分类修正
42
+ - **禅道**:修复记录归档(扫描对账自动归档 + 手动归档/reopen 重认领)
43
+
7
44
  ## v0.1.69(2026-09-25)
8
45
 
9
46
  - **智能体目录新增三款 CLI(第三批接入)**:Google **Gemini CLI**(每日 1000 次免费请求额度,凭据走个人 Google 账号登录)、腾讯云 **CodeBuddy CLI**(`cbc`,claude 兼容系)、字节开源 **Trae Agent**(`trae-cli`,PyPI 无包故走 uv 从 GitHub 直装)。三者均支持目录管理、一键安装/升级与无头编排(走各 CLI 自带登录态);因其凭据环境变量与 CodeBee 的两种 wire 协议不对应,暂不支持「CLI 绑定」链——配置了也会被明确拦截提示,不会出现「链显示活着、实际走 CLI 默认模型」的假绑定。
package/README.md CHANGED
@@ -33,10 +33,11 @@ Trae Agent……),提供
33
33
  ---
34
34
 
35
35
  <!-- relnotes:start -->
36
- ### 最新版更新内容(v0.1.69)
36
+ ### 最新版更新内容(v0.1.70)
37
37
 
38
- - 智能体目录新增三款 CLI:Google **Gemini CLI**、腾讯云 **CodeBuddy CLI**(`cbc`)、字节开源 **Trae Agent**(`trae-cli`)——目录页一键安装/升级与无头编排,凭据走各 CLI 自带登录态
39
- - 防假绑定:这三款暂不支持「CLI 绑定」链(走本机凭据),配置了供应商链会被明确拦截提示,不再出现「链显示活着、实际走 CLI 默认模型」
38
+ - 发布链三连修:七猫建书盲点点击闭环、标签弹层假成功修复、已发章数平台实况校准
39
+ - 连载链加固:签约质量链四件套、超时自动续跑可配置、删除任务审计台账
40
+ - 评测台自定义样题 + 定时回归;台账导出 CSV;金额记账闭环;CodeBee MCP 服务器
40
41
  <!-- relnotes:end -->
41
42
 
42
43
 
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
Binary file
@@ -0,0 +1,30 @@
1
+ # -*- coding: utf-8 -*-
2
+ """轻量审计日志:不可逆操作留痕(当前仅任务删除)。
3
+
4
+ 删除任务连运行记录一起物理清除且无回收站,出争议时无据可查
5
+ (2026-09-28 连载根任务被删案:谁删的、何时删的全无线索)。
6
+ 这里按月追加 jsonl 到 data/audit/,一行一对象、正序追加,
7
+ 与用量台账同款纪律。审计失败绝不拖垮主流程。
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import time
13
+ from pathlib import Path
14
+
15
+ from . import paths
16
+
17
+
18
+ def record(event: str, detail: dict) -> None:
19
+ """追加一条审计记录(event 即台账名,如 task_delete)。"""
20
+ try:
21
+ day = time.strftime("%Y-%m")
22
+ d = Path(paths.DATA_DIR) / "audit"
23
+ d.mkdir(parents=True, exist_ok=True)
24
+ line = json.dumps(
25
+ {"ts": time.strftime("%Y-%m-%d %H:%M:%S"), "event": event, **detail},
26
+ ensure_ascii=False)
27
+ with open(d / ("%s-%s.jsonl" % (event, day)), "a", encoding="utf-8") as f:
28
+ f.write(line + "\n")
29
+ except Exception:
30
+ pass
@@ -3,19 +3,18 @@
3
3
 
4
4
  数据落盘 <data>/automation.json(tmp + os.replace 原子写;data 目录由 paths.py
5
5
  决定,TUTTI_DATA 环境变量感知)。调度是一个 daemon 线程,每 TICK_SECONDS 秒
6
- 扫一遍到期任务:到点即复用与 /api/tasks 完全相同的链路(store.create_task →
7
- store.create_run → jobs.enqueue)拉起一次真实运行,不自己造运行器。单次触发
6
+ 扫一遍到期任务:首次到点按 /api/tasks 的链路创建一个持久任务,后续触发复用
7
+ 该任务并只新增 run 记录,再交给 jobs.enqueue 拉起真实运行。单次触发
8
8
  失败只把 last_status 记为 error 并推进 next_run,调度线程绝不允许因异常退出。
9
9
 
10
10
  任务模型:
11
11
  {id, name, prompt, workdir, kind: daily|interval|weekly|once, time: "HH:MM",
12
12
  interval_hours, weekday(0-6 周一=0), run_at(once 用, ISO), flow(编排流程 id),
13
13
  mode(编排模式), thinking(思考程度), direct_provider_id/direct_model(仅 direct 流程),
14
- enabled, created_at, last_run, next_run, run_count, last_status}
14
+ enabled, created_at, last_run, next_run, run_count, last_status, task_id}
15
15
 
16
16
  运行偏好(mode/thinking/对话模型)与新建任务 Composer 的三颗胶囊同一套取值,
17
- 到点拉起时原样透传给 store.create_task——定时任务不是另一条执行链,用户在这里
18
- 选的编排强度与模型偏好必须与手动建任务完全等价。
17
+ 首次创建时原样透传给 store.create_task;配置身份变化时才建立新的任务上下文。
19
18
 
20
19
  重启语义:错过的 once 不补跑(启动恢复时直接停用);daily/weekly/interval
21
20
  重算 next_run 到下一个未来时刻即可,不追赶停机期间错过的周期。
@@ -43,11 +42,24 @@ from . import jobs, paths, store
43
42
  log = logging.getLogger(__name__)
44
43
 
45
44
  _LOCK = threading.RLock()
45
+ # Scheduler ticks and the manual "run now" action can arrive concurrently.
46
+ # Keep the active-run check and enqueue decision single-flight per process.
47
+ _LAUNCH_LOCK = threading.RLock()
46
48
  _FILE = paths.DATA_DIR / "automation.json"
47
49
  _TASKS = {} # id → task dict(内存真源;落盘为 {"version":1,"tasks":[全部任务]})
48
50
  _LOADED = False
49
51
  _STARTED = False
50
52
 
53
+ # 模块外 tick 钩子(main.py 启动注入,如 evalbench.fire_due 定时回归):
54
+ # automation 不点名任何业务模块——注入方向 main(L3)→钩子,本模块静态图保持干净
55
+ _TICK_HOOKS = []
56
+
57
+
58
+ def register_tick_hook(fn):
59
+ """注册一个每 tick 调用的零参函数(自节流,异常自行吞掉)。"""
60
+ if callable(fn) and fn not in _TICK_HOOKS:
61
+ _TICK_HOOKS.append(fn)
62
+
51
63
  TICK_SECONDS = 25 # 调度扫描间隔:到点触发误差不超过半个周期
52
64
  KINDS = ("daily", "interval", "weekly", "once")
53
65
  INTERVAL_MIN, INTERVAL_MAX = 1, 720 # interval_hours 合法区间(小时)
@@ -65,7 +77,7 @@ _DEFAULTS = {"id": "", "name": "", "prompt": "", "workdir": "", "kind": "", "tim
65
77
  "mode": "auto", "thinking": "standard",
66
78
  "direct_provider_id": "", "direct_model": "",
67
79
  "enabled": False, "created_at": "", "last_run": "", "next_run": "",
68
- "run_count": 0, "last_status": "", "last_run_id": ""}
80
+ "run_count": 0, "last_status": "", "last_run_id": "", "task_id": ""}
69
81
 
70
82
  # 允许通过 update() 修改的字段(id/created_at/run_count 等运行痕迹不可改)
71
83
  _UPDATABLE = ("name", "prompt", "workdir", "kind", "time", "interval_hours",
@@ -277,6 +289,7 @@ def _normalize(d):
277
289
  except (TypeError, ValueError):
278
290
  t["run_count"] = 0
279
291
  t["enabled"] = bool(t["enabled"])
292
+ t["task_id"] = str(t.get("task_id") or "").strip()
280
293
  return t
281
294
 
282
295
 
@@ -333,6 +346,60 @@ def _recover_after_restart():
333
346
 
334
347
  # ---------------------------------------------------------------- 运行拉起
335
348
 
349
+
350
+ class _AutomationBusy(RuntimeError):
351
+ """The persistent task already has a queued or running run."""
352
+
353
+
354
+ def _active_run(task_id):
355
+ """Return an active run for a persistent task, if any."""
356
+ try:
357
+ for run in store.task_runs(task_id):
358
+ if run.get("status") in ("queued", "running"):
359
+ return run
360
+ except Exception:
361
+ # Fail closed: if the run index cannot be read, do not risk launching
362
+ # a duplicate run against the same persistent task.
363
+ log.debug("automation: active-run check failed task=%s", task_id,
364
+ exc_info=True)
365
+ return True
366
+ return None
367
+
368
+
369
+ def _effective_workdir(value):
370
+ """Resolve an automation workdir the same way store.create_task does."""
371
+ requested = str(value or "").strip()
372
+ if not requested:
373
+ from . import settings
374
+ requested = settings.default_workdir()
375
+ return os.path.normcase(os.path.abspath(os.path.expanduser(requested)))
376
+
377
+
378
+ def _persistent_task_compatible(task, payload):
379
+ """Only reuse a backing task while its execution identity is unchanged."""
380
+ if not task or task.get("type") != payload.get("type"):
381
+ return False
382
+ if task.get("goal") != payload.get("goal"):
383
+ return False
384
+ if _effective_workdir(task.get("workdir")) != _effective_workdir(payload.get("workdir")):
385
+ return False
386
+ for key in ("mode", "thinking", "direct_provider_id", "direct_model"):
387
+ if (task.get(key) or "") != (payload.get(key) or ""):
388
+ return False
389
+ return True
390
+
391
+
392
+ def _bind_task_id(automation_id, task_id):
393
+ """Persist the CodeBee task backing one schedule."""
394
+ if not automation_id or not task_id:
395
+ return
396
+ with _LOCK:
397
+ current = _TASKS.get(automation_id)
398
+ if current is None or current.get("task_id") == task_id:
399
+ return
400
+ current["task_id"] = task_id
401
+ _save_locked()
402
+
336
403
  def _launch_run(t):
337
404
  """拉起一次真实编排运行:与 main.py 的 /api/tasks 走同一条链路
338
405
  (store.create_task → store.create_run → jobs 直接启动),返回 run_id。
@@ -353,12 +420,27 @@ def _launch_run(t):
353
420
  task = None
354
421
  run = None
355
422
  try:
356
- task = store.create_task(payload)
357
- run = store.create_run("orchestration", task["title"], task_id=task["id"])
358
- store.update_task_status(task["id"], "queued")
359
- jobs.enqueue({"kind": "orchestration", "run_id": run["id"],
360
- "task_id": task["id"]})
361
- return run["id"]
423
+ with _LAUNCH_LOCK:
424
+ persistent_id = str(t.get("task_id") or "").strip()
425
+ if persistent_id:
426
+ task = store.get_task(persistent_id)
427
+ if task is None or not _persistent_task_compatible(task, payload):
428
+ # The user may have deleted the backing task. Recreate the
429
+ # persistent conversation when its definition changed.
430
+ task = None
431
+ elif _active_run(persistent_id):
432
+ raise _AutomationBusy("上一次定时运行仍在执行,跳过本次重叠触发")
433
+ if task is None:
434
+ task = store.create_task(payload)
435
+ t["task_id"] = task["id"]
436
+ _bind_task_id(t.get("id"), task["id"])
437
+ run = store.create_run("orchestration", task["title"], task_id=task["id"])
438
+ store.update_task_status(task["id"], "queued")
439
+ jobs.enqueue({"kind": "orchestration", "run_id": run["id"],
440
+ "task_id": task["id"]})
441
+ return run["id"]
442
+ except _AutomationBusy:
443
+ raise
362
444
  except Exception:
363
445
  # 启动失败必须收口,不能留下看似仍在启动的任务/run。
364
446
  # Keep the public error generic; the detailed traceback stays in the
@@ -389,6 +471,9 @@ def _fire(snapshot, now):
389
471
  run_id = ""
390
472
  try:
391
473
  run_id = _launch_run(snapshot) or ""
474
+ except _AutomationBusy as e:
475
+ log.info("automation: 跳过重叠触发 %s: %s", snapshot.get("id"), e)
476
+ status = "busy"
392
477
  except Exception as e:
393
478
  log.warning("automation: 定时触发 %s 失败: %r", snapshot.get("id"), e)
394
479
  status = "error"
@@ -515,6 +600,13 @@ def _tick():
515
600
  (",%d 项失败" % len(r.get("errors") or [])) if r.get("errors") else "")
516
601
  except Exception:
517
602
  log.debug("automation: 每日清理跳过", exc_info=True)
603
+ # 模块外 tick 钩子(如评测台定时回归):钩子自节流、自行吞错
604
+ for _hook in list(_TICK_HOOKS):
605
+ try:
606
+ _hook()
607
+ except Exception:
608
+ log.debug("automation: tick 钩子跳过", exc_info=True)
609
+
518
610
 
519
611
 
520
612
  def _loop():
@@ -637,6 +729,9 @@ def run_now(tid):
637
729
  with _LOCK:
638
730
  snapshot = dict(_TASKS[tid])
639
731
  run_id = _launch_run(snapshot) or ""
732
+ except _AutomationBusy as e:
733
+ log.info("automation: 手动触发 %s 跳过重叠运行: %s", tid, e)
734
+ status = "busy"
640
735
  except Exception as e:
641
736
  log.warning("automation: 手动触发 %s 失败: %r", tid, e)
642
737
  status = "error"
@@ -0,0 +1,95 @@
1
+ # -*- coding: utf-8 -*-
2
+ """评测基准台存储层(L0):data/eval_bench.json 的读写与「实测分软信号」。
3
+
4
+ evalbench(L2,跑评测)与 dispatch(L0,模型链评分)都要摸这份数据——
5
+ 读写下沉到这里,两边同层合法引用(dispatch 不能 import L2 的 evalbench,
6
+ 分层政策 L_i 只准 import L_j (j<=i))。
7
+
8
+ 实测分软信号口径(写入 docs/execution-standard.md 候选打分权重表):
9
+ bonus = clamp((overall − 7.0) × 1.5, −4.5, +4.5)
10
+ 7 分以下开始倒扣、9 分封顶 +4.5;无数据/超新鲜度记 0——缺数据不猜分。
11
+ 新鲜度 14 天:评测会过期(evaluation.py 同理念),过期实测不参与排序。
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import json
16
+ import threading
17
+ import time
18
+ from pathlib import Path
19
+
20
+ from . import paths
21
+
22
+ _LOCK = threading.RLock()
23
+ FRESH_DAYS = 14 # 实测分新鲜度(天);0 = 永不过期(测试用)
24
+ _BASELINE = 7.0 # 及格线:低于它开始倒扣
25
+ _K = 1.5 # 斜率:每 1 分 ±1.5
26
+ _MAX_BONUS = 4.5 # 封顶(与在线信号 ±6 同量级,翻不动硬约束)
27
+
28
+
29
+ def _path():
30
+ return Path(paths.DATA_DIR) / "eval_bench.json"
31
+
32
+
33
+ def read_doc():
34
+ with _LOCK:
35
+ try:
36
+ data = json.loads(_path().read_text(encoding="utf-8"))
37
+ return data if isinstance(data, dict) else {}
38
+ except Exception:
39
+ return {}
40
+
41
+
42
+ def write_doc(data):
43
+ with _LOCK:
44
+ _path().parent.mkdir(parents=True, exist_ok=True)
45
+ tmp = _path().with_suffix(".tmp")
46
+ tmp.write_text(json.dumps(data, ensure_ascii=False, indent=2), encoding="utf-8")
47
+ tmp.replace(_path())
48
+
49
+
50
+ def read_results():
51
+ results = read_doc().get("results")
52
+ return [r for r in results if isinstance(r, dict)] if isinstance(results, list) else []
53
+
54
+
55
+ def _fresh(row, max_age_days):
56
+ """行是否在新鲜度窗口内。ts 缺失/解析失败按过期处理(宁缺毋滥)。"""
57
+ if max_age_days <= 0:
58
+ return True
59
+ try:
60
+ ts = time.mktime(time.strptime(str(row.get("ts") or ""),
61
+ "%Y-%m-%d %H:%M:%S"))
62
+ except ValueError:
63
+ return False
64
+ return (time.time() - ts) <= max_age_days * 86400.0
65
+
66
+
67
+ def bonus_map(max_age_days=FRESH_DAYS):
68
+ """{(provider_id, model): {"overall": x, "ts": s}}——每键取最新一条
69
+ 已得分记录,且必须在新鲜度窗口内。"""
70
+ latest = {}
71
+ for r in read_results():
72
+ if not r.get("scored") or r.get("overall") is None:
73
+ continue
74
+ key = (str(r.get("provider_id") or ""), str(r.get("model") or ""))
75
+ if not key[1]:
76
+ continue
77
+ prev = latest.get(key)
78
+ if prev is None or str(r.get("ts") or "") > str(prev.get("ts") or ""):
79
+ latest[key] = r
80
+ return {k: {"overall": float(v["overall"]), "ts": str(v.get("ts") or "")}
81
+ for k, v in latest.items() if _fresh(v, max_age_days)}
82
+
83
+
84
+ def bonus_for(provider_id, model, max_age_days=FRESH_DAYS):
85
+ """单条目的实测软信号。返回 (score, reason);无数据/过期 → (0.0, "")。"""
86
+ try:
87
+ info = bonus_map(max_age_days).get((str(provider_id or ""), str(model or "")))
88
+ if not info:
89
+ return 0.0, ""
90
+ score = max(-_MAX_BONUS, min(_MAX_BONUS, (info["overall"] - _BASELINE) * _K))
91
+ score = round(score, 2)
92
+ return score, "实测 %.1f 分(%+.1f,%s)" % (
93
+ info["overall"], score, (info.get("ts") or "")[:10])
94
+ except Exception:
95
+ return 0.0, ""
@@ -264,10 +264,15 @@ def _collect_material(task):
264
264
  parts.append("## 已有大纲\n" + "\n".join(lines))
265
265
  head = ""
266
266
  try:
267
- p = Path(task.get("workdir") or "") / "chapter-001.md"
268
- if p.is_file():
269
- from . import runner
270
- head = runner.read_text_any_enc(p)[:_FIRST_CH_HEAD]
267
+ from . import runner
268
+ # 章节文件命名并存:连载流水线落 chapter-01.md(两位),有的书是
269
+ # chapter-001.md(三位)——只认一种会瞎掉「第一章开头」素材
270
+ # (2026-09-28 马甲书案:明明 8 章成稿在手却报「没有正文」)。
271
+ for name in ("chapter-001.md", "chapter-01.md", "chapter-1.md"):
272
+ p = Path(task.get("workdir") or "") / name
273
+ if p.is_file():
274
+ head = runner.read_text_any_enc(p)[:_FIRST_CH_HEAD]
275
+ break
271
276
  except OSError:
272
277
  pass
273
278
  if head: