workflow-ai 1.0.51 → 1.0.52
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/package.json +1 -1
- package/src/runner.mjs +92 -139
package/package.json
CHANGED
package/src/runner.mjs
CHANGED
|
@@ -2,92 +2,64 @@
|
|
|
2
2
|
|
|
3
3
|
import fs from 'fs';
|
|
4
4
|
import path from 'path';
|
|
5
|
-
import { spawn, execSync
|
|
5
|
+
import { spawn, execSync } from 'child_process';
|
|
6
6
|
import crypto from 'crypto';
|
|
7
7
|
import yaml from './lib/js-yaml.mjs';
|
|
8
8
|
import { findProjectRoot } from './lib/find-root.mjs';
|
|
9
9
|
|
|
10
10
|
// ============================================================================
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
11
|
+
// ProcessTracker — snapshot-based process cleanup для Windows.
|
|
12
|
+
//
|
|
13
|
+
// Проблема: runner spawn → claude CLI → npx → node (MCP-сервер).
|
|
14
|
+
// При завершении claude CLI, MCP-серверы становятся сиротами.
|
|
15
|
+
// taskkill /T не находит их, wmic parent-tree разорвано.
|
|
16
|
+
//
|
|
17
|
+
// Решение: снимаем снапшот PID'ов ДО spawn, при cleanup берём текущие PID'ы,
|
|
18
|
+
// разница = процессы порождённые нашим агентом (включая внуков и сирот).
|
|
15
19
|
// ============================================================================
|
|
16
|
-
function
|
|
17
|
-
if (process.platform !== 'win32') return
|
|
20
|
+
function getProcessSnapshot() {
|
|
21
|
+
if (process.platform !== 'win32') return new Set();
|
|
18
22
|
try {
|
|
19
23
|
const output = execSync(
|
|
20
|
-
|
|
21
|
-
{ stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf-8' }
|
|
24
|
+
'wmic process where "name=\'node.exe\' or name=\'python.exe\' or name=\'python3.exe\' or name=\'cmd.exe\'" get ProcessId /FORMAT:LIST',
|
|
25
|
+
{ stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf-8', timeout: 5000 }
|
|
22
26
|
);
|
|
23
|
-
const pids =
|
|
27
|
+
const pids = new Set();
|
|
24
28
|
for (const line of output.split('\n')) {
|
|
25
29
|
const match = line.match(/ProcessId=(\d+)/);
|
|
26
|
-
if (match)
|
|
27
|
-
const childPid = parseInt(match[1], 10);
|
|
28
|
-
pids.push(childPid);
|
|
29
|
-
// Рекурсивно собираем внуков
|
|
30
|
-
pids.push(...getDescendantPids(childPid));
|
|
31
|
-
}
|
|
30
|
+
if (match) pids.add(parseInt(match[1], 10));
|
|
32
31
|
}
|
|
33
32
|
return pids;
|
|
34
33
|
} catch {
|
|
35
|
-
return
|
|
34
|
+
return new Set();
|
|
36
35
|
}
|
|
37
36
|
}
|
|
38
37
|
|
|
39
|
-
function
|
|
40
|
-
|
|
41
|
-
if (!pid) return;
|
|
42
|
-
|
|
38
|
+
function killPids(pids, logger = null) {
|
|
39
|
+
if (pids.length === 0) return;
|
|
43
40
|
if (process.platform === 'win32') {
|
|
44
|
-
|
|
45
|
-
const allPids = [...getDescendantPids(pid), pid];
|
|
46
|
-
|
|
47
|
-
// Убиваем снизу вверх (сначала внуки, потом дети, потом root)
|
|
48
|
-
for (const p of allPids) {
|
|
41
|
+
for (const pid of pids) {
|
|
49
42
|
try {
|
|
50
|
-
execSync(`taskkill /pid ${
|
|
51
|
-
} catch {
|
|
52
|
-
// Процесс уже завершился
|
|
53
|
-
}
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
if (logger && allPids.length > 1) {
|
|
57
|
-
logger.info(`Killed process tree: ${allPids.join(' → ')}`, 'ProcessCleanup');
|
|
43
|
+
execSync(`taskkill /pid ${pid} /F`, { stdio: 'pipe' });
|
|
44
|
+
} catch { /* already dead */ }
|
|
58
45
|
}
|
|
59
46
|
} else {
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
child.kill('SIGTERM');
|
|
63
|
-
} catch {
|
|
64
|
-
return;
|
|
47
|
+
for (const pid of pids) {
|
|
48
|
+
try { process.kill(pid, 'SIGKILL'); } catch { /* already dead */ }
|
|
65
49
|
}
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
process.kill(pid, 0);
|
|
70
|
-
child.kill('SIGKILL');
|
|
71
|
-
if (logger) {
|
|
72
|
-
logger.warn(`Process ${pid} did not exit after SIGTERM, sent SIGKILL`, 'ProcessCleanup');
|
|
73
|
-
}
|
|
74
|
-
} catch {
|
|
75
|
-
// Процесс уже завершился после SIGTERM
|
|
76
|
-
}
|
|
77
|
-
}, 5000);
|
|
50
|
+
}
|
|
51
|
+
if (logger && pids.length > 0) {
|
|
52
|
+
logger.info(`Cleaned up ${pids.length} orphan process(es): ${pids.join(', ')}`, 'ProcessCleanup');
|
|
78
53
|
}
|
|
79
54
|
}
|
|
80
55
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
}
|
|
89
|
-
} catch {
|
|
90
|
-
// уже завершился
|
|
56
|
+
function killChildProcess(child) {
|
|
57
|
+
if (!child.pid) return;
|
|
58
|
+
if (process.platform === 'win32') {
|
|
59
|
+
// taskkill /T /F — лучший effort для прямого дерева
|
|
60
|
+
try { execSync(`taskkill /pid ${child.pid} /T /F`, { stdio: 'pipe' }); } catch {}
|
|
61
|
+
} else {
|
|
62
|
+
try { child.kill('SIGTERM'); } catch {}
|
|
91
63
|
}
|
|
92
64
|
}
|
|
93
65
|
|
|
@@ -848,59 +820,34 @@ class StageExecutor {
|
|
|
848
820
|
|
|
849
821
|
// Трекинг активных дочерних процессов для cleanup
|
|
850
822
|
this.activeChildren = new Set();
|
|
851
|
-
//
|
|
852
|
-
this.
|
|
853
|
-
// Интервал для периодического сбора PID-дерева
|
|
854
|
-
this._pidScanInterval = null;
|
|
855
|
-
}
|
|
856
|
-
|
|
857
|
-
/**
|
|
858
|
-
* Начинает периодический сбор PID'ов потомков для всех активных child processes.
|
|
859
|
-
* Нужно потому что при завершении промежуточного процесса (npx, cmd.exe)
|
|
860
|
-
* дерево разрывается и потомки становятся сиротами — их уже не найти через parent PID.
|
|
861
|
-
*/
|
|
862
|
-
_startPidTracking() {
|
|
863
|
-
if (this._pidScanInterval || process.platform !== 'win32') return;
|
|
864
|
-
this._pidScanInterval = setInterval(() => {
|
|
865
|
-
for (const child of this.activeChildren) {
|
|
866
|
-
if (child.pid) {
|
|
867
|
-
this.trackedPids.add(child.pid);
|
|
868
|
-
for (const pid of getDescendantPids(child.pid)) {
|
|
869
|
-
this.trackedPids.add(pid);
|
|
870
|
-
}
|
|
871
|
-
}
|
|
872
|
-
}
|
|
873
|
-
}, 3000);
|
|
823
|
+
// Снапшот PID'ов перед spawn — для вычисления порождённых процессов
|
|
824
|
+
this.preSpawnSnapshot = new Set();
|
|
874
825
|
}
|
|
875
826
|
|
|
876
827
|
/**
|
|
877
|
-
*
|
|
878
|
-
|
|
879
|
-
|
|
880
|
-
if (this._pidScanInterval) {
|
|
881
|
-
clearInterval(this._pidScanInterval);
|
|
882
|
-
this._pidScanInterval = null;
|
|
883
|
-
}
|
|
884
|
-
}
|
|
885
|
-
|
|
886
|
-
/**
|
|
887
|
-
* Убивает все активные дочерние процессы и все ранее найденные PID'ы потомков
|
|
828
|
+
* Убивает все активные дочерние процессы и порождённых ими сирот.
|
|
829
|
+
* Snapshot-based: сравнивает текущие PID'ы с снапшотом до spawn,
|
|
830
|
+
* новые процессы = порождённые нашим агентом (включая внуков/MCP-серверов).
|
|
888
831
|
*/
|
|
889
832
|
killAllChildren() {
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
// Сначала убиваем известные child objects через killProcessTree
|
|
833
|
+
// Убиваем прямых детей через taskkill /T (best effort)
|
|
893
834
|
for (const child of this.activeChildren) {
|
|
894
|
-
|
|
895
|
-
if (child.pid) this.trackedPids.delete(child.pid);
|
|
835
|
+
killChildProcess(child);
|
|
896
836
|
}
|
|
897
837
|
this.activeChildren.clear();
|
|
898
838
|
|
|
899
|
-
//
|
|
900
|
-
|
|
901
|
-
|
|
839
|
+
// Snapshot-based: убиваем все процессы, появившиеся после spawn
|
|
840
|
+
if (this.preSpawnSnapshot.size > 0) {
|
|
841
|
+
const currentPids = getProcessSnapshot();
|
|
842
|
+
const orphans = [];
|
|
843
|
+
for (const pid of currentPids) {
|
|
844
|
+
if (!this.preSpawnSnapshot.has(pid) && pid !== process.pid) {
|
|
845
|
+
orphans.push(pid);
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
killPids(orphans, this.logger);
|
|
902
849
|
}
|
|
903
|
-
this.
|
|
850
|
+
this.preSpawnSnapshot = new Set();
|
|
904
851
|
}
|
|
905
852
|
|
|
906
853
|
/**
|
|
@@ -1038,6 +985,11 @@ class StageExecutor {
|
|
|
1038
985
|
}
|
|
1039
986
|
}
|
|
1040
987
|
|
|
988
|
+
// Снапшот PID'ов до spawn — для snapshot-based cleanup сирот
|
|
989
|
+
if (this.preSpawnSnapshot.size === 0) {
|
|
990
|
+
this.preSpawnSnapshot = getProcessSnapshot();
|
|
991
|
+
}
|
|
992
|
+
|
|
1041
993
|
const child = spawn(agent.command, args, {
|
|
1042
994
|
cwd: path.resolve(this.projectRoot, agent.workdir || '.'),
|
|
1043
995
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
@@ -1046,8 +998,6 @@ class StageExecutor {
|
|
|
1046
998
|
|
|
1047
999
|
// Регистрируем дочерний процесс для cleanup
|
|
1048
1000
|
this.activeChildren.add(child);
|
|
1049
|
-
if (child.pid) this.trackedPids.add(child.pid);
|
|
1050
|
-
this._startPidTracking();
|
|
1051
1001
|
|
|
1052
1002
|
// Передаём промпт через stdin или закрываем если не нужно
|
|
1053
1003
|
if (useStdin) {
|
|
@@ -1061,11 +1011,10 @@ class StageExecutor {
|
|
|
1061
1011
|
let stderr = '';
|
|
1062
1012
|
let timedOut = false;
|
|
1063
1013
|
|
|
1064
|
-
// Таймаут — убиваем
|
|
1014
|
+
// Таймаут — убиваем child + snapshot-based cleanup через killAllChildren
|
|
1065
1015
|
const timeoutId = setTimeout(() => {
|
|
1066
1016
|
timedOut = true;
|
|
1067
|
-
|
|
1068
|
-
this.activeChildren.delete(child);
|
|
1017
|
+
this.killAllChildren(); // убьёт child + сирот по snapshot
|
|
1069
1018
|
if (this.logger) {
|
|
1070
1019
|
this.logger.timeout(stageId, timeout);
|
|
1071
1020
|
}
|
|
@@ -1116,7 +1065,6 @@ class StageExecutor {
|
|
|
1116
1065
|
child.on('close', (code) => {
|
|
1117
1066
|
clearTimeout(timeoutId);
|
|
1118
1067
|
this.activeChildren.delete(child);
|
|
1119
|
-
if (this.activeChildren.size === 0) this._stopPidTracking();
|
|
1120
1068
|
// Обрабатываем остаток буфера стриминга
|
|
1121
1069
|
if (stdoutBuffer.trim()) {
|
|
1122
1070
|
try {
|
|
@@ -1307,8 +1255,8 @@ class PipelineRunner {
|
|
|
1307
1255
|
|
|
1308
1256
|
// Текущий executor для доступа к activeChildren при shutdown
|
|
1309
1257
|
this.currentExecutor = null;
|
|
1310
|
-
//
|
|
1311
|
-
this.
|
|
1258
|
+
// Снапшот PID'ов перед стартом пайплайна — для snapshot-based cleanup
|
|
1259
|
+
this.preRunSnapshot = new Set();
|
|
1312
1260
|
|
|
1313
1261
|
// Настройка graceful shutdown
|
|
1314
1262
|
this.setupGracefulShutdown();
|
|
@@ -1375,6 +1323,9 @@ class PipelineRunner {
|
|
|
1375
1323
|
const maxSteps = this.pipeline.execution?.max_steps || 100;
|
|
1376
1324
|
const delayBetweenStages = this.pipeline.execution?.delay_between_stages || 5;
|
|
1377
1325
|
|
|
1326
|
+
// Снапшот процессов до старта — для snapshot-based cleanup при выходе
|
|
1327
|
+
this.preRunSnapshot = getProcessSnapshot();
|
|
1328
|
+
|
|
1378
1329
|
this.logger.info('=== Pipeline Runner Started ===', 'PipelineRunner');
|
|
1379
1330
|
this.logger.info(`Entry stage: ${this.pipeline.entry}`, 'PipelineRunner');
|
|
1380
1331
|
this.logger.info(`Max steps: ${maxSteps}`, 'PipelineRunner');
|
|
@@ -1406,10 +1357,6 @@ class PipelineRunner {
|
|
|
1406
1357
|
} else {
|
|
1407
1358
|
this.currentExecutor = new StageExecutor(this.config, this.context, this.counters, {}, this.fileGuard, this.logger, this.projectRoot);
|
|
1408
1359
|
result = await this.currentExecutor.execute(this.currentStage);
|
|
1409
|
-
// Сохраняем tracked PIDs для cleanup сирот при выходе
|
|
1410
|
-
for (const pid of this.currentExecutor.trackedPids) {
|
|
1411
|
-
this.allTrackedPids.add(pid);
|
|
1412
|
-
}
|
|
1413
1360
|
this.currentExecutor = null;
|
|
1414
1361
|
}
|
|
1415
1362
|
|
|
@@ -1465,6 +1412,9 @@ class PipelineRunner {
|
|
|
1465
1412
|
// Записываем итоговый summary
|
|
1466
1413
|
this.logger.writeSummary();
|
|
1467
1414
|
|
|
1415
|
+
// Cleanup: убиваем процессы-сироты, порождённые агентами во время пайплайна
|
|
1416
|
+
this._cleanupOrphans();
|
|
1417
|
+
|
|
1468
1418
|
return {
|
|
1469
1419
|
steps: this.stepCount,
|
|
1470
1420
|
tasksExecuted: this.tasksExecuted,
|
|
@@ -1591,29 +1541,45 @@ class PipelineRunner {
|
|
|
1591
1541
|
/**
|
|
1592
1542
|
* Настройка graceful shutdown
|
|
1593
1543
|
*/
|
|
1544
|
+
/**
|
|
1545
|
+
* Snapshot-based cleanup: убивает процессы, появившиеся после старта пайплайна.
|
|
1546
|
+
* Сравнивает текущие PID'ы node/python/cmd с снапшотом до run() —
|
|
1547
|
+
* разница = процессы порождённые агентами (включая MCP-серверы-сироты).
|
|
1548
|
+
*/
|
|
1549
|
+
_cleanupOrphans() {
|
|
1550
|
+
if (this.preRunSnapshot.size === 0) return;
|
|
1551
|
+
const currentPids = getProcessSnapshot();
|
|
1552
|
+
const orphans = [];
|
|
1553
|
+
for (const pid of currentPids) {
|
|
1554
|
+
if (!this.preRunSnapshot.has(pid) && pid !== process.pid) {
|
|
1555
|
+
orphans.push(pid);
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
if (orphans.length > 0) {
|
|
1559
|
+
killPids(orphans, this.logger);
|
|
1560
|
+
}
|
|
1561
|
+
}
|
|
1562
|
+
|
|
1594
1563
|
setupGracefulShutdown() {
|
|
1595
1564
|
const shutdown = (signal) => {
|
|
1596
1565
|
if (this.logger) {
|
|
1597
1566
|
this.logger.info(`Received ${signal}. Killing child processes and shutting down...`, 'PipelineRunner');
|
|
1598
1567
|
}
|
|
1599
1568
|
this.running = false;
|
|
1600
|
-
// Убиваем
|
|
1569
|
+
// Убиваем активные дочерние процессы текущего stage
|
|
1601
1570
|
if (this.currentExecutor) {
|
|
1602
1571
|
this.currentExecutor.killAllChildren();
|
|
1603
1572
|
}
|
|
1604
|
-
//
|
|
1605
|
-
|
|
1606
|
-
killPid(pid);
|
|
1607
|
-
}
|
|
1608
|
-
this.allTrackedPids.clear();
|
|
1573
|
+
// Snapshot-based: убиваем всех сирот от всех stage'ей
|
|
1574
|
+
this._cleanupOrphans();
|
|
1609
1575
|
};
|
|
1610
1576
|
|
|
1611
1577
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
1612
1578
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
1613
1579
|
|
|
1614
|
-
// Финальный cleanup при выходе
|
|
1580
|
+
// Финальный синхронный cleanup при выходе — последний шанс убить сирот
|
|
1615
1581
|
process.on('exit', () => {
|
|
1616
|
-
// Убиваем активные child objects (если
|
|
1582
|
+
// Убиваем активные child objects (если exit во время работы stage)
|
|
1617
1583
|
if (this.currentExecutor) {
|
|
1618
1584
|
for (const child of this.currentExecutor.activeChildren) {
|
|
1619
1585
|
if (child.pid) {
|
|
@@ -1624,22 +1590,9 @@ class PipelineRunner {
|
|
|
1624
1590
|
}
|
|
1625
1591
|
}
|
|
1626
1592
|
}
|
|
1627
|
-
// Добавляем PID'ы текущего executor'а
|
|
1628
|
-
for (const pid of this.currentExecutor.trackedPids) {
|
|
1629
|
-
this.allTrackedPids.add(pid);
|
|
1630
|
-
}
|
|
1631
|
-
}
|
|
1632
|
-
|
|
1633
|
-
// Убиваем все ранее собранные PID'ы потомков (сироты от всех stage'ей)
|
|
1634
|
-
for (const pid of this.allTrackedPids) {
|
|
1635
|
-
try {
|
|
1636
|
-
if (process.platform === 'win32') {
|
|
1637
|
-
execSync(`taskkill /pid ${pid} /F`, { stdio: 'pipe' });
|
|
1638
|
-
} else {
|
|
1639
|
-
process.kill(pid, 'SIGKILL');
|
|
1640
|
-
}
|
|
1641
|
-
} catch {}
|
|
1642
1593
|
}
|
|
1594
|
+
// Snapshot-based cleanup
|
|
1595
|
+
this._cleanupOrphans();
|
|
1643
1596
|
});
|
|
1644
1597
|
}
|
|
1645
1598
|
}
|