@yemi33/minions 0.1.1985 → 0.1.1987
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/bin/minions.js +3 -1
- package/dashboard/js/qa.js +53 -0
- package/dashboard/js/refresh.js +4 -2
- package/dashboard/js/render-managed.js +43 -9
- package/dashboard/js/render-other.js +41 -11
- package/dashboard/layout.html +1 -0
- package/dashboard/pages/qa.html +23 -0
- package/dashboard-build.js +2 -2
- package/dashboard.js +135 -24
- package/docs/README.md +2 -0
- package/docs/constellation-bridge.md +94 -0
- package/docs/security.md +177 -0
- package/engine/bridge.js +124 -0
- package/engine/cc-worker-pool.js +48 -1
- package/engine/cleanup.js +72 -23
- package/engine/cli.js +126 -12
- package/engine/dispatch.js +24 -11
- package/engine/github.js +79 -26
- package/engine/issues.js +14 -3
- package/engine/lifecycle.js +47 -11
- package/engine/llm.js +16 -9
- package/engine/meeting.js +16 -5
- package/engine/queries.js +123 -52
- package/engine/shared.js +265 -5
- package/engine/spawn-agent.js +13 -5
- package/engine/timeout.js +4 -2
- package/engine.js +59 -15
- package/package.json +1 -1
package/bin/minions.js
CHANGED
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
* minions kill Kill active agents and reset to pending
|
|
28
28
|
* minions complete <dispatch-id> Mark a dispatch completed
|
|
29
29
|
* minions config set-cli <R> [--model M] Persist default runtime/model
|
|
30
|
+
* minions bridge <subcmd> Constellation bridge: status|health|enable|disable
|
|
30
31
|
* minions plan <file|text> [proj] Run a plan
|
|
31
32
|
* minions mcp-sync Sync MCP servers from ~/.claude.json
|
|
32
33
|
* minions nuke --confirm Factory reset runtime state/config
|
|
@@ -681,7 +682,7 @@ const engineCmds = new Set([
|
|
|
681
682
|
'start', 'stop', 'status', 'pause', 'resume',
|
|
682
683
|
'queue', 'sources', 'discover', 'dispatch',
|
|
683
684
|
'spawn', 'work', 'cleanup', 'mcp-sync', 'plan',
|
|
684
|
-
'kill', 'complete', 'config', 'pr',
|
|
685
|
+
'kill', 'complete', 'config', 'pr', 'bridge',
|
|
685
686
|
]);
|
|
686
687
|
|
|
687
688
|
if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
@@ -714,6 +715,7 @@ if (!cmd || cmd === 'help' || cmd === '--help' || cmd === '-h') {
|
|
|
714
715
|
minions complete <dispatch-id> Manually mark a dispatch as completed
|
|
715
716
|
minions config set-cli <R> [--model M]
|
|
716
717
|
Persist default runtime/model without starting
|
|
718
|
+
minions bridge <subcmd> Constellation bridge: status|health|enable|disable
|
|
717
719
|
minions mcp-sync Sync MCP servers from ~/.claude.json
|
|
718
720
|
minions cleanup Clean temp files, worktrees, zombies
|
|
719
721
|
minions pr comment <repo> <n> Post a marker-prepended PR comment via gh
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// dashboard/js/qa.js — QA tab wiring (W-mpd5ewhj000oc5c5).
|
|
2
|
+
//
|
|
3
|
+
// The QA tab is the canonical home for long-running test/validation surfaces.
|
|
4
|
+
// Phase 1 (this WI) mounts the managed-spawn + keep-processes panels into the
|
|
5
|
+
// QA page using the shared mount API on render-managed.js + render-other.js.
|
|
6
|
+
// The Engine page keeps its own mount registered eagerly inside those modules
|
|
7
|
+
// so dual-render works (engine.html and qa.html show the same data, fed by
|
|
8
|
+
// the same poll loop in refresh.js — no extra fetches, no extra SSE streams).
|
|
9
|
+
//
|
|
10
|
+
// SSE log streaming is lazy: openManagedLog() opens a single EventSource on
|
|
11
|
+
// user click and closeManagedLog() aborts it. The modal is a singleton, so
|
|
12
|
+
// opening from either tab doesn't multiply connections (cf. render-managed.js
|
|
13
|
+
// "single-stream invariant").
|
|
14
|
+
//
|
|
15
|
+
// Out of scope: actual runbook dispatch wiring. The placeholder card with the
|
|
16
|
+
// disabled "+ New runbook" button is the only UX hook for the next phase.
|
|
17
|
+
|
|
18
|
+
(function () {
|
|
19
|
+
function _registerQaMounts() {
|
|
20
|
+
if (typeof mountManagedProcessesPanel === 'function') {
|
|
21
|
+
mountManagedProcessesPanel({
|
|
22
|
+
contentId: 'qa-managed-processes-content',
|
|
23
|
+
countId: 'qa-managed-processes-count',
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
if (typeof mountKeepProcessesPanel === 'function') {
|
|
27
|
+
mountKeepProcessesPanel({
|
|
28
|
+
contentId: 'qa-keep-processes-content',
|
|
29
|
+
countId: 'qa-keep-processes-count',
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// The page fragment is in the DOM at script-load time (all .page divs are
|
|
35
|
+
// assembled into layout.html at build), so registering immediately is safe.
|
|
36
|
+
// The mount API is no-op when the QA fragment is missing (defensive).
|
|
37
|
+
_registerQaMounts();
|
|
38
|
+
|
|
39
|
+
// Close any open managed-log SSE stream when the user navigates away from a
|
|
40
|
+
// page that triggered it — the modal otherwise floats over the new page and
|
|
41
|
+
// the EventSource keeps streaming. Hooks into the existing switchPage()
|
|
42
|
+
// function from state.js without changing its signature.
|
|
43
|
+
if (typeof switchPage === 'function' && !switchPage.__qaWrapped) {
|
|
44
|
+
const _origSwitchPage = switchPage;
|
|
45
|
+
window.switchPage = function (page, pushState) {
|
|
46
|
+
try { if (typeof closeManagedLog === 'function') closeManagedLog(); } catch {}
|
|
47
|
+
return _origSwitchPage(page, pushState);
|
|
48
|
+
};
|
|
49
|
+
window.switchPage.__qaWrapped = true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
window.MinionsQA = { _registerQaMounts };
|
|
53
|
+
})();
|
package/dashboard/js/refresh.js
CHANGED
|
@@ -103,11 +103,13 @@ function _processStatusUpdate(data) {
|
|
|
103
103
|
prunePrdRequeueState(window._lastWorkItems);
|
|
104
104
|
if (_changed('engineLog', data.engineLog)) renderEngineLog(data.engineLog || []);
|
|
105
105
|
if (_changed('metrics', data.metrics)) renderMetrics(data.metrics || {});
|
|
106
|
-
// keep_processes panel
|
|
106
|
+
// keep_processes panel renders on every page where its mount is in the DOM
|
|
107
|
+
// (Engine page + QA page — W-mpd5ewhj000oc5c5). Cheap call (one fetch); the
|
|
108
|
+
// renderer iterates all registered mounts and skips when none are present.
|
|
107
109
|
if (typeof renderKeepProcesses === 'function') {
|
|
108
110
|
try { renderKeepProcesses(); } catch {}
|
|
109
111
|
}
|
|
110
|
-
// managed-processes panel — same
|
|
112
|
+
// managed-processes panel — same mount-point pattern, ETag-gated so
|
|
111
113
|
// unchanged ticks return 304 with no body (P-6e2a8b13).
|
|
112
114
|
if (typeof renderManagedProcesses === 'function') {
|
|
113
115
|
try { renderManagedProcesses(); } catch {}
|
|
@@ -21,6 +21,30 @@ let _managedProcessesEtag = null;
|
|
|
21
21
|
let _managedProcessesLastItems = null;
|
|
22
22
|
let _managedLogES = null;
|
|
23
23
|
|
|
24
|
+
// Mount-point registry (W-mpd5ewhj000oc5c5 — QA tab dual-render).
|
|
25
|
+
// Each mount = { contentId, countId } pointing at a DOM container to populate.
|
|
26
|
+
// Both the Engine page and the QA page register their own mount, and
|
|
27
|
+
// renderManagedProcesses() writes to every mount that's currently in the DOM.
|
|
28
|
+
// The Engine page mount is registered eagerly here so the existing engine.html
|
|
29
|
+
// behavior keeps working without an explicit mount call.
|
|
30
|
+
const _managedMounts = [
|
|
31
|
+
{ contentId: 'managed-processes-content', countId: 'managed-processes-count' },
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
function mountManagedProcessesPanel(opts) {
|
|
35
|
+
if (!opts || !opts.contentId) return;
|
|
36
|
+
for (const m of _managedMounts) {
|
|
37
|
+
if (m.contentId === opts.contentId) return; // already registered
|
|
38
|
+
}
|
|
39
|
+
_managedMounts.push({ contentId: opts.contentId, countId: opts.countId || null });
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function unmountManagedProcessesPanel(contentId) {
|
|
43
|
+
for (let i = _managedMounts.length - 1; i >= 0; i--) {
|
|
44
|
+
if (_managedMounts[i].contentId === contentId) _managedMounts.splice(i, 1);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
24
48
|
function _fmtAgo(ms) {
|
|
25
49
|
if (!ms || ms < 0) return '0s';
|
|
26
50
|
const s = Math.floor(ms / 1000);
|
|
@@ -85,9 +109,12 @@ function _renderManagedTable(items) {
|
|
|
85
109
|
}
|
|
86
110
|
|
|
87
111
|
async function renderManagedProcesses() {
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
112
|
+
// Resolve every registered mount that's currently in the DOM (Engine page +
|
|
113
|
+
// QA page when present). Skip the fetch entirely when nothing's mounted.
|
|
114
|
+
const liveMounts = _managedMounts
|
|
115
|
+
.map(m => ({ ...m, root: document.getElementById(m.contentId), countEl: m.countId ? document.getElementById(m.countId) : null }))
|
|
116
|
+
.filter(m => m.root);
|
|
117
|
+
if (!liveMounts.length) return;
|
|
91
118
|
let items;
|
|
92
119
|
let fetchErr = null;
|
|
93
120
|
try {
|
|
@@ -112,14 +139,15 @@ async function renderManagedProcesses() {
|
|
|
112
139
|
fetchErr = e;
|
|
113
140
|
}
|
|
114
141
|
let html;
|
|
142
|
+
let countText;
|
|
115
143
|
if (fetchErr) {
|
|
116
|
-
|
|
144
|
+
countText = '?';
|
|
117
145
|
html = '<span style="color:var(--red)">Failed to load: ' + escHtml(fetchErr.message) + '</span>';
|
|
118
146
|
} else if (!items || !items.length) {
|
|
119
|
-
|
|
147
|
+
countText = '0';
|
|
120
148
|
html = '<p class="empty">No managed processes. Agents declare them via <code>agents/<id>/managed-spawn.json</code>.</p>';
|
|
121
149
|
} else {
|
|
122
|
-
|
|
150
|
+
countText = String(items.length);
|
|
123
151
|
// Group by owner_project (empty/missing groups under "(unassigned)").
|
|
124
152
|
const groups = {};
|
|
125
153
|
for (const s of items) {
|
|
@@ -140,9 +168,13 @@ async function renderManagedProcesses() {
|
|
|
140
168
|
}
|
|
141
169
|
// DocumentFragment instead of innerHTML — keeps the file out of the
|
|
142
170
|
// dynamic-innerHTML regression gate (cf. render-other.js renderKeepProcesses).
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
171
|
+
// Build the fragment once per mount (DocumentFragment nodes get adopted on
|
|
172
|
+
// first append so each mount needs its own copy).
|
|
173
|
+
for (const m of liveMounts) {
|
|
174
|
+
if (m.countEl) m.countEl.textContent = countText;
|
|
175
|
+
const frag = document.createRange().createContextualFragment(html);
|
|
176
|
+
m.root.replaceChildren(frag);
|
|
177
|
+
}
|
|
146
178
|
}
|
|
147
179
|
|
|
148
180
|
async function killManagedSpec(name) {
|
|
@@ -268,4 +300,6 @@ window.MinionsManagedProcesses = {
|
|
|
268
300
|
restartManagedSpec,
|
|
269
301
|
openManagedLog,
|
|
270
302
|
closeManagedLog,
|
|
303
|
+
mountManagedProcessesPanel,
|
|
304
|
+
unmountManagedProcessesPanel,
|
|
271
305
|
};
|
|
@@ -488,12 +488,38 @@ window.MinionsOther = { renderProjects, optimisticallyAddProject, projectChipRem
|
|
|
488
488
|
// Polls /api/keep-processes every refresh (engine page only) and renders a
|
|
489
489
|
// table of active agents/<id>/keep-pids.json declarations with one-click
|
|
490
490
|
// "Kill PID" buttons that hit POST /api/keep-processes/kill.
|
|
491
|
+
//
|
|
492
|
+
// Mount-point registry (W-mpd5ewhj000oc5c5 — QA tab dual-render). Both the
|
|
493
|
+
// Engine page and the QA page register a mount; renderKeepProcesses() writes
|
|
494
|
+
// to every mount that's currently in the DOM. The Engine page mount is
|
|
495
|
+
// registered eagerly so the existing engine.html behavior is unchanged.
|
|
496
|
+
const _keepProcessesMounts = [
|
|
497
|
+
{ contentId: 'keep-processes-content', countId: 'keep-processes-count' },
|
|
498
|
+
];
|
|
499
|
+
|
|
500
|
+
function mountKeepProcessesPanel(opts) {
|
|
501
|
+
if (!opts || !opts.contentId) return;
|
|
502
|
+
for (const m of _keepProcessesMounts) {
|
|
503
|
+
if (m.contentId === opts.contentId) return;
|
|
504
|
+
}
|
|
505
|
+
_keepProcessesMounts.push({ contentId: opts.contentId, countId: opts.countId || null });
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function unmountKeepProcessesPanel(contentId) {
|
|
509
|
+
for (let i = _keepProcessesMounts.length - 1; i >= 0; i--) {
|
|
510
|
+
if (_keepProcessesMounts[i].contentId === contentId) _keepProcessesMounts.splice(i, 1);
|
|
511
|
+
}
|
|
512
|
+
}
|
|
491
513
|
|
|
492
514
|
async function renderKeepProcesses() {
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
515
|
+
// Resolve every registered mount currently in the DOM (Engine + QA pages).
|
|
516
|
+
// Skip the fetch entirely when nothing is mounted.
|
|
517
|
+
const liveMounts = _keepProcessesMounts
|
|
518
|
+
.map(m => ({ ...m, root: document.getElementById(m.contentId), countEl: m.countId ? document.getElementById(m.countId) : null }))
|
|
519
|
+
.filter(m => m.root);
|
|
520
|
+
if (!liveMounts.length) return;
|
|
496
521
|
let html;
|
|
522
|
+
let countText;
|
|
497
523
|
let items;
|
|
498
524
|
let fetchErr = null;
|
|
499
525
|
try {
|
|
@@ -504,13 +530,13 @@ async function renderKeepProcesses() {
|
|
|
504
530
|
fetchErr = e;
|
|
505
531
|
}
|
|
506
532
|
if (fetchErr) {
|
|
507
|
-
|
|
533
|
+
countText = '?';
|
|
508
534
|
html = '<span style="color:var(--red)">Failed to load: ' + escHtml(fetchErr.message) + '</span>';
|
|
509
535
|
} else if (!items.length) {
|
|
510
|
-
|
|
536
|
+
countText = '0';
|
|
511
537
|
html = '<p class="empty">No agents have left processes running. Set <code>meta.keep_processes: true</code> on a work item to enable.</p>';
|
|
512
538
|
} else {
|
|
513
|
-
|
|
539
|
+
countText = String(items.length);
|
|
514
540
|
html = items.map(function (it) {
|
|
515
541
|
if (!it.valid) {
|
|
516
542
|
return '<div style="border:1px solid var(--border);border-radius:4px;padding:8px;margin-bottom:8px;background:var(--surface2)">' +
|
|
@@ -548,10 +574,14 @@ async function renderKeepProcesses() {
|
|
|
548
574
|
// Use DocumentFragment instead of innerHTML assignment to keep this
|
|
549
575
|
// function out of the dynamic-innerHTML regression gate (see
|
|
550
576
|
// test/unit.test.js DYNAMIC_INNERHTML_BASELINE). All embedded
|
|
551
|
-
// user-controlled fields above are wrapped in escHtml().
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
577
|
+
// user-controlled fields above are wrapped in escHtml(). Build the
|
|
578
|
+
// fragment once per mount (DocumentFragment nodes get adopted on first
|
|
579
|
+
// append so each mount needs its own copy).
|
|
580
|
+
for (const m of liveMounts) {
|
|
581
|
+
if (m.countEl) m.countEl.textContent = countText;
|
|
582
|
+
const frag = document.createRange().createContextualFragment(html);
|
|
583
|
+
m.root.replaceChildren(frag);
|
|
584
|
+
}
|
|
555
585
|
}
|
|
556
586
|
|
|
557
587
|
async function killKeepPid(agentId, pid) {
|
|
@@ -573,5 +603,5 @@ async function killKeepPid(agentId, pid) {
|
|
|
573
603
|
renderKeepProcesses();
|
|
574
604
|
}
|
|
575
605
|
|
|
576
|
-
window.MinionsKeepProcesses = { renderKeepProcesses, killKeepPid };
|
|
606
|
+
window.MinionsKeepProcesses = { renderKeepProcesses, killKeepPid, mountKeepProcessesPanel, unmountKeepProcessesPanel };
|
|
577
607
|
|
package/dashboard/layout.html
CHANGED
|
@@ -69,6 +69,7 @@
|
|
|
69
69
|
<a class="sidebar-link" data-page="work" href="/work">Work Items <span class="sidebar-count" id="sidebar-wi"></span></a>
|
|
70
70
|
<a class="sidebar-link" data-page="plans" href="/plans">Plans & PRD</a>
|
|
71
71
|
<a class="sidebar-link" data-page="prs" href="/prs">Pull Requests <span class="sidebar-count" id="sidebar-pr"></span></a>
|
|
72
|
+
<a class="sidebar-link" data-page="qa" href="/qa" title="QA — live processes + validation runbooks (managed-spawn + keep-processes)">QA</a>
|
|
72
73
|
<a class="sidebar-link" data-page="inbox" href="/inbox">Notes & KB</a>
|
|
73
74
|
<a class="sidebar-link" data-page="tools" href="/tools">Skills & MCP</a>
|
|
74
75
|
<a class="sidebar-link" data-page="schedule" href="/schedule" title="Cron-based recurring tasks (single task per schedule)">Schedules</a>
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<section>
|
|
2
|
+
<h2>Live Processes <span style="font-size:10px;color:var(--muted);font-weight:400;text-transform:none;letter-spacing:0">canonical home for managed instances + agent-left processes (W-mpd5ewhj000oc5c5)</span></h2>
|
|
3
|
+
<p class="empty" style="margin:4px 0 12px 0">The QA tab is the foundation for human-driven and agent-driven validation against running managed instances. Phase 1 surfaces the live process inventory; runbook dispatch lands in a follow-up WI.</p>
|
|
4
|
+
</section>
|
|
5
|
+
<section id="qa-managed-processes-section">
|
|
6
|
+
<h2>Managed Processes <span class="count" id="qa-managed-processes-count">0</span>
|
|
7
|
+
<span style="font-size:10px;color:var(--muted);font-weight:400;text-transform:none;letter-spacing:0">engine-managed long-running services (P-6e2a8b13 — managed-spawn primitive)</span>
|
|
8
|
+
</h2>
|
|
9
|
+
<div id="qa-managed-processes-content"><p class="empty">No managed processes. Agents declare them via <code>agents/<id>/managed-spawn.json</code>.</p></div>
|
|
10
|
+
</section>
|
|
11
|
+
<section id="qa-keep-processes-section">
|
|
12
|
+
<h2>Keep-Processes <span class="count" id="qa-keep-processes-count">0</span>
|
|
13
|
+
<span style="font-size:10px;color:var(--muted);font-weight:400;text-transform:none;letter-spacing:0">processes left running by agents (W-mp68q6ke0010de68 — opt-in keep_processes flag)</span>
|
|
14
|
+
</h2>
|
|
15
|
+
<div id="qa-keep-processes-content"><p class="empty">No agents have left processes running. Set <code>meta.keep_processes: true</code> on a work item to enable.</p></div>
|
|
16
|
+
</section>
|
|
17
|
+
<section id="qa-runbooks-section">
|
|
18
|
+
<h2>Validation Runbooks <span style="font-size:10px;color:var(--muted);font-weight:400;text-transform:none;letter-spacing:0">human or agent-driven smoke / E2E flows against the live instances above</span></h2>
|
|
19
|
+
<div style="border:1px dashed var(--border);border-radius:6px;padding:16px;background:var(--surface2);text-align:center">
|
|
20
|
+
<p class="empty" style="margin:0 0 12px 0">Validation runbooks will live here. Coming soon: dispatch a human or agent to validate a running instance.</p>
|
|
21
|
+
<button id="qa-new-runbook-btn" disabled title="Coming soon — runbook schema lands in the next WI" style="padding:6px 14px;background:var(--surface);color:var(--muted);border:1px solid var(--border);border-radius:4px;cursor:not-allowed;font-size:12px">+ New runbook</button>
|
|
22
|
+
</div>
|
|
23
|
+
</section>
|
package/dashboard-build.js
CHANGED
|
@@ -20,7 +20,7 @@ function buildDashboardHtml() {
|
|
|
20
20
|
const layout = safeRead(layoutPath);
|
|
21
21
|
const css = safeRead(path.join(dashDir, 'styles.css'));
|
|
22
22
|
|
|
23
|
-
const pages = ['home', 'work', 'prs', 'plans', 'inbox', 'tools', 'schedule', 'watches', 'pipelines', 'meetings', 'engine'];
|
|
23
|
+
const pages = ['home', 'work', 'prs', 'plans', 'inbox', 'tools', 'schedule', 'watches', 'pipelines', 'meetings', 'qa', 'engine'];
|
|
24
24
|
let pageHtml = '';
|
|
25
25
|
for (const p of pages) {
|
|
26
26
|
const content = safeRead(path.join(dashDir, 'pages', p + '.html'));
|
|
@@ -34,7 +34,7 @@ function buildDashboardHtml() {
|
|
|
34
34
|
'render-prs', 'render-plans', 'render-inbox', 'render-kb', 'render-skills',
|
|
35
35
|
'render-other', 'render-managed', 'render-schedules', 'render-watches', 'render-pipelines', 'render-meetings', 'render-pinned',
|
|
36
36
|
'command-parser', 'command-input', 'command-center', 'command-history',
|
|
37
|
-
'modal', 'modal-qa', 'settings', 'refresh'
|
|
37
|
+
'modal', 'modal-qa', 'settings', 'qa', 'refresh'
|
|
38
38
|
];
|
|
39
39
|
let jsHtml = '';
|
|
40
40
|
for (const f of jsFiles) {
|
package/dashboard.js
CHANGED
|
@@ -95,6 +95,21 @@ function reloadConfig() {
|
|
|
95
95
|
}
|
|
96
96
|
ensureConfiguredProjectStateFiles();
|
|
97
97
|
|
|
98
|
+
// Pre-warm git-status cache for every configured project so the first
|
|
99
|
+
// /api/status after dashboard boot already has branch/dirty data — without
|
|
100
|
+
// blocking the event loop on the per-project git shell-outs. Fire-and-forget,
|
|
101
|
+
// boot-only: re-warming on every reloadConfig() would spawn 4× git probes on
|
|
102
|
+
// every 10s status-poll cache miss. Project add/remove handlers explicitly
|
|
103
|
+
// invoke this when the project list actually changes.
|
|
104
|
+
function warmProjectGitStatusCache() {
|
|
105
|
+
for (const p of PROJECTS) {
|
|
106
|
+
if (p && p.localPath) {
|
|
107
|
+
try { queries.warmProjectGitStatus(p.localPath); } catch { /* swallow — warming is opportunistic */ }
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
warmProjectGitStatusCache();
|
|
112
|
+
|
|
98
113
|
function resolveScheduleProjectValue(project, projects = PROJECTS) {
|
|
99
114
|
if (project === undefined) return { project: undefined };
|
|
100
115
|
const target = shared.resolveConfiguredProject(project, projects);
|
|
@@ -860,7 +875,7 @@ function buildDashboardHtml() {
|
|
|
860
875
|
// Assemble page fragments. Each wrapper gets a `.page-toast` inline slot at
|
|
861
876
|
// the top — showToast('cmd-toast', …) auto-routes here when a page is active,
|
|
862
877
|
// so feedback lands near the action instead of the floating top-right toast.
|
|
863
|
-
const pages = ['home', 'work', 'prs', 'plans', 'inbox', 'tools', 'schedule', 'watches', 'pipelines', 'meetings', 'engine'];
|
|
878
|
+
const pages = ['home', 'work', 'prs', 'plans', 'inbox', 'tools', 'schedule', 'watches', 'pipelines', 'meetings', 'qa', 'engine'];
|
|
864
879
|
const pageToast = ' <div class="cmd-toast cmd-toast-inline page-toast" style="margin:6px 16px"></div>\n';
|
|
865
880
|
let pageHtml = '';
|
|
866
881
|
for (const p of pages) {
|
|
@@ -876,7 +891,7 @@ function buildDashboardHtml() {
|
|
|
876
891
|
'render-prs', 'render-plans', 'render-inbox', 'render-kb', 'render-skills',
|
|
877
892
|
'render-other', 'render-managed', 'render-schedules', 'render-watches', 'render-pipelines', 'render-meetings', 'render-pinned',
|
|
878
893
|
'command-parser', 'command-input', 'command-center', 'command-history',
|
|
879
|
-
'modal', 'modal-qa', 'settings', 'refresh'
|
|
894
|
+
'modal', 'modal-qa', 'settings', 'qa', 'refresh'
|
|
880
895
|
];
|
|
881
896
|
let jsHtml = '';
|
|
882
897
|
for (const f of jsFiles) {
|
|
@@ -1581,6 +1596,8 @@ const CC_CALL_TIMEOUT_MS = 60 * 60 * 1000; // 1 hour — long-running CC orchest
|
|
|
1581
1596
|
const CC_INFLIGHT_TIMEOUT_MS = 2 * 60 * 1000; // 2 minutes — auto-release if request hangs
|
|
1582
1597
|
const CC_LOCK_WAIT_MS = 200; // grace period for previous handler's finally to release lock
|
|
1583
1598
|
const CC_STREAM_HEARTBEAT_MS = 15000; // keep streaming responses alive across proxies/restart races
|
|
1599
|
+
const CC_STREAM_STALL_THRESHOLD_MS = 3000; // log [cc-stall] when heartbeat fires >3s late (event-loop blocked)
|
|
1600
|
+
const CC_LOG_ERROR_MAX_LEN = 80; // truncate exception messages in [cc-stream] log lines
|
|
1584
1601
|
const CC_STREAM_REATTACH_GRACE_MS = 60000; // keep CC job alive briefly after disconnect so the UI can reattach
|
|
1585
1602
|
const CC_STREAM_DONE_RETENTION_MS = 30000; // retain final payload briefly so reconnect can still receive it
|
|
1586
1603
|
const CC_LIVE_STREAM_MAX_AGE_MS = shared.ENGINE_DEFAULTS.ccLiveStreamMaxAgeMs;
|
|
@@ -1704,6 +1721,32 @@ function _ccTabIsInFlight(tabId) {
|
|
|
1704
1721
|
return true;
|
|
1705
1722
|
}
|
|
1706
1723
|
|
|
1724
|
+
// Emits exactly one structured log line per CC SSE stream termination, so
|
|
1725
|
+
// "Failed to fetch" reports can be matched to a server-side reason (restart,
|
|
1726
|
+
// abort, timeout, rate-limit, write-failed, stall, ...). Idempotent via the
|
|
1727
|
+
// telemetry object's `_logged` flag — first call wins, subsequent calls no-op.
|
|
1728
|
+
function _logCcStreamEnd(telemetry, reason, extra) {
|
|
1729
|
+
if (!telemetry || telemetry._logged) return;
|
|
1730
|
+
telemetry._logged = true;
|
|
1731
|
+
const durationMs = telemetry.startedAt ? (Date.now() - telemetry.startedAt) : 0;
|
|
1732
|
+
const parts = [
|
|
1733
|
+
`tab=${telemetry.tabId || 'unknown'}`,
|
|
1734
|
+
`session=${telemetry.sessionId || 'none'}`,
|
|
1735
|
+
`reason=${reason || 'unknown'}`,
|
|
1736
|
+
`duration=${durationMs}ms`,
|
|
1737
|
+
`chunks=${telemetry.chunks || 0}`,
|
|
1738
|
+
`tools=${telemetry.tools || 0}`,
|
|
1739
|
+
`bytes=${telemetry.bytes || 0}`,
|
|
1740
|
+
];
|
|
1741
|
+
if (extra && typeof extra === 'object') {
|
|
1742
|
+
for (const [k, v] of Object.entries(extra)) {
|
|
1743
|
+
if (v === undefined || v === null) continue;
|
|
1744
|
+
parts.push(`${k}=${v}`);
|
|
1745
|
+
}
|
|
1746
|
+
}
|
|
1747
|
+
console.log(`[cc-stream] ${parts.join(' ')}`);
|
|
1748
|
+
}
|
|
1749
|
+
|
|
1707
1750
|
// _ccPromptHash computed after CC_STATIC_SYSTEM_PROMPT is defined (see below)
|
|
1708
1751
|
|
|
1709
1752
|
function ccSessionValid() {
|
|
@@ -6268,6 +6311,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
6268
6311
|
});
|
|
6269
6312
|
if (duplicate) return jsonReply(res, 400, { error: 'Project already linked at ' + target });
|
|
6270
6313
|
reloadConfig(); // Update in-memory project list immediately
|
|
6314
|
+
warmProjectGitStatusCache(); // Probe the new project's git status in the background
|
|
6271
6315
|
// includeSlow: PROJECTS lives in the slow-state cache (60s TTL); without
|
|
6272
6316
|
// flushing it, /api/status keeps returning the previous project list for
|
|
6273
6317
|
// up to a minute after the add. Matches handleProjectsRemove's behavior.
|
|
@@ -6713,6 +6757,10 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
6713
6757
|
}
|
|
6714
6758
|
|
|
6715
6759
|
async function handleCommandCenterStream(req, res) {
|
|
6760
|
+
// Per-stream telemetry — _logCcStreamEnd is idempotent so callers don't
|
|
6761
|
+
// need to coordinate. Augmented by writeCcEvent (chunks, bytes) and the
|
|
6762
|
+
// heartbeat stall detector below.
|
|
6763
|
+
const _ccTelemetry = { startedAt: Date.now(), tabId: null, sessionId: null, chunks: 0, tools: 0, bytes: 0, _logged: false, _stallLogged: false };
|
|
6716
6764
|
// SSE Origin gate (belt-and-suspenders: the top-level dispatcher has
|
|
6717
6765
|
// already rejected disallowed origins on POST, but validate again here
|
|
6718
6766
|
// before res.writeHead(200, text/event-stream) so any future refactor
|
|
@@ -6723,16 +6771,29 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
6723
6771
|
res.statusCode = 403;
|
|
6724
6772
|
res.setHeader('Content-Type', 'application/json');
|
|
6725
6773
|
res.end(JSON.stringify({ error: 'Origin not allowed' }));
|
|
6774
|
+
_logCcStreamEnd(_ccTelemetry, 'origin-rejected', { origin: _origin });
|
|
6775
|
+
return;
|
|
6776
|
+
}
|
|
6777
|
+
if (checkRateLimit('command-center', 10)) {
|
|
6778
|
+
res.statusCode = 429; res.end('Rate limited');
|
|
6779
|
+
_logCcStreamEnd(_ccTelemetry, 'rate-limited-pre-stream');
|
|
6726
6780
|
return;
|
|
6727
6781
|
}
|
|
6728
|
-
if (checkRateLimit('command-center', 10)) { res.statusCode = 429; res.end('Rate limited'); return; }
|
|
6729
6782
|
let tabId;
|
|
6730
6783
|
let _ccStreamAbort = null;
|
|
6731
6784
|
let _ccStreamEnded = false;
|
|
6732
6785
|
let _ccHeartbeatTimer = null;
|
|
6786
|
+
let _ccLastHeartbeatAt = Date.now();
|
|
6733
6787
|
const writeCcEvent = (payload) => {
|
|
6734
6788
|
try {
|
|
6735
|
-
|
|
6789
|
+
const wire = 'data: ' + JSON.stringify(payload) + '\n\n';
|
|
6790
|
+
res.write(wire);
|
|
6791
|
+
if (payload && payload.type === 'chunk') {
|
|
6792
|
+
_ccTelemetry.chunks++;
|
|
6793
|
+
_ccTelemetry.bytes += Buffer.byteLength(String(payload.text || ''), 'utf8');
|
|
6794
|
+
} else if (payload && payload.type === 'tool') {
|
|
6795
|
+
_ccTelemetry.tools++;
|
|
6796
|
+
}
|
|
6736
6797
|
return true;
|
|
6737
6798
|
} catch {
|
|
6738
6799
|
return false;
|
|
@@ -6744,29 +6805,66 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
6744
6805
|
_ccHeartbeatTimer = null;
|
|
6745
6806
|
}
|
|
6746
6807
|
};
|
|
6808
|
+
// Event-loop-stall detector — piggybacks on the heartbeat interval.
|
|
6809
|
+
// setInterval timers fire late when the loop is blocked; if the gap
|
|
6810
|
+
// between fires exceeds HEARTBEAT_MS + STALL_THRESHOLD_MS, the loop was
|
|
6811
|
+
// stuck (sync I/O, large JSON.parse, shelled-out command, ...). Log once
|
|
6812
|
+
// per stream to avoid spamming.
|
|
6813
|
+
const _checkStall = () => {
|
|
6814
|
+
const now = Date.now();
|
|
6815
|
+
const expected = _ccLastHeartbeatAt + CC_STREAM_HEARTBEAT_MS;
|
|
6816
|
+
const drift = now - expected;
|
|
6817
|
+
_ccLastHeartbeatAt = now;
|
|
6818
|
+
if (drift > CC_STREAM_STALL_THRESHOLD_MS && !_ccTelemetry._stallLogged) {
|
|
6819
|
+
_ccTelemetry._stallLogged = true;
|
|
6820
|
+
console.warn(`[cc-stall] tab=${_ccTelemetry.tabId || 'unknown'} drift=${drift}ms heartbeatExpected=${CC_STREAM_HEARTBEAT_MS}ms — event loop blocked`);
|
|
6821
|
+
}
|
|
6822
|
+
};
|
|
6823
|
+
// Start the heartbeat + stall-detector interval. Both SSE paths (reconnect
|
|
6824
|
+
// and normal stream) used to duplicate this block; factor it out so the
|
|
6825
|
+
// stall check and write-failure log-line stay in one place.
|
|
6826
|
+
const _startCcHeartbeat = (writeFailedReason) => {
|
|
6827
|
+
_ccLastHeartbeatAt = Date.now();
|
|
6828
|
+
_ccHeartbeatTimer = setInterval(() => {
|
|
6829
|
+
if (_ccStreamEnded) {
|
|
6830
|
+
stopCcHeartbeat();
|
|
6831
|
+
return;
|
|
6832
|
+
}
|
|
6833
|
+
_checkStall();
|
|
6834
|
+
if (!writeCcEvent({ type: 'heartbeat' })) {
|
|
6835
|
+
stopCcHeartbeat();
|
|
6836
|
+
if (writeFailedReason) _logCcStreamEnd(_ccTelemetry, writeFailedReason);
|
|
6837
|
+
}
|
|
6838
|
+
}, CC_STREAM_HEARTBEAT_MS);
|
|
6839
|
+
};
|
|
6747
6840
|
const finishMissingRuntime = (result, liveState) => {
|
|
6748
6841
|
const text = result.text || result.stderr || 'Minions runtime is not installed or configured.';
|
|
6749
6842
|
liveState.donePayload = { type: 'done', text, actions: [], sessionId: null, missingRuntime: true };
|
|
6750
6843
|
if (liveState.writer) liveState.writer(liveState.donePayload);
|
|
6751
6844
|
if (liveState.endResponse) liveState.endResponse();
|
|
6752
6845
|
_scheduleCcLiveCleanup(tabId);
|
|
6846
|
+
_logCcStreamEnd(_ccTelemetry, 'missing-runtime');
|
|
6753
6847
|
};
|
|
6754
6848
|
try {
|
|
6755
6849
|
const body = await readBody(req);
|
|
6756
|
-
if (!body.message && !body.reconnect) {
|
|
6850
|
+
if (!body.message && !body.reconnect) {
|
|
6851
|
+
res.statusCode = 400; res.end('message required');
|
|
6852
|
+
_logCcStreamEnd(_ccTelemetry, 'bad-request');
|
|
6853
|
+
return;
|
|
6854
|
+
}
|
|
6757
6855
|
tabId = body.tabId || 'default';
|
|
6856
|
+
_ccTelemetry.tabId = tabId;
|
|
6857
|
+
_ccTelemetry.sessionId = body.sessionId || null;
|
|
6758
6858
|
if (body.reconnect) {
|
|
6759
6859
|
const live = _getCcLiveStream(tabId);
|
|
6760
|
-
if (!live) {
|
|
6860
|
+
if (!live) {
|
|
6861
|
+
res.statusCode = 409; res.end('No live command-center response to reconnect');
|
|
6862
|
+
_logCcStreamEnd(_ccTelemetry, 'reconnect-not-found');
|
|
6863
|
+
return;
|
|
6864
|
+
}
|
|
6761
6865
|
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' });
|
|
6762
6866
|
writeCcEvent({ type: 'heartbeat' });
|
|
6763
|
-
|
|
6764
|
-
if (_ccStreamEnded) {
|
|
6765
|
-
stopCcHeartbeat();
|
|
6766
|
-
return;
|
|
6767
|
-
}
|
|
6768
|
-
if (!writeCcEvent({ type: 'heartbeat' })) stopCcHeartbeat();
|
|
6769
|
-
}, CC_STREAM_HEARTBEAT_MS);
|
|
6867
|
+
_startCcHeartbeat();
|
|
6770
6868
|
let reconnectDone;
|
|
6771
6869
|
const reconnectDonePromise = new Promise(resolve => { reconnectDone = resolve; });
|
|
6772
6870
|
_attachCcLiveStream(tabId, writeCcEvent, () => {
|
|
@@ -6774,6 +6872,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
6774
6872
|
_ccStreamEnded = true;
|
|
6775
6873
|
stopCcHeartbeat();
|
|
6776
6874
|
try { res.end(); } catch {}
|
|
6875
|
+
_logCcStreamEnd(_ccTelemetry, 'reconnect-attached-done');
|
|
6777
6876
|
reconnectDone();
|
|
6778
6877
|
});
|
|
6779
6878
|
req.on('close', () => {
|
|
@@ -6781,6 +6880,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
6781
6880
|
stopCcHeartbeat();
|
|
6782
6881
|
_detachCcLiveStream(tabId, writeCcEvent);
|
|
6783
6882
|
_scheduleCcLiveAbort(tabId);
|
|
6883
|
+
_logCcStreamEnd(_ccTelemetry, 'reconnect-client-disconnect');
|
|
6784
6884
|
reconnectDone();
|
|
6785
6885
|
});
|
|
6786
6886
|
for (const tool of live.tools || []) {
|
|
@@ -6793,6 +6893,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
6793
6893
|
stopCcHeartbeat();
|
|
6794
6894
|
try { res.end(); } catch {}
|
|
6795
6895
|
_scheduleCcLiveCleanup(tabId);
|
|
6896
|
+
_logCcStreamEnd(_ccTelemetry, 'reconnect-replayed-done');
|
|
6796
6897
|
return;
|
|
6797
6898
|
}
|
|
6798
6899
|
await reconnectDonePromise;
|
|
@@ -6804,7 +6905,9 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
6804
6905
|
if (prevAbort) { prevAbort(); }
|
|
6805
6906
|
await new Promise(r => setTimeout(r, CC_LOCK_WAIT_MS)); // let previous finally run and release the lock
|
|
6806
6907
|
if (_ccTabIsInFlight(tabId)) {
|
|
6807
|
-
res.statusCode = 429; res.end('This tab is already processing');
|
|
6908
|
+
res.statusCode = 429; res.end('This tab is already processing');
|
|
6909
|
+
_logCcStreamEnd(_ccTelemetry, 'tabid-collision');
|
|
6910
|
+
return;
|
|
6808
6911
|
}
|
|
6809
6912
|
}
|
|
6810
6913
|
ccInFlightTabs.set(tabId, Date.now());
|
|
@@ -6818,13 +6921,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
6818
6921
|
|
|
6819
6922
|
res.writeHead(200, { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive' });
|
|
6820
6923
|
writeCcEvent({ type: 'heartbeat' }); // flush headers quickly and keep intermediaries from idling out
|
|
6821
|
-
|
|
6822
|
-
if (_ccStreamEnded) {
|
|
6823
|
-
stopCcHeartbeat();
|
|
6824
|
-
return;
|
|
6825
|
-
}
|
|
6826
|
-
if (!writeCcEvent({ type: 'heartbeat' })) stopCcHeartbeat();
|
|
6827
|
-
}, CC_STREAM_HEARTBEAT_MS);
|
|
6924
|
+
_startCcHeartbeat('heartbeat-write-failed');
|
|
6828
6925
|
// Kill LLM process immediately if client disconnects mid-stream.
|
|
6829
6926
|
// Keep the LLM alive briefly after disconnect so the UI can reattach to the same in-flight turn.
|
|
6830
6927
|
req.on('close', () => {
|
|
@@ -6832,6 +6929,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
6832
6929
|
stopCcHeartbeat();
|
|
6833
6930
|
_detachCcLiveStream(tabId, writeCcEvent);
|
|
6834
6931
|
_scheduleCcLiveAbort(tabId);
|
|
6932
|
+
_logCcStreamEnd(_ccTelemetry, 'client-disconnect');
|
|
6835
6933
|
}
|
|
6836
6934
|
});
|
|
6837
6935
|
|
|
@@ -6955,7 +7053,11 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
6955
7053
|
return;
|
|
6956
7054
|
}
|
|
6957
7055
|
if (!result.text) {
|
|
6958
|
-
if (req.destroyed) {
|
|
7056
|
+
if (req.destroyed) {
|
|
7057
|
+
_ccStreamEnded = true;
|
|
7058
|
+
_logCcStreamEnd(_ccTelemetry, 'llm-empty-client-gone', { code: result.code });
|
|
7059
|
+
return;
|
|
7060
|
+
}
|
|
6959
7061
|
const debugInfo = result.code !== 0 ? `(exit code ${result.code})` : '(empty response)';
|
|
6960
7062
|
const stderrTail = (result.stderr || '').trim().split('\n').filter(Boolean).slice(-3).join(' | ');
|
|
6961
7063
|
console.error(`[CC-stream] Failed: code=${result.code}, stderr=${(result.stderr || '').slice(0, 500)}, stdout_tail=${(result.raw || '').slice(-500)}`);
|
|
@@ -6964,6 +7066,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
6964
7066
|
if (liveState.writer) liveState.writer(liveState.donePayload);
|
|
6965
7067
|
if (liveState.endResponse) liveState.endResponse();
|
|
6966
7068
|
_scheduleCcLiveCleanup(tabId);
|
|
7069
|
+
_logCcStreamEnd(_ccTelemetry, 'llm-failed-fallback-sent', { code: result.code });
|
|
6967
7070
|
return;
|
|
6968
7071
|
}
|
|
6969
7072
|
|
|
@@ -7013,6 +7116,8 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
7013
7116
|
|
|
7014
7117
|
if (liveState.endResponse) liveState.endResponse();
|
|
7015
7118
|
_scheduleCcLiveCleanup(tabId);
|
|
7119
|
+
_ccTelemetry.sessionId = responseSessionId || _ccTelemetry.sessionId;
|
|
7120
|
+
_logCcStreamEnd(_ccTelemetry, 'done', { actions: actions.length });
|
|
7016
7121
|
} finally {
|
|
7017
7122
|
stopCcHeartbeat();
|
|
7018
7123
|
_releaseCCTab(tabId);
|
|
@@ -7026,9 +7131,11 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
7026
7131
|
res.statusCode = e.statusCode || 500;
|
|
7027
7132
|
res.setHeader('Content-Type', 'application/json');
|
|
7028
7133
|
try { res.end(JSON.stringify({ error: e.message })); } catch {}
|
|
7134
|
+
_logCcStreamEnd(_ccTelemetry, 'error-pre-stream', { error: (e && e.message ? e.message.slice(0, CC_LOG_ERROR_MAX_LEN) : 'unknown') });
|
|
7029
7135
|
} else {
|
|
7030
7136
|
writeCcEvent({ type: 'error', error: e.message });
|
|
7031
7137
|
_ccStreamEnded = true; try { res.end(); } catch {}
|
|
7138
|
+
_logCcStreamEnd(_ccTelemetry, 'error-mid-stream', { error: (e && e.message ? e.message.slice(0, CC_LOG_ERROR_MAX_LEN) : 'unknown') });
|
|
7032
7139
|
}
|
|
7033
7140
|
}
|
|
7034
7141
|
}
|
|
@@ -8520,8 +8627,12 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
8520
8627
|
} },
|
|
8521
8628
|
{ method: 'GET', path: '/api/agent-output', desc: 'Read agent output log file', params: 'file', handler: async (req, res) => {
|
|
8522
8629
|
const file = new URL(req.url, 'http://localhost').searchParams.get('file');
|
|
8523
|
-
|
|
8524
|
-
|
|
8630
|
+
let safePath;
|
|
8631
|
+
try { safePath = shared.sanitizePath(file, MINIONS_DIR); }
|
|
8632
|
+
catch { return jsonReply(res, 400, { error: 'invalid path' }); }
|
|
8633
|
+
const agentsDir = path.join(MINIONS_DIR, 'agents');
|
|
8634
|
+
if (!safePath.startsWith(agentsDir + path.sep)) return jsonReply(res, 400, { error: 'path must be within agents/' });
|
|
8635
|
+
const content = safeRead(safePath);
|
|
8525
8636
|
if (content === null) return jsonReply(res, 404, { error: 'not found' });
|
|
8526
8637
|
res.setHeader('Content-Type', 'text/plain; charset=utf-8');
|
|
8527
8638
|
res.setHeader('Cache-Control', 'no-cache');
|