workflow-ai 1.0.53 → 1.0.55
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.
- package/README.md +44 -14
- package/configs/config.yaml +1 -0
- package/configs/pipeline.yaml +31 -1
- package/package.json +1 -1
- package/src/runner.mjs +22 -234
package/README.md
CHANGED
|
@@ -52,6 +52,7 @@ The `workflow init` command creates the `.workflow/` directory structure:
|
|
|
52
52
|
│ └── ticket-movement-rules.yaml
|
|
53
53
|
├── plans/
|
|
54
54
|
│ ├── current/ # Current development plans
|
|
55
|
+
│ ├── templates/ # Plan templates with triggers (recurring plans)
|
|
55
56
|
│ └── archive/ # Archived plans
|
|
56
57
|
├── tickets/
|
|
57
58
|
│ ├── backlog/ # Awaiting conditions
|
|
@@ -74,20 +75,21 @@ The `workflow init` command creates the `.workflow/` directory structure:
|
|
|
74
75
|
The `workflow run` command executes a multi-stage pipeline:
|
|
75
76
|
|
|
76
77
|
1. **pick-first-task** — select ticket from ready queue
|
|
77
|
-
2. **check-plan-
|
|
78
|
-
3. **
|
|
79
|
-
4. **
|
|
80
|
-
5. **
|
|
81
|
-
6. **
|
|
82
|
-
7. **
|
|
83
|
-
8. **
|
|
84
|
-
9. **
|
|
85
|
-
10. **
|
|
86
|
-
11. **review
|
|
87
|
-
12. **
|
|
88
|
-
13. **
|
|
89
|
-
14. **
|
|
90
|
-
15. **
|
|
78
|
+
2. **check-plan-templates** — evaluate plan template triggers, create plans if fired
|
|
79
|
+
3. **check-plan-decomposition** — verify plan is decomposed into tickets
|
|
80
|
+
4. **decompose-plan** — break down plan into tickets (if needed)
|
|
81
|
+
5. **check-conditions** — validate ticket readiness conditions
|
|
82
|
+
6. **move-to-ready** — move tickets from backlog to ready
|
|
83
|
+
7. **pick-next-task** — select next ticket for execution
|
|
84
|
+
8. **move-to-in-progress** — start execution
|
|
85
|
+
9. **check-relevance** — verify ticket is still relevant
|
|
86
|
+
10. **execute-task** — perform the work via AI agent
|
|
87
|
+
11. **move-to-review** — submit for review
|
|
88
|
+
12. **review-result** — validate results against Definition of Done
|
|
89
|
+
13. **increment-task-attempts** — track retry attempts
|
|
90
|
+
14. **move-ticket** — move to done/blocked based on review
|
|
91
|
+
15. **create-report** — generate execution report
|
|
92
|
+
16. **analyze-report / decompose-gaps** — analyze results and iterate
|
|
91
93
|
|
|
92
94
|
### Supported Agents
|
|
93
95
|
|
|
@@ -121,6 +123,34 @@ Skills are stored globally in `~/.workflow/skills/` and symlinked into projects.
|
|
|
121
123
|
|
|
122
124
|
Use `workflow eject <skill>` to copy a skill into the project for customization.
|
|
123
125
|
|
|
126
|
+
## Plan Templates
|
|
127
|
+
|
|
128
|
+
Plan templates allow recurring plans to be created automatically. Templates live in `.workflow/plans/templates/` and contain trigger conditions in their frontmatter.
|
|
129
|
+
|
|
130
|
+
### Template Format
|
|
131
|
+
|
|
132
|
+
```yaml
|
|
133
|
+
id: "TMPL-001"
|
|
134
|
+
title: "Daily manual testing"
|
|
135
|
+
type: template
|
|
136
|
+
trigger:
|
|
137
|
+
type: daily # daily | weekly | date_after | interval_days
|
|
138
|
+
params: {} # type-specific params
|
|
139
|
+
last_triggered: "" # auto-updated on trigger
|
|
140
|
+
enabled: true
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
### Trigger Types
|
|
144
|
+
|
|
145
|
+
| Type | Params | Description |
|
|
146
|
+
|------|--------|-------------|
|
|
147
|
+
| `daily` | — | Once per day |
|
|
148
|
+
| `weekly` | `days_of_week: [1,3,5]` (0=Sun) | On specific weekdays |
|
|
149
|
+
| `date_after` | `date: "2026-04-01"` | Once after a specific date |
|
|
150
|
+
| `interval_days` | `days: 3` | Every N days |
|
|
151
|
+
|
|
152
|
+
When a trigger fires, the pipeline creates a plan in `plans/current/` with status `approved`, then the normal decomposition flow proceeds.
|
|
153
|
+
|
|
124
154
|
## Task Types
|
|
125
155
|
|
|
126
156
|
| Type | Prefix | Description |
|
package/configs/config.yaml
CHANGED
package/configs/pipeline.yaml
CHANGED
|
@@ -121,6 +121,12 @@ pipeline:
|
|
|
121
121
|
workdir: "."
|
|
122
122
|
description: "Скрипт для перемещения завершённых тикетов из in-progress/ в review/"
|
|
123
123
|
|
|
124
|
+
script-check-templates:
|
|
125
|
+
command: "node"
|
|
126
|
+
args: [".workflow/src/scripts/check-plan-templates.js"]
|
|
127
|
+
workdir: "."
|
|
128
|
+
description: "Проверка и активация шаблонов планов по триггерам"
|
|
129
|
+
|
|
124
130
|
|
|
125
131
|
default_agent: qwen-code
|
|
126
132
|
|
|
@@ -185,9 +191,24 @@ pipeline:
|
|
|
185
191
|
stage: move-to-review
|
|
186
192
|
params:
|
|
187
193
|
ticket_id: "$result.ticket_id"
|
|
188
|
-
empty: check-plan-
|
|
194
|
+
empty: check-plan-templates
|
|
189
195
|
error: create-report
|
|
190
196
|
|
|
197
|
+
# -------------------------------------------------------------------------
|
|
198
|
+
# 0a. check-plan-templates
|
|
199
|
+
# Проверяет шаблоны планов в plans/templates/.
|
|
200
|
+
# Если триггер сработал — создаёт план в plans/current/ со статусом approved.
|
|
201
|
+
# Всегда переходит на check-plan-decomposition.
|
|
202
|
+
# -------------------------------------------------------------------------
|
|
203
|
+
check-plan-templates:
|
|
204
|
+
description: "Проверить шаблоны планов и создать планы по триггерам"
|
|
205
|
+
agent: script-check-templates
|
|
206
|
+
goto:
|
|
207
|
+
plan_created: check-plan-decomposition
|
|
208
|
+
no_triggers: check-plan-decomposition
|
|
209
|
+
default: check-plan-decomposition
|
|
210
|
+
error: check-plan-decomposition
|
|
211
|
+
|
|
191
212
|
# -------------------------------------------------------------------------
|
|
192
213
|
# 0b. check-plan-decomposition
|
|
193
214
|
# Если нет тикетов — проверяет, есть ли недекомпозированный план.
|
|
@@ -522,6 +543,15 @@ pipeline:
|
|
|
522
543
|
- ".workflow/plans/**" # Планы — только для чтения
|
|
523
544
|
- ".workflow/config/**" # Конфигурация
|
|
524
545
|
|
|
546
|
+
# ===========================================================================
|
|
547
|
+
# Доверенные агенты (trusted_agents)
|
|
548
|
+
# ===========================================================================
|
|
549
|
+
# Агенты, для которых FileGuard не откатывает изменения в protected_files.
|
|
550
|
+
# Поддерживает glob-паттерны: "script-*" соответствует "script-move" и т.д.
|
|
551
|
+
# ===========================================================================
|
|
552
|
+
trusted_agents:
|
|
553
|
+
- "script-check-templates" # Создаёт планы в plans/current/
|
|
554
|
+
|
|
525
555
|
# ===========================================================================
|
|
526
556
|
# Настройки выполнения (execution)
|
|
527
557
|
# ===========================================================================
|
package/package.json
CHANGED
package/src/runner.mjs
CHANGED
|
@@ -7,143 +7,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
|
-
// ============================================================================
|
|
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 гарантирован при следующем запуске
|
|
21
|
-
// ============================================================================
|
|
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();
|
|
30
|
-
try {
|
|
31
|
-
const output = execSync(
|
|
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 }
|
|
34
|
-
);
|
|
35
|
-
const pids = new Set();
|
|
36
|
-
for (const line of output.split('\n')) {
|
|
37
|
-
const match = line.match(/ProcessId=(\d+)/);
|
|
38
|
-
if (match) pids.add(parseInt(match[1], 10));
|
|
39
|
-
}
|
|
40
|
-
return pids;
|
|
41
|
-
} catch {
|
|
42
|
-
return new Set();
|
|
43
|
-
}
|
|
44
|
-
}
|
|
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
|
-
|
|
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 {}
|
|
144
|
-
}
|
|
145
|
-
}
|
|
146
|
-
|
|
147
10
|
// ============================================================================
|
|
148
11
|
// Logger — система логирования с уровнями DEBUG/INFO/WARN/ERROR
|
|
149
12
|
// ============================================================================
|
|
@@ -899,36 +762,21 @@ class StageExecutor {
|
|
|
899
762
|
this.promptBuilder = new PromptBuilder(context, counters, previousResults);
|
|
900
763
|
this.resultParser = new ResultParser();
|
|
901
764
|
|
|
902
|
-
//
|
|
903
|
-
this.
|
|
904
|
-
// Снапшот PID'ов перед spawn — для вычисления порождённых процессов
|
|
905
|
-
this.preSpawnSnapshot = new Set();
|
|
765
|
+
// Текущий дочерний процесс агента (для kill при shutdown)
|
|
766
|
+
this.currentChild = null;
|
|
906
767
|
}
|
|
907
768
|
|
|
908
769
|
/**
|
|
909
|
-
* Убивает
|
|
910
|
-
* Snapshot-based: сравнивает текущие PID'ы с снапшотом до spawn,
|
|
911
|
-
* новые процессы = порождённые нашим агентом (включая внуков/MCP-серверов).
|
|
770
|
+
* Убивает текущий дочерний процесс агента
|
|
912
771
|
*/
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
|
|
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);
|
|
772
|
+
killCurrentChild() {
|
|
773
|
+
const child = this.currentChild;
|
|
774
|
+
if (!child || !child.pid) return;
|
|
775
|
+
if (process.platform === 'win32') {
|
|
776
|
+
try { execSync(`taskkill /pid ${child.pid} /T /F`, { stdio: 'pipe' }); } catch {}
|
|
777
|
+
} else {
|
|
778
|
+
try { child.kill('SIGTERM'); } catch {}
|
|
930
779
|
}
|
|
931
|
-
this.preSpawnSnapshot = new Set();
|
|
932
780
|
}
|
|
933
781
|
|
|
934
782
|
/**
|
|
@@ -1066,19 +914,12 @@ class StageExecutor {
|
|
|
1066
914
|
}
|
|
1067
915
|
}
|
|
1068
916
|
|
|
1069
|
-
// Снапшот PID'ов до spawn — для snapshot-based cleanup сирот
|
|
1070
|
-
if (this.preSpawnSnapshot.size === 0) {
|
|
1071
|
-
this.preSpawnSnapshot = getProcessSnapshot();
|
|
1072
|
-
}
|
|
1073
|
-
|
|
1074
917
|
const child = spawn(agent.command, args, {
|
|
1075
918
|
cwd: path.resolve(this.projectRoot, agent.workdir || '.'),
|
|
1076
919
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1077
920
|
shell: useShell
|
|
1078
921
|
});
|
|
1079
|
-
|
|
1080
|
-
// Регистрируем дочерний процесс для cleanup
|
|
1081
|
-
this.activeChildren.add(child);
|
|
922
|
+
this.currentChild = child;
|
|
1082
923
|
|
|
1083
924
|
// Передаём промпт через stdin или закрываем если не нужно
|
|
1084
925
|
if (useStdin) {
|
|
@@ -1092,10 +933,15 @@ class StageExecutor {
|
|
|
1092
933
|
let stderr = '';
|
|
1093
934
|
let timedOut = false;
|
|
1094
935
|
|
|
1095
|
-
// Таймаут
|
|
936
|
+
// Таймаут
|
|
1096
937
|
const timeoutId = setTimeout(() => {
|
|
1097
938
|
timedOut = true;
|
|
1098
|
-
|
|
939
|
+
// На Windows SIGTERM игнорируется — используем taskkill /T /F для убийства дерева
|
|
940
|
+
if (process.platform === 'win32' && child.pid) {
|
|
941
|
+
try { execSync(`taskkill /pid ${child.pid} /T /F`, { stdio: 'pipe' }); } catch {}
|
|
942
|
+
} else {
|
|
943
|
+
child.kill('SIGTERM');
|
|
944
|
+
}
|
|
1099
945
|
if (this.logger) {
|
|
1100
946
|
this.logger.timeout(stageId, timeout);
|
|
1101
947
|
}
|
|
@@ -1144,8 +990,8 @@ class StageExecutor {
|
|
|
1144
990
|
});
|
|
1145
991
|
|
|
1146
992
|
child.on('close', (code) => {
|
|
993
|
+
this.currentChild = null;
|
|
1147
994
|
clearTimeout(timeoutId);
|
|
1148
|
-
this.activeChildren.delete(child);
|
|
1149
995
|
// Обрабатываем остаток буфера стриминга
|
|
1150
996
|
if (stdoutBuffer.trim()) {
|
|
1151
997
|
try {
|
|
@@ -1217,7 +1063,6 @@ class StageExecutor {
|
|
|
1217
1063
|
|
|
1218
1064
|
child.on('error', (err) => {
|
|
1219
1065
|
clearTimeout(timeoutId);
|
|
1220
|
-
this.activeChildren.delete(child);
|
|
1221
1066
|
if (!timedOut) {
|
|
1222
1067
|
if (this.logger) {
|
|
1223
1068
|
this.logger.error(`CLI error: ${err.message}`, stageId);
|
|
@@ -1333,13 +1178,7 @@ class PipelineRunner {
|
|
|
1333
1178
|
const trustedAgents = this.pipeline.trusted_agents || [];
|
|
1334
1179
|
this.fileGuard = new FileGuard(protectedPatterns, projectRoot, trustedAgents);
|
|
1335
1180
|
this.projectRoot = projectRoot;
|
|
1336
|
-
|
|
1337
|
-
// Текущий executor для доступа к activeChildren при shutdown
|
|
1338
1181
|
this.currentExecutor = null;
|
|
1339
|
-
// Путь к PID-файлу для cleanup сирот между запусками
|
|
1340
|
-
this.pidFilePath = getPidFilePath(projectRoot);
|
|
1341
|
-
// Снапшот PID'ов перед стартом пайплайна — для snapshot-based cleanup
|
|
1342
|
-
this.preRunSnapshot = new Set();
|
|
1343
1182
|
|
|
1344
1183
|
// Настройка graceful shutdown
|
|
1345
1184
|
this.setupGracefulShutdown();
|
|
@@ -1406,13 +1245,6 @@ class PipelineRunner {
|
|
|
1406
1245
|
const maxSteps = this.pipeline.execution?.max_steps || 100;
|
|
1407
1246
|
const delayBetweenStages = this.pipeline.execution?.delay_between_stages || 5;
|
|
1408
1247
|
|
|
1409
|
-
// Cleanup сирот от предыдущего запуска (если runner был убит жёстко)
|
|
1410
|
-
cleanupOrphansFromFile(this.pidFilePath, this.logger);
|
|
1411
|
-
|
|
1412
|
-
// Снапшот процессов до старта + запись PID-файла
|
|
1413
|
-
this.preRunSnapshot = getProcessSnapshot();
|
|
1414
|
-
writePidFile(this.pidFilePath, this.preRunSnapshot);
|
|
1415
|
-
|
|
1416
1248
|
this.logger.info('=== Pipeline Runner Started ===', 'PipelineRunner');
|
|
1417
1249
|
this.logger.info(`Entry stage: ${this.pipeline.entry}`, 'PipelineRunner');
|
|
1418
1250
|
this.logger.info(`Max steps: ${maxSteps}`, 'PipelineRunner');
|
|
@@ -1499,9 +1331,6 @@ class PipelineRunner {
|
|
|
1499
1331
|
// Записываем итоговый summary
|
|
1500
1332
|
this.logger.writeSummary();
|
|
1501
1333
|
|
|
1502
|
-
// Cleanup: убиваем процессы-сироты, порождённые агентами во время пайплайна
|
|
1503
|
-
this._cleanupOrphans();
|
|
1504
|
-
|
|
1505
1334
|
return {
|
|
1506
1335
|
steps: this.stepCount,
|
|
1507
1336
|
tasksExecuted: this.tasksExecuted,
|
|
@@ -1628,61 +1457,20 @@ class PipelineRunner {
|
|
|
1628
1457
|
/**
|
|
1629
1458
|
* Настройка graceful shutdown
|
|
1630
1459
|
*/
|
|
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
|
-
|
|
1652
1460
|
setupGracefulShutdown() {
|
|
1653
1461
|
const shutdown = (signal) => {
|
|
1654
1462
|
if (this.logger) {
|
|
1655
|
-
this.logger.info(`Received ${signal}.
|
|
1463
|
+
this.logger.info(`Received ${signal}. Shutting down gracefully...`, 'PipelineRunner');
|
|
1656
1464
|
}
|
|
1657
1465
|
this.running = false;
|
|
1658
|
-
// Убиваем
|
|
1466
|
+
// Убиваем текущего агента
|
|
1659
1467
|
if (this.currentExecutor) {
|
|
1660
|
-
this.currentExecutor.
|
|
1468
|
+
this.currentExecutor.killCurrentChild();
|
|
1661
1469
|
}
|
|
1662
|
-
// Snapshot-based: убиваем всех сирот от всех stage'ей
|
|
1663
|
-
this._cleanupOrphans();
|
|
1664
1470
|
};
|
|
1665
1471
|
|
|
1666
1472
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
1667
1473
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
1668
|
-
|
|
1669
|
-
// Финальный синхронный cleanup при выходе — последний шанс убить сирот
|
|
1670
|
-
process.on('exit', () => {
|
|
1671
|
-
// Убиваем активные child objects (если exit во время работы stage)
|
|
1672
|
-
if (this.currentExecutor) {
|
|
1673
|
-
for (const child of this.currentExecutor.activeChildren) {
|
|
1674
|
-
if (child.pid) {
|
|
1675
|
-
if (process.platform === 'win32') {
|
|
1676
|
-
try { execSync(`taskkill /pid ${child.pid} /T /F`, { stdio: 'pipe' }); } catch {}
|
|
1677
|
-
} else {
|
|
1678
|
-
try { child.kill('SIGKILL'); } catch {}
|
|
1679
|
-
}
|
|
1680
|
-
}
|
|
1681
|
-
}
|
|
1682
|
-
}
|
|
1683
|
-
// Snapshot-based cleanup
|
|
1684
|
-
this._cleanupOrphans();
|
|
1685
|
-
});
|
|
1686
1474
|
}
|
|
1687
1475
|
}
|
|
1688
1476
|
|