@yemi33/minions 0.1.1026 → 0.1.1028
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 +35 -1
- package/dashboard/js/modal-qa.js +326 -104
- package/dashboard/js/modal.js +4 -11
- package/dashboard.js +129 -88
- package/engine/cleanup.js +12 -10
- package/engine/shared.js +2 -1
- package/engine.js +15 -6
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.1.
|
|
3
|
+
## 0.1.1028 (2026-04-16)
|
|
4
4
|
|
|
5
5
|
### Features
|
|
6
6
|
- route implement items to dedicated implement playbook (#1115)
|
|
@@ -25,6 +25,40 @@
|
|
|
25
25
|
- harden audited state transitions
|
|
26
26
|
|
|
27
27
|
### Other
|
|
28
|
+
- [E2E] Dashboard & engine perf: gzip cache, tiered status, lock backoff, polling parallelization (#1174)
|
|
29
|
+
- Fix doc chat session isolation
|
|
30
|
+
- test(pipeline): add unit tests for CRUD, stage execution, run lifecycle (#1162)
|
|
31
|
+
- chore: test publish after removing stale status check
|
|
32
|
+
- chore: trigger publish test
|
|
33
|
+
- chore: test publish workflow fix
|
|
34
|
+
- chore: trigger publish workflow test
|
|
35
|
+
|
|
36
|
+
## 0.1.1027 (2026-04-16)
|
|
37
|
+
|
|
38
|
+
### Features
|
|
39
|
+
- route implement items to dedicated implement playbook (#1115)
|
|
40
|
+
|
|
41
|
+
### Fixes
|
|
42
|
+
- prevent test from corrupting live meeting state
|
|
43
|
+
- scheduler enabled field falsy check treats undefined as disabled (#1160)
|
|
44
|
+
- update branch-dedup tests to use canonical PR IDs after merge with master (#1146)
|
|
45
|
+
- auto-review not firing for manually-linked PRs with autoObserve=true
|
|
46
|
+
- mark PR abandoned on 404 instead of silently retrying each tick
|
|
47
|
+
- replace undefined PROJECTS with config.projects in checkWatches (#1108)
|
|
48
|
+
- write permission for publish workflow
|
|
49
|
+
- run tests inline and post check runs for publish PRs
|
|
50
|
+
- add maxBuffer to all GitHub CLI spawns + paginate PR list (#1130)
|
|
51
|
+
- add required CI checks for PRs + update publish for auto-merge
|
|
52
|
+
- revert to PR merge now that stale status check is removed
|
|
53
|
+
- guard live review check against undefined vote/state values (#1132)
|
|
54
|
+
- push version bump directly to master instead of via PR
|
|
55
|
+
- add push-triggered CI for chore/publish branches
|
|
56
|
+
- publish workflow chore PRs failing to merge
|
|
57
|
+
- harden KB ordering
|
|
58
|
+
- harden audited state transitions
|
|
59
|
+
|
|
60
|
+
### Other
|
|
61
|
+
- Fix doc chat session isolation
|
|
28
62
|
- test(pipeline): add unit tests for CRUD, stage execution, run lifecycle (#1162)
|
|
29
63
|
- chore: test publish after removing stale status check
|
|
30
64
|
- chore: trigger publish test
|
package/dashboard/js/modal-qa.js
CHANGED
|
@@ -40,6 +40,7 @@ function _renderQaUserMessage(thread, message, selection) {
|
|
|
40
40
|
_showThreadWrap();
|
|
41
41
|
}
|
|
42
42
|
const _qaSessions = new Map(); // persist conversations across modal open/close {key → {history, threadHtml}}
|
|
43
|
+
const _qaRuntime = new Map(); // key → {history, processing, abortController, queue}
|
|
43
44
|
// Restore from localStorage
|
|
44
45
|
try {
|
|
45
46
|
const saved = JSON.parse(localStorage.getItem('qa-sessions') || '{}');
|
|
@@ -55,6 +56,175 @@ function _saveQaSessions() {
|
|
|
55
56
|
} catch { /* localStorage might be full */ }
|
|
56
57
|
}
|
|
57
58
|
|
|
59
|
+
function _qaCloneQueue(queue) {
|
|
60
|
+
return Array.isArray(queue) ? queue.map(item => ({ ...item })) : [];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function _qaGetRuntime(key) {
|
|
64
|
+
if (!key) return null;
|
|
65
|
+
let runtime = _qaRuntime.get(key);
|
|
66
|
+
if (!runtime) {
|
|
67
|
+
const prior = _qaSessions.get(key);
|
|
68
|
+
runtime = {
|
|
69
|
+
history: Array.isArray(prior?.history) ? prior.history.slice() : [],
|
|
70
|
+
processing: false,
|
|
71
|
+
abortController: null,
|
|
72
|
+
queue: _qaCloneQueue(prior?.queue),
|
|
73
|
+
};
|
|
74
|
+
_qaRuntime.set(key, runtime);
|
|
75
|
+
}
|
|
76
|
+
return runtime;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function _qaThreadEl() {
|
|
80
|
+
return document.getElementById('modal-qa-thread');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function _qaThreadHtml() {
|
|
84
|
+
return (_qaThreadEl() || {}).innerHTML || '';
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function _qaIsActiveSession(key) {
|
|
88
|
+
return !!key && _qaSessionKey === key && document.getElementById('modal')?.classList?.contains('open');
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function _qaSyncActiveRuntime() {
|
|
92
|
+
if (!_qaSessionKey) return;
|
|
93
|
+
const runtime = _qaGetRuntime(_qaSessionKey);
|
|
94
|
+
if (!runtime) return;
|
|
95
|
+
runtime.history = _qaHistory.slice();
|
|
96
|
+
runtime.processing = _qaProcessing;
|
|
97
|
+
runtime.abortController = _qaAbortController || null;
|
|
98
|
+
runtime.queue = _qaCloneQueue(_qaQueue);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function _qaPersistSession(key, { threadHtml, docContext, filePath, history, queue } = {}) {
|
|
102
|
+
if (!key) return;
|
|
103
|
+
const runtime = _qaGetRuntime(key);
|
|
104
|
+
const prior = _qaSessions.get(key) || {};
|
|
105
|
+
const persistedHistory = Array.isArray(history)
|
|
106
|
+
? history.slice()
|
|
107
|
+
: Array.isArray(runtime?.history)
|
|
108
|
+
? runtime.history.slice()
|
|
109
|
+
: Array.isArray(prior.history)
|
|
110
|
+
? prior.history.slice()
|
|
111
|
+
: [];
|
|
112
|
+
_qaSessions.set(key, {
|
|
113
|
+
history: persistedHistory,
|
|
114
|
+
threadHtml: threadHtml != null ? threadHtml : (prior.threadHtml || ''),
|
|
115
|
+
docContext: docContext ? { ...docContext } : (prior.docContext ? { ...prior.docContext } : { title: '', content: '', selection: '' }),
|
|
116
|
+
filePath: filePath !== undefined ? filePath : prior.filePath,
|
|
117
|
+
queue: Array.isArray(queue) ? _qaCloneQueue(queue) : _qaCloneQueue(runtime?.queue),
|
|
118
|
+
});
|
|
119
|
+
_saveQaSessions();
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
function _qaSaveActiveSessionState() {
|
|
123
|
+
if (!_qaSessionKey) return;
|
|
124
|
+
_qaSyncActiveRuntime();
|
|
125
|
+
_qaPersistSession(_qaSessionKey, {
|
|
126
|
+
threadHtml: _qaThreadHtml(),
|
|
127
|
+
docContext: { ..._modalDocContext },
|
|
128
|
+
filePath: _modalFilePath,
|
|
129
|
+
history: _qaHistory,
|
|
130
|
+
queue: _qaQueue,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function _qaLoadSessionState(key) {
|
|
135
|
+
const prior = _qaSessions.get(key);
|
|
136
|
+
const runtime = _qaGetRuntime(key);
|
|
137
|
+
_qaHistory = Array.isArray(runtime?.history) && runtime.history.length
|
|
138
|
+
? runtime.history.slice()
|
|
139
|
+
: Array.isArray(prior?.history)
|
|
140
|
+
? prior.history.slice()
|
|
141
|
+
: [];
|
|
142
|
+
_qaProcessing = !!runtime?.processing;
|
|
143
|
+
_qaAbortController = runtime?.abortController || null;
|
|
144
|
+
_qaQueue = _qaCloneQueue(runtime?.queue);
|
|
145
|
+
const thread = _qaThreadEl();
|
|
146
|
+
if (thread) {
|
|
147
|
+
const tmp = document.createElement('div');
|
|
148
|
+
tmp.innerHTML = prior?.threadHtml || '';
|
|
149
|
+
if (!_qaProcessing) tmp.querySelectorAll('.modal-qa-loading').forEach(el => el.remove());
|
|
150
|
+
thread.innerHTML = tmp.innerHTML;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
function _qaResetActiveState() {
|
|
155
|
+
_qaHistory = [];
|
|
156
|
+
_qaProcessing = false;
|
|
157
|
+
_qaAbortController = null;
|
|
158
|
+
_qaQueue = [];
|
|
159
|
+
_qaSessionKey = '';
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function _qaBuildUserMessageHtml(message, selection) {
|
|
163
|
+
let qHtml = '<div class="modal-qa-q">' + escHtml(message);
|
|
164
|
+
if (selection) {
|
|
165
|
+
qHtml += '<span class="selection-ref">Re: "' + escHtml(selection.slice(0, 100)) + ((selection.length > 100) ? '...' : '') + '"</span>';
|
|
166
|
+
}
|
|
167
|
+
qHtml += '</div>';
|
|
168
|
+
return qHtml;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function _qaBuildQueuedHtml(message) {
|
|
172
|
+
const preview = escHtml(message.length > 60 ? message.slice(0, 57) + '...' : message);
|
|
173
|
+
return '<div class="qa-queued-item" style="color:var(--muted);font-size:10px;padding:4px 8px">Queued: "' + preview + '"</div>';
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function _qaBuildLoadingHtml(loadingId, queueCount) {
|
|
177
|
+
const qaQueueBadge = queueCount > 0 ? ' <span style="font-size:9px;color:var(--muted);background:var(--surface);padding:1px 5px;border-radius:8px;border:1px solid var(--border)">+' + queueCount + ' queued</span>' : '';
|
|
178
|
+
return '<div class="modal-qa-loading" id="' + loadingId + '">' +
|
|
179
|
+
'<div class="dot-pulse"><span></span><span></span><span></span></div> ' +
|
|
180
|
+
'<span id="' + loadingId + '-text">Thinking...</span> ' +
|
|
181
|
+
'<span id="' + loadingId + '-time" style="font-size:10px;color:var(--muted)"></span>' +
|
|
182
|
+
' <button onclick="qaAbort()" style="font-size:9px;padding:2px 8px;background:var(--surface2);border:1px solid var(--border);border-radius:4px;color:var(--red);cursor:pointer">Stop</button>' +
|
|
183
|
+
qaQueueBadge + '</div>';
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
function _qaBuildAssistantHtml(text, opts) {
|
|
187
|
+
const body = opts?.isError ? escHtml(text) : renderMd(text);
|
|
188
|
+
const style = opts?.isError
|
|
189
|
+
? 'color:' + (opts?.color || 'var(--red)')
|
|
190
|
+
: 'border-left-color:' + (opts?.borderColor || 'var(--blue)');
|
|
191
|
+
const pad = opts?.isError ? '' : 'padding-right:24px;';
|
|
192
|
+
return '<div class="modal-qa-a" style="' + style + '">' +
|
|
193
|
+
(opts?.isError ? '' : llmCopyBtn()) +
|
|
194
|
+
body +
|
|
195
|
+
'<div style="font-size:9px;color:var(--muted);margin-top:4px;text-align:right;' + pad + '">' + opts.elapsed + 's</div>' +
|
|
196
|
+
'</div>';
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function _qaMutateThreadHtml(key, mutate) {
|
|
200
|
+
const tmp = document.createElement('div');
|
|
201
|
+
tmp.innerHTML = _qaIsActiveSession(key) ? _qaThreadHtml() : ((_qaSessions.get(key) || {}).threadHtml || '');
|
|
202
|
+
mutate(tmp);
|
|
203
|
+
const html = tmp.innerHTML;
|
|
204
|
+
if (_qaIsActiveSession(key)) {
|
|
205
|
+
const thread = _qaThreadEl();
|
|
206
|
+
if (thread) {
|
|
207
|
+
thread.innerHTML = html;
|
|
208
|
+
thread.scrollTop = thread.scrollHeight;
|
|
209
|
+
}
|
|
210
|
+
_showThreadWrap();
|
|
211
|
+
}
|
|
212
|
+
return html;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function _qaResumeQueuedMessages() {
|
|
216
|
+
if (!_qaSessionKey || _qaProcessing || _qaQueue.length === 0) return;
|
|
217
|
+
const next = _qaQueue.shift();
|
|
218
|
+
const thread = _qaThreadEl();
|
|
219
|
+
if (thread) {
|
|
220
|
+
const queuedEl = thread.querySelector('.qa-queued-item');
|
|
221
|
+
if (queuedEl) queuedEl.remove();
|
|
222
|
+
_renderQaUserMessage(thread, next.message, next.selection);
|
|
223
|
+
}
|
|
224
|
+
_qaSaveActiveSessionState();
|
|
225
|
+
_processQaMessage(next.message, next.selection);
|
|
226
|
+
}
|
|
227
|
+
|
|
58
228
|
function modalAskAboutSelection() {
|
|
59
229
|
document.getElementById('ask-selection-btn').style.display = 'none';
|
|
60
230
|
|
|
@@ -91,16 +261,14 @@ function clearQaSelection() {
|
|
|
91
261
|
function _initQaSession() {
|
|
92
262
|
var key = _modalFilePath || _modalDocContext.title || '';
|
|
93
263
|
if (!key || _qaSessionKey === key) return;
|
|
264
|
+
if (_qaSessionKey && _qaSessionKey !== key) _qaSaveActiveSessionState();
|
|
94
265
|
_qaSessionKey = key;
|
|
95
|
-
// Clear notification badge on the source card when reopening
|
|
96
266
|
const card = findCardForFile(_modalFilePath);
|
|
97
267
|
if (card) clearNotifBadge(card);
|
|
98
268
|
var prior = _qaSessions.get(key);
|
|
269
|
+
_qaLoadSessionState(key);
|
|
99
270
|
if (prior) {
|
|
100
|
-
_qaHistory = prior.history;
|
|
101
|
-
document.getElementById('modal-qa-thread').innerHTML = prior.threadHtml;
|
|
102
271
|
if (prior.docContext) {
|
|
103
|
-
// Preserve freshly-fetched content and title — prior session may have stale/empty content
|
|
104
272
|
const freshContent = _modalDocContext.content;
|
|
105
273
|
const freshTitle = _modalDocContext.title;
|
|
106
274
|
_modalDocContext = Object.assign({}, prior.docContext, {
|
|
@@ -111,11 +279,13 @@ function _initQaSession() {
|
|
|
111
279
|
}
|
|
112
280
|
if (prior.filePath) _modalFilePath = prior.filePath;
|
|
113
281
|
_showThreadWrap();
|
|
114
|
-
// Defer scroll — container just transitioned from display:none, layout not yet computed
|
|
115
282
|
requestAnimationFrame(function() {
|
|
116
283
|
var thread = document.getElementById('modal-qa-thread');
|
|
117
284
|
if (thread) thread.scrollTop = thread.scrollHeight;
|
|
118
285
|
});
|
|
286
|
+
if (_qaQueue.length > 0 && !_qaProcessing) {
|
|
287
|
+
setTimeout(_qaResumeQueuedMessages, 0);
|
|
288
|
+
}
|
|
119
289
|
} else {
|
|
120
290
|
_qaHistory = [];
|
|
121
291
|
document.getElementById('modal-qa-thread').innerHTML = '';
|
|
@@ -127,9 +297,13 @@ function _initQaSession() {
|
|
|
127
297
|
}
|
|
128
298
|
|
|
129
299
|
function clearQaConversation() {
|
|
300
|
+
const runtime = _qaGetRuntime(_qaSessionKey);
|
|
301
|
+
if (_qaAbortController) _qaAbortController.abort();
|
|
302
|
+
if (runtime?.abortController && runtime.abortController !== _qaAbortController) runtime.abortController.abort();
|
|
130
303
|
_qaHistory = [];
|
|
131
304
|
_qaQueue = [];
|
|
132
305
|
_qaProcessing = false;
|
|
306
|
+
_qaAbortController = null;
|
|
133
307
|
document.getElementById('modal-qa-thread').innerHTML = '';
|
|
134
308
|
var wrap = document.getElementById('modal-qa-thread-wrap');
|
|
135
309
|
var expandBar = document.getElementById('qa-expand-bar');
|
|
@@ -137,6 +311,7 @@ function clearQaConversation() {
|
|
|
137
311
|
if (expandBar) expandBar.style.display = 'none';
|
|
138
312
|
if (_qaSessionKey) {
|
|
139
313
|
_qaSessions.delete(_qaSessionKey);
|
|
314
|
+
_qaRuntime.set(_qaSessionKey, { history: [], processing: false, abortController: null, queue: [] });
|
|
140
315
|
_saveQaSessions();
|
|
141
316
|
}
|
|
142
317
|
}
|
|
@@ -164,7 +339,6 @@ function modalSend() {
|
|
|
164
339
|
var thread = document.getElementById('modal-qa-thread');
|
|
165
340
|
const selection = _modalDocContext.selection || '';
|
|
166
341
|
|
|
167
|
-
// Clear input immediately so user can type next message
|
|
168
342
|
input.value = '';
|
|
169
343
|
_modalDocContext.selection = '';
|
|
170
344
|
document.getElementById('modal-qa-pill').style.display = 'none';
|
|
@@ -174,63 +348,79 @@ function modalSend() {
|
|
|
174
348
|
showToast('cmd-toast', 'Queue full — wait for current response', false);
|
|
175
349
|
return;
|
|
176
350
|
}
|
|
177
|
-
// Queue the message — show only grey queued indicator (blue bubble shows when processing starts)
|
|
178
351
|
_qaQueue.push({ message, selection });
|
|
179
|
-
|
|
180
|
-
thread.insertAdjacentHTML('beforeend', '<div class="qa-queued-item" style="color:var(--muted);font-size:10px;padding:4px 8px">Queued: "' + preview + '"</div>');
|
|
352
|
+
thread.insertAdjacentHTML('beforeend', _qaBuildQueuedHtml(message));
|
|
181
353
|
thread.scrollTop = thread.scrollHeight;
|
|
182
354
|
_showThreadWrap();
|
|
355
|
+
_qaSaveActiveSessionState();
|
|
183
356
|
return;
|
|
184
357
|
}
|
|
185
358
|
|
|
186
|
-
// Show message in thread when processing starts (not when queued)
|
|
187
359
|
_renderQaUserMessage(thread, message, selection);
|
|
188
|
-
|
|
360
|
+
_qaSaveActiveSessionState();
|
|
189
361
|
_processQaMessage(message, selection);
|
|
190
362
|
}
|
|
191
363
|
|
|
192
|
-
async function _processQaMessage(message, selection) {
|
|
193
|
-
const
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
const
|
|
199
|
-
const
|
|
364
|
+
async function _processQaMessage(message, selection, opts) {
|
|
365
|
+
const sessionKey = opts?.sessionKey || _qaSessionKey;
|
|
366
|
+
if (!sessionKey) return;
|
|
367
|
+
const runtime = _qaGetRuntime(sessionKey);
|
|
368
|
+
if (!runtime) return;
|
|
369
|
+
|
|
370
|
+
const prior = _qaSessions.get(sessionKey);
|
|
371
|
+
const capturedFilePath = opts?.filePath !== undefined ? opts.filePath : (prior?.filePath || _modalFilePath);
|
|
372
|
+
const capturedDocContext = Object.assign({}, prior?.docContext || {}, opts?.docContext || (_qaIsActiveSession(sessionKey) ? _modalDocContext : {}));
|
|
373
|
+
const isActiveSession = _qaIsActiveSession(sessionKey);
|
|
374
|
+
|
|
375
|
+
runtime.processing = true;
|
|
376
|
+
const abortController = new AbortController();
|
|
377
|
+
runtime.abortController = abortController;
|
|
378
|
+
if (isActiveSession) {
|
|
379
|
+
_qaProcessing = true;
|
|
380
|
+
_qaAbortController = abortController;
|
|
381
|
+
}
|
|
200
382
|
|
|
201
|
-
// Show processing badge on the source card
|
|
202
383
|
const sourceCard = findCardForFile(capturedFilePath);
|
|
203
384
|
if (sourceCard) showNotifBadge(sourceCard, 'processing');
|
|
204
385
|
|
|
205
386
|
const loadingId = 'chat-loading-' + Date.now();
|
|
206
|
-
const
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
387
|
+
const loadingHtml = _qaBuildLoadingHtml(loadingId, runtime.queue.length);
|
|
388
|
+
const startThreadHtml = _qaMutateThreadHtml(sessionKey, tmp => {
|
|
389
|
+
tmp.insertAdjacentHTML('beforeend', loadingHtml);
|
|
390
|
+
});
|
|
391
|
+
_qaPersistSession(sessionKey, {
|
|
392
|
+
threadHtml: startThreadHtml,
|
|
393
|
+
docContext: capturedDocContext,
|
|
394
|
+
filePath: capturedFilePath,
|
|
395
|
+
history: runtime.history,
|
|
396
|
+
queue: runtime.queue,
|
|
397
|
+
});
|
|
215
398
|
|
|
216
|
-
const isPlanEdit =
|
|
399
|
+
const isPlanEdit = capturedFilePath && capturedFilePath.match(/^plans\/.*\.md$/);
|
|
217
400
|
const qaStartTime = Date.now();
|
|
218
401
|
const qaPhases = isPlanEdit
|
|
219
402
|
? [[0,'Reading plan...'],[3000,'Analyzing structure...'],[8000,'Researching context...'],[15000,'Drafting revisions...'],[30000,'Writing updated plan...'],[60000,'Still working (large document)...'],[120000,'Deep edit in progress...'],[300000,'Almost there...']]
|
|
220
403
|
: [[0,'Thinking...'],[3000,'Reading document...'],[8000,'Analyzing...'],[20000,'Still working...'],[60000,'Taking a while...']];
|
|
221
404
|
const qaTimer = setInterval(() => {
|
|
222
405
|
const elapsed = Date.now() - qaStartTime;
|
|
223
|
-
const timeEl = document.getElementById(loadingId + '-time');
|
|
224
|
-
const textEl = document.getElementById(loadingId + '-text');
|
|
406
|
+
const timeEl = _qaIsActiveSession(sessionKey) ? document.getElementById(loadingId + '-time') : null;
|
|
407
|
+
const textEl = _qaIsActiveSession(sessionKey) ? document.getElementById(loadingId + '-text') : null;
|
|
225
408
|
if (timeEl) timeEl.textContent = Math.floor(elapsed / 1000) + 's';
|
|
226
|
-
if (textEl) {
|
|
409
|
+
if (textEl) {
|
|
410
|
+
for (let i = qaPhases.length - 1; i >= 0; i--) {
|
|
411
|
+
if (elapsed >= qaPhases[i][0]) {
|
|
412
|
+
textEl.textContent = qaPhases[i][1];
|
|
413
|
+
break;
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
}
|
|
227
417
|
}, 500);
|
|
228
418
|
|
|
229
419
|
try {
|
|
230
420
|
const res = await fetch('/api/doc-chat', {
|
|
231
421
|
method: 'POST',
|
|
232
422
|
headers: { 'Content-Type': 'application/json' },
|
|
233
|
-
signal:
|
|
423
|
+
signal: abortController.signal,
|
|
234
424
|
body: JSON.stringify({
|
|
235
425
|
message,
|
|
236
426
|
document: capturedDocContext.content,
|
|
@@ -242,109 +432,141 @@ async function _processQaMessage(message, selection) {
|
|
|
242
432
|
});
|
|
243
433
|
const data = await res.json();
|
|
244
434
|
clearInterval(qaTimer);
|
|
245
|
-
const
|
|
246
|
-
|
|
435
|
+
const qaElapsed = Math.round((Date.now() - qaStartTime) / 1000);
|
|
436
|
+
let sessionDocContext = { ...capturedDocContext };
|
|
247
437
|
|
|
248
438
|
if (data.ok) {
|
|
249
439
|
const borderColor = data.edited ? 'var(--green)' : 'var(--blue)';
|
|
250
440
|
const suffix = data.edited ? '\n\n\u2713 Document saved.' : '';
|
|
251
|
-
const
|
|
252
|
-
const
|
|
253
|
-
|
|
441
|
+
const answerHtml = _qaBuildAssistantHtml(data.answer + suffix, { borderColor, elapsed: qaElapsed });
|
|
442
|
+
const updatedThreadHtml = _qaMutateThreadHtml(sessionKey, tmp => {
|
|
443
|
+
const loadingEl = tmp.querySelector('#' + loadingId);
|
|
444
|
+
if (loadingEl) loadingEl.remove();
|
|
445
|
+
tmp.insertAdjacentHTML('beforeend', answerHtml);
|
|
446
|
+
});
|
|
254
447
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
448
|
+
runtime.history.push({ role: 'user', text: message });
|
|
449
|
+
runtime.history.push({ role: 'assistant', text: data.answer });
|
|
450
|
+
if (_qaIsActiveSession(sessionKey)) _qaHistory = runtime.history.slice();
|
|
258
451
|
|
|
259
|
-
// Notify sidebar page link
|
|
260
452
|
_qaNotifySidebar(capturedFilePath);
|
|
261
|
-
|
|
262
|
-
// Execute any CC actions (dispatch, note, etc.)
|
|
263
453
|
if (data.actions && data.actions.length > 0) {
|
|
264
|
-
for (const action of data.actions)
|
|
454
|
+
for (const action of data.actions) await ccExecuteAction(action);
|
|
265
455
|
}
|
|
266
456
|
|
|
267
|
-
// Refresh modal body if document was edited
|
|
268
457
|
if (data.edited && data.content) {
|
|
269
458
|
const display = data.content.replace(/^---[\s\S]*?---\n*/m, '');
|
|
270
459
|
const isJson = capturedFilePath && capturedFilePath.endsWith('.json');
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
460
|
+
sessionDocContext.content = display;
|
|
461
|
+
sessionDocContext.selection = '';
|
|
462
|
+
if (_qaIsActiveSession(sessionKey)) {
|
|
463
|
+
const body = document.getElementById('modal-body');
|
|
464
|
+
if (isJson) {
|
|
465
|
+
body.textContent = display;
|
|
466
|
+
} else {
|
|
467
|
+
body.innerHTML = renderMd(display);
|
|
468
|
+
body.style.fontFamily = "'Segoe UI', system-ui, sans-serif";
|
|
469
|
+
body.style.whiteSpace = 'normal';
|
|
470
|
+
}
|
|
471
|
+
_modalDocContext.content = display;
|
|
278
472
|
}
|
|
279
|
-
_modalDocContext.content = display;
|
|
280
473
|
}
|
|
474
|
+
|
|
475
|
+
_qaPersistSession(sessionKey, {
|
|
476
|
+
threadHtml: updatedThreadHtml,
|
|
477
|
+
docContext: sessionDocContext,
|
|
478
|
+
filePath: capturedFilePath,
|
|
479
|
+
history: runtime.history,
|
|
480
|
+
queue: runtime.queue,
|
|
481
|
+
});
|
|
281
482
|
} else {
|
|
282
|
-
const
|
|
283
|
-
|
|
483
|
+
const errorHtml = _qaBuildAssistantHtml('Error: ' + (data.error || 'Failed'), { color: 'var(--red)', isError: true, elapsed: qaElapsed });
|
|
484
|
+
const updatedThreadHtml = _qaMutateThreadHtml(sessionKey, tmp => {
|
|
485
|
+
const loadingEl = tmp.querySelector('#' + loadingId);
|
|
486
|
+
if (loadingEl) loadingEl.remove();
|
|
487
|
+
tmp.insertAdjacentHTML('beforeend', errorHtml);
|
|
488
|
+
});
|
|
489
|
+
_qaPersistSession(sessionKey, {
|
|
490
|
+
threadHtml: updatedThreadHtml,
|
|
491
|
+
docContext: sessionDocContext,
|
|
492
|
+
filePath: capturedFilePath,
|
|
493
|
+
history: runtime.history,
|
|
494
|
+
queue: runtime.queue,
|
|
495
|
+
});
|
|
284
496
|
}
|
|
285
497
|
} catch (e) {
|
|
286
498
|
clearInterval(qaTimer);
|
|
287
|
-
const loadingEl = document.getElementById(loadingId);
|
|
288
|
-
if (loadingEl) loadingEl.remove();
|
|
289
499
|
const qaElapsedExc = Math.round((Date.now() - qaStartTime) / 1000);
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
500
|
+
const messageHtml = e.name === 'AbortError'
|
|
501
|
+
? _qaBuildAssistantHtml('Stopped', { color: 'var(--muted)', isError: true, elapsed: qaElapsedExc })
|
|
502
|
+
: _qaBuildAssistantHtml('Error: ' + e.message, { color: 'var(--red)', isError: true, elapsed: qaElapsedExc });
|
|
503
|
+
const updatedThreadHtml = _qaMutateThreadHtml(sessionKey, tmp => {
|
|
504
|
+
const loadingEl = tmp.querySelector('#' + loadingId);
|
|
505
|
+
if (loadingEl) loadingEl.remove();
|
|
506
|
+
tmp.insertAdjacentHTML('beforeend', messageHtml);
|
|
507
|
+
});
|
|
508
|
+
_qaPersistSession(sessionKey, {
|
|
509
|
+
threadHtml: updatedThreadHtml,
|
|
510
|
+
docContext: capturedDocContext,
|
|
511
|
+
filePath: capturedFilePath,
|
|
512
|
+
history: runtime.history,
|
|
513
|
+
queue: runtime.queue,
|
|
514
|
+
});
|
|
295
515
|
}
|
|
296
516
|
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
if (_qaQueue.length === 0) {
|
|
303
|
-
const doneCard = findCardForFile(capturedFilePath);
|
|
304
|
-
if (doneCard) clearNotifBadge(doneCard);
|
|
517
|
+
runtime.processing = false;
|
|
518
|
+
runtime.abortController = null;
|
|
519
|
+
if (_qaIsActiveSession(sessionKey)) {
|
|
520
|
+
_qaProcessing = false;
|
|
521
|
+
_qaAbortController = null;
|
|
305
522
|
}
|
|
306
523
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
_qaSessions.set(_qaSessionKey, {
|
|
314
|
-
history: _qaHistory,
|
|
315
|
-
threadHtml: thread.innerHTML,
|
|
316
|
-
docContext: sessionDocContext,
|
|
317
|
-
filePath: sessionFilePath,
|
|
318
|
-
});
|
|
319
|
-
_saveQaSessions();
|
|
320
|
-
// Show notification badge on source card when modal was closed during processing
|
|
321
|
-
if (!modalIsOpen) {
|
|
322
|
-
const card = findCardForFile(sessionFilePath);
|
|
323
|
-
if (card) showNotifBadge(card, _qaQueue.length > 0 ? 'processing' : 'done');
|
|
324
|
-
_qaSessionKey = '';
|
|
524
|
+
if (runtime.queue.length === 0) {
|
|
525
|
+
const doneCard = findCardForFile(capturedFilePath);
|
|
526
|
+
if (_qaIsActiveSession(sessionKey)) {
|
|
527
|
+
if (doneCard) clearNotifBadge(doneCard);
|
|
528
|
+
} else if (doneCard) {
|
|
529
|
+
showNotifBadge(doneCard, 'done');
|
|
325
530
|
}
|
|
531
|
+
} else {
|
|
532
|
+
const pendingCard = findCardForFile(capturedFilePath);
|
|
533
|
+
if (pendingCard) showNotifBadge(pendingCard, 'processing');
|
|
326
534
|
}
|
|
327
535
|
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
const
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
536
|
+
if (runtime.queue.length > 0) {
|
|
537
|
+
const next = runtime.queue.shift();
|
|
538
|
+
const nextThreadHtml = _qaMutateThreadHtml(sessionKey, tmp => {
|
|
539
|
+
const queuedEl = tmp.querySelector('.qa-queued-item');
|
|
540
|
+
if (queuedEl) queuedEl.remove();
|
|
541
|
+
tmp.insertAdjacentHTML('beforeend', _qaBuildUserMessageHtml(next.message, next.selection));
|
|
542
|
+
});
|
|
543
|
+
_qaPersistSession(sessionKey, {
|
|
544
|
+
threadHtml: nextThreadHtml,
|
|
545
|
+
docContext: (_qaSessions.get(sessionKey) || {}).docContext || capturedDocContext,
|
|
546
|
+
filePath: capturedFilePath,
|
|
547
|
+
history: runtime.history,
|
|
548
|
+
queue: runtime.queue,
|
|
549
|
+
});
|
|
550
|
+
if (_qaIsActiveSession(sessionKey)) _qaQueue = _qaCloneQueue(runtime.queue);
|
|
551
|
+
_processQaMessage(next.message, next.selection, {
|
|
552
|
+
sessionKey,
|
|
553
|
+
filePath: capturedFilePath,
|
|
554
|
+
docContext: (_qaSessions.get(sessionKey) || {}).docContext || capturedDocContext,
|
|
555
|
+
});
|
|
556
|
+
} else if (_qaIsActiveSession(sessionKey)) {
|
|
557
|
+
_qaQueue = [];
|
|
337
558
|
document.getElementById('modal-qa-input')?.focus();
|
|
338
559
|
}
|
|
339
560
|
}
|
|
340
561
|
|
|
341
562
|
function qaAbort() {
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
563
|
+
const runtime = _qaGetRuntime(_qaSessionKey);
|
|
564
|
+
if (runtime?.abortController) {
|
|
565
|
+
runtime.abortController.abort();
|
|
566
|
+
runtime.abortController = null;
|
|
345
567
|
}
|
|
346
|
-
|
|
347
|
-
//
|
|
568
|
+
if (_qaAbortController) _qaAbortController = null;
|
|
569
|
+
// Don't reset _qaProcessing here — the catch block in _processQaMessage handles it.
|
|
348
570
|
// Don't clear _qaQueue — queued messages auto-process after abort.
|
|
349
571
|
}
|
|
350
572
|
|
package/dashboard/js/modal.js
CHANGED
|
@@ -13,24 +13,17 @@ function closeModal() {
|
|
|
13
13
|
const resetBtn = document.getElementById('modal-settings-reset');
|
|
14
14
|
if (resetBtn) resetBtn.remove();
|
|
15
15
|
// Save Q&A session for this document (persist across modal open/close)
|
|
16
|
-
if (_qaSessionKey && (_qaHistory.length > 0 || _qaQueue.length > 0)) {
|
|
17
|
-
|
|
18
|
-
history: _qaHistory,
|
|
19
|
-
threadHtml: (document.getElementById('modal-qa-thread') || {}).innerHTML || '',
|
|
20
|
-
docContext: { ..._modalDocContext },
|
|
21
|
-
filePath: _modalFilePath,
|
|
22
|
-
});
|
|
23
|
-
_saveQaSessions();
|
|
16
|
+
if (_qaSessionKey && (_qaHistory.length > 0 || _qaQueue.length > 0 || _qaProcessing)) {
|
|
17
|
+
_qaSaveActiveSessionState();
|
|
24
18
|
}
|
|
25
19
|
// If still processing, show animated badge on the source card
|
|
26
20
|
if (_qaProcessing && _modalFilePath) {
|
|
27
21
|
const card = findCardForFile(_modalFilePath);
|
|
28
22
|
if (card) showNotifBadge(card, 'processing');
|
|
29
23
|
}
|
|
30
|
-
// Reset UI state but don't kill processing
|
|
24
|
+
// Reset active UI state but don't kill background processing — it is tracked per session
|
|
25
|
+
_qaResetActiveState();
|
|
31
26
|
_modalDocContext = { title: '', content: '', selection: '' };
|
|
32
|
-
// Keep session key alive if processing is in flight — result will save when it completes
|
|
33
|
-
if (!_qaProcessing) _qaSessionKey = '';
|
|
34
27
|
document.getElementById('modal-qa-input').value = '';
|
|
35
28
|
document.getElementById('modal-qa-input').placeholder = 'Ask about this document (or select text first)...';
|
|
36
29
|
document.getElementById('modal-qa-pill').style.display = 'none';
|
package/dashboard.js
CHANGED
|
@@ -171,7 +171,14 @@ function getVerifyGuides() {
|
|
|
171
171
|
function getArchivedPrds() { return []; }
|
|
172
172
|
function getEngineState() { return queries.getControl(); }
|
|
173
173
|
|
|
174
|
+
let _worktreeCountCache = 0;
|
|
175
|
+
let _worktreeCountCacheTs = 0;
|
|
176
|
+
|
|
174
177
|
function _countWorktrees() {
|
|
178
|
+
const now = Date.now();
|
|
179
|
+
if (_worktreeCountCacheTs && (now - _worktreeCountCacheTs) < shared.ENGINE_DEFAULTS.worktreeCountCacheTtl) {
|
|
180
|
+
return _worktreeCountCache;
|
|
181
|
+
}
|
|
175
182
|
try {
|
|
176
183
|
const config = queries.getConfig();
|
|
177
184
|
const projects = shared.getProjects(config);
|
|
@@ -199,6 +206,8 @@ function _countWorktrees() {
|
|
|
199
206
|
}
|
|
200
207
|
} catch {}
|
|
201
208
|
}
|
|
209
|
+
_worktreeCountCache = count;
|
|
210
|
+
_worktreeCountCacheTs = now;
|
|
202
211
|
return count;
|
|
203
212
|
} catch { return 0; }
|
|
204
213
|
}
|
|
@@ -302,13 +311,20 @@ function parsePinnedEntries(content) {
|
|
|
302
311
|
return entries;
|
|
303
312
|
}
|
|
304
313
|
|
|
314
|
+
// Two-tier status cache: fast state (10s) for frequently-changing data, slow state (60s) for rarely-changing data.
|
|
315
|
+
// Combined into _statusCache for API/SSE consumers — no API contract change.
|
|
316
|
+
let _fastState = null;
|
|
317
|
+
let _fastStateTs = 0;
|
|
318
|
+
const FAST_STATE_TTL = 10000; // 10s — dispatch, agents, metrics, work items, etc.
|
|
319
|
+
let _slowState = null;
|
|
320
|
+
let _slowStateTs = 0;
|
|
321
|
+
const SLOW_STATE_TTL = 60000; // 60s — skills, PRDs, pinned, version, projects, etc.
|
|
305
322
|
let _statusCache = null;
|
|
306
323
|
let _statusCacheJson = null; // cached JSON.stringify(_statusCache) — avoids double-serialization for SSE
|
|
307
|
-
let
|
|
308
|
-
const STATUS_CACHE_TTL = 10000; // 10s — reduces expensive aggregation frequency; mutations call invalidateStatusCache()
|
|
324
|
+
let _statusCacheGzip = null; // pre-computed gzip of _statusCacheJson — avoids per-request gzipSync
|
|
309
325
|
const _statusStreamClients = new Set();
|
|
310
326
|
let _statusPushTimer = null;
|
|
311
|
-
let
|
|
327
|
+
let _lastStatusPushRef = null; // last JSON string reference pushed to SSE — O(1) change detection
|
|
312
328
|
|
|
313
329
|
// mtime-based cache invalidation — skip full rebuild if no tracked files changed
|
|
314
330
|
const _mtimeTrackedFiles = () => {
|
|
@@ -348,8 +364,12 @@ function _mtimesChanged(prev, curr) {
|
|
|
348
364
|
}
|
|
349
365
|
|
|
350
366
|
function invalidateStatusCache() {
|
|
367
|
+
_fastState = null;
|
|
368
|
+
_fastStateTs = 0;
|
|
369
|
+
// Slow state continues on its own TTL — not invalidated by mutations
|
|
351
370
|
_statusCache = null;
|
|
352
371
|
_statusCacheJson = null;
|
|
372
|
+
_statusCacheGzip = null;
|
|
353
373
|
// Push to SSE clients (debounced 500ms to avoid flooding during batch mutations)
|
|
354
374
|
if (_statusPushTimer) return;
|
|
355
375
|
_statusPushTimer = setTimeout(() => {
|
|
@@ -364,89 +384,110 @@ function invalidateStatusCache() {
|
|
|
364
384
|
|
|
365
385
|
function getStatus() {
|
|
366
386
|
const now = Date.now();
|
|
367
|
-
|
|
368
|
-
|
|
387
|
+
|
|
388
|
+
// Fast state: 10s TTL with mtime-based validation for early exit
|
|
389
|
+
let fastStale = !_fastState || (now - _fastStateTs) >= FAST_STATE_TTL;
|
|
390
|
+
if (!fastStale) {
|
|
391
|
+
// Within TTL — check mtimes for early return (skip rebuild if no tracked files changed)
|
|
369
392
|
const currMtimes = _getMtimes();
|
|
370
|
-
if (
|
|
393
|
+
if (_mtimesChanged(_lastMtimes, currMtimes)) fastStale = true;
|
|
371
394
|
}
|
|
372
395
|
|
|
373
|
-
//
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
_statusCache
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
const {
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
396
|
+
// Slow state: 60s TTL, pure TTL (no mtime check — these files change rarely)
|
|
397
|
+
const slowStale = !_slowState || (now - _slowStateTs) >= SLOW_STATE_TTL;
|
|
398
|
+
|
|
399
|
+
// If nothing stale, return cached merged result
|
|
400
|
+
if (!fastStale && !slowStale && _statusCache) return _statusCache;
|
|
401
|
+
|
|
402
|
+
// Rebuild fast state (frequently-changing data: ~12-15 reads)
|
|
403
|
+
if (fastStale) {
|
|
404
|
+
// Reload config on fast-state miss — picks up external changes (minions init, minions add)
|
|
405
|
+
reloadConfig();
|
|
406
|
+
_fastState = {
|
|
407
|
+
agents: getAgents(),
|
|
408
|
+
inbox: getInbox(),
|
|
409
|
+
notes: getNotesWithMeta(),
|
|
410
|
+
pullRequests: getPullRequests(),
|
|
411
|
+
engine: { ...getEngineState(), worktreeCount: _countWorktrees() },
|
|
412
|
+
adoThrottle: ado.getAdoThrottleState(),
|
|
413
|
+
ghThrottle: gh.getGhThrottleState(),
|
|
414
|
+
dispatch: getDispatchQueue(),
|
|
415
|
+
engineLog: getEngineLog(),
|
|
416
|
+
metrics: getMetrics(),
|
|
417
|
+
workItems: getWorkItems(),
|
|
418
|
+
watches: watchesMod.getWatches(),
|
|
419
|
+
meetings: (() => { try { return require('./engine/meeting').getMeetings(); } catch { return []; } })(),
|
|
420
|
+
};
|
|
421
|
+
_fastStateTs = now;
|
|
422
|
+
_lastMtimes = _getMtimes();
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Rebuild slow state (rarely-changing data: ~8-15 reads, 60s TTL)
|
|
426
|
+
if (slowStale) {
|
|
427
|
+
const prdInfo = getPrdInfo();
|
|
428
|
+
_slowState = {
|
|
429
|
+
prdProgress: prdInfo.progress,
|
|
430
|
+
prd: prdInfo.status,
|
|
431
|
+
verifyGuides: getVerifyGuides(),
|
|
432
|
+
archivedPrds: getArchivedPrds(),
|
|
433
|
+
skills: getSkills(),
|
|
434
|
+
mcpServers: getMcpServers(),
|
|
435
|
+
schedules: (() => {
|
|
436
|
+
const scheds = CONFIG.schedules || [];
|
|
437
|
+
const runs = shared.safeJson(path.join(MINIONS_DIR, 'engine', 'schedule-runs.json')) || {};
|
|
438
|
+
return scheds.map(s => {
|
|
439
|
+
const runEntry = runs[s.id];
|
|
440
|
+
// Backward compat: runEntry can be a string (old format) or object (new format with back-references)
|
|
441
|
+
const _lastRun = typeof runEntry === 'string' ? runEntry : (runEntry?.lastRun || runEntry?.lastCompletedAt || null);
|
|
442
|
+
const extra = typeof runEntry === 'object' && runEntry ? { _lastWorkItemId: runEntry.lastWorkItemId, _lastResult: runEntry.lastResult, _lastCompletedAt: runEntry.lastCompletedAt } : {};
|
|
443
|
+
return { ...s, _lastRun, ...extra };
|
|
444
|
+
});
|
|
445
|
+
})(),
|
|
446
|
+
pipelines: (() => { try { const pl = require('./engine/pipeline'); return pl.getPipelines().map(p => ({ ...p, runs: (pl.getPipelineRuns()[p.id] || []).slice(-5) })); } catch { return []; } })(),
|
|
447
|
+
pinned: (() => { try { return parsePinnedEntries(safeRead(path.join(MINIONS_DIR, 'pinned.md'))); } catch { return []; } })(),
|
|
448
|
+
projects: PROJECTS.map(p => ({ name: p.name, path: p.localPath, description: p.description || '' })),
|
|
449
|
+
autoMode: {
|
|
450
|
+
approvePlans: !!CONFIG.engine?.autoApprovePlans,
|
|
451
|
+
decompose: CONFIG.engine?.autoDecompose !== false,
|
|
452
|
+
tempAgents: !!CONFIG.engine?.allowTempAgents,
|
|
453
|
+
inboxThreshold: CONFIG.engine?.inboxConsolidateThreshold || shared.ENGINE_DEFAULTS.inboxConsolidateThreshold,
|
|
454
|
+
ccModel: CONFIG.engine?.ccModel || shared.ENGINE_DEFAULTS.ccModel,
|
|
455
|
+
ccEffort: CONFIG.engine?.ccEffort || shared.ENGINE_DEFAULTS.ccEffort,
|
|
456
|
+
},
|
|
457
|
+
initialized: !!(CONFIG.agents && Object.keys(CONFIG.agents).length > 0),
|
|
458
|
+
installId: safeRead(path.join(MINIONS_DIR, '.install-id')).trim() || null,
|
|
459
|
+
version: (() => {
|
|
460
|
+
const engine = getEngineState();
|
|
461
|
+
const { diskVersion, diskCommit, isGitRepo } = getDiskVersion();
|
|
462
|
+
const engineStale = !!(engine.codeVersion && diskVersion && engine.codeVersion !== diskVersion) ||
|
|
463
|
+
!!(engine.codeCommit && diskCommit && engine.codeCommit !== diskCommit);
|
|
464
|
+
const dashboardStale = !!(diskVersion && _dashboardVersion.codeVersion && diskVersion !== _dashboardVersion.codeVersion) ||
|
|
465
|
+
!!(diskCommit && _dashboardVersion.codeCommit && diskCommit !== _dashboardVersion.codeCommit);
|
|
466
|
+
return {
|
|
467
|
+
running: engine.codeVersion || null,
|
|
468
|
+
runningCommit: engine.codeCommit || null,
|
|
469
|
+
dashboardRunning: _dashboardVersion.codeVersion,
|
|
470
|
+
dashboardRunningCommit: _dashboardVersion.codeCommit,
|
|
471
|
+
dashboardStartedAt: _dashboardVersion.startedAt,
|
|
472
|
+
disk: diskVersion,
|
|
473
|
+
diskCommit,
|
|
474
|
+
engineStale,
|
|
475
|
+
dashboardStale,
|
|
476
|
+
stale: engineStale || dashboardStale,
|
|
477
|
+
latest: _npmVersionCache?.latest || null,
|
|
478
|
+
// Only show "update available" for npm installs (no git repo) — repo users manage their own updates
|
|
479
|
+
updateAvailable: !isGitRepo && !!(diskVersion && _npmVersionCache?.latest && _npmVersionCache.latest !== diskVersion && _compareVersions(_npmVersionCache.latest, diskVersion) > 0),
|
|
480
|
+
_npmCheckError: _npmVersionCache?.error || null,
|
|
481
|
+
};
|
|
482
|
+
})(),
|
|
483
|
+
};
|
|
484
|
+
_slowStateTs = now;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
// Merge both tiers — no API contract change
|
|
488
|
+
_statusCache = { ..._fastState, ..._slowState, timestamp: new Date().toISOString() };
|
|
448
489
|
_statusCacheJson = null; // invalidate cached JSON — will be lazily rebuilt by getStatusJson()
|
|
449
|
-
|
|
490
|
+
_statusCacheGzip = null;
|
|
450
491
|
return _statusCache;
|
|
451
492
|
}
|
|
452
493
|
|
|
@@ -455,6 +496,7 @@ function getStatusJson() {
|
|
|
455
496
|
getStatus(); // ensure _statusCache is fresh
|
|
456
497
|
if (!_statusCacheJson) {
|
|
457
498
|
_statusCacheJson = JSON.stringify(_statusCache);
|
|
499
|
+
_statusCacheGzip = zlib.gzipSync(_statusCacheJson); // pre-compute gzip once per cache rebuild
|
|
458
500
|
}
|
|
459
501
|
return _statusCacheJson;
|
|
460
502
|
}
|
|
@@ -463,9 +505,8 @@ function getStatusJson() {
|
|
|
463
505
|
setInterval(() => {
|
|
464
506
|
if (_statusStreamClients.size === 0) return;
|
|
465
507
|
const data = getStatusJson();
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
_lastStatusHash = hash;
|
|
508
|
+
if (data === _lastStatusPushRef) return; // O(1) reference comparison — new string ref means content changed
|
|
509
|
+
_lastStatusPushRef = data;
|
|
469
510
|
for (const res of _statusStreamClients) {
|
|
470
511
|
try { res.write('data: ' + data + '\n\n'); } catch { _statusStreamClients.delete(res); }
|
|
471
512
|
}
|
|
@@ -4222,15 +4263,15 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
4222
4263
|
|
|
4223
4264
|
async function handleStatus(req, res) {
|
|
4224
4265
|
try {
|
|
4225
|
-
// Use pre-serialized JSON
|
|
4266
|
+
// Use pre-serialized JSON and pre-computed gzip buffer — zero per-request compression
|
|
4226
4267
|
const json = getStatusJson();
|
|
4227
4268
|
res.setHeader('Content-Type', 'application/json');
|
|
4228
4269
|
res.setHeader('Access-Control-Allow-Origin', '*');
|
|
4229
4270
|
res.statusCode = 200;
|
|
4230
4271
|
const ae = req && req.headers && req.headers['accept-encoding'] || '';
|
|
4231
|
-
if (ae.includes('gzip') &&
|
|
4272
|
+
if (ae.includes('gzip') && _statusCacheGzip) {
|
|
4232
4273
|
res.setHeader('Content-Encoding', 'gzip');
|
|
4233
|
-
res.end(
|
|
4274
|
+
res.end(_statusCacheGzip);
|
|
4234
4275
|
} else {
|
|
4235
4276
|
res.end(json);
|
|
4236
4277
|
}
|
package/engine/cleanup.js
CHANGED
|
@@ -442,22 +442,24 @@ function runCleanup(config, verbose = false) {
|
|
|
442
442
|
|
|
443
443
|
// 6a. Reconcile failed work items that have an attached PR (#407)
|
|
444
444
|
// If a work item is 'failed' but already has _pr, it should be 'done'.
|
|
445
|
+
// Uses mutateWorkItems() for locked atomic read-modify-write — prevents
|
|
446
|
+
// race conditions with concurrent engine/dashboard/lifecycle writers.
|
|
445
447
|
for (const project of projects) {
|
|
446
448
|
try {
|
|
447
449
|
const wiPath = projectWorkItemsPath(project);
|
|
448
|
-
const items = safeJson(wiPath) || [];
|
|
449
450
|
let reconciled = 0;
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
item.status
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
451
|
+
mutateWorkItems(wiPath, items => {
|
|
452
|
+
for (const item of items) {
|
|
453
|
+
if (item.status === shared.WI_STATUS.FAILED && item._pr) {
|
|
454
|
+
item.status = shared.WI_STATUS.DONE;
|
|
455
|
+
if (item.failReason) delete item.failReason;
|
|
456
|
+
if (item.failedAt) delete item.failedAt;
|
|
457
|
+
if (!item.completedAt) item.completedAt = shared.ts();
|
|
458
|
+
reconciled++;
|
|
459
|
+
}
|
|
457
460
|
}
|
|
458
|
-
}
|
|
461
|
+
});
|
|
459
462
|
if (reconciled > 0) {
|
|
460
|
-
safeWrite(wiPath, items);
|
|
461
463
|
log('info', `Reconciled ${reconciled} failed-with-PR item(s) → done in ${project.name}`);
|
|
462
464
|
}
|
|
463
465
|
} catch (e) { log('warn', 'reconcile failed-with-PR: ' + e.message); }
|
package/engine/shared.js
CHANGED
|
@@ -534,6 +534,7 @@ const ENGINE_DEFAULTS = {
|
|
|
534
534
|
worktreeCreateTimeout: 300000, // 5min for git worktree add on large Windows repos
|
|
535
535
|
worktreeCreateRetries: 1, // retry once on transient timeout/lock races
|
|
536
536
|
worktreeRoot: '../worktrees',
|
|
537
|
+
worktreeCountCacheTtl: 30000, // 30s — TTL for cached _countWorktrees() result in dashboard
|
|
537
538
|
idleAlertMinutes: 15,
|
|
538
539
|
fanOutTimeout: null, // falls back to agentTimeout
|
|
539
540
|
restartGracePeriod: 1200000, // 20min
|
|
@@ -554,7 +555,7 @@ const ENGINE_DEFAULTS = {
|
|
|
554
555
|
versionCheckInterval: 3600000, // 1 hour — how often to check npm for updates (ms)
|
|
555
556
|
logFlushInterval: 5000, // 5s — how often to flush buffered log entries to disk
|
|
556
557
|
logBufferSize: 50, // flush immediately when buffer exceeds this many entries
|
|
557
|
-
lockRetries:
|
|
558
|
+
lockRetries: 0, // no retries — single 5s timeout window with 25ms polling (200 attempts) is sufficient; stale lock recovery at 60s handles crashes
|
|
558
559
|
lockRetryBackoffMs: 500, // base backoff between lock retries (doubles each attempt: 500ms, 1s, 2s, ...)
|
|
559
560
|
maxBuildFixAttempts: 3, // max consecutive auto-fix dispatch cycles per PR before escalation to human
|
|
560
561
|
buildFixGracePeriod: 600000, // 10min — wait for CI to run after build fix before re-dispatching
|
package/engine.js
CHANGED
|
@@ -3229,16 +3229,19 @@ async function tickInner() {
|
|
|
3229
3229
|
// Awaited so PR state is consistent before discoverWork reads it
|
|
3230
3230
|
// Also re-polls early if previous tick had ADO auth failures (stale build status recovery)
|
|
3231
3231
|
if (tickCount % adoPollStatusEvery === 0 || needsAdoPollRetry()) {
|
|
3232
|
+
// Build promise array — enabled+unthrottled polls run concurrently via Promise.allSettled
|
|
3233
|
+
const statusPolls = [];
|
|
3232
3234
|
if (adoPollEnabled && !isAdoThrottled()) {
|
|
3233
|
-
|
|
3235
|
+
statusPolls.push(pollPrStatus(config).catch(err => { log('warn', `ADO PR status poll error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }));
|
|
3234
3236
|
} else if (adoPollEnabled && isAdoThrottled()) {
|
|
3235
3237
|
log('info', '[ado] PR status poll skipped — throttled');
|
|
3236
3238
|
}
|
|
3237
3239
|
if (ghPollEnabled && !isGhThrottled()) {
|
|
3238
|
-
|
|
3240
|
+
statusPolls.push(ghPollPrStatus(config).catch(err => { log('warn', `GitHub PR status poll error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }));
|
|
3239
3241
|
} else if (ghPollEnabled && isGhThrottled()) {
|
|
3240
3242
|
log('info', '[gh] PR status poll skipped — throttled');
|
|
3241
3243
|
}
|
|
3244
|
+
if (statusPolls.length) await Promise.allSettled(statusPolls);
|
|
3242
3245
|
try { await processPendingRebases(config); } catch (err) { log('warn', `Pending rebase processing error: ${err?.message || err}`); }
|
|
3243
3246
|
// Sync PR status back to PRD items (missing → done when active PR exists)
|
|
3244
3247
|
try { syncPrdFromPrs(config); } catch (err) { log('warn', `PRD sync error: ${err?.message || err}`); }
|
|
@@ -3260,19 +3263,25 @@ async function tickInner() {
|
|
|
3260
3263
|
|
|
3261
3264
|
// 2.7. Poll PR threads for human comments (every adoPollCommentsEvery ticks, default ~12 minutes)
|
|
3262
3265
|
if (tickCount % adoPollCommentsEvery === 0) {
|
|
3266
|
+
// Build promise array — enabled+unthrottled comment polls run concurrently via Promise.allSettled
|
|
3267
|
+
const commentPolls = [];
|
|
3263
3268
|
if (adoPollEnabled && !isAdoThrottled()) {
|
|
3264
|
-
|
|
3269
|
+
commentPolls.push(pollPrHumanComments(config).catch(err => { log('warn', `ADO PR comment poll error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }));
|
|
3265
3270
|
} else if (adoPollEnabled && isAdoThrottled()) {
|
|
3266
3271
|
log('info', '[ado] PR comment poll skipped — throttled');
|
|
3267
3272
|
}
|
|
3268
3273
|
if (ghPollEnabled && !isGhThrottled()) {
|
|
3269
|
-
|
|
3274
|
+
commentPolls.push(ghPollPrHumanComments(config).catch(err => { log('warn', `GitHub PR comment poll error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }));
|
|
3270
3275
|
} else if (ghPollEnabled && isGhThrottled()) {
|
|
3271
3276
|
log('info', '[gh] PR comment poll skipped — throttled');
|
|
3272
3277
|
}
|
|
3278
|
+
if (commentPolls.length) await Promise.allSettled(commentPolls);
|
|
3273
3279
|
// Reconciliation runs regardless of poll flags — it's a recovery sweep, not a convenience poll
|
|
3274
|
-
|
|
3275
|
-
|
|
3280
|
+
// Reconciliation also parallelized — ADO and GitHub reconciliation are independent
|
|
3281
|
+
const reconcilePolls = [];
|
|
3282
|
+
reconcilePolls.push(reconcilePrs(config).catch(err => { log('warn', `ADO PR reconciliation error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }));
|
|
3283
|
+
reconcilePolls.push(ghReconcilePrs(config).catch(err => { log('warn', `GitHub PR reconciliation error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }));
|
|
3284
|
+
await Promise.allSettled(reconcilePolls);
|
|
3276
3285
|
}
|
|
3277
3286
|
|
|
3278
3287
|
// 2.9. Stalled dispatch detection — auto-retry failed items blocking the graph (every 20 ticks = ~10 min)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1028",
|
|
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"
|