@optima-chat/agentic-sdk 0.34.1 → 0.36.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 +50 -0
- package/dist/client/conversation-api.d.ts +6 -0
- package/dist/client/conversation-api.d.ts.map +1 -1
- package/dist/client/conversation-api.js +29 -0
- package/dist/client/conversation-api.js.map +1 -1
- package/dist/client/delivery-outbox.d.ts +18 -3
- package/dist/client/delivery-outbox.d.ts.map +1 -1
- package/dist/client/delivery-outbox.js +31 -7
- package/dist/client/delivery-outbox.js.map +1 -1
- package/dist/client/event-parser.d.ts +3 -0
- package/dist/client/event-parser.d.ts.map +1 -1
- package/dist/client/event-parser.js +14 -0
- package/dist/client/event-parser.js.map +1 -1
- package/dist/client/gateway-client.d.ts +107 -16
- package/dist/client/gateway-client.d.ts.map +1 -1
- package/dist/client/gateway-client.js +308 -18
- package/dist/client/gateway-client.js.map +1 -1
- package/dist/client/pinned-at.d.ts +7 -0
- package/dist/client/pinned-at.d.ts.map +1 -0
- package/dist/client/pinned-at.js +16 -0
- package/dist/client/pinned-at.js.map +1 -0
- package/dist/hooks/use-conversations.d.ts +3 -0
- package/dist/hooks/use-conversations.d.ts.map +1 -1
- package/dist/hooks/use-conversations.js +12 -0
- package/dist/hooks/use-conversations.js.map +1 -1
- package/dist/provider/chat-provider.d.ts.map +1 -1
- package/dist/provider/chat-provider.js +11 -0
- package/dist/provider/chat-provider.js.map +1 -1
- package/dist/store/chat-store.d.ts +4 -0
- package/dist/store/chat-store.d.ts.map +1 -1
- package/dist/store/chat-store.js +6 -0
- package/dist/store/chat-store.js.map +1 -1
- package/dist/types.d.ts +9 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
|
@@ -29,6 +29,37 @@ export interface GatewayClientOptions {
|
|
|
29
29
|
* SDK). Optional — omit it in headless/test contexts that don't trace.
|
|
30
30
|
*/
|
|
31
31
|
sessionIdProvider?: () => string | null | undefined;
|
|
32
|
+
/**
|
|
33
|
+
* gw#2313 S1 — multi-session attach addressing. When this returns a session
|
|
34
|
+
* id, every socket open appends `?sessionId=<id>` so the gateway ATTACHES to
|
|
35
|
+
* that exact session (gateway three-intent leg, gw#905: attach / `?new=1`
|
|
36
|
+
* create / default reuse). Return null/undefined for the default-reuse leg
|
|
37
|
+
* (unchanged behaviour for existing hosts — this is why it is a separate
|
|
38
|
+
* option from `sessionIdProvider`, which only feeds OTel and must not change
|
|
39
|
+
* reconnect semantics). Non-string returns are treated as absent.
|
|
40
|
+
*
|
|
41
|
+
* Ownership mismatch or a dead session makes the gateway fall through to
|
|
42
|
+
* default resolution — the client cannot attach to someone else's session.
|
|
43
|
+
*
|
|
44
|
+
* Multi-tab recipe (gw#2313 decision, 06-25): store the id in **sessionStorage**
|
|
45
|
+
* (per-tab in the common case; localStorage would make tabs collide on one
|
|
46
|
+
* session), write it on `session_ready`, and return it here so F5/reconnect
|
|
47
|
+
* lands on the same session. Two caveats (#147 r1): "duplicate tab" and
|
|
48
|
+
* same-origin `target=_blank` COPY sessionStorage — a copied tab attaches to
|
|
49
|
+
* its parent's session unless the host detects the copy and calls
|
|
50
|
+
* `startNewSession()`; and the storage key should be namespaced per
|
|
51
|
+
* principal/environment (impersonation or personal↔enterprise switches leave
|
|
52
|
+
* a stale id that the gateway will reject with a warn on every open).
|
|
53
|
+
* `startNewSession()` takes precedence for its one open: an explicit "give
|
|
54
|
+
* me a fresh session" must not be silently overridden by a stored attach id
|
|
55
|
+
* (the caller should clear/overwrite its stored id when the new
|
|
56
|
+
* `session_ready` arrives). Blank/whitespace ids are treated as absent;
|
|
57
|
+
* 3 consecutive attach opens that upgraded but never reached `session_ready`
|
|
58
|
+
* drop the id (also a `?sessionId=` baked into `gatewayUrl`) until the next
|
|
59
|
+
* `session_ready` (escape hatch from owner-proxy pre-route dead loops); the
|
|
60
|
+
* count is keyed by id, so a new id starts from zero.
|
|
61
|
+
*/
|
|
62
|
+
sessionAttachProvider?: () => string | null | undefined;
|
|
32
63
|
reconnectOptions?: {
|
|
33
64
|
maxRetries?: number;
|
|
34
65
|
baseDelay?: number;
|
|
@@ -47,11 +78,6 @@ export interface GatewayClientOptions {
|
|
|
47
78
|
*/
|
|
48
79
|
onMessageDispatched?: (clientMsgId: string, conversationId: string) => void;
|
|
49
80
|
}
|
|
50
|
-
/**
|
|
51
|
-
* WebSocket client for the Optima gateway. Handles connect/reconnect,
|
|
52
|
-
* heartbeat, token-refresh signalling, and event dispatch. Does not own any
|
|
53
|
-
* state — everything flows out via the callbacks.
|
|
54
|
-
*/
|
|
55
81
|
export declare class GatewayClient {
|
|
56
82
|
private readonly options;
|
|
57
83
|
private ws;
|
|
@@ -74,14 +100,53 @@ export declare class GatewayClient {
|
|
|
74
100
|
*/
|
|
75
101
|
private connecting;
|
|
76
102
|
/**
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
*
|
|
81
|
-
*
|
|
82
|
-
*
|
|
103
|
+
* When set, socket opens append `new=1` so the gateway takes the
|
|
104
|
+
* `createSession` path (COO toggle ENTER/EXIT #467/#1076; gw#2313 "give me a
|
|
105
|
+
* fresh session"). Set via startNewSession().
|
|
106
|
+
* Consumed on the next **`session_ready`**, NOT when the URL is built and NOT
|
|
107
|
+
* on `onopen` (#147 r2 finding 1: `onopen` is only the HTTP 101 upgrade; the
|
|
108
|
+
* gateway rejects — `CONCURRENCY_LIMIT_EXCEEDED`, `AUTH_TOKEN_INVALID` — over
|
|
109
|
+
* the already-open socket). So it **deliberately survives failed opens and
|
|
110
|
+
* flows into auto-reconnect**: that leak IS the fix — do not "tidy" it back
|
|
111
|
+
* to consume-on-build, that re-introduces the silent fall-back to the session
|
|
112
|
+
* the caller just asked to leave. Bounded by FORCE_NEW_FAIL_ESCAPE_N (#147 r3
|
|
113
|
+
* finding 2): after N opens that upgraded but never reached `session_ready`
|
|
114
|
+
* the intent is dropped and the next open resolves by attach/default.
|
|
83
115
|
*/
|
|
84
116
|
private forceNewNextConnect;
|
|
117
|
+
/** r1(#147 🟠4):上一次 session_ready 报的 sessionId——跨 session 闸的判据。 */
|
|
118
|
+
private lastReadySessionId;
|
|
119
|
+
/**
|
|
120
|
+
* r1(#147 🟡):带 attach 的 open 连续失败计数——owner-proxy 预路由拒连时(1013 循环),
|
|
121
|
+
* 每次重连都带同一个 sessionId 会撞同一判定直到重试耗尽。连续 ≥3 次失败后,下一次
|
|
122
|
+
* open 丢掉 attach id 按默认解析(gateway 本地可服务),成功后清零恢复 attach。
|
|
123
|
+
* r3 🟡:按 id 归键——宿主换了**新的有效 id** 时计数归零,不会因为旧 id 的失败被丢一轮。
|
|
124
|
+
* attach id 来源两种:provider 供的,或 README 临时通路烤在 `gatewayUrl` 里的 `?sessionId=`
|
|
125
|
+
* (r3 finding 3:后者也必须计 streak + 可删,否则 owner-proxy 1013 循环对它无界)。
|
|
126
|
+
*/
|
|
127
|
+
private attachFailStreak;
|
|
128
|
+
/** 本次 open 是否带了 attach id(失败时归因给 streak 用)。 */
|
|
129
|
+
private lastOpenHadAttach;
|
|
130
|
+
/** #147 r3 finding 2:带 new=1 的 open 连续「已 open 但没 ready」计数(见 FORCE_NEW_FAIL_ESCAPE_N)。 */
|
|
131
|
+
private forceNewFailStreak;
|
|
132
|
+
/**
|
|
133
|
+
* #147 r3 finding 4:startNewSession() 那一刻的 outbox switch 纪元——new 意图活过失败 open
|
|
134
|
+
* 期间入队的帧都属于新 session,跨 session 闸只丢这个纪元之前的。随意图一起在 session_ready 清空。
|
|
135
|
+
*/
|
|
136
|
+
private forceNewEpoch;
|
|
137
|
+
/** 同上,attach 腿:宿主要求 attach 到与当前不同的 id 时的纪元(同 id 重试沿用)。 */
|
|
138
|
+
private attachSwitch;
|
|
139
|
+
/**
|
|
140
|
+
* 🔴 #147 r2 finding 1/2/3 共用的一块状态:**本次 open 的寻址意图**。
|
|
141
|
+
* 消费点必须是 `session_ready` 而不是 `onopen` —— `onopen` 只是 HTTP 101 升级,
|
|
142
|
+
* gateway 的 `/ws` 是 `@fastify/websocket`,`handleWebSocket` 在 101 **之后**才跑,
|
|
143
|
+
* `CONCURRENCY_LIMIT_EXCEEDED` / `AUTH_TOKEN_INVALID` 都是通过**已打开的 socket**
|
|
144
|
+
* 发来的。在 onopen 消费 = 点「新建」→ 101 → 意图蒸发 → 上限错误关闭 → 重连带
|
|
145
|
+
* `sessionId=ses_old` → 静默回到刚要离开的那个 session。
|
|
146
|
+
*/
|
|
147
|
+
private pendingOpenIntent;
|
|
148
|
+
/** 当前 socket 的「见过 session_ready」回写钩子(随连接作用域创建/清空)。 */
|
|
149
|
+
private readyFlagForCurrentSocket;
|
|
85
150
|
/**
|
|
86
151
|
* `sub` of the token the CURRENT socket was opened with — the principal this
|
|
87
152
|
* connection is bound to, since the gateway resolves the session from the
|
|
@@ -169,11 +234,37 @@ export declare class GatewayClient {
|
|
|
169
234
|
reconnect(opts?: TokenProviderOpts): Promise<void>;
|
|
170
235
|
/**
|
|
171
236
|
* Force a brand-new gateway session: appends `new=1` to the next connect URL
|
|
172
|
-
* (→ gateway `createSession` instead of resolve/reuse) and reconnects.
|
|
173
|
-
* the
|
|
174
|
-
*
|
|
175
|
-
*
|
|
176
|
-
*
|
|
237
|
+
* (→ gateway `createSession` instead of resolve/reuse) and reconnects.
|
|
238
|
+
* ⚠️ This MOVES the client to a fresh session — it does not open an
|
|
239
|
+
* additional one (one GatewayClient holds exactly one socket/session; a
|
|
240
|
+
* multi-session host runs one client per tab/instance). The abandoned
|
|
241
|
+
* session is NOT released server-side: it stays alive (own container,
|
|
242
|
+
* counted by `GET /api/sessions/concurrency`) until idle-suspend/TTL —
|
|
243
|
+
* repeated calls can hit the concurrency limit (gw#2315 default 10).
|
|
244
|
+
* **General-purpose primitive** (gw#2313): any caller that wants a fresh
|
|
245
|
+
* session uses this, and the COO
|
|
246
|
+
* toggle (ENTER/EXIT, #467/#1076) uses it because mode is baked at session
|
|
247
|
+
* creation. Takes precedence over `sessionAttachProvider` for its one open.
|
|
248
|
+
* One-shot, but **deliberately survives failed opens**: the flag is consumed on
|
|
249
|
+
* the next `session_ready`, not on `onopen` (#147 r2 finding 1). So if the first
|
|
250
|
+
* attempt is rejected *after* the 101 upgrade — gw#2315 concurrency limit,
|
|
251
|
+
* `AUTH_TOKEN_INVALID`, any drop between 101 and `session_ready` — auto-reconnect
|
|
252
|
+
* keeps retrying with `new=1` instead of silently attaching back to the session
|
|
253
|
+
* the caller just asked to leave. That "leak into auto-reconnect" **is** the fix;
|
|
254
|
+
* do not undo it. Bounded (#147 r3 finding 2): after FORCE_NEW_FAIL_ESCAPE_N
|
|
255
|
+
* opens that upgraded but never reached `session_ready`, the intent is dropped
|
|
256
|
+
* and the next open resolves by attach/default — otherwise a sustained
|
|
257
|
+
* `CONCURRENCY_LIMIT_EXCEEDED` would retry forever on the client side (every
|
|
258
|
+
* 101 resets `reconnectAttempt`, so `maxRetries` never fires).
|
|
259
|
+
* What the cap bounds is **client retries, not server cost**: a rejected open
|
|
260
|
+
* mints **zero** server-side sessions — gateway's `SessionManager.createSession`
|
|
261
|
+
* calls `concurrencyGate.acquire()` first and throws `ConcurrencyLimitError`
|
|
262
|
+
* before `createSessionInner()` ever runs, so orphans stay at 0.
|
|
263
|
+
* B1-freeness (no orphan always-on container) is a gateway invariant, not this
|
|
264
|
+
* call's responsibility.
|
|
265
|
+
* Resolves on `onopen` (before `session_ready`). Frames the host enqueues after
|
|
266
|
+
* this call are for the NEW session: the cross-session outbox gate only drops
|
|
267
|
+
* entries enqueued before it (#147 r3 finding 4).
|
|
177
268
|
*/
|
|
178
269
|
startNewSession(opts?: TokenProviderOpts): Promise<void>;
|
|
179
270
|
/**
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"gateway-client.d.ts","sourceRoot":"","sources":["../../src/client/gateway-client.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EACV,WAAW,EACX,WAAW,EACZ,MAAM,+BAA+B,CAAC;AACvC,OAAO,KAAK,EACV,SAAS,EACT,eAAe,EACf,iBAAiB,EAClB,MAAM,aAAa,CAAC;AAUrB,OAAO,EAAkB,KAAK,aAAa,EAAE,KAAK,WAAW,EAAE,MAAM,sBAAsB,CAAC;AAG5F,MAAM,WAAW,oBAAoB;IACnC,UAAU,EAAE,MAAM,CAAC;IACnB,aAAa,EAAE,CAAC,IAAI,CAAC,EAAE,iBAAiB,KAAK,OAAO,CAAC,MAAM,CAAC,GAAG,MAAM,CAAC;IACtE,OAAO,EAAE,CAAC,KAAK,EAAE,WAAW,KAAK,IAAI,CAAC;IACtC,uBAAuB,EAAE,CAAC,KAAK,EAAE,eAAe,KAAK,IAAI,CAAC;IAC1D,OAAO,EAAE,CAAC,KAAK,EAAE,SAAS,KAAK,IAAI,CAAC;IACpC,WAAW,EAAE,CAAC,KAAK,EAAE,SAAS,KAAK,IAAI,CAAC;IACxC,YAAY,CAAC,EAAE,CAAC,KAAK,EAAE,WAAW,KAAK,WAAW,GAAG,IAAI,CAAC;IAC1D;;;OAGG;IACH,kBAAkB,CAAC,EAAE,CAAC,OAAO,EAAE,MAAM,KAAK,IAAI,CAAC;IAC/C;;;;;;;OAOG;IACH,cAAc,CAAC,EAAE,MAAM,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;IACjD;;;;OAIG;IACH,iBAAiB,CAAC,EAAE,MAAM,MAAM,GAAG,IAAI,GAAG,SAAS,CAAC;IACpD,gBAAgB,CAAC,EAAE;QACjB,UAAU,CAAC,EAAE,MAAM,CAAC;QACpB,SAAS,CAAC,EAAE,MAAM,CAAC;QACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;KACnB,CAAC;IACF,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B;;;;OAIG;IACH,qBAAqB,CAAC,EAAE,CAAC,WAAW,EAAE,MAAM,EAAE,KAAK,EAAE,aAAa,EAAE,KAAK,CAAC,EAAE,WAAW,KAAK,IAAI,CAAC;IACjG;;;OAGG;IACH,mBAAmB,CAAC,EAAE,CAAC,WAAW,EAAE,MAAM,EAAE,cAAc,EAAE,MAAM,KAAK,IAAI,CAAC;CAC7E;
|
|
1
|
+
{"version":3,"file":"gateway-client.d.ts","sourceRoot":"","sources":["../../src/client/gateway-client.ts"],"names":[],"mappings":"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"}
|
|
@@ -7,6 +7,62 @@ import { readTokenSubject } from './token-subject.js';
|
|
|
7
7
|
* heartbeat, token-refresh signalling, and event dispatch. Does not own any
|
|
8
8
|
* state — everything flows out via the callbacks.
|
|
9
9
|
*/
|
|
10
|
+
/**
|
|
11
|
+
* attach 逃生口阈值:连续 N 次「带 attach 打开了 socket、却始终没等到 `session_ready`」
|
|
12
|
+
* 之后,下一次开连接丢掉 attach 按 gateway 默认解析。
|
|
13
|
+
* 🔴 #147 r2 finding 3:判据是「**开了但没 ready**」,不是「从未 open」——
|
|
14
|
+
* · owner-proxy 关的是**已打开**的 socket(1013),客户端已见过 onopen ⇒ 按「从未 open」
|
|
15
|
+
* 数永远到不了阈值,逃生口对它要救的场景是死代码;
|
|
16
|
+
* · 反过来 TCP/DNS 失败全是「从未 open」,一次 wifi 抖动 ~7-14 秒就能凑够三次 ⇒
|
|
17
|
+
* 第一次成功重连不带 sessionId → gateway 默认解析 → **tab 2 落进 tab 1 的 session**,
|
|
18
|
+
* 正是这个 feature 要防的。
|
|
19
|
+
*/
|
|
20
|
+
const ATTACH_FAIL_ESCAPE_N = 3;
|
|
21
|
+
/**
|
|
22
|
+
* forceNew 逃生口阈值(#147 r3 finding 2,与 attach 对称):new 意图钉到 `session_ready`
|
|
23
|
+
* 才兑现是对的(r2 finding 1),但没有上限就是无界——持续 `CONCURRENCY_LIMIT_EXCEEDED` 下
|
|
24
|
+
* open→101→拒→close→重连带 new=1→gateway 再 createSession→…,而 onopen 每次 101 都把
|
|
25
|
+
* `reconnectAttempt` 清零,maxRetries 永不触发。连续 N 次「带 new=1 打开了 socket、却没等到
|
|
26
|
+
* session_ready」之后,丢掉 new 意图按默认(attach/复用)解析。
|
|
27
|
+
* ⚠️ 这个上限封的是**客户端重试次数**,不是服务端成本 —— `CONCURRENCY_LIMIT_EXCEEDED`
|
|
28
|
+
* 这一路**服务端零 session**:gateway 的 `SessionManager.createSession` 先
|
|
29
|
+
* `concurrencyGate.acquire()`,`!decision.allowed` 当场 throw `ConcurrencyLimitError`,
|
|
30
|
+
* `createSessionInner()` **从未被调用**(optima-gateway
|
|
31
|
+
* `packages/session-manager/src/session-manager.ts` 的 `createSession`)。
|
|
32
|
+
* ⇒ 孤儿 session 恒为 0,不占并发预算,也没有「等 idle-suspend 才释放」这回事。
|
|
33
|
+
* 之所以仍要封口,是因为**无界重连本身**是 bug:onopen 每次 101 都把 `reconnectAttempt`
|
|
34
|
+
* 清零 ⇒ `maxRetries` 永不触发,客户端会永远转下去。
|
|
35
|
+
*/
|
|
36
|
+
const FORCE_NEW_FAIL_ESCAPE_N = 3;
|
|
37
|
+
/**
|
|
38
|
+
* #147 r3 finding 1:`new URL(gatewayUrl)` 对**相对地址**(`'/ws'`)无 base 直接抛,而改前的
|
|
39
|
+
* 字符串拼接交给浏览器 WebSocket 是能解析相对地址的(BC)。解析顺序:
|
|
40
|
+
* 1. 绝对地址 → 原样;
|
|
41
|
+
* 2. 相对地址 + 浏览器有 `location` → 按 `location.href` 解析,http(s) 换成 ws(s)
|
|
42
|
+
* (浏览器 WebSocket 内部就是这么做的,只是提前做掉,兼容不接受 http(s) 的老实现);
|
|
43
|
+
* 3. 相对地址 + 无 `location`(Node/SSR)→ 用占位 base 解析后剥掉占位 origin,保留相对形态
|
|
44
|
+
* 交给 WebSocket 构造器(与改前一致:能不能连由宿主运行时决定)。
|
|
45
|
+
* 抛错只剩「真畸形」(如 `wss://[bad`),由调用方的 catch 按连接失败收口。
|
|
46
|
+
*/
|
|
47
|
+
function resolveGatewayUrl(gatewayUrl) {
|
|
48
|
+
try {
|
|
49
|
+
return { url: new URL(gatewayUrl), relativePrefix: null };
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
/* 相对地址或畸形:往下走 */
|
|
53
|
+
}
|
|
54
|
+
const loc = globalThis.location?.href;
|
|
55
|
+
if (typeof loc === 'string' && loc.length > 0) {
|
|
56
|
+
const url = new URL(gatewayUrl, loc);
|
|
57
|
+
if (url.protocol === 'http:')
|
|
58
|
+
url.protocol = 'ws:';
|
|
59
|
+
else if (url.protocol === 'https:')
|
|
60
|
+
url.protocol = 'wss:';
|
|
61
|
+
return { url, relativePrefix: null };
|
|
62
|
+
}
|
|
63
|
+
const placeholder = 'ws://relative.invalid';
|
|
64
|
+
return { url: new URL(gatewayUrl, placeholder), relativePrefix: placeholder };
|
|
65
|
+
}
|
|
10
66
|
export class GatewayClient {
|
|
11
67
|
options;
|
|
12
68
|
ws = null;
|
|
@@ -29,14 +85,53 @@ export class GatewayClient {
|
|
|
29
85
|
*/
|
|
30
86
|
connecting = false;
|
|
31
87
|
/**
|
|
32
|
-
*
|
|
33
|
-
*
|
|
34
|
-
*
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
88
|
+
* When set, socket opens append `new=1` so the gateway takes the
|
|
89
|
+
* `createSession` path (COO toggle ENTER/EXIT #467/#1076; gw#2313 "give me a
|
|
90
|
+
* fresh session"). Set via startNewSession().
|
|
91
|
+
* Consumed on the next **`session_ready`**, NOT when the URL is built and NOT
|
|
92
|
+
* on `onopen` (#147 r2 finding 1: `onopen` is only the HTTP 101 upgrade; the
|
|
93
|
+
* gateway rejects — `CONCURRENCY_LIMIT_EXCEEDED`, `AUTH_TOKEN_INVALID` — over
|
|
94
|
+
* the already-open socket). So it **deliberately survives failed opens and
|
|
95
|
+
* flows into auto-reconnect**: that leak IS the fix — do not "tidy" it back
|
|
96
|
+
* to consume-on-build, that re-introduces the silent fall-back to the session
|
|
97
|
+
* the caller just asked to leave. Bounded by FORCE_NEW_FAIL_ESCAPE_N (#147 r3
|
|
98
|
+
* finding 2): after N opens that upgraded but never reached `session_ready`
|
|
99
|
+
* the intent is dropped and the next open resolves by attach/default.
|
|
38
100
|
*/
|
|
39
101
|
forceNewNextConnect = false;
|
|
102
|
+
/** r1(#147 🟠4):上一次 session_ready 报的 sessionId——跨 session 闸的判据。 */
|
|
103
|
+
lastReadySessionId = null;
|
|
104
|
+
/**
|
|
105
|
+
* r1(#147 🟡):带 attach 的 open 连续失败计数——owner-proxy 预路由拒连时(1013 循环),
|
|
106
|
+
* 每次重连都带同一个 sessionId 会撞同一判定直到重试耗尽。连续 ≥3 次失败后,下一次
|
|
107
|
+
* open 丢掉 attach id 按默认解析(gateway 本地可服务),成功后清零恢复 attach。
|
|
108
|
+
* r3 🟡:按 id 归键——宿主换了**新的有效 id** 时计数归零,不会因为旧 id 的失败被丢一轮。
|
|
109
|
+
* attach id 来源两种:provider 供的,或 README 临时通路烤在 `gatewayUrl` 里的 `?sessionId=`
|
|
110
|
+
* (r3 finding 3:后者也必须计 streak + 可删,否则 owner-proxy 1013 循环对它无界)。
|
|
111
|
+
*/
|
|
112
|
+
attachFailStreak = { id: null, count: 0 };
|
|
113
|
+
/** 本次 open 是否带了 attach id(失败时归因给 streak 用)。 */
|
|
114
|
+
lastOpenHadAttach = false;
|
|
115
|
+
/** #147 r3 finding 2:带 new=1 的 open 连续「已 open 但没 ready」计数(见 FORCE_NEW_FAIL_ESCAPE_N)。 */
|
|
116
|
+
forceNewFailStreak = 0;
|
|
117
|
+
/**
|
|
118
|
+
* #147 r3 finding 4:startNewSession() 那一刻的 outbox switch 纪元——new 意图活过失败 open
|
|
119
|
+
* 期间入队的帧都属于新 session,跨 session 闸只丢这个纪元之前的。随意图一起在 session_ready 清空。
|
|
120
|
+
*/
|
|
121
|
+
forceNewEpoch = null;
|
|
122
|
+
/** 同上,attach 腿:宿主要求 attach 到与当前不同的 id 时的纪元(同 id 重试沿用)。 */
|
|
123
|
+
attachSwitch = null;
|
|
124
|
+
/**
|
|
125
|
+
* 🔴 #147 r2 finding 1/2/3 共用的一块状态:**本次 open 的寻址意图**。
|
|
126
|
+
* 消费点必须是 `session_ready` 而不是 `onopen` —— `onopen` 只是 HTTP 101 升级,
|
|
127
|
+
* gateway 的 `/ws` 是 `@fastify/websocket`,`handleWebSocket` 在 101 **之后**才跑,
|
|
128
|
+
* `CONCURRENCY_LIMIT_EXCEEDED` / `AUTH_TOKEN_INVALID` 都是通过**已打开的 socket**
|
|
129
|
+
* 发来的。在 onopen 消费 = 点「新建」→ 101 → 意图蒸发 → 上限错误关闭 → 重连带
|
|
130
|
+
* `sessionId=ses_old` → 静默回到刚要离开的那个 session。
|
|
131
|
+
*/
|
|
132
|
+
pendingOpenIntent = null;
|
|
133
|
+
/** 当前 socket 的「见过 session_ready」回写钩子(随连接作用域创建/清空)。 */
|
|
134
|
+
readyFlagForCurrentSocket = null;
|
|
40
135
|
/**
|
|
41
136
|
* `sub` of the token the CURRENT socket was opened with — the principal this
|
|
42
137
|
* connection is bound to, since the gateway resolves the session from the
|
|
@@ -188,6 +283,14 @@ export class GatewayClient {
|
|
|
188
283
|
clearTimeout(this.reconnectTimer);
|
|
189
284
|
this.reconnectTimer = null;
|
|
190
285
|
}
|
|
286
|
+
// #147 r3 🟡:寻址意图/逃生计数不活过显式 disconnect——登出再登入不该带着旧的
|
|
287
|
+
// 「新建」意图去铸 session,也不该带着上个身份的 attach 失败计数把新 id 丢一轮。
|
|
288
|
+
this.forceNewNextConnect = false;
|
|
289
|
+
this.forceNewEpoch = null;
|
|
290
|
+
this.forceNewFailStreak = 0;
|
|
291
|
+
this.attachFailStreak = { id: null, count: 0 };
|
|
292
|
+
this.attachSwitch = null;
|
|
293
|
+
this.pendingOpenIntent = null;
|
|
191
294
|
if (this.ws) {
|
|
192
295
|
// Reject pending switch_config waiters HERE, not from onclose: a real
|
|
193
296
|
// browser fires onclose asynchronously, by which time this.ws is
|
|
@@ -338,14 +441,42 @@ export class GatewayClient {
|
|
|
338
441
|
}
|
|
339
442
|
/**
|
|
340
443
|
* Force a brand-new gateway session: appends `new=1` to the next connect URL
|
|
341
|
-
* (→ gateway `createSession` instead of resolve/reuse) and reconnects.
|
|
342
|
-
* the
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
*
|
|
444
|
+
* (→ gateway `createSession` instead of resolve/reuse) and reconnects.
|
|
445
|
+
* ⚠️ This MOVES the client to a fresh session — it does not open an
|
|
446
|
+
* additional one (one GatewayClient holds exactly one socket/session; a
|
|
447
|
+
* multi-session host runs one client per tab/instance). The abandoned
|
|
448
|
+
* session is NOT released server-side: it stays alive (own container,
|
|
449
|
+
* counted by `GET /api/sessions/concurrency`) until idle-suspend/TTL —
|
|
450
|
+
* repeated calls can hit the concurrency limit (gw#2315 default 10).
|
|
451
|
+
* **General-purpose primitive** (gw#2313): any caller that wants a fresh
|
|
452
|
+
* session uses this, and the COO
|
|
453
|
+
* toggle (ENTER/EXIT, #467/#1076) uses it because mode is baked at session
|
|
454
|
+
* creation. Takes precedence over `sessionAttachProvider` for its one open.
|
|
455
|
+
* One-shot, but **deliberately survives failed opens**: the flag is consumed on
|
|
456
|
+
* the next `session_ready`, not on `onopen` (#147 r2 finding 1). So if the first
|
|
457
|
+
* attempt is rejected *after* the 101 upgrade — gw#2315 concurrency limit,
|
|
458
|
+
* `AUTH_TOKEN_INVALID`, any drop between 101 and `session_ready` — auto-reconnect
|
|
459
|
+
* keeps retrying with `new=1` instead of silently attaching back to the session
|
|
460
|
+
* the caller just asked to leave. That "leak into auto-reconnect" **is** the fix;
|
|
461
|
+
* do not undo it. Bounded (#147 r3 finding 2): after FORCE_NEW_FAIL_ESCAPE_N
|
|
462
|
+
* opens that upgraded but never reached `session_ready`, the intent is dropped
|
|
463
|
+
* and the next open resolves by attach/default — otherwise a sustained
|
|
464
|
+
* `CONCURRENCY_LIMIT_EXCEEDED` would retry forever on the client side (every
|
|
465
|
+
* 101 resets `reconnectAttempt`, so `maxRetries` never fires).
|
|
466
|
+
* What the cap bounds is **client retries, not server cost**: a rejected open
|
|
467
|
+
* mints **zero** server-side sessions — gateway's `SessionManager.createSession`
|
|
468
|
+
* calls `concurrencyGate.acquire()` first and throws `ConcurrencyLimitError`
|
|
469
|
+
* before `createSessionInner()` ever runs, so orphans stay at 0.
|
|
470
|
+
* B1-freeness (no orphan always-on container) is a gateway invariant, not this
|
|
471
|
+
* call's responsibility.
|
|
472
|
+
* Resolves on `onopen` (before `session_ready`). Frames the host enqueues after
|
|
473
|
+
* this call are for the NEW session: the cross-session outbox gate only drops
|
|
474
|
+
* entries enqueued before it (#147 r3 finding 4).
|
|
346
475
|
*/
|
|
347
476
|
async startNewSession(opts) {
|
|
348
477
|
this.forceNewNextConnect = true;
|
|
478
|
+
this.forceNewFailStreak = 0;
|
|
479
|
+
this.forceNewEpoch = this.delivery.bumpEpoch();
|
|
349
480
|
await this.reconnect(opts);
|
|
350
481
|
}
|
|
351
482
|
/**
|
|
@@ -626,14 +757,112 @@ export class GatewayClient {
|
|
|
626
757
|
// is what that state wants anyway) and a "same" verdict sends onto a closed
|
|
627
758
|
// socket, where send() returns false as a noop.
|
|
628
759
|
this.connectedSub = readTokenSubject(token);
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
//
|
|
632
|
-
//
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
760
|
+
// r1(#147 🔴3):URL 一律经 `new URL()` + searchParams 构造——手搓字符串拼接在
|
|
761
|
+
// gatewayUrl 自带 query(临时通路 `?sessionId=`)时会发出重复参数,而 gateway 的
|
|
762
|
+
// `searchParams.get()` 取第一个 ⇒ `startNewSession()` 发出 `?sessionId=X&new=1`
|
|
763
|
+
// 时 attach 反而压过 new,本 PR 的核心不变量在 README 自己推荐的路径上失效。
|
|
764
|
+
// set/delete 语义天然去重,顺带修掉 gatewayUrl 带 fragment 时 query 落进 fragment。
|
|
765
|
+
// r1(#147 🟠5)+ r2:try/catch 必须罩住**整段** —— r1 只罩了 provider 调用本身,
|
|
766
|
+
// 而 `attachId?.trim()`(provider 返回非字符串时 TypeError)和
|
|
767
|
+
// `new URL(this.options.gatewayUrl)`(宿主传了畸形 url)都在守卫之外。抛在这里会落在
|
|
768
|
+
// `connecting=false` 之后、disown 置空 `this.ws` 之前 ⇒ **永久卡在 reconnecting**,
|
|
769
|
+
// 与 r1 finding 5 的形态一字不差。
|
|
770
|
+
let url;
|
|
771
|
+
let intent;
|
|
772
|
+
try {
|
|
773
|
+
// r3 finding 1:相对 gatewayUrl('/ws')必须给 base,否则 new URL() 直接抛(改前的
|
|
774
|
+
// 字符串拼接是能连的 —— BC)。见 resolveGatewayUrl。
|
|
775
|
+
const { url: wsUrl, relativePrefix } = resolveGatewayUrl(this.options.gatewayUrl);
|
|
776
|
+
wsUrl.hash = '';
|
|
777
|
+
wsUrl.searchParams.set('token', token);
|
|
778
|
+
// 🔴 r2:`lastOpenHadAttach` 无条件先置 false —— r1 只在 else 分支重置,
|
|
779
|
+
// forceNew 那次会**继承上次的值**,把自己的失败记进 attach streak。
|
|
780
|
+
this.lastOpenHadAttach = false;
|
|
781
|
+
let attachForThisOpen = null;
|
|
782
|
+
let switchEpoch = null;
|
|
783
|
+
// r3 finding 2:forceNew 逃生口 —— 连续 N 次带 new=1 已 open 却没 ready,丢掉意图,
|
|
784
|
+
// 本次按 attach/默认解析(否则持续上限拒绝下客户端无限重连——服务端零 session)。
|
|
785
|
+
if (this.forceNewNextConnect && this.forceNewFailStreak >= FORCE_NEW_FAIL_ESCAPE_N) {
|
|
786
|
+
this.forceNewNextConnect = false;
|
|
787
|
+
this.forceNewEpoch = null;
|
|
788
|
+
this.forceNewFailStreak = 0;
|
|
789
|
+
}
|
|
790
|
+
if (this.forceNewNextConnect) {
|
|
791
|
+
wsUrl.searchParams.delete('sessionId');
|
|
792
|
+
wsUrl.searchParams.set('new', '1');
|
|
793
|
+
switchEpoch = this.forceNewEpoch;
|
|
794
|
+
}
|
|
795
|
+
else {
|
|
796
|
+
wsUrl.searchParams.delete('new'); // 防 gatewayUrl 自带残留的 new=1 漏进重连
|
|
797
|
+
// gw#2313 S1 — attach addressing: pin this open to an exact session.
|
|
798
|
+
let attachId;
|
|
799
|
+
try {
|
|
800
|
+
attachId = this.options.sessionAttachProvider?.();
|
|
801
|
+
}
|
|
802
|
+
catch {
|
|
803
|
+
attachId = null;
|
|
804
|
+
}
|
|
805
|
+
// 空串/空白当「没配」(getItem 残留空值常见);r3 finding 1:非字符串(JSON.parse 出的
|
|
806
|
+
// 数字/对象)同样归一为「没配」,README「按无 attach 处理」的承诺才对所有形态成立。
|
|
807
|
+
const trimmed = typeof attachId === 'string' ? attachId.trim() : '';
|
|
808
|
+
// r3 finding 3:attach id 的候选 = provider 供的,否则 gatewayUrl 自带的(临时通路)。
|
|
809
|
+
// 两种来源同一套 streak/逃生口 —— 否则纯 baked-URL 宿主在 owner-proxy 1013 循环里
|
|
810
|
+
// streak 永远不涨、id 永远不删,而每次 101 又把 maxRetries 清零 ⇒ 无界。
|
|
811
|
+
const candidate = trimmed || wsUrl.searchParams.get('sessionId')?.trim() || null;
|
|
812
|
+
// r3 🟡:streak 按 id 归键——换了新的有效 id 从零计。
|
|
813
|
+
if (candidate !== this.attachFailStreak.id) {
|
|
814
|
+
this.attachFailStreak = { id: candidate, count: 0 };
|
|
815
|
+
}
|
|
816
|
+
if (candidate && this.attachFailStreak.count < ATTACH_FAIL_ESCAPE_N) {
|
|
817
|
+
wsUrl.searchParams.set('sessionId', candidate);
|
|
818
|
+
this.lastOpenHadAttach = true;
|
|
819
|
+
attachForThisOpen = candidate;
|
|
820
|
+
// 宿主要求 attach 到与当前不同的 session = 一次 switch 请求(跨 session 闸的纪元锚点)。
|
|
821
|
+
// 同一个 id 的重试沿用同一纪元:重试期间入队的帧仍算「请求之后」。
|
|
822
|
+
if (candidate !== this.lastReadySessionId) {
|
|
823
|
+
if (this.attachSwitch?.id !== candidate) {
|
|
824
|
+
this.attachSwitch = { id: candidate, epoch: this.delivery.bumpEpoch() };
|
|
825
|
+
}
|
|
826
|
+
switchEpoch = this.attachSwitch.epoch;
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
else {
|
|
830
|
+
// 逃生口生效(或根本没 id):本次按 gateway 默认解析。
|
|
831
|
+
// 🔴 r2:**必须一并删掉 gatewayUrl 自带的 sessionId** —— README 的临时通路把
|
|
832
|
+
// attach id 烤在 url 里,不删的话逃生口形同虚设(照样进 owner-proxy 预路由)。
|
|
833
|
+
wsUrl.searchParams.delete('sessionId');
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
intent = { forceNew: this.forceNewNextConnect, attachId: attachForThisOpen, switchEpoch };
|
|
837
|
+
url = relativePrefix !== null
|
|
838
|
+
? wsUrl.toString().slice(relativePrefix.length)
|
|
839
|
+
: wsUrl.toString();
|
|
636
840
|
}
|
|
841
|
+
catch (err) {
|
|
842
|
+
// 构不出 URL = 这次连接根本发不出去。按「连接失败」收口,绝不让异常穿过
|
|
843
|
+
// connecting=false 之后的窗口。
|
|
844
|
+
// 🔴 r3 finding 1:这条路径从没构造出 socket、永远到不了 onclose,maxRetries 检查
|
|
845
|
+
// 必须在这里自己做 —— r2 的版本无条件 scheduleReconnect + rethrow:reconnectAttempt
|
|
846
|
+
// 无界增长、永不 reconnect_exhausted、outbox 永无终态,定时器里的 `void openSocket()`
|
|
847
|
+
// 每 tick 一个 unhandled rejection(Node/SSR 宿主直接挂)。不再 rethrow:与 onclose
|
|
848
|
+
// 路径一致,调用方通过 onConnectionStateChange / onError 观察。
|
|
849
|
+
this.connecting = false;
|
|
850
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
851
|
+
if (this.reconnectAttempt >= this.reconnectConfig.maxRetries) {
|
|
852
|
+
this.options.onConnectionStateChange('disconnected');
|
|
853
|
+
this.delivery.onConnTerminal();
|
|
854
|
+
this.options.onError({
|
|
855
|
+
code: 'reconnect_exhausted',
|
|
856
|
+
message: `WebSocket reconnect retries exhausted (connect URL invalid: ${message})`,
|
|
857
|
+
retryable: false,
|
|
858
|
+
});
|
|
859
|
+
return;
|
|
860
|
+
}
|
|
861
|
+
this.options.onConnectionStateChange('reconnecting');
|
|
862
|
+
this.scheduleReconnect();
|
|
863
|
+
return;
|
|
864
|
+
}
|
|
865
|
+
this.pendingOpenIntent = intent;
|
|
637
866
|
// #99: disown any previous socket BEFORE creating its replacement. Multiple
|
|
638
867
|
// reconnect entrances (onclose backoff timer, token refresh, heartbeat
|
|
639
868
|
// recovery) can race into openSocket; without this the superseded socket
|
|
@@ -676,11 +905,21 @@ export class GatewayClient {
|
|
|
676
905
|
// under the disown above — any path that replaces this.ws without
|
|
677
906
|
// detaching (future refactors included) still can't double-feed the
|
|
678
907
|
// store or trigger ghost reconnects from a superseded socket.
|
|
908
|
+
// 声明在赋值它的闭包**之前**(r2 小项:r1 写在 onopen 之后,靠 hoisting 才不炸)。
|
|
909
|
+
let everOpened = false;
|
|
910
|
+
let sawReady = false;
|
|
911
|
+
this.readyFlagForCurrentSocket = () => { sawReady = true; };
|
|
679
912
|
ws.onopen = () => {
|
|
680
913
|
if (this.ws !== ws) {
|
|
681
914
|
resolve();
|
|
682
915
|
return;
|
|
683
916
|
}
|
|
917
|
+
everOpened = true;
|
|
918
|
+
// 🔴 #147 r2 finding 1:**这里不消费任何寻址意图**。onopen 只是 HTTP 101 升级,
|
|
919
|
+
// gateway 的 handleWebSocket 在 101 之后才跑,CONCURRENCY_LIMIT_EXCEEDED /
|
|
920
|
+
// AUTH_TOKEN_INVALID 都是通过已打开的 socket 发来的。在这里消费 =
|
|
921
|
+
// 点「新建」→ 101 → 意图蒸发 → 上限错误关闭 → 重连带 sessionId=ses_old →
|
|
922
|
+
// 静默回到刚要离开的 session。消费点是 `session_ready`(见 handleEvent)。
|
|
684
923
|
if (this.reconnectAttempt !== 0) {
|
|
685
924
|
this.reconnectAttempt = 0;
|
|
686
925
|
this.options.onReconnectAttempt?.(0);
|
|
@@ -709,6 +948,19 @@ export class GatewayClient {
|
|
|
709
948
|
return;
|
|
710
949
|
}
|
|
711
950
|
this.stopHeartbeat();
|
|
951
|
+
// 🔴 #147 r2 finding 3:计数判据 = 「**开了但从没等到 session_ready**」+ 带 attach。
|
|
952
|
+
// 这正好覆盖 owner-proxy 1013 与 101↔session_ready 之间的各类拒绝,
|
|
953
|
+
// 又把 TCP/DNS 失败(从未 open)排除在外——后者是网络问题,不是寻址问题,
|
|
954
|
+
// 拿它去触发逃生口会把 tab 2 甩进 tab 1 的 session。
|
|
955
|
+
if (everOpened && !sawReady) {
|
|
956
|
+
if (this.lastOpenHadAttach)
|
|
957
|
+
this.attachFailStreak.count++;
|
|
958
|
+
// r3 finding 2:forceNew 同判据计数(101 后被拒——服务端零 session,见 FORCE_NEW_FAIL_ESCAPE_N 注释)。
|
|
959
|
+
if (intent.forceNew)
|
|
960
|
+
this.forceNewFailStreak++;
|
|
961
|
+
}
|
|
962
|
+
this.pendingOpenIntent = null; // 本次 open 的意图随连接作用域作废(未兑现)
|
|
963
|
+
this.readyFlagForCurrentSocket = null;
|
|
712
964
|
// gw#1587:断连边沿——in-flight ack 等待作废回 pending(重连 armed 后全量 drain 重投);
|
|
713
965
|
// stale 10s 计时器随连接作用域取消(防跨连接误兜底)。
|
|
714
966
|
this.cancelArmTimer();
|
|
@@ -765,6 +1017,44 @@ export class GatewayClient {
|
|
|
765
1017
|
// 未 retry 的兜底 failed 条目);不带信号 → 维持 disarmed(幂等)。
|
|
766
1018
|
if (event.type === 'session_ready') {
|
|
767
1019
|
this.cancelArmTimer();
|
|
1020
|
+
// r1(#147 🟠4):跨 session 闸——本连接解析到了**不同的 session**(startNewSession /
|
|
1021
|
+
// attach id 改写 / 旧 session 被回收后 attach 落空)时,旧 session 的待发帧不得
|
|
1022
|
+
// 灌进新容器(帧里带着旧 conversation_id)。对齐 foreign-principal 闸的处置顺序:
|
|
1023
|
+
// 先 conn_terminal(宿主拿到每条的终态 failed)再 dropAll——且必须在下方
|
|
1024
|
+
// onArmedDecision 之前,armed 恢复腿才不会把旧帧复活重投。同 session 重连不受影响。
|
|
1025
|
+
const readySessionId = event.session?.sessionId;
|
|
1026
|
+
const intent = this.pendingOpenIntent;
|
|
1027
|
+
// 🔴 #147 r2 finding 1:**寻址意图在这里才算兑现**。走到 session_ready 说明
|
|
1028
|
+
// gateway 真的按本次 URL 建/接上了会话;在 onopen(101)消费会让上限拒绝、
|
|
1029
|
+
// 鉴权失败这类「已 open 再拒」把用户的「新建」意图静默吃掉。
|
|
1030
|
+
this.readyFlagForCurrentSocket?.();
|
|
1031
|
+
this.forceNewNextConnect = false;
|
|
1032
|
+
this.forceNewEpoch = null;
|
|
1033
|
+
this.forceNewFailStreak = 0;
|
|
1034
|
+
this.attachFailStreak.count = 0;
|
|
1035
|
+
this.attachSwitch = null;
|
|
1036
|
+
this.pendingOpenIntent = null;
|
|
1037
|
+
// 🔴 #147 r2 finding 2:跨 session 闸必须**同时**满足「换了 session」和
|
|
1038
|
+
// 「客户端确实要求换」。r1 只看前者 ⇒ 没配 attach 的宿主(今天的 agentic-chat
|
|
1039
|
+
// 生产)断网期间 session 被回收、重连拿到新 id,两条 retry 通道被 dropAll 一起杀掉,
|
|
1040
|
+
// 而改前它们会在下次 armed drain 重投。抄的 principal-switch 先例不能平移:
|
|
1041
|
+
// conversation 是 **user 域**的(connection-handler 的 getConversationsForUser),
|
|
1042
|
+
// `conv-A` 在新 session 里仍可恢复 —— 这些帧是可投递的,不是毒。
|
|
1043
|
+
const hostAskedToSwitch = intent?.forceNew === true
|
|
1044
|
+
|| (intent?.attachId != null && intent.attachId !== this.lastReadySessionId);
|
|
1045
|
+
if (hostAskedToSwitch
|
|
1046
|
+
&& this.lastReadySessionId && readySessionId
|
|
1047
|
+
&& readySessionId !== this.lastReadySessionId) {
|
|
1048
|
+
// r3 finding 4:只丢 switch 请求**之前**入队的(epoch < switchEpoch)。startNewSession()
|
|
1049
|
+
// 在 onopen 就 resolve,宿主随即重新启用输入框——此后到 session_ready 之间用户发的帧
|
|
1050
|
+
// 是为新 session 写的,可投递,不是毒。switchEpoch 为 null(理论上不会:hostAskedToSwitch
|
|
1051
|
+
// 为真时两条腿都设了)则退回全丢。
|
|
1052
|
+
const epochBelow = intent?.switchEpoch ?? undefined;
|
|
1053
|
+
this.delivery.onConnTerminal(epochBelow);
|
|
1054
|
+
this.delivery.dropAll(epochBelow);
|
|
1055
|
+
}
|
|
1056
|
+
if (readySessionId)
|
|
1057
|
+
this.lastReadySessionId = readySessionId;
|
|
768
1058
|
const supports = event.session?.supportsMessageReceipt === true;
|
|
769
1059
|
this.deliveryArmState = supports ? 'armed' : 'disarmed';
|
|
770
1060
|
this.delivery.onArmedDecision(supports, 'session_ready');
|