|
| 1 | +# Orchestra: 画布即多 agent 黑板(Blackboard) |
| 2 | + |
| 3 | +> Yjs Y.Doc 不只是"前端同步用的状态",它本身就是 agent 共享内存。任何能 connect y-websocket 的 Node 进程,都是这块画布的一等公民 worker。 |
| 4 | +
|
| 5 | +--- |
| 6 | + |
| 7 | +## 1. 核心思想 |
| 8 | + |
| 9 | +传统多 agent 框架(CrewAI / AutoGen / LangGraph)都给 agent 一个**集中 orchestrator**,agent 之间通过 message bus 协调。 |
| 10 | + |
| 11 | +我们的形态不同: |
| 12 | + |
| 13 | +| 角色 | 做什么 | 通信方式 | |
| 14 | +|------|-------|---------| |
| 15 | +| **画布(Y.Doc)** | 共享黑板 — 所有 task / status / 关系都写在这里 | Yjs CRDT | |
| 16 | +| **dispatcher** | 看黑板,决定哪些 task 现在 ready;超时回收 lease | Y.Doc observe + transact | |
| 17 | +| **agent worker** | 看到自己名下的 ready task 就抢锁开干 | Y.Doc observe + transact | |
| 18 | +| **人** | 在画布上画/拖/改任意节点 | React Flow + Yjs | |
| 19 | + |
| 20 | +**关键洞察**:人和 agent 操作同一个 Y.Doc,行为模式完全一致。人在画布上拖一条边(加依赖)→ agent 立刻照新拓扑工作。这是中心化 orchestrator 给不了的"人在 loop 里"。 |
| 21 | + |
| 22 | +--- |
| 23 | + |
| 24 | +## 2. 与现有 manual 流的关系 |
| 25 | + |
| 26 | +现在 TaskNode 已经有 manual 流: |
| 27 | + |
| 28 | +``` |
| 29 | +浏览器 click "派给 Hermes" → fetch hermes-proxy:17081 → Hermes API → polling → 更新 TaskNode |
| 30 | +``` |
| 31 | + |
| 32 | +这条流**完全保留**。orchestra 是**旁路新增**: |
| 33 | + |
| 34 | +``` |
| 35 | +任何人改画布让 TaskNode.agentMode='auto' → dispatcher 在 Y.Doc 看到 → 写"ready"标记 |
| 36 | + ↓ |
| 37 | +agent worker 看到自己的 ready task → CAS 抢锁 → 跑 → 写结果回 Y.Doc → 浏览器同步 |
| 38 | +``` |
| 39 | + |
| 40 | +切换由 `data.agentMode` 字段决定(默认 `'manual'`)。零回归风险。 |
| 41 | + |
| 42 | +--- |
| 43 | + |
| 44 | +## 3. 节点 schema 扩展 |
| 45 | + |
| 46 | +现有 `TaskNode.data` 不动,新增以下 **可选** 字段: |
| 47 | + |
| 48 | +| 字段 | 类型 | 默认 | 含义 | |
| 49 | +|------|------|------|------| |
| 50 | +| `agentMode` | `'manual'` \| `'auto'` | `'manual'` | auto 时由 orchestra 自动调度 | |
| 51 | +| `assignedTo` | string \| null | null | agent 名(`'hermes'` / `'claude-cli'` / `'feishu-bot'` / `'human:nieao'`)| |
| 52 | +| `claimedAt` | ISO timestamp \| null | null | 抢锁时间,用于 lease 超时回收 | |
| 53 | +| `claimedBy` | string \| null | null | 抢到锁的 worker 实例 ID(同名 agent 多副本时区分)| |
| 54 | +| `leaseExpiresAt` | ISO timestamp \| null | null | lease 到期时间,过期 dispatcher 把 status 改回 'pending' | |
| 55 | +| `inputs` | string[] | [] | 上游节点 ID 列表(暂未启用,预留依赖图)| |
| 56 | + |
| 57 | +`status` 沿用现有 `TASK_NODE_STATUS`: |
| 58 | + |
| 59 | +``` |
| 60 | +draft → dispatching → pending → running → done | failed |
| 61 | +``` |
| 62 | + |
| 63 | +orchestra 增加一个虚拟态 `'pending'` 表示"已 ready 但还没被 worker 抢"。 |
| 64 | + |
| 65 | +--- |
| 66 | + |
| 67 | +## 4. 状态机(auto 模式) |
| 68 | + |
| 69 | +``` |
| 70 | +draft |
| 71 | + │ (人填完 title + assignedTo, 或 dispatcher 看到 agentMode='auto' && deps OK) |
| 72 | + ▼ |
| 73 | +pending |
| 74 | + │ (worker A 抢锁: CAS-set status='running', claimedBy=A, claimedAt=now, leaseExpiresAt=now+lease) |
| 75 | + ▼ |
| 76 | +running |
| 77 | + │ (worker A 跑完,写结果 + status='done') │ (lease 超时, dispatcher 改回 pending) |
| 78 | + ▼ ▼ |
| 79 | +done pending (允许另一 worker 重抢) |
| 80 | +
|
| 81 | +任意状态 → failed (worker 显式标记 + 写 error 到 data.error) |
| 82 | +``` |
| 83 | + |
| 84 | +**幂等性**:worker 任务必须做幂等(重抢可能再跑一次)。Hermes 调用走 `idempotency_key`。 |
| 85 | + |
| 86 | +--- |
| 87 | + |
| 88 | +## 5. CAS 抢锁协议 |
| 89 | + |
| 90 | +Y.Doc 的 transact 只在**当前 client 进程内**原子,跨 client 仍可能两个 worker 同时通过判断后 set。所以需要二段验证: |
| 91 | + |
| 92 | +```js |
| 93 | +// in worker |
| 94 | +function tryClaim(nodeId) { |
| 95 | + let claimed = false |
| 96 | + ydoc.transact(() => { |
| 97 | + const fresh = nodesMap.get(nodeId) |
| 98 | + if (!fresh) return |
| 99 | + if (fresh.data.status !== 'pending') return // 别人抢走了或已 running/done |
| 100 | + if (fresh.data.assignedTo !== MY_NAME) return // 不归我 |
| 101 | + nodesMap.set(nodeId, { |
| 102 | + ...fresh, |
| 103 | + data: { |
| 104 | + ...fresh.data, |
| 105 | + status: 'running', |
| 106 | + claimedBy: WORKER_ID, |
| 107 | + claimedAt: new Date().toISOString(), |
| 108 | + leaseExpiresAt: new Date(Date.now() + LEASE_MS).toISOString(), |
| 109 | + }, |
| 110 | + }) |
| 111 | + claimed = true |
| 112 | + }) |
| 113 | + |
| 114 | + if (!claimed) return false |
| 115 | + |
| 116 | + // 二段确认: 100ms 后再读, 看 claimedBy 是不是我 (CRDT 收敛后的真值) |
| 117 | + await sleep(100) |
| 118 | + const settled = nodesMap.get(nodeId) |
| 119 | + if (settled?.data?.claimedBy !== WORKER_ID) { |
| 120 | + // 双跑了, 我退让 |
| 121 | + return false |
| 122 | + } |
| 123 | + return true |
| 124 | +} |
| 125 | +``` |
| 126 | +
|
| 127 | +**双跑窗口**:100ms 内两个 worker 都通过 transact 都 set 自己 — Yjs 收敛后只有一个 `claimedBy`(last-write-wins on Map.set)。落败方退出,胜出方继续。坏情况:100ms 内的工作量被白做一次(agent 端无副作用 / 已幂等就 OK)。 |
| 128 | +
|
| 129 | +--- |
| 130 | +
|
| 131 | +## 6. lease 心跳与回收 |
| 132 | +
|
| 133 | +worker 在执行期间每 30s 续 lease: |
| 134 | +
|
| 135 | +```js |
| 136 | +setInterval(() => { |
| 137 | + if (!isRunning) return |
| 138 | + ydoc.transact(() => { |
| 139 | + const fresh = nodesMap.get(nodeId) |
| 140 | + if (fresh?.data?.claimedBy !== WORKER_ID) return // 已被回收别瞎写 |
| 141 | + nodesMap.set(nodeId, { |
| 142 | + ...fresh, |
| 143 | + data: { ...fresh.data, leaseExpiresAt: new Date(Date.now() + LEASE_MS).toISOString() }, |
| 144 | + }) |
| 145 | + }) |
| 146 | +}, 30_000) |
| 147 | +``` |
| 148 | +
|
| 149 | +dispatcher 每 10s 扫一遍所有 `status='running'` 的节点,`leaseExpiresAt < now` 的强制 reset 到 `pending`,并清空 `claimedBy/claimedAt/leaseExpiresAt`。 |
| 150 | +
|
| 151 | +--- |
| 152 | +
|
| 153 | +## 7. 调度器职责(dispatcher) |
| 154 | +
|
| 155 | +只做三件事: |
| 156 | +
|
| 157 | +1. **ready-set 计算**:scan nodesMap,找 `agentMode='auto' && status='draft' && (deps all done)`,把 status 改为 `'pending'` |
| 158 | +2. **lease 超时回收**:上面 §6 的 reset |
| 159 | +3. **环检测**(防 agent 互相派单死循环):当一条边 from A.outputs to B.inputs 但 B 的 outputs 又指向 A 时,把 A.B 双方设为 `failed` 并写 error |
| 160 | +
|
| 161 | +dispatcher 不做"派给谁" — 那是节点 `assignedTo` 字段决定的(人或上游 agent 写)。 |
| 162 | +
|
| 163 | +--- |
| 164 | +
|
| 165 | +## 8. agent 命名规范 |
| 166 | +
|
| 167 | +- `hermes` — Hermes Kanban worker |
| 168 | +- `claude-cli` — 调本地 claude CLI |
| 169 | +- `feishu-bot` — 飞书机器人 |
| 170 | +- `human:<username>` — 派给具体的人(agent 跳过;人在画布上手动改 status) |
| 171 | +
|
| 172 | +worker 启动时声明自己叫什么,只接 `assignedTo` 等于自己名的任务。 |
| 173 | +
|
| 174 | +--- |
| 175 | +
|
| 176 | +## 9. 与已有 hermes-proxy 的关系 |
| 177 | +
|
| 178 | +`server/hermes-proxy.js` 是 HTTP 代理,给浏览器 manual 派单用 — **保留不动**。 |
| 179 | +
|
| 180 | +`server/orchestra-hermes-worker.js` 是 Y.Doc client + Hermes 调用合体: |
| 181 | +- import hermes-proxy.js 里的 `hermesCall` 函数(重构出来共用) |
| 182 | +- 不走 HTTP 中转,直接调 Hermes API |
| 183 | +- 完成后把结果写 Y.Doc,浏览器同步看到 |
| 184 | +
|
| 185 | +**两条流复用 hermesCall 一份代码**,避免 schema 漂移。 |
| 186 | +
|
| 187 | +--- |
| 188 | +
|
| 189 | +## 10. 多 agent 创建 agent 的护栏 |
| 190 | +
|
| 191 | +防止 agent 自我繁殖爆炸: |
| 192 | +
|
| 193 | +- **agent 不能写 status='draft'** 的新节点(draft 只能由人或专门的"分支 agent"创建) |
| 194 | +- agent 只能 : |
| 195 | + - 改自己 owned 节点的 status / data / outputs |
| 196 | + - 创建 type='resultNode' 的结果节点 + 一条 edge 接到自己的 TaskNode |
| 197 | +- 违规写入由 dispatcher 检测 + 回滚 |
| 198 | +
|
| 199 | +最小可演示阶段,agent 行为白名单:`status update only` + `create resultNode + edge`。 |
| 200 | +
|
| 201 | +--- |
| 202 | +
|
| 203 | +## 11. demo 场景(黑客松级) |
| 204 | +
|
| 205 | +人在画布上画: |
| 206 | +
|
| 207 | +``` |
| 208 | +[ConceptNode: 营销主题] |
| 209 | + │ |
| 210 | + ▼ |
| 211 | +[TaskNode: 调研 5 个竞品] |
| 212 | +agentMode: 'auto' |
| 213 | +assignedTo: 'hermes' |
| 214 | + │ |
| 215 | + ▼ |
| 216 | +[TaskNode: 总结成对比表] |
| 217 | +agentMode: 'auto' |
| 218 | +assignedTo: 'claude-cli' |
| 219 | + │ |
| 220 | + ▼ |
| 221 | +[ResultNode: 待 agent 填] |
| 222 | +``` |
| 223 | +
|
| 224 | +启动 dispatcher + hermes worker + claude-cli worker: |
| 225 | +
|
| 226 | +1. 5 秒内"调研 5 个竞品"被 hermes worker 抢锁,status → running |
| 227 | +2. 30 秒后 hermes 完成,写 ResultNode,status → done |
| 228 | +3. dispatcher 看到下游"总结对比表"deps OK,把它从 draft 推到 pending |
| 229 | +4. claude-cli worker 抢锁,2 分钟跑完,写最终 ResultNode |
| 230 | +
|
| 231 | +**全程三人浏览器同步看到节点变色、进度条流动、ResultNode 涌现**。 |
| 232 | +
|
| 233 | +--- |
| 234 | +
|
| 235 | +## 12. 失败模式与诊断 |
| 236 | +
|
| 237 | +| 现象 | 可能原因 | 排查 | |
| 238 | +|------|--------|------| |
| 239 | +| 节点卡 pending 超过 1 分钟 | 没有匹配 assignedTo 的 worker 在线 | 看 server 日志,启动对应 worker | |
| 240 | +| 节点 running 不动 | worker 卡死 / 网络断 | 等 lease 超时(5 分钟)自动回 pending | |
| 241 | +| 双跑(结果出现两次) | CAS 二段确认失败但都跑了 | 检查 worker 端是否幂等;查 idempotency_key | |
| 242 | +| dispatcher 不推 ready | agent 看到的 status 还在 draft 不是 auto | 检查 `data.agentMode === 'auto'` 是否真的写进 Y.Doc | |
| 243 | +
|
| 244 | +dispatcher 自己也写一份运行日志到 `nodesMap.get('__dispatcher_log__')`(一个不渲染的特殊节点 ID),浏览器可以开开发者工具看。 |
| 245 | +
|
| 246 | +--- |
| 247 | +
|
| 248 | +## 13. 不在范围内(明确切割) |
| 249 | +
|
| 250 | +- ❌ Cron / 定时触发 — 用户启动是手动的 |
| 251 | +- ❌ 流水线编辑器(visual programming) — 边只是 dependency hint,不传数据 |
| 252 | +- ❌ 跨房间任务 — 每个 room 是独立的 Y.Doc,agent 只能服务它当前 connect 的 room |
| 253 | +- ❌ 鉴权细分 — agent 跟用户在同一房间,写权限相同(黑客松简化;P1 加 ACL) |
| 254 | +
|
| 255 | +--- |
| 256 | +
|
| 257 | +## 14. P0 实现清单(本次会话目标) |
| 258 | +
|
| 259 | +- [x] 写 spec(本文档) |
| 260 | +- [x] `server/orchestra-base.js` — Y.Doc client 基类 |
| 261 | +- [x] `server/orchestra-dispatcher.js` — 调度器 |
| 262 | +- [x] `server/orchestra-hermes-worker.js` — Hermes worker(mock 完成,真 API 路径已写但未连真凭据) |
| 263 | +- [x] `server/package.json` 加 `dispatcher` / `worker:hermes` / `orchestra:e2e` / `orchestra:race` script |
| 264 | +- [x] 端到端 mock demo (`npm run orchestra:e2e`) — draft → running → done + 自动建 ResultNode |
| 265 | +- [x] CAS 抢锁竞态测试 (`npm run orchestra:race`) — 3 worker 抢 5 task 不双跑 |
| 266 | +
|
| 267 | +## 15. 启动 demo 的最小操作 |
| 268 | +
|
| 269 | +```bash |
| 270 | +# 终端 1: y-ws-server (1234) |
| 271 | +cd server && npm run yws |
| 272 | + |
| 273 | +# 终端 2: dispatcher |
| 274 | +cd server && node orchestra-dispatcher.js demo-room |
| 275 | + |
| 276 | +# 终端 3: hermes worker (默认 mock; 真调 Hermes 需 set HERMES_USER/HERMES_PASS) |
| 277 | +cd server && node orchestra-hermes-worker.js demo-room |
| 278 | + |
| 279 | +# 浏览器: 进入 ?room=demo-room, 双击空白加 TaskNode, |
| 280 | +# 改 data.agentMode='auto' + data.assignedTo='hermes', dispatcher 5s 内 promote, worker 抢锁跑 |
| 281 | +``` |
| 282 | +
|
| 283 | +## 16. 验证命令 |
| 284 | +
|
| 285 | +```bash |
| 286 | +cd server |
| 287 | +npm run orchestra:e2e # 单 worker 端到端: draft → done + ResultNode |
| 288 | +npm run orchestra:race # 3 副本抢 5 task: 不双跑 |
| 289 | +``` |
| 290 | +
|
| 291 | +P1(黑客松后): |
| 292 | +- claude-cli worker (复用 server/claude-bridge.js 或直接 spawn) |
| 293 | +- 飞书 bot worker (听飞书消息 → 写画布 / 监听画布 → 推飞书群) |
| 294 | +- 真接 Hermes API (设 HERMES_USER/HERMES_PASS, 关掉 ORCHESTRA_MOCK) |
| 295 | +- 节点 outputs 字段做"上游 result 注入下游 prompt"的数据流 |
| 296 | +- 环检测 + 黑名单 (防 agent 互相派单死循环) |
0 commit comments