@yemi33/minions 0.1.2304 → 0.1.2306
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/refresh.js +17 -13
- package/dashboard/js/render-other.js +251 -3
- package/dashboard/js/render-plans.js +1 -1
- package/dashboard/js/render-prs.js +4 -1
- package/dashboard/js/render-utils.js +13 -0
- package/dashboard/js/render-work-items.js +13 -14
- package/dashboard/js/settings.js +3 -3
- package/dashboard/layout.html +13 -0
- package/dashboard/slim/js/modals-tiles.js +21 -130
- package/dashboard/slim/styles.css +9 -14
- package/dashboard/styles.css +79 -0
- package/dashboard.js +41 -9
- package/docs/completion-reports.md +25 -0
- package/docs/copilot-cli-schema.md +31 -1
- package/docs/design-state-storage.md +1 -1
- package/docs/live-checkout-mode.md +5 -3
- package/engine/ado.js +37 -0
- package/engine/consolidation.js +59 -2
- package/engine/lifecycle.js +149 -0
- package/engine/live-checkout.js +290 -79
- package/engine/playbook.js +2 -2
- package/engine/queries.js +11 -7
- package/engine/shared.js +6 -5
- package/engine/supervisor.js +131 -19
- package/engine.js +2 -2
- package/package.json +1 -1
package/engine/live-checkout.js
CHANGED
|
@@ -132,6 +132,121 @@ function _isPartialCloneBlobError(message = '') {
|
|
|
132
132
|
);
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
// W-mqva907q — auto-clean safe untracked build artifacts before a live-checkout
|
|
136
|
+
// dirty bail. A `git status --porcelain` line for a stray `__pycache__/` (or
|
|
137
|
+
// other regenerable cache) should NOT block dispatch the way a real edit does.
|
|
138
|
+
// These patterns describe untracked-only paths that are always safe to delete
|
|
139
|
+
// because the build/test toolchain regenerates them. Matching is intentionally
|
|
140
|
+
// implemented with plain string checks (no glob dep — Minions ships zero runtime
|
|
141
|
+
// deps). dist/ and build/ are deliberately EXCLUDED: they can hold real,
|
|
142
|
+
// non-regenerable output, so auto-deleting them would be unsafe.
|
|
143
|
+
const LIVE_CHECKOUT_AUTO_CLEAN_PATTERNS = Object.freeze([
|
|
144
|
+
'**/__pycache__/',
|
|
145
|
+
'**/*.pyc',
|
|
146
|
+
'**/*.pyo',
|
|
147
|
+
'**/.pytest_cache/',
|
|
148
|
+
'**/node_modules/.cache/',
|
|
149
|
+
'**/*.egg-info/',
|
|
150
|
+
'.tox/',
|
|
151
|
+
]);
|
|
152
|
+
|
|
153
|
+
// Returns true when a single untracked path (relative, git-porcelain form) is a
|
|
154
|
+
// known regenerable build artifact safe to delete. Normalizes separators and a
|
|
155
|
+
// trailing slash, and conservatively unquotes git's C-style quoting for paths
|
|
156
|
+
// with special chars. Directory artifacts match on their final path segment so
|
|
157
|
+
// nested copies (e.g. a/b/__pycache__/) are covered like the `**/` globs above.
|
|
158
|
+
function _isAutoCleanableArtifact(rawPath) {
|
|
159
|
+
if (!rawPath || typeof rawPath !== 'string') return false;
|
|
160
|
+
let p = rawPath.trim();
|
|
161
|
+
if (p.length >= 2 && p.startsWith('"') && p.endsWith('"')) {
|
|
162
|
+
try { p = JSON.parse(p); } catch { /* keep raw on parse failure */ }
|
|
163
|
+
}
|
|
164
|
+
p = p.replace(/\\/g, '/').replace(/\/+$/, '');
|
|
165
|
+
if (!p) return false;
|
|
166
|
+
if (p.endsWith('.pyc') || p.endsWith('.pyo')) return true;
|
|
167
|
+
if (p.endsWith('.egg-info')) return true;
|
|
168
|
+
if (p === 'node_modules/.cache' || p.endsWith('/node_modules/.cache')) return true;
|
|
169
|
+
const seg = p.split('/').pop();
|
|
170
|
+
if (seg === '__pycache__' || seg === '.pytest_cache' || seg === '.tox') return true;
|
|
171
|
+
return false;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Classifies the porcelain dirty lines for auto-clean. Returns the list of
|
|
175
|
+
// untracked artifact paths ONLY when EVERY dirty entry is an untracked (`?? `)
|
|
176
|
+
// line matching a safe artifact pattern; returns null otherwise (a tracked
|
|
177
|
+
// modification, a staged/deleted change, or any untracked path that is NOT a
|
|
178
|
+
// known artifact). Null means "do not auto-clean — fail dirty as before", which
|
|
179
|
+
// enforces the all-or-nothing contract: partial dirt is never silently cleaned.
|
|
180
|
+
function _collectAutoCleanablePaths(dirtyFiles) {
|
|
181
|
+
const cleanable = [];
|
|
182
|
+
for (const line of dirtyFiles) {
|
|
183
|
+
if (!line.startsWith('?? ')) return null;
|
|
184
|
+
const p = line.slice(3);
|
|
185
|
+
if (!_isAutoCleanableArtifact(p)) return null;
|
|
186
|
+
cleanable.push(p);
|
|
187
|
+
}
|
|
188
|
+
return cleanable.length > 0 ? cleanable : null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// issue #608 — detects a branch name that the ENGINE created for a live-mode
|
|
192
|
+
// dispatch (never the operator's own branch). Matches ONLY the literal
|
|
193
|
+
// `work/<wi-id>` convention produced by `shared.deriveWorkItemBranchName` —
|
|
194
|
+
// deliberately narrow so an operator's own `user/...`/`feature/...` branch is
|
|
195
|
+
// never mistaken for agent litter. Used by the orphaned-agent-branch self-heal
|
|
196
|
+
// below (prepareLiveCheckout) to recognize a stray branch left behind by an
|
|
197
|
+
// earlier dispatch whose dispatch-end restore did not complete.
|
|
198
|
+
function _looksLikeAgentBranch(name) {
|
|
199
|
+
return typeof name === 'string' && /^work\//.test(name.trim());
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// issue #608 — pull the current local branch name out of the `## <branch>...`
|
|
203
|
+
// header line that `git status --porcelain=v1 -b` already returns, so the
|
|
204
|
+
// orphaned-agent-branch self-heal below needs ZERO extra git calls on the
|
|
205
|
+
// common (non-stray) dirty path — preserving the existing "no extra git calls
|
|
206
|
+
// when the tree isn't all-artifact-cleanable" contract asserted by
|
|
207
|
+
// test/unit/prepare-live-checkout.test.js. Returns '' for a detached HEAD
|
|
208
|
+
// (`## HEAD (no branch)`) or an unparsable header.
|
|
209
|
+
function _extractBranchFromPorcelainHeader(branchInfo) {
|
|
210
|
+
if (!branchInfo || typeof branchInfo !== 'string') return '';
|
|
211
|
+
const body = branchInfo.replace(/^##\s*/, '');
|
|
212
|
+
const firstToken = body.split(' ')[0] || '';
|
|
213
|
+
const localBranch = firstToken.split('...')[0];
|
|
214
|
+
return (!localBranch || localBranch === 'HEAD') ? '' : localBranch;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// issue #608 — commit whatever is staged/unstaged onto the CURRENT branch
|
|
218
|
+
// with bounded retry+backoff, specifically to survive a transient
|
|
219
|
+
// `.git/index.lock` collision against a sibling git process (the root cause
|
|
220
|
+
// of the 2026-06-30 incident: two live-checkout dispatches raced `git add -A`
|
|
221
|
+
// / `git commit` against the same working tree, the commit lost the lock race
|
|
222
|
+
// once, and the prior code gave up on the first failure with no retry). Only
|
|
223
|
+
// RETRIES when the failure looks like transient lock contention; any other
|
|
224
|
+
// failure (e.g. "nothing to commit") returns immediately so callers fall
|
|
225
|
+
// through to their existing best-effort handling. Never throws — callers get
|
|
226
|
+
// `{ ok:false, error }` on exhaustion.
|
|
227
|
+
async function _commitAgentWipWithRetry(git, baseOpts, message, options = {}) {
|
|
228
|
+
const attempts = Number.isFinite(options.attempts) ? options.attempts : 3;
|
|
229
|
+
const baseDelayMs = Number.isFinite(options.baseDelayMs) ? options.baseDelayMs : 300;
|
|
230
|
+
const sleep = typeof options.sleep === 'function'
|
|
231
|
+
? options.sleep
|
|
232
|
+
: (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
233
|
+
let lastErr = null;
|
|
234
|
+
for (let attempt = 1; attempt <= attempts; attempt++) {
|
|
235
|
+
try {
|
|
236
|
+
await git(['add', '-A'], baseOpts);
|
|
237
|
+
await git(['commit', '--no-verify', '-m', message], baseOpts);
|
|
238
|
+
return { ok: true, attempts: attempt };
|
|
239
|
+
} catch (e) {
|
|
240
|
+
lastErr = e;
|
|
241
|
+
const msg = String((e && e.message) || '');
|
|
242
|
+
const transientLock = /index\.lock/i.test(msg) || /another git process/i.test(msg);
|
|
243
|
+
if (!transientLock || attempt === attempts) break;
|
|
244
|
+
await sleep(baseDelayMs * attempt);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return { ok: false, error: lastErr, attempts };
|
|
248
|
+
}
|
|
249
|
+
|
|
135
250
|
// W-mqvejug6000eeb20 — production default for resolving the live-checkout
|
|
136
251
|
// auto-reset decision when the caller (engine.js spawnAgent) does not pass an
|
|
137
252
|
// explicit `autoReset` boolean. engine.js is intentionally not threading the
|
|
@@ -178,8 +293,10 @@ async function prepareLiveCheckout(opts = {}) {
|
|
|
178
293
|
// defers to `_resolveAutoReset`.
|
|
179
294
|
_git, // private injection for testing — defaults to shared.shellSafeGit
|
|
180
295
|
_exists, // private injection for testing — defaults to fs.existsSync
|
|
296
|
+
_rm, // private injection for testing — defaults to fs.rmSync (recursive, force)
|
|
181
297
|
_resolveAutoReset, // private injection for testing — defaults to config-based resolver
|
|
182
298
|
_writeInboxNote, // private injection for testing — defaults to dispatch.writeInboxAlert
|
|
299
|
+
_sleep, // private injection for testing — defaults to a real setTimeout-based sleep
|
|
183
300
|
} = opts;
|
|
184
301
|
|
|
185
302
|
// ── Required-arg guards. Throw rather than return {ok:false} so a
|
|
@@ -206,6 +323,8 @@ async function prepareLiveCheckout(opts = {}) {
|
|
|
206
323
|
|
|
207
324
|
const git = (typeof _git === 'function') ? _git : shared.shellSafeGit;
|
|
208
325
|
const exists = (typeof _exists === 'function') ? _exists : fs.existsSync;
|
|
326
|
+
const rm = (typeof _rm === 'function') ? _rm : ((absPath) => fs.rmSync(absPath, { recursive: true, force: true }));
|
|
327
|
+
const logFn = (typeof log === 'function') ? log : () => {};
|
|
209
328
|
// W-mqvejug6000eeb20 — default maxBuffer (50 MB) so a large `git status
|
|
210
329
|
// --porcelain` never overflows execFile's 1 MB ceiling. A caller-supplied
|
|
211
330
|
// gitOpts.maxBuffer still wins (spread after the default).
|
|
@@ -230,91 +349,160 @@ async function prepareLiveCheckout(opts = {}) {
|
|
|
230
349
|
// before the dirtyFiles check so callers receive it as `branchInfo`.
|
|
231
350
|
if (!skipDirtyCheck) {
|
|
232
351
|
const statusRaw = await git(['status', '--porcelain=v1', '-b'], baseOpts);
|
|
233
|
-
const
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
352
|
+
const parsePorcelain = (raw) => {
|
|
353
|
+
const lines = (typeof raw === 'string' ? raw : '')
|
|
354
|
+
.split(/\r?\n/)
|
|
355
|
+
.map((line) => line.replace(/\s+$/, ''))
|
|
356
|
+
.filter((line) => line.length > 0);
|
|
357
|
+
return {
|
|
358
|
+
branchInfo: lines.find((line) => line.startsWith('## ')) || '',
|
|
359
|
+
dirtyFiles: lines.filter((line) => !line.startsWith('## ')),
|
|
360
|
+
};
|
|
361
|
+
};
|
|
362
|
+
let { branchInfo, dirtyFiles } = parsePorcelain(statusRaw);
|
|
240
363
|
if (dirtyFiles.length > 0) {
|
|
241
|
-
// W-
|
|
242
|
-
//
|
|
243
|
-
// and
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
364
|
+
// W-mqva907q — auto-clean safe untracked build artifacts BEFORE bailing. This
|
|
365
|
+
// runs unconditionally (no config flag — deleting regenerable caches is always
|
|
366
|
+
// safe) and BEFORE the spawnAgent auto-stash path. Only fires when EVERY dirty
|
|
367
|
+
// entry is an untracked artifact (all-or-nothing); a single tracked edit or
|
|
368
|
+
// non-artifact untracked file means zero cleaning and the original dirty bail.
|
|
369
|
+
const cleanablePaths = _collectAutoCleanablePaths(dirtyFiles);
|
|
370
|
+
if (cleanablePaths) {
|
|
371
|
+
for (const rel of cleanablePaths) {
|
|
372
|
+
const abs = path.isAbsolute(rel) ? rel : path.join(localPath, rel);
|
|
373
|
+
rm(abs);
|
|
374
|
+
}
|
|
375
|
+
logFn(`[live-checkout] auto-cleaned ${cleanablePaths.length} artifact path(s) before dirty check: ${cleanablePaths.join(', ')}`, 'debug');
|
|
376
|
+
// Re-run the dirty check. If cleaning fully resolved the tree we fall
|
|
377
|
+
// through and proceed; if anything remains (e.g. a race re-created a file)
|
|
378
|
+
// we still bail dirty with the post-clean snapshot.
|
|
379
|
+
const reRaw = await git(['status', '--porcelain=v1', '-b'], baseOpts);
|
|
380
|
+
({ branchInfo, dirtyFiles } = parsePorcelain(reRaw));
|
|
254
381
|
}
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
382
|
+
// issue #608 — orphaned-agent-branch self-heal. If the tree is STILL dirty
|
|
383
|
+
// and HEAD is sitting on a DIFFERENT engine-created branch than the one
|
|
384
|
+
// THIS dispatch is about to use, the dirt is provably leftover from an
|
|
385
|
+
// earlier dispatch whose dispatch-end restore never completed (e.g. an
|
|
386
|
+
// `.git/index.lock` race against a sibling dispatch — see
|
|
387
|
+
// restoreLiveCheckoutAtDispatchEnd). Left alone, the ORIGINAL-REF CAPTURE
|
|
388
|
+
// below (Step 3) would adopt that stray branch as `originalRef`, and every
|
|
389
|
+
// future dispatch would inherit it in turn — the tree never stops
|
|
390
|
+
// orbiting agent branches and every dispatch fails LIVE_CHECKOUT_DIRTY
|
|
391
|
+
// forever (321 incidents / 12 days). Since the branch was created BY the
|
|
392
|
+
// engine (matched narrowly via `_looksLikeAgentBranch` — the literal
|
|
393
|
+
// `work/<id>` convention from shared.deriveWorkItemBranchName, never the
|
|
394
|
+
// operator's own branch), committing the leftover WIP there is exactly as
|
|
395
|
+
// safe as the existing dispatch-end self-heal: additive, fully
|
|
396
|
+
// recoverable from that branch's own history, and never touches the
|
|
397
|
+
// operator's real branch. Unconditional (not opt-in) because it is
|
|
398
|
+
// non-destructive, unlike the auto-reset path below. After committing,
|
|
399
|
+
// switch back to `mainRef` — the one ref that is always a real,
|
|
400
|
+
// non-agent baseline — so the tree stops drifting onto agent branches.
|
|
401
|
+
if (dirtyFiles.length > 0) {
|
|
402
|
+
const strayBranch = _extractBranchFromPorcelainHeader(branchInfo);
|
|
403
|
+
if (strayBranch && strayBranch !== branchName && _looksLikeAgentBranch(strayBranch)) {
|
|
404
|
+
const commitRes = await _commitAgentWipWithRetry(
|
|
405
|
+
git, baseOpts,
|
|
406
|
+
`minions: auto-save orphaned WIP from ${strayBranch} (dispatch ${dispatchId || '(unknown)'})`,
|
|
407
|
+
{ sleep: _sleep },
|
|
408
|
+
);
|
|
409
|
+
if (commitRes.ok) {
|
|
410
|
+
logFn(`[live-checkout] self-healed orphaned agent branch '${strayBranch}' — committed WIP` +
|
|
411
|
+
(commitRes.attempts > 1 ? ` after ${commitRes.attempts} attempts` : '') +
|
|
412
|
+
`, switching back to '${mainRef}'`, 'info');
|
|
413
|
+
try {
|
|
414
|
+
await git(['checkout', mainRef], baseOpts);
|
|
415
|
+
} catch (backErr) {
|
|
416
|
+
logFn(`[live-checkout] could not switch back to '${mainRef}' after self-healing '${strayBranch}': ${backErr && backErr.message}`, 'warn');
|
|
417
|
+
}
|
|
418
|
+
// Re-run the dirty check. Either the tree is now clean (self-heal
|
|
419
|
+
// succeeded end-to-end) or something new appeared (rare) — either
|
|
420
|
+
// way the normal auto-reset / dirty-bail logic below sees fresh state.
|
|
421
|
+
const healRaw = await git(['status', '--porcelain=v1', '-b'], baseOpts);
|
|
422
|
+
({ branchInfo, dirtyFiles } = parsePorcelain(healRaw));
|
|
423
|
+
} else {
|
|
424
|
+
logFn(`[live-checkout] could not self-heal orphaned agent branch '${strayBranch}' (commit failed): ${commitRes.error && commitRes.error.message}`, 'warn');
|
|
425
|
+
}
|
|
426
|
+
}
|
|
258
427
|
}
|
|
428
|
+
if (dirtyFiles.length > 0) {
|
|
429
|
+
// W-mqvejug6000eeb20 — opt-in auto-reset. Default behavior is to bail with
|
|
430
|
+
// reason:'dirty' (spawnAgent translates to non-retryable LIVE_CHECKOUT_DIRTY
|
|
431
|
+
// and alerts the operator). When auto-reset is enabled — either the caller
|
|
432
|
+
// passed an explicit `autoReset` boolean, or the config-based resolver says
|
|
433
|
+
// so — we DISCARD the dirty state via `git fetch origin` + `git reset --hard
|
|
434
|
+
// origin/<branch>` and continue. This is destructive (the operator’s
|
|
435
|
+
// uncommitted work is gone), which is why it is strictly opt-in.
|
|
436
|
+
let wantAutoReset = false;
|
|
437
|
+
if (typeof autoReset === 'boolean') {
|
|
438
|
+
wantAutoReset = autoReset;
|
|
439
|
+
} else {
|
|
440
|
+
const resolver = (typeof _resolveAutoReset === 'function') ? _resolveAutoReset : _defaultResolveAutoReset;
|
|
441
|
+
try { wantAutoReset = !!resolver(localPath); } catch { wantAutoReset = false; }
|
|
442
|
+
}
|
|
443
|
+
if (!wantAutoReset) {
|
|
444
|
+
return { ok: false, reason: 'dirty', dirtyFiles, branchInfo };
|
|
445
|
+
}
|
|
259
446
|
|
|
260
|
-
if (typeof log === 'function') {
|
|
261
|
-
log(`live-checkout auto-reset: discarding ${dirtyFiles.length} dirty path(s) on '${branchName}' via fetch + reset --hard origin/${branchName}`);
|
|
262
|
-
}
|
|
263
|
-
let resetOk = true;
|
|
264
|
-
try {
|
|
265
|
-
await git(['fetch', 'origin'], baseOpts);
|
|
266
|
-
await git(['reset', '--hard', `origin/${branchName}`], baseOpts);
|
|
267
|
-
} catch (e) {
|
|
268
|
-
resetOk = false;
|
|
269
447
|
if (typeof log === 'function') {
|
|
270
|
-
log(`live-checkout auto-reset
|
|
448
|
+
log(`live-checkout auto-reset: discarding ${dirtyFiles.length} dirty path(s) on '${branchName}' via fetch + reset --hard origin/${branchName}`);
|
|
271
449
|
}
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
// Re-run the porcelain preflight once. If the tree is now clean we proceed;
|
|
275
|
-
// otherwise (reset failed, or something is still dirty) we fall back to the
|
|
276
|
-
// safe dirty refusal so we never dispatch onto an unexpected tree.
|
|
277
|
-
let stillDirty = dirtyFiles;
|
|
278
|
-
if (resetOk) {
|
|
450
|
+
let resetOk = true;
|
|
279
451
|
try {
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
stillDirty = recheckStr
|
|
283
|
-
.split(/\r?\n/)
|
|
284
|
-
.map((line) => line.replace(/\s+$/, ''))
|
|
285
|
-
.filter((line) => line.length > 0 && !line.startsWith('## '));
|
|
452
|
+
await git(['fetch', 'origin'], baseOpts);
|
|
453
|
+
await git(['reset', '--hard', `origin/${branchName}`], baseOpts);
|
|
286
454
|
} catch (e) {
|
|
287
455
|
resetOk = false;
|
|
288
456
|
if (typeof log === 'function') {
|
|
289
|
-
log(`live-checkout auto-reset
|
|
457
|
+
log(`live-checkout auto-reset FAILED (fetch/reset): ${e && e.message ? e.message : e}`);
|
|
290
458
|
}
|
|
291
459
|
}
|
|
292
|
-
}
|
|
293
460
|
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
461
|
+
// Re-run the porcelain preflight once. If the tree is now clean we proceed;
|
|
462
|
+
// otherwise (reset failed, or something is still dirty) we fall back to the
|
|
463
|
+
// safe dirty refusal so we never dispatch onto an unexpected tree.
|
|
464
|
+
let stillDirty = dirtyFiles;
|
|
465
|
+
if (resetOk) {
|
|
466
|
+
try {
|
|
467
|
+
const recheckRaw = await git(['status', '--porcelain=v1', '-b'], baseOpts);
|
|
468
|
+
const recheckStr = typeof recheckRaw === 'string' ? recheckRaw : '';
|
|
469
|
+
stillDirty = recheckStr
|
|
470
|
+
.split(/\r?\n/)
|
|
471
|
+
.map((line) => line.replace(/\s+$/, ''))
|
|
472
|
+
.filter((line) => line.length > 0 && !line.startsWith('## '));
|
|
473
|
+
} catch (e) {
|
|
474
|
+
resetOk = false;
|
|
475
|
+
if (typeof log === 'function') {
|
|
476
|
+
log(`live-checkout auto-reset re-check FAILED: ${e && e.message ? e.message : e}`);
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
}
|
|
297
480
|
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
''
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
481
|
+
if (!resetOk || stillDirty.length > 0) {
|
|
482
|
+
return { ok: false, reason: 'dirty', dirtyFiles: stillDirty, branchInfo };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
// Audit trail: record the discarded paths so the operator can recover from
|
|
486
|
+
// reflog / understand why their tree changed. Best-effort, never throws.
|
|
487
|
+
try {
|
|
488
|
+
const writeNote = (typeof _writeInboxNote === 'function') ? _writeInboxNote : _defaultWriteInboxNote;
|
|
489
|
+
const slug = `live-checkout-autoreset-${wiId || dispatchId || 'unknown'}`;
|
|
490
|
+
const body = [
|
|
491
|
+
`# Live-checkout auto-reset on '${branchName}'`,
|
|
492
|
+
'',
|
|
493
|
+
`⚠️ The live-checkout tree at \`${localPath}\` was dirty at dispatch time and`,
|
|
494
|
+
'`liveCheckoutAutoReset` is enabled, so it was force-reset to',
|
|
495
|
+
`\`origin/${branchName}\`. **The following uncommitted changes were DISCARDED**`,
|
|
496
|
+
'(recover from `git reflog` / `git fsck --lost-found` if needed):',
|
|
497
|
+
'',
|
|
498
|
+
'```',
|
|
499
|
+
...dirtyFiles,
|
|
500
|
+
'```',
|
|
501
|
+
].join('\n');
|
|
502
|
+
writeNote(slug, body);
|
|
503
|
+
} catch { /* best-effort audit note */ }
|
|
504
|
+
// Fall through — tree is now clean, continue with normal preparation.
|
|
505
|
+
}
|
|
318
506
|
}
|
|
319
507
|
}
|
|
320
508
|
|
|
@@ -559,6 +747,7 @@ async function restoreLiveCheckoutAtDispatchEnd(opts = {}) {
|
|
|
559
747
|
log,
|
|
560
748
|
writeInboxAlert,
|
|
561
749
|
_git, // private injection for testing — defaults to shared.shellSafeGit
|
|
750
|
+
_sleep, // private injection for testing — defaults to a real setTimeout-based sleep
|
|
562
751
|
} = opts;
|
|
563
752
|
|
|
564
753
|
const logFn = (typeof log === 'function') ? log : () => {};
|
|
@@ -668,16 +857,27 @@ async function restoreLiveCheckoutAtDispatchEnd(opts = {}) {
|
|
|
668
857
|
onAgentBranch = cur && cur === branchName;
|
|
669
858
|
} catch { /* best-effort */ }
|
|
670
859
|
if (onAgentBranch) {
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
860
|
+
// issue #608 — bounded retry+backoff on the commit. The 2026-06-30
|
|
861
|
+
// incident was caused by exactly this commit losing a transient
|
|
862
|
+
// `.git/index.lock` race against a sibling dispatch's git process;
|
|
863
|
+
// the previous single-attempt code gave up immediately and left the
|
|
864
|
+
// stage half-committed, which then perpetuated as a stray agent
|
|
865
|
+
// branch for every subsequent dispatch (see the orphaned-agent-branch
|
|
866
|
+
// self-heal in prepareLiveCheckout). `--no-verify` skips operator
|
|
867
|
+
// commit hooks that may fail headless.
|
|
868
|
+
const commitRes = await _commitAgentWipWithRetry(
|
|
869
|
+
git, baseOpts,
|
|
870
|
+
`minions: auto-save agent WIP (dispatch ${did})`,
|
|
871
|
+
{ sleep: _sleep },
|
|
872
|
+
);
|
|
873
|
+
if (commitRes.ok) {
|
|
675
874
|
result.autoCommitted = true;
|
|
676
|
-
logFn('info', `live-checkout: auto-committed agent WIP onto ${branchName} before restore (dispatch ${did})`
|
|
677
|
-
|
|
875
|
+
logFn('info', `live-checkout: auto-committed agent WIP onto ${branchName} before restore (dispatch ${did})` +
|
|
876
|
+
(commitRes.attempts > 1 ? ` after ${commitRes.attempts} attempts` : ''));
|
|
877
|
+
} else {
|
|
678
878
|
// Leave the tree as-is; the plain checkout below will refuse and the
|
|
679
879
|
// fallback alert fires. Never force, never discard.
|
|
680
|
-
logFn('warn', `live-checkout: could not auto-save agent WIP onto ${branchName} for ${did}: ${
|
|
880
|
+
logFn('warn', `live-checkout: could not auto-save agent WIP onto ${branchName} for ${did}: ${commitRes.error && commitRes.error.message}`);
|
|
681
881
|
}
|
|
682
882
|
}
|
|
683
883
|
}
|
|
@@ -817,4 +1017,15 @@ async function maybeRestoreLiveCheckoutFromRecord(opts = {}) {
|
|
|
817
1017
|
}
|
|
818
1018
|
}
|
|
819
1019
|
|
|
820
|
-
module.exports = {
|
|
1020
|
+
module.exports = {
|
|
1021
|
+
prepareLiveCheckout,
|
|
1022
|
+
restoreLiveCheckoutAtDispatchEnd,
|
|
1023
|
+
maybeRestoreLiveCheckoutFromRecord,
|
|
1024
|
+
_isPartialCloneBlobError,
|
|
1025
|
+
_isAutoCleanableArtifact,
|
|
1026
|
+
_collectAutoCleanablePaths,
|
|
1027
|
+
_looksLikeAgentBranch,
|
|
1028
|
+
_extractBranchFromPorcelainHeader,
|
|
1029
|
+
_commitAgentWipWithRetry,
|
|
1030
|
+
LIVE_CHECKOUT_AUTO_CLEAN_PATTERNS,
|
|
1031
|
+
};
|
package/engine/playbook.js
CHANGED
|
@@ -13,7 +13,7 @@ const { wrapUntrusted, buildSource } = require('./untrusted-fence');
|
|
|
13
13
|
const { MINIONS_BRAND_URL } = require('./comment-format');
|
|
14
14
|
|
|
15
15
|
const { safeJson, safeRead, getProjects, log, ts, dateStamp, truncateTextBytes, ENGINE_DEFAULTS, WI_STATUS, WORK_TYPE, PR_STATUS, DISPATCH_RESULT, getProjectOrg } = shared;
|
|
16
|
-
const { getConfig, getDispatch, getNotes,
|
|
16
|
+
const { getConfig, getDispatch, getNotes, getPrs, getKnowledgeBaseIndex, AGENTS_DIR } = queries;
|
|
17
17
|
|
|
18
18
|
const MINIONS_DIR = shared.MINIONS_DIR;
|
|
19
19
|
const PLAYBOOKS_DIR = path.join(MINIONS_DIR, 'playbooks');
|
|
@@ -1052,7 +1052,7 @@ function _checkPlaybookSections(content, context = '') {
|
|
|
1052
1052
|
// Lean system prompt: agent identity + rules only (~2-4KB, never grows)
|
|
1053
1053
|
function buildSystemPrompt(agentId, config, project) {
|
|
1054
1054
|
const agent = config.agents[agentId] || tempAgents.get(agentId) || { name: agentId, role: 'Temporary Agent', expertise: [] };
|
|
1055
|
-
const charter =
|
|
1055
|
+
const charter = safeRead(path.join(AGENTS_DIR, agentId, 'charter.md')); // returns '' for temp agents (no charter file)
|
|
1056
1056
|
project = project || getProjects(config)[0] || {};
|
|
1057
1057
|
|
|
1058
1058
|
let prompt = '';
|
package/engine/queries.js
CHANGED
|
@@ -722,10 +722,6 @@ function getAgentStatus(agentId) {
|
|
|
722
722
|
// setAgentStatus removed — agent status is derived from dispatch.json.
|
|
723
723
|
// Status.json files no longer exist.
|
|
724
724
|
|
|
725
|
-
function getAgentCharter(agentId) {
|
|
726
|
-
return safeRead(path.join(AGENTS_DIR, agentId, 'charter.md'));
|
|
727
|
-
}
|
|
728
|
-
|
|
729
725
|
function getAgents(config) {
|
|
730
726
|
config = config || getConfig();
|
|
731
727
|
// Fall back to DEFAULT_AGENTS if config has no agents (uninitialized repo)
|
|
@@ -2063,13 +2059,21 @@ function getWorkItems(config, opts) {
|
|
|
2063
2059
|
});
|
|
2064
2060
|
|
|
2065
2061
|
const cacheMtimes = _snapshotWorkItemsMtimes(config);
|
|
2062
|
+
// Use Date.now() here (not the `now` captured at function entry) so the
|
|
2063
|
+
// 1 s TTL is measured from when the cache was BUILT, not from when the
|
|
2064
|
+
// call started. On a slow or loaded machine the first cold call can take
|
|
2065
|
+
// > 1 s while DB migrations run; if we used the entry-time `now`, the
|
|
2066
|
+
// very next call would see (entry_time_of_call_2 − entry_time_of_call_1)
|
|
2067
|
+
// >= 1 000 ms and miss the cache, defeating the cache entirely.
|
|
2068
|
+
// (W-mr00aooc000i1d6b — queries-work-items-cache CI failure on slow runners)
|
|
2069
|
+
const cacheSetAt = Date.now();
|
|
2066
2070
|
if (enrich) {
|
|
2067
2071
|
_workItemsCache = allItems;
|
|
2068
|
-
_workItemsCacheAt =
|
|
2072
|
+
_workItemsCacheAt = cacheSetAt;
|
|
2069
2073
|
_workItemsCacheMtimes = cacheMtimes;
|
|
2070
2074
|
} else {
|
|
2071
2075
|
_workItemsLeanCache = allItems;
|
|
2072
|
-
_workItemsLeanCacheAt =
|
|
2076
|
+
_workItemsLeanCacheAt = cacheSetAt;
|
|
2073
2077
|
_workItemsLeanCacheMtimes = cacheMtimes;
|
|
2074
2078
|
}
|
|
2075
2079
|
return allItems;
|
|
@@ -3320,7 +3324,7 @@ module.exports = {
|
|
|
3320
3324
|
_buildNotesByWiMap, // exported for testing
|
|
3321
3325
|
|
|
3322
3326
|
// Agents
|
|
3323
|
-
getAgentStatus,
|
|
3327
|
+
getAgentStatus, getAgents, getAgentDetail,
|
|
3324
3328
|
|
|
3325
3329
|
// Pull requests
|
|
3326
3330
|
getPrs, getPullRequests,
|
package/engine/shared.js
CHANGED
|
@@ -3254,11 +3254,12 @@ const ENGINE_DEFAULTS = {
|
|
|
3254
3254
|
// When ON, a dirty/broken live-checkout tree is `git fetch origin` +
|
|
3255
3255
|
// `git reset --hard origin/<branch>`'d before dispatch instead of failing
|
|
3256
3256
|
// LIVE_CHECKOUT_DIRTY. Per-project `project.liveCheckoutAutoReset` overrides
|
|
3257
|
-
// this. Default
|
|
3258
|
-
//
|
|
3259
|
-
//
|
|
3260
|
-
//
|
|
3261
|
-
|
|
3257
|
+
// this. Default ON — discarding uncommitted dirt via reset-to-remote keeps
|
|
3258
|
+
// live-checkout branches from diverging (the old `false` default accumulated
|
|
3259
|
+
// `minions: auto-save agent WIP` commits on every dirty exit). Resolution
|
|
3260
|
+
// precedence lives in `resolveLiveCheckoutAutoReset`. Fires ONLY on confirmed
|
|
3261
|
+
// dirty-tree detection, never on mid-operation / blob-fetch / tooling failures.
|
|
3262
|
+
liveCheckoutAutoReset: true,
|
|
3262
3263
|
orphanHolderScanTimeoutMs: 5000, // 5s ceiling for the cross-platform holder scan (PowerShell / /proc walk / lsof)
|
|
3263
3264
|
ccMaxTurns: 50, // max tool-use turns per CC/doc-chat call before CLI stops (per response, not per session)
|
|
3264
3265
|
ccWorkerIdleTimeoutMs: 30 * 60 * 1000, // W-mr0qs0vw: idle-reaper window for the persistent `copilot --acp` worker pool (engine/cc-worker-pool.js). After this much inactivity with no in-flight turn the warm ACP process is killed; the next message cold-spawns a fresh session with NO memory of prior turns (CC shows a "context cleared after inactivity" notice). Tradeoff: shorter = less idle memory/process footprint, longer = more context durability across gaps between messages. Wired into the pool via ccWorkerPool.setIdleTimeoutMs() on every reloadConfig(); clamped to [60000, 28800000] (1min–8h) in the settings POST handler.
|