Compare commits

...

37 Commits

Author SHA1 Message Date
cfdaily 6c6e884ce3 fix(spawner): Review M1/M2 — 删除 overflow/timeout 死代码,只保留 precheck;更新设计文档 v5
CI / lint (pull_request) Successful in 8s
CI / test (pull_request) Successful in 8s
CI / notify-on-failure (pull_request) Successful in 0s
M1: overflow/timeout 标记不含 sessionKey,被前置过滤跳过是死代码。
    precheck 总在 overflow 之前触发且含 sessionKey,已覆盖 overflow 场景。
    删除 overflow/timeout 分支,只保留 precheck route=compact_then_truncate。

M2: §24 设计文档新增 v5 章节描述(方案概述、三种触发路径分析、为什么只依赖 precheck)。
2026-06-13 07:01:54 +08:00
cfdaily 36ba629b69 fix(spawner): compact 检测 v5 — gateway log 开始标记 + jsonl 结束标记配对
CI / lint (pull_request) Successful in 7s
CI / test (pull_request) Successful in 9s
CI / notify-on-failure (pull_request) Successful in 0s
- 新增 _find_compact_start_in_gateway_log: 检测 overflow/timeout/precheck 三种开始标记
- 新增 _check_compaction_finished_in_jsonl: 检测 jsonl compaction entry 作为结束标记
- 重写 _check_session_state compact 检测逻辑: 开始+结束配对
- 无开始标记 (threshold/manual) 不阻塞,靠 counter+lock+status 保护
- 超时兜底 15 分钟保留
- 旧方法标记 deprecated 保留
- 427 passed
2026-06-13 00:27:39 +08:00
pangtong-fujunshi 3b7ecaf446 Merge pull request 'fix(toolchain): is_pr detection check value not key' (#47) from fix/is-pr-detection into main 2026-06-12 13:15:58 +00:00
cfdaily a8a1886f27 fix(toolchain): is_pr detection - check value not key existence
CI / lint (pull_request) Successful in 7s
CI / test (pull_request) Successful in 8s
CI / notify-on-failure (pull_request) Successful in 0s
Gitea Issue API returns pull_request: null for pure Issues (key exists but
value is None). 'pull_request' in issue was always True, causing all Issue
@mention mails to show 'PR #N' instead of 'Issue #N'.

Fix: issue.get('pull_request') is not None
2026-06-12 20:26:02 +08:00
pangtong-fujunshi c7aca6fc72 Merge pull request 'docs: sync §25 design doc help_keywords with actual code' (#46) from docs/25-sync-help-keywords into main 2026-06-12 11:09:53 +00:00
cfdaily 387fa3214f docs: sync §25 design doc help_keywords with actual code
CI / lint (pull_request) Successful in 6s
CI / test (pull_request) Successful in 8s
CI / notify-on-failure (pull_request) Successful in 1s
2026-06-12 19:09:23 +08:00
pangtong-fujunshi 88a2409e67 Merge PR #45 2026-06-12 11:08:50 +00:00
cfdaily e7f28cd36e fix(mention): address PR #45 review feedback (M1-M3, S1-S3)
CI / lint (pull_request) Successful in 7s
CI / test (pull_request) Successful in 8s
CI / notify-on-failure (pull_request) Successful in 0s
M1: Remove '帮忙' from help_keywords to fix keyword priority bug
M3: Add unit tests for mention_utils (§25.7)
S1: Merge two 'if action == opened' blocks in _handle_issues
S2: Extract _send_review_mentions helper to deduplicate @mention code
S3: Remove redundant 'import re' inside conditional block
2026-06-12 18:56:54 +08:00
cfdaily f25af64f00 feat(toolchain): §25 Gitea @mention end-to-end integration
New files:
- src/api/mention_utils.py: extract_mentions(), infer_intent(),
  _build_response_guidance(), AGENT_ALIAS mapping
- templates/toolchain/mention.md: @mention notification template
  with context API + response guidance by intent type

Modified:
- src/api/toolchain_routes.py: S1-S5 handler changes
  - S1: Issue body @mention on opened
  - S2+S4: _handle_issue_comment control flow refactored
    (guard clause → positive if, CI + @mention independent paths)
  - S3: PR body @mention on opened
  - S5: Review body @mention on submitted
  - New _send_mention_mails() with auto-flow suppression
- src/daemon/toolchain_templates.py: register mention template

Design: docs/design/25-gitea-mention-toolchain.md (v2.0)
Tests: 405 passed, 3 skipped
2026-06-12 18:56:54 +08:00
pangtong-fujunshi 866060e557 Merge PR #44 2026-06-12 10:54:25 +00:00
cfdaily 33c58a7dae fix(auto-deploy): address PR #44 review feedback (M1, M2)
CI / lint (pull_request) Successful in 6s
CI / test (pull_request) Successful in 8s
CI / notify-on-failure (pull_request) Successful in 0s
M1: Replace nohup with asyncio.sleep - preserves subprocess output/error detection
M2: Use PM2_HOME env check + regex matching instead of fragile string match
S1: pm2_name now has clear purpose for M2's regex-based self-restart detection

405 passed, 3 skipped.
2026-06-12 15:07:43 +08:00
cfdaily d82d29fd79 fix(auto-deploy): prevent self-kill when pm2 restart runs inside webhook handler
CI / lint (pull_request) Successful in 7s
CI / test (pull_request) Successful in 9s
CI / notify-on-failure (pull_request) Successful in 0s
post_deploy commands that restart the current process (pm2 restart {pm2_name})
now use nohup+sleep to defer execution, allowing the webhook handler to
return normally before the restart happens.

Fix by jiangwei-infra, synced from install dir.
2026-06-12 15:05:06 +08:00
pangtong-fujunshi 0e19ea2009 Merge PR #43 2026-06-12 05:58:43 +00:00
cfdaily 73454c0787 refactor(auto-deploy): YAML config + post_deploy list + deploy failure mail
CI / lint (pull_request) Successful in 7s
CI / test (pull_request) Successful in 8s
CI / notify-on-failure (pull_request) Successful in 1s
- New config/deploy-targets.yaml: centralized deploy target config
- Rewrite auto-deploy in _handle_pr_closed to use YAML config
- Add _send_deploy_failure_mail helper (reuses deploy_failure template)
- Support post_deploy command list (not just pm2 restart)
- Docs-only changes skip post_deploy
- Add pyyaml to pyproject.toml dependencies
- Update design doc §23 with new architecture
2026-06-12 13:57:55 +08:00
cfdaily b80290fe78 fix: address PR #43 review feedback (M1-M3, S1)
M1: git pull in dev dir + rsync to install dir (install dir has no .git)
M2: use asyncio.create_subprocess_exec instead of subprocess.run
M3: add repo whitelist (only sanguo/sanguo_moziplus_v2 triggers auto-deploy)
S1: notify jiangwei-infra on rsync/pm2 restart failure
2026-06-12 13:57:55 +08:00
cfdaily 9bb1e9dc64 feat(toolchain): auto-deploy on PR merge (git pull + pm2 restart) (#43)
- Add auto-deploy logic in _handle_pr_closed after mail notification
- git pull origin main in install dir on merge
- Smart restart: only restart pm2 when src/templates/frontend/*.py changed
- Pure docs changes: pull only, no restart
- Deploy failure logged but does not block mail notification
- Update design doc §23 with auto-deploy section
2026-06-12 13:57:55 +08:00
pangtong-fujunshi 5474d0a0e8 Merge PR #42 2026-06-12 05:03:45 +00:00
cfdaily d0e0055a2e fix: review_result 模板加合并提醒
CI / lint (pull_request) Successful in 7s
CI / test (pull_request) Successful in 10s
CI / notify-on-failure (pull_request) Successful in 1s
Review 通过后模板末尾增加合并提示,防止遗漏合并步骤。
配合 AGENTS.md 流程规则 #6 双保险。
2026-06-12 13:02:46 +08:00
pangtong-fujunshi 9727bf98d9 Merge PR #39: docs: §13/§18/§23 更新 synchronize fallback + merge 通知 2026-06-12 04:53:23 +00:00
cfdaily 5d24183c14 docs: 修正最后一处 pr_merged → review_merged (仲达 M1)
CI / lint (pull_request) Successful in 7s
CI / test (pull_request) Successful in 8s
CI / notify-on-failure (pull_request) Successful in 1s
2026-06-12 12:39:54 +08:00
cfdaily 7b32994c75 docs: 修正模板文件名 pr_merged → review_merged 2026-06-12 12:39:54 +08:00
cfdaily 40efa1c623 docs: §13/§18/§23 更新 synchronize fallback + merge 通知 2026-06-12 12:39:54 +08:00
pangtong-fujunshi f4fc941bd1 Merge pull request 'fix(toolchain): 注册 pull_request_sync + pull_request_comment event type' (#41) from fix/toolchain-event-type-registration into main 2026-06-12 02:18:04 +00:00
cfdaily c6a0567161 fix(toolchain): 注册 pull_request_sync 和 pull_request_comment event type
CI / lint (pull_request) Successful in 8s
CI / test (pull_request) Successful in 9s
CI / notify-on-failure (pull_request) Successful in 0s
Gitea 对 PR branch push 发的是独立事件类型 pull_request_sync,
不是 pull_request + action=synchronize。
同时补注册 pull_request_comment(review comment)。
删除 _handle_pull_request 中永远不会触发的 synchronize 分支。
2026-06-12 10:11:49 +08:00
pangtong-fujunshi 3f5b3619c8 Merge pull request 'fix(toolchain): synchronize fallback + merge 通知' (#38) from fix/toolchain-synchronize-fallback-and-merge-notify into main 2026-06-12 00:27:42 +00:00
cfdaily e9bbcf41c9 fix(toolchain): 模板双花括号→单花括号 (仲达 M1)
CI / lint (pull_request) Successful in 7s
CI / test (pull_request) Successful in 8s
CI / notify-on-failure (pull_request) Successful in 1s
2026-06-12 08:26:42 +08:00
cfdaily 2c612baa04 fix(toolchain): synchronize fallback + merge 通知 2026-06-12 08:26:42 +08:00
pangtong-fujunshi 98d17292b0 Merge PR #37: §24 v4 compact检测 2026-06-11 16:06:19 +00:00
cfdaily fe541f6c89 fix(spawner): §24 v4 仲达review M1(双staticmethod) + S1(TC11)
CI / lint (pull_request) Successful in 7s
CI / test (pull_request) Successful in 9s
CI / notify-on-failure (pull_request) Successful in 0s
2026-06-12 00:04:10 +08:00
cfdaily ddc1c7285a fix(lint): remove unused timedelta import
CI / lint (pull_request) Successful in 7s
CI / test (pull_request) Successful in 10s
CI / notify-on-failure (pull_request) Successful in 0s
2026-06-12 00:00:18 +08:00
cfdaily 3f71f53e4a fix(spawner): §24 v4 修正注释缩进 + 仲达评审 M1 数据修正 + S2 证据补充
CI / lint (pull_request) Failing after 7s
CI / test (pull_request) Has been skipped
CI / notify-on-failure (pull_request) Successful in 1s
2026-06-11 23:58:29 +08:00
cfdaily 3c2c0f3175 fix(spawner): §24 v4 compact检测 - trajectory prompt.submitted 替换 gateway rotation
CI / lint (pull_request) Failing after 7s
CI / test (pull_request) Has been skipped
CI / notify-on-failure (pull_request) Successful in 0s
2026-06-11 23:57:09 +08:00
pangtong-fujunshi 95a8abca96 Merge PR #36: §24 compact detection via gateway log rotation events 2026-06-11 13:47:55 +00:00
cfdaily bcb8ced17a fix(spawner): address PR#36 review feedback (M1+M2+S1+S2)
CI / lint (pull_request) Successful in 8s
CI / test (pull_request) Successful in 9s
CI / notify-on-failure (pull_request) Successful in 0s
2026-06-11 21:40:09 +08:00
cfdaily caf750fad6 fix(spawner): §24 compact check must run when status=done (compact in progress)
CI / lint (pull_request) Successful in 7s
CI / test (pull_request) Successful in 9s
CI / notify-on-failure (pull_request) Successful in 0s
2026-06-11 21:18:33 +08:00
cfdaily 7918b12ff7 feat(spawner): §24 compact detection via gateway log rotation events 2026-06-11 21:18:33 +08:00
admin 3441f4325f Merge PR #35: §23 PR 全生命周期通知 2026-06-11 06:10:44 +00:00
17 changed files with 2652 additions and 79 deletions
+16
View File
@@ -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
+14 -7
View File
@@ -233,20 +233,27 @@ def _revive_session(agent_id: str) -> bool:
pass
```
### 4.5 O5: compact 扫描条件收紧
### 4.5 O5: compact 检测(§24 rotation-only v3
当前 compact 扫描在 status 非 idle/done/unknown/None 时都触发,范围过宽。
§24 设计文档:`docs/design/24-compact-detection-fix.md`
**改后**只在 status 为 running 或 compacting 相关时扫描:
**检测方法**读 gateway 日志尾部 2MB,按 sessionKey 过滤 `[compaction] rotated active transcript` 事件。
如果最近的 rotation 事件在 120s 窗口内 → 视为 compact 循环进行中(可能还在 post-compact retry)。
旧方法 `_check_recent_compaction_jsonl`(扫描 session jsonl 的 `type=compaction` 事件)保留作为 fallback。
```python
# 只在这些状态下检查 compact
if result["status"] in ("running",) and sf:
result["recent_compact"] = AgentSpawner._check_recent_compaction_jsonl(sf)
# §24 v3: compact 检测优先用 gateway 日志 rotation 事件
if result["status"] not in ("idle", "unknown", None):
session_key = f"agent:{agent_id}:main"
result["recent_compact"] = AgentSpawner._check_compact_in_progress_gateway(
session_key)
if not result["recent_compact"] and sf:
result["recent_compact"] = AgentSpawner._check_recent_compaction_jsonl(sf)
```
注:Gateway 的 sessions.json status 实际值主要是 `idle/running/timeout/failed`
`running` 时检查 compact 有意义(agent turn 执行中可能触发 compact
非空闲状态(`running`/`timeout`/`failed`)时检查 compact 有意义
其他状态不需要检查。
## 五、改动范围
+159 -12
View File
@@ -33,26 +33,173 @@
| 项 | 配置 |
|----|------|
| 地址 | `http://192.168.2.154:3000` |
| 版本 | v1.23.4 |
| 认证 | HTTP + token(待配置 |
| 权限 | cfdaily 用户;姜维持有 admin 权限(启用 Actions、分支保护等) |
| 版本 | v1.26.22026-06-11 从 v1.23.4 升级) |
| 认证 | HTTP + tokenadmin 账号(姜维持有 |
| 权限 | 姜维持有 admin 权限(启用 Actions、分支保护、org webhook 等) |
| 数据库 | SQLite3 |
| 部署方式 | Docker(NAS 群晖),数据卷 `/volume2/@docker/volumes/gitea-data/_data` |
### 2.2 CI/CDGitea Actions
| 项 | 配置 |
|----|------|
| Runner | Mac mini 裸机,act-runnerGo 二进制 |
| Runner | Mac mini 裸机,gitea-runner v1.0.8(通过 PM2 管理 `sanguo-act-runner` |
| 配置文件 | `.gitea/workflows/*.yml`,每个项目自管 |
| 语法 | 兼容 GitHub Actionsv1.23.4 已验证支持) |
| 触发 | push / PR / tag |
| 语法 | 兼容 GitHub Actionsv1.26.2 已验证支持 concurrency groups |
| 触发 | push / PR / tag / workflow_dispatch |
| v1.26 新增 | concurrency groups、re-run failed jobs、可配置 GITEA_TOKEN 权限 |
| 仍不支持 | `failure()``continue-on-error``timeout-minutes` |
### 2.3 部署目标
### 2.4 Gitea 基础设施 Setup 记录(2026-06-11 姜维)
> 以下为 Gitea 从 v1.23.4 升级到 v1.26.2 的完整操作记录,作为未来参考。
#### 2.4.1 升级 v1.23.4 → v1.26.2
**升级原因**v1.23.4 不支持 concurrency groups,导致双倍触发问题无根因解法。
**升级步骤**
1. 备份:`docker exec sanguo_gitea gitea dump -c /data/gitea/conf/app.ini -f /data/gitea/gitea-backup-pre-v126.zip`765MB
2. 拉取镜像:Mac 上 skopeo 下载 → python docker SDK 远程 load(群晖 Docker Hub 太慢)
3. 停止旧容器 + rename 保留回滚
4. 启动新容器(数据库自动迁移 Migration[312]→[326],含 concurrency #323
5. 验证:API + Web UI + 仓库数据 + 用户数据
**踩坑:群晖内核 3.10 + git 2.52 不兼容**
- 根因:git 2.52 使用 `getrandom(2)` syscall,群晖内核 3.10.108 不支持(3.17 才加入)
- 症状:`git push``unable to create temporary file: Function not implemented`
- 修复:entrypoint 脚本在容器启动时自动从本地缓存降级 git 到 2.45.4
- 持久化:`/data/entrypoint-wrapper.sh` + `/data/git-2.45.4-r0.apk` 在数据卷里,容器重建不丢失
- 群晖内核**无法通过 DSM 升级**,内核版本跟硬件型号绑定
**完整重建命令**
```bash
docker -H tcp://192.168.2.154:2375 run -d \
--name sanguo_gitea \
--restart=always \
-p 3000:3000 \
-p 2221:22 \
-v /volume2/@docker/volumes/gitea-data/_data:/data \
-e GITEA__database__DB_TYPE=sqlite3 \
-e GITEA__database__PATH=/data/gitea/gitea.db \
-e GITEA__server__ROOT_URL=http://192.168.2.154:3000/ \
--entrypoint /bin/sh \
gitea/gitea:1.26.2 \
-c '/data/entrypoint-wrapper.sh'
```
#### 2.4.2 act_runner 升级 v0.2.11 → v1.0.8
**升级原因**v0.2.11 的 multi-step job 执行有 bugSetup Python 和 Lint step 被跳过。
**升级步骤**
1. 下载 `gitea-runner-1.0.8-darwin-arm64`(从 gitea.com releases
2. `codesign --force --sign -` 重签(macOS Gatekeeper 会 SIGKILL 未签名的二进制)
3. 替换 `/Users/chufeng/bin/act_runner`
4. PM2 restart `sanguo-act-runner`
**注意**act_runner 通过 **PM2** 管理(`sanguo-act-runner`),不是 launchd。launchd plist 仅为备份。
**PM2 常用命令**
```bash
pm2 restart sanguo-act-runner # 重启
pm2 logs sanguo-act-runner # 查看日志
pm2 show sanguo-act-runner # 详情
```
#### 2.4.3 CI Workflow 配置
**三个 workflow 文件**
| 文件 | 触发 | concurrency | 说明 |
|------|------|-------------|------|
| `ci.yml` | `pull_request` | `group: ci-${{ gitea.ref }}, cancel-in-progress: true` | 同一 PR 新 push 自动取消旧 run |
| `deploy.yml` | `push to main` | `group: deploy-${{ gitea.ref }}, cancel-in-progress: false` | 部署排队不取消 |
| `e2e.yml` | `workflow_dispatch` | `group: e2e-${{ gitea.ref }}, cancel-in-progress: true` | 手动触发 |
**Branch Protectionmain 分支)**
- 禁止直接 push
- status check`CI / lint (pull_request)` 必须通过
- 至少 1 人 Review
**⚠️ 踩坑**v1.26 上报的 commit status context 格式变了:
- 旧格式:`lint`
- 新格式:`CI / lint (pull_request)`
- branch protection 必须用新格式匹配,否则 merge 报 "Not all required status checks successful"
#### 2.4.4 Org Webhook 配置
- **对象**Gitea 组织 `sanguo` webhook id=28
- **URL**`http://192.168.2.153:8083/webhook/gitea`
- **事件**16 个(push/issues/PR/PR review 等)
**⚠️ 踩坑**Gitea v1.26 的 PATCH hooks API,只传 `{"active": true}` 会把 events 重置为 `["push"]`。**必须每次 PATCH 都带上完整的 events 列表。**
**临时措施(已恢复)**2026-06-10 曾临时关闭 webhookCI 错误大爆炸期间),2026-06-11 已恢复。
#### 2.4.5 凭据管理
| 凭据 | 用途 | 持有者 |
|------|------|--------|
| Gitea admin:cf7561523 | 仓库管理、branch protection、org webhook | 姜维 |
| Gitea PAT (jiangwei-infra) | API 操作、git clone/push | 姜维 |
| Gitea PAT (cfdaily) | CI workflow 中的 git 操作 | CI secrets |
#### 2.4.6 备份与回滚
| 项目 | 路径 | 说明 |
|------|------|------|
| Gitea 数据库备份 | `/data/gitea/gitea-backup-pre-v126.zip` | 升级前 dump |
| 旧容器 | 已清理 | 升级验证通过后 `docker rm` |
| 变更记录 | `~/.openclaw/workspace-jiangwei/changes/gitea-emergency-2026-06-10.md` | 完整操作日志 |
| 环境 | 位置 | 说明 |
|------|------|------|
| Mac mini 本机 | `~/.sanguo_projects/<project>/` | 主力开发和运行环境 |
| NAS Docker | `192.168.2.154` | 部分服务(Gitea、回测等) |
#### 2.4.7 Gitea 迁移验证记录(2026-06-11 司马懿)
> 验证 Gitea 从 gitee 迁移完成后的状态。所有验证在 2026-06-11 完成。
**仓库迁移状态**
| 项目 | Gitea 仓库 | 开发目录 | 远程地址 | gitee 残留 |
|------|-----------|---------|---------|----------|
| sanguo_moziplus_v2 | `sanguo/sanguo_moziplus_v2` | `~/.openclaw/sanguo_projects/sanguo_moziplus_v2/` | `http://192.168.2.154:3000/sanguo/sanguo_moziplus_v2.git` | ✅ 无 |
| sanguo_quant_live | `sanguo/sanguo_quant_live` | `~/.openclaw/sanguo_projects/sanguo_quant_live/` | `http://192.168.2.154:3000/sanguo/sanguo_quant_live.git` | ✅ 无 |
| sanguo_vnpy | `sanguo/sanguo_vnpy` | `~/.openclaw/sanguo_projects/sanguo_vnpy/` | `http://192.168.2.154:3000/sanguo/sanguo_vnpy.git` | ✅ 无 |
**验证方法**:在 3 个开发目录分别执行 `git remote -v`,确认 origin 指向 gitea 且无 gitee remote。
**CI 管道验证**
| 验证项 | 结果 | 备注 |
|--------|------|------|
| PR #33 Lint 修复 CI 通过 | ✅ | flake8 全通过 |
| CD pipeline (deploy.yml) 合并 | ✅ | 含 CI + deploy + notify-deploy-failure 三个 job |
| Branch protection 生效 | ✅ | main 分支需 CI 通过 + 1 人 Review 才能合并 |
| Gitea squash merge 兼容 | ✅ | `merge_commit_sha` 在 squash merge 下仍等于 gitea.sha |
**工具链事件中枢验证**
| Webhook → Mail 流 | 验证结果 |
|-------------------|--------|
| PR opened → Review 请求 Mail | ✅ 司马懿收到 PR #30-#35 的 Review 请求 |
| PR review → 结果 Mail | ✅ 张飞/庞统收到 Review 结果通知 |
| Issue assigned → 指派 Mail | ✅ E2E 验证通过) |
| CI 失败评论 → 通知 Mail | ✅ (E2E 验证通过) |
| PR synchronize → reviewer 重审 Mail | ✅ 新增(§23 |
| Review COMMENTED → PR 作者通知 | ✅ 新增(§23) |
**Agent Gitea 凭据**(各 Agent 自行持有 PAT):
| Agent | Gitea 用户名 | PAT 用途 |
|-------|-------------|--------:|
| simayi-challenger | simayi-challenger | PR Review 提交 |
| pangtong-fujunshi | pangtong-fujunshi | PR 创建/合并、代码 push |
| jiangwei-infra | jiangwei-infra | 基础设施配置(admin 级操作) |
---
## §3. 分支策略
@@ -157,7 +304,7 @@ Open → In Progress → Review → Closed
每个项目在 `.gitea/workflows/ci.yml` 自定义具体步骤,但遵循统一骨架。
> **注**Gitea Actions v1.23.4 不支持 `paths` 过滤触发条件。通过路径判断放在 job 级别的 `if` 条件中,使用确定支持的语法。(M4 修订)
> **注**Gitea Actions v1.26.2 不支持 `paths` 过滤触发条件。通过路径判断放在 job 级别的 `if` 条件中,使用确定支持的语法。(M4 修订)
```yaml
name: CI
@@ -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"]
+25
View File
@@ -119,3 +119,28 @@
- 姜维第一次分析给出了错误根因(Gitea 双 notifier),第二次深入调查后自我纠正
- 庞统把姜维的第一次结论当事实汇报给主公,没有标注"这是姜维的调查结论,尚未独立验证"
- **改进**:SOUL.md 新增规则——推测 vs 事实显式标注、引用他人结论时标注来源、结论被推翻时及时更正
---
## PR #38 新增场景(synchronize fallback + merge 通知)
> 2026-06-12 新增,对应 PR #38 的设计变更
### 步骤 9synchronize 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`
### 步骤 10merge 通知 ✅
- 操作: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)不触发通知
+71 -9
View File
@@ -28,6 +28,7 @@
|---|---|---|---|---|
| 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) | 低 | 非关键路径 |
@@ -64,17 +65,42 @@ async def _handle_pull_request(payload: Dict[str, Any]) -> None:
新增 `_handle_pr_synchronize`
1. 从 payload 取 PR 信息(number、title、author、head sha
2. 查询最近一次 reviewGitea API `GET /repos/{owner}/{repo}/pulls/{number}/reviews`)取 reviewer
3. 如果没有 review 记录(首次 push 后 reviewer 还没 review),跳过(opened 事件已经通知过了
3. 如果没有 review 记录(`_fetch_latest_reviewer()` 返回 None),fallback 到默认 reviewer `simayi-challenger`,而非跳过通知(PR #38 改动:确保无 review 历史时也能通知默认审查者
4. 渲染 `review_updated.md` 模板,发送 Mail 给 reviewer
**关键设计决策**
- 不用 `requested_reviewers`(可能为空),用最近 review 的提交者
- 只在有 review 历史时才通知(避免 opened + synchronize 重复通知)
- review 历史时 fallback 到默认 reviewer `simayi-challenger`PR #38避免 opened + synchronize 间隔较短时 reviewer 未收到任何通知)
- Mail from 用 `system`
### ~~Handler 2PR 合并通知~~ — 已删除
### Handler 2`_handle_pr_closed`PR 合并通知)— PR #38 恢复
> 司马懿 review 指出与 §22 CD 成功通知重叠。CD 成功通知已隐含合并信息,无需单独发 merged 通知。
**触发**`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 3review COMMENTED 处理
@@ -101,7 +127,7 @@ async def _handle_pull_request(payload: Dict[str, Any]) -> None:
| 模板文件 | 变量 | 说明 |
|---|---|---|
| `review_updated.md` | repo, pr_number, pr_title, pr_author, branch, new_sha, reviewer | PR 有新 commit,请重新 review |
| ~~`pr_merged.md`~~ | ~~已删除~~ | ~~—~~ |
| `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` 无需改动
@@ -110,6 +136,41 @@ async def _handle_pull_request(payload: Dict[str, Any]) -> None:
所以 **`_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
### 不做的事
| 项 | 理由 |
@@ -122,9 +183,9 @@ async def _handle_pull_request(payload: Dict[str, Any]) -> None:
| 文件 | 改动 |
|---|---|
| `src/api/toolchain_routes.py` | 修改 `_handle_pull_request`(扩展 action 分发)+ 新增 `_handle_pr_synchronize` + 修改 `_handle_pull_request_review`(支持 COMMENTED |
| `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/pr_merged.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` | 本文档 |
@@ -134,8 +195,9 @@ async def _handle_pull_request(payload: Dict[str, Any]) -> None:
`sanguo/moziplus-v2` 测试仓库上 E2E 验证:
1. **synchronize**:创建 PR → review 驳回 → push 新 commit → 验证 reviewer 收到"请重新 review" Mail
~~2. merged~~:已删除
3. **COMMENTED**review 提交纯评论 → 验证 PR 作者收到通知
2. **synchronize fallback**PR #38):创建 PR → push commit(无 review 历史)→ 验证默认 reviewer (`simayi-challenger`) 收到通知
3. **merge 通知**PR #38 恢复):PR merge → 验证 PR 作者收到合并通知 Mail
4. **COMMENTED**:review 提交纯评论 → 验证 PR 作者收到通知
## 风险评估
+304
View File
@@ -0,0 +1,304 @@
# §24 — Compact 检测方案修正
> 状态:**v5 已实现**gateway log + jsonl 配对)
> 作者:庞统
> 日期:2026-06-11v4),2026-06-13v5
> 框架:基于 §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:02pangtong main session 正在做 compaction13:59:26 开始,14:06:00 结束,耗时 ~6.5 分钟),但 spawner Phase 2 检查时 `compact=False`,仍然 spawn 了新进程处理 Mail,导致两个 agent turn 撞车。
### 1.2 根因
当前 compact 检测方法 `_check_recent_compaction_jsonl` 扫描 session jsonl,查找 `type == "compaction"` 事件。这是 compact **完成后**才写入的摘要记录,compact **进行中**时不存在 → 漏检。
同时 Gateway 触发 compact 时先把 session 标为 `done`,所以 `status=running + lock_pid_alive` 检查也无效。14:02:11 实际状态:`status=done lock_pid_alive=False compact=False`——三个检查全部漏过。
## 2. 方案 ATrajectory 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 个 turn34 有 prompt.submitted(正常),5 无
- 4 个 tool loop 子迭代(compactionCount=0, <1s, gateway 无 compact 事件)
- 1 个 context-overflow precheck 触发 compact
- simayi 24 个 turn23 有,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 → 清 flagcompact 结束)
- 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 不存在 | 返回 Falsefallback | 安全 |
| 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 Optimizationcompact_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 logprecheck 开始标记)+ jsonlcompaction 结束标记)配对 → 仲达评审通过后实现,PR #48 Review 驳回 M1/M2 修正后合并
> ⚠️ **v4 已 deprecated**。v4 的 trajectory prompt.submitted 方案未实施,最终实施的是 v5。v4 的分析(skipPromptSubmission 路径、实测数据)仍有参考价值。
+802
View File
@@ -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)
# 排除自己 @自己(假设 A1Gitea 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 PATtoken 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. 排除 senderIssue 创建者)
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 还是 IssueGitea 中 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-challengerreview_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 UUIDL1 去重拦不住。
**解决方案**
- `_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 封 Mailreview_request |
| E2E-5 | Review body @mention | Review 驳回 body 含 `@pangtong-fujunshi 设计有问题` | 庞统收到 @mention MailPR 作者收到 review_result(不重复) |
| E2E-6 | Review body @PR作者 去重 | Review body 含 `@zhangfei-dev 请修改` | 张飞只收 1 封 Mailreview_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 假设文档化 |
+3
View File
@@ -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"
+169
View File
@@ -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)
# 排除自己 @自己(假设 A1Gitea 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)并按需回复。"
+394 -45
View File
@@ -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
@@ -253,19 +260,88 @@ 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 → 通知 reviewersynchronize → 通知 reviewer 重新 review"""
"""处理 pull_request 事件:opened → 通知 reviewerclosed → merge 通知"""
action = payload.get("action", "")
if action == "opened":
await _handle_pr_opened(payload)
elif action == "synchronize":
await _handle_pr_synchronize(payload)
# 其他 action 忽略
elif action == "closed":
await _handle_pr_closed(payload)
async def _handle_pr_opened(payload: Dict[str, Any]) -> None:
@@ -303,6 +379,52 @@ async def _handle_pr_opened(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-challengerreview_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 作者。
@@ -365,6 +487,10 @@ async def _handle_pull_request_review(payload: Dict[str, Any]) -> None:
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": "驳回 ✗"}
@@ -384,6 +510,9 @@ 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 的提交者。
@@ -434,9 +563,9 @@ async def _handle_pr_synchronize(payload: Dict[str, Any]) -> None:
# 查询最近 review 的提交者
reviewer = await _fetch_latest_reviewer(repo, pr_number)
if not reviewer:
# 没有 review 历史,跳过(opened 事件已经通知过)
logger.debug("No review history for PR #%s, skipping synchronize notification", pr_number)
return
# 没有已有 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,
@@ -451,6 +580,169 @@ async def _handle_pr_synchronize(payload: Dict[str, Any]) -> None:
_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+部署失败 → 通知运维。"""
action = payload.get("action", "")
@@ -495,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 hashGitea 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 hashGitea 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 @mentionopened 时检查)
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 还是 IssueGitea 中 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,
)
# ---------------------------------------------------------------------------
@@ -562,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,
@@ -620,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}")
+355 -6
View File
@@ -1297,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:
@@ -1413,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
覆盖compacttimeouthook blocksession 结束等所有非正常状态
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:
+2
View File
@@ -23,6 +23,8 @@ _TEMPLATE_MAP: Dict[str, str] = {
"deploy_failure": "deploy_failure.md",
"review_updated": "review_updated.md",
"review_comment": "review_comment.md",
"review_merged": "review_merged.md",
"mention": "mention.md",
}
# 模板缓存
+16
View File
@@ -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}
完成后按指引操作。
+8
View File
@@ -0,0 +1,8 @@
## PR 已合并 ✅
**仓库**: {repo}
**PR #{pr_number}**: {pr_title}
**作者**: @{pr_author}
**合并者**: @{merged_by}
PR 已成功合并到主分支。
+1
View File
@@ -7,3 +7,4 @@ PR: http://192.168.2.154:3000/{repo}/pulls/{pr_number}
{review_body}
如需修改请更新 PR 后重新请求 Review。
如 Review 已通过,请及时合并此 PR。
+184
View File
@@ -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
+129
View File
@@ -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):
"""'请帮忙' → collaborateNOT 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"