@yemi33/minions 0.1.2405 → 0.1.2406
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/engine/spawn-agent.js +187 -45
- package/engine.js +3 -1
- package/package.json +1 -1
package/engine/spawn-agent.js
CHANGED
|
@@ -203,26 +203,78 @@ function formatProcessExitSentinel(exitCode, signal) {
|
|
|
203
203
|
return `\n[process-exit] code=${exitCode}${signal ? ` signal=${signal}` : ''}\n`;
|
|
204
204
|
}
|
|
205
205
|
|
|
206
|
+
// Parse a `git rev-list --count <range>` result into a finite integer (NaN on
|
|
207
|
+
// failure). Tolerates string / {stdout} return shapes and Windows CRLF.
|
|
208
|
+
async function _revListCount(execFn, range, opts) {
|
|
209
|
+
const out = await execFn(`git rev-list --count ${range}`, opts);
|
|
210
|
+
const raw = typeof out === 'string'
|
|
211
|
+
? out
|
|
212
|
+
: (out?.stdout?.toString?.() ?? String(out ?? ''));
|
|
213
|
+
const n = parseInt(String(raw).trim(), 10);
|
|
214
|
+
return Number.isFinite(n) ? n : NaN;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Return the list of dirty porcelain lines in the worktree, EXCLUDING the
|
|
218
|
+
// engine's own `.minions-worktree` ownership marker (which a reused worktree
|
|
219
|
+
// can surface as untracked without meaning the tree carries real work).
|
|
220
|
+
async function _worktreeDirtyFiles(execFn, opts) {
|
|
221
|
+
const out = await execFn('git status --porcelain', opts);
|
|
222
|
+
const raw = typeof out === 'string'
|
|
223
|
+
? out
|
|
224
|
+
: (out?.stdout?.toString?.() ?? String(out ?? ''));
|
|
225
|
+
const shared = require('./shared');
|
|
226
|
+
return String(raw)
|
|
227
|
+
.split(/\r?\n/)
|
|
228
|
+
.filter((line) => line.trim().length > 0)
|
|
229
|
+
.filter((line) => !shared.isWorktreeOwnerMarkerStatusLine(line));
|
|
230
|
+
}
|
|
231
|
+
|
|
206
232
|
/**
|
|
207
|
-
* Pre-push stale-HEAD guard for fix-task dispatches
|
|
233
|
+
* Pre-push stale-HEAD guard + safe behind-only refresh for fix-task dispatches
|
|
234
|
+
* (P-c8f2d5e3; safe-refresh extension W-mrza78rc001s6241).
|
|
235
|
+
*
|
|
236
|
+
* When the engine reuses an existing worktree on a PR branch, its local HEAD
|
|
237
|
+
* can sit behind origin/<branch> for two very different reasons:
|
|
238
|
+
*
|
|
239
|
+
* 1. The branch was force-pushed / rebased upstream so local history and
|
|
240
|
+
* remote history DIVERGED. Pushing from here silently overwrites the
|
|
241
|
+
* rebased tip — a confirmed silent-overwrite footgun. This MUST be
|
|
242
|
+
* refused.
|
|
243
|
+
* 2. The branch simply advanced on origin (more commits pushed) while the
|
|
244
|
+
* fresh, engine-owned, clean worktree has NO local-only commits. This is
|
|
245
|
+
* a plain fast-forward; refusing it needlessly aborts otherwise-healthy
|
|
246
|
+
* fix dispatches (the recurring PR #921 / #944 failures). Here we can
|
|
247
|
+
* safely refresh to the remote tip before the agent starts.
|
|
208
248
|
*
|
|
209
|
-
*
|
|
210
|
-
*
|
|
211
|
-
* first push from that worktree silently overwrites the rebased history — a
|
|
212
|
-
* confirmed silent-overwrite footgun captured in team memory.
|
|
249
|
+
* The helper runs `git fetch origin <branch>` then computes both the behind
|
|
250
|
+
* (`HEAD..origin/<branch>`) and ahead (`origin/<branch>..HEAD`) counts:
|
|
213
251
|
*
|
|
214
|
-
*
|
|
215
|
-
*
|
|
216
|
-
*
|
|
217
|
-
*
|
|
218
|
-
*
|
|
219
|
-
*
|
|
252
|
+
* - behind == 0 → `{ ok: true, behindCount: 0 }`
|
|
253
|
+
* - behind > 0 && ahead > 0 (diverged) → throw STALE_HEAD (fail closed)
|
|
254
|
+
* - behind > 0 && ahead == 0 (pure behind), worktree clean & engine-owned,
|
|
255
|
+
* and `autoRefresh` is on → `git merge --ff-only origin/<branch>` and
|
|
256
|
+
* return `{ ok: true, refreshed: true, behindCount, aheadCount: 0,
|
|
257
|
+
* previousHead, newHead }`
|
|
258
|
+
* - anything unproven (dirty tree, cannot prove engine ownership, ahead
|
|
259
|
+
* lookup failed, ff-only rejected after bounded retries) → throw
|
|
260
|
+
* STALE_HEAD (fail closed, never discards data)
|
|
220
261
|
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
223
|
-
*
|
|
224
|
-
*
|
|
225
|
-
*
|
|
262
|
+
* Cleanliness and ahead/behind are RE-COMPUTED immediately before the ref
|
|
263
|
+
* change to close the race where the branch moved (locally or via a concurrent
|
|
264
|
+
* fetch) between the checks and the merge. A remote that advances again during
|
|
265
|
+
* preparation triggers bounded re-evaluation (`maxRefreshAttempts`) rather than
|
|
266
|
+
* an opaque failure — each attempt re-fetches and re-decides.
|
|
267
|
+
*
|
|
268
|
+
* `--ff-only` is intentionally non-destructive: it errors (leaving HEAD
|
|
269
|
+
* untouched) whenever HEAD is no longer an ancestor of origin/<branch>, so no
|
|
270
|
+
* local commit or working-tree change is ever discarded. Branch continuity is
|
|
271
|
+
* preserved (HEAD stays on the same local branch, fast-forwarded) which keeps
|
|
272
|
+
* the agent's later `--force-with-lease` push safe.
|
|
273
|
+
*
|
|
274
|
+
* The fetch is best-effort: a branch missing on origin (first push on a fresh
|
|
275
|
+
* branch, common for shared-branch plan items) returns
|
|
276
|
+
* `{ ok: true, skipped: 'no-upstream' }`; any other fetch failure returns
|
|
277
|
+
* `{ ok: true, skipped: 'fetch-failed' }` so transient network issues don't
|
|
226
278
|
* brick an otherwise-healthy dispatch.
|
|
227
279
|
*
|
|
228
280
|
* @param {object} args
|
|
@@ -230,10 +282,25 @@ function formatProcessExitSentinel(exitCode, signal) {
|
|
|
230
282
|
* @param {string} args.cwd - Worktree path
|
|
231
283
|
* @param {function} [args.exec] - Async exec(cmd, opts) — injectable for tests
|
|
232
284
|
* @param {object} [args.gitOpts] - Options passed through to exec
|
|
233
|
-
* @
|
|
234
|
-
* @
|
|
285
|
+
* @param {boolean} [args.autoRefresh=true] - Enable safe behind-only refresh
|
|
286
|
+
* @param {function} [args.isEngineOwned] - Predicate(cwd) proving engine
|
|
287
|
+
* ownership; defaults to the `.minions-worktree` marker check
|
|
288
|
+
* @param {number} [args.maxRefreshAttempts=3] - Bounded re-evaluation budget
|
|
289
|
+
* @returns {Promise<{ok: true, behindCount: number, aheadCount?: number,
|
|
290
|
+
* refreshed?: boolean, previousHead?: string, newHead?: string,
|
|
291
|
+
* skipped?: string}>}
|
|
292
|
+
* @throws {Error & {code: 'STALE_HEAD'}} when local HEAD is behind origin and
|
|
293
|
+
* cannot be safely fast-forwarded
|
|
235
294
|
*/
|
|
236
|
-
async function assertStaleHeadOk({
|
|
295
|
+
async function assertStaleHeadOk({
|
|
296
|
+
branch,
|
|
297
|
+
cwd,
|
|
298
|
+
exec,
|
|
299
|
+
gitOpts,
|
|
300
|
+
autoRefresh = true,
|
|
301
|
+
isEngineOwned,
|
|
302
|
+
maxRefreshAttempts = 3,
|
|
303
|
+
} = {}) {
|
|
237
304
|
if (!branch) throw new Error('assertStaleHeadOk: branch is required');
|
|
238
305
|
if (!cwd) throw new Error('assertStaleHeadOk: cwd is required');
|
|
239
306
|
const execFn = typeof exec === 'function'
|
|
@@ -241,39 +308,114 @@ async function assertStaleHeadOk({ branch, cwd, exec, gitOpts } = {}) {
|
|
|
241
308
|
: require('./shared').execAsync;
|
|
242
309
|
const opts = { ...(gitOpts || {}), cwd };
|
|
243
310
|
|
|
244
|
-
//
|
|
245
|
-
//
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
if (/couldn'?t find remote ref|not found in upstream|unknown revision/i.test(msg)) {
|
|
251
|
-
return { ok: true, behindCount: 0, skipped: 'no-upstream' };
|
|
311
|
+
// Proof of engine ownership. Absence = cannot prove current-dispatch-safe,
|
|
312
|
+
// so we never mutate the ref. Defaults to the `.minions-worktree` marker the
|
|
313
|
+
// engine stamps on every worktree it creates (engine.js writeWorktreeOwnerMarker).
|
|
314
|
+
const ownerOk = () => {
|
|
315
|
+
if (typeof isEngineOwned === 'function') {
|
|
316
|
+
try { return !!isEngineOwned(cwd); } catch { return false; }
|
|
252
317
|
}
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
}
|
|
318
|
+
try { return require('./shared').hasWorktreeOwnerMarker(cwd); } catch { return false; }
|
|
319
|
+
};
|
|
256
320
|
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
//
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
321
|
+
const attempts = Math.max(1, Math.trunc(Number(maxRefreshAttempts) || 0));
|
|
322
|
+
let lastBehind = 0;
|
|
323
|
+
|
|
324
|
+
for (let attempt = 0; attempt < attempts; attempt++) {
|
|
325
|
+
// Best-effort fetch, re-run each attempt so a remote that advanced during
|
|
326
|
+
// preparation is re-observed. Branch-missing-on-origin is a legitimate
|
|
327
|
+
// state (first push on a freshly-cut feature branch) and must NOT block.
|
|
328
|
+
try {
|
|
329
|
+
await execFn(`git fetch origin "${branch}"`, opts);
|
|
330
|
+
} catch (err) {
|
|
331
|
+
const msg = (err && (err.stderr?.toString?.() || err.message || '')) + '';
|
|
332
|
+
if (/couldn'?t find remote ref|not found in upstream|unknown revision/i.test(msg)) {
|
|
333
|
+
return { ok: true, behindCount: 0, skipped: 'no-upstream' };
|
|
334
|
+
}
|
|
335
|
+
// Other failures (network/auth/timeout) — skip rather than block.
|
|
336
|
+
return { ok: true, behindCount: 0, skipped: 'fetch-failed' };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
let behind;
|
|
340
|
+
try {
|
|
341
|
+
behind = await _revListCount(execFn, `HEAD..origin/${branch}`, opts);
|
|
342
|
+
} catch (err) {
|
|
343
|
+
// origin/<branch> resolution failed AFTER fetch — treat as no-upstream.
|
|
344
|
+
return { ok: true, behindCount: 0, skipped: 'rev-list-failed' };
|
|
345
|
+
}
|
|
346
|
+
if (!Number.isFinite(behind) || behind <= 0) {
|
|
347
|
+
return { ok: true, behindCount: Number.isFinite(behind) ? behind : 0 };
|
|
348
|
+
}
|
|
349
|
+
lastBehind = behind;
|
|
350
|
+
|
|
351
|
+
// Behind > 0. Distinguish a safe pure-behind fast-forward from an unsafe
|
|
352
|
+
// divergence (any local-only commit).
|
|
353
|
+
let ahead;
|
|
354
|
+
try {
|
|
355
|
+
ahead = await _revListCount(execFn, `origin/${branch}..HEAD`, opts);
|
|
356
|
+
} catch {
|
|
357
|
+
ahead = NaN; // unknown → fail closed below
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
const canRefresh = autoRefresh
|
|
361
|
+
&& Number.isFinite(ahead) && ahead === 0
|
|
362
|
+
&& ownerOk();
|
|
363
|
+
if (!canRefresh) {
|
|
364
|
+
break; // diverged / unproven → fall through to STALE_HEAD (fail closed)
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// Worktree must be clean (ignoring the engine ownership marker) — never
|
|
368
|
+
// fast-forward over uncommitted work.
|
|
369
|
+
let dirty;
|
|
370
|
+
try {
|
|
371
|
+
dirty = await _worktreeDirtyFiles(execFn, opts);
|
|
372
|
+
} catch {
|
|
373
|
+
break; // couldn't prove cleanliness → fail closed
|
|
374
|
+
}
|
|
375
|
+
if (dirty.length > 0) break;
|
|
376
|
+
|
|
377
|
+
// Re-compute ahead/behind IMMEDIATELY before changing the ref to close the
|
|
378
|
+
// race where the branch moved between the checks and the merge.
|
|
379
|
+
let behind2, ahead2;
|
|
380
|
+
try {
|
|
381
|
+
behind2 = await _revListCount(execFn, `HEAD..origin/${branch}`, opts);
|
|
382
|
+
ahead2 = await _revListCount(execFn, `origin/${branch}..HEAD`, opts);
|
|
383
|
+
} catch {
|
|
384
|
+
continue; // transient — bounded re-evaluation
|
|
385
|
+
}
|
|
386
|
+
if (!Number.isFinite(behind2) || behind2 <= 0) {
|
|
387
|
+
// Concurrently became current — nothing to refresh, nothing stale.
|
|
388
|
+
return { ok: true, behindCount: 0, refreshed: false };
|
|
389
|
+
}
|
|
390
|
+
if (!Number.isFinite(ahead2) || ahead2 !== 0) {
|
|
391
|
+
break; // became divergent under us → fail closed
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
let previousHead = null;
|
|
395
|
+
try { previousHead = String(await execFn('git rev-parse HEAD', opts)).trim(); } catch { /* best effort */ }
|
|
396
|
+
try {
|
|
397
|
+
await execFn(`git merge --ff-only "origin/${branch}"`, opts);
|
|
398
|
+
} catch {
|
|
399
|
+
continue; // ref moved under us → bounded re-evaluation, never destructive
|
|
400
|
+
}
|
|
401
|
+
let newHead = null;
|
|
402
|
+
try { newHead = String(await execFn('git rev-parse HEAD', opts)).trim(); } catch { /* best effort */ }
|
|
403
|
+
return {
|
|
404
|
+
ok: true,
|
|
405
|
+
refreshed: true,
|
|
406
|
+
behindCount: behind2,
|
|
407
|
+
aheadCount: 0,
|
|
408
|
+
previousHead,
|
|
409
|
+
newHead,
|
|
410
|
+
};
|
|
270
411
|
}
|
|
412
|
+
|
|
271
413
|
const err = new Error(
|
|
272
|
-
`PR branch was rebased; local HEAD is stale (${
|
|
414
|
+
`PR branch was rebased; local HEAD is stale (${lastBehind} commits behind origin). ` +
|
|
273
415
|
`Run \`git pull --rebase origin ${branch}\` first.`
|
|
274
416
|
);
|
|
275
417
|
err.code = 'STALE_HEAD';
|
|
276
|
-
err.behindCount =
|
|
418
|
+
err.behindCount = lastBehind;
|
|
277
419
|
err.branch = branch;
|
|
278
420
|
throw err;
|
|
279
421
|
}
|
package/engine.js
CHANGED
|
@@ -4778,7 +4778,9 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
4778
4778
|
exec: execAsync,
|
|
4779
4779
|
gitOpts: { ..._gitOpts, timeout: 15000 },
|
|
4780
4780
|
});
|
|
4781
|
-
if (guard.
|
|
4781
|
+
if (guard.refreshed) {
|
|
4782
|
+
log('info', `Stale-HEAD guard fast-forwarded ${id} (${branchName}) ${guard.behindCount} commit(s) to origin tip: ${guard.previousHead?.slice(0, 8)} → ${guard.newHead?.slice(0, 8)}`);
|
|
4783
|
+
} else if (guard.skipped) {
|
|
4782
4784
|
log('info', `Stale-HEAD guard skipped for ${id} (${branchName}): ${guard.skipped}`);
|
|
4783
4785
|
}
|
|
4784
4786
|
} catch (err) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2406",
|
|
4
4
|
"description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
|
|
5
5
|
"bin": {
|
|
6
6
|
"minions": "bin/minions.js"
|