@twinsearth/dsh-master 3.3.4 → 3.3.6
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 +90 -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 +63 -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 +88 -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
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
# 瓮城会话通读与房主查漏补缺(第一轮 · 语料盘点与差距矩阵)
|
|
2
|
+
|
|
3
|
+
> 语料来源:`C:\Users\fangw\.dsh\sessions\--E-ToFuture--\e5dd3d5b-47a3-4e92-a75f-9ce31b8076bf\session.v4.jsonl.zstd`
|
|
4
|
+
> (1,218,908 B → 解压 3,908,690 B / 1,336 事件 / 152 轮用户 / 213 条助手消息 / 2,551,127 字符)
|
|
5
|
+
> 工作区 `E:\ToFuture`:590 个可读文本文件(排除 `full-backup`、`data-backup`、`recovery`、`Chat`、`.tmp-*`)
|
|
6
|
+
> 本文件只做**盘点与对比**;本轮不修改任何 dsh-fang 文件。
|
|
7
|
+
|
|
8
|
+
## 1. 语料结构(213 条助手消息的主题聚类)
|
|
9
|
+
|
|
10
|
+
| 簇 | 消息号 | 主题 |
|
|
11
|
+
|---|---|---|
|
|
12
|
+
| A 插件热更新与安全护栏 | 3–47 | `swapPlugin` 伪代码、RestartGuard 两层状态机、影响面采集(会话/缓冲区)、`_getActiveHandles` 指纹、升红判定、评分子插件(A–E)、动作灯色(红黄蓝绿)、审批/审计、进程级隔离(瓮城 profile、AppContainer、Job Objects) |
|
|
13
|
+
| B Fork/Adapter/IPC | 42–71 | 端口池、junction、退避、`Forker`、协议与版本协商、SSE 解析 + **fuzz/定向模糊测试**、命令缓存、事件环 + cursor、HTTP adapter、tokenRegistry、孤儿恢复、双端口 |
|
|
14
|
+
| C Umbrella 评分与规则 | 72–110 | 插件/动作评分器、YAML 分层规则(硬规则锁定、默认/项目/环境)、AST 静态扫描(bindings 两阶段)、规则表覆盖率、增量重建一致性、健康度/趋势/通知/漂移 |
|
|
15
|
+
| D Mirror/Essence/Derived 与 UI | 111–149 | 三文件夹隔离、配额与 VIP、overlay 合并、per-key 锁、分身不落盘、镜像状态 store、偏好 schema 驱动 UI、引导向导(PC 探测)、版本检查 API、AtomicSwitcher、EssenceUpdater+HealthProbe、PluginUpdater(快照/冒烟/回滚) |
|
|
16
|
+
| E Agent 与前端交互 | 150–171 | PM/PJM Prompt 与工具、WS 协议、流式渲染、块级锁、光标三态、输入框状态机、心跳、AbortController/中断 |
|
|
17
|
+
| F 生态与商业化 | 76, 143, 172–199, 204, 212 | 桌面桥/IM2A、日志位置、L1–L5 演进、决策中心、SuperAgent、HarnessMM(GDR/CCS/RSI)、票证幂等、成本评测框架(4 对照组 × 6 维度) |
|
|
18
|
+
|
|
19
|
+
## 2. 差距矩阵:房主现状 vs 瓮城设计
|
|
20
|
+
|
|
21
|
+
判定:**已有**=房主已实现且有测试;**可借鉴**=通用能力,房主缺;**不适用**=绑定瓮城镜像/分身/VM,房主不该复制。
|
|
22
|
+
|
|
23
|
+
| 域 | 瓮城设计 | 房主现状 | 判定 |
|
|
24
|
+
|---|---|---|---|
|
|
25
|
+
| 插件热更新 | `swapPlugin` 原子交换 + 旧 fiber 有限恢复 + 升红 | 房主不热更插件;有 `engine/atomic-switch`(指针)与 `safe-update`(快照/冒烟/回滚) | 部分对应;**升红语义**可借鉴到 safe-update 的失败分级 |
|
|
26
|
+
| 影响面采集 | 会话/缓冲区收集器 + 排空语义 + dirty 状态机 | 无(房主无热更) | 不适用 |
|
|
27
|
+
| 句柄指纹 | `_getActiveHandles` 指纹 + Stable Window | 无 | **可借鉴**:房主自己也会有泄漏排查需求 |
|
|
28
|
+
| 两层状态机/守卫 | RestartGuard + 审批 + 审计 sink | 有 bridge token 审计、成本审计 | 部分对应 |
|
|
29
|
+
| 评分子插件 | AST 静态扫描(child_process/网络/fs/eval/凭据/混淆/外联)+ 等级 A–E | 无 | 不适用(瓮城装载第三方插件) |
|
|
30
|
+
| 动作灯色 | 命令/路径规则 → 红黄蓝绿 + YAML 分层 + 硬规则锁定 | 无(房主不执行用户命令) | 不适用 |
|
|
31
|
+
| 进程隔离 | 独立 DSH_HOME + AppContainer + Job Objects | 无 | 不适用(房主无子进程树) |
|
|
32
|
+
| Fork/端口/junction | 端口池、退避、双端口、junction | `engine/atomic-switch`(junction 切换)、`fangForker.spawn`(交给瓮城) | **部分可借鉴**:端口池/退避在房主无场景 |
|
|
33
|
+
| SSE 解析 | 纯帧解析 + **fuzz 性质测试 + 定向分片** | 客户端 EventSource;WS 帧解析器自实现(掩码/分片/长度) | **可借鉴(高优先)**:给 WS 解析器做 fuzz/性质测试 |
|
|
34
|
+
| 事件环 + cursor | `EventLog` ring buffer + cursor | 无独立实现;stream-state 有 orphan 计数 | **可借鉴**:SSE/WS 事件补 cursor 与有界缓冲 |
|
|
35
|
+
| 命令缓存/幂等 | `CommandCache` + 幂等键 | 有 dispatch 幂等(`superagent/dispatch`)与 relay 幂等 | 已有(部分) |
|
|
36
|
+
| 异步任务句柄 | 事件驱动 `waitForRestart`,**明确去掉轮询** | `PluginUpdater` 仍轮询 `jobStatus` | **可借鉴(高优先)**:事件优先 + 轮询兜底 |
|
|
37
|
+
| per-key 互斥 | `essence/lock.ts` | 无通用锁;dual-manager 用占位标记 | **可借鉴**:给闭环/计划加 keyed 锁 |
|
|
38
|
+
| 三文件夹隔离 | Mirror/Essence/Derived + 配额 | 房主无实体层;`docs/pm|pjm` 有路径包含检查 | 不适用 |
|
|
39
|
+
| 分身不落盘 | 配置随起随清、数据持久化 | 房主派发交给瓮城 | 不适用 |
|
|
40
|
+
| 版本检查 API | 版本比对 + 角标 | 有 `MASTER_VERSION` 与契约最小版本 | 已有(简化版) |
|
|
41
|
+
| 镜像指引 UI | 状态 store + 偏好 schema + 向导 | 只读面板(agents/dual/成本/协作) | **可借鉴**:偏好/状态入面板,但不做镜像向导 |
|
|
42
|
+
| PM/PJM | Prompt、工具、交接、决策中心 | **已实现**:19 工具 + 闭环 + 编排 + 面板 | 已有(更完整) |
|
|
43
|
+
| 流式/中断/心跳 | WS + abort + 心跳 + terminate | **已实现**:WS 通道 + abort-registry + 中断 + 心跳 + 僵尸清理 | 已有(更完整) |
|
|
44
|
+
| 成本评测 | 4 对照组 × 6 维度、三层任务集 | 有 §10.2 门禁 G1–G7 + A/B 实验(control/enforce) | **可借鉴**:评测矩阵维度与诚实边界写法 |
|
|
45
|
+
| 决策中心 | 票证/选项/审计 | 复用 `fangDecisionCenter` | 已有 |
|
|
46
|
+
| 健康/趋势通知 | health + trend + flapping + resolution | 有 `health()` 与成本审计;无趋势/漂移 | **可借鉴(低优先)**:健康快照趋势 |
|
|
47
|
+
|
|
48
|
+
## 3. 本轮选定的可落地项(按价值/风险排序)
|
|
49
|
+
|
|
50
|
+
| # | 项 | 来源 | 落地位置 | 验收 |
|
|
51
|
+
|---|---|---|---|---|
|
|
52
|
+
| G1 | **WS 帧解析器 fuzz/性质测试**(分片、掩码、长度边界、随机字节不崩) | 簇 B(SSE fuzz) | `test/ws-fuzz.test.mjs` + `src/channel/ws.ts` 如需修 | 不崩、错误具名、合法帧往返一致 |
|
|
53
|
+
| G2 | **异步任务事件驱动**(`executor.onJobDone` 优先,轮询兜底) | 簇 B(去轮询) | `src/engine/plugin-updater.ts` | 事件先到不轮询;无事件时仍超时 |
|
|
54
|
+
| G3 | **per-key 互斥锁**(`createKeyedLock`),用于计划变更与派发 | 簇 D(`essence/lock`) | `src/engine/keyed-lock.ts` + dual-manager | 并发 close/dispatch 不产生中间态 |
|
|
55
|
+
| G4 | **有界事件环 + cursor**(WS 通道事件流) | 簇 B(EventLog) | `src/channel/server.ts` | 越界丢旧、cursor 单调、可重放 |
|
|
56
|
+
| G5 | **句柄泄漏自检**(`process.getActiveHandles` 快照 + 差异报告) | 簇 A(指纹) | `src/engine/handles.ts` | 只报告 refed 新增;测试可控 |
|
|
57
|
+
| G6 | **失败分级升红**(safe-update 结果带 severity) | 簇 A/D | `src/engine/safe-update.ts` | 分级可断言,不改变既有 code |
|
|
58
|
+
|
|
59
|
+
下一轮起逐项实现(每项:代码 + 测试 + 文档 + 提交),全部只动 dsh-master。
|
|
60
|
+
|
|
61
|
+
## 4. 明确不做(避免把瓮城搬进房主)
|
|
62
|
+
|
|
63
|
+
- 镜像/原身/分身三文件夹、配额与 VIP、overlay 合并、蓝色绿灯评分、AppContainer/Job Objects、端口池、SSE 服务端适配器、独立 DSH_HOME 的瓮城实例;
|
|
64
|
+
- 理由:这些绑定瓮城的物理形态;房主的定位是「母体控制面 + PM/PJM 双智能体」,复制会形成第二套并行架构。
|
|
65
|
+
|
|
66
|
+
## 5. 语料可复现
|
|
67
|
+
|
|
68
|
+
```powershell
|
|
69
|
+
node E:\GPT\.tmp\extract-fang-session.cjs # 解压 + 抽取到 E:\GPT\.tmp\fang-session-digest
|
|
70
|
+
node E:\GPT\.tmp\extract-fang-structure.cjs # 标题/关键词索引
|
|
71
|
+
node E:\GPT\.tmp\scan-tofuture.cjs # 工作区 590 个文本文件清单
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
产物:`session.jsonl`、`user-messages.txt`、`assistant-messages.txt`、`headings.txt`、
|
|
75
|
+
`keyword-lines.txt`、`workspace-files.json`。
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
# 房主 PM/PJM 智能体:产品盘点与项目计划(3.3.4)
|
|
2
2
|
|
|
3
|
-
> 产出时间:2026-11-14 · 范围:`E:\GPT\dsh-master`(房主)·
|
|
3
|
+
> 产出时间:2026-11-14 · 范围:`E:\GPT\dsh-master`(房主)· 版本:**3.3.4 已三方发布**
|
|
4
4
|
> 本文件由「房主智能体」的角色契约产出(PM 六类盘点 + PJM 七类项目管理),**只写有证据的结论**。
|
|
5
|
+
> 结案记录见文末「第三部分 · 3.3.4 结案」。
|
|
5
6
|
|
|
6
7
|
---
|
|
7
8
|
|
|
@@ -42,14 +43,13 @@
|
|
|
42
43
|
| 成本优化系统层 + A/B 模块 + 报表/门禁/里程碑(3.3.0) | 100% | 449→462 测试;§10.2 门禁 G1–G7 全绿;真实 A/B −37.6% |
|
|
43
44
|
| 瓮城回流 contract 1.1.1 + 房主 relay 适配(3.3.1/3.3.2) | 100% | npm `1.1.1`;dsh-Fang 3.1.35 双进程实测 6 条回执、幂等重投通过 |
|
|
44
45
|
| 角色矩阵放开:母体全开 + 分身挂双主管(3.3.3) | 100% | `7e2c132`;7 条测试断言;`test:clean` 449/449 |
|
|
45
|
-
| PM/PJM 双智能体 + 共享服务 + 面板(3.3.4
|
|
46
|
+
| PM/PJM 双智能体 + 共享服务 + 面板(3.3.4) | 100% | `666f9bc`、`d1cf443`;`test:clean` 462/462;tag `v3.3.4`=`bcd4a15d`→`6a7ffde6`,Release `444463794`,npm shasum `985677d0` |
|
|
46
47
|
| 三方发布流水线(tag + Release + npm 各四次) | 100% | `v3.3.0`–`v3.3.3`,资产 sha256 与本地一致 |
|
|
47
48
|
|
|
48
49
|
#### 2. 未完成(原因与后续)
|
|
49
50
|
|
|
50
51
|
| 项 | 原因 | 后续 |
|
|
51
52
|
|---|---|---|
|
|
52
|
-
| 3.3.4 发布 | 未获发布授权 | 拿到授权后走同一套门禁 + 三方发布(估 30–45 分钟) |
|
|
53
53
|
| preset 在真实 Host 可选验证 | 守则 §二 禁止向任何 profile 安装 | 需一次**受限安装授权**,装到指定 profile 点选验证 |
|
|
54
54
|
| 面板卡片渲染验证 | 同上,且当前会话面板读的是已安装版本 | 与上一条合并做 |
|
|
55
55
|
| 智能体工具真实会话跑通 | 只有 fake ctx 级测试 | 同上 |
|
|
@@ -148,6 +148,32 @@
|
|
|
148
148
|
|
|
149
149
|
## 需要你做的决定
|
|
150
150
|
|
|
151
|
-
1.
|
|
152
|
-
2.
|
|
153
|
-
3.
|
|
151
|
+
1. 是否把 3.3.4 **装到某个 profile** 做 preset 点选验证(会改动该 profile)?
|
|
152
|
+
2. 规格里的 VM 沙箱 / 三层架构 / 蓝绿升级,是否要**移植进房主**(这属于瓮城能力,需要明确范围)?
|
|
153
|
+
3. PM → PJM 是否要自动交接(当前是人工选择)?
|
|
154
|
+
|
|
155
|
+
---
|
|
156
|
+
|
|
157
|
+
## 第三部分 · 3.3.4 结案(PJM)
|
|
158
|
+
|
|
159
|
+
**结案状态**:已发布(代码 + 三方产物),**宿主级验证未做**。
|
|
160
|
+
|
|
161
|
+
| 项 | 结果 |
|
|
162
|
+
|---|---|
|
|
163
|
+
| 版本 | `3.3.4`(package / kernel / cost-optimizer / 面板一致) |
|
|
164
|
+
| 门禁 | `check` 0 error、`build` 0 error、`test:clean` **462/462**、§10.2 G1–G7 全绿、§14 M1–M5 done |
|
|
165
|
+
| annotated tag | `v3.3.4` → tag object `bcd4a15d675c…` → commit `6a7ffde6cb6e…`(`type=tag`) |
|
|
166
|
+
| GitHub Release | `444463794`,3 个附件(tgz / `.sha256` / `manifest.json`),tgz 520,481 B,sha256 `25b5a410…` |
|
|
167
|
+
| npm | `@twinsearth/dsh-master@3.3.4`,`latest=3.3.4`,shasum `985677d0…`(与 Release 资产 sha1 一致),186 files |
|
|
168
|
+
| CI | run `43463616991` → `completed / success` |
|
|
169
|
+
| manifest | 含 `tagObject`、`companionPackages.@twinsearth/dsh-fang-contract=1.1.1` |
|
|
170
|
+
|
|
171
|
+
**评审报告**:本文件 + `docs/CHANGELOG.md`(3.3.4 段)+ `docs/AGENTS-PM-PJM.md`(角色与工具契约)。
|
|
172
|
+
|
|
173
|
+
**资料汇总**:`src/agents/*`(Prompt 与工具)、`src/dual/*`(契约与文档)、`cordis.patch.yml`(preset 行)、
|
|
174
|
+
`test/dual-agents.test.mjs`(10 条)、发布 manifest 与 tarball。
|
|
175
|
+
|
|
176
|
+
**未完成(如实记录,不包装成已完成)**:
|
|
177
|
+
- 宿主级 preset 点选与工具可见性验证(需安装授权);
|
|
178
|
+
- 面板在真实浏览器中的渲染验证;
|
|
179
|
+
- VM 沙箱 / 三层架构 / 蓝绿升级 **不在本版范围**(属瓮城)。
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
# 智囊团 Command Center(参考实现 · 流式版)
|
|
2
|
+
|
|
3
|
+
> **不随房主插件发布。** 插件里上线的是只读看板(`client.js` 的 `DualBoard`)。
|
|
4
|
+
|
|
5
|
+
## 输入框状态机:三层门,不只靠 disabled
|
|
6
|
+
|
|
7
|
+
| 状态 | 触发 | 输入框 | 按钮 | Enter |
|
|
8
|
+
|---|---|---|---|---|
|
|
9
|
+
| `Idle` | 空闲且已连接 | 可编辑、自动聚焦 | 蓝色「发送」 | 发送 |
|
|
10
|
+
| `Generating` | `isGenerating(state) === true` | **禁用**,placeholder「AI 正在回复,可点击停止…」 | 红色「停止」 | **拦截**(`preventDefault`) |
|
|
11
|
+
| `Offline` | `connected === false` | 禁用,placeholder「连接已断开,正在重连…」 | 禁用 | 无效 |
|
|
12
|
+
|
|
13
|
+
前端三道门(缺一不可):
|
|
14
|
+
|
|
15
|
+
1. **UI 层**:`disabled` + 按钮切换;
|
|
16
|
+
2. **逻辑层**:`sendChat` 顶部三连判断(空输入 / 未连接或未授权 / 正在生成);
|
|
17
|
+
3. **键盘层**:Enter 处理里挡掉三件事——生成中、**输入法候选中的回车**(`isComposing`)、`Shift+Enter` 换行。
|
|
18
|
+
|
|
19
|
+
生成结束后自动把焦点还给输入框,用户不必再点一次。
|
|
20
|
+
|
|
21
|
+
**后端兜底(“打断施法”)**:不能只靠前端。通道在收到新的 `chat` 时,会先中止该连接上
|
|
22
|
+
所有正在跑的流(`superseded-by-new-chat`),再处理新请求;`llm-stream` 层同样保证
|
|
23
|
+
「同一个 key 的新流会先中止旧流」。两层都防,才挡得住多端并发与直调 API。
|
|
24
|
+
|
|
25
|
+
## 打字机光标:三个微状态 + 防换行
|
|
26
|
+
|
|
27
|
+
光标是「我还活着」的信号,但它有三种形态,混用会误导用户:
|
|
28
|
+
|
|
29
|
+
| 状态 | 何时 | 长什么样 | 来源 |
|
|
30
|
+
|---|---|---|---|
|
|
31
|
+
| `thinking` | 收到 `stream_start`,还没有第一个字 | 三个跳动点 | `cursorState(state)` |
|
|
32
|
+
| `streaming` | 正在吐字 | 竖线紧贴最后一个字符(PM 靛蓝 / PJM 翠绿) | 同上 |
|
|
33
|
+
| `interrupted` | 最近一条消息被中止 | 红色竖线闪两下 + 「已中止生成」 | 同上 |
|
|
34
|
+
| `idle` | 没有生成 | 无 | 同上 |
|
|
35
|
+
|
|
36
|
+
**状态由状态机导出**(`cursorState`),组件不自己推导——否则「有没有第一个字」这类判断到处复制,早晚写歪。
|
|
37
|
+
测试覆盖四种形态(`test/stream-state.test.mjs`)。
|
|
38
|
+
|
|
39
|
+
**防换行**(经典 bug:光标被挤到下一行开头):不是给光标 `position:absolute`,
|
|
40
|
+
而是把**最后一个字符 + 光标**包进 `whitespace-nowrap` 行内盒(`StreamingText` 已实现):
|
|
41
|
+
|
|
42
|
+
```tsx
|
|
43
|
+
const tail = value.slice(-1)
|
|
44
|
+
const head = value.slice(0, -1)
|
|
45
|
+
return <div className="whitespace-pre-wrap break-words">
|
|
46
|
+
{head}<span className="whitespace-nowrap">{tail}<StreamingCursor/></span>
|
|
47
|
+
</div>
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
**硬闪烁**:Tailwind 的 `animate-pulse` 是呼吸灯,像“加载中”;终端光标要用 `step-end` 阶梯闪烁。
|
|
51
|
+
`styles.css` 提供 `animate-blink` / `animate-ping-once` / `animate-blink-fast`,
|
|
52
|
+
并且对 `prefers-reduced-motion` 降级为静态光标。
|
|
53
|
+
|
|
54
|
+
**Markdown 场景**:不要往 Markdown 源里拼 `<span>`(解析器会把它当文本或丢到代码块外)。
|
|
55
|
+
实践是「解析后再插入」:把 `▌` 之类哨兵字符附着在最后一个文本节点上,渲染器命中后替换为光标组件。
|
|
56
|
+
|
|
57
|
+
## 高频流式下的三层性能保障
|
|
58
|
+
|
|
59
|
+
| 层 | 做法 | 为什么 |
|
|
60
|
+
|---|---|---|
|
|
61
|
+
| 状态层 | `state.history`(引用稳定)+ `state.active`(只有它每 chunk 换对象) | 历史列表订阅 `history`:引用没变就**不重渲染**,也不用遍历 |
|
|
62
|
+
| 渲染层 | `React.memo` + `key=id` | 只有当前气泡 diff;历史组件直接跳过 |
|
|
63
|
+
| 滚动层 | `useAutoScroll`:rAF 合并 + 仅贴底时滚动 + `behavior:'auto'` | 每 chunk 调 `scrollIntoView` 会强制重排;连续 `smooth` 会互相打断 |
|
|
64
|
+
|
|
65
|
+
**只有历史超过约 100 条时才需要虚拟列表**:`VirtualChatFeed.tsx`(依赖 `@tanstack/react-virtual`,
|
|
66
|
+
需自行安装;插件本体不依赖)。它让 DOM 数量与历史长度解耦,但会牺牲"全部可搜索/可 Ctrl+F"。
|
|
67
|
+
|
|
68
|
+
> 参考测量口径:`history` 引用不变可以直接用 `===` 断言;测试里已经这么做了
|
|
69
|
+
> (`test/stream-state.test.mjs`:30 个 chunk 后 `history === before`)。
|
|
70
|
+
|
|
71
|
+
## 块级锁(Blocking)在代码里长什么样
|
|
72
|
+
|
|
73
|
+
| 步骤 | 状态机行为 | 测试断言 |
|
|
74
|
+
|---|---|---|
|
|
75
|
+
| 后端流式中发来 `card_choice` | 收尾正在流式的文本(`streaming=false`)、**清空 `activeStreamId`**、插入卡片 | `activeStreamId === null`、前文一字不丢 |
|
|
76
|
+
| 后端要接着说 | 必须重新发 `stream_start`(不会复用旧气泡) | 新消息追加,旧消息引用不变 |
|
|
77
|
+
| 用户点选项 | `answerChoice(id, value, {echo})`:卡片置 `answered`、写 `answer`、追加 `local:true` 用户气泡 | 重复点击返回 `choice-already-answered` |
|
|
78
|
+
| 卡片重复到达 | 同 id 原地更新,不重复插卡 | 消息数不变、内容更新 |
|
|
79
|
+
| 历史消息 | 引用保持不变,`React.memo` 跳过 | `messages[0] === before` |
|
|
80
|
+
|
|
81
|
+
「AI 说到一半递卡片」不是动画效果,而是状态机里一次原子变更:**光标停在那里,句子不补全、不回退**。
|
|
82
|
+
|
|
83
|
+
## 极简渲染管线(性能要点)
|
|
84
|
+
|
|
85
|
+
```text
|
|
86
|
+
WebSocket ──► createChatStream(纯状态机)──► useSyncExternalStore ──► ChatFeed
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
**关键不是 `map`,而是引用稳定性**:`createChatStream` 每次只替换发生变化的那一条消息对象,
|
|
90
|
+
历史消息的引用保持不变,所以 `React.memo` 才能真正跳过它们。这一条有测试直接断言:
|
|
91
|
+
|
|
92
|
+
```js
|
|
93
|
+
chat.apply({ type: 'stream_start', messageId: 'm1', role: 'pjm' })
|
|
94
|
+
for (let i = 0; i < 50; i++) chat.apply({ type: 'stream_chunk', messageId: 'm1', delta: 'x' })
|
|
95
|
+
assert.equal(chat.getState().messages[0], before) // 历史消息 === 原对象
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
其他已实现的性能手段:
|
|
99
|
+
|
|
100
|
+
| 手段 | 位置 | 作用 |
|
|
101
|
+
|---|---|---|
|
|
102
|
+
| `applyBatch(messages)` | 状态机 | 多个 chunk 只通知一次;UI 按帧 flush |
|
|
103
|
+
| `subscribe(listener)` | 状态机 | 配合 `useSyncExternalStore`,不把大数组塞进 `useState` |
|
|
104
|
+
| `activeStreamId` / `activeMessage()` | 状态机 | UI 只关心“当前那一条”,不必全量比较 |
|
|
105
|
+
| 内容/条数上限 | 状态机 | 200 条、单条 20 万字符,超长截断并标记 |
|
|
106
|
+
| 光标用纯 CSS | 参考 UI | `animate-pulse`,不额外触发渲染 |
|
|
107
|
+
|
|
108
|
+
`useChatStream.ts` 是这套组合的参考实现(`useSyncExternalStore` + 按帧批量 + outbox 队列)。
|
|
109
|
+
|
|
110
|
+
## 文件
|
|
111
|
+
|
|
112
|
+
| 文件 | 内容 |
|
|
113
|
+
|---|---|
|
|
114
|
+
| `types.ts` | 客户端/服务端消息类型(与 `src/channel/` 协议对齐) |
|
|
115
|
+
| `useAgentSocket.ts` | WebSocket Hook:指数退避重连、连接期消息队列、调用流式状态机 |
|
|
116
|
+
| `useChatStream.ts` | 极简渲染管线参考:`useSyncExternalStore` + 按帧批量 + 引用稳定 |
|
|
117
|
+
| `useAutoScroll.ts` | 自动贴底:rAF 合并 + 仅贴底滚动(`isNearBottom` 可单测) |
|
|
118
|
+
| `StreamingText.tsx` | 流式文本 + 三态光标(防换行:最后一字与光标同一个 nowrap 盒) |
|
|
119
|
+
| `styles.css` | 硬闪烁 `animate-blink` / 中断 `animate-ping-once` / reduced-motion 降级 |
|
|
120
|
+
| `VirtualChatFeed.tsx` | 可选虚拟列表(`@tanstack/react-virtual`,不随插件发布) |
|
|
121
|
+
| `AgentCommandCenter.tsx` | 主面板:流式打字机、卡片、能力开关(chat=false 时禁用输入) |
|
|
122
|
+
| `MessageComponents.tsx` | 早期独立组件版(保留作参考,主面板已内联渲染) |
|
|
123
|
+
| `demo-server.mjs` | 本地流式演示服务(复用房主已测试的 RFC 6455 编解码) |
|
|
124
|
+
|
|
125
|
+
流式拼接逻辑**不在**这里重复实现,而是复用房主已发布的纯状态机:
|
|
126
|
+
|
|
127
|
+
```ts
|
|
128
|
+
import { createChatStream } from '@twinsearth/dsh-master/channel/stream-state'
|
|
129
|
+
```
|
|
130
|
+
|
|
131
|
+
它负责:`stream_start` 幂等建气泡、`stream_chunk` 追加(容忍乱序、丢孤儿帧)、
|
|
132
|
+
`stream_end` 收尾光标、`card_*` 打断流并插入卡片、错误变系统消息、消息数与内容长度有界。
|
|
133
|
+
|
|
134
|
+
## 看效果(不需要真实后端)
|
|
135
|
+
|
|
136
|
+
```bash
|
|
137
|
+
node docs/examples/command-center/demo-server.mjs
|
|
138
|
+
# [demo] command-center stream at ws://127.0.0.1:51234/dsh-master/ws/command-center?token=demo
|
|
139
|
+
```
|
|
140
|
+
|
|
141
|
+
把 `AgentCommandCenter` 的 `channel` 传成 `{ path: '/dsh-master/ws/command-center', token: 'demo' }`、
|
|
142
|
+
`wsBase` 传 `ws://127.0.0.1:51234`,发一句话就能看到:逐字流式回复 → 选择题卡片 → PRD 卡片 → 任务进度卡片。
|
|
143
|
+
|
|
144
|
+
> 演示服务故意把 `capabilities.chat` 设为 `true`(否则 UI 会禁用输入)。
|
|
145
|
+
> 真实宿主目前是 `false`,原因见下。
|
|
146
|
+
|
|
147
|
+
## 接真实宿主时会发生什么
|
|
148
|
+
|
|
149
|
+
`hello` 会带回:
|
|
150
|
+
|
|
151
|
+
```json
|
|
152
|
+
{ "type": "hello", "protocol": 1,
|
|
153
|
+
"capabilities": { "dualSnapshot": true, "chat": false, "answer": false },
|
|
154
|
+
"limitations": { "chat": "plugin cannot submit a user message into a preset agent session" } }
|
|
155
|
+
```
|
|
156
|
+
|
|
157
|
+
所以真实环境下面板显示「只读:chat 能力未开放」,输入框禁用,并给出原因——
|
|
158
|
+
**不会假装在跟 PM 聊天**。要打开它,Host 必须先提供这四样(见 `docs/CHANNEL-WEBSOCKET.md` §4):
|
|
159
|
+
|
|
160
|
+
1. 按 preset 建/选会话;2. 提交 user message 触发回合;3. 订阅该会话事件流;4. 把问题回答回填到会话。
|
|
161
|
+
|
|
162
|
+
## 渲染性能说明
|
|
163
|
+
|
|
164
|
+
- 状态机每次返回新对象,React 用 `id` 作 key,只重渲染变化的那条气泡;
|
|
165
|
+
- 长会话有上限(200 条 / 单条 20 万字符),超出截断并显示提示;
|
|
166
|
+
- 自动贴底在 `useEffect` 里做,依赖 `messages` 与连接状态;
|
|
167
|
+
- 流式文本末尾的光标是纯 CSS `animate-pulse`,不额外触发渲染。
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@twinsearth/dsh-master",
|
|
3
|
-
"version": "3.3.
|
|
3
|
+
"version": "3.3.6",
|
|
4
4
|
"private": false,
|
|
5
5
|
"publishConfig": {
|
|
6
6
|
"access": "public"
|
|
@@ -39,6 +39,8 @@
|
|
|
39
39
|
"./dual-manager": "./dist-host/plugins/dual-manager.js",
|
|
40
40
|
"./pm-agent": "./dist-host/agents/pm-agent.js",
|
|
41
41
|
"./pjm-agent": "./dist-host/agents/pjm-agent.js",
|
|
42
|
+
"./engine": "./dist-host/engine/index.js",
|
|
43
|
+
"./channel/stream-state": "./dist-host/channel/stream-state.js",
|
|
42
44
|
"./client": {
|
|
43
45
|
"browser": "./client.js",
|
|
44
46
|
"default": "./client.js"
|
package/src/agents/pjm-agent.ts
CHANGED
|
@@ -12,14 +12,26 @@ export const inject = ['systemPrompt', 'tools']
|
|
|
12
12
|
export function apply(ctx) {
|
|
13
13
|
const dual = () => dualService(ctx)
|
|
14
14
|
const tools = [
|
|
15
|
-
jsonTool('pjm_create_plan', '把已确认 PRD 拆成 WBS
|
|
16
|
-
prdId: id('PRD id'), title: string('项目计划标题'), how: string('实施路径'), where: string('执行场景'),
|
|
15
|
+
jsonTool('pjm_create_plan', '把已确认 PRD 拆成 WBS 与七类项目计划;给 handoffId 时同时完成接单。', objectSchema({
|
|
16
|
+
prdId: id('PRD id'), handoffId: id('可选:PM 交接包 id'), title: string('项目计划标题'), how: string('实施路径'), where: string('执行场景'),
|
|
17
17
|
projectReview: strings('项目会审结论'), requirements: strings('项目需求与内容'),
|
|
18
18
|
steps: objects('WBS:id/title/prompt/owner/where/estimateMinutes/dependsOn/acceptance/kind'),
|
|
19
19
|
schedule: { type: 'object', description: 'startAt 与 milestones[](title/owner/estimateMinutes/dependsOn)', additionalProperties: true },
|
|
20
20
|
team: objects('人员:role/owner/responsibility'),
|
|
21
21
|
quality: { type: 'object', description: 'tests[] / deployment[] / acceptance[]', additionalProperties: true },
|
|
22
22
|
}, ['prdId', 'steps']), async (args) => dual() ? dual().pjm.plan(args.prdId, args) : dualUnavailable()),
|
|
23
|
+
jsonTool('pjm_take_handoff', '接单:只有 PM 标记 ready 的交接包可以接走;needs-clarification 必须退回 PM。', objectSchema({
|
|
24
|
+
handoffId: id('交接包 id'), planId: id('可选:关联计划 id'),
|
|
25
|
+
}, ['handoffId']), async (args) => dual() ? dual().pjm.takeHandoff(args.handoffId, args) : dualUnavailable()),
|
|
26
|
+
jsonTool('pjm_report', '节点自反馈:执行中随时汇报进展/阻塞,blocked 必须写原因。', objectSchema({
|
|
27
|
+
planId: id('计划 id'), summary: string('节点摘要'),
|
|
28
|
+
status: { type: 'string', enum: ['progress', 'blocked', 'done'] },
|
|
29
|
+
stepId: id('可选:关联步骤'), blocker: string('阻塞原因(blocked 必填)'), evidence: strings('证据'),
|
|
30
|
+
}, ['planId', 'summary']), async (args) => dual() ? dual().pjm.report(args.planId, args) : dualUnavailable()),
|
|
31
|
+
jsonTool('pjm_submit_feedback', '项目级自反馈:计划全部步骤完成后提交给 PM 复核,写清交付、偏差、遗留问题与测试。', objectSchema({
|
|
32
|
+
planId: id('计划 id'), summary: string('执行总结(必填)'),
|
|
33
|
+
deviations: strings('相对计划的偏差'), openIssues: strings('遗留问题'), tests: strings('测试证据'),
|
|
34
|
+
}, ['planId', 'summary']), async (args) => dual() ? dual().pjm.submitFeedback(args.planId, args) : dualUnavailable()),
|
|
23
35
|
jsonTool('pjm_dispatch_step', '通过 fangForker.spawn 在隔离分身中执行一个 WBS 步骤;不可用会 fail-closed,不在宿主本地执行。', objectSchema({
|
|
24
36
|
planId: id('计划 id'), stepId: id('步骤 id'),
|
|
25
37
|
}, ['planId', 'stepId']), async (args) => dual() ? await dual().pjm.dispatch(args.planId, { stepId: args.stepId }) : dualUnavailable()),
|
|
@@ -28,6 +40,10 @@ export function apply(ctx) {
|
|
|
28
40
|
status: { type: 'string', enum: ['done', 'failed'] }, summary: string('结果摘要'),
|
|
29
41
|
evidence: strings('测试/文件/报告证据'), actualMinutes: { type: 'number', description: '实际用时(分钟)' },
|
|
30
42
|
}, ['planId', 'stepId', 'status']), async (args) => dual() ? dual().pjm.completeStep(args.planId, args.stepId, args) : dualUnavailable()),
|
|
43
|
+
jsonTool('pjm_check_step', '查单个步骤真实状态:派发/执行/汇报、证据、用时与关联报告。结果来自计划与节点汇报,不是 VM 作业队列。', objectSchema({
|
|
44
|
+
planId: id('计划 id'), stepId: id('步骤 id'),
|
|
45
|
+
}, ['planId', 'stepId']), async (args) => dual() ? dual().pjm.checkStep(args.planId, args.stepId) : dualUnavailable()),
|
|
46
|
+
jsonTool('pjm_next_action', '读计划状态,返回 PJM 下一步唯一推荐动作与理由(纯建议,不执行)。', objectSchema({ planId: id('计划 id') }, ['planId']), async (args) => dual() ? dual().pjm.nextAction(args.planId) : dualUnavailable()),
|
|
31
47
|
jsonTool('pjm_project_status', '读取项目看板:完成/进行/待办/失败、估时、实际用时、是否可结案。', objectSchema({ planId: id('计划 id') }, ['planId']), async (args) => dual() ? dual().pjm.progress(args.planId) : dualUnavailable()),
|
|
32
48
|
jsonTool('pjm_write_project', '把项目计划与当前节点写成 workspace/docs/pjm 下的 Markdown。', objectSchema({ planId: id('计划 id') }, ['planId']), async (args, exec) => dual() ? await dual().pjm.writeProject(args.planId, { agent: exec.agent }) : dualUnavailable()),
|
|
33
49
|
jsonTool('pjm_close_project', '全部步骤完成后结案:写入总结、评审报告、验收与资料清单,并可生成结案文档。', objectSchema({
|
package/src/agents/pm-agent.ts
CHANGED
|
@@ -32,7 +32,27 @@ export function apply(ctx) {
|
|
|
32
32
|
jsonTool('pm_write_prd', '把 PRD(可带六类盘点)写成 workspace/docs/pm 下的 Markdown,返回文档路径。', objectSchema({
|
|
33
33
|
prdId: id('PRD id'), assessmentId: id('可选:关联的 Assessment id'),
|
|
34
34
|
}, ['prdId']), async (args, exec) => dual() ? await dual().pm.writePrd(args.prdId, { assessmentId: args.assessmentId, agent: exec.agent }) : dualUnavailable()),
|
|
35
|
-
jsonTool('
|
|
35
|
+
jsonTool('pm_handoff_to_pjm', '把 PRD(可带盘点)打成交接包交给 PJM。有未决问题或冲突时不给 ready,必须先澄清。', objectSchema({
|
|
36
|
+
prdId: id('PRD id'), assessmentId: id('可选:关联的盘点 id'),
|
|
37
|
+
acceptance: strings('可选:覆盖 PRD 的验收标准'), openQuestions: strings('可选:补充未决问题'),
|
|
38
|
+
}, ['prdId']), async (args) => dual() ? dual().pm.createHandoff(args.prdId, args) : dualUnavailable()),
|
|
39
|
+
jsonTool('pm_review_feedback', '复核 PJM 的执行反馈:逐条验收给 met 布尔与证据,写复盘与总结;结论必须与验收一致。', objectSchema({
|
|
40
|
+
feedbackId: id('PJM 反馈 id'),
|
|
41
|
+
acceptanceChecks: objects('逐条验收:criterion/met(boolean)/evidence[]/note'),
|
|
42
|
+
verdict: { type: 'string', enum: ['accepted', 'changes-requested'] },
|
|
43
|
+
retrospective: { type: 'object', description: 'kept[] / improve[] / lessons[]', additionalProperties: true },
|
|
44
|
+
summary: string('复核总结(必填)'),
|
|
45
|
+
write: { type: 'boolean', description: '是否同时写复核文档,默认 true' },
|
|
46
|
+
}, ['feedbackId', 'acceptanceChecks', 'summary']), async (args, exec) => {
|
|
47
|
+
if (!dual()) return dualUnavailable()
|
|
48
|
+
const reviewed = dual().pm.reviewFeedback(args.feedbackId, args)
|
|
49
|
+
if (!reviewed.ok || args.write === false) return reviewed
|
|
50
|
+
const document = await dual().pm.writeReview(reviewed.review.id, { agent: exec.agent })
|
|
51
|
+
return { ...reviewed, document }
|
|
52
|
+
}),
|
|
53
|
+
jsonTool('pm_pipeline', '读一个 PRD 的闭环状态:drafted → handoff-ready / needs-clarification → planning → feedback → accepted / changes-requested。', objectSchema({ prdId: id('PRD id') }, ['prdId']), async (args) => dual() ? dual().pm.pipeline(args.prdId) : dualUnavailable()),
|
|
54
|
+
jsonTool('pm_next_action', '读闭环状态,返回 PM 下一步唯一推荐动作与理由(纯建议,不执行)。', objectSchema({ prdId: id('PRD id') }, ['prdId']), async (args) => dual() ? dual().pm.nextAction(args.prdId) : dualUnavailable()),
|
|
55
|
+
jsonTool('pm_list_prds', '列出当前 Host 中的 PRD、六类盘点、交接包与复核索引。', objectSchema({}), async () => dual() ? ({ ok: true, prds: dual().pm.listPrds().items, assessments: dual().pm.listAssessments().items, handoffs: dual().pm.listHandoffs().items, reviews: dual().pm.listReviews().items }) : dualUnavailable()),
|
|
36
56
|
]
|
|
37
57
|
return installAgent(ctx, { prompt: PM_SYSTEM_PROMPT, tools })
|
|
38
58
|
}
|
package/src/agents/prompts.ts
CHANGED
|
@@ -16,14 +16,19 @@ export const PM_SYSTEM_PROMPT = String.raw`# Role
|
|
|
16
16
|
1. 先识别目标、范围、约束和未决项;重大歧义或不可逆选择必须用 ask_user_question 提出一个具体决策点,给 2–4 个选项并标推荐项。不要为琐碎实现细节打断用户。
|
|
17
17
|
2. 盘点前先用 read/grep/glob 读取真实代码、测试和文档。没有证据的“完成”不得写 100%,不得把设计稿当成已交付。
|
|
18
18
|
3. 用 pm_create_prd 保存结构化 PRD;用 pm_create_assessment 保存六类盘点。每个 completed 项必须附文件、测试或报告证据。
|
|
19
|
-
4. 用 pm_write_prd 生成 Markdown 文档;需要用户打开时用 present
|
|
19
|
+
4. 用 pm_write_prd 生成 Markdown 文档;需要用户打开时用 present 交付。
|
|
20
|
+
5. **交接**:确认 PRD 有验收标准、且没有未决问题/冲突后,用 pm_handoff_to_pjm 交接给 PJM;不 ready 时先回去澄清,不得硬塞。
|
|
21
|
+
6. **复核闭环**:PJM 提交反馈后用 pm_review_feedback 逐条核对验收(met + 证据),写复盘(kept/improve/lessons)与总结;结论必须与验收一致(有未达标就 changes-requested)。
|
|
22
|
+
7. 用 pm_pipeline 查闭环阶段(drafted → handoff-ready → planning → feedback → accepted / changes-requested)。
|
|
23
|
+
8. 不确定下一步时先调 pm_next_action:它给出唯一推荐动作(create_prd / create_assessment / clarify / handoff / review / done / blocked)与理由;按它走,不要自己跳步。
|
|
20
24
|
|
|
21
25
|
# Rules
|
|
22
26
|
- 不臆测、不伪造证据、不把“可以实现”写成“已经完成”。
|
|
23
27
|
- 冲突必须列出冲突双方、影响、可选方案和需要用户重新判断的问题。
|
|
24
28
|
- 歧义必须写出当前理解与待确认问题;用户未回答前不得把该理解当成事实。
|
|
25
29
|
- PM 不写生产代码、不跑 shell、不发布、不安装、不调度 VM/分身。
|
|
26
|
-
-
|
|
30
|
+
- 只有 PM 能复核 PJM 的交付;不得自己给自己复核,也不得替 PJM 编造执行结果。
|
|
31
|
+
- 只写 PRD/盘点/复核文档;不改需求范围外的文件。
|
|
27
32
|
|
|
28
33
|
# Output Format
|
|
29
34
|
默认用简洁 Markdown 汇报,必须按任务需要包含:
|
|
@@ -44,15 +49,20 @@ export const PJM_SYSTEM_PROMPT = String.raw`# Role
|
|
|
44
49
|
|
|
45
50
|
# Workflow
|
|
46
51
|
1. 项目会审:核对 PRD、验收标准、约束、未决问题;未决项未解决不得自行扩权。
|
|
47
|
-
2.
|
|
48
|
-
3.
|
|
49
|
-
4.
|
|
50
|
-
5.
|
|
52
|
+
2. 接单:收到 PM 交接包后用 pjm_take_handoff 或 pjm_create_plan({handoffId}) 接单;交接包 needs-clarification 时退回 PM,不得硬开工。
|
|
53
|
+
3. 用 pjm_create_plan 建立七类项目内容:项目会审、WBS、需求内容、时间、人员、测试部署验收、结案。
|
|
54
|
+
4. 执行前申请隔离资源,通过 pjm_dispatch_step 走 fangForker.spawn;不可用就 fail-closed,绝不在母体/原身本地降级执行,不虚构 vm_create/vm_exec。
|
|
55
|
+
5. **节点自反馈**:执行中随时用 pjm_report 汇报进展/阻塞(blocked 必须写原因)。
|
|
56
|
+
6. **提交反馈**:全部步骤完成后用 pjm_submit_feedback 把交付、偏差、遗留问题、测试证据交给 PM 复核;不得在未完成时提交。
|
|
57
|
+
7. 用 pjm_project_status 汇总节点,用 todo_write 跟踪当前工作;完成步骤时记录 summary/evidence/actualMinutes。
|
|
58
|
+
8. 用 pjm_write_project 保存计划与节点报告。全部步骤完成后才能 pjm_close_project;结案必须有总结、验收记录、评审报告和资料清单,并交付文档。
|
|
59
|
+
9. 不确定下一步时先调 pjm_next_action:它给出唯一推荐动作(take_handoff / return_to_pm / create_plan / dispatch / report / complete_feedback / close / done)与理由;按它走,不要跳步或重复派发。
|
|
51
60
|
|
|
52
61
|
# Rules
|
|
53
62
|
- 按时、按质、按量报告真实状态;失败、阻塞、未测量不得包装成完成。
|
|
54
63
|
- 不发布、不部署到生产、不修改 profile、不触碰凭据,除非用户对该动作另行明确授权。
|
|
55
64
|
- 不代替 PM 决定需求,不替用户回答选择题;阻塞时给出原因、影响、可选方案和所需决策。
|
|
65
|
+
- 不给自己复核:交付是否正确由 PM 复核;PM 判 changes-requested 时按新的验收项重做。
|
|
56
66
|
- 不做无限轮询;节点汇报写完成/进行/待办/失败及下一步。
|
|
57
67
|
|
|
58
68
|
# Output Format
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
2
|
+
// Copyright (c) 2026 dsh-Master contributors — see LICENSE-CORE.
|
|
3
|
+
// @ts-nocheck
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* 流式生成的取消登记表(3.3.5)。
|
|
7
|
+
*
|
|
8
|
+
* 为什么单独抽出来:中断的正确性取决于「谁在跑、谁负责停、停完是否清理」。
|
|
9
|
+
* 把它写成可测原语后,通道、会话桥、演示生产者都能复用同一套语义。
|
|
10
|
+
*
|
|
11
|
+
* 语义:
|
|
12
|
+
* * `begin(key)` 拿到 `{ key, signal, abort, end }`;同一个 key 重复 begin 会**先中止旧的**(新的一轮覆盖旧的)。
|
|
13
|
+
* * `abort(key)` 触发 signal 并保留登记,直到 `end()` 才真正清理 —— 这样“已中止但尚未收尾”可被观察。
|
|
14
|
+
* * `end(key)` 幂等清理;调用后 `has(key)` 为 false。
|
|
15
|
+
* * `abortAll()` 用于连接关闭 / 插件卸载,保证没有僵尸流。
|
|
16
|
+
* * 所有方法都不抛(除了传入非法 key 返回 false)。
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
export function createAbortRegistry() {
|
|
20
|
+
const entries = new Map()
|
|
21
|
+
|
|
22
|
+
function begin(key, { onAbort } = {}) {
|
|
23
|
+
const id = String(key ?? '')
|
|
24
|
+
if (!id) throw new Error('abort-registry: key is required')
|
|
25
|
+
// 同一 key 上已有生成:先中止旧的,避免两路流同时往同一个消息里写
|
|
26
|
+
const previous = entries.get(id)
|
|
27
|
+
if (previous) {
|
|
28
|
+
previous.abort('superseded')
|
|
29
|
+
}
|
|
30
|
+
const controller = new AbortController()
|
|
31
|
+
const entry = {
|
|
32
|
+
key: id,
|
|
33
|
+
controller,
|
|
34
|
+
signal: controller.signal,
|
|
35
|
+
aborted: false,
|
|
36
|
+
reason: null,
|
|
37
|
+
startedAt: Date.now(),
|
|
38
|
+
endedAt: null,
|
|
39
|
+
}
|
|
40
|
+
controller.signal.addEventListener('abort', () => {
|
|
41
|
+
entry.aborted = true
|
|
42
|
+
try { onAbort?.(entry.reason ?? controller.signal.reason ?? 'aborted') } catch { /* 回调异常不影响中止 */ }
|
|
43
|
+
}, { once: true })
|
|
44
|
+
entry.abort = (reason = 'user-interrupt') => {
|
|
45
|
+
if (controller.signal.aborted) return false
|
|
46
|
+
try { controller.abort(reason) } catch { /* ignore */ }
|
|
47
|
+
entry.reason = String(reason)
|
|
48
|
+
return true
|
|
49
|
+
}
|
|
50
|
+
entry.end = () => {
|
|
51
|
+
if (entries.get(id) === entry) entries.delete(id)
|
|
52
|
+
entry.endedAt = Date.now()
|
|
53
|
+
return true
|
|
54
|
+
}
|
|
55
|
+
entries.set(id, entry)
|
|
56
|
+
return entry
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return {
|
|
60
|
+
begin,
|
|
61
|
+
has: (key) => entries.has(String(key ?? '')),
|
|
62
|
+
get: (key) => entries.get(String(key ?? '')) ?? null,
|
|
63
|
+
/** 中止并返回是否真的发生了中止。 */
|
|
64
|
+
abort: (key, reason = 'user-interrupt') => {
|
|
65
|
+
const entry = entries.get(String(key ?? ''))
|
|
66
|
+
if (!entry) return false
|
|
67
|
+
return entry.abort(reason)
|
|
68
|
+
},
|
|
69
|
+
abortAll: (reason = 'shutdown') => {
|
|
70
|
+
const aborted = []
|
|
71
|
+
for (const entry of entries.values()) {
|
|
72
|
+
if (entry.abort(reason)) aborted.push(entry.key)
|
|
73
|
+
}
|
|
74
|
+
return aborted
|
|
75
|
+
},
|
|
76
|
+
/** 结束并清理(幂等)。 */
|
|
77
|
+
end: (key) => {
|
|
78
|
+
const entry = entries.get(String(key ?? ''))
|
|
79
|
+
if (!entry) return false
|
|
80
|
+
return entry.end()
|
|
81
|
+
},
|
|
82
|
+
keys: () => [...entries.keys()],
|
|
83
|
+
size: () => entries.size,
|
|
84
|
+
}
|
|
85
|
+
}
|