workflow-ai 1.0.52 → 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 +97 -8
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "workflow-ai",
3
- "version": "1.0.52",
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
@@ -8,15 +8,23 @@ import yaml from './lib/js-yaml.mjs';
8
8
  import { findProjectRoot } from './lib/find-root.mjs';
9
9
 
10
10
  // ============================================================================
11
- // ProcessTracker — snapshot-based process cleanup для Windows.
11
+ // ProcessTracker — файловый трекинг дочерних процессов.
12
12
  //
13
13
  // Проблема: runner spawn → claude CLI → npx → node (MCP-сервер).
14
- // При завершении claude CLI, MCP-серверы становятся сиротами.
15
- // taskkill /T не находит их, wmic parent-tree разорвано.
14
+ // При жёстком завершении runner'а (kill, закрытие терминала) process.on('exit')
15
+ // не срабатывает, MCP-серверы остаются сиротами навсегда.
16
16
  //
17
- // Решение: снимаем снапшот PID'ов ДО spawn, при cleanup берём текущие PID'ы,
18
- // разница = процессы порождённые нашим агентом (включая внуков и сирот).
17
+ // Решение: PID-файл (.workflow/logs/.runner-pids).
18
+ // - Перед spawn: записываем снапшот существующих PID'ов в файл
19
+ // - При старте runner'а: читаем файл, вычисляем сирот (текущие − снапшот), убиваем
20
+ // - Файл переживает крэш/kill — cleanup гарантирован при следующем запуске
19
21
  // ============================================================================
22
+ const PIDFILE_NAME = '.runner-pids';
23
+
24
+ function getPidFilePath(projectRoot) {
25
+ return path.resolve(projectRoot, '.workflow/logs', PIDFILE_NAME);
26
+ }
27
+
20
28
  function getProcessSnapshot() {
21
29
  if (process.platform !== 'win32') return new Set();
22
30
  try {
@@ -35,6 +43,80 @@ function getProcessSnapshot() {
35
43
  }
36
44
  }
37
45
 
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
+ }
61
+
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
+ }
77
+
78
+ function removePidFile(pidFilePath) {
79
+ try { fs.unlinkSync(pidFilePath); } catch { /* ok */ }
80
+ }
81
+
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);
101
+ }
102
+ }
103
+
104
+ if (orphans.length > 0) {
105
+ killPids(orphans, logger);
106
+ }
107
+
108
+ removePidFile(pidFilePath);
109
+ }
110
+
111
+ function isProcessAlive(pid) {
112
+ try {
113
+ process.kill(pid, 0);
114
+ return true;
115
+ } catch {
116
+ return false;
117
+ }
118
+ }
119
+
38
120
  function killPids(pids, logger = null) {
39
121
  if (pids.length === 0) return;
40
122
  if (process.platform === 'win32') {
@@ -48,7 +130,7 @@ function killPids(pids, logger = null) {
48
130
  try { process.kill(pid, 'SIGKILL'); } catch { /* already dead */ }
49
131
  }
50
132
  }
51
- if (logger && pids.length > 0) {
133
+ if (logger) {
52
134
  logger.info(`Cleaned up ${pids.length} orphan process(es): ${pids.join(', ')}`, 'ProcessCleanup');
53
135
  }
54
136
  }
@@ -56,7 +138,6 @@ function killPids(pids, logger = null) {
56
138
  function killChildProcess(child) {
57
139
  if (!child.pid) return;
58
140
  if (process.platform === 'win32') {
59
- // taskkill /T /F — лучший effort для прямого дерева
60
141
  try { execSync(`taskkill /pid ${child.pid} /T /F`, { stdio: 'pipe' }); } catch {}
61
142
  } else {
62
143
  try { child.kill('SIGTERM'); } catch {}
@@ -1255,6 +1336,8 @@ class PipelineRunner {
1255
1336
 
1256
1337
  // Текущий executor для доступа к activeChildren при shutdown
1257
1338
  this.currentExecutor = null;
1339
+ // Путь к PID-файлу для cleanup сирот между запусками
1340
+ this.pidFilePath = getPidFilePath(projectRoot);
1258
1341
  // Снапшот PID'ов перед стартом пайплайна — для snapshot-based cleanup
1259
1342
  this.preRunSnapshot = new Set();
1260
1343
 
@@ -1323,8 +1406,12 @@ class PipelineRunner {
1323
1406
  const maxSteps = this.pipeline.execution?.max_steps || 100;
1324
1407
  const delayBetweenStages = this.pipeline.execution?.delay_between_stages || 5;
1325
1408
 
1326
- // Снапшот процессов до старта для snapshot-based cleanup при выходе
1409
+ // Cleanup сирот от предыдущего запуска (если runner был убит жёстко)
1410
+ cleanupOrphansFromFile(this.pidFilePath, this.logger);
1411
+
1412
+ // Снапшот процессов до старта + запись PID-файла
1327
1413
  this.preRunSnapshot = getProcessSnapshot();
1414
+ writePidFile(this.pidFilePath, this.preRunSnapshot);
1328
1415
 
1329
1416
  this.logger.info('=== Pipeline Runner Started ===', 'PipelineRunner');
1330
1417
  this.logger.info(`Entry stage: ${this.pipeline.entry}`, 'PipelineRunner');
@@ -1558,6 +1645,8 @@ class PipelineRunner {
1558
1645
  if (orphans.length > 0) {
1559
1646
  killPids(orphans, this.logger);
1560
1647
  }
1648
+ // PID-файл больше не нужен — cleanup выполнен
1649
+ removePidFile(this.pidFilePath);
1561
1650
  }
1562
1651
 
1563
1652
  setupGracefulShutdown() {