@animastor/orchestration 0.1.1

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.
Files changed (36) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +75 -0
  3. package/package.json +51 -0
  4. package/src/host/host-bindings.js +138 -0
  5. package/src/index.js +99 -0
  6. package/src/orchestration/index.js +40 -0
  7. package/src/orchestration/orchestrator.js +630 -0
  8. package/src/orchestration/runtime-result-consumer.js +54 -0
  9. package/src/orchestration/scene-callbacks.js +542 -0
  10. package/src/orchestration/scene-orchestrator.js +601 -0
  11. package/src/orchestration/scene-restoration.js +129 -0
  12. package/src/orchestration/scene-utils.js +36 -0
  13. package/src/runtime/active-scenes-index.js +164 -0
  14. package/src/runtime/audio-fsm-port.js +187 -0
  15. package/src/runtime/circuit-breaker.js +588 -0
  16. package/src/runtime/counter-reconciliation.js +308 -0
  17. package/src/runtime/dispatch-engine.js +1768 -0
  18. package/src/runtime/failure-taxonomy.js +125 -0
  19. package/src/runtime/hub-cancel-port.js +122 -0
  20. package/src/runtime/layer-config-port.js +136 -0
  21. package/src/runtime/lease-manager.js +607 -0
  22. package/src/runtime/persistence-port.js +162 -0
  23. package/src/runtime/placeholder-audio-port.js +117 -0
  24. package/src/runtime/progress-events-port.js +121 -0
  25. package/src/runtime/reconciliation-engine.js +2405 -0
  26. package/src/runtime/retry-budget-manager.js +229 -0
  27. package/src/runtime/runtime-metrics.js +427 -0
  28. package/src/runtime/runtime-result-emitter.js +123 -0
  29. package/src/runtime/runtime-scheduler.js +629 -0
  30. package/src/runtime/scene-data-port.js +114 -0
  31. package/src/runtime/scene-window.js +827 -0
  32. package/src/runtime/video-fsm-port.js +189 -0
  33. package/src/runtime/worker-health.js +270 -0
  34. package/test/counter-reconciliation.test.js +164 -0
  35. package/test/mocks/redis-mock.js +221 -0
  36. package/test/test-bindings.js +60 -0
@@ -0,0 +1,588 @@
1
+ // ======================================================
2
+ // Circuit Breaker - v1.0.0
3
+ // ======================================================
4
+ // Implements circuit breaker pattern for resilient runtime.
5
+ // Prevents cascading failures and retry storms.
6
+ //
7
+ // STATES:
8
+ // - CLOSED: normal operation, dispatches allowed
9
+ // - OPEN: dispatches blocked, circuit tripped
10
+ // - HALF_OPEN: test dispatches allowed, limited recovery
11
+ //
12
+ // TARGETS:
13
+ // - audio generation
14
+ // - image generation
15
+ // - video generation
16
+ // - Redis connection
17
+ // - filesystem operations
18
+ // - external worker callbacks
19
+
20
+ // S-2: media service targets resolved from the registry
21
+ const mediaRegistry = require('@animastor/generation').mediaRegistry;
22
+
23
+ const logPrefix = '[CIRCUIT]';
24
+
25
+ function log(msg) {
26
+ console.log(`${logPrefix} ${msg}`);
27
+ }
28
+
29
+ function warn(msg) {
30
+ console.warn(`${logPrefix} ⚠️ ${msg}`);
31
+ }
32
+
33
+ function error(msg) {
34
+ console.error(`${logPrefix} ❌ ${msg}`);
35
+ }
36
+
37
+ // ======================================================
38
+ // CIRCUIT STATES
39
+ // ======================================================
40
+
41
+ const CircuitState = {
42
+ CLOSED: 'closed',
43
+ OPEN: 'open',
44
+ HALF_OPEN: 'half_open'
45
+ };
46
+
47
+ // ======================================================
48
+ // CIRCUIT CONFIGURATION
49
+ // ======================================================
50
+
51
+ const CIRCUIT_CONFIG = {
52
+ // Failure thresholds
53
+ failureThreshold: 5, // 5 failures triggers OPEN
54
+ failureWindowMs: 2 * 60 * 1000, // 2 minutes window
55
+
56
+ // Recovery
57
+ recoveryTimeoutMs: 60 * 1000, // 60 seconds cooldown before HALF_OPEN
58
+
59
+ // Half-open limits
60
+ halfOpenMaxRequests: 2, // Allow 2 test dispatches in HALF_OPEN
61
+
62
+ // Satiety config (to recover from failure)
63
+ saturationThreshold: 8, // 8 failures triggers saturation (more aggressive)
64
+ saturationRecoveryMs: 5 * 60 * 1000, // 5 minutes saturation recovery
65
+ };
66
+
67
+ // ======================================================
68
+ // CIRCUIT KEYS
69
+ // ======================================================
70
+
71
+ /**
72
+ * Get circuit state key for service.
73
+ */
74
+ function getCircuitStateKey(service) {
75
+ return `animastor:circuit:${service}`;
76
+ }
77
+
78
+ /**
79
+ * Get circuit failure count key.
80
+ */
81
+ function getCircuitFailureKey(service) {
82
+ return `animastor:circuit:${service}:failures`;
83
+ }
84
+
85
+ /**
86
+ * Get circuit last failure timestamp key.
87
+ */
88
+ function getCircuitLastFailureKey(service) {
89
+ return `animastor:circuit:${service}:last-failure`;
90
+ }
91
+
92
+ /**
93
+ * Get circuit half-open requests key.
94
+ */
95
+ function getCircuitHalfOpenKey(service) {
96
+ return `animastor:circuit:${service}:half-open`;
97
+ }
98
+
99
+ // ======================================================
100
+ // SERVICE TARGETS
101
+ // ======================================================
102
+
103
+ const SERVICE_TARGETS = {
104
+ // S-2: media types resolved from registry at runtime
105
+ ...Object.fromEntries(mediaRegistry.listMediaTypes().map(t => [t.toUpperCase(), t])),
106
+ REDIS: 'redis',
107
+ FILESYSTEM: 'filesystem',
108
+ WORKER_CALLBACK: 'worker_callback',
109
+ SCHEDULER: 'scheduler'
110
+ };
111
+
112
+ // ======================================================
113
+ // CIRCUIT STATE MANAGEMENT
114
+ // ======================================================
115
+
116
+ /**
117
+ * Get current circuit state for a service.
118
+ */
119
+ async function getCircuitState(redis, service) {
120
+ const stateKey = getCircuitStateKey(service);
121
+ const rawState = await redis.get(stateKey);
122
+
123
+ if (!rawState) {
124
+ // Default to CLOSED if not set
125
+ return CircuitState.CLOSED;
126
+ }
127
+
128
+ return rawState;
129
+ }
130
+
131
+ /**
132
+ * Set circuit state for a service.
133
+ */
134
+ async function setCircuitState(redis, service, state) {
135
+ const stateKey = getCircuitStateKey(service);
136
+ await redis.set(stateKey, state, 'EX', 24 * 60 * 60); // 24 hour TTL
137
+
138
+ log(`CIRCUIT_${state.toUpperCase()}: ${service}`);
139
+ return { state, setAt: Date.now() };
140
+ }
141
+
142
+ /**
143
+ * Check if circuit is open for a service.
144
+ */
145
+ async function isCircuitOpen(redis, service) {
146
+ const state = await getCircuitState(redis, service);
147
+ return state === CircuitState.OPEN;
148
+ }
149
+
150
+ /**
151
+ * Check if circuit is in half-open state.
152
+ */
153
+ async function isCircuitHalfOpen(redis, service) {
154
+ const state = await getCircuitState(redis, service);
155
+ return state === CircuitState.HALF_OPEN;
156
+ }
157
+
158
+ // ======================================================
159
+ // FAILURE TRACKING
160
+ // ======================================================
161
+
162
+ /**
163
+ * Record a failure for a service.
164
+ * Returns: { circuitState, isTripped, newCount }
165
+ */
166
+ async function recordFailure(redis, service) {
167
+ const failureKey = getCircuitFailureKey(service);
168
+ const lastFailureKey = getCircuitLastFailureKey(service);
169
+
170
+ // HALF_OPEN test request failed → re-open immediately (single test failure
171
+ // is enough to abort recovery; no need to wait for the full threshold).
172
+ const currentState = await getCircuitState(redis, service);
173
+ if (currentState === CircuitState.HALF_OPEN) {
174
+ const halfOpenKey = getCircuitHalfOpenKey(service);
175
+ await redis.del(halfOpenKey);
176
+ await redis.set(failureKey, '1', 'EX', Math.ceil(CIRCUIT_CONFIG.failureWindowMs / 1000));
177
+ await redis.set(lastFailureKey, Date.now().toString());
178
+ await setCircuitState(redis, service, CircuitState.OPEN);
179
+ log(`CIRCUIT_REOPENED: ${service} (half-open test failed)`);
180
+ return { circuitState: CircuitState.OPEN, isTripped: true, newCount: 1, service };
181
+ }
182
+
183
+ // Increment failure count
184
+ const newCount = await redis.incr(failureKey);
185
+ await redis.set(lastFailureKey, Date.now().toString());
186
+
187
+ // Check if threshold exceeded
188
+ const isTripped = newCount >= CIRCUIT_CONFIG.failureThreshold;
189
+
190
+ // If tripped, open the circuit
191
+ if (isTripped) {
192
+ await setCircuitState(redis, service, CircuitState.OPEN);
193
+ log(`CIRCUIT_TRIPPED: ${service} (failures: ${newCount})`);
194
+ }
195
+
196
+ // Set expiry on failure count (auto-cleanup after failure window)
197
+ await redis.expire(failureKey, Math.ceil(CIRCUIT_CONFIG.failureWindowMs / 1000));
198
+
199
+ return {
200
+ circuitState: await getCircuitState(redis, service),
201
+ isTripped,
202
+ newCount,
203
+ service
204
+ };
205
+ }
206
+
207
+ /**
208
+ * Record a successful operation.
209
+ * Resets failure count for closed circuits; heals a HALF_OPEN circuit
210
+ * (test request succeeded → back to CLOSED).
211
+ */
212
+ async function recordSuccess(redis, service) {
213
+ const failureKey = getCircuitFailureKey(service);
214
+
215
+ // B8: recordSuccess НЕ переключает OPEN → HALF_OPEN.
216
+ // Этот переход делает только tryRecover с проверкой recoveryTimeout.
217
+ // Если circuit OPEN — успех не должен пробивать защиту.
218
+ const currentState = await getCircuitState(redis, service);
219
+ if (currentState === CircuitState.OPEN) {
220
+ // Circuit is open — success calls should not happen through normal flow
221
+ // But if they do (e.g., test request passed the guard), just log and return
222
+ return { success: false, state: CircuitState.OPEN, reason: 'circuit_open' };
223
+ }
224
+
225
+ // HALF_OPEN: the test dispatch succeeded → circuit heals back to CLOSED.
226
+ if (currentState === CircuitState.HALF_OPEN) {
227
+ const halfOpenKey = getCircuitHalfOpenKey(service);
228
+ await redis.del(halfOpenKey);
229
+ await redis.set(failureKey, '0', 'EX', Math.ceil(CIRCUIT_CONFIG.failureWindowMs / 1000));
230
+ await setCircuitState(redis, service, CircuitState.CLOSED);
231
+ log(`CIRCUIT_HEALED: ${service} (half-open test succeeded)`);
232
+ return { success: true, state: CircuitState.CLOSED, reset: true, healed: true, testRequest: true };
233
+ }
234
+
235
+ // Reset failure count if circuit is closed
236
+ if (currentState === CircuitState.CLOSED) {
237
+ await redis.set(failureKey, '0', 'EX', Math.ceil(CIRCUIT_CONFIG.failureWindowMs / 1000));
238
+ return { success: true, state: CircuitState.CLOSED, reset: true };
239
+ }
240
+
241
+ return { success: true, state: currentState };
242
+ }
243
+
244
+ /**
245
+ * Release a half-open test permit.
246
+ *
247
+ * Fix (video-retry incident follow-up): a permit acquired by
248
+ * checkAndIncrementTestRequest was only released by recordSuccess/
249
+ * recordFailure. A half-open test dispatch that aborted BEFORE finalization
250
+ * (retry-budget exceeded, backpressure, duplicate, lease failure, cancelled)
251
+ * leaked its permit forever — with halfOpenMaxRequests=2 both permits leaked,
252
+ * the counter stuck at the limit and every later dispatch got
253
+ * 'half_open_limit_reached' → the stage stayed blocked permanently.
254
+ *
255
+ * Callers MUST call this when an admitted test request will NOT reach
256
+ * finalizeDispatch with outcome 'success'|'failure' (those two already DEL
257
+ * the counter themselves).
258
+ */
259
+ async function releaseHalfOpenPermit(redis, service) {
260
+ const currentState = await getCircuitState(redis, service);
261
+ if (currentState !== CircuitState.HALF_OPEN) {
262
+ // Nothing to release (circuit already healed/reopened — the key was DELeted).
263
+ return { released: false, reason: 'not_half_open' };
264
+ }
265
+ const halfOpenKey = getCircuitHalfOpenKey(service);
266
+ const count = await redis.decr(halfOpenKey);
267
+ if (count <= 0) {
268
+ await redis.del(halfOpenKey);
269
+ }
270
+ return { released: true, remaining: Math.max(count, 0) };
271
+ }
272
+
273
+ /**
274
+ * Check if circuit has failed recently.
275
+ */
276
+ async function hasRecentFailures(redis, service) {
277
+ const failureKey = getCircuitFailureKey(service);
278
+ const lastFailureKey = getCircuitLastFailureKey(service);
279
+
280
+ const failureCount = await redis.get(failureKey);
281
+ const lastFailure = await redis.get(lastFailureKey);
282
+
283
+ if (!failureCount || !lastFailure) {
284
+ return { recent: false, count: 0 };
285
+ }
286
+
287
+ const count = parseInt(failureCount, 10);
288
+ const lastFailureTime = parseInt(lastFailure, 10);
289
+ const now = Date.now();
290
+ const elapsed = now - lastFailureTime;
291
+
292
+ const recent = elapsed < CIRCUIT_CONFIG.failureWindowMs;
293
+
294
+ return {
295
+ recent,
296
+ count,
297
+ elapsedMs: elapsed,
298
+ withinWindow: elapsed < CIRCUIT_CONFIG.failureWindowMs
299
+ };
300
+ }
301
+
302
+ // ======================================================
303
+ // DISPATCH CHECK
304
+ // ======================================================
305
+
306
+ /**
307
+ * Check if dispatch should be allowed.
308
+ * Returns: { allowed, reason, circuitState }
309
+ */
310
+ async function checkDispatch(redis, service) {
311
+ const currentState = await getCircuitState(redis, service);
312
+
313
+ switch (currentState) {
314
+ case CircuitState.CLOSED:
315
+ return { allowed: true, reason: 'circuit_closed', circuitState: currentState };
316
+
317
+ case CircuitState.OPEN:
318
+ return { allowed: false, reason: 'circuit_open', circuitState: currentState };
319
+
320
+ case CircuitState.HALF_OPEN:
321
+ const halfOpenKey = getCircuitHalfOpenKey(service);
322
+ const halfOpenCount = await redis.get(halfOpenKey);
323
+ const count = parseInt(halfOpenCount || '0', 10);
324
+
325
+ if (count >= CIRCUIT_CONFIG.halfOpenMaxRequests) {
326
+ // Too many test requests, go back to open
327
+ return { allowed: false, reason: 'half_open_limit_reached', circuitState: currentState };
328
+ }
329
+
330
+ return { allowed: true, reason: 'half_open_test', circuitState: currentState, isTestRequest: true };
331
+
332
+ default:
333
+ return { allowed: false, reason: 'unknown_state', circuitState: currentState };
334
+ }
335
+ }
336
+
337
+ /**
338
+ * Check dispatch and increment test counter if half-open.
339
+ */
340
+ async function checkAndIncrementTestRequest(redis, service) {
341
+ const result = await checkDispatch(redis, service);
342
+
343
+ if (result.allowed && result.isTestRequest) {
344
+ const halfOpenKey = getCircuitHalfOpenKey(service);
345
+ await redis.incr(halfOpenKey);
346
+ await redis.expire(halfOpenKey, Math.ceil(CIRCUIT_CONFIG.recoveryTimeoutMs / 1000));
347
+ }
348
+
349
+ return result;
350
+ }
351
+
352
+ /**
353
+ * Check dispatch with automatic recovery.
354
+ *
355
+ * When the circuit is OPEN and the recovery timeout has elapsed, transitions
356
+ * to HALF_OPEN and allows a limited test dispatch — so a tripped circuit
357
+ * recovers on its own instead of blocking the stage forever.
358
+ *
359
+ * Returns the same shape as checkDispatch, with an extra `recovered` flag:
360
+ * { allowed, reason, circuitState, recovered? }
361
+ */
362
+ async function checkDispatchWithRecovery(redis, service) {
363
+ const currentState = await getCircuitState(redis, service);
364
+
365
+ if (currentState !== CircuitState.OPEN) {
366
+ return checkAndIncrementTestRequest(redis, service);
367
+ }
368
+
369
+ const recovery = await tryRecover(redis, service);
370
+ if (recovery.recoverable) {
371
+ // Transitioned OPEN → HALF_OPEN: this dispatch becomes the test request.
372
+ const result = await checkAndIncrementTestRequest(redis, service);
373
+ return { ...result, recovered: true };
374
+ }
375
+
376
+ // Not recoverable yet (timeout not met or no failure record) — still blocked.
377
+ return { allowed: false, reason: 'circuit_open', circuitState: currentState, recovery };
378
+ }
379
+
380
+ // ======================================================
381
+ // RECOVERY LOGIC
382
+ // ======================================================
383
+
384
+ /**
385
+ * Try to recover a circuit from OPEN to HALF_OPEN.
386
+ * Only allowed after recovery timeout.
387
+ */
388
+ async function tryRecover(redis, service) {
389
+ const stateKey = getCircuitStateKey(service);
390
+ const state = await getCircuitState(redis, service);
391
+
392
+ if (state !== CircuitState.OPEN) {
393
+ return { recoverable: false, reason: 'not_open', state };
394
+ }
395
+
396
+ // Check if enough time has passed
397
+ const lastFailureKey = getCircuitLastFailureKey(service);
398
+ const lastFailure = await redis.get(lastFailureKey);
399
+
400
+ if (!lastFailure) {
401
+ // No failures recorded, safe to recover
402
+ const result = await setCircuitState(redis, service, CircuitState.HALF_OPEN);
403
+ return { recoverable: true, reason: 'no_failures', state, ...result };
404
+ }
405
+
406
+ const lastFailureTime = parseInt(lastFailure, 10);
407
+ const elapsed = Date.now() - lastFailureTime;
408
+
409
+ if (elapsed < CIRCUIT_CONFIG.recoveryTimeoutMs) {
410
+ return {
411
+ recoverable: false,
412
+ reason: 'timeout_not_met',
413
+ state,
414
+ elapsedMs: elapsed,
415
+ timeoutRemainingMs: CIRCUIT_CONFIG.recoveryTimeoutMs - elapsed
416
+ };
417
+ }
418
+
419
+ // Recover to half-open
420
+ const halfOpenKey = getCircuitHalfOpenKey(service);
421
+ await redis.set(halfOpenKey, '0', 'EX', Math.ceil(CIRCUIT_CONFIG.recoveryTimeoutMs / 1000));
422
+
423
+ const result = await setCircuitState(redis, service, CircuitState.HALF_OPEN);
424
+ log(`CIRCUIT_RECOVERED: ${service} (elapsed: ${Math.floor(elapsed / 1000)}s)`);
425
+
426
+ return { recoverable: true, reason: 'timeout_met', state, ...result };
427
+ }
428
+
429
+ /**
430
+ * Force open a circuit (emergency stop).
431
+ * Sets a last-failure timestamp so automatic recovery (tryRecover) still
432
+ * respects the recovery timeout — an emergency stop must not be immediately
433
+ * recoverable on the very next dispatch.
434
+ */
435
+ async function forceOpen(redis, service, reason) {
436
+ const result = await setCircuitState(redis, service, CircuitState.OPEN);
437
+ const lastFailureKey = getCircuitLastFailureKey(service);
438
+ await redis.set(lastFailureKey, Date.now().toString());
439
+ log(`CIRCUIT_FORCED_OPEN: ${service} (reason: ${reason})`);
440
+ return result;
441
+ }
442
+
443
+ /**
444
+ * Force close a circuit (manual recovery).
445
+ */
446
+ async function forceClose(redis, service, reason) {
447
+ const failureKey = getCircuitFailureKey(service);
448
+ await redis.set(failureKey, '0');
449
+
450
+ const result = await setCircuitState(redis, service, CircuitState.CLOSED);
451
+ log(`CIRCUIT_FORCED_CLOSED: ${service} (reason: ${reason})`);
452
+ return result;
453
+ }
454
+
455
+ // ======================================================
456
+ // CIRCUIT METRICS
457
+ // ======================================================
458
+
459
+ /**
460
+ * Get all circuit states.
461
+ */
462
+ async function getCircuitsStatus(redis) {
463
+ const circuits = {};
464
+ const services = Object.values(SERVICE_TARGETS);
465
+
466
+ for (const service of services) {
467
+ circuits[service] = {
468
+ state: await getCircuitState(redis, service),
469
+ failures: await redis.get(getCircuitFailureKey(service)),
470
+ lastFailure: await redis.get(getCircuitLastFailureKey(service)),
471
+ halfOpenCount: await redis.get(getCircuitHalfOpenKey(service))
472
+ };
473
+ }
474
+
475
+ return circuits;
476
+ }
477
+
478
+ /**
479
+ * Get circuit metrics for a specific service.
480
+ */
481
+ async function getCircuitMetrics(redis, service) {
482
+ const stateKey = getCircuitStateKey(service);
483
+ const state = await getCircuitState(redis, service);
484
+
485
+ const failureCount = await redis.get(getCircuitFailureKey(service));
486
+ const lastFailure = await redis.get(getCircuitLastFailureKey(service));
487
+ const halfOpenCount = await redis.get(getCircuitHalfOpenKey(service));
488
+
489
+ let lastFailureTime = null;
490
+ let timeSinceLastFailure = null;
491
+ if (lastFailure) {
492
+ lastFailureTime = parseInt(lastFailure, 10);
493
+ timeSinceLastFailure = Date.now() - lastFailureTime;
494
+ }
495
+
496
+ let halfOpenValue = null;
497
+ if (halfOpenCount) {
498
+ halfOpenValue = parseInt(halfOpenCount, 10);
499
+ }
500
+
501
+ return {
502
+ service,
503
+ state,
504
+ failureCount: parseInt(failureCount || '0', 10),
505
+ halfOpenCount: halfOpenValue,
506
+ lastFailureTime,
507
+ timeSinceLastFailure,
508
+ config: {
509
+ failureThreshold: CIRCUIT_CONFIG.failureThreshold,
510
+ failureWindowMs: CIRCUIT_CONFIG.failureWindowMs,
511
+ recoveryTimeoutMs: CIRCUIT_CONFIG.recoveryTimeoutMs
512
+ }
513
+ };
514
+ }
515
+
516
+ // ======================================================
517
+ // HELPER: Check all circuit breakers at once
518
+ // ======================================================
519
+
520
+ /**
521
+ * Check if any circuit is open (overall runtime health).
522
+ */
523
+ async function hasAnyOpenCircuit(redis) {
524
+ const services = Object.values(SERVICE_TARGETS);
525
+ const openCircuits = [];
526
+
527
+ for (const service of services) {
528
+ const state = await getCircuitState(redis, service);
529
+ if (state === CircuitState.OPEN) {
530
+ openCircuits.push(service);
531
+ }
532
+ }
533
+
534
+ return {
535
+ hasOpen: openCircuits.length > 0,
536
+ openCircuits
537
+ };
538
+ }
539
+
540
+ /**
541
+ * Check if runtime is healthy (all circuits closed).
542
+ */
543
+ async function isRuntimeHealthy(redis) {
544
+ const status = await getCircuitsStatus(redis);
545
+ return Object.values(status).every(c => c.state === CircuitState.CLOSED);
546
+ }
547
+
548
+ // ======================================================
549
+ // exported
550
+ // ======================================================
551
+
552
+ module.exports = {
553
+ // Constants
554
+ CircuitState,
555
+ SERVICE_TARGETS,
556
+
557
+ // Config
558
+ CIRCUIT_CONFIG,
559
+
560
+ // State management
561
+ getCircuitStateKey,
562
+ getCircuitState,
563
+ setCircuitState,
564
+ isCircuitOpen,
565
+ isCircuitHalfOpen,
566
+
567
+ // Failure tracking
568
+ recordFailure,
569
+ recordSuccess,
570
+ hasRecentFailures,
571
+
572
+ // Dispatch check
573
+ checkDispatch,
574
+ checkAndIncrementTestRequest,
575
+ checkDispatchWithRecovery,
576
+ releaseHalfOpenPermit,
577
+
578
+ // Recovery
579
+ tryRecover,
580
+ forceOpen,
581
+ forceClose,
582
+
583
+ // Metrics
584
+ getCircuitsStatus,
585
+ getCircuitMetrics,
586
+ hasAnyOpenCircuit,
587
+ isRuntimeHealthy
588
+ };