Compare commits

...

42 Commits

Author SHA1 Message Date
cfdaily f5bf671410 [moz] chore(daemon): S6 deprecated 代码标记 + ticker 经验蒸馏空转修复
CI / lint (pull_request) Successful in 7s
CI / test (pull_request) Successful in 29s
CI / frontend (pull_request) Successful in 12s
CI / notify-on-failure (pull_request) Successful in 0s
B4: ticker.py ExperienceDistiller 调用改为 debug 日志空转(§19 双层 daily cron 替代)
B5: skill_system.py / experience.py 文件头部标记 DEPRECATED

保留代码向后兼容,P3 时再物理删除。
2026-06-18 23:04:44 +08:00
pangtong-fujunshi ccb5d5d3ea Merge PR #87: [moz] docs(§19): S3-S5 cron 配置方案 + 一致性偏差修复清单
Deploy / ci (push) Failing after 7s
Deploy / deploy (push) Has been skipped
Deploy / notify-deploy-failure (push) Successful in 1s
Deploy / notify-deploy-success (push) Successful in 0s
2026-06-18 14:57:44 +00:00
cfdaily ee825db818 [moz] docs(§19): 补充 S3-S5 cron 配置方案 + 一致性偏差修复清单
CI / lint (pull_request) Successful in 7s
CI / test (pull_request) Successful in 38s
CI / frontend (pull_request) Successful in 12s
CI / notify-on-failure (pull_request) Successful in 0s
§11A: Cron 配置方案
- L1 各 agent 用各自 agentId 执行(D4: agent 是自己经验的最佳蒸馏者)
- 6 个 L1 cron(03:00-04:15 错开)+ 1 个 L2 cron(05:00 庞统)+ 1 个 IMPROVE cron(每周日 06:00)
- 每个 cron 的 payload/sessionTarget/delivery/timeout 规范

§11B: 一致性偏差修复清单
- B4: ticker.py ExperienceDistiller 调用未移除
- B5: skill_system.py / experience.py 未标记 deprecated
- B6: SELF_IMPROVEMENT_REMINDER.md 引用残留(实际已无影响)
2026-06-18 22:53:32 +08:00
pangtong-fujunshi cdf984aa0c Merge PR #86: [moz] fix(spawner): _get_task_info SELECT 补 must_haves 字段
Deploy / ci (push) Failing after 7s
Deploy / deploy (push) Has been skipped
Deploy / notify-deploy-failure (push) Successful in 1s
Deploy / notify-deploy-success (push) Successful in 0s
2026-06-18 14:43:27 +00:00
cfdaily 6798f098b5 [moz] fix(spawner): _get_task_info SELECT 补 must_haves 字段
CI / lint (pull_request) Successful in 8s
CI / test (pull_request) Successful in 45s
CI / frontend (pull_request) Successful in 12s
CI / notify-on-failure (pull_request) Successful in 0s
根因:_get_task_info 只 SELECT id, title, status,漏查 must_haves。
续杯/retry 路径从返回 dict 中 .get('must_haves') 永远拿到空值,
导致 PromptContext.event_type 为空,toolchain 通知显示「事件类型: 未知」。

修复:SELECT 补 must_haves 字段。

发现方式:L1 自蒸馏端到端验证中识别到 Recurrence-Count=2 信号
2026-06-18 22:39:30 +08:00
pangtong-fujunshi 33e38254c1 Merge PR #85: §19 Skill 生命周期管理 v2.0 + skill-management Skill
Deploy / ci (push) Failing after 7s
Deploy / deploy (push) Has been skipped
Deploy / notify-deploy-failure (push) Successful in 1s
Deploy / notify-deploy-success (push) Successful in 0s
2026-06-18 14:17:21 +00:00
cfdaily 166172e0b8 [moz] impl(skill-mgmt): S1+S2 实现 — skill-management Skill + 设计文档修复
CI / lint (pull_request) Successful in 7s
CI / test (pull_request) Successful in 53s
CI / frontend (pull_request) Successful in 12s
CI / notify-on-failure (pull_request) Successful in 0s
S1: AGENTS.md 经验闭环规则(workspace 层,单独管理)
S2: skill-management Skill 完整实现
  - SKILL.md(主:综述 + 四阶段速查 + 验证标准 + 自我修补规则)
  - references/discover-l1.md(各 agent 03:00 自蒸馏操作指南)
  - references/discover-l2.md(庞统 05:00 整合审查操作指南)
  - references/distill.md(蒸馏规范 + 验证标准 + 矛盾处理)
  - references/apply.md(openclaw 原生机制 + per-agent 可见性)
  - references/improve.md(引用追踪 + 淘汰 + 提升)
  - assets/templates/skill-template.md(SKILL.md 标准模板)
  - assets/templates/signal-format.md(信号输出格式模板)
  - assets/checklists/quality-check.md(质量检查清单)

文档修复:cron 错开时间 5min → 15min
2026-06-18 22:13:01 +08:00
cfdaily 0bd1caff90 [moz] fix(docs): §19 审查修复 M1+S1-S4+G1-G2
CI / lint (pull_request) Successful in 7s
CI / test (pull_request) Successful in 29s
CI / frontend (pull_request) Successful in 11s
CI / notify-on-failure (pull_request) Successful in 0s
M1: MERGE 流程注明 skill_workshop 只能写 workspace,公共目录用 cp/symlink
S1: 数据源计数统一(12 个含 L1 drafts)
S2: 各 agent L1 cron 错开 5 分钟避免资源争用
S3: MERGE 后通知各 agent quarantine workspace 同名 draft
S4: S6 清理计划补充 SELF_IMPROVEMENT_REMINDER.md
G1: Recurrence-Count 加 30 天时间窗口
G2: proposal 存储路径注明首次自动创建
2026-06-18 21:52:34 +08:00
cfdaily f615326514 [moz] docs: §19 Skill 生命周期管理 v2.0 — 双层 daily 蒸馏 + 融合 self-improvement + 部署目录结构
CI / lint (pull_request) Successful in 8s
CI / test (pull_request) Successful in 33s
CI / frontend (pull_request) Successful in 12s
CI / notify-on-failure (pull_request) Successful in 1s
主要变更:
- D3/D4 改为双层 daily(L1 各 agent 03:00 + L2 庞统 05:00)
- D6 废弃 .learnings/,JSONL 是唯一数据源
- D7 只创建一个 skill-management,四阶段放 references/
- DISCOVER 重写为双层结构
- 验证机制替换为 Recurrence-Count + Skill Extraction Criteria
- 新增 §7.4 per-agent Skill 存放位置与可见性
- 新增 §13 部署目录结构(完整流转路径)
- self-improvement skill 优势融合到 DISCOVER 输出格式
- 参考映射新增 7 条
2026-06-18 21:45:58 +08:00
pangtong-fujunshi c2c6c9a7f6 Merge PR #84: [moz] docs: §19 Skill 生命周期管理 + 经验闭环四阶段设计
Deploy / ci (push) Failing after 7s
Deploy / deploy (push) Has been skipped
Deploy / notify-deploy-failure (push) Successful in 1s
Deploy / notify-deploy-success (push) Successful in 0s
2026-06-18 05:49:11 +00:00
cfdaily f26de6cfda [moz] docs: §19 Skill 生命周期管理 + 经验闭环四阶段设计
CI / lint (pull_request) Successful in 8s
CI / test (pull_request) Successful in 29s
CI / frontend (pull_request) Successful in 12s
CI / notify-on-failure (pull_request) Successful in 0s
2026-06-18 13:34:14 +08:00
pangtong-fujunshi 1ff4d98a03 Merge PR #83: [moz] fix(spawner): PromptContext event_type/event_data 缺失修复
Deploy / ci (push) Failing after 7s
Deploy / deploy (push) Has been skipped
Deploy / notify-deploy-failure (push) Successful in 0s
Deploy / notify-deploy-success (push) Successful in 0s
2026-06-16 23:21:02 +00:00
cfdaily a953fc0bc7 [moz] fix(spawner): PromptContext 缺少 event_type/event_data 导致通知显示「事件类型: 未知」
CI / lint (pull_request) Successful in 7s
CI / test (pull_request) Successful in 29s
CI / frontend (pull_request) Successful in 12s
CI / notify-on-failure (pull_request) Successful in 0s
根因:spawner 构建 PromptContext 时只传了 action_type/action_steps,
遗漏了 event_type 和 event_data。ToolchainContextSection.render() 从
context.event_type 取值,为空时回退到 '未知'。

修复:从 must_haves JSON 同时提取 event_type 和 context(→event_data)。
2026-06-17 07:19:04 +08:00
pangtong-fujunshi 7f17ee69d7 Merge PR #82: [moz] docs: §18 Mail Handler Verify/Prompt 强化设计
Deploy / ci (push) Failing after 7s
Deploy / deploy (push) Has been skipped
Deploy / notify-deploy-failure (push) Successful in 1s
Deploy / notify-deploy-success (push) Successful in 0s
2026-06-16 23:16:08 +00:00
cfdaily f1e513cba2 [moz] docs: §18 Mail Handler Verify/Prompt 强化设计
CI / lint (pull_request) Successful in 7s
CI / test (pull_request) Successful in 28s
CI / frontend (pull_request) Successful in 11s
CI / notify-on-failure (pull_request) Successful in 0s
2026-06-16 23:14:23 +00:00
pangtong-fujunshi 627982db09 Merge PR #81: [moz] feat: Runaway Guard per-task dispatch 上限
Deploy / ci (push) Failing after 7s
Deploy / deploy (push) Has been skipped
Deploy / notify-deploy-failure (push) Successful in 1s
Deploy / notify-deploy-success (push) Successful in 0s
2026-06-16 23:10:42 +00:00
cfdaily 9ec601d747 [moz] feat: Runaway Guard per-task dispatch 上限
CI / lint (pull_request) Successful in 8s
CI / test (pull_request) Successful in 29s
CI / frontend (pull_request) Successful in 12s
CI / notify-on-failure (pull_request) Successful in 1s
§15 Runaway Guard — per-task dispatch_count 上限,防止无限循环 dispatch

问题:mail/toolchain task 走 handler auto-working(跳过 claim),不受
claim_timeout 3 次重试兜底保护。如果反复 spawn 但永远到不了 done/failed,
会无限循环消耗资源(实际案例:2026-06-15 mention 重复投递事件)。

设计:
- tasks 表新增 dispatch_count 字段
- 每次 ticker 成功 dispatch 时递增
- dispatch_count >= 10 时自动标 failed(reason=runaway_guard)
- 覆盖所有非终态(pending/working/claimed)
- 参考 Hermes v0.13 §3 Per-Task 重试上限

改动文件:
- src/blackboard/db.py: _safe_add_column dispatch_count
- src/blackboard/models.py: Task dataclass 加 dispatch_count
- src/daemon/ticker.py: dispatch 递增 + _check_timeouts runaway guard
- docs/design/15-runaway-guard.md: 设计文档
- tests/integration/test_ticker_integration.py: E13 测试 3 个

测试:456 passed, 3 skipped
2026-06-16 23:10:27 +00:00
pangtong-fujunshi cc5c7f5ad1 Merge PR #80
Deploy / ci (push) Failing after 8s
Deploy / deploy (push) Has been skipped
Deploy / notify-deploy-failure (push) Successful in 0s
Deploy / notify-deploy-success (push) Successful in 0s
2026-06-16 14:49:17 +00:00
cfdaily d6cb854f68 fix: mention 重复投递 + mail 失败通知竞态保护 + §14 设计文档同步
CI / lint (pull_request) Successful in 7s
CI / test (pull_request) Successful in 31s
CI / frontend (pull_request) Successful in 12s
CI / notify-on-failure (pull_request) Successful in 0s
Bug 1: spawn_full_agent use_main_session 返回 None 导致 mention 重复投递
- 根因: use_main_session=True 时 session_id=None, return None 被 ticker
  _process_posts 误判为 spawn 失败, 每次 tick 都重试
- 修复: 引入 effective_sid = session_id or 'main', 统一用于
  _register_session / _monitor_process / return value

Bug 2: _mark_task failed 时未检查已完成状态导致误发投递失败通知
- 根因: spawner 标 failed 和 handler 标 done 竞态条件下, 已完成的
  mail task 被误发投递失败通知
- 修复: notify_mail_failed 调用前加防御性检查, 若 task 已 done 则跳过

设计文档: §13 三个 handler sections 列表同步 DeliveryChecklistSection
  及 GiteaConventionSection / WikiGuideSection, 更新 section 复用分析表
  及文件结构 section 计数
2026-06-15 09:48:09 +08:00
pangtong-fujunshi 1f373d5cb5 Merge PR #79
Deploy / ci (push) Failing after 8s
Deploy / deploy (push) Has been skipped
Deploy / notify-deploy-failure (push) Successful in 1s
Deploy / notify-deploy-success (push) Successful in 0s
2026-06-15 00:06:56 +00:00
cfdaily a8c9d25857 [moz] feat(prompt): L0~L2 prompt improvements
CI / lint (pull_request) Successful in 8s
CI / test (pull_request) Successful in 29s
CI / frontend (pull_request) Successful in 13s
CI / notify-on-failure (pull_request) Successful in 0s
- L0 wiki-rule: 扩充检索路径(practices/concepts/docs/design/)+ 检索方式(index→summary→grep→full)
- L1 SOUL.md: 同步测试 + PR 审查(代码改动检查设计文档+测试脚本,PR/CI/CD 三重把关)
- L1 AGENTS.md: 新增测试规范段(生产隔离/残留清理/测试开发分离)
- L2 prompt_composer: 新增 DeliveryChecklistSection(executor/mail/toolchain handler 注册)
- 456 passed, 0 failed
2026-06-15 08:04:42 +08:00
pangtong-fujunshi 660ac4b659 Merge PR #78: [moz] feat(frontend): 工具链面板加 from/to 显示 + 筛选 + 修复事件类型未知
Deploy / ci (push) Successful in 9s
Deploy / deploy (push) Successful in 12s
Deploy / notify-deploy-failure (push) Successful in 0s
Deploy / notify-deploy-success (push) Successful in 1s
2026-06-14 09:13:55 +00:00
cfdaily 91685ebfdd [moz] feat(frontend): 工具链面板加 from/to 显示 + 筛选 + 修复事件类型未知
CI / lint (pull_request) Successful in 7s
CI / test (pull_request) Successful in 29s
CI / frontend (pull_request) Successful in 11s
CI / notify-on-failure (pull_request) Successful in 0s
- 前端:列表项加 from → to 标签(Agent 中文名)
- 前端:加「全部 / 未处理」筛选按钮
- 前端:详情区也显示 from → to
- 后端:ToolchainContextSection 修复事件类型 fallback 为中文标签
- 后端:加来源/指派信息到 prompt 消息体
2026-06-14 17:12:11 +08:00
pangtong-fujunshi 65910f5417 Merge PR #77: [moz] fix(api): list_tasks 默认排序改为 DESC
Deploy / ci (push) Successful in 10s
Deploy / deploy (push) Successful in 13s
Deploy / notify-deploy-failure (push) Successful in 0s
Deploy / notify-deploy-success (push) Successful in 1s
2026-06-14 08:55:14 +00:00
cfdaily 17b87290c8 [moz] fix(api): list_tasks 默认排序改为 created_at DESC(最新在前)
CI / lint (pull_request) Successful in 8s
CI / test (pull_request) Successful in 29s
CI / frontend (pull_request) Successful in 10s
CI / notify-on-failure (pull_request) Successful in 0s
2026-06-14 16:53:35 +08:00
pangtong-fujunshi bd5735f970 Merge PR #76: [moz] refactor(frontend): 工具链 Tab 移入系统设置子页签
Deploy / ci (push) Successful in 9s
Deploy / deploy (push) Successful in 13s
Deploy / notify-deploy-failure (push) Successful in 1s
Deploy / notify-deploy-success (push) Successful in 1s
2026-06-14 08:37:16 +00:00
cfdaily 05f9112fab [moz] refactor(frontend): 工具链 Tab 移入系统设置子页签
CI / lint (pull_request) Successful in 8s
CI / test (pull_request) Successful in 28s
CI / frontend (pull_request) Successful in 11s
CI / notify-on-failure (pull_request) Successful in 0s
2026-06-14 16:36:34 +08:00
jiangwei-infra b926b35703 Merge PR #75: [moz] fix(ci): 修复 deploy push trigger 不触发问题
Deploy / ci (push) Successful in 9s
Deploy / deploy (push) Successful in 13s
Deploy / notify-deploy-failure (push) Successful in 0s
Deploy / notify-deploy-success (push) Successful in 0s
2026-06-14 08:31:35 +00:00
jiangwei-infra 8df1d4a83c Merge branch 'main' into fix/cd-push-trigger-yaml
CI / lint (pull_request) Successful in 7s
CI / test (pull_request) Successful in 27s
CI / frontend (pull_request) Successful in 11s
CI / notify-on-failure (pull_request) Successful in 0s
2026-06-14 08:30:20 +00:00
cfdaily aad5a6b317 [moz] fix(ci): 修复 deploy push trigger 不触发问题
CI / lint (pull_request) Successful in 7s
CI / test (pull_request) Successful in 28s
CI / notify-on-failure (pull_request) Successful in 0s
根因:deploy.yml notify-deploy-success job 中 python3 -c 使用多行字符串,
Python 代码零缩进(column 0)破坏了 YAML literal block scalar (run: |),
导致 Gitea YAML 解析器报错 'line 114: could not find expected :',
在 DetectWorkflows 阶段被静默丢弃,push 事件无法触发 deploy。

Gitea 日志证据:
  ignore invalid workflow "deploy.yml": yaml: line 114: could not find expected ':'

修复:将多行 python3 -c 改为单行,避免零缩进代码行破坏 YAML 块结构。

影响范围:仅 deploy.yml,不影响 ci.yml 和 e2e.yml
验证方式:YAML 解析已通过,合并后观察 push 事件是否触发 Actions
2026-06-14 16:28:41 +08:00
pangtong-fujunshi ad34750075 Merge PR #74: [moz] ci: CI 管道新增 frontend build job 2026-06-14 08:14:15 +00:00
cfdaily cd7e24cd3c [moz] ci: CI 管道新增 frontend build job(tsc + vite build)
CI / lint (pull_request) Successful in 7s
CI / test (pull_request) Successful in 28s
CI / frontend (pull_request) Successful in 40s
CI / notify-on-failure (pull_request) Successful in 0s
2026-06-14 16:12:05 +08:00
pangtong-fujunshi 0521b7b6f0 Merge PR #73: [moz] feat(frontend): 工具链 Tab 2026-06-14 07:24:24 +00:00
cfdaily fc30f91183 [moz] feat(frontend): 新增工具链 Tab — 列表+详情+搜索栏
CI / lint (pull_request) Successful in 7s
CI / test (pull_request) Successful in 28s
CI / notify-on-failure (pull_request) Successful in 0s
2026-06-14 15:22:34 +08:00
pangtong-fujunshi 8c72ff0565 Merge PR #72: [moz] refactor(api): API 拆分 + expand 聚合 + 搜索 2026-06-14 06:55:08 +00:00
cfdaily cc2e5aa64c [moz] fix(api): Review M1 修复 — expand=all 保持旧格式 + _toolchain 加入 _VIRTUAL_PROJECTS
CI / lint (pull_request) Successful in 7s
CI / test (pull_request) Successful in 30s
CI / notify-on-failure (pull_request) Successful in 0s
- M1: expand=all 保持旧 list 格式(向后兼容 TaskModal .map()/.length)
- 细粒度 expand=comments,events 用新 {items,total_count,limit} 格式
- S1(PR#73): _toolchain 加入 _VIRTUAL_PROJECTS
- S1(PR#72): 移除 _validate_project 未使用 import
2026-06-14 14:22:14 +08:00
cfdaily d09fd4a173 [moz] fix(api): flake8 lint 修复 — 移除未使用 import
CI / lint (pull_request) Successful in 8s
CI / test (pull_request) Successful in 28s
CI / notify-on-failure (pull_request) Successful in 1s
2026-06-14 14:20:33 +08:00
cfdaily 5db4c89fe7 [moz] refactor(api): 拆分 blackboard_routes → task_routes + task_relation_routes + shared + expand 细粒度聚合
CI / lint (pull_request) Failing after 9s
CI / test (pull_request) Has been skipped
CI / notify-on-failure (pull_request) Successful in 2s
2026-06-14 14:02:59 +08:00
pangtong-fujunshi e70816a69f Merge PR #71: [moz] docs: §18 API 聚合重构 + 工具链 Tab 设计 2026-06-14 05:57:21 +00:00
cfdaily 33521b8b39 [moz] docs: §18 职责分离 — 测试详细代码移入 18-test-design.md
CI / lint (pull_request) Successful in 7s
CI / test (pull_request) Successful in 27s
CI / notify-on-failure (pull_request) Successful in 0s
- 主文档 §6 只保留概要表格 + 文件指向
- 测试 fixture/完整代码/覆盖矩阵 → 18-test-design.md
- 删除误加的 GATE/委派/wiki 章节
- CI 集成改为表格格式 + 引用
2026-06-14 13:56:47 +08:00
cfdaily f55a037c98 [moz] docs: §18 API 聚合重构 + 工具链 Tab 设计文档
CI / lint (pull_request) Successful in 7s
CI / test (pull_request) Successful in 28s
CI / notify-on-failure (pull_request) Successful in 1s
- 18-api-refactor-and-toolchain-tab.md: 主设计(9章+实施约束)
  - 后端拆分方案 B(task_routes + task_relation_routes + shared)
  - expand 细粒度聚合(comments/events 带 limit+total_count)
  - 任务列表搜索参数 q
  - 工具链 Tab 设计(仿 MailPanel + 搜索栏)
  - GATE 门控 + 委派原则 + wiki 查询规则
  - 司马懿已审(mail-1781415763066)
- 18-test-design.md: 测试用例详细设计(34 个用例 + CI 集成)
- tests/scripts/verify_api_compat.sh: 路由兼容性验证脚本
2026-06-14 13:53:56 +08:00
pangtong-fujunshi 923971ad92 Merge PR #70: [moz] docs: 重写 §26 Gitea 协作规范设计 2026-06-14 04:09:22 +00:00
35 changed files with 3707 additions and 351 deletions
+23 -3
View File
@@ -62,12 +62,30 @@ jobs:
(echo '=== RETRY WITH VERBOSE ===' && \
PYTHONPATH=$(pwd) /tmp/ci-venv-test/bin/pytest tests/ -m "not e2e" -x -v 2>&1 | tail -30)
# ── Job 3: CI 失败通知 ───────────────────────────────
# ── Job 3: Frontend Build ───────────────────────────
frontend:
runs-on: macos-arm64
needs: lint
steps:
- uses: actions/checkout@v4
- name: Setup Node
uses: actions/setup-node@v4
with:
node-version: 20
- name: Install & Build
run: |
cd src/frontend
npm ci || npm install
npm run build
# ── Job 4: CI 失败通知 ───────────────────────────────
# 使用 needs.<job>.result 直接判断,不查询 commit status API
# 根因:notify 自身的 pending status 会污染 commit status 查询结果(竞态条件)
notify-on-failure:
runs-on: macos-arm64
needs: [lint, test]
needs: [lint, test, frontend]
if: always()
steps:
- name: Check results and notify
@@ -75,12 +93,13 @@ jobs:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
LINT_RESULT: ${{ needs.lint.result }}
TEST_RESULT: ${{ needs.test.result }}
FRONTEND_RESULT: ${{ needs.frontend.result }}
run: |
echo "Lint result: $LINT_RESULT"
echo "Test result: $TEST_RESULT"
# 只有 lint 或 test 明确失败时才发通知
if [ "$LINT_RESULT" = "failure" ] || [ "$TEST_RESULT" = "failure" ]; then
if [ "$LINT_RESULT" = "failure" ] || [ "$TEST_RESULT" = "failure" ] || [ "$FRONTEND_RESULT" = "failure" ]; then
echo "CI has failures, sending notification..."
# 如果是 PR 事件,写评论通知
@@ -90,6 +109,7 @@ jobs:
FAILED_JOBS=""
[ "$LINT_RESULT" = "failure" ] && FAILED_JOBS="${FAILED_JOBS}lint "
[ "$TEST_RESULT" = "failure" ] && FAILED_JOBS="${FAILED_JOBS}test "
[ "$FRONTEND_RESULT" = "failure" ] && FAILED_JOBS="${FAILED_JOBS}frontend "
curl -sf -X POST \
-H "Authorization: token $GITEA_TOKEN" \
+1 -9
View File
@@ -110,15 +110,7 @@ jobs:
PR_AUTHOR=$(curl --max-time 5 -sf \
-H "Authorization: token $GITEA_TOKEN" \
"$API_URL/repos/$REPO/pulls?state=closed&sort=updated&order=desc&limit=10" | \
python3 -c "
import json, sys
sha = '$COMMIT_SHA'
for pr in json.load(sys.stdin):
merge_sha = pr.get('merge_commit_sha', '') or ''
if merge_sha.startswith(sha) or sha.startswith(merge_sha):
print(pr['user']['login'])
break
" 2>/dev/null || echo "")
python3 -c "import json,sys; sha='$COMMIT_SHA'; matches=[pr['user']['login'] for pr in json.load(sys.stdin) if (pr.get('merge_commit_sha','') or '').startswith(sha) or sha.startswith(pr.get('merge_commit_sha','') or '')]; print(matches[0] if matches else '')" 2>/dev/null || echo "")
# 确定通知对象
if [ -n "$PR_AUTHOR" ]; then
+251 -5
View File
@@ -1,10 +1,11 @@
---
title: "TaskTypeRegistry + Handler 架构重构"
created: 2026-06-10
version: v3.0
version: v3.1
---
> 状态: ✅ 已完成(Step 1-5 全部合并,394 passed
> v3.1 新增 §18Mail Handler Verify/Prompt 强化(2026-06-16,进行中)
# §1 现状分析(v3.0 更新说明:§1-§13 保留原样,新增 §14-§18,更新 §3/§5/§7
@@ -585,6 +586,18 @@ class PromptComposer:
| 50-59 | 硬约束 | 安全红线、禁止行为 |
| 60-69 | 扩展段 | 保留给未来使用 |
## 共性 Section(三 handler 共享)
以下三个 Section 在 `prompt_composer.py` 中统一定义,被 Task/Mail/Toolchain 三个 handler 共同注入:
| Section | priority | 用途 |
|---------|----------|------|
| `GiteaConventionSection` | 55 | Gitea Issue/PR 标题规范、分支命名、提交格式 |
| `DeliveryChecklistSection` | 55 | 交付前检查清单(产出格式、验证项、必读文档) |
| `WikiGuideSection` | 60 | Wiki 知识库检索指引(检索路径、优先级、知识缺口记录) |
设计意图:将跨 handler 的共性约束从各 handler 的 ConstraintsSection 中抽离,避免重复维护。
---
# §13 三个 Handler 的 Section 注册
@@ -601,6 +614,9 @@ def get_sections(self) -> list[PromptSection]:
RoleSkillSection(priority=30), # BootstrapBuilder 段 3Skill 全文)
TaskApiSection(priority=40), # API 操作指令,success_status="review"
TaskConstraintsSection(priority=50), # 硬约束
GiteaConventionSection(priority=55), # Gitea 协作规范(共性)
WikiGuideSection(priority=60), # Wiki 知识库检索指引(共性)
DeliveryChecklistSection(priority=55), # 交付检查清单(共性)
]
```
@@ -611,6 +627,9 @@ def get_sections(self) -> list[PromptSection]:
| RoleSkillSection | BootstrapBuilder 段 3 | 个性:只有 task 读 Skill 全文 |
| TaskApiSection | spawner `_build_api_section` | **共性基础 + 个性参数**success_status |
| TaskConstraintsSection | BootstrapBuilder 段 4 | 个性:每种 task 约束不同 |
| GiteaConventionSection | prompt_composer.py | **共性**Gitea Issue/PR 规范 |
| WikiGuideSection | prompt_composer.py | **共性**Wiki 检索指引 |
| DeliveryChecklistSection | prompt_composer.py | **共性**:交付前检查清单 |
## MailHandler sections
@@ -620,6 +639,9 @@ def get_sections(self) -> list[PromptSection]:
MailContextSection(priority=10), # from/to/title/text,区分 inform/request
MailApiSection(priority=40), # API 操作指令,success_status="done"
MailConstraintsSection(priority=50), # 硬约束(禁止状态转换命令等)
GiteaConventionSection(priority=55), # Gitea 协作规范(共性)
WikiGuideSection(priority=60), # Wiki 知识库检索指引(共性)
DeliveryChecklistSection(priority=55), # 交付检查清单(共性)
]
```
@@ -628,6 +650,9 @@ def get_sections(self) -> list[PromptSection]:
| MailContextSection | MAIL_INFORM_TEMPLATE / MAIL_REQUEST_TEMPLATE | 个性:邮件格式 |
| MailApiSection | spawner `_build_api_section` 变体 | **共性基础 + 个性参数**success_status="done",含 Mail API 指令) |
| MailConstraintsSection | 模板中的 ⚠️ 约束 | 个性 |
| GiteaConventionSection | prompt_composer.py | **共性**Gitea Issue/PR 规范 |
| WikiGuideSection | prompt_composer.py | **共性**Wiki 检索指引 |
| DeliveryChecklistSection | prompt_composer.py | **共性**:交付前检查清单 |
## ToolchainHandler sections
@@ -637,6 +662,9 @@ def get_sections(self) -> list[PromptSection]:
ToolchainContextSection(priority=10), # 事件类型 + 事件详情
ToolchainApiSection(priority=40), # API 操作指令,success_status="done"
ToolchainConstraintsSection(priority=50), # 硬约束
GiteaConventionSection(priority=55), # Gitea 协作规范(共性)
WikiGuideSection(priority=60), # Wiki 知识库检索指引(共性)
DeliveryChecklistSection(priority=55), # 交付检查清单(共性)
]
```
@@ -645,6 +673,9 @@ def get_sections(self) -> list[PromptSection]:
| ToolchainContextSection | toolchain_templates.py + md 文件 | 个性:事件格式 |
| ToolchainApiSection | spawner `_build_api_section` 变体 | **共性基础 + 个性参数** |
| ToolchainConstraintsSection | 新增 | 个性 |
| GiteaConventionSection | prompt_composer.py | **共性**Gitea Issue/PR 规范 |
| WikiGuideSection | prompt_composer.py | **共性**Wiki 检索指引 |
| DeliveryChecklistSection | prompt_composer.py | **共性**:交付前检查清单 |
## Section 复用分析
@@ -655,6 +686,9 @@ def get_sections(self) -> list[PromptSection]:
| *ConstraintsSection | ✅ | ✅ | ✅ | ❌ 约束内容不同,各自实现 |
| PriorOutputsSection | ✅ | ❌ | ❌ | 仅 task |
| RoleSkillSection | ✅ | ❌ | ❌ | 仅 task |
| GiteaConventionSection | ✅ | ✅ | ✅ | **共性**:三 handler 共享,prompt_composer.py 定义 |
| WikiGuideSection | ✅ | ✅ | ✅ | **共性**:三 handler 共享,prompt_composer.py 定义 |
| DeliveryChecklistSection | ✅ | ✅ | ✅ | **共性**:三 handler 共享,prompt_composer.py 定义 |
**结论**ApiSection 可以抽一个 BaseApiSectioncurl 模板 + success_status 参数),其余 section 各自实现。
@@ -667,9 +701,9 @@ src/daemon/
├── task_type_registry.py # §3 + §4Protocol + Registry
├── prompt_composer.py # §12 PromptSection + PromptContext + PromptComposer
├── base_task_handler.py # §16 BaseTaskHandler 基类
├── task_handler.py # §13 TaskHandler(继承 BaseTaskHandler+ 5 sections
├── mail_handler.py # §13 MailHandler(继承 BaseTaskHandler+ 3 sections
├── toolchain_handler.py # §13 ToolchainHandler(继承 BaseTaskHandler+ 3 sections
├── task_handler.py # §13 TaskHandler(继承 BaseTaskHandler+ 8 sections
├── mail_handler.py # §13 MailHandler(继承 BaseTaskHandler+ 6 sections
├── toolchain_handler.py # §13 ToolchainHandler(继承 BaseTaskHandler+ 6 sections
├── dispatcher.py # §6 改动
├── spawner.py # §6 改动
├── ticker.py # §6 改动
@@ -952,7 +986,219 @@ handler.post_complete(task_id, agent_id, outcome, db_path)
---
## §14. Mail 失败通知机制
## §18. Mail Handler Verify/Prompt 强化
> 日期:2026-06-16 | 作者:庞统 | 状态:方向 1-5 全部已确认
## 18.1 问题背景
### 触发事件
2026-06-12 daemon 重启后,_mail DB 中积压的 E2E 测试遗留邮件(5/18~6/1 创建,type=requestperformative="text")被 dispatch 给 agent。agent 正常处理并输出文本(如"已阅,无需处理"),但 `verify_completion` 判定 no_reply → 标 failed → 触发 `notify_mail_failed` → 产生 38 封 `[投递失败]` 通知邮件,每 ~2.5 分钟一轮,持续 10 轮。
### 根因链
```
E2E 测试脚本 bugtype="text"
→ mail_routes.py 不校验 type 值,直接透传
→ performative="text" ≠ "inform" → 走 _check_reply
→ _check_reply 查 in_reply_to taskagent 没用 Mail API 回复
→ verify 失败 → on_failure 标 failed
→ notify_mail_failed 发 [投递失败] 通知
→ 通知本身也是 task,循环触发
```
### 三种 handler verify 对比
| 维度 | TaskHandler | MailHandler | ToolchainHandler |
|------|------------|-------------|------------------|
| verify 信号 | output / comment(≥50字) / terminal_status(三信号) | in_reply_to task(单信号) | action_report / output / comment(≥20字)(三层 fallback |
| inform 处理 | N/A | 直接通过(不检查执行证据) | N/A |
| verify 失败后 | **留 working**(覆盖 post_complete | **标 failed**base post_complete + mail on_failure | 标 failedbase post_complete + tc on_failure |
| agent 输出持久化 | 靠 agent 主动 POST output/comment | **无**agent 输出只在内存) | 靠 agent 主动 POST action_report |
**关键发现**
1. MailHandler 继承 BaseTaskHandler,未覆盖 `post_complete` → verify 失败时走 base 的 `on_failure` → 标 failed
2. TaskHandler 覆盖了 `post_complete` → verify 失败时留 working,让 ticker 重试
3. MailHandler 的 verify 只有 `in_reply_to` 一条路径,没有 fallback
4. inform 类型直接通过(`VerifyResult(True)`),不检查任何执行证据——inform 是"无需回复"不是"无需检查"
5. E2E 测试用 `TestClient(app)` 写生产 `_mail DB`,且测试脚本用了非标准 `type="text"`
## 18.2 修复方向
### 方向 1mail verify 对齐 toolchain 模式(✅ 已确认)
**问题**mail verify 只有 in_reply_to task 一条路径。task/toolchain 都有多层 fallbackoutputs / comments)。
**方案**mail 对齐 toolchain 模式——prompt 加 action report 要求,verify 优先查 action_report → fallback outputs → fallback comments。in_reply_to 回复邮件从唯一信号降为 request 类型的第 4 优先级信号。
#### prompt 强化(MailApiSection
参照 ToolchainApiSection,在 mail prompt 中追加 action report 要求:
```
### 完成后必须提交 action report
执行完邮件处理后,必须提交 action report
curl -s -X POST "http://localhost:8083/api/projects/_mail/tasks/{task_id}/comments" \
-H "Content-Type: application/json" \
-d '{"author": "{agent_id}", "comment_type": "action_report", "body": "处理结果摘要"}'
⚠️ 不提交 action report 的任务会被标记为 failed。
```
#### verify 改造(MailHandler.verify_completion
```python
def verify_completion(self, task_id, db_path) -> VerifyResult:
performative = self._parse_performative(task_id, db_path)
# 1. 优先检查 action_report comment(所有类型通用)
if self._has_action_report(task_id, db_path):
return VerifyResult(True, "has_action_report", "action_report found")
# 2. fallback: outputs
if self._has_outputs(task_id, db_path):
return VerifyResult(True, "has_output", f"output_count={count}")
# 3. fallback: 有实质内容的 comment(≥20字,非 system
if self._has_comment(task_id, db_path):
return VerifyResult(True, "has_comment", f"comment_count={count}")
# 4. request 特有:检查 in_reply_to 回复邮件
if performative == "request":
if self._check_reply(task_id, db_path):
return VerifyResult(True, "has_reply", "in_reply_to found")
return VerifyResult(False, "no_action",
"no action_report, no output, no comment, no reply")
```
注意:action_report 提交到 moziplus DBcomments 表),不是 Gitea。Gitea comment 是跨 agent 协作用的,不是 verify 检查的依据。
### 方向 2:prompt 约束强化(✅ 已确认)
**问题**:当前 mail prompt 只给了 curl 示例,没有硬约束要求 agent 必须输出处理结果。agent 判断"已阅"后直接跳过,不创建 in_reply_to task。
**方案**mail request/inform prompt 加 JSON 输出约束(参考 toolchain 的 Red Flags 模式)。
#### MailContextSection 强化
**request 类型**追加:
```
### 输出要求
- 你的回复必须包含对邮件的实际处理结果
- 如果是第一次收到:正常处理,输出处理结果
- 如果是重复邮件(你之前处理过相同 ID 的邮件):输出"此前已处理" + 之前的处理结果摘要
- ⚠️ "已阅""无需处理"不是有效处理结果
```
**inform 类型**追加:
```
### 输出要求
- 你的回复必须确认已处理(读取/执行/记录),不能只说"已阅"
- 如果是重复邮件:输出"此前已处理" + 处理结果摘要
- ⚠️ "已阅"不是有效输出
```
**MailConstraintsSection** 追加 Red Flags
```
| Agent 想法 | Red Flag 驳回 |
|------------|--------------|
| "已阅即可" | ❌ 错!必须输出处理结果或确认执行 |
| "重复邮件忽略" | ❌ 错!输出"此前已处理" + 结果摘要 |
| "无需回复" | ❌ 错!request 必须回复,inform 必须确认处理 |
```
### 方向 3:inform 也要检查执行证据(✅ 已确认)
**问题**:当前 inform verify 直接返回 `VerifyResult(True)`,不检查任何执行证据。inform 是"无需回复"不是"无需检查"。
**方案**inform verify 改为检查 agent 是否有实质输出(comment/output),和 request 走不同的验证路径但都需要验证。
**改动文件**`src/daemon/mail_handler.py` `verify_completion` 方法
### 方向 4verify 失败保持 working(✅ 已确认)
**问题**MailHandler 继承 BaseTaskHandlerverify 失败时走 base 的 `on_failure` → 标 failed。而 TaskHandler 覆盖了 `post_complete`verify 失败时留 working。
**原始设计意图**(§2 设计文档):"不通过 → 留 workingticker 重查(最多 3 次,然后标 failed"。
**方案**MailHandler 覆盖 `post_complete`verify 失败时不标 failed,保持 working。ticker 的 `_check_timeouts` 超时兜底:
- `check_completion` 通过(有回复)→ done
- `check_completion` 不通过 → 超时后标 failed
- Runaway Guard(§15 dispatch_count ≥ 10)兜底防止无限循环
**改动文件**`src/daemon/mail_handler.py`,新增 `post_complete` 覆盖
### 方向 5type 校验 + E2E 修复 + DB 清理(✅ 已确认)
#### 5.1 mail_routes.py type 校验
**问题**`mail_type = body.get("type")` 直接透传,传什么存什么。`"text"` 不是标准值。
**方案**:创建时校验 type 只允许 `inform` / `request`,非法值默认 `request`。
```python
mail_type = body.get("type")
if mail_type is None:
mail_type = "inform" if in_reply_to else "request"
elif mail_type not in ("inform", "request"):
# 非标准值,校正为默认值
mail_type = "inform" if in_reply_to else "request"
```
**改动文件**`src/api/mail_routes.py`
#### 5.2 _parse_performative 容错
**问题**`meta.get("performative", meta.get("type", "request"))` 当 performative="text" 时返回 "text",不等于 "inform" → 走 _check_reply。
**方案**:只认 `inform` 和 `request` 两个值,其他一律当 `request`。
```python
def _parse_performative(self, task_id, db_path) -> str:
raw = meta.get("performative", meta.get("type", "request"))
if raw == "inform":
return "inform"
return "request" # 非标准值一律当 request
```
**改动文件**`src/daemon/mail_handler.py` `_parse_performative` 方法
#### 5.3 E2E 测试修复
**问题**`tests/e2e/test_e2e_v27.py` 用 `type="text"` 创建测试邮件,且用 `TestClient(app)` 写生产 `_mail DB`。
**修复**
1. `type="text"` 全部改为 `type="inform"` 或 `type="request"`
2. E2E 测试跑完后清理测试邮件(`mail_ids` 列表中记录的 task
**改动文件**`tests/e2e/test_e2e_v27.py`
#### 5.4 生产 DB 清理
**问题**:生产 `_mail DB` 中残留大量 E2E 测试邮件(5/18~6/3 创建的"筛选测试""详情测试""已读测试""任务分配"等)。
**方案**:手动清理这些测试残留(一次性操作,不需要代码改动)。
## 18.3 影响范围
| 文件 | 改动类型 | 影响面 |
|------|---------|--------|
| `src/daemon/mail_handler.py` | verify + post_complete + prompt section | MailHandler 核心逻辑 |
| `src/api/mail_routes.py` | type 校验 | Mail API 创建入口 |
| `tests/e2e/test_e2e_v27.py` | type 值修正 + 清理 | E2E 测试 |
| 生产 `_mail DB` | 清理测试残留 | 一次性操作 |
## 18.4 验证计划
1. 单元测试:mail_handler verify/prompt 变更
2. 集成测试:mail dispatch → verify → done/working 全链路
3. 回归测试:`pytest -m "not e2e"` 全量
4. 手工验证:创建 inform/request 邮件,确认 verify 行为正确
---
# §14. Mail 失败通知机制
### 20.1 背景
+61
View File
@@ -0,0 +1,61 @@
# §15 Runaway Guard — Per-Task Dispatch 上限
> 设计文档 v1.0 | 2026-06-16
## 问题
mail/toolchain task 走 handler auto-working(跳过 claim 阶段),不受 claim_timeout 的 3 次重试兜底保护。如果一个 auto-working task 反复 spawn 但永远到不了 done/failed,会无限循环消耗资源。
### 实际案例
2026-06-15 mention 重复投递事件:`spawn_full_agent``use_main_session=True` 时返回 `None`ticker `_process_mentions` 误判为失败,每次 tick(30s)都重试。同一 mention 投递了 4 次,直到 retry_count 达到 mention_queue 的 5 次上限才停止。
直接根因已由 PR #80 修复,但如果类似 bug 再次出现,当前没有任何机制阻止 task 层面的无限循环。
## 设计
### 机制
tasks 表新增 `dispatch_count` 字段,每次 ticker 成功 dispatch 一个 task 时递增。当 `dispatch_count >= 10`(全局默认)时,自动标 failed。
### 默认值选择
全局默认 10 次。参考 Hermes v0.13 Best Practices §3 "Per-Task 重试上限"
- 简单任务重试 1 次
- 复杂任务重试 3 次
- crash recovery3 次)+ api_retry3 次)余量 = ~10 次
### 适用范围
所有 task 类型(task/mail/toolchain),所有非终态(pending/working/claimed)。
### 检查时机
`_check_timeouts` 方法开头,先于现有的 claimed/working 超时检查执行。
### 与现有机制的关系
| 机制 | 覆盖场景 | 触发动作 |
|------|---------|---------|
| claim_timeout retry_count >= 3 | 广播任务无人认领 | 升级庞统 |
| crash_limit 3/30min | working 状态 crash | 标 failed |
| api_retry_count | API 连续失败 | 标 failed |
| 续杯 max_retries 3 | 续杯耗尽 | 标 failed |
| working timeout | working 超时 | 标 failed 或 done |
| **runaway_guard 10 次** | **任何状态的无限循环** | **标 failed** |
runaway_guard 是最后一道防线,覆盖所有其他机制遗漏的循环场景。
## 改动文件
| 文件 | 改动 |
|------|------|
| `src/blackboard/db.py` | `_safe_add_column(conn, "tasks", "dispatch_count", "INTEGER DEFAULT 0")` |
| `src/blackboard/models.py` | Task dataclass 加 `dispatch_count: int = 0` |
| `src/daemon/ticker.py` | `_dispatch_pending` / `_dispatch_reviews` 递增 dispatch_count`_check_timeouts` 加 runaway guard 检查 |
## 参考
- Hermes v0.13 Kanban Best Practices §3 "Per-Task 重试上限"
- 实际案例:2026-06-15 mention 重复投递事件(PR #80 修复了直接根因,runaway guard 作为兜底)
@@ -0,0 +1,503 @@
# API 聚合重构 + 工具链 Tab 设计
> **编号**: §18
> **状态**: 设计中
> **日期**: 2026-06-14
> **作者**: 庞统(副军师)🐦
> **审查**: 司马懿(mail-1781415763066 已回复,方案 B 调整版确认)
---
## 1. 背景与目标
### 1.1 问题
1. **blackboard_routes.py 膨胀**572 行、22 个路由,task/comment/output/review/event/decision/observation/archive 全堆一个文件,维护困难
2. **前端 N+1 请求**:打开 TaskModal 需要 5 次独立请求(task + events + subtasks + progress + comments),影响前端性能
3. **工具链事件无前端展示**`_toolchain` DB 隔离已完成,但前端无对应 Tab,工具链事件只能通过 Agent 收 Mail 感知
### 1.2 目标
1. 按领域拆分 blackboard_routes.py → 3 个文件
2. 实现细粒度 expand 聚合接口,前端 1-2 次请求拿全任务详情
3. 新增工具链 Tab(列表 + 详情 + 搜索栏)
4. 任务列表支持标题搜索
### 1.3 不做
- checkpoint_routes.py 不纳入拆分(已独立)
- mail_routes / toolchain_routes / project_routes 不动
- SQL JOIN / batch query 性能优化(当前 SQLite 单写下多次查询可接受)
---
## 2. 后端 API 文件拆分
### 2.1 拆分方案(方案 B 调整版,司马懿确认)
| 新文件 | 内容 | 预估行数 |
|--------|------|---------|
| `task_routes.py` | task CRUD + create(含 AI 标题) + patch + progress + claim + status(含广播) + archive + archive-done | ~280 |
| `task_relation_routes.py` | comments + outputs(含文件写入) + reviews + decisions + observations + events + experiences + summary | ~250 |
| `shared.py` | `_bb()` / `_q()` / `_validate_project()` / `_task_to_dict()` / `_init_agent_ids()` / `_extract_mentions()` / 常量导入 | ~30 |
### 2.2 路由分配明细
**task_routes.py**10 个路由):
| 路由 | 方法 | 函数 | 说明 |
|------|------|------|------|
| `/tasks` | GET | `list_tasks` | 列表(新增 `q` 搜索参数) |
| `/tasks` | POST | `create_task` | 创建(含 `_generate_title` |
| `/tasks/{tid}` | GET | `get_task` | 详情(含 expand 聚合) |
| `/tasks/{tid}` | PATCH | `patch_task` | 更新 |
| `/tasks/{tid}/progress` | GET | `task_progress` | 进度 |
| `/tasks/{tid}/claim` | POST | `claim_task` | 认领 |
| `/tasks/{tid}/status` | POST | `update_status` | 状态流转(含广播逻辑) |
| `/tasks/{tid}/archive` | POST | `archive_task` | 归档 |
| `/tasks/archive-done` | POST | `archive_done_tasks` | 批量归档 |
**task_relation_routes.py**13 个路由):
| 路由 | 方法 | 函数 | 说明 |
|------|------|------|------|
| `/tasks/{tid}/comments` | GET | `get_comments` | 评论列表 |
| `/tasks/{tid}/comments` | POST | `add_comment` | 添加评论(含 @mention 提取) |
| `/tasks/{tid}/outputs` | GET | `get_outputs` | 产出列表 |
| `/tasks/{tid}/outputs` | POST | `write_output` | 写入产出(含文件写入逻辑) |
| `/tasks/{tid}/decisions` | GET | `get_decisions` | 决策列表 |
| `/tasks/{tid}/decisions` | POST | `add_decision` | 添加决策 |
| `/tasks/{tid}/observations` | POST | `add_observation` | 添加观察 |
| `/tasks/{tid}/reviews` | GET | `get_reviews` | 审查列表 |
| `/tasks/{tid}/reviews` | POST | `add_review` | 添加审查 |
| `/tasks/{tid}/events` | GET | `get_task_events` | 事件列表 |
| `/tasks/{tid}/experiences` | GET | `get_task_experiences` | 经验列表 |
| `/events` | GET | `get_events` | 项目级事件 |
| `/summary` | GET | `task_summary` | 任务汇总 |
### 2.3 shared.py 共享件
从 blackboard_routes.py 提取到 shared.py
| 符号 | 类型 | 说明 |
|------|------|------|
| `_validate_project()` | function | 项目 ID 校验 |
| `_bb()` | function | Blackboard 实例获取 |
| `_q()` | function | Queries 实例获取 |
| `_task_to_dict()` | function | Task → dict 序列化 |
| `_init_agent_ids()` | function | Agent ID 初始化 |
| `_extract_mentions()` | function | @mention 提取 |
| `VALID_STATUSES` | import | 从 db.py 重导出 |
| `OUTPUT_TYPES` | import | 从 db.py 重导出 |
### 2.4 main.py 路由注册变更
```python
# 拆分前
from src.api.blackboard_routes import router as blackboard_router
app.include_router(blackboard_router)
# 拆分后
from src.api.task_routes import router as task_router
from src.api.task_relation_routes import router as task_relation_router
app.include_router(task_router)
app.include_router(task_relation_router)
```
URL prefix 不变:所有路由仍是 `/api/projects/{pid}/...`,前端 URL 零改动。
### 2.5 向后兼容
- 删除 `blackboard_routes.py`,所有引用指向新文件
- `expand=all` 保持兼容(内部映射为全量 expand)
- 不改变任何 API 的请求/响应格式(仅文件组织变化)
---
## 3. expand 聚合接口
### 3.1 设计
`GET /api/projects/{pid}/tasks/{tid}?expand=comments,outputs,reviews,events,decisions`
支持逗号分隔的细粒度选择,替代当前的 `expand=all`
### 3.2 返回格式
```json
{
"task": { "id": "...", "title": "...", "status": "working", ... },
"comments": {
"items": [...],
"total_count": 45,
"limit": 20
},
"events": {
"items": [...],
"total_count": 120,
"limit": 30
},
"outputs": [...],
"reviews": [...],
"decisions": [...]
}
```
### 3.3 limit 策略
| 关联资源 | expand 返回 | 分页支持 | 理由 |
|----------|------------|---------|------|
| comments | 最新 20 条 + total_count | `GET /comments?limit=50&offset=0` | 高频资源,长任务可能积累几十条 |
| events | 最新 30 条 + total_count | `GET /events?limit=100&offset=0` | 运行几天可能上百条 |
| outputs | 全部 | 不需要 | 通常 <5 条 |
| reviews | 全部 | 不需要 | 通常 <5 条 |
| decisions | 全部 | 不需要 | 通常 <5 条 |
前端拿到 `total_count > items.length` 时显示"还有 N 条",按需点击加载。
### 3.4 实现伪码
```python
@router.get("/tasks/{task_id}")
async def get_task(project_id: str, task_id: str,
expand: Optional[str] = None):
bb = _bb(project_id)
task = bb.get_task(task_id)
if not task:
raise HTTPException(404, f"Task not found: {task_id}")
result = _task_to_dict(task)
if not expand:
return result
expand_list = expand.split(",") if expand != "all" else [
"comments", "outputs", "reviews", "events", "decisions"
]
q = _q(project_id)
if "comments" in expand_list:
all_comments = bb.get_comments(task_id)
result["comments"] = {
"items": [dict(c.__dict__) for c in all_comments[-20:]],
"total_count": len(all_comments),
"limit": 20,
}
if "events" in expand_list:
all_events = q.task_events(task_id)
result["events"] = {
"items": all_events[-30:],
"total_count": len(all_events),
"limit": 30,
}
if "outputs" in expand_list:
result["outputs"] = [dict(o.__dict__) for o in bb.get_outputs(task_id)]
if "reviews" in expand_list:
result["reviews"] = [dict(r.__dict__) for r in bb.get_reviews(task_id)]
if "decisions" in expand_list:
result["decisions"] = [dict(d.__dict__) for d in bb.get_decisions(task_id)]
return result
```
### 3.5 性能分析
| 场景 | 当前(无 expand | expand 后 | 改善 |
|------|-----------------|-----------|------|
| 打开 TaskModal | 5 次 HTTP 请求 | 2 次(task+expand + subtasks | -60% 请求 |
| 单次 expand 响应体 | — | ~5-15KB(典型) | 一次大请求 < 五次小请求 |
| DB 查询次数 | 5 次(各端点独立查) | 5 次(expand 内部循环) | 相同,暂不优化 |
---
## 4. 任务列表搜索
### 4.1 设计
`GET /api/projects/{pid}/tasks?q=关键词`
在现有 `list_tasks` 基础上增加 `q` 查询参数,支持标题模糊搜索(SQL LIKE)。
### 4.2 实现
```python
@router.get("/tasks")
async def list_tasks(project_id: str, q: Optional[str] = None, ...):
bb = _bb(project_id)
tasks = bb.list_tasks(status=status, ...)
if q:
q_lower = q.lower()
tasks = [t for t in tasks if q_lower in (t.title or "").lower()]
return {"tasks": [_task_to_dict(t) for t in tasks]}
```
**设计决策**:过滤在 Python 层做而非 SQL 层。
- 理由:当前 `list_tasks` 已在 Python 层做 status 筛选,加一层 title 过滤一致性更好
- 如果后续任务量大(>1000),再改为 SQL LIKE 查询
---
## 5. 前端:工具链 Tab
### 5.1 Tab 定义
```typescript
// store.ts TabKey 新增
| 'toolchain'
// TAB_DEFS 新增(插在 settings 前面)
{ key: 'toolchain', label: '工具链', icon: '⛓️' },
```
### 5.2 数据加载
```typescript
// store.ts 新增
toolchainTasks: any[];
loadToolchain: async () => {
const res = await fetch('/api/projects/_toolchain/tasks');
const data = await res.json();
set({ toolchainTasks: data.tasks || [] });
}
// Tab 切换时加载
if (tab === 'toolchain') s.loadToolchain();
```
### 5.3 ToolchainPanel 组件
仿 MailPanel 结构,三个区域:
**搜索栏**(顶部):
- 文本输入框,输入关键词实时过滤列表
- 调用 `GET /api/projects/_toolchain/tasks?q=关键词`
**列表区**(左侧):
- 工具链事件列表(时间倒序)
- 每条显示:标题 + 时间 + 状态标签
- 点击选中,高亮当前选中项
**详情区**(右侧):
- 选中事件的完整内容
- 调用 `GET /api/projects/_toolchain/tasks/{tid}?expand=comments` 获取详情
- 展示:标题、描述、状态、评论(action_report 等)
### 5.4 和 Mail 的隔离
| 维度 | Mail Tab | 工具链 Tab |
|------|---------|-----------|
| 数据源 | `_mail` 项目 | `_toolchain` 项目 |
| 事件类型 | Agent 间通信(inform/request | 系统事件(CI/PR/部署/Review |
| 搜索 | 无(邮件量不大) | 有(工具链事件频率高) |
---
## 6. 测试设计
### 6.1 后端 API 拆分测试
**目标**:验证拆分后所有路由 URL 不变、行为不变。
**测试文件**`tests/integration/test_api.py`(扩展现有)+ 新增 `tests/unit/test_task_routes.py`
| 测试类 | 测试用例 | 验证点 |
|--------|---------|--------|
| TestTaskRoutes | test_list_tasks | GET /tasks 返回格式不变 |
| | test_list_tasks_with_search | q 参数过滤正确 |
| | test_list_tasks_empty_q | q 为空时返回全部 |
| | test_get_task | GET /tasks/{tid} 基本详情 |
| | test_get_task_expand_comments | expand=comments 返回带 total_count + limit |
| | test_get_task_expand_events | expand=events 返回带 total_count + limit |
| | test_get_task_expand_outputs | expand=outputs 全量返回 |
| | test_get_task_expand_multiple | expand=comments,outputs,reviews 组合 |
| | test_get_task_expand_all | expand=all 向后兼容 |
| | test_get_task_no_expand | 不传 expand 返回基本 task |
| | test_create_task | POST 格式不变 |
| | test_claim_task | 认领行为不变 |
| | test_update_status | 状态流转不变 |
| | test_patch_task | PATCH 不变 |
| | test_archive_task | 归档不变 |
| 测试类 | 测试用例 | 验证点 |
|--------|---------|--------|
| TestTaskRelationRoutes | test_comments_crud | GET/POST comments 不变 |
| | test_outputs_crud | GET/POST outputs 不变 |
| | test_write_output_file | 文件写入逻辑不变 |
| | test_reviews_crud | GET/POST reviews 不变 |
| | test_decisions_crud | GET/POST decisions 不变 |
| | test_observations_add | POST observations 不变 |
| | test_events_list | GET events 不变 |
| | test_experiences_list | GET experiences 不变 |
| | test_project_events | GET /events 不变 |
| | test_summary | GET /summary 不变 |
**兼容性验证脚本**
```bash
#!/bin/bash
# tests/scripts/verify_api_compat.sh
# 对比拆分前后所有路由 URL 和方法,确保零变化
echo "=== 拆分前路由清单 ==="
# 从 git stash 或 main 分支提取
git stash
python -c "
from src.main import app
for route in app.routes:
if hasattr(route, 'methods') and hasattr(route, 'path'):
for m in sorted(route.methods):
if m in ('GET','POST','PATCH','DELETE','PUT'):
print(f'{m} {route.path}')
" | sort > /tmp/routes_before.txt
git stash pop
echo "=== 拆分后路由清单 ==="
python -c "
from src.main import app
for route in app.routes:
if hasattr(route, 'methods') and hasattr(route, 'path'):
for m in sorted(route.methods):
if m in ('GET','POST','PATCH','DELETE','PUT'):
print(f'{m} {route.path}')
" | sort > /tmp/routes_after.txt
echo "=== Diff ==="
diff /tmp/routes_before.txt /tmp/routes_after.txt
if [ $? -eq 0 ]; then
echo "✅ 路由完全一致"
else
echo "❌ 路由有差异"
exit 1
fi
```
### 6.2 expand 聚合测试
**测试文件**`tests/unit/test_expand_api.py`
| 测试用例 | 验证点 |
|---------|--------|
| test_expand_comments_limit | comments 返回最新 20 条 + total_count=25 |
| test_expand_comments_are_latest | 验证返回的是最新 20 条(index 5-24 |
| test_expand_events_limit | events 返回最新 30 条 + total_count=35 |
| test_expand_outputs_full | outputs 全量返回(list 格式,不分页) |
| test_expand_reviews_full | reviews 全量返回 |
| test_expand_decisions_full | decisions 全量返回 |
| test_expand_multiple_fields | expand=comments,outputs,reviews 组合,未请求的不返回 |
| test_expand_all_compat | expand=all 向后兼容 |
| test_no_expand | 不传 expand 只返回基本 task |
| test_expand_invalid_field_ignored | 无效字段静默忽略 |
### 6.3 搜索测试
**测试文件**`tests/unit/test_task_routes.py``TestTaskListRoutes`
| 测试用例 | 验证点 |
|---------|--------|
| test_list_tasks_with_search | q 参数标题模糊搜索 |
| test_list_tasks_search_case_insensitive | 大小写不敏感 |
| test_list_tasks_search_no_match | 无匹配返回空列表 |
| test_list_tasks_search_empty_q | q 为空返回全部 |
### 6.4 前端测试(手动验证)
| 验证点 | 操作 | 预期 |
|--------|------|------|
| 工具链 Tab 出现 | 打开前端 | Tab 栏有 ⛓️ 工具链 |
| 列表加载 | 点击工具链 Tab | 显示 _toolchain 事件列表 |
| 搜索过滤 | 输入关键词 | 列表实时过滤 |
| 详情展示 | 点击某条事件 | 右侧/弹窗显示完整内容 |
| Tab 切换不丢数据 | 切到其他 Tab 再回来 | 数据保持 |
### 6.5 CI 集成
| 命令 | 说明 |
|------|------|
| `bash tests/scripts/verify_api_compat.sh` | 路由兼容性验证(CI 必跑) |
| `pytest tests/unit/test_task_routes.py tests/unit/test_expand_api.py tests/integration/test_api.py -m "not e2e" -v` | 新增单元 + 集成测试 |
> 测试用例详细设计(fixture + 完整代码 + 覆盖矩阵)见 `docs/design/18-test-design.md`
---
## 7. 实施计划
### Phase 1: 后端 API 拆分(不含功能变更)
| 步骤 | 内容 | 验证 |
|------|------|------|
| 1.1 | 创建 `shared.py`,提取共享 helper | import 无报错 |
| 1.2 | 创建 `task_routes.py`,迁移 10 个路由 | 路由注册成功 |
| 1.3 | 创建 `task_relation_routes.py`,迁移 13 个路由 | 路由注册成功 |
| 1.4 | 更新 `main.py` router 注册 | app 启动无报错 |
| 1.5 | 删除 `blackboard_routes.py` | — |
| 1.6 | 运行 `verify_api_compat.sh` | 路由清单 diff = 0 |
| 1.7 | 运行现有测试 | 全量通过 |
### Phase 2: expand 聚合 + 搜索
| 步骤 | 内容 | 验证 |
|------|------|------|
| 2.1 | 重写 `get_task` expand 逻辑(细粒度) | TestExpandAPI 通过 |
| 2.2 | `list_tasks``q` 参数 | TestTaskSearch 通过 |
| 2.3 | 新增测试用例 | 覆盖率达标 |
### Phase 3: 前端工具链 Tab
| 步骤 | 内容 | 验证 |
|------|------|------|
| 3.1 | store.ts 新增 toolchain 数据加载 | — |
| 3.2 | api.ts 新增 expand 调用封装 | — |
| 3.3 | 创建 `ToolchainPanel.tsx` | 组件渲染正常 |
| 3.4 | App.tsx 注册新 Tab | Tab 显示正确 |
| 3.5 | TaskModal 改用 expand 减少 | 请求次数减少 |
### Phase 4: 联调 + 评审
| 步骤 | 内容 |
|------|------|
| 4.1 | 全量测试 `pytest -m "not e2e"` |
| 4.2 | 发评审给司马懿 |
| 4.3 | 前端手动验证 |
---
## 8. 风险评估
| 风险 | 级别 | 缓解 |
|------|------|------|
| 拆分后 import 路径断裂 | 中 | IDE 全局搜索 + 运行时验证 |
| expand 返回体过大 | 低 | comments/events 有 limit |
| 工具链事件量大影响前端 | 低 | 搜索栏 + 分页 |
| expand=all 向后兼容 | 低 | 单独兼容分支处理 |
---
## 9. 评审记录
### 司马懿 mail-17814157630662026-06-14
| 项目 | 结论 |
|------|------|
| 文件拆分 | 方案 B 调整版(task_routes + task_relation_routes + shared |
| expand | 细粒度,events/comments 带 limit+total_count |
| 性能 | 当前 SQLite 多次查询可接受 |
| checkpoint | 不纳入 |
| _generate_title | 留在 task_routes.py |
| write_output | 注意不是简单 CRUD |
---
## 10. 变更记录
| 日期 | 版本 | 内容 |
|------|------|------|
| 2026-06-14 | v1.0 | 初版设计 |
+484
View File
@@ -0,0 +1,484 @@
# §18 测试用例详细设计
> **关联**: `docs/design/18-api-refactor-and-toolchain-tab.md`
> **日期**: 2026-06-14
---
## 1. 测试文件规划
| 文件 | 类型 | 测试数 | 说明 |
|------|------|--------|------|
| `tests/integration/test_api.py` | 集成 | 扩展现有 | 拆分后回归验证 |
| `tests/unit/test_task_routes.py` | 单元 | 14 | task_routes 专项 |
| `tests/unit/test_expand_api.py` | 单元 | 7 | expand 聚合专项 |
| `tests/unit/test_task_search.py` | 单元 | 4 | 搜索专项 |
| `tests/scripts/verify_api_compat.sh` | 脚本 | 1 | CI 路由兼容性 |
**总计**:26 个测试 + 1 个 CI 脚本
---
## 2. 预置 Fixture
```python
# tests/conftest.py 新增(如果不存在则补充)
@pytest.fixture
def expand_env(tmp_path):
"""expand 测试环境:1 个 task + 预置关联数据"""
project_root = tmp_path / "projects"
project_root.mkdir()
os.environ["BLACKBOARD_ROOT"] = str(project_root)
reg = ProjectRegistry(project_root)
reg.create_project("test-proj", "Test Project", agents=["agent1"])
bb = Blackboard(project_root / "test-proj" / "blackboard.db")
bb.create_task(Task(id="t1", title="Expand Test Task", task_type="coding"))
# 预置 25 条 comment
for i in range(25):
bb.add_comment("t1", agent="agent1", comment_type="general",
content=f"Comment number {i}")
# 预置 5 条 output
for i in range(5):
bb.write_output("t1", agent="agent1",
output_type="code",
content=f"output content {i}",
filename=f"file_{i}.py")
# 预置 3 条 review
for i in range(3):
bb.add_review("t1", reviewer="agent1",
verdict="APPROVE",
confidence=0.9,
risk_level="low",
summary=f"Review {i}")
# 预置 2 条 decision
for i in range(2):
bb.add_decision("t1", agent="agent1",
decision_type="scope",
rationale=f"Decision {i}")
# 预置 35 条 event
from src.blackboard.queries import Queries
q = Queries(project_root / "test-proj" / "blackboard.db")
for i in range(35):
q.add_event("t1", event_type="status_change",
detail=f"Event {i}")
yield project_root
del os.environ["BLACKBOARD_ROOT"]
```
---
## 3. task_routes.py 测试(test_task_routes.py
```python
"""task_routes.py 路由测试 — 验证拆分后行为不变"""
import pytest
from fastapi.testclient import TestClient
from src.main import app
client = TestClient(app)
pytestmark = pytest.mark.integration
class TestTaskListRoutes:
"""GET /tasks + 搜索"""
def test_list_tasks_basic(self, project_env):
"""列表基本返回格式不变"""
resp = client.get("/api/projects/test-proj/tasks")
assert resp.status_code == 200
data = resp.json()
assert "tasks" in data
assert isinstance(data["tasks"], list)
def test_list_tasks_with_search(self, project_env):
"""q 参数搜索标题"""
resp = client.get("/api/projects/test-proj/tasks?q=Existing")
data = resp.json()
assert len(data["tasks"]) == 1
assert "Existing" in data["tasks"][0]["title"]
def test_list_tasks_search_case_insensitive(self, project_env):
"""大小写不敏感"""
resp = client.get("/api/projects/test-proj/tasks?q=existing")
data = resp.json()
assert len(data["tasks"]) == 1
def test_list_tasks_search_no_match(self, project_env):
"""无匹配返回空列表"""
resp = client.get("/api/projects/test-proj/tasks?q=nonexistent_xyz")
data = resp.json()
assert len(data["tasks"]) == 0
def test_list_tasks_search_empty_q(self, project_env):
"""q 为空返回全部"""
resp = client.get("/api/projects/test-proj/tasks?q=")
data = resp.json()
assert len(data["tasks"]) >= 1
class TestTaskDetailRoutes:
"""GET /tasks/{tid} + expand"""
def test_get_task_basic(self, project_env):
"""无 expand 返回基本 task"""
resp = client.get("/api/projects/test-proj/tasks/t1")
assert resp.status_code == 200
data = resp.json()
assert data["id"] == "t1"
assert "comments" not in data # 无 expand 不含关联数据
def test_get_task_404(self, project_env):
"""不存在的 task 返回 404"""
resp = client.get("/api/projects/test-proj/tasks/nonexistent")
assert resp.status_code == 404
class TestTaskActionRoutes:
"""claim/status/patch/archive 行为不变"""
def test_claim_task(self, project_env):
"""认领行为不变"""
resp = client.post("/api/projects/test-proj/tasks/t1/claim",
json={"agent": "agent1"})
assert resp.status_code == 200
def test_update_status(self, project_env):
"""状态流转不变"""
client.post("/api/projects/test-proj/tasks/t1/claim",
json={"agent": "agent1"})
resp = client.post("/api/projects/test-proj/tasks/t1/status",
json={"agent": "agent1", "status": "working"})
assert resp.status_code == 200
def test_invalid_status_transition(self, project_env):
"""非法状态转换返回 409"""
resp = client.post("/api/projects/test-proj/tasks/t1/status",
json={"agent": "agent1", "status": "done"})
assert resp.status_code == 409
def test_patch_task(self, project_env):
"""PATCH 更新不变"""
resp = client.patch("/api/projects/test-proj/tasks/t1",
json={"priority": 5})
assert resp.status_code == 200
def test_archive_task(self, project_env):
"""归档不变"""
resp = client.post("/api/projects/test-proj/tasks/t1/archive",
json={"agent": "agent1"})
assert resp.status_code == 200
class TestTaskCreateRoute:
"""POST /tasks 创建行为不变"""
def test_create_task(self, project_env):
"""创建格式不变"""
resp = client.post("/api/projects/test-proj/tasks",
json={"title": "New Task", "description": "test"})
assert resp.status_code == 201
data = resp.json()
assert "id" in data
```
---
## 4. expand 聚合测试(test_expand_api.py
```python
"""expand 聚合接口测试"""
import pytest
from fastapi.testclient import TestClient
from src.main import app
client = TestClient(app)
pytestmark = pytest.mark.integration
class TestExpandComments:
"""expand=comments"""
def test_comments_limit_and_count(self, expand_env):
"""返回最新 20 条 + total_count=25"""
resp = client.get("/api/projects/test-proj/tasks/t1?expand=comments")
data = resp.json()
comments = data["comments"]
assert isinstance(comments, dict)
assert len(comments["items"]) == 20
assert comments["total_count"] == 25
assert comments["limit"] == 20
def test_comments_are_latest(self, expand_env):
"""返回的是最新 20 条(Comment 5-24"""
resp = client.get("/api/projects/test-proj/tasks/t1?expand=comments")
data = resp.json()
first_content = data["comments"]["items"][0]["content"]
last_content = data["comments"]["items"][-1]["content"]
# 最新 20 条 = index 5 到 24
assert "5" in first_content or "24" in last_content
class TestExpandEvents:
"""expand=events"""
def test_events_limit_and_count(self, expand_env):
"""返回最新 30 条 + total_count=35"""
resp = client.get("/api/projects/test-proj/tasks/t1?expand=events")
data = resp.json()
events = data["events"]
assert isinstance(events, dict)
assert len(events["items"]) == 30
assert events["total_count"] == 35
assert events["limit"] == 30
class TestExpandFullResources:
"""outputs/reviews/decisions 全量返回"""
def test_expand_outputs_full(self, expand_env):
"""outputs 全量返回(5 条),格式是 list 不是 dict"""
resp = client.get("/api/projects/test-proj/tasks/t1?expand=outputs")
data = resp.json()
outputs = data["outputs"]
assert isinstance(outputs, list)
assert len(outputs) == 5
def test_expand_reviews_full(self, expand_env):
"""reviews 全量返回(3 条)"""
resp = client.get("/api/projects/test-proj/tasks/t1?expand=reviews")
data = resp.json()
reviews = data["reviews"]
assert isinstance(reviews, list)
assert len(reviews) == 3
def test_expand_decisions_full(self, expand_env):
"""decisions 全量返回(2 条)"""
resp = client.get("/api/projects/test-proj/tasks/t1?expand=decisions")
data = resp.json()
decisions = data["decisions"]
assert isinstance(decisions, list)
assert len(decisions) == 2
class TestExpandCombinations:
"""组合 expand"""
def test_expand_multiple_fields(self, expand_env):
"""expand=comments,outputs,reviews 组合"""
resp = client.get(
"/api/projects/test-proj/tasks/t1?expand=comments,outputs,reviews"
)
data = resp.json()
assert "comments" in data
assert "outputs" in data
assert "reviews" in data
assert "events" not in data # 未请求
assert "decisions" not in data
def test_expand_all_compat(self, expand_env):
"""expand=all 向后兼容"""
resp = client.get("/api/projects/test-proj/tasks/t1?expand=all")
data = resp.json()
# all 返回所有关联资源
assert "comments" in data
assert "outputs" in data
assert "reviews" in data
assert "events" in data
assert "decisions" in data
def test_no_expand(self, expand_env):
"""不传 expand 只返回基本 task"""
resp = client.get("/api/projects/test-proj/tasks/t1")
data = resp.json()
assert "comments" not in data
assert "outputs" not in data
assert data["id"] == "t1"
def test_expand_invalid_field_ignored(self, expand_env):
"""无效 expand 字段静默忽略"""
resp = client.get(
"/api/projects/test-proj/tasks/t1?expand=comments,invalid_field"
)
data = resp.json()
assert "comments" in data
assert "invalid_field" not in data
```
---
## 5. task_relation_routes.py 回归测试
```python
"""task_relation_routes.py 路由回归 — 验证拆分后行为不变"""
import pytest
from fastapi.testclient import TestClient
from src.main import app
client = TestClient(app)
pytestmark = pytest.mark.integration
class TestRelationRoutesRegression:
"""关联路由回归测试"""
def test_comments_crud(self, project_env):
"""GET/POST comments 不变"""
# POST
resp = client.post("/api/projects/test-proj/tasks/t1/comments",
json={"agent": "a1", "comment_type": "general",
"content": "test comment"})
assert resp.status_code == 201
# GET
resp = client.get("/api/projects/test-proj/tasks/t1/comments")
assert resp.status_code == 200
assert len(resp.json()["comments"]) >= 1
def test_outputs_crud(self, project_env):
"""GET/POST outputs 不变"""
resp = client.post("/api/projects/test-proj/tasks/t1/outputs",
json={"agent": "a1", "type": "code",
"content": "print('hello')"})
assert resp.status_code == 201
resp = client.get("/api/projects/test-proj/tasks/t1/outputs")
assert resp.status_code == 200
def test_write_output_with_filename(self, project_env):
"""output 含 filename 的文件写入不变"""
resp = client.post("/api/projects/test-proj/tasks/t1/outputs",
json={"agent": "a1", "type": "code",
"content": "x = 1",
"filename": "test.py"})
assert resp.status_code == 201
def test_write_output_invalid_type(self, project_env):
"""output 无效 type 返回 422"""
resp = client.post("/api/projects/test-proj/tasks/t1/outputs",
json={"agent": "a1", "type": "invalid_type",
"content": "x"})
assert resp.status_code == 422
def test_reviews_crud(self, project_env):
"""GET/POST reviews 不变"""
resp = client.post("/api/projects/test-proj/tasks/t1/reviews",
json={"reviewer": "a1", "verdict": "APPROVE",
"confidence": 0.9, "risk_level": "low",
"summary": "LGTM"})
assert resp.status_code == 201
resp = client.get("/api/projects/test-proj/tasks/t1/reviews")
assert resp.status_code == 200
def test_decisions_crud(self, project_env):
"""GET/POST decisions 不变"""
resp = client.post("/api/projects/test-proj/tasks/t1/decisions",
json={"agent": "a1", "decision_type": "scope",
"rationale": "test"})
assert resp.status_code == 201
resp = client.get("/api/projects/test-proj/tasks/t1/decisions")
assert resp.status_code == 200
def test_observations_add(self, project_env):
"""POST observations 不变"""
resp = client.post("/api/projects/test-proj/tasks/t1/observations",
json={"agent": "a1", "observation_type": "note",
"content": "observed"})
assert resp.status_code == 201
def test_events_list(self, project_env):
"""GET events 不变"""
resp = client.get("/api/projects/test-proj/tasks/t1/events")
assert resp.status_code == 200
assert "events" in resp.json()
def test_project_events(self, project_env):
"""GET /events 项目级事件不变"""
resp = client.get("/api/projects/test-proj/events")
assert resp.status_code == 200
def test_summary(self, project_env):
"""GET /summary 不变"""
resp = client.get("/api/projects/test-proj/summary")
assert resp.status_code == 200
```
---
## 6. CI 集成
### .gitea/workflows/ci.yml 新增步骤
```yaml
test:
runs-on: ubuntu-latest
steps:
# ... 现有步骤 ...
# API 兼容性验证
- name: Verify API Compatibility
run: |
cd src/frontend && npm run build
bash tests/scripts/verify_api_compat.sh
# 新增测试
- name: Run API Tests
run: |
pytest tests/unit/test_task_routes.py \
tests/unit/test_expand_api.py \
tests/integration/test_api.py \
-m "not e2e" -v
```
### 本地开发验证流程
```bash
# 1. 改完代码后先跑兼容性验证
bash tests/scripts/verify_api_compat.sh
# 2. 跑全量测试
pytest -m "not e2e" -v
# 3. 跑新增专项测试
pytest tests/unit/test_task_routes.py tests/unit/test_expand_api.py -v
```
---
## 7. 测试覆盖矩阵
| 设计文档章节 | 测试文件 | 测试类 | 用例数 |
|-------------|---------|--------|--------|
| §2 路由拆分(兼容性) | verify_api_compat.sh | — | 1 |
| §3.1 基本详情 | test_task_routes.py | TestTaskDetailRoutes | 2 |
| §3.2 搜索 | test_task_routes.py | TestTaskListRoutes | 5 |
| §3.3 动作路由 | test_task_routes.py | TestTaskActionRoutes | 5 |
| §3.4 创建 | test_task_routes.py | TestTaskCreateRoute | 1 |
| §4 expand comments | test_expand_api.py | TestExpandComments | 2 |
| §4 expand events | test_expand_api.py | TestExpandEvents | 1 |
| §4 expand 全量 | test_expand_api.py | TestExpandFullResources | 3 |
| §4 expand 组合 | test_expand_api.py | TestExpandCombinations | 4 |
| §5.2 关联回归 | test_api.py | TestRelationRoutesRegression | 10 |
| **合计** | | | **34** |
@@ -0,0 +1,808 @@
# §19 Skill 生命周期管理 + 经验闭环四阶段设计
> 作者:庞统士元
> 日期:2026-06-18v2.0
> 状态:方案待确认
> 前置:§14 Task 五层架构、§16 知识注入四层体系
## 变更摘要(v2.0
| 变更项 | 原设计 | 新设计 | 理由 |
|--------|--------|--------|------|
| 蒸馏频率 | 庞统每天一次 | **双层 daily**:各 agent 03:00 自蒸馏 + 庞统 05:00 整合 | agent 是自己经验的最佳蒸馏者;庞统负责跨 agent 整合 |
| 蒸馏者 | 庞统一人 | **双层**L1 各 agent + L2 庞统 | 消除蒸馏者偏差;经验是 per-agent 的 |
| .learnings/ | DISCOVER 数据源之一 | **废弃**。JSONL 是唯一数据源 | 信息冗余;agent 执行中不应分心写 .learnings/ |
| 三重验证 | 跨任务复现 + 生成力 + 排他性 | **Recurrence-Count 机制**(融合 self-improvement skill | ≥3 次自动触发提升,比主观判断更客观 |
| Skill 数量 | 未明确 | **一个 skillskill-management** + references/ 四阶段 | 减少上下文开销;DISCOVER/IMPROVE 是 cron 场景不需要独立 skill description |
| self-improvement skill | 未提及 | **废弃**。优势融合到 DISCOVER 输出格式 | 职责重叠;统一为单一闭环 |
| Skill 存放 | 未区分 | **per-agent 目录 + 公共目录** | agent 专属经验不污染其他 agent 上下文 |
## 1. 背景
moziplus v2.0 的 P4 剩余两项:
- T7 C3Skill 生命周期管理(draft → active → deprecated
- T7 C5:经验闭环 IMPROVE 阶段(DISCOVER → DISTILL → APPLY → IMPROVE 中的最后一步)
### 当前实现状态
| 组件 | 状态 | 问题 |
|------|------|------|
| `SkillRegistry`skill_system.py | 死代码 | 只有 register/match 方法,从未被外部调用 |
| `SkillExecutor`skill_system.py | 死代码 | 从未被外部调用 |
| `ExperienceDistiller`experience.py | 空转 | ticker 调用时没传 review_result 和 outputs,蒸馏函数收到 None 直接返回空 |
| `ExperienceStore`experience.py | 空转 | experiences 目录全部为空 |
| `experiences` 表(db.py | 未使用 | 代码用 jsonl 文件不用 DB 表 |
| Skill 生命周期 | 缺失 | 只有 enabled bool,无 draft/active/deprecated 状态流转 |
**结论**:现有的 experience.py 和 skill_system.py 需要重新设计,不是修补能解决的。
### 实际运行的知识体系
实际的 Skill 发现和加载走的是 **openclaw 原生 skill 机制**
- openclaw 扫描 skills 目录 → 生成 `<available_skills>` 列表注入 system prompt
- Agent 按 description 匹配 → `read` SKILL.md → 按内容执行
- moziplus 的 SkillRegistry/SkillExecutor 完全不参与
因此本设计**不重建 moziplus 的 skill 引擎**,而是基于 openclaw 原生机制构建。
## 2. 设计目标
1. 经验从「发现→蒸馏→应用→改善」形成完整闭环
2. Skill 有明确的生命周期管理(draft → active → deprecated
3. 产物统一为 Skill,不再有 experiences.jsonl / .learnings/ 等中间形态散落各处
4. 追踪 Skill 引用情况,支撑淘汰决策
5. 充分利用 openclaw 已有的 skill_workshop 工具和 skill 加载机制
6. **每个 agent 是自己经验的最佳蒸馏者**——经验 per-agent,精益求精
## 3. 核心设计决策
| # | 决策 | 理由 | 参考 |
|---|------|------|------|
| D1 | 统一产物:Skill-only | 不再有中间形态散落各处。Hermes 只有 Skill + Memory 两种载体,没有第三种 | Hermes skill_manage + memory_tool |
| D2 | 生命周期通过 skill_workshop 管理 | OpenClaw 已有 pending → applied → rejected → quarantined 生命周期 | OpenClaw skill_workshop 工具 |
| D3 | 蒸馏频率:双层 daily | L1 各 agent 每天 03:00 自蒸馏;L2 庞统每天 05:00 整合。有距离感的蒸馏优于即时记录 | self-improvement skill daily review;主公确认 |
| D4 | 蒸馏者:双层(各 agent + 庞统) | L1 每个 agent 蒸馏自己的经验(自己最准);L2 庞统负责跨 agent 共性识别 + draft 审查 | Hermes skill_manage:「每个将军都应建立自己的 Skill 库」 |
| D5 | 二级蒸馏抽象为根因模式 | 不固化在特定技术细节。description 描述「问题模式」而非「技术症状」 | Superpowers writing-skillsdescription = when not how |
| D6 | 废弃 .learnings/ 作为数据源 | JSONL 已包含完整信息(工具调用、推理过程、错误输出)。.learnings/ 只是重复抄写,且打断 agent 执行流 | DISCOVER 统一采集;主公确认 |
| D7 | 只创建一个 skillskill-management | 四阶段的详细操作放到 references/ 目录。DISCOVER/IMPROVE 是 cron 场景不需要独立 skill description 常驻上下文 | moziplus skill-engineering practices §4:组合模式 |
## 4. L4 知识层:Skill Workshop
在现有 L0-L3 四层知识体系上新增 L4:
| 层级 | 名称 | 内容 | 加载方式 | 已有 |
|------|------|------|---------|------|
| L0 | 注入式上下文 | MEMORY.md / TOOLS.md | 每次 session 启动 | ✅ |
| L1 | 确定性规则 | SOUL.md / AGENTS.md | 每次 session 启动 | ✅ |
| L2 | 任务上下文 | BootstrapBuilder PromptSection | 按 task_type 注入 | ✅ |
| L3 | 按需 Skill | openclaw skills | description 匹配 → agent read | ✅ |
| **L4** | **Skill 生命周期** | **skill_workshop** | **draft → active → deprecated 管理** | **新增** |
L4 不是一个 prompt 层,而是 Skill 的**管理层**——负责 Skill 的创建、验证、应用、追踪、淘汰。
## 5. DISCOVER 阶段(双层)
### 5.1 L1 各 agent 自蒸馏(每天 03:00
每个 agent 的 cron 扫描**自己当天**的 session JSONL,识别信号,蒸馏为 draft proposal。
**数据源(1 个)**
| 数据源 | 位置 | 包含什么 |
|--------|------|---------|
| 自己的 Session JSONL | ~/.openclaw/agents/<agent_id>/sessions/*.jsonl | 当天完整思考过程、工具调用、错误恢复、用户对话 |
**不需要**扫描黑板/Gitea/Mail 等——那是 L2 庞统的职责。L1 聚焦自己的经验。
**信号识别(5 类高价值信号)**
| 信号类型 | 从哪发现 | 识别特征 |
|---------|---------|---------|
| 失败模式 | task failed、CI failed、review rejected | 有明确的失败原因 |
| 重复问题 | 跨多个任务出现同类问题 | 同关键词出现 ≥2 次 |
| 决策转折 | rebuttal comment、需求澄清、主公纠正 | 原方向被推翻或修正 |
| 新实践 | 设计文档新增、wiki-vault 新页面 | 之前没有的知识 |
| 知识缺口 | agent 表达不确定、查不到的东西 | 查不到/不确定的东西 |
**输出**draft skill proposal(提交到 skill_workshoppending 状态)
### 5.2 L2 庞统整合(每天 05:00
庞统的 cron 在 L1 全部完成后执行,扫描全量数据源 + 审查所有 L1 draft proposal。
**数据源(全量)**
| 数据源 | 位置 | 包含什么 |
|--------|------|---------|
| 黑板 tasks | 各项目 blackboard.db | 任务生命周期:创建、分配、执行、完成/失败 |
| 黑板 reviews | reviews 表 | 审查结论 + 逐步骤 verdict + suggestions |
| 黑板 comments | comments 表 | @mention 讨论、rebuttal 推理、action_report |
| 黑板 outputs | outputs 表 | 任务产出物内容 |
| 黑板 events | events 表 | 状态变更、guardrail 拦截、异常检测 |
| Gitea Issues/PRs | Gitea API | 问题报告、diff、review 评论 |
| Gitea CI | Gitea Actions | lint/test/build 成功/失败 |
| Mail | mail API | 跨 agent 通信、讨论推理过程 |
| **所有 agent 的 Session JSONL** | ~/.openclaw/agents/*/sessions/ | 全团队完整思考过程 |
| MEMORY.md | 各 agent workspace | 长期记忆、已有经验教训 |
| knowledge-gaps.md | wiki-vault/_meta/ | 知识缺口 |
| **L1 draft proposals** | skill_workshop pending | 各 agent 当天提交的 draft |
**核心职责**
a. **跨 agent 共性模式识别**:张飞和关羽都在类似场景踩坑 → 合并为共享 Skill
b. **审查 L1 draft proposals**
- APPROVE:质量达标的个人经验 → 变 active(仅作者 agent 可见)
- MERGE:跨 agent 共性 → 合并为共享 Skill(所有 agent 可见)
- REJECT:质量不够(附原因,agent 看到反馈后改进)
c. **全局提升**:高确定性/高频率经验 → 提升到 AGENTS.md 规则(所有 agent 强制注入)
### 5.3 去重
同一事件在多个数据源出现(CI 失败 → toolchain task → mail → comment 讨论),按时间窗口 + 关键词去重,保留信息量最大的那条。
跨 agent 的同一模式,按 Pattern-Key 去重,合并为共享信号。
### 5.4 输出格式(融合 self-improvement skill 结构化字段)
每条候选信号包含:
```
信号类型 | 来源(task_id / PR / review / session| 时间 | 简述(≤100 字)
ID: SIG-YYYYMMDD-XXX
Priority: low | medium | high | critical
Status: pending | in_progress | resolved | promoted
See Also: SIG-YYYYMMDD-XXX(关联信号)
Recurrence-Count: N(同一模式出现次数)
Pattern-Key: category.subcategory(稳定去重键,如 sync.field_mapping
```
**字段说明**(汲取自 self-improvement skill):
| 字段 | 用途 | 借鉴来源 |
|------|------|---------|
| ID | 唯一标识,便于交叉引用 | self-improvement logging format |
| Priority | 优先级排序,critical/high 优先处理 | self-improvement priority guidelines |
| Status | 生命周期跟踪 | self-improvement status lifecycle |
| See Also | 关联相似信号,发现共性模式 | self-improvement recurring pattern detection |
| Recurrence-Count | 同一模式出现次数,≥3 触发自动提升 | self-improvement recurring pattern + Skill Extraction Criteria |
| Pattern-Key | 稳定去重键,跨 agent 匹配同一模式 | self-improvement Pattern-Key |
## 6. DISTILL 阶段
### 6.1 核心原则:HOW not WHAT
蒸馏的是「怎么做」不是「发生了什么」(nuwa-skill 实践 #5):
```
❌ "PR #83 修复了 event_type 未知的问题"
→ 这是 WHAT,无法复用
✅ "数据消费者与数据生产者解耦时,新增字段必须同步所有生产者的提取逻辑"
→ 这是 HOW,可复用到任何消费者/生产者场景
```
### 6.2 蒸馏产物 = Skill
直接产出 SKILL.md 格式或对现有 Skill 的 patch,提交到 skill_workshop。
**SKILL.md 编写规范**(参考 Superpowers writing-skills):
```yaml
---
name: skill-name
description: Use when [触发条件/问题模式描述],不描述工作流
---
# Skill 标题
## 什么时候用
(具体的触发场景,按问题模式描述,不按技术特定症状)
## 怎么做
(根因分析 + 操作步骤)
## 常见错误
(反模式:什么不该做)
## 来源
evidence:哪些 task/PR/review 提炼了这条经验)
```
**description 关键规则**Superpowers 的核心发现):
- 只描述触发条件(when to use),**绝不描述工作流**how)
- 以「Use when...」开头
- 描述问题模式,不描述技术特定症状
- 原因:测试发现 description 如果总结了工作流,agent 会按 description 执行而跳过读完整 SKILL.md
### 6.3 蒸馏示例
**一级蒸馏**(从具体案例提取):
```yaml
# 案例 1PromptContext event_type 未知
# 案例 2PromptContext from_agent/mail_type 缺失(PR #26 D2
→ 共同根因:消费者/生产者字段同步问题
# 蒸馏为 Skill section(加到 trial-and-error-patterns):
## 消费者/生产者字段同步
**什么时候用**:修改 dataclass 时,如果该 dataclass 由外部 JSON 提取填充
**怎么做**
1. 改 dataclass 定义
2. 检查所有从 JSON 提取字段的代码路径,同步新增提取逻辑
3. 检查所有构造该 dataclass 的调用点,同步新增参数
4. 跑一次构建测试验证字段不为空
**常见错误**:只改 dataclass 不改提取逻辑 → 字段默认值为空 → 运行时不报错但行为异常
```
**二级蒸馏**(从多个一级经验提取通用模式):
如果「消费者/生产者字段同步」经验在 ≥2 个不同场景复现(PromptContext + 其他),验证通过后,可以提升为独立 Skill 或固化到 AGENTS.md 规则。
### 6.4 验证机制(融合 self-improvement Recurrence-Count + Skill Extraction Criteria
从 draft → active 的验证标准:
| 验证维度 | 标准 | 不通过的处理 |
|---------|------|------------|
| Recurrence-Count ≥ 2 | 同一 Pattern-Key 在 ≥2 个不同场景出现过 | 降级为 MEMORY.md 临时记录 |
| 有生成力 | 能给出具体的操作指引 | 丢弃 |
| 有排他性 | 不是「代码要测试」的常识 | 丢弃 |
**提升触发条件**(从 draft 提升为 active Skill,融合 self-improvement Skill Extraction Criteria):
全部满足时触发提升:
- Recurrence-Count ≥ 3(同一模式 30 天内出现 3 次以上)
- 跨 ≥2 个不同任务验证
**时间窗口**Recurrence-Count 以 30 天为窗口,超过 30 天的记录不计入。6 个月内 3 次 vs 1 周内 3 次信号强度不同,30 天窗口确保经验仍然新鲜。
**Skill Extraction 质量 Gate**(汲取自 self-improvement skill):
| 标准 | 描述 |
|------|------|
| Recurring | 有 See Also 链接到 2+ 个相似信号 |
| Verified | Status 是 resolved 且有工作修复 |
| Non-obvious | 需要实际调试才能发现(不是常识) |
| Broadly applicable | 不是项目特定,可跨场景复用 |
### 6.5 质量检查自动化
参考 nuwa-skill quality_check.py,对蒸馏产出做结构化检查:
| 检查项 | 标准 |
|--------|------|
| trigger 是否具体 | 不是「注意代码质量」这种泛泛而谈 |
| action 是否可执行 | 不是「要小心」这种无操作指引 |
| 是否与已有 Skill 重复 | 检查现有 skills 目录中是否已有覆盖 |
| description 是否只含触发条件 | 不包含工作流描述 |
### 6.6 矛盾处理(nuwa-skill 实践 #10
新经验与已有经验冲突时:
- **时间性矛盾**(观点演化)→ 记录演化轨迹,以近期为主
- **领域性矛盾**(不同场景不同规则)→ 分场景记录
- **本质性张力**(价值观内在冲突)→ 标注为「核心张力」,两个版本都保留
**矛盾是特征,不是 Bug。** 强制调和会丢失关键信号。
### 6.7 蒸馏者(双层)
**L1:每个 agent 自己(每天 03:00 cron,各 agent 错开 15 分钟避免资源争用:03:00, 03:15, 03:30, ...**
1. 扫描自己的 session JSONL
2. 用判断力提取根因模式(不是机械提取)
3. 按 SKILL.md 格式产出
4. 提交到 skill_workshoppending proposal
**L2:庞统(每天 05:00 cron**
1. 审查所有 agent 提交的 draft proposalapprove / merge / reject
2. 跨 agent 共性模式识别和合并
3. 高频/高确定性经验提升到 AGENTS.md 规则
未来考虑半自动化(LLM 辅助草案 + agent 审阅确认)。
## 7. APPLY 阶段
### 7.1 统一走 openclaw skill 机制
**不新建 ExperienceSection 或任何 moziplus 自定义注入**。因为产物统一为 Skill,openclaw 已有的机制天然支持:
1. openclaw 扫描 skills 目录 → 生成 `<available_skills>` 列表
2. Agent 按 description 匹配 → `read` SKILL.md
3. Agent 按内容执行
### 7.2 Skill description 编写规范
这是 APPLY 阶段效果好坏的关键。参考 Superpowers writing-skills 的核心发现:
```yaml
# ❌ BAD:描述了工作流,agent 会按 description 执行而跳过读 SKILL.md
description: Use when modifying dataclass — checks all extraction points, runs tests, verifies non-null fields
# ✅ GOOD:只描述触发条件
description: Use when modifying a dataclass that is populated from JSON extraction by another module
# ❌ BAD:太抽象
description: Use for code quality
# ✅ GOOD:描述问题模式
description: Use when a field added to a dataclass appears empty or as default value at runtime
```
### 7.3 渐进式加载
openclaw 已有的机制:
- L1`<available_skills>` 列表(~100 token/skill,只有 name + description
- L2Agent `read` SKILL.md(完整内容)
- L3SKILL.md 内引用的 references/ 文件(按需加载)
### 7.4 Skill 存放位置与可见性
agent 专属经验放到 agent 自己的 workspace skills 目录,全局共享 Skill 放到公共 skills 目录。openclaw 扫描时自动合并。
| Skill 位置 | 谁能看到 | 适用场景 |
|-----------|---------|---------|
| `~/.openclaw/workspace-zhangfei/skills/` | 只有张飞 | 编码模式、个人踩坑经验 |
| `~/.openclaw/workspace-pangtong/skills/` | 只有庞统 | 规划经验、方向把控 |
| `~/.openclaw/workspace-simayi/skills/` | 只有司马懿 | 审查技巧、挑战模式 |
| `~/.sanguo_projects/sanguo_mozi/skills/` | 所有 moziplus agent | 团队共识、协作规范、通用实践 |
**设计原则**
- 个人经验不污染其他 agent 上下文(张飞的编码坑不需要司马懿看到)
- 共性经验自动共享(庞统 MERGE 后放到公共目录)
- openclaw 原生机制天然支持(扫描时合并所有 skills 目录)
## 8. IMPROVE 阶段
### 8.1 Skill 自我修补
参考 Hermes skill_manage 的设计哲学:
> "If you used a skill and hit issues not covered by it, patch it immediately."
> "Skills that aren't maintained become liabilities."
Agent 使用 Skill 时发现问题(缺步骤、过时信息、命令变更)→ 立即通过 skill_workshop 提交 revise proposalpatch)。
这不需要定时任务,靠 agent 的主动维护。关键是在 agent 的 prompt 中注入这条规则(SOUL.md 或 AGENTS.md)。
### 8.2 引用追踪
**设计原则**:不追求精确归因,做时间维度的信号采集。
| 信号 | 采集方式 | 可信度 |
|------|---------|--------|
| Skill 最近被 read 的时间 | 扫描 session JSONL 中 `"tool":"read"` + SKILL.md 路径 | 中 |
| Skill 在 available_skills 中被注入 | 扫描 JSONL 中 available_skills 列表 | 中(被注入但未必被用) |
| Agent 在输出中提及了 skill name | grep skill name in assistant messages | 高(主动提到说明确实用了) |
| Skill 文件最近修改时间 | git log / 文件 mtime | 高 |
**采集频率**:每周一次 cron,扫描过去 7 天的所有 session JSONL。
### 8.3 淘汰机制
**决策流程**
```
30 天无引用信号
→ 生成淘汰候选报告(庞统审阅)
→ 确认淘汰 → skill_workshop quarantine
→ 保留观察 → 标注,下轮再查
→ 更新后保留 → 修改 description / 内容,重置计时
```
**注意**openclaw 本身的 skill~/.openclaw/plugin-skills/ 和全局 skills)也纳入追踪范围。主公可以据此决定哪些 openclaw skill 可以禁用。
### 8.4 经验提升路径
同一 Skill section 被频繁引用(≥5 次)且经过多次验证 → 考虑提升:
| 提升目标 | 条件 | 效果 |
|---------|------|------|
| 独立 Skill | 足够通用,有自己的触发条件 | 独立 SKILL.mddescription 匹配 |
| AGENTS.md 规则 | 确定性高,适用于所有 agent | L1 确定性注入,强制生效 |
| guardrail | 安全相关,不可违反 | 强制检查 |
### 8.5 反馈到 DISCOVER
IMPROVE 发现的经验缺口(「这条 Skill 不适用 XXX 场景」)→ 写入 knowledge-gaps.md → 成为下一轮 DISCOVER L2 的输入。
## 9. 闭环全景
```
DISCOVER L1(每天 03:00,各 agent cron
数据源:自己的 session JSONL
信号识别:5 类高价值信号
输出:draft skill proposalstructured,带 ID/Priority/Pattern-Key/Recurrence-Count
DISCOVER L2(每天 05:00,庞统 cron
数据源:全量 12 个数据源(含 L1 draft proposals
跨 agent 共性模式识别
审查 draft proposalsapprove / merge / reject
DISTILLL2 庞统执行)
原则:HOW not WHAT(根因模式,不固化技术细节)
验证:Recurrence-Count ≥ 2 + 生成力 + 排他性
提升:Recurrence-Count ≥ 3 → 独立 Skill / AGENTS.md 规则
质量:自动化检查 + 矛盾保留
产物:Skill(通过 skill_workshop 管理)
APPLY(实时,openclaw skill 机制)
匹配:description 匹配 → agent read SKILL.md
执行:agent 按内容执行
自我修补:使用时发现问题 → 立即 revise proposal
per-agent 隔离:专属 Skill 在 agent workspace,共享 Skill 在公共目录
IMPROVE(每周 cron,庞统执行)
追踪:scan JSONL 引用信号
淘汰:30天无引用 → 庞统审查 → quarantine
提升:高频引用 → 独立 Skill / AGENTS.md 规则 / guardrail
反馈:知识缺口 → knowledge-gaps.md → 回到 DISCOVER L2
```
## 10. 与现有实现的关系
| 组件 | 处理方式 |
|------|---------|
| `skill_system.py`SkillRegistry/SkillExecutor | **标记 deprecated,后续清理。** 死代码,实际不参与 skill 发现/加载 |
| `experience.py`ExperienceDistiller/ExperienceStore | **标记 deprecated,后续清理。** 空转代码,experiences 目录全空 |
| `experiences` 表 / `experience_tags` 表(db.py | **保留表结构但不再写入。** 未来如果需要 DB 查询可以重新启用 |
| ticker.py:336-348 经验蒸馏逻辑 | **移除。** 不再逐任务蒸馏,改为双层 daily cron |
| `skill_workshop` 工具 | **核心使用。** 所有 Skill 生命周期通过它管理 |
| openclaw `<available_skills>` 机制 | **核心依赖。** APPLY 阶段完全基于此 |
| **self-improvement skill**`~/.openclaw/workspace/skills/self-improving-agent/` | **废弃。** 其优势(结构化 ID/Status/Priority/See Also/Recurrence-Count)已融合到 DISCOVER 输出格式中。原 skill 文件保留但标记 deprecated |
| **.learnings/ 目录**(各 agent workspace | **废弃。** JSONL 是唯一数据源。目录保留但不再写入新内容(历史数据保留) |
| **SELF_IMPROVEMENT_REMINDER.md** | **废弃。** 规则已融合到 skill-management skill 中 |
## 11. 实现计划
| 步骤 | 内容 | 优先级 | 工作量 |
|------|------|--------|--------|
| S1 | 在 SOUL.md / AGENTS.md 加入 Skill 自我修补规则 + 双层 daily 蒸馏规则 | P0 | L1(改文案) |
| S2 | 创建 skill-management Skill(主 SKILL.md + references/ 四阶段详细操作) | P0 | L2 |
| S3 | 创建各 agent 的 03:00 cron(自蒸馏 L1 | P1 | L1 |
| S4 | 创建庞统的 05:00 cron(整合 + 审查 L2 | P1 | L1-L2 |
| S5 | 实现 IMPROVE cronJSONL 引用追踪 + 淘汰报告(每周) | P2 | L2-L3 |
| S6 | 清理 deprecated 代码(skill_system.py / experience.py / self-improvement skill / SELF_IMPROVEMENT_REMINDER.md | P3 | L1 |
S1 和 S2 已完成(PR #85)。S3-S5 设计见下方 §11A。
## 11A. Cron 配置方案(S3-S5 详细设计)
### 设计决策
**每个 agent 用自己的 agentId 执行 L1 cron**,不由庞统代理。
理由(对照设计目标 D4:
- L1 核心价值是"每个 agent 是自己经验的最佳蒸馏者"——agent 扫描自己的 JSONL,用自己的判断力识别信号
- 如果庞统代理,变成庞统替别人蒸馏,消除不了蒸馏者偏差(D4 要解决的正是这个问题)
- openclaw cron 原生支持 `agentId` 参数 + `sessionTarget: "isolated"`,技术上无障碍
### S3: L1 各 agent 自蒸馏 cron
6 个 agent,各创建一个 isolated cron,错开 15 分钟(和 discover-l1.md 时间表一致):
| Agent | agentId | cron 表达式 | 时区 |
|-------|---------|-----------|------|
| 张飞 | zhangfei-dev | `0 3 * * *` | Asia/Shanghai |
| 关羽 | guanyu-dev | `15 3 * * *` | Asia/Shanghai |
| 赵云 | zhaoyun-data | `30 3 * * *` | Asia/Shanghai |
| 司马懿 | simayi-challenger | `45 3 * * *` | Asia/Shanghai |
| 庞统 | pangtong-fujunshi | `0 4 * * *` | Asia/Shanghai |
| 姜维 | jiangwei-infra | `15 4 * * *` | Asia/Shanghai |
**Cron 配置规范**(每个 L1 cron job):
```json
{
"schedule": { "kind": "cron", "expr": "<时间>", "tz": "Asia/Shanghai" },
"sessionTarget": "isolated",
"agentId": "<agent-id>",
"payload": {
"kind": "agentTurn",
"message": "L1 自蒸馏 cron。请执行:\n1. read ~/.sanguo_projects/sanguo_mozi/skills/skill-management/SKILL.md\n2. read ~/.sanguo_projects/sanguo_mozi/skills/skill-management/references/discover-l1.md\n3. 按 discover-l1.md 步骤执行自蒸馏\n4. 如有信号:蒸馏为 HOW 格式,使用 skill_workshop(action=create) 提交 draft proposal\n5. 如无有价值信号:不产出,这是正常的",
"timeoutSeconds": 600
},
"delivery": { "mode": "none" }
}
```
**设计要点**:
- `sessionTarget: "isolated"`:每次创建临时 session,不污染 main session context
- `delivery.mode: "none"`:L1 不需要通知任何人,proposal 存在 skill_workshop 中即可
- `timeoutSeconds: 600`:10 分钟足够(扫描 JSONL + 蒸馏 + 提交 proposal
- message 指引 read SKILL.md + discover-l1.mdagent 按 references 指南执行,不依赖 memory
### S4: L2 庞统整合审查 cron
庞统的 L2 cron 在所有 L1 完成后执行(最后一个 agent 04:15 开始,L2 设在 05:00):
| 角色 | agentId | cron 表达式 | 时区 |
|------|---------|-----------|------|
| 庞统 | pangtong-fujunshi | `0 5 * * *` | Asia/Shanghai |
**Cron 配置**:
```json
{
"schedule": { "kind": "cron", "expr": "0 5 * * *", "tz": "Asia/Shanghai" },
"sessionTarget": "isolated",
"agentId": "pangtong-fujunshi",
"payload": {
"kind": "agentTurn",
"message": "L2 整合审查 cron。请执行:\n1. read ~/.sanguo_projects/sanguo_mozi/skills/skill-management/references/discover-l2.md\n2. 按 discover-l2.md 步骤执行:\n a. skill_workshop(action=list, status=pending) 获取所有 L1 draft proposals\n b. 全量数据源扫描,识别跨 agent 共性模式\n c. 逐个审查 proposalapprove / merge / reject\n d. 全局提升检查(Recurrence-Count >= 3 的经验提升为规则)\n e. 知识缺口反馈到 knowledge-gaps.md",
"timeoutSeconds": 1200
},
"delivery": { "mode": "none" }
}
```
**设计要点**:
- `timeoutSeconds: 1200`(20 分钟):L2 需要扫描全量数据源 + 审查多个 proposal,时间更长
- 庞统可以访问所有 agent 的 JSONL 和 skill_workshop proposals
### S5: IMPROVE 每周引用追踪 cron
庞统每周日 06:00 执行引用追踪(周日选活动量最低的时段):
| 角色 | agentId | cron 表达式 | 时区 |
|------|---------|-----------|------|
| 庞统 | pangtong-fujunshi | `0 6 * * 0` | Asia/Shanghai |
**Cron 配置**:
```json
{
"schedule": { "kind": "cron", "expr": "0 6 * * 0", "tz": "Asia/Shanghai" },
"sessionTarget": "isolated",
"agentId": "pangtong-fujunshi",
"payload": {
"kind": "agentTurn",
"message": "IMPROVE 每周引用追踪 cron。请执行:\n1. read ~/.sanguo_projects/sanguo_mozi/skills/skill-management/references/improve.md\n2. 按 improve.md 步骤执行:\n a. 扫描过去 7 天所有 agent 的 session JSONL,采集 Skill 引用信号\n b. 生成淘汰候选报告(30 天无引用的 Skill)\n c. 庞统审阅决策:quarantine / 保留观察 / 更新后保留\n d. 经验提升检查(被频繁引用 >= 5 次的 Skill\n e. 反馈知识缺口到 knowledge-gaps.md",
"timeoutSeconds": 1800
},
"delivery": { "mode": "none" }
}
```
**设计要点**:
- `timeoutSeconds: 1800`(30 分钟):全量 JSONL 扫描是最重的操作
- 每周一次频率足够——Skill 引用变化不会很快
- 淘汰决策通过 skill_workshop quarantine 执行,提升决策通过手动编辑 AGENTS.md
### Cron 创建方式
使用 openclaw cron 工具创建。**不是 moziplus 管理**,而是直接在 openclaw 层面配置。
创建顺序:先 S3(L1),再 S4(L2),最后 S5IMPROVE)。
创建后验证:`cron(action=list)` 确认所有 job 存在且 enabled=true。
### 去重和幂等
每个 cron job 的 name 包含 agent 名,避免命名冲突:
- `l1-distill-zhangfei`
- `l1-distill-guanyu`
- `l1-distill-zhaoyun`
- `l1-distill-simayi`
- `l1-distill-pangtong`
- `l1-distill-jiangwei`
- `l2-review-pangtong`
- `improve-weekly-pangtong`
创建前先 `cron(action=list)` 检查同名 job 是否已存在,避免重复创建。
## 11B. 一致性偏差修复清单(S6 补充)
§19 设计-实现一致性检查(2026-06-18)发现以下偏差,列入 S6 一并修复:
| # | 偏差 | 严重度 | 修复方式 |
|---|------|--------|--------|
| B4 | ticker.py:336-348 ExperienceDistiller 调用未移除 | 中 | 移除 experience_distiller 参数和调用,日志改为 debug 级空转提示 |
| B5 | skill_system.py / experience.py 未标记 deprecated | 低 | 文件头部加 `# DEPRECATED — §19 重设计,不再参与 skill 发现/加载` 注释 |
| B6 | SELF_IMPROVEMENT_REMINDER.md 引用残留 | 低 | AGENTS.md 中已标注废弃,但 system prompt 仍注入。从 workspace 文件列表中移除该文件 |
**B4 修复细节**
ticker.py 构造函数 `__init__` 接受 `experience_distiller` 参数(默认 None),tick() 中第 336-348 行有条件调用。修复方式:
- 保留参数(向后兼容),但条件块内加 `logger.debug("ExperienceDistiller deprecated per §19, skipping")` 后直接 return
- 不删除代码(P3 级清理时再做物理删除)
**B6 修复细节**
当前 Project Context 中注入了 `SELF_IMPROVEMENT_REMINDER.md` 的内容。该文件在 workspace-pangtong 中已不存在(被删),但 system prompt 模板仍引用它。修复方式:确认文件不存在即可——openclaw 会跳过不存在的注入文件。实际已无影响,标注为 resolved。
## 12. wiki-vault / 知识库参考实践映射
| 设计决策 | 参考来源 | 核心借鉴 |
|---------|---------|---------|
| 统一产物 Skill-only | Hermes skill_manage + memory_tool | 只有 Skill 和 Memory 两种载体 |
| HOW not WHAT | nuwa-skill 实践 #5 | 蒸馏思维方式不是知识内容 |
| description = when not how | Superpowers writing-skills | description 只描述触发条件 |
| 质量检查自动化 | nuwa-skill quality_check.py | 结构化检查代替主观判断 |
| 矛盾处理 | nuwa-skill 实践 #10 | 矛盾是特征不是 Bug |
| Skill 自我修补 | Hermes skill_manage schema | 使用时发现问题立即 patch |
| 闭环学习循环 | 知识管理体系实践 #1 | DISCOVER→DISTILL→APPLY→IMPROVE |
| Experience→Skill 延迟转化 | moziplus 经验实践 #2 | 多次验证后才固化 |
| Skill 生命周期 draft→active→deprecated | OpenClaw skill_workshop | pending→applied→rejected→quarantined |
| 棘轮机制 | moziplus 经验实践 #2 | 经验只能改进不能退化 |
| 优雅降级 | nuwa-skill 实践 #17 | 信息不足时不要强行蒸馏 |
| 迭代上限 | nuwa-skill 实践 #18 | 最多 2 轮验证,不无限打磨 |
| **双层 daily 蒸馏** | self-improvement skill daily review | 有距离感的蒸馏优于即时记录 |
| **结构化信号格式** | self-improvement skill logging format | ID/Status/Priority/See Also/Recurrence-Count |
| **Recurrence-Count 验证** | self-improvement skill recurring pattern detection | ≥3 次自动触发提升,比主观判断更客观 |
| **Skill Extraction Criteria** | self-improvement skill extraction | Recurring + Verified + Non-obvious + Broadly applicable |
| **per-agent Skill 目录** | Hermes skill_manage + self-improving-agent practice §5 | 每个 agent 建立自己的 Skill 库 |
| **废弃 .learnings/** | DISCOVER 统一采集 | JSONL 是唯一数据源,避免信息冗余 |
| **组合模式(主 skill + references** | moziplus skill-engineering practices §4 | Skill 之间通过产出物松耦合传递 |
## 13. 部署目录结构
### 13.1 openclaw skill 加载优先级
OpenClaw 按 6 级优先级扫描 skill 目录,同名 skill 高优先级覆盖低优先级:
| 优先级 | 来源 | 路径 | 可见性 |
|--------|------|------|--------|
| 1 — 最高 | Workspace skills | `<workspace>/skills` | 只对该 agent |
| 2 | Project agent skills | `<workspace>/.agents/skills` | 只对该 workspace 的 agent |
| 3 | Personal agent skills | `~/.agents/skills` | 所有 agent |
| 4 | Managed / local skills | `~/.openclaw/skills` | 所有 agent |
| 5 | Bundled skills | 随安装包(`/opt/homebrew/.../openclaw/skills/` | 所有 agent |
| 6 — 最低 | Extra dirs + plugin skills | `skills.load.extraDirs` + `~/.openclaw/plugin-skills/` | 所有 agent |
### 13.2 skill-management Skill 目录结构
放在公共目录(`~/.sanguo_projects/sanguo_mozi/skills/`),所有 moziplus agent 可见:
```
~/.sanguo_projects/sanguo_mozi/skills/skill-management/
├── SKILL.md # 主 Skill:综述 + 核心原则 + 各阶段职责摘要
├── references/
│ ├── discover-l1.md # L1 各 agent 自蒸馏详细操作(03:00 cron 读这个)
│ ├── discover-l2.md # L2 庞统整合详细操作(05:00 cron 读这个)
│ ├── distill.md # DISTILL 阶段详细操作(蒸馏规范 + 验证标准)
│ ├── apply.md # APPLY 阶段说明(openclaw 原生机制,简短)
│ └── improve.md # IMPROVE 阶段详细操作(引用追踪 + 淘汰 + 提升)
└── assets/
├── templates/
│ ├── skill-template.md # SKILL.md 标准模板
│ └── signal-format.md # 信号输出格式模板(ID/Priority/Pattern-Key
└── checklists/
└── quality-check.md # 质量检查清单
```
**为什么放公共目录**:所有 agent 都需要触发这个 skillDISCOVER L1 时各 agent 按 description 匹配 → read SKILL.md → 再按需 read references/)。DISCOVER/IMPROVE 是 cron 场景,cron payload 中直接指定 `read references/xxx.md` 按内容执行。
**为什么不拆分为独立 skill**5 个 skill = 5 条 description 常驻上下文(~500-800 token)。其中 DISCOVER 和 IMPROVE 是 cron 触发不是 agent 按描述触发,不需要常驻 description。用 references/ 按需加载更省上下文。
### 13.3 Cron 产出流转路径
```
L1 产出(各 agent 03:00
↓ skill_workshop createpending proposal
↓ 存储:skill_workshop 内部管理(~/.openclaw/workspace-<agent>/.skill-workshop/
L2 审查(庞统 05:00
↓ skill_workshop list → inspect → 决策
├─ APPROVE(个人经验,质量达标)
│ → skill_workshop apply
│ → 写入:~/.openclaw/workspace-<agent>/skills/<skill-name>/SKILL.md
│ → 仅该 agent 可见(workspace skill,优先级 1
├─ MERGE(跨 agent 共性)
│ → 合并多个 proposal 为共享 Skill
│ → skill_workshop apply 到庞统 workspace,然后 cp/symlink 到公共目录
│ → 写入:~/.sanguo_projects/sanguo_mozi/skills/<skill-name>/SKILL.md
│ → 所有 agent 可见(extra dir,优先级 6
│ → 清理:MERGE 后通知各 agent quarantine workspace 中的同名 draft
│ ⚠️ skill_workshop 只能写 workspace skills,不能写 extraDir。
│ MERGE 流程的实际写入方式:庞统在 workspace apply 后,
│ 手动 cp 到公共目录(或配置 skills.load.allowSymlinkTargets 用 symlink)。
├─ REJECT(质量不够)
│ → skill_workshop reject(附原因)
│ → agent 在下次 L1 蒸馏时看到反馈
└─ PROMOTE(高确定性,提升为确定性规则)
→ 手动写入 AGENTS.md / SOUL.md / TOOLS.md
→ 所有 agent 强制注入(L1 确定性规则层)
```
**关键设计**APPROVE 写入 per-agent workspace(优先级 1,最高),MERGE 写入公共目录(优先级 6,最低)。如果同名 skill 在两边都有,workspace 版本覆盖公共版本——agent 可以有自己改进过的版本。
### 13.4 Per-agent Skill 目录
各 agent workspace 下的 skills 目录(目前不存在,L2 审查 APPROVE 后由 skill_workshop 自动创建):
```
~/.openclaw/workspace-zhangfei/skills/ # 张飞的个人经验 Skill
~/.openclaw/workspace-guanyu/skills/ # 关羽的个人经验 Skill
~/.openclaw/workspace-zhaoyun/skills/ # 赵云的个人经验 Skill
~/.openclaw/workspace-simayi/skills/ # 司马懿的个人经验 Skill
~/.openclaw/workspace-pangtong/skills/ # 庞统的个人经验 Skill
~/.openclaw/workspace-jiangwei/skills/ # 姜维的个人经验 Skill
```
**适用场景**
- 张飞的编码踩坑模式 → 只有张飞需要,不污染其他 agent 上下文
- 司马懿的审查技巧 → 只有司马懿需要
- 庞统的规划经验 → 只有庞统需要
### 13.5 Proposal 中间产物存储
```
~/.openclaw/workspace-<agent>/.skill-workshop/
├── proposals/
│ ├── <proposal-id>/
│ │ ├── PROPOSAL.md # 草案内容
│ │ ├── metadata.json # 状态、hash、scanner state
│ │ └── support-files/ # 附带的 references/assets
│ └── ...
├── applied/ # 已 apply 的 proposal 归档
├── rejected/ # 已 reject 的 proposal 归档
└── quarantined/ # 已 quarantine 的 proposal 归档
```
**注意**proposal 存储由 skill_workshop 内部管理,不需要手动操作。首次使用 skill_workshop 时自动创建 `.skill-workshop/` 目录。庞统 L2 cron 通过 `skill_workshop list`(查看所有 agent 的 pending proposal+ `skill_workshop inspect`(查看具体内容)+ `skill_workshop apply/reject/quarantine`(执行决策)完成审查。
### 13.6 全景目录结构
```
# ━━━━━━━ Skill 来源(按 openclaw 优先级) ━━━━━━━━
# P1: Per-agent workspace skills(个人经验,L2 APPROVE 后写入)
~/.openclaw/workspace-<agent>/skills/<skill-name>/SKILL.md
# P4: Managed / local skills(保留,目前为空)
~/.openclaw/skills/
# P5: Bundled skillsopenclaw 自带,不动)
/opt/homebrew/lib/node_modules/openclaw/skills/
# P6: Extra dirs + plugin skills
~/.sanguo_projects/sanguo_mozi/skills/ # moziplus 团队共享 Skill
├── skill-management/ # ← §19 核心 Skill
│ ├── SKILL.md
│ ├── references/{discover-l1, discover-l2, distill, apply, improve}.md
│ └── assets/{templates, checklists}/
├── blackboard-executor/ # 现有
├── blackboard-reviewer/ # 现有
├── trial-and-error-patterns/ # 现有(经验会追加到这里)
└── ...(其他现有 skill)
~/.openclaw/plugin-skills/ # plugin Skillfeishu 等)
# ━━━━━━━ Cron 产出流转 ━━━━━━━━
# L103:00 各 agent
# 输入:~/.openclaw/agents/<agent_id>/sessions/*.jsonl
# 产出:skill_workshop create → proposalpending
# 存储:~/.openclaw/workspace-<agent>/.skill-workshop/proposals/
# L205:00 庞统)
# 输入:全量数据源 + 所有 pending proposals
# 审查:skill_workshop list → inspect → apply/merge/reject
# 产出:
# APPROVE → ~/.openclaw/workspace-<agent>/skills/<skill-name>/per-agent
# MERGE → ~/.sanguo_projects/sanguo_mozi/skills/<skill-name>/(共享)
# REJECT → proposal 归档到 rejected/
# PROMOTE → 手动写入 AGENTS.md / SOUL.md / TOOLS.md
# IMPROVE(每周 庞统)
# 输入:过去 7 天所有 agent 的 session JSONL
# 产出:淘汰候选报告 → skill_workshop quarantine
# ━━━━━━━ 废弃的目录(保留历史,不再写入) ━━━━━━━━
# .learnings/ — 不再写入
~/.openclaw/workspace-*/.learnings/
# self-improvement skill — 不再激活
~/.openclaw/workspace/skills/self-improving-agent/
# SELF_IMPROVEMENT_REMINDER.md — 废弃
# 规则已融合到 skill-management skill 中
```
+66
View File
@@ -0,0 +1,66 @@
---
name: skill-management
description: "Use when managing skill lifecycle through the DISCOVER-DISTILL-APPLY-IMPROVE loop, when doing daily experience distillation, or when reviewing/auditing skill proposals."
---
# Skill Management — 经验闭环 + Skill 生命周期
四阶段闭环:DISCOVER → DISTILL → APPLY → IMPROVE。双层 daily 蒸馏架构。
## 什么时候用
- **L1 自蒸馏**(每天 03:00,各 agent):扫描自己的 session JSONL,蒸馏自己的经验 → 提交 draft proposal
- **L2 整合审查**(每天 05:00,庞统):扫描全量数据源 + 审查所有 L1 draft → approve/merge/reject
- **IMPROVE**(每周,庞统):追踪 Skill 引用情况,淘汰 30 天无引用的 Skill
- **自我修补**(实时,任何 agent):使用 Skill 时发现问题 → 立即 revise proposal
详细操作步骤见 references/ 目录,按当前阶段 `read` 对应文件。
## 核心原则
1. **统一产物 Skill-only**:产物只有 Skillskill_workshop 管理)和 MemoryMEMORY.md),不再有 .learnings/ 等中间形态
2. **HOW not WHAT**:蒸馏「怎么做」不是「发生了什么」。描述问题模式,不固化技术细节
3. **description = when not how**Skill 的 description 只描述触发条件,不描述工作流
4. **双层蒸馏**:L1 各 agent 自己蒸馏(自己最准);L2 庞统负责跨 agent 共性识别 + 审查
5. **矛盾是特征不是 Bug**:保留矛盾,标注类型(时间性/领域性/本质性),不强制调和
## 四阶段速查
| 阶段 | 谁 | 何时 | 做什么 | 详细文档 |
|------|---|------|--------|---------|
| DISCOVER L1 | 每个 agent | 03:00(错开 15min | 扫描自己 JSONL → 蒸馏 → draft proposal | `references/discover-l1.md` |
| DISCOVER L2 | 庞统 | 05:00 | 全量扫描 + 审查 draft → approve/merge/reject | `references/discover-l2.md` |
| DISTILL | L1 各 agent + L2 庞统 | 同 DISCOVER | 提取根因模式,按 SKILL.md 格式产出 | `references/distill.md` |
| APPLY | openclaw 原生 | 实时 | description 匹配 → read SKILL.md → 执行 | `references/apply.md` |
| IMPROVE | 庞统 | 每周 | JSONL 引用追踪 + 淘汰 + 提升 | `references/improve.md` |
## 验证标准(Recurrence-Count 机制)
从 draft → active
| 维度 | 标准 | 不通过 |
|------|------|--------|
| Recurrence-Count ≥ 2 | 同一 Pattern-Key 在 ≥2 个场景出现 | 降级为 MEMORY.md |
| 有生成力 | 能给出具体操作指引 | 丢弃 |
| 有排他性 | 不是常识 | 丢弃 |
提升触发(全部满足):30 天内 ≥3 次 + 跨 ≥2 个任务。
## 自我修补规则
使用 Skill 时发现缺步骤、过时信息、命令变更 → **立即** 通过 skill_workshop 提交 revise proposal。不等定时任务,不等到下次 review。
## 常见错误
| 错误 | 后果 | 正确做法 |
|------|------|---------|
| 蒸馏 WHAT 不 HOW | 经验无法复用 | 描述根因模式 |
| description 包含工作流 | Agent 跳过读完整 SKILL.md | description 只描述触发条件 |
| 缺少 Recurrence-Count | 偶发问题被固化 | 必须 ≥2 次才提升 |
| 强制调和矛盾 | 丢失关键信号 | 保留矛盾,标注类型 |
| skill_workshop 写公共目录 | 操作失败 | skill_workshop 只能写 workspace,公共目录用 cp/symlink |
## 来源
- 设计文档:`docs/design/19-skill-lifecycle-and-experience-loop.md` v2.0
- 参考实践:Hermes skill_manage、nuwa-skill、Superpowers writing-skills、self-improvement skill
@@ -0,0 +1,36 @@
---
name: quality-check
description: "Skill 蒸馏产出质量检查清单"
---
# 质量检查清单
蒸馏产出提交前,逐条检查:
## 结构检查
- [ ] frontmatter 有 name 和 description
- [ ] description 以「Use when...」开头
- [ ] description 只含触发条件,不含工作流
- [ ] 有「什么时候用」章节
- [ ] 有「怎么做」章节
- [ ] 有「常见错误」章节
- [ ] 有「来源」章节
## 内容检查
- [ ] trigger 是否具体(不是「注意代码质量」这种泛泛而谈)
- [ ] action 是否可执行(不是「要小心」这种无操作指引)
- [ ] 蒸馏的是 HOW 不是 WHAT(根因模式,不是事件描述)
- [ ] 没有项目特定的硬编码值
## 验证检查
- [ ] Recurrence-Count ≥ 2(同一模式在 ≥2 个场景出现)
- [ ] 有生成力(能给出具体操作指引)
- [ ] 有排他性(不是常识)
## 重复检查
- [ ] 检查现有 skills 目录中是否已有覆盖
- [ ] 如果是对已有 Skill 的增量更新,使用 revise 而非 create
@@ -0,0 +1,39 @@
---
name: signal-format
description: "DISCOVER 阶段信号输出格式模板"
---
# 信号输出格式
每条候选信号包含:
```
信号类型 | 来源(task_id / PR / review / session| 时间 | 简述(≤100 字)
ID: SIG-YYYYMMDD-XXX
Priority: low | medium | high | critical
Status: pending | in_progress | resolved | promoted
See Also: SIG-YYYYMMDD-XXX(关联信号)
Recurrence-Count: N(同一模式出现次数)
Pattern-Key: category.subcategory(稳定去重键)
```
## 字段说明
| 字段 | 用途 | 示例 |
|------|------|------|
| ID | 唯一标识,便于交叉引用 | SIG-20260618-001 |
| Priority | 优先级排序 | critical: 阻断核心功能; high: 影响常见流程; medium: 有 workaround; low: 边缘场景 |
| Status | 生命周期跟踪 | pending → in_progress → resolved / promoted |
| See Also | 关联相似信号,发现共性模式 | SIG-20260617-003 |
| Recurrence-Count | 同一模式出现次数,≥3 触发自动提升 | 2 |
| Pattern-Key | 稳定去重键,跨 agent 匹配同一模式 | sync.field_mapping |
## 信号类型(5 类)
| 类型 | 识别特征 |
|------|---------|
| 失败模式 | 有明确的失败原因 + 排查过程 |
| 重复问题 | 同关键词出现 ≥2 次 |
| 决策转折 | 原方向被推翻或修正 |
| 新实践 | 之前没有的知识 |
| 知识缺口 | 查不到/不确定的东西 |
@@ -0,0 +1,51 @@
---
name: skill-template
description: "SKILL.md 标准模板 — 蒸馏产出时按此格式编写"
---
# Skill 标准模板
```yaml
---
name: <skill-name>
description: "Use when <触发条件/问题模式描述>"
---
# <Skill 标题>
## 什么时候用
<具体的触发场景,按问题模式描述,不按技术特定症状>
## 怎么做
<根因分析 + 操作步骤>
1. <步骤 1>
2. <步骤 2>
3. <步骤 3>
## 常见错误
<反模式:什么不该做>
- ❌ <错误做法> → <后果>
- ❌ <错误做法> → <后果>
## 来源
<evidence:哪些 task/PR/review 提炼了这条经验>
- task <id>: <简述>
- PR #<num>: <简述>
```
## description 编写要点
- 以「Use when...」开头
- 只描述触发条件(when),**不描述工作流**(how
- 描述问题模式,不描述技术特定症状
- 控制在 1-2 句话
## 质量自检
- [ ] trigger 是否具体(不是「注意代码质量」)
- [ ] action 是否可执行(不是「要小心」)
- [ ] 是否与已有 Skill 重复
- [ ] description 是否只含触发条件
@@ -0,0 +1,34 @@
# APPLY — Skill 应用阶段
## 机制
APPLY 完全基于 openclaw 原生 skill 机制,不需要额外代码:
1. openclaw 扫描 skills 目录 → 生成 `<available_skills>` 列表(只有 name + description
2. Agent 按 description 匹配 → `read` SKILL.md 完整内容
3. Agent 按内容执行
## 渐进式加载
- L1`<available_skills>` 列表(~100 token/skill)— 每次启动注入
- L2Agent `read` SKILL.md — 按需加载
- L3SKILL.md 内引用的 references/ 文件 — 按需加载
## Skill 存放位置与可见性
| 位置 | 可见性 | 优先级 |
|------|--------|--------|
| `~/.openclaw/workspace-<agent>/skills/` | 仅该 agent | 1(最高) |
| `~/.sanguo_projects/sanguo_mozi/skills/` | 所有 moziplus agent | 6(最低) |
workspace 版本覆盖公共版本——agent 可以有自己改进过的版本。
## 自我修补
使用 Skill 时发现问题(缺步骤、过时信息、命令变更)→ **立即** 通过 skill_workshop 提交 revise proposal
```python
skill_workshop(action="revise", proposal_id="<id>", proposal_content="<修改后的内容>")
```
不等定时任务,不等到下次 review。
@@ -0,0 +1,84 @@
# DISCOVER L1 — 各 agent 自蒸馏(每天 03:00
## 你是谁
你是某个 agent(张飞/关羽/赵云/司马懿/庞统/姜维),在每天 03:00 被 cron 唤醒,执行自己的经验蒸馏。
## cron 错开时间
各 agent 错开 15 分钟避免资源争用:
| Agent | 时间 |
|-------|------|
| zhangfei-dev | 03:00 |
| guanyu-dev | 03:15 |
| zhaoyun-data | 03:30 |
| simayi-challenger | 03:45 |
| pangtong-fujunshi | 04:00 |
| jiangwei-infra | 04:15 |
## 操作步骤
### Step 1: 扫描当天 session JSONL
```
输入:~/.openclaw/agents/<your-agent-id>/sessions/*.jsonl
时间范围:过去 24 小时(上次 L1 到现在)
```
重点扫描以下内容:
- `"tool":"exec"` 失败的命令(exit code 非 0
- `"role":"user"` 消息中的纠正(「不对」「错了」「应该是」等)
- `"role":"assistant"` 中的反复返工(同一文件改了 3 次以上)
- task status 变更为 failed 的事件
- review verdict 为 REQUEST_CHANGES 的记录
### Step 2: 信号识别(5 类高价值信号)
| 信号类型 | 识别特征 | 示例 |
|---------|---------|------|
| 失败模式 | 有明确的失败原因 + 排查过程 | 命令报错、CI 失败、review 驳回 |
| 重复问题 | 同关键词在当天出现 ≥2 次 | 反复修改同一段代码、同类错误 |
| 决策转折 | 原方向被推翻或修正 | 主公纠正、需求澄清、rebuttal |
| 新实践 | 之前没有的知识 | 新工具用法、新架构模式 |
| 知识缺口 | 表达不确定、查不到 | 「不确定」「没找到」「推测」 |
### Step 3: 蒸馏(HOW not WHAT
对每个信号,提取根因模式,不是事件描述:
```
❌ "PR #83 修复了 event_type 未知的问题"WHAT,无法复用)
✅ "消费者/生产者字段同步:新增 dataclass 字段时,必须同步所有从 JSON 提取该字段的代码路径"(HOW,可复用)
```
蒸馏规范详见 `references/distill.md`
### Step 4: 产出 draft proposal
对蒸馏后的经验,使用 skill_workshop 提交:
```
skill_workshop(action="create", name="<skill-name>", description="Use when <触发条件>", proposal_content="<SKILL.md 内容>")
```
输出格式(每条信号):
```
信号类型 | 来源(task_id / session| 时间 | 简述(≤100 字)
ID: SIG-YYYYMMDD-XXX
Priority: low | medium | high | critical
Status: pending
Recurrence-Count: N
Pattern-Key: category.subcategory(如 sync.field_mapping
```
### Step 5: 完成
所有 draft proposal 提交后,L1 结束。不需要等待 L2 审查结果(庞统会在 05:00 处理)。
## 注意事项
- 数据源**只有**你自己的 session JSONL,不需要扫描黑板/Gitea/Mail
- 如果当天没有有价值的信号(没踩坑、没被纠正、没新发现),不产出任何 proposal,这是正常的
- 不要为了产出而强行蒸馏——偶发问题降级为 MEMORY.md,不提交 proposal
- 质量优于数量:1 条高质量 proposal 比 5 条流水账有价值
@@ -0,0 +1,118 @@
# DISCOVER L2 — 庞统整合审查(每天 05:00)
## 你是谁
你是庞统,在每天 05:00 被 cron 唤醒,执行跨 agent 整合 + draft proposal 审查。
前提:所有 agent 的 L1 自蒸馏(03:00-04:15)已完成。
## 操作步骤
### Step 1: 获取所有 L1 draft proposals
```
skill_workshop(action="list", status="pending")
```
列出所有 pending 状态的 proposal,检查哪些是今天 L1 产出的。
### Step 2: 全量数据源扫描
扫描以下数据源,识别跨 agent 共性模式:
| 数据源 | 位置 | 关注什么 |
|--------|------|---------|
| 黑板 tasks | 各项目 blackboard.db | task failed、状态异常 |
| 黑板 reviews | reviews 表 | REQUEST_CHANGES verdict + suggestions |
| 黑板 comments | comments 表 | rebuttal 讨论、@mention 争议 |
| 黑板 events | events 表 | guardrail 拦截、异常检测 |
| Gitea Issues/PRs | Gitea API | 新问题、PR review 评论 |
| Gitea CI | Gitea Actions | lint/test/build 失败 |
| Mail | mail API | 跨 agent 讨论、推理过程 |
| 所有 agent JSONL | ~/.openclaw/agents/*/sessions/ | 全团队当天思考过程 |
| MEMORY.md | 各 agent workspace | 已有经验教训 |
| knowledge-gaps.md | wiki-vault/_meta/ | 知识缺口 |
| L1 draft proposals | skill_workshop pending | 各 agent 当天提交 |
### Step 3: 跨 agent 共性模式识别
寻找同一 Pattern-Key 在多个 agent 的 JSONL/proposal 中出现的情况:
```
张飞 SIG-20260618-001: Pattern-Key: sync.field_mapping
关羽 SIG-20260618-002: Pattern-Key: sync.field_mapping
→ 共性信号!Recurrence-Count = 2,可合并为共享 Skill
```
### Step 4: 审查每个 draft proposal
对每个 L1 draft proposal,逐条审查:
```
skill_workshop(action="inspect", proposal_id="<id>")
```
审查维度:
| 维度 | 标准 | 不通过 |
|------|------|--------|
| Recurrence-Count ≥ 2 | 同一 Pattern-Key 在 ≥2 个场景出现 | 降级为 MEMORY.md |
| 有生成力 | 能给出具体操作指引 | 丢弃 |
| 有排他性 | 不是常识 | 丢弃 |
| description 合规 | 只描述触发条件,不含工作流 | 要求 revise |
| trigger 具体 | 不是「注意代码质量」 | 要求 revise |
### Step 5: 执行决策
对每个 proposal 做出决策:
**APPROVE**(个人经验,质量达标):
```python
skill_workshop(action="apply", proposal_id="<id>")
# skill_workshop 自动写入 agent workspace: ~/.openclaw/workspace-<agent>/skills/<skill-name>/
# 仅该 agent 可见
```
**MERGE**(跨 agent 共性):
```python
# 1. 在庞统 workspace apply 合并后的版本
skill_workshop(action="apply", proposal_id="<id>")
# 2. cp 到公共目录(skill_workshop 不能写 extraDir
cp ~/.openclaw/workspace-pangtong/skills/<skill-name>/SKILL.md \
~/.sanguo_projects/sanguo_mozi/skills/<skill-name>/SKILL.md
# 3. 通知各 agent quarantine workspace 中的同名 draft
# 在相关 PR/Issue 中 @agent 说明
```
**REJECT**(质量不够):
```python
skill_workshop(action="reject", proposal_id="<id>", reason="<具体原因>")
# agent 在下次 L1 时看到反馈
```
**PROMOTE**(高确定性经验,提升为规则):
```python
# 手动写入 AGENTS.md / SOUL.md / TOOLS.md 对应区块
# 这不属于 skill_workshop 管理范围
```
### Step 6: 全局提升检查
检查是否有经验达到提升条件(Recurrence-Count ≥ 3 + 跨 ≥2 任务 + 30 天内):
| 提升目标 | 条件 | 效果 |
|---------|------|------|
| 独立 Skill | 足够通用,有自己的触发条件 | 独立 SKILL.md |
| AGENTS.md 规则 | 确定性高,适用于所有 agent | L1 强制注入 |
| guardrail | 安全相关,不可违反 | 强制检查 |
### Step 7: 知识缺口反馈
IMPROVE 发现的经验缺口或 L2 发现的新领域 → 追加到 `knowledge-gaps.md`
## 注意事项
- L2 时间窗口:05:00 执行,确保 L1 全部完成(最后一个 agent 04:15 开始)
- 全量扫描不需要逐行读 JSONL,用 grep 定位关键词再精读匹配段
- MERGE 后必须清理各 agent workspace 的同名 draft(避免覆盖公共版本)
- REJECT 必须附具体原因,帮 agent 改进而非打击
@@ -0,0 +1,137 @@
# DISTILL — 蒸馏规范
## 核心原则:HOW not WHAT
蒸馏的是「怎么做」不是「发生了什么」:
```
❌ "PR #83 修复了 event_type 未知的问题"
→ 这是 WHAT,无法复用
✅ "消费者/生产者字段同步:新增 dataclass 字段时,必须同步所有从 JSON 提取该字段的代码路径"
→ 这是 HOW,可复用到任何消费者/生产者场景
```
## SKILL.md 编写规范
```yaml
---
name: skill-name
description: Use when [触发条件/问题模式描述],不描述工作流
---
# Skill 标题
## 什么时候用
(具体的触发场景,按问题模式描述,不按技术特定症状)
## 怎么做
(根因分析 + 操作步骤)
## 常见错误
(反模式:什么不该做)
## 来源
evidence:哪些 task/PR/review 提炼了这条经验)
```
## description 关键规则
- 只描述触发条件(when to use),**绝不描述工作流**how)
- 以「Use when...」开头
- 描述问题模式,不描述技术特定症状
- 原因:测试发现 description 如果总结了工作流,agent 会按 description 执行而跳过读完整 SKILL.md
### 示例
```yaml
# ❌ BAD:描述了工作流
description: Use when modifying dataclass — checks all extraction points, runs tests
# ✅ GOOD:只描述触发条件
description: Use when modifying a dataclass that is populated from JSON extraction by another module
# ❌ BAD:太抽象
description: Use for code quality
# ✅ GOOD:描述问题模式
description: Use when a field added to a dataclass appears empty or as default value at runtime
```
## 蒸馏示例
**一级蒸馏**(从具体案例提取):
```yaml
# 案例 1PromptContext event_type 未知
# 案例 2PromptContext from_agent/mail_type 缺失(PR #26 D2
→ 共同根因:消费者/生产者字段同步问题
## 消费者/生产者字段同步
**什么时候用**:修改 dataclass 时,如果该 dataclass 由外部 JSON 提取填充
**怎么做**
1. 改 dataclass 定义
2. 检查所有从 JSON 提取字段的代码路径,同步新增提取逻辑
3. 检查所有构造该 dataclass 的调用点,同步新增参数
4. 跑一次构建测试验证字段不为空
**常见错误**:只改 dataclass 不改提取逻辑 → 字段默认值为空 → 运行时不报错但行为异常
```
**二级蒸馏**(从多个一级经验提取通用模式):
如果经验在 ≥2 个不同场景复现,验证通过后,可以提升为独立 Skill 或固化到 AGENTS.md 规则。
## 验证标准
从 draft → active
| 维度 | 标准 | 不通过 |
|------|------|--------|
| Recurrence-Count ≥ 2 | 同一 Pattern-Key 在 ≥2 个场景出现 | 降级为 MEMORY.md |
| 有生成力 | 能给出具体操作指引 | 丢弃 |
| 有排他性 | 不是常识 | 丢弃 |
提升触发(全部满足):30 天内 ≥3 次 + 跨 ≥2 个任务。
## Skill Extraction 质量 Gate
| 标准 | 描述 |
|------|------|
| Recurring | 有 See Also 链接到 2+ 个相似信号 |
| Verified | Status 是 resolved 且有工作修复 |
| Non-obvious | 需要实际调试才能发现 |
| Broadly applicable | 不是项目特定,可跨场景复用 |
## 质量检查
| 检查项 | 标准 |
|--------|------|
| trigger 是否具体 | 不是「注意代码质量」 |
| action 是否可执行 | 不是「要小心」 |
| 是否与已有 Skill 重复 | 检查现有 skills 目录 |
| description 是否只含触发条件 | 不包含工作流描述 |
## 矛盾处理
新经验与已有经验冲突时:
- **时间性矛盾**(观点演化)→ 记录演化轨迹,以近期为主
- **领域性矛盾**(不同场景不同规则)→ 分场景记录
- **本质性张力**(价值观内在冲突)→ 标注为「核心张力」,两个版本都保留
**矛盾是特征,不是 Bug。** 强制调和会丢失关键信号。
## 信号输出格式
每条信号包含:
```
信号类型 | 来源 | 时间 | 简述(≤100 字)
ID: SIG-YYYYMMDD-XXX
Priority: low | medium | high | critical
Status: pending | in_progress | resolved | promoted
See Also: SIG-YYYYMMDD-XXX
Recurrence-Count: N
Pattern-Key: category.subcategory
```
@@ -0,0 +1,70 @@
# IMPROVE — 引用追踪 + 淘汰 + 提升(每周 cron)
## 你是谁
你是庞统,每周执行一次 IMPROVE cron,扫描过去 7 天的所有 session JSONL。
## 操作步骤
### Step 1: 引用追踪
扫描过去 7 天所有 agent 的 session JSONL,采集 Skill 引用信号:
| 信号 | 采集方式 | 可信度 |
|------|---------|--------|
| Skill 被 read 的时间 | grep `"tool":"read"` + SKILL.md 路径 | 中 |
| Skill 在 available_skills 中被注入 | grep available_skills 列表 | 中(注入但未必用) |
| Agent 输出中提及 skill name | grep skill name in assistant messages | 高 |
| Skill 文件最近修改时间 | git log / 文件 mtime | 高 |
### Step 2: 生成淘汰候选报告
对每个 Skill 检查最近 30 天的引用信号:
```
30 天无引用信号
→ 加入淘汰候选列表
```
输出淘汰候选报告:
```
| Skill 名称 | 最后引用时间 | 存放位置 | 建议 |
|-----------|------------|---------|------|
| xxx | 2026-05-15 | 公共目录 | 建议淘汰 |
| yyy | 从未被引用 | 张飞 workspace | 建议淘汰 |
```
### Step 3: 庞统审阅决策
逐条审阅淘汰候选:
- **确认淘汰** → `skill_workshop(action="quarantine", proposal_id="<id>")`
- **保留观察** → 标注,下轮再查
- **更新后保留** → 修改 description / 内容,重置计时
**注意**openclaw 本身的 skill~/.openclaw/plugin-skills/ 和全局 skills)也纳入追踪。报告给主公决定是否禁用。
### Step 4: 经验提升检查
检查是否有 Skill 达到提升条件(被频繁引用 ≥5 次 + 多次验证):
| 提升目标 | 条件 | 效果 |
|---------|------|------|
| 独立 Skill | 足够通用,有自己的触发条件 | 独立 SKILL.md |
| AGENTS.md 规则 | 确定性高,适用于所有 agent | L1 强制注入 |
| guardrail | 安全相关,不可违反 | 强制检查 |
### Step 5: 反馈到 DISCOVER
IMPROVE 发现的经验缺口写入 knowledge-gaps.md
```
- [日期] IMPROVE 发现「<skill-name> 不适用 <场景>」→ 待 DISCOVER 处理
```
成为下一轮 DISCOVER L2 的输入。
## 注意事项
- 不追求精确归因,做时间维度的信号采集
- 淘汰决策由庞统判断,不自动执行
- 提升到 AGENTS.md 的规则需要主公确认(影响所有 agent 的确定性注入)
+73
View File
@@ -0,0 +1,73 @@
"""共享 helper 和常量"""
from typing import Any, Dict
from fastapi import HTTPException
from src.blackboard.operations import Blackboard
from src.blackboard.queries import Queries
from src.blackboard.models import Task
from src.blackboard.registry import ProjectRegistry
from src.utils import get_data_root
# 虚拟项目白名单
_VIRTUAL_PROJECTS = frozenset({"_general", "_mail", "_toolchain"})
def _validate_project(project_id: str) -> str:
"""校验 project_id"""
if project_id in _VIRTUAL_PROJECTS:
return project_id
reg = ProjectRegistry(get_data_root())
if reg.get_project(project_id):
return project_id
raise HTTPException(400, {
"ok": False,
"error": "project_not_found",
"detail": f"Project '{project_id}' is not registered.",
"suggestions": [
f"Register first: POST /api/projects with id='{project_id}'",
"Or use '_general' for tasks without a specific project",
],
})
def _bb(project_id: str) -> Blackboard:
_validate_project(project_id)
return Blackboard(get_data_root() / project_id / "blackboard.db")
def _q(project_id: str) -> Queries:
_validate_project(project_id)
return Queries(get_data_root() / project_id / "blackboard.db")
def _task_to_dict(t: Task) -> Dict[str, Any]:
d = {k: v for k, v in t.__dict__.items() if v is not None}
return d
_KNOWN_AGENT_IDS: list = []
def _init_agent_ids():
"""从配置文件加载 Agent ID 列表"""
global _KNOWN_AGENT_IDS
if _KNOWN_AGENT_IDS:
return
try:
import yaml
import os
cfg_path = os.path.join(os.path.dirname(__file__), "..", "..", "config", "default.yaml")
with open(cfg_path) as f:
cfg = yaml.safe_load(f)
_KNOWN_AGENT_IDS = list(cfg.get("daemon", {}).get("agent_profiles", {}).keys())
except Exception:
_KNOWN_AGENT_IDS = []
def _extract_mentions(text: str) -> list:
"""从文本中自动提取 @agent-id 格式的 mention"""
import re
_init_agent_ids()
candidates = set(re.findall(r'@([a-z][a-z0-9]*(?:-[a-z][a-z0-9]*)+)', text))
return [a for a in candidates if a in _KNOWN_AGENT_IDS]
+240
View File
@@ -0,0 +1,240 @@
"""Task 关联路由 — comments / outputs / decisions / observations / reviews / events / experiences / summary"""
from __future__ import annotations
import json
import os
from typing import Any, Dict, Optional
from fastapi import APIRouter, HTTPException, Query
from src.blackboard.models import Review
from src.blackboard.db import OUTPUT_TYPES
from src.api.shared import (
_bb,
_q,
_extract_mentions,
)
router = APIRouter(prefix="/api/projects/{project_id}", tags=["blackboard"])
# --------------------------------------------------------------------------- #
# Comments
# --------------------------------------------------------------------------- #
@router.get("/tasks/{task_id}/comments")
async def get_comments(project_id: str, task_id: str,
comment_type: Optional[str] = None):
bb = _bb(project_id)
comments = bb.get_comments(task_id, comment_type=comment_type)
return {"comments": [dict(c.__dict__) for c in comments]}
@router.post("/tasks/{task_id}/comments")
async def add_comment(project_id: str, task_id: str, body: Dict[str, Any]):
bb = _bb(project_id)
mentions_raw = body.get("mentions")
comment_body = body["body"]
# #04: 自动从 body 提取 @mention,与显式传的 mentions 取并集
auto_mentions = _extract_mentions(comment_body)
if isinstance(mentions_raw, str):
try:
explicit_mentions = json.loads(mentions_raw)
except Exception:
explicit_mentions = []
elif isinstance(mentions_raw, list):
explicit_mentions = mentions_raw
else:
explicit_mentions = []
merged_mentions = list(set(explicit_mentions + auto_mentions))
cid = bb.add_comment(task_id, body["author"], comment_body,
comment_type=body.get("comment_type", "general"),
mentions=merged_mentions)
if merged_mentions:
bb.record_mentions(cid, task_id, merged_mentions)
# #10: SSE 通知前端黑板有新 comment
try:
from src.api.sse_routes import get_broker
broker = get_broker()
broker.publish_sync("comment_added", {
"project_id": project_id,
"task_id": task_id,
"comment_id": cid,
"author": body["author"],
})
except Exception:
pass
return {"ok": True, "comment_id": cid, "mentions": merged_mentions}
# --------------------------------------------------------------------------- #
# Outputs
# --------------------------------------------------------------------------- #
@router.get("/tasks/{task_id}/outputs")
async def get_outputs(project_id: str, task_id: str):
bb = _bb(project_id)
return {"outputs": [dict(o.__dict__) for o in bb.get_outputs(task_id)]}
@router.post("/tasks/{task_id}/outputs")
async def write_output(project_id: str, task_id: str, body: Dict[str, Any]):
bb = _bb(project_id)
# 字段校验 + Agent-friendly 错误
agent = body.get("agent")
if not agent:
raise HTTPException(422, {
"error": "validation_failed",
"detail": "Missing required field: agent",
"hint": "Provide your agent ID, e.g. 'zhangfei-dev'",
})
# type 字段:接受 type 或 content_type(别名兼容)
output_type = body.get("type") or body.get("content_type")
valid_types = sorted(OUTPUT_TYPES)
if not output_type:
raise HTTPException(422, {
"error": "validation_failed",
"detail": "Missing required field: type",
"valid_values": {"type": valid_types},
"hint": "Use 'type' field. Also accepts 'content_type' as alias.",
})
if output_type not in OUTPUT_TYPES:
raise HTTPException(422, {
"error": "validation_failed",
"detail": f"Invalid type: '{output_type}'",
"valid_values": {"type": valid_types},
})
title = body.get("title")
if not title:
raise HTTPException(422, {
"error": "validation_failed",
"detail": "Missing required field: title",
"hint": "Provide a brief title describing this output",
})
# 内容模式:content(直传)或 content_path(引用)
content = body.get("content")
content_path = body.get("content_path") or body.get("path")
if content and not content_path:
# 内容直传模式:自动写文件
artifacts_dir = os.path.join(
os.path.dirname(bb.db_path), "artifacts", task_id
)
os.makedirs(artifacts_dir, exist_ok=True)
# 安全文件名
safe_name = "".join(
c if c.isalnum() or c in "._-" else "_" for c in title)
if not safe_name:
safe_name = "output"
file_path = os.path.join(artifacts_dir, safe_name)
with open(file_path, "w", encoding="utf-8") as f:
f.write(content)
content_path = file_path
oid = bb.write_output(
task_id, agent, output_type, title,
content_path=content_path,
summary=body.get("summary"),
metadata=body.get("metadata"),
)
return {"ok": True, "output_id": oid}
# --------------------------------------------------------------------------- #
# Decisions
# --------------------------------------------------------------------------- #
@router.get("/tasks/{task_id}/decisions")
async def get_decisions(project_id: str, task_id: str):
bb = _bb(project_id)
return {"decisions": [dict(d.__dict__) for d in bb.get_decisions(task_id)]}
@router.post("/tasks/{task_id}/decisions")
async def add_decision(project_id: str, task_id: str, body: Dict[str, Any]):
bb = _bb(project_id)
did = bb.add_decision(task_id, body["decider"], body["decision"],
body["rationale"],
alternatives=body.get("alternatives"))
return {"ok": True, "decision_id": did}
# --------------------------------------------------------------------------- #
# Observations
# --------------------------------------------------------------------------- #
@router.post("/tasks/{task_id}/observations")
async def add_observation(project_id: str, task_id: str, body: Dict[str, Any]):
bb = _bb(project_id)
oid = bb.add_observation(task_id, body["observer"], body["body"],
severity=body.get("severity", "info"))
return {"ok": True, "observation_id": oid}
# --------------------------------------------------------------------------- #
# Reviews
# --------------------------------------------------------------------------- #
@router.get("/tasks/{task_id}/reviews")
async def get_reviews(project_id: str, task_id: str):
bb = _bb(project_id)
return {"reviews": [dict(r.__dict__) for r in bb.get_reviews(task_id)]}
@router.post("/tasks/{task_id}/reviews")
async def add_review(project_id: str, task_id: str, body: Dict[str, Any]):
bb = _bb(project_id)
review = Review(
id=body["id"], task_id=task_id, reviewer=body["reviewer"],
review_type=body["review_type"], verdict=body["verdict"],
summary=body["summary"], confidence=body.get("confidence"),
round=body.get("round", 1), max_rounds=body.get("max_rounds", 3),
)
bb.add_review(review)
return {"ok": True, "review_id": review.id}
# --------------------------------------------------------------------------- #
# Per-task Events & Experiences
# --------------------------------------------------------------------------- #
@router.get("/tasks/{task_id}/events")
async def get_task_events(project_id: str, task_id: str,
limit: int = Query(50, le=200)):
q = _q(project_id)
return {"events": q.task_events(task_id, limit)}
@router.get("/tasks/{task_id}/experiences")
async def get_task_experiences(project_id: str, task_id: str):
q = _q(project_id)
return {"experiences": q.task_experiences(task_id)}
# --------------------------------------------------------------------------- #
# Global Events
# --------------------------------------------------------------------------- #
@router.get("/events")
async def get_events(project_id: str, limit: int = Query(50, le=200)):
q = _q(project_id)
return {"events": q.recent_events(limit)}
# --------------------------------------------------------------------------- #
# Summary
# --------------------------------------------------------------------------- #
@router.get("/summary")
async def task_summary(project_id: str):
q = _q(project_id)
return {"summary": q.task_summary()}
@@ -1,68 +1,45 @@
"""API 路由 — 黑板 CRUD"""
"""Task 核心路由 — CRUD、状态、归档"""
from __future__ import annotations
import json
import os
import sqlite3
from datetime import datetime
from pathlib import Path
from typing import Any, Dict, Optional
from fastapi import APIRouter, HTTPException, Query
from src.blackboard.operations import Blackboard
from src.blackboard.models import Task, Review
from src.blackboard.queries import Queries
from src.blackboard.db import VALID_STATUSES, OUTPUT_TYPES
from src.blackboard.registry import ProjectRegistry
from fastapi import APIRouter, HTTPException
from src.blackboard.models import Task
from src.blackboard.db import VALID_STATUSES, VALID_TRANSITIONS
from src.utils import get_data_root
from src.api.shared import (
_bb,
_q,
_task_to_dict,
_extract_mentions,
)
router = APIRouter(prefix="/api/projects/{project_id}", tags=["blackboard"])
# 虚拟项目白名单(不需要在 registry 注册)
_VIRTUAL_PROJECTS = frozenset({"_general", "_mail"})
def _validate_project(project_id: str) -> str:
"""校验 project_id,已知项目/虚拟项目放行,未知项目返回 400"""
if project_id in _VIRTUAL_PROJECTS:
return project_id
reg = ProjectRegistry(get_data_root())
if reg.get_project(project_id):
return project_id
raise HTTPException(400, {
"ok": False,
"error": "project_not_found",
"detail": f"Project '{project_id}' is not registered.",
"suggestions": [
f"Register first: POST /api/projects with id='{project_id}'",
"Or use '_general' for tasks without a specific project",
],
})
def _bb(project_id: str) -> Blackboard:
_validate_project(project_id)
return Blackboard(get_data_root() / project_id / "blackboard.db")
def _q(project_id: str) -> Queries:
_validate_project(project_id)
return Queries(get_data_root() / project_id / "blackboard.db")
# --- Tasks ---
# --------------------------------------------------------------------------- #
# Tasks
# --------------------------------------------------------------------------- #
@router.get("/tasks")
async def list_tasks(project_id: str,
status: Optional[str] = None,
assignee: Optional[str] = None,
parent_task: Optional[str] = None):
parent_task: Optional[str] = None,
q: Optional[str] = None):
bb = _bb(project_id)
tasks = bb.list_tasks(
status=status,
assignee=assignee,
parent_task=parent_task)
tasks = bb.list_tasks(status=status, assignee=assignee, parent_task=parent_task)
if q:
q_lower = q.lower()
tasks = [t for t in tasks if q_lower in (t.title or "").lower()]
return {"tasks": [_task_to_dict(t) for t in tasks]}
@@ -74,6 +51,11 @@ async def get_task(project_id: str, task_id: str,
if not task:
raise HTTPException(404, f"Task not found: {task_id}")
result = _task_to_dict(task)
if not expand:
return result
# expand=all: 保持旧格式(list + 聚合字段),向后兼容前端 TaskModal
if expand == "all":
q = _q(project_id)
detail = q.task_detail(task_id)
@@ -90,6 +72,37 @@ async def get_task(project_id: str, task_id: str,
for d in bb.get_decisions(task_id)]
result["events"] = q.task_events(task_id)
result["experiences"] = q.task_experiences(task_id)
return result
# 细粒度 expand: 新格式(comments/events 带 limit + total_count
expand_list = expand.split(",")
q = _q(project_id)
if "comments" in expand_list:
all_comments = bb.get_comments(task_id)
result["comments"] = {
"items": [dict(c.__dict__) for c in all_comments[-20:]],
"total_count": len(all_comments),
"limit": 20,
}
if "events" in expand_list:
all_events = q.task_events(task_id, limit=99999)
result["events"] = {
"items": all_events[-30:],
"total_count": len(all_events),
"limit": 30,
}
if "outputs" in expand_list:
result["outputs"] = [dict(o.__dict__) for o in bb.get_outputs(task_id)]
if "reviews" in expand_list:
result["reviews"] = [dict(r.__dict__) for r in bb.get_reviews(task_id)]
if "decisions" in expand_list:
result["decisions"] = [dict(d.__dict__) for d in bb.get_decisions(task_id)]
return result
@@ -100,11 +113,9 @@ async def create_task(project_id: str, body: Dict[str, Any]):
task_id = body.get("id")
if not task_id:
import re
from datetime import datetime
prefix = re.sub(r'[^a-z0-9]', '-', project_id.lower()).strip('-')[:20]
date_str = datetime.now().strftime('%Y%m%d')
# seq: 查当前项目最大 seq
import sqlite3
db_path = get_data_root() / project_id / "blackboard.db"
try:
conn = sqlite3.connect(str(db_path), timeout=5)
@@ -237,7 +248,6 @@ async def update_status(project_id: str, task_id: str, body: Dict[str, Any]):
})
# 检查转换是否合法
from src.blackboard.db import VALID_TRANSITIONS
current = old_task.status
allowed = VALID_TRANSITIONS.get(current, set())
if new_status not in allowed:
@@ -271,220 +281,6 @@ async def update_status(project_id: str, task_id: str, body: Dict[str, Any]):
return {"ok": True, "old_status": current, "new_status": new_status}
# --- @mention 自动提取(#04 ---
_KNOWN_AGENT_IDS: list = []
def _init_agent_ids():
"""从配置文件加载 Agent ID 列表"""
global _KNOWN_AGENT_IDS
if _KNOWN_AGENT_IDS:
return
try:
import yaml
cfg_path = os.path.join(
os.path.dirname(__file__),
"..",
"..",
"config",
"default.yaml")
with open(cfg_path) as f:
cfg = yaml.safe_load(f)
_KNOWN_AGENT_IDS = list(
cfg.get(
"daemon",
{}).get(
"agent_profiles",
{}).keys())
except Exception:
_KNOWN_AGENT_IDS = []
def _extract_mentions(text: str) -> list:
"""从文本中自动提取 @agent-id 格式的 mention"""
import re
_init_agent_ids()
candidates = set(
re.findall(
r'@([a-z][a-z0-9]*(?:-[a-z][a-z0-9]*)+)',
text))
return [a for a in candidates if a in _KNOWN_AGENT_IDS]
# --- Comments ---
@router.get("/tasks/{task_id}/comments")
async def get_comments(project_id: str, task_id: str,
comment_type: Optional[str] = None):
bb = _bb(project_id)
comments = bb.get_comments(task_id, comment_type=comment_type)
return {"comments": [dict(c.__dict__) for c in comments]}
@router.post("/tasks/{task_id}/comments")
async def add_comment(project_id: str, task_id: str, body: Dict[str, Any]):
bb = _bb(project_id)
mentions_raw = body.get("mentions")
comment_body = body["body"]
# #04: 自动从 body 提取 @mention,与显式传的 mentions 取并集
auto_mentions = _extract_mentions(comment_body)
if isinstance(mentions_raw, str):
try:
explicit_mentions = json.loads(mentions_raw)
except Exception:
explicit_mentions = []
elif isinstance(mentions_raw, list):
explicit_mentions = mentions_raw
else:
explicit_mentions = []
merged_mentions = list(set(explicit_mentions + auto_mentions))
cid = bb.add_comment(task_id, body["author"], comment_body,
comment_type=body.get("comment_type", "general"),
mentions=merged_mentions)
if merged_mentions:
bb.record_mentions(cid, task_id, merged_mentions)
# #10: SSE 通知前端黑板有新 comment
try:
from src.api.sse_routes import get_broker
broker = get_broker()
broker.publish_sync("comment_added", {
"project_id": project_id,
"task_id": task_id,
"comment_id": cid,
"author": body["author"],
})
except Exception:
pass
return {"ok": True, "comment_id": cid, "mentions": merged_mentions}
# --- Outputs ---
@router.get("/tasks/{task_id}/outputs")
async def get_outputs(project_id: str, task_id: str):
bb = _bb(project_id)
return {"outputs": [dict(o.__dict__) for o in bb.get_outputs(task_id)]}
@router.post("/tasks/{task_id}/outputs")
async def write_output(project_id: str, task_id: str, body: Dict[str, Any]):
bb = _bb(project_id)
# 字段校验 + Agent-friendly 错误
agent = body.get("agent")
if not agent:
raise HTTPException(422, {
"error": "validation_failed",
"detail": "Missing required field: agent",
"hint": "Provide your agent ID, e.g. 'zhangfei-dev'",
})
# type 字段:接受 type 或 content_type(别名兼容)
output_type = body.get("type") or body.get("content_type")
valid_types = sorted(OUTPUT_TYPES)
if not output_type:
raise HTTPException(422, {
"error": "validation_failed",
"detail": "Missing required field: type",
"valid_values": {"type": valid_types},
"hint": "Use 'type' field. Also accepts 'content_type' as alias.",
})
if output_type not in OUTPUT_TYPES:
raise HTTPException(422, {
"error": "validation_failed",
"detail": f"Invalid type: '{output_type}'",
"valid_values": {"type": valid_types},
})
title = body.get("title")
if not title:
raise HTTPException(422, {
"error": "validation_failed",
"detail": "Missing required field: title",
"hint": "Provide a brief title describing this output",
})
# 内容模式:content(直传)或 content_path(引用)
content = body.get("content")
content_path = body.get("content_path") or body.get("path")
if content and not content_path:
# 内容直传模式:自动写文件
import os
artifacts_dir = os.path.join(
os.path.dirname(bb.db_path), "artifacts", task_id
)
os.makedirs(artifacts_dir, exist_ok=True)
# 安全文件名
safe_name = "".join(
c if c.isalnum() or c in "._-" else "_" for c in title)
if not safe_name:
safe_name = "output"
file_path = os.path.join(artifacts_dir, safe_name)
with open(file_path, "w", encoding="utf-8") as f:
f.write(content)
content_path = file_path
oid = bb.write_output(
task_id, agent, output_type, title,
content_path=content_path,
summary=body.get("summary"),
metadata=body.get("metadata"),
)
return {"ok": True, "output_id": oid}
# --- Decisions ---
@router.get("/tasks/{task_id}/decisions")
async def get_decisions(project_id: str, task_id: str):
bb = _bb(project_id)
return {"decisions": [dict(d.__dict__) for d in bb.get_decisions(task_id)]}
@router.post("/tasks/{task_id}/decisions")
async def add_decision(project_id: str, task_id: str, body: Dict[str, Any]):
bb = _bb(project_id)
did = bb.add_decision(task_id, body["decider"], body["decision"],
body["rationale"],
alternatives=body.get("alternatives"))
return {"ok": True, "decision_id": did}
# --- Observations ---
@router.post("/tasks/{task_id}/observations")
async def add_observation(project_id: str, task_id: str, body: Dict[str, Any]):
bb = _bb(project_id)
oid = bb.add_observation(task_id, body["observer"], body["body"],
severity=body.get("severity", "info"))
return {"ok": True, "observation_id": oid}
# --- Reviews ---
@router.get("/tasks/{task_id}/reviews")
async def get_reviews(project_id: str, task_id: str):
bb = _bb(project_id)
return {"reviews": [dict(r.__dict__) for r in bb.get_reviews(task_id)]}
@router.post("/tasks/{task_id}/reviews")
async def add_review(project_id: str, task_id: str, body: Dict[str, Any]):
bb = _bb(project_id)
review = Review(
id=body["id"], task_id=task_id, reviewer=body["reviewer"],
review_type=body["review_type"], verdict=body["verdict"],
summary=body["summary"], confidence=body.get("confidence"),
round=body.get("round", 1), max_rounds=body.get("max_rounds", 3),
)
bb.add_review(review)
return {"ok": True, "review_id": review.id}
@router.patch("/tasks/{task_id}")
async def patch_task(project_id: str, task_id: str, body: Dict[str, Any]):
"""更新任务元数据(归档、标题等)"""
@@ -497,7 +293,6 @@ async def patch_task(project_id: str, task_id: str, body: Dict[str, Any]):
if not updates:
return {"ok": True}
# 直接用 SQL 更新
import sqlite3
conn = sqlite3.connect(str(bb.db_path), timeout=5)
try:
set_clause = ", ".join(f"{k}=?" for k in updates)
@@ -509,38 +304,9 @@ async def patch_task(project_id: str, task_id: str, body: Dict[str, Any]):
return {"ok": True}
# --- Per-task Events & Experiences ---
@router.get("/tasks/{task_id}/events")
async def get_task_events(project_id: str, task_id: str,
limit: int = Query(50, le=200)):
q = _q(project_id)
return {"events": q.task_events(task_id, limit)}
@router.get("/tasks/{task_id}/experiences")
async def get_task_experiences(project_id: str, task_id: str):
q = _q(project_id)
return {"experiences": q.task_experiences(task_id)}
# --- Global Events ---
@router.get("/events")
async def get_events(project_id: str, limit: int = Query(50, le=200)):
q = _q(project_id)
return {"events": q.recent_events(limit)}
# --- Summary ---
@router.get("/summary")
async def task_summary(project_id: str):
q = _q(project_id)
return {"summary": q.task_summary()}
# --- Archive (v2.8) ---
# --------------------------------------------------------------------------- #
# Archive (v2.8)
# --------------------------------------------------------------------------- #
@router.post("/tasks/{task_id}/archive")
async def archive_task(project_id: str, task_id: str,
@@ -563,10 +329,3 @@ async def archive_done_tasks(project_id: str):
bb = _bb(project_id)
count = bb.archive_done_tasks()
return {"ok": True, "archived_count": count}
# --- Helper ---
def _task_to_dict(t: Task) -> Dict[str, Any]:
d = {k: v for k, v in t.__dict__.items() if v is not None}
return d
+1
View File
@@ -117,6 +117,7 @@ def _migrate_v28(conn: sqlite3.Connection) -> None:
_safe_add_column(conn, "tasks", "round_count", "INTEGER DEFAULT 0")
_safe_add_column(conn, "tasks", "resumed_from", "TEXT")
_safe_add_column(conn, "tasks", "dispatch_count", "INTEGER DEFAULT 0")
# 3. checkpoints 表(M3
conn.execute("""CREATE TABLE IF NOT EXISTS checkpoints (
+2
View File
@@ -41,6 +41,8 @@ class Task:
resumed_from: Optional[str] = None # 暂停前状态,恢复时回到原状态
# v2.9 四相循环
round_count: int = 0 # 庞统 review 轮次计数
# §15 Runaway Guard
dispatch_count: int = 0 # 被 ticker dispatch 的总次数
# v2.8 归档
archived: bool = False
archived_at: Optional[str] = None
+1 -1
View File
@@ -208,7 +208,7 @@ class Blackboard:
params.append(parent_task)
if conditions:
query += " WHERE " + " AND ".join(conditions)
query += " ORDER BY priority ASC, created_at ASC"
query += " ORDER BY priority ASC, created_at DESC"
rows = conn.execute(query, params).fetchall()
return [Task.from_row(r) for r in rows]
finally:
+2
View File
@@ -1,3 +1,5 @@
# DEPRECATED per §19 重设计 — 经验蒸馏改为双层 daily cronL1 各 agent + L2 庞统)
# 保留代码供参考,后续 P3 清理时物理删除
"""Experience Distillation — 经验蒸馏
从已完成的任务产出中提取经验
+2 -2
View File
@@ -9,7 +9,7 @@ import logging
from pathlib import Path
from src.daemon.base_task_handler import BaseTaskHandler, VerifyResult
from src.daemon.prompt_composer import PromptComposer, PromptContext, GiteaConventionSection, WikiGuideSection
from src.daemon.prompt_composer import PromptComposer, PromptContext, GiteaConventionSection, WikiGuideSection, DeliveryChecklistSection
from src.blackboard.db import get_connection
logger = logging.getLogger("moziplus-v2.handler.mail")
@@ -36,7 +36,7 @@ class MailHandler(BaseTaskHandler):
return composer.compose(context)
def get_sections(self) -> list:
return [MailContextSection(), MailApiSection(), MailConstraintsSection(), GiteaConventionSection(), WikiGuideSection()]
return [MailContextSection(), MailApiSection(), MailConstraintsSection(), GiteaConventionSection(), WikiGuideSection(), DeliveryChecklistSection()]
def verify_completion(self, task_id: str, db_path: Path) -> VerifyResult:
"""Mail 完成验证:区分 inform/request。
+24
View File
@@ -174,3 +174,27 @@ class WikiGuideSection:
def should_include(self, context: "PromptContext") -> bool:
return True
# ---------------------------------------------------------------------------
# DeliveryChecklistSection — 交付检查清单
# ---------------------------------------------------------------------------
class DeliveryChecklistSection:
"""交付检查清单 — 提醒 Agent 完成前同步关联成果物。"""
name: str = "delivery_checklist"
priority: int = 55 # CONSTRAINTS(50) 和 EXTENSION(60) 之间
CHECKLIST_TEXT = (
"## 交付检查\n"
"完成代码改动前确认:\n"
"- 改了实现 → docs/design/ 对应设计文档是否需要更新\n"
"- 改了实现 → tests/ 是否有对应测试脚本需要更新\n"
"- 所有成果物变更通过 PR 流程:PR review 把关设计合理性,CI 把关代码质量,CD 把关部署正确性\n"
)
def render(self, context: "PromptContext") -> str:
return self.CHECKLIST_TEXT
def should_include(self, context: "PromptContext") -> bool:
return True
+3
View File
@@ -1,3 +1,6 @@
# DEPRECATED per §19 重设计 — 不再参与 skill 发现/加载
# 实际 skill 发现走 openclaw 原生 <available_skills> 机制
# 保留代码供参考,后续 P3 清理时物理删除
"""Skill System — 技能注册、加载、匹配、执行
三层自由度
+28 -5
View File
@@ -288,6 +288,8 @@ class AgentSpawner:
mail_type = ""
action_type = ""
action_steps = []
event_type = ""
event_data = {}
try:
meta = json.loads(must_haves) if must_haves else {}
from_agent = meta.get("from", "")
@@ -295,6 +297,8 @@ class AgentSpawner:
# toolchain 字段提取
action_type = meta.get("action_type", "")
action_steps = meta.get("steps", [])
event_type = meta.get("event_type", "")
event_data = meta.get("context", {})
except Exception:
pass
ctx = PromptContext(
@@ -304,6 +308,7 @@ class AgentSpawner:
spawn_type=spawn_type,
from_agent=from_agent, mail_type=mail_type,
action_type=action_type, action_steps=action_steps,
event_type=event_type, event_data=event_data,
)
return handler.build_prompt(ctx)
@@ -625,19 +630,24 @@ curl -X POST http://{self.api_host}:{self.api_port}/api/projects/{project_id}/ta
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
self._register_session(session_id, agent_id, task_id, proc.pid,
# use_main_session=True 时 session_id 为 None,但 _register_session 和
# _monitor_process 需要一个非 None 的 key;同时 ticker 等调用方用
# `result is not None` 判断 spawn 是否成功,返回 None 会被误判为失败。
# 统一用 "main" 作为占位标识。
effective_sid = session_id or "main"
self._register_session(effective_sid, agent_id, task_id, proc.pid,
broadcast_task_ids=broadcast_task_ids)
logger.info("Spawned agent %s (session=%s, pid=%d)",
agent_id, session_id, proc.pid)
agent_id, effective_sid, proc.pid)
# Schedule monitor(传 wrapped_on_complete)
asyncio.create_task(
self._monitor_process(session_id, proc, agent_id, task_id,
self._monitor_process(effective_sid, proc, agent_id, task_id,
on_complete=_wrapped_on_complete,
db_path=task_db_path or self.db_path)
)
return session_id
return effective_sid
except Exception as e:
# spawn 失败也要 release counter
@@ -1251,7 +1261,7 @@ curl -X POST http://{api_host}:{api_port}/api/projects/{project_id}/tasks/{task_
conn = get_connection(db_path)
try:
row = conn.execute(
"SELECT id, title, status FROM tasks WHERE id=?", (
"SELECT id, title, status, must_haves FROM tasks WHERE id=?", (
task_id,)
).fetchone()
if not row:
@@ -1949,6 +1959,19 @@ curl -X POST http://{api_host}:{api_port}/api/projects/{project_id}/tasks/{task_
try:
from src.daemon.mail_notify import _is_mail_project, notify_mail_failed
if _is_mail_project(db_path):
# 防御性检查:如果 task 已经 done,不触发失败通知(竞态保护)
# 场景:spawner 标 failed 和 handler 标 done 同时发生
try:
conn2 = get_connection(db_path)
current_status = conn2.execute(
"SELECT status FROM tasks WHERE id=?", (task_id,)
).fetchone()
conn2.close()
if current_status and current_status["status"] == "done":
logger.info("Task %s already done, skipping mail failure notification", task_id)
return
except Exception:
pass
# Mail 失败:通知发件人,不 @pangtong
notify_mail_failed(db_path, task_id, reason, detail)
else:
+2 -1
View File
@@ -10,7 +10,7 @@ from pathlib import Path
from typing import Dict, Optional
from src.daemon.base_task_handler import BaseTaskHandler, VerifyResult
from src.daemon.prompt_composer import PromptComposer, PromptContext, GiteaConventionSection, WikiGuideSection
from src.daemon.prompt_composer import PromptComposer, PromptContext, GiteaConventionSection, WikiGuideSection, DeliveryChecklistSection
from src.blackboard.db import get_connection
logger = logging.getLogger("moziplus-v2.handler")
@@ -315,6 +315,7 @@ class TaskHandler(BaseTaskHandler):
TaskConstraintsSection(),
GiteaConventionSection(),
WikiGuideSection(),
DeliveryChecklistSection(),
]
def build_prompt(self, context: PromptContext) -> str:
+54 -18
View File
@@ -332,25 +332,10 @@ class Ticker:
except Exception as e:
logger.warning("HealthChecker error for %s: %s", project_id, e)
# 9. 经验蒸馏(完成的 task 自动触发)
# 9. 经验蒸馏 — DEPRECATED per §19, 双层 daily cron 替代
# 保留参数向后兼容,不再执行逐任务蒸馏
if self.experience_distiller:
try:
conn2 = get_connection(db_path)
try:
done_tasks = conn2.execute(
"SELECT id FROM tasks WHERE status='done' AND updated_at > datetime('now', '-60 seconds')"
).fetchall()
finally:
conn2.close()
for row in done_tasks:
t = Blackboard(db_path).get_task(row[0])
if t:
self.experience_distiller.distill_from_task(
task_id=t.id, task_title=t.title, task_type=t.task_type
)
except Exception as e:
logger.warning(
"ExperienceDistiller error for %s: %s", project_id, e)
logger.debug("ExperienceDistiller deprecated per §19, skipping (use L1/L2 daily cron)")
# 10. 扫描后状态
result["summary_after"] = queries.task_summary()
@@ -1084,6 +1069,19 @@ Parent Task ID: {parent_task.id}
broadcast_ids = await self._broadcast_claim(broadcast_tasks, db_path, project_id)
dispatched.extend(broadcast_ids)
# §15 Runaway Guard: 统一递增 dispatch_count
if dispatched:
conn = get_connection(db_path)
try:
for tid in dispatched:
conn.execute(
"UPDATE tasks SET dispatch_count = COALESCE(dispatch_count, 0) + 1 WHERE id=?",
(tid,),
)
conn.commit()
finally:
conn.close()
return dispatched
async def _broadcast_claim(self, tasks: list, db_path: Path,
@@ -1376,6 +1374,19 @@ Parent Task ID: {parent_task.id}
except Exception:
logger.exception("Review dispatch failed for %s", task.id)
# §15 Runaway Guard: 统一递增 dispatch_count (review)
if dispatched:
conn = get_connection(db_path)
try:
for tid in dispatched:
conn.execute(
"UPDATE tasks SET dispatch_count = COALESCE(dispatch_count, 0) + 1 WHERE id=?",
(tid,),
)
conn.commit()
finally:
conn.close()
return dispatched
# ------------------------------------------------------------------
@@ -1388,6 +1399,31 @@ Parent Task ID: {parent_task.id}
reclaimed: List[str] = []
now = datetime.utcnow() # UTC,与 SQLite datetime('now') 一致
# §15 Runaway Guard: per-task dispatch_count 上限检查
# 覆盖所有状态,防止无限循环 dispatch
MAX_DISPATCH_COUNT = 10
for status_to_check in ("pending", "working", "claimed"):
tasks_to_check = queries.tasks_by_status(status_to_check)
for task in tasks_to_check:
dispatch_count = getattr(task, 'dispatch_count', 0) or 0
if dispatch_count >= MAX_DISPATCH_COUNT:
conn = get_connection(db_path)
try:
ok = self._transition_status(
conn, task.id, "failed",
agent="daemon",
detail={"reason": "runaway_guard",
"dispatch_count": dispatch_count,
"message": f"dispatch {dispatch_count} 次仍未完成,自动标 failed"},
)
if ok:
reclaimed.append(task.id)
logger.error(
"Task %s: runaway guard triggered (dispatch_count=%d, status=%s), marking failed",
task.id, dispatch_count, status_to_check)
finally:
conn.close()
# claimed 超时 → 重置为 pending(如果 retry_count >= 3 则升级庞统)
claimed = queries.tasks_by_status("claimed")
for task in claimed:
+27 -2
View File
@@ -13,7 +13,7 @@ from pathlib import Path
from typing import Dict, List
from src.daemon.base_task_handler import BaseTaskHandler, VerifyResult
from src.daemon.prompt_composer import PromptComposer, PromptContext, GiteaConventionSection, WikiGuideSection
from src.daemon.prompt_composer import PromptComposer, PromptContext, GiteaConventionSection, WikiGuideSection, DeliveryChecklistSection
from src.daemon.toolchain_templates import render_template, _TEMPLATE_MAP
from src.blackboard.db import get_connection
@@ -51,17 +51,41 @@ class ToolchainContextSection:
name: str = "toolchain_context"
priority: int = 10
EVENT_LABELS_ZH: Dict[str, str] = {
"review_request": "Review 请求",
"review_result": "Review 结果",
"review_merged": "PR 合并",
"review_comment": "Review 评论",
"review_updated": "Review 更新",
"ci_failure": "CI 失败",
"deploy_failure": "部署失败",
"issue_assigned": "Issue 指派",
"mention": "@提及",
}
def render(self, context: PromptContext) -> str:
event_type = context.event_type
event_data: Dict = context.event_data or {}
# 事件类型中文标签
event_label = self.EVENT_LABELS_ZH.get(event_type, event_type or '未知')
# from / to 信息
to_agent = context.agent_id or ''
from_agent = 'system'
# Part 1: 事件信息(现有模板引擎)
if event_type in _TEMPLATE_MAP:
variables = {k: str(v) for k, v in event_data.items()}
event_text = render_template(event_type, variables)
# 补充事件类型中文标签 + from/to
header = f"- **事件类型**: {event_label}\n- **来源**: {from_agent}\n- **指派**: {to_agent}\n"
event_text = header + "\n" + event_text
else:
lines = ["## 工具链事件", ""]
lines.append(f"- **事件类型**: {event_type or '未知'}")
lines.append(f"- **事件类型**: {event_label}")
lines.append(f"- **来源**: {from_agent}")
lines.append(f"- **指派**: {to_agent}")
if event_data:
lines.append("- **事件详情**:")
for key, value in event_data.items():
@@ -228,6 +252,7 @@ class ToolchainHandler(BaseTaskHandler):
ToolchainConstraintsSection(),
GiteaConventionSection(),
WikiGuideSection(),
DeliveryChecklistSection(),
]
def build_prompt(self, context: PromptContext) -> str:
@@ -5,6 +5,7 @@
import { useState, useCallback } from 'react';
import { api, AgentsStatusData } from '../api';
import ToolchainPanel from './ToolchainPanel';
interface ServiceCheckResult {
name: string;
@@ -15,7 +16,7 @@ interface ServiceCheckResult {
}
export default function SettingsPanel() {
const [tab, setTab] = useState<'connections' | 'security' | 'version' | 'logs'>('connections');
const [tab, setTab] = useState<'connections' | 'security' | 'version' | 'logs' | 'toolchain'>('connections');
// 接线状态巡检
const [checking, setChecking] = useState(false);
@@ -95,6 +96,7 @@ export default function SettingsPanel() {
{ key: 'security' as const, label: '🛡️ 安全防务' },
{ key: 'version' as const, label: '📦 版本更新' },
{ key: 'logs' as const, label: '📋 城防日志' },
{ key: 'toolchain' as const, label: '⛓️ 工具链' },
].map((t) => (
<button key={t.key} className={`btn ${tab === t.key ? 'btn-primary' : ''}`} onClick={() => setTab(t.key)}>
{t.label}
@@ -288,6 +290,9 @@ export default function SettingsPanel() {
</div>
</div>
)}
{/* ========== 工具链 ========== */}
{tab === 'toolchain' && <ToolchainPanel />}
</div>
);
}
@@ -0,0 +1,250 @@
/**
* ToolchainPanel
* _toolchain tasksCI/PR//Review
*/
import { useEffect, useState } from 'react';
const AGENT_NAMES: Record<string, string> = {
'pangtong-fujunshi': '庞统',
'simayi-challenger': '司马懿',
'zhangfei-dev': '张飞',
'guanyu-dev': '关羽',
'zhaoyun-data': '赵云',
'jiangwei-infra': '姜维',
'system': '系统',
};
const EVENT_LABELS: Record<string, string> = {
'review_request': 'Review 请求',
'review_result': 'Review 结果',
'review_merged': 'PR 合并',
'review_comment': 'Review 评论',
'review_updated': 'Review 更新',
'ci_failure': 'CI 失败',
'deploy_failure': '部署失败',
'issue_assigned': 'Issue 指派',
'mention': '@提及',
};
const STATUS_COLORS: Record<string, string> = {
pending: '#f59e0b22', claimed: '#6a9eff22', working: '#6a9eff22',
review: '#818cf822', done: '#2ecc8a22', failed: '#ef444422',
cancelled: '#6b728022', blocked: '#ef444422',
};
const STATUS_LABELS: Record<string, string> = {
pending: '待处理', claimed: '已认领', working: '处理中',
review: '审查中', done: '已完成', failed: '失败',
cancelled: '已取消', blocked: '已拦截',
};
function fmtTime(iso: string): string {
try {
const d = new Date(iso.includes('T') ? iso : iso.replace(' ', 'T') + 'Z');
const now = Date.now();
const diff = now - d.getTime();
const mins = Math.floor(diff / 60000);
if (mins < 1) return '刚刚';
if (mins < 60) return `${mins}分钟前`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}小时前`;
return `${d.getMonth() + 1}/${d.getDate()} ${d.getHours()}:${String(d.getMinutes()).padStart(2, '0')}`;
} catch { return iso; }
}
export default function ToolchainPanel() {
const [tasks, setTasks] = useState<any[]>([]);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [detail, setDetail] = useState<any>(null);
const [searchQuery, setSearchQuery] = useState('');
const [loading, setLoading] = useState(false);
const [filterMode, setFilterMode] = useState<'all' | 'pending'>('all');
const loadTasks = async (q?: string) => {
setLoading(true);
try {
const url = q
? `/api/projects/_toolchain/tasks?q=${encodeURIComponent(q)}`
: `/api/projects/_toolchain/tasks`;
const res = await fetch(url);
if (res.ok) {
const data = await res.json();
setTasks(data.tasks || []);
}
} catch { /* */ }
setLoading(false);
};
const displayed = filterMode === 'pending'
? tasks.filter(t => !['done', 'failed', 'cancelled'].includes(t.status))
: tasks;
useEffect(() => { loadTasks(); }, []);
// 搜索防抖 300ms
useEffect(() => {
const timer = setTimeout(() => {
if (searchQuery !== undefined) loadTasks(searchQuery || undefined);
}, 300);
return () => clearTimeout(timer);
}, [searchQuery]);
useEffect(() => {
if (!selectedId) { setDetail(null); return; }
(async () => {
try {
const res = await fetch(
`/api/projects/_toolchain/tasks/${selectedId}?expand=comments`
);
if (res.ok) setDetail(await res.json());
} catch { /* */ }
})();
}, [selectedId]);
// 渲染评论列表(兼容 expand 和裸 list 格式)
const renderComments = (comments: any[]) => {
if (!comments || comments.length === 0) return null;
return (
<div style={{ marginTop: 16 }}>
<div style={{ fontSize: 11, color: 'var(--muted)', marginBottom: 8, fontWeight: 600 }}>
📋 ({comments.length})
</div>
{comments.map((c: any, i: number) => (
<div key={c.id || i} style={{
padding: '8px 12px', background: 'var(--panel2)', borderRadius: 6, marginBottom: 6,
}}>
<div style={{ display: 'flex', justifyContent: 'space-between', marginBottom: 4 }}>
<span style={{ fontSize: 10, color: 'var(--acc)', fontWeight: 600 }}>
{c.author || 'system'}
</span>
<span style={{ fontSize: 9, color: 'var(--muted)' }}>{fmtTime(c.created_at)}</span>
</div>
<div style={{ fontSize: 12, color: '#a0aec0', lineHeight: 1.5 }}>{c.body}</div>
</div>
))}
</div>
);
};
return (
<div style={{ display: 'flex', gap: 0, height: '100%', minHeight: 500 }}>
{/* 左侧列表 */}
<div style={{ width: 380, borderRight: '1px solid var(--line)', display: 'flex', flexDirection: 'column', flexShrink: 0 }}>
{/* 搜索栏 + 刷新 */}
<div style={{ padding: '10px 14px', borderBottom: '1px solid var(--line)', display: 'flex', gap: 6, alignItems: 'center' }}>
<input
type="text"
placeholder="搜索工具链事件..."
value={searchQuery}
onChange={e => setSearchQuery(e.target.value)}
style={{
flex: 1, padding: '4px 8px', borderRadius: 4, fontSize: 11,
border: '1px solid #2a3550', background: '#161b2e', color: '#dde4f8',
outline: 'none',
}}
/>
<button onClick={() => loadTasks(searchQuery || undefined)} style={{
padding: '3px 8px', borderRadius: 4, fontSize: 10,
border: '1px solid #2a3550', background: '#161b2e', color: '#8899aa', cursor: 'pointer',
}}>🔄</button>
<button onClick={() => setFilterMode('all')} style={{
padding: '3px 8px', borderRadius: 4, fontSize: 10,
border: `1px solid ${filterMode === 'all' ? 'var(--acc)' : '#2a3550'}`,
background: filterMode === 'all' ? 'var(--acc)22' : '#161b2e',
color: filterMode === 'all' ? 'var(--acc)' : '#8899aa', cursor: 'pointer',
}}></button>
<button onClick={() => setFilterMode('pending')} style={{
padding: '3px 8px', borderRadius: 4, fontSize: 10,
border: `1px solid ${filterMode === 'pending' ? 'var(--acc)' : '#2a3550'}`,
background: filterMode === 'pending' ? 'var(--acc)22' : '#161b2e',
color: filterMode === 'pending' ? 'var(--acc)' : '#8899aa', cursor: 'pointer',
}}></button>
<span style={{ fontSize: 10, color: 'var(--muted)' }}>{filterMode === 'pending' ? displayed.length : tasks.length} </span>
</div>
{/* 事件列表 */}
<div style={{ flex: 1, overflowY: 'auto' }}>
{tasks.length === 0 && (
<div style={{ textAlign: 'center', padding: 40, color: 'var(--muted)', fontSize: 12 }}>
{loading ? '加载中...' : '暂无工具链事件'}
</div>
)}
{displayed.map((t: any) => (
<div key={t.id} onClick={() => setSelectedId(t.id)} style={{
padding: '10px 14px', borderBottom: '1px solid var(--line)',
cursor: 'pointer', transition: 'background .15s',
background: selectedId === t.id ? 'var(--panel2)' : 'transparent',
}}
onMouseEnter={e => e.currentTarget.style.background = 'var(--panel2)'}
onMouseLeave={e => e.currentTarget.style.background = selectedId === t.id ? 'var(--panel2)' : 'transparent'}
>
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
<span style={{
fontSize: 9, padding: '1px 5px', borderRadius: 3,
background: STATUS_COLORS[t.status] || '#2a3550',
color: '#dde4f8',
}}>{STATUS_LABELS[t.status] || t.status}</span>
<span style={{ fontSize: 9, color: 'var(--muted)' }}>{fmtTime(t.created_at)}</span>
</div>
<div style={{
fontSize: 12, fontWeight: 500, color: '#dde4f8',
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
}}>{t.title}</div>
<div style={{ fontSize: 10, color: 'var(--muted)', marginTop: 2 }}>
{AGENT_NAMES['system'] || '系统'} {AGENT_NAMES[t.assignee] || t.assignee || '?'}
</div>
</div>
))}
</div>
</div>
{/* 右侧详情 */}
<div style={{ flex: 1, padding: '16px 20px', overflowY: 'auto' }}>
{!detail ? (
<div style={{ textAlign: 'center', padding: 60, color: 'var(--muted)' }}>
<div style={{ fontSize: 36, marginBottom: 12 }}></div>
<div style={{ fontSize: 13 }}></div>
</div>
) : (
<>
{/* 头部 */}
<div style={{ marginBottom: 16 }}>
<div style={{ display: 'flex', alignItems: 'center', gap: 8, marginBottom: 6 }}>
<span style={{ fontSize: 10, padding: '2px 6px', borderRadius: 4, background: STATUS_COLORS[detail.status] || '#2a3550', color: '#dde4f8' }}>
{STATUS_LABELS[detail.status] || detail.status}
</span>
<span style={{ fontSize: 10, color: 'var(--muted)' }}>{detail.id}</span>
</div>
<div style={{ fontSize: 18, fontWeight: 700, lineHeight: 1.3 }}>{detail.title}</div>
<div style={{ fontSize: 11, color: 'var(--muted)', marginTop: 4 }}>
{AGENT_NAMES['system'] || '系统'} {AGENT_NAMES[detail.assignee] || detail.assignee || '?'}
</div>
<div style={{ fontSize: 12, color: 'var(--muted)', marginTop: 6 }}>
{fmtTime(detail.created_at)}
</div>
</div>
{/* 正文 */}
{detail.description && (
<div style={{
padding: '14px 16px', background: 'var(--panel2)', borderRadius: 10,
fontSize: 13, color: '#a0aec0', lineHeight: 1.7, whiteSpace: 'pre-wrap',
}}>
{detail.description}
</div>
)}
{/* action_report 评论 — expand 格式 {items, total_count} */}
{detail.comments && detail.comments.items && detail.comments.items.length > 0 &&
renderComments(detail.comments.items)
}
{/* 兼容裸 list 格式 */}
{detail.comments && Array.isArray(detail.comments) && detail.comments.length > 0 &&
renderComments(detail.comments)
}
</>
)}
</div>
</div>
);
}
+4 -2
View File
@@ -7,7 +7,8 @@ from src.api.sse_routes import router as sse_router
from src.api.project_routes import router as project_router
from src.api.daemon_routes import router as daemon_router
from src.api.checkpoint_routes import router as checkpoint_router
from src.api.blackboard_routes import router as blackboard_router
from src.api.task_routes import router as task_router
from src.api.task_relation_routes import router as task_relation_router
import logging
from contextlib import asynccontextmanager
@@ -273,7 +274,8 @@ app.add_middleware(
# ---------------------------------------------------------------------------
app.include_router(blackboard_router)
app.include_router(task_router)
app.include_router(task_relation_router)
app.include_router(checkpoint_router)
app.include_router(daemon_router)
app.include_router(project_router)
@@ -543,3 +543,94 @@ class TestCheckTimeoutsUnified:
reclaimed = ticker._check_timeouts(db_path)
assert "t-review-dead" not in reclaimed
# ---------------------------------------------------------------------------
# E13: §15 Runaway Guard — per-task dispatch_count 上限
# ---------------------------------------------------------------------------
class TestRunawayGuard:
"""E13: dispatch_count >= 10 → 自动标 failed(覆盖所有非终态)"""
@pytest.fixture
def guard_project(self, tmp_path):
"""创建项目 + 任务"""
data_root = tmp_path / "projects"
registry = ProjectRegistry(data_root)
registry.create_project("guard-proj", "Guard Test", agents=["agent-a"])
db_path = data_root / "guard-proj" / "blackboard.db"
bb = Blackboard(db_path)
return registry, db_path, bb
def test_runaway_guard_triggers_working(self, guard_project):
"""E13.1: working 状态 dispatch_count >= 10 → 标 failed"""
registry, db_path, bb = guard_project
bb.create_task(Task(
id="t-runaway", title="Runaway Task", status="working",
assigned_by="daemon", current_agent="agent-a",
))
conn = bb._conn()
try:
conn.execute(
"UPDATE tasks SET dispatch_count = 10 WHERE id = ?", ("t-runaway",))
conn.commit()
finally:
conn.close()
ticker = Ticker(registry, tick_interval=30)
reclaimed = ticker._check_timeouts(db_path)
assert "t-runaway" in reclaimed
task = Queries(db_path).task_by_id("t-runaway")
assert task.status == "failed"
def test_runaway_guard_triggers_pending(self, guard_project):
"""E13.2: pending 状态 dispatch_count >= 10 → 标 failed"""
registry, db_path, bb = guard_project
bb.create_task(Task(
id="t-pending-runaway", title="Pending Runaway", status="pending",
assigned_by="daemon",
))
conn = bb._conn()
try:
conn.execute(
"UPDATE tasks SET dispatch_count = 10 WHERE id = ?",
("t-pending-runaway",))
conn.commit()
finally:
conn.close()
ticker = Ticker(registry, tick_interval=30)
reclaimed = ticker._check_timeouts(db_path)
assert "t-pending-runaway" in reclaimed
task = Queries(db_path).task_by_id("t-pending-runaway")
assert task.status == "failed"
def test_runaway_guard_not_triggered(self, guard_project):
"""E13.3: dispatch_count < 10 → 正常流程不受影响"""
registry, db_path, bb = guard_project
bb.create_task(Task(
id="t-normal", title="Normal Task", status="working",
assigned_by="daemon", current_agent="agent-a",
))
conn = bb._conn()
try:
conn.execute(
"UPDATE tasks SET dispatch_count = 5 WHERE id = ?", ("t-normal",))
conn.commit()
finally:
conn.close()
ticker = Ticker(registry, tick_interval=30)
reclaimed = ticker._check_timeouts(db_path)
assert "t-normal" not in reclaimed
task = Queries(db_path).task_by_id("t-normal")
assert task.status == "working"
+70
View File
@@ -0,0 +1,70 @@
#!/bin/bash
# verify_api_compat.sh — 验证 API 拆分前后路由清单完全一致
#
# 用法:
# bash tests/scripts/verify_api_compat.sh
#
# 前置:
# - 当前在开发目录(sanguo_moziplus_v2/
# - git working tree 有拆分改动
# - main 分支是拆分前的基准
#
# 输出:
# ✅ 路由完全一致(exit 0)
# ❌ 路由有差异(exit 1,打印 diff)
set -euo pipefail
BEFORE_FILE="/tmp/routes_before_$$.txt"
AFTER_FILE="/tmp/routes_after_$$.txt"
echo "=== 提取拆分前路由清单(main 分支)==="
# stash 当前改动(如果有 untracked 新文件,--include-untracked
STASHED=0
if ! git diff --quiet || ! git diff --cached --quiet; then
git stash --include-untracked
STASHED=1
fi
python3 -c "
from src.main import app
for route in app.routes:
if hasattr(route, 'methods') and hasattr(route, 'path'):
for m in sorted(route.methods):
if m in ('GET','POST','PATCH','DELETE','PUT'):
print(f'{m} {route.path}')
" | sort > "$BEFORE_FILE"
echo "Routes before: $(wc -l < "$BEFORE_FILE")"
# 恢复改动
if [ "$STASHED" = "1" ]; then
git stash pop
fi
echo "=== 提取拆分后路由清单(当前 working tree==="
python3 -c "
from src.main import app
for route in app.routes:
if hasattr(route, 'methods') and hasattr(route, 'path'):
for m in sorted(route.methods):
if m in ('GET','POST','PATCH','DELETE','PUT'):
print(f'{m} {route.path}')
" | sort > "$AFTER_FILE"
echo "Routes after: $(wc -l < "$AFTER_FILE")"
echo ""
echo "=== Diff ==="
if diff "$BEFORE_FILE" "$AFTER_FILE"; then
echo "✅ 路由完全一致"
rm -f "$BEFORE_FILE" "$AFTER_FILE"
exit 0
else
echo "❌ 路由有差异"
rm -f "$BEFORE_FILE" "$AFTER_FILE"
exit 1
fi