@qwen-code/channel-base 0.19.9 → 0.19.10
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/dist/AcpBridge.js +18 -3
- package/dist/AcpBridge.js.map +1 -1
- package/dist/ChannelAgentBridge.d.ts +4 -2
- package/dist/ChannelBase.d.ts +3 -2
- package/dist/ChannelBase.js +14 -5
- package/dist/ChannelBase.js.map +1 -1
- package/dist/DaemonChannelBridge.d.ts +9 -2
- package/dist/DaemonChannelBridge.js +95 -13
- package/dist/DaemonChannelBridge.js.map +1 -1
- package/dist/SessionRouter.d.ts +27 -1
- package/dist/SessionRouter.js +410 -72
- package/dist/SessionRouter.js.map +1 -1
- package/package.json +1 -1
package/dist/SessionRouter.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import { existsSync, readFileSync,
|
|
1
|
+
import { chmodSync, existsSync, mkdirSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync, } from 'node:fs';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
2
3
|
import process from 'node:process';
|
|
3
4
|
import { sanitizeLogText } from './sanitize.js';
|
|
4
5
|
export class SessionRouter {
|
|
@@ -7,17 +8,22 @@ export class SessionRouter {
|
|
|
7
8
|
toCwd = new Map(); // session ID → cwd
|
|
8
9
|
creatingSessions = new Map();
|
|
9
10
|
sessionLoadWindows = new Set();
|
|
11
|
+
liveSessionIds = new Set();
|
|
12
|
+
routeTokens = new Map();
|
|
13
|
+
lifecycleGeneration = 0;
|
|
10
14
|
bridge;
|
|
11
15
|
defaultCwd;
|
|
12
16
|
defaultScope;
|
|
13
17
|
channelScopes = new Map();
|
|
14
18
|
channelApprovalModes = new Map();
|
|
15
19
|
persistPath;
|
|
16
|
-
|
|
20
|
+
recoveryMode;
|
|
21
|
+
constructor(bridge, defaultCwd, scope = 'user', persistPath, options = {}) {
|
|
17
22
|
this.bridge = bridge;
|
|
18
23
|
this.defaultCwd = defaultCwd;
|
|
19
24
|
this.defaultScope = scope;
|
|
20
25
|
this.persistPath = persistPath;
|
|
26
|
+
this.recoveryMode = options.recoveryMode ?? 'eager';
|
|
21
27
|
}
|
|
22
28
|
/** Replace the bridge instance (used after crash recovery restart). */
|
|
23
29
|
setBridge(bridge) {
|
|
@@ -53,65 +59,181 @@ export class SessionRouter {
|
|
|
53
59
|
}
|
|
54
60
|
async resolve(channelName, senderId, chatId, threadId, cwd, isGroup, options) {
|
|
55
61
|
const key = this.routingKey(channelName, senderId, chatId, options?.routingThreadId ?? threadId);
|
|
56
|
-
|
|
62
|
+
const input = {
|
|
63
|
+
channelName,
|
|
64
|
+
senderId,
|
|
65
|
+
chatId,
|
|
66
|
+
threadId,
|
|
67
|
+
cwd: cwd || this.defaultCwd,
|
|
68
|
+
isGroup,
|
|
69
|
+
};
|
|
70
|
+
let failedWaits = 0;
|
|
57
71
|
for (;;) {
|
|
58
72
|
const existing = this.toSession.get(key);
|
|
59
|
-
if (existing) {
|
|
73
|
+
if (existing && this.isLive(existing)) {
|
|
60
74
|
this.promoteTargetToGroup(existing, isGroup);
|
|
61
75
|
return existing;
|
|
62
76
|
}
|
|
63
77
|
const creating = this.creatingSessions.get(key);
|
|
64
78
|
if (creating) {
|
|
65
79
|
try {
|
|
66
|
-
const sessionId = await creating;
|
|
80
|
+
const sessionId = await creating.promise;
|
|
81
|
+
try {
|
|
82
|
+
this.assertOperationResultCurrent(key, sessionId, creating);
|
|
83
|
+
}
|
|
84
|
+
catch (error) {
|
|
85
|
+
this.scheduleDiscardInvalidatedSession(sessionId, creating);
|
|
86
|
+
throw error;
|
|
87
|
+
}
|
|
67
88
|
this.promoteTargetToGroup(sessionId, isGroup);
|
|
68
89
|
return sessionId;
|
|
69
90
|
}
|
|
70
|
-
catch (
|
|
91
|
+
catch (error) {
|
|
92
|
+
if (creating.invalidationError) {
|
|
93
|
+
throw creating.invalidationError;
|
|
94
|
+
}
|
|
71
95
|
if (this.creatingSessions.get(key) === creating) {
|
|
72
96
|
this.creatingSessions.delete(key);
|
|
73
97
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
98
|
+
this.releaseRouteToken(key, creating);
|
|
99
|
+
failedWaits++;
|
|
100
|
+
if (failedWaits > 3)
|
|
101
|
+
throw error;
|
|
78
102
|
continue;
|
|
79
103
|
}
|
|
80
104
|
}
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
105
|
+
const operation = this.createSessionOperation(key, {
|
|
106
|
+
channelName: input.channelName,
|
|
107
|
+
senderId: input.senderId,
|
|
108
|
+
chatId: input.chatId,
|
|
109
|
+
threadId: input.threadId,
|
|
110
|
+
isGroup: input.isGroup,
|
|
111
|
+
}, (currentOperation) => existing
|
|
112
|
+
? this.loadOrReplaceSession(key, existing, input, currentOperation)
|
|
113
|
+
: this.createAndStoreSession(key, input, currentOperation));
|
|
114
|
+
this.creatingSessions.set(key, operation);
|
|
115
|
+
try {
|
|
116
|
+
const sessionId = await operation.promise;
|
|
86
117
|
try {
|
|
87
|
-
|
|
88
|
-
this.toSession.set(key, sessionId);
|
|
89
|
-
this.toTarget.set(sessionId, {
|
|
90
|
-
channelName,
|
|
91
|
-
senderId,
|
|
92
|
-
chatId,
|
|
93
|
-
threadId,
|
|
94
|
-
isGroup,
|
|
95
|
-
});
|
|
96
|
-
this.toCwd.set(sessionId, sessionCwd);
|
|
97
|
-
this.persist();
|
|
98
|
-
return sessionId;
|
|
118
|
+
this.assertOperationResultCurrent(key, sessionId, operation);
|
|
99
119
|
}
|
|
100
|
-
|
|
101
|
-
this.
|
|
120
|
+
catch (error) {
|
|
121
|
+
this.scheduleDiscardInvalidatedSession(sessionId, operation);
|
|
122
|
+
throw error;
|
|
102
123
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
try {
|
|
106
|
-
return await created;
|
|
124
|
+
this.promoteTargetToGroup(sessionId, isGroup);
|
|
125
|
+
return sessionId;
|
|
107
126
|
}
|
|
108
127
|
finally {
|
|
109
|
-
if (this.creatingSessions.get(key) ===
|
|
128
|
+
if (this.creatingSessions.get(key) === operation) {
|
|
110
129
|
this.creatingSessions.delete(key);
|
|
111
130
|
}
|
|
131
|
+
this.releaseRouteToken(key, operation);
|
|
112
132
|
}
|
|
113
133
|
}
|
|
114
134
|
}
|
|
135
|
+
isLive(sessionId) {
|
|
136
|
+
return this.recoveryMode === 'eager' || this.liveSessionIds.has(sessionId);
|
|
137
|
+
}
|
|
138
|
+
async createAndStoreSession(key, input, operation) {
|
|
139
|
+
const loadWindow = this.beginSessionLoad();
|
|
140
|
+
try {
|
|
141
|
+
const sessionId = await this.createLiveSession(input.cwd, loadWindow, key, this.sessionOptions(input.channelName), operation);
|
|
142
|
+
try {
|
|
143
|
+
this.assertOperationCurrent(operation);
|
|
144
|
+
}
|
|
145
|
+
catch (error) {
|
|
146
|
+
this.scheduleDiscardInvalidatedSession(sessionId, operation);
|
|
147
|
+
throw error;
|
|
148
|
+
}
|
|
149
|
+
this.toSession.set(key, sessionId);
|
|
150
|
+
this.toTarget.set(sessionId, {
|
|
151
|
+
channelName: input.channelName,
|
|
152
|
+
senderId: input.senderId,
|
|
153
|
+
chatId: input.chatId,
|
|
154
|
+
threadId: input.threadId,
|
|
155
|
+
isGroup: input.isGroup,
|
|
156
|
+
});
|
|
157
|
+
this.toCwd.set(sessionId, input.cwd);
|
|
158
|
+
this.liveSessionIds.add(sessionId);
|
|
159
|
+
this.persist();
|
|
160
|
+
return sessionId;
|
|
161
|
+
}
|
|
162
|
+
finally {
|
|
163
|
+
this.endSessionLoad(loadWindow);
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
async loadOrReplaceSession(key, savedSessionId, input, operation) {
|
|
167
|
+
const savedCwd = this.toCwd.get(savedSessionId) ?? input.cwd;
|
|
168
|
+
const loadWindow = this.beginSessionLoad();
|
|
169
|
+
try {
|
|
170
|
+
try {
|
|
171
|
+
const loadedSessionId = await this.bridge.loadSession(savedSessionId, savedCwd, this.sessionOptions(input.channelName), operation);
|
|
172
|
+
try {
|
|
173
|
+
this.assertOperationCurrent(operation);
|
|
174
|
+
if (this.toSession.get(key) !== savedSessionId) {
|
|
175
|
+
this.invalidateOperation(operation);
|
|
176
|
+
this.assertOperationCurrent(operation);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
catch (error) {
|
|
180
|
+
this.scheduleDiscardInvalidatedSession(loadedSessionId, operation);
|
|
181
|
+
throw error;
|
|
182
|
+
}
|
|
183
|
+
if (typeof loadedSessionId !== 'string' ||
|
|
184
|
+
loadedSessionId.length === 0 ||
|
|
185
|
+
loadWindow.delete(loadedSessionId)) {
|
|
186
|
+
throw new Error('Invalid or dead restored session ID');
|
|
187
|
+
}
|
|
188
|
+
if (loadedSessionId !== savedSessionId) {
|
|
189
|
+
const target = this.toTarget.get(savedSessionId);
|
|
190
|
+
this.deleteByKey(key);
|
|
191
|
+
this.toSession.set(key, loadedSessionId);
|
|
192
|
+
if (target)
|
|
193
|
+
this.toTarget.set(loadedSessionId, target);
|
|
194
|
+
this.toCwd.set(loadedSessionId, savedCwd);
|
|
195
|
+
this.persist();
|
|
196
|
+
}
|
|
197
|
+
this.liveSessionIds.add(loadedSessionId);
|
|
198
|
+
return loadedSessionId;
|
|
199
|
+
}
|
|
200
|
+
catch (loadError) {
|
|
201
|
+
this.assertOperationCurrent(operation);
|
|
202
|
+
try {
|
|
203
|
+
const replacement = await this.createLiveSession(input.cwd, loadWindow, key, this.sessionOptions(input.channelName), operation);
|
|
204
|
+
try {
|
|
205
|
+
this.assertOperationCurrent(operation);
|
|
206
|
+
}
|
|
207
|
+
catch (error) {
|
|
208
|
+
this.scheduleDiscardInvalidatedSession(replacement, operation);
|
|
209
|
+
throw error;
|
|
210
|
+
}
|
|
211
|
+
this.deleteByKey(key);
|
|
212
|
+
this.toSession.set(key, replacement);
|
|
213
|
+
this.toTarget.set(replacement, {
|
|
214
|
+
channelName: input.channelName,
|
|
215
|
+
senderId: input.senderId,
|
|
216
|
+
chatId: input.chatId,
|
|
217
|
+
threadId: input.threadId,
|
|
218
|
+
isGroup: input.isGroup,
|
|
219
|
+
});
|
|
220
|
+
this.toCwd.set(replacement, input.cwd);
|
|
221
|
+
this.liveSessionIds.add(replacement);
|
|
222
|
+
this.persist();
|
|
223
|
+
process.stderr.write(`[SessionRouter] Replaced unavailable session ${sanitizeLogText(savedSessionId, 128)} for key ${sanitizeLogText(key, 256)} after load failed: ${sanitizeLogText(loadError instanceof Error ? loadError.message : String(loadError), 512)}\n`);
|
|
224
|
+
return replacement;
|
|
225
|
+
}
|
|
226
|
+
catch (createError) {
|
|
227
|
+
this.assertOperationCurrent(operation);
|
|
228
|
+
process.stderr.write(`[SessionRouter] Failed to load session ${sanitizeLogText(savedSessionId, 128)} for key ${sanitizeLogText(key, 256)} (${sanitizeLogText(loadError instanceof Error ? loadError.message : String(loadError), 512)}) and failed to create a replacement (${sanitizeLogText(createError instanceof Error ? createError.message : String(createError), 512)})\n`);
|
|
229
|
+
throw createError;
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
finally {
|
|
234
|
+
this.endSessionLoad(loadWindow);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
115
237
|
getTarget(sessionId) {
|
|
116
238
|
return this.toTarget.get(sessionId);
|
|
117
239
|
}
|
|
@@ -145,6 +267,7 @@ export class SessionRouter {
|
|
|
145
267
|
const scope = this.channelScopes.get(channelName) || this.defaultScope;
|
|
146
268
|
if (chatId) {
|
|
147
269
|
const key = this.routingKey(channelName, senderId, chatId, threadId);
|
|
270
|
+
this.invalidateRouteOperation(key);
|
|
148
271
|
const sessionId = this.deleteByKey(key);
|
|
149
272
|
if (sessionId)
|
|
150
273
|
removedIds.push(sessionId);
|
|
@@ -158,11 +281,18 @@ export class SessionRouter {
|
|
|
158
281
|
const target = this.toTarget.get(mappedSessionId);
|
|
159
282
|
if (target?.channelName === channelName &&
|
|
160
283
|
target.senderId === senderId) {
|
|
284
|
+
this.invalidateRouteOperation(k);
|
|
161
285
|
const sessionId = this.deleteByKey(k);
|
|
162
286
|
if (sessionId)
|
|
163
287
|
removedIds.push(sessionId);
|
|
164
288
|
}
|
|
165
289
|
}
|
|
290
|
+
for (const [key, operation] of [...this.creatingSessions]) {
|
|
291
|
+
if (operation.target.channelName === channelName &&
|
|
292
|
+
operation.target.senderId === senderId) {
|
|
293
|
+
this.invalidateRouteOperation(key);
|
|
294
|
+
}
|
|
295
|
+
}
|
|
166
296
|
}
|
|
167
297
|
if (removedIds.length > 0)
|
|
168
298
|
this.persist();
|
|
@@ -173,6 +303,7 @@ export class SessionRouter {
|
|
|
173
303
|
let removed = false;
|
|
174
304
|
for (const [key, mappedSessionId] of [...this.toSession.entries()]) {
|
|
175
305
|
if (mappedSessionId === sessionId) {
|
|
306
|
+
this.invalidateRouteOperation(key);
|
|
176
307
|
this.toSession.delete(key);
|
|
177
308
|
removed = true;
|
|
178
309
|
}
|
|
@@ -183,6 +314,7 @@ export class SessionRouter {
|
|
|
183
314
|
if (this.toCwd.delete(sessionId)) {
|
|
184
315
|
removed = true;
|
|
185
316
|
}
|
|
317
|
+
this.liveSessionIds.delete(sessionId);
|
|
186
318
|
if (!removed && this.sessionLoadWindows.size > 0) {
|
|
187
319
|
for (const loadWindow of this.sessionLoadWindows) {
|
|
188
320
|
loadWindow.add(sessionId);
|
|
@@ -193,6 +325,17 @@ export class SessionRouter {
|
|
|
193
325
|
}
|
|
194
326
|
return removed;
|
|
195
327
|
}
|
|
328
|
+
handleSessionDied(sessionId) {
|
|
329
|
+
if (this.recoveryMode === 'eager') {
|
|
330
|
+
return this.removeSessionId(sessionId);
|
|
331
|
+
}
|
|
332
|
+
const known = this.toTarget.has(sessionId);
|
|
333
|
+
this.liveSessionIds.delete(sessionId);
|
|
334
|
+
for (const loadWindow of this.sessionLoadWindows) {
|
|
335
|
+
loadWindow.add(sessionId);
|
|
336
|
+
}
|
|
337
|
+
return known;
|
|
338
|
+
}
|
|
196
339
|
deleteByKey(key) {
|
|
197
340
|
const sessionId = this.toSession.get(key);
|
|
198
341
|
if (!sessionId)
|
|
@@ -200,6 +343,7 @@ export class SessionRouter {
|
|
|
200
343
|
this.toSession.delete(key);
|
|
201
344
|
this.toTarget.delete(sessionId);
|
|
202
345
|
this.toCwd.delete(sessionId);
|
|
346
|
+
this.liveSessionIds.delete(sessionId);
|
|
203
347
|
return sessionId;
|
|
204
348
|
}
|
|
205
349
|
promoteTargetToGroup(sessionId, isGroup) {
|
|
@@ -222,49 +366,72 @@ export class SessionRouter {
|
|
|
222
366
|
}
|
|
223
367
|
return entries;
|
|
224
368
|
}
|
|
369
|
+
restoreRoutes() {
|
|
370
|
+
if (this.recoveryMode !== 'lazy') {
|
|
371
|
+
throw new Error('restoreRoutes requires lazy recovery mode');
|
|
372
|
+
}
|
|
373
|
+
const persisted = this.readPersistedEntries();
|
|
374
|
+
if (!persisted)
|
|
375
|
+
return { restored: 0, dropped: 0 };
|
|
376
|
+
this.dispose();
|
|
377
|
+
let restored = 0;
|
|
378
|
+
for (const [key, entry] of Object.entries(persisted.entries)) {
|
|
379
|
+
this.toSession.set(key, entry.sessionId);
|
|
380
|
+
this.toTarget.set(entry.sessionId, entry.target);
|
|
381
|
+
this.toCwd.set(entry.sessionId, entry.cwd);
|
|
382
|
+
restored++;
|
|
383
|
+
}
|
|
384
|
+
if (persisted.dropped > 0)
|
|
385
|
+
this.persist();
|
|
386
|
+
return { restored, dropped: persisted.dropped };
|
|
387
|
+
}
|
|
225
388
|
/**
|
|
226
389
|
* Restore session mappings from a previous bridge.
|
|
227
390
|
* Called after bridge restart — attempts loadSession for each saved mapping.
|
|
228
391
|
* Failed loads are dropped (new session on next message).
|
|
229
392
|
*/
|
|
230
393
|
async restoreSessions() {
|
|
231
|
-
const
|
|
232
|
-
if (!
|
|
233
|
-
return { restored: 0, failed: 0 };
|
|
234
|
-
}
|
|
235
|
-
let entries;
|
|
236
|
-
try {
|
|
237
|
-
entries = JSON.parse(readFileSync(persistPath, 'utf-8'));
|
|
238
|
-
}
|
|
239
|
-
catch (err) {
|
|
240
|
-
const reason = err instanceof Error ? err.message : String(err);
|
|
241
|
-
process.stderr.write(`[SessionRouter] Corrupted persist file at ${sanitizeLogText(persistPath, 1024)}: ${sanitizeLogText(reason, 512)}\n`);
|
|
394
|
+
const persisted = this.readPersistedEntries();
|
|
395
|
+
if (!persisted)
|
|
242
396
|
return { restored: 0, failed: 0 };
|
|
243
|
-
|
|
397
|
+
const entries = persisted.entries;
|
|
398
|
+
const restoreGeneration = this.lifecycleGeneration;
|
|
244
399
|
let restored = 0;
|
|
245
400
|
let failed = 0;
|
|
246
|
-
let changed =
|
|
401
|
+
let changed = persisted.dropped > 0;
|
|
247
402
|
const reservations = new Map();
|
|
403
|
+
for (const key of persisted.droppedKeys) {
|
|
404
|
+
this.deleteByKey(key);
|
|
405
|
+
}
|
|
248
406
|
// Reserve every persisted key up front so inbound messages during restart
|
|
249
407
|
// wait for restore instead of returning stale IDs or creating duplicates.
|
|
250
408
|
for (const key of Object.keys(entries)) {
|
|
251
409
|
this.deleteByKey(key);
|
|
252
410
|
const reservation = this.createSessionReservation();
|
|
253
411
|
reservation.promise.catch(() => undefined);
|
|
254
|
-
this.
|
|
255
|
-
|
|
412
|
+
const operation = this.createSessionOperation(key, entries[key].target, () => reservation.promise);
|
|
413
|
+
operation.promise.catch(() => undefined);
|
|
414
|
+
this.creatingSessions.set(key, operation);
|
|
415
|
+
reservations.set(key, { reservation, operation });
|
|
256
416
|
}
|
|
257
417
|
const loadWindow = this.beginSessionLoad();
|
|
258
418
|
try {
|
|
259
419
|
for (const [key, entry] of Object.entries(entries)) {
|
|
260
|
-
const
|
|
261
|
-
if (!
|
|
420
|
+
const reserved = reservations.get(key);
|
|
421
|
+
if (!reserved)
|
|
262
422
|
continue;
|
|
423
|
+
const { reservation, operation } = reserved;
|
|
263
424
|
try {
|
|
425
|
+
this.assertOperationCurrent(operation);
|
|
264
426
|
const options = this.sessionOptions(entry.target.channelName);
|
|
265
|
-
const sessionId = options
|
|
266
|
-
|
|
267
|
-
|
|
427
|
+
const sessionId = await this.bridge.loadSession(entry.sessionId, entry.cwd, options, operation);
|
|
428
|
+
try {
|
|
429
|
+
this.assertOperationCurrent(operation);
|
|
430
|
+
}
|
|
431
|
+
catch (error) {
|
|
432
|
+
this.scheduleDiscardInvalidatedSession(sessionId, operation);
|
|
433
|
+
throw error;
|
|
434
|
+
}
|
|
268
435
|
if (typeof sessionId !== 'string' || sessionId.length === 0) {
|
|
269
436
|
throw new Error('Invalid restored session ID');
|
|
270
437
|
}
|
|
@@ -289,9 +456,10 @@ export class SessionRouter {
|
|
|
289
456
|
changed = true;
|
|
290
457
|
}
|
|
291
458
|
finally {
|
|
292
|
-
if (this.creatingSessions.get(key) ===
|
|
459
|
+
if (this.creatingSessions.get(key) === operation) {
|
|
293
460
|
this.creatingSessions.delete(key);
|
|
294
461
|
}
|
|
462
|
+
this.releaseRouteToken(key, operation);
|
|
295
463
|
}
|
|
296
464
|
}
|
|
297
465
|
}
|
|
@@ -299,18 +467,27 @@ export class SessionRouter {
|
|
|
299
467
|
this.endSessionLoad(loadWindow);
|
|
300
468
|
}
|
|
301
469
|
// Update persist file to only include successfully restored sessions
|
|
302
|
-
if (changed) {
|
|
470
|
+
if (changed && restoreGeneration === this.lifecycleGeneration) {
|
|
303
471
|
this.persist();
|
|
304
472
|
}
|
|
305
473
|
return { restored, failed };
|
|
306
474
|
}
|
|
307
|
-
|
|
308
|
-
|
|
475
|
+
dispose() {
|
|
476
|
+
this.lifecycleGeneration++;
|
|
477
|
+
for (const operation of this.creatingSessions.values()) {
|
|
478
|
+
this.invalidateOperation(operation);
|
|
479
|
+
}
|
|
309
480
|
this.toSession.clear();
|
|
310
481
|
this.toTarget.clear();
|
|
311
482
|
this.toCwd.clear();
|
|
312
483
|
this.creatingSessions.clear();
|
|
313
484
|
this.sessionLoadWindows.clear();
|
|
485
|
+
this.liveSessionIds.clear();
|
|
486
|
+
this.routeTokens.clear();
|
|
487
|
+
}
|
|
488
|
+
/** Clear in-memory state and delete persist file. Used on clean shutdown. */
|
|
489
|
+
clearAll() {
|
|
490
|
+
this.dispose();
|
|
314
491
|
if (this.persistPath && existsSync(this.persistPath)) {
|
|
315
492
|
try {
|
|
316
493
|
unlinkSync(this.persistPath);
|
|
@@ -320,34 +497,128 @@ export class SessionRouter {
|
|
|
320
497
|
}
|
|
321
498
|
}
|
|
322
499
|
}
|
|
500
|
+
readPersistedEntries() {
|
|
501
|
+
const persistPath = this.persistPath;
|
|
502
|
+
if (!persistPath || !existsSync(persistPath))
|
|
503
|
+
return undefined;
|
|
504
|
+
let parsed;
|
|
505
|
+
try {
|
|
506
|
+
parsed = JSON.parse(readFileSync(persistPath, 'utf-8'));
|
|
507
|
+
}
|
|
508
|
+
catch (error) {
|
|
509
|
+
const quarantinePath = `${persistPath}.corrupt-${Date.now()}`;
|
|
510
|
+
try {
|
|
511
|
+
renameSync(persistPath, quarantinePath);
|
|
512
|
+
}
|
|
513
|
+
catch {
|
|
514
|
+
// Keep startup available even if quarantine itself fails.
|
|
515
|
+
}
|
|
516
|
+
process.stderr.write(`[SessionRouter] Corrupted persist file at ${sanitizeLogText(persistPath, 1024)}: ${sanitizeLogText(error instanceof Error ? error.message : String(error), 512)}\n`);
|
|
517
|
+
return undefined;
|
|
518
|
+
}
|
|
519
|
+
if (typeof parsed !== 'object' ||
|
|
520
|
+
parsed === null ||
|
|
521
|
+
Array.isArray(parsed)) {
|
|
522
|
+
const quarantinePath = `${persistPath}.corrupt-${Date.now()}`;
|
|
523
|
+
try {
|
|
524
|
+
renameSync(persistPath, quarantinePath);
|
|
525
|
+
}
|
|
526
|
+
catch {
|
|
527
|
+
// Keep startup available even if quarantine itself fails.
|
|
528
|
+
}
|
|
529
|
+
process.stderr.write(`[SessionRouter] Invalid route store at ${sanitizeLogText(persistPath, 1024)}: expected an object\n`);
|
|
530
|
+
return undefined;
|
|
531
|
+
}
|
|
532
|
+
const entries = {};
|
|
533
|
+
const droppedKeys = [];
|
|
534
|
+
for (const [key, value] of Object.entries(parsed)) {
|
|
535
|
+
if (this.isPersistedEntry(value))
|
|
536
|
+
entries[key] = value;
|
|
537
|
+
else
|
|
538
|
+
droppedKeys.push(key);
|
|
539
|
+
}
|
|
540
|
+
return { entries, dropped: droppedKeys.length, droppedKeys };
|
|
541
|
+
}
|
|
542
|
+
isPersistedEntry(value) {
|
|
543
|
+
if (typeof value !== 'object' || value === null)
|
|
544
|
+
return false;
|
|
545
|
+
const entry = value;
|
|
546
|
+
const target = entry['target'];
|
|
547
|
+
if (typeof target !== 'object' || target === null)
|
|
548
|
+
return false;
|
|
549
|
+
const typedTarget = target;
|
|
550
|
+
return (typeof entry['sessionId'] === 'string' &&
|
|
551
|
+
entry['sessionId'].length > 0 &&
|
|
552
|
+
typeof entry['cwd'] === 'string' &&
|
|
553
|
+
entry['cwd'].length > 0 &&
|
|
554
|
+
typeof typedTarget['channelName'] === 'string' &&
|
|
555
|
+
typeof typedTarget['senderId'] === 'string' &&
|
|
556
|
+
typeof typedTarget['chatId'] === 'string' &&
|
|
557
|
+
(typedTarget['threadId'] === undefined ||
|
|
558
|
+
typeof typedTarget['threadId'] === 'string') &&
|
|
559
|
+
(typedTarget['isGroup'] === undefined ||
|
|
560
|
+
typeof typedTarget['isGroup'] === 'boolean'));
|
|
561
|
+
}
|
|
323
562
|
persist() {
|
|
324
563
|
if (!this.persistPath)
|
|
325
564
|
return;
|
|
326
565
|
const data = {};
|
|
327
566
|
for (const [key, sessionId] of this.toSession) {
|
|
328
567
|
const target = this.toTarget.get(sessionId);
|
|
329
|
-
if (target)
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
}
|
|
568
|
+
if (!target)
|
|
569
|
+
continue;
|
|
570
|
+
data[key] = {
|
|
571
|
+
sessionId,
|
|
572
|
+
target,
|
|
573
|
+
cwd: this.toCwd.get(sessionId) ?? this.defaultCwd,
|
|
574
|
+
};
|
|
336
575
|
}
|
|
576
|
+
const dir = dirname(this.persistPath);
|
|
577
|
+
const tempPath = join(dir, `${Date.now()}-${process.pid}-${Math.random().toString(16).slice(2)}.tmp`);
|
|
337
578
|
try {
|
|
338
|
-
|
|
579
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
580
|
+
try {
|
|
581
|
+
chmodSync(dir, 0o700);
|
|
582
|
+
}
|
|
583
|
+
catch {
|
|
584
|
+
// Windows and some filesystems do not implement POSIX modes.
|
|
585
|
+
}
|
|
586
|
+
writeFileSync(tempPath, JSON.stringify(data, null, 2), {
|
|
587
|
+
encoding: 'utf-8',
|
|
588
|
+
mode: 0o600,
|
|
589
|
+
});
|
|
590
|
+
renameSync(tempPath, this.persistPath);
|
|
591
|
+
try {
|
|
592
|
+
chmodSync(this.persistPath, 0o600);
|
|
593
|
+
}
|
|
594
|
+
catch {
|
|
595
|
+
// Windows and some filesystems do not implement POSIX modes.
|
|
596
|
+
}
|
|
339
597
|
}
|
|
340
|
-
catch {
|
|
341
|
-
|
|
598
|
+
catch (error) {
|
|
599
|
+
process.stderr.write(`[SessionRouter] Failed to persist routes at ${sanitizeLogText(this.persistPath, 1024)}: ${sanitizeLogText(error instanceof Error ? error.message : String(error), 512)}\n`);
|
|
600
|
+
}
|
|
601
|
+
finally {
|
|
602
|
+
try {
|
|
603
|
+
rmSync(tempPath, { force: true });
|
|
604
|
+
}
|
|
605
|
+
catch {
|
|
606
|
+
// best-effort temp cleanup
|
|
607
|
+
}
|
|
342
608
|
}
|
|
343
609
|
}
|
|
344
|
-
async createLiveSession(cwd, loadWindow, routingKey, options) {
|
|
610
|
+
async createLiveSession(cwd, loadWindow, routingKey, options, operation) {
|
|
345
611
|
const maxAttempts = 2;
|
|
346
612
|
let lastDeadSessionId;
|
|
347
613
|
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
348
|
-
const sessionId = options
|
|
349
|
-
|
|
350
|
-
|
|
614
|
+
const sessionId = await this.bridge.newSession(cwd, options, operation);
|
|
615
|
+
try {
|
|
616
|
+
this.assertOperationCurrent(operation);
|
|
617
|
+
}
|
|
618
|
+
catch (error) {
|
|
619
|
+
this.scheduleDiscardInvalidatedSession(sessionId, operation);
|
|
620
|
+
throw error;
|
|
621
|
+
}
|
|
351
622
|
if (typeof sessionId !== 'string' || sessionId.length === 0) {
|
|
352
623
|
throw new Error('Invalid session ID from bridge');
|
|
353
624
|
}
|
|
@@ -363,6 +634,73 @@ export class SessionRouter {
|
|
|
363
634
|
this.sessionLoadWindows.add(loadWindow);
|
|
364
635
|
return loadWindow;
|
|
365
636
|
}
|
|
637
|
+
createSessionOperation(key, target, run) {
|
|
638
|
+
let routeToken = this.routeTokens.get(key);
|
|
639
|
+
if (!routeToken) {
|
|
640
|
+
routeToken = {};
|
|
641
|
+
this.routeTokens.set(key, routeToken);
|
|
642
|
+
}
|
|
643
|
+
const operation = {
|
|
644
|
+
promise: Promise.resolve(''),
|
|
645
|
+
target,
|
|
646
|
+
lifecycleGeneration: this.lifecycleGeneration,
|
|
647
|
+
routeToken,
|
|
648
|
+
};
|
|
649
|
+
operation.promise = Promise.resolve()
|
|
650
|
+
.then(() => run(operation))
|
|
651
|
+
.catch((error) => {
|
|
652
|
+
this.assertOperationCurrent(operation);
|
|
653
|
+
throw error;
|
|
654
|
+
});
|
|
655
|
+
return operation;
|
|
656
|
+
}
|
|
657
|
+
invalidateRouteOperation(key) {
|
|
658
|
+
this.routeTokens.delete(key);
|
|
659
|
+
const operation = this.creatingSessions.get(key);
|
|
660
|
+
if (!operation)
|
|
661
|
+
return;
|
|
662
|
+
this.invalidateOperation(operation);
|
|
663
|
+
this.creatingSessions.delete(key);
|
|
664
|
+
}
|
|
665
|
+
invalidateOperation(operation) {
|
|
666
|
+
operation.invalidationError ??= new Error('Session route operation was invalidated');
|
|
667
|
+
}
|
|
668
|
+
assertOperationCurrent(operation) {
|
|
669
|
+
if (operation.lifecycleGeneration !== this.lifecycleGeneration) {
|
|
670
|
+
this.invalidateOperation(operation);
|
|
671
|
+
}
|
|
672
|
+
if (operation.invalidationError) {
|
|
673
|
+
throw operation.invalidationError;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
assertOperationResultCurrent(key, sessionId, operation) {
|
|
677
|
+
if (operation.routeToken !== this.routeTokens.get(key)) {
|
|
678
|
+
this.invalidateOperation(operation);
|
|
679
|
+
}
|
|
680
|
+
if (this.toSession.get(key) !== sessionId) {
|
|
681
|
+
this.invalidateOperation(operation);
|
|
682
|
+
}
|
|
683
|
+
this.assertOperationCurrent(operation);
|
|
684
|
+
}
|
|
685
|
+
releaseRouteToken(key, operation) {
|
|
686
|
+
if (this.routeTokens.get(key) === operation.routeToken &&
|
|
687
|
+
!this.toSession.has(key) &&
|
|
688
|
+
!this.creatingSessions.has(key)) {
|
|
689
|
+
this.routeTokens.delete(key);
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
scheduleDiscardInvalidatedSession(sessionId, operation) {
|
|
693
|
+
if ([...this.toSession.values()].includes(sessionId))
|
|
694
|
+
return;
|
|
695
|
+
try {
|
|
696
|
+
void this.bridge
|
|
697
|
+
.discardSession?.(sessionId, operation)
|
|
698
|
+
.catch(() => undefined);
|
|
699
|
+
}
|
|
700
|
+
catch {
|
|
701
|
+
// Best-effort cleanup must not replace the terminal invalidation.
|
|
702
|
+
}
|
|
703
|
+
}
|
|
366
704
|
createSessionReservation() {
|
|
367
705
|
let resolveReservation;
|
|
368
706
|
let rejectReservation;
|