@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.
- package/engine/comment-format.js +44 -5
- package/engine/spawn-agent.js +187 -45
- package/engine.js +3 -1
- package/package.json +1 -1
package/engine/comment-format.js
CHANGED
|
@@ -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 (
|
|
120
|
-
|
|
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 (
|
|
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 });
|
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.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"
|