@yemi33/minions 0.1.1005 → 0.1.1007
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 +5 -5
- package/dashboard/js/command-center.js +7 -0
- package/dashboard/js/render-work-items.js +16 -0
- package/dashboard.js +131 -0
- package/engine/ado.js +8 -0
- package/engine/cleanup.js +17 -3
- package/engine/cooldown.js +12 -2
- package/engine/dispatch.js +26 -0
- package/engine/github.js +8 -0
- package/engine/lifecycle.js +24 -0
- package/engine/shared.js +1 -0
- package/package.json +1 -1
- package/prompts/cc-system.md +4 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.1.
|
|
3
|
+
## 0.1.1007 (2026-04-16)
|
|
4
4
|
|
|
5
5
|
### Features
|
|
6
|
+
- fix duplicate PR creation + cancel stale review dispatches on PR close (#1137)
|
|
7
|
+
- intercept /loop skill invocations in CC and convert to watches (#1140)
|
|
8
|
+
- add cancel work item endpoint, UI button, and CC action (#1136)
|
|
9
|
+
- cap pendingContexts in cooldowns.json to prevent bloat (#1126)
|
|
6
10
|
- fix stopAfter=0 watch expiry — run forever for all conditions (#1117)
|
|
7
11
|
- fix doc-chat Clear chat not persisting session deletion to localStorage (#1102)
|
|
8
12
|
- remove DEFAULTS alias from timeout.js (#1101)
|
|
@@ -19,10 +23,6 @@
|
|
|
19
23
|
- add quality standard reminder to all agent and CC prompts
|
|
20
24
|
- surface in-flight tool calls in lastAction (#1064)
|
|
21
25
|
- implement dashboard loop/watch management panel
|
|
22
|
-
- reassign tasks when preferred agent is busy too long
|
|
23
|
-
- wire agentBusyReassignMs into settings UI and persist
|
|
24
|
-
- ADO throttle detection, poll guards, and dashboard banner (#1051)
|
|
25
|
-
- gate auto-fix conflict dispatch behind autoFixConflicts flag
|
|
26
26
|
|
|
27
27
|
### Fixes
|
|
28
28
|
- harden repo-scoped PR identity handling
|
|
@@ -860,6 +860,13 @@ async function ccExecuteAction(action, targetTabId) {
|
|
|
860
860
|
status.style.color = 'var(--orange)';
|
|
861
861
|
break;
|
|
862
862
|
}
|
|
863
|
+
case 'cancel-work-item': {
|
|
864
|
+
await _ccFetch('/api/work-items/cancel', { id: action.id, source: action.source || '', reason: action.reason || 'cc' });
|
|
865
|
+
status.innerHTML = '✓ Cancelled work item: <strong>' + escHtml(action.id) + '</strong>';
|
|
866
|
+
status.style.color = 'var(--orange)';
|
|
867
|
+
wakeEngine();
|
|
868
|
+
break;
|
|
869
|
+
}
|
|
863
870
|
case 'plan-edit': {
|
|
864
871
|
// Read the plan, send instruction to doc-chat, show version actions
|
|
865
872
|
var normalizedFile = normalizePlanFile(action.file);
|
|
@@ -70,6 +70,7 @@ function wiRow(item) {
|
|
|
70
70
|
((item.status === 'pending' || item.status === 'failed' || item.status === 'needs-human-review') ? '<button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:var(--blue);border-color:var(--blue);margin-right:4px" onclick="event.stopPropagation();editWorkItem(\'' + escHtml(item.id) + '\',\'' + escHtml(item._source || '') + '\')" title="Edit work item">✎</button>' : '') +
|
|
71
71
|
((item.status === 'done' || item.status === 'failed' || item.status === 'needs-human-review') ? '<button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:var(--muted);border-color:var(--border);margin-right:4px" onclick="event.stopPropagation();archiveWorkItem(\'' + escHtml(item.id) + '\',\'' + escHtml(item._source || '') + '\')" title="Archive work item">📦</button>' : '') +
|
|
72
72
|
((item.status === 'done' || item.status === 'failed' || item.status === 'needs-human-review') && !item._humanFeedback ? '<button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:var(--green);border-color:var(--green);margin-right:4px" onclick="event.stopPropagation();feedbackWorkItem(\'' + escHtml(item.id) + '\',\'' + escHtml(item._source || '') + '\')" title="Give feedback">👍👎</button>' : (item._humanFeedback ? '<span style="font-size:9px" title="Feedback given">' + (item._humanFeedback.rating === 'up' ? '👍' : '👎') + '</span> ' : '')) +
|
|
73
|
+
((item.status === 'pending' || item.status === 'dispatched' || item.status === 'queued' || item.status === 'failed' || item.status === 'needs-human-review') ? '<button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:var(--orange);border-color:var(--orange);margin-right:4px" onclick="event.stopPropagation();cancelWorkItem(\'' + escHtml(item.id) + '\',\'' + escHtml(item._source || '') + '\')" title="Cancel work item and kill agent">🚫</button>' : '') +
|
|
73
74
|
'<button class="pr-pager-btn" style="font-size:9px;padding:1px 6px;color:var(--red);border-color:var(--red)" onclick="event.stopPropagation();deleteWorkItem(\'' + escHtml(item.id) + '\',\'' + escHtml(item._source || '') + '\')" title="Delete work item and kill agent">✕</button>' +
|
|
74
75
|
'</td>' +
|
|
75
76
|
'</tr>';
|
|
@@ -209,6 +210,21 @@ async function deleteWorkItem(id, source) {
|
|
|
209
210
|
} catch (e) { showToast('cmd-toast', 'Delete error: ' + e.message, false); refresh(); }
|
|
210
211
|
}
|
|
211
212
|
|
|
213
|
+
async function cancelWorkItem(id, source) {
|
|
214
|
+
if (!confirm('Cancel work item ' + id + '? This will kill any running agent and mark it cancelled.')) return;
|
|
215
|
+
markDeleted('wi:' + id);
|
|
216
|
+
var wiTable = document.getElementById('work-items-content');
|
|
217
|
+
(wiTable || document).querySelectorAll('tr').forEach(function(r) { if (r.textContent.includes(id)) r.remove(); });
|
|
218
|
+
showToast('cmd-toast', 'Work item cancelled', true);
|
|
219
|
+
try {
|
|
220
|
+
const res = await fetch('/api/work-items/cancel', {
|
|
221
|
+
method: 'POST', headers: { 'Content-Type': 'application/json' },
|
|
222
|
+
body: JSON.stringify({ id, source: source || undefined })
|
|
223
|
+
});
|
|
224
|
+
if (!res.ok) { const d = await res.json().catch(() => ({})); showToast('cmd-toast', 'Cancel failed: ' + (d.error || 'unknown'), false); refresh(); }
|
|
225
|
+
} catch (e) { showToast('cmd-toast', 'Cancel error: ' + e.message, false); refresh(); }
|
|
226
|
+
}
|
|
227
|
+
|
|
212
228
|
async function archiveWorkItem(id, source) {
|
|
213
229
|
markDeleted('wi:' + id);
|
|
214
230
|
var wiTable = document.getElementById('work-items-content');
|
package/dashboard.js
CHANGED
|
@@ -607,6 +607,65 @@ function parseCCActions(text) {
|
|
|
607
607
|
return { text: displayText, actions };
|
|
608
608
|
}
|
|
609
609
|
|
|
610
|
+
// ── /loop → create-watch safety net ──────────────────────────────────────────
|
|
611
|
+
// CC sometimes invokes the /loop skill instead of emitting a create-watch action.
|
|
612
|
+
// This pure function detects /loop invocation in CC response text and synthesizes
|
|
613
|
+
// a create-watch action as a fallback. Returns null if no conversion needed.
|
|
614
|
+
|
|
615
|
+
function _detectLoopInvocation(text, actions) {
|
|
616
|
+
if (!text) return null;
|
|
617
|
+
// If a create-watch action was already emitted, no fallback needed
|
|
618
|
+
if (actions && actions.some(a => a.type === 'create-watch')) return null;
|
|
619
|
+
|
|
620
|
+
// Check for /loop invocation patterns in CC response
|
|
621
|
+
const loopPatterns = [
|
|
622
|
+
/\/loop\b/i,
|
|
623
|
+
/\bloop skill\b/i,
|
|
624
|
+
/\bSkill.*\bloop\b/i,
|
|
625
|
+
/\bstarted.*\bloop\b/i,
|
|
626
|
+
/\bmonitoring.*\bloop\b/i,
|
|
627
|
+
/\binvok(?:e|ed|ing).*\bloop\b/i,
|
|
628
|
+
];
|
|
629
|
+
if (!loopPatterns.some(p => p.test(text))) return null;
|
|
630
|
+
|
|
631
|
+
// Extract target — PR number or work item ID
|
|
632
|
+
const prMatch = text.match(/\bPR[- #]?(\d+)\b/i) || text.match(/\bpull[- ]request[- #]?(\d+)/i);
|
|
633
|
+
const wiMatch = text.match(/\bW-([a-z0-9]+)\b/);
|
|
634
|
+
|
|
635
|
+
let target = null, targetType = 'pr';
|
|
636
|
+
if (prMatch) {
|
|
637
|
+
target = prMatch[1];
|
|
638
|
+
targetType = 'pr';
|
|
639
|
+
} else if (wiMatch) {
|
|
640
|
+
target = 'W-' + wiMatch[1];
|
|
641
|
+
targetType = 'work-item';
|
|
642
|
+
}
|
|
643
|
+
if (!target) return null; // Can't synthesize without a target
|
|
644
|
+
|
|
645
|
+
// Extract interval (e.g. "every 15 minutes", "every 5m")
|
|
646
|
+
const intervalMatch = text.match(/every\s+(\d+)\s*(s|sec|seconds?|m|min|minutes?|h|hr|hours?)\b/i);
|
|
647
|
+
let interval = '5m';
|
|
648
|
+
if (intervalMatch) interval = intervalMatch[1] + intervalMatch[2][0];
|
|
649
|
+
|
|
650
|
+
// Infer condition from keywords
|
|
651
|
+
let condition = 'any';
|
|
652
|
+
if (/\bbuild\b/i.test(text) && /\b(?:pass(?:es|ing|ed)?|green|succeed(?:s|ed)?|success)\b/i.test(text)) condition = 'build-pass';
|
|
653
|
+
else if (/\bbuild\b/i.test(text) && /\b(?:fail(?:s|ing|ed)?|red|broken|broke)\b/i.test(text)) condition = 'build-fail';
|
|
654
|
+
else if (/\bmerge[d]?\b/i.test(text)) condition = 'merged';
|
|
655
|
+
else if (/\bcomplete[d]?\b/i.test(text)) condition = 'completed';
|
|
656
|
+
|
|
657
|
+
return {
|
|
658
|
+
type: 'create-watch',
|
|
659
|
+
target,
|
|
660
|
+
targetType,
|
|
661
|
+
condition,
|
|
662
|
+
interval,
|
|
663
|
+
owner: 'human',
|
|
664
|
+
description: 'Auto-converted from /loop invocation',
|
|
665
|
+
stopAfter: condition === 'any' ? 0 : 1,
|
|
666
|
+
};
|
|
667
|
+
}
|
|
668
|
+
|
|
610
669
|
// ── Server-side CC action execution ──────────────────────────────────────────
|
|
611
670
|
// Actions are executed server-side so all clients (frontend, curl, Teams) get the same behavior.
|
|
612
671
|
// The frontend still shows status toasts but no longer needs to fire the API calls.
|
|
@@ -1419,6 +1478,63 @@ const server = http.createServer(async (req, res) => {
|
|
|
1419
1478
|
} catch (e) { return jsonReply(res, 400, { error: e.message }); }
|
|
1420
1479
|
}
|
|
1421
1480
|
|
|
1481
|
+
async function handleWorkItemsCancel(req, res) {
|
|
1482
|
+
try {
|
|
1483
|
+
const body = await readBody(req);
|
|
1484
|
+
const { id, source, reason } = body;
|
|
1485
|
+
if (!id) return jsonReply(res, 400, { error: 'id required' });
|
|
1486
|
+
|
|
1487
|
+
// Find the right work-items file
|
|
1488
|
+
let wiPath;
|
|
1489
|
+
if (!source || source === 'central') {
|
|
1490
|
+
wiPath = path.join(MINIONS_DIR, 'work-items.json');
|
|
1491
|
+
} else {
|
|
1492
|
+
const proj = PROJECTS.find(p => p.name === source);
|
|
1493
|
+
if (proj) wiPath = shared.projectWorkItemsPath(proj);
|
|
1494
|
+
}
|
|
1495
|
+
if (!wiPath) return jsonReply(res, 404, { error: 'source not found' });
|
|
1496
|
+
|
|
1497
|
+
let result = null;
|
|
1498
|
+
mutateJsonFileLocked(wiPath, (items) => {
|
|
1499
|
+
if (!Array.isArray(items)) items = [];
|
|
1500
|
+
const item = items.find(i => i.id === id);
|
|
1501
|
+
if (!item) { result = { code: 404, body: { error: 'item not found' } }; return items; }
|
|
1502
|
+
// Reject already-done or already-cancelled items
|
|
1503
|
+
if (DONE_STATUSES.has(item.status) || item.status === WI_STATUS.CANCELLED) {
|
|
1504
|
+
result = { code: 400, body: { error: 'cannot cancel item with status: ' + item.status } };
|
|
1505
|
+
return items;
|
|
1506
|
+
}
|
|
1507
|
+
item.status = WI_STATUS.CANCELLED;
|
|
1508
|
+
item._cancelledBy = reason || 'user';
|
|
1509
|
+
item.cancelledAt = new Date().toISOString();
|
|
1510
|
+
result = { code: 200, body: { ok: true, item } };
|
|
1511
|
+
return items;
|
|
1512
|
+
});
|
|
1513
|
+
if (!result) return jsonReply(res, 500, { error: 'unexpected state' });
|
|
1514
|
+
if (result.code !== 200) return jsonReply(res, result.code, result.body);
|
|
1515
|
+
|
|
1516
|
+
// Clean dispatch entries + kill running agent (outside lock)
|
|
1517
|
+
const dispatchRemoved = cleanDispatchEntries(d =>
|
|
1518
|
+
d.meta?.item?.id === id ||
|
|
1519
|
+
d.meta?.dispatchKey?.endsWith(id)
|
|
1520
|
+
);
|
|
1521
|
+
|
|
1522
|
+
// Clean cooldown entries
|
|
1523
|
+
try {
|
|
1524
|
+
const cooldownPath = path.join(MINIONS_DIR, 'engine', 'cooldowns.json');
|
|
1525
|
+
const cooldowns = safeJsonObj(cooldownPath);
|
|
1526
|
+
let cleaned = false;
|
|
1527
|
+
for (const key of Object.keys(cooldowns)) {
|
|
1528
|
+
if (key.includes(id)) { delete cooldowns[key]; cleaned = true; }
|
|
1529
|
+
}
|
|
1530
|
+
if (cleaned) safeWrite(cooldownPath, cooldowns);
|
|
1531
|
+
} catch (e) { console.error('cooldown cleanup on cancel:', e.message); }
|
|
1532
|
+
|
|
1533
|
+
invalidateStatusCache();
|
|
1534
|
+
return jsonReply(res, 200, { ok: true, id, dispatchRemoved });
|
|
1535
|
+
} catch (e) { return jsonReply(res, 400, { error: e.message }); }
|
|
1536
|
+
}
|
|
1537
|
+
|
|
1422
1538
|
async function handleWorkItemsArchive(req, res) {
|
|
1423
1539
|
try {
|
|
1424
1540
|
const body = await readBody(req);
|
|
@@ -3532,6 +3648,13 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
3532
3648
|
}
|
|
3533
3649
|
|
|
3534
3650
|
const parsed = parseCCActions(result.text);
|
|
3651
|
+
// Safety net: detect /loop invocation and convert to create-watch
|
|
3652
|
+
const _loopWatch = _detectLoopInvocation(parsed.text, parsed.actions);
|
|
3653
|
+
if (_loopWatch) {
|
|
3654
|
+
parsed.actions.push(_loopWatch);
|
|
3655
|
+
console.warn('[CC] /loop invocation detected — converted to create-watch');
|
|
3656
|
+
try { shared.log('warn', '/loop invocation detected in CC response — auto-converted to create-watch'); } catch {}
|
|
3657
|
+
}
|
|
3535
3658
|
if (parsed.actions.length > 0) {
|
|
3536
3659
|
parsed.actionResults = await executeCCActions(parsed.actions);
|
|
3537
3660
|
}
|
|
@@ -3694,6 +3817,13 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
3694
3817
|
|
|
3695
3818
|
// Send final result with actions — execute server-side first
|
|
3696
3819
|
const { text: displayText, actions } = parseCCActions(result.text);
|
|
3820
|
+
// Safety net: detect /loop invocation and convert to create-watch
|
|
3821
|
+
const _loopWatch = _detectLoopInvocation(displayText, actions);
|
|
3822
|
+
if (_loopWatch) {
|
|
3823
|
+
actions.push(_loopWatch);
|
|
3824
|
+
console.warn('[CC] /loop invocation detected — converted to create-watch');
|
|
3825
|
+
try { shared.log('warn', '/loop invocation detected in CC response — auto-converted to create-watch'); } catch {}
|
|
3826
|
+
}
|
|
3697
3827
|
let actionResults;
|
|
3698
3828
|
if (actions.length > 0) {
|
|
3699
3829
|
actionResults = await executeCCActions(actions);
|
|
@@ -4244,6 +4374,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
4244
4374
|
{ method: 'POST', path: '/api/work-items/update', desc: 'Edit a pending/failed work item', params: 'id, source?, title?, description?, type?, priority?, agent?, references?, acceptanceCriteria?', handler: handleWorkItemsUpdate },
|
|
4245
4375
|
{ method: 'POST', path: '/api/work-items/retry', desc: 'Reset a failed/dispatched item to pending', params: 'id, source?', handler: handleWorkItemsRetry },
|
|
4246
4376
|
{ method: 'POST', path: '/api/work-items/delete', desc: 'Remove a work item, kill agent, clear dispatch', params: 'id, source?', handler: handleWorkItemsDelete },
|
|
4377
|
+
{ method: 'POST', path: '/api/work-items/cancel', desc: 'Cancel a work item, kill agent, clear dispatch', params: 'id, source?, reason?', handler: handleWorkItemsCancel },
|
|
4247
4378
|
{ method: 'POST', path: '/api/work-items/archive', desc: 'Move a completed/failed work item to archive', params: 'id, source?', handler: handleWorkItemsArchive },
|
|
4248
4379
|
{ method: 'GET', path: '/api/work-items/archive', desc: 'List archived work items', handler: handleWorkItemsArchiveList },
|
|
4249
4380
|
{ method: 'POST', path: '/api/work-items/reopen', desc: 'Reopen a done/failed work item back to pending', params: 'id, project?, description?', handler: handleWorkItemsReopen },
|
package/engine/ado.js
CHANGED
|
@@ -16,6 +16,10 @@ function engine() {
|
|
|
16
16
|
return _engine;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
// Lazy require for dispatch module (avoids circular dependency via engine)
|
|
20
|
+
let _dispatch = null;
|
|
21
|
+
function dispatchModule() { if (!_dispatch) _dispatch = require('./dispatch'); return _dispatch; }
|
|
22
|
+
|
|
19
23
|
const stripRefsHeads = s => (s || '').replace('refs/heads/', '');
|
|
20
24
|
const getAdoPrUrl = (project, prNumber) => {
|
|
21
25
|
if (project.prUrlBase) return `${project.prUrlBase}${prNumber}`;
|
|
@@ -346,6 +350,10 @@ async function pollPrStatus(config) {
|
|
|
346
350
|
delete pr.buildFixAttempts;
|
|
347
351
|
delete pr.buildFixEscalated;
|
|
348
352
|
}
|
|
353
|
+
// Cancel any pending review/fix dispatches — they're stale now that the PR is closed
|
|
354
|
+
try {
|
|
355
|
+
dispatchModule().cancelPendingDispatchesForPr(pr.id);
|
|
356
|
+
} catch (e) { log('warn', `Cancel dispatches for ${pr.id}: ${e.message}`); }
|
|
349
357
|
await engine().handlePostMerge(pr, project, config, newStatus);
|
|
350
358
|
}
|
|
351
359
|
}
|
package/engine/cleanup.js
CHANGED
|
@@ -8,7 +8,7 @@ const path = require('path');
|
|
|
8
8
|
const shared = require('./shared');
|
|
9
9
|
const queries = require('./queries');
|
|
10
10
|
|
|
11
|
-
const { exec, execSilent, log, ts } = shared;
|
|
11
|
+
const { exec, execSilent, log, ts, ENGINE_DEFAULTS } = shared;
|
|
12
12
|
const { safeJson, safeWrite, safeReadDir, mutateWorkItems, getProjects, projectWorkItemsPath, projectPrPath,
|
|
13
13
|
sanitizeBranch, KB_CATEGORIES } = shared;
|
|
14
14
|
const { getDispatch, getAgentStatus } = queries;
|
|
@@ -597,11 +597,23 @@ function runCleanup(config, verbose = false) {
|
|
|
597
597
|
} catch (e) { log('warn', 'prune doc-sessions: ' + e.message); }
|
|
598
598
|
|
|
599
599
|
// 11. Cap cooldowns.json — keep at most 500 entries (on top of 24h TTL in cooldown.js)
|
|
600
|
+
// Also trim pendingContexts arrays to ENGINE_DEFAULTS.maxPendingContexts to prevent bloat.
|
|
600
601
|
cleaned.cooldowns = 0;
|
|
602
|
+
cleaned.pendingContextsTrimmed = 0;
|
|
601
603
|
try {
|
|
602
604
|
const cooldownPath = path.join(ENGINE_DIR, 'cooldowns.json');
|
|
603
605
|
const cooldowns = safeJson(cooldownPath);
|
|
604
606
|
if (cooldowns && typeof cooldowns === 'object') {
|
|
607
|
+
let dirty = false;
|
|
608
|
+
// Trim oversized pendingContexts arrays (one-time migration + ongoing cap)
|
|
609
|
+
const pendingCtxCap = ENGINE_DEFAULTS.maxPendingContexts;
|
|
610
|
+
for (const v of Object.values(cooldowns)) {
|
|
611
|
+
if (Array.isArray(v.pendingContexts) && v.pendingContexts.length > pendingCtxCap) {
|
|
612
|
+
v.pendingContexts = v.pendingContexts.slice(-pendingCtxCap);
|
|
613
|
+
cleaned.pendingContextsTrimmed++;
|
|
614
|
+
dirty = true;
|
|
615
|
+
}
|
|
616
|
+
}
|
|
605
617
|
const entries = Object.entries(cooldowns);
|
|
606
618
|
const COOLDOWN_CAP = 500;
|
|
607
619
|
if (entries.length > COOLDOWN_CAP) {
|
|
@@ -610,6 +622,8 @@ function runCleanup(config, verbose = false) {
|
|
|
610
622
|
const keep = Object.fromEntries(entries.slice(0, COOLDOWN_CAP));
|
|
611
623
|
cleaned.cooldowns = entries.length - COOLDOWN_CAP;
|
|
612
624
|
safeWrite(cooldownPath, keep);
|
|
625
|
+
} else if (dirty) {
|
|
626
|
+
safeWrite(cooldownPath, cooldowns);
|
|
613
627
|
}
|
|
614
628
|
}
|
|
615
629
|
} catch (e) { log('warn', 'cap cooldowns: ' + e.message); }
|
|
@@ -653,8 +667,8 @@ function runCleanup(config, verbose = false) {
|
|
|
653
667
|
}
|
|
654
668
|
} catch { /* optional — file may not exist */ }
|
|
655
669
|
|
|
656
|
-
if (cleaned.ccSessions + cleaned.docSessions + cleaned.cooldowns + cleaned.pidFiles > 0) {
|
|
657
|
-
log('info', `Cleanup (resources): ${cleaned.ccSessions} cc-sessions, ${cleaned.docSessions} doc-sessions, ${cleaned.cooldowns} cooldowns, ${cleaned.pidFiles} PID files`);
|
|
670
|
+
if (cleaned.ccSessions + cleaned.docSessions + cleaned.cooldowns + cleaned.pidFiles + cleaned.pendingContextsTrimmed > 0) {
|
|
671
|
+
log('info', `Cleanup (resources): ${cleaned.ccSessions} cc-sessions, ${cleaned.docSessions} doc-sessions, ${cleaned.cooldowns} cooldowns, ${cleaned.pendingContextsTrimmed} pendingCtx trimmed, ${cleaned.pidFiles} PID files`);
|
|
658
672
|
}
|
|
659
673
|
|
|
660
674
|
return cleaned;
|
package/engine/cooldown.js
CHANGED
|
@@ -7,7 +7,7 @@ const path = require('path');
|
|
|
7
7
|
const shared = require('./shared');
|
|
8
8
|
const queries = require('./queries');
|
|
9
9
|
|
|
10
|
-
const { safeJson, safeWrite, log } = shared;
|
|
10
|
+
const { safeJson, safeWrite, log, ENGINE_DEFAULTS } = shared;
|
|
11
11
|
const { ENGINE_DIR } = queries;
|
|
12
12
|
|
|
13
13
|
const COOLDOWN_PATH = path.join(ENGINE_DIR, 'cooldowns.json');
|
|
@@ -37,6 +37,13 @@ function saveCooldowns() {
|
|
|
37
37
|
for (const [k, v] of dispatchCooldowns) {
|
|
38
38
|
if (now - v.timestamp > 24 * 60 * 60 * 1000) dispatchCooldowns.delete(k);
|
|
39
39
|
}
|
|
40
|
+
// Trim pendingContexts arrays before writing to prevent bloat
|
|
41
|
+
const cap = ENGINE_DEFAULTS.maxPendingContexts;
|
|
42
|
+
for (const [, v] of dispatchCooldowns) {
|
|
43
|
+
if (Array.isArray(v.pendingContexts) && v.pendingContexts.length > cap) {
|
|
44
|
+
v.pendingContexts = v.pendingContexts.slice(-cap);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
40
47
|
const obj = Object.fromEntries(dispatchCooldowns);
|
|
41
48
|
try {
|
|
42
49
|
safeWrite(COOLDOWN_PATH, obj);
|
|
@@ -61,8 +68,11 @@ function setCooldown(key) {
|
|
|
61
68
|
|
|
62
69
|
function setCooldownWithContext(key, context) {
|
|
63
70
|
const existing = dispatchCooldowns.get(key);
|
|
64
|
-
|
|
71
|
+
let pendingContexts = existing?.pendingContexts || [];
|
|
65
72
|
if (context) pendingContexts.push(context);
|
|
73
|
+
// Cap to last N entries to prevent unbounded growth (cooldowns.json bloat)
|
|
74
|
+
const cap = ENGINE_DEFAULTS.maxPendingContexts;
|
|
75
|
+
if (pendingContexts.length > cap) pendingContexts = pendingContexts.slice(-cap);
|
|
66
76
|
dispatchCooldowns.set(key, {
|
|
67
77
|
timestamp: Date.now(),
|
|
68
78
|
failures: existing?.failures || 0,
|
package/engine/dispatch.js
CHANGED
|
@@ -278,6 +278,31 @@ function updateAgentStatus(dispatchId, status, detail) {
|
|
|
278
278
|
});
|
|
279
279
|
}
|
|
280
280
|
|
|
281
|
+
// ─── Cancel Pending Dispatches for Closed PR ───────────────────────────────
|
|
282
|
+
|
|
283
|
+
/**
|
|
284
|
+
* Cancel all pending dispatch entries that reference a specific PR.
|
|
285
|
+
* Called when a PR transitions to merged/abandoned/closed — any pending
|
|
286
|
+
* review, fix, or re-review dispatches for that PR are stale and should
|
|
287
|
+
* not be spawned.
|
|
288
|
+
* @param {string} prId — PR identifier (e.g. 'PR-100')
|
|
289
|
+
* @returns {number} count of cancelled entries
|
|
290
|
+
*/
|
|
291
|
+
function cancelPendingDispatchesForPr(prId) {
|
|
292
|
+
if (!prId) return 0;
|
|
293
|
+
let cancelled = 0;
|
|
294
|
+
mutateDispatch((dispatch) => {
|
|
295
|
+
const before = dispatch.pending.length;
|
|
296
|
+
dispatch.pending = dispatch.pending.filter(d => d.meta?.pr?.id !== prId);
|
|
297
|
+
cancelled = before - dispatch.pending.length;
|
|
298
|
+
return dispatch;
|
|
299
|
+
});
|
|
300
|
+
if (cancelled > 0) {
|
|
301
|
+
log('info', `Cancelled ${cancelled} pending dispatch(es) for closed PR ${prId}`);
|
|
302
|
+
}
|
|
303
|
+
return cancelled;
|
|
304
|
+
}
|
|
305
|
+
|
|
281
306
|
// ─── Exports ─────────────────────────────────────────────────────────────────
|
|
282
307
|
|
|
283
308
|
module.exports = {
|
|
@@ -287,4 +312,5 @@ module.exports = {
|
|
|
287
312
|
completeDispatch,
|
|
288
313
|
writeInboxAlert,
|
|
289
314
|
updateAgentStatus,
|
|
315
|
+
cancelPendingDispatchesForPr,
|
|
290
316
|
};
|
package/engine/github.js
CHANGED
|
@@ -16,6 +16,10 @@ function engine() {
|
|
|
16
16
|
return _engine;
|
|
17
17
|
}
|
|
18
18
|
|
|
19
|
+
// Lazy require for dispatch module (avoids circular dependency via engine)
|
|
20
|
+
let _dispatch = null;
|
|
21
|
+
function dispatchModule() { if (!_dispatch) _dispatch = require('./dispatch'); return _dispatch; }
|
|
22
|
+
|
|
19
23
|
// ─── Helpers ────────────────────────────────────────────────────────────────
|
|
20
24
|
|
|
21
25
|
function isGitHub(project) {
|
|
@@ -327,6 +331,10 @@ async function pollPrStatus(config) {
|
|
|
327
331
|
delete pr.buildFixAttempts;
|
|
328
332
|
delete pr.buildFixEscalated;
|
|
329
333
|
}
|
|
334
|
+
// Cancel any pending review/fix dispatches — they're stale now that the PR is closed
|
|
335
|
+
try {
|
|
336
|
+
dispatchModule().cancelPendingDispatchesForPr(pr.id);
|
|
337
|
+
} catch (e) { log('warn', `Cancel dispatches for ${pr.id}: ${e.message}`); }
|
|
330
338
|
await engine().handlePostMerge(pr, project, config, newStatus);
|
|
331
339
|
}
|
|
332
340
|
}
|
package/engine/lifecycle.js
CHANGED
|
@@ -814,6 +814,8 @@ function syncPrsFromOutput(output, agentId, meta, config) {
|
|
|
814
814
|
});
|
|
815
815
|
}
|
|
816
816
|
|
|
817
|
+
const entryBranch = meta?.branch || '';
|
|
818
|
+
|
|
817
819
|
for (const [prPath, { name, project: targetProject, entries }] of newPrsByPath) {
|
|
818
820
|
const linksToPersist = [];
|
|
819
821
|
mutateJsonFileLocked(prPath, (data) => {
|
|
@@ -824,6 +826,28 @@ function syncPrsFromOutput(output, agentId, meta, config) {
|
|
|
824
826
|
}
|
|
825
827
|
for (const { prId, fullId, entry } of entries) {
|
|
826
828
|
if (prs.some(p => p.id === fullId || (p.url && p.url === entry.url))) continue;
|
|
829
|
+
|
|
830
|
+
// Branch-level dedup: skip if an active PR already exists on the same branch.
|
|
831
|
+
// This prevents duplicate PRs when an agent retries and calls `gh pr create` again
|
|
832
|
+
// on the same branch (GitHub allows multiple PRs from one branch).
|
|
833
|
+
// Only block when the existing PR is active — abandoned/merged PRs don't conflict.
|
|
834
|
+
const branch = entry.branch || entryBranch;
|
|
835
|
+
if (branch) {
|
|
836
|
+
const existingOnBranch = prs.find(p => p.branch === branch && p.status === PR_STATUS.ACTIVE && p.id !== fullId);
|
|
837
|
+
if (existingOnBranch) {
|
|
838
|
+
log('warn', `Duplicate PR detected: ${fullId} on branch ${branch} — already tracked as ${existingOnBranch.id}. Skipping.`);
|
|
839
|
+
// Best-effort close the duplicate on GitHub (non-blocking, fire-and-forget)
|
|
840
|
+
try {
|
|
841
|
+
const ghSlug = output.match(/github\.com\/([^/]+\/[^/]+)/)?.[1];
|
|
842
|
+
if (ghSlug) {
|
|
843
|
+
execAsync(`gh pr close ${prId} --repo ${ghSlug} --comment "Closing duplicate — ${existingOnBranch.id} already tracks this branch."`, { timeout: 15000 })
|
|
844
|
+
.catch(() => {});
|
|
845
|
+
}
|
|
846
|
+
} catch { /* best-effort */ }
|
|
847
|
+
continue;
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
|
|
827
851
|
prs.push(entry);
|
|
828
852
|
if (meta?.item?.id) {
|
|
829
853
|
linksToPersist.push({ prId: fullId, itemId: meta.item.id, project: targetProject, prNumber: entry.prNumber, url: entry.url });
|
package/engine/shared.js
CHANGED
|
@@ -569,6 +569,7 @@ const ENGINE_DEFAULTS = {
|
|
|
569
569
|
ccModel: 'sonnet', // model for Command Center and doc-chat (sonnet, haiku, opus)
|
|
570
570
|
ccEffort: null, // effort level for CC/doc-chat (null, 'low', 'medium', 'high')
|
|
571
571
|
heartbeatTimeouts: {}, // populated after WORK_TYPE is defined (below)
|
|
572
|
+
maxPendingContexts: 20, // cap pendingContexts arrays in cooldowns.json to prevent unbounded growth
|
|
572
573
|
ccMaxTurns: 50, // max tool-use turns for CC/doc-chat before CLI stops
|
|
573
574
|
// Teams integration — config.teams shape: { enabled, appId, appPassword, certPath, privateKeyPath, tenantId, notifyEvents, ccMirror, inboxPollInterval }
|
|
574
575
|
// Auth modes: (1) appId + appPassword (client secret), or (2) appId + certPath + privateKeyPath + tenantId (certificate)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1007",
|
|
4
4
|
"description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
|
|
5
5
|
"bin": {
|
|
6
6
|
"minions": "bin/minions.js"
|
package/prompts/cc-system.md
CHANGED
|
@@ -91,11 +91,14 @@ Additional actions (all take `id` or `file` as primary key):
|
|
|
91
91
|
- Schedules: schedule (id, title, cron, workType, project, agent, description, priority, enabled), delete-schedule (id)
|
|
92
92
|
- Pipelines: create-pipeline (id, title, stages[], trigger, stopWhen, monitoredResources), edit-pipeline (id, title, stages, trigger), delete-pipeline (id), trigger-pipeline (id), abort-pipeline (id), retrigger-pipeline (id)
|
|
93
93
|
- Meetings: add-meeting-note (id, note), advance-meeting (id), end-meeting (id), archive-meeting (id), unarchive-meeting (id), delete-meeting (id)
|
|
94
|
-
- Work item ops: delete-work-item (id, source), archive-work-item (id), work-item-feedback (id, rating: up/down, comment), reopen-work-item (id, project[, description] — reopen a done/failed item back to pending)
|
|
94
|
+
- Work item ops: delete-work-item (id, source), cancel-work-item (id, source?, reason? — cancel a pending/dispatched/failed item, kills running agent), archive-work-item (id), work-item-feedback (id, rating: up/down, comment), reopen-work-item (id, project[, description] — reopen a done/failed item back to pending)
|
|
95
95
|
- PRD ops: edit-prd-item, remove-prd-item, reopen-prd-item (id, file — re-dispatches on existing branch)
|
|
96
96
|
- **create-watch**: target, targetType (pr/work-item), condition (merged/build-fail/build-pass/completed/failed/status-change/any/new-comments/vote-change), interval ("15m", "1h", "30s" — default "5m"), owner (agent id or "human"), description, project, stopAfter (0=run forever, 1=expire on first match, N=expire after N matches), onNotMet (null=do nothing, "notify"=write to inbox each poll while condition not met)
|
|
97
|
+
**NEVER use the /loop skill for monitoring tasks.** Always use the `create-watch` action — it persists across engine restarts and appears in the Watches page. /loop runs ephemerally in the session and leaves no trace.
|
|
98
|
+
Trigger phrases: user says "keep an eye on X", "watch X every N min", "monitor X", "check X periodically", "ping me when X" → always emit `create-watch`.
|
|
97
99
|
Example: user says "check PR 1065 build every 15 min until green" → `{"type":"create-watch","target":"1065","targetType":"pr","condition":"build-pass","interval":"15m","stopAfter":1,"description":"Watch PR 1065 build until green"}`
|
|
98
100
|
Example: user says "ping me every 15 min while build is still failing" → `{"type":"create-watch","target":"1065","targetType":"pr","condition":"build-pass","interval":"15m","stopAfter":1,"onNotMet":"notify","description":"Watch PR 1065 build — notify each poll"}`
|
|
101
|
+
Example: user says "keep an eye on PR 200 every 5 min" → `{"type":"create-watch","target":"200","targetType":"pr","condition":"any","interval":"5m","stopAfter":0,"description":"Monitor PR 200"}`
|
|
99
102
|
- **delete-watch**: id — remove a watch permanently
|
|
100
103
|
Example: user says "stop watching PR 1065" → `{"type":"delete-watch","id":"watch-abc123"}`
|
|
101
104
|
- **pause-watch**: id — pause a watch without deleting it (can be resumed later)
|