@yemi33/minions 0.1.1870 → 0.1.1872
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/CHANGELOG.md +8 -0
- package/engine/consolidation.js +122 -5
- package/engine/dispatch.js +100 -0
- package/engine/lifecycle.js +26 -3
- package/engine/playbook.js +19 -0
- package/engine/pre-dispatch-eval.js +130 -0
- package/engine/shared.js +1 -0
- package/engine/spawn-agent.js +76 -1
- package/engine.js +53 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.1872 (2026-05-11)
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
- Stale-HEAD guard on fix-task pushes (P-c8f2d5e3) (#2360)
|
|
7
|
+
- Cached buildStatus invalidation on no-op completion (#2355)
|
|
8
|
+
- per-agent memory file architecture (P-f1c5a8b6) (#2354)
|
|
9
|
+
- Implement pre-dispatch acceptance criteria validation gate (P-a2d6b9c7) (#2352)
|
|
10
|
+
|
|
3
11
|
## 0.1.1870 (2026-05-11)
|
|
4
12
|
|
|
5
13
|
### Fixes
|
package/engine/consolidation.js
CHANGED
|
@@ -15,6 +15,109 @@ const queries = require('./queries');
|
|
|
15
15
|
const { getInboxFiles, getNotes, INBOX_DIR, ENGINE_DIR,
|
|
16
16
|
NOTES_PATH, KNOWLEDGE_DIR, ARCHIVE_DIR } = queries;
|
|
17
17
|
|
|
18
|
+
// Per-agent memory files live under knowledge/agents/<agent>.md and are
|
|
19
|
+
// injected into individual agent prompts (in addition to the broadcast
|
|
20
|
+
// notes.md). See knowledge/agents/README.md for the convention.
|
|
21
|
+
const AGENT_MEMORY_DIR = path.join(KNOWLEDGE_DIR, 'agents');
|
|
22
|
+
// Cap per-agent file size; oldest sections are pruned at section boundaries.
|
|
23
|
+
const AGENT_MEMORY_BUDGET_BYTES = 25000;
|
|
24
|
+
// Match valid agent IDs (lowercase alphanumeric + hyphen, no path separators);
|
|
25
|
+
// excludes temp-* IDs which we filter separately.
|
|
26
|
+
const AGENT_ID_PATTERN = /^[a-z][a-z0-9-]{0,40}$/;
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Extract the authoring agent for an inbox item.
|
|
30
|
+
* Prefers YAML frontmatter `agent:` field; falls back to filename prefix
|
|
31
|
+
* (`<agent>-...md`). Returns lowercase agent id or null.
|
|
32
|
+
*/
|
|
33
|
+
function extractInboxAgent(item) {
|
|
34
|
+
const content = String(item?.content || '');
|
|
35
|
+
const fmMatch = content.match(/^---\n([\s\S]*?)\n---/);
|
|
36
|
+
if (fmMatch) {
|
|
37
|
+
const agentLine = fmMatch[1].split('\n').find(l => /^agent:\s*/i.test(l));
|
|
38
|
+
if (agentLine) {
|
|
39
|
+
const val = agentLine.replace(/^agent:\s*/i, '').trim().toLowerCase();
|
|
40
|
+
if (val && AGENT_ID_PATTERN.test(val)) return val;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
// Filename fallback: take the leading [a-z][a-z0-9]* segment up to the
|
|
44
|
+
// first hyphen (e.g. `dallas-task-1.md` → `dallas`). Hyphens inside the
|
|
45
|
+
// capture would over-match (`rebecca-pr-7-2026.md` → `rebecca-pr-7`).
|
|
46
|
+
const nameMatch = String(item?.name || '').match(/^([a-z][a-z0-9]{1,40})-/i);
|
|
47
|
+
if (nameMatch) {
|
|
48
|
+
const val = nameMatch[1].toLowerCase();
|
|
49
|
+
if (AGENT_ID_PATTERN.test(val)) return val;
|
|
50
|
+
}
|
|
51
|
+
return null;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Append an inbox item to its author's personal memory file when the agent
|
|
56
|
+
* is a known team member (must be present in `knownAgents`) and not a
|
|
57
|
+
* temp-* id. Strict superset of broadcast consolidation — this never
|
|
58
|
+
* replaces the notes.md write; it's an additional per-agent personalization
|
|
59
|
+
* layer. Returns true on write, false on skip.
|
|
60
|
+
*
|
|
61
|
+
* `knownAgents` is required (a Set of lowercase agent IDs from
|
|
62
|
+
* `config.agents`). When omitted, per-agent routing is skipped entirely so
|
|
63
|
+
* we never create memory files for unverified IDs.
|
|
64
|
+
*/
|
|
65
|
+
function appendToAgentMemory(item, knownAgents) {
|
|
66
|
+
const agent = extractInboxAgent(item);
|
|
67
|
+
if (!agent) return false;
|
|
68
|
+
if (agent.startsWith('temp-')) return false;
|
|
69
|
+
if (!knownAgents || !knownAgents.has(agent)) return false;
|
|
70
|
+
|
|
71
|
+
if (!fs.existsSync(AGENT_MEMORY_DIR)) {
|
|
72
|
+
try { fs.mkdirSync(AGENT_MEMORY_DIR, { recursive: true }); }
|
|
73
|
+
catch (err) { log('warn', `Failed to create agent memory dir: ${err.message}`); return false; }
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const memPath = path.join(AGENT_MEMORY_DIR, `${agent}.md`);
|
|
77
|
+
const content = String(item.content || '').trim();
|
|
78
|
+
if (!content) return false;
|
|
79
|
+
|
|
80
|
+
const titleMatch = content.match(/^#\s+(.+)/m);
|
|
81
|
+
const title = titleMatch ? titleMatch[1].trim() : (item.name || 'untitled').replace(/\.md$/, '');
|
|
82
|
+
const entry = `\n\n---\n\n### ${dateStamp()}: ${title}\n_Source: \`notes/inbox/${item.name}\`_\n\n${content}\n`;
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
shared.withFileLock(memPath + '.lock', () => {
|
|
86
|
+
const existing = (fs.existsSync(memPath) ? safeRead(memPath) : '') || '';
|
|
87
|
+
let next = existing + entry;
|
|
88
|
+
if (Buffer.byteLength(next, 'utf8') > AGENT_MEMORY_BUDGET_BYTES) {
|
|
89
|
+
// Find the last section boundary that keeps us under budget.
|
|
90
|
+
const limit = AGENT_MEMORY_BUDGET_BYTES;
|
|
91
|
+
// Keep the header (everything before the first '\n---\n\n### ' boundary)
|
|
92
|
+
// and as many recent sections as fit.
|
|
93
|
+
const firstBoundary = next.indexOf('\n---\n\n### ');
|
|
94
|
+
if (firstBoundary > 0) {
|
|
95
|
+
const header = next.slice(0, firstBoundary);
|
|
96
|
+
const rest = next.slice(firstBoundary);
|
|
97
|
+
// Drop oldest sections until we're under budget.
|
|
98
|
+
const sections = rest.split('\n---\n\n### ').filter(Boolean);
|
|
99
|
+
let trimmed = sections;
|
|
100
|
+
while (trimmed.length > 1 &&
|
|
101
|
+
Buffer.byteLength(header + '\n---\n\n### ' + trimmed.join('\n---\n\n### '), 'utf8') > limit) {
|
|
102
|
+
trimmed = trimmed.slice(1);
|
|
103
|
+
}
|
|
104
|
+
next = header + '\n---\n\n### ' + trimmed.join('\n---\n\n### ');
|
|
105
|
+
if (!next.endsWith('\n')) next += '\n';
|
|
106
|
+
} else {
|
|
107
|
+
// No boundaries — just truncate from the end (rare).
|
|
108
|
+
next = next.slice(-limit);
|
|
109
|
+
}
|
|
110
|
+
log('info', `Pruned knowledge/agents/${agent}.md to stay under ${limit} bytes`);
|
|
111
|
+
}
|
|
112
|
+
safeWrite(memPath, next);
|
|
113
|
+
});
|
|
114
|
+
return true;
|
|
115
|
+
} catch (err) {
|
|
116
|
+
log('warn', `Failed to append to knowledge/agents/${agent}.md: ${err.message}`);
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
18
121
|
// Track in-flight LLM consolidation to prevent concurrent runs
|
|
19
122
|
let _consolidationInFlight = false;
|
|
20
123
|
let _consolidationStartedAt = 0;
|
|
@@ -174,7 +277,7 @@ function consolidateWithLLM(items, existingNotes, files, config) {
|
|
|
174
277
|
fallbackDone = true;
|
|
175
278
|
if (message) log('warn', message);
|
|
176
279
|
if (err?.message) log('debug', `LLM error: ${err.message}`);
|
|
177
|
-
consolidateWithRegex(items, files);
|
|
280
|
+
consolidateWithRegex(items, files, config);
|
|
178
281
|
}
|
|
179
282
|
|
|
180
283
|
const llmCall = callLLM(prompt, sysPrompt, {
|
|
@@ -250,7 +353,7 @@ function consolidateWithLLM(items, existingNotes, files, config) {
|
|
|
250
353
|
|
|
251
354
|
safeWrite(NOTES_PATH, newContent);
|
|
252
355
|
});
|
|
253
|
-
classifyToKnowledgeBase(items);
|
|
356
|
+
classifyToKnowledgeBase(items, config);
|
|
254
357
|
archiveInboxFiles(files);
|
|
255
358
|
log('info', `LLM consolidation complete: ${files.length} notes processed`);
|
|
256
359
|
} else {
|
|
@@ -267,7 +370,7 @@ function consolidateWithLLM(items, existingNotes, files, config) {
|
|
|
267
370
|
|
|
268
371
|
// ─── Regex Fallback Consolidation ────────────────────────────────────────────
|
|
269
372
|
|
|
270
|
-
function consolidateWithRegex(items, files) {
|
|
373
|
+
function consolidateWithRegex(items, files, config) {
|
|
271
374
|
|
|
272
375
|
const allInsights = [];
|
|
273
376
|
for (const item of items) {
|
|
@@ -379,17 +482,21 @@ function consolidateWithRegex(items, files) {
|
|
|
379
482
|
}
|
|
380
483
|
safeWrite(NOTES_PATH, newContent);
|
|
381
484
|
});
|
|
382
|
-
classifyToKnowledgeBase(items);
|
|
485
|
+
classifyToKnowledgeBase(items, config);
|
|
383
486
|
archiveInboxFiles(files);
|
|
384
487
|
log('info', `Regex fallback: consolidated ${files.length} notes \u2192 ${deduped.length} insights into notes.md`);
|
|
385
488
|
}
|
|
386
489
|
|
|
387
490
|
// ─── Knowledge Base Classification ───────────────────────────────────────────
|
|
388
491
|
|
|
389
|
-
function classifyToKnowledgeBase(items) {
|
|
492
|
+
function classifyToKnowledgeBase(items, config) {
|
|
390
493
|
|
|
391
494
|
if (!fs.existsSync(KNOWLEDGE_DIR)) fs.mkdirSync(KNOWLEDGE_DIR, { recursive: true });
|
|
392
495
|
|
|
496
|
+
const knownAgents = config && config.agents
|
|
497
|
+
? new Set(Object.keys(config.agents).map(k => k.toLowerCase()))
|
|
498
|
+
: null;
|
|
499
|
+
|
|
393
500
|
const categoryDirs = {};
|
|
394
501
|
// Include 'general' as fallback category even if not in KB_CATEGORIES
|
|
395
502
|
const allCategories = KB_CATEGORIES.includes('general') ? KB_CATEGORIES : [...KB_CATEGORIES, 'general'];
|
|
@@ -424,6 +531,11 @@ function classifyToKnowledgeBase(items) {
|
|
|
424
531
|
} catch (err) {
|
|
425
532
|
log('warn', `Failed to classify ${item.name} to knowledge base: ${err.message}`);
|
|
426
533
|
}
|
|
534
|
+
|
|
535
|
+
// Per-agent memory routing — strict superset of broadcast consolidation.
|
|
536
|
+
// Appends the inbox content to knowledge/agents/<agent>.md when the
|
|
537
|
+
// author is a configured team member (skips temp-* and unknown agents).
|
|
538
|
+
appendToAgentMemory(item, knownAgents);
|
|
427
539
|
}
|
|
428
540
|
|
|
429
541
|
if (classified > 0) {
|
|
@@ -474,6 +586,11 @@ module.exports = {
|
|
|
474
586
|
consolidateInbox,
|
|
475
587
|
classifyToKnowledgeBase,
|
|
476
588
|
checkDuplicateHash,
|
|
589
|
+
// per-agent memory routing
|
|
590
|
+
extractInboxAgent,
|
|
591
|
+
appendToAgentMemory,
|
|
592
|
+
AGENT_MEMORY_DIR,
|
|
593
|
+
AGENT_MEMORY_BUDGET_BYTES,
|
|
477
594
|
// exported for testing
|
|
478
595
|
buildConsolidationPrompt,
|
|
479
596
|
consolidateWithLLM,
|
package/engine/dispatch.js
CHANGED
|
@@ -174,6 +174,105 @@ function addToDispatch(item) {
|
|
|
174
174
|
return item.id;
|
|
175
175
|
}
|
|
176
176
|
|
|
177
|
+
// ─── Pre-Dispatch Acceptance Criteria Gate (P-a2d6b9c7, Ripley §3) ──────────
|
|
178
|
+
//
|
|
179
|
+
// Optional cheap-LLM validation gate that runs *before* queue insertion so
|
|
180
|
+
// impossible/ambiguous work items are routed to a review queue rather than
|
|
181
|
+
// burning a full agent run. Opt-in via ENGINE_DEFAULTS.enablePreDispatchEval.
|
|
182
|
+
//
|
|
183
|
+
// Wired from engine.js discoverWork(); kept as a separate async wrapper so
|
|
184
|
+
// the existing synchronous addToDispatch() call sites are unaffected.
|
|
185
|
+
|
|
186
|
+
function _persistInvalidWorkItem(item, evaluation) {
|
|
187
|
+
const meta = item?.meta;
|
|
188
|
+
const itemId = meta?.item?.id;
|
|
189
|
+
if (!itemId) return;
|
|
190
|
+
let wiPath;
|
|
191
|
+
try { wiPath = lifecycle().resolveWorkItemPath(meta); } catch { wiPath = null; }
|
|
192
|
+
if (!wiPath) return;
|
|
193
|
+
try {
|
|
194
|
+
mutateWorkItems(wiPath, (items) => {
|
|
195
|
+
if (!Array.isArray(items)) return items;
|
|
196
|
+
const idx = items.findIndex(w => w && w.id === itemId);
|
|
197
|
+
if (idx === -1) return items;
|
|
198
|
+
items[idx]._preDispatchEval = {
|
|
199
|
+
valid: false,
|
|
200
|
+
reason: evaluation.reason || '',
|
|
201
|
+
evaluatedAt: ts(),
|
|
202
|
+
};
|
|
203
|
+
return items;
|
|
204
|
+
}, { skipWriteIfUnchanged: true });
|
|
205
|
+
} catch (e) {
|
|
206
|
+
log('warn', `pre-dispatch-eval: failed to persist reason on ${itemId}: ${e.message}`);
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function _routeToReviewQueue(item, evaluation) {
|
|
211
|
+
item.id = item.id || `${item.agent || 'unassigned'}-${item.type}-${shared.uid()}`;
|
|
212
|
+
item.created_at = ts();
|
|
213
|
+
item.meta = item.meta && typeof item.meta === 'object' ? item.meta : {};
|
|
214
|
+
item._preDispatchEval = {
|
|
215
|
+
valid: false,
|
|
216
|
+
reason: evaluation.reason || '',
|
|
217
|
+
evaluatedAt: ts(),
|
|
218
|
+
};
|
|
219
|
+
mutateDispatch((dispatch) => {
|
|
220
|
+
dispatch.review = Array.isArray(dispatch.review) ? dispatch.review : [];
|
|
221
|
+
// Dedup against the review queue itself so repeated discovery passes don't
|
|
222
|
+
// flood the queue with duplicates of the same WI.
|
|
223
|
+
const wiId = item.meta?.item?.id;
|
|
224
|
+
if (wiId && dispatch.review.some(d => d?.meta?.item?.id === wiId)) {
|
|
225
|
+
return dispatch;
|
|
226
|
+
}
|
|
227
|
+
dispatch.review.push(item);
|
|
228
|
+
return dispatch;
|
|
229
|
+
});
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Async wrapper around addToDispatch that consults the pre-dispatch validator
|
|
234
|
+
* when ENGINE_DEFAULTS.enablePreDispatchEval is true. Validator failures are
|
|
235
|
+
* fail-open (the item still queues) — this gate must never wedge dispatch.
|
|
236
|
+
*
|
|
237
|
+
* @param {object} item - dispatch item (same shape addToDispatch expects).
|
|
238
|
+
* @param {object} [opts]
|
|
239
|
+
* @param {object} [opts.config] - engine config; defaults to queries.getConfig().
|
|
240
|
+
* @param {Function} [opts.validate] - injection point for tests; defaults to
|
|
241
|
+
* require('./pre-dispatch-eval').validateAcceptanceCriteria.
|
|
242
|
+
* @returns {Promise<string|null>} dispatch id when queued; null when routed to review.
|
|
243
|
+
*/
|
|
244
|
+
async function addToDispatchWithValidation(item, opts = {}) {
|
|
245
|
+
const config = opts.config || queries.getConfig();
|
|
246
|
+
const enabled = config?.engine?.enablePreDispatchEval ?? ENGINE_DEFAULTS.enablePreDispatchEval;
|
|
247
|
+
if (!enabled) return addToDispatch(item);
|
|
248
|
+
|
|
249
|
+
const wi = item?.meta?.item;
|
|
250
|
+
const criteria = wi && (wi.acceptance_criteria || wi.acceptanceCriteria);
|
|
251
|
+
if (!Array.isArray(criteria) || criteria.length === 0) {
|
|
252
|
+
return addToDispatch(item);
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const validate = typeof opts.validate === 'function'
|
|
256
|
+
? opts.validate
|
|
257
|
+
: require('./pre-dispatch-eval').validateAcceptanceCriteria;
|
|
258
|
+
|
|
259
|
+
let evaluation;
|
|
260
|
+
try {
|
|
261
|
+
evaluation = await validate(wi, { engineConfig: config?.engine });
|
|
262
|
+
} catch (e) {
|
|
263
|
+
log('warn', `pre-dispatch-eval: validator threw — failing open: ${e.message}`);
|
|
264
|
+
return addToDispatch(item);
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
if (!evaluation || evaluation.valid !== false) return addToDispatch(item);
|
|
268
|
+
|
|
269
|
+
_persistInvalidWorkItem(item, evaluation);
|
|
270
|
+
_routeToReviewQueue(item, evaluation);
|
|
271
|
+
log('warn', `pre-dispatch-eval: blocked work item ${wi.id} — ${evaluation.reason || 'criteria not actionable'}`);
|
|
272
|
+
return null;
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
|
|
177
276
|
function _resolveDispatchProject(projectRef, config) {
|
|
178
277
|
if (!projectRef) return null;
|
|
179
278
|
const projects = getProjects(config);
|
|
@@ -686,6 +785,7 @@ function cancelPendingWorkItems(wiPath, matchFn, reason) {
|
|
|
686
785
|
module.exports = {
|
|
687
786
|
mutateDispatch,
|
|
688
787
|
addToDispatch,
|
|
788
|
+
addToDispatchWithValidation,
|
|
689
789
|
getPrDispatchDedupeKey,
|
|
690
790
|
isRetryableFailureReason,
|
|
691
791
|
completeDispatch,
|
package/engine/lifecycle.js
CHANGED
|
@@ -1592,7 +1592,7 @@ async function detectPrFixBranchChange(meta, config) {
|
|
|
1592
1592
|
return { changed: null, beforeHead, afterHead: remoteHead || '', reason: 'unable to prove branch head after fix' };
|
|
1593
1593
|
}
|
|
1594
1594
|
|
|
1595
|
-
function recordPrNoOpFixAttempt(target, cause, source, dispatchItem, branchChange, config) {
|
|
1595
|
+
function recordPrNoOpFixAttempt(target, cause, source, dispatchItem, branchChange, config, noopReason) {
|
|
1596
1596
|
const evidenceFingerprint = shared.prFixEvidenceFingerprint(target, cause);
|
|
1597
1597
|
const prior = shared.getPrNoOpFixRecord(target, cause);
|
|
1598
1598
|
const sameEvidence = prior?.evidenceFingerprint === evidenceFingerprint;
|
|
@@ -1623,6 +1623,20 @@ function recordPrNoOpFixAttempt(target, cause, source, dispatchItem, branchChang
|
|
|
1623
1623
|
afterHead: branchChange?.afterHead || '',
|
|
1624
1624
|
};
|
|
1625
1625
|
|
|
1626
|
+
// Record a same-SHA dispatch outcome on the PR record so the eligibility
|
|
1627
|
+
// filter can short-circuit duplicate build-fix dispatches against an
|
|
1628
|
+
// unchanged commit. Reset happens implicitly when headSha advances and the
|
|
1629
|
+
// discovery filter compares lastDispatchHeadSha to the current head.
|
|
1630
|
+
const headSha = getPrFixBaselineHead(target);
|
|
1631
|
+
target.lastDispatchedAt = now;
|
|
1632
|
+
target.lastDispatchOutcome = 'noop';
|
|
1633
|
+
target.lastDispatchHeadSha = headSha;
|
|
1634
|
+
target.lastDispatchReason = String(
|
|
1635
|
+
noopReason
|
|
1636
|
+
|| branchChange?.reason
|
|
1637
|
+
|| 'fix completed without changing the PR branch'
|
|
1638
|
+
).slice(0, 500);
|
|
1639
|
+
|
|
1626
1640
|
if (cause === shared.PR_FIX_CAUSE.HUMAN_FEEDBACK && target.humanFeedback) {
|
|
1627
1641
|
target.humanFeedback.pendingFix = !paused;
|
|
1628
1642
|
if (paused) target.humanFeedback.noOpPaused = true;
|
|
@@ -1639,6 +1653,14 @@ function clearPrNoOpFixAttempt(target, cause) {
|
|
|
1639
1653
|
if (Object.keys(target._noOpFixes).length === 0) delete target._noOpFixes;
|
|
1640
1654
|
if (target._lastNoOpFix?.cause === cause) delete target._lastNoOpFix;
|
|
1641
1655
|
if (target.humanFeedback) delete target.humanFeedback.noOpPaused;
|
|
1656
|
+
// The lastDispatch* trackers exist to prevent duplicate noop dispatches at
|
|
1657
|
+
// the same head; once the agent actually pushed a fix we no longer want them
|
|
1658
|
+
// to suppress a fresh dispatch (the SHA may have moved or the next failure
|
|
1659
|
+
// is genuinely new).
|
|
1660
|
+
delete target.lastDispatchedAt;
|
|
1661
|
+
delete target.lastDispatchOutcome;
|
|
1662
|
+
delete target.lastDispatchHeadSha;
|
|
1663
|
+
delete target.lastDispatchReason;
|
|
1642
1664
|
}
|
|
1643
1665
|
|
|
1644
1666
|
function updatePrAfterFix(pr, project, source, options = {}, legacyDispatchId = '') {
|
|
@@ -1666,7 +1688,7 @@ function updatePrAfterFix(pr, project, source, options = {}, legacyDispatchId =
|
|
|
1666
1688
|
target.minionsReview = next;
|
|
1667
1689
|
};
|
|
1668
1690
|
if (explicitlyChangedBranch && options.branchChange?.changed === false) {
|
|
1669
|
-
const record = recordPrNoOpFixAttempt(target, cause, source, options.dispatchItem, options.branchChange, options.config);
|
|
1691
|
+
const record = recordPrNoOpFixAttempt(target, cause, source, options.dispatchItem, options.branchChange, options.config, options.noopReason);
|
|
1670
1692
|
result = { noOp: true, cause, paused: !!record.paused, count: record.count };
|
|
1671
1693
|
log('warn', `Updated ${pr.id} → recorded no-op ${cause} fix attempt ${record.count}${record.paused ? ' (paused)' : ''}; PR branch was unchanged`);
|
|
1672
1694
|
return prs;
|
|
@@ -1678,7 +1700,7 @@ function updatePrAfterFix(pr, project, source, options = {}, legacyDispatchId =
|
|
|
1678
1700
|
// automation cause handled — a future tick with working detection must
|
|
1679
1701
|
// be free to re-dispatch.
|
|
1680
1702
|
if (explicitlyChangedBranch && options.branchChange?.changed === null) {
|
|
1681
|
-
const record = recordPrNoOpFixAttempt(target, cause, source, options.dispatchItem, options.branchChange, options.config);
|
|
1703
|
+
const record = recordPrNoOpFixAttempt(target, cause, source, options.dispatchItem, options.branchChange, options.config, options.noopReason);
|
|
1682
1704
|
result = { noOp: true, cause, paused: !!record.paused, count: record.count, indeterminate: true };
|
|
1683
1705
|
log('warn', `Updated ${pr.id} → recorded indeterminate ${cause} fix attempt ${record.count}${record.paused ? ' (paused)' : ''}; PR branch advance could not be verified${options.branchChange?.reason ? ` (${options.branchChange.reason})` : ''}`);
|
|
1684
1706
|
return prs;
|
|
@@ -3208,6 +3230,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
|
|
|
3208
3230
|
dispatchItem,
|
|
3209
3231
|
branchChange: prFixBranchChange,
|
|
3210
3232
|
config,
|
|
3233
|
+
noopReason: noopRationale || meta?._noopReason || '',
|
|
3211
3234
|
});
|
|
3212
3235
|
// (#984) Sync PRD status for PR-linked features: fix work items have a different ID
|
|
3213
3236
|
// than the original PRD feature, so syncPrdItemStatus(fixWiId, ...) finds nothing.
|
package/engine/playbook.js
CHANGED
|
@@ -421,6 +421,25 @@ function renderPlaybook(type, vars) {
|
|
|
421
421
|
inertAppendices.push('\n\n---\n\n## Team Notes (MUST READ)\n\n' + notes);
|
|
422
422
|
}
|
|
423
423
|
|
|
424
|
+
// Inject per-agent memory file (knowledge/agents/<agentId>.md) — personal
|
|
425
|
+
// notebook curated by the consolidation pipeline. Capped at the same
|
|
426
|
+
// notes budget; missing file degrades gracefully (silent skip).
|
|
427
|
+
const agentIdForMemory = vars.agent_id;
|
|
428
|
+
if (agentIdForMemory && /^[a-z][a-z0-9-]{0,40}$/i.test(agentIdForMemory) && !String(agentIdForMemory).toLowerCase().startsWith('temp-')) {
|
|
429
|
+
const agentMemPath = path.join(MINIONS_DIR, 'knowledge', 'agents', `${String(agentIdForMemory).toLowerCase()}.md`);
|
|
430
|
+
let agentMem = '';
|
|
431
|
+
try { agentMem = fs.readFileSync(agentMemPath, 'utf8'); } catch { /* optional — file may not exist */ }
|
|
432
|
+
if (agentMem && agentMem.trim()) {
|
|
433
|
+
if (Buffer.byteLength(agentMem, 'utf8') > ENGINE_DEFAULTS.maxNotesPromptBytes) {
|
|
434
|
+
const sections = agentMem.split(/(?=^### )/m);
|
|
435
|
+
const recent = sections.slice(-10).join('') || agentMem;
|
|
436
|
+
const budget = Math.max(0, ENGINE_DEFAULTS.maxNotesPromptBytes);
|
|
437
|
+
agentMem = truncateTextBytes(recent, budget, '\n\n_...agent memory truncated_');
|
|
438
|
+
}
|
|
439
|
+
inertAppendices.push('\n\n---\n\n## Personal Memory (your past learnings — MUST READ)\n\n' + agentMem);
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
|
|
424
443
|
// Inject KB guardrail
|
|
425
444
|
content += `\n\n---\n\n## Knowledge Base Rules\n\n`;
|
|
426
445
|
content += `**Never delete, move, or overwrite files in \`knowledge/\`.** The sweep (consolidation engine) is the only process that writes to \`knowledge/\`. If you think a KB file is wrong, note it in your learnings file — do not touch \`knowledge/\` directly.\n`;
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engine/pre-dispatch-eval.js — Cheap LLM gate that screens work items for
|
|
3
|
+
* clear/actionable/testable acceptance criteria *before* they are queued for
|
|
4
|
+
* dispatch. Catches impossible or ambiguous items so a slow/expensive agent
|
|
5
|
+
* run isn't burned on something that can't succeed.
|
|
6
|
+
*
|
|
7
|
+
* Conservative scope (per task contract):
|
|
8
|
+
* - Validation only — never rewrites or "fixes" criteria.
|
|
9
|
+
* - Opt-in via `ENGINE_DEFAULTS.enablePreDispatchEval` (default false) so it
|
|
10
|
+
* can be enabled per-environment without forcing fleet-wide rollout.
|
|
11
|
+
* - Fail-open: any LLM error / runtime-unavailable / parse failure resolves
|
|
12
|
+
* `{ valid: true }` so the gate cannot wedge dispatch on its own.
|
|
13
|
+
*
|
|
14
|
+
* Wired from engine/dispatch.js → addToDispatchWithValidation().
|
|
15
|
+
*
|
|
16
|
+
* Source: Ripley §3 recommendation in
|
|
17
|
+
* knowledge/architecture/2026-05-11-ripley-daily-architecture-bug-review-ripley-s-investigati.md
|
|
18
|
+
* (Daily Architecture & Bug Review — 2026-05-11). Lambert + Rebecca debate
|
|
19
|
+
* rounds reaffirmed: validate-only, no auto-rewrite, opt-in flag.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
const shared = require('./shared');
|
|
23
|
+
const { log } = shared;
|
|
24
|
+
const { callLLM } = require('./llm');
|
|
25
|
+
|
|
26
|
+
const SYSTEM_PROMPT = 'Output only JSON.';
|
|
27
|
+
const DEFAULT_TIMEOUT_MS = 60000;
|
|
28
|
+
const DEFAULT_MODEL = 'haiku'; // claude shorthand; the runtime adapter expands it (see engine/runtimes/claude.js resolveModel)
|
|
29
|
+
|
|
30
|
+
function _extractCriteria(workItem) {
|
|
31
|
+
if (!workItem || typeof workItem !== 'object') return [];
|
|
32
|
+
const candidates = [workItem.acceptance_criteria, workItem.acceptanceCriteria];
|
|
33
|
+
for (const c of candidates) {
|
|
34
|
+
if (Array.isArray(c) && c.length > 0) return c.map(String).filter(Boolean);
|
|
35
|
+
}
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function _buildPrompt(workItem, criteria) {
|
|
40
|
+
const title = workItem.title || workItem.name || workItem.id || 'untitled';
|
|
41
|
+
const description = (workItem.description || '').trim();
|
|
42
|
+
const lines = [
|
|
43
|
+
`Work item: ${title}`,
|
|
44
|
+
];
|
|
45
|
+
if (description) lines.push('', 'Description:', description);
|
|
46
|
+
lines.push('', 'Acceptance criteria:');
|
|
47
|
+
for (const c of criteria) lines.push(`- ${c}`);
|
|
48
|
+
lines.push('',
|
|
49
|
+
'Are these acceptance criteria clear, actionable, and testable?',
|
|
50
|
+
'Reply with JSON: {"valid": true|false, "reason": "..."}.');
|
|
51
|
+
return lines.join('\n');
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function _parseResponse(text) {
|
|
55
|
+
let body = String(text || '').trim();
|
|
56
|
+
if (!body) return null;
|
|
57
|
+
const fence = body.match(/```(?:json)?\s*([\s\S]*?)```/i);
|
|
58
|
+
if (fence) body = fence[1].trim();
|
|
59
|
+
// Tolerate prose around the JSON object — grab the first top-level {...}
|
|
60
|
+
if (body[0] !== '{') {
|
|
61
|
+
const m = body.match(/\{[\s\S]*\}/);
|
|
62
|
+
if (m) body = m[0];
|
|
63
|
+
}
|
|
64
|
+
try { return JSON.parse(body); } catch { return null; }
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* Validate a work item's acceptance criteria with a fast/cheap LLM call.
|
|
69
|
+
*
|
|
70
|
+
* @param {object} workItem - work item with `acceptance_criteria` (or
|
|
71
|
+
* `acceptanceCriteria`) plus title/description for context.
|
|
72
|
+
* @param {object} [opts]
|
|
73
|
+
* @param {object} [opts.engineConfig] - passed through to callLLM for
|
|
74
|
+
* runtime/model resolution (CC path).
|
|
75
|
+
* @param {string} [opts.model] - explicit model override; defaults to 'haiku'.
|
|
76
|
+
* @param {number} [opts.timeout] - LLM timeout in ms.
|
|
77
|
+
* @returns {Promise<{valid: boolean, reason: string}>}
|
|
78
|
+
*/
|
|
79
|
+
async function validateAcceptanceCriteria(workItem, opts = {}) {
|
|
80
|
+
const criteria = _extractCriteria(workItem);
|
|
81
|
+
if (criteria.length === 0) {
|
|
82
|
+
return { valid: true, reason: 'no acceptance criteria to validate' };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const prompt = _buildPrompt(workItem, criteria);
|
|
86
|
+
let result;
|
|
87
|
+
try {
|
|
88
|
+
result = await callLLM(prompt, SYSTEM_PROMPT, {
|
|
89
|
+
timeout: Number(opts.timeout) > 0 ? Number(opts.timeout) : DEFAULT_TIMEOUT_MS,
|
|
90
|
+
label: 'pre-dispatch-eval',
|
|
91
|
+
model: opts.model || DEFAULT_MODEL,
|
|
92
|
+
maxTurns: 1,
|
|
93
|
+
direct: true,
|
|
94
|
+
engineConfig: opts.engineConfig,
|
|
95
|
+
});
|
|
96
|
+
} catch (e) {
|
|
97
|
+
log('warn', `pre-dispatch-eval: LLM call threw — failing open: ${e?.message || e}`);
|
|
98
|
+
return { valid: true, reason: `validator error: ${e?.message || 'unknown'}` };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!result) {
|
|
102
|
+
return { valid: true, reason: 'validator returned no result' };
|
|
103
|
+
}
|
|
104
|
+
if (result.missingRuntime) {
|
|
105
|
+
log('warn', 'pre-dispatch-eval: runtime unavailable — failing open');
|
|
106
|
+
return { valid: true, reason: 'validator runtime unavailable' };
|
|
107
|
+
}
|
|
108
|
+
if (result.code !== 0) {
|
|
109
|
+
log('warn', `pre-dispatch-eval: LLM exit ${result.code} — failing open: ${result.errorMessage || ''}`);
|
|
110
|
+
return { valid: true, reason: `validator exit ${result.code}` };
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const parsed = _parseResponse(result.text);
|
|
114
|
+
if (!parsed || typeof parsed.valid !== 'boolean') {
|
|
115
|
+
log('warn', 'pre-dispatch-eval: response missing boolean valid field — failing open');
|
|
116
|
+
return { valid: true, reason: 'validator response unparseable' };
|
|
117
|
+
}
|
|
118
|
+
return {
|
|
119
|
+
valid: parsed.valid,
|
|
120
|
+
reason: String(parsed.reason || '').trim() || (parsed.valid ? 'criteria look testable' : 'criteria not clear/actionable/testable'),
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
module.exports = {
|
|
125
|
+
validateAcceptanceCriteria,
|
|
126
|
+
// Exposed for unit testing — engine code MUST go through validateAcceptanceCriteria.
|
|
127
|
+
_extractCriteria,
|
|
128
|
+
_buildPrompt,
|
|
129
|
+
_parseResponse,
|
|
130
|
+
};
|
package/engine/shared.js
CHANGED
|
@@ -1099,6 +1099,7 @@ const ENGINE_DEFAULTS = {
|
|
|
1099
1099
|
ignoredCommentAuthors: [], // comments from these authors are auto-closed and never trigger fixes
|
|
1100
1100
|
agentBusyReassignMs: 600000, // 10min — reassign work item to another agent if preferred agent is busy beyond this threshold
|
|
1101
1101
|
ccEffort: null, // effort level for CC/doc-chat (null, 'low', 'medium', 'high')
|
|
1102
|
+
enablePreDispatchEval: false, // opt-in: cheap LLM gate before queueing — see engine/pre-dispatch-eval.js (Ripley §3 recommendation, 2026-05-11 architecture review)
|
|
1102
1103
|
|
|
1103
1104
|
// ── Runtime fleet (P-3b8e5f1d) ──────────────────────────────────────────────
|
|
1104
1105
|
// Single source of truth for which CLI runtime + model every spawn uses.
|
package/engine/spawn-agent.js
CHANGED
|
@@ -162,6 +162,81 @@ function formatProcessExitSentinel(exitCode, signal) {
|
|
|
162
162
|
return `\n[process-exit] code=${exitCode}${signal ? ` signal=${signal}` : ''}\n`;
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
+
/**
|
|
166
|
+
* Pre-push stale-HEAD guard for fix-task dispatches (P-c8f2d5e3).
|
|
167
|
+
*
|
|
168
|
+
* When the engine reuses an existing worktree on a PR branch that was rebased
|
|
169
|
+
* upstream (force-push), the local HEAD can sit behind origin/<branch>. The
|
|
170
|
+
* first push from that worktree silently overwrites the rebased history — a
|
|
171
|
+
* confirmed silent-overwrite footgun captured in team memory.
|
|
172
|
+
*
|
|
173
|
+
* This helper runs:
|
|
174
|
+
* git fetch origin <branch>
|
|
175
|
+
* git rev-list --count HEAD..origin/<branch>
|
|
176
|
+
* inside the worktree. When the count is > 0 it throws a clear, actionable
|
|
177
|
+
* error so engine.spawnAgent can abort the dispatch before invoking the
|
|
178
|
+
* runtime CLI — i.e. before the agent has a chance to push.
|
|
179
|
+
*
|
|
180
|
+
* The fetch is best-effort: if origin doesn't have the ref yet (first push on
|
|
181
|
+
* a fresh branch, common for shared-branch plan items), the helper returns
|
|
182
|
+
* `{ ok: true, skipped: 'no-upstream' }` instead of failing — there's no
|
|
183
|
+
* rebased tip to overwrite. Any other fetch failure is also treated as a
|
|
184
|
+
* skip with `skipped: 'fetch-failed'` so transient network issues don't
|
|
185
|
+
* brick an otherwise-healthy dispatch.
|
|
186
|
+
*
|
|
187
|
+
* @param {object} args
|
|
188
|
+
* @param {string} args.branch - PR branch name (already sanitized)
|
|
189
|
+
* @param {string} args.cwd - Worktree path
|
|
190
|
+
* @param {function} [args.exec] - Async exec(cmd, opts) — injectable for tests
|
|
191
|
+
* @param {object} [args.gitOpts] - Options passed through to exec
|
|
192
|
+
* @returns {Promise<{ok: true, behindCount: number, skipped?: string}>}
|
|
193
|
+
* @throws {Error & {code: 'STALE_HEAD'}} when local HEAD is behind origin
|
|
194
|
+
*/
|
|
195
|
+
async function assertStaleHeadOk({ branch, cwd, exec, gitOpts } = {}) {
|
|
196
|
+
if (!branch) throw new Error('assertStaleHeadOk: branch is required');
|
|
197
|
+
if (!cwd) throw new Error('assertStaleHeadOk: cwd is required');
|
|
198
|
+
const execFn = typeof exec === 'function'
|
|
199
|
+
? exec
|
|
200
|
+
: require('./shared').execAsync;
|
|
201
|
+
const opts = { ...(gitOpts || {}), cwd };
|
|
202
|
+
|
|
203
|
+
// Best-effort fetch. Branch-missing-on-origin is a legitimate state (first
|
|
204
|
+
// push on a freshly-cut feature branch) and must NOT block dispatch.
|
|
205
|
+
try {
|
|
206
|
+
await execFn(`git fetch origin "${branch}"`, opts);
|
|
207
|
+
} catch (err) {
|
|
208
|
+
const msg = (err && (err.stderr?.toString?.() || err.message || '')) + '';
|
|
209
|
+
if (/couldn'?t find remote ref|not found in upstream|unknown revision/i.test(msg)) {
|
|
210
|
+
return { ok: true, behindCount: 0, skipped: 'no-upstream' };
|
|
211
|
+
}
|
|
212
|
+
// Other failures (network/auth/timeout) — skip rather than block.
|
|
213
|
+
return { ok: true, behindCount: 0, skipped: 'fetch-failed' };
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
let countOut;
|
|
217
|
+
try {
|
|
218
|
+
countOut = await execFn(`git rev-list --count HEAD..origin/${branch}`, opts);
|
|
219
|
+
} catch (err) {
|
|
220
|
+
// origin/<branch> resolution failed AFTER fetch — treat as no-upstream.
|
|
221
|
+
return { ok: true, behindCount: 0, skipped: 'rev-list-failed' };
|
|
222
|
+
}
|
|
223
|
+
const raw = typeof countOut === 'string'
|
|
224
|
+
? countOut
|
|
225
|
+
: (countOut?.stdout?.toString?.() ?? String(countOut ?? ''));
|
|
226
|
+
const behindCount = parseInt(String(raw).trim(), 10);
|
|
227
|
+
if (!Number.isFinite(behindCount) || behindCount <= 0) {
|
|
228
|
+
return { ok: true, behindCount: Number.isFinite(behindCount) ? behindCount : 0 };
|
|
229
|
+
}
|
|
230
|
+
const err = new Error(
|
|
231
|
+
`PR branch was rebased; local HEAD is stale (${behindCount} commits behind origin). ` +
|
|
232
|
+
`Run \`git pull --rebase origin ${branch}\` first.`
|
|
233
|
+
);
|
|
234
|
+
err.code = 'STALE_HEAD';
|
|
235
|
+
err.behindCount = behindCount;
|
|
236
|
+
err.branch = branch;
|
|
237
|
+
throw err;
|
|
238
|
+
}
|
|
239
|
+
|
|
165
240
|
// The orphan reaper recovers an agent's exit code by scanning live-output.log for
|
|
166
241
|
// `[process-exit] code=N`. The previous design wrote the sentinel to stdout, hoping
|
|
167
242
|
// the engine's stdout consumer (engine.js) would copy it into the file — but when
|
|
@@ -456,6 +531,6 @@ function main() {
|
|
|
456
531
|
});
|
|
457
532
|
}
|
|
458
533
|
|
|
459
|
-
module.exports = { parseSpawnArgs, buildSpawnInvocation, normalizeRuntimeExit, shouldInjectAdoTokenEnv, injectAdoTokenEnv, injectAdoTokenEnvForRepoHost, writeProcessExitSentinel, computeAddDirs, createParentPipeForwarder };
|
|
534
|
+
module.exports = { parseSpawnArgs, buildSpawnInvocation, normalizeRuntimeExit, shouldInjectAdoTokenEnv, injectAdoTokenEnv, injectAdoTokenEnvForRepoHost, writeProcessExitSentinel, computeAddDirs, createParentPipeForwarder, assertStaleHeadOk };
|
|
460
535
|
|
|
461
536
|
if (require.main === module) main();
|
package/engine.js
CHANGED
|
@@ -28,6 +28,7 @@ const { exec, execAsync, execSilent, runFile, ts, ENGINE_DEFAULTS,
|
|
|
28
28
|
WI_STATUS, DONE_STATUSES, WORK_TYPE, PLAN_STATUS, PRD_ITEM_STATUS, PRD_MATERIALIZABLE, PR_STATUS, DISPATCH_RESULT, AGENT_STATUS,
|
|
29
29
|
FAILURE_CLASS } = shared;
|
|
30
30
|
const { resolveRuntime } = require('./engine/runtimes');
|
|
31
|
+
const { assertStaleHeadOk } = require('./engine/spawn-agent');
|
|
31
32
|
const queries = require('./engine/queries');
|
|
32
33
|
|
|
33
34
|
// ─── Paths ──────────────────────────────────────────────────────────────────
|
|
@@ -110,7 +111,7 @@ function isPipelineBranchName(branchName) {
|
|
|
110
111
|
|
|
111
112
|
// ─── Dispatch Management (extracted to engine/dispatch.js) ───────────────────
|
|
112
113
|
|
|
113
|
-
const { mutateDispatch, addToDispatch, isRetryableFailureReason, completeDispatch,
|
|
114
|
+
const { mutateDispatch, addToDispatch, addToDispatchWithValidation, isRetryableFailureReason, completeDispatch,
|
|
114
115
|
writeInboxAlert, updateAgentStatus, pruneStalePrDispatches } = require('./engine/dispatch');
|
|
115
116
|
|
|
116
117
|
// ─── Timeout / Steering / Idle (extracted to engine/timeout.js) ──────────────
|
|
@@ -1114,6 +1115,41 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
1114
1115
|
log('warn', `Agent ${agentId} running ${type} task in main repo (no worktree) for ${id} — changes may land on master directly`);
|
|
1115
1116
|
}
|
|
1116
1117
|
|
|
1118
|
+
// ── Stale-HEAD guard for fix-task pushes (P-c8f2d5e3) ────────────────────
|
|
1119
|
+
// When a PR branch is rebased upstream (force-push), a reused worktree can
|
|
1120
|
+
// sit on local HEAD that's behind origin/<branch>. The first push from that
|
|
1121
|
+
// worktree silently overwrites the rebased history. Fix-task dispatches are
|
|
1122
|
+
// the canonical case: they always target an existing PR branch the engine
|
|
1123
|
+
// already polled. Abort dispatch BEFORE invoking the runtime CLI so the
|
|
1124
|
+
// agent never gets a chance to push over the rebased tip.
|
|
1125
|
+
// Read-only and non-fix dispatches are out of scope — implement tasks cut
|
|
1126
|
+
// their own branch from main, and review/verify don't push.
|
|
1127
|
+
if (type === WORK_TYPE.FIX && branchName && worktreePath && cwd === worktreePath) {
|
|
1128
|
+
try {
|
|
1129
|
+
const guard = await assertStaleHeadOk({
|
|
1130
|
+
branch: branchName,
|
|
1131
|
+
cwd: worktreePath,
|
|
1132
|
+
exec: execAsync,
|
|
1133
|
+
gitOpts: { ..._gitOpts, timeout: 15000 },
|
|
1134
|
+
});
|
|
1135
|
+
if (guard.skipped) {
|
|
1136
|
+
log('info', `Stale-HEAD guard skipped for ${id} (${branchName}): ${guard.skipped}`);
|
|
1137
|
+
}
|
|
1138
|
+
} catch (err) {
|
|
1139
|
+
if (err && err.code === 'STALE_HEAD') {
|
|
1140
|
+
log('error', `Stale-HEAD guard rejected fix dispatch ${id} on ${branchName}: ${err.message}`);
|
|
1141
|
+
_cleanupPromptFiles();
|
|
1142
|
+
completeDispatch(id, DISPATCH_RESULT.ERROR, err.message.slice(0, 300));
|
|
1143
|
+
cleanupTempAgent(agentId);
|
|
1144
|
+
return null;
|
|
1145
|
+
}
|
|
1146
|
+
// Non-STALE_HEAD failures from the guard itself shouldn't block dispatch
|
|
1147
|
+
// (the guard is conservative by design — fetch/network issues fall through
|
|
1148
|
+
// to skipped:'fetch-failed'). Log and continue.
|
|
1149
|
+
log('warn', `Stale-HEAD guard error for ${id} (${branchName}): ${err.message}`);
|
|
1150
|
+
}
|
|
1151
|
+
}
|
|
1152
|
+
|
|
1117
1153
|
// ── Runtime + opts resolution (P-2a6d9c4f) ────────────────────────────────
|
|
1118
1154
|
// Every CLI-specific knob flows through the runtime adapter resolved from
|
|
1119
1155
|
// resolveAgentCli(agent, engine). Engine code MUST NOT branch on
|
|
@@ -2936,6 +2972,20 @@ async function discoverFromPrs(config, project) {
|
|
|
2936
2972
|
const autoFixBuilds = config.engine?.autoFixBuilds ?? ENGINE_DEFAULTS.autoFixBuilds;
|
|
2937
2973
|
if (pollEnabled && autoFixBuilds && pr.status === PR_STATUS.ACTIVE && pr.buildStatus === 'failing'
|
|
2938
2974
|
&& !isPrNoOpFixCauseSuppressed(pr, shared.PR_FIX_CAUSE.BUILD_FAILURE)) {
|
|
2975
|
+
// P-b7e1c4d2: skip when the most recent dispatch already noop'd against
|
|
2976
|
+
// the same head SHA — chronic across PRs #2315–#2323 where every fix
|
|
2977
|
+
// agent rebutted "this is a pre-existing master baseline" but the
|
|
2978
|
+
// cached buildStatus:failing kept re-triggering the loop. The check
|
|
2979
|
+
// clears automatically once a new commit lands (lastDispatchHeadSha
|
|
2980
|
+
// stops matching the current head).
|
|
2981
|
+
const currentHeadSha = String(pr.headSha || pr._adoSourceCommit || pr._adoHeadCommit || '').trim();
|
|
2982
|
+
if (pr.lastDispatchOutcome === 'noop'
|
|
2983
|
+
&& pr.lastDispatchHeadSha
|
|
2984
|
+
&& currentHeadSha
|
|
2985
|
+
&& pr.lastDispatchHeadSha === currentHeadSha) {
|
|
2986
|
+
log('info', `Skipping build-fix for ${pr.id}: last dispatch was noop on the same head ${currentHeadSha.slice(0, 8)} (${(pr.lastDispatchReason || '').slice(0, 120)})`);
|
|
2987
|
+
continue;
|
|
2988
|
+
}
|
|
2939
2989
|
const buildCauseKey = getPrAutomationCauseKey('build', pr);
|
|
2940
2990
|
const key = getPrAutomationDispatchKey(`build-fix-${project?.name || 'default'}-${prDisplayId}`, buildCauseKey);
|
|
2941
2991
|
if (isPrAutomationCauseHandledOrPending(project, pr, buildCauseKey)) continue;
|
|
@@ -4257,7 +4307,7 @@ async function discoverWork(config) {
|
|
|
4257
4307
|
const allWork = [...allFixes, ...allReviews, ...allWorkItems, ...centralWork];
|
|
4258
4308
|
|
|
4259
4309
|
for (const item of allWork) {
|
|
4260
|
-
|
|
4310
|
+
await addToDispatchWithValidation(item, { config });
|
|
4261
4311
|
if (item.meta?.dispatchKey) setCooldown(item.meta.dispatchKey);
|
|
4262
4312
|
if (item.meta?.source === 'pr-human-feedback') {
|
|
4263
4313
|
clearPendingHumanFeedbackFlag(item.meta.project, item.meta.pr?.id);
|
|
@@ -4977,7 +5027,7 @@ module.exports = {
|
|
|
4977
5027
|
validateConfig,
|
|
4978
5028
|
|
|
4979
5029
|
// Dispatch management (re-exported from engine/dispatch.js)
|
|
4980
|
-
mutateDispatch, addToDispatch, isRetryableFailureReason, completeDispatch, writeInboxAlert, updateAgentStatus, pruneStalePrDispatches,
|
|
5030
|
+
mutateDispatch, addToDispatch, addToDispatchWithValidation, isRetryableFailureReason, completeDispatch, writeInboxAlert, updateAgentStatus, pruneStalePrDispatches,
|
|
4981
5031
|
activeProcesses, realActivityMap, engineRestartGraceExempt,
|
|
4982
5032
|
get engineRestartGraceUntil() { return engineRestartGraceUntil; },
|
|
4983
5033
|
set engineRestartGraceUntil(v) { engineRestartGraceUntil = v; },
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1872",
|
|
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"
|