@zhushanwen/session-delivery 0.11.0 → 1.0.0
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 -2
- package/dist/index.d.mts +299 -22
- package/dist/index.mjs +381 -73
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -5,7 +5,7 @@ Session 消息投递内核:零 pi 依赖的策略层,负责排队、合批
|
|
|
5
5
|
## 设计原则
|
|
6
6
|
|
|
7
7
|
- **零 pi 依赖**:内核不 import pi 包任何符号。steer/followUp/triggerTurn/streamingBehavior 等 pi 词汇封闭在两侧适配器内。
|
|
8
|
-
- **端口注入**:`DeliveryPort` 是内核与外部世界的唯一接口(
|
|
8
|
+
- **端口注入**:`DeliveryPort` 是内核与外部世界的唯一接口(supportedPayloads / isIdle / send / subscribeSettled),调用方负责注入运行时能力。busy 判定不经 port 询问:内核内查 active 表在途条目,非空即在途、gate 不放行。
|
|
9
9
|
- **意图驱动**:调用方声明投递意图(`DeliveryIntent`),内核处理与目标 session 运行状态的冲突。intent → pi 参数的翻译在适配器内。
|
|
10
10
|
|
|
11
11
|
## 接口
|
|
@@ -36,7 +36,6 @@ handle.dispose()
|
|
|
36
36
|
| 策略 | 默认值 | 说明 |
|
|
37
37
|
|------|--------|------|
|
|
38
38
|
| `intent` | `'interrupt-at-turn-boundary'` | turn 边界抢占(F1 教训内化) |
|
|
39
|
-
| `busyPolicy` | `'retry-force'` | settled 边沿驱动 flush + watch-dog 30s 复核;无订阅装配退化退避 {100ms, 50}≈5s 后强制发送 |
|
|
40
39
|
| `mergeWindowMs` | `0` | 关;显式设值启用滑动窗口合批 |
|
|
41
40
|
| `backoff` | `{ ms: 100, max: 50 }` | 退避参数 |
|
|
42
41
|
| `watchdogMs` | `30_000` | watch-dog 复核间隔 |
|
package/dist/index.d.mts
CHANGED
|
@@ -5,13 +5,28 @@
|
|
|
5
5
|
* - 零 pi 依赖:不出现 steer/followUp/triggerTurn/streamingBehavior 等 pi 词汇
|
|
6
6
|
* - 意图驱动:调用方声明 intent,内核处理与 session 运行状态的冲突
|
|
7
7
|
* - 判别联合 payload:适配器声明 supportedPayloads 能力
|
|
8
|
+
*
|
|
9
|
+
* v2 增量(投递所有权内核,设计 .tmp/tech-design/delivery-ownership-kernel.md):
|
|
10
|
+
* 条目状态机/lane/条目双形态(活跃条目 + tombstone)/entriesFull()+projection() 双视图;行为实现在
|
|
11
|
+
* 本包 src/delivery.ts。lane 字面量中的 'steer' 是投递车道语义名(设计 §1.1 术语表
|
|
12
|
+
* /D1),非 pi API 词汇——intent → 底层参数的翻译在适配器内部,本包只记录车道,
|
|
13
|
+
* 零依赖纪律对 lane 不破例。
|
|
8
14
|
*/
|
|
9
15
|
/** 投递意图(D3):turn 边界抢占 / run 结束后注入,均含 idle 唤醒语义。 */
|
|
10
16
|
type DeliveryIntent = 'interrupt-at-turn-boundary' | 'after-run';
|
|
11
|
-
/**
|
|
17
|
+
/**
|
|
18
|
+
* 文本 payload(runtime 通路一期唯一支持)。
|
|
19
|
+
* images(D9④):可选图片附件(base64 data + mimeType,形态对齐协议层
|
|
20
|
+
* 'message.send'.images;底层通道支持图片附件的契约旁证 = 设计 F9)。内核不解析、
|
|
21
|
+
* 随出站投递透传,底层 wire 格式组装归 runtime 适配层——本包零依赖纪律不变。
|
|
22
|
+
*/
|
|
12
23
|
interface TextPayload {
|
|
13
24
|
kind: 'text';
|
|
14
25
|
content: string;
|
|
26
|
+
images?: Array<{
|
|
27
|
+
data: string;
|
|
28
|
+
mimeType: string;
|
|
29
|
+
}>;
|
|
15
30
|
}
|
|
16
31
|
/** custom message payload(extension 通路支持)。 */
|
|
17
32
|
interface CustomPayload {
|
|
@@ -67,12 +82,16 @@ interface DeliveryPort {
|
|
|
67
82
|
supportedPayloads: readonly DeliveryPayload['kind'][];
|
|
68
83
|
/** 主 agent 是否空闲。 */
|
|
69
84
|
isIdle(): boolean;
|
|
70
|
-
/** 是否有排队中的消息。 */
|
|
71
|
-
hasPendingMessages(): boolean;
|
|
72
85
|
/**
|
|
73
86
|
* 投递消息(intent → pi 参数的翻译在适配器内部)。
|
|
74
87
|
* 返回受理回执(U2 扩展位):显式 `{accepted:false}` = 受理失败(内核按发送失败
|
|
75
88
|
* 处理);void / `{accepted:true}` = 受理成功。旧实现返回 void 兼容。
|
|
89
|
+
*
|
|
90
|
+
* settle 契约:返回 promise 时实现必须 settle(resolve 或 reject)——悬挂的
|
|
91
|
+
* promise 会让内核 in-flight 防重永久占位。内核侧按控制面单请求粒度设有界兜底
|
|
92
|
+
* (默认 60s,C-proc-19):超时按发送失败收口(warn 留痕 + 走错误重试链)。
|
|
93
|
+
* 已接受代价:迟到的原请求在超时强制失败后仍可能到达底层通道,与重试构成重复
|
|
94
|
+
* 投递;通道内判重由适配器/对端按裸标记(clientUuid)负责,内核不建第二套判重。
|
|
76
95
|
*/
|
|
77
96
|
send(msg: DeliveryMessage, intent: DeliveryIntent): Promise<SendReceipt | void> | SendReceipt | void;
|
|
78
97
|
/** agent_settled 边沿订阅(D8)。缺省时内核退化退避轮询。返回退订函数。 */
|
|
@@ -82,8 +101,6 @@ interface DeliveryPort {
|
|
|
82
101
|
interface DeliveryConfig {
|
|
83
102
|
/** 默认意图:'interrupt-at-turn-boundary'(D3)。 */
|
|
84
103
|
intent?: DeliveryIntent;
|
|
85
|
-
/** busy 策略:'retry-force'(默认)/ 'park'。 */
|
|
86
|
-
busyPolicy?: 'retry-force' | 'park';
|
|
87
104
|
/** 合批窗口(ms):0 = 关;>0 = 滑动窗口合批。 */
|
|
88
105
|
mergeWindowMs?: number;
|
|
89
106
|
/** 合批依赖谓词(D4 must-fix #1)。true 时 send() 走合批窗口,false/缺省时立即投。
|
|
@@ -108,6 +125,16 @@ interface DeliveryConfig {
|
|
|
108
125
|
* 队列不触发 onSettled」契约一致);回调内再 send() 的新消息走标准 flush 管线
|
|
109
126
|
* (空闲时可能在回调栈内立即投递)、不参与本批回调循环(契约完备性登记:当前
|
|
110
127
|
* 消费方两种形态均零可达)。
|
|
128
|
+
*
|
|
129
|
+
* [D9⑤ 口径升级——送达口径] outcome 'delivered' 升级为**送达口径** = 已拿到送达回执
|
|
130
|
+
* (两阶段回执第二阶段:消息已写入 durable 存储;D2),而非仅 port.send 受理
|
|
131
|
+
* (受理 ≠ 送达——SendReceipt 注释口径的机制化落地)。**显式例外:sendChecked 的
|
|
132
|
+
* 同步 settle 维持受理口径不变**——session_manager send 的「立即受理确认」契约
|
|
133
|
+
* 锚定在受理时点,后移到送达会让 agent 工具调用阻塞至目标 session 当前 turn 结束
|
|
134
|
+
* (steer 类车道可达数十秒)。本注释即接口口径权威声明。
|
|
135
|
+
*
|
|
136
|
+
* 异常契约:实现不应抛——回调异常由内核捕获并 warn 留痕,不影响条目状态机与
|
|
137
|
+
* 其余条目的回调(回调异常 ≠ 投递失败,不进错误重试链)。
|
|
111
138
|
*/
|
|
112
139
|
onSettled?: (msg: DeliveryMessage, outcome: 'delivered' | 'rejected') => void;
|
|
113
140
|
}
|
|
@@ -119,29 +146,141 @@ interface DeliveryHandle {
|
|
|
119
146
|
}): void;
|
|
120
147
|
/** 入队 + 可达性同步确认。reject = 入队失败。 */
|
|
121
148
|
sendChecked(msg: DeliveryMessage): Promise<void>;
|
|
122
|
-
/** 强制投递尝试(shutdown /
|
|
149
|
+
/** 强制投递尝试(shutdown flush / 外部重触发 / settled 边沿内部复用)。 */
|
|
123
150
|
flush(): void;
|
|
124
151
|
/** 队列深度(诊断/测试)。 */
|
|
125
152
|
depth(): number;
|
|
126
153
|
/** 销毁(清空队列 + 清 timer + 退订 settled)。 */
|
|
127
154
|
dispose(): void;
|
|
128
155
|
}
|
|
156
|
+
/**
|
|
157
|
+
* 投递车道(D1)。lane 在条目创建时由 runtime 内核适配层判定(读权威 occupancy
|
|
158
|
+
* 投影 + 内核自身队列态,单一判定源)后记录,此后不变。三档为投递车道语义名
|
|
159
|
+
* (设计 §1.1 术语表),非底层通道 API 词汇。
|
|
160
|
+
*/
|
|
161
|
+
type DeliveryLane = 'direct' | 'steer' | 'queued';
|
|
162
|
+
/**
|
|
163
|
+
* 条目状态机五态(D9①)。迁移规则(§3.4 错误规格表 / D3 / D10):
|
|
164
|
+
* - queued → in-flight:出站投递被受理(两阶段回执第一阶段,D2)
|
|
165
|
+
* - in-flight → delivered:送达回执到达(message_end 标记命中 / 适配器确认,D2)
|
|
166
|
+
* - in-flight → queued:对账回收重投(滞留收回,D3;cancel 部分收回的其余条目同路径)
|
|
167
|
+
* - in-flight/queued → cancelled:用户撤销(delivery.cancel)
|
|
168
|
+
* - in-flight → failed:重试耗尽(sendAttempts 超 backoff.max,既有上限语义保留)
|
|
169
|
+
* - failed → queued:用户重试(resync 单条重报);failed → cancelled:用户移除
|
|
170
|
+
* 帧投影四态差异:cancelled 不进 session.delivery 帧(D5③),协议侧帧 state 无此值。
|
|
171
|
+
*/
|
|
172
|
+
type DeliveryEntryState = 'queued' | 'in-flight' | 'delivered' | 'failed' | 'cancelled';
|
|
173
|
+
/**
|
|
174
|
+
* 活跃条目(D5①):queued/in-flight/failed 三态全量保留完整字段(failed 至用户
|
|
175
|
+
* 处置:重试/移除)。id 即客户端幂等 id(协议层 clientUuid,出站裸标记按它构造,D2),
|
|
176
|
+
* 是 resync 判重与 reattach 收养判重的锚(D5②)。
|
|
177
|
+
*/
|
|
178
|
+
interface DeliveryEntry {
|
|
179
|
+
/** 客户端幂等 id(= 协议层 clientUuid;裸标记身份源 D2,判重锚 D5②)。 */
|
|
180
|
+
id: string;
|
|
181
|
+
state: DeliveryEntryState;
|
|
182
|
+
/** 投递车道(创建时判定后记录,不变,D1)。 */
|
|
183
|
+
lane: DeliveryLane;
|
|
184
|
+
/** 载荷(一期仅 text,DeliveryPayload 联合保留扩展位)。 */
|
|
185
|
+
payload: DeliveryPayload;
|
|
186
|
+
/** 入队时间(epoch ms)。 */
|
|
187
|
+
createdAt: number;
|
|
188
|
+
/** 最近一次状态迁移时间(epoch ms)。 */
|
|
189
|
+
updatedAt: number;
|
|
190
|
+
/** 已尝试投递次数(重试耗尽判定源:sendAttempts > backoff.max → failed,§3.4)。 */
|
|
191
|
+
sendAttempts: number;
|
|
192
|
+
/** 终态落定时间(epoch ms;delivered/failed/cancelled 时有值,tombstone 提取源)。 */
|
|
193
|
+
settledAt?: number;
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* 终态判重记录 tombstone(D5②):delivered 与 cancelled 的轻量元数据。runtime
|
|
197
|
+
* 存活期内全量保留、**不设数量窗口**——断连前积压长队列的 resync 重报判重锚不丢;
|
|
198
|
+
* cancelled tombstone 防「cancel 确认帧断连窗口丢失 → 已撤销消息被 resync 复活」。
|
|
199
|
+
* 栖身 runtime 进程内存、不跨 runtime 重启;reattach 场景(判重表已清空)判重锚
|
|
200
|
+
* 回落 transcript 全量标记扫描(D5②/§3.4)。
|
|
201
|
+
*/
|
|
202
|
+
interface DeliveryTombstone {
|
|
203
|
+
id: string;
|
|
204
|
+
/** 仅两终态(活跃态不产 tombstone)。 */
|
|
205
|
+
state: Extract<DeliveryEntryState, 'delivered' | 'cancelled'>;
|
|
206
|
+
lane: DeliveryLane;
|
|
207
|
+
/** 终态落定时间(epoch ms)。 */
|
|
208
|
+
settledAt: number;
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* 全量视图(D9②):对账器与判重消费。活跃条目(完整字段)+ 全部 tombstone 元数据。
|
|
212
|
+
* 消费方:Reconciler 判别在途集(runtime registry 侧)、resync 判重、reattach 收养判定。
|
|
213
|
+
* 访问入口 = handle.entriesFull()(MF-1-13 拆名:与投影视图分开命名,误用编译期可查)。
|
|
214
|
+
*/
|
|
215
|
+
interface DeliveryEntriesFull {
|
|
216
|
+
active: readonly DeliveryEntry[];
|
|
217
|
+
tombstones: readonly DeliveryTombstone[];
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* 投影视图(D9②/D5③):session.delivery 帧装配用。活跃条目(queued/in-flight/failed)
|
|
221
|
+
* 全量 + delivered 最近 50 条完整条目;稳态体积有界 ≤ 50 + 活跃条目数。
|
|
222
|
+
* 装配(帧 DTO 转换)归 runtime transport 侧(u3a),内核只产出本视图。
|
|
223
|
+
* 访问入口 = handle.projection();投影窗口恒为默认常量 50,无参数位(与全量视图
|
|
224
|
+
* entriesFull() 拆名为两个具名方法——双视图误用编译期即红,不靠注释纪律维持)。
|
|
225
|
+
*/
|
|
226
|
+
interface DeliveryEntriesProjection {
|
|
227
|
+
entries: readonly DeliveryEntry[];
|
|
228
|
+
}
|
|
129
229
|
|
|
130
230
|
/**
|
|
131
|
-
*
|
|
132
|
-
*
|
|
133
|
-
*
|
|
134
|
-
*
|
|
135
|
-
* -
|
|
136
|
-
*
|
|
137
|
-
*
|
|
138
|
-
*
|
|
139
|
-
* -
|
|
140
|
-
*
|
|
141
|
-
*
|
|
142
|
-
* -
|
|
143
|
-
*
|
|
144
|
-
* -
|
|
231
|
+
* 投递所有权内核实现:createDelivery(v2)。
|
|
232
|
+
*
|
|
233
|
+
* 在 v1 投递循环(入队 → dedupe → 合批窗口 → busy gate → port.send → backoff
|
|
234
|
+
* 重试 → settled 边沿/watchdog 驱动)之上升级为条目制所有权内核(设计
|
|
235
|
+
* .tmp/tech-design/delivery-ownership-kernel.md §3 D9①②③⑤ / D5②③ / D3,单元 u1)。
|
|
236
|
+
* 零 pi 依赖不变:确认/回收由适配器(runtime registry)调 handle 方法驱动。
|
|
237
|
+
*
|
|
238
|
+
* 五态状态机(D9①,types.ts 迁移表)+ 本文件登记的两处实施扩展:
|
|
239
|
+
* - queued → in-flight:出站投递被受理(两阶段回执第一阶段,D2)
|
|
240
|
+
* - in-flight → delivered:confirmDelivered(送达回执,D2)
|
|
241
|
+
* - in-flight → queued:requeue(对账回收重投,D3)
|
|
242
|
+
* - queued/failed → cancelled:cancel(本地移除 + tombstone / 用户 × 移除)
|
|
243
|
+
* - in-flight → failed:重试耗尽(sendAttempts > backoff.max,v1 语义保留)
|
|
244
|
+
* - failed → queued:requeue(用户重试/resync 单条重报)
|
|
245
|
+
* - [扩展①] queued → delivered:confirmDelivered 也接受 queued 态——服务 reattach
|
|
246
|
+
* 场景 rebuild 直确认重建(D3②:适配器扫描 transcript 判 delivered 后 send 重建
|
|
247
|
+
* + confirmDelivered 一步落终态),以及出站批次在途时送达回执先于受理回执到达
|
|
248
|
+
* 的竞态形态(message_end 比 RPC resolve 快,事实优先)。
|
|
249
|
+
* - [扩展②] cancel 对 in-flight 两段式:首次调用只标记待收回(条目留守 in-flight,
|
|
250
|
+
* 返回 reclaim-requested,适配器驱动 clear_queue 收回);适配器收回确认后再次
|
|
251
|
+
* cancel(id) 终结为 cancelled + tombstone。收回失败不再调 → 条目留守原态
|
|
252
|
+
* (§3.4「目标条目留守原态」);若文本实际进了 transcript,confirmDelivered
|
|
253
|
+
* 对已标记条目事实优先转 delivered(delivered 事实 > cancel 意图)。
|
|
254
|
+
* - [扩展③] confirmAccepted 受理登记(分段 port 实现用,dmg-r1-4):申报批内条目
|
|
255
|
+
* 已由底层通道受理——queued → in-flight + 出批 + checked waiter 受理口径 resolve。
|
|
256
|
+
* 合批分段中途失败时已登记段留守 in-flight(等回执/对账),仅未受理段走失败面。
|
|
257
|
+
*
|
|
258
|
+
* onSettled 记账口径(D9⑤ 升级):
|
|
259
|
+
* - 'delivered' = 送达口径:仅 confirmDelivered 驱动回调(受理只转 in-flight,
|
|
260
|
+
* 不回调——受理 ≠ 送达的机制化落地);per-message 契约(ext-simplify-08)在
|
|
261
|
+
* confirmDelivered 路径同样成立——对每条消息各回调一次,msg 为该条原始消息
|
|
262
|
+
* (非 composed 合批消息),additive meta(notifyId 等)随原消息引用原样
|
|
263
|
+
* 透传到回调,内核不读不改。
|
|
264
|
+
* - 'rejected' = 重试耗尽通知(v1 时点不变,判定源 = sendAttempts > max,仅通知)。
|
|
265
|
+
* - 显式例外:sendChecked 的同步 settle 维持受理口径不变——promise 在 port.send
|
|
266
|
+
* 受理成功时点 resolve(session_manager send 的 {queued:true} 契约锚,D9⑤ 锁定),
|
|
267
|
+
* 与 onSettled 送达口径正交。
|
|
268
|
+
*
|
|
269
|
+
* 双视图(D9②/D5③):entriesFull() = 全量视图(活跃条目 + 全量 tombstone,对账/
|
|
270
|
+
* 判重消费);projection() = 投影视图(活跃全量 + delivered 最近 50 条完整条目,
|
|
271
|
+
* cancelled 不投影;窗口恒为默认常量)。delivered 完整条目留存上限
|
|
272
|
+
* = 默认投影窗口 50(tombstone 全量保留不受限——判重正确性不依赖展示窗口)。
|
|
273
|
+
*
|
|
274
|
+
* 判重 tombstone(D5②):delivered 与 cancelled 都写 tombstone,runtime 存活期内
|
|
275
|
+
* 全量保留、不设数量窗口;resync 重报/重建以显式 id 查活跃集与 tombstone 去重
|
|
276
|
+
* (send/sendChecked 的 opts.id 命中即吞)。条目 id = opts.id ?? 内部生成;未传
|
|
277
|
+
* id 的消息不参与幂等判重(v1「无 dedupe 配置不去重」语义保持)。
|
|
278
|
+
*
|
|
279
|
+
* 其余 v1 机制逐条保持:busy gate(isIdle + 内核在途内查双条件——hasPendingMessages
|
|
280
|
+
* 自镜像四件套已按 msg-pipeline-debloat D2 拆除,busy 判定内查 active 表)、backoff
|
|
281
|
+
* 有限重试、合批窗口、dedupe LRU、30s watchdog、dispose 语义(丢弃不触发
|
|
282
|
+
* onSettled、checked 挂账 reject)。depth() 口径保持 v1 = 尚未受理的消息数
|
|
283
|
+
* (受理转 in-flight 后不计;「在途未确认」数经 entriesFull() 全量视图消费)。
|
|
145
284
|
*/
|
|
146
285
|
|
|
147
286
|
/**
|
|
@@ -156,12 +295,150 @@ interface DeliveryWarnSink {
|
|
|
156
295
|
}
|
|
157
296
|
/** createDelivery 配置(DeliveryConfig + warn 出口注入)。 */
|
|
158
297
|
type DeliveryConfigWithWarn = DeliveryConfig & DeliveryWarnSink;
|
|
298
|
+
/** send/sendChecked 提交选项(v1 `{ merge? }` 的超集,向后兼容)。 */
|
|
299
|
+
interface DeliverySubmitOptions {
|
|
300
|
+
/** 合批窗口判定覆盖(v1 既有)。 */
|
|
301
|
+
merge?: boolean;
|
|
302
|
+
/**
|
|
303
|
+
* 客户端幂等 id(= 条目 id = 协议层 clientUuid,D2 裸标记身份源/D5② 判重锚)。
|
|
304
|
+
* 显式提供时参与幂等判重:命中活跃条目或 tombstone 即吞(resync 重报/重放去重)。
|
|
305
|
+
* 缺省内部生成、不参与判重。
|
|
306
|
+
*/
|
|
307
|
+
id?: string;
|
|
308
|
+
/** 投递车道(D1):适配器判定后传入记录;缺省 'direct'(既有调用方语义)。 */
|
|
309
|
+
lane?: DeliveryLane;
|
|
310
|
+
/**
|
|
311
|
+
* 送达回执锚申报(msg-pipeline-debloat D1 申报制):提交方声明本条目的送达判定锚。
|
|
312
|
+
* - 'marker'(缺省)= 出站文本尾附裸标记、送达以 message_end 回执命中为准
|
|
313
|
+
* (用户消息主通路语义);受理后维持 in-flight 等回执。
|
|
314
|
+
* - 'acceptance' = 无回执锚点条目(agent 通路:完成回流 / session_manager send /
|
|
315
|
+
* notifier 通知——出站文本原样不改),port.send 受理成功即落 delivered 终态
|
|
316
|
+
* (「受理 = 送达」是唯一可判事实,v1 记账口径 / D9⑤ 显式例外)。
|
|
317
|
+
*
|
|
318
|
+
* 缺省 'marker' 是刻意的保守取向:无标记提交点漏申报的代价 = 复发已知死锁形态
|
|
319
|
+
* (in-flight 永挂、gate 恒关,有全链诊断可查,可见);误申报 'acceptance' 的代价
|
|
320
|
+
* = 静默丢送达确认语义(更隐蔽)。两害取可见者。新增无标记投递来源必须显式申报
|
|
321
|
+
* 'acceptance' 并登记来源清单(ADR-0074 词条)。
|
|
322
|
+
*/
|
|
323
|
+
receiptAnchor?: 'marker' | 'acceptance';
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* send() 受理结果(b31-D6:失败双表面窄口收口)。调用方忽略返回值不受影响
|
|
327
|
+
* (v1 句柄面 send 仍声明 void,结构兼容)。三分口径:
|
|
328
|
+
* - accepted:已入队并进入投递流程(id = 条目 id,供调用方对账/判重锚定)
|
|
329
|
+
* - swallowed:幂等吞(already-seen = 显式 id 命中活跃集/tombstone;duplicate-content
|
|
330
|
+
* = dedupeKey 内容级去重命中)——设计内去重,非失败
|
|
331
|
+
* - rejected:拒收未入队(disposed = 句柄已销毁;unsupported-payload = 通路能力
|
|
332
|
+
* 不含该 payload kind)——仅 warn 单表面 + 本返回值,无终态无 onSettled
|
|
333
|
+
*
|
|
334
|
+
* void 兼容口径(与 SendReceipt 的 U2 扩展位同构):接口返回类型含 void——
|
|
335
|
+
* v1 → v2 测试替身适配(runtime asHandleV2 直转发 v1 handle.send)返回 void =
|
|
336
|
+
* 受理未知;内核本体实现恒返回具体结果,void 分支仅为替身/旧实现预留。
|
|
337
|
+
*/
|
|
338
|
+
type DeliverySendResult = {
|
|
339
|
+
kind: 'accepted';
|
|
340
|
+
id: string;
|
|
341
|
+
} | {
|
|
342
|
+
kind: 'swallowed';
|
|
343
|
+
reason: 'already-seen' | 'duplicate-content';
|
|
344
|
+
} | {
|
|
345
|
+
kind: 'rejected';
|
|
346
|
+
reason: 'disposed' | 'unsupported-payload';
|
|
347
|
+
};
|
|
348
|
+
/** v2 句柄:v1 DeliveryHandle 的超集(结构兼容,既有消费者零改动)。 */
|
|
349
|
+
interface DeliveryHandleV2 extends DeliveryHandle {
|
|
350
|
+
/** 唯一常规入口(D4 入口收敛);返回受理/吞/拒收口径(void = 受理未知,替身兼容)。 */
|
|
351
|
+
send(msg: DeliveryMessage, opts?: DeliverySubmitOptions): DeliverySendResult | void;
|
|
352
|
+
sendChecked(msg: DeliveryMessage, opts?: DeliverySubmitOptions): Promise<void>;
|
|
353
|
+
/** 全量视图(D9②):活跃条目(完整字段)+ 全部 tombstone。快照(防御性拷贝)。 */
|
|
354
|
+
entriesFull(): DeliveryEntriesFull;
|
|
355
|
+
/** 投影视图(D9②/D5③):活跃全量 + delivered 最近 50 条完整条目,cancelled 不投影。 */
|
|
356
|
+
projection(): DeliveryEntriesProjection;
|
|
357
|
+
/**
|
|
358
|
+
* 变更订阅(state topic 装配用):任何条目创建/迁移/终态触发。返回退订函数。
|
|
359
|
+
* 回调实现不应抛——异常由内核捕获并 warn,不影响其余订阅者与状态机。
|
|
360
|
+
*/
|
|
361
|
+
onChange(cb: () => void): () => void;
|
|
362
|
+
/**
|
|
363
|
+
* 送达回执驱动转 delivered(D9③)。接受 in-flight(正规送达路径)与 queued
|
|
364
|
+
* (扩展①:rebuild 直确认 / 出站中回执先到)。幂等:未知/已终态 no-op。
|
|
365
|
+
* @returns 是否发生了状态转移(诊断/测试用)。
|
|
366
|
+
*/
|
|
367
|
+
confirmDelivered(id: string): boolean;
|
|
368
|
+
/**
|
|
369
|
+
* 受理登记([扩展③],dmg-r1-4):分段 port 实现申报「本条目已由底层通道受理」。
|
|
370
|
+
* 只接受当前出站批内的 queued 条目(受理事实锚定于在途投递);生效即 queued →
|
|
371
|
+
* in-flight 转移 + 出批 + checked waiter 按受理口径 resolve(D9⑤)。幂等:非批内/
|
|
372
|
+
* 已转移/未知 id 返回 false。合批分段中途失败时已登记段留守 in-flight(等回执/对账),
|
|
373
|
+
* 仅未受理段走失败面——已进底层通道的文本不再误报「投递失败」。
|
|
374
|
+
*/
|
|
375
|
+
confirmAccepted(id: string): boolean;
|
|
376
|
+
/**
|
|
377
|
+
* 回收重置 queued 至队首(保持 ids 相对序,D3 own 处置/failed 重试)。
|
|
378
|
+
* 只接受 in-flight/failed;queued 幂等跳过(已在队列);cancelled/delivered
|
|
379
|
+
* 拒绝(防复活纪律)。@returns 实际重排条数。
|
|
380
|
+
*/
|
|
381
|
+
requeue(ids: readonly string[]): number;
|
|
382
|
+
/**
|
|
383
|
+
* 撤销(D9③/§3.1 场景 D):queued/failed 本地移除 + tombstone;in-flight 标记
|
|
384
|
+
* 待收回(扩展②两段式);已终态幂等返回既有 tombstone。
|
|
385
|
+
*/
|
|
386
|
+
cancel(id: string): DeliveryCancelResult;
|
|
387
|
+
/** 全量取回文本(forceQuit,D9③):条目清空 + 全部记 cancelled tombstone。 */
|
|
388
|
+
drain(): DrainResult;
|
|
389
|
+
}
|
|
390
|
+
/** cancel(id) 结果(D-3 偏差:cancelled 形态携带条目快照含 payload 全文供草稿恢复)。 */
|
|
391
|
+
type DeliveryCancelResult = {
|
|
392
|
+
kind: 'cancelled';
|
|
393
|
+
entry: DeliveryEntry;
|
|
394
|
+
} | {
|
|
395
|
+
kind: 'reclaim-requested';
|
|
396
|
+
entry: DeliveryEntry;
|
|
397
|
+
} | {
|
|
398
|
+
kind: 'already-final';
|
|
399
|
+
tombstone: DeliveryTombstone;
|
|
400
|
+
} | {
|
|
401
|
+
kind: 'not-found';
|
|
402
|
+
};
|
|
403
|
+
/** drain() 返回:全部未终态条目的文本取回(Segment[] 快照切分归上层,ADR-0043)。 */
|
|
404
|
+
type DrainResult = ReadonlyArray<{
|
|
405
|
+
id: string;
|
|
406
|
+
payload: DeliveryPayload;
|
|
407
|
+
}>;
|
|
159
408
|
/**
|
|
160
409
|
* 创建投递句柄。
|
|
161
410
|
*
|
|
162
411
|
* 约束:同 session 必须单例 handle(多 handle 并发投递竞态无保护)。
|
|
163
412
|
* subscribeSettled 的退订语义由适配器负责兑现。
|
|
164
413
|
*/
|
|
165
|
-
declare function createDelivery(port: DeliveryPort, config?: DeliveryConfigWithWarn):
|
|
414
|
+
declare function createDelivery(port: DeliveryPort, config?: DeliveryConfigWithWarn): DeliveryHandleV2;
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* 内核错误类(msg-pipeline-debloat D4-1,审计候选 16)。
|
|
418
|
+
*
|
|
419
|
+
* 判别形态依据(设计 §3.3 D4 尾注):本包与消费方(runtime registry)是进程内同包
|
|
420
|
+
* 调用边界——编译期类型可得,instanceof 是最简判别且免词表;与跨进程 WS 边界的
|
|
421
|
+
* 分类码形态(CompactErrorCode)不构成双轨。
|
|
422
|
+
*/
|
|
423
|
+
/**
|
|
424
|
+
* 「用户主动回收」对挂起 sendChecked waiter 的 reject 错误类。
|
|
425
|
+
*
|
|
426
|
+
* 产生点(delivery.ts):cancel 终结条目(queued/failed 本地终结 + in-flight 收回确认)
|
|
427
|
+
* 与 drain 全量回收时的 waiter reject。语义:文本已回草稿(V9/V11),条目已被内核
|
|
428
|
+
* 终结——不是投递失败,消费方(registry dispose 错误分级)据 instanceof 判别后
|
|
429
|
+
* 只记日志、不进用户可见错误面。
|
|
430
|
+
*
|
|
431
|
+
* 结构化字段(原文案契约的语义信息转字段,文案漂移不再击穿判别):
|
|
432
|
+
* - kind:回收方式。'cancelled' = cancel(id) 单条回收;'drained' = drain() 全量回收。
|
|
433
|
+
* - entryId:被回收条目 id(drain 全量回收无单条目指向,undefined)。
|
|
434
|
+
*
|
|
435
|
+
* message 保持历史文案形态(`delivery cancelled: <id>` / `delivery drained`):
|
|
436
|
+
* 日志与诊断输出字节级不变,判别不再依赖它。
|
|
437
|
+
*/
|
|
438
|
+
declare class DeliveryReclaimError extends Error {
|
|
439
|
+
readonly kind: 'cancelled' | 'drained';
|
|
440
|
+
readonly entryId: string | undefined;
|
|
441
|
+
constructor(kind: 'cancelled' | 'drained', entryId?: string);
|
|
442
|
+
}
|
|
166
443
|
|
|
167
|
-
export { type CustomPayload, type DeliveryConfig, type DeliveryHandle, type DeliveryIntent, type DeliveryMessage, type DeliveryPayload, type DeliveryPort, type SendReceipt, type TextPayload, createDelivery };
|
|
444
|
+
export { type CustomPayload, type DeliveryCancelResult, type DeliveryConfig, type DeliveryConfigWithWarn, type DeliveryEntriesFull, type DeliveryEntriesProjection, type DeliveryEntry, type DeliveryEntryState, type DeliveryHandle, type DeliveryHandleV2, type DeliveryIntent, type DeliveryLane, type DeliveryMessage, type DeliveryPayload, type DeliveryPort, DeliveryReclaimError, type DeliverySendResult, type DeliverySubmitOptions, type DeliveryTombstone, type DeliveryWarnSink, type DrainResult, type SendReceipt, type TextPayload, createDelivery };
|
package/dist/index.mjs
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
|
+
// src/errors.ts
|
|
2
|
+
var DeliveryReclaimError = class extends Error {
|
|
3
|
+
kind;
|
|
4
|
+
entryId;
|
|
5
|
+
constructor(kind, entryId) {
|
|
6
|
+
super(kind === "cancelled" ? `delivery cancelled: ${entryId ?? ""}` : "delivery drained");
|
|
7
|
+
this.name = "DeliveryReclaimError";
|
|
8
|
+
this.kind = kind;
|
|
9
|
+
this.entryId = entryId;
|
|
10
|
+
}
|
|
11
|
+
};
|
|
12
|
+
|
|
1
13
|
// src/lru.ts
|
|
2
14
|
var LruSet = class {
|
|
3
15
|
constructor(maxKeys) {
|
|
@@ -32,11 +44,12 @@ var LruSet = class {
|
|
|
32
44
|
// src/delivery.ts
|
|
33
45
|
var DEFAULT_CONFIG = {
|
|
34
46
|
intent: "interrupt-at-turn-boundary",
|
|
35
|
-
busyPolicy: "retry-force",
|
|
36
47
|
mergeWindowMs: 0,
|
|
37
48
|
backoff: { ms: 100, max: 50 },
|
|
38
49
|
watchdogMs: 3e4
|
|
39
50
|
};
|
|
51
|
+
var DEFAULT_DELIVERED_WINDOW = 50;
|
|
52
|
+
var PORT_SEND_SETTLE_TIMEOUT_MS = 6e4;
|
|
40
53
|
function isThenable(v) {
|
|
41
54
|
return !!v && typeof v.then === "function";
|
|
42
55
|
}
|
|
@@ -62,9 +75,10 @@ function buildBatchPayload(messages) {
|
|
|
62
75
|
}
|
|
63
76
|
};
|
|
64
77
|
}
|
|
78
|
+
const images = messages.flatMap((m) => m.payload.kind === "text" ? m.payload.images ?? [] : []);
|
|
65
79
|
return {
|
|
66
80
|
...first,
|
|
67
|
-
payload: { kind: "text", content }
|
|
81
|
+
payload: images.length > 0 ? { kind: "text", content, images } : { kind: "text", content }
|
|
68
82
|
};
|
|
69
83
|
}
|
|
70
84
|
function settleChecked(batch, err, checkedPending) {
|
|
@@ -73,7 +87,7 @@ function settleChecked(batch, err, checkedPending) {
|
|
|
73
87
|
const batchSet = new Set(batch);
|
|
74
88
|
const kept = [];
|
|
75
89
|
for (const w of checkedPending) {
|
|
76
|
-
if (!batchSet.has(w.
|
|
90
|
+
if (!batchSet.has(w.entry)) {
|
|
77
91
|
kept.push(w);
|
|
78
92
|
continue;
|
|
79
93
|
}
|
|
@@ -81,28 +95,21 @@ function settleChecked(batch, err, checkedPending) {
|
|
|
81
95
|
w.resolve();
|
|
82
96
|
} else {
|
|
83
97
|
w.reject(err);
|
|
84
|
-
rejected.add(w.
|
|
98
|
+
rejected.add(w.entry);
|
|
85
99
|
}
|
|
86
100
|
}
|
|
87
101
|
checkedPending.length = 0;
|
|
88
102
|
checkedPending.push(...kept);
|
|
89
103
|
return rejected;
|
|
90
104
|
}
|
|
91
|
-
function safeIsIdle(port) {
|
|
105
|
+
function safeIsIdle(port, onFault) {
|
|
92
106
|
try {
|
|
93
107
|
return port.isIdle();
|
|
94
|
-
} catch {
|
|
108
|
+
} catch (err) {
|
|
109
|
+
onFault?.(err);
|
|
95
110
|
return false;
|
|
96
111
|
}
|
|
97
112
|
}
|
|
98
|
-
function isBusy(port) {
|
|
99
|
-
if (!safeIsIdle(port)) return true;
|
|
100
|
-
try {
|
|
101
|
-
return port.hasPendingMessages();
|
|
102
|
-
} catch {
|
|
103
|
-
return true;
|
|
104
|
-
}
|
|
105
|
-
}
|
|
106
113
|
function resolveWarnSink(config) {
|
|
107
114
|
return config?.warn ?? ((msg, err) => {
|
|
108
115
|
console.warn(`[session-delivery] ${msg}`, err ?? "");
|
|
@@ -114,18 +121,110 @@ function createDelivery(port, config) {
|
|
|
114
121
|
...config,
|
|
115
122
|
backoff: config?.backoff ?? DEFAULT_CONFIG.backoff
|
|
116
123
|
};
|
|
117
|
-
const
|
|
124
|
+
const active = [];
|
|
125
|
+
const activeIndex = /* @__PURE__ */ new Map();
|
|
126
|
+
const tombstones = /* @__PURE__ */ new Map();
|
|
127
|
+
const deliveredLog = [];
|
|
118
128
|
const checkedPending = [];
|
|
119
129
|
let inflightBatch = [];
|
|
120
130
|
let inFlight = false;
|
|
121
131
|
let sendAttempts = 0;
|
|
122
132
|
let mergeTimer;
|
|
123
133
|
let backoffTimer;
|
|
134
|
+
let hangTimer;
|
|
124
135
|
let watchdogTimer;
|
|
125
136
|
let disposed = false;
|
|
126
137
|
let settledUnsub;
|
|
127
138
|
let missingKeyWarned = false;
|
|
139
|
+
let probeFaultWarned = false;
|
|
140
|
+
let idSeq = 0;
|
|
128
141
|
const dedupSet = config?.dedupe ? new LruSet(config.dedupe.maxKeys) : null;
|
|
142
|
+
const changeSubs = /* @__PURE__ */ new Set();
|
|
143
|
+
function now() {
|
|
144
|
+
return Date.now();
|
|
145
|
+
}
|
|
146
|
+
function genId() {
|
|
147
|
+
idSeq += 1;
|
|
148
|
+
return `d-${idSeq}`;
|
|
149
|
+
}
|
|
150
|
+
function snapshot(e) {
|
|
151
|
+
return {
|
|
152
|
+
id: e.id,
|
|
153
|
+
state: e.state,
|
|
154
|
+
lane: e.lane,
|
|
155
|
+
payload: e.payload,
|
|
156
|
+
createdAt: e.createdAt,
|
|
157
|
+
updatedAt: e.updatedAt,
|
|
158
|
+
sendAttempts: e.sendAttempts,
|
|
159
|
+
...e.settledAt !== void 0 ? { settledAt: e.settledAt } : {}
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
function notifyChange() {
|
|
163
|
+
for (const cb of changeSubs) {
|
|
164
|
+
try {
|
|
165
|
+
cb();
|
|
166
|
+
} catch (err) {
|
|
167
|
+
warn("onChange callback threw; isolated to keep notifying remaining subscribers", err);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
function callOnSettled(msg, outcome) {
|
|
172
|
+
try {
|
|
173
|
+
cfg.onSettled?.(msg, outcome);
|
|
174
|
+
} catch (err) {
|
|
175
|
+
warn(`onSettled callback threw (outcome=${outcome}); isolated from delivery state machine`, err);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
function warnProbeFault(err) {
|
|
179
|
+
if (probeFaultWarned) return;
|
|
180
|
+
probeFaultWarned = true;
|
|
181
|
+
warn("port busy probe (isIdle) threw; conservatively treating as busy", err);
|
|
182
|
+
}
|
|
183
|
+
function isBusySafe() {
|
|
184
|
+
if (!safeIsIdle(port, warnProbeFault)) return true;
|
|
185
|
+
return hasInFlightEntries();
|
|
186
|
+
}
|
|
187
|
+
function hasInFlightEntries() {
|
|
188
|
+
for (const e of active) if (e.state === "in-flight") return true;
|
|
189
|
+
return false;
|
|
190
|
+
}
|
|
191
|
+
function inRegistry(e) {
|
|
192
|
+
return activeIndex.get(e.id) === e;
|
|
193
|
+
}
|
|
194
|
+
function hasQueuedEntries() {
|
|
195
|
+
for (const e of active) if (e.state === "queued") return true;
|
|
196
|
+
return false;
|
|
197
|
+
}
|
|
198
|
+
function finalizeEntry(e, state) {
|
|
199
|
+
const batchIdx = inflightBatch.indexOf(e);
|
|
200
|
+
if (batchIdx !== -1) inflightBatch.splice(batchIdx, 1);
|
|
201
|
+
const idx = active.indexOf(e);
|
|
202
|
+
if (idx !== -1) active.splice(idx, 1);
|
|
203
|
+
activeIndex.delete(e.id);
|
|
204
|
+
e.state = state;
|
|
205
|
+
const ts = now();
|
|
206
|
+
e.updatedAt = ts;
|
|
207
|
+
e.settledAt = ts;
|
|
208
|
+
tombstones.set(e.id, { id: e.id, state, lane: e.lane, settledAt: ts });
|
|
209
|
+
if (state === "delivered") {
|
|
210
|
+
deliveredLog.push(e);
|
|
211
|
+
if (deliveredLog.length > DEFAULT_DELIVERED_WINDOW) deliveredLog.shift();
|
|
212
|
+
}
|
|
213
|
+
notifyChange();
|
|
214
|
+
}
|
|
215
|
+
function removeActive(e) {
|
|
216
|
+
const idx = active.indexOf(e);
|
|
217
|
+
if (idx !== -1) active.splice(idx, 1);
|
|
218
|
+
activeIndex.delete(e.id);
|
|
219
|
+
}
|
|
220
|
+
function rejectWaitersOf(e, err) {
|
|
221
|
+
for (let i = checkedPending.length - 1; i >= 0; i--) {
|
|
222
|
+
if (checkedPending[i].entry === e) {
|
|
223
|
+
checkedPending[i].reject(err);
|
|
224
|
+
checkedPending.splice(i, 1);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
129
228
|
function clearMergeTimer() {
|
|
130
229
|
if (mergeTimer !== void 0) {
|
|
131
230
|
clearTimeout(mergeTimer);
|
|
@@ -143,11 +242,23 @@ function createDelivery(port, config) {
|
|
|
143
242
|
clearMergeTimer();
|
|
144
243
|
armMergeTimer();
|
|
145
244
|
}
|
|
245
|
+
function clearBackoffTimer() {
|
|
246
|
+
if (backoffTimer !== void 0) {
|
|
247
|
+
clearTimeout(backoffTimer);
|
|
248
|
+
backoffTimer = void 0;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
function clearHangTimer() {
|
|
252
|
+
if (hangTimer !== void 0) {
|
|
253
|
+
clearTimeout(hangTimer);
|
|
254
|
+
hangTimer = void 0;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
146
257
|
function ensureSettledSub() {
|
|
147
258
|
if (settledUnsub || !port.subscribeSettled) return;
|
|
148
259
|
settledUnsub = port.subscribeSettled(() => {
|
|
149
260
|
if (disposed) return;
|
|
150
|
-
if (!
|
|
261
|
+
if (!isBusySafe()) {
|
|
151
262
|
flush();
|
|
152
263
|
}
|
|
153
264
|
});
|
|
@@ -163,8 +274,8 @@ function createDelivery(port, config) {
|
|
|
163
274
|
if (!port.subscribeSettled) return;
|
|
164
275
|
watchdogTimer = setInterval(() => {
|
|
165
276
|
if (disposed || inFlight) return;
|
|
166
|
-
if (
|
|
167
|
-
if (!
|
|
277
|
+
if (!hasQueuedEntries()) return;
|
|
278
|
+
if (!isBusySafe()) {
|
|
168
279
|
flush();
|
|
169
280
|
}
|
|
170
281
|
}, cfg.watchdogMs);
|
|
@@ -176,15 +287,45 @@ function createDelivery(port, config) {
|
|
|
176
287
|
}
|
|
177
288
|
}
|
|
178
289
|
function attemptSend() {
|
|
290
|
+
if (inflightBatch.some((e) => !inRegistry(e))) {
|
|
291
|
+
inflightBatch = inflightBatch.filter((e) => inRegistry(e));
|
|
292
|
+
}
|
|
293
|
+
if (inflightBatch.length === 0) {
|
|
294
|
+
inFlight = false;
|
|
295
|
+
sendAttempts = 0;
|
|
296
|
+
pump();
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
179
299
|
const batch = inflightBatch;
|
|
180
|
-
const composed = buildBatchPayload(batch);
|
|
300
|
+
const composed = buildBatchPayload(batch.map((e) => e.msg));
|
|
181
301
|
const intent = composed.intent ?? cfg.intent;
|
|
182
302
|
try {
|
|
183
303
|
const result = port.send(composed, intent);
|
|
184
304
|
if (isThenable(result)) {
|
|
305
|
+
let settled = false;
|
|
306
|
+
clearHangTimer();
|
|
307
|
+
hangTimer = setTimeout(() => {
|
|
308
|
+
if (settled || disposed) return;
|
|
309
|
+
settled = true;
|
|
310
|
+
hangTimer = void 0;
|
|
311
|
+
warn(
|
|
312
|
+
`port.send hung; force-failed after ${PORT_SEND_SETTLE_TIMEOUT_MS}ms (retries may duplicate-deliver; see DeliveryPort.send settle contract)`
|
|
313
|
+
);
|
|
314
|
+
onSendFail(new Error(`port.send promise did not settle within ${PORT_SEND_SETTLE_TIMEOUT_MS}ms`));
|
|
315
|
+
}, PORT_SEND_SETTLE_TIMEOUT_MS);
|
|
185
316
|
result.then(
|
|
186
|
-
(receipt) =>
|
|
187
|
-
|
|
317
|
+
(receipt) => {
|
|
318
|
+
if (settled) return;
|
|
319
|
+
settled = true;
|
|
320
|
+
clearHangTimer();
|
|
321
|
+
onSendReceipt(receipt);
|
|
322
|
+
},
|
|
323
|
+
(err) => {
|
|
324
|
+
if (settled) return;
|
|
325
|
+
settled = true;
|
|
326
|
+
clearHangTimer();
|
|
327
|
+
onSendFail(err);
|
|
328
|
+
}
|
|
188
329
|
);
|
|
189
330
|
} else {
|
|
190
331
|
onSendReceipt(result);
|
|
@@ -202,23 +343,36 @@ function createDelivery(port, config) {
|
|
|
202
343
|
}
|
|
203
344
|
function onSendOk() {
|
|
204
345
|
if (disposed) return;
|
|
205
|
-
const
|
|
346
|
+
const batch = inflightBatch;
|
|
206
347
|
inFlight = false;
|
|
207
348
|
inflightBatch = [];
|
|
208
349
|
sendAttempts = 0;
|
|
209
|
-
settleChecked(
|
|
210
|
-
|
|
350
|
+
settleChecked(batch, void 0, checkedPending);
|
|
351
|
+
let changed = false;
|
|
352
|
+
for (const e of batch) {
|
|
353
|
+
if (disposed) break;
|
|
354
|
+
if (!inRegistry(e)) continue;
|
|
355
|
+
if (e.state !== "queued") continue;
|
|
356
|
+
e.state = "in-flight";
|
|
357
|
+
e.updatedAt = now();
|
|
358
|
+
changed = true;
|
|
359
|
+
}
|
|
360
|
+
if (changed) notifyChange();
|
|
361
|
+
for (const e of batch) {
|
|
211
362
|
if (disposed) break;
|
|
212
|
-
|
|
363
|
+
if (e.receiptAnchor === "acceptance") confirmDelivered(e.id);
|
|
213
364
|
}
|
|
214
365
|
pump();
|
|
215
366
|
}
|
|
216
367
|
function onSendFail(err) {
|
|
217
368
|
if (disposed) return;
|
|
218
369
|
sendAttempts++;
|
|
370
|
+
for (const e of inflightBatch) e.sendAttempts = sendAttempts;
|
|
219
371
|
const rejected = settleChecked(inflightBatch, err, checkedPending);
|
|
220
372
|
if (rejected.size > 0) {
|
|
221
|
-
inflightBatch = inflightBatch.filter((
|
|
373
|
+
inflightBatch = inflightBatch.filter((e) => !rejected.has(e));
|
|
374
|
+
for (const e of rejected) removeActive(e);
|
|
375
|
+
notifyChange();
|
|
222
376
|
}
|
|
223
377
|
if (inflightBatch.length === 0) {
|
|
224
378
|
inFlight = false;
|
|
@@ -229,15 +383,23 @@ function createDelivery(port, config) {
|
|
|
229
383
|
return;
|
|
230
384
|
}
|
|
231
385
|
if (sendAttempts > cfg.backoff.max) {
|
|
232
|
-
const
|
|
386
|
+
const failedBatch = inflightBatch;
|
|
233
387
|
inFlight = false;
|
|
234
388
|
inflightBatch = [];
|
|
235
389
|
sendAttempts = 0;
|
|
236
390
|
warn("port.send failed after max retries", err);
|
|
237
|
-
|
|
391
|
+
let changed = false;
|
|
392
|
+
for (const e of failedBatch) {
|
|
238
393
|
if (disposed) break;
|
|
239
|
-
|
|
394
|
+
if (!inRegistry(e)) continue;
|
|
395
|
+
e.state = "failed";
|
|
396
|
+
const ts = now();
|
|
397
|
+
e.updatedAt = ts;
|
|
398
|
+
e.settledAt = ts;
|
|
399
|
+
changed = true;
|
|
400
|
+
callOnSettled(e.msg, "rejected");
|
|
240
401
|
}
|
|
402
|
+
if (changed) notifyChange();
|
|
241
403
|
pump();
|
|
242
404
|
return;
|
|
243
405
|
}
|
|
@@ -251,44 +413,38 @@ function createDelivery(port, config) {
|
|
|
251
413
|
function pump() {
|
|
252
414
|
if (disposed || inFlight) return;
|
|
253
415
|
if (checkedPending.length > 0) {
|
|
254
|
-
const
|
|
416
|
+
const batch = [];
|
|
255
417
|
for (const w of checkedPending) {
|
|
256
|
-
|
|
257
|
-
if (idx !== -1) {
|
|
258
|
-
queue.splice(idx, 1);
|
|
259
|
-
msgs.push(w.msg);
|
|
260
|
-
}
|
|
418
|
+
if (inRegistry(w.entry) && w.entry.state === "queued") batch.push(w.entry);
|
|
261
419
|
}
|
|
262
|
-
if (
|
|
263
|
-
inflightBatch =
|
|
420
|
+
if (batch.length > 0) {
|
|
421
|
+
inflightBatch = batch;
|
|
264
422
|
inFlight = true;
|
|
265
423
|
sendAttempts = 0;
|
|
266
424
|
attemptSend();
|
|
267
425
|
return;
|
|
268
426
|
}
|
|
269
427
|
}
|
|
270
|
-
if (
|
|
428
|
+
if (hasQueuedEntries()) {
|
|
271
429
|
scheduleFlush(0);
|
|
272
430
|
return;
|
|
273
431
|
}
|
|
274
432
|
if (checkedPending.length === 0) stopWatchdog();
|
|
275
433
|
}
|
|
276
434
|
function doSend() {
|
|
277
|
-
if (disposed ||
|
|
435
|
+
if (disposed || inFlight) return;
|
|
436
|
+
const batch = active.filter((e) => e.state === "queued");
|
|
437
|
+
if (batch.length === 0) return;
|
|
278
438
|
inFlight = true;
|
|
279
|
-
inflightBatch =
|
|
439
|
+
inflightBatch = batch;
|
|
280
440
|
sendAttempts = 0;
|
|
281
441
|
attemptSend();
|
|
282
442
|
}
|
|
283
443
|
function scheduleFlush(attempt) {
|
|
284
|
-
if (disposed ||
|
|
444
|
+
if (disposed || !hasQueuedEntries()) return;
|
|
285
445
|
if (inFlight) return;
|
|
286
|
-
|
|
287
|
-
if (
|
|
288
|
-
clearTimeout(backoffTimer);
|
|
289
|
-
backoffTimer = void 0;
|
|
290
|
-
}
|
|
291
|
-
if (isBusy(port) && attempt < cfg.backoff.max) {
|
|
446
|
+
clearBackoffTimer();
|
|
447
|
+
if (isBusySafe() && attempt < cfg.backoff.max) {
|
|
292
448
|
if (port.subscribeSettled) {
|
|
293
449
|
startWatchdog();
|
|
294
450
|
return;
|
|
@@ -318,38 +474,71 @@ function createDelivery(port, config) {
|
|
|
318
474
|
dedupSet.add(msg.dedupeKey);
|
|
319
475
|
return true;
|
|
320
476
|
}
|
|
477
|
+
function seenId(id) {
|
|
478
|
+
if (id === void 0) return false;
|
|
479
|
+
return activeIndex.has(id) || tombstones.has(id);
|
|
480
|
+
}
|
|
481
|
+
function createEntry(msg, opts) {
|
|
482
|
+
const ts = now();
|
|
483
|
+
const entry = {
|
|
484
|
+
id: opts?.id ?? genId(),
|
|
485
|
+
state: "queued",
|
|
486
|
+
lane: opts?.lane ?? "direct",
|
|
487
|
+
payload: msg.payload,
|
|
488
|
+
createdAt: ts,
|
|
489
|
+
updatedAt: ts,
|
|
490
|
+
sendAttempts: 0,
|
|
491
|
+
msg,
|
|
492
|
+
cancelRequested: false,
|
|
493
|
+
receiptAnchor: opts?.receiptAnchor ?? "marker"
|
|
494
|
+
};
|
|
495
|
+
active.push(entry);
|
|
496
|
+
activeIndex.set(entry.id, entry);
|
|
497
|
+
notifyChange();
|
|
498
|
+
return entry;
|
|
499
|
+
}
|
|
321
500
|
function send(msg, opts) {
|
|
322
|
-
if (disposed)
|
|
501
|
+
if (disposed) {
|
|
502
|
+
warn("send ignored: delivery handle disposed (caller should re-create the handle)");
|
|
503
|
+
return { kind: "rejected", reason: "disposed" };
|
|
504
|
+
}
|
|
323
505
|
if (!port.supportedPayloads.includes(msg.payload.kind)) {
|
|
324
506
|
warn(`unsupported payload kind: ${msg.payload.kind}`);
|
|
325
|
-
return;
|
|
507
|
+
return { kind: "rejected", reason: "unsupported-payload" };
|
|
326
508
|
}
|
|
327
|
-
if (
|
|
328
|
-
|
|
509
|
+
if (seenId(opts?.id)) {
|
|
510
|
+
warn(`send swallowed by idempotency dedupe: id already seen (id=${opts?.id})`);
|
|
511
|
+
return { kind: "swallowed", reason: "already-seen" };
|
|
512
|
+
}
|
|
513
|
+
if (!passDedupe(msg)) return { kind: "swallowed", reason: "duplicate-content" };
|
|
514
|
+
const entry = createEntry(msg, opts);
|
|
329
515
|
const useMerge = opts?.merge ?? (cfg.mergeWindowMs > 0 && cfg.mergeHoldActive != null && cfg.mergeHoldActive());
|
|
330
516
|
if (useMerge) {
|
|
331
517
|
resetMergeTimer();
|
|
332
518
|
ensureSettledSub();
|
|
333
|
-
return;
|
|
519
|
+
return { kind: "accepted", id: entry.id };
|
|
334
520
|
}
|
|
335
521
|
clearMergeTimer();
|
|
336
522
|
ensureSettledSub();
|
|
337
523
|
scheduleFlush(0);
|
|
524
|
+
return { kind: "accepted", id: entry.id };
|
|
338
525
|
}
|
|
339
|
-
async function sendChecked(msg) {
|
|
526
|
+
async function sendChecked(msg, opts) {
|
|
340
527
|
if (disposed) throw new Error("delivery handle disposed");
|
|
341
528
|
if (!port.supportedPayloads.includes(msg.payload.kind)) {
|
|
342
529
|
throw new Error(`unsupported payload kind: ${msg.payload.kind}`);
|
|
343
530
|
}
|
|
531
|
+
if (seenId(opts?.id)) {
|
|
532
|
+
warn(`sendChecked swallowed by idempotency dedupe: id already seen (id=${opts?.id})`);
|
|
533
|
+
return;
|
|
534
|
+
}
|
|
344
535
|
if (!passDedupe(msg)) return;
|
|
345
|
-
|
|
536
|
+
const entry = createEntry(msg, opts);
|
|
346
537
|
ensureSettledSub();
|
|
347
538
|
return new Promise((resolve, reject) => {
|
|
348
|
-
checkedPending.push({
|
|
539
|
+
checkedPending.push({ entry, resolve, reject });
|
|
349
540
|
if (!inFlight) {
|
|
350
|
-
|
|
351
|
-
if (idx !== -1) queue.splice(idx, 1);
|
|
352
|
-
inflightBatch = [msg];
|
|
541
|
+
inflightBatch = [entry];
|
|
353
542
|
inFlight = true;
|
|
354
543
|
sendAttempts = 0;
|
|
355
544
|
attemptSend();
|
|
@@ -358,38 +547,157 @@ function createDelivery(port, config) {
|
|
|
358
547
|
}
|
|
359
548
|
function flush() {
|
|
360
549
|
if (disposed) return;
|
|
361
|
-
|
|
362
|
-
clearTimeout(mergeTimer);
|
|
363
|
-
mergeTimer = void 0;
|
|
364
|
-
}
|
|
550
|
+
clearMergeTimer();
|
|
365
551
|
scheduleFlush(0);
|
|
366
552
|
}
|
|
367
553
|
function depth() {
|
|
368
|
-
|
|
554
|
+
let n = 0;
|
|
555
|
+
for (const e of active) if (e.state === "queued") n++;
|
|
556
|
+
return n;
|
|
369
557
|
}
|
|
370
|
-
function
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
558
|
+
function entriesFull() {
|
|
559
|
+
return {
|
|
560
|
+
active: active.map(snapshot),
|
|
561
|
+
tombstones: [...tombstones.values()].map((t) => ({ ...t }))
|
|
562
|
+
};
|
|
563
|
+
}
|
|
564
|
+
function projection() {
|
|
565
|
+
return { entries: [...active.map(snapshot), ...deliveredLog.map(snapshot)] };
|
|
566
|
+
}
|
|
567
|
+
function onChange(cb) {
|
|
568
|
+
changeSubs.add(cb);
|
|
569
|
+
return () => {
|
|
570
|
+
changeSubs.delete(cb);
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
function confirmDelivered(id) {
|
|
574
|
+
if (disposed) {
|
|
575
|
+
warn(`confirmDelivered ignored: delivery handle disposed (id=${id})`);
|
|
576
|
+
return false;
|
|
375
577
|
}
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
578
|
+
const e = activeIndex.get(id);
|
|
579
|
+
if (!e) return false;
|
|
580
|
+
if (e.state !== "queued" && e.state !== "in-flight") return false;
|
|
581
|
+
finalizeEntry(e, "delivered");
|
|
582
|
+
if (!disposed) callOnSettled(e.msg, "delivered");
|
|
583
|
+
return true;
|
|
584
|
+
}
|
|
585
|
+
function confirmAccepted(id) {
|
|
586
|
+
if (disposed) return false;
|
|
587
|
+
const e = activeIndex.get(id);
|
|
588
|
+
if (!e || e.state !== "queued") return false;
|
|
589
|
+
if (!inflightBatch.includes(e)) return false;
|
|
590
|
+
inflightBatch.splice(inflightBatch.indexOf(e), 1);
|
|
591
|
+
e.state = "in-flight";
|
|
592
|
+
e.updatedAt = now();
|
|
593
|
+
settleChecked([e], void 0, checkedPending);
|
|
594
|
+
notifyChange();
|
|
595
|
+
return true;
|
|
596
|
+
}
|
|
597
|
+
function requeue(ids) {
|
|
598
|
+
if (disposed) {
|
|
599
|
+
warn("requeue ignored: delivery handle disposed");
|
|
600
|
+
return 0;
|
|
601
|
+
}
|
|
602
|
+
if (ids.length === 0) return 0;
|
|
603
|
+
const found = [];
|
|
604
|
+
const seen = /* @__PURE__ */ new Set();
|
|
605
|
+
for (const id of ids) {
|
|
606
|
+
if (seen.has(id)) continue;
|
|
607
|
+
seen.add(id);
|
|
608
|
+
const e = activeIndex.get(id);
|
|
609
|
+
if (!e) continue;
|
|
610
|
+
if (e.state !== "in-flight" && e.state !== "failed") continue;
|
|
611
|
+
if (e.cancelRequested) continue;
|
|
612
|
+
found.push(e);
|
|
613
|
+
}
|
|
614
|
+
if (found.length === 0) return 0;
|
|
615
|
+
for (const e of found) {
|
|
616
|
+
const idx = active.indexOf(e);
|
|
617
|
+
if (idx !== -1) active.splice(idx, 1);
|
|
618
|
+
e.state = "queued";
|
|
619
|
+
e.sendAttempts = 0;
|
|
620
|
+
e.updatedAt = now();
|
|
621
|
+
e.settledAt = void 0;
|
|
379
622
|
}
|
|
623
|
+
active.unshift(...found);
|
|
624
|
+
notifyChange();
|
|
625
|
+
scheduleFlush(0);
|
|
626
|
+
return found.length;
|
|
627
|
+
}
|
|
628
|
+
function cancel(id) {
|
|
629
|
+
if (disposed) {
|
|
630
|
+
warn(`cancel ignored: delivery handle disposed (id=${id}, reported as not-found)`);
|
|
631
|
+
return { kind: "not-found" };
|
|
632
|
+
}
|
|
633
|
+
const e = activeIndex.get(id);
|
|
634
|
+
if (!e) {
|
|
635
|
+
const tb = tombstones.get(id);
|
|
636
|
+
if (tb) return { kind: "already-final", tombstone: { ...tb } };
|
|
637
|
+
return { kind: "not-found" };
|
|
638
|
+
}
|
|
639
|
+
if (e.state === "in-flight") {
|
|
640
|
+
if (!e.cancelRequested) {
|
|
641
|
+
e.cancelRequested = true;
|
|
642
|
+
e.updatedAt = now();
|
|
643
|
+
notifyChange();
|
|
644
|
+
return { kind: "reclaim-requested", entry: snapshot(e) };
|
|
645
|
+
}
|
|
646
|
+
rejectWaitersOf(e, new DeliveryReclaimError("cancelled", id));
|
|
647
|
+
finalizeEntry(e, "cancelled");
|
|
648
|
+
return { kind: "cancelled", entry: snapshot(e) };
|
|
649
|
+
}
|
|
650
|
+
rejectWaitersOf(e, new DeliveryReclaimError("cancelled", id));
|
|
651
|
+
finalizeEntry(e, "cancelled");
|
|
652
|
+
return { kind: "cancelled", entry: snapshot(e) };
|
|
653
|
+
}
|
|
654
|
+
function drain() {
|
|
655
|
+
if (disposed) return [];
|
|
656
|
+
clearMergeTimer();
|
|
657
|
+
clearBackoffTimer();
|
|
658
|
+
clearHangTimer();
|
|
659
|
+
const drained = active.slice();
|
|
660
|
+
active.length = 0;
|
|
661
|
+
activeIndex.clear();
|
|
662
|
+
inflightBatch = [];
|
|
663
|
+
inFlight = false;
|
|
664
|
+
sendAttempts = 0;
|
|
665
|
+
const ts = now();
|
|
666
|
+
const result = drained.map((e) => {
|
|
667
|
+
tombstones.set(e.id, { id: e.id, state: "cancelled", lane: e.lane, settledAt: ts });
|
|
668
|
+
return { id: e.id, payload: e.payload };
|
|
669
|
+
});
|
|
670
|
+
for (const w of checkedPending) {
|
|
671
|
+
w.reject(new DeliveryReclaimError("drained"));
|
|
672
|
+
}
|
|
673
|
+
checkedPending.length = 0;
|
|
674
|
+
stopWatchdog();
|
|
675
|
+
if (drained.length > 0) notifyChange();
|
|
676
|
+
return result;
|
|
677
|
+
}
|
|
678
|
+
function dispose() {
|
|
679
|
+
disposed = true;
|
|
680
|
+
clearMergeTimer();
|
|
681
|
+
clearBackoffTimer();
|
|
682
|
+
clearHangTimer();
|
|
380
683
|
stopWatchdog();
|
|
381
684
|
teardownSettledSub();
|
|
382
|
-
|
|
685
|
+
active.length = 0;
|
|
686
|
+
activeIndex.clear();
|
|
687
|
+
tombstones.clear();
|
|
688
|
+
deliveredLog.length = 0;
|
|
383
689
|
inflightBatch = [];
|
|
384
690
|
inFlight = false;
|
|
691
|
+
changeSubs.clear();
|
|
385
692
|
for (const w of checkedPending) {
|
|
386
693
|
w.reject(new Error("delivery handle disposed"));
|
|
387
694
|
}
|
|
388
695
|
checkedPending.length = 0;
|
|
389
696
|
dedupSet?.clear();
|
|
390
697
|
}
|
|
391
|
-
return { send, sendChecked, flush, depth, dispose };
|
|
698
|
+
return { send, sendChecked, flush, depth, entriesFull, projection, onChange, confirmDelivered, confirmAccepted, requeue, cancel, drain, dispose };
|
|
392
699
|
}
|
|
393
700
|
export {
|
|
701
|
+
DeliveryReclaimError,
|
|
394
702
|
createDelivery
|
|
395
703
|
};
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@zhushanwen/session-delivery",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "1.0.0",
|
|
4
4
|
"description": "Session message delivery kernel: zero-pi-dependency strategy layer for queueing, batching, dedup, and gated flush of messages into pi sessions",
|
|
5
5
|
"main": "dist/index.mjs",
|
|
6
6
|
"module": "dist/index.mjs",
|