workflow-ai 1.0.54 → 1.0.56
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/pipeline.yaml +27 -8
- package/package.json +1 -1
- package/src/runner.mjs +36 -238
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/pipeline.yaml
CHANGED
|
@@ -144,6 +144,9 @@ pipeline:
|
|
|
144
144
|
# counter: <string> — имя счётчика попыток для этого stage
|
|
145
145
|
# agent_by_type: — маршрутизация агента по типу задачи (task_type)
|
|
146
146
|
# <type>: <agent-id> — например: impl: claude-sonnet, arch: claude-opus
|
|
147
|
+
# <type>: — или расширенный формат с fallback_model:
|
|
148
|
+
# agent: <agent-id>
|
|
149
|
+
# fallback_model: <agent-id>
|
|
147
150
|
# agent_by_attempt: — ротация агента по номеру попытки
|
|
148
151
|
# <N>: <agent-id> — например: 1: qwen-code, 2: claude-sonnet
|
|
149
152
|
# goto: — логика переходов по результату
|
|
@@ -348,14 +351,21 @@ pipeline:
|
|
|
348
351
|
skill: execute-task
|
|
349
352
|
counter: task_attempts
|
|
350
353
|
agent_by_type:
|
|
351
|
-
coach:
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
354
|
+
coach:
|
|
355
|
+
agent: claude-coach
|
|
356
|
+
fallback_model: claude-sonnet
|
|
357
|
+
qa:
|
|
358
|
+
agent: claude-qa
|
|
359
|
+
fallback_model: claude-sonnet
|
|
360
|
+
impl:
|
|
361
|
+
agent: kilo-minimax
|
|
362
|
+
fallback_model: kilo-glm
|
|
363
|
+
arch:
|
|
364
|
+
agent: claude-opus
|
|
365
|
+
fallback_model: claude-sonnet
|
|
366
|
+
admin:
|
|
367
|
+
agent: kilo-minimax
|
|
368
|
+
fallback_model: kilo-glm
|
|
359
369
|
agent_by_attempt:
|
|
360
370
|
2: qwen-code
|
|
361
371
|
3: claude-sonnet
|
|
@@ -543,6 +553,15 @@ pipeline:
|
|
|
543
553
|
- ".workflow/plans/**" # Планы — только для чтения
|
|
544
554
|
- ".workflow/config/**" # Конфигурация
|
|
545
555
|
|
|
556
|
+
# ===========================================================================
|
|
557
|
+
# Доверенные агенты (trusted_agents)
|
|
558
|
+
# ===========================================================================
|
|
559
|
+
# Агенты, для которых FileGuard не откатывает изменения в protected_files.
|
|
560
|
+
# Поддерживает glob-паттерны: "script-*" соответствует "script-move" и т.д.
|
|
561
|
+
# ===========================================================================
|
|
562
|
+
trusted_agents:
|
|
563
|
+
- "script-check-templates" # Создаёт планы в plans/current/
|
|
564
|
+
|
|
546
565
|
# ===========================================================================
|
|
547
566
|
# Настройки выполнения (execution)
|
|
548
567
|
# ===========================================================================
|
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
|
/**
|
|
@@ -948,6 +796,7 @@ class StageExecutor {
|
|
|
948
796
|
// 3. stage.agent — явно указанный агент stage
|
|
949
797
|
// 4. default_agent — глобальный дефолт
|
|
950
798
|
let agentId = stage.agent || this.pipeline.default_agent;
|
|
799
|
+
let fallbackModelId = stage.fallback_agent; // fallback_model из agent_by_type имеет приоритет
|
|
951
800
|
const attempt = (stage.counter && this.counters[stage.counter]) || 0;
|
|
952
801
|
|
|
953
802
|
// Фоллбэк: если task_type не задан, вычисляем из префикса ticket_id (PMA-005 → pma)
|
|
@@ -957,8 +806,17 @@ class StageExecutor {
|
|
|
957
806
|
|
|
958
807
|
if (attempt <= 1 && stage.agent_by_type && taskType) {
|
|
959
808
|
// Первая попытка: выбор по типу задачи
|
|
960
|
-
|
|
961
|
-
|
|
809
|
+
const agentConfig = stage.agent_by_type[taskType];
|
|
810
|
+
if (agentConfig) {
|
|
811
|
+
// Поддержка формата: { agent: string, fallback_model: string } или просто string
|
|
812
|
+
if (typeof agentConfig === 'object' && agentConfig.agent) {
|
|
813
|
+
agentId = agentConfig.agent;
|
|
814
|
+
if (agentConfig.fallback_model) {
|
|
815
|
+
fallbackModelId = agentConfig.fallback_model;
|
|
816
|
+
}
|
|
817
|
+
} else {
|
|
818
|
+
agentId = agentConfig;
|
|
819
|
+
}
|
|
962
820
|
if (this.logger) {
|
|
963
821
|
this.logger.info(`Agent by type: task_type="${taskType}" → ${agentId}`, stageId);
|
|
964
822
|
}
|
|
@@ -996,8 +854,8 @@ class StageExecutor {
|
|
|
996
854
|
this.fileGuard.takeSnapshot();
|
|
997
855
|
}
|
|
998
856
|
|
|
999
|
-
// Вызываем CLI-агента с поддержкой fallback
|
|
1000
|
-
const result = await this.callAgentWithFallback(agent, prompt, stageId, stage.skill,
|
|
857
|
+
// Вызываем CLI-агента с поддержкой fallback (приоритет: fallback_model из agent_by_type > stage.fallback_agent)
|
|
858
|
+
const result = await this.callAgentWithFallback(agent, prompt, stageId, stage.skill, fallbackModelId);
|
|
1001
859
|
|
|
1002
860
|
// Логгируем завершение stage
|
|
1003
861
|
if (this.logger) {
|
|
@@ -1066,19 +924,12 @@ class StageExecutor {
|
|
|
1066
924
|
}
|
|
1067
925
|
}
|
|
1068
926
|
|
|
1069
|
-
// Снапшот PID'ов до spawn — для snapshot-based cleanup сирот
|
|
1070
|
-
if (this.preSpawnSnapshot.size === 0) {
|
|
1071
|
-
this.preSpawnSnapshot = getProcessSnapshot();
|
|
1072
|
-
}
|
|
1073
|
-
|
|
1074
927
|
const child = spawn(agent.command, args, {
|
|
1075
928
|
cwd: path.resolve(this.projectRoot, agent.workdir || '.'),
|
|
1076
929
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
1077
930
|
shell: useShell
|
|
1078
931
|
});
|
|
1079
|
-
|
|
1080
|
-
// Регистрируем дочерний процесс для cleanup
|
|
1081
|
-
this.activeChildren.add(child);
|
|
932
|
+
this.currentChild = child;
|
|
1082
933
|
|
|
1083
934
|
// Передаём промпт через stdin или закрываем если не нужно
|
|
1084
935
|
if (useStdin) {
|
|
@@ -1092,10 +943,15 @@ class StageExecutor {
|
|
|
1092
943
|
let stderr = '';
|
|
1093
944
|
let timedOut = false;
|
|
1094
945
|
|
|
1095
|
-
// Таймаут
|
|
946
|
+
// Таймаут
|
|
1096
947
|
const timeoutId = setTimeout(() => {
|
|
1097
948
|
timedOut = true;
|
|
1098
|
-
|
|
949
|
+
// На Windows SIGTERM игнорируется — используем taskkill /T /F для убийства дерева
|
|
950
|
+
if (process.platform === 'win32' && child.pid) {
|
|
951
|
+
try { execSync(`taskkill /pid ${child.pid} /T /F`, { stdio: 'pipe' }); } catch {}
|
|
952
|
+
} else {
|
|
953
|
+
child.kill('SIGTERM');
|
|
954
|
+
}
|
|
1099
955
|
if (this.logger) {
|
|
1100
956
|
this.logger.timeout(stageId, timeout);
|
|
1101
957
|
}
|
|
@@ -1144,8 +1000,8 @@ class StageExecutor {
|
|
|
1144
1000
|
});
|
|
1145
1001
|
|
|
1146
1002
|
child.on('close', (code) => {
|
|
1003
|
+
this.currentChild = null;
|
|
1147
1004
|
clearTimeout(timeoutId);
|
|
1148
|
-
this.activeChildren.delete(child);
|
|
1149
1005
|
// Обрабатываем остаток буфера стриминга
|
|
1150
1006
|
if (stdoutBuffer.trim()) {
|
|
1151
1007
|
try {
|
|
@@ -1217,7 +1073,6 @@ class StageExecutor {
|
|
|
1217
1073
|
|
|
1218
1074
|
child.on('error', (err) => {
|
|
1219
1075
|
clearTimeout(timeoutId);
|
|
1220
|
-
this.activeChildren.delete(child);
|
|
1221
1076
|
if (!timedOut) {
|
|
1222
1077
|
if (this.logger) {
|
|
1223
1078
|
this.logger.error(`CLI error: ${err.message}`, stageId);
|
|
@@ -1333,13 +1188,7 @@ class PipelineRunner {
|
|
|
1333
1188
|
const trustedAgents = this.pipeline.trusted_agents || [];
|
|
1334
1189
|
this.fileGuard = new FileGuard(protectedPatterns, projectRoot, trustedAgents);
|
|
1335
1190
|
this.projectRoot = projectRoot;
|
|
1336
|
-
|
|
1337
|
-
// Текущий executor для доступа к activeChildren при shutdown
|
|
1338
1191
|
this.currentExecutor = null;
|
|
1339
|
-
// Путь к PID-файлу для cleanup сирот между запусками
|
|
1340
|
-
this.pidFilePath = getPidFilePath(projectRoot);
|
|
1341
|
-
// Снапшот PID'ов перед стартом пайплайна — для snapshot-based cleanup
|
|
1342
|
-
this.preRunSnapshot = new Set();
|
|
1343
1192
|
|
|
1344
1193
|
// Настройка graceful shutdown
|
|
1345
1194
|
this.setupGracefulShutdown();
|
|
@@ -1406,13 +1255,6 @@ class PipelineRunner {
|
|
|
1406
1255
|
const maxSteps = this.pipeline.execution?.max_steps || 100;
|
|
1407
1256
|
const delayBetweenStages = this.pipeline.execution?.delay_between_stages || 5;
|
|
1408
1257
|
|
|
1409
|
-
// Cleanup сирот от предыдущего запуска (если runner был убит жёстко)
|
|
1410
|
-
cleanupOrphansFromFile(this.pidFilePath, this.logger);
|
|
1411
|
-
|
|
1412
|
-
// Снапшот процессов до старта + запись PID-файла
|
|
1413
|
-
this.preRunSnapshot = getProcessSnapshot();
|
|
1414
|
-
writePidFile(this.pidFilePath, this.preRunSnapshot);
|
|
1415
|
-
|
|
1416
1258
|
this.logger.info('=== Pipeline Runner Started ===', 'PipelineRunner');
|
|
1417
1259
|
this.logger.info(`Entry stage: ${this.pipeline.entry}`, 'PipelineRunner');
|
|
1418
1260
|
this.logger.info(`Max steps: ${maxSteps}`, 'PipelineRunner');
|
|
@@ -1499,9 +1341,6 @@ class PipelineRunner {
|
|
|
1499
1341
|
// Записываем итоговый summary
|
|
1500
1342
|
this.logger.writeSummary();
|
|
1501
1343
|
|
|
1502
|
-
// Cleanup: убиваем процессы-сироты, порождённые агентами во время пайплайна
|
|
1503
|
-
this._cleanupOrphans();
|
|
1504
|
-
|
|
1505
1344
|
return {
|
|
1506
1345
|
steps: this.stepCount,
|
|
1507
1346
|
tasksExecuted: this.tasksExecuted,
|
|
@@ -1628,61 +1467,20 @@ class PipelineRunner {
|
|
|
1628
1467
|
/**
|
|
1629
1468
|
* Настройка graceful shutdown
|
|
1630
1469
|
*/
|
|
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
1470
|
setupGracefulShutdown() {
|
|
1653
1471
|
const shutdown = (signal) => {
|
|
1654
1472
|
if (this.logger) {
|
|
1655
|
-
this.logger.info(`Received ${signal}.
|
|
1473
|
+
this.logger.info(`Received ${signal}. Shutting down gracefully...`, 'PipelineRunner');
|
|
1656
1474
|
}
|
|
1657
1475
|
this.running = false;
|
|
1658
|
-
// Убиваем
|
|
1476
|
+
// Убиваем текущего агента
|
|
1659
1477
|
if (this.currentExecutor) {
|
|
1660
|
-
this.currentExecutor.
|
|
1478
|
+
this.currentExecutor.killCurrentChild();
|
|
1661
1479
|
}
|
|
1662
|
-
// Snapshot-based: убиваем всех сирот от всех stage'ей
|
|
1663
|
-
this._cleanupOrphans();
|
|
1664
1480
|
};
|
|
1665
1481
|
|
|
1666
1482
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
1667
1483
|
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
1484
|
}
|
|
1687
1485
|
}
|
|
1688
1486
|
|