Compare commits
39 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 478d2b932d | |||
| f05455be59 | |||
| bdd18f4e3b | |||
| 51f8c68204 | |||
| 3b78aceeaf | |||
| 466a5263fa | |||
| 31a5836dd6 | |||
| feff387ff7 | |||
| d1491a31d2 | |||
| 1e1d2bc4a0 | |||
| 3bc629de8c | |||
| 4ed5c18f21 | |||
| 28f4cc7deb | |||
| f961037fd2 | |||
| 38372786a2 | |||
| 1f61b81734 | |||
| 2b00269d7d | |||
| 6e1d86f44a | |||
| 91fd0f9318 | |||
| 9f97f85ea2 | |||
| 1b3a8b56fe | |||
| f267ccc699 | |||
| 1083c118db | |||
| f866e47390 | |||
| e0af9f6bce | |||
| 89888a5e51 | |||
| ec2bd9b3ec | |||
| 8c6782a7ad | |||
| b889082a0b | |||
| 91d53f2771 | |||
| 5d50894430 | |||
| c3781284c1 | |||
| 137fa01b3d | |||
| fdda305c3d | |||
| 24e47c6968 | |||
| 6d69508071 | |||
| dd5ee7bea3 | |||
| 64755aaf8e | |||
| 0c736fbee2 |
@@ -46,7 +46,7 @@ jobs:
|
||||
- name: Setup Python
|
||||
run: |
|
||||
python3 -m venv /tmp/ci-venv-test
|
||||
/tmp/ci-venv-test/bin/pip install --quiet -r requirements.txt
|
||||
/tmp/ci-venv-test/bin/pip install --quiet fastapi pydantic pyyaml uvicorn requests pytest pytest-asyncio httpx
|
||||
|
||||
- name: Run tests (exclude E2E)
|
||||
run: |
|
||||
|
||||
@@ -1590,7 +1590,7 @@ daemon 内部 ───────┘ │ 5. 创建 Mail │
|
||||
| 只处理白名单内的事件类型 | 未知的忽略 + 日志 |
|
||||
| issue_comment 需判断来源 | 只处理 CI workflow 写的评论(按特定前缀匹配:`❌ **CI 失败**` 或统一后的 `[CI]` 前缀) |
|
||||
| PR 作者/审查者必须是已知 Agent | 未知的忽略 + 日志 |
|
||||
| 幂等:同一事件不重复创建 Mail | 按 `{x_gitea_event}-{x_gitea_delivery}` 去重(delivery ID 来自 `X-Gitea-Delivery` header) |
|
||||
| 幂等:同一事件不重复创建 Mail | 双重去重:① delivery UUID(`{event}-{delivery}`)标准幂等;② review 事件 payload 内容去重(`{event}:{pr_num}:{sender}:{sha256(body_or_content)[:16]}`),防御同一 review 被不同来源重复提交(2026-06-09 新增) |
|
||||
|
||||
---
|
||||
|
||||
@@ -2007,6 +2007,9 @@ CI workflow 已有 `notify-on-failure` job(ci.yml),当前格式:
|
||||
| 7 | 签名算法 | ✅ 已确认 | Gitea 使用 HMAC-SHA256,代码注释已补 |
|
||||
| 8 | Webhook 作用范围 | ✅ 组织级 | Gitea 组织级 webhook(Hook ID=28),覆盖 sanguo 下所有仓库,新增仓库自动覆盖 |
|
||||
| 9 | ALLOWED_HOST_LIST | ✅ 已修复 | Gitea 容器配置 `192.168.2.153, 127.0.0.1, localhost, 172.17.0.0/16, 192.168.2.0/24` |
|
||||
| 10 | Gitea review payload 格式 | ✅ 姜维调研确认(2026-06-08) | Gitea v1.23.4 review payload 只有 `type` + `content`,没有 `state`/`body`/`user`,这不是 org vs repo 差异而是 Gitea 设计。v1.24.0 格式不变。双格式兼容是防御性编码,保持现状 |
|
||||
| 11 | Spawner compact 检测窗口 | ✅ 已修复 | 窗口 300s→900s,尾部读取 50KB→1MB。实测长对话中 compact 记录被推出窗口导致漏检 |
|
||||
| 12 | inform 类型 Mail crash 误标 done | ✅ 已修复 | `_mail_auto_complete` 增加 outcome 感知,inform 用白名单(completed/claimed/no_reply)控制 done 标记。spawner crash cooldown 300s→60s |
|
||||
|
||||
---
|
||||
|
||||
@@ -2713,10 +2716,10 @@ Gitea v1.23.4 自带完整的 CI 管理界面:
|
||||
| # | 条件 | 状态 | 谁确认 |
|
||||
|---|------|------|--------|
|
||||
| 1 | act-runner 已注册且 label = `macos-arm64` | ✅ PM2 托管(sanguo-act-runner, id=44),崩溃自动重启 | 姜维确认 |
|
||||
| 2 | Gitea repository secrets 已配置(CI_TOKEN) | ⚠️ 需确认 | 姜维 |
|
||||
| 2 | Gitea repository secrets 已配置(CI_TOKEN) | ✅ 姜维确认(sanguo/moziplus-v2 已配 CI_TOKEN) | 姜维 |
|
||||
| 3 | Gitea 组织级 Webhook 已启用(Hook ID=28) | ✅ 已确认 | 已确认 |
|
||||
| 4 | 各 Agent 的 GITEA_TOKEN 环境变量 | ⚠️ 待分配 | 庞统协调 |
|
||||
| 5 | main 分支保护规则(Review 才能 merge) | ⚠️ 需确认 | 姜维 |
|
||||
| 4 | 各 Agent 的 GITEA_TOKEN 环境变量 | ✅ 已写入各 Agent TOOLS.md,姜维确认 token 记录存在 | 庞统+姜维 |
|
||||
| 5 | main 分支保护规则(Review 才能 merge) | ✅ 姜维已配置(moziplus-v2 + sanguo_moziplus_v2,需1个approve) | 姜维 |
|
||||
| 6 | 禁止在 daemon 运行时跑全量 E2E | ✅ 已警告司马懿 | 已确认 |
|
||||
|
||||
> 第 5 点很关键——如果 main 分支没有保护规则,开发者可以直接 push main 跳过 Review。
|
||||
@@ -2753,3 +2756,12 @@ Gitea v1.23.4 自带完整的 CI 管理界面:
|
||||
| §17.6.4 | 新增 P3 端到端验证结果(S1-S6 逐项) |
|
||||
| §17.6.4 | 新增调研发现:Review API 枚举值、PullRequestReview webhook 支持、act-runner PM2 托管 |
|
||||
| §17.10 | #1 状态更新:act-runner 已纳入 PM2 托管 |
|
||||
|
||||
### v3.1 → v3.2 变更(工具链修复 + Mail 投递 bug 修复)
|
||||
|
||||
| 编号 | 变更内容 |
|
||||
|------|----------|
|
||||
| §16.4 | Review handler 双格式兼容:HANDLERS 注册表同时注册 `pull_request_review` / `pull_request_approved` 等多种事件名;`_handle_pull_request_review` 兼容 repo webhook(review.state/body/user)和 org webhook(review.type/content/sender)两种 payload 格式 |
|
||||
| §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 |
|
||||
|
||||
@@ -0,0 +1,121 @@
|
||||
# §18. 工具链端到端验证测试
|
||||
|
||||
> 日期:2026-06-09
|
||||
> 状态:已完成 ✅
|
||||
> 目标:用真实 Webhook 触发验证整条 Mail 通知链路
|
||||
|
||||
## 前置确认
|
||||
|
||||
- Gitea 用户名 ↔ Agent ID 映射:完全一致(admin, guanyu-dev, jiangwei-infra, pangtong-fujunshi, simayi-challenger, zhangfei-dev, zhaoyun-data)
|
||||
- Gitea 组织级 Webhook(Hook ID=28):姜维确认最近 5 条投递全部 is_succeed=1
|
||||
- Daemon 在线:sanguo-moziplus-v2 运行中
|
||||
- 测试仓库:sanguo/moziplus-v2
|
||||
|
||||
## 命名规范
|
||||
|
||||
- Issue 标题:`[E2E-TEST] xxx`
|
||||
- PR 标题:`[E2E-TEST] xxx`
|
||||
- 分支名:`test/e2e-<timestamp>`
|
||||
|
||||
## 验证步骤
|
||||
|
||||
| 步骤 | 操作 | 触发事件 | 预期 Mail 通知 | 验证点 |
|
||||
|------|------|----------|---------------|--------|
|
||||
| 1 | 创建 Issue `[E2E-TEST] Issue指派测试`,assignee=zhangfei-dev | issues (assigned) | zhangfei-dev 收到 "Issue 指派" Mail | Mail to/模板正确 |
|
||||
| 2 | 开分支 `test/e2e-<ts>`,创建 PR `[E2E-TEST] Review请求测试` | pull_request (opened) | simayi-challenger 收到 "Review 请求" Mail | Mail to/风险级别/文件列表 |
|
||||
| 3 | PR Review APPROVED | pull_request_review (approved) | PR 作者(pangtong-fujunshi) 收到 "Review 通过 ✓" Mail | result=通过 ✓ |
|
||||
| 4 | PR Review REQUEST_CHANGES | pull_request_review (rejected) | PR 作者收到 "Review 驳回 ✗" Mail | result=驳回 ✗ |
|
||||
| 5 | Issue 上发评论 `[CI] CI 失败 — 分支: test/e2e-xxx, 错误: build timeout` | issue_comment | Issue 作者收到 "CI 失败" Mail | 模板含分支/错误摘要 |
|
||||
| 6 | 创建标题含"部署失败"的 Issue(无指派) | issues (opened) | jiangwei-infra + pangtong-fujunshi 各收到 "部署失败" Mail | 双收件人 |
|
||||
| 7 | 关闭步骤 1 的 Issue,再发 CI 失败评论 | issue_comment (closed issue) | 不产生 Mail(负面测试) | handler 跳过 closed |
|
||||
| 8 | 重发步骤 1 Webhook(相同 delivery ID) | 重复事件 | 不产生新 Mail(幂等测试) | 返回 duplicate |
|
||||
|
||||
## 签名校验
|
||||
|
||||
已测试(GITEA_WEBHOOK_SECRET 已配置且生效):
|
||||
- ✅ 正确签名:请求正常处理
|
||||
- ✅ 无签名:返回 403 `signature verification failed`
|
||||
|
||||
## Review 意见来源
|
||||
|
||||
- 姜维(基础设施确认 + 边界验证建议)
|
||||
- 司马懿(遗漏点补充 + 命名规范 + 风险防范)
|
||||
|
||||
---
|
||||
|
||||
## 执行记录
|
||||
|
||||
> 2026-06-09 00:40~00:50 CST
|
||||
|
||||
### 步骤 1:Issue 指派 ✅
|
||||
- 操作:创建 Issue #22 `[E2E-TEST] Issue指派测试`,assignee=zhangfei-dev
|
||||
- Mail:`mail-1780936736480`,from=system, to=zhangfei-dev, title=`Issue 指派: [E2E-TEST] Issue指派测试`
|
||||
- 模板渲染正确(含 Issue 链接、标签、描述、建议分支名)
|
||||
|
||||
### 步骤 2:PR Review 请求 ✅
|
||||
- 操作:创建分支 `test/e2e-1780936838`,创建 PR #23
|
||||
- Mail:`mail-1780936851715`,from=system, to=simayi-challenger
|
||||
- 模板含 PR 链接、标题、作者(pangtong-fujunshi)、分支、风险级别(standard)
|
||||
- 附带:CI 失败通知 `mail-1780936876572`(CI 自动触发,符合预期)
|
||||
|
||||
### 步骤 3:Review APPROVED ✅
|
||||
- 操作:用 simayi-challenger token 提交 APPROVED review
|
||||
- Mail:`mail-1780936968411`,from=system, to=pangtong-fujunshi, title=`Review 通过 ✓`
|
||||
- 描述含审查者(simayi-challenger)、review body
|
||||
- ⚠️ 收到 2 封重复 Mail(org webhook + repo webhook 双触发)
|
||||
|
||||
### 步骤 4:Review REQUEST_CHANGES ✅
|
||||
- 操作:用 simayi-challenger token 提交 REQUEST_CHANGES review
|
||||
- Mail:`mail-1780936972207`,from=system, to=pangtong-fujunshi, title=`Review 驳回 ✗`
|
||||
- ⚠️ 同上,收到 2 封重复 Mail
|
||||
|
||||
### 步骤 5:CI 失败评论 ✅
|
||||
- 操作:在 Issue #22 发评论 `[CI] CI 失败 — 分支: test/e2e-1780936838, 错误: build timeout`
|
||||
- Mail:`mail-1780936994513`,from=system, to=pangtong-fujunshi, title=`CI 失败: sanguo/moziplus-v2#22`
|
||||
- 模板含分支提取和错误摘要
|
||||
|
||||
### 步骤 6:部署失败 Issue ✅
|
||||
- 操作:创建 Issue #24 `[E2E-TEST] 部署失败: test deploy`(无指派)
|
||||
- Mail:`mail-1780936999660` to=jiangwei-infra, `mail-1780936999684` to=pangtong-fujunshi
|
||||
- 双收件人验证通过 ✅
|
||||
|
||||
### 步骤 7:已关闭 Issue 负面测试 ✅
|
||||
- 操作:关闭 Issue #22 后发 `[CI] CI 失败 — 应被过滤`
|
||||
- 结果:未产生新 Mail ✅(只有步骤 5 的 1 封 CI Mail,步骤 7 的评论被正确过滤)
|
||||
|
||||
### 步骤 8:幂等测试 ✅
|
||||
- 操作:构造带正确 HMAC-SHA256 签名的 Webhook,用同一 delivery ID `test-idempotency-002` 发两次
|
||||
- 第一次:返回 `ok`,产生 Mail ✅
|
||||
- 第二次:返回 `duplicate`,无新 Mail ✅
|
||||
- 额外验证:不带签名的请求返回 403 `signature verification failed`(签名校验正常工作)
|
||||
|
||||
---
|
||||
|
||||
## 汇总
|
||||
|
||||
| 步骤 | 状态 | 备注 |
|
||||
|------|------|------|
|
||||
| 1. Issue 指派 | ✅ 通过 | Mail to/模板正确 |
|
||||
| 2. PR Review 请求 | ✅ 通过 | Mail to/风险级别/文件列表正确 |
|
||||
| 3. Review APPROVED | ✅ 通过 | E2E 测试中产生 2 封 Mail(根因已查明,非平台问题) |
|
||||
| 4. Review REQUEST_CHANGES | ✅ 通过 | 同上 |
|
||||
| 5. CI 失败评论 | ✅ 通过 | 分支提取正确 |
|
||||
| 6. 部署失败 Issue | ✅ 通过 | 双收件人验证通过 |
|
||||
| 7. 已关闭 Issue 过滤 | ✅ 通过 | 负面测试通过,无新 Mail |
|
||||
| 8. 幂等测试 | ✅ 通过 | 第二次返回 duplicate,无新 Mail;签名校验正常拦截无签名请求 |
|
||||
|
||||
## 发现的问题
|
||||
|
||||
### Review 事件双 Mail(已修复)
|
||||
- **现象**:E2E 测试步骤 3/4 中 Review 事件产生 2 封 Mail
|
||||
- **根因**(姜维深入调查确认):E2E 测试中庞统手动用 simayi token 提交了 Review,同时 simayi agent 收到 Review 请求 Mail 后也自主提交了 Review。是两次独立的 API 调用,**不是 Gitea bug 或平台配置问题**
|
||||
- 姜维控制实验:一次 review API 调用只产生 1 个 hook_task
|
||||
- Gitea 路由日志确认两次 POST 间隔 7 秒,payload 有差异(review_comments、updated_at 不同)
|
||||
- 之前的错误分析("Gitea webhookNotifier + actionsNotifier 双投递")已被推翻:actionsNotifier 走 handleWorkflows() 不创建 hook_task
|
||||
- **修复**:payload 内容去重作为防御性编程保留(`_is_duplicate` 新增内容去重 key = event + pr_num + sender + sha256(body_or_content)),司马懿 APPROVED
|
||||
- **验证**:PR #27 实测只产生 1 封 Mail ✅
|
||||
|
||||
### 根因分析教训
|
||||
- 姜维第一次分析给出了错误根因(Gitea 双 notifier),第二次深入调查后自我纠正
|
||||
- 庞统把姜维的第一次结论当事实汇报给主公,没有标注"这是姜维的调查结论,尚未独立验证"
|
||||
- **改进**:SOUL.md 新增规则——推测 vs 事实显式标注、引用他人结论时标注来源、结论被推翻时及时更正
|
||||
@@ -0,0 +1,372 @@
|
||||
# #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.md)5 行信息,无流程引导;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 各层具体内容
|
||||
|
||||
#### L1:AGENTS.md 加工具链协作行为段(所有 Agent 统一)
|
||||
|
||||
```markdown
|
||||
## 工具链协作(Gitea)
|
||||
|
||||
收到 Gitea 事件通知(Issue 指派、Review 请求、CI 失败等)时,按以下流程操作:
|
||||
|
||||
### 基本流程
|
||||
- **Issue 指派** → clone 仓库 → 开分支 → 编码 → 提 PR(参考 git-workflow Skill)
|
||||
- **Review 请求** → 读 PR diff(Gitea 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. 所有模板变量统一补齐
|
||||
|
||||
#### L3:Skill 按需加载(不改 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 改造 |
|
||||
+5
-4
@@ -11,9 +11,10 @@
|
||||
| 场景 | 命令 | 耗时 | 说明 |
|
||||
|------|------|------|------|
|
||||
| **改了某个模块** | `pytest tests/unit/test_spawner.py` | <5s | 只跑改动的模块对应的单元测试 |
|
||||
| **改了 API 层** | `pytest tests/integration/` | ~1min | 跑全部集成测试 |
|
||||
| **提交前快速验证** | `pytest -m "not e2e"` | ~2min | 不跑 E2E,验证不破坏现有功能 |
|
||||
| **部署前全量验证** | `RUN_INTEGRATION=1 pytest` | ~60min | 含 E2E,真实 Agent |
|
||||
| **改了 API 层** | `RUN_INTEGRATION=1 pytest tests/integration/` | ~1min | 跑全部集成测试 |
|
||||
| **提交前快速验证** | `pytest` | ~2min | 默认排除 integration 和 e2e |
|
||||
| **含集成测试** | `RUN_INTEGRATION=1 pytest` | ~5min | 包含 integration 测试 |
|
||||
| **部署前全量验证** | `RUN_INTEGRATION=1 pytest` | ~60min | 含 e2e,真实 Agent |
|
||||
| **只跑 E2E 场景** | `RUN_INTEGRATION=1 pytest tests/e2e/test_e2e_scenarios.py` | ~30min | 串行,一个跑完再下一个 |
|
||||
| **只跑 E2E 压力** | `RUN_INTEGRATION=1 pytest tests/e2e/test_e2e_stress.py` | ~10min | 并发测试 |
|
||||
|
||||
@@ -101,7 +102,7 @@ E2E(慢,真实 Agent) → 验证完整链路,需要 RUN_INTEGRATION=1
|
||||
## 关键规则
|
||||
|
||||
1. **只有 E2E 会 spawn 真实 Agent**,单元和集成不会
|
||||
2. **不带 `RUN_INTEGRATION=1` 跑 `pytest` 是安全的**,E2E 全部 skip
|
||||
2. **直接跑 `pytest` 是安全的**,integration 和 e2e 全部被排除(需 `RUN_INTEGRATION=1` 才跑)
|
||||
3. **E2E 场景测试串行**,一个完成再下一个,失败要分析根因再继续
|
||||
4. **E2E 压力测试并行**,场景测试全通过后再跑
|
||||
5. **测试数据用 `e2e-` 前缀**,atexit 兜底清理,手动清理见上方
|
||||
|
||||
+3
-1
@@ -8,8 +8,10 @@ requires-python = ">=3.9"
|
||||
asyncio_mode = "auto"
|
||||
testpaths = ["tests"]
|
||||
markers = [
|
||||
"integration: real agent tests (requires RUN_INTEGRATION=1)",
|
||||
"integration: integration tests (requires RUN_INTEGRATION=1)",
|
||||
"e2e: end-to-end tests with real daemon + Agent (requires RUN_INTEGRATION=1)",
|
||||
]
|
||||
# Default deselection of integration/e2e handled in conftest.py pytest_collection_modifyitems
|
||||
|
||||
[tool.pyright]
|
||||
venvPath = "."
|
||||
|
||||
@@ -5,14 +5,14 @@ from __future__ import annotations
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
|
||||
from src.blackboard.operations import Blackboard
|
||||
from src.blackboard.models import Task, Review
|
||||
from src.blackboard.queries import Queries
|
||||
from src.blackboard.db import VALID_STATUSES, VALID_TRANSITIONS, COMMENT_TYPES, OUTPUT_TYPES
|
||||
from src.blackboard.db import VALID_STATUSES, OUTPUT_TYPES
|
||||
from src.blackboard.registry import ProjectRegistry
|
||||
|
||||
from src.utils import get_data_root
|
||||
@@ -240,7 +240,7 @@ async def update_status(project_id: str, task_id: str, body: Dict[str, Any]):
|
||||
})
|
||||
|
||||
if not bb.update_task_status(task_id, new_status,
|
||||
agent=body.get("agent")):
|
||||
agent=body.get("agent")):
|
||||
raise HTTPException(409, {
|
||||
"error": "transition_failed",
|
||||
"detail": f"Status update failed for {task_id}",
|
||||
@@ -265,6 +265,7 @@ async def update_status(project_id: str, task_id: str, body: Dict[str, Any]):
|
||||
# --- @mention 自动提取(#04) ---
|
||||
_KNOWN_AGENT_IDS: list = []
|
||||
|
||||
|
||||
def _init_agent_ids():
|
||||
"""从配置文件加载 Agent ID 列表"""
|
||||
global _KNOWN_AGENT_IDS
|
||||
@@ -279,6 +280,7 @@ def _init_agent_ids():
|
||||
except Exception:
|
||||
_KNOWN_AGENT_IDS = []
|
||||
|
||||
|
||||
def _extract_mentions(text: str) -> list:
|
||||
"""从文本中自动提取 @agent-id 格式的 mention"""
|
||||
import re
|
||||
@@ -317,8 +319,8 @@ async def add_comment(project_id: str, task_id: str, body: Dict[str, Any]):
|
||||
merged_mentions = list(set(explicit_mentions + auto_mentions))
|
||||
|
||||
cid = bb.add_comment(task_id, body["author"], comment_body,
|
||||
comment_type=body.get("comment_type", "general"),
|
||||
mentions=merged_mentions)
|
||||
comment_type=body.get("comment_type", "general"),
|
||||
mentions=merged_mentions)
|
||||
if merged_mentions:
|
||||
bb.record_mentions(cid, task_id, merged_mentions)
|
||||
# #10: SSE 通知前端黑板有新 comment
|
||||
@@ -424,8 +426,8 @@ async def get_decisions(project_id: str, task_id: str):
|
||||
async def add_decision(project_id: str, task_id: str, body: Dict[str, Any]):
|
||||
bb = _bb(project_id)
|
||||
did = bb.add_decision(task_id, body["decider"], body["decision"],
|
||||
body["rationale"],
|
||||
alternatives=body.get("alternatives"))
|
||||
body["rationale"],
|
||||
alternatives=body.get("alternatives"))
|
||||
return {"ok": True, "decision_id": did}
|
||||
|
||||
|
||||
@@ -435,7 +437,7 @@ async def add_decision(project_id: str, task_id: str, body: Dict[str, Any]):
|
||||
async def add_observation(project_id: str, task_id: str, body: Dict[str, Any]):
|
||||
bb = _bb(project_id)
|
||||
oid = bb.add_observation(task_id, body["observer"], body["body"],
|
||||
severity=body.get("severity", "info"))
|
||||
severity=body.get("severity", "info"))
|
||||
return {"ok": True, "observation_id": oid}
|
||||
|
||||
|
||||
|
||||
@@ -9,7 +9,7 @@ from __future__ import annotations
|
||||
import json
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
|
||||
@@ -36,6 +36,7 @@ def _get_valid_agents() -> set:
|
||||
# fallback:硬编码
|
||||
return {"zhangfei-dev", "guanyu-dev", "zhaoyun-data", "jiangwei-infra", "pangtong-fujunshi", "simayi-challenger"}
|
||||
|
||||
|
||||
router = APIRouter(prefix="/api/mail", tags=["mail"])
|
||||
|
||||
MAIL_PROJECT_ID = "_mail"
|
||||
@@ -222,7 +223,7 @@ async def send_mail(body: Dict[str, Any]):
|
||||
|
||||
# A8: 只有原邮件的双方能回复(严格 1 对 1)
|
||||
if from_agent not in (orig_from, orig_to):
|
||||
raise HTTPException(400, f"只有邮件的发送者或接收者可以回复")
|
||||
raise HTTPException(400, "只有邮件的发送者或接收者可以回复")
|
||||
|
||||
# A6/A7: 自动纠正 to → 原邮件发件者
|
||||
to_agent = body.get("to", "").strip()
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict
|
||||
|
||||
from fastapi import APIRouter, HTTPException, Query
|
||||
|
||||
@@ -76,7 +76,7 @@ async def list_projects():
|
||||
async def create_project(body: Dict[str, Any]):
|
||||
reg = _registry()
|
||||
try:
|
||||
info = reg.create_project(
|
||||
reg.create_project(
|
||||
body["id"], body["name"],
|
||||
agents=body.get("agents", []),
|
||||
description=body.get("description", ""),
|
||||
|
||||
+67
-14
@@ -46,17 +46,42 @@ _TTL_SECONDS = 7 * 24 * 3600
|
||||
_idempotency_lock = asyncio.Lock()
|
||||
|
||||
|
||||
def _is_duplicate(event: str, delivery: str) -> bool:
|
||||
"""检查 Webhook 是否重复投递,自动清理过期条目。"""
|
||||
def _is_duplicate(event: str, delivery: str, payload: Optional[Dict[str, Any]] = None) -> bool:
|
||||
"""检查 Webhook 是否重复投递,自动清理过期条目。
|
||||
|
||||
双重去重策略:
|
||||
1. delivery UUID 去重(标准幂等)
|
||||
2. payload 内容去重(应对 Gitea v1.23.4 的 webhookNotifier + actionsNotifier
|
||||
对同一 review 生成不同 UUID 的双投递问题)
|
||||
"""
|
||||
now = time.time()
|
||||
# 清理过期条目
|
||||
while _delivery_timestamps and (now - _delivery_timestamps[0][0]) > _TTL_SECONDS:
|
||||
_, key = _delivery_timestamps.pop(0)
|
||||
_delivery_cache.discard(key)
|
||||
|
||||
# 检查 delivery UUID 去重
|
||||
key = f"{event}-{delivery}"
|
||||
if key in _delivery_cache:
|
||||
return True
|
||||
|
||||
# 检查 payload 内容去重(review 事件:同一 PR + 同一用户 + 同一内容)
|
||||
# 注意:Gitea webhookNotifier 用 review.body,actionsNotifier 用 review.content
|
||||
# 所以去重 key 需要同时取两个字段,确保两种格式生成相同 key
|
||||
if payload and "review" in event:
|
||||
pr_num = payload.get("pull_request", {}).get("number")
|
||||
sender = payload.get("sender", {}).get("login")
|
||||
review = payload.get("review", {})
|
||||
# 取 body 或 content,优先 body(webhookNotifier 格式)
|
||||
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}"
|
||||
if content_key in _delivery_cache:
|
||||
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))
|
||||
|
||||
_delivery_cache.add(key)
|
||||
_delivery_timestamps.append((now, key))
|
||||
return False
|
||||
@@ -141,7 +166,6 @@ def _calc_risk_level(changed_files: List[str]) -> str:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
|
||||
MAIL_PROJECT_ID = "_mail"
|
||||
|
||||
|
||||
@@ -258,7 +282,12 @@ async def _handle_pull_request(payload: Dict[str, Any]) -> None:
|
||||
|
||||
|
||||
async def _handle_pull_request_review(payload: Dict[str, Any]) -> None:
|
||||
"""处理 pull_request_review 事件:非 COMMENTED → 通知 PR 作者。"""
|
||||
"""处理 pull_request_review 事件:非 COMMENTED → 通知 PR 作者。
|
||||
|
||||
支持两种 payload 格式:
|
||||
- repo webhook: review.state = "APPROVED" / "REQUEST_CHANGES"
|
||||
- org webhook (Gitea v1.23.4): review.type = "pull_request_review_approved" / "pull_request_review_rejected"
|
||||
"""
|
||||
review = payload.get("review")
|
||||
if not review or not isinstance(review, dict):
|
||||
logger.warning("pull_request_review event missing review field, skipping")
|
||||
@@ -267,7 +296,18 @@ async def _handle_pull_request_review(payload: Dict[str, Any]) -> None:
|
||||
if not pr or not isinstance(pr, dict):
|
||||
logger.warning("pull_request_review event missing pull_request field, skipping")
|
||||
return
|
||||
|
||||
# 兼容两种 payload 格式提取 state
|
||||
state = review.get("state", "")
|
||||
if not state:
|
||||
# org webhook 格式:review.type = "pull_request_review_approved"
|
||||
review_type = review.get("type", "")
|
||||
type_map = {
|
||||
"pull_request_review_approved": "APPROVED",
|
||||
"pull_request_review_rejected": "REQUEST_CHANGES",
|
||||
"pull_request_review_comment": "COMMENTED",
|
||||
}
|
||||
state = type_map.get(review_type, "")
|
||||
|
||||
# 只通知 APPROVED 和 REQUEST_CHANGES,跳过 COMMENTED 和其他状态
|
||||
if state == "COMMENTED":
|
||||
@@ -277,8 +317,9 @@ async def _handle_pull_request_review(payload: Dict[str, Any]) -> None:
|
||||
pr_number = pr.get("number", 0)
|
||||
pr_title = pr.get("title", "")
|
||||
pr_author = pr.get("user", {}).get("login", "unknown")
|
||||
reviewer = review.get("user", {}).get("login", "unknown")
|
||||
review_body = review.get("body", "(无评论)")
|
||||
# 兼容:org webhook 的 review 没有 user,从 sender 取
|
||||
reviewer = review.get("user", {}).get("login", "") or payload.get("sender", {}).get("login", "unknown")
|
||||
review_body = review.get("body", "") or review.get("content", "(无评论)")
|
||||
|
||||
result_map = {"APPROVED": "通过 ✓", "REQUEST_CHANGES": "驳回 ✗"}
|
||||
if state not in result_map:
|
||||
@@ -372,6 +413,12 @@ async def _handle_issue_comment(payload: Dict[str, Any]) -> None:
|
||||
if not issue or not isinstance(issue, dict):
|
||||
logger.warning("issue_comment event missing issue field, skipping")
|
||||
return
|
||||
|
||||
# 已关闭的 Issue/PR 不再发送 CI 失败通知
|
||||
if issue.get("state") == "closed":
|
||||
logger.debug("Skipping CI failure notification for closed issue #%s", issue.get("number"))
|
||||
return
|
||||
|
||||
repo = _repo_fullname(payload)
|
||||
issue_number = issue.get("number", 0)
|
||||
|
||||
@@ -401,6 +448,12 @@ async def _handle_issue_comment(payload: Dict[str, Any]) -> None:
|
||||
_EVENT_HANDLERS: Dict[str, Any] = {
|
||||
"pull_request": _handle_pull_request,
|
||||
"pull_request_review": _handle_pull_request_review,
|
||||
"pull_request_review_approved": _handle_pull_request_review,
|
||||
"pull_request_review_rejected": _handle_pull_request_review,
|
||||
"pull_request_review_comment": _handle_pull_request_review,
|
||||
# Gitea v1.23.4 实际发出的 review 子事件(无 _review_ 中间段)
|
||||
"pull_request_approved": _handle_pull_request_review,
|
||||
"pull_request_rejected": _handle_pull_request_review,
|
||||
"issues": _handle_issues,
|
||||
"issue_comment": _handle_issue_comment,
|
||||
}
|
||||
@@ -433,20 +486,20 @@ async def gitea_webhook(
|
||||
logger.warning("Webhook signature verification failed")
|
||||
return Response(status_code=403, content="signature verification failed")
|
||||
|
||||
# 2. 幂等检查
|
||||
if x_gitea_event and x_gitea_delivery:
|
||||
async with _idempotency_lock:
|
||||
if _is_duplicate(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")
|
||||
|
||||
# 3. 解析 payload
|
||||
# 3. 解析 payload(提前解析,用于幂等检查)
|
||||
try:
|
||||
payload = await request.json()
|
||||
except Exception:
|
||||
logger.warning("Failed to parse webhook payload")
|
||||
return Response(status_code=200, content="invalid payload")
|
||||
|
||||
# 2. 幂等检查(需要在 payload 解析后,以支持内容去重)
|
||||
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)
|
||||
return Response(status_code=200, content="duplicate")
|
||||
|
||||
# 4. 查找 handler
|
||||
handler = _EVENT_HANDLERS.get(x_gitea_event or "")
|
||||
if not handler:
|
||||
|
||||
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
import sqlite3
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def init_db(db_path: Path) -> None:
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, List, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
|
||||
@@ -11,7 +11,6 @@ from typing import Any, Dict, List, Optional
|
||||
|
||||
from .db import (
|
||||
VALID_TRANSITIONS,
|
||||
VALID_STATUSES,
|
||||
COMMENT_TYPES,
|
||||
EVENT_TYPES,
|
||||
OUTPUT_TYPES,
|
||||
@@ -693,7 +692,6 @@ class Blackboard:
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
# ── Checkpoint CRUD(M3) ──
|
||||
|
||||
def create_checkpoint(
|
||||
|
||||
@@ -10,7 +10,7 @@ from typing import List, Optional
|
||||
|
||||
from src.blackboard.operations import Blackboard
|
||||
from src.utils import get_data_root
|
||||
from src.blackboard.models import Task, Comment, Output, Decision, Observation, Review, Experience
|
||||
from src.blackboard.models import Task, Review
|
||||
from src.blackboard.queries import Queries
|
||||
from src.blackboard.registry import ProjectRegistry
|
||||
|
||||
@@ -262,7 +262,7 @@ def build_admin_parser() -> argparse.ArgumentParser:
|
||||
p_pc.add_argument("--description", default="")
|
||||
|
||||
# project list
|
||||
p_pl = 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")
|
||||
|
||||
@@ -11,8 +11,7 @@ A 类 Skill 由引擎确定性注入全文,不靠 Description 触发。
|
||||
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, List
|
||||
|
||||
logger = logging.getLogger("moziplus-v2.bootstrap")
|
||||
|
||||
|
||||
@@ -73,7 +73,7 @@ class ActiveAgentCounter:
|
||||
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])
|
||||
agent_id, cd, self._cooldown_until[agent_id])
|
||||
|
||||
async def can_acquire(self, agent_id: str, session_id: str = "main") -> bool:
|
||||
"""三层检查:cooldown → global → per agent → per session key"""
|
||||
|
||||
@@ -12,9 +12,9 @@ Dispatcher 负责:
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import pathlib
|
||||
import logging
|
||||
import sqlite3
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
@@ -22,7 +22,7 @@ from typing import Any, Dict, List, Optional
|
||||
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, RouteDecision
|
||||
from src.daemon.router import AgentRouter
|
||||
|
||||
logger = logging.getLogger("moziplus-v2.dispatcher")
|
||||
|
||||
@@ -194,6 +194,7 @@ class Dispatcher:
|
||||
_task_id = task.id
|
||||
_mail_db = db_path
|
||||
_disp = self
|
||||
|
||||
def _mail_on_checks_passed():
|
||||
nonlocal _mail_marked_working
|
||||
if not _disp._mail_auto_working(_task_id, _mail_db):
|
||||
@@ -203,8 +204,8 @@ class Dispatcher:
|
||||
|
||||
# 构建 spawn message
|
||||
message = self._build_spawn_message(task, agent_id, project_config,
|
||||
mode=decision.get("mode", ""),
|
||||
spawn_type=action_type or "executor")
|
||||
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 保证
|
||||
@@ -218,7 +219,7 @@ class Dispatcher:
|
||||
def _mail_on_complete(aid, outcome):
|
||||
# 幻觉门控:检查是否有回复,自动标 done/failed
|
||||
try:
|
||||
_dispatcher._mail_auto_complete(_task_id, aid, _mail_db, _must_haves)
|
||||
_dispatcher._mail_auto_complete(_task_id, aid, _mail_db, _must_haves, outcome=outcome)
|
||||
except Exception as e:
|
||||
logger.error("Mail %s: on_complete error: %s", _task_id, e)
|
||||
on_complete = _mail_on_complete
|
||||
@@ -269,8 +270,8 @@ class Dispatcher:
|
||||
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")
|
||||
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 状态
|
||||
@@ -576,7 +577,7 @@ class Dispatcher:
|
||||
|
||||
def _mail_oc_legacy(aid, outcome):
|
||||
try:
|
||||
_disp._mail_auto_complete(_t_id, aid, _m_db, _m_mh)
|
||||
_disp._mail_auto_complete(_t_id, aid, _m_db, _m_mh, outcome=outcome)
|
||||
except Exception as e:
|
||||
logger.error("Mail %s: legacy on_complete error: %s", _t_id, e)
|
||||
on_complete_legacy = _mail_oc_legacy
|
||||
@@ -661,7 +662,7 @@ class Dispatcher:
|
||||
logger.error("Mail %s: failed to revert to pending: %s", task_id, e)
|
||||
|
||||
def _mail_auto_complete(self, task_id: str, agent_id: str,
|
||||
db_path: Path, must_haves: str) -> None:
|
||||
db_path: Path, must_haves: str, outcome=None) -> None:
|
||||
"""Mail 任务:on_complete 后自动标 done/failed(含幻觉门控)"""
|
||||
try:
|
||||
# 解析 performative
|
||||
@@ -712,6 +713,14 @@ class Dispatcher:
|
||||
logger.error("Mail %s: all 3 failed attempts failed, leaving for ticker", task_id)
|
||||
return
|
||||
|
||||
# inform 类型:只对成功 outcome 标 done,失败 outcome 留 working 等 ticker 重投
|
||||
# Task 路径不受此 bug 影响(走 _task_auto_complete 独立逻辑)
|
||||
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)
|
||||
return
|
||||
|
||||
# 标 done(重试 3 次)
|
||||
for attempt in range(3):
|
||||
try:
|
||||
|
||||
@@ -14,7 +14,7 @@ import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
logger = logging.getLogger("moziplus-v2.experience")
|
||||
|
||||
@@ -68,7 +68,7 @@ class Experience:
|
||||
@classmethod
|
||||
def from_dict(cls, data: Dict[str, Any]) -> Experience:
|
||||
return cls(**{k: v for k, v in data.items() if k != "id"},
|
||||
experience_id=data.get("id"))
|
||||
experience_id=data.get("id"))
|
||||
|
||||
|
||||
class ExperienceStore:
|
||||
@@ -284,7 +284,7 @@ class ExperienceDistiller:
|
||||
all_tags.append(task_type)
|
||||
|
||||
results = self.store.search(tags=all_tags if all_tags else None,
|
||||
query=query, limit=limit)
|
||||
query=query, limit=limit)
|
||||
|
||||
# 按置信度排序
|
||||
results.sort(key=lambda e: e.confidence, reverse=True)
|
||||
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
|
||||
@@ -9,9 +9,9 @@ from __future__ import annotations
|
||||
import json
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, Optional
|
||||
from typing import Any, Dict
|
||||
|
||||
from src.blackboard.db import get_connection, init_db
|
||||
from src.blackboard.db import get_connection
|
||||
from src.blackboard.queries import Queries
|
||||
|
||||
logger = logging.getLogger("moziplus-v2.health")
|
||||
@@ -41,7 +41,6 @@ class HealthChecker:
|
||||
{"healthy": bool, "zombie": bool, "stale_ticks": int,
|
||||
"alert_written": bool, "resolved": bool}
|
||||
"""
|
||||
db_key = str(db_path)
|
||||
result: Dict[str, Any] = {
|
||||
"healthy": True,
|
||||
"zombie": False,
|
||||
@@ -58,7 +57,7 @@ class HealthChecker:
|
||||
# 用 event count 变化判断是否有真实变更
|
||||
conn = queries._conn()
|
||||
try:
|
||||
total_events = 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'"
|
||||
|
||||
+2
-3
@@ -15,7 +15,6 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Coroutine, Dict, List, Optional
|
||||
|
||||
@@ -57,7 +56,7 @@ class InboxWatcher:
|
||||
self._running = True
|
||||
self._task = asyncio.create_task(self._loop())
|
||||
logger.info("Inbox watcher started (path=%s, interval=%.1fs)",
|
||||
self.inbox_path, self.watch_interval)
|
||||
self.inbox_path, self.watch_interval)
|
||||
|
||||
async def stop(self) -> None:
|
||||
"""停止监听"""
|
||||
@@ -69,7 +68,7 @@ class InboxWatcher:
|
||||
except asyncio.CancelledError:
|
||||
pass
|
||||
logger.info("Inbox watcher stopped (processed=%d, errors=%d)",
|
||||
self._total_processed, self._total_errors)
|
||||
self._total_processed, self._total_errors)
|
||||
|
||||
@property
|
||||
def is_running(self) -> bool:
|
||||
|
||||
@@ -108,7 +108,7 @@ def notify_mail_failed(db_path: Path, original_mail_id: str,
|
||||
)
|
||||
bb.create_task(notify_task)
|
||||
logger.info("Mail %s: sent failure notification to %s (original_sender=%s, reason=%s, notify_id=%s)",
|
||||
original_mail_id, target_agent, from_agent, reason, notify_id)
|
||||
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)
|
||||
|
||||
@@ -8,15 +8,12 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
from typing import Any, Callable, Dict, List, Optional
|
||||
|
||||
from src.blackboard.models import Task
|
||||
from src.blackboard.operations import Blackboard
|
||||
from src.blackboard.queries import Queries
|
||||
|
||||
logger = logging.getLogger("moziplus-v2.review")
|
||||
|
||||
|
||||
@@ -10,12 +10,11 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import re
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional, Tuple
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
logger = logging.getLogger("moziplus-v2.skill")
|
||||
|
||||
|
||||
+18
-15
@@ -7,6 +7,7 @@ Subagent: 占位(实际通过 OpenClaw Gateway API sessions_spawn,F17 完善)
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import pathlib
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
@@ -15,7 +16,7 @@ from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from src.blackboard.db import get_connection, init_db
|
||||
from src.blackboard.db import get_connection
|
||||
|
||||
logger = logging.getLogger("moziplus-v2.spawner")
|
||||
|
||||
@@ -299,7 +300,7 @@ class AgentSpawner:
|
||||
project_id, agent_id)
|
||||
|
||||
def _build_minimal_fallback(self, task_id, title, description, must_haves,
|
||||
project_id, agent_id):
|
||||
project_id, agent_id):
|
||||
"""最小 fallback:只有任务上下文 + API 指令"""
|
||||
task_section = f"""## 任务
|
||||
{title}
|
||||
@@ -311,7 +312,7 @@ class AgentSpawner:
|
||||
return task_section + "\n\n---\n\n" + api_section
|
||||
|
||||
def _build_api_section(self, project_id: str, task_id: str,
|
||||
agent_id: str) -> str:
|
||||
agent_id: str) -> str:
|
||||
"""构建 API 回写操作指令(BootstrapBuilder 模式下补充)"""
|
||||
# mail 任务直接 done,不走 review
|
||||
success_status = '"done"' if project_id == "_mail" else '"review"'
|
||||
@@ -337,8 +338,8 @@ curl -X POST http://{self.api_host}:{self.api_port}/api/projects/{project_id}/ta
|
||||
"""
|
||||
|
||||
def _build_discussion_prompt(self, task_id: str, title: str,
|
||||
description: str, must_haves: str,
|
||||
project_id: str, agent_id: str) -> str:
|
||||
description: str, must_haves: str,
|
||||
project_id: str, agent_id: str) -> str:
|
||||
"""构建讨论类 spawn prompt(§3.3 框架 + Boids)"""
|
||||
goal_snapshot = description or title
|
||||
constraints = must_haves or "(无特殊约束)"
|
||||
@@ -379,9 +380,8 @@ curl -X POST http://{self.api_host}:{self.api_port}/api/projects/{project_id}/ta
|
||||
return router.agent_profiles.get(agent_id)
|
||||
return None
|
||||
|
||||
|
||||
def _build_mail_prompt(self, task_id: str, title: str, description: str,
|
||||
must_haves: str, agent_id: str) -> str:
|
||||
must_haves: str, agent_id: str) -> str:
|
||||
"""构建 Mail 专用精简模板"""
|
||||
# 解析 must_haves 获取 from 和 performative
|
||||
from_agent = agent_id
|
||||
@@ -575,7 +575,7 @@ curl -X POST http://{self.api_host}:{self.api_port}/api/projects/{project_id}/ta
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
self._register_session(session_id, agent_id, task_id, proc.pid,
|
||||
broadcast_task_ids=broadcast_task_ids)
|
||||
broadcast_task_ids=broadcast_task_ids)
|
||||
logger.info("Spawned agent %s (session=%s, pid=%d)",
|
||||
agent_id, session_id, proc.pid)
|
||||
|
||||
@@ -848,10 +848,13 @@ curl -X POST http://{api_host}:{api_port}/api/projects/{project_id}/tasks/{task_
|
||||
# A8(gateway_unreachable), A11(lock_conflict),
|
||||
# A10(compact_failed), A12(agent_error)
|
||||
# v2.8.1 Fix-3a: crash 类 outcome 设 cooldown,给 agent session 恢复时间
|
||||
if outcome in ("crashed", "compact_failed", "process_crash", "session_stuck",
|
||||
"compact_hanging", "agent_error", "compact_interrupted") and self.counter:
|
||||
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)
|
||||
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("Crash/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)
|
||||
@@ -878,7 +881,7 @@ curl -X POST http://{api_host}:{api_port}/api/projects/{project_id}/tasks/{task_
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
stderr_text = b"".join(stderr_chunks).decode("utf-8", errors="replace")
|
||||
_ = b"".join(stderr_chunks).decode("utf-8", errors="replace")
|
||||
|
||||
# 检查 session 状态
|
||||
state = self._check_session_state(agent_id)
|
||||
@@ -1425,7 +1428,7 @@ curl -X POST http://{api_host}:{api_port}/api/projects/{project_id}/tasks/{task_
|
||||
return defaults
|
||||
|
||||
def _update_retry_counts(self, db_path: Optional[Path],
|
||||
task_id: Optional[str], counts: dict):
|
||||
task_id: Optional[str], counts: dict):
|
||||
"""将 retry counts 写回最新 task_attempt 的 metadata"""
|
||||
if not db_path or not task_id:
|
||||
return
|
||||
@@ -1485,8 +1488,8 @@ curl -X POST http://{api_host}:{api_port}/api/projects/{project_id}/tasks/{task_
|
||||
from src.blackboard.operations import Blackboard
|
||||
bb = Blackboard(db_path)
|
||||
cid = bb.add_comment(task_id, "daemon",
|
||||
f"@pangtong-fujunshi 任务执行失败: {reason},请评估是否需要介入",
|
||||
comment_type="system")
|
||||
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)
|
||||
except Exception as e:
|
||||
|
||||
+1
-4
@@ -9,14 +9,11 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import subprocess
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional, Set
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from src.blackboard.models import Event
|
||||
|
||||
logger = logging.getLogger("moziplus-v2.sse")
|
||||
|
||||
|
||||
+23
-24
@@ -21,7 +21,6 @@ from dataclasses import dataclass, field as dc_field
|
||||
|
||||
from src.blackboard.operations import Blackboard
|
||||
from src.blackboard.db import get_connection
|
||||
from src.blackboard.models import Task
|
||||
from src.daemon.spawner import AgentBusyError
|
||||
from src.blackboard.queries import Queries
|
||||
from src.blackboard.registry import ProjectRegistry
|
||||
@@ -35,6 +34,7 @@ class BroadcastRound:
|
||||
responded_agents: set = dc_field(default_factory=set) # 已返回反馈的 Agent(含 NO_REPLY)
|
||||
round_number: int = 0 # 当前第几轮(0=未开始,1=第1轮)
|
||||
|
||||
|
||||
logger = logging.getLogger("moziplus-v2.ticker")
|
||||
|
||||
|
||||
@@ -391,7 +391,7 @@ class Ticker:
|
||||
MAX_ROUNDS = 5 # §4.5 防无限循环
|
||||
|
||||
async def _check_round_complete(self, db_path: Path,
|
||||
project_id: str) -> List[str]:
|
||||
project_id: str) -> List[str]:
|
||||
"""检测 parent task 下所有 sub task 终态 → spawn 庞统 review
|
||||
|
||||
流程(§4.4):
|
||||
@@ -462,7 +462,7 @@ class Ticker:
|
||||
"Round %d review spawned for parent %s (subs: %s)",
|
||||
new_round, parent_id, summary
|
||||
)
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
logger.exception("Round check error for parent %s", parent_id)
|
||||
|
||||
return reviewed
|
||||
@@ -531,9 +531,9 @@ Parent Task ID: {parent_task.id}
|
||||
"""
|
||||
|
||||
async def _spawn_pangtong_review(self, parent_task,
|
||||
review_prompt: str,
|
||||
project_id: str,
|
||||
new_round: int = 0) -> bool:
|
||||
review_prompt: str,
|
||||
project_id: str,
|
||||
new_round: int = 0) -> bool:
|
||||
"""Spawn 庞统进行 review
|
||||
|
||||
流程:
|
||||
@@ -543,7 +543,6 @@ Parent Task ID: {parent_task.id}
|
||||
"""
|
||||
try:
|
||||
agent_id = "pangtong-fujunshi"
|
||||
session_id = f"review-{parent_task.id}-r{new_round}"
|
||||
|
||||
# 构造 on_complete 回调:解析庞统结论,更新 parent 状态
|
||||
async def _on_review_complete(aid: str, outcome: str):
|
||||
@@ -586,7 +585,7 @@ Parent Task ID: {parent_task.id}
|
||||
self._set_parent_reviewing(parent_task.id, project_id)
|
||||
return True
|
||||
return False
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
logger.exception("Failed to spawn pangtong review for %s", parent_task.id)
|
||||
return False
|
||||
|
||||
@@ -603,14 +602,14 @@ Parent Task ID: {parent_task.id}
|
||||
(parent_id,))
|
||||
conn.commit()
|
||||
logger.info("Parent %s → reviewing (round review in progress)",
|
||||
parent_id)
|
||||
parent_id)
|
||||
finally:
|
||||
conn.close()
|
||||
except Exception:
|
||||
logger.exception("Failed to set parent %s to reviewing", parent_id)
|
||||
|
||||
def _handle_review_conclusion(self, parent_id: str, project_id: str,
|
||||
review_text: str, round_num: int):
|
||||
review_text: str, round_num: int):
|
||||
"""解析庞统 review 结论,更新 parent 状态
|
||||
|
||||
review_text 是庞统回复的文本(从 spawner session meta payloads 拼接)。
|
||||
@@ -675,7 +674,7 @@ Parent Task ID: {parent_task.id}
|
||||
MENTION_MAX_RETRIES = 5
|
||||
|
||||
async def _process_mentions(self, db_path: Path,
|
||||
project_id: str) -> List[str]:
|
||||
project_id: str) -> List[str]:
|
||||
"""扫描 pending mentions → spawn 被 @ 的 Agent
|
||||
|
||||
流程(§3.4):
|
||||
@@ -767,8 +766,8 @@ Parent Task ID: {parent_task.id}
|
||||
from src.blackboard.blackboard import Blackboard
|
||||
bb2 = Blackboard(rdb_path)
|
||||
bb2.add_comment(_t_id, "daemon",
|
||||
f"@{t_row['assignee']} 审查结论: {verdict_str},请查看详情并决定接受或反驳",
|
||||
comment_type="review")
|
||||
f"@{t_row['assignee']} 审查结论: {verdict_str},请查看详情并决定接受或反驳",
|
||||
comment_type="review")
|
||||
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)
|
||||
@@ -805,7 +804,7 @@ Parent Task ID: {parent_task.id}
|
||||
# Agent 忙,不递增 retry_count,等下次 tick 自然重试
|
||||
logger.info("Mention spawn skipped: %s busy, will retry next tick", agent_id)
|
||||
|
||||
except Exception as e:
|
||||
except Exception:
|
||||
logger.exception("Mention processing error for agent %s", agent_id)
|
||||
for item in items:
|
||||
try:
|
||||
@@ -948,7 +947,7 @@ Parent Task ID: {parent_task.id}
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
async def _dispatch_pending(self, db_path: Path,
|
||||
project_id: str) -> List[str]:
|
||||
project_id: str) -> List[str]:
|
||||
"""扫描 pending 任务并调度
|
||||
|
||||
v3.0: 两条路径
|
||||
@@ -1242,7 +1241,7 @@ Parent Task ID: {parent_task.id}
|
||||
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]:
|
||||
project_id: str) -> List[str]:
|
||||
"""扫描 review 状态任务,检查是否有产出,调度审查 Agent"""
|
||||
# mail 任务不走 review 流程,直接跳过
|
||||
if project_id == "_mail":
|
||||
@@ -1344,7 +1343,7 @@ Parent Task ID: {parent_task.id}
|
||||
)
|
||||
reclaimed.append(task.id)
|
||||
logger.warning("Escalated %s: no taker after %d broadcasts",
|
||||
task.id, retry_count)
|
||||
task.id, retry_count)
|
||||
finally:
|
||||
conn.close()
|
||||
else:
|
||||
@@ -1423,7 +1422,7 @@ Parent Task ID: {parent_task.id}
|
||||
if ok:
|
||||
reclaimed.append(task.id)
|
||||
logger.info("Mail %s: ticker recheck found reply, marked done (%.1fm)",
|
||||
task.id, elapsed)
|
||||
task.id, elapsed)
|
||||
finally:
|
||||
conn.close()
|
||||
continue
|
||||
@@ -1440,7 +1439,7 @@ Parent Task ID: {parent_task.id}
|
||||
if ok:
|
||||
reclaimed.append(task.id)
|
||||
logger.warning("Task %s timed out (working %.1fm > %.1fm)",
|
||||
task.id, elapsed, timeout_minutes)
|
||||
task.id, elapsed, timeout_minutes)
|
||||
finally:
|
||||
conn.close()
|
||||
except (ValueError, TypeError):
|
||||
@@ -1501,7 +1500,7 @@ Parent Task ID: {parent_task.id}
|
||||
return True # 保守:查询失败假设有回复
|
||||
|
||||
def _check_recent_routing(self, db_path: Path, task_id: str,
|
||||
action_type: str) -> bool:
|
||||
action_type: str) -> bool:
|
||||
"""检查最近 5 分钟内是否已 dispatch 过指定类型的路由(防重复)"""
|
||||
try:
|
||||
conn = get_connection(db_path)
|
||||
@@ -1579,11 +1578,11 @@ Parent Task ID: {parent_task.id}
|
||||
|
||||
if recovery_report["total_recovered"] > 0:
|
||||
logger.info("Startup recovery: %d tasks recovered across %d projects",
|
||||
recovery_report["total_recovered"],
|
||||
len(recovery_report["projects"]))
|
||||
recovery_report["total_recovered"],
|
||||
len(recovery_report["projects"]))
|
||||
elif recovery_report["total_noop"] > 0:
|
||||
logger.info("Startup recovery: %d tasks kept as-is (no recovery needed)",
|
||||
recovery_report["total_noop"])
|
||||
recovery_report["total_noop"])
|
||||
else:
|
||||
logger.info("Startup recovery: no non-terminal tasks found, clean start")
|
||||
|
||||
@@ -1629,7 +1628,7 @@ Parent Task ID: {parent_task.id}
|
||||
return recovered, noop_count
|
||||
|
||||
def _determine_recovery_action(self, conn, task, status: str,
|
||||
db_path: Path) -> Optional[str]:
|
||||
db_path: Path) -> Optional[str]:
|
||||
"""根据黑板线索决定恢复动作,返回 None 表示不需要干预"""
|
||||
task_id = task["id"]
|
||||
|
||||
|
||||
+22
-12
@@ -191,7 +191,6 @@ async def lifespan(app: FastAPI):
|
||||
)
|
||||
|
||||
# ExperienceDistiller(经验自动蒸馏)
|
||||
experience_config = config.get("experience", {})
|
||||
experience_distiller = ExperienceDistiller(
|
||||
store=ExperienceStore(store_path=DATA_ROOT / "experiences.jsonl"),
|
||||
)
|
||||
@@ -252,13 +251,13 @@ app.add_middleware(
|
||||
# API 路由注册
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
from src.api.blackboard_routes import router as blackboard_router
|
||||
from src.api.checkpoint_routes import router as checkpoint_router
|
||||
from src.api.daemon_routes import router as daemon_router
|
||||
from src.api.project_routes import router as project_router
|
||||
from src.api.sse_routes import router as sse_router
|
||||
from src.api.mail_routes import router as mail_router
|
||||
from src.api.toolchain_routes import router as toolchain_router
|
||||
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)
|
||||
@@ -268,6 +267,17 @@ app.include_router(sse_router)
|
||||
app.include_router(mail_router)
|
||||
app.include_router(toolchain_router)
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 健康检查端点
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@app.get("/api/healthz")
|
||||
async def healthz():
|
||||
"""轻量级健康检查,无需认证"""
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 兼容端点
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -289,16 +299,16 @@ async def list_projects_compat():
|
||||
DIST_DIR = Path(__file__).parent / "frontend" / "dist"
|
||||
if DIST_DIR.exists():
|
||||
# v3.1: 缓存策略 - HTML 不缓存(确保新版本生效),JS/CSS 长缓存(Vite content hash 已处理)
|
||||
import mimetypes
|
||||
_static_app = StaticFiles(directory=str(DIST_DIR), html=True)
|
||||
|
||||
|
||||
class CachedStaticFiles:
|
||||
"""包装 StaticFiles,添加 Cache-Control 头"""
|
||||
def __init__(self, app):
|
||||
self._app = app
|
||||
|
||||
|
||||
async def __call__(self, scope, receive, send):
|
||||
original_send = send
|
||||
|
||||
async def patched_send(message):
|
||||
if message.get("type") == "http.response.start":
|
||||
headers = dict(message.get("headers", []))
|
||||
@@ -310,5 +320,5 @@ if DIST_DIR.exists():
|
||||
message["headers"] = list(headers.items())
|
||||
await original_send(message)
|
||||
await self._app(scope, receive, patched_send)
|
||||
|
||||
|
||||
app.mount("/", CachedStaticFiles(_static_app), name="frontend")
|
||||
|
||||
@@ -10,7 +10,6 @@ from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
|
||||
|
||||
def get_data_root() -> Path:
|
||||
|
||||
@@ -55,6 +55,21 @@ def client_with_isolation(isolated_data_root):
|
||||
|
||||
# ── E2E gate ──
|
||||
|
||||
def pytest_collection_modifyitems(config, items):
|
||||
if not os.environ.get("RUN_INTEGRATION"):
|
||||
skip_reason = "needs RUN_INTEGRATION=1"
|
||||
remaining = []
|
||||
deselected = []
|
||||
for item in items:
|
||||
if "integration" in item.keywords or "e2e" in item.keywords:
|
||||
deselected.append(item)
|
||||
else:
|
||||
remaining.append(item)
|
||||
if deselected:
|
||||
config.hook.pytest_deselected(items=deselected)
|
||||
items[:] = remaining
|
||||
|
||||
|
||||
skip_no_integration = pytest.mark.skipif(
|
||||
not os.environ.get("RUN_INTEGRATION"),
|
||||
reason="Set RUN_INTEGRATION=1 to run E2E tests against real daemon",
|
||||
|
||||
Reference in New Issue
Block a user