# 02-main-session-delegation.md — Main Session + Delegation 架构

**日期**: 2026-05-30
**作者**: 庞统
**状态**: 已修订 v1.1（根据司马懿 2026-05-30 评审意见）
**前置**: `01-four-phase-loop.md`（四相循环 E2E 验证暴露 session 爆炸问题）

---

## 一、问题：现行 spawn 方案的 session 爆炸

### 现状

spawner 通过 `openclaw agent --session-id UUID --message "..." --json` spawn Agent：

```
spawner → 新 UUID session → Agent 执行 → 进程退出 → session 残留
```

### 问题

| 问题 | 证据 | 影响 |
|------|------|------|
| **Session 爆炸** | 今天 E2E 测试 6 个 Agent 产生 68 个 session（16~484 每个），其中 56 个 `gateway-fallback-*` | 文件系统堆积、磁盘浪费 |
| **空 payloads** | 绝大部分 spawn 返回 `status: None, payloads: []` | Daemon 以为任务完成了但实际没有产出 |
| **上下文丢失** | 每次 spawn 新 session，Agent 无历史、无 workspace bootstrap | Agent 不知道自己是谁、不知道之前的决策 |
| **续杯浪费** | 续杯时 `reuse_session_id` 复用 UUID，但 Gateway 可能又创建 fallback | round_count 空转、Agent busy 但不产出 |

### 根因

`openclaw agent --session-id UUID` 的语义是"在指定 session 里执行 turn"，但 Gateway 的实际行为是 find-or-create：
- session 不存在 → 创建 `gateway-fallback-<UUID>` session
- 每次都是全新上下文
- Agent 没有历史，没有 SOUL.md 等身份注入

**这是结构性问题，不是 bug。** 在这个方案上继续修 bug 没有意义。

---

## 二、方案：Main Session + Delegation

### 核心思路

```
现行：Daemon 硬编码步骤 → spawner 直接 spawn 新 session → Agent 被动执行

改为：Daemon 投递任务 → Agent main session 收到 → Agent 自主分析 → sessions_spawn(subagent) 执行 → sub 完成 → Agent review → 写回黑板
```

### 架构对比

```
┌─ 现行 ──────────────────────────────────────────┐
│                                                  │
│  Daemon/Ticker                                   │
│  ├── broadcast → spawn_full_agent(UUID session)  │
│  │   → Agent 执行固定步骤 prompt                  │
│  │   → 空 payloads / session 残留                 │
│  ├── mention  → spawn_full_agent(UUID session)   │
│  └── review   → spawn_full_agent(UUID session)   │
│                                                  │
└──────────────────────────────────────────────────┘

┌─ 改为 ───────────────────────────────────────────┐
│                                                   │
│  Daemon/Ticker                                    │
│  ├── broadcast → 投递到 Agent main session        │
│  ├── mention  → 投递到 Agent main session         │
│  └── review   → 投递到庞统 main session           │
│                                                   │
│  Agent Main Session（有完整身份+上下文）            │
│  ├── 收到任务消息                                  │
│  ├── 分析黑板（API 读任务详情/上下文/依赖）          │
│  ├── sessions_spawn(subagent, cleanup: "delete")  │
│  │   └── sub 执行具体工作 → 完成后自动清理          │
│  ├── sessions_yield → 等待 sub 完成               │
│  ├── review sub 结果                               │
│  └── 写回黑板（产出/状态/评论）                      │
│                                                   │
└───────────────────────────────────────────────────┘
```

### 三条路径的变化

| 路径 | 现行 | 改为 |
|------|------|------|
| **Broadcast dispatch** | `spawn_full_agent(session_id=UUID)` 新 session | `spawn_full_agent(use_main_session=True)` main session |
| **Mention** | `spawn_full_agent(session_id=UUID)` 新 session | `spawn_full_agent(use_main_session=True)` main session |
| **Review**（庞统） | `spawn_full_agent(session_id=UUID)` 新 session | `spawn_full_agent(use_main_session=True)` main session |

---

## 三、关键机制

### 3.1 投递到 Main Session

**方式**：`spawn_full_agent(use_main_session=True)`（不传 `--session-id`）

底层命令行：`openclaw agent --agent <id> --message "..." --json --timeout N`

Gateway 行为（已确认）：
- 不传 `--session-id` → 投递到 Agent 的 main session（`agent:<id>:main`）
- main session 正忙 → 消息排队，Agent 当前 turn 完成后处理
- Agent 有完整上下文（SOUL.md / MEMORY.md / workspace bootstrap）

**调用链路**：

```
spawner.spawn_full_agent(use_main_session=True)
  → session_id = None（不传 --session-id）
  → counter.acquire(agent_id, "main")
  → asyncio.create_subprocess_exec("openclaw", "agent", "--agent", "xxx", ...)
  → asyncio.create_task(_monitor_process(...))  # 异步不阻塞
  → 返回 "main"

_monitor_process()
  → await proc.wait() + 读 stdout  # 异步等子进程退出
  → 子进程退出 → _handle_exit()
    → 解析 --json 输出
    → on_complete 回调（幻觉门控、标状态等）
    → counter.release(agent_id, "main")
```

**关键：`openclaw agent` 子进程内部是同步阻塞的**——它会同步等 Gateway 投递消息到 main session → Agent 执行 turn → 回复 → 子进程退出。所以 `on_complete` 只在 Agent 真正完成 turn 后才触发。

### 3.2 Delegation（subagent-delegation skill）

Agent 收到任务后，用 `sessions_spawn` 创建 subagent 执行具体工作：

```javascript
sessions_spawn({
  task: "任务目标\n\n上下文信息",
  cleanup: "delete",    // 完成后自动清理 session
  model: "zhipu/glm-5.1",
  label: "T1-01"
})
```

**关键特性**：
- `cleanup: "delete"` → sub 执行完 session 立即删除（transcript 保留可查）
- main session 通过 `sessions_yield` 等待 sub 完成（**非阻塞**，可被新消息打断，Gateway 还能投递新消息）
- sub 的结果通过完成事件回传给 main session
- subagent 是 Agent 自己 spawn 的，**不经过 Daemon counter**

### 3.3 续杯机制

改用 main session 后，续杯逻辑简化：

| 维度 | 现行 | 改为 |
|------|------|------|
| 续杯 session | `reuse_session_id=UUID` 复用原 session | `use_main_session=True` 重新投递到 main session |
| 上下文 | 新 session 无历史 | main session 有完整历史 |
| counter | acquire 新 session key | acquire 同一个 "main" key |
| 失败处理 | 重试时可能创建新的 fallback session | 直接重投 main session，无 fallback 问题 |

**改动**：`_do_retry` 中把 `reuse_session_id` 改为 `use_main_session=True`，移除 `reuse_session_id` 参数。

### 3.4 Counter（无需改动）

改用 main session 后 counter 逻辑不变：
- 所有 spawn 走同一个 session key（`"main"`）
- `max_per_session=1` 保证同一时间只有一个 active turn
- `max_concurrent_sessions=3` 限制同一 Agent 最多 3 个不同 session（main + 可能的 sub）
- `max_global=5` 限制全局并发

**注意**：subagent 是 Agent 自己 spawn 的，不经过 Daemon counter。Daemon 只控制 main session 的投递。

---

## 四、Prompt 设计

### 4.1 投递消息格式

Daemon 投递到 main session 的消息包含足够的上下文，让 Agent 自主决策：

```markdown
📋 新任务到达

**任务**: {title}
**描述**: {description}
**Project**: {project_id}
**Task ID**: {task_id}
**类型**: {task_type} | **风险**: {risk_level}
**验收标准**: {must_haves}
**依赖产出**: {depends_on_outputs_summary}

**黑板 API**: http://localhost:8083/api/projects/{project_id}
- 读任务详情: GET /api/projects/{project_id}/tasks/{task_id}?expand=all
- 写产出: POST /api/projects/{project_id}/tasks/{task_id}/outputs
- 写评论: POST /api/projects/{project_id}/tasks/{task_id}/comments
- 更新状态: POST /api/projects/{project_id}/tasks/{task_id}/status

**约束**:
- 完成后必须写产出 + 标 review
- 失败了标 failed 并写明原因
- 优先使用 subagent-delegation skill 进行具体执行
- 禁止使用 sessions_send 直接发消息
```

### 4.2 Agent 自主决策

Agent 收到消息后的自主行为（不是硬编码步骤）：

1. **分析上下文**：读黑板 API 获取任务详情、依赖、历史讨论
2. **决定执行策略**：
   - 简单任务 → 自己直接做
   - 复杂任务 → `sessions_spawn` 拆分 sub task
   - 需要协作 → @mention 其他 Agent
3. **执行**：通过 subagent 或直接执行
4. **Review**：检查 sub 结果质量
5. **写回**：产出 + 状态 + handoff comment（handoff comment 必须 ≥ 50 字符，用于幻觉门控第三信号检测）

### 4.3 消息优先级与中断策略

Agent main session 可能同时收到多种消息（新 task / mention / review），按以下优先级处理：

**优先级顺序**（Prompt 中明确告知 Agent）：
1. 🔍 **Review 消息**（庞统的打回重做 / GOAL_ACHIEVED）— 最高优先级，先处理再继续当前工作
2. 💬 **Mention 消息**（其他 Agent 的协作请求）— 高优先级，完成当前 sub task 后立即处理
3. 📋 **新任务消息**（ticker 投递的广播任务）— 普通优先级，排队处理

**中断策略**（Prompt 中明确告知 Agent）：
- 收到 review 消息时 → 暂停当前 sub task，先完成 review，再决定是否继续
- 收到 mention 消息时 → 完成当前 sub task（如果 sub 正在执行），然后处理 mention
- 收到新任务消息时 → 排队等待，当前任务完成后再处理
- 任何时候一个任务完成后，检查是否有更高优先级消息排队

**队列管理**：
- Agent 自己维护任务队列（在对话中记忆），不需要 Daemon 管理
- 每一步开始前先检查新消息，如果有高优先级消息就调整顺序
- 完成当前任务后，从队列取下一个

### 4.4 Subagent 背压控制

Agent 同时 spawn 的 subagent 数量有硬性上限：

| 参数 | 值 |
|------|-----|
| 单轮同时 sub 上限 | 3 |
| 单任务累计 sub 上限 | 8 |

超限时 subagent-delegation skill 自动拒绝，并在 prompt 中提醒 Agent："已超出 subagent 数量限制，请等待现有 sub 完成后再创建新 sub。"

在 subagent-delegation skill 中通过计数器维护（Daemon 不参与 subagent 计数）。

### 4.5 庞统 Review 消息格式

```markdown
🔍 一轮结束，需要你的 review

**Parent Task**: {title}（ID: {task_id}）
**Project**: {project_id}
**Round**: {round_num}/{max_rounds}

**本轮状态**:
- 完成: {done} | 失败: {failed} | 取消: {cancelled}
- 总计: {total}

**三问**:
1. Goal 还清晰吗？（是否有 goal drift）
2. 成果物覆盖 goal 了吗？（逐条检查验收标准）
3. 下一轮需要做什么？（创建新 sub tasks / 标记完成 / 调整方向）

**你可以**:
- 创建新 sub task: curl -X POST http://localhost:8083/api/projects/{project_id}/tasks ...
- 调整 goal
- 标记 GOAL_ACHIEVED

回复 GOAL_ACHIEVED 表示完成，否则系统会等待你创建新 sub tasks。
```

---

## 五、Daemon 侧改动

### 5.1 改动范围

**不需要新增方法**。直接复用 `spawn_full_agent(use_main_session=True)`，Mail 路径已验证此模式。

改动集中在调用侧（把参数改对）和回调侧（把完成检测从邮件改为黑板）：

| 文件 | 改动点 | 改动量 |
|------|--------|-------|
| `ticker.py` | 3 处 `spawn_full_agent` 调用加 `use_main_session=True`；移除 `new_session=True` | ~15 行 |
| `dispatcher.py` | 确定性路由改 `use_main_session=True`；新增 `_task_auto_complete` | ~40 行 |
| `spawner.py` | `_do_retry` 续杯改 `use_main_session=True`；标注 `new_session`/`reuse_session_id` 废弃 | ~10 行 |
| `ticker.py` | 新增 `_task_verify_completion`（第一层幻觉门控）；review 前跑 `_verify_output_files`（第二层） | ~50 行 |

**总改动量**：~115 行，涉及 3 个文件。不涉及 DB schema 变更、不涉及前端。

### 5.2 Ticker 改动

| 方法 | 改动 |
|------|------|
| `_broadcast_and_dispatch` | `spawn_full_agent(..., use_main_session=True)` 替代当前的默认参数 |
| `_process_mentions` | 同上 |
| `_spawn_pangtong_review` | 同上 |
| `_check_round_complete` | 保留 `reviewing` 中间态逻辑（SYS-BUG-01 修复） |
| `_handle_review_conclusion` | 保留，从 `on_complete` 回调中的黑板状态变化触发 |
| 新增 `_task_verify_completion` | 第一层幻觉门控（三信号检查） |
| 新增 `_verify_output_files` | 第二层幻觉门控（文件存在性检查） |

### 5.3 Dispatcher 改动

- 确定性路由改 `use_main_session=True`
- 新增 `_task_auto_complete`（复用 Mail 的 `_mail_auto_complete` 模式）
- `new_session` 参数标注废弃

### 5.4 废弃代码

- `spawn_full_agent` 的 `new_session` 参数 → 标注废弃（保留接口兼容，变更前 `grep -rn 'new_session'` 确认零调用方后标废弃）
- `reuse_session_id` 参数 → 标注废弃（续杯改用 `use_main_session=True`；变更前 `grep -rn 'reuse_session_id'` 确认零调用方）
- `gateway-fallback-*` session 的产生路径 → 自然消失（不再传 `--session-id UUID`）

### 5.5 `on_complete` 回调路由

`on_complete` 回调中，根据 `task_type` 分支选择检测逻辑：

```python
def _handle_completion(self, task, db_path):
    if task["type"] == "mail":
        return self._mail_auto_complete(task, db_path)
    else:
        return self._task_auto_complete(task, db_path)
```

`_task_auto_complete` 检查黑板三信号（status/outputs/comments），`_mail_auto_complete` 检查是否有回复邮件。两者共享超时兜底逻辑（ticker 轮询 working 状态）。

---

## 六、Session 生命周期对比

### 现行

```
每次 spawn → 创建新 session → 执行 → 残留 → 手动清理
                                          ↓
                              gateway-fallback-* 堆积
```

### 改为

```
main session（常驻，有完整上下文）
  ├── 收到任务 A → spawn sub(A1, cleanup: delete) → yield → review → 写回
  ├── 收到任务 B → spawn sub(B1, cleanup: delete) → yield → review → 写回
  └── 收到 mention → 自主处理 → 写回

sub session（临时，完成即删）
  ├── sub(A1) → 执行 → 完成 → session 删除（transcript 保留）
  └── sub(B1) → 执行 → 完成 → session 删除（transcript 保留）
```

**Session 数量**：6 个 main session（常驻）+ 临时 sub session（用完即删）
**对比现行**：6 个 main + N 个 `gateway-fallback-*`（无限增长）

---

## 七、收益与风险

### 收益

| 维度 | 改善 |
|------|------|
| **Session 数量** | 从无限增长 → 6 个 main + 临时 sub |
| **Agent 上下文** | 从无历史 → 完整身份 + 记忆 + workspace |
| **代码简化** | `new_session` / `reuse_session_id` 不再有调用方 |
| **Prompt 灵活度** | 从硬编码步骤 → Agent 自主决策 |
| **空 payloads** | 消除（main session 有完整生命周期管理） |
| **续杯可靠性** | 不再创建 fallback session，直接重投 main session |

### 风险与缓解

| 风险 | 缓解 |
|------|------|
| **main session 上下文膨胀** | OpenClaw 自带的 compact 机制处理；sub session 不膨胀（用完删） |
| **main session 被阻塞** | `sessions_yield` 非阻塞，可被新消息打断；Gateway 排队机制保证不丢消息 |
| **sub 质量不可控** | main session review sub 结果；reviewing 中间态保证 round 不重复触发 |
| **Prompt 依赖** | 投递消息中明确告诉 Agent "优先使用 subagent-delegation skill"；skill 已调优 |
| **main session 排队延迟** | Daemon 投递后 `on_complete` 异步等结果；ticker 轮询黑板状态兜底 |
| **main session prompt 污染** | 每次投递消息中明确标注当前任务 ID；Agent 每步开始前确认当前处理的任务；compact 机制清除旧消息 |
| **sub 失败传导** | 投递 prompt 中明确告诉 Agent：sub 失败时不要标 done，先分析失败原因；可重试 ≤ 2 次，然后标 failed 并写原因到 comment |
| **openclaw agent 子进程超时** | `--timeout` 超时后子进程退出，但 main session 中的 Agent turn 可能还在进行。Daemon 遇到超时：不立即标 failed，留 working 等 ticker 下一次轮询检查 |

---

## 八、Mail 路径已验证的完整模式（可直接复用）

Mail 路径（`dispatcher.py` `_mail_on_checks_passed` + `_mail_auto_complete`）已经完整实现了 main session + 完成检测的全链路：

```
openclaw agent --agent xxx（不传 --session-id）→ Gateway 投递到 main session
  → Agent 处理 → 子进程退出
  → _monitor_process → _handle_exit
    → on_complete 回调 → _mail_auto_complete
      → 幻觉门控：检查黑板是否有回复
        → 有回复 → 标 done
        → 无回复 → 留 working，等 ticker 超时兜底再查
    → counter.release(agent_id, "main")
```

### 已验证的机制

| 机制 | Mail 实现 | 普通 Task 复用 |
|------|----------|--------------|
| Main session 投递 | `use_main_session=True` | ✅ 直接复用 |
| `on_complete` 回调 | `_mail_on_complete` → `_mail_auto_complete` | ✅ 改为 `_task_auto_complete`（检查产出/状态而非邮件回复） |
| 幻觉门控 | `_mail_check_reply` 检查回复邮件 | ⚠️ 改为三信号检查（status + outputs + comments） |
| 超时兜底 | ticker 轮询 working 状态 + 再次检查 | ✅ 直接复用 |
| auto-working | `on_checks_passed` 回调中标 working | ✅ 直接复用 |
| Counter acquire/release | `"main"` session key | ✅ 直接复用 |

### 普通 Task 的完成检测

Mail 的 `_mail_auto_complete` 检查"是否有回复邮件"。普通 Task 改为 `_task_auto_complete`，检查黑板三信号（详见第九节幻觉门控方案）。

---

## 九、幻觉门控方案（三层）

### 第一层：Daemon 确定性检查（on_complete 触发）

**触发时机**：`on_complete` 回调（`openclaw agent` 子进程退出时）。
Agent 在 main session 里执行时子进程还活着，不会触发。不存在长任务误判。

**三信号检查**：status / outputs / comments 任一存在即视为有效完成。

```python
def _task_verify_completion(self, task_id, db_path) -> bool:
    """普通 Task 完成验证（泛化 Mail 幻觉门控）"""
    conn = get_connection(db_path)
    try:
        # 信号 1：Agent 已自行更新状态（review/done/failed）
        row = conn.execute(
            "SELECT status FROM tasks WHERE id=?", (task_id,)
        ).fetchone()
        TERMINAL_STATES = {"review", "done", "failed", "cancelled"}
        if not row or row["status"] in TERMINAL_STATES:
            return True  # Agent 自己标了终态

        # 信号 2：outputs 表有产出
        output_count = conn.execute(
            "SELECT COUNT(*) FROM outputs WHERE task_id=?", (task_id,)
        ).fetchone()[0]
        if output_count > 0:
            return True

        # 信号 3：有评论/handoff（Agent 至少写了交接文档，且 ≥ 50 字符）
        comment_count = conn.execute(
            "SELECT COUNT(*) FROM comments WHERE task_id=? AND author != 'system' AND LENGTH(content) >= 50",
            (task_id,)
        ).fetchone()[0]
        if comment_count > 0:
            return True

        # 三无 → 可能是幻觉
        return False
    finally:
        conn.close()
```

**行为**：
- 通过 → 标 review（等待司马懿 review，详见 #01 §4.6）
- 不通过 → 留 working，ticker 重查（最多 3 次，然后标 failed）

**重试计数器存储**：在 tasks 表中复用 `retry_count` 字段（现有字段，用于续杯计数）。Daemon 在 `_task_verify_completion` 返回 False 后递增该字段，达到 3 次后标 failed。不走内存维护（重启丢失）。

### 第二层：Daemon 产出物文件验证（review 阶段触发）

对 `outputs` 表中有 `content_path` 的产出，验证文件是否真实存在。放在 Daemon（确定性代码，成本低、不依赖 AI）：

```python
def _verify_output_files(self, task_id, db_path) -> list[str]:
    """验证产出物文件是否真实存在"""
    conn = get_connection(db_path)
    outputs = conn.execute(
        "SELECT content_path FROM outputs WHERE task_id=? AND content_path IS NOT NULL",
        (task_id,)
    ).fetchall()
    missing = []
    for o in outputs:
        if not Path(o["content_path"]).exists():
            missing.append(o["content_path"])
    return missing
```

**触发时机**：review 阶段（庞统 review 前），Daemon 先跑一次文件验证。
**行为**：missing 非空 → 在黑板 comment 中标注缺失文件，供庞统 review 时参考。

### 第三层：AI 验证（review prompt 中要求）

庞统/司马懿 review 时，在 prompt 中要求：
1. 检查产出是否覆盖 must_haves 中的验收标准
2. 如果产出不可验证，在评论中标注

这一层由 AI 做，成本高但准确度高，只在 review 时触发。

### 三层关系

```
openclaw agent 子进程退出
  → on_complete 回调
    → 第一层（三信号：status / outputs / comments）
      → 有信号 → 标 review
      → 三无 → 留 working（ticker 重查，最多 3 次 → failed）

司马懿 review（_dispatch_reviews 确定性路由）
  → review 通过 → 标 done
  → review 不通过 → 打回 working

庞统 review（_check_round_complete → _spawn_pangtong_review）
  → 第二层（文件存在性：content_path 验证）
  → 第三层（AI 验证：产出是否覆盖 must_haves）
  → GOAL_ACHIEVED → parent done
  → 创建新 sub task → 新一轮执行
  → 不通过 → 打回重做
```

---

## 十、实施计划

### Phase 1：统一投递到 main session + 幻觉门控

- 所有 `spawn_full_agent` 调用改为 `use_main_session=True`（3 处）
- 续杯 `_do_retry` 改为 `use_main_session=True`
- 新增 `_task_verify_completion`（第一层幻觉门控）
- 新增 `_verify_output_files`（第二层幻觉门控）
- 单元测试覆盖

### Phase 2：E2E 测试适配 + 验证

- 适配新的投递机制（不再依赖 `--session-id UUID`）
- 验证 session 不爆炸
- 验证 Agent 自主决策行为
- 重跑 01-four-phase-loop 的 E2E 测试

### Phase 3：已完成

用户确认一步到位直接使用 `use_main_session=True`，不需要 feature flag 灰度切换。
所有 spawn_full_agent 调用均已硬编码 `use_main_session=True`。
- Agent main session 频繁超时（>5 次/小时`openclaw agent` 子进程超时）

---

## 十一、参考文件

| 文件 | 说明 |
|------|------|
| `01-four-phase-loop.md` | 四相循环设计（现行方案） |
| `v2.8-direction-notes.md` | v2.8/v2.9 方向（Prompt 进化 + Daemon 退化） |
| `subagent-delegation/SKILL.md` | Delegation skill（Agent 侧执行指南） |
| `counter.py` | 并发控制（per session 粒度） |
| `spawner.py` `_do_retry` | 续杯机制 |
| `dispatcher.py` `_mail_auto_complete` | Mail 幻觉门控（已验证，可复用） |
