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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/runner.mjs +92 -139
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "workflow-ai",
3
- "version": "1.0.51",
3
+ "version": "1.0.52",
4
4
  "description": "AI Agent Workflow Coordinator — kanban-based pipeline for AI coding agents",
5
5
  "type": "module",
6
6
  "bin": {
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, exec } from 'child_process';
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
- // killProcessTreeубивает процесс и всех его потомков рекурсивно.
12
- // На Windows child.kill() не убивает дочерние процессы (npx → node → python).
13
- // taskkill /T /F не находит сирот (чей parent уже завершился).
14
- // Поэтому: сначала собираем всё дерево PID через wmic, потом убиваем снизу вверх.
11
+ // ProcessTrackersnapshot-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 getDescendantPids(pid) {
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
- `wmic process where (ParentProcessId=${pid}) get ProcessId /FORMAT:LIST`,
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 killProcessTree(child, logger = null) {
40
- const pid = child.pid;
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 ${p} /F`, { stdio: 'pipe' });
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
- // Unix: отправляем SIGTERM, через 5 сек — SIGKILL если ещё жив
61
- try {
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
- setTimeout(() => {
68
- try {
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
- // killPid — убивает один PID (для cleanup сирот)
82
- function killPid(pid) {
83
- try {
84
- if (process.platform === 'win32') {
85
- execSync(`taskkill /pid ${pid} /F`, { stdio: 'pipe' });
86
- } else {
87
- process.kill(pid, 'SIGKILL');
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
- // Все PID'ы потомков (включая внуков), собранные при жизни процесса
852
- this.trackedPids = new Set();
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
- * Останавливает периодический сбор PID'ов
878
- */
879
- _stopPidTracking() {
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
- this._stopPidTracking();
891
-
892
- // Сначала убиваем известные child objects через killProcessTree
833
+ // Убиваем прямых детей через taskkill /T (best effort)
893
834
  for (const child of this.activeChildren) {
894
- killProcessTree(child, this.logger);
895
- if (child.pid) this.trackedPids.delete(child.pid);
835
+ killChildProcess(child);
896
836
  }
897
837
  this.activeChildren.clear();
898
838
 
899
- // Затем убиваем все ранее собранные PID'ы-сироты которые могли остаться
900
- for (const pid of this.trackedPids) {
901
- killPid(pid);
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.trackedPids.clear();
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
- // Таймаут — убиваем всё дерево процессов (на Windows child.kill не убивает внуков)
1014
+ // Таймаут — убиваем child + snapshot-based cleanup через killAllChildren
1065
1015
  const timeoutId = setTimeout(() => {
1066
1016
  timedOut = true;
1067
- killProcessTree(child, this.logger);
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
- // Все PID'ы потомков всех executor'ов — для cleanup сирот при выходе
1311
- this.allTrackedPids = new Set();
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
- // Убиваем все активные дочерние процессы (включая внуков на Windows)
1569
+ // Убиваем активные дочерние процессы текущего stage
1601
1570
  if (this.currentExecutor) {
1602
1571
  this.currentExecutor.killAllChildren();
1603
1572
  }
1604
- // Убиваем сирот от предыдущих stage'ей
1605
- for (const pid of this.allTrackedPids) {
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 (если shutdown во время работы stage)
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
  }