@qwen-code/channel-base 0.19.8 → 0.19.9-preview.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.
@@ -1,4 +1,5 @@
1
- import { existsSync, readFileSync, writeFileSync, unlinkSync } from 'node:fs';
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,16 +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();
18
+ channelApprovalModes = new Map();
14
19
  persistPath;
15
- constructor(bridge, defaultCwd, scope = 'user', persistPath) {
20
+ recoveryMode;
21
+ constructor(bridge, defaultCwd, scope = 'user', persistPath, options = {}) {
16
22
  this.bridge = bridge;
17
23
  this.defaultCwd = defaultCwd;
18
24
  this.defaultScope = scope;
19
25
  this.persistPath = persistPath;
26
+ this.recoveryMode = options.recoveryMode ?? 'eager';
20
27
  }
21
28
  /** Replace the bridge instance (used after crash recovery restart). */
22
29
  setBridge(bridge) {
@@ -26,6 +33,14 @@ export class SessionRouter {
26
33
  setChannelScope(channelName, scope) {
27
34
  this.channelScopes.set(channelName, scope);
28
35
  }
36
+ setChannelApprovalMode(channelName, approvalMode) {
37
+ if (approvalMode) {
38
+ this.channelApprovalModes.set(channelName, approvalMode);
39
+ }
40
+ else {
41
+ this.channelApprovalModes.delete(channelName);
42
+ }
43
+ }
29
44
  routingKey(channelName, senderId, chatId, threadId) {
30
45
  const scope = this.channelScopes.get(channelName) || this.defaultScope;
31
46
  switch (scope) {
@@ -38,66 +53,186 @@ export class SessionRouter {
38
53
  return `${channelName}:${senderId}:${chatId}`;
39
54
  }
40
55
  }
41
- async resolve(channelName, senderId, chatId, threadId, cwd, isGroup) {
42
- const key = this.routingKey(channelName, senderId, chatId, threadId);
43
- let failedCreateWaits = 0;
56
+ sessionOptions(channelName) {
57
+ const approvalMode = this.channelApprovalModes.get(channelName);
58
+ return approvalMode ? { approvalMode } : undefined;
59
+ }
60
+ async resolve(channelName, senderId, chatId, threadId, cwd, isGroup, options) {
61
+ const key = this.routingKey(channelName, senderId, chatId, options?.routingThreadId ?? threadId);
62
+ const input = {
63
+ channelName,
64
+ senderId,
65
+ chatId,
66
+ threadId,
67
+ cwd: cwd || this.defaultCwd,
68
+ isGroup,
69
+ };
70
+ let failedWaits = 0;
44
71
  for (;;) {
45
72
  const existing = this.toSession.get(key);
46
- if (existing) {
73
+ if (existing && this.isLive(existing)) {
47
74
  this.promoteTargetToGroup(existing, isGroup);
48
75
  return existing;
49
76
  }
50
77
  const creating = this.creatingSessions.get(key);
51
78
  if (creating) {
52
79
  try {
53
- 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
+ }
54
88
  this.promoteTargetToGroup(sessionId, isGroup);
55
89
  return sessionId;
56
90
  }
57
- catch (err) {
91
+ catch (error) {
92
+ if (creating.invalidationError) {
93
+ throw creating.invalidationError;
94
+ }
58
95
  if (this.creatingSessions.get(key) === creating) {
59
96
  this.creatingSessions.delete(key);
60
97
  }
61
- failedCreateWaits++;
62
- if (failedCreateWaits > 3) {
63
- throw err;
64
- }
98
+ this.releaseRouteToken(key, creating);
99
+ failedWaits++;
100
+ if (failedWaits > 3)
101
+ throw error;
65
102
  continue;
66
103
  }
67
104
  }
68
- // Register the in-flight route before starting newSession(), because a
69
- // bridge can emit sessionDied synchronously while creating the session.
70
- const created = Promise.resolve().then(async () => {
71
- const sessionCwd = cwd || this.defaultCwd;
72
- const loadWindow = this.beginSessionLoad();
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;
73
117
  try {
74
- const sessionId = await this.createLiveSession(sessionCwd, loadWindow, key);
75
- this.toSession.set(key, sessionId);
76
- this.toTarget.set(sessionId, {
77
- channelName,
78
- senderId,
79
- chatId,
80
- threadId,
81
- isGroup,
82
- });
83
- this.toCwd.set(sessionId, sessionCwd);
84
- this.persist();
85
- return sessionId;
118
+ this.assertOperationResultCurrent(key, sessionId, operation);
86
119
  }
87
- finally {
88
- this.endSessionLoad(loadWindow);
120
+ catch (error) {
121
+ this.scheduleDiscardInvalidatedSession(sessionId, operation);
122
+ throw error;
89
123
  }
90
- });
91
- this.creatingSessions.set(key, created);
92
- try {
93
- return await created;
124
+ this.promoteTargetToGroup(sessionId, isGroup);
125
+ return sessionId;
94
126
  }
95
127
  finally {
96
- if (this.creatingSessions.get(key) === created) {
128
+ if (this.creatingSessions.get(key) === operation) {
97
129
  this.creatingSessions.delete(key);
98
130
  }
131
+ this.releaseRouteToken(key, operation);
132
+ }
133
+ }
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
+ }
99
231
  }
100
232
  }
233
+ finally {
234
+ this.endSessionLoad(loadWindow);
235
+ }
101
236
  }
102
237
  getTarget(sessionId) {
103
238
  return this.toTarget.get(sessionId);
@@ -132,6 +267,7 @@ export class SessionRouter {
132
267
  const scope = this.channelScopes.get(channelName) || this.defaultScope;
133
268
  if (chatId) {
134
269
  const key = this.routingKey(channelName, senderId, chatId, threadId);
270
+ this.invalidateRouteOperation(key);
135
271
  const sessionId = this.deleteByKey(key);
136
272
  if (sessionId)
137
273
  removedIds.push(sessionId);
@@ -145,11 +281,18 @@ export class SessionRouter {
145
281
  const target = this.toTarget.get(mappedSessionId);
146
282
  if (target?.channelName === channelName &&
147
283
  target.senderId === senderId) {
284
+ this.invalidateRouteOperation(k);
148
285
  const sessionId = this.deleteByKey(k);
149
286
  if (sessionId)
150
287
  removedIds.push(sessionId);
151
288
  }
152
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
+ }
153
296
  }
154
297
  if (removedIds.length > 0)
155
298
  this.persist();
@@ -160,6 +303,7 @@ export class SessionRouter {
160
303
  let removed = false;
161
304
  for (const [key, mappedSessionId] of [...this.toSession.entries()]) {
162
305
  if (mappedSessionId === sessionId) {
306
+ this.invalidateRouteOperation(key);
163
307
  this.toSession.delete(key);
164
308
  removed = true;
165
309
  }
@@ -170,6 +314,7 @@ export class SessionRouter {
170
314
  if (this.toCwd.delete(sessionId)) {
171
315
  removed = true;
172
316
  }
317
+ this.liveSessionIds.delete(sessionId);
173
318
  if (!removed && this.sessionLoadWindows.size > 0) {
174
319
  for (const loadWindow of this.sessionLoadWindows) {
175
320
  loadWindow.add(sessionId);
@@ -180,6 +325,17 @@ export class SessionRouter {
180
325
  }
181
326
  return removed;
182
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
+ }
183
339
  deleteByKey(key) {
184
340
  const sessionId = this.toSession.get(key);
185
341
  if (!sessionId)
@@ -187,6 +343,7 @@ export class SessionRouter {
187
343
  this.toSession.delete(key);
188
344
  this.toTarget.delete(sessionId);
189
345
  this.toCwd.delete(sessionId);
346
+ this.liveSessionIds.delete(sessionId);
190
347
  return sessionId;
191
348
  }
192
349
  promoteTargetToGroup(sessionId, isGroup) {
@@ -209,46 +366,72 @@ export class SessionRouter {
209
366
  }
210
367
  return entries;
211
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
+ }
212
388
  /**
213
389
  * Restore session mappings from a previous bridge.
214
390
  * Called after bridge restart — attempts loadSession for each saved mapping.
215
391
  * Failed loads are dropped (new session on next message).
216
392
  */
217
393
  async restoreSessions() {
218
- const persistPath = this.persistPath;
219
- if (!persistPath || !existsSync(persistPath)) {
220
- return { restored: 0, failed: 0 };
221
- }
222
- let entries;
223
- try {
224
- entries = JSON.parse(readFileSync(persistPath, 'utf-8'));
225
- }
226
- catch (err) {
227
- const reason = err instanceof Error ? err.message : String(err);
228
- process.stderr.write(`[SessionRouter] Corrupted persist file at ${sanitizeLogText(persistPath, 1024)}: ${sanitizeLogText(reason, 512)}\n`);
394
+ const persisted = this.readPersistedEntries();
395
+ if (!persisted)
229
396
  return { restored: 0, failed: 0 };
230
- }
397
+ const entries = persisted.entries;
398
+ const restoreGeneration = this.lifecycleGeneration;
231
399
  let restored = 0;
232
400
  let failed = 0;
233
- let changed = false;
401
+ let changed = persisted.dropped > 0;
234
402
  const reservations = new Map();
403
+ for (const key of persisted.droppedKeys) {
404
+ this.deleteByKey(key);
405
+ }
235
406
  // Reserve every persisted key up front so inbound messages during restart
236
407
  // wait for restore instead of returning stale IDs or creating duplicates.
237
408
  for (const key of Object.keys(entries)) {
238
409
  this.deleteByKey(key);
239
410
  const reservation = this.createSessionReservation();
240
411
  reservation.promise.catch(() => undefined);
241
- this.creatingSessions.set(key, reservation.promise);
242
- reservations.set(key, reservation);
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 });
243
416
  }
244
417
  const loadWindow = this.beginSessionLoad();
245
418
  try {
246
419
  for (const [key, entry] of Object.entries(entries)) {
247
- const reservation = reservations.get(key);
248
- if (!reservation)
420
+ const reserved = reservations.get(key);
421
+ if (!reserved)
249
422
  continue;
423
+ const { reservation, operation } = reserved;
250
424
  try {
251
- const sessionId = await this.bridge.loadSession(entry.sessionId, entry.cwd);
425
+ this.assertOperationCurrent(operation);
426
+ const options = this.sessionOptions(entry.target.channelName);
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
+ }
252
435
  if (typeof sessionId !== 'string' || sessionId.length === 0) {
253
436
  throw new Error('Invalid restored session ID');
254
437
  }
@@ -273,9 +456,10 @@ export class SessionRouter {
273
456
  changed = true;
274
457
  }
275
458
  finally {
276
- if (this.creatingSessions.get(key) === reservation.promise) {
459
+ if (this.creatingSessions.get(key) === operation) {
277
460
  this.creatingSessions.delete(key);
278
461
  }
462
+ this.releaseRouteToken(key, operation);
279
463
  }
280
464
  }
281
465
  }
@@ -283,18 +467,27 @@ export class SessionRouter {
283
467
  this.endSessionLoad(loadWindow);
284
468
  }
285
469
  // Update persist file to only include successfully restored sessions
286
- if (changed) {
470
+ if (changed && restoreGeneration === this.lifecycleGeneration) {
287
471
  this.persist();
288
472
  }
289
473
  return { restored, failed };
290
474
  }
291
- /** Clear in-memory state and delete persist file. Used on clean shutdown. */
292
- clearAll() {
475
+ dispose() {
476
+ this.lifecycleGeneration++;
477
+ for (const operation of this.creatingSessions.values()) {
478
+ this.invalidateOperation(operation);
479
+ }
293
480
  this.toSession.clear();
294
481
  this.toTarget.clear();
295
482
  this.toCwd.clear();
296
483
  this.creatingSessions.clear();
297
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();
298
491
  if (this.persistPath && existsSync(this.persistPath)) {
299
492
  try {
300
493
  unlinkSync(this.persistPath);
@@ -304,32 +497,128 @@ export class SessionRouter {
304
497
  }
305
498
  }
306
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
+ }
307
562
  persist() {
308
563
  if (!this.persistPath)
309
564
  return;
310
565
  const data = {};
311
566
  for (const [key, sessionId] of this.toSession) {
312
567
  const target = this.toTarget.get(sessionId);
313
- if (target) {
314
- data[key] = {
315
- sessionId,
316
- target,
317
- cwd: this.toCwd.get(sessionId) || this.defaultCwd,
318
- };
319
- }
568
+ if (!target)
569
+ continue;
570
+ data[key] = {
571
+ sessionId,
572
+ target,
573
+ cwd: this.toCwd.get(sessionId) ?? this.defaultCwd,
574
+ };
320
575
  }
576
+ const dir = dirname(this.persistPath);
577
+ const tempPath = join(dir, `${Date.now()}-${process.pid}-${Math.random().toString(16).slice(2)}.tmp`);
321
578
  try {
322
- writeFileSync(this.persistPath, JSON.stringify(data, null, 2), 'utf-8');
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
+ }
323
597
  }
324
- catch {
325
- // best-effort — don't break message flow for persistence failure
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
+ }
326
608
  }
327
609
  }
328
- async createLiveSession(cwd, loadWindow, routingKey) {
610
+ async createLiveSession(cwd, loadWindow, routingKey, options, operation) {
329
611
  const maxAttempts = 2;
330
612
  let lastDeadSessionId;
331
613
  for (let attempt = 0; attempt < maxAttempts; attempt++) {
332
- const sessionId = await this.bridge.newSession(cwd);
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
+ }
333
622
  if (typeof sessionId !== 'string' || sessionId.length === 0) {
334
623
  throw new Error('Invalid session ID from bridge');
335
624
  }
@@ -345,6 +634,73 @@ export class SessionRouter {
345
634
  this.sessionLoadWindows.add(loadWindow);
346
635
  return loadWindow;
347
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
+ }
348
704
  createSessionReservation() {
349
705
  let resolveReservation;
350
706
  let rejectReservation;