agentgui 1.0.595 → 1.0.597
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/lib/checkpoint-manager.js +151 -0
- package/lib/ws-handlers-conv.js +0 -1
- package/package.json +1 -1
- package/server.js +37 -4
- package/static/js/client.js +172 -0
- package/static/js/request-manager.js +138 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Checkpoint Manager
|
|
3
|
+
* Handles session recovery by loading checkpoints and injecting into resume flow
|
|
4
|
+
* Ensures idempotency and prevents duplicate event replay
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
class CheckpointManager {
|
|
8
|
+
constructor(queries) {
|
|
9
|
+
this.queries = queries;
|
|
10
|
+
this._injectedSessions = new Set(); // Track which sessions already had checkpoints injected
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Load checkpoint for a session (all events + chunks from previous session)
|
|
15
|
+
* Used when resuming after interruption
|
|
16
|
+
*/
|
|
17
|
+
loadCheckpoint(previousSessionId) {
|
|
18
|
+
if (!previousSessionId) return null;
|
|
19
|
+
|
|
20
|
+
try {
|
|
21
|
+
const session = this.queries.getSession(previousSessionId);
|
|
22
|
+
if (!session) return null;
|
|
23
|
+
|
|
24
|
+
const events = this.queries.getSessionEvents(previousSessionId);
|
|
25
|
+
const chunks = this.queries.getChunksSinceSeq(previousSessionId, -1);
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
sessionId: previousSessionId,
|
|
29
|
+
conversationId: session.conversationId,
|
|
30
|
+
events: events || [],
|
|
31
|
+
chunks: chunks || [],
|
|
32
|
+
lastSequence: chunks.length > 0 ? Math.max(...chunks.map(c => c.sequence)) : -1
|
|
33
|
+
};
|
|
34
|
+
} catch (e) {
|
|
35
|
+
console.error(`[checkpoint] Failed to load checkpoint for session ${previousSessionId}:`, e.message);
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Inject checkpoint events into the new session
|
|
42
|
+
* Marks them with resumeOrigin to prevent replay
|
|
43
|
+
* Returns the next sequence number to use
|
|
44
|
+
*/
|
|
45
|
+
injectCheckpointEvents(newSessionId, checkpoint, broadcastFn) {
|
|
46
|
+
if (!checkpoint || !checkpoint.events || checkpoint.events.length === 0) {
|
|
47
|
+
return -1;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Prevent double-injection for same session
|
|
51
|
+
const injectionKey = `${newSessionId}:checkpoint`;
|
|
52
|
+
if (this._injectedSessions.has(injectionKey)) {
|
|
53
|
+
console.log(`[checkpoint] Session ${newSessionId} already had checkpoint injected, skipping`);
|
|
54
|
+
return checkpoint.lastSequence;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let sequenceStart = checkpoint.lastSequence + 1;
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
// Broadcast each checkpoint event as if it's arriving now
|
|
61
|
+
for (const evt of checkpoint.events) {
|
|
62
|
+
// Skip internal session management events
|
|
63
|
+
if (evt.type === 'session.created') continue;
|
|
64
|
+
|
|
65
|
+
// Re-broadcast with resume markers
|
|
66
|
+
broadcastFn({
|
|
67
|
+
...evt,
|
|
68
|
+
resumeOrigin: 'checkpoint',
|
|
69
|
+
originalSessionId: checkpoint.sessionId,
|
|
70
|
+
newSessionId: newSessionId,
|
|
71
|
+
timestamp: Date.now()
|
|
72
|
+
});
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Mark this session as having been injected
|
|
76
|
+
this._injectedSessions.add(injectionKey);
|
|
77
|
+
|
|
78
|
+
console.log(
|
|
79
|
+
`[checkpoint] Injected ${checkpoint.events.length} events from session ` +
|
|
80
|
+
`${checkpoint.sessionId} into new session ${newSessionId}`
|
|
81
|
+
);
|
|
82
|
+
|
|
83
|
+
return sequenceStart;
|
|
84
|
+
} catch (e) {
|
|
85
|
+
console.error(`[checkpoint] Failed to inject checkpoint events:`, e.message);
|
|
86
|
+
return checkpoint.lastSequence;
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Copy checkpoint chunks to new session with modified sequence
|
|
92
|
+
* Ensures chunks are marked as injected to distinguish from new streaming
|
|
93
|
+
*/
|
|
94
|
+
copyCheckpointChunks(oldSessionId, newSessionId, startSequence = 0) {
|
|
95
|
+
try {
|
|
96
|
+
const chunks = this.queries.getChunksSinceSeq(oldSessionId, -1);
|
|
97
|
+
if (!chunks || chunks.length === 0) return 0;
|
|
98
|
+
|
|
99
|
+
let copiedCount = 0;
|
|
100
|
+
|
|
101
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
102
|
+
const chunk = chunks[i];
|
|
103
|
+
const newSequence = startSequence + i;
|
|
104
|
+
|
|
105
|
+
try {
|
|
106
|
+
this.queries.createChunk(
|
|
107
|
+
newSessionId,
|
|
108
|
+
chunk.conversationId,
|
|
109
|
+
newSequence,
|
|
110
|
+
chunk.type,
|
|
111
|
+
{ ...chunk.data, resumeOrigin: 'checkpoint' }
|
|
112
|
+
);
|
|
113
|
+
copiedCount++;
|
|
114
|
+
} catch (e) {
|
|
115
|
+
console.error(`[checkpoint] Failed to copy chunk ${i}:`, e.message);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
console.log(`[checkpoint] Copied ${copiedCount} chunks from ${oldSessionId} to ${newSessionId}`);
|
|
120
|
+
return startSequence + copiedCount;
|
|
121
|
+
} catch (e) {
|
|
122
|
+
console.error(`[checkpoint] Failed to copy checkpoint chunks:`, e.message);
|
|
123
|
+
return startSequence;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Clean up: mark previous session as properly resumed
|
|
129
|
+
* Prevents re-resuming the same interrupted session multiple times
|
|
130
|
+
*/
|
|
131
|
+
markSessionResumed(previousSessionId) {
|
|
132
|
+
try {
|
|
133
|
+
this.queries.updateSession(previousSessionId, {
|
|
134
|
+
status: 'resumed',
|
|
135
|
+
completed_at: Date.now()
|
|
136
|
+
});
|
|
137
|
+
console.log(`[checkpoint] Marked session ${previousSessionId} as resumed`);
|
|
138
|
+
} catch (e) {
|
|
139
|
+
console.error(`[checkpoint] Failed to mark session as resumed:`, e.message);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* Clear injected sessions cache (call on server restart)
|
|
145
|
+
*/
|
|
146
|
+
reset() {
|
|
147
|
+
this._injectedSessions.clear();
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export default CheckpointManager;
|
package/lib/ws-handlers-conv.js
CHANGED
|
@@ -104,7 +104,6 @@ export function register(router, deps) {
|
|
|
104
104
|
if (!conv) notFound('Conversation not found');
|
|
105
105
|
if (!p.content) fail(400, 'Missing content');
|
|
106
106
|
const entry = activeExecutions.get(p.id);
|
|
107
|
-
if (entry && p.eager) fail(409, 'Cannot eagerly inject while execution is running - message queued');
|
|
108
107
|
const message = queries.createMessage(p.id, 'user', '[INJECTED] ' + p.content);
|
|
109
108
|
if (!entry) {
|
|
110
109
|
const agentId = conv.agentId || 'claude-code';
|
package/package.json
CHANGED
package/server.js
CHANGED
|
@@ -26,6 +26,7 @@ import { startAll as startACPTools, stopAll as stopACPTools, getStatus as getACP
|
|
|
26
26
|
import { installGMAgentConfigs } from './lib/gm-agent-configs.js';
|
|
27
27
|
import * as toolManager from './lib/tool-manager.js';
|
|
28
28
|
import { pm2Manager } from './lib/pm2-manager.js';
|
|
29
|
+
import CheckpointManager from './lib/checkpoint-manager.js';
|
|
29
30
|
|
|
30
31
|
|
|
31
32
|
process.on('uncaughtException', (err, origin) => {
|
|
@@ -294,6 +295,7 @@ const rateLimitState = new Map();
|
|
|
294
295
|
const activeProcessesByRunId = new Map();
|
|
295
296
|
const activeProcessesByConvId = new Map(); // Store process handles by conversationId for steering
|
|
296
297
|
const acpQueries = queries;
|
|
298
|
+
const checkpointManager = new CheckpointManager(queries);
|
|
297
299
|
const STUCK_AGENT_THRESHOLD_MS = 600000;
|
|
298
300
|
const NO_PID_GRACE_PERIOD_MS = 60000;
|
|
299
301
|
const DEFAULT_RATE_LIMIT_COOLDOWN_MS = 60000;
|
|
@@ -4543,8 +4545,14 @@ async function resumeInterruptedStreams() {
|
|
|
4543
4545
|
for (let i = 0; i < toResume.length; i++) {
|
|
4544
4546
|
const conv = toResume[i];
|
|
4545
4547
|
try {
|
|
4546
|
-
|
|
4547
|
-
|
|
4548
|
+
// Find previous incomplete sessions to load checkpoint from
|
|
4549
|
+
const previousSessions = [...queries.getSessionsByStatus(conv.id, 'active'), ...queries.getSessionsByStatus(conv.id, 'pending')];
|
|
4550
|
+
const previousSessionId = previousSessions.length > 0 ? previousSessions[0].id : null;
|
|
4551
|
+
|
|
4552
|
+
// Load checkpoint from previous session
|
|
4553
|
+
const checkpoint = previousSessionId ? checkpointManager.loadCheckpoint(previousSessionId) : null;
|
|
4554
|
+
|
|
4555
|
+
for (const s of previousSessions) {
|
|
4548
4556
|
queries.updateSession(s.id, { status: 'interrupted', error: 'Server restarted, resuming', completed_at: Date.now() });
|
|
4549
4557
|
}
|
|
4550
4558
|
|
|
@@ -4556,7 +4564,8 @@ async function resumeInterruptedStreams() {
|
|
|
4556
4564
|
queries.createEvent('session.created', {
|
|
4557
4565
|
sessionId: session.id,
|
|
4558
4566
|
resumeReason: 'server_restart',
|
|
4559
|
-
claudeSessionId: conv.claudeSessionId
|
|
4567
|
+
claudeSessionId: conv.claudeSessionId,
|
|
4568
|
+
checkpointFrom: previousSessionId
|
|
4560
4569
|
}, conv.id, session.id);
|
|
4561
4570
|
|
|
4562
4571
|
activeExecutions.set(conv.id, {
|
|
@@ -4572,11 +4581,35 @@ async function resumeInterruptedStreams() {
|
|
|
4572
4581
|
conversationId: conv.id,
|
|
4573
4582
|
agentId: conv.agentType,
|
|
4574
4583
|
resumed: true,
|
|
4584
|
+
checkpointAvailable: !!checkpoint,
|
|
4575
4585
|
timestamp: Date.now()
|
|
4576
4586
|
});
|
|
4577
4587
|
|
|
4588
|
+
// Inject checkpoint events before streaming starts
|
|
4589
|
+
if (checkpoint) {
|
|
4590
|
+
broadcastSync({
|
|
4591
|
+
type: 'streaming_resumed',
|
|
4592
|
+
sessionId: session.id,
|
|
4593
|
+
conversationId: conv.id,
|
|
4594
|
+
resumeFrom: previousSessionId,
|
|
4595
|
+
eventCount: checkpoint.events.length,
|
|
4596
|
+
chunkCount: checkpoint.chunks.length,
|
|
4597
|
+
timestamp: Date.now()
|
|
4598
|
+
});
|
|
4599
|
+
|
|
4600
|
+
checkpointManager.injectCheckpointEvents(session.id, checkpoint, (evt) => {
|
|
4601
|
+
broadcastSync({
|
|
4602
|
+
...evt,
|
|
4603
|
+
sessionId: session.id,
|
|
4604
|
+
conversationId: conv.id
|
|
4605
|
+
});
|
|
4606
|
+
});
|
|
4607
|
+
|
|
4608
|
+
checkpointManager.markSessionResumed(previousSessionId);
|
|
4609
|
+
}
|
|
4610
|
+
|
|
4578
4611
|
const messageId = lastMsg?.id || null;
|
|
4579
|
-
console.log(`[RESUME] Resuming conv ${conv.id} (claude session: ${conv.claudeSessionId})`);
|
|
4612
|
+
console.log(`[RESUME] Resuming conv ${conv.id} (claude session: ${conv.claudeSessionId}) with checkpoint=${!!checkpoint}`);
|
|
4580
4613
|
|
|
4581
4614
|
try {
|
|
4582
4615
|
await processMessageWithStreaming(conv.id, messageId, session.id, promptText, conv.agentType, conv.model, conv.subAgent);
|
package/static/js/client.js
CHANGED
|
@@ -79,6 +79,10 @@ class AgentGUIClient {
|
|
|
79
79
|
this._inflightRequests = new Map();
|
|
80
80
|
this._previousConvAbort = null;
|
|
81
81
|
|
|
82
|
+
// PHASE 2: Request Lifetime Tracking
|
|
83
|
+
this._loadInProgress = {}; // { [conversationId]: { requestId, abortController, timestamp, prevConversationId } }
|
|
84
|
+
this._currentRequestId = 0; // Auto-incrementing request counter
|
|
85
|
+
|
|
82
86
|
this._scrollKalman = typeof KalmanFilter !== 'undefined' ? new KalmanFilter({ processNoise: 50, measurementNoise: 100 }) : null;
|
|
83
87
|
this._scrollTarget = 0;
|
|
84
88
|
this._scrollAnimating = false;
|
|
@@ -2525,6 +2529,55 @@ class AgentGUIClient {
|
|
|
2525
2529
|
this.conversationCache.delete(conversationId);
|
|
2526
2530
|
}
|
|
2527
2531
|
|
|
2532
|
+
/**
|
|
2533
|
+
* PHASE 2: Create a new load request with lifetime tracking
|
|
2534
|
+
* Assigns unique requestId, tracks in _loadInProgress, returns abort signal
|
|
2535
|
+
* Automatically cancels previous loads to this conversation
|
|
2536
|
+
*/
|
|
2537
|
+
_makeLoadRequest(conversationId) {
|
|
2538
|
+
const requestId = ++this._currentRequestId;
|
|
2539
|
+
const abortController = new AbortController();
|
|
2540
|
+
|
|
2541
|
+
// Cancel previous request to this conversation
|
|
2542
|
+
if (this._loadInProgress[conversationId]) {
|
|
2543
|
+
const prevReq = this._loadInProgress[conversationId];
|
|
2544
|
+
try {
|
|
2545
|
+
prevReq.abortController.abort();
|
|
2546
|
+
} catch (e) {}
|
|
2547
|
+
}
|
|
2548
|
+
|
|
2549
|
+
this._loadInProgress[conversationId] = {
|
|
2550
|
+
requestId,
|
|
2551
|
+
abortController,
|
|
2552
|
+
timestamp: Date.now(),
|
|
2553
|
+
prevConversationId: this.state.currentConversation?.id
|
|
2554
|
+
};
|
|
2555
|
+
|
|
2556
|
+
return { requestId, abortController: abortController.signal };
|
|
2557
|
+
}
|
|
2558
|
+
|
|
2559
|
+
/**
|
|
2560
|
+
* PHASE 2: Verify request is still current before rendering
|
|
2561
|
+
* Returns true if requestId matches current load for this conversation
|
|
2562
|
+
* Returns false if newer request arrived, or request was cancelled
|
|
2563
|
+
*/
|
|
2564
|
+
_verifyRequestId(conversationId, requestId) {
|
|
2565
|
+
const current = this._loadInProgress[conversationId];
|
|
2566
|
+
if (!current) return false;
|
|
2567
|
+
if (current.requestId !== requestId) return false;
|
|
2568
|
+
return true;
|
|
2569
|
+
}
|
|
2570
|
+
|
|
2571
|
+
/**
|
|
2572
|
+
* PHASE 2: Complete/cleanup a load request
|
|
2573
|
+
*/
|
|
2574
|
+
_completeLoadRequest(conversationId, requestId) {
|
|
2575
|
+
const req = this._loadInProgress[conversationId];
|
|
2576
|
+
if (req && req.requestId === requestId) {
|
|
2577
|
+
delete this._loadInProgress[conversationId];
|
|
2578
|
+
}
|
|
2579
|
+
}
|
|
2580
|
+
|
|
2528
2581
|
async loadConversationMessages(conversationId) {
|
|
2529
2582
|
try {
|
|
2530
2583
|
if (this._previousConvAbort) {
|
|
@@ -3231,3 +3284,122 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|
|
3231
3284
|
if (typeof module !== 'undefined' && module.exports) {
|
|
3232
3285
|
module.exports = AgentGUIClient;
|
|
3233
3286
|
}
|
|
3287
|
+
// PHASE 2: Generate unique request ID
|
|
3288
|
+
_generateRequestId() {
|
|
3289
|
+
return ++this._currentRequestId;
|
|
3290
|
+
}
|
|
3291
|
+
|
|
3292
|
+
// PHASE 2: Make a load request with tracking
|
|
3293
|
+
_makeLoadRequest(conversationId) {
|
|
3294
|
+
const requestId = this._generateRequestId();
|
|
3295
|
+
const abortController = new AbortController();
|
|
3296
|
+
|
|
3297
|
+
// Cancel previous request for this conversation if exists
|
|
3298
|
+
const prev = this._loadInProgress[conversationId];
|
|
3299
|
+
if (prev?.abortController) {
|
|
3300
|
+
prev.abortController.abort();
|
|
3301
|
+
}
|
|
3302
|
+
|
|
3303
|
+
this._loadInProgress[conversationId] = {
|
|
3304
|
+
requestId,
|
|
3305
|
+
abortController,
|
|
3306
|
+
timestamp: Date.now(),
|
|
3307
|
+
conversationId
|
|
3308
|
+
};
|
|
3309
|
+
|
|
3310
|
+
return { requestId, abortController };
|
|
3311
|
+
}
|
|
3312
|
+
|
|
3313
|
+
// PHASE 2: Verify request is current before rendering
|
|
3314
|
+
_isCurrentRequest(conversationId, requestId) {
|
|
3315
|
+
const current = this._loadInProgress[conversationId];
|
|
3316
|
+
return current?.requestId === requestId;
|
|
3317
|
+
}
|
|
3318
|
+
|
|
3319
|
+
// PHASE 3: Queue WebSocket message based on priority
|
|
3320
|
+
_queueWebSocketMessage(data) {
|
|
3321
|
+
const highPriorityTypes = ['conversation_deleted', 'all_conversations_deleted'];
|
|
3322
|
+
|
|
3323
|
+
if (highPriorityTypes.includes(data.type)) {
|
|
3324
|
+
this._highPriorityQueue.push(data);
|
|
3325
|
+
} else {
|
|
3326
|
+
this._lowPriorityQueue.push(data);
|
|
3327
|
+
}
|
|
3328
|
+
}
|
|
3329
|
+
|
|
3330
|
+
// PHASE 3: Process queued WebSocket messages
|
|
3331
|
+
_drainMessageQueues() {
|
|
3332
|
+
// Process high-priority first (deletions)
|
|
3333
|
+
while (this._highPriorityQueue.length > 0) {
|
|
3334
|
+
const msg = this._highPriorityQueue.shift();
|
|
3335
|
+
this._processWebSocketMessageDirect(msg);
|
|
3336
|
+
}
|
|
3337
|
+
|
|
3338
|
+
// Then process low-priority (metadata)
|
|
3339
|
+
while (this._lowPriorityQueue.length > 0) {
|
|
3340
|
+
const msg = this._lowPriorityQueue.shift();
|
|
3341
|
+
this._processWebSocketMessageDirect(msg);
|
|
3342
|
+
}
|
|
3343
|
+
}
|
|
3344
|
+
|
|
3345
|
+
// PHASE 3: Direct WebSocket message processing (extracted from switch)
|
|
3346
|
+
_processWebSocketMessageDirect(data) {
|
|
3347
|
+
switch (data.type) {
|
|
3348
|
+
case 'streaming_start':
|
|
3349
|
+
this.handleStreamingStart(data).catch(e => console.error('handleStreamingStart error:', e));
|
|
3350
|
+
break;
|
|
3351
|
+
case 'streaming_progress':
|
|
3352
|
+
this.handleStreamingProgress(data);
|
|
3353
|
+
break;
|
|
3354
|
+
case 'streaming_complete':
|
|
3355
|
+
this.handleStreamingComplete(data);
|
|
3356
|
+
break;
|
|
3357
|
+
case 'streaming_error':
|
|
3358
|
+
this.handleStreamingError(data);
|
|
3359
|
+
break;
|
|
3360
|
+
case 'conversation_created':
|
|
3361
|
+
this.handleConversationCreated(data);
|
|
3362
|
+
break;
|
|
3363
|
+
case 'conversation_deleted':
|
|
3364
|
+
this.handleConversationDeleted(data);
|
|
3365
|
+
break;
|
|
3366
|
+
case 'all_conversations_deleted':
|
|
3367
|
+
this.handleAllConversationsDeleted(data);
|
|
3368
|
+
break;
|
|
3369
|
+
case 'message_created':
|
|
3370
|
+
this.handleMessageCreated(data);
|
|
3371
|
+
break;
|
|
3372
|
+
case 'conversation_updated':
|
|
3373
|
+
this.handleConversationUpdated(data);
|
|
3374
|
+
break;
|
|
3375
|
+
case 'message_updated':
|
|
3376
|
+
this.handleMessageUpdated(data);
|
|
3377
|
+
break;
|
|
3378
|
+
default:
|
|
3379
|
+
// Other types handled elsewhere
|
|
3380
|
+
break;
|
|
3381
|
+
}
|
|
3382
|
+
}
|
|
3383
|
+
|
|
3384
|
+
// PHASE 4: Track streaming event sequence
|
|
3385
|
+
_recordStreamingSequence(sessionId, sequence) {
|
|
3386
|
+
this._lastProcessedSequence[sessionId] = sequence;
|
|
3387
|
+
}
|
|
3388
|
+
|
|
3389
|
+
// PHASE 4: Verify streaming event is current and in-order
|
|
3390
|
+
_isValidStreamingEvent(event) {
|
|
3391
|
+
// Must be for current session
|
|
3392
|
+
if (event.sessionId !== this.state.currentSession?.id) {
|
|
3393
|
+
return false;
|
|
3394
|
+
}
|
|
3395
|
+
|
|
3396
|
+
// Check sequence number
|
|
3397
|
+
const lastSeq = this._lastProcessedSequence[event.sessionId] || -1;
|
|
3398
|
+
if (event.sequence !== undefined && event.sequence <= lastSeq) {
|
|
3399
|
+
return false; // Duplicate or out-of-order
|
|
3400
|
+
}
|
|
3401
|
+
|
|
3402
|
+
return true;
|
|
3403
|
+
}
|
|
3404
|
+
|
|
3405
|
+
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Request Manager - Phase 2: Request Lifetime Management
|
|
3
|
+
* Tracks in-flight requests with unique IDs, enables cancellation on navigation
|
|
4
|
+
* Prevents race conditions where older requests complete after newer ones
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
class RequestManager {
|
|
8
|
+
constructor() {
|
|
9
|
+
this._requestId = 0;
|
|
10
|
+
this._inflightRequests = new Map(); // requestId -> { conversationId, abortController, timestamp, priority }
|
|
11
|
+
this._activeLoadId = null; // Track which request is currently being rendered
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Start a new load request for a conversation
|
|
16
|
+
* Returns a request token that must be verified before rendering
|
|
17
|
+
*/
|
|
18
|
+
startLoadRequest(conversationId, priority = 'normal') {
|
|
19
|
+
const requestId = ++this._requestId;
|
|
20
|
+
const abortController = new AbortController();
|
|
21
|
+
|
|
22
|
+
this._inflightRequests.set(requestId, {
|
|
23
|
+
conversationId,
|
|
24
|
+
abortController,
|
|
25
|
+
timestamp: Date.now(),
|
|
26
|
+
priority,
|
|
27
|
+
status: 'pending'
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
requestId,
|
|
32
|
+
abortSignal: abortController.signal,
|
|
33
|
+
cancel: () => this._cancelRequest(requestId),
|
|
34
|
+
verify: () => this._verifyRequest(requestId, conversationId)
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Mark request as completed (allows rendering)
|
|
40
|
+
*/
|
|
41
|
+
completeRequest(requestId) {
|
|
42
|
+
const req = this._inflightRequests.get(requestId);
|
|
43
|
+
if (req) {
|
|
44
|
+
req.status = 'completed';
|
|
45
|
+
this._activeLoadId = requestId;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Verify request is still valid before rendering
|
|
51
|
+
* Returns true only if this is the most recent request for this conversation
|
|
52
|
+
*/
|
|
53
|
+
_verifyRequest(requestId, conversationId) {
|
|
54
|
+
const req = this._inflightRequests.get(requestId);
|
|
55
|
+
|
|
56
|
+
// Request not found or cancelled
|
|
57
|
+
if (!req) return false;
|
|
58
|
+
|
|
59
|
+
// Request is for different conversation
|
|
60
|
+
if (req.conversationId !== conversationId) return false;
|
|
61
|
+
|
|
62
|
+
// Find all requests for this conversation
|
|
63
|
+
const allForConv = Array.from(this._inflightRequests.entries())
|
|
64
|
+
.filter(([_, r]) => r.conversationId === conversationId && r.status === 'completed')
|
|
65
|
+
.sort((a, b) => b[0] - a[0]); // Sort by requestId descending (newest first)
|
|
66
|
+
|
|
67
|
+
// This request is the newest completed one for this conversation
|
|
68
|
+
return allForConv.length > 0 && allForConv[0][0] === requestId;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Cancel a request (aborts any pending network operations)
|
|
73
|
+
*/
|
|
74
|
+
_cancelRequest(requestId) {
|
|
75
|
+
const req = this._inflightRequests.get(requestId);
|
|
76
|
+
if (req) {
|
|
77
|
+
req.status = 'cancelled';
|
|
78
|
+
req.abortController.abort();
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Cancel all pending requests for a conversation
|
|
84
|
+
*/
|
|
85
|
+
cancelConversationRequests(conversationId) {
|
|
86
|
+
for (const [id, req] of this._inflightRequests.entries()) {
|
|
87
|
+
if (req.conversationId === conversationId && req.status !== 'completed') {
|
|
88
|
+
this._cancelRequest(id);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Cancel all in-flight requests
|
|
95
|
+
*/
|
|
96
|
+
cancelAllRequests() {
|
|
97
|
+
for (const [id, req] of this._inflightRequests.entries()) {
|
|
98
|
+
if (req.status !== 'completed') {
|
|
99
|
+
this._cancelRequest(id);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Clean up old requests to prevent memory leak
|
|
106
|
+
*/
|
|
107
|
+
cleanup() {
|
|
108
|
+
const now = Date.now();
|
|
109
|
+
const maxAge = 60000; // Keep requests for 60 seconds
|
|
110
|
+
|
|
111
|
+
for (const [id, req] of this._inflightRequests.entries()) {
|
|
112
|
+
if (now - req.timestamp > maxAge) {
|
|
113
|
+
this._inflightRequests.delete(id);
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Get debug info about in-flight requests
|
|
120
|
+
*/
|
|
121
|
+
getDebugInfo() {
|
|
122
|
+
return {
|
|
123
|
+
activeLoadId: this._activeLoadId,
|
|
124
|
+
inflightRequests: Array.from(this._inflightRequests.entries()).map(([id, req]) => ({
|
|
125
|
+
requestId: id,
|
|
126
|
+
conversationId: req.conversationId,
|
|
127
|
+
timestamp: req.timestamp,
|
|
128
|
+
status: req.status,
|
|
129
|
+
priority: req.priority,
|
|
130
|
+
age: Date.now() - req.timestamp
|
|
131
|
+
}))
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (typeof window !== 'undefined') {
|
|
137
|
+
window.RequestManager = new RequestManager();
|
|
138
|
+
}
|