Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| a953fc0bc7 | |||
| 7f17ee69d7 | |||
| f1e513cba2 | |||
| 627982db09 | |||
| 9ec601d747 | |||
| cc5c7f5ad1 | |||
| d6cb854f68 | |||
| 1f373d5cb5 | |||
| a8c9d25857 | |||
| 660ac4b659 | |||
| 91685ebfdd | |||
| 65910f5417 | |||
| 17b87290c8 | |||
| bd5735f970 | |||
| 05f9112fab | |||
| b926b35703 | |||
| 8df1d4a83c | |||
| aad5a6b317 | |||
| ad34750075 | |||
| cd7e24cd3c | |||
| 0521b7b6f0 |
+23
-3
@@ -62,12 +62,30 @@ jobs:
|
||||
(echo '=== RETRY WITH VERBOSE ===' && \
|
||||
PYTHONPATH=$(pwd) /tmp/ci-venv-test/bin/pytest tests/ -m "not e2e" -x -v 2>&1 | tail -30)
|
||||
|
||||
# ── Job 3: CI 失败通知 ───────────────────────────────
|
||||
# ── Job 3: Frontend Build ───────────────────────────
|
||||
frontend:
|
||||
runs-on: macos-arm64
|
||||
needs: lint
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: 20
|
||||
|
||||
- name: Install & Build
|
||||
run: |
|
||||
cd src/frontend
|
||||
npm ci || npm install
|
||||
npm run build
|
||||
|
||||
# ── Job 4: CI 失败通知 ───────────────────────────────
|
||||
# 使用 needs.<job>.result 直接判断,不查询 commit status API
|
||||
# 根因:notify 自身的 pending status 会污染 commit status 查询结果(竞态条件)
|
||||
notify-on-failure:
|
||||
runs-on: macos-arm64
|
||||
needs: [lint, test]
|
||||
needs: [lint, test, frontend]
|
||||
if: always()
|
||||
steps:
|
||||
- name: Check results and notify
|
||||
@@ -75,12 +93,13 @@ jobs:
|
||||
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
|
||||
LINT_RESULT: ${{ needs.lint.result }}
|
||||
TEST_RESULT: ${{ needs.test.result }}
|
||||
FRONTEND_RESULT: ${{ needs.frontend.result }}
|
||||
run: |
|
||||
echo "Lint result: $LINT_RESULT"
|
||||
echo "Test result: $TEST_RESULT"
|
||||
|
||||
# 只有 lint 或 test 明确失败时才发通知
|
||||
if [ "$LINT_RESULT" = "failure" ] || [ "$TEST_RESULT" = "failure" ]; then
|
||||
if [ "$LINT_RESULT" = "failure" ] || [ "$TEST_RESULT" = "failure" ] || [ "$FRONTEND_RESULT" = "failure" ]; then
|
||||
echo "CI has failures, sending notification..."
|
||||
|
||||
# 如果是 PR 事件,写评论通知
|
||||
@@ -90,6 +109,7 @@ jobs:
|
||||
FAILED_JOBS=""
|
||||
[ "$LINT_RESULT" = "failure" ] && FAILED_JOBS="${FAILED_JOBS}lint "
|
||||
[ "$TEST_RESULT" = "failure" ] && FAILED_JOBS="${FAILED_JOBS}test "
|
||||
[ "$FRONTEND_RESULT" = "failure" ] && FAILED_JOBS="${FAILED_JOBS}frontend "
|
||||
|
||||
curl -sf -X POST \
|
||||
-H "Authorization: token $GITEA_TOKEN" \
|
||||
|
||||
@@ -110,15 +110,7 @@ jobs:
|
||||
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 "")
|
||||
python3 -c "import json,sys; sha='$COMMIT_SHA'; matches=[pr['user']['login'] for pr in json.load(sys.stdin) if (pr.get('merge_commit_sha','') or '').startswith(sha) or sha.startswith(pr.get('merge_commit_sha','') or '')]; print(matches[0] if matches else '')" 2>/dev/null || echo "")
|
||||
|
||||
# 确定通知对象
|
||||
if [ -n "$PR_AUTHOR" ]; then
|
||||
|
||||
@@ -1,10 +1,11 @@
|
||||
---
|
||||
title: "TaskTypeRegistry + Handler 架构重构"
|
||||
created: 2026-06-10
|
||||
version: v3.0
|
||||
version: v3.1
|
||||
---
|
||||
|
||||
> 状态: ✅ 已完成(Step 1-5 全部合并,394 passed)
|
||||
> v3.1 新增 §18:Mail Handler Verify/Prompt 强化(2026-06-16,进行中)
|
||||
|
||||
# §1 现状分析(v3.0 更新说明:§1-§13 保留原样,新增 §14-§18,更新 §3/§5/§7)
|
||||
|
||||
@@ -585,6 +586,18 @@ class PromptComposer:
|
||||
| 50-59 | 硬约束 | 安全红线、禁止行为 |
|
||||
| 60-69 | 扩展段 | 保留给未来使用 |
|
||||
|
||||
## 共性 Section(三 handler 共享)
|
||||
|
||||
以下三个 Section 在 `prompt_composer.py` 中统一定义,被 Task/Mail/Toolchain 三个 handler 共同注入:
|
||||
|
||||
| Section | priority | 用途 |
|
||||
|---------|----------|------|
|
||||
| `GiteaConventionSection` | 55 | Gitea Issue/PR 标题规范、分支命名、提交格式 |
|
||||
| `DeliveryChecklistSection` | 55 | 交付前检查清单(产出格式、验证项、必读文档) |
|
||||
| `WikiGuideSection` | 60 | Wiki 知识库检索指引(检索路径、优先级、知识缺口记录) |
|
||||
|
||||
设计意图:将跨 handler 的共性约束从各 handler 的 ConstraintsSection 中抽离,避免重复维护。
|
||||
|
||||
---
|
||||
|
||||
# §13 三个 Handler 的 Section 注册
|
||||
@@ -601,6 +614,9 @@ def get_sections(self) -> list[PromptSection]:
|
||||
RoleSkillSection(priority=30), # BootstrapBuilder 段 3(Skill 全文)
|
||||
TaskApiSection(priority=40), # API 操作指令,success_status="review"
|
||||
TaskConstraintsSection(priority=50), # 硬约束
|
||||
GiteaConventionSection(priority=55), # Gitea 协作规范(共性)
|
||||
WikiGuideSection(priority=60), # Wiki 知识库检索指引(共性)
|
||||
DeliveryChecklistSection(priority=55), # 交付检查清单(共性)
|
||||
]
|
||||
```
|
||||
|
||||
@@ -611,6 +627,9 @@ def get_sections(self) -> list[PromptSection]:
|
||||
| RoleSkillSection | BootstrapBuilder 段 3 | 个性:只有 task 读 Skill 全文 |
|
||||
| TaskApiSection | spawner `_build_api_section` | **共性基础 + 个性参数**(success_status) |
|
||||
| TaskConstraintsSection | BootstrapBuilder 段 4 | 个性:每种 task 约束不同 |
|
||||
| GiteaConventionSection | prompt_composer.py | **共性**:Gitea Issue/PR 规范 |
|
||||
| WikiGuideSection | prompt_composer.py | **共性**:Wiki 检索指引 |
|
||||
| DeliveryChecklistSection | prompt_composer.py | **共性**:交付前检查清单 |
|
||||
|
||||
## MailHandler sections
|
||||
|
||||
@@ -620,6 +639,9 @@ def get_sections(self) -> list[PromptSection]:
|
||||
MailContextSection(priority=10), # from/to/title/text,区分 inform/request
|
||||
MailApiSection(priority=40), # API 操作指令,success_status="done"
|
||||
MailConstraintsSection(priority=50), # 硬约束(禁止状态转换命令等)
|
||||
GiteaConventionSection(priority=55), # Gitea 协作规范(共性)
|
||||
WikiGuideSection(priority=60), # Wiki 知识库检索指引(共性)
|
||||
DeliveryChecklistSection(priority=55), # 交付检查清单(共性)
|
||||
]
|
||||
```
|
||||
|
||||
@@ -628,6 +650,9 @@ def get_sections(self) -> list[PromptSection]:
|
||||
| MailContextSection | MAIL_INFORM_TEMPLATE / MAIL_REQUEST_TEMPLATE | 个性:邮件格式 |
|
||||
| MailApiSection | spawner `_build_api_section` 变体 | **共性基础 + 个性参数**(success_status="done",含 Mail API 指令) |
|
||||
| MailConstraintsSection | 模板中的 ⚠️ 约束 | 个性 |
|
||||
| GiteaConventionSection | prompt_composer.py | **共性**:Gitea Issue/PR 规范 |
|
||||
| WikiGuideSection | prompt_composer.py | **共性**:Wiki 检索指引 |
|
||||
| DeliveryChecklistSection | prompt_composer.py | **共性**:交付前检查清单 |
|
||||
|
||||
## ToolchainHandler sections
|
||||
|
||||
@@ -637,6 +662,9 @@ def get_sections(self) -> list[PromptSection]:
|
||||
ToolchainContextSection(priority=10), # 事件类型 + 事件详情
|
||||
ToolchainApiSection(priority=40), # API 操作指令,success_status="done"
|
||||
ToolchainConstraintsSection(priority=50), # 硬约束
|
||||
GiteaConventionSection(priority=55), # Gitea 协作规范(共性)
|
||||
WikiGuideSection(priority=60), # Wiki 知识库检索指引(共性)
|
||||
DeliveryChecklistSection(priority=55), # 交付检查清单(共性)
|
||||
]
|
||||
```
|
||||
|
||||
@@ -645,6 +673,9 @@ def get_sections(self) -> list[PromptSection]:
|
||||
| ToolchainContextSection | toolchain_templates.py + md 文件 | 个性:事件格式 |
|
||||
| ToolchainApiSection | spawner `_build_api_section` 变体 | **共性基础 + 个性参数** |
|
||||
| ToolchainConstraintsSection | 新增 | 个性 |
|
||||
| GiteaConventionSection | prompt_composer.py | **共性**:Gitea Issue/PR 规范 |
|
||||
| WikiGuideSection | prompt_composer.py | **共性**:Wiki 检索指引 |
|
||||
| DeliveryChecklistSection | prompt_composer.py | **共性**:交付前检查清单 |
|
||||
|
||||
## Section 复用分析
|
||||
|
||||
@@ -655,6 +686,9 @@ def get_sections(self) -> list[PromptSection]:
|
||||
| *ConstraintsSection | ✅ | ✅ | ✅ | ❌ 约束内容不同,各自实现 |
|
||||
| PriorOutputsSection | ✅ | ❌ | ❌ | 仅 task |
|
||||
| RoleSkillSection | ✅ | ❌ | ❌ | 仅 task |
|
||||
| GiteaConventionSection | ✅ | ✅ | ✅ | **共性**:三 handler 共享,prompt_composer.py 定义 |
|
||||
| WikiGuideSection | ✅ | ✅ | ✅ | **共性**:三 handler 共享,prompt_composer.py 定义 |
|
||||
| DeliveryChecklistSection | ✅ | ✅ | ✅ | **共性**:三 handler 共享,prompt_composer.py 定义 |
|
||||
|
||||
**结论**:ApiSection 可以抽一个 BaseApiSection(curl 模板 + success_status 参数),其余 section 各自实现。
|
||||
|
||||
@@ -667,9 +701,9 @@ src/daemon/
|
||||
├── task_type_registry.py # §3 + §4:Protocol + Registry
|
||||
├── prompt_composer.py # §12 PromptSection + PromptContext + PromptComposer
|
||||
├── base_task_handler.py # §16 BaseTaskHandler 基类
|
||||
├── task_handler.py # §13 TaskHandler(继承 BaseTaskHandler)+ 5 sections
|
||||
├── mail_handler.py # §13 MailHandler(继承 BaseTaskHandler)+ 3 sections
|
||||
├── toolchain_handler.py # §13 ToolchainHandler(继承 BaseTaskHandler)+ 3 sections
|
||||
├── task_handler.py # §13 TaskHandler(继承 BaseTaskHandler)+ 8 sections
|
||||
├── mail_handler.py # §13 MailHandler(继承 BaseTaskHandler)+ 6 sections
|
||||
├── toolchain_handler.py # §13 ToolchainHandler(继承 BaseTaskHandler)+ 6 sections
|
||||
├── dispatcher.py # §6 改动
|
||||
├── spawner.py # §6 改动
|
||||
├── ticker.py # §6 改动
|
||||
@@ -952,7 +986,219 @@ handler.post_complete(task_id, agent_id, outcome, db_path)
|
||||
|
||||
---
|
||||
|
||||
## §14. Mail 失败通知机制
|
||||
## §18. Mail Handler Verify/Prompt 强化
|
||||
|
||||
> 日期:2026-06-16 | 作者:庞统 | 状态:方向 1-5 全部已确认
|
||||
|
||||
## 18.1 问题背景
|
||||
|
||||
### 触发事件
|
||||
|
||||
2026-06-12 daemon 重启后,_mail DB 中积压的 E2E 测试遗留邮件(5/18~6/1 创建,type=request,performative="text")被 dispatch 给 agent。agent 正常处理并输出文本(如"已阅,无需处理"),但 `verify_completion` 判定 no_reply → 标 failed → 触发 `notify_mail_failed` → 产生 38 封 `[投递失败]` 通知邮件,每 ~2.5 分钟一轮,持续 10 轮。
|
||||
|
||||
### 根因链
|
||||
|
||||
```
|
||||
E2E 测试脚本 bug(type="text")
|
||||
→ mail_routes.py 不校验 type 值,直接透传
|
||||
→ performative="text" ≠ "inform" → 走 _check_reply
|
||||
→ _check_reply 查 in_reply_to task,agent 没用 Mail API 回复
|
||||
→ verify 失败 → on_failure 标 failed
|
||||
→ notify_mail_failed 发 [投递失败] 通知
|
||||
→ 通知本身也是 task,循环触发
|
||||
```
|
||||
|
||||
### 三种 handler verify 对比
|
||||
|
||||
| 维度 | TaskHandler | MailHandler | ToolchainHandler |
|
||||
|------|------------|-------------|------------------|
|
||||
| verify 信号 | output / comment(≥50字) / terminal_status(三信号) | in_reply_to task(单信号) | action_report / output / comment(≥20字)(三层 fallback) |
|
||||
| inform 处理 | N/A | 直接通过(不检查执行证据) | N/A |
|
||||
| verify 失败后 | **留 working**(覆盖 post_complete) | **标 failed**(base post_complete + mail on_failure) | 标 failed(base post_complete + tc on_failure) |
|
||||
| agent 输出持久化 | 靠 agent 主动 POST output/comment | **无**(agent 输出只在内存) | 靠 agent 主动 POST action_report |
|
||||
|
||||
**关键发现**:
|
||||
1. MailHandler 继承 BaseTaskHandler,未覆盖 `post_complete` → verify 失败时走 base 的 `on_failure` → 标 failed
|
||||
2. TaskHandler 覆盖了 `post_complete` → verify 失败时留 working,让 ticker 重试
|
||||
3. MailHandler 的 verify 只有 `in_reply_to` 一条路径,没有 fallback
|
||||
4. inform 类型直接通过(`VerifyResult(True)`),不检查任何执行证据——inform 是"无需回复"不是"无需检查"
|
||||
5. E2E 测试用 `TestClient(app)` 写生产 `_mail DB`,且测试脚本用了非标准 `type="text"`
|
||||
|
||||
## 18.2 修复方向
|
||||
|
||||
### 方向 1:mail verify 对齐 toolchain 模式(✅ 已确认)
|
||||
|
||||
**问题**:mail verify 只有 in_reply_to task 一条路径。task/toolchain 都有多层 fallback(outputs / comments)。
|
||||
|
||||
**方案**:mail 对齐 toolchain 模式——prompt 加 action report 要求,verify 优先查 action_report → fallback outputs → fallback comments。in_reply_to 回复邮件从唯一信号降为 request 类型的第 4 优先级信号。
|
||||
|
||||
#### prompt 强化(MailApiSection)
|
||||
|
||||
参照 ToolchainApiSection,在 mail prompt 中追加 action report 要求:
|
||||
```
|
||||
### 完成后必须提交 action report
|
||||
执行完邮件处理后,必须提交 action report:
|
||||
curl -s -X POST "http://localhost:8083/api/projects/_mail/tasks/{task_id}/comments" \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"author": "{agent_id}", "comment_type": "action_report", "body": "处理结果摘要"}'
|
||||
|
||||
⚠️ 不提交 action report 的任务会被标记为 failed。
|
||||
```
|
||||
|
||||
#### verify 改造(MailHandler.verify_completion)
|
||||
|
||||
```python
|
||||
def verify_completion(self, task_id, db_path) -> VerifyResult:
|
||||
performative = self._parse_performative(task_id, db_path)
|
||||
|
||||
# 1. 优先检查 action_report comment(所有类型通用)
|
||||
if self._has_action_report(task_id, db_path):
|
||||
return VerifyResult(True, "has_action_report", "action_report found")
|
||||
|
||||
# 2. fallback: outputs
|
||||
if self._has_outputs(task_id, db_path):
|
||||
return VerifyResult(True, "has_output", f"output_count={count}")
|
||||
|
||||
# 3. fallback: 有实质内容的 comment(≥20字,非 system)
|
||||
if self._has_comment(task_id, db_path):
|
||||
return VerifyResult(True, "has_comment", f"comment_count={count}")
|
||||
|
||||
# 4. request 特有:检查 in_reply_to 回复邮件
|
||||
if performative == "request":
|
||||
if self._check_reply(task_id, db_path):
|
||||
return VerifyResult(True, "has_reply", "in_reply_to found")
|
||||
|
||||
return VerifyResult(False, "no_action",
|
||||
"no action_report, no output, no comment, no reply")
|
||||
```
|
||||
|
||||
注意:action_report 提交到 moziplus DB(comments 表),不是 Gitea。Gitea comment 是跨 agent 协作用的,不是 verify 检查的依据。
|
||||
|
||||
### 方向 2:prompt 约束强化(✅ 已确认)
|
||||
|
||||
**问题**:当前 mail prompt 只给了 curl 示例,没有硬约束要求 agent 必须输出处理结果。agent 判断"已阅"后直接跳过,不创建 in_reply_to task。
|
||||
|
||||
**方案**:mail request/inform prompt 加 JSON 输出约束(参考 toolchain 的 Red Flags 模式)。
|
||||
|
||||
#### MailContextSection 强化
|
||||
|
||||
**request 类型**追加:
|
||||
```
|
||||
### 输出要求
|
||||
- 你的回复必须包含对邮件的实际处理结果
|
||||
- 如果是第一次收到:正常处理,输出处理结果
|
||||
- 如果是重复邮件(你之前处理过相同 ID 的邮件):输出"此前已处理" + 之前的处理结果摘要
|
||||
- ⚠️ "已阅""无需处理"不是有效处理结果
|
||||
```
|
||||
|
||||
**inform 类型**追加:
|
||||
```
|
||||
### 输出要求
|
||||
- 你的回复必须确认已处理(读取/执行/记录),不能只说"已阅"
|
||||
- 如果是重复邮件:输出"此前已处理" + 处理结果摘要
|
||||
- ⚠️ "已阅"不是有效输出
|
||||
```
|
||||
|
||||
**MailConstraintsSection** 追加 Red Flags:
|
||||
```
|
||||
| Agent 想法 | Red Flag 驳回 |
|
||||
|------------|--------------|
|
||||
| "已阅即可" | ❌ 错!必须输出处理结果或确认执行 |
|
||||
| "重复邮件忽略" | ❌ 错!输出"此前已处理" + 结果摘要 |
|
||||
| "无需回复" | ❌ 错!request 必须回复,inform 必须确认处理 |
|
||||
```
|
||||
|
||||
### 方向 3:inform 也要检查执行证据(✅ 已确认)
|
||||
|
||||
**问题**:当前 inform verify 直接返回 `VerifyResult(True)`,不检查任何执行证据。inform 是"无需回复"不是"无需检查"。
|
||||
|
||||
**方案**:inform verify 改为检查 agent 是否有实质输出(comment/output),和 request 走不同的验证路径但都需要验证。
|
||||
|
||||
**改动文件**:`src/daemon/mail_handler.py` `verify_completion` 方法
|
||||
|
||||
### 方向 4:verify 失败保持 working(✅ 已确认)
|
||||
|
||||
**问题**:MailHandler 继承 BaseTaskHandler,verify 失败时走 base 的 `on_failure` → 标 failed。而 TaskHandler 覆盖了 `post_complete`,verify 失败时留 working。
|
||||
|
||||
**原始设计意图**(§2 设计文档):"不通过 → 留 working,ticker 重查(最多 3 次,然后标 failed)"。
|
||||
|
||||
**方案**:MailHandler 覆盖 `post_complete`,verify 失败时不标 failed,保持 working。ticker 的 `_check_timeouts` 超时兜底:
|
||||
- `check_completion` 通过(有回复)→ done
|
||||
- `check_completion` 不通过 → 超时后标 failed
|
||||
- Runaway Guard(§15 dispatch_count ≥ 10)兜底防止无限循环
|
||||
|
||||
**改动文件**:`src/daemon/mail_handler.py`,新增 `post_complete` 覆盖
|
||||
|
||||
### 方向 5:type 校验 + E2E 修复 + DB 清理(✅ 已确认)
|
||||
|
||||
#### 5.1 mail_routes.py type 校验
|
||||
|
||||
**问题**:`mail_type = body.get("type")` 直接透传,传什么存什么。`"text"` 不是标准值。
|
||||
|
||||
**方案**:创建时校验 type 只允许 `inform` / `request`,非法值默认 `request`。
|
||||
|
||||
```python
|
||||
mail_type = body.get("type")
|
||||
if mail_type is None:
|
||||
mail_type = "inform" if in_reply_to else "request"
|
||||
elif mail_type not in ("inform", "request"):
|
||||
# 非标准值,校正为默认值
|
||||
mail_type = "inform" if in_reply_to else "request"
|
||||
```
|
||||
|
||||
**改动文件**:`src/api/mail_routes.py`
|
||||
|
||||
#### 5.2 _parse_performative 容错
|
||||
|
||||
**问题**:`meta.get("performative", meta.get("type", "request"))` 当 performative="text" 时返回 "text",不等于 "inform" → 走 _check_reply。
|
||||
|
||||
**方案**:只认 `inform` 和 `request` 两个值,其他一律当 `request`。
|
||||
|
||||
```python
|
||||
def _parse_performative(self, task_id, db_path) -> str:
|
||||
raw = meta.get("performative", meta.get("type", "request"))
|
||||
if raw == "inform":
|
||||
return "inform"
|
||||
return "request" # 非标准值一律当 request
|
||||
```
|
||||
|
||||
**改动文件**:`src/daemon/mail_handler.py` `_parse_performative` 方法
|
||||
|
||||
#### 5.3 E2E 测试修复
|
||||
|
||||
**问题**:`tests/e2e/test_e2e_v27.py` 用 `type="text"` 创建测试邮件,且用 `TestClient(app)` 写生产 `_mail DB`。
|
||||
|
||||
**修复**:
|
||||
1. `type="text"` 全部改为 `type="inform"` 或 `type="request"`
|
||||
2. E2E 测试跑完后清理测试邮件(`mail_ids` 列表中记录的 task)
|
||||
|
||||
**改动文件**:`tests/e2e/test_e2e_v27.py`
|
||||
|
||||
#### 5.4 生产 DB 清理
|
||||
|
||||
**问题**:生产 `_mail DB` 中残留大量 E2E 测试邮件(5/18~6/3 创建的"筛选测试""详情测试""已读测试""任务分配"等)。
|
||||
|
||||
**方案**:手动清理这些测试残留(一次性操作,不需要代码改动)。
|
||||
|
||||
## 18.3 影响范围
|
||||
|
||||
| 文件 | 改动类型 | 影响面 |
|
||||
|------|---------|--------|
|
||||
| `src/daemon/mail_handler.py` | verify + post_complete + prompt section | MailHandler 核心逻辑 |
|
||||
| `src/api/mail_routes.py` | type 校验 | Mail API 创建入口 |
|
||||
| `tests/e2e/test_e2e_v27.py` | type 值修正 + 清理 | E2E 测试 |
|
||||
| 生产 `_mail DB` | 清理测试残留 | 一次性操作 |
|
||||
|
||||
## 18.4 验证计划
|
||||
|
||||
1. 单元测试:mail_handler verify/prompt 变更
|
||||
2. 集成测试:mail dispatch → verify → done/working 全链路
|
||||
3. 回归测试:`pytest -m "not e2e"` 全量
|
||||
4. 手工验证:创建 inform/request 邮件,确认 verify 行为正确
|
||||
|
||||
---
|
||||
|
||||
# §14. Mail 失败通知机制
|
||||
|
||||
### 20.1 背景
|
||||
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
# §15 Runaway Guard — Per-Task Dispatch 上限
|
||||
|
||||
> 设计文档 v1.0 | 2026-06-16
|
||||
|
||||
## 问题
|
||||
|
||||
mail/toolchain task 走 handler auto-working(跳过 claim 阶段),不受 claim_timeout 的 3 次重试兜底保护。如果一个 auto-working task 反复 spawn 但永远到不了 done/failed,会无限循环消耗资源。
|
||||
|
||||
### 实际案例
|
||||
|
||||
2026-06-15 mention 重复投递事件:`spawn_full_agent` 在 `use_main_session=True` 时返回 `None`,ticker `_process_mentions` 误判为失败,每次 tick(30s)都重试。同一 mention 投递了 4 次,直到 retry_count 达到 mention_queue 的 5 次上限才停止。
|
||||
|
||||
直接根因已由 PR #80 修复,但如果类似 bug 再次出现,当前没有任何机制阻止 task 层面的无限循环。
|
||||
|
||||
## 设计
|
||||
|
||||
### 机制
|
||||
|
||||
tasks 表新增 `dispatch_count` 字段,每次 ticker 成功 dispatch 一个 task 时递增。当 `dispatch_count >= 10`(全局默认)时,自动标 failed。
|
||||
|
||||
### 默认值选择
|
||||
|
||||
全局默认 10 次。参考 Hermes v0.13 Best Practices §3 "Per-Task 重试上限":
|
||||
|
||||
- 简单任务重试 1 次
|
||||
- 复杂任务重试 3 次
|
||||
- crash recovery(3 次)+ api_retry(3 次)余量 = ~10 次
|
||||
|
||||
### 适用范围
|
||||
|
||||
所有 task 类型(task/mail/toolchain),所有非终态(pending/working/claimed)。
|
||||
|
||||
### 检查时机
|
||||
|
||||
在 `_check_timeouts` 方法开头,先于现有的 claimed/working 超时检查执行。
|
||||
|
||||
### 与现有机制的关系
|
||||
|
||||
| 机制 | 覆盖场景 | 触发动作 |
|
||||
|------|---------|---------|
|
||||
| claim_timeout retry_count >= 3 | 广播任务无人认领 | 升级庞统 |
|
||||
| crash_limit 3/30min | working 状态 crash | 标 failed |
|
||||
| api_retry_count | API 连续失败 | 标 failed |
|
||||
| 续杯 max_retries 3 | 续杯耗尽 | 标 failed |
|
||||
| working timeout | working 超时 | 标 failed 或 done |
|
||||
| **runaway_guard 10 次** | **任何状态的无限循环** | **标 failed** |
|
||||
|
||||
runaway_guard 是最后一道防线,覆盖所有其他机制遗漏的循环场景。
|
||||
|
||||
## 改动文件
|
||||
|
||||
| 文件 | 改动 |
|
||||
|------|------|
|
||||
| `src/blackboard/db.py` | `_safe_add_column(conn, "tasks", "dispatch_count", "INTEGER DEFAULT 0")` |
|
||||
| `src/blackboard/models.py` | Task dataclass 加 `dispatch_count: int = 0` |
|
||||
| `src/daemon/ticker.py` | `_dispatch_pending` / `_dispatch_reviews` 递增 dispatch_count;`_check_timeouts` 加 runaway guard 检查 |
|
||||
|
||||
## 参考
|
||||
|
||||
- Hermes v0.13 Kanban Best Practices §3 "Per-Task 重试上限"
|
||||
- 实际案例:2026-06-15 mention 重复投递事件(PR #80 修复了直接根因,runaway guard 作为兜底)
|
||||
@@ -117,6 +117,7 @@ def _migrate_v28(conn: sqlite3.Connection) -> None:
|
||||
|
||||
_safe_add_column(conn, "tasks", "round_count", "INTEGER DEFAULT 0")
|
||||
_safe_add_column(conn, "tasks", "resumed_from", "TEXT")
|
||||
_safe_add_column(conn, "tasks", "dispatch_count", "INTEGER DEFAULT 0")
|
||||
|
||||
# 3. checkpoints 表(M3)
|
||||
conn.execute("""CREATE TABLE IF NOT EXISTS checkpoints (
|
||||
|
||||
@@ -41,6 +41,8 @@ class Task:
|
||||
resumed_from: Optional[str] = None # 暂停前状态,恢复时回到原状态
|
||||
# v2.9 四相循环
|
||||
round_count: int = 0 # 庞统 review 轮次计数
|
||||
# §15 Runaway Guard
|
||||
dispatch_count: int = 0 # 被 ticker dispatch 的总次数
|
||||
# v2.8 归档
|
||||
archived: bool = False
|
||||
archived_at: Optional[str] = None
|
||||
|
||||
@@ -208,7 +208,7 @@ class Blackboard:
|
||||
params.append(parent_task)
|
||||
if conditions:
|
||||
query += " WHERE " + " AND ".join(conditions)
|
||||
query += " ORDER BY priority ASC, created_at ASC"
|
||||
query += " ORDER BY priority ASC, created_at DESC"
|
||||
rows = conn.execute(query, params).fetchall()
|
||||
return [Task.from_row(r) for r in rows]
|
||||
finally:
|
||||
|
||||
@@ -9,7 +9,7 @@ import logging
|
||||
from pathlib import Path
|
||||
|
||||
from src.daemon.base_task_handler import BaseTaskHandler, VerifyResult
|
||||
from src.daemon.prompt_composer import PromptComposer, PromptContext, GiteaConventionSection, WikiGuideSection
|
||||
from src.daemon.prompt_composer import PromptComposer, PromptContext, GiteaConventionSection, WikiGuideSection, DeliveryChecklistSection
|
||||
from src.blackboard.db import get_connection
|
||||
|
||||
logger = logging.getLogger("moziplus-v2.handler.mail")
|
||||
@@ -36,7 +36,7 @@ class MailHandler(BaseTaskHandler):
|
||||
return composer.compose(context)
|
||||
|
||||
def get_sections(self) -> list:
|
||||
return [MailContextSection(), MailApiSection(), MailConstraintsSection(), GiteaConventionSection(), WikiGuideSection()]
|
||||
return [MailContextSection(), MailApiSection(), MailConstraintsSection(), GiteaConventionSection(), WikiGuideSection(), DeliveryChecklistSection()]
|
||||
|
||||
def verify_completion(self, task_id: str, db_path: Path) -> VerifyResult:
|
||||
"""Mail 完成验证:区分 inform/request。
|
||||
|
||||
@@ -174,3 +174,27 @@ class WikiGuideSection:
|
||||
|
||||
def should_include(self, context: "PromptContext") -> bool:
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# DeliveryChecklistSection — 交付检查清单
|
||||
# ---------------------------------------------------------------------------
|
||||
class DeliveryChecklistSection:
|
||||
"""交付检查清单 — 提醒 Agent 完成前同步关联成果物。"""
|
||||
|
||||
name: str = "delivery_checklist"
|
||||
priority: int = 55 # CONSTRAINTS(50) 和 EXTENSION(60) 之间
|
||||
|
||||
CHECKLIST_TEXT = (
|
||||
"## 交付检查\n"
|
||||
"完成代码改动前确认:\n"
|
||||
"- 改了实现 → docs/design/ 对应设计文档是否需要更新\n"
|
||||
"- 改了实现 → tests/ 是否有对应测试脚本需要更新\n"
|
||||
"- 所有成果物变更通过 PR 流程:PR review 把关设计合理性,CI 把关代码质量,CD 把关部署正确性\n"
|
||||
)
|
||||
|
||||
def render(self, context: "PromptContext") -> str:
|
||||
return self.CHECKLIST_TEXT
|
||||
|
||||
def should_include(self, context: "PromptContext") -> bool:
|
||||
return True
|
||||
|
||||
+27
-4
@@ -288,6 +288,8 @@ class AgentSpawner:
|
||||
mail_type = ""
|
||||
action_type = ""
|
||||
action_steps = []
|
||||
event_type = ""
|
||||
event_data = {}
|
||||
try:
|
||||
meta = json.loads(must_haves) if must_haves else {}
|
||||
from_agent = meta.get("from", "")
|
||||
@@ -295,6 +297,8 @@ class AgentSpawner:
|
||||
# toolchain 字段提取
|
||||
action_type = meta.get("action_type", "")
|
||||
action_steps = meta.get("steps", [])
|
||||
event_type = meta.get("event_type", "")
|
||||
event_data = meta.get("context", {})
|
||||
except Exception:
|
||||
pass
|
||||
ctx = PromptContext(
|
||||
@@ -304,6 +308,7 @@ class AgentSpawner:
|
||||
spawn_type=spawn_type,
|
||||
from_agent=from_agent, mail_type=mail_type,
|
||||
action_type=action_type, action_steps=action_steps,
|
||||
event_type=event_type, event_data=event_data,
|
||||
)
|
||||
return handler.build_prompt(ctx)
|
||||
|
||||
@@ -625,19 +630,24 @@ curl -X POST http://{self.api_host}:{self.api_port}/api/projects/{project_id}/ta
|
||||
stdout=asyncio.subprocess.PIPE,
|
||||
stderr=asyncio.subprocess.PIPE,
|
||||
)
|
||||
self._register_session(session_id, agent_id, task_id, proc.pid,
|
||||
# use_main_session=True 时 session_id 为 None,但 _register_session 和
|
||||
# _monitor_process 需要一个非 None 的 key;同时 ticker 等调用方用
|
||||
# `result is not None` 判断 spawn 是否成功,返回 None 会被误判为失败。
|
||||
# 统一用 "main" 作为占位标识。
|
||||
effective_sid = session_id or "main"
|
||||
self._register_session(effective_sid, agent_id, task_id, proc.pid,
|
||||
broadcast_task_ids=broadcast_task_ids)
|
||||
logger.info("Spawned agent %s (session=%s, pid=%d)",
|
||||
agent_id, session_id, proc.pid)
|
||||
agent_id, effective_sid, proc.pid)
|
||||
|
||||
# Schedule monitor(传 wrapped_on_complete)
|
||||
asyncio.create_task(
|
||||
self._monitor_process(session_id, proc, agent_id, task_id,
|
||||
self._monitor_process(effective_sid, proc, agent_id, task_id,
|
||||
on_complete=_wrapped_on_complete,
|
||||
db_path=task_db_path or self.db_path)
|
||||
)
|
||||
|
||||
return session_id
|
||||
return effective_sid
|
||||
|
||||
except Exception as e:
|
||||
# spawn 失败也要 release counter
|
||||
@@ -1949,6 +1959,19 @@ curl -X POST http://{api_host}:{api_port}/api/projects/{project_id}/tasks/{task_
|
||||
try:
|
||||
from src.daemon.mail_notify import _is_mail_project, notify_mail_failed
|
||||
if _is_mail_project(db_path):
|
||||
# 防御性检查:如果 task 已经 done,不触发失败通知(竞态保护)
|
||||
# 场景:spawner 标 failed 和 handler 标 done 同时发生
|
||||
try:
|
||||
conn2 = get_connection(db_path)
|
||||
current_status = conn2.execute(
|
||||
"SELECT status FROM tasks WHERE id=?", (task_id,)
|
||||
).fetchone()
|
||||
conn2.close()
|
||||
if current_status and current_status["status"] == "done":
|
||||
logger.info("Task %s already done, skipping mail failure notification", task_id)
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
# Mail 失败:通知发件人,不 @pangtong
|
||||
notify_mail_failed(db_path, task_id, reason, detail)
|
||||
else:
|
||||
|
||||
@@ -10,7 +10,7 @@ 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, GiteaConventionSection, WikiGuideSection
|
||||
from src.daemon.prompt_composer import PromptComposer, PromptContext, GiteaConventionSection, WikiGuideSection, DeliveryChecklistSection
|
||||
from src.blackboard.db import get_connection
|
||||
|
||||
logger = logging.getLogger("moziplus-v2.handler")
|
||||
@@ -315,6 +315,7 @@ class TaskHandler(BaseTaskHandler):
|
||||
TaskConstraintsSection(),
|
||||
GiteaConventionSection(),
|
||||
WikiGuideSection(),
|
||||
DeliveryChecklistSection(),
|
||||
]
|
||||
|
||||
def build_prompt(self, context: PromptContext) -> str:
|
||||
|
||||
@@ -1084,6 +1084,19 @@ Parent Task ID: {parent_task.id}
|
||||
broadcast_ids = await self._broadcast_claim(broadcast_tasks, db_path, project_id)
|
||||
dispatched.extend(broadcast_ids)
|
||||
|
||||
# §15 Runaway Guard: 统一递增 dispatch_count
|
||||
if dispatched:
|
||||
conn = get_connection(db_path)
|
||||
try:
|
||||
for tid in dispatched:
|
||||
conn.execute(
|
||||
"UPDATE tasks SET dispatch_count = COALESCE(dispatch_count, 0) + 1 WHERE id=?",
|
||||
(tid,),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return dispatched
|
||||
|
||||
async def _broadcast_claim(self, tasks: list, db_path: Path,
|
||||
@@ -1376,6 +1389,19 @@ Parent Task ID: {parent_task.id}
|
||||
except Exception:
|
||||
logger.exception("Review dispatch failed for %s", task.id)
|
||||
|
||||
# §15 Runaway Guard: 统一递增 dispatch_count (review)
|
||||
if dispatched:
|
||||
conn = get_connection(db_path)
|
||||
try:
|
||||
for tid in dispatched:
|
||||
conn.execute(
|
||||
"UPDATE tasks SET dispatch_count = COALESCE(dispatch_count, 0) + 1 WHERE id=?",
|
||||
(tid,),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
return dispatched
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
@@ -1388,6 +1414,31 @@ Parent Task ID: {parent_task.id}
|
||||
reclaimed: List[str] = []
|
||||
now = datetime.utcnow() # UTC,与 SQLite datetime('now') 一致
|
||||
|
||||
# §15 Runaway Guard: per-task dispatch_count 上限检查
|
||||
# 覆盖所有状态,防止无限循环 dispatch
|
||||
MAX_DISPATCH_COUNT = 10
|
||||
for status_to_check in ("pending", "working", "claimed"):
|
||||
tasks_to_check = queries.tasks_by_status(status_to_check)
|
||||
for task in tasks_to_check:
|
||||
dispatch_count = getattr(task, 'dispatch_count', 0) or 0
|
||||
if dispatch_count >= MAX_DISPATCH_COUNT:
|
||||
conn = get_connection(db_path)
|
||||
try:
|
||||
ok = self._transition_status(
|
||||
conn, task.id, "failed",
|
||||
agent="daemon",
|
||||
detail={"reason": "runaway_guard",
|
||||
"dispatch_count": dispatch_count,
|
||||
"message": f"dispatch {dispatch_count} 次仍未完成,自动标 failed"},
|
||||
)
|
||||
if ok:
|
||||
reclaimed.append(task.id)
|
||||
logger.error(
|
||||
"Task %s: runaway guard triggered (dispatch_count=%d, status=%s), marking failed",
|
||||
task.id, dispatch_count, status_to_check)
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
# claimed 超时 → 重置为 pending(如果 retry_count >= 3 则升级庞统)
|
||||
claimed = queries.tasks_by_status("claimed")
|
||||
for task in claimed:
|
||||
|
||||
@@ -13,7 +13,7 @@ from pathlib import Path
|
||||
from typing import Dict, List
|
||||
|
||||
from src.daemon.base_task_handler import BaseTaskHandler, VerifyResult
|
||||
from src.daemon.prompt_composer import PromptComposer, PromptContext, GiteaConventionSection, WikiGuideSection
|
||||
from src.daemon.prompt_composer import PromptComposer, PromptContext, GiteaConventionSection, WikiGuideSection, DeliveryChecklistSection
|
||||
from src.daemon.toolchain_templates import render_template, _TEMPLATE_MAP
|
||||
from src.blackboard.db import get_connection
|
||||
|
||||
@@ -51,17 +51,41 @@ class ToolchainContextSection:
|
||||
name: str = "toolchain_context"
|
||||
priority: int = 10
|
||||
|
||||
EVENT_LABELS_ZH: Dict[str, str] = {
|
||||
"review_request": "Review 请求",
|
||||
"review_result": "Review 结果",
|
||||
"review_merged": "PR 合并",
|
||||
"review_comment": "Review 评论",
|
||||
"review_updated": "Review 更新",
|
||||
"ci_failure": "CI 失败",
|
||||
"deploy_failure": "部署失败",
|
||||
"issue_assigned": "Issue 指派",
|
||||
"mention": "@提及",
|
||||
}
|
||||
|
||||
def render(self, context: PromptContext) -> str:
|
||||
event_type = context.event_type
|
||||
event_data: Dict = context.event_data or {}
|
||||
|
||||
# 事件类型中文标签
|
||||
event_label = self.EVENT_LABELS_ZH.get(event_type, event_type or '未知')
|
||||
|
||||
# from / to 信息
|
||||
to_agent = context.agent_id or ''
|
||||
from_agent = 'system'
|
||||
|
||||
# Part 1: 事件信息(现有模板引擎)
|
||||
if event_type in _TEMPLATE_MAP:
|
||||
variables = {k: str(v) for k, v in event_data.items()}
|
||||
event_text = render_template(event_type, variables)
|
||||
# 补充事件类型中文标签 + from/to
|
||||
header = f"- **事件类型**: {event_label}\n- **来源**: {from_agent}\n- **指派**: {to_agent}\n"
|
||||
event_text = header + "\n" + event_text
|
||||
else:
|
||||
lines = ["## 工具链事件", ""]
|
||||
lines.append(f"- **事件类型**: {event_type or '未知'}")
|
||||
lines.append(f"- **事件类型**: {event_label}")
|
||||
lines.append(f"- **来源**: {from_agent}")
|
||||
lines.append(f"- **指派**: {to_agent}")
|
||||
if event_data:
|
||||
lines.append("- **事件详情**:")
|
||||
for key, value in event_data.items():
|
||||
@@ -228,6 +252,7 @@ class ToolchainHandler(BaseTaskHandler):
|
||||
ToolchainConstraintsSection(),
|
||||
GiteaConventionSection(),
|
||||
WikiGuideSection(),
|
||||
DeliveryChecklistSection(),
|
||||
]
|
||||
|
||||
def build_prompt(self, context: PromptContext) -> str:
|
||||
|
||||
@@ -17,7 +17,6 @@ import CourtCeremony from './components/CourtCeremony';
|
||||
import CourtDiscussion from './components/CourtDiscussion';
|
||||
import UsagePanel from './components/UsagePanel';
|
||||
import SettingsPanel from './components/SettingsPanel';
|
||||
import ToolchainPanel from './components/ToolchainPanel';
|
||||
import GlobalSearch from './components/GlobalSearch';
|
||||
import NotificationCenter from './components/NotificationCenter';
|
||||
|
||||
@@ -101,7 +100,6 @@ export default function App() {
|
||||
usage: <UsagePanel />,
|
||||
morning: <MorningPanel />,
|
||||
settings: <SettingsPanel />,
|
||||
toolchain: <ToolchainPanel />,
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { api, AgentsStatusData } from '../api';
|
||||
import ToolchainPanel from './ToolchainPanel';
|
||||
|
||||
interface ServiceCheckResult {
|
||||
name: string;
|
||||
@@ -15,7 +16,7 @@ interface ServiceCheckResult {
|
||||
}
|
||||
|
||||
export default function SettingsPanel() {
|
||||
const [tab, setTab] = useState<'connections' | 'security' | 'version' | 'logs'>('connections');
|
||||
const [tab, setTab] = useState<'connections' | 'security' | 'version' | 'logs' | 'toolchain'>('connections');
|
||||
|
||||
// 接线状态巡检
|
||||
const [checking, setChecking] = useState(false);
|
||||
@@ -95,6 +96,7 @@ export default function SettingsPanel() {
|
||||
{ key: 'security' as const, label: '🛡️ 安全防务' },
|
||||
{ key: 'version' as const, label: '📦 版本更新' },
|
||||
{ key: 'logs' as const, label: '📋 城防日志' },
|
||||
{ key: 'toolchain' as const, label: '⛓️ 工具链' },
|
||||
].map((t) => (
|
||||
<button key={t.key} className={`btn ${tab === t.key ? 'btn-primary' : ''}`} onClick={() => setTab(t.key)}>
|
||||
{t.label}
|
||||
@@ -288,6 +290,9 @@ export default function SettingsPanel() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* ========== 工具链 ========== */}
|
||||
{tab === 'toolchain' && <ToolchainPanel />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -4,6 +4,28 @@
|
||||
*/
|
||||
import { useEffect, useState } from 'react';
|
||||
|
||||
const AGENT_NAMES: Record<string, string> = {
|
||||
'pangtong-fujunshi': '庞统',
|
||||
'simayi-challenger': '司马懿',
|
||||
'zhangfei-dev': '张飞',
|
||||
'guanyu-dev': '关羽',
|
||||
'zhaoyun-data': '赵云',
|
||||
'jiangwei-infra': '姜维',
|
||||
'system': '系统',
|
||||
};
|
||||
|
||||
const EVENT_LABELS: Record<string, string> = {
|
||||
'review_request': 'Review 请求',
|
||||
'review_result': 'Review 结果',
|
||||
'review_merged': 'PR 合并',
|
||||
'review_comment': 'Review 评论',
|
||||
'review_updated': 'Review 更新',
|
||||
'ci_failure': 'CI 失败',
|
||||
'deploy_failure': '部署失败',
|
||||
'issue_assigned': 'Issue 指派',
|
||||
'mention': '@提及',
|
||||
};
|
||||
|
||||
const STATUS_COLORS: Record<string, string> = {
|
||||
pending: '#f59e0b22', claimed: '#6a9eff22', working: '#6a9eff22',
|
||||
review: '#818cf822', done: '#2ecc8a22', failed: '#ef444422',
|
||||
@@ -36,6 +58,7 @@ export default function ToolchainPanel() {
|
||||
const [detail, setDetail] = useState<any>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [filterMode, setFilterMode] = useState<'all' | 'pending'>('all');
|
||||
|
||||
const loadTasks = async (q?: string) => {
|
||||
setLoading(true);
|
||||
@@ -52,6 +75,10 @@ export default function ToolchainPanel() {
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
const displayed = filterMode === 'pending'
|
||||
? tasks.filter(t => !['done', 'failed', 'cancelled'].includes(t.status))
|
||||
: tasks;
|
||||
|
||||
useEffect(() => { loadTasks(); }, []);
|
||||
|
||||
// 搜索防抖 300ms
|
||||
@@ -120,7 +147,19 @@ export default function ToolchainPanel() {
|
||||
padding: '3px 8px', borderRadius: 4, fontSize: 10,
|
||||
border: '1px solid #2a3550', background: '#161b2e', color: '#8899aa', cursor: 'pointer',
|
||||
}}>🔄</button>
|
||||
<span style={{ fontSize: 10, color: 'var(--muted)' }}>{tasks.length} 条</span>
|
||||
<button onClick={() => setFilterMode('all')} style={{
|
||||
padding: '3px 8px', borderRadius: 4, fontSize: 10,
|
||||
border: `1px solid ${filterMode === 'all' ? 'var(--acc)' : '#2a3550'}`,
|
||||
background: filterMode === 'all' ? 'var(--acc)22' : '#161b2e',
|
||||
color: filterMode === 'all' ? 'var(--acc)' : '#8899aa', cursor: 'pointer',
|
||||
}}>全部</button>
|
||||
<button onClick={() => setFilterMode('pending')} style={{
|
||||
padding: '3px 8px', borderRadius: 4, fontSize: 10,
|
||||
border: `1px solid ${filterMode === 'pending' ? 'var(--acc)' : '#2a3550'}`,
|
||||
background: filterMode === 'pending' ? 'var(--acc)22' : '#161b2e',
|
||||
color: filterMode === 'pending' ? 'var(--acc)' : '#8899aa', cursor: 'pointer',
|
||||
}}>未处理</button>
|
||||
<span style={{ fontSize: 10, color: 'var(--muted)' }}>{filterMode === 'pending' ? displayed.length : tasks.length} 条</span>
|
||||
</div>
|
||||
|
||||
{/* 事件列表 */}
|
||||
@@ -130,7 +169,7 @@ export default function ToolchainPanel() {
|
||||
{loading ? '加载中...' : '暂无工具链事件'}
|
||||
</div>
|
||||
)}
|
||||
{tasks.map((t: any) => (
|
||||
{displayed.map((t: any) => (
|
||||
<div key={t.id} onClick={() => setSelectedId(t.id)} style={{
|
||||
padding: '10px 14px', borderBottom: '1px solid var(--line)',
|
||||
cursor: 'pointer', transition: 'background .15s',
|
||||
@@ -151,6 +190,9 @@ export default function ToolchainPanel() {
|
||||
fontSize: 12, fontWeight: 500, color: '#dde4f8',
|
||||
overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap',
|
||||
}}>{t.title}</div>
|
||||
<div style={{ fontSize: 10, color: 'var(--muted)', marginTop: 2 }}>
|
||||
{AGENT_NAMES['system'] || '系统'} → {AGENT_NAMES[t.assignee] || t.assignee || '?'}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -174,6 +216,9 @@ export default function ToolchainPanel() {
|
||||
<span style={{ fontSize: 10, color: 'var(--muted)' }}>{detail.id}</span>
|
||||
</div>
|
||||
<div style={{ fontSize: 18, fontWeight: 700, lineHeight: 1.3 }}>{detail.title}</div>
|
||||
<div style={{ fontSize: 11, color: 'var(--muted)', marginTop: 4 }}>
|
||||
{AGENT_NAMES['system'] || '系统'} → {AGENT_NAMES[detail.assignee] || detail.assignee || '?'}
|
||||
</div>
|
||||
<div style={{ fontSize: 12, color: 'var(--muted)', marginTop: 6 }}>
|
||||
{fmtTime(detail.created_at)}
|
||||
</div>
|
||||
|
||||
@@ -120,7 +120,7 @@ export function isArchived(t: Task): boolean {
|
||||
export type TabKey =
|
||||
| 'tasks' | 'court' | 'monitor' | 'agents'
|
||||
| 'models' | 'skills' | 'sessions' | 'archives' | 'templates'
|
||||
| 'usage' | 'settings' | 'officials' | 'morning' | 'mail' | 'toolchain';
|
||||
| 'usage' | 'settings' | 'officials' | 'morning' | 'mail';
|
||||
|
||||
export const TAB_DEFS: { key: TabKey; label: string; icon: string }[] = [
|
||||
{ key: 'tasks', label: '任务看板', icon: '📜' },
|
||||
@@ -135,7 +135,6 @@ export const TAB_DEFS: { key: TabKey; label: string; icon: string }[] = [
|
||||
{ key: 'archives', label: '奏折阁', icon: '📜' },
|
||||
{ key: 'morning', label: '早朝简报', icon: '🌅' },
|
||||
{ key: 'templates', label: '任务模板', icon: '📋' },
|
||||
{ key: 'toolchain', label: '工具链', icon: '⛓️' },
|
||||
{ key: 'settings', label: '系统设置', icon: '⚙️' },
|
||||
];
|
||||
|
||||
|
||||
@@ -543,3 +543,94 @@ class TestCheckTimeoutsUnified:
|
||||
reclaimed = ticker._check_timeouts(db_path)
|
||||
|
||||
assert "t-review-dead" not in reclaimed
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# E13: §15 Runaway Guard — per-task dispatch_count 上限
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
class TestRunawayGuard:
|
||||
"""E13: dispatch_count >= 10 → 自动标 failed(覆盖所有非终态)"""
|
||||
|
||||
@pytest.fixture
|
||||
def guard_project(self, tmp_path):
|
||||
"""创建项目 + 任务"""
|
||||
data_root = tmp_path / "projects"
|
||||
registry = ProjectRegistry(data_root)
|
||||
registry.create_project("guard-proj", "Guard Test", agents=["agent-a"])
|
||||
db_path = data_root / "guard-proj" / "blackboard.db"
|
||||
bb = Blackboard(db_path)
|
||||
return registry, db_path, bb
|
||||
|
||||
def test_runaway_guard_triggers_working(self, guard_project):
|
||||
"""E13.1: working 状态 dispatch_count >= 10 → 标 failed"""
|
||||
registry, db_path, bb = guard_project
|
||||
|
||||
bb.create_task(Task(
|
||||
id="t-runaway", title="Runaway Task", status="working",
|
||||
assigned_by="daemon", current_agent="agent-a",
|
||||
))
|
||||
|
||||
conn = bb._conn()
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE tasks SET dispatch_count = 10 WHERE id = ?", ("t-runaway",))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
ticker = Ticker(registry, tick_interval=30)
|
||||
reclaimed = ticker._check_timeouts(db_path)
|
||||
|
||||
assert "t-runaway" in reclaimed
|
||||
task = Queries(db_path).task_by_id("t-runaway")
|
||||
assert task.status == "failed"
|
||||
|
||||
def test_runaway_guard_triggers_pending(self, guard_project):
|
||||
"""E13.2: pending 状态 dispatch_count >= 10 → 标 failed"""
|
||||
registry, db_path, bb = guard_project
|
||||
|
||||
bb.create_task(Task(
|
||||
id="t-pending-runaway", title="Pending Runaway", status="pending",
|
||||
assigned_by="daemon",
|
||||
))
|
||||
|
||||
conn = bb._conn()
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE tasks SET dispatch_count = 10 WHERE id = ?",
|
||||
("t-pending-runaway",))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
ticker = Ticker(registry, tick_interval=30)
|
||||
reclaimed = ticker._check_timeouts(db_path)
|
||||
|
||||
assert "t-pending-runaway" in reclaimed
|
||||
task = Queries(db_path).task_by_id("t-pending-runaway")
|
||||
assert task.status == "failed"
|
||||
|
||||
def test_runaway_guard_not_triggered(self, guard_project):
|
||||
"""E13.3: dispatch_count < 10 → 正常流程不受影响"""
|
||||
registry, db_path, bb = guard_project
|
||||
|
||||
bb.create_task(Task(
|
||||
id="t-normal", title="Normal Task", status="working",
|
||||
assigned_by="daemon", current_agent="agent-a",
|
||||
))
|
||||
|
||||
conn = bb._conn()
|
||||
try:
|
||||
conn.execute(
|
||||
"UPDATE tasks SET dispatch_count = 5 WHERE id = ?", ("t-normal",))
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
ticker = Ticker(registry, tick_interval=30)
|
||||
reclaimed = ticker._check_timeouts(db_path)
|
||||
|
||||
assert "t-normal" not in reclaimed
|
||||
task = Queries(db_path).task_by_id("t-normal")
|
||||
assert task.status == "working"
|
||||
|
||||
Reference in New Issue
Block a user