/** * gw#1587 客户端投递保证:出站 outbox 状态机 + 串行 drain 调度器。 * * 语义源 = spec gw-1587-client-msg-delivery §5.1/§5.2/§5.3(v6.1,五轮评审收敛)。 * 关键不变量: * - G1:armed 连接上带生效 client_msg_id 的消息,终态只有 delivered / failed(可见可重试)。 * - 终态信号 = reconnect_exhausted / 显式 disconnect() / disarm 判定——**绝不用** * 'disconnected' 状态回调(reconnect() 的 identity guard 吞 onclose、不发回调,§1.8)。 * - eventwait 60s 重探**不计 transientCount**(15min 兜底是唯一上限——v4 评审:计数会使 * 5×60s 恒先到顶,5-15min 在飞 turn false-failed 复活)。 * - deliveryState 只驱动消息级指示,**绝不写 conv 级 processing/isThinking**(§5.4)。 */ export type DeliveryState = 'pending' | 'delivered' | 'failed'; export type FailedCause = 'arm_timeout' | 'disarm' | 'conn_terminal' | 'terminal' | 'timeout_exhaust' | 'transient_exhaust'; export interface DeliveryOutboxHooks { /** 直发一帧(true=已写入 socket buffer)。 */ sendFrame(frameJson: string): boolean; /** ack 超时时主动重连(僵尸 socket 主模式的唯一有效动作,§1.8/F4)。 */ reconnect(): void; /** 消息级投递状态变化(→ store per-message deliveryState;绝不碰 conv 级态)。 */ onStateChange(clientMsgId: string, state: DeliveryState, cause?: FailedCause): void; /** * MAJOR-2 修:一条帧真正投出(drain dispatch)时回调——host 在此置 thinking(spec §5.1 * 「入队不置 thinking,drain 投出时置位」)。gap 入队后终局 failed 的消息从不 dispatch → * 不置 thinking → 不 wedge composer(#619 同型)。 */ onDispatch?(clientMsgId: string, conversationId: string): void; } export declare class DeliveryOutbox { private readonly hooks; private entries; private draining; private drainAgain; private armed; private connected; private reconnectRequestedForTimeout; /** * #147 r3 finding 4:switch 纪元。宿主每次「要求换 session」(startNewSession / attach 到 * 不同 id)都 bumpEpoch(),之后入队的条目带新纪元。跨 session 闸在 session_ready 兑现时 * 只终结/丢弃 `epoch < switchEpoch` 的条目 —— 换 session 请求之后、session_ready 之前入队 * 的帧(startNewSession 在 onopen 就 resolve,宿主此刻已重新启用输入框)是为新 session * 写的,可投递,不是毒。 */ private epoch; constructor(hooks: DeliveryOutboxHooks); /** 宿主要求换 session 的那一刻调用;返回新纪元(之后入队的条目都属于它)。 */ bumpEpoch(): number; /** connected 边沿(armed 判定尚未发生——只记状态,不 drain)。 */ onConnected(): void; /** 连接断开(非终态——正常重连路径;in-flight ack 等待作废回 pending)。 */ onDisconnected(): void; /** * armed 判定(本连接 session_ready 处理完成 / 10s 兜底)。 * armed=true:全量 drain(eventwait 转 pending + failedCause∈{arm_timeout,disarm} 且未 * retry 的条目自动恢复——**跨连接**也生效,§5.1 恢复规则)。 * armed=false(disarm/兜底):存量非 delivered 全量 failed(G1 例外③)。 */ onArmedDecision(armed: boolean, cause: 'session_ready' | 'arm_timeout'): void; /** * 终态信号:reconnect_exhausted / 显式 disconnect()。 * `epochBelow`(跨 session 闸专用):只终结 `epoch < epochBelow` 的条目——switch 请求之后 * 入队的留给新 session 投递。 */ onConnTerminal(epochBelow?: number): void; /** * #1677:update_token 已写入 socket 后放行 refresh-hold 条目(GatewayClient 在 * handleTokenRefresh 成功 send 后调用)。放行的条目由 drain 带 resend:true 幂等重投; * 服务端未落库者正常入库、已落库者命中 dedup 回 receipt——两头都收敛 delivered。 */ releaseRefreshHold(): void; /** * #128:换 principal——旧身份的条目连「可重试」资格一并作废。 * * `onConnTerminal()` 只翻 state、条目仍留在 map 里(为的是用户还能 retry), * 换身份时这恰恰是问题:`retry()` 只拒 `delivered`、不看 cause,disarmed 分支的 * `peekFrame()` 也照样能取到帧直发 ⇒ 旧身份的 conversation_id + 正文会发到新身份 * 的连接上。整体出队后两条 retry 通道自然返回 false,宿主无需改代码。 * * 调用方**必须先 `onConnTerminal()` 再调它**:先把 failed{conn_terminal} 发给 * 消费方,UI 的消息状态不丢;出队只是让它不可再发。 * `epochBelow` 同 onConnTerminal:只出队 `epoch < epochBelow` 的条目。 */ dropAll(epochBelow?: number): void; get isArmed(): boolean; /** armed 下 sendMessage 入队(先于任何 send;由 drain 统一按序投)。 */ enqueue(clientMsgId: string, conversationId: string, frame: Record): void; /** * 手动重试(§5.2):同 id、双计数归零(归零后从 0 重计)、resend:true; * eventwait 中重试同语义;retry 后再次进入 eventwait 视为新「首次进入」(时钟重置)。 * disarmed 连接的 retry 由调用方(GatewayClient)走旧语义重发并调 removeEntry。 */ retry(clientMsgId: string): boolean; /** disarmed retry:旧语义重发由调用方做,条目出 outbox(无跟踪,spec §5.1 明文)。 */ removeEntry(clientMsgId: string): Record | null; getState(clientMsgId: string): { state: DeliveryState | 'eventwait'; failedCause?: FailedCause; } | null; /** PR-A「id∈本 tab outbox」判定(duplicate finish 提示过滤等)。 */ has(clientMsgId: string): boolean; /** 只读取帧(disarmed retry 发送成功前不出队,MINOR-2)。 */ peekFrame(clientMsgId: string): Record | null; routeEvent(event: Record): void; private toPending; private toEventwait; private onTransient; private toFailed; private failAllNonDelivered; private emit; private emitState; private clearTimers; private kickDrain; private drainLoop; private dispatchOne; private onAckTimeout; } //# sourceMappingURL=delivery-outbox.d.ts.map