workflow-ai 1.0.51 → 1.0.53

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 +179 -137
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "workflow-ai",
3
- "version": "1.0.51",
3
+ "version": "1.0.53",
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,145 @@
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
+ // ProcessTrackerфайловый трекинг дочерних процессов.
12
+ //
13
+ // Проблема: runner spawn claude CLI npx node (MCP-сервер).
14
+ // При жёстком завершении runner'а (kill, закрытие терминала) process.on('exit')
15
+ // не срабатывает, MCP-серверы остаются сиротами навсегда.
16
+ //
17
+ // Решение: PID-файл (.workflow/logs/.runner-pids).
18
+ // - Перед spawn: записываем снапшот существующих PID'ов в файл
19
+ // - При старте runner'а: читаем файл, вычисляем сирот (текущие − снапшот), убиваем
20
+ // - Файл переживает крэш/kill — cleanup гарантирован при следующем запуске
15
21
  // ============================================================================
16
- function getDescendantPids(pid) {
17
- if (process.platform !== 'win32') return [];
22
+ const PIDFILE_NAME = '.runner-pids';
23
+
24
+ function getPidFilePath(projectRoot) {
25
+ return path.resolve(projectRoot, '.workflow/logs', PIDFILE_NAME);
26
+ }
27
+
28
+ function getProcessSnapshot() {
29
+ if (process.platform !== 'win32') return new Set();
18
30
  try {
19
31
  const output = execSync(
20
- `wmic process where (ParentProcessId=${pid}) get ProcessId /FORMAT:LIST`,
21
- { stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf-8' }
32
+ 'wmic process where "name=\'node.exe\' or name=\'python.exe\' or name=\'python3.exe\' or name=\'cmd.exe\'" get ProcessId /FORMAT:LIST',
33
+ { stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf-8', timeout: 5000 }
22
34
  );
23
- const pids = [];
35
+ const pids = new Set();
24
36
  for (const line of output.split('\n')) {
25
37
  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
- }
38
+ if (match) pids.add(parseInt(match[1], 10));
32
39
  }
33
40
  return pids;
34
41
  } catch {
35
- return [];
42
+ return new Set();
36
43
  }
37
44
  }
38
45
 
39
- function killProcessTree(child, logger = null) {
40
- const pid = child.pid;
41
- if (!pid) return;
46
+ /**
47
+ * Записывает снапшот + runner PID в файл.
48
+ * Формат: JSON { runnerPid, snapshot: [...], timestamp }
49
+ */
50
+ function writePidFile(pidFilePath, snapshot) {
51
+ try {
52
+ const dir = path.dirname(pidFilePath);
53
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
54
+ fs.writeFileSync(pidFilePath, JSON.stringify({
55
+ runnerPid: process.pid,
56
+ snapshot: [...snapshot],
57
+ timestamp: new Date().toISOString()
58
+ }));
59
+ } catch { /* non-critical */ }
60
+ }
42
61
 
43
- if (process.platform === 'win32') {
44
- // Собираем всех потомков ДО убийства (иначе дерево разорвётся)
45
- const allPids = [...getDescendantPids(pid), pid];
62
+ /**
63
+ * Читает PID-файл. Возвращает { runnerPid, snapshot } или null.
64
+ */
65
+ function readPidFile(pidFilePath) {
66
+ try {
67
+ if (!fs.existsSync(pidFilePath)) return null;
68
+ const data = JSON.parse(fs.readFileSync(pidFilePath, 'utf-8'));
69
+ return {
70
+ runnerPid: data.runnerPid,
71
+ snapshot: new Set(data.snapshot || [])
72
+ };
73
+ } catch {
74
+ return null;
75
+ }
76
+ }
46
77
 
47
- // Убиваем снизу вверх (сначала внуки, потом дети, потом root)
48
- for (const p of allPids) {
49
- try {
50
- execSync(`taskkill /pid ${p} /F`, { stdio: 'pipe' });
51
- } catch {
52
- // Процесс уже завершился
53
- }
54
- }
78
+ function removePidFile(pidFilePath) {
79
+ try { fs.unlinkSync(pidFilePath); } catch { /* ok */ }
80
+ }
55
81
 
56
- if (logger && allPids.length > 1) {
57
- logger.info(`Killed process tree: ${allPids.join(' ')}`, 'ProcessCleanup');
58
- }
59
- } else {
60
- // Unix: отправляем SIGTERM, через 5 сек — SIGKILL если ещё жив
61
- try {
62
- child.kill('SIGTERM');
63
- } catch {
64
- return;
82
+ /**
83
+ * Cleanup сирот от предыдущего запуска.
84
+ * Читает PID-файл, берёт текущие процессы, убивает разницу (текущие − снапшот).
85
+ * Пропускает собственный PID runner'а.
86
+ */
87
+ function cleanupOrphansFromFile(pidFilePath, logger = null) {
88
+ const saved = readPidFile(pidFilePath);
89
+ if (!saved) return;
90
+
91
+ // Проверяем что предыдущий runner мёртв (иначе не трогаем — он ещё работает)
92
+ if (saved.runnerPid && isProcessAlive(saved.runnerPid)) {
93
+ return;
94
+ }
95
+
96
+ const currentPids = getProcessSnapshot();
97
+ const orphans = [];
98
+ for (const pid of currentPids) {
99
+ if (!saved.snapshot.has(pid) && pid !== process.pid) {
100
+ orphans.push(pid);
65
101
  }
102
+ }
66
103
 
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);
104
+ if (orphans.length > 0) {
105
+ killPids(orphans, logger);
78
106
  }
107
+
108
+ removePidFile(pidFilePath);
79
109
  }
80
110
 
81
- // killPid — убивает один PID (для cleanup сирот)
82
- function killPid(pid) {
111
+ function isProcessAlive(pid) {
83
112
  try {
84
- if (process.platform === 'win32') {
85
- execSync(`taskkill /pid ${pid} /F`, { stdio: 'pipe' });
86
- } else {
87
- process.kill(pid, 'SIGKILL');
88
- }
113
+ process.kill(pid, 0);
114
+ return true;
89
115
  } catch {
90
- // уже завершился
116
+ return false;
117
+ }
118
+ }
119
+
120
+ function killPids(pids, logger = null) {
121
+ if (pids.length === 0) return;
122
+ if (process.platform === 'win32') {
123
+ for (const pid of pids) {
124
+ try {
125
+ execSync(`taskkill /pid ${pid} /F`, { stdio: 'pipe' });
126
+ } catch { /* already dead */ }
127
+ }
128
+ } else {
129
+ for (const pid of pids) {
130
+ try { process.kill(pid, 'SIGKILL'); } catch { /* already dead */ }
131
+ }
132
+ }
133
+ if (logger) {
134
+ logger.info(`Cleaned up ${pids.length} orphan process(es): ${pids.join(', ')}`, 'ProcessCleanup');
135
+ }
136
+ }
137
+
138
+ function killChildProcess(child) {
139
+ if (!child.pid) return;
140
+ if (process.platform === 'win32') {
141
+ try { execSync(`taskkill /pid ${child.pid} /T /F`, { stdio: 'pipe' }); } catch {}
142
+ } else {
143
+ try { child.kill('SIGTERM'); } catch {}
91
144
  }
92
145
  }
93
146
 
@@ -848,59 +901,34 @@ class StageExecutor {
848
901
 
849
902
  // Трекинг активных дочерних процессов для cleanup
850
903
  this.activeChildren = new Set();
851
- // Все PID'ы потомков (включая внуков), собранные при жизни процесса
852
- this.trackedPids = new Set();
853
- // Интервал для периодического сбора PID-дерева
854
- this._pidScanInterval = null;
904
+ // Снапшот PID'ов перед spawn для вычисления порождённых процессов
905
+ this.preSpawnSnapshot = new Set();
855
906
  }
856
907
 
857
908
  /**
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'ы потомков
909
+ * Убивает все активные дочерние процессы и порождённых ими сирот.
910
+ * Snapshot-based: сравнивает текущие PID'ы с снапшотом до spawn,
911
+ * новые процессы = порождённые нашим агентом (включая внуков/MCP-серверов).
888
912
  */
889
913
  killAllChildren() {
890
- this._stopPidTracking();
891
-
892
- // Сначала убиваем известные child objects через killProcessTree
914
+ // Убиваем прямых детей через taskkill /T (best effort)
893
915
  for (const child of this.activeChildren) {
894
- killProcessTree(child, this.logger);
895
- if (child.pid) this.trackedPids.delete(child.pid);
916
+ killChildProcess(child);
896
917
  }
897
918
  this.activeChildren.clear();
898
919
 
899
- // Затем убиваем все ранее собранные PID'ы-сироты которые могли остаться
900
- for (const pid of this.trackedPids) {
901
- killPid(pid);
920
+ // Snapshot-based: убиваем все процессы, появившиеся после spawn
921
+ if (this.preSpawnSnapshot.size > 0) {
922
+ const currentPids = getProcessSnapshot();
923
+ const orphans = [];
924
+ for (const pid of currentPids) {
925
+ if (!this.preSpawnSnapshot.has(pid) && pid !== process.pid) {
926
+ orphans.push(pid);
927
+ }
928
+ }
929
+ killPids(orphans, this.logger);
902
930
  }
903
- this.trackedPids.clear();
931
+ this.preSpawnSnapshot = new Set();
904
932
  }
905
933
 
906
934
  /**
@@ -1038,6 +1066,11 @@ class StageExecutor {
1038
1066
  }
1039
1067
  }
1040
1068
 
1069
+ // Снапшот PID'ов до spawn — для snapshot-based cleanup сирот
1070
+ if (this.preSpawnSnapshot.size === 0) {
1071
+ this.preSpawnSnapshot = getProcessSnapshot();
1072
+ }
1073
+
1041
1074
  const child = spawn(agent.command, args, {
1042
1075
  cwd: path.resolve(this.projectRoot, agent.workdir || '.'),
1043
1076
  stdio: ['pipe', 'pipe', 'pipe'],
@@ -1046,8 +1079,6 @@ class StageExecutor {
1046
1079
 
1047
1080
  // Регистрируем дочерний процесс для cleanup
1048
1081
  this.activeChildren.add(child);
1049
- if (child.pid) this.trackedPids.add(child.pid);
1050
- this._startPidTracking();
1051
1082
 
1052
1083
  // Передаём промпт через stdin или закрываем если не нужно
1053
1084
  if (useStdin) {
@@ -1061,11 +1092,10 @@ class StageExecutor {
1061
1092
  let stderr = '';
1062
1093
  let timedOut = false;
1063
1094
 
1064
- // Таймаут — убиваем всё дерево процессов (на Windows child.kill не убивает внуков)
1095
+ // Таймаут — убиваем child + snapshot-based cleanup через killAllChildren
1065
1096
  const timeoutId = setTimeout(() => {
1066
1097
  timedOut = true;
1067
- killProcessTree(child, this.logger);
1068
- this.activeChildren.delete(child);
1098
+ this.killAllChildren(); // убьёт child + сирот по snapshot
1069
1099
  if (this.logger) {
1070
1100
  this.logger.timeout(stageId, timeout);
1071
1101
  }
@@ -1116,7 +1146,6 @@ class StageExecutor {
1116
1146
  child.on('close', (code) => {
1117
1147
  clearTimeout(timeoutId);
1118
1148
  this.activeChildren.delete(child);
1119
- if (this.activeChildren.size === 0) this._stopPidTracking();
1120
1149
  // Обрабатываем остаток буфера стриминга
1121
1150
  if (stdoutBuffer.trim()) {
1122
1151
  try {
@@ -1307,8 +1336,10 @@ class PipelineRunner {
1307
1336
 
1308
1337
  // Текущий executor для доступа к activeChildren при shutdown
1309
1338
  this.currentExecutor = null;
1310
- // Все PID потомков всех executor'ов — для cleanup сирот при выходе
1311
- this.allTrackedPids = new Set();
1339
+ // Путь к PID-файлу для cleanup сирот между запусками
1340
+ this.pidFilePath = getPidFilePath(projectRoot);
1341
+ // Снапшот PID'ов перед стартом пайплайна — для snapshot-based cleanup
1342
+ this.preRunSnapshot = new Set();
1312
1343
 
1313
1344
  // Настройка graceful shutdown
1314
1345
  this.setupGracefulShutdown();
@@ -1375,6 +1406,13 @@ class PipelineRunner {
1375
1406
  const maxSteps = this.pipeline.execution?.max_steps || 100;
1376
1407
  const delayBetweenStages = this.pipeline.execution?.delay_between_stages || 5;
1377
1408
 
1409
+ // Cleanup сирот от предыдущего запуска (если runner был убит жёстко)
1410
+ cleanupOrphansFromFile(this.pidFilePath, this.logger);
1411
+
1412
+ // Снапшот процессов до старта + запись PID-файла
1413
+ this.preRunSnapshot = getProcessSnapshot();
1414
+ writePidFile(this.pidFilePath, this.preRunSnapshot);
1415
+
1378
1416
  this.logger.info('=== Pipeline Runner Started ===', 'PipelineRunner');
1379
1417
  this.logger.info(`Entry stage: ${this.pipeline.entry}`, 'PipelineRunner');
1380
1418
  this.logger.info(`Max steps: ${maxSteps}`, 'PipelineRunner');
@@ -1406,10 +1444,6 @@ class PipelineRunner {
1406
1444
  } else {
1407
1445
  this.currentExecutor = new StageExecutor(this.config, this.context, this.counters, {}, this.fileGuard, this.logger, this.projectRoot);
1408
1446
  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
1447
  this.currentExecutor = null;
1414
1448
  }
1415
1449
 
@@ -1465,6 +1499,9 @@ class PipelineRunner {
1465
1499
  // Записываем итоговый summary
1466
1500
  this.logger.writeSummary();
1467
1501
 
1502
+ // Cleanup: убиваем процессы-сироты, порождённые агентами во время пайплайна
1503
+ this._cleanupOrphans();
1504
+
1468
1505
  return {
1469
1506
  steps: this.stepCount,
1470
1507
  tasksExecuted: this.tasksExecuted,
@@ -1591,29 +1628,47 @@ class PipelineRunner {
1591
1628
  /**
1592
1629
  * Настройка graceful shutdown
1593
1630
  */
1631
+ /**
1632
+ * Snapshot-based cleanup: убивает процессы, появившиеся после старта пайплайна.
1633
+ * Сравнивает текущие PID'ы node/python/cmd с снапшотом до run() —
1634
+ * разница = процессы порождённые агентами (включая MCP-серверы-сироты).
1635
+ */
1636
+ _cleanupOrphans() {
1637
+ if (this.preRunSnapshot.size === 0) return;
1638
+ const currentPids = getProcessSnapshot();
1639
+ const orphans = [];
1640
+ for (const pid of currentPids) {
1641
+ if (!this.preRunSnapshot.has(pid) && pid !== process.pid) {
1642
+ orphans.push(pid);
1643
+ }
1644
+ }
1645
+ if (orphans.length > 0) {
1646
+ killPids(orphans, this.logger);
1647
+ }
1648
+ // PID-файл больше не нужен — cleanup выполнен
1649
+ removePidFile(this.pidFilePath);
1650
+ }
1651
+
1594
1652
  setupGracefulShutdown() {
1595
1653
  const shutdown = (signal) => {
1596
1654
  if (this.logger) {
1597
1655
  this.logger.info(`Received ${signal}. Killing child processes and shutting down...`, 'PipelineRunner');
1598
1656
  }
1599
1657
  this.running = false;
1600
- // Убиваем все активные дочерние процессы (включая внуков на Windows)
1658
+ // Убиваем активные дочерние процессы текущего stage
1601
1659
  if (this.currentExecutor) {
1602
1660
  this.currentExecutor.killAllChildren();
1603
1661
  }
1604
- // Убиваем сирот от предыдущих stage'ей
1605
- for (const pid of this.allTrackedPids) {
1606
- killPid(pid);
1607
- }
1608
- this.allTrackedPids.clear();
1662
+ // Snapshot-based: убиваем всех сирот от всех stage'ей
1663
+ this._cleanupOrphans();
1609
1664
  };
1610
1665
 
1611
1666
  process.on('SIGINT', () => shutdown('SIGINT'));
1612
1667
  process.on('SIGTERM', () => shutdown('SIGTERM'));
1613
1668
 
1614
- // Финальный cleanup при выходе процесса синхронный, последний шанс убить детей и сирот
1669
+ // Финальный синхронный cleanup при выходе — последний шанс убить сирот
1615
1670
  process.on('exit', () => {
1616
- // Убиваем активные child objects (если shutdown во время работы stage)
1671
+ // Убиваем активные child objects (если exit во время работы stage)
1617
1672
  if (this.currentExecutor) {
1618
1673
  for (const child of this.currentExecutor.activeChildren) {
1619
1674
  if (child.pid) {
@@ -1624,22 +1679,9 @@ class PipelineRunner {
1624
1679
  }
1625
1680
  }
1626
1681
  }
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
1682
  }
1683
+ // Snapshot-based cleanup
1684
+ this._cleanupOrphans();
1643
1685
  });
1644
1686
  }
1645
1687
  }