@yemi33/minions 0.1.59 → 0.1.61
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/CHANGELOG.md +22 -0
- package/engine/cleanup.js +393 -0
- package/engine/cooldown.js +117 -0
- package/engine/dispatch.js +207 -0
- package/engine/playbook.js +479 -0
- package/engine/routing.js +163 -0
- package/engine/timeout.js +280 -0
- package/engine.js +41 -1398
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,27 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.61 (2026-03-30)
|
|
4
|
+
|
|
5
|
+
### Engine
|
|
6
|
+
- engine.js
|
|
7
|
+
- engine/cleanup.js
|
|
8
|
+
- engine/dispatch.js
|
|
9
|
+
- engine/timeout.js
|
|
10
|
+
|
|
11
|
+
### Other
|
|
12
|
+
- test/unit.test.js
|
|
13
|
+
|
|
14
|
+
## 0.1.60 (2026-03-30)
|
|
15
|
+
|
|
16
|
+
### Engine
|
|
17
|
+
- engine.js
|
|
18
|
+
- engine/cooldown.js
|
|
19
|
+
- engine/playbook.js
|
|
20
|
+
- engine/routing.js
|
|
21
|
+
|
|
22
|
+
### Other
|
|
23
|
+
- test/unit.test.js
|
|
24
|
+
|
|
3
25
|
## 0.1.59 (2026-03-30)
|
|
4
26
|
|
|
5
27
|
### Engine
|
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engine/cleanup.js — Periodic cleanup: temp files, worktrees, zombies, migrations.
|
|
3
|
+
* Extracted from engine.js for modularity. No logic changes.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const shared = require('./shared');
|
|
9
|
+
const queries = require('./queries');
|
|
10
|
+
|
|
11
|
+
const { exec, execSilent } = shared;
|
|
12
|
+
const { safeJson, safeWrite, safeReadDir, getProjects, projectWorkItemsPath, projectPrPath,
|
|
13
|
+
sanitizeBranch, KB_CATEGORIES } = shared;
|
|
14
|
+
const { getDispatch, getAgentStatus } = queries;
|
|
15
|
+
|
|
16
|
+
const MINIONS_DIR = shared.MINIONS_DIR;
|
|
17
|
+
const AGENTS_DIR = queries.AGENTS_DIR;
|
|
18
|
+
const ENGINE_DIR = queries.ENGINE_DIR;
|
|
19
|
+
const PRD_DIR = queries.PRD_DIR;
|
|
20
|
+
const PLANS_DIR = queries.PLANS_DIR;
|
|
21
|
+
|
|
22
|
+
// Lazy require to break circular dependency with engine.js
|
|
23
|
+
let _engine = null;
|
|
24
|
+
function engine() { if (!_engine) _engine = require('../engine'); return _engine; }
|
|
25
|
+
function log(level, msg, meta) { return engine().log(level, msg, meta); }
|
|
26
|
+
function ts() { return engine().ts(); }
|
|
27
|
+
|
|
28
|
+
// Lazy require for dispatch module
|
|
29
|
+
let _dispatch = null;
|
|
30
|
+
function dispatchModule() { if (!_dispatch) _dispatch = require('./dispatch'); return _dispatch; }
|
|
31
|
+
|
|
32
|
+
// ─── Cleanup Orchestrator ────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
function runCleanup(config, verbose = false) {
|
|
35
|
+
const activeProcesses = engine().activeProcesses;
|
|
36
|
+
const projects = getProjects(config);
|
|
37
|
+
let cleaned = { tempFiles: 0, liveOutputs: 0, worktrees: 0, zombies: 0 };
|
|
38
|
+
|
|
39
|
+
// 1. Clean stale temp prompt/sysprompt files (older than 1 hour)
|
|
40
|
+
const oneHourAgo = Date.now() - 3600000;
|
|
41
|
+
try {
|
|
42
|
+
const tmpDir = path.join(ENGINE_DIR, 'tmp');
|
|
43
|
+
const scanDirs = [ENGINE_DIR, ...(fs.existsSync(tmpDir) ? [tmpDir] : [])];
|
|
44
|
+
for (const dir of scanDirs) {
|
|
45
|
+
for (const f of fs.readdirSync(dir)) {
|
|
46
|
+
if (f.startsWith('prompt-') || f.startsWith('sysprompt-') || f.startsWith('tmp-sysprompt-')) {
|
|
47
|
+
const fp = path.join(dir, f);
|
|
48
|
+
try {
|
|
49
|
+
const stat = fs.statSync(fp);
|
|
50
|
+
if (stat.mtimeMs < oneHourAgo) {
|
|
51
|
+
fs.unlinkSync(fp);
|
|
52
|
+
cleaned.tempFiles++;
|
|
53
|
+
}
|
|
54
|
+
} catch { /* cleanup */ }
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
} catch (e) { log('warn', 'cleanup temp files: ' + e.message); }
|
|
59
|
+
|
|
60
|
+
// 2. Clean live-output.log for idle agents (not currently working)
|
|
61
|
+
for (const [agentId] of Object.entries(config.agents || {})) {
|
|
62
|
+
const status = getAgentStatus(agentId);
|
|
63
|
+
if (status.status !== 'working') {
|
|
64
|
+
const livePath = path.join(AGENTS_DIR, agentId, 'live-output.log');
|
|
65
|
+
if (fs.existsSync(livePath)) {
|
|
66
|
+
try {
|
|
67
|
+
const stat = fs.statSync(livePath);
|
|
68
|
+
if (stat.mtimeMs < oneHourAgo) {
|
|
69
|
+
fs.unlinkSync(livePath);
|
|
70
|
+
cleaned.liveOutputs++;
|
|
71
|
+
}
|
|
72
|
+
} catch { /* cleanup */ }
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// 3. Clean git worktrees for merged/abandoned PRs
|
|
78
|
+
for (const project of projects) {
|
|
79
|
+
const root = project.localPath ? path.resolve(project.localPath) : null;
|
|
80
|
+
if (!root || !fs.existsSync(root)) continue;
|
|
81
|
+
|
|
82
|
+
const worktreeRoot = path.resolve(root, config.engine?.worktreeRoot || '../worktrees');
|
|
83
|
+
if (!fs.existsSync(worktreeRoot)) continue;
|
|
84
|
+
|
|
85
|
+
// Get PRs for this project
|
|
86
|
+
const prs = safeJson(projectPrPath(project)) || [];
|
|
87
|
+
const mergedBranches = new Set();
|
|
88
|
+
for (const pr of prs) {
|
|
89
|
+
if (pr.status === 'merged' || pr.status === 'abandoned' || pr.status === 'completed') {
|
|
90
|
+
if (pr.branch) mergedBranches.add(pr.branch);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// List worktrees — collect info for age-based + cap-based cleanup
|
|
95
|
+
const MAX_WORKTREES = 10;
|
|
96
|
+
try {
|
|
97
|
+
const dirs = fs.readdirSync(worktreeRoot);
|
|
98
|
+
const wtEntries = []; // { dir, wtPath, mtime, shouldClean, isProtected }
|
|
99
|
+
const dispatch = getDispatch();
|
|
100
|
+
|
|
101
|
+
for (const dir of dirs) {
|
|
102
|
+
const wtPath = path.join(worktreeRoot, dir);
|
|
103
|
+
try { if (!fs.statSync(wtPath).isDirectory()) continue; } catch { continue; }
|
|
104
|
+
|
|
105
|
+
let shouldClean = false;
|
|
106
|
+
let isProtected = false;
|
|
107
|
+
|
|
108
|
+
// Check if this worktree's branch is merged/abandoned
|
|
109
|
+
// Use sanitized exact match on the branch portion of the dir name (format: {slug}-{branch}-{suffix})
|
|
110
|
+
const dirLower = dir.toLowerCase();
|
|
111
|
+
for (const branch of mergedBranches) {
|
|
112
|
+
const branchSlug = sanitizeBranch(branch).toLowerCase();
|
|
113
|
+
if (dirLower === branchSlug || dirLower.includes(branchSlug + '-') || dirLower.endsWith('-' + branchSlug)) {
|
|
114
|
+
shouldClean = true;
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Check if referenced by active/pending dispatch (use sanitized branch comparison)
|
|
120
|
+
const isReferenced = [...dispatch.pending, ...(dispatch.active || [])].some(d => {
|
|
121
|
+
if (!d.meta?.branch) return false;
|
|
122
|
+
const dispBranch = sanitizeBranch(d.meta.branch).toLowerCase();
|
|
123
|
+
return dirLower.includes(dispBranch);
|
|
124
|
+
});
|
|
125
|
+
if (isReferenced) isProtected = true;
|
|
126
|
+
|
|
127
|
+
// Also clean worktrees older than 2 hours with no active dispatch referencing them
|
|
128
|
+
let mtime = Date.now();
|
|
129
|
+
if (!shouldClean) {
|
|
130
|
+
try {
|
|
131
|
+
const stat = fs.statSync(wtPath);
|
|
132
|
+
mtime = stat.mtimeMs;
|
|
133
|
+
const ageMs = Date.now() - mtime;
|
|
134
|
+
if (ageMs > 7200000 && !isReferenced) { // 2 hours
|
|
135
|
+
shouldClean = true;
|
|
136
|
+
}
|
|
137
|
+
} catch { /* optional */ }
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Skip worktrees for active shared-branch plans (check both prd/ and plans/ for .json PRDs)
|
|
141
|
+
if (shouldClean || !isProtected) {
|
|
142
|
+
try {
|
|
143
|
+
for (const checkDir of [PRD_DIR, path.join(MINIONS_DIR, 'plans')]) {
|
|
144
|
+
if (!fs.existsSync(checkDir)) continue;
|
|
145
|
+
for (const pf of fs.readdirSync(checkDir).filter(f => f.endsWith('.json'))) {
|
|
146
|
+
const plan = safeJson(path.join(checkDir, pf));
|
|
147
|
+
if (plan?.branch_strategy === 'shared-branch' && plan?.feature_branch && plan?.status !== 'completed') {
|
|
148
|
+
const planBranch = sanitizeBranch(plan.feature_branch).toLowerCase();
|
|
149
|
+
if (dirLower.includes(planBranch)) {
|
|
150
|
+
isProtected = true;
|
|
151
|
+
if (shouldClean) {
|
|
152
|
+
shouldClean = false;
|
|
153
|
+
if (verbose) console.log(` Skipping worktree ${dir}: active shared-branch plan`);
|
|
154
|
+
}
|
|
155
|
+
break;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
if (isProtected) break;
|
|
160
|
+
}
|
|
161
|
+
} catch (e) { log('warn', 'check shared-branch protection: ' + e.message); }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
wtEntries.push({ dir, wtPath, mtime, shouldClean, isProtected });
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Enforce max worktree cap — if over limit, mark oldest unprotected for cleanup
|
|
168
|
+
const surviving = wtEntries.filter(e => !e.shouldClean && !e.isProtected);
|
|
169
|
+
if (surviving.length + wtEntries.filter(e => e.isProtected).length > MAX_WORKTREES) {
|
|
170
|
+
// Sort oldest first
|
|
171
|
+
surviving.sort((a, b) => a.mtime - b.mtime);
|
|
172
|
+
const excess = surviving.length + wtEntries.filter(e => e.isProtected).length - MAX_WORKTREES;
|
|
173
|
+
for (let i = 0; i < Math.min(excess, surviving.length); i++) {
|
|
174
|
+
surviving[i].shouldClean = true;
|
|
175
|
+
if (verbose) console.log(` Marking worktree ${surviving[i].dir} for cap cleanup (${MAX_WORKTREES} max)`);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Remove all marked worktrees
|
|
180
|
+
for (const entry of wtEntries) {
|
|
181
|
+
if (entry.shouldClean) {
|
|
182
|
+
try {
|
|
183
|
+
exec(`git worktree remove "${entry.wtPath}" --force`, { cwd: root, stdio: 'pipe' });
|
|
184
|
+
cleaned.worktrees++;
|
|
185
|
+
if (verbose) console.log(` Removed worktree: ${entry.wtPath}`);
|
|
186
|
+
} catch (e) {
|
|
187
|
+
if (verbose) console.log(` Failed to remove worktree ${entry.wtPath}: ${e.message}`);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
} catch (e) { log('warn', 'cleanup worktrees: ' + e.message); }
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
// 4. Kill zombie claude processes not tracked by the engine
|
|
195
|
+
// List all node processes, check if any are running spawn-agent.js for our minions
|
|
196
|
+
try {
|
|
197
|
+
const dispatch = getDispatch();
|
|
198
|
+
const activePids = new Set();
|
|
199
|
+
for (const [, info] of activeProcesses.entries()) {
|
|
200
|
+
if (info.proc?.pid) activePids.add(info.proc.pid);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// Clean individual orphaned processes — no matching active dispatch
|
|
204
|
+
const activeIds = new Set((dispatch.active || []).map(d => d.id));
|
|
205
|
+
for (const [id, info] of activeProcesses.entries()) {
|
|
206
|
+
if (!activeIds.has(id)) {
|
|
207
|
+
try { if (info.proc) info.proc.kill('SIGTERM'); } catch { /* process may be dead */ }
|
|
208
|
+
activeProcesses.delete(id);
|
|
209
|
+
cleaned.zombies++;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
} catch (e) { log('warn', 'cleanup zombie processes: ' + e.message); }
|
|
213
|
+
|
|
214
|
+
// 5. Clean spawn-debug.log
|
|
215
|
+
try { fs.unlinkSync(path.join(ENGINE_DIR, 'spawn-debug.log')); } catch { /* cleanup */ }
|
|
216
|
+
|
|
217
|
+
// 6. Prune old output archive files (keep last 30 per agent)
|
|
218
|
+
for (const agentId of Object.keys(config.agents || {})) {
|
|
219
|
+
const agentDir = path.join(MINIONS_DIR, 'agents', agentId);
|
|
220
|
+
if (!fs.existsSync(agentDir)) continue;
|
|
221
|
+
try {
|
|
222
|
+
const outputFiles = fs.readdirSync(agentDir)
|
|
223
|
+
.filter(f => f.startsWith('output-') && f.endsWith('.log') && f !== 'output.log')
|
|
224
|
+
.map(f => ({ name: f, mtime: fs.statSync(path.join(agentDir, f)).mtimeMs }))
|
|
225
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
226
|
+
for (const old of outputFiles.slice(30)) {
|
|
227
|
+
try { fs.unlinkSync(path.join(agentDir, old.name)); cleaned.files++; } catch { /* cleanup */ }
|
|
228
|
+
}
|
|
229
|
+
} catch (e) { log('warn', 'prune output archives: ' + e.message); }
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// 7. Prune orphaned dispatch entries — items whose source work item no longer exists
|
|
233
|
+
cleaned.orphanedDispatches = 0;
|
|
234
|
+
try {
|
|
235
|
+
const dispatch = getDispatch();
|
|
236
|
+
// Collect all work item IDs across all sources
|
|
237
|
+
const allWiIds = new Set();
|
|
238
|
+
try {
|
|
239
|
+
const central = safeJson(path.join(MINIONS_DIR, 'work-items.json')) || [];
|
|
240
|
+
central.forEach(w => allWiIds.add(w.id));
|
|
241
|
+
} catch (e) { log('warn', 'read central work items for orphan check: ' + e.message); }
|
|
242
|
+
for (const project of projects) {
|
|
243
|
+
try {
|
|
244
|
+
const projItems = safeJson(projectWorkItemsPath(project)) || [];
|
|
245
|
+
projItems.forEach(w => allWiIds.add(w.id));
|
|
246
|
+
} catch (e) { log('warn', 'read project work items for orphan check: ' + e.message); }
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
let changed = false;
|
|
250
|
+
for (const queue of ['pending', 'active']) {
|
|
251
|
+
if (!dispatch[queue]) continue;
|
|
252
|
+
const before = dispatch[queue].length;
|
|
253
|
+
dispatch[queue] = dispatch[queue].filter(d => {
|
|
254
|
+
const itemId = d.meta?.item?.id;
|
|
255
|
+
if (!itemId) return true; // keep entries without item tracking
|
|
256
|
+
return allWiIds.has(itemId);
|
|
257
|
+
});
|
|
258
|
+
const removed = before - dispatch[queue].length;
|
|
259
|
+
if (removed > 0) {
|
|
260
|
+
cleaned.orphanedDispatches += removed;
|
|
261
|
+
changed = true;
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
if (changed) {
|
|
265
|
+
const { mutateDispatch } = dispatchModule();
|
|
266
|
+
mutateDispatch((dp) => {
|
|
267
|
+
for (const queue of ['pending', 'active']) {
|
|
268
|
+
if (!dp[queue]) continue;
|
|
269
|
+
dp[queue] = dp[queue].filter(d => {
|
|
270
|
+
const itemId = d.meta?.item?.id;
|
|
271
|
+
if (!itemId) return true;
|
|
272
|
+
return allWiIds.has(itemId);
|
|
273
|
+
});
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
} catch (e) { log('warn', 'prune orphaned dispatches: ' + e.message); }
|
|
278
|
+
|
|
279
|
+
if (cleaned.tempFiles + cleaned.liveOutputs + cleaned.worktrees + cleaned.zombies + (cleaned.files || 0) + cleaned.orphanedDispatches > 0) {
|
|
280
|
+
log('info', `Cleanup: ${cleaned.tempFiles} temp, ${cleaned.liveOutputs} live outputs, ${cleaned.worktrees} worktrees, ${cleaned.zombies} zombies, ${cleaned.files || 0} archives, ${cleaned.orphanedDispatches} orphaned dispatches`);
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// 8. Clean swept KB files older than 7 days
|
|
284
|
+
try {
|
|
285
|
+
const sweptDir = path.join(MINIONS_DIR, 'knowledge', '_swept');
|
|
286
|
+
if (fs.existsSync(sweptDir)) {
|
|
287
|
+
const sevenDaysAgo = Date.now() - 7 * 86400000;
|
|
288
|
+
for (const f of fs.readdirSync(sweptDir)) {
|
|
289
|
+
try {
|
|
290
|
+
const fp = path.join(sweptDir, f);
|
|
291
|
+
if (fs.statSync(fp).mtimeMs < sevenDaysAgo) {
|
|
292
|
+
fs.unlinkSync(fp);
|
|
293
|
+
if (!cleaned.sweptKb) cleaned.sweptKb = 0;
|
|
294
|
+
cleaned.sweptKb++;
|
|
295
|
+
}
|
|
296
|
+
} catch { /* cleanup */ }
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
} catch (e) { log('warn', 'cleanup swept KB files: ' + e.message); }
|
|
300
|
+
|
|
301
|
+
// 9. KB watchdog — restore deleted KB files from git if count dropped vs checkpoint
|
|
302
|
+
try {
|
|
303
|
+
const checkpoint = safeJson(path.join(ENGINE_DIR, 'kb-checkpoint.json'));
|
|
304
|
+
if (checkpoint && checkpoint.count > 0) {
|
|
305
|
+
const cats = KB_CATEGORIES;
|
|
306
|
+
const knowledgeDir = path.join(MINIONS_DIR, 'knowledge');
|
|
307
|
+
let current = 0;
|
|
308
|
+
for (const cat of cats) {
|
|
309
|
+
const d = path.join(knowledgeDir, cat);
|
|
310
|
+
if (fs.existsSync(d)) current += fs.readdirSync(d).length;
|
|
311
|
+
}
|
|
312
|
+
if (current < checkpoint.count) {
|
|
313
|
+
log('warn', `KB watchdog: file count dropped ${checkpoint.count} → ${current}, restoring from git`);
|
|
314
|
+
try {
|
|
315
|
+
const trackedCheck = execSilent('git ls-tree --name-only HEAD -- knowledge', { cwd: MINIONS_DIR }).toString().trim();
|
|
316
|
+
if (!trackedCheck) {
|
|
317
|
+
log('warn', 'KB watchdog: knowledge/ is not tracked in git HEAD — skipping restore');
|
|
318
|
+
} else {
|
|
319
|
+
execSilent('git checkout HEAD -- knowledge', { cwd: MINIONS_DIR });
|
|
320
|
+
log('info', 'KB watchdog: restored knowledge/ from git HEAD');
|
|
321
|
+
}
|
|
322
|
+
} catch (err) {
|
|
323
|
+
log('error', `KB watchdog: git restore failed — ${err.message}`);
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
} catch (e) { log('warn', 'KB watchdog check: ' + e.message); }
|
|
328
|
+
|
|
329
|
+
// 6. Migrate legacy work-item statuses to canonical values
|
|
330
|
+
// in-pr, implemented, complete → done (one-time correction per item)
|
|
331
|
+
const LEGACY_DONE_STATUSES = new Set(['in-pr', 'implemented', 'complete']);
|
|
332
|
+
for (const project of projects) {
|
|
333
|
+
try {
|
|
334
|
+
const wiPath = projectWorkItemsPath(project);
|
|
335
|
+
const items = safeJson(wiPath) || [];
|
|
336
|
+
let migrated = 0;
|
|
337
|
+
for (const item of items) {
|
|
338
|
+
if (LEGACY_DONE_STATUSES.has(item.status)) {
|
|
339
|
+
item.status = 'done';
|
|
340
|
+
migrated++;
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
if (migrated > 0) {
|
|
344
|
+
safeWrite(wiPath, items);
|
|
345
|
+
log('info', `Migrated ${migrated} legacy status(es) → done in ${project.name} work items`);
|
|
346
|
+
}
|
|
347
|
+
} catch (e) { log('warn', 'migrate legacy statuses: ' + e.message); }
|
|
348
|
+
}
|
|
349
|
+
// Central work items
|
|
350
|
+
try {
|
|
351
|
+
const centralPath = path.join(MINIONS_DIR, 'work-items.json');
|
|
352
|
+
const centralItems = safeJson(centralPath) || [];
|
|
353
|
+
let migrated = 0;
|
|
354
|
+
for (const item of centralItems) {
|
|
355
|
+
if (LEGACY_DONE_STATUSES.has(item.status)) {
|
|
356
|
+
item.status = 'done';
|
|
357
|
+
migrated++;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
if (migrated > 0) {
|
|
361
|
+
safeWrite(centralPath, centralItems);
|
|
362
|
+
log('info', `Migrated ${migrated} legacy status(es) → done in central work items`);
|
|
363
|
+
}
|
|
364
|
+
} catch (e) { log('warn', 'migrate central legacy statuses: ' + e.message); }
|
|
365
|
+
// PRD items (missing_features[].status)
|
|
366
|
+
try {
|
|
367
|
+
const prdFiles = fs.readdirSync(PRD_DIR).filter(f => f.endsWith('.json'));
|
|
368
|
+
for (const pf of prdFiles) {
|
|
369
|
+
const prdPath = path.join(PRD_DIR, pf);
|
|
370
|
+
const prd = safeJson(prdPath);
|
|
371
|
+
if (!prd?.missing_features) continue;
|
|
372
|
+
let migrated = 0;
|
|
373
|
+
for (const feat of prd.missing_features) {
|
|
374
|
+
if (LEGACY_DONE_STATUSES.has(feat.status)) {
|
|
375
|
+
feat.status = 'done';
|
|
376
|
+
migrated++;
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
if (migrated > 0) {
|
|
380
|
+
safeWrite(prdPath, prd);
|
|
381
|
+
log('info', `Migrated ${migrated} legacy PRD item status(es) → done in ${pf}`);
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
} catch (e) { log('warn', 'migrate PRD legacy statuses: ' + e.message); }
|
|
385
|
+
|
|
386
|
+
return cleaned;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
// ─── Exports ─────────────────────────────────────────────────────────────────
|
|
390
|
+
|
|
391
|
+
module.exports = {
|
|
392
|
+
runCleanup,
|
|
393
|
+
};
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engine/cooldown.js — Dispatch cooldowns, deduplication, and context coalescing.
|
|
3
|
+
* Extracted from engine.js.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const shared = require('./shared');
|
|
8
|
+
const queries = require('./queries');
|
|
9
|
+
|
|
10
|
+
const { safeJson, safeWrite } = shared;
|
|
11
|
+
const { ENGINE_DIR } = queries;
|
|
12
|
+
|
|
13
|
+
// Lazy require to avoid circular dependency with engine.js
|
|
14
|
+
let _engine = null;
|
|
15
|
+
function engine() { if (!_engine) _engine = require('../engine'); return _engine; }
|
|
16
|
+
|
|
17
|
+
const COOLDOWN_PATH = path.join(ENGINE_DIR, 'cooldowns.json');
|
|
18
|
+
const dispatchCooldowns = new Map(); // key → { timestamp, failures }
|
|
19
|
+
|
|
20
|
+
function loadCooldowns() {
|
|
21
|
+
const saved = safeJson(COOLDOWN_PATH);
|
|
22
|
+
if (!saved) return;
|
|
23
|
+
const now = Date.now();
|
|
24
|
+
for (const [k, v] of Object.entries(saved)) {
|
|
25
|
+
// Prune entries older than 24 hours
|
|
26
|
+
if (now - v.timestamp < 24 * 60 * 60 * 1000) {
|
|
27
|
+
dispatchCooldowns.set(k, v);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
engine().log('info', `Loaded ${dispatchCooldowns.size} cooldowns from disk`);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let _cooldownWriteTimer = null;
|
|
34
|
+
function saveCooldowns() {
|
|
35
|
+
// Debounce: reset timer on each call so latest state is always written
|
|
36
|
+
if (_cooldownWriteTimer) clearTimeout(_cooldownWriteTimer);
|
|
37
|
+
_cooldownWriteTimer = setTimeout(() => {
|
|
38
|
+
_cooldownWriteTimer = null;
|
|
39
|
+
// Prune expired entries (>24h) before saving
|
|
40
|
+
const now = Date.now();
|
|
41
|
+
for (const [k, v] of dispatchCooldowns) {
|
|
42
|
+
if (now - v.timestamp > 24 * 60 * 60 * 1000) dispatchCooldowns.delete(k);
|
|
43
|
+
}
|
|
44
|
+
const obj = Object.fromEntries(dispatchCooldowns);
|
|
45
|
+
safeWrite(COOLDOWN_PATH, obj);
|
|
46
|
+
}, 1000); // debounce — write at most once per second
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function isOnCooldown(key, cooldownMs) {
|
|
50
|
+
const entry = dispatchCooldowns.get(key);
|
|
51
|
+
if (!entry) return false;
|
|
52
|
+
const backoff = Math.min(Math.pow(2, entry.failures || 0), 8);
|
|
53
|
+
return (Date.now() - entry.timestamp) < (cooldownMs * backoff);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function setCooldown(key) {
|
|
57
|
+
const existing = dispatchCooldowns.get(key);
|
|
58
|
+
dispatchCooldowns.set(key, { timestamp: Date.now(), failures: existing?.failures || 0 });
|
|
59
|
+
saveCooldowns();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function setCooldownWithContext(key, context) {
|
|
63
|
+
const existing = dispatchCooldowns.get(key);
|
|
64
|
+
const pendingContexts = existing?.pendingContexts || [];
|
|
65
|
+
if (context) pendingContexts.push(context);
|
|
66
|
+
dispatchCooldowns.set(key, {
|
|
67
|
+
timestamp: Date.now(),
|
|
68
|
+
failures: existing?.failures || 0,
|
|
69
|
+
pendingContexts
|
|
70
|
+
});
|
|
71
|
+
saveCooldowns();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function getCoalescedContexts(key) {
|
|
75
|
+
const entry = dispatchCooldowns.get(key);
|
|
76
|
+
const contexts = entry?.pendingContexts || [];
|
|
77
|
+
if (contexts.length > 0 && entry) {
|
|
78
|
+
entry.pendingContexts = []; // Clear after retrieval
|
|
79
|
+
}
|
|
80
|
+
return contexts;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function setCooldownFailure(key) {
|
|
84
|
+
const existing = dispatchCooldowns.get(key);
|
|
85
|
+
const failures = (existing?.failures || 0) + 1;
|
|
86
|
+
dispatchCooldowns.set(key, { timestamp: Date.now(), failures });
|
|
87
|
+
if (failures >= 3) {
|
|
88
|
+
engine().log('warn', `${key} has failed ${failures} times — cooldown is now ${Math.min(Math.pow(2, failures), 8)}x`);
|
|
89
|
+
}
|
|
90
|
+
saveCooldowns();
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function isAlreadyDispatched(key) {
|
|
94
|
+
const dispatch = queries.getDispatch();
|
|
95
|
+
// Check pending and active
|
|
96
|
+
const inFlight = [...dispatch.pending, ...(dispatch.active || [])];
|
|
97
|
+
if (inFlight.some(d => d.meta?.dispatchKey === key)) return true;
|
|
98
|
+
// Also check recently completed (last hour) to prevent re-dispatch
|
|
99
|
+
const oneHourAgo = Date.now() - 3600000;
|
|
100
|
+
const recentCompleted = (dispatch.completed || []).filter(d =>
|
|
101
|
+
d.completed_at && new Date(d.completed_at).getTime() > oneHourAgo
|
|
102
|
+
);
|
|
103
|
+
return recentCompleted.some(d => d.meta?.dispatchKey === key);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
module.exports = {
|
|
107
|
+
COOLDOWN_PATH,
|
|
108
|
+
dispatchCooldowns,
|
|
109
|
+
loadCooldowns,
|
|
110
|
+
saveCooldowns,
|
|
111
|
+
isOnCooldown,
|
|
112
|
+
setCooldown,
|
|
113
|
+
setCooldownWithContext,
|
|
114
|
+
getCoalescedContexts,
|
|
115
|
+
setCooldownFailure,
|
|
116
|
+
isAlreadyDispatched,
|
|
117
|
+
};
|