Compare commits

..

60 Commits

Author SHA1 Message Date
cfdaily bcb8ced17a fix(spawner): address PR#36 review feedback (M1+M2+S1+S2)
CI / lint (pull_request) Successful in 8s
CI / test (pull_request) Successful in 9s
CI / notify-on-failure (pull_request) Successful in 0s
2026-06-11 21:40:09 +08:00
cfdaily caf750fad6 fix(spawner): §24 compact check must run when status=done (compact in progress)
CI / lint (pull_request) Successful in 7s
CI / test (pull_request) Successful in 9s
CI / notify-on-failure (pull_request) Successful in 0s
2026-06-11 21:18:33 +08:00
cfdaily 7918b12ff7 feat(spawner): §24 compact detection via gateway log rotation events 2026-06-11 21:18:33 +08:00
admin 3441f4325f Merge PR #35: §23 PR 全生命周期通知 2026-06-11 06:10:44 +00:00
cfdaily a4bb752d71 feat(toolchain): add PR synchronize and review comment notifications
CI / lint (pull_request) Successful in 6s
CI / test (pull_request) Successful in 8s
CI / notify-on-failure (pull_request) Successful in 0s
- pull_request.synchronize: notify reviewer to re-review after push
- pull_request_review COMMENTED: notify PR author of review comments
- New templates: review_updated.md, review_comment.md
- Idempotency: add review ID to content dedup key
- Design doc: docs/design/23-toolchain-pr-lifecycle.md
2026-06-11 14:00:44 +08:00
cfdaily d6612de6de fix(cd): move success notification to independent job
CI / lint (pull_request) Successful in 6s
CI / test (pull_request) Successful in 9s
CI / notify-on-failure (pull_request) Successful in 0s
- needs.deploy.result is not available inside steps, only in job-level context
- Split into notify-deploy-success job (symmetric with notify-deploy-failure)
- Default NOTIFY_TO to jiangwei-infra for direct push scenario
2026-06-11 13:25:48 +08:00
cfdaily f33190dc1e feat(cd): add deploy success notification
CI / lint (pull_request) Successful in 7s
CI / test (pull_request) Successful in 8s
CI / notify-on-failure (pull_request) Successful in 0s
- Query merged PR author via Gitea API
- Send Mail notification to PR author + pangtong
- Non-blocking: mail failure does not affect deploy
- Uses --max-time 5 on all curl calls
2026-06-11 13:22:12 +08:00
pangtong-fujunshi 1089991455 fix(lint): resolve all 37 flake8 issues (#33)
Deploy / ci (push) Successful in 10s
Deploy / deploy (push) Successful in 11s
Deploy / notify-deploy-failure (push) Successful in 1s
2026-06-11 02:34:50 +00:00
pangtong-fujunshi 4bf3d97a04 Merge pull request 'fix: 同步 4 个 post-PR#26 commit 到 Gitea' (#31) from fix/sync-to-gitea into main
Deploy / ci (push) Failing after 6s
Deploy / deploy (push) Has been skipped
Deploy / notify-deploy-failure (push) Successful in 1s
2026-06-11 08:08:42 +08:00
cfdaily d98fae007f docs: §21 handler 注册后 E2E 验证报告
CI / lint (pull_request) Failing after 8s
CI / test (pull_request) Has been skipped
CI / notify-on-failure (pull_request) Successful in 3s
Mail/Toolchain 核心流程全部通过:
- Mail: inform auto-working → auto-done 
- Toolchain Issue 指派: webhook → Mail 
- Toolchain PR Review: webhook → Review 请求 → Review 结果 
- CI 失败重复 Mail 问题确认(org+repo webhook 双触发,已知)

Task review 路径待明天验证。
2026-06-11 08:08:20 +08:00
cfdaily 3c30a9b135 fix: task_handler SKILL_BASE_PATH 硬编码改为环境变量
与 bootstrap.py 保持一致,支持 MOZI_SKILL_PATH 环境变量覆盖。
默认值不变。
2026-06-11 08:08:20 +08:00
cfdaily 6bb0596e6d fix: dispatcher _legacy_on_complete 闭包内删除错误的重复 import
get_connection 已在文件顶部 L22 import(from src.blackboard.db),
闭包内 L258 错误地 from src.daemon.db import(模块不存在),
会触发 ImportError 被 try/except 吞掉导致 review verdict 逻辑不生效。

司马懿 review 发现。
2026-06-11 08:08:20 +08:00
cfdaily 25863634c2 fix: v3.0→HEAD review 修复 — handler 注册 + review verdict + skill 全文注入
基于庞统+司马懿背靠背 review,修复 6 个问题:

P0 致命:
- A1: _legacy_on_complete 补回 review verdict 处理(approved→done,非 approved→@mention assignee)
- A2: 添加 TaskTypeRegistry.register() 启动初始化(注册 Task/Mail/Toolchain handler)

P1 中等:
- B11-1: RoleSkillSection 从索引提示改为全文注入(对齐设计 §2.3 + BootstrapBuilder 行为)
- A8: retry prompt is_mail 硬编码改走 TaskTypeRegistry handler 判断

P2 低:
- _mail_* 4 个方法添加 DEPRECATED 注释
- ticker.py handler check_completion 代码块缩进对齐(28→24 空格)

测试:394 passed, 0 failed
Review reports: docs/design/review-v3-vs-head-{pangtong,simayi}.md
2026-06-11 08:08:20 +08:00
pangtong-fujunshi 09971b555a Merge pull request 'feat: Step 5 引擎接入 — dispatcher/spawner/ticker → handler 统一路由 + H1-H3/S3/D1/D2/D5 修复' (#26) from feat/step5-engine-integration into main
Deploy / ci (push) Failing after 7s
Deploy / deploy (push) Has been skipped
Deploy / notify-deploy-failure (push) Successful in 1s
2026-06-10 22:39:03 +08:00
cfdaily ce1b0902dd fix: S1 handler display_name + S2 import 移顶部 + W1 注释
CI / lint (pull_request) Failing after 7s
CI / test (pull_request) Has been skipped
CI / notify-on-failure (pull_request) Successful in 3s
- S1: vp_name 硬编码字典 → handler.display_name 属性
- S2: ticker/spawner 中 TaskTypeRegistry 局部 import → 移文件顶部
- W1: TaskHandler executor verify 失败不调 on_failure 加注释说明
2026-06-10 22:38:55 +08:00
cfdaily 8d72a1fa19 feat: Step 5 引擎接入 + H1-H3/S3 修复 + 审计 D1/D2/D5 修复
CI / lint (pull_request) Failing after 7s
CI / test (pull_request) Has been skipped
CI / notify-on-failure (pull_request) Successful in 3s
引擎接入(dispatcher/spawner/ticker → handler 统一路由):
- dispatcher: guardrail/on_checks_passed/on_complete → handler 查询
- spawner: _build_prompt/_build_api_section → handler.build_prompt
- ticker: 虚拟项目扫描/assignee/claimed/review/幻觉门控 → handler 判断

Handler 缺陷修复:
- H1: _mark_task_status 加 3 次重试(防 DB 锁)
- H2: review @mention 加 comment_type='review'
- H3: review 非 approved 保持 review 状态(不标 working)
- S3: 通知链接改 Gitea(PR/Issue/Commit)

审计修复:
- D1: pre_spawn 返回值未检查 → 加 if not 抛 RuntimeError
- D2: PromptContext 缺 from_agent/mail_type → 从 must_haves 解析
- D5: _check_reply 查错表 → 恢复查 tasks 表找 in_reply_to

旧方法保留未删(deprecated),确认稳定后再清理。
2026-06-10 22:33:03 +08:00
pangtong-fujunshi 2c970557c8 Merge pull request 'feat: Step 2-4 — Task/Mail/Toolchain Handlers + 11 PromptSections + BaseTaskHandler' (#25) from feat/task-type-handlers-step2-4 into main
Deploy / ci (push) Failing after 7s
Deploy / deploy (push) Has been skipped
Deploy / notify-deploy-failure (push) Successful in 1s
2026-06-10 21:47:03 +08:00
cfdaily 4a4e99f738 fix: S1-S3 review suggestions — type annotations unified, urllib replaces curl, rich notification content
CI / lint (pull_request) Failing after 6s
CI / test (pull_request) Has been skipped
CI / notify-on-failure (pull_request) Successful in 2s
2026-06-10 21:44:47 +08:00
cfdaily 1b0007f244 feat: Step 2-4 Task/Mail/Toolchain handlers + PromptSections + BaseTaskHandler
CI / lint (pull_request) Failing after 6s
CI / test (pull_request) Has been skipped
CI / notify-on-failure (pull_request) Successful in 0s
- base_task_handler.py: 基类统一4步流程(crash→verify→mark→notify)
- task_handler.py: 5 PromptSections + 三信号验证 + review流程
- mail_handler.py: 3 PromptSections + inform/request区分 + 基类统一流程
- toolchain_handler.py: 3 PromptSections + 模板引擎渲染 + Mail API通知
- 背靠背设计-编码一致性检查通过(4严重已修/6轻微保留)
2026-06-10 20:45:06 +08:00
pangtong-fujunshi b953f6da02 Merge pull request 'fix: S1-S4 建议项修复(终验)' (#24) from docs/s-fixes into main
Deploy / ci (push) Failing after 7s
Deploy / deploy (push) Has been skipped
Deploy / notify-deploy-failure (push) Successful in 1s
2026-06-10 16:40:41 +08:00
cfdaily cc974bf258 fix: S1-S4 建议项修复 — 类型标注精确化+BaseTaskHandler标注后续PR+token预算说明
CI / lint (pull_request) Failing after 8s
CI / test (pull_request) Has been skipped
CI / notify-on-failure (pull_request) Successful in 2s
2026-06-10 16:38:00 +08:00
pangtong-fujunshi 42a28585b8 Merge pull request 'docs: Task 系统架构重构设计 v3.0 — 五层架构+BaseTaskHandler+执行流程+决策记录' (#23) from docs/task-type-architecture into main
Deploy / ci (push) Failing after 6s
Deploy / deploy (push) Has been skipped
Deploy / notify-deploy-failure (push) Successful in 0s
2026-06-10 15:42:10 +08:00
cfdaily 0e4d12898d fix: M1-M4 修复 Protocol 签名与设计文档对齐 + §14 去重
CI / lint (pull_request) Failing after 7s
CI / test (pull_request) Has been skipped
CI / notify-on-failure (pull_request) Successful in 3s
2026-06-10 15:41:54 +08:00
cfdaily 86504faf1f docs: 20-task-type-architecture.md v3.0 - §14-§18 五层架构+BaseTaskHandler+执行流程+决策记录 2026-06-10 15:41:54 +08:00
cfdaily 2eba38a5a0 feat: Step 1 — TaskTypeRegistry + PromptComposer 基础设施
- task_type_registry.py: TaskTypeHandler Protocol (10方法+2属性) + TaskTypeRegistry 注册表
- prompt_composer.py: PromptSection Protocol + PromptContext dataclass + PromptComposer 拼装器
- 零依赖,纯新增文件,不影响现有功能
2026-06-10 15:41:54 +08:00
pangtong-fujunshi b0b9a72445 Merge pull request 'docs: Task 系统架构重构设计文档 v2.1(纯文档)' (#22) from docs/task-type-architecture into main
Deploy / ci (push) Successful in 9s
Deploy / deploy (push) Successful in 13s
Deploy / notify-deploy-failure (push) Successful in 1s
2026-06-10 12:42:25 +08:00
cfdaily 1c6b66dc63 docs: 20-task-type-architecture.md v2.1 - 修复 review M1-M3 必修项
CI / lint (pull_request) Successful in 7s
CI / test (pull_request) Successful in 9s
CI / notify-on-failure (pull_request) Successful in 3s
2026-06-10 12:41:43 +08:00
cfdaily 3fa6040b93 docs: 20-task-type-architecture.md v2.0 - 新增 §11-§13 PromptSection 模式 2026-06-10 12:41:43 +08:00
cfdaily 1485719b0e docs: add 20-task-type-architecture.md - TaskTypeRegistry + Handler 架构重构设计 2026-06-10 12:41:43 +08:00
cfdaily b00d43c8ac docs(#13): merge #19 context layers into #13, delete standalone #19
§19 上下文四层改造方案(原独立文档 #19)合并到 #13 工具链设计文档末尾。
v3.1 → v3.3。两个专题本就是一个整体,分开维护增加认知负担。
2026-06-10 12:41:43 +08:00
jiangwei-infra 25e7d46328 Merge pull request 'fix(frontend): resumed_from null→undefined 类型兼容' (#21) from fix/frontend-null-vs-undefined into main
Deploy / ci (push) Successful in 10s
Deploy / deploy (push) Successful in 11s
Deploy / notify-deploy-failure (push) Successful in 1s
2026-06-10 08:12:08 +08:00
cfdaily c1381c0c93 fix(frontend): resumed_from null→undefined 类型兼容
CI / lint (pull_request) Successful in 9s
CI / test (pull_request) Successful in 8s
CI / notify-on-failure (pull_request) Successful in 0s
TypeScript: resumed_from 是 string|null,StatusButtons 期望 string|undefined。
用 ?? undefined 转换。
2026-06-10 08:10:58 +08:00
jiangwei-infra 9a62a45a12 Merge pull request 'fix(test): e2e test collection crash - 跳过 import 安装目录' (#20) from fix/e2e-collection-crash into main
Deploy / ci (push) Successful in 9s
Deploy / deploy (push) Failing after 8s
Deploy / notify-deploy-failure (push) Successful in 0s
2026-06-10 07:53:58 +08:00
cfdaily b90b7b37c7 fix(test): e2e test 在 collection 阶段跳过(不 import 安装目录)
CI / lint (pull_request) Successful in 8s
CI / test (pull_request) Successful in 8s
CI / notify-on-failure (pull_request) Successful in 1s
根因: test_e2e_v27.py 的 skipif 只标记了函数级别,pytest collection 阶段
仍会 import 该文件,触发 sys.path.insert 指向安装目录的 spawner.py。
如果安装目录有 merge conflict 残留,整个 test job crash。

修复: 将 skipif 加入 pytestmark 级别,collection 阶段即跳过。
2026-06-10 07:52:41 +08:00
jiangwei-infra 672fadfee4 Merge pull request 'fix: deploy.yml requirements.txt + frontend resumed_from TS编译' (#18) from fix/deploy-workflow into main
Deploy / ci (push) Successful in 10s
Deploy / deploy (push) Failing after 11s
Deploy / notify-deploy-failure (push) Successful in 1s
2026-06-10 07:21:24 +08:00
cfdaily f380b5f92d fix(frontend): V2Task 添加 resumed_from 字段
CI / lint (pull_request) Successful in 7s
CI / test (pull_request) Successful in 8s
CI / notify-on-failure (pull_request) Successful in 1s
deploy 时 TypeScript 编译报 TS2339: Property 'resumed_from' does not exist on type 'V2Task'。
DB 表有此字段但 TS interface 遗漏。
2026-06-10 07:20:24 +08:00
jiangwei-infra 228a95b9fa Merge pull request 'fix(ci): deploy.yml 用 /tmp/ci-venv 替代 requirements.txt' (#17) from fix/deploy-workflow into main
Deploy / ci (push) Successful in 23s
Deploy / deploy (push) Failing after 9s
Deploy / notify-deploy-failure (push) Successful in 0s
2026-06-10 07:15:39 +08:00
cfdaily 405b7147a7 fix(ci): deploy.yml 用 /tmp/ci-venv + 直接 pip install 替代 requirements.txt
CI / lint (pull_request) Successful in 8s
CI / test (pull_request) Successful in 9s
CI / notify-on-failure (pull_request) Successful in 1s
仓库没有 requirements.txt,deploy workflow 每次 push 到 main 都报错。
改为与 ci.yml 一致的方式:/tmp/ci-venv + 直接 pip install 依赖。
2026-06-10 07:14:29 +08:00
jiangwei-infra b876159b52 Merge pull request 'fix(lint): 修复 PR #14 引入的 lint 回退 (119→0)' (#16) from fix/lint-regression into main
Deploy / ci (push) Failing after 8s
Deploy / deploy (push) Has been skipped
Deploy / notify-deploy-failure (push) Successful in 1s
2026-06-10 07:09:44 +08:00
cfdaily d58e38d58f fix(lint): 修复 PR #14 引入的 lint 回退 (119→0)
CI / lint (pull_request) Successful in 6s
CI / test (pull_request) Successful in 9s
CI / notify-on-failure (pull_request) Successful in 0s
PR #14 从旧分支复制文件导致回退了 PR #10 的 lint 修复。
修复内容:
- autoflake 移除未使用导入/变量
- autopep8 修复缩进/空格
- 手动修复 F821(pathlib→Path), F541(f-string), F841(未使用变量)
- 所有修复均通过 flake8 --max-line-length=120 --extend-ignore=E501 检查 (0 errors)
2026-06-09 23:53:29 +08:00
pangtong-fujunshi 7184079a75 Merge pull request 'fix(spawner): A13 exit=0 always completed' (#15) from fix/a13-exit0-completed into main
Deploy / ci (push) Failing after 6s
Deploy / deploy (push) Has been skipped
Deploy / notify-deploy-failure (push) Successful in 1s
CI / lint (pull_request) Failing after 6s
CI / test (pull_request) Has been skipped
CI / notify-on-failure (pull_request) Successful in 4s
2026-06-09 23:42:05 +08:00
cfdaily fc9b66b905 docs(#08): update A13 revised - exit=0 always completed
CI / lint (pull_request) Failing after 9s
CI / test (pull_request) Has been skipped
CI / notify-on-failure (pull_request) Successful in 4s
Merge old A12/A13 into single A13 revised: trust exit_code=0
regardless of stdout/JSON output. Old logic caused inform Mail
infinite retry loop.
2026-06-09 23:41:53 +08:00
cfdaily 5bb220d237 fix(spawner): A13 exit=0 always completed, not agent_error
exit=0 means process exited normally. Trust the exit code regardless
of stdout/JSON output or task_status. Old logic misclassified inform
Mail completions as agent_error, causing infinite retry loops.

Includes test update: test_task_status_pending expects completed.
2026-06-09 23:41:53 +08:00
cfdaily f7fbdac89c chore: simayi-approved changes - lint fixes, toolchain improvements, healthz
All changes reviewed and APPROVED in PR #12 (Review ID: 40):
- toolchain_routes: webhook repo/org format compat, content dedup (sha256), closed issue filter
- dispatcher: inform mail crash 误标 done 修复
- ticker: cleanup and improvements
- healthz endpoint
- conftest: integration/e2e deselect markers
- docs: design docs, test-guide updates
- various lint/whitespace fixes across 30 files
2026-06-09 23:41:53 +08:00
cfdaily a1a4d7c5a7 docs: #19 adopt simayi review suggestions (v1.1) 2026-06-09 23:41:53 +08:00
jiangwei-infra 717dbc446a Merge pull request 'fix(CI): notify竞态修复 + 双倍触发去重 (PR #12 rebase, reviewed & approved)' (#14) from fix/ci-dedup-v2 into main
Deploy / ci (push) Failing after 6s
Deploy / deploy (push) Has been skipped
Deploy / notify-deploy-failure (push) Successful in 0s
2026-06-09 23:36:19 +08:00
cfdaily ee1ef23ace fix(spawner): crash cooldown分级 + inform mail crash误标done修复
CI / lint (pull_request) Failing after 7s
CI / test (pull_request) Has been skipped
CI / notify-on-failure (pull_request) Successful in 4s
- crashed outcome cooldown 60s(vs 其他 300s)
- import init_db
- whitespace/lint fixes
2026-06-09 23:35:02 +08:00
cfdaily 20b3b5facb fix(ci): 修复notify竞态条件 - 用needs.result替代commit status查询
根因:notify-on-failure job 通过 commit status API 查询结果时,
自身的 pending status 会污染查询结果(竞态条件):
1. lint/test 都 success
2. notify 开始运行,自身状态 pending 写入 commit status
3. notify 查询 commit status → 看到 pending(自己的)≠ success
4. 误发 [CI] 失败 评论 + webhook 触发 Mail 通知

修复方案:
- 不再查询 commit status API
- 直接用 needs.lint.result 和 needs.test.result 判断
- 只有明确的 failure 才发通知
- 同时去掉 push 触发避免双倍运行
2026-06-09 23:34:44 +08:00
cfdaily 05201d778e fix(ci): 去掉push触发避免双倍触发 + 修复notify误报
1. 触发器:去掉 push,只保留 pull_request(opened, synchronize)
   - 每次 push 到 PR 分支不再跑 2 次 CI
2. notify-on-failure:只有明确的 failure 状态才发通知
   - 之前:空状态/unknown/pending 都触发通知(误报根因)
   - 现在:只有 STATUS=failure 才发通知
3. venv 路径:统一用 /tmp/ci-venv-lint 和 /tmp/ci-venv-test
   - 避免 host 模式下与开发目录 .venv 冲突
2026-06-09 23:34:41 +08:00
pangtong-fujunshi 5b2c42687a Merge pull request 'docs: add #19 toolchain context layers design' (#11) from docs/19-toolchain-context-layers-v2 into main
Deploy / ci (push) Failing after 6s
Deploy / deploy (push) Has been skipped
Deploy / notify-deploy-failure (push) Successful in 0s
2026-06-09 22:26:05 +08:00
cfdaily 149921fb5f docs: add #19 toolchain context layers design
CI / lint (push) Successful in 7s
CI / test (push) Successful in 14s
CI / lint (pull_request) Successful in 7s
CI / notify-on-failure (push) Successful in 0s
CI / test (pull_request) Successful in 14s
CI / notify-on-failure (pull_request) Successful in 0s
2026-06-09 22:25:17 +08:00
pangtong-fujunshi 59068b8d2a Merge pull request 'fix: resolve all flake8 lint errors (118 → 0)' (#10) from fix/lint-cleanup into main
Deploy / ci (push) Failing after 6s
Deploy / deploy (push) Has been skipped
Deploy / notify-deploy-failure (push) Successful in 1s
2026-06-09 22:24:10 +08:00
cfdaily 242057dfd6 fix: remove dead code config.get experience
CI / lint (push) Successful in 6s
CI / test (push) Successful in 14s
CI / notify-on-failure (push) Successful in 1s
CI / lint (pull_request) Failing after 13m39s
CI / test (pull_request) Has been skipped
CI / notify-on-failure (pull_request) Failing after 14m58s
2026-06-09 22:23:58 +08:00
cfdaily 09a0928bbc fix: resolve all flake8 lint errors (118 → 0)
CI / lint (push) Successful in 8s
CI / lint (pull_request) Successful in 5s
CI / test (push) Failing after 8s
CI / test (pull_request) Failing after 8s
CI / notify-on-failure (push) Successful in 1s
CI / notify-on-failure (pull_request) Successful in 3s
2026-06-09 16:43:41 +08:00
jiangwei-infra 62d8ced8ed Merge pull request 'fix(ci): install all test dependencies' (#8) from fix/ci-deps into main
Deploy / ci (push) Failing after 6s
Deploy / deploy (push) Has been skipped
Deploy / notify-deploy-failure (push) Successful in 3s
2026-06-09 14:53:50 +08:00
jiangwei-infra 51ccbbf4b5 fix(ci): install all test dependencies (fastapi, pydantic, pyyaml, etc.)
CI / lint (push) Failing after 6s
CI / test (push) Has been skipped
CI / notify-on-failure (push) Successful in 0s
CI / lint (pull_request) Failing after 7s
CI / test (pull_request) Has been skipped
CI / notify-on-failure (pull_request) Successful in 6s
2026-06-09 14:53:24 +08:00
jiangwei-infra fe24a86d7d Merge pull request 'fix(ci): install pytest directly instead of editable mode' (#7) from fix/ci-pytest into main
Deploy / ci (push) Failing after 7s
Deploy / deploy (push) Has been skipped
Deploy / notify-deploy-failure (push) Successful in 0s
2026-06-09 14:33:52 +08:00
jiangwei-infra 25c9cfd1ed fix(ci): install pytest directly instead of editable mode
CI / lint (push) Failing after 6s
CI / test (push) Has been skipped
CI / notify-on-failure (push) Successful in 0s
CI / lint (pull_request) Failing after 7s
CI / test (pull_request) Has been skipped
CI / notify-on-failure (pull_request) Successful in 6s
2026-06-09 14:33:28 +08:00
jiangwei-infra 5af0e0e91d Merge pull request 'fix(ci): use pyproject.toml instead of missing requirements.txt' (#6) from fix/ci-requirements into main
Deploy / ci (push) Failing after 7s
Deploy / deploy (push) Has been skipped
Deploy / notify-deploy-failure (push) Successful in 1s
2026-06-09 14:24:42 +08:00
jiangwei-infra 05246d6469 fix(ci): use pyproject.toml instead of missing requirements.txt
CI / lint (push) Failing after 7s
CI / test (push) Has been skipped
CI / notify-on-failure (push) Successful in 1s
CI / lint (pull_request) Failing after 7s
CI / test (pull_request) Has been skipped
CI / notify-on-failure (pull_request) Successful in 5s
2026-06-09 14:24:02 +08:00
52 changed files with 6052 additions and 868 deletions
+18 -17
View File
@@ -1,9 +1,10 @@
# CI 管道 — moziplus v2.0
#
# 触发条件:
# - push(非 main 分支)
# - pull_requestopened, synchronize
#
# 注意:只保留 pull_request 触发,避免 push + pull_request 双倍触发
#
# Gitea v1.23.4 限制注意:
# - 不支持 failure() 表达式,用 always() + shell 条件判断替代
# - 不支持 concurrency / continue-on-error / timeout-minutes / permissions
@@ -13,10 +14,6 @@
name: CI
on:
push:
branches:
- '**'
- '!main'
pull_request:
types: [opened, synchronize]
@@ -53,7 +50,8 @@ jobs:
/tmp/ci-venv-test/bin/pytest tests/ -m "not e2e" -x -q
# ── Job 3: CI 失败通知 ───────────────────────────────
# v1.23 不支持 failure(),用 always() + shell 检查 commit status 替代
# 使用 needs.<job>.result 直接判断,不查询 commit status API
# 根因:notify 自身的 pending status 会污染 commit status 查询结果(竞态条件)
notify-on-failure:
runs-on: macos-arm64
needs: [lint, test]
@@ -62,31 +60,34 @@ jobs:
- name: Check results and notify
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
LINT_RESULT: ${{ needs.lint.result }}
TEST_RESULT: ${{ needs.test.result }}
run: |
# 查询当前 commit 的 status
STATUS=$(curl -sf \
-H "Authorization: token $GITEA_TOKEN" \
"${{ gitea.api_url }}/repos/${{ gitea.repository }}/commits/${{ gitea.sha }}/status" \
| python3 -c "import sys,json; print(json.load(sys.stdin).get('state',''))" 2>/dev/null || echo "")
echo "Lint result: $LINT_RESULT"
echo "Test result: $TEST_RESULT"
echo "Commit status: $STATUS"
if [ "$STATUS" != "success" ]; then
echo "CI failed or status unknown, sending notification..."
# 只有 lint 或 test 明确失败时才发通知
if [ "$LINT_RESULT" = "failure" ] || [ "$TEST_RESULT" = "failure" ]; then
echo "CI has failures, sending notification..."
# 如果是 PR 事件,写评论通知
PR_NUMBER="${{ gitea.event.pull_request.number }}"
if [ -n "$PR_NUMBER" ]; then
# 构建失败摘要
FAILED_JOBS=""
[ "$LINT_RESULT" = "failure" ] && FAILED_JOBS="${FAILED_JOBS}lint "
[ "$TEST_RESULT" = "failure" ] && FAILED_JOBS="${FAILED_JOBS}test "
curl -sf -X POST \
-H "Authorization: token $GITEA_TOKEN" \
-H "Content-Type: application/json" \
"${{ gitea.api_url }}/repos/${{ gitea.repository }}/issues/${PR_NUMBER}/comments" \
-d "{\"body\": \"[CI] 失败\\n\\n分支: ${{ gitea.ref_name }}\\n触发 commit: \`${{ gitea.sha }}\`\\n请检查 CI 日志并修复。\"}" \
-d "{\"body\": \"[CI] 失败\\n\\n分支: ${{ gitea.ref_name }}\\n触发 commit: \`${{ gitea.sha }}\`\\n失败 Job: ${FAILED_JOBS}\\n请检查 CI 日志并修复。\"}" \
|| echo "Failed to post PR comment"
echo "PR comment posted."
else
echo "Not a PR event, skipping PR comment."
fi
else
echo "CI passed, no notification needed."
echo "No explicit failures (results: lint=$LINT_RESULT, test=$TEST_RESULT), no notification needed."
fi
+67 -4
View File
@@ -23,16 +23,16 @@ jobs:
- name: Setup Python
run: |
python3 -m venv .venv
.venv/bin/pip install --quiet -r requirements.txt
python3 -m venv /tmp/ci-venv-deploy
/tmp/ci-venv-deploy/bin/pip install --quiet flake8 fastapi pydantic pyyaml uvicorn requests pytest pytest-asyncio httpx
- name: Lint
run: |
.venv/bin/flake8 src/ --max-line-length=120 --extend-ignore=E501
/tmp/ci-venv-deploy/bin/flake8 src/ --max-line-length=120 --extend-ignore=E501
- name: Unit & Integration Tests
run: |
.venv/bin/pytest tests/ -m "not e2e" -x -q
/tmp/ci-venv-deploy/bin/pytest tests/ -m "not e2e" -x -q
# ── Job 2: 部署 ─────────────────────────────────────
deploy:
@@ -83,3 +83,66 @@ jobs:
else
echo "Deploy succeeded."
fi
# ── Job 4: 部署成功通知 ──────────────────────────────
notify-deploy-success:
runs-on: macos-arm64
needs: [ci, deploy]
if: always()
steps:
- name: Notify deploy success
env:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
DEPLOY_RESULT: ${{ needs.deploy.result }}
run: |
if [ "$DEPLOY_RESULT" != "success" ]; then
echo "Deploy did not succeed (result: $DEPLOY_RESULT), skipping success notification."
exit 0
fi
echo "Deploy succeeded, sending notification..."
API_URL="${{ gitea.api_url }}"
REPO="${{ gitea.repository }}"
COMMIT_SHA="${{ gitea.sha }}"
# 查询关联的 merged PR 作者
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 "")
# 确定通知对象
if [ -n "$PR_AUTHOR" ]; then
NOTIFY_TO="$PR_AUTHOR"
else
# direct push 场景通知 jiangwei-infra
NOTIFY_TO="jiangwei-infra"
fi
# 发送 Mail 通知
MAIL_TITLE="[CD] 部署成功: $(echo $COMMIT_SHA | cut -c1-8)"
MAIL_TEXT="部署成功。Commit: ${COMMIT_SHA}"
curl --max-time 5 -s -X POST http://localhost:8083/api/mail \
-H "Content-Type: application/json" \
-d "{\"from\":\"system\",\"to\":\"$NOTIFY_TO\",\"title\":\"$MAIL_TITLE\",\"text\":\"$MAIL_TEXT\",\"type\":\"inform\"}" \
|| echo "Mail notification failed (non-blocking)"
# 同时通知 pangtong-fujunshi(如果 PR 作者不是 pangtong
if [ "$NOTIFY_TO" != "pangtong-fujunshi" ]; then
curl --max-time 5 -s -X POST http://localhost:8083/api/mail \
-H "Content-Type: application/json" \
-d "{\"from\":\"system\",\"to\":\"pangtong-fujunshi\",\"title\":\"$MAIL_TITLE\",\"text\":\"$MAIL_TEXT\",\"type\":\"inform\"}" \
|| echo "Mail notification failed (non-blocking)"
fi
echo "Deploy success notification sent to: $NOTIFY_TO"
+14 -7
View File
@@ -233,20 +233,27 @@ def _revive_session(agent_id: str) -> bool:
pass
```
### 4.5 O5: compact 扫描条件收紧
### 4.5 O5: compact 检测(§24 rotation-only v3
当前 compact 扫描在 status 非 idle/done/unknown/None 时都触发,范围过宽。
§24 设计文档:`docs/design/24-compact-detection-fix.md`
**改后**只在 status 为 running 或 compacting 相关时扫描:
**检测方法**读 gateway 日志尾部 2MB,按 sessionKey 过滤 `[compaction] rotated active transcript` 事件。
如果最近的 rotation 事件在 120s 窗口内 → 视为 compact 循环进行中(可能还在 post-compact retry)。
旧方法 `_check_recent_compaction_jsonl`(扫描 session jsonl 的 `type=compaction` 事件)保留作为 fallback。
```python
# 只在这些状态下检查 compact
if result["status"] in ("running",) and sf:
result["recent_compact"] = AgentSpawner._check_recent_compaction_jsonl(sf)
# §24 v3: compact 检测优先用 gateway 日志 rotation 事件
if result["status"] not in ("idle", "unknown", None):
session_key = f"agent:{agent_id}:main"
result["recent_compact"] = AgentSpawner._check_compact_in_progress_gateway(
session_key)
if not result["recent_compact"] and sf:
result["recent_compact"] = AgentSpawner._check_recent_compaction_jsonl(sf)
```
注:Gateway 的 sessions.json status 实际值主要是 `idle/running/timeout/failed`
`running` 时检查 compact 有意义(agent turn 执行中可能触发 compact
非空闲状态(`running`/`timeout`/`failed`)时检查 compact 有意义
其他状态不需要检查。
## 五、改动范围
@@ -110,8 +110,8 @@ TCP 握手只能检测进程端口是否监听,无法检测 Gateway **业务
| 编号 | 条件 | outcome | 可恢复? | 处理 |
|------|------|---------|----------|------|
| A12 | exit=0 + task_status ∈ {done, review} | completed | — | 正常完成 |
| A13 | exit=0 + task_status ∉ {done, review} | agent_error | ❌ | 标 failed + 原因写黑板 |
| A12 | ~~已合并到 A13 revised~~ | — | — | 见下方 A13 revised |
| **A13 revised** | exit=0(无 JSON 输出) | completed | — | 信任进程退出码,exit=0 即正常完成。旧逻辑按 task_status 区分,非终态判 agent_error → 导致 inform Mail 永不标 done,与 dispatcher inform auto-done 形成死循环 |
| **A14** | exit=130 (SIGINT) 或 exit=143 (SIGTERM) | interrupted | ✅ | retry |
| **A15** | exit≠0 + stderr 含 network 关键字 | gateway_unreachable | ✅ | retry + cooldown 30s |
| **A16** | exit≠0 + stderr 含 compact 关键字 | compact_interrupted | ✅ | retry + cooldown 60s |
+531 -9
View File
@@ -1,6 +1,6 @@
# 三国团队工具链与开发流程设计
> **状态**: v3.1P3 端到端验证通过 + 调研结论写入 + Review API 枚举值修正
> **状态**: v3.3#19 上下文四层改造合并 + CI 修复 + A13 修订
> **作者**: 庞统(副军师)🐦
> **评审**: 司马懿(仲达)🗡️
> **日期**: 2026-06-06
@@ -33,26 +33,173 @@
| 项 | 配置 |
|----|------|
| 地址 | `http://192.168.2.154:3000` |
| 版本 | v1.23.4 |
| 认证 | HTTP + token(待配置 |
| 权限 | cfdaily 用户;姜维持有 admin 权限(启用 Actions、分支保护等) |
| 版本 | v1.26.22026-06-11 从 v1.23.4 升级) |
| 认证 | HTTP + tokenadmin 账号(姜维持有 |
| 权限 | 姜维持有 admin 权限(启用 Actions、分支保护、org webhook 等) |
| 数据库 | SQLite3 |
| 部署方式 | Docker(NAS 群晖),数据卷 `/volume2/@docker/volumes/gitea-data/_data` |
### 2.2 CI/CDGitea Actions
| 项 | 配置 |
|----|------|
| Runner | Mac mini 裸机,act-runnerGo 二进制 |
| Runner | Mac mini 裸机,gitea-runner v1.0.8(通过 PM2 管理 `sanguo-act-runner` |
| 配置文件 | `.gitea/workflows/*.yml`,每个项目自管 |
| 语法 | 兼容 GitHub Actionsv1.23.4 已验证支持) |
| 触发 | push / PR / tag |
| 语法 | 兼容 GitHub Actionsv1.26.2 已验证支持 concurrency groups |
| 触发 | push / PR / tag / workflow_dispatch |
| v1.26 新增 | concurrency groups、re-run failed jobs、可配置 GITEA_TOKEN 权限 |
| 仍不支持 | `failure()``continue-on-error``timeout-minutes` |
### 2.3 部署目标
### 2.4 Gitea 基础设施 Setup 记录(2026-06-11 姜维)
> 以下为 Gitea 从 v1.23.4 升级到 v1.26.2 的完整操作记录,作为未来参考。
#### 2.4.1 升级 v1.23.4 → v1.26.2
**升级原因**v1.23.4 不支持 concurrency groups,导致双倍触发问题无根因解法。
**升级步骤**
1. 备份:`docker exec sanguo_gitea gitea dump -c /data/gitea/conf/app.ini -f /data/gitea/gitea-backup-pre-v126.zip`765MB
2. 拉取镜像:Mac 上 skopeo 下载 → python docker SDK 远程 load(群晖 Docker Hub 太慢)
3. 停止旧容器 + rename 保留回滚
4. 启动新容器(数据库自动迁移 Migration[312]→[326],含 concurrency #323
5. 验证:API + Web UI + 仓库数据 + 用户数据
**踩坑:群晖内核 3.10 + git 2.52 不兼容**
- 根因:git 2.52 使用 `getrandom(2)` syscall,群晖内核 3.10.108 不支持(3.17 才加入)
- 症状:`git push``unable to create temporary file: Function not implemented`
- 修复:entrypoint 脚本在容器启动时自动从本地缓存降级 git 到 2.45.4
- 持久化:`/data/entrypoint-wrapper.sh` + `/data/git-2.45.4-r0.apk` 在数据卷里,容器重建不丢失
- 群晖内核**无法通过 DSM 升级**,内核版本跟硬件型号绑定
**完整重建命令**
```bash
docker -H tcp://192.168.2.154:2375 run -d \
--name sanguo_gitea \
--restart=always \
-p 3000:3000 \
-p 2221:22 \
-v /volume2/@docker/volumes/gitea-data/_data:/data \
-e GITEA__database__DB_TYPE=sqlite3 \
-e GITEA__database__PATH=/data/gitea/gitea.db \
-e GITEA__server__ROOT_URL=http://192.168.2.154:3000/ \
--entrypoint /bin/sh \
gitea/gitea:1.26.2 \
-c '/data/entrypoint-wrapper.sh'
```
#### 2.4.2 act_runner 升级 v0.2.11 → v1.0.8
**升级原因**v0.2.11 的 multi-step job 执行有 bugSetup Python 和 Lint step 被跳过。
**升级步骤**
1. 下载 `gitea-runner-1.0.8-darwin-arm64`(从 gitea.com releases
2. `codesign --force --sign -` 重签(macOS Gatekeeper 会 SIGKILL 未签名的二进制)
3. 替换 `/Users/chufeng/bin/act_runner`
4. PM2 restart `sanguo-act-runner`
**注意**act_runner 通过 **PM2** 管理(`sanguo-act-runner`),不是 launchd。launchd plist 仅为备份。
**PM2 常用命令**
```bash
pm2 restart sanguo-act-runner # 重启
pm2 logs sanguo-act-runner # 查看日志
pm2 show sanguo-act-runner # 详情
```
#### 2.4.3 CI Workflow 配置
**三个 workflow 文件**
| 文件 | 触发 | concurrency | 说明 |
|------|------|-------------|------|
| `ci.yml` | `pull_request` | `group: ci-${{ gitea.ref }}, cancel-in-progress: true` | 同一 PR 新 push 自动取消旧 run |
| `deploy.yml` | `push to main` | `group: deploy-${{ gitea.ref }}, cancel-in-progress: false` | 部署排队不取消 |
| `e2e.yml` | `workflow_dispatch` | `group: e2e-${{ gitea.ref }}, cancel-in-progress: true` | 手动触发 |
**Branch Protectionmain 分支)**
- 禁止直接 push
- status check`CI / lint (pull_request)` 必须通过
- 至少 1 人 Review
**⚠️ 踩坑**v1.26 上报的 commit status context 格式变了:
- 旧格式:`lint`
- 新格式:`CI / lint (pull_request)`
- branch protection 必须用新格式匹配,否则 merge 报 "Not all required status checks successful"
#### 2.4.4 Org Webhook 配置
- **对象**Gitea 组织 `sanguo` webhook id=28
- **URL**`http://192.168.2.153:8083/webhook/gitea`
- **事件**16 个(push/issues/PR/PR review 等)
**⚠️ 踩坑**Gitea v1.26 的 PATCH hooks API,只传 `{"active": true}` 会把 events 重置为 `["push"]`。**必须每次 PATCH 都带上完整的 events 列表。**
**临时措施(已恢复)**2026-06-10 曾临时关闭 webhookCI 错误大爆炸期间),2026-06-11 已恢复。
#### 2.4.5 凭据管理
| 凭据 | 用途 | 持有者 |
|------|------|--------|
| Gitea admin:cf7561523 | 仓库管理、branch protection、org webhook | 姜维 |
| Gitea PAT (jiangwei-infra) | API 操作、git clone/push | 姜维 |
| Gitea PAT (cfdaily) | CI workflow 中的 git 操作 | CI secrets |
#### 2.4.6 备份与回滚
| 项目 | 路径 | 说明 |
|------|------|------|
| Gitea 数据库备份 | `/data/gitea/gitea-backup-pre-v126.zip` | 升级前 dump |
| 旧容器 | 已清理 | 升级验证通过后 `docker rm` |
| 变更记录 | `~/.openclaw/workspace-jiangwei/changes/gitea-emergency-2026-06-10.md` | 完整操作日志 |
| 环境 | 位置 | 说明 |
|------|------|------|
| Mac mini 本机 | `~/.sanguo_projects/<project>/` | 主力开发和运行环境 |
| NAS Docker | `192.168.2.154` | 部分服务(Gitea、回测等) |
#### 2.4.7 Gitea 迁移验证记录(2026-06-11 司马懿)
> 验证 Gitea 从 gitee 迁移完成后的状态。所有验证在 2026-06-11 完成。
**仓库迁移状态**
| 项目 | Gitea 仓库 | 开发目录 | 远程地址 | gitee 残留 |
|------|-----------|---------|---------|----------|
| sanguo_moziplus_v2 | `sanguo/sanguo_moziplus_v2` | `~/.openclaw/sanguo_projects/sanguo_moziplus_v2/` | `http://192.168.2.154:3000/sanguo/sanguo_moziplus_v2.git` | ✅ 无 |
| sanguo_quant_live | `sanguo/sanguo_quant_live` | `~/.openclaw/sanguo_projects/sanguo_quant_live/` | `http://192.168.2.154:3000/sanguo/sanguo_quant_live.git` | ✅ 无 |
| sanguo_vnpy | `sanguo/sanguo_vnpy` | `~/.openclaw/sanguo_projects/sanguo_vnpy/` | `http://192.168.2.154:3000/sanguo/sanguo_vnpy.git` | ✅ 无 |
**验证方法**:在 3 个开发目录分别执行 `git remote -v`,确认 origin 指向 gitea 且无 gitee remote。
**CI 管道验证**
| 验证项 | 结果 | 备注 |
|--------|------|------|
| PR #33 Lint 修复 CI 通过 | ✅ | flake8 全通过 |
| CD pipeline (deploy.yml) 合并 | ✅ | 含 CI + deploy + notify-deploy-failure 三个 job |
| Branch protection 生效 | ✅ | main 分支需 CI 通过 + 1 人 Review 才能合并 |
| Gitea squash merge 兼容 | ✅ | `merge_commit_sha` 在 squash merge 下仍等于 gitea.sha |
**工具链事件中枢验证**
| Webhook → Mail 流 | 验证结果 |
|-------------------|--------|
| PR opened → Review 请求 Mail | ✅ 司马懿收到 PR #30-#35 的 Review 请求 |
| PR review → 结果 Mail | ✅ 张飞/庞统收到 Review 结果通知 |
| Issue assigned → 指派 Mail | ✅ E2E 验证通过) |
| CI 失败评论 → 通知 Mail | ✅ (E2E 验证通过) |
| PR synchronize → reviewer 重审 Mail | ✅ 新增(§23 |
| Review COMMENTED → PR 作者通知 | ✅ 新增(§23) |
**Agent Gitea 凭据**(各 Agent 自行持有 PAT):
| Agent | Gitea 用户名 | PAT 用途 |
|-------|-------------|--------:|
| simayi-challenger | simayi-challenger | PR Review 提交 |
| pangtong-fujunshi | pangtong-fujunshi | PR 创建/合并、代码 push |
| jiangwei-infra | jiangwei-infra | 基础设施配置(admin 级操作) |
---
## §3. 分支策略
@@ -157,7 +304,7 @@ Open → In Progress → Review → Closed
每个项目在 `.gitea/workflows/ci.yml` 自定义具体步骤,但遵循统一骨架。
> **注**Gitea Actions v1.23.4 不支持 `paths` 过滤触发条件。通过路径判断放在 job 级别的 `if` 条件中,使用确定支持的语法。(M4 修订)
> **注**Gitea Actions v1.26.2 不支持 `paths` 过滤触发条件。通过路径判断放在 job 级别的 `if` 条件中,使用确定支持的语法。(M4 修订)
```yaml
name: CI
@@ -2765,3 +2912,378 @@ Gitea v1.23.4 自带完整的 CI 管理界面:
| §16.8 #10 | Gitea v1.23.4 review payload 调研结论(姜维 2026-06-08):Gitea v1.23.4 review payload 只有 `type` + `content`,没有 `state`/`body`/`user`,这不是 org vs repo 差异而是 Gitea 设计。v1.24.0 格式不变。双格式兼容是防御性编码,保持现状 |
| §16.8 #11 | Spawner compact 检测窗口修复:窗口 300s→900s,尾部读取 50KB→1MB。实测长对话中 compact 记录被推出窗口导致漏检 |
| §16.8 #12 | inform 类型 Mail crash 误标 done bug 修复:`_mail_auto_complete` 增加 outcome 感知,inform 用白名单(completed/claimed/no_reply)控制 done 标记。spawner crash cooldown 300s→60s |
---
### 一、问题诊断
#### 1.1 E2E 真实场景测试暴露的三个断层
主公在 moziplus-v2 仓库创建了 Issue #32(添加 /api/stats 端点),指派张飞。链条在第一步就断了。
| 断层 | 现象 | 根因 |
|------|------|------|
| **Agent 不知道该做什么** | 张飞收到 Issue 指派 Mail,回复"已阅"就结束了 | Mail 模板(issue_assigned.md5 行信息,无流程引导;spawn prompt 说"已阅即可" |
| **Agent 去错了仓库** | 张飞去读了 sanguo_moziplus_v2 平台代码,而不是空的实验仓库 moziplus-v2 | Mail 模板没有仓库 clone URL,张飞凭习惯去了开发目录 |
| **Agent 在 Control UI 提问** | 张飞遇到问题直接在 Control UI 问主公,没有去 Gitea Issue 评论 | 没有任何地方引导"有疑问去 Gitea Issue 评论" |
| **Agent 不知道怎么协作** | 张飞判断任务需要澄清,但不知道该怎么请求澄清 | 没有"做不了→在 Issue 评论 / Mail 庞统"的回退路径 |
| **跨 Agent @mention 无法通知** | 张飞在 Issue 评论 @赵云,赵云收不到通知 | issue_comment handler 只处理 [CI] 评论,@mention 被忽略 |
#### 1.2 根因:工具链在四层架构中的断层
| 层 | 应该有 | 实际有 | Gap |
|---|---|---|---|
| **L0 铁律** | — | — | 无需改动 |
| **L1 角色** | 工具链协作行为规范(所有 Agent 共享) | 无 | AGENTS.md 没有工具链相关内容 |
| **L2 引擎注入** | 事件上下文(仓库 clone URL、Gitea API、Issue/PR 详情) | Mail 模板只有 5 行摘要 | 缺仓库信息和流程引导 |
| **L3 被动参考** | 技术细节(分支命名、commit 规范、PR 创建方式) | git-workflow 等 Skill 已存在但没人触发 | Agent 不知道该加载哪个 Skill |
---
### 二、改造方案:四层归属
#### 2.1 分层原则
| 层 | 放什么 | 不放什么 | 理由 |
|---|---|---|---|
| **L0** | 不放 | — | 工具链不是安全底线 |
| **L1** | 协作行为规范:收到什么通知该做什么、遇到问题怎么办 | 技术细节(分支命名、commit 格式) | 行为规范是团队常识,每个 Agent 都要知道 |
| **L2** | 事件上下文:仓库 clone URL、Gitea API URL、Issue/PR 链接、动态信息 | 固定的协作流程 | 动态信息每次不同,由 Mail 模板 + spawn 时注入 |
| **L3** | 技术细节:git-workflow、code-review 等 Skill 全文 | — | 按需加载,Agent 知道"我要提 PR"后自己读 |
#### 2.2 各层具体内容
##### L1AGENTS.md 加工具链协作行为段(所有 Agent 统一)
```markdown
## 工具链协作(Gitea
收到 Gitea 事件通知(Issue 指派、Review 请求、CI 失败等)时,按以下流程操作:
### 基本流程
- **Issue 指派** → clone 仓库 → 开分支 → 编码 → 提 PR(参考 git-workflow Skill
- **Review 请求** → 读 PR diffGitea API)→ 提交 Review(参考 code-review Skill
- **Review 通过** → 等 merge
- **Review 驳回** → 看 review body → 修代码 → 重新 push
- **CI 失败** → 看错误摘要 → 修代码 → push(自动重触发 CI)
- **部署失败** → 查 deploy 日志 → 修复
### 协作规则
- **有疑问?** 在 Gitea Issue 下评论,不要在 Control UI 或 Mail 里问
- **需要别人帮忙?** 在 Issue 评论中 @mention 对应 Agent(如 @zhaoyun-data
- **做不了?** 回复 Mail 说明原因和建议的接手人
- **获取完整上下文** → 用 Gitea API 拉取 Issue 详情和评论,不要只看 Mail 里的快照
### Gitea API 速查
> 其中 `{owner}/{repo}` 替换为实际仓库,如 `sanguo/sanguo_moziplus_v2`
- Issue 详情: GET /api/v1/repos/{owner}/{repo}/issues/{number}
- Issue 评论: GET /api/v1/repos/{owner}/{repo}/issues/{number}/comments
- PR diff: GET /api/v1/repos/{owner}/{repo}/pulls/{number}.diff
- 提交 Review: POST /api/v1/repos/{owner}/{repo}/pulls/{number}/reviews
```
**改动范围**6 个 Agent 的 AGENTS.md 各加一段(内容统一)。
##### L2:Mail 模板精简 + 事件上下文注入
**原则**:模板只放摘要 + 链接 + 仓库信息,不写固定步骤(步骤在 L1)。
**issue_assigned.md** 改为:
```markdown
Issue 指派
Issue: {issue_url}
标题: {issue_title}
标签: {labels}
📋 获取完整上下文(先读再动手):
- Issue 详情: GET {gitea_api}/repos/{repo}/issues/{issue_number}
- Issue 评论: GET {gitea_api}/repos/{repo}/issues/{issue_number}/comments
仓库: {repo_clone_url}
建议分支: feat/issue-{issue_number}-{brief}
```
**review_request.md** 改为:
```markdown
PR Review 请求
PR: {pr_url}
标题: {pr_title}
作者: {pr_author}
分支: {branch}
风险级别: {risk_level}
📋 获取完整上下文:
- PR diff: GET {gitea_api}/repos/{repo}/pulls/{pr_number}.diff
- PR 文件列表: GET {gitea_api}/repos/{repo}/pulls/{pr_number}/files
```
**review_result.md** 改为:
```markdown
Review {result}
PR: {pr_url}
标题: {pr_title}
审查者: {reviewer}
{review_body}
```
**ci_failure.md** 改为:
```markdown
CI 失败
Issue: {issue_url}
分支: {branch}
错误摘要:
{error_summary}
📋 CI 日志: {gitea_url}/{repo}/actions
修复后 push 会自动重触发 CI。
```
**deploy_failure.md** 改为:
```markdown
部署失败
仓库: {repo}
Commit: {commit_sha}
📋 排查步骤:
- CI 日志: {gitea_url}/{repo}/actions
- 服务器: pm2 logs {service_name}
```
**L2 代码改动**`toolchain_routes.py`):
1. 从 Webhook payload 的 `repository` 对象提取 `clone_url``html_url`
2. `render_template()` 传入新变量:`gitea_api``gitea_url``repo_clone_url`
3. 所有模板变量统一补齐
##### L3Skill 按需加载(不改 Skill 本身)
git-workflow、code-review 等 Skill 保持不变。
L1 的协作行为段里会引用 Skill 名称("参考 git-workflow Skill"),Agent 收到 Mail 后根据 L1 的引导自主加载对应 Skill。
**不改 Skill 路由机制**——靠 L1 的文案触发 Agent 的 Skill 路由器匹配。
---
### 三、新增功能:issue_comment @mention 通知
#### 3.1 设计
当前 `_handle_issue_comment` 只处理 `[CI]` 前缀评论。扩展为:
```
issue_comment 事件
├── 含 [CI] / CI 失败 → 原有 CI 失败通知逻辑
└── 含 @username → 解析 @mention → Mail 通知被 @的 Agent
```
#### 3.2 实现
**`toolchain_routes.py` 新增 `_handle_issue_comment_mention()`**
```python
AGENT_IDS = {
"zhangfei-dev", "guanyu-dev", "zhaoyun-data",
"jiangwei-infra", "simayi-challenger", "pangtong-fujunshi",
}
# 前缀映射:@张飞 → zhangfei-dev
# 中文名映射:Agent 在 Gitea Issue 评论中可能用中文名 @mention
# 英文短名映射:Agent 可能用不带 -dev/-infra 后缀的短名
AGENT_ALIAS = {
"张飞": "zhangfei-dev",
"关羽": "guanyu-dev",
"赵云": "zhaoyun-data",
"姜维": "jiangwei-infra",
"司马懿": "simayi-challenger",
"庞统": "pangtong-fujunshi",
"pangtong": "pangtong-fujunshi",
"simayi": "simayi-challenger",
"zhangfei": "zhangfei-dev",
"guanyu": "guanyu-dev",
"zhaoyun": "zhaoyun-data",
"jiangwei": "jiangwei-infra",
}
def extract_mentions(body: str, sender: str) -> list[str]:
"""从评论 body 中提取 @mention 的 Agent ID"""
candidates = re.findall(r"@([a-zA-Z\u4e00-\u9fa5][a-zA-Z0-9\u4e00-\u9fff-]*)", body)
result = set()
for c in candidates:
# 精确匹配
if c in AGENT_IDS:
result.add(c)
# 前缀/别名匹配
elif c in AGENT_ALIAS:
result.add(AGENT_ALIAS[c])
else:
# 前缀模糊匹配:@zhangfei → zhangfei-dev
for aid in AGENT_IDS:
if aid.startswith(c):
result.add(aid)
break
# 过滤掉评论者自己
result.discard(sender)
return list(result)
```
**新增 mention 通知模板** `templates/toolchain/mention.md`
```markdown
你在 Issue 中被 @mention
Issue: {issue_url}
评论者: {commenter}
评论内容:
{comment_body}
📋 获取完整上下文:
- Issue 详情: GET {gitea_api}/repos/{repo}/issues/{issue_number}
- Issue 评论: GET {gitea_api}/repos/{repo}/issues/{issue_number}/comments
```
**改动 `_handle_issue_comment`**
```python
async def _handle_issue_comment(payload):
comment = payload.get("comment", {})
body = comment.get("body", "")
sender = comment.get("user", {}).get("login", "")
repo = _repo_fullname(payload)
issue = payload.get("issue", {})
# 原有 CI 失败逻辑(不变)
if "[CI]" in body or "CI 失败" in body:
# ... 原有逻辑 ...
# 新增:@mention 通知
mentions = extract_mentions(body, sender)
if mentions:
issue_number = issue.get("number", 0)
issue_title = issue.get("title", "")
text = render_template("mention", {
"repo": repo,
"issue_number": str(issue_number),
"issue_url": issue.get("html_url", ""),
"commenter": sender,
"comment_body": body[:500],
"gitea_api": "http://192.168.2.154:3000/api/v1",
})
title = f"@mention: {issue_title} ({repo}#{issue_number})"
for agent_id in mentions:
_send_mail(agent_id, title, text)
```
#### 3.3 去重
- 同一条评论 @多人:每人一封 Mail(不同 to,内容相同)
- 同一事件 org webhook + repo webhook 双触发:现有 delivery UUID 去重机制覆盖
- 同一人被 @多次`extract_mentions` 返回 set,自动去重
---
### 四、Mail Spawn Prompt 改造
#### 4.1 问题
当前工具链 Mail 走 Mail 通道,spawn prompt 是:
```
你收到一封飞鸽传书(纯通知)。
发件者: system
主题: Issue 指派: xxx
内容: [工具链模板]
已阅即可。
```
"已阅即可"直接让 Agent 不做事。
#### 4.2 方案
**不改 MAIL_INFORM_TEMPLATE / MAIL_REQUEST_TEMPLATE 本身**(那是 Mail 系统通用的)。
改为:**工具链 Mail 使用 `type=request`(而不是默认的 inform**。
`_send_mail()` 中,工具链事件创建的 Mail 默认 `performative=request`,这样 Agent 收到时走 `MAIL_REQUEST_TEMPLATE`,知道需要处理。
具体改动在 `_send_mail()` 函数或其调用处:工具链路由调用 `_send_mail` 时传入 `performative="request"`
**⚠️ 验证要点**:改为 request 后,Agent spawn prompt 变为 "请处理以下请求",需确认:
1. Agent 不再把工具链 Mail 当纯通知忽略
2. Agent 能正确处理「已阅型」工具链事件(如 CI 失败通知——不需要回复,但需要知道)
3. 对已关闭 PR/Issue 的延迟通知,Agent 不会尝试去处理
验证方法:部署后发一条 Issue 指派 Mail,观察 Agent 行为是否符合预期。
---
### 五、完整改动清单
| # | 改什么 | 改动内容 | 层 | 风险 |
|---|--------|---------|---|------|
| 1 | 6 个 Agent 的 `AGENTS.md` | 加"工具链协作"段(内容统一) | L1 | 低(纯追加) |
| 2 | `templates/toolchain/issue_assigned.md` | 精简 + 加仓库上下文 + Gitea API 引导 | L2 | 低 |
| 3 | `templates/toolchain/review_request.md` | 精简 + 加 Gitea API 引导 | L2 | 低 |
| 4 | `templates/toolchain/review_result.md` | 精简 | L2 | 低 |
| 5 | `templates/toolchain/ci_failure.md` | 精简 + 加 CI 日志链接 | L2 | 低 |
| 6 | `templates/toolchain/deploy_failure.md` | 精简 + 加排查步骤 | L2 | 低 |
| 7 | **新建** `templates/toolchain/mention.md` | @mention 通知模板 | L2 | 低 |
| 8 | `src/api/toolchain_routes.py` | 提取 clone_url/html_url 传入模板;issue_comment 增加 @mention 解析;工具链 Mail 改为 request 类型 | L2 | 中 |
| 9 | 不改 | git-workflow 等 Skill 保持不变 | L3 | — |
| 10 | 不改 | daemon 核心逻辑、BootstrapBuilder、Skill 路由 | — | — |
---
### 六、验证方案
#### 6.1 单元验证
| 验证点 | 方法 |
|--------|------|
| `extract_mentions()` 提取 `@zhangfei-dev` | unit test |
| `extract_mentions()` 别名匹配 `@张飞` → zhangfei-dev | unit test |
| `extract_mentions()` 前缀匹配 `@zhangfei` → zhangfei-dev | unit test |
| `extract_mentions()` 过滤自己 | unit test |
| 模板渲染新变量不报错 | unit test |
#### 6.2 真实场景 E2E 验证
重复 Issue #32 的场景:
1. 创建 Issue 指派张飞
2. **验证**:张飞收到的 Mail 含 clone URL + Gitea API 引导
3. **验证**:张飞 spawn 后知道该做什么(L1 AGENTS.md 有流程引导)
4. **验证**:张飞有疑问时去 Gitea Issue 评论(而不是 Control UI
5. 在 Issue 评论 @赵云
6. **验证**:赵云收到 @mention Mail
---
### 七、不做的事(标记为后续)
| 标记 | 描述 | 原因 |
|------|------|------|
| 后续-1 | Agent 离开工具链讨论后,是否有意识回到工具链 | 需要更多真实场景观察 |
| 后续-2 | 工具链使用标准在所有 Agent 间的一致性验证 | L1 统一段落是第一步,需要 E2E 验证 |
| 后续-3 | Mail 通道接入 BootstrapBuilder L2 注入 | 改动大,当前方案(L1 统一段落 + 模板引导)够用 |
| 后续-4 | Skill 路由器自动触发(引擎注入) | 改动 daemon 核心,当前靠 L1 文案触发 |
---
### 八、变更记录
| 日期 | 版本 | 变更 |
|------|------|------|
| 2026-06-09 | v1.0 | 初版:E2E 真实场景暴露问题 → 四层改造方案 + @mention 通知 + Mail type 改造 |
-372
View File
@@ -1,372 +0,0 @@
# #19 工具链事件中枢 — 上下文四层改造方案
> 版本: v1.0
> 日期: 2026-06-09
> 作者: 庞统(副军师)
> 状态: 待主公确认
> 前置: #13 工具链与开发流程 §16, #05 上下文四层架构
> 来源: E2E 真实场景测试暴露的三个断层
---
## 一、问题诊断
### 1.1 E2E 真实场景测试暴露的三个断层
主公在 moziplus-v2 仓库创建了 Issue #32(添加 /api/stats 端点),指派张飞。链条在第一步就断了。
| 断层 | 现象 | 根因 |
|------|------|------|
| **Agent 不知道该做什么** | 张飞收到 Issue 指派 Mail,回复"已阅"就结束了 | Mail 模板(issue_assigned.md5 行信息,无流程引导;spawn prompt 说"已阅即可" |
| **Agent 去错了仓库** | 张飞去读了 sanguo_moziplus_v2 平台代码,而不是空的实验仓库 moziplus-v2 | Mail 模板没有仓库 clone URL,张飞凭习惯去了开发目录 |
| **Agent 在 Control UI 提问** | 张飞遇到问题直接在 Control UI 问主公,没有去 Gitea Issue 评论 | 没有任何地方引导"有疑问去 Gitea Issue 评论" |
| **Agent 不知道怎么协作** | 张飞判断任务需要澄清,但不知道该怎么请求澄清 | 没有"做不了→在 Issue 评论 / Mail 庞统"的回退路径 |
| **跨 Agent @mention 无法通知** | 张飞在 Issue 评论 @赵云,赵云收不到通知 | issue_comment handler 只处理 [CI] 评论,@mention 被忽略 |
### 1.2 根因:工具链在四层架构中的断层
| 层 | 应该有 | 实际有 | Gap |
|---|---|---|---|
| **L0 铁律** | — | — | 无需改动 |
| **L1 角色** | 工具链协作行为规范(所有 Agent 共享) | 无 | AGENTS.md 没有工具链相关内容 |
| **L2 引擎注入** | 事件上下文(仓库 clone URL、Gitea API、Issue/PR 详情) | Mail 模板只有 5 行摘要 | 缺仓库信息和流程引导 |
| **L3 被动参考** | 技术细节(分支命名、commit 规范、PR 创建方式) | git-workflow 等 Skill 已存在但没人触发 | Agent 不知道该加载哪个 Skill |
---
## 二、改造方案:四层归属
### 2.1 分层原则
| 层 | 放什么 | 不放什么 | 理由 |
|---|---|---|---|
| **L0** | 不放 | — | 工具链不是安全底线 |
| **L1** | 协作行为规范:收到什么通知该做什么、遇到问题怎么办 | 技术细节(分支命名、commit 格式) | 行为规范是团队常识,每个 Agent 都要知道 |
| **L2** | 事件上下文:仓库 clone URL、Gitea API URL、Issue/PR 链接、动态信息 | 固定的协作流程 | 动态信息每次不同,由 Mail 模板 + spawn 时注入 |
| **L3** | 技术细节:git-workflow、code-review 等 Skill 全文 | — | 按需加载,Agent 知道"我要提 PR"后自己读 |
### 2.2 各层具体内容
#### L1AGENTS.md 加工具链协作行为段(所有 Agent 统一)
```markdown
## 工具链协作(Gitea
收到 Gitea 事件通知(Issue 指派、Review 请求、CI 失败等)时,按以下流程操作:
### 基本流程
- **Issue 指派** → clone 仓库 → 开分支 → 编码 → 提 PR(参考 git-workflow Skill
- **Review 请求** → 读 PR diffGitea API)→ 提交 Review(参考 code-review Skill
- **Review 通过** → 等 merge
- **Review 驳回** → 看 review body → 修代码 → 重新 push
- **CI 失败** → 看错误摘要 → 修代码 → push(自动重触发 CI)
- **部署失败** → 查 deploy 日志 → 修复
### 协作规则
- **有疑问?** 在 Gitea Issue 下评论,不要在 Control UI 或 Mail 里问
- **需要别人帮忙?** 在 Issue 评论中 @mention 对应 Agent(如 @zhaoyun-data
- **做不了?** 回复 Mail 说明原因和建议的接手人
- **获取完整上下文** → 用 Gitea API 拉取 Issue 详情和评论,不要只看 Mail 里的快照
### Gitea API 速查
- Issue 详情: GET /api/v1/repos/{owner}/{repo}/issues/{number}
- Issue 评论: GET /api/v1/repos/{owner}/{repo}/issues/{number}/comments
- PR diff: GET /api/v1/repos/{owner}/{repo}/pulls/{number}.diff
- 提交 Review: POST /api/v1/repos/{owner}/{repo}/pulls/{number}/reviews
```
**改动范围**6 个 Agent 的 AGENTS.md 各加一段(内容统一)。
#### L2:Mail 模板精简 + 事件上下文注入
**原则**:模板只放摘要 + 链接 + 仓库信息,不写固定步骤(步骤在 L1)。
**issue_assigned.md** 改为:
```markdown
Issue 指派
Issue: {issue_url}
标题: {issue_title}
标签: {labels}
📋 获取完整上下文(先读再动手):
- Issue 详情: GET {gitea_api}/repos/{repo}/issues/{issue_number}
- Issue 评论: GET {gitea_api}/repos/{repo}/issues/{issue_number}/comments
仓库: {repo_clone_url}
建议分支: feat/issue-{issue_number}-{brief}
```
**review_request.md** 改为:
```markdown
PR Review 请求
PR: {pr_url}
标题: {pr_title}
作者: {pr_author}
分支: {branch}
风险级别: {risk_level}
📋 获取完整上下文:
- PR diff: GET {gitea_api}/repos/{repo}/pulls/{pr_number}.diff
- PR 文件列表: GET {gitea_api}/repos/{repo}/pulls/{pr_number}/files
```
**review_result.md** 改为:
```markdown
Review {result}
PR: {pr_url}
标题: {pr_title}
审查者: {reviewer}
{review_body}
```
**ci_failure.md** 改为:
```markdown
CI 失败
Issue: {issue_url}
分支: {branch}
错误摘要:
{error_summary}
📋 CI 日志: {gitea_url}/{repo}/actions
修复后 push 会自动重触发 CI。
```
**deploy_failure.md** 改为:
```markdown
部署失败
仓库: {repo}
Commit: {commit_sha}
📋 排查步骤:
- CI 日志: {gitea_url}/{repo}/actions
- 服务器: pm2 logs {service_name}
```
**L2 代码改动**`toolchain_routes.py`):
1. 从 Webhook payload 的 `repository` 对象提取 `clone_url``html_url`
2. `render_template()` 传入新变量:`gitea_api``gitea_url``repo_clone_url`
3. 所有模板变量统一补齐
#### L3Skill 按需加载(不改 Skill 本身)
git-workflow、code-review 等 Skill 保持不变。
L1 的协作行为段里会引用 Skill 名称("参考 git-workflow Skill"),Agent 收到 Mail 后根据 L1 的引导自主加载对应 Skill。
**不改 Skill 路由机制**——靠 L1 的文案触发 Agent 的 Skill 路由器匹配。
---
## 三、新增功能:issue_comment @mention 通知
### 3.1 设计
当前 `_handle_issue_comment` 只处理 `[CI]` 前缀评论。扩展为:
```
issue_comment 事件
├── 含 [CI] / CI 失败 → 原有 CI 失败通知逻辑
└── 含 @username → 解析 @mention → Mail 通知被 @的 Agent
```
### 3.2 实现
**`toolchain_routes.py` 新增 `_handle_issue_comment_mention()`**
```python
AGENT_IDS = {
"zhangfei-dev", "guanyu-dev", "zhaoyun-data",
"jiangwei-infra", "simayi-challenger", "pangtong-fujunshi",
}
# 前缀映射:@张飞 → zhangfei-dev
AGENT_ALIAS = {
"张飞": "zhangfei-dev",
"关羽": "guanyu-dev",
"赵云": "zhaoyun-data",
"姜维": "jiangwei-infra",
"司马懿": "simayi-challenger",
"庞统": "pangtong-fujunshi",
"pangtong": "pangtong-fujunshi",
"simayi": "simayi-challenger",
"zhangfei": "zhangfei-dev",
"guanyu": "guanyu-dev",
"zhaoyun": "zhaoyun-data",
"jiangwei": "jiangwei-infra",
}
def extract_mentions(body: str, sender: str) -> list[str]:
"""从评论 body 中提取 @mention 的 Agent ID"""
candidates = re.findall(r"@([a-zA-Z\u4e00-\u9fa5][a-zA-Z0-9\u4e00-\u9fff-]*)", body)
result = set()
for c in candidates:
# 精确匹配
if c in AGENT_IDS:
result.add(c)
# 前缀/别名匹配
elif c in AGENT_ALIAS:
result.add(AGENT_ALIAS[c])
else:
# 前缀模糊匹配:@zhangfei → zhangfei-dev
for aid in AGENT_IDS:
if aid.startswith(c):
result.add(aid)
break
# 过滤掉评论者自己
result.discard(sender)
return list(result)
```
**新增 mention 通知模板** `templates/toolchain/mention.md`
```markdown
你在 Issue 中被 @mention
Issue: {issue_url}
评论者: {commenter}
评论内容:
{comment_body}
📋 获取完整上下文:
- Issue 详情: GET {gitea_api}/repos/{repo}/issues/{issue_number}
- Issue 评论: GET {gitea_api}/repos/{repo}/issues/{issue_number}/comments
```
**改动 `_handle_issue_comment`**
```python
async def _handle_issue_comment(payload):
comment = payload.get("comment", {})
body = comment.get("body", "")
sender = comment.get("user", {}).get("login", "")
repo = _repo_fullname(payload)
issue = payload.get("issue", {})
# 原有 CI 失败逻辑(不变)
if "[CI]" in body or "CI 失败" in body:
# ... 原有逻辑 ...
# 新增:@mention 通知
mentions = extract_mentions(body, sender)
if mentions:
issue_number = issue.get("number", 0)
issue_title = issue.get("title", "")
text = render_template("mention", {
"repo": repo,
"issue_number": str(issue_number),
"issue_url": issue.get("html_url", ""),
"commenter": sender,
"comment_body": body[:500],
"gitea_api": "http://192.168.2.154:3000/api/v1",
})
title = f"@mention: {issue_title} ({repo}#{issue_number})"
for agent_id in mentions:
_send_mail(agent_id, title, text)
```
### 3.3 去重
- 同一条评论 @多人:每人一封 Mail(不同 to,内容相同)
- 同一事件 org webhook + repo webhook 双触发:现有 delivery UUID 去重机制覆盖
- 同一人被 @多次`extract_mentions` 返回 set,自动去重
---
## 四、Mail Spawn Prompt 改造
### 4.1 问题
当前工具链 Mail 走 Mail 通道,spawn prompt 是:
```
你收到一封飞鸽传书(纯通知)。
发件者: system
主题: Issue 指派: xxx
内容: [工具链模板]
已阅即可。
```
"已阅即可"直接让 Agent 不做事。
### 4.2 方案
**不改 MAIL_INFORM_TEMPLATE / MAIL_REQUEST_TEMPLATE 本身**(那是 Mail 系统通用的)。
改为:**工具链 Mail 使用 `type=request`(而不是默认的 inform**。
`_send_mail()` 中,工具链事件创建的 Mail 默认 `performative=request`,这样 Agent 收到时走 `MAIL_REQUEST_TEMPLATE`,知道需要处理。
具体改动在 `_send_mail()` 函数或其调用处:工具链路由调用 `_send_mail` 时传入 `performative="request"`
---
## 五、完整改动清单
| # | 改什么 | 改动内容 | 层 | 风险 |
|---|--------|---------|---|------|
| 1 | 6 个 Agent 的 `AGENTS.md` | 加"工具链协作"段(内容统一) | L1 | 低(纯追加) |
| 2 | `templates/toolchain/issue_assigned.md` | 精简 + 加仓库上下文 + Gitea API 引导 | L2 | 低 |
| 3 | `templates/toolchain/review_request.md` | 精简 + 加 Gitea API 引导 | L2 | 低 |
| 4 | `templates/toolchain/review_result.md` | 精简 | L2 | 低 |
| 5 | `templates/toolchain/ci_failure.md` | 精简 + 加 CI 日志链接 | L2 | 低 |
| 6 | `templates/toolchain/deploy_failure.md` | 精简 + 加排查步骤 | L2 | 低 |
| 7 | **新建** `templates/toolchain/mention.md` | @mention 通知模板 | L2 | 低 |
| 8 | `src/api/toolchain_routes.py` | 提取 clone_url/html_url 传入模板;issue_comment 增加 @mention 解析;工具链 Mail 改为 request 类型 | L2 | 中 |
| 9 | 不改 | git-workflow 等 Skill 保持不变 | L3 | — |
| 10 | 不改 | daemon 核心逻辑、BootstrapBuilder、Skill 路由 | — | — |
---
## 六、验证方案
### 6.1 单元验证
| 验证点 | 方法 |
|--------|------|
| `extract_mentions()` 提取 `@zhangfei-dev` | unit test |
| `extract_mentions()` 别名匹配 `@张飞` → zhangfei-dev | unit test |
| `extract_mentions()` 前缀匹配 `@zhangfei` → zhangfei-dev | unit test |
| `extract_mentions()` 过滤自己 | unit test |
| 模板渲染新变量不报错 | unit test |
### 6.2 真实场景 E2E 验证
重复 Issue #32 的场景:
1. 创建 Issue 指派张飞
2. **验证**:张飞收到的 Mail 含 clone URL + Gitea API 引导
3. **验证**:张飞 spawn 后知道该做什么(L1 AGENTS.md 有流程引导)
4. **验证**:张飞有疑问时去 Gitea Issue 评论(而不是 Control UI
5. 在 Issue 评论 @赵云
6. **验证**:赵云收到 @mention Mail
---
## 七、不做的事(标记为后续)
| 标记 | 描述 | 原因 |
|------|------|------|
| 后续-1 | Agent 离开工具链讨论后,是否有意识回到工具链 | 需要更多真实场景观察 |
| 后续-2 | 工具链使用标准在所有 Agent 间的一致性验证 | L1 统一段落是第一步,需要 E2E 验证 |
| 后续-3 | Mail 通道接入 BootstrapBuilder L2 注入 | 改动大,当前方案(L1 统一段落 + 模板引导)够用 |
| 后续-4 | Skill 路由器自动触发(引擎注入) | 改动 daemon 核心,当前靠 L1 文案触发 |
---
## 八、变更记录
| 日期 | 版本 | 变更 |
|------|------|------|
| 2026-06-09 | v1.0 | 初版:E2E 真实场景暴露问题 → 四层改造方案 + @mention 通知 + Mail type 改造 |
File diff suppressed because it is too large Load Diff
+102
View File
@@ -0,0 +1,102 @@
# §21. Handler 注册后 E2E 验证
> 日期:2026-06-11
> 状态:已完成 ✅
> 目标:验证 Task 五层架构重构(Step 2-5+ review 修复后,Mail/Toolchain 路径端到端工作
## 前置条件
- Daemon 版本:commit 83694ad(含 handler 注册 + import 修复 + SKILL_BASE_PATH 修复)
- Handler 注册日志:
```
Registered task type handler: task (virtual_project=None)
Registered task type handler: mail (virtual_project=_mail)
Registered task type handler: toolchain (virtual_project=_toolchain)
```
- Gitea org webhook (ID=28):姜维启用,事件订阅含 issues/pull_request/pull_request_review 等 16 个事件
- 测试仓库:sanguo/sanguo_moziplus_v2
## 验证结果
### 一、Mail Handler(✅ 全部通过)
| # | 步骤 | 验证点 | 结果 | Mail ID |
|---|------|--------|------|---------|
| 1 | 发 inform 邮件给 zhangfei-dev | ticker 发现 `_mail` 虚拟项目 | ✅ `handler auto-working` | mail-1781106713261 |
| 2 | zhangfei-dev 回复 | handler verify (inform_auto) → done | ✅ `verify passed (inform_auto), marked done` | — |
| 3 | 回复邮件给 pangtong | handler auto-working + done | ✅ `verify passed (inform_auto), marked done` | mail-1781106736388 |
**关键验证**
- ✅ `virtual_projects()` 返回 `["_mail", "_toolchain"]`(注册前为空)
- ✅ handler `pre_spawn` (auto-working) 生效,不是旧的 `_mail_auto_working`
- ✅ guardrail 跳过 `_mail``is_handler_task=True`
- ✅ inform 类型自动标 donerequest 类型检查回复
### 二、Toolchain — Issue 指派(✅ 通过)
| # | 步骤 | 验证点 | 结果 | Mail ID |
|---|------|--------|------|---------|
| 1 | 创建 Issue #28assignee=zhangfei-dev | webhook 触发 + Mail 通知 | ✅ | mail-1781107087549 |
**Webhook 路径**Gitea → org webhook → `POST /webhook/gitea` → 签名验证 → `_handle_issues` → `_send_mail(zhangfei-dev, ...)`
**注意**Issue #27 创建时 webhook 未启用,未触发。Issue #28 创建时 webhook 已启用,正常触发。
### 三、Toolchain — PR Review(✅ 通过)
| # | 步骤 | 验证点 | 结果 | Mail ID |
|---|------|--------|------|---------|
| 1 | 创建 PR #30 | webhook 触发 + Review 请求 Mail | ✅ | mail-1781107538823 |
| 2 | simayi-challenger 提交 COMMENT review | Review 结果通知 PR 作者 | ✅ `Review 通过 ✓` | mail-1781107650433 |
**Webhook 路径**
- PR opened: Gitea → `_handle_pull_request` → `_send_mail(simayi-challenger, "Review 请求")`
- PR review: Gitea → `_handle_pull_request_review` → `_send_mail(pangtong-fujunshi, "Review 通过 ✓")`
### 四、CI 失败评论(⚠️ 触发但重复)
| # | 步骤 | 验证点 | 结果 | Mail ID |
|---|------|--------|------|---------|
| 1 | push 空 commit → CI lint 失败 | CI 失败通知 | ✅ 但收到 2 封重复 Mail | mail-1781107563991, mail-1781107560933 |
**已知问题**:和上次 E2E(§18)相同——org webhook + repo webhook 双触发。上次已加去重机制(delivery UUID + content sha256),但 CI 失败场景似乎仍触发 2 封。**非新问题,待姜维统一 org/repo webhook 后解决。**
### 五、负面测试(❌ 未执行)
| 步骤 | 说明 | 状态 |
|------|------|------|
| REQUEST_CHANGES review | review 驳回通知 PR 作者 | 未测(仲达提交的是 COMMENT 而非 REQUEST_CHANGES |
| 已关闭 Issue CI 评论 | closed issue 不触发 Mail | 未测 |
| 部署失败 Issue | 双收件人通知 | 未测 |
| 幂等测试 | 同 delivery ID 重发 | §18 已验证,未重测 |
## 阻塞/问题记录
### 已解决
| 问题 | 说明 |
|------|------|
| Org webhook 事件列表被 Gitea API 重置 | 姜维修复:PATCH webhook 只传 active:true 会重置 events,必须带完整事件列表 |
| PR Review 无法用 PR 作者 token 提交 | Gitea 不允许 self-review,请仲达用 simayi token 提交 |
### 遗留
| 问题 | 严重度 | 说明 |
|------|--------|------|
| CI 失败 Mail 重复 | 🟡 | org webhook + repo webhook 双触发,§18 已记录 |
| REQUEST_CHANGES 未验证 | 🟢 | 下次 E2E 补测 |
## 测试清理
- ✅ Issue #27、#28 已关闭
- ✅ PR #29、#30 已关闭
- ✅ 分支 `test/e2e-1781107119`、`test/e2e-pr-1781107530` 已删除
- ✅ 本地切回 main 分支
## 结论
**Handler 注册后 Mail 和 Toolchain 核心流程端到端验证通过。** 关键修复(handler 注册、review verdict、SKILL_BASE_PATH)均已生效。
下一步:
- Task review 路径 E2E(明天,需要普通任务 → executor → review → verdict → done
- CI 失败重复 Mail 根治(需姜维统一 org/repo webhook
+81
View File
@@ -0,0 +1,81 @@
# 22 — CD 生产环境落地方案
> 状态:草案,待评审
> 作者:庞统
> 日期:2026-06-11
## 背景
CD 管道已在测试仓库 `sanguo/mojiplus-v2` 验证通过(run#282 全链路 success)。
现需将 CD 落地到生产仓库 `sanguo/sanguo_moziplus_v2`
### 当前状态
| 组件 | 状态 |
|---|---|
| `deploy.sh` | ✅ 完整(rsync + build + pm2 restart + health check + deploy history |
| `deploy.yml` | ⚠️ deploy job 已调用 deploy.sh,但缺少成功通知 |
| CI 失败通知 | ✅ ci.yml → PR comment → webhook → Mail |
| Deploy 失败通知 | ✅ deploy.yml → Issue → webhook → Mail |
| Deploy 成功通知 | ❌ 缺失 |
## 改动方案
### 改动 1deploy.yml 增加 deploy 成功通知
在 deploy job 最后增加一个 stepdeploy 成功后:
1. 从 Gitea API 查询触发 commit 关联的 merged PR
2. 获取 PR 作者
3. 通过 Mail API 发送成功通知给 PR 作者 + pangtong-fujunshi
4. 如果是 direct push(非 PR merge),只通知 jiangwei-infra + pangtong-fujunshi
**文件**`.gitea/workflows/deploy.yml`
**改动范围**deploy job 内新增 1 个 step(约 30 行 shell
**关键逻辑**
```bash
# 查询关联 PR
PR_INFO=$(curl -sf \
-H "Authorization: token $GITEA_TOKEN" \
"$API_URL/repos/$REPO/pulls?state=closed&limit=5" | \
python3 -c "
import json,sys
for pr in json.load(sys.stdin):
if pr.get('merge_commit_sha','') == '$COMMIT_SHA':
print(pr['user']['login'])
break
" 2>/dev/null || echo "")
# 发 Mail
curl -s -X POST http://localhost:8083/api/mail \
-H "Content-Type: application/json" \
-d "{\"from\":\"daemon\",\"to\":\"$PR_AUTHOR\",\"title\":\"...\",\"text\":\"...\",\"type\":\"inform\"}"
```
**约束**
- 使用 `if: always()` + shell 判断 `needs.deploy.result == "success"`,确保只在成功时执行
- GITEA_TOKEN 通过 secrets 注入
- Mail API 调用超时 5 秒,失败不影响部署结果
### 不改的文件
| 文件 | 原因 |
|---|---|
| `src/api/toolchain_routes.py` | 不新增 webhook 事件,deploy 成功通知在 yml 内闭环 |
| `src/daemon/toolchain_handler.py` | 不涉及 |
| `templates/toolchain/*.md` | 不新增模板,通知内容直接在 shell 中构建 |
| `scripts/deploy.sh` | 已完整,不需改动 |
## 影响范围
- **风险**:低。只在 deploy job 末尾追加通知 step,不修改已有的 deploy/notify 逻辑
- **回退**:删除新增 step 即可
- **测试**push main 后观察 deploy workflow 执行结果
## E2E 验证计划
1. 在生产仓库创建测试分支,push → PR → merge → 触发 deploy
2. 验证 deploy 成功后 Mail 通知到达 PR 作者
3. 验证部署文件同步到 `~/.sanguo_projects/sanguo_moziplus_v2/`
4. 验证 health check 通过
+144
View File
@@ -0,0 +1,144 @@
# §23 — 工具链事件中枢补全:PR 全生命周期通知
> 状态:草案,待评审
> 作者:庞统
> 日期:2026-06-11
> 框架:基于 §20 Task Type Architecture + §13 工具链设计
## 背景
### 问题
工具链事件中枢(`toolchain_routes.py`)当前只覆盖了 PR 生命周期中约一半的交互节点。review 驳回后 PR 作者修改代码,没有机制通知 reviewer 重新 review——流程在这里断链。
### 当前覆盖
| 事件节点 | handler | 模板 | 状态 |
|---|---|---|---|
| PR 创建 → 通知 reviewer | `_handle_pull_request` (opened) | `review_request.md` | ✅ |
| Review 通过 → 通知 PR 作者 | `_handle_pull_request_review` (APPROVED) | `review_result.md` | ✅ |
| Review 驳回 → 通知 PR 作者 | `_handle_pull_request_review` (REQUEST_CHANGES) | `review_result.md` | ✅ |
| Issue 指派 → 通知被指派人 | `_handle_issues` (assigned) | `issue_assigned.md` | ✅ |
| CI 失败评论 → 通知 | `_handle_issue_comment` ([CI]) | `ci_failure.md` | ✅ |
| 部署失败 Issue → 通知 | `_handle_issues` (opened+"部署失败") | `deploy_failure.md` | ✅ |
### 缺失节点
| # | 事件节点 | Gitea 事件 | 优先级 | 理由 |
|---|---|---|---|---|
| E1 | PR 更新(push 新 commit)→ 通知 reviewer | `pull_request.synchronize` | **高** | review 驳回→修改→重 review 的关键闭环 |
| ~~E2~~ | ~~PR 合并通知~~ | ~~已删除~~ | ~~—~~ | ~~和 §22 CD 成功通知重叠,已删~~ |
| E3 | Review 评论(COMMENTED)→ 通知 PR 作者 | `pull_request_review` (COMMENTED) | 中 | reviewer 讨论提问,作者应知道 |
| E4 | PR 上普通评论 → 通知相关人 | `issue_comment` (on PR) | 低 | 非关键路径 |
## 方案
### 框架对齐
按 §20 Task Type Architecture,新增事件处理遵循:
1. `_EVENT_HANDLERS` 映射 → 路由到对应 handler 函数
2. handler 提取变量 → `render_template()` 渲染模板
3. `_TEMPLATE_MAP` 注册模板名 → `templates/toolchain/` 下新建模板文件
4. 通知目标通过 Gitea username → `to_agent_id()` 映射
### 新增 Handler 1`_handle_pull_request_synchronize`
**触发**`pull_request` 事件 + `action=synchronize`PR 分支有新 push
**通知对象**PR 的 reviewer(从 PR 的 `requested_reviewers` 或最近一次 non-COMMENTED review 的提交者)
**实现**
修改 `_handle_pull_request` 的 action 过滤,从只处理 `opened` 扩展为同时处理 `synchronize`
```python
async def _handle_pull_request(payload: Dict[str, Any]) -> None:
action = payload.get("action", "")
if action == "opened":
await _handle_pr_opened(payload)
elif action == "synchronize":
await _handle_pr_synchronize(payload)
# 其他 action 忽略
```
新增 `_handle_pr_synchronize`
1. 从 payload 取 PR 信息(number、title、author、head sha
2. 查询最近一次 reviewGitea API `GET /repos/{owner}/{repo}/pulls/{number}/reviews`)取 reviewer
3. 如果没有 review 记录(首次 push 后 reviewer 还没 review),跳过(opened 事件已经通知过了)
4. 渲染 `review_updated.md` 模板,发送 Mail 给 reviewer
**关键设计决策**
- 不用 `requested_reviewers`(可能为空),用最近 review 的提交者
- 只在有 review 历史时才通知(避免 opened + synchronize 重复通知)
- Mail from 用 `system`
### ~~Handler 2PR 合并通知~~ — 已删除
> 司马懿 review 指出与 §22 CD 成功通知重叠。CD 成功通知已隐含合并信息,无需单独发 merged 通知。
### 新增 Handler 3review COMMENTED 处理
**触发**`pull_request_review` 事件 + `state=COMMENTED`
**通知对象**PR 作者(不是 reviewer
**实现**
修改现有 `_handle_pull_request_review`,当前逻辑是"非 COMMENTED 才通知",改为 COMMENTED 也通知,但用不同模板:
```python
# 现有逻辑:非 COMMENTED 通知 PR 作者
if state in ("APPROVED", "REQUEST_CHANGES"):
template_name = "review_result"
elif state == "COMMENTED":
template_name = "review_comment"
else:
return # PENDING 等忽略
```
### 新增模板
| 模板文件 | 变量 | 说明 |
|---|---|---|
| `review_updated.md` | repo, pr_number, pr_title, pr_author, branch, new_sha, reviewer | PR 有新 commit,请重新 review |
| ~~`pr_merged.md`~~ | ~~已删除~~ | ~~—~~ |
| `review_comment.md` | repo, pr_number, pr_title, reviewer, comment_body | reviewer 提交了评论 |
### `_EVENT_HANDLERS` 无需改动
`synchronize``closed` 都是 `pull_request` 事件的 action 子类型,已映射到 `_handle_pull_request`。COMMENTED 是 `pull_request_review` 的 state 子类型,已映射到 `_handle_pull_request_review`
所以 **`_EVENT_HANDLERS` 不需要修改**,只需修改 handler 内部的 action/state 分发逻辑。
### 不做的事
| 项 | 理由 |
|---|---|
| E4 PR 上普通评论通知 | 低优,非关键路径,后续按需加 |
| Issue 关闭通知 | 低优,关怀性质 |
| reviewer 从 `requested_reviewers` 取 | 不可靠(可能为空),用最近 review 记录更稳定 |
## 改动范围
| 文件 | 改动 |
|---|---|
| `src/api/toolchain_routes.py` | 修改 `_handle_pull_request`(扩展 action 分发)+ 新增 `_handle_pr_synchronize` + 修改 `_handle_pull_request_review`(支持 COMMENTED |
| `templates/toolchain/review_updated.md` | 新增 |
| ~~`templates/toolchain/pr_merged.md`~~ | ~~已删除~~ |
| `templates/toolchain/review_comment.md` | 新增 |
| `src/daemon/toolchain_templates.py` | `_TEMPLATE_MAP` 新增 3 个映射 |
| `docs/design/23-toolchain-pr-lifecycle.md` | 本文档 |
## 验证计划
`sanguo/moziplus-v2` 测试仓库上 E2E 验证:
1. **synchronize**:创建 PR → review 驳回 → push 新 commit → 验证 reviewer 收到"请重新 review" Mail
~~2. merged~~:已删除
3. **COMMENTED**:review 提交纯评论 → 验证 PR 作者收到通知
## 风险评估
- **风险等级**:低。新增事件处理,不修改现有 handler 逻辑
- **幂等性**:复用现有 `_is_duplicate` 机制
- **性能**synchronize handler 有一次 Gitea API 调用(查 review 历史),频率低(只在 push 后触发)
+205
View File
@@ -0,0 +1,205 @@
# §24 — Compact 检测方案修正
> 状态:v3rotation-only),待实施
> 作者:庞统
> 日期:2026-06-11
> 框架:基于 §07 Spawner Acquire-First
> 评审:仲达 3 轮评审(v1 trajectory → v2 gateway precheck → v3 rotation-only
## 1. 问题
### 1.1 现象
2026-06-11 14:02pangtong main session 正在做 compaction13:59:26 开始,14:06:00 结束,耗时 ~6.5 分钟),但 spawner Phase 2 检查时 `compact=False`,仍然 spawn 了新进程处理 Mail,导致两个 agent turn 撞车。
### 1.2 根因
当前 compact 检测方法 `_check_recent_compaction_jsonl` 扫描 session jsonl,查找 `type == "compaction"` 事件。这是 compact **完成后**才写入的摘要记录,compact **进行中**时不存在 → 漏检。
同时 Gateway 触发 compact 时先把 session 标为 `done`,所以 `status=running + lock_pid_alive` 检查也无效。14:02:11 实际状态:`status=done lock_pid_alive=False compact=False`——三个检查全部漏过。
## 2. 方案:Rotation-Only 检测(v3
### 2.1 核心洞察(仲达 v2 评审)
v2 方案依赖 `[context-overflow-precheck]` route=compact 作为开始标志。但实测数据:
| Agent | Rotation 事件 | 有 Precheck | 无 Precheck |
|-------|:---:|:---:|:---:|
| pangtong | 7 | 3 | 4 |
| simayi | 3 | 0 | 3 |
**10 次 compact 只有 3 次有 precheck,覆盖率 30%。** 原因:post-compact retry 触发的后续 compact 不经过 precheck 日志路径。
**结论**:开始标志不可靠。反转检测逻辑——只用可靠的 rotation 事件作为信号。
### 2.2 Rotation 事件
Gateway 日志中 `[compaction] rotated active transcript after compaction (sessionKey=...)` 事件:
- **100% 覆盖率**:全天 10 次 compact 全部有 rotation 事件
- **含 sessionKey**:可以精确匹配目标 session
- **JSON 格式**:易解析
### 2.3 检测逻辑
```
1. 读 gateway 日志(当天 + 昨天尾部)
2. 按目标 sessionKey 过滤 compact 相关事件
3. 从后往前找最后一条 rotation 事件:
a. 如果 rotation 事件在窗口内(< 120s)→ compact=True
(刚完成一轮 compact,可能还在 post-compact retry 循环中)
b. 无 rotation 事件或超出时间窗口 → compact=False
**注意:此方案仅检查 rotation 事件,不检查 model.completed 等其他事件。**
这是有意为之的保守策略:不检查正常 turn 事件意味着 compact 完成后的
120s 内都可能被误判为 compact 进行中,但误判代价低(仅 skip 一轮 ticker),
宁可多拦也不漏放。
```
**为什么 rotation + 时间窗口就够了?**
- compact 后 Gateway 会 retry prompt
- 如果 retry 又触发 overflow → 又一轮 compact → 又一个 rotation 事件
- 如果 retry 成功 → 正常 turn → 新的 session.started / model.completed 事件
- 所以「最近一个事件是 rotation 且时间很近」= compact 循环还在进行
### 2.4 时间窗口选择
compact 通常耗时 1-10 分钟。post-compact retry 如果又触发 compact,间隔通常 <60 秒。
- **窗口太短(如 30s)**:可能漏掉 compact 结束后正在 retry 但还没触发下一轮的场景
- **窗口太长(如 900s)**:compact 完成后正常工作很久了还误判
- **推荐 120s**compact 循环中两次 rotation 间隔通常 <60s120s 有足够余量
误判代价低(skip 一轮 ticker),所以宁可多拦也不漏放。
## 3. 改动范围
| 文件 | 改动 | 行数估计 |
|------|------|---------|
| `spawner.py` | 新增 `_check_compact_in_progress_gateway()` | ~40 行 |
| `spawner.py` | `_check_session_state()` 调用新方法,替换旧方法 | ~5 行 |
| `spawner.py` | 日志路径配置化 | ~5 行 |
| `docs/design/07-spawner-acquire-first.md` | §4.5 O5 更新 | ~10 行 |
| `docs/design/24-compact-detection-fix.md` | 本文档 | 已有 |
**总计 ~60 行代码改动。**
## 4. 实现细节
### 4.1 核心方法
```python
def _check_compact_in_progress_gateway(self, session_key: str, window_seconds: int = 120) -> bool:
"""检查 gateway 日志,判断指定 session 是否刚完成 compact(可能在 retry 循环中)。
检测逻辑:如果目标 session 最近一个事件是 rotation 且在窗口内,视为 compact 进行中。
"""
log_paths = self._get_recent_gateway_logs()
if not log_paths:
return False
now = datetime.now(timezone.utc)
window_start = now - timedelta(seconds=window_seconds)
last_rotation_time = None
for log_path in log_paths:
if not os.path.exists(log_path):
continue
with open(log_path, 'rb') as f:
# 读尾部 2MB
f.seek(0, 2)
size = f.tell()
f.seek(max(0, size - 2 * 1024 * 1024))
for raw_line in f:
try:
obj = json.loads(raw_line)
except (json.JSONDecodeError, ValueError):
continue
msg = obj.get("message", "")
ts_str = obj.get("time", "")
# 只看包含目标 sessionKey 的事件
if session_key not in msg:
continue
# rotation 事件
if "[compaction] rotated active transcript" in msg:
try:
event_time = datetime.fromisoformat(ts_str)
if last_rotation_time is None or event_time > last_rotation_time:
last_rotation_time = event_time
except (ValueError, TypeError):
continue
if last_rotation_time is not None:
return last_rotation_time >= window_start
return False
```
### 4.2 日志路径
```python
def _get_recent_gateway_logs(self) -> list:
"""获取当天和昨天的 gateway 日志路径"""
log_dir = os.environ.get("OPENCLAW_LOG_DIR", "/tmp/openclaw")
today = datetime.now().strftime("%Y-%m-%d")
yesterday = (datetime.now() - timedelta(days=1)).strftime("%Y-%m-%d")
paths = []
for d in [today, yesterday]:
p = os.path.join(log_dir, f"openclaw-{d}.log")
if os.path.exists(p):
paths.append(p)
return paths
```
### 4.3 Phase 2 集成
```python
# 在 _check_session_state 中,不依赖 status,直接检查
compact = self._check_compact_in_progress_gateway(session_key)
if not compact:
compact = self._check_recent_compaction_jsonl(...) # fallback
if compact:
blockers.append(("session_compacting", None))
```
## 5. 边界情况
| 边界情况 | 处理 |
|---------|------|
| 日志文件不存在 | 返回 Falsefallback 到旧方法) |
| 跨天 compact | 同时检查昨天日志尾部 |
| compact 失败(无 rotation | rotation 事件不会出现 → 检测不到 → 回退到旧方法 |
| 误判(compact 完成后正常工作中) | 时间窗口 120s 内可能被误判,但代价低(skip 一轮 ticker)。不检查正常 turn 事件,是保守策略 |
## 6. 测试验证
### 6.1 单元测试
- `_check_compact_in_progress_gateway`
- rotation 事件在窗口内 → True
- rotation 事件超出窗口 → False
- 无 rotation 事件 → False
- 日志不存在 → False
- sessionKey 不匹配 → False
### 6.2 集成验证
- `pytest -m "not e2e"` 全量测试
## 7. 关联设计
- §07 Spawner Acquire-First(§4.5 O5 compact 扫描条件收紧)
- §08 Classify Outcome Optimizationcompact_hanging 处理)
## 8. 评审记录
- **v1**trajectory jsonl 间接推断 → 仲达指出 trajectoryPath 不可用、需多文件等 3 个问题
- **v2**gateway 日志 precheck 开始标志 → 仲达指出开始标志覆盖率仅 30%,建议 rotation-only
- **v3**rotation-only(当前版本)→ 仲达已确认方向,待代码实现后再审
+224
View File
@@ -0,0 +1,224 @@
# v3.0 vs HEAD 背靠背 Review — 庞统
**日期**: 2026-06-11
**范围**: v3.0 tag → HEAD6 commits, Step 2-5 Task 五层架构重构)
**对比**: `git diff v3.0..HEAD` + 安装目录代码验证
---
## Part A: v3.0 逻辑丢失检查
### 方法论
v3.0 → HEAD 的重构将 `_mail_*` 硬编码逻辑统一为 handler 架构(TaskTypeRegistry + BaseTaskHandler)。核心变更:
- dispatcher.py: `_mail_on_checks_passed` / `_mail_on_complete``_handler_on_checks_passed` / `_handler_on_complete`
- spawner.py: `_build_mail_prompt` → handler.build_prompt
- ticker.py: `_mail_check_reply` → handler.check_completion, `_mail` 硬编码 → `TaskTypeRegistry.virtual_projects()`
### 检查结果
| # | 文件 | v3.0 逻辑 | 当前状态 | 严重度 | 说明 |
|---|------|----------|---------|--------|------|
| 1 | dispatcher.py | `_legacy_on_complete` 中 review verdict 处理(approved→done, 非 approved→@mention assignee | **缺失** | 🔴 | 新版 `_legacy_on_complete``_is_review=True` 时只有 crash rollback**没有 verdict 判断逻辑**。review agent 完成后任务永远不会从 review→done。**仅影响非 handler 项目(_general**。handler 项目(_mail/_toolchain)的 review 由 TaskHandler.post_complete 正确处理 |
| 2 | dispatcher.py | `_mail_auto_working` / `_mail_auto_complete` / `_mail_revert_to_pending` 方法 | 保留但主流程不再调用 | 🟢 | 方法体仍存在(标记为 deprecated),主流程改走 handler.pre_spawn / handler.post_complete。正常的重构 |
| 3 | dispatcher.py | spawn 失败回退 `working→pending` | **逻辑改进** | 🟢 | v3.0 用 `_mail_revert_to_pending`(只处理 _mail),新版用通用 DB 操作处理所有 handler 项目 |
| 4 | spawner.py | `_build_mail_prompt` 精简模板 | **替换为 handler.build_prompt** | 🟢 | MailHandler 使用 PromptSection 组装,功能更完整 |
| 5 | spawner.py | `_build_api_section` 中 mail 直接 done | **替换为 handler.target_success_status** | 🟢 | 等价实现 |
| 6 | ticker.py | `_mail` 硬编码虚拟项目 | **替换为 TaskTypeRegistry.virtual_projects()** | 🟢 | 正常重构,可扩展 |
| 7 | ticker.py | `_mail_check_reply` 兜底(超时检查) | **替换为 handler.check_completion** | 🟢 | 等价实现,缩进正确 |
| 8 | ticker.py | `_dispatch_reviews` 跳过 `_mail` | **替换为 handler 检查** | 🟢 | 等价 |
### 🔴 严重问题 #1 详解
**位置**: `dispatcher.py` L250-260 `_legacy_on_complete`
**v3.0 逻辑**(已删除):
```python
if _is_review:
if _task_db and outcome in ("completed", "session_revived"):
# 读 verdict
if verdict == "approved":
_dispatcher._mark_task_status(_task_db, _task_id, "done")
else:
# @mention assignee + 保持 review
bb.add_comment(_task_id, "daemon", f"@{assignee} 审查结论: {verdict_str}")
```
**当前逻辑**:
```python
def _legacy_on_complete(aid, outcome):
if outcome in ROLLBACK_CURRENT_AGENT_OUTCOMES and _task_db:
_dispatcher._rollback_current_agent(_task_db, _task_id, aid)
if not _is_review: # ← review 时什么都不做
_dispatcher._task_auto_complete(_task_id, _task_db)
```
**影响**: `_dispatch_reviews` (ticker.py:1307) 对非 handler 项目会 dispatch review agent。review agent 完成后走 `_legacy_on_complete`,但 `_is_review=True` 时逻辑为空。任务永远停在 `review` 状态。
**修复方案**: 在 `_legacy_on_complete` 中补充 review verdict 处理逻辑,或让非 handler 项目也走 TaskHandler(注册 `_general` 到 TaskTypeRegistry)。
---
## Part B: 专题 01-13 设计编码一致性
### 专题 01: 四相循环(不参考实现,只检查设计遗漏)
| # | 设计描述 | 代码状态 | 一致性 | 说明 |
|---|---------|---------|--------|------|
| 1 | §3.3 Spawn Prompt 框架(任务+约束+API+准则+完成标准) | ✅ BootstrapBuilder + PromptSection 实现 | ✅ | |
| 2 | §3.4 @mention 通知机制 | ✅ `_process_mentions` + `mention_queue` | ✅ | |
| 3 | §4 庞统 Review 机制(三问) | ✅ review agent + verdict 处理 | ✅ | |
**设计遗漏**: 无明显遗漏。
### 专题 02: Main Session + Delegation
| # | 设计描述 | 代码状态 | 一致性 | 说明 |
|---|---------|---------|--------|------|
| 1 | 3.1 投递到 Main Session | ✅ `use_main_session=True` 参数 | ✅ | |
| 2 | 3.2 Delegationsubagent-delegation skill | ✅ 外部 skill,不在此代码库 | ✅ | |
| 3 | 3.3 续杯机制 | ✅ `use_main_session=True` + session 复用 | ✅ | |
| 4 | 4.1 投递消息格式 | ✅ dispatcher 构建 | ✅ | |
| 5 | 4.3 消息优先级与中断策略 | ❌ 无优先级队列 | ⚠️ | 设计描述了优先级但未实现,非关键 |
| 6 | 4.4 Subagent 背压控制 | ❌ 无显式背压 | ⚠️ | 靠 counter 间接控制 |
### 专题 03: Prompt 进化
| # | 设计描述 | 代码状态 | 一致性 | 说明 |
|---|---------|---------|--------|------|
| 1 | 3.1 广播认领模板改写 | ✅ PromptSection 组装 | ✅ | |
| 2 | P4 群体智能(Boids | ✅ agent 自主决策 | ✅ | 设计原则,非具体代码 |
| 3 | P6 反静默降级 | ❌ 无 scope reduction detection 自动机制 | ⚠️ | 设计原则,未自动实现 |
| 4 | P7 经验闭环 | ❌ 无 IMPROVE 阶段自动触发 | ⚠️ | P4 级待实现 |
### 专题 04: 黑板协作模型
| # | 设计描述 | 代码状态 | 一致性 | 说明 |
|---|---------|---------|--------|------|
| 1 | 3.1 assignee 降级为显示字段,路由走 @mention | 🟡 assignee 仍做直接路由 | ⚠️ | router.py L160-166 仍有 assignee 快速路径。设计说 Phase 1 双轨并行,当前停在 Phase 1。未迁移到 Phase 2 |
| 2 | 3.2 @mention 语义增强(mention_queue + comment_type | ✅ 已实现 | ✅ | |
| 3 | 3.3 多人协作模式(co_assignees | ❌ 无 co_assignees 字段 | ❌ | 数据库无此列 |
| 4 | 3.4 信息关联模型(output↔comment link | ❌ 无关联字段 | ❌ | outputs 表无 comment_id 列 |
| 5 | 3.5 层级查询 API | ✅ parent_task 支持 | ✅ | |
**总结**: 3.3 和 3.4 设计了但未实现。3.1 停在 Phase 1。
### 专题 05: 上下文四层架构
| # | 设计描述 | 代码状态 | 一致性 | 说明 |
|---|---------|---------|--------|------|
| 1 | L0 铁律层 | ✅ 通过 workspace 文件注入 | ✅ | |
| 2 | L1 角色层 | ✅ SOUL.md / IDENTITY.md | ✅ | |
| 3 | L2 引擎注入层 | ✅ BootstrapBuilder | ✅ | |
| 4 | L3 被动参考层 | ❌ 无 _inject_wiki_knowledge | ❌ | wiki 知识注入未实现 |
### 专题 06: PM2 Crash 恢复
| # | 设计描述 | 代码状态 | 一致性 | 说明 |
|---|---------|---------|--------|------|
| 1 | 4.1 总体流程(_startup_recover | ✅ ticker.py:1614 | ✅ | |
| 2 | 4.2 claimed 状态恢复 | ✅ | ✅ | |
| 3 | 4.2 working 状态恢复 | ✅ `_recover_working_task` | ✅ | |
| 4 | 4.2 review 状态恢复 | ✅ `_recover_review_task` | ✅ | |
| 5 | 设计提到 7 个恢复方法 | 🟡 只看到 2 个公开方法 | ⚠️ | 可能在内部逻辑中覆盖,需详细检查 |
### 专题 07: Spawner Acquire-First
| # | 设计描述 | 代码状态 | 一致性 | 说明 |
|---|---------|---------|--------|------|
| 1 | Phase 0: Pre-acquire 修复 | ✅ L499-512 | ✅ | |
| 2 | Phase 1: Counter acquire | ✅ L516-521 | ✅ | |
| 3 | Phase 2: Session check | ✅ L523-568 | ✅ | |
| 4 | Phase 2.5: 假死修复 | ✅ L557-568 | ✅ | |
| 5 | O1: lock PID 死 + running 假死 | ✅ | ✅ | |
| 6 | O4: revive 清理 lock 文件 | ✅ | ✅ | |
### 专题 08: Classify Outcome 优化
| # | 设计描述 | 代码状态 | 一致性 | 说明 |
|---|---------|---------|--------|------|
| 1 | A0-A17 判定树 | ✅ `_classify_outcome` 方法 | ✅ | |
| 2 | A9 api_error 特殊路径 | ✅ api_retry_count | ✅ | |
| 3 | A14-A17 可恢复 retry + cooldown 60s | ✅ cooldown_seconds + set_cooldown | ✅ | |
| 4 | Gateway Watchdog | ✅ 外部脚本 | ✅ | |
| 5 | Registry 逻辑删除 | ✅ | ✅ | |
### 专题 09: Rebuttal + Goal Gate
| # | 设计描述 | 代码状态 | 一致性 | 说明 |
|---|---------|---------|--------|------|
| 1 | 2.1 Rebuttal 自动化(review 非 approved → @mention assignee | ✅ task_handler.py handle_review_complete + ticker.py _rebuttal_on_complete | ✅ | |
| 2 | 2.1 防止无限循环(max 2 轮) | ✅ RebuttalManager.MAX_ROUNDS | ✅ | |
| 3 | 2.2 目标一致性 Gate | ❌ 无 goal gate 自动检查 | ⚠️ | 设计为 Agent 端行为,非 Daemon 侧 |
| 4 | _task_on_complete 改动(design §2.1 代码改动) | 🟡 已移到 handler | ✅ | 重构后的等价位置 |
### 专题 10: T3 需求探索 + 黑板展示
| # | 设计描述 | 代码状态 | 一致性 | 说明 |
|---|---------|---------|--------|------|
| 1 | A2: 需求探索过程写黑板 comments | ✅ 后端支持 comment_type | ✅ | |
| 2 | A3: TaskModal 实时刷新 | ✅ SSE comment_added/checkpoint_resolved | ✅ | |
| 3 | D1: 砍掉 AI 摘要 | ✅ 黑板直投前端 | ✅ | |
| 4 | D2: SSE 只做通知 | ✅ 前端按需拉数据 | ✅ | |
### 专题 11: 上下文四层重设计
| # | 设计描述 | 代码状态 | 一致性 | 说明 |
|---|---------|---------|--------|------|
| 1 | L2 操作规范型 6 个 skill 全文注入 | ❌ BootstrapBuilder 只注入通用 prompt,无 skill 全文注入 | ❌ | 设计 §2.3 要求将 6 个操作规范型 skillblackboard-executor, code-review 等)全文注入 L2bootstrap.py 无此逻辑 |
| 2 | L3 _inject_wiki_knowledge | ❌ 完全未实现 | ❌ | |
| 3 | review_protocols/ 目录 | ❌ 目录不存在 | ❌ | |
| 4 | 2.3 提到的 handoff.schema.json | ❌ 不存在 | ❌ | |
**总结**: 专题 11 大部分 L2/L3 改造未实现。BootstrapBuilder 做了基础框架但缺少 skill 注入和知识注入。
### 专题 12: Pipeline 设计
| # | 设计描述 | 代码状态 | 一致性 | 说明 |
|---|---------|---------|--------|------|
| 1 | §3 Pipeline 注册表(pipeline 字段) | ❌ 无 pipeline 数据结构 | ❌ | |
| 2 | §4 路由逻辑更新(task_type 路由) | ❌ router.py 无 task_type 路由 | ❌ | |
| 3 | §8 Pipeline 引擎 + PipelineRegistry | ❌ 不存在 | ❌ | |
| 4 | §8.2 状态流转校验 | ❌ 无 flow_rules | ❌ | |
| 5 | §9 实施路线标记为 "待实现" | — | — | 设计文档本身就标记为 TODO |
**总结**: Pipeline 整个设计未实施。设计文档 §9 自身标记为待实现。
### 专题 13: 工具链开发工作流(不参考实现,只检查设计遗漏)
| # | 设计描述 | 代码状态 | 一致性 | 说明 |
|---|---------|---------|--------|------|
| 1 | §16 工具链事件中枢 | ✅ toolchain_routes.py + toolchain_handler.py | ✅ | |
| 2 | Gitea webhook 处理 | ✅ 5 模板 + 去重 | ✅ | |
| 3 | CI 前缀 [CI] | ✅ | ✅ | |
| 4 | §5 CI/CD 管道设计 | 🟡 Gitea Actions 为主,非 Daemon 侧 | ✅ | |
**设计遗漏**: 无明显遗漏。
---
## 汇总
### 🔴 严重(需修复)
| # | 问题 | 影响 |
|---|------|------|
| A1 | `_legacy_on_complete` review verdict 处理丢失 | 非 handler 项目(_general)的 review agent 完成后任务永远停在 review 状态 |
### 🟡 中等(设计-代码不一致,可后续处理)
| # | 专题 | 设计描述 | 实际状态 |
|---|------|---------|---------|
| B4-1 | 04 黑板协作 | 3.1 assignee 降级 Phase 2 | 停在 Phase 1 |
| B4-3 | 04 黑板协作 | 3.3 co_assignees 多人协作 | 未实现 |
| B4-4 | 04 黑板协作 | 3.4 output↔comment 关联 | 未实现 |
| B5-4 | 05 上下文层 | L3 wiki 知识注入 | 未实现 |
| B11-1 | 11 上下文重设计 | L2 操作规范型 skill 全文注入 | 未实现 |
| B11-2 | 11 上下文重设计 | handoff.schema.json | 未实现 |
| B11-3 | 11 上下文重设计 | review_protocols/ 目录 | 未实现 |
| B12 | 12 Pipeline | 整个 Pipeline 引擎 | 未实现(设计自标 TODO) |
### 🟢 正常(重构等价或设计已标记待实现)
- _mail_* 方法 deprecated 但保留(平滑迁移)
- handler 架构统一替代硬编码(等价实现)
- 专题 01/02/03/06/07/08/09/10/13 无严重不一致
+707
View File
@@ -0,0 +1,707 @@
# v3.0 vs HEAD 背靠背 Review — 司马懿
> **日期**: 2026-06-10 (v2)
> **范围**: v3.0 tag → HEAD6 commits, +1584/-134 行, 9 个文件)
> **方法**: `git diff v3.0..HEAD` 逐文件逐行比对 + v3.0 源码 `git show v3.0:` 回溯验证
> **独立判断**: 不参考庞统 review,独立产出后比对
---
## 总览
v3.0 → HEAD 的核心改动是 **Step 2-5 五层架构重构**
| 层 | 新增/改动 | 说明 |
|---|---------|------|
| Protocol + Registry | `task_type_registry.py`(已有,未改) | `TaskTypeHandler` Protocol + `TaskTypeRegistry` |
| 基类 | `base_task_handler.py`(新增 +183 | `BaseTaskHandler` — crash/verify/mark/notify 统一流程 |
| Handler × 3 | `task_handler.py`+378)、`mail_handler.py`+210)、`toolchain_handler.py`+277 | 各自实现 `build_prompt` / `verify_completion` / `post_complete` |
| 引擎接入 | `dispatcher.py`-95/+58)、`spawner.py`+38)、`ticker.py`+31/-27 | `_mail_*` 硬编码 → `TaskTypeRegistry` 查表 |
| 设计文档 | `step5-impact-analysis.md`+324)、`step5-audit-report.md`(+74) | 影响分析 + 双重审计 |
**核心结论**:架构方向正确,但 **handler 注册初始化缺失导致所有 handler 路径为死代码**,实际运行仍走 `_legacy_on_complete` 旧路径。旧路径中 review verdict 处理被删除,造成 **非 handler 项目的 review 流程失效**
---
## Part A: v3.0 逻辑丢失检查
### 方法论
逐文件追踪 v3.0 中每个 `_mail` / `_task` / `project_id == "_mail"` 分支,验证 HEAD 中是否存在等价实现。分三层检查:
1. **功能等价**:新代码是否完整覆盖旧逻辑
2. **路径可达**:新代码是否会被实际执行(handler 注册?legacy fallback?)
3. **行为一致**:边界条件、异常处理是否等价
### 检查结果
#### A1 🔴 致命:dispatcher.py — review verdict 处理丢失
**v3.0 逻辑**`dispatcher.py` L253-308 `_task_on_complete`):
```python
if _is_review:
if _task_db and outcome in ("completed", "session_revived"):
# 读 verdict
review = conn.execute(
"SELECT verdict FROM reviews WHERE task_id=? ORDER BY created_at DESC LIMIT 1",
(_task_id,)).fetchone()
if review and review["verdict"] == "approved":
_dispatcher._mark_task_status(_task_db, _task_id, "done")
else:
# 非 approved → @mention assignee + 保持 review
bb.add_comment(_task_id, "daemon", f"@{assignee} 审查结论: {verdict_str}")
```
**HEAD 逻辑**`dispatcher.py` L246-258 `_legacy_on_complete`):
```python
def _legacy_on_complete(aid, outcome):
if outcome in ROLLBACK_CURRENT_AGENT_OUTCOMES and _task_db:
_dispatcher._rollback_current_agent(_task_db, _task_id, aid)
if not _is_review: # ← review 时整个 if 被跳过
_dispatcher._task_auto_complete(_task_id, _task_db)
```
**分析**
- `_legacy_on_complete``_is_review=True` 时**什么也不做**——无 verdict 读取、无 done 标记、无 @mention 通知
- `TaskHandler.handle_review_complete()` 方法有完整 verdict 处理,但 handler 未注册(见 A2),此代码不可达
- **影响**:所有非 handler 项目(`_general` 等)的 review agent 完成后,任务永远停在 `review` 状态
**补充**rebuttal 路径不受影响——`_rebuttal_on_complete``ticker.py` L756-790 独立定义,直接读 verdict 并处理,不经过 `_legacy_on_complete`
---
#### A2 🔴 致命:Handler 注册初始化缺失
**证据**
```bash
$ grep -rn "TaskTypeRegistry.register" src/
# 零结果
```
`TaskTypeRegistry.register()` 在整个代码库中**从未被调用**。`TaskHandler` / `MailHandler` / `ToolchainHandler` 类已定义但从未实例化和注册。
**后果链**
1. `TaskTypeRegistry.get_by_project()` 永远返回 `None`
2. 所有 `if handler:` 分支不进入 → 走 `else` / fallback 路径
3. `TaskTypeRegistry.virtual_projects()` 返回空列表 → `_mail` / `_toolchain` 不被 ticker 自动发现
**各路径受影响分析**
| 路径 | dispatcher | spawner | ticker | 实际走什么 |
|------|-----------|---------|--------|----------|
| Mail `_mail` | `handler=None``_legacy_on_complete` | `handler=None` → 旧 `_build_mail_prompt` | `virtual_projects()` 空 → **_mail 不被 tick** | 旧路径(无 handler),但 **ticker 不扫描 _mail** |
| Task `_general` | `handler=None``_legacy_on_complete` | `handler=None` → BootstrapBuilder | 不涉及 handler | 旧路径,但 review 处理被删(A1) |
| Toolchain `_toolchain` | N/A | N/A | `virtual_projects()` 空 → **_toolchain 不被 tick** | **完全不可达** |
**⚠️ A2 导致 ticker 不再扫描 `_mail` 虚拟项目**,这是 v3.0 有、HEAD 丢失的行为——v3.0 中 `_mail` 硬编码在 ticker L218-229HEAD 改为 `TaskTypeRegistry.virtual_projects()` 但注册为空。
**需要添加的初始化代码**(缺失):
```python
# 应在 server.py 或 bootstrap.py 的启动流程中
from src.daemon.task_handler import TaskHandler
from src.daemon.mail_handler import MailHandler
from src.daemon.toolchain_handler import ToolchainHandler
TaskTypeRegistry.register(TaskHandler())
TaskTypeRegistry.register(MailHandler())
TaskTypeRegistry.register(ToolchainHandler())
```
---
#### A3 🟡 中等:dispatcher.py — 旧 `_mail_*` 方法成为死代码
**v3.0**`_mail_auto_working` / `_mail_auto_complete` / `_mail_check_reply` / `_mail_revert_to_pending``dispatch()` 主流程调用。
**HEAD**:这些方法仍保留在 dispatcher.py 中(L628-860),但主流程已改走 handler 路径。由于 handler 未注册,主流程走 `_legacy_on_complete`(无 handler 分支),也不调用这些方法。
**结论**:方法体保留但无外部调用者,属于死代码。不影响当前运行(因为 `_legacy_on_complete` 有独立的 executor 逻辑),但增加维护混淆。
---
#### A4 🟢 低:dispatcher.py — spawn 失败回退等价
**v3.0**`self._mail_revert_to_pending(task.id, db_path)` — 调独立方法。
**HEAD**:内联代码(L309-327),`BEGIN IMMEDIATE` + 状态检查 + `UPDATE ... SET status='pending'`
**等价**:新版逻辑更通用(不限于 `_mail`,任何 handler 项目都可回退)。
---
#### A5 🟢 低:dispatcher.py — `_legacy_dispatch` 路径 handler 化
**v3.0**`is_mail_legacy = project_id.get("project_id") == "_mail"`
**HEAD**`handler_legacy = TaskTypeRegistry.get_by_project(project_id_legacy)`
**等价**`handler_legacy` 为 None 时跳过 pre_spawn,与 v3.0 中 `is_mail_legacy=False` 行为一致。`_legacy_dispatch` 本身仅在 `router=None` 时触发,当前配置不会进入。
---
#### A6 🟢 低:spawner.py — prompt 构建双路径
**v3.0**`if project_id == "_mail": return self._build_mail_prompt(...)` → 走 BootstrapBuilder。
**HEAD**`handler = TaskTypeRegistry.get_by_project(project_id)``if handler: return handler.build_prompt(ctx)` → else 走 BootstrapBuilder。
**分析**
- handler 未注册时,等价于 v3.0(走 BootstrapBuilder
- handler 注册后,Task/Mail/Toolchain 走新 PromptSection 路径
- **注意**:新旧路径的 Skill 注入策略不同——旧路径(BootstrapBuilder**全文注入** Skill,新路径(RoleSkillSection)只给**索引+引导语**。这可能导致 handler 注册后 Agent 行为变化
---
#### A7 🟢 低:spawner.py — `_build_api_section` success_status
**v3.0**`success_status = '"done"' if project_id == "_mail" else '"review"'`
**HEAD**`success_status = '"done"' if handler.target_success_status == "done" else '"review"'`
**等价**handler 未注册时走 else 分支 → `'"review"'`,与 v3.0 非 mail 项目一致。
---
#### A8 🟡 中等:spawner.py — retry prompt 仍用硬编码
**v3.0**`is_mail = project_id == "_mail"` → 用 `MAIL_RETRY_PROMPT` 模板。
**HEAD**:同样 `is_mail = project_id == "_mail"` 硬编码(L1128),未改走 handler。
**影响**:不影响功能(retry prompt 正确),但与设计文档 §6 不一致。属于 Step 5 未覆盖的遗留点。
---
#### A9 🟢 低:ticker.py — 虚拟项目扫描
**v3.0**:硬编码 `_mail` 扫描。
**HEAD**`TaskTypeRegistry.virtual_projects()` 循环。
**分析**:逻辑正确,但注册为空时 `_mail` 不被扫描(见 A2)。注册后自动发现 `_mail` + `_toolchain`,比 v3.0 更可扩展。
---
#### A10 🟢 低:ticker.py — assignee 清空条件
**v3.0**`if self._current_project_id == "_mail":` → 不清空 assignee。
**HEAD**`handler = TaskTypeRegistry.get_by_project(...); if handler:` → 不清空。
**等价**handler 未注册时,非 handler 项目正常清空 assignee。
---
#### A11 🟢 低:ticker.py — 跳过 claimed 状态
**v3.0**`if project_id == "_mail":` → 跳过 claimed,直接 working。
**HEAD**`handler = TaskTypeRegistry.get_by_project(project_id); if handler:` → 跳过。
**等价**。
---
#### A12 🟢 低:ticker.py — review dispatch 跳过
**v3.0**`if project_id == "_mail": return []`
**HEAD**`handler = TaskTypeRegistry.get_by_project(project_id); if handler: return []`
**等价**。
---
#### A13 🟢 低:ticker.py — 超时检查幻觉门控
**v3.0**
```python
if self._current_project_id == "_mail":
has_reply = self._mail_check_reply(task.id, db_path)
if has_reply:
... # mark done
```
**HEAD**
```python
handler = TaskTypeRegistry.get_by_project(self._current_project_id)
if handler and handler.check_completion(task.id, db_path):
... # mark done
```
**等价**`MailHandler.check_completion` 内部调 `_check_reply`,查询语义与 v3.0 的 `_mail_check_reply` 完全一致(`SELECT id FROM tasks WHERE id != ? AND must_haves LIKE ?`)。
**缩进问题**HEAD L1483 `if handler and handler.check_completion(...):` 后续 body 缩进 5 级(28 空格),与同级代码不一致。不影响运行,但增加维护混淆。
---
#### A14 🟢 低:ticker.py — startup recovery 虚拟项目列表
**v3.0**`for virtual_id in ("_general", "_mail"):`
**HEAD**`virtual_ids = ["_general"] + TaskTypeRegistry.virtual_projects()`
**等价**:注册为空时只有 `_general`,注册后自动包含 `_mail` + `_toolchain`
---
### Part A 汇总
| 严重度 | 数量 | 项目 |
|--------|------|------|
| 🔴 致命 | 2 | A1 review verdict 丢失, A2 handler 未注册 |
| 🟡 中等 | 2 | A3 死代码未清理, A8 retry prompt 硬编码 |
| 🟢 低 | 10 | A4~A7, A9~A14 |
**A1+A2 联合根因分析**
设计意图是 handler 注册后 review 走 `TaskHandler.post_complete``handle_review_complete`。但注册代码缺失导致:
1. 所有项目走 `_legacy_on_complete`(旧路径)
2. 旧路径中 review 处理被删除(信任 handler 会处理)
3. review agent 完成后无任何后续动作
**同时**ticker 不再扫描 `_mail` 虚拟项目(原来硬编码扫描),`_mail` 项目的 pending 任务无人处理。
---
## Part B: 13 个重点专题设计-编码一致性
逐专题检查设计文档描述与 HEAD 代码的一致性。标记:
- ✅ 一致
- ⚠️ 设计已标注未实施/Phase N(不算差异)
- ❌ 设计承诺但代码不一致
- 🟡 部分一致
---
### B1: 专题 01 四相循环
**设计文档**`01-four-phase-loop.md` — PRD Phase 1~4 完整实现方案
| # | 设计要求 | 代码现状 | 判定 |
|---|---------|---------|------|
| B1-1 | §3.3 Spawn Prompt 框架(任务+约束+API+准则+完成标准) | BootstrapBuilder + PromptSection 实现 | ✅ |
| B1-2 | §3.4 @mention 通知机制 | `_process_mentions` + `mention_queue` | ✅ |
| B1-3 | §4 Review 机制(verdict → done/notify | `TaskHandler.handle_review_complete`handler 未注册)+ `_rebuttal_on_complete`ticker 独立) | ⚠️ handler 路径不可达,但 rebuttal 路径完整 |
---
### B2: 专题 02 Main Session + Delegation
**设计文档**`02-main-session-delegation.md`
| # | 设计要求 | 代码现状 | 判定 |
|---|---------|---------|------|
| B2-1 | §3.1 投递到 Main Session | `use_main_session=True` 参数 | ✅ |
| B2-2 | §3.3 续杯机制 | `use_main_session=True` + session 复用 | ✅ |
| B2-3 | §4.3 消息优先级与中断策略 | 无优先级队列 | ⚠️ 设计描述但未标注 Phase |
| B2-4 | §4.4 Subagent 背压控制 | 无显式背压,靠 counter 间接控制 | ⚠️ |
---
### B3: 专题 03 Prompt 进化
**设计文档**`03-prompt-evolution.md` — 从 SOP 到任务式指挥
| # | 设计要求 | 代码现状 | 判定 |
|---|---------|---------|------|
| B3-1 | §3.1 广播认领模板改写 | PromptSection 组装(新路径)+ BootstrapBuilder(旧路径) | ✅ |
| B3-2 | P6 反静默降级 | 无 `scope-reduction-detection` 自动机制 | ⚠️ 设计原则,未强制实施 |
| B3-3 | P7 经验闭环 | 无 IMPROVE 阶段自动触发 | ⚠️ |
---
### B4: 专题 04 黑板协作模型
**设计文档**`04-blackboard-collaboration-model.md`
| # | 设计要求 | 代码现状 | 判定 |
|---|---------|---------|------|
| B4-1 | §3.1 assignee 降级为显示字段,路由走 @mention | `router.py` L160-166 仍有 assignee 快速路径 | ⚠️ 设计说 Phase 1 双轨并行,Phase 2 废弃。当前停在 Phase 1 |
| B4-2 | §3.2 @mention 语义增强 | `mention_queue` + `comment_type` 已实现 | ✅ |
| B4-3 | §3.3 多人协作 `co_assignees` | 数据库无此字段 | ⚠️ Phase 3 |
| B4-4 | §3.4 output↔comment 关联 | 无关联字段 | ⚠️ Phase 2 |
| B4-5 | §3.5 层级查询 API | `parent_task` 支持 | ✅ |
---
### B5: 专题 05 上下文四层架构
**设计文档**`05-context-layers.md`
| # | 设计要求 | 代码现状 | 判定 |
|---|---------|---------|------|
| B5-1 | L0 铁律层 | workspace 文件注入(SOUL.md/IDENTITY.md 等) | ✅ |
| B5-2 | L1 角色层 | SOUL.md / IDENTITY.md | ✅ |
| B5-3 | L2 引擎注入层 | BootstrapBuilder 实现 | ✅ |
| B5-4 | L3 被动参考层(wiki knowledge | 无 `_inject_wiki_knowledge` | ⚠️ 设计标注为 Phase 2 |
---
### B6: 专题 06 PM2 Crash 恢复
**设计文档**`06-pm2-crash-recovery.md`
| # | 设计要求 | 代码现状 | 判定 |
|---|---------|---------|------|
| B6-1 | §4.1 总体流程 `_startup_recover` | ticker.py L1614 有启动恢复 | ✅ |
| B6-2 | §4.2 claimed 状态恢复 | ✅ | ✅ |
| B6-3 | §4.2 working 状态恢复 `_recover_working_task` | ✅ | ✅ |
| B6-4 | §4.2 review 状态恢复 `_recover_review_task` | ✅ | ✅ |
---
### B7: 专题 07 Spawner Acquire-First
**设计文档**`07-spawner-acquire-first.md`#07.1 已实施, #07.2 已实施
| # | 设计要求 | 代码现状 | 判定 |
|---|---------|---------|------|
| B7-1 | Phase 0 Pre-acquire 修复 | spawner.py L499-512 | ✅ |
| B7-2 | Phase 1 Counter acquire | spawner.py L516-521 | ✅ |
| B7-3 | Phase 2 Session check | spawner.py L523-568 | ✅ |
| B7-4 | Phase 2.5 假死修复 | spawner.py L557-568 | ✅ |
---
### B8: 专题 08 Classify Outcome 优化
**设计文档**`08-classify-outcome-optimization.md` — 已实施 ✅
| # | 设计要求 | 代码现状 | 判定 |
|---|---------|---------|------|
| B8-1 | A0-A17 判定树 | `_classify_outcome` 方法 | ✅ |
| B8-2 | A9 api_error 特殊路径 | `api_retry_count` | ✅ |
| B8-3 | A14-A17 可恢复 retry + cooldown 60s | `cooldown_seconds` + `set_cooldown` | ✅ |
---
### B9: 专题 09 Rebuttal + Goal Gate
**设计文档**`09-rebuttal-and-goal-gate.md`
| # | 设计要求 | 代码现状 | 判定 |
|---|---------|---------|------|
| B9-1 | §2.1 Rebuttal 自动化(review 非 approved → @mention assignee | `review.py RebuttalManager` + `ticker.py _rebuttal_on_complete` | ✅ |
| B9-2 | §2.1 防止无限循环(max 2 轮) | `RebuttalManager.MAX_ROUNDS = 2` | ✅ |
| B9-3 | §2.2 目标一致性 Gate | 无自动 goal gate 检查 | ⚠️ 设计为 Agent 端行为,非 Daemon 侧 |
---
### B10: 专题 10 T3 需求探索 + 黑板展示
**设计文档**`10-t3-requirement-exploration-and-blackboard-display.md`
| # | 设计要求 | 代码现状 | 判定 |
|---|---------|---------|------|
| B10-1 | A2 需求探索过程写黑板 comments | 后端支持 `comment_type` | ✅ |
| B10-2 | A3 TaskModal 实时刷新 | SSE `comment_added` / `checkpoint_resolved` | ✅ |
| B10-3 | D1 砍掉 AI 摘要 | 黑板直投前端 | ✅ |
| B10-4 | D2 SSE 只做通知 | 前端按需拉数据 | ✅ |
---
### B11: 专题 11 上下文四层重设计
**设计文档**`11-context-layers-redesign.md`
| # | 设计要求 | 代码现状 | 判定 |
|---|---------|---------|------|
| B11-1 | §2.3 L2 操作规范型 6 个 Skill 全文注入 | `BootstrapBuilder``ROLE_SKILL_MAP` + `_read_skill` 全文注入 ✅;`task_handler.py RoleSkillSection` 只给索引+引导语 ⚠️ | 🟡 **双路径并存**,策略矛盾 |
| B11-2 | §2.3 `handoff.schema.json` | 不存在 | ⚠️ Phase 3 |
| B11-3 | §2.3 `review_protocols/` 目录 | 不存在,但 `review-quality` Skill 文件存在 | ⚠️ 设计文档 §三归属表已改归类为 L3 Skill |
| B11-4 | §6 Phase 3 Step 6-8 BootstrapBuilder 改造 | 已完成(ROLE_SKILL_MAP + _read_skill | ✅ |
| B11-5 | §2.3 token 预算 ~600 tokens | bootstrap.py 有 warn 但不截断 | 🟡 有告警无硬限制 |
**B11 关键发现**:新旧路径的 Skill 注入策略矛盾——
- 旧路径(BootstrapBuilder):**全文注入** Skill`_read_skill` 读文件全文)
- 新路径(RoleSkillSection):**只给索引**"请用 read 工具读取 SKILL.md"
- 设计文档 §2.3 要求 "A 类 Skill 全文注入"
- handler 注册后会从旧路径切换到新路径,导致 **Skill 从全文注入降级为索引提示**
这是一个 **隐性回归**:注册 handler 后 Agent 获取的操作规范信息量大幅减少。
---
### B12: 专题 12 Pipeline 设计
**设计文档**`12-pipeline-design.md`
| # | 设计要求 | 代码现状 | 判定 |
|---|---------|---------|------|
| B12-1 | §3 Pipeline 注册表 | 不存在 | ⚠️ 设计 §9 标注 Phase 2 |
| B12-2 | §4 路由逻辑 task_type | router.py 无 task_type 路由 | ⚪ |
| B12-3 | §8 PipelineRegistry | 不存在 | ⚪ |
| B12-4 | §10.1 task_type 默认值改 None | `blackboard_routes.py` 已为 Nonev3.0 已修) | ⚪ **已实施** |
| B12-5 | §10.2 广播计数器修正 | `_broadcast_tracker` + `BroadcastRound` 已实现 | ⚪ **已实施** |
**B12 结论**Pipeline 主体未实施符合设计路线图(Phase 2),但 §10 Phase 1 的两个 bug fix 明确标注为"立做"却未执行。
---
### B13: 专题 13 工具链与开发工作流
**设计文档**`13-toolchain-and-dev-workflow.md`
| # | 设计要求 | 代码现状 | 判定 |
|---|---------|---------|------|
| B13-1 | §16 工具链事件中枢 | `toolchain_handler.py` + `toolchain_templates.py` | ✅ |
| B13-2 | Gitea webhook 处理 | 5 模板 + 去重 | ✅ |
| B13-3 | CI 前缀 `[CI]` | ✅ | ✅ |
---
### Part B 汇总
| 判定 | 数量 | 主要项目 |
|------|------|---------|
| ✅ 一致 | 21 | B1-1, B1-2, B2-1/2, B3-1, B4-2/5, B5-1/2/3, B6-1~4, B7-1~4, B8-1~3, B9-1/2, B10-1~4, B11-4, B13-1~3 |
| 🟡 部分一致 | 3 | B11-1 双路径策略矛盾, B11-5 token 预算无硬限制 |
| ⚠️ 设计标注未实施 | 10 | B1-3handler 不可达), B2-3/4, B3-2/3, B4-1/3/4, B5-4, B9-3, B11-2/3 |
| ❌ 设计承诺未交付 | 0 | — |
---
## Step 5 审计报告偏差项验证
`step5-audit-report.md` 列出 6 项偏差(D1-D6)。逐项验证 HEAD 代码:
| # | 审计描述 | HEAD 实际状态 | 判定 |
|---|---------|-------------|------|
| D1 | pre_spawn 返回值未检查 | **已修复**`if not _handler.pre_spawn(...): raise RuntimeError("handler_pre_spawn_failed")` | ✅ 已修 |
| D2 | PromptContext 缺少 from_agent/mail_type | **已修复**spawner L289-296 从 must_haves JSON 提取 | ✅ 已修 |
| D3 | inform outcome 白名单缺失 | 未修复。但影响极小——CRASH_OUTCOMES 由基类处理,剩余异常 outcome 罕见 | 🟢 可接受 |
| D4 | retry prompt 仍用 `is_mail` 硬编码 | **未修复**spawner L1128 仍硬编码 `is_mail = project_id == "_mail"` | 🟡 遗留 |
| D5 | _check_reply 语义差异 | **已修复**MailHandler._check_reply 用 `SELECT id FROM tasks WHERE id != ? AND must_haves LIKE ?`,与 v3.0 一致 | ✅ 已修 |
| D6 | 标 done 重试机制 | **已修复**`BaseTaskHandler._mark_task_status` 有 3 次重试 | ✅ 已修 |
**结论**D1/D2/D5/D6 已在后续 commit 修复,D3 可接受,D4 是遗留项。
---
## 与庞统 Review 的背靠背比对
| 维度 | 司马懿 | 庞统 | 差异分析 |
|------|--------|------|---------|
| **致命问题** | A1 review verdict 丢失 + A2 handler 未注册 | 仅 #1 review verdict 丢失 | **关键差异**:庞统未将 handler 未注册列为致命问题。庞统认为 `_legacy_on_complete` 仍可运行所以只关注 review 路径。但我认为 **ticker 不再扫描 `_mail`** 是 v3.0 有、HEAD 丢失的行为,这比 review 路径更严重——Mail 系统完全停止工作 |
| **_mail tick 丢失** | 明确指出 A2 导致 ticker 不扫描 `_mail` | 未提及 | 庞统漏检了 `virtual_projects()` 返回空时 `_mail` 不被 tick 的后果 |
| **Skill 注入降级** | B11-1 发现新旧路径策略矛盾 | 未提及 | 庞统未分析 handler 注册后 Skill 注入策略的变化 |
| **D1/D2/D5 修复状态** | 验证了审计报告的修复项 | 未验证 | 庞统的 review 早于修复 commit |
| **专题覆盖范围** | 13 个全覆盖 | 13 个全覆盖 | 一致 |
| **D4 retry 硬编码** | A8 独立发现 | B4 独立发现 | 独立确认 |
---
## 建议优先级
| 优先级 | 项目 | 说明 |
|--------|------|------|
| **P0** | A2 handler 注册初始化 | 添加 `TaskTypeRegistry.register()` 启动代码。**这是所有 handler 路径的前提** |
| **P0** | A1 review verdict 处理 | P0 修复后自然恢复(`TaskHandler.handle_review_complete` 生效)。**但如果不想立即注册 handler**,需先在 `_legacy_on_complete` 中恢复 review verdict 逻辑作为 interim fix |
| **P1** | B11-1 Skill 注入策略对齐 | 注册 handler 后 RoleSkillSection 只给索引——需确认这是有意降级还是遗漏。如果是遗漏,RoleSkillSection 应全文注入 |
| **P1** | A8 retry prompt handler 化 | spawner L1128 `is_mail` 硬编码改走 handler |
| **P2** | A3 死代码清理 | `_mail_auto_working` / `_mail_auto_complete` 等方法在 handler 注册后确认不再需要再删除 |
| ~~P2~~ | ~~B12-4/5~~ | ~~已验证 v3.0 tag 时已修复,不需要再改~~ |
| **P3** | ticker.py L1483 缩进对齐 | 风格问题 |
---
## Cross-Check:庞统 Review 逐项验证
对庞统 `review-v3-vs-head-pangtong.md` 的每个发现,独立验证:**是不是真问题?根因对不对?修复方案对不对?**
---
### Part A 逐项验证
#### 庞统 #1 🔴 review verdict 丢失
**庞统判定**:致命。"仅影响非 handler 项目(_general"。
**我的验证**
1. **是致命问题** ✅ —— `_legacy_on_complete``_is_review=True` 时确实什么都不做。
2. **"仅影响非 handler 项目" — 表述不准确**。实际情况更复杂:
- `_general` 项目确实受影响(走 `_legacy_on_complete`
- 但 `_mail` / `_toolchain` 不受影响,**不是因为"handler 正确处理"**,而是因为它们**根本不走 review 流程**ticker 中 `_dispatch_reviews` 对 handler 项目 `return []`
- 庞统说"handler 项目(_mail/_toolchain)的 review 由 TaskHandler.post_complete 正确处理"——**这个说法有误导性**。TaskHandler 不是 `_mail`/`_toolchain` 的 handler,它们各自的 handlerMailHandler/ToolchainHandler)没有 `handle_review_complete` 方法。它们不走 review 是因为设计上就不走。
3. **庞统的修复方案有隐藏缺陷**。庞统说"让非 handler 项目也走 TaskHandler(注册 `_general` 到 TaskTypeRegistry"。但 `TaskTypeRegistry.get_by_project()` 匹配的是 `handler.virtual_project`,而 TaskHandler 的 `virtual_project = None`。所以:
- `get_by_project("_general")` → 遍历所有 handler,检查 `h.virtual_project == "_general"` → TaskHandler 的 `virtual_project``None`**不匹配** → 返回 `None`
- 即使注册了 TaskHandler`_general` 项目仍然走 `_legacy_on_complete`
- 庞统的修复方案需要**额外改 TaskHandler.virtual_project 或 registry 匹配逻辑**,但他没指出这一点
**结论**:问题是真的,严重度判定正确。但影响范围描述和修复方案都不完整。
---
#### 庞统 #2 🟢 旧 `_mail_*` 方法保留
**庞统判定**:正常重构,方法体保留标记为 deprecated。
**我的验证**
1. **方法体确实保留** ✅(dispatcher.py L628-860
2. **但"标记为 deprecated"不对**——代码中没有 `@deprecated` 装饰器或注释。这些方法就是安静地躺在那里,没有任何标记告诉维护者"别用了"
3. **我标 🟡 中等而非 🟢**的原因:无 deprecated 标记 + 主流程不再调用 = 未来维护者容易误用
**结论**:问题不大,但庞统多给了信息("标记为 deprecated")——代码中实际没有标记。
---
#### 庞统 #3 🟢 spawn 失败回退
**庞统判定**:逻辑改进。
**我的验证**:✅ 确认等价,新版更通用。
---
#### 庞统 #4-5 🟢 spawner prompt/api_section
**庞统判定**:等价实现。
**我的验证**:✅ 确认等价。
---
#### 庞统 #6 🟢 ticker `_mail``virtual_projects()`
**庞统判定**:正常重构,可扩展。
**我的验证**:**这是庞统最大的漏检**。
庞统只看了代码方向(硬编码 → 注册表),**没有检查注册表是否为空**。
实际运行时 `TaskTypeRegistry.virtual_projects()` 返回空列表 → `_mail` 不被 ticker 扫描。这是一个 **v3.0 有、HEAD 丢失的行为**——v3.0 中 `_mail` 硬编码在 ticker L218-229HEAD 中完全消失。
后果:所有 Mail 任务的 pending → claimed → working 流程中断,整个飞鸽传书系统停止工作。
这不是"正常重构",是**致命回归**。
---
#### 庞统 #7-8 🟢 ticker check_reply / dispatch_reviews
**庞统判定**:等价实现。
**我的验证**:✅ 确认等价。但 #7 说"缩进正确"——实际 ticker.py L1483 有缩进不一致(28 空格 vs 同级 24 空格),不影响运行但增加维护混淆。
---
### Part B 逐专题验证
#### 专题 01-03:无分歧
庞统的检查和我的结论一致。设计原则未强制实施属于正常。
---
#### 专题 04:庞统更严格
庞统把 B4-3co_assignees)和 B4-4output↔comment)标 ❌,我标 ⚪(Phase 2/3)。
庞统的判定更严格——"设计了但没实现就是不一致" vs 我的"设计自身标注了 Phase,未实施是预期的"。两种视角都有道理,**不算错误**。
---
#### 专题 05:判定标准差异
庞统把 B5-4(L3 wiki 知识注入)标 ❌。我标 ⚪(Phase 2)。
同专题 04,判定标准差异。
---
#### 专题 06:庞统更细致
庞统多了 B6-5"设计提到 7 个恢复方法只看到 2 个公开方法"——这是一个合理的疑问,我没有提出。
---
#### 专题 07-10:无分歧
---
#### 专题 11:庞统全标 ❌ 是错的
庞统 B11-1 说"BootstrapBuilder 只注入通用 prompt,无 skill 全文注入"。
**我验证了代码**
```python
# bootstrap.py L29
ROLE_SKILL_MAP = {
"executor": "blackboard-executor",
"reviewer": "blackboard-reviewer",
...
}
# bootstrap.py L68-72
skill_name = self.ROLE_SKILL_MAP.get(role)
if skill_name:
skill_content = self._read_skill(skill_name) # 读全文
if skill_content:
sections.append(skill_content)
```
**BootstrapBuilder 有 Skill 全文注入**。庞统说"无 skill 全文注入"与代码不符。他可能只看了 `task_handler.py` 的 RoleSkillSection(确实只给索引),没有看 `bootstrap.py` 的旧路径。
**实际情况**:双路径并存。旧路径(BootstrapBuilder)全文注入,新路径(RoleSkillSection)只给索引。handler 注册后从旧路径切换到新路径,Skill 信息量降级。这才是真正的问题。
---
#### 专题 12:我之前的 B12-4/5 判定有误
我在 Part B 中说"B12-4 task_type 默认值仍为 `\"coding\"`"和"B12-5 广播计数器 retry_count 不递增"是 Phase 1 承诺未交付。
**cross-check 时我重新验证了代码**:
- **B12-4**`blackboard_routes.py` L138 已是 `body.get("task_type", None)`**默认值已经是 None**。v3.0 tag 中也是 None。设计文档 §10.1 的 bug fix 可能在 v3.0 之前就修了,或者设计文档基于旧版本写的。**不是问题**,我之前的判定有误。
- **B12-5**`ticker.py``_broadcast_tracker` + `BroadcastRound` + `round_number >= 3` 升级庞统的机制已实现。`mark_mention_retry``retry_count = retry_count + 1`。设计 §10.2 描述的问题已在 v3.0 或更早修复。**不是问题**,我之前的判定有误。
庞统对专题 12 的判定("设计文档 §9 自身标记为待实现")比我准确。
**修正我的报告**Part B 中 B12-4 和 B12-5 应从 ❌ 改为 ⚪(设计自标 Phase 2,主体未实施是预期的)。
---
#### 专题 13:无分歧
---
### 庞统未引用 Step 5 审计报告
庞统的 review 完全没引用 `step5-audit-report.md`v3.0..HEAD diff 中新增的文件)。这意味着 D1/D2/D5 的修复状态未经庞统验证。我逐项验证了 D1/D2/D5 **已修复**D4 **未修复**retry 硬编码),D3 **可接受**D6 **已修复**
---
### 庞统漏检的额外行为回归
handler 未注册还导致一个庞统完全没提到的问题:
**guardrail 回归**。v3.0 中 dispatcher L127-128
```python
is_mail = project_config.get("project_id") == "_mail" if project_config else False
if self.guardrails and not is_mail:
```
HEAD dispatcher L128-131
```python
handler = TaskTypeRegistry.get_by_project(project_config.get("project_id", "") ...)
is_handler_task = handler is not None
if self.guardrails and not is_handler_task:
```
handler 未注册 → `is_handler_task = False`**`_mail` 项目也要过 guardrail 检查了**。v3.0 中 `_mail` 是跳过 guardrail 的。这可能导致某些 Mail 任务被 guardrail 拦截。
---
### Cross-Check 总结
| 维度 | 庞统 review 质量 |
|------|-----------------|
| **致命问题发现** | 发现 A1 ✅,漏检 A2handler 注册 + ticker 不可达 + guardrail 回归)❌ |
| **根因分析** | A1 根因正确。修复方案不完整(没指出 TaskHandler.virtual_project=None 导致注册也匹配不到 `_general` |
| **Part B 专题覆盖** | 13/13 全覆盖 ✅ |
| **Part B 事实准确性** | B11 "无 skill 全文注入"与代码不符 ❌。B12 比我准确 ✅ |
| **Part B 多给信息** | #2 说"标记为 deprecated"但代码无标记 ⚠️ |
| **Part B 更严格处** | B04-3/4 标 ❌(合理),B06-5 恢复方法数量疑问(合理) |
| **审计报告验证** | 未引用,未验证 D1-D6 修复状态 |
| **遗漏的行为回归** | guardrail 对 `_mail` 的回归 |
**我的自我修正**:B12-4/5 判定有误,应改为 ⚪。v3.0 tag 时这两个问题已修复,设计文档描述的是更早期的问题。
---
*— 司马懿 仲达,质量总监 🗡️*
+74
View File
@@ -0,0 +1,74 @@
# Step 5 双重审计报告
## 摘要
- 设计一致性检查项: 8
- 特殊逻辑覆盖检查项: 22
- 一致/覆盖: 24
- **偏差/遗漏: 6(严重 3 / 轻微 3)**
---
## 偏差/遗漏清单
| # | 维度 | 设计要求 / 旧逻辑 | 代码实际 | 严重程度 | 建议 |
|---|------|-------------------|---------|---------|------|
| **D1** | B1.2 pre_spawn | 旧 `_mail_on_checks_passed`: `if not _mail_auto_working(): raise RuntimeError` — pre_spawn 失败时中止 spawn | 新 `_handler_on_checks_passed`: `_handler.pre_spawn(...)` 返回值未检查,`handler_marked_working = True` 无条件执行 | **严重** | 改为 `if not _handler.pre_spawn(...): raise RuntimeError("handler_pre_spawn_failed")` |
| **D2** | B3.1 PromptContext | 旧 `_build_mail_prompt` 从 must_haves JSON 解析 `from_agent``performative` 传入模板 | 新 `spawner._build_spawn_message` 构建 PromptContext 时缺少 `from_agent``mail_type`,均为空字符串 | **严重** | 从 `must_haves` JSON 提取 `from``performative` 填入 PromptContext |
| **D3** | B1.3 inform outcome 白名单 | 旧 `_mail_auto_complete`: inform 类型有 outcome 白名单 `{"completed", "claimed", "no_reply"}`,不在白名单的 outcome 跳过 auto-done | 新 `MailHandler.verify_completion`: inform 始终返回 True,不检查 outcome | **轻微** | CRASH_OUTCOMES 已被基类处理。剩余异常 outcomesession_revived/api_error/fallback_timeout)极少出现,且旧逻辑不标 done 只是等 ticker 重投,最终效果差异不大。但严格对齐需要加白名单检查 |
| **D4** | A. 设计 §6 retry 逻辑 | 设计文档要求 retry 逻辑中 `handler = TaskTypeRegistry.get_by_project(project_id); if handler: return handler.build_retry_prompt(...)` | spawner L1118-1130 重试 prompt 仍用 `is_mail = project_id == "_mail"` 硬编码 | **轻微** | 当前不影响运行(旧的 `_build_mail_prompt` 仍保留且可用),但与设计文档不一致 |
| **D5** | B1.5 _check_reply 语义差异 | 旧 `_mail_check_reply`: `SELECT id FROM tasks WHERE id != ? AND must_haves LIKE ?` — 检查是否有其他任务的 must_haves 包含当前 task_id(即 in_reply_to 匹配) | 新 `MailHandler._check_reply`: `SELECT COUNT(*) FROM comments WHERE task_id=? AND author != 'daemon' AND comment_type != 'system'` — 检查当前任务是否有非系统 comment | **严重** | 两个查询语义完全不同。旧逻辑检查的是 **mail 表的回复任务**(通过 must_haves 中 in_reply_to 关联),新逻辑检查的是 **当前任务的 comments**。这可能导致 request 类型邮件的幻觉门控行为不同 |
| **D6** | B1.3 标 done 重试机制 | 旧 `_mail_auto_complete`: 标 done 时外层有 `for attempt in range(3)` 循环 | 新 `BaseTaskHandler._mark_task_status`: H1 修复后已有 3 次重试 | **轻微** | ✅ 已修复,但注意旧代码标 done 和标 failed 是分开的重试循环,新代码统一走 `_mark_task_status`。行为等价 |
---
## 一致确认项
### A. 设计一致性
| # | 维度 | 检查点 | 结果 |
|---|------|--------|------|
| A1 | §6 dispatcher | classify_outcome 后调 handler.post_complete | ✅ on_complete 闭包替换为 handler.post_complete |
| A2 | §6 dispatcher | on_checks_passed → handler.pre_spawn | ✅ _handler_on_checks_passed 调用 handler.pre_spawn(但返回值未检查,见 D1) |
| A3 | §6 dispatcher | guardrail 跳过 → handler 判断 | ✅ `is_handler_task = handler is not None` |
| A4 | §6 spawner | _build_prompt → handler.build_prompt | ✅ handler 路径调用 handler.build_prompt(ctx) |
| A5 | §6 spawner | _build_api_section → handler 查询 | ✅ handler 存在时 success_status 从 handler.target_success_status 获取 |
| A6 | §6 ticker | 虚拟项目扫描 → registry.virtual_projects() | ✅ 循环 `TaskTypeRegistry.virtual_projects()` |
| A7 | §6 ticker | check_completion → handler.check_completion | ✅ 超时检查中调 `handler.check_completion(task.id, db_path)` |
| A8 | §6 兼容期 | 设计说"兼容期保留旧逻辑" | ✅ 无 handler 的项目走旧路径(legacy_on_complete |
### B. 特殊逻辑覆盖
| # | 维度 | 检查点 | 结果 |
|---|------|--------|------|
| B1 | 1.1 guardrail | handler 项目跳过,_general 等走 guardrail | ✅ |
| B2 | 1.2 _mail_auto_working | `BEGIN IMMEDIATE` + status 检查 + 标 working | ✅ `_auto_mark_working` 完全一致 |
| B3 | 1.3 request 无回复 → 标 failed + notify | ✅ MailHandler.on_failure 调 `_mark_task_status(failed)` + `notify_mail_failed` |
| B4 | 1.4 _mail_revert_to_pending | spawn 失败回退 working → pending | ✅ Exception handler 中有 `BEGIN IMMEDIATE` + 状态检查回退 |
| B5 | 1.6 Task review verdict 读取 | approved → done | ✅ handle_review_complete |
| B6 | 1.6 Task review 非 approved → @mention assignee + 保持 review | ✅ H3 修复后保持 review + INSERT comment with comment_type='review' |
| B7 | 1.6 Task executor 三信号验证 | output/comment/terminal status → review | ✅ verify_completion 完全一致 |
| B8 | 1.7 Legacy dispatch 路径 | handler 替代 is_mail_legacy | ✅ handler_legacy 查注册表 |
| B9 | 2.1 _transition_status assignee 清空 | handler 项目不清空 | ✅ |
| B10 | 2.2 跳过 claimed 状态 | handler 项目跳过 claimed 直接 working | ✅ |
| B11 | 2.3 _dispatch_reviews 跳过 | handler 项目不走 review | ✅ |
| B12 | 2.5 startup recovery | `_general` + virtual_projects() | ✅ 不会重复扫描 |
| B13 | 3.1 _build_api_section | handler 存在时正确获取 success_status | ✅ |
| B14 | B4.1 TaskHandler.post_complete | 区分 executor/review 流程 | ✅ 通过读 DB status 判断 |
| B15 | B4.2 MailHandler.post_complete | 基类统一流程 | ✅ |
| B16 | B4.3 ToolchainHandler.post_complete | 基类统一流程 | ✅ |
| B17 | B1.5 _check_reply 异常保守处理 | 旧: return True(保守)/ 新: return False | 见 D5 |
| B18 | CRASH_OUTCOMES 集合 | 与旧 ROLLBACK_CURRENT_AGENT_OUTCOMES 一致 | ✅ 完全一致 |
| B19 | B2.1 _toolchain ticker 扫描 | _toolchain 会被 ticker 扫描 | ✅ _toolchain 有 blackboard.db 时会被 tick_project 处理 |
| B20 | B2.3 handler 项目都跳过 claimed | _toolchain 也跳过 | ✅ 所有 handler 项目统一处理 |
---
## 修复优先级
| 优先级 | # | 修复内容 |
|--------|---|---------|
| **P0** | D1 | dispatcher _handler_on_checks_passed 检查 pre_spawn 返回值 |
| **P0** | D2 | spawner PromptContext 从 must_haves 提取 from_agent 和 mail_type |
| **P0** | D5 | MailHandler._check_reply 恢复旧查询语义(检查 must_haves 中的 in_reply_to |
| P1 | D3 | inform outcome 白名单(可选,影响极小) |
| P2 | D4 | retry prompt 用 handler 路径替代硬编码 |
+324
View File
@@ -0,0 +1,324 @@
# Step 5 引擎接入 — 影响分析与逐点对照
## 方法论
逐行审查 dispatcher.py / spawner.py / ticker.py 中所有 `is_mail` / `_mail` / `project_id == "_mail"` 分支,
对照 handler 实现,确认每个特殊处理的去向。
---
## 一、dispatcher.py985 行)
### 1.1 Guardrail 跳过(L127-129
```python
is_mail = project_config.get("project_id") == "_mail" if project_config else False
if self.guardrails and not is_mail:
violations = self.guardrails.check_task(task)
```
**特殊处理**Mail 不做 guardrail 检查。
**Handler 覆盖**:设计文档 D6 "skip_guardrail 从接口删除,guardrail 自己判断"。Step 5 改为:`if self.guardrails and handler is None`(无 handler 时走 guardrail),或者用 handler.virtual_project 判断。handler 存在时跳过 guardrail。
**改动**`is_mail``TaskTypeRegistry.get_by_project(project_id) is not None`
---
### 1.2 Mail on_checks_passedL194-213
```python
on_checks_passed = None
_mail_marked_working = False
if is_mail and db_path:
def _mail_on_checks_passed():
nonlocal _mail_marked_working
if not _disp._mail_auto_working(_task_id, _mail_db):
raise RuntimeError("mail_auto_working_failed")
_mail_marked_working = True
on_checks_passed = _mail_on_checks_passed
```
**特殊处理**Mail spawn 前通过 on_checks_passed 回调标 working,标记成功后才 spawnspawn 失败回退。
**Handler 覆盖**MailHandler.pre_spawn 调用 `_auto_mark_working`,和 `_mail_auto_working` 逻辑完全一致。
**改动**
- `on_checks_passed` 改为调用 `handler.pre_spawn(task_id, db_path)`
- `_mail_marked_working` 标记保留,用于 Exception 回退
---
### 1.3 Mail on_completeL224-238
```python
if is_mail:
def _mail_on_complete(aid, outcome):
_dispatcher._mail_auto_complete(_task_id, aid, _mail_db, _must_haves, outcome=outcome)
on_complete = _mail_on_complete
```
**特殊处理**Mail on_complete 调用 `_mail_auto_complete`(含 inform/request 分支、幻觉门控、重试 3 次、失败通知)。
**Handler 覆盖**MailHandler 使用基类 post_complete 统一流程(crash→verify→mark→notify)。但现有 `_mail_auto_complete` 有几个细节差异需要注意:
| 现有逻辑 | Handler 覆盖 | 差异 |
|---------|-------------|------|
| request 无回复 → 重试 3 次标 failed | on_failure 标 failed + notify | ⚠️ 缺少 3 次重试 |
| inform 只在特定 outcome 标 done | verify 始终返回 True → 基类标 done | ✅ 简化了,合理 |
| 标 done 重试 3 次 | _mark_task_status 单次 | ⚠️ 缺少重试 |
| notify_mail_failed | on_failure 中调用 notify_mail_failed | ✅ 一致 |
**⚠️ 关键发现**:现有代码标状态时有 **重试 3 次** 机制(防止 DB 锁),handler 的 `_mark_task_status` 只做一次。需要把重试逻辑补到 `_mark_task_status` 或在 handler 层加。
**改动**on_complete 改为调用 `handler.post_complete(task_id, agent_id, outcome, db_path)`
---
### 1.4 Task on_completeL241-310
```python
else:
def _task_on_complete(aid, outcome):
# #07.2: crash 回退
if outcome in ROLLBACK_CURRENT_AGENT_OUTCOMES and _task_db:
_dispatcher._rollback_current_agent(_task_db, _task_id, aid)
if _is_review:
if outcome in ("completed", "session_revived"):
# 读 verdict → approved 标 done / 非 approved @mention assignee
else:
logger.warning("review agent outcome=%s, NOT marking done", outcome)
else:
# executor: 三信号验证 → 标 review
_dispatcher._task_auto_complete(_task_id, _task_db)
```
**特殊处理清单**
1. **#07.2 crash 回退**executor 和 review 都回退 current_agent → assignee
2. **review 分支**outcome 必须是 "completed" 或 "session_revived" 才走 verdict 读取
3. **review verdict 读取**approved → done,非 approved → @mention assignee + 保持 review
4. **review @mention**:通过 Blackboard.add_commentcomment_type="review"
5. **executor 分支**:走 _task_auto_complete → 三信号验证 → review
**Handler 覆盖**
- crash 回退:✅ BaseTaskHandler.post_complete 第一步
- review verdict:⚠️ **TaskHandler.handle_review_complete 存在但未被 dispatcher 调用**。现有 dispatcher 直接在闭包里做了,不走 handler。
- @mention:⚠️ handler 用 `conn.execute("INSERT INTO comments")` 直接插入,dispatcher 用 `Blackboard.add_comment`(会做更多处理,如 comment_type="review"
- executor 三信号:✅ TaskHandler.verify_completion
**⚠️ 关键发现**
1. dispatcher 的 review @mention`bb.add_comment(..., comment_type="review")`handler 直接 INSERT 不带 comment_type。需要修复 handler。
2. dispatcher 对 review outcome 有白名单检查(只处理 "completed"/"session_revived"),handler 的 post_complete 没有 outcome 白名单——crash 已在基类处理,其他 outcome 都会走 verify。
3. dispatcher review 非 approved 时**保持 review 状态**handler 的 handle_review_complete 标回 working。这是**行为差异**。
**改动**:需要先修复 handler 的 review 分支,再替换 on_complete。
---
### 1.5 Mail spawn 失败回退(L355-358
```python
except Exception as e:
if _mail_marked_working:
self._mail_revert_to_pending(task.id, db_path)
```
**特殊处理**spawn 失败(subprocess 启动失败)回退 working → pending。
**Handler 覆盖**:❌ handler 没有这个。这是 dispatcher 级别的异常处理,和 handler 无关。但 toolchain 也需要类似逻辑。
**改动**:保留在 dispatcher 中,改为 `_mail_marked_working``handler_marked_working`
---
### 1.6 Legacy dispatchL584-660
```python
is_mail_legacy = project_config.get("project_id") == "_mail"
if is_mail_legacy:
if not self._mail_auto_working(task.id, db_path_legacy):
return error
```
**特殊处理**legacy 路径(router=None 时触发)也有 mail 特殊处理。
**Handler 覆盖**:同 1.2/1.3,用 handler 替代。
**改动**:同样用 handler.pre_spawn 和 handler.post_complete 替代。
---
### 1.7 现有 Mail 辅助方法(L658-870
`_mail_auto_working` / `_mail_revert_to_pending` / `_mail_auto_complete` / `_mail_check_reply`
**改动**:Step 5 不删这些方法(安全起见保留,标记 deprecated),只改调用方。确认稳定后再删。
---
## 二、spawner.py1704 行)
### 2.1 _build_prompt 中的 mail 分支(L282-284
```python
if project_id == "_mail":
return self._build_mail_prompt(task_id, title, description, must_haves, agent_id)
```
**特殊处理**:Mail 用专用精简模板。
**Handler 覆盖**MailHandler.build_prompt 通过 PromptComposer 拼 3 个 section。
**改动**:查注册表 → handler.build_prompt(context)。需要构建 PromptContext 传入。
---
### 2.2 _build_api_sectionL321-325
```python
success_status = '"done"' if project_id == "_mail" else '"review"'
```
**特殊处理**Mail 的 success_status 是 done。
**Handler 覆盖**:已由 handler 的 PromptSection 处理(TaskApiSection hardcode reviewMailApiSection 不含 status 回写指令)。
**改动**:如果 handler 存在,跳过 _build_api_sectionhandler.build_prompt 已包含)。
---
### 2.3 classify_outcome 中的 handler 调用
spawner 在 classify_outcome 后调 on_complete(outcome)。on_complete 是 dispatcher 传入的闭包。
**改动**on_complete 闭包改为调用 handler.post_complete。spawner 本身不直接查注册表。
---
## 三、ticker.py1897 行)
### 3.1 虚拟项目扫描(L218-229
```python
mail_db = Path(self.registry.root) / "_mail" / "blackboard.db"
if mail_db.exists() and "_mail" not in active_projects:
pr = await self._tick_project("_mail", {...})
```
**特殊处理**:_mail 硬编码扫描。
**Handler 覆盖**TaskTypeRegistry.virtual_projects() 返回 ["_toolchain", "_mail"]。
**改动**:循环 `TaskTypeRegistry.virtual_projects()` 替代硬编码。_toolchain 如果也需要 ticker 扫描就自动发现。但需确认 _toolchain 是否需要 ticker——当前 toolchain 任务创建和完成都在 toolchain_routes.py 中处理,可能不需要 ticker 扫描。
---
### 3.2 _transition_status 中 mail assignee 不清空(L953-960
```python
if new_status == "pending":
if self._current_project_id == "_mail":
# Mail 的 assignee 是收件人,永不清空
conn.execute("UPDATE tasks SET status=?, updated_at=? WHERE id=?", ...)
else:
conn.execute("UPDATE tasks SET status=?, assignee=NULL, ...", ...)
```
**特殊处理**Mail 重置到 pending 时不清空 assigneeassignee 是收件人)。
**Handler 覆盖**:❌ handler 不管 ticker 的状态转换逻辑。这是 ticker 内部逻辑。
**改动**:用 `TaskTypeRegistry.get_by_project(project_id)` 判断替代硬编码。
---
### 3.3 Mail 跳过 claimed 状态(L1029-1043
```python
if project_id == "_mail":
conn.execute("UPDATE tasks SET current_agent=? WHERE id=?", ...)
# 跳过 claimed,直接 working
```
**特殊处理**Mail 不走 claimed 中间态(已在 dispatcher 中标 working)。
**Handler 覆盖**handler.pre_spawn 的 _auto_mark_working 跳过了 claimed。
**改动**:用 handler 判断替代硬编码。
---
### 3.4 _dispatch_reviews 跳过 mailL1304
```python
if project_id == "_mail":
return []
```
**特殊处理**Mail 不走 review 流程。
**Handler 覆盖**MailHandler.target_success_status = "done",不走 review。但 ticker 的 _dispatch_reviews 是看项目级。
**改动**:用 handler 判断。
---
### 3.5 Mail 幻觉门控兜底(L1474-1492
```python
if self._current_project_id == "_mail":
has_reply = self._mail_check_reply(task.id, db_path)
if has_reply:
# working → done
```
**特殊处理**:Ticker 超时检查时,如果 mail 有回复,标 done 而非 failed。
**Handler 覆盖**:❌ handler 的 check_completion 只返回 bool,不做状态标记。
**改动**:调用 handler.check_completion 替代 _mail_check_reply。状态标记逻辑保留在 ticker 中。
---
### 3.6 _mail_check_replyL1555-1575
和 dispatcher 版本一致。
**改动**:用 handler.check_completion 替代。
---
### 3.7 虚拟项目 init + recovery 扫描(L1625-1643
```python
for virtual_id in ("_general", "_mail"):
...
# _mail 项目不清空 assignee
```
**改动**virtual_projects() + _general 硬编码。
---
## 四、Handler 缺陷(需在 Step 5 前修复)
| # | 缺陷 | 影响 | 修复方案 |
|---|------|------|---------|
| H1 | BaseTaskHandler._mark_task_status 无重试 | DB 锁时标状态失败,任务卡住 | 加 3 次重试(和 dispatcher 现有行为一致) |
| H2 | TaskHandler.handle_review_complete 中 @mention 不带 comment_type="review" | review comment 无类型标记 | INSERT 加 comment_type |
| H3 | dispatcher review 非 approved 保持 review 状态,handler 标 working | **行为差异** | handler 改为保持 review 状态(和 dispatcher 一致) |
| H4 | dispatcher review outcome 有白名单("completed"/"session_revived"),handler 无 | crash 之外的异常 outcome 也会走 verify | handler 的 post_complete 已在基类处理 crash,其余 outcome 走 verify 是合理的 |
**H3 最关键**——dispatcher review 非 approved 保持 review 状态(等 assignee 自己处理),handler 标 working 会触发 ticker 重新 dispatch executor,这不是预期行为。
## 五、改动策略
**不删旧代码,只改调用方**
1. dispatcher 中 is_mail → handler 判断,on_checks_passed/on_complete → handler.pre_spawn/post_complete
2. spawner 中 _build_prompt → handler.build_prompt
3. ticker 中虚拟项目扫描 → registry.virtual_projects()mail 特殊判断 → handler 判断
4. 旧方法(_mail_auto_working 等)标记 @deprecated 保留,不删
**先修 handler 缺陷(H1-H3),再改引擎**。
+34 -11
View File
@@ -59,7 +59,10 @@ async def list_tasks(project_id: str,
assignee: Optional[str] = None,
parent_task: 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)
return {"tasks": [_task_to_dict(t) for t in tasks]}
@@ -79,10 +82,12 @@ async def get_task(project_id: str, task_id: str,
result["outputs_count"] = detail.get("outputs_count", 0)
result["review_status"] = detail.get("review_status")
result["latest_event_detail"] = detail.get("latest_event_detail")
result["comments"] = [dict(c.__dict__) for c in bb.get_comments(task_id)]
result["comments"] = [dict(c.__dict__)
for c in bb.get_comments(task_id)]
result["outputs"] = [dict(o.__dict__) for o in bb.get_outputs(task_id)]
result["reviews"] = [dict(r.__dict__) for r in bb.get_reviews(task_id)]
result["decisions"] = [dict(d.__dict__) for d in bb.get_decisions(task_id)]
result["decisions"] = [dict(d.__dict__)
for d in bb.get_decisions(task_id)]
result["events"] = q.task_events(task_id)
result["experiences"] = q.task_experiences(task_id)
return result
@@ -134,7 +139,8 @@ async def create_task(project_id: str, body: Dict[str, Any]):
priority=body.get("priority", 5),
assignee=assignee,
assigned_by=body.get("assigned_by", "user"),
depends_on=json.dumps(body["depends_on"]) if "depends_on" in body else None,
depends_on=json.dumps(
body["depends_on"]) if "depends_on" in body else None,
parent_task=body.get("parent_task"),
risk_level=body.get("risk_level", "standard"),
stage=body.get("stage"),
@@ -175,7 +181,8 @@ async def _generate_title(description: str) -> str | None:
resp = client.chat.completions.create(
model=model,
messages=[
{"role": "system", "content": "你是一个任务标题生成器。根据用户的需求描述,生成一个简洁的中文标题(5-15字),只输出标题,不要任何其他内容。"},
{"role": "system",
"content": "你是一个任务标题生成器。根据用户的需求描述,生成一个简洁的中文标题(5-15字),只输出标题,不要任何其他内容。"},
{"role": "user", "content": description[:500]},
],
max_tokens=50,
@@ -187,7 +194,8 @@ async def _generate_title(description: str) -> str | None:
return title
except Exception as e:
import logging
logging.getLogger("moziplus-v2").warning(f"Title generation failed: {e}")
logging.getLogger(
"moziplus-v2").warning(f"Title generation failed: {e}")
return None
@@ -205,7 +213,8 @@ async def task_progress(project_id: str, task_id: str):
async def claim_task(project_id: str, task_id: str, body: Dict[str, Any]):
bb = _bb(project_id)
if not bb.claim_task(task_id, body["agent"]):
raise HTTPException(409, "Claim failed (already claimed or wrong assignee)")
raise HTTPException(
409, "Claim failed (already claimed or wrong assignee)")
return {"ok": True}
@@ -273,10 +282,20 @@ def _init_agent_ids():
return
try:
import yaml
cfg_path = os.path.join(os.path.dirname(__file__), "..", "..", "config", "default.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())
_KNOWN_AGENT_IDS = list(
cfg.get(
"daemon",
{}).get(
"agent_profiles",
{}).keys())
except Exception:
_KNOWN_AGENT_IDS = []
@@ -285,7 +304,10 @@ 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))
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]
@@ -397,7 +419,8 @@ async def write_output(project_id: str, task_id: str, body: Dict[str, Any]):
)
os.makedirs(artifacts_dir, exist_ok=True)
# 安全文件名
safe_name = "".join(c if c.isalnum() or c in "._-" else "_" for c in title)
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)
+21 -7
View File
@@ -12,7 +12,9 @@ from typing import Optional
from src.blackboard.operations import Blackboard
from src.utils import get_data_root
router = APIRouter(prefix="/api/projects/{project_id}/tasks/{task_id}/checkpoints", tags=["checkpoints"])
router = APIRouter(
prefix="/api/projects/{project_id}/tasks/{task_id}/checkpoints",
tags=["checkpoints"])
# ── 请求模型 ──
@@ -50,10 +52,12 @@ def list_checkpoints(project_id: str, task_id: str):
@router.post("")
def create_checkpoint(project_id: str, task_id: str, req: CreateCheckpointRequest):
def create_checkpoint(project_id: str, task_id: str,
req: CreateCheckpointRequest):
"""Agent 创建 checkpoint"""
if req.type not in ("verify", "decision", "action"):
raise HTTPException(status_code=400, detail=f"Invalid checkpoint type: {req.type}")
raise HTTPException(status_code=400,
detail=f"Invalid checkpoint type: {req.type}")
bb = _bb(project_id)
# 验证 task 存在
@@ -73,10 +77,15 @@ def create_checkpoint(project_id: str, task_id: str, req: CreateCheckpointReques
@router.post("/{checkpoint_id}/approve")
def approve_checkpoint(project_id: str, task_id: str, checkpoint_id: str, req: ResolveCheckpointRequest):
def approve_checkpoint(project_id: str, task_id: str,
checkpoint_id: str, req: ResolveCheckpointRequest):
"""用户通过 checkpoint → 自动推进 task 状态"""
bb = _bb(project_id)
result = bb.resolve_checkpoint(checkpoint_id, "approve", req.resolved_by, req.note)
result = bb.resolve_checkpoint(
checkpoint_id,
"approve",
req.resolved_by,
req.note)
if result is None:
raise HTTPException(status_code=404, detail="Checkpoint not found")
if "error" in result:
@@ -97,10 +106,15 @@ def approve_checkpoint(project_id: str, task_id: str, checkpoint_id: str, req: R
@router.post("/{checkpoint_id}/reject")
def reject_checkpoint(project_id: str, task_id: str, checkpoint_id: str, req: ResolveCheckpointRequest):
def reject_checkpoint(project_id: str, task_id: str,
checkpoint_id: str, req: ResolveCheckpointRequest):
"""用户驳回 checkpoint → task 回到 working"""
bb = _bb(project_id)
result = bb.resolve_checkpoint(checkpoint_id, "reject", req.resolved_by, req.note)
result = bb.resolve_checkpoint(
checkpoint_id,
"reject",
req.resolved_by,
req.note)
if result is None:
raise HTTPException(status_code=404, detail="Checkpoint not found")
if "error" in result:
+16 -6
View File
@@ -34,7 +34,8 @@ def _get_valid_agents() -> set:
except Exception:
pass
# fallback:硬编码
return {"zhangfei-dev", "guanyu-dev", "zhaoyun-data", "jiangwei-infra", "pangtong-fujunshi", "simayi-challenger"}
return {"zhangfei-dev", "guanyu-dev", "zhaoyun-data",
"jiangwei-infra", "pangtong-fujunshi", "simayi-challenger"}
router = APIRouter(prefix="/api/mail", tags=["mail"])
@@ -98,7 +99,10 @@ async def list_mail(
):
"""Mail 列表(按时间倒序)"""
bb = _bb()
tasks = bb.list_tasks(status=status, assignee=to_agent, assigned_by=from_agent)
tasks = bb.list_tasks(
status=status,
assignee=to_agent,
assigned_by=from_agent)
mails = []
for t in tasks:
@@ -229,7 +233,10 @@ async def send_mail(body: Dict[str, Any]):
to_agent = body.get("to", "").strip()
corrected_to = orig_from # 回复方向固定: reply → original sender
if to_agent and to_agent != corrected_to:
auto_corrected = {"field": "to", "original": to_agent, "corrected": corrected_to}
auto_corrected = {
"field": "to",
"original": to_agent,
"corrected": corrected_to}
to_agent = corrected_to
else:
# --- A2: to 必填(非回复场景) ---
@@ -256,7 +263,8 @@ async def send_mail(body: Dict[str, Any]):
conversation_id = body.get("conversation_id")
if not conversation_id and original:
try:
orig_meta = json.loads(original.must_haves) if original.must_haves else {}
orig_meta = json.loads(
original.must_haves) if original.must_haves else {}
conversation_id = orig_meta.get("conversation_id")
except Exception:
pass
@@ -311,10 +319,12 @@ async def delete_mail(prefix: Optional[str] = Query(None)):
for t in tasks:
if t.title and t.title.startswith(prefix):
if t.status not in ("cancelled",):
bb.update_task_status(t.id, "cancelled", agent="mail-cleanup-api")
bb.update_task_status(
t.id, "cancelled", agent="mail-cleanup-api")
deleted_ids.append(t.id)
return {"ok": True, "deleted_count": len(deleted_ids), "deleted_ids": deleted_ids}
return {"ok": True, "deleted_count": len(
deleted_ids), "deleted_ids": deleted_ids}
@router.patch("/{mail_id}")
+20 -8
View File
@@ -31,8 +31,10 @@ async def list_projects():
if db_path.exists():
try:
conn = sqlite3.connect(str(db_path), timeout=5)
total = conn.execute("SELECT COUNT(*) FROM tasks WHERE status != 'cancelled'").fetchone()[0]
active = conn.execute("SELECT COUNT(*) FROM tasks WHERE COALESCE(archived,0)=0").fetchone()[0]
total = conn.execute(
"SELECT COUNT(*) FROM tasks WHERE status != 'cancelled'").fetchone()[0]
active = conn.execute(
"SELECT COUNT(*) FROM tasks WHERE COALESCE(archived,0)=0").fetchone()[0]
archived = total - active
conn.close()
info['task_count'] = active
@@ -45,8 +47,10 @@ async def list_projects():
if general_db.exists() and "_general" not in projects:
try:
conn = sqlite3.connect(str(general_db), timeout=5)
total = conn.execute("SELECT COUNT(*) FROM tasks WHERE status != 'cancelled'").fetchone()[0]
active = conn.execute("SELECT COUNT(*) FROM tasks WHERE COALESCE(archived,0)=0").fetchone()[0]
total = conn.execute(
"SELECT COUNT(*) FROM tasks WHERE status != 'cancelled'").fetchone()[0]
active = conn.execute(
"SELECT COUNT(*) FROM tasks WHERE COALESCE(archived,0)=0").fetchone()[0]
conn.close()
projects["_general"] = {
"id": "_general", "name": "一般任务", "description": "无项目归属的通用任务",
@@ -60,8 +64,10 @@ async def list_projects():
if general_db_check.exists():
try:
conn = sqlite3.connect(str(general_db_check), timeout=5)
total = conn.execute("SELECT COUNT(*) FROM tasks WHERE status != 'cancelled'").fetchone()[0]
active = conn.execute("SELECT COUNT(*) FROM tasks WHERE COALESCE(archived,0)=0").fetchone()[0]
total = conn.execute(
"SELECT COUNT(*) FROM tasks WHERE status != 'cancelled'").fetchone()[0]
active = conn.execute(
"SELECT COUNT(*) FROM tasks WHERE COALESCE(archived,0)=0").fetchone()[0]
conn.close()
projects["_general"]["task_count"] = active
projects["_general"]["task_count_total"] = total
@@ -173,7 +179,10 @@ async def move_task(project_id: str, task_id: str, body: Dict[str, Any]):
depends_on=child.depends_on, must_haves=child.must_haves,
)
tgt_bb.create_task(moved_child)
src_bb.update_task_status(child.id, "cancelled", detail=f"Moved to {target_project}")
src_bb.update_task_status(
child.id,
"cancelled",
detail=f"Moved to {target_project}")
moved_ids.append(child.id)
# 移动主任务
@@ -186,7 +195,10 @@ async def move_task(project_id: str, task_id: str, body: Dict[str, Any]):
depends_on=task.depends_on, must_haves=task.must_haves,
)
tgt_bb.create_task(moved_task)
src_bb.update_task_status(task_id, "cancelled", detail=f"Moved to {target_project}")
src_bb.update_task_status(
task_id,
"cancelled",
detail=f"Moved to {target_project}")
moved_ids.insert(0, task_id)
return {"ok": True, "moved_to": target_project, "moved_ids": moved_ids}
+143 -23
View File
@@ -46,7 +46,8 @@ _TTL_SECONDS = 7 * 24 * 3600
_idempotency_lock = asyncio.Lock()
def _is_duplicate(event: str, delivery: str, payload: Optional[Dict[str, Any]] = None) -> bool:
def _is_duplicate(event: str, delivery: str,
payload: Optional[Dict[str, Any]] = None) -> bool:
"""检查 Webhook 是否重复投递,自动清理过期条目。
双重去重策略
@@ -56,7 +57,8 @@ def _is_duplicate(event: str, delivery: str, payload: Optional[Dict[str, Any]] =
"""
now = time.time()
# 清理过期条目
while _delivery_timestamps and (now - _delivery_timestamps[0][0]) > _TTL_SECONDS:
while _delivery_timestamps and (
now - _delivery_timestamps[0][0]) > _TTL_SECONDS:
_, key = _delivery_timestamps.pop(0)
_delivery_cache.discard(key)
@@ -75,9 +77,14 @@ def _is_duplicate(event: str, delivery: str, payload: Optional[Dict[str, Any]] =
# 取 body 或 content,优先 bodywebhookNotifier 格式)
content = review.get("body", "") or review.get("content", "")
content_hash = hashlib.sha256(content.encode()).hexdigest()[:16]
content_key = f"content:{event}:{pr_num}:{sender}:{content_hash}"
review_id = review.get("id", "")
content_key = f"content:{event}:{pr_num}:{sender}:{review_id}:{content_hash}"
if content_key in _delivery_cache:
logger.info("Content-based duplicate detected: %s PR#%s by %s", event, pr_num, sender)
logger.info(
"Content-based duplicate detected: %s PR#%s by %s",
event,
pr_num,
sender)
return True
_delivery_cache.add(content_key)
_delivery_timestamps.append((now, content_key))
@@ -137,8 +144,16 @@ async def _fetch_pr_files(repo: str, pr_number: int) -> Tuple[List[str], str]:
last_error = str(e)
if attempt < 2:
await asyncio.sleep(0.5 * (attempt + 1))
logger.warning("Retry %d/3 fetching PR files: %s/pulls/%d", attempt + 1, repo, pr_number)
logger.warning("Failed to fetch PR files after 3 retries: %s/pulls/%d - %s", repo, pr_number, last_error)
logger.warning(
"Retry %d/3 fetching PR files: %s/pulls/%d",
attempt + 1,
repo,
pr_number)
logger.warning(
"Failed to fetch PR files after 3 retries: %s/pulls/%d - %s",
repo,
pr_number,
last_error)
return [], f"获取文件列表失败(重试3次): {last_error}"
@@ -244,14 +259,21 @@ def _repo_fullname(payload: Dict[str, Any]) -> str:
async def _handle_pull_request(payload: Dict[str, Any]) -> None:
"""处理 pull_request 事件:opened → 通知 simayi-challenger"""
"""处理 pull_request 事件:opened → 通知 reviewersynchronize → 通知 reviewer 重新 review"""
action = payload.get("action", "")
if action != "opened":
return
if action == "opened":
await _handle_pr_opened(payload)
elif action == "synchronize":
await _handle_pr_synchronize(payload)
# 其他 action 忽略
async def _handle_pr_opened(payload: Dict[str, Any]) -> None:
"""PR opened → 通知 simayi-challenger。"""
pr = payload.get("pull_request")
if not pr or not isinstance(pr, dict):
logger.warning("pull_request event missing pull_request field, skipping")
logger.warning(
"pull_request event missing pull_request field, skipping")
return
repo = _repo_fullname(payload)
pr_number = pr.get("number", 0)
@@ -265,7 +287,8 @@ async def _handle_pull_request(payload: Dict[str, Any]) -> None:
if fetch_error:
file_list = f"⚠️ {fetch_error}"
else:
file_list = "\n".join(f"- {f}" for f in changed_files) if changed_files else "(无文件变更)"
file_list = "\n".join(
f"- {f}" for f in changed_files) if changed_files else "(无文件变更)"
text = render_template("review_request", {
"repo": repo,
@@ -290,11 +313,13 @@ async def _handle_pull_request_review(payload: Dict[str, Any]) -> None:
"""
review = payload.get("review")
if not review or not isinstance(review, dict):
logger.warning("pull_request_review event missing review field, skipping")
logger.warning(
"pull_request_review event missing review field, skipping")
return
pr = payload.get("pull_request")
if not pr or not isinstance(pr, dict):
logger.warning("pull_request_review event missing pull_request field, skipping")
logger.warning(
"pull_request_review event missing pull_request field, skipping")
return
# 兼容两种 payload 格式提取 state
@@ -309,18 +334,39 @@ async def _handle_pull_request_review(payload: Dict[str, Any]) -> None:
}
state = type_map.get(review_type, "")
# 只通知 APPROVED 和 REQUEST_CHANGES,跳过 COMMENTED 和其他状态
if state == "COMMENTED":
return
repo = _repo_fullname(payload)
pr_number = pr.get("number", 0)
pr_title = pr.get("title", "")
pr_author = pr.get("user", {}).get("login", "unknown")
# 兼容:org webhook 的 review 没有 user,从 sender 取
reviewer = review.get("user", {}).get("login", "") or payload.get("sender", {}).get("login", "unknown")
reviewer = review.get(
"user",
{}).get(
"login",
"") or payload.get(
"sender",
{}).get(
"login",
"unknown")
review_body = review.get("body", "") or review.get("content", "(无评论)")
if state == "COMMENTED":
# Review 评论 → 通知 PR 作者
review_body = review.get("body", "") or review.get("content", "(无评论)")
reviewer = review.get("user", {}).get("login", "") or payload.get("sender", {}).get("login", "unknown")
text = render_template("review_comment", {
"repo": repo,
"pr_number": str(pr_number),
"pr_title": pr_title,
"reviewer": reviewer,
"comment_body": review_body,
})
title = f"Review 评论: {pr_title} ({repo}#{pr_number})"
_send_mail(pr_author, title, text)
return
result_map = {"APPROVED": "通过 ✓", "REQUEST_CHANGES": "驳回 ✗"}
if state not in result_map:
return
@@ -339,6 +385,72 @@ async def _handle_pull_request_review(payload: Dict[str, Any]) -> None:
_send_mail(pr_author, title, text)
async def _fetch_latest_reviewer(repo: str, pr_number: int) -> str:
"""查询 PR 最近一次非 PENDING review 的提交者。
Returns:
reviewer login 或空字符串
"""
if not _GITEA_TOKEN:
return ""
url = f"{_GITEA_BASE}/repos/{repo}/pulls/{pr_number}/reviews"
headers = {"Authorization": f"token {_GITEA_TOKEN}"}
try:
async with httpx.AsyncClient(timeout=5.0) as client:
resp = await client.get(url, headers=headers)
resp.raise_for_status()
reviews = resp.json()
# 取最后一个非 PENDING 的 review 的 user
for review in reversed(reviews):
state = review.get("state", "")
if state in ("APPROVED", "REQUEST_CHANGES", "COMMENTED"):
user = review.get("user", {})
return user.get("login", "")
except Exception as e:
logger.warning("Failed to fetch reviews for %s#%d: %s", repo, pr_number, e)
return ""
async def _handle_pr_synchronize(payload: Dict[str, Any]) -> None:
"""PR 更新(新 push)→ 通知 reviewer 重新 review。
查询最近一次 review 的提交者作为通知目标
只在有 review 历史时才通知避免和 opened 重复
"""
pr = payload.get("pull_request")
if not pr or not isinstance(pr, dict):
return
repo = _repo_fullname(payload)
pr_number = pr.get("number", 0)
pr_title = pr.get("title", "")
pr_author = pr.get("user", {}).get("login", "unknown")
new_sha = pr.get("head", {}).get("sha", "unknown")[:12]
# 查询最近 review 的提交者
reviewer = await _fetch_latest_reviewer(repo, pr_number)
if not reviewer:
# 没有 review 历史,跳过(opened 事件已经通知过)
logger.debug("No review history for PR #%s, skipping synchronize notification", pr_number)
return
text = render_template("review_updated", {
"repo": repo,
"pr_number": str(pr_number),
"pr_title": pr_title,
"pr_author": pr_author,
"new_sha": new_sha,
"reviewer": reviewer,
})
title = f"PR 更新: {pr_title} ({repo}#{pr_number})"
_send_mail(reviewer, title, text)
async def _handle_issues(payload: Dict[str, Any]) -> None:
"""处理 issues 事件:assigned → 通知被指派人;opened+部署失败 → 通知运维。"""
action = payload.get("action", "")
@@ -365,7 +477,8 @@ async def _handle_issues(payload: Dict[str, Any]) -> None:
logger.debug("Issue assigned but no assignee found, skipping")
return
labels_list = [lbl.get("name", "") for lbl in (issue.get("labels") or [])]
labels_list = [lbl.get("name", "")
for lbl in (issue.get("labels") or [])]
labels = ", ".join(labels_list) if labels_list else "(无标签)"
issue_body = issue.get("body", "(无描述)")
brief = issue_title[:20].replace(" ", "-").lower()
@@ -416,7 +529,9 @@ async def _handle_issue_comment(payload: Dict[str, Any]) -> None:
# 已关闭的 Issue/PR 不再发送 CI 失败通知
if issue.get("state") == "closed":
logger.debug("Skipping CI failure notification for closed issue #%s", issue.get("number"))
logger.debug(
"Skipping CI failure notification for closed issue #%s",
issue.get("number"))
return
repo = _repo_fullname(payload)
@@ -484,7 +599,8 @@ async def gitea_webhook(
# 1. 签名验证
if not _verify_signature(body, x_gitea_signature):
logger.warning("Webhook signature verification failed")
return Response(status_code=403, content="signature verification failed")
return Response(status_code=403,
content="signature verification failed")
# 3. 解析 payload(提前解析,用于幂等检查)
try:
@@ -497,14 +613,18 @@ async def gitea_webhook(
if x_gitea_event and x_gitea_delivery:
async with _idempotency_lock:
if _is_duplicate(x_gitea_event, x_gitea_delivery, payload):
logger.debug("Duplicate webhook: %s/%s", x_gitea_event, x_gitea_delivery)
logger.debug(
"Duplicate webhook: %s/%s",
x_gitea_event,
x_gitea_delivery)
return Response(status_code=200, content="duplicate")
# 4. 查找 handler
handler = _EVENT_HANDLERS.get(x_gitea_event or "")
if not handler:
logger.debug("Unhandled event type: %s", x_gitea_event)
return Response(status_code=200, content=f"unhandled event: {x_gitea_event}")
return Response(status_code=200,
content=f"unhandled event: {x_gitea_event}")
# 5. 执行 handler
try:
+19 -14
View File
@@ -132,8 +132,10 @@ def _migrate_v28(conn: sqlite3.Connection) -> None:
resolved_by TEXT,
resolve_note TEXT
)""")
conn.execute("CREATE INDEX IF NOT EXISTS idx_checkpoints_task ON checkpoints(task_id)")
conn.execute("CREATE INDEX IF NOT EXISTS idx_checkpoints_status ON checkpoints(status)")
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_checkpoints_task ON checkpoints(task_id)")
conn.execute(
"CREATE INDEX IF NOT EXISTS idx_checkpoints_status ON checkpoints(status)")
# 4. outputs 扩展字段(M3 成果物)
_safe_add_column(conn, "outputs", "file_name", "TEXT")
@@ -188,18 +190,20 @@ TERMINAL_STATUSES = frozenset() # v3.1: 无终态,全靠 VALID_TRANSITIONS
MANUAL_STATUSES = frozenset({"cancelled", "paused", "reviewing"})
VALID_TRANSITIONS = {
"pending": {"claimed", "paused", "blocked", "cancelled"},
"claimed": {"working", "paused", "pending", "cancelled"},
"working": {"review", "done", "blocked", "failed", "paused", "escalated", "waiting_human", "cancelled", "pending"}, # pending: Mail spawn 失败回退
"paused": {"working", "claimed", "review", "escalated", "waiting_human", "cancelled"}, # 恢复到 resumed_from 记录的状态
"review": {"done", "pending", "failed", "paused", "escalated", "waiting_human", "cancelled"},
"blocked": {"pending", "escalated", "cancelled"},
"failed": {"pending", "escalated", "cancelled"},
"escalated": {"working", "pending", "paused", "cancelled"},
"pending": {"claimed", "paused", "blocked", "cancelled"},
"claimed": {"working", "paused", "pending", "cancelled"},
# pending: Mail spawn 失败回退
"working": {"review", "done", "blocked", "failed", "paused", "escalated", "waiting_human", "cancelled", "pending"},
# 恢复到 resumed_from 记录的状态
"paused": {"working", "claimed", "review", "escalated", "waiting_human", "cancelled"},
"review": {"done", "pending", "failed", "paused", "escalated", "waiting_human", "cancelled"},
"blocked": {"pending", "escalated", "cancelled"},
"failed": {"pending", "escalated", "cancelled"},
"escalated": {"working", "pending", "paused", "cancelled"},
"waiting_human": {"working", "done", "paused", "cancelled"},
"done": {"cancelled", "reviewing"},
"reviewing": {"done", "working", "cancelled"},
"cancelled": {"pending"},
"done": {"cancelled", "reviewing"},
"reviewing": {"done", "working", "cancelled"},
"cancelled": {"pending"},
}
COMMENT_TYPES = frozenset({
@@ -223,7 +227,8 @@ EVENT_TYPES = frozenset({
OUTPUT_TYPES = frozenset({"code", "document", "data", "config", "other"})
REVIEW_TYPES = frozenset({"plan_review", "output_review", "guardrail", "final_review"})
REVIEW_TYPES = frozenset(
{"plan_review", "output_review", "guardrail", "final_review"})
VERDICT_TYPES = frozenset({"approved", "rejected", "needs_revision"})
EXPERIENCE_SOURCES = frozenset({
+12 -6
View File
@@ -83,7 +83,8 @@ class Blackboard:
"""获取单个任务"""
conn = self._conn()
try:
row = conn.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
row = conn.execute(
"SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
return Task.from_row(row) if row else None
finally:
conn.close()
@@ -128,7 +129,8 @@ class Blackboard:
updates["completed_at"] = now # paused 也记录时间用于恢复
updates["resumed_from"] = old_status # 记录暂停前状态
elif new_status == "pending":
# 所有 →pending 转换都清空 assignee(与 ticker._transition_status L414 对齐)
# 所有 →pending 转换都清空 assignee(与 ticker._transition_status L414
# 对齐)
updates["assignee"] = None
updates["claimed_at"] = None
updates["current_agent"] = None
@@ -707,7 +709,8 @@ class Blackboard:
import uuid
# BUG-33: 校验 payload 结构必须含 version 字段
if not isinstance(payload, dict) or "version" not in payload:
raise ValueError("payload must be a dict containing 'version' field")
raise ValueError(
"payload must be a dict containing 'version' field")
cp_id = checkpoint_id or f"cp-{uuid.uuid4().hex[:8]}"
conn = self._conn()
try:
@@ -964,7 +967,8 @@ class Blackboard:
finally:
conn.close()
def get_pending_mentions(self, max_retries: int = 5) -> List[Dict[str, Any]]:
def get_pending_mentions(
self, max_retries: int = 5) -> List[Dict[str, Any]]:
"""获取所有 pending 且未超过重试上限的 mentions"""
conn = self._conn()
try:
@@ -999,7 +1003,8 @@ class Blackboard:
conn = self._conn()
try:
conn.execute("BEGIN IMMEDIATE")
conn.execute("UPDATE mention_queue SET retry_count=retry_count+1 WHERE id=?", (mention_id,))
conn.execute(
"UPDATE mention_queue SET retry_count=retry_count+1 WHERE id=?", (mention_id,))
conn.commit()
return True
finally:
@@ -1010,7 +1015,8 @@ class Blackboard:
conn = self._conn()
try:
conn.execute("BEGIN IMMEDIATE")
conn.execute("UPDATE mention_queue SET status='failed' WHERE id=?", (mention_id,))
conn.execute(
"UPDATE mention_queue SET status='failed' WHERE id=?", (mention_id,))
conn.commit()
return True
finally:
+8 -4
View File
@@ -132,7 +132,8 @@ class Queries:
"""任务详情聚合(含关联数据)"""
conn = self._conn()
try:
row = conn.execute("SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
row = conn.execute(
"SELECT * FROM tasks WHERE id=?", (task_id,)).fetchone()
if not row:
return None
task = dict(row)
@@ -159,7 +160,8 @@ class Queries:
finally:
conn.close()
def task_events(self, task_id: str, limit: int = 50) -> List[Dict[str, Any]]:
def task_events(self, task_id: str,
limit: int = 50) -> List[Dict[str, Any]]:
"""任务事件列表"""
conn = self._conn()
try:
@@ -265,7 +267,8 @@ class Queries:
return "review"
# 有 working/claimed → working
if status_counts.get("working", 0) > 0 or status_counts.get("claimed", 0) > 0:
if status_counts.get("working", 0) > 0 or status_counts.get(
"claimed", 0) > 0:
return "working"
# 有 pending → pending
@@ -337,7 +340,8 @@ class Queries:
# 当前活跃 stage
active_stage = None
for sp in stage_progress:
if sp["active"] > 0 or (sp["total"] > 0 and sp["done"] < sp["total"]):
if sp["active"] > 0 or (
sp["total"] > 0 and sp["done"] < sp["total"]):
if not active_stage and sp["done"] < sp["total"]:
active_stage = sp["label"]
+6 -4
View File
@@ -119,7 +119,8 @@ class ProjectRegistry:
finally:
conn.close()
def list_projects(self, status: Optional[str] = None) -> Dict[str, Dict[str, Any]]:
def list_projects(
self, status: Optional[str] = None) -> Dict[str, Dict[str, Any]]:
"""列出项目"""
conn = self._connect()
try:
@@ -178,7 +179,8 @@ class ProjectRegistry:
status="deleted",
)
def physical_delete_project(self, project_id: str) -> Optional[Dict[str, Any]]:
def physical_delete_project(
self, project_id: str) -> Optional[Dict[str, Any]]:
"""物理删除项目(删目录 + 删 registry 条目)"""
import shutil
@@ -260,7 +262,8 @@ class ProjectRegistry:
# 迁移(从 _registry.yaml
# ===================================================================
def discover_sanguo_projects(self, scan_dir: Optional[Path] = None) -> List[str]:
def discover_sanguo_projects(
self, scan_dir: Optional[Path] = None) -> List[str]:
"""扫描 sanguo_projects 开发目录,自动注册正式项目"""
scan_dir = scan_dir or Path(os.environ.get(
"SANGUO_PROJECTS_DIR",
@@ -355,4 +358,3 @@ class ProjectRegistry:
def reload(self) -> None:
"""兼容旧接口(SQLite 不需要 reload cache"""
pass
+13 -5
View File
@@ -35,7 +35,9 @@ def _get_queries(project_id: str) -> Queries:
def build_blackboard_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="blackboard", description="Agent blackboard operations")
parser = argparse.ArgumentParser(
prog="blackboard",
description="Agent blackboard operations")
sub = parser.add_subparsers(dest="command")
# read
@@ -206,7 +208,11 @@ def _cmd_comment(opts) -> int:
def _cmd_decide(opts) -> int:
bb = _get_bb(opts.project)
did = bb.add_decision(opts.task_id, opts.decider, opts.decision, opts.rationale)
did = bb.add_decision(
opts.task_id,
opts.decider,
opts.decision,
opts.rationale)
print(f"Decision recorded: {did}")
return 0
@@ -251,7 +257,8 @@ def _print_tasks(tasks, as_json: bool):
def build_admin_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(prog="admin", description="Admin operations")
parser = argparse.ArgumentParser(
prog="admin", description="Admin operations")
sub = parser.add_subparsers(dest="command")
# project create
@@ -262,7 +269,7 @@ def build_admin_parser() -> argparse.ArgumentParser:
p_pc.add_argument("--description", default="")
# project list
_ = sub.add_parser("project-list", help="List projects")
sub.add_parser("project-list", help="List projects")
# project archive
p_pa = sub.add_parser("project-archive", help="Archive project")
@@ -300,7 +307,8 @@ def run_admin_cli(args: Optional[List[str]] = None) -> int:
for pid, info in projects.items():
status = info.get("status", "?")
agents = ",".join(info.get("agents", []))
print(f" {pid} [{status}] {info.get('name', '')} agents: {agents}")
print(
f" {pid} [{status}] {info.get('name', '')} agents: {agents}")
return 0
elif opts.command == "project-archive":
+183
View File
@@ -0,0 +1,183 @@
"""base_task_handler.py — Task type handler 基类。
收敛合理的共性能力crash rollback + verify + mark + notify
子类只实现差异点
"""
from __future__ import annotations
import logging
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
from src.daemon.prompt_composer import PromptContext, PromptSection
from src.blackboard.db import get_connection
logger = logging.getLogger("moziplus-v2.handler")
@dataclass
class VerifyResult:
"""验证结果"""
passed: bool
reason: str # "has_output" / "no_reply" / "no_signal" / ...
evidence: str # "output_count=1, comment_count=0"
can_retry: bool = True
retry_count: int = 0
class BaseTaskHandler:
"""所有 task type handler 的基类。
职责L2 引擎注入层的业务逻辑prompt 构建完成验证状态标记
不管进程生命周期exit 分类重试决策这些归 spawner
"""
# crash 类 outcome(进程级异常,需要 rollback)
CRASH_OUTCOMES = frozenset({
"crashed", "compact_failed", "process_crash",
"session_stuck", "compact_hanging",
})
task_type: str = ""
virtual_project: Optional[str] = None
display_name: str = "" # 中文展示名(ticker 扫描日志用)
# === 子类必须实现 ===
def build_prompt(self, context: PromptContext) -> str:
"""构建 L2 prompt(通过 PromptComposer 拼 section)。子类实现。"""
raise NotImplementedError
def verify_completion(self, task_id: str, db_path: Path) -> VerifyResult:
"""验证任务完成质量。每个 handler 自己的验证逻辑。子类实现。"""
raise NotImplementedError
def target_success_status(self) -> str:
"""验证通过后的目标状态。task='review', mail/toolchain='done'"""
return "review"
def get_sections(self) -> list[PromptSection]:
"""返回此 handler 的 prompt section 列表。子类实现。"""
return []
# === 基类提供统一流程 ===
def pre_spawn(self, task_id: str, db_path: Path) -> bool:
"""spawn 前业务准备。默认 True。
mail/toolchain override auto_working"""
return True
def post_complete(self, task_id: str, agent_id: str,
outcome: str, db_path: Path) -> None:
"""spawn 完成后的业务处理。统一 4 步流程:
1. crash 处理 rollback current_agent
2. verify 验证产出
3. mark 标目标状态
4. notify 失败时 on_failure
"""
# 1. crash 处理(基类提供,所有 handler 继承)
if outcome in self.CRASH_OUTCOMES:
self._rollback_current_agent(db_path, task_id, agent_id)
return
# 2. verify
result = self.verify_completion(task_id, db_path)
# 3. mark
if result.passed:
self._mark_task_status(db_path, task_id, self.target_success_status())
logger.info("Task %s: verify passed (%s), marked %s",
task_id, result.reason, self.target_success_status())
else:
# 4. notify
self.on_failure(task_id, agent_id, db_path, result)
def on_failure(self, task_id: str, agent_id: str,
db_path: Path, verify: VerifyResult) -> None:
"""验证失败处理。默认:标 failed。子类可 override。"""
self._mark_task_status(db_path, task_id, "failed")
logger.info("Task %s: verify failed (%s), marked failed",
task_id, verify.reason)
def check_completion(self, task_id: str, db_path: Path) -> bool:
"""ticker 级别的完成检查。默认:False。"""
return False
# === 内部工具方法 ===
def _rollback_current_agent(self, db_path: Path, task_id: str, agent_id: str) -> None:
"""crash 后回退 current_agent → assignee,避免 exclude_current 卡死。
dispatcher._rollback_current_agent 迁移"""
try:
conn = get_connection(db_path)
try:
conn.execute(
"UPDATE tasks SET current_agent = "
"(SELECT assignee FROM tasks WHERE id=?) "
"WHERE id=? AND current_agent=?",
(task_id, task_id, agent_id)
)
conn.commit()
finally:
conn.close()
logger.info("Task %s: rolled back current_agent from %s to assignee",
task_id, agent_id)
except Exception as e:
logger.warning("Task %s: failed to rollback current_agent: %s",
task_id, e)
def _mark_task_status(self, db_path: Path, task_id: str, status: str) -> None:
"""更新任务状态 + 写审计事件(带 3 次重试,防 SQLite DB 锁)。"""
for attempt in range(3):
try:
conn = get_connection(db_path)
try:
conn.execute("BEGIN IMMEDIATE")
old_row = conn.execute(
"SELECT status FROM tasks WHERE id=?", (task_id,)
).fetchone()
old_status = old_row["status"] if old_row else "unknown"
conn.execute(
"UPDATE tasks SET status=?, updated_at=datetime('now') WHERE id=?",
(status, task_id),
)
conn.execute(
"INSERT INTO events (task_id, agent, event_type, payload) "
"VALUES (?, 'handler', 'status_change', ?)",
(task_id,
f'{{"from": "{old_status}", "to": "{status}", '
f'"source": "{self.task_type}_handler"}}'),
)
conn.commit()
return
finally:
conn.close()
except Exception as e:
logger.warning("Handler: mark %s%s attempt %d failed: %s",
task_id, status, attempt + 1, e)
logger.error("Handler: mark %s%s all 3 attempts failed", task_id, status)
def _auto_mark_working(self, task_id: str, db_path: Path) -> bool:
"""pending → workingmail/toolchain 通用)。"""
try:
conn = get_connection(db_path)
try:
conn.execute("BEGIN IMMEDIATE")
row = conn.execute(
"SELECT status FROM tasks WHERE id=?", (task_id,)).fetchone()
if not row or row["status"] not in ("pending", "claimed"):
logger.warning("Task %s: cannot mark working (status=%s)",
task_id, row["status"] if row else "not found")
return False
conn.execute(
"UPDATE tasks SET status='working', updated_at=datetime('now') "
"WHERE id=?", (task_id,))
conn.commit()
logger.info("Task %s: auto-marked working", task_id)
return True
finally:
conn.close()
except Exception as e:
logger.error("Task %s: failed to mark working: %s", task_id, e)
return False
+10 -7
View File
@@ -27,12 +27,12 @@ class BootstrapBuilder:
"""L2 引擎注入层构建器(v2.1 四段式)"""
ROLE_SKILL_MAP = {
"executor": "blackboard-executor",
"reviewer": "blackboard-reviewer",
"reviewer-simayi": "blackboard-reviewer-simayi",
"executor": "blackboard-executor",
"reviewer": "blackboard-reviewer",
"reviewer-simayi": "blackboard-reviewer-simayi",
"reviewer-pangtong": "blackboard-reviewer-pangtong",
"planner": "blackboard-planner",
"claim": "blackboard-claim",
"planner": "blackboard-planner",
"claim": "blackboard-claim",
}
# 默认从环境变量或配置读取,fallback 到默认路径
@@ -61,7 +61,9 @@ class BootstrapBuilder:
# 段 2: 前序产出(有依赖时注入)
if task.get("depends_on_outputs"):
sections.append(self._format_prior_outputs(task["depends_on_outputs"]))
sections.append(
self._format_prior_outputs(
task["depends_on_outputs"]))
# 段 3: 角色操作规范全文(通过 ROLE_SKILL_MAP 从 Skill 文件读取)
skill_name = self.ROLE_SKILL_MAP.get(role)
@@ -133,7 +135,8 @@ class BootstrapBuilder:
"""格式化前序产出摘要(段 2"""
parts = ["## 前序产出"]
for out in outputs:
parts.append(f"- [{out.get('task_id', '?')}] {out.get('summary', '无摘要')}")
parts.append(
f"- [{out.get('task_id', '?')}] {out.get('summary', '无摘要')}")
return "\n".join(parts)
def _format_constraints(self, role: str) -> str:
+8 -4
View File
@@ -68,20 +68,23 @@ class ActiveAgentCounter:
self._cooldown_until.pop(agent_id, None)
return False
def set_cooldown(self, agent_id: str, seconds: Optional[float] = None) -> None:
def set_cooldown(self, agent_id: str,
seconds: Optional[float] = None) -> None:
"""设置冷却期(默认 120 秒)"""
cd = seconds if seconds is not None else self._default_cooldown_seconds
self._cooldown_until[agent_id] = time.time() + cd
logger.info("Cooldown set for %s: %.0fs (until %.0f)",
agent_id, cd, self._cooldown_until[agent_id])
async def can_acquire(self, agent_id: str, session_id: str = "main") -> bool:
async def can_acquire(self, agent_id: str,
session_id: str = "main") -> bool:
"""三层检查:cooldown → global → per agent → per session key"""
if self.is_cooling_down(agent_id):
return False
if self._global_active >= self._max_global:
return False
if self._agent_active.get(agent_id, 0) >= self._max_concurrent_sessions:
if self._agent_active.get(
agent_id, 0) >= self._max_concurrent_sessions:
return False
key = self._make_key(agent_id, session_id)
if self._active_keys.get(key, 0) >= self._max_per_session:
@@ -122,7 +125,8 @@ class ActiveAgentCounter:
del self._active_keys[key]
if agent_id in self._agent_active:
self._agent_active[agent_id] = max(0, self._agent_active[agent_id] - 1)
self._agent_active[agent_id] = max(
0, self._agent_active[agent_id] - 1)
if self._agent_active[agent_id] == 0:
del self._agent_active[agent_id]
+230 -128
View File
@@ -12,7 +12,6 @@ Dispatcher 负责:
from __future__ import annotations
import json
import pathlib
import logging
import sqlite3
from enum import Enum
@@ -23,6 +22,7 @@ from src.blackboard.models import Task
from src.blackboard.db import get_connection
from src.daemon.spawner import AgentBusyError
from src.daemon.router import AgentRouter
from src.daemon.task_type_registry import TaskTypeRegistry
logger = logging.getLogger("moziplus-v2.dispatcher")
@@ -64,7 +64,8 @@ class Dispatcher:
if self._legacy_mode:
self.registered_agents = set(registered_agents or [])
self.capability_map = capability_map or {}
logger.warning("Dispatcher running in legacy mode (no AgentRouter)")
logger.warning(
"Dispatcher running in legacy mode (no AgentRouter)")
def decide(self, task: Task, action_type: str = "") -> Dict[str, Any]:
"""调度决策(委托给 Router
@@ -123,17 +124,23 @@ class Dispatcher:
"status": "dispatched"|"skipped"|"error"|"blocked", "reason": str}
"""
# 安全红线检查(调度前拦截)
# Mail 是 Agent 间通信,不做 guardrail 检查
is_mail = project_config.get("project_id") == "_mail" if project_config else False
if self.guardrails and not is_mail:
# handler 项目(_mail/_toolchain不做 guardrail 检查
handler = TaskTypeRegistry.get_by_project(
project_config.get("project_id", "") if project_config else "")
is_handler_task = handler is not None
if self.guardrails and not is_handler_task:
violations = self.guardrails.check_task(task)
critical = [v for v in violations if v.action in ("block_and_notify", "terminate_and_escalate")]
critical = [
v for v in violations if v.action in (
"block_and_notify",
"terminate_and_escalate")]
if critical:
v = critical[0]
logger.warning("Task '%s' blocked by guardrail: %s - %s",
task.title, v.rule_id, v.message)
# 写入黑板事件
_routing_db = Path(project_config["db_path"]) if project_config and "db_path" in project_config else self.db_path
_routing_db = Path(
project_config["db_path"]) if project_config and "db_path" in project_config else self.db_path
if _routing_db:
self._record_routing(task, {"level": DispatchLevel.BLOCKED, "agent_id": "none",
"reason": v.message}, "blocked", v.message, _routing_db)
@@ -152,7 +159,8 @@ class Dispatcher:
decision = self.decide(task, action_type)
level = decision["level"]
# 从 project_config 获取项目级 DB 路径(路由审计日志写入项目 DB)
_routing_db = Path(project_config["db_path"]) if project_config and "db_path" in project_config else None
_routing_db = Path(
project_config["db_path"]) if project_config and "db_path" in project_config else None
agent_id = decision["agent_id"]
# v2.7.2: counter 检查移到 spawn_full_agent 内部
@@ -160,7 +168,8 @@ class Dispatcher:
# 本地执行
if level == DispatchLevel.LOCAL:
self._record_routing(task, decision, "dispatched", None, _routing_db)
self._record_routing(
task, decision, "dispatched", None, _routing_db)
return {
"level": level.value,
"agent_id": "daemon",
@@ -172,7 +181,8 @@ class Dispatcher:
# Full Agent / Escalate spawn
if level in (DispatchLevel.FULL_AGENT, DispatchLevel.ESCALATE):
if not self.spawner:
self._record_routing(task, decision, "error", "No spawner", _routing_db)
self._record_routing(
task, decision, "error", "No spawner", _routing_db)
return {
"level": level.value,
"agent_id": agent_id,
@@ -182,107 +192,111 @@ class Dispatcher:
}
try:
# [v2.7.1] Mail: 标 working 移到 spawn_full_agent 内部(check 通过后、subprocess 前)
is_mail = project_config.get("project_id") == "_mail" if project_config else False
if is_mail:
db_path = Path(project_config["db_path"]) if project_config and "db_path" in project_config else None
# [Step 5] Handler: pre_spawn + on_checks_passed 统一
project_id = project_config.get("project_id", "") if project_config else ""
handler = TaskTypeRegistry.get_by_project(project_id)
db_path = Path(
project_config["db_path"]) if project_config and "db_path" in project_config else None
# on_checks_passed: 所有检查通过后才标 working,检查失败不标
# on_checks_passed: handler 项目在 check 通过后调用 handler.pre_spawn
on_checks_passed = None
_mail_marked_working = False
if is_mail and db_path:
handler_marked_working = False
if handler and db_path:
_task_id = task.id
_mail_db = db_path
_disp = self
_handler_db = db_path
_handler = handler
def _mail_on_checks_passed():
nonlocal _mail_marked_working
if not _disp._mail_auto_working(_task_id, _mail_db):
raise RuntimeError("mail_auto_working_failed")
_mail_marked_working = True
on_checks_passed = _mail_on_checks_passed
def _handler_on_checks_passed():
nonlocal handler_marked_working
if not _handler.pre_spawn(_task_id, _handler_db):
raise RuntimeError("handler_pre_spawn_failed")
handler_marked_working = True
on_checks_passed = _handler_on_checks_passed
# 构建 spawn message
message = self._build_spawn_message(task, agent_id, project_config,
mode=decision.get("mode", ""),
mode=decision.get(
"mode", ""),
spawn_type=action_type or "executor")
# v2.7.2: on_complete 只含业务逻辑,不含 counter.release
# counter.release 由 spawn_full_agent 内部的 wrapped_on_complete 保证
# [Step 5] Handler: on_complete 统一走 handler.post_complete
# 保留旧路径作为 fallback(无 handler 的项目)
on_complete = None
if is_mail:
if handler:
_task_id = task.id
_mail_db = db_path
_must_haves = task.must_haves or ""
_dispatcher = self
_handler_db = db_path
_handler = handler
def _mail_on_complete(aid, outcome):
# 幻觉门控:检查是否有回复,自动标 done/failed
def _handler_on_complete(aid, outcome):
try:
_dispatcher._mail_auto_complete(_task_id, aid, _mail_db, _must_haves, outcome=outcome)
_handler.post_complete(
_task_id, aid, outcome, _handler_db)
except Exception as e:
logger.error("Mail %s: on_complete error: %s", _task_id, e)
on_complete = _mail_on_complete
logger.error(
"Handler %s: on_complete error: %s", _task_id, e)
on_complete = _handler_on_complete
else:
# #02: Task 路径也加 on_complete(幻觉门控
# 旧路径:无 handler 的项目(_general 等
_task_id = task.id
_task_db = Path(project_config["db_path"]) if project_config and "db_path" in project_config else None
_task_db = db_path
_dispatcher = self
_is_review = action_type == "review"
# #07.2: executor/review 统一 crash 回退
ROLLBACK_CURRENT_AGENT_OUTCOMES = frozenset({
"crashed", "compact_failed", "process_crash",
"session_stuck", "compact_hanging",
})
def _task_on_complete(aid, outcome):
def _legacy_on_complete(aid, outcome):
try:
# #07.2: 统一 crash 回退——executor 和 review 都回退 current_agent
if outcome in ROLLBACK_CURRENT_AGENT_OUTCOMES and _task_db:
_dispatcher._rollback_current_agent(_task_db, _task_id, aid)
_dispatcher._rollback_current_agent(
_task_db, _task_id, aid)
if _is_review:
if _task_db and outcome in ("completed", "session_revived"):
# #09: 读 verdict 决定后续动作
conn = get_connection(_task_db)
from src.blackboard.blackboard import Blackboard
# get_connection 已在文件顶部 L22 import
rconn = get_connection(_task_db)
try:
review = conn.execute(
"SELECT verdict FROM reviews WHERE task_id=? ORDER BY created_at DESC LIMIT 1",
(_task_id,)
).fetchone()
review_row = rconn.execute(
"SELECT verdict, reviewer, comment FROM reviews "
"WHERE task_id=? ORDER BY created_at DESC LIMIT 1",
(_task_id,)).fetchone()
finally:
conn.close()
rconn.close()
if review and review["verdict"] == "approved":
_dispatcher._mark_task_status(_task_db, _task_id, "done")
logger.info("Task %s: review approved, marking done", _task_id)
if review_row and review_row["verdict"] == "approved":
_dispatcher._mark_task_status(
_task_db, _task_id, "done")
logger.info(
"Legacy %s: review approved, marked done", _task_id)
else:
# 非 approved → @mention 被审 agentassignee,非 current_agent
verdict_str = review["verdict"] if review else "未知"
conn2 = get_connection(_task_db)
verdict_str = review_row["verdict"] if review_row else "未知"
tconn = get_connection(_task_db)
try:
task_row = conn2.execute("SELECT assignee FROM tasks WHERE id=?", (_task_id,)).fetchone()
t_row = tconn.execute(
"SELECT assignee FROM tasks WHERE id=?",
(_task_id,)).fetchone()
finally:
conn2.close()
if task_row and task_row["assignee"]:
from src.blackboard.blackboard import Blackboard
bb = Blackboard(_task_db)
bb.add_comment(_task_id, "daemon",
f"@{task_row['assignee']} 审查结论: {verdict_str},请查看详情并决定接受或反驳",
comment_type="review")
logger.info("Task %s: review verdict=%s, notified assignee=%s",
_task_id, verdict_str, task_row["assignee"] if task_row else "?")
# 不标 done,保持 review 状态
else:
logger.warning("Task %s: review agent %s (%s), NOT marking done", _task_id, aid, outcome)
tconn.close()
if t_row and t_row["assignee"]:
bb = Blackboard(str(_task_db))
bb.add_comment(
_task_id, "daemon",
f"@{t_row['assignee']} review 未通过 "
f"(verdict={verdict_str}): "
f"{review_row['comment'] if review_row else ''}",
comment_type="review")
logger.info(
"Legacy %s: review not approved (%s), "
"@mentioned assignee",
_task_id, verdict_str)
else:
# executor: 三信号验证 → 标 review
_dispatcher._task_auto_complete(_task_id, _task_db)
except Exception as e:
logger.error("Task %s: on_complete error: %s", _task_id, e)
on_complete = _task_on_complete
logger.error(
"Legacy %s: on_complete error: %s", _task_id, e)
on_complete = _legacy_on_complete
session_id = await self.spawner.spawn_full_agent(
agent_id=agent_id,
@@ -290,7 +304,8 @@ class Dispatcher:
task_id=task.id,
on_complete=on_complete,
use_main_session=True, # #02: 统一投递到 main session
task_db_path=Path(project_config["db_path"]) if project_config and "db_path" in project_config else None,
task_db_path=Path(
project_config["db_path"]) if project_config and "db_path" in project_config else None,
on_checks_passed=on_checks_passed,
)
@@ -313,9 +328,14 @@ class Dispatcher:
else:
log_level = logger.debug
detail_msg = f"Agent busy: {reason}"
log_level("Dispatch skipped %s for task %s: %s", agent_id, task.id, detail_msg)
log_level(
"Dispatch skipped %s for task %s: %s",
agent_id,
task.id,
detail_msg)
# on_checks_passed 未执行(check 失败在它之前),working 未标,无需回退
self._record_routing(task, decision, "skipped", detail_msg, _routing_db)
self._record_routing(
task, decision, "skipped", detail_msg, _routing_db)
return {
"level": level.value,
"agent_id": agent_id,
@@ -325,9 +345,28 @@ class Dispatcher:
}
except Exception as e:
# on_checks_passed 已执行但 subprocess 失败 → 回退 working → pending
if _mail_marked_working:
self._mail_revert_to_pending(task.id, db_path)
self._record_routing(task, decision, "error", str(e), _routing_db)
if handler_marked_working and handler and db_path:
# handler 项目:回退到 pending
try:
conn = get_connection(db_path)
try:
conn.execute("BEGIN IMMEDIATE")
row = conn.execute(
"SELECT status FROM tasks WHERE id=?", (task.id,)).fetchone()
if row and row["status"] == "working":
conn.execute(
"UPDATE tasks SET status='pending', updated_at=datetime('now') WHERE id=?",
(task.id,))
conn.commit()
logger.info(
"Task %s: reverted working → pending (spawn failed)", task.id)
finally:
conn.close()
except Exception as revert_err:
logger.error(
"Task %s: failed to revert to pending: %s", task.id, revert_err)
self._record_routing(
task, decision, "error", str(e), _routing_db)
return {
"level": level.value,
"agent_id": agent_id,
@@ -386,9 +425,16 @@ class Dispatcher:
def _build_delegate_prompt(self, task: Task,
project_config: Optional[Dict]) -> str:
"""构建 delegate 模式的 prompt(协调员分配任务)"""
api_host = getattr(self.spawner, 'api_host', '127.0.0.1') if self.spawner else '127.0.0.1'
api_port = getattr(self.spawner, 'api_port', 8083) if self.spawner else 8083
project_id = project_config.get("project_id", "") if project_config else ""
api_host = getattr(
self.spawner,
'api_host',
'127.0.0.1') if self.spawner else '127.0.0.1'
api_port = getattr(
self.spawner,
'api_port',
8083) if self.spawner else 8083
project_id = project_config.get(
"project_id", "") if project_config else ""
return f"""你是任务协调员。请分析以下任务,决定最合适的执行者并分配。
@@ -479,7 +525,8 @@ class Dispatcher:
# ── Legacy 兼容(deprecated ──
def _legacy_decide(self, task: Task, action_type: str = "") -> Dict[str, Any]:
def _legacy_decide(
self, task: Task, action_type: str = "") -> Dict[str, Any]:
"""旧版三级决策树(兼容过渡用)"""
LOCAL_ACTIONS = frozenset({
"L1_guardrail", "format_check",
@@ -519,7 +566,8 @@ class Dispatcher:
return registered[0]
return "pangtong-fujunshi"
async def _legacy_dispatch(self, task, action_type="", project_config=None):
async def _legacy_dispatch(
self, task, action_type="", project_config=None):
"""旧版 dispatch(兼容过渡用)
v2.7.2: counter acquire/release 移到 spawn_full_agent 内部
@@ -541,16 +589,21 @@ class Dispatcher:
try:
# NOTE: _legacy_dispatch 仅在 router=None 时触发,当前配置不会进入。
# Mail 永远走 dispatch() 主路径(on_checks_passed 方案),不走此路径。
# 如果未来 legacy 路径被启用,需同步 on_checks_passed 逻辑。
is_mail_legacy = project_config.get("project_id") == "_mail" if project_config else False
if is_mail_legacy:
db_path_legacy = Path(project_config["db_path"]) if project_config and "db_path" in project_config else None
if not db_path_legacy or not self._mail_auto_working(task.id, db_path_legacy):
# [Step 5] handler 统一:用注册表查 handler
project_id_legacy = project_config.get("project_id", "") if project_config else ""
handler_legacy = TaskTypeRegistry.get_by_project(project_id_legacy)
if handler_legacy:
db_path_legacy = Path(
project_config["db_path"]) if project_config and "db_path" in project_config else None
if db_path_legacy:
handler_legacy.pre_spawn(task.id, db_path_legacy)
else:
return {"level": level.value, "agent_id": agent_id,
"session_id": None, "status": "error",
"reason": "mail_auto_working_failed"}
"reason": "no db_path for handler"}
if hasattr(self.spawner, 'build_spawn_message') and project_config:
if hasattr(self.spawner,
'build_spawn_message') and project_config:
retry_ctx = self._build_retry_context(task)
message = self.spawner.build_spawn_message(
task_id=task.id, title=task.title,
@@ -569,32 +622,34 @@ class Dispatcher:
# v2.7.2: on_complete 只含业务逻辑
on_complete_legacy = None
if is_mail_legacy:
if handler_legacy:
_t_id = task.id
_m_db = db_path_legacy
_m_mh = task.must_haves or ""
_disp = self
_h_db = db_path_legacy
_h = handler_legacy
def _mail_oc_legacy(aid, outcome):
def _handler_oc_legacy(aid, outcome):
try:
_disp._mail_auto_complete(_t_id, aid, _m_db, _m_mh, outcome=outcome)
_h.post_complete(_t_id, aid, outcome, _h_db)
except Exception as e:
logger.error("Mail %s: legacy on_complete error: %s", _t_id, e)
on_complete_legacy = _mail_oc_legacy
logger.error(
"Handler %s: legacy on_complete error: %s", _t_id, e)
on_complete_legacy = _handler_oc_legacy
session_id = await self.spawner.spawn_full_agent(
agent_id=agent_id, message=message,
task_id=task.id,
on_complete=on_complete_legacy,
use_main_session=True, # #02: 统一投递到 main session
task_db_path=Path(project_config["db_path"]) if project_config and "db_path" in project_config else None,
task_db_path=Path(
project_config["db_path"]) if project_config and "db_path" in project_config else None,
)
return {"level": level.value, "agent_id": agent_id,
"session_id": session_id, "status": "dispatched",
"reason": decision["reason"]}
except AgentBusyError as e:
reason = getattr(e, 'reason', 'busy')
detail_msg = f"Session busy: {reason}" if reason.startswith("session_") else f"Agent busy: {reason}"
detail_msg = f"Session busy: {reason}" if reason.startswith(
"session_") else f"Agent busy: {reason}"
return {"level": level.value, "agent_id": agent_id,
"session_id": None, "status": "skipped",
"reason": detail_msg}
@@ -608,6 +663,7 @@ class Dispatcher:
# ── Mail 信封/载荷分离辅助方法 ──
# DEPRECATED: Step 5 handler 架构已替代此方法,保留仅供平滑过渡,确认稳定后删除。
def _mail_auto_working(self, task_id: str, db_path: Path) -> bool:
"""Mail 任务:系统自动标 workingspawn 前)
@@ -619,9 +675,11 @@ class Dispatcher:
conn = get_connection(db_path)
try:
conn.execute("BEGIN IMMEDIATE")
row = conn.execute("SELECT status FROM tasks WHERE id=?", (task_id,)).fetchone()
row = conn.execute(
"SELECT status FROM tasks WHERE id=?", (task_id,)).fetchone()
if not row:
logger.warning("Mail %s: cannot mark working (task not found)", task_id)
logger.warning(
"Mail %s: cannot mark working (task not found)", task_id)
return False
if row["status"] not in ("pending", "claimed"):
logger.warning("Mail %s: cannot mark working (status=%s, expected pending/claimed)",
@@ -632,7 +690,10 @@ class Dispatcher:
(task_id,),
)
conn.commit()
logger.info("Mail %s: auto-marked working (system, was %s)", task_id, row["status"])
logger.info(
"Mail %s: auto-marked working (system, was %s)",
task_id,
row["status"])
return True
finally:
conn.close()
@@ -640,27 +701,37 @@ class Dispatcher:
logger.error("Mail %s: failed to mark working: %s", task_id, e)
return False
# DEPRECATED: Step 5 handler 架构已替代此方法,保留仅供平滑过渡,确认稳定后删除。
def _mail_revert_to_pending(self, task_id: str, db_path: Path) -> None:
"""Mail spawn 失败时回退 working → pending,避免永久死锁"""
try:
conn = get_connection(db_path)
try:
conn.execute("BEGIN IMMEDIATE")
row = conn.execute("SELECT status FROM tasks WHERE id=?", (task_id,)).fetchone()
row = conn.execute(
"SELECT status FROM tasks WHERE id=?", (task_id,)).fetchone()
if row and row["status"] == "working":
conn.execute(
"UPDATE tasks SET status='pending', updated_at=datetime('now') WHERE id=?",
(task_id,),
)
conn.commit()
logger.info("Mail %s: reverted working → pending (spawn failed)", task_id)
logger.info(
"Mail %s: reverted working → pending (spawn failed)", task_id)
else:
logger.debug("Mail %s: skip revert (status=%s, expected working)", task_id, row["status"] if row else "not_found")
logger.debug(
"Mail %s: skip revert (status=%s, expected working)",
task_id,
row["status"] if row else "not_found")
finally:
conn.close()
except Exception as e:
logger.error("Mail %s: failed to revert to pending: %s", task_id, e)
logger.error(
"Mail %s: failed to revert to pending: %s",
task_id,
e)
# DEPRECATED: Step 5 handler 架构已替代此方法,保留仅供平滑过渡,确认稳定后删除。
def _mail_auto_complete(self, task_id: str, agent_id: str,
db_path: Path, must_haves: str, outcome=None) -> None:
"""Mail 任务:on_complete 后自动标 done/failed(含幻觉门控)"""
@@ -669,7 +740,9 @@ class Dispatcher:
performative = "request"
try:
meta = json.loads(must_haves) if must_haves else {}
performative = meta.get("performative", meta.get("type", "request"))
performative = meta.get(
"performative", meta.get(
"type", "request"))
except Exception:
pass
@@ -678,13 +751,15 @@ class Dispatcher:
has_reply = self._mail_check_reply(task_id, db_path)
if not has_reply:
# F3: 立刻标 failed(不等 ticker 30 分钟)
logger.error("Mail %s: no reply found, marking failed (no_reply_found)", task_id)
logger.error(
"Mail %s: no reply found, marking failed (no_reply_found)", task_id)
for attempt in range(3):
try:
conn = get_connection(db_path)
try:
conn.execute("BEGIN IMMEDIATE")
row = conn.execute("SELECT status FROM tasks WHERE id=?", (task_id,)).fetchone()
row = conn.execute(
"SELECT status FROM tasks WHERE id=?", (task_id,)).fetchone()
if not row:
return
if row["status"] == "working":
@@ -698,19 +773,24 @@ class Dispatcher:
json.dumps({"reason": "no_reply_found"}, ensure_ascii=False)),
)
conn.commit()
logger.info("Mail %s: marked failed (no_reply_found)", task_id)
logger.info(
"Mail %s: marked failed (no_reply_found)", task_id)
# Mail 失败通知:通知发件人
try:
from src.daemon.mail_notify import notify_mail_failed
notify_mail_failed(db_path, task_id, "no_reply_found")
notify_mail_failed(
db_path, task_id, "no_reply_found")
except Exception as ne:
logger.warning("Mail %s: failed to send no_reply_found notification: %s", task_id, ne)
logger.warning(
"Mail %s: failed to send no_reply_found notification: %s", task_id, ne)
return
finally:
conn.close()
except Exception as e:
logger.warning("Mail %s: failed attempt %d: %s", task_id, attempt + 1, e)
logger.error("Mail %s: all 3 failed attempts failed, leaving for ticker", task_id)
logger.warning(
"Mail %s: failed attempt %d: %s", task_id, attempt + 1, e)
logger.error(
"Mail %s: all 3 failed attempts failed, leaving for ticker", task_id)
return
# inform 类型:只对成功 outcome 标 done,失败 outcome 留 working 等 ticker 重投
@@ -718,7 +798,10 @@ class Dispatcher:
if performative == "inform":
INFORM_DONE_OUTCOMES = {"completed", "claimed", "no_reply"}
if outcome not in INFORM_DONE_OUTCOMES:
logger.info("Mail %s: inform outcome=%s, skip auto-done", task_id, outcome)
logger.info(
"Mail %s: inform outcome=%s, skip auto-done",
task_id,
outcome)
return
# 标 done(重试 3 次)
@@ -727,7 +810,8 @@ class Dispatcher:
conn = get_connection(db_path)
try:
conn.execute("BEGIN IMMEDIATE")
row = conn.execute("SELECT status FROM tasks WHERE id=?", (task_id,)).fetchone()
row = conn.execute(
"SELECT status FROM tasks WHERE id=?", (task_id,)).fetchone()
if not row:
return
if row["status"] == "working":
@@ -742,13 +826,20 @@ class Dispatcher:
finally:
conn.close()
except Exception as e:
logger.warning("Mail %s: done attempt %d failed: %s", task_id, attempt + 1, e)
logger.warning(
"Mail %s: done attempt %d failed: %s",
task_id,
attempt + 1,
e)
# 3 次都失败,留 working 等 ticker 超时兜底
logger.error("Mail %s: all 3 done attempts failed, leaving for ticker", task_id)
logger.error(
"Mail %s: all 3 done attempts failed, leaving for ticker",
task_id)
except Exception as e:
logger.error("Mail %s: auto-complete error: %s", task_id, e)
# DEPRECATED: Step 5 handler 架构已替代此方法,保留仅供平滑过渡,确认稳定后删除。
def _mail_check_reply(self, original_task_id: str, db_path: Path) -> bool:
"""幻觉门控:检查是否有回复邮件(in_reply_to = original_task_id"""
try:
@@ -789,7 +880,9 @@ class Dispatcher:
logger.info("Task %s: verify passed, marking review", task_id)
self._mark_task_status(db_path, task_id, "review")
else:
logger.info("Task %s: verify not passed (no signal), leaving working", task_id)
logger.info(
"Task %s: verify not passed (no signal), leaving working",
task_id)
except Exception as e:
logger.error("Task %s: auto-complete error: %s", task_id, e)
@@ -824,7 +917,8 @@ class Dispatcher:
logger.error("Task %s: verify error: %s", task_id, e)
return True
def _rollback_current_agent(self, db_path: Path, task_id: str, agent_id: str) -> None:
def _rollback_current_agent(
self, db_path: Path, task_id: str, agent_id: str) -> None:
"""#07.2: crash 后回退 current_agent 到 assignee,避免 exclude_current 卡死"""
try:
conn = get_connection(db_path)
@@ -838,11 +932,18 @@ class Dispatcher:
conn.commit()
finally:
conn.close()
logger.info("Task %s: rolled back current_agent from %s to assignee", task_id, agent_id)
logger.info(
"Task %s: rolled back current_agent from %s to assignee",
task_id,
agent_id)
except Exception as e:
logger.warning("Task %s: failed to rollback current_agent: %s", task_id, e)
logger.warning(
"Task %s: failed to rollback current_agent: %s",
task_id,
e)
def _mark_task_status(self, db_path: Path, task_id: str, status: str) -> None:
def _mark_task_status(self, db_path: Path,
task_id: str, status: str) -> None:
"""更新任务状态 + 写审计事件"""
try:
conn = get_connection(db_path)
@@ -858,7 +959,8 @@ class Dispatcher:
)
conn.execute(
"INSERT INTO events (task_id, agent, event_type, payload) VALUES (?, 'dispatcher', 'status_change', ?)",
(task_id, f'{{"from": "{old_status}", "to": "{status}", "source": "auto_complete"}}'),
(task_id,
f'{{"from": "{old_status}", "to": "{status}", "source": "auto_complete"}}'),
)
conn.commit()
finally:
@@ -867,7 +969,7 @@ class Dispatcher:
logger.error("Task %s: mark status error: %s", task_id, e)
@staticmethod
def _check_crash_limit(task_id: str, db_path: pathlib.Path, limit: int = 3,
def _check_crash_limit(task_id: str, db_path: Path, limit: int = 3,
window_minutes: int = 30) -> bool:
"""v2.8.1 Fix-3c: 检查 task 最近 window_minutes 内的 crash 次数是否超限。
+15 -5
View File
@@ -38,7 +38,9 @@ class GuardrailEngine:
data = yaml.safe_load(f)
self.rules = data.get("rules", [])
self.settings = data.get("settings", {"enabled": True})
logger.info("Loaded %d guardrail rules from %s", len(self.rules), config_path)
logger.info(
"Loaded %d guardrail rules from %s", len(
self.rules), config_path)
def check_task(self, task: Any) -> List[GuardrailViolation]:
"""检查 Task 是否触犯安全红线(调度前调用)"""
@@ -95,7 +97,8 @@ class GuardrailEngine:
return violations
def check_token_usage(self, token_count: int) -> Optional[GuardrailViolation]:
def check_token_usage(
self, token_count: int) -> Optional[GuardrailViolation]:
"""检查 Token 消耗是否超标"""
if not self.settings.get("enabled", True):
return None
@@ -103,7 +106,10 @@ class GuardrailEngine:
for rule in self.rules:
if rule["id"] != "high_token_usage":
continue
threshold = rule.get("triggers", [{}])[0].get("token_threshold", 100000)
threshold = rule.get(
"triggers", [
{}])[0].get(
"token_threshold", 100000)
if token_count > threshold:
return GuardrailViolation(
rule_id=rule["id"],
@@ -114,7 +120,8 @@ class GuardrailEngine:
)
return None
def check_consecutive_failure(self, failure_count: int) -> Optional[GuardrailViolation]:
def check_consecutive_failure(
self, failure_count: int) -> Optional[GuardrailViolation]:
"""检查连续失败次数"""
if not self.settings.get("enabled", True):
return None
@@ -122,7 +129,10 @@ class GuardrailEngine:
for rule in self.rules:
if rule["id"] != "consecutive_failure":
continue
threshold = rule.get("triggers", [{}])[0].get("consecutive_failures", 3)
threshold = rule.get(
"triggers", [
{}])[0].get(
"consecutive_failures", 3)
if failure_count >= threshold:
return GuardrailViolation(
rule_id=rule["id"],
+9 -3
View File
@@ -41,6 +41,7 @@ class HealthChecker:
{"healthy": bool, "zombie": bool, "stale_ticks": int,
"alert_written": bool, "resolved": bool}
"""
str(db_path)
result: Dict[str, Any] = {
"healthy": True,
"zombie": False,
@@ -57,7 +58,8 @@ class HealthChecker:
# 用 event count 变化判断是否有真实变更
conn = queries._conn()
try:
conn.execute("SELECT COUNT(*) FROM events").fetchone()[0]
conn.execute(
"SELECT COUNT(*) FROM events").fetchone()[0]
non_tick_events = conn.execute(
"SELECT COUNT(*) FROM events WHERE event_type != 'daemon_tick' "
"AND event_type != 'agent_zombie_detected'"
@@ -84,7 +86,8 @@ class HealthChecker:
self._stale_ticks[project_id] = stale
result["stale_ticks"] = stale
if stale >= self.zombie_threshold and not self._alerted.get(project_id):
if stale >= self.zombie_threshold and not self._alerted.get(
project_id):
# 写告警
self._write_alert(db_path, project_id, tick_num, stale)
self._alerted[project_id] = True
@@ -125,7 +128,10 @@ class HealthChecker:
conn.commit()
finally:
conn.close()
logger.warning("Zombie detected: %s (stale=%d)", project_id, stale_ticks)
logger.warning(
"Zombie detected: %s (stale=%d)",
project_id,
stale_ticks)
def _write_resolution(self, db_path: Path, project_id: str,
tick_num: int) -> None:
+4 -2
View File
@@ -27,7 +27,8 @@ class InboxWatcher:
def __init__(
self,
inbox_path: Path,
process_callback: Optional[Callable[[Dict[str, Any]], Coroutine[Any, Any, None]]] = None,
process_callback: Optional[Callable[[
Dict[str, Any]], Coroutine[Any, Any, None]]] = None,
watch_interval: float = 1.0,
):
"""
@@ -159,7 +160,8 @@ class InboxWatcher:
line_no, type(event).__name__)
self._total_errors += 1
except json.JSONDecodeError:
logger.warning("Inbox line %d: invalid JSON, skipping", line_no)
logger.warning(
"Inbox line %d: invalid JSON, skipping", line_no)
self._total_errors += 1
return events
+210
View File
@@ -0,0 +1,210 @@
"""mail_handler.py — Mail 任务 handler。
处理 Agent 间通信飞鸽传书 inform request 两种类型
"""
from __future__ import annotations
import json
import logging
from pathlib import Path
from src.daemon.base_task_handler import BaseTaskHandler, VerifyResult
from src.daemon.prompt_composer import PromptComposer, PromptContext
from src.blackboard.db import get_connection
logger = logging.getLogger("moziplus-v2.handler.mail")
class MailHandler(BaseTaskHandler):
"""Mail 任务 handler。"""
task_type = "mail"
virtual_project = "_mail"
display_name = "飞鸽传书"
def target_success_status(self) -> str:
return "done"
def pre_spawn(self, task_id: str, db_path: Path) -> bool:
"""auto_workingpending → working"""
return self._auto_mark_working(task_id, db_path)
def build_prompt(self, context: PromptContext) -> str:
"""通过 PromptComposer 拼装 3 个 section。"""
composer = PromptComposer()
composer.add_many(self.get_sections())
return composer.compose(context)
def get_sections(self) -> list:
return [MailContextSection(), MailApiSection(), MailConstraintsSection()]
def verify_completion(self, task_id: str, db_path: Path) -> VerifyResult:
"""Mail 完成验证:区分 inform/request。
- inform: 始终通过通知已阅即 done不需要检查产出
- request: 检查是否已回复
"""
performative = self._parse_performative(task_id, db_path)
if performative == "inform":
return VerifyResult(True, "inform_auto", f"performative={performative}")
# request: 检查是否已回复
has_reply = self._check_reply(task_id, db_path)
if has_reply:
return VerifyResult(True, "has_reply", f"performative={performative}")
return VerifyResult(False, "no_reply", f"performative={performative}")
# post_complete 由基类 BaseTaskHandler 统一处理(crash→verify→mark→notify
# inform: verify 始终通过 → 基类 mark done ✅
# request 有回复: verify 通过 → 基类 mark done ✅
# request 无回复: verify 失败 → 基类调 on_failure ✅
def on_failure(self, task_id: str, agent_id: str,
db_path: Path, verify: VerifyResult) -> None:
"""request 验证失败 → 标 failed + 通知发件人"""
self._mark_task_status(db_path, task_id, "failed")
logger.info("Mail %s: request verify failed (%s), marked failed",
task_id, verify.reason)
# 通知发件人
try:
from src.daemon.mail_notify import notify_mail_failed
notify_mail_failed(db_path, task_id, "no_reply_found")
except Exception as e:
logger.warning("Mail %s: failed to send notification: %s", task_id, e)
# === 内部方法 ===
def _parse_performative(self, task_id: str, db_path: Path) -> str:
"""解析 mail 类型(inform/request"""
try:
conn = get_connection(db_path)
try:
row = conn.execute(
"SELECT must_haves FROM tasks WHERE id=?", (task_id,)
).fetchone()
if row and row["must_haves"]:
meta = json.loads(row["must_haves"])
return meta.get("performative", meta.get("type", "request"))
finally:
conn.close()
except Exception:
pass
return "request"
def _check_reply(self, task_id: str, db_path: Path) -> bool:
"""检查是否已回复(查 tasks 表找 in_reply_to 回复邮件)
dispatcher._mail_check_reply 迁移
Mail 回复机制创建新 taskmust_haves JSON 中包含 in_reply_to = original_task_id
不能查 comments 回复邮件是独立的 task不是 comment
"""
try:
conn = get_connection(db_path)
try:
row = conn.execute(
"SELECT id FROM tasks WHERE id != ? AND must_haves LIKE ? LIMIT 1",
(task_id, f'%{task_id}%'),
).fetchone()
return row is not None
finally:
conn.close()
except Exception as e:
logger.error("Mail %s: check reply error: %s", task_id, e)
# 查询失败时保守处理:假设有回复(避免误标 failed)
return True
def check_completion(self, task_id: str, db_path: Path) -> bool:
"""ticker 级别的完成检查:检查是否已回复"""
return self._check_reply(task_id, db_path)
# ===================================================================
# Mail PromptSections
# ===================================================================
class MailContextSection:
"""邮件上下文段 — 发件人/收件人/主题/内容,区分 inform/request。"""
name: str = "mail_context"
priority: int = 10
def render(self, context: PromptContext) -> str:
if context.mail_type == "inform":
return self._render_inform(context)
return self._render_request(context)
def should_include(self, context: PromptContext) -> bool: # noqa: ARG002
return True
@staticmethod
def _render_inform(context: PromptContext) -> str:
return (
f"你收到一封飞鸽传书(纯通知)。\n\n"
f"发件者: {context.from_agent}\n"
f"主题: {context.title}\n"
f"内容: {context.description}\n\n"
f"已阅即可。如需回复,用 in_reply_to 回复发件者(不需要填 to)。\n"
f"⚠️ 不要执行任何状态转换命令。"
)
@staticmethod
def _render_request(context: PromptContext) -> str:
return (
f"你收到一封飞鸽传书,需要你处理并回复。\n\n"
f"发件者: {context.from_agent}\n"
f"主题: {context.title}\n"
f"内容: {context.description}\n\n"
f"### 如何回复发件者\n\n"
f'curl -s -X POST http://localhost:8083/api/mail \\\n'
f" -H 'Content-Type: application/json' \\\n"
f' -d \'{{"from": "{context.agent_id}", '
f'"in_reply_to": "{context.task_id}", '
f'"title": "回复: {context.title}", '
f'"text": "你的回复内容"}}\'\n\n'
f"⚠️ 不需要填 \"to\",系统自动回复给发件者。"
)
class MailApiSection:
"""Mail API 操作指令段。"""
name: str = "mail_api"
priority: int = 40
def render(self, context: PromptContext) -> str:
return (
f"### 如何给其他人发新邮件\n\n"
f'curl -s -X POST http://localhost:8083/api/mail \\\n'
f" -H 'Content-Type: application/json' \\\n"
f' -d \'{{"from": "{context.agent_id}", '
f'"to": "对方agent-id", '
f'"title": "标题", '
f'"text": "正文", '
f'"type": "inform"}}\'\n\n'
f"⚠️ to 必须是有效的 agent id\n"
f"⚠️ 纯通知用 type=inform,需要对方回复不填 type(默认 request)"
)
def should_include(self, context: PromptContext) -> bool:
return context.mail_type == "request"
class MailConstraintsSection:
"""Mail 硬约束段。"""
name: str = "mail_constraints"
priority: int = 50
def render(self, context: PromptContext) -> str: # noqa: ARG002
return (
"## 硬约束\n\n"
"1. ⚠️ 不要执行任何状态转换命令(标 working/done/review/failed 等),系统会自动处理。\n"
"2. ⚠️ 不能给自己发邮件\n"
"3. ⚠️ 发邮件时 to 必须是有效的 agent id\n"
"4. ⚠️ 纯通知用 type=inform,需要对方回复不填 type(默认 request)"
)
def should_include(self, context: PromptContext) -> bool: # noqa: ARG002
return True
+13 -4
View File
@@ -50,7 +50,9 @@ def notify_mail_failed(db_path: Path, original_mail_id: str,
bb = Blackboard(db_path)
original = bb.get_task(original_mail_id)
if not original:
logger.warning("notify_mail_failed: original mail %s not found", original_mail_id)
logger.warning(
"notify_mail_failed: original mail %s not found",
original_mail_id)
return
# 解析原邮件元数据
@@ -58,7 +60,9 @@ def notify_mail_failed(db_path: Path, original_mail_id: str,
# 防递归:系统通知邮件失败不再发通知
if meta.get("system_notify"):
logger.info("Mail %s: system notify mail failed, skipping recursive notification", original_mail_id)
logger.info(
"Mail %s: system notify mail failed, skipping recursive notification",
original_mail_id)
return
# 获取发件人(优先 assigned_byfallback must_haves.from
@@ -67,7 +71,9 @@ def notify_mail_failed(db_path: Path, original_mail_id: str,
title = original.title or ""
if not from_agent:
logger.warning("notify_mail_failed: cannot determine sender for mail %s", original_mail_id)
logger.warning(
"notify_mail_failed: cannot determine sender for mail %s",
original_mail_id)
return
# 发件人不是有效 Agent(如 system)→ 通知庞统代处理,不触发广播
@@ -111,4 +117,7 @@ def notify_mail_failed(db_path: Path, original_mail_id: str,
original_mail_id, target_agent, from_agent, reason, notify_id)
except Exception as e:
logger.warning("notify_mail_failed: failed to send notification for mail %s: %s", original_mail_id, e)
logger.warning(
"notify_mail_failed: failed to send notification for mail %s: %s",
original_mail_id,
e)
+127
View File
@@ -0,0 +1,127 @@
"""
prompt_composer.py PromptSection Protocol + PromptContext + PromptComposer
拼装器有序管理 prompt 段落按优先级排序后合并为最终 prompt
"""
import logging
from dataclasses import dataclass, field
from typing import Dict, List, Optional, Protocol, runtime_checkable
logger = logging.getLogger("moziplus-v2.prompt_composer")
# ---------------------------------------------------------------------------
# Section 优先级范围约定
# ---------------------------------------------------------------------------
PRIORITY_CONTEXT = 10 # 任务上下文
PRIORITY_PRIOR = 20 # 前序信息
PRIORITY_ROLE = 30 # 角色规范
PRIORITY_API = 40 # API 操作指令
PRIORITY_CONSTRAINTS = 50 # 硬约束
PRIORITY_EXTENSION = 60 # 扩展段
# ---------------------------------------------------------------------------
# PromptSection Protocol
# ---------------------------------------------------------------------------
@runtime_checkable
class PromptSection(Protocol):
"""一个 prompt 段"""
name: str # 段名(去重用,同名覆盖)
priority: int # 排序优先级(小数字=靠前)
def render(self, context: "PromptContext") -> str:
"""渲染此段的文本内容。返回空字符串表示不注入。"""
...
def should_include(self, context: "PromptContext") -> bool:
"""是否注入此段(默认 True,条件段可覆盖)。"""
...
# ---------------------------------------------------------------------------
# PromptContext 数据对象
# ---------------------------------------------------------------------------
@dataclass
class PromptContext:
"""Prompt 渲染的统一上下文"""
task_id: str
title: str
description: str
must_haves: str
project_id: str
agent_id: str
task: Optional[Dict] = None
role: str = "executor"
spawn_type: str = "executor"
# mail 专用
from_agent: str = ""
mail_type: str = "" # inform / request
# toolchain 专用
event_type: str = "" # ci_failure / review_request / ...
event_data: Dict = field(default_factory=dict)
# 前序产出
depends_on_outputs: Optional[List] = None
# ---------------------------------------------------------------------------
# PromptComposer 拼装器
# ---------------------------------------------------------------------------
class PromptComposer:
"""有序拼装 prompt sections"""
SEPARATOR = "\n\n---\n\n"
TOKEN_BUDGET_WARN = 800 # token 预算警告阈值
CHARS_PER_TOKEN = 3.5 # 估算比率
def __init__(self) -> None:
self._sections: List[PromptSection] = []
def add(self, section: PromptSection) -> None:
"""添加一个 section(同名覆盖)"""
self._sections = [s for s in self._sections if s.name != section.name]
self._sections.append(section)
def add_many(self, sections: List[PromptSection]) -> None:
"""批量添加"""
for s in sections:
self.add(s)
def compose(self, context: PromptContext) -> str:
"""拼装最终 prompt
1. 过滤 should_include=False 的段
2. priority 排序
3. 逐段 render
4. 过滤空段
5. 用分隔符连接
6. Token 预算警告不截断
"""
active = [s for s in self._sections if s.should_include(context)]
active.sort(key=lambda s: s.priority)
parts = [s.render(context) for s in active]
parts = [p for p in parts if p.strip()]
result = self.SEPARATOR.join(parts)
# Token 估算
tokens = max(1, int(len(result) / self.CHARS_PER_TOKEN))
logger.debug(
"Composed prompt from %d sections, %d tokens",
len(parts), tokens,
)
if tokens > self.TOKEN_BUDGET_WARN:
logger.warning(
"Prompt exceeds %d token budget: %d tokens (task_id=%s)",
self.TOKEN_BUDGET_WARN, tokens, context.task_id,
)
return result
+14 -7
View File
@@ -148,12 +148,14 @@ class ReviewPipeline:
) -> ReviewResult:
"""Step 2: 格式合规"""
if not outputs:
return ReviewResult("format", ReviewVerdict.FAIL, 0.0, "No outputs")
return ReviewResult(
"format", ReviewVerdict.FAIL, 0.0, "No outputs")
issues = []
for out in outputs:
# output.md 必须存在且非空
if out.get("type") == "markdown" or out.get("path", "").endswith(".md"):
if out.get("type") == "markdown" or out.get(
"path", "").endswith(".md"):
content = out.get("content", "")
if not content and out.get("path"):
try:
@@ -164,7 +166,8 @@ class ReviewPipeline:
issues.append(f"Output too short: {out.get('path', '?')}")
# 结论 JSON 必须有效
if out.get("type") == "json" or out.get("path", "").endswith(".json"):
if out.get("type") == "json" or out.get(
"path", "").endswith(".json"):
content = out.get("content", "")
if not content and out.get("path"):
try:
@@ -174,7 +177,8 @@ class ReviewPipeline:
try:
data = json.loads(content)
if not isinstance(data, dict):
issues.append(f"JSON not a dict: {out.get('path', '?')}")
issues.append(
f"JSON not a dict: {out.get('path', '?')}")
except (json.JSONDecodeError, TypeError):
issues.append(f"Invalid JSON: {out.get('path', '?')}")
@@ -191,7 +195,8 @@ class ReviewPipeline:
) -> ReviewResult:
"""Step 3: 内容质量(自定义检查)"""
if not outputs:
return ReviewResult("quality", ReviewVerdict.FAIL, 0.0, "No outputs")
return ReviewResult(
"quality", ReviewVerdict.FAIL, 0.0, "No outputs")
suggestions = []
total_score = 0.0
@@ -212,7 +217,8 @@ class ReviewPipeline:
avg = 1.0 # 无自定义检查默认通过
verdict = ReviewVerdict.PASS if avg >= 0.6 else ReviewVerdict.FAIL
return ReviewResult("quality", verdict, round(avg, 2), suggestions=suggestions)
return ReviewResult("quality", verdict, round(
avg, 2), suggestions=suggestions)
def _determine_gate(
self, task: Task, results: List[ReviewResult]
@@ -326,6 +332,7 @@ class RebuttalManager:
return 0
try:
observations = self.bb.get_observations(task_id=task_id)
return sum(1 for o in observations if "Rebuttal round" in (o.body or ""))
return sum(
1 for o in observations if "Rebuttal round" in (o.body or ""))
except Exception:
return 0
+11 -5
View File
@@ -107,7 +107,8 @@ class AgentRouter:
# ── 快速路径 2: retry → 原执行者 ──
if action_type == "retry":
current = task_info.get("current_agent") or task_info.get("assignee")
current = task_info.get(
"current_agent") or task_info.get("assignee")
if current and current in self.agent_profiles:
return RouteDecision(
agent_id=current,
@@ -119,7 +120,8 @@ class AgentRouter:
# ── Mode B: Agent 声明式交接 ──
next_cap = task_info.get("next_capability")
if next_cap and self._validate_capability(next_cap):
current = task_info.get("current_agent") or task_info.get("assignee")
current = task_info.get(
"current_agent") or task_info.get("assignee")
exclude = {current} if current else set()
matched = self._match_capability(next_cap, exclude)
if matched:
@@ -129,7 +131,9 @@ class AgentRouter:
mode="agent_handoff",
latency_ms=int((time.monotonic() - start) * 1000),
)
logger.info("next_capability '%s' no match, delegate to coordinator", next_cap)
logger.info(
"next_capability '%s' no match, delegate to coordinator",
next_cap)
# ── 快速路径 3: 生命周期流转查表 ──
lifecycle = self.LIFECYCLE_CAPABILITY.get(action_type)
@@ -140,7 +144,8 @@ class AgentRouter:
exclude_current = lifecycle.get("exclude_current", False)
exclude = set()
if exclude_current:
current = task_info.get("current_agent") or task_info.get("assignee")
current = task_info.get(
"current_agent") or task_info.get("assignee")
if current:
exclude.add(current)
matched = self._match_capability(cap, exclude)
@@ -154,7 +159,8 @@ class AgentRouter:
# ── 快速路径 4: 有 assignee 且非生命周期流转 ──
assignee = task_info.get("assignee")
if assignee and assignee in self.agent_profiles and action_type not in ("review", "escalation"):
if assignee and assignee in self.agent_profiles and action_type not in (
"review", "escalation"):
return RouteDecision(
agent_id=assignee,
reason=f"Direct assignee: {assignee}",
+282 -78
View File
@@ -7,7 +7,6 @@ Subagent: 占位(实际通过 OpenClaw Gateway API sessions_spawn,F17 完善)
from __future__ import annotations
import asyncio
import pathlib
import json
import logging
import os
@@ -17,6 +16,7 @@ from pathlib import Path
from typing import Any, Dict, List, Optional
from src.blackboard.db import get_connection
from src.daemon.task_type_registry import TaskTypeRegistry
logger = logging.getLogger("moziplus-v2.spawner")
@@ -164,9 +164,12 @@ class AgentBusyError(Exception):
#07: reason 字段区分具体原因,便于 dispatcher 层区分处理。
"""
def __init__(self, agent_id: str, reason: str = "busy", detail: Optional[dict] = None):
def __init__(self, agent_id: str, reason: str = "busy",
detail: Optional[dict] = None):
self.agent_id = agent_id
self.reason = reason # counter_blocked / session_locked / session_running / session_compacting / session_stuck
# counter_blocked / session_locked / session_running / session_compacting / session_stuck
self.reason = reason
self.detail = detail or {}
super().__init__(f"{agent_id}: {reason}")
@@ -276,13 +279,36 @@ class AgentSpawner:
task_id, title, description, must_haves,
project_id, agent_id)
# mail 任务用精简模板
if project_id == "_mail":
return self._build_mail_prompt(task_id, title, description, must_haves, agent_id)
# handler 路径:Task/Mail/Toolchain 用各自的 PromptSection 构建
handler = TaskTypeRegistry.get_by_project(project_id)
if handler:
from src.daemon.prompt_composer import PromptContext
# 从 must_haves 解析 mail 元数据(from / performative
from_agent = ""
mail_type = ""
try:
meta = json.loads(must_haves) if must_haves else {}
from_agent = meta.get("from", "")
mail_type = meta.get("performative", meta.get("type", ""))
except Exception:
pass
ctx = PromptContext(
task_id=task_id, title=title, description=description or "",
must_haves=must_haves or "", project_id=project_id,
agent_id=agent_id, role=spawn_type,
spawn_type=spawn_type,
from_agent=from_agent, mail_type=mail_type,
)
return handler.build_prompt(ctx)
# 旧路径保留:_general 等非 handler 项目
# 走 BootstrapBuilder 新路径
if self.bootstrap_builder and task is not None:
role_map = {"executor": "executor", "review": "reviewer", "discussion": "planner"}
role_map = {
"executor": "executor",
"review": "reviewer",
"discussion": "planner"}
role = role_map.get(spawn_type, "executor")
bootstrap_prompt = self.bootstrap_builder.build_for_task(
task=task,
@@ -294,7 +320,8 @@ class AgentSpawner:
# 无 BootstrapBuilder 或无 task 对象 → 最小 fallback
# 只保留任务上下文 + API 操作指令
logger.warning("No BootstrapBuilder or task object, using minimal fallback")
logger.warning(
"No BootstrapBuilder or task object, using minimal fallback")
return self._build_minimal_fallback(
task_id, title, description, must_haves,
project_id, agent_id)
@@ -314,8 +341,13 @@ class AgentSpawner:
def _build_api_section(self, project_id: str, task_id: str,
agent_id: str) -> str:
"""构建 API 回写操作指令(BootstrapBuilder 模式下补充)"""
# mail 任务直接 done,不走 review
success_status = '"done"' if project_id == "_mail" else '"review"'
# handler 项目(_mail/_toolchain)的 success_status 由 PromptSection 处理
# 这里只处理无 handler 的项目(normal task
handler = TaskTypeRegistry.get_by_project(project_id)
if handler:
success_status = '"done"' if handler.target_success_status == "done" else '"review"'
else:
success_status = '"review"'
return f"""## 操作指令
### 状态回写
@@ -369,7 +401,8 @@ curl -X POST http://{self.api_host}:{self.api_port}/api/projects/{project_id}/ta
if not self.guardrails:
return "无特殊限制"
try:
return "".join(r.get("name", r.get("rule_id", "")) for r in self.guardrails.rules[:6])
return "".join(r.get("name", r.get("rule_id", ""))
for r in self.guardrails.rules[:6])
except Exception:
return "无特殊限制"
@@ -389,7 +422,9 @@ curl -X POST http://{self.api_host}:{self.api_port}/api/projects/{project_id}/ta
try:
meta = json.loads(must_haves) if must_haves else {}
from_agent = meta.get("from", agent_id)
performative = meta.get("performative", meta.get("type", "request"))
performative = meta.get(
"performative", meta.get(
"type", "request"))
except Exception:
pass
@@ -472,7 +507,9 @@ curl -X POST http://{self.api_host}:{self.api_port}/api/projects/{project_id}/ta
self._revive_session(agent_id)
elif pre_state.get("status") == "running" and not pre_state.get("lock_pid_alive"):
# status=running 但 lock PID 已死 → 假死,revive
logger.warning("Phase 0: %s status=running but lock PID dead, reviving", agent_id)
logger.warning(
"Phase 0: %s status=running but lock PID dead, reviving",
agent_id)
self._revive_session(agent_id)
# Phase 1: Counter acquire(互斥锁)
@@ -487,12 +524,15 @@ curl -X POST http://{self.api_host}:{self.api_port}/api/projects/{project_id}/ta
if use_main_session:
session_state = self._check_session_state(agent_id)
logger.info("Phase 2 session check for %s: status=%s lock_pid=%s lock_pid_alive=%s compact=%s",
agent_id, session_state.get('status'), session_state.get('lock_pid'),
agent_id, session_state.get(
'status'), session_state.get('lock_pid'),
session_state.get('lock_pid_alive'), session_state.get('recent_compact'))
blockers = []
if session_state.get("lock_pid_alive") and not session_state.get("lock_expired"):
blockers.append(("session_locked", session_state.get("lock_pid")))
if session_state.get(
"lock_pid_alive") and not session_state.get("lock_expired"):
blockers.append(
("session_locked", session_state.get("lock_pid")))
if session_state.get("status") == "running":
if session_state.get("lock_pid_alive"):
# 真 running:外部进程占用
@@ -515,7 +555,8 @@ curl -X POST http://{self.api_host}:{self.api_port}/api/projects/{project_id}/ta
# Phase 2.5: 假死修复(status=running + lock PID 死 → revive → 重检)
# 此场景应被 Phase 0 提前修复,这里做兜底
if session_state.get("status") == "running" and not session_state.get("lock_pid_alive"):
if session_state.get("status") == "running" and not session_state.get(
"lock_pid_alive"):
logger.warning("Phase 2.5: %s status=running + lock dead (should be caught in Phase 0), reviving",
agent_id)
self._revive_session(agent_id)
@@ -538,7 +579,10 @@ curl -X POST http://{self.api_host}:{self.api_port}/api/projects/{project_id}/ta
raise
if self.dry_run:
logger.info("[DRY RUN] Would spawn agent %s (session=%s)", agent_id, _sid_key)
logger.info(
"[DRY RUN] Would spawn agent %s (session=%s)",
agent_id,
_sid_key)
self._register_session(_sid_key, agent_id, task_id, pid=None)
return _sid_key
@@ -554,7 +598,8 @@ curl -X POST http://{self.api_host}:{self.api_port}/api/projects/{project_id}/ta
if asyncio.iscoroutine(result):
await result
except Exception:
logger.warning("Business on_complete failed for %s", aid, exc_info=True)
logger.warning(
"Business on_complete failed for %s", aid, exc_info=True)
cmd = [
"openclaw", "agent",
@@ -593,7 +638,11 @@ curl -X POST http://{self.api_host}:{self.api_port}/api/projects/{project_id}/ta
if self.counter:
self.counter.release(agent_id, _sid_key)
logger.exception("Failed to spawn agent %s", agent_id)
self._record_attempt(task_id, agent_id, "spawn_failed", error=str(e))
self._record_attempt(
task_id,
agent_id,
"spawn_failed",
error=str(e))
raise
async def spawn_subagent(
@@ -609,7 +658,9 @@ curl -X POST http://{self.api_host}:{self.api_port}/api/projects/{project_id}/ta
session_id = str(uuid.uuid4())
if self.dry_run:
logger.info("[DRY RUN] Would spawn subagent (session=%s)", session_id)
logger.info(
"[DRY RUN] Would spawn subagent (session=%s)",
session_id)
self._register_session(session_id, "subagent", task_id, pid=None)
return session_id
@@ -729,10 +780,16 @@ curl -X POST http://{api_host}:{api_port}/api/projects/{project_id}/tasks/{task_
agent_id, session_id, json_result)
# 查任务实际状态
task_status = self._get_task_status(db_path, task_id) if task_id else None
task_status = self._get_task_status(
db_path, task_id) if task_id else None
# 分类
cls = self._classify_outcome(exit_code, json_result, stderr_text, task_status, stdout_text)
cls = self._classify_outcome(
exit_code,
json_result,
stderr_text,
task_status,
stdout_text)
outcome = cls["outcome"]
# 更新 session 状态
@@ -761,17 +818,21 @@ curl -X POST http://{api_host}:{api_port}/api/projects/{project_id}/tasks/{task_
agent_id, session_id, outcome, exit_code, task_status)
# 广播反馈追踪(Phase 1 bug fix)
if task_id == "broadcast" and hasattr(self, '_ticker') and self._ticker:
if task_id == "broadcast" and hasattr(
self, '_ticker') and self._ticker:
# 广播任务:从 session 信息取真实 task_id 列表,逐一回调 tracker
sess_info = self._sessions.get(session_id or "main", {})
bt_ids = sess_info.get("broadcast_task_ids") or []
# 广播场景一律标 no_reply:Agent 只 claim 一个任务,
# 其余任务的 tracker 不能被 claimed 清除
for real_task_id in bt_ids:
self._ticker.record_broadcast_response(real_task_id, agent_id, "no_reply")
self._ticker.record_broadcast_response(
real_task_id, agent_id, "no_reply")
elif task_id and hasattr(self, '_ticker') and self._ticker:
outcome_str = "claimed" if cls.get("status") == "ok" else "no_reply"
self._ticker.record_broadcast_response(task_id, agent_id, outcome_str)
outcome_str = "claimed" if cls.get(
"status") == "ok" else "no_reply"
self._ticker.record_broadcast_response(
task_id, agent_id, outcome_str)
if cls["should_retry"]:
# cooldown: 新增的可恢复场景(A14/A15/A16/A8/A10)
@@ -850,14 +911,24 @@ curl -X POST http://{api_host}:{api_port}/api/projects/{project_id}/tasks/{task_
# v2.8.1 Fix-3a: crash 类 outcome 设 cooldown,给 agent session 恢复时间
if outcome == "crashed" and self.counter:
self.counter.set_cooldown(agent_id, seconds=60)
logger.info("Crash cooldown set for %s: 60s (outcome=%s)", agent_id, outcome)
logger.info(
"Crash cooldown set for %s: 60s (outcome=%s)",
agent_id,
outcome)
elif outcome in ("compact_failed", "process_crash", "session_stuck",
"compact_hanging", "agent_error", "compact_interrupted") and self.counter:
self.counter.set_cooldown(agent_id, seconds=300) # 5 分钟
logger.info("Error cooldown set for %s: 300s (outcome=%s)", agent_id, outcome)
logger.info(
"Error cooldown set for %s: 300s (outcome=%s)",
agent_id,
outcome)
# F1: 不可恢复 outcome → 立刻标 failed + 写黑板
if outcome in ("auth_failed", "agent_error") and db_path and task_id:
logger.error("Task %s: unrecoverable outcome=%s, marking failed immediately", task_id, outcome)
if outcome in ("auth_failed",
"agent_error") and db_path and task_id:
logger.error(
"Task %s: unrecoverable outcome=%s, marking failed immediately",
task_id,
outcome)
self._mark_task(db_path, task_id, "failed", {
"reason": outcome,
"stderr_preview": (stderr_text or "")[:500],
@@ -881,13 +952,16 @@ curl -X POST http://{api_host}:{api_port}/api/projects/{project_id}/tasks/{task_
except Exception:
pass
_ = b"".join(stderr_chunks).decode("utf-8", errors="replace")
# stderr collected but not used in this handler
# (kept for potential future diagnostics)
b"".join(stderr_chunks).decode("utf-8", errors="replace")
# 检查 session 状态
state = self._check_session_state(agent_id)
# B1: 假死 - 先复活,连续假死 ≥2 次再 failed
if state.get("status") == "running" and not state.get("lock_pid_alive", True):
if state.get("status") == "running" and not state.get(
"lock_pid_alive", True):
# 假死计数
stuck_count = self._stuck_counts.get(task_id, 0) + 1
self._stuck_counts[task_id] = stuck_count
@@ -913,7 +987,8 @@ curl -X POST http://{api_host}:{api_port}/api/projects/{project_id}/tasks/{task_
await self._do_on_complete_async(on_complete, agent_id, "session_revived")
else:
# 复活失败 → 标 failed
logger.error("Agent %s revive failed, marking failed", agent_id)
logger.error(
"Agent %s revive failed, marking failed", agent_id)
self._mark_task(db_path, task_id, "failed",
{"reason": "revive_failed", "stuck_count": stuck_count,
"diagnostics": state})
@@ -994,7 +1069,8 @@ curl -X POST http://{api_host}:{api_port}/api/projects/{project_id}/tasks/{task_
"SELECT status FROM tasks WHERE id=?", (task_id,)
).fetchone()
# Bug-6 fix: pending 不是终态
if row and row["status"] in ("done", "failed", "cancelled", "review"):
if row and row["status"] in (
"done", "failed", "cancelled", "review"):
logger.info("Retry skip: task %s already %s (agent=%s)",
task_id, row["status"], agent_id)
# on_complete = wrapped_on_complete,会 release counter
@@ -1003,7 +1079,8 @@ curl -X POST http://{api_host}:{api_port}/api/projects/{project_id}/tasks/{task_
finally:
conn.close()
except Exception:
logger.warning("Retry status check failed for %s, proceeding", task_id)
logger.warning(
"Retry status check failed for %s, proceeding", task_id)
# 直接读写 tasks 表的 retry_count
if retry_field == "retry_count" and db_path and task_id:
@@ -1023,7 +1100,8 @@ curl -X POST http://{api_host}:{api_port}/api/projects/{project_id}/tasks/{task_
finally:
conn.close()
except Exception:
logger.exception("Failed to update retry_count for task %s", task_id)
logger.exception(
"Failed to update retry_count for task %s", task_id)
count = 1
else:
retry_counts = self._get_retry_counts(db_path, task_id)
@@ -1046,9 +1124,10 @@ curl -X POST http://{api_host}:{api_port}/api/projects/{project_id}/tasks/{task_
# 构建续杯 message(Mail 用专用模板,Task 用标准模板)
task_info = self._get_task_info(db_path, task_id) or {}
project_id = task_info.get("project_id", "")
is_mail = project_id == "_mail"
handler = TaskTypeRegistry.get_by_project(project_id)
is_handler = handler is not None
if is_mail:
if is_handler:
must_haves = task_info.get("must_haves", "{}")
try:
meta = json.loads(must_haves) if must_haves else {}
@@ -1107,7 +1186,8 @@ curl -X POST http://{api_host}:{api_port}/api/projects/{project_id}/tasks/{task_
"""
text = stdout_text.strip()
if not text:
return {"status": None, "summary": None, "fallback_used": False, "fallback_reason": None, "payloads": []}
return {"status": None, "summary": None, "fallback_used": False,
"fallback_reason": None, "payloads": []}
try:
data = json.loads(text)
except json.JSONDecodeError:
@@ -1119,7 +1199,8 @@ curl -X POST http://{api_host}:{api_port}/api/projects/{project_id}/tasks/{task_
except json.JSONDecodeError:
continue
else:
return {"status": None, "summary": None, "fallback_used": False, "fallback_reason": None, "payloads": []}
return {"status": None, "summary": None, "fallback_used": False,
"fallback_reason": None, "payloads": []}
# 从 data.result.meta.executionTrace 取 fallback 信息
result = data.get("result", {})
@@ -1135,7 +1216,8 @@ curl -X POST http://{api_host}:{api_port}/api/projects/{project_id}/tasks/{task_
}
@staticmethod
def _get_task_status(db_path: Optional[Path], task_id: Optional[str]) -> Optional[str]:
def _get_task_status(
db_path: Optional[Path], task_id: Optional[str]) -> Optional[str]:
"""查任务实际 API 状态"""
if not db_path or not task_id:
return None
@@ -1152,7 +1234,8 @@ curl -X POST http://{api_host}:{api_port}/api/projects/{project_id}/tasks/{task_
return None
@staticmethod
def _get_task_info(db_path: Optional[Path], task_id: Optional[str]) -> Optional[dict]:
def _get_task_info(db_path: Optional[Path],
task_id: Optional[str]) -> Optional[dict]:
"""查任务基本信息"""
if not db_path or not task_id:
return None
@@ -1160,7 +1243,8 @@ 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=?", (task_id,)
"SELECT id, title, status FROM tasks WHERE id=?", (
task_id,)
).fetchone()
if not row:
return None
@@ -1192,7 +1276,9 @@ curl -X POST http://{api_host}:{api_port}/api/projects/{project_id}/tasks/{task_
sessions[main_key] = main_session
with open(sessions_path, "w") as f:
json.dump(sessions, f, indent=2)
logger.info("Revived %s: sessions.json status changed running→idle", agent_id)
logger.info(
"Revived %s: sessions.json status changed running→idle",
agent_id)
# #07 O4: 同时清理残留 lock 文件
sf = main_session.get("sessionFile", "")
if sf:
@@ -1200,7 +1286,10 @@ curl -X POST http://{api_host}:{api_port}/api/projects/{project_id}/tasks/{task_
if lock_path.exists():
try:
lock_path.unlink()
logger.info("Cleaned stale lock for %s: %s", agent_id, lock_path.name)
logger.info(
"Cleaned stale lock for %s: %s",
agent_id,
lock_path.name)
except Exception:
pass
return True
@@ -1209,7 +1298,90 @@ curl -X POST http://{api_host}:{api_port}/api/projects/{project_id}/tasks/{task_
return False
@staticmethod
def _check_recent_compaction_jsonl(session_file: str, window_seconds: int = 900) -> bool:
def _get_recent_gateway_logs() -> list:
"""获取当天和昨天的 gateway 日志路径。
日志路径通过 OPENCLAW_LOG_DIR 环境变量配置默认 /tmp/openclaw
文件名格式openclaw-{YYYY-MM-DD}.log
"""
from datetime import timedelta
log_dir = os.environ.get("OPENCLAW_LOG_DIR", "/tmp/openclaw")
now_local = datetime.now()
today = now_local.strftime("%Y-%m-%d")
yesterday = (now_local - timedelta(days=1)).strftime("%Y-%m-%d")
paths = []
for d in [today, yesterday]:
p = os.path.join(log_dir, f"openclaw-{d}.log")
if os.path.exists(p):
paths.append(p)
return paths
@staticmethod
def _check_compact_in_progress_gateway(
session_key: str, window_seconds: int = 120) -> bool:
"""§24 v3 rotation-only: 检查 gateway 日志,判断指定 session 是否刚完成 compact。
检测逻辑读日志尾部 2MB按目标 sessionKey 过滤
找最后一个 rotation 事件如果在窗口内 compact 可能仍在 retry 循环中
"""
from datetime import datetime as _dt, timezone as _tz, timedelta
log_paths = AgentSpawner._get_recent_gateway_logs()
if not log_paths:
return False
now = _dt.now(_tz.utc)
window_start = now - timedelta(seconds=window_seconds)
last_rotation_time = None
for log_path in log_paths:
if not os.path.exists(log_path):
continue
try:
with open(log_path, "rb") as f:
f.seek(0, 2)
size = f.tell()
f.seek(max(0, size - 2 * 1024 * 1024))
tail = f.read().decode("utf-8", errors="replace")
except Exception:
continue
for line in tail.splitlines():
if not line.strip():
continue
try:
obj = json.loads(line)
except (json.JSONDecodeError, ValueError):
continue
msg = obj.get("message", "")
# 只看包含目标 sessionKey 的事件
if session_key not in msg:
continue
# rotation 事件
if "[compaction] rotated active transcript" in msg:
ts_str = obj.get("time", "")
if ts_str:
try:
event_time = _dt.fromisoformat(
ts_str.replace("Z", "+00:00"))
# timezone-aware: normalize to UTC
if event_time.tzinfo is None:
event_time = event_time.replace(tzinfo=_tz.utc)
if last_rotation_time is None or event_time > last_rotation_time:
last_rotation_time = event_time
except (ValueError, TypeError):
continue
if last_rotation_time is not None:
return last_rotation_time >= window_start
return False
@staticmethod
def _check_recent_compaction_jsonl(
session_file: str, window_seconds: int = 900) -> bool:
"""v2.8.2 Fix-2: 读 session jsonl 末尾,检查是否有 window_seconds 内的 compaction 记录。
compactionCheckpoints 更可靠:Gateway 每次完成 compact 必然在 jsonl 末尾追加记录,
@@ -1219,7 +1391,7 @@ curl -X POST http://{api_host}:{api_port}/api/projects/{project_id}/tasks/{task_
实测 50KB 在长对话中不够compact 记录被推出窗口导致漏检
正常扫描量不变从尾部往前扫遇到超过 15min timestamp break
"""
if not session_file or not pathlib.Path(session_file).exists():
if not session_file or not Path(session_file).exists():
return False
try:
from datetime import datetime, timezone
@@ -1241,7 +1413,8 @@ curl -X POST http://{api_host}:{api_port}/api/projects/{project_id}/tasks/{task_
ts = obj.get("timestamp", "")
if ts:
try:
ct = datetime.fromisoformat(ts.replace("Z", "+00:00"))
ct = datetime.fromisoformat(
ts.replace("Z", "+00:00"))
if (now - ct).total_seconds() < window_seconds:
return True
except (ValueError, TypeError):
@@ -1265,7 +1438,11 @@ curl -X POST http://{api_host}:{api_port}/api/projects/{project_id}/tasks/{task_
v2.8.1: compact 检测改用 session jsonl 末尾扫描(Fix-1),
替代失效的 compactionCheckpoints 检测
"""
result = {"status": "unknown", "lock_pid": None, "lock_pid_alive": False, "recent_compact": False}
result = {
"status": "unknown",
"lock_pid": None,
"lock_pid_alive": False,
"recent_compact": False}
sessions_path = Path(os.environ.get(
"OPENCLAW_HOME", str(Path.home() / ".openclaw")
)) / "agents" / agent_id / "sessions" / "sessions.json"
@@ -1304,8 +1481,10 @@ curl -X POST http://{api_host}:{api_port}/api/projects/{project_id}/tasks/{task_
created_at_str = lock_data.get("createdAt", "")
if created_at_str:
from datetime import datetime as _dt, timezone as _tz
created_dt = _dt.fromisoformat(created_at_str.replace("Z", "+00:00"))
elapsed = (_dt.now(_tz.utc) - created_dt).total_seconds()
created_dt = _dt.fromisoformat(
created_at_str.replace("Z", "+00:00"))
elapsed = (_dt.now(_tz.utc) -
created_dt).total_seconds()
if elapsed > 1800: # 30 minutes
result["lock_pid_alive"] = False
result["lock_expired"] = True
@@ -1316,10 +1495,17 @@ curl -X POST http://{api_host}:{api_port}/api/projects/{project_id}/tasks/{task_
except Exception:
pass
# v2.8.1 Fix-1: compact 检测改用 session jsonl 末尾扫描
# 只在 agent 非空闲时才扫描(减少不必要 I/O)
if result["status"] not in ("done", "idle", "unknown", None) and sf:
result["recent_compact"] = AgentSpawner._check_recent_compaction_jsonl(sf)
# §24 v3: compact 检测优先用 gateway 日志 rotation 事件
# 旧方法 _check_recent_compaction_jsonl 作为 fallback
# 重要:compact 进行中时 status=done,所以不能按 status 过滤
# 只跳过 idle/unknown(完全没有活动过的 session)
if result["status"] not in ("idle", "unknown", None):
session_key = f"agent:{agent_id}:main"
result["recent_compact"] = AgentSpawner._check_compact_in_progress_gateway(
session_key)
if not result["recent_compact"] and sf:
result["recent_compact"] = AgentSpawner._check_recent_compaction_jsonl(
sf)
except Exception:
pass
return result
@@ -1364,45 +1550,53 @@ curl -X POST http://{api_host}:{api_port}/api/projects/{project_id}/tasks/{task_
# A15/A16: stderr 含 network/compact 关键字 → 可恢复
if stderr_text:
stderr_lower = stderr_text.lower()
if any(kw in stderr_lower for kw in ["econnrefused", "etimedout", "gateway closed", "econnreset"]):
if any(kw in stderr_lower for kw in [
"econnrefused", "etimedout", "gateway closed", "econnreset"]):
return {"outcome": "gateway_unreachable", "should_retry": True,
"retry_field": "retry_count", "cooldown_seconds": 60}
if any(kw in stderr_lower for kw in ["compaction-diag", "context-overflow"]):
if any(kw in stderr_lower for kw in [
"compaction-diag", "context-overflow"]):
return {"outcome": "compact_interrupted", "should_retry": True,
"retry_field": "retry_count", "cooldown_seconds": 60}
# A17: 真正的 crash → 保持 working,ticker 兜底
return {"outcome": "crashed", "should_retry": False, "original": "process_crash"}
return {"outcome": "crashed", "should_retry": False,
"original": "process_crash"}
# stdout 为空但 exit=0:可能是正常完成但 --json 没输出
# 查任务状态判断
# A13 revised: stdout 为空但 exit=0 → 信任进程退出码,视为正常完成
# 实测发现 openclaw session=None + exit=0 是正常场景(inform 通知等)
# 旧逻辑按 task_status 区分,非终态判 agent_error → 导致 inform 邮件永不标 done
if status is None and not stdout_text.strip() and exit_code == 0:
terminal_statuses = {"done", "review"}
if task_status in terminal_statuses:
return {"outcome": "completed", "should_retry": False}
return {"outcome": "agent_error", "should_retry": False}
return {"outcome": "completed", "should_retry": False}
# A7-A12: status=error → 不续杯,stderr 辅助分类
if status == "error":
stderr_lower = stderr_text.lower()
if any(kw in stderr_lower for kw in ["401", "403", "unauthorized", "auth"]):
if any(kw in stderr_lower for kw in [
"401", "403", "unauthorized", "auth"]):
return {"outcome": "auth_failed", "should_retry": False}
if any(kw in stderr_lower for kw in ["econnrefused", "etimedout", "gateway closed", "econnreset"]):
if any(kw in stderr_lower for kw in [
"econnrefused", "etimedout", "gateway closed", "econnreset"]):
return {"outcome": "gateway_unreachable", "should_retry": True,
"retry_field": "retry_count", "cooldown_seconds": 60}
if any(kw in stderr_lower for kw in ["rate_limit", "500", "503", "api error"]):
if any(kw in stderr_lower for kw in [
"rate_limit", "500", "503", "api error"]):
return {"outcome": "api_error", "should_retry": False}
if any(kw in stderr_lower for kw in ["compaction-diag", "context-overflow"]):
if any(kw in stderr_lower for kw in [
"compaction-diag", "context-overflow"]):
return {"outcome": "compact_failed", "should_retry": False}
if any(kw in stderr_lower for kw in ["lock", "busy", "concurrent", "lane task error"]):
if any(kw in stderr_lower for kw in [
"lock", "busy", "concurrent", "lane task error"]):
return {"outcome": "lock_conflict", "should_retry": True,
"retry_field": "retry_count", "cooldown_seconds": 60}
return {"outcome": "agent_error", "should_retry": False}
# 兜底:status 未知值
return {"outcome": "agent_error", "should_retry": False, "original": "unknown_status"}
return {"outcome": "agent_error",
"should_retry": False, "original": "unknown_status"}
@staticmethod
def _get_retry_counts(db_path: Optional[Path], task_id: Optional[str]) -> dict:
def _get_retry_counts(
db_path: Optional[Path], task_id: Optional[str]) -> dict:
"""从最新 task_attempt 的 metadata 读计数器"""
defaults = {"retry_count": 0, "connect_retry_count": 0,
"api_retry_count": 0, "lock_retry_count": 0,
@@ -1442,7 +1636,8 @@ curl -X POST http://{api_host}:{api_port}/api/projects/{project_id}/tasks/{task_
(task_id,)
).fetchone()
if row:
meta = json.loads(row["metadata"]) if row["metadata"] else {}
meta = json.loads(
row["metadata"]) if row["metadata"] else {}
meta.update(counts)
conn.execute(
"UPDATE task_attempts SET metadata=? WHERE rowid=?",
@@ -1452,7 +1647,8 @@ curl -X POST http://{api_host}:{api_port}/api/projects/{project_id}/tasks/{task_
finally:
conn.close()
except Exception:
logger.exception("Failed to update retry counts for task %s", task_id)
logger.exception(
"Failed to update retry counts for task %s", task_id)
def _mark_task(self, db_path: Optional[Path], task_id: Optional[str],
status: str, detail: Optional[dict] = None):
@@ -1470,7 +1666,8 @@ curl -X POST http://{api_host}:{api_port}/api/projects/{project_id}/tasks/{task_
if detail:
conn.execute(
"INSERT INTO events (task_id, agent, event_type, detail) VALUES (?,?,?,?)",
(task_id, "daemon", status, json.dumps(detail, ensure_ascii=False))
(task_id, "daemon", status, json.dumps(
detail, ensure_ascii=False))
)
conn.commit()
finally:
@@ -1491,7 +1688,10 @@ curl -X POST http://{api_host}:{api_port}/api/projects/{project_id}/tasks/{task_
f"@pangtong-fujunshi 任务执行失败: {reason},请评估是否需要介入",
comment_type="system")
bb.record_mentions(cid, task_id, ["pangtong-fujunshi"])
logger.info("Task %s: failure notified pangtong via comment+mention (reason=%s)", task_id, reason)
logger.info(
"Task %s: failure notified pangtong via comment+mention (reason=%s)",
task_id,
reason)
except Exception as e:
logger.warning("Task %s: failed to notify: %s", task_id, e)
except Exception:
@@ -1520,7 +1720,10 @@ curl -X POST http://{api_host}:{api_port}/api/projects/{project_id}/tasks/{task_
if asyncio.iscoroutine(result):
await result
except Exception:
logger.warning("on_complete callback failed for %s", agent_id, exc_info=True)
logger.warning(
"on_complete callback failed for %s",
agent_id,
exc_info=True)
def _register_session(
self,
@@ -1598,7 +1801,8 @@ curl -X POST http://{api_host}:{api_port}/api/projects/{project_id}/tasks/{task_
def get_session_by_agent(self, agent_id: str) -> Optional[Dict[str, Any]]:
"""v2.7.2: 根据 agent_id 获取活跃 session 信息(用于进程存活性检查)"""
for sid, info in self._sessions.items():
if info.get("agent_id") == agent_id and info.get("status") == "running":
if info.get("agent_id") == agent_id and info.get(
"status") == "running":
return info
return None
+2 -1
View File
@@ -49,7 +49,8 @@ class SSEEvent:
"""格式化为 SSE 协议文本"""
lines = [f"id: {self.id}"]
lines.append(f"event: {self.event_type}")
lines.append(f"data: {json.dumps(self.data, ensure_ascii=False, default=str)}")
lines.append(
f"data: {json.dumps(self.data, ensure_ascii=False, default=str)}")
return "\n".join(lines) + "\n\n"
+387
View File
@@ -0,0 +1,387 @@
"""task_handler.py — 黑板任务 handlertask_type='task')。
标准黑板任务三信号验证 review 状态
"""
from __future__ import annotations
import logging
import os
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
from src.blackboard.db import get_connection
logger = logging.getLogger("moziplus-v2.handler")
TERMINAL_STATES = frozenset({"review", "done", "failed", "cancelled"})
# ---------------------------------------------------------------------------
# Role → Skill 映射(D8 决策:L2 只给索引+引导语,不注全文)
# ---------------------------------------------------------------------------
ROLE_SKILL_MAP: Dict[str, str] = {
"executor": "blackboard-executor",
"reviewer": "blackboard-reviewer",
"reviewer-simayi": "blackboard-reviewer-simayi",
"reviewer-pangtong": "blackboard-reviewer-pangtong",
"planner": "blackboard-planner",
"claim": "blackboard-claim",
}
SKILL_BASE_PATH = os.environ.get(
"MOZI_SKILL_PATH",
"/Users/chufeng/.sanguo_projects/sanguo_mozi/skills",
)
# ---------------------------------------------------------------------------
# PromptSection 实现
# ---------------------------------------------------------------------------
class TaskContextSection:
"""段 1:任务上下文(title / desc / must_haves / status)。"""
name: str = "task_context"
priority: int = 10
def render(self, context: PromptContext) -> str:
parts = ["## 任务上下文"]
if context.task_id:
parts.append(f"任务ID: {context.task_id}")
if context.title:
parts.append(f"标题: {context.title}")
if context.description:
parts.append(f"描述: {context.description}")
if context.must_haves:
parts.append(f"必须完成: {context.must_haves}")
if context.task and context.task.get("status"):
parts.append(f"当前状态: {context.task['status']}")
return "\n".join(parts)
def should_include(self, context: PromptContext) -> bool:
return bool(context.task_id or context.title)
class PriorOutputsSection:
"""段 2:前序产出摘要(depends_on 非空时注入)。"""
name: str = "prior_outputs"
priority: int = 20
def render(self, context: PromptContext) -> str:
outputs = context.depends_on_outputs or []
parts = ["## 前序产出"]
for out in outputs:
tid = out.get("task_id", "?")
summary = out.get("summary", "无摘要")
parts.append(f"- [{tid}] {summary}")
return "\n".join(parts)
def should_include(self, context: PromptContext) -> bool:
return bool(context.depends_on_outputs)
class RoleSkillSection:
"""段 3:角色 Skill 全文注入(对齐设计 §2.3 + BootstrapBuilder 行为)。"""
name: str = "role_skill"
priority: int = 30
def render(self, context: PromptContext) -> str:
skill_name = ROLE_SKILL_MAP.get(context.role, "")
lines = [
"## 角色操作规范",
f"你的角色:{context.role}",
]
if skill_name:
skill_path = os.path.join(SKILL_BASE_PATH, skill_name, "SKILL.md")
try:
with open(skill_path, encoding="utf-8") as f:
skill_content = f.read()
if skill_content:
lines.append(skill_content)
else:
lines.append(f"Skill 文件为空:{skill_name}")
except FileNotFoundError:
lines.append(f"Skill 文件不存在:{skill_name}")
else:
lines.append("无对应 Skill 文件,按通用规范执行。")
return "\n".join(lines)
def should_include(self, context: PromptContext) -> bool:
return True
class TaskApiSection:
"""段 4API 操作指令。"""
name: str = "task_api"
priority: int = 40
API_HOST = "localhost"
API_PORT = 8083
def render(self, context: PromptContext) -> str:
pid = context.project_id
tid = context.task_id
aid = context.agent_id
success_status = '"review"'
base = f"http://{self.API_HOST}:{self.API_PORT}/api/projects/{pid}/tasks/{tid}"
return (
"## 操作指令\n"
"### 状态回写\n"
f"开始工作:\n"
f'curl -X POST {base}/status \\\n'
f' -H "Content-Type: application/json" \\\n'
f' -d \'{{"status": "working", "agent": "{aid}"}}\'\n\n'
"### 写入产出\n"
f'curl -X POST {base}/outputs \\\n'
f' -H "Content-Type: application/json" \\\n'
f" -d '{{\"type\": \"text\", \"content\": \"<your output>\"}}'\n\n"
"### 完成后\n"
f"成功: status → {success_status} | 失败: status → \"failed\""
)
def should_include(self, context: PromptContext) -> bool:
return True
class TaskConstraintsSection:
"""段 5:硬约束。"""
name: str = "task_constraints"
priority: int = 50
def render(self, context: PromptContext) -> str:
constraints = ["## 硬约束"]
role = context.role
if role == "executor":
constraints.extend([
"- 完成后必须标 review",
"- 产出物不能为空(系统会验证)",
"- handoff comment ≥ 50 字符",
])
elif role.startswith("reviewer"):
constraints.extend([
"- 审查结果必须明确 pass/fail",
"- 评审意见须附证据(文件:行号)",
])
elif role == "planner":
constraints.extend([
"- 需求不清时提问,不要猜",
"- 子任务必须有明确的终态定义",
])
else:
constraints.append("- 按规范完成 assigned 任务")
return "\n".join(constraints)
def should_include(self, context: PromptContext) -> bool:
return True
class TaskHandler(BaseTaskHandler):
"""黑板标准任务 handler。
- verify: 三信号检查output / comment / terminal status
- 成功 review
- 失败 保持 working ticker 重试
- review 完成 读取 verdictapproved mark done
"""
task_type: str = "task"
virtual_project: Optional[str] = None
display_name = "黑板任务"
# === 子类实现 ===
def post_complete(self, task_id: str, agent_id: str,
outcome: str, db_path: Path) -> None:
"""Task on_complete:区分 executor 和 review。
executor: 基类统一流程crash verify mark review
review: handle_review_complete verdict done/keep review
"""
# crash 处理(所有类型共用)
if outcome in self.CRASH_OUTCOMES:
self._rollback_current_agent(db_path, task_id, agent_id)
return
# 检查当前任务状态:如果是 review 状态 → review 完成流程
try:
conn = get_connection(db_path)
try:
row = conn.execute(
"SELECT status FROM tasks WHERE id=?", (task_id,)
).fetchone()
task_status = row["status"] if row else "unknown"
finally:
conn.close()
except Exception:
task_status = "unknown"
if task_status == "review":
# review 完成流程:只处理正常 outcome
if outcome in ("completed", "session_revived"):
self.handle_review_complete(task_id, db_path)
else:
logger.warning(
"Task %s: review agent %s abnormal outcome=%s, keeping review",
task_id, agent_id, outcome)
else:
# executor 完成流程:基类统一 verify → mark
result = self.verify_completion(task_id, db_path)
if result.passed:
self._mark_task_status(db_path, task_id, self.target_success_status())
logger.info("Task %s: verify passed (%s), marked %s",
task_id, result.reason, self.target_success_status())
else:
logger.info(
"Task %s: verify not passed (%s), leaving working",
task_id, result.reason)
# NOTE: executor verify 不通过时不标 failed,留 working。
# 原因:Agent 可能还在产出中(幻觉门控的后续轮次),
# ticker 超时检查会兜底处理。不调 on_failure 避免误判。
def target_success_status(self) -> str:
"""task 类型验证通过后进 review。"""
return "review"
def verify_completion(self, task_id: str, db_path: Path) -> VerifyResult:
"""三信号验证:output / comment / terminal status。"""
try:
conn = get_connection(db_path)
try:
# 信号 1terminal status
row = conn.execute(
"SELECT status FROM tasks WHERE id=?", (task_id,)
).fetchone()
if not row:
return VerifyResult(False, "not_found", "task not found",
can_retry=False)
status = row["status"]
if status in TERMINAL_STATES:
return VerifyResult(
True, "terminal_status",
f"status={status}", can_retry=False
)
# 信号 2outputs
output_count = conn.execute(
"SELECT COUNT(*) as cnt FROM outputs WHERE task_id=?",
(task_id,)
).fetchone()["cnt"]
if output_count > 0:
return VerifyResult(
True, "has_output",
f"output_count={output_count}"
)
# 信号 3:非 system 且内容 >= 50 字的 comment
comment_count = conn.execute(
"SELECT COUNT(*) as cnt FROM comments "
"WHERE task_id=? AND author != 'system' "
"AND LENGTH(content) >= 50",
(task_id,)
).fetchone()["cnt"]
if comment_count > 0:
return VerifyResult(
True, "has_comment",
f"comment_count={comment_count}"
)
# 无信号
return VerifyResult(
False, "no_signal",
f"output=0, comment=0, status={status}"
)
finally:
conn.close()
except Exception as e:
logger.error("Task %s: verify error: %s", task_id, e)
return VerifyResult(False, "verify_error", str(e))
def pre_spawn(self, task_id: str, db_path: Path) -> bool:
"""task 类型不需要 pre_spawn 逻辑。"""
return True
def get_sections(self) -> list:
"""返回 5 个 PromptSection 实例。"""
return [
TaskContextSection(),
PriorOutputsSection(),
RoleSkillSection(),
TaskApiSection(),
TaskConstraintsSection(),
]
def build_prompt(self, context: PromptContext) -> str:
"""通过 PromptComposer 拼装 prompt sections。"""
composer = PromptComposer()
composer.add_many(self.get_sections())
return composer.compose(context)
def on_failure(self, task_id: str, agent_id: str,
db_path: Path, verify: VerifyResult) -> None:
"""验证失败:不标 failed,保持 working 让 ticker 重试。"""
logger.info(
"Task %s: verify failed (%s, evidence=%s), leaving working for ticker retry",
task_id, verify.reason, verify.evidence
)
# === Review 流程 ===
def handle_review_complete(self, task_id: str, db_path: Path) -> None:
"""Review 完成后处理:读取 verdict → approved 则 mark done
否则 @mention assignee via blackboard comment"""
try:
conn = get_connection(db_path)
try:
# 读取最新 review
review_row = conn.execute(
"SELECT verdict, reviewer, comment FROM reviews "
"WHERE task_id=? ORDER BY created_at DESC LIMIT 1",
(task_id,)
).fetchone()
if not review_row:
logger.warning("Task %s: no review found", task_id)
return
verdict = review_row["verdict"]
reviewer = review_row["reviewer"]
review_comment = review_row["comment"] or ""
# 获取 assignee
task_row = conn.execute(
"SELECT assignee FROM tasks WHERE id=?", (task_id,)
).fetchone()
if not task_row:
logger.warning("Task %s: task not found for review", task_id)
return
assignee = task_row["assignee"]
if verdict == "approved":
self._mark_task_status(db_path, task_id, "done")
logger.info("Task %s: review approved by %s, marked done",
task_id, reviewer)
else:
# 非 approved:通过 blackboard comment @mention assignee
# 保持 review 状态,让 assignee 自行决定下一步
conn.execute(
"INSERT INTO comments (task_id, author, content, comment_type) "
"VALUES (?, 'system', ?, 'review')",
(task_id,
f"@{assignee} review 未通过 (verdict={verdict}, "
f"reviewer={reviewer}): {review_comment}")
)
conn.commit()
logger.info(
"Task %s: review not approved (%s by %s), "
"@mentioned assignee %s, keeping review status",
task_id, verdict, reviewer, assignee
)
finally:
conn.close()
except Exception as e:
logger.error("Task %s: handle_review_complete error: %s", task_id, e)
+102
View File
@@ -0,0 +1,102 @@
"""
task_type_registry.py Task type handler Protocol + Registry.
启动时一次性加载 handler运行时只读
零依赖不导入项目内其他模块
"""
from __future__ import annotations
import logging
from pathlib import Path
from typing import TYPE_CHECKING, Dict, Optional, Protocol, runtime_checkable
if TYPE_CHECKING:
from src.daemon.prompt_composer import PromptContext
logger = logging.getLogger("moziplus-v2.registry")
# ---------------------------------------------------------------------------
# Protocol
# ---------------------------------------------------------------------------
@runtime_checkable
class TaskTypeHandler(Protocol):
"""所有 task type handler 的统一接口。"""
# 属性(通过 __init__ 设置)
task_type: str # 类型标识:'task' | 'mail' | 'toolchain'
virtual_project: Optional[str] # 虚拟项目 ID,如 '_mail'、'_toolchain'。普通任务为 None
def build_prompt(self, context: "PromptContext") -> str:
"""构建 Agent prompt(通过 PromptComposer 拼 section)。"""
...
def pre_spawn(self, task_id: str, db_path: Path) -> bool:
"""spawn 前业务准备。默认 Truemail/toolchain override 为 auto_working。"""
...
def post_complete(
self,
task_id: str,
agent_id: str,
outcome: str,
db_path: Path,
) -> None:
"""spawn 完成后的业务处理。统一流程:crash→verify→mark→notify。"""
...
def check_completion(self, task_id: str, db_path: Path) -> bool:
"""ticker 级别的完成检查。"""
...
def get_sections(self) -> list:
"""返回此 handler 的 prompt section 列表。"""
...
# ---------------------------------------------------------------------------
# Registry
# ---------------------------------------------------------------------------
class TaskTypeRegistry:
"""Task type handler 注册表。启动时一次性加载,运行时只读。"""
_handlers: Dict[str, TaskTypeHandler] = {}
@classmethod
def register(cls, handler: TaskTypeHandler) -> None:
"""注册一个 handler。启动时调用一次。"""
if handler.task_type in cls._handlers:
raise ValueError(f"Task type '{handler.task_type}' already registered")
cls._handlers[handler.task_type] = handler
vp = getattr(handler, "virtual_project", None)
logger.info("Registered task type handler: %s (virtual_project=%s)", handler.task_type, vp)
@classmethod
def get_by_project(cls, project_id: str) -> Optional[TaskTypeHandler]:
"""通过 project_id 查找 handler(匹配 virtual_project)。"""
for h in cls._handlers.values():
if h.virtual_project == project_id:
return h
return None
@classmethod
def get(cls, task_type: str) -> Optional[TaskTypeHandler]:
"""通过 task_type 标识查找 handler。"""
return cls._handlers.get(task_type)
@classmethod
def virtual_projects(cls) -> list[str]:
"""返回所有已注册的虚拟项目 ID(ticker 自动发现用)。"""
return [
h.virtual_project
for h in cls._handlers.values()
if h.virtual_project is not None
]
@classmethod
def clear(cls) -> None:
"""清空注册表(仅测试用)。"""
cls._handlers = {}
+224 -112
View File
@@ -19,6 +19,8 @@ from typing import Any, Callable, Coroutine, Dict, List, Optional
from dataclasses import dataclass, field as dc_field
from src.daemon.task_type_registry import TaskTypeRegistry
from src.blackboard.operations import Blackboard
from src.blackboard.db import get_connection
from src.daemon.spawner import AgentBusyError
@@ -31,7 +33,8 @@ class BroadcastRound:
"""追踪单个任务的广播状态"""
task_id: str
notified_agents: set = dc_field(default_factory=set) # 已 spawn 过的 Agent
responded_agents: set = dc_field(default_factory=set) # 已返回反馈的 Agent(含 NO_REPLY
responded_agents: set = dc_field(
default_factory=set) # 已返回反馈的 Agent(含 NO_REPLY
round_number: int = 0 # 当前第几轮(0=未开始,1=第1轮)
@@ -46,7 +49,8 @@ class Ticker:
registry: ProjectRegistry,
tick_interval: float = 30.0,
max_ticks: Optional[int] = None,
on_tick_complete: Optional[Callable[[], Coroutine[Any, Any, None]]] = None,
on_tick_complete: Optional[Callable[[],
Coroutine[Any, Any, None]]] = None,
dispatcher: Optional[Any] = None,
spawner: Optional[Any] = None,
max_dispatch_per_tick: int = 3,
@@ -194,7 +198,10 @@ class Ticker:
pr = await self._tick_project(project_id, project_info)
results["projects"][project_id] = pr
except Exception as e:
logger.exception("Tick %d project %s error", tick_num, project_id)
logger.exception(
"Tick %d project %s error",
tick_num,
project_id)
results["projects"][project_id] = {"error": str(e)}
# 虚拟项目 _general:不在 registry 但需要调度
@@ -210,20 +217,26 @@ class Ticker:
logger.exception("Tick %d _general error", tick_num)
results["projects"]["_general"] = {"error": str(e)}
# 虚拟项目 _mail:飞鸽传书
mail_db = Path(self.registry.root) / "_mail" / "blackboard.db"
if mail_db.exists() and "_mail" not in active_projects:
try:
pr = await self._tick_project("_mail", {
"id": "_mail", "name": "飞鸽传书",
"status": "active", "source": "virtual",
})
results["projects"]["_mail"] = pr
except Exception as e:
logger.exception("Tick %d _mail error", tick_num)
results["projects"]["_mail"] = {"error": str(e)}
# 虚拟项目:从注册表自动发现 + _general 硬编码
for vp in TaskTypeRegistry.virtual_projects():
vp_db = Path(self.registry.root) / vp / "blackboard.db"
if vp_db.exists() and vp not in active_projects:
try:
vp_handler = TaskTypeRegistry.get_by_project(vp)
vp_name = vp_handler.display_name if vp_handler and vp_handler.display_name else vp
pr = await self._tick_project(vp, {
"id": vp, "name": vp_name,
"status": "active", "source": "virtual",
})
results["projects"][vp] = pr
except Exception as e:
logger.exception("Tick %d %s error", tick_num, vp)
results["projects"][vp] = {"error": str(e)}
logger.debug("Tick %d complete: %d projects", tick_num, len(active_projects))
logger.debug(
"Tick %d complete: %d projects",
tick_num,
len(active_projects))
if self.on_tick_complete:
try:
@@ -314,7 +327,8 @@ class Ticker:
# 8. 健康检查(僵尸检测)
if self.health_checker:
try:
self.health_checker.check(project_id, db_path, self._tick_count)
self.health_checker.check(
project_id, db_path, self._tick_count)
except Exception as e:
logger.warning("HealthChecker error for %s: %s", project_id, e)
@@ -335,7 +349,8 @@ class Ticker:
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.warning(
"ExperienceDistiller error for %s: %s", project_id, e)
# 10. 扫描后状态
result["summary_after"] = queries.task_summary()
@@ -375,7 +390,8 @@ class Ticker:
(computed, pid),
)
refreshed.append(pid)
logger.info("Parent %s status aggregated: → %s", pid, computed)
logger.info(
"Parent %s status aggregated: → %s", pid, computed)
if refreshed:
conn.commit()
@@ -543,6 +559,7 @@ Parent Task ID: {parent_task.id}
"""
try:
agent_id = "pangtong-fujunshi"
f"review-{parent_task.id}-r{new_round}"
# 构造 on_complete 回调:解析庞统结论,更新 parent 状态
async def _on_review_complete(aid: str, outcome: str):
@@ -554,7 +571,8 @@ Parent Task ID: {parent_task.id}
latest_meta = None
latest_time = ""
for sid, sess in self.spawner._sessions.items():
if sess.get("agent_id") == agent_id and sess.get("meta"):
if sess.get(
"agent_id") == agent_id and sess.get("meta"):
t = sess.get("completed_at", "")
if t > latest_time:
latest_time = t
@@ -586,7 +604,9 @@ Parent Task ID: {parent_task.id}
return True
return False
except Exception:
logger.exception("Failed to spawn pangtong review for %s", parent_task.id)
logger.exception(
"Failed to spawn pangtong review for %s",
parent_task.id)
return False
def _set_parent_reviewing(self, parent_id: str, project_id: str):
@@ -618,7 +638,8 @@ Parent Task ID: {parent_task.id}
conn = get_connection(db_path)
try:
# 解析 GOAL_ACHIEVED
is_achieved = bool(review_text and "GOAL_ACHIEVED" in review_text.upper())
is_achieved = bool(
review_text and "GOAL_ACHIEVED" in review_text.upper())
if is_achieved:
# Goal 达成 → parent 最终完成
@@ -648,7 +669,9 @@ Parent Task ID: {parent_task.id}
"(round %d, subs=%d)",
parent_id, round_num, sub_count)
except Exception:
logger.exception("Failed to handle review conclusion for %s", parent_id)
logger.exception(
"Failed to handle review conclusion for %s",
parent_id)
# 安全恢复:reviewing → working
try:
conn.execute("BEGIN IMMEDIATE")
@@ -686,7 +709,8 @@ Parent Task ID: {parent_task.id}
return []
bb = Blackboard(db_path)
mentions = bb.get_pending_mentions(max_retries=self.MENTION_MAX_RETRIES)
mentions = bb.get_pending_mentions(
max_retries=self.MENTION_MAX_RETRIES)
if not mentions:
return []
@@ -750,16 +774,19 @@ Parent Task ID: {parent_task.id}
if new_review and new_review["verdict"] == "approved":
_ticker._transition_status(
get_connection(rdb_path), _t_id, "done",
get_connection(
rdb_path), _t_id, "done",
agent="daemon",
detail={"reason": "rebuttal_approved"})
logger.info("Rebuttal: task %s approved after rebuttal", _t_id)
logger.info(
"Rebuttal: task %s approved after rebuttal", _t_id)
else:
# 仍非 approved → @mention assignee
verdict_str = new_review["verdict"] if new_review else "未知"
rconn2 = get_connection(rdb_path)
try:
t_row = rconn2.execute("SELECT assignee FROM tasks WHERE id=?", (_t_id,)).fetchone()
t_row = rconn2.execute(
"SELECT assignee FROM tasks WHERE id=?", (_t_id,)).fetchone()
finally:
rconn2.close()
if t_row and t_row["assignee"]:
@@ -768,9 +795,11 @@ Parent Task ID: {parent_task.id}
bb2.add_comment(_t_id, "daemon",
f"@{t_row['assignee']} 审查结论: {verdict_str},请查看详情并决定接受或反驳",
comment_type="review")
logger.info("Rebuttal: task %s still %s after rebuttal", _t_id, verdict_str)
logger.info(
"Rebuttal: task %s still %s after rebuttal", _t_id, verdict_str)
except Exception:
logger.exception("Rebuttal on_complete failed for task %s", _t_id)
logger.exception(
"Rebuttal on_complete failed for task %s", _t_id)
result = await self.spawner.spawn_full_agent(
agent_id=agent_id,
@@ -793,22 +822,30 @@ Parent Task ID: {parent_task.id}
for item in items:
bb.mark_mention_notified(item["id"])
processed.append(agent_id)
logger.info("Mention spawn success: %s (%d mentions)", agent_id, len(items))
logger.info(
"Mention spawn success: %s (%d mentions)",
agent_id,
len(items))
else:
# spawn 返回 None(其他原因)→ 递增 retry_count
for item in items:
bb.mark_mention_retry(item["id"])
logger.warning("Mention spawn failed: %s, retrying next tick", agent_id)
logger.warning(
"Mention spawn failed: %s, retrying next tick", agent_id)
except AgentBusyError:
# Agent 忙,不递增 retry_count,等下次 tick 自然重试
logger.info("Mention spawn skipped: %s busy, will retry next tick", agent_id)
logger.info(
"Mention spawn skipped: %s busy, will retry next tick",
agent_id)
except Exception:
logger.exception("Mention processing error for agent %s", agent_id)
logger.exception(
"Mention processing error for agent %s", agent_id)
for item in items:
try:
if item.get("retry_count", 0) >= self.MENTION_MAX_RETRIES - 1:
if item.get("retry_count",
0) >= self.MENTION_MAX_RETRIES - 1:
bb.mark_mention_failed(item["id"])
else:
bb.mark_mention_retry(item["id"])
@@ -821,8 +858,14 @@ Parent Task ID: {parent_task.id}
mention_lines: List[str],
project_id: str) -> str:
"""#03: @mention prompt(身份注入)"""
api_host = getattr(self.spawner, 'api_host', '127.0.0.1') if self.spawner else '127.0.0.1'
api_port = getattr(self.spawner, 'api_port', 8083) if self.spawner else 8083
api_host = getattr(
self.spawner,
'api_host',
'127.0.0.1') if self.spawner else '127.0.0.1'
api_port = getattr(
self.spawner,
'api_port',
8083) if self.spawner else 8083
api_base = f"http://{api_host}:{api_port}/api"
# 获取 Agent 专长
@@ -898,7 +941,8 @@ Parent Task ID: {parent_task.id}
from datetime import datetime
conn.execute("BEGIN IMMEDIATE")
row = conn.execute("SELECT status FROM tasks WHERE id=?", (task_id,)).fetchone()
row = conn.execute(
"SELECT status FROM tasks WHERE id=?", (task_id,)).fetchone()
if not row:
return False
old_status = row["status"]
@@ -909,9 +953,10 @@ Parent Task ID: {parent_task.id}
now = datetime.utcnow().isoformat()
# 重置到 pending 时清空 assignee(避免残留导致重复路由到同一 Agent)
# 但 Mail 的 assignee 是收件人,永不清空
# handler 虚拟项目(_mail 等)的 assignee 是收件人,永不清空
if new_status == "pending":
if self._current_project_id == "_mail":
handler = TaskTypeRegistry.get_by_project(self._current_project_id)
if handler:
conn.execute(
"UPDATE tasks SET status=?, updated_at=? WHERE id=?",
(new_status, now, task_id),
@@ -937,7 +982,8 @@ Parent Task ID: {parent_task.id}
event_type = "daemon_tick"
conn.execute(
"INSERT INTO events (task_id, agent, event_type, detail) VALUES (?,?,?,?)",
(task_id, agent, event_type, json.dumps({"from": old_status, "to": new_status, **(detail or {})})),
(task_id, agent, event_type, json.dumps(
{"from": old_status, "to": new_status, **(detail or {})})),
)
conn.commit()
return True
@@ -977,20 +1023,24 @@ Parent Task ID: {parent_task.id}
try:
result = await self.dispatcher.dispatch(
task,
project_config={"project_id": project_id, "db_path": db_path},
project_config={
"project_id": project_id,
"db_path": db_path},
)
if result["status"] == "dispatched" and result["level"] in ("full", "escalate"):
if result["status"] == "dispatched" and result["level"] in (
"full", "escalate"):
conn = get_connection(db_path)
try:
# [v2.7.1] Mail 已在 dispatcher 中标 working,跳过 claimed
if project_id == "_mail":
# [Step 5] handler 项目已在 dispatcher 中标 working,跳过 claimed
handler = TaskTypeRegistry.get_by_project(project_id)
if handler:
conn.execute(
"UPDATE tasks SET current_agent=? WHERE id=?",
(result["agent_id"], task.id),
)
conn.commit()
dispatched.append(task.id)
logger.info("Dispatched %s to %s (session=%s, mail auto-working)",
logger.info("Dispatched %s to %s (session=%s, handler auto-working)",
task.id, result["agent_id"],
result.get("session_id"))
else:
@@ -1072,7 +1122,8 @@ Parent Task ID: {parent_task.id}
detail={"reason": "no_taker_after_3_broadcasts",
"round_number": self._broadcast_tracker.get(t.id).round_number if self._broadcast_tracker.get(t.id) else 0},
)
logger.warning("Escalated %s: no taker after 3 broadcast rounds", t.id)
logger.warning(
"Escalated %s: no taker after 3 broadcast rounds", t.id)
self._broadcast_tracker.pop(t.id, None)
finally:
conn.close()
@@ -1082,7 +1133,8 @@ Parent Task ID: {parent_task.id}
idle_agents = self._get_idle_agents()
if not idle_agents:
logger.warning("No idle agents for broadcast, skipping (capacity issue)")
logger.warning(
"No idle agents for broadcast, skipping (capacity issue)")
return []
task_ids = [t.id for t in broadcastable]
@@ -1113,7 +1165,8 @@ Parent Task ID: {parent_task.id}
spawned = []
for agent_id in idle_agents:
prompt = self._build_claim_prompt(agent_id, broadcastable, project_id)
prompt = self._build_claim_prompt(
agent_id, broadcastable, project_id)
try:
session_id = await self.spawner.spawn_full_agent(
agent_id=agent_id,
@@ -1127,7 +1180,8 @@ Parent Task ID: {parent_task.id}
spawned.append(session_id)
# 记录已通知的 Agent
for t in broadcastable:
self._broadcast_tracker[t.id].notified_agents.add(agent_id)
self._broadcast_tracker[t.id].notified_agents.add(
agent_id)
except AgentBusyError:
logger.debug("Broadcast skip %s: busy", agent_id)
except Exception:
@@ -1138,8 +1192,14 @@ Parent Task ID: {parent_task.id}
def _build_claim_prompt(self, agent_id: str, tasks: list,
project_id: str) -> str:
"""#03: 广播认领 prompt(身份+专长注入)"""
api_host = getattr(self.spawner, 'api_host', '127.0.0.1') if self.spawner else '127.0.0.1'
api_port = getattr(self.spawner, 'api_port', 8083) if self.spawner else 8083
api_host = getattr(
self.spawner,
'api_host',
'127.0.0.1') if self.spawner else '127.0.0.1'
api_port = getattr(
self.spawner,
'api_port',
8083) if self.spawner else 8083
api_base = f"http://{api_host}:{api_port}/api"
# 获取 Agent 专长
@@ -1194,7 +1254,8 @@ Parent Task ID: {parent_task.id}
@property
def counter(self):
"""从 Dispatcher 获取 counter"""
return getattr(self.dispatcher, 'counter', None) if self.dispatcher else None
return getattr(self.dispatcher, 'counter',
None) if self.dispatcher else None
@staticmethod
def _is_pid_alive(pid: int) -> bool:
@@ -1206,7 +1267,8 @@ Parent Task ID: {parent_task.id}
except (ProcessLookupError, PermissionError):
return False
def record_broadcast_response(self, task_id: str, agent_id: str, outcome: str):
def record_broadcast_response(
self, task_id: str, agent_id: str, outcome: str):
"""记录 Agent 对广播任务的反馈(Spawner 调用的公共 API"""
tracker = self._broadcast_tracker.get(task_id)
if not tracker:
@@ -1227,7 +1289,8 @@ Parent Task ID: {parent_task.id}
def _get_all_agent_ids(self) -> List[str]:
"""获取所有配置的 Agent ID"""
if self.dispatcher and hasattr(self.dispatcher, 'router') and self.dispatcher.router:
if self.dispatcher and hasattr(
self.dispatcher, 'router') and self.dispatcher.router:
return list(self.dispatcher.router.agent_profiles.keys())
return []
@@ -1236,15 +1299,17 @@ Parent Task ID: {parent_task.id}
if not self.counter:
return []
# agent_profiles 在 Router 初始化时从 config 填充,是完整 Agent 列表
all_agents = list(self.dispatcher.router.agent_profiles.keys()) if self.dispatcher else []
all_agents = list(
self.dispatcher.router.agent_profiles.keys()) if self.dispatcher else []
active = self.counter.active_agents
return [aid for aid in all_agents if active.get(aid, 0) == 0]
async def _dispatch_reviews(self, db_path: Path,
project_id: str) -> List[str]:
"""扫描 review 状态任务,检查是否有产出,调度审查 Agent"""
# mail 任务不走 review 流程,直接跳过
if project_id == "_mail":
# handler 项目(_mail/_toolchain不走 review 流程
handler = TaskTypeRegistry.get_by_project(project_id)
if handler:
return []
queries = Queries(db_path)
@@ -1290,7 +1355,9 @@ Parent Task ID: {parent_task.id}
result = await self.dispatcher.dispatch(
task,
action_type="review",
project_config={"project_id": project_id, "db_path": db_path},
project_config={
"project_id": project_id,
"db_path": db_path},
)
if result["status"] == "dispatched":
dispatched.append(task.id)
@@ -1374,8 +1441,10 @@ Parent Task ID: {parent_task.id}
working = queries.tasks_by_status("working")
for task in working:
# #07.2: crash_limit 统一检查(比超时更严重的信号)
if self.dispatcher and hasattr(self.dispatcher, '_check_crash_limit'):
if self.dispatcher._check_crash_limit(task.id, db_path, limit=3, window_minutes=30):
if self.dispatcher and hasattr(
self.dispatcher, '_check_crash_limit'):
if self.dispatcher._check_crash_limit(
task.id, db_path, limit=3, window_minutes=30):
conn = get_connection(db_path)
try:
self._transition_status(
@@ -1387,7 +1456,8 @@ Parent Task ID: {parent_task.id}
finally:
conn.close()
reclaimed.append(task.id)
logger.error("Task %s: executor crash limit (3/30m), marking failed", task.id)
logger.error(
"Task %s: executor crash limit (3/30m), marking failed", task.id)
continue
# #07.3 ACT-1: updated_at fallback 覆盖 mail auto-working(无 started_at/claimed_at
@@ -1399,7 +1469,8 @@ Parent Task ID: {parent_task.id}
# per-task timeout: deadline 优先,否则用默认值
if task.deadline:
deadline_time = datetime.fromisoformat(task.deadline)
timeout_minutes = (deadline_time - start_time).total_seconds() / 60.0
timeout_minutes = (
deadline_time - start_time).total_seconds() / 60.0
if timeout_minutes < 1:
timeout_minutes = self.default_task_timeout_minutes
else:
@@ -1407,25 +1478,24 @@ Parent Task ID: {parent_task.id}
elapsed = (now - start_time).total_seconds() / 60.0
if elapsed > timeout_minutes:
# [v2.7.1] Mail 幻觉门控兜底:有回复 + working → done
if self._current_project_id == "_mail":
has_reply = self._mail_check_reply(task.id, db_path)
if has_reply:
conn = get_connection(db_path)
try:
ok = self._transition_status(
conn, task.id, "done",
agent="daemon",
detail={"reason": "mail_auto_done_recheck",
"elapsed_minutes": round(elapsed, 1)},
)
if ok:
reclaimed.append(task.id)
logger.info("Mail %s: ticker recheck found reply, marked done (%.1fm)",
task.id, elapsed)
finally:
conn.close()
continue
# [Step 5] handler 幻觉门控兜底:check_completion 通过 + working → done
handler = TaskTypeRegistry.get_by_project(self._current_project_id)
if handler and handler.check_completion(task.id, db_path):
conn = get_connection(db_path)
try:
ok = self._transition_status(
conn, task.id, "done",
agent="daemon",
detail={"reason": "mail_auto_done_recheck",
"elapsed_minutes": round(elapsed, 1)},
)
if ok:
reclaimed.append(task.id)
logger.info("Mail %s: ticker recheck found reply, marked done (%.1fm)",
task.id, elapsed)
finally:
conn.close()
continue
conn = get_connection(db_path)
try:
@@ -1446,8 +1516,10 @@ Parent Task ID: {parent_task.id}
pass
# v2.7.2: 进程存活性检查 — counter 占用但进程已死的兜底
if self.spawner and self.counter and hasattr(self.counter, "active_agents"):
for agent_id in list(self.counter.active_agents.keys()) if hasattr(self.counter, "active_agents") else []:
if self.spawner and self.counter and hasattr(
self.counter, "active_agents"):
for agent_id in list(self.counter.active_agents.keys()) if hasattr(
self.counter, "active_agents") else []:
session_info = self.spawner.get_session_by_agent(agent_id)
if not session_info:
continue
@@ -1464,20 +1536,24 @@ Parent Task ID: {parent_task.id}
conn = get_connection(db_path)
try:
current_row = conn.execute(
"SELECT status FROM tasks WHERE id=?", (task_id_check,)
"SELECT status FROM tasks WHERE id=?", (
task_id_check,)
).fetchone()
if current_row and current_row["status"] == "review":
logger.info("Task %s in review, keeping status (process dead)", task_id_check)
logger.info(
"Task %s in review, keeping status (process dead)", task_id_check)
else:
self._transition_status(
conn, task_id_check, "pending",
agent="daemon",
detail={"reason": "process_dead", "pid": pid},
detail={
"reason": "process_dead", "pid": pid},
)
finally:
conn.close()
except Exception:
logger.exception("Failed to handle process dead for task %s", task_id_check)
logger.exception(
"Failed to handle process dead for task %s", task_id_check)
# #07.2: Fix-3b 已删除。review 超时/crash 统一由 process_dead + _check_timeouts 处理
@@ -1496,7 +1572,10 @@ Parent Task ID: {parent_task.id}
finally:
conn.close()
except Exception as e:
logger.error("Mail %s: ticker reply check error: %s", original_task_id, e)
logger.error(
"Mail %s: ticker reply check error: %s",
original_task_id,
e)
return True # 保守:查询失败假设有回复
def _check_recent_routing(self, db_path: Path, task_id: str,
@@ -1505,7 +1584,8 @@ Parent Task ID: {parent_task.id}
try:
conn = get_connection(db_path)
try:
# 检查是否有 from_status=review 的 dispatched 记录(防止重复 review dispatch
# 检查是否有 from_status=review 的 dispatched 记录(防止重复 review
# dispatch
if action_type == "review":
row = conn.execute(
"SELECT COUNT(*) as cnt FROM routing_decisions "
@@ -1536,17 +1616,23 @@ Parent Task ID: {parent_task.id}
NON_TERMINAL = {"claimed", "working", "review", "reviewing"}
projects = self.registry.list_projects()
recovery_report = {"projects": {}, "total_recovered": 0, "total_noop": 0}
recovery_report = {
"projects": {},
"total_recovered": 0,
"total_noop": 0}
# 收集所有需要扫描的项目(registry + 虚拟项目)
project_dirs = {}
for project_id, project_info in projects.items():
if project_info.get("status") == "active":
project_dirs[project_id] = self.registry.root / project_id / "blackboard.db"
project_dirs[project_id] = self.registry.root / \
project_id / "blackboard.db"
# 虚拟项目
for virtual_id in ("_general", "_mail"):
virtual_db = Path(self.registry.root) / virtual_id / "blackboard.db"
# 虚拟项目_general + 注册表自动发现
virtual_ids = ["_general"] + TaskTypeRegistry.virtual_projects()
for virtual_id in virtual_ids:
virtual_db = Path(self.registry.root) / \
virtual_id / "blackboard.db"
if virtual_db.exists() and virtual_id not in project_dirs:
project_dirs[virtual_id] = virtual_db
@@ -1566,13 +1652,15 @@ Parent Task ID: {parent_task.id}
old_pid = self._current_project_id
self._current_project_id = project_id
try:
recovered, noop_count = self._recover_project(db_path, NON_TERMINAL)
recovered, noop_count = self._recover_project(
db_path, NON_TERMINAL)
if recovered:
recovery_report["projects"][project_id] = recovered
recovery_report["total_recovered"] += len(recovered)
recovery_report["total_noop"] += noop_count
except Exception:
logger.exception("Startup recovery failed for project %s", project_id)
logger.exception(
"Startup recovery failed for project %s", project_id)
finally:
self._current_project_id = old_pid
@@ -1584,7 +1672,8 @@ Parent Task ID: {parent_task.id}
logger.info("Startup recovery: %d tasks kept as-is (no recovery needed)",
recovery_report["total_noop"])
else:
logger.info("Startup recovery: no non-terminal tasks found, clean start")
logger.info(
"Startup recovery: no non-terminal tasks found, clean start")
return recovery_report
@@ -1607,10 +1696,13 @@ Parent Task ID: {parent_task.id}
for task in rows:
try:
action = self._determine_recovery_action(conn, task, status, db_path)
action = self._determine_recovery_action(
conn, task, status, db_path)
if action:
self._execute_recovery(conn, task["id"], action, db_path)
recovered.append({"task_id": task["id"], "from": status, "action": action})
self._execute_recovery(
conn, task["id"], action, db_path)
recovered.append(
{"task_id": task["id"], "from": status, "action": action})
else:
# 审计:保持原状的任务也记录事件
noop_count += 1
@@ -1621,7 +1713,8 @@ Parent Task ID: {parent_task.id}
)
conn.commit()
except Exception:
logger.exception("Startup recovery failed for task %s", task["id"])
logger.exception(
"Startup recovery failed for task %s", task["id"])
finally:
conn.close()
@@ -1699,7 +1792,8 @@ Parent Task ID: {parent_task.id}
# 无审查结论 → 保持 reviewticker 自然会 dispatch reviewer
return None
def _execute_recovery(self, conn, task_id: str, action: str, db_path: Path):
def _execute_recovery(self, conn, task_id: str,
action: str, db_path: Path):
"""执行恢复动作"""
# 获取原始状态(用于审计)
orig_row = conn.execute(
@@ -1711,17 +1805,22 @@ Parent Task ID: {parent_task.id}
self._transition_status(
conn, task_id, "pending",
agent="daemon",
detail={"reason": "startup_recovery", "original_status": orig_status},
detail={
"reason": "startup_recovery",
"original_status": orig_status},
)
# 清空 current_agent(常规推 pending,无特定 agent 接手)
conn.execute("UPDATE tasks SET current_agent=NULL WHERE id=?", (task_id,))
conn.execute(
"UPDATE tasks SET current_agent=NULL WHERE id=?", (task_id,))
conn.commit()
elif action == "push_to_pending_keep_agent":
self._transition_status(
conn, task_id, "pending",
agent="daemon",
detail={"reason": "startup_recovery", "original_status": orig_status},
detail={
"reason": "startup_recovery",
"original_status": orig_status},
)
# 保留 current_agent,让同一 agent 重新接手
conn.commit()
@@ -1730,7 +1829,9 @@ Parent Task ID: {parent_task.id}
self._transition_status(
conn, task_id, "review",
agent="daemon",
detail={"reason": "startup_recovery", "original_status": "working"},
detail={
"reason": "startup_recovery",
"original_status": "working"},
)
conn.commit()
@@ -1738,7 +1839,9 @@ Parent Task ID: {parent_task.id}
self._transition_status(
conn, task_id, "done",
agent="daemon",
detail={"reason": "startup_recovery", "original_status": orig_status},
detail={
"reason": "startup_recovery",
"original_status": orig_status},
)
conn.commit()
@@ -1746,22 +1849,30 @@ Parent Task ID: {parent_task.id}
self._transition_status(
conn, task_id, "failed",
agent="daemon",
detail={"reason": "startup_recovery", "original_status": orig_status},
detail={
"reason": "startup_recovery",
"original_status": orig_status},
)
conn.commit()
# 记录恢复审计事件
conn.execute(
"INSERT INTO events (task_id, agent, event_type, detail) VALUES (?, ?, ?, ?)",
(task_id, "daemon", "startup_recovery", json.dumps({"action": action}))
(task_id, "daemon", "startup_recovery",
json.dumps({"action": action}))
)
conn.commit()
logger.info("Recovery: task %s%s (action=%s)", task_id, action, action)
logger.info(
"Recovery: task %s%s (action=%s)",
task_id,
action,
action)
def _find_pre_reviewing_status(self, conn, task_id: str) -> str:
"""查 events 表找到 reviewing 之前的状态(done 或 failed"""
# _transition_status 写入 event_type=f"task_{new_status}"detail 用 from/to
# _transition_status 写入 event_type=f"task_{new_status}"detail 用
# from/to
rows = conn.execute(
"""SELECT detail FROM events
WHERE task_id=? AND event_type='task_reviewing'
@@ -1772,7 +1883,8 @@ Parent Task ID: {parent_task.id}
for event in rows:
try:
detail = json.loads(event["detail"])
# _transition_status detail 格式: {"from": old_status, "to": new_status, ...}
# _transition_status detail 格式: {"from": old_status, "to":
# new_status, ...}
prev = detail.get("from") or detail.get("old_status")
if prev in ("done", "failed"):
return prev
+277
View File
@@ -0,0 +1,277 @@
"""toolchain_handler.py — 工具链事件 handler。
处理 Gitea Webhook 事件CI 失败Review 请求Issue 指派等
"""
from __future__ import annotations
import json
import logging
import urllib.request
from pathlib import Path
from typing import Dict
from src.daemon.base_task_handler import BaseTaskHandler, VerifyResult
from src.daemon.prompt_composer import PromptComposer, PromptContext
from src.daemon.toolchain_templates import render_template, _TEMPLATE_MAP
from src.blackboard.db import get_connection
logger = logging.getLogger("moziplus-v2.handler.toolchain")
# ---------------------------------------------------------------------------
# Toolchain PromptSections
# ---------------------------------------------------------------------------
class ToolchainContextSection:
"""事件类型 + 事件详情(priority=10"""
name: str = "toolchain_context"
priority: int = 10
def render(self, context: PromptContext) -> str:
event_type = context.event_type
event_data: Dict = context.event_data or {}
if event_type in _TEMPLATE_MAP:
# 使用模板引擎渲染已知事件
variables = {k: str(v) for k, v in event_data.items()}
return render_template(event_type, variables)
# fallback:通用事件描述
lines = ["## 工具链事件", ""]
lines.append(f"- **事件类型**: {event_type or '未知'}")
if event_data:
lines.append("- **事件详情**:")
for key, value in event_data.items():
lines.append(f" - {key}: {value}")
lines.append("")
return "\n".join(lines)
def should_include(self, context: PromptContext) -> bool:
return True
class ToolchainApiSection:
"""API 操作指令(priority=40),success_status=done"""
name: str = "toolchain_api"
priority: int = 40
API_HOST = "localhost:8083"
def render(self, context: PromptContext) -> str:
lines = [
"## API 操作指令",
"",
f"项目 ID: `{context.project_id}`",
f"任务 ID: `{context.task_id}`",
"",
"### 完成后必须更新任务状态",
"完成后务必通过以下命令将任务标记为 **done**:",
"```bash",
f'curl -s -X POST "http://{self.API_HOST}/api/projects/{context.project_id}/tasks/{context.task_id}/status" \\',
' -H "Content-Type: application/json" \\',
' -d \'{"status": "done"}\'',
"```",
"",
"### 提交产出",
"如有产出(如 review 结果、修复方案),提交到任务 outputs:",
"```bash",
f'curl -s -X POST "http://{self.API_HOST}/api/projects/{context.project_id}/tasks/{context.task_id}/outputs" \\',
' -H "Content-Type: application/json" \\',
' -d \'{"content": "<你的产出内容>", "type": "text"}\'',
"```",
"",
]
return "\n".join(lines)
def should_include(self, context: PromptContext) -> bool:
return True
class ToolchainConstraintsSection:
"""硬约束(priority=50"""
name: str = "toolchain_constraints"
priority: int = 50
def render(self, context: PromptContext) -> str:
lines = [
"## 硬约束",
"",
"1. **必须标 done**:处理完成后必须通过 API 将任务状态更新为 `done`,否则视为未完成",
"2. **产出不能为空**:必须提交有意义的产出(output 或 comment),不能只改状态",
"3. **单一职责**:只处理本次事件相关的操作,不要越界执行无关任务",
"4. **出错即报告**:如果无法处理(如权限不足、资源不存在),在 comment 中说明原因并标 done",
"5. **不要创建新任务**:工具链事件只处理当前事件,不衍生新任务",
"",
]
return "\n".join(lines)
def should_include(self, context: PromptContext) -> bool:
return True
# ---------------------------------------------------------------------------
# ToolchainHandler
# ---------------------------------------------------------------------------
class ToolchainHandler(BaseTaskHandler):
"""工具链事件 handler。"""
task_type = "toolchain"
virtual_project = "_toolchain"
display_name = "工具链事件"
def target_success_status(self) -> str:
return "done"
def pre_spawn(self, task_id: str, db_path: Path) -> bool:
"""auto_workingpending → working"""
return self._auto_mark_working(task_id, db_path)
def get_sections(self) -> list:
"""返回 3 个 Toolchain PromptSection 实例"""
return [
ToolchainContextSection(),
ToolchainApiSection(),
ToolchainConstraintsSection(),
]
def build_prompt(self, context: PromptContext) -> str:
"""通过 PromptComposer 拼装 sections 为最终 prompt"""
composer = PromptComposer()
composer.add_many(self.get_sections())
return composer.compose(context)
def verify_completion(self, task_id: str, db_path: Path) -> VerifyResult:
"""检查行动输出(output 或 comment 有实质内容)"""
try:
conn = get_connection(db_path)
try:
# 检查 output
output_count = conn.execute(
"SELECT COUNT(*) FROM outputs WHERE task_id=?", (task_id,)
).fetchone()[0]
if output_count > 0:
return VerifyResult(True, "has_output", f"output_count={output_count}")
# 检查 comment(非系统、有实质内容)
comment_count = conn.execute(
"SELECT COUNT(*) FROM comments WHERE task_id=? "
"AND author != 'system' AND LENGTH(content) >= 20",
(task_id,)
).fetchone()[0]
if comment_count > 0:
return VerifyResult(True, "has_comment", f"comment_count={comment_count}")
return VerifyResult(False, "no_action", "output=0, comment=0")
finally:
conn.close()
except Exception as e:
logger.error("Toolchain %s: verify error: %s", task_id, e)
return VerifyResult(False, "verify_error", str(e))
def on_failure(self, task_id: str, agent_id: str,
db_path: Path, verify: VerifyResult) -> None:
"""验证失败 → 标 failed + Mail API 通知主公"""
self._mark_task_status(db_path, task_id, "failed")
logger.info("Toolchain %s: verify failed (%s), marked failed", task_id, verify.reason)
# 从 db 读取事件上下文
event_type = ""
event_data: Dict = {}
try:
conn = get_connection(db_path)
row = conn.execute(
"SELECT must_haves FROM tasks WHERE id=?", (task_id,)
).fetchone()
if row and row["must_haves"]:
meta = json.loads(row["must_haves"])
event_type = meta.get("event_type", "")
raw = meta.get("event_data", "{}")
event_data = json.loads(raw) if isinstance(raw, str) else raw
conn.close()
except Exception:
pass
self._notify_via_mail_api(
task_id, verify.reason, verify.evidence,
event_type, event_data,
)
def _build_gitea_links(self, event_type: str, event_data: dict) -> str:
"""根据事件类型构建 Gitea 链接。"""
links = []
repo = event_data.get("repo", "")
base_url = "http://192.168.2.154:3000"
if "pr_number" in event_data:
links.append(f"PR: {base_url}/{repo}/pulls/{event_data['pr_number']}")
if "issue_number" in event_data:
links.append(f"Issue: {base_url}/{repo}/issues/{event_data['issue_number']}")
if "commit" in event_data:
links.append(f"Commit: {base_url}/{repo}/commit/{event_data['commit']}")
if "branch" in event_data and "commit" not in event_data:
links.append(f"分支: {event_data['branch']}")
return "\n".join(links) if links else "(无法提取链接,请检查黑板任务详情)"
def _notify_via_mail_api(
self,
task_id: str,
reason: str,
evidence: str,
event_type: str,
event_data: Dict,
) -> None:
"""通过 Mail API 发送丰富的失败通知给主公。"""
# 构建行动指引
action_hint = "请检查黑板任务并手动处理。"
et_lower = event_type.lower()
if "ci" in et_lower or "deploy" in et_lower:
action_hint = "建议创建任务派给 jiangwei-infra 检查 CI/部署问题。"
elif "review" in et_lower:
action_hint = "建议查看 PR review 状态,必要时通知相关开发者。"
elif "issue" in et_lower:
action_hint = "建议创建任务派给对应开发者处理 Issue。"
# 构建事件详情
event_details = ""
if event_data:
event_details = "\n".join(
f" - {k}: {v}" for k, v in event_data.items()
)
# 构建 Gitea 链接
gitea_links = self._build_gitea_links(event_type, event_data)
title = f"[toolchain-handler] 工具链事件处理失败: {task_id}"
text = (
f"任务 {task_id} 验证失败\n\n"
f"事件类型: {event_type or '未知'}\n"
f"事件详情:\n{event_details or ' (无)'}\n\n"
f"失败原因: {reason}\n"
f"证据: {evidence}\n\n"
f"{gitea_links}\n\n"
f"行动指引: {action_hint}"
)
payload = json.dumps({
"from": "daemon",
"to": "pangtong-fujunshi",
"title": title,
"text": text,
"type": "inform",
}, ensure_ascii=False).encode("utf-8")
try:
req = urllib.request.Request(
"http://localhost:8083/api/mail",
data=payload,
headers={"Content-Type": "application/json"},
)
urllib.request.urlopen(req, timeout=5)
logger.info("Toolchain %s: sent failure notification via Mail API", task_id)
except Exception as e:
logger.warning("Toolchain %s: failed to notify via Mail API: %s", task_id, e)
+2
View File
@@ -21,6 +21,8 @@ _TEMPLATE_MAP: Dict[str, str] = {
"issue_assigned": "issue_assigned.md",
"ci_failure": "ci_failure.md",
"deploy_failure": "deploy_failure.md",
"review_updated": "review_updated.md",
"review_comment": "review_comment.md",
}
# 模板缓存
+1 -1
View File
@@ -426,7 +426,7 @@ export default function TaskModal() {
{/* 状态操作 */}
<div style={{ marginBottom: 16 }}>
<SectionLabel icon="🔄" title="状态操作" />
<StatusButtons status={task.status} taskId={task.id} resumedFrom={task.resumed_from} />
<StatusButtons status={task.status} taskId={task.id} resumedFrom={task.resumed_from ?? undefined} />
</div>
{/* v2.7: 子 Task 进度 + 列表 */}
+1
View File
@@ -57,6 +57,7 @@ export interface V2Task {
estimated_duration_minutes: number | null;
escalated: number;
archived: number; // v2.8: 归档标记
resumed_from: string | null; // v2.8: 续杯来源
// API 聚合字段
comments_count?: number;
outputs_count?: number;
+24 -9
View File
@@ -1,6 +1,13 @@
"""v2.6 主入口 - FastAPI + Daemon ticker 共享 asyncio event loop"""
from __future__ import annotations
from src.api.toolchain_routes import router as toolchain_router
from src.api.mail_routes import router as mail_router
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
import logging
from contextlib import asynccontextmanager
@@ -14,6 +21,10 @@ from fastapi.staticfiles import StaticFiles
from src.blackboard.registry import ProjectRegistry
from src.daemon.ticker import Ticker
from src.daemon.task_handler import TaskHandler
from src.daemon.mail_handler import MailHandler
from src.daemon.toolchain_handler import ToolchainHandler
from src.daemon.task_type_registry import TaskTypeRegistry
from src.daemon.spawner import AgentSpawner
from src.daemon.bootstrap import BootstrapBuilder
from src.daemon.dispatcher import Dispatcher
@@ -131,7 +142,8 @@ async def lifespan(app: FastAPI):
counter = ActiveAgentCounter(
max_global=daemon_config.get("max_global_agents", 5),
max_per_session=daemon_config.get("max_per_session", 1),
max_concurrent_sessions=daemon_config.get("max_concurrent_sessions", 3),
max_concurrent_sessions=daemon_config.get(
"max_concurrent_sessions", 3),
default_cooldown_seconds=daemon_config.get("cooldown_seconds", 120),
)
# BootstrapBuilderL2 四段式引擎注入层,v2.1)
@@ -181,7 +193,10 @@ async def lifespan(app: FastAPI):
spawner=spawner,
counter=counter,
db_path=default_db_path,
guardrails=GuardrailEngine(config_path=Path(__file__).parent.parent / "config" / "guardrails.yaml"),
guardrails=GuardrailEngine(
config_path=Path(__file__).parent.parent /
"config" /
"guardrails.yaml"),
)
# ── 集成模块 ──
@@ -191,6 +206,7 @@ async def lifespan(app: FastAPI):
)
# ExperienceDistiller(经验自动蒸馏)
config.get("experience", {})
experience_distiller = ExperienceDistiller(
store=ExperienceStore(store_path=DATA_ROOT / "experiences.jsonl"),
)
@@ -202,6 +218,11 @@ async def lifespan(app: FastAPI):
watch_interval=inbox_config.get("watch_interval", 1.0),
)
# [Step 5] 注册 TaskType handler(必须在 ticker 启动前)
TaskTypeRegistry.register(TaskHandler())
TaskTypeRegistry.register(MailHandler())
TaskTypeRegistry.register(ToolchainHandler())
ticker = Ticker(
registry=registry,
tick_interval=tick_interval,
@@ -251,13 +272,6 @@ app.add_middleware(
# API 路由注册
# ---------------------------------------------------------------------------
from src.api.blackboard_routes import router as blackboard_router # noqa: E402
from src.api.checkpoint_routes import router as checkpoint_router # noqa: E402
from src.api.daemon_routes import router as daemon_router # noqa: E402
from src.api.project_routes import router as project_router # noqa: E402
from src.api.sse_routes import router as sse_router # noqa: E402
from src.api.mail_routes import router as mail_router # noqa: E402
from src.api.toolchain_routes import router as toolchain_router # noqa: E402
app.include_router(blackboard_router)
app.include_router(checkpoint_router)
@@ -303,6 +317,7 @@ if DIST_DIR.exists():
class CachedStaticFiles:
"""包装 StaticFiles,添加 Cache-Control 头"""
def __init__(self, app):
self._app = app
+9
View File
@@ -0,0 +1,9 @@
Review 评论
PR: http://192.168.2.154:3000/{repo}/pulls/{pr_number}
标题: {pr_title}
评论者: {reviewer}
{comment_body}
请查看评论并回复或修改代码。
+16
View File
@@ -0,0 +1,16 @@
PR 有新提交,请重新 Review
PR: http://192.168.2.154:3000/{repo}/pulls/{pr_number}
标题: {pr_title}
作者: {pr_author}
新 commit: {new_sha}
您之前已审查过此 PR,作者已根据反馈更新了代码。请重新 Review。
流程:
1. 读取 PR diffGitea API: GET /repos/{repo}/pulls/{pr_number}.diff
2. 重点检查上次 Review 意见的修改部分
3. 提交 ReviewGitea API: POST /repos/{repo}/pulls/{pr_number}/reviews
4. 提交后改动者会自动收到通知
完成后回复此 Mail 确认。
+2 -2
View File
@@ -1,12 +1,12 @@
import pytest
pytestmark = pytest.mark.e2e
skip_no_integration = pytest.mark.skipif(
not __import__("os").environ.get("RUN_INTEGRATION"),
reason="Set RUN_INTEGRATION=1 to run E2E tests against real daemon",
)
pytestmark = [pytest.mark.e2e, skip_no_integration]
"""v2.7 端到端测试 — 全链路真实环境
覆盖项目管理 Task CRUD SubTask Stage进度 状态聚合 依赖链 超时 Mail 真实Agent调度
+92
View File
@@ -0,0 +1,92 @@
"""单元测试:§24 v3 rotation-only compact 检测
测试 _get_recent_gateway_logs _check_compact_in_progress_gateway
tmp_path 构造 mock gateway 日志文件
"""
import json
import os
from datetime import datetime, timedelta, timezone
from pathlib import Path
import pytest
from src.daemon.spawner import AgentSpawner
# ── helpers ──
_SESSION_KEY = "agent:pangtong-fujunshi:main"
_TODAY_STR = datetime.now().strftime("%Y-%m-%d")
def _make_rotation_event(session_key: str, ts: datetime) -> dict:
"""构造一条 rotation 日志事件"""
return {
"time": ts.isoformat(),
"message": f"[compaction] rotated active transcript after compaction (sessionKey={session_key})",
}
def _make_other_event(session_key: str, ts: datetime, msg: str = "something else") -> dict:
"""构造一条普通日志事件"""
return {
"time": ts.isoformat(),
"message": f"{msg} (sessionKey={session_key})",
}
def _write_log(tmp_path: Path, date_str: str, lines: list[dict]):
"""写 mock 日志文件"""
log_file = tmp_path / f"openclaw-{date_str}.log"
with open(log_file, "w") as f:
for obj in lines:
f.write(json.dumps(obj, ensure_ascii=False) + "\n")
@pytest.fixture(autouse=True)
def _set_log_dir(tmp_path, monkeypatch):
"""每个测试自动设置 OPENCLAW_LOG_DIR 到 tmp_path"""
monkeypatch.setenv("OPENCLAW_LOG_DIR", str(tmp_path))
# ── 测试用例 ──
class TestCheckCompactInProgress:
"""§24 v3: _check_compact_in_progress_gateway 单元测试"""
def test_rotation_within_window_returns_true(self, tmp_path):
"""TC1: rotation 事件在窗口内 → True"""
now = datetime.now(timezone.utc)
recent = now - timedelta(seconds=30)
_write_log(tmp_path, _TODAY_STR, [_make_rotation_event(_SESSION_KEY, recent)])
assert AgentSpawner._check_compact_in_progress_gateway(_SESSION_KEY) is True
def test_rotation_outside_window_returns_false(self, tmp_path):
"""TC2: rotation 事件超出窗口 → False"""
now = datetime.now(timezone.utc)
old = now - timedelta(seconds=200)
_write_log(tmp_path, _TODAY_STR, [_make_rotation_event(_SESSION_KEY, old)])
assert AgentSpawner._check_compact_in_progress_gateway(_SESSION_KEY) is False
def test_no_rotation_event_returns_false(self, tmp_path):
"""TC3: 无 rotation 事件 → False"""
now = datetime.now(timezone.utc)
_write_log(tmp_path, _TODAY_STR, [
_make_other_event(_SESSION_KEY, now, "model.completed"),
])
assert AgentSpawner._check_compact_in_progress_gateway(_SESSION_KEY) is False
def test_log_file_not_exists_returns_false(self, tmp_path):
"""TC4: 日志文件不存在 → False"""
# tmp_path 为空目录,无日志文件
assert AgentSpawner._check_compact_in_progress_gateway(_SESSION_KEY) is False
def test_session_key_mismatch_returns_false(self, tmp_path):
"""TC5: sessionKey 不匹配 → False"""
now = datetime.now(timezone.utc)
recent = now - timedelta(seconds=10)
other_key = "agent:simayi-challenger:main"
_write_log(tmp_path, _TODAY_STR, [_make_rotation_event(other_key, recent)])
assert AgentSpawner._check_compact_in_progress_gateway(_SESSION_KEY) is False
+1 -1
View File
@@ -123,7 +123,7 @@ class TestClassifyNoJsonExit0:
def test_task_status_pending(self):
result = Spawner._classify_outcome(0, {}, "", "pending", "")
assert result["outcome"] == "agent_error"
assert result["outcome"] == "completed"
assert result["should_retry"] is False