@twinsearth/dsh-master 3.3.4 → 3.3.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +1 -0
- package/client.js +83 -4
- package/cordis.patch.yml +2 -0
- package/dist-host/agents/pjm-agent.js +18 -2
- package/dist-host/agents/pm-agent.js +23 -1
- package/dist-host/agents/prompts.js +16 -6
- package/dist-host/channel/abort-registry.js +93 -0
- package/dist-host/channel/protocol.js +114 -0
- package/dist-host/channel/server.js +349 -0
- package/dist-host/channel/stream-state.js +343 -0
- package/dist-host/channel/ws.js +199 -0
- package/dist-host/dual/documents.js +36 -0
- package/dist-host/dual/orchestrator.js +214 -0
- package/dist-host/dual/schema.js +153 -0
- package/dist-host/engine/atomic-switch.js +159 -0
- package/dist-host/engine/health-probe.js +86 -0
- package/dist-host/engine/index.js +18 -0
- package/dist-host/engine/llm-stream.js +215 -0
- package/dist-host/engine/plugin-updater.js +392 -0
- package/dist-host/engine/safe-update.js +150 -0
- package/dist-host/index.js +114 -2
- package/dist-host/kernel.js +7 -7
- package/dist-host/plugins/cost-optimizer.js +3 -3
- package/dist-host/plugins/dual-manager.js +230 -6
- package/docs/AGENTS-PM-PJM.md +90 -6
- package/docs/CHANGELOG.md +58 -0
- package/docs/CHANNEL-WEBSOCKET.md +169 -0
- package/docs/DESIGN-command-center.md +147 -0
- package/docs/ENGINE-PRIMITIVES.md +215 -0
- package/docs/GAP-ANALYSIS-fang-session.md +75 -0
- package/docs/REVIEW-PM-PJM-3.3.4.md +32 -6
- package/docs/examples/command-center/README.md +167 -0
- package/package.json +3 -1
- package/src/agents/pjm-agent.ts +18 -2
- package/src/agents/pm-agent.ts +21 -1
- package/src/agents/prompts.ts +16 -6
- package/src/channel/abort-registry.ts +85 -0
- package/src/channel/protocol.ts +110 -0
- package/src/channel/server.ts +289 -0
- package/src/channel/stream-state.ts +349 -0
- package/src/channel/ws.ts +182 -0
- package/src/dual/documents.ts +35 -0
- package/src/dual/orchestrator.ts +223 -0
- package/src/dual/schema.ts +148 -1
- package/src/engine/atomic-switch.ts +146 -0
- package/src/engine/health-probe.ts +84 -0
- package/src/engine/index.ts +20 -0
- package/src/engine/llm-stream.ts +189 -0
- package/src/engine/plugin-updater.ts +359 -0
- package/src/engine/safe-update.ts +148 -0
- package/src/index.ts +105 -2
- package/src/kernel.ts +7 -7
- package/src/plugins/cost-optimizer.ts +3 -3
- package/src/plugins/dual-manager.ts +211 -6
package/docs/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,61 @@
|
|
|
1
|
+
## 3.3.5(2026-11-14 已发布)
|
|
2
|
+
|
|
3
|
+
> 三方发布:annotated tag `v3.3.5` + GitHub Release + npm `@twinsearth/dsh-master@3.3.5`。
|
|
4
|
+
> 接口包无变化(仍 `@twinsearth/dsh-fang-contract@1.1.1`)。
|
|
5
|
+
|
|
6
|
+
**主题:宿主侧引擎原语 + PM/PJM 闭环 + 面板/通道/流式管线。**
|
|
7
|
+
|
|
8
|
+
- **闭环第一步 · PM 交接**:`pm_handoff_to_pjm` 生成交接包;**验收标准为空、或仍有未决问题/冲突时不给 `ready`**(`needs-clarification` + 具名原因),PJM 不允许接单。
|
|
9
|
+
- **闭环第二步 · PJM 接单与自反馈**:`pjm_take_handoff` / `pjm_create_plan({handoffId})` 接单(重复接单被拒);`pjm_report` 支持执行中随时汇报(`blocked` 必须写 blocker);全部步骤完成后 `pjm_submit_feedback` 提交交付/偏差/遗留/测试与实测用时。
|
|
10
|
+
- **闭环第三步 · PM 复核/复盘/总结**:`pm_review_feedback` 逐条验收(`met` 必须是布尔 + 证据),写 `kept/improve/lessons` 复盘与总结;**结论必须与验收一致**(有未达标却判 accepted、或全达标却判 changes-requested,都直接拒收);`accepted` 把计划标记为 `pmReview.accepted`,`changes-requested` 保持闭环打开。
|
|
11
|
+
- **闭环可视**:`pm_pipeline` 给出 `drafted → handoff-ready / needs-clarification → planning → feedback → accepted / changes-requested`;`status().dual.loop` 与面板 Command Center 显示待复核/通过/退回计数。
|
|
12
|
+
- **文档**:复核文档 `docs/pm/REVIEW-<reviewId>.md`(复核表 + 执行回执 + 交接对照 + 复盘 + 总结)。
|
|
13
|
+
- **引擎原语**(上一提交):`src/engine/` —— 指针切换 / 健康检查 / 安全更新,真实平台行为 + 注入式执行器,导出 `./engine`。
|
|
14
|
+
- **插件安全更新流水线**(3.3.5):`src/engine/plugin-updater.ts` —— `createPluginUpdater({executor})`,
|
|
15
|
+
完整阶段机(snapshotting → updating → smoke-testing → success / failed)、**异步作业轮询**、整体与冒烟独立超时、
|
|
16
|
+
同 key 并发锁、`keepSnapshotOnSuccess`、`onProgress` 异常隔离;**回滚失败升级为 `fatal:true` + FATAL 日志**,
|
|
17
|
+
不让“更新失败且回滚失败”与普通失败同形。执行能力仍由调用方注入,不假设存在 `vm_*` 工具。
|
|
18
|
+
- **智囊团面板(3.3.5)**:新增只读端点 `GET /dsh-master/api/dual`(交接/计划/反馈/复核快照,无凭据、逐项截断);
|
|
19
|
+
面板新增「智囊团 · 闭环」五段看板(待澄清 / 可接单 / 项目进度 / 待复核 / 已复核),轮询 5s,失败显示「不可达」而不是旧数据;
|
|
20
|
+
**面板不提供聊天、不回答问题、不派发**(测试静态禁止 mock 定时器与 agent 工具调用);设计见 `docs/DESIGN-command-center.md`,
|
|
21
|
+
Tailwind/Lucide 的完整交互版放在 `docs/examples/command-center/`(types/组件/主面板/README)作为参考实现,**不随插件发布**。
|
|
22
|
+
- **智囊团 WebSocket 通道(3.3.5)**:`src/channel/` 自实现 RFC 6455 服务端子集(握手/掩码/分片/控制帧/心跳),
|
|
23
|
+
经 `webServer.registerUpgrade` 挂载 `ws://<host>/dsh-master/ws/command-center?token=…`;
|
|
24
|
+
鉴权 = 随机 token(`status().channel` 暴露给同源面板)+ **Origin 校验** + 连接数上限,fail-closed;
|
|
25
|
+
连接即推 `hello` 与 `dual` 快照,`ping/resync` 可用;**`chat`/`answer` 返回 `session-prompt-unavailable` /
|
|
26
|
+
`session-answer-unavailable`**——插件侧没有“向 preset 会话投递消息”的 Host API,不假装能聊天。
|
|
27
|
+
详见 `docs/CHANNEL-WEBSOCKET.md`(含威胁模型与缺失能力清单)。
|
|
28
|
+
- **中断机制(3.3.5)**:`src/channel/abort-registry.ts` 取消原语(begin/abort/end、同 id 覆盖旧流、
|
|
29
|
+
断连与卸载清理、幂等)+ 通道 `interrupt` 消息与 `interrupted` 确认;`stream_end` 支持 `interrupted`;
|
|
30
|
+
状态机收到中断后清光标、**保留半截文本**(`savedContent` 优先),`isGenerating(state)` 驱动“发送/停止”切换。
|
|
31
|
+
没有在跑的流时如实返回 `no-active-stream`。演示服务实测:3 chunk 后中断 → `interrupted(savedContent)` → 流程不再继续。
|
|
32
|
+
- **PluginUpdater 生产级增强(3.3.5)**:`dispose()` 生命周期中止(宿主卸载不再留僵尸轮询)、
|
|
33
|
+
外部 `signal` + 整体超时 + dispose 三者合并、**卡死的 `jobStatus` 也能被信号打断**(Promise.race)、
|
|
34
|
+
中止后回滚(快照前中止则不动环境)、冒烟期中止不会被当成功、`minProgressIntervalMs` 进度节流
|
|
35
|
+
(阶段切换与终态必发)、`onFatal()` 致命告警钩子、`shellArgv()`/`buildSmokeScript()` 命令构造
|
|
36
|
+
(脚本始终单 argv,不手写转义;端口不硬编码;重试次数与间隔 clamp)。
|
|
37
|
+
- **可中断 LLM 流(3.3.5)**:`src/engine/llm-stream.ts` —— `createLlmStreamer({streamFn})` 封装宿主真实的
|
|
38
|
+
`ctx.llm.stream(options)`(`signal` 原生支持取消,`finish` 可能是 `aborted`):
|
|
39
|
+
同 key 新流先中止旧流、中止保留半截文本(`savedContent`)、错误三分(`aborted` / `llm-error` / `stream-failed`)、
|
|
40
|
+
`dispose()` 中止全部、`finally` 关闭上游。文档明确标注「停止扣费」无法从插件侧验证。
|
|
41
|
+
- 测试:新增 `test/pm-pjm-loop.test.mjs`(9 条)与 `test/engine.test.mjs`(8 条);全量 **479 条**。
|
|
42
|
+
|
|
43
|
+
- **新增 `src/engine/`**(导出 `./engine`):
|
|
44
|
+
- `switchPointer` / `readPointer` / `switchWithRollback`:目录指针原子切换;
|
|
45
|
+
做法是「临时链接 + rename」;**Windows 上 rename 无法覆盖已存在 junction(EPERM)**,
|
|
46
|
+
退化为「先移开再放」,结果里明确 `atomic:false / replaced:'move-aside'`,不假装原子;
|
|
47
|
+
- `probeHealth`:有总超时与最大次数的健康检查,只有期望状态码才算健康,连不上不算健康;
|
|
48
|
+
- `runSafeUpdate`:**snapshot → update → smoke → 失败 restore(+restart)**;
|
|
49
|
+
执行能力由调用方注入 `executor`,房主不自带 VM,缺执行器直接返回 `executor-required`;
|
|
50
|
+
- `switchAndVerify`:切指针后做健康检查,失败切回**切换前**的旧目标。
|
|
51
|
+
- **与草案的差异**:草案的 `AtomicSwitcher` / `EssenceUpdater` / `PluginSafeUpdater` 依赖
|
|
52
|
+
`VmManager` 与 `vm_create/vm_exec/vm_snapshot`,房主与当前 Host 都没有该工具面;
|
|
53
|
+
本版只实现可验证的宿主原语,VM 对接留作适配层(不在本版)。
|
|
54
|
+
- 测试:新增 `test/engine.test.mjs`(8 条,真实临时目录 + 真实本地 HTTP + 注入执行器),
|
|
55
|
+
覆盖:两次切换、拒绝覆盖真实目录、目标缺失、自引用、健康成功/503/不可达、快照失败阻断、
|
|
56
|
+
冒烟失败回滚并重启、健康失败回滚到旧槽。
|
|
57
|
+
- 文档:`docs/ENGINE-PRIMITIVES.md`。
|
|
58
|
+
|
|
1
59
|
## 3.3.4(2026-11-14 已发布)
|
|
2
60
|
|
|
3
61
|
> 三方发布:annotated tag `v3.3.4` + GitHub Release + npm `@twinsearth/dsh-master@3.3.4`。
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# 智囊团 WebSocket 通道(3.3.5)
|
|
2
|
+
|
|
3
|
+
> 宿主侧实现:`src/channel/`
|
|
4
|
+
> 一句话:**管道是真的,聊天口还没开。**
|
|
5
|
+
|
|
6
|
+
## 1. 为什么是 WebSocket,以及为什么不用 `ws`
|
|
7
|
+
|
|
8
|
+
- `webServer.registerUpgrade({ path, handler(req, socket, head) })` 是 DSH 原生能力(已用 Inspect 核实),
|
|
9
|
+
所以 WebSocket 不需要额外服务、也不需要 `ws` 依赖;
|
|
10
|
+
- 帧编解码在 `src/channel/ws.ts` 里自己实现(RFC 6455 服务端子集),保持房主**零运行时依赖**;
|
|
11
|
+
- 握手 accept 值用 RFC 官方测试向量验证:`dGhlIHNhbXBsZSBub25jZQ==` → `s3pPLMBiTxaQ9kYGzzhZRbK+xOo=`。
|
|
12
|
+
|
|
13
|
+
## 2. 端点与鉴权
|
|
14
|
+
|
|
15
|
+
```
|
|
16
|
+
ws://<host>/dsh-master/ws/command-center?token=<channelToken>
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
| 检查 | 失败行为 |
|
|
20
|
+
|---|---|
|
|
21
|
+
| 升级握手合法(Upgrade/Connection/Key 16B/Version 13) | `400` + 具名 code |
|
|
22
|
+
| `Origin` 与宿主 origin 一致(浏览器一定带 Origin) | `403 channel-origin-rejected` |
|
|
23
|
+
| `?token=` 或 `Authorization: Bearer` 等于通道 token | `401 channel-unauthorized` |
|
|
24
|
+
| 连接数 ≤ 8 | `403 channel-full` |
|
|
25
|
+
| 没有配置 token(fail-closed) | 通道不挂载,状态里报 `channel-token-missing` |
|
|
26
|
+
|
|
27
|
+
token 每次插件加载随机生成(`randomBytes(24)`),通过只读状态接口 `status().channel` 暴露给同源面板。
|
|
28
|
+
|
|
29
|
+
**威胁模型(如实说)**:本地进程读 status 也能拿到 token,因此 token 是纵深防御,真正的门是 **Origin 校验**;
|
|
30
|
+
对「本机任意进程」这一层没有硬防护——那需要宿主级的 peer/principal 鉴权,当前插件拿不到。
|
|
31
|
+
|
|
32
|
+
## 3. 协议
|
|
33
|
+
|
|
34
|
+
客户端 → 服务端:
|
|
35
|
+
|
|
36
|
+
```json
|
|
37
|
+
{ "type": "ping" }
|
|
38
|
+
{ "type": "resync" }
|
|
39
|
+
{ "type": "chat", "content": "…", "agent": "pm" }
|
|
40
|
+
{ "type": "answer", "callId": "call_1", "value": "A" }
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
服务端 → 客户端:
|
|
44
|
+
|
|
45
|
+
```json
|
|
46
|
+
{ "type": "hello", "protocol": 1, "capabilities": { "dualSnapshot": true, "chat": false, "answer": false },
|
|
47
|
+
"limitations": { "chat": "…", "answer": "…" }, "tokenRequired": true, "serverTime": 0 }
|
|
48
|
+
{ "type": "dual", "snapshot": { "handoffs": [], "plans": [], "feedbacks": [], "reviews": [], "loop": {} } }
|
|
49
|
+
{ "type": "pong", "at": 0 }
|
|
50
|
+
{ "type": "error", "code": "…", "message": "…", "detail": {} }
|
|
51
|
+
```
|
|
52
|
+
|
|
53
|
+
校验全部具名返回:`invalid-json` / `unknown-type` / `content-required` / `content-too-long` /
|
|
54
|
+
`agent-invalid` / `answer-incomplete` / `message-too-large`(64 KiB)。
|
|
55
|
+
|
|
56
|
+
## 4. `chat` 为什么返回错误(不假装)
|
|
57
|
+
|
|
58
|
+
`chat` 的语义是「把用户输入提交给 PM/PJM 会话」。当前插件**没有**这个能力:
|
|
59
|
+
|
|
60
|
+
| 需要的 Host API | 现状 |
|
|
61
|
+
|---|---|
|
|
62
|
+
| 以 `master-pm`/`master-pjm` preset 新建或选中一个会话 | 未核实到插件可用的“按 preset 建会话并拿到 Agent”入口 |
|
|
63
|
+
| 向该会话提交一条 user message 并触发一轮 turn | `sessions` 只有 store(create/prepare/enter/announce/get/list/fork),**没有投递消息** |
|
|
64
|
+
| 订阅该会话的流式事件 | 会话事件可通过 store 观察,但没有面向“外部通道”的订阅口 |
|
|
65
|
+
| 回答该会话里的 user question | `userQuestions.answer(agent, callId, answer)` 需要 **Agent** 句柄,插件侧拿不到 |
|
|
66
|
+
|
|
67
|
+
所以通道对 `chat` 返回:
|
|
68
|
+
|
|
69
|
+
```json
|
|
70
|
+
{ "type": "error", "code": "session-prompt-unavailable",
|
|
71
|
+
"message": "this host has no API to submit a user message into a preset agent session; use a PM/PJM session to chat",
|
|
72
|
+
"detail": { "agent": "pm", "protocol": 1 } }
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`answer` 同理返回 `session-answer-unavailable`。**面板不会因为 WS 通了就能聊天**——这一点写在 `hello.capabilities` 里,
|
|
76
|
+
客户端可以据此禁用输入框(当前 `client.js` 只看板,不提供聊天入口)。
|
|
77
|
+
|
|
78
|
+
## 5. 流式渲染状态机(`stream-state.ts`)
|
|
79
|
+
|
|
80
|
+
UI 侧的拼接规则抽成了纯模块(`src/channel/stream-state.ts`,包导出 `./channel/stream-state`):
|
|
81
|
+
|
|
82
|
+
```ts
|
|
83
|
+
const chat = createChatStream()
|
|
84
|
+
chat.apply(serverMessage) // 返回新 state,React 直接渲染
|
|
85
|
+
chat.getState() // { messages, capabilities, snapshot, orphanChunks, lastError }
|
|
86
|
+
```
|
|
87
|
+
|
|
88
|
+
| 输入 | 行为 |
|
|
89
|
+
|---|---|
|
|
90
|
+
| `stream_start` | 新建空气泡并标记 `streaming`;**重复 start 幂等**(不新建第二条,保留已收文本) |
|
|
91
|
+
| `stream_chunk` | 追加到对应气泡;**孤儿帧只计数**(`orphanChunks++`),绝不凭空造消息 |
|
|
92
|
+
| `stream_end` | 清除光标;`fullContent` 比已收文本长时以其为准 |
|
|
93
|
+
| `card_choice` / `card_prd` / `card_status` | 收尾正在流式的文本,插入卡片;`card_status` 同 id 原地更新(进度条不重复插卡) |
|
|
94
|
+
| `answerChoice(id, value, {echo})` | **本地乐观**回答选择题:卡片置 `answered` 并写 `answer`,追加 `local:true` 用户气泡;重复回答被拒(`choice-already-answered`) |
|
|
95
|
+
| `error` | 变成 `system/error` 消息并记入 `lastError`(UI 可据此禁用输入) |
|
|
96
|
+
| `hello` | 记录 `capabilities`,`chat:false` 时 UI 禁用输入 |
|
|
97
|
+
| `dual` | 只更新 `snapshot`,**不产生聊天消息** |
|
|
98
|
+
| 未知类型 | 计数忽略,不抛 |
|
|
99
|
+
|
|
100
|
+
**渲染管线(极简 + 引用稳定)**:
|
|
101
|
+
|
|
102
|
+
| API | 作用 |
|
|
103
|
+
|---|---|
|
|
104
|
+
| `apply(message)` | 单条应用;每次通知订阅者 |
|
|
105
|
+
| `applyBatch(messages)` | 批量应用,**只通知一次**(UI 按帧 flush) |
|
|
106
|
+
| `subscribe(listener)` | 外部 store 订阅,配合 `useSyncExternalStore` |
|
|
107
|
+
| `getState().activeStreamId` / `activeMessage(state)` | 当前正在流式的那一条,避免全量比较 |
|
|
108
|
+
| `isGenerating(state)` | 发送键 ↔ 停止键切换 |
|
|
109
|
+
|
|
110
|
+
性能关键:状态机**只替换变化的那条消息对象**,历史消息引用保持不变,`React.memo` 才能生效。
|
|
111
|
+
测试直接断言「50 个 chunk 后历史消息 `===` 原对象」(`test/stream-state.test.mjs`)。
|
|
112
|
+
|
|
113
|
+
有界:200 条消息、单条 20 万字符、选项 ≤20、快照 ≤256 KiB(超限置空)。
|
|
114
|
+
|
|
115
|
+
**演示**:`docs/examples/command-center/demo-server.mjs` 复用房主的 RFC 6455 编解码起一个本地流式服务,
|
|
116
|
+
实际观测到的帧序列为 `hello → dual → stream_start → stream_chunk → stream_end → card_choice`。
|
|
117
|
+
|
|
118
|
+
## 6. 中断(Interrupt)
|
|
119
|
+
|
|
120
|
+
```json
|
|
121
|
+
客户端 → 服务端:{ "type": "interrupt", "messageId": "msg-1" } // messageId 可省略 = 停本连接全部
|
|
122
|
+
服务端 → 客户端:{ "type": "interrupted", "messageId": "msg-1", "savedContent": "半截文本", "aborted": true }
|
|
123
|
+
或:{ "type": "error", "code": "no-active-stream" } // 没有流在跑,如实说
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
语义(`src/channel/abort-registry.ts`,全部有测试):
|
|
127
|
+
|
|
128
|
+
| 场景 | 行为 |
|
|
129
|
+
|---|---|
|
|
130
|
+
| 生产者登记流 | `channel.openStream(messageId)` → `{ signal, end() }`;生产者必须在循环里检查 `signal.aborted` |
|
|
131
|
+
| 用户中断 | 按 messageId 中止;没有正在跑的流 → `no-active-stream`(不假装中止成功) |
|
|
132
|
+
| 同一 messageId 重复登记 | **旧流立即中止**(reason=`superseded`),避免两路流写同一条消息 |
|
|
133
|
+
| 连接关闭 / 拔网线 | 中止该连接拥有的全部流(`connection-closed`),不留僵尸生成 |
|
|
134
|
+
| 心跳超时(2×heartbeat 无任何数据) | 判定僵尸连接 → 先中止其流 → `destroy()` socket;**不等对端回 close 帧** |
|
|
135
|
+
| 插件卸载 | `dispose()` 中止所有流(`server-shutdown`)并清理心跳定时器 |
|
|
136
|
+
| 重复 abort | 第二次返回 false(不会重复计数/重复扣费) |
|
|
137
|
+
| 收尾 | `end()` 幂等清理登记;中止后登记保留到 `end()`,便于观察“已中止未收尾” |
|
|
138
|
+
|
|
139
|
+
前端(参考 UI):`isGenerating(state)` 为真时发送键变红色停止键,点击发 `interrupt`;
|
|
140
|
+
收到 `interrupted` 或 `stream_end{interrupted}` 后光标消失、输入框恢复、**半截文本保留**(`savedContent` 更长时以其为准)。
|
|
141
|
+
|
|
142
|
+
实测(`demo-server.mjs` + Node 内置 WebSocket 客户端):3 个 chunk 后发送 interrupt,得到
|
|
143
|
+
`interrupted(savedContent="收到需求。我")` → `stream_end{interrupted:true}`,且**后续流程不再继续**。
|
|
144
|
+
|
|
145
|
+
## 7. 现在能做什么
|
|
146
|
+
|
|
147
|
+
| 能力 | 状态 |
|
|
148
|
+
|---|---|
|
|
149
|
+
| 同源面板建立 WS 连接、心跳、断线重连(客户端侧) | 通道已就绪;面板目前仍用轮询,可后续切换 |
|
|
150
|
+
| 推送交接/计划/反馈/复核快照(`dual`) | ✅ 连接时推一次,`resync` 可再拉 |
|
|
151
|
+
| `ping`/`pong` 保活与超时断连 | ✅ 2× heartbeat 无数据即断开 |
|
|
152
|
+
| 协议违规断连(未加掩码、超大帧、非法 JSON 不断连只回错误) | ✅ |
|
|
153
|
+
| 向 PM/PJM 会话聊天、回答问题 | ❌ 缺 Host API,见 §4 |
|
|
154
|
+
|
|
155
|
+
## 6. 测试
|
|
156
|
+
|
|
157
|
+
`test/channel.test.mjs`(10 条,全部离线、真实帧字节):
|
|
158
|
+
|
|
159
|
+
- RFC 6455 accept 向量与非法握手;
|
|
160
|
+
- 掩码帧解析、未加掩码拒绝、分片续帧、16 位长度;
|
|
161
|
+
- ping/pong/close 控制帧;
|
|
162
|
+
- 协议校验的每条具名错误;
|
|
163
|
+
- token 解析(query 与 Bearer);
|
|
164
|
+
- 无 token / 无 webServer 时拒绝挂载;
|
|
165
|
+
- 错误 token、错误 Origin、正常连接(hello + dual);
|
|
166
|
+
- `chat`/`answer` 返回具名缺口、`ping`/`resync` 正常、`dispose` 断开;
|
|
167
|
+
- 协议违规关闭连接。
|
|
168
|
+
|
|
169
|
+
另有 `test/core.test.mjs` 断言:`status().channel` 存在、token 长度 ≥ 32、升级路由确实注册到 `webServer.registerUpgrade`。
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
# 智囊团面板(Command Center)· 规划与设计
|
|
2
|
+
|
|
3
|
+
> 版本:3.3.5-dev · 范围:房主 `E:\GPT\dsh-master` 客户端面板
|
|
4
|
+
> 状态:设计定稿 → 代码收尾 → 测试 → 完善
|
|
5
|
+
|
|
6
|
+
---
|
|
7
|
+
|
|
8
|
+
## 1. 目标
|
|
9
|
+
|
|
10
|
+
在房主侧边栏「房主」抽屉里,给 PM/PJM 双智能体一个**可读的闭环视图**:
|
|
11
|
+
能看出「谁在等谁、卡在哪、做到哪一步」,而不是只能看到两个计数。
|
|
12
|
+
|
|
13
|
+
## 2. 非目标(明确不做,避免做出假象)
|
|
14
|
+
|
|
15
|
+
| 不做 | 原因 |
|
|
16
|
+
|---|---|
|
|
17
|
+
| 面板内直接和 PM/PJM 聊天 | 没有“面板↔指定 Agent 会话”的 Host 通道;PM/PJM 是 Agent preset,聊天发生在会话里 |
|
|
18
|
+
| 面板内回答问题 / 替用户做选择 | 决策必须发生在 PM 会话(并带权限与审计);面板点击无法落账 |
|
|
19
|
+
| 面板内派发 / 结案 / 生成 PRD | 会绕过 `fangForker` fail-closed、复核门禁与 Mandate 边界 |
|
|
20
|
+
| 用 Tailwind / Lucide / TSX 上线 | DSH 客户端插件是纯 JS + `h()`,无构建链路;参考 TSX 只放 `docs/examples` |
|
|
21
|
+
| 用 setTimeout 模拟对话 | 假聊天会让人误以为智能体在工作(已在测试里静态禁止) |
|
|
22
|
+
|
|
23
|
+
## 3. 真实约束(来自仓库与宿主)
|
|
24
|
+
|
|
25
|
+
1. 客户端插件:`client.js`,React 由宿主注入,`h()` 渲染,内联 style;
|
|
26
|
+
2. 面板已有的数据源:`GET /dsh-master/api/status`(`agents` / `dual` 摘要);
|
|
27
|
+
3. **摘要不够**:要看交接/计划/反馈/复核的明细,需要新的只读端点;
|
|
28
|
+
4. 所有 Agent 状态都在 `dual-manager`(内存 Service)里,没有持久化;
|
|
29
|
+
5. 守则 §二:不安装、不发布到 profile,因此面板无法在真实宿主里点选验证。
|
|
30
|
+
|
|
31
|
+
## 4. 数据契约
|
|
32
|
+
|
|
33
|
+
### 4.1 新增只读端点
|
|
34
|
+
|
|
35
|
+
```
|
|
36
|
+
GET /dsh-master/api/dual
|
|
37
|
+
```
|
|
38
|
+
|
|
39
|
+
响应(有 dual 服务):
|
|
40
|
+
|
|
41
|
+
```json
|
|
42
|
+
{
|
|
43
|
+
"ok": true,
|
|
44
|
+
"available": true,
|
|
45
|
+
"updatedAt": 1794580000000,
|
|
46
|
+
"agents": { "pm": "master-pm", "pjm": "master-pjm" },
|
|
47
|
+
"loop": { "pendingHandoffs": 1, "acceptedHandoffs": 0, "awaitingReview": 0, "accepted": 0, "changesRequested": 0 },
|
|
48
|
+
"handoffs": [{ "id": "handoff_1", "prdId": "prd_1", "title": "…", "status": "ready|needs-clarification|accepted",
|
|
49
|
+
"reason": null, "openQuestions": ["…"], "acceptance": ["…"], "planId": null, "preparedAt": 0 }],
|
|
50
|
+
"plans": [{ "id": "plan_1", "title": "…", "status": "planned|running|done|closed",
|
|
51
|
+
"handoffId": "handoff_1", "progress": { "total": 3, "done": 1, "percent": 33, "canClose": false },
|
|
52
|
+
"lastReport": { "status": "progress|blocked|done", "summary": "…", "blocker": null, "at": 0 },
|
|
53
|
+
"pmReview": { "verdict": "accepted|changes-requested", "accepted": true },
|
|
54
|
+
"feedbackId": "feedback_1" }],
|
|
55
|
+
"feedbacks": [{ "id": "feedback_1", "planId": "plan_1", "summary": "…", "metrics": { }, "openIssues": [] }],
|
|
56
|
+
"reviews": [{ "id": "pmreview_1", "feedbackId": "feedback_1", "planId": "plan_1",
|
|
57
|
+
"verdict": "accepted|changes-requested", "failed": ["未达标验收项"] }]
|
|
58
|
+
}
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
没有 dual 服务时:
|
|
62
|
+
|
|
63
|
+
```json
|
|
64
|
+
{ "ok": true, "available": false, "reason": "dual-service-unavailable",
|
|
65
|
+
"handoffs": [], "plans": [], "feedbacks": [], "reviews": [], "loop": null }
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
**约束**:只读、无写口;不含 token/secret/authorization;列表逐项截断(问题/验收各 ≤10,摘要 ≤500 字符)。
|
|
69
|
+
|
|
70
|
+
### 4.2 与已有端点的分工
|
|
71
|
+
|
|
72
|
+
| 端点 | 用途 | 频率 |
|
|
73
|
+
|---|---|---|
|
|
74
|
+
| `/dsh-master/api/status` | 角色、子插件、agents、dual 摘要、成本 | 5s |
|
|
75
|
+
| `/dsh-master/api/dual` | 交接/计划/反馈/复核明细 | 5s |
|
|
76
|
+
|
|
77
|
+
## 5. 界面结构
|
|
78
|
+
|
|
79
|
+
```
|
|
80
|
+
房主抽屉
|
|
81
|
+
├─ 主 Agent 控制面(角色/版本/运行时长)
|
|
82
|
+
├─ 子插件卡片 ×5(Router/Worker/Loop/cost/dual)
|
|
83
|
+
├─ 智能体卡片 ×2(master-pm / master-pjm,可选状态与工具边界)
|
|
84
|
+
├─ 智囊团 · Command Center(摘要:PRD/盘点/交接/复核、计划/结案/反馈、闭环计数、协作可用性)
|
|
85
|
+
├─ 智囊团 · 闭环看板(本设计新增,五段)
|
|
86
|
+
│ ① 需要 PM 澄清(needs-clarification + 未决问题)
|
|
87
|
+
│ ② 交接已就绪(等 PJM 接单,显示验收条数)
|
|
88
|
+
│ ③ 项目进度(done/total + 百分比 + 最近节点 + blocked 原因)
|
|
89
|
+
│ ④ 等 PM 复核(反馈摘要 + 遗留问题数)
|
|
90
|
+
│ ⑤ 已复核(通过/退回 + 未达标项)
|
|
91
|
+
├─ 成本(只读)
|
|
92
|
+
├─ 与瓮城协作
|
|
93
|
+
└─ 实时事件流(SSE)
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
## 6. 状态机与显示规则
|
|
97
|
+
|
|
98
|
+
| 状态 | 显示 |
|
|
99
|
+
|---|---|
|
|
100
|
+
| 读取中 | “正在读取交接/计划/反馈/复核…” |
|
|
101
|
+
| 不可达 | 红字错误,不显示旧数据为“正常” |
|
|
102
|
+
| `available:false` | “dual-manager 未启用…” + 指明工具会返回 `dual-service-unavailable` |
|
|
103
|
+
| 空 | 各段不渲染,只留计数行,不编造条目 |
|
|
104
|
+
| 有数据 | 每段最多 3 条,按状态过滤;其余只给计数 |
|
|
105
|
+
|
|
106
|
+
统一规则:
|
|
107
|
+
- **只读**:所有条目无 onClick、无输入框、无“发送”;
|
|
108
|
+
- **不许假装**:①段明确写“不能在面板里回答”;
|
|
109
|
+
- 长文本截断(标题不截、摘要 60 字、未决问题 10 条)。
|
|
110
|
+
|
|
111
|
+
## 7. 失败与降级
|
|
112
|
+
|
|
113
|
+
| 故障 | 行为 |
|
|
114
|
+
|---|---|
|
|
115
|
+
| `/api/dual` 404 / 非 JSON | 显示“智囊团数据不可达”,保留其它卡片 |
|
|
116
|
+
| dual 抛异常 | 服务端 `safe()` 兜底返回空数组,面板显示空而不崩 |
|
|
117
|
+
| 轮询重叠 | 单次 `load` 无状态累积,天然幂等 |
|
|
118
|
+
| 组件卸载 | `alive` 标记 + 清定时器,避免 setState 泄漏 |
|
|
119
|
+
|
|
120
|
+
## 8. 安全
|
|
121
|
+
|
|
122
|
+
- 端点只读(GET),不接入 `bridgeToken`,不暴露 dispatch 配置;
|
|
123
|
+
- 快照只含 id/标题/状态/计数/摘要;
|
|
124
|
+
- 测试断言:快照文本不得出现 `token|secret|authorization|apikey`;
|
|
125
|
+
- 面板不调用任何 `pm_*` / `pjm_*` 工具(静态断言),也不出现模拟对话定时器。
|
|
126
|
+
|
|
127
|
+
## 9. 测试计划
|
|
128
|
+
|
|
129
|
+
| 层 | 用例 |
|
|
130
|
+
|---|---|
|
|
131
|
+
| 服务端 | `/api/dual` 已注册;无 dual 时 `available:false`;有 dual 时 handoffs/plans 形状与计数;无凭据字段 |
|
|
132
|
+
| 客户端契约 | `client.js` 引用 `/dsh-master/api/dual`、有 `dual-board` 标记、含“不能在面板里回答”、无 mock 定时器、不调 agent 工具 |
|
|
133
|
+
| 回归 | 既有 `command-center` 断言仍通过;全量 `check/build/test:clean` |
|
|
134
|
+
|
|
135
|
+
## 10. 落地步骤
|
|
136
|
+
|
|
137
|
+
1. **P1 端点**:`src/index.ts` 增加 `DUAL_ROUTE` + `dualSnapshot()`(只读、截断、无凭据);
|
|
138
|
+
2. **P2 面板**:`client.js` 增加 `DualBoard`(五段、状态机、轮询);
|
|
139
|
+
3. **P3 测试**:`core.test.mjs` 路由形状 + 凭据断言;`package-contract.test.mjs` 面板契约;
|
|
140
|
+
4. **P4 参考实现**:`docs/examples/command-center/`(Tailwind/Lucide 版:`types.ts` + `MessageComponents.tsx` + `AgentCommandCenter.tsx` + `README.md`,标明不可直接上线 + 接真后端要补的 4 项 Host 能力);
|
|
141
|
+
5. **P5 验证**:`npm run check` / `build` / `test:clean`,确认全绿后提交。
|
|
142
|
+
|
|
143
|
+
## 11. 未决问题(需要你定)
|
|
144
|
+
|
|
145
|
+
1. **是否要“从面板新建 PM/PJM 会话”**?这需要新增 Host 能力(创建 preset 会话 + 提交消息 + 回答提问),范围明显更大;
|
|
146
|
+
2. **是否要持久化闭环数据**?当前全在内存,面板重启即空;持久化要设计落盘格式与清理策略;
|
|
147
|
+
3. **是否要面板只显示“我的”数据**?现在显示全部手头的交接/计划,单用户场景够用,多用户需要过滤维度。
|
|
@@ -0,0 +1,215 @@
|
|
|
1
|
+
# 宿主侧引擎原语:指针切换 / 健康检查 / 安全更新
|
|
2
|
+
|
|
3
|
+
> 3.3.5 新增。源码 `src/engine/`,对外导出 `@twinsearth/dsh-master/engine`。
|
|
4
|
+
> 这三个原语只回答「切换/验证/回滚」的**正确性**,不负责管理 VM,也不 import 瓮城代码。
|
|
5
|
+
|
|
6
|
+
## 为什么单独拆出来
|
|
7
|
+
|
|
8
|
+
规格里的 `AtomicSwitcher` / `EssenceUpdater` / `PluginSafeUpdater` 都假设存在一个
|
|
9
|
+
`VmManager` 和 `vm_create`/`vm_exec`/`vm_snapshot` 那套工具。核对结果:**房主没有这套工具面**,
|
|
10
|
+
当前 Host 也没有。若照着写,只能写出“看起来能编译、实际调不到”的骨架。
|
|
11
|
+
|
|
12
|
+
所以这里改成三层可验证的实现:
|
|
13
|
+
|
|
14
|
+
| 原语 | 文件 | 依赖 |
|
|
15
|
+
|---|---|---|
|
|
16
|
+
| 指针原子切换 | `atomic-switch.ts` | 只用 `node:fs/promises` + 平台判断 |
|
|
17
|
+
| 健康检查 | `health-probe.ts` | 只用 `fetch`(可注入) |
|
|
18
|
+
| 安全更新 + 回滚 | `safe-update.ts` | **执行能力由调用方注入**(`executor`) |
|
|
19
|
+
|
|
20
|
+
## 1. 指针切换
|
|
21
|
+
|
|
22
|
+
```js
|
|
23
|
+
import { switchPointer, readPointer, switchWithRollback } from '@twinsearth/dsh-master/engine'
|
|
24
|
+
|
|
25
|
+
await switchPointer({ linkPath: '/srv/current', targetDir: '/srv/green' })
|
|
26
|
+
// → { ok: true, code: 'switched', linkType: 'dir', atomic: true, resolvedMatchesTarget: true }
|
|
27
|
+
```
|
|
28
|
+
|
|
29
|
+
- 做法:新链接写临时名 → `rename` 覆盖(同文件系统内原子);
|
|
30
|
+
- **Windows 真实限制**:`rename` 不能覆盖已存在的 junction(`EPERM`),退化为「先移开再放」,
|
|
31
|
+
结果里 `atomic: false`、`replaced: 'move-aside'`——如实标记,不假装原子;
|
|
32
|
+
- 拒绝:目标不存在、目标是普通文件/目录(不覆盖真实数据)、自引用、父目录缺失、跨卷;
|
|
33
|
+
- `switchWithRollback()`:切换失败时把指针切回原目标,并返回 `rollback` 结果。
|
|
34
|
+
|
|
35
|
+
## 2. 健康检查
|
|
36
|
+
|
|
37
|
+
```js
|
|
38
|
+
await probeHealth({ url: 'http://127.0.0.1:60457/api/health', timeoutMs: 30_000, intervalMs: 1_000 })
|
|
39
|
+
// → { ok: true, status: 200, attempts, elapsedMs } 或
|
|
40
|
+
// { ok: false, code: 'unhealthy-status' | 'probe-failed' | 'probe-timeout', status, attempts }
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
- 有总超时与最大次数,不会无限轮询;
|
|
44
|
+
- 只有拿到期望状态码才算健康;**连不上不等于健康**;
|
|
45
|
+
- `switchAndVerify()` = 切指针 + 健康检查;检查失败时切回**切换前**的旧目标(不是新目标)。
|
|
46
|
+
|
|
47
|
+
## 3. 安全更新
|
|
48
|
+
|
|
49
|
+
```js
|
|
50
|
+
await runSafeUpdate({
|
|
51
|
+
executor: { snapshot, restore, run }, // 必须注入;房主不自带 VM
|
|
52
|
+
update: ['dsh', 'plugin', 'update', '--all'],
|
|
53
|
+
smoke: ['dsh', 'restart'],
|
|
54
|
+
restart: ['dsh', 'restart'],
|
|
55
|
+
})
|
|
56
|
+
```
|
|
57
|
+
|
|
58
|
+
- 顺序固定:**snapshot → update → smoke**,任何一步失败即 `restore`(+ 可选 `restart`);
|
|
59
|
+
- 没有执行器时返回 `executor-required`,不会假装成功;
|
|
60
|
+
- 快照失败直接拒绝更新(没有快照就没有回滚能力)。
|
|
61
|
+
|
|
62
|
+
## 4. 插件安全更新流水线(`plugin-updater.ts`,最完整的那个)
|
|
63
|
+
|
|
64
|
+
```js
|
|
65
|
+
import { createPluginUpdater } from '@twinsearth/dsh-master/engine'
|
|
66
|
+
|
|
67
|
+
const updater = createPluginUpdater({
|
|
68
|
+
executor, // 见下方契约
|
|
69
|
+
logger: { info, warn, error },
|
|
70
|
+
onProgress: (progress) => ui.push(progress), // phase + percent,抛异常也不会打断事务
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
const result = await updater.safeUpdate({
|
|
74
|
+
update: ['dsh', 'plugin', 'update', '--all'],
|
|
75
|
+
smoke: ['dsh', 'restart', '&&', 'curl', '-f', 'http://127.0.0.1:PORT/api/health'],
|
|
76
|
+
restart: ['dsh', 'restart'],
|
|
77
|
+
timeoutMs: 600_000,
|
|
78
|
+
key: 'essence-1', // 并发锁维度
|
|
79
|
+
})
|
|
80
|
+
// → { success, code, message, rolledBack, fatal, snapshotId, steps, phase }
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
**executor 契约**(房主不内置实现,也不假设有 VM):
|
|
84
|
+
|
|
85
|
+
| 方法 | 说明 |
|
|
86
|
+
|---|---|
|
|
87
|
+
| `snapshot(name)` | 建快照;失败即中止更新 |
|
|
88
|
+
| `restore(name)` | 回滚到快照 |
|
|
89
|
+
| `run(argv, {phase})` | 执行命令;返回 `{ok, jobId}`,有 `jobId` 即异步作业 |
|
|
90
|
+
| `jobStatus(jobId)` | `{status:'running'\|'completed'\|'failed', exitCode?, stdout?, stderr?}` |
|
|
91
|
+
| `deleteSnapshot?(name)` | 成功后的清理(可选) |
|
|
92
|
+
|
|
93
|
+
**流水线语义**:
|
|
94
|
+
|
|
95
|
+
```text
|
|
96
|
+
snapshotting ──失败──▶ failed(环境未改动,rolledBack:false)
|
|
97
|
+
│
|
|
98
|
+
▼
|
|
99
|
+
updating ──同步失败/作业失败/作业超时/被中止──▶ rolling-back → restore(+restart)
|
|
100
|
+
│ │
|
|
101
|
+
▼ └─ restore 失败 → code:'rollback-failed', fatal:true
|
|
102
|
+
smoke-testing ──失败/被中止──▶ rolling-back …
|
|
103
|
+
│
|
|
104
|
+
▼
|
|
105
|
+
success(按策略清理快照;清理失败只告警)
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
**生命周期与中断(生产级要点)**:
|
|
109
|
+
|
|
110
|
+
| 能力 | 说明 |
|
|
111
|
+
|---|---|
|
|
112
|
+
| `dispose()` | 宿主卸载时调用(包在 `ctx.effect` 里):中止所有在跑的更新;之后 `safeUpdate` 返回 `updater-disposed` |
|
|
113
|
+
| `safeUpdate({ signal })` | 外部取消;与整体超时、dispose 三者合并成一个内部信号 |
|
|
114
|
+
| 卡死适配器也能打断 | `jobStatus` 与 `sleep` 都和中止信号 `Promise.race`,不会出现“信号来了但轮询还挂着” |
|
|
115
|
+
| 中止后回滚 | 只要快照已经建立,中止即回滚(`aborted` + `rolledBack:true`);快照前中止则不动环境 |
|
|
116
|
+
| 冒烟期间中止 | 冒烟结束前会再检查一次信号,不会把“刚好跑完”当成功 |
|
|
117
|
+
| `minProgressIntervalMs` | 进度节流:阶段切换与终态必发,中间轮询可折叠 |
|
|
118
|
+
| `onFatal(failure)` | 回滚失败时必被调用(宿主可据此弹全局告警);钩子抛异常不改变结论 |
|
|
119
|
+
|
|
120
|
+
**命令构造(避免引号地狱 / 注入)**:
|
|
121
|
+
|
|
122
|
+
```js
|
|
123
|
+
import { shellArgv, buildSmokeScript } from '@twinsearth/dsh-master/engine'
|
|
124
|
+
|
|
125
|
+
const smoke = buildSmokeScript({ port: 18042 }) // set -eu + 有界健康探测,端口来自调用方
|
|
126
|
+
await updater.safeUpdate({ update: ['dsh', 'plugin', 'update', '--all'], smoke: shellArgv(smoke) })
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
`shellArgv(script)` 返回 `['bash', '-lc', script]`:脚本始终是**单个 argv 元素**,不做字符串拼接转义。
|
|
130
|
+
`buildSmokeScript` 的尝试次数与间隔会 clamp(≤60 次 / ≤30 秒),并且**不硬编码 3000 端口**。
|
|
131
|
+
|
|
132
|
+
**与草案 `PluginUpdater.ts` 的差异(都是有意为之)**:
|
|
133
|
+
|
|
134
|
+
| 草案 | 本实现 | 原因 |
|
|
135
|
+
|---|---|---|
|
|
136
|
+
| `vm.snapshot/submitJob/getJobStatus/restore` 直连 | 注入 `executor` | 房主没有 VM 工具面;直连会让代码永远调不到 |
|
|
137
|
+
| 整体超时只传给 `waitForJob(update)` | 整体预算 + 冒烟独立预算 + 每步 `remaining()` | 否则「更新 10 分钟 + 冒烟 1 分钟」会超总预算 |
|
|
138
|
+
| 回滚失败返回 `success:false` | 追加 `fatal:true` + `logger.error('FATAL …')` | 双重故障必须升级为“需人工介入”,不能和普通失败同形 |
|
|
139
|
+
| 无并发保护 | 同 `key` 并发直接 `update-in-progress` | 两路更新会互相踩快照 |
|
|
140
|
+
| 成功即删快照(异步、不等待) | `keepSnapshotOnSuccess` 可保留;删除失败只告警 | 删快照失败不应把成功翻成失败,但也不能静默 |
|
|
141
|
+
| `onProgress` 直接调用 | 回调异常被吞掉 | UI 出错不能让事务半途而废 |
|
|
142
|
+
| `waitForJob` 见 `failed` 即返回 | 同时看 `exitCode` | `completed` 但退出码非 0 也必须回滚 |
|
|
143
|
+
|
|
144
|
+
**接瓮城 VM 层的方式**:写一个 adapter,把 `vm_snapshot`→`snapshot`、`vm_job_submit`→`run`、
|
|
145
|
+
`vm_job_status`→`jobStatus`、`vm_restore`→`restore` 映射过来即可。**不要**在房主代码里直接写 `vm_*` 工具名。
|
|
146
|
+
|
|
147
|
+
## 5. 可中断的 LLM 流(`llm-stream.ts`)
|
|
148
|
+
|
|
149
|
+
**先纠正一个常见误解**:DSH 插件侧的真实 API 是 `ctx.llm.stream(options)`,
|
|
150
|
+
不是 `ctx.llm.client.chat.completions.create`,也不是某个 SDK 的 `create({stream:true})`。
|
|
151
|
+
|
|
152
|
+
```ts
|
|
153
|
+
ctx.llm.stream({
|
|
154
|
+
provider: 'deepseek', model: 'deepseek-chat',
|
|
155
|
+
messages, system, tools,
|
|
156
|
+
signal, // ← 原生支持取消
|
|
157
|
+
})
|
|
158
|
+
// 产出 StreamChunk:{type:'text-delta', text} / {type:'usage'} / {type:'finish', reason}
|
|
159
|
+
// 取消时 reason 是 {kind:'aborted', failure}
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
封装后的用法:
|
|
163
|
+
|
|
164
|
+
```ts
|
|
165
|
+
const streamer = createLlmStreamer({ streamFn: llmStreamFn(ctx.llm) })
|
|
166
|
+
const result = await streamer.run({
|
|
167
|
+
key: 'session-1',
|
|
168
|
+
options: { provider, model, messages, system },
|
|
169
|
+
onDelta: (delta) => ws.send({ type: 'stream_chunk', delta }),
|
|
170
|
+
})
|
|
171
|
+
|
|
172
|
+
// 用户点“停止”:
|
|
173
|
+
streamer.abort('session-1')
|
|
174
|
+
|
|
175
|
+
// result:
|
|
176
|
+
// { ok:false, code:'aborted', text:'半截内容', savedContent:'半截内容', aborted:true }
|
|
177
|
+
// 或 { ok:true, code:'completed', text, usage, finish:{kind:'stop'} }
|
|
178
|
+
// 或 { ok:false, code:'llm-error', failure:{message,code,status} }
|
|
179
|
+
```
|
|
180
|
+
|
|
181
|
+
**语义保证**:
|
|
182
|
+
|
|
183
|
+
| 行为 | 说明 |
|
|
184
|
+
|---|---|
|
|
185
|
+
| signal 透传 | 取消信号直接交给 `ctx.llm.stream`,并 `Promise.race` 每一跳,卡住的上游也能返回 |
|
|
186
|
+
| 半截文本保留 | 中止后 `text` / `savedContent` 是已产出内容,可直接写回上下文续写 |
|
|
187
|
+
| 错误分类 | `aborted`(主动取消)/ `llm-error`(模型侧错误,带 failure)/ `stream-failed`(网络/其他抛出)三分,**不把取消记成失败** |
|
|
188
|
+
| 同 key 覆盖 | 同一会话新开一轮会先中止旧的,避免两路流并发扣费 |
|
|
189
|
+
| 生命周期 | `dispose()` 中止全部(宿主卸载),之后 `run` 返回 `streamer-disposed` |
|
|
190
|
+
| 收尾 | `finally` 里**有界**关闭上游:`return()` 与 50ms 定时器 race,避免卡死的生成器把清理挂住 |
|
|
191
|
+
|
|
192
|
+
**一个真实的坑(已在代码里修)**:卡死的异步生成器会把 `return()` 排在未完成的 `next()` 之后,
|
|
193
|
+
`await iterator.return()` 会**永久挂起**——这正是"僵尸句柄"在清理路径上的翻版。
|
|
194
|
+
所以收尾必须有界:`Promise.race([return(), 50ms])`。
|
|
195
|
+
|
|
196
|
+
**上游卡住兜底**:`createLlmStreamer({ stallTimeoutMs })`。超过该时间没有新 chunk 时,
|
|
197
|
+
主动中止并返回 `code:'stalled'`(`aborted:true`,保留半截文本),避免半死连接一直挂着烧钱。
|
|
198
|
+
|
|
199
|
+
**必须说清的边界**:
|
|
200
|
+
|
|
201
|
+
- 我能保证的是「**我们停止读取、并把 signal 交给了上游**」;
|
|
202
|
+
- 「服务端立刻停止推理与计费」取决于**模型厂商网关是否会因连接断开而终止生成**——
|
|
203
|
+
这一点从插件侧无法验证,也没有日志能证明。任何声称"已验证停止扣费"的说法都是假的;
|
|
204
|
+
- 唯一可观测的间接证据是 usage:如果网关在 stream 结束前发过 `usage`,可以对比中止前后的 token 数;
|
|
205
|
+
但如果它没发,就没有证据。
|
|
206
|
+
|
|
207
|
+
## 6. 接入方式(诚实说明)
|
|
208
|
+
|
|
209
|
+
房主本身不带 VM,`executor` 需要由外部提供:
|
|
210
|
+
|
|
211
|
+
- 瓮城的 VM 层(`vm_snapshot` / `vm_restore` / `vm_exec`)可包成 executor;
|
|
212
|
+
- 或宿主自己的进程/文件管理能力;
|
|
213
|
+
- 或测试里的假执行器(本项目 `test/engine.test.mjs` 就是这么验证顺序与回滚的)。
|
|
214
|
+
|
|
215
|
+
在没有真实 executor 之前,这些原语可以被单测与脚本调用,但**不会自动接管任何宿主进程**。
|