@yemi33/minions 0.1.2405 → 0.1.2407

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.
@@ -57,7 +57,7 @@ function linkifyBrandTrailer(text) {
57
57
  // - model concrete runtime execution model (optional)
58
58
  // - no field may contain `--` (would close the HTML comment early)
59
59
 
60
- const { normalizeExecutionModel } = require('./execution-model');
60
+ const { normalizeExecutionModel, UNKNOWN_MODEL } = require('./execution-model');
61
61
 
62
62
  const AGENT_ID_RE = /^[a-z][a-z0-9-]{0,30}$/;
63
63
  const KIND_RE = /^[a-z][a-z0-9-]{0,30}$/;
@@ -104,24 +104,63 @@ function _encodeMarkerModel(model) {
104
104
  return encoded.includes('--') ? encoded.replace(/-/g, '%2D') : encoded;
105
105
  }
106
106
 
107
+ // A genuinely-absent model must NOT be recorded in the hidden marker — the
108
+ // `unknown` sentinel is the signal that no runtime/model evidence exists, so we
109
+ // omit the segment entirely (W-mryk8hf1) rather than persisting `model=unknown`.
110
+ function _isKnownModel(model) {
111
+ return !!model && model !== UNKNOWN_MODEL;
112
+ }
113
+
107
114
  function _buildMarker({ agentId, kind, workItemId, model }) {
108
115
  _validateMarkerInputs({ agentId, kind, workItemId, model });
109
116
  const wi = (workItemId !== undefined && workItemId !== null) ? ` wi=${workItemId}` : '';
110
- const modelPart = model ? ` model=${_encodeMarkerModel(model)}` : '';
117
+ const modelPart = _isKnownModel(model) ? ` model=${_encodeMarkerModel(model)}` : '';
111
118
  return `<!-- minions:agent=${agentId} kind=${kind}${wi}${modelPart} -->`;
112
119
  }
113
120
 
114
121
  const MODEL_SIGNOFF_RE =
115
122
  /(\b(?:Review|Reviewed|Fixed|Verified|Rebased) by (?:Minions|\[Minions\]\([^)]+\)) \([^\r\n)]*? · )[^)\r\n]+(\))/g;
116
123
 
124
+ // Matches the visible sign-off when its model segment is the `unknown` sentinel,
125
+ // capturing everything up to (but excluding) the ` · unknown` so it can be
126
+ // dropped — turning `(Ripley — Explorer · unknown)` into `(Ripley — Explorer)`.
127
+ const UNKNOWN_MODEL_SIGNOFF_RE =
128
+ /(\b(?:Review|Reviewed|Fixed|Verified|Rebased) by (?:Minions|\[Minions\]\([^)]+\)) \([^\r\n)]*?) · unknown(\))/g;
129
+
130
+ /**
131
+ * Stamp the concrete runtime execution model into the visible sign-off.
132
+ *
133
+ * Three cases, mirroring the execution-model resolver's evidence outcomes:
134
+ * - known model → replace the model segment with it.
135
+ * - `unknown` sentinel → the resolver positively determined no runtime/
136
+ * model evidence exists, so omit the whole
137
+ * ` · <model>` segment rather than printing it
138
+ * (W-mryk8hf1). The footer degrades to
139
+ * `(<name> — <role>)`.
140
+ * - no opinion (falsy/null) → leave any concrete model the agent wrote
141
+ * intact, but still drop a bare ` · unknown`
142
+ * sentinel so the footer never advertises it.
143
+ */
117
144
  function stampExecutionModel(text, model) {
145
+ if (typeof text !== 'string') return text;
118
146
  const normalized = normalizeExecutionModel(model);
119
- if (!normalized || typeof text !== 'string') return text;
120
- return text.replace(MODEL_SIGNOFF_RE, (_match, prefix, suffix) => `${prefix}${normalized}${suffix}`);
147
+ if (_isKnownModel(normalized)) {
148
+ return text.replace(MODEL_SIGNOFF_RE, (_match, prefix, suffix) => `${prefix}${normalized}${suffix}`);
149
+ }
150
+ if (normalized === UNKNOWN_MODEL) {
151
+ // prefix ends with " · " — drop that separator along with the model segment.
152
+ return text.replace(MODEL_SIGNOFF_RE, (_match, prefix, suffix) => `${prefix.replace(/ · $/, '')}${suffix}`);
153
+ }
154
+ return text.replace(UNKNOWN_MODEL_SIGNOFF_RE, (_match, prefix, suffix) => `${prefix}${suffix}`);
121
155
  }
122
156
 
123
157
  function _stampLeadingMarkerModel(text, model) {
124
- if (!model || typeof text !== 'string') return text;
158
+ if (typeof text !== 'string') return text;
159
+ // Rewrite the leading marker whenever a model value is supplied. A known model
160
+ // is recorded; the `unknown` sentinel rebuilds the marker without `model=`
161
+ // (buildMarker omits it), stripping any pre-existing stale model=. A
162
+ // genuinely-absent (falsy/null) model leaves the marker untouched.
163
+ if (!normalizeExecutionModel(model)) return text;
125
164
  return text.replace(MINIONS_COMMENT_MARKER_RE, (marker, agentId, kind, workItemId) => {
126
165
  if (marker !== text.slice(0, marker.length)) return marker;
127
166
  return _buildMarker({ agentId, kind, workItemId, model });
@@ -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 (P-c8f2d5e3).
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
- * When the engine reuses an existing worktree on a PR branch that was rebased
210
- * upstream (force-push), the local HEAD can sit behind origin/<branch>. The
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
- * This helper runs:
215
- * git fetch origin <branch>
216
- * git rev-list --count HEAD..origin/<branch>
217
- * inside the worktree. When the count is > 0 it throws a clear, actionable
218
- * error so engine.spawnAgent can abort the dispatch before invoking the
219
- * runtime CLI — i.e. before the agent has a chance to push.
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
- * The fetch is best-effort: if origin doesn't have the ref yet (first push on
222
- * a fresh branch, common for shared-branch plan items), the helper returns
223
- * `{ ok: true, skipped: 'no-upstream' }` instead of failing there's no
224
- * rebased tip to overwrite. Any other fetch failure is also treated as a
225
- * skip with `skipped: 'fetch-failed'` so transient network issues don't
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
- * @returns {Promise<{ok: true, behindCount: number, skipped?: string}>}
234
- * @throws {Error & {code: 'STALE_HEAD'}} when local HEAD is behind origin
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({ branch, cwd, exec, gitOpts } = {}) {
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
- // Best-effort fetch. Branch-missing-on-origin is a legitimate state (first
245
- // push on a freshly-cut feature branch) and must NOT block dispatch.
246
- try {
247
- await execFn(`git fetch origin "${branch}"`, opts);
248
- } catch (err) {
249
- const msg = (err && (err.stderr?.toString?.() || err.message || '')) + '';
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
- // Other failures (network/auth/timeout) skip rather than block.
254
- return { ok: true, behindCount: 0, skipped: 'fetch-failed' };
255
- }
318
+ try { return require('./shared').hasWorktreeOwnerMarker(cwd); } catch { return false; }
319
+ };
256
320
 
257
- let countOut;
258
- try {
259
- countOut = await execFn(`git rev-list --count HEAD..origin/${branch}`, opts);
260
- } catch (err) {
261
- // origin/<branch> resolution failed AFTER fetch treat as no-upstream.
262
- return { ok: true, behindCount: 0, skipped: 'rev-list-failed' };
263
- }
264
- const raw = typeof countOut === 'string'
265
- ? countOut
266
- : (countOut?.stdout?.toString?.() ?? String(countOut ?? ''));
267
- const behindCount = parseInt(String(raw).trim(), 10);
268
- if (!Number.isFinite(behindCount) || behindCount <= 0) {
269
- return { ok: true, behindCount: Number.isFinite(behindCount) ? behindCount : 0 };
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 (${behindCount} commits behind origin). ` +
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 = 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.skipped) {
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.2405",
3
+ "version": "0.1.2407",
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"