# v3.0 调度重构方案:去掉独立 LLM,改为广播认领 + 确定性交接 **日期**: 2026-05-21 **状态**: 待司马懿评审 **影响文件**: `router.py`, `dispatcher.py`, `ticker.py`, `main.py`, `config/default.yaml` --- ## 1. 问题 ### 1.1 LLMDriver 是设计之外的野路子 当前 Router 的 `LLMDriver` 用独立 `OpenAI()` 客户端直连 zhipu API 做路由决策: - 不属于 L1(调了 LLM)、不属于 L2(不是 openclaw agent)、不属于 L3(不是完整 Agent) - 不走 Gateway(无模型路由、无 fallback、无计费) - 需要单独维护 api_base/api_key(和 Gateway 配置重复) **已导致实际故障**:`general-20260521-0004` 因凭据为空反复调度失败。 ### 1.2 _dispatch_pending 绕过了认领机制 设计文档核心原则(§1.2): > "编排是 AI Agent 在黑板上**自主领活**(动态协作)" > "Daemon 是**投递员,不是决策者**" 但 `_dispatch_pending` 的实际行为是: ``` pending → Router 决定 agent → 强制 claimed + spawn → Agent 被动执行 ``` 代码里认领的全部基础设施(API + CAS + inbox 事件)都已经实现,但被跳过了。 ### 1.3 设计违背 architecture-v2.6.md §1.1: > "Agent 决策,Daemon 执行" — 庞统做 plan、张飞领任务、关羽发现风险,都写在黑板上。 Daemon 替 Agent 做决策(Router 决定分给谁),违反了"Agent 决策"原则。 --- ## 2. 设计文档中已有的完整方案 ### 2.1 认领基础设施(已实现) | 组件 | 代码位置 | 说明 | |------|----------|------| | **claim API** | `POST /tasks/{id}/claim` | 原子 CAS 认领 | | **claim_task()** | `operations.py:155` | `WHERE status='pending' AND (assignee IS NULL OR assignee=?)` | | **inbox agent_claim** | `inbox.py:264` | Agent 通过 JSONL 通知 Daemon 认领 | | **claim_timeout** | `ticker.py:530` | claimed 超时 5 分钟 → 重置为 pending(续杯) | ### 2.2 竞态解决(已设计,§3.6) 1. **默认:先到先得** — SQLite CAS,谁先 claim 谁做 2. **升级:庞统仲裁** — 争议时 @庞统 请求仲裁 3. **最终:用户拍板** — @user 请求用户决定 ### 2.3 续杯兜底(已实现) ``` pending → 广播 → 无人认领 → claim_timeout(5min) → pending → 再广播 → ... → 庞统兜底 ``` `claim_timeout_minutes = 5.0`,`_check_timeouts` 自动把超时的 claimed 重置为 pending。 ### 2.4 三层执行模型(§4.2) | 层级 | 方式 | 命令 | 适用场景 | |------|------|------|---------| | **L1 Daemon 直接操作** | SQLite/文件 | — | 状态更新、机械验证 | | **L2 spawn sub** | 隔离 session | `openclaw agent --agent --session-id ` | scope guard、格式校验 | | **L3 run agent** | 完整黑板参与者 | `openclaw agent --agent ` | 编码、审查、策略 | > **核心原则:系统只有两种 LLM 调用方式,都通过 Gateway,没有第三种。** --- ## 3. 方案:广播认领 + 确定性交接 ### 3.1 核心思路 任务调度分两条路径: **确定性路径**:已明确知道下一步该谁做 → 直接 spawn,不需要广播 - retry → 原执行者 - Agent handoff(next_capability)→ 能力匹配 - 有 assignee → 直接分 - review 生命周期 → 能力匹配 **广播认领路径**:首次分配 / 不确定场景 → spawn 所有空闲 Agent,自主 claim - Agent 读黑板 → 自己判断是否适合 → claim - 无人认领 → 续杯 → 庞统兜底 ### 3.2 完整调度流程 ``` Ticker._tick_project() │ ├─ 1. 扫描状态 ├─ 2. 依赖推进 ├─ 3. 超时检测(claimed 5min → pending,working 30min → failed) │ ├─ 4. 调度 pending 任务:_dispatch_pending() │ for each pending task: │ ├─ 有 next_capability?→ 能力匹配 → 直接 spawn 对应 Agent │ ├─ 有 assignee?→ 直接 spawn 该 Agent │ ├─ retry?→ spawn 原执行者 │ └─ 都没有?→ _broadcast_claim() │ → spawn 每个空闲 Agent,传入"黑板上有新任务,请认领"的 prompt │ → Agent 读黑板(GET /tasks?status=pending) │ → Agent 判断是否适合自己 │ → Agent claim(POST /tasks/{id}/claim)或退出 │ ├─ 5. 调度 review 任务:_dispatch_reviews()(不变) └─ 6-10. 其他处理(不变) ``` ### 3.3 广播认领的 Spawn Prompt 广播 spawn 的 Agent 收到的 prompt: ``` 你是 {agent_id}。黑板上有新的待认领任务。 ## 操作 1. 读黑板查看待认领任务: curl http://{api_host}:{api_port}/api/projects/{project_id}/tasks?status=pending 2. 分析每个 pending 任务,判断是否适合你: - 你的能力:{capabilities} - 任务类型、描述、优先级是否匹配你的专长 3. 如果有适合你的任务,立即认领: curl -X POST http://{api_host}:{api_port}/api/projects/{project_id}/tasks/{task_id}/claim \ -H 'Content-Type: application/json' \ -d '{"agent": "{agent_id}"}' 4. 认领成功后,开始执行(状态改为 working) 5. 如果没有适合你的任务,直接退出 ``` ### 3.4 Claim 竞争处理 多个 Agent 同时 claim 同一个任务: - **SQLite CAS**:`WHERE status='pending'` — 只有第一个成功,其余 rowcount=0 - **Agent 行为**:claim 失败 → 检查其他 pending 任务 → 没有适合的 → 退出 - **自然负载均衡**:不同 Agent 倾向认领不同类型的任务(张飞→coding,司马懿→review) ### 3.5 无人认领兜底 ``` 广播 → 5 分钟内无人 claim → claim_timeout → pending → 下轮 ticker 再广播 → 连续 3 轮无人认领 → spawn 庞统 → 庞统决定分配或自己执行 ``` 连续无人认领的检测:在 `events` 表中记录 `broadcast_sent` 事件,`_check_timeouts` 中统计广播轮次,超过阈值 escalate to 庞统。 --- ## 4. 改动清单 ### 4.1 删除 `LLMDriver` 类(router.py) 删除整个 `LLMDriver` 类(~120 行)。`AgentRouter.route()` 末尾改为返回 delegate。 ### 4.2 `AgentRouter.__init__` 去掉 `llm_driver` 参数 ### 4.3 新增 `_broadcast_claim`(ticker.py) 司马懿建议:攒一批任务,每轮 ticker 最多广播一次(而非每个 pending 任务触发一次广播)。 5 个任务只需 spawn 5 个 Agent,而不是 25 个。 广播前检查全局并发(司马懿建议 1):接近上限时跳过本轮广播。 ```python async def _broadcast_claim(self, tasks, db_path, project_id): """广播一批待认领任务给所有空闲 Agent,每轮最多广播一次""" # 全局并发检查 if self.counter and self.counter.global_active >= self.counter._max_global - 1: logger.info("Skipping broadcast: global concurrent near limit") return [] idle_agents = self._get_idle_agents() if not idle_agents: return [] spawned = [] for agent_id in idle_agents: if not await self.counter.can_acquire(agent_id): continue prompt = self._build_claim_prompt(agent_id, tasks, project_id) await self.counter.acquire(agent_id) session_id = await self.spawner.spawn_full_agent( agent_id=agent_id, message=prompt, on_complete=lambda aid, _: self.counter.release(aid), ) spawned.append(agent_id) return spawned ``` 广播 prompt 包含所有 pending 任务列表: ```python def _build_claim_prompt(self, agent_id, tasks, project_id): task_list = "\n".join([ f"- ID: {t.id}, 标题: {t.title}, 类型: {t.task_type}, 优先级: {t.priority}" for t in tasks ]) return f"""你是 {agent_id}。黑板上有 {len(tasks)} 个待认领任务。 ## 待认领任务 {task_list} ## 操作 1. 读黑板查看详情: curl http://{api}/api/projects/{project_id}/tasks?status=pending 2. 选择适合你的任务并认领: curl -X POST http://{api}/api/projects/{project_id}/tasks/{task_id}/claim \ -H 'Content-Type: application/json' -d '{{"agent": "{agent_id}"}}' 3. 认领成功后开始执行(状态改为 working) 4. 没有适合你的任务则退出 """ ``` ### 4.4 Dispatcher 增加 delegate 模式(dispatcher.py) `_build_spawn_message` 增加 `delegate` 分支(庞统兜底 prompt),和广播认领共存: - 广播认领失败 → 任务回到 pending → 多轮后 → spawn 庞统 delegate ### 4.5 main.py 去掉 LLMDriver 初始化 ### 4.6 config/default.yaml 去掉 routing 节 ### 4.7 无人认领检测(复用 retry_count) 司马懿建议:不需要新增 broadcast_sent 事件。无人认领重置 pending 时 retry_count 已在递增,直接用它判断阈值。当 `retry_count >= 3` 时 escalate to 庞统。 --- ## 5. 广播认领完整生命周期 > 本节记录从任务创建到最终闭环的完整链路,包括所有已实现但此前未在设计文档中记录的机制。 ### 5.1 完整流程图 ``` 用户创建任务 → pending ↓ Ticker 检测到 pending 任务 ↓ 有确定性路径?(retry/handoff/assignee/生命周期) ├─ 是 → 直接 spawn 指定 Agent └─ 否 → 进入广播认领 ↓ 广播前检查全局并发(counter.is_near_limit()) → 接近上限?跳过本轮 ↓ 获取空闲 Agent 列表(从 config agents 列表取) → 无空闲?log warning,不递增 retry_count,等下轮 ↓ spawn 所有空闲 Agent,传入 claim prompt retry_count++(本轮广播计数) ↓ ┌─ 有人 claim → 正常执行流程 └─ 无人 claim ↓ claimed 超时(5min)→ _check_timeouts 重置为 pending retry_count 已递增 ↓ retry_count < 3?→ 下轮 Ticker 继续广播 retry_count >= 3?→ escalate to 庞统 ``` ### 5.2 retry_count 的三种递增场景 | 场景 | 触发位置 | retry_count++? | 说明 | |------|----------|-----------------|------| | 广播后无人认领 | `_broadcast_claim` L536 | ✅ 是 | 每轮广播递增,记录广播轮次 | | claimed 超时回收 | `_check_timeouts` L724 | ✅ 是 | Agent 认领后未执行,视为失败 | | 无空闲 Agent | `_broadcast_claim` L527 | ❌ 否 | 系统容量问题,不是任务问题 | ### 5.3 超时阈值(与 working 状态一致) | 状态 | 超时 | 处理 | |------|------|------| | claimed | `claim_timeout_minutes = 5.0` | → pending(retry_count < 3)或 escalated(retry_count >= 3) | | working | `default_task_timeout_minutes = 30.0` | → failed | 超时后 retry_count >= 3 时的处理与 working 超时一致:标记 escalated,触发用户介入流程。 ### 5.4 庞统兜底(delegate 模式) 当任务进入以下状态时,Router 路由到 `FALLBACK_AGENT = "pangtong-fujunshi"`,mode="delegate": - 广播 3 轮无人认领(retry_count >= 3) - 确定性路由无法匹配(模糊场景) 庞统收到的 delegate prompt 包含:任务信息 + 团队能力列表 + API 端点。庞统可以: 1. 直接分配给指定 Agent(通过 claim API) 2. 自己执行 3. 判断任务不合理 → cancel ### 5.5 用户介入链路(已完整实现) 当任务 escalated 后,用户介入的完整链路: ``` Agent 超时/广播无人认领 → Ticker 标记 status="escalated" → SSE broker.publish_sync("task_updated", {...}) → 前端 EventSource 实时收到事件 → 通知中心推送 "{old_status} → escalated" → 卡片红色高亮 + ⚠️ 图标 → 用户点开 TaskModal → 看到 escalated 按钮: · ▶ 继续执行 (→ working) · 🔄 重新分配 (→ pending, retry_count 重置) · 🚫 取消 (→ cancelled) ▸ 高级操作: · (可扩展更多手动干预) → 用户点击 → POST /tasks/{id}/status → SSE 推送 → 状态更新 ``` **已实现代码对应表:** | 环节 | 代码文件 | 具体实现 | |------|----------|----------| | 标记 escalated | `ticker.py` _check_timeouts / _broadcast_claim | _transition_status(conn, task_id, "escalated", ...) | | SSE 推送 | `blackboard_routes.py` update_status | broker.publish_sync("task_updated", ...) | | SSE 基础设施 | `sse.py` / `sse_routes.py` | SSEBroker + EventSource endpoint | | 前端 SSE 监听 | `store.ts` startSSE() | addEventListener('task_updated', ...) | | 前端通知中心 | `store.ts` _pushSseEvent() | 通知列表 + 未读计数 | | escalated 按钮 | `TaskModal.tsx` PRIMARY_ACTIONS[escalated] | ▶ 继续执行 + 🔄 重新分配 | | escalated 高级操作 | `TaskModal.tsx` ADVANCED_ACTIONS | 🚫 取消 | | 状态转换 API | `blackboard_routes.py` POST /tasks/{id}/status | VALID_TRANSITIONS 校验 | | 转换矩阵 | `db.py` VALID_TRANSITIONS | escalated → {working, pending, cancelled} | ### 5.6 前端按钮完整矩阵(v3.1 用户确认版) **设计原则:** - 暂停和取消是用户的通用权利,所有非终态都有 - AI 自动流转的操作(认领、开始、提审、通过、打回)不需要按钮 - escalated / waiting_human 是 AI 发起后等待用户决策,不是通用按钮 - blocked 任务本身没在动,暂停无意义 - failed 的升级是 AI 发起不是按钮 #### 状态定义 | 状态 | 含义 | 谁触发 | 颜色 | |------|------|--------|------| | pending | 等待 AI 认领 | 用户创建/AI流转 | 蓝 | | claimed | AI 已认领 | AI 认领 | 紫 | | working | AI 执行中 | AI 开始 | 蓝 | | paused | 用户暂停 | 用户 | 紫 | | review | AI 审查中 | AI 提交审查 | 紫 | | failed | 失败 | AI 或 daemon | 红 | | blocked | 缺条件卡住 | Agent 遇到外部阻塞 | 橙 | | escalated | AI 解决不了,升级求助 | Agent | 红 | | waiting_human | 流程验收点等用户确认 | Agent Checkpoint | 橙 | | done | 完成 | 审查通过/用户确认 | 绿 | | cancelled | 取消 | 用户 | 灰 | **escalated vs waiting_human 区别:** - escalated:Agent 遇到**意外问题**解决不了(如技术选型不确定)→ 红色 → 严重 - waiting_human:执行到**计划中的验收点**(如 Checkpoint)→ 橙色 → 正常进度 #### 按钮矩阵 | 状态 | 用户按钮 | 说明 | |------|---------|------| | **pending** | ⏸ 暂停、🚫 取消 | 等 AI 认领时用户可暂停或取消 | | **claimed** | ⏸ 暂停、🚫 取消 | AI 已认领未开始 | | **working** | ⏸ 暂停、🚫 取消 | AI 执行中 | | **paused** | ▶ 恢复、🚫 取消 | 恢复回暂停前状态 | | **review** | ⏸ 暂停、🚫 取消 | AI 审查中 | | **failed** | 🔄 重试、🚫 取消 | 重试回 pending | | **blocked** | 🔓 解除(→pending)、🚫 取消 | 任务本身没在动,暂停无意义 | | **escalated** | ▶ 继续(→working)、🔄 重分配(→pending)、🚫 取消 | AI 发起升级后用户决策 | | **waiting_human** | ✅ 确认、🔄 拒绝(→working)、🚫 取消 | 流程验收点 | | **done** | 📦 归档 | 终态 | | **cancelled** | 🔄 重新启动(→pending)、📦 归档 | 可重启保留历史 | #### 状态机变更 暂停是通用权利,需要在更多状态允许 → paused 转换: ```python # 当前 VALID_TRANSITIONS = { "pending": {"claimed", "cancelled"}, "claimed": {"working", "paused", "pending", "cancelled"}, "working": {"review", "blocked", "failed", "paused", "escalated", "waiting_human", "cancelled"}, "paused": {"working", "cancelled"}, "review": {"done", "pending", "failed", "escalated", "waiting_human", "cancelled"}, "blocked": {"pending", "escalated", "cancelled"}, "failed": {"pending", "escalated", "cancelled"}, "escalated": {"working", "pending", "cancelled"}, "waiting_human": {"working", "done", "cancelled"}, "done": set(), "cancelled": set(), } # 变更后 VALID_TRANSITIONS = { "pending": {"claimed", "paused", "cancelled"}, # +paused "claimed": {"working", "paused", "pending", "cancelled"}, # 不变 "working": {"review", "blocked", "failed", "paused", "escalated", "waiting_human", "cancelled"}, # 不变 "paused": {"pending", "cancelled"}, # 恢复统一回 pending "review": {"done", "pending", "failed", "paused", "escalated", "waiting_human", "cancelled"}, # +paused "blocked": {"pending", "escalated", "cancelled"}, # 不变(blocked暂停无意义) "failed": {"pending", "escalated", "cancelled"}, # 不变(暂停语义不清晰) "escalated": {"working", "pending", "paused", "cancelled"}, # +paused "waiting_human": {"working", "done", "paused", "cancelled"}, # +paused "done": {"cancelled"}, # +cancelled(取消已完成任务) "cancelled": {"pending"}, # +pending(重新启动) } ``` **paused 恢复机制:** paused 记录 `resumed_from` 字段,恢复时回到暂停前状态,从暂停的节点继续执行(允许一个 stage 的冗余重跑)。 - working → paused → ▶恢复 → working - review → paused → ▶恢复 → review - claimed → paused → ▶恢复 → claimed #### 取消 ADVANCED_ACTIONS 折叠区 v3.1 不再使用 ADVANCED_ACTIONS 折叠区。所有用户操作直接展示在主操作区。 原因:暂停和取消是通用权利,不应隐藏在折叠区里。 --- ## 6. 场景对比 | 场景 | 改前(独立 LLM 分配) | 改后(广播认领 + 确定性交接) | |------|---------------------|---------------------------| | retry | 原执行者 | 不变(确定性) | | Agent handoff | 能力匹配 | 不变(确定性) | | 有 assignee | 直接分 | 不变(确定性) | | review 生命周期 | 能力匹配 | 不变(确定性) | | **首次分配** | **独立 LLM 决定分给谁** | **广播所有空闲 Agent,自主认领** | | **无人认领** | **无此场景(强制分配)** | **续杯 → 庞统兜底** | --- ## 7. 代码量 - **删**:~130 行(`LLMDriver` + routing config 初始化 + config.yaml routing 节) - **改**:~30 行(`Router.route()` 末尾 + `Dispatcher._build_spawn_message()` delegate 分支) - **新增**:~60 行(`_broadcast_claim` + `_build_claim_prompt`) - **净减**:~40 行 --- ## 8. 风险与缓解 | # | 风险 | 评估 | 缓解 | |---|------|------|------| | 1 | 广播 spawn 消耗资源(每个 pending 任务都 spawn 所有空闲 Agent) | 中 | 只有"无确定性路径"的任务才广播;且 Agent 读黑板后无适合任务会快速退出 | | 2 | 多 Agent 竞争 claim | 低 | SQLite CAS 先到先得,已实现 | | 3 | 无人认领 | 低 | 续杯机制兜底,多轮后庞统接管 | | 4 | Agent 认领了不适合的任务 | 低 | Agent 有完整上下文(SOUL+AGENTS+能力),比 LLM 判断更准确 | | 5 | 广播速度比直接分配慢 | 低 | 首次分配不需要快,准确比快重要 | --- ## 9. 实施步骤 1. `router.py`:删除 `LLMDriver` 类 + `AgentRouter` 去掉 `llm_driver` + `route()` 末尾改 delegate 2. `ticker.py`:新增 `_broadcast_claim` + `_build_claim_prompt`,修改 `_dispatch_pending` 增加 广播路径 3. `dispatcher.py`:`_build_spawn_message()` 增加 delegate 分支(庞统兜底) 4. `main.py`:删除 `llm_driver` 初始化块 5. `config/default.yaml`:删除 `routing` 节 6. 测试:创建 pending 任务 → 观察广播 spawn → Agent claim → 执行