Compare commits

..

33 Commits

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