9 Commits

Author SHA1 Message Date
claude_dev da8269f16b [moz] docs(incident): daemon ticker 停摆 8h 指控调查报告 — 时区误读,服务全程健康
CI / lint (pull_request) Failing after 13s
CI / test (pull_request) Has been skipped
CI / frontend (pull_request) Has been skipped
CI / notify-on-failure (pull_request) Successful in 2s
2026-09-03 05:19:30 +08:00
jiangwei-infra 89bbfeb54a Merge PR #126: docs(§22): v1.3 TC Discuss 重构 + Gitea Round Review 适配设计 2026-06-25 10:18:49 +00:00
claude_dev 0e8e9d0c24 Merge branch 'main' into docs/22-tc-discuss-and-gitea-round-review
CI / lint (pull_request) Successful in 21s
CI / test (pull_request) Successful in 26s
CI / frontend (pull_request) Successful in 13s
CI / notify-on-failure (pull_request) Successful in 0s
2026-06-25 18:18:38 +08:00
claude_dev 24600c38f9 chore: retrigger CI via synchronize 2026-06-25 18:15:11 +08:00
claude_dev 37a6257689 Merge branch 'main' into docs/22-tc-discuss-and-gitea-round-review 2026-06-25 08:42:13 +08:00
claude_dev 3c58baa00f chore: retrigger CI (paths-ignore fix #129 merged) 2026-06-25 08:39:44 +08:00
claude_dev c19286eb5b [moz] docs(§22): v1.5 采纳司马懿 S1-S4+P1-P3 审查建议 2026-06-25 07:45:26 +08:00
claude_dev a376fdadbc [moz] docs(§22): v1.4 设计补足 — 6 个问题修复(基础设施排除/单spawn自主模式/迁移策略/TC数据源分流通透) 2026-06-25 07:41:17 +08:00
cfdaily 1ec950a7ce [moz] docs(§22): v1.3 §22.6 TC Discuss 重构 + §22.8 TC Round Review Gitea 适配设计 2026-06-24 08:46:46 +08:00
2 changed files with 480 additions and 52 deletions
+356 -52
View File
@@ -1,9 +1,12 @@
---
title: "End-to-End Flow — 端到端任务流程设计"
created: 2026-06-22
version: v1.2
version: v1.5
status: draft
changelog: v1.2 §22.3/§22.4 更新 Phase 1 为 ✅ 已实现(PR #124
changelog: v1.5 采纳司马懿 S1-S4+P1-P3:判断逻辑补 infrastructure 分支、flow/discuss deprecated、定向讨论 fallback、task_state.status 完整生命周期、parent_status 查表不硬编码
v1.4 §22.6/§22.8 设计补足:基础设施排除、定向讨论简化为单spawn自主模式、迁移策略、TC数据源/round_count分流通透
v1.3 §22.6 重构为「所有 TC 流程第一步走 Discuss」+ §22.8 TC Round Review Gitea 适配设计
v1.2 §22.3/§22.4 更新 Phase 1 为 ✅ 已实现(PR #124
v1.1 补充轻量路径设计(§22.6)、数据流澄清(§22.7)、修正设计原则3、统一Phase编号、Phase 1标注
v1.0 初版
---
@@ -264,35 +267,96 @@ issue_assigned:
## §22.6 轻量路径设计(Direct Assignment
> 不是所有任务都需要 Discussion 广播。需求明确、单一执行者的任务应跳过 Phase 0/1 直接进入 Phase 2。
> **核心原则:所有 TC 流程第一步都走 Discuss(基础设施和 flow/direct 除外)。**
> Discussion 不是可选项,而是 TC 流程的必经阶段——确保方案对齐、风险暴露后再进入 exec。
> 区别只在于「谁参与讨论」。
### 路径决策矩阵
| 条件 | 路径 | 流程 |
|------|------|------|
| Issue 无 assignee + type/* label | **Discussion 路径**(默认) | Phase 0 → 1 → 2 → … |
| Issue 有 assignee | **Direct 路径** | → Phase 2(跳过 Phase 0/1 |
| Issue 有 `flow/direct` label | **强制 Direct** | → Phase 2(即使无 assigneedaemon 自动 assign |
| Issue 有 `flow/discuss` label | **强制 Discussion** | Phase 0 → 1 → 2 → …(即使有 assignee |
| 条件 | 讨论范围 | 流程 |
|------|---------|------|
| Issue 无 assignee + type/* label | **广播所有空闲 agent** | Phase 0 → 1(广播讨论)→ 2 → … |
| Issue 有 assignee(非 infrastructure | **assignee 自主定向讨论** | assignee 写方案 → @司马懿 review → 创建 sub Issue 进 exec |
| Issue 有 `type/infrastructure` label | **跳过讨论** | → executor(运维排障,无需方案审查 |
| Issue 有 `flow/direct` label | **跳过讨论** | Phase 2(极小改动逃生舱 |
**设计原则**
1. 基础设施 Issue`type/infrastructure`)→ 直接到 executor(运维排障任务,不需要写方案+审查)
2. `flow/direct` label → 直接到 executor(明确不需要讨论的小改动)
3. 有 assignee(非 infrastructure)→ assignee 自主走「方案→review→exec」流程,daemon 不编排
4. 没有 assignee → 广播所有 agent 讨论,有 agent 认领后创建 sub Issue
> **v1.4 补充**v1.3 原设计将 infrastructure 也纳入 Discuss,但基础设施排障任务(Gitea 挂了、磁盘满等)不需要写实现方案和司马懿审查,应排除。
### 判断逻辑
```
parent Issue 创建
├─ 有 flow/discuss label?→ Discussion 路径(Phase 0 → 1
├─ 有 flow/direct label?→ Direct 路径(daemon 自动 assign → Phase 2
├─ 有 assignee?→ Direct 路径(→ Phase 2
└─ 无 assignee(默认)→ Discussion 路径(Phase 0 → 1
├─ 有 flow/direct label?→ Direct 路径(Phase 2,跳过讨论
├─ 有 type/infrastructure label?→ Direct 路径(→ executor,运维排障
├─ 有 assignee(非 infrastructure)?→ 定向 Discussionassignee 自主模式
└─ 无 assignee(默认)→ 广播 Discussion(所有空闲 agent
```
优先级:`flow/*` label > assignee > 默认行为。
### 定向讨论流程(有 assignee,非 infrastructure
> **v1.4 修正**v1.3 原设计「ticker 同时 spawn assignee + 司马懿」过于复杂,需要 daemon 协调两个 agent 生命周期。
> v1.4 简化为**单 spawn 自主模式**——daemon 只创建 discussion task 给 assigneeassignee 在一个 session 内自主完成全部流程。
```
Issue assigned webhook
→ daemon 创建 issue_discussion taskassignee=该 agentcontext_data 带 issue/repo
→ ticker dispatch:有 assignee → 确定性路由 spawn assignee 单人
→ discussion prompt 引导 assignee
1. 读 Issue 全文,在 Gitea Issue comment 写实现方案(技术选型、实现路径、影响范围)
2. @simayi-challenger 请求方案审查(利用现有 @mention 机制自动创建 review task
3. 等司马懿 review 结果(通过 @mention 回传)
4. review 通过 → 创建 sub Issueassign 自己)→ 进入 exec
5. review 驳回 → 修改方案重新提交(重新 comment + @司马懿)
```
**为什么不双 spawn**
1. assignee 写方案时间不确定,双 spawn 后司马懿可能干等
2. 两阶段串行更简单,assignee 完成后 @mention 自动触发司马懿
3. 和广播讨论一致——daemon 只创建初始 task,后续靠 agent 自主 + @mention
**与广播讨论的区别**
- 广播讨论:assignee=None → ticker 归入 broadcast_tasks → 广播所有空闲 agent
- 定向讨论:assignee=该 agent → ticker 归入 deterministic_tasks → 确定性路由 spawn 单人
### 定向讨论 fallbackassignee 无响应)
> **v1.4 补充(司马懿 S4)**:广播讨论有「3 轮无 taker → 升级庞统」机制,定向讨论也需要 fallback。
单 spawn assignee 后,如果 assignee NO_REPLY 或 session 超时:
```
ticker 定期 check(复用现有 broadcast _broadcast_tracker 机制)
→ discussion task 仍 pending/working 且超过 3 轮 check 未终态
→ 升级庞统(escalated 状态)
→ 庞统判断:重新分配 / 转为广播讨论 / 直接关闭
```
复用现有 `_broadcast_tracker``round_number >= 3 → escalated` 逻辑,不引入新机制。
### 广播讨论流程(无 assignee
```
Issue opened webhook
→ daemon 创建 issue_discussion taskassignee=None
→ ticker broadcast:广播所有空闲 agent
→ discussion prompt 引导每个 agent
1. 在 Gitea Issue comment 回应(定位/建议/认领/风险)
2. 需要参与的 agent 创建 sub Issue assign 自己
```
### 适用场景
| 路径 | 适用场景 | 示例 |
|------|---------|------|
| Discussion | 需求不明确、需要多角色讨论、跨模块协作 | 新功能设计、架构变更、涉及 3+ agent 的任务 |
| Direct | 需求明确、单一执行者、小范围改动 | Bug 修复、文档更新、配置调整、单人任务 |
| 广播讨论 | 需求不明确、跨模块协作、涉及 3+ agent、无人认领 | 新功能设计、架构变更 |
| 定向讨论 | 需求明确但需要方案确认 | 有明确 assignee 的 Issue |
| Direct | 改 typo、改配置值等极小改动 | `flow/direct` label |
### Discussion 路径中的降级
@@ -304,26 +368,42 @@ Discussion 进行中,如果所有 agent 都认为任务足够简单只涉及
庞统判断后创建 sub Issue 直接 assign → 该 agent 跳过讨论直接进入 Phase 2。
### Direct 路径跳过的 Phase
### 迁移策略
| Phase | 是否跳过 | 原因 |
|-------|---------|------|
| Phase 0parent Issue 讨论 task | ✅ 跳过 | 不创建 discussion task |
| Phase 1Discussion 广播) | ✅ 跳过 | 不广播,不打扰其他 agent |
| Phase 2assigned → executor | ❌ 直接进入 | 起点 |
> **v1.4 补充**v1.3 未说明现有 `issue_assigned` 路径如何处理。
### 对现有代码的影响
**保留 `issue_assigned` 给 infrastructure / flow-direct,新增定向讨论路径**
当前 `_handle_issues` 已有两个分支:
| 条件 | 旧路径(v1.2) | 新路径(v1.4) | 变化 |
|------|----------------|----------------|------|
| 有 assignee + 非 infrastructure | issue_assigned → executor | issue_discussion → discussion(定向) | **改** |
| 有 assignee + `type/infrastructure` | issue_assigned → executor | issue_assigned → executor(不变) | 无 |
| 有 `flow/direct` label | issue_assigned → executor | issue_assigned → executor(不变) | 无 |
| 无 assignee + type/* label | issue_discussion → broadcast | issue_discussion → broadcast(不变) | 无 |
- `opened`(无 assignee + type/* label)→ discussion task ← Discussion 路径 ✅
- `assigned`(有 assignee)→ executor task ← Direct 路径 ✅
`_ACTION_HINTS` / YAML steps / `ToolchainContextSection` 中的 `issue_assigned` 相关逻辑**保留**infrastructure/flow-direct 还用),新增 `issue_review_discussion` action_type。
**轻量路径已全部实现**。包括:
> **`flow/discuss` label deprecated**v1.2 曾设计 `flow/discuss` label(强制 Discussion 即使有 assignee),v1.4 废弃。所有有 assignee 的 Issue(非 infrastructure)默认走定向讨论,无需显式 label。Gitea 上 `flow/discuss` labelid=101)保留但不影响路由。
1.`flow/direct``flow/discuss` label 已创建(Gitea label id=100, 101
2.`toolchain_routes.py` `_handle_issues` opened 分支识别 `flow/direct` → executor task
3. ✅ Discussion prompt 中包含降级机制引导(§22.6)
### 实现方案
**toolchain_routes.py**
- `opened` 分支(无 assignee)→ discussion taskassignee=None)— 现有逻辑 ✅
- `assigned` 分支改造:
- `is_infrastructure=True` → executor task(现有逻辑保留 ✅)
- `has_flow_direct=True` → executor task(现有逻辑保留 ✅)
- **其他** → discussion taskassignee=该 agentcontext_data 带 issue/repo)— **需要改动**
**ticker.py**
- 定向讨论 task 有 assignee → `_dispatch_pending` 确定性路由直接 spawn assignee 单人
- 广播讨论 task 无 assignee → `_broadcast_claim` 广播所有空闲 agent(现有逻辑 ✅)
- `_broadcast_claim` 中的 action_type 判断逻辑保留(discussion task 走 `_build_discussion_prompt`
**讨论 prompt 差异化**
- 定向讨论 prompt 额外指引:你是 assignee,需要写方案并主动 @司马懿 review
- 广播讨论 prompt 保持现有模板(4 维度回应 + 自主认领)
**不影响**mail / task 流程(它们不走 toolchain_routes.py
---
@@ -360,29 +440,11 @@ ticker: _check_round_complete
→ 全部终态 → spawn 庞统 review
```
### 前置条件
### 前置条件(已全部实现 ✅)
1. **task_state 表创建**§20 设计了 DDL 但未实现。需要先建表:
```sql
CREATE TABLE task_state (
issue_number INTEGER PRIMARY KEY,
repo TEXT,
parent_issue INTEGER,
status TEXT DEFAULT 'pending',
action_type TEXT,
retry_count INTEGER DEFAULT 0,
dispatch_count INTEGER DEFAULT 0,
round_count INTEGER DEFAULT 0,
created_at TEXT,
updated_at TEXT
);
CREATE INDEX idx_task_state_parent ON task_state(parent_issue);
```
2. **parent_issue 解析**daemon 在 `_handle_issues` 中解析标题 `[parent #{N}]` 模式,写入 `task_state.parent_issue`(§21 §14b L920 已有此设计)。
3. **_check_round_complete 改造**:从扫 `tasks.parent_task`(黑板)改为扫 `task_state.parent_issue`toolchain DB)(§21 §16.2 L1148 已有此设计)。
1. **task_state 表创建**已在 PR #125 中实现(`_init_task_state_table`CREATE IF NOT EXISTS)。
2. **parent_issue 解析**:已实现(`_ensure_task_state``_handle_issues` assigned 分支解析 `[parent #N]`)。
3. **_check_round_complete 双源扫描**:已实现(`task_state.parent_issue` + `tasks.parent_task`)。
### 混合期处理
@@ -405,3 +467,245 @@ task_state 表创建前,`_check_round_complete` 仍扫 `tasks.parent_task`。
| 5 | toolchain DB: task(review_merged) | Gitea webhook (PR closed/merged) | — | — |
| 6 | task_state: round_count++ | task_state: parent_issue + sub status | Gitea parent Issue body + sub Issue comments | Gitea Issue comment(三问结论)+ 关闭/创建 Issue |
| 7 | toolchain DB: task(issue_closed) | Gitea webhook (issues/closed) | — | — |
---
## §22.8 TC Round Review Gitea 适配
> **设计原则:三个流程已通过 Handler 架构分离(§14)。TC Round Review 的改造通过 ToolchainHandler 分流,不影响黑板流程。**
### 问题(v1.4 修正:不只是 prompt 不同,数据查询链路也不同)
`_check_round_complete` 中调用 `bb.get_subtasks_summary(parent_id)` / `bb.get_aggregate_outputs()` / `bb.get_round_comments()` 全部查黑板 DB。但 TC 的子任务信息在 `task_state` 表 + Gitea Issue/PR**根本不在黑板 tasks/outputs/comments 表**。
因此 handler 分流不只是 prompt 层面,需要贯穿**数据查询 → prompt 构建 → round_count 递增**三个环节。
### TC 数据源 vs 黑板数据源对照
| 环节 | 黑板路径 | TC 路径 |
|------|---------|---------|
| 子任务状态 | `bb.get_subtasks_summary()` → tasks 表 | 新增 `get_tc_subtask_summary()` → task_state 表 |
| 成果物 | `bb.get_aggregate_outputs()` → outputs 表 | 庞统自己读 Gitea APIPR/commit |
| 讨论历史 | `bb.get_round_comments()` → comments 表 | 庞统自己读 Gitea Issue comments |
| round_count | `bb.increment_round_count()` → tasks.round_count | `UPDATE task_state SET round_count+1` |
| parent status | tasks.status | task_state.status(需补充更新逻辑) |
### task_state.status 更新机制
> **v1.4 补充(司马懿 S3**:当前 `_ensure_task_state` 只做 `INSERT OR IGNORE`status 初始为 `pending`,后续没有任何代码更新。需补充。
**状态生命周期**
| 状态 | 触发来源 | 更新位置 |
|------|---------|----------|
| `pending` | `_ensure_task_state` 初始插入 | `toolchain_routes.py` assigned 分支 |
| `done` | Gitea Issue closed webhook | `toolchain_routes.py` closed 分支 |
| `failed` | executor task 验证失败且未重试 | `toolchain_handler.py` `post_complete``_update_task_state_status()` |
**关键路径说明**
1. **sub Issue 创建**`_ensure_task_state` 写入 `status='pending'`
2. **sub Issue 对应的 executor task 完成** → ToolchainHandler 通过 @mention 指引 agent 创建 PR(含 `Closes #N`)→ PR 合并时 Gitea 自动关闭 Issue → webhook `issues/closed` → 更新 `status='done'`
3. **executor task 失败**`ToolchainHandler.post_complete()` 中检测 verify 失败且 retry_count 达上限 → 调用 `_update_task_state_status(issue_number, 'failed')`
**`post_complete` 何时触发**spawner 完成 session 后(agent exit),ticker 在下个 tick 检测到 session 终态 → 调用 `handler.post_complete(task_id, agent_id, outcome, db_path)`。此时 handler 可以:
- 检查 verify 结果
- 如果 failed 且不可 retry → 查 task_state 找到对应 issue_number → 更新 status='failed'
**新增方法**
```python
# toolchain_handler.py
def _update_task_state_status(self, issue_number: int, status: str):
"""更新 task_state.statusexecutor 失败时调用)"""
tc_db = _toolchain_db_path()
conn = get_connection(tc_db)
try:
conn.execute(
"UPDATE task_state SET status=?, updated_at=datetime('now') "
"WHERE issue_number=?",
(status, issue_number)
)
conn.commit()
finally:
conn.close()
```
**toolchain_routes.py closed 分支补充**
```python
elif action == "closed":
# 更新 task_state status='done'
tc_db = _toolchain_db_path()
conn = get_connection(tc_db)
try:
conn.execute(
"UPDATE task_state SET status='done', updated_at=datetime('now') "
"WHERE issue_number=?",
(issue_number,)
)
conn.commit()
finally:
conn.close()
# ... 现有通知逻辑
```
> **注意**`get_tc_subtask_summary` 中的 `all_terminal` 只依赖 `done` 和 `failed` 两种终态。`pending`/`working` 计入 `other`,确保未完成的 sub 不会误触发 round review。
### 分流方案(全链路)
```
_check_round_complete:
handler = TaskTypeRegistry.get_by_project(project_id)
is_tc = handler and handler.virtual_project == "_toolchain"
# 1. 数据查询分流
if is_tc and parent_id in task_state_parents:
summary = get_tc_subtask_summary(tc_db, int(parent_id))
# 不查 outputs/comments — 庞统 spawn 后自己读 Gitea API
else:
summary = bb.get_subtasks_summary(parent_id)
outputs = bb.get_aggregate_outputs(parent_id)
comments = bb.get_round_comments(parent_id)
# 2. prompt 构建分流
if is_tc:
review_prompt = handler.build_round_review_prompt(parent_id, summary, ...)
else:
review_prompt = self._build_review_prompt(parent_task, summary, outputs, comments, ...)
# 3. round_count 递增分流
if spawned:
if is_tc:
UPDATE task_state SET round_count = round_count + 1 WHERE issue_number = ?
else:
bb.increment_round_count(parent_id)
```
### BaseTaskHandler 接口扩展
`BaseTaskHandler``task_type_registry.py`)中新增默认方法:
```python
def build_round_review_prompt(self, parent_id: str, summary: dict,
outputs: list, comments: list,
round_num: int, **kwargs) -> str:
"""构建 Round Review prompt。默认 raise NotImplementedError。"""
raise NotImplementedError(
f"{self.task_type} handler does not support round review")
```
普通 task 和 mail 不调用此方法(走 ticker 原有 `_build_review_prompt`),只有 ToolchainHandler override。
### ToolchainHandler.build_round_review_prompt
TC 版本的 Round Review prompt。核心区别:
| 维度 | 黑板版(原) | Gitea 版(新) |
|------|-----------|-------------|
| Goal 来源 | `parent_task.description`(黑板 DB | parent Issue body(庞统自己读 Gitea API |
| 成果物 | `bb.get_aggregate_outputs()`(黑板 DB | PR / commit(庞统自己读 Gitea API |
| 讨论历史 | `bb.get_round_comments()`(黑板 DB | Issue comments(庞统自己读 Gitea API |
| 创建新 sub | `POST localhost:8083/api/.../tasks`(黑板 API | `POST Gitea /repos/.../issues`Gitea API |
| 关闭 parent | 更新黑板 status | `PATCH Gitea /repos/.../issues/{N}` |
**prompt 设计**:只给 Issue 编号和 repo,让庞统 spawn 后自己调 Gitea API 读详情。daemon 不做 Gitea API 调用。
```
## 庞统 TC Round Review(第 {round_num} 轮)
### Parent Issue
- Repo: {repo}
- Issue: #{parent_issue_number}
### 本轮 Sub Issue 状态
- 完成: {done}
- 失败: {failed}
- 总计: {total}
### 你必须做什么
1. 读 parent Issue: GET /repos/{repo}/issues/{parent_issue_number}
2. 读所有 sub Issue 的 comments 和 PR
3. 三问评估:
- Goal 还清晰吗?
- 成果物覆盖 goal 了吗?(逐条检查 + docs/design 同步)
- 下一轮需要做什么?
4. 决策:
- GOAL_ACHIEVED → 关闭 parent Issue
- 需要新轮 → 创建新 sub Issues
### Gitea API
- 读 Issue: GET /repos/{repo}/issues/{N}
- 读 comments: GET /repos/{repo}/issues/{N}/comments
- 创建 sub Issue: POST /repos/{repo}/issues
- 关闭 Issue: PATCH /repos/{repo}/issues/{N} (state=closed)
```
### 不影响黑板流程的保证
1. **Handler 分流**`_check_round_complete` 通过 `TaskTypeRegistry.get_by_project(project_id)` 判断。普通项目(无 handler)走 `_build_review_prompt`(黑板版本不变)
2. **数据隔离**TC review 读 `task_state` 表 + Gitea API;黑板 review 读 `tasks` 表 + `outputs` / `comments`
3. **prompt 隔离**TC prompt 只含 Gitea API 指引;黑板 prompt 只含黑板 API 指引
4. **Handler 分流模式已在 `_dispatch_reviews` 中验证可行**L1379 `if handler: return []` 用 Handler 分流跳过 handler 项目的 PR Review 流程,TC Round Review 采用相同模式
### 实现方案
| 文件 | 改动 |
|------|------|
| `task_type_registry.py` | `BaseTaskHandler``build_round_review_prompt` 默认方法(raise NotImplementedError |
| `toolchain_handler.py` | `ToolchainHandler` override `build_round_review_prompt` → 返回 Gitea API 版 prompt;新增 `get_tc_subtask_summary()` 静态方法 |
| `ticker.py` | `_check_round_complete` 全链路分流:数据查询 + prompt 构建 + round_count 递增 |
| `toolchain_routes.py` | `_handle_issues` closed 分支:更新 `task_state.status = 'done'` |
### get_tc_subtask_summary 方法设计
```python
def get_tc_subtask_summary(tc_db: Path, parent_issue: int) -> Optional[dict]:
"""TC 路径:从 task_state 表查子任务状态摘要"""
conn = get_connection(tc_db)
try:
parent_row = conn.execute(
"SELECT round_count, status FROM task_state WHERE issue_number = ?",
(parent_issue,)
).fetchone()
if not parent_row:
return None
rows = conn.execute(
"SELECT status, COUNT(*) as cnt FROM task_state "
"WHERE parent_issue = ? GROUP BY status",
(parent_issue,)
).fetchall()
if not rows:
return None
summary = {
"parent_id": str(parent_issue),
"parent_status": parent_row["status"], # 从 task_state 读取实际状态
"round_count": parent_row["round_count"],
"total": 0, "done": 0, "failed": 0, "cancelled": 0, "other": 0,
}
for row in rows:
summary["total"] += row["cnt"]
if row["status"] in ("done", "failed", "cancelled"):
summary[row["status"]] += row["cnt"]
else:
summary["other"] += row["cnt"]
summary["all_terminal"] = summary["other"] == 0 and summary["total"] > 0
return summary
finally:
conn.close()
```
### TaskState round_count 同步
TC 路径的 `round_count` 存在 `task_state` 表中。`_check_round_complete` 需要在 spawn review 成功后递增:
```python
if handler and handler.virtual_project == "_toolchain":
# TC 路径:递增 task_state.round_count
conn.execute("UPDATE task_state SET round_count = round_count + 1 WHERE issue_number = ?", ...)
else:
# 黑板路径:原有 bb.increment_round_count(parent_id)
```
@@ -0,0 +1,124 @@
# 事故调查报告:daemon ticker「停摆 8 小时」指控(2026-09-02/03
**调查人**:姜维(jiangwei-infra subagent,只读调查)
**结论**:⚠️ **不存在 daemon ticker 停摆。所谓「8 小时零 tick」是 SQLite UTC 时间戳被误读为本地时间(GMT+8)造成的误报,时差恰好 8 小时。** 调查期间 ticker 全程健康运行,每 30 秒一轮 tick,从未中断。
---
## ① 时间线(已全部换算为本地时间 Asia/ShanghaiDB 原始时间戳为 UTC
| 本地时间 | 事件 | 证据来源 |
|---|---|---|
| 09-02 05:02:3105:03:55 | simayi→pangtong 两封邮件正常 dispatch→spawn→completed→marked done,闭环完成 | daemon.err.log L3525-3539 |
| 09-02 05:14:35 | `Zombie detected: _mail (stale=20)`(凌晨无邮件活动,连续 20 tick 无非 tick 事件的**常规告警**09-01 05:21:49 同样出现过) | daemon.err.log L3540 |
| 09-02 05:14 → 09-03 05:01 | 应用日志(daemon.err.log)约 24h 无任何输出 —— 因期间无 INFO 级事件(无 dispatch/spawn/errortick 完成日志为 debug 级不输出)。**期间 ticker 正常运行**DB 中每 30s 一条 daemon_tick,「疑似停摆窗口」内 `_mail` 有 915 条、`sanguo_moziplus_v2` 有 915 条规律 tick | daemon.err.log`data/_mail/blackboard.db``data/sanguo_moziplus_v2/blackboard.db` events 表 |
| 09-03 05:01:5152 | 调查发起方逐项目扫描 `GET /api/projects/*/tasks?status=failed`22 个项目) | daemon.out.log |
| 09-03 05:03:00 | 调查发起方 `POST /api/mail` 创建 `mail-1788382980110`(标题:「黑板 daemon 疑似停摆 8h09-02 21:01 起零 tick),请调查根因」) | daemon.out.log`_mail` DBcreated_at=**2026-09-02 21:03:00 UTC** = 本地 09-03 05:03:00 ✅ |
| 09-03 05:03:21 | 下一个 30s ticktick 18292**正常调度**该邮件:`Phase 2 session check for jiangwei-infra``Spawned agent jiangwei-infra (pid=36795)``Dispatched mail-1788382980110` → 邮件事件使 _mail 有真实变更 → `Zombie resolved: _mail`。**这是 ticker 主循环的常规调度路径,不是「API 旁路」** | daemon.err.log`_mail` DB 同 tick18292daemon_tick |
| 09-03 05:04:2505:04:56 | 第 18295 轮 tick 遍历 22 个正式项目(31s/轮)——被误读为「最后 tick 21:04:25/21:04:56」 | 各项目 DB daemon_tickUTC 21:04:25/21:04:56 |
| 09-03 05:0505:13(调查进行中) | ticker 持续 tick18296→18310`GET /api/daemon/status` 返回 `{"ticker_running":true,"tick_count":18310}`;调查查询 SQLite 时多次遇到 `database is locked`daemon 正在写入) | API 实测;`_mail` DB daemon_tick 至 UTC 21:12:03(本地 05:12:03)仍在推进 |
| 09-03 05:07:39 | 上述邮件 agent completed 但未回信 → request 类型 verify(no_reply) → 标 failed → 自动创建「[投递失败]」通知邮件(**行为问题,非 daemon 故障**,见 ④-6 | `_mail` DBagent_completed/status_change/task_created @UTC 21:07:39 |
## ② 证据清单
1. **`src/blackboard/db.py:82`**`created_at TEXT NOT NULL DEFAULT (datetime('now'))` —— SQLite `datetime('now')` 返回 **UTC**。所有黑板 DB 时间戳均为 UTC。
2. **`src/main.py` logging 配置**`_setup_logging``datefmt="%Y-%m-%d %H:%M:%S"`)—— Python logging 使用**本地时间**Asia/Shanghai)。两套时间系统相差 8 小时。
3. **tick 连续性**(决定性反证):`sqlite3 data/_mail/blackboard.db`UTC 13:0021:00=本地 09-02 21:0009-03 05:00,即所谓停摆窗口)daemon_tick **915 条**min=13:00:03、max=20:59:56,间隔 30s 规律分布;`sanguo_moziplus_v2` 项目同窗口同样 **915 条**。UTC 12:30–14:00 抽样逐条核验,无任何空洞。
4. **ticker 当前状态**`GET /api/daemon/status``ticker_running=true, tick_count=18310`(调查时刻),与 DB 最新 daemon_ticktick 18308 @UTC 21:12:03)吻合。
5. **05:03:21 调度日志与 DB 同 tick 对齐**`_mail` DB tick 18292 的 daemon_tick 落在 UTC 21:03:21,与 `Dispatched mail-1788382980110``Zombie resolved: _mail` 同秒——三者都出自 `_tick_project("_mail")` 单次执行(dispatch→写 tick→health check),证明是主循环常规路径。
6. **mail-1788382980110 创建时间**`_mail` DB `task_created @UTC 2026-09-02 21:03:00` = 本地 09-03 05:03:00,与 daemon.out.log `POST /api/mail` 访问日志完全一致。
7. **macOS sample/tmp/sanguo_daemon_sample.txt**
- 主线程:uvloop `uv_run/uv__io_poll(kevent)` + `uv__run_idle` 正常轮转;
- 主线程 2 秒采样中 91/1571 samples(≈6%)在执行一个 Python 回调,其中 77 samples 深入 `sqlite3_step → sqlite3VdbeExec → sqlite3BtreeCount → pread`(对大表做 `COUNT(*)` 全表扫描,见 ④-5);
- Thread_4920533:卡在 `PyThread_acquire_lock_timed → _pthread_cond_wait`。**源码 grep 全仓无任何 `threading.Thread`/`run_in_executor`/`queue.Queue` 用法**,应用层不创建工作线程;结合 FastAPI/Starlette 架构,这是 **anyio 的空闲 worker 线程在等待新任务**(sync 端点线程池),属正常空闲态,与停摆无关。
8. **代码一致性**`diff -rq` 开发目录与安装目录 `src/` **零差异**(运行版本 = 开发版本)。
9. **任务状态**:全部 25 个 DB 中无任何 pending/claimed/working/blocked 卡住任务。
10. **git log**daemon 相关最近提交(35959e1 §22 P1+P2、dc1d444 §22 P0 等)均早于事发且无异常回滚。
## ③ 根因分析
### 直接根因:时区误读(UTC vs 本地 GMT+8,差值恰为 8 小时)
误判链条还原:
1. 调查发起方查询各项目 DB `SELECT MAX(created_at) FROM events WHERE event_type='daemon_tick'`,得到 `2026-09-02 21:04:56`**UTC**,即本地 09-03 05:04:56 —— 查询前 2~3 分钟,ticker 刚写的)。
2. 该值被当作**本地时间**解读 →「21:04 起零 tick」→ 与当下 05:07 相差约 8 小时 →「停摆 8 小时」。
3. 「遍历第一个项目 sanguo_moziplus_v2 = 21:04:25、最后一个 = 21:04:56」实为**同一轮正常 tick**22 项目 31s,与 30s tick_interval + 1s 工作量完全一致)。
4. 「此后仍无新 daemon_tick 写入」不成立:tick 1829618310 在 UTC 21:05:2721:12:03 持续写入(发起方可能再次被 UTC 混淆,或在少数项目上抽查时机不巧遇到 `database is locked`)。
5. 交叉印证全部自洽:
- 「进程未崩溃、API 正常、主线程 uvloop 正常」—— 因为服务本就健康;
- 「连 `Tick error` 都没触发」—— 因为根本没有错误发生;
- 「05:03:21 恢复活动」—— 不是恢复,是 30s tick 命中了刚创建的邮件任务;
- 「Zombie _mail 每天凌晨告警、次日 dispatch 时 resolved」—— health.py 逻辑(连续 20 tick=10 分钟无非 tick 事件即告警),凌晨无邮件 → 常规告警;09-02 05:14 告警后因 _mail 长时间无真实事件,直到 09-03 05:03 新邮件才触发 resolved。与停摆无因果。
### 证据文件:行号
| 事实 | 位置 |
|---|---|
| DB 时间戳 = UTC | `src/blackboard/db.py:82``DEFAULT (datetime('now'))` |
| 日志时间戳 = 本地 | `src/main.py` `_setup_logging()` Formatter |
| tick 主循环 30s | `src/daemon/ticker.py` `Ticker.__init__/tick_interval``_loop()` |
| dispatch 日志("Dispatched %s to %s" | `src/daemon/ticker.py:1058/1075``_dispatch_pending` |
| Zombie 告警/解除 | `src/daemon/health.py` `_write_alert/_write_resolution`stale 单位 = tick 数,阈值 20 |
| 邮件创建仅 create_task、由 ticker 调度 | `src/api/mail_routes.py` `send_mail()`(无 dispatch 逻辑,旁路指控不成立) |
| 调度-写tick-健康检查同轮次序 | `src/daemon/ticker.py` `_tick_project()` 步骤 4/9/8' |
### 已排除项
- ❌ counter/Semaphore 死锁(`counter.acquire``can_acquire` 先检后取,无无限等待路径)
- ❌ spawner/monitor 卡死(monitor 全程 `asyncio.wait_for` 超时保护)
- ❌ 事件循环阻塞(uvloop 主线程 kevent 正常)
- ❌ Thread_4920533 异常(anyio 空闲 worker 正常态)
- ❌ 代码版本漂移(开发/安装目录零 diff)
- ❌ SQLite 锁死(`database is locked` 是调查方与 daemon 写入的瞬时竞争,且均为调查进程侧报错)
### 区分方法(若未来再现类似指控)
```bash
# 1) 看绝对最新 tick(注意 DB 是 UTC,本地=UTC+8
sqlite3 data/_mail/blackboard.db \
"SELECT datetime(created_at,'+8 hours'), detail FROM events WHERE event_type='daemon_tick' ORDER BY created_at DESC LIMIT 3"
# 2) 看 ticker 内存状态(无需任何推理)
curl -s localhost:8083/api/daemon/status
```
若 status 显示 `ticker_running=true` 且 tick_count 在两次查询间递增,即可直接否定「停摆」。
## ④ 次要发现(真实存在、值得处理的问题)
1. **可观测性缺陷(本次误报的温床)**:黑板 DB 存 UTC、日志存本地时间、无任何文档标注;且 ticker 正常轮转时**完全静默**(`Tick %d complete` 是 debug 级,`main.py:49` 设 INFO 不输出),导致「无日志 = 疑似挂死」的错误直觉。
2. **events 表无限膨胀 + 每轮 tick 全表 COUNT**:每项目 events 已 22–27 万行(6 天累积,其中 daemon_tick 占绝对多数)。`src/daemon/health.py:check()` 每轮 tick 对每项目执行 `SELECT COUNT(*) FROM events` 及带 WHERE 的 COUNT —— sample 显示该扫描已占主线程约 6% 采样时间(`sqlite3BtreeCount→pread`)。按当前增速会持续恶化,最终可能显著拖慢 tick 甚至长时间阻塞事件循环。
3. **`_check_session_state` 每次读 gateway 日志尾部 2MB + session jsonl 尾部 1MB**:每次 spawn 前同步文件 IO,随日志增大变慢(目前未致命)。
4. **邮件 request 类型验证过严**:本例 agent 回复了文本 payload 但未通过 `POST /api/mail` 回信,被 verify 判 `no_reply` → failed → 自动发「投递失败」邮件(05:07:39),对发起方造成二次困扰,也污染了本次判断。
5. **Zombie 告警语义误导**`stale=20` 的单位是 tick 数(约 10 分钟),凌晨空闲期每天必然告警一次,属告警噪声。
6. **调查方 SQLite 直查与 daemon 写入竞争**会得到 `database is locked`,容易误判为「锁死」。建议只读调查加 `-readonly` 打开(`sqlite3 "file:xxx.db?mode=ro"`)。
## ⑤ 恢复方案建议(只建议,未执行)
- **无需恢复**:服务健康,PM2 restarts=0tick 持续。不要重启。
- 可选:将 `mail-1788382980110` 及其「[投递失败]」通知邮件标记已读/取消(`PATCH /api/mail/{id}`),避免后续再次触发 spawn。
- 可选:对 mail 模板加一句「如需回复请务必走 POST /api/mail」的强调(`src/daemon/mail_handler.py` MailContextSection),缓解 ④-4。
## ⑥ 预防建议
1. **统一时间语义**(治本):
- 短期:所有 DB 查询工具/文档标注「created_at 为 UTC」;给运维巡检提供 `datetime(created_at,'+8 hours')` 的惯用查询;
- 中期:`get_connection` 写入统一 `datetime('now','localtime')` 或迁移 ISO8601 带 `+08:00` 后缀(需一次性数据迁移,建议排期设计)。
2. **ticker 心跳可观测**:将 `Tick %d complete` 从 debug 提到 INFO(或每 N=10 轮打一条 INFO 心跳),并在 `/api/daemon/status` 增加 `last_tick_at`(本地时间)字段 —— 任何人一条 curl 即可自证清白,本次误报可在 30 秒内排除。
3. **daemon_tick 事件瘦身/归档**daemon_tick 写入 events 是主要膨胀源。建议(任选):a) 每 N 轮写一条汇总 tick;b) 建立每日 cron 归档/删除 7 天前的 daemon_tick 行;c) health.py 的 COUNT 改为维护计数表或 `SELECT MAX(rowid)` 估算,消除每轮全表扫描。
4. **watchdog(若仍担心静默挂死)**:外部(PM2/cron)每 5 分钟 curl `/api/daemon/status`,断言 `tick_count` 递增;连续 2 次不递增才告警 —— 基于计数而非时间戳,天然免疫时区问题。
5. **health.py zombie 告警降噪**:凌晨时段(00:00–07:00)提高阈值或静默,或告警文案注明「空闲属正常」。
---
### 附:原始证据核对表(发起方证据 → 实际含义)
| 发起方证据(原文) | 实际含义(时区纠正后) | 是否成立 |
|---|---|---|
| 22 项目最后 daemon_tick = 21:04:56DB | 本地 09-03 05:04:56,查询前 2~3 分钟的正常最新 tick | ❌ 误读 |
| 第一个项目 tick = 21:04:25 | 同一轮 tick 的起点(22 项目 31s),非「遍历开始」异常 | ❌ 误读 |
| 09-02 21:01 起零 tick | UTC 12:3014:00(本地 20:3022:00)实测每 30s 有 tick,无空洞 | ❌ 不成立 |
| 05:03:21 恢复活动(API 旁路) | 30s tick 常规调度新邮件,dispatch/daemon_tick/zombie-resolved 同 tick 出现 | ❌ 并非旁路 |
| 此后仍无新 daemon_tick | tick 1829618310 持续写入(UTC 21:0521:12 | ❌ 不成立 |
| 8h 无报错日志(连 Tick error 都没有) | 24h 无 INFO 级事件属正常静默;无错误因为无错误发生 | ❌ 误读 |
| Zombie _mail stale=20 每天 05:1x 告警 | health.py 凌晨空闲常规告警(stale 单位=tick 数) | ✅ 现象真实,但与停摆无关 |
| sample Thread_4920533 卡锁 | anyio 空闲 worker 线程等待任务,正常态 | ❌ 干扰项 |