@yemi33/minions 0.1.1
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 +819 -0
- package/LICENSE +21 -0
- package/README.md +598 -0
- package/agents/dallas/charter.md +56 -0
- package/agents/lambert/charter.md +67 -0
- package/agents/ralph/charter.md +45 -0
- package/agents/rebecca/charter.md +57 -0
- package/agents/ripley/charter.md +47 -0
- package/bin/minions.js +467 -0
- package/config.template.json +28 -0
- package/dashboard.html +4822 -0
- package/dashboard.js +2623 -0
- package/docs/auto-discovery.md +416 -0
- package/docs/blog-first-successful-dispatch.md +128 -0
- package/docs/command-center.md +156 -0
- package/docs/demo/01-dashboard-overview.gif +0 -0
- package/docs/demo/02-command-center.gif +0 -0
- package/docs/demo/03-work-items.gif +0 -0
- package/docs/demo/04-plan-docchat.gif +0 -0
- package/docs/demo/05-prd-progress.gif +0 -0
- package/docs/demo/06-inbox-metrics.gif +0 -0
- package/docs/deprecated.json +83 -0
- package/docs/distribution.md +96 -0
- package/docs/engine-restart.md +92 -0
- package/docs/human-vs-automated.md +108 -0
- package/docs/index.html +221 -0
- package/docs/plan-lifecycle.md +140 -0
- package/docs/self-improvement.md +344 -0
- package/engine/ado-mcp-wrapper.js +42 -0
- package/engine/ado.js +383 -0
- package/engine/check-status.js +23 -0
- package/engine/cli.js +754 -0
- package/engine/consolidation.js +417 -0
- package/engine/github.js +331 -0
- package/engine/lifecycle.js +1113 -0
- package/engine/llm.js +116 -0
- package/engine/queries.js +677 -0
- package/engine/shared.js +397 -0
- package/engine/spawn-agent.js +151 -0
- package/engine.js +3227 -0
- package/minions.js +556 -0
- package/package.json +48 -0
- package/playbooks/ask.md +49 -0
- package/playbooks/build-and-test.md +155 -0
- package/playbooks/explore.md +64 -0
- package/playbooks/fix.md +57 -0
- package/playbooks/implement-shared.md +68 -0
- package/playbooks/implement.md +95 -0
- package/playbooks/plan-to-prd.md +104 -0
- package/playbooks/plan.md +99 -0
- package/playbooks/review.md +68 -0
- package/playbooks/test.md +75 -0
- package/playbooks/verify.md +190 -0
- package/playbooks/work-item.md +74 -0
package/engine.js
ADDED
|
@@ -0,0 +1,3227 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Minions Engine — Auto-assigning orchestrator
|
|
4
|
+
*
|
|
5
|
+
* Discovers work from configurable sources (PRD gaps, PR tracker, manual queue),
|
|
6
|
+
* renders playbook templates with agent charters as system prompts, and spawns
|
|
7
|
+
* isolated `claude` CLI processes in git worktrees.
|
|
8
|
+
*
|
|
9
|
+
* Usage:
|
|
10
|
+
* node .minions/engine.js Start the engine (daemon mode)
|
|
11
|
+
* node .minions/engine.js status Show current state
|
|
12
|
+
* node .minions/engine.js pause Pause dispatching
|
|
13
|
+
* node .minions/engine.js resume Resume dispatching
|
|
14
|
+
* node .minions/engine.js stop Stop the engine
|
|
15
|
+
* node .minions/engine.js queue Show dispatch queue
|
|
16
|
+
* node .minions/engine.js dispatch Force a dispatch cycle
|
|
17
|
+
* node .minions/engine.js complete <id> Mark dispatch as done
|
|
18
|
+
* node .minions/engine.js spawn <agent> <prompt> Manually spawn an agent
|
|
19
|
+
* node .minions/engine.js work <title> [opts-json] Add to work queue
|
|
20
|
+
* node .minions/engine.js sources Show work source status
|
|
21
|
+
* node .minions/engine.js discover Dry-run work discovery
|
|
22
|
+
*/
|
|
23
|
+
|
|
24
|
+
const fs = require('fs');
|
|
25
|
+
const path = require('path');
|
|
26
|
+
const shared = require('./engine/shared');
|
|
27
|
+
const { exec, execSilent, runFile, ENGINE_DEFAULTS: DEFAULTS } = shared;
|
|
28
|
+
const queries = require('./engine/queries');
|
|
29
|
+
|
|
30
|
+
// ─── Paths ──────────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
const MINIONS_DIR = __dirname;
|
|
33
|
+
const ROUTING_PATH = path.join(MINIONS_DIR, 'routing.md');
|
|
34
|
+
const PLAYBOOKS_DIR = path.join(MINIONS_DIR, 'playbooks');
|
|
35
|
+
const ARCHIVE_DIR = path.join(MINIONS_DIR, 'notes', 'archive');
|
|
36
|
+
const IDENTITY_DIR = path.join(MINIONS_DIR, 'identity');
|
|
37
|
+
|
|
38
|
+
// Re-export from queries for internal use (avoid changing every call site)
|
|
39
|
+
const { CONFIG_PATH, NOTES_PATH, AGENTS_DIR, ENGINE_DIR, CONTROL_PATH,
|
|
40
|
+
DISPATCH_PATH, LOG_PATH, INBOX_DIR, KNOWLEDGE_DIR, PLANS_DIR, PRD_DIR } = queries;
|
|
41
|
+
|
|
42
|
+
// ─── Multi-Project Support ──────────────────────────────────────────────────
|
|
43
|
+
// Config can have either:
|
|
44
|
+
// "project": { ... } — single project (legacy, .minions inside repo)
|
|
45
|
+
// "projects": [ { ... }, ... ] — multi-project (central .minions)
|
|
46
|
+
// Each project must have "localPath" pointing to the repo root.
|
|
47
|
+
|
|
48
|
+
function validateConfig(config) {
|
|
49
|
+
let errors = 0;
|
|
50
|
+
// Agents
|
|
51
|
+
if (!config.agents || Object.keys(config.agents).length === 0) {
|
|
52
|
+
console.error('FATAL: No agents defined in config.json');
|
|
53
|
+
errors++;
|
|
54
|
+
}
|
|
55
|
+
// Projects
|
|
56
|
+
const projects = getProjects(config);
|
|
57
|
+
if (projects.length === 0) {
|
|
58
|
+
console.error('FATAL: No projects configured');
|
|
59
|
+
errors++;
|
|
60
|
+
}
|
|
61
|
+
for (const p of projects) {
|
|
62
|
+
if (!p.localPath || !fs.existsSync(path.resolve(p.localPath))) {
|
|
63
|
+
console.error(`WARN: Project "${p.name}" path not found: ${p.localPath}`);
|
|
64
|
+
}
|
|
65
|
+
if (!p.repositoryId) {
|
|
66
|
+
console.warn(`WARN: Project "${p.name}" missing repositoryId — PR operations will fail`);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// Playbooks
|
|
70
|
+
const requiredPlaybooks = ['implement', 'review', 'fix', 'work-item'];
|
|
71
|
+
for (const pb of requiredPlaybooks) {
|
|
72
|
+
if (!fs.existsSync(path.join(PLAYBOOKS_DIR, `${pb}.md`))) {
|
|
73
|
+
console.error(`WARN: Missing playbook: playbooks/${pb}.md`);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
// Routing
|
|
77
|
+
if (!fs.existsSync(ROUTING_PATH)) {
|
|
78
|
+
console.error('WARN: routing.md not found — agent routing will use fallbacks only');
|
|
79
|
+
}
|
|
80
|
+
if (errors > 0) {
|
|
81
|
+
console.error(`\n${errors} fatal config error(s) — exiting.`);
|
|
82
|
+
process.exit(1);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
const { getProjects, projectRoot, projectStateDir, projectWorkItemsPath, projectPrPath, getAdoOrgBase, sanitizeBranch, parseSkillFrontmatter, safeReadDir } = shared;
|
|
87
|
+
|
|
88
|
+
// ─── Utilities ──────────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
function ts() { return new Date().toISOString(); }
|
|
91
|
+
function logTs() { return new Date().toLocaleTimeString(); }
|
|
92
|
+
function dateStamp() { return new Date().toISOString().slice(0, 10); }
|
|
93
|
+
|
|
94
|
+
const safeJson = shared.safeJson;
|
|
95
|
+
const safeRead = shared.safeRead;
|
|
96
|
+
const safeWrite = shared.safeWrite;
|
|
97
|
+
const mutateJsonFileLocked = shared.mutateJsonFileLocked;
|
|
98
|
+
|
|
99
|
+
function log(level, msg, meta = {}) {
|
|
100
|
+
const entry = { timestamp: ts(), level, message: msg, ...meta };
|
|
101
|
+
console.log(`[${logTs()}] [${level}] ${msg}`);
|
|
102
|
+
|
|
103
|
+
let logData = safeJson(LOG_PATH) || [];
|
|
104
|
+
if (!Array.isArray(logData)) logData = logData.entries || [];
|
|
105
|
+
logData.push(entry);
|
|
106
|
+
if (logData.length > 500) logData.splice(0, logData.length - 500);
|
|
107
|
+
safeWrite(LOG_PATH, logData);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function mutateDispatch(mutator) {
|
|
111
|
+
const defaultDispatch = { pending: [], active: [], completed: [] };
|
|
112
|
+
return mutateJsonFileLocked(DISPATCH_PATH, (dispatch) => {
|
|
113
|
+
dispatch.pending = Array.isArray(dispatch.pending) ? dispatch.pending : [];
|
|
114
|
+
dispatch.active = Array.isArray(dispatch.active) ? dispatch.active : [];
|
|
115
|
+
dispatch.completed = Array.isArray(dispatch.completed) ? dispatch.completed : [];
|
|
116
|
+
return mutator(dispatch) || dispatch;
|
|
117
|
+
}, { defaultValue: defaultDispatch });
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ─── State Readers (delegated to engine/queries.js) ─────────────────────────
|
|
121
|
+
|
|
122
|
+
const { getConfig, getControl, getDispatch, getNotes,
|
|
123
|
+
getAgentStatus, getAgentCharter, getInboxFiles,
|
|
124
|
+
collectSkillFiles, getSkillIndex, getKnowledgeBaseIndex,
|
|
125
|
+
getPrs, SKILLS_DIR } = queries;
|
|
126
|
+
|
|
127
|
+
function getRouting() {
|
|
128
|
+
return safeRead(ROUTING_PATH);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
// ─── Routing Parser ─────────────────────────────────────────────────────────
|
|
132
|
+
|
|
133
|
+
let _routingCache = null;
|
|
134
|
+
let _routingCacheMtime = 0;
|
|
135
|
+
|
|
136
|
+
function parseRoutingTable() {
|
|
137
|
+
const content = getRouting();
|
|
138
|
+
const routes = {};
|
|
139
|
+
const lines = content.split('\n');
|
|
140
|
+
let inTable = false;
|
|
141
|
+
|
|
142
|
+
for (const line of lines) {
|
|
143
|
+
if (line.startsWith('| Work Type')) { inTable = true; continue; }
|
|
144
|
+
if (line.startsWith('|---')) continue;
|
|
145
|
+
if (!inTable || !line.startsWith('|')) {
|
|
146
|
+
if (inTable && !line.startsWith('|')) inTable = false;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
const cells = line.split('|').map(c => c.trim()).filter(Boolean);
|
|
150
|
+
if (cells.length >= 3) {
|
|
151
|
+
routes[cells[0].toLowerCase()] = {
|
|
152
|
+
preferred: cells[1].toLowerCase(),
|
|
153
|
+
fallback: cells[2].toLowerCase()
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
return routes;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
function getRoutingTableCached() {
|
|
161
|
+
let mtime = 0;
|
|
162
|
+
try { mtime = fs.statSync(ROUTING_PATH).mtimeMs; } catch {}
|
|
163
|
+
if (_routingCache && _routingCacheMtime === mtime) return _routingCache;
|
|
164
|
+
_routingCache = parseRoutingTable();
|
|
165
|
+
_routingCacheMtime = mtime;
|
|
166
|
+
return _routingCache;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function getAgentErrorRate(agentId) {
|
|
170
|
+
const metricsPath = path.join(ENGINE_DIR, 'metrics.json');
|
|
171
|
+
const metrics = safeJson(metricsPath) || {};
|
|
172
|
+
const m = metrics[agentId];
|
|
173
|
+
if (!m) return 0;
|
|
174
|
+
const total = m.tasksCompleted + m.tasksErrored;
|
|
175
|
+
return total > 0 ? m.tasksErrored / total : 0;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function isAgentIdle(agentId) {
|
|
179
|
+
// Dispatch queue is the single source of truth for agent availability
|
|
180
|
+
const dispatch = safeJson(DISPATCH_PATH) || {};
|
|
181
|
+
return !(dispatch.active || []).some(d => d.agent === agentId);
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Track agents claimed during a single discovery pass to distribute work
|
|
185
|
+
const _claimedAgents = new Set();
|
|
186
|
+
function resetClaimedAgents() { _claimedAgents.clear(); }
|
|
187
|
+
|
|
188
|
+
function resolveAgent(workType, config, authorAgent = null) {
|
|
189
|
+
const routes = getRoutingTableCached();
|
|
190
|
+
const route = routes[workType] || routes['implement'];
|
|
191
|
+
const agents = config.agents || {};
|
|
192
|
+
|
|
193
|
+
// Resolve _author_ token
|
|
194
|
+
let preferred = route.preferred === '_author_' ? authorAgent : route.preferred;
|
|
195
|
+
let fallback = route.fallback === '_author_' ? authorAgent : route.fallback;
|
|
196
|
+
|
|
197
|
+
const isAvailable = (id) => agents[id] && isAgentIdle(id) && !_claimedAgents.has(id);
|
|
198
|
+
|
|
199
|
+
// Check preferred and fallback first (routing table order)
|
|
200
|
+
if (preferred && isAvailable(preferred)) { _claimedAgents.add(preferred); return preferred; }
|
|
201
|
+
if (fallback && isAvailable(fallback)) { _claimedAgents.add(fallback); return fallback; }
|
|
202
|
+
|
|
203
|
+
// Fall back to any idle agent, preferring lower error rates
|
|
204
|
+
const idle = Object.keys(agents)
|
|
205
|
+
.filter(id => id !== preferred && id !== fallback && isAvailable(id))
|
|
206
|
+
.sort((a, b) => getAgentErrorRate(a) - getAgentErrorRate(b));
|
|
207
|
+
|
|
208
|
+
if (idle[0]) { _claimedAgents.add(idle[0]); return idle[0]; }
|
|
209
|
+
|
|
210
|
+
// No idle agent available — return null, item stays pending until next tick
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ─── Task Context Resolution ────────────────────────────────────────────────
|
|
215
|
+
// Resolves implicit references in task descriptions (e.g., "ripley's plan",
|
|
216
|
+
// "dallas's PR") to actual artifacts and injects their content.
|
|
217
|
+
|
|
218
|
+
function resolveTaskContext(item, config) {
|
|
219
|
+
const title = (item.title || '').toLowerCase();
|
|
220
|
+
const desc = (item.description || '').toLowerCase();
|
|
221
|
+
const text = title + ' ' + desc;
|
|
222
|
+
const agentNames = Object.entries(config.agents || {}).map(([id, a]) => ({
|
|
223
|
+
id,
|
|
224
|
+
name: (a.name || id).toLowerCase(),
|
|
225
|
+
}));
|
|
226
|
+
const resolved = { additionalContext: '', referencedFiles: [] };
|
|
227
|
+
|
|
228
|
+
// Match agent references: "ripley's plan", "dallas's pr", "lambert's output", etc.
|
|
229
|
+
for (const agent of agentNames) {
|
|
230
|
+
const patterns = [
|
|
231
|
+
new RegExp(`${agent.name}(?:'s|s)?\\s+plan`, 'i'),
|
|
232
|
+
new RegExp(`${agent.id}(?:'s|s)?\\s+plan`, 'i'),
|
|
233
|
+
new RegExp(`plan\\s+(?:created|made|written|generated)\\s+by\\s+${agent.name}`, 'i'),
|
|
234
|
+
new RegExp(`plan\\s+(?:created|made|written|generated)\\s+by\\s+${agent.id}`, 'i'),
|
|
235
|
+
];
|
|
236
|
+
const matchesPlan = patterns.some(p => p.test(text));
|
|
237
|
+
if (matchesPlan) {
|
|
238
|
+
// Find plans created by this agent (check work items for plan tasks dispatched to this agent)
|
|
239
|
+
try {
|
|
240
|
+
const plans = fs.readdirSync(path.join(MINIONS_DIR, 'plans')).filter(f => f.endsWith('.md') || f.endsWith('.json'));
|
|
241
|
+
// Check work-items to find which plan file this agent created
|
|
242
|
+
const workItems = safeJson(path.join(MINIONS_DIR, 'work-items.json')) || [];
|
|
243
|
+
const agentPlanItems = workItems.filter(w =>
|
|
244
|
+
w.type === 'plan' && w.dispatched_to === agent.id && w.status === 'done' && w._planFileName
|
|
245
|
+
).sort((a, b) => (b.completedAt || '').localeCompare(a.completedAt || ''));
|
|
246
|
+
|
|
247
|
+
if (agentPlanItems.length > 0) {
|
|
248
|
+
const planFile = agentPlanItems[0]._planFileName;
|
|
249
|
+
const planPath = path.join(MINIONS_DIR, 'plans', planFile);
|
|
250
|
+
try {
|
|
251
|
+
const content = safeRead(planPath);
|
|
252
|
+
resolved.additionalContext += `\n\n## Referenced Plan: ${planFile} (created by ${agent.name})\n\n${content}`;
|
|
253
|
+
resolved.referencedFiles.push(planPath);
|
|
254
|
+
log('info', `Context resolution: found plan "${planFile}" by ${agent.name} for work item ${item.id}`);
|
|
255
|
+
} catch {}
|
|
256
|
+
} else if (plans.length > 0) {
|
|
257
|
+
// Fallback: try to find a plan file with the agent's name or ID in it
|
|
258
|
+
const match = plans.find(f => f.toLowerCase().includes(agent.id) || f.toLowerCase().includes(agent.name));
|
|
259
|
+
if (match) {
|
|
260
|
+
const planPath = path.join(MINIONS_DIR, 'plans', match);
|
|
261
|
+
try {
|
|
262
|
+
const content = safeRead(planPath);
|
|
263
|
+
resolved.additionalContext += `\n\n## Referenced Plan: ${match}\n\n${content}`;
|
|
264
|
+
resolved.referencedFiles.push(planPath);
|
|
265
|
+
log('info', `Context resolution: found plan "${match}" (name match) for work item ${item.id}`);
|
|
266
|
+
} catch {}
|
|
267
|
+
}
|
|
268
|
+
}
|
|
269
|
+
} catch {}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Match agent output/notes references
|
|
273
|
+
const outputPatterns = [
|
|
274
|
+
new RegExp(`${agent.name}(?:'s|s)?\\s+(?:output|findings|notes|results)`, 'i'),
|
|
275
|
+
new RegExp(`(?:output|findings|notes|results)\\s+(?:from|by)\\s+${agent.name}`, 'i'),
|
|
276
|
+
];
|
|
277
|
+
if (outputPatterns.some(p => p.test(text))) {
|
|
278
|
+
// Find the agent's latest inbox notes
|
|
279
|
+
try {
|
|
280
|
+
const inboxDir = path.join(MINIONS_DIR, 'notes', 'inbox');
|
|
281
|
+
const files = fs.readdirSync(inboxDir)
|
|
282
|
+
.filter(f => f.startsWith(agent.id + '-'))
|
|
283
|
+
.sort().reverse();
|
|
284
|
+
if (files.length > 0) {
|
|
285
|
+
const content = safeRead(path.join(inboxDir, files[0]));
|
|
286
|
+
resolved.additionalContext += `\n\n## Referenced Notes by ${agent.name}: ${files[0]}\n\n${content.slice(0, 5000)}`;
|
|
287
|
+
resolved.referencedFiles.push(path.join(inboxDir, files[0]));
|
|
288
|
+
log('info', `Context resolution: found notes "${files[0]}" by ${agent.name} for work item ${item.id}`);
|
|
289
|
+
}
|
|
290
|
+
} catch {}
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// If no specific reference was resolved but the text mentions "the plan" or "latest plan",
|
|
295
|
+
// find the most recent plan
|
|
296
|
+
if (!resolved.additionalContext && /\b(the|latest|last|recent)\s+plan\b/i.test(text)) {
|
|
297
|
+
try {
|
|
298
|
+
const plans = fs.readdirSync(path.join(MINIONS_DIR, 'plans'))
|
|
299
|
+
.filter(f => f.endsWith('.md') || f.endsWith('.json'))
|
|
300
|
+
.sort().reverse();
|
|
301
|
+
if (plans.length > 0) {
|
|
302
|
+
const planPath = path.join(MINIONS_DIR, 'plans', plans[0]);
|
|
303
|
+
const content = safeRead(planPath);
|
|
304
|
+
resolved.additionalContext += `\n\n## Referenced Plan (latest): ${plans[0]}\n\n${content}`;
|
|
305
|
+
resolved.referencedFiles.push(planPath);
|
|
306
|
+
log('info', `Context resolution: using latest plan "${plans[0]}" for work item ${item.id}`);
|
|
307
|
+
}
|
|
308
|
+
} catch {}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
return resolved;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
// ─── Playbook Renderer ──────────────────────────────────────────────────────
|
|
315
|
+
|
|
316
|
+
function renderPlaybook(type, vars) {
|
|
317
|
+
const pbPath = path.join(PLAYBOOKS_DIR, `${type}.md`);
|
|
318
|
+
let content;
|
|
319
|
+
try { content = fs.readFileSync(pbPath, 'utf8'); } catch {
|
|
320
|
+
log('warn', `Playbook not found: ${type}`);
|
|
321
|
+
return null;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
// Inject team notes context
|
|
325
|
+
const notes = getNotes();
|
|
326
|
+
if (notes) {
|
|
327
|
+
content += '\n\n---\n\n## Team Notes (MUST READ)\n\n' + notes;
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
// Inject KB guardrail
|
|
331
|
+
content += `\n\n---\n\n## Knowledge Base Rules\n\n`;
|
|
332
|
+
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`;
|
|
333
|
+
|
|
334
|
+
// Inject learnings requirement
|
|
335
|
+
content += `\n\n---\n\n## REQUIRED: Write Learnings\n\n`;
|
|
336
|
+
content += `After completing your task, you MUST write a findings/learnings file to:\n`;
|
|
337
|
+
content += `\`${MINIONS_DIR}/notes/inbox/${vars.agent_id || 'agent'}-${dateStamp()}.md\`\n\n`;
|
|
338
|
+
content += `Include:\n`;
|
|
339
|
+
content += `- What you learned about the codebase\n`;
|
|
340
|
+
content += `- Patterns you discovered or established\n`;
|
|
341
|
+
content += `- Gotchas or warnings for future agents\n`;
|
|
342
|
+
content += `- Conventions to follow\n`;
|
|
343
|
+
content += `- **SOURCE REFERENCES for every finding** — file paths with line numbers, PR URLs, API endpoints, config keys. Format: \`(source: path/to/file.ts:42)\` or \`(source: PR-12345)\`. Without references, findings cannot be verified.\n\n`;
|
|
344
|
+
content += `### Skill Extraction (IMPORTANT)\n\n`;
|
|
345
|
+
content += `If during this task you discovered a **repeatable workflow** — a multi-step procedure, workaround, build process, or pattern that other agents should follow in similar situations — output it as a fenced skill block. The engine will automatically extract it.\n\n`;
|
|
346
|
+
content += `Format your skill as a fenced code block with the \`skill\` language tag:\n\n`;
|
|
347
|
+
content += '````\n```skill\n';
|
|
348
|
+
content += `---\nname: short-descriptive-name\ndescription: One-line description of what this skill does\nallowed-tools: Bash, Read, Edit\ntrigger: when should an agent use this\nscope: minions\nproject: any\n---\n\n# Skill Title\n\n## Steps\n1. ...\n2. ...\n\n## Notes\n...\n`;
|
|
349
|
+
content += '```\n````\n\n';
|
|
350
|
+
content += `- Set \`scope: minions\` for cross-project skills (engine writes to ~/.claude/skills/ automatically)\n`;
|
|
351
|
+
content += `- Set \`scope: project\` + \`project: <name>\` for repo-specific skills (engine queues a PR to <project>/.claude/skills/)\n`;
|
|
352
|
+
content += `- Only output a skill block if you genuinely discovered something reusable — don't force it\n`;
|
|
353
|
+
|
|
354
|
+
// Inject project-level variables from config
|
|
355
|
+
const config = getConfig();
|
|
356
|
+
const projects = getProjects(config);
|
|
357
|
+
// Find the specific project being dispatched (match by repo_id or repo_name from vars)
|
|
358
|
+
const dispatchProject = (vars.repo_id && projects.find(p => p.repositoryId === vars.repo_id))
|
|
359
|
+
|| (vars.repo_name && projects.find(p => p.repoName === vars.repo_name))
|
|
360
|
+
|| projects[0] || {};
|
|
361
|
+
const projectVars = {
|
|
362
|
+
project_name: dispatchProject.name || 'Unknown Project',
|
|
363
|
+
ado_org: dispatchProject.adoOrg || 'Unknown',
|
|
364
|
+
ado_project: dispatchProject.adoProject || 'Unknown',
|
|
365
|
+
repo_name: dispatchProject.repoName || 'Unknown',
|
|
366
|
+
pr_create_instructions: getPrCreateInstructions(dispatchProject),
|
|
367
|
+
pr_comment_instructions: getPrCommentInstructions(dispatchProject),
|
|
368
|
+
pr_fetch_instructions: getPrFetchInstructions(dispatchProject),
|
|
369
|
+
pr_vote_instructions: getPrVoteInstructions(dispatchProject),
|
|
370
|
+
repo_host_label: getRepoHostLabel(dispatchProject),
|
|
371
|
+
};
|
|
372
|
+
const allVars = { ...projectVars, ...vars };
|
|
373
|
+
|
|
374
|
+
// Substitute variables
|
|
375
|
+
for (const [key, val] of Object.entries(allVars)) {
|
|
376
|
+
content = content.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), String(val));
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
return content;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// ─── Repo Host Helpers ──────────────────────────────────────────────────────
|
|
383
|
+
|
|
384
|
+
function getRepoHost(project) {
|
|
385
|
+
return project?.repoHost || 'ado';
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
function getPrCreateInstructions(project) {
|
|
389
|
+
const host = getRepoHost(project);
|
|
390
|
+
const repoId = project?.repositoryId || '';
|
|
391
|
+
if (host === 'github') {
|
|
392
|
+
return `Use \`gh pr create\` or the GitHub MCP tools to create a pull request.`;
|
|
393
|
+
}
|
|
394
|
+
// Default: Azure DevOps
|
|
395
|
+
return `Use \`mcp__azure-ado__repo_create_pull_request\`:\n- repositoryId: \`${repoId}\``;
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
function getPrCommentInstructions(project) {
|
|
399
|
+
const host = getRepoHost(project);
|
|
400
|
+
const repoId = project?.repositoryId || '';
|
|
401
|
+
if (host === 'github') {
|
|
402
|
+
return `Use \`gh pr comment\` or the GitHub MCP tools to post a comment on the PR.`;
|
|
403
|
+
}
|
|
404
|
+
return `Use \`mcp__azure-ado__repo_create_pull_request_thread\`:\n- repositoryId: \`${repoId}\``;
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
function getPrFetchInstructions(project) {
|
|
408
|
+
const host = getRepoHost(project);
|
|
409
|
+
if (host === 'github') {
|
|
410
|
+
return `Use \`gh pr view\` or the GitHub MCP tools to fetch PR status.`;
|
|
411
|
+
}
|
|
412
|
+
return `Use \`mcp__azure-ado__repo_get_pull_request_by_id\` to fetch PR status.`;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
function getPrVoteInstructions(project) {
|
|
416
|
+
const host = getRepoHost(project);
|
|
417
|
+
const repoId = project?.repositoryId || '';
|
|
418
|
+
if (host === 'github') {
|
|
419
|
+
return `Use \`gh pr review\` to approve or request changes:\n- \`gh pr review <number> --approve\`\n- \`gh pr review <number> --request-changes\``;
|
|
420
|
+
}
|
|
421
|
+
return `Use \`mcp__azure-ado__repo_update_pull_request_reviewers\`:\n- repositoryId: \`${repoId}\`\n- Set your reviewer vote on the PR (10=approve, 5=approve-with-suggestions, -10=reject)`;
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
function getRepoHostLabel(project) {
|
|
425
|
+
const host = getRepoHost(project);
|
|
426
|
+
if (host === 'github') return 'GitHub';
|
|
427
|
+
return 'Azure DevOps';
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function getRepoHostToolRule(project) {
|
|
431
|
+
const host = getRepoHost(project);
|
|
432
|
+
if (host === 'github') return 'Use GitHub MCP tools or `gh` CLI for PR operations';
|
|
433
|
+
return 'Use Azure DevOps MCP tools (mcp__azure-ado__*) for PR operations — NEVER use gh CLI';
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// ─── System Prompt Builder ──────────────────────────────────────────────────
|
|
437
|
+
|
|
438
|
+
// Lean system prompt: agent identity + rules only (~2-4KB, never grows)
|
|
439
|
+
function buildSystemPrompt(agentId, config, project) {
|
|
440
|
+
const agent = config.agents[agentId];
|
|
441
|
+
const charter = getAgentCharter(agentId);
|
|
442
|
+
project = project || getProjects(config)[0] || {};
|
|
443
|
+
|
|
444
|
+
let prompt = '';
|
|
445
|
+
|
|
446
|
+
// Agent identity
|
|
447
|
+
prompt += `# You are ${agent.name} (${agent.role})\n\n`;
|
|
448
|
+
prompt += `Agent ID: ${agentId}\n`;
|
|
449
|
+
prompt += `Skills: ${(agent.skills || []).join(', ')}\n\n`;
|
|
450
|
+
|
|
451
|
+
// Charter (detailed instructions — typically 1-2KB)
|
|
452
|
+
if (charter) {
|
|
453
|
+
prompt += `## Your Charter\n\n${charter}\n\n`;
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
// Project context (fixed size)
|
|
457
|
+
prompt += `## Project: ${project.name || 'Unknown Project'}\n\n`;
|
|
458
|
+
prompt += `- Repo: ${project.repoName || 'Unknown'} (${project.adoOrg || 'Unknown'}/${project.adoProject || 'Unknown'})\n`;
|
|
459
|
+
prompt += `- Repo ID: ${project.repositoryId || ''}\n`;
|
|
460
|
+
prompt += `- Repo host: ${getRepoHostLabel(project)}\n`;
|
|
461
|
+
prompt += `- Main branch: ${project.mainBranch || 'main'}\n\n`;
|
|
462
|
+
|
|
463
|
+
// Critical rules (fixed size)
|
|
464
|
+
prompt += `## Critical Rules\n\n`;
|
|
465
|
+
prompt += `1. Use git worktrees — NEVER checkout on main working tree\n`;
|
|
466
|
+
prompt += `2. ${getRepoHostToolRule(project)}\n`;
|
|
467
|
+
prompt += `3. Follow the project conventions in CLAUDE.md if present\n`;
|
|
468
|
+
prompt += `4. Write learnings to: ${MINIONS_DIR}/notes/inbox/${agentId}-${dateStamp()}.md\n`;
|
|
469
|
+
prompt += `5. Agent status is managed by the engine via dispatch.json — agents do not need to track their own status\n`;
|
|
470
|
+
prompt += `6. If you discover a repeatable workflow, output it as a \\\`\\\`\\\`skill fenced block — the engine auto-extracts it to ~/.claude/skills/\n\n`;
|
|
471
|
+
|
|
472
|
+
return prompt;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// Bulk context: history, notes, conventions, skills — prepended to user/task prompt.
|
|
476
|
+
// This is the content that grows over time and would bloat the system prompt.
|
|
477
|
+
function buildAgentContext(agentId, config, project) {
|
|
478
|
+
project = project || getProjects(config)[0] || {};
|
|
479
|
+
let context = '';
|
|
480
|
+
|
|
481
|
+
// Agent history — last 5 tasks only (keeps it relevant, avoids 37KB dumps)
|
|
482
|
+
const history = safeRead(path.join(AGENTS_DIR, agentId, 'history.md'));
|
|
483
|
+
if (history && history.trim() !== '# Agent History') {
|
|
484
|
+
const entries = history.split(/(?=^### )/m);
|
|
485
|
+
const header = entries[0].startsWith('#') && !entries[0].startsWith('### ') ? entries.shift() : '';
|
|
486
|
+
const recent = entries.slice(-5);
|
|
487
|
+
const trimmed = (header ? header + '\n' : '') + recent.join('');
|
|
488
|
+
context += `## Your Recent History (last 5 tasks)\n\n${trimmed}\n\n`;
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
// Project conventions (from CLAUDE.md) — always relevant for code quality
|
|
492
|
+
if (project.localPath) {
|
|
493
|
+
const claudeMd = safeRead(path.join(project.localPath, 'CLAUDE.md'));
|
|
494
|
+
if (claudeMd && claudeMd.trim()) {
|
|
495
|
+
const truncated = claudeMd.length > 8192 ? claudeMd.slice(0, 8192) + '\n\n...(truncated)' : claudeMd;
|
|
496
|
+
context += `## Project Conventions (from CLAUDE.md)\n\n${truncated}\n\n`;
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// KB and skills: NOT injected — agents can Glob/Read when needed
|
|
501
|
+
// This saves ~27KB per dispatch. Reference note so agents know they exist:
|
|
502
|
+
context += `## Reference Files\n\nKnowledge base entries are in \`knowledge/{category}/*.md\`. Skills are in \`skills/*.md\` and \`.claude/skills/\`. Use Glob/Read to browse when relevant.\n\n`;
|
|
503
|
+
|
|
504
|
+
// Minions awareness: what's in flight, who's doing what
|
|
505
|
+
const dispatch = getDispatch();
|
|
506
|
+
const activeItems = (dispatch.active || []).map(d =>
|
|
507
|
+
`- **${d.agent}**: ${d.type} — ${(d.task || '').slice(0, 100)}${d.agent === agentId ? ' ← (you)' : ''}`
|
|
508
|
+
);
|
|
509
|
+
if (activeItems.length > 0) {
|
|
510
|
+
context += `## Active Agents\n\n${activeItems.join('\n')}\n\n`;
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
// Recent completions (last 5, not 10)
|
|
514
|
+
const recentCompleted = (dispatch.completed || []).slice(-5).reverse().map(d =>
|
|
515
|
+
`- **${d.agent}** ${d.result === 'success' ? 'completed' : 'failed'}: ${(d.task || '').slice(0, 80)}${d.resultSummary ? ' — ' + d.resultSummary.slice(0, 100) : ''}`
|
|
516
|
+
);
|
|
517
|
+
if (recentCompleted.length > 0) {
|
|
518
|
+
context += `## Recently Completed\n\n${recentCompleted.join('\n')}\n\n`;
|
|
519
|
+
}
|
|
520
|
+
|
|
521
|
+
// Active PRs across projects — coordination awareness
|
|
522
|
+
const projects = getProjects(config);
|
|
523
|
+
const allPrs = [];
|
|
524
|
+
for (const p of projects) {
|
|
525
|
+
const prs = getPrs(p).filter(pr => pr.status === 'active');
|
|
526
|
+
for (const pr of prs) allPrs.push({ ...pr, _project: p.name });
|
|
527
|
+
}
|
|
528
|
+
if (allPrs.length > 0) {
|
|
529
|
+
const prLines = allPrs.map(pr =>
|
|
530
|
+
`- **${pr.id}** (${pr._project}): ${(pr.title || '').slice(0, 80)} [${pr.reviewStatus || 'pending'}${pr.buildStatus === 'failing' ? ', BUILD FAILING' : ''}]${pr.branch ? ' branch: `' + pr.branch + '`' : ''}`
|
|
531
|
+
);
|
|
532
|
+
context += `## Active Pull Requests\n\n${prLines.join('\n')}\n\n`;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
// Pending work items
|
|
536
|
+
const pendingItems = (dispatch.pending || []).slice(0, 10).map(d =>
|
|
537
|
+
`- ${d.type}: ${(d.task || '').slice(0, 80)}`
|
|
538
|
+
);
|
|
539
|
+
if (pendingItems.length > 0) {
|
|
540
|
+
context += `## Pending Work Queue (${(dispatch.pending || []).length} items)\n\n${pendingItems.join('\n')}${(dispatch.pending || []).length > 10 ? '\n- ... and ' + ((dispatch.pending || []).length - 10) + ' more' : ''}\n\n`;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
// Team notes — last 5 sections only (recent learnings, not the full history)
|
|
544
|
+
const notes = getNotes();
|
|
545
|
+
if (notes) {
|
|
546
|
+
const sections = notes.split(/(?=^### )/m);
|
|
547
|
+
const header = sections[0] && !sections[0].startsWith('### ') ? sections.shift() : '';
|
|
548
|
+
if (sections.length > 5) {
|
|
549
|
+
const recent = sections.slice(-5).join('');
|
|
550
|
+
context += `## Team Notes (last 5 of ${sections.length})\n\n${recent}\n\n_${sections.length - 5} older entries in \`notes.md\` — Read if needed._\n\n`;
|
|
551
|
+
} else {
|
|
552
|
+
context += `## Team Notes\n\n${notes}\n\n`;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
return context;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
// sanitizeBranch imported from shared.js
|
|
560
|
+
|
|
561
|
+
// ─── Lifecycle (extracted to engine/lifecycle.js) ────────────────────────────
|
|
562
|
+
|
|
563
|
+
const { runPostCompletionHooks, updateWorkItemStatus, syncPrdItemStatus, handlePostMerge, checkPlanCompletion,
|
|
564
|
+
syncPrsFromOutput, updatePrAfterReview, updatePrAfterFix, checkForLearnings, extractSkillsFromOutput,
|
|
565
|
+
updateAgentHistory, updateMetrics, createReviewFeedbackForAuthor, parseAgentOutput, syncPrdFromPrs } = require('./engine/lifecycle');
|
|
566
|
+
|
|
567
|
+
// ─── Agent Spawner ──────────────────────────────────────────────────────────
|
|
568
|
+
|
|
569
|
+
const activeProcesses = new Map(); // dispatchId → { proc, agentId, startedAt }
|
|
570
|
+
let engineRestartGraceUntil = 0; // timestamp — suppress orphan detection until this time
|
|
571
|
+
|
|
572
|
+
// Resolve dependency plan item IDs to their PR branches
|
|
573
|
+
function resolveDependencyBranches(depIds, sourcePlan, project, config) {
|
|
574
|
+
const results = []; // [{ branch, prId }]
|
|
575
|
+
if (!depIds?.length) return results;
|
|
576
|
+
|
|
577
|
+
const projects = shared.getProjects(config);
|
|
578
|
+
|
|
579
|
+
// Find work items for each dependency plan item
|
|
580
|
+
const depWorkItems = [];
|
|
581
|
+
for (const p of projects) {
|
|
582
|
+
const wiPath = shared.projectWorkItemsPath(p);
|
|
583
|
+
const items = safeJson(wiPath) || [];
|
|
584
|
+
for (const wi of items) {
|
|
585
|
+
if (depIds.includes(wi.id)) {
|
|
586
|
+
depWorkItems.push(wi);
|
|
587
|
+
}
|
|
588
|
+
}
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
// Find PR branches for each dependency work item
|
|
592
|
+
for (const p of projects) {
|
|
593
|
+
const prPath = shared.projectPrPath(p);
|
|
594
|
+
const prs = safeJson(prPath) || [];
|
|
595
|
+
for (const pr of prs) {
|
|
596
|
+
if (!pr.branch || pr.status !== 'active') continue;
|
|
597
|
+
const linked = (pr.prdItems || []).some(id =>
|
|
598
|
+
depWorkItems.find(w => w.id === id)
|
|
599
|
+
);
|
|
600
|
+
if (linked && !results.find(r => r.branch === pr.branch)) {
|
|
601
|
+
results.push({ branch: pr.branch, prId: pr.id });
|
|
602
|
+
}
|
|
603
|
+
}
|
|
604
|
+
}
|
|
605
|
+
|
|
606
|
+
return results;
|
|
607
|
+
}
|
|
608
|
+
|
|
609
|
+
// Find an existing worktree already checked out on a given branch
|
|
610
|
+
function findExistingWorktree(repoDir, branchName) {
|
|
611
|
+
try {
|
|
612
|
+
const out = exec(`git worktree list --porcelain`, { cwd: repoDir, stdio: 'pipe', timeout: 10000 }).toString();
|
|
613
|
+
const branchRef = `branch refs/heads/${branchName}`;
|
|
614
|
+
const lines = out.split('\n');
|
|
615
|
+
for (let i = 0; i < lines.length; i++) {
|
|
616
|
+
if (lines[i].trim() === branchRef) {
|
|
617
|
+
// Walk back to find the worktree path
|
|
618
|
+
for (let j = i - 1; j >= 0; j--) {
|
|
619
|
+
if (lines[j].startsWith('worktree ')) {
|
|
620
|
+
const wtPath = lines[j].slice('worktree '.length).trim();
|
|
621
|
+
if (fs.existsSync(wtPath)) return wtPath;
|
|
622
|
+
break;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
}
|
|
627
|
+
} catch {}
|
|
628
|
+
return null;
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
function isWorktreeRetryableError(err) {
|
|
632
|
+
const msg = String(err?.message || '');
|
|
633
|
+
return msg.includes('ETIMEDOUT')
|
|
634
|
+
|| msg.includes('index.lock')
|
|
635
|
+
|| msg.includes('timed out')
|
|
636
|
+
|| msg.includes('could not lock')
|
|
637
|
+
|| msg.includes('resource busy')
|
|
638
|
+
|| msg.includes('already exists');
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
function removeStaleIndexLock(rootDir) {
|
|
642
|
+
const lockFile = path.join(rootDir, '.git', 'index.lock');
|
|
643
|
+
try {
|
|
644
|
+
if (fs.existsSync(lockFile)) {
|
|
645
|
+
const age = Date.now() - fs.statSync(lockFile).mtimeMs;
|
|
646
|
+
if (age > 300000) {
|
|
647
|
+
fs.unlinkSync(lockFile);
|
|
648
|
+
log('warn', `Removed stale index.lock (${Math.round(age / 1000)}s old) in ${rootDir}`);
|
|
649
|
+
}
|
|
650
|
+
}
|
|
651
|
+
} catch {}
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
function runWorktreeAdd(rootDir, worktreePath, args, gitOpts, worktreeCreateRetries) {
|
|
655
|
+
let lastErr = null;
|
|
656
|
+
const retries = Math.max(0, Number(worktreeCreateRetries) || 0);
|
|
657
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
658
|
+
try {
|
|
659
|
+
if (attempt > 0) {
|
|
660
|
+
try { exec('git worktree prune', { ...gitOpts, cwd: rootDir, timeout: 15000 }); } catch {}
|
|
661
|
+
removeStaleIndexLock(rootDir);
|
|
662
|
+
log('warn', `Retrying git worktree add (attempt ${attempt + 1}/${retries + 1}) for ${path.basename(worktreePath)}`);
|
|
663
|
+
}
|
|
664
|
+
exec(`git worktree add "${worktreePath}" ${args}`, { ...gitOpts, cwd: rootDir });
|
|
665
|
+
return;
|
|
666
|
+
} catch (err) {
|
|
667
|
+
lastErr = err;
|
|
668
|
+
if (attempt >= retries || !isWorktreeRetryableError(err)) throw err;
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
if (lastErr) throw lastErr;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
function recoverPartialWorktree(rootDir, worktreePath, branchName, gitOpts) {
|
|
675
|
+
if (!branchName) return false;
|
|
676
|
+
const existingWt = findExistingWorktree(rootDir, branchName);
|
|
677
|
+
if (existingWt && fs.existsSync(existingWt)) return true;
|
|
678
|
+
if (!fs.existsSync(worktreePath)) return false;
|
|
679
|
+
try {
|
|
680
|
+
exec(`git -C "${worktreePath}" rev-parse --is-inside-work-tree`, { ...gitOpts, timeout: 10000 });
|
|
681
|
+
exec(`git -C "${worktreePath}" rev-parse --abbrev-ref HEAD`, { ...gitOpts, timeout: 10000 });
|
|
682
|
+
log('warn', `Recovered partially-created worktree for ${branchName} at ${worktreePath}`);
|
|
683
|
+
return true;
|
|
684
|
+
} catch {
|
|
685
|
+
return false;
|
|
686
|
+
}
|
|
687
|
+
}
|
|
688
|
+
|
|
689
|
+
function spawnAgent(dispatchItem, config) {
|
|
690
|
+
const { id, agent: agentId, prompt: taskPrompt, type, meta } = dispatchItem;
|
|
691
|
+
const claudeConfig = config.claude || {};
|
|
692
|
+
const engineConfig = config.engine || {};
|
|
693
|
+
const startedAt = ts();
|
|
694
|
+
|
|
695
|
+
// Resolve project context for this dispatch
|
|
696
|
+
const project = meta?.project || getProjects(config)[0] || {};
|
|
697
|
+
const rootDir = project.localPath ? path.resolve(project.localPath) : path.resolve(MINIONS_DIR, '..');
|
|
698
|
+
|
|
699
|
+
// Determine working directory
|
|
700
|
+
let cwd = rootDir;
|
|
701
|
+
let worktreePath = null;
|
|
702
|
+
let branchName = meta?.branch ? sanitizeBranch(meta.branch) : null;
|
|
703
|
+
const worktreeCreateTimeout = Math.max(60000, Number(engineConfig.worktreeCreateTimeout) || DEFAULTS.worktreeCreateTimeout);
|
|
704
|
+
const worktreeCreateRetries = Math.max(0, Math.min(3, Number(engineConfig.worktreeCreateRetries) || DEFAULTS.worktreeCreateRetries));
|
|
705
|
+
const _gitOpts = { stdio: 'pipe', timeout: 30000, windowsHide: true, env: shared.gitEnv() };
|
|
706
|
+
const _worktreeGitOpts = { ..._gitOpts, timeout: worktreeCreateTimeout };
|
|
707
|
+
|
|
708
|
+
if (branchName) {
|
|
709
|
+
const wtSuffix = id ? id.split('-').pop() : shared.uid();
|
|
710
|
+
const projectSlug = (project.name || 'default').replace(/[^a-zA-Z0-9_-]/g, '-');
|
|
711
|
+
const wtDirName = `${projectSlug}-${branchName}-${wtSuffix}`;
|
|
712
|
+
worktreePath = path.resolve(rootDir, engineConfig.worktreeRoot || '../worktrees', wtDirName);
|
|
713
|
+
|
|
714
|
+
// If branch is already checked out in an existing worktree, reuse it
|
|
715
|
+
const existingWt = findExistingWorktree(rootDir, branchName);
|
|
716
|
+
if (existingWt) {
|
|
717
|
+
worktreePath = existingWt;
|
|
718
|
+
log('info', `Reusing existing worktree for ${branchName}: ${existingWt}`);
|
|
719
|
+
try { exec(`git fetch origin "${branchName}"`, { ..._gitOpts, cwd: rootDir }); } catch {}
|
|
720
|
+
try { exec(`git pull origin "${branchName}"`, { ..._gitOpts, cwd: existingWt }); } catch {}
|
|
721
|
+
} else if (type !== 'implement') {
|
|
722
|
+
// Only implement tasks may create new worktrees.
|
|
723
|
+
// Other task types are reuse-only: if no existing worktree, run in rootDir.
|
|
724
|
+
log('info', `${type}: no existing worktree for ${branchName} — creation disabled for non-implement tasks, falling back to rootDir`);
|
|
725
|
+
branchName = null;
|
|
726
|
+
worktreePath = null;
|
|
727
|
+
} else {
|
|
728
|
+
try {
|
|
729
|
+
if (!fs.existsSync(worktreePath)) {
|
|
730
|
+
const isSharedBranch = meta?.branchStrategy === 'shared-branch' || meta?.useExistingBranch;
|
|
731
|
+
// Prune stale worktree entries before creating (handles leftover entries from crashed runs)
|
|
732
|
+
try { exec(`git worktree prune`, { ..._gitOpts, cwd: rootDir, timeout: 10000 }); } catch {}
|
|
733
|
+
// Remove stale index.lock before creating worktree (Windows crashes can leave this behind)
|
|
734
|
+
removeStaleIndexLock(rootDir);
|
|
735
|
+
|
|
736
|
+
if (isSharedBranch) {
|
|
737
|
+
log('info', `Creating worktree for shared branch: ${worktreePath} on ${branchName}`);
|
|
738
|
+
try { exec(`git fetch origin "${branchName}"`, { ..._gitOpts, cwd: rootDir }); } catch {}
|
|
739
|
+
try {
|
|
740
|
+
runWorktreeAdd(rootDir, worktreePath, `"${branchName}"`, _worktreeGitOpts, worktreeCreateRetries);
|
|
741
|
+
} catch (eShared) {
|
|
742
|
+
if (eShared.message?.includes('already used by worktree') || eShared.message?.includes('already checked out')) {
|
|
743
|
+
const existingWtPath = findExistingWorktree(rootDir, branchName);
|
|
744
|
+
if (existingWtPath && fs.existsSync(existingWtPath)) {
|
|
745
|
+
log('info', `Shared branch ${branchName} already checked out at ${existingWtPath} — reusing`);
|
|
746
|
+
worktreePath = existingWtPath;
|
|
747
|
+
} else { throw eShared; }
|
|
748
|
+
} else { throw eShared; }
|
|
749
|
+
}
|
|
750
|
+
} else {
|
|
751
|
+
log('info', `Creating worktree: ${worktreePath} on branch ${branchName}`);
|
|
752
|
+
try {
|
|
753
|
+
runWorktreeAdd(rootDir, worktreePath, `-b "${branchName}" ${sanitizeBranch(project.mainBranch || 'main')}`, _worktreeGitOpts, worktreeCreateRetries);
|
|
754
|
+
} catch (e1) {
|
|
755
|
+
// Branch already exists or checked out elsewhere — try without -b
|
|
756
|
+
try { exec(`git fetch origin "${branchName}"`, { ..._gitOpts, cwd: rootDir }); } catch {}
|
|
757
|
+
try {
|
|
758
|
+
runWorktreeAdd(rootDir, worktreePath, `"${branchName}"`, _worktreeGitOpts, worktreeCreateRetries);
|
|
759
|
+
log('info', `Reusing existing branch: ${branchName}`);
|
|
760
|
+
} catch (e2) {
|
|
761
|
+
// "already checked out" or "already used by worktree" — find and reuse or recover
|
|
762
|
+
const alreadyUsed = e2.message?.includes('already checked out') || e2.message?.includes('already used by worktree')
|
|
763
|
+
|| e1.message?.includes('already checked out') || e1.message?.includes('already used by worktree');
|
|
764
|
+
if (alreadyUsed) {
|
|
765
|
+
const existingWtPath = findExistingWorktree(rootDir, branchName);
|
|
766
|
+
if (existingWtPath && fs.existsSync(existingWtPath)) {
|
|
767
|
+
// Directory exists — reuse it if no other active dispatch is using it
|
|
768
|
+
const dispatch = safeJson(DISPATCH_PATH) || {};
|
|
769
|
+
const activelyUsed = (dispatch.active || []).some(d => {
|
|
770
|
+
const dBranch = d.meta?.branch ? sanitizeBranch(d.meta.branch) : '';
|
|
771
|
+
return dBranch === branchName && d.id !== id;
|
|
772
|
+
});
|
|
773
|
+
if (activelyUsed) {
|
|
774
|
+
log('warn', `Branch ${branchName} actively used by another agent at ${existingWtPath} — cannot create worktree`);
|
|
775
|
+
throw e2;
|
|
776
|
+
}
|
|
777
|
+
// Reuse the existing worktree — update path so the rest of spawnAgent uses it
|
|
778
|
+
log('info', `Branch ${branchName} already checked out at ${existingWtPath} — reusing`);
|
|
779
|
+
worktreePath = existingWtPath;
|
|
780
|
+
} else if (existingWtPath && !fs.existsSync(existingWtPath)) {
|
|
781
|
+
// Directory gone but git still tracks it — prune and recreate
|
|
782
|
+
log('warn', `Branch ${branchName} tracked in missing dir ${existingWtPath} — pruning and recreating`);
|
|
783
|
+
try { exec(`git worktree prune`, { ..._gitOpts, cwd: rootDir, timeout: 10000 }); } catch {}
|
|
784
|
+
runWorktreeAdd(rootDir, worktreePath, `"${branchName}"`, _worktreeGitOpts, worktreeCreateRetries);
|
|
785
|
+
log('info', `Recovered worktree for ${branchName} after stale entry prune`);
|
|
786
|
+
} else {
|
|
787
|
+
// Can't find the worktree at all — prune and retry
|
|
788
|
+
try { exec(`git worktree prune`, { ..._gitOpts, cwd: rootDir, timeout: 10000 }); } catch {}
|
|
789
|
+
runWorktreeAdd(rootDir, worktreePath, `"${branchName}"`, _worktreeGitOpts, worktreeCreateRetries);
|
|
790
|
+
}
|
|
791
|
+
} else {
|
|
792
|
+
throw e2;
|
|
793
|
+
}
|
|
794
|
+
}
|
|
795
|
+
}
|
|
796
|
+
}
|
|
797
|
+
} else if (meta?.branchStrategy === 'shared-branch') {
|
|
798
|
+
log('info', `Pulling latest on shared branch ${branchName}`);
|
|
799
|
+
try { exec(`git pull origin "${branchName}"`, { ..._gitOpts, cwd: worktreePath }); } catch {}
|
|
800
|
+
}
|
|
801
|
+
} catch (err) {
|
|
802
|
+
if (recoverPartialWorktree(rootDir, worktreePath, branchName, _gitOpts)) {
|
|
803
|
+
cwd = worktreePath;
|
|
804
|
+
log('warn', `Proceeding with recovered worktree after add failure for ${branchName}`);
|
|
805
|
+
} else {
|
|
806
|
+
log('error', `Failed to create worktree for ${branchName}: ${err.message}${err.stderr ? '\n' + err.stderr.toString().slice(0, 500) : ''}`);
|
|
807
|
+
completeDispatch(id, 'error', 'Worktree creation failed: ' + (err.message || '').slice(0, 200));
|
|
808
|
+
return null;
|
|
809
|
+
}
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
// Merge dependency PR branches into worktree (applies to both reused and new worktrees)
|
|
814
|
+
if (worktreePath && fs.existsSync(worktreePath)) {
|
|
815
|
+
cwd = worktreePath;
|
|
816
|
+
const depIds = meta?.item?.depends_on || [];
|
|
817
|
+
if (depIds.length > 0) {
|
|
818
|
+
try {
|
|
819
|
+
const depBranches = resolveDependencyBranches(depIds, meta?.item?.sourcePlan, project, config);
|
|
820
|
+
for (const { branch: depBranch, prId } of depBranches) {
|
|
821
|
+
try {
|
|
822
|
+
exec(`git fetch origin "${depBranch}"`, { ..._gitOpts, cwd: rootDir });
|
|
823
|
+
exec(`git merge "origin/${depBranch}" --no-edit`, { ..._gitOpts, cwd: worktreePath });
|
|
824
|
+
log('info', `Merged dependency branch ${depBranch} (${prId}) into worktree ${branchName}`);
|
|
825
|
+
} catch (mergeErr) {
|
|
826
|
+
log('warn', `Failed to merge dependency ${depBranch} into ${branchName}: ${mergeErr.message}`);
|
|
827
|
+
}
|
|
828
|
+
}
|
|
829
|
+
} catch (e) {
|
|
830
|
+
log('warn', `Could not resolve dependency branches for ${branchName}: ${e.message}`);
|
|
831
|
+
}
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// Build lean system prompt (identity + rules, ~2-4KB) and bulk context (history, notes, skills)
|
|
837
|
+
const systemPrompt = buildSystemPrompt(agentId, config, project);
|
|
838
|
+
const agentContext = buildAgentContext(agentId, config, project);
|
|
839
|
+
|
|
840
|
+
// Prepend bulk context to task prompt — keeps system prompt small and stable
|
|
841
|
+
const fullTaskPrompt = agentContext
|
|
842
|
+
? `## Agent Context\n\n${agentContext}\n---\n\n## Your Task\n\n${taskPrompt}`
|
|
843
|
+
: taskPrompt;
|
|
844
|
+
|
|
845
|
+
// Write prompt and system prompt to temp files (avoids shell escaping issues)
|
|
846
|
+
const tmpDir = path.join(ENGINE_DIR, 'tmp');
|
|
847
|
+
if (!fs.existsSync(tmpDir)) fs.mkdirSync(tmpDir, { recursive: true });
|
|
848
|
+
const promptPath = path.join(tmpDir, `prompt-${id}.md`);
|
|
849
|
+
safeWrite(promptPath, fullTaskPrompt);
|
|
850
|
+
|
|
851
|
+
const sysPromptPath = path.join(tmpDir, `sysprompt-${id}.md`);
|
|
852
|
+
safeWrite(sysPromptPath, systemPrompt);
|
|
853
|
+
|
|
854
|
+
// Build claude CLI args
|
|
855
|
+
const args = [
|
|
856
|
+
'--output-format', claudeConfig.outputFormat || 'stream-json',
|
|
857
|
+
'--max-turns', String(engineConfig.maxTurns || DEFAULTS.maxTurns),
|
|
858
|
+
'--verbose',
|
|
859
|
+
'--permission-mode', claudeConfig.permissionMode || 'bypassPermissions'
|
|
860
|
+
];
|
|
861
|
+
|
|
862
|
+
if (claudeConfig.allowedTools) {
|
|
863
|
+
args.push('--allowedTools', claudeConfig.allowedTools);
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
// MCP servers: agents inherit from ~/.claude.json directly as Claude Code processes.
|
|
867
|
+
// No --mcp-config needed — avoids redundant config and ensures agents always have latest servers.
|
|
868
|
+
|
|
869
|
+
log('info', `Spawning agent: ${agentId} (${id}) in ${cwd}`);
|
|
870
|
+
log('info', `Task type: ${type} | Branch: ${branchName || 'none'}`);
|
|
871
|
+
|
|
872
|
+
// Agent status is derived from dispatch.json — no setAgentStatus needed for working state.
|
|
873
|
+
|
|
874
|
+
// Spawn the claude process
|
|
875
|
+
const childEnv = shared.cleanChildEnv();
|
|
876
|
+
|
|
877
|
+
// Spawn via wrapper script — node directly (no bash intermediary)
|
|
878
|
+
// spawn-agent.js handles CLAUDECODE env cleanup and claude binary resolution
|
|
879
|
+
const spawnScript = path.join(ENGINE_DIR, 'spawn-agent.js');
|
|
880
|
+
const spawnArgs = [spawnScript, promptPath, sysPromptPath, ...args];
|
|
881
|
+
|
|
882
|
+
const proc = runFile(process.execPath, spawnArgs, {
|
|
883
|
+
cwd,
|
|
884
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
885
|
+
env: childEnv,
|
|
886
|
+
});
|
|
887
|
+
|
|
888
|
+
const MAX_OUTPUT = 1024 * 1024; // 1MB
|
|
889
|
+
let stdout = '';
|
|
890
|
+
let stderr = '';
|
|
891
|
+
let lastOutputAt = Date.now();
|
|
892
|
+
let heartbeatTimer = null;
|
|
893
|
+
|
|
894
|
+
// Live output file — written as data arrives so dashboard can tail it
|
|
895
|
+
const liveOutputPath = path.join(AGENTS_DIR, agentId, 'live-output.log');
|
|
896
|
+
safeWrite(liveOutputPath, `# Live output for ${agentId} — ${id}\n# Started: ${startedAt}\n# Task: ${dispatchItem.task}\n\n`);
|
|
897
|
+
|
|
898
|
+
// Keep live log active even when the agent produces no stdout/stderr for long stretches.
|
|
899
|
+
// This makes "silent but running" states visible in the dashboard tail view.
|
|
900
|
+
heartbeatTimer = setInterval(() => {
|
|
901
|
+
const silentMs = Date.now() - lastOutputAt;
|
|
902
|
+
if (silentMs < 30000) return;
|
|
903
|
+
const silentSec = Math.round(silentMs / 1000);
|
|
904
|
+
try { fs.appendFileSync(liveOutputPath, `[heartbeat] running — no output for ${silentSec}s\n`); } catch {}
|
|
905
|
+
}, 30000);
|
|
906
|
+
|
|
907
|
+
proc.stdout.on('data', (data) => {
|
|
908
|
+
const chunk = data.toString();
|
|
909
|
+
lastOutputAt = Date.now();
|
|
910
|
+
if (stdout.length < MAX_OUTPUT) stdout += chunk.slice(0, MAX_OUTPUT - stdout.length);
|
|
911
|
+
try { fs.appendFileSync(liveOutputPath, chunk); } catch {}
|
|
912
|
+
});
|
|
913
|
+
|
|
914
|
+
proc.stderr.on('data', (data) => {
|
|
915
|
+
const chunk = data.toString();
|
|
916
|
+
lastOutputAt = Date.now();
|
|
917
|
+
if (stderr.length < MAX_OUTPUT) stderr += chunk.slice(0, MAX_OUTPUT - stderr.length);
|
|
918
|
+
try { fs.appendFileSync(liveOutputPath, '[stderr] ' + chunk); } catch {}
|
|
919
|
+
});
|
|
920
|
+
|
|
921
|
+
proc.on('close', (code) => {
|
|
922
|
+
if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
|
|
923
|
+
log('info', `Agent ${agentId} (${id}) exited with code ${code}`);
|
|
924
|
+
activeProcesses.delete(id);
|
|
925
|
+
|
|
926
|
+
// If timeout checker already finalized this dispatch, don't overwrite work-item status again.
|
|
927
|
+
// This avoids races where close-handler marks an auto-retried item as failed.
|
|
928
|
+
const dispatchNow = getDispatch();
|
|
929
|
+
const stillActive = (dispatchNow.active || []).some(d => d.id === id);
|
|
930
|
+
if (!stillActive) {
|
|
931
|
+
log('info', `Agent ${agentId} (${id}) close event ignored — dispatch already completed elsewhere`);
|
|
932
|
+
try { fs.unlinkSync(sysPromptPath); } catch {}
|
|
933
|
+
try { fs.unlinkSync(promptPath); } catch {}
|
|
934
|
+
try { fs.unlinkSync(promptPath.replace(/prompt-/, 'pid-').replace(/\.md$/, '.pid')); } catch {}
|
|
935
|
+
return;
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
// Save output — per-dispatch archive + latest symlink
|
|
939
|
+
const outputContent = `# Output for dispatch ${id}\n# Exit code: ${code}\n# Completed: ${ts()}\n\n## stdout\n${stdout}\n\n## stderr\n${stderr}`;
|
|
940
|
+
const archivePath = path.join(AGENTS_DIR, agentId, `output-${id}.log`);
|
|
941
|
+
const latestPath = path.join(AGENTS_DIR, agentId, 'output.log');
|
|
942
|
+
safeWrite(archivePath, outputContent);
|
|
943
|
+
safeWrite(latestPath, outputContent); // overwrite latest for dashboard compat
|
|
944
|
+
|
|
945
|
+
// Parse output and run all post-completion hooks
|
|
946
|
+
const { resultSummary } = runPostCompletionHooks(dispatchItem, agentId, code, stdout, config);
|
|
947
|
+
|
|
948
|
+
// Move from active to completed in dispatch (single source of truth for agent status)
|
|
949
|
+
completeDispatch(id, code === 0 ? 'success' : 'error', '', resultSummary);
|
|
950
|
+
|
|
951
|
+
// Cleanup temp files (including PID file now that dispatch is complete)
|
|
952
|
+
try { fs.unlinkSync(sysPromptPath); } catch {}
|
|
953
|
+
try { fs.unlinkSync(promptPath); } catch {}
|
|
954
|
+
try { fs.unlinkSync(promptPath.replace(/prompt-/, 'pid-').replace(/\.md$/, '.pid')); } catch {}
|
|
955
|
+
|
|
956
|
+
log('info', `Agent ${agentId} completed. Output saved to ${archivePath}`);
|
|
957
|
+
});
|
|
958
|
+
|
|
959
|
+
proc.on('error', (err) => {
|
|
960
|
+
if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
|
|
961
|
+
log('error', `Failed to spawn agent ${agentId}: ${err.message}`);
|
|
962
|
+
activeProcesses.delete(id);
|
|
963
|
+
completeDispatch(id, 'error', `Spawn error: ${err.message}`);
|
|
964
|
+
});
|
|
965
|
+
|
|
966
|
+
// Safety: if process exits immediately (within 3s), log it
|
|
967
|
+
setTimeout(() => {
|
|
968
|
+
if (proc.exitCode !== null && !proc.killed) {
|
|
969
|
+
log('warn', `Agent ${agentId} (${id}) exited within 3s with code ${proc.exitCode}`);
|
|
970
|
+
}
|
|
971
|
+
}, 3000);
|
|
972
|
+
|
|
973
|
+
// Track process — even if PID isn't available yet (async on Windows)
|
|
974
|
+
activeProcesses.set(id, { proc, agentId, startedAt });
|
|
975
|
+
|
|
976
|
+
// Log PID and persist to registry
|
|
977
|
+
if (proc.pid) {
|
|
978
|
+
log('info', `Agent process started: PID ${proc.pid}`);
|
|
979
|
+
} else {
|
|
980
|
+
log('warn', `Agent spawn returned no PID initially — will verify via PID file`);
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
// Verify spawn after 5 seconds via PID file written by spawn-agent.js
|
|
984
|
+
// PID file is kept (not deleted) so engine can re-attach on restart
|
|
985
|
+
setTimeout(() => {
|
|
986
|
+
const pidFile = promptPath.replace(/prompt-/, 'pid-').replace(/\.md$/, '.pid');
|
|
987
|
+
try {
|
|
988
|
+
const pidStr = fs.readFileSync(pidFile, 'utf8').trim();
|
|
989
|
+
if (pidStr) {
|
|
990
|
+
log('info', `Agent ${agentId} verified via PID file: ${pidStr}`);
|
|
991
|
+
}
|
|
992
|
+
// Don't delete — keep for re-attachment on engine restart
|
|
993
|
+
} catch {
|
|
994
|
+
if (!fs.existsSync(liveOutputPath) || fs.statSync(liveOutputPath).size <= 200) {
|
|
995
|
+
log('error', `Agent ${agentId} (${id}) — no PID file and no output after 5s. Spawn likely failed.`);
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
}, 5000);
|
|
999
|
+
|
|
1000
|
+
// Move pending -> active under a lock to avoid cross-process lost updates (engine/dashboard)
|
|
1001
|
+
mutateDispatch((dispatch) => {
|
|
1002
|
+
const idx = dispatch.pending.findIndex(d => d.id === id);
|
|
1003
|
+
if (idx < 0) return;
|
|
1004
|
+
const item = dispatch.pending.splice(idx, 1)[0];
|
|
1005
|
+
item.started_at = startedAt;
|
|
1006
|
+
if (!dispatch.active.some(d => d.id === id)) {
|
|
1007
|
+
dispatch.active.push(item);
|
|
1008
|
+
}
|
|
1009
|
+
});
|
|
1010
|
+
|
|
1011
|
+
return proc;
|
|
1012
|
+
}
|
|
1013
|
+
|
|
1014
|
+
// ─── Dispatch Management ────────────────────────────────────────────────────
|
|
1015
|
+
|
|
1016
|
+
function addToDispatch(item) {
|
|
1017
|
+
item.id = item.id || `${item.agent}-${item.type}-${shared.uid()}`;
|
|
1018
|
+
item.created_at = ts();
|
|
1019
|
+
mutateDispatch((dispatch) => {
|
|
1020
|
+
dispatch.pending.push(item);
|
|
1021
|
+
});
|
|
1022
|
+
log('info', `Queued dispatch: ${item.id} (${item.type} → ${item.agent})`);
|
|
1023
|
+
return item.id;
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
function isRetryableFailureReason(reason = '') {
|
|
1027
|
+
const r = String(reason || '').toLowerCase();
|
|
1028
|
+
if (!r) return true; // unknown error from tool exit — keep retryable
|
|
1029
|
+
const nonRetryable = [
|
|
1030
|
+
'no playbook rendered',
|
|
1031
|
+
'failed to render',
|
|
1032
|
+
'no target project available',
|
|
1033
|
+
'no plan files found',
|
|
1034
|
+
'plan file not found',
|
|
1035
|
+
'invalid filename',
|
|
1036
|
+
'invalid file path',
|
|
1037
|
+
'missing required',
|
|
1038
|
+
'validation failed',
|
|
1039
|
+
];
|
|
1040
|
+
return !nonRetryable.some(s => r.includes(s));
|
|
1041
|
+
}
|
|
1042
|
+
|
|
1043
|
+
function completeDispatch(id, result = 'success', reason = '', resultSummary = '', opts = {}) {
|
|
1044
|
+
const { processWorkItemFailure = true } = opts;
|
|
1045
|
+
let item = null;
|
|
1046
|
+
|
|
1047
|
+
mutateDispatch((dispatch) => {
|
|
1048
|
+
// Check active list first
|
|
1049
|
+
let idx = dispatch.active.findIndex(d => d.id === id);
|
|
1050
|
+
if (idx >= 0) {
|
|
1051
|
+
item = dispatch.active.splice(idx, 1)[0];
|
|
1052
|
+
} else {
|
|
1053
|
+
// Also check pending list (e.g., worktree failure before spawn)
|
|
1054
|
+
idx = dispatch.pending.findIndex(d => d.id === id);
|
|
1055
|
+
if (idx >= 0) item = dispatch.pending.splice(idx, 1)[0];
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
if (!item) return;
|
|
1059
|
+
item.completed_at = ts();
|
|
1060
|
+
item.result = result;
|
|
1061
|
+
if (reason) item.reason = reason;
|
|
1062
|
+
if (resultSummary) item.resultSummary = resultSummary;
|
|
1063
|
+
delete item.prompt;
|
|
1064
|
+
if (dispatch.completed.length >= 100) {
|
|
1065
|
+
dispatch.completed = dispatch.completed.slice(-99);
|
|
1066
|
+
}
|
|
1067
|
+
dispatch.completed.push(item);
|
|
1068
|
+
});
|
|
1069
|
+
|
|
1070
|
+
if (item) {
|
|
1071
|
+
log('info', `Completed dispatch: ${id} (${result}${reason ? ': ' + reason : ''})`);
|
|
1072
|
+
|
|
1073
|
+
// Update source work item status on failure + auto-retry with backoff
|
|
1074
|
+
const retryableFailure = isRetryableFailureReason(reason);
|
|
1075
|
+
if (result === 'error' && item.meta?.dispatchKey && retryableFailure) setCooldownFailure(item.meta.dispatchKey);
|
|
1076
|
+
|
|
1077
|
+
if (processWorkItemFailure && result === 'error' && item.meta?.item?.id) {
|
|
1078
|
+
const retries = (item.meta.item._retryCount || 0);
|
|
1079
|
+
if (retryableFailure && retries < 3) {
|
|
1080
|
+
log('info', `Dispatch error for ${item.meta.item.id} — auto-retry ${retries + 1}/3`);
|
|
1081
|
+
updateWorkItemStatus(item.meta, 'pending', '');
|
|
1082
|
+
// Remove this dispatch key from completed so dedupe doesn't block immediate redispatch.
|
|
1083
|
+
if (item.meta?.dispatchKey) {
|
|
1084
|
+
try {
|
|
1085
|
+
mutateDispatch((dp) => {
|
|
1086
|
+
const before = Array.isArray(dp.completed) ? dp.completed.length : 0;
|
|
1087
|
+
dp.completed = Array.isArray(dp.completed) ? dp.completed.filter(d => d.meta?.dispatchKey !== item.meta.dispatchKey) : [];
|
|
1088
|
+
return dp.completed.length !== before ? dp : undefined;
|
|
1089
|
+
});
|
|
1090
|
+
} catch {}
|
|
1091
|
+
}
|
|
1092
|
+
// Increment retry counter on the source work item
|
|
1093
|
+
try {
|
|
1094
|
+
const wiPath = item.meta.source === 'central-work-item' || item.meta.source === 'central-work-item-fanout'
|
|
1095
|
+
? path.join(MINIONS_DIR, 'work-items.json')
|
|
1096
|
+
: item.meta.project?.name ? projectWorkItemsPath({ name: item.meta.project.name, localPath: item.meta.project.localPath }) : null;
|
|
1097
|
+
if (wiPath) {
|
|
1098
|
+
const items = safeJson(wiPath) || [];
|
|
1099
|
+
const wi = items.find(i => i.id === item.meta.item.id);
|
|
1100
|
+
if (wi && wi.status !== 'paused') {
|
|
1101
|
+
wi._retryCount = retries + 1;
|
|
1102
|
+
wi.status = 'pending';
|
|
1103
|
+
wi._lastRetryReason = reason || '';
|
|
1104
|
+
wi._lastRetryAt = ts();
|
|
1105
|
+
delete wi.failReason;
|
|
1106
|
+
delete wi.failedAt;
|
|
1107
|
+
delete wi.dispatched_at;
|
|
1108
|
+
delete wi.dispatched_to;
|
|
1109
|
+
safeWrite(wiPath, items);
|
|
1110
|
+
}
|
|
1111
|
+
}
|
|
1112
|
+
} catch {}
|
|
1113
|
+
} else {
|
|
1114
|
+
const finalReason = !retryableFailure
|
|
1115
|
+
? `Non-retryable failure: ${reason || 'Unknown error'}`
|
|
1116
|
+
: (reason || 'Failed after 3 retries');
|
|
1117
|
+
updateWorkItemStatus(item.meta, 'failed', finalReason);
|
|
1118
|
+
// Alert: find items blocked by this failure and write inbox note
|
|
1119
|
+
try {
|
|
1120
|
+
const config = getConfig();
|
|
1121
|
+
const failedId = item.meta.item.id;
|
|
1122
|
+
const blockedItems = [];
|
|
1123
|
+
for (const p of getProjects(config)) {
|
|
1124
|
+
const items = safeJson(projectWorkItemsPath(p)) || [];
|
|
1125
|
+
items.filter(w => w.status === 'pending' && (w.depends_on || []).includes(failedId))
|
|
1126
|
+
.forEach(w => blockedItems.push(`- \`${w.id}\` — ${w.title}`));
|
|
1127
|
+
}
|
|
1128
|
+
const centralItems = safeJson(path.join(MINIONS_DIR, 'work-items.json')) || [];
|
|
1129
|
+
centralItems.filter(w => w.status === 'pending' && (w.depends_on || []).includes(failedId))
|
|
1130
|
+
.forEach(w => blockedItems.push(`- \`${w.id}\` — ${w.title}`));
|
|
1131
|
+
|
|
1132
|
+
writeInboxAlert(`failed-${failedId}`,
|
|
1133
|
+
`# Work Item Failed — \`${failedId}\`\n\n` +
|
|
1134
|
+
`**Item:** ${item.meta.item.title || failedId}\n` +
|
|
1135
|
+
`**Reason:** ${finalReason}\n\n` +
|
|
1136
|
+
(blockedItems.length > 0
|
|
1137
|
+
? `**Blocked dependents (${blockedItems.length}):**\n${blockedItems.join('\n')}\n\n` +
|
|
1138
|
+
`These items cannot dispatch until \`${failedId}\` is fixed and reset to \`pending\`.\n`
|
|
1139
|
+
: `No downstream items are blocked.\n`)
|
|
1140
|
+
);
|
|
1141
|
+
} catch {}
|
|
1142
|
+
}
|
|
1143
|
+
}
|
|
1144
|
+
}
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
// ─── Dependency Gate ─────────────────────────────────────────────────────────
|
|
1148
|
+
// Returns: true (deps met), false (deps pending), 'failed' (dep failed — propagate)
|
|
1149
|
+
function areDependenciesMet(item, config) {
|
|
1150
|
+
const deps = item.depends_on;
|
|
1151
|
+
if (!deps || deps.length === 0) return true;
|
|
1152
|
+
const sourcePlan = item.sourcePlan;
|
|
1153
|
+
if (!sourcePlan) return true;
|
|
1154
|
+
const projects = getProjects(config);
|
|
1155
|
+
|
|
1156
|
+
// Collect work items from ALL projects (dependencies can be cross-project)
|
|
1157
|
+
let allWorkItems = [];
|
|
1158
|
+
for (const p of projects) {
|
|
1159
|
+
try {
|
|
1160
|
+
const wi = safeJson(projectWorkItemsPath(p)) || [];
|
|
1161
|
+
allWorkItems = allWorkItems.concat(wi);
|
|
1162
|
+
} catch {}
|
|
1163
|
+
}
|
|
1164
|
+
// PRD item statuses that count as "done" for dep resolution
|
|
1165
|
+
const PRD_MET_STATUSES = new Set(['done', 'in-pr', 'implemented', 'complete']);
|
|
1166
|
+
|
|
1167
|
+
for (const depId of deps) {
|
|
1168
|
+
const depItem = allWorkItems.find(w => w.id === depId);
|
|
1169
|
+
if (!depItem) {
|
|
1170
|
+
// Fallback: check PRD JSON — plan-to-prd agents may pre-set items to done
|
|
1171
|
+
try {
|
|
1172
|
+
const plan = safeJson(path.join(PRD_DIR, sourcePlan));
|
|
1173
|
+
const prdItem = (plan?.missing_features || []).find(f => f.id === depId);
|
|
1174
|
+
if (prdItem && PRD_MET_STATUSES.has(prdItem.status)) continue; // PRD says done — treat as met
|
|
1175
|
+
} catch {}
|
|
1176
|
+
log('warn', `Dependency ${depId} not found for ${item.id} (plan: ${sourcePlan}) — treating as unmet`);
|
|
1177
|
+
return false;
|
|
1178
|
+
}
|
|
1179
|
+
if (depItem.status === 'failed' && (depItem._retryCount || 0) >= 3) return 'failed'; // Only cascade after retries exhausted
|
|
1180
|
+
if (depItem.status !== 'done' && depItem.status !== 'in-pr') return false; // Pending, dispatched, or retrying — wait (in-pr accepted for backward compat)
|
|
1181
|
+
}
|
|
1182
|
+
return true;
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
function detectDependencyCycles(items) {
|
|
1186
|
+
const graph = new Map();
|
|
1187
|
+
for (const item of items) graph.set(item.id, item.depends_on || []);
|
|
1188
|
+
const visited = new Set(), inStack = new Set(), cycleIds = new Set();
|
|
1189
|
+
function dfs(id) {
|
|
1190
|
+
if (inStack.has(id)) { cycleIds.add(id); return true; }
|
|
1191
|
+
if (visited.has(id)) return false;
|
|
1192
|
+
visited.add(id); inStack.add(id);
|
|
1193
|
+
for (const dep of (graph.get(id) || [])) { if (dfs(dep)) cycleIds.add(id); }
|
|
1194
|
+
inStack.delete(id); return false;
|
|
1195
|
+
}
|
|
1196
|
+
for (const id of graph.keys()) dfs(id);
|
|
1197
|
+
return [...cycleIds];
|
|
1198
|
+
}
|
|
1199
|
+
|
|
1200
|
+
|
|
1201
|
+
// ─── Shared helpers ───────────────────────────────────────────────────────────
|
|
1202
|
+
|
|
1203
|
+
// Write a one-off alert note to notes/inbox so the user sees it on next consolidation
|
|
1204
|
+
function writeInboxAlert(slug, content) {
|
|
1205
|
+
try {
|
|
1206
|
+
const file = path.join(INBOX_DIR, `engine-alert-${slug}-${dateStamp()}.md`);
|
|
1207
|
+
// Dedupe: don't write the same alert twice in the same day
|
|
1208
|
+
const existing = safeReadDir(INBOX_DIR).find(f => f.startsWith(`engine-alert-${slug}-${dateStamp()}`));
|
|
1209
|
+
if (existing) return;
|
|
1210
|
+
safeWrite(file, content);
|
|
1211
|
+
} catch {}
|
|
1212
|
+
}
|
|
1213
|
+
|
|
1214
|
+
// Reconciles work items against known PRs via exact prdItems match only.
|
|
1215
|
+
// reconcilePrs (ado.js) and syncPrsFromOutput (lifecycle.js) are responsible for
|
|
1216
|
+
// correctly populating prdItems on PRs — this function just reads that linkage.
|
|
1217
|
+
// onlyIds: if provided, only items whose ID is in this Set are eligible.
|
|
1218
|
+
function reconcileItemsWithPrs(items, allPrs, { onlyIds } = {}) {
|
|
1219
|
+
let reconciled = 0;
|
|
1220
|
+
for (const wi of items) {
|
|
1221
|
+
if (wi.status !== 'pending' || wi._pr) continue;
|
|
1222
|
+
if (onlyIds && !onlyIds.has(wi.id)) continue;
|
|
1223
|
+
|
|
1224
|
+
const exactPr = allPrs.find(pr => (pr.prdItems || []).includes(wi.id));
|
|
1225
|
+
if (exactPr) {
|
|
1226
|
+
wi.status = 'done';
|
|
1227
|
+
wi._pr = exactPr.id;
|
|
1228
|
+
reconciled++;
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
return reconciled;
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
// ─── Inbox Consolidation (extracted to engine/consolidation.js) ──────────────
|
|
1235
|
+
|
|
1236
|
+
const { consolidateInbox } = require('./engine/consolidation');
|
|
1237
|
+
const { pollPrStatus, pollPrHumanComments, reconcilePrs } = require('./engine/ado');
|
|
1238
|
+
const { pollPrStatus: ghPollPrStatus, pollPrHumanComments: ghPollPrHumanComments, reconcilePrs: ghReconcilePrs } = require('./engine/github');
|
|
1239
|
+
|
|
1240
|
+
// ─── State Snapshot ─────────────────────────────────────────────────────────
|
|
1241
|
+
|
|
1242
|
+
function updateSnapshot(config) {
|
|
1243
|
+
const dispatch = getDispatch();
|
|
1244
|
+
const agents = config.agents || {};
|
|
1245
|
+
const projects = getProjects(config);
|
|
1246
|
+
|
|
1247
|
+
let snapshot = `# Minions State — ${ts()}\n\n`;
|
|
1248
|
+
snapshot += `## Projects: ${projects.map(p => p.name).join(', ')}\n\n`;
|
|
1249
|
+
|
|
1250
|
+
snapshot += `## Agents\n\n`;
|
|
1251
|
+
snapshot += `| Agent | Role | Status | Task |\n`;
|
|
1252
|
+
snapshot += `|-------|------|--------|------|\n`;
|
|
1253
|
+
for (const [id, agent] of Object.entries(agents)) {
|
|
1254
|
+
const status = getAgentStatus(id);
|
|
1255
|
+
snapshot += `| ${agent.emoji} ${agent.name} | ${agent.role} | ${status.status} | ${status.task || '-'} |\n`;
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
snapshot += `\n## Dispatch Queue\n\n`;
|
|
1259
|
+
snapshot += `- Pending: ${dispatch.pending.length}\n`;
|
|
1260
|
+
snapshot += `- Active: ${(dispatch.active || []).length}\n`;
|
|
1261
|
+
snapshot += `- Completed: ${(dispatch.completed || []).length}\n`;
|
|
1262
|
+
|
|
1263
|
+
if (dispatch.pending.length > 0) {
|
|
1264
|
+
snapshot += `\n### Pending\n`;
|
|
1265
|
+
for (const d of dispatch.pending) {
|
|
1266
|
+
snapshot += `- [${d.id}] ${d.type} → ${d.agent}: ${d.task}\n`;
|
|
1267
|
+
}
|
|
1268
|
+
}
|
|
1269
|
+
if ((dispatch.active || []).length > 0) {
|
|
1270
|
+
snapshot += `\n### Active\n`;
|
|
1271
|
+
for (const d of dispatch.active) {
|
|
1272
|
+
snapshot += `- [${d.id}] ${d.type} → ${d.agent}: ${d.task} (since ${d.started_at})\n`;
|
|
1273
|
+
}
|
|
1274
|
+
}
|
|
1275
|
+
|
|
1276
|
+
safeWrite(path.join(IDENTITY_DIR, 'now.md'), snapshot);
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
// ─── Idle Alert ─────────────────────────────────────────────────────────────
|
|
1280
|
+
|
|
1281
|
+
let _lastActivityTime = Date.now();
|
|
1282
|
+
let _idleAlertSent = false;
|
|
1283
|
+
|
|
1284
|
+
function checkIdleThreshold(config) {
|
|
1285
|
+
const thresholdMs = (config.engine?.idleAlertMinutes || 15) * 60 * 1000;
|
|
1286
|
+
const agents = Object.keys(config.agents || {});
|
|
1287
|
+
const allIdle = agents.every(id => isAgentIdle(id));
|
|
1288
|
+
const dispatch = getDispatch();
|
|
1289
|
+
const hasPending = (dispatch.pending || []).length > 0;
|
|
1290
|
+
|
|
1291
|
+
if (!allIdle || hasPending) {
|
|
1292
|
+
_lastActivityTime = Date.now();
|
|
1293
|
+
_idleAlertSent = false;
|
|
1294
|
+
return;
|
|
1295
|
+
}
|
|
1296
|
+
|
|
1297
|
+
const idleMs = Date.now() - _lastActivityTime;
|
|
1298
|
+
if (idleMs > thresholdMs && !_idleAlertSent) {
|
|
1299
|
+
const mins = Math.round(idleMs / 60000);
|
|
1300
|
+
log('warn', `All agents idle for ${mins} minutes — no work sources producing items`);
|
|
1301
|
+
_idleAlertSent = true;
|
|
1302
|
+
}
|
|
1303
|
+
}
|
|
1304
|
+
|
|
1305
|
+
// ─── Timeout Checker ────────────────────────────────────────────────────────
|
|
1306
|
+
|
|
1307
|
+
function checkTimeouts(config) {
|
|
1308
|
+
const timeout = config.engine?.agentTimeout || DEFAULTS.agentTimeout;
|
|
1309
|
+
const heartbeatTimeout = config.engine?.heartbeatTimeout || DEFAULTS.heartbeatTimeout;
|
|
1310
|
+
|
|
1311
|
+
// 1. Check tracked processes for hard timeout (supports per-item deadline from fan-out)
|
|
1312
|
+
for (const [id, info] of activeProcesses.entries()) {
|
|
1313
|
+
const itemTimeout = info.meta?.deadline ? Math.max(0, info.meta.deadline - new Date(info.startedAt).getTime()) : timeout;
|
|
1314
|
+
const elapsed = Date.now() - new Date(info.startedAt).getTime();
|
|
1315
|
+
if (elapsed > itemTimeout) {
|
|
1316
|
+
log('warn', `Agent ${info.agentId} (${id}) hit hard timeout after ${Math.round(elapsed / 1000)}s — killing`);
|
|
1317
|
+
try { info.proc.kill('SIGTERM'); } catch {}
|
|
1318
|
+
setTimeout(() => {
|
|
1319
|
+
try { info.proc.kill('SIGKILL'); } catch {}
|
|
1320
|
+
}, 5000);
|
|
1321
|
+
}
|
|
1322
|
+
}
|
|
1323
|
+
|
|
1324
|
+
// 2. Heartbeat check — for ALL active dispatch items (catches orphans after engine restart)
|
|
1325
|
+
// Uses live-output.log mtime as heartbeat. If no output for heartbeatTimeout, agent is dead.
|
|
1326
|
+
const dispatch = getDispatch();
|
|
1327
|
+
const deadItems = [];
|
|
1328
|
+
|
|
1329
|
+
for (const item of (dispatch.active || [])) {
|
|
1330
|
+
if (!item.agent) continue;
|
|
1331
|
+
|
|
1332
|
+
const hasProcess = activeProcesses.has(item.id);
|
|
1333
|
+
const liveLogPath = path.join(AGENTS_DIR, item.agent, 'live-output.log');
|
|
1334
|
+
let lastActivity = item.started_at ? new Date(item.started_at).getTime() : 0;
|
|
1335
|
+
|
|
1336
|
+
// Check live-output.log mtime as heartbeat
|
|
1337
|
+
try {
|
|
1338
|
+
const stat = fs.statSync(liveLogPath);
|
|
1339
|
+
lastActivity = Math.max(lastActivity, stat.mtimeMs);
|
|
1340
|
+
} catch {}
|
|
1341
|
+
|
|
1342
|
+
const silentMs = Date.now() - lastActivity;
|
|
1343
|
+
const silentSec = Math.round(silentMs / 1000);
|
|
1344
|
+
|
|
1345
|
+
// Check if the agent actually completed (result event in live output)
|
|
1346
|
+
// Optimization: only read file if recent activity (avoids reading stale 1MB logs)
|
|
1347
|
+
let completedViaOutput = false;
|
|
1348
|
+
try {
|
|
1349
|
+
if (silentMs > 600000) throw 'skip'; // No point reading a file silent for >10min
|
|
1350
|
+
const liveLog = safeRead(liveLogPath);
|
|
1351
|
+
if (liveLog && liveLog.includes('"type":"result"')) {
|
|
1352
|
+
completedViaOutput = true;
|
|
1353
|
+
const isSuccess = liveLog.includes('"subtype":"success"');
|
|
1354
|
+
log('info', `Agent ${item.agent} (${item.id}) completed via output detection (${isSuccess ? 'success' : 'error'})`);
|
|
1355
|
+
|
|
1356
|
+
// Extract output text for the output.log
|
|
1357
|
+
const outputLogPath = path.join(AGENTS_DIR, item.agent, 'output.log');
|
|
1358
|
+
try {
|
|
1359
|
+
const resultLine = liveLog.split('\n').find(l => l.includes('"type":"result"'));
|
|
1360
|
+
if (resultLine) {
|
|
1361
|
+
const result = JSON.parse(resultLine);
|
|
1362
|
+
safeWrite(outputLogPath, `# Output for dispatch ${item.id}\n# Exit code: ${isSuccess ? 0 : 1}\n# Completed: ${ts()}\n# Detected via output scan\n\n## Result\n${result.result || '(no text)'}\n`);
|
|
1363
|
+
}
|
|
1364
|
+
} catch {}
|
|
1365
|
+
|
|
1366
|
+
completeDispatch(item.id, isSuccess ? 'success' : 'error', 'Completed (detected from output)');
|
|
1367
|
+
|
|
1368
|
+
// Run post-completion hooks via shared helper
|
|
1369
|
+
runPostCompletionHooks(item, item.agent, isSuccess ? 0 : 1, liveLog, config);
|
|
1370
|
+
|
|
1371
|
+
if (hasProcess) {
|
|
1372
|
+
try { activeProcesses.get(item.id)?.proc.kill('SIGTERM'); } catch {}
|
|
1373
|
+
activeProcesses.delete(item.id);
|
|
1374
|
+
}
|
|
1375
|
+
continue; // Skip orphan/hung detection — we handled it
|
|
1376
|
+
}
|
|
1377
|
+
} catch {}
|
|
1378
|
+
|
|
1379
|
+
// Check if agent is in a blocking tool call (TaskOutput block:true, Bash with long timeout, etc.)
|
|
1380
|
+
// These tools produce no stdout for extended periods — don't kill them prematurely
|
|
1381
|
+
// Check for BOTH tracked and untracked processes (orphan case after engine restart)
|
|
1382
|
+
let isBlocking = false;
|
|
1383
|
+
let blockingTimeout = heartbeatTimeout;
|
|
1384
|
+
if (silentMs > heartbeatTimeout) {
|
|
1385
|
+
try {
|
|
1386
|
+
const liveLog = safeRead(liveLogPath);
|
|
1387
|
+
if (liveLog) {
|
|
1388
|
+
// Find the last tool_use call in the output — check if it's a known blocking tool
|
|
1389
|
+
const lines = liveLog.split('\n');
|
|
1390
|
+
for (let i = lines.length - 1; i >= Math.max(0, lines.length - 30); i--) {
|
|
1391
|
+
const line = lines[i];
|
|
1392
|
+
if (!line.includes('"tool_use"')) continue;
|
|
1393
|
+
try {
|
|
1394
|
+
const parsed = JSON.parse(line);
|
|
1395
|
+
const toolUse = parsed?.message?.content?.find?.(c => c.type === 'tool_use');
|
|
1396
|
+
if (!toolUse) continue;
|
|
1397
|
+
const input = toolUse.input || {};
|
|
1398
|
+
const name = toolUse.name || '';
|
|
1399
|
+
// TaskOutput with block:true — waiting for a background task
|
|
1400
|
+
if (name === 'TaskOutput' && input.block === true) {
|
|
1401
|
+
const taskTimeout = input.timeout || 600000; // default 10min
|
|
1402
|
+
blockingTimeout = Math.max(heartbeatTimeout, taskTimeout + 60000); // task timeout + 1min grace
|
|
1403
|
+
isBlocking = true;
|
|
1404
|
+
}
|
|
1405
|
+
// Bash with explicit long timeout (>5min)
|
|
1406
|
+
if (name === 'Bash' && input.timeout && input.timeout > heartbeatTimeout) {
|
|
1407
|
+
blockingTimeout = Math.max(heartbeatTimeout, input.timeout + 60000);
|
|
1408
|
+
isBlocking = true;
|
|
1409
|
+
}
|
|
1410
|
+
break; // only check the most recent tool_use
|
|
1411
|
+
} catch {}
|
|
1412
|
+
}
|
|
1413
|
+
if (isBlocking) {
|
|
1414
|
+
log('info', `Agent ${item.agent} (${item.id}) is in a blocking tool call — extended timeout to ${Math.round(blockingTimeout / 1000)}s (silent for ${silentSec}s)`);
|
|
1415
|
+
}
|
|
1416
|
+
}
|
|
1417
|
+
} catch {}
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1420
|
+
const effectiveTimeout = isBlocking ? blockingTimeout : heartbeatTimeout;
|
|
1421
|
+
|
|
1422
|
+
if (!hasProcess && silentMs > effectiveTimeout && Date.now() > engineRestartGraceUntil) {
|
|
1423
|
+
// No tracked process AND no recent output past effective timeout AND grace period expired → orphaned
|
|
1424
|
+
log('warn', `Orphan detected: ${item.agent} (${item.id}) — no process tracked, silent for ${silentSec}s${isBlocking ? ' (blocking timeout exceeded)' : ''}`);
|
|
1425
|
+
deadItems.push({ item, reason: `Orphaned — no process, silent for ${silentSec}s` });
|
|
1426
|
+
} else if (hasProcess && silentMs > effectiveTimeout) {
|
|
1427
|
+
// Has process but no output past effective timeout → hung
|
|
1428
|
+
log('warn', `Hung agent: ${item.agent} (${item.id}) — process exists but no output for ${silentSec}s${isBlocking ? ' (blocking timeout exceeded)' : ''}`);
|
|
1429
|
+
const procInfo = activeProcesses.get(item.id);
|
|
1430
|
+
if (procInfo) {
|
|
1431
|
+
try { procInfo.proc.kill('SIGTERM'); } catch {}
|
|
1432
|
+
setTimeout(() => { try { procInfo.proc.kill('SIGKILL'); } catch {} }, 5000);
|
|
1433
|
+
activeProcesses.delete(item.id);
|
|
1434
|
+
}
|
|
1435
|
+
deadItems.push({ item, reason: `Hung — no output for ${silentSec}s` });
|
|
1436
|
+
}
|
|
1437
|
+
// If has process and recent output → healthy, let it run
|
|
1438
|
+
}
|
|
1439
|
+
|
|
1440
|
+
// Clean up dead items
|
|
1441
|
+
for (const { item, reason } of deadItems) {
|
|
1442
|
+
completeDispatch(item.id, 'error', reason);
|
|
1443
|
+
}
|
|
1444
|
+
|
|
1445
|
+
// Agent status is now derived from dispatch.json at read time (getAgentStatus).
|
|
1446
|
+
// No reconcile sweep needed — dispatch IS the source of truth.
|
|
1447
|
+
|
|
1448
|
+
// Reconcile: find work items stuck in "dispatched" with no matching active dispatch
|
|
1449
|
+
const activeKeys = new Set((dispatch.active || []).map(d => d.meta?.dispatchKey).filter(Boolean));
|
|
1450
|
+
const allWiPaths = [path.join(MINIONS_DIR, 'work-items.json')];
|
|
1451
|
+
for (const project of getProjects(config)) {
|
|
1452
|
+
allWiPaths.push(projectWorkItemsPath(project));
|
|
1453
|
+
}
|
|
1454
|
+
for (const wiPath of allWiPaths) {
|
|
1455
|
+
const items = safeJson(wiPath);
|
|
1456
|
+
if (!items || !Array.isArray(items)) continue;
|
|
1457
|
+
let changed = false;
|
|
1458
|
+
for (const item of items) {
|
|
1459
|
+
if (item.status !== 'dispatched') continue;
|
|
1460
|
+
// Check if any active dispatch references this item
|
|
1461
|
+
// Dispatch keys include project name: work-{project}-{id} or central-work-{id}
|
|
1462
|
+
const projectNames = getProjects(config).map(p => p.name);
|
|
1463
|
+
const possibleKeys = [
|
|
1464
|
+
`central-work-${item.id}`,
|
|
1465
|
+
...projectNames.map(p => `work-${p}-${item.id}`),
|
|
1466
|
+
];
|
|
1467
|
+
const isActive = possibleKeys.some(k => activeKeys.has(k)) ||
|
|
1468
|
+
(dispatch.active || []).some(d => d.meta?.item?.id === item.id);
|
|
1469
|
+
if (!isActive) {
|
|
1470
|
+
const retries = (item._retryCount || 0);
|
|
1471
|
+
if (retries < 3) {
|
|
1472
|
+
log('info', `Reconcile: work item ${item.id} agent died — auto-retry ${retries + 1}/3`);
|
|
1473
|
+
item.status = 'pending';
|
|
1474
|
+
item._retryCount = retries + 1;
|
|
1475
|
+
delete item.dispatched_at;
|
|
1476
|
+
delete item.dispatched_to;
|
|
1477
|
+
} else {
|
|
1478
|
+
log('warn', `Reconcile: work item ${item.id} failed after ${retries} retries — marking as failed`);
|
|
1479
|
+
item.status = 'failed';
|
|
1480
|
+
item.failReason = 'Agent died or was killed (3 retries exhausted)';
|
|
1481
|
+
item.failedAt = ts();
|
|
1482
|
+
}
|
|
1483
|
+
changed = true;
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
if (changed) safeWrite(wiPath, items);
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
// ─── Cleanup ─────────────────────────────────────────────────────────────────
|
|
1491
|
+
|
|
1492
|
+
function runCleanup(config, verbose = false) {
|
|
1493
|
+
const projects = getProjects(config);
|
|
1494
|
+
let cleaned = { tempFiles: 0, liveOutputs: 0, worktrees: 0, zombies: 0 };
|
|
1495
|
+
|
|
1496
|
+
// 1. Clean stale temp prompt/sysprompt files (older than 1 hour)
|
|
1497
|
+
const oneHourAgo = Date.now() - 3600000;
|
|
1498
|
+
try {
|
|
1499
|
+
const tmpDir = path.join(ENGINE_DIR, 'tmp');
|
|
1500
|
+
const scanDirs = [ENGINE_DIR, ...(fs.existsSync(tmpDir) ? [tmpDir] : [])];
|
|
1501
|
+
for (const dir of scanDirs) {
|
|
1502
|
+
for (const f of fs.readdirSync(dir)) {
|
|
1503
|
+
if (f.startsWith('prompt-') || f.startsWith('sysprompt-') || f.startsWith('tmp-sysprompt-')) {
|
|
1504
|
+
const fp = path.join(dir, f);
|
|
1505
|
+
try {
|
|
1506
|
+
const stat = fs.statSync(fp);
|
|
1507
|
+
if (stat.mtimeMs < oneHourAgo) {
|
|
1508
|
+
fs.unlinkSync(fp);
|
|
1509
|
+
cleaned.tempFiles++;
|
|
1510
|
+
}
|
|
1511
|
+
} catch {}
|
|
1512
|
+
}
|
|
1513
|
+
}
|
|
1514
|
+
}
|
|
1515
|
+
} catch {}
|
|
1516
|
+
|
|
1517
|
+
// 2. Clean live-output.log for idle agents (not currently working)
|
|
1518
|
+
for (const [agentId] of Object.entries(config.agents || {})) {
|
|
1519
|
+
const status = getAgentStatus(agentId);
|
|
1520
|
+
if (status.status !== 'working') {
|
|
1521
|
+
const livePath = path.join(AGENTS_DIR, agentId, 'live-output.log');
|
|
1522
|
+
if (fs.existsSync(livePath)) {
|
|
1523
|
+
try {
|
|
1524
|
+
const stat = fs.statSync(livePath);
|
|
1525
|
+
if (stat.mtimeMs < oneHourAgo) {
|
|
1526
|
+
fs.unlinkSync(livePath);
|
|
1527
|
+
cleaned.liveOutputs++;
|
|
1528
|
+
}
|
|
1529
|
+
} catch {}
|
|
1530
|
+
}
|
|
1531
|
+
}
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1534
|
+
// 3. Clean git worktrees for merged/abandoned PRs
|
|
1535
|
+
for (const project of projects) {
|
|
1536
|
+
const root = project.localPath ? path.resolve(project.localPath) : null;
|
|
1537
|
+
if (!root || !fs.existsSync(root)) continue;
|
|
1538
|
+
|
|
1539
|
+
const worktreeRoot = path.resolve(root, config.engine?.worktreeRoot || '../worktrees');
|
|
1540
|
+
if (!fs.existsSync(worktreeRoot)) continue;
|
|
1541
|
+
|
|
1542
|
+
// Get PRs for this project
|
|
1543
|
+
const prs = safeJson(projectPrPath(project)) || [];
|
|
1544
|
+
const mergedBranches = new Set();
|
|
1545
|
+
for (const pr of prs) {
|
|
1546
|
+
if (pr.status === 'merged' || pr.status === 'abandoned' || pr.status === 'completed') {
|
|
1547
|
+
if (pr.branch) mergedBranches.add(pr.branch);
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1550
|
+
|
|
1551
|
+
// List worktrees — collect info for age-based + cap-based cleanup
|
|
1552
|
+
const MAX_WORKTREES = 10;
|
|
1553
|
+
try {
|
|
1554
|
+
const dirs = fs.readdirSync(worktreeRoot);
|
|
1555
|
+
const wtEntries = []; // { dir, wtPath, mtime, shouldClean, isProtected }
|
|
1556
|
+
const dispatch = getDispatch();
|
|
1557
|
+
|
|
1558
|
+
for (const dir of dirs) {
|
|
1559
|
+
const wtPath = path.join(worktreeRoot, dir);
|
|
1560
|
+
try { if (!fs.statSync(wtPath).isDirectory()) continue; } catch { continue; }
|
|
1561
|
+
|
|
1562
|
+
let shouldClean = false;
|
|
1563
|
+
let isProtected = false;
|
|
1564
|
+
|
|
1565
|
+
// Check if this worktree's branch is merged/abandoned
|
|
1566
|
+
// Use sanitized exact match on the branch portion of the dir name (format: {slug}-{branch}-{suffix})
|
|
1567
|
+
const dirLower = dir.toLowerCase();
|
|
1568
|
+
for (const branch of mergedBranches) {
|
|
1569
|
+
const branchSlug = sanitizeBranch(branch).toLowerCase();
|
|
1570
|
+
if (dirLower === branchSlug || dirLower.includes(branchSlug + '-') || dirLower.endsWith('-' + branchSlug)) {
|
|
1571
|
+
shouldClean = true;
|
|
1572
|
+
break;
|
|
1573
|
+
}
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
// Check if referenced by active/pending dispatch (use sanitized branch comparison)
|
|
1577
|
+
const isReferenced = [...dispatch.pending, ...(dispatch.active || [])].some(d => {
|
|
1578
|
+
if (!d.meta?.branch) return false;
|
|
1579
|
+
const dispBranch = sanitizeBranch(d.meta.branch).toLowerCase();
|
|
1580
|
+
return dirLower.includes(dispBranch);
|
|
1581
|
+
});
|
|
1582
|
+
if (isReferenced) isProtected = true;
|
|
1583
|
+
|
|
1584
|
+
// Also clean worktrees older than 2 hours with no active dispatch referencing them
|
|
1585
|
+
let mtime = Date.now();
|
|
1586
|
+
if (!shouldClean) {
|
|
1587
|
+
try {
|
|
1588
|
+
const stat = fs.statSync(wtPath);
|
|
1589
|
+
mtime = stat.mtimeMs;
|
|
1590
|
+
const ageMs = Date.now() - mtime;
|
|
1591
|
+
if (ageMs > 7200000 && !isReferenced) { // 2 hours
|
|
1592
|
+
shouldClean = true;
|
|
1593
|
+
}
|
|
1594
|
+
} catch {}
|
|
1595
|
+
}
|
|
1596
|
+
|
|
1597
|
+
// Skip worktrees for active shared-branch plans (check both prd/ and plans/ for .json PRDs)
|
|
1598
|
+
if (shouldClean || !isProtected) {
|
|
1599
|
+
try {
|
|
1600
|
+
for (const checkDir of [PRD_DIR, path.join(MINIONS_DIR, 'plans')]) {
|
|
1601
|
+
if (!fs.existsSync(checkDir)) continue;
|
|
1602
|
+
for (const pf of fs.readdirSync(checkDir).filter(f => f.endsWith('.json'))) {
|
|
1603
|
+
const plan = safeJson(path.join(checkDir, pf));
|
|
1604
|
+
if (plan?.branch_strategy === 'shared-branch' && plan?.feature_branch && plan?.status !== 'completed') {
|
|
1605
|
+
const planBranch = sanitizeBranch(plan.feature_branch).toLowerCase();
|
|
1606
|
+
if (dirLower.includes(planBranch)) {
|
|
1607
|
+
isProtected = true;
|
|
1608
|
+
if (shouldClean) {
|
|
1609
|
+
shouldClean = false;
|
|
1610
|
+
if (verbose) console.log(` Skipping worktree ${dir}: active shared-branch plan`);
|
|
1611
|
+
}
|
|
1612
|
+
break;
|
|
1613
|
+
}
|
|
1614
|
+
}
|
|
1615
|
+
}
|
|
1616
|
+
if (isProtected) break;
|
|
1617
|
+
}
|
|
1618
|
+
} catch {}
|
|
1619
|
+
}
|
|
1620
|
+
|
|
1621
|
+
wtEntries.push({ dir, wtPath, mtime, shouldClean, isProtected });
|
|
1622
|
+
}
|
|
1623
|
+
|
|
1624
|
+
// Enforce max worktree cap — if over limit, mark oldest unprotected for cleanup
|
|
1625
|
+
const surviving = wtEntries.filter(e => !e.shouldClean && !e.isProtected);
|
|
1626
|
+
if (surviving.length + wtEntries.filter(e => e.isProtected).length > MAX_WORKTREES) {
|
|
1627
|
+
// Sort oldest first
|
|
1628
|
+
surviving.sort((a, b) => a.mtime - b.mtime);
|
|
1629
|
+
const excess = surviving.length + wtEntries.filter(e => e.isProtected).length - MAX_WORKTREES;
|
|
1630
|
+
for (let i = 0; i < Math.min(excess, surviving.length); i++) {
|
|
1631
|
+
surviving[i].shouldClean = true;
|
|
1632
|
+
if (verbose) console.log(` Marking worktree ${surviving[i].dir} for cap cleanup (${MAX_WORKTREES} max)`);
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
|
|
1636
|
+
// Remove all marked worktrees
|
|
1637
|
+
for (const entry of wtEntries) {
|
|
1638
|
+
if (entry.shouldClean) {
|
|
1639
|
+
try {
|
|
1640
|
+
exec(`git worktree remove "${entry.wtPath}" --force`, { cwd: root, stdio: 'pipe' });
|
|
1641
|
+
cleaned.worktrees++;
|
|
1642
|
+
if (verbose) console.log(` Removed worktree: ${entry.wtPath}`);
|
|
1643
|
+
} catch (e) {
|
|
1644
|
+
if (verbose) console.log(` Failed to remove worktree ${entry.wtPath}: ${e.message}`);
|
|
1645
|
+
}
|
|
1646
|
+
}
|
|
1647
|
+
}
|
|
1648
|
+
} catch {}
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
// 4. Kill zombie claude processes not tracked by the engine
|
|
1652
|
+
// List all node processes, check if any are running spawn-agent.js for our minions
|
|
1653
|
+
try {
|
|
1654
|
+
const dispatch = getDispatch();
|
|
1655
|
+
const activePids = new Set();
|
|
1656
|
+
for (const [, info] of activeProcesses.entries()) {
|
|
1657
|
+
if (info.proc?.pid) activePids.add(info.proc.pid);
|
|
1658
|
+
}
|
|
1659
|
+
|
|
1660
|
+
// Clean individual orphaned processes — no matching active dispatch
|
|
1661
|
+
const activeIds = new Set((dispatch.active || []).map(d => d.id));
|
|
1662
|
+
for (const [id, info] of activeProcesses.entries()) {
|
|
1663
|
+
if (!activeIds.has(id)) {
|
|
1664
|
+
try { if (info.proc) info.proc.kill('SIGTERM'); } catch {}
|
|
1665
|
+
activeProcesses.delete(id);
|
|
1666
|
+
cleaned.zombies++;
|
|
1667
|
+
}
|
|
1668
|
+
}
|
|
1669
|
+
} catch {}
|
|
1670
|
+
|
|
1671
|
+
// 5. Clean spawn-debug.log
|
|
1672
|
+
try { fs.unlinkSync(path.join(ENGINE_DIR, 'spawn-debug.log')); } catch {}
|
|
1673
|
+
|
|
1674
|
+
// 6. Prune old output archive files (keep last 30 per agent)
|
|
1675
|
+
for (const agentId of Object.keys(config.agents || {})) {
|
|
1676
|
+
const agentDir = path.join(MINIONS_DIR, 'agents', agentId);
|
|
1677
|
+
if (!fs.existsSync(agentDir)) continue;
|
|
1678
|
+
try {
|
|
1679
|
+
const outputFiles = fs.readdirSync(agentDir)
|
|
1680
|
+
.filter(f => f.startsWith('output-') && f.endsWith('.log') && f !== 'output.log')
|
|
1681
|
+
.map(f => ({ name: f, mtime: fs.statSync(path.join(agentDir, f)).mtimeMs }))
|
|
1682
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
1683
|
+
for (const old of outputFiles.slice(30)) {
|
|
1684
|
+
try { fs.unlinkSync(path.join(agentDir, old.name)); cleaned.files++; } catch {}
|
|
1685
|
+
}
|
|
1686
|
+
} catch {}
|
|
1687
|
+
}
|
|
1688
|
+
|
|
1689
|
+
// 7. Prune orphaned dispatch entries — items whose source work item no longer exists
|
|
1690
|
+
cleaned.orphanedDispatches = 0;
|
|
1691
|
+
try {
|
|
1692
|
+
const dispatch = getDispatch();
|
|
1693
|
+
// Collect all work item IDs across all sources
|
|
1694
|
+
const allWiIds = new Set();
|
|
1695
|
+
try {
|
|
1696
|
+
const central = safeJson(path.join(MINIONS_DIR, 'work-items.json')) || [];
|
|
1697
|
+
central.forEach(w => allWiIds.add(w.id));
|
|
1698
|
+
} catch {}
|
|
1699
|
+
for (const project of projects) {
|
|
1700
|
+
try {
|
|
1701
|
+
const projItems = safeJson(projectWorkItemsPath(project)) || [];
|
|
1702
|
+
projItems.forEach(w => allWiIds.add(w.id));
|
|
1703
|
+
} catch {}
|
|
1704
|
+
}
|
|
1705
|
+
|
|
1706
|
+
let changed = false;
|
|
1707
|
+
for (const queue of ['pending', 'active']) {
|
|
1708
|
+
if (!dispatch[queue]) continue;
|
|
1709
|
+
const before = dispatch[queue].length;
|
|
1710
|
+
dispatch[queue] = dispatch[queue].filter(d => {
|
|
1711
|
+
const itemId = d.meta?.item?.id;
|
|
1712
|
+
if (!itemId) return true; // keep entries without item tracking
|
|
1713
|
+
return allWiIds.has(itemId);
|
|
1714
|
+
});
|
|
1715
|
+
const removed = before - dispatch[queue].length;
|
|
1716
|
+
if (removed > 0) {
|
|
1717
|
+
cleaned.orphanedDispatches += removed;
|
|
1718
|
+
changed = true;
|
|
1719
|
+
}
|
|
1720
|
+
}
|
|
1721
|
+
if (changed) {
|
|
1722
|
+
mutateDispatch((dp) => {
|
|
1723
|
+
for (const queue of ['pending', 'active']) {
|
|
1724
|
+
if (!dp[queue]) continue;
|
|
1725
|
+
dp[queue] = dp[queue].filter(d => {
|
|
1726
|
+
const itemId = d.meta?.item?.id;
|
|
1727
|
+
if (!itemId) return true;
|
|
1728
|
+
return allWiIds.has(itemId);
|
|
1729
|
+
});
|
|
1730
|
+
}
|
|
1731
|
+
});
|
|
1732
|
+
}
|
|
1733
|
+
} catch {}
|
|
1734
|
+
|
|
1735
|
+
if (cleaned.tempFiles + cleaned.liveOutputs + cleaned.worktrees + cleaned.zombies + (cleaned.files || 0) + cleaned.orphanedDispatches > 0) {
|
|
1736
|
+
log('info', `Cleanup: ${cleaned.tempFiles} temp, ${cleaned.liveOutputs} live outputs, ${cleaned.worktrees} worktrees, ${cleaned.zombies} zombies, ${cleaned.files || 0} archives, ${cleaned.orphanedDispatches} orphaned dispatches`);
|
|
1737
|
+
}
|
|
1738
|
+
|
|
1739
|
+
// 8. Clean swept KB files older than 7 days
|
|
1740
|
+
try {
|
|
1741
|
+
const sweptDir = path.join(MINIONS_DIR, 'knowledge', '_swept');
|
|
1742
|
+
if (fs.existsSync(sweptDir)) {
|
|
1743
|
+
const sevenDaysAgo = Date.now() - 7 * 86400000;
|
|
1744
|
+
for (const f of fs.readdirSync(sweptDir)) {
|
|
1745
|
+
try {
|
|
1746
|
+
const fp = path.join(sweptDir, f);
|
|
1747
|
+
if (fs.statSync(fp).mtimeMs < sevenDaysAgo) {
|
|
1748
|
+
fs.unlinkSync(fp);
|
|
1749
|
+
if (!cleaned.sweptKb) cleaned.sweptKb = 0;
|
|
1750
|
+
cleaned.sweptKb++;
|
|
1751
|
+
}
|
|
1752
|
+
} catch {}
|
|
1753
|
+
}
|
|
1754
|
+
}
|
|
1755
|
+
} catch {}
|
|
1756
|
+
|
|
1757
|
+
// 9. KB watchdog — restore deleted KB files from git if count dropped vs checkpoint
|
|
1758
|
+
try {
|
|
1759
|
+
const checkpoint = safeJson(path.join(ENGINE_DIR, 'kb-checkpoint.json'));
|
|
1760
|
+
if (checkpoint && checkpoint.count > 0) {
|
|
1761
|
+
const { KB_CATEGORIES: cats } = shared;
|
|
1762
|
+
const knowledgeDir = path.join(MINIONS_DIR, 'knowledge');
|
|
1763
|
+
let current = 0;
|
|
1764
|
+
for (const cat of cats) {
|
|
1765
|
+
const d = path.join(knowledgeDir, cat);
|
|
1766
|
+
if (fs.existsSync(d)) current += fs.readdirSync(d).length;
|
|
1767
|
+
}
|
|
1768
|
+
if (current < checkpoint.count) {
|
|
1769
|
+
log('warn', `KB watchdog: file count dropped ${checkpoint.count} → ${current}, restoring from git`);
|
|
1770
|
+
try {
|
|
1771
|
+
const trackedCheck = execSilent('git ls-tree --name-only HEAD -- knowledge', { cwd: MINIONS_DIR }).toString().trim();
|
|
1772
|
+
if (!trackedCheck) {
|
|
1773
|
+
log('warn', 'KB watchdog: knowledge/ is not tracked in git HEAD — skipping restore');
|
|
1774
|
+
} else {
|
|
1775
|
+
execSilent('git checkout HEAD -- knowledge', { cwd: MINIONS_DIR });
|
|
1776
|
+
log('info', 'KB watchdog: restored knowledge/ from git HEAD');
|
|
1777
|
+
}
|
|
1778
|
+
} catch (err) {
|
|
1779
|
+
log('error', `KB watchdog: git restore failed — ${err.message}`);
|
|
1780
|
+
}
|
|
1781
|
+
}
|
|
1782
|
+
}
|
|
1783
|
+
} catch {}
|
|
1784
|
+
|
|
1785
|
+
// 6. Migrate legacy work-item statuses to canonical values
|
|
1786
|
+
// in-pr, implemented, complete → done (one-time correction per item)
|
|
1787
|
+
const LEGACY_DONE_STATUSES = new Set(['in-pr', 'implemented', 'complete']);
|
|
1788
|
+
for (const project of projects) {
|
|
1789
|
+
try {
|
|
1790
|
+
const wiPath = projectWorkItemsPath(project);
|
|
1791
|
+
const items = safeJson(wiPath) || [];
|
|
1792
|
+
let migrated = 0;
|
|
1793
|
+
for (const item of items) {
|
|
1794
|
+
if (LEGACY_DONE_STATUSES.has(item.status)) {
|
|
1795
|
+
item.status = 'done';
|
|
1796
|
+
migrated++;
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
if (migrated > 0) {
|
|
1800
|
+
safeWrite(wiPath, items);
|
|
1801
|
+
log('info', `Migrated ${migrated} legacy status(es) → done in ${project.name} work items`);
|
|
1802
|
+
}
|
|
1803
|
+
} catch {}
|
|
1804
|
+
}
|
|
1805
|
+
// Central work items
|
|
1806
|
+
try {
|
|
1807
|
+
const centralPath = path.join(MINIONS_DIR, 'work-items.json');
|
|
1808
|
+
const centralItems = safeJson(centralPath) || [];
|
|
1809
|
+
let migrated = 0;
|
|
1810
|
+
for (const item of centralItems) {
|
|
1811
|
+
if (LEGACY_DONE_STATUSES.has(item.status)) {
|
|
1812
|
+
item.status = 'done';
|
|
1813
|
+
migrated++;
|
|
1814
|
+
}
|
|
1815
|
+
}
|
|
1816
|
+
if (migrated > 0) {
|
|
1817
|
+
safeWrite(centralPath, centralItems);
|
|
1818
|
+
log('info', `Migrated ${migrated} legacy status(es) → done in central work items`);
|
|
1819
|
+
}
|
|
1820
|
+
} catch {}
|
|
1821
|
+
// PRD items (missing_features[].status)
|
|
1822
|
+
try {
|
|
1823
|
+
const prdFiles = fs.readdirSync(PRD_DIR).filter(f => f.endsWith('.json'));
|
|
1824
|
+
for (const pf of prdFiles) {
|
|
1825
|
+
const prdPath = path.join(PRD_DIR, pf);
|
|
1826
|
+
const prd = safeJson(prdPath);
|
|
1827
|
+
if (!prd?.missing_features) continue;
|
|
1828
|
+
let migrated = 0;
|
|
1829
|
+
for (const feat of prd.missing_features) {
|
|
1830
|
+
if (LEGACY_DONE_STATUSES.has(feat.status)) {
|
|
1831
|
+
feat.status = 'done';
|
|
1832
|
+
migrated++;
|
|
1833
|
+
}
|
|
1834
|
+
}
|
|
1835
|
+
if (migrated > 0) {
|
|
1836
|
+
safeWrite(prdPath, prd);
|
|
1837
|
+
log('info', `Migrated ${migrated} legacy PRD item status(es) → done in ${pf}`);
|
|
1838
|
+
}
|
|
1839
|
+
}
|
|
1840
|
+
} catch {}
|
|
1841
|
+
|
|
1842
|
+
return cleaned;
|
|
1843
|
+
}
|
|
1844
|
+
|
|
1845
|
+
// ─── Work Discovery ─────────────────────────────────────────────────────────
|
|
1846
|
+
|
|
1847
|
+
const COOLDOWN_PATH = path.join(ENGINE_DIR, 'cooldowns.json');
|
|
1848
|
+
const dispatchCooldowns = new Map(); // key → { timestamp, failures }
|
|
1849
|
+
|
|
1850
|
+
function loadCooldowns() {
|
|
1851
|
+
const saved = safeJson(COOLDOWN_PATH);
|
|
1852
|
+
if (!saved) return;
|
|
1853
|
+
const now = Date.now();
|
|
1854
|
+
for (const [k, v] of Object.entries(saved)) {
|
|
1855
|
+
// Prune entries older than 24 hours
|
|
1856
|
+
if (now - v.timestamp < 24 * 60 * 60 * 1000) {
|
|
1857
|
+
dispatchCooldowns.set(k, v);
|
|
1858
|
+
}
|
|
1859
|
+
}
|
|
1860
|
+
log('info', `Loaded ${dispatchCooldowns.size} cooldowns from disk`);
|
|
1861
|
+
}
|
|
1862
|
+
|
|
1863
|
+
let _cooldownWriteTimer = null;
|
|
1864
|
+
function saveCooldowns() {
|
|
1865
|
+
// Debounce: reset timer on each call so latest state is always written
|
|
1866
|
+
if (_cooldownWriteTimer) clearTimeout(_cooldownWriteTimer);
|
|
1867
|
+
_cooldownWriteTimer = setTimeout(() => {
|
|
1868
|
+
_cooldownWriteTimer = null;
|
|
1869
|
+
// Prune expired entries (>24h) before saving
|
|
1870
|
+
const now = Date.now();
|
|
1871
|
+
for (const [k, v] of dispatchCooldowns) {
|
|
1872
|
+
if (now - v.timestamp > 24 * 60 * 60 * 1000) dispatchCooldowns.delete(k);
|
|
1873
|
+
}
|
|
1874
|
+
const obj = Object.fromEntries(dispatchCooldowns);
|
|
1875
|
+
safeWrite(COOLDOWN_PATH, obj);
|
|
1876
|
+
}, 1000); // debounce — write at most once per second
|
|
1877
|
+
}
|
|
1878
|
+
|
|
1879
|
+
function isOnCooldown(key, cooldownMs) {
|
|
1880
|
+
const entry = dispatchCooldowns.get(key);
|
|
1881
|
+
if (!entry) return false;
|
|
1882
|
+
const backoff = Math.min(Math.pow(2, entry.failures || 0), 2);
|
|
1883
|
+
return (Date.now() - entry.timestamp) < (cooldownMs * backoff);
|
|
1884
|
+
}
|
|
1885
|
+
|
|
1886
|
+
function setCooldown(key) {
|
|
1887
|
+
const existing = dispatchCooldowns.get(key);
|
|
1888
|
+
dispatchCooldowns.set(key, { timestamp: Date.now(), failures: existing?.failures || 0 });
|
|
1889
|
+
saveCooldowns();
|
|
1890
|
+
}
|
|
1891
|
+
|
|
1892
|
+
function setCooldownFailure(key) {
|
|
1893
|
+
const existing = dispatchCooldowns.get(key);
|
|
1894
|
+
const failures = (existing?.failures || 0) + 1;
|
|
1895
|
+
dispatchCooldowns.set(key, { timestamp: Date.now(), failures });
|
|
1896
|
+
if (failures >= 3) {
|
|
1897
|
+
log('warn', `${key} has failed ${failures} times — cooldown is now ${Math.min(Math.pow(2, failures), 2)}x`);
|
|
1898
|
+
}
|
|
1899
|
+
saveCooldowns();
|
|
1900
|
+
}
|
|
1901
|
+
|
|
1902
|
+
function isAlreadyDispatched(key) {
|
|
1903
|
+
const dispatch = getDispatch();
|
|
1904
|
+
// Check pending and active
|
|
1905
|
+
const inFlight = [...dispatch.pending, ...(dispatch.active || [])];
|
|
1906
|
+
if (inFlight.some(d => d.meta?.dispatchKey === key)) return true;
|
|
1907
|
+
// Also check recently completed (last hour) to prevent re-dispatch
|
|
1908
|
+
const oneHourAgo = Date.now() - 3600000;
|
|
1909
|
+
const recentCompleted = (dispatch.completed || []).filter(d =>
|
|
1910
|
+
d.completed_at && new Date(d.completed_at).getTime() > oneHourAgo
|
|
1911
|
+
);
|
|
1912
|
+
return recentCompleted.some(d => d.meta?.dispatchKey === key);
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
|
|
1916
|
+
|
|
1917
|
+
/**
|
|
1918
|
+
* Scan ~/.minions/plans/ for plan-generated PRD files → queue implement tasks.
|
|
1919
|
+
* Plans are project-scoped JSON files written by the plan-to-prd playbook.
|
|
1920
|
+
*/
|
|
1921
|
+
/**
|
|
1922
|
+
* Convert plan files into project work items (side-effect, like specs).
|
|
1923
|
+
* Plans write to the target project's work-items.json — picked up by discoverFromWorkItems next tick.
|
|
1924
|
+
*/
|
|
1925
|
+
// Auto-clean pending/failed work items for a PRD so they re-materialize with updated plan data
|
|
1926
|
+
function autoCleanPrdWorkItems(prdFile, config) {
|
|
1927
|
+
const allProjects = getProjects(config);
|
|
1928
|
+
const wiPaths = [path.join(MINIONS_DIR, 'work-items.json')];
|
|
1929
|
+
for (const proj of allProjects) wiPaths.push(projectWorkItemsPath(proj));
|
|
1930
|
+
const deletedIds = [];
|
|
1931
|
+
for (const wiPath of wiPaths) {
|
|
1932
|
+
try {
|
|
1933
|
+
const items = safeJson(wiPath);
|
|
1934
|
+
if (!items) continue;
|
|
1935
|
+
const filtered = items.filter(w => {
|
|
1936
|
+
if (w.sourcePlan === prdFile && (w.status === 'pending' || w.status === 'failed')) {
|
|
1937
|
+
deletedIds.push(w.id); return false;
|
|
1938
|
+
}
|
|
1939
|
+
return true;
|
|
1940
|
+
});
|
|
1941
|
+
if (filtered.length < items.length) safeWrite(wiPath, filtered);
|
|
1942
|
+
} catch {}
|
|
1943
|
+
}
|
|
1944
|
+
if (deletedIds.length > 0) {
|
|
1945
|
+
const deletedSet = new Set(deletedIds);
|
|
1946
|
+
cleanDispatchEntries(d => deletedSet.has(d.meta?.item?.id) && d.meta?.item?.sourcePlan === prdFile);
|
|
1947
|
+
log('info', `Plan sync: cleared ${deletedIds.length} pending/failed work items for ${prdFile}`);
|
|
1948
|
+
}
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1951
|
+
function materializePlansAsWorkItems(config) {
|
|
1952
|
+
if (!fs.existsSync(PRD_DIR)) { try { fs.mkdirSync(PRD_DIR, { recursive: true }); } catch {} }
|
|
1953
|
+
|
|
1954
|
+
// Enforce: PRDs must be .json — auto-rename .md files that contain valid PRD JSON
|
|
1955
|
+
// Check both prd/ and plans/ (agents may still write JSON to plans/)
|
|
1956
|
+
for (const checkDir of [PRD_DIR, PLANS_DIR]) {
|
|
1957
|
+
if (!fs.existsSync(checkDir)) continue;
|
|
1958
|
+
try {
|
|
1959
|
+
const mdFiles = fs.readdirSync(checkDir).filter(f => f.endsWith('.md'));
|
|
1960
|
+
for (const mf of mdFiles) {
|
|
1961
|
+
try {
|
|
1962
|
+
const content = (safeRead(path.join(checkDir, mf)) || '').trim();
|
|
1963
|
+
// Strip markdown code fences if agent wrapped JSON in them
|
|
1964
|
+
const stripped = content.replace(/^```(?:json)?\s*\n?/, '').replace(/\n?```\s*$/, '').trim();
|
|
1965
|
+
const parsed = JSON.parse(stripped);
|
|
1966
|
+
if (parsed.missing_features) {
|
|
1967
|
+
const jsonName = mf.replace(/\.md$/, '.json');
|
|
1968
|
+
safeWrite(path.join(PRD_DIR, jsonName), parsed);
|
|
1969
|
+
try { fs.unlinkSync(path.join(checkDir, mf)); } catch {}
|
|
1970
|
+
log('info', `Plan enforcement: moved ${mf} → prd/${jsonName} (PRDs must be .json in prd/)`);
|
|
1971
|
+
}
|
|
1972
|
+
} catch {} // Not JSON — it's a proper plan .md, leave it
|
|
1973
|
+
}
|
|
1974
|
+
} catch {}
|
|
1975
|
+
// Also migrate any .json PRD files from plans/ to prd/
|
|
1976
|
+
if (checkDir === PLANS_DIR) {
|
|
1977
|
+
try {
|
|
1978
|
+
const jsonInPlans = fs.readdirSync(PLANS_DIR).filter(f => f.endsWith('.json'));
|
|
1979
|
+
for (const jf of jsonInPlans) {
|
|
1980
|
+
try {
|
|
1981
|
+
const parsed = safeJson(path.join(PLANS_DIR, jf));
|
|
1982
|
+
if (parsed?.missing_features) {
|
|
1983
|
+
safeWrite(path.join(PRD_DIR, jf), parsed);
|
|
1984
|
+
try { fs.unlinkSync(path.join(PLANS_DIR, jf)); } catch {}
|
|
1985
|
+
log('info', `Auto-migrated PRD ${jf} from plans/ to prd/`);
|
|
1986
|
+
}
|
|
1987
|
+
} catch {}
|
|
1988
|
+
}
|
|
1989
|
+
} catch {}
|
|
1990
|
+
}
|
|
1991
|
+
}
|
|
1992
|
+
|
|
1993
|
+
let planFiles;
|
|
1994
|
+
try { planFiles = fs.readdirSync(PRD_DIR).filter(f => f.endsWith('.json')); } catch { return; }
|
|
1995
|
+
|
|
1996
|
+
for (const file of planFiles) {
|
|
1997
|
+
const plan = safeJson(path.join(PRD_DIR, file));
|
|
1998
|
+
if (!plan?.missing_features) continue;
|
|
1999
|
+
|
|
2000
|
+
// Plan staleness: if source_plan .md was modified since last sync, auto-clean and re-sync
|
|
2001
|
+
if (plan.source_plan) {
|
|
2002
|
+
const sourcePlanPath = path.join(PLANS_DIR, plan.source_plan);
|
|
2003
|
+
try {
|
|
2004
|
+
const sourceMtime = Math.floor(fs.statSync(sourcePlanPath).mtimeMs); // floor to strip sub-ms Windows precision
|
|
2005
|
+
const recorded = plan.sourcePlanModifiedAt ? new Date(plan.sourcePlanModifiedAt).getTime() : null;
|
|
2006
|
+
if (!recorded) {
|
|
2007
|
+
// First time seeing this plan — record baseline mtime (no clean needed)
|
|
2008
|
+
plan.sourcePlanModifiedAt = new Date(sourceMtime).toISOString();
|
|
2009
|
+
safeWrite(path.join(PRD_DIR, file), plan);
|
|
2010
|
+
} else if (sourceMtime > recorded) {
|
|
2011
|
+
// Source plan changed — auto-clean pending/failed items so they re-materialize with updated data
|
|
2012
|
+
log('info', `Source plan ${plan.source_plan} updated — re-syncing PRD ${file}`);
|
|
2013
|
+
autoCleanPrdWorkItems(file, config);
|
|
2014
|
+
plan.sourcePlanModifiedAt = new Date(sourceMtime).toISOString();
|
|
2015
|
+
plan.lastSyncedFromPlan = new Date().toISOString();
|
|
2016
|
+
|
|
2017
|
+
// Handle PRD based on current status
|
|
2018
|
+
const prdStatus = plan.status || (plan.requires_approval ? 'awaiting-approval' : null);
|
|
2019
|
+
|
|
2020
|
+
// Approved/executing PRDs: flag as stale but don't disrupt in-flight work
|
|
2021
|
+
if (prdStatus === 'approved' || prdStatus === 'completed') {
|
|
2022
|
+
plan.planStale = true;
|
|
2023
|
+
log('info', `PRD ${file} flagged as stale (plan revised while ${prdStatus}) — user can regenerate from dashboard`);
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
// Awaiting-approval PRDs: invalidate, carry over completed items, delete old PRD, queue regeneration
|
|
2027
|
+
if (prdStatus === 'awaiting-approval') {
|
|
2028
|
+
log('info', `PRD ${file} invalidated (was awaiting-approval) — queuing regeneration from revised plan`);
|
|
2029
|
+
|
|
2030
|
+
// Collect completed items to carry over to new PRD
|
|
2031
|
+
const completedStatuses = new Set(['done', 'in-pr', 'implemented']); // in-pr kept for backward compat
|
|
2032
|
+
const completedItems = (plan.missing_features || [])
|
|
2033
|
+
.filter(f => completedStatuses.has(f.status))
|
|
2034
|
+
.map(f => ({ id: f.id, name: f.name, status: f.status }));
|
|
2035
|
+
|
|
2036
|
+
const completedContext = completedItems.length > 0
|
|
2037
|
+
? `\nPreviously completed items (preserve their status in the new PRD):\n${completedItems.map(i => `- ${i.id}: ${i.name} [${i.status}]`).join('\n')}`
|
|
2038
|
+
: '';
|
|
2039
|
+
|
|
2040
|
+
// Delete old PRD — agent will write replacement at same path
|
|
2041
|
+
try { fs.unlinkSync(path.join(PRD_DIR, file)); } catch {}
|
|
2042
|
+
|
|
2043
|
+
// Queue plan-to-prd regeneration
|
|
2044
|
+
const planContent = safeRead(path.join(PLANS_DIR, plan.source_plan));
|
|
2045
|
+
if (planContent) {
|
|
2046
|
+
const projectName = plan.project || file.replace(/-\d{4}-\d{2}-\d{2}\.json$/, '');
|
|
2047
|
+
const allProjects = getProjects(config);
|
|
2048
|
+
const targetProject = allProjects.find(p => p.name?.toLowerCase() === projectName.toLowerCase()) || allProjects[0];
|
|
2049
|
+
if (targetProject) {
|
|
2050
|
+
const centralWiPath = path.join(MINIONS_DIR, 'work-items.json');
|
|
2051
|
+
const centralItems = safeJson(centralWiPath) || [];
|
|
2052
|
+
const alreadyQueued = centralItems.some(w =>
|
|
2053
|
+
w.type === 'plan-to-prd' && w.planFile === plan.source_plan && (w.status === 'pending' || w.status === 'dispatched')
|
|
2054
|
+
);
|
|
2055
|
+
if (!alreadyQueued) {
|
|
2056
|
+
centralItems.push({
|
|
2057
|
+
id: 'W-' + shared.uid(),
|
|
2058
|
+
title: `Regenerate PRD from revised plan: ${plan.source_plan}`,
|
|
2059
|
+
type: 'plan-to-prd',
|
|
2060
|
+
priority: 'high',
|
|
2061
|
+
description: `Plan file: plans/${plan.source_plan}\nTarget PRD filename: ${file}\nSource plan was revised while PRD was awaiting approval — regenerating.${completedContext}`,
|
|
2062
|
+
status: 'pending',
|
|
2063
|
+
created: ts(),
|
|
2064
|
+
createdBy: 'engine:plan-revision',
|
|
2065
|
+
project: targetProject.name,
|
|
2066
|
+
planFile: plan.source_plan,
|
|
2067
|
+
_targetPrdFile: file,
|
|
2068
|
+
});
|
|
2069
|
+
safeWrite(centralWiPath, centralItems);
|
|
2070
|
+
log('info', `Queued plan-to-prd regeneration for revised plan ${plan.source_plan} (${completedItems.length} completed items to carry over)`);
|
|
2071
|
+
}
|
|
2072
|
+
}
|
|
2073
|
+
}
|
|
2074
|
+
continue; // Old PRD deleted — skip safeWrite below
|
|
2075
|
+
}
|
|
2076
|
+
|
|
2077
|
+
safeWrite(path.join(PRD_DIR, file), plan);
|
|
2078
|
+
}
|
|
2079
|
+
} catch {}
|
|
2080
|
+
}
|
|
2081
|
+
|
|
2082
|
+
// Human approval gate: plans start as 'awaiting-approval' and must be approved before work begins
|
|
2083
|
+
// Plans without a status (legacy) or with status 'approved' are allowed through
|
|
2084
|
+
const planStatus = plan.status || (plan.requires_approval ? 'awaiting-approval' : null);
|
|
2085
|
+
if (planStatus === 'awaiting-approval' || planStatus === 'paused' || planStatus === 'rejected' || planStatus === 'revision-requested') {
|
|
2086
|
+
continue; // Skip — waiting for human approval, paused, or revision
|
|
2087
|
+
}
|
|
2088
|
+
// Stale PRDs: source plan was revised — don't materialize NEW items until user regenerates
|
|
2089
|
+
if (plan.planStale) {
|
|
2090
|
+
continue;
|
|
2091
|
+
}
|
|
2092
|
+
|
|
2093
|
+
const defaultProjectName = plan.project || file.replace(/-\d{4}-\d{2}-\d{2}\.json$/, '');
|
|
2094
|
+
const allProjects = getProjects(config);
|
|
2095
|
+
const defaultProject = allProjects.find(p => p.name?.toLowerCase() === defaultProjectName.toLowerCase());
|
|
2096
|
+
if (!defaultProject) continue;
|
|
2097
|
+
|
|
2098
|
+
const statusFilter = ['missing', 'planned'];
|
|
2099
|
+
// Also materialize in-pr/done items that never got a work item (race with PR status sync)
|
|
2100
|
+
const allExistingWiIds = new Set();
|
|
2101
|
+
for (const p of allProjects) {
|
|
2102
|
+
for (const w of (safeJson(projectWorkItemsPath(p)) || [])) {
|
|
2103
|
+
if (w.id) allExistingWiIds.add(w.id);
|
|
2104
|
+
}
|
|
2105
|
+
}
|
|
2106
|
+
const items = plan.missing_features.filter(f =>
|
|
2107
|
+
statusFilter.includes(f.status) ||
|
|
2108
|
+
((f.status === 'in-pr' || f.status === 'done') && f.id && !allExistingWiIds.has(f.id))
|
|
2109
|
+
);
|
|
2110
|
+
|
|
2111
|
+
// Group items by target project (per-item project field overrides plan-level project)
|
|
2112
|
+
const itemsByProject = new Map(); // projectName -> { project, items: [] }
|
|
2113
|
+
for (const item of items) {
|
|
2114
|
+
const itemProjectName = item.project || defaultProjectName;
|
|
2115
|
+
const itemProject = allProjects.find(p => p.name?.toLowerCase() === itemProjectName.toLowerCase()) || defaultProject;
|
|
2116
|
+
if (!itemsByProject.has(itemProject.name)) {
|
|
2117
|
+
itemsByProject.set(itemProject.name, { project: itemProject, items: [] });
|
|
2118
|
+
}
|
|
2119
|
+
itemsByProject.get(itemProject.name).items.push(item);
|
|
2120
|
+
}
|
|
2121
|
+
|
|
2122
|
+
// Cycle detection BEFORE materialization — skip cyclic items
|
|
2123
|
+
const cycleSet = new Set();
|
|
2124
|
+
const planDerivedItems = plan.missing_features.filter(f => f.depends_on && f.depends_on.length > 0);
|
|
2125
|
+
if (planDerivedItems.length > 0) {
|
|
2126
|
+
const cycles = detectDependencyCycles(plan.missing_features);
|
|
2127
|
+
if (cycles.length > 0) {
|
|
2128
|
+
log('error', `Dependency cycle detected in plan ${file}: ${cycles.join(', ')} — skipping cyclic items`);
|
|
2129
|
+
cycles.forEach(c => cycleSet.add(c));
|
|
2130
|
+
writeInboxAlert(`cycle-${path.basename(file, '.json')}`,
|
|
2131
|
+
`# Dependency Cycle Detected — ${path.basename(file)}\n\n` +
|
|
2132
|
+
`The following PRD items form a cycle and were **skipped** (will never be dispatched):\n\n` +
|
|
2133
|
+
cycles.map(id => `- \`${id}\``).join('\n') + '\n\n' +
|
|
2134
|
+
`Fix by removing or reordering the \`depends_on\` relationships in \`prd/${path.basename(file)}\`.\n`
|
|
2135
|
+
);
|
|
2136
|
+
}
|
|
2137
|
+
}
|
|
2138
|
+
|
|
2139
|
+
let totalCreated = 0;
|
|
2140
|
+
for (const [projName, { project, items: projItems }] of itemsByProject) {
|
|
2141
|
+
const wiPath = projectWorkItemsPath(project);
|
|
2142
|
+
const existingItems = safeJson(wiPath) || [];
|
|
2143
|
+
let created = 0;
|
|
2144
|
+
const newlyCreatedIds = new Set(); // tracks IDs created in this pass for reconciliation scoping
|
|
2145
|
+
|
|
2146
|
+
for (const item of projItems) {
|
|
2147
|
+
// Skip if already materialized — work item ID = PRD item ID, check all projects
|
|
2148
|
+
let alreadyExists = existingItems.some(w => w.id === item.id);
|
|
2149
|
+
if (!alreadyExists) {
|
|
2150
|
+
for (const p of allProjects) {
|
|
2151
|
+
if (p.name === projName) continue;
|
|
2152
|
+
const otherItems = safeJson(projectWorkItemsPath(p)) || [];
|
|
2153
|
+
if (otherItems.some(w => w.id === item.id)) { alreadyExists = true; break; }
|
|
2154
|
+
}
|
|
2155
|
+
}
|
|
2156
|
+
if (alreadyExists) continue;
|
|
2157
|
+
// Skip items involved in dependency cycles
|
|
2158
|
+
if (cycleSet.has(item.id)) continue;
|
|
2159
|
+
|
|
2160
|
+
const id = item.id; // Work item ID = PRD item ID — no indirection
|
|
2161
|
+
const complexity = item.estimated_complexity || 'medium';
|
|
2162
|
+
const criteria = (item.acceptance_criteria || []).map(c => `- ${c}`).join('\n');
|
|
2163
|
+
|
|
2164
|
+
const newItem = {
|
|
2165
|
+
id,
|
|
2166
|
+
title: `Implement: ${item.name}`,
|
|
2167
|
+
type: complexity === 'large' ? 'implement:large' : 'implement',
|
|
2168
|
+
priority: item.priority || 'medium',
|
|
2169
|
+
description: `${item.description || ''}\n\n**Plan:** ${file}\n**Plan Item:** ${item.id}\n**Complexity:** ${complexity}${criteria ? '\n\n**Acceptance Criteria:**\n' + criteria : ''}`,
|
|
2170
|
+
status: 'pending',
|
|
2171
|
+
created: ts(),
|
|
2172
|
+
createdBy: 'engine:plan-discovery',
|
|
2173
|
+
sourcePlan: file,
|
|
2174
|
+
depends_on: item.depends_on || [],
|
|
2175
|
+
branchStrategy: plan.branch_strategy || 'parallel',
|
|
2176
|
+
featureBranch: plan.feature_branch || null,
|
|
2177
|
+
project: item.project || plan.project || null,
|
|
2178
|
+
};
|
|
2179
|
+
existingItems.push(newItem);
|
|
2180
|
+
newlyCreatedIds.add(id);
|
|
2181
|
+
created++;
|
|
2182
|
+
}
|
|
2183
|
+
|
|
2184
|
+
if (created > 0) {
|
|
2185
|
+
// Reconciliation: exact prdItems match only, scoped to newly created items
|
|
2186
|
+
const allPrsForReconcile = allProjects.flatMap(p => safeJson(projectPrPath(p)) || []);
|
|
2187
|
+
const reconciled = reconcileItemsWithPrs(existingItems, allPrsForReconcile, { onlyIds: newlyCreatedIds });
|
|
2188
|
+
if (reconciled > 0) log('info', `Plan reconciliation: marked ${reconciled} item(s) as done → ${projName}`);
|
|
2189
|
+
|
|
2190
|
+
// PRD removal sync: cancel pending work items whose PRD item was removed from the plan
|
|
2191
|
+
const currentPrdIds = new Set(plan.missing_features.map(f => f.id));
|
|
2192
|
+
let cancelled = 0;
|
|
2193
|
+
for (const wi of existingItems) {
|
|
2194
|
+
if (wi.status !== 'pending' || wi.sourcePlan !== file) continue;
|
|
2195
|
+
if (!currentPrdIds.has(wi.id)) {
|
|
2196
|
+
wi.status = 'cancelled';
|
|
2197
|
+
wi.cancelledAt = ts();
|
|
2198
|
+
wi.cancelReason = `PRD item removed from ${file}`;
|
|
2199
|
+
cancelled++;
|
|
2200
|
+
}
|
|
2201
|
+
}
|
|
2202
|
+
if (cancelled > 0) log('info', `Plan sync: cancelled ${cancelled} item(s) removed from ${file} → ${projName}`);
|
|
2203
|
+
|
|
2204
|
+
safeWrite(wiPath, existingItems);
|
|
2205
|
+
log('info', `Plan discovery: created ${created} work item(s) from ${file} → ${projName}`);
|
|
2206
|
+
}
|
|
2207
|
+
totalCreated += created;
|
|
2208
|
+
}
|
|
2209
|
+
|
|
2210
|
+
if (totalCreated > 0) {
|
|
2211
|
+
log('info', `Plan discovery: ${totalCreated} total item(s) from ${file} across ${itemsByProject.size} project(s)`);
|
|
2212
|
+
|
|
2213
|
+
// Pre-create shared feature branch if branch_strategy is shared-branch
|
|
2214
|
+
if (plan.branch_strategy === 'shared-branch' && plan.feature_branch) {
|
|
2215
|
+
try {
|
|
2216
|
+
const root = path.resolve(project.localPath);
|
|
2217
|
+
const mainBranch = project.mainBranch || 'main';
|
|
2218
|
+
const branch = sanitizeBranch(plan.feature_branch);
|
|
2219
|
+
// Create branch from main (idempotent — ignores if exists)
|
|
2220
|
+
exec(`git branch "${branch}" "${mainBranch}" 2>/dev/null || true`, { cwd: root, stdio: 'pipe' });
|
|
2221
|
+
exec(`git push -u origin "${branch}" 2>/dev/null || true`, { cwd: root, stdio: 'pipe' });
|
|
2222
|
+
log('info', `Shared branch pre-created: ${branch} for plan ${file}`);
|
|
2223
|
+
} catch (err) {
|
|
2224
|
+
log('warn', `Failed to pre-create shared branch for ${file}: ${err.message}`);
|
|
2225
|
+
}
|
|
2226
|
+
}
|
|
2227
|
+
|
|
2228
|
+
// (cycle detection moved before materialization loop)
|
|
2229
|
+
}
|
|
2230
|
+
}
|
|
2231
|
+
}
|
|
2232
|
+
|
|
2233
|
+
// ─── Work Discovery Helpers ──────────────────────────────────────────────────
|
|
2234
|
+
|
|
2235
|
+
function buildBaseVars(agentId, config, project) {
|
|
2236
|
+
return {
|
|
2237
|
+
agent_id: agentId,
|
|
2238
|
+
agent_name: config.agents[agentId]?.name || agentId,
|
|
2239
|
+
agent_role: config.agents[agentId]?.role || 'Agent',
|
|
2240
|
+
team_root: MINIONS_DIR,
|
|
2241
|
+
repo_id: project?.repositoryId || '',
|
|
2242
|
+
project_name: project?.name || 'Unknown Project',
|
|
2243
|
+
ado_org: project?.adoOrg || 'Unknown',
|
|
2244
|
+
ado_project: project?.adoProject || 'Unknown',
|
|
2245
|
+
repo_name: project?.repoName || 'Unknown',
|
|
2246
|
+
main_branch: project?.mainBranch || 'main',
|
|
2247
|
+
date: dateStamp(),
|
|
2248
|
+
};
|
|
2249
|
+
}
|
|
2250
|
+
|
|
2251
|
+
function selectPlaybook(workType, item) {
|
|
2252
|
+
if (item?.branchStrategy === 'shared-branch' && (workType === 'implement' || workType === 'implement:large')) {
|
|
2253
|
+
return 'implement-shared';
|
|
2254
|
+
}
|
|
2255
|
+
if (workType === 'review' && !item?._pr && !item?.pr_id) {
|
|
2256
|
+
return 'work-item';
|
|
2257
|
+
}
|
|
2258
|
+
const typeSpecificPlaybooks = ['explore', 'review', 'test', 'plan-to-prd', 'plan', 'ask', 'verify'];
|
|
2259
|
+
return typeSpecificPlaybooks.includes(workType) ? workType : 'work-item';
|
|
2260
|
+
}
|
|
2261
|
+
|
|
2262
|
+
function buildPrDispatch(agentId, config, project, pr, type, extraVars, taskLabel, meta) {
|
|
2263
|
+
const vars = { ...buildBaseVars(agentId, config, project), ...extraVars };
|
|
2264
|
+
const playbookName = type === 'test' ? 'build-and-test' : (type === 'review' ? 'review' : 'fix');
|
|
2265
|
+
const prompt = renderPlaybook(playbookName, vars);
|
|
2266
|
+
if (!prompt) return null;
|
|
2267
|
+
return {
|
|
2268
|
+
type,
|
|
2269
|
+
agent: agentId,
|
|
2270
|
+
agentName: config.agents[agentId]?.name,
|
|
2271
|
+
agentRole: config.agents[agentId]?.role,
|
|
2272
|
+
task: `[${project?.name || 'project'}] ${taskLabel}`,
|
|
2273
|
+
prompt,
|
|
2274
|
+
meta,
|
|
2275
|
+
};
|
|
2276
|
+
}
|
|
2277
|
+
|
|
2278
|
+
/**
|
|
2279
|
+
* Scan pull-requests.json for PRs needing review or fixes
|
|
2280
|
+
*/
|
|
2281
|
+
function discoverFromPrs(config, project) {
|
|
2282
|
+
const src = project?.workSources?.pullRequests || config.workSources?.pullRequests;
|
|
2283
|
+
if (!src?.enabled) return [];
|
|
2284
|
+
|
|
2285
|
+
const prs = safeJson(projectPrPath(project)) || [];
|
|
2286
|
+
const cooldownMs = (src.cooldownMinutes || 30) * 60 * 1000;
|
|
2287
|
+
const newWork = [];
|
|
2288
|
+
|
|
2289
|
+
const projMeta = { name: project?.name, localPath: project?.localPath };
|
|
2290
|
+
|
|
2291
|
+
// Collect active PR dispatches to prevent simultaneous review+fix on same PR
|
|
2292
|
+
const dispatch = getDispatch();
|
|
2293
|
+
const activePrIds = new Set(
|
|
2294
|
+
(dispatch.active || []).filter(d => d.meta?.pr?.id).map(d => d.meta.pr.id)
|
|
2295
|
+
);
|
|
2296
|
+
|
|
2297
|
+
for (const pr of prs) {
|
|
2298
|
+
if (pr.status !== 'active') continue;
|
|
2299
|
+
if (activePrIds.has(pr.id)) continue; // Skip PRs with active dispatch (prevent race)
|
|
2300
|
+
|
|
2301
|
+
const prNumber = (pr.id || '').replace(/^PR-/, '');
|
|
2302
|
+
const minionsStatus = pr.minionsReview?.status;
|
|
2303
|
+
|
|
2304
|
+
// PRs needing review
|
|
2305
|
+
const needsReview = !minionsStatus || minionsStatus === 'waiting';
|
|
2306
|
+
if (needsReview) {
|
|
2307
|
+
const key = `review-${project?.name || 'default'}-${pr.id}`;
|
|
2308
|
+
if (isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) continue;
|
|
2309
|
+
// No self-review: exclude the PR author from review assignment
|
|
2310
|
+
const prAuthor = (pr.agent || '').toLowerCase();
|
|
2311
|
+
let agentId = resolveAgent('review', config);
|
|
2312
|
+
if (agentId && agentId === prAuthor) {
|
|
2313
|
+
agentId = resolveAgent('review', config); // retry — prAuthor now claimed, gets skipped
|
|
2314
|
+
}
|
|
2315
|
+
if (!agentId) continue;
|
|
2316
|
+
|
|
2317
|
+
const item = buildPrDispatch(agentId, config, project, pr, 'review', {
|
|
2318
|
+
pr_id: pr.id, pr_number: prNumber, pr_title: pr.title || '', pr_branch: pr.branch || '',
|
|
2319
|
+
pr_author: pr.agent || '', pr_url: pr.url || '',
|
|
2320
|
+
}, `Review PR ${pr.id}: ${pr.title}`, { dispatchKey: key, source: 'pr', pr, branch: pr.branch, project: projMeta });
|
|
2321
|
+
if (item) { newWork.push(item); setCooldown(key); }
|
|
2322
|
+
}
|
|
2323
|
+
|
|
2324
|
+
// PRs with changes requested → route back to author for fix
|
|
2325
|
+
if (minionsStatus === 'changes-requested') {
|
|
2326
|
+
const key = `fix-${project?.name || 'default'}-${pr.id}`;
|
|
2327
|
+
if (isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) continue;
|
|
2328
|
+
const agentId = resolveAgent('fix', config, pr.agent);
|
|
2329
|
+
if (!agentId) continue;
|
|
2330
|
+
|
|
2331
|
+
const item = buildPrDispatch(agentId, config, project, pr, 'fix', {
|
|
2332
|
+
pr_id: pr.id, pr_branch: pr.branch || '',
|
|
2333
|
+
review_note: pr.minionsReview?.note || pr.reviewNote || 'See PR thread comments',
|
|
2334
|
+
}, `Fix PR ${pr.id} review feedback`, { dispatchKey: key, source: 'pr', pr, branch: pr.branch, project: projMeta });
|
|
2335
|
+
if (item) { newWork.push(item); setCooldown(key); }
|
|
2336
|
+
}
|
|
2337
|
+
|
|
2338
|
+
// PRs with pending human feedback
|
|
2339
|
+
if (pr.humanFeedback?.pendingFix) {
|
|
2340
|
+
const key = `human-fix-${project?.name || 'default'}-${pr.id}`;
|
|
2341
|
+
if (isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) continue;
|
|
2342
|
+
const agentId = resolveAgent('fix', config, pr.agent);
|
|
2343
|
+
if (!agentId) continue;
|
|
2344
|
+
|
|
2345
|
+
const item = buildPrDispatch(agentId, config, project, pr, 'fix', {
|
|
2346
|
+
pr_id: pr.id, pr_number: prNumber, pr_title: pr.title || '', pr_branch: pr.branch || '',
|
|
2347
|
+
reviewer: 'Human Reviewer',
|
|
2348
|
+
review_note: pr.humanFeedback.feedbackContent || 'See PR thread comments',
|
|
2349
|
+
}, `Fix PR ${pr.id} — human feedback`, { dispatchKey: key, source: 'pr-human-feedback', pr, branch: pr.branch, project: projMeta });
|
|
2350
|
+
if (item) { newWork.push(item); pr.humanFeedback.pendingFix = false; setCooldown(key); }
|
|
2351
|
+
}
|
|
2352
|
+
|
|
2353
|
+
// PRs with build failures
|
|
2354
|
+
if (pr.status === 'active' && pr.buildStatus === 'failing') {
|
|
2355
|
+
const key = `build-fix-${project?.name || 'default'}-${pr.id}`;
|
|
2356
|
+
if (isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) continue;
|
|
2357
|
+
const agentId = resolveAgent('fix', config);
|
|
2358
|
+
if (!agentId) continue;
|
|
2359
|
+
|
|
2360
|
+
const item = buildPrDispatch(agentId, config, project, pr, 'fix', {
|
|
2361
|
+
pr_id: pr.id, pr_branch: pr.branch || '',
|
|
2362
|
+
review_note: `Build is failing: ${pr.buildFailReason || 'Check CI pipeline for details'}. Fix the build errors and push.`,
|
|
2363
|
+
}, `Fix build failure on PR ${pr.id}`, { dispatchKey: key, source: 'pr', pr, branch: pr.branch, project: projMeta });
|
|
2364
|
+
if (item) { newWork.push(item); setCooldown(key); }
|
|
2365
|
+
}
|
|
2366
|
+
|
|
2367
|
+
}
|
|
2368
|
+
// Build & test now runs once at PRD completion (via verify task), not per-PR.
|
|
2369
|
+
|
|
2370
|
+
return newWork;
|
|
2371
|
+
}
|
|
2372
|
+
|
|
2373
|
+
/**
|
|
2374
|
+
* Scan work-items.json for manually queued tasks
|
|
2375
|
+
*/
|
|
2376
|
+
function discoverFromWorkItems(config, project) {
|
|
2377
|
+
const src = project?.workSources?.workItems || config.workSources?.workItems;
|
|
2378
|
+
if (!src?.enabled) return [];
|
|
2379
|
+
|
|
2380
|
+
const root = project?.localPath ? path.resolve(project.localPath) : path.resolve(MINIONS_DIR, '..');
|
|
2381
|
+
const items = safeJson(projectWorkItemsPath(project)) || [];
|
|
2382
|
+
const cooldownMs = (src.cooldownMinutes || 0) * 60 * 1000;
|
|
2383
|
+
const newWork = [];
|
|
2384
|
+
const skipped = { gated: 0, noAgent: 0 };
|
|
2385
|
+
let needsWrite = false;
|
|
2386
|
+
|
|
2387
|
+
for (const item of items) {
|
|
2388
|
+
// Re-evaluate failed items: if deps have recovered, reset to pending
|
|
2389
|
+
if (item.status === 'failed' && item.failReason === 'Dependency failed — cannot proceed') {
|
|
2390
|
+
const depStatus = areDependenciesMet(item, config);
|
|
2391
|
+
if (depStatus === true) {
|
|
2392
|
+
item.status = 'pending';
|
|
2393
|
+
delete item.failReason;
|
|
2394
|
+
log('info', `Recovered ${item.id} from dependency failure — deps now met`);
|
|
2395
|
+
needsWrite = true;
|
|
2396
|
+
}
|
|
2397
|
+
}
|
|
2398
|
+
|
|
2399
|
+
if (item.status !== 'queued' && item.status !== 'pending') continue;
|
|
2400
|
+
|
|
2401
|
+
// Dependency gate: skip items whose depends_on are not yet met; propagate failure
|
|
2402
|
+
if (item.depends_on && item.depends_on.length > 0) {
|
|
2403
|
+
const depStatus = areDependenciesMet(item, config);
|
|
2404
|
+
if (depStatus === 'failed') {
|
|
2405
|
+
item.status = 'failed';
|
|
2406
|
+
item.failReason = 'Dependency failed — cannot proceed';
|
|
2407
|
+
log('warn', `Marking ${item.id} as failed: dependency failed (plan: ${item.sourcePlan})`);
|
|
2408
|
+
continue;
|
|
2409
|
+
}
|
|
2410
|
+
if (!depStatus) continue;
|
|
2411
|
+
}
|
|
2412
|
+
|
|
2413
|
+
const key = `work-${project?.name || 'default'}-${item.id}`;
|
|
2414
|
+
// Self-heal: if an item is pending, stale completed/cooldown markers must not gate redispatch.
|
|
2415
|
+
// This protects against persisted state drift from old runtime versions.
|
|
2416
|
+
try {
|
|
2417
|
+
mutateDispatch((dp) => {
|
|
2418
|
+
const before = Array.isArray(dp.completed) ? dp.completed.length : 0;
|
|
2419
|
+
dp.completed = Array.isArray(dp.completed) ? dp.completed.filter(d => d.meta?.dispatchKey !== key) : [];
|
|
2420
|
+
return dp.completed.length !== before ? dp : undefined;
|
|
2421
|
+
});
|
|
2422
|
+
dispatchCooldowns.delete(key);
|
|
2423
|
+
} catch {}
|
|
2424
|
+
// Cooldown bypass for resumed items — clear in-memory cooldown so they dispatch immediately
|
|
2425
|
+
if (item._resumedAt) {
|
|
2426
|
+
dispatchCooldowns.delete(key);
|
|
2427
|
+
delete item._resumedAt;
|
|
2428
|
+
safeWrite(projectWorkItemsPath(project), items);
|
|
2429
|
+
}
|
|
2430
|
+
if (isAlreadyDispatched(key) || isOnCooldown(key, cooldownMs)) { skipped.gated++; continue; }
|
|
2431
|
+
|
|
2432
|
+
let workType = item.type || 'implement';
|
|
2433
|
+
if (workType === 'implement' && (item.complexity === 'large' || item.estimated_complexity === 'large')) {
|
|
2434
|
+
workType = 'implement:large';
|
|
2435
|
+
}
|
|
2436
|
+
const agentId = item.agent || resolveAgent(workType, config);
|
|
2437
|
+
if (!agentId) { skipped.noAgent++; continue; }
|
|
2438
|
+
|
|
2439
|
+
const isShared = item.branchStrategy === 'shared-branch' && item.featureBranch;
|
|
2440
|
+
const branchName = isShared ? item.featureBranch : (item.branch || `work/${item.id}`);
|
|
2441
|
+
const vars = {
|
|
2442
|
+
...buildBaseVars(agentId, config, project),
|
|
2443
|
+
item_id: item.id,
|
|
2444
|
+
item_name: item.title || item.id,
|
|
2445
|
+
item_priority: item.priority || 'medium',
|
|
2446
|
+
item_description: item.description || '',
|
|
2447
|
+
item_complexity: item.complexity || item.estimated_complexity || 'medium',
|
|
2448
|
+
task_description: item.title + (item.description ? '\n\n' + item.description : ''),
|
|
2449
|
+
task_id: item.id,
|
|
2450
|
+
work_type: workType,
|
|
2451
|
+
additional_context: item.prompt ? `## Additional Context\n\n${item.prompt}` : '',
|
|
2452
|
+
scope_section: `## Scope: Project — ${project?.name || 'default'}\n\nThis task is scoped to a single project.`,
|
|
2453
|
+
branch_name: branchName,
|
|
2454
|
+
project_path: root,
|
|
2455
|
+
worktree_path: path.resolve(root, config.engine?.worktreeRoot || '../worktrees', `${branchName}`),
|
|
2456
|
+
commit_message: item.commitMessage || `feat: ${item.title || item.id}`,
|
|
2457
|
+
notes_content: '',
|
|
2458
|
+
};
|
|
2459
|
+
try { vars.notes_content = fs.readFileSync(path.join(MINIONS_DIR, 'notes.md'), 'utf8'); } catch {}
|
|
2460
|
+
|
|
2461
|
+
// Inject ask-specific variables for the ask playbook
|
|
2462
|
+
if (workType === 'ask') {
|
|
2463
|
+
vars.question = item.title + (item.description ? '\n\n' + item.description : '');
|
|
2464
|
+
vars.task_id = item.id;
|
|
2465
|
+
vars.notes_content = '';
|
|
2466
|
+
try { vars.notes_content = fs.readFileSync(path.join(MINIONS_DIR, 'notes.md'), 'utf8'); } catch {}
|
|
2467
|
+
}
|
|
2468
|
+
|
|
2469
|
+
// Resolve implicit context references (e.g., "ripley's plan", "the latest plan")
|
|
2470
|
+
const resolvedCtx = resolveTaskContext(item, config);
|
|
2471
|
+
if (resolvedCtx.additionalContext) {
|
|
2472
|
+
vars.additional_context = (vars.additional_context || '') + resolvedCtx.additionalContext;
|
|
2473
|
+
vars.task_description = vars.task_description + resolvedCtx.additionalContext;
|
|
2474
|
+
}
|
|
2475
|
+
|
|
2476
|
+
const playbookName = selectPlaybook(workType, item);
|
|
2477
|
+
if (playbookName === 'work-item' && workType === 'review') {
|
|
2478
|
+
log('info', `Work item ${item.id} is type "review" but has no PR — using work-item playbook`);
|
|
2479
|
+
}
|
|
2480
|
+
const prompt = item.prompt || renderPlaybook(playbookName, vars) || renderPlaybook('work-item', vars) || item.description;
|
|
2481
|
+
if (!prompt) {
|
|
2482
|
+
log('warn', `No playbook rendered for ${item.id} (type: ${workType}, playbook: ${playbookName}) — skipping`);
|
|
2483
|
+
continue;
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2486
|
+
// Mark item as dispatched BEFORE adding to newWork (prevents race on next tick)
|
|
2487
|
+
item.status = 'dispatched';
|
|
2488
|
+
item.dispatched_at = ts();
|
|
2489
|
+
item.dispatched_to = agentId;
|
|
2490
|
+
syncPrdItemStatus(item.id, 'dispatched', item.sourcePlan);
|
|
2491
|
+
|
|
2492
|
+
newWork.push({
|
|
2493
|
+
type: workType,
|
|
2494
|
+
agent: agentId,
|
|
2495
|
+
agentName: config.agents[agentId]?.name,
|
|
2496
|
+
agentRole: config.agents[agentId]?.role,
|
|
2497
|
+
task: `[${project?.name || 'project'}] ${item.title || item.description?.slice(0, 80) || item.id}`,
|
|
2498
|
+
prompt,
|
|
2499
|
+
meta: { dispatchKey: key, source: 'work-item', branch: branchName, branchStrategy: item.branchStrategy || 'parallel', useExistingBranch: !!(item.branchStrategy === 'shared-branch' && item.featureBranch), item, project: { name: project?.name, localPath: project?.localPath } }
|
|
2500
|
+
});
|
|
2501
|
+
|
|
2502
|
+
setCooldown(key);
|
|
2503
|
+
}
|
|
2504
|
+
|
|
2505
|
+
// Write back updated statuses (always, since we mark items dispatched before newWork check)
|
|
2506
|
+
if (newWork.length > 0) {
|
|
2507
|
+
const workItemsPath = projectWorkItemsPath(project);
|
|
2508
|
+
safeWrite(workItemsPath, items);
|
|
2509
|
+
}
|
|
2510
|
+
|
|
2511
|
+
if (needsWrite) safeWrite(projectWorkItemsPath(project), items);
|
|
2512
|
+
|
|
2513
|
+
const skipTotal = skipped.gated + skipped.noAgent;
|
|
2514
|
+
if (skipTotal > 0) {
|
|
2515
|
+
log('debug', `Work item discovery (${project?.name}): skipped ${skipTotal} items (${skipped.gated} gated, ${skipped.noAgent} no agent)`);
|
|
2516
|
+
}
|
|
2517
|
+
|
|
2518
|
+
return newWork;
|
|
2519
|
+
}
|
|
2520
|
+
|
|
2521
|
+
/**
|
|
2522
|
+
* Build the multi-project context section for central work items.
|
|
2523
|
+
* Inserted into the playbook via {{scope_section}}.
|
|
2524
|
+
*/
|
|
2525
|
+
function buildProjectContext(projects, assignedProject, isFanOut, agentName, agentRole) {
|
|
2526
|
+
const projectList = projects.map(p => {
|
|
2527
|
+
let line = `### ${p.name}\n`;
|
|
2528
|
+
line += `- **Path:** ${p.localPath}\n`;
|
|
2529
|
+
line += `- **Repo:** ${p.adoOrg}/${p.adoProject}/${p.repoName} (ID: ${p.repositoryId || 'unknown'}, host: ${getRepoHostLabel(p)})\n`;
|
|
2530
|
+
if (p.description) line += `- **What it is:** ${p.description}\n`;
|
|
2531
|
+
return line;
|
|
2532
|
+
}).join('\n');
|
|
2533
|
+
|
|
2534
|
+
let section = '';
|
|
2535
|
+
|
|
2536
|
+
if (isFanOut && assignedProject) {
|
|
2537
|
+
section += `## Scope: Fan-out (parallel multi-agent)\n\n`;
|
|
2538
|
+
section += `You are assigned to **${assignedProject.name}**. Other agents are handling the other projects.\n\n`;
|
|
2539
|
+
} else {
|
|
2540
|
+
section += `## Scope: Multi-project (you decide where to work)\n\n`;
|
|
2541
|
+
section += `Determine which project(s) this task applies to. It may span multiple repos.\n`;
|
|
2542
|
+
section += `If multi-repo, work on each sequentially (worktree + PR per repo).\n`;
|
|
2543
|
+
section += `Note cross-repo dependencies in PR descriptions.\n\n`;
|
|
2544
|
+
}
|
|
2545
|
+
|
|
2546
|
+
section += `## Available Projects\n\n${projectList}`;
|
|
2547
|
+
return section;
|
|
2548
|
+
}
|
|
2549
|
+
|
|
2550
|
+
/**
|
|
2551
|
+
* Detect merged PRs containing spec documents and create implement work items.
|
|
2552
|
+
* "Specs" = any markdown doc merged into the repo that describes work to build.
|
|
2553
|
+
* Writes work items as a side-effect; discoverFromWorkItems() picks them up next tick.
|
|
2554
|
+
*
|
|
2555
|
+
* Config key: workSources.specs
|
|
2556
|
+
* Only processes docs with frontmatter `type: spec` — regular docs are ignored.
|
|
2557
|
+
*/
|
|
2558
|
+
function materializeSpecsAsWorkItems(config, project) {
|
|
2559
|
+
const src = project?.workSources?.specs;
|
|
2560
|
+
if (!src?.enabled) return;
|
|
2561
|
+
|
|
2562
|
+
const root = projectRoot(project);
|
|
2563
|
+
const filePatterns = src.filePatterns || ['docs/**/*.md'];
|
|
2564
|
+
const trackerPath = path.join(projectStateDir(project), 'spec-tracker.json');
|
|
2565
|
+
const tracker = safeJson(trackerPath) || { processedPrs: {} };
|
|
2566
|
+
|
|
2567
|
+
const prs = getPrs(project);
|
|
2568
|
+
const mergedPrs = prs.filter(pr =>
|
|
2569
|
+
(pr.status === 'merged' || pr.status === 'completed') &&
|
|
2570
|
+
!tracker.processedPrs[pr.id]
|
|
2571
|
+
);
|
|
2572
|
+
|
|
2573
|
+
if (mergedPrs.length === 0) return;
|
|
2574
|
+
|
|
2575
|
+
const sinceDate = src.lookbackDays ? `${src.lookbackDays} days ago` : '7 days ago';
|
|
2576
|
+
let recentSpecs = [];
|
|
2577
|
+
for (const pattern of filePatterns) {
|
|
2578
|
+
try {
|
|
2579
|
+
const result = exec(
|
|
2580
|
+
`git log --diff-filter=AM --name-only --pretty=format:"COMMIT:%H|%s" --since="${sinceDate}" -- "${pattern}"`,
|
|
2581
|
+
{ cwd: root, encoding: 'utf8', timeout: 10000 }
|
|
2582
|
+
).trim();
|
|
2583
|
+
if (!result) continue;
|
|
2584
|
+
|
|
2585
|
+
let currentCommit = null;
|
|
2586
|
+
for (const line of result.split('\n')) {
|
|
2587
|
+
if (line.startsWith('COMMIT:')) {
|
|
2588
|
+
const [hash, ...msgParts] = line.replace('COMMIT:', '').split('|');
|
|
2589
|
+
currentCommit = { hash: hash.trim(), message: msgParts.join('|').trim() };
|
|
2590
|
+
} else if (line.trim() && currentCommit) {
|
|
2591
|
+
recentSpecs.push({ file: line.trim(), ...currentCommit });
|
|
2592
|
+
}
|
|
2593
|
+
}
|
|
2594
|
+
} catch {}
|
|
2595
|
+
}
|
|
2596
|
+
|
|
2597
|
+
if (recentSpecs.length === 0) return;
|
|
2598
|
+
|
|
2599
|
+
const wiPath = projectWorkItemsPath(project);
|
|
2600
|
+
const existingItems = safeJson(wiPath) || [];
|
|
2601
|
+
let created = 0;
|
|
2602
|
+
|
|
2603
|
+
for (const pr of mergedPrs) {
|
|
2604
|
+
const prBranch = (pr.branch || '').toLowerCase();
|
|
2605
|
+
const matchedSpecs = recentSpecs.filter(doc => {
|
|
2606
|
+
const msg = doc.message.toLowerCase();
|
|
2607
|
+
// Match any doc whose commit message references this PR's branch
|
|
2608
|
+
return prBranch && msg.includes(prBranch.split('/').pop());
|
|
2609
|
+
});
|
|
2610
|
+
|
|
2611
|
+
if (matchedSpecs.length === 0) {
|
|
2612
|
+
tracker.processedPrs[pr.id] = { processedAt: ts(), matched: false };
|
|
2613
|
+
continue;
|
|
2614
|
+
}
|
|
2615
|
+
|
|
2616
|
+
for (const doc of matchedSpecs) {
|
|
2617
|
+
if (existingItems.some(i => i.sourceSpec === doc.file)) continue;
|
|
2618
|
+
|
|
2619
|
+
const info = extractSpecInfo(doc.file, root);
|
|
2620
|
+
if (!info) continue;
|
|
2621
|
+
|
|
2622
|
+
const newId = 'SP-' + shared.uid();
|
|
2623
|
+
|
|
2624
|
+
existingItems.push({
|
|
2625
|
+
id: newId,
|
|
2626
|
+
type: 'implement',
|
|
2627
|
+
title: `Implement: ${info.title}`,
|
|
2628
|
+
description: `Implementation work from merged spec.\n\n**Spec:** \`${doc.file}\`\n**Source PR:** ${pr.id} — ${pr.title || ''}\n**PR URL:** ${pr.url || 'N/A'}\n\n## Summary\n\n${info.summary}\n\nRead the full spec at \`${doc.file}\` before starting.`,
|
|
2629
|
+
priority: info.priority,
|
|
2630
|
+
status: 'queued',
|
|
2631
|
+
created: ts(),
|
|
2632
|
+
createdBy: 'engine:spec-discovery',
|
|
2633
|
+
sourceSpec: doc.file,
|
|
2634
|
+
sourcePr: pr.id
|
|
2635
|
+
});
|
|
2636
|
+
created++;
|
|
2637
|
+
log('info', `Spec discovery: created ${newId} "${info.title}" from PR ${pr.id} in ${project.name}`);
|
|
2638
|
+
}
|
|
2639
|
+
|
|
2640
|
+
tracker.processedPrs[pr.id] = { processedAt: ts(), matched: true, specs: matchedSpecs.map(d => d.file) };
|
|
2641
|
+
}
|
|
2642
|
+
|
|
2643
|
+
if (created > 0) {
|
|
2644
|
+
safeWrite(wiPath, existingItems);
|
|
2645
|
+
}
|
|
2646
|
+
safeWrite(trackerPath, tracker);
|
|
2647
|
+
}
|
|
2648
|
+
|
|
2649
|
+
/**
|
|
2650
|
+
* Extract title, summary, and priority from a spec markdown file.
|
|
2651
|
+
* Returns null if the file doesn't have `type: spec` in its frontmatter.
|
|
2652
|
+
*/
|
|
2653
|
+
function extractSpecInfo(filePath, projectRoot_) {
|
|
2654
|
+
const fullPath = path.resolve(projectRoot_, filePath);
|
|
2655
|
+
const content = safeRead(fullPath);
|
|
2656
|
+
if (!content) return null;
|
|
2657
|
+
|
|
2658
|
+
// Require frontmatter with type: spec
|
|
2659
|
+
const fmBlock = content.match(/^---\n([\s\S]*?)\n---/);
|
|
2660
|
+
if (!fmBlock) return null;
|
|
2661
|
+
const frontmatter = fmBlock[1];
|
|
2662
|
+
if (!/type:\s*spec/i.test(frontmatter)) return null;
|
|
2663
|
+
|
|
2664
|
+
let title = '';
|
|
2665
|
+
const fmMatch = content.match(/^---\n[\s\S]*?title:\s*(.+)\n[\s\S]*?---/);
|
|
2666
|
+
const h1Match = content.match(/^#\s+(.+)$/m);
|
|
2667
|
+
title = fmMatch?.[1]?.trim() || h1Match?.[1]?.trim() || path.basename(filePath, '.md');
|
|
2668
|
+
|
|
2669
|
+
let summary = '';
|
|
2670
|
+
const summaryMatch = content.match(/##\s*Summary\n\n([\s\S]*?)(?:\n##|\n---|$)/);
|
|
2671
|
+
if (summaryMatch) {
|
|
2672
|
+
summary = summaryMatch[1].trim();
|
|
2673
|
+
} else {
|
|
2674
|
+
const lines = content.split('\n');
|
|
2675
|
+
let pastTitle = false;
|
|
2676
|
+
for (const line of lines) {
|
|
2677
|
+
if (line.startsWith('# ')) { pastTitle = true; continue; }
|
|
2678
|
+
if (pastTitle && line.trim() && !line.startsWith('#') && !line.startsWith('---')) {
|
|
2679
|
+
summary = line.trim();
|
|
2680
|
+
break;
|
|
2681
|
+
}
|
|
2682
|
+
}
|
|
2683
|
+
}
|
|
2684
|
+
|
|
2685
|
+
const priorityMatch = content.match(/priority:\s*(high|medium|low|critical)/i);
|
|
2686
|
+
const priority = priorityMatch?.[1]?.toLowerCase() || 'medium';
|
|
2687
|
+
|
|
2688
|
+
return { title, summary: summary.slice(0, 1500), priority };
|
|
2689
|
+
}
|
|
2690
|
+
|
|
2691
|
+
/**
|
|
2692
|
+
* Scan central ~/.minions/work-items.json for project-agnostic tasks.
|
|
2693
|
+
* Uses the shared work-item.md playbook with multi-project context injected.
|
|
2694
|
+
*/
|
|
2695
|
+
function discoverCentralWorkItems(config) {
|
|
2696
|
+
const centralPath = path.join(MINIONS_DIR, 'work-items.json');
|
|
2697
|
+
const items = safeJson(centralPath) || [];
|
|
2698
|
+
const projects = getProjects(config);
|
|
2699
|
+
const newWork = [];
|
|
2700
|
+
|
|
2701
|
+
for (const item of items) {
|
|
2702
|
+
if (item.status !== 'queued' && item.status !== 'pending') continue;
|
|
2703
|
+
|
|
2704
|
+
const key = `central-work-${item.id}`;
|
|
2705
|
+
if (isAlreadyDispatched(key) || isOnCooldown(key, 0)) continue;
|
|
2706
|
+
|
|
2707
|
+
const workType = item.type || 'implement';
|
|
2708
|
+
const isFanOut = item.scope === 'fan-out';
|
|
2709
|
+
|
|
2710
|
+
if (isFanOut) {
|
|
2711
|
+
// ─── Fan-out: dispatch to ALL idle agents ───────────────────────
|
|
2712
|
+
const idleAgents = Object.entries(config.agents)
|
|
2713
|
+
.filter(([id]) => {
|
|
2714
|
+
const s = getAgentStatus(id);
|
|
2715
|
+
return ['idle', 'done', 'completed'].includes(s.status);
|
|
2716
|
+
})
|
|
2717
|
+
.map(([id, info]) => ({ id, ...info }));
|
|
2718
|
+
|
|
2719
|
+
if (idleAgents.length === 0) {
|
|
2720
|
+
log('info', `Fan-out: all agents busy for ${item.id}, will retry next tick`);
|
|
2721
|
+
continue; // Item stays pending, retried next tick
|
|
2722
|
+
}
|
|
2723
|
+
|
|
2724
|
+
const assignments = idleAgents.map((agent, i) => ({
|
|
2725
|
+
agent,
|
|
2726
|
+
assignedProject: projects.length > 0 ? projects[i % projects.length] : null
|
|
2727
|
+
}));
|
|
2728
|
+
|
|
2729
|
+
for (const { agent, assignedProject } of assignments) {
|
|
2730
|
+
const fanKey = `${key}-${agent.id}`;
|
|
2731
|
+
if (isAlreadyDispatched(fanKey)) continue;
|
|
2732
|
+
|
|
2733
|
+
const ap = assignedProject || projects[0];
|
|
2734
|
+
const vars = {
|
|
2735
|
+
...buildBaseVars(agent.id, config, ap),
|
|
2736
|
+
item_id: item.id,
|
|
2737
|
+
item_name: item.title || item.id,
|
|
2738
|
+
item_priority: item.priority || 'medium',
|
|
2739
|
+
item_description: item.description || '',
|
|
2740
|
+
work_type: workType,
|
|
2741
|
+
additional_context: item.prompt ? `## Additional Context\n\n${item.prompt}` : '',
|
|
2742
|
+
scope_section: buildProjectContext(projects, assignedProject, true, agent.name, agent.role),
|
|
2743
|
+
project_path: ap?.localPath || '',
|
|
2744
|
+
};
|
|
2745
|
+
|
|
2746
|
+
if (workType === 'ask') {
|
|
2747
|
+
vars.question = item.title + (item.description ? '\n\n' + item.description : '');
|
|
2748
|
+
vars.task_id = item.id;
|
|
2749
|
+
vars.notes_content = '';
|
|
2750
|
+
try { vars.notes_content = fs.readFileSync(path.join(MINIONS_DIR, 'notes.md'), 'utf8'); } catch {}
|
|
2751
|
+
}
|
|
2752
|
+
|
|
2753
|
+
const resolvedCtx = resolveTaskContext(item, config);
|
|
2754
|
+
if (resolvedCtx.additionalContext) {
|
|
2755
|
+
vars.additional_context = (vars.additional_context || '') + resolvedCtx.additionalContext;
|
|
2756
|
+
}
|
|
2757
|
+
|
|
2758
|
+
const playbookName = selectPlaybook(workType, item);
|
|
2759
|
+
const prompt = renderPlaybook(playbookName, vars) || renderPlaybook('work-item', vars);
|
|
2760
|
+
if (!prompt) {
|
|
2761
|
+
log('warn', `Fan-out: playbook '${playbookName}' failed to render for ${item.id} → ${agent.id}, skipping`);
|
|
2762
|
+
continue;
|
|
2763
|
+
}
|
|
2764
|
+
|
|
2765
|
+
newWork.push({
|
|
2766
|
+
type: workType,
|
|
2767
|
+
agent: agent.id,
|
|
2768
|
+
agentName: agent.name,
|
|
2769
|
+
agentRole: agent.role,
|
|
2770
|
+
task: `[fan-out] ${item.title} → ${agent.name}${assignedProject ? ' → ' + assignedProject.name : ''}`,
|
|
2771
|
+
prompt,
|
|
2772
|
+
meta: {
|
|
2773
|
+
dispatchKey: fanKey, source: 'central-work-item-fanout', item, parentKey: key,
|
|
2774
|
+
deadline: item.timeout ? Date.now() + item.timeout : Date.now() + (config.engine?.fanOutTimeout || config.engine?.agentTimeout || DEFAULTS.agentTimeout)
|
|
2775
|
+
}
|
|
2776
|
+
});
|
|
2777
|
+
}
|
|
2778
|
+
|
|
2779
|
+
item.status = 'dispatched';
|
|
2780
|
+
item.dispatched_at = ts();
|
|
2781
|
+
item.dispatched_to = idleAgents.map(a => a.id).join(', ');
|
|
2782
|
+
item.scope = 'fan-out';
|
|
2783
|
+
item.fanOutAgents = idleAgents.map(a => a.id);
|
|
2784
|
+
setCooldown(key);
|
|
2785
|
+
log('info', `Fan-out: ${item.id} dispatched to ${idleAgents.length} agents: ${idleAgents.map(a => a.name).join(', ')}`);
|
|
2786
|
+
|
|
2787
|
+
} else {
|
|
2788
|
+
// ─── Normal: single agent dispatch ──────────────────────────────
|
|
2789
|
+
const agentId = item.agent || resolveAgent(workType, config);
|
|
2790
|
+
if (!agentId) continue;
|
|
2791
|
+
|
|
2792
|
+
const agentName = config.agents[agentId]?.name || agentId;
|
|
2793
|
+
const agentRole = config.agents[agentId]?.role || 'Agent';
|
|
2794
|
+
const firstProject = projects[0];
|
|
2795
|
+
|
|
2796
|
+
const vars = {
|
|
2797
|
+
...buildBaseVars(agentId, config, firstProject),
|
|
2798
|
+
item_id: item.id,
|
|
2799
|
+
item_name: item.title || item.id,
|
|
2800
|
+
item_priority: item.priority || 'medium',
|
|
2801
|
+
item_description: item.description || '',
|
|
2802
|
+
item_complexity: item.complexity || item.estimated_complexity || 'medium',
|
|
2803
|
+
task_description: item.title + (item.description ? '\n\n' + item.description : ''),
|
|
2804
|
+
task_id: item.id,
|
|
2805
|
+
work_type: workType,
|
|
2806
|
+
additional_context: item.prompt ? `## Additional Context\n\n${item.prompt}` : '',
|
|
2807
|
+
scope_section: buildProjectContext(projects, null, false, agentName, agentRole),
|
|
2808
|
+
project_path: firstProject?.localPath || '',
|
|
2809
|
+
notes_content: '',
|
|
2810
|
+
};
|
|
2811
|
+
try { vars.notes_content = fs.readFileSync(path.join(MINIONS_DIR, 'notes.md'), 'utf8'); } catch {}
|
|
2812
|
+
|
|
2813
|
+
// Inject plan-specific variables for the plan playbook
|
|
2814
|
+
if (workType === 'plan') {
|
|
2815
|
+
// Ensure plans directory exists before agent tries to write
|
|
2816
|
+
if (!fs.existsSync(PLANS_DIR)) fs.mkdirSync(PLANS_DIR, { recursive: true });
|
|
2817
|
+
const planFileName = `plan-${item.id.toLowerCase()}-${dateStamp()}.md`;
|
|
2818
|
+
vars.plan_content = item.title + (item.description ? '\n\n' + item.description : '');
|
|
2819
|
+
vars.plan_title = item.title;
|
|
2820
|
+
vars.plan_file = planFileName;
|
|
2821
|
+
vars.task_description = item.title;
|
|
2822
|
+
vars.notes_content = '';
|
|
2823
|
+
try { vars.notes_content = fs.readFileSync(path.join(MINIONS_DIR, 'notes.md'), 'utf8'); } catch {}
|
|
2824
|
+
// Track expected plan filename in meta for chainPlanToPrd
|
|
2825
|
+
item._planFileName = planFileName;
|
|
2826
|
+
}
|
|
2827
|
+
|
|
2828
|
+
// Inject plan-to-prd variables — read the plan file content for the playbook
|
|
2829
|
+
if (workType === 'plan-to-prd' && item.planFile) {
|
|
2830
|
+
if (!fs.existsSync(PLANS_DIR)) fs.mkdirSync(PLANS_DIR, { recursive: true });
|
|
2831
|
+
if (!fs.existsSync(PRD_DIR)) fs.mkdirSync(PRD_DIR, { recursive: true });
|
|
2832
|
+
const planPath = path.join(PLANS_DIR, item.planFile);
|
|
2833
|
+
try {
|
|
2834
|
+
vars.plan_content = fs.readFileSync(planPath, 'utf8');
|
|
2835
|
+
} catch (e) {
|
|
2836
|
+
log('warn', `plan-to-prd: could not read plan file ${item.planFile} for ${item.id}: ${e.message}`);
|
|
2837
|
+
vars.plan_content = item.description || '';
|
|
2838
|
+
}
|
|
2839
|
+
vars.plan_summary = (item.title || item.planFile).substring(0, 80);
|
|
2840
|
+
vars.plan_file = item.planFile || '';
|
|
2841
|
+
vars.project_name_lower = (firstProject?.name || 'project').toLowerCase();
|
|
2842
|
+
vars.branch_strategy_hint = item.branchStrategy
|
|
2843
|
+
? `The user requested **${item.branchStrategy}** strategy. Use this unless the analysis strongly suggests otherwise.`
|
|
2844
|
+
: 'Choose the best strategy based on your analysis of item dependencies.';
|
|
2845
|
+
}
|
|
2846
|
+
|
|
2847
|
+
// Inject ask-specific variables for the ask playbook
|
|
2848
|
+
if (workType === 'ask') {
|
|
2849
|
+
vars.question = item.title + (item.description ? '\n\n' + item.description : '');
|
|
2850
|
+
vars.task_id = item.id;
|
|
2851
|
+
vars.notes_content = '';
|
|
2852
|
+
try { vars.notes_content = fs.readFileSync(path.join(MINIONS_DIR, 'notes.md'), 'utf8'); } catch {}
|
|
2853
|
+
}
|
|
2854
|
+
|
|
2855
|
+
// Resolve implicit context references
|
|
2856
|
+
const resolvedCtx = resolveTaskContext(item, config);
|
|
2857
|
+
if (resolvedCtx.additionalContext) {
|
|
2858
|
+
vars.additional_context = (vars.additional_context || '') + resolvedCtx.additionalContext;
|
|
2859
|
+
vars.task_description = vars.task_description + resolvedCtx.additionalContext;
|
|
2860
|
+
}
|
|
2861
|
+
|
|
2862
|
+
const playbookName = selectPlaybook(workType, item);
|
|
2863
|
+
const prompt = renderPlaybook(playbookName, vars) || renderPlaybook('work-item', vars);
|
|
2864
|
+
if (!prompt) {
|
|
2865
|
+
log('warn', `Dispatch: playbook '${playbookName}' failed to render for ${item.id}, resetting to pending`);
|
|
2866
|
+
item.status = 'pending';
|
|
2867
|
+
continue;
|
|
2868
|
+
}
|
|
2869
|
+
|
|
2870
|
+
newWork.push({
|
|
2871
|
+
type: workType,
|
|
2872
|
+
agent: agentId,
|
|
2873
|
+
agentName,
|
|
2874
|
+
agentRole,
|
|
2875
|
+
task: item.title || item.description?.slice(0, 80) || item.id,
|
|
2876
|
+
prompt,
|
|
2877
|
+
meta: { dispatchKey: key, source: 'central-work-item', item, planFileName: item.planFile || item._planFileName || null }
|
|
2878
|
+
});
|
|
2879
|
+
|
|
2880
|
+
item.status = 'dispatched';
|
|
2881
|
+
item.dispatched_at = ts();
|
|
2882
|
+
item.dispatched_to = agentId;
|
|
2883
|
+
setCooldown(key);
|
|
2884
|
+
}
|
|
2885
|
+
}
|
|
2886
|
+
|
|
2887
|
+
if (newWork.length > 0) safeWrite(centralPath, items);
|
|
2888
|
+
return newWork;
|
|
2889
|
+
}
|
|
2890
|
+
|
|
2891
|
+
|
|
2892
|
+
/**
|
|
2893
|
+
* Run all work discovery sources and queue new items
|
|
2894
|
+
* Priority: fix (0) > ask (1) > review (1) > implement (2) > work-items (3) > central (4)
|
|
2895
|
+
*/
|
|
2896
|
+
function discoverWork(config) {
|
|
2897
|
+
resetClaimedAgents(); // Reset per-tick agent claims for fair distribution
|
|
2898
|
+
const projects = getProjects(config);
|
|
2899
|
+
let allFixes = [], allReviews = [], allWorkItems = [];
|
|
2900
|
+
|
|
2901
|
+
// Side-effect passes: materialize plans and design docs into work-items.json
|
|
2902
|
+
// These write to project work queues — picked up by discoverFromWorkItems below.
|
|
2903
|
+
materializePlansAsWorkItems(config);
|
|
2904
|
+
|
|
2905
|
+
for (const project of projects) {
|
|
2906
|
+
const root = project.localPath ? path.resolve(project.localPath) : null;
|
|
2907
|
+
if (!root || !fs.existsSync(root)) continue;
|
|
2908
|
+
|
|
2909
|
+
// Source 1: Pull Requests → fixes, reviews, build-test
|
|
2910
|
+
const prWork = discoverFromPrs(config, project);
|
|
2911
|
+
allFixes.push(...prWork.filter(w => w.type === 'fix'));
|
|
2912
|
+
allReviews.push(...prWork.filter(w => w.type === 'review'));
|
|
2913
|
+
allWorkItems.push(...prWork.filter(w => w.type === 'test'));
|
|
2914
|
+
|
|
2915
|
+
// Side-effect: specs → work items (picked up below)
|
|
2916
|
+
materializeSpecsAsWorkItems(config, project);
|
|
2917
|
+
|
|
2918
|
+
// Source 3: Work items (includes auto-filed from plans, design docs, build failures)
|
|
2919
|
+
allWorkItems.push(...discoverFromWorkItems(config, project));
|
|
2920
|
+
}
|
|
2921
|
+
|
|
2922
|
+
// Source 2: Minions-level PRD → implements (multi-project, called once outside project loop)
|
|
2923
|
+
// PRD items now flow through plans/*.json → materializePlansAsWorkItems → discoverFromWorkItems
|
|
2924
|
+
|
|
2925
|
+
// Central work items (project-agnostic — agent decides where to work)
|
|
2926
|
+
const centralWork = discoverCentralWorkItems(config);
|
|
2927
|
+
|
|
2928
|
+
// Gate reviews and fixes: do not dispatch until all implement items are complete
|
|
2929
|
+
const hasIncompleteImplements = projects.some(project => {
|
|
2930
|
+
const items = safeJson(projectWorkItemsPath(project)) || [];
|
|
2931
|
+
return items.some(i => ['queued', 'pending', 'dispatched'].includes(i.status) && (i.type || '').startsWith('implement'));
|
|
2932
|
+
});
|
|
2933
|
+
if (hasIncompleteImplements) {
|
|
2934
|
+
if (allReviews.length > 0) {
|
|
2935
|
+
log('info', `Gating ${allReviews.length} reviews — implement items still in progress`);
|
|
2936
|
+
allReviews = [];
|
|
2937
|
+
}
|
|
2938
|
+
if (allFixes.length > 0) {
|
|
2939
|
+
log('info', `Gating ${allFixes.length} fixes — implement items still in progress`);
|
|
2940
|
+
allFixes = [];
|
|
2941
|
+
}
|
|
2942
|
+
}
|
|
2943
|
+
|
|
2944
|
+
const allWork = [...allFixes, ...allReviews, ...allWorkItems, ...centralWork];
|
|
2945
|
+
|
|
2946
|
+
for (const item of allWork) {
|
|
2947
|
+
addToDispatch(item);
|
|
2948
|
+
}
|
|
2949
|
+
|
|
2950
|
+
if (allWork.length > 0) {
|
|
2951
|
+
log('info', `Discovered ${allWork.length} new work items: ${allFixes.length} fixes, ${allReviews.length} reviews, ${allWorkItems.length} work-items`);
|
|
2952
|
+
}
|
|
2953
|
+
|
|
2954
|
+
return allWork.length;
|
|
2955
|
+
}
|
|
2956
|
+
|
|
2957
|
+
// ─── Main Tick ──────────────────────────────────────────────────────────────
|
|
2958
|
+
|
|
2959
|
+
let tickCount = 0;
|
|
2960
|
+
|
|
2961
|
+
let tickRunning = false;
|
|
2962
|
+
|
|
2963
|
+
async function tick() {
|
|
2964
|
+
if (tickRunning) return; // prevent overlapping ticks
|
|
2965
|
+
tickRunning = true;
|
|
2966
|
+
try {
|
|
2967
|
+
await tickInner();
|
|
2968
|
+
} catch (e) {
|
|
2969
|
+
log('error', `Tick error: ${e.message}`);
|
|
2970
|
+
} finally {
|
|
2971
|
+
tickRunning = false;
|
|
2972
|
+
}
|
|
2973
|
+
}
|
|
2974
|
+
|
|
2975
|
+
async function tickInner() {
|
|
2976
|
+
const control = getControl();
|
|
2977
|
+
if (control.state !== 'running') {
|
|
2978
|
+
log('info', `Engine state is "${control.state}" — exiting process`);
|
|
2979
|
+
process.exit(0);
|
|
2980
|
+
}
|
|
2981
|
+
|
|
2982
|
+
// Write heartbeat so dashboard can detect stale engine
|
|
2983
|
+
try { safeWrite(CONTROL_PATH, { ...control, heartbeat: Date.now() }); } catch {}
|
|
2984
|
+
|
|
2985
|
+
const config = getConfig();
|
|
2986
|
+
tickCount++;
|
|
2987
|
+
|
|
2988
|
+
// 1. Check for timed-out agents and idle threshold
|
|
2989
|
+
checkTimeouts(config);
|
|
2990
|
+
checkIdleThreshold(config);
|
|
2991
|
+
|
|
2992
|
+
// 2. Consolidate inbox
|
|
2993
|
+
consolidateInbox(config);
|
|
2994
|
+
|
|
2995
|
+
// 2.5. Periodic cleanup + MCP sync (every 10 ticks = ~5 minutes)
|
|
2996
|
+
if (tickCount % 10 === 0) {
|
|
2997
|
+
runCleanup(config);
|
|
2998
|
+
}
|
|
2999
|
+
|
|
3000
|
+
// 2.6. Poll PR status: build, review, merge (every 6 ticks = ~3 minutes)
|
|
3001
|
+
// Awaited so PR state is consistent before discoverWork reads it
|
|
3002
|
+
if (tickCount % 6 === 0) {
|
|
3003
|
+
try { await pollPrStatus(config); } catch (err) { log('warn', `ADO PR status poll error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }
|
|
3004
|
+
try { await ghPollPrStatus(config); } catch (err) { log('warn', `GitHub PR status poll error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }
|
|
3005
|
+
// Sync PR status back to PRD items (missing → done when active PR exists)
|
|
3006
|
+
try { syncPrdFromPrs(config); } catch (err) { log('warn', `PRD sync error: ${err?.message || err}`); }
|
|
3007
|
+
// Check if any plans can be marked completed (all features done/in-pr)
|
|
3008
|
+
try {
|
|
3009
|
+
const prdFiles = safeReadDir(PRD_DIR).filter(f => f.endsWith('.json'));
|
|
3010
|
+
for (const file of prdFiles) {
|
|
3011
|
+
const plan = safeJson(path.join(PRD_DIR, file));
|
|
3012
|
+
if (plan && plan.missing_features && plan.status !== 'completed') {
|
|
3013
|
+
checkPlanCompletion({ item: { sourcePlan: file } }, config);
|
|
3014
|
+
}
|
|
3015
|
+
}
|
|
3016
|
+
} catch (err) { log('warn', `Plan completion check error: ${err?.message || err}`); }
|
|
3017
|
+
}
|
|
3018
|
+
|
|
3019
|
+
// 2.7. Poll PR threads for human comments (every 12 ticks = ~6 minutes)
|
|
3020
|
+
if (tickCount % 12 === 0) {
|
|
3021
|
+
try { await pollPrHumanComments(config); } catch (err) { log('warn', `ADO PR comment poll error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }
|
|
3022
|
+
try { await ghPollPrHumanComments(config); } catch (err) { log('warn', `GitHub PR comment poll error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }
|
|
3023
|
+
try { await reconcilePrs(config); } catch (err) { log('warn', `ADO PR reconciliation error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }
|
|
3024
|
+
try { await ghReconcilePrs(config); } catch (err) { log('warn', `GitHub PR reconciliation error: ${err?.message || err}${err?.stack ? ' | ' + err.stack.split('\n')[1]?.trim() : ''}`); }
|
|
3025
|
+
}
|
|
3026
|
+
|
|
3027
|
+
// 2.9. Stalled dispatch detection — auto-retry failed items blocking the graph (every 20 ticks = ~10 min)
|
|
3028
|
+
if (tickCount % 20 === 0) {
|
|
3029
|
+
try {
|
|
3030
|
+
const projects = getProjects(config);
|
|
3031
|
+
const dispatch = getDispatch();
|
|
3032
|
+
const activeCount = (dispatch.active || []).length;
|
|
3033
|
+
const allAgentsIdle = Object.keys(config.agents || {}).every(id => {
|
|
3034
|
+
const s = getAgentStatus(id);
|
|
3035
|
+
return !s || s.status === 'idle';
|
|
3036
|
+
});
|
|
3037
|
+
|
|
3038
|
+
if (allAgentsIdle && activeCount === 0) {
|
|
3039
|
+
// Check for failed items blocking pending items
|
|
3040
|
+
for (const project of projects) {
|
|
3041
|
+
try {
|
|
3042
|
+
const wiPath = projectWorkItemsPath(project);
|
|
3043
|
+
const items = safeJson(wiPath) || [];
|
|
3044
|
+
let changed = false;
|
|
3045
|
+
const failedIds = new Set(items.filter(w => w.status === 'failed').map(w => w.id));
|
|
3046
|
+
const pendingWithBlockedDeps = items.filter(w =>
|
|
3047
|
+
w.status === 'pending' && (w.depends_on || []).some(d => failedIds.has(d))
|
|
3048
|
+
);
|
|
3049
|
+
|
|
3050
|
+
if (pendingWithBlockedDeps.length > 0) {
|
|
3051
|
+
// Auto-retry failed items that are blocking others (transient errors)
|
|
3052
|
+
for (const item of items) {
|
|
3053
|
+
if (item.status !== 'failed') continue;
|
|
3054
|
+
// Only retry if something depends on this item
|
|
3055
|
+
const isBlocking = items.some(w => w.status === 'pending' && (w.depends_on || []).includes(item.id));
|
|
3056
|
+
if (!isBlocking) continue;
|
|
3057
|
+
|
|
3058
|
+
log('info', `Stall recovery: auto-retrying ${item.id} (blocking ${pendingWithBlockedDeps.filter(w => (w.depends_on || []).includes(item.id)).length} items)`);
|
|
3059
|
+
item.status = 'pending';
|
|
3060
|
+
item._retryCount = 0;
|
|
3061
|
+
delete item.failReason;
|
|
3062
|
+
delete item.failedAt;
|
|
3063
|
+
delete item.dispatched_at;
|
|
3064
|
+
delete item.dispatched_to;
|
|
3065
|
+
changed = true;
|
|
3066
|
+
|
|
3067
|
+
// Clear completed dispatch entries so isAlreadyDispatched doesn't block re-dispatch
|
|
3068
|
+
try {
|
|
3069
|
+
const key = `work-${project.name}-${item.id}`;
|
|
3070
|
+
mutateDispatch((dp) => {
|
|
3071
|
+
const before = dp.completed.length;
|
|
3072
|
+
dp.completed = dp.completed.filter(d => d.meta?.dispatchKey !== key);
|
|
3073
|
+
if (dp.completed.length !== before) return dp;
|
|
3074
|
+
});
|
|
3075
|
+
} catch {}
|
|
3076
|
+
|
|
3077
|
+
// Clear cooldown so item isn't blocked by exponential backoff
|
|
3078
|
+
try {
|
|
3079
|
+
const key = `work-${project.name}-${item.id}`;
|
|
3080
|
+
if (dispatchCooldowns.has(key)) {
|
|
3081
|
+
dispatchCooldowns.delete(key);
|
|
3082
|
+
saveCooldowns();
|
|
3083
|
+
}
|
|
3084
|
+
} catch {}
|
|
3085
|
+
}
|
|
3086
|
+
}
|
|
3087
|
+
|
|
3088
|
+
// Un-fail dependent items that were cascade-failed
|
|
3089
|
+
if (changed) {
|
|
3090
|
+
const retriedIds = new Set(items.filter(w => w.status === 'pending' && w._retryCount === 0).map(w => w.id));
|
|
3091
|
+
for (const dep of items) {
|
|
3092
|
+
if (dep.status === 'failed' && dep.failReason === 'Dependency failed — cannot proceed') {
|
|
3093
|
+
const blockers = (dep.depends_on || []).filter(d => retriedIds.has(d));
|
|
3094
|
+
if (blockers.length > 0) {
|
|
3095
|
+
log('info', `Stall recovery: un-failing ${dep.id} (blocker ${blockers.join(',')} retried)`);
|
|
3096
|
+
dep.status = 'pending';
|
|
3097
|
+
dep._retryCount = 0;
|
|
3098
|
+
delete dep.failReason;
|
|
3099
|
+
delete dep.failedAt;
|
|
3100
|
+
delete dep.dispatched_at;
|
|
3101
|
+
delete dep.dispatched_to;
|
|
3102
|
+
// Clear dispatch entries for this dependent too
|
|
3103
|
+
try {
|
|
3104
|
+
const key = `work-${project.name}-${dep.id}`;
|
|
3105
|
+
mutateDispatch((dp) => {
|
|
3106
|
+
dp.completed = dp.completed.filter(d => d.meta?.dispatchKey !== key);
|
|
3107
|
+
});
|
|
3108
|
+
} catch {}
|
|
3109
|
+
}
|
|
3110
|
+
}
|
|
3111
|
+
}
|
|
3112
|
+
}
|
|
3113
|
+
|
|
3114
|
+
if (changed) safeWrite(wiPath, items);
|
|
3115
|
+
} catch {}
|
|
3116
|
+
}
|
|
3117
|
+
}
|
|
3118
|
+
} catch (err) { log('warn', `Stall detection error: ${err?.message || err}`); }
|
|
3119
|
+
}
|
|
3120
|
+
|
|
3121
|
+
// 3. Discover new work from sources
|
|
3122
|
+
discoverWork(config);
|
|
3123
|
+
|
|
3124
|
+
// 4. Update snapshot
|
|
3125
|
+
updateSnapshot(config);
|
|
3126
|
+
|
|
3127
|
+
// 5. Process pending dispatches — auto-spawn agents
|
|
3128
|
+
const dispatch = getDispatch();
|
|
3129
|
+
const activeCount = (dispatch.active || []).length;
|
|
3130
|
+
const maxConcurrent = config.engine?.maxConcurrent || 3;
|
|
3131
|
+
|
|
3132
|
+
if (activeCount >= maxConcurrent) {
|
|
3133
|
+
log('info', `At max concurrency (${activeCount}/${maxConcurrent}) — skipping dispatch`);
|
|
3134
|
+
return;
|
|
3135
|
+
}
|
|
3136
|
+
|
|
3137
|
+
const slotsAvailable = maxConcurrent - activeCount;
|
|
3138
|
+
|
|
3139
|
+
// Priority dispatch: fixes > reviews > plan-to-prd > implement > verify > other
|
|
3140
|
+
const typePriority = { 'implement:large': 0, implement: 0, fix: 1, ask: 1, review: 2, test: 3, verify: 3, plan: 4, 'plan-to-prd': 4 };
|
|
3141
|
+
const itemPriority = { high: 0, medium: 1, low: 2 };
|
|
3142
|
+
dispatch.pending.sort((a, b) => {
|
|
3143
|
+
const ta = typePriority[a.type] ?? 5, tb = typePriority[b.type] ?? 5;
|
|
3144
|
+
if (ta !== tb) return ta - tb;
|
|
3145
|
+
const pa = itemPriority[a.meta?.item?.priority] ?? 1, pb = itemPriority[b.meta?.item?.priority] ?? 1;
|
|
3146
|
+
return pa - pb;
|
|
3147
|
+
});
|
|
3148
|
+
mutateDispatch((dp) => {
|
|
3149
|
+
dp.pending = dispatch.pending;
|
|
3150
|
+
dp.active = dispatch.active || dp.active;
|
|
3151
|
+
});
|
|
3152
|
+
|
|
3153
|
+
// Only dispatch to agents that aren't already busy (one task per agent at a time).
|
|
3154
|
+
// Build set of agents currently active.
|
|
3155
|
+
const busyAgents = new Set((dispatch.active || []).map(d => d.agent));
|
|
3156
|
+
const toDispatch = [];
|
|
3157
|
+
for (const item of dispatch.pending) {
|
|
3158
|
+
if (toDispatch.length >= slotsAvailable) break;
|
|
3159
|
+
if (busyAgents.has(item.agent)) continue; // agent already has an active task
|
|
3160
|
+
toDispatch.push(item);
|
|
3161
|
+
busyAgents.add(item.agent); // mark busy for this dispatch round too
|
|
3162
|
+
}
|
|
3163
|
+
|
|
3164
|
+
// Dispatch items — spawnAgent moves each from pending→active on disk.
|
|
3165
|
+
// We use the already-loaded item objects; spawnAgent handles the state transition.
|
|
3166
|
+
const dispatched = new Set();
|
|
3167
|
+
for (const item of toDispatch) {
|
|
3168
|
+
if (!dispatched.has(item.id)) {
|
|
3169
|
+
spawnAgent(item, config);
|
|
3170
|
+
dispatched.add(item.id);
|
|
3171
|
+
}
|
|
3172
|
+
}
|
|
3173
|
+
}
|
|
3174
|
+
|
|
3175
|
+
// ─── Exports (for engine/cli.js and other modules) ──────────────────────────
|
|
3176
|
+
|
|
3177
|
+
module.exports = {
|
|
3178
|
+
// Paths
|
|
3179
|
+
MINIONS_DIR, ENGINE_DIR, AGENTS_DIR, PLAYBOOKS_DIR, PLANS_DIR, PRD_DIR,
|
|
3180
|
+
CONTROL_PATH, DISPATCH_PATH, LOG_PATH, INBOX_DIR, KNOWLEDGE_DIR, ARCHIVE_DIR,
|
|
3181
|
+
IDENTITY_DIR, CONFIG_PATH, ROUTING_PATH, NOTES_PATH, SKILLS_DIR,
|
|
3182
|
+
|
|
3183
|
+
// Utilities
|
|
3184
|
+
ts, logTs, dateStamp, log,
|
|
3185
|
+
safeJson, safeRead, safeWrite,
|
|
3186
|
+
|
|
3187
|
+
// State readers/writers
|
|
3188
|
+
getConfig, getControl, getDispatch, getRouting, getNotes,
|
|
3189
|
+
getAgentStatus, getAgentCharter, getInboxFiles, getPrs,
|
|
3190
|
+
validateConfig,
|
|
3191
|
+
|
|
3192
|
+
// Dispatch management
|
|
3193
|
+
addToDispatch, completeDispatch,
|
|
3194
|
+
activeProcesses, get engineRestartGraceUntil() { return engineRestartGraceUntil; },
|
|
3195
|
+
set engineRestartGraceUntil(v) { engineRestartGraceUntil = v; },
|
|
3196
|
+
|
|
3197
|
+
// Agent lifecycle
|
|
3198
|
+
spawnAgent, resolveAgent,
|
|
3199
|
+
|
|
3200
|
+
// Discovery
|
|
3201
|
+
discoverWork, discoverFromPrs, discoverFromWorkItems,
|
|
3202
|
+
materializePlansAsWorkItems,
|
|
3203
|
+
|
|
3204
|
+
// Shared helpers (used by lifecycle.js and tests)
|
|
3205
|
+
reconcileItemsWithPrs, writeInboxAlert, detectDependencyCycles,
|
|
3206
|
+
|
|
3207
|
+
// Playbooks
|
|
3208
|
+
renderPlaybook,
|
|
3209
|
+
|
|
3210
|
+
// Post-completion / lifecycle
|
|
3211
|
+
updateWorkItemStatus, runCleanup, handlePostMerge,
|
|
3212
|
+
|
|
3213
|
+
// Cooldowns
|
|
3214
|
+
loadCooldowns,
|
|
3215
|
+
|
|
3216
|
+
// Tick
|
|
3217
|
+
tick,
|
|
3218
|
+
};
|
|
3219
|
+
|
|
3220
|
+
// ─── Entrypoint ─────────────────────────────────────────────────────────────
|
|
3221
|
+
|
|
3222
|
+
if (require.main === module) {
|
|
3223
|
+
const { handleCommand } = require('./engine/cli');
|
|
3224
|
+
const [cmd, ...args] = process.argv.slice(2);
|
|
3225
|
+
handleCommand(cmd, args);
|
|
3226
|
+
}
|
|
3227
|
+
|