@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 CHANGED
@@ -5,7 +5,7 @@ Session 消息投递内核:零 pi 依赖的策略层,负责排队、合批
5
5
  ## 设计原则
6
6
 
7
7
  - **零 pi 依赖**:内核不 import pi 包任何符号。steer/followUp/triggerTurn/streamingBehavior 等 pi 词汇封闭在两侧适配器内。
8
- - **端口注入**:`DeliveryPort` 是内核与外部世界的唯一接口(isIdle / hasPendingMessages / send / subscribeSettled),调用方负责注入运行时能力。
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
- /** 文本 payload(runtime 通路一期唯一支持)。 */
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 / park 外部重触发 / settled 边沿内部复用)。 */
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
- * 投递内核实现:createDelivery。
132
- *
133
- * 核心行为(design.md §3.3 D4/D8):
134
- * - send() 入队 → dedupe → 合批窗口判定 → 立即投或窗口到期投
135
- * - flush() → busy gate(isIdle + hasPendingMessages 双条件,G4)→ port.send
136
- * - settled 边沿 → busy 复核通过 → flush;有订阅装配下 busy 只依赖边沿 + watch-dog(不退避强发)
137
- * - watch-dog 低频复核(D8 兜底层①:settled 事件丢失的恢复路径)
138
- * - port.send 失败 → 消息留在途、按 backoff 有限重试,达上限 settle rejected(D4 错误重试)
139
- * - 终态上报 per-message(ext-simplify-08 D1/B1):onSettled 对批次内每条消息各回调
140
- * 一次,msg 为该条原始消息(非 composed 合批消息)——additive meta(notifyId 等)
141
- * 随原消息引用原样透传到回调(内核不读不改);单消息批次行为不变
142
- * - sendChecked() 统一经投递循环:resolve 挂钩 port.send 受理结果(busy 时经
143
- * streaming 受理入 pi 队列即回,以此确认可达);首次受理失败即 reject(入口即拦)
144
- * - in-flight 防重:单 handle 至多一个 port.send 在途
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): DeliveryHandle;
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.msg)) {
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.msg);
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 queue = [];
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 (!isBusy(port)) {
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 (queue.length === 0) return;
167
- if (!isBusy(port)) {
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) => onSendReceipt(receipt),
187
- (err) => onSendFail(err)
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 delivered = inflightBatch;
346
+ const batch = inflightBatch;
206
347
  inFlight = false;
207
348
  inflightBatch = [];
208
349
  sendAttempts = 0;
209
- settleChecked(delivered, void 0, checkedPending);
210
- for (const m of delivered) {
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
- cfg.onSettled?.(m, "delivered");
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((m) => !rejected.has(m));
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 rejectedBatch = inflightBatch;
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
- for (const m of rejectedBatch) {
391
+ let changed = false;
392
+ for (const e of failedBatch) {
238
393
  if (disposed) break;
239
- cfg.onSettled?.(m, "rejected");
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 msgs = [];
416
+ const batch = [];
255
417
  for (const w of checkedPending) {
256
- const idx = queue.indexOf(w.msg);
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 (msgs.length > 0) {
263
- inflightBatch = msgs;
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 (queue.length > 0) {
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 || queue.length === 0 || inFlight) return;
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 = queue.splice(0);
439
+ inflightBatch = batch;
280
440
  sendAttempts = 0;
281
441
  attemptSend();
282
442
  }
283
443
  function scheduleFlush(attempt) {
284
- if (disposed || queue.length === 0) return;
444
+ if (disposed || !hasQueuedEntries()) return;
285
445
  if (inFlight) return;
286
- if (cfg.busyPolicy === "park" && attempt > 0) return;
287
- if (backoffTimer !== void 0) {
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) return;
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 (!passDedupe(msg)) return;
328
- queue.push(msg);
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
- queue.push(msg);
536
+ const entry = createEntry(msg, opts);
346
537
  ensureSettledSub();
347
538
  return new Promise((resolve, reject) => {
348
- checkedPending.push({ msg, resolve, reject });
539
+ checkedPending.push({ entry, resolve, reject });
349
540
  if (!inFlight) {
350
- const idx = queue.indexOf(msg);
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
- if (mergeTimer !== void 0) {
362
- clearTimeout(mergeTimer);
363
- mergeTimer = void 0;
364
- }
550
+ clearMergeTimer();
365
551
  scheduleFlush(0);
366
552
  }
367
553
  function depth() {
368
- return queue.length + inflightBatch.length;
554
+ let n = 0;
555
+ for (const e of active) if (e.state === "queued") n++;
556
+ return n;
369
557
  }
370
- function dispose() {
371
- disposed = true;
372
- if (mergeTimer !== void 0) {
373
- clearTimeout(mergeTimer);
374
- mergeTimer = void 0;
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
- if (backoffTimer !== void 0) {
377
- clearTimeout(backoffTimer);
378
- backoffTimer = void 0;
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
- queue.length = 0;
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.11.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",