@yemi33/minions 0.1.60 → 0.1.62

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,15 +106,18 @@ 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
- }
109
+ // ─── Dispatch Management (extracted to engine/dispatch.js) ───────────────────
110
+
111
+ const { mutateDispatch, addToDispatch, isRetryableFailureReason, completeDispatch,
112
+ writeInboxAlert } = require('./engine/dispatch');
113
+
114
+ // ─── Timeout / Steering / Idle (extracted to engine/timeout.js) ──────────────
115
+
116
+ const { checkTimeouts, checkSteering, checkIdleThreshold } = require('./engine/timeout');
117
+
118
+ // ─── Cleanup (extracted to engine/cleanup.js) ────────────────────────────────
119
+
120
+ const { runCleanup } = require('./engine/cleanup');
118
121
 
119
122
  // ─── State Readers (delegated to engine/queries.js) ─────────────────────────
120
123
 
@@ -714,147 +717,9 @@ function spawnAgent(dispatchItem, config) {
714
717
  return proc;
715
718
  }
716
719
 
717
- // ─── Dispatch Management ────────────────────────────────────────────────────
720
+ // addToDispatch, isRetryableFailureReason now in engine/dispatch.js
718
721
 
719
- function addToDispatch(item) {
720
- item.id = item.id || `${item.agent}-${item.type}-${shared.uid()}`;
721
- item.created_at = ts();
722
- mutateDispatch((dispatch) => {
723
- dispatch.pending.push(item);
724
- });
725
- log('info', `Queued dispatch: ${item.id} (${item.type} → ${item.agent})`);
726
- return item.id;
727
- }
728
-
729
- function isRetryableFailureReason(reason = '') {
730
- const r = String(reason || '').toLowerCase();
731
- if (!r) return true; // unknown error from tool exit — keep retryable
732
- const nonRetryable = [
733
- 'no playbook rendered',
734
- 'failed to render',
735
- 'no target project available',
736
- 'no plan files found',
737
- 'plan file not found',
738
- 'invalid filename',
739
- 'invalid file path',
740
- 'missing required',
741
- 'validation failed',
742
- ];
743
- return !nonRetryable.some(s => r.includes(s));
744
- }
745
-
746
- function completeDispatch(id, result = 'success', reason = '', resultSummary = '', opts = {}) {
747
- const { processWorkItemFailure = true } = opts;
748
- let item = null;
749
-
750
- mutateDispatch((dispatch) => {
751
- // Check active list first
752
- let idx = dispatch.active.findIndex(d => d.id === id);
753
- if (idx >= 0) {
754
- item = dispatch.active.splice(idx, 1)[0];
755
- } else {
756
- // Also check pending list (e.g., worktree failure before spawn)
757
- idx = dispatch.pending.findIndex(d => d.id === id);
758
- if (idx >= 0) item = dispatch.pending.splice(idx, 1)[0];
759
- }
760
-
761
- if (!item) return;
762
- item.completed_at = ts();
763
- item.result = result;
764
- if (reason) item.reason = reason;
765
- if (resultSummary) item.resultSummary = resultSummary;
766
- delete item.prompt;
767
- if (dispatch.completed.length >= 100) {
768
- dispatch.completed = dispatch.completed.slice(-99);
769
- }
770
- dispatch.completed.push(item);
771
- });
772
-
773
- if (item) {
774
- log('info', `Completed dispatch: ${id} (${result}${reason ? ': ' + reason : ''})`);
775
-
776
- // Update source work item status on failure + auto-retry with backoff
777
- const retryableFailure = isRetryableFailureReason(reason);
778
- if (result === 'error' && item.meta?.dispatchKey && retryableFailure) setCooldownFailure(item.meta.dispatchKey);
779
-
780
- if (processWorkItemFailure && result === 'error' && item.meta?.item?.id) {
781
- let retries = (item.meta.item._retryCount || 0);
782
- try {
783
- const wiPath = item.meta.source === 'central-work-item' || item.meta.source === 'central-work-item-fanout'
784
- ? path.join(MINIONS_DIR, 'work-items.json')
785
- : item.meta.project?.name ? projectWorkItemsPath({ name: item.meta.project.name, localPath: item.meta.project.localPath }) : null;
786
- if (wiPath) {
787
- const items = safeJson(wiPath) || [];
788
- const wi = items.find(i => i.id === item.meta.item.id);
789
- if (wi) retries = wi._retryCount || 0;
790
- }
791
- } catch (e) { log('warn', 'read retry count: ' + e.message); }
792
- if (retryableFailure && retries < 3) {
793
- log('info', `Dispatch error for ${item.meta.item.id} — auto-retry ${retries + 1}/3`);
794
- updateWorkItemStatus(item.meta, 'pending', '');
795
- // Remove this dispatch key from completed so dedupe doesn't block immediate redispatch.
796
- if (item.meta?.dispatchKey) {
797
- try {
798
- mutateDispatch((dp) => {
799
- dp.completed = Array.isArray(dp.completed) ? dp.completed.filter(d => d.meta?.dispatchKey !== item.meta.dispatchKey) : [];
800
- return dp;
801
- });
802
- } catch (e) { log('warn', 'clear dispatch for retry: ' + e.message); }
803
- }
804
- // Increment retry counter on the source work item
805
- try {
806
- const wiPath = item.meta.source === 'central-work-item' || item.meta.source === 'central-work-item-fanout'
807
- ? path.join(MINIONS_DIR, 'work-items.json')
808
- : item.meta.project?.name ? projectWorkItemsPath({ name: item.meta.project.name, localPath: item.meta.project.localPath }) : null;
809
- if (wiPath) {
810
- const items = safeJson(wiPath) || [];
811
- const wi = items.find(i => i.id === item.meta.item.id);
812
- if (wi && wi.status !== 'paused') {
813
- wi._retryCount = retries + 1;
814
- wi.status = 'pending';
815
- wi._lastRetryReason = reason || '';
816
- wi._lastRetryAt = ts();
817
- delete wi.failReason;
818
- delete wi.failedAt;
819
- delete wi.dispatched_at;
820
- delete wi.dispatched_to;
821
- safeWrite(wiPath, items);
822
- }
823
- }
824
- } catch (e) { log('warn', 'increment retry counter: ' + e.message); }
825
- } else {
826
- const finalReason = !retryableFailure
827
- ? `Non-retryable failure: ${reason || 'Unknown error'}`
828
- : (reason || 'Failed after 3 retries');
829
- updateWorkItemStatus(item.meta, 'failed', finalReason);
830
- // Alert: find items blocked by this failure and write inbox note
831
- try {
832
- const config = getConfig();
833
- const failedId = item.meta.item.id;
834
- const blockedItems = [];
835
- for (const p of getProjects(config)) {
836
- const items = safeJson(projectWorkItemsPath(p)) || [];
837
- items.filter(w => w.status === 'pending' && (w.depends_on || []).includes(failedId))
838
- .forEach(w => blockedItems.push(`- \`${w.id}\` — ${w.title}`));
839
- }
840
- const centralItems = safeJson(path.join(MINIONS_DIR, 'work-items.json')) || [];
841
- centralItems.filter(w => w.status === 'pending' && (w.depends_on || []).includes(failedId))
842
- .forEach(w => blockedItems.push(`- \`${w.id}\` — ${w.title}`));
843
-
844
- writeInboxAlert(`failed-${failedId}`,
845
- `# Work Item Failed — \`${failedId}\`\n\n` +
846
- `**Item:** ${item.meta.item.title || failedId}\n` +
847
- `**Reason:** ${finalReason}\n\n` +
848
- (blockedItems.length > 0
849
- ? `**Blocked dependents (${blockedItems.length}):**\n${blockedItems.join('\n')}\n\n` +
850
- `These items cannot dispatch until \`${failedId}\` is fixed and reset to \`pending\`.\n`
851
- : `No downstream items are blocked.\n`)
852
- );
853
- } catch (e) { log('warn', 'write failure alert: ' + e.message); }
854
- }
855
- }
856
- }
857
- }
722
+ // completeDispatch — now in engine/dispatch.js
858
723
 
859
724
  // ─── Dependency Gate ─────────────────────────────────────────────────────────
860
725
  // Returns: true (deps met), false (deps pending), 'failed' (dep failed — propagate)
@@ -910,18 +775,7 @@ function detectDependencyCycles(items) {
910
775
  }
911
776
 
912
777
 
913
- // ─── Shared helpers ───────────────────────────────────────────────────────────
914
-
915
- // Write a one-off alert note to notes/inbox so the user sees it on next consolidation
916
- function writeInboxAlert(slug, content) {
917
- try {
918
- const file = path.join(INBOX_DIR, `engine-alert-${slug}-${dateStamp()}.md`);
919
- // Dedupe: don't write the same alert twice in the same day
920
- const existing = safeReadDir(INBOX_DIR).find(f => f.startsWith(`engine-alert-${slug}-${dateStamp()}`));
921
- if (existing) return;
922
- safeWrite(file, content);
923
- } catch (e) { log('warn', 'write inbox alert: ' + e.message); }
924
- }
778
+ // writeInboxAlert now in engine/dispatch.js
925
779
 
926
780
  // Reconciles work items against known PRs.
927
781
  // Primary linkage comes from prdItems in pull-requests.json; fallback linkage
@@ -993,599 +847,9 @@ function updateSnapshot(config) {
993
847
  safeWrite(path.join(IDENTITY_DIR, 'now.md'), snapshot);
994
848
  }
995
849
 
996
- // ─── Idle Alert ─────────────────────────────────────────────────────────────
997
-
998
- let _lastActivityTime = Date.now();
999
- let _idleAlertSent = false;
1000
-
1001
- function checkIdleThreshold(config) {
1002
- const thresholdMs = (config.engine?.idleAlertMinutes || 15) * 60 * 1000;
1003
- const agents = Object.keys(config.agents || {});
1004
- const allIdle = agents.every(id => isAgentIdle(id));
1005
- const dispatch = getDispatch();
1006
- const hasPending = (dispatch.pending || []).length > 0;
1007
-
1008
- if (!allIdle || hasPending) {
1009
- _lastActivityTime = Date.now();
1010
- _idleAlertSent = false;
1011
- return;
1012
- }
1013
-
1014
- const idleMs = Date.now() - _lastActivityTime;
1015
- if (idleMs > thresholdMs && !_idleAlertSent) {
1016
- const mins = Math.round(idleMs / 60000);
1017
- log('warn', `All agents idle for ${mins} minutes — no work sources producing items`);
1018
- _idleAlertSent = true;
1019
- }
1020
- }
1021
-
1022
- // ─── Steering Checker ────────────────────────────────────────────────────────
1023
-
1024
- function checkSteering(config) {
1025
- for (const [id, info] of activeProcesses) {
1026
- const steerPath = path.join(AGENTS_DIR, info.agentId, 'steer.md');
1027
- if (!fs.existsSync(steerPath)) continue;
1028
-
1029
- const message = safeRead(steerPath);
1030
- try { fs.unlinkSync(steerPath); } catch { /* cleanup */ }
1031
- if (!message) continue;
1032
-
1033
- const sessionId = info.sessionId;
1034
- if (!sessionId) {
1035
- log('warn', `Steering: no sessionId for ${info.agentId} — cannot resume. Message dropped.`);
1036
- continue;
1037
- }
1038
-
1039
- log('info', `Steering: killing ${info.agentId} (${id}) for session resume with human message`);
1040
-
1041
- // Kill current process
1042
- try { info.proc.kill('SIGTERM'); } catch { /* process may be dead */ }
1043
-
1044
- // Store steering context for re-spawn on close
1045
- info._steeringMessage = message;
1046
- info._steeringSessionId = sessionId;
1047
- }
1048
- }
1049
-
1050
- // ─── Timeout Checker ────────────────────────────────────────────────────────
1051
-
1052
- function checkTimeouts(config) {
1053
- const timeout = config.engine?.agentTimeout || DEFAULTS.agentTimeout;
1054
- const heartbeatTimeout = config.engine?.heartbeatTimeout || DEFAULTS.heartbeatTimeout;
1055
-
1056
- // 1. Check tracked processes for hard timeout (supports per-item deadline from fan-out)
1057
- for (const [id, info] of activeProcesses.entries()) {
1058
- const itemTimeout = info.meta?.deadline ? Math.max(0, info.meta.deadline - new Date(info.startedAt).getTime()) : timeout;
1059
- const elapsed = Date.now() - new Date(info.startedAt).getTime();
1060
- if (elapsed > itemTimeout) {
1061
- log('warn', `Agent ${info.agentId} (${id}) hit hard timeout after ${Math.round(elapsed / 1000)}s — killing`);
1062
- try { info.proc.kill('SIGTERM'); } catch { /* process may be dead */ }
1063
- setTimeout(() => {
1064
- try { info.proc.kill('SIGKILL'); } catch { /* process may be dead */ }
1065
- }, 5000);
1066
- }
1067
- }
850
+ // checkIdleThreshold, checkSteering, checkTimeouts — now in engine/timeout.js
1068
851
 
1069
- // 2. Heartbeat check for ALL active dispatch items (catches orphans after engine restart)
1070
- // Uses live-output.log mtime as heartbeat. If no output for heartbeatTimeout, agent is dead.
1071
- const dispatch = getDispatch();
1072
- const deadItems = [];
1073
-
1074
- for (const item of (dispatch.active || [])) {
1075
- if (!item.agent) continue;
1076
-
1077
- const hasProcess = activeProcesses.has(item.id);
1078
- const liveLogPath = path.join(AGENTS_DIR, item.agent, 'live-output.log');
1079
- let lastActivity = item.started_at ? new Date(item.started_at).getTime() : 0;
1080
-
1081
- // Check live-output.log mtime as heartbeat
1082
- try {
1083
- const stat = fs.statSync(liveLogPath);
1084
- lastActivity = Math.max(lastActivity, stat.mtimeMs);
1085
- } catch { /* optional */ }
1086
-
1087
- const silentMs = Date.now() - lastActivity;
1088
- const silentSec = Math.round(silentMs / 1000);
1089
-
1090
- // Check if the agent actually completed (result event in live output)
1091
- // Optimization: only read file if recent activity (avoids reading stale 1MB logs)
1092
- let completedViaOutput = false;
1093
- try {
1094
- if (silentMs > 600000) throw 'skip'; // No point reading a file silent for >10min
1095
- const liveLog = safeRead(liveLogPath);
1096
- if (liveLog && liveLog.includes('"type":"result"')) {
1097
- completedViaOutput = true;
1098
- const isSuccess = liveLog.includes('"subtype":"success"');
1099
- log('info', `Agent ${item.agent} (${item.id}) completed via output detection (${isSuccess ? 'success' : 'error'})`);
1100
-
1101
- // Extract output text for the output.log
1102
- const outputLogPath = path.join(AGENTS_DIR, item.agent, 'output.log');
1103
- try {
1104
- const resultLine = liveLog.split('\n').find(l => l.includes('"type":"result"'));
1105
- if (resultLine) {
1106
- const result = JSON.parse(resultLine);
1107
- 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`);
1108
- }
1109
- } catch (e) { log('warn', 'parse output result: ' + e.message); }
1110
-
1111
- completeDispatch(item.id, isSuccess ? 'success' : 'error', 'Completed (detected from output)');
1112
-
1113
- // Run post-completion hooks via shared helper
1114
- runPostCompletionHooks(item, item.agent, isSuccess ? 0 : 1, liveLog, config);
1115
-
1116
- if (hasProcess) {
1117
- try { activeProcesses.get(item.id)?.proc.kill('SIGTERM'); } catch { /* process may be dead */ }
1118
- activeProcesses.delete(item.id);
1119
- }
1120
- continue; // Skip orphan/hung detection — we handled it
1121
- }
1122
- } catch (e) { log('warn', 'output completion detection: ' + e.message); }
1123
-
1124
- // Check if agent is in a blocking tool call (TaskOutput block:true, Bash with long timeout, etc.)
1125
- // These tools produce no stdout for extended periods — don't kill them prematurely
1126
- // Check for BOTH tracked and untracked processes (orphan case after engine restart)
1127
- let isBlocking = false;
1128
- let blockingTimeout = heartbeatTimeout;
1129
- if (silentMs > heartbeatTimeout) {
1130
- try {
1131
- const liveLog = safeRead(liveLogPath);
1132
- if (liveLog) {
1133
- // Find the last tool_use call in the output — check if it's a known blocking tool
1134
- const lines = liveLog.split('\n');
1135
- for (let i = lines.length - 1; i >= Math.max(0, lines.length - 30); i--) {
1136
- const line = lines[i];
1137
- if (!line.includes('"tool_use"')) continue;
1138
- try {
1139
- const parsed = JSON.parse(line);
1140
- const toolUse = parsed?.message?.content?.find?.(c => c.type === 'tool_use');
1141
- if (!toolUse) continue;
1142
- const input = toolUse.input || {};
1143
- const name = toolUse.name || '';
1144
- // TaskOutput with block:true — waiting for a background task
1145
- if (name === 'TaskOutput' && input.block === true) {
1146
- const taskTimeout = input.timeout || 600000; // default 10min
1147
- blockingTimeout = Math.max(heartbeatTimeout, taskTimeout + 60000); // task timeout + 1min grace
1148
- isBlocking = true;
1149
- }
1150
- // Bash with explicit long timeout (>5min)
1151
- if (name === 'Bash' && input.timeout && input.timeout > heartbeatTimeout) {
1152
- blockingTimeout = Math.max(heartbeatTimeout, input.timeout + 60000);
1153
- isBlocking = true;
1154
- }
1155
- break; // only check the most recent tool_use
1156
- } catch { /* JSON parse — line may not be valid JSON */ }
1157
- }
1158
- if (isBlocking) {
1159
- 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)`);
1160
- }
1161
- }
1162
- } catch (e) { log('warn', 'blocking tool detection: ' + e.message); }
1163
- }
1164
-
1165
- const effectiveTimeout = isBlocking ? blockingTimeout : heartbeatTimeout;
1166
-
1167
- if (!hasProcess && silentMs > effectiveTimeout && Date.now() > engineRestartGraceUntil) {
1168
- // No tracked process AND no recent output past effective timeout AND grace period expired → orphaned
1169
- log('warn', `Orphan detected: ${item.agent} (${item.id}) — no process tracked, silent for ${silentSec}s${isBlocking ? ' (blocking timeout exceeded)' : ''}`);
1170
- deadItems.push({ item, reason: `Orphaned — no process, silent for ${silentSec}s` });
1171
- } else if (hasProcess && silentMs > effectiveTimeout) {
1172
- // Has process but no output past effective timeout → hung
1173
- log('warn', `Hung agent: ${item.agent} (${item.id}) — process exists but no output for ${silentSec}s${isBlocking ? ' (blocking timeout exceeded)' : ''}`);
1174
- const procInfo = activeProcesses.get(item.id);
1175
- if (procInfo) {
1176
- try { procInfo.proc.kill('SIGTERM'); } catch { /* process may be dead */ }
1177
- setTimeout(() => { try { procInfo.proc.kill('SIGKILL'); } catch { /* process may be dead */ } }, 5000);
1178
- activeProcesses.delete(item.id);
1179
- }
1180
- deadItems.push({ item, reason: `Hung — no output for ${silentSec}s` });
1181
- }
1182
- // If has process and recent output → healthy, let it run
1183
- }
1184
-
1185
- // Clean up dead items
1186
- for (const { item, reason } of deadItems) {
1187
- completeDispatch(item.id, 'error', reason);
1188
- }
1189
-
1190
- // Agent status is now derived from dispatch.json at read time (getAgentStatus).
1191
- // No reconcile sweep needed — dispatch IS the source of truth.
1192
-
1193
- // Reconcile: find work items stuck in "dispatched" with no matching active dispatch
1194
- const activeKeys = new Set((dispatch.active || []).map(d => d.meta?.dispatchKey).filter(Boolean));
1195
- const allWiPaths = [path.join(MINIONS_DIR, 'work-items.json')];
1196
- for (const project of getProjects(config)) {
1197
- allWiPaths.push(projectWorkItemsPath(project));
1198
- }
1199
- for (const wiPath of allWiPaths) {
1200
- const items = safeJson(wiPath);
1201
- if (!items || !Array.isArray(items)) continue;
1202
- let changed = false;
1203
- for (const item of items) {
1204
- if (item.status !== 'dispatched') continue;
1205
- // Check if any active dispatch references this item
1206
- // Dispatch keys include project name: work-{project}-{id} or central-work-{id}
1207
- const projectNames = getProjects(config).map(p => p.name);
1208
- const possibleKeys = [
1209
- `central-work-${item.id}`,
1210
- ...projectNames.map(p => `work-${p}-${item.id}`),
1211
- ];
1212
- const isActive = possibleKeys.some(k => activeKeys.has(k)) ||
1213
- (dispatch.active || []).some(d => d.meta?.item?.id === item.id);
1214
- if (!isActive) {
1215
- const retries = (item._retryCount || 0);
1216
- if (retries < 3) {
1217
- log('info', `Reconcile: work item ${item.id} agent died — auto-retry ${retries + 1}/3`);
1218
- item.status = 'pending';
1219
- item._retryCount = retries + 1;
1220
- delete item.dispatched_at;
1221
- delete item.dispatched_to;
1222
- } else {
1223
- log('warn', `Reconcile: work item ${item.id} failed after ${retries} retries — marking as failed`);
1224
- item.status = 'failed';
1225
- item.failReason = 'Agent died or was killed (3 retries exhausted)';
1226
- item.failedAt = ts();
1227
- }
1228
- changed = true;
1229
- }
1230
- }
1231
- if (changed) safeWrite(wiPath, items);
1232
- }
1233
- }
1234
-
1235
- // ─── Cleanup ─────────────────────────────────────────────────────────────────
1236
-
1237
- function runCleanup(config, verbose = false) {
1238
- const projects = getProjects(config);
1239
- let cleaned = { tempFiles: 0, liveOutputs: 0, worktrees: 0, zombies: 0 };
1240
-
1241
- // 1. Clean stale temp prompt/sysprompt files (older than 1 hour)
1242
- const oneHourAgo = Date.now() - 3600000;
1243
- try {
1244
- const tmpDir = path.join(ENGINE_DIR, 'tmp');
1245
- const scanDirs = [ENGINE_DIR, ...(fs.existsSync(tmpDir) ? [tmpDir] : [])];
1246
- for (const dir of scanDirs) {
1247
- for (const f of fs.readdirSync(dir)) {
1248
- if (f.startsWith('prompt-') || f.startsWith('sysprompt-') || f.startsWith('tmp-sysprompt-')) {
1249
- const fp = path.join(dir, f);
1250
- try {
1251
- const stat = fs.statSync(fp);
1252
- if (stat.mtimeMs < oneHourAgo) {
1253
- fs.unlinkSync(fp);
1254
- cleaned.tempFiles++;
1255
- }
1256
- } catch { /* cleanup */ }
1257
- }
1258
- }
1259
- }
1260
- } catch (e) { log('warn', 'cleanup temp files: ' + e.message); }
1261
-
1262
- // 2. Clean live-output.log for idle agents (not currently working)
1263
- for (const [agentId] of Object.entries(config.agents || {})) {
1264
- const status = getAgentStatus(agentId);
1265
- if (status.status !== 'working') {
1266
- const livePath = path.join(AGENTS_DIR, agentId, 'live-output.log');
1267
- if (fs.existsSync(livePath)) {
1268
- try {
1269
- const stat = fs.statSync(livePath);
1270
- if (stat.mtimeMs < oneHourAgo) {
1271
- fs.unlinkSync(livePath);
1272
- cleaned.liveOutputs++;
1273
- }
1274
- } catch { /* cleanup */ }
1275
- }
1276
- }
1277
- }
1278
-
1279
- // 3. Clean git worktrees for merged/abandoned PRs
1280
- for (const project of projects) {
1281
- const root = project.localPath ? path.resolve(project.localPath) : null;
1282
- if (!root || !fs.existsSync(root)) continue;
1283
-
1284
- const worktreeRoot = path.resolve(root, config.engine?.worktreeRoot || '../worktrees');
1285
- if (!fs.existsSync(worktreeRoot)) continue;
1286
-
1287
- // Get PRs for this project
1288
- const prs = safeJson(projectPrPath(project)) || [];
1289
- const mergedBranches = new Set();
1290
- for (const pr of prs) {
1291
- if (pr.status === 'merged' || pr.status === 'abandoned' || pr.status === 'completed') {
1292
- if (pr.branch) mergedBranches.add(pr.branch);
1293
- }
1294
- }
1295
-
1296
- // List worktrees — collect info for age-based + cap-based cleanup
1297
- const MAX_WORKTREES = 10;
1298
- try {
1299
- const dirs = fs.readdirSync(worktreeRoot);
1300
- const wtEntries = []; // { dir, wtPath, mtime, shouldClean, isProtected }
1301
- const dispatch = getDispatch();
1302
-
1303
- for (const dir of dirs) {
1304
- const wtPath = path.join(worktreeRoot, dir);
1305
- try { if (!fs.statSync(wtPath).isDirectory()) continue; } catch { continue; }
1306
-
1307
- let shouldClean = false;
1308
- let isProtected = false;
1309
-
1310
- // Check if this worktree's branch is merged/abandoned
1311
- // Use sanitized exact match on the branch portion of the dir name (format: {slug}-{branch}-{suffix})
1312
- const dirLower = dir.toLowerCase();
1313
- for (const branch of mergedBranches) {
1314
- const branchSlug = sanitizeBranch(branch).toLowerCase();
1315
- if (dirLower === branchSlug || dirLower.includes(branchSlug + '-') || dirLower.endsWith('-' + branchSlug)) {
1316
- shouldClean = true;
1317
- break;
1318
- }
1319
- }
1320
-
1321
- // Check if referenced by active/pending dispatch (use sanitized branch comparison)
1322
- const isReferenced = [...dispatch.pending, ...(dispatch.active || [])].some(d => {
1323
- if (!d.meta?.branch) return false;
1324
- const dispBranch = sanitizeBranch(d.meta.branch).toLowerCase();
1325
- return dirLower.includes(dispBranch);
1326
- });
1327
- if (isReferenced) isProtected = true;
1328
-
1329
- // Also clean worktrees older than 2 hours with no active dispatch referencing them
1330
- let mtime = Date.now();
1331
- if (!shouldClean) {
1332
- try {
1333
- const stat = fs.statSync(wtPath);
1334
- mtime = stat.mtimeMs;
1335
- const ageMs = Date.now() - mtime;
1336
- if (ageMs > 7200000 && !isReferenced) { // 2 hours
1337
- shouldClean = true;
1338
- }
1339
- } catch { /* optional */ }
1340
- }
1341
-
1342
- // Skip worktrees for active shared-branch plans (check both prd/ and plans/ for .json PRDs)
1343
- if (shouldClean || !isProtected) {
1344
- try {
1345
- for (const checkDir of [PRD_DIR, path.join(MINIONS_DIR, 'plans')]) {
1346
- if (!fs.existsSync(checkDir)) continue;
1347
- for (const pf of fs.readdirSync(checkDir).filter(f => f.endsWith('.json'))) {
1348
- const plan = safeJson(path.join(checkDir, pf));
1349
- if (plan?.branch_strategy === 'shared-branch' && plan?.feature_branch && plan?.status !== 'completed') {
1350
- const planBranch = sanitizeBranch(plan.feature_branch).toLowerCase();
1351
- if (dirLower.includes(planBranch)) {
1352
- isProtected = true;
1353
- if (shouldClean) {
1354
- shouldClean = false;
1355
- if (verbose) console.log(` Skipping worktree ${dir}: active shared-branch plan`);
1356
- }
1357
- break;
1358
- }
1359
- }
1360
- }
1361
- if (isProtected) break;
1362
- }
1363
- } catch (e) { log('warn', 'check shared-branch protection: ' + e.message); }
1364
- }
1365
-
1366
- wtEntries.push({ dir, wtPath, mtime, shouldClean, isProtected });
1367
- }
1368
-
1369
- // Enforce max worktree cap — if over limit, mark oldest unprotected for cleanup
1370
- const surviving = wtEntries.filter(e => !e.shouldClean && !e.isProtected);
1371
- if (surviving.length + wtEntries.filter(e => e.isProtected).length > MAX_WORKTREES) {
1372
- // Sort oldest first
1373
- surviving.sort((a, b) => a.mtime - b.mtime);
1374
- const excess = surviving.length + wtEntries.filter(e => e.isProtected).length - MAX_WORKTREES;
1375
- for (let i = 0; i < Math.min(excess, surviving.length); i++) {
1376
- surviving[i].shouldClean = true;
1377
- if (verbose) console.log(` Marking worktree ${surviving[i].dir} for cap cleanup (${MAX_WORKTREES} max)`);
1378
- }
1379
- }
1380
-
1381
- // Remove all marked worktrees
1382
- for (const entry of wtEntries) {
1383
- if (entry.shouldClean) {
1384
- try {
1385
- exec(`git worktree remove "${entry.wtPath}" --force`, { cwd: root, stdio: 'pipe' });
1386
- cleaned.worktrees++;
1387
- if (verbose) console.log(` Removed worktree: ${entry.wtPath}`);
1388
- } catch (e) {
1389
- if (verbose) console.log(` Failed to remove worktree ${entry.wtPath}: ${e.message}`);
1390
- }
1391
- }
1392
- }
1393
- } catch (e) { log('warn', 'cleanup worktrees: ' + e.message); }
1394
- }
1395
-
1396
- // 4. Kill zombie claude processes not tracked by the engine
1397
- // List all node processes, check if any are running spawn-agent.js for our minions
1398
- try {
1399
- const dispatch = getDispatch();
1400
- const activePids = new Set();
1401
- for (const [, info] of activeProcesses.entries()) {
1402
- if (info.proc?.pid) activePids.add(info.proc.pid);
1403
- }
1404
-
1405
- // Clean individual orphaned processes — no matching active dispatch
1406
- const activeIds = new Set((dispatch.active || []).map(d => d.id));
1407
- for (const [id, info] of activeProcesses.entries()) {
1408
- if (!activeIds.has(id)) {
1409
- try { if (info.proc) info.proc.kill('SIGTERM'); } catch { /* process may be dead */ }
1410
- activeProcesses.delete(id);
1411
- cleaned.zombies++;
1412
- }
1413
- }
1414
- } catch (e) { log('warn', 'cleanup zombie processes: ' + e.message); }
1415
-
1416
- // 5. Clean spawn-debug.log
1417
- try { fs.unlinkSync(path.join(ENGINE_DIR, 'spawn-debug.log')); } catch { /* cleanup */ }
1418
-
1419
- // 6. Prune old output archive files (keep last 30 per agent)
1420
- for (const agentId of Object.keys(config.agents || {})) {
1421
- const agentDir = path.join(MINIONS_DIR, 'agents', agentId);
1422
- if (!fs.existsSync(agentDir)) continue;
1423
- try {
1424
- const outputFiles = fs.readdirSync(agentDir)
1425
- .filter(f => f.startsWith('output-') && f.endsWith('.log') && f !== 'output.log')
1426
- .map(f => ({ name: f, mtime: fs.statSync(path.join(agentDir, f)).mtimeMs }))
1427
- .sort((a, b) => b.mtime - a.mtime);
1428
- for (const old of outputFiles.slice(30)) {
1429
- try { fs.unlinkSync(path.join(agentDir, old.name)); cleaned.files++; } catch { /* cleanup */ }
1430
- }
1431
- } catch (e) { log('warn', 'prune output archives: ' + e.message); }
1432
- }
1433
-
1434
- // 7. Prune orphaned dispatch entries — items whose source work item no longer exists
1435
- cleaned.orphanedDispatches = 0;
1436
- try {
1437
- const dispatch = getDispatch();
1438
- // Collect all work item IDs across all sources
1439
- const allWiIds = new Set();
1440
- try {
1441
- const central = safeJson(path.join(MINIONS_DIR, 'work-items.json')) || [];
1442
- central.forEach(w => allWiIds.add(w.id));
1443
- } catch (e) { log('warn', 'read central work items for orphan check: ' + e.message); }
1444
- for (const project of projects) {
1445
- try {
1446
- const projItems = safeJson(projectWorkItemsPath(project)) || [];
1447
- projItems.forEach(w => allWiIds.add(w.id));
1448
- } catch (e) { log('warn', 'read project work items for orphan check: ' + e.message); }
1449
- }
1450
-
1451
- let changed = false;
1452
- for (const queue of ['pending', 'active']) {
1453
- if (!dispatch[queue]) continue;
1454
- const before = dispatch[queue].length;
1455
- dispatch[queue] = dispatch[queue].filter(d => {
1456
- const itemId = d.meta?.item?.id;
1457
- if (!itemId) return true; // keep entries without item tracking
1458
- return allWiIds.has(itemId);
1459
- });
1460
- const removed = before - dispatch[queue].length;
1461
- if (removed > 0) {
1462
- cleaned.orphanedDispatches += removed;
1463
- changed = true;
1464
- }
1465
- }
1466
- if (changed) {
1467
- mutateDispatch((dp) => {
1468
- for (const queue of ['pending', 'active']) {
1469
- if (!dp[queue]) continue;
1470
- dp[queue] = dp[queue].filter(d => {
1471
- const itemId = d.meta?.item?.id;
1472
- if (!itemId) return true;
1473
- return allWiIds.has(itemId);
1474
- });
1475
- }
1476
- });
1477
- }
1478
- } catch (e) { log('warn', 'prune orphaned dispatches: ' + e.message); }
1479
-
1480
- if (cleaned.tempFiles + cleaned.liveOutputs + cleaned.worktrees + cleaned.zombies + (cleaned.files || 0) + cleaned.orphanedDispatches > 0) {
1481
- log('info', `Cleanup: ${cleaned.tempFiles} temp, ${cleaned.liveOutputs} live outputs, ${cleaned.worktrees} worktrees, ${cleaned.zombies} zombies, ${cleaned.files || 0} archives, ${cleaned.orphanedDispatches} orphaned dispatches`);
1482
- }
1483
-
1484
- // 8. Clean swept KB files older than 7 days
1485
- try {
1486
- const sweptDir = path.join(MINIONS_DIR, 'knowledge', '_swept');
1487
- if (fs.existsSync(sweptDir)) {
1488
- const sevenDaysAgo = Date.now() - 7 * 86400000;
1489
- for (const f of fs.readdirSync(sweptDir)) {
1490
- try {
1491
- const fp = path.join(sweptDir, f);
1492
- if (fs.statSync(fp).mtimeMs < sevenDaysAgo) {
1493
- fs.unlinkSync(fp);
1494
- if (!cleaned.sweptKb) cleaned.sweptKb = 0;
1495
- cleaned.sweptKb++;
1496
- }
1497
- } catch { /* cleanup */ }
1498
- }
1499
- }
1500
- } catch (e) { log('warn', 'cleanup swept KB files: ' + e.message); }
1501
-
1502
- // 9. KB watchdog — restore deleted KB files from git if count dropped vs checkpoint
1503
- try {
1504
- const checkpoint = safeJson(path.join(ENGINE_DIR, 'kb-checkpoint.json'));
1505
- if (checkpoint && checkpoint.count > 0) {
1506
- const { KB_CATEGORIES: cats } = shared;
1507
- const knowledgeDir = path.join(MINIONS_DIR, 'knowledge');
1508
- let current = 0;
1509
- for (const cat of cats) {
1510
- const d = path.join(knowledgeDir, cat);
1511
- if (fs.existsSync(d)) current += fs.readdirSync(d).length;
1512
- }
1513
- if (current < checkpoint.count) {
1514
- log('warn', `KB watchdog: file count dropped ${checkpoint.count} → ${current}, restoring from git`);
1515
- try {
1516
- const trackedCheck = execSilent('git ls-tree --name-only HEAD -- knowledge', { cwd: MINIONS_DIR }).toString().trim();
1517
- if (!trackedCheck) {
1518
- log('warn', 'KB watchdog: knowledge/ is not tracked in git HEAD — skipping restore');
1519
- } else {
1520
- execSilent('git checkout HEAD -- knowledge', { cwd: MINIONS_DIR });
1521
- log('info', 'KB watchdog: restored knowledge/ from git HEAD');
1522
- }
1523
- } catch (err) {
1524
- log('error', `KB watchdog: git restore failed — ${err.message}`);
1525
- }
1526
- }
1527
- }
1528
- } catch (e) { log('warn', 'KB watchdog check: ' + e.message); }
1529
-
1530
- // 6. Migrate legacy work-item statuses to canonical values
1531
- // in-pr, implemented, complete → done (one-time correction per item)
1532
- const LEGACY_DONE_STATUSES = new Set(['in-pr', 'implemented', 'complete']);
1533
- for (const project of projects) {
1534
- try {
1535
- const wiPath = projectWorkItemsPath(project);
1536
- const items = safeJson(wiPath) || [];
1537
- let migrated = 0;
1538
- for (const item of items) {
1539
- if (LEGACY_DONE_STATUSES.has(item.status)) {
1540
- item.status = 'done';
1541
- migrated++;
1542
- }
1543
- }
1544
- if (migrated > 0) {
1545
- safeWrite(wiPath, items);
1546
- log('info', `Migrated ${migrated} legacy status(es) → done in ${project.name} work items`);
1547
- }
1548
- } catch (e) { log('warn', 'migrate legacy statuses: ' + e.message); }
1549
- }
1550
- // Central work items
1551
- try {
1552
- const centralPath = path.join(MINIONS_DIR, 'work-items.json');
1553
- const centralItems = safeJson(centralPath) || [];
1554
- let migrated = 0;
1555
- for (const item of centralItems) {
1556
- if (LEGACY_DONE_STATUSES.has(item.status)) {
1557
- item.status = 'done';
1558
- migrated++;
1559
- }
1560
- }
1561
- if (migrated > 0) {
1562
- safeWrite(centralPath, centralItems);
1563
- log('info', `Migrated ${migrated} legacy status(es) → done in central work items`);
1564
- }
1565
- } catch (e) { log('warn', 'migrate central legacy statuses: ' + e.message); }
1566
- // PRD items (missing_features[].status)
1567
- try {
1568
- const prdFiles = fs.readdirSync(PRD_DIR).filter(f => f.endsWith('.json'));
1569
- for (const pf of prdFiles) {
1570
- const prdPath = path.join(PRD_DIR, pf);
1571
- const prd = safeJson(prdPath);
1572
- if (!prd?.missing_features) continue;
1573
- let migrated = 0;
1574
- for (const feat of prd.missing_features) {
1575
- if (LEGACY_DONE_STATUSES.has(feat.status)) {
1576
- feat.status = 'done';
1577
- migrated++;
1578
- }
1579
- }
1580
- if (migrated > 0) {
1581
- safeWrite(prdPath, prd);
1582
- log('info', `Migrated ${migrated} legacy PRD item status(es) → done in ${pf}`);
1583
- }
1584
- }
1585
- } catch (e) { log('warn', 'migrate PRD legacy statuses: ' + e.message); }
1586
-
1587
- return cleaned;
1588
- }
852
+ // runCleanupnow in engine/cleanup.js
1589
853
 
1590
854
  // ─── Cooldowns (extracted to engine/cooldown.js) ─────────────────────────────
1591
855
 
@@ -2992,8 +2256,8 @@ module.exports = {
2992
2256
  getAgentStatus, getAgentCharter, getInboxFiles, getPrs,
2993
2257
  validateConfig,
2994
2258
 
2995
- // Dispatch management
2996
- addToDispatch, completeDispatch,
2259
+ // Dispatch management (re-exported from engine/dispatch.js)
2260
+ mutateDispatch, addToDispatch, isRetryableFailureReason, completeDispatch, writeInboxAlert,
2997
2261
  activeProcesses, get engineRestartGraceUntil() { return engineRestartGraceUntil; },
2998
2262
  set engineRestartGraceUntil(v) { engineRestartGraceUntil = v; },
2999
2263
 
@@ -3005,13 +2269,19 @@ module.exports = {
3005
2269
  materializePlansAsWorkItems,
3006
2270
 
3007
2271
  // Shared helpers (used by lifecycle.js and tests)
3008
- reconcileItemsWithPrs, writeInboxAlert, detectDependencyCycles,
2272
+ reconcileItemsWithPrs, detectDependencyCycles,
3009
2273
 
3010
2274
  // Playbooks
3011
2275
  renderPlaybook,
3012
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
+
3013
2283
  // Post-completion / lifecycle
3014
- updateWorkItemStatus, runCleanup, handlePostMerge,
2284
+ updateWorkItemStatus, handlePostMerge,
3015
2285
 
3016
2286
  // Cooldowns
3017
2287
  loadCooldowns, setCooldownWithContext, getCoalescedContexts,