agentgui 1.0.596 → 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.
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agentgui",
3
- "version": "1.0.596",
3
+ "version": "1.0.597",
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;
@@ -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
- const staleSessions = [...queries.getSessionsByStatus(conv.id, 'active'), ...queries.getSessionsByStatus(conv.id, 'pending')];
4547
- for (const s of staleSessions) {
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);
@@ -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
+ }