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