@yemi33/minions 0.1.2050 → 0.1.2051

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.
@@ -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 instead of SSE to avoid HTTP/1.1 connection exhaustion
53
- // (SSE holds a persistent connection, blocking CC and other API calls)
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 };
@@ -189,19 +189,15 @@ function _renderJsonObj(obj, state) {
189
189
  }
190
190
 
191
191
  if (obj.type === 'result') {
192
- // Banner is gated on TWO conditions: (a) this is the LAST result line in the output,
193
- // and (b) the [process-exit] sentinel has been seen. Intermediate result lines from
194
- // ScheduleWakeup resume cycles fall through and render nothing.
195
- if (state.isFinalResult) {
196
- var subtype = typeof obj.subtype === 'string' ? obj.subtype : '';
197
- var resultIsError = obj.is_error === true || subtype.startsWith('error');
198
- var exitFailed = state.exitInfo && state.exitInfo.success === false;
199
- if (resultIsError || exitFailed) {
200
- parts.push('<div style="background:rgba(248,81,73,0.1);border:1px solid var(--red);padding:8px 12px;border-radius:8px;margin:8px 0;font-size:12px;color:var(--red)">\u2717 Task ended with error</div>');
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
- // ── Pre-scan ──────────────────────────────────────────────────────────────
241
- // ScheduleWakeup-based polling agents emit one `"type":"result"` JSONL line
242
- // per resume cycle, followed by another resume only the LAST result line
243
- // before spawn-agent.js writes its `[process-exit]` sentinel is the true
244
- // final result. Intermediate result lines must NOT trigger the banner.
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 — never holds the canonical result, banner never fires here
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 — banner fires only when this is the final result AND process exited
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
- // Fallback error banner: process exited with non-zero code but never emitted
346
- // a `"type":"result"` line (CLI crashed before producing one). Without this,
347
- // the user would see only stderr noise with no terminal-state indicator.
348
- if (exitInfo && !exitInfo.success && lastResultLineIdx === -1) {
349
- fragments.push('<div style="background:rgba(248,81,73,0.1);border:1px solid var(--red);padding:8px 12px;border-radius:8px;margin:8px 0;font-size:12px;color:var(--red)">✗ Task ended with error</div>');
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');
@@ -5740,7 +5741,13 @@ const server = http.createServer(async (req, res) => {
5740
5741
 
5741
5742
  // Send initial content — tail only (last 64KB by default) to avoid memory spikes
5742
5743
  const params = new URL(req.url, 'http://localhost').searchParams;
5743
- const tailBytes = Math.max(0, parseInt(params.get('tail') || '65536', 10) || 65536);
5744
+ // W-mpob4nyk0006580e accept tail=0 verbatim so terminal-only subscribers
5745
+ // (live-stream.js opens this endpoint just for `event: dispatch.terminal`)
5746
+ // don't pay 64KB of unused log payload on connect.
5747
+ const _tailParam = params.get('tail');
5748
+ const tailBytes = _tailParam !== null
5749
+ ? Math.max(0, parseInt(_tailParam, 10) || 0)
5750
+ : 65536;
5744
5751
  let offset = 0;
5745
5752
  try {
5746
5753
  const stat = fs.statSync(liveLogPath);
@@ -5748,7 +5755,7 @@ const server = http.createServer(async (req, res) => {
5748
5755
 
5749
5756
  // Fall back to previous session log when current is sparse (fixes #543)
5750
5757
  const SPARSE_THRESHOLD = 500;
5751
- if (fileSize < SPARSE_THRESHOLD) {
5758
+ if (tailBytes > 0 && fileSize < SPARSE_THRESHOLD) {
5752
5759
  const prevPath = path.join(agentDir, 'live-output-prev.log');
5753
5760
  try {
5754
5761
  const prevStat = fs.statSync(prevPath);
@@ -5765,7 +5772,7 @@ const server = http.createServer(async (req, res) => {
5765
5772
  } catch { /* prev file may not exist — that's fine */ }
5766
5773
  }
5767
5774
 
5768
- if (fileSize > 0) {
5775
+ if (tailBytes > 0 && fileSize > 0) {
5769
5776
  const readStart = Math.max(0, fileSize - tailBytes);
5770
5777
  const readLen = fileSize - readStart;
5771
5778
  const fd = fs.openSync(liveLogPath, 'r');
@@ -5775,6 +5782,10 @@ const server = http.createServer(async (req, res) => {
5775
5782
  const content = buf.toString('utf8');
5776
5783
  if (content) safeWrite(`data: ${JSON.stringify(content)}\n\n`);
5777
5784
  offset = fileSize;
5785
+ } else {
5786
+ // Skipped log replay (tail=0 subscribers); still pin the watcher
5787
+ // offset to current EOF so we only stream future appends.
5788
+ offset = fileSize;
5778
5789
  }
5779
5790
  } catch { /* optional — file may not exist yet */ }
5780
5791
 
@@ -5797,12 +5808,65 @@ const server = http.createServer(async (req, res) => {
5797
5808
 
5798
5809
  fs.watchFile(liveLogPath, { interval: 500 }, watcher);
5799
5810
 
5811
+ // W-mpob4nyk0006580e — single source of truth for the live-view banner.
5812
+ // Instead of the client deriving terminal state from log heuristics
5813
+ // ([process-exit] + "result" line), the engine pushes a terminal event
5814
+ // through engine/dispatch-events.js (JSONL ring at
5815
+ // engine/dispatch-terminal-events.jsonl, written by completeDispatch()
5816
+ // immediately after `result` is committed). The dashboard tails that
5817
+ // ring and re-broadcasts each new entry as an SSE `dispatch.terminal`
5818
+ // frame so the banner mirrors `dispatch.result` exactly.
5819
+ const _sentTerminalDispatchIds = new Set();
5820
+ const sendTerminalEvent = (event) => {
5821
+ if (!event || !event.dispatchId) return;
5822
+ if (_sentTerminalDispatchIds.has(event.dispatchId)) return;
5823
+ _sentTerminalDispatchIds.add(event.dispatchId);
5824
+ safeWrite(`event: dispatch.terminal\ndata: ${JSON.stringify(event)}\n\n`);
5825
+ };
5826
+
5827
+ // Replay-on-connect: if a terminal event for this agent landed within
5828
+ // the ring's TTL, push it immediately so a viewer who opens the live
5829
+ // view AFTER completion still sees the verdict.
5830
+ try {
5831
+ const recent = dispatchEvents.getLastTerminalForAgent(agentId);
5832
+ if (recent) sendTerminalEvent(recent);
5833
+ } catch { /* best-effort replay */ }
5834
+
5835
+ const eventsFile = dispatchEvents._eventsFile();
5836
+ let _terminalEventsOffset = 0;
5837
+ try { _terminalEventsOffset = fs.statSync(eventsFile).size; }
5838
+ catch { _terminalEventsOffset = 0; }
5839
+
5840
+ const terminalWatcher = () => {
5841
+ if (_cleanedUp) return;
5842
+ let stat;
5843
+ try { stat = fs.statSync(eventsFile); } catch { return; }
5844
+ if (stat.size === _terminalEventsOffset) return;
5845
+ // File rotation/truncation — rewind to start so we don't miss entries.
5846
+ if (stat.size < _terminalEventsOffset) _terminalEventsOffset = 0;
5847
+ try {
5848
+ const fd = fs.openSync(eventsFile, 'r');
5849
+ const buf = Buffer.alloc(stat.size - _terminalEventsOffset);
5850
+ fs.readSync(fd, buf, 0, buf.length, _terminalEventsOffset);
5851
+ fs.closeSync(fd);
5852
+ _terminalEventsOffset = stat.size;
5853
+ const lines = buf.toString('utf8').split('\n').filter(Boolean);
5854
+ for (const line of lines) {
5855
+ let evt;
5856
+ try { evt = JSON.parse(line); } catch { continue; }
5857
+ if (evt && evt.agentId === agentId) sendTerminalEvent(evt);
5858
+ }
5859
+ } catch { /* read race; next watcher tick retries */ }
5860
+ };
5861
+ fs.watchFile(eventsFile, { interval: 500 }, terminalWatcher);
5862
+
5800
5863
  // Idempotent cleanup helper to prevent handle leaks
5801
5864
  const cleanup = () => {
5802
5865
  if (_cleanedUp) return;
5803
5866
  _cleanedUp = true;
5804
5867
  try { clearInterval(doneCheck); } catch { /* optional */ }
5805
5868
  try { fs.unwatchFile(liveLogPath, watcher); } catch { /* optional */ }
5869
+ try { fs.unwatchFile(eventsFile, terminalWatcher); } catch { /* optional */ }
5806
5870
  };
5807
5871
 
5808
5872
  // 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
+ };
@@ -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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yemi33/minions",
3
- "version": "0.1.2050",
3
+ "version": "0.1.2051",
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"