@probelabs/probe 0.6.0-rc215 → 0.6.0-rc217

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/build/delegate.js CHANGED
@@ -22,12 +22,18 @@ class DelegationManager {
22
22
  constructor() {
23
23
  this.maxConcurrent = parseInt(process.env.MAX_CONCURRENT_DELEGATIONS || '3', 10);
24
24
  this.maxPerSession = parseInt(process.env.MAX_DELEGATIONS_PER_SESSION || '10', 10);
25
+ // Default queue timeout: 60 seconds. Set DELEGATION_QUEUE_TIMEOUT=0 to disable.
26
+ this.defaultQueueTimeout = parseInt(process.env.DELEGATION_QUEUE_TIMEOUT || '60000', 10);
25
27
 
26
28
  // Track delegations per session with timestamp for potential TTL cleanup
27
29
  // Map<string, { count: number, lastUpdated: number }>
28
30
  this.sessionDelegations = new Map();
29
31
  this.globalActive = 0;
30
32
 
33
+ // Queue for waiting delegations (FIFO)
34
+ // Each entry: { resolve, reject, parentSessionId, queuedAt, timeoutId }
35
+ this.waitQueue = [];
36
+
31
37
  // Start periodic cleanup of stale sessions (every 5 minutes)
32
38
  // Wrapped in try-catch to prevent interval errors from crashing the process
33
39
  this.cleanupInterval = setInterval(() => {
@@ -47,6 +53,7 @@ class DelegationManager {
47
53
  /**
48
54
  * Check limits and increment counters (synchronous, atomic in Node.js event loop)
49
55
  * @param {string|null|undefined} parentSessionId - Parent session ID for tracking
56
+ * @returns {boolean} true if acquired, false if would need to wait
50
57
  */
51
58
  tryAcquire(parentSessionId) {
52
59
  // Validate parentSessionId parameter
@@ -54,9 +61,9 @@ class DelegationManager {
54
61
  throw new TypeError('parentSessionId must be a string, null, or undefined');
55
62
  }
56
63
 
57
- // Check global limit
64
+ // Check global limit - return false instead of throwing
58
65
  if (this.globalActive >= this.maxConcurrent) {
59
- throw new Error(`Maximum concurrent delegations (${this.maxConcurrent}) reached. Please wait for some delegations to complete.`);
66
+ return false;
60
67
  }
61
68
 
62
69
  // Check per-session limit
@@ -70,6 +77,16 @@ class DelegationManager {
70
77
  }
71
78
 
72
79
  // Increment counters (atomic in single-threaded Node.js)
80
+ this._incrementCounters(parentSessionId);
81
+
82
+ return true;
83
+ }
84
+
85
+ /**
86
+ * Internal helper to increment counters
87
+ * @private
88
+ */
89
+ _incrementCounters(parentSessionId) {
73
90
  this.globalActive++;
74
91
 
75
92
  if (parentSessionId) {
@@ -84,12 +101,73 @@ class DelegationManager {
84
101
  });
85
102
  }
86
103
  }
104
+ }
87
105
 
88
- return true;
106
+ /**
107
+ * Acquire a delegation slot, waiting in queue if necessary
108
+ * @param {string|null|undefined} parentSessionId - Parent session ID for tracking
109
+ * @param {boolean} debug - Enable debug logging
110
+ * @param {number|null} queueTimeout - Max time to wait in queue (ms). Defaults to this.defaultQueueTimeout. Set to 0 to disable.
111
+ * @returns {Promise<boolean>} Resolves when slot is acquired, rejects on timeout or session limit error
112
+ */
113
+ async acquire(parentSessionId, debug = false, queueTimeout = null) {
114
+ // Use instance default if not specified
115
+ const effectiveTimeout = queueTimeout !== null ? queueTimeout : this.defaultQueueTimeout;
116
+ // Try immediate acquisition first
117
+ if (this.tryAcquire(parentSessionId)) {
118
+ return true;
119
+ }
120
+
121
+ // Need to wait in queue
122
+ if (debug) {
123
+ console.error(`[DelegationManager] Slot unavailable (${this.globalActive}/${this.maxConcurrent}), queuing... (queue size: ${this.waitQueue.length}, timeout: ${effectiveTimeout}ms)`);
124
+ }
125
+
126
+ // Create a promise that will be resolved when a slot becomes available
127
+ // or rejected if session limit is exceeded or queue timeout expires
128
+ return new Promise((resolve, reject) => {
129
+ const entry = {
130
+ resolve: null, // Will be wrapped below
131
+ reject: null, // Will be wrapped below
132
+ parentSessionId,
133
+ debug,
134
+ queuedAt: Date.now(),
135
+ timeoutId: null
136
+ };
137
+
138
+ // Wrap resolve/reject to clear timeout and prevent double-settling
139
+ let settled = false;
140
+ entry.resolve = (value) => {
141
+ if (settled) return;
142
+ settled = true;
143
+ if (entry.timeoutId) clearTimeout(entry.timeoutId);
144
+ resolve(value);
145
+ };
146
+ entry.reject = (error) => {
147
+ if (settled) return;
148
+ settled = true;
149
+ if (entry.timeoutId) clearTimeout(entry.timeoutId);
150
+ reject(error);
151
+ };
152
+
153
+ // Set up queue timeout if enabled
154
+ if (effectiveTimeout > 0) {
155
+ entry.timeoutId = setTimeout(() => {
156
+ // Remove from queue if still there
157
+ const index = this.waitQueue.indexOf(entry);
158
+ if (index !== -1) {
159
+ this.waitQueue.splice(index, 1);
160
+ }
161
+ entry.reject(new Error(`Delegation queue timeout: waited ${effectiveTimeout}ms for an available slot`));
162
+ }, effectiveTimeout);
163
+ }
164
+
165
+ this.waitQueue.push(entry);
166
+ });
89
167
  }
90
168
 
91
169
  /**
92
- * Decrement counters (synchronous, atomic in Node.js event loop)
170
+ * Decrement counters and process queue (synchronous, atomic in Node.js event loop)
93
171
  */
94
172
  release(parentSessionId, debug = false) {
95
173
  this.globalActive = Math.max(0, this.globalActive - 1);
@@ -107,7 +185,52 @@ class DelegationManager {
107
185
  }
108
186
 
109
187
  if (debug) {
110
- console.error(`[DELEGATE] Released. Global active: ${this.globalActive}`);
188
+ console.error(`[DELEGATE] Released. Global active: ${this.globalActive}, queue size: ${this.waitQueue.length}`);
189
+ }
190
+
191
+ // Process next item in queue if there's capacity
192
+ this._processQueue(debug);
193
+ }
194
+
195
+ /**
196
+ * Process the wait queue - grant slot to next waiting delegation
197
+ * @private
198
+ */
199
+ _processQueue(debug = false) {
200
+ // Process queue items one at a time when slots are available
201
+ // Items are only removed when they can be granted or must be rejected
202
+ while (this.waitQueue.length > 0 && this.globalActive < this.maxConcurrent) {
203
+ const next = this.waitQueue.shift();
204
+ if (!next) break;
205
+
206
+ const { resolve, reject, parentSessionId, queuedAt } = next;
207
+
208
+ // Check per-session limit before granting
209
+ if (parentSessionId) {
210
+ const sessionData = this.sessionDelegations.get(parentSessionId);
211
+ const sessionCount = sessionData?.count || 0;
212
+
213
+ if (sessionCount >= this.maxPerSession) {
214
+ // Session limit reached - reject with error (consistent with tryAcquire behavior)
215
+ // This is a hard limit, not something that will resolve by waiting longer
216
+ if (debug) {
217
+ console.error(`[DelegationManager] Session limit (${this.maxPerSession}) reached for queued item, rejecting`);
218
+ }
219
+ reject(new Error(`Maximum delegations per session (${this.maxPerSession}) reached for session ${parentSessionId}`));
220
+ // Continue to process next item in queue
221
+ continue;
222
+ }
223
+ }
224
+
225
+ // Grant the slot
226
+ this._incrementCounters(parentSessionId);
227
+
228
+ if (debug) {
229
+ const waitTime = Date.now() - queuedAt;
230
+ console.error(`[DelegationManager] Granted slot from queue (waited ${waitTime}ms). Active: ${this.globalActive}/${this.maxConcurrent}`);
231
+ }
232
+
233
+ resolve(true);
111
234
  }
112
235
  }
113
236
 
@@ -119,7 +242,9 @@ class DelegationManager {
119
242
  globalActive: this.globalActive,
120
243
  maxConcurrent: this.maxConcurrent,
121
244
  maxPerSession: this.maxPerSession,
122
- sessionCount: this.sessionDelegations.size
245
+ defaultQueueTimeout: this.defaultQueueTimeout,
246
+ sessionCount: this.sessionDelegations.size,
247
+ queueSize: this.waitQueue.length
123
248
  };
124
249
  }
125
250
 
@@ -143,13 +268,30 @@ class DelegationManager {
143
268
  clearInterval(this.cleanupInterval);
144
269
  this.cleanupInterval = null;
145
270
  }
271
+
272
+ // Clear all pending queue entries and their timeouts
273
+ for (const entry of this.waitQueue) {
274
+ if (entry.timeoutId) {
275
+ clearTimeout(entry.timeoutId);
276
+ }
277
+ // Reject pending entries so they don't hang
278
+ if (entry.reject) {
279
+ entry.reject(new Error('DelegationManager was cleaned up'));
280
+ }
281
+ }
282
+ this.waitQueue = [];
283
+
146
284
  this.sessionDelegations.clear();
147
285
  this.globalActive = 0;
148
286
  }
149
287
  }
150
288
 
151
- // Singleton instance for the module
152
- const delegationManager = new DelegationManager();
289
+ // Default singleton instance for backward compatibility
290
+ // New code should create per-instance DelegationManager via ProbeAgent
291
+ const defaultDelegationManager = new DelegationManager();
292
+
293
+ // Export the class for per-instance usage
294
+ export { DelegationManager };
153
295
 
154
296
  /**
155
297
  * Delegate a big distinct task to a probe subagent (used automatically by AI agents)
@@ -214,12 +356,16 @@ export async function delegate({
214
356
  enableTasks = false,
215
357
  enableMcp = false,
216
358
  mcpConfig = null,
217
- mcpConfigPath = null
359
+ mcpConfigPath = null,
360
+ delegationManager = null // Optional per-instance manager, falls back to default singleton
218
361
  }) {
219
362
  if (!task || typeof task !== 'string') {
220
363
  throw new Error('Task parameter is required and must be a string');
221
364
  }
222
365
 
366
+ // Use provided manager or fall back to default singleton
367
+ const manager = delegationManager || defaultDelegationManager;
368
+
223
369
  const sessionId = randomUUID();
224
370
  const startTime = Date.now();
225
371
 
@@ -235,19 +381,19 @@ export async function delegate({
235
381
  let acquired = false;
236
382
 
237
383
  try {
238
- // Check limits and acquire delegation slot inside try block for proper cleanup
239
- delegationManager.tryAcquire(parentSessionId);
384
+ // Acquire delegation slot (waits in queue if necessary)
385
+ await manager.acquire(parentSessionId, debug);
240
386
  acquired = true;
241
387
 
242
388
  if (debug) {
243
- const stats = delegationManager.getStats();
389
+ const stats = manager.getStats();
244
390
  console.error(`[DELEGATE] Starting delegation session ${sessionId}`);
245
391
  console.error(`[DELEGATE] Parent session: ${parentSessionId || 'none'}`);
246
392
  console.error(`[DELEGATE] Task: ${task}`);
247
393
  console.error(`[DELEGATE] Current iteration: ${currentIteration}/${maxIterations}`);
248
394
  console.error(`[DELEGATE] Remaining iterations for subagent: ${remainingIterations}`);
249
395
  console.error(`[DELEGATE] Timeout configured: ${timeout} seconds`);
250
- console.error(`[DELEGATE] Global active delegations: ${stats.globalActive}/${stats.maxConcurrent}`);
396
+ console.error(`[DELEGATE] Global active delegations: ${stats.globalActive}/${stats.maxConcurrent}, queue: ${stats.queueSize}`);
251
397
  console.error(`[DELEGATE] Using ProbeAgent SDK with ${promptType} prompt`);
252
398
  }
253
399
  // Create a new ProbeAgent instance for the delegated task
@@ -360,7 +506,7 @@ export async function delegate({
360
506
 
361
507
  // Release delegation slot
362
508
  if (acquired) {
363
- delegationManager.release(parentSessionId, debug);
509
+ manager.release(parentSessionId, debug);
364
510
  }
365
511
 
366
512
  return response;
@@ -376,7 +522,7 @@ export async function delegate({
376
522
 
377
523
  // Release delegation slot on error (only if it was acquired)
378
524
  if (acquired) {
379
- delegationManager.release(parentSessionId, debug);
525
+ manager.release(parentSessionId, debug);
380
526
  }
381
527
 
382
528
  if (debug) {
@@ -423,16 +569,20 @@ export async function isDelegateAvailable() {
423
569
 
424
570
  /**
425
571
  * Get delegation statistics (for monitoring/debugging)
572
+ * Note: Returns stats from the default singleton manager.
573
+ * For per-instance stats, use manager.getStats() directly.
426
574
  *
427
575
  * @returns {Object} Current delegation stats
428
576
  */
429
577
  export function getDelegationStats() {
430
- return delegationManager.getStats();
578
+ return defaultDelegationManager.getStats();
431
579
  }
432
580
 
433
581
  /**
434
582
  * Cleanup delegation manager (for testing or shutdown)
583
+ * Note: Cleans up the default singleton manager.
584
+ * For per-instance cleanup, use manager.cleanup() directly.
435
585
  */
436
586
  export async function cleanupDelegationManager() {
437
- return delegationManager.cleanup();
587
+ return defaultDelegationManager.cleanup();
438
588
  }
@@ -192,7 +192,8 @@ Instructions:
192
192
  enableBash: false,
193
193
  promptType: 'code-researcher',
194
194
  allowedTools: ['extract'],
195
- maxIterations: 5
195
+ maxIterations: 5,
196
+ delegationManager: options.delegationManager // Per-instance delegation limits
196
197
  // timeout removed - inherit default from delegate (300s)
197
198
  });
198
199
 
@@ -327,7 +328,8 @@ Organize all findings into clear categories with items listed under each.${compl
327
328
  enableBash: false,
328
329
  promptType: 'code-researcher',
329
330
  allowedTools: [],
330
- maxIterations: 5
331
+ maxIterations: 5,
332
+ delegationManager: options.delegationManager // Per-instance delegation limits
331
333
  // timeout removed - inherit default from delegate (300s)
332
334
  });
333
335
 
@@ -402,7 +404,8 @@ CRITICAL: Do NOT guess keywords. Actually run searches and see what returns resu
402
404
  enableBash: false,
403
405
  promptType: 'code-researcher',
404
406
  // Full tool access for exploration and experimentation
405
- maxIterations: 15
407
+ maxIterations: 15,
408
+ delegationManager: options.delegationManager // Per-instance delegation limits
406
409
  // timeout removed - inherit default from delegate (300s)
407
410
  });
408
411
 
@@ -472,7 +475,8 @@ IMPORTANT: When completing, use the FULL format: <attempt_completion><result>YOU
472
475
  enableBash: false,
473
476
  promptType: 'code-researcher',
474
477
  allowedTools: [],
475
- maxIterations: 5
478
+ maxIterations: 5,
479
+ delegationManager: options.delegationManager // Per-instance delegation limits
476
480
  // timeout removed - inherit default from delegate (300s)
477
481
  });
478
482
 
@@ -515,7 +519,8 @@ export async function analyzeAll(options) {
515
519
  model,
516
520
  tracer,
517
521
  chunkSizeTokens = DEFAULT_CHUNK_SIZE_TOKENS,
518
- maxChunks = MAX_CHUNKS
522
+ maxChunks = MAX_CHUNKS,
523
+ delegationManager = null // Per-instance delegation limits
519
524
  } = options;
520
525
 
521
526
  if (!question) {
@@ -529,7 +534,8 @@ export async function analyzeAll(options) {
529
534
  allowedFolders,
530
535
  provider,
531
536
  model,
532
- tracer
537
+ tracer,
538
+ delegationManager // Per-instance delegation limits
533
539
  };
534
540
 
535
541
  // ============================================================
@@ -470,7 +470,7 @@ export const extractTool = (options = {}) => {
470
470
  * @returns {Object} Configured delegate tool
471
471
  */
472
472
  export const delegateTool = (options = {}) => {
473
- const { debug = false, timeout = 300, cwd, allowedFolders, enableBash = false, bashConfig, architectureFileName, enableMcp = false, mcpConfig = null, mcpConfigPath = null } = options;
473
+ const { debug = false, timeout = 300, cwd, allowedFolders, enableBash = false, bashConfig, architectureFileName, enableMcp = false, mcpConfig = null, mcpConfigPath = null, delegationManager = null } = options;
474
474
 
475
475
  return tool({
476
476
  name: 'delegate',
@@ -562,7 +562,8 @@ export const delegateTool = (options = {}) => {
562
562
  searchDelegate,
563
563
  enableMcp,
564
564
  mcpConfig,
565
- mcpConfigPath
565
+ mcpConfigPath,
566
+ delegationManager // Per-instance delegation limits
566
567
  });
567
568
 
568
569
  return result;
@@ -584,7 +585,7 @@ export const delegateTool = (options = {}) => {
584
585
  * @returns {Object} Configured analyze_all tool
585
586
  */
586
587
  export const analyzeAllTool = (options = {}) => {
587
- const { sessionId, debug = false } = options;
588
+ const { sessionId, debug = false, delegationManager = null } = options;
588
589
 
589
590
  return tool({
590
591
  name: 'analyze_all',
@@ -616,7 +617,8 @@ export const analyzeAllTool = (options = {}) => {
616
617
  allowedFolders: options.allowedFolders,
617
618
  provider: options.provider,
618
619
  model: options.model,
619
- tracer: options.tracer
620
+ tracer: options.tracer,
621
+ delegationManager // Per-instance delegation limits
620
622
  });
621
623
 
622
624
  return result;