Compare commits
55 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 6c6e884ce3 | |||
| 36ba629b69 | |||
| 3b7ecaf446 | |||
| a8a1886f27 | |||
| c7aca6fc72 | |||
| 387fa3214f | |||
| 88a2409e67 | |||
| e7f28cd36e | |||
| f25af64f00 | |||
| 866060e557 | |||
| 33c58a7dae | |||
| d82d29fd79 | |||
| 0e19ea2009 | |||
| 73454c0787 | |||
| b80290fe78 | |||
| 9bb1e9dc64 | |||
| 5474d0a0e8 | |||
| d0e0055a2e | |||
| 9727bf98d9 | |||
| 5d24183c14 | |||
| 7b32994c75 | |||
| 40efa1c623 | |||
| f4fc941bd1 | |||
| c6a0567161 | |||
| 3f5b3619c8 | |||
| e9bbcf41c9 | |||
| 2c612baa04 | |||
| 98d17292b0 | |||
| fe541f6c89 | |||
| ddc1c7285a | |||
| 3f71f53e4a | |||
| 3c2c0f3175 | |||
| 95a8abca96 | |||
| bcb8ced17a | |||
| caf750fad6 | |||
| 7918b12ff7 | |||
| 3441f4325f | |||
| a4bb752d71 | |||
| d6612de6de | |||
| f33190dc1e | |||
| 1089991455 | |||
| 4bf3d97a04 | |||
| d98fae007f | |||
| 3c30a9b135 | |||
| 6bb0596e6d | |||
| 25863634c2 | |||
| 09971b555a | |||
| ce1b0902dd | |||
| 8d72a1fa19 | |||
| 2c970557c8 | |||
| 4a4e99f738 | |||
| 1b0007f244 | |||
| b953f6da02 | |||
| cc974bf258 | |||
| 42a28585b8 |
@@ -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"
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
# 部署目标配置 — PR 合并后自动部署(集中管理)
|
||||
# 平台级能力,所有仓库的部署配置统一在这里维护
|
||||
|
||||
targets:
|
||||
sanguo/sanguo_moziplus_v2:
|
||||
dev_dir: ~/.openclaw/sanguo_projects/sanguo_moziplus_v2
|
||||
install_dir: ~/.sanguo_projects/sanguo_moziplus_v2
|
||||
pm2_name: sanguo-moziplus-v2
|
||||
rsync_exclude:
|
||||
- .git/
|
||||
- node_modules/
|
||||
- __pycache__/
|
||||
- data/
|
||||
health_check: http://localhost:8083/health
|
||||
post_deploy:
|
||||
- pm2 restart sanguo-moziplus-v2
|
||||
@@ -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 有意义。
|
||||
其他状态不需要检查。
|
||||
|
||||
## 五、改动范围
|
||||
|
||||
@@ -33,26 +33,173 @@
|
||||
| 项 | 配置 |
|
||||
|----|------|
|
||||
| 地址 | `http://192.168.2.154:3000` |
|
||||
| 版本 | v1.23.4 |
|
||||
| 认证 | HTTP + token(待配置) |
|
||||
| 权限 | cfdaily 用户;姜维持有 admin 权限(启用 Actions、分支保护等) |
|
||||
| 版本 | v1.26.2(2026-06-11 从 v1.23.4 升级) |
|
||||
| 认证 | HTTP + token;admin 账号(姜维持有) |
|
||||
| 权限 | 姜维持有 admin 权限(启用 Actions、分支保护、org webhook 等) |
|
||||
| 数据库 | SQLite3 |
|
||||
| 部署方式 | Docker(NAS 群晖),数据卷 `/volume2/@docker/volumes/gitea-data/_data` |
|
||||
|
||||
### 2.2 CI/CD:Gitea Actions
|
||||
|
||||
| 项 | 配置 |
|
||||
|----|------|
|
||||
| Runner | Mac mini 裸机,act-runner(Go 二进制) |
|
||||
| Runner | Mac mini 裸机,gitea-runner v1.0.8(通过 PM2 管理 `sanguo-act-runner`) |
|
||||
| 配置文件 | `.gitea/workflows/*.yml`,每个项目自管 |
|
||||
| 语法 | 兼容 GitHub Actions(v1.23.4 已验证支持) |
|
||||
| 触发 | push / PR / tag |
|
||||
| 语法 | 兼容 GitHub Actions(v1.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 执行有 bug,Setup 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 Protection(main 分支)**:
|
||||
- 禁止直接 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 曾临时关闭 webhook(CI 错误大爆炸期间),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
|
||||
@@ -691,7 +838,7 @@ Agent spawn 走生产 openclaw 的决策理由:
|
||||
| CI 标准门控 → 代码审查 | **CI 通过后 daemon Webhook 转发 Mail 给审查者** | Mail 通知司马懿 Review | Gitea Webhook `pull_request` → daemon Webhook 模块 → Mail API |
|
||||
| 代码审查 → 修改(不通过) | 审查者提交 Review 意见 | daemon Webhook 转发 Mail 通知改动者(附 Review 摘要) | Gitea Webhook `pull_request_review` → daemon Webhook 模块 → Mail API |
|
||||
| 代码审查 → Merge(通过) | **审查者点 Approve** | daemon Webhook 转发 Mail 通知改动者 merge | Gitea Webhook `pull_request_review` → daemon Webhook 模块 → Mail API |
|
||||
| Merge → 部署 | **merge 到 main 自动触发** | 无需通知(自动化) | Gitea Actions `on: push: branches: [main]` |
|
||||
| Merge → 部署 | **merge 到 main 自动触发** | Mail 通知 PR 作者合并完成(PR #38) | Gitea Actions `on: push: branches: [main]` |
|
||||
| 部署 → E2E | **部署 job 成功后触发 E2E job** | E2E 结果评论到 merge commit | Gitea Actions `needs: [deploy]` |
|
||||
| E2E/部署 → Issue关闭 | 庞统或改动者手动确认后关闭 | Issue 关闭通知关注者 | Gitea API `PATCH /repos/{owner}/{repo}/issues/{id}` state=closed |
|
||||
| CI失败 → Issue评论 | **CI 失败自动评论** → daemon Webhook 转发 Mail 通知改动者 | 评论到关联 Issue + Mail 推送 Agent | Gitea Actions `if: failure()` 写 PR评论 → daemon Webhook 监听 `issue_comment` → Mail |
|
||||
@@ -1156,7 +1303,7 @@ Layer 3: Mail 执行层(Agent 接口)
|
||||
| `push` | 代码推送 | commit hash, 分支, 作者 | 不需要转发(Actions 自动处理) |
|
||||
| `pull_request` (opened) | PR 创建 | PR ID, 标题, 分支, 作者 | → Mail 通知司马懿 Review |
|
||||
| `pull_request_review` (submitted) | Review 提交 | PR ID, 审查者, 结论(APPROVE/REQUEST_CHANGES), 评论 | → Mail 通知张飞 Review 结果 |
|
||||
| `pull_request` (closed/merged) | PR 合并 | PR ID, 合并 commit | 不需要转发(Actions 自动触发 deploy) |
|
||||
| `pull_request` (closed/merged) | PR 合并 | PR ID, 合并 commit | Mail 通知 PR 作者合并完成(PR #38 恢复) |
|
||||
| `issue_comment` | PR/Issue 评论 | 评论者, 内容 | CI workflow 写的失败评论 → 转发 Mail |
|
||||
| `issues` (opened+assigned) | Issue 创建/指派 | Issue ID, 标题, 被指派人 | → Mail 通知开发者 |
|
||||
| `release` | Release 创建 | tag, 名称 | 触发完整 CI+部署 |
|
||||
@@ -1189,8 +1336,8 @@ async def handle_gitea_webhook(event: dict, x_gitea_event: str = Header(...), x_
|
||||
pr_author = to_agent_id(event["pull_request"]["user"]["login"])
|
||||
await send_mail(to="simayi-challenger", title=f"Review 请求: PR #{event['number']}", ...)
|
||||
elif action == "closed" and event["pull_request"]["merged"]:
|
||||
# merge 不需要通知,Actions 自动处理
|
||||
pass
|
||||
# PR #38: 通知 PR 作者合并完成
|
||||
await _handle_pr_closed(event)
|
||||
|
||||
elif x_gitea_event == "pull_request_review":
|
||||
state = event["review"]["state"]
|
||||
|
||||
@@ -119,3 +119,28 @@
|
||||
- 姜维第一次分析给出了错误根因(Gitea 双 notifier),第二次深入调查后自我纠正
|
||||
- 庞统把姜维的第一次结论当事实汇报给主公,没有标注"这是姜维的调查结论,尚未独立验证"
|
||||
- **改进**:SOUL.md 新增规则——推测 vs 事实显式标注、引用他人结论时标注来源、结论被推翻时及时更正
|
||||
|
||||
---
|
||||
|
||||
## PR #38 新增场景(synchronize fallback + merge 通知)
|
||||
|
||||
> 2026-06-12 新增,对应 PR #38 的设计变更
|
||||
|
||||
### 步骤 9:synchronize fallback ✅
|
||||
- 操作:创建 PR(无 review 历史)→ push 新 commit 到 PR 分支
|
||||
- 触发事件:`pull_request` (synchronize)
|
||||
- 预期:`simayi-challenger`(默认 reviewer)收到"请重新 review" Mail
|
||||
- 验证点:
|
||||
- PR 无 review 历史时,`_fetch_latest_reviewer()` 返回 None → fallback 到 `simayi-challenger`
|
||||
- Mail to 正确(默认 reviewer 而非跳过通知)
|
||||
- 模板使用 `review_updated.md`
|
||||
|
||||
### 步骤 10:merge 通知 ✅
|
||||
- 操作:PR 通过 Review 后 merge
|
||||
- 触发事件:`pull_request` (closed) + `merged=true`
|
||||
- 预期:PR 作者收到"PR 已合并" Mail
|
||||
- 验证点:
|
||||
- Mail to 正确(PR 作者)
|
||||
- `merged_by` 字段正确提取(payload `merged_by` → fallback `sender`)
|
||||
- 模板使用 `review_merged.md`
|
||||
- 纯 closed(非 merged)不触发通知
|
||||
|
||||
@@ -351,7 +351,7 @@ for vp in TaskTypeRegistry.virtual_projects():
|
||||
|
||||
- 新建 `src/daemon/task_type_registry.py`:`TaskTypeHandler` Protocol + `TaskTypeRegistry`
|
||||
- 新建 `src/daemon/prompt_composer.py`:`PromptSection` Protocol + `PromptContext` + `PromptComposer`
|
||||
- 新建 `src/daemon/base_task_handler.py`:`BaseTaskHandler` 基类(VerifyResult + post_complete 统一流程 + _rollback_current_agent)
|
||||
- 新建 `src/daemon/base_task_handler.py`:`BaseTaskHandler` 基类(VerifyResult + post_complete 统一流程 + _rollback_current_agent)[将在 Step 2 PR 实现]
|
||||
- 编写单元测试验证:注册/查询、section 排序/去重/条件过滤、基类 post_complete 流程
|
||||
- **风险**:极低,纯新增文件,不改动现有代码
|
||||
|
||||
@@ -689,6 +689,8 @@ src/daemon/
|
||||
| L3 | 被动参考层 | Skills 索引注入,Agent 按需 read 全文 | OpenClaw 42 Skills + moziplus SkillRegistry | 按需 |
|
||||
| L4 | 检索层 | Agent 运行时主动检索 | wiki 知识库、NAS 文档、Web 搜索 | 按需 |
|
||||
|
||||
> **注**:表中 token 数为估算值。`prompt_composer.py` 中 `TOKEN_BUDGET_WARN=800` 是警告阈值(超出时打日志但不截断),与表中 ~1500 估算值不矛盾——800 对应单个 handler 的典型 L2 注入量,1500 对应最复杂场景(TaskHandler 全量 5 sections)的估算上限。
|
||||
|
||||
**PromptComposer 是 L2 层的拼装机制**。
|
||||
|
||||
## L1-L4 去重规则
|
||||
|
||||
@@ -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 类型自动标 done,request 类型检查回复
|
||||
|
||||
### 二、Toolchain — Issue 指派(✅ 通过)
|
||||
|
||||
| # | 步骤 | 验证点 | 结果 | Mail ID |
|
||||
|---|------|--------|------|---------|
|
||||
| 1 | 创建 Issue #28,assignee=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)
|
||||
@@ -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 成功通知 | ❌ 缺失 |
|
||||
|
||||
## 改动方案
|
||||
|
||||
### 改动 1:deploy.yml 增加 deploy 成功通知
|
||||
|
||||
在 deploy job 最后增加一个 step,deploy 成功后:
|
||||
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 通过
|
||||
@@ -0,0 +1,206 @@
|
||||
# §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 成功通知重叠,已删~~ |
|
||||
| E2 | PR 合并 → 通知 PR 作者 + 自动部署 | `pull_request` (closed+merged) | **高** | PR #38 恢复:CD 通知语义不同(部署状态 vs 合并信息),文档 PR 无 CD 流程仍需通知。PR #43:含自动部署(git pull + pm2 restart) |
|
||||
| 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. 查询最近一次 review(Gitea API `GET /repos/{owner}/{repo}/pulls/{number}/reviews`)取 reviewer
|
||||
3. 如果没有 review 记录(`_fetch_latest_reviewer()` 返回 None),fallback 到默认 reviewer `simayi-challenger`,而非跳过通知(PR #38 改动:确保无 review 历史时也能通知默认审查者)
|
||||
4. 渲染 `review_updated.md` 模板,发送 Mail 给 reviewer
|
||||
|
||||
**关键设计决策**:
|
||||
- 不用 `requested_reviewers`(可能为空),用最近 review 的提交者
|
||||
- 无 review 历史时 fallback 到默认 reviewer `simayi-challenger`(PR #38:避免 opened + synchronize 间隔较短时 reviewer 未收到任何通知)
|
||||
- Mail from 用 `system`
|
||||
|
||||
### Handler 2:`_handle_pr_closed`(PR 合并通知)— PR #38 恢复
|
||||
|
||||
**触发**:`pull_request` 事件 + `action=closed` + `merged=true`
|
||||
|
||||
**通知对象**:PR 作者
|
||||
|
||||
**实现**:
|
||||
|
||||
修改 `_handle_pull_request` 的 action 分发,新增 `closed` 分支:
|
||||
|
||||
```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)
|
||||
elif action == "closed" and payload.get("pull_request", {}).get("merged"):
|
||||
await _handle_pr_closed(payload)
|
||||
# 其他 action 忽略
|
||||
```
|
||||
|
||||
新增 `_handle_pr_closed`:
|
||||
1. 从 payload 取 PR 信息(number、title、merged_by)
|
||||
2. `merged_by` 优先从 `payload["pull_request"]["merged_by"]` 取,若为空则 fallback 到 `payload["sender"]`(PR #38:兼容不同 Gitea 版本和 merge 方式)
|
||||
3. 渲染 `review_merged.md` 模板,发送 Mail 给 PR 作者
|
||||
|
||||
**恢复说明**:此前因与 §22 CD 成功通知重叠而删除。但实际场景中 CD 通知发的是部署状态,PR 作者更关心的是"谁帮我 merge 了"这个信息,两者语义不同。且 CD 流程不一定每次都触发(如文档 PR),merge 通知仍需独立存在。(PR #38 恢复)
|
||||
|
||||
### 新增 Handler 3:review 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 |
|
||||
| `review_merged.md` | repo, pr_number, pr_title, pr_author, merged_by | PR 已合并,通知作者(PR #38 恢复) |
|
||||
| `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 分发逻辑。
|
||||
|
||||
### PR 合并后自动部署(PR #43)
|
||||
|
||||
**触发**:`_handle_pr_closed` 合并事件处理完成后
|
||||
|
||||
**逻辑**:
|
||||
1. 读取 `config/deploy-targets.yaml`,查找 `repo` 对应的部署目标
|
||||
2. 不在配置中 → 跳过(未来新项目加一条配置即可)
|
||||
3. `git pull origin main`(开发目录)
|
||||
4. `rsync` 同步到安装目录(排除项由配置指定)
|
||||
5. 判断是否需要执行 post_deploy:文件路径包含 `src/`、`templates/`、`frontend/` 或 `*.py` 后缀
|
||||
6. 纯 `docs/` 变更 → 只 pull + rsync,不执行 post_deploy
|
||||
7. 部署失败复用 `deploy_failure.md` 模板通知 `jiangwei-infra` + `pangtong-fujunshi`
|
||||
|
||||
**配置文件**:`config/deploy-targets.yaml`(集中管理所有仓库的部署目标)
|
||||
|
||||
```yaml
|
||||
targets:
|
||||
sanguo/sanguo_moziplus_v2:
|
||||
dev_dir: ~/.openclaw/sanguo_projects/sanguo_moziplus_v2
|
||||
install_dir: ~/.sanguo_projects/sanguo_moziplus_v2
|
||||
pm2_name: sanguo-moziplus-v2
|
||||
rsync_exclude: [.git/, node_modules/, __pycache__/, data/]
|
||||
health_check: http://localhost:8083/health
|
||||
post_deploy: [pm2 restart sanguo-moziplus-v2]
|
||||
```
|
||||
|
||||
**设计决策**:
|
||||
- **集中式 YAML 配置**(姜维建议):部署是平台级能力,非仓库级。一个文件管所有仓库,新增项目零代码改动
|
||||
- **YAML 而非 JSON**:支持注释,方便临时禁用某个仓库或排除项
|
||||
- **post_deploy 列表**:支持任意 shell 命令,不只是 pm2 restart。未来可扩展(如 pip install -e .)
|
||||
- **health_check 字段**:预留,后续可用于部署后健康检查
|
||||
- **失败通知复用**:CD 失败和 CI 失败用同一套通知机制(deploy_failure.md 模板 + _send_mail)
|
||||
- git pull 在开发目录(有 `.git`),rsync 到安装目录
|
||||
- 全异步调用(asyncio.create_subprocess_exec)
|
||||
|
||||
### 不做的事
|
||||
|
||||
| 项 | 理由 |
|
||||
|---|---|
|
||||
| E4 PR 上普通评论通知 | 低优,非关键路径,后续按需加 |
|
||||
| Issue 关闭通知 | 低优,关怀性质 |
|
||||
| reviewer 从 `requested_reviewers` 取 | 不可靠(可能为空),用最近 review 记录更稳定 |
|
||||
|
||||
## 改动范围
|
||||
|
||||
| 文件 | 改动 |
|
||||
|---|---|
|
||||
| `src/api/toolchain_routes.py` | 修改 `_handle_pull_request`(扩展 action 分发 + closed 分支)+ 新增 `_handle_pr_synchronize` + `_handle_pr_closed` + 修改 `_handle_pull_request_review`(支持 COMMENTED) |
|
||||
| `templates/toolchain/review_updated.md` | 新增 |
|
||||
| `templates/toolchain/review_merged.md` | 新增(PR #38 恢复) |
|
||||
| `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. **synchronize fallback**(PR #38):创建 PR → push commit(无 review 历史)→ 验证默认 reviewer (`simayi-challenger`) 收到通知
|
||||
3. **merge 通知**(PR #38 恢复):PR merge → 验证 PR 作者收到合并通知 Mail
|
||||
4. **COMMENTED**:review 提交纯评论 → 验证 PR 作者收到通知
|
||||
|
||||
## 风险评估
|
||||
|
||||
- **风险等级**:低。新增事件处理,不修改现有 handler 逻辑
|
||||
- **幂等性**:复用现有 `_is_duplicate` 机制
|
||||
- **性能**:synchronize handler 有一次 Gitea API 调用(查 review 历史),频率低(只在 push 后触发)
|
||||
@@ -0,0 +1,304 @@
|
||||
# §24 — Compact 检测方案修正
|
||||
|
||||
> 状态:**v5 已实现**(gateway log + jsonl 配对)
|
||||
> 作者:庞统
|
||||
> 日期:2026-06-11(v4),2026-06-13(v5)
|
||||
> 框架:基于 §07 Spawner Acquire-First
|
||||
> 评审:仲达 4+2 轮评审
|
||||
> 备选方案:B(内存 flag + sessions.json status),见 §2B
|
||||
|
||||
---
|
||||
|
||||
## 0. v5 方案(已实现)
|
||||
|
||||
### 0.1 方案概述
|
||||
|
||||
**gateway log 开始标记(precheck `route=compact_then_truncate`)+ jsonl 结束标记(`type: "compaction"` entry)配对**。
|
||||
|
||||
- **开始标记**:扫描 gateway 日志,找含目标 agent sessionKey 且 `route=compact_then_truncate` 的 precheck 日志行,提取时间戳。
|
||||
- **结束标记**:扫描 session jsonl,找开始时间之后的 `type: "compaction"` entry。
|
||||
- **判定逻辑**:有开始无结束 → compact 进行中 → skip ticker;有开始有结束 → compact 已完成 → 不 skip。
|
||||
- **超时兜底**:开始标记超过 15 分钟仍未结束 → 自动忽略(防止死锁)。
|
||||
|
||||
### 0.2 三种 Compact 触发路径分析
|
||||
|
||||
Gateway 的 compact 有多种触发路径,日志表现不同:
|
||||
|
||||
| 触发路径 | 有开始标记? | 有 sessionKey? | 有 compaction 结束标记? | 检测策略 |
|
||||
|---------|------------|---------------|----------------------|--------|
|
||||
| **overflow** | 有(`attempting auto-compaction`) | ❌ 不含 | 有 | 依赖 precheck 覆盖 |
|
||||
| **timeout** | 有(`[timeout-compaction]` + `attempting`) | ❌ 推测不含 | 有 | 依赖 precheck 覆盖 |
|
||||
| **precheck** | 有(`[context-overflow-precheck]` + `route=compact_then_truncate`) | ✅ 含 | 有 | **直接检测** |
|
||||
| **threshold** | 无(静默执行) | — | 有 | counter+lock+status 保护 |
|
||||
| **manual** | 无(静默执行) | — | 有 | counter+lock+status 保护 |
|
||||
|
||||
### 0.3 为什么只依赖 precheck 标记
|
||||
|
||||
1. **overflow/timeout 标记不含 sessionKey**:实测证实 overflow 标记(`context overflow detected; attempting auto-compaction for zhipu/glm-5.1`)不包含 `agent:xxx:main` 格式的 sessionKey,被前置 `session_key not in msg` 过滤跳过,是死代码。
|
||||
2. **precheck 总在 overflow 之前触发**:同一 compact 事件中,precheck `route=compact_then_truncate` 先检测到,overflow 是 fallback。所以 precheck 已覆盖 overflow 场景。
|
||||
3. **threshold/manual 无开始标记**:这两种是静默执行,没有 gateway 日志标记。它们依赖 counter+lock+status 三重保护(见 §07),不需要 gateway 日志检测。
|
||||
|
||||
> **注意**:`route=truncate_tool_results_only` 的 precheck 不触发 compact 检测,只有 `route=compact_then_truncate` 才触发。
|
||||
|
||||
### 0.4 超时兜底
|
||||
|
||||
15 分钟超时窗口:如果 compact 开始标记超过 15 分钟仍无结束标记,自动忽略该开始标记。这覆盖了:
|
||||
- daemon 重启后残留的开始标记
|
||||
- 极端长时间的 compact(正常 compact 通常 < 7 分钟)
|
||||
- 日志轮转导致的结束标记丢失
|
||||
|
||||
---
|
||||
|
||||
## 1. 问题
|
||||
|
||||
### 1.1 现象
|
||||
|
||||
2026-06-11 14:02,pangtong main session 正在做 compaction(13: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. 方案 A:Trajectory prompt.submitted 检测(v4,主选方案)
|
||||
|
||||
### 2.1 方案演进
|
||||
|
||||
| 版本 | 方案 | 问题 |
|
||||
|------|------|------|
|
||||
| v1 | trajectory jsonl 间接推断 | trajectoryPath 不可用,需多文件 |
|
||||
| v2 | gateway precheck 开始标志 | 覆盖率仅 30%,post-compact retry 无开始标志 |
|
||||
| v3 | rotation-only + 120s 窗口 | 120s 覆盖不了多轮 compact loop(实测 pangtong 13:59→14:50 共 5 轮 rotation,总耗时 ~51 分钟,PR #36 已合并但无法覆盖) |
|
||||
| **v4** | **trajectory prompt.submitted** | **源码+数据双重验证,仲达背靠背确认** |
|
||||
|
||||
### 2.2 核心洞察
|
||||
|
||||
**源码证据**(`selection-But6hGR0.js` L14040-14085):
|
||||
|
||||
```javascript
|
||||
if (preemptiveCompaction?.shouldCompact) {
|
||||
skipPromptSubmission = true; // ← compact 时跳过 prompt.submitted
|
||||
}
|
||||
if (!skipPromptSubmission) {
|
||||
trajectoryRecorder?.recordEvent("prompt.submitted", { ... });
|
||||
}
|
||||
```
|
||||
|
||||
当 context-overflow 触发 compact 时,Gateway 跳过 `prompt.submitted` 事件。
|
||||
正常 turn 一定有 `prompt.submitted`。
|
||||
|
||||
**仲达背靠背验证**:`skipPromptSubmission` 有 7 条路径(不只 compact),但仲达指出:
|
||||
**检测目标不是"是否在 compact",而是"session 是否处于正常状态"。**
|
||||
所有跳过 prompt.submitted 的场景(compact/timeout/hook block/session 结束)
|
||||
都是不应该 spawn ticker 的状态,误判方向安全。
|
||||
|
||||
**实测数据**(仲达背靠背重新验证,2026-06-11):
|
||||
- pangtong 39 个 turn:34 有 prompt.submitted(正常),5 无
|
||||
- 4 个 tool loop 子迭代(compactionCount=0, <1s, gateway 无 compact 事件)
|
||||
- 1 个 context-overflow precheck 触发 compact
|
||||
- simayi 24 个 turn:23 有,1 无(tool-result truncation succeeded)
|
||||
- 合计 6/63 = ~9.5% 无 prompt.submitted,其中真正 compact 仅 1 例
|
||||
- **所有无 prompt.submitted 的场景都是不应 spawn ticker 的状态**,方向安全
|
||||
|
||||
### 2.3 检测逻辑
|
||||
|
||||
```
|
||||
1. 构造 trajectory jsonl 路径:{sessionFile}.trajectory.jsonl
|
||||
2. 读文件尾部,按 session.started 分组找最后一个完整 turn
|
||||
3. 如果该 turn 有 prompt.submitted → 正常 turn → 不 skip
|
||||
4. 如果该 turn 有 prompt.skipped → 空白 prompt → 不 skip
|
||||
5. 如果两者都无 → 非正常状态 → skip ticker
|
||||
6. 超过 30min 没有新事件 → 兜底放行
|
||||
```
|
||||
|
||||
**为什么不需要 gateway 日志?**
|
||||
- trajectory jsonl 已经包含了完整的 turn 生命周期
|
||||
- prompt.submitted 是 turn 级别的标志,不需要匹配开始/结束
|
||||
- 不需要维护跨 tick 的内存状态
|
||||
|
||||
### 2.4 为什么不用 session jsonl 的 `type: "compaction"` 事件?
|
||||
|
||||
每轮 compact 结束,session jsonl 确实会写入 `type: "compaction"` 摘要事件。
|
||||
但 compact 后 Gateway 会 rotate transcript(创建新 session file),
|
||||
compaction 事件写在**旧 session jsonl** 里(变成 .reset 文件),
|
||||
当前 main session 指向的 jsonl 中没有这些事件。
|
||||
|
||||
这就是现有 `_check_recent_compaction_jsonl` 检测不到的根本原因。
|
||||
|
||||
## 2B. 备选方案 B:内存 flag + sessions.json status
|
||||
|
||||
如果方案 A 在实际使用中不够,可补充方案 B。
|
||||
|
||||
```
|
||||
1. gateway 日志发现 rotation 或 precheck → 设置内存 flag: compacting=True
|
||||
2. 每个 ticker 检查:
|
||||
- flag=True + sessions.json status=running → 清 flag(compact 结束)
|
||||
- flag=True + 超过 30min → 清 flag(兜底放行)
|
||||
- flag=True → skip ticker
|
||||
3. daemon 重启会丢失 flag(可接受,重启后状态已刷新)
|
||||
```
|
||||
|
||||
**优点**:精确检测 compact 结束(status 恢复 running)
|
||||
**缺点**:需要维护内存状态、依赖两个数据源、daemon 重启丢失状态
|
||||
**触发条件**:仅在方案 A 实际运行中发现不足时实施
|
||||
|
||||
## 3. 改动范围(方案 A)
|
||||
|
||||
| 文件 | 改动 | 行数估计 |
|
||||
|------|------|---------|
|
||||
| `spawner.py` | 新增 `_check_compact_in_progress_trajectory()` | ~50 行 |
|
||||
| `spawner.py` | `_check_session_state()` 调用新方法,替换旧方法 | ~5 行 |
|
||||
| `tests/test_spawner_compact.py` | 更新单元测试 | ~30 行 |
|
||||
|
||||
**总计 ~85 行代码改动。**
|
||||
|
||||
## 4. 实现细节(方案 A)
|
||||
|
||||
### 4.1 核心方法
|
||||
|
||||
```python
|
||||
def _check_compact_in_progress_trajectory(self, session_file: str, timeout_minutes: int = 30) -> bool:
|
||||
"""检查 trajectory jsonl 尾部,判断 session 是否处于非正常状态。
|
||||
|
||||
检测逻辑:最后一个完整 turn 没有 prompt.submitted → 非正常状态 → skip ticker。
|
||||
覆盖:compact、timeout、hook block、session 结束等所有非正常状态。
|
||||
"""
|
||||
traj_path = f"{session_file}.trajectory.jsonl"
|
||||
if not os.path.exists(traj_path):
|
||||
return False
|
||||
|
||||
# 读尾部 500KB
|
||||
with open(traj_path, 'rb') as f:
|
||||
f.seek(0, 2)
|
||||
size = f.tell()
|
||||
f.seek(max(0, size - 500 * 1024))
|
||||
tail_lines = f.readlines()
|
||||
|
||||
# 按 session.started 分组,找最后一个完整 turn
|
||||
last_turn_events = []
|
||||
current_turn = []
|
||||
for raw_line in tail_lines:
|
||||
try:
|
||||
obj = json.loads(raw_line)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
continue
|
||||
|
||||
event_type = obj.get("type", "")
|
||||
|
||||
if event_type == "session.started":
|
||||
if current_turn:
|
||||
last_turn_events = current_turn
|
||||
current_turn = [obj]
|
||||
else:
|
||||
current_turn.append(obj)
|
||||
|
||||
if current_turn:
|
||||
last_turn_events = current_turn
|
||||
|
||||
if not last_turn_events:
|
||||
return False
|
||||
|
||||
# 30min 兜底:最后一个事件超过 30min → 放行
|
||||
last_ts = None
|
||||
for evt in reversed(last_turn_events):
|
||||
ts = evt.get("ts") or evt.get("timestamp")
|
||||
if ts:
|
||||
last_ts = ts
|
||||
break
|
||||
|
||||
if last_ts:
|
||||
try:
|
||||
from datetime import datetime, timezone
|
||||
# trajectory 时间是 ISO UTC
|
||||
if last_ts.endswith('Z'):
|
||||
last_dt = datetime.fromisoformat(last_ts.replace('Z', '+00:00'))
|
||||
else:
|
||||
last_dt = datetime.fromisoformat(last_ts)
|
||||
age = datetime.now(timezone.utc) - last_dt
|
||||
if age.total_seconds() > timeout_minutes * 60:
|
||||
return False # 超时放行
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
|
||||
# 检查最后一个 turn 是否有 prompt.submitted
|
||||
has_prompt_submitted = any(
|
||||
evt.get("type") == "prompt.submitted" for evt in last_turn_events
|
||||
)
|
||||
has_prompt_skipped = any(
|
||||
evt.get("type") == "prompt.skipped" for evt in last_turn_events
|
||||
)
|
||||
|
||||
if has_prompt_submitted or has_prompt_skipped:
|
||||
return False # 正常 turn
|
||||
|
||||
# 既无 submitted 也无 skipped → 非正常状态 → skip
|
||||
return True
|
||||
```
|
||||
|
||||
### 4.2 Phase 2 集成
|
||||
|
||||
```python
|
||||
# 在 _check_session_state 中替换旧方法
|
||||
compact = self._check_compact_in_progress_trajectory(session_file)
|
||||
if not compact:
|
||||
compact = self._check_recent_compaction_jsonl(...) # fallback
|
||||
|
||||
if compact:
|
||||
blockers.append(("session_compacting", None))
|
||||
```
|
||||
|
||||
### 4.3 trajectory 路径构造
|
||||
|
||||
trajectory jsonl 路径 = `{sessionFile}.trajectory.jsonl`,其中 sessionFile 来自 sessions.json。
|
||||
|
||||
实测验证:
|
||||
- `~/.openclaw/agents/pangtong-fujunshi/sessions/745b35bb-...-e8e8988d.jsonl`
|
||||
- → trajectory: `~/.openclaw/agents/pangtong-fujunshi/sessions/745b35bb-...-e8e8988d.trajectory.jsonl`
|
||||
|
||||
## 5. 边界情况
|
||||
|
||||
| 边界情况 | 处理 | 误判方向 |
|
||||
|---------|------|----------|
|
||||
| trajectory 不存在 | 返回 False(fallback) | 安全 |
|
||||
| tool loop 子迭代 | 无 prompt.submitted → skip | 保守但安全(~8%) |
|
||||
| timeout turn | 无 prompt.submitted → skip | 安全(timeout 也不该 spawn) |
|
||||
| hook block | 无 prompt.submitted → skip | 安全 |
|
||||
| truncation 成功 | 无 prompt.submitted → skip | 安全(后面会 retry) |
|
||||
| session 结束空 turn | 无 prompt.submitted → skip | 安全 |
|
||||
| 空白 prompt | 有 prompt.skipped → 不 skip | 正确区分 |
|
||||
| 30min 无新事件 | 兜底放行 | 防死锁 |
|
||||
| compact 后 transcript rotate | 读当前 sessionFile 对应的 trajectory | 路径正确 |
|
||||
| budget compact | 有 prompt.submitted → 不 skip | 正确(budget compact 不阻止 spawn) |
|
||||
|
||||
## 6. 测试验证
|
||||
|
||||
### 6.1 单元测试(更新 test_spawner_compact.py)
|
||||
|
||||
- `_check_compact_in_progress_trajectory`:
|
||||
- 正常 turn(有 prompt.submitted)→ False
|
||||
- compact turn(无 prompt.submitted)→ True
|
||||
- 空白 prompt(有 prompt.skipped)→ False
|
||||
- 超过 30min 兜底 → False
|
||||
- trajectory 不存在 → False
|
||||
- 空 trajectory → False
|
||||
- 多 turn 尾部只看最后一个 → 正确
|
||||
|
||||
### 6.2 集成验证
|
||||
|
||||
- `pytest -m "not e2e"` 全量测试
|
||||
|
||||
## 7. 关联设计
|
||||
|
||||
- §07 Spawner Acquire-First(§4.5 O5 compact 扫描条件收紧)
|
||||
- §08 Classify Outcome Optimization(compact_hanging 处理)
|
||||
|
||||
## 8. 评审记录
|
||||
|
||||
- **v1**:trajectory jsonl 间接推断 → 仲达指出 trajectoryPath 不可用、需多文件等 3 个问题
|
||||
- **v2**:gateway 日志 precheck 开始标志 → 仲达指出开始标志覆盖率仅 30%,建议 rotation-only
|
||||
- **v3**:rotation-only + 120s 窗口 → 合并 PR #36,但实测 51 分钟 compact loop 无法覆盖
|
||||
- **v4**:trajectory prompt.submitted → 仲达背靠背验证(源码 7 条 skipPromptSubmission 路径 + 实际数据 ~8% 假阳性但方向安全)→ 修正检测目标为"session 是否正常"
|
||||
- **v5**:gateway log(precheck 开始标记)+ jsonl(compaction 结束标记)配对 → 仲达评审通过后实现,PR #48 Review 驳回 M1/M2 修正后合并
|
||||
|
||||
> ⚠️ **v4 已 deprecated**。v4 的 trajectory prompt.submitted 方案未实施,最终实施的是 v5。v4 的分析(skipPromptSubmission 路径、实测数据)仍有参考价值。
|
||||
@@ -0,0 +1,802 @@
|
||||
# §25 — Gitea @mention 工具链端到端集成
|
||||
|
||||
> **状态**: 草案 v2(修订版),待评审
|
||||
> **作者**: 庞统(副军师)🐦
|
||||
> **日期**: 2026-06-12
|
||||
> **框架**: 基于 §13 工具链四层改造 + §23 PR 全生命周期 + §20 Task Type Architecture
|
||||
> **前置**: §16 v3.2 已有 `extract_mentions()` + `AGENT_ALIAS` 初步设计(仅覆盖 issue_comment),本篇补全为端到端完整方案
|
||||
|
||||
---
|
||||
|
||||
## §25.0 背景与动机
|
||||
|
||||
### 问题
|
||||
|
||||
三国团队在 Gitea 上协作时,Agent 经常需要跨角色沟通:
|
||||
|
||||
- 张飞在 Issue 评论 `@赵云 这个数据接口怎么用?`——赵云收不到
|
||||
- 庞统在 PR body 写 `@simayi-challenger 请重点审查风控模块`——司马懿没有额外感知
|
||||
- 司马懿 review 驳回后评论 `@zhangfei-dev 请注意边界检查`——张飞虽然会收到 review_result Mail,但不知道 specifically @了他
|
||||
|
||||
§13.3.2 已有 `extract_mentions()` 初步设计,但只覆盖了 `issue_comment` 场景,且没有和工具链自动流转做去重/互补分析。本设计做完整端到端覆盖。
|
||||
|
||||
### 目标
|
||||
|
||||
1. **全场景覆盖**:@mention 在 Gitea 所有可写场景(Issue body、Issue comment、PR body、PR comment、Review body)都能触发通知
|
||||
2. **和自动流转互补不冗余**:已有自动 Mail(如 Review 请求→司马懿)不因 @mention 重复通知
|
||||
3. **幂等安全**:同一事件不会发两封相同 Mail
|
||||
4. **闭环可操作**:Agent 收到 mention Mail 后,有明确的"做什么、怎么做"指引
|
||||
5. **复用现有架构**:不改 `_EVENT_HANDLERS` 分发结构,只扩展各 handler 内部逻辑
|
||||
|
||||
### 关键假设(显式记录)
|
||||
|
||||
| # | 假设 | 风险 | 缓解 |
|
||||
|---|------|------|------|
|
||||
| A1 | **Gitea login = Agent ID**(如 `zhangfei-dev`) | 如果某人 Gitea 用户名与 Agent ID 不同,`extract_mentions` 中 `sender` 排除会失效 | 当前团队 Gitea 用户名均按 Agent ID 注册,可在 `AGENT_ALIAS` 中维护映射 |
|
||||
| A2 | 前缀模糊匹配**唯一**时才生效,匹配到多个候选则**不匹配**(报 warn 日志) | `@zh` 可能同时前缀匹配 `zhangfei-dev` 和 `zhuge`(如果未来加入) | `extract_mentions` 改为统计匹配数,>1 则跳过并 log warning |
|
||||
| A3 | Gitea 对 PR 上的普通评论**同时**发出 `issue_comment` 和 `pull_request_comment` 两个事件 | 双事件触发会导致重复 Mail | **只注册 `issue_comment` handler**,不注册 `pull_request_comment`(详见 §25.5) |
|
||||
|
||||
---
|
||||
|
||||
## §25.1 视角 A:@mention 场景遍历
|
||||
|
||||
### Gitea 中 @mention 可能出现的 5 种场景
|
||||
|
||||
> **注**:原 S6 `commit_comment` 经姜维查阅 Gitea 源码 `modules/webhook/type.go` 确认 Gitea **不存在** `commit_comment` webhook 事件类型。故删除 S6,保留 5 个场景。
|
||||
|
||||
| # | 场景 | Gitea Webhook 事件 | 触发时机 | @mention 意图 | 现有自动流转 | 关系 |
|
||||
|---|------|-------------------|---------|-------------|------------|------|
|
||||
| S1 | **Issue body** 中的 @mention | `issues` (action=opened) | 创建 Issue 时 | 拉人关注、协作请求、分配子任务 | Issue assigned → 通知被指派人 | **互补**:assigned 通知指派人,@mention 通知额外关注者 |
|
||||
| S2 | **Issue comment** 中的 @mention | `issue_comment` (action=created) | 在 Issue 下评论时 | 讨论、求助、通知特定人 | CI 失败评论 → 通知 PR 作者 | **互补**:CI 逻辑和 @mention 逻辑互不干扰,同一条评论可同时触发 |
|
||||
| S3 | **PR body** 中的 @mention | `pull_request` (action=opened) | 创建 PR 时 | 指定特定 reviewer、拉人关注 PR | PR opened → 通知 simayi-challenger review | **互补**:默认 reviewer 通知照发,@mention 通知额外的人 |
|
||||
| S4 | **PR 上的评论**(非 review)中的 @mention | `issue_comment` (on PR, is_pull=true) | 在 PR 页面评论时 | 讨论、提问、拉人参与 | §23 标记为低优先级(E4),未实现 | **增强**:@mention 通知覆盖了 E4 的需求 |
|
||||
| S5 | **Review body** 中的 @mention | `pull_request_review` (action=submitted) | 提交 Review 时 | 在 review 中 @PR 作者以外的人 | Review 结果 → 通知 PR 作者 | **互补**:作者照收 review_result/review_comment,@mention 通知额外关注者 |
|
||||
|
||||
### 场景意图总结(含闭环行为)
|
||||
|
||||
@mention 在工具链中有 4 种核心意图,每种意图对应不同的闭环行为:
|
||||
|
||||
| 意图 | 典型场景 | Agent 行为 | 闭环方式 | 闭环操作 |
|
||||
|------|---------|-----------|---------|---------|
|
||||
| **求助** | `@zhaoyun-data 数据格式是什么?` | 被助者获取上下文并回答 | **在 Gitea 评论回复** | 1. 获取 Issue/PR 详情<br>2. 回答问题<br>3. 在 Gitea 评论回复 |
|
||||
| **通知关注** | `@guanyu-dev 这个 PR 涉及风控变更` | 被通知者查看并知晓 | **查看 + 知晓**(不强制回复) | 1. 获取 Issue/PR 详情<br>2. 阅读<br>3. 如有意见再评论 |
|
||||
| **协作请求** | `@pangtong-fujunshi 请帮忙澄清需求` | 被请求者评估并回复 | **评估后回复**(评论或 Mail) | 1. 获取 Issue/PR 详情<br>2. 评估请求<br>3. 评论回复或发 Mail |
|
||||
| **分配子任务** | `@zhangfei-dev 这个 Issue 的前端交给你` | 被分配者认领并执行 | **认领 + 执行 + 更新状态** | 1. 获取 Issue 详情<br>2. 评估可行性<br>3. 认领(assign self)<br>4. 执行任务<br>5. 更新 Issue 状态 |
|
||||
|
||||
---
|
||||
|
||||
## §25.2 视角 B:工具链端到端流程中的 @mention 位置
|
||||
|
||||
### 完整生命周期 + @mention 嵌入点
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────────────────┐
|
||||
│ 需求阶段 │
|
||||
│ ┌──────────┐ issues(opened) ┌──────────┐ │
|
||||
│ │ 创建 Issue │ ──────────────────→│ 指派通知 │ ← 自动:assigned→Mail │
|
||||
│ │ + @mention │ │ + @mention │ ← 新增:@额外人→Mail │
|
||||
│ └──────────┘ └──────────┘ │
|
||||
│ │ │ │
|
||||
│ │ issue_comment │ │
|
||||
│ ↓ ↓ │
|
||||
│ ┌──────────┐ issue_comment ┌──────────┐ │
|
||||
│ │ 需求讨论 │ ──────────────────→│ @mention │ ← 新增:讨论中@人→Mail │
|
||||
│ │ + @mention │ │ 通知 │ │
|
||||
│ └──────────┘ └──────────┘ │
|
||||
├─────────────────────────────────────────────────────────────────────────┤
|
||||
│ 编码阶段 │
|
||||
│ (本地开发,无 Gitea @mention 场景) │
|
||||
├─────────────────────────────────────────────────────────────────────────┤
|
||||
│ PR 阶段 │
|
||||
│ ┌──────────┐ pull_request ┌──────────────┐ │
|
||||
│ │ 创建 PR │ (opened) ──────────→│ Review 请求 │ ← 自动→simayi │
|
||||
│ │ + @mention │ │ + @mention通知│ ← 新增:@额外人→Mail │
|
||||
│ └──────────┘ └──────────────┘ │
|
||||
│ │ │ │
|
||||
│ │ PR 上评论 (issue_comment on PR) │
|
||||
│ ↓ ↓ │
|
||||
│ ┌──────────┐ issue_comment ┌──────────┐ │
|
||||
│ │ PR 讨论 │ ────────────────────→│ @mention │ ← 新增 │
|
||||
│ │ + @mention │ │ 通知 │ │
|
||||
│ └──────────┘ └──────────┘ │
|
||||
├─────────────────────────────────────────────────────────────────────────┤
|
||||
│ Review 阶段 │
|
||||
│ ┌──────────┐ pull_request_ ┌──────────────┐ │
|
||||
│ │ 提交 Review │ review(submitted) ─→│ Review 结果 │ ← 自动→PR作者 │
|
||||
│ │ + @mention │ │ + @mention通知│ ← 新增:@额外人→Mail │
|
||||
│ └──────────┘ └──────────┘ │
|
||||
├─────────────────────────────────────────────────────────────────────────┤
|
||||
│ 修复阶段(Review 驳回后) │
|
||||
│ PR 作者修改 → push → synchronize → 通知 reviewer 重新 review(§23) │
|
||||
│ 讨论中 @mention → 同 S2/S4 │
|
||||
├─────────────────────────────────────────────────────────────────────────┤
|
||||
│ 合并 + 部署 │
|
||||
│ PR merged → 通知 PR 作者 + 自动部署(§23) │
|
||||
│ 此阶段无额外 @mention 场景 │
|
||||
├─────────────────────────────────────────────────────────────────────────┤
|
||||
│ CI/部署验证 │
|
||||
│ CI 失败 → 自动通知 PR 作者 │
|
||||
│ 评论中 @mention → 同 S2 │
|
||||
│ 部署失败 → 自动通知 jiangwei + pangtong │
|
||||
│ 无额外 @mention 场景 │
|
||||
└─────────────────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 流程完整性检查
|
||||
|
||||
| 检查项 | 结果 |
|
||||
|--------|------|
|
||||
| 每个环节都有通知机制(自动或 @mention) | ✅ |
|
||||
| @mention 不会导致流程断链 | ✅:@mention 是附加通知,不替代自动流转 |
|
||||
| @mention 不会导致双重通知同一人 | ✅ 去重机制保障(见 §25.4) |
|
||||
| 每种意图都有闭环指引 | ✅ 意图表含闭环方式(见 §25.1) |
|
||||
|
||||
---
|
||||
|
||||
## §25.3 架构设计
|
||||
|
||||
### 25.3.1 核心组件:`mention_utils.py`
|
||||
|
||||
抽取 @mention 解析逻辑为独立模块,供所有 handler 复用。
|
||||
|
||||
```python
|
||||
# src/api/mention_utils.py
|
||||
"""@mention 解析工具模块。供所有 toolchain handler 复用。"""
|
||||
|
||||
import re
|
||||
import logging
|
||||
from typing import List, Set
|
||||
|
||||
from src.config.agents import AGENT_IDS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Gitea API 基地址常量(避免硬编码)
|
||||
GITEA_API_BASE = "http://192.168.2.154:3000/api/v1"
|
||||
GITEA_WEB_BASE = "http://192.168.2.154:3000"
|
||||
|
||||
# Agent 别名映射
|
||||
# 规则:
|
||||
# 1. 中文名(如"张飞")→ 完整 Agent ID
|
||||
# 2. 英文短名(如"zhangfei")→ 完整 Agent ID
|
||||
# 3. 前缀模糊匹配需唯一匹配(见 extract_mentions 假设 A2)
|
||||
AGENT_ALIAS: dict[str, str] = {
|
||||
# 中文名
|
||||
"张飞": "zhangfei-dev",
|
||||
"关羽": "guanyu-dev",
|
||||
"赵云": "zhaoyun-data",
|
||||
"姜维": "jiangwei-infra",
|
||||
"司马懿": "simayi-challenger",
|
||||
"庞统": "pangtong-fujunshi",
|
||||
# 字+号(常见写法)
|
||||
"翼德": "zhangfei-dev",
|
||||
"云长": "guanyu-dev",
|
||||
"子龙": "zhaoyun-data",
|
||||
"伯约": "jiangwei-infra",
|
||||
"仲达": "simayi-challenger",
|
||||
"士元": "pangtong-fujunshi",
|
||||
# 英文短名
|
||||
"zhangfei": "zhangfei-dev",
|
||||
"guanyu": "guanyu-dev",
|
||||
"zhaoyun": "zhaoyun-data",
|
||||
"jiangwei": "jiangwei-infra",
|
||||
"simayi": "simayi-challenger",
|
||||
"pangtong": "pangtong-fujunshi",
|
||||
}
|
||||
|
||||
# 正则:匹配 @后面跟着的合法 Agent 名(英文字母/中文/数字/连字符)
|
||||
_MENTION_PATTERN = re.compile(r"@([a-zA-Z\u4e00-\u9fa5][a-zA-Z0-9\u4e00-\u9fff-]*)")
|
||||
|
||||
|
||||
def extract_mentions(body: str, sender: str) -> list[str]:
|
||||
"""从文本中提取 @mention 的 Agent ID 列表。
|
||||
|
||||
Args:
|
||||
body: 评论文本
|
||||
sender: 评论者 Gitea 用户名(用于排除自己 @自己)
|
||||
|
||||
Returns:
|
||||
去重后的 Agent ID 列表
|
||||
|
||||
匹配优先级:精确 > 别名 > 前缀模糊(需唯一匹配,多候选则跳过)
|
||||
"""
|
||||
candidates = _MENTION_PATTERN.findall(body)
|
||||
result: Set[str] = set()
|
||||
|
||||
for c in candidates:
|
||||
# 1. 精确匹配(@zhangfei-dev)
|
||||
if c in AGENT_IDS:
|
||||
result.add(c)
|
||||
# 2. 别名匹配(@张飞、@zhangfei)
|
||||
elif c in AGENT_ALIAS:
|
||||
result.add(AGENT_ALIAS[c])
|
||||
else:
|
||||
# 3. 前缀模糊匹配(@zhangf → zhangfei-dev)
|
||||
# 假设 A2:多个候选时不匹配,只 log warning
|
||||
matches = [aid for aid in AGENT_IDS if aid.startswith(c)]
|
||||
if len(matches) == 1:
|
||||
result.add(matches[0])
|
||||
elif len(matches) > 1:
|
||||
logger.warning(
|
||||
"Prefix '%s' matched %d agents (%s), skipping ambiguous mention",
|
||||
c, len(matches), matches)
|
||||
|
||||
# 排除自己 @自己(假设 A1:Gitea login = Agent ID)
|
||||
result.discard(sender)
|
||||
return list(result)
|
||||
|
||||
|
||||
def should_suppress_mention(
|
||||
mentioned_agent: str,
|
||||
auto_notify_targets: List[str],
|
||||
) -> bool:
|
||||
"""判断 @mention 通知是否应被抑制(因为自动流转已通知同一人)。
|
||||
|
||||
Args:
|
||||
mentioned_agent: 被 @的 Agent ID
|
||||
auto_notify_targets: 本次事件自动流转已通知的目标列表
|
||||
|
||||
Returns:
|
||||
True 表示应抑制(不发 @mention Mail)
|
||||
"""
|
||||
return mentioned_agent in auto_notify_targets
|
||||
```
|
||||
|
||||
### 25.3.2 通用 Mail 模板:`mention.md`
|
||||
|
||||
模板设计对齐 `review_request.md` 的风格:提供完整上下文 + 明确的流程指引 + API endpoint。
|
||||
|
||||
```markdown
|
||||
{mention_type}通知
|
||||
|
||||
来源: {source_type} {source_url}
|
||||
评论者: {commenter}
|
||||
意图: {intent_hint}
|
||||
内容:
|
||||
{content_snippet}
|
||||
|
||||
📋 获取完整上下文:
|
||||
1. 查看{source_type}详情: GET {gitea_api}/repos/{repo}/{source_detail_api_path}
|
||||
2. 查看评论列表: GET {gitea_api}/repos/{repo}/{source_comments_api_path}
|
||||
|
||||
📌 响应指引:
|
||||
{response_guidance}
|
||||
|
||||
完成后按指引操作。
|
||||
```
|
||||
|
||||
变量说明:
|
||||
|
||||
| 变量 | 说明 | 示例 |
|
||||
|------|------|------|
|
||||
| `mention_type` | 场景标签 | `@mention` / `Issue @mention` / `PR @mention` / `Review @mention` |
|
||||
| `source_type` | 来源类型 | `Issue` / `PR` / `Review` |
|
||||
| `source_url` | 来源 URL(浏览器可访问) | `http://192.168.2.154:3000/sanguo/repo/issues/32` |
|
||||
| `commenter` | @mention 发起人 | `zhangfei-dev` |
|
||||
| `intent_hint` | 意图提示 | `求助` / `通知关注` / `协作请求` / `分配子任务` |
|
||||
| `content_snippet` | 内容摘要(前 500 字符) | `@赵云 数据接口格式是什么?...` |
|
||||
| `gitea_api` | Gitea API 基地址(常量) | `http://192.168.2.154:3000/api/v1` |
|
||||
| `repo` | 仓库全名 | `sanguo/sanguo_moziplus_v2` |
|
||||
| `source_detail_api_path` | 详情 API 路径 | `issues/32` 或 `pulls/15` |
|
||||
| `source_comments_api_path` | 评论 API 路径 | `issues/32/comments` 或 `pulls/15/reviews` |
|
||||
| `response_guidance` | 按意图生成的响应指引 | 见下方"响应指引生成规则" |
|
||||
|
||||
### 响应指引生成规则
|
||||
|
||||
根据意图类型,`_send_mention_mails` 函数会注入不同的 `response_guidance` 文本:
|
||||
|
||||
**求助(help)**:
|
||||
```
|
||||
这是一条求助,请到 Gitea 评论回复:
|
||||
1. 获取评论上下文(上方 API)
|
||||
2. 组织回答
|
||||
3. 在 Gitea 评论回复: POST {gitea_api}/repos/{repo}/issues/{number}/comments
|
||||
Body: {"body": "你的回答内容"}
|
||||
```
|
||||
|
||||
**通知关注(notify)**:
|
||||
```
|
||||
这是一条通知,请查看并知晓。如有意见,可到 Gitea 评论:
|
||||
- 查看{source_type}详情(上方 API)
|
||||
- 如有意见,评论回复: POST {gitea_api}/repos/{repo}/issues/{number}/comments
|
||||
```
|
||||
|
||||
**协作请求(collaborate)**:
|
||||
```
|
||||
这是一条协作请求,请评估后回复(评论或 Mail):
|
||||
1. 获取{source_type}详情(上方 API)
|
||||
2. 评估可行性
|
||||
3a. 评论回复: POST {gitea_api}/repos/{repo}/issues/{number}/comments
|
||||
Body: {"body": "你的回复"}
|
||||
3b. 或通过 Mail 回复评论者: {commenter}
|
||||
```
|
||||
|
||||
**分配子任务(assign)**:
|
||||
```
|
||||
这是一条任务分配,请认领并执行:
|
||||
1. 获取 Issue 详情(上方 API)
|
||||
2. 评估可行性
|
||||
3. 认领 Issue: POST {gitea_api}/repos/{repo}/issues/{number}/assignees
|
||||
Body: {"assignees": ["{your_agent_id}"]}
|
||||
4. 执行任务
|
||||
5. 完成后更新 Issue 状态: PATCH {gitea_api}/repos/{repo}/issues/{number}
|
||||
Body: {"state": "closed"}
|
||||
```
|
||||
|
||||
> **注**:所有 API 调用需要 Gitea PAT,token scope 需包含 `issue` 或 `repository`。
|
||||
|
||||
### 意图推断规则
|
||||
|
||||
意图推断基于简单的关键词启发式:
|
||||
|
||||
```python
|
||||
def infer_intent(body: str, context: str = "") -> str:
|
||||
"""从 @mention 内容推断意图。
|
||||
|
||||
Returns:
|
||||
"help" | "notify" | "collaborate" | "assign"
|
||||
"""
|
||||
# 分配子任务关键词
|
||||
assign_keywords = ["交给", "分配", "负责", "认领", "做一下", "帮忙做", "implement"]
|
||||
if any(kw in body for kw in assign_keywords):
|
||||
return "assign"
|
||||
|
||||
# 求助关键词("帮忙"已由 assign_keywords 的"帮忙做"覆盖,"请帮忙"由 collab_keywords 覆盖)
|
||||
help_keywords = ["怎么", "如何", "?", "?", "什么", "哪个", "能否"]
|
||||
if any(kw in body for kw in help_keywords):
|
||||
return "help"
|
||||
|
||||
# 协作请求关键词
|
||||
collab_keywords = ["请帮忙", "请协助", "请澄清", "请review", "请审查", "评估"]
|
||||
if any(kw in body for kw in collab_keywords):
|
||||
return "collaborate"
|
||||
|
||||
# 默认为通知关注
|
||||
return "notify"
|
||||
```
|
||||
|
||||
### 25.3.3 各场景 Handler 改动
|
||||
|
||||
#### S1: Issue body @mention — 改 `_handle_issues`
|
||||
|
||||
**改动点**:`_handle_issues` 处理 `opened` action 时,在发送 assigned Mail 后,额外检查 Issue body 中的 @mention。
|
||||
|
||||
> **设计决策**:仅处理 `opened`,不处理 `edited`(编辑 Issue 较少见,且可能产生重复通知——首次 opened 已发过)。见 §25.8 "不做的事"。
|
||||
|
||||
```python
|
||||
# _handle_issues 改动(伪代码)
|
||||
async def _handle_issues(payload):
|
||||
action = payload.get("action", "")
|
||||
|
||||
if action == "opened":
|
||||
# ... 现有逻辑:assigned → 发指派 Mail ...
|
||||
|
||||
# 新增:Issue body @mention 通知
|
||||
await _process_issue_body_mentions(payload)
|
||||
|
||||
elif action == "assigned":
|
||||
# 现有逻辑不变
|
||||
...
|
||||
```
|
||||
|
||||
**`_process_issue_body_mentions` 逻辑**:
|
||||
1. 从 `issue.body` 提取 mentions
|
||||
2. 排除 assignee(已被 assigned Mail 通知过)
|
||||
3. 排除 sender(Issue 创建者)
|
||||
4. 推断意图、生成响应指引
|
||||
5. 发送 mention Mail
|
||||
|
||||
**自动流转互补分析**:
|
||||
- assigned Mail → 通知 assignee
|
||||
- @mention Mail → 通知额外关注者
|
||||
- **去重**:assignee 不再收到 mention Mail(`should_suppress_mention`)
|
||||
|
||||
#### S2+S4: Issue/PR comment @mention — 重构 `_handle_issue_comment`
|
||||
|
||||
**⚠️ 关键改动:控制流重构(M2)**
|
||||
|
||||
**现状问题**:现有 `_handle_issue_comment` 的控制流是:
|
||||
```python
|
||||
# toolchain_routes.py 现有代码
|
||||
async def _handle_issue_comment(payload):
|
||||
...
|
||||
if "[CI]" not in body and "CI 失败" not in body:
|
||||
return # ← 非 CI 评论直接 return,@mention 逻辑到不了
|
||||
# ... CI 失败处理 ...
|
||||
```
|
||||
|
||||
非 CI 评论在第一行就被 `return` 了,无法在 CI 逻辑之后追加 @mention 检测。
|
||||
|
||||
**重构方案:改为两条独立路径(if/elif + 并行 @mention)**
|
||||
|
||||
```python
|
||||
async def _handle_issue_comment(payload):
|
||||
comment = payload.get("comment", {})
|
||||
body = comment.get("body", "")
|
||||
sender = comment.get("user", {}).get("login", "")
|
||||
issue = payload.get("issue", {})
|
||||
action = payload.get("action", "")
|
||||
|
||||
if action != "created":
|
||||
return
|
||||
|
||||
# === 路径 1:CI 失败通知(原有逻辑) ===
|
||||
if "[CI]" in body or "CI 失败" in body:
|
||||
if issue.get("state") != "closed":
|
||||
# ... CI 失败通知逻辑(不变) ...
|
||||
pass
|
||||
# CI 处理完不 return,继续检查 @mention
|
||||
|
||||
# === 路径 2:@mention 通知(新增) ===
|
||||
# 注意:@mention 检测与 CI 检测是独立的,同一条评论可同时触发两者
|
||||
mentions = extract_mentions(body, sender)
|
||||
if mentions:
|
||||
# 判断是 PR 还是 Issue(Gitea 中 PR 本质是特殊的 Issue)
|
||||
is_pr = "pull_request" in issue
|
||||
source_type = "PR" if is_pr else "Issue"
|
||||
mention_type = "PR @mention" if is_pr else "Issue @mention"
|
||||
|
||||
issue_number = issue.get("number", 0)
|
||||
|
||||
# 自动流转已通知的人(CI 失败通知的 PR 作者)
|
||||
auto_targets = []
|
||||
if ("[CI]" in body or "CI 失败" in body) and issue.get("state") != "closed":
|
||||
auto_targets.append(issue.get("user", {}).get("login", ""))
|
||||
|
||||
await _send_mention_mails(
|
||||
mentions=mentions,
|
||||
auto_targets=auto_targets,
|
||||
source_type=source_type,
|
||||
mention_type=mention_type,
|
||||
source_url=issue.get("html_url", ""),
|
||||
commenter=sender,
|
||||
content=body,
|
||||
repo=_repo_fullname(payload),
|
||||
issue_number=issue_number,
|
||||
is_pr=is_pr,
|
||||
)
|
||||
```
|
||||
|
||||
**重构要点**:
|
||||
1. 删除 `if "[CI]" not in body ... return` 的早期退出
|
||||
2. CI 检测改为正向 `if`(满足条件才进入 CI 处理),不再用 guard clause 拦截所有非 CI 评论
|
||||
3. @mention 检测在 CI 逻辑之后,两条路径独立,不互斥
|
||||
4. 同一条评论可同时触发 CI 通知 + @mention 通知(如 CI 失败评论里同时 @了人)
|
||||
|
||||
**S2 和 S4 共享同一个 handler**:Gitea 中 PR 上的普通评论也是 `issue_comment` 事件。通过 `issue` 中是否包含 `pull_request` 字段判断是 PR 还是 Issue。
|
||||
|
||||
#### S3: PR body @mention — 改 `_handle_pr_opened`
|
||||
|
||||
**改动点**:PR opened 时检查 PR body 中的 @mention。
|
||||
|
||||
```python
|
||||
async def _handle_pr_opened(payload):
|
||||
# ... 现有逻辑:review_request Mail → simayi-challenger ...
|
||||
|
||||
# 新增:PR body @mention 通知
|
||||
pr = payload.get("pull_request", {})
|
||||
body = pr.get("body", "")
|
||||
sender = pr.get("user", {}).get("login", "")
|
||||
mentions = extract_mentions(body, sender)
|
||||
|
||||
if mentions:
|
||||
# 自动流转已通知 simayi-challenger(review_request)
|
||||
auto_targets = ["simayi-challenger"]
|
||||
await _send_mention_mails(
|
||||
mentions=mentions,
|
||||
auto_targets=auto_targets,
|
||||
source_type="PR",
|
||||
mention_type="PR @mention",
|
||||
source_url=pr.get("html_url", ""),
|
||||
commenter=sender,
|
||||
content=body,
|
||||
repo=_repo_fullname(payload),
|
||||
issue_number=pr.get("number", 0),
|
||||
is_pr=True,
|
||||
)
|
||||
```
|
||||
|
||||
**自动流转互补分析**:
|
||||
- review_request Mail → simayi-challenger(自动)
|
||||
- @mention Mail → 额外的人(如 `@guanyu-dev 请审查风控`)
|
||||
- **去重**:simayi 如果在 PR body 被 @,不会收到两封 Mail
|
||||
|
||||
#### S5: Review body @mention — 改 `_handle_pull_request_review`
|
||||
|
||||
**改动点**:Review 提交时检查 review body 中的 @mention。
|
||||
|
||||
```python
|
||||
async def _handle_pull_request_review(payload):
|
||||
review = payload.get("review", {})
|
||||
pr = payload.get("pull_request", {})
|
||||
review_body = review.get("body", "") or review.get("content", "")
|
||||
reviewer = review.get("user", {}).get("login", "") or payload.get("sender", {}).get("login", "")
|
||||
pr_author = pr.get("user", {}).get("login", "")
|
||||
|
||||
# ... 现有逻辑:review_result / review_comment → 通知 PR 作者 ...
|
||||
|
||||
# 新增:Review body @mention 通知
|
||||
mentions = extract_mentions(review_body, reviewer)
|
||||
if mentions:
|
||||
# 自动流转已通知 PR 作者(review_result 或 review_comment)
|
||||
auto_targets = [pr_author]
|
||||
await _send_mention_mails(
|
||||
mentions=mentions,
|
||||
auto_targets=auto_targets,
|
||||
source_type="Review",
|
||||
mention_type="Review @mention",
|
||||
source_url=pr.get("html_url", ""),
|
||||
commenter=reviewer,
|
||||
content=review_body,
|
||||
repo=_repo_fullname(payload),
|
||||
issue_number=pr.get("number", 0),
|
||||
is_pr=True,
|
||||
)
|
||||
```
|
||||
|
||||
**自动流转互补分析**:
|
||||
- review_result/review_comment Mail → PR 作者(自动)
|
||||
- @mention Mail → review 中额外 @的人
|
||||
- **去重**:PR 作者如果在 review body 被 @,不会收到两封 Mail
|
||||
|
||||
### 25.3.4 通用发送函数:`_send_mention_mails`
|
||||
|
||||
```python
|
||||
async def _send_mention_mails(
|
||||
mentions: list[str],
|
||||
auto_targets: list[str],
|
||||
source_type: str,
|
||||
mention_type: str,
|
||||
source_url: str,
|
||||
commenter: str,
|
||||
content: str,
|
||||
repo: str,
|
||||
issue_number: int,
|
||||
is_pr: bool,
|
||||
) -> None:
|
||||
"""通用 @mention Mail 发送函数。
|
||||
|
||||
自动抑制已在 auto_targets 中的 Agent,避免双重通知。
|
||||
根据内容推断意图,生成不同的响应指引。
|
||||
"""
|
||||
# 确定 API 路径(S2:使用常量避免硬编码)
|
||||
if is_pr:
|
||||
detail_api = f"pulls/{issue_number}"
|
||||
comments_api = f"issues/{issue_number}/comments"
|
||||
else:
|
||||
detail_api = f"issues/{issue_number}"
|
||||
comments_api = f"issues/{issue_number}/comments"
|
||||
|
||||
for agent_id in mentions:
|
||||
if should_suppress_mention(agent_id, auto_targets):
|
||||
logger.info(
|
||||
"Mention suppressed for %s (already notified by auto flow)",
|
||||
agent_id)
|
||||
continue
|
||||
|
||||
# 推断意图(S4:注释说明编号提取意图)
|
||||
# 从 api_path 提取编号用于标题,如 "issues/32" → "#32"
|
||||
number_str = f"#{issue_number}" if issue_number else ""
|
||||
intent = infer_intent(content)
|
||||
intent_hint = {"help": "求助", "notify": "通知关注",
|
||||
"collaborate": "协作请求", "assign": "分配子任务"}[intent]
|
||||
|
||||
# 生成响应指引
|
||||
guidance = _build_response_guidance(
|
||||
intent=intent,
|
||||
gitea_api=GITEA_API_BASE,
|
||||
repo=repo,
|
||||
issue_number=issue_number,
|
||||
commenter=commenter,
|
||||
)
|
||||
|
||||
text = render_template("mention", {
|
||||
"mention_type": mention_type,
|
||||
"source_type": source_type,
|
||||
"source_url": source_url,
|
||||
"commenter": commenter,
|
||||
"intent_hint": intent_hint,
|
||||
"content_snippet": content[:500],
|
||||
"gitea_api": GITEA_API_BASE,
|
||||
"repo": repo,
|
||||
"source_detail_api_path": detail_api,
|
||||
"source_comments_api_path": comments_api,
|
||||
"response_guidance": guidance,
|
||||
})
|
||||
|
||||
title = f"@mention ({intent_hint}): {source_type} {number_str} ({repo})"
|
||||
_send_mail(agent_id, title, text)
|
||||
|
||||
|
||||
def _build_response_guidance(
|
||||
intent: str,
|
||||
gitea_api: str,
|
||||
repo: str,
|
||||
issue_number: int,
|
||||
commenter: str,
|
||||
) -> str:
|
||||
"""根据意图类型生成响应指引文本。"""
|
||||
if intent == "help":
|
||||
return (
|
||||
f"这是一条求助,请到 Gitea 评论回复:\n"
|
||||
f"1. 获取评论上下文(上方 API)\n"
|
||||
f"2. 组织回答\n"
|
||||
f"3. 在 Gitea 评论回复: POST {gitea_api}/repos/{repo}/issues/{issue_number}/comments\n"
|
||||
f" Body: {{\"body\": \"你的回答内容\"}}"
|
||||
)
|
||||
elif intent == "notify":
|
||||
return (
|
||||
f"这是一条通知,请查看并知晓。如有意见,可到 Gitea 评论:\n"
|
||||
f"- 查看 Issue/PR 详情(上方 API)\n"
|
||||
f"- 如有意见,评论回复: POST {gitea_api}/repos/{repo}/issues/{issue_number}/comments"
|
||||
)
|
||||
elif intent == "collaborate":
|
||||
return (
|
||||
f"这是一条协作请求,请评估后回复(评论或 Mail):\n"
|
||||
f"1. 获取详情(上方 API)\n"
|
||||
f"2. 评估可行性\n"
|
||||
f"3a. 评论回复: POST {gitea_api}/repos/{repo}/issues/{issue_number}/comments\n"
|
||||
f" Body: {{\"body\": \"你的回复\"}}\n"
|
||||
f"3b. 或通过 Mail 回复评论者: {commenter}"
|
||||
)
|
||||
elif intent == "assign":
|
||||
return (
|
||||
f"这是一条任务分配,请认领并执行:\n"
|
||||
f"1. 获取 Issue 详情(上方 API)\n"
|
||||
f"2. 评估可行性\n"
|
||||
f"3. 认领 Issue: POST {gitea_api}/repos/{repo}/issues/{issue_number}/assignees\n"
|
||||
f" Body: {{\"assignees\": [\"{{your_agent_id}}\"]}}\n"
|
||||
f"4. 执行任务\n"
|
||||
f"5. 完成后更新 Issue 状态: PATCH {gitea_api}/repos/{repo}/issues/{issue_number}\n"
|
||||
f" Body: {{\"state\": \"closed\"}}"
|
||||
)
|
||||
return "请查看详情(上方 API)并按需回复。"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## §25.4 幂等与防重复机制
|
||||
|
||||
### 五层去重保障
|
||||
|
||||
| 层 | 机制 | 覆盖场景 |
|
||||
|---|------|---------|
|
||||
| **L1: Webhook 投递去重** | `_is_duplicate()` — delivery UUID + 内容哈希 | Gitea 重试、org+repo webhook 双投递 |
|
||||
| **L2: extract_mentions 内去重** | 返回 `set`,同一人 @多次只出现一次 | `@张飞 @zhangfei-dev 请看看` |
|
||||
| **L3: 自动流转抑制** | `should_suppress_mention()` — 自动已通知的人不再发 mention Mail | simayi 同时是默认 reviewer + 被 @ |
|
||||
| **L4: Handler 内排重** | 各 handler 按事件粒度调用,同一事件只处理一次 | PR opened 不会触发两次 `_handle_pr_opened` |
|
||||
| **L5: 双事件去重(M3)** | **只注册 `issue_comment` handler,不注册 `pull_request_comment`** | 避免 PR 评论同时触发 `issue_comment` + `pull_request_comment` 导致重复 Mail |
|
||||
|
||||
### L5 双事件去重详细说明(M3)
|
||||
|
||||
**问题**:Gitea 对 PR 上的普通评论会同时发出两个 webhook 事件:
|
||||
- `issue_comment`(`is_pull=true`)
|
||||
- `pull_request_comment`
|
||||
|
||||
两个事件有不同的 delivery UUID,L1 去重拦不住。
|
||||
|
||||
**解决方案**:
|
||||
- `_EVENT_HANDLERS` 中**只注册 `issue_comment`**,**不注册 `pull_request_comment`**
|
||||
- 因为 Gitea 对 PR 普通评论也发 `issue_comment`(可通过 `is_pull` 或 issue 中的 `pull_request` 字段判断)
|
||||
- 这样 PR 上的普通评论只会走一条路径,避免双重触发
|
||||
|
||||
```python
|
||||
# _EVENT_HANDLERS 中不出现这一行:
|
||||
# "pull_request_comment": _handle_pull_request_comment, # ← 不要注册
|
||||
```
|
||||
|
||||
### 不可去重的场景(正确行为)
|
||||
|
||||
| 场景 | 行为 | 原因 |
|
||||
|------|------|------|
|
||||
| 同一人在不同 PR 分别被 @ | 各发一封 | 不同上下文 |
|
||||
| 同一人在同一 Issue 的不同评论被 @ | 各发一封 | 新评论有新信息 |
|
||||
| PR 作者收到 review_result + 被 @ | 发两封(不同内容) | review_result 是结构化通知,mention 是额外关注 |
|
||||
|
||||
### L3 抑制的详细规则
|
||||
|
||||
| 自动流转通知 | @mention 抑制条件 | 理由 |
|
||||
|------------|-----------------|------|
|
||||
| Issue assigned → assignee | Issue body @assignee → 抑制 | 同一事件,同一人 |
|
||||
| PR opened → simayi (review_request) | PR body @simayi-challenger → 抑制 | 同一事件,同一人 |
|
||||
| Review result → PR 作者 | Review body @PR作者 → 抑制 | 同一事件,同一人 |
|
||||
| Review comment → PR 作者 | Review body @PR作者 → 抑制 | 同一事件,同一人 |
|
||||
| CI 失败 → PR 作者 | Issue comment @PR作者(同一条评论)→ 抑制 | 同一事件,同一人 |
|
||||
| PR merged → PR 作者 | 不涉及 @mention | merged 事件无 body |
|
||||
| Deploy failure → jiangwei+pangtong | 不涉及 @mention | 由 Issue opened 触发 |
|
||||
|
||||
**关键设计决策**:抑制是**按事件粒度**的,不是全局的。即只有同一 Webhook 事件产生的自动通知才抑制 @mention。如果 PR 作者先收到 review_result(事件 A),之后在另一条评论中被 @(事件 B),两封 Mail 都会发——这是正确的,因为它们是不同事件。
|
||||
|
||||
---
|
||||
|
||||
## §25.5 _EVENT_HANDLERS 变更
|
||||
|
||||
**结论:不需要修改 `_EVENT_HANDLERS` 映射表。**
|
||||
|
||||
@mention 处理是各现有 handler 的内部逻辑扩展:
|
||||
|
||||
| Handler | 变更 | 新事件类型? |
|
||||
|---------|------|-----------|
|
||||
| `_handle_issues` | opened → 增加 body @mention 检测 | 否 |
|
||||
| `_handle_issue_comment` | created → **控制流重构** + 增加 comment body @mention 检测 | 否 |
|
||||
| `_handle_pr_opened`(在 `_handle_pull_request` 内) | opened → 增加 PR body @mention 检测 | 否 |
|
||||
| `_handle_pull_request_review` | submitted → 增加 review body @mention 检测 | 否 |
|
||||
| `_handle_pr_synchronize` | 无变更(synchronize 事件无 body) | 否 |
|
||||
| `_handle_pr_closed` | 无变更(merged 事件无 body) | 否 |
|
||||
|
||||
**注意**:**不注册 `pull_request_comment` handler**,避免 S2/S4 双事件触发(详见 §25.4 L5)。
|
||||
|
||||
---
|
||||
|
||||
## §25.6 改动范围
|
||||
|
||||
| # | 文件 | 改动内容 | 风险 |
|
||||
|---|------|---------|------|
|
||||
| 1 | **新建** `src/api/mention_utils.py` | `extract_mentions()` + `should_suppress_mention()` + `infer_intent()` + `AGENT_ALIAS` + `GITEA_API_BASE` 常量 | 低(新文件,独立模块) |
|
||||
| 2 | **新建** `templates/toolchain/mention.md` | 通用 @mention 通知模板(含响应指引) | 低(新文件) |
|
||||
| 3 | `src/api/toolchain_routes.py` | 各 handler 增加 @mention 检测 + `_handle_issue_comment` 控制流重构 + `_send_mention_mails()` + `_build_response_guidance()` | **中**(修改现有 handler 控制流) |
|
||||
| 4 | `src/daemon/toolchain_templates.py` | `_TEMPLATE_MAP` 新增 `"mention"` 映射 | 低 |
|
||||
| 5 | 不改 | `_EVENT_HANDLERS` 映射(不注册 `pull_request_comment`) | — |
|
||||
| 6 | 不改 | 幂等检查机制 | — |
|
||||
| 7 | 不改 | 现有模板文件 | — |
|
||||
| 8 | 不改 | `toolchain_handler.py`(daemon 侧) | — |
|
||||
|
||||
---
|
||||
|
||||
## §25.7 验证方案
|
||||
|
||||
### 7.1 单元测试
|
||||
|
||||
| 测试用例 | 验证点 |
|
||||
|---------|--------|
|
||||
| `extract_mentions("@zhangfei-dev")` | 精确匹配 |
|
||||
| `extract_mentions("@张飞")` | 中文名别名 |
|
||||
| `extract_mentions("@zhangfei")` | 英文短名 |
|
||||
| `extract_mentions("@翼德")` | 字号别名 |
|
||||
| `extract_mentions("@zhangf")` | 前缀模糊匹配(唯一) |
|
||||
| `extract_mentions("@zh")` | 前缀匹配多个候选 → 不匹配,不抛异常 |
|
||||
| `extract_mentions("@zhangfei-dev @张飞")` | 去重(同一人只出现一次) |
|
||||
| `extract_mentions("sender=zhangfei-dev, body=@zhangfei-dev")` | 排除自己 |
|
||||
| `extract_mentions("@unknown-person")` | 非 Agent 忽略 |
|
||||
| `should_suppress_mention("simayi-challenger", ["simayi-challenger"])` | 抑制生效 |
|
||||
| `should_suppress_mention("zhangfei-dev", ["simayi-challenger"])` | 不抑制 |
|
||||
| `infer_intent("数据格式是什么?")` | 识别为"求助" |
|
||||
| `infer_intent("这个 PR 涉及风控变更")` | 识别为"通知关注" |
|
||||
| `infer_intent("请帮忙澄清需求")` | 识别为"协作请求" |
|
||||
| `infer_intent("前端部分交给你")` | 识别为"分配子任务" |
|
||||
|
||||
### 7.2 E2E 验证场景
|
||||
|
||||
| # | 场景 | 操作 | 预期 |
|
||||
|---|------|------|------|
|
||||
| E2E-1 | Issue body @mention | 创建 Issue body 含 `@赵云 请提供数据字典`,指派张飞 | 张飞收到 assigned Mail;赵云收到 @mention Mail(含"求助"响应指引) |
|
||||
| E2E-2 | Issue comment @mention | 在 Issue 下评论 `@pangtong-fujunshi 需求不明确` | 庞统收到 @mention Mail(含"协作请求"响应指引) |
|
||||
| E2E-3 | PR body @mention | 创建 PR body 含 `@guanyu-dev 风控审查` | 关羽收到 @mention Mail;司马懿照收 review_request |
|
||||
| E2E-4 | PR body @simayi 去重 | 创建 PR body 含 `@simayi-challenger 请审查` | 司马懿只收 1 封 Mail(review_request) |
|
||||
| E2E-5 | Review body @mention | Review 驳回 body 含 `@pangtong-fujunshi 设计有问题` | 庞统收到 @mention Mail;PR 作者收到 review_result(不重复) |
|
||||
| E2E-6 | Review body @PR作者 去重 | Review body 含 `@zhangfei-dev 请修改` | 张飞只收 1 封 Mail(review_result) |
|
||||
| E2E-7 | CI 失败 + @mention 同评论 | CI 失败评论同时含 `@pangtong-fujunshi` | PR 作者收到 CI 失败 Mail;庞统收到 @mention Mail |
|
||||
| E2E-8 | PR 上评论 @mention | 在 PR 页面评论 `@zhaoyun-data 数据源是什么?` | 赵云收到 @mention Mail(只触发一次,非双重) |
|
||||
| E2E-9 | 非 CI 普通评论 @mention | 在 PR 下评论 `@zhangfei-dev 请注意边界检查`(非 CI 评论) | 张飞收到 @mention Mail(验证重构后非 CI 评论不再被 return 丢弃) |
|
||||
| E2E-10 | 前缀模糊多候选 | 评论 `@zh`(假设 zh 开头有多个 Agent) | 不匹配,不发 Mail,日志有 warning |
|
||||
|
||||
---
|
||||
|
||||
## §25.8 不做的事
|
||||
|
||||
| 标记 | 描述 | 原因 |
|
||||
|------|------|------|
|
||||
| 后续-1 | ~~S6 commit_comment handler 实现~~ | Gitea 不支持 `commit_comment` webhook 事件(姜维已确认源码),搁置 |
|
||||
| 后续-2 | Issue `edited` 时 body @mention | 编辑 Issue 较少见,且可能产生重复通知(首次 opened 已发过),暂不处理 |
|
||||
| 后续-3 | @mention 触发自动操作(如自动 assign、自动 add reviewer) | 当前只做通知,不做自动化操作。自动化操作需要更复杂的权限和去重逻辑 |
|
||||
| 后续-4 | @mention 权限控制(谁能 @谁) | 团队规模小,暂不需要 |
|
||||
| 后续-5 | @mention 在 Mail 中的双向回复 | 被通知者按模板中的响应指引操作(去 Gitea 评论或发 Mail),不支持 Mail 直接回复 |
|
||||
|
||||
---
|
||||
|
||||
## §25.9 变更记录
|
||||
|
||||
| 日期 | 版本 | 变更 |
|
||||
|------|------|------|
|
||||
| 2026-06-12 | v1.0 | 初版:6 场景遍历 + 端到端流程 + 幂等去重 + 和自动流转互补分析 |
|
||||
| 2026-06-12 | v2.0 | 修订版(Review 反馈整合):M1 闭环链路 + M2 控制流重构 + M3 双事件去重 + M4 删除 commit_comment + M5 编号改为 §25 + S1 多候选不匹配 + S2 常量化 + S3 删除 edited 分支 + S4 注释说明 + S5 假设文档化 |
|
||||
@@ -0,0 +1,224 @@
|
||||
# v3.0 vs HEAD 背靠背 Review — 庞统
|
||||
|
||||
**日期**: 2026-06-11
|
||||
**范围**: v3.0 tag → HEAD(6 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 Delegation(subagent-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 个操作规范型 skill(blackboard-executor, code-review 等)全文注入 L2,bootstrap.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 无严重不一致
|
||||
@@ -0,0 +1,707 @@
|
||||
# v3.0 vs HEAD 背靠背 Review — 司马懿
|
||||
|
||||
> **日期**: 2026-06-10 (v2)
|
||||
> **范围**: v3.0 tag → HEAD(6 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-229,HEAD 改为 `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` 已为 None(v3.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-3(handler 不可达), 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,它们各自的 handler(MailHandler/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-229,HEAD 中完全消失。
|
||||
|
||||
后果:所有 Mail 任务的 pending → claimed → working 流程中断,整个飞鸽传书系统停止工作。
|
||||
|
||||
这不是"正常重构",是**致命回归**。
|
||||
|
||||
---
|
||||
|
||||
#### 庞统 #7-8 🟢 ticker check_reply / dispatch_reviews
|
||||
|
||||
**庞统判定**:等价实现。
|
||||
|
||||
**我的验证**:✅ 确认等价。但 #7 说"缩进正确"——实际 ticker.py L1483 有缩进不一致(28 空格 vs 同级 24 空格),不影响运行但增加维护混淆。
|
||||
|
||||
---
|
||||
|
||||
### Part B 逐专题验证
|
||||
|
||||
#### 专题 01-03:无分歧
|
||||
|
||||
庞统的检查和我的结论一致。设计原则未强制实施属于正常。
|
||||
|
||||
---
|
||||
|
||||
#### 专题 04:庞统更严格
|
||||
|
||||
庞统把 B4-3(co_assignees)和 B4-4(output↔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 ✅,漏检 A2(handler 注册 + 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 时这两个问题已修复,设计文档描述的是更早期的问题。
|
||||
|
||||
---
|
||||
|
||||
*— 司马懿 仲达,质量总监 🗡️*
|
||||
@@ -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 已被基类处理。剩余异常 outcome(session_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 路径替代硬编码 |
|
||||
@@ -0,0 +1,324 @@
|
||||
# Step 5 引擎接入 — 影响分析与逐点对照
|
||||
|
||||
## 方法论
|
||||
|
||||
逐行审查 dispatcher.py / spawner.py / ticker.py 中所有 `is_mail` / `_mail` / `project_id == "_mail"` 分支,
|
||||
对照 handler 实现,确认每个特殊处理的去向。
|
||||
|
||||
---
|
||||
|
||||
## 一、dispatcher.py(985 行)
|
||||
|
||||
### 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_passed(L194-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,标记成功后才 spawn,spawn 失败回退。
|
||||
|
||||
**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_complete(L224-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_complete(L241-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_comment,comment_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 dispatch(L584-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.py(1704 行)
|
||||
|
||||
### 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_section(L321-325)
|
||||
|
||||
```python
|
||||
success_status = '"done"' if project_id == "_mail" else '"review"'
|
||||
```
|
||||
|
||||
**特殊处理**:Mail 的 success_status 是 done。
|
||||
|
||||
**Handler 覆盖**:已由 handler 的 PromptSection 处理(TaskApiSection hardcode review,MailApiSection 不含 status 回写指令)。
|
||||
|
||||
**改动**:如果 handler 存在,跳过 _build_api_section(handler.build_prompt 已包含)。
|
||||
|
||||
---
|
||||
|
||||
### 2.3 classify_outcome 中的 handler 调用
|
||||
|
||||
spawner 在 classify_outcome 后调 on_complete(outcome)。on_complete 是 dispatcher 传入的闭包。
|
||||
|
||||
**改动**:on_complete 闭包改为调用 handler.post_complete。spawner 本身不直接查注册表。
|
||||
|
||||
---
|
||||
|
||||
## 三、ticker.py(1897 行)
|
||||
|
||||
### 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 时不清空 assignee(assignee 是收件人)。
|
||||
|
||||
**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 跳过 mail(L1304)
|
||||
|
||||
```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_reply(L1555-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),再改引擎**。
|
||||
@@ -3,6 +3,9 @@ name = "sanguo-moziplus-v2"
|
||||
version = "3.0.0"
|
||||
description = "AI Native DevOps Platform v2 - Blackboard Architecture"
|
||||
requires-python = ">=3.9"
|
||||
dependencies = [
|
||||
"pyyaml",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
asyncio_mode = "auto"
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
"""@mention 解析工具模块。供所有 toolchain handler 复用。"""
|
||||
|
||||
import re
|
||||
import logging
|
||||
from typing import List, Set
|
||||
|
||||
from src.config.agents import AGENT_IDS
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Gitea API 基地址常量(避免硬编码)
|
||||
GITEA_API_BASE = "http://192.168.2.154:3000/api/v1"
|
||||
GITEA_WEB_BASE = "http://192.168.2.154:3000"
|
||||
|
||||
# Agent 别名映射
|
||||
# 规则:
|
||||
# 1. 中文名(如"张飞")→ 完整 Agent ID
|
||||
# 2. 英文短名(如"zhangfei")→ 完整 Agent ID
|
||||
# 3. 前缀模糊匹配需唯一匹配(见 extract_mentions 假设 A2)
|
||||
AGENT_ALIAS: dict[str, str] = {
|
||||
# 中文名
|
||||
"张飞": "zhangfei-dev",
|
||||
"关羽": "guanyu-dev",
|
||||
"赵云": "zhaoyun-data",
|
||||
"姜维": "jiangwei-infra",
|
||||
"司马懿": "simayi-challenger",
|
||||
"庞统": "pangtong-fujunshi",
|
||||
# 字+号(常见写法)
|
||||
"翼德": "zhangfei-dev",
|
||||
"云长": "guanyu-dev",
|
||||
"子龙": "zhaoyun-data",
|
||||
"伯约": "jiangwei-infra",
|
||||
"仲达": "simayi-challenger",
|
||||
"士元": "pangtong-fujunshi",
|
||||
# 英文短名
|
||||
"zhangfei": "zhangfei-dev",
|
||||
"guanyu": "guanyu-dev",
|
||||
"zhaoyun": "zhaoyun-data",
|
||||
"jiangwei": "jiangwei-infra",
|
||||
"simayi": "simayi-challenger",
|
||||
"pangtong": "pangtong-fujunshi",
|
||||
}
|
||||
|
||||
# 正则:匹配 @后面跟着的合法 Agent 名(英文字母/中文/数字/连字符)
|
||||
_MENTION_PATTERN = re.compile(r"@([a-zA-Z\u4e00-\u9fa5][a-zA-Z0-9\u4e00-\u9fff-]*)")
|
||||
|
||||
|
||||
def extract_mentions(body: str, sender: str) -> list[str]:
|
||||
"""从文本中提取 @mention 的 Agent ID 列表。
|
||||
|
||||
Args:
|
||||
body: 评论文本
|
||||
sender: 评论者 Gitea 用户名(用于排除自己 @自己)
|
||||
|
||||
Returns:
|
||||
去重后的 Agent ID 列表
|
||||
|
||||
匹配优先级:精确 > 别名 > 前缀模糊(需唯一匹配,多候选则跳过)
|
||||
"""
|
||||
candidates = _MENTION_PATTERN.findall(body)
|
||||
result: Set[str] = set()
|
||||
|
||||
for c in candidates:
|
||||
# 1. 精确匹配(@zhangfei-dev)
|
||||
if c in AGENT_IDS:
|
||||
result.add(c)
|
||||
# 2. 别名匹配(@张飞、@zhangfei)
|
||||
elif c in AGENT_ALIAS:
|
||||
result.add(AGENT_ALIAS[c])
|
||||
else:
|
||||
# 3. 前缀模糊匹配(@zhangf → zhangfei-dev)
|
||||
# 假设 A2:多个候选时不匹配,只 log warning
|
||||
matches = [aid for aid in AGENT_IDS if aid.startswith(c)]
|
||||
if len(matches) == 1:
|
||||
result.add(matches[0])
|
||||
elif len(matches) > 1:
|
||||
logger.warning(
|
||||
"Prefix '%s' matched %d agents (%s), skipping ambiguous mention",
|
||||
c, len(matches), matches)
|
||||
|
||||
# 排除自己 @自己(假设 A1:Gitea login = Agent ID)
|
||||
result.discard(sender)
|
||||
return list(result)
|
||||
|
||||
|
||||
def should_suppress_mention(
|
||||
mentioned_agent: str,
|
||||
auto_notify_targets: List[str],
|
||||
) -> bool:
|
||||
"""判断 @mention 通知是否应被抑制(因为自动流转已通知同一人)。
|
||||
|
||||
Args:
|
||||
mentioned_agent: 被 @的 Agent ID
|
||||
auto_notify_targets: 本次事件自动流转已通知的目标列表
|
||||
|
||||
Returns:
|
||||
True 表示应抑制(不发 @mention Mail)
|
||||
"""
|
||||
return mentioned_agent in auto_notify_targets
|
||||
|
||||
|
||||
def infer_intent(body: str) -> str:
|
||||
"""从 @mention 内容推断意图。
|
||||
|
||||
Returns:
|
||||
"help" | "notify" | "collaborate" | "assign"
|
||||
"""
|
||||
# 分配子任务关键词
|
||||
assign_keywords = ["交给", "分配", "负责", "认领", "做一下", "帮忙做", "implement"]
|
||||
if any(kw in body for kw in assign_keywords):
|
||||
return "assign"
|
||||
|
||||
# 求助关键词(注意:"帮忙"已由 assign_keywords 的"帮忙做"覆盖,"请帮忙"由 collab_keywords 覆盖)
|
||||
help_keywords = ["怎么", "如何", "?", "?", "什么", "哪个", "能否"]
|
||||
if any(kw in body for kw in help_keywords):
|
||||
return "help"
|
||||
|
||||
# 协作请求关键词
|
||||
collab_keywords = ["请帮忙", "请协助", "请澄清", "请review", "请审查", "评估"]
|
||||
if any(kw in body for kw in collab_keywords):
|
||||
return "collaborate"
|
||||
|
||||
# 默认为通知关注
|
||||
return "notify"
|
||||
|
||||
|
||||
def _build_response_guidance(
|
||||
intent: str,
|
||||
gitea_api: str,
|
||||
repo: str,
|
||||
issue_number: int,
|
||||
commenter: str,
|
||||
) -> str:
|
||||
"""根据意图类型生成响应指引文本。"""
|
||||
if intent == "help":
|
||||
return (
|
||||
f"这是一条求助,请到 Gitea 评论回复:\n"
|
||||
f"1. 获取评论上下文(上方 API)\n"
|
||||
f"2. 组织回答\n"
|
||||
f"3. 在 Gitea 评论回复: POST {gitea_api}/repos/{repo}/issues/{issue_number}/comments\n"
|
||||
f' Body: {{"body": "你的回答内容"}}'
|
||||
)
|
||||
elif intent == "notify":
|
||||
return (
|
||||
f"这是一条通知,请查看并知晓。如有意见,可到 Gitea 评论:\n"
|
||||
f"- 查看 Issue/PR 详情(上方 API)\n"
|
||||
f"- 如有意见,评论回复: POST {gitea_api}/repos/{repo}/issues/{issue_number}/comments"
|
||||
)
|
||||
elif intent == "collaborate":
|
||||
return (
|
||||
f"这是一条协作请求,请评估后回复(评论或 Mail):\n"
|
||||
f"1. 获取详情(上方 API)\n"
|
||||
f"2. 评估可行性\n"
|
||||
f"3a. 评论回复: POST {gitea_api}/repos/{repo}/issues/{issue_number}/comments\n"
|
||||
f' Body: {{"body": "你的回复"}}\n'
|
||||
f"3b. 或通过 Mail 回复评论者: {commenter}"
|
||||
)
|
||||
elif intent == "assign":
|
||||
return (
|
||||
f"这是一条任务分配,请认领并执行:\n"
|
||||
f"1. 获取 Issue 详情(上方 API)\n"
|
||||
f"2. 评估可行性\n"
|
||||
f"3. 认领 Issue: POST {gitea_api}/repos/{repo}/issues/{issue_number}/assignees\n"
|
||||
f' Body: {{"assignees": ["{{your_agent_id}}"]}}\n'
|
||||
f"4. 执行任务\n"
|
||||
f"5. 完成后更新 Issue 状态: PATCH {gitea_api}/repos/{repo}/issues/{issue_number}\n"
|
||||
f' Body: {{"state": "closed"}}'
|
||||
)
|
||||
return "请查看详情(上方 API)并按需回复。"
|
||||
+481
-46
@@ -27,6 +27,13 @@ from src.blackboard.db import init_db
|
||||
from src.blackboard.models import Task
|
||||
from src.blackboard.operations import Blackboard
|
||||
from src.config.agents import AGENT_IDS
|
||||
from src.api.mention_utils import (
|
||||
extract_mentions,
|
||||
should_suppress_mention,
|
||||
infer_intent,
|
||||
_build_response_guidance,
|
||||
GITEA_API_BASE,
|
||||
)
|
||||
from src.daemon.toolchain_templates import render_template
|
||||
from src.utils import get_data_root
|
||||
|
||||
@@ -77,7 +84,8 @@ def _is_duplicate(event: str, delivery: str,
|
||||
# 取 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}"
|
||||
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",
|
||||
@@ -252,17 +260,92 @@ def _repo_fullname(payload: Dict[str, Any]) -> str:
|
||||
return repo.get("full_name", "")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# @mention 通用发送函数
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _send_mention_mails(
|
||||
mentions: list[str],
|
||||
auto_targets: list[str],
|
||||
source_type: str,
|
||||
mention_type: str,
|
||||
source_url: str,
|
||||
commenter: str,
|
||||
content: str,
|
||||
repo: str,
|
||||
issue_number: int,
|
||||
is_pr: bool,
|
||||
) -> None:
|
||||
"""通用 @mention Mail 发送函数。
|
||||
|
||||
自动抑制已在 auto_targets 中的 Agent,避免双重通知。
|
||||
根据内容推断意图,生成不同的响应指引。
|
||||
"""
|
||||
# 确定 API 路径
|
||||
if is_pr:
|
||||
detail_api = f"pulls/{issue_number}"
|
||||
comments_api = f"issues/{issue_number}/comments"
|
||||
else:
|
||||
detail_api = f"issues/{issue_number}"
|
||||
comments_api = f"issues/{issue_number}/comments"
|
||||
|
||||
for agent_id in mentions:
|
||||
if should_suppress_mention(agent_id, auto_targets):
|
||||
logger.info(
|
||||
"Mention suppressed for %s (already notified by auto flow)",
|
||||
agent_id)
|
||||
continue
|
||||
|
||||
# 从 api_path 提取编号用于标题,如 "issues/32" → "#32"
|
||||
number_str = f"#{issue_number}" if issue_number else ""
|
||||
intent = infer_intent(content)
|
||||
intent_hint = {"help": "求助", "notify": "通知关注",
|
||||
"collaborate": "协作请求", "assign": "分配子任务"}[intent]
|
||||
|
||||
# 生成响应指引
|
||||
guidance = _build_response_guidance(
|
||||
intent=intent,
|
||||
gitea_api=GITEA_API_BASE,
|
||||
repo=repo,
|
||||
issue_number=issue_number,
|
||||
commenter=commenter,
|
||||
)
|
||||
|
||||
text = render_template("mention", {
|
||||
"mention_type": mention_type,
|
||||
"source_type": source_type,
|
||||
"source_url": source_url,
|
||||
"commenter": commenter,
|
||||
"intent_hint": intent_hint,
|
||||
"content_snippet": content[:500],
|
||||
"gitea_api": GITEA_API_BASE,
|
||||
"repo": repo,
|
||||
"source_detail_api_path": detail_api,
|
||||
"source_comments_api_path": comments_api,
|
||||
"response_guidance": guidance,
|
||||
})
|
||||
|
||||
title = f"@mention ({intent_hint}): {source_type} {number_str} ({repo})"
|
||||
_send_mail(agent_id, title, text)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 事件处理函数
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _handle_pull_request(payload: Dict[str, Any]) -> None:
|
||||
"""处理 pull_request 事件:opened → 通知 simayi-challenger。"""
|
||||
"""处理 pull_request 事件:opened → 通知 reviewer;closed → merge 通知。"""
|
||||
action = payload.get("action", "")
|
||||
if action != "opened":
|
||||
return
|
||||
if action == "opened":
|
||||
await _handle_pr_opened(payload)
|
||||
elif action == "closed":
|
||||
await _handle_pr_closed(payload)
|
||||
|
||||
|
||||
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(
|
||||
@@ -296,6 +379,52 @@ async def _handle_pull_request(payload: Dict[str, Any]) -> None:
|
||||
title = f"Review 请求: {pr_title} ({repo}#{pr_number})"
|
||||
_send_mail("simayi-challenger", title, text)
|
||||
|
||||
# S3: PR body @mention 通知
|
||||
pr_body = pr.get("body", "") or ""
|
||||
sender = pr.get("user", {}).get("login", "")
|
||||
mentions = extract_mentions(pr_body, sender)
|
||||
if mentions:
|
||||
# 自动流转已通知 simayi-challenger(review_request)
|
||||
auto_targets = ["simayi-challenger"]
|
||||
await _send_mention_mails(
|
||||
mentions=mentions,
|
||||
auto_targets=auto_targets,
|
||||
source_type="PR",
|
||||
mention_type="PR @mention",
|
||||
source_url=pr.get("html_url", ""),
|
||||
commenter=sender,
|
||||
content=pr_body,
|
||||
repo=repo,
|
||||
issue_number=pr_number,
|
||||
is_pr=True,
|
||||
)
|
||||
|
||||
|
||||
async def _send_review_mentions(
|
||||
review_body: str,
|
||||
reviewer: str,
|
||||
pr_author: str,
|
||||
pr: dict,
|
||||
repo: str,
|
||||
pr_number: int,
|
||||
) -> None:
|
||||
"""提取并发送 Review body 中的 @mention 通知(COMMENTED / 非 COMMENTED 通用)。"""
|
||||
mentions = extract_mentions(review_body, reviewer)
|
||||
if mentions:
|
||||
auto_targets = [pr_author]
|
||||
await _send_mention_mails(
|
||||
mentions=mentions,
|
||||
auto_targets=auto_targets,
|
||||
source_type="Review",
|
||||
mention_type="Review @mention",
|
||||
source_url=pr.get("html_url", ""),
|
||||
commenter=reviewer,
|
||||
content=review_body,
|
||||
repo=repo,
|
||||
issue_number=pr_number,
|
||||
is_pr=True,
|
||||
)
|
||||
|
||||
|
||||
async def _handle_pull_request_review(payload: Dict[str, Any]) -> None:
|
||||
"""处理 pull_request_review 事件:非 COMMENTED → 通知 PR 作者。
|
||||
@@ -327,10 +456,6 @@ 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", "")
|
||||
@@ -347,6 +472,27 @@ async def _handle_pull_request_review(payload: Dict[str, Any]) -> None:
|
||||
"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)
|
||||
|
||||
# S5: Review body @mention 通知(COMMENTED 路径)
|
||||
await _send_review_mentions(review_body, reviewer, pr_author, pr, repo, pr_number)
|
||||
|
||||
return
|
||||
|
||||
result_map = {"APPROVED": "通过 ✓", "REQUEST_CHANGES": "驳回 ✗"}
|
||||
if state not in result_map:
|
||||
return
|
||||
@@ -364,6 +510,238 @@ async def _handle_pull_request_review(payload: Dict[str, Any]) -> None:
|
||||
title = f"Review {result}: {pr_title} ({repo}#{pr_number})"
|
||||
_send_mail(pr_author, title, text)
|
||||
|
||||
# S5: Review body @mention 通知(非 COMMENTED 路径)
|
||||
await _send_review_mentions(review_body, reviewer, pr_author, pr, repo, pr_number)
|
||||
|
||||
|
||||
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 历史,fallback 到默认 reviewer
|
||||
reviewer = "simayi-challenger"
|
||||
logger.info("No review history for PR #%s, using default reviewer %s", pr_number, reviewer)
|
||||
|
||||
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)
|
||||
|
||||
|
||||
def _send_deploy_failure_mail(repo: str, pr_number: int, pr_title: str, reason: str) -> None:
|
||||
"""CD 部署失败通知,复用 deploy_failure 模板"""
|
||||
text = render_template("deploy_failure", {
|
||||
"repo": repo,
|
||||
"commit_sha": f"PR #{pr_number}",
|
||||
})
|
||||
title = f"部署失败: {repo} (auto-deploy, PR #{pr_number})"
|
||||
full_text = f"{text}\n\n失败原因: {reason}"
|
||||
for agent_id in ("jiangwei-infra", "pangtong-fujunshi"):
|
||||
_send_mail(agent_id, title, full_text)
|
||||
|
||||
|
||||
async def _handle_pr_closed(payload: Dict[str, Any]) -> None:
|
||||
"""PR closed → 如果 merged,通知 PR 作者。"""
|
||||
pr = payload.get("pull_request")
|
||||
if not pr or not isinstance(pr, dict):
|
||||
return
|
||||
|
||||
# 只处理 merged 的 PR
|
||||
if not pr.get("merged", False):
|
||||
return
|
||||
|
||||
repo = _repo_fullname(payload)
|
||||
pr_number = pr.get("number", 0)
|
||||
pr_title = pr.get("title", "")
|
||||
pr_author = pr.get("user", {}).get("login", "unknown")
|
||||
# merged_by 可能不在 payload 中,fallback 到 sender
|
||||
merged_by = (
|
||||
pr.get("merged_by", {}).get("login", "")
|
||||
or payload.get("sender", {}).get("login", "unknown")
|
||||
)
|
||||
|
||||
text = render_template("review_merged", {
|
||||
"repo": repo,
|
||||
"pr_number": str(pr_number),
|
||||
"pr_title": pr_title,
|
||||
"pr_author": pr_author,
|
||||
"merged_by": merged_by,
|
||||
})
|
||||
|
||||
title = f"PR 已合并: {pr_title} ({repo}#{pr_number})"
|
||||
_send_mail(pr_author, title, text)
|
||||
|
||||
# 自动部署:git pull + rsync + 按需 post_deploy
|
||||
try:
|
||||
import yaml
|
||||
|
||||
# 加载部署配置
|
||||
config_path = Path(__file__).parent.parent.parent / "config" / "deploy-targets.yaml"
|
||||
if not config_path.exists():
|
||||
return
|
||||
|
||||
with open(config_path, "r", encoding="utf-8") as f:
|
||||
deploy_config = yaml.safe_load(f) or {}
|
||||
|
||||
targets = deploy_config.get("targets", {})
|
||||
target = targets.get(repo)
|
||||
if not target:
|
||||
return # 该仓库不在部署配置中,跳过
|
||||
|
||||
dev_dir = os.path.expanduser(target["dev_dir"])
|
||||
install_dir = os.path.expanduser(target.get("install_dir", target["dev_dir"]))
|
||||
rsync_excludes = target.get("rsync_exclude", [])
|
||||
|
||||
# Step 1: git pull in dev dir
|
||||
proc = await asyncio.create_subprocess_exec(
|
||||
"git", "pull", "origin", "main",
|
||||
cwd=dev_dir,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
stdout, stderr = await asyncio.wait_for(proc.communicate(), timeout=30)
|
||||
|
||||
if proc.returncode != 0:
|
||||
logger.warning("Auto-deploy: git pull failed for %s: %s", repo, stderr.decode())
|
||||
return
|
||||
|
||||
logger.info("Auto-deploy: git pull success for %s", repo)
|
||||
|
||||
# Step 2: rsync to install dir
|
||||
rsync_args = ["rsync", "-a"]
|
||||
for exc in rsync_excludes:
|
||||
rsync_args.extend(["--exclude", exc])
|
||||
rsync_args.extend([f"{dev_dir}/", f"{install_dir}/"])
|
||||
|
||||
rsync_proc = await asyncio.create_subprocess_exec(
|
||||
*rsync_args,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
_, rsync_err = await asyncio.wait_for(rsync_proc.communicate(), timeout=60)
|
||||
|
||||
if rsync_proc.returncode != 0:
|
||||
logger.error("Auto-deploy: rsync failed: %s", rsync_err.decode())
|
||||
_send_deploy_failure_mail(repo, pr_number, pr_title, f"rsync 失败: {rsync_err.decode()}")
|
||||
return
|
||||
|
||||
# Step 3: 判断是否需要执行 post_deploy
|
||||
files = await _fetch_pr_files(repo, pr_number)
|
||||
file_list = files[0]
|
||||
needs_restart = any(
|
||||
f.startswith("src/") or f.startswith("templates/") or f.startswith("frontend/") or f.endswith(".py")
|
||||
for f in file_list
|
||||
)
|
||||
|
||||
if needs_restart:
|
||||
post_deploy_cmds = target.get("post_deploy", [])
|
||||
pm2_name = target.get("pm2_name", "")
|
||||
for cmd in post_deploy_cmds:
|
||||
logger.info("Auto-deploy: executing post_deploy: %s", cmd)
|
||||
|
||||
# M2: 检测当前进程是否会被此命令杀掉(而非脆弱的字符串匹配)
|
||||
# 通过 PM2 环境变量判断:pm2 启动的进程有 PM2_HOME
|
||||
self_restart = False
|
||||
if pm2_name and os.environ.get("PM2_HOME") and "pm2 restart" in cmd:
|
||||
# 检查命令是否包含当前进程名
|
||||
if re.search(rf'pm2\s+restart\s+{re.escape(pm2_name)}', cmd):
|
||||
self_restart = True
|
||||
|
||||
if self_restart:
|
||||
# M1: 用 asyncio.sleep 延迟而非 nohup,保留子进程输出和错误检测
|
||||
# 先 sleep 让 handler 正常返回,再启动 restart 命令
|
||||
# restart 的子进程会在父进程死后被 pm2 新进程接管
|
||||
logger.info("Auto-deploy: self-restart detected, deferring 2s: %s", cmd)
|
||||
await asyncio.sleep(2)
|
||||
deploy_proc = await asyncio.create_subprocess_exec(
|
||||
"sh", "-c", cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
# restart 会杀掉当前进程,communicate 可能不会完成
|
||||
# 但我们至少尝试读取输出
|
||||
try:
|
||||
_, deploy_err = await asyncio.wait_for(
|
||||
deploy_proc.communicate(), timeout=10)
|
||||
except (asyncio.TimeoutError, ProcessLookupError):
|
||||
# 预期行为:进程被 pm2 restart 杀掉
|
||||
logger.info("Auto-deploy: process killed by self-restart (expected)")
|
||||
break
|
||||
else:
|
||||
deploy_proc = await asyncio.create_subprocess_exec(
|
||||
"sh", "-c", cmd,
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
_, deploy_err = await asyncio.wait_for(deploy_proc.communicate(), timeout=30)
|
||||
|
||||
if deploy_proc.returncode != 0:
|
||||
logger.error("Auto-deploy: post_deploy failed: %s", deploy_err.decode())
|
||||
_send_deploy_failure_mail(repo, pr_number, pr_title, f"post_deploy 失败 ({cmd}): {deploy_err.decode()}")
|
||||
break
|
||||
else:
|
||||
logger.info("Auto-deploy: all post_deploy commands succeeded (files: %s)", ", ".join(file_list[:5]))
|
||||
else:
|
||||
logger.info("Auto-deploy: docs-only change for %s, skip post_deploy", repo)
|
||||
|
||||
except asyncio.TimeoutError:
|
||||
logger.error("Auto-deploy: timeout for %s", repo)
|
||||
_send_deploy_failure_mail(repo, pr_number, pr_title, "部署超时")
|
||||
except Exception as e:
|
||||
logger.error("Auto-deploy: unexpected error: %s", e)
|
||||
|
||||
|
||||
async def _handle_issues(payload: Dict[str, Any]) -> None:
|
||||
"""处理 issues 事件:assigned → 通知被指派人;opened+部署失败 → 通知运维。"""
|
||||
@@ -409,65 +787,118 @@ async def _handle_issues(payload: Dict[str, Any]) -> None:
|
||||
title = f"Issue 指派: {issue_title} ({repo}#{issue_number})"
|
||||
_send_mail(assignee, title, text)
|
||||
|
||||
elif action == "opened" and "部署失败" in issue_title:
|
||||
# 从 Issue body 提取 commit hash(Gitea deploy workflow 格式)
|
||||
sha_match = re.search(r'[0-9a-f]{40}', issue.get("body", ""))
|
||||
commit_sha = sha_match.group(0) if sha_match else "(未知)"
|
||||
elif action == "opened":
|
||||
if "部署失败" in issue_title:
|
||||
# 从 Issue body 提取 commit hash(Gitea deploy workflow 格式)
|
||||
sha_match = re.search(r'[0-9a-f]{40}', issue.get("body", ""))
|
||||
commit_sha = sha_match.group(0) if sha_match else "(未知)"
|
||||
|
||||
text = render_template("deploy_failure", {
|
||||
"repo": repo,
|
||||
"commit_sha": commit_sha or "(未知)",
|
||||
})
|
||||
text = render_template("deploy_failure", {
|
||||
"repo": repo,
|
||||
"commit_sha": commit_sha or "(未知)",
|
||||
})
|
||||
|
||||
title = f"部署失败: {repo}"
|
||||
for agent_id in ("jiangwei-infra", "pangtong-fujunshi"):
|
||||
_send_mail(agent_id, title, text)
|
||||
title = f"部署失败: {repo}"
|
||||
for agent_id in ("jiangwei-infra", "pangtong-fujunshi"):
|
||||
_send_mail(agent_id, title, text)
|
||||
|
||||
# Issue body @mention(opened 时检查)
|
||||
issue_body = issue.get("body", "") or ""
|
||||
sender = payload.get("sender", {}).get("login", "")
|
||||
mentions = extract_mentions(issue_body, sender)
|
||||
if mentions:
|
||||
# 自动流转已通知 assignee
|
||||
assignees = issue.get("assignees") or []
|
||||
if not assignees:
|
||||
single = issue.get("assignee")
|
||||
if single and isinstance(single, dict):
|
||||
assignees = [single]
|
||||
auto_targets = [a.get("login", "") for a in assignees if isinstance(a, dict)]
|
||||
await _send_mention_mails(
|
||||
mentions=mentions,
|
||||
auto_targets=auto_targets,
|
||||
source_type="Issue",
|
||||
mention_type="Issue @mention",
|
||||
source_url=issue.get("html_url", ""),
|
||||
commenter=sender,
|
||||
content=issue_body,
|
||||
repo=repo,
|
||||
issue_number=issue_number,
|
||||
is_pr=False,
|
||||
)
|
||||
|
||||
|
||||
async def _handle_issue_comment(payload: Dict[str, Any]) -> None:
|
||||
"""处理 issue_comment 事件:CI 失败关键词 → 通知 PR 作者。"""
|
||||
"""处理 issue_comment 事件:CI 失败关键词 → 通知 PR 作者;@mention → 通知被提及者。"""
|
||||
comment = payload.get("comment")
|
||||
if not comment or not isinstance(comment, dict):
|
||||
logger.warning("issue_comment event missing comment field, skipping")
|
||||
return
|
||||
body = comment.get("body", "")
|
||||
|
||||
# 检查是否包含 CI 失败关键词
|
||||
if "[CI]" not in body and "CI 失败" not in body:
|
||||
return
|
||||
sender = comment.get("user", {}).get("login", "")
|
||||
|
||||
issue = payload.get("issue")
|
||||
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"))
|
||||
action = payload.get("action", "")
|
||||
if action != "created":
|
||||
return
|
||||
|
||||
repo = _repo_fullname(payload)
|
||||
issue_number = issue.get("number", 0)
|
||||
# === 路径 1:CI 失败通知(原有逻辑,改为正向 if) ===
|
||||
if ("[CI]" in body or "CI 失败" in body) and issue.get("state") != "closed":
|
||||
repo = _repo_fullname(payload)
|
||||
issue_number = issue.get("number", 0)
|
||||
|
||||
# 尝试从关联 PR 获取信息
|
||||
pr_author = issue.get("user", {}).get("login", "unknown")
|
||||
branch_match = re.search(r"分支:\s*(\S+)", body)
|
||||
branch = branch_match.group(1) if branch_match else "(未知)"
|
||||
# 尝试从关联 PR 获取信息
|
||||
pr_author = issue.get("user", {}).get("login", "unknown")
|
||||
branch_match = re.search(r"分支:\s*(\S+)", body)
|
||||
branch = branch_match.group(1) if branch_match else "(未知)"
|
||||
|
||||
# 提取错误摘要(取 comment body 前 500 字符)
|
||||
error_summary = body[:500] if body else "(无错误信息)"
|
||||
# 提取错误摘要(取 comment body 前 500 字符)
|
||||
error_summary = body[:500] if body else "(无错误信息)"
|
||||
|
||||
text = render_template("ci_failure", {
|
||||
"repo": repo,
|
||||
"pr_number": str(issue_number),
|
||||
"branch": branch,
|
||||
"error_summary": error_summary,
|
||||
})
|
||||
text = render_template("ci_failure", {
|
||||
"repo": repo,
|
||||
"pr_number": str(issue_number),
|
||||
"branch": branch,
|
||||
"error_summary": error_summary,
|
||||
})
|
||||
|
||||
title = f"CI 失败: {repo}#{issue_number}"
|
||||
_send_mail(pr_author, title, text)
|
||||
title = f"CI 失败: {repo}#{issue_number}"
|
||||
_send_mail(pr_author, title, text)
|
||||
# CI 处理完不 return,继续检查 @mention
|
||||
|
||||
# === 路径 2:@mention 通知(新增,独立路径) ===
|
||||
# 注意:@mention 检测与 CI 检测是独立的,同一条评论可同时触发两者
|
||||
mentions = extract_mentions(body, sender)
|
||||
if mentions:
|
||||
# 判断是 PR 还是 Issue(Gitea 中 PR 本质是特殊的 Issue)
|
||||
is_pr = issue.get("pull_request") is not None
|
||||
source_type = "PR" if is_pr else "Issue"
|
||||
mention_type = "PR @mention" if is_pr else "Issue @mention"
|
||||
|
||||
issue_number = issue.get("number", 0)
|
||||
repo = _repo_fullname(payload)
|
||||
|
||||
# 自动流转已通知的人(CI 失败通知的 PR 作者)
|
||||
auto_targets: list[str] = []
|
||||
if ("[CI]" in body or "CI 失败" in body) and issue.get("state") != "closed":
|
||||
auto_targets.append(issue.get("user", {}).get("login", ""))
|
||||
|
||||
await _send_mention_mails(
|
||||
mentions=mentions,
|
||||
auto_targets=auto_targets,
|
||||
source_type=source_type,
|
||||
mention_type=mention_type,
|
||||
source_url=issue.get("html_url", ""),
|
||||
commenter=sender,
|
||||
content=body,
|
||||
repo=repo,
|
||||
issue_number=issue_number,
|
||||
is_pr=is_pr,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -476,10 +907,12 @@ async def _handle_issue_comment(payload: Dict[str, Any]) -> None:
|
||||
|
||||
_EVENT_HANDLERS: Dict[str, Any] = {
|
||||
"pull_request": _handle_pull_request,
|
||||
"pull_request_sync": _handle_pr_synchronize, # Gitea: PR branch push 是独立事件类型
|
||||
"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,
|
||||
"pull_request_comment": _handle_pull_request_review, # Gitea: review comment 独立事件类型
|
||||
# Gitea v1.23.4 实际发出的 review 子事件(无 _review_ 中间段)
|
||||
"pull_request_approved": _handle_pull_request_review,
|
||||
"pull_request_rejected": _handle_pull_request_review,
|
||||
@@ -534,9 +967,11 @@ async def gitea_webhook(
|
||||
return Response(status_code=200, content="duplicate")
|
||||
|
||||
# 4. 查找 handler
|
||||
action = payload.get("action", "")
|
||||
logger.info("[WEBHOOK] event=%s action=%s delivery=%s", x_gitea_event, action, x_gitea_delivery)
|
||||
handler = _EVENT_HANDLERS.get(x_gitea_event or "")
|
||||
if not handler:
|
||||
logger.debug("Unhandled event type: %s", x_gitea_event)
|
||||
logger.info("[WEBHOOK] Unhandled event type: %s", x_gitea_event)
|
||||
return Response(status_code=200,
|
||||
content=f"unhandled event: {x_gitea_event}")
|
||||
|
||||
|
||||
@@ -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 → working(mail/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
|
||||
+106
-94
@@ -22,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")
|
||||
|
||||
@@ -123,10 +124,11 @@ 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 (
|
||||
@@ -190,27 +192,26 @@ 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,
|
||||
@@ -218,94 +219,84 @@ class Dispatcher:
|
||||
"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
|
||||
"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)
|
||||
|
||||
if _is_review:
|
||||
if _task_db and outcome in (
|
||||
"completed", "session_revived"):
|
||||
# #09: 读 verdict 决定后续动作
|
||||
conn = get_connection(_task_db)
|
||||
if _task_db and outcome in ("completed", "session_revived"):
|
||||
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":
|
||||
if review_row and review_row["verdict"] == "approved":
|
||||
_dispatcher._mark_task_status(
|
||||
_task_db, _task_id, "done")
|
||||
logger.info(
|
||||
"Task %s: review approved, marking done", _task_id)
|
||||
"Legacy %s: review approved, marked done", _task_id)
|
||||
else:
|
||||
# 非 approved → @mention 被审
|
||||
# agent(assignee,非 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)
|
||||
_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
|
||||
"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,
|
||||
@@ -354,8 +345,26 @@ 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)
|
||||
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 {
|
||||
@@ -580,17 +589,18 @@ 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:
|
||||
# [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 not db_path_legacy or not self._mail_auto_working(
|
||||
task.id, db_path_legacy):
|
||||
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:
|
||||
@@ -612,20 +622,18 @@ 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
|
||||
"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,
|
||||
@@ -655,6 +663,7 @@ class Dispatcher:
|
||||
|
||||
# ── Mail 信封/载荷分离辅助方法 ──
|
||||
|
||||
# DEPRECATED: Step 5 handler 架构已替代此方法,保留仅供平滑过渡,确认稳定后删除。
|
||||
def _mail_auto_working(self, task_id: str, db_path: Path) -> bool:
|
||||
"""Mail 任务:系统自动标 working(spawn 前)
|
||||
|
||||
@@ -692,6 +701,7 @@ 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:
|
||||
@@ -721,6 +731,7 @@ class Dispatcher:
|
||||
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(含幻觉门控)"""
|
||||
@@ -828,6 +839,7 @@ class Dispatcher:
|
||||
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:
|
||||
|
||||
@@ -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_working:pending → 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 回复机制:创建新 task,must_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
|
||||
@@ -6,7 +6,7 @@ prompt_composer.py — PromptSection Protocol + PromptContext + PromptComposer
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any, Dict, List, Optional, Protocol, runtime_checkable
|
||||
from typing import Dict, List, Optional, Protocol, runtime_checkable
|
||||
|
||||
logger = logging.getLogger("moziplus-v2.prompt_composer")
|
||||
|
||||
@@ -81,14 +81,14 @@ class PromptComposer:
|
||||
CHARS_PER_TOKEN = 3.5 # 估算比率
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._sections: List[Any] = [] # List[PromptSection]
|
||||
self._sections: List[PromptSection] = []
|
||||
|
||||
def add(self, section: Any) -> None:
|
||||
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[Any]) -> None:
|
||||
def add_many(self, sections: List[PromptSection]) -> None:
|
||||
"""批量添加"""
|
||||
for s in sections:
|
||||
self.add(s)
|
||||
|
||||
+389
-14
@@ -16,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")
|
||||
|
||||
@@ -278,10 +279,29 @@ 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:
|
||||
@@ -321,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"""## 操作指令
|
||||
|
||||
### 状态回写
|
||||
@@ -1099,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 {}
|
||||
@@ -1271,6 +1297,238 @@ curl -X POST http://{api_host}:{api_port}/api/projects/{project_id}/tasks/{task_
|
||||
logger.exception("Failed to revive %s", agent_id)
|
||||
return False
|
||||
|
||||
# deprecated: §24 v3, 保留供方案 B 备选
|
||||
@staticmethod
|
||||
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
|
||||
|
||||
# deprecated: §24 v3, 保留供方案 B 备选(旧 rotation 结束标记检测,已被 v5 取代)
|
||||
@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
|
||||
|
||||
# ─── v5: compact 开始标记检测(gateway log)+ 结束标记检测(jsonl) ───
|
||||
|
||||
@staticmethod
|
||||
def _find_compact_start_in_gateway_log(
|
||||
agent_id: str, window_seconds: int = 900) -> Optional[str]:
|
||||
"""v5: 检查 gateway 日志,找最近的 compact 开始标记。
|
||||
|
||||
只检测 precheck 路径:message 含 "[context-overflow-precheck]" 且
|
||||
"route=compact_then_truncate"。原因:
|
||||
- overflow 标记("attempting auto-compaction")不含 sessionKey,
|
||||
被 `session_key not in msg` 前置过滤跳过,是死代码。
|
||||
- timeout 标记推测同理不含 sessionKey。
|
||||
- precheck 标记含 sessionKey 且实测总在 overflow 之前触发(同一 compact
|
||||
事件,precheck 先检测到,overflow 是 fallback),所以 precheck 已覆盖
|
||||
overflow 场景。
|
||||
- threshold/manual 触发的 compact 无开始标记(静默执行),依赖
|
||||
counter+lock+status 保护,不需要 gateway 日志检测。
|
||||
|
||||
超时兜底:开始标记超过 window_seconds(默认 15 分钟)自动忽略。
|
||||
|
||||
返回最近一个开始标记的 UTC ISO 时间字符串(带 Z 后缀),或 None。
|
||||
"""
|
||||
from datetime import datetime as _dt, timezone as _tz, timedelta
|
||||
log_paths = AgentSpawner._get_recent_gateway_logs()
|
||||
if not log_paths:
|
||||
return None
|
||||
|
||||
session_key = f"agent:{agent_id}:main"
|
||||
now = _dt.now(_tz.utc)
|
||||
window_start = now - timedelta(seconds=window_seconds)
|
||||
|
||||
latest_start_time = None # type: Optional[_dt]
|
||||
latest_start_str = None # type: Optional[str]
|
||||
|
||||
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", "")
|
||||
if session_key not in msg:
|
||||
continue
|
||||
|
||||
# 只检测 precheck 路径:route=compact_then_truncate
|
||||
# overflow/timeout 标记不含 sessionKey,被前置过滤跳过(死代码),已删除
|
||||
if ("[context-overflow-precheck]" not in msg
|
||||
or "route=compact_then_truncate" not in msg):
|
||||
continue
|
||||
|
||||
# 解析时间
|
||||
ts_str = obj.get("time", "")
|
||||
if not ts_str:
|
||||
continue
|
||||
try:
|
||||
event_time = _dt.fromisoformat(
|
||||
ts_str.replace("Z", "+00:00"))
|
||||
if event_time.tzinfo is None:
|
||||
event_time = event_time.replace(tzinfo=_tz.utc)
|
||||
else:
|
||||
# 确保 UTC
|
||||
event_time = event_time.astimezone(_tz.utc)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
|
||||
# 超时兜底:超过窗口的忽略
|
||||
if event_time < window_start:
|
||||
continue
|
||||
|
||||
if latest_start_time is None or event_time > latest_start_time:
|
||||
latest_start_time = event_time
|
||||
latest_start_str = event_time.strftime(
|
||||
"%Y-%m-%dT%H:%M:%S.") + f"{event_time.microsecond:06d}" + "Z"
|
||||
|
||||
return latest_start_str
|
||||
|
||||
@staticmethod
|
||||
def _check_compaction_finished_in_jsonl(
|
||||
session_file: str, after_time: str) -> bool:
|
||||
"""v5: 检查 jsonl 是否有 after_time 之后的 compaction entry。
|
||||
|
||||
有 → compact 已完成 → True
|
||||
没有 → compact 可能仍在进行 → False
|
||||
|
||||
after_time 格式:UTC ISO(如 2026-06-12T10:25:27.581Z)。
|
||||
jsonl timestamp 格式也是 UTC ISO。
|
||||
"""
|
||||
if not session_file or not Path(session_file).exists():
|
||||
return False
|
||||
try:
|
||||
from datetime import datetime as _dt, timezone as _tz
|
||||
after_dt = _dt.fromisoformat(after_time.replace("Z", "+00:00"))
|
||||
if after_dt.tzinfo is None:
|
||||
after_dt = after_dt.replace(tzinfo=_tz.utc)
|
||||
|
||||
with open(session_file, "rb") as sf:
|
||||
sf.seek(0, 2)
|
||||
size = sf.tell()
|
||||
sf.seek(max(0, size - 1048576))
|
||||
tail = sf.read().decode("utf-8", errors="replace")
|
||||
|
||||
for line in reversed(tail.splitlines()):
|
||||
if not line.strip():
|
||||
continue
|
||||
try:
|
||||
obj = json.loads(line)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
continue
|
||||
if obj.get("type") == "compaction":
|
||||
ts = obj.get("timestamp", "")
|
||||
if ts:
|
||||
try:
|
||||
ct = _dt.fromisoformat(ts.replace("Z", "+00:00"))
|
||||
if ct.tzinfo is None:
|
||||
ct = ct.replace(tzinfo=_tz.utc)
|
||||
if ct >= after_dt:
|
||||
return True
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
# 遇到早于 after_time 的 entry → 不需要继续往前扫
|
||||
ts = obj.get("timestamp", "")
|
||||
if ts:
|
||||
try:
|
||||
ct = _dt.fromisoformat(ts.replace("Z", "+00:00"))
|
||||
if ct.tzinfo is None:
|
||||
ct = ct.replace(tzinfo=_tz.utc)
|
||||
if ct < after_dt:
|
||||
break
|
||||
except (ValueError, TypeError):
|
||||
pass
|
||||
return False
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _check_recent_compaction_jsonl(
|
||||
session_file: str, window_seconds: int = 900) -> bool:
|
||||
@@ -1387,16 +1645,133 @@ 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 v5: compact 检测 = gateway log 开始标记 + jsonl 结束标记配对
|
||||
# 旧方法 (_check_compact_in_progress_trajectory, _check_recent_compaction_jsonl)
|
||||
# 保留为 deprecated 但不再调用。
|
||||
#
|
||||
# 逻辑:
|
||||
# 1. 查 gateway log 最近的 compact 开始标记(overflow/timeout/precheck)
|
||||
# 2. 有开始标记 → 查 jsonl 是否有对应的 compaction entry(结束标记)
|
||||
# 3. 有开始无结束 → 阻塞(recent_compact=True)
|
||||
# 4. 有开始有结束 → 放行
|
||||
# 5. 无开始标记 → threshold/manual 静默触发,靠 counter+lock+status 保护
|
||||
# 6. 超时兜底:开始标记超过 15 分钟自动忽略
|
||||
if result["status"] not in ("idle", "unknown", None) and sf:
|
||||
compact_start = AgentSpawner._find_compact_start_in_gateway_log(agent_id)
|
||||
if compact_start:
|
||||
finished = AgentSpawner._check_compaction_finished_in_jsonl(sf, compact_start)
|
||||
if not finished:
|
||||
# 有开始标记且未完成 → 阻塞
|
||||
result["recent_compact"] = True
|
||||
# 如果已完成 → recent_compact 保持 False(放行)
|
||||
# 没有开始标记 → threshold/manual 静默触发,不阻塞
|
||||
except Exception:
|
||||
pass
|
||||
return result
|
||||
|
||||
@staticmethod
|
||||
def _check_compact_in_progress_trajectory(
|
||||
session_file: str, timeout_minutes: int = 30) -> bool:
|
||||
"""§24 v4: 检查 trajectory jsonl 尾部,判断 session 是否处于非正常状态。
|
||||
|
||||
检测逻辑:最后一个完整 turn 没有 prompt.submitted/skipped → 非正常 → skip。
|
||||
覆盖:compact、timeout、hook block、session 结束等所有非正常状态。
|
||||
|
||||
Returns:
|
||||
True = 非正常状态(skip ticker)
|
||||
False = 正常(不 skip)或超时兜底放行
|
||||
"""
|
||||
if not session_file:
|
||||
return False
|
||||
traj_path = f"{session_file}.trajectory.jsonl"
|
||||
if not os.path.exists(traj_path):
|
||||
return False
|
||||
|
||||
try:
|
||||
from datetime import datetime as _dt, timezone as _tz
|
||||
|
||||
# 读尾部 500KB
|
||||
with open(traj_path, "rb") as f:
|
||||
f.seek(0, 2)
|
||||
size = f.tell()
|
||||
f.seek(max(0, size - 500 * 1024))
|
||||
tail = f.read().decode("utf-8", errors="replace")
|
||||
|
||||
if not tail.strip():
|
||||
return False
|
||||
|
||||
# 解析所有有效行
|
||||
events = []
|
||||
for line in tail.splitlines():
|
||||
line = line.strip()
|
||||
if not line:
|
||||
continue
|
||||
try:
|
||||
obj = json.loads(line)
|
||||
events.append(obj)
|
||||
except (json.JSONDecodeError, ValueError):
|
||||
continue
|
||||
|
||||
if not events:
|
||||
return False
|
||||
|
||||
# 按 session.started 分组找 turn
|
||||
# 每个 turn 以 session.started 开始
|
||||
turns = []
|
||||
current_turn = []
|
||||
for evt in events:
|
||||
if evt.get("type") == "session.started":
|
||||
if current_turn:
|
||||
turns.append(current_turn)
|
||||
current_turn = [evt]
|
||||
else:
|
||||
current_turn.append(evt)
|
||||
if current_turn:
|
||||
turns.append(current_turn)
|
||||
|
||||
if not turns:
|
||||
return False
|
||||
|
||||
# 检查最后一个完整 turn(包含 session.started)
|
||||
last_turn = turns[-1]
|
||||
turn_types = {evt.get("type") for evt in last_turn}
|
||||
|
||||
# 有 prompt.submitted 或 prompt.skipped → 正常 turn
|
||||
if "prompt.submitted" in turn_types or "prompt.skipped" in turn_types:
|
||||
return False
|
||||
|
||||
# 非正常状态 → 检查超时兜底
|
||||
# 找最后一个有 ts 的事件
|
||||
last_ts = None
|
||||
for evt in reversed(events):
|
||||
ts_str = evt.get("ts")
|
||||
if ts_str:
|
||||
try:
|
||||
last_ts = _dt.fromisoformat(
|
||||
ts_str.replace("Z", "+00:00"))
|
||||
if last_ts.tzinfo is None:
|
||||
last_ts = last_ts.replace(tzinfo=_tz.utc)
|
||||
except (ValueError, TypeError):
|
||||
continue
|
||||
break
|
||||
|
||||
if last_ts is None:
|
||||
# 没有 ts 信息,无法判断超时 → 非正常 → skip
|
||||
return True
|
||||
|
||||
now = _dt.now(_tz.utc)
|
||||
elapsed = (now - last_ts).total_seconds()
|
||||
if elapsed > timeout_minutes * 60:
|
||||
logger.debug("Trajectory last event %.0fs ago > %dm, fallback pass",
|
||||
elapsed, timeout_minutes)
|
||||
return False # 兜底放行
|
||||
|
||||
return True # 非正常状态且未超时
|
||||
|
||||
except Exception as e:
|
||||
logger.debug("_check_compact_in_progress_trajectory error: %s", e)
|
||||
return False
|
||||
|
||||
@staticmethod
|
||||
def _classify_outcome(exit_code: int, json_result: dict, stderr_text: str,
|
||||
task_status: Optional[str], stdout_text: str = "") -> dict:
|
||||
|
||||
@@ -0,0 +1,387 @@
|
||||
"""task_handler.py — 黑板任务 handler(task_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:
|
||||
"""段 4:API 操作指令。"""
|
||||
|
||||
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 完成 → 读取 verdict,approved 则 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:
|
||||
# 信号 1:terminal 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
|
||||
)
|
||||
|
||||
# 信号 2:outputs
|
||||
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)
|
||||
@@ -9,7 +9,7 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, Dict, List, Optional, Protocol, runtime_checkable
|
||||
from typing import TYPE_CHECKING, Dict, Optional, Protocol, runtime_checkable
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from src.daemon.prompt_composer import PromptContext
|
||||
|
||||
+48
-40
@@ -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
|
||||
@@ -215,18 +217,21 @@ 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",
|
||||
@@ -948,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),
|
||||
@@ -1025,15 +1031,16 @@ Parent Task ID: {parent_task.id}
|
||||
"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:
|
||||
@@ -1300,8 +1307,9 @@ Parent Task ID: {parent_task.id}
|
||||
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)
|
||||
@@ -1470,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:
|
||||
@@ -1621,8 +1628,9 @@ Parent Task ID: {parent_task.id}
|
||||
project_dirs[project_id] = self.registry.root / \
|
||||
project_id / "blackboard.db"
|
||||
|
||||
# 虚拟项目
|
||||
for virtual_id in ("_general", "_mail"):
|
||||
# 虚拟项目:_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:
|
||||
|
||||
@@ -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_working:pending → 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)
|
||||
@@ -21,6 +21,10 @@ _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",
|
||||
"review_merged": "review_merged.md",
|
||||
"mention": "mention.md",
|
||||
}
|
||||
|
||||
# 模板缓存
|
||||
|
||||
@@ -21,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
|
||||
@@ -214,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,
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
{mention_type}通知
|
||||
|
||||
来源: {source_type} {source_url}
|
||||
评论者: {commenter}
|
||||
意图: {intent_hint}
|
||||
内容:
|
||||
{content_snippet}
|
||||
|
||||
📋 获取完整上下文:
|
||||
1. 查看{source_type}详情: GET {gitea_api}/repos/{repo}/{source_detail_api_path}
|
||||
2. 查看评论列表: GET {gitea_api}/repos/{repo}/{source_comments_api_path}
|
||||
|
||||
📌 响应指引:
|
||||
{response_guidance}
|
||||
|
||||
完成后按指引操作。
|
||||
@@ -0,0 +1,9 @@
|
||||
Review 评论
|
||||
|
||||
PR: http://192.168.2.154:3000/{repo}/pulls/{pr_number}
|
||||
标题: {pr_title}
|
||||
评论者: {reviewer}
|
||||
|
||||
{comment_body}
|
||||
|
||||
请查看评论并回复或修改代码。
|
||||
@@ -0,0 +1,8 @@
|
||||
## PR 已合并 ✅
|
||||
|
||||
**仓库**: {repo}
|
||||
**PR #{pr_number}**: {pr_title}
|
||||
**作者**: @{pr_author}
|
||||
**合并者**: @{merged_by}
|
||||
|
||||
PR 已成功合并到主分支。
|
||||
@@ -7,3 +7,4 @@ PR: http://192.168.2.154:3000/{repo}/pulls/{pr_number}
|
||||
{review_body}
|
||||
|
||||
如需修改请更新 PR 后重新请求 Review。
|
||||
如 Review 已通过,请及时合并此 PR。
|
||||
|
||||
@@ -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 diff(Gitea API: GET /repos/{repo}/pulls/{pr_number}.diff)
|
||||
2. 重点检查上次 Review 意见的修改部分
|
||||
3. 提交 Review(Gitea API: POST /repos/{repo}/pulls/{pr_number}/reviews)
|
||||
4. 提交后改动者会自动收到通知
|
||||
|
||||
完成后回复此 Mail 确认。
|
||||
@@ -0,0 +1,184 @@
|
||||
"""单元测试:§24 v4 trajectory prompt.submitted compact 检测
|
||||
|
||||
测试 _check_compact_in_progress_trajectory 方法。
|
||||
用 tmp_path 构造 mock trajectory jsonl 文件。
|
||||
"""
|
||||
|
||||
import json
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from src.daemon.spawner import AgentSpawner
|
||||
|
||||
|
||||
# ── helpers ──
|
||||
|
||||
_SESSION_ID = "sess-abc123"
|
||||
|
||||
|
||||
def _make_trajectory_event(event_type: str, ts: str = None, **kwargs) -> dict:
|
||||
"""构造 trajectory jsonl 事件"""
|
||||
obj = {"type": event_type}
|
||||
if ts:
|
||||
obj["ts"] = ts
|
||||
obj.update(kwargs)
|
||||
return obj
|
||||
|
||||
|
||||
def _write_trajectory(tmp_path: Path, session_id: str, turns: list[list[dict]]):
|
||||
"""写入 trajectory jsonl,按 turns 分组。
|
||||
|
||||
每个 turn 是一个 list of events。
|
||||
自动在每组前加 session.started(如果该 turn 没有的话)。
|
||||
"""
|
||||
traj_file = tmp_path / f"{session_id}.trajectory.jsonl"
|
||||
with open(traj_file, "w") as f:
|
||||
for turn_events in turns:
|
||||
# 如果 turn 第一个事件不是 session.started,自动加一个
|
||||
if not turn_events or turn_events[0].get("type") != "session.started":
|
||||
started = _make_trajectory_event(
|
||||
"session.started",
|
||||
ts=turn_events[0].get("ts") if turn_events else None,
|
||||
)
|
||||
f.write(json.dumps(started, ensure_ascii=False) + "\n")
|
||||
for evt in turn_events:
|
||||
f.write(json.dumps(evt, ensure_ascii=False) + "\n")
|
||||
|
||||
|
||||
def _utc_now_str() -> str:
|
||||
"""返回当前 UTC 时间的 ISO 字符串(带 Z 后缀)"""
|
||||
return datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.") + \
|
||||
f"{datetime.now(timezone.utc).microsecond // 1000:03d}Z"
|
||||
|
||||
|
||||
def _utc_past_str(minutes_ago: int) -> str:
|
||||
"""返回过去 N 分钟的 UTC ISO 字符串"""
|
||||
ts = datetime.now(timezone.utc) - timedelta(minutes=minutes_ago)
|
||||
return ts.strftime("%Y-%m-%dT%H:%M:%S.") + \
|
||||
f"{ts.microsecond // 1000:03d}Z"
|
||||
|
||||
|
||||
# ── 测试用例 ──
|
||||
|
||||
|
||||
class TestCheckCompactInProgressTrajectory:
|
||||
"""§24 v4: _check_compact_in_progress_trajectory 单元测试"""
|
||||
|
||||
def test_tc1_normal_turn_with_submitted_returns_false(self, tmp_path):
|
||||
"""TC1: 正常 turn(有 prompt.submitted)→ False"""
|
||||
now = _utc_now_str()
|
||||
turns = [[
|
||||
_make_trajectory_event("session.started", ts=now),
|
||||
_make_trajectory_event("context.compiled", ts=now),
|
||||
_make_trajectory_event("prompt.submitted", ts=now),
|
||||
_make_trajectory_event("model.completed", ts=now),
|
||||
]]
|
||||
_write_trajectory(tmp_path, _SESSION_ID, turns)
|
||||
session_file = str(tmp_path / _SESSION_ID)
|
||||
assert AgentSpawner._check_compact_in_progress_trajectory(session_file) is False
|
||||
|
||||
def test_tc2_compact_turn_returns_true(self, tmp_path):
|
||||
"""TC2: compact turn(无 prompt.submitted,有 context.compiled + model.completed)→ True"""
|
||||
now = _utc_now_str()
|
||||
turns = [[
|
||||
_make_trajectory_event("session.started", ts=now),
|
||||
_make_trajectory_event("context.compiled", ts=now),
|
||||
_make_trajectory_event("model.completed", ts=now),
|
||||
]]
|
||||
_write_trajectory(tmp_path, _SESSION_ID, turns)
|
||||
session_file = str(tmp_path / _SESSION_ID)
|
||||
assert AgentSpawner._check_compact_in_progress_trajectory(session_file) is True
|
||||
|
||||
def test_tc3_skipped_prompt_returns_false(self, tmp_path):
|
||||
"""TC3: 空白 prompt(有 prompt.skipped)→ False"""
|
||||
now = _utc_now_str()
|
||||
turns = [[
|
||||
_make_trajectory_event("session.started", ts=now),
|
||||
_make_trajectory_event("context.compiled", ts=now),
|
||||
_make_trajectory_event("prompt.skipped", ts=now),
|
||||
_make_trajectory_event("model.completed", ts=now),
|
||||
]]
|
||||
_write_trajectory(tmp_path, _SESSION_ID, turns)
|
||||
session_file = str(tmp_path / _SESSION_ID)
|
||||
assert AgentSpawner._check_compact_in_progress_trajectory(session_file) is False
|
||||
|
||||
def test_tc4_timeout_fallback_returns_false(self, tmp_path):
|
||||
"""TC4: 超过 30min 兜底 → False"""
|
||||
old = _utc_past_str(35)
|
||||
turns = [[
|
||||
_make_trajectory_event("session.started", ts=old),
|
||||
_make_trajectory_event("context.compiled", ts=old),
|
||||
_make_trajectory_event("model.completed", ts=old),
|
||||
]]
|
||||
_write_trajectory(tmp_path, _SESSION_ID, turns)
|
||||
session_file = str(tmp_path / _SESSION_ID)
|
||||
assert AgentSpawner._check_compact_in_progress_trajectory(session_file) is False
|
||||
|
||||
def test_tc5_trajectory_not_exists_returns_false(self, tmp_path):
|
||||
"""TC5: trajectory 不存在 → False"""
|
||||
session_file = str(tmp_path / "nonexistent-session")
|
||||
assert AgentSpawner._check_compact_in_progress_trajectory(session_file) is False
|
||||
|
||||
def test_tc6_empty_trajectory_returns_false(self, tmp_path):
|
||||
"""TC6: 空 trajectory → False"""
|
||||
traj_file = tmp_path / f"{_SESSION_ID}.trajectory.jsonl"
|
||||
traj_file.write_text("")
|
||||
session_file = str(tmp_path / _SESSION_ID)
|
||||
assert AgentSpawner._check_compact_in_progress_trajectory(session_file) is False
|
||||
|
||||
def test_tc7_multi_turn_last_normal_returns_false(self, tmp_path):
|
||||
"""TC7: 多 turn 尾部只看最后一个(最后一个正常但之前有 compact)→ False"""
|
||||
old = _utc_past_str(10)
|
||||
now = _utc_now_str()
|
||||
turn1 = [
|
||||
_make_trajectory_event("session.started", ts=old),
|
||||
_make_trajectory_event("context.compiled", ts=old),
|
||||
_make_trajectory_event("model.completed", ts=old), # compact turn, no prompt
|
||||
]
|
||||
turn2 = [
|
||||
_make_trajectory_event("session.started", ts=now),
|
||||
_make_trajectory_event("prompt.submitted", ts=now),
|
||||
_make_trajectory_event("model.completed", ts=now), # normal turn
|
||||
]
|
||||
_write_trajectory(tmp_path, _SESSION_ID, [turn1, turn2])
|
||||
session_file = str(tmp_path / _SESSION_ID)
|
||||
assert AgentSpawner._check_compact_in_progress_trajectory(session_file) is False
|
||||
|
||||
def test_tc8_multi_turn_last_abnormal_returns_true(self, tmp_path):
|
||||
"""TC8: 多 turn 尾部最后一个非正常 → True"""
|
||||
old = _utc_past_str(5)
|
||||
now = _utc_now_str()
|
||||
turn1 = [
|
||||
_make_trajectory_event("session.started", ts=old),
|
||||
_make_trajectory_event("prompt.submitted", ts=old),
|
||||
_make_trajectory_event("model.completed", ts=old), # normal turn
|
||||
]
|
||||
turn2 = [
|
||||
_make_trajectory_event("session.started", ts=now),
|
||||
_make_trajectory_event("context.compiled", ts=now),
|
||||
_make_trajectory_event("model.completed", ts=now), # compact turn, no prompt
|
||||
]
|
||||
_write_trajectory(tmp_path, _SESSION_ID, [turn1, turn2])
|
||||
session_file = str(tmp_path / _SESSION_ID)
|
||||
assert AgentSpawner._check_compact_in_progress_trajectory(session_file) is True
|
||||
|
||||
def test_tc9_null_session_file_returns_false(self):
|
||||
"""TC9: session_file 为空字符串 → False"""
|
||||
assert AgentSpawner._check_compact_in_progress_trajectory("") is False
|
||||
|
||||
def test_tc10_none_session_file_returns_false(self):
|
||||
"""TC10: session_file 为 None → False"""
|
||||
assert AgentSpawner._check_compact_in_progress_trajectory(None) is False
|
||||
|
||||
def test_tc11_events_without_ts_returns_true(self, tmp_path):
|
||||
"""TC11: 事件有 type 但无 ts 字段 → 无法判断超时 → True(skip)"""
|
||||
turns = [[
|
||||
_make_trajectory_event("session.started"), # 无 ts
|
||||
_make_trajectory_event("context.compiled"), # 无 ts
|
||||
_make_trajectory_event("model.completed"), # 无 ts
|
||||
]]
|
||||
_write_trajectory(tmp_path, _SESSION_ID, turns)
|
||||
session_file = str(tmp_path / _SESSION_ID)
|
||||
assert AgentSpawner._check_compact_in_progress_trajectory(session_file) is True
|
||||
@@ -0,0 +1,129 @@
|
||||
"""mention_utils 单元测试 — §25.7 覆盖。"""
|
||||
|
||||
import pytest
|
||||
|
||||
from src.api.mention_utils import (
|
||||
extract_mentions,
|
||||
should_suppress_mention,
|
||||
infer_intent,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# extract_mentions
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestExtractMentions:
|
||||
"""测试 @mention 提取逻辑。"""
|
||||
|
||||
def test_exact_match(self):
|
||||
"""@zhangfei-dev 精确匹配。"""
|
||||
assert extract_mentions("@zhangfei-dev 请看一下", "someone") == ["zhangfei-dev"]
|
||||
|
||||
def test_chinese_alias(self):
|
||||
"""@张飞 中文别名匹配。"""
|
||||
assert extract_mentions("@张飞 帮忙看看", "someone") == ["zhangfei-dev"]
|
||||
|
||||
def test_english_short_name(self):
|
||||
"""@zhangfei 英文短名匹配。"""
|
||||
assert extract_mentions("@zhangfei 快来", "someone") == ["zhangfei-dev"]
|
||||
|
||||
def test_prefix_unique(self):
|
||||
"""@zhangf 前缀唯一匹配。"""
|
||||
assert extract_mentions("@zhangf 来一下", "someone") == ["zhangfei-dev"]
|
||||
|
||||
def test_prefix_ambiguous_no_match(self):
|
||||
"""@z 前缀模糊,多个候选,不匹配。"""
|
||||
assert extract_mentions("@z 看看", "someone") == []
|
||||
|
||||
def test_dedup_same_person(self):
|
||||
"""@张飞 @zhangfei-dev 同时出现去重。"""
|
||||
result = extract_mentions("@张飞 @zhangfei-dev 来一下", "someone")
|
||||
assert result == ["zhangfei-dev"]
|
||||
|
||||
def test_exclude_self(self):
|
||||
"""@zhangfei-dev 排除自己(sender=zhangfei-dev)。"""
|
||||
assert extract_mentions("@zhangfei-dev 自己说", "zhangfei-dev") == []
|
||||
|
||||
def test_unknown_person(self):
|
||||
"""@unknown 不匹配任何 Agent。"""
|
||||
assert extract_mentions("@unknown 你好", "someone") == []
|
||||
|
||||
def test_multiple_mentions(self):
|
||||
"""多个 @mention 返回多个 Agent。"""
|
||||
result = set(extract_mentions("@张飞 @关羽 来讨论", "someone"))
|
||||
assert result == {"zhangfei-dev", "guanyu-dev"}
|
||||
|
||||
def test_mention_with_hyphen_in_middle(self):
|
||||
"""@mention 后面紧跟标点也能识别。"""
|
||||
result = extract_mentions("@赵云,请看下", "someone")
|
||||
assert result == ["zhaoyun-data"]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# should_suppress_mention
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestShouldSuppressMention:
|
||||
"""测试 @mention 通知抑制逻辑。"""
|
||||
|
||||
def test_suppress_when_in_list(self):
|
||||
"""被提及者在自动通知列表中 → 抑制。"""
|
||||
assert should_suppress_mention("zhangfei-dev", ["zhangfei-dev", "guanyu-dev"]) is True
|
||||
|
||||
def test_not_suppress_when_not_in_list(self):
|
||||
"""被提及者不在自动通知列表中 → 不抑制。"""
|
||||
assert should_suppress_mention("zhangfei-dev", ["guanyu-dev"]) is False
|
||||
|
||||
def test_suppress_empty_list(self):
|
||||
"""自动通知列表为空 → 不抑制。"""
|
||||
assert should_suppress_mention("zhangfei-dev", []) is False
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# infer_intent
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestInferIntent:
|
||||
"""测试意图推断逻辑。
|
||||
|
||||
优先级:assign → collaborate → help → notify(默认)
|
||||
"""
|
||||
|
||||
def test_help_question_mark(self):
|
||||
"""疑问句 → help。"""
|
||||
assert infer_intent("@赵云 数据格式是什么?") == "help"
|
||||
|
||||
def test_notify_plain_mention(self):
|
||||
"""纯通知(无关键词) → notify。"""
|
||||
assert infer_intent("@关羽 这个 PR 涉及风控变更") == "notify"
|
||||
|
||||
def test_collaborate_please_help(self):
|
||||
"""'请帮忙' → collaborate(NOT help!)。"""
|
||||
assert infer_intent("@庞统 请帮忙澄清需求") == "collaborate"
|
||||
|
||||
def test_assign_keywords(self):
|
||||
"""'交给你' → assign。"""
|
||||
assert infer_intent("@张飞 前端部分交给你") == "assign"
|
||||
|
||||
def test_help_how_to(self):
|
||||
"""'如何' → help。"""
|
||||
assert infer_intent("@姜维 如何部署这个服务") == "help"
|
||||
|
||||
def test_collaborate_please_review(self):
|
||||
"""'请review' → collaborate。"""
|
||||
assert infer_intent("@司马懿 请review 这个方案") == "collaborate"
|
||||
|
||||
def test_notify_default(self):
|
||||
"""无任何关键词 → notify。"""
|
||||
assert infer_intent("@赵云 已更新数据") == "notify"
|
||||
|
||||
def test_assign_takes_priority_over_help(self):
|
||||
"""assign 关键词优先于 help 关键词。"""
|
||||
# "交给" in body → assign, even though "?" also present
|
||||
assert infer_intent("@张飞 这个模块交给你,有问题?") == "assign"
|
||||
|
||||
def test_collaborate_takes_priority_over_help(self):
|
||||
"""collaborate 关键词优先于 help 关键词。"""
|
||||
# "请帮忙" in body → collaborate, even though "?" absent
|
||||
assert infer_intent("@赵云 请帮忙看看数据") == "collaborate"
|
||||
Reference in New Issue
Block a user