@yemi33/minions 0.1.59 → 0.1.61

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/engine.js CHANGED
@@ -106,533 +106,37 @@ function log(level, msg, meta = {}) {
106
106
  safeWrite(LOG_PATH, logData);
107
107
  }
108
108
 
109
- function mutateDispatch(mutator) {
110
- const defaultDispatch = { pending: [], active: [], completed: [] };
111
- return mutateJsonFileLocked(DISPATCH_PATH, (dispatch) => {
112
- dispatch.pending = Array.isArray(dispatch.pending) ? dispatch.pending : [];
113
- dispatch.active = Array.isArray(dispatch.active) ? dispatch.active : [];
114
- dispatch.completed = Array.isArray(dispatch.completed) ? dispatch.completed : [];
115
- return mutator(dispatch) || dispatch;
116
- }, { defaultValue: defaultDispatch });
117
- }
118
-
119
- // ─── State Readers (delegated to engine/queries.js) ─────────────────────────
120
-
121
- const { getConfig, getControl, getDispatch, getNotes,
122
- getAgentStatus, getAgentCharter, getInboxFiles,
123
- collectSkillFiles, getSkillIndex, getKnowledgeBaseIndex,
124
- getPrs, SKILLS_DIR } = queries;
125
-
126
- function getRouting() {
127
- return safeRead(ROUTING_PATH);
128
- }
129
-
130
- // ─── Routing Parser ─────────────────────────────────────────────────────────
131
-
132
- let _routingCache = null;
133
- let _routingCacheMtime = 0;
134
-
135
- function parseRoutingTable() {
136
- const content = getRouting();
137
- const routes = {};
138
- const lines = content.split('\n');
139
- let inTable = false;
140
-
141
- for (const line of lines) {
142
- if (line.startsWith('| Work Type')) { inTable = true; continue; }
143
- if (line.startsWith('|---')) continue;
144
- if (!inTable || !line.startsWith('|')) {
145
- if (inTable && !line.startsWith('|')) inTable = false;
146
- continue;
147
- }
148
- const cells = line.split('|').map(c => c.trim()).filter(Boolean);
149
- if (cells.length >= 3) {
150
- routes[cells[0].toLowerCase()] = {
151
- preferred: cells[1].toLowerCase(),
152
- fallback: cells[2].toLowerCase()
153
- };
154
- }
155
- }
156
- return routes;
157
- }
158
-
159
- function getRoutingTableCached() {
160
- let mtime = 0;
161
- try { mtime = fs.statSync(ROUTING_PATH).mtimeMs; } catch { /* optional */ }
162
- if (_routingCache && _routingCacheMtime === mtime) return _routingCache;
163
- _routingCache = parseRoutingTable();
164
- _routingCacheMtime = mtime;
165
- return _routingCache;
166
- }
167
-
168
- function getMonthlySpend(agentId) {
169
- const metrics = safeJson(path.join(ENGINE_DIR, 'metrics.json')) || {};
170
- const daily = metrics._daily || {};
171
- const now = new Date();
172
- const monthPrefix = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}`;
173
- let total = 0;
174
- for (const [date, data] of Object.entries(daily)) {
175
- if (date.startsWith(monthPrefix)) {
176
- total += (data.perAgent?.[agentId]?.costUsd || 0);
177
- }
178
- }
179
- // Fallback: if no per-agent daily data, use cumulative (less accurate for monthly)
180
- if (total === 0 && metrics[agentId]?.totalCostUsd) {
181
- // Can't distinguish monthly from cumulative — treat as monthly estimate
182
- // This path is for backward compat before per-agent daily tracking was added
183
- }
184
- return total;
185
- }
186
-
187
- function getAgentErrorRate(agentId) {
188
- const metricsPath = path.join(ENGINE_DIR, 'metrics.json');
189
- const metrics = safeJson(metricsPath) || {};
190
- const m = metrics[agentId];
191
- if (!m) return 0;
192
- const total = m.tasksCompleted + m.tasksErrored;
193
- return total > 0 ? m.tasksErrored / total : 0;
194
- }
195
-
196
- function isAgentIdle(agentId) {
197
- // Dispatch queue is the single source of truth for agent availability
198
- const dispatch = safeJson(DISPATCH_PATH) || {};
199
- return !(dispatch.active || []).some(d => d.agent === agentId);
200
- }
201
-
202
- // Track agents claimed during a single discovery pass to distribute work
203
- const _claimedAgents = new Set();
204
- function resetClaimedAgents() { _claimedAgents.clear(); }
205
-
206
- function resolveAgent(workType, config, authorAgent = null) {
207
- const routes = getRoutingTableCached();
208
- const route = routes[workType] || routes['implement'];
209
- const agents = config.agents || {};
210
-
211
- // Resolve _author_ token
212
- let preferred = route.preferred === '_author_' ? authorAgent : route.preferred;
213
- let fallback = route.fallback === '_author_' ? authorAgent : route.fallback;
214
-
215
- const isAvailable = (id) => {
216
- if (!agents[id] || !isAgentIdle(id) || _claimedAgents.has(id)) return false;
217
- // Budget check — no budget means infinite (no limit)
218
- const budget = agents[id].monthlyBudgetUsd;
219
- if (budget && budget > 0) {
220
- if (getMonthlySpend(id) >= budget) return false;
221
- }
222
- return true;
223
- };
224
-
225
- // Check preferred and fallback first (routing table order)
226
- if (preferred && isAvailable(preferred)) { _claimedAgents.add(preferred); return preferred; }
227
- if (fallback && isAvailable(fallback)) { _claimedAgents.add(fallback); return fallback; }
228
-
229
- // Fall back to any idle agent, preferring lower error rates
230
- const idle = Object.keys(agents)
231
- .filter(id => id !== preferred && id !== fallback && isAvailable(id))
232
- .sort((a, b) => getAgentErrorRate(a) - getAgentErrorRate(b));
233
-
234
- if (idle[0]) { _claimedAgents.add(idle[0]); return idle[0]; }
235
-
236
- // No idle configured agent — try temp agent if enabled
237
- if (config.engine?.allowTempAgents) {
238
- const tempId = `temp-${shared.uid()}`;
239
- _claimedAgents.add(tempId);
240
- tempAgents.set(tempId, { name: `Temp-${tempId.slice(5, 9)}`, role: 'Temporary Agent', createdAt: ts() });
241
- log('info', `Spawning temp agent ${tempId} — all permanent agents busy`);
242
- return tempId;
243
- }
244
-
245
- // No idle agent available — return null, item stays pending until next tick
246
- return null;
247
- }
248
-
249
- // ─── Task Context Resolution ────────────────────────────────────────────────
250
- // Resolves implicit references in task descriptions (e.g., "ripley's plan",
251
- // "dallas's PR") to actual artifacts and injects their content.
252
-
253
- function resolveTaskContext(item, config) {
254
- const title = (item.title || '').toLowerCase();
255
- const desc = (item.description || '').toLowerCase();
256
- const text = title + ' ' + desc;
257
- const agentNames = Object.entries(config.agents || {}).map(([id, a]) => ({
258
- id,
259
- name: (a.name || id).toLowerCase(),
260
- }));
261
- const resolved = { additionalContext: '', referencedFiles: [] };
262
-
263
- // Match agent references: "ripley's plan", "dallas's pr", "lambert's output", etc.
264
- for (const agent of agentNames) {
265
- const patterns = [
266
- new RegExp(`${agent.name}(?:'s|s)?\\s+plan`, 'i'),
267
- new RegExp(`${agent.id}(?:'s|s)?\\s+plan`, 'i'),
268
- new RegExp(`plan\\s+(?:created|made|written|generated)\\s+by\\s+${agent.name}`, 'i'),
269
- new RegExp(`plan\\s+(?:created|made|written|generated)\\s+by\\s+${agent.id}`, 'i'),
270
- ];
271
- const matchesPlan = patterns.some(p => p.test(text));
272
- if (matchesPlan) {
273
- // Find plans created by this agent (check work items for plan tasks dispatched to this agent)
274
- try {
275
- const plans = fs.readdirSync(path.join(MINIONS_DIR, 'plans')).filter(f => f.endsWith('.md') || f.endsWith('.json'));
276
- // Check work-items to find which plan file this agent created
277
- const workItems = safeJson(path.join(MINIONS_DIR, 'work-items.json')) || [];
278
- const agentPlanItems = workItems.filter(w =>
279
- w.type === 'plan' && w.dispatched_to === agent.id && w.status === 'done' && w._planFileName
280
- ).sort((a, b) => (b.completedAt || '').localeCompare(a.completedAt || ''));
281
-
282
- if (agentPlanItems.length > 0) {
283
- const planFile = agentPlanItems[0]._planFileName;
284
- const planPath = path.join(MINIONS_DIR, 'plans', planFile);
285
- try {
286
- const content = safeRead(planPath);
287
- resolved.additionalContext += `\n\n## Referenced Plan: ${planFile} (created by ${agent.name})\n\n${content}`;
288
- resolved.referencedFiles.push(planPath);
289
- log('info', `Context resolution: found plan "${planFile}" by ${agent.name} for work item ${item.id}`);
290
- } catch (e) { log('warn', 'resolve plan context: ' + e.message); }
291
- } else if (plans.length > 0) {
292
- // Fallback: try to find a plan file with the agent's name or ID in it
293
- const match = plans.find(f => f.toLowerCase().includes(agent.id) || f.toLowerCase().includes(agent.name));
294
- if (match) {
295
- const planPath = path.join(MINIONS_DIR, 'plans', match);
296
- try {
297
- const content = safeRead(planPath);
298
- resolved.additionalContext += `\n\n## Referenced Plan: ${match}\n\n${content}`;
299
- resolved.referencedFiles.push(planPath);
300
- log('info', `Context resolution: found plan "${match}" (name match) for work item ${item.id}`);
301
- } catch (e) { log('warn', 'resolve plan fallback context: ' + e.message); }
302
- }
303
- }
304
- } catch (e) { log('warn', 'resolve agent plan context: ' + e.message); }
305
- }
306
-
307
- // Match agent output/notes references
308
- const outputPatterns = [
309
- new RegExp(`${agent.name}(?:'s|s)?\\s+(?:output|findings|notes|results)`, 'i'),
310
- new RegExp(`(?:output|findings|notes|results)\\s+(?:from|by)\\s+${agent.name}`, 'i'),
311
- ];
312
- if (outputPatterns.some(p => p.test(text))) {
313
- // Find the agent's latest inbox notes
314
- try {
315
- const inboxDir = path.join(MINIONS_DIR, 'notes', 'inbox');
316
- const files = fs.readdirSync(inboxDir)
317
- .filter(f => f.startsWith(agent.id + '-'))
318
- .sort().reverse();
319
- if (files.length > 0) {
320
- const content = safeRead(path.join(inboxDir, files[0]));
321
- resolved.additionalContext += `\n\n## Referenced Notes by ${agent.name}: ${files[0]}\n\n${content.slice(0, 5000)}`;
322
- resolved.referencedFiles.push(path.join(inboxDir, files[0]));
323
- log('info', `Context resolution: found notes "${files[0]}" by ${agent.name} for work item ${item.id}`);
324
- }
325
- } catch (e) { log('warn', 'resolve plan context outer: ' + e.message); }
326
- }
327
- }
328
-
329
- // If no specific reference was resolved but the text mentions "the plan" or "latest plan",
330
- // find the most recent plan
331
- if (!resolved.additionalContext && /\b(the|latest|last|recent)\s+plan\b/i.test(text)) {
332
- try {
333
- const plans = fs.readdirSync(path.join(MINIONS_DIR, 'plans'))
334
- .filter(f => f.endsWith('.md') || f.endsWith('.json'))
335
- .sort().reverse();
336
- if (plans.length > 0) {
337
- const planPath = path.join(MINIONS_DIR, 'plans', plans[0]);
338
- const content = safeRead(planPath);
339
- resolved.additionalContext += `\n\n## Referenced Plan (latest): ${plans[0]}\n\n${content}`;
340
- resolved.referencedFiles.push(planPath);
341
- log('info', `Context resolution: using latest plan "${plans[0]}" for work item ${item.id}`);
342
- }
343
- } catch (e) { log('warn', 'resolve latest plan context: ' + e.message); }
344
- }
345
-
346
- return resolved;
347
- }
348
-
349
- // ─── Playbook Renderer ──────────────────────────────────────────────────────
350
-
351
- function renderPlaybook(type, vars) {
352
- const pbPath = path.join(PLAYBOOKS_DIR, `${type}.md`);
353
- let content;
354
- try { content = fs.readFileSync(pbPath, 'utf8'); } catch {
355
- log('warn', `Playbook not found: ${type}`);
356
- return null;
357
- }
358
-
359
- // Inject pinned context (always visible to agents) — capped at 4KB
360
- let pinnedContent = '';
361
- try { pinnedContent = fs.readFileSync(path.join(MINIONS_DIR, 'pinned.md'), 'utf8'); } catch { /* optional */ }
362
- if (pinnedContent) {
363
- if (pinnedContent.length > 4096) pinnedContent = pinnedContent.slice(0, 4096) + '\n\n_...pinned.md truncated (read full file if needed)_';
364
- content += '\n\n---\n\n## Pinned Context (CRITICAL — READ FIRST)\n\n' + pinnedContent;
365
- }
109
+ // ─── Dispatch Management (extracted to engine/dispatch.js) ───────────────────
366
110
 
367
- // Inject team notes (single injection point — not in buildAgentContext) — capped at 8KB
368
- let notes = getNotes();
369
- if (notes) {
370
- if (notes.length > 8192) {
371
- const sections = notes.split(/(?=^### )/m);
372
- const recent = sections.slice(-10).join('');
373
- notes = recent.length > 8192 ? recent.slice(0, 8192) + '\n\n_...notes truncated_' : recent;
374
- notes += '\n\n_' + Math.max(0, sections.length - 10) + ' older entries in `notes.md` — Read if needed._';
375
- }
376
- content += '\n\n---\n\n## Team Notes (MUST READ)\n\n' + notes;
377
- }
111
+ const { mutateDispatch, addToDispatch, isRetryableFailureReason, completeDispatch,
112
+ writeInboxAlert } = require('./engine/dispatch');
378
113
 
379
- // Inject KB guardrail
380
- content += `\n\n---\n\n## Knowledge Base Rules\n\n`;
381
- 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`;
382
-
383
- // Inject learnings requirement
384
- content += `\n\n---\n\n## REQUIRED: Write Learnings\n\n`;
385
- content += `After completing your task, you MUST write a findings/learnings file to:\n`;
386
- content += `\`${MINIONS_DIR}/notes/inbox/${vars.agent_id || 'agent'}-${dateStamp()}.md\`\n\n`;
387
- content += `Include:\n`;
388
- content += `- What you learned about the codebase\n`;
389
- content += `- Patterns you discovered or established\n`;
390
- content += `- Gotchas or warnings for future agents\n`;
391
- content += `- Conventions to follow\n`;
392
- 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`;
393
- content += `### Skill Extraction (IMPORTANT)\n\n`;
394
- 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`;
395
- content += `Format your skill as a fenced code block with the \`skill\` language tag:\n\n`;
396
- content += '````\n```skill\n';
397
- 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`;
398
- content += '```\n````\n\n';
399
- content += `- Set \`scope: minions\` for cross-project skills (engine writes to ~/.claude/skills/ automatically)\n`;
400
- content += `- Set \`scope: project\` + \`project: <name>\` for repo-specific skills (engine queues a PR to <project>/.claude/skills/)\n`;
401
- content += `- Only output a skill block if you genuinely discovered something reusable — don't force it\n`;
402
-
403
- // Inject project-level variables from config
404
- const config = getConfig();
405
- const projects = getProjects(config);
406
- // Find the specific project being dispatched (match by repo_id or repo_name from vars)
407
- const dispatchProject = (vars.repo_id && projects.find(p => p.repositoryId === vars.repo_id))
408
- || (vars.repo_name && projects.find(p => p.repoName === vars.repo_name))
409
- || projects[0] || {};
410
- const projectVars = {
411
- project_name: dispatchProject.name || 'Unknown Project',
412
- ado_org: dispatchProject.adoOrg || 'Unknown',
413
- ado_project: dispatchProject.adoProject || 'Unknown',
414
- repo_name: dispatchProject.repoName || 'Unknown',
415
- pr_create_instructions: getPrCreateInstructions(dispatchProject),
416
- pr_comment_instructions: getPrCommentInstructions(dispatchProject),
417
- pr_fetch_instructions: getPrFetchInstructions(dispatchProject),
418
- pr_vote_instructions: getPrVoteInstructions(dispatchProject),
419
- repo_host_label: getRepoHostLabel(dispatchProject),
420
- };
421
- const allVars = { ...projectVars, ...vars };
422
-
423
- // Substitute variables
424
- for (const [key, val] of Object.entries(allVars)) {
425
- content = content.replace(new RegExp(`\\{\\{${key}\\}\\}`, 'g'), String(val));
426
- }
114
+ // ─── Timeout / Steering / Idle (extracted to engine/timeout.js) ──────────────
427
115
 
428
- return content;
429
- }
116
+ const { checkTimeouts, checkSteering, checkIdleThreshold } = require('./engine/timeout');
430
117
 
431
- // ─── Repo Host Helpers ──────────────────────────────────────────────────────
118
+ // ─── Cleanup (extracted to engine/cleanup.js) ────────────────────────────────
432
119
 
433
- function getRepoHost(project) {
434
- return project?.repoHost || 'ado';
435
- }
436
-
437
- function getPrCreateInstructions(project) {
438
- const host = getRepoHost(project);
439
- const repoId = project?.repositoryId || '';
440
- if (host === 'github') {
441
- const org = project?.adoOrg || '';
442
- const repo = project?.repoName || '';
443
- const mainBranch = project?.mainBranch || 'main';
444
- return `Use \`gh pr create\` to create a pull request:\n` +
445
- `- \`gh pr create --base ${mainBranch} --head <your-branch> --title "PR title" --body "PR description" --repo ${org}/${repo}\`\n` +
446
- `- Always set --base to \`${mainBranch}\` (the main branch)\n` +
447
- `- Always set --repo to \`${org}/${repo}\` to target the correct repository\n` +
448
- `- Use --head to specify your feature branch name\n` +
449
- `- Include a meaningful --title and --body describing the changes`;
450
- }
451
- // Default: Azure DevOps
452
- return `Use \`mcp__azure-ado__repo_create_pull_request\`:\n- repositoryId: \`${repoId}\``;
453
- }
454
-
455
- function getPrCommentInstructions(project) {
456
- const host = getRepoHost(project);
457
- const repoId = project?.repositoryId || '';
458
- if (host === 'github') {
459
- const org = project?.adoOrg || '';
460
- const repo = project?.repoName || '';
461
- return `Use \`gh pr comment\` to post a comment on the PR:\n` +
462
- `- \`gh pr comment <number> --body "Your comment text" --repo ${org}/${repo}\`\n` +
463
- `- Replace <number> with the PR number\n` +
464
- `- Always set --repo to \`${org}/${repo}\` to target the correct repository\n` +
465
- `- Use --body to provide the comment text (supports Markdown)`;
466
- }
467
- return `Use \`mcp__azure-ado__repo_create_pull_request_thread\`:\n- repositoryId: \`${repoId}\``;
468
- }
469
-
470
- function getPrFetchInstructions(project) {
471
- const host = getRepoHost(project);
472
- if (host === 'github') {
473
- const org = project?.adoOrg || '';
474
- const repo = project?.repoName || '';
475
- const mainBranch = project?.mainBranch || 'main';
476
- return `Use \`gh pr view\` to fetch PR status:\n` +
477
- `- \`gh pr view <number> --json number,title,state,mergeable,reviewDecision,headRefName,baseRefName,statusCheckRollup --repo ${org}/${repo}\`\n` +
478
- `- This returns JSON with PR state, mergeability, review decision, and check statuses\n` +
479
- `- To fetch the PR branch locally:\n` +
480
- ` 1. \`git fetch origin <branch-name>\`\n` +
481
- ` 2. \`git checkout <branch-name>\`\n` +
482
- `- Or use \`gh pr checkout <number> --repo ${org}/${repo}\` to fetch and checkout in one step\n` +
483
- `- The base branch is \`${mainBranch}\``;
484
- }
485
- return `Use \`mcp__azure-ado__repo_get_pull_request_by_id\` to fetch PR status.`;
486
- }
487
-
488
- function getPrVoteInstructions(project) {
489
- const host = getRepoHost(project);
490
- const repoId = project?.repositoryId || '';
491
- if (host === 'github') {
492
- const org = project?.adoOrg || '';
493
- const repo = project?.repoName || '';
494
- return `Use \`gh pr review\` to submit a review on the PR:\n` +
495
- `- Approve: \`gh pr review <number> --approve --body "Approval comment" --repo ${org}/${repo}\`\n` +
496
- `- Request changes: \`gh pr review <number> --request-changes --body "What needs to change" --repo ${org}/${repo}\`\n` +
497
- `- Comment only: \`gh pr review <number> --comment --body "Review comment" --repo ${org}/${repo}\`\n` +
498
- `- Replace <number> with the PR number\n` +
499
- `- Always set --repo to \`${org}/${repo}\` to target the correct repository\n` +
500
- `- Use --body to provide a review summary (supports Markdown)`;
501
- }
502
- 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)`;
503
- }
504
-
505
- function getRepoHostLabel(project) {
506
- const host = getRepoHost(project);
507
- if (host === 'github') return 'GitHub';
508
- return 'Azure DevOps';
509
- }
510
-
511
- function getRepoHostToolRule(project) {
512
- const host = getRepoHost(project);
513
- if (host === 'github') return 'Use GitHub MCP tools or `gh` CLI for PR operations';
514
- return 'Use Azure DevOps MCP tools (mcp__azure-ado__*) for PR operations — NEVER use gh CLI';
515
- }
516
-
517
- // ─── System Prompt Builder ──────────────────────────────────────────────────
518
-
519
- // Lean system prompt: agent identity + rules only (~2-4KB, never grows)
520
- function buildSystemPrompt(agentId, config, project) {
521
- const agent = config.agents[agentId] || tempAgents.get(agentId) || { name: agentId, role: 'Temporary Agent', skills: [] };
522
- const charter = getAgentCharter(agentId); // returns '' for temp agents (no charter file)
523
- project = project || getProjects(config)[0] || {};
524
-
525
- let prompt = '';
526
-
527
- // Agent identity
528
- prompt += `# You are ${agent.name} (${agent.role})\n\n`;
529
- prompt += `Agent ID: ${agentId}\n`;
530
- prompt += `Skills: ${(agent.skills || []).join(', ')}\n\n`;
531
-
532
- // Charter (detailed instructions — typically 1-2KB)
533
- if (charter) {
534
- prompt += `## Your Charter\n\n${charter}\n\n`;
535
- }
120
+ const { runCleanup } = require('./engine/cleanup');
536
121
 
537
- // Project context (fixed size)
538
- prompt += `## Project: ${project.name || 'Unknown Project'}\n\n`;
539
- prompt += `- Repo: ${project.repoName || 'Unknown'} (${project.adoOrg || 'Unknown'}/${project.adoProject || 'Unknown'})\n`;
540
- prompt += `- Repo ID: ${project.repositoryId || ''}\n`;
541
- prompt += `- Repo host: ${getRepoHostLabel(project)}\n`;
542
- prompt += `- Main branch: ${project.mainBranch || 'main'}\n\n`;
543
-
544
- // Critical rules (fixed size)
545
- prompt += `## Critical Rules\n\n`;
546
- prompt += `1. Use git worktrees — NEVER checkout on main working tree\n`;
547
- prompt += `2. ${getRepoHostToolRule(project)}\n`;
548
- prompt += `3. Follow the project conventions in CLAUDE.md if present\n`;
549
- prompt += `4. Write learnings to: ${MINIONS_DIR}/notes/inbox/${agentId}-${dateStamp()}.md\n`;
550
- prompt += `5. Agent status is managed by the engine via dispatch.json — agents do not need to track their own status\n`;
551
- 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`;
552
-
553
- return prompt;
554
- }
555
-
556
- // Bulk context: history, notes, conventions, skills — prepended to user/task prompt.
557
- // This is the content that grows over time and would bloat the system prompt.
558
- function buildAgentContext(agentId, config, project) {
559
- project = project || getProjects(config)[0] || {};
560
- let context = '';
561
-
562
- // Agent history — last 5 tasks only (keeps it relevant, avoids 37KB dumps)
563
- const history = safeRead(path.join(AGENTS_DIR, agentId, 'history.md'));
564
- if (history && history.trim() !== '# Agent History') {
565
- const entries = history.split(/(?=^### )/m);
566
- const header = entries[0].startsWith('#') && !entries[0].startsWith('### ') ? entries.shift() : '';
567
- const recent = entries.slice(-5);
568
- const trimmed = (header ? header + '\n' : '') + recent.join('');
569
- context += `## Your Recent History (last 5 tasks)\n\n${trimmed}\n\n`;
570
- }
122
+ // ─── State Readers (delegated to engine/queries.js) ─────────────────────────
571
123
 
572
- // Project conventions (from CLAUDE.md) — always relevant for code quality
573
- if (project.localPath) {
574
- const claudeMd = safeRead(path.join(project.localPath, 'CLAUDE.md'));
575
- if (claudeMd && claudeMd.trim()) {
576
- const truncated = claudeMd.length > 8192 ? claudeMd.slice(0, 8192) + '\n\n...(truncated)' : claudeMd;
577
- context += `## Project Conventions (from CLAUDE.md)\n\n${truncated}\n\n`;
578
- }
579
- }
124
+ const { getConfig, getControl, getDispatch, getNotes,
125
+ getAgentStatus, getAgentCharter, getInboxFiles,
126
+ collectSkillFiles, getSkillIndex, getKnowledgeBaseIndex,
127
+ getPrs, SKILLS_DIR } = queries;
580
128
 
581
- // KB and skills: NOT injected — agents can Glob/Read when needed
582
- // This saves ~27KB per dispatch. Reference note so agents know they exist:
583
- 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`;
129
+ // ─── Routing (extracted to engine/routing.js) ───────────────────────────────
584
130
 
585
- // Minions awareness: what's in flight, who's doing what
586
- const dispatch = getDispatch();
587
- const activeItems = (dispatch.active || []).map(d =>
588
- `- **${d.agent}**: ${d.type} — ${(d.task || '').slice(0, 100)}${d.agent === agentId ? ' ← (you)' : ''}`
589
- );
590
- if (activeItems.length > 0) {
591
- context += `## Active Agents\n\n${activeItems.join('\n')}\n\n`;
592
- }
131
+ const { getRouting, parseRoutingTable, getRoutingTableCached, getMonthlySpend,
132
+ getAgentErrorRate, isAgentIdle, resolveAgent, resetClaimedAgents,
133
+ tempAgents } = require('./engine/routing');
593
134
 
594
- // Recent completions (last 5, not 10)
595
- const recentCompleted = (dispatch.completed || []).slice(-5).reverse().map(d =>
596
- `- **${d.agent}** ${d.result === 'success' ? 'completed' : 'failed'}: ${(d.task || '').slice(0, 80)}${d.resultSummary ? ' — ' + d.resultSummary.slice(0, 100) : ''}`
597
- );
598
- if (recentCompleted.length > 0) {
599
- context += `## Recently Completed\n\n${recentCompleted.join('\n')}\n\n`;
600
- }
135
+ // ─── Playbook, system prompt, agent context (extracted to engine/playbook.js)
601
136
 
602
- // Active + linked PRs across projects — coordination awareness
603
- const projects = getProjects(config);
604
- const allPrs = [];
605
- for (const p of projects) {
606
- const prs = getPrs(p).filter(pr => pr.status === 'active' || pr.status === 'linked');
607
- for (const pr of prs) allPrs.push({ ...pr, _project: p.name });
608
- }
609
- // Also check central pull-requests.json
610
- try {
611
- const centralPrs = safeJson(path.join(MINIONS_DIR, 'pull-requests.json')) || [];
612
- for (const pr of centralPrs.filter(pr => pr.status === 'active' || pr.status === 'linked')) {
613
- if (!allPrs.some(p => p.id === pr.id)) allPrs.push({ ...pr, _project: 'central' });
614
- }
615
- } catch (e) { log('warn', 'read central pull-requests: ' + e.message); }
616
- if (allPrs.length > 0) {
617
- const prLines = allPrs.map(pr =>
618
- `- **${pr.id}** (${pr._project}): ${(pr.title || '').slice(0, 80)} [${pr.status === 'linked' ? 'context-only' : (pr.reviewStatus || 'pending')}${pr.buildStatus === 'failing' ? ', BUILD FAILING' : ''}]${pr.branch ? ' branch: `' + pr.branch + '`' : ''}${pr._context ? ' — ' + pr._context.slice(0, 100) : ''}`
619
- );
620
- context += `## Active Pull Requests\n\n${prLines.join('\n')}\n\n`;
621
- }
622
-
623
- // Pending work items
624
- const pendingItems = (dispatch.pending || []).slice(0, 10).map(d =>
625
- `- ${d.type}: ${(d.task || '').slice(0, 80)}`
626
- );
627
- if (pendingItems.length > 0) {
628
- 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`;
629
- }
630
-
631
- // Team notes injected via renderPlaybook (single injection point, with truncation)
632
- // Not duplicated here to avoid double-injection and token waste
633
-
634
- return context;
635
- }
137
+ const { renderPlaybook, buildSystemPrompt, buildAgentContext, selectPlaybook,
138
+ buildBaseVars, buildPrDispatch, resolveTaskContext,
139
+ getRepoHostLabel, getRepoHostToolRule } = require('./engine/playbook');
636
140
 
637
141
  // sanitizeBranch imported from shared.js
638
142
 
@@ -645,7 +149,7 @@ const { runPostCompletionHooks, updateWorkItemStatus, syncPrdItemStatus, handleP
645
149
  // ─── Agent Spawner ──────────────────────────────────────────────────────────
646
150
 
647
151
  const activeProcesses = new Map(); // dispatchId → { proc, agentId, startedAt }
648
- const tempAgents = new Map(); // tempAgentId → { name, role, createdAt }
152
+ // tempAgents imported from engine/routing.js
649
153
  let engineRestartGraceUntil = 0; // timestamp — suppress orphan detection until this time
650
154
 
651
155
  // Resolve dependency plan item IDs to their PR branches
@@ -1213,147 +717,9 @@ function spawnAgent(dispatchItem, config) {
1213
717
  return proc;
1214
718
  }
1215
719
 
1216
- // ─── Dispatch Management ────────────────────────────────────────────────────
1217
-
1218
- function addToDispatch(item) {
1219
- item.id = item.id || `${item.agent}-${item.type}-${shared.uid()}`;
1220
- item.created_at = ts();
1221
- mutateDispatch((dispatch) => {
1222
- dispatch.pending.push(item);
1223
- });
1224
- log('info', `Queued dispatch: ${item.id} (${item.type} → ${item.agent})`);
1225
- return item.id;
1226
- }
1227
-
1228
- function isRetryableFailureReason(reason = '') {
1229
- const r = String(reason || '').toLowerCase();
1230
- if (!r) return true; // unknown error from tool exit — keep retryable
1231
- const nonRetryable = [
1232
- 'no playbook rendered',
1233
- 'failed to render',
1234
- 'no target project available',
1235
- 'no plan files found',
1236
- 'plan file not found',
1237
- 'invalid filename',
1238
- 'invalid file path',
1239
- 'missing required',
1240
- 'validation failed',
1241
- ];
1242
- return !nonRetryable.some(s => r.includes(s));
1243
- }
720
+ // addToDispatch, isRetryableFailureReason now in engine/dispatch.js
1244
721
 
1245
- function completeDispatch(id, result = 'success', reason = '', resultSummary = '', opts = {}) {
1246
- const { processWorkItemFailure = true } = opts;
1247
- let item = null;
1248
-
1249
- mutateDispatch((dispatch) => {
1250
- // Check active list first
1251
- let idx = dispatch.active.findIndex(d => d.id === id);
1252
- if (idx >= 0) {
1253
- item = dispatch.active.splice(idx, 1)[0];
1254
- } else {
1255
- // Also check pending list (e.g., worktree failure before spawn)
1256
- idx = dispatch.pending.findIndex(d => d.id === id);
1257
- if (idx >= 0) item = dispatch.pending.splice(idx, 1)[0];
1258
- }
1259
-
1260
- if (!item) return;
1261
- item.completed_at = ts();
1262
- item.result = result;
1263
- if (reason) item.reason = reason;
1264
- if (resultSummary) item.resultSummary = resultSummary;
1265
- delete item.prompt;
1266
- if (dispatch.completed.length >= 100) {
1267
- dispatch.completed = dispatch.completed.slice(-99);
1268
- }
1269
- dispatch.completed.push(item);
1270
- });
1271
-
1272
- if (item) {
1273
- log('info', `Completed dispatch: ${id} (${result}${reason ? ': ' + reason : ''})`);
1274
-
1275
- // Update source work item status on failure + auto-retry with backoff
1276
- const retryableFailure = isRetryableFailureReason(reason);
1277
- if (result === 'error' && item.meta?.dispatchKey && retryableFailure) setCooldownFailure(item.meta.dispatchKey);
1278
-
1279
- if (processWorkItemFailure && result === 'error' && item.meta?.item?.id) {
1280
- let retries = (item.meta.item._retryCount || 0);
1281
- try {
1282
- const wiPath = item.meta.source === 'central-work-item' || item.meta.source === 'central-work-item-fanout'
1283
- ? path.join(MINIONS_DIR, 'work-items.json')
1284
- : item.meta.project?.name ? projectWorkItemsPath({ name: item.meta.project.name, localPath: item.meta.project.localPath }) : null;
1285
- if (wiPath) {
1286
- const items = safeJson(wiPath) || [];
1287
- const wi = items.find(i => i.id === item.meta.item.id);
1288
- if (wi) retries = wi._retryCount || 0;
1289
- }
1290
- } catch (e) { log('warn', 'read retry count: ' + e.message); }
1291
- if (retryableFailure && retries < 3) {
1292
- log('info', `Dispatch error for ${item.meta.item.id} — auto-retry ${retries + 1}/3`);
1293
- updateWorkItemStatus(item.meta, 'pending', '');
1294
- // Remove this dispatch key from completed so dedupe doesn't block immediate redispatch.
1295
- if (item.meta?.dispatchKey) {
1296
- try {
1297
- mutateDispatch((dp) => {
1298
- dp.completed = Array.isArray(dp.completed) ? dp.completed.filter(d => d.meta?.dispatchKey !== item.meta.dispatchKey) : [];
1299
- return dp;
1300
- });
1301
- } catch (e) { log('warn', 'clear dispatch for retry: ' + e.message); }
1302
- }
1303
- // Increment retry counter on the source work item
1304
- try {
1305
- const wiPath = item.meta.source === 'central-work-item' || item.meta.source === 'central-work-item-fanout'
1306
- ? path.join(MINIONS_DIR, 'work-items.json')
1307
- : item.meta.project?.name ? projectWorkItemsPath({ name: item.meta.project.name, localPath: item.meta.project.localPath }) : null;
1308
- if (wiPath) {
1309
- const items = safeJson(wiPath) || [];
1310
- const wi = items.find(i => i.id === item.meta.item.id);
1311
- if (wi && wi.status !== 'paused') {
1312
- wi._retryCount = retries + 1;
1313
- wi.status = 'pending';
1314
- wi._lastRetryReason = reason || '';
1315
- wi._lastRetryAt = ts();
1316
- delete wi.failReason;
1317
- delete wi.failedAt;
1318
- delete wi.dispatched_at;
1319
- delete wi.dispatched_to;
1320
- safeWrite(wiPath, items);
1321
- }
1322
- }
1323
- } catch (e) { log('warn', 'increment retry counter: ' + e.message); }
1324
- } else {
1325
- const finalReason = !retryableFailure
1326
- ? `Non-retryable failure: ${reason || 'Unknown error'}`
1327
- : (reason || 'Failed after 3 retries');
1328
- updateWorkItemStatus(item.meta, 'failed', finalReason);
1329
- // Alert: find items blocked by this failure and write inbox note
1330
- try {
1331
- const config = getConfig();
1332
- const failedId = item.meta.item.id;
1333
- const blockedItems = [];
1334
- for (const p of getProjects(config)) {
1335
- const items = safeJson(projectWorkItemsPath(p)) || [];
1336
- items.filter(w => w.status === 'pending' && (w.depends_on || []).includes(failedId))
1337
- .forEach(w => blockedItems.push(`- \`${w.id}\` — ${w.title}`));
1338
- }
1339
- const centralItems = safeJson(path.join(MINIONS_DIR, 'work-items.json')) || [];
1340
- centralItems.filter(w => w.status === 'pending' && (w.depends_on || []).includes(failedId))
1341
- .forEach(w => blockedItems.push(`- \`${w.id}\` — ${w.title}`));
1342
-
1343
- writeInboxAlert(`failed-${failedId}`,
1344
- `# Work Item Failed — \`${failedId}\`\n\n` +
1345
- `**Item:** ${item.meta.item.title || failedId}\n` +
1346
- `**Reason:** ${finalReason}\n\n` +
1347
- (blockedItems.length > 0
1348
- ? `**Blocked dependents (${blockedItems.length}):**\n${blockedItems.join('\n')}\n\n` +
1349
- `These items cannot dispatch until \`${failedId}\` is fixed and reset to \`pending\`.\n`
1350
- : `No downstream items are blocked.\n`)
1351
- );
1352
- } catch (e) { log('warn', 'write failure alert: ' + e.message); }
1353
- }
1354
- }
1355
- }
1356
- }
722
+ // completeDispatch now in engine/dispatch.js
1357
723
 
1358
724
  // ─── Dependency Gate ─────────────────────────────────────────────────────────
1359
725
  // Returns: true (deps met), false (deps pending), 'failed' (dep failed — propagate)
@@ -1409,18 +775,7 @@ function detectDependencyCycles(items) {
1409
775
  }
1410
776
 
1411
777
 
1412
- // ─── Shared helpers ───────────────────────────────────────────────────────────
1413
-
1414
- // Write a one-off alert note to notes/inbox so the user sees it on next consolidation
1415
- function writeInboxAlert(slug, content) {
1416
- try {
1417
- const file = path.join(INBOX_DIR, `engine-alert-${slug}-${dateStamp()}.md`);
1418
- // Dedupe: don't write the same alert twice in the same day
1419
- const existing = safeReadDir(INBOX_DIR).find(f => f.startsWith(`engine-alert-${slug}-${dateStamp()}`));
1420
- if (existing) return;
1421
- safeWrite(file, content);
1422
- } catch (e) { log('warn', 'write inbox alert: ' + e.message); }
1423
- }
778
+ // writeInboxAlert now in engine/dispatch.js
1424
779
 
1425
780
  // Reconciles work items against known PRs.
1426
781
  // Primary linkage comes from prdItems in pull-requests.json; fallback linkage
@@ -1492,690 +847,15 @@ function updateSnapshot(config) {
1492
847
  safeWrite(path.join(IDENTITY_DIR, 'now.md'), snapshot);
1493
848
  }
1494
849
 
1495
- // ─── Idle Alert ─────────────────────────────────────────────────────────────
1496
-
1497
- let _lastActivityTime = Date.now();
1498
- let _idleAlertSent = false;
1499
-
1500
- function checkIdleThreshold(config) {
1501
- const thresholdMs = (config.engine?.idleAlertMinutes || 15) * 60 * 1000;
1502
- const agents = Object.keys(config.agents || {});
1503
- const allIdle = agents.every(id => isAgentIdle(id));
1504
- const dispatch = getDispatch();
1505
- const hasPending = (dispatch.pending || []).length > 0;
1506
-
1507
- if (!allIdle || hasPending) {
1508
- _lastActivityTime = Date.now();
1509
- _idleAlertSent = false;
1510
- return;
1511
- }
1512
-
1513
- const idleMs = Date.now() - _lastActivityTime;
1514
- if (idleMs > thresholdMs && !_idleAlertSent) {
1515
- const mins = Math.round(idleMs / 60000);
1516
- log('warn', `All agents idle for ${mins} minutes — no work sources producing items`);
1517
- _idleAlertSent = true;
1518
- }
1519
- }
1520
-
1521
- // ─── Steering Checker ────────────────────────────────────────────────────────
1522
-
1523
- function checkSteering(config) {
1524
- for (const [id, info] of activeProcesses) {
1525
- const steerPath = path.join(AGENTS_DIR, info.agentId, 'steer.md');
1526
- if (!fs.existsSync(steerPath)) continue;
1527
-
1528
- const message = safeRead(steerPath);
1529
- try { fs.unlinkSync(steerPath); } catch { /* cleanup */ }
1530
- if (!message) continue;
1531
-
1532
- const sessionId = info.sessionId;
1533
- if (!sessionId) {
1534
- log('warn', `Steering: no sessionId for ${info.agentId} — cannot resume. Message dropped.`);
1535
- continue;
1536
- }
1537
-
1538
- log('info', `Steering: killing ${info.agentId} (${id}) for session resume with human message`);
1539
-
1540
- // Kill current process
1541
- try { info.proc.kill('SIGTERM'); } catch { /* process may be dead */ }
1542
-
1543
- // Store steering context for re-spawn on close
1544
- info._steeringMessage = message;
1545
- info._steeringSessionId = sessionId;
1546
- }
1547
- }
1548
-
1549
- // ─── Timeout Checker ────────────────────────────────────────────────────────
1550
-
1551
- function checkTimeouts(config) {
1552
- const timeout = config.engine?.agentTimeout || DEFAULTS.agentTimeout;
1553
- const heartbeatTimeout = config.engine?.heartbeatTimeout || DEFAULTS.heartbeatTimeout;
1554
-
1555
- // 1. Check tracked processes for hard timeout (supports per-item deadline from fan-out)
1556
- for (const [id, info] of activeProcesses.entries()) {
1557
- const itemTimeout = info.meta?.deadline ? Math.max(0, info.meta.deadline - new Date(info.startedAt).getTime()) : timeout;
1558
- const elapsed = Date.now() - new Date(info.startedAt).getTime();
1559
- if (elapsed > itemTimeout) {
1560
- log('warn', `Agent ${info.agentId} (${id}) hit hard timeout after ${Math.round(elapsed / 1000)}s — killing`);
1561
- try { info.proc.kill('SIGTERM'); } catch { /* process may be dead */ }
1562
- setTimeout(() => {
1563
- try { info.proc.kill('SIGKILL'); } catch { /* process may be dead */ }
1564
- }, 5000);
1565
- }
1566
- }
1567
-
1568
- // 2. Heartbeat check — for ALL active dispatch items (catches orphans after engine restart)
1569
- // Uses live-output.log mtime as heartbeat. If no output for heartbeatTimeout, agent is dead.
1570
- const dispatch = getDispatch();
1571
- const deadItems = [];
1572
-
1573
- for (const item of (dispatch.active || [])) {
1574
- if (!item.agent) continue;
1575
-
1576
- const hasProcess = activeProcesses.has(item.id);
1577
- const liveLogPath = path.join(AGENTS_DIR, item.agent, 'live-output.log');
1578
- let lastActivity = item.started_at ? new Date(item.started_at).getTime() : 0;
1579
-
1580
- // Check live-output.log mtime as heartbeat
1581
- try {
1582
- const stat = fs.statSync(liveLogPath);
1583
- lastActivity = Math.max(lastActivity, stat.mtimeMs);
1584
- } catch { /* optional */ }
1585
-
1586
- const silentMs = Date.now() - lastActivity;
1587
- const silentSec = Math.round(silentMs / 1000);
1588
-
1589
- // Check if the agent actually completed (result event in live output)
1590
- // Optimization: only read file if recent activity (avoids reading stale 1MB logs)
1591
- let completedViaOutput = false;
1592
- try {
1593
- if (silentMs > 600000) throw 'skip'; // No point reading a file silent for >10min
1594
- const liveLog = safeRead(liveLogPath);
1595
- if (liveLog && liveLog.includes('"type":"result"')) {
1596
- completedViaOutput = true;
1597
- const isSuccess = liveLog.includes('"subtype":"success"');
1598
- log('info', `Agent ${item.agent} (${item.id}) completed via output detection (${isSuccess ? 'success' : 'error'})`);
1599
-
1600
- // Extract output text for the output.log
1601
- const outputLogPath = path.join(AGENTS_DIR, item.agent, 'output.log');
1602
- try {
1603
- const resultLine = liveLog.split('\n').find(l => l.includes('"type":"result"'));
1604
- if (resultLine) {
1605
- const result = JSON.parse(resultLine);
1606
- 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`);
1607
- }
1608
- } catch (e) { log('warn', 'parse output result: ' + e.message); }
1609
-
1610
- completeDispatch(item.id, isSuccess ? 'success' : 'error', 'Completed (detected from output)');
1611
-
1612
- // Run post-completion hooks via shared helper
1613
- runPostCompletionHooks(item, item.agent, isSuccess ? 0 : 1, liveLog, config);
1614
-
1615
- if (hasProcess) {
1616
- try { activeProcesses.get(item.id)?.proc.kill('SIGTERM'); } catch { /* process may be dead */ }
1617
- activeProcesses.delete(item.id);
1618
- }
1619
- continue; // Skip orphan/hung detection — we handled it
1620
- }
1621
- } catch (e) { log('warn', 'output completion detection: ' + e.message); }
1622
-
1623
- // Check if agent is in a blocking tool call (TaskOutput block:true, Bash with long timeout, etc.)
1624
- // These tools produce no stdout for extended periods — don't kill them prematurely
1625
- // Check for BOTH tracked and untracked processes (orphan case after engine restart)
1626
- let isBlocking = false;
1627
- let blockingTimeout = heartbeatTimeout;
1628
- if (silentMs > heartbeatTimeout) {
1629
- try {
1630
- const liveLog = safeRead(liveLogPath);
1631
- if (liveLog) {
1632
- // Find the last tool_use call in the output — check if it's a known blocking tool
1633
- const lines = liveLog.split('\n');
1634
- for (let i = lines.length - 1; i >= Math.max(0, lines.length - 30); i--) {
1635
- const line = lines[i];
1636
- if (!line.includes('"tool_use"')) continue;
1637
- try {
1638
- const parsed = JSON.parse(line);
1639
- const toolUse = parsed?.message?.content?.find?.(c => c.type === 'tool_use');
1640
- if (!toolUse) continue;
1641
- const input = toolUse.input || {};
1642
- const name = toolUse.name || '';
1643
- // TaskOutput with block:true — waiting for a background task
1644
- if (name === 'TaskOutput' && input.block === true) {
1645
- const taskTimeout = input.timeout || 600000; // default 10min
1646
- blockingTimeout = Math.max(heartbeatTimeout, taskTimeout + 60000); // task timeout + 1min grace
1647
- isBlocking = true;
1648
- }
1649
- // Bash with explicit long timeout (>5min)
1650
- if (name === 'Bash' && input.timeout && input.timeout > heartbeatTimeout) {
1651
- blockingTimeout = Math.max(heartbeatTimeout, input.timeout + 60000);
1652
- isBlocking = true;
1653
- }
1654
- break; // only check the most recent tool_use
1655
- } catch { /* JSON parse — line may not be valid JSON */ }
1656
- }
1657
- if (isBlocking) {
1658
- 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)`);
1659
- }
1660
- }
1661
- } catch (e) { log('warn', 'blocking tool detection: ' + e.message); }
1662
- }
1663
-
1664
- const effectiveTimeout = isBlocking ? blockingTimeout : heartbeatTimeout;
1665
-
1666
- if (!hasProcess && silentMs > effectiveTimeout && Date.now() > engineRestartGraceUntil) {
1667
- // No tracked process AND no recent output past effective timeout AND grace period expired → orphaned
1668
- log('warn', `Orphan detected: ${item.agent} (${item.id}) — no process tracked, silent for ${silentSec}s${isBlocking ? ' (blocking timeout exceeded)' : ''}`);
1669
- deadItems.push({ item, reason: `Orphaned — no process, silent for ${silentSec}s` });
1670
- } else if (hasProcess && silentMs > effectiveTimeout) {
1671
- // Has process but no output past effective timeout → hung
1672
- log('warn', `Hung agent: ${item.agent} (${item.id}) — process exists but no output for ${silentSec}s${isBlocking ? ' (blocking timeout exceeded)' : ''}`);
1673
- const procInfo = activeProcesses.get(item.id);
1674
- if (procInfo) {
1675
- try { procInfo.proc.kill('SIGTERM'); } catch { /* process may be dead */ }
1676
- setTimeout(() => { try { procInfo.proc.kill('SIGKILL'); } catch { /* process may be dead */ } }, 5000);
1677
- activeProcesses.delete(item.id);
1678
- }
1679
- deadItems.push({ item, reason: `Hung — no output for ${silentSec}s` });
1680
- }
1681
- // If has process and recent output → healthy, let it run
1682
- }
1683
-
1684
- // Clean up dead items
1685
- for (const { item, reason } of deadItems) {
1686
- completeDispatch(item.id, 'error', reason);
1687
- }
1688
-
1689
- // Agent status is now derived from dispatch.json at read time (getAgentStatus).
1690
- // No reconcile sweep needed — dispatch IS the source of truth.
1691
-
1692
- // Reconcile: find work items stuck in "dispatched" with no matching active dispatch
1693
- const activeKeys = new Set((dispatch.active || []).map(d => d.meta?.dispatchKey).filter(Boolean));
1694
- const allWiPaths = [path.join(MINIONS_DIR, 'work-items.json')];
1695
- for (const project of getProjects(config)) {
1696
- allWiPaths.push(projectWorkItemsPath(project));
1697
- }
1698
- for (const wiPath of allWiPaths) {
1699
- const items = safeJson(wiPath);
1700
- if (!items || !Array.isArray(items)) continue;
1701
- let changed = false;
1702
- for (const item of items) {
1703
- if (item.status !== 'dispatched') continue;
1704
- // Check if any active dispatch references this item
1705
- // Dispatch keys include project name: work-{project}-{id} or central-work-{id}
1706
- const projectNames = getProjects(config).map(p => p.name);
1707
- const possibleKeys = [
1708
- `central-work-${item.id}`,
1709
- ...projectNames.map(p => `work-${p}-${item.id}`),
1710
- ];
1711
- const isActive = possibleKeys.some(k => activeKeys.has(k)) ||
1712
- (dispatch.active || []).some(d => d.meta?.item?.id === item.id);
1713
- if (!isActive) {
1714
- const retries = (item._retryCount || 0);
1715
- if (retries < 3) {
1716
- log('info', `Reconcile: work item ${item.id} agent died — auto-retry ${retries + 1}/3`);
1717
- item.status = 'pending';
1718
- item._retryCount = retries + 1;
1719
- delete item.dispatched_at;
1720
- delete item.dispatched_to;
1721
- } else {
1722
- log('warn', `Reconcile: work item ${item.id} failed after ${retries} retries — marking as failed`);
1723
- item.status = 'failed';
1724
- item.failReason = 'Agent died or was killed (3 retries exhausted)';
1725
- item.failedAt = ts();
1726
- }
1727
- changed = true;
1728
- }
1729
- }
1730
- if (changed) safeWrite(wiPath, items);
1731
- }
1732
- }
1733
-
1734
- // ─── Cleanup ─────────────────────────────────────────────────────────────────
1735
-
1736
- function runCleanup(config, verbose = false) {
1737
- const projects = getProjects(config);
1738
- let cleaned = { tempFiles: 0, liveOutputs: 0, worktrees: 0, zombies: 0 };
1739
-
1740
- // 1. Clean stale temp prompt/sysprompt files (older than 1 hour)
1741
- const oneHourAgo = Date.now() - 3600000;
1742
- try {
1743
- const tmpDir = path.join(ENGINE_DIR, 'tmp');
1744
- const scanDirs = [ENGINE_DIR, ...(fs.existsSync(tmpDir) ? [tmpDir] : [])];
1745
- for (const dir of scanDirs) {
1746
- for (const f of fs.readdirSync(dir)) {
1747
- if (f.startsWith('prompt-') || f.startsWith('sysprompt-') || f.startsWith('tmp-sysprompt-')) {
1748
- const fp = path.join(dir, f);
1749
- try {
1750
- const stat = fs.statSync(fp);
1751
- if (stat.mtimeMs < oneHourAgo) {
1752
- fs.unlinkSync(fp);
1753
- cleaned.tempFiles++;
1754
- }
1755
- } catch { /* cleanup */ }
1756
- }
1757
- }
1758
- }
1759
- } catch (e) { log('warn', 'cleanup temp files: ' + e.message); }
1760
-
1761
- // 2. Clean live-output.log for idle agents (not currently working)
1762
- for (const [agentId] of Object.entries(config.agents || {})) {
1763
- const status = getAgentStatus(agentId);
1764
- if (status.status !== 'working') {
1765
- const livePath = path.join(AGENTS_DIR, agentId, 'live-output.log');
1766
- if (fs.existsSync(livePath)) {
1767
- try {
1768
- const stat = fs.statSync(livePath);
1769
- if (stat.mtimeMs < oneHourAgo) {
1770
- fs.unlinkSync(livePath);
1771
- cleaned.liveOutputs++;
1772
- }
1773
- } catch { /* cleanup */ }
1774
- }
1775
- }
1776
- }
1777
-
1778
- // 3. Clean git worktrees for merged/abandoned PRs
1779
- for (const project of projects) {
1780
- const root = project.localPath ? path.resolve(project.localPath) : null;
1781
- if (!root || !fs.existsSync(root)) continue;
1782
-
1783
- const worktreeRoot = path.resolve(root, config.engine?.worktreeRoot || '../worktrees');
1784
- if (!fs.existsSync(worktreeRoot)) continue;
1785
-
1786
- // Get PRs for this project
1787
- const prs = safeJson(projectPrPath(project)) || [];
1788
- const mergedBranches = new Set();
1789
- for (const pr of prs) {
1790
- if (pr.status === 'merged' || pr.status === 'abandoned' || pr.status === 'completed') {
1791
- if (pr.branch) mergedBranches.add(pr.branch);
1792
- }
1793
- }
1794
-
1795
- // List worktrees — collect info for age-based + cap-based cleanup
1796
- const MAX_WORKTREES = 10;
1797
- try {
1798
- const dirs = fs.readdirSync(worktreeRoot);
1799
- const wtEntries = []; // { dir, wtPath, mtime, shouldClean, isProtected }
1800
- const dispatch = getDispatch();
850
+ // checkIdleThreshold, checkSteering, checkTimeouts — now in engine/timeout.js
1801
851
 
1802
- for (const dir of dirs) {
1803
- const wtPath = path.join(worktreeRoot, dir);
1804
- try { if (!fs.statSync(wtPath).isDirectory()) continue; } catch { continue; }
852
+ // runCleanup now in engine/cleanup.js
1805
853
 
1806
- let shouldClean = false;
1807
- let isProtected = false;
854
+ // ─── Cooldowns (extracted to engine/cooldown.js) ─────────────────────────────
1808
855
 
1809
- // Check if this worktree's branch is merged/abandoned
1810
- // Use sanitized exact match on the branch portion of the dir name (format: {slug}-{branch}-{suffix})
1811
- const dirLower = dir.toLowerCase();
1812
- for (const branch of mergedBranches) {
1813
- const branchSlug = sanitizeBranch(branch).toLowerCase();
1814
- if (dirLower === branchSlug || dirLower.includes(branchSlug + '-') || dirLower.endsWith('-' + branchSlug)) {
1815
- shouldClean = true;
1816
- break;
1817
- }
1818
- }
1819
-
1820
- // Check if referenced by active/pending dispatch (use sanitized branch comparison)
1821
- const isReferenced = [...dispatch.pending, ...(dispatch.active || [])].some(d => {
1822
- if (!d.meta?.branch) return false;
1823
- const dispBranch = sanitizeBranch(d.meta.branch).toLowerCase();
1824
- return dirLower.includes(dispBranch);
1825
- });
1826
- if (isReferenced) isProtected = true;
1827
-
1828
- // Also clean worktrees older than 2 hours with no active dispatch referencing them
1829
- let mtime = Date.now();
1830
- if (!shouldClean) {
1831
- try {
1832
- const stat = fs.statSync(wtPath);
1833
- mtime = stat.mtimeMs;
1834
- const ageMs = Date.now() - mtime;
1835
- if (ageMs > 7200000 && !isReferenced) { // 2 hours
1836
- shouldClean = true;
1837
- }
1838
- } catch { /* optional */ }
1839
- }
1840
-
1841
- // Skip worktrees for active shared-branch plans (check both prd/ and plans/ for .json PRDs)
1842
- if (shouldClean || !isProtected) {
1843
- try {
1844
- for (const checkDir of [PRD_DIR, path.join(MINIONS_DIR, 'plans')]) {
1845
- if (!fs.existsSync(checkDir)) continue;
1846
- for (const pf of fs.readdirSync(checkDir).filter(f => f.endsWith('.json'))) {
1847
- const plan = safeJson(path.join(checkDir, pf));
1848
- if (plan?.branch_strategy === 'shared-branch' && plan?.feature_branch && plan?.status !== 'completed') {
1849
- const planBranch = sanitizeBranch(plan.feature_branch).toLowerCase();
1850
- if (dirLower.includes(planBranch)) {
1851
- isProtected = true;
1852
- if (shouldClean) {
1853
- shouldClean = false;
1854
- if (verbose) console.log(` Skipping worktree ${dir}: active shared-branch plan`);
1855
- }
1856
- break;
1857
- }
1858
- }
1859
- }
1860
- if (isProtected) break;
1861
- }
1862
- } catch (e) { log('warn', 'check shared-branch protection: ' + e.message); }
1863
- }
1864
-
1865
- wtEntries.push({ dir, wtPath, mtime, shouldClean, isProtected });
1866
- }
1867
-
1868
- // Enforce max worktree cap — if over limit, mark oldest unprotected for cleanup
1869
- const surviving = wtEntries.filter(e => !e.shouldClean && !e.isProtected);
1870
- if (surviving.length + wtEntries.filter(e => e.isProtected).length > MAX_WORKTREES) {
1871
- // Sort oldest first
1872
- surviving.sort((a, b) => a.mtime - b.mtime);
1873
- const excess = surviving.length + wtEntries.filter(e => e.isProtected).length - MAX_WORKTREES;
1874
- for (let i = 0; i < Math.min(excess, surviving.length); i++) {
1875
- surviving[i].shouldClean = true;
1876
- if (verbose) console.log(` Marking worktree ${surviving[i].dir} for cap cleanup (${MAX_WORKTREES} max)`);
1877
- }
1878
- }
1879
-
1880
- // Remove all marked worktrees
1881
- for (const entry of wtEntries) {
1882
- if (entry.shouldClean) {
1883
- try {
1884
- exec(`git worktree remove "${entry.wtPath}" --force`, { cwd: root, stdio: 'pipe' });
1885
- cleaned.worktrees++;
1886
- if (verbose) console.log(` Removed worktree: ${entry.wtPath}`);
1887
- } catch (e) {
1888
- if (verbose) console.log(` Failed to remove worktree ${entry.wtPath}: ${e.message}`);
1889
- }
1890
- }
1891
- }
1892
- } catch (e) { log('warn', 'cleanup worktrees: ' + e.message); }
1893
- }
1894
-
1895
- // 4. Kill zombie claude processes not tracked by the engine
1896
- // List all node processes, check if any are running spawn-agent.js for our minions
1897
- try {
1898
- const dispatch = getDispatch();
1899
- const activePids = new Set();
1900
- for (const [, info] of activeProcesses.entries()) {
1901
- if (info.proc?.pid) activePids.add(info.proc.pid);
1902
- }
1903
-
1904
- // Clean individual orphaned processes — no matching active dispatch
1905
- const activeIds = new Set((dispatch.active || []).map(d => d.id));
1906
- for (const [id, info] of activeProcesses.entries()) {
1907
- if (!activeIds.has(id)) {
1908
- try { if (info.proc) info.proc.kill('SIGTERM'); } catch { /* process may be dead */ }
1909
- activeProcesses.delete(id);
1910
- cleaned.zombies++;
1911
- }
1912
- }
1913
- } catch (e) { log('warn', 'cleanup zombie processes: ' + e.message); }
1914
-
1915
- // 5. Clean spawn-debug.log
1916
- try { fs.unlinkSync(path.join(ENGINE_DIR, 'spawn-debug.log')); } catch { /* cleanup */ }
1917
-
1918
- // 6. Prune old output archive files (keep last 30 per agent)
1919
- for (const agentId of Object.keys(config.agents || {})) {
1920
- const agentDir = path.join(MINIONS_DIR, 'agents', agentId);
1921
- if (!fs.existsSync(agentDir)) continue;
1922
- try {
1923
- const outputFiles = fs.readdirSync(agentDir)
1924
- .filter(f => f.startsWith('output-') && f.endsWith('.log') && f !== 'output.log')
1925
- .map(f => ({ name: f, mtime: fs.statSync(path.join(agentDir, f)).mtimeMs }))
1926
- .sort((a, b) => b.mtime - a.mtime);
1927
- for (const old of outputFiles.slice(30)) {
1928
- try { fs.unlinkSync(path.join(agentDir, old.name)); cleaned.files++; } catch { /* cleanup */ }
1929
- }
1930
- } catch (e) { log('warn', 'prune output archives: ' + e.message); }
1931
- }
1932
-
1933
- // 7. Prune orphaned dispatch entries — items whose source work item no longer exists
1934
- cleaned.orphanedDispatches = 0;
1935
- try {
1936
- const dispatch = getDispatch();
1937
- // Collect all work item IDs across all sources
1938
- const allWiIds = new Set();
1939
- try {
1940
- const central = safeJson(path.join(MINIONS_DIR, 'work-items.json')) || [];
1941
- central.forEach(w => allWiIds.add(w.id));
1942
- } catch (e) { log('warn', 'read central work items for orphan check: ' + e.message); }
1943
- for (const project of projects) {
1944
- try {
1945
- const projItems = safeJson(projectWorkItemsPath(project)) || [];
1946
- projItems.forEach(w => allWiIds.add(w.id));
1947
- } catch (e) { log('warn', 'read project work items for orphan check: ' + e.message); }
1948
- }
1949
-
1950
- let changed = false;
1951
- for (const queue of ['pending', 'active']) {
1952
- if (!dispatch[queue]) continue;
1953
- const before = dispatch[queue].length;
1954
- dispatch[queue] = dispatch[queue].filter(d => {
1955
- const itemId = d.meta?.item?.id;
1956
- if (!itemId) return true; // keep entries without item tracking
1957
- return allWiIds.has(itemId);
1958
- });
1959
- const removed = before - dispatch[queue].length;
1960
- if (removed > 0) {
1961
- cleaned.orphanedDispatches += removed;
1962
- changed = true;
1963
- }
1964
- }
1965
- if (changed) {
1966
- mutateDispatch((dp) => {
1967
- for (const queue of ['pending', 'active']) {
1968
- if (!dp[queue]) continue;
1969
- dp[queue] = dp[queue].filter(d => {
1970
- const itemId = d.meta?.item?.id;
1971
- if (!itemId) return true;
1972
- return allWiIds.has(itemId);
1973
- });
1974
- }
1975
- });
1976
- }
1977
- } catch (e) { log('warn', 'prune orphaned dispatches: ' + e.message); }
1978
-
1979
- if (cleaned.tempFiles + cleaned.liveOutputs + cleaned.worktrees + cleaned.zombies + (cleaned.files || 0) + cleaned.orphanedDispatches > 0) {
1980
- log('info', `Cleanup: ${cleaned.tempFiles} temp, ${cleaned.liveOutputs} live outputs, ${cleaned.worktrees} worktrees, ${cleaned.zombies} zombies, ${cleaned.files || 0} archives, ${cleaned.orphanedDispatches} orphaned dispatches`);
1981
- }
1982
-
1983
- // 8. Clean swept KB files older than 7 days
1984
- try {
1985
- const sweptDir = path.join(MINIONS_DIR, 'knowledge', '_swept');
1986
- if (fs.existsSync(sweptDir)) {
1987
- const sevenDaysAgo = Date.now() - 7 * 86400000;
1988
- for (const f of fs.readdirSync(sweptDir)) {
1989
- try {
1990
- const fp = path.join(sweptDir, f);
1991
- if (fs.statSync(fp).mtimeMs < sevenDaysAgo) {
1992
- fs.unlinkSync(fp);
1993
- if (!cleaned.sweptKb) cleaned.sweptKb = 0;
1994
- cleaned.sweptKb++;
1995
- }
1996
- } catch { /* cleanup */ }
1997
- }
1998
- }
1999
- } catch (e) { log('warn', 'cleanup swept KB files: ' + e.message); }
2000
-
2001
- // 9. KB watchdog — restore deleted KB files from git if count dropped vs checkpoint
2002
- try {
2003
- const checkpoint = safeJson(path.join(ENGINE_DIR, 'kb-checkpoint.json'));
2004
- if (checkpoint && checkpoint.count > 0) {
2005
- const { KB_CATEGORIES: cats } = shared;
2006
- const knowledgeDir = path.join(MINIONS_DIR, 'knowledge');
2007
- let current = 0;
2008
- for (const cat of cats) {
2009
- const d = path.join(knowledgeDir, cat);
2010
- if (fs.existsSync(d)) current += fs.readdirSync(d).length;
2011
- }
2012
- if (current < checkpoint.count) {
2013
- log('warn', `KB watchdog: file count dropped ${checkpoint.count} → ${current}, restoring from git`);
2014
- try {
2015
- const trackedCheck = execSilent('git ls-tree --name-only HEAD -- knowledge', { cwd: MINIONS_DIR }).toString().trim();
2016
- if (!trackedCheck) {
2017
- log('warn', 'KB watchdog: knowledge/ is not tracked in git HEAD — skipping restore');
2018
- } else {
2019
- execSilent('git checkout HEAD -- knowledge', { cwd: MINIONS_DIR });
2020
- log('info', 'KB watchdog: restored knowledge/ from git HEAD');
2021
- }
2022
- } catch (err) {
2023
- log('error', `KB watchdog: git restore failed — ${err.message}`);
2024
- }
2025
- }
2026
- }
2027
- } catch (e) { log('warn', 'KB watchdog check: ' + e.message); }
2028
-
2029
- // 6. Migrate legacy work-item statuses to canonical values
2030
- // in-pr, implemented, complete → done (one-time correction per item)
2031
- const LEGACY_DONE_STATUSES = new Set(['in-pr', 'implemented', 'complete']);
2032
- for (const project of projects) {
2033
- try {
2034
- const wiPath = projectWorkItemsPath(project);
2035
- const items = safeJson(wiPath) || [];
2036
- let migrated = 0;
2037
- for (const item of items) {
2038
- if (LEGACY_DONE_STATUSES.has(item.status)) {
2039
- item.status = 'done';
2040
- migrated++;
2041
- }
2042
- }
2043
- if (migrated > 0) {
2044
- safeWrite(wiPath, items);
2045
- log('info', `Migrated ${migrated} legacy status(es) → done in ${project.name} work items`);
2046
- }
2047
- } catch (e) { log('warn', 'migrate legacy statuses: ' + e.message); }
2048
- }
2049
- // Central work items
2050
- try {
2051
- const centralPath = path.join(MINIONS_DIR, 'work-items.json');
2052
- const centralItems = safeJson(centralPath) || [];
2053
- let migrated = 0;
2054
- for (const item of centralItems) {
2055
- if (LEGACY_DONE_STATUSES.has(item.status)) {
2056
- item.status = 'done';
2057
- migrated++;
2058
- }
2059
- }
2060
- if (migrated > 0) {
2061
- safeWrite(centralPath, centralItems);
2062
- log('info', `Migrated ${migrated} legacy status(es) → done in central work items`);
2063
- }
2064
- } catch (e) { log('warn', 'migrate central legacy statuses: ' + e.message); }
2065
- // PRD items (missing_features[].status)
2066
- try {
2067
- const prdFiles = fs.readdirSync(PRD_DIR).filter(f => f.endsWith('.json'));
2068
- for (const pf of prdFiles) {
2069
- const prdPath = path.join(PRD_DIR, pf);
2070
- const prd = safeJson(prdPath);
2071
- if (!prd?.missing_features) continue;
2072
- let migrated = 0;
2073
- for (const feat of prd.missing_features) {
2074
- if (LEGACY_DONE_STATUSES.has(feat.status)) {
2075
- feat.status = 'done';
2076
- migrated++;
2077
- }
2078
- }
2079
- if (migrated > 0) {
2080
- safeWrite(prdPath, prd);
2081
- log('info', `Migrated ${migrated} legacy PRD item status(es) → done in ${pf}`);
2082
- }
2083
- }
2084
- } catch (e) { log('warn', 'migrate PRD legacy statuses: ' + e.message); }
2085
-
2086
- return cleaned;
2087
- }
2088
-
2089
- // ─── Work Discovery ─────────────────────────────────────────────────────────
2090
-
2091
- const COOLDOWN_PATH = path.join(ENGINE_DIR, 'cooldowns.json');
2092
- const dispatchCooldowns = new Map(); // key → { timestamp, failures }
2093
-
2094
- function loadCooldowns() {
2095
- const saved = safeJson(COOLDOWN_PATH);
2096
- if (!saved) return;
2097
- const now = Date.now();
2098
- for (const [k, v] of Object.entries(saved)) {
2099
- // Prune entries older than 24 hours
2100
- if (now - v.timestamp < 24 * 60 * 60 * 1000) {
2101
- dispatchCooldowns.set(k, v);
2102
- }
2103
- }
2104
- log('info', `Loaded ${dispatchCooldowns.size} cooldowns from disk`);
2105
- }
2106
-
2107
- let _cooldownWriteTimer = null;
2108
- function saveCooldowns() {
2109
- // Debounce: reset timer on each call so latest state is always written
2110
- if (_cooldownWriteTimer) clearTimeout(_cooldownWriteTimer);
2111
- _cooldownWriteTimer = setTimeout(() => {
2112
- _cooldownWriteTimer = null;
2113
- // Prune expired entries (>24h) before saving
2114
- const now = Date.now();
2115
- for (const [k, v] of dispatchCooldowns) {
2116
- if (now - v.timestamp > 24 * 60 * 60 * 1000) dispatchCooldowns.delete(k);
2117
- }
2118
- const obj = Object.fromEntries(dispatchCooldowns);
2119
- safeWrite(COOLDOWN_PATH, obj);
2120
- }, 1000); // debounce — write at most once per second
2121
- }
2122
-
2123
- function isOnCooldown(key, cooldownMs) {
2124
- const entry = dispatchCooldowns.get(key);
2125
- if (!entry) return false;
2126
- const backoff = Math.min(Math.pow(2, entry.failures || 0), 8);
2127
- return (Date.now() - entry.timestamp) < (cooldownMs * backoff);
2128
- }
2129
-
2130
- function setCooldown(key) {
2131
- const existing = dispatchCooldowns.get(key);
2132
- dispatchCooldowns.set(key, { timestamp: Date.now(), failures: existing?.failures || 0 });
2133
- saveCooldowns();
2134
- }
2135
-
2136
- function setCooldownWithContext(key, context) {
2137
- const existing = dispatchCooldowns.get(key);
2138
- const pendingContexts = existing?.pendingContexts || [];
2139
- if (context) pendingContexts.push(context);
2140
- dispatchCooldowns.set(key, {
2141
- timestamp: Date.now(),
2142
- failures: existing?.failures || 0,
2143
- pendingContexts
2144
- });
2145
- saveCooldowns();
2146
- }
2147
-
2148
- function getCoalescedContexts(key) {
2149
- const entry = dispatchCooldowns.get(key);
2150
- const contexts = entry?.pendingContexts || [];
2151
- if (contexts.length > 0 && entry) {
2152
- entry.pendingContexts = []; // Clear after retrieval
2153
- }
2154
- return contexts;
2155
- }
2156
-
2157
- function setCooldownFailure(key) {
2158
- const existing = dispatchCooldowns.get(key);
2159
- const failures = (existing?.failures || 0) + 1;
2160
- dispatchCooldowns.set(key, { timestamp: Date.now(), failures });
2161
- if (failures >= 3) {
2162
- log('warn', `${key} has failed ${failures} times — cooldown is now ${Math.min(Math.pow(2, failures), 8)}x`);
2163
- }
2164
- saveCooldowns();
2165
- }
2166
-
2167
- function isAlreadyDispatched(key) {
2168
- const dispatch = getDispatch();
2169
- // Check pending and active
2170
- const inFlight = [...dispatch.pending, ...(dispatch.active || [])];
2171
- if (inFlight.some(d => d.meta?.dispatchKey === key)) return true;
2172
- // Also check recently completed (last hour) to prevent re-dispatch
2173
- const oneHourAgo = Date.now() - 3600000;
2174
- const recentCompleted = (dispatch.completed || []).filter(d =>
2175
- d.completed_at && new Date(d.completed_at).getTime() > oneHourAgo
2176
- );
2177
- return recentCompleted.some(d => d.meta?.dispatchKey === key);
2178
- }
856
+ const { COOLDOWN_PATH, dispatchCooldowns, loadCooldowns, saveCooldowns,
857
+ isOnCooldown, setCooldown, setCooldownWithContext, getCoalescedContexts,
858
+ setCooldownFailure, isAlreadyDispatched } = require('./engine/cooldown');
2179
859
 
2180
860
 
2181
861
 
@@ -2507,50 +1187,7 @@ function materializePlansAsWorkItems(config) {
2507
1187
  }
2508
1188
  }
2509
1189
 
2510
- // ─── Work Discovery Helpers ──────────────────────────────────────────────────
2511
-
2512
- function buildBaseVars(agentId, config, project) {
2513
- return {
2514
- agent_id: agentId,
2515
- agent_name: config.agents[agentId]?.name || agentId,
2516
- agent_role: config.agents[agentId]?.role || 'Agent',
2517
- team_root: MINIONS_DIR,
2518
- repo_id: project?.repositoryId || '',
2519
- project_name: project?.name || 'Unknown Project',
2520
- ado_org: project?.adoOrg || 'Unknown',
2521
- ado_project: project?.adoProject || 'Unknown',
2522
- repo_name: project?.repoName || 'Unknown',
2523
- main_branch: project?.mainBranch || 'main',
2524
- date: dateStamp(),
2525
- };
2526
- }
2527
-
2528
- function selectPlaybook(workType, item) {
2529
- if (item?.branchStrategy === 'shared-branch' && (workType === 'implement' || workType === 'implement:large')) {
2530
- return 'implement-shared';
2531
- }
2532
- if (workType === 'review' && !item?._pr && !item?.pr_id) {
2533
- return 'work-item';
2534
- }
2535
- const typeSpecificPlaybooks = ['explore', 'review', 'test', 'plan-to-prd', 'plan', 'ask', 'verify', 'decompose'];
2536
- return typeSpecificPlaybooks.includes(workType) ? workType : 'work-item';
2537
- }
2538
-
2539
- function buildPrDispatch(agentId, config, project, pr, type, extraVars, taskLabel, meta) {
2540
- const vars = { ...buildBaseVars(agentId, config, project), ...extraVars };
2541
- const playbookName = type === 'test' ? 'build-and-test' : (type === 'review' ? 'review' : 'fix');
2542
- const prompt = renderPlaybook(playbookName, vars);
2543
- if (!prompt) return null;
2544
- return {
2545
- type,
2546
- agent: agentId,
2547
- agentName: config.agents[agentId]?.name,
2548
- agentRole: config.agents[agentId]?.role,
2549
- task: `[${project?.name || 'project'}] ${taskLabel}`,
2550
- prompt,
2551
- meta,
2552
- };
2553
- }
1190
+ // buildBaseVars, selectPlaybook, buildPrDispatch extracted to engine/playbook.js
2554
1191
 
2555
1192
  function clearPendingHumanFeedbackFlag(projectMeta, prId) {
2556
1193
  if (!prId) return;
@@ -3619,8 +2256,8 @@ module.exports = {
3619
2256
  getAgentStatus, getAgentCharter, getInboxFiles, getPrs,
3620
2257
  validateConfig,
3621
2258
 
3622
- // Dispatch management
3623
- addToDispatch, completeDispatch,
2259
+ // Dispatch management (re-exported from engine/dispatch.js)
2260
+ mutateDispatch, addToDispatch, isRetryableFailureReason, completeDispatch, writeInboxAlert,
3624
2261
  activeProcesses, get engineRestartGraceUntil() { return engineRestartGraceUntil; },
3625
2262
  set engineRestartGraceUntil(v) { engineRestartGraceUntil = v; },
3626
2263
 
@@ -3632,13 +2269,19 @@ module.exports = {
3632
2269
  materializePlansAsWorkItems,
3633
2270
 
3634
2271
  // Shared helpers (used by lifecycle.js and tests)
3635
- reconcileItemsWithPrs, writeInboxAlert, detectDependencyCycles,
2272
+ reconcileItemsWithPrs, detectDependencyCycles,
3636
2273
 
3637
2274
  // Playbooks
3638
2275
  renderPlaybook,
3639
2276
 
2277
+ // Timeout / Steering / Idle (re-exported from engine/timeout.js)
2278
+ checkTimeouts, checkSteering, checkIdleThreshold,
2279
+
2280
+ // Cleanup (re-exported from engine/cleanup.js)
2281
+ runCleanup,
2282
+
3640
2283
  // Post-completion / lifecycle
3641
- updateWorkItemStatus, runCleanup, handlePostMerge,
2284
+ updateWorkItemStatus, handlePostMerge,
3642
2285
 
3643
2286
  // Cooldowns
3644
2287
  loadCooldowns, setCooldownWithContext, getCoalescedContexts,