dual-brain 7.1.2 → 7.1.4

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.
@@ -1,572 +0,0 @@
1
- #!/usr/bin/env node
2
- /**
3
- * parallelism-scaler.mjs — Adaptive concurrency controller for the Dual-Brain Orchestrator.
4
- *
5
- * Replaces the fixed MAX_WAVE_PARALLELISM = 4 with a dynamic system that scales
6
- * concurrency based on real-time provider health, budget pressure, and task outcomes.
7
- *
8
- * Exported API:
9
- * class ParallelismScaler → adaptive concurrency controller
10
- * createScaler(options) → factory function
11
- * scalerFromBudget() → create pre-configured from budget-balancer state
12
- *
13
- * CLI:
14
- * node hooks/parallelism-scaler.mjs --status
15
- * node hooks/parallelism-scaler.mjs --simulate --tasks 10 --failures 2
16
- */
17
-
18
- import { getProviderStatus } from './budget-balancer.mjs';
19
- import { fileURLToPath } from 'url';
20
-
21
- // ---------------------------------------------------------------------------
22
- // Constants
23
- // ---------------------------------------------------------------------------
24
-
25
- const FAILURE_TYPES = {
26
- TRANSIENT: 'transient', // timeout, rate-limit — capacity issue
27
- LOGIC: 'logic', // wrong output, test fail — not capacity
28
- PROVIDER: 'provider', // API down — severe capacity issue
29
- };
30
-
31
- const TREND = {
32
- RAMPING_UP: 'ramping-up',
33
- STABLE: 'stable',
34
- RAMPING_DOWN: 'ramping-down',
35
- THROTTLED: 'throttled',
36
- };
37
-
38
- // Provider pressure thresholds for slot gating
39
- const PRESSURE_CAP_THRESHOLD = 0.80; // >80% → cap provider at 1 concurrent task
40
- const PRESSURE_BLOCK_THRESHOLD = 1.00; // >=100% → 0 tasks for that provider
41
-
42
- // ---------------------------------------------------------------------------
43
- // ParallelismScaler
44
- // ---------------------------------------------------------------------------
45
-
46
- export class ParallelismScaler {
47
- /**
48
- * @param {object} options
49
- * @param {number} [options.minConcurrency=1] - Never go below this
50
- * @param {number} [options.maxConcurrency=8] - Hard ceiling
51
- * @param {number} [options.initialConcurrency=2] - Start conservative
52
- * @param {number} [options.rampUpThreshold=3] - Consecutive successes before ramping up
53
- * @param {number} [options.rampDownThreshold=1] - Failures before ramping down
54
- */
55
- constructor(options = {}) {
56
- this.minConcurrency = options.minConcurrency ?? 1;
57
- this.maxConcurrency = options.maxConcurrency ?? 8;
58
- this.initialConcurrency = options.initialConcurrency ?? 2;
59
- this.rampUpThreshold = options.rampUpThreshold ?? 3;
60
- this.rampDownThreshold = options.rampDownThreshold ?? 1;
61
-
62
- // Mutable state
63
- this.currentConcurrency = this.initialConcurrency;
64
- this.consecutiveSuccesses = 0;
65
- this.consecutiveFailures = 0;
66
-
67
- // Sliding window of last 10 task results: { taskId, success, provider, durationMs, ts }
68
- this.recentErrors = [];
69
-
70
- // Per-provider health score 0–100 (100 = fully healthy)
71
- this.providerHealth = { claude: 100, openai: 100 };
72
-
73
- // Count of currently running tasks per provider
74
- this.activeTasksByProvider = { claude: 0, openai: 0 };
75
-
76
- // Budget pressure cache keyed by provider (set via recordProviderHealth)
77
- this._providerPressure = { claude: 0, openai: 0 };
78
- this._providerThrottled = { claude: false, openai: false };
79
-
80
- // After a ramp-down, require 2x the normal rampUpThreshold (hysteresis)
81
- this._inHysteresis = false;
82
-
83
- // Trend tracking
84
- this._lastTrend = TREND.STABLE;
85
-
86
- // Internal: how many failures in rolling rampDownThreshold window
87
- this._pendingFailures = 0;
88
- }
89
-
90
- // ---------------------------------------------------------------------------
91
- // Core query methods
92
- // ---------------------------------------------------------------------------
93
-
94
- /**
95
- * How many more tasks can be launched right now across all providers.
96
- * Considers currentConcurrency, active task count, and provider budget pressure.
97
- */
98
- getAvailableSlots() {
99
- const activeTasks = this._totalActive();
100
- const baseSlots = Math.max(0, this.currentConcurrency - activeTasks);
101
-
102
- // If both providers are throttled, allow 0 new tasks
103
- if (this._providerThrottled.claude && this._providerThrottled.openai) {
104
- return 0;
105
- }
106
-
107
- return baseSlots;
108
- }
109
-
110
- /**
111
- * How many tasks this specific provider can handle right now.
112
- * Based on provider health score, active tasks, and throttle state.
113
- *
114
- * @param {'claude'|'openai'} provider
115
- * @returns {number}
116
- */
117
- getProviderSlots(provider) {
118
- if (!['claude', 'openai'].includes(provider)) return 0;
119
-
120
- // Throttled → 0 slots
121
- if (this._providerThrottled[provider]) return 0;
122
-
123
- const pressure = this._providerPressure[provider] ?? 0;
124
- const health = this.providerHealth[provider] ?? 100;
125
- const active = this.activeTasksByProvider[provider] ?? 0;
126
-
127
- // High pressure → cap at 1
128
- if (pressure >= PRESSURE_CAP_THRESHOLD) {
129
- return Math.max(0, 1 - active);
130
- }
131
-
132
- // Scale max slots by health score: 100 health = full slots, 0 health = 0 slots
133
- const healthFraction = health / 100;
134
- const providerMax = Math.max(1, Math.round(this.currentConcurrency * healthFraction));
135
- return Math.max(0, providerMax - active);
136
- }
137
-
138
- // ---------------------------------------------------------------------------
139
- // Outcome recording
140
- // ---------------------------------------------------------------------------
141
-
142
- /**
143
- * Record a successful task completion.
144
- * After rampUpThreshold consecutive successes, increment currentConcurrency.
145
- *
146
- * @param {string} taskId
147
- * @param {'claude'|'openai'} provider
148
- * @param {number} durationMs
149
- */
150
- recordSuccess(taskId, provider, durationMs = 0) {
151
- this._decrementActive(provider);
152
- this._pushResult({ taskId, success: true, provider, durationMs, ts: Date.now() });
153
-
154
- this.consecutiveSuccesses++;
155
- this.consecutiveFailures = 0;
156
- this._pendingFailures = 0;
157
-
158
- // Slightly recover provider health on success
159
- if (provider && this.providerHealth[provider] !== undefined) {
160
- this.providerHealth[provider] = Math.min(100, this.providerHealth[provider] + 5);
161
- }
162
-
163
- const effectiveThreshold = this._inHysteresis
164
- ? this.rampUpThreshold * 2
165
- : this.rampUpThreshold;
166
-
167
- if (this.consecutiveSuccesses >= effectiveThreshold) {
168
- if (this.currentConcurrency < this.maxConcurrency) {
169
- this.currentConcurrency++;
170
- this._lastTrend = TREND.RAMPING_UP;
171
- } else {
172
- this._lastTrend = TREND.STABLE;
173
- }
174
- this.consecutiveSuccesses = 0;
175
- this._inHysteresis = false; // successfully ramped back up → exit hysteresis
176
- }
177
- }
178
-
179
- /**
180
- * Record a task failure.
181
- *
182
- * @param {string} taskId
183
- * @param {'claude'|'openai'} provider
184
- * @param {Error|string} error
185
- * @param {'transient'|'logic'|'provider'} failureType
186
- */
187
- recordFailure(taskId, provider, error = '', failureType = FAILURE_TYPES.TRANSIENT) {
188
- this._decrementActive(provider);
189
- this._pushResult({ taskId, success: false, provider, error: String(error), ts: Date.now() });
190
-
191
- this.consecutiveSuccesses = 0;
192
-
193
- if (failureType === FAILURE_TYPES.LOGIC) {
194
- // Logic failure: not a capacity issue — don't touch concurrency
195
- return;
196
- }
197
-
198
- // Transient or provider failure: reduce provider health
199
- if (provider && this.providerHealth[provider] !== undefined) {
200
- const penalty = failureType === FAILURE_TYPES.PROVIDER ? 50 : 20;
201
- this.providerHealth[provider] = Math.max(0, this.providerHealth[provider] - penalty);
202
- }
203
-
204
- if (failureType === FAILURE_TYPES.PROVIDER) {
205
- // Immediately halve concurrency for this provider
206
- // We halve the global concurrency as a proxy (provider-level is capped via health)
207
- this.providerHealth[provider] = Math.max(0, Math.floor(this.providerHealth[provider] / 2));
208
- this._rampDown('provider failure — API down');
209
- return;
210
- }
211
-
212
- // Transient failure
213
- this._pendingFailures++;
214
- this.consecutiveFailures++;
215
-
216
- if (this._pendingFailures >= this.rampDownThreshold) {
217
- this._rampDown('transient failure — timeout or rate-limit');
218
- this._pendingFailures = 0;
219
- }
220
- }
221
-
222
- /**
223
- * Update provider health from external budget-balancer data.
224
- *
225
- * @param {'claude'|'openai'} provider
226
- * @param {{ pressure: number, isThrottled: boolean, remainingTokens: number }} metrics
227
- */
228
- recordProviderHealth(provider, metrics = {}) {
229
- if (!['claude', 'openai'].includes(provider)) return;
230
-
231
- const pressure = metrics.pressure ?? 0;
232
- const isThrottled = metrics.isThrottled ?? false;
233
-
234
- this._providerPressure[provider] = pressure;
235
- this._providerThrottled[provider] = isThrottled || pressure >= PRESSURE_BLOCK_THRESHOLD;
236
-
237
- // Compute health score from pressure (100 at 0%, 0 at 100%)
238
- const pressureHealth = Math.max(0, Math.round((1 - pressure) * 100));
239
- // Blend with existing health (external signal + internal observation)
240
- this.providerHealth[provider] = Math.round(
241
- (pressureHealth * 0.7) + (this.providerHealth[provider] * 0.3)
242
- );
243
-
244
- // If both throttled → trend is throttled
245
- if (this._providerThrottled.claude && this._providerThrottled.openai) {
246
- this._lastTrend = TREND.THROTTLED;
247
- }
248
- }
249
-
250
- // ---------------------------------------------------------------------------
251
- // Recommendation / observability
252
- // ---------------------------------------------------------------------------
253
-
254
- /**
255
- * Return current slot recommendation across providers.
256
- *
257
- * @returns {{ totalSlots: number, byProvider: { claude: number, openai: number }, reason: string, trend: string }}
258
- */
259
- recommend() {
260
- const claudeSlots = this.getProviderSlots('claude');
261
- const openaiSlots = this.getProviderSlots('openai');
262
- const totalSlots = this.getAvailableSlots();
263
-
264
- const reasons = [];
265
-
266
- if (this._providerThrottled.claude && this._providerThrottled.openai) {
267
- reasons.push('both providers throttled — holding');
268
- } else {
269
- if (this._providerThrottled.claude) reasons.push('claude throttled (0 slots)');
270
- if (this._providerThrottled.openai) reasons.push('openai throttled (0 slots)');
271
- if (this._providerPressure.claude >= PRESSURE_CAP_THRESHOLD && !this._providerThrottled.claude) {
272
- reasons.push(`claude pressure at ${Math.round(this._providerPressure.claude * 100)}% — capped at 1`);
273
- }
274
- if (this._providerPressure.openai >= PRESSURE_CAP_THRESHOLD && !this._providerThrottled.openai) {
275
- reasons.push(`openai pressure at ${Math.round(this._providerPressure.openai * 100)}% — capped at 1`);
276
- }
277
- }
278
-
279
- if (this._inHysteresis) {
280
- reasons.push(`hysteresis active — need ${this.rampUpThreshold * 2} successes to ramp up`);
281
- }
282
-
283
- if (!reasons.length) {
284
- reasons.push(`concurrency ${this.currentConcurrency}, ${this.consecutiveSuccesses} consecutive successes`);
285
- }
286
-
287
- // Trend
288
- let trend = this._lastTrend;
289
- if (this._providerThrottled.claude && this._providerThrottled.openai) {
290
- trend = TREND.THROTTLED;
291
- }
292
-
293
- return {
294
- totalSlots,
295
- byProvider: { claude: claudeSlots, openai: openaiSlots },
296
- reason: reasons.join('; '),
297
- trend,
298
- };
299
- }
300
-
301
- /**
302
- * Return full internal state for observability and debugging.
303
- */
304
- getStats() {
305
- return {
306
- currentConcurrency: this.currentConcurrency,
307
- minConcurrency: this.minConcurrency,
308
- maxConcurrency: this.maxConcurrency,
309
- consecutiveSuccesses: this.consecutiveSuccesses,
310
- consecutiveFailures: this.consecutiveFailures,
311
- inHysteresis: this._inHysteresis,
312
- effectiveRampUpThreshold: this._inHysteresis
313
- ? this.rampUpThreshold * 2
314
- : this.rampUpThreshold,
315
- providerHealth: { ...this.providerHealth },
316
- activeTasksByProvider:{ ...this.activeTasksByProvider },
317
- providerPressure: { ...this._providerPressure },
318
- providerThrottled: { ...this._providerThrottled },
319
- recentResults: this.recentErrors.map(r => ({ ...r })),
320
- trend: this._lastTrend,
321
- availableSlots: this.getAvailableSlots(),
322
- recommendation: this.recommend(),
323
- };
324
- }
325
-
326
- // ---------------------------------------------------------------------------
327
- // Active task tracking (call before dispatching / after completing)
328
- // ---------------------------------------------------------------------------
329
-
330
- /** Mark a task as started for a provider. Call before dispatching. */
331
- markStarted(provider) {
332
- if (provider && this.activeTasksByProvider[provider] !== undefined) {
333
- this.activeTasksByProvider[provider]++;
334
- }
335
- }
336
-
337
- // ---------------------------------------------------------------------------
338
- // Internal helpers
339
- // ---------------------------------------------------------------------------
340
-
341
- _totalActive() {
342
- return (this.activeTasksByProvider.claude ?? 0) +
343
- (this.activeTasksByProvider.openai ?? 0);
344
- }
345
-
346
- _decrementActive(provider) {
347
- if (provider && this.activeTasksByProvider[provider] !== undefined) {
348
- this.activeTasksByProvider[provider] = Math.max(
349
- 0,
350
- this.activeTasksByProvider[provider] - 1
351
- );
352
- }
353
- }
354
-
355
- _pushResult(result) {
356
- this.recentErrors.push(result);
357
- if (this.recentErrors.length > 10) {
358
- this.recentErrors.shift();
359
- }
360
- }
361
-
362
- _rampDown(reason) {
363
- if (this.currentConcurrency > this.minConcurrency) {
364
- this.currentConcurrency--;
365
- this._lastTrend = TREND.RAMPING_DOWN;
366
- this._inHysteresis = true;
367
- }
368
- }
369
- }
370
-
371
- // ---------------------------------------------------------------------------
372
- // Factory function
373
- // ---------------------------------------------------------------------------
374
-
375
- /**
376
- * Create a ParallelismScaler with the given options.
377
- *
378
- * @param {object} options - See ParallelismScaler constructor
379
- * @returns {ParallelismScaler}
380
- */
381
- export function createScaler(options = {}) {
382
- return new ParallelismScaler(options);
383
- }
384
-
385
- // ---------------------------------------------------------------------------
386
- // Integration helper: scalerFromBudget()
387
- // ---------------------------------------------------------------------------
388
-
389
- /**
390
- * Create a scaler pre-configured from current budget-balancer state.
391
- * Reads provider pressure and sets initial concurrency based on available headroom.
392
- *
393
- * @returns {ParallelismScaler}
394
- */
395
- export function scalerFromBudget() {
396
- let status;
397
- try {
398
- status = getProviderStatus();
399
- } catch {
400
- // Budget-balancer unavailable — return a conservative default scaler
401
- return new ParallelismScaler({ initialConcurrency: 2 });
402
- }
403
-
404
- // Use execute-tier pressure as the primary signal for concurrency sizing
405
- const claudeExec = status?.claude?.execute ?? {};
406
- const openaiExec = status?.openai?.execute ?? {};
407
-
408
- const claudePressure = claudeExec.effectivePressure ?? 0;
409
- const openaiPressure = openaiExec.effectivePressure ?? 0;
410
-
411
- // Both available pressures determine initial concurrency
412
- // Low pressure → start at 4; medium → 3; hot → 2; throttled → 1
413
- const avgPressure = (claudePressure + openaiPressure) / 2;
414
- let initialConcurrency;
415
- if (avgPressure < 0.30) {
416
- initialConcurrency = 4;
417
- } else if (avgPressure < 0.55) {
418
- initialConcurrency = 3;
419
- } else if (avgPressure < 0.80) {
420
- initialConcurrency = 2;
421
- } else {
422
- initialConcurrency = 1;
423
- }
424
-
425
- const scaler = new ParallelismScaler({ initialConcurrency });
426
-
427
- // Apply current budget state to provider health
428
- for (const provider of ['claude', 'openai']) {
429
- const exec = status?.[provider]?.execute ?? {};
430
- scaler.recordProviderHealth(provider, {
431
- pressure: exec.effectivePressure ?? 0,
432
- isThrottled: exec.state === 'throttled',
433
- remainingTokens: Math.max(0, (exec.budget ?? 0) - (exec.tokens ?? 0)),
434
- });
435
- }
436
-
437
- return scaler;
438
- }
439
-
440
- // ---------------------------------------------------------------------------
441
- // CLI helpers
442
- // ---------------------------------------------------------------------------
443
-
444
- function formatBar(value, max, width = 10) {
445
- const filled = Math.min(width, Math.round((value / Math.max(1, max)) * width));
446
- return '█'.repeat(filled) + '░'.repeat(width - filled);
447
- }
448
-
449
- function formatTrend(trend) {
450
- const map = {
451
- [TREND.RAMPING_UP]: '↑ ramping-up',
452
- [TREND.STABLE]: '→ stable',
453
- [TREND.RAMPING_DOWN]: '↓ ramping-down',
454
- [TREND.THROTTLED]: '✕ throttled',
455
- };
456
- return map[trend] || trend;
457
- }
458
-
459
- function printStatusReport(scaler) {
460
- const stats = scaler.getStats();
461
- const rec = stats.recommendation;
462
- const LINE = 60;
463
- const border = '═'.repeat(LINE - 2);
464
-
465
- const h = (text) => {
466
- const padded = ` ${text}`.padEnd(LINE - 4);
467
- return `║ ${padded} ║`;
468
- };
469
-
470
- const lines = [
471
- `╔${border}╗`,
472
- h(' Parallelism Scaler Status'),
473
- `╠${border}╣`,
474
- h(` Current concurrency : ${stats.currentConcurrency} (min ${stats.minConcurrency}, max ${stats.maxConcurrency})`),
475
- h(` Available slots : ${stats.availableSlots}`),
476
- h(` Trend : ${formatTrend(stats.trend)}`),
477
- h(` Hysteresis : ${stats.inHysteresis ? `yes (need ${stats.effectiveRampUpThreshold} successes)` : 'no'}`),
478
- h(` Consec. successes : ${stats.consecutiveSuccesses} / ${stats.effectiveRampUpThreshold} threshold`),
479
- `╠${border}╣`,
480
- h(' Provider Health'),
481
- h(` Claude : ${formatBar(stats.providerHealth.claude, 100)} ${String(stats.providerHealth.claude).padStart(3)}% health pressure ${Math.round(stats.providerPressure.claude * 100)}% slots ${rec.byProvider.claude}`),
482
- h(` OpenAI : ${formatBar(stats.providerHealth.openai, 100)} ${String(stats.providerHealth.openai).padStart(3)}% health pressure ${Math.round(stats.providerPressure.openai * 100)}% slots ${rec.byProvider.openai}`),
483
- `╠${border}╣`,
484
- h(` Recommendation: ${rec.totalSlots} total slots`),
485
- h(` Reason: ${rec.reason}`),
486
- `╚${border}╝`,
487
- ];
488
-
489
- console.log(lines.join('\n'));
490
- }
491
-
492
- function runSimulation(options) {
493
- const { tasks = 10, failures = 2, maxConcurrency = 8 } = options;
494
-
495
- console.log(`\nSimulation: ${tasks} tasks, ${failures} injected failures, max concurrency ${maxConcurrency}\n`);
496
-
497
- const scaler = createScaler({ maxConcurrency });
498
- const results = [];
499
-
500
- // Distribute failures evenly across task sequence
501
- const failureSet = new Set();
502
- for (let i = 0; i < failures; i++) {
503
- failureSet.add(Math.floor((tasks / (failures + 1)) * (i + 1)));
504
- }
505
-
506
- for (let i = 0; i < tasks; i++) {
507
- const taskId = `task-${i + 1}`;
508
- const provider = i % 2 === 0 ? 'claude' : 'openai';
509
- const isFailure = failureSet.has(i);
510
-
511
- scaler.markStarted(provider);
512
-
513
- const before = scaler.currentConcurrency;
514
-
515
- if (isFailure) {
516
- scaler.recordFailure(taskId, provider, new Error('simulated timeout'), FAILURE_TYPES.TRANSIENT);
517
- } else {
518
- scaler.recordSuccess(taskId, provider, 1200 + Math.random() * 800);
519
- }
520
-
521
- const after = scaler.currentConcurrency;
522
- const delta = after - before;
523
- const deltaStr = delta > 0 ? `+${delta}` : delta < 0 ? String(delta) : ' ';
524
- const status = isFailure ? 'FAIL' : 'OK ';
525
- const icon = isFailure ? '✕' : '✓';
526
-
527
- results.push({ taskId, provider, status, concurrencyBefore: before, concurrencyAfter: after });
528
- console.log(` ${icon} ${taskId.padEnd(8)} [${provider.padEnd(6)}] ${status} concurrency: ${before} → ${after} (${deltaStr}) trend: ${scaler._lastTrend}`);
529
- }
530
-
531
- console.log('');
532
- printStatusReport(scaler);
533
- }
534
-
535
- // ---------------------------------------------------------------------------
536
- // CLI entry point
537
- // ---------------------------------------------------------------------------
538
-
539
- async function main() {
540
- const args = process.argv.slice(2);
541
-
542
- if (args.includes('--status')) {
543
- const scaler = scalerFromBudget();
544
- printStatusReport(scaler);
545
- return;
546
- }
547
-
548
- if (args.includes('--simulate')) {
549
- const tasksIdx = args.indexOf('--tasks');
550
- const failuresIdx = args.indexOf('--failures');
551
- const maxIdx = args.indexOf('--max-concurrency');
552
-
553
- const tasks = tasksIdx >= 0 ? parseInt(args[tasksIdx + 1], 10) || 10 : 10;
554
- const failures = failuresIdx >= 0 ? parseInt(args[failuresIdx + 1], 10) || 2 : 2;
555
- const maxConcurrency = maxIdx >= 0 ? parseInt(args[maxIdx + 1], 10) || 8 : 8;
556
-
557
- runSimulation({ tasks, failures, maxConcurrency });
558
- return;
559
- }
560
-
561
- // Default: show status
562
- const scaler = scalerFromBudget();
563
- printStatusReport(scaler);
564
- }
565
-
566
- // Run as CLI only when invoked directly
567
- if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
568
- main().catch((err) => {
569
- process.stderr.write(`[parallelism-scaler] ${err.message}\n`);
570
- process.exit(1);
571
- });
572
- }