@yemi33/minions 0.1.2050 → 0.1.2052
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/detail-panel.js +1 -0
- package/dashboard/js/live-stream.js +49 -3
- package/dashboard/js/refresh.js +56 -4
- package/dashboard/js/render-utils.js +66 -65
- package/dashboard.js +72 -3
- package/engine/dispatch-events.js +177 -0
- package/engine/dispatch.js +19 -0
- package/engine.js +11 -0
- package/package.json +1 -1
|
@@ -97,6 +97,7 @@ function renderDetailContent(detail, tab) {
|
|
|
97
97
|
// eslint-disable-next-line no-unsanitized/property -- reason: composed from internal server timestamp and static live-chat controls (no user data flows in)
|
|
98
98
|
el.innerHTML =
|
|
99
99
|
'<div id="live-chat" style="display:flex;flex-direction:column;height:60vh">' +
|
|
100
|
+
'<div id="live-terminal-banner"></div>' +
|
|
100
101
|
'<div id="live-messages" style="flex:1;overflow-y:auto;padding:8px;font-size:11px;line-height:1.6;display:flex;flex-direction:column"></div>' +
|
|
101
102
|
'<div id="live-status-bar" style="padding:4px 8px;display:flex;align-items:center;gap:8px;border-top:1px solid var(--border)">' +
|
|
102
103
|
'<span class="pulse"></span><span id="live-status-label" style="font-size:11px;color:var(--green)">Streaming live</span>' +
|
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
let livePollingInterval = null;
|
|
4
4
|
let liveEventSource = null;
|
|
5
|
+
let liveTerminalSource = null;
|
|
5
6
|
let _steerInFlight = false;
|
|
6
7
|
let _lastRenderedText = '';
|
|
7
8
|
let _runtimeTimer = null;
|
|
@@ -45,13 +46,54 @@ function startLiveStream(agentId) {
|
|
|
45
46
|
if (msgEl) msgEl.innerHTML = '';
|
|
46
47
|
_lastRenderedText = '';
|
|
47
48
|
|
|
49
|
+
// W-mpob4nyk0006580e — clear any stale banner from a previous agent.
|
|
50
|
+
const bannerEl = document.getElementById('live-terminal-banner');
|
|
51
|
+
if (bannerEl) bannerEl.innerHTML = '';
|
|
52
|
+
|
|
48
53
|
// Start runtime counter
|
|
49
54
|
_updateRuntimeCounter();
|
|
50
55
|
_runtimeTimer = setInterval(_updateRuntimeCounter, 1000);
|
|
51
56
|
|
|
52
|
-
// Use polling
|
|
53
|
-
//
|
|
57
|
+
// Use polling for log content (avoids HTTP/1.1 connection exhaustion that
|
|
58
|
+
// a full SSE log stream would cause — see W-mpob4nyk0006580e note below).
|
|
54
59
|
startLivePolling();
|
|
60
|
+
|
|
61
|
+
// W-mpob4nyk0006580e — terminal banner is driven by the engine's
|
|
62
|
+
// `dispatch.terminal` SSE event, NOT by parsing the JSONL log tail.
|
|
63
|
+
// The /live-stream endpoint multiplexes log frames (default `data:`
|
|
64
|
+
// messages) and terminal events (named `event: dispatch.terminal`); we
|
|
65
|
+
// open the SSE connection but only consume the terminal event. The
|
|
66
|
+
// stream stays mostly idle (one event per dispatch lifecycle), so this
|
|
67
|
+
// does not regress the connection-exhaustion fix that switched log
|
|
68
|
+
// tailing to polling.
|
|
69
|
+
startTerminalEventSource(agentId);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function startTerminalEventSource(agentId) {
|
|
73
|
+
if (typeof window.EventSource !== 'function') return;
|
|
74
|
+
try {
|
|
75
|
+
liveTerminalSource = new EventSource('/api/agent/' + encodeURIComponent(agentId) + '/live-stream?tail=0');
|
|
76
|
+
} catch (e) { console.error('terminal-event source open:', e.message); return; }
|
|
77
|
+
liveTerminalSource.addEventListener('dispatch.terminal', function(ev) {
|
|
78
|
+
let payload;
|
|
79
|
+
try { payload = JSON.parse(ev.data); } catch { return; }
|
|
80
|
+
renderTerminalBannerForAgent(agentId, payload);
|
|
81
|
+
});
|
|
82
|
+
// Ignore default `data:` log frames — log content arrives via polling.
|
|
83
|
+
liveTerminalSource.onerror = function() {
|
|
84
|
+
// EventSource auto-reconnects; nothing to do.
|
|
85
|
+
};
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function renderTerminalBannerForAgent(agentId, payload) {
|
|
89
|
+
// Guard against late-arriving events for a previous agent (tab switch race).
|
|
90
|
+
if (typeof currentAgentId !== 'undefined' && currentAgentId !== agentId) return;
|
|
91
|
+
if (typeof currentTab !== 'undefined' && currentTab !== 'live') return;
|
|
92
|
+
const bannerEl = document.getElementById('live-terminal-banner');
|
|
93
|
+
if (!bannerEl) return;
|
|
94
|
+
const html = renderTerminalBanner(payload);
|
|
95
|
+
// eslint-disable-next-line no-unsanitized/property -- reason: renderTerminalBanner() escapes all user-controlled fields (summary, reason, prUrl) before assembling HTML (see dashboard/js/render-utils.js)
|
|
96
|
+
bannerEl.innerHTML = html;
|
|
55
97
|
}
|
|
56
98
|
|
|
57
99
|
function stopLiveStream() {
|
|
@@ -59,6 +101,10 @@ function stopLiveStream() {
|
|
|
59
101
|
liveEventSource.close();
|
|
60
102
|
liveEventSource = null;
|
|
61
103
|
}
|
|
104
|
+
if (liveTerminalSource) {
|
|
105
|
+
try { liveTerminalSource.close(); } catch { /* optional */ }
|
|
106
|
+
liveTerminalSource = null;
|
|
107
|
+
}
|
|
62
108
|
if (_runtimeTimer) { clearInterval(_runtimeTimer); _runtimeTimer = null; }
|
|
63
109
|
stopLivePolling();
|
|
64
110
|
}
|
|
@@ -169,4 +215,4 @@ async function sendSteering() {
|
|
|
169
215
|
}
|
|
170
216
|
}
|
|
171
217
|
|
|
172
|
-
window.MinionsLive = { renderLiveChatMessage, startLiveStream, stopLiveStream, startLivePolling, stopLivePolling, refreshLiveOutput, sendSteering };
|
|
218
|
+
window.MinionsLive = { renderLiveChatMessage, renderTerminalBannerForAgent, startLiveStream, stopLiveStream, startLivePolling, stopLivePolling, refreshLiveOutput, sendSteering };
|
package/dashboard/js/refresh.js
CHANGED
|
@@ -73,7 +73,7 @@ const RENDER_VERSIONS = {
|
|
|
73
73
|
prd: 1,
|
|
74
74
|
prs: 2,
|
|
75
75
|
archivedPrds: 1,
|
|
76
|
-
engine:
|
|
76
|
+
engine: 3,
|
|
77
77
|
version: 1,
|
|
78
78
|
adoThrottle: 1,
|
|
79
79
|
ghThrottle: 1,
|
|
@@ -147,6 +147,47 @@ function _formatCcDrawerLabel(autoMode) {
|
|
|
147
147
|
return runtimeLabel + (model ? ' (' + model + ')' : '') + '-powered. Full minions context. Enter to send, Shift+Enter for newline.';
|
|
148
148
|
}
|
|
149
149
|
|
|
150
|
+
// W-mpnc4u8c001d9d6c — #engine-quick-stats "Next tick in Xs" countdown.
|
|
151
|
+
//
|
|
152
|
+
// Origin is engine.lastTickAt (stamped by tickInner at the start of every
|
|
153
|
+
// tick, see engine.js) plus engine.tickInterval (server-side config, surfaced
|
|
154
|
+
// via _buildStatusFastState). The chip updates every 1s without re-rendering
|
|
155
|
+
// the surrounding row — mirrors the _tickAgentRuntimes pattern in
|
|
156
|
+
// dashboard/js/render-agents.js. The countdown clamps at 0 and shows "due"
|
|
157
|
+
// when the engine is mid-tick (PR polls etc. can push tick-to-tick wall time
|
|
158
|
+
// past the nominal interval). When the engine isn't running, show "—" so a
|
|
159
|
+
// stale lastTickAt doesn't render a ticking countdown the engine can't honor.
|
|
160
|
+
var _engineCountdown = { lastTickAt: 0, tickInterval: 0, engineState: 'stopped' };
|
|
161
|
+
var _engineNextTickTimer = null;
|
|
162
|
+
|
|
163
|
+
function _formatNextTickText() {
|
|
164
|
+
var state = _engineCountdown.engineState;
|
|
165
|
+
if (state !== 'running' && state !== 'stopping') return '—';
|
|
166
|
+
var last = _engineCountdown.lastTickAt;
|
|
167
|
+
var interval = _engineCountdown.tickInterval;
|
|
168
|
+
if (!last || !interval) return '—';
|
|
169
|
+
var remainingMs = last + interval - Date.now();
|
|
170
|
+
if (remainingMs <= 0) return 'due';
|
|
171
|
+
return Math.ceil(remainingMs / 1000) + 's';
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function _updateNextTickChip() {
|
|
175
|
+
var el = document.getElementById('engine-next-tick');
|
|
176
|
+
if (!el) {
|
|
177
|
+
if (_engineNextTickTimer) { clearInterval(_engineNextTickTimer); _engineNextTickTimer = null; }
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
var next = _formatNextTickText();
|
|
181
|
+
if (el.textContent !== next) el.textContent = next;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function _startNextTickTicker() {
|
|
185
|
+
// Idempotent — refresh.js re-runs every 4s and reseats the data on the
|
|
186
|
+
// shared _engineCountdown; we don't want compounding intervals.
|
|
187
|
+
if (_engineNextTickTimer) return;
|
|
188
|
+
_engineNextTickTimer = setInterval(_updateNextTickChip, 1000);
|
|
189
|
+
}
|
|
190
|
+
|
|
150
191
|
function _processStatusUpdate(data) {
|
|
151
192
|
// Detect fresh install — clear stale browser state AND reload so module-scoped
|
|
152
193
|
// JS caches (_sectionCache, _prevCounts, _managedProcessesLastItems,
|
|
@@ -246,10 +287,21 @@ function _processStatusUpdate(data) {
|
|
|
246
287
|
var qs = document.getElementById('engine-quick-stats');
|
|
247
288
|
if (qs && data.engine) {
|
|
248
289
|
var wt = data.engine.worktreeCount != null ? data.engine.worktreeCount : '-';
|
|
249
|
-
var tick = data.engine.tick || '-';
|
|
250
290
|
var pid = data.engine.pid || '-';
|
|
251
|
-
//
|
|
252
|
-
|
|
291
|
+
// W-mpnc4u8c001d9d6c — replace the dead "Tick: -" chip (control.json
|
|
292
|
+
// never carried a `tick` field) with a live "Next tick in Xs" countdown
|
|
293
|
+
// driven by engine.lastTickAt (stamped at the start of every tickInner)
|
|
294
|
+
// and engine.tickInterval (config, surfaced in the status payload).
|
|
295
|
+
// _updateNextTickChip below ticks the inner span every 1s without
|
|
296
|
+
// re-rendering this whole row.
|
|
297
|
+
_engineCountdown.lastTickAt = Number(data.engine.lastTickAt) || 0;
|
|
298
|
+
_engineCountdown.tickInterval = Number(data.engine.tickInterval) || 0;
|
|
299
|
+
_engineCountdown.engineState = data.engine.state || 'stopped';
|
|
300
|
+
// eslint-disable-next-line no-unsanitized/property -- reason: composed from internal engine metrics (pid, lastTickAt/tickInterval, worktreeCount) and a literal id; no user data flows in
|
|
301
|
+
qs.innerHTML = '<span>PID: <b>' + pid + '</b></span>' +
|
|
302
|
+
'<span>Next tick in: <b id="engine-next-tick">' + _formatNextTickText() + '</b></span>' +
|
|
303
|
+
'<span>Worktrees: <b>' + wt + '</b></span>';
|
|
304
|
+
_startNextTickTicker();
|
|
253
305
|
}
|
|
254
306
|
}
|
|
255
307
|
if (_changed('version', data.version)) renderVersionBanner(data.version);
|
|
@@ -189,19 +189,15 @@ function _renderJsonObj(obj, state) {
|
|
|
189
189
|
}
|
|
190
190
|
|
|
191
191
|
if (obj.type === 'result') {
|
|
192
|
-
//
|
|
193
|
-
//
|
|
194
|
-
//
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
} else {
|
|
202
|
-
parts.push('<div style="background:rgba(63,185,80,0.1);border:1px solid var(--green);padding:8px 12px;border-radius:8px;margin:8px 0;font-size:12px;color:var(--green)">\u2713 Task complete</div>');
|
|
203
|
-
}
|
|
204
|
-
}
|
|
192
|
+
// W-mpob4nyk0006580e — the terminal banner is no longer derived here.
|
|
193
|
+
// The engine's dispatch.result is the single source of truth and is
|
|
194
|
+
// pushed to the live view via the SSE `dispatch.terminal` channel
|
|
195
|
+
// (engine/dispatch-events.js → dashboard.js handleAgentLiveStream).
|
|
196
|
+
// `result` JSONL lines and the `[process-exit]` sentinel are still
|
|
197
|
+
// parsed for other purposes (e.g. tool deltas above), but they must
|
|
198
|
+
// NOT trigger any banner here — log heuristics can disagree with the
|
|
199
|
+
// engine verdict (CLI crash AFTER successful completion report, exit-0
|
|
200
|
+
// with no result line, timeout kill, …). See renderTerminalBanner().
|
|
205
201
|
}
|
|
206
202
|
|
|
207
203
|
return parts.join('');
|
|
@@ -237,46 +233,12 @@ function renderAgentOutput(text) {
|
|
|
237
233
|
}
|
|
238
234
|
}
|
|
239
235
|
|
|
240
|
-
//
|
|
241
|
-
//
|
|
242
|
-
//
|
|
243
|
-
//
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
// spawn-agent.js writes:
|
|
247
|
-
// "\n[process-exit] code=N\n" on normal close (engine/spawn-agent.js:202)
|
|
248
|
-
// "\n[process-exit] spawn-failed\n" on synchronous spawn() throw
|
|
249
|
-
//
|
|
250
|
-
// We pre-scan to find: (1) whether [process-exit] was emitted at all, (2) its
|
|
251
|
-
// exit code (success vs failure), and (3) the line index of the last result
|
|
252
|
-
// strictly before that sentinel.
|
|
253
|
-
var exitInfo = null; // null = process still running (no banner ever fires)
|
|
254
|
-
var exitLineIdx = -1;
|
|
255
|
-
var lastResultLineIdx = -1;
|
|
256
|
-
var exitRe = /^\[process-exit\]\s+(?:code=)?(-?\d+|spawn-failed)\s*$/;
|
|
257
|
-
for (var k = 0; k < lines.length; k++) {
|
|
258
|
-
var t = lines[k].trim();
|
|
259
|
-
if (!t) continue;
|
|
260
|
-
var em = exitRe.exec(t);
|
|
261
|
-
if (em) {
|
|
262
|
-
var token = em[1];
|
|
263
|
-
var code = token === 'spawn-failed' ? -1 : parseInt(token, 10);
|
|
264
|
-
exitInfo = { code: code, success: code === 0 };
|
|
265
|
-
exitLineIdx = k;
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
|
|
269
|
-
if (exitLineIdx !== -1) {
|
|
270
|
-
for (var r = 0; r < exitLineIdx; r++) {
|
|
271
|
-
var rt = lines[r].trim();
|
|
272
|
-
if (!rt || rt.charCodeAt(0) !== 123 /* '{' */) continue;
|
|
273
|
-
try {
|
|
274
|
-
var probe = JSON.parse(rt);
|
|
275
|
-
if (probe && probe.type === 'result') lastResultLineIdx = r;
|
|
276
|
-
} catch (e) { /* ignore parse errors during scan */ }
|
|
277
|
-
}
|
|
278
|
-
}
|
|
279
|
-
state.exitInfo = exitInfo;
|
|
236
|
+
// W-mpob4nyk0006580e — pre-scan removed. Previously we found the final
|
|
237
|
+
// `"type":"result"` line + `[process-exit]` sentinel to gate a terminal
|
|
238
|
+
// banner here, but the terminal banner is now pushed from the engine via
|
|
239
|
+
// the `dispatch.terminal` SSE channel (see renderTerminalBanner()). The
|
|
240
|
+
// log lines below are still parsed for tool calls / assistant text /
|
|
241
|
+
// reasoning deltas — only the banner-gating heuristic is gone.
|
|
280
242
|
|
|
281
243
|
for (var i = 0; i < lines.length; i++) {
|
|
282
244
|
var trimmed = lines[i].trim();
|
|
@@ -304,26 +266,24 @@ function renderAgentOutput(text) {
|
|
|
304
266
|
continue;
|
|
305
267
|
}
|
|
306
268
|
|
|
307
|
-
// JSON array line
|
|
269
|
+
// JSON array line
|
|
308
270
|
if (trimmed.startsWith('[')) {
|
|
309
271
|
try {
|
|
310
272
|
var arr = JSON.parse(trimmed);
|
|
311
273
|
if (Array.isArray(arr)) {
|
|
312
|
-
state.isFinalResult = false;
|
|
313
274
|
for (var j = 0; j < arr.length; j++) fragments.push(_renderJsonObj(arr[j], state));
|
|
314
275
|
continue;
|
|
315
276
|
}
|
|
316
277
|
} catch (e) { /* fall through */ }
|
|
317
278
|
}
|
|
318
279
|
|
|
319
|
-
// JSON object line
|
|
280
|
+
// JSON object line
|
|
320
281
|
if (trimmed.startsWith('{')) {
|
|
321
282
|
try {
|
|
322
283
|
var obj = JSON.parse(trimmed);
|
|
323
284
|
if (obj.type !== 'assistant.message_delta' && obj.type !== 'assistant.reasoning' && obj.type !== 'assistant.reasoning_delta' && obj.type !== 'assistant.message') {
|
|
324
285
|
flushCopilotPending();
|
|
325
286
|
}
|
|
326
|
-
state.isFinalResult = (i === lastResultLineIdx) && (exitInfo !== null);
|
|
327
287
|
fragments.push(_renderJsonObj(obj, state));
|
|
328
288
|
continue;
|
|
329
289
|
} catch (e) { /* fall through */ }
|
|
@@ -342,16 +302,57 @@ function renderAgentOutput(text) {
|
|
|
342
302
|
|
|
343
303
|
flushCopilotPending();
|
|
344
304
|
|
|
345
|
-
//
|
|
346
|
-
//
|
|
347
|
-
//
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
305
|
+
// W-mpob4nyk0006580e — no log-derived banner here either. The fallback
|
|
306
|
+
// "exit non-zero with no result line" branch used to render a red banner
|
|
307
|
+
// for crashes mid-write; that case is now covered by the engine's
|
|
308
|
+
// `dispatch.result` (set by completeDispatch and pushed via the SSE
|
|
309
|
+
// `dispatch.terminal` channel — see renderTerminalBanner()). Removing
|
|
310
|
+
// this branch is what makes the live view banner agree with the agent
|
|
311
|
+
// row's status in cases like exit-1 AFTER a success completion report.
|
|
352
312
|
return fragments.join('');
|
|
353
313
|
}
|
|
354
314
|
|
|
315
|
+
/**
|
|
316
|
+
* W-mpob4nyk0006580e — render the terminal banner from an authoritative
|
|
317
|
+
* `dispatch.terminal` SSE event. The shape mirrors
|
|
318
|
+
* engine/dispatch-events.js buildTerminalEvent():
|
|
319
|
+
* { type, ts, dispatchId, agentId, result, reason, failureClass, prUrl, summary }
|
|
320
|
+
*
|
|
321
|
+
* The banner state mirrors `dispatch.result` exactly — green for SUCCESS,
|
|
322
|
+
* red otherwise. Optional reason / failure class / PR link / summary first
|
|
323
|
+
* line are surfaced as secondary context.
|
|
324
|
+
*
|
|
325
|
+
* Returns HTML for a single banner div. Callers replace the banner
|
|
326
|
+
* container's innerHTML on each event.
|
|
327
|
+
*/
|
|
328
|
+
function renderTerminalBanner(event) {
|
|
329
|
+
if (!event || !event.result) return '';
|
|
330
|
+
var isSuccess = event.result === 'success';
|
|
331
|
+
var color = isSuccess ? 'var(--green)' : 'var(--red)';
|
|
332
|
+
var bg = isSuccess ? 'rgba(63,185,80,0.1)' : 'rgba(248,81,73,0.1)';
|
|
333
|
+
var glyph = isSuccess ? '\u2713' : '\u2717';
|
|
334
|
+
var headline;
|
|
335
|
+
if (isSuccess) {
|
|
336
|
+
headline = glyph + ' Task complete';
|
|
337
|
+
} else {
|
|
338
|
+
// 'error' / 'timeout' / anything-else → use reason word from the engine
|
|
339
|
+
var reasonWord = event.result === 'timeout' ? 'timeout' : (event.failureClass || 'error');
|
|
340
|
+
headline = glyph + ' Task ended with ' + escHtml(String(reasonWord));
|
|
341
|
+
}
|
|
342
|
+
var extra = '';
|
|
343
|
+
if (event.summary) extra += '<div style="font-size:11px;margin-top:4px;opacity:0.9">' + escHtml(event.summary) + '</div>';
|
|
344
|
+
if (event.reason && event.reason !== event.summary) {
|
|
345
|
+
extra += '<div style="font-size:10px;margin-top:2px;opacity:0.75">' + escHtml(event.reason) + '</div>';
|
|
346
|
+
}
|
|
347
|
+
if (event.prUrl) {
|
|
348
|
+
var safeUrl = encodeURI(String(event.prUrl));
|
|
349
|
+
extra += '<div style="font-size:11px;margin-top:4px"><a href="' + escHtml(safeUrl) + '" target="_blank" rel="noopener" style="color:' + color + '">' + escHtml(event.prUrl) + '</a></div>';
|
|
350
|
+
}
|
|
351
|
+
return '<div style="background:' + bg + ';border:1px solid ' + color + ';padding:8px 12px;border-radius:8px;margin:8px 0;font-size:12px;color:' + color + '">' +
|
|
352
|
+
headline + extra +
|
|
353
|
+
'</div>';
|
|
354
|
+
}
|
|
355
|
+
|
|
355
356
|
/**
|
|
356
357
|
* Standard "Prev / Next" pager HTML for a paginated list. Used by inbox, KB,
|
|
357
358
|
* completed dispatches, and engine log. The caller is responsible for the
|
|
@@ -404,4 +405,4 @@ function pinButton(pinKey, pinned, source, opts) {
|
|
|
404
405
|
(pinned ? 'Unpin' : 'Pin') + '</button>';
|
|
405
406
|
}
|
|
406
407
|
|
|
407
|
-
window.MinionsRenderUtils = { formatToolSummary, renderAgentOutput, renderPager, pinButton };
|
|
408
|
+
window.MinionsRenderUtils = { formatToolSummary, renderAgentOutput, renderTerminalBanner, renderPager, pinButton };
|
package/dashboard.js
CHANGED
|
@@ -31,6 +31,7 @@ const qaRunsMod = require('./engine/qa-runs');
|
|
|
31
31
|
const routing = require('./engine/routing');
|
|
32
32
|
const playbook = require('./engine/playbook');
|
|
33
33
|
const dispatchMod = require('./engine/dispatch');
|
|
34
|
+
const dispatchEvents = require('./engine/dispatch-events');
|
|
34
35
|
const { wrapUntrusted, buildSource } = require('./engine/untrusted-fence');
|
|
35
36
|
const steering = require('./engine/steering');
|
|
36
37
|
const projectDiscovery = require('./engine/project-discovery');
|
|
@@ -1729,6 +1730,11 @@ function _buildStatusFastState() {
|
|
|
1729
1730
|
// possibly-cached payload (false-positive banner regression #2754).
|
|
1730
1731
|
heartbeatAgeMs: engineState.heartbeat ? hbAge : null,
|
|
1731
1732
|
heartbeatStale: !!(engineState.heartbeat && hbAge > ENGINE_HEARTBEAT_STALE_MS),
|
|
1733
|
+
// W-mpnc4u8c001d9d6c — Surface the tick cadence so the client's
|
|
1734
|
+
// #engine-quick-stats "Next tick in Xs" countdown reads the SAME value
|
|
1735
|
+
// the engine actually uses (Settings parity rule). Paired with
|
|
1736
|
+
// control.lastTickAt (above) stamped at the start of every tickInner.
|
|
1737
|
+
tickInterval: Number(CONFIG?.engine?.tickInterval) || shared.ENGINE_DEFAULTS.tickInterval,
|
|
1732
1738
|
},
|
|
1733
1739
|
adoThrottle: ado.getAdoThrottleState(),
|
|
1734
1740
|
ghThrottle: gh.getGhThrottleState(),
|
|
@@ -5740,7 +5746,13 @@ const server = http.createServer(async (req, res) => {
|
|
|
5740
5746
|
|
|
5741
5747
|
// Send initial content — tail only (last 64KB by default) to avoid memory spikes
|
|
5742
5748
|
const params = new URL(req.url, 'http://localhost').searchParams;
|
|
5743
|
-
|
|
5749
|
+
// W-mpob4nyk0006580e — accept tail=0 verbatim so terminal-only subscribers
|
|
5750
|
+
// (live-stream.js opens this endpoint just for `event: dispatch.terminal`)
|
|
5751
|
+
// don't pay 64KB of unused log payload on connect.
|
|
5752
|
+
const _tailParam = params.get('tail');
|
|
5753
|
+
const tailBytes = _tailParam !== null
|
|
5754
|
+
? Math.max(0, parseInt(_tailParam, 10) || 0)
|
|
5755
|
+
: 65536;
|
|
5744
5756
|
let offset = 0;
|
|
5745
5757
|
try {
|
|
5746
5758
|
const stat = fs.statSync(liveLogPath);
|
|
@@ -5748,7 +5760,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
5748
5760
|
|
|
5749
5761
|
// Fall back to previous session log when current is sparse (fixes #543)
|
|
5750
5762
|
const SPARSE_THRESHOLD = 500;
|
|
5751
|
-
if (fileSize < SPARSE_THRESHOLD) {
|
|
5763
|
+
if (tailBytes > 0 && fileSize < SPARSE_THRESHOLD) {
|
|
5752
5764
|
const prevPath = path.join(agentDir, 'live-output-prev.log');
|
|
5753
5765
|
try {
|
|
5754
5766
|
const prevStat = fs.statSync(prevPath);
|
|
@@ -5765,7 +5777,7 @@ const server = http.createServer(async (req, res) => {
|
|
|
5765
5777
|
} catch { /* prev file may not exist — that's fine */ }
|
|
5766
5778
|
}
|
|
5767
5779
|
|
|
5768
|
-
if (fileSize > 0) {
|
|
5780
|
+
if (tailBytes > 0 && fileSize > 0) {
|
|
5769
5781
|
const readStart = Math.max(0, fileSize - tailBytes);
|
|
5770
5782
|
const readLen = fileSize - readStart;
|
|
5771
5783
|
const fd = fs.openSync(liveLogPath, 'r');
|
|
@@ -5775,6 +5787,10 @@ const server = http.createServer(async (req, res) => {
|
|
|
5775
5787
|
const content = buf.toString('utf8');
|
|
5776
5788
|
if (content) safeWrite(`data: ${JSON.stringify(content)}\n\n`);
|
|
5777
5789
|
offset = fileSize;
|
|
5790
|
+
} else {
|
|
5791
|
+
// Skipped log replay (tail=0 subscribers); still pin the watcher
|
|
5792
|
+
// offset to current EOF so we only stream future appends.
|
|
5793
|
+
offset = fileSize;
|
|
5778
5794
|
}
|
|
5779
5795
|
} catch { /* optional — file may not exist yet */ }
|
|
5780
5796
|
|
|
@@ -5797,12 +5813,65 @@ const server = http.createServer(async (req, res) => {
|
|
|
5797
5813
|
|
|
5798
5814
|
fs.watchFile(liveLogPath, { interval: 500 }, watcher);
|
|
5799
5815
|
|
|
5816
|
+
// W-mpob4nyk0006580e — single source of truth for the live-view banner.
|
|
5817
|
+
// Instead of the client deriving terminal state from log heuristics
|
|
5818
|
+
// ([process-exit] + "result" line), the engine pushes a terminal event
|
|
5819
|
+
// through engine/dispatch-events.js (JSONL ring at
|
|
5820
|
+
// engine/dispatch-terminal-events.jsonl, written by completeDispatch()
|
|
5821
|
+
// immediately after `result` is committed). The dashboard tails that
|
|
5822
|
+
// ring and re-broadcasts each new entry as an SSE `dispatch.terminal`
|
|
5823
|
+
// frame so the banner mirrors `dispatch.result` exactly.
|
|
5824
|
+
const _sentTerminalDispatchIds = new Set();
|
|
5825
|
+
const sendTerminalEvent = (event) => {
|
|
5826
|
+
if (!event || !event.dispatchId) return;
|
|
5827
|
+
if (_sentTerminalDispatchIds.has(event.dispatchId)) return;
|
|
5828
|
+
_sentTerminalDispatchIds.add(event.dispatchId);
|
|
5829
|
+
safeWrite(`event: dispatch.terminal\ndata: ${JSON.stringify(event)}\n\n`);
|
|
5830
|
+
};
|
|
5831
|
+
|
|
5832
|
+
// Replay-on-connect: if a terminal event for this agent landed within
|
|
5833
|
+
// the ring's TTL, push it immediately so a viewer who opens the live
|
|
5834
|
+
// view AFTER completion still sees the verdict.
|
|
5835
|
+
try {
|
|
5836
|
+
const recent = dispatchEvents.getLastTerminalForAgent(agentId);
|
|
5837
|
+
if (recent) sendTerminalEvent(recent);
|
|
5838
|
+
} catch { /* best-effort replay */ }
|
|
5839
|
+
|
|
5840
|
+
const eventsFile = dispatchEvents._eventsFile();
|
|
5841
|
+
let _terminalEventsOffset = 0;
|
|
5842
|
+
try { _terminalEventsOffset = fs.statSync(eventsFile).size; }
|
|
5843
|
+
catch { _terminalEventsOffset = 0; }
|
|
5844
|
+
|
|
5845
|
+
const terminalWatcher = () => {
|
|
5846
|
+
if (_cleanedUp) return;
|
|
5847
|
+
let stat;
|
|
5848
|
+
try { stat = fs.statSync(eventsFile); } catch { return; }
|
|
5849
|
+
if (stat.size === _terminalEventsOffset) return;
|
|
5850
|
+
// File rotation/truncation — rewind to start so we don't miss entries.
|
|
5851
|
+
if (stat.size < _terminalEventsOffset) _terminalEventsOffset = 0;
|
|
5852
|
+
try {
|
|
5853
|
+
const fd = fs.openSync(eventsFile, 'r');
|
|
5854
|
+
const buf = Buffer.alloc(stat.size - _terminalEventsOffset);
|
|
5855
|
+
fs.readSync(fd, buf, 0, buf.length, _terminalEventsOffset);
|
|
5856
|
+
fs.closeSync(fd);
|
|
5857
|
+
_terminalEventsOffset = stat.size;
|
|
5858
|
+
const lines = buf.toString('utf8').split('\n').filter(Boolean);
|
|
5859
|
+
for (const line of lines) {
|
|
5860
|
+
let evt;
|
|
5861
|
+
try { evt = JSON.parse(line); } catch { continue; }
|
|
5862
|
+
if (evt && evt.agentId === agentId) sendTerminalEvent(evt);
|
|
5863
|
+
}
|
|
5864
|
+
} catch { /* read race; next watcher tick retries */ }
|
|
5865
|
+
};
|
|
5866
|
+
fs.watchFile(eventsFile, { interval: 500 }, terminalWatcher);
|
|
5867
|
+
|
|
5800
5868
|
// Idempotent cleanup helper to prevent handle leaks
|
|
5801
5869
|
const cleanup = () => {
|
|
5802
5870
|
if (_cleanedUp) return;
|
|
5803
5871
|
_cleanedUp = true;
|
|
5804
5872
|
try { clearInterval(doneCheck); } catch { /* optional */ }
|
|
5805
5873
|
try { fs.unwatchFile(liveLogPath, watcher); } catch { /* optional */ }
|
|
5874
|
+
try { fs.unwatchFile(eventsFile, terminalWatcher); } catch { /* optional */ }
|
|
5806
5875
|
};
|
|
5807
5876
|
|
|
5808
5877
|
// Check if agent is still active (poll every 5s)
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engine/dispatch-events.js — Single source of truth for dispatch terminal events.
|
|
3
|
+
*
|
|
4
|
+
* W-mpob4nyk0006580e: The live view banner ("✓ Task complete" / "✗ Task ended
|
|
5
|
+
* with …") must mirror `dispatch.result` exactly. Previously the banner was
|
|
6
|
+
* derived in the browser from the JSONL `result` line + the `[process-exit]`
|
|
7
|
+
* sentinel, which disagrees with the engine's terminal state in real-world
|
|
8
|
+
* cases (CLI crash AFTER writing a successful completion report, exit-0 with
|
|
9
|
+
* no result line, hard-kill by timeout sweep, …). This module replaces that
|
|
10
|
+
* heuristic with a push channel rooted in `completeDispatch()`:
|
|
11
|
+
*
|
|
12
|
+
* completeDispatch() → mutateDispatch writes `result` → emitDispatchTerminal()
|
|
13
|
+
*
|
|
14
|
+
* Subscribers are notified two ways so the channel works in both the
|
|
15
|
+
* in-process test harness and the engine↔dashboard cross-process runtime:
|
|
16
|
+
*
|
|
17
|
+
* 1. EventEmitter — same-process consumers (unit tests, future co-located
|
|
18
|
+
* callers) subscribe via onDispatchTerminal().
|
|
19
|
+
* 2. JSONL ring at `<engine>/dispatch-terminal-events.jsonl` — the dashboard
|
|
20
|
+
* process tails this file via fs.watchFile (see dashboard.js
|
|
21
|
+
* handleAgentLiveStream) and re-broadcasts each new entry as an SSE
|
|
22
|
+
* `dispatch.terminal` frame to live-view clients.
|
|
23
|
+
*
|
|
24
|
+
* The ring is capped (MAX_RING_ENTRIES, REPLAY_TTL_MS) so a viewer that
|
|
25
|
+
* connects AFTER a dispatch completes still receives the most recent terminal
|
|
26
|
+
* verdict on connect (replay-on-connect).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
const fs = require('fs');
|
|
30
|
+
const path = require('path');
|
|
31
|
+
const { EventEmitter } = require('events');
|
|
32
|
+
const shared = require('./shared');
|
|
33
|
+
|
|
34
|
+
const MAX_RING_ENTRIES = 50; // bound file size + memory; dev tool
|
|
35
|
+
const REPLAY_TTL_MS = 5 * 60 * 1000; // 5 min — long enough for tab refresh
|
|
36
|
+
|
|
37
|
+
const _emitter = new EventEmitter();
|
|
38
|
+
// Live view streams subscribe per-connection; cap is generous to avoid
|
|
39
|
+
// MaxListenersExceeded warnings in dashboards with many open live tabs.
|
|
40
|
+
_emitter.setMaxListeners(500);
|
|
41
|
+
|
|
42
|
+
function _eventsFile() {
|
|
43
|
+
// Re-read shared.MINIONS_DIR at call time so test-isolated minions dirs
|
|
44
|
+
// (createTestMinionsDir) get a tmp-scoped events file rather than the live
|
|
45
|
+
// engine/ directory.
|
|
46
|
+
return path.join(shared.MINIONS_DIR, 'engine', 'dispatch-terminal-events.jsonl');
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function _now() { return Date.now(); }
|
|
50
|
+
|
|
51
|
+
function _readLines() {
|
|
52
|
+
try {
|
|
53
|
+
const raw = fs.readFileSync(_eventsFile(), 'utf8');
|
|
54
|
+
return raw.split('\n').filter(Boolean);
|
|
55
|
+
} catch { return []; }
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function _appendEventLine(event) {
|
|
59
|
+
const file = _eventsFile();
|
|
60
|
+
const dir = path.dirname(file);
|
|
61
|
+
try {
|
|
62
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
63
|
+
let lines = _readLines();
|
|
64
|
+
lines.push(JSON.stringify(event));
|
|
65
|
+
// Drop entries older than REPLAY_TTL_MS, then cap by entry count.
|
|
66
|
+
const cutoff = _now() - REPLAY_TTL_MS;
|
|
67
|
+
lines = lines.filter(line => {
|
|
68
|
+
try { return (JSON.parse(line).ts || 0) >= cutoff; }
|
|
69
|
+
catch { return false; }
|
|
70
|
+
});
|
|
71
|
+
if (lines.length > MAX_RING_ENTRIES) {
|
|
72
|
+
lines = lines.slice(lines.length - MAX_RING_ENTRIES);
|
|
73
|
+
}
|
|
74
|
+
fs.writeFileSync(file, lines.join('\n') + '\n');
|
|
75
|
+
} catch {
|
|
76
|
+
// Best-effort observability channel — engine progress must never block
|
|
77
|
+
// on a failed disk write here.
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Build + broadcast a dispatch terminal event. Called from completeDispatch()
|
|
83
|
+
* after the dispatch record's `result` is committed. The event payload is the
|
|
84
|
+
* authoritative shape consumed by the live-view banner renderer.
|
|
85
|
+
*
|
|
86
|
+
* Shape:
|
|
87
|
+
* {
|
|
88
|
+
* type: 'dispatch.terminal',
|
|
89
|
+
* ts: <epoch-ms>,
|
|
90
|
+
* dispatchId: <string>,
|
|
91
|
+
* agentId: <string>,
|
|
92
|
+
* result: 'success' | 'error' | 'timeout',
|
|
93
|
+
* reason: <string>, // human-readable
|
|
94
|
+
* failureClass: <string|null>, // FAILURE_CLASS enum or null
|
|
95
|
+
* prUrl: <string|null>, // PR URL/id from completion report, or null
|
|
96
|
+
* summary: <string> // first line of completion summary (240ch max)
|
|
97
|
+
* }
|
|
98
|
+
*/
|
|
99
|
+
function emitDispatchTerminal(event) {
|
|
100
|
+
if (!event || typeof event !== 'object') return;
|
|
101
|
+
if (!event.type) event.type = 'dispatch.terminal';
|
|
102
|
+
if (!event.ts) event.ts = _now();
|
|
103
|
+
_appendEventLine(event);
|
|
104
|
+
try { _emitter.emit('dispatch.terminal', event); } catch { /* never throw */ }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function onDispatchTerminal(listener) {
|
|
108
|
+
_emitter.on('dispatch.terminal', listener);
|
|
109
|
+
return () => _emitter.off('dispatch.terminal', listener);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function readRecentEvents() {
|
|
113
|
+
const cutoff = _now() - REPLAY_TTL_MS;
|
|
114
|
+
return _readLines()
|
|
115
|
+
.map(line => { try { return JSON.parse(line); } catch { return null; } })
|
|
116
|
+
.filter(e => e && (e.ts || 0) >= cutoff);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function getLastTerminalForAgent(agentId) {
|
|
120
|
+
if (!agentId) return null;
|
|
121
|
+
const events = readRecentEvents();
|
|
122
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
123
|
+
if (events[i].agentId === agentId) return events[i];
|
|
124
|
+
}
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function getLastTerminalForDispatch(dispatchId) {
|
|
129
|
+
if (!dispatchId) return null;
|
|
130
|
+
const events = readRecentEvents();
|
|
131
|
+
for (let i = events.length - 1; i >= 0; i--) {
|
|
132
|
+
if (events[i].dispatchId === dispatchId) return events[i];
|
|
133
|
+
}
|
|
134
|
+
return null;
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Build the terminal event payload from a freshly-completed dispatch entry +
|
|
139
|
+
* the inputs to completeDispatch(). Extracted so both the engine emitter and
|
|
140
|
+
* the dashboard's JSONL tailer reach the same shape.
|
|
141
|
+
*/
|
|
142
|
+
function buildTerminalEvent({ dispatchId, agentId, result, reason, failureClass, structuredCompletion, resultSummary }) {
|
|
143
|
+
let prUrl = null;
|
|
144
|
+
let summary = '';
|
|
145
|
+
if (structuredCompletion && typeof structuredCompletion === 'object') {
|
|
146
|
+
const sc = structuredCompletion;
|
|
147
|
+
if (sc.pr && typeof sc.pr === 'string' && sc.pr !== 'N/A') prUrl = sc.pr;
|
|
148
|
+
const rawSummary = typeof sc.summary === 'string' ? sc.summary : '';
|
|
149
|
+
if (rawSummary) summary = rawSummary.split('\n')[0].slice(0, 240);
|
|
150
|
+
}
|
|
151
|
+
if (!summary && typeof resultSummary === 'string' && resultSummary) {
|
|
152
|
+
summary = resultSummary.split('\n')[0].slice(0, 240);
|
|
153
|
+
}
|
|
154
|
+
return {
|
|
155
|
+
type: 'dispatch.terminal',
|
|
156
|
+
dispatchId,
|
|
157
|
+
agentId: agentId || null,
|
|
158
|
+
result,
|
|
159
|
+
reason: reason || '',
|
|
160
|
+
failureClass: failureClass || null,
|
|
161
|
+
prUrl,
|
|
162
|
+
summary,
|
|
163
|
+
};
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
module.exports = {
|
|
167
|
+
emitDispatchTerminal,
|
|
168
|
+
onDispatchTerminal,
|
|
169
|
+
readRecentEvents,
|
|
170
|
+
getLastTerminalForAgent,
|
|
171
|
+
getLastTerminalForDispatch,
|
|
172
|
+
buildTerminalEvent,
|
|
173
|
+
// exposed for testing / dashboard tailer
|
|
174
|
+
_eventsFile,
|
|
175
|
+
MAX_RING_ENTRIES,
|
|
176
|
+
REPLAY_TTL_MS,
|
|
177
|
+
};
|
package/engine/dispatch.js
CHANGED
|
@@ -8,6 +8,7 @@ const path = require('path');
|
|
|
8
8
|
const shared = require('./shared');
|
|
9
9
|
const queries = require('./queries');
|
|
10
10
|
const { setCooldown, setCooldownFailure } = require('./cooldown');
|
|
11
|
+
const dispatchEvents = require('./dispatch-events');
|
|
11
12
|
|
|
12
13
|
const { safeJsonArr, mutateJsonFileLocked, mutateWorkItems,
|
|
13
14
|
mutatePullRequests, getProjects, projectPrPath, log, ts, dateStamp,
|
|
@@ -583,6 +584,24 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
|
|
|
583
584
|
|
|
584
585
|
if (item) {
|
|
585
586
|
log('info', `Completed dispatch: ${id} (${result}${reason ? ': ' + reason : ''})`);
|
|
587
|
+
// W-mpob4nyk0006580e — broadcast terminal state immediately after the
|
|
588
|
+
// `result` field is committed to dispatch.json. This is the single
|
|
589
|
+
// source of truth for the live-view banner; subscribers (dashboard SSE
|
|
590
|
+
// handler at handleAgentLiveStream) push it to clients so the banner
|
|
591
|
+
// mirrors `dispatch.result` exactly — no log-parsing heuristics that can
|
|
592
|
+
// disagree with the engine's verdict.
|
|
593
|
+
try {
|
|
594
|
+
dispatchEvents.emitDispatchTerminal(dispatchEvents.buildTerminalEvent({
|
|
595
|
+
dispatchId: id,
|
|
596
|
+
agentId: item.agent,
|
|
597
|
+
result,
|
|
598
|
+
reason,
|
|
599
|
+
failureClass,
|
|
600
|
+
structuredCompletion: opts.structuredCompletion || item.structuredCompletion || null,
|
|
601
|
+
resultSummary,
|
|
602
|
+
}));
|
|
603
|
+
} catch (e) { log('warn', 'emit dispatch.terminal: ' + e.message); }
|
|
604
|
+
|
|
586
605
|
if (result === DISPATCH_RESULT.ERROR) {
|
|
587
606
|
writeFailedAgentReport(item, reason, resultSummary, failureClass);
|
|
588
607
|
}
|
package/engine.js
CHANGED
|
@@ -6522,6 +6522,17 @@ async function tickInner() {
|
|
|
6522
6522
|
// Per-phase guards inside the rest of tickInner are sub-task -b's scope.
|
|
6523
6523
|
if (_isTickStale(myGeneration)) return;
|
|
6524
6524
|
|
|
6525
|
+
// W-mpnc4u8c001d9d6c — Stamp `lastTickAt` on every tickInner entry so the
|
|
6526
|
+
// dashboard's #engine-quick-stats "Next tick in Xs" countdown has a reliable
|
|
6527
|
+
// anchor (control.json carries no separate tick-counter field). Synchronous
|
|
6528
|
+
// mutator, no awaits inside the lock — mirrors the writer contract used by
|
|
6529
|
+
// engine/cli.js#writeHeartbeatNow. Distinct from control.heartbeat: this
|
|
6530
|
+
// advances once per tick cadence, not on the 15s heartbeat cadence, so the
|
|
6531
|
+
// client can render an accurate "next tick" countdown without coupling to
|
|
6532
|
+
// the staleness signal.
|
|
6533
|
+
try { shared.mutateControl(c => { c.lastTickAt = Date.now(); return c; }); }
|
|
6534
|
+
catch (e) { log('warn', `lastTickAt write: ${e.message}`); }
|
|
6535
|
+
|
|
6525
6536
|
const config = getConfig();
|
|
6526
6537
|
tickCount++;
|
|
6527
6538
|
const now = Date.now();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2052",
|
|
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"
|