@yemi33/minions 0.1.1657 → 0.1.1659
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 +13 -0
- package/dashboard.js +30 -46
- package/engine/cleanup.js +3 -3
- package/engine/cli.js +14 -11
- package/engine/cooldown.js +31 -21
- package/engine/copilot-models.json +1 -1
- package/engine/dispatch.js +1 -1
- package/engine/lifecycle.js +30 -61
- package/engine/pipeline.js +5 -3
- package/engine/queries.js +15 -1
- package/engine/routing.js +41 -10
- package/engine/scheduler.js +4 -2
- package/engine/shared.js +22 -0
- package/engine/timeout.js +2 -2
- package/engine.js +20 -20
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.1659 (2026-05-01)
|
|
4
|
+
|
|
5
|
+
### Other
|
|
6
|
+
- Harden runtime state writes
|
|
7
|
+
|
|
8
|
+
## 0.1.1658 (2026-05-01)
|
|
9
|
+
|
|
10
|
+
### Fixes
|
|
11
|
+
- trust structured completion status, drop prose regex
|
|
12
|
+
|
|
13
|
+
### Other
|
|
14
|
+
- Fix soft routing hints
|
|
15
|
+
|
|
3
16
|
## 0.1.1657 (2026-05-01)
|
|
4
17
|
|
|
5
18
|
### Other
|
package/dashboard.js
CHANGED
|
@@ -31,7 +31,7 @@ const dispatchMod = require('./engine/dispatch');
|
|
|
31
31
|
const steering = require('./engine/steering');
|
|
32
32
|
const os = require('os');
|
|
33
33
|
|
|
34
|
-
const { safeRead, safeReadDir, safeWrite, safeJson, safeJsonObj, safeJsonArr, safeUnlink, mutateJsonFileLocked, mutateWorkItems, getProjects: _getProjects, DONE_STATUSES, WI_STATUS, reopenWorkItem } = shared;
|
|
34
|
+
const { safeRead, safeReadDir, safeWrite, safeJson, safeJsonObj, safeJsonArr, safeUnlink, mutateJsonFileLocked, mutateControl, mutateCooldowns, mutateWorkItems, getProjects: _getProjects, DONE_STATUSES, WI_STATUS, WORK_TYPE, reopenWorkItem } = shared;
|
|
35
35
|
const { getAgents, getAgentDetail, getPrdInfo, getWorkItems, getDispatchQueue,
|
|
36
36
|
getSkills, getInbox, getNotesWithMeta, getPullRequests,
|
|
37
37
|
getEngineLog, getMetrics, getKnowledgeBaseEntries, timeSince,
|
|
@@ -1392,7 +1392,7 @@ async function executeCCActions(actions) {
|
|
|
1392
1392
|
try {
|
|
1393
1393
|
switch (action.type) {
|
|
1394
1394
|
case 'dispatch': case 'fix': case 'implement': case 'explore': case 'review': case 'test': {
|
|
1395
|
-
const workType = action.workType || (action.type !== 'dispatch' ? action.type :
|
|
1395
|
+
const workType = routing.normalizeWorkType(action.workType || (action.type !== 'dispatch' ? action.type : WORK_TYPE.IMPLEMENT), WORK_TYPE.IMPLEMENT);
|
|
1396
1396
|
const id = 'W-' + shared.uid();
|
|
1397
1397
|
const project = action.project || '';
|
|
1398
1398
|
|
|
@@ -2116,12 +2116,10 @@ const { cleanDispatchEntries } = require('./engine/dispatch');
|
|
|
2116
2116
|
// ── Engine Restart Helpers (used by watchdog + API) ─────────────────────────
|
|
2117
2117
|
|
|
2118
2118
|
function spawnEngine() {
|
|
2119
|
-
const controlPath = path.join(ENGINE_DIR, 'control.json');
|
|
2120
2119
|
// Don't pre-write 'stopped' — let the new engine process own its state transition.
|
|
2121
2120
|
// The engine start code already handles state:'running' with a dead PID gracefully.
|
|
2122
2121
|
// Only set restarted_at + clear stale pid so dashboard shows the restart timestamp.
|
|
2123
|
-
|
|
2124
|
-
safeWrite(controlPath, { ...control, pid: null, restarted_at: new Date().toISOString() });
|
|
2122
|
+
mutateControl(control => ({ ...control, pid: null, restarted_at: new Date().toISOString() }));
|
|
2125
2123
|
const { spawn: cpSpawn } = require('child_process');
|
|
2126
2124
|
const childEnv = { ...process.env };
|
|
2127
2125
|
for (const key of Object.keys(childEnv)) {
|
|
@@ -2413,12 +2411,10 @@ const server = http.createServer(async (req, res) => {
|
|
|
2413
2411
|
}, { defaultValue: { pending: [], active: [], completed: [] } });
|
|
2414
2412
|
} catch (e) { console.error('dispatch cleanup:', e.message); }
|
|
2415
2413
|
try {
|
|
2416
|
-
|
|
2417
|
-
const cooldowns = safeJsonObj(cooldownPath);
|
|
2418
|
-
if (cooldowns[dispatchKey]) {
|
|
2414
|
+
mutateCooldowns(cooldowns => {
|
|
2419
2415
|
delete cooldowns[dispatchKey];
|
|
2420
|
-
|
|
2421
|
-
}
|
|
2416
|
+
return cooldowns;
|
|
2417
|
+
});
|
|
2422
2418
|
} catch (e) { console.error('cooldown cleanup:', e.message); }
|
|
2423
2419
|
|
|
2424
2420
|
return jsonReply(res, 200, { ok: true, id, rematerialized: true });
|
|
@@ -2463,12 +2459,10 @@ const server = http.createServer(async (req, res) => {
|
|
|
2463
2459
|
|
|
2464
2460
|
// Clear cooldown so item isn't blocked by exponential backoff
|
|
2465
2461
|
try {
|
|
2466
|
-
|
|
2467
|
-
const cooldowns = safeJsonObj(cooldownPath);
|
|
2468
|
-
if (cooldowns[dispatchKey]) {
|
|
2462
|
+
mutateCooldowns(cooldowns => {
|
|
2469
2463
|
delete cooldowns[dispatchKey];
|
|
2470
|
-
|
|
2471
|
-
}
|
|
2464
|
+
return cooldowns;
|
|
2465
|
+
});
|
|
2472
2466
|
} catch (e) { console.error('cooldown cleanup:', e.message); }
|
|
2473
2467
|
|
|
2474
2468
|
return jsonReply(res, 200, { ok: true, id });
|
|
@@ -2514,13 +2508,12 @@ const server = http.createServer(async (req, res) => {
|
|
|
2514
2508
|
|
|
2515
2509
|
// Clean cooldown entries so item can be re-created immediately
|
|
2516
2510
|
try {
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
2521
|
-
|
|
2522
|
-
}
|
|
2523
|
-
if (cleaned) safeWrite(cooldownPath, cooldowns);
|
|
2511
|
+
mutateCooldowns(cooldowns => {
|
|
2512
|
+
for (const key of Object.keys(cooldowns)) {
|
|
2513
|
+
if (key.includes(id)) delete cooldowns[key];
|
|
2514
|
+
}
|
|
2515
|
+
return cooldowns;
|
|
2516
|
+
});
|
|
2524
2517
|
} catch (e) { console.error('cooldown cleanup:', e.message); }
|
|
2525
2518
|
|
|
2526
2519
|
// Reset PRD item status so it doesn't stay 'dispatched' with no work item (#779)
|
|
@@ -2579,13 +2572,12 @@ const server = http.createServer(async (req, res) => {
|
|
|
2579
2572
|
|
|
2580
2573
|
// Clean cooldown entries
|
|
2581
2574
|
try {
|
|
2582
|
-
|
|
2583
|
-
|
|
2584
|
-
|
|
2585
|
-
|
|
2586
|
-
|
|
2587
|
-
}
|
|
2588
|
-
if (cleaned) safeWrite(cooldownPath, cooldowns);
|
|
2575
|
+
mutateCooldowns(cooldowns => {
|
|
2576
|
+
for (const key of Object.keys(cooldowns)) {
|
|
2577
|
+
if (key.includes(id)) delete cooldowns[key];
|
|
2578
|
+
}
|
|
2579
|
+
return cooldowns;
|
|
2580
|
+
});
|
|
2589
2581
|
} catch (e) { console.error('cooldown cleanup on cancel:', e.message); }
|
|
2590
2582
|
|
|
2591
2583
|
invalidateStatusCache();
|
|
@@ -2704,12 +2696,10 @@ const server = http.createServer(async (req, res) => {
|
|
|
2704
2696
|
}, { defaultValue: { pending: [], active: [], completed: [] } });
|
|
2705
2697
|
} catch (e) { console.error('dispatch cleanup on reopen:', e.message); }
|
|
2706
2698
|
try {
|
|
2707
|
-
|
|
2708
|
-
const cooldowns = safeJsonObj(cooldownPath);
|
|
2709
|
-
if (cooldowns[dispatchKey]) {
|
|
2699
|
+
mutateCooldowns(cooldowns => {
|
|
2710
2700
|
delete cooldowns[dispatchKey];
|
|
2711
|
-
|
|
2712
|
-
}
|
|
2701
|
+
return cooldowns;
|
|
2702
|
+
});
|
|
2713
2703
|
} catch (e) { console.error('cooldown cleanup on reopen:', e.message); }
|
|
2714
2704
|
|
|
2715
2705
|
invalidateStatusCache();
|
|
@@ -2733,22 +2723,19 @@ const server = http.createServer(async (req, res) => {
|
|
|
2733
2723
|
}
|
|
2734
2724
|
const id = 'W-' + shared.uid();
|
|
2735
2725
|
const item = {
|
|
2736
|
-
id, title: body.title.trim(), type: body.type
|
|
2726
|
+
id, title: body.title.trim(), type: routing.normalizeWorkType(body.type, WORK_TYPE.IMPLEMENT),
|
|
2737
2727
|
priority: body.priority || 'medium', description: body.description || '',
|
|
2738
2728
|
status: WI_STATUS.PENDING, created: new Date().toISOString(), createdBy: 'dashboard',
|
|
2739
2729
|
};
|
|
2740
2730
|
if (body.scope) item.scope = body.scope;
|
|
2741
|
-
// Agent assignment normalization:
|
|
2742
|
-
//
|
|
2743
|
-
//
|
|
2744
|
-
// by setting `item.agent`. The engine reads `item.agent || resolveAgent(…)`,
|
|
2745
|
-
// so a hard-pinned item bypasses routing entirely and queues until that
|
|
2746
|
-
// exact agent is free. Multi-agent arrays remain `item.agents` (hints
|
|
2747
|
-
// for resolveAgent or fan-out scope).
|
|
2731
|
+
// Agent assignment normalization: `agent` and `agents` are routing hints.
|
|
2732
|
+
// Use agentLock/hardAgent only for the rare case where an item must wait
|
|
2733
|
+
// for one exact agent instead of falling through to another idle agent.
|
|
2748
2734
|
const _agentsArr = Array.isArray(body.agents) ? body.agents.filter(Boolean) : (typeof body.agents === 'string' && body.agents ? [body.agents] : []);
|
|
2749
2735
|
if (body.agent) item.agent = String(body.agent);
|
|
2750
2736
|
else if (_agentsArr.length === 1 && body.scope !== 'fan-out') item.agent = String(_agentsArr[0]);
|
|
2751
2737
|
if (_agentsArr.length > 0) item.agents = _agentsArr;
|
|
2738
|
+
if (body.agentLock === true || body.hardAgent === true) item.agentLock = true;
|
|
2752
2739
|
if (body.references) item.references = body.references;
|
|
2753
2740
|
if (body.acceptanceCriteria) item.acceptanceCriteria = body.acceptanceCriteria;
|
|
2754
2741
|
if (body.skipPr) item.skipPr = true;
|
|
@@ -6449,10 +6436,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
6449
6436
|
|
|
6450
6437
|
// Engine
|
|
6451
6438
|
{ method: 'POST', path: '/api/engine/wakeup', desc: 'Trigger immediate engine tick via control.json signal', handler: async (req, res) => {
|
|
6452
|
-
|
|
6453
|
-
const control = shared.safeJson(controlPath) || {};
|
|
6454
|
-
control._wakeupAt = Date.now();
|
|
6455
|
-
shared.safeWrite(controlPath, control);
|
|
6439
|
+
shared.mutateControl(control => ({ ...control, _wakeupAt: Date.now() }));
|
|
6456
6440
|
return jsonReply(res, 200, { ok: true, message: 'Wakeup signal sent' });
|
|
6457
6441
|
}},
|
|
6458
6442
|
{ method: 'POST', path: '/api/engine/restart', desc: 'Force-kill engine and restart immediately', handler: handleEngineRestart },
|
package/engine/cleanup.js
CHANGED
|
@@ -9,7 +9,7 @@ const shared = require('./shared');
|
|
|
9
9
|
const queries = require('./queries');
|
|
10
10
|
|
|
11
11
|
const { exec, execSilent, log, ts, ENGINE_DEFAULTS } = shared;
|
|
12
|
-
const { safeJson, safeWrite, safeReadDir, mutateWorkItems, mutateJsonFileLocked, getProjects, projectWorkItemsPath, projectPrPath,
|
|
12
|
+
const { safeJson, safeWrite, safeReadDir, mutateCooldowns, mutateWorkItems, mutateJsonFileLocked, getProjects, projectWorkItemsPath, projectPrPath,
|
|
13
13
|
sanitizeBranch, KB_CATEGORIES } = shared;
|
|
14
14
|
const { getDispatch, getAgentStatus } = queries;
|
|
15
15
|
|
|
@@ -675,9 +675,9 @@ function runCleanup(config, verbose = false) {
|
|
|
675
675
|
entries.sort((a, b) => (b[1].timestamp || 0) - (a[1].timestamp || 0));
|
|
676
676
|
const keep = Object.fromEntries(entries.slice(0, COOLDOWN_CAP));
|
|
677
677
|
cleaned.cooldowns = entries.length - COOLDOWN_CAP;
|
|
678
|
-
|
|
678
|
+
mutateCooldowns(() => keep);
|
|
679
679
|
} else if (dirty) {
|
|
680
|
-
|
|
680
|
+
mutateCooldowns(() => cooldowns);
|
|
681
681
|
}
|
|
682
682
|
}
|
|
683
683
|
} catch (e) { log('warn', 'cap cooldowns: ' + e.message); }
|
package/engine/cli.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
const fs = require('fs');
|
|
7
7
|
const path = require('path');
|
|
8
8
|
const shared = require('./shared');
|
|
9
|
-
const { safeRead, safeJson, safeWrite, mutateWorkItems, ts, WI_STATUS, WORK_TYPE, PLAN_STATUS, PR_STATUS, DISPATCH_RESULT } = shared;
|
|
9
|
+
const { safeRead, safeJson, safeWrite, mutateControl, mutateWorkItems, ts, WI_STATUS, WORK_TYPE, PLAN_STATUS, PR_STATUS, DISPATCH_RESULT } = shared;
|
|
10
10
|
const queries = require('./queries');
|
|
11
11
|
const { getConfig, getControl, getDispatch, getAgentStatus,
|
|
12
12
|
MINIONS_DIR, ENGINE_DIR, AGENTS_DIR, PLANS_DIR, PRD_DIR, CONTROL_PATH, DISPATCH_PATH } = queries;
|
|
@@ -312,7 +312,7 @@ const commands = {
|
|
|
312
312
|
}
|
|
313
313
|
let codeCommit = null;
|
|
314
314
|
try { codeCommit = require('child_process').execSync('git rev-parse --short HEAD', { cwd: path.resolve(__dirname, '..'), encoding: 'utf8', timeout: 5000, windowsHide: true }).trim(); } catch {}
|
|
315
|
-
|
|
315
|
+
mutateControl(() => ({ state: 'running', pid: process.pid, started_at: e.ts(), codeVersion, codeCommit }));
|
|
316
316
|
// Keep .minions-version in sync so `minions version` stays accurate after git pulls
|
|
317
317
|
if (codeVersion) {
|
|
318
318
|
try { fs.writeFileSync(path.join(shared.MINIONS_DIR, '.minions-version'), codeVersion); } catch {}
|
|
@@ -599,7 +599,10 @@ const commands = {
|
|
|
599
599
|
const ctrl = getControl();
|
|
600
600
|
if (ctrl._wakeupAt && Date.now() - ctrl._wakeupAt < 5000) {
|
|
601
601
|
delete ctrl._wakeupAt;
|
|
602
|
-
|
|
602
|
+
mutateControl((control) => {
|
|
603
|
+
delete control._wakeupAt;
|
|
604
|
+
return control;
|
|
605
|
+
});
|
|
603
606
|
e.tick();
|
|
604
607
|
}
|
|
605
608
|
}, 1000);
|
|
@@ -669,11 +672,11 @@ const commands = {
|
|
|
669
672
|
clearInterval(fastPollTimer);
|
|
670
673
|
if (teamsInboxTimer) clearInterval(teamsInboxTimer);
|
|
671
674
|
for (const f of _watchedFiles) { try { fs.unwatchFile(f); } catch { /* cleanup */ } }
|
|
672
|
-
|
|
675
|
+
mutateControl(() => ({ state: 'stopping', pid: process.pid, stopping_at: e.ts() }));
|
|
673
676
|
e.log('info', `Graceful shutdown initiated (${signal})`);
|
|
674
677
|
|
|
675
678
|
if (e.activeProcesses.size === 0) {
|
|
676
|
-
|
|
679
|
+
mutateControl(() => ({ state: 'stopped', stopped_at: e.ts() }));
|
|
677
680
|
e.log('info', 'Graceful shutdown complete (no active agents)');
|
|
678
681
|
shared.flushLogs(); // drain buffered log entries before exit
|
|
679
682
|
console.log('No active agents — stopped.');
|
|
@@ -687,7 +690,7 @@ const commands = {
|
|
|
687
690
|
const poll = setInterval(() => {
|
|
688
691
|
if (e.activeProcesses.size === 0) {
|
|
689
692
|
clearInterval(poll);
|
|
690
|
-
|
|
693
|
+
mutateControl(() => ({ state: 'stopped', stopped_at: e.ts() }));
|
|
691
694
|
e.log('info', 'Graceful shutdown complete (all agents finished)');
|
|
692
695
|
shared.flushLogs(); // drain buffered log entries before exit
|
|
693
696
|
console.log('All agents finished — stopped.');
|
|
@@ -695,7 +698,7 @@ const commands = {
|
|
|
695
698
|
}
|
|
696
699
|
if (Date.now() >= deadline) {
|
|
697
700
|
clearInterval(poll);
|
|
698
|
-
|
|
701
|
+
mutateControl(() => ({ state: 'stopped', stopped_at: e.ts() }));
|
|
699
702
|
e.log('warn', `Graceful shutdown timed out after ${timeout / 1000}s with ${e.activeProcesses.size} agent(s) still active`);
|
|
700
703
|
shared.flushLogs(); // drain buffered log entries before exit
|
|
701
704
|
console.log(`Shutdown timeout (${timeout / 1000}s) — force exiting with ${e.activeProcesses.size} agent(s) still running.`);
|
|
@@ -742,14 +745,14 @@ const commands = {
|
|
|
742
745
|
if (control.pid && control.pid !== process.pid) {
|
|
743
746
|
try { process.kill(control.pid); } catch { /* process may be dead */ }
|
|
744
747
|
}
|
|
745
|
-
|
|
748
|
+
mutateControl(() => ({ state: 'stopped', stopped_at: e.ts() }));
|
|
746
749
|
e.log('info', 'Engine stopped');
|
|
747
750
|
console.log('Engine stopped.');
|
|
748
751
|
},
|
|
749
752
|
|
|
750
753
|
pause() {
|
|
751
754
|
const e = engine();
|
|
752
|
-
|
|
755
|
+
mutateControl(() => ({ state: 'paused', paused_at: e.ts() }));
|
|
753
756
|
e.log('info', 'Engine paused');
|
|
754
757
|
console.log('Engine paused. Run `node .minions/engine.js resume` to resume.');
|
|
755
758
|
},
|
|
@@ -761,7 +764,7 @@ const commands = {
|
|
|
761
764
|
console.log('Engine is already running.');
|
|
762
765
|
return;
|
|
763
766
|
}
|
|
764
|
-
|
|
767
|
+
mutateControl(() => ({ state: 'running', resumed_at: e.ts() }));
|
|
765
768
|
e.log('info', 'Engine resumed');
|
|
766
769
|
console.log('Engine resumed.');
|
|
767
770
|
},
|
|
@@ -932,7 +935,7 @@ const commands = {
|
|
|
932
935
|
dispatch() {
|
|
933
936
|
const control = getControl();
|
|
934
937
|
if (control.state === 'running' && isEngineProcessAlive(control)) {
|
|
935
|
-
|
|
938
|
+
mutateControl((c) => ({ ...c, _wakeupAt: Date.now() }));
|
|
936
939
|
console.log(`Dispatch wakeup requested from running engine (PID ${control.pid}).`);
|
|
937
940
|
return;
|
|
938
941
|
}
|
package/engine/cooldown.js
CHANGED
|
@@ -7,7 +7,7 @@ const path = require('path');
|
|
|
7
7
|
const shared = require('./shared');
|
|
8
8
|
const queries = require('./queries');
|
|
9
9
|
|
|
10
|
-
const { safeJson,
|
|
10
|
+
const { safeJson, mutateCooldowns, log, ENGINE_DEFAULTS } = shared;
|
|
11
11
|
const { ENGINE_DIR } = queries;
|
|
12
12
|
|
|
13
13
|
/**
|
|
@@ -37,6 +37,7 @@ function _truncateContextEntry(entry, maxBytes) {
|
|
|
37
37
|
|
|
38
38
|
const COOLDOWN_PATH = path.join(ENGINE_DIR, 'cooldowns.json');
|
|
39
39
|
const dispatchCooldowns = new Map(); // key → { timestamp, failures }
|
|
40
|
+
let _lastDiskCooldownKeys = new Set();
|
|
40
41
|
|
|
41
42
|
function loadCooldowns() {
|
|
42
43
|
const saved = safeJson(COOLDOWN_PATH);
|
|
@@ -48,6 +49,7 @@ function loadCooldowns() {
|
|
|
48
49
|
dispatchCooldowns.set(k, v);
|
|
49
50
|
}
|
|
50
51
|
}
|
|
52
|
+
_lastDiskCooldownKeys = new Set(dispatchCooldowns.keys());
|
|
51
53
|
log('info', `Loaded ${dispatchCooldowns.size} cooldowns from disk`);
|
|
52
54
|
}
|
|
53
55
|
|
|
@@ -57,27 +59,35 @@ function saveCooldowns() {
|
|
|
57
59
|
if (_cooldownWriteTimer) clearTimeout(_cooldownWriteTimer);
|
|
58
60
|
_cooldownWriteTimer = setTimeout(() => {
|
|
59
61
|
_cooldownWriteTimer = null;
|
|
60
|
-
// Prune expired entries (>24h) before saving
|
|
61
|
-
const now = Date.now();
|
|
62
|
-
for (const [k, v] of dispatchCooldowns) {
|
|
63
|
-
if (now - v.timestamp > 24 * 60 * 60 * 1000) dispatchCooldowns.delete(k);
|
|
64
|
-
}
|
|
65
|
-
// Trim pendingContexts arrays before writing to prevent bloat
|
|
66
|
-
const cap = ENGINE_DEFAULTS.maxPendingContexts;
|
|
67
|
-
const entryLimit = ENGINE_DEFAULTS.maxPendingContextEntryBytes;
|
|
68
|
-
for (const [, v] of dispatchCooldowns) {
|
|
69
|
-
if (Array.isArray(v.pendingContexts)) {
|
|
70
|
-
if (v.pendingContexts.length > cap) {
|
|
71
|
-
v.pendingContexts = v.pendingContexts.slice(-cap);
|
|
72
|
-
}
|
|
73
|
-
// Also truncate oversized individual entries — #1167 showed
|
|
74
|
-
// 20 entries × 25 MB each still produced a 500 MB cooldowns.json.
|
|
75
|
-
v.pendingContexts = v.pendingContexts.map(e => _truncateContextEntry(e, entryLimit));
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
const obj = Object.fromEntries(dispatchCooldowns);
|
|
79
62
|
try {
|
|
80
|
-
|
|
63
|
+
mutateCooldowns((diskCooldowns) => {
|
|
64
|
+
for (const key of Array.from(dispatchCooldowns.keys())) {
|
|
65
|
+
if (_lastDiskCooldownKeys.has(key) && !Object.prototype.hasOwnProperty.call(diskCooldowns, key)) {
|
|
66
|
+
dispatchCooldowns.delete(key);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
// Prune expired entries (>24h) before saving
|
|
70
|
+
const now = Date.now();
|
|
71
|
+
for (const [k, v] of dispatchCooldowns) {
|
|
72
|
+
if (now - v.timestamp > 24 * 60 * 60 * 1000) dispatchCooldowns.delete(k);
|
|
73
|
+
}
|
|
74
|
+
// Trim pendingContexts arrays before writing to prevent bloat
|
|
75
|
+
const cap = ENGINE_DEFAULTS.maxPendingContexts;
|
|
76
|
+
const entryLimit = ENGINE_DEFAULTS.maxPendingContextEntryBytes;
|
|
77
|
+
for (const [, v] of dispatchCooldowns) {
|
|
78
|
+
if (Array.isArray(v.pendingContexts)) {
|
|
79
|
+
if (v.pendingContexts.length > cap) {
|
|
80
|
+
v.pendingContexts = v.pendingContexts.slice(-cap);
|
|
81
|
+
}
|
|
82
|
+
// Also truncate oversized individual entries — #1167 showed
|
|
83
|
+
// 20 entries × 25 MB each still produced a 500 MB cooldowns.json.
|
|
84
|
+
v.pendingContexts = v.pendingContexts.map(e => _truncateContextEntry(e, entryLimit));
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
const obj = Object.fromEntries(dispatchCooldowns);
|
|
88
|
+
_lastDiskCooldownKeys = new Set(Object.keys(obj));
|
|
89
|
+
return obj;
|
|
90
|
+
});
|
|
81
91
|
} catch (err) {
|
|
82
92
|
log('warn', `saveCooldowns failed writing ${COOLDOWN_PATH}: ${err.message}`);
|
|
83
93
|
}
|
package/engine/dispatch.js
CHANGED
|
@@ -81,7 +81,7 @@ function addToDispatch(item) {
|
|
|
81
81
|
let added = false;
|
|
82
82
|
mutateDispatch((dispatch) => {
|
|
83
83
|
// Dedup: skip if same work item ID is already pending or active
|
|
84
|
-
const wiId = item.meta?.item?.id;
|
|
84
|
+
const wiId = item.meta?.source === 'central-work-item-fanout' ? null : item.meta?.item?.id;
|
|
85
85
|
if (wiId) {
|
|
86
86
|
const existing = [...dispatch.pending, ...(dispatch.active || [])].find(d => d.meta?.item?.id === wiId);
|
|
87
87
|
if (existing) {
|
package/engine/lifecycle.js
CHANGED
|
@@ -1820,70 +1820,39 @@ function normalizeCompletionStatus(status) {
|
|
|
1820
1820
|
return String(status || '').trim().toLowerCase().replace(/[\s_]+/g, '-');
|
|
1821
1821
|
}
|
|
1822
1822
|
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
1837
|
-
|
|
1838
|
-
|
|
1839
|
-
|
|
1840
|
-
|
|
1841
|
-
|
|
1823
|
+
// Trust the agent's explicit structured `status` field as the only signal that
|
|
1824
|
+
// a completion is non-terminal. Earlier versions also scanned the agent's
|
|
1825
|
+
// resultSummary prose with regex (looking for "pending", "in progress",
|
|
1826
|
+
// "partial", "wake up", etc.), but that produced false positives on benign
|
|
1827
|
+
// phrases like "I checked the pending PRs" or "build is in progress on CI"
|
|
1828
|
+
// and burned 3-9 minutes of agent time per false-positive retry.
|
|
1829
|
+
//
|
|
1830
|
+
// Both structured signals (the JSON completion report at MINIONS_COMPLETION_REPORT
|
|
1831
|
+
// and the fenced ```completion block in stdout) carry a `status` field. If the
|
|
1832
|
+
// agent explicitly says they're not done, honor it; otherwise accept the
|
|
1833
|
+
// dispatch. The PR attachment contract still catches silent-failure cases
|
|
1834
|
+
// for PR-producing work.
|
|
1835
|
+
const NON_TERMINAL_COMPLETION_STATUSES = new Set([
|
|
1836
|
+
'partial', 'partially-complete', 'in-progress', 'pending', 'deferred',
|
|
1837
|
+
'blocked', 'incomplete', 'to-be-continued',
|
|
1838
|
+
'failed', 'failure', 'error',
|
|
1839
|
+
]);
|
|
1840
|
+
|
|
1841
|
+
function detectNonTerminalResultSummary(_resultSummary, structuredCompletion, completionReport) {
|
|
1842
|
+
const candidates = [completionReport?.status, structuredCompletion?.status];
|
|
1843
|
+
for (const status of candidates) {
|
|
1844
|
+
const norm = normalizeCompletionStatus(status);
|
|
1845
|
+
if (!norm) continue;
|
|
1846
|
+
if (NON_TERMINAL_COMPLETION_STATUSES.has(norm)) {
|
|
1847
|
+
const isFailure = norm === 'failed' || norm === 'failure' || norm === 'error';
|
|
1842
1848
|
return {
|
|
1843
|
-
phrase: `status:${
|
|
1844
|
-
reason:
|
|
1849
|
+
phrase: `status:${status}`,
|
|
1850
|
+
reason: isFailure
|
|
1851
|
+
? `Nonterminal completion summary: structured status is '${status}', not a successful terminal state`
|
|
1852
|
+
: `Nonterminal completion summary: structured status is '${status}'`,
|
|
1845
1853
|
};
|
|
1846
1854
|
}
|
|
1847
|
-
if (/^(?:fail|failed|failure|error)/.test(completionStatus)) {
|
|
1848
|
-
return {
|
|
1849
|
-
phrase: `status:${structuredCompletion.status}`,
|
|
1850
|
-
reason: `Nonterminal completion summary: structured status is '${structuredCompletion.status}', not a successful terminal state`,
|
|
1851
|
-
};
|
|
1852
|
-
}
|
|
1853
|
-
}
|
|
1854
|
-
|
|
1855
|
-
if (structuredCompletion?.pending && !isTerminalPendingValue(structuredCompletion.pending)) {
|
|
1856
|
-
return {
|
|
1857
|
-
phrase: 'pending',
|
|
1858
|
-
reason: `Nonterminal completion summary: pending work remains (${String(structuredCompletion.pending).slice(0, 160)})`,
|
|
1859
|
-
};
|
|
1860
1855
|
}
|
|
1861
|
-
|
|
1862
|
-
const text = String(resultSummary || '').replace(/\r/g, '').trim();
|
|
1863
|
-
if (!text) return null;
|
|
1864
|
-
|
|
1865
|
-
const patterns = [
|
|
1866
|
-
{ phrase: 'still running', re: /\b(?:still|currently|continues?\s+to\s+be)\s+(?:running|ongoing|in\s+progress)\b/i },
|
|
1867
|
-
{ phrase: 'will check later', re: /\b(?:i(?:'|’)ll|i\s+will|we(?:'|’)ll|we\s+will|will)\s+(?:check|verify|review|follow\s+up|revisit)\s+(?:again\s+)?(?:later|soon|in\b|after\b|when\b)/i },
|
|
1868
|
-
{ phrase: 'wake up', re: /\bwake(?:\s|-)?up\b|\bwake\b.*\b(?:check|verify|review)\b/i },
|
|
1869
|
-
{ phrase: 'not yet complete', re: /\b(?:not\s+yet|isn(?:'|’)t|not|incomplete|not\s+fully|not\s+completely)\s+(?:complete|completed|done|finished|validated|verified)\b/i },
|
|
1870
|
-
{ phrase: 'partial', re: /\bpartial(?:ly)?\b/i },
|
|
1871
|
-
{ phrase: 'to be continued', re: /\bto\s+be\s+continued\b|\btbc\b/i },
|
|
1872
|
-
{ phrase: 'in progress', re: /\bin\s+progress\b|\bongoing\b|\bincomplete\b/i },
|
|
1873
|
-
];
|
|
1874
|
-
for (const { phrase, re } of patterns) {
|
|
1875
|
-
if (re.test(text)) {
|
|
1876
|
-
return { phrase, reason: `Nonterminal completion summary: matched '${phrase}'` };
|
|
1877
|
-
}
|
|
1878
|
-
}
|
|
1879
|
-
|
|
1880
|
-
const pendingLines = text.split('\n').filter(line => /\bpending\b/i.test(line));
|
|
1881
|
-
for (const line of pendingLines) {
|
|
1882
|
-
if (!isTerminalPendingLine(line)) {
|
|
1883
|
-
return { phrase: 'pending', reason: `Nonterminal completion summary: matched 'pending'` };
|
|
1884
|
-
}
|
|
1885
|
-
}
|
|
1886
|
-
|
|
1887
1856
|
return null;
|
|
1888
1857
|
}
|
|
1889
1858
|
|
|
@@ -2256,7 +2225,7 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
|
|
|
2256
2225
|
|
|
2257
2226
|
let completionContractFailure = null;
|
|
2258
2227
|
if (effectiveSuccess && meta?.item?.id && !skipDoneStatus) {
|
|
2259
|
-
const nonTerminalCompletion = detectNonTerminalResultSummary(completionGateSummary, structuredCompletion);
|
|
2228
|
+
const nonTerminalCompletion = detectNonTerminalResultSummary(completionGateSummary, structuredCompletion, reportCompletion);
|
|
2260
2229
|
if (nonTerminalCompletion) {
|
|
2261
2230
|
skipDoneStatus = true;
|
|
2262
2231
|
const reason = deferNonTerminalCompletion(meta, nonTerminalCompletion);
|
package/engine/pipeline.js
CHANGED
|
@@ -9,6 +9,7 @@ const path = require('path');
|
|
|
9
9
|
const shared = require('./shared');
|
|
10
10
|
const queries = require('./queries');
|
|
11
11
|
const { safeJson, safeWrite, safeRead, safeReadDir, uid, log, ts, dateStamp, mutateJsonFileLocked, mutateWorkItems, slugify, formatTranscriptEntry, WI_STATUS, WORK_TYPE, PLAN_STATUS, PR_STATUS, PIPELINE_STATUS, STAGE_TYPE, MEETING_STATUS, ENGINE_DEFAULTS, MINIONS_DIR } = shared;
|
|
12
|
+
const routing = require('./routing');
|
|
12
13
|
const http = require('http');
|
|
13
14
|
const { parseCronExpr, shouldRunNow } = require('./scheduler');
|
|
14
15
|
|
|
@@ -306,10 +307,11 @@ function executeTaskStage(stage, stageState, run, config) {
|
|
|
306
307
|
id,
|
|
307
308
|
title: item.title || stage.title,
|
|
308
309
|
description: item.description || stage.description || '',
|
|
309
|
-
type: item.type || stage.taskType
|
|
310
|
+
type: routing.normalizeWorkType(item.type || stage.taskType, WORK_TYPE.EXPLORE),
|
|
310
311
|
priority: item.priority || stage.priority || 'medium',
|
|
311
|
-
//
|
|
312
|
+
// Agent is a soft routing hint unless agentLock/hardAgent is set.
|
|
312
313
|
...(item.agent || stage.agent ? { agent: item.agent || stage.agent } : {}),
|
|
314
|
+
...(item.agentLock === true || stage.agentLock === true || item.hardAgent === true || stage.hardAgent === true ? { agentLock: true } : {}),
|
|
313
315
|
status: WI_STATUS.PENDING,
|
|
314
316
|
created: ts(),
|
|
315
317
|
createdBy: 'pipeline:' + run.pipelineId,
|
|
@@ -587,7 +589,7 @@ function executeMergePrsStage(stage, stageState, run, config) {
|
|
|
587
589
|
|
|
588
590
|
function executeScheduleStage(stage, stageState, config) {
|
|
589
591
|
// Create/update schedules in config
|
|
590
|
-
const schedules = stage.schedules || [{ id: stage.id + '-sched', cron: stage.cron, title: stage.title, type: stage.taskType
|
|
592
|
+
const schedules = stage.schedules || [{ id: stage.id + '-sched', cron: stage.cron, title: stage.title, type: routing.normalizeWorkType(stage.taskType, WORK_TYPE.IMPLEMENT) }];
|
|
591
593
|
// Write to config via shared
|
|
592
594
|
for (const sched of schedules) {
|
|
593
595
|
const existing = (config.schedules || []).find(s => s.id === sched.id);
|
package/engine/queries.js
CHANGED
|
@@ -101,7 +101,21 @@ function timeSince(ms) {
|
|
|
101
101
|
}
|
|
102
102
|
|
|
103
103
|
function readJsonNoRestore(filePath) {
|
|
104
|
-
|
|
104
|
+
let raw;
|
|
105
|
+
try {
|
|
106
|
+
raw = fs.readFileSync(filePath, 'utf8');
|
|
107
|
+
} catch (e) {
|
|
108
|
+
if (e && e.code !== 'ENOENT') {
|
|
109
|
+
console.warn(`[queries] failed to read ${_relativeStatePath(filePath)}: ${e.message}`);
|
|
110
|
+
}
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
try {
|
|
114
|
+
return JSON.parse(raw);
|
|
115
|
+
} catch (e) {
|
|
116
|
+
console.warn(`[queries] corrupt JSON in ${_relativeStatePath(filePath)}: ${e.message}`);
|
|
117
|
+
return null;
|
|
118
|
+
}
|
|
105
119
|
}
|
|
106
120
|
|
|
107
121
|
// ── Core State Readers ──────────────────────────────────────────────────────
|
package/engine/routing.js
CHANGED
|
@@ -8,7 +8,7 @@ const path = require('path');
|
|
|
8
8
|
const shared = require('./shared');
|
|
9
9
|
const queries = require('./queries');
|
|
10
10
|
|
|
11
|
-
const { safeJson, safeRead, log, ts } = shared;
|
|
11
|
+
const { safeJson, safeRead, log, ts, WORK_TYPE } = shared;
|
|
12
12
|
const { ENGINE_DIR, DISPATCH_PATH } = queries;
|
|
13
13
|
|
|
14
14
|
const MINIONS_DIR = shared.MINIONS_DIR;
|
|
@@ -116,12 +116,43 @@ function setTempBudget(n) {
|
|
|
116
116
|
}
|
|
117
117
|
function getTempBudget() { return _tempBudget; }
|
|
118
118
|
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
119
|
+
function normalizeWorkType(workType, fallback = WORK_TYPE.IMPLEMENT) {
|
|
120
|
+
const type = String(workType || fallback || '').trim();
|
|
121
|
+
if (!type) return fallback;
|
|
122
|
+
return type;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function routeForWorkType(workType) {
|
|
126
|
+
const routes = getRoutingTableCached();
|
|
127
|
+
return routes[normalizeWorkType(workType)] || routes[WORK_TYPE.IMPLEMENT] || { preferred: '_any_', fallback: '_any_' };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function isAgentHardPinned(item) {
|
|
131
|
+
return !!(item && (
|
|
132
|
+
item.agentLock === true ||
|
|
133
|
+
item.agent_locked === true ||
|
|
134
|
+
item.hardAgent === true ||
|
|
135
|
+
item.hard_agent === true
|
|
136
|
+
));
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function getHardPinnedAgent(item, agents = null) {
|
|
140
|
+
if (!isAgentHardPinned(item) || !item?.agent) return null;
|
|
141
|
+
const normalized = normalizeAgentHints(item.agent, null, agents);
|
|
142
|
+
return normalized[0] || null;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Centralizes the work-item shape used to derive routing hints. `agent`,
|
|
146
|
+
// `preferred_agent`, and `agents` are soft preferences unless an explicit
|
|
147
|
+
// hard-pin flag is present (agentLock / hardAgent variants).
|
|
122
148
|
function extractAgentHints(item) {
|
|
123
149
|
if (!item || typeof item !== 'object') return null;
|
|
124
|
-
|
|
150
|
+
const hints = [];
|
|
151
|
+
if (!isAgentHardPinned(item) && item.agent) hints.push(item.agent);
|
|
152
|
+
if (item.preferred_agent) hints.push(item.preferred_agent);
|
|
153
|
+
if (Array.isArray(item.agents)) hints.push(...item.agents);
|
|
154
|
+
else if (item.agents) hints.push(item.agents);
|
|
155
|
+
return hints.length > 0 ? hints : null;
|
|
125
156
|
}
|
|
126
157
|
|
|
127
158
|
// Normalize a list of agent-hint inputs. Accepts:
|
|
@@ -158,8 +189,7 @@ function normalizeAgentHints(agentHints, authorAgent = null, agents = null) {
|
|
|
158
189
|
|
|
159
190
|
function resolveAgent(workType, config, opts = {}) {
|
|
160
191
|
const { authorAgent = null, agentHints = null } = opts || {};
|
|
161
|
-
const
|
|
162
|
-
const route = routes[workType] || routes['implement'] || { preferred: '_any_', fallback: '_any_' };
|
|
192
|
+
const route = routeForWorkType(workType);
|
|
163
193
|
const agents = config.agents || {};
|
|
164
194
|
|
|
165
195
|
// Resolve _author_ token
|
|
@@ -191,7 +221,6 @@ function resolveAgent(workType, config, opts = {}) {
|
|
|
191
221
|
for (const id of hintedAgents) {
|
|
192
222
|
if (isAvailable(id)) { _claimedAgents.add(id); return id; }
|
|
193
223
|
}
|
|
194
|
-
return null;
|
|
195
224
|
}
|
|
196
225
|
|
|
197
226
|
// Resolve _any_ token — pick any available agent (#480)
|
|
@@ -230,8 +259,7 @@ function resolveAgent(workType, config, opts = {}) {
|
|
|
230
259
|
|
|
231
260
|
function resolveAgentReservation(workType, config, opts = {}) {
|
|
232
261
|
const { authorAgent = null, agentHints = null } = opts || {};
|
|
233
|
-
const
|
|
234
|
-
const route = routes[workType] || routes['implement'] || { preferred: '_any_', fallback: '_any_' };
|
|
262
|
+
const route = routeForWorkType(workType);
|
|
235
263
|
const agents = config.agents || {};
|
|
236
264
|
const hintedAgents = normalizeAgentHints(agentHints, authorAgent, agents);
|
|
237
265
|
|
|
@@ -274,6 +302,9 @@ module.exports = {
|
|
|
274
302
|
isAgentIdle,
|
|
275
303
|
normalizeAgentHints,
|
|
276
304
|
extractAgentHints,
|
|
305
|
+
isAgentHardPinned,
|
|
306
|
+
getHardPinnedAgent,
|
|
307
|
+
normalizeWorkType,
|
|
277
308
|
_claimedAgents,
|
|
278
309
|
resetClaimedAgents,
|
|
279
310
|
resolveAgent,
|
package/engine/scheduler.js
CHANGED
|
@@ -24,7 +24,8 @@
|
|
|
24
24
|
const fs = require('fs');
|
|
25
25
|
const path = require('path');
|
|
26
26
|
const shared = require('./shared');
|
|
27
|
-
const
|
|
27
|
+
const routing = require('./routing');
|
|
28
|
+
const { safeJson, safeWrite, mutateJsonFileLocked, ts, dateStamp, WI_STATUS, WORK_TYPE } = shared;
|
|
28
29
|
|
|
29
30
|
const SCHEDULE_RUNS_PATH = path.join(shared.MINIONS_DIR, 'engine', 'schedule-runs.json');
|
|
30
31
|
|
|
@@ -167,13 +168,14 @@ function discoverScheduledWork(config) {
|
|
|
167
168
|
work.push({
|
|
168
169
|
id: workItemId,
|
|
169
170
|
title: resolveScheduleTemplateVars(sched.title),
|
|
170
|
-
type: sched.type
|
|
171
|
+
type: routing.normalizeWorkType(sched.type, WORK_TYPE.IMPLEMENT),
|
|
171
172
|
priority: sched.priority || 'medium',
|
|
172
173
|
description: resolveScheduleTemplateVars(sched.description || sched.title),
|
|
173
174
|
status: WI_STATUS.PENDING,
|
|
174
175
|
created: ts(),
|
|
175
176
|
createdBy: 'scheduler',
|
|
176
177
|
agent: sched.agent || null,
|
|
178
|
+
...(sched.agentLock === true || sched.hardAgent === true ? { agentLock: true } : {}),
|
|
177
179
|
project: sched.project || null,
|
|
178
180
|
_scheduleId: sched.id,
|
|
179
181
|
});
|
package/engine/shared.js
CHANGED
|
@@ -8,6 +8,9 @@ const path = require('path');
|
|
|
8
8
|
const crypto = require('crypto');
|
|
9
9
|
|
|
10
10
|
const MINIONS_DIR = process.env.MINIONS_TEST_DIR || path.resolve(__dirname, '..');
|
|
11
|
+
const ENGINE_DIR = path.join(MINIONS_DIR, 'engine');
|
|
12
|
+
const CONTROL_PATH = path.join(ENGINE_DIR, 'control.json');
|
|
13
|
+
const COOLDOWNS_PATH = path.join(ENGINE_DIR, 'cooldowns.json');
|
|
11
14
|
const PR_LINKS_PATH = path.join(MINIONS_DIR, 'engine', 'pr-links.json');
|
|
12
15
|
const PINNED_ITEMS_PATH = path.join(MINIONS_DIR, 'engine', 'kb-pins.json');
|
|
13
16
|
const LOG_PATH = path.join(MINIONS_DIR, 'engine', 'log.json');
|
|
@@ -428,6 +431,20 @@ function mutateJsonFileLocked(filePath, mutateFn, {
|
|
|
428
431
|
}, { retries, retryBackoffMs });
|
|
429
432
|
}
|
|
430
433
|
|
|
434
|
+
function mutateControl(mutator) {
|
|
435
|
+
return mutateJsonFileLocked(CONTROL_PATH, (data) => {
|
|
436
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) data = {};
|
|
437
|
+
return mutator(data) || data;
|
|
438
|
+
}, { defaultValue: { state: 'stopped', pid: null }, skipWriteIfUnchanged: true });
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
function mutateCooldowns(mutator) {
|
|
442
|
+
return mutateJsonFileLocked(COOLDOWNS_PATH, (data) => {
|
|
443
|
+
if (!data || typeof data !== 'object' || Array.isArray(data)) data = {};
|
|
444
|
+
return mutator(data) || data;
|
|
445
|
+
}, { defaultValue: {}, skipWriteIfUnchanged: true });
|
|
446
|
+
}
|
|
447
|
+
|
|
431
448
|
/**
|
|
432
449
|
* Generate a unique ID suffix: timestamp + 4 random chars.
|
|
433
450
|
* Use for filenames that could collide (dispatch IDs, temp files, etc.)
|
|
@@ -2304,6 +2321,9 @@ function createThrottleTracker({ label, baseBackoffMs = 60000, maxBackoffMs = 32
|
|
|
2304
2321
|
|
|
2305
2322
|
module.exports = {
|
|
2306
2323
|
MINIONS_DIR,
|
|
2324
|
+
ENGINE_DIR,
|
|
2325
|
+
CONTROL_PATH,
|
|
2326
|
+
COOLDOWNS_PATH,
|
|
2307
2327
|
PR_LINKS_PATH,
|
|
2308
2328
|
PINNED_ITEMS_PATH,
|
|
2309
2329
|
LOG_PATH,
|
|
@@ -2325,6 +2345,8 @@ module.exports = {
|
|
|
2325
2345
|
assertStateFileSize,
|
|
2326
2346
|
withFileLock,
|
|
2327
2347
|
mutateJsonFileLocked,
|
|
2348
|
+
mutateControl,
|
|
2349
|
+
mutateCooldowns,
|
|
2328
2350
|
mutateWorkItems,
|
|
2329
2351
|
reopenWorkItem,
|
|
2330
2352
|
mutatePullRequests,
|
package/engine/timeout.js
CHANGED
|
@@ -147,7 +147,7 @@ function checkTimeouts(config) {
|
|
|
147
147
|
const engineRestartGraceUntil = engine().engineRestartGraceUntil;
|
|
148
148
|
const engineRestartGraceExempt = engine().engineRestartGraceExempt;
|
|
149
149
|
const { completeDispatch } = dispatch();
|
|
150
|
-
const { runPostCompletionHooks, parseAgentOutput, parseStructuredCompletion, detectNonTerminalResultSummary } = require('./lifecycle');
|
|
150
|
+
const { runPostCompletionHooks, parseAgentOutput, parseStructuredCompletion, parseCompletionReportFile, detectNonTerminalResultSummary } = require('./lifecycle');
|
|
151
151
|
|
|
152
152
|
const timeout = config.engine?.agentTimeout || ENGINE_DEFAULTS.agentTimeout;
|
|
153
153
|
const defaultStaleOrphanTimeout = config.engine?.heartbeatTimeout || ENGINE_DEFAULTS.heartbeatTimeout;
|
|
@@ -265,7 +265,7 @@ function checkTimeouts(config) {
|
|
|
265
265
|
outputResultSummary = parseAgentOutput(fullLogForHooks, runtimeName).resultSummary || '';
|
|
266
266
|
const gateSummary = outputResultSummary || (!fullLogForHooks.includes('"type":') ? fullLogForHooks : '');
|
|
267
267
|
completionDetection = isSuccess
|
|
268
|
-
? detectNonTerminalResultSummary(gateSummary, parseStructuredCompletion(fullLogForHooks, runtimeName))
|
|
268
|
+
? detectNonTerminalResultSummary(gateSummary, parseStructuredCompletion(fullLogForHooks, runtimeName), parseCompletionReportFile(item))
|
|
269
269
|
: null;
|
|
270
270
|
} catch (e) { log('warn', 'completion summary gate: ' + e.message); }
|
|
271
271
|
|
package/engine.js
CHANGED
|
@@ -95,6 +95,7 @@ const safeRead = shared.safeRead;
|
|
|
95
95
|
const safeWrite = shared.safeWrite;
|
|
96
96
|
const safeUnlink = shared.safeUnlink;
|
|
97
97
|
const mutateJsonFileLocked = shared.mutateJsonFileLocked;
|
|
98
|
+
const mutateControl = shared.mutateControl;
|
|
98
99
|
const mutateWorkItems = shared.mutateWorkItems;
|
|
99
100
|
const mutatePullRequests = shared.mutatePullRequests;
|
|
100
101
|
const withFileLock = shared.withFileLock;
|
|
@@ -2644,7 +2645,7 @@ function discoverFromWorkItems(config, project) {
|
|
|
2644
2645
|
skipped.gated++; continue;
|
|
2645
2646
|
}
|
|
2646
2647
|
|
|
2647
|
-
let workType = item.type
|
|
2648
|
+
let workType = routing.normalizeWorkType(item.type, WORK_TYPE.IMPLEMENT);
|
|
2648
2649
|
if (workType === WORK_TYPE.IMPLEMENT && (item.complexity === 'large' || item.estimated_complexity === 'large')) {
|
|
2649
2650
|
workType = WORK_TYPE.IMPLEMENT_LARGE;
|
|
2650
2651
|
}
|
|
@@ -2655,16 +2656,17 @@ function discoverFromWorkItems(config, project) {
|
|
|
2655
2656
|
needsWrite = true;
|
|
2656
2657
|
}
|
|
2657
2658
|
const agentHints = routing.extractAgentHints(item);
|
|
2658
|
-
const
|
|
2659
|
-
let agentId =
|
|
2659
|
+
const hardPinRequested = routing.isAgentHardPinned(item);
|
|
2660
|
+
let agentId = routing.getHardPinnedAgent(item, config.agents || {})
|
|
2661
|
+
|| (!hardPinRequested ? resolveAgent(workType, config, { agentHints }) : null);
|
|
2660
2662
|
const cfgAgents = config.agents || {};
|
|
2661
2663
|
const budgetBlocked = Object.keys(cfgAgents).some(id => {
|
|
2662
2664
|
const b = cfgAgents[id].monthlyBudgetUsd;
|
|
2663
2665
|
return b && b > 0 && getMonthlySpend(id) >= b && isAgentIdle(id);
|
|
2664
2666
|
});
|
|
2665
2667
|
if (!agentId) {
|
|
2666
|
-
if (!budgetBlocked && !
|
|
2667
|
-
agentId = resolveAgentReservation(workType, config);
|
|
2668
|
+
if (!budgetBlocked && !hardPinRequested) {
|
|
2669
|
+
agentId = resolveAgentReservation(workType, config, { agentHints });
|
|
2668
2670
|
}
|
|
2669
2671
|
if (agentId) {
|
|
2670
2672
|
delete item._pendingReason;
|
|
@@ -3104,7 +3106,7 @@ function discoverCentralWorkItems(config) {
|
|
|
3104
3106
|
}
|
|
3105
3107
|
if (isOnCooldown(key, 0)) continue;
|
|
3106
3108
|
|
|
3107
|
-
const workType = item.type
|
|
3109
|
+
const workType = routing.normalizeWorkType(item.type, WORK_TYPE.IMPLEMENT);
|
|
3108
3110
|
const isFanOut = item.scope === 'fan-out';
|
|
3109
3111
|
|
|
3110
3112
|
if (isFanOut) {
|
|
@@ -3185,10 +3187,10 @@ function discoverCentralWorkItems(config) {
|
|
|
3185
3187
|
} else {
|
|
3186
3188
|
// ─── Normal: single agent dispatch ──────────────────────────────
|
|
3187
3189
|
const agentHints = routing.extractAgentHints(item);
|
|
3188
|
-
const
|
|
3189
|
-
const agentId = item.
|
|
3190
|
-
|| resolveAgent(workType, config, { agentHints })
|
|
3191
|
-
|| (!
|
|
3190
|
+
const hardPinRequested = routing.isAgentHardPinned(item);
|
|
3191
|
+
const agentId = routing.getHardPinnedAgent(item, config.agents || {})
|
|
3192
|
+
|| (!hardPinRequested ? resolveAgent(workType, config, { agentHints }) : null)
|
|
3193
|
+
|| (!hardPinRequested ? resolveAgentReservation(workType, config, { agentHints }) : null);
|
|
3192
3194
|
if (!agentId) continue;
|
|
3193
3195
|
|
|
3194
3196
|
const agentName = config.agents[agentId]?.name || agentId;
|
|
@@ -3556,7 +3558,7 @@ async function tickInner() {
|
|
|
3556
3558
|
}
|
|
3557
3559
|
|
|
3558
3560
|
// Write heartbeat so dashboard can detect stale engine
|
|
3559
|
-
try {
|
|
3561
|
+
try { mutateControl(c => ({ ...c, heartbeat: Date.now() })); } catch (e) { log('warn', 'write heartbeat: ' + e.message); }
|
|
3560
3562
|
|
|
3561
3563
|
const config = getConfig();
|
|
3562
3564
|
tickCount++;
|
|
@@ -3862,7 +3864,7 @@ async function tickInner() {
|
|
|
3862
3864
|
// be of type string. Received undefined` and re-queues — every tick. Try to
|
|
3863
3865
|
// resolve a fallback via routing; if none is available, skip this tick.
|
|
3864
3866
|
if (!item.agent || typeof item.agent !== 'string') {
|
|
3865
|
-
const fallback = resolveAgent(item.type
|
|
3867
|
+
const fallback = resolveAgent(routing.normalizeWorkType(item.type, WORK_TYPE.IMPLEMENT), config, { agentHints: routing.extractAgentHints(item.meta?.item) });
|
|
3866
3868
|
if (!fallback) {
|
|
3867
3869
|
log('warn', `Pending dispatch ${item.id} has no agent and routing returned no fallback — skipping`);
|
|
3868
3870
|
continue;
|
|
@@ -3893,7 +3895,7 @@ async function tickInner() {
|
|
|
3893
3895
|
// them eagerly before the busy check so an idle named agent can pick up.
|
|
3894
3896
|
const isUnspawnedTemp = item.agent?.startsWith('temp-') && !busyAgents.has(item.agent);
|
|
3895
3897
|
if (isUnspawnedTemp) {
|
|
3896
|
-
const altAgent = resolveAgent(item.type, config);
|
|
3898
|
+
const altAgent = resolveAgent(routing.normalizeWorkType(item.type, WORK_TYPE.IMPLEMENT), config);
|
|
3897
3899
|
if (altAgent && altAgent !== item.agent) {
|
|
3898
3900
|
const prevAgent = item.agent;
|
|
3899
3901
|
item.agent = altAgent;
|
|
@@ -3920,12 +3922,12 @@ async function tickInner() {
|
|
|
3920
3922
|
// Agent busy reassignment: if item has been waiting on a busy agent past the threshold,
|
|
3921
3923
|
// try to find an alternative agent via routing. Skip explicitly assigned items.
|
|
3922
3924
|
const reassignMs = config.engine?.agentBusyReassignMs ?? ENGINE_DEFAULTS.agentBusyReassignMs;
|
|
3923
|
-
const
|
|
3924
|
-
if (!
|
|
3925
|
+
const isHardPinned = routing.isAgentHardPinned(item.meta?.item);
|
|
3926
|
+
if (!isHardPinned && reassignMs > 0 && item._agentBusySince) {
|
|
3925
3927
|
const busySinceMs = new Date(item._agentBusySince).getTime();
|
|
3926
3928
|
if (Date.now() - busySinceMs > reassignMs) {
|
|
3927
3929
|
const originalAgent = item.agent;
|
|
3928
|
-
const altAgent = resolveAgent(item.type, config);
|
|
3930
|
+
const altAgent = resolveAgent(routing.normalizeWorkType(item.type, WORK_TYPE.IMPLEMENT), config, { agentHints: routing.extractAgentHints(item.meta?.item) });
|
|
3929
3931
|
if (altAgent && altAgent !== originalAgent && !busyAgents.has(altAgent)) {
|
|
3930
3932
|
log('info', `Reassigning ${item.id} from ${originalAgent} to ${altAgent} — agent busy > ${reassignMs}ms`);
|
|
3931
3933
|
item.agent = altAgent;
|
|
@@ -3959,14 +3961,12 @@ async function tickInner() {
|
|
|
3959
3961
|
// Branch mutex: skip items targeting a branch already locked by an active or newly-dispatched task
|
|
3960
3962
|
const itemBranch = item.meta?.branch ? sanitizeBranch(item.meta.branch) : null;
|
|
3961
3963
|
if (itemBranch && lockedBranches.has(itemBranch)) continue;
|
|
3962
|
-
|
|
3963
|
-
const isExplicitAssignment = !!item.meta?.item?.agent;
|
|
3964
|
-
if (!isExplicitAssignment && generalSlots <= 0) continue;
|
|
3964
|
+
if (generalSlots <= 0) continue;
|
|
3965
3965
|
seenPendingIds.add(item.id);
|
|
3966
3966
|
toDispatch.push(item);
|
|
3967
3967
|
busyAgents.add(item.agent);
|
|
3968
3968
|
if (itemBranch) lockedBranches.add(itemBranch);
|
|
3969
|
-
|
|
3969
|
+
generalSlots--;
|
|
3970
3970
|
}
|
|
3971
3971
|
|
|
3972
3972
|
// Dispatch items — spawnAgent moves each from pending→active on disk.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1659",
|
|
4
4
|
"description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
|
|
5
5
|
"bin": {
|
|
6
6
|
"minions": "bin/minions.js"
|