agentgui 1.0.596 → 1.0.598
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 +182 -0
- package/package.json +1 -1
- package/server.js +53 -4
- package/static/js/client.js +119 -0
- package/static/js/request-manager.js +138 -0
|
@@ -0,0 +1,182 @@
|
|
|
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
|
+
this._pendingCheckpoints = new Map(); // Store checkpoints to inject on subscribe: { conversationId -> checkpoint }
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Load checkpoint for a session (all events + chunks from previous session)
|
|
16
|
+
* Used when resuming after interruption
|
|
17
|
+
*/
|
|
18
|
+
loadCheckpoint(previousSessionId) {
|
|
19
|
+
if (!previousSessionId) return null;
|
|
20
|
+
|
|
21
|
+
try {
|
|
22
|
+
const session = this.queries.getSession(previousSessionId);
|
|
23
|
+
if (!session) return null;
|
|
24
|
+
|
|
25
|
+
const events = this.queries.getSessionEvents(previousSessionId);
|
|
26
|
+
const chunks = this.queries.getChunksSinceSeq(previousSessionId, -1);
|
|
27
|
+
|
|
28
|
+
return {
|
|
29
|
+
sessionId: previousSessionId,
|
|
30
|
+
conversationId: session.conversationId,
|
|
31
|
+
events: events || [],
|
|
32
|
+
chunks: chunks || [],
|
|
33
|
+
lastSequence: chunks.length > 0 ? Math.max(...chunks.map(c => c.sequence)) : -1
|
|
34
|
+
};
|
|
35
|
+
} catch (e) {
|
|
36
|
+
console.error(`[checkpoint] Failed to load checkpoint for session ${previousSessionId}:`, e.message);
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Inject checkpoint events into the new session
|
|
43
|
+
* Marks them with resumeOrigin to prevent replay
|
|
44
|
+
* Returns the next sequence number to use
|
|
45
|
+
*/
|
|
46
|
+
injectCheckpointEvents(newSessionId, checkpoint, broadcastFn) {
|
|
47
|
+
if (!checkpoint || !checkpoint.events || checkpoint.events.length === 0) {
|
|
48
|
+
return -1;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// Prevent double-injection for same session
|
|
52
|
+
const injectionKey = `${newSessionId}:checkpoint`;
|
|
53
|
+
if (this._injectedSessions.has(injectionKey)) {
|
|
54
|
+
console.log(`[checkpoint] Session ${newSessionId} already had checkpoint injected, skipping`);
|
|
55
|
+
return checkpoint.lastSequence;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
let sequenceStart = checkpoint.lastSequence + 1;
|
|
59
|
+
|
|
60
|
+
try {
|
|
61
|
+
// Broadcast each checkpoint event as if it's arriving now
|
|
62
|
+
for (const evt of checkpoint.events) {
|
|
63
|
+
// Skip internal session management events
|
|
64
|
+
if (evt.type === 'session.created') continue;
|
|
65
|
+
|
|
66
|
+
// Re-broadcast with resume markers
|
|
67
|
+
broadcastFn({
|
|
68
|
+
...evt,
|
|
69
|
+
resumeOrigin: 'checkpoint',
|
|
70
|
+
originalSessionId: checkpoint.sessionId,
|
|
71
|
+
newSessionId: newSessionId,
|
|
72
|
+
timestamp: Date.now()
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Mark this session as having been injected
|
|
77
|
+
this._injectedSessions.add(injectionKey);
|
|
78
|
+
|
|
79
|
+
console.log(
|
|
80
|
+
`[checkpoint] Injected ${checkpoint.events.length} events from session ` +
|
|
81
|
+
`${checkpoint.sessionId} into new session ${newSessionId}`
|
|
82
|
+
);
|
|
83
|
+
|
|
84
|
+
return sequenceStart;
|
|
85
|
+
} catch (e) {
|
|
86
|
+
console.error(`[checkpoint] Failed to inject checkpoint events:`, e.message);
|
|
87
|
+
return checkpoint.lastSequence;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Copy checkpoint chunks to new session with modified sequence
|
|
93
|
+
* Ensures chunks are marked as injected to distinguish from new streaming
|
|
94
|
+
*/
|
|
95
|
+
copyCheckpointChunks(oldSessionId, newSessionId, startSequence = 0) {
|
|
96
|
+
try {
|
|
97
|
+
const chunks = this.queries.getChunksSinceSeq(oldSessionId, -1);
|
|
98
|
+
if (!chunks || chunks.length === 0) return 0;
|
|
99
|
+
|
|
100
|
+
let copiedCount = 0;
|
|
101
|
+
|
|
102
|
+
for (let i = 0; i < chunks.length; i++) {
|
|
103
|
+
const chunk = chunks[i];
|
|
104
|
+
const newSequence = startSequence + i;
|
|
105
|
+
|
|
106
|
+
try {
|
|
107
|
+
this.queries.createChunk(
|
|
108
|
+
newSessionId,
|
|
109
|
+
chunk.conversationId,
|
|
110
|
+
newSequence,
|
|
111
|
+
chunk.type,
|
|
112
|
+
{ ...chunk.data, resumeOrigin: 'checkpoint' }
|
|
113
|
+
);
|
|
114
|
+
copiedCount++;
|
|
115
|
+
} catch (e) {
|
|
116
|
+
console.error(`[checkpoint] Failed to copy chunk ${i}:`, e.message);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
console.log(`[checkpoint] Copied ${copiedCount} chunks from ${oldSessionId} to ${newSessionId}`);
|
|
121
|
+
return startSequence + copiedCount;
|
|
122
|
+
} catch (e) {
|
|
123
|
+
console.error(`[checkpoint] Failed to copy checkpoint chunks:`, e.message);
|
|
124
|
+
return startSequence;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Clean up: mark previous session as properly resumed
|
|
130
|
+
* Prevents re-resuming the same interrupted session multiple times
|
|
131
|
+
*/
|
|
132
|
+
markSessionResumed(previousSessionId) {
|
|
133
|
+
try {
|
|
134
|
+
this.queries.updateSession(previousSessionId, {
|
|
135
|
+
status: 'resumed',
|
|
136
|
+
completed_at: Date.now()
|
|
137
|
+
});
|
|
138
|
+
console.log(`[checkpoint] Marked session ${previousSessionId} as resumed`);
|
|
139
|
+
} catch (e) {
|
|
140
|
+
console.error(`[checkpoint] Failed to mark session as resumed:`, e.message);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* Clear injected sessions cache (call on server restart)
|
|
146
|
+
*/
|
|
147
|
+
reset() {
|
|
148
|
+
this._injectedSessions.clear();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Store a checkpoint to be injected when client subscribes
|
|
153
|
+
* (Used during server startup when no clients are connected yet)
|
|
154
|
+
*/
|
|
155
|
+
storeCheckpointForDelay(conversationId, checkpoint) {
|
|
156
|
+
if (checkpoint && checkpoint.events && checkpoint.events.length > 0) {
|
|
157
|
+
this._pendingCheckpoints.set(conversationId, checkpoint);
|
|
158
|
+
console.log(`[checkpoint] Stored checkpoint for ${conversationId} to inject on subscribe (${checkpoint.events.length} events, ${checkpoint.chunks.length} chunks)`);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Get and remove a pending checkpoint (called when client subscribes)
|
|
164
|
+
*/
|
|
165
|
+
getPendingCheckpoint(conversationId) {
|
|
166
|
+
const checkpoint = this._pendingCheckpoints.get(conversationId);
|
|
167
|
+
if (checkpoint) {
|
|
168
|
+
this._pendingCheckpoints.delete(conversationId);
|
|
169
|
+
console.log(`[checkpoint] Retrieved pending checkpoint for ${conversationId}`);
|
|
170
|
+
}
|
|
171
|
+
return checkpoint;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/**
|
|
175
|
+
* Check if there's a pending checkpoint for a conversation
|
|
176
|
+
*/
|
|
177
|
+
hasPendingCheckpoint(conversationId) {
|
|
178
|
+
return this._pendingCheckpoints.has(conversationId);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export default CheckpointManager;
|
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;
|
|
@@ -4240,6 +4242,38 @@ wsRouter.onLegacy((data, ws) => {
|
|
|
4240
4242
|
conversationId: data.conversationId,
|
|
4241
4243
|
timestamp: Date.now()
|
|
4242
4244
|
}));
|
|
4245
|
+
|
|
4246
|
+
// Inject pending checkpoint events if this is a conversation subscription
|
|
4247
|
+
if (data.conversationId && checkpointManager.hasPendingCheckpoint(data.conversationId)) {
|
|
4248
|
+
const checkpoint = checkpointManager.getPendingCheckpoint(data.conversationId);
|
|
4249
|
+
if (checkpoint) {
|
|
4250
|
+
console.log(`[checkpoint] Injecting ${checkpoint.events.length} events to client for ${data.conversationId}`);
|
|
4251
|
+
|
|
4252
|
+
// Get the session to use for the injection
|
|
4253
|
+
const latestSession = queries.getLatestSession(data.conversationId);
|
|
4254
|
+
if (latestSession) {
|
|
4255
|
+
// Send streaming_resumed event first
|
|
4256
|
+
ws.send(JSON.stringify({
|
|
4257
|
+
type: 'streaming_resumed',
|
|
4258
|
+
sessionId: latestSession.id,
|
|
4259
|
+
conversationId: data.conversationId,
|
|
4260
|
+
resumeFrom: checkpoint.sessionId,
|
|
4261
|
+
eventCount: checkpoint.events.length,
|
|
4262
|
+
chunkCount: checkpoint.chunks.length,
|
|
4263
|
+
timestamp: Date.now()
|
|
4264
|
+
}));
|
|
4265
|
+
|
|
4266
|
+
// Inject each checkpoint event
|
|
4267
|
+
checkpointManager.injectCheckpointEvents(latestSession.id, checkpoint, (evt) => {
|
|
4268
|
+
ws.send(JSON.stringify({
|
|
4269
|
+
...evt,
|
|
4270
|
+
sessionId: latestSession.id,
|
|
4271
|
+
conversationId: data.conversationId
|
|
4272
|
+
}));
|
|
4273
|
+
});
|
|
4274
|
+
}
|
|
4275
|
+
}
|
|
4276
|
+
}
|
|
4243
4277
|
} else if (data.type === 'unsubscribe') {
|
|
4244
4278
|
if (data.sessionId) {
|
|
4245
4279
|
ws.subscriptions.delete(data.sessionId);
|
|
@@ -4543,8 +4577,14 @@ async function resumeInterruptedStreams() {
|
|
|
4543
4577
|
for (let i = 0; i < toResume.length; i++) {
|
|
4544
4578
|
const conv = toResume[i];
|
|
4545
4579
|
try {
|
|
4546
|
-
|
|
4547
|
-
|
|
4580
|
+
// Find previous incomplete sessions to load checkpoint from
|
|
4581
|
+
const previousSessions = [...queries.getSessionsByStatus(conv.id, 'active'), ...queries.getSessionsByStatus(conv.id, 'pending')];
|
|
4582
|
+
const previousSessionId = previousSessions.length > 0 ? previousSessions[0].id : null;
|
|
4583
|
+
|
|
4584
|
+
// Load checkpoint from previous session
|
|
4585
|
+
const checkpoint = previousSessionId ? checkpointManager.loadCheckpoint(previousSessionId) : null;
|
|
4586
|
+
|
|
4587
|
+
for (const s of previousSessions) {
|
|
4548
4588
|
queries.updateSession(s.id, { status: 'interrupted', error: 'Server restarted, resuming', completed_at: Date.now() });
|
|
4549
4589
|
}
|
|
4550
4590
|
|
|
@@ -4556,7 +4596,8 @@ async function resumeInterruptedStreams() {
|
|
|
4556
4596
|
queries.createEvent('session.created', {
|
|
4557
4597
|
sessionId: session.id,
|
|
4558
4598
|
resumeReason: 'server_restart',
|
|
4559
|
-
claudeSessionId: conv.claudeSessionId
|
|
4599
|
+
claudeSessionId: conv.claudeSessionId,
|
|
4600
|
+
checkpointFrom: previousSessionId
|
|
4560
4601
|
}, conv.id, session.id);
|
|
4561
4602
|
|
|
4562
4603
|
activeExecutions.set(conv.id, {
|
|
@@ -4572,11 +4613,19 @@ async function resumeInterruptedStreams() {
|
|
|
4572
4613
|
conversationId: conv.id,
|
|
4573
4614
|
agentId: conv.agentType,
|
|
4574
4615
|
resumed: true,
|
|
4616
|
+
checkpointAvailable: !!checkpoint,
|
|
4575
4617
|
timestamp: Date.now()
|
|
4576
4618
|
});
|
|
4577
4619
|
|
|
4620
|
+
// Store checkpoint to inject when client subscribes (not now, since no clients connected yet)
|
|
4621
|
+
if (checkpoint) {
|
|
4622
|
+
checkpointManager.storeCheckpointForDelay(conv.id, checkpoint);
|
|
4623
|
+
checkpointManager.markSessionResumed(previousSessionId);
|
|
4624
|
+
console.log(`[RESUME] Checkpoint stored for ${conv.id}, will inject on next client subscribe`);
|
|
4625
|
+
}
|
|
4626
|
+
|
|
4578
4627
|
const messageId = lastMsg?.id || null;
|
|
4579
|
-
console.log(`[RESUME] Resuming conv ${conv.id} (claude session: ${conv.claudeSessionId})`);
|
|
4628
|
+
console.log(`[RESUME] Resuming conv ${conv.id} (claude session: ${conv.claudeSessionId}) with checkpoint=${!!checkpoint}`);
|
|
4580
4629
|
|
|
4581
4630
|
try {
|
|
4582
4631
|
await processMessageWithStreaming(conv.id, messageId, session.id, promptText, conv.agentType, conv.model, conv.subAgent);
|
package/static/js/client.js
CHANGED
|
@@ -3284,3 +3284,122 @@ document.addEventListener('DOMContentLoaded', async () => {
|
|
|
3284
3284
|
if (typeof module !== 'undefined' && module.exports) {
|
|
3285
3285
|
module.exports = AgentGUIClient;
|
|
3286
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
|
+
}
|