workflow-ai 1.0.50 → 1.0.51

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 +132 -13
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "workflow-ai",
3
- "version": "1.0.50",
3
+ "version": "1.0.51",
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
@@ -8,43 +8,89 @@ 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 для уничтожения всего дерева.
11
+ // killProcessTree — убивает процесс и всех его потомков рекурсивно.
12
+ // На Windows child.kill() не убивает дочерние процессы (npx → node → python).
13
+ // taskkill /T /F не находит сирот (чей parent уже завершился).
14
+ // Поэтому: сначала собираем всё дерево PID через wmic, потом убиваем снизу вверх.
14
15
  // ============================================================================
16
+ function getDescendantPids(pid) {
17
+ if (process.platform !== 'win32') return [];
18
+ try {
19
+ const output = execSync(
20
+ `wmic process where (ParentProcessId=${pid}) get ProcessId /FORMAT:LIST`,
21
+ { stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf-8' }
22
+ );
23
+ const pids = [];
24
+ for (const line of output.split('\n')) {
25
+ 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
+ }
32
+ }
33
+ return pids;
34
+ } catch {
35
+ return [];
36
+ }
37
+ }
38
+
15
39
  function killProcessTree(child, logger = null) {
16
40
  const pid = child.pid;
17
41
  if (!pid) return;
18
42
 
19
43
  if (process.platform === 'win32') {
20
- try {
21
- execSync(`taskkill /pid ${pid} /T /F`, { stdio: 'pipe' });
22
- } catch {
23
- // Процесс уже завершился это нормально
44
+ // Собираем всех потомков ДО убийства (иначе дерево разорвётся)
45
+ const allPids = [...getDescendantPids(pid), pid];
46
+
47
+ // Убиваем снизу вверх (сначала внуки, потом дети, потом root)
48
+ for (const p of allPids) {
49
+ 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');
24
58
  }
25
59
  } else {
26
60
  // Unix: отправляем SIGTERM, через 5 сек — SIGKILL если ещё жив
27
61
  try {
28
62
  child.kill('SIGTERM');
29
63
  } catch {
30
- return; // процесс уже завершился
64
+ return;
31
65
  }
32
66
 
33
67
  setTimeout(() => {
34
68
  try {
35
- // Проверяем что процесс ещё жив (kill(0) не убивает, только проверяет)
36
69
  process.kill(pid, 0);
37
70
  child.kill('SIGKILL');
38
71
  if (logger) {
39
72
  logger.warn(`Process ${pid} did not exit after SIGTERM, sent SIGKILL`, 'ProcessCleanup');
40
73
  }
41
74
  } catch {
42
- // Процесс уже завершился после SIGTERM — всё ок
75
+ // Процесс уже завершился после SIGTERM
43
76
  }
44
77
  }, 5000);
45
78
  }
46
79
  }
47
80
 
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
+ // уже завершился
91
+ }
92
+ }
93
+
48
94
  // ============================================================================
49
95
  // Logger — система логирования с уровнями DEBUG/INFO/WARN/ERROR
50
96
  // ============================================================================
@@ -802,16 +848,59 @@ class StageExecutor {
802
848
 
803
849
  // Трекинг активных дочерних процессов для cleanup
804
850
  this.activeChildren = new Set();
851
+ // Все PID'ы потомков (включая внуков), собранные при жизни процесса
852
+ this.trackedPids = new Set();
853
+ // Интервал для периодического сбора PID-дерева
854
+ this._pidScanInterval = null;
805
855
  }
806
856
 
807
857
  /**
808
- * Убивает все активные дочерние процессы
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);
874
+ }
875
+
876
+ /**
877
+ * Останавливает периодический сбор PID'ов
878
+ */
879
+ _stopPidTracking() {
880
+ if (this._pidScanInterval) {
881
+ clearInterval(this._pidScanInterval);
882
+ this._pidScanInterval = null;
883
+ }
884
+ }
885
+
886
+ /**
887
+ * Убивает все активные дочерние процессы и все ранее найденные PID'ы потомков
809
888
  */
810
889
  killAllChildren() {
890
+ this._stopPidTracking();
891
+
892
+ // Сначала убиваем известные child objects через killProcessTree
811
893
  for (const child of this.activeChildren) {
812
894
  killProcessTree(child, this.logger);
895
+ if (child.pid) this.trackedPids.delete(child.pid);
813
896
  }
814
897
  this.activeChildren.clear();
898
+
899
+ // Затем убиваем все ранее собранные PID'ы-сироты которые могли остаться
900
+ for (const pid of this.trackedPids) {
901
+ killPid(pid);
902
+ }
903
+ this.trackedPids.clear();
815
904
  }
816
905
 
817
906
  /**
@@ -957,6 +1046,8 @@ class StageExecutor {
957
1046
 
958
1047
  // Регистрируем дочерний процесс для cleanup
959
1048
  this.activeChildren.add(child);
1049
+ if (child.pid) this.trackedPids.add(child.pid);
1050
+ this._startPidTracking();
960
1051
 
961
1052
  // Передаём промпт через stdin или закрываем если не нужно
962
1053
  if (useStdin) {
@@ -1025,6 +1116,7 @@ class StageExecutor {
1025
1116
  child.on('close', (code) => {
1026
1117
  clearTimeout(timeoutId);
1027
1118
  this.activeChildren.delete(child);
1119
+ if (this.activeChildren.size === 0) this._stopPidTracking();
1028
1120
  // Обрабатываем остаток буфера стриминга
1029
1121
  if (stdoutBuffer.trim()) {
1030
1122
  try {
@@ -1215,6 +1307,8 @@ class PipelineRunner {
1215
1307
 
1216
1308
  // Текущий executor для доступа к activeChildren при shutdown
1217
1309
  this.currentExecutor = null;
1310
+ // Все PID'ы потомков всех executor'ов — для cleanup сирот при выходе
1311
+ this.allTrackedPids = new Set();
1218
1312
 
1219
1313
  // Настройка graceful shutdown
1220
1314
  this.setupGracefulShutdown();
@@ -1312,6 +1406,10 @@ class PipelineRunner {
1312
1406
  } else {
1313
1407
  this.currentExecutor = new StageExecutor(this.config, this.context, this.counters, {}, this.fileGuard, this.logger, this.projectRoot);
1314
1408
  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
+ }
1315
1413
  this.currentExecutor = null;
1316
1414
  }
1317
1415
 
@@ -1503,14 +1601,20 @@ class PipelineRunner {
1503
1601
  if (this.currentExecutor) {
1504
1602
  this.currentExecutor.killAllChildren();
1505
1603
  }
1604
+ // Убиваем сирот от предыдущих stage'ей
1605
+ for (const pid of this.allTrackedPids) {
1606
+ killPid(pid);
1607
+ }
1608
+ this.allTrackedPids.clear();
1506
1609
  };
1507
1610
 
1508
1611
  process.on('SIGINT', () => shutdown('SIGINT'));
1509
1612
  process.on('SIGTERM', () => shutdown('SIGTERM'));
1510
1613
 
1511
- // Финальный cleanup при выходе процесса — синхронный, последний шанс убить детей
1614
+ // Финальный cleanup при выходе процесса — синхронный, последний шанс убить детей и сирот
1512
1615
  process.on('exit', () => {
1513
- if (this.currentExecutor && this.currentExecutor.activeChildren.size > 0) {
1616
+ // Убиваем активные child objects (если shutdown во время работы stage)
1617
+ if (this.currentExecutor) {
1514
1618
  for (const child of this.currentExecutor.activeChildren) {
1515
1619
  if (child.pid) {
1516
1620
  if (process.platform === 'win32') {
@@ -1520,6 +1624,21 @@ class PipelineRunner {
1520
1624
  }
1521
1625
  }
1522
1626
  }
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 {}
1523
1642
  }
1524
1643
  });
1525
1644
  }