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.
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.596",
3
+ "version": "1.0.598",
4
4
  "description": "Multi-agent ACP client with real-time communication",
5
5
  "type": "module",
6
6
  "main": "server.js",
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
- const staleSessions = [...queries.getSessionsByStatus(conv.id, 'active'), ...queries.getSessionsByStatus(conv.id, 'pending')];
4547
- for (const s of staleSessions) {
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);
@@ -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
+ }