scene-capability-engine 3.0.0
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/CHANGELOG.md +2513 -0
- package/LICENSE +21 -0
- package/README.md +765 -0
- package/README.zh.md +630 -0
- package/bin/kiro-spec-engine.js +796 -0
- package/bin/kse.js +3 -0
- package/bin/sce.js +3 -0
- package/bin/sco.js +3 -0
- package/docs/331-poc-adaptation-roadmap.md +156 -0
- package/docs/331-poc-dual-track-integration-guide.md +120 -0
- package/docs/331-poc-weekly-delivery-checklist.md +52 -0
- package/docs/OFFLINE_INSTALL.md +96 -0
- package/docs/README.md +279 -0
- package/docs/adopt-migration-guide.md +599 -0
- package/docs/adoption-guide.md +616 -0
- package/docs/agent-hooks-analysis.md +815 -0
- package/docs/architecture.md +733 -0
- package/docs/articles/ai-driven-development-philosophy-and-practice-review.md +208 -0
- package/docs/articles/ai-driven-development-philosophy-and-practice.en.md +459 -0
- package/docs/articles/ai-driven-development-philosophy-and-practice.md +492 -0
- package/docs/autonomous-control-guide.md +851 -0
- package/docs/command-reference.md +1368 -0
- package/docs/community.md +115 -0
- package/docs/cross-tool-guide.md +555 -0
- package/docs/developer-guide.md +619 -0
- package/docs/document-governance.md +865 -0
- package/docs/environment-management-guide.md +526 -0
- package/docs/examples/add-export-command/design.md +194 -0
- package/docs/examples/add-export-command/requirements.md +110 -0
- package/docs/examples/add-export-command/tasks.md +88 -0
- package/docs/examples/add-rest-api/design.md +855 -0
- package/docs/examples/add-rest-api/requirements.md +323 -0
- package/docs/examples/add-rest-api/tasks.md +355 -0
- package/docs/examples/add-user-dashboard/design.md +192 -0
- package/docs/examples/add-user-dashboard/requirements.md +143 -0
- package/docs/examples/add-user-dashboard/tasks.md +91 -0
- package/docs/faq.md +697 -0
- package/docs/handoffs/evidence/ontology/moqui-template-baseline-2026-02-17-232922.json +156 -0
- package/docs/handoffs/evidence/ontology/moqui-template-baseline-2026-02-17-232922.md +24 -0
- package/docs/images/wechat-qr.png +0 -0
- package/docs/integration-modes.md +529 -0
- package/docs/integration-philosophy.md +313 -0
- package/docs/knowledge-management-guide.md +263 -0
- package/docs/manual-workflows-guide.md +418 -0
- package/docs/moqui-capability-matrix.md +73 -0
- package/docs/moqui-template-core-library-playbook.md +109 -0
- package/docs/multi-agent-coordination-guide.md +553 -0
- package/docs/multi-repo-management-guide.md +1344 -0
- package/docs/quick-start-with-ai-tools.md +375 -0
- package/docs/quick-start.md +146 -0
- package/docs/release-checklist.md +121 -0
- package/docs/releases/README.md +13 -0
- package/docs/releases/v1.46.2-validation.md +45 -0
- package/docs/releases/v1.46.2.md +50 -0
- package/docs/scene-runtime-guide.md +347 -0
- package/docs/spec-collaboration-guide.md +369 -0
- package/docs/spec-locking-guide.md +225 -0
- package/docs/spec-numbering-guide.md +348 -0
- package/docs/spec-workflow.md +519 -0
- package/docs/steering-strategy-guide.md +196 -0
- package/docs/team-collaboration-guide.md +465 -0
- package/docs/testing-strategy.md +272 -0
- package/docs/tools/claude-guide.md +654 -0
- package/docs/tools/cursor-guide.md +706 -0
- package/docs/tools/generic-guide.md +446 -0
- package/docs/tools/kiro-guide.md +308 -0
- package/docs/tools/vscode-guide.md +445 -0
- package/docs/tools/windsurf-guide.md +391 -0
- package/docs/troubleshooting.md +1135 -0
- package/docs/upgrade-guide.md +639 -0
- package/docs/value-observability-guide.md +127 -0
- package/docs/zh/README.md +341 -0
- package/docs/zh/quick-start.md +764 -0
- package/docs/zh/release-checklist.md +121 -0
- package/docs/zh/releases/README.md +13 -0
- package/docs/zh/releases/v1.46.2-validation.md +45 -0
- package/docs/zh/releases/v1.46.2.md +50 -0
- package/docs/zh/spec-numbering-guide.md +348 -0
- package/docs/zh/tools/claude-guide.md +349 -0
- package/docs/zh/tools/cursor-guide.md +281 -0
- package/docs/zh/tools/generic-guide.md +499 -0
- package/docs/zh/tools/kiro-guide.md +342 -0
- package/docs/zh/tools/vscode-guide.md +449 -0
- package/docs/zh/tools/windsurf-guide.md +378 -0
- package/docs/zh/value-observability-guide.md +127 -0
- package/docs//344/272/244/344/273/230/346/270/205/345/215/225.md +75 -0
- package/lib/adoption/adoption-logger.js +487 -0
- package/lib/adoption/adoption-strategy.js +538 -0
- package/lib/adoption/backup-manager.js +420 -0
- package/lib/adoption/conflict-resolver.js +410 -0
- package/lib/adoption/detection-engine.js +275 -0
- package/lib/adoption/diff-viewer.js +226 -0
- package/lib/adoption/error-formatter.js +509 -0
- package/lib/adoption/file-classifier.js +385 -0
- package/lib/adoption/progress-reporter.js +534 -0
- package/lib/adoption/smart-orchestrator.js +470 -0
- package/lib/adoption/strategy-selector.js +218 -0
- package/lib/adoption/summary-generator.js +493 -0
- package/lib/adoption/template-sync.js +605 -0
- package/lib/auto/autonomous-engine.js +485 -0
- package/lib/auto/checkpoint-manager.js +300 -0
- package/lib/auto/close-loop-runner.js +2476 -0
- package/lib/auto/config-schema.js +176 -0
- package/lib/auto/decision-engine.js +344 -0
- package/lib/auto/error-recovery-manager.js +580 -0
- package/lib/auto/goal-decomposer.js +278 -0
- package/lib/auto/progress-tracker.js +502 -0
- package/lib/auto/safety-manager.js +186 -0
- package/lib/auto/semantic-decomposer.js +137 -0
- package/lib/auto/state-manager.js +126 -0
- package/lib/auto/task-queue-manager.js +340 -0
- package/lib/backup/backup-system.js +372 -0
- package/lib/backup/selective-backup.js +207 -0
- package/lib/collab/agent-registry.js +240 -0
- package/lib/collab/collab-manager.js +285 -0
- package/lib/collab/contract-manager.js +320 -0
- package/lib/collab/coordinator.js +370 -0
- package/lib/collab/dependency-manager.js +280 -0
- package/lib/collab/index.js +20 -0
- package/lib/collab/integration-manager.js +202 -0
- package/lib/collab/merge-coordinator.js +252 -0
- package/lib/collab/metadata-manager.js +233 -0
- package/lib/collab/multi-agent-config.js +120 -0
- package/lib/collab/spec-lifecycle-manager.js +304 -0
- package/lib/collab/sync-barrier.js +88 -0
- package/lib/collab/visualizer.js +208 -0
- package/lib/commands/adopt.js +749 -0
- package/lib/commands/auto.js +19559 -0
- package/lib/commands/collab.js +275 -0
- package/lib/commands/context.js +99 -0
- package/lib/commands/docs.js +808 -0
- package/lib/commands/doctor.js +273 -0
- package/lib/commands/env.js +420 -0
- package/lib/commands/knowledge.js +309 -0
- package/lib/commands/lock.js +235 -0
- package/lib/commands/ops.js +409 -0
- package/lib/commands/orchestrate.js +446 -0
- package/lib/commands/prompt.js +105 -0
- package/lib/commands/repo.js +118 -0
- package/lib/commands/rollback.js +219 -0
- package/lib/commands/scene.js +15549 -0
- package/lib/commands/spec-bootstrap.js +147 -0
- package/lib/commands/spec-gate.js +157 -0
- package/lib/commands/spec-pipeline.js +205 -0
- package/lib/commands/status.js +321 -0
- package/lib/commands/task.js +199 -0
- package/lib/commands/templates.js +654 -0
- package/lib/commands/upgrade.js +231 -0
- package/lib/commands/value.js +569 -0
- package/lib/commands/watch.js +684 -0
- package/lib/commands/workflows.js +240 -0
- package/lib/commands/workspace-multi.js +325 -0
- package/lib/commands/workspace.js +189 -0
- package/lib/context/context-exporter.js +378 -0
- package/lib/context/prompt-generator.js +482 -0
- package/lib/data/moqui-capability-lexicon.json +45 -0
- package/lib/environment/backup-system.js +189 -0
- package/lib/environment/environment-manager.js +379 -0
- package/lib/environment/environment-registry.js +168 -0
- package/lib/gitignore/gitignore-backup.js +229 -0
- package/lib/gitignore/gitignore-detector.js +239 -0
- package/lib/gitignore/gitignore-integration.js +267 -0
- package/lib/gitignore/gitignore-transformer.js +193 -0
- package/lib/gitignore/layered-rules-template.js +42 -0
- package/lib/governance/archive-tool.js +284 -0
- package/lib/governance/cleanup-tool.js +237 -0
- package/lib/governance/config-manager.js +186 -0
- package/lib/governance/diagnostic-engine.js +271 -0
- package/lib/governance/doc-reference-checker.js +200 -0
- package/lib/governance/execution-logger.js +243 -0
- package/lib/governance/file-scanner.js +285 -0
- package/lib/governance/hooks-manager.js +333 -0
- package/lib/governance/reporter.js +337 -0
- package/lib/governance/validation-engine.js +181 -0
- package/lib/i18n.js +79 -0
- package/lib/knowledge/entry-manager.js +208 -0
- package/lib/knowledge/index-manager.js +261 -0
- package/lib/knowledge/knowledge-manager.js +273 -0
- package/lib/knowledge/template-manager.js +191 -0
- package/lib/lock/index.js +21 -0
- package/lib/lock/lock-file.js +192 -0
- package/lib/lock/lock-manager.js +321 -0
- package/lib/lock/machine-identifier.js +135 -0
- package/lib/lock/steering-file-lock.js +207 -0
- package/lib/lock/task-lock-manager.js +345 -0
- package/lib/operations/audit-logger.js +293 -0
- package/lib/operations/feedback-manager.js +1147 -0
- package/lib/operations/index.js +23 -0
- package/lib/operations/models/index.js +170 -0
- package/lib/operations/operations-manager.js +151 -0
- package/lib/operations/operations-validator.js +280 -0
- package/lib/operations/permission-manager.js +354 -0
- package/lib/operations/template-loader.js +143 -0
- package/lib/orchestrator/agent-spawner.js +629 -0
- package/lib/orchestrator/bootstrap-prompt-builder.js +236 -0
- package/lib/orchestrator/index.js +19 -0
- package/lib/orchestrator/orchestration-engine.js +1270 -0
- package/lib/orchestrator/orchestrator-config.js +173 -0
- package/lib/orchestrator/status-monitor.js +591 -0
- package/lib/python-checker.js +209 -0
- package/lib/repo/config-manager.js +580 -0
- package/lib/repo/errors/config-error.js +13 -0
- package/lib/repo/errors/git-error.js +15 -0
- package/lib/repo/errors/repo-error.js +14 -0
- package/lib/repo/git-operations.js +181 -0
- package/lib/repo/handlers/.gitkeep +1 -0
- package/lib/repo/handlers/exec-handler.js +155 -0
- package/lib/repo/handlers/health-handler.js +169 -0
- package/lib/repo/handlers/init-handler.js +197 -0
- package/lib/repo/handlers/status-handler.js +176 -0
- package/lib/repo/output-formatter.js +184 -0
- package/lib/repo/path-resolver.js +178 -0
- package/lib/repo/repo-manager.js +514 -0
- package/lib/scene-runtime/audit-emitter.js +59 -0
- package/lib/scene-runtime/binding-plugin-loader.js +351 -0
- package/lib/scene-runtime/binding-registry.js +349 -0
- package/lib/scene-runtime/eval-bridge.js +44 -0
- package/lib/scene-runtime/index.js +19 -0
- package/lib/scene-runtime/moqui-adapter.js +620 -0
- package/lib/scene-runtime/moqui-client.js +606 -0
- package/lib/scene-runtime/moqui-extractor.js +2029 -0
- package/lib/scene-runtime/plan-compiler.js +208 -0
- package/lib/scene-runtime/policy-gate.js +58 -0
- package/lib/scene-runtime/runtime-executor.js +358 -0
- package/lib/scene-runtime/scene-loader.js +96 -0
- package/lib/scene-runtime/scene-ontology.js +959 -0
- package/lib/scene-runtime/scene-template-linter.js +852 -0
- package/lib/scene-runtime/templates/scene-template-erp-query-v0.1.yaml +28 -0
- package/lib/scene-runtime/templates/scene-template-hybrid-shadow-v0.1.yaml +34 -0
- package/lib/spec/bootstrap/context-collector.js +48 -0
- package/lib/spec/bootstrap/draft-generator.js +158 -0
- package/lib/spec/bootstrap/questionnaire-engine.js +70 -0
- package/lib/spec/bootstrap/trace-emitter.js +59 -0
- package/lib/spec/multi-spec-orchestrate.js +93 -0
- package/lib/spec/pipeline/constants.js +6 -0
- package/lib/spec/pipeline/stage-adapters.js +118 -0
- package/lib/spec/pipeline/stage-runner.js +146 -0
- package/lib/spec/pipeline/state-store.js +119 -0
- package/lib/spec-gate/engine/gate-engine.js +165 -0
- package/lib/spec-gate/policy/default-policy.js +22 -0
- package/lib/spec-gate/policy/policy-loader.js +103 -0
- package/lib/spec-gate/result-emitter.js +81 -0
- package/lib/spec-gate/rules/default-rules.js +156 -0
- package/lib/spec-gate/rules/rule-registry.js +51 -0
- package/lib/steering/adoption-config.js +164 -0
- package/lib/steering/compliance-auto-fixer.js +204 -0
- package/lib/steering/compliance-cache.js +99 -0
- package/lib/steering/compliance-error-reporter.js +70 -0
- package/lib/steering/context-sync-manager.js +273 -0
- package/lib/steering/index.js +92 -0
- package/lib/steering/spec-steering.js +230 -0
- package/lib/steering/steering-compliance-checker.js +73 -0
- package/lib/steering/steering-loader.js +144 -0
- package/lib/steering/steering-manager.js +289 -0
- package/lib/task/index.js +12 -0
- package/lib/task/task-claimer.js +489 -0
- package/lib/task/task-status-store.js +418 -0
- package/lib/templates/cache-manager.js +440 -0
- package/lib/templates/content-generalizer.js +247 -0
- package/lib/templates/frontmatter-generator.js +128 -0
- package/lib/templates/git-handler.js +471 -0
- package/lib/templates/metadata-collector.js +328 -0
- package/lib/templates/path-utils.js +144 -0
- package/lib/templates/registry-parser.js +505 -0
- package/lib/templates/spec-reader.js +216 -0
- package/lib/templates/template-applicator.js +249 -0
- package/lib/templates/template-creator.js +256 -0
- package/lib/templates/template-error.js +143 -0
- package/lib/templates/template-exporter.js +502 -0
- package/lib/templates/template-manager.js +782 -0
- package/lib/templates/template-validator.js +361 -0
- package/lib/upgrade/migration-engine.js +382 -0
- package/lib/upgrade/migrations/.gitkeep +52 -0
- package/lib/upgrade/migrations/1.0.0-to-1.1.0.js +78 -0
- package/lib/utils/file-diff.js +177 -0
- package/lib/utils/fs-utils.js +274 -0
- package/lib/utils/tool-detector.js +383 -0
- package/lib/utils/validation.js +324 -0
- package/lib/value/gate-summary-emitter.js +99 -0
- package/lib/value/metric-contract-loader.js +210 -0
- package/lib/value/risk-evaluator.js +117 -0
- package/lib/value/weekly-snapshot-builder.js +61 -0
- package/lib/version/version-checker.js +156 -0
- package/lib/version/version-manager.js +327 -0
- package/lib/watch/action-executor.js +458 -0
- package/lib/watch/event-debouncer.js +323 -0
- package/lib/watch/execution-logger.js +550 -0
- package/lib/watch/file-watcher.js +499 -0
- package/lib/watch/presets.js +266 -0
- package/lib/watch/watch-manager.js +533 -0
- package/lib/workspace/multi/global-config.js +150 -0
- package/lib/workspace/multi/index.js +22 -0
- package/lib/workspace/multi/path-utils.js +173 -0
- package/lib/workspace/multi/workspace-context-resolver.js +244 -0
- package/lib/workspace/multi/workspace-registry.js +196 -0
- package/lib/workspace/multi/workspace-state-manager.js +537 -0
- package/lib/workspace/multi/workspace.js +90 -0
- package/lib/workspace/workspace-manager.js +370 -0
- package/lib/workspace/workspace-sync.js +356 -0
- package/locales/en.json +114 -0
- package/locales/zh.json +114 -0
- package/package.json +102 -0
- package/template/.kiro/README.md +247 -0
- package/template/.kiro/hooks/check-spec-on-create.kiro.hook +17 -0
- package/template/.kiro/hooks/run-tests-on-save.kiro.hook +13 -0
- package/template/.kiro/hooks/sync-tasks-on-edit.kiro.hook +16 -0
- package/template/.kiro/specs/SPEC_WORKFLOW_GUIDE.md +134 -0
- package/template/.kiro/steering/CORE_PRINCIPLES.md +133 -0
- package/template/.kiro/steering/CURRENT_CONTEXT.md +30 -0
- package/template/.kiro/steering/ENVIRONMENT.md +35 -0
- package/template/.kiro/steering/RULES_GUIDE.md +46 -0
- package/template/.kiro/templates/operations/default/change-impact.md +112 -0
- package/template/.kiro/templates/operations/default/deployment.md +91 -0
- package/template/.kiro/templates/operations/default/feedback-response.md +269 -0
- package/template/.kiro/templates/operations/default/migration-plan.md +172 -0
- package/template/.kiro/templates/operations/default/monitoring.md +135 -0
- package/template/.kiro/templates/operations/default/operations.md +135 -0
- package/template/.kiro/templates/operations/default/rollback.md +143 -0
- package/template/.kiro/templates/operations/default/tools.yaml +364 -0
- package/template/.kiro/templates/operations/default/troubleshooting.md +123 -0
- package/template/.kiro/tools/backup_manager.py +295 -0
- package/template/.kiro/tools/configuration_manager.py +218 -0
- package/template/.kiro/tools/document_evaluator.py +550 -0
- package/template/.kiro/tools/enhancement_logger.py +168 -0
- package/template/.kiro/tools/error_handler.py +335 -0
- package/template/.kiro/tools/improvement_identifier.py +444 -0
- package/template/.kiro/tools/modification_applicator.py +737 -0
- package/template/.kiro/tools/quality_gate_enforcer.py +207 -0
- package/template/.kiro/tools/quality_scorer.py +305 -0
- package/template/.kiro/tools/report_generator.py +154 -0
- package/template/.kiro/tools/ultrawork_enhancer.py +676 -0
- package/template/.kiro/tools/ultrawork_enhancer_refactored.py +0 -0
- package/template/.kiro/tools/ultrawork_enhancer_v2.py +463 -0
- package/template/.kiro/tools/ultrawork_enhancer_v3.py +606 -0
- package/template/.kiro/tools/workflow_quality_gate.py +100 -0
- package/template/README.md +111 -0
|
@@ -0,0 +1,1270 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Orchestration Engine — Batch Scheduling Engine (Core)
|
|
3
|
+
*
|
|
4
|
+
* Coordinates all orchestrator components: builds dependency graphs via
|
|
5
|
+
* DependencyManager, computes topological batches, spawns agents via
|
|
6
|
+
* AgentSpawner, tracks status via StatusMonitor, and integrates with
|
|
7
|
+
* SpecLifecycleManager and AgentRegistry.
|
|
8
|
+
*
|
|
9
|
+
* Requirements: 3.1-3.7 (dependency graph, batches, parallel, failure propagation)
|
|
10
|
+
* 5.1-5.6 (crash detection, retry, timeout, graceful stop, deregister)
|
|
11
|
+
* 8.1-8.5 (SLM transitions, AgentRegistry, TaskLockManager, CSM sync)
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const { EventEmitter } = require('events');
|
|
15
|
+
const path = require('path');
|
|
16
|
+
const fsUtils = require('../utils/fs-utils');
|
|
17
|
+
|
|
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 DEFAULT_RATE_LIMIT_LAUNCH_BUDGET_PER_MINUTE = 12;
|
|
26
|
+
const DEFAULT_RATE_LIMIT_LAUNCH_BUDGET_WINDOW_MS = 60000;
|
|
27
|
+
const RATE_LIMIT_BACKOFF_JITTER_RATIO = 0.5;
|
|
28
|
+
const RATE_LIMIT_RETRY_AFTER_MAX_MS = 10 * 60 * 1000;
|
|
29
|
+
const RATE_LIMIT_ERROR_PATTERNS = [
|
|
30
|
+
/(^|[^0-9])429([^0-9]|$)/i,
|
|
31
|
+
/too many requests/i,
|
|
32
|
+
/rate[\s-]?limit/i,
|
|
33
|
+
/resource exhausted/i,
|
|
34
|
+
/quota exceeded/i,
|
|
35
|
+
/exceeded.*quota/i,
|
|
36
|
+
/requests per minute/i,
|
|
37
|
+
/tokens per minute/i,
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
class OrchestrationEngine extends EventEmitter {
|
|
41
|
+
/**
|
|
42
|
+
* @param {string} workspaceRoot - Absolute path to the project root
|
|
43
|
+
* @param {object} options
|
|
44
|
+
* @param {import('./agent-spawner').AgentSpawner} options.agentSpawner
|
|
45
|
+
* @param {import('../collab/dependency-manager')} options.dependencyManager
|
|
46
|
+
* @param {import('../collab/spec-lifecycle-manager').SpecLifecycleManager} options.specLifecycleManager
|
|
47
|
+
* @param {import('./status-monitor').StatusMonitor} options.statusMonitor
|
|
48
|
+
* @param {import('./orchestrator-config').OrchestratorConfig} options.orchestratorConfig
|
|
49
|
+
* @param {import('../collab/agent-registry').AgentRegistry} options.agentRegistry
|
|
50
|
+
*/
|
|
51
|
+
constructor(workspaceRoot, options) {
|
|
52
|
+
super();
|
|
53
|
+
this._workspaceRoot = workspaceRoot;
|
|
54
|
+
this._agentSpawner = options.agentSpawner;
|
|
55
|
+
this._dependencyManager = options.dependencyManager;
|
|
56
|
+
this._specLifecycleManager = options.specLifecycleManager;
|
|
57
|
+
this._statusMonitor = options.statusMonitor;
|
|
58
|
+
this._orchestratorConfig = options.orchestratorConfig;
|
|
59
|
+
this._agentRegistry = options.agentRegistry;
|
|
60
|
+
|
|
61
|
+
/** @type {'idle'|'running'|'completed'|'failed'|'stopped'} */
|
|
62
|
+
this._state = 'idle';
|
|
63
|
+
/** @type {Map<string, string>} specName → agentId */
|
|
64
|
+
this._runningAgents = new Map();
|
|
65
|
+
/** @type {Map<string, number>} specName → retry count */
|
|
66
|
+
this._retryCounts = new Map();
|
|
67
|
+
/** @type {Set<string>} specs marked as final failure */
|
|
68
|
+
this._failedSpecs = new Set();
|
|
69
|
+
/** @type {Set<string>} specs skipped due to dependency failure */
|
|
70
|
+
this._skippedSpecs = new Set();
|
|
71
|
+
/** @type {Set<string>} specs completed successfully */
|
|
72
|
+
this._completedSpecs = new Set();
|
|
73
|
+
/** @type {boolean} whether stop() has been called */
|
|
74
|
+
this._stopped = false;
|
|
75
|
+
/** @type {object|null} execution plan */
|
|
76
|
+
this._executionPlan = null;
|
|
77
|
+
/** @type {number} max retries for rate-limit failures */
|
|
78
|
+
this._rateLimitMaxRetries = DEFAULT_RATE_LIMIT_MAX_RETRIES;
|
|
79
|
+
/** @type {number} base delay for rate-limit retries */
|
|
80
|
+
this._rateLimitBackoffBaseMs = DEFAULT_RATE_LIMIT_BACKOFF_BASE_MS;
|
|
81
|
+
/** @type {number} max delay for rate-limit retries */
|
|
82
|
+
this._rateLimitBackoffMaxMs = DEFAULT_RATE_LIMIT_BACKOFF_MAX_MS;
|
|
83
|
+
/** @type {boolean} enable adaptive parallel throttling on rate-limit signals */
|
|
84
|
+
this._rateLimitAdaptiveParallel = DEFAULT_RATE_LIMIT_ADAPTIVE_PARALLEL;
|
|
85
|
+
/** @type {number} minimum effective parallelism during rate-limit cooldown */
|
|
86
|
+
this._rateLimitParallelFloor = DEFAULT_RATE_LIMIT_PARALLEL_FLOOR;
|
|
87
|
+
/** @type {number} cooldown before each adaptive parallel recovery step */
|
|
88
|
+
this._rateLimitCooldownMs = DEFAULT_RATE_LIMIT_COOLDOWN_MS;
|
|
89
|
+
/** @type {number|null} configured max parallel for current run */
|
|
90
|
+
this._baseMaxParallel = null;
|
|
91
|
+
/** @type {number|null} dynamic effective parallel limit for current run */
|
|
92
|
+
this._effectiveMaxParallel = null;
|
|
93
|
+
/** @type {number} timestamp after which recovery can step up */
|
|
94
|
+
this._rateLimitCooldownUntil = 0;
|
|
95
|
+
/** @type {number} timestamp before which new launches are paused after rate-limit */
|
|
96
|
+
this._rateLimitLaunchHoldUntil = 0;
|
|
97
|
+
/** @type {number} max spec launches allowed within rolling launch-budget window */
|
|
98
|
+
this._rateLimitLaunchBudgetPerMinute = DEFAULT_RATE_LIMIT_LAUNCH_BUDGET_PER_MINUTE;
|
|
99
|
+
/** @type {number} rolling window size for launch-budget throttling */
|
|
100
|
+
this._rateLimitLaunchBudgetWindowMs = DEFAULT_RATE_LIMIT_LAUNCH_BUDGET_WINDOW_MS;
|
|
101
|
+
/** @type {number[]} timestamps (ms) of recent spec launches for rolling budget accounting */
|
|
102
|
+
this._rateLimitLaunchTimestamps = [];
|
|
103
|
+
/** @type {number} last launch-budget hold telemetry emission timestamp (ms) */
|
|
104
|
+
this._launchBudgetLastHoldSignalAt = 0;
|
|
105
|
+
/** @type {number} last launch-budget hold duration emitted to telemetry (ms) */
|
|
106
|
+
this._launchBudgetLastHoldMs = 0;
|
|
107
|
+
/** @type {() => number} */
|
|
108
|
+
this._random = typeof options.random === 'function' ? options.random : Math.random;
|
|
109
|
+
/** @type {() => number} */
|
|
110
|
+
this._now = typeof options.now === 'function' ? options.now : Date.now;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// Public API
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Start orchestration execution.
|
|
119
|
+
*
|
|
120
|
+
* 1. Validate spec existence
|
|
121
|
+
* 2. Build dependency graph via DependencyManager (Req 3.1, 3.7)
|
|
122
|
+
* 3. Detect circular dependencies (Req 3.2)
|
|
123
|
+
* 4. Compute batches via topological sort (Req 3.3)
|
|
124
|
+
* 5. Execute batches sequentially, specs within batch in parallel (Req 3.4, 3.5)
|
|
125
|
+
*
|
|
126
|
+
* @param {string[]} specNames - Specs to orchestrate
|
|
127
|
+
* @param {object} [options]
|
|
128
|
+
* @param {number} [options.maxParallel] - Override max parallel from config
|
|
129
|
+
* @returns {Promise<object>} OrchestrationResult
|
|
130
|
+
*/
|
|
131
|
+
async start(specNames, options = {}) {
|
|
132
|
+
if (this._state === 'running') {
|
|
133
|
+
throw new Error('Orchestration is already running');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
this._reset();
|
|
137
|
+
this._state = 'running';
|
|
138
|
+
this._stopped = false;
|
|
139
|
+
this._statusMonitor.setOrchestrationState('running');
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
// Step 1: Validate spec existence (Req 6.4)
|
|
143
|
+
const missingSpecs = await this._validateSpecExistence(specNames);
|
|
144
|
+
if (missingSpecs.length > 0) {
|
|
145
|
+
const error = `Specs not found: ${missingSpecs.join(', ')}`;
|
|
146
|
+
this._state = 'failed';
|
|
147
|
+
this._statusMonitor.setOrchestrationState('failed');
|
|
148
|
+
return this._buildResult('failed', error);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Step 2: Build dependency graph (Req 3.1, 3.7)
|
|
152
|
+
const graph = await this._dependencyManager.buildDependencyGraph(specNames);
|
|
153
|
+
|
|
154
|
+
// Step 3: Detect circular dependencies (Req 3.2)
|
|
155
|
+
const cyclePath = this._dependencyManager.detectCircularDependencies(graph);
|
|
156
|
+
if (cyclePath) {
|
|
157
|
+
const error = `Circular dependency detected: ${cyclePath.join(' → ')}`;
|
|
158
|
+
this._state = 'failed';
|
|
159
|
+
this._statusMonitor.setOrchestrationState('failed');
|
|
160
|
+
this._executionPlan = {
|
|
161
|
+
specs: specNames,
|
|
162
|
+
batches: [],
|
|
163
|
+
dependencies: this._extractDependencies(graph, specNames),
|
|
164
|
+
hasCycle: true,
|
|
165
|
+
cyclePath,
|
|
166
|
+
};
|
|
167
|
+
return this._buildResult('failed', error);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Step 4: Compute batches (Req 3.3)
|
|
171
|
+
const dependencies = this._extractDependencies(graph, specNames);
|
|
172
|
+
const batches = this._computeBatches(specNames, dependencies);
|
|
173
|
+
|
|
174
|
+
this._executionPlan = {
|
|
175
|
+
specs: specNames,
|
|
176
|
+
batches,
|
|
177
|
+
dependencies,
|
|
178
|
+
hasCycle: false,
|
|
179
|
+
cyclePath: null,
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
// Initialize specs in StatusMonitor
|
|
183
|
+
for (let batchIdx = 0; batchIdx < batches.length; batchIdx++) {
|
|
184
|
+
for (const specName of batches[batchIdx]) {
|
|
185
|
+
this._statusMonitor.initSpec(specName, batchIdx);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
this._statusMonitor.setBatchInfo(0, batches.length);
|
|
189
|
+
|
|
190
|
+
// Get config for maxParallel and maxRetries
|
|
191
|
+
const config = await this._orchestratorConfig.getConfig();
|
|
192
|
+
this._applyRetryPolicyConfig(config);
|
|
193
|
+
const maxParallel = options.maxParallel || config.maxParallel || 3;
|
|
194
|
+
const maxRetries = config.maxRetries || 2;
|
|
195
|
+
this._initializeAdaptiveParallel(maxParallel);
|
|
196
|
+
|
|
197
|
+
// Step 5: Execute batches (Req 3.4)
|
|
198
|
+
await this._executeBatches(batches, maxParallel, maxRetries);
|
|
199
|
+
|
|
200
|
+
// Determine final state
|
|
201
|
+
if (this._stopped) {
|
|
202
|
+
this._state = 'stopped';
|
|
203
|
+
this._statusMonitor.setOrchestrationState('stopped');
|
|
204
|
+
} else if (this._failedSpecs.size > 0) {
|
|
205
|
+
this._state = 'failed';
|
|
206
|
+
this._statusMonitor.setOrchestrationState('failed');
|
|
207
|
+
} else {
|
|
208
|
+
this._state = 'completed';
|
|
209
|
+
this._statusMonitor.setOrchestrationState('completed');
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
this.emit('orchestration:complete', this._buildResult(this._state));
|
|
213
|
+
return this._buildResult(this._state);
|
|
214
|
+
} catch (err) {
|
|
215
|
+
this._state = 'failed';
|
|
216
|
+
this._statusMonitor.setOrchestrationState('failed');
|
|
217
|
+
return this._buildResult('failed', err.message);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* Gracefully stop all running agents and halt orchestration (Req 5.5).
|
|
223
|
+
* @returns {Promise<void>}
|
|
224
|
+
*/
|
|
225
|
+
async stop() {
|
|
226
|
+
this._stopped = true;
|
|
227
|
+
|
|
228
|
+
if (this._state !== 'running') {
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Kill all running agents
|
|
233
|
+
await this._agentSpawner.killAll();
|
|
234
|
+
|
|
235
|
+
// Mark running specs as stopped
|
|
236
|
+
for (const [specName] of this._runningAgents) {
|
|
237
|
+
this._statusMonitor.updateSpecStatus(specName, 'skipped', null, 'Orchestration stopped');
|
|
238
|
+
}
|
|
239
|
+
this._runningAgents.clear();
|
|
240
|
+
|
|
241
|
+
this._state = 'stopped';
|
|
242
|
+
this._statusMonitor.setOrchestrationState('stopped');
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/**
|
|
246
|
+
* Get current orchestration status.
|
|
247
|
+
* @returns {object} OrchestrationStatus
|
|
248
|
+
*/
|
|
249
|
+
getStatus() {
|
|
250
|
+
return this._statusMonitor.getOrchestrationStatus();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
// ---------------------------------------------------------------------------
|
|
254
|
+
// Batch Execution
|
|
255
|
+
// ---------------------------------------------------------------------------
|
|
256
|
+
|
|
257
|
+
/**
|
|
258
|
+
* Execute all batches sequentially.
|
|
259
|
+
* Within each batch, specs run in parallel up to maxParallel.
|
|
260
|
+
*
|
|
261
|
+
* @param {string[][]} batches
|
|
262
|
+
* @param {number} maxParallel
|
|
263
|
+
* @param {number} maxRetries
|
|
264
|
+
* @returns {Promise<void>}
|
|
265
|
+
* @private
|
|
266
|
+
*/
|
|
267
|
+
async _executeBatches(batches, maxParallel, maxRetries) {
|
|
268
|
+
for (let batchIdx = 0; batchIdx < batches.length; batchIdx++) {
|
|
269
|
+
if (this._stopped) break;
|
|
270
|
+
|
|
271
|
+
const batch = batches[batchIdx];
|
|
272
|
+
this._statusMonitor.setBatchInfo(batchIdx + 1, batches.length);
|
|
273
|
+
|
|
274
|
+
// Filter out skipped specs (dependency failures)
|
|
275
|
+
const executableSpecs = batch.filter(s => !this._skippedSpecs.has(s));
|
|
276
|
+
|
|
277
|
+
if (executableSpecs.length === 0) {
|
|
278
|
+
continue;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
this.emit('batch:start', { batch: batchIdx, specs: executableSpecs });
|
|
282
|
+
|
|
283
|
+
// Execute specs in parallel with maxParallel limit
|
|
284
|
+
await this._executeSpecsInParallel(executableSpecs, maxParallel, maxRetries);
|
|
285
|
+
|
|
286
|
+
this.emit('batch:complete', {
|
|
287
|
+
batch: batchIdx,
|
|
288
|
+
completed: executableSpecs.filter(s => this._completedSpecs.has(s)),
|
|
289
|
+
failed: executableSpecs.filter(s => this._failedSpecs.has(s)),
|
|
290
|
+
skipped: executableSpecs.filter(s => this._skippedSpecs.has(s)),
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Execute a set of specs in parallel, respecting maxParallel limit (Req 3.5).
|
|
297
|
+
*
|
|
298
|
+
* @param {string[]} specNames
|
|
299
|
+
* @param {number} maxParallel
|
|
300
|
+
* @param {number} maxRetries
|
|
301
|
+
* @returns {Promise<void>}
|
|
302
|
+
* @private
|
|
303
|
+
*/
|
|
304
|
+
async _executeSpecsInParallel(specNames, maxParallel, maxRetries) {
|
|
305
|
+
const pending = [...specNames];
|
|
306
|
+
const inFlight = new Map(); // specName → Promise
|
|
307
|
+
|
|
308
|
+
const launchNext = async () => {
|
|
309
|
+
while (pending.length > 0 && !this._stopped) {
|
|
310
|
+
const rateLimitHoldMs = this._getRateLimitLaunchHoldRemainingMs();
|
|
311
|
+
const launchBudgetHoldMs = this._getLaunchBudgetHoldRemainingMs();
|
|
312
|
+
const launchHoldMs = Math.max(rateLimitHoldMs, launchBudgetHoldMs);
|
|
313
|
+
if (launchHoldMs > 0) {
|
|
314
|
+
// Pause new launches when provider asks us to retry later or launch budget is exhausted.
|
|
315
|
+
if (launchBudgetHoldMs > 0) {
|
|
316
|
+
this._onLaunchBudgetHold(launchBudgetHoldMs);
|
|
317
|
+
}
|
|
318
|
+
await this._sleep(Math.min(launchHoldMs, 1000));
|
|
319
|
+
continue;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
if (inFlight.size >= this._getEffectiveMaxParallel(maxParallel)) {
|
|
323
|
+
break;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const specName = pending.shift();
|
|
327
|
+
if (this._skippedSpecs.has(specName)) continue;
|
|
328
|
+
|
|
329
|
+
this._recordLaunchStart();
|
|
330
|
+
const promise = this._executeSpec(specName, maxRetries);
|
|
331
|
+
inFlight.set(specName, promise);
|
|
332
|
+
|
|
333
|
+
// When done, remove from inFlight and try to launch more
|
|
334
|
+
promise.then(() => {
|
|
335
|
+
inFlight.delete(specName);
|
|
336
|
+
});
|
|
337
|
+
}
|
|
338
|
+
};
|
|
339
|
+
|
|
340
|
+
// Initial launch
|
|
341
|
+
await launchNext();
|
|
342
|
+
|
|
343
|
+
// Wait for all in-flight specs to complete, launching new ones as slots open
|
|
344
|
+
while (inFlight.size > 0 && !this._stopped) {
|
|
345
|
+
// Wait for any one to complete
|
|
346
|
+
await Promise.race(inFlight.values());
|
|
347
|
+
// Launch more if slots available
|
|
348
|
+
await launchNext();
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ---------------------------------------------------------------------------
|
|
353
|
+
// Single Spec Execution
|
|
354
|
+
// ---------------------------------------------------------------------------
|
|
355
|
+
|
|
356
|
+
/**
|
|
357
|
+
* Execute a single spec with retry support (Req 5.2, 5.3).
|
|
358
|
+
*
|
|
359
|
+
* @param {string} specName
|
|
360
|
+
* @param {number} maxRetries
|
|
361
|
+
* @returns {Promise<void>}
|
|
362
|
+
* @private
|
|
363
|
+
*/
|
|
364
|
+
async _executeSpec(specName, maxRetries) {
|
|
365
|
+
if (this._stopped) return;
|
|
366
|
+
|
|
367
|
+
this._retryCounts.set(specName, this._retryCounts.get(specName) || 0);
|
|
368
|
+
|
|
369
|
+
// Transition to assigned then in-progress via SLM (Req 8.1)
|
|
370
|
+
await this._transitionSafe(specName, 'assigned');
|
|
371
|
+
await this._transitionSafe(specName, 'in-progress');
|
|
372
|
+
|
|
373
|
+
this._statusMonitor.updateSpecStatus(specName, 'running');
|
|
374
|
+
this.emit('spec:start', { specName });
|
|
375
|
+
|
|
376
|
+
try {
|
|
377
|
+
// Spawn agent via AgentSpawner
|
|
378
|
+
const agent = await this._agentSpawner.spawn(specName);
|
|
379
|
+
this._runningAgents.set(specName, agent.agentId);
|
|
380
|
+
|
|
381
|
+
// Wait for agent completion
|
|
382
|
+
const result = await this._waitForAgent(specName, agent.agentId);
|
|
383
|
+
|
|
384
|
+
this._runningAgents.delete(specName);
|
|
385
|
+
|
|
386
|
+
if (result.status === 'completed') {
|
|
387
|
+
await this._handleSpecCompleted(specName, agent.agentId);
|
|
388
|
+
} else {
|
|
389
|
+
// failed or timeout (Req 5.1, 5.4)
|
|
390
|
+
await this._handleSpecFailed(specName, agent.agentId, maxRetries, result.error);
|
|
391
|
+
}
|
|
392
|
+
} catch (err) {
|
|
393
|
+
// Spawn failure (Req 5.1)
|
|
394
|
+
this._runningAgents.delete(specName);
|
|
395
|
+
await this._handleSpecFailed(specName, null, maxRetries, err.message);
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
/**
|
|
400
|
+
* Wait for an agent to complete, fail, or timeout.
|
|
401
|
+
* Returns a promise that resolves with the outcome.
|
|
402
|
+
*
|
|
403
|
+
* @param {string} specName
|
|
404
|
+
* @param {string} agentId
|
|
405
|
+
* @returns {Promise<{status: string, error: string|null}>}
|
|
406
|
+
* @private
|
|
407
|
+
*/
|
|
408
|
+
_waitForAgent(specName, agentId) {
|
|
409
|
+
return new Promise((resolve) => {
|
|
410
|
+
const onCompleted = (data) => {
|
|
411
|
+
if (data.agentId === agentId) {
|
|
412
|
+
cleanup();
|
|
413
|
+
resolve({ status: 'completed', error: null });
|
|
414
|
+
}
|
|
415
|
+
};
|
|
416
|
+
|
|
417
|
+
const onFailed = (data) => {
|
|
418
|
+
if (data.agentId === agentId) {
|
|
419
|
+
cleanup();
|
|
420
|
+
const error = data.stderr || data.error || `Exit code: ${data.exitCode}`;
|
|
421
|
+
resolve({ status: 'failed', error });
|
|
422
|
+
}
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
const onTimeout = (data) => {
|
|
426
|
+
if (data.agentId === agentId) {
|
|
427
|
+
cleanup();
|
|
428
|
+
resolve({ status: 'timeout', error: `Timeout after ${data.timeoutSeconds}s` });
|
|
429
|
+
}
|
|
430
|
+
};
|
|
431
|
+
|
|
432
|
+
const cleanup = () => {
|
|
433
|
+
this._agentSpawner.removeListener('agent:completed', onCompleted);
|
|
434
|
+
this._agentSpawner.removeListener('agent:failed', onFailed);
|
|
435
|
+
this._agentSpawner.removeListener('agent:timeout', onTimeout);
|
|
436
|
+
};
|
|
437
|
+
|
|
438
|
+
this._agentSpawner.on('agent:completed', onCompleted);
|
|
439
|
+
this._agentSpawner.on('agent:failed', onFailed);
|
|
440
|
+
this._agentSpawner.on('agent:timeout', onTimeout);
|
|
441
|
+
});
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/**
|
|
445
|
+
* Handle successful spec completion (Req 8.2, 5.6).
|
|
446
|
+
*
|
|
447
|
+
* @param {string} specName
|
|
448
|
+
* @param {string} agentId
|
|
449
|
+
* @returns {Promise<void>}
|
|
450
|
+
* @private
|
|
451
|
+
*/
|
|
452
|
+
async _handleSpecCompleted(specName, agentId) {
|
|
453
|
+
this._completedSpecs.add(specName);
|
|
454
|
+
this._statusMonitor.updateSpecStatus(specName, 'completed', agentId);
|
|
455
|
+
|
|
456
|
+
// Transition to completed via SLM (Req 8.2)
|
|
457
|
+
await this._transitionSafe(specName, 'completed');
|
|
458
|
+
|
|
459
|
+
// Sync external status (Req 8.5)
|
|
460
|
+
await this._syncExternalSafe(specName, 'completed');
|
|
461
|
+
|
|
462
|
+
this.emit('spec:complete', { specName, agentId });
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/**
|
|
466
|
+
* Handle spec failure — retry or propagate (Req 5.2, 5.3, 3.6).
|
|
467
|
+
*
|
|
468
|
+
* @param {string} specName
|
|
469
|
+
* @param {string|null} agentId
|
|
470
|
+
* @param {number} maxRetries
|
|
471
|
+
* @param {string} error
|
|
472
|
+
* @returns {Promise<void>}
|
|
473
|
+
* @private
|
|
474
|
+
*/
|
|
475
|
+
async _handleSpecFailed(specName, agentId, maxRetries, error) {
|
|
476
|
+
const resolvedError = `${error || 'Unknown error'}`;
|
|
477
|
+
const retryCount = this._retryCounts.get(specName) || 0;
|
|
478
|
+
const isRateLimitError = this._isRateLimitError(resolvedError);
|
|
479
|
+
const retryLimit = isRateLimitError
|
|
480
|
+
? Math.max(maxRetries, this._rateLimitMaxRetries || DEFAULT_RATE_LIMIT_MAX_RETRIES)
|
|
481
|
+
: maxRetries;
|
|
482
|
+
|
|
483
|
+
if (retryCount < retryLimit && !this._stopped) {
|
|
484
|
+
// Retry (Req 5.2)
|
|
485
|
+
this._retryCounts.set(specName, retryCount + 1);
|
|
486
|
+
this._statusMonitor.incrementRetry(specName);
|
|
487
|
+
this._statusMonitor.updateSpecStatus(specName, 'pending', null, resolvedError);
|
|
488
|
+
|
|
489
|
+
const retryDelayMs = isRateLimitError
|
|
490
|
+
? Math.max(
|
|
491
|
+
this._calculateRateLimitBackoffMs(retryCount),
|
|
492
|
+
this._extractRateLimitRetryAfterMs(resolvedError)
|
|
493
|
+
)
|
|
494
|
+
: 0;
|
|
495
|
+
if (retryDelayMs > 0) {
|
|
496
|
+
this._onRateLimitSignal(retryDelayMs);
|
|
497
|
+
const launchHoldMs = this._getRateLimitLaunchHoldRemainingMs();
|
|
498
|
+
this._updateStatusMonitorRateLimit({
|
|
499
|
+
specName,
|
|
500
|
+
retryCount,
|
|
501
|
+
retryDelayMs,
|
|
502
|
+
launchHoldMs,
|
|
503
|
+
error: resolvedError,
|
|
504
|
+
});
|
|
505
|
+
this.emit('spec:rate-limited', {
|
|
506
|
+
specName,
|
|
507
|
+
retryCount,
|
|
508
|
+
retryDelayMs,
|
|
509
|
+
launchHoldMs,
|
|
510
|
+
error: resolvedError,
|
|
511
|
+
});
|
|
512
|
+
await this._sleep(retryDelayMs);
|
|
513
|
+
if (this._stopped) {
|
|
514
|
+
return;
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// Re-execute
|
|
519
|
+
await this._executeSpec(specName, maxRetries);
|
|
520
|
+
} else {
|
|
521
|
+
// Final failure (Req 5.3)
|
|
522
|
+
this._failedSpecs.add(specName);
|
|
523
|
+
this._statusMonitor.updateSpecStatus(specName, 'failed', agentId, resolvedError);
|
|
524
|
+
|
|
525
|
+
// Sync external status
|
|
526
|
+
await this._syncExternalSafe(specName, 'failed');
|
|
527
|
+
|
|
528
|
+
this.emit('spec:failed', { specName, agentId, error: resolvedError, retryCount });
|
|
529
|
+
|
|
530
|
+
// Propagate failure to dependents (Req 3.6)
|
|
531
|
+
this._propagateFailure(specName);
|
|
532
|
+
}
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// ---------------------------------------------------------------------------
|
|
536
|
+
// Dependency Graph & Batch Computation
|
|
537
|
+
// ---------------------------------------------------------------------------
|
|
538
|
+
|
|
539
|
+
/**
|
|
540
|
+
* Extract dependency map from the graph for the given specs.
|
|
541
|
+
* edges go FROM dependent TO dependency (from: specA, to: specB means specA depends on specB).
|
|
542
|
+
*
|
|
543
|
+
* @param {object} graph - {nodes, edges}
|
|
544
|
+
* @param {string[]} specNames
|
|
545
|
+
* @returns {object} {[specName]: string[]} - each spec maps to its dependencies
|
|
546
|
+
* @private
|
|
547
|
+
*/
|
|
548
|
+
_extractDependencies(graph, specNames) {
|
|
549
|
+
const specSet = new Set(specNames);
|
|
550
|
+
const deps = {};
|
|
551
|
+
|
|
552
|
+
for (const specName of specNames) {
|
|
553
|
+
deps[specName] = [];
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
for (const edge of graph.edges) {
|
|
557
|
+
if (specSet.has(edge.from) && specSet.has(edge.to)) {
|
|
558
|
+
deps[edge.from].push(edge.to);
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
return deps;
|
|
563
|
+
}
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* Compute execution batches via topological sort (Req 3.3).
|
|
567
|
+
* Specs with no dependencies → batch 0.
|
|
568
|
+
* Specs whose dependencies are all in earlier batches → next batch.
|
|
569
|
+
*
|
|
570
|
+
* @param {string[]} specNames
|
|
571
|
+
* @param {object} dependencies - {[specName]: string[]}
|
|
572
|
+
* @returns {string[][]} Array of batches
|
|
573
|
+
* @private
|
|
574
|
+
*/
|
|
575
|
+
_computeBatches(specNames, dependencies) {
|
|
576
|
+
const batches = [];
|
|
577
|
+
const assigned = new Set(); // specs already assigned to a batch
|
|
578
|
+
|
|
579
|
+
while (assigned.size < specNames.length) {
|
|
580
|
+
const batch = [];
|
|
581
|
+
|
|
582
|
+
for (const specName of specNames) {
|
|
583
|
+
if (assigned.has(specName)) continue;
|
|
584
|
+
|
|
585
|
+
// Check if all dependencies are in earlier batches
|
|
586
|
+
const deps = dependencies[specName] || [];
|
|
587
|
+
const allDepsAssigned = deps.every(d => assigned.has(d));
|
|
588
|
+
|
|
589
|
+
if (allDepsAssigned) {
|
|
590
|
+
batch.push(specName);
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
if (batch.length === 0) {
|
|
595
|
+
// Should not happen if cycle detection passed, but safety guard
|
|
596
|
+
break;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
batches.push(batch);
|
|
600
|
+
for (const specName of batch) {
|
|
601
|
+
assigned.add(specName);
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
return batches;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* Propagate failure: mark all direct and indirect dependents as skipped (Req 3.6).
|
|
610
|
+
*
|
|
611
|
+
* @param {string} failedSpec
|
|
612
|
+
* @private
|
|
613
|
+
*/
|
|
614
|
+
_propagateFailure(failedSpec) {
|
|
615
|
+
if (!this._executionPlan) return;
|
|
616
|
+
|
|
617
|
+
const deps = this._executionPlan.dependencies;
|
|
618
|
+
const toSkip = new Set();
|
|
619
|
+
|
|
620
|
+
// Find all specs that directly or indirectly depend on failedSpec
|
|
621
|
+
const findDependents = (specName) => {
|
|
622
|
+
for (const candidate of this._executionPlan.specs) {
|
|
623
|
+
if (toSkip.has(candidate) || this._completedSpecs.has(candidate)) continue;
|
|
624
|
+
const candidateDeps = deps[candidate] || [];
|
|
625
|
+
if (candidateDeps.includes(specName)) {
|
|
626
|
+
toSkip.add(candidate);
|
|
627
|
+
findDependents(candidate); // recursive: indirect dependents
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
};
|
|
631
|
+
|
|
632
|
+
findDependents(failedSpec);
|
|
633
|
+
|
|
634
|
+
for (const specName of toSkip) {
|
|
635
|
+
this._skippedSpecs.add(specName);
|
|
636
|
+
this._statusMonitor.updateSpecStatus(
|
|
637
|
+
specName, 'skipped', null,
|
|
638
|
+
`Skipped: dependency '${failedSpec}' failed`
|
|
639
|
+
);
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
|
|
643
|
+
// ---------------------------------------------------------------------------
|
|
644
|
+
// Validation & Helpers
|
|
645
|
+
// ---------------------------------------------------------------------------
|
|
646
|
+
|
|
647
|
+
/**
|
|
648
|
+
* Resolve retry-related runtime config with safe defaults.
|
|
649
|
+
*
|
|
650
|
+
* @param {object} config
|
|
651
|
+
* @private
|
|
652
|
+
*/
|
|
653
|
+
_applyRetryPolicyConfig(config) {
|
|
654
|
+
this._rateLimitMaxRetries = this._toNonNegativeInteger(
|
|
655
|
+
config && config.rateLimitMaxRetries,
|
|
656
|
+
DEFAULT_RATE_LIMIT_MAX_RETRIES
|
|
657
|
+
);
|
|
658
|
+
|
|
659
|
+
const baseMs = this._toPositiveInteger(
|
|
660
|
+
config && config.rateLimitBackoffBaseMs,
|
|
661
|
+
DEFAULT_RATE_LIMIT_BACKOFF_BASE_MS
|
|
662
|
+
);
|
|
663
|
+
const maxMs = this._toPositiveInteger(
|
|
664
|
+
config && config.rateLimitBackoffMaxMs,
|
|
665
|
+
DEFAULT_RATE_LIMIT_BACKOFF_MAX_MS
|
|
666
|
+
);
|
|
667
|
+
|
|
668
|
+
this._rateLimitBackoffBaseMs = Math.min(baseMs, maxMs);
|
|
669
|
+
this._rateLimitBackoffMaxMs = Math.max(baseMs, maxMs);
|
|
670
|
+
this._rateLimitAdaptiveParallel = this._toBoolean(
|
|
671
|
+
config && config.rateLimitAdaptiveParallel,
|
|
672
|
+
DEFAULT_RATE_LIMIT_ADAPTIVE_PARALLEL
|
|
673
|
+
);
|
|
674
|
+
this._rateLimitParallelFloor = this._toPositiveInteger(
|
|
675
|
+
config && config.rateLimitParallelFloor,
|
|
676
|
+
DEFAULT_RATE_LIMIT_PARALLEL_FLOOR
|
|
677
|
+
);
|
|
678
|
+
this._rateLimitCooldownMs = this._toPositiveInteger(
|
|
679
|
+
config && config.rateLimitCooldownMs,
|
|
680
|
+
DEFAULT_RATE_LIMIT_COOLDOWN_MS
|
|
681
|
+
);
|
|
682
|
+
this._rateLimitLaunchBudgetPerMinute = this._toNonNegativeInteger(
|
|
683
|
+
config && config.rateLimitLaunchBudgetPerMinute,
|
|
684
|
+
DEFAULT_RATE_LIMIT_LAUNCH_BUDGET_PER_MINUTE
|
|
685
|
+
);
|
|
686
|
+
this._rateLimitLaunchBudgetWindowMs = this._toPositiveInteger(
|
|
687
|
+
config && config.rateLimitLaunchBudgetWindowMs,
|
|
688
|
+
DEFAULT_RATE_LIMIT_LAUNCH_BUDGET_WINDOW_MS
|
|
689
|
+
);
|
|
690
|
+
}
|
|
691
|
+
|
|
692
|
+
/**
|
|
693
|
+
* @param {number} maxParallel
|
|
694
|
+
* @private
|
|
695
|
+
*/
|
|
696
|
+
_initializeAdaptiveParallel(maxParallel) {
|
|
697
|
+
const boundedMax = this._toPositiveInteger(maxParallel, 1);
|
|
698
|
+
this._baseMaxParallel = boundedMax;
|
|
699
|
+
this._effectiveMaxParallel = boundedMax;
|
|
700
|
+
this._rateLimitCooldownUntil = 0;
|
|
701
|
+
this._rateLimitLaunchHoldUntil = 0;
|
|
702
|
+
this._rateLimitLaunchTimestamps = [];
|
|
703
|
+
this._launchBudgetLastHoldSignalAt = 0;
|
|
704
|
+
this._launchBudgetLastHoldMs = 0;
|
|
705
|
+
this._updateStatusMonitorParallelTelemetry({
|
|
706
|
+
adaptive: this._isAdaptiveParallelEnabled(),
|
|
707
|
+
maxParallel: boundedMax,
|
|
708
|
+
effectiveMaxParallel: boundedMax,
|
|
709
|
+
floor: Math.min(
|
|
710
|
+
boundedMax,
|
|
711
|
+
this._toPositiveInteger(this._rateLimitParallelFloor, DEFAULT_RATE_LIMIT_PARALLEL_FLOOR)
|
|
712
|
+
),
|
|
713
|
+
});
|
|
714
|
+
const launchBudgetConfig = this._getLaunchBudgetConfig();
|
|
715
|
+
if (launchBudgetConfig.budgetPerMinute > 0) {
|
|
716
|
+
this._updateStatusMonitorLaunchBudget({
|
|
717
|
+
budgetPerMinute: launchBudgetConfig.budgetPerMinute,
|
|
718
|
+
windowMs: launchBudgetConfig.windowMs,
|
|
719
|
+
used: 0,
|
|
720
|
+
holdMs: 0,
|
|
721
|
+
});
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* @param {number} maxParallel
|
|
727
|
+
* @returns {number}
|
|
728
|
+
* @private
|
|
729
|
+
*/
|
|
730
|
+
_getEffectiveMaxParallel(maxParallel) {
|
|
731
|
+
const boundedMax = this._toPositiveInteger(maxParallel, 1);
|
|
732
|
+
const floor = Math.min(
|
|
733
|
+
boundedMax,
|
|
734
|
+
this._toPositiveInteger(this._rateLimitParallelFloor, DEFAULT_RATE_LIMIT_PARALLEL_FLOOR)
|
|
735
|
+
);
|
|
736
|
+
|
|
737
|
+
if (!this._isAdaptiveParallelEnabled()) {
|
|
738
|
+
this._baseMaxParallel = boundedMax;
|
|
739
|
+
this._effectiveMaxParallel = boundedMax;
|
|
740
|
+
this._updateStatusMonitorParallelTelemetry({
|
|
741
|
+
adaptive: false,
|
|
742
|
+
maxParallel: boundedMax,
|
|
743
|
+
effectiveMaxParallel: boundedMax,
|
|
744
|
+
floor,
|
|
745
|
+
});
|
|
746
|
+
return boundedMax;
|
|
747
|
+
}
|
|
748
|
+
|
|
749
|
+
this._baseMaxParallel = boundedMax;
|
|
750
|
+
this._maybeRecoverParallelLimit(boundedMax);
|
|
751
|
+
|
|
752
|
+
const effective = this._toPositiveInteger(this._effectiveMaxParallel, boundedMax);
|
|
753
|
+
const resolved = Math.max(floor, Math.min(boundedMax, effective));
|
|
754
|
+
this._updateStatusMonitorParallelTelemetry({
|
|
755
|
+
adaptive: true,
|
|
756
|
+
maxParallel: boundedMax,
|
|
757
|
+
effectiveMaxParallel: resolved,
|
|
758
|
+
floor,
|
|
759
|
+
});
|
|
760
|
+
return resolved;
|
|
761
|
+
}
|
|
762
|
+
|
|
763
|
+
/**
|
|
764
|
+
* @private
|
|
765
|
+
*/
|
|
766
|
+
_onRateLimitSignal(retryDelayMs = 0) {
|
|
767
|
+
const now = this._getNow();
|
|
768
|
+
const launchHoldMs = this._toNonNegativeInteger(retryDelayMs, 0);
|
|
769
|
+
if (launchHoldMs > 0) {
|
|
770
|
+
const currentHoldUntil = this._toNonNegativeInteger(this._rateLimitLaunchHoldUntil, 0);
|
|
771
|
+
this._rateLimitLaunchHoldUntil = Math.max(currentHoldUntil, now + launchHoldMs);
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
if (!this._isAdaptiveParallelEnabled()) {
|
|
775
|
+
return;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
const base = this._toPositiveInteger(this._baseMaxParallel, 1);
|
|
779
|
+
const current = this._toPositiveInteger(this._effectiveMaxParallel, base);
|
|
780
|
+
const floor = Math.min(
|
|
781
|
+
base,
|
|
782
|
+
this._toPositiveInteger(this._rateLimitParallelFloor, DEFAULT_RATE_LIMIT_PARALLEL_FLOOR)
|
|
783
|
+
);
|
|
784
|
+
const next = Math.max(floor, Math.floor(current / 2));
|
|
785
|
+
|
|
786
|
+
if (next < current) {
|
|
787
|
+
this._effectiveMaxParallel = next;
|
|
788
|
+
this._updateStatusMonitorParallelTelemetry({
|
|
789
|
+
event: 'throttled',
|
|
790
|
+
reason: 'rate-limit',
|
|
791
|
+
adaptive: true,
|
|
792
|
+
maxParallel: base,
|
|
793
|
+
effectiveMaxParallel: next,
|
|
794
|
+
floor,
|
|
795
|
+
});
|
|
796
|
+
this.emit('parallel:throttled', {
|
|
797
|
+
reason: 'rate-limit',
|
|
798
|
+
previousMaxParallel: current,
|
|
799
|
+
effectiveMaxParallel: next,
|
|
800
|
+
floor,
|
|
801
|
+
});
|
|
802
|
+
} else {
|
|
803
|
+
this._effectiveMaxParallel = current;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
this._rateLimitCooldownUntil = now + this._rateLimitCooldownMs;
|
|
807
|
+
}
|
|
808
|
+
|
|
809
|
+
/**
|
|
810
|
+
* @param {number} maxParallel
|
|
811
|
+
* @private
|
|
812
|
+
*/
|
|
813
|
+
_maybeRecoverParallelLimit(maxParallel) {
|
|
814
|
+
if (!this._isAdaptiveParallelEnabled()) {
|
|
815
|
+
return;
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
const boundedMax = this._toPositiveInteger(maxParallel, 1);
|
|
819
|
+
const current = this._toPositiveInteger(this._effectiveMaxParallel, boundedMax);
|
|
820
|
+
if (current >= boundedMax) {
|
|
821
|
+
this._effectiveMaxParallel = boundedMax;
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
if (this._getNow() < this._rateLimitCooldownUntil) {
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
|
|
829
|
+
const next = Math.min(boundedMax, current + 1);
|
|
830
|
+
if (next > current) {
|
|
831
|
+
this._effectiveMaxParallel = next;
|
|
832
|
+
this._rateLimitCooldownUntil = this._getNow() + this._rateLimitCooldownMs;
|
|
833
|
+
this._updateStatusMonitorParallelTelemetry({
|
|
834
|
+
event: 'recovered',
|
|
835
|
+
adaptive: true,
|
|
836
|
+
maxParallel: boundedMax,
|
|
837
|
+
effectiveMaxParallel: next,
|
|
838
|
+
});
|
|
839
|
+
this.emit('parallel:recovered', {
|
|
840
|
+
previousMaxParallel: current,
|
|
841
|
+
effectiveMaxParallel: next,
|
|
842
|
+
maxParallel: boundedMax,
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
|
|
847
|
+
/**
|
|
848
|
+
* @returns {boolean}
|
|
849
|
+
* @private
|
|
850
|
+
*/
|
|
851
|
+
_isAdaptiveParallelEnabled() {
|
|
852
|
+
if (typeof this._rateLimitAdaptiveParallel === 'boolean') {
|
|
853
|
+
return this._rateLimitAdaptiveParallel;
|
|
854
|
+
}
|
|
855
|
+
return DEFAULT_RATE_LIMIT_ADAPTIVE_PARALLEL;
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
/**
|
|
859
|
+
* @returns {number}
|
|
860
|
+
* @private
|
|
861
|
+
*/
|
|
862
|
+
_getNow() {
|
|
863
|
+
return typeof this._now === 'function' ? this._now() : Date.now();
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/**
|
|
867
|
+
* @returns {number}
|
|
868
|
+
* @private
|
|
869
|
+
*/
|
|
870
|
+
_getRateLimitLaunchHoldRemainingMs() {
|
|
871
|
+
const holdUntil = this._toNonNegativeInteger(this._rateLimitLaunchHoldUntil, 0);
|
|
872
|
+
if (holdUntil <= 0) {
|
|
873
|
+
return 0;
|
|
874
|
+
}
|
|
875
|
+
return Math.max(0, holdUntil - this._getNow());
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/**
|
|
879
|
+
* @returns {{ budgetPerMinute: number, windowMs: number }}
|
|
880
|
+
* @private
|
|
881
|
+
*/
|
|
882
|
+
_getLaunchBudgetConfig() {
|
|
883
|
+
return {
|
|
884
|
+
budgetPerMinute: this._toNonNegativeInteger(
|
|
885
|
+
this._rateLimitLaunchBudgetPerMinute,
|
|
886
|
+
DEFAULT_RATE_LIMIT_LAUNCH_BUDGET_PER_MINUTE
|
|
887
|
+
),
|
|
888
|
+
windowMs: this._toPositiveInteger(
|
|
889
|
+
this._rateLimitLaunchBudgetWindowMs,
|
|
890
|
+
DEFAULT_RATE_LIMIT_LAUNCH_BUDGET_WINDOW_MS
|
|
891
|
+
),
|
|
892
|
+
};
|
|
893
|
+
}
|
|
894
|
+
|
|
895
|
+
/**
|
|
896
|
+
* @param {number} windowMs
|
|
897
|
+
* @private
|
|
898
|
+
*/
|
|
899
|
+
_pruneLaunchBudgetHistory(windowMs) {
|
|
900
|
+
const now = this._getNow();
|
|
901
|
+
const history = Array.isArray(this._rateLimitLaunchTimestamps)
|
|
902
|
+
? this._rateLimitLaunchTimestamps
|
|
903
|
+
: [];
|
|
904
|
+
this._rateLimitLaunchTimestamps = history
|
|
905
|
+
.filter((timestamp) => Number.isFinite(timestamp) && timestamp > (now - windowMs));
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
/**
|
|
909
|
+
* @returns {number}
|
|
910
|
+
* @private
|
|
911
|
+
*/
|
|
912
|
+
_getLaunchBudgetHoldRemainingMs() {
|
|
913
|
+
const { budgetPerMinute, windowMs } = this._getLaunchBudgetConfig();
|
|
914
|
+
if (budgetPerMinute <= 0) {
|
|
915
|
+
return 0;
|
|
916
|
+
}
|
|
917
|
+
this._pruneLaunchBudgetHistory(windowMs);
|
|
918
|
+
if (this._rateLimitLaunchTimestamps.length < budgetPerMinute) {
|
|
919
|
+
return 0;
|
|
920
|
+
}
|
|
921
|
+
const oldest = this._rateLimitLaunchTimestamps[0];
|
|
922
|
+
if (!Number.isFinite(oldest)) {
|
|
923
|
+
return 0;
|
|
924
|
+
}
|
|
925
|
+
return Math.max(0, windowMs - (this._getNow() - oldest));
|
|
926
|
+
}
|
|
927
|
+
|
|
928
|
+
/**
|
|
929
|
+
* @private
|
|
930
|
+
*/
|
|
931
|
+
_recordLaunchStart() {
|
|
932
|
+
const { budgetPerMinute, windowMs } = this._getLaunchBudgetConfig();
|
|
933
|
+
if (budgetPerMinute <= 0) {
|
|
934
|
+
return;
|
|
935
|
+
}
|
|
936
|
+
this._pruneLaunchBudgetHistory(windowMs);
|
|
937
|
+
this._rateLimitLaunchTimestamps.push(this._getNow());
|
|
938
|
+
const holdMs = this._getLaunchBudgetHoldRemainingMs();
|
|
939
|
+
this._updateStatusMonitorLaunchBudget({
|
|
940
|
+
budgetPerMinute,
|
|
941
|
+
windowMs,
|
|
942
|
+
used: this._rateLimitLaunchTimestamps.length,
|
|
943
|
+
holdMs,
|
|
944
|
+
});
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
/**
|
|
948
|
+
* @param {number} holdMs
|
|
949
|
+
* @private
|
|
950
|
+
*/
|
|
951
|
+
_onLaunchBudgetHold(holdMs) {
|
|
952
|
+
const { budgetPerMinute, windowMs } = this._getLaunchBudgetConfig();
|
|
953
|
+
if (budgetPerMinute <= 0 || holdMs <= 0) {
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
if (!Array.isArray(this._rateLimitLaunchTimestamps)) {
|
|
957
|
+
this._rateLimitLaunchTimestamps = [];
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
const now = this._getNow();
|
|
961
|
+
const lastSignalAt = this._toNonNegativeInteger(this._launchBudgetLastHoldSignalAt, 0);
|
|
962
|
+
const lastHoldMs = this._toNonNegativeInteger(this._launchBudgetLastHoldMs, 0);
|
|
963
|
+
const deltaFromLast = now - lastSignalAt;
|
|
964
|
+
const holdDelta = Math.abs(holdMs - lastHoldMs);
|
|
965
|
+
if (deltaFromLast < 1000 && holdDelta < 200) {
|
|
966
|
+
return;
|
|
967
|
+
}
|
|
968
|
+
this._launchBudgetLastHoldSignalAt = now;
|
|
969
|
+
this._launchBudgetLastHoldMs = holdMs;
|
|
970
|
+
|
|
971
|
+
this._updateStatusMonitorLaunchBudget({
|
|
972
|
+
event: 'hold',
|
|
973
|
+
budgetPerMinute,
|
|
974
|
+
windowMs,
|
|
975
|
+
used: this._rateLimitLaunchTimestamps.length,
|
|
976
|
+
holdMs,
|
|
977
|
+
});
|
|
978
|
+
this.emit('launch:budget-hold', {
|
|
979
|
+
reason: 'rate-limit-launch-budget',
|
|
980
|
+
holdMs,
|
|
981
|
+
budgetPerMinute,
|
|
982
|
+
windowMs,
|
|
983
|
+
used: this._rateLimitLaunchTimestamps.length,
|
|
984
|
+
});
|
|
985
|
+
}
|
|
986
|
+
|
|
987
|
+
/**
|
|
988
|
+
* @param {any} value
|
|
989
|
+
* @param {boolean} fallback
|
|
990
|
+
* @returns {boolean}
|
|
991
|
+
* @private
|
|
992
|
+
*/
|
|
993
|
+
_toBoolean(value, fallback) {
|
|
994
|
+
if (typeof value === 'boolean') {
|
|
995
|
+
return value;
|
|
996
|
+
}
|
|
997
|
+
return fallback;
|
|
998
|
+
}
|
|
999
|
+
|
|
1000
|
+
/**
|
|
1001
|
+
* @param {any} value
|
|
1002
|
+
* @param {number} fallback
|
|
1003
|
+
* @returns {number}
|
|
1004
|
+
* @private
|
|
1005
|
+
*/
|
|
1006
|
+
_toPositiveInteger(value, fallback) {
|
|
1007
|
+
const numeric = Number(value);
|
|
1008
|
+
if (!Number.isFinite(numeric) || numeric <= 0) {
|
|
1009
|
+
return fallback;
|
|
1010
|
+
}
|
|
1011
|
+
return Math.floor(numeric);
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
/**
|
|
1015
|
+
* @param {any} value
|
|
1016
|
+
* @param {number} fallback
|
|
1017
|
+
* @returns {number}
|
|
1018
|
+
* @private
|
|
1019
|
+
*/
|
|
1020
|
+
_toNonNegativeInteger(value, fallback) {
|
|
1021
|
+
const numeric = Number(value);
|
|
1022
|
+
if (!Number.isFinite(numeric) || numeric < 0) {
|
|
1023
|
+
return fallback;
|
|
1024
|
+
}
|
|
1025
|
+
return Math.floor(numeric);
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/**
|
|
1029
|
+
* @param {string} error
|
|
1030
|
+
* @returns {boolean}
|
|
1031
|
+
* @private
|
|
1032
|
+
*/
|
|
1033
|
+
_isRateLimitError(error) {
|
|
1034
|
+
return RATE_LIMIT_ERROR_PATTERNS.some(pattern => pattern.test(`${error || ''}`));
|
|
1035
|
+
}
|
|
1036
|
+
|
|
1037
|
+
/**
|
|
1038
|
+
* Parse retry-after hints from rate-limit error messages.
|
|
1039
|
+
* Supports formats like:
|
|
1040
|
+
* - "Retry-After: 7"
|
|
1041
|
+
* - "retry after 2s"
|
|
1042
|
+
* - "try again in 1500ms"
|
|
1043
|
+
*
|
|
1044
|
+
* @param {string} error
|
|
1045
|
+
* @returns {number} delay in ms (0 when no hint)
|
|
1046
|
+
* @private
|
|
1047
|
+
*/
|
|
1048
|
+
_extractRateLimitRetryAfterMs(error) {
|
|
1049
|
+
const message = `${error || ''}`;
|
|
1050
|
+
if (!message) {
|
|
1051
|
+
return 0;
|
|
1052
|
+
}
|
|
1053
|
+
|
|
1054
|
+
const patterns = [
|
|
1055
|
+
/retry[-_\s]?after\s*[:=]?\s*(\d+(?:\.\d+)?)\s*(ms|msec|milliseconds?|s|sec|seconds?|m|min|minutes?)?/i,
|
|
1056
|
+
/try\s+again\s+in\s+(\d+(?:\.\d+)?)\s*(ms|msec|milliseconds?|s|sec|seconds?|m|min|minutes?)?/i,
|
|
1057
|
+
];
|
|
1058
|
+
|
|
1059
|
+
for (const pattern of patterns) {
|
|
1060
|
+
const match = pattern.exec(message);
|
|
1061
|
+
if (!match) {
|
|
1062
|
+
continue;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
const value = Number(match[1]);
|
|
1066
|
+
if (!Number.isFinite(value) || value <= 0) {
|
|
1067
|
+
continue;
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
const unit = `${match[2] || 's'}`.trim().toLowerCase();
|
|
1071
|
+
let multiplier = 1000;
|
|
1072
|
+
if (unit === 'ms' || unit === 'msec' || unit.startsWith('millisecond')) {
|
|
1073
|
+
multiplier = 1;
|
|
1074
|
+
} else if (unit === 'm' || unit === 'min' || unit.startsWith('minute')) {
|
|
1075
|
+
multiplier = 60 * 1000;
|
|
1076
|
+
} else {
|
|
1077
|
+
multiplier = 1000;
|
|
1078
|
+
}
|
|
1079
|
+
|
|
1080
|
+
const delayMs = Math.round(value * multiplier);
|
|
1081
|
+
return Math.max(0, Math.min(RATE_LIMIT_RETRY_AFTER_MAX_MS, delayMs));
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
return 0;
|
|
1085
|
+
}
|
|
1086
|
+
|
|
1087
|
+
/**
|
|
1088
|
+
* @param {number} retryCount
|
|
1089
|
+
* @returns {number}
|
|
1090
|
+
* @private
|
|
1091
|
+
*/
|
|
1092
|
+
_calculateRateLimitBackoffMs(retryCount) {
|
|
1093
|
+
const exponent = Math.max(0, retryCount);
|
|
1094
|
+
const cappedBaseDelay = Math.min(
|
|
1095
|
+
this._rateLimitBackoffMaxMs || DEFAULT_RATE_LIMIT_BACKOFF_MAX_MS,
|
|
1096
|
+
(this._rateLimitBackoffBaseMs || DEFAULT_RATE_LIMIT_BACKOFF_BASE_MS) * (2 ** exponent)
|
|
1097
|
+
);
|
|
1098
|
+
|
|
1099
|
+
const randomValue = typeof this._random === 'function' ? this._random() : Math.random();
|
|
1100
|
+
const normalizedRandom = Number.isFinite(randomValue)
|
|
1101
|
+
? Math.min(1, Math.max(0, randomValue))
|
|
1102
|
+
: 0.5;
|
|
1103
|
+
const jitterFactor = (1 - RATE_LIMIT_BACKOFF_JITTER_RATIO)
|
|
1104
|
+
+ (normalizedRandom * RATE_LIMIT_BACKOFF_JITTER_RATIO);
|
|
1105
|
+
|
|
1106
|
+
return Math.max(1, Math.round(cappedBaseDelay * jitterFactor));
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
/**
|
|
1110
|
+
* @param {number} ms
|
|
1111
|
+
* @returns {Promise<void>}
|
|
1112
|
+
* @private
|
|
1113
|
+
*/
|
|
1114
|
+
_sleep(ms) {
|
|
1115
|
+
if (!ms || ms <= 0) {
|
|
1116
|
+
return Promise.resolve();
|
|
1117
|
+
}
|
|
1118
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1119
|
+
}
|
|
1120
|
+
|
|
1121
|
+
/**
|
|
1122
|
+
* Safely update StatusMonitor rate-limit telemetry.
|
|
1123
|
+
*
|
|
1124
|
+
* @param {object} payload
|
|
1125
|
+
* @private
|
|
1126
|
+
*/
|
|
1127
|
+
_updateStatusMonitorRateLimit(payload) {
|
|
1128
|
+
const handler = this._statusMonitor && this._statusMonitor.recordRateLimitEvent;
|
|
1129
|
+
if (typeof handler === 'function') {
|
|
1130
|
+
try {
|
|
1131
|
+
handler.call(this._statusMonitor, payload);
|
|
1132
|
+
} catch (_err) {
|
|
1133
|
+
// Non-fatal status telemetry update.
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1138
|
+
/**
|
|
1139
|
+
* Safely update StatusMonitor adaptive parallel telemetry.
|
|
1140
|
+
*
|
|
1141
|
+
* @param {object} payload
|
|
1142
|
+
* @private
|
|
1143
|
+
*/
|
|
1144
|
+
_updateStatusMonitorParallelTelemetry(payload) {
|
|
1145
|
+
const handler = this._statusMonitor && this._statusMonitor.updateParallelTelemetry;
|
|
1146
|
+
if (typeof handler === 'function') {
|
|
1147
|
+
try {
|
|
1148
|
+
handler.call(this._statusMonitor, payload);
|
|
1149
|
+
} catch (_err) {
|
|
1150
|
+
// Non-fatal status telemetry update.
|
|
1151
|
+
}
|
|
1152
|
+
}
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
/**
|
|
1156
|
+
* Safely update StatusMonitor launch-budget telemetry.
|
|
1157
|
+
*
|
|
1158
|
+
* @param {object} payload
|
|
1159
|
+
* @private
|
|
1160
|
+
*/
|
|
1161
|
+
_updateStatusMonitorLaunchBudget(payload) {
|
|
1162
|
+
const handler = this._statusMonitor && this._statusMonitor.updateLaunchBudgetTelemetry;
|
|
1163
|
+
if (typeof handler === 'function') {
|
|
1164
|
+
try {
|
|
1165
|
+
handler.call(this._statusMonitor, payload);
|
|
1166
|
+
} catch (_err) {
|
|
1167
|
+
// Non-fatal status telemetry update.
|
|
1168
|
+
}
|
|
1169
|
+
}
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
/**
|
|
1173
|
+
* Validate that all spec directories exist (Req 6.4).
|
|
1174
|
+
*
|
|
1175
|
+
* @param {string[]} specNames
|
|
1176
|
+
* @returns {Promise<string[]>} List of missing spec names
|
|
1177
|
+
* @private
|
|
1178
|
+
*/
|
|
1179
|
+
async _validateSpecExistence(specNames) {
|
|
1180
|
+
const missing = [];
|
|
1181
|
+
for (const specName of specNames) {
|
|
1182
|
+
const specDir = path.join(this._workspaceRoot, SPECS_DIR, specName);
|
|
1183
|
+
const exists = await fsUtils.pathExists(specDir);
|
|
1184
|
+
if (!exists) {
|
|
1185
|
+
missing.push(specName);
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
return missing;
|
|
1189
|
+
}
|
|
1190
|
+
|
|
1191
|
+
/**
|
|
1192
|
+
* Safely transition a spec via SpecLifecycleManager (Req 8.1, 8.2).
|
|
1193
|
+
* Failures are logged but do not propagate (non-fatal).
|
|
1194
|
+
*
|
|
1195
|
+
* @param {string} specName
|
|
1196
|
+
* @param {string} newStatus
|
|
1197
|
+
* @returns {Promise<void>}
|
|
1198
|
+
* @private
|
|
1199
|
+
*/
|
|
1200
|
+
async _transitionSafe(specName, newStatus) {
|
|
1201
|
+
try {
|
|
1202
|
+
await this._specLifecycleManager.transition(specName, newStatus);
|
|
1203
|
+
} catch (err) {
|
|
1204
|
+
console.warn(
|
|
1205
|
+
`[OrchestrationEngine] SLM transition failed for ${specName} → ${newStatus}: ${err.message}`
|
|
1206
|
+
);
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
|
|
1210
|
+
/**
|
|
1211
|
+
* Safely sync external status via StatusMonitor (Req 8.5).
|
|
1212
|
+
* Failures are logged but do not propagate (non-fatal).
|
|
1213
|
+
*
|
|
1214
|
+
* @param {string} specName
|
|
1215
|
+
* @param {string} status
|
|
1216
|
+
* @returns {Promise<void>}
|
|
1217
|
+
* @private
|
|
1218
|
+
*/
|
|
1219
|
+
async _syncExternalSafe(specName, status) {
|
|
1220
|
+
try {
|
|
1221
|
+
await this._statusMonitor.syncExternalStatus(specName, status);
|
|
1222
|
+
} catch (err) {
|
|
1223
|
+
console.warn(
|
|
1224
|
+
`[OrchestrationEngine] External sync failed for ${specName}: ${err.message}`
|
|
1225
|
+
);
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
/**
|
|
1230
|
+
* Build the orchestration result object.
|
|
1231
|
+
*
|
|
1232
|
+
* @param {string} status
|
|
1233
|
+
* @param {string|null} [error=null]
|
|
1234
|
+
* @returns {object}
|
|
1235
|
+
* @private
|
|
1236
|
+
*/
|
|
1237
|
+
_buildResult(status, error = null) {
|
|
1238
|
+
return {
|
|
1239
|
+
status,
|
|
1240
|
+
plan: this._executionPlan,
|
|
1241
|
+
completed: [...this._completedSpecs],
|
|
1242
|
+
failed: [...this._failedSpecs],
|
|
1243
|
+
skipped: [...this._skippedSpecs],
|
|
1244
|
+
error,
|
|
1245
|
+
};
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
/**
|
|
1249
|
+
* Reset internal state for a new orchestration run.
|
|
1250
|
+
* @private
|
|
1251
|
+
*/
|
|
1252
|
+
_reset() {
|
|
1253
|
+
this._runningAgents.clear();
|
|
1254
|
+
this._retryCounts.clear();
|
|
1255
|
+
this._failedSpecs.clear();
|
|
1256
|
+
this._skippedSpecs.clear();
|
|
1257
|
+
this._completedSpecs.clear();
|
|
1258
|
+
this._executionPlan = null;
|
|
1259
|
+
this._stopped = false;
|
|
1260
|
+
this._baseMaxParallel = null;
|
|
1261
|
+
this._effectiveMaxParallel = null;
|
|
1262
|
+
this._rateLimitCooldownUntil = 0;
|
|
1263
|
+
this._rateLimitLaunchHoldUntil = 0;
|
|
1264
|
+
this._rateLimitLaunchTimestamps = [];
|
|
1265
|
+
this._launchBudgetLastHoldSignalAt = 0;
|
|
1266
|
+
this._launchBudgetLastHoldMs = 0;
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
module.exports = { OrchestrationEngine };
|