@yemi33/minions 0.1.2238 → 0.1.2240
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/command-center.js +25 -2
- package/dashboard/js/refresh.js +18 -3
- package/dashboard/js/render-other.js +19 -5
- package/dashboard/js/render-plans.js +8 -6
- package/dashboard/js/render-work-items.js +5 -3
- package/dashboard/js/utils.js +23 -13
- package/dashboard.js +2 -2
- package/docs/harness-transparency.md +6 -5
- package/docs/live-checkout-mode.md +37 -7
- package/engine/cli.js +26 -0
- package/engine/comment-classifier.js +1 -1
- package/engine/consolidation.js +119 -24
- package/engine/dispatch-store.js +27 -4
- package/engine/dispatch.js +1 -0
- package/engine/github.js +4 -1
- package/engine/live-checkout.js +292 -10
- package/engine/queries.js +115 -14
- package/engine/shared.js +49 -17
- package/engine/timeout.js +10 -1
- package/engine/watchdog.js +75 -0
- package/engine.js +202 -13
- package/package.json +1 -1
|
@@ -1144,6 +1144,14 @@ async function _ccDoSend(message, skipUserMsg, forceTabId, intentMetadata) {
|
|
|
1144
1144
|
// render-utils.js ccSegments* helpers.
|
|
1145
1145
|
var segments = [];
|
|
1146
1146
|
if (activeTab) activeTab._segments = segments;
|
|
1147
|
+
// Set when a stream interruption triggers a reconnect. The server replays the
|
|
1148
|
+
// full accumulated snapshot (all tools + the whole text as one chunk) on
|
|
1149
|
+
// reconnect, so the already-rendered segments must be cleared to avoid
|
|
1150
|
+
// duplication — but we DEFER that clear until the replay's first event
|
|
1151
|
+
// actually arrives (see _handleEvent) instead of blanking the bubble at
|
|
1152
|
+
// reconnect time. Eager clearing produced a visible disappear/reappear of the
|
|
1153
|
+
// streamed response during the >1s reconnect wait (health check + backoff).
|
|
1154
|
+
var _ccReplayPending = false;
|
|
1147
1155
|
|
|
1148
1156
|
// Get active tab's sessionId to send with request
|
|
1149
1157
|
var tabSessionId = activeTab ? activeTab.sessionId : null;
|
|
@@ -1258,6 +1266,17 @@ async function _ccDoSend(message, skipUserMsg, forceTabId, intentMetadata) {
|
|
|
1258
1266
|
var pendingEventName = '';
|
|
1259
1267
|
|
|
1260
1268
|
async function _handleEvent(evt) {
|
|
1269
|
+
// First event after a reconnect: the server replays the full accumulated
|
|
1270
|
+
// snapshot (all prior tools, then the whole text as one chunk — see the
|
|
1271
|
+
// reconnect branch in dashboard.js). Swap the pre-reconnect segments out
|
|
1272
|
+
// atomically HERE, the instant the replay starts arriving, instead of
|
|
1273
|
+
// blanking them at reconnect time. This keeps the streamed response on
|
|
1274
|
+
// screen continuously across a reconnect (no disappear/reappear flicker).
|
|
1275
|
+
if (_ccReplayPending && (evt.type === 'chunk' || evt.type === 'tool' || evt.type === 'tool-update' || evt.type === 'done')) {
|
|
1276
|
+
segments.length = 0; // clear in place — activeTab._segments shares this ref
|
|
1277
|
+
if (activeTab) activeTab._segments = segments;
|
|
1278
|
+
_ccReplayPending = false;
|
|
1279
|
+
}
|
|
1261
1280
|
if (evt.type === 'chunk') {
|
|
1262
1281
|
// evt.segmentId (from engine/llm.js) marks distinct assistant text
|
|
1263
1282
|
// blocks; ccSegmentsApplyChunk falls back to a heuristic when it's
|
|
@@ -1450,8 +1469,12 @@ async function _ccDoSend(message, skipUserMsg, forceTabId, intentMetadata) {
|
|
|
1450
1469
|
break;
|
|
1451
1470
|
}
|
|
1452
1471
|
reconnectAttempts++;
|
|
1453
|
-
|
|
1454
|
-
|
|
1472
|
+
// Don't blank the already-streamed content here. Defer clearing `segments`
|
|
1473
|
+
// until the reconnect's replay actually starts delivering (see the
|
|
1474
|
+
// _ccReplayPending guard in _handleEvent), so the bubble keeps showing the
|
|
1475
|
+
// prior content (with the "reattaching" note below it) instead of going
|
|
1476
|
+
// empty for the >1s reconnect window and then re-filling.
|
|
1477
|
+
_ccReplayPending = true;
|
|
1455
1478
|
streamStatusNote = 'Connection interrupted — reattaching to the live response...';
|
|
1456
1479
|
updateStreamDiv();
|
|
1457
1480
|
await new Promise(function(r) { setTimeout(r, 1000 * reconnectAttempts); });
|
package/dashboard/js/refresh.js
CHANGED
|
@@ -151,7 +151,9 @@ const RENDER_VERSIONS = {
|
|
|
151
151
|
skills: 1,
|
|
152
152
|
commands: 1,
|
|
153
153
|
mcpServers: 1,
|
|
154
|
-
|
|
154
|
+
// Bumped to 2 by W-mqptgmcn000k5c90: renderHarnessDiagnostics now stamps each
|
|
155
|
+
// <details> with a stable data-hkey and the render is gated behind _changed().
|
|
156
|
+
harnessDiag: 2,
|
|
155
157
|
schedules: 1,
|
|
156
158
|
watches: 3,
|
|
157
159
|
meetings: 1,
|
|
@@ -875,10 +877,23 @@ function _processStatusUpdate(data, opts) {
|
|
|
875
877
|
.then(function (r) { return r.ok ? r.json() : Promise.reject(); })
|
|
876
878
|
.then(function (fresh) {
|
|
877
879
|
window._lastHarnessDiag = fresh;
|
|
878
|
-
|
|
880
|
+
// renderHarnessDiagnostics rewrites #harness-diag innerHTML, which
|
|
881
|
+
// destroys the native <details> open/closed state the operator just
|
|
882
|
+
// clicked. The diagnostic changes rarely, so only re-render when the
|
|
883
|
+
// freshly-fetched payload differs from the last-rendered one (mirrors
|
|
884
|
+
// the _changed() cache-bust used for mcpServers/commands). Steady-state
|
|
885
|
+
// polls bail out here and leave the user's expanded sections intact.
|
|
886
|
+
if (_changed('harnessDiag', fresh)) {
|
|
887
|
+
_safeRender('harnessDiag', function() { renderHarnessDiagnostics(fresh); });
|
|
888
|
+
}
|
|
879
889
|
})
|
|
880
890
|
.catch(function () {
|
|
881
|
-
|
|
891
|
+
// Fetch failed — only paint the cached diagnostic if it has not already
|
|
892
|
+
// been rendered (first-load failure recovery). Re-painting an
|
|
893
|
+
// already-rendered payload would needlessly collapse open sections.
|
|
894
|
+
if (window._lastHarnessDiag && _changed('harnessDiag', window._lastHarnessDiag)) {
|
|
895
|
+
_safeRender('harnessDiag', function() { renderHarnessDiagnostics(window._lastHarnessDiag); });
|
|
896
|
+
}
|
|
882
897
|
});
|
|
883
898
|
});
|
|
884
899
|
// Schedule definitions stay on /api/status (config-derived), but the
|
|
@@ -663,7 +663,7 @@ function renderHarnessDiagnostics(diag) {
|
|
|
663
663
|
|
|
664
664
|
// Per-runtime adapter rows
|
|
665
665
|
for (const r of diag.runtimes || []) {
|
|
666
|
-
parts.push('<details style="margin:6px 0;border:1px solid var(--border);border-radius:6px;padding:8px 10px;background:var(--surface2)"><summary style="font-weight:600;cursor:pointer">Runtime: ' + escHtml(r.name) + '</summary>' +
|
|
666
|
+
parts.push('<details data-hkey="rt:' + escHtml(r.name) + '" style="margin:6px 0;border:1px solid var(--border);border-radius:6px;padding:8px 10px;background:var(--surface2)"><summary style="font-weight:600;cursor:pointer">Runtime: ' + escHtml(r.name) + '</summary>' +
|
|
667
667
|
_harnessRowsBlock('User asset dirs (--add-dir)', r.userAssetDirs) +
|
|
668
668
|
_harnessRowsBlock('Skill roots (CLI native discovery)', r.skillRoots) +
|
|
669
669
|
_harnessRowsBlock('Skill write targets', r.skillWriteTargets) +
|
|
@@ -674,7 +674,7 @@ function renderHarnessDiagnostics(diag) {
|
|
|
674
674
|
|
|
675
675
|
// --add-dir snapshot for fleet default
|
|
676
676
|
const snap = diag.addDirSnapshot || [];
|
|
677
|
-
parts.push('<details open style="margin:6px 0;border:1px solid var(--border);border-radius:6px;padding:8px 10px;background:var(--surface2)"><summary style="font-weight:600;cursor:pointer">--add-dir snapshot for ' + escHtml(diag.fleetDefaultCli || 'fleet default') + ' <span style="color:var(--muted);font-weight:400">(' + snap.length + ' dir' + (snap.length === 1 ? '' : 's') + ')</span></summary>' +
|
|
677
|
+
parts.push('<details data-hkey="addDirSnapshot" open style="margin:6px 0;border:1px solid var(--border);border-radius:6px;padding:8px 10px;background:var(--surface2)"><summary style="font-weight:600;cursor:pointer">--add-dir snapshot for ' + escHtml(diag.fleetDefaultCli || 'fleet default') + ' <span style="color:var(--muted);font-weight:400">(' + snap.length + ' dir' + (snap.length === 1 ? '' : 's') + ')</span></summary>' +
|
|
678
678
|
(snap.length === 0
|
|
679
679
|
? '<div style="font-size:var(--text-sm);color:var(--muted);margin:6px 0 0 12px"><em>(no dirs attached — engine/spawn-agent.js unavailable or adapter has no asset dirs)</em></div>'
|
|
680
680
|
: snap.map(d =>
|
|
@@ -686,7 +686,7 @@ function renderHarnessDiagnostics(diag) {
|
|
|
686
686
|
// Suppressed assets
|
|
687
687
|
const suppressed = diag.suppressed || [];
|
|
688
688
|
if (suppressed.length > 0) {
|
|
689
|
-
parts.push('<details open style="margin:6px 0;border:1px solid var(--border);border-radius:6px;padding:8px 10px;background:var(--surface2)"><summary style="font-weight:600;cursor:pointer">Suppressed assets <span style="color:var(--muted);font-weight:400">(' + suppressed.length + ')</span></summary>' +
|
|
689
|
+
parts.push('<details data-hkey="suppressed" open style="margin:6px 0;border:1px solid var(--border);border-radius:6px;padding:8px 10px;background:var(--surface2)"><summary style="font-weight:600;cursor:pointer">Suppressed assets <span style="color:var(--muted);font-weight:400">(' + suppressed.length + ')</span></summary>' +
|
|
690
690
|
suppressed.map(s =>
|
|
691
691
|
'<div style="margin:6px 0 0 12px"><code style="color:var(--orange)">' + escHtml(s.flag) + '</code> <span style="color:var(--muted);font-size:var(--text-sm)">(' + escHtml(String(s.value)) + ')</span>' +
|
|
692
692
|
'<div style="font-size:var(--text-sm);color:var(--muted);margin-left:18px">' + escHtml(s.effect || '') + '</div></div>'
|
|
@@ -697,7 +697,7 @@ function renderHarnessDiagnostics(diag) {
|
|
|
697
697
|
// Project-local-on-main footgun
|
|
698
698
|
const footgun = (diag.projectLocalOnMain || []).filter(e => e && e.uncommittedAssets && e.uncommittedAssets.length > 0);
|
|
699
699
|
if (footgun.length > 0) {
|
|
700
|
-
parts.push('<details open style="margin:6px 0;border:1px solid var(--orange);border-radius:6px;padding:8px 10px;background:var(--surface2)"><summary style="font-weight:600;cursor:pointer;color:var(--orange)">⚠ Project-local assets on main checkout (won\'t propagate to worktree)</summary>' +
|
|
700
|
+
parts.push('<details data-hkey="footgun" open style="margin:6px 0;border:1px solid var(--orange);border-radius:6px;padding:8px 10px;background:var(--surface2)"><summary style="font-weight:600;cursor:pointer;color:var(--orange)">⚠ Project-local assets on main checkout (won\'t propagate to worktree)</summary>' +
|
|
701
701
|
footgun.map(entry =>
|
|
702
702
|
'<div style="margin:8px 0 0 12px">' +
|
|
703
703
|
'<div style="font-weight:600">' + escHtml(entry.project) + ' <span style="font-family:monospace;color:var(--muted);font-weight:400">' + escHtml(entry.localPath) + '</span></div>' +
|
|
@@ -713,7 +713,7 @@ function renderHarnessDiagnostics(diag) {
|
|
|
713
713
|
// Missing dirs summary (aggregate)
|
|
714
714
|
const missing = diag.missingDirs || [];
|
|
715
715
|
if (missing.length > 0) {
|
|
716
|
-
parts.push('<details style="margin:6px 0;border:1px solid var(--border);border-radius:6px;padding:8px 10px;background:var(--surface2)"><summary style="font-weight:600;cursor:pointer">Missing on-disk dirs <span style="color:var(--muted);font-weight:400">(' + missing.length + ')</span></summary>' +
|
|
716
|
+
parts.push('<details data-hkey="missingDirs" style="margin:6px 0;border:1px solid var(--border);border-radius:6px;padding:8px 10px;background:var(--surface2)"><summary style="font-weight:600;cursor:pointer">Missing on-disk dirs <span style="color:var(--muted);font-weight:400">(' + missing.length + ')</span></summary>' +
|
|
717
717
|
'<div style="font-size:var(--text-sm);color:var(--muted);margin:4px 0 6px 12px">Adapters declare these paths but they do not exist on this host. Warnings, not failures — create them if the runtime should look there.</div>' +
|
|
718
718
|
missing.map(m =>
|
|
719
719
|
'<div style="font-family:monospace;font-size:var(--text-sm);margin-left:18px"><span style="color:var(--orange)">⚠</span> ' + escHtml(m.path) + ' <span style="color:var(--muted)">[' + escHtml(m.scope) + ' · ' + escHtml(m.kind) + ' · ' + escHtml(m.runtime) + ']</span></div>'
|
|
@@ -721,8 +721,22 @@ function renderHarnessDiagnostics(diag) {
|
|
|
721
721
|
'</details>');
|
|
722
722
|
}
|
|
723
723
|
|
|
724
|
+
// Native details open/closed is DOM state, not data — a full innerHTML
|
|
725
|
+
// rewrite would reset every section the operator expanded. Snapshot the
|
|
726
|
+
// current open/closed state keyed by the stable data-hkey before the rewrite,
|
|
727
|
+
// then re-apply it afterward so a genuine content change doesn't collapse the
|
|
728
|
+
// user's expanded sections. (Steady-state no-op polls are already short-
|
|
729
|
+
// circuited upstream in refresh.js via _changed('harnessDiag', …).)
|
|
730
|
+
const prevOpen = {};
|
|
731
|
+
for (const d of el.querySelectorAll('details[data-hkey]')) {
|
|
732
|
+
prevOpen[d.getAttribute('data-hkey')] = d.open;
|
|
733
|
+
}
|
|
724
734
|
// eslint-disable-next-line no-unsanitized/property -- reason: structural HTML is a string literal; all user data wrapped in escHtml() (fields: runtime name, path, scope, flag, effect, project name, localPath, file, kind)
|
|
725
735
|
el.innerHTML = parts.join('');
|
|
736
|
+
for (const d of el.querySelectorAll('details[data-hkey]')) {
|
|
737
|
+
const k = d.getAttribute('data-hkey');
|
|
738
|
+
if (Object.prototype.hasOwnProperty.call(prevOpen, k)) d.open = prevOpen[k];
|
|
739
|
+
}
|
|
726
740
|
}
|
|
727
741
|
|
|
728
742
|
window.MinionsOther = { renderProjects, optimisticallyAddProject, projectChipRemove, renderMcpServers, renderCommands, renderHarnessDiagnostics, renderMetrics, renderLlmPerf, renderTokenUsage, _aggregateEngineUsageForTokenTile, openScanProjectsModal };
|
|
@@ -7,12 +7,14 @@ function _plansPrev() { if (_plansPage > 0) { _plansPage--; refresh(); } }
|
|
|
7
7
|
function _plansNext() { _plansPage++; refresh(); }
|
|
8
8
|
|
|
9
9
|
function openCreatePlanModal() {
|
|
10
|
-
const projOpts = (typeof cmdProjects !== 'undefined' ? cmdProjects : []).map(p =>
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
10
|
+
const projOpts = (typeof cmdProjects !== 'undefined' ? cmdProjects : []).map(p => {
|
|
11
|
+
const name = typeof p === 'object' ? p.name : p;
|
|
12
|
+
const label = (typeof p === 'object' && (p.displayName || p.name)) || name;
|
|
13
|
+
return '<label style="display:flex;align-items:center;gap:6px;padding:4px 8px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);cursor:pointer;font-size:var(--text-md);color:var(--text)">' +
|
|
14
|
+
'<input type="checkbox" class="plan-new-project-cb" value="' + escapeHtml(name) + '" onchange="_updatePlanProjectHint()">' +
|
|
15
|
+
escapeHtml(label) +
|
|
16
|
+
'</label>';
|
|
17
|
+
}).join('');
|
|
16
18
|
const inputStyle = 'display:block;width:100%;margin-top:4px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:var(--text-md);font-family:inherit';
|
|
17
19
|
|
|
18
20
|
document.getElementById('modal-title').textContent = 'Create Plan';
|
|
@@ -579,9 +579,11 @@ function openCreateWorkItemModal() {
|
|
|
579
579
|
const agentOpts = (typeof cmdAgents !== 'undefined' ? cmdAgents : []).map(a =>
|
|
580
580
|
'<option value="' + escapeHtml(a.id) + '">' + escapeHtml(a.name) + '</option>'
|
|
581
581
|
).join('');
|
|
582
|
-
const projOpts = (typeof cmdProjects !== 'undefined' ? cmdProjects : []).map(p =>
|
|
583
|
-
|
|
584
|
-
|
|
582
|
+
const projOpts = (typeof cmdProjects !== 'undefined' ? cmdProjects : []).map(p => {
|
|
583
|
+
const name = typeof p === 'object' ? p.name : p;
|
|
584
|
+
const label = (typeof p === 'object' && (p.displayName || p.name)) || name;
|
|
585
|
+
return '<option value="' + escapeHtml(name) + '">' + escapeHtml(label) + '</option>';
|
|
586
|
+
}).join('');
|
|
585
587
|
const inputStyle = 'display:block;width:100%;margin-top:4px;padding:6px 8px;background:var(--bg);border:1px solid var(--border);border-radius:var(--radius-sm);color:var(--text);font-size:var(--text-md);font-family:inherit';
|
|
586
588
|
|
|
587
589
|
document.getElementById('modal-title').textContent = 'Create Work Item';
|
package/dashboard/js/utils.js
CHANGED
|
@@ -180,6 +180,29 @@ function pushModalFrame(frame) {
|
|
|
180
180
|
// The current visible (if any) is snapshotted and pushed onto _modalStack as
|
|
181
181
|
// a breadcrumb; scrollY is captured here so popping restores the user's
|
|
182
182
|
// place in the parent view.
|
|
183
|
+
//
|
|
184
|
+
// Direct-open promotion: a modal opened by a direct row-click opener
|
|
185
|
+
// (openWorkItemDetail, openModal, openAllWorkItems, viewAgentOutput, …) paints
|
|
186
|
+
// the body and adds .open WITHOUT routing through pushModalFrame, so it sets
|
|
187
|
+
// neither _currentVisibleArtifact nor a history entry of its own. If we only
|
|
188
|
+
// snapshotted it as an opaque breadcrumb (the old `else if` path) the parent
|
|
189
|
+
// never got a baseline depth-1 history entry, so a later Back diffed straight
|
|
190
|
+
// past it to depth 0 and tore the WHOLE modal down. Fix: FIRST promote the
|
|
191
|
+
// direct-opened parent to a tracked opaque '_direct' frame and push its own
|
|
192
|
+
// baseline depth-1 history entry, THEN fall through to the normal breadcrumb
|
|
193
|
+
// snapshot + depth-2 child push below. Net history: [page] -> [parent _direct
|
|
194
|
+
// depth 1] -> [child depth 2], so Back lands on the parent (depth 1) and only
|
|
195
|
+
// a second Back closes the modal.
|
|
196
|
+
if (!_currentVisibleArtifact && _modalIsOpen()) {
|
|
197
|
+
_currentVisibleArtifact = {
|
|
198
|
+
type: '_direct',
|
|
199
|
+
id: '_direct_' + (++_legacyFrameSeq),
|
|
200
|
+
openFn: null,
|
|
201
|
+
openArgs: null,
|
|
202
|
+
};
|
|
203
|
+
_updateModalChrome();
|
|
204
|
+
_pushModalHistoryState();
|
|
205
|
+
}
|
|
183
206
|
if (_currentVisibleArtifact) {
|
|
184
207
|
var snap = _captureModalSnapshot();
|
|
185
208
|
_modalStack.push({
|
|
@@ -191,19 +214,6 @@ function pushModalFrame(frame) {
|
|
|
191
214
|
openFn: _currentVisibleArtifact.openFn || null,
|
|
192
215
|
openArgs: _currentVisibleArtifact.openArgs || null,
|
|
193
216
|
});
|
|
194
|
-
} else if (_modalIsOpen()) {
|
|
195
|
-
// Modal was direct-opened (row click) without going through openArtifact.
|
|
196
|
-
// Snapshot it as an opaque _direct breadcrumb so Back still restores it.
|
|
197
|
-
var snap2 = _captureModalSnapshot();
|
|
198
|
-
_modalStack.push({
|
|
199
|
-
type: '_direct',
|
|
200
|
-
id: '_direct_' + (++_legacyFrameSeq),
|
|
201
|
-
titleHtml: snap2.titleHtml,
|
|
202
|
-
bodyHtml: snap2.bodyHtml,
|
|
203
|
-
scrollY: snap2.scrollY,
|
|
204
|
-
openFn: null,
|
|
205
|
-
openArgs: null,
|
|
206
|
-
});
|
|
207
217
|
}
|
|
208
218
|
_currentVisibleArtifact = {
|
|
209
219
|
type: frame.type,
|
package/dashboard.js
CHANGED
|
@@ -4036,7 +4036,7 @@ function buildCCStatePreamble() {
|
|
|
4036
4036
|
const pending = (dq.pending || []).length;
|
|
4037
4037
|
|
|
4038
4038
|
const prCount = getPullRequests().length;
|
|
4039
|
-
const wiCount = getWorkItems().length;
|
|
4039
|
+
const wiCount = getWorkItems(null, { enrich: false }).length;
|
|
4040
4040
|
|
|
4041
4041
|
const planFiles = [...safeReadDir(PLANS_DIR), ...safeReadDir(PRD_DIR)].filter(f => f.endsWith('.md') || f.endsWith('.json'));
|
|
4042
4042
|
|
|
@@ -4131,7 +4131,7 @@ function buildCCStateRefresh() {
|
|
|
4131
4131
|
const active = activeLines ? activeLines + overflow : '(none)';
|
|
4132
4132
|
const pending = (dq.pending || []).length;
|
|
4133
4133
|
const prCount = getPullRequests().length;
|
|
4134
|
-
const wiCount = getWorkItems().length;
|
|
4134
|
+
const wiCount = getWorkItems(null, { enrich: false }).length;
|
|
4135
4135
|
activitySection = `
|
|
4136
4136
|
|
|
4137
4137
|
**Active dispatch:**
|
|
@@ -127,15 +127,16 @@ evaluation pass) can see what tooling drove a dispatch:
|
|
|
127
127
|
rely on the work-item modal surface (#3) instead — they write no inbox note
|
|
128
128
|
at all (#308 removed the standalone harness-usage digest to cut note spam).
|
|
129
129
|
3. **Work-item detail modal** — the dashboard work-item modal shows the
|
|
130
|
-
|
|
131
|
-
`grounded: false` entries visually distinguished (P-d5a6f7c4). On
|
|
130
|
+
harness list alongside the completion artifacts (P-d5a6f7c4). On
|
|
132
131
|
completion, `engine/lifecycle.js promoteCompletionArtifacts` persists the
|
|
133
132
|
grounded `structuredCompletion.harnessUsed` onto the work item as
|
|
134
133
|
`_harnessUsed` (it survives the slim `/api/work-items` list payload and
|
|
135
134
|
modal hydration). `dashboard/js/render-work-items.js _wiRenderDetail` renders
|
|
136
|
-
the "Repo harnesses used" section from `item._harnessUsed`: one
|
|
137
|
-
skill / MCP / command / doc
|
|
138
|
-
|
|
135
|
+
the "Repo harnesses used" section from `item._harnessUsed`: one plain solid
|
|
136
|
+
pill per skill / MCP / command / doc. The grounding flag is **not** surfaced
|
|
137
|
+
in this modal — every entry renders identically (the per-pill dashed-border /
|
|
138
|
+
⚠ marker distinction was dropped in PR #296). Grounding is surfaced only on
|
|
139
|
+
the PR-comment surface above.
|
|
139
140
|
|
|
140
141
|
## Why it matters
|
|
141
142
|
|
|
@@ -18,9 +18,9 @@ The default `worktree` mode spawns each dispatch inside its own git worktree und
|
|
|
18
18
|
|
|
19
19
|
For these repos, the operator usually already has one canonical checkout that builds correctly. Live-checkout mode lets the agent dispatch into that checkout instead of cloning a side-by-side copy.
|
|
20
20
|
|
|
21
|
-
## Contract (
|
|
21
|
+
## Contract (five guarantees)
|
|
22
22
|
|
|
23
|
-
Live mode is opinionated about what the engine will and will not do to the operator's tree. The contract has
|
|
23
|
+
Live mode is opinionated about what the engine will and will not do to the operator's tree. The contract has five guarantees; the engine enforces all five and fails dispatches that would violate them.
|
|
24
24
|
|
|
25
25
|
### 1. Per-project mutating-concurrency cap of 1
|
|
26
26
|
|
|
@@ -30,15 +30,19 @@ Implementation: `engine.js` builds a `liveProjectsInUse` set from the active dis
|
|
|
30
30
|
|
|
31
31
|
### 2. Refuse-on-dirty (engine never resets the operator tree)
|
|
32
32
|
|
|
33
|
-
Before spawning, `engine/live-checkout.js#prepareLiveCheckout` runs `git status --porcelain` from `project.localPath`. Any output (staged, unstaged, untracked) refuses the dispatch immediately:
|
|
33
|
+
Before spawning, `engine/live-checkout.js#prepareLiveCheckout` runs `git status --porcelain` from `project.localPath`. Any output (staged, unstaged, untracked) **returns** an explicit `{ ok:false, reason:'dirty', dirtyFiles:[…] }` result (it does NOT throw), which refuses the dispatch immediately:
|
|
34
34
|
|
|
35
|
-
- Non-retryable `FAILURE_CLASS.LIVE_CHECKOUT_DIRTY` (added to `engine/dispatch.js`'s `neverRetry` set so the dispatcher never re-spawns mechanically).
|
|
35
|
+
- Non-retryable `FAILURE_CLASS.LIVE_CHECKOUT_DIRTY` (added to `engine/dispatch.js`'s `neverRetry` set so the dispatcher never re-spawns mechanically). **Reserved for this confirmed-dirty result only** — a thrown helper/git error is `LIVE_CHECKOUT_FAILED`, see below (#305).
|
|
36
36
|
- Inbox alert written via `dispatch.writeInboxAlert('live-checkout-dirty-<wi-id>', body)`. The body lists the dirty files verbatim from `git status --porcelain`.
|
|
37
37
|
- Work item stamped with `_pendingReason: 'live_checkout_dirty'` so the dashboard surfaces the block.
|
|
38
38
|
- Completion summary: `live-checkout refused: N dirty file(s) in <localPath>`.
|
|
39
39
|
|
|
40
40
|
The engine never calls `git reset --hard`, `git clean -fd`, `git stash`, or any other state-mutating command against the operator's checkout — not at spawn, not at cleanup, not on timeout, not on engine restart. Cleanup paths (`worktreePool.returnToPool`, `worktree-gc.gcDispatchWorktreeIfOrphan`, `_quarantineDirtyWorktree`) are naturally no-ops because `worktreePath` stays `null` end-to-end (`engine.js:1219`).
|
|
41
41
|
|
|
42
|
+
#### 2a. Thrown pre-spawn failures are retryable, NOT dirty (#305)
|
|
43
|
+
|
|
44
|
+
A thrown error from `prepareLiveCheckout` — a required-arg/ref-validation guard, or a transient `git status`/`rev-parse`/`checkout`/`symbolic-ref` failure — is **not** proof that the operator tree is dirty. `spawnAgent`'s `catch` block therefore completes the dispatch with the **separate** `FAILURE_CLASS.LIVE_CHECKOUT_FAILED` (`'live-checkout-failed'`), which is deliberately **excluded** from `engine/dispatch.js`'s `neverRetry` set. The dispatcher auto-retries with bounded backoff up to `ENGINE_DEFAULTS.maxRetries`, so racy branch-lock handoff, a just-finished sibling dispatch, or transient git state recovers on the next attempt **without a manual `/api/work-items/retry`**. Genuinely terminal underlying reasons (auth, validation) still short-circuit via the reason-string check in `isRetryableFailureReason`. No dirty-files inbox alert is written and no `_pendingReason: 'live_checkout_dirty'` stamp is applied for this path — those belong to the confirmed-dirty result above.
|
|
45
|
+
|
|
42
46
|
### 3. No auto-pull, no `--force`, no fast-forward
|
|
43
47
|
|
|
44
48
|
After the clean-tree check, `prepareLiveCheckout`:
|
|
@@ -57,6 +61,26 @@ Live mode shares one checkout per project. There is no pool to recycle, no quara
|
|
|
57
61
|
|
|
58
62
|
Pool short-circuits live in `engine.js:1368` and `engine/cleanup.js`; both gate on `worktreePath` truthiness and `!liveMode`, so the borrow / return / orphan-GC paths execute zero git commands when the project is live.
|
|
59
63
|
|
|
64
|
+
### 5. Refuse on mid-operation / detached HEAD (engine never aborts the operator's in-progress op)
|
|
65
|
+
|
|
66
|
+
`prepareLiveCheckout` runs a second preflight *between* the dirty-tree check (Guarantee 2) and branch resolution (Guarantee 3). Even on a **clean** tree, the branch switch/create is refused when the operator checkout is mid-operation or sitting on a detached HEAD:
|
|
67
|
+
|
|
68
|
+
- **In-progress git operation.** The git dir is resolved robustly via `git rev-parse --git-dir` (so submodule / gitdir-file / `repo`-managed trees — the setups that motivate live mode — are covered), then sentinel paths under it are probed: `MERGE_HEAD` → merge, `rebase-merge/` & `rebase-apply/` → rebase, `CHERRY_PICK_HEAD` → cherry-pick, `REVERT_HEAD` → revert. First hit returns `{ ok:false, reason:'mid-operation', op, details }`.
|
|
69
|
+
- **Detached HEAD.** `git symbolic-ref -q HEAD` exiting non-zero returns `{ ok:false, reason:'detached-head', sha }` (sha from `git rev-parse HEAD`). Branching off a detached HEAD would strand the operator's anonymous commits.
|
|
70
|
+
|
|
71
|
+
Either condition fails the dispatch non-retryably with `FAILURE_CLASS.LIVE_CHECKOUT_MID_OPERATION` (`'live-checkout-mid-operation'`; added to `engine/dispatch.js`'s neverRetry set alongside `LIVE_CHECKOUT_DIRTY`). `spawnAgent` writes a `live-checkout-blocked-<wi-id>` inbox alert and stamps the work item `_pendingReason: 'live_checkout_mid_operation'` (or `'live_checkout_detached_head'` for the detached case). The recovery guidance tells the operator to finish or abort the in-progress op with their own commands (`git <op> --continue` / `git <op> --abort`), or checkout a branch, then re-dispatch. The engine never runs `git reset`, `git clean`, `git stash`, `git rebase --abort`, or moves HEAD on the operator's behalf.
|
|
72
|
+
|
|
73
|
+
## Dispatch-end auto-restore
|
|
74
|
+
|
|
75
|
+
Live-mode agents run **in-place** in the operator's checkout, so when a dispatch ends the engine switches the tree back to the ref it was on before the agent ran. This runs on **every** terminal result — success, failure, timeout, crash — and also on the engine-restart re-attach completion path (`engine/cli.js`), so a restart mid-dispatch does not strand the checkout on the agent's branch.
|
|
76
|
+
|
|
77
|
+
- **Original-ref capture.** `prepareLiveCheckout` records the operator's starting ref *before* the first checkout: `git symbolic-ref --short HEAD` → `{ originalRef:<branch>, originalRefType:'branch' }`, falling back to `git rev-parse HEAD` → `{ originalRef:<sha>, originalRefType:'detached' }`. `spawnAgent` persists `originalRef` / `originalRefType` onto the dispatch record via `mutateDispatch`, so the restore survives an engine restart, where the in-memory spawn closure is gone and only the persisted record remains.
|
|
78
|
+
- **AUTO-RESTORE (best-effort, never `--force`).** At dispatch-end `restoreLiveCheckoutAtDispatchEnd` issues a **plain** `git checkout <originalRef>` — no `--force`, no `-B`, no reset, no clean, no stash. It no-ops when there is nothing to restore: no captured `originalRef`, the agent branch *is* the original ref, or HEAD already sits on the original ref (matched against the branch name *or* the raw sha so the detached-HEAD case is recognized). It is strictly best-effort: every error is swallowed and logged, and a restore never alters the dispatch result.
|
|
79
|
+
- **Fallback notify (only when a safe switch is impossible).** If git declines the plain checkout — most likely because the agent left uncommitted changes a checkout would overwrite — the refusal is **honored**: the tree is left exactly as the agent left it and a deduped `live-checkout-branch-<dispatchId>` inbox alert tells the operator how to switch back manually (`git -C <localPath> checkout <originalRef>`). The engine never forces the switch.
|
|
80
|
+
- **Terminal-failure alert.** When the dispatch ends in a non-success terminal state, a deduped `live-checkout-failed-<dispatchId>` inbox alert is written so the operator knows a live-mode run failed inside their own checkout (where any partial work is visible). This is independent of the restore and fires even when the restore itself succeeds.
|
|
81
|
+
|
|
82
|
+
The core invariant holds end-to-end through restore: **the engine only ever switches branches — it never `git reset`s, `git clean`s, or `git stash`es the operator's tree, and never passes `--force`.**
|
|
83
|
+
|
|
60
84
|
## Operator workflow
|
|
61
85
|
|
|
62
86
|
### Enabling live mode (dashboard)
|
|
@@ -127,7 +151,7 @@ Live-checkout mode is deliberately small. These are NOT supported and will not b
|
|
|
127
151
|
- **No per-WI subdirectory isolation.** Live mode is one-checkout-per-project by design. If you need isolation, use `checkoutMode: 'worktree'` (the default).
|
|
128
152
|
- **No per-WI override.** `checkoutMode` is per-project only. There is no `meta.checkoutMode` on a work item that overrides the project setting.
|
|
129
153
|
- **No auto-pull / no fast-forward on existing branches.** See Guarantee 3. If a PR branch is checked out locally at a different SHA than `origin/<branch>`, the operator resolves it manually.
|
|
130
|
-
- **No special timeout / kill handling.** Live-mode dispatches are killed by PID exactly like isolated-mode dispatches (`engine/timeout.js` header comment). The engine sends SIGTERM/SIGKILL to the tracked process and never touches the working tree
|
|
154
|
+
- **No special timeout / kill handling.** Live-mode dispatches are killed by PID exactly like isolated-mode dispatches (`engine/timeout.js` header comment). The engine sends SIGTERM/SIGKILL to the tracked process and never touches the working tree to deliver the kill. (Dispatch-end auto-restore still runs afterward — a plain branch switch back to the operator's original ref, never a reset/clean/stash; see [Dispatch-end auto-restore](#dispatch-end-auto-restore).)
|
|
131
155
|
|
|
132
156
|
## Related code
|
|
133
157
|
|
|
@@ -135,12 +159,18 @@ Live-checkout mode is deliberately small. These are NOT supported and will not b
|
|
|
135
159
|
|---|---|
|
|
136
160
|
| `engine/shared.js` — `CHECKOUT_MODES`, `validateCheckoutMode`, `resolveCheckoutMode`, `isLiveCheckoutProject` | Enum + validator + back-compat resolver (P-a3f9b201; consolidated W-mqiaw974). |
|
|
137
161
|
| `engine/shared.js` — `resolveSpawnPaths` | Returns `{ cwd: localPath, worktreeRootDir: null, liveMode: true }` for live projects (P-a3f9b202). |
|
|
138
|
-
| `engine/live-checkout.js` — `prepareLiveCheckout` | Pure helper: dirty check, branch resolution from HEAD (no fetch, no `origin/<mainRef>` — issue #226) (P-a3f9b203). |
|
|
162
|
+
| `engine/live-checkout.js` — `prepareLiveCheckout` | Pure helper: dirty check, mid-operation / detached-HEAD preflight, original-ref capture, branch resolution from HEAD (no fetch, no `origin/<mainRef>` — issue #226) (P-a3f9b203; preflight + capture P-b2e8d4a6). |
|
|
163
|
+
| `engine/live-checkout.js` — `restoreLiveCheckoutAtDispatchEnd` | Dispatch-end auto-restore (plain `git checkout <originalRef>`, never `--force`/reset/clean/stash, best-effort) + `live-checkout-failed-<dispatchId>` terminal-failure alert + `live-checkout-branch-<dispatchId>` fallback notify (P-d9e6b2c4). |
|
|
139
164
|
| `engine.js` — `spawnAgent` live-mode block | Calls `prepareLiveCheckout`, handles dirty / throw branches, gates `git worktree add` on `!liveMode` (P-a3f9b204). |
|
|
165
|
+
| `engine.js` — `spawnAgent` mid-op / detached-HEAD refusal block | Emits `LIVE_CHECKOUT_MID_OPERATION`, writes `live-checkout-blocked-<wi-id>` alert, stamps `_pendingReason: 'live_checkout_mid_operation'` / `'live_checkout_detached_head'` (P-c5a1f3b8). |
|
|
166
|
+
| `engine.js` — `spawnAgent` originalRef persistence | Persists `originalRef` / `originalRefType` onto the dispatch record via `mutateDispatch` so restore survives an engine restart (P-c5a1f3b8). |
|
|
167
|
+
| `engine.js` — `onAgentClose` live-mode restore wiring | Calls `restoreLiveCheckoutAtDispatchEnd` on every terminal result (P-d9e6b2c4). |
|
|
168
|
+
| `engine/cli.js` — orphan / reattach restore | Fires the same dispatch-end restore from the persisted record on the engine-restart completion path (P-d9e6b2c4). |
|
|
140
169
|
| `engine.js` — dispatcher `liveProjectsInUse` set | Per-project mutating-concurrency cap (P-a3f9b205). |
|
|
141
170
|
| `engine.js` — worktree-pool / orphan-GC short-circuits | `worktreePath===null` no-ops in live mode (P-a3f9b206). |
|
|
142
171
|
| `dashboard/js/settings.js` — checkoutMode dropdown + chip | Operator-facing UI (P-a3f9b207). |
|
|
143
172
|
| `test/unit/{resolve-spawn-paths-live-mode,prepare-live-checkout,spawn-agent-live-mode-wiring}.test.js` | Wiring and contract tests (P-a3f9b208). |
|
|
144
173
|
| `engine/shared.js` — `FAILURE_CLASS.LIVE_CHECKOUT_DIRTY` | Non-retryable refusal class. |
|
|
145
|
-
| `engine/
|
|
174
|
+
| `engine/shared.js` — `FAILURE_CLASS.LIVE_CHECKOUT_MID_OPERATION` | Non-retryable refusal class for a mid-operation / detached-HEAD operator tree (in-progress merge/rebase/cherry-pick/revert or detached HEAD), distinct from the dirty-tree class. Emitted by `spawnAgent`'s mid-op / detached-HEAD refusal block (P-a7f3c1d9; wired P-c5a1f3b8). |
|
|
175
|
+
| `engine/dispatch.js` — `isRetryableFailureReason` neverRetry | Excludes `LIVE_CHECKOUT_DIRTY` and `LIVE_CHECKOUT_MID_OPERATION` from mechanical retry. |
|
|
146
176
|
| `engine/timeout.js` header comment | Confirms no special live-mode kill handling. |
|
package/engine/cli.js
CHANGED
|
@@ -895,6 +895,32 @@ const commands = {
|
|
|
895
895
|
);
|
|
896
896
|
} catch (err) { e.log('warn', `Orphan dispatch complete: ${err.message}`); }
|
|
897
897
|
|
|
898
|
+
// P-d9e6b2c4 — live-mode dispatch-end auto-restore + terminal-failure
|
|
899
|
+
// notify on the engine-restart reattach completion path. originalRef
|
|
900
|
+
// is only persisted for live-mode dispatches (P-c5a1f3b8), so its
|
|
901
|
+
// presence is the liveMode signal here — the slimmed meta.project
|
|
902
|
+
// carries no checkoutMode. Mirrors the in-process onAgentClose wiring
|
|
903
|
+
// (engine.js). Fire-and-forget + best-effort: the helper swallows its
|
|
904
|
+
// own errors and only ever issues a plain `git checkout <originalRef>`
|
|
905
|
+
// (never --force/reset/clean/stash) against the operator tree.
|
|
906
|
+
if (item.originalRef && item.meta?.branch && item.meta?.project?.localPath) {
|
|
907
|
+
try {
|
|
908
|
+
require('./live-checkout').restoreLiveCheckoutAtDispatchEnd({
|
|
909
|
+
localPath: item.meta.project.localPath,
|
|
910
|
+
branchName: item.meta.branch,
|
|
911
|
+
originalRef: item.originalRef,
|
|
912
|
+
originalRefType: item.originalRefType || 'branch',
|
|
913
|
+
dispatchId: item.id,
|
|
914
|
+
projectName: item.meta.project.name,
|
|
915
|
+
isTerminalFailure: !isSuccess,
|
|
916
|
+
resultLabel: result,
|
|
917
|
+
gitOpts: { env: shared.gitEnv(), windowsHide: true, timeout: 30000 },
|
|
918
|
+
log: (lvl, msg) => e.log(lvl, msg),
|
|
919
|
+
writeInboxAlert: dispatchModule().writeInboxAlert,
|
|
920
|
+
}).catch(() => {});
|
|
921
|
+
} catch (err) { e.log('warn', `Orphan live-checkout restore: ${err.message}`); }
|
|
922
|
+
}
|
|
923
|
+
|
|
898
924
|
// Check plan completion
|
|
899
925
|
if (isSuccess && item.meta?.item?.sourcePlan) {
|
|
900
926
|
try { lifecycle.checkPlanCompletion(item.meta, config); } catch (err) { e.log('warn', `Orphan plan completion: ${err.message}`); }
|
|
@@ -64,7 +64,7 @@ const _BADGE_RE = /!\[.*\]\(https?:\/\/.*badge/i;
|
|
|
64
64
|
const _PREVIEW_HEADING_RE =
|
|
65
65
|
/^#{1,3}\s*(Firebase(?:\s+App\s+Distribution)?|Appetize|Preview|Deploy(?:ment)?|Status)\b/i;
|
|
66
66
|
|
|
67
|
-
const _FIREBASE_BRAND_RE = /\bFirebase\s+App\s+Distribution\b/i;
|
|
67
|
+
const _FIREBASE_BRAND_RE = /\bFirebase\s+(?:App\s+Distribution|Hosting)\b/i;
|
|
68
68
|
const _FIREBASE_URL_RE = /\bappdistribution\.firebase\b/i;
|
|
69
69
|
const _APPETIZE_URL_RE = /\bappetize\.io\b/i;
|
|
70
70
|
|