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.
- package/bin/dual-brain.mjs +38 -28
- package/mcp-server/index.mjs +1 -1
- package/package.json +44 -4
- package/src/decide.mjs +32 -0
- package/src/index.mjs +1 -1
- package/src/profile.mjs +7 -4
- package/src/session.mjs +50 -10
- package/src/tui.mjs +10 -1
- package/hooks/agent-fleet.mjs +0 -659
- package/hooks/context-guard.mjs +0 -468
- package/hooks/dag-scheduler.mjs +0 -1249
- package/hooks/head-guard.sh +0 -41
- package/hooks/hook-dispatch.mjs +0 -254
- package/hooks/ledger-analysis.mjs +0 -337
- package/hooks/parallelism-scaler.mjs +0 -572
- package/hooks/quality-tiers.mjs +0 -642
- package/src/test.mjs +0 -1374
|
@@ -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
|
-
}
|