workflow-ai 1.0.51 → 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.
- package/package.json +1 -1
- package/src/runner.mjs +179 -137
package/package.json
CHANGED
package/src/runner.mjs
CHANGED
|
@@ -2,92 +2,145 @@
|
|
|
2
2
|
|
|
3
3
|
import fs from 'fs';
|
|
4
4
|
import path from 'path';
|
|
5
|
-
import { spawn, execSync
|
|
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
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
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 гарантирован при следующем запуске
|
|
15
21
|
// ============================================================================
|
|
16
|
-
|
|
17
|
-
|
|
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();
|
|
18
30
|
try {
|
|
19
31
|
const output = execSync(
|
|
20
|
-
|
|
21
|
-
{ stdio: ['pipe', 'pipe', 'pipe'], encoding: 'utf-8' }
|
|
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 }
|
|
22
34
|
);
|
|
23
|
-
const pids =
|
|
35
|
+
const pids = new Set();
|
|
24
36
|
for (const line of output.split('\n')) {
|
|
25
37
|
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
|
-
}
|
|
38
|
+
if (match) pids.add(parseInt(match[1], 10));
|
|
32
39
|
}
|
|
33
40
|
return pids;
|
|
34
41
|
} catch {
|
|
35
|
-
return
|
|
42
|
+
return new Set();
|
|
36
43
|
}
|
|
37
44
|
}
|
|
38
45
|
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
+
}
|
|
42
61
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
+
}
|
|
46
77
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
execSync(`taskkill /pid ${p} /F`, { stdio: 'pipe' });
|
|
51
|
-
} catch {
|
|
52
|
-
// Процесс уже завершился
|
|
53
|
-
}
|
|
54
|
-
}
|
|
78
|
+
function removePidFile(pidFilePath) {
|
|
79
|
+
try { fs.unlinkSync(pidFilePath); } catch { /* ok */ }
|
|
80
|
+
}
|
|
55
81
|
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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);
|
|
65
101
|
}
|
|
102
|
+
}
|
|
66
103
|
|
|
67
|
-
|
|
68
|
-
|
|
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);
|
|
104
|
+
if (orphans.length > 0) {
|
|
105
|
+
killPids(orphans, logger);
|
|
78
106
|
}
|
|
107
|
+
|
|
108
|
+
removePidFile(pidFilePath);
|
|
79
109
|
}
|
|
80
110
|
|
|
81
|
-
|
|
82
|
-
function killPid(pid) {
|
|
111
|
+
function isProcessAlive(pid) {
|
|
83
112
|
try {
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
} else {
|
|
87
|
-
process.kill(pid, 'SIGKILL');
|
|
88
|
-
}
|
|
113
|
+
process.kill(pid, 0);
|
|
114
|
+
return true;
|
|
89
115
|
} catch {
|
|
90
|
-
|
|
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 {}
|
|
91
144
|
}
|
|
92
145
|
}
|
|
93
146
|
|
|
@@ -848,59 +901,34 @@ class StageExecutor {
|
|
|
848
901
|
|
|
849
902
|
// Трекинг активных дочерних процессов для cleanup
|
|
850
903
|
this.activeChildren = new Set();
|
|
851
|
-
//
|
|
852
|
-
this.
|
|
853
|
-
// Интервал для периодического сбора PID-дерева
|
|
854
|
-
this._pidScanInterval = null;
|
|
904
|
+
// Снапшот PID'ов перед spawn — для вычисления порождённых процессов
|
|
905
|
+
this.preSpawnSnapshot = new Set();
|
|
855
906
|
}
|
|
856
907
|
|
|
857
908
|
/**
|
|
858
|
-
*
|
|
859
|
-
*
|
|
860
|
-
*
|
|
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'ы потомков
|
|
909
|
+
* Убивает все активные дочерние процессы и порождённых ими сирот.
|
|
910
|
+
* Snapshot-based: сравнивает текущие PID'ы с снапшотом до spawn,
|
|
911
|
+
* новые процессы = порождённые нашим агентом (включая внуков/MCP-серверов).
|
|
888
912
|
*/
|
|
889
913
|
killAllChildren() {
|
|
890
|
-
|
|
891
|
-
|
|
892
|
-
// Сначала убиваем известные child objects через killProcessTree
|
|
914
|
+
// Убиваем прямых детей через taskkill /T (best effort)
|
|
893
915
|
for (const child of this.activeChildren) {
|
|
894
|
-
|
|
895
|
-
if (child.pid) this.trackedPids.delete(child.pid);
|
|
916
|
+
killChildProcess(child);
|
|
896
917
|
}
|
|
897
918
|
this.activeChildren.clear();
|
|
898
919
|
|
|
899
|
-
//
|
|
900
|
-
|
|
901
|
-
|
|
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);
|
|
902
930
|
}
|
|
903
|
-
this.
|
|
931
|
+
this.preSpawnSnapshot = new Set();
|
|
904
932
|
}
|
|
905
933
|
|
|
906
934
|
/**
|
|
@@ -1038,6 +1066,11 @@ class StageExecutor {
|
|
|
1038
1066
|
}
|
|
1039
1067
|
}
|
|
1040
1068
|
|
|
1069
|
+
// Снапшот PID'ов до spawn — для snapshot-based cleanup сирот
|
|
1070
|
+
if (this.preSpawnSnapshot.size === 0) {
|
|
1071
|
+
this.preSpawnSnapshot = getProcessSnapshot();
|
|
1072
|
+
}
|
|
1073
|
+
|
|
1041
1074
|
const child = spawn(agent.command, args, {
|
|
1042
1075
|
cwd: path.resolve(this.projectRoot, agent.workdir || '.'),
|
|
1043
1076
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
@@ -1046,8 +1079,6 @@ class StageExecutor {
|
|
|
1046
1079
|
|
|
1047
1080
|
// Регистрируем дочерний процесс для cleanup
|
|
1048
1081
|
this.activeChildren.add(child);
|
|
1049
|
-
if (child.pid) this.trackedPids.add(child.pid);
|
|
1050
|
-
this._startPidTracking();
|
|
1051
1082
|
|
|
1052
1083
|
// Передаём промпт через stdin или закрываем если не нужно
|
|
1053
1084
|
if (useStdin) {
|
|
@@ -1061,11 +1092,10 @@ class StageExecutor {
|
|
|
1061
1092
|
let stderr = '';
|
|
1062
1093
|
let timedOut = false;
|
|
1063
1094
|
|
|
1064
|
-
// Таймаут — убиваем
|
|
1095
|
+
// Таймаут — убиваем child + snapshot-based cleanup через killAllChildren
|
|
1065
1096
|
const timeoutId = setTimeout(() => {
|
|
1066
1097
|
timedOut = true;
|
|
1067
|
-
|
|
1068
|
-
this.activeChildren.delete(child);
|
|
1098
|
+
this.killAllChildren(); // убьёт child + сирот по snapshot
|
|
1069
1099
|
if (this.logger) {
|
|
1070
1100
|
this.logger.timeout(stageId, timeout);
|
|
1071
1101
|
}
|
|
@@ -1116,7 +1146,6 @@ class StageExecutor {
|
|
|
1116
1146
|
child.on('close', (code) => {
|
|
1117
1147
|
clearTimeout(timeoutId);
|
|
1118
1148
|
this.activeChildren.delete(child);
|
|
1119
|
-
if (this.activeChildren.size === 0) this._stopPidTracking();
|
|
1120
1149
|
// Обрабатываем остаток буфера стриминга
|
|
1121
1150
|
if (stdoutBuffer.trim()) {
|
|
1122
1151
|
try {
|
|
@@ -1307,8 +1336,10 @@ class PipelineRunner {
|
|
|
1307
1336
|
|
|
1308
1337
|
// Текущий executor для доступа к activeChildren при shutdown
|
|
1309
1338
|
this.currentExecutor = null;
|
|
1310
|
-
//
|
|
1311
|
-
this.
|
|
1339
|
+
// Путь к PID-файлу для cleanup сирот между запусками
|
|
1340
|
+
this.pidFilePath = getPidFilePath(projectRoot);
|
|
1341
|
+
// Снапшот PID'ов перед стартом пайплайна — для snapshot-based cleanup
|
|
1342
|
+
this.preRunSnapshot = new Set();
|
|
1312
1343
|
|
|
1313
1344
|
// Настройка graceful shutdown
|
|
1314
1345
|
this.setupGracefulShutdown();
|
|
@@ -1375,6 +1406,13 @@ class PipelineRunner {
|
|
|
1375
1406
|
const maxSteps = this.pipeline.execution?.max_steps || 100;
|
|
1376
1407
|
const delayBetweenStages = this.pipeline.execution?.delay_between_stages || 5;
|
|
1377
1408
|
|
|
1409
|
+
// Cleanup сирот от предыдущего запуска (если runner был убит жёстко)
|
|
1410
|
+
cleanupOrphansFromFile(this.pidFilePath, this.logger);
|
|
1411
|
+
|
|
1412
|
+
// Снапшот процессов до старта + запись PID-файла
|
|
1413
|
+
this.preRunSnapshot = getProcessSnapshot();
|
|
1414
|
+
writePidFile(this.pidFilePath, this.preRunSnapshot);
|
|
1415
|
+
|
|
1378
1416
|
this.logger.info('=== Pipeline Runner Started ===', 'PipelineRunner');
|
|
1379
1417
|
this.logger.info(`Entry stage: ${this.pipeline.entry}`, 'PipelineRunner');
|
|
1380
1418
|
this.logger.info(`Max steps: ${maxSteps}`, 'PipelineRunner');
|
|
@@ -1406,10 +1444,6 @@ class PipelineRunner {
|
|
|
1406
1444
|
} else {
|
|
1407
1445
|
this.currentExecutor = new StageExecutor(this.config, this.context, this.counters, {}, this.fileGuard, this.logger, this.projectRoot);
|
|
1408
1446
|
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
1447
|
this.currentExecutor = null;
|
|
1414
1448
|
}
|
|
1415
1449
|
|
|
@@ -1465,6 +1499,9 @@ class PipelineRunner {
|
|
|
1465
1499
|
// Записываем итоговый summary
|
|
1466
1500
|
this.logger.writeSummary();
|
|
1467
1501
|
|
|
1502
|
+
// Cleanup: убиваем процессы-сироты, порождённые агентами во время пайплайна
|
|
1503
|
+
this._cleanupOrphans();
|
|
1504
|
+
|
|
1468
1505
|
return {
|
|
1469
1506
|
steps: this.stepCount,
|
|
1470
1507
|
tasksExecuted: this.tasksExecuted,
|
|
@@ -1591,29 +1628,47 @@ class PipelineRunner {
|
|
|
1591
1628
|
/**
|
|
1592
1629
|
* Настройка graceful shutdown
|
|
1593
1630
|
*/
|
|
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
|
+
|
|
1594
1652
|
setupGracefulShutdown() {
|
|
1595
1653
|
const shutdown = (signal) => {
|
|
1596
1654
|
if (this.logger) {
|
|
1597
1655
|
this.logger.info(`Received ${signal}. Killing child processes and shutting down...`, 'PipelineRunner');
|
|
1598
1656
|
}
|
|
1599
1657
|
this.running = false;
|
|
1600
|
-
// Убиваем
|
|
1658
|
+
// Убиваем активные дочерние процессы текущего stage
|
|
1601
1659
|
if (this.currentExecutor) {
|
|
1602
1660
|
this.currentExecutor.killAllChildren();
|
|
1603
1661
|
}
|
|
1604
|
-
//
|
|
1605
|
-
|
|
1606
|
-
killPid(pid);
|
|
1607
|
-
}
|
|
1608
|
-
this.allTrackedPids.clear();
|
|
1662
|
+
// Snapshot-based: убиваем всех сирот от всех stage'ей
|
|
1663
|
+
this._cleanupOrphans();
|
|
1609
1664
|
};
|
|
1610
1665
|
|
|
1611
1666
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
1612
1667
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
|
1613
1668
|
|
|
1614
|
-
// Финальный cleanup при выходе
|
|
1669
|
+
// Финальный синхронный cleanup при выходе — последний шанс убить сирот
|
|
1615
1670
|
process.on('exit', () => {
|
|
1616
|
-
// Убиваем активные child objects (если
|
|
1671
|
+
// Убиваем активные child objects (если exit во время работы stage)
|
|
1617
1672
|
if (this.currentExecutor) {
|
|
1618
1673
|
for (const child of this.currentExecutor.activeChildren) {
|
|
1619
1674
|
if (child.pid) {
|
|
@@ -1624,22 +1679,9 @@ class PipelineRunner {
|
|
|
1624
1679
|
}
|
|
1625
1680
|
}
|
|
1626
1681
|
}
|
|
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
1682
|
}
|
|
1683
|
+
// Snapshot-based cleanup
|
|
1684
|
+
this._cleanupOrphans();
|
|
1643
1685
|
});
|
|
1644
1686
|
}
|
|
1645
1687
|
}
|