kiro-spec-engine 1.47.10 → 1.47.12

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.
@@ -16,6 +16,23 @@ const path = require('path');
16
16
  const fsUtils = require('../utils/fs-utils');
17
17
 
18
18
  const SPECS_DIR = '.kiro/specs';
19
+ const DEFAULT_RATE_LIMIT_MAX_RETRIES = 6;
20
+ const DEFAULT_RATE_LIMIT_BACKOFF_BASE_MS = 1000;
21
+ const DEFAULT_RATE_LIMIT_BACKOFF_MAX_MS = 30000;
22
+ const DEFAULT_RATE_LIMIT_ADAPTIVE_PARALLEL = true;
23
+ const DEFAULT_RATE_LIMIT_PARALLEL_FLOOR = 1;
24
+ const DEFAULT_RATE_LIMIT_COOLDOWN_MS = 30000;
25
+ const RATE_LIMIT_BACKOFF_JITTER_RATIO = 0.5;
26
+ const RATE_LIMIT_ERROR_PATTERNS = [
27
+ /(^|[^0-9])429([^0-9]|$)/i,
28
+ /too many requests/i,
29
+ /rate[\s-]?limit/i,
30
+ /resource exhausted/i,
31
+ /quota exceeded/i,
32
+ /exceeded.*quota/i,
33
+ /requests per minute/i,
34
+ /tokens per minute/i,
35
+ ];
19
36
 
20
37
  class OrchestrationEngine extends EventEmitter {
21
38
  /**
@@ -54,6 +71,28 @@ class OrchestrationEngine extends EventEmitter {
54
71
  this._stopped = false;
55
72
  /** @type {object|null} execution plan */
56
73
  this._executionPlan = null;
74
+ /** @type {number} max retries for rate-limit failures */
75
+ this._rateLimitMaxRetries = DEFAULT_RATE_LIMIT_MAX_RETRIES;
76
+ /** @type {number} base delay for rate-limit retries */
77
+ this._rateLimitBackoffBaseMs = DEFAULT_RATE_LIMIT_BACKOFF_BASE_MS;
78
+ /** @type {number} max delay for rate-limit retries */
79
+ this._rateLimitBackoffMaxMs = DEFAULT_RATE_LIMIT_BACKOFF_MAX_MS;
80
+ /** @type {boolean} enable adaptive parallel throttling on rate-limit signals */
81
+ this._rateLimitAdaptiveParallel = DEFAULT_RATE_LIMIT_ADAPTIVE_PARALLEL;
82
+ /** @type {number} minimum effective parallelism during rate-limit cooldown */
83
+ this._rateLimitParallelFloor = DEFAULT_RATE_LIMIT_PARALLEL_FLOOR;
84
+ /** @type {number} cooldown before each adaptive parallel recovery step */
85
+ this._rateLimitCooldownMs = DEFAULT_RATE_LIMIT_COOLDOWN_MS;
86
+ /** @type {number|null} configured max parallel for current run */
87
+ this._baseMaxParallel = null;
88
+ /** @type {number|null} dynamic effective parallel limit for current run */
89
+ this._effectiveMaxParallel = null;
90
+ /** @type {number} timestamp after which recovery can step up */
91
+ this._rateLimitCooldownUntil = 0;
92
+ /** @type {() => number} */
93
+ this._random = typeof options.random === 'function' ? options.random : Math.random;
94
+ /** @type {() => number} */
95
+ this._now = typeof options.now === 'function' ? options.now : Date.now;
57
96
  }
58
97
 
59
98
  // ---------------------------------------------------------------------------
@@ -135,8 +174,10 @@ class OrchestrationEngine extends EventEmitter {
135
174
 
136
175
  // Get config for maxParallel and maxRetries
137
176
  const config = await this._orchestratorConfig.getConfig();
177
+ this._applyRetryPolicyConfig(config);
138
178
  const maxParallel = options.maxParallel || config.maxParallel || 3;
139
179
  const maxRetries = config.maxRetries || 2;
180
+ this._initializeAdaptiveParallel(maxParallel);
140
181
 
141
182
  // Step 5: Execute batches (Req 3.4)
142
183
  await this._executeBatches(batches, maxParallel, maxRetries);
@@ -250,7 +291,11 @@ class OrchestrationEngine extends EventEmitter {
250
291
  const inFlight = new Map(); // specName → Promise
251
292
 
252
293
  const launchNext = async () => {
253
- while (pending.length > 0 && inFlight.size < maxParallel && !this._stopped) {
294
+ while (
295
+ pending.length > 0
296
+ && inFlight.size < this._getEffectiveMaxParallel(maxParallel)
297
+ && !this._stopped
298
+ ) {
254
299
  const specName = pending.shift();
255
300
  if (this._skippedSpecs.has(specName)) continue;
256
301
 
@@ -400,25 +445,47 @@ class OrchestrationEngine extends EventEmitter {
400
445
  * @private
401
446
  */
402
447
  async _handleSpecFailed(specName, agentId, maxRetries, error) {
448
+ const resolvedError = `${error || 'Unknown error'}`;
403
449
  const retryCount = this._retryCounts.get(specName) || 0;
450
+ const isRateLimitError = this._isRateLimitError(resolvedError);
451
+ const retryLimit = isRateLimitError
452
+ ? Math.max(maxRetries, this._rateLimitMaxRetries || DEFAULT_RATE_LIMIT_MAX_RETRIES)
453
+ : maxRetries;
404
454
 
405
- if (retryCount < maxRetries && !this._stopped) {
455
+ if (retryCount < retryLimit && !this._stopped) {
406
456
  // Retry (Req 5.2)
407
457
  this._retryCounts.set(specName, retryCount + 1);
408
458
  this._statusMonitor.incrementRetry(specName);
409
- this._statusMonitor.updateSpecStatus(specName, 'pending', null, error);
459
+ this._statusMonitor.updateSpecStatus(specName, 'pending', null, resolvedError);
460
+
461
+ const retryDelayMs = isRateLimitError
462
+ ? this._calculateRateLimitBackoffMs(retryCount)
463
+ : 0;
464
+ if (retryDelayMs > 0) {
465
+ this._onRateLimitSignal();
466
+ this.emit('spec:rate-limited', {
467
+ specName,
468
+ retryCount,
469
+ retryDelayMs,
470
+ error: resolvedError,
471
+ });
472
+ await this._sleep(retryDelayMs);
473
+ if (this._stopped) {
474
+ return;
475
+ }
476
+ }
410
477
 
411
478
  // Re-execute
412
479
  await this._executeSpec(specName, maxRetries);
413
480
  } else {
414
481
  // Final failure (Req 5.3)
415
482
  this._failedSpecs.add(specName);
416
- this._statusMonitor.updateSpecStatus(specName, 'failed', agentId, error);
483
+ this._statusMonitor.updateSpecStatus(specName, 'failed', agentId, resolvedError);
417
484
 
418
485
  // Sync external status
419
486
  await this._syncExternalSafe(specName, 'failed');
420
487
 
421
- this.emit('spec:failed', { specName, agentId, error, retryCount });
488
+ this.emit('spec:failed', { specName, agentId, error: resolvedError, retryCount });
422
489
 
423
490
  // Propagate failure to dependents (Req 3.6)
424
491
  this._propagateFailure(specName);
@@ -537,6 +604,243 @@ class OrchestrationEngine extends EventEmitter {
537
604
  // Validation & Helpers
538
605
  // ---------------------------------------------------------------------------
539
606
 
607
+ /**
608
+ * Resolve retry-related runtime config with safe defaults.
609
+ *
610
+ * @param {object} config
611
+ * @private
612
+ */
613
+ _applyRetryPolicyConfig(config) {
614
+ this._rateLimitMaxRetries = this._toNonNegativeInteger(
615
+ config && config.rateLimitMaxRetries,
616
+ DEFAULT_RATE_LIMIT_MAX_RETRIES
617
+ );
618
+
619
+ const baseMs = this._toPositiveInteger(
620
+ config && config.rateLimitBackoffBaseMs,
621
+ DEFAULT_RATE_LIMIT_BACKOFF_BASE_MS
622
+ );
623
+ const maxMs = this._toPositiveInteger(
624
+ config && config.rateLimitBackoffMaxMs,
625
+ DEFAULT_RATE_LIMIT_BACKOFF_MAX_MS
626
+ );
627
+
628
+ this._rateLimitBackoffBaseMs = Math.min(baseMs, maxMs);
629
+ this._rateLimitBackoffMaxMs = Math.max(baseMs, maxMs);
630
+ this._rateLimitAdaptiveParallel = this._toBoolean(
631
+ config && config.rateLimitAdaptiveParallel,
632
+ DEFAULT_RATE_LIMIT_ADAPTIVE_PARALLEL
633
+ );
634
+ this._rateLimitParallelFloor = this._toPositiveInteger(
635
+ config && config.rateLimitParallelFloor,
636
+ DEFAULT_RATE_LIMIT_PARALLEL_FLOOR
637
+ );
638
+ this._rateLimitCooldownMs = this._toPositiveInteger(
639
+ config && config.rateLimitCooldownMs,
640
+ DEFAULT_RATE_LIMIT_COOLDOWN_MS
641
+ );
642
+ }
643
+
644
+ /**
645
+ * @param {number} maxParallel
646
+ * @private
647
+ */
648
+ _initializeAdaptiveParallel(maxParallel) {
649
+ const boundedMax = this._toPositiveInteger(maxParallel, 1);
650
+ this._baseMaxParallel = boundedMax;
651
+ this._effectiveMaxParallel = boundedMax;
652
+ this._rateLimitCooldownUntil = 0;
653
+ }
654
+
655
+ /**
656
+ * @param {number} maxParallel
657
+ * @returns {number}
658
+ * @private
659
+ */
660
+ _getEffectiveMaxParallel(maxParallel) {
661
+ const boundedMax = this._toPositiveInteger(maxParallel, 1);
662
+ if (!this._isAdaptiveParallelEnabled()) {
663
+ this._baseMaxParallel = boundedMax;
664
+ return boundedMax;
665
+ }
666
+
667
+ this._baseMaxParallel = boundedMax;
668
+ this._maybeRecoverParallelLimit(boundedMax);
669
+
670
+ const floor = Math.min(
671
+ boundedMax,
672
+ this._toPositiveInteger(this._rateLimitParallelFloor, DEFAULT_RATE_LIMIT_PARALLEL_FLOOR)
673
+ );
674
+ const effective = this._toPositiveInteger(this._effectiveMaxParallel, boundedMax);
675
+ return Math.max(floor, Math.min(boundedMax, effective));
676
+ }
677
+
678
+ /**
679
+ * @private
680
+ */
681
+ _onRateLimitSignal() {
682
+ if (!this._isAdaptiveParallelEnabled()) {
683
+ return;
684
+ }
685
+
686
+ const base = this._toPositiveInteger(this._baseMaxParallel, 1);
687
+ const current = this._toPositiveInteger(this._effectiveMaxParallel, base);
688
+ const floor = Math.min(
689
+ base,
690
+ this._toPositiveInteger(this._rateLimitParallelFloor, DEFAULT_RATE_LIMIT_PARALLEL_FLOOR)
691
+ );
692
+ const next = Math.max(floor, Math.floor(current / 2));
693
+
694
+ if (next < current) {
695
+ this._effectiveMaxParallel = next;
696
+ this.emit('parallel:throttled', {
697
+ reason: 'rate-limit',
698
+ previousMaxParallel: current,
699
+ effectiveMaxParallel: next,
700
+ floor,
701
+ });
702
+ } else {
703
+ this._effectiveMaxParallel = current;
704
+ }
705
+
706
+ this._rateLimitCooldownUntil = this._getNow() + this._rateLimitCooldownMs;
707
+ }
708
+
709
+ /**
710
+ * @param {number} maxParallel
711
+ * @private
712
+ */
713
+ _maybeRecoverParallelLimit(maxParallel) {
714
+ if (!this._isAdaptiveParallelEnabled()) {
715
+ return;
716
+ }
717
+
718
+ const boundedMax = this._toPositiveInteger(maxParallel, 1);
719
+ const current = this._toPositiveInteger(this._effectiveMaxParallel, boundedMax);
720
+ if (current >= boundedMax) {
721
+ this._effectiveMaxParallel = boundedMax;
722
+ return;
723
+ }
724
+
725
+ if (this._getNow() < this._rateLimitCooldownUntil) {
726
+ return;
727
+ }
728
+
729
+ const next = Math.min(boundedMax, current + 1);
730
+ if (next > current) {
731
+ this._effectiveMaxParallel = next;
732
+ this._rateLimitCooldownUntil = this._getNow() + this._rateLimitCooldownMs;
733
+ this.emit('parallel:recovered', {
734
+ previousMaxParallel: current,
735
+ effectiveMaxParallel: next,
736
+ maxParallel: boundedMax,
737
+ });
738
+ }
739
+ }
740
+
741
+ /**
742
+ * @returns {boolean}
743
+ * @private
744
+ */
745
+ _isAdaptiveParallelEnabled() {
746
+ if (typeof this._rateLimitAdaptiveParallel === 'boolean') {
747
+ return this._rateLimitAdaptiveParallel;
748
+ }
749
+ return DEFAULT_RATE_LIMIT_ADAPTIVE_PARALLEL;
750
+ }
751
+
752
+ /**
753
+ * @returns {number}
754
+ * @private
755
+ */
756
+ _getNow() {
757
+ return typeof this._now === 'function' ? this._now() : Date.now();
758
+ }
759
+
760
+ /**
761
+ * @param {any} value
762
+ * @param {boolean} fallback
763
+ * @returns {boolean}
764
+ * @private
765
+ */
766
+ _toBoolean(value, fallback) {
767
+ if (typeof value === 'boolean') {
768
+ return value;
769
+ }
770
+ return fallback;
771
+ }
772
+
773
+ /**
774
+ * @param {any} value
775
+ * @param {number} fallback
776
+ * @returns {number}
777
+ * @private
778
+ */
779
+ _toPositiveInteger(value, fallback) {
780
+ const numeric = Number(value);
781
+ if (!Number.isFinite(numeric) || numeric <= 0) {
782
+ return fallback;
783
+ }
784
+ return Math.floor(numeric);
785
+ }
786
+
787
+ /**
788
+ * @param {any} value
789
+ * @param {number} fallback
790
+ * @returns {number}
791
+ * @private
792
+ */
793
+ _toNonNegativeInteger(value, fallback) {
794
+ const numeric = Number(value);
795
+ if (!Number.isFinite(numeric) || numeric < 0) {
796
+ return fallback;
797
+ }
798
+ return Math.floor(numeric);
799
+ }
800
+
801
+ /**
802
+ * @param {string} error
803
+ * @returns {boolean}
804
+ * @private
805
+ */
806
+ _isRateLimitError(error) {
807
+ return RATE_LIMIT_ERROR_PATTERNS.some(pattern => pattern.test(`${error || ''}`));
808
+ }
809
+
810
+ /**
811
+ * @param {number} retryCount
812
+ * @returns {number}
813
+ * @private
814
+ */
815
+ _calculateRateLimitBackoffMs(retryCount) {
816
+ const exponent = Math.max(0, retryCount);
817
+ const cappedBaseDelay = Math.min(
818
+ this._rateLimitBackoffMaxMs || DEFAULT_RATE_LIMIT_BACKOFF_MAX_MS,
819
+ (this._rateLimitBackoffBaseMs || DEFAULT_RATE_LIMIT_BACKOFF_BASE_MS) * (2 ** exponent)
820
+ );
821
+
822
+ const randomValue = typeof this._random === 'function' ? this._random() : Math.random();
823
+ const normalizedRandom = Number.isFinite(randomValue)
824
+ ? Math.min(1, Math.max(0, randomValue))
825
+ : 0.5;
826
+ const jitterFactor = (1 - RATE_LIMIT_BACKOFF_JITTER_RATIO)
827
+ + (normalizedRandom * RATE_LIMIT_BACKOFF_JITTER_RATIO);
828
+
829
+ return Math.max(1, Math.round(cappedBaseDelay * jitterFactor));
830
+ }
831
+
832
+ /**
833
+ * @param {number} ms
834
+ * @returns {Promise<void>}
835
+ * @private
836
+ */
837
+ _sleep(ms) {
838
+ if (!ms || ms <= 0) {
839
+ return Promise.resolve();
840
+ }
841
+ return new Promise((resolve) => setTimeout(resolve, ms));
842
+ }
843
+
540
844
  /**
541
845
  * Validate that all spec directories exist (Req 6.4).
542
846
  *
@@ -625,6 +929,9 @@ class OrchestrationEngine extends EventEmitter {
625
929
  this._completedSpecs.clear();
626
930
  this._executionPlan = null;
627
931
  this._stopped = false;
932
+ this._baseMaxParallel = null;
933
+ this._effectiveMaxParallel = null;
934
+ this._rateLimitCooldownUntil = 0;
628
935
  }
629
936
  }
630
937
 
@@ -24,6 +24,12 @@ const KNOWN_KEYS = new Set([
24
24
  'maxParallel',
25
25
  'timeoutSeconds',
26
26
  'maxRetries',
27
+ 'rateLimitMaxRetries',
28
+ 'rateLimitBackoffBaseMs',
29
+ 'rateLimitBackoffMaxMs',
30
+ 'rateLimitAdaptiveParallel',
31
+ 'rateLimitParallelFloor',
32
+ 'rateLimitCooldownMs',
27
33
  'apiKeyEnvVar',
28
34
  'bootstrapTemplate',
29
35
  'codexArgs',
@@ -36,6 +42,12 @@ const DEFAULT_CONFIG = Object.freeze({
36
42
  maxParallel: 3,
37
43
  timeoutSeconds: 600,
38
44
  maxRetries: 2,
45
+ rateLimitMaxRetries: 6,
46
+ rateLimitBackoffBaseMs: 1000,
47
+ rateLimitBackoffMaxMs: 30000,
48
+ rateLimitAdaptiveParallel: true,
49
+ rateLimitParallelFloor: 1,
50
+ rateLimitCooldownMs: 30000,
39
51
  apiKeyEnvVar: 'CODEX_API_KEY',
40
52
  bootstrapTemplate: null,
41
53
  codexArgs: [],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kiro-spec-engine",
3
- "version": "1.47.10",
3
+ "version": "1.47.12",
4
4
  "description": "kiro-spec-engine (kse) - A CLI tool and npm package for spec-driven development with AI coding assistants. NOT the Kiro IDE desktop application.",
5
5
  "main": "index.js",
6
6
  "bin": {