@yemi33/minions 0.1.1033 → 0.1.1035
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 +79 -0
- package/dashboard/js/modal-qa.js +1 -0
- package/dashboard.js +70 -8
- package/engine/cli.js +12 -0
- package/engine/cooldown.js +39 -3
- package/engine/dispatch.js +28 -1
- package/engine/shared.js +121 -0
- package/engine.js +5 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,84 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.1035 (2026-04-16)
|
|
4
|
+
|
|
5
|
+
### Features
|
|
6
|
+
- Doc-chat performance — debounced persistence, session TTL, smart disk reads (#1164)
|
|
7
|
+
- split getStatus() into fast/slow state tiers with separate TTLs (#1170)
|
|
8
|
+
- Cache _countWorktrees() with 30s TTL (#1166)
|
|
9
|
+
- Pre-cache gzipped status buffer alongside JSON cache (#1171)
|
|
10
|
+
- parallelize ADO and GitHub PR polling with Promise.allSettled (#1172)
|
|
11
|
+
- route implement items to dedicated implement playbook (#1115)
|
|
12
|
+
|
|
13
|
+
### Fixes
|
|
14
|
+
- sidecar oversized dispatch prompts to prevent dashboard OOM (closes #1167) (#1183)
|
|
15
|
+
- use locked write for failed-with-PR reconciliation in cleanup.js (#1169)
|
|
16
|
+
- prevent test from corrupting live meeting state
|
|
17
|
+
- scheduler enabled field falsy check treats undefined as disabled (#1160)
|
|
18
|
+
- update branch-dedup tests to use canonical PR IDs after merge with master (#1146)
|
|
19
|
+
- auto-review not firing for manually-linked PRs with autoObserve=true
|
|
20
|
+
- mark PR abandoned on 404 instead of silently retrying each tick
|
|
21
|
+
- replace undefined PROJECTS with config.projects in checkWatches (#1108)
|
|
22
|
+
- write permission for publish workflow
|
|
23
|
+
- run tests inline and post check runs for publish PRs
|
|
24
|
+
- add maxBuffer to all GitHub CLI spawns + paginate PR list (#1130)
|
|
25
|
+
- add required CI checks for PRs + update publish for auto-merge
|
|
26
|
+
- revert to PR merge now that stale status check is removed
|
|
27
|
+
- guard live review check against undefined vote/state values (#1132)
|
|
28
|
+
- push version bump directly to master instead of via PR
|
|
29
|
+
- add push-triggered CI for chore/publish branches
|
|
30
|
+
- publish workflow chore PRs failing to merge
|
|
31
|
+
- harden KB ordering
|
|
32
|
+
- harden audited state transitions
|
|
33
|
+
|
|
34
|
+
### Other
|
|
35
|
+
- [E2E] Dashboard & engine perf: gzip cache, tiered status, lock backoff, polling parallelization (#1174)
|
|
36
|
+
- Fix doc chat session isolation
|
|
37
|
+
- test(pipeline): add unit tests for CRUD, stage execution, run lifecycle (#1162)
|
|
38
|
+
- chore: test publish after removing stale status check
|
|
39
|
+
- chore: trigger publish test
|
|
40
|
+
- chore: test publish workflow fix
|
|
41
|
+
- chore: trigger publish workflow test
|
|
42
|
+
|
|
43
|
+
## 0.1.1034 (2026-04-16)
|
|
44
|
+
|
|
45
|
+
### Features
|
|
46
|
+
- Doc-chat performance — debounced persistence, session TTL, smart disk reads (#1164)
|
|
47
|
+
- split getStatus() into fast/slow state tiers with separate TTLs (#1170)
|
|
48
|
+
- Cache _countWorktrees() with 30s TTL (#1166)
|
|
49
|
+
- Pre-cache gzipped status buffer alongside JSON cache (#1171)
|
|
50
|
+
- parallelize ADO and GitHub PR polling with Promise.allSettled (#1172)
|
|
51
|
+
- route implement items to dedicated implement playbook (#1115)
|
|
52
|
+
|
|
53
|
+
### Fixes
|
|
54
|
+
- use locked write for failed-with-PR reconciliation in cleanup.js (#1169)
|
|
55
|
+
- prevent test from corrupting live meeting state
|
|
56
|
+
- scheduler enabled field falsy check treats undefined as disabled (#1160)
|
|
57
|
+
- update branch-dedup tests to use canonical PR IDs after merge with master (#1146)
|
|
58
|
+
- auto-review not firing for manually-linked PRs with autoObserve=true
|
|
59
|
+
- mark PR abandoned on 404 instead of silently retrying each tick
|
|
60
|
+
- replace undefined PROJECTS with config.projects in checkWatches (#1108)
|
|
61
|
+
- write permission for publish workflow
|
|
62
|
+
- run tests inline and post check runs for publish PRs
|
|
63
|
+
- add maxBuffer to all GitHub CLI spawns + paginate PR list (#1130)
|
|
64
|
+
- add required CI checks for PRs + update publish for auto-merge
|
|
65
|
+
- revert to PR merge now that stale status check is removed
|
|
66
|
+
- guard live review check against undefined vote/state values (#1132)
|
|
67
|
+
- push version bump directly to master instead of via PR
|
|
68
|
+
- add push-triggered CI for chore/publish branches
|
|
69
|
+
- publish workflow chore PRs failing to merge
|
|
70
|
+
- harden KB ordering
|
|
71
|
+
- harden audited state transitions
|
|
72
|
+
|
|
73
|
+
### Other
|
|
74
|
+
- [E2E] Dashboard & engine perf: gzip cache, tiered status, lock backoff, polling parallelization (#1174)
|
|
75
|
+
- Fix doc chat session isolation
|
|
76
|
+
- test(pipeline): add unit tests for CRUD, stage execution, run lifecycle (#1162)
|
|
77
|
+
- chore: test publish after removing stale status check
|
|
78
|
+
- chore: trigger publish test
|
|
79
|
+
- chore: test publish workflow fix
|
|
80
|
+
- chore: trigger publish workflow test
|
|
81
|
+
|
|
3
82
|
## 0.1.1033 (2026-04-16)
|
|
4
83
|
|
|
5
84
|
### Features
|
package/dashboard/js/modal-qa.js
CHANGED
|
@@ -428,6 +428,7 @@ async function _processQaMessage(message, selection, opts) {
|
|
|
428
428
|
selection: selection,
|
|
429
429
|
filePath: capturedFilePath || null,
|
|
430
430
|
model: window._lastStatus?.autoMode?.ccModel || undefined,
|
|
431
|
+
contentHash: capturedDocContext.content ? capturedDocContext.content.length + ':' + capturedDocContext.content.charCodeAt(0) + ':' + capturedDocContext.content.charCodeAt(capturedDocContext.content.length - 1) : undefined,
|
|
431
432
|
}),
|
|
432
433
|
});
|
|
433
434
|
const data = await res.json();
|
package/dashboard.js
CHANGED
|
@@ -32,6 +32,24 @@ const { getAgents, getAgentDetail, getPrdInfo, getWorkItems, getDispatchQueue,
|
|
|
32
32
|
getEngineLog, getMetrics, getKnowledgeBaseEntries, timeSince,
|
|
33
33
|
MINIONS_DIR, AGENTS_DIR, ENGINE_DIR, INBOX_DIR, DISPATCH_PATH, PRD_DIR } = queries;
|
|
34
34
|
|
|
35
|
+
// Startup size guard (#1167): fail fast with a clear error when dispatch.json /
|
|
36
|
+
// cooldowns.json have ballooned past ENGINE_DEFAULTS.maxStateFileBytes. Without
|
|
37
|
+
// this, V8 silently OOMs on JSON.parse(~1 GB) and the operator has no hint as to
|
|
38
|
+
// which file is bloated. The thrown error names the file and directs to
|
|
39
|
+
// engine/contexts/ where sidecars live.
|
|
40
|
+
(() => {
|
|
41
|
+
const stateFiles = [
|
|
42
|
+
DISPATCH_PATH,
|
|
43
|
+
path.join(ENGINE_DIR, 'cooldowns.json'),
|
|
44
|
+
];
|
|
45
|
+
for (const fp of stateFiles) {
|
|
46
|
+
try { shared.assertStateFileSize(fp); } catch (e) {
|
|
47
|
+
console.error('\n[dashboard] STARTUP ABORTED — ' + e.message + '\n');
|
|
48
|
+
process.exit(78); // 78 = configuration error; consistent with spawn-agent.js
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
})();
|
|
52
|
+
|
|
35
53
|
const PORT = parseInt(process.env.PORT || process.argv[2]) || 7331;
|
|
36
54
|
let CONFIG = queries.getConfig();
|
|
37
55
|
let PROJECTS = _getProjects(CONFIG);
|
|
@@ -852,16 +870,18 @@ async function executeCCActions(actions) {
|
|
|
852
870
|
// Session store for doc modals — keyed by filePath or title, persisted to disk
|
|
853
871
|
const CC_SESSIONS_PATH = path.join(ENGINE_DIR, 'cc-sessions.json');
|
|
854
872
|
const DOC_SESSIONS_PATH = path.join(ENGINE_DIR, 'doc-sessions.json');
|
|
873
|
+
const DOC_SESSION_TTL_MS = 7 * 24 * 60 * 60 * 1000; // 604800000 — 7 days
|
|
855
874
|
const docSessions = new Map(); // key → { sessionId, lastActiveAt, turnCount }
|
|
856
875
|
|
|
857
876
|
// Load persisted doc sessions on startup
|
|
858
877
|
try {
|
|
859
878
|
const saved = safeJson(DOC_SESSIONS_PATH);
|
|
860
879
|
if (saved && typeof saved === 'object') {
|
|
880
|
+
const now = Date.now();
|
|
861
881
|
for (const [key, s] of Object.entries(saved)) {
|
|
862
|
-
if (s.turnCount
|
|
863
|
-
|
|
864
|
-
|
|
882
|
+
if (s.turnCount >= CC_SESSION_MAX_TURNS) continue;
|
|
883
|
+
if (s.lastActiveAt && now - new Date(s.lastActiveAt).getTime() > DOC_SESSION_TTL_MS) continue;
|
|
884
|
+
docSessions.set(key, s);
|
|
865
885
|
}
|
|
866
886
|
}
|
|
867
887
|
} catch { /* optional */ }
|
|
@@ -872,6 +892,25 @@ function persistDocSessions() {
|
|
|
872
892
|
safeWrite(DOC_SESSIONS_PATH, obj);
|
|
873
893
|
}
|
|
874
894
|
|
|
895
|
+
// Debounced variant — coalesces rapid writes (e.g. back-to-back doc-chat turns)
|
|
896
|
+
let _persistDocSessionsTimer = null;
|
|
897
|
+
function schedulePersistDocSessions() {
|
|
898
|
+
if (_persistDocSessionsTimer) clearTimeout(_persistDocSessionsTimer);
|
|
899
|
+
_persistDocSessionsTimer = setTimeout(() => {
|
|
900
|
+
_persistDocSessionsTimer = null;
|
|
901
|
+
persistDocSessions();
|
|
902
|
+
}, 5000); // 5s debounce — rapid turns produce one write per burst
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
/** Flush any pending debounced write immediately (call on shutdown). */
|
|
906
|
+
function flushPendingDocSessions() {
|
|
907
|
+
if (_persistDocSessionsTimer) {
|
|
908
|
+
clearTimeout(_persistDocSessionsTimer);
|
|
909
|
+
_persistDocSessionsTimer = null;
|
|
910
|
+
persistDocSessions();
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
875
914
|
// Resolve session from any store (CC global or doc-specific)
|
|
876
915
|
function resolveSession(store, key) {
|
|
877
916
|
if (store === 'cc') {
|
|
@@ -885,6 +924,11 @@ function resolveSession(store, key) {
|
|
|
885
924
|
persistDocSessions();
|
|
886
925
|
return null;
|
|
887
926
|
}
|
|
927
|
+
if (s.lastActiveAt && Date.now() - new Date(s.lastActiveAt).getTime() > DOC_SESSION_TTL_MS) {
|
|
928
|
+
docSessions.delete(key);
|
|
929
|
+
persistDocSessions();
|
|
930
|
+
return null;
|
|
931
|
+
}
|
|
888
932
|
return s;
|
|
889
933
|
}
|
|
890
934
|
|
|
@@ -909,7 +953,7 @@ function updateSession(store, key, sessionId, existing) {
|
|
|
909
953
|
turnCount: (existing && prev ? prev.turnCount : 0) + 1,
|
|
910
954
|
_docHash: prev?._docHash || null,
|
|
911
955
|
});
|
|
912
|
-
|
|
956
|
+
schedulePersistDocSessions();
|
|
913
957
|
}
|
|
914
958
|
}
|
|
915
959
|
|
|
@@ -972,7 +1016,7 @@ async function ccCall(message, { store = 'cc', sessionKey, extraContext, label =
|
|
|
972
1016
|
safeWrite(path.join(ENGINE_DIR, 'cc-session.json'), ccSession);
|
|
973
1017
|
} else if (sessionKey) {
|
|
974
1018
|
docSessions.delete(sessionKey);
|
|
975
|
-
|
|
1019
|
+
schedulePersistDocSessions();
|
|
976
1020
|
}
|
|
977
1021
|
}
|
|
978
1022
|
|
|
@@ -1007,6 +1051,12 @@ async function ccCall(message, { store = 'cc', sessionKey, extraContext, label =
|
|
|
1007
1051
|
return result;
|
|
1008
1052
|
}
|
|
1009
1053
|
|
|
1054
|
+
// Lightweight content fingerprint — same algorithm used browser-side (no crypto needed)
|
|
1055
|
+
function contentFingerprint(str) {
|
|
1056
|
+
if (!str) return '';
|
|
1057
|
+
return str.length + ':' + str.charCodeAt(0) + ':' + str.charCodeAt(str.length - 1);
|
|
1058
|
+
}
|
|
1059
|
+
|
|
1010
1060
|
// Doc-specific wrapper — adds document context, parses ---DOCUMENT---
|
|
1011
1061
|
async function ccDocCall({ message, document, title, filePath, selection, canEdit, isJson, model, freshSession, onAbortReady }) {
|
|
1012
1062
|
const sessionKey = filePath || title;
|
|
@@ -1047,7 +1097,7 @@ async function ccDocCall({ message, document, title, filePath, selection, canEdi
|
|
|
1047
1097
|
// One-shot call — discard the session ccCall just stored so it cannot
|
|
1048
1098
|
// bleed into future interactions under the same key.
|
|
1049
1099
|
docSessions.delete(sessionKey);
|
|
1050
|
-
|
|
1100
|
+
schedulePersistDocSessions();
|
|
1051
1101
|
} else if (result.code === 0 && result.sessionId) {
|
|
1052
1102
|
// Store doc hash for next call's unchanged check
|
|
1053
1103
|
const session = resolveSession('doc', sessionKey);
|
|
@@ -3221,7 +3271,14 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
3221
3271
|
try { shared.sanitizePath(body.filePath, MINIONS_DIR); } catch { return jsonReply(res, 400, { error: 'path must be under minions directory' }); }
|
|
3222
3272
|
fullPath = path.resolve(MINIONS_DIR, body.filePath);
|
|
3223
3273
|
const diskContent = safeRead(fullPath);
|
|
3224
|
-
if (diskContent !== null)
|
|
3274
|
+
if (diskContent !== null) {
|
|
3275
|
+
// If client sent a contentHash and it matches disk, skip replacement — client copy is fresh
|
|
3276
|
+
if (body.contentHash && contentFingerprint(diskContent) === body.contentHash) {
|
|
3277
|
+
// body.document is already current — no override needed
|
|
3278
|
+
} else {
|
|
3279
|
+
currentContent = diskContent;
|
|
3280
|
+
}
|
|
3281
|
+
}
|
|
3225
3282
|
}
|
|
3226
3283
|
|
|
3227
3284
|
const { answer, content, actions } = await ccDocCall({
|
|
@@ -4741,7 +4798,7 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
4741
4798
|
{ method: 'GET', path: /^\/api\/knowledge\/([^/]+)\/([^?]+)/, desc: 'Read a specific knowledge base entry', handler: handleKnowledgeRead },
|
|
4742
4799
|
|
|
4743
4800
|
// Doc chat
|
|
4744
|
-
{ method: 'POST', path: '/api/doc-chat', desc: 'Minions-aware doc Q&A + editing via CC session', params: 'message, document, title?, filePath?, selection?', handler: handleDocChat },
|
|
4801
|
+
{ method: 'POST', path: '/api/doc-chat', desc: 'Minions-aware doc Q&A + editing via CC session', params: 'message, document, title?, filePath?, selection?, contentHash?', handler: handleDocChat },
|
|
4745
4802
|
|
|
4746
4803
|
// Inbox
|
|
4747
4804
|
{ method: 'POST', path: '/api/inbox/persist', desc: 'Promote an inbox item to team notes', params: 'name', handler: handleInboxPersist },
|
|
@@ -5102,3 +5159,8 @@ server.on('error', e => {
|
|
|
5102
5159
|
}
|
|
5103
5160
|
process.exit(1);
|
|
5104
5161
|
});
|
|
5162
|
+
|
|
5163
|
+
// ── Graceful shutdown: flush debounced writes ──────────────────────────────
|
|
5164
|
+
server.on('close', () => flushPendingDocSessions());
|
|
5165
|
+
process.on('SIGTERM', () => { flushPendingDocSessions(); process.exit(0); });
|
|
5166
|
+
process.on('SIGINT', () => { flushPendingDocSessions(); process.exit(0); });
|
package/engine/cli.js
CHANGED
|
@@ -51,6 +51,18 @@ function handleCommand(cmd, args) {
|
|
|
51
51
|
|
|
52
52
|
const commands = {
|
|
53
53
|
start() {
|
|
54
|
+
// Startup state-file size guard (#1167): dispatch.json / cooldowns.json
|
|
55
|
+
// bloated past 100 MB silently OOMed V8 on JSON.parse at startup. Fail fast
|
|
56
|
+
// with an actionable message instead.
|
|
57
|
+
try {
|
|
58
|
+
for (const fp of [DISPATCH_PATH, path.join(ENGINE_DIR, 'cooldowns.json')]) {
|
|
59
|
+
shared.assertStateFileSize(fp);
|
|
60
|
+
}
|
|
61
|
+
} catch (stateErr) {
|
|
62
|
+
console.error('\n[engine] STARTUP ABORTED — ' + stateErr.message + '\n');
|
|
63
|
+
process.exit(78); // 78 = configuration error
|
|
64
|
+
}
|
|
65
|
+
|
|
54
66
|
// Run preflight checks (warn but don't block — engine may still be useful)
|
|
55
67
|
try {
|
|
56
68
|
const { runPreflight, printPreflight } = require('./preflight');
|
package/engine/cooldown.js
CHANGED
|
@@ -10,6 +10,31 @@ const queries = require('./queries');
|
|
|
10
10
|
const { safeJson, safeWrite, log, ENGINE_DEFAULTS } = shared;
|
|
11
11
|
const { ENGINE_DIR } = queries;
|
|
12
12
|
|
|
13
|
+
/**
|
|
14
|
+
* Truncate any string fields on a pendingContexts entry so a single huge PR
|
|
15
|
+
* comment / build log cannot bloat cooldowns.json to hundreds of MB (#1167).
|
|
16
|
+
* Returns a new entry object (does not mutate the caller's copy).
|
|
17
|
+
*/
|
|
18
|
+
function _truncateContextEntry(entry, maxBytes) {
|
|
19
|
+
if (entry == null) return entry;
|
|
20
|
+
const limit = Number(maxBytes) > 0 ? Number(maxBytes) : ENGINE_DEFAULTS.maxPendingContextEntryBytes;
|
|
21
|
+
if (typeof entry === 'string') {
|
|
22
|
+
return Buffer.byteLength(entry, 'utf8') > limit
|
|
23
|
+
? entry.slice(0, limit) + `\n\n... [truncated: context exceeded ${Math.round(limit / 1024)} KB]`
|
|
24
|
+
: entry;
|
|
25
|
+
}
|
|
26
|
+
if (typeof entry !== 'object') return entry;
|
|
27
|
+
const out = Array.isArray(entry) ? [] : {};
|
|
28
|
+
for (const [k, v] of Object.entries(entry)) {
|
|
29
|
+
if (typeof v === 'string' && Buffer.byteLength(v, 'utf8') > limit) {
|
|
30
|
+
out[k] = v.slice(0, limit) + `\n\n... [truncated: ${k} exceeded ${Math.round(limit / 1024)} KB]`;
|
|
31
|
+
} else {
|
|
32
|
+
out[k] = v;
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
return out;
|
|
36
|
+
}
|
|
37
|
+
|
|
13
38
|
const COOLDOWN_PATH = path.join(ENGINE_DIR, 'cooldowns.json');
|
|
14
39
|
const dispatchCooldowns = new Map(); // key → { timestamp, failures }
|
|
15
40
|
|
|
@@ -39,9 +64,15 @@ function saveCooldowns() {
|
|
|
39
64
|
}
|
|
40
65
|
// Trim pendingContexts arrays before writing to prevent bloat
|
|
41
66
|
const cap = ENGINE_DEFAULTS.maxPendingContexts;
|
|
67
|
+
const entryLimit = ENGINE_DEFAULTS.maxPendingContextEntryBytes;
|
|
42
68
|
for (const [, v] of dispatchCooldowns) {
|
|
43
|
-
if (Array.isArray(v.pendingContexts)
|
|
44
|
-
v.pendingContexts
|
|
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));
|
|
45
76
|
}
|
|
46
77
|
}
|
|
47
78
|
const obj = Object.fromEntries(dispatchCooldowns);
|
|
@@ -69,7 +100,12 @@ function setCooldown(key) {
|
|
|
69
100
|
function setCooldownWithContext(key, context) {
|
|
70
101
|
const existing = dispatchCooldowns.get(key);
|
|
71
102
|
let pendingContexts = existing?.pendingContexts || [];
|
|
72
|
-
if (context)
|
|
103
|
+
if (context) {
|
|
104
|
+
// Truncate oversized string fields per-entry BEFORE storing so a single
|
|
105
|
+
// huge payload (PR diff, build log, full repo dump) cannot bloat
|
|
106
|
+
// cooldowns.json regardless of array cap (#1167).
|
|
107
|
+
pendingContexts.push(_truncateContextEntry(context, ENGINE_DEFAULTS.maxPendingContextEntryBytes));
|
|
108
|
+
}
|
|
73
109
|
// Cap to last N entries to prevent unbounded growth (cooldowns.json bloat)
|
|
74
110
|
const cap = ENGINE_DEFAULTS.maxPendingContexts;
|
|
75
111
|
if (pendingContexts.length > cap) pendingContexts = pendingContexts.slice(-cap);
|
package/engine/dispatch.js
CHANGED
|
@@ -11,6 +11,7 @@ const { setCooldownFailure } = require('./cooldown');
|
|
|
11
11
|
|
|
12
12
|
const { safeJson, safeWrite, safeReadDir, mutateJsonFileLocked, mutateWorkItems,
|
|
13
13
|
mutatePullRequests, getProjects, projectWorkItemsPath, projectPrPath, log, ts, dateStamp,
|
|
14
|
+
sidecarDispatchPrompt, deleteDispatchPromptSidecar,
|
|
14
15
|
WI_STATUS, DISPATCH_RESULT, ENGINE_DEFAULTS, AGENT_STATUS, FAILURE_CLASS } = shared;
|
|
15
16
|
const { getConfig, getDispatch, DISPATCH_PATH, INBOX_DIR } = queries;
|
|
16
17
|
|
|
@@ -24,13 +25,34 @@ function recovery() { if (!_recovery) _recovery = require('./recovery'); return
|
|
|
24
25
|
|
|
25
26
|
// ─── Dispatch Mutation ───────────────────────────────────────────────────────
|
|
26
27
|
|
|
28
|
+
/**
|
|
29
|
+
* Sweep pending + active dispatch entries and move any oversized prompts to
|
|
30
|
+
* sidecar files. Keeps dispatch.json from bloating to hundreds of MB when
|
|
31
|
+
* fix-type prompts inline PR diffs / build logs / coalesced feedback (#1167).
|
|
32
|
+
* Safe to call on every mutation: small prompts are untouched.
|
|
33
|
+
*/
|
|
34
|
+
function _sidecarOversizedPrompts(dispatch) {
|
|
35
|
+
const threshold = ENGINE_DEFAULTS.maxDispatchPromptBytes;
|
|
36
|
+
const lists = [dispatch.pending, dispatch.active];
|
|
37
|
+
for (const list of lists) {
|
|
38
|
+
if (!Array.isArray(list)) continue;
|
|
39
|
+
for (const item of list) {
|
|
40
|
+
if (item && typeof item.prompt === 'string') sidecarDispatchPrompt(item, threshold);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
27
45
|
function mutateDispatch(mutator) {
|
|
28
46
|
const defaultDispatch = { pending: [], active: [], completed: [] };
|
|
29
47
|
const result = mutateJsonFileLocked(DISPATCH_PATH, (dispatch) => {
|
|
30
48
|
dispatch.pending = Array.isArray(dispatch.pending) ? dispatch.pending : [];
|
|
31
49
|
dispatch.active = Array.isArray(dispatch.active) ? dispatch.active : [];
|
|
32
50
|
dispatch.completed = Array.isArray(dispatch.completed) ? dispatch.completed : [];
|
|
33
|
-
|
|
51
|
+
const next = mutator(dispatch) ?? dispatch;
|
|
52
|
+
// Prompt-size guard: runs on every write so a single bad item cannot bloat
|
|
53
|
+
// dispatch.json. Sidecars live in engine/contexts/<id>.md.
|
|
54
|
+
_sidecarOversizedPrompts(next);
|
|
55
|
+
return next;
|
|
34
56
|
}, { defaultValue: defaultDispatch });
|
|
35
57
|
// Invalidate the read cache so next getDispatch() sees fresh data
|
|
36
58
|
try { require('./queries').invalidateDispatchCache(); } catch {}
|
|
@@ -116,7 +138,12 @@ function completeDispatch(id, result = DISPATCH_RESULT.SUCCESS, reason = '', res
|
|
|
116
138
|
if (reason) item.reason = reason;
|
|
117
139
|
if (resultSummary) item.resultSummary = resultSummary;
|
|
118
140
|
if (failureClass && result === DISPATCH_RESULT.ERROR) item.failureClass = failureClass;
|
|
141
|
+
// Drop prompt (and sidecar file, if any) — completed entries don't need
|
|
142
|
+
// replayable content and it would accumulate forever (#1167).
|
|
143
|
+
try { deleteDispatchPromptSidecar(item); } catch { /* best-effort */ }
|
|
119
144
|
delete item.prompt;
|
|
145
|
+
delete item._promptFile;
|
|
146
|
+
delete item._promptBytes;
|
|
120
147
|
if (dispatch.completed.length >= 100) {
|
|
121
148
|
dispatch.completed = dispatch.completed.slice(-100);
|
|
122
149
|
}
|
package/engine/shared.js
CHANGED
|
@@ -162,6 +162,118 @@ function safeUnlink(p) {
|
|
|
162
162
|
try { fs.unlinkSync(p); } catch { /* cleanup */ }
|
|
163
163
|
}
|
|
164
164
|
|
|
165
|
+
// ── Dispatch Prompt Sidecar (#1167) ─────────────────────────────────────────
|
|
166
|
+
// Large prompts (PR diffs, build error logs, coalesced human feedback) inlined
|
|
167
|
+
// into dispatch.json caused hundreds-of-MB bloat per entry and eventual V8 OOM
|
|
168
|
+
// at startup. Sidecar files keep dispatch.json small while preserving full
|
|
169
|
+
// content for the agent at spawn time.
|
|
170
|
+
|
|
171
|
+
// Resolve lazily so MINIONS_TEST_DIR overrides work in tests.
|
|
172
|
+
function _promptContextsDir() {
|
|
173
|
+
return path.join(MINIONS_DIR, 'engine', 'contexts');
|
|
174
|
+
}
|
|
175
|
+
// Keep the constant for callers that expect a stable export; callers that need
|
|
176
|
+
// the current value (tests) should call _promptContextsDir().
|
|
177
|
+
const PROMPT_CONTEXTS_DIR = _promptContextsDir();
|
|
178
|
+
|
|
179
|
+
/** Absolute path to the sidecar prompt file for a given dispatch id. */
|
|
180
|
+
function dispatchPromptSidecarPath(dispatchId) {
|
|
181
|
+
if (!dispatchId) return null;
|
|
182
|
+
const safeId = String(dispatchId).replace(/[^a-zA-Z0-9._-]/g, '-');
|
|
183
|
+
return path.join(_promptContextsDir(), `${safeId}.md`);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* If the dispatch item's prompt exceeds thresholdBytes, write the full prompt
|
|
188
|
+
* to engine/contexts/<id>.md and replace `item.prompt` with a short stub
|
|
189
|
+
* + `_promptFile` reference. Mutates item in place and returns true when
|
|
190
|
+
* sidecaring happened, false otherwise.
|
|
191
|
+
*/
|
|
192
|
+
function sidecarDispatchPrompt(item, thresholdBytes) {
|
|
193
|
+
if (!item || typeof item.prompt !== 'string') return false;
|
|
194
|
+
const threshold = Number(thresholdBytes) > 0
|
|
195
|
+
? Number(thresholdBytes)
|
|
196
|
+
: ENGINE_DEFAULTS.maxDispatchPromptBytes;
|
|
197
|
+
const byteLen = Buffer.byteLength(item.prompt, 'utf8');
|
|
198
|
+
if (byteLen <= threshold) return false;
|
|
199
|
+
if (!item.id) return false; // can't sidecar without a stable id
|
|
200
|
+
try {
|
|
201
|
+
const ctxDir = _promptContextsDir();
|
|
202
|
+
if (!fs.existsSync(ctxDir)) fs.mkdirSync(ctxDir, { recursive: true });
|
|
203
|
+
const sidecar = dispatchPromptSidecarPath(item.id);
|
|
204
|
+
safeWrite(sidecar, item.prompt);
|
|
205
|
+
const relPath = path.relative(MINIONS_DIR, sidecar).replace(/\\/g, '/');
|
|
206
|
+
item._promptFile = relPath;
|
|
207
|
+
item._promptBytes = byteLen;
|
|
208
|
+
item.prompt = `[Prompt sidecarred to ${relPath} — ${Math.round(byteLen / 1024)} KB. The engine reads the sidecar when spawning this agent.]`;
|
|
209
|
+
try { log('warn', `Sidecarred oversized dispatch prompt: ${item.id} (${Math.round(byteLen / 1024)} KB → ${relPath})`); } catch { /* logger may not be ready */ }
|
|
210
|
+
return true;
|
|
211
|
+
} catch (e) {
|
|
212
|
+
try { log('warn', `sidecarDispatchPrompt failed for ${item.id}: ${e.message}`); } catch { /* cleanup */ }
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Read the effective prompt for a dispatch item. Prefers the sidecar file when
|
|
219
|
+
* `_promptFile` is set so spawnAgent always sees the full prompt even though
|
|
220
|
+
* dispatch.json only stores a small stub.
|
|
221
|
+
*/
|
|
222
|
+
function resolveDispatchPrompt(item) {
|
|
223
|
+
if (!item) return '';
|
|
224
|
+
if (item._promptFile) {
|
|
225
|
+
const candidates = [
|
|
226
|
+
path.isAbsolute(item._promptFile) ? item._promptFile : path.resolve(MINIONS_DIR, item._promptFile),
|
|
227
|
+
dispatchPromptSidecarPath(item.id),
|
|
228
|
+
].filter(Boolean);
|
|
229
|
+
for (const c of candidates) {
|
|
230
|
+
try {
|
|
231
|
+
const content = fs.readFileSync(c, 'utf8');
|
|
232
|
+
if (content) return content;
|
|
233
|
+
} catch { /* try next candidate */ }
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
return item.prompt || '';
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/** Remove the sidecar prompt file for a completed/cancelled dispatch. */
|
|
240
|
+
function deleteDispatchPromptSidecar(item) {
|
|
241
|
+
if (!item) return;
|
|
242
|
+
const paths = new Set();
|
|
243
|
+
if (item._promptFile) {
|
|
244
|
+
paths.add(path.isAbsolute(item._promptFile) ? item._promptFile : path.resolve(MINIONS_DIR, item._promptFile));
|
|
245
|
+
}
|
|
246
|
+
const idPath = dispatchPromptSidecarPath(item.id);
|
|
247
|
+
if (idPath) paths.add(idPath);
|
|
248
|
+
for (const p of paths) safeUnlink(p);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Startup guard: throw a clear error when a state file has grown past
|
|
253
|
+
* maxStateFileBytes. Without this the dashboard silently OOMs on JSON.parse
|
|
254
|
+
* (seen on a 491 MB dispatch.json + 509 MB cooldowns.json — #1167).
|
|
255
|
+
* The thrown error points at the bloated file so operators can act instead
|
|
256
|
+
* of chasing V8 heap traces.
|
|
257
|
+
*/
|
|
258
|
+
function assertStateFileSize(filePath, maxBytes) {
|
|
259
|
+
const limit = Number(maxBytes) > 0 ? Number(maxBytes) : ENGINE_DEFAULTS.maxStateFileBytes;
|
|
260
|
+
try {
|
|
261
|
+
const stat = fs.statSync(filePath);
|
|
262
|
+
if (stat.size > limit) {
|
|
263
|
+
throw new Error(
|
|
264
|
+
`State file too large: ${filePath} is ${Math.round(stat.size / (1024 * 1024))} MB ` +
|
|
265
|
+
`(limit ${Math.round(limit / (1024 * 1024))} MB). ` +
|
|
266
|
+
`This usually means dispatch prompts or cooldown contexts were inlined and not sidecarred. ` +
|
|
267
|
+
`Inspect/trim the file manually, then restart. See engine/contexts/ for sidecar files.`
|
|
268
|
+
);
|
|
269
|
+
}
|
|
270
|
+
} catch (e) {
|
|
271
|
+
if (e.code === 'ENOENT') return; // file absent is fine
|
|
272
|
+
if (e.message && e.message.startsWith('State file too large:')) throw e;
|
|
273
|
+
// Other stat errors (permission etc.) — do not block startup
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
165
277
|
function sleepMs(ms) {
|
|
166
278
|
try {
|
|
167
279
|
const ab = new SharedArrayBuffer(4);
|
|
@@ -571,6 +683,9 @@ const ENGINE_DEFAULTS = {
|
|
|
571
683
|
ccEffort: null, // effort level for CC/doc-chat (null, 'low', 'medium', 'high')
|
|
572
684
|
heartbeatTimeouts: {}, // populated after WORK_TYPE is defined (below)
|
|
573
685
|
maxPendingContexts: 20, // cap pendingContexts arrays in cooldowns.json to prevent unbounded growth
|
|
686
|
+
maxPendingContextEntryBytes: 256 * 1024, // 256 KB — cap each pendingContexts entry to prevent huge PR comments from bloating cooldowns.json
|
|
687
|
+
maxDispatchPromptBytes: 1024 * 1024, // 1 MB — dispatch items with prompts larger than this sidecar to engine/contexts/ to prevent dispatch.json OOM (#1167)
|
|
688
|
+
maxStateFileBytes: 100 * 1024 * 1024, // 100 MB — fail startup with a clear error when dispatch.json / cooldowns.json exceed this, rather than silently OOMing on JSON.parse (#1167)
|
|
574
689
|
ccMaxTurns: 50, // max tool-use turns for CC/doc-chat before CLI stops
|
|
575
690
|
// Teams integration — config.teams shape: { enabled, appId, appPassword, certPath, privateKeyPath, tenantId, notifyEvents, ccMirror, inboxPollInterval }
|
|
576
691
|
// Auth modes: (1) appId + appPassword (client secret), or (2) appId + certPath + privateKeyPath + tenantId (certificate)
|
|
@@ -1399,6 +1514,12 @@ module.exports = {
|
|
|
1399
1514
|
safeJson, safeJsonObj, safeJsonArr,
|
|
1400
1515
|
safeWrite,
|
|
1401
1516
|
safeUnlink,
|
|
1517
|
+
PROMPT_CONTEXTS_DIR,
|
|
1518
|
+
dispatchPromptSidecarPath,
|
|
1519
|
+
sidecarDispatchPrompt,
|
|
1520
|
+
resolveDispatchPrompt,
|
|
1521
|
+
deleteDispatchPromptSidecar,
|
|
1522
|
+
assertStateFileSize,
|
|
1402
1523
|
withFileLock,
|
|
1403
1524
|
mutateJsonFileLocked,
|
|
1404
1525
|
mutateWorkItems,
|
package/engine.js
CHANGED
|
@@ -355,7 +355,11 @@ async function recoverPartialWorktree(rootDir, worktreePath, branchName, gitOpts
|
|
|
355
355
|
}
|
|
356
356
|
|
|
357
357
|
async function spawnAgent(dispatchItem, config) {
|
|
358
|
-
const { id, agent: agentId,
|
|
358
|
+
const { id, agent: agentId, type, meta } = dispatchItem;
|
|
359
|
+
// Resolve prompt — prefers sidecar file when dispatchItem._promptFile is set
|
|
360
|
+
// (large prompts are written to engine/contexts/<id>.md to keep dispatch.json
|
|
361
|
+
// small — see shared.sidecarDispatchPrompt / #1167).
|
|
362
|
+
const taskPrompt = shared.resolveDispatchPrompt(dispatchItem);
|
|
359
363
|
const claudeConfig = config.claude || {};
|
|
360
364
|
const engineConfig = config.engine || {};
|
|
361
365
|
const startedAt = ts();
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1035",
|
|
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"
|