@yemi33/minions 0.1.2041 → 0.1.2043
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/dashboard/js/refresh.js +13 -4
- package/dashboard/js/render-dispatch.js +29 -34
- package/dashboard/js/render-inbox.js +7 -9
- package/dashboard/js/render-kb.js +7 -9
- package/dashboard/js/render-utils.js +53 -1
- package/dashboard/js/settings.js +2 -0
- package/dashboard.js +127 -2
- package/engine/dispatch.js +31 -16
- package/engine/github.js +29 -0
- package/engine/lifecycle.js +9 -9
- package/engine/shared.js +13 -0
- package/engine.js +55 -46
- package/package.json +1 -1
package/dashboard/js/refresh.js
CHANGED
|
@@ -14,7 +14,15 @@ const _pageCounters = {
|
|
|
14
14
|
// triggers or the count changes; triggerCount removed because it advances
|
|
15
15
|
// on the same event as last_triggered (F9/S4).
|
|
16
16
|
watches: function(d) { return (d.watches || []).length + '|' + (d.watches || []).reduce(function(m, w) { return Math.max(m, new Date(w.last_triggered || 0).getTime() || 0); }, 0); },
|
|
17
|
-
|
|
17
|
+
// meetings signature: full count + sum of all rounds. Uses meetingsTotal
|
|
18
|
+
// (top-level full count of meetings on disk) NOT meetings.length — the
|
|
19
|
+
// latter is the slim slice which drops terminal meetings >7d via
|
|
20
|
+
// statusMeetingsRetentionDays, so an archived meeting reaching round 3
|
|
21
|
+
// would silently fail to flip the dot. Round-sum stays on the slim slice
|
|
22
|
+
// (we don't track per-meeting round in meetingsTotal) — operators who
|
|
23
|
+
// care about archived-meeting round transitions can crank the retention
|
|
24
|
+
// window or set it to 0. (W-mphlrxx6000a8760)
|
|
25
|
+
meetings: function(d) { return (d.meetingsTotal ?? (d.meetings || []).length) + '|' + (d.meetings || []).reduce(function(s, m) { return s + (m.round || 0); }, 0); },
|
|
18
26
|
pipelines: function(d) { return (d.pipelines || []).length + '|' + (d.pipelines || []).reduce(function(s, p) { return s + (p.runs || []).length; }, 0); },
|
|
19
27
|
schedule: function(d) { return (d.schedules || []).length; },
|
|
20
28
|
// tools signature: skills count + mcp servers count.
|
|
@@ -59,7 +67,7 @@ const RENDER_VERSIONS = {
|
|
|
59
67
|
agents: 1,
|
|
60
68
|
prdProgress: 1,
|
|
61
69
|
prdPrs: 1,
|
|
62
|
-
inbox:
|
|
70
|
+
inbox: 2,
|
|
63
71
|
projects: 1,
|
|
64
72
|
notes: 1,
|
|
65
73
|
prd: 1,
|
|
@@ -69,8 +77,8 @@ const RENDER_VERSIONS = {
|
|
|
69
77
|
version: 1,
|
|
70
78
|
adoThrottle: 1,
|
|
71
79
|
ghThrottle: 1,
|
|
72
|
-
dispatch:
|
|
73
|
-
engineLog:
|
|
80
|
+
dispatch: 2,
|
|
81
|
+
engineLog: 2,
|
|
74
82
|
metrics: 1,
|
|
75
83
|
workItems: 1,
|
|
76
84
|
skills: 1,
|
|
@@ -80,6 +88,7 @@ const RENDER_VERSIONS = {
|
|
|
80
88
|
meetings: 1,
|
|
81
89
|
pipelines: 1,
|
|
82
90
|
pinned: 1,
|
|
91
|
+
kbPayload: 2,
|
|
83
92
|
};
|
|
84
93
|
const _sectionCache = {};
|
|
85
94
|
const _lastValueByKey = {};
|
|
@@ -191,19 +191,24 @@ function renderDispatch(dispatch) {
|
|
|
191
191
|
'<div class="dispatch-stat"><div class="dispatch-stat-num blue">' + (dispatch.pending || []).length + '</div><div class="dispatch-stat-label">Pending</div></div>' +
|
|
192
192
|
'<div class="dispatch-stat"><div class="dispatch-stat-num green">' + (dispatch.completedTotal || (dispatch.completed || []).length) + '</div><div class="dispatch-stat-label">Completed</div></div>';
|
|
193
193
|
|
|
194
|
+
// Shared row markup for the Active and Pending lists. The two differ only in
|
|
195
|
+
// the trailing chip: Active shows started_at, Pending shows skipReason.
|
|
196
|
+
const dispatchItemHtml = (d, trailing) =>
|
|
197
|
+
'<div class="dispatch-item">' +
|
|
198
|
+
'<span class="dispatch-type ' + (d.type || '') + '">' + escHtml(d.type || '') + '</span>' +
|
|
199
|
+
'<span class="dispatch-agent">' + escHtml(d.agentName || d.agent || '') + '</span>' +
|
|
200
|
+
'<span class="dispatch-task" title="' + escHtml(d.task || '') + '">' + escHtml(d.task || '') + '</span>' +
|
|
201
|
+
renderStuckChip(d) +
|
|
202
|
+
trailing +
|
|
203
|
+
'</div>';
|
|
204
|
+
|
|
194
205
|
// Active
|
|
195
206
|
const activeEl = document.getElementById('dispatch-active');
|
|
196
207
|
if ((dispatch.active || []).length > 0) {
|
|
197
208
|
activeEl.innerHTML = '<div style="font-size:11px;color:var(--green);margin-bottom:6px;font-weight:600">ACTIVE</div><div class="dispatch-list">' +
|
|
198
|
-
dispatch.active.map(d =>
|
|
199
|
-
'<
|
|
200
|
-
|
|
201
|
-
'<span class="dispatch-agent">' + escHtml(d.agentName || d.agent || '') + '</span>' +
|
|
202
|
-
'<span class="dispatch-task" title="' + escHtml(d.task || '') + '">' + escHtml(d.task || '') + '</span>' +
|
|
203
|
-
renderStuckChip(d) +
|
|
204
|
-
'<span class="dispatch-time">' + shortTime(d.started_at) + '</span>' +
|
|
205
|
-
'</div>'
|
|
206
|
-
).join('') + '</div>';
|
|
209
|
+
dispatch.active.map(d => dispatchItemHtml(d,
|
|
210
|
+
'<span class="dispatch-time">' + shortTime(d.started_at) + '</span>'
|
|
211
|
+
)).join('') + '</div>';
|
|
207
212
|
} else {
|
|
208
213
|
activeEl.innerHTML = '<div style="color:var(--muted);font-size:11px;margin-bottom:8px">No active dispatches</div>';
|
|
209
214
|
}
|
|
@@ -212,15 +217,9 @@ function renderDispatch(dispatch) {
|
|
|
212
217
|
const pendingEl = document.getElementById('dispatch-pending');
|
|
213
218
|
if ((dispatch.pending || []).length > 0) {
|
|
214
219
|
pendingEl.innerHTML = '<div style="font-size:11px;color:var(--yellow);margin:8px 0 6px;font-weight:600">PENDING</div><div class="dispatch-list">' +
|
|
215
|
-
dispatch.pending.map(d =>
|
|
216
|
-
'<
|
|
217
|
-
|
|
218
|
-
'<span class="dispatch-agent">' + escHtml(d.agentName || d.agent || '') + '</span>' +
|
|
219
|
-
'<span class="dispatch-task" title="' + escHtml(d.task || '') + '">' + escHtml(d.task || '') + '</span>' +
|
|
220
|
-
renderStuckChip(d) +
|
|
221
|
-
(d.skipReason ? '<span style="font-size:9px;color:var(--muted);margin-left:6px" title="' + escHtml(d.skipReason) + '">' + escHtml(d.skipReason.replace(/_/g, ' ')) + '</span>' : '') +
|
|
222
|
-
'</div>'
|
|
223
|
-
).join('') + '</div>';
|
|
220
|
+
dispatch.pending.map(d => dispatchItemHtml(d,
|
|
221
|
+
d.skipReason ? '<span style="font-size:9px;color:var(--muted);margin-left:6px" title="' + escHtml(d.skipReason) + '">' + escHtml(d.skipReason.replace(/_/g, ' ')) + '</span>' : ''
|
|
222
|
+
)).join('') + '</div>';
|
|
224
223
|
} else {
|
|
225
224
|
pendingEl.innerHTML = '';
|
|
226
225
|
}
|
|
@@ -254,14 +253,12 @@ function renderDispatch(dispatch) {
|
|
|
254
253
|
'<td class="pr-date">' + shortTime(d.completed_at) + '</td>' +
|
|
255
254
|
'</tr>';
|
|
256
255
|
}).join('') + '</tbody></table>';
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
'</div></div>');
|
|
264
|
-
}
|
|
256
|
+
const compPager = renderPager({
|
|
257
|
+
start: compStart, perPage: COMPLETED_PER_PAGE, total: completed.length,
|
|
258
|
+
page: _completedPage, totalPages: totalCompPages,
|
|
259
|
+
onPrev: '_completedPrev()', onNext: '_completedNext()',
|
|
260
|
+
});
|
|
261
|
+
if (compPager) completedEl.insertAdjacentHTML('beforeend', compPager);
|
|
265
262
|
} else {
|
|
266
263
|
completedEl.innerHTML = '<p class="empty">No completed dispatches yet.</p>';
|
|
267
264
|
}
|
|
@@ -288,14 +285,12 @@ function renderEngineLog(log) {
|
|
|
288
285
|
escHtml(e.message || '') +
|
|
289
286
|
'</div>'
|
|
290
287
|
).join('');
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
'</div></div>');
|
|
298
|
-
}
|
|
288
|
+
const logPager = renderPager({
|
|
289
|
+
start: logStart, perPage: LOG_PER_PAGE, total: reversed.length,
|
|
290
|
+
page: _logPage, totalPages: totalLogPages,
|
|
291
|
+
onPrev: '_logPrev()', onNext: '_logNext()',
|
|
292
|
+
});
|
|
293
|
+
if (logPager) el.insertAdjacentHTML('beforeend', logPager);
|
|
299
294
|
}
|
|
300
295
|
|
|
301
296
|
function shortTime(t) {
|
|
@@ -35,21 +35,19 @@ function renderInbox(inbox) {
|
|
|
35
35
|
</div>
|
|
36
36
|
<div class="inbox-preview" onclick="openModal(${idx})" style="cursor:pointer">${escapeHtml(item.content.slice(0,200))}</div>
|
|
37
37
|
<div style="display:flex;gap:6px;margin-top:6px;align-items:center">
|
|
38
|
-
|
|
38
|
+
${pinButton(pk, pinned, 'inbox')}
|
|
39
39
|
<button class="pr-pager-btn" style="font-size:9px;padding:2px 8px" data-inbox-name="${escapeHtml(item.name)}" onclick="event.stopPropagation();promoteToKB(this.dataset.inboxName)">Add to Knowledge Base</button>
|
|
40
40
|
<button class="pr-pager-btn" style="font-size:9px;padding:2px 8px" data-inbox-name="${escapeHtml(item.name)}" onclick="event.stopPropagation();openInboxInExplorer(this.dataset.inboxName)">Open in Explorer</button>
|
|
41
41
|
<button class="pr-pager-btn" style="font-size:9px;padding:2px 8px;color:var(--red)" data-inbox-name="${escapeHtml(item.name)}" onclick="event.stopPropagation();deleteInboxItem(this.dataset.inboxName)">Delete</button>
|
|
42
42
|
</div>
|
|
43
43
|
</div>`;
|
|
44
44
|
}).join('');
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
'</div></div>');
|
|
52
|
-
}
|
|
45
|
+
const inboxPager = renderPager({
|
|
46
|
+
start: inboxStart, perPage: INBOX_PER_PAGE, total: inbox.length,
|
|
47
|
+
page: _inboxPage, totalPages: totalInboxPages,
|
|
48
|
+
onPrev: '_inboxPrev()', onNext: '_inboxNext()',
|
|
49
|
+
});
|
|
50
|
+
if (inboxPager) list.insertAdjacentHTML('beforeend', inboxPager);
|
|
53
51
|
restoreNotifBadges();
|
|
54
52
|
}
|
|
55
53
|
|
|
@@ -142,7 +142,7 @@ function renderKnowledgeBase() {
|
|
|
142
142
|
return '<div class="kb-item' + (pinned ? ' item-pinned' : '') + '" data-file="knowledge/' + escapeHtml(item.category) + '/' + escapeHtml(item.file) + '" onclick="kbOpenItem(\'' + escapeHtml(item.category) + '\', \'' + escapeHtml(item.file) + '\')">' +
|
|
143
143
|
'<div class="kb-item-body">' +
|
|
144
144
|
'<div class="kb-item-title">' + icon + ' ' + escapeHtml(item.title) +
|
|
145
|
-
'
|
|
145
|
+
' ' + pinButton(pinKey, pinned, 'kb', { extraStyle: 'padding:1px 6px;margin-left:6px;vertical-align:middle' }) +
|
|
146
146
|
'</div>' +
|
|
147
147
|
'<div class="kb-item-meta">' +
|
|
148
148
|
'<span>' + label + '</span>' +
|
|
@@ -154,14 +154,12 @@ function renderKnowledgeBase() {
|
|
|
154
154
|
'</div>' +
|
|
155
155
|
'</div>';
|
|
156
156
|
}).join('');
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
'</div></div>');
|
|
164
|
-
}
|
|
157
|
+
const kbPager = renderPager({
|
|
158
|
+
start: kbStart, perPage: KB_PER_PAGE, total: items.length,
|
|
159
|
+
page: _kbPage, totalPages: totalKbPages,
|
|
160
|
+
onPrev: '_kbPrev()', onNext: '_kbNext()',
|
|
161
|
+
});
|
|
162
|
+
if (kbPager) listEl.insertAdjacentHTML('beforeend', kbPager);
|
|
165
163
|
restoreNotifBadges();
|
|
166
164
|
}
|
|
167
165
|
|
|
@@ -352,4 +352,56 @@ function renderAgentOutput(text) {
|
|
|
352
352
|
return fragments.join('');
|
|
353
353
|
}
|
|
354
354
|
|
|
355
|
-
|
|
355
|
+
/**
|
|
356
|
+
* Standard "Prev / Next" pager HTML for a paginated list. Used by inbox, KB,
|
|
357
|
+
* completed dispatches, and engine log. The caller is responsible for the
|
|
358
|
+
* `onPrev`/`onNext` JS expression strings — they're inlined as `onclick`
|
|
359
|
+
* attributes, so callers must pass safe identifier-only expressions
|
|
360
|
+
* (e.g. `'_inboxPrev()'`).
|
|
361
|
+
*
|
|
362
|
+
* @param {object} opts
|
|
363
|
+
* @param {number} opts.start - Zero-based start index of the current page.
|
|
364
|
+
* @param {number} opts.perPage - Items per page.
|
|
365
|
+
* @param {number} opts.total - Total items across all pages.
|
|
366
|
+
* @param {number} opts.page - Zero-based current page index.
|
|
367
|
+
* @param {number} opts.totalPages - Total page count.
|
|
368
|
+
* @param {string} opts.onPrev - JS expression for the Prev button's onclick.
|
|
369
|
+
* @param {string} opts.onNext - JS expression for the Next button's onclick.
|
|
370
|
+
* @returns {string} HTML string for the pager (empty when total <= perPage).
|
|
371
|
+
*/
|
|
372
|
+
function renderPager(opts) {
|
|
373
|
+
var start = opts.start, perPage = opts.perPage, total = opts.total;
|
|
374
|
+
var page = opts.page, totalPages = opts.totalPages;
|
|
375
|
+
if (total <= perPage) return '';
|
|
376
|
+
return '<div class="pr-pager">' +
|
|
377
|
+
'<span class="pr-page-info">Showing ' + (start + 1) + ' to ' + Math.min(start + perPage, total) + ' of ' + total + '</span>' +
|
|
378
|
+
'<div class="pr-pager-btns">' +
|
|
379
|
+
'<button class="pr-pager-btn ' + (page === 0 ? 'disabled' : '') + '" onclick="' + opts.onPrev + '">Prev</button>' +
|
|
380
|
+
'<button class="pr-pager-btn ' + (page >= totalPages - 1 ? 'disabled' : '') + '" onclick="' + opts.onNext + '">Next</button>' +
|
|
381
|
+
'</div></div>';
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Renders the standard Pin/Unpin chip used in the inbox and KB lists. Both
|
|
386
|
+
* call _togglePinAndRefresh(pinKey, source) on click; the only thing that
|
|
387
|
+
* varies is the source name. The data-pin-key attribute is escaped via
|
|
388
|
+
* escHtml; callers must pass the already-computed pin key.
|
|
389
|
+
*
|
|
390
|
+
* @param {string} pinKey - The pin storage key (e.g. inboxPinKey(name)).
|
|
391
|
+
* @param {boolean} pinned - Current pinned state.
|
|
392
|
+
* @param {string} source - The source name passed to _togglePinAndRefresh
|
|
393
|
+
* (e.g. 'inbox', 'kb').
|
|
394
|
+
* @param {object} [opts]
|
|
395
|
+
* @param {string} [opts.extraStyle] - Inline CSS appended to the button.
|
|
396
|
+
* @returns {string} HTML for the pin button.
|
|
397
|
+
*/
|
|
398
|
+
function pinButton(pinKey, pinned, source, opts) {
|
|
399
|
+
var extraStyle = (opts && opts.extraStyle) || '';
|
|
400
|
+
return '<button class="pr-pager-btn pin-btn' + (pinned ? ' pinned' : '') +
|
|
401
|
+
'" style="font-size:9px;padding:2px 8px;' + extraStyle +
|
|
402
|
+
'" data-pin-key="' + escHtml(pinKey) +
|
|
403
|
+
'" onclick="event.stopPropagation();_togglePinAndRefresh(this.dataset.pinKey,\'' + source + '\')">' +
|
|
404
|
+
(pinned ? 'Unpin' : 'Pin') + '</button>';
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
window.MinionsRenderUtils = { formatToolSummary, renderAgentOutput, renderPager, pinButton };
|
package/dashboard/js/settings.js
CHANGED
|
@@ -89,6 +89,7 @@ async function openSettings() {
|
|
|
89
89
|
settingsField('Meeting Round Timeout', 'set-meetingRoundTimeout', e.meetingRoundTimeout || 900000, 'ms', 'Auto-advance meeting round after this') +
|
|
90
90
|
settingsField('Operator login (used in branch names)', 'set-operatorLogin', e.operatorLogin || '', '', 'Override the human operator login used in user/<loginname>/<wi-id>-<slug> branches. Empty = auto-resolve via gh / git email / OS username (currently resolves to: ' + (e._resolvedOperatorLogin || 'unknown') + ')') +
|
|
91
91
|
settingsField('Status WorkItems Retention', 'set-statusWorkItemsRetentionDays', e.statusWorkItemsRetentionDays ?? 7, 'days', 'Trim done/failed/cancelled work items older than N days from the /api/status workItems slice (active items are always shipped). Cuts SPA payload from ~3MB to <500KB. Set to 0 to disable trimming (full list shipped, restoring legacy behavior).') +
|
|
92
|
+
settingsField('Status Meetings Retention', 'set-statusMeetingsRetentionDays', e.statusMeetingsRetentionDays ?? 7, 'days', 'Trim completed/archived meetings older than N days from the /api/status meetings slice (active meetings are always shipped). Cuts SPA payload from ~4.3MB to <500KB. Detail modal still fetches full transcripts via /api/meetings/:id. Set to 0 to disable trimming (full list shipped, restoring legacy behavior).') +
|
|
92
93
|
'</div>' +
|
|
93
94
|
'<h3 style="font-size:13px;color:var(--blue);margin-bottom:8px">Automation</h3>' +
|
|
94
95
|
'<div style="display:flex;flex-direction:column;gap:6px;margin-bottom:16px">' +
|
|
@@ -599,6 +600,7 @@ async function saveSettings() {
|
|
|
599
600
|
meetingRoundTimeout: document.getElementById('set-meetingRoundTimeout').value,
|
|
600
601
|
operatorLogin: (document.getElementById('set-operatorLogin')?.value ?? '').trim(),
|
|
601
602
|
statusWorkItemsRetentionDays: document.getElementById('set-statusWorkItemsRetentionDays').value,
|
|
603
|
+
statusMeetingsRetentionDays: document.getElementById('set-statusMeetingsRetentionDays').value,
|
|
602
604
|
autoApprovePlans: document.getElementById('set-autoApprovePlans').checked,
|
|
603
605
|
evalLoop: document.getElementById('set-evalLoop').checked,
|
|
604
606
|
autoDecompose: document.getElementById('set-autoDecompose').checked,
|
package/dashboard.js
CHANGED
|
@@ -23,6 +23,7 @@ const shared = require('./engine/shared');
|
|
|
23
23
|
const queries = require('./engine/queries');
|
|
24
24
|
const ado = require('./engine/ado');
|
|
25
25
|
const gh = require('./engine/github');
|
|
26
|
+
const ghToken = require('./engine/gh-token');
|
|
26
27
|
const issues = require('./engine/issues');
|
|
27
28
|
const watchesMod = require('./engine/watches');
|
|
28
29
|
const meetingMod = require('./engine/meeting');
|
|
@@ -1710,7 +1711,14 @@ function _buildStatusFastState() {
|
|
|
1710
1711
|
metrics: getMetrics(),
|
|
1711
1712
|
workItems: _slimWorkItemsForStatus(getWorkItems()),
|
|
1712
1713
|
watches: watchesMod.getWatches(),
|
|
1713
|
-
meetings: _safeStatusSlice('meetings', () => meetingMod.getMeetings(), []),
|
|
1714
|
+
meetings: _safeStatusSlice('meetings', () => _slimMeetingsForStatus(meetingMod.getMeetings()), []),
|
|
1715
|
+
// Top-level full meeting count (NOT slim slice length). Surfaced so the
|
|
1716
|
+
// sidebar activity-dot counter (dashboard/js/refresh.js _pageCounters.meetings)
|
|
1717
|
+
// still fires when ANY meeting — including old/archived ones dropped from
|
|
1718
|
+
// the slim slice by statusMeetingsRetentionDays — gains a new round.
|
|
1719
|
+
// Without this, completing the third round of an archived meeting would
|
|
1720
|
+
// silently fail to light the sidebar dot. (W-mphlrxx6000a8760)
|
|
1721
|
+
meetingsTotal: _safeStatusSlice('meetingsTotal', () => _countMeetingsForStatus(), 0),
|
|
1714
1722
|
// QA runs — surfaced for the sidebar activity-dot counter and any future
|
|
1715
1723
|
// CC/aggregate view. Tab-level rendering keeps its own /api/qa/runs poll
|
|
1716
1724
|
// (5 s while the QA page is mounted). qa-runs.json is in the mtime tracker
|
|
@@ -1844,6 +1852,101 @@ function _slimWorkItemsForStatus(items) {
|
|
|
1844
1852
|
return surviving;
|
|
1845
1853
|
}
|
|
1846
1854
|
|
|
1855
|
+
// ── /api/status meetings slimming (W-mphlrxx6000a8760) ──────────────────────
|
|
1856
|
+
// Mirrors the workItems trim above (PR #2816). Meetings are the second
|
|
1857
|
+
// largest /api/status slice after workItems — live measurement: 22 meetings
|
|
1858
|
+
// / 4.3MB (60% of the 7.2MB payload). The list renderer in
|
|
1859
|
+
// dashboard/js/render-meetings.js:renderMeetings only needs:
|
|
1860
|
+
// - id, title, status, round, participants, agenda(short), createdAt,
|
|
1861
|
+
// completedAt
|
|
1862
|
+
// - per-participant booleans of findings/debate (used to pick the
|
|
1863
|
+
// ✓/⏳/○ icon — `m.findings?.[p]` truthy check, line 48-50)
|
|
1864
|
+
// The detail modal calls `/api/meetings/:id` which serves the full record
|
|
1865
|
+
// (findings.content + debate.content + conclusion + transcript bodies), so
|
|
1866
|
+
// dropping those from the slice is safe.
|
|
1867
|
+
//
|
|
1868
|
+
// Active meetings (investigating/debating/concluding) are ALWAYS kept
|
|
1869
|
+
// regardless of age. Terminal meetings (completed/archived) only survive
|
|
1870
|
+
// if their completedAt/roundStartedAt/createdAt is within the window.
|
|
1871
|
+
// Set engine.statusMeetingsRetentionDays = 0 to disable trimming entirely
|
|
1872
|
+
// (returns the full list — but still slim-shaped — restoring legacy size).
|
|
1873
|
+
const _ACTIVE_MEETING_STATUSES_FOR_STATUS = new Set(['investigating', 'debating', 'concluding']);
|
|
1874
|
+
const _TERMINAL_MEETING_STATUSES_FOR_STATUS = new Set(['completed', 'archived']);
|
|
1875
|
+
function _resolveStatusMeetingsRetentionDays() {
|
|
1876
|
+
const raw = CONFIG?.engine?.statusMeetingsRetentionDays;
|
|
1877
|
+
if (raw === 0 || raw === '0') return 0;
|
|
1878
|
+
const n = Number(raw);
|
|
1879
|
+
if (Number.isFinite(n) && n >= 0) return n;
|
|
1880
|
+
return shared.ENGINE_DEFAULTS.statusMeetingsRetentionDays;
|
|
1881
|
+
}
|
|
1882
|
+
function _slimMeetingForStatus(meeting) {
|
|
1883
|
+
// Reduce findings/debate objects to {agentId: true} sentinels — the list
|
|
1884
|
+
// renderer only checks `m.findings?.[p]` for truthiness when picking the
|
|
1885
|
+
// participant-badge icon. Keeping just the keys preserves that contract
|
|
1886
|
+
// while dropping the per-round agent transcript bodies (~95KB+ each).
|
|
1887
|
+
const findingsKeys = meeting.findings && typeof meeting.findings === 'object'
|
|
1888
|
+
? Object.keys(meeting.findings) : [];
|
|
1889
|
+
const debateKeys = meeting.debate && typeof meeting.debate === 'object'
|
|
1890
|
+
? Object.keys(meeting.debate) : [];
|
|
1891
|
+
const findings = {};
|
|
1892
|
+
for (const k of findingsKeys) findings[k] = true;
|
|
1893
|
+
const debate = {};
|
|
1894
|
+
for (const k of debateKeys) debate[k] = true;
|
|
1895
|
+
const slim = {
|
|
1896
|
+
id: meeting.id,
|
|
1897
|
+
title: meeting.title,
|
|
1898
|
+
status: meeting.status,
|
|
1899
|
+
round: meeting.round,
|
|
1900
|
+
participants: Array.isArray(meeting.participants) ? meeting.participants : [],
|
|
1901
|
+
agenda: meeting.agenda,
|
|
1902
|
+
createdAt: meeting.createdAt,
|
|
1903
|
+
findings,
|
|
1904
|
+
debate,
|
|
1905
|
+
};
|
|
1906
|
+
if (meeting.completedAt !== undefined) slim.completedAt = meeting.completedAt;
|
|
1907
|
+
if (meeting.roundStartedAt !== undefined) slim.roundStartedAt = meeting.roundStartedAt;
|
|
1908
|
+
if (meeting.createdBy !== undefined) slim.createdBy = meeting.createdBy;
|
|
1909
|
+
return slim;
|
|
1910
|
+
}
|
|
1911
|
+
function _slimMeetingsForStatus(meetings) {
|
|
1912
|
+
if (!Array.isArray(meetings)) return meetings;
|
|
1913
|
+
const retentionDays = _resolveStatusMeetingsRetentionDays();
|
|
1914
|
+
if (retentionDays <= 0) {
|
|
1915
|
+
// Trimming disabled — keep full list but still flatten via slim shape
|
|
1916
|
+
// so wire format is consistent and the heavy bodies never ship via
|
|
1917
|
+
// /api/status regardless of operator config.
|
|
1918
|
+
return meetings.map(_slimMeetingForStatus);
|
|
1919
|
+
}
|
|
1920
|
+
const cutoffMs = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
|
|
1921
|
+
const surviving = [];
|
|
1922
|
+
for (const meeting of meetings) {
|
|
1923
|
+
if (!meeting) continue;
|
|
1924
|
+
const status = meeting.status || 'investigating';
|
|
1925
|
+
if (_TERMINAL_MEETING_STATUSES_FOR_STATUS.has(status)) {
|
|
1926
|
+
const ts = meeting.completedAt || meeting.roundStartedAt || meeting.createdAt || '';
|
|
1927
|
+
const tsMs = ts ? Date.parse(ts) : NaN;
|
|
1928
|
+
// Drop only when we have a parseable timestamp and it's beyond the
|
|
1929
|
+
// window. Meetings with missing/unparseable timestamps stay visible —
|
|
1930
|
+
// we'd rather over-include than silently hide them.
|
|
1931
|
+
if (Number.isFinite(tsMs) && tsMs < cutoffMs) {
|
|
1932
|
+
continue;
|
|
1933
|
+
}
|
|
1934
|
+
} else if (!_ACTIVE_MEETING_STATUSES_FOR_STATUS.has(status)) {
|
|
1935
|
+
// Unknown status — keep, so a future round name isn't silently
|
|
1936
|
+
// hidden until the constant set is updated.
|
|
1937
|
+
}
|
|
1938
|
+
surviving.push(_slimMeetingForStatus(meeting));
|
|
1939
|
+
}
|
|
1940
|
+
return surviving;
|
|
1941
|
+
}
|
|
1942
|
+
// Count meetings on disk without rehydrating bodies — backs the sidebar
|
|
1943
|
+
// activity dot signature so new rounds in trimmed/archived meetings still
|
|
1944
|
+
// flip the counter (refresh.js _pageCounters.meetings reads meetingsTotal).
|
|
1945
|
+
function _countMeetingsForStatus() {
|
|
1946
|
+
const meetings = meetingMod.getMeetings();
|
|
1947
|
+
return Array.isArray(meetings) ? meetings.length : 0;
|
|
1948
|
+
}
|
|
1949
|
+
|
|
1847
1950
|
// Build the slow-state slice (rarely-changing data: ~60s TTL).
|
|
1848
1951
|
function _buildStatusSlowState() {
|
|
1849
1952
|
const prdInfo = getPrdInfo();
|
|
@@ -8538,6 +8641,20 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
8538
8641
|
_setEngineConfig('statusWorkItemsRetentionDays', val);
|
|
8539
8642
|
}
|
|
8540
8643
|
}
|
|
8644
|
+
// W-mphlrxx6000a8760: /api/status meetings retention window. Same
|
|
8645
|
+
// shape as statusWorkItemsRetentionDays — 0 must persist literally
|
|
8646
|
+
// (disables trim), so handled outside the numericFields loop.
|
|
8647
|
+
if (e.statusMeetingsRetentionDays !== undefined) {
|
|
8648
|
+
const raw = e.statusMeetingsRetentionDays;
|
|
8649
|
+
if (raw === '' || raw === null) {
|
|
8650
|
+
_deleteEngineConfig('statusMeetingsRetentionDays');
|
|
8651
|
+
} else {
|
|
8652
|
+
let val = Number(raw);
|
|
8653
|
+
if (!Number.isFinite(val) || val < 0) val = D.statusMeetingsRetentionDays;
|
|
8654
|
+
if (val > 365) { _clamped.push(`statusMeetingsRetentionDays: ${val} → 365 (range: 0–365)`); val = 365; }
|
|
8655
|
+
_setEngineConfig('statusMeetingsRetentionDays', val);
|
|
8656
|
+
}
|
|
8657
|
+
}
|
|
8541
8658
|
// W-mpejf0fq000e84d6: operator login override. Empty string clears
|
|
8542
8659
|
// the override (engine falls back to gh/git/os resolution); any other
|
|
8543
8660
|
// value pins the login used in `user/<login>/<wi-id>-<slug>` branches.
|
|
@@ -9915,7 +10032,15 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
9915
10032
|
// a regex match against untrusted PR-link input (the body of POST
|
|
9916
10033
|
// /api/pull-requests/link); validate before exec. `prNum` is already
|
|
9917
10034
|
// a number; coerce to string for argv.
|
|
9918
|
-
|
|
10035
|
+
//
|
|
10036
|
+
// W-mphm0kt0000cebc3 (Bug A): route the GH PAT per slug. Without this,
|
|
10037
|
+
// cross-account scopes (e.g. opg-microsoft/minions when active gh is
|
|
10038
|
+
// yemi33) 404 silently and enrichment never lands — title stays
|
|
10039
|
+
// "PR #N (polling...)" indefinitely. Mirrors engine/github.js:316.
|
|
10040
|
+
const token = ghToken.resolveTokenForSlug(slug);
|
|
10041
|
+
const ghOpts = { timeout: 15000 };
|
|
10042
|
+
if (token) ghOpts.env = { ...process.env, GH_TOKEN: token };
|
|
10043
|
+
const result = await shared.shellSafeGh(['api', `repos/${shared.validateGhSlug(slug)}/pulls/${String(prNum)}`], ghOpts);
|
|
9919
10044
|
const d = JSON.parse(result);
|
|
9920
10045
|
prData = { title: d.title, description: d.body, branch: d.head?.ref, author: d.user?.login };
|
|
9921
10046
|
} else if (adoTarget && !initialPrData) {
|
package/engine/dispatch.js
CHANGED
|
@@ -9,11 +9,11 @@ const shared = require('./shared');
|
|
|
9
9
|
const queries = require('./queries');
|
|
10
10
|
const { setCooldown, setCooldownFailure } = require('./cooldown');
|
|
11
11
|
|
|
12
|
-
const {
|
|
13
|
-
mutatePullRequests, getProjects,
|
|
12
|
+
const { safeJsonArr, mutateJsonFileLocked, mutateWorkItems,
|
|
13
|
+
mutatePullRequests, getProjects, projectPrPath, log, ts, dateStamp,
|
|
14
14
|
sidecarDispatchPrompt, deleteDispatchPromptSidecar,
|
|
15
15
|
WI_STATUS, WORK_TYPE, DISPATCH_RESULT, ENGINE_DEFAULTS, AGENT_STATUS, FAILURE_CLASS, PR_STATUS } = shared;
|
|
16
|
-
const { getConfig,
|
|
16
|
+
const { getConfig, DISPATCH_PATH, INBOX_DIR } = queries;
|
|
17
17
|
|
|
18
18
|
const MINIONS_DIR = shared.MINIONS_DIR;
|
|
19
19
|
|
|
@@ -111,14 +111,28 @@ function getBranchDispatchLockKey(entry) {
|
|
|
111
111
|
}
|
|
112
112
|
|
|
113
113
|
function findActivePrOrBranchLock(dispatch, item) {
|
|
114
|
-
if (item?.type !== WORK_TYPE.FIX) return null;
|
|
115
114
|
const active = dispatch.active || [];
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
115
|
+
|
|
116
|
+
// PR-target dedup is FIX-only: a FIX shouldn't stack on top of another FIX
|
|
117
|
+
// for the same PR, but a REVIEW + FIX pair targeting the same PR is the
|
|
118
|
+
// normal review-then-fix flow and must not be dedup'd here.
|
|
119
|
+
if (item?.type === WORK_TYPE.FIX) {
|
|
120
|
+
const prTargetKey = getPrDispatchTargetKey(item);
|
|
121
|
+
if (prTargetKey) {
|
|
122
|
+
const existing = active.find(d => getPrDispatchTargetKey(d) === prTargetKey);
|
|
123
|
+
if (existing) return { existing, reason: `active PR dispatch ${prTargetKey}` };
|
|
124
|
+
}
|
|
120
125
|
}
|
|
121
126
|
|
|
127
|
+
// Branch-lock applies to EVERY type, not just FIX (W-mphll3py0006234e —
|
|
128
|
+
// issue #2817). Any dispatch that carries meta.branch is, by definition,
|
|
129
|
+
// claiming ownership of that branch for the duration of its run — if two
|
|
130
|
+
// such dispatches overlap on the same branch they race the eventual
|
|
131
|
+
// `git push` and the spawn-time stale-HEAD guard fails whichever push
|
|
132
|
+
// lost the race. Mirrors the dispatch-loop's `lockedBranches` mutex
|
|
133
|
+
// (engine.js ~6577-6731) at queue-time so maintenance-class dispatches
|
|
134
|
+
// (setup / test / docs / implement / verify / decompose / review …) get
|
|
135
|
+
// the same coordination FIX already had.
|
|
122
136
|
const branchLockKey = getBranchDispatchLockKey(item);
|
|
123
137
|
if (!branchLockKey) return null;
|
|
124
138
|
const existing = active.find(d => getBranchDispatchLockKey(d) === branchLockKey);
|
|
@@ -135,10 +149,13 @@ function addToDispatch(item) {
|
|
|
135
149
|
}
|
|
136
150
|
let added = false;
|
|
137
151
|
mutateDispatch((dispatch) => {
|
|
152
|
+
// Walked once per addToDispatch — all three dedup checks below find against the same set.
|
|
153
|
+
const queued = [...dispatch.pending, ...(dispatch.active || [])];
|
|
154
|
+
|
|
138
155
|
// Dedup: skip if same work item ID is already pending or active
|
|
139
156
|
const wiId = item.meta?.source === 'central-work-item-fanout' ? null : item.meta?.item?.id;
|
|
140
157
|
if (wiId) {
|
|
141
|
-
const existing =
|
|
158
|
+
const existing = queued.find(d => d.meta?.item?.id === wiId);
|
|
142
159
|
if (existing) {
|
|
143
160
|
log('info', `Dedup: skipping ${item.id} — work item ${wiId} already in ${existing.id}`);
|
|
144
161
|
return dispatch;
|
|
@@ -146,7 +163,7 @@ function addToDispatch(item) {
|
|
|
146
163
|
}
|
|
147
164
|
// Also dedup by dispatchKey
|
|
148
165
|
if (item.meta?.dispatchKey) {
|
|
149
|
-
const existing =
|
|
166
|
+
const existing = queued.find(d => d.meta?.dispatchKey === item.meta.dispatchKey);
|
|
150
167
|
if (existing) {
|
|
151
168
|
log('info', `Dedup: skipping ${item.id} — dispatchKey ${item.meta.dispatchKey} already in ${existing.id}`);
|
|
152
169
|
return dispatch;
|
|
@@ -154,8 +171,7 @@ function addToDispatch(item) {
|
|
|
154
171
|
}
|
|
155
172
|
const prDedupeKey = getPrDispatchDedupeKey(item);
|
|
156
173
|
if (prDedupeKey) {
|
|
157
|
-
const existing =
|
|
158
|
-
.find(d => getPrDispatchDedupeKey(d) === prDedupeKey);
|
|
174
|
+
const existing = queued.find(d => getPrDispatchDedupeKey(d) === prDedupeKey);
|
|
159
175
|
if (existing) {
|
|
160
176
|
log('info', `Dedup: skipping ${item.id} — PR dispatch ${prDedupeKey} already in ${existing.id}`);
|
|
161
177
|
return dispatch;
|
|
@@ -439,8 +455,8 @@ function readLiveWorkItem(meta) {
|
|
|
439
455
|
if (!itemId) return null;
|
|
440
456
|
const wiPath = lifecycle().resolveWorkItemPath(meta);
|
|
441
457
|
if (!wiPath) return null;
|
|
442
|
-
const items =
|
|
443
|
-
return
|
|
458
|
+
const items = safeJsonArr(wiPath);
|
|
459
|
+
return items.find(i => i.id === itemId) || null;
|
|
444
460
|
}
|
|
445
461
|
|
|
446
462
|
function writeFailedAgentReport(item, reason, resultSummary, failureClass) {
|
|
@@ -868,14 +884,13 @@ function cancelPendingDispatchesForPr(prId) {
|
|
|
868
884
|
* @returns {number} count of removed entries
|
|
869
885
|
*/
|
|
870
886
|
function cleanDispatchEntries(matchFn) {
|
|
871
|
-
const dispatchPath = path.join(MINIONS_DIR, 'engine', 'dispatch.json');
|
|
872
887
|
const tmpDir = path.join(MINIONS_DIR, 'engine', 'tmp');
|
|
873
888
|
let removed = 0;
|
|
874
889
|
const pidsToKill = [];
|
|
875
890
|
const filesToDelete = [];
|
|
876
891
|
const dispatchDirsToRemove = [];
|
|
877
892
|
try {
|
|
878
|
-
mutateJsonFileLocked(
|
|
893
|
+
mutateJsonFileLocked(DISPATCH_PATH, (dispatch) => {
|
|
879
894
|
for (const queue of ['pending', 'active', 'completed']) {
|
|
880
895
|
dispatch[queue] = Array.isArray(dispatch[queue]) ? dispatch[queue] : [];
|
|
881
896
|
const before = dispatch[queue].length;
|
package/engine/github.js
CHANGED
|
@@ -669,6 +669,35 @@ async function pollPrStatus(config) {
|
|
|
669
669
|
}
|
|
670
670
|
}
|
|
671
671
|
|
|
672
|
+
// W-mphm0kt0000cebc3 (Bug B): backfill title/description/agent for
|
|
673
|
+
// project-local PRs that are still on the link-time placeholder. The
|
|
674
|
+
// central poller (above, ~lines 561-583) does this already; without
|
|
675
|
+
// parity here a manually-linked project-local PR whose initial
|
|
676
|
+
// enrichment IIFE failed (cross-account auth, transient blip) would
|
|
677
|
+
// stay stuck on "PR #N (polling...)" forever. `prData` is already in
|
|
678
|
+
// hand from line 622 — no extra API call needed.
|
|
679
|
+
const currentTitleForBackfill = pr.title || '';
|
|
680
|
+
if (!currentTitleForBackfill
|
|
681
|
+
|| currentTitleForBackfill.includes('polling...')
|
|
682
|
+
|| /[{}"\[\]]/.test(currentTitleForBackfill)
|
|
683
|
+
|| /^[0-9a-f-]{8,}$/i.test(currentTitleForBackfill)) {
|
|
684
|
+
if (prData.title) {
|
|
685
|
+
const nextTitle = String(prData.title).slice(0, 120);
|
|
686
|
+
if (pr.title !== nextTitle) {
|
|
687
|
+
pr.title = nextTitle;
|
|
688
|
+
updated = true;
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
if (pr.description === undefined) {
|
|
693
|
+
pr.description = (prData.body || '').slice(0, 500);
|
|
694
|
+
updated = true;
|
|
695
|
+
}
|
|
696
|
+
if (pr.agent === 'human' && prData.user?.login) {
|
|
697
|
+
pr.agent = prData.user.login;
|
|
698
|
+
updated = true;
|
|
699
|
+
}
|
|
700
|
+
|
|
672
701
|
// Map GitHub PR state to minions status
|
|
673
702
|
let newStatus = pr.status;
|
|
674
703
|
if (prData.merged) newStatus = PR_STATUS.MERGED;
|
package/engine/lifecycle.js
CHANGED
|
@@ -7,7 +7,7 @@ const fs = require('fs');
|
|
|
7
7
|
const path = require('path');
|
|
8
8
|
const os = require('os');
|
|
9
9
|
const shared = require('./shared');
|
|
10
|
-
const { safeRead, safeJson, safeJsonNoRestore, safeWrite, mutateJsonFileLocked, mutateWorkItems, execAsync, projectPrPath, getPrLinks,
|
|
10
|
+
const { safeRead, safeJson, safeJsonArr, safeJsonNoRestore, safeWrite, mutateJsonFileLocked, mutateWorkItems, execAsync, projectPrPath, getPrLinks,
|
|
11
11
|
log, ts, dateStamp, WI_STATUS, DONE_STATUSES, PLAN_TERMINAL_STATUSES, WORK_TYPE, PLAN_STATUS, PRD_ITEM_STATUS, PR_STATUS, DISPATCH_RESULT,
|
|
12
12
|
ENGINE_DEFAULTS, DEFAULT_AGENT_METRICS, FAILURE_CLASS } = shared;
|
|
13
13
|
const { trackEngineUsage } = require('./llm');
|
|
@@ -113,7 +113,7 @@ function checkPlanCompletion(meta, config) {
|
|
|
113
113
|
for (const p of projects) {
|
|
114
114
|
try {
|
|
115
115
|
const prPath = shared.projectPrPath(p);
|
|
116
|
-
const prs =
|
|
116
|
+
const prs = safeJsonArr(prPath);
|
|
117
117
|
const prLinks = getPrLinks();
|
|
118
118
|
for (const pr of prs) {
|
|
119
119
|
const linkedItemIds = prLinks[pr.id] || [];
|
|
@@ -221,7 +221,7 @@ function checkPlanCompletion(meta, config) {
|
|
|
221
221
|
const projectPrs = {};
|
|
222
222
|
const prLinks = getPrLinks();
|
|
223
223
|
for (const p of projects) {
|
|
224
|
-
const prs = (
|
|
224
|
+
const prs = safeJsonArr(shared.projectPrPath(p))
|
|
225
225
|
.filter(pr => {
|
|
226
226
|
const linkedIds = prLinks[pr.id] || [];
|
|
227
227
|
return pr.status === PR_STATUS.ACTIVE && linkedIds.some(itemId => doneItems.find(w => w.id === itemId));
|
|
@@ -498,7 +498,7 @@ function cleanupPlanWorktrees(planFile, plan, projects, config) {
|
|
|
498
498
|
|
|
499
499
|
for (const p of projects) {
|
|
500
500
|
try {
|
|
501
|
-
const prs =
|
|
501
|
+
const prs = safeJsonArr(shared.projectPrPath(p));
|
|
502
502
|
const prLinks = getPrLinks();
|
|
503
503
|
for (const pr of prs) {
|
|
504
504
|
const linkedIds = prLinks[pr.id] || [];
|
|
@@ -1612,7 +1612,7 @@ function resolveReviewPrContext(pr, project, config, structuredCompletion = null
|
|
|
1612
1612
|
|
|
1613
1613
|
for (const candidateProject of projectCandidates) {
|
|
1614
1614
|
const prPath = shared.projectPrPath(candidateProject);
|
|
1615
|
-
const prs =
|
|
1615
|
+
const prs = safeJsonArr(prPath);
|
|
1616
1616
|
for (const ref of refs) {
|
|
1617
1617
|
const refUrl = typeof ref === 'object' ? ref.url || '' : String(ref || '');
|
|
1618
1618
|
if (!shared.isPrCompatibleWithProject(candidateProject, ref, refUrl)) continue;
|
|
@@ -1622,7 +1622,7 @@ function resolveReviewPrContext(pr, project, config, structuredCompletion = null
|
|
|
1622
1622
|
}
|
|
1623
1623
|
|
|
1624
1624
|
const centralPath = centralPrPath();
|
|
1625
|
-
const centralPrs =
|
|
1625
|
+
const centralPrs = safeJsonArr(centralPath);
|
|
1626
1626
|
const centralRefs = reportedPr ? [reportedPr] : refs;
|
|
1627
1627
|
for (const ref of centralRefs) {
|
|
1628
1628
|
const target = shared.findPrRecord(centralPrs, ref, null);
|
|
@@ -2215,7 +2215,7 @@ function findDependentActivePrs(mergedItemId, config) {
|
|
|
2215
2215
|
|
|
2216
2216
|
const projects = shared.getProjects(config);
|
|
2217
2217
|
for (const p of projects) {
|
|
2218
|
-
const prs =
|
|
2218
|
+
const prs = safeJsonArr(projectPrPath(p));
|
|
2219
2219
|
for (const pr of prs) {
|
|
2220
2220
|
if (!pr.branch || pr.status !== PR_STATUS.ACTIVE) continue;
|
|
2221
2221
|
const linked = (pr.prdItems || []).some(id => dependentWis.some(wi => wi.id === id));
|
|
@@ -4505,12 +4505,12 @@ function syncPrdFromPrs(config) {
|
|
|
4505
4505
|
const allProjects = shared.getProjects(config);
|
|
4506
4506
|
|
|
4507
4507
|
// Exact prdItems match only — no fuzzy matching
|
|
4508
|
-
const allPrs = allProjects.flatMap(p =>
|
|
4508
|
+
const allPrs = allProjects.flatMap(p => safeJsonArr(shared.projectPrPath(p)));
|
|
4509
4509
|
|
|
4510
4510
|
let totalReconciled = 0;
|
|
4511
4511
|
for (const project of allProjects) {
|
|
4512
4512
|
const wiPath = shared.projectWorkItemsPath(project);
|
|
4513
|
-
const items =
|
|
4513
|
+
const items = safeJsonArr(wiPath);
|
|
4514
4514
|
const hasReconcilable = items.some(wi =>
|
|
4515
4515
|
(wi.status === WI_STATUS.PENDING && !wi._pr) || wi.status === WI_STATUS.FAILED);
|
|
4516
4516
|
if (!hasReconcilable) continue;
|
package/engine/shared.js
CHANGED
|
@@ -2041,6 +2041,19 @@ const ENGINE_DEFAULTS = {
|
|
|
2041
2041
|
// via GET /api/work-items/<id> when description/references/AC are needed.
|
|
2042
2042
|
// 0 disables the trim (full list shipped, restoring legacy behavior).
|
|
2043
2043
|
statusWorkItemsRetentionDays: 7,
|
|
2044
|
+
|
|
2045
|
+
// ── /api/status meetings retention (W-mphlrxx6000a8760) ─────────────────────
|
|
2046
|
+
// Same shape as statusWorkItemsRetentionDays — mirrors the trim+slim pass
|
|
2047
|
+
// for the meetings slice (live: 22 meetings / 4.3MB → ~5 meetings / <500KB
|
|
2048
|
+
// typical). Active meetings (investigating/debating/concluding) are ALWAYS
|
|
2049
|
+
// shipped regardless of age — only terminal meetings (completed/archived)
|
|
2050
|
+
// past the window are dropped. The detail modal fetches the full record
|
|
2051
|
+
// (findings, debate, conclusion, transcript bodies) on demand via
|
|
2052
|
+
// GET /api/meetings/<id> when opened. A top-level meetingsTotal field is
|
|
2053
|
+
// synthesized so the sidebar activity dot still fires when ANY meeting
|
|
2054
|
+
// (including those dropped from the slim slice) gains a new round.
|
|
2055
|
+
// 0 disables the trim (full list shipped, restoring legacy behavior).
|
|
2056
|
+
statusMeetingsRetentionDays: 7,
|
|
2044
2057
|
};
|
|
2045
2058
|
|
|
2046
2059
|
// ─── Runtime Fleet Resolution (P-3b8e5f1d) ──────────────────────────────────
|
package/engine.js
CHANGED
|
@@ -94,6 +94,8 @@ const { getProjects, projectRoot, projectStateDir, projectWorkItemsPath, project
|
|
|
94
94
|
// ─── Utilities ──────────────────────────────────────────────────────────────
|
|
95
95
|
|
|
96
96
|
const safeJson = shared.safeJson;
|
|
97
|
+
const safeJsonArr = shared.safeJsonArr;
|
|
98
|
+
const safeJsonObj = shared.safeJsonObj;
|
|
97
99
|
const safeJsonNoRestore = shared.safeJsonNoRestore;
|
|
98
100
|
const safeRead = shared.safeRead;
|
|
99
101
|
const safeWrite = shared.safeWrite;
|
|
@@ -154,8 +156,6 @@ const { renderPlaybook, validatePlaybookVars, PLAYBOOK_REQUIRED_VARS,
|
|
|
154
156
|
// through to an interactive `gh auth login` device-code flow.
|
|
155
157
|
const ghToken = require('./engine/gh-token');
|
|
156
158
|
|
|
157
|
-
// sanitizeBranch imported from shared.js
|
|
158
|
-
|
|
159
159
|
// ─── Lifecycle (extracted to engine/lifecycle.js) ────────────────────────────
|
|
160
160
|
|
|
161
161
|
const { runPostCompletionHooks, updateWorkItemStatus, syncPrdItemStatus, reconcilePrdStatuses, handlePostMerge, checkPlanCompletion,
|
|
@@ -559,9 +559,9 @@ function resolveDependencyBranches(depIds, sourcePlan, project, config) {
|
|
|
559
559
|
// Find PR branches for each dependency work item
|
|
560
560
|
for (const p of projects) {
|
|
561
561
|
const prPath = shared.projectPrPath(p);
|
|
562
|
-
const prs =
|
|
562
|
+
const prs = safeJsonArr(prPath);
|
|
563
563
|
for (const pr of prs) {
|
|
564
|
-
if (!pr.branch || pr.status !==
|
|
564
|
+
if (!pr.branch || pr.status !== PR_STATUS.ACTIVE) continue;
|
|
565
565
|
const linked = (pr.prdItems || []).some(id =>
|
|
566
566
|
depWorkItems.find(w => w.id === id)
|
|
567
567
|
);
|
|
@@ -1463,10 +1463,10 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
1463
1463
|
// Fetch all dependency branches in parallel (git fetches are independent)
|
|
1464
1464
|
const fetchable = depBranches.filter(d => !_failedRefCache.has(d.branch));
|
|
1465
1465
|
const unfetchable = depBranches.filter(d => _failedRefCache.has(d.branch));
|
|
1466
|
-
const allPrsForDeps = unfetchable.length > 0 ? shared.getProjects(config).reduce((acc, p) => acc.concat(
|
|
1466
|
+
const allPrsForDeps = unfetchable.length > 0 ? shared.getProjects(config).reduce((acc, p) => acc.concat(safeJsonArr(shared.projectPrPath(p))), []) : [];
|
|
1467
1467
|
for (const { branch: depBranch, prId } of unfetchable) {
|
|
1468
1468
|
const pr = allPrsForDeps.find(p => p.id === prId);
|
|
1469
|
-
if (pr && (pr.status ===
|
|
1469
|
+
if (pr && (pr.status === PR_STATUS.MERGED || pr.status === PR_STATUS.CLOSED)) {
|
|
1470
1470
|
log('info', `Dependency ${depBranch} (${prId}) already merged — skipping, changes already in main`);
|
|
1471
1471
|
continue;
|
|
1472
1472
|
}
|
|
@@ -1482,7 +1482,7 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
1482
1482
|
)
|
|
1483
1483
|
);
|
|
1484
1484
|
const hasFetchFailures = fetchResults.some(r => r.status === 'rejected');
|
|
1485
|
-
const allPrsForFetch = hasFetchFailures ? shared.getProjects(config).reduce((acc, p) => acc.concat(
|
|
1485
|
+
const allPrsForFetch = hasFetchFailures ? shared.getProjects(config).reduce((acc, p) => acc.concat(safeJsonArr(shared.projectPrPath(p))), []) : [];
|
|
1486
1486
|
// Track branches recovered by local-only push so they can be merged
|
|
1487
1487
|
const recoveredBranches = new Set();
|
|
1488
1488
|
for (let i = 0; i < fetchResults.length; i++) {
|
|
@@ -1491,7 +1491,7 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
1491
1491
|
const failedPrId = fetchable[i].prId;
|
|
1492
1492
|
const errMsg = fetchResults[i].reason?.message || '';
|
|
1493
1493
|
const pr = allPrsForFetch.find(p => p.id === failedPrId);
|
|
1494
|
-
if (pr && (pr.status ===
|
|
1494
|
+
if (pr && (pr.status === PR_STATUS.MERGED || pr.status === PR_STATUS.CLOSED)) {
|
|
1495
1495
|
log('info', `Dependency ${failedBranch} (${failedPrId}) already merged — skipping, changes already in main`);
|
|
1496
1496
|
continue;
|
|
1497
1497
|
}
|
|
@@ -2079,10 +2079,12 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
2079
2079
|
// orphan detector's "logSize > stub-only" check can tell this apart from a
|
|
2080
2080
|
// hung process. Preserves the diagnostic the prior inline catch wrote.
|
|
2081
2081
|
try { fs.appendFileSync(liveOutputPath, `[${new Date().toISOString()}] spawn-failed: ${spawnErr.message}\n[process-exit] spawn-failed\n`); } catch { /* cleanup-only best effort */ }
|
|
2082
|
-
} else if (proc &&
|
|
2082
|
+
} else if (proc && proc.pid) {
|
|
2083
2083
|
// spawn() returned a handle but a later registration step threw —
|
|
2084
|
-
// kill the orphan child so it doesn't run unmonitored.
|
|
2085
|
-
|
|
2084
|
+
// kill the orphan child so it doesn't run unmonitored. shared.killImmediate
|
|
2085
|
+
// recurses into the process tree (footgun #4) — plain proc.kill('SIGKILL')
|
|
2086
|
+
// doesn't on Windows.
|
|
2087
|
+
try { shared.killImmediate(proc); } catch { /* already exited */ }
|
|
2086
2088
|
}
|
|
2087
2089
|
if (registeredInActiveProcesses) {
|
|
2088
2090
|
try { activeProcesses.delete(id); } catch { /* map.delete never throws but be defensive */ }
|
|
@@ -3271,10 +3273,6 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
3271
3273
|
return proc;
|
|
3272
3274
|
}
|
|
3273
3275
|
|
|
3274
|
-
// addToDispatch, isRetryableFailureReason — now in engine/dispatch.js
|
|
3275
|
-
|
|
3276
|
-
// completeDispatch — now in engine/dispatch.js
|
|
3277
|
-
|
|
3278
3276
|
// ─── Dependency Gate ─────────────────────────────────────────────────────────
|
|
3279
3277
|
// Returns: true (deps met), false (deps pending), 'failed' (dep failed — propagate)
|
|
3280
3278
|
function areDependenciesMet(item, config) {
|
|
@@ -3368,9 +3366,6 @@ function detectDependencyCycles(items) {
|
|
|
3368
3366
|
return [...cycleIds];
|
|
3369
3367
|
}
|
|
3370
3368
|
|
|
3371
|
-
|
|
3372
|
-
// writeInboxAlert — now in engine/dispatch.js
|
|
3373
|
-
|
|
3374
3369
|
// Reconciles work items against known PRs.
|
|
3375
3370
|
// Primary linkage comes from prdItems in pull-requests.json; fallback linkage
|
|
3376
3371
|
// uses engine/pr-links.json so matching does not depend on branch/title parsing.
|
|
@@ -3462,10 +3457,6 @@ function updateSnapshot(config) {
|
|
|
3462
3457
|
safeWrite(path.join(IDENTITY_DIR, 'now.md'), snapshot);
|
|
3463
3458
|
}
|
|
3464
3459
|
|
|
3465
|
-
// checkIdleThreshold, checkSteering, checkTimeouts — now in engine/timeout.js
|
|
3466
|
-
|
|
3467
|
-
// runCleanup — now in engine/cleanup.js
|
|
3468
|
-
|
|
3469
3460
|
// ─── Cooldowns (extracted to engine/cooldown.js) ─────────────────────────────
|
|
3470
3461
|
|
|
3471
3462
|
const { COOLDOWN_PATH, dispatchCooldowns, loadCooldowns, saveCooldowns,
|
|
@@ -3473,16 +3464,6 @@ const { COOLDOWN_PATH, dispatchCooldowns, loadCooldowns, saveCooldowns,
|
|
|
3473
3464
|
setCooldownFailure, clearCooldown, getPrReviewCooldownKey, clearLegacyPrReviewCooldown,
|
|
3474
3465
|
isAlreadyDispatched, isBranchActive } = require('./engine/cooldown');
|
|
3475
3466
|
|
|
3476
|
-
|
|
3477
|
-
|
|
3478
|
-
/**
|
|
3479
|
-
* Scan ~/.minions/plans/ for plan-generated PRD files → queue implement tasks.
|
|
3480
|
-
* Plans are project-scoped JSON files written by the plan-to-prd playbook.
|
|
3481
|
-
*/
|
|
3482
|
-
/**
|
|
3483
|
-
* Convert plan files into project work items (side-effect, like specs).
|
|
3484
|
-
* Plans write to the target project's work-items.json — picked up by discoverFromWorkItems next tick.
|
|
3485
|
-
*/
|
|
3486
3467
|
// Auto-clean pending/failed work items for a PRD so they re-materialize with updated plan data
|
|
3487
3468
|
function autoCleanPrdWorkItems(prdFile, config) {
|
|
3488
3469
|
const allProjects = getProjects(config);
|
|
@@ -3928,7 +3909,7 @@ function materializePlansAsWorkItems(config) {
|
|
|
3928
3909
|
if (!alreadyExists) {
|
|
3929
3910
|
for (const p of allProjects) {
|
|
3930
3911
|
if (String(p.name || '').toLowerCase() === String(projName || '').toLowerCase()) continue;
|
|
3931
|
-
const otherItems =
|
|
3912
|
+
const otherItems = safeJsonArr(projectWorkItemsPath(p));
|
|
3932
3913
|
const otherWi = otherItems.find(w => w.id === item.id);
|
|
3933
3914
|
if (otherWi) {
|
|
3934
3915
|
if (DONE_STATUSES.has(otherWi.status) && shouldReopen) {
|
|
@@ -3969,7 +3950,7 @@ function materializePlansAsWorkItems(config) {
|
|
|
3969
3950
|
|
|
3970
3951
|
if (created > 0) {
|
|
3971
3952
|
// Reconciliation: exact prdItems match only, scoped to newly created items
|
|
3972
|
-
const allPrsForReconcile = allProjects.flatMap(p =>
|
|
3953
|
+
const allPrsForReconcile = allProjects.flatMap(p => safeJsonArr(projectPrPath(p)));
|
|
3973
3954
|
const reconciled = reconcileItemsWithPrs(existingItems, allPrsForReconcile, { onlyIds: newlyCreatedIds });
|
|
3974
3955
|
if (reconciled > 0) log('info', `Plan reconciliation: marked ${reconciled} item(s) as done → ${projName}`);
|
|
3975
3956
|
|
|
@@ -4151,6 +4132,18 @@ function ensurePrBranchForDispatch(project, pr, automationType) {
|
|
|
4151
4132
|
}
|
|
4152
4133
|
return branch;
|
|
4153
4134
|
}
|
|
4135
|
+
// W-mphm0kt0000cebc3 (Bug C): suppress the red "missing branch" badge during
|
|
4136
|
+
// the link grace window. User reports the badge "is so loud as a warning -
|
|
4137
|
+
// makes the user think something is wrong when it's just taking its time".
|
|
4138
|
+
// A just-linked PR has `created` set within the last few seconds and has
|
|
4139
|
+
// never been polled (no `headSha`); the enrichment IIFE + first poll cycle
|
|
4140
|
+
// need 10-30s to land the real `branch`. During that window, return ''
|
|
4141
|
+
// silently so dispatch is deferred without flipping any UI state. The next
|
|
4142
|
+
// tick retries; if branch still missing past the grace window, fall through
|
|
4143
|
+
// to the existing _branchResolutionError path (red badge as before).
|
|
4144
|
+
if (isWithinLinkGraceWindow(pr)) {
|
|
4145
|
+
return '';
|
|
4146
|
+
}
|
|
4154
4147
|
const reason = `Cannot dispatch ${automationType} for ${shared.getPrDisplayId(pr)}: missing pr_branch/source branch metadata. Link or refresh the PR so the source branch is known.`;
|
|
4155
4148
|
if (updatePrBranchResolutionState(project, pr, { reason })) {
|
|
4156
4149
|
log('warn', `PR ${pr.id}: ${reason}`);
|
|
@@ -4158,6 +4151,21 @@ function ensurePrBranchForDispatch(project, pr, automationType) {
|
|
|
4158
4151
|
return '';
|
|
4159
4152
|
}
|
|
4160
4153
|
|
|
4154
|
+
// W-mphm0kt0000cebc3 (Bug C): a PR is "freshly linked" if it was created in
|
|
4155
|
+
// the last 120s AND has never been successfully polled (no `headSha`). The
|
|
4156
|
+
// enrichment IIFE in dashboard.js's POST /api/pull-requests/link runs async
|
|
4157
|
+
// after returning the response, and the first GH/ADO poll completes within
|
|
4158
|
+
// 10-30s. The 120s window comfortably covers both without masking PRs whose
|
|
4159
|
+
// branch is genuinely missing.
|
|
4160
|
+
const PR_LINK_GRACE_WINDOW_MS = 120 * 1000;
|
|
4161
|
+
function isWithinLinkGraceWindow(pr) {
|
|
4162
|
+
if (!pr || !pr.created) return false;
|
|
4163
|
+
if (pr.headSha) return false; // poll has run successfully — past the grace window
|
|
4164
|
+
const createdMs = Date.parse(pr.created);
|
|
4165
|
+
if (!Number.isFinite(createdMs)) return false;
|
|
4166
|
+
return (Date.now() - createdMs) < PR_LINK_GRACE_WINDOW_MS;
|
|
4167
|
+
}
|
|
4168
|
+
|
|
4161
4169
|
function prCausePart(value, fallback = 'unknown') {
|
|
4162
4170
|
const raw = String(value || '').trim();
|
|
4163
4171
|
return shared.safeSlugComponent(raw || fallback, 80);
|
|
@@ -4923,7 +4931,7 @@ function resolveWorkItemPrRecord(item, project) {
|
|
|
4923
4931
|
if (!project) return null;
|
|
4924
4932
|
const prRef = getWorkItemPrRef(item);
|
|
4925
4933
|
if (!prRef) return null;
|
|
4926
|
-
const prs =
|
|
4934
|
+
const prs = safeJsonArr(projectPrPath(project));
|
|
4927
4935
|
shared.normalizePrRecords(prs, project);
|
|
4928
4936
|
return shared.findPrRecord(prs, prRef, project);
|
|
4929
4937
|
}
|
|
@@ -5010,7 +5018,7 @@ function discoverFromWorkItems(config, project) {
|
|
|
5010
5018
|
}
|
|
5011
5019
|
|
|
5012
5020
|
const root = project?.localPath ? path.resolve(project.localPath) : path.resolve(MINIONS_DIR, '..');
|
|
5013
|
-
const items =
|
|
5021
|
+
const items = safeJsonArr(projectWorkItemsPath(project));
|
|
5014
5022
|
const cooldownMs = (src.cooldownMinutes || 0) * 60 * 1000;
|
|
5015
5023
|
const newWork = [];
|
|
5016
5024
|
// PRD sync for dispatched status deferred to spawnAgent success (#480)
|
|
@@ -5560,7 +5568,7 @@ function extractSpecInfo(filePath, projectRoot_) {
|
|
|
5560
5568
|
*/
|
|
5561
5569
|
function discoverCentralWorkItems(config) {
|
|
5562
5570
|
const centralPath = path.join(MINIONS_DIR, 'work-items.json');
|
|
5563
|
-
const items =
|
|
5571
|
+
const items = safeJsonArr(centralPath);
|
|
5564
5572
|
const projects = getProjects(config);
|
|
5565
5573
|
const dispatchProjects = getCentralDispatchProjects(projects);
|
|
5566
5574
|
const newWork = [];
|
|
@@ -6068,11 +6076,11 @@ async function discoverWork(config) {
|
|
|
6068
6076
|
// readdir and read (e.g. concurrent archive), do not resurrect it
|
|
6069
6077
|
// from a stale .backup sidecar (W-mouptdh1000h9f39).
|
|
6070
6078
|
const plan = safeJsonNoRestore(path.join(prdDir, f));
|
|
6071
|
-
if (!plan?.missing_features || plan.status ===
|
|
6072
|
-
if (plan?.status ===
|
|
6079
|
+
if (!plan?.missing_features || plan.status === PLAN_STATUS.COMPLETED) {
|
|
6080
|
+
if (plan?.status === PLAN_STATUS.COMPLETED) completedPlanCache.add(f);
|
|
6073
6081
|
continue;
|
|
6074
6082
|
}
|
|
6075
|
-
if (plan.status !==
|
|
6083
|
+
if (plan.status !== PLAN_STATUS.APPROVED && plan.status !== PLAN_STATUS.ACTIVE) continue;
|
|
6076
6084
|
// Simulate the meta object checkPlanCompletion expects
|
|
6077
6085
|
const completed = lifecycle.checkPlanCompletion({ item: { sourcePlan: f } }, config);
|
|
6078
6086
|
if (completed) completedPlanCache.add(f);
|
|
@@ -6380,14 +6388,14 @@ async function tickInner() {
|
|
|
6380
6388
|
const projects = getProjects(config);
|
|
6381
6389
|
const pullRequests = projects.flatMap(p => {
|
|
6382
6390
|
const prPath = path.join(MINIONS_DIR, 'projects', p.name, 'pull-requests.json');
|
|
6383
|
-
return
|
|
6391
|
+
return safeJsonArr(prPath);
|
|
6384
6392
|
});
|
|
6385
6393
|
const workItems = projects.flatMap(p => {
|
|
6386
6394
|
const wiPath = path.join(MINIONS_DIR, 'projects', p.name, 'work-items.json');
|
|
6387
|
-
return
|
|
6395
|
+
return safeJsonArr(wiPath);
|
|
6388
6396
|
});
|
|
6389
6397
|
// Also include central work items
|
|
6390
|
-
const centralWi =
|
|
6398
|
+
const centralWi = safeJsonArr(path.join(MINIONS_DIR, 'work-items.json'));
|
|
6391
6399
|
|
|
6392
6400
|
// Gather state for the new generalized target types. Each block is
|
|
6393
6401
|
// best-effort — if a module/file is missing the watch evaluator will
|
|
@@ -6411,7 +6419,7 @@ async function tickInner() {
|
|
|
6411
6419
|
} catch { /* optional */ }
|
|
6412
6420
|
|
|
6413
6421
|
let scheduleRuns = {};
|
|
6414
|
-
try { scheduleRuns =
|
|
6422
|
+
try { scheduleRuns = safeJsonObj(path.join(MINIONS_DIR, 'engine', 'schedule-runs.json')); } catch { /* optional */ }
|
|
6415
6423
|
|
|
6416
6424
|
let pipelineRuns = {};
|
|
6417
6425
|
try { pipelineRuns = require('./engine/pipeline').getPipelineRuns(); } catch { /* optional */ }
|
|
@@ -6481,10 +6489,10 @@ async function tickInner() {
|
|
|
6481
6489
|
continue;
|
|
6482
6490
|
}
|
|
6483
6491
|
const plan = safeJson(path.join(PRD_DIR, file));
|
|
6484
|
-
if (plan && plan.missing_features && plan.status !==
|
|
6492
|
+
if (plan && plan.missing_features && plan.status !== PLAN_STATUS.COMPLETED) {
|
|
6485
6493
|
const completed = checkPlanCompletion({ item: { sourcePlan: file } }, config);
|
|
6486
6494
|
if (completed) completedPlanCache.add(file);
|
|
6487
|
-
} else if (plan?.status ===
|
|
6495
|
+
} else if (plan?.status === PLAN_STATUS.COMPLETED) {
|
|
6488
6496
|
completedPlanCache.add(file);
|
|
6489
6497
|
}
|
|
6490
6498
|
}
|
|
@@ -6983,6 +6991,7 @@ module.exports = {
|
|
|
6983
6991
|
_maxTurnsForType, buildProjectContext, normalizeAc, _buildAgentSpawnFlags, _classifyAgentFailure, // exported for testing
|
|
6984
6992
|
promoteCheckpointSteeringForClose, // exported for testing
|
|
6985
6993
|
normalizePrBranch, resolvePrBranch, prCausePart, getPrCauseHead, getPrCauseBase, getPrAutomationCauseKey, getPrAutomationDispatchKey, // exported for testing
|
|
6994
|
+
ensurePrBranchForDispatch, isWithinLinkGraceWindow, PR_LINK_GRACE_WINDOW_MS, // exported for testing (W-mphm0kt0000cebc3)
|
|
6986
6995
|
|
|
6987
6996
|
// Playbooks
|
|
6988
6997
|
renderPlaybook, validatePlaybookVars, PLAYBOOK_REQUIRED_VARS, buildWorkItemDispatchVars,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2043",
|
|
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"
|