workflow-ai 1.0.49 → 1.0.51
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/configs/pipeline.yaml +4 -4
- package/package.json +1 -1
- package/src/runner.mjs +215 -8
- package/templates/plan-template.md +1 -1
package/configs/pipeline.yaml
CHANGED
|
@@ -33,25 +33,25 @@ pipeline:
|
|
|
33
33
|
agents:
|
|
34
34
|
claude-sonnet:
|
|
35
35
|
command: "claude"
|
|
36
|
-
args: ["--model", "claude-sonnet-4-6", "--permission-mode", "bypassPermissions
|
|
36
|
+
args: ["--model", "claude-sonnet-4-6", "--permission-mode", "bypassPermissions", "-p"]
|
|
37
37
|
workdir: "."
|
|
38
38
|
description: "Claude Sonnet — быстрая модель для простых задач"
|
|
39
39
|
|
|
40
40
|
claude-opus:
|
|
41
41
|
command: "claude"
|
|
42
|
-
args: ["--model", "claude-opus-4-6", "--permission-mode", "bypassPermissions
|
|
42
|
+
args: ["--model", "claude-opus-4-6", "--permission-mode", "bypassPermissions", "-p"]
|
|
43
43
|
workdir: "."
|
|
44
44
|
description: "Claude Opus — мощная модель для сложных задач"
|
|
45
45
|
|
|
46
46
|
claude-coach:
|
|
47
47
|
command: "claude"
|
|
48
|
-
args: ["--model", "claude-opus-4-6", "--permission-mode", "bypassPermissions
|
|
48
|
+
args: ["--model", "claude-opus-4-6", "--permission-mode", "bypassPermissions", "-p", "coach"]
|
|
49
49
|
workdir: "."
|
|
50
50
|
description: "Claude Opus — coach"
|
|
51
51
|
|
|
52
52
|
claude-qa:
|
|
53
53
|
command: "claude"
|
|
54
|
-
args: ["--model", "claude-sonnet-4-6", "--permission-mode", "bypassPermissions
|
|
54
|
+
args: ["--model", "claude-sonnet-4-6", "--permission-mode", "bypassPermissions", "-p", "manual-testing"]
|
|
55
55
|
workdir: "."
|
|
56
56
|
description: "Claude Opus — QA"
|
|
57
57
|
|
package/package.json
CHANGED
package/src/runner.mjs
CHANGED
|
@@ -2,11 +2,95 @@
|
|
|
2
2
|
|
|
3
3
|
import fs from 'fs';
|
|
4
4
|
import path from 'path';
|
|
5
|
-
import { spawn, execSync } from 'child_process';
|
|
5
|
+
import { spawn, execSync, exec } 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
|
+
// ============================================================================
|
|
11
|
+
// killProcessTree — убивает процесс и всех его потомков рекурсивно.
|
|
12
|
+
// На Windows child.kill() не убивает дочерние процессы (npx → node → python).
|
|
13
|
+
// taskkill /T /F не находит сирот (чей parent уже завершился).
|
|
14
|
+
// Поэтому: сначала собираем всё дерево PID через wmic, потом убиваем снизу вверх.
|
|
15
|
+
// ============================================================================
|
|
16
|
+
function getDescendantPids(pid) {
|
|
17
|
+
if (process.platform !== 'win32') return [];
|
|
18
|
+
try {
|
|
19
|
+
const output = execSync(
|
|
20
|
+
`wmic process where (ParentProcessId=${pid}) get ProcessId /FORMAT:LIST`,
|
|
21
|
+
{ stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf-8' }
|
|
22
|
+
);
|
|
23
|
+
const pids = [];
|
|
24
|
+
for (const line of output.split('\n')) {
|
|
25
|
+
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
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return pids;
|
|
34
|
+
} catch {
|
|
35
|
+
return [];
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function killProcessTree(child, logger = null) {
|
|
40
|
+
const pid = child.pid;
|
|
41
|
+
if (!pid) return;
|
|
42
|
+
|
|
43
|
+
if (process.platform === 'win32') {
|
|
44
|
+
// Собираем всех потомков ДО убийства (иначе дерево разорвётся)
|
|
45
|
+
const allPids = [...getDescendantPids(pid), pid];
|
|
46
|
+
|
|
47
|
+
// Убиваем снизу вверх (сначала внуки, потом дети, потом root)
|
|
48
|
+
for (const p of allPids) {
|
|
49
|
+
try {
|
|
50
|
+
execSync(`taskkill /pid ${p} /F`, { stdio: 'pipe' });
|
|
51
|
+
} catch {
|
|
52
|
+
// Процесс уже завершился
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
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;
|
|
65
|
+
}
|
|
66
|
+
|
|
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);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// killPid — убивает один PID (для cleanup сирот)
|
|
82
|
+
function killPid(pid) {
|
|
83
|
+
try {
|
|
84
|
+
if (process.platform === 'win32') {
|
|
85
|
+
execSync(`taskkill /pid ${pid} /F`, { stdio: 'pipe' });
|
|
86
|
+
} else {
|
|
87
|
+
process.kill(pid, 'SIGKILL');
|
|
88
|
+
}
|
|
89
|
+
} catch {
|
|
90
|
+
// уже завершился
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
10
94
|
// ============================================================================
|
|
11
95
|
// Logger — система логирования с уровнями DEBUG/INFO/WARN/ERROR
|
|
12
96
|
// ============================================================================
|
|
@@ -761,6 +845,62 @@ class StageExecutor {
|
|
|
761
845
|
// Инициализируем билдер и парсер
|
|
762
846
|
this.promptBuilder = new PromptBuilder(context, counters, previousResults);
|
|
763
847
|
this.resultParser = new ResultParser();
|
|
848
|
+
|
|
849
|
+
// Трекинг активных дочерних процессов для cleanup
|
|
850
|
+
this.activeChildren = new Set();
|
|
851
|
+
// Все PID'ы потомков (включая внуков), собранные при жизни процесса
|
|
852
|
+
this.trackedPids = new Set();
|
|
853
|
+
// Интервал для периодического сбора PID-дерева
|
|
854
|
+
this._pidScanInterval = null;
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
/**
|
|
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'ы потомков
|
|
888
|
+
*/
|
|
889
|
+
killAllChildren() {
|
|
890
|
+
this._stopPidTracking();
|
|
891
|
+
|
|
892
|
+
// Сначала убиваем известные child objects через killProcessTree
|
|
893
|
+
for (const child of this.activeChildren) {
|
|
894
|
+
killProcessTree(child, this.logger);
|
|
895
|
+
if (child.pid) this.trackedPids.delete(child.pid);
|
|
896
|
+
}
|
|
897
|
+
this.activeChildren.clear();
|
|
898
|
+
|
|
899
|
+
// Затем убиваем все ранее собранные PID'ы-сироты которые могли остаться
|
|
900
|
+
for (const pid of this.trackedPids) {
|
|
901
|
+
killPid(pid);
|
|
902
|
+
}
|
|
903
|
+
this.trackedPids.clear();
|
|
764
904
|
}
|
|
765
905
|
|
|
766
906
|
/**
|
|
@@ -904,6 +1044,11 @@ class StageExecutor {
|
|
|
904
1044
|
shell: useShell
|
|
905
1045
|
});
|
|
906
1046
|
|
|
1047
|
+
// Регистрируем дочерний процесс для cleanup
|
|
1048
|
+
this.activeChildren.add(child);
|
|
1049
|
+
if (child.pid) this.trackedPids.add(child.pid);
|
|
1050
|
+
this._startPidTracking();
|
|
1051
|
+
|
|
907
1052
|
// Передаём промпт через stdin или закрываем если не нужно
|
|
908
1053
|
if (useStdin) {
|
|
909
1054
|
child.stdin.write(finalPrompt);
|
|
@@ -916,10 +1061,11 @@ class StageExecutor {
|
|
|
916
1061
|
let stderr = '';
|
|
917
1062
|
let timedOut = false;
|
|
918
1063
|
|
|
919
|
-
// Таймаут
|
|
1064
|
+
// Таймаут — убиваем всё дерево процессов (на Windows child.kill не убивает внуков)
|
|
920
1065
|
const timeoutId = setTimeout(() => {
|
|
921
1066
|
timedOut = true;
|
|
922
|
-
child.
|
|
1067
|
+
killProcessTree(child, this.logger);
|
|
1068
|
+
this.activeChildren.delete(child);
|
|
923
1069
|
if (this.logger) {
|
|
924
1070
|
this.logger.timeout(stageId, timeout);
|
|
925
1071
|
}
|
|
@@ -969,6 +1115,8 @@ class StageExecutor {
|
|
|
969
1115
|
|
|
970
1116
|
child.on('close', (code) => {
|
|
971
1117
|
clearTimeout(timeoutId);
|
|
1118
|
+
this.activeChildren.delete(child);
|
|
1119
|
+
if (this.activeChildren.size === 0) this._stopPidTracking();
|
|
972
1120
|
// Обрабатываем остаток буфера стриминга
|
|
973
1121
|
if (stdoutBuffer.trim()) {
|
|
974
1122
|
try {
|
|
@@ -1002,8 +1150,16 @@ class StageExecutor {
|
|
|
1002
1150
|
// Парсим результат из вывода агента через ResultParser
|
|
1003
1151
|
const result = this.resultParser.parse(stdout, stageId);
|
|
1004
1152
|
|
|
1005
|
-
// Если exit code ≠ 0
|
|
1006
|
-
if (code !== 0) {
|
|
1153
|
+
// Если exit code ≠ 0, но результат уже распарсен — используем его
|
|
1154
|
+
if (code !== 0 && result.parsed && result.status && result.status !== 'default') {
|
|
1155
|
+
if (this.logger) {
|
|
1156
|
+
this.logger.warn(
|
|
1157
|
+
`Agent exited with code ${code}, but RESULT was parsed (status: ${result.status}). Using parsed result.`,
|
|
1158
|
+
stageId
|
|
1159
|
+
);
|
|
1160
|
+
}
|
|
1161
|
+
// Проваливаемся в resolve ниже
|
|
1162
|
+
} else if (code !== 0) {
|
|
1007
1163
|
const err = new Error(`Agent exited with code ${code}`);
|
|
1008
1164
|
err.code = 'NON_ZERO_EXIT';
|
|
1009
1165
|
err.exitCode = code;
|
|
@@ -1032,6 +1188,7 @@ class StageExecutor {
|
|
|
1032
1188
|
|
|
1033
1189
|
child.on('error', (err) => {
|
|
1034
1190
|
clearTimeout(timeoutId);
|
|
1191
|
+
this.activeChildren.delete(child);
|
|
1035
1192
|
if (!timedOut) {
|
|
1036
1193
|
if (this.logger) {
|
|
1037
1194
|
this.logger.error(`CLI error: ${err.message}`, stageId);
|
|
@@ -1148,6 +1305,11 @@ class PipelineRunner {
|
|
|
1148
1305
|
this.fileGuard = new FileGuard(protectedPatterns, projectRoot, trustedAgents);
|
|
1149
1306
|
this.projectRoot = projectRoot;
|
|
1150
1307
|
|
|
1308
|
+
// Текущий executor для доступа к activeChildren при shutdown
|
|
1309
|
+
this.currentExecutor = null;
|
|
1310
|
+
// Все PID'ы потомков всех executor'ов — для cleanup сирот при выходе
|
|
1311
|
+
this.allTrackedPids = new Set();
|
|
1312
|
+
|
|
1151
1313
|
// Настройка graceful shutdown
|
|
1152
1314
|
this.setupGracefulShutdown();
|
|
1153
1315
|
}
|
|
@@ -1242,8 +1404,13 @@ class PipelineRunner {
|
|
|
1242
1404
|
if (stage.type === 'update-counter') {
|
|
1243
1405
|
result = this.executeUpdateCounter(this.currentStage, stage);
|
|
1244
1406
|
} else {
|
|
1245
|
-
|
|
1246
|
-
result = await
|
|
1407
|
+
this.currentExecutor = new StageExecutor(this.config, this.context, this.counters, {}, this.fileGuard, this.logger, this.projectRoot);
|
|
1408
|
+
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
|
+
this.currentExecutor = null;
|
|
1247
1414
|
}
|
|
1248
1415
|
|
|
1249
1416
|
this.logger.info(`Stage ${this.currentStage} completed with status: ${result.status}`, 'PipelineRunner');
|
|
@@ -1427,13 +1594,53 @@ class PipelineRunner {
|
|
|
1427
1594
|
setupGracefulShutdown() {
|
|
1428
1595
|
const shutdown = (signal) => {
|
|
1429
1596
|
if (this.logger) {
|
|
1430
|
-
this.logger.info(`Received ${signal}.
|
|
1597
|
+
this.logger.info(`Received ${signal}. Killing child processes and shutting down...`, 'PipelineRunner');
|
|
1431
1598
|
}
|
|
1432
1599
|
this.running = false;
|
|
1600
|
+
// Убиваем все активные дочерние процессы (включая внуков на Windows)
|
|
1601
|
+
if (this.currentExecutor) {
|
|
1602
|
+
this.currentExecutor.killAllChildren();
|
|
1603
|
+
}
|
|
1604
|
+
// Убиваем сирот от предыдущих stage'ей
|
|
1605
|
+
for (const pid of this.allTrackedPids) {
|
|
1606
|
+
killPid(pid);
|
|
1607
|
+
}
|
|
1608
|
+
this.allTrackedPids.clear();
|
|
1433
1609
|
};
|
|
1434
1610
|
|
|
1435
1611
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
1436
1612
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
1613
|
+
|
|
1614
|
+
// Финальный cleanup при выходе процесса — синхронный, последний шанс убить детей и сирот
|
|
1615
|
+
process.on('exit', () => {
|
|
1616
|
+
// Убиваем активные child objects (если shutdown во время работы stage)
|
|
1617
|
+
if (this.currentExecutor) {
|
|
1618
|
+
for (const child of this.currentExecutor.activeChildren) {
|
|
1619
|
+
if (child.pid) {
|
|
1620
|
+
if (process.platform === 'win32') {
|
|
1621
|
+
try { execSync(`taskkill /pid ${child.pid} /T /F`, { stdio: 'pipe' }); } catch {}
|
|
1622
|
+
} else {
|
|
1623
|
+
try { child.kill('SIGKILL'); } catch {}
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
}
|
|
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
|
+
}
|
|
1643
|
+
});
|
|
1437
1644
|
}
|
|
1438
1645
|
}
|
|
1439
1646
|
|