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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/runner.mjs +108 -36
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "workflow-ai",
3
- "version": "1.0.50",
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,46 +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 для уничтожения всего дерева.
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
+ // разница = процессы порождённые нашим агентом (включая внуков и сирот).
14
19
  // ============================================================================
15
- function killProcessTree(child, logger = null) {
16
- const pid = child.pid;
17
- if (!pid) return;
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
- try {
21
- execSync(`taskkill /pid ${pid} /T /F`, { stdio: 'pipe' });
22
- } catch {
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
- // Unix: отправляем SIGTERM, через 5 сек — SIGKILL если ещё жив
27
- try {
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
- setTimeout(() => {
34
- try {
35
- // Проверяем что процесс ещё жив (kill(0) не убивает, только проверяет)
36
- process.kill(pid, 0);
37
- child.kill('SIGKILL');
38
- if (logger) {
39
- logger.warn(`Process ${pid} did not exit after SIGTERM, sent SIGKILL`, 'ProcessCleanup');
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
- killProcessTree(child, this.logger);
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
- // Таймаут — убиваем всё дерево процессов (на Windows child.kill не убивает внуков)
1014
+ // Таймаут — убиваем child + snapshot-based cleanup через killAllChildren
974
1015
  const timeoutId = setTimeout(() => {
975
1016
  timedOut = true;
976
- killProcessTree(child, this.logger);
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
- // Убиваем все активные дочерние процессы (включая внуков на Windows)
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
- if (this.currentExecutor && this.currentExecutor.activeChildren.size > 0) {
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
  }