workflow-ai 1.0.50 → 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 +108 -36
package/package.json
CHANGED
package/src/runner.mjs
CHANGED
|
@@ -2,46 +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
|
-
//
|
|
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
|
+
// разница = процессы порождённые нашим агентом (включая внуков и сирот).
|
|
14
19
|
// ============================================================================
|
|
15
|
-
function
|
|
16
|
-
|
|
17
|
-
|
|
20
|
+
function getProcessSnapshot() {
|
|
21
|
+
if (process.platform !== 'win32') return new Set();
|
|
22
|
+
try {
|
|
23
|
+
const output = execSync(
|
|
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 }
|
|
26
|
+
);
|
|
27
|
+
const pids = new Set();
|
|
28
|
+
for (const line of output.split('\n')) {
|
|
29
|
+
const match = line.match(/ProcessId=(\d+)/);
|
|
30
|
+
if (match) pids.add(parseInt(match[1], 10));
|
|
31
|
+
}
|
|
32
|
+
return pids;
|
|
33
|
+
} catch {
|
|
34
|
+
return new Set();
|
|
35
|
+
}
|
|
36
|
+
}
|
|
18
37
|
|
|
38
|
+
function killPids(pids, logger = null) {
|
|
39
|
+
if (pids.length === 0) return;
|
|
19
40
|
if (process.platform === 'win32') {
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
41
|
+
for (const pid of pids) {
|
|
42
|
+
try {
|
|
43
|
+
execSync(`taskkill /pid ${pid} /F`, { stdio: 'pipe' });
|
|
44
|
+
} catch { /* already dead */ }
|
|
24
45
|
}
|
|
25
46
|
} else {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
child.kill('SIGTERM');
|
|
29
|
-
} catch {
|
|
30
|
-
return; // процесс уже завершился
|
|
47
|
+
for (const pid of pids) {
|
|
48
|
+
try { process.kill(pid, 'SIGKILL'); } catch { /* already dead */ }
|
|
31
49
|
}
|
|
50
|
+
}
|
|
51
|
+
if (logger && pids.length > 0) {
|
|
52
|
+
logger.info(`Cleaned up ${pids.length} orphan process(es): ${pids.join(', ')}`, 'ProcessCleanup');
|
|
53
|
+
}
|
|
54
|
+
}
|
|
32
55
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
41
|
-
} catch {
|
|
42
|
-
// Процесс уже завершился после SIGTERM — всё ок
|
|
43
|
-
}
|
|
44
|
-
}, 5000);
|
|
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 {}
|
|
45
63
|
}
|
|
46
64
|
}
|
|
47
65
|
|
|
@@ -802,16 +820,34 @@ class StageExecutor {
|
|
|
802
820
|
|
|
803
821
|
// Трекинг активных дочерних процессов для cleanup
|
|
804
822
|
this.activeChildren = new Set();
|
|
823
|
+
// Снапшот PID'ов перед spawn — для вычисления порождённых процессов
|
|
824
|
+
this.preSpawnSnapshot = new Set();
|
|
805
825
|
}
|
|
806
826
|
|
|
807
827
|
/**
|
|
808
|
-
* Убивает все активные дочерние процессы
|
|
828
|
+
* Убивает все активные дочерние процессы и порождённых ими сирот.
|
|
829
|
+
* Snapshot-based: сравнивает текущие PID'ы с снапшотом до spawn,
|
|
830
|
+
* новые процессы = порождённые нашим агентом (включая внуков/MCP-серверов).
|
|
809
831
|
*/
|
|
810
832
|
killAllChildren() {
|
|
833
|
+
// Убиваем прямых детей через taskkill /T (best effort)
|
|
811
834
|
for (const child of this.activeChildren) {
|
|
812
|
-
|
|
835
|
+
killChildProcess(child);
|
|
813
836
|
}
|
|
814
837
|
this.activeChildren.clear();
|
|
838
|
+
|
|
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);
|
|
849
|
+
}
|
|
850
|
+
this.preSpawnSnapshot = new Set();
|
|
815
851
|
}
|
|
816
852
|
|
|
817
853
|
/**
|
|
@@ -949,6 +985,11 @@ class StageExecutor {
|
|
|
949
985
|
}
|
|
950
986
|
}
|
|
951
987
|
|
|
988
|
+
// Снапшот PID'ов до spawn — для snapshot-based cleanup сирот
|
|
989
|
+
if (this.preSpawnSnapshot.size === 0) {
|
|
990
|
+
this.preSpawnSnapshot = getProcessSnapshot();
|
|
991
|
+
}
|
|
992
|
+
|
|
952
993
|
const child = spawn(agent.command, args, {
|
|
953
994
|
cwd: path.resolve(this.projectRoot, agent.workdir || '.'),
|
|
954
995
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
@@ -970,11 +1011,10 @@ class StageExecutor {
|
|
|
970
1011
|
let stderr = '';
|
|
971
1012
|
let timedOut = false;
|
|
972
1013
|
|
|
973
|
-
// Таймаут — убиваем
|
|
1014
|
+
// Таймаут — убиваем child + snapshot-based cleanup через killAllChildren
|
|
974
1015
|
const timeoutId = setTimeout(() => {
|
|
975
1016
|
timedOut = true;
|
|
976
|
-
|
|
977
|
-
this.activeChildren.delete(child);
|
|
1017
|
+
this.killAllChildren(); // убьёт child + сирот по snapshot
|
|
978
1018
|
if (this.logger) {
|
|
979
1019
|
this.logger.timeout(stageId, timeout);
|
|
980
1020
|
}
|
|
@@ -1215,6 +1255,8 @@ class PipelineRunner {
|
|
|
1215
1255
|
|
|
1216
1256
|
// Текущий executor для доступа к activeChildren при shutdown
|
|
1217
1257
|
this.currentExecutor = null;
|
|
1258
|
+
// Снапшот PID'ов перед стартом пайплайна — для snapshot-based cleanup
|
|
1259
|
+
this.preRunSnapshot = new Set();
|
|
1218
1260
|
|
|
1219
1261
|
// Настройка graceful shutdown
|
|
1220
1262
|
this.setupGracefulShutdown();
|
|
@@ -1281,6 +1323,9 @@ class PipelineRunner {
|
|
|
1281
1323
|
const maxSteps = this.pipeline.execution?.max_steps || 100;
|
|
1282
1324
|
const delayBetweenStages = this.pipeline.execution?.delay_between_stages || 5;
|
|
1283
1325
|
|
|
1326
|
+
// Снапшот процессов до старта — для snapshot-based cleanup при выходе
|
|
1327
|
+
this.preRunSnapshot = getProcessSnapshot();
|
|
1328
|
+
|
|
1284
1329
|
this.logger.info('=== Pipeline Runner Started ===', 'PipelineRunner');
|
|
1285
1330
|
this.logger.info(`Entry stage: ${this.pipeline.entry}`, 'PipelineRunner');
|
|
1286
1331
|
this.logger.info(`Max steps: ${maxSteps}`, 'PipelineRunner');
|
|
@@ -1367,6 +1412,9 @@ class PipelineRunner {
|
|
|
1367
1412
|
// Записываем итоговый summary
|
|
1368
1413
|
this.logger.writeSummary();
|
|
1369
1414
|
|
|
1415
|
+
// Cleanup: убиваем процессы-сироты, порождённые агентами во время пайплайна
|
|
1416
|
+
this._cleanupOrphans();
|
|
1417
|
+
|
|
1370
1418
|
return {
|
|
1371
1419
|
steps: this.stepCount,
|
|
1372
1420
|
tasksExecuted: this.tasksExecuted,
|
|
@@ -1493,24 +1541,46 @@ class PipelineRunner {
|
|
|
1493
1541
|
/**
|
|
1494
1542
|
* Настройка graceful shutdown
|
|
1495
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
|
+
|
|
1496
1563
|
setupGracefulShutdown() {
|
|
1497
1564
|
const shutdown = (signal) => {
|
|
1498
1565
|
if (this.logger) {
|
|
1499
1566
|
this.logger.info(`Received ${signal}. Killing child processes and shutting down...`, 'PipelineRunner');
|
|
1500
1567
|
}
|
|
1501
1568
|
this.running = false;
|
|
1502
|
-
// Убиваем
|
|
1569
|
+
// Убиваем активные дочерние процессы текущего stage
|
|
1503
1570
|
if (this.currentExecutor) {
|
|
1504
1571
|
this.currentExecutor.killAllChildren();
|
|
1505
1572
|
}
|
|
1573
|
+
// Snapshot-based: убиваем всех сирот от всех stage'ей
|
|
1574
|
+
this._cleanupOrphans();
|
|
1506
1575
|
};
|
|
1507
1576
|
|
|
1508
1577
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
1509
1578
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
1510
1579
|
|
|
1511
|
-
// Финальный cleanup при выходе
|
|
1580
|
+
// Финальный синхронный cleanup при выходе — последний шанс убить сирот
|
|
1512
1581
|
process.on('exit', () => {
|
|
1513
|
-
|
|
1582
|
+
// Убиваем активные child objects (если exit во время работы stage)
|
|
1583
|
+
if (this.currentExecutor) {
|
|
1514
1584
|
for (const child of this.currentExecutor.activeChildren) {
|
|
1515
1585
|
if (child.pid) {
|
|
1516
1586
|
if (process.platform === 'win32') {
|
|
@@ -1521,6 +1591,8 @@ class PipelineRunner {
|
|
|
1521
1591
|
}
|
|
1522
1592
|
}
|
|
1523
1593
|
}
|
|
1594
|
+
// Snapshot-based cleanup
|
|
1595
|
+
this._cleanupOrphans();
|
|
1524
1596
|
});
|
|
1525
1597
|
}
|
|
1526
1598
|
}
|