@yemi33/minions 0.1.550 → 0.1.552
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 +10 -0
- package/dashboard/js/command-center.js +37 -0
- package/dashboard/js/render-inbox.js +9 -1
- package/dashboard/js/render-kb.js +45 -20
- package/dashboard/js/render-prs.js +2 -2
- package/dashboard/js/utils.js +40 -6
- package/dashboard/styles.css +6 -0
- package/docs/design-state-storage.md +431 -0
- package/engine/ado.js +34 -0
- package/engine.js +3 -2
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,15 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.552 (2026-04-07)
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
- temporary pin-to-top for inbox and KB items
|
|
7
|
+
|
|
8
|
+
## 0.1.551 (2026-04-07)
|
|
9
|
+
|
|
10
|
+
### Fixes
|
|
11
|
+
- mark stale build status on ADO auth failure, bypass 6-tick cadence for recovery (#483)
|
|
12
|
+
|
|
3
13
|
## 0.1.550 (2026-04-07)
|
|
4
14
|
|
|
5
15
|
### Fixes
|
|
@@ -9,6 +9,23 @@ let _ccAbortController = null;
|
|
|
9
9
|
// Clear stale sending state on page load — SSE streams don't survive refresh
|
|
10
10
|
try { localStorage.removeItem('cc-sending'); } catch {}
|
|
11
11
|
|
|
12
|
+
function _ccFindPinTarget(query) {
|
|
13
|
+
for (var i = 0; i < (inboxData || []).length; i++) {
|
|
14
|
+
if (inboxData[i].name.toLowerCase().includes(query)) {
|
|
15
|
+
return { key: inboxPinKey(inboxData[i].name), label: inboxData[i].name };
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
for (var [cat, items] of Object.entries(_kbData || {})) {
|
|
19
|
+
if (!Array.isArray(items)) continue;
|
|
20
|
+
for (var j = 0; j < items.length; j++) {
|
|
21
|
+
if ((items[j].title || '').toLowerCase().includes(query) || (items[j].file || '').toLowerCase().includes(query)) {
|
|
22
|
+
return { key: kbPinKey(cat, items[j].file), label: items[j].title || items[j].file };
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
12
29
|
function ccAbort() {
|
|
13
30
|
if (_ccAbortController) {
|
|
14
31
|
_ccAbortController.abort();
|
|
@@ -165,6 +182,26 @@ function _renderQueueIndicator() {
|
|
|
165
182
|
}
|
|
166
183
|
|
|
167
184
|
async function _ccDoSend(message, skipUserMsg) {
|
|
185
|
+
// Client-side /pin and /unpin — no LLM round-trip needed
|
|
186
|
+
var pinMatch = message.match(/^\/(pin|unpin)\s+(.+)/i);
|
|
187
|
+
if (pinMatch) {
|
|
188
|
+
if (!skipUserMsg) ccAddMessage('user', escHtml(message));
|
|
189
|
+
var pinAction = pinMatch[1].toLowerCase();
|
|
190
|
+
var pinQuery = pinMatch[2].toLowerCase().trim();
|
|
191
|
+
var found = _ccFindPinTarget(pinQuery);
|
|
192
|
+
if (found) {
|
|
193
|
+
var wasPinned = isPinned(found.key);
|
|
194
|
+
if (pinAction === 'pin' && !wasPinned) { togglePin(found.key); ccAddMessage('assistant', 'Pinned <strong>' + escHtml(found.label) + '</strong> to top'); }
|
|
195
|
+
else if (pinAction === 'unpin' && wasPinned) { togglePin(found.key); ccAddMessage('assistant', 'Unpinned <strong>' + escHtml(found.label) + '</strong>'); }
|
|
196
|
+
else { ccAddMessage('assistant', '<strong>' + escHtml(found.label) + '</strong> is already ' + (wasPinned ? 'pinned' : 'unpinned')); }
|
|
197
|
+
showToast('cmd-toast', pinAction === 'pin' ? 'Pinned to top' : 'Unpinned', true);
|
|
198
|
+
renderInbox(inboxData); renderKnowledgeBase();
|
|
199
|
+
} else {
|
|
200
|
+
ccAddMessage('assistant', 'No inbox or KB item matching "' + escHtml(pinQuery) + '"');
|
|
201
|
+
}
|
|
202
|
+
return;
|
|
203
|
+
}
|
|
204
|
+
|
|
168
205
|
_ccSending = true;
|
|
169
206
|
_ccAbortController = new AbortController();
|
|
170
207
|
try { localStorage.setItem('cc-sending', JSON.stringify({ sending: true, startedAt: Date.now() })); } catch {}
|
|
@@ -7,7 +7,12 @@ function _inboxPrev() { if (_inboxPage > 0) { _inboxPage--; renderInbox(inboxDat
|
|
|
7
7
|
function _inboxNext() { _inboxPage++; renderInbox(inboxData); }
|
|
8
8
|
|
|
9
9
|
function renderInbox(inbox) {
|
|
10
|
+
invalidatePinsCache();
|
|
10
11
|
inbox = inbox.filter(function(item) { return !isDeleted('inbox:' + item.name); });
|
|
12
|
+
// Stable sort — pinned items float to top
|
|
13
|
+
inbox.sort(function(a, b) {
|
|
14
|
+
return (isPinned(inboxPinKey(a.name)) ? 0 : 1) - (isPinned(inboxPinKey(b.name)) ? 0 : 1);
|
|
15
|
+
});
|
|
11
16
|
inboxData = inbox;
|
|
12
17
|
const list = document.getElementById('inbox-list');
|
|
13
18
|
const count = document.getElementById('inbox-count');
|
|
@@ -22,12 +27,15 @@ function renderInbox(inbox) {
|
|
|
22
27
|
|
|
23
28
|
list.innerHTML = pageInbox.map((item, i) => {
|
|
24
29
|
const idx = inboxStart + i;
|
|
25
|
-
|
|
30
|
+
const pk = inboxPinKey(item.name);
|
|
31
|
+
const pinned = isPinned(pk);
|
|
32
|
+
return `<div class="inbox-item${pinned ? ' item-pinned' : ''}" data-file="notes/inbox/${escHtml(item.name)}">
|
|
26
33
|
<div class="inbox-name" onclick="openModal(${idx})" style="cursor:pointer">
|
|
27
34
|
<span>${escHtml(item.name)}</span><span>${escHtml(item.age || '')}</span>
|
|
28
35
|
</div>
|
|
29
36
|
<div class="inbox-preview" onclick="openModal(${idx})" style="cursor:pointer">${escHtml(item.content.slice(0,200))}</div>
|
|
30
37
|
<div style="display:flex;gap:6px;margin-top:6px;align-items:center">
|
|
38
|
+
<button class="pr-pager-btn pin-btn${pinned ? ' pinned' : ''}" style="font-size:9px;padding:2px 8px" data-pin-key="${escHtml(pk)}" onclick="event.stopPropagation();_togglePinAndRefresh(this.dataset.pinKey,'inbox')">${pinned ? 'Unpin' : 'Pin'}</button>
|
|
31
39
|
<button class="pr-pager-btn" style="font-size:9px;padding:2px 8px" data-inbox-name="${escHtml(item.name)}" onclick="event.stopPropagation();promoteToKB(this.dataset.inboxName)">Add to Knowledge Base</button>
|
|
32
40
|
<button class="pr-pager-btn" style="font-size:9px;padding:2px 8px" data-inbox-name="${escHtml(item.name)}" onclick="event.stopPropagation();openInboxInExplorer(this.dataset.inboxName)">Open in Explorer</button>
|
|
33
41
|
<button class="pr-pager-btn" style="font-size:9px;padding:2px 8px;color:var(--red)" data-inbox-name="${escHtml(item.name)}" onclick="event.stopPropagation();deleteInboxItem(this.dataset.inboxName)">Delete</button>
|
|
@@ -32,40 +32,61 @@ function renderKnowledgeBase() {
|
|
|
32
32
|
const listEl = document.getElementById('kb-list');
|
|
33
33
|
const countEl = document.getElementById('kb-count');
|
|
34
34
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
35
|
+
invalidatePinsCache();
|
|
36
|
+
|
|
37
|
+
// Single pass: flatten all KB items and count pinned
|
|
38
|
+
const allItems = [];
|
|
39
|
+
let pinnedCount = 0;
|
|
40
|
+
for (const [cat, catItems] of Object.entries(_kbData)) {
|
|
41
|
+
if (!Array.isArray(catItems)) continue;
|
|
42
|
+
for (const item of catItems) {
|
|
43
|
+
const entry = { ...item, category: cat };
|
|
44
|
+
allItems.push(entry);
|
|
45
|
+
if (isPinned(kbPinKey(cat, item.file))) pinnedCount++;
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
countEl.textContent = allItems.length;
|
|
39
49
|
|
|
40
|
-
// Last swept timestamp
|
|
41
50
|
const sweptEl = document.getElementById('kb-swept-time');
|
|
42
51
|
if (sweptEl) sweptEl.textContent = _kbData.lastSwept ? 'swept ' + timeSinceStr(new Date(_kbData.lastSwept)) : '';
|
|
43
52
|
|
|
44
|
-
if (
|
|
53
|
+
if (allItems.length === 0) {
|
|
45
54
|
tabsEl.innerHTML = '';
|
|
46
55
|
listEl.innerHTML = '<p class="empty">No knowledge entries yet. Notes are classified here after consolidation.</p>';
|
|
47
56
|
return;
|
|
48
57
|
}
|
|
49
58
|
|
|
59
|
+
if (_kbActiveTab === 'pinned' && pinnedCount === 0) _kbActiveTab = 'all';
|
|
60
|
+
|
|
50
61
|
// Render tabs
|
|
51
|
-
let tabsHtml = '
|
|
52
|
-
|
|
53
|
-
|
|
62
|
+
let tabsHtml = '';
|
|
63
|
+
if (pinnedCount > 0) {
|
|
64
|
+
tabsHtml += '<button class="kb-tab ' + (_kbActiveTab === 'pinned' ? 'active' : '') + '" style="color:var(--yellow)" onclick="kbSetTab(\'pinned\')">Pinned <span class="badge">' + pinnedCount + '</span></button>';
|
|
65
|
+
}
|
|
66
|
+
tabsHtml += '<button class="kb-tab ' + (_kbActiveTab === 'all' ? 'active' : '') + '" onclick="kbSetTab(\'all\')">All <span class="badge">' + allItems.length + '</span></button>';
|
|
67
|
+
for (const [cat, catArr] of Object.entries(_kbData)) {
|
|
68
|
+
if (!Array.isArray(catArr) || catArr.length === 0) continue;
|
|
54
69
|
const label = KB_CAT_LABELS[cat] || cat;
|
|
55
|
-
tabsHtml += '<button class="kb-tab ' + (_kbActiveTab === cat ? 'active' : '') + '" onclick="kbSetTab(\'' + cat + '\')">' + label + ' <span class="badge">' +
|
|
70
|
+
tabsHtml += '<button class="kb-tab ' + (_kbActiveTab === cat ? 'active' : '') + '" onclick="kbSetTab(\'' + cat + '\')">' + label + ' <span class="badge">' + catArr.length + '</span></button>';
|
|
56
71
|
}
|
|
57
72
|
tabsEl.innerHTML = tabsHtml;
|
|
58
73
|
|
|
59
|
-
//
|
|
60
|
-
let items
|
|
61
|
-
if (_kbActiveTab === '
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
}
|
|
74
|
+
// Filter items for active tab
|
|
75
|
+
let items;
|
|
76
|
+
if (_kbActiveTab === 'pinned') {
|
|
77
|
+
items = allItems.filter(i => isPinned(kbPinKey(i.category, i.file)));
|
|
78
|
+
} else if (_kbActiveTab === 'all') {
|
|
79
|
+
items = allItems.slice();
|
|
66
80
|
items.sort((a, b) => (b.date || '').localeCompare(a.date || ''));
|
|
67
81
|
} else {
|
|
68
|
-
items =
|
|
82
|
+
items = allItems.filter(i => i.category === _kbActiveTab);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Stable sort — pinned items float to top (skip on pinned tab where all are pinned)
|
|
86
|
+
if (_kbActiveTab !== 'pinned') {
|
|
87
|
+
items.sort(function(a, b) {
|
|
88
|
+
return (isPinned(kbPinKey(a.category, a.file)) ? 0 : 1) - (isPinned(kbPinKey(b.category, b.file)) ? 0 : 1);
|
|
89
|
+
});
|
|
69
90
|
}
|
|
70
91
|
|
|
71
92
|
if (items.length === 0) {
|
|
@@ -82,9 +103,13 @@ function renderKnowledgeBase() {
|
|
|
82
103
|
listEl.innerHTML = pageItems.map(item => {
|
|
83
104
|
const icon = KB_CAT_ICONS[item.category] || '\u{1F4C4}';
|
|
84
105
|
const label = KB_CAT_LABELS[item.category] || item.category;
|
|
85
|
-
|
|
106
|
+
var pinKey = kbPinKey(item.category, item.file);
|
|
107
|
+
var pinned = isPinned(pinKey);
|
|
108
|
+
return '<div class="kb-item' + (pinned ? ' item-pinned' : '') + '" data-file="knowledge/' + escHtml(item.category) + '/' + escHtml(item.file) + '" onclick="kbOpenItem(\'' + escHtml(item.category) + '\', \'' + escHtml(item.file) + '\')">' +
|
|
86
109
|
'<div class="kb-item-body">' +
|
|
87
|
-
'<div class="kb-item-title">' + icon + ' ' + escHtml(item.title) +
|
|
110
|
+
'<div class="kb-item-title">' + icon + ' ' + escHtml(item.title) +
|
|
111
|
+
' <button class="pr-pager-btn pin-btn' + (pinned ? ' pinned' : '') + '" style="font-size:9px;padding:1px 6px;margin-left:6px;vertical-align:middle" data-pin-key="' + escHtml(pinKey) + '" onclick="event.stopPropagation();_togglePinAndRefresh(this.dataset.pinKey,\'kb\')">' + (pinned ? 'Unpin' : 'Pin') + '</button>' +
|
|
112
|
+
'</div>' +
|
|
88
113
|
'<div class="kb-item-meta">' +
|
|
89
114
|
'<span>' + label + '</span>' +
|
|
90
115
|
(item.agent ? '<span>' + escHtml(item.agent) + '</span>' : '') +
|
|
@@ -12,8 +12,8 @@ function prRow(pr) {
|
|
|
12
12
|
const reviewSource = sq.status || effectiveReviewStatus || 'pending';
|
|
13
13
|
const reviewClass = reviewSource === 'approved' ? 'approved' : (reviewSource === 'changes-requested' || reviewSource === 'rejected') ? 'rejected' : reviewSource === 'waiting' ? 'building' : 'draft';
|
|
14
14
|
const reviewLabel = sq.status === 'waiting' ? 'reviewing (minions)' : sq.status ? sq.status + ' (minions)' : (effectiveReviewStatus || 'pending');
|
|
15
|
-
const buildClass = pr.buildStatus === 'passing' ? 'build-pass' : pr.buildStatus === 'failing' ? 'build-fail' : pr.buildStatus === 'running' ? 'building' : 'no-build';
|
|
16
|
-
const buildLabel = pr.buildStatus || 'none';
|
|
15
|
+
const buildClass = pr._buildStatusStale ? 'build-stale' : pr.buildStatus === 'passing' ? 'build-pass' : pr.buildStatus === 'failing' ? 'build-fail' : pr.buildStatus === 'running' ? 'building' : 'no-build';
|
|
16
|
+
const buildLabel = (pr.buildStatus || 'none') + (pr._buildStatusStale ? ' (stale)' : '');
|
|
17
17
|
const statusClass = pr.status === 'merged' ? 'merged' : pr.status === 'abandoned' ? 'rejected' : pr.status === 'active' ? 'active' : 'draft';
|
|
18
18
|
const statusLabel = pr.status || 'active';
|
|
19
19
|
const url = pr.url || '#';
|
package/dashboard/js/utils.js
CHANGED
|
@@ -8,6 +8,33 @@ const _deletedIds = new Map(); // key → expiry timestamp
|
|
|
8
8
|
function markDeleted(key) { _deletedIds.set(key, Date.now() + 10000); } // suppress for 10s
|
|
9
9
|
function isDeleted(key) { const exp = _deletedIds.get(key); if (!exp) return false; if (Date.now() > exp) { _deletedIds.delete(key); return false; } return true; }
|
|
10
10
|
|
|
11
|
+
// Temporary pin-to-top — UI-only, stored in localStorage, does not affect agents
|
|
12
|
+
const PINS_KEY = 'minions-pinned-items';
|
|
13
|
+
let _pinsCache = null;
|
|
14
|
+
function invalidatePinsCache() { _pinsCache = null; }
|
|
15
|
+
function getPinnedItems() {
|
|
16
|
+
if (_pinsCache) return _pinsCache;
|
|
17
|
+
try { _pinsCache = JSON.parse(localStorage.getItem(PINS_KEY) || '[]'); } catch { _pinsCache = []; }
|
|
18
|
+
return _pinsCache;
|
|
19
|
+
}
|
|
20
|
+
function isPinned(key) { return getPinnedItems().includes(key); }
|
|
21
|
+
function togglePin(key) {
|
|
22
|
+
const pins = getPinnedItems();
|
|
23
|
+
const idx = pins.indexOf(key);
|
|
24
|
+
if (idx >= 0) pins.splice(idx, 1); else pins.unshift(key);
|
|
25
|
+
localStorage.setItem(PINS_KEY, JSON.stringify(pins));
|
|
26
|
+
invalidatePinsCache();
|
|
27
|
+
return idx < 0; // true if now pinned
|
|
28
|
+
}
|
|
29
|
+
function inboxPinKey(name) { return 'notes/inbox/' + name; }
|
|
30
|
+
function kbPinKey(cat, file) { return 'knowledge/' + cat + '/' + file; }
|
|
31
|
+
function _togglePinAndRefresh(key, source) {
|
|
32
|
+
var pinned = togglePin(key);
|
|
33
|
+
showToast('cmd-toast', pinned ? 'Pinned to top' : 'Unpinned', true);
|
|
34
|
+
if (source === 'inbox') renderInbox(inboxData);
|
|
35
|
+
else if (source === 'kb') renderKnowledgeBase();
|
|
36
|
+
}
|
|
37
|
+
|
|
11
38
|
function escHtml(s) {
|
|
12
39
|
return String(s).replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"').replace(/'/g,''');
|
|
13
40
|
}
|
|
@@ -217,12 +244,19 @@ function _renderMdChunked(fullText) {
|
|
|
217
244
|
var target = pos + MD_CHUNK_SIZE;
|
|
218
245
|
var searchStart = Math.max(pos + Math.floor(MD_CHUNK_SIZE * 0.7), pos);
|
|
219
246
|
var searchEnd = Math.min(target + 500, fullText.length);
|
|
220
|
-
|
|
221
|
-
var
|
|
222
|
-
var
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
247
|
+
// Find the last \n\n in [searchStart, searchEnd] that isn't inside a code fence
|
|
248
|
+
var fencesBefore = (fullText.slice(pos, searchStart).match(/```/g) || []).length;
|
|
249
|
+
var inFence = (fencesBefore % 2) === 1;
|
|
250
|
+
var best = null;
|
|
251
|
+
for (var si = searchStart; si < searchEnd - 1; si++) {
|
|
252
|
+
if (fullText[si] === '`' && fullText[si + 1] === '`' && fullText[si + 2] === '`') {
|
|
253
|
+
inFence = !inFence;
|
|
254
|
+
si += 2;
|
|
255
|
+
} else if (!inFence && fullText[si] === '\n' && fullText[si + 1] === '\n') {
|
|
256
|
+
best = si + 2;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if (best === null) {
|
|
226
260
|
var nl = fullText.indexOf('\n', target);
|
|
227
261
|
best = (nl !== -1 && nl - target < 500) ? nl + 1 : target;
|
|
228
262
|
}
|
package/dashboard/styles.css
CHANGED
|
@@ -154,6 +154,7 @@
|
|
|
154
154
|
.kb-item { display: flex; align-items: flex-start; gap: var(--space-5); padding: var(--space-4) 0; border-bottom: 1px solid var(--border); cursor: pointer; transition: background var(--transition-fast); }
|
|
155
155
|
.kb-item:hover { background: var(--surface2); }
|
|
156
156
|
.kb-item:last-child { border-bottom: none; }
|
|
157
|
+
.kb-item.item-pinned { border-left: 3px solid var(--yellow); padding-left: var(--space-4); background: rgba(210, 153, 34, 0.05); }
|
|
157
158
|
.kb-item-body { flex: 1; min-width: 0; }
|
|
158
159
|
.kb-item-title { font-size: var(--text-md); color: var(--text); font-weight: 500; }
|
|
159
160
|
.kb-item-meta { font-size: var(--text-sm); color: var(--muted); margin-top: var(--space-1); display: flex; gap: var(--space-4); }
|
|
@@ -211,6 +212,10 @@
|
|
|
211
212
|
.notes-preview:hover { border-color: var(--blue); }
|
|
212
213
|
.inbox-item { background: var(--surface2); border: 1px solid var(--border); border-left: 3px solid var(--purple); border-radius: var(--radius-sm); padding: var(--space-5) var(--space-6); cursor: pointer; }
|
|
213
214
|
.inbox-item:hover { border-color: var(--blue); border-left-color: var(--blue); }
|
|
215
|
+
.item-pinned { border-left-color: var(--yellow); background: rgba(210, 153, 34, 0.05); }
|
|
216
|
+
.item-pinned:hover { border-left-color: var(--yellow); }
|
|
217
|
+
.pin-btn { color: var(--muted); border-color: var(--border); }
|
|
218
|
+
.pin-btn.pinned { color: var(--yellow); border-color: var(--yellow); }
|
|
214
219
|
.inbox-name { font-weight: 500; font-size: var(--text-md); color: var(--purple); margin-bottom: var(--space-2); display: flex; justify-content: space-between; }
|
|
215
220
|
.inbox-preview { font-size: var(--text-base); color: var(--muted); line-height: 1.5; max-height: 60px; overflow: hidden; }
|
|
216
221
|
|
|
@@ -251,6 +256,7 @@
|
|
|
251
256
|
.pr-badge.build-pass { background: rgba(63,185,80,0.15); color: var(--green); border: 1px solid var(--green); }
|
|
252
257
|
.pr-badge.build-fail { background: rgba(248,81,73,0.15); color: var(--red); border: 1px solid var(--red); }
|
|
253
258
|
.pr-badge.no-build { background: var(--surface); color: var(--muted); border: 1px solid var(--border); }
|
|
259
|
+
.pr-badge.build-stale { background: rgba(210,153,34,0.15); color: var(--orange); border: 1px dashed var(--orange); }
|
|
254
260
|
.error-details-btn { font-size: var(--text-xs); padding: var(--space-1) var(--space-3); margin-left: var(--space-2); background: rgba(248,81,73,0.15); color: var(--red); border: 1px solid var(--red); border-radius: var(--radius-lg); cursor: pointer; font-weight: 600; text-transform: uppercase; letter-spacing: 0.3px; }
|
|
255
261
|
.error-details-btn:hover { background: rgba(248,81,73,0.3); }
|
|
256
262
|
.pr-empty { color: var(--muted); font-style: italic; font-size: var(--text-md); padding: var(--space-6) 0; }
|
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
# Design: Replacing File-Based State with a Structured Database
|
|
2
|
+
|
|
3
|
+
> Author: Rebecca (Architect) | Date: 2026-04-07 | Status: Proposal
|
|
4
|
+
|
|
5
|
+
## Executive Summary
|
|
6
|
+
|
|
7
|
+
Minions persists all runtime state as flat JSON files guarded by file-lock-based concurrency (`mutateJsonFileLocked`). This analysis evaluates five options for migrating to a structured database, benchmarks each against the current approach, and delivers a phased recommendation.
|
|
8
|
+
|
|
9
|
+
**Verdict:** Stay with improved file-based state short-term. Adopt `node:sqlite` (`DatabaseSync`) as the medium-term target once it exits experimental status, migrating the highest-pain state files first.
|
|
10
|
+
|
|
11
|
+
---
|
|
12
|
+
|
|
13
|
+
## 1. Current State Architecture
|
|
14
|
+
|
|
15
|
+
### 1.1 State Files Inventory
|
|
16
|
+
|
|
17
|
+
| File | Size (live) | Records | Access Pattern | Contention | Pain Level |
|
|
18
|
+
|------|-------------|---------|----------------|------------|------------|
|
|
19
|
+
| `engine/dispatch.json` | 380 KB | 102 completed + 2 active | R/W every tick; 10+ reads/tick (2s cache) | **High** — engine + dashboard + lifecycle | High |
|
|
20
|
+
| `engine/log.json` | 292 KB | 2,162 entries | Append-only (buffered flush every 500ms) | Medium — log buffer serializes | Medium |
|
|
21
|
+
| `engine/cooldowns.json` | 511 KB | 125 keys | R/W on dispatch failure + retry | Low — infrequent writes | High (bloated) |
|
|
22
|
+
| `engine/metrics.json` | 5 KB | Per-agent stats | R/W on PR approval/merge | Low | Low |
|
|
23
|
+
| `engine/control.json` | 169 B | Single object | R/W on start/stop/heartbeat | Low | Low |
|
|
24
|
+
| `projects/*/work-items.json` | 370 KB | 180 items | R/W every 1-2 ticks; dashboard reads on-demand | **High** — engine + lifecycle + dashboard | High |
|
|
25
|
+
| `projects/*/pull-requests.json` | 241 KB | 128 PRs | R/W every 6 ticks (polling); lifecycle writes | Medium | Medium |
|
|
26
|
+
| `engine/pipeline-runs.json` | 36 KB | Pipeline state | R/W on pipeline execution | Low | Low |
|
|
27
|
+
| `engine/schedule-runs.json` | 115 B | Last-run times | R every 10 ticks; W on schedule execution | Low | Low |
|
|
28
|
+
|
|
29
|
+
**Total live state:** ~1.8 MB across 9+ JSON files.
|
|
30
|
+
|
|
31
|
+
(source: `engine/shared.js:233-252` for locking, `engine/queries.js:57-61` for paths, live file sizes from `ls -la engine/*.json`)
|
|
32
|
+
|
|
33
|
+
### 1.2 Concurrency Model
|
|
34
|
+
|
|
35
|
+
All mutations go through `mutateJsonFileLocked()` (source: `engine/shared.js:233-252`):
|
|
36
|
+
|
|
37
|
+
```
|
|
38
|
+
acquire .lock file (exclusive create via fs.openSync 'wx')
|
|
39
|
+
→ read JSON file (full parse)
|
|
40
|
+
→ apply mutation function
|
|
41
|
+
→ write entire file (atomic rename via safeWrite)
|
|
42
|
+
→ create .backup sidecar
|
|
43
|
+
release .lock file
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Key properties:
|
|
47
|
+
- **Synchronous blocking** — `withFileLock` spins with `sleepMs(25)` until lock acquired or 5s timeout (source: `engine/shared.js:175-231`)
|
|
48
|
+
- **Whole-file granularity** — updating one field in one work item rewrites all 180 items (370 KB)
|
|
49
|
+
- **Stale lock recovery** — locks older than 60s are force-removed (source: `engine/shared.js:173`, `LOCK_STALE_MS`)
|
|
50
|
+
- **Read caching** — only `dispatch.json` has a 2s TTL cache (source: `engine/queries.js:82-91`)
|
|
51
|
+
|
|
52
|
+
### 1.3 Read vs Write Ratio
|
|
53
|
+
|
|
54
|
+
| Consumer | Reads/tick | Writes/tick | Pattern |
|
|
55
|
+
|----------|-----------|-------------|---------|
|
|
56
|
+
| Engine tick cycle | ~15 | ~3 | Heavy read, selective write |
|
|
57
|
+
| Dashboard (per page load) | ~8 | 0 | Read-only display |
|
|
58
|
+
| Dashboard (user action) | ~2 | ~2 | Read-modify-write |
|
|
59
|
+
| PR polling (every 6 ticks) | ~4 | ~2 | Batch read-modify-write |
|
|
60
|
+
| Consolidation (every 10 ticks) | ~3 | ~2 | Read inbox files, write notes.md |
|
|
61
|
+
|
|
62
|
+
**Read:write ratio is approximately 8:1.** This strongly favors a system that can serve reads without locking (e.g., WAL mode).
|
|
63
|
+
|
|
64
|
+
---
|
|
65
|
+
|
|
66
|
+
## 2. Option Evaluation
|
|
67
|
+
|
|
68
|
+
### 2.1 `node:sqlite` (DatabaseSync) — Node 22.5+
|
|
69
|
+
|
|
70
|
+
**Current status:** Available in Node v24.12.0 (this machine). Marked `ExperimentalWarning`. Synchronous API via `DatabaseSync`.
|
|
71
|
+
|
|
72
|
+
**Benchmark results** (measured on this machine):
|
|
73
|
+
|
|
74
|
+
| Operation | `node:sqlite` | File-based (current) |
|
|
75
|
+
|-----------|---------------|---------------------|
|
|
76
|
+
| Insert 1,000 rows (transaction) | 4.6 ms | N/A (no equivalent) |
|
|
77
|
+
| Single SELECT by status (200 rows) | 0.13 ms | 2.9 ms (full parse) + 0.06 ms (filter) |
|
|
78
|
+
| 100 individual UPDATEs (no txn) | 48.7 ms | ~270 ms (100x full rewrite) |
|
|
79
|
+
| 100 UPDATEs in transaction | 0.7 ms | N/A |
|
|
80
|
+
| SELECT all 1,000 rows | 0.7 ms | 2.9 ms (parse 370 KB) |
|
|
81
|
+
|
|
82
|
+
**Dependency story:** Zero npm dependencies. Ships with Node.js. No native addon build step.
|
|
83
|
+
|
|
84
|
+
**Cross-platform:** SQLite is compiled into Node itself — works identically on Windows, macOS, Linux.
|
|
85
|
+
|
|
86
|
+
**Concurrency model:**
|
|
87
|
+
- WAL mode allows concurrent readers with one writer (verified working — source: benchmark tests above)
|
|
88
|
+
- `DatabaseSync` is synchronous, matching the current blocking model exactly
|
|
89
|
+
- Transactions replace file locks — `BEGIN EXCLUSIVE` provides the same mutual exclusion
|
|
90
|
+
- Row-level updates eliminate whole-file rewrites
|
|
91
|
+
|
|
92
|
+
**Migration complexity:** High. ~40+ `safeJson` read sites across `engine.js`, `dashboard.js`, `queries.js`, `lifecycle.js`. ~20+ `mutateJsonFileLocked` write sites. Each needs conversion to prepared statements.
|
|
93
|
+
|
|
94
|
+
**JSON support:** SQLite JSON1 extension works — `json_extract()`, `json_each()` verified functional for dependency resolution queries.
|
|
95
|
+
|
|
96
|
+
**Pros:**
|
|
97
|
+
- Zero dependencies (built into Node)
|
|
98
|
+
- WAL mode eliminates read-write contention
|
|
99
|
+
- Row-level operations (no more 370 KB rewrites for one field change)
|
|
100
|
+
- Indexed queries (find pending items by status without scanning all items)
|
|
101
|
+
- Transactions provide stronger atomicity than file locks
|
|
102
|
+
- Single `.db` file replaces 9+ JSON files + 9 `.backup` + 9 `.lock` files
|
|
103
|
+
|
|
104
|
+
**Cons:**
|
|
105
|
+
- **Experimental API** — could change between Node versions with no migration path
|
|
106
|
+
- State files become opaque (can't `cat dispatch.json` for debugging)
|
|
107
|
+
- Schema migrations needed as data model evolves
|
|
108
|
+
- No async API yet (acceptable — current code is sync anyway)
|
|
109
|
+
- `ExperimentalWarning` printed on first import (suppressible with `--no-warnings=ExperimentalWarning`)
|
|
110
|
+
|
|
111
|
+
**Risk assessment:** The experimental status is the **only** serious blocker. The API surface is small (`DatabaseSync`, `prepare`, `exec`, `get`, `all`, `run`) and mirrors `better-sqlite3` closely — likely to stabilize without breaking changes. But "likely" is not "guaranteed."
|
|
112
|
+
|
|
113
|
+
### 2.2 LevelDB / LMDB (Embedded Key-Value)
|
|
114
|
+
|
|
115
|
+
**Dependency story:** npm packages with native C/C++ addons. `level` (LevelDB wrapper) or `lmdb-js`.
|
|
116
|
+
|
|
117
|
+
**Cross-platform:** Requires native build toolchain (node-gyp, C++ compiler). Windows support historically fragile.
|
|
118
|
+
|
|
119
|
+
**Concurrency model:**
|
|
120
|
+
- LMDB: MVCC with zero-copy reads — excellent read performance
|
|
121
|
+
- LevelDB: Single-process lock; read-free but writes serialize
|
|
122
|
+
|
|
123
|
+
**Migration complexity:** Medium-high. Key-value model doesn't naturally support the relational queries needed (e.g., "find all pending work items for project X with unmet dependencies").
|
|
124
|
+
|
|
125
|
+
**Pros:**
|
|
126
|
+
- Battle-tested in production
|
|
127
|
+
- LMDB: extremely fast reads with memory-mapped I/O
|
|
128
|
+
- Supports ordered iteration (useful for log entries)
|
|
129
|
+
|
|
130
|
+
**Cons:**
|
|
131
|
+
- **Breaks zero-dependency principle** (hard stop)
|
|
132
|
+
- Native addon build complexity on Windows
|
|
133
|
+
- Key-value model is a poor fit for relational queries on work items
|
|
134
|
+
- Adds ~15-50 MB to install footprint
|
|
135
|
+
|
|
136
|
+
**Verdict:** Disqualified by the zero-dependency constraint.
|
|
137
|
+
|
|
138
|
+
### 2.3 Better-SQLite3
|
|
139
|
+
|
|
140
|
+
**Dependency story:** npm package with native C addon. Prebuilt binaries available for most platforms via `prebuild-install`.
|
|
141
|
+
|
|
142
|
+
**Cross-platform:** Good — prebuilt binaries for Windows/macOS/Linux. Fallback to node-gyp if no prebuild.
|
|
143
|
+
|
|
144
|
+
**Concurrency model:** Identical to `node:sqlite` — synchronous, WAL mode, same SQLite engine underneath.
|
|
145
|
+
|
|
146
|
+
**Migration complexity:** Same as `node:sqlite` — the API is nearly identical (`db.prepare().run()`, `.get()`, `.all()`).
|
|
147
|
+
|
|
148
|
+
**Pros:**
|
|
149
|
+
- Most popular SQLite binding for Node.js (14M weekly downloads)
|
|
150
|
+
- Stable, well-maintained, extensive documentation
|
|
151
|
+
- Synchronous API (perfect match)
|
|
152
|
+
- Full SQLite feature set including JSON1
|
|
153
|
+
|
|
154
|
+
**Cons:**
|
|
155
|
+
- **Breaks zero-dependency principle** (hard stop)
|
|
156
|
+
- Native addon (prebuilt binaries help but don't eliminate all build issues)
|
|
157
|
+
- ~8 MB added to node_modules
|
|
158
|
+
|
|
159
|
+
**Verdict:** Disqualified by the zero-dependency constraint. However, if `node:sqlite` stabilizes with an API modeled on `better-sqlite3` (which it is), then `better-sqlite3` serves as a proven reference implementation.
|
|
160
|
+
|
|
161
|
+
### 2.4 Improved File-Based Approach
|
|
162
|
+
|
|
163
|
+
Keep JSON files but address the worst pain points structurally.
|
|
164
|
+
|
|
165
|
+
**Specific improvements:**
|
|
166
|
+
|
|
167
|
+
| Improvement | Targets | Effort | Impact |
|
|
168
|
+
|-------------|---------|--------|--------|
|
|
169
|
+
| **Split dispatch.json** into `dispatch/pending.json`, `dispatch/active.json`, `dispatch/completed/` (per-entry files) | dispatch.json (380 KB) | Medium | Reduces lock contention — pending/active/completed are independently lockable |
|
|
170
|
+
| **Cap and rotate cooldowns.json** — delete entries older than 7 days | cooldowns.json (511 KB) | Low | 511 KB → ~20 KB |
|
|
171
|
+
| **Per-entity files for completed dispatches** — `dispatch/completed/{id}.json` | dispatch.json completed array | Medium | Eliminates growing array; reads become `readdir + filter` |
|
|
172
|
+
| **Add read caches** — extend 2s TTL cache from dispatch.json to work-items.json and pull-requests.json | queries.js | Low | 60-80% fewer disk reads per tick |
|
|
173
|
+
| **JSON Lines for log.json** — append-only `.jsonl` format | log.json (292 KB) | Low | Eliminates parse-entire-file-to-append; rotation becomes `tail -n 2000` |
|
|
174
|
+
| **Structured directory layout** — `state/{entity}/{id}.json` | All state files | High | Per-entity locking, but dramatically increases file count |
|
|
175
|
+
|
|
176
|
+
**Dependency story:** Zero — pure Node.js.
|
|
177
|
+
|
|
178
|
+
**Cross-platform:** Identical to current.
|
|
179
|
+
|
|
180
|
+
**Concurrency model:** Same file locks, but with finer granularity (per-entity instead of per-file).
|
|
181
|
+
|
|
182
|
+
**Migration complexity:** Low-medium. Read/write APIs stay the same shape; internal storage layout changes.
|
|
183
|
+
|
|
184
|
+
**Pros:**
|
|
185
|
+
- Zero risk — no new dependencies or experimental APIs
|
|
186
|
+
- Human-readable state (critical for debugging)
|
|
187
|
+
- Incremental migration (one file at a time)
|
|
188
|
+
- Preserves existing backup/restore pattern
|
|
189
|
+
|
|
190
|
+
**Cons:**
|
|
191
|
+
- Doesn't solve the fundamental problem: whole-file read-modify-write for arrays
|
|
192
|
+
- Per-entity files create thousands of small files (OS inode pressure on large deployments)
|
|
193
|
+
- No indexed queries — filtering still requires scanning all files
|
|
194
|
+
- Read caches add TTL staleness risk
|
|
195
|
+
|
|
196
|
+
### 2.5 Hybrid: JSON Lines + Indexed Views
|
|
197
|
+
|
|
198
|
+
A creative zero-dep option: use append-only JSON Lines files as the write log, with periodic compaction into indexed JSON snapshots.
|
|
199
|
+
|
|
200
|
+
**How it works:**
|
|
201
|
+
1. Writes append to `state/{entity}.jsonl` (no locking needed for appends)
|
|
202
|
+
2. Reads come from a cached in-memory index (rebuilt from JSONL on startup)
|
|
203
|
+
3. Periodic compaction rewrites the JSONL file, discarding superseded entries
|
|
204
|
+
|
|
205
|
+
**Pros:**
|
|
206
|
+
- Append-only writes are naturally lock-free
|
|
207
|
+
- In-memory index serves reads instantly
|
|
208
|
+
- Human-readable (JSONL is `cat`-able)
|
|
209
|
+
- Zero dependencies
|
|
210
|
+
|
|
211
|
+
**Cons:**
|
|
212
|
+
- Requires custom index implementation (error-prone)
|
|
213
|
+
- Compaction logic is complex to get right under concurrent access
|
|
214
|
+
- Crash recovery requires replaying the full JSONL file
|
|
215
|
+
- Reinventing a (bad) database
|
|
216
|
+
|
|
217
|
+
**Verdict:** Too much complexity for marginal gain. If we're going to build database-like infrastructure, use an actual database.
|
|
218
|
+
|
|
219
|
+
---
|
|
220
|
+
|
|
221
|
+
## 3. Recommendation
|
|
222
|
+
|
|
223
|
+
### Phase 1: Quick Wins (Now — 1-2 days effort)
|
|
224
|
+
|
|
225
|
+
Stay with files. Fix the two highest-pain issues immediately:
|
|
226
|
+
|
|
227
|
+
1. **Cap `cooldowns.json`** — Add a cleanup sweep that deletes entries older than 7 days. This file is 511 KB with 125 keys, most of which are stale. Implement in `engine/cooldown.js` cleanup function. (source: `engine/cooldown.js`)
|
|
228
|
+
|
|
229
|
+
2. **Cap `dispatch.json` completed array** more aggressively — Currently capped at 100 entries (source: `engine/dispatch.js:112-114`). Reduce to 50 or archive to `dispatch/completed/` directory. The 380 KB file is mostly completed entries.
|
|
230
|
+
|
|
231
|
+
3. **Add read caches to `work-items.json` and `pull-requests.json`** — Same 2s TTL pattern as dispatch.json (source: `engine/queries.js:82-91`). These are read 8+ times per tick but only written 1-2 times.
|
|
232
|
+
|
|
233
|
+
4. **Convert `log.json` to append-only JSONL** — Eliminates the parse-entire-file-to-append pattern in `_flushLogBuffer()` (source: `engine/shared.js:49-59`). Log rotation becomes `readFile → keep last 2000 lines → writeFile` instead of `parse JSON array → splice → stringify → write`.
|
|
234
|
+
|
|
235
|
+
### Phase 2: `node:sqlite` Migration (When API stabilizes — estimated Node 26 LTS)
|
|
236
|
+
|
|
237
|
+
Monitor `node:sqlite` stability. When it exits experimental:
|
|
238
|
+
|
|
239
|
+
**Migration order** (highest pain first):
|
|
240
|
+
|
|
241
|
+
| Priority | State File | Why First |
|
|
242
|
+
|----------|-----------|-----------|
|
|
243
|
+
| 1 | `engine/log.json` | Append-heavy, benefits most from indexed queries, lowest risk (read-only by dashboard) |
|
|
244
|
+
| 2 | `engine/dispatch.json` | Most contended file, benefits from row-level operations, eliminates completed array growth |
|
|
245
|
+
| 3 | `engine/cooldowns.json` | Bloated key-value store, natural fit for TTL-indexed table |
|
|
246
|
+
| 4 | `projects/*/work-items.json` | Core entity, benefits from indexed status/project/type queries |
|
|
247
|
+
| 5 | `projects/*/pull-requests.json` | Similar to work items, lower contention |
|
|
248
|
+
| 6 | `engine/metrics.json` | Small, low contention — migrate for consistency |
|
|
249
|
+
| 7 | `engine/control.json` | Tiny, single-object — migrate last for consistency |
|
|
250
|
+
|
|
251
|
+
**Migration architecture:**
|
|
252
|
+
|
|
253
|
+
```
|
|
254
|
+
┌─────────────────────────────────────────────────┐
|
|
255
|
+
│ StateStore API │
|
|
256
|
+
│ (drop-in replacement for mutateJsonFileLocked) │
|
|
257
|
+
├─────────────────────────────────────────────────┤
|
|
258
|
+
│ getWorkItems(filter?) → WorkItem[] │
|
|
259
|
+
│ mutateWorkItem(id, fn) → WorkItem │
|
|
260
|
+
│ getDispatch() → DispatchQueue │
|
|
261
|
+
│ mutateDispatch(fn) → DispatchQueue │
|
|
262
|
+
│ appendLog(entry) → void │
|
|
263
|
+
│ ... │
|
|
264
|
+
├─────────────────────────────────────────────────┤
|
|
265
|
+
│ Backend: SQLite (DatabaseSync) │
|
|
266
|
+
│ ┌─────────┐ ┌──────────┐ ┌──────────────────┐ │
|
|
267
|
+
│ │ WAL mode│ │ Prepared │ │ JSON data column │ │
|
|
268
|
+
│ │ │ │statements│ │ + indexed columns │ │
|
|
269
|
+
│ └─────────┘ └──────────┘ └──────────────────┘ │
|
|
270
|
+
└─────────────────────────────────────────────────┘
|
|
271
|
+
```
|
|
272
|
+
|
|
273
|
+
**Key design decisions for the SQLite schema:**
|
|
274
|
+
|
|
275
|
+
1. **Hybrid column strategy** — Store frequently-queried fields as indexed columns (`id`, `status`, `type`, `project`), keep the full object in a `data TEXT` column as JSON. This allows SQL `WHERE` on hot fields while preserving schema flexibility.
|
|
276
|
+
|
|
277
|
+
2. **Single database file** — All state in one `.db` file in `engine/minions.db`. WAL mode enables concurrent reads. Transactions replace file locks.
|
|
278
|
+
|
|
279
|
+
3. **Prepared statement cache** — Create all prepared statements at startup, reuse throughout process lifetime. This avoids the 0.7ms-per-prepare overhead measured in benchmarks.
|
|
280
|
+
|
|
281
|
+
4. **Migration layer** — On first startup with SQLite, read existing JSON files, populate tables, rename JSON files to `.json.migrated`. On rollback, the `.migrated` files can be renamed back.
|
|
282
|
+
|
|
283
|
+
5. **Debug tooling** — Add `minions db` CLI command that opens an interactive SQLite shell on `engine/minions.db` for debugging (replaces `cat dispatch.json`).
|
|
284
|
+
|
|
285
|
+
**Proposed schema (core tables):**
|
|
286
|
+
|
|
287
|
+
```sql
|
|
288
|
+
-- Dispatch queue entries
|
|
289
|
+
CREATE TABLE dispatch (
|
|
290
|
+
id TEXT PRIMARY KEY,
|
|
291
|
+
queue TEXT NOT NULL CHECK(queue IN ('pending','active','completed')),
|
|
292
|
+
type TEXT NOT NULL,
|
|
293
|
+
agent TEXT,
|
|
294
|
+
task TEXT,
|
|
295
|
+
meta TEXT, -- JSON blob
|
|
296
|
+
created_at TEXT,
|
|
297
|
+
started_at TEXT,
|
|
298
|
+
completed_at TEXT,
|
|
299
|
+
result TEXT,
|
|
300
|
+
reason TEXT
|
|
301
|
+
);
|
|
302
|
+
CREATE INDEX idx_dispatch_queue ON dispatch(queue);
|
|
303
|
+
CREATE INDEX idx_dispatch_agent ON dispatch(agent);
|
|
304
|
+
|
|
305
|
+
-- Work items
|
|
306
|
+
CREATE TABLE work_items (
|
|
307
|
+
id TEXT PRIMARY KEY,
|
|
308
|
+
project TEXT NOT NULL,
|
|
309
|
+
status TEXT NOT NULL,
|
|
310
|
+
type TEXT,
|
|
311
|
+
title TEXT,
|
|
312
|
+
priority TEXT,
|
|
313
|
+
data TEXT, -- Full JSON blob
|
|
314
|
+
created TEXT,
|
|
315
|
+
updated_at TEXT DEFAULT (datetime('now'))
|
|
316
|
+
);
|
|
317
|
+
CREATE INDEX idx_wi_status ON work_items(status);
|
|
318
|
+
CREATE INDEX idx_wi_project ON work_items(project);
|
|
319
|
+
CREATE INDEX idx_wi_project_status ON work_items(project, status);
|
|
320
|
+
|
|
321
|
+
-- Pull requests
|
|
322
|
+
CREATE TABLE pull_requests (
|
|
323
|
+
id TEXT PRIMARY KEY,
|
|
324
|
+
project TEXT NOT NULL,
|
|
325
|
+
status TEXT,
|
|
326
|
+
branch TEXT,
|
|
327
|
+
agent TEXT,
|
|
328
|
+
data TEXT, -- Full JSON blob
|
|
329
|
+
created TEXT,
|
|
330
|
+
updated_at TEXT DEFAULT (datetime('now'))
|
|
331
|
+
);
|
|
332
|
+
CREATE INDEX idx_pr_status ON pull_requests(status);
|
|
333
|
+
CREATE INDEX idx_pr_project ON pull_requests(project);
|
|
334
|
+
|
|
335
|
+
-- Engine log (append-only)
|
|
336
|
+
CREATE TABLE engine_log (
|
|
337
|
+
rowid INTEGER PRIMARY KEY AUTOINCREMENT,
|
|
338
|
+
timestamp TEXT NOT NULL,
|
|
339
|
+
level TEXT NOT NULL,
|
|
340
|
+
message TEXT,
|
|
341
|
+
meta TEXT -- JSON blob
|
|
342
|
+
);
|
|
343
|
+
CREATE INDEX idx_log_timestamp ON engine_log(timestamp);
|
|
344
|
+
CREATE INDEX idx_log_level ON engine_log(level);
|
|
345
|
+
|
|
346
|
+
-- Cooldowns (TTL-based)
|
|
347
|
+
CREATE TABLE cooldowns (
|
|
348
|
+
key TEXT PRIMARY KEY,
|
|
349
|
+
failures INTEGER DEFAULT 0,
|
|
350
|
+
last_failure TEXT,
|
|
351
|
+
cooldown_until TEXT,
|
|
352
|
+
data TEXT
|
|
353
|
+
);
|
|
354
|
+
CREATE INDEX idx_cd_until ON cooldowns(cooldown_until);
|
|
355
|
+
|
|
356
|
+
-- Metrics
|
|
357
|
+
CREATE TABLE metrics (
|
|
358
|
+
agent TEXT PRIMARY KEY,
|
|
359
|
+
data TEXT -- JSON blob with token usage, quality, etc.
|
|
360
|
+
);
|
|
361
|
+
|
|
362
|
+
-- Key-value store for small state (control, schedule-runs, etc.)
|
|
363
|
+
CREATE TABLE kv (
|
|
364
|
+
key TEXT PRIMARY KEY,
|
|
365
|
+
value TEXT
|
|
366
|
+
);
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
### Phase 3: Advanced Optimizations (Post-migration)
|
|
370
|
+
|
|
371
|
+
Once on SQLite, unlock capabilities impossible with file-based state:
|
|
372
|
+
|
|
373
|
+
1. **Dashboard SSE from SQLite triggers** — Use `sqlite3_update_hook` (if exposed) or polling with `WHERE updated_at > ?` instead of file-watching
|
|
374
|
+
2. **Dependency resolution via SQL** — Replace in-memory graph traversal with recursive CTEs
|
|
375
|
+
3. **Metrics aggregation** — `SELECT agent, SUM(tokens) FROM dispatch WHERE completed_at > ? GROUP BY agent`
|
|
376
|
+
4. **Log analysis** — `SELECT level, COUNT(*) FROM engine_log WHERE timestamp > ? GROUP BY level`
|
|
377
|
+
5. **Automatic compaction** — `DELETE FROM engine_log WHERE rowid < (SELECT MAX(rowid) - 2000 FROM engine_log)`
|
|
378
|
+
|
|
379
|
+
---
|
|
380
|
+
|
|
381
|
+
## 4. What NOT to Do
|
|
382
|
+
|
|
383
|
+
1. **Don't migrate to SQLite while the API is experimental.** A Node.js upgrade that changes `DatabaseSync` parameters or removes the module would be catastrophic for a state storage layer.
|
|
384
|
+
|
|
385
|
+
2. **Don't use an async SQLite API** (if one is added to Node). The current codebase is fundamentally synchronous — `mutateJsonFileLocked` is called from synchronous code paths. Mixing sync and async state access is a recipe for race conditions.
|
|
386
|
+
|
|
387
|
+
3. **Don't split into per-entity files** at scale. Going from 9 files to potentially thousands (one per work item, per dispatch entry) creates inode pressure, `readdir` performance issues, and makes atomic multi-entity operations harder (need to lock multiple files).
|
|
388
|
+
|
|
389
|
+
4. **Don't add npm dependencies** for state storage. The zero-dependency principle is a genuine architectural strength — it means `git clone && node engine.js` works everywhere with zero setup.
|
|
390
|
+
|
|
391
|
+
5. **Don't build a custom database.** The JSON Lines + compaction + in-memory index approach is literally reimplementing SQLite badly. Use the real thing when it's stable.
|
|
392
|
+
|
|
393
|
+
---
|
|
394
|
+
|
|
395
|
+
## 5. Risk Analysis
|
|
396
|
+
|
|
397
|
+
| Risk | Probability | Impact | Mitigation |
|
|
398
|
+
|------|-------------|--------|------------|
|
|
399
|
+
| `node:sqlite` API breaks on Node upgrade | Medium (experimental) | High — state inaccessible | Phase 2 only after API stabilizes; keep JSON export/import |
|
|
400
|
+
| File-based approach hits scaling limit | Low (current data tiny) | Medium — slower ticks | Phase 1 caching + capping buys years of headroom |
|
|
401
|
+
| SQLite `.db` corruption | Very low (SQLite is ACID) | High — state lost | WAL mode + periodic `.db` backup to `.db.backup` |
|
|
402
|
+
| Migration bugs lose state | Medium | High | Dual-write period: write to both JSON and SQLite for 1 week |
|
|
403
|
+
| Dashboard performance degrades during migration | Low | Low | Read API stays the same shape; backend changes only |
|
|
404
|
+
|
|
405
|
+
---
|
|
406
|
+
|
|
407
|
+
## 6. Decision Matrix
|
|
408
|
+
|
|
409
|
+
| Criterion | Files (current) | Files (improved) | `node:sqlite` | `better-sqlite3` | LevelDB/LMDB |
|
|
410
|
+
|-----------|----------------|------------------|---------------|-------------------|---------------|
|
|
411
|
+
| Zero dependencies | **Yes** | **Yes** | **Yes** | No | No |
|
|
412
|
+
| Cross-platform | **Yes** | **Yes** | **Yes** | Mostly | Fragile on Win |
|
|
413
|
+
| Concurrency | File locks | File locks (finer) | WAL + transactions | WAL + transactions | MVCC |
|
|
414
|
+
| Row-level ops | No | Partial | **Yes** | **Yes** | Yes (KV) |
|
|
415
|
+
| Indexed queries | No | No | **Yes** | **Yes** | No |
|
|
416
|
+
| Human-readable | **Yes** | **Yes** | No | No | No |
|
|
417
|
+
| API stability | Stable | Stable | **Experimental** | Stable | Stable |
|
|
418
|
+
| Migration effort | None | Low | High | High | High |
|
|
419
|
+
| Debugging ease | **Excellent** | **Excellent** | Needs tooling | Needs tooling | Poor |
|
|
420
|
+
|
|
421
|
+
---
|
|
422
|
+
|
|
423
|
+
## 7. Summary
|
|
424
|
+
|
|
425
|
+
**Short-term (this week):** Implement Phase 1 quick wins — cap cooldowns, add read caches, reduce dispatch completed cap. Zero risk, immediate improvement.
|
|
426
|
+
|
|
427
|
+
**Medium-term (Node 26 LTS timeframe):** Adopt `node:sqlite` with the hybrid column schema. Migrate log.json first (lowest risk), then dispatch.json (highest pain), then work-items.json. Use a dual-write period for safety.
|
|
428
|
+
|
|
429
|
+
**The current file-based system is not broken.** At 180 work items and 128 PRs, we're well within the comfortable range for JSON files. The biggest issues (511 KB cooldowns.json, 380 KB dispatch.json) are capping/rotation problems, not fundamental architecture problems. Fix those first.
|
|
430
|
+
|
|
431
|
+
SQLite is the right long-term answer, but only when `node:sqlite` is no longer experimental. The API is excellent, the performance is superior, and it maintains the zero-dependency principle. Patience here avoids a painful migration if the API changes.
|
package/engine/ado.js
CHANGED
|
@@ -21,6 +21,20 @@ function engine() {
|
|
|
21
21
|
let _adoTokenCache = { token: null, expiresAt: 0 };
|
|
22
22
|
let _adoTokenFailedUntil = 0; // backoff: skip azureauth calls until this timestamp
|
|
23
23
|
|
|
24
|
+
// ─── Auth Failure Tracking ──────────────────────────────────────────────────
|
|
25
|
+
// Set when pollPrStatus encounters auth errors mid-loop. The engine checks this
|
|
26
|
+
// to bypass the normal 6-tick cadence and re-poll on the next tick.
|
|
27
|
+
let _adoPollHadAuthFailure = false;
|
|
28
|
+
|
|
29
|
+
/** Check if auth failure during PR poll means an early re-poll is needed. */
|
|
30
|
+
function needsAdoPollRetry() { return _adoPollHadAuthFailure; }
|
|
31
|
+
|
|
32
|
+
/** Detect auth-related errors from adoFetch (HTML redirect, 401, 403). */
|
|
33
|
+
function isAdoAuthError(err) {
|
|
34
|
+
const msg = err?.message || '';
|
|
35
|
+
return msg.includes('auth redirect') || msg.includes('HTML instead of JSON') || /ADO API (401|403)/.test(msg);
|
|
36
|
+
}
|
|
37
|
+
|
|
24
38
|
async function getAdoToken() {
|
|
25
39
|
if (_adoTokenCache.token && Date.now() < _adoTokenCache.expiresAt) {
|
|
26
40
|
return _adoTokenCache.token;
|
|
@@ -133,16 +147,23 @@ async function forEachActivePr(config, token, callback) {
|
|
|
133
147
|
// ─── PR Status Polling ───────────────────────────────────────────────────────
|
|
134
148
|
|
|
135
149
|
async function pollPrStatus(config) {
|
|
150
|
+
_adoPollHadAuthFailure = false; // reset before polling — set again if errors recur
|
|
151
|
+
|
|
136
152
|
const token = await getAdoToken();
|
|
137
153
|
if (!token) {
|
|
138
154
|
log('warn', 'Skipping PR status poll — no ADO token available');
|
|
155
|
+
_adoPollHadAuthFailure = true; // trigger retry on next tick
|
|
139
156
|
return;
|
|
140
157
|
}
|
|
141
158
|
|
|
142
159
|
const totalUpdated = await forEachActivePr(config, token, async (project, pr, prNum, orgBase) => {
|
|
160
|
+
try {
|
|
143
161
|
const repoBase = `${orgBase}/${project.adoProject}/_apis/git/repositories/${project.repositoryId}/pullrequests/${prNum}`;
|
|
144
162
|
let updated = false;
|
|
145
163
|
|
|
164
|
+
// Clear stale flag — we're attempting a fresh poll
|
|
165
|
+
if (pr._buildStatusStale) { delete pr._buildStatusStale; updated = true; }
|
|
166
|
+
|
|
146
167
|
const prData = await adoFetch(`${repoBase}?api-version=7.1`, token);
|
|
147
168
|
|
|
148
169
|
let newStatus = pr.status;
|
|
@@ -278,6 +299,17 @@ async function pollPrStatus(config) {
|
|
|
278
299
|
}
|
|
279
300
|
|
|
280
301
|
return updated;
|
|
302
|
+
} catch (err) {
|
|
303
|
+
// Auth errors → mark build status stale so dashboard shows uncertainty
|
|
304
|
+
// and engine re-polls on next tick instead of waiting 6 ticks
|
|
305
|
+
if (isAdoAuthError(err)) {
|
|
306
|
+
pr._buildStatusStale = true;
|
|
307
|
+
_adoPollHadAuthFailure = true;
|
|
308
|
+
log('warn', `PR ${pr.id}: build status marked stale (auth error: ${err.message})`);
|
|
309
|
+
return true; // count as updated to persist the stale flag
|
|
310
|
+
}
|
|
311
|
+
throw err; // re-throw non-auth errors for forEachActivePr to handle
|
|
312
|
+
}
|
|
281
313
|
});
|
|
282
314
|
|
|
283
315
|
if (totalUpdated > 0) {
|
|
@@ -520,5 +552,7 @@ module.exports = {
|
|
|
520
552
|
pollPrHumanComments,
|
|
521
553
|
reconcilePrs,
|
|
522
554
|
checkLiveReviewStatus,
|
|
555
|
+
needsAdoPollRetry,
|
|
556
|
+
isAdoAuthError, // exported for testing
|
|
523
557
|
};
|
|
524
558
|
|
package/engine.js
CHANGED
|
@@ -937,7 +937,7 @@ function reconcileItemsWithPrs(items, allPrs, { onlyIds } = {}) {
|
|
|
937
937
|
// ─── Inbox Consolidation (extracted to engine/consolidation.js) ──────────────
|
|
938
938
|
|
|
939
939
|
const { consolidateInbox } = require('./engine/consolidation');
|
|
940
|
-
const { pollPrStatus, pollPrHumanComments, reconcilePrs, checkLiveReviewStatus: adoCheckLiveReview } = require('./engine/ado');
|
|
940
|
+
const { pollPrStatus, pollPrHumanComments, reconcilePrs, checkLiveReviewStatus: adoCheckLiveReview, needsAdoPollRetry } = require('./engine/ado');
|
|
941
941
|
const { pollPrStatus: ghPollPrStatus, pollPrHumanComments: ghPollPrHumanComments, reconcilePrs: ghReconcilePrs, checkLiveReviewStatus: ghCheckLiveReview } = require('./engine/github');
|
|
942
942
|
|
|
943
943
|
// ─── State Snapshot ─────────────────────────────────────────────────────────
|
|
@@ -2447,7 +2447,8 @@ async function tickInner() {
|
|
|
2447
2447
|
|
|
2448
2448
|
// 2.6. Poll PR status: build, review, merge (every 6 ticks = ~3 minutes)
|
|
2449
2449
|
// Awaited so PR state is consistent before discoverWork reads it
|
|
2450
|
-
if (
|
|
2450
|
+
// Also re-polls early if previous tick had ADO auth failures (stale build status recovery)
|
|
2451
|
+
if (tickCount % 6 === 0 || needsAdoPollRetry()) {
|
|
2451
2452
|
try { await pollPrStatus(config); } catch (err) { log('warn', `ADO PR status poll error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }
|
|
2452
2453
|
try { await ghPollPrStatus(config); } catch (err) { log('warn', `GitHub PR status poll error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }
|
|
2453
2454
|
// Sync PR status back to PRD items (missing → done when active PR exists)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.552",
|
|
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"
|