@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/CHANGELOG.md +39 -0
- package/dashboard/js/command-center.js +2 -0
- package/dashboard/js/command-history.js +2 -0
- package/dashboard/js/command-input.js +2 -0
- package/dashboard/js/command-parser.js +2 -0
- package/dashboard/js/detail-panel.js +2 -0
- package/dashboard/js/live-stream.js +2 -0
- package/dashboard/js/modal-qa.js +2 -0
- package/dashboard/js/modal.js +2 -0
- package/dashboard/js/refresh.js +2 -0
- package/dashboard/js/render-agents.js +2 -0
- package/dashboard/js/render-dispatch.js +2 -0
- package/dashboard/js/render-inbox.js +2 -0
- package/dashboard/js/render-kb.js +2 -0
- package/dashboard/js/render-other.js +2 -0
- package/dashboard/js/render-pinned.js +2 -0
- package/dashboard/js/render-plans.js +2 -0
- package/dashboard/js/render-prd.js +2 -0
- package/dashboard/js/render-prs.js +2 -0
- package/dashboard/js/render-schedules.js +2 -0
- package/dashboard/js/render-skills.js +2 -0
- package/dashboard/js/render-work-items.js +2 -0
- package/dashboard/js/settings.js +2 -0
- package/dashboard/js/state.js +2 -0
- package/dashboard/js/utils.js +2 -0
- package/engine/cleanup.js +393 -0
- package/engine/dispatch.js +207 -0
- package/engine/timeout.js +280 -0
- package/engine.js +27 -757
- package/package.json +1 -1
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engine/dispatch.js — Dispatch queue management: add, complete, mutate, alerts.
|
|
3
|
+
* Extracted from engine.js for modularity. No logic changes.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const shared = require('./shared');
|
|
9
|
+
const queries = require('./queries');
|
|
10
|
+
const { setCooldownFailure } = require('./cooldown');
|
|
11
|
+
|
|
12
|
+
const { safeJson, safeWrite, safeReadDir, mutateJsonFileLocked,
|
|
13
|
+
getProjects, projectWorkItemsPath } = shared;
|
|
14
|
+
const { getConfig, getDispatch, DISPATCH_PATH, INBOX_DIR } = queries;
|
|
15
|
+
|
|
16
|
+
const MINIONS_DIR = shared.MINIONS_DIR;
|
|
17
|
+
|
|
18
|
+
// Lazy require to break circular dependency with engine.js
|
|
19
|
+
let _lifecycle = null;
|
|
20
|
+
function lifecycle() { if (!_lifecycle) _lifecycle = require('./lifecycle'); return _lifecycle; }
|
|
21
|
+
|
|
22
|
+
// ─── Engine utilities (lazy require to avoid circular deps) ──────────────────
|
|
23
|
+
let _engine = null;
|
|
24
|
+
function engine() { if (!_engine) _engine = require('../engine'); return _engine; }
|
|
25
|
+
function log(level, msg, meta) { return engine().log(level, msg, meta); }
|
|
26
|
+
function ts() { return engine().ts(); }
|
|
27
|
+
function dateStamp() { return new Date().toISOString().slice(0, 10); }
|
|
28
|
+
|
|
29
|
+
// ─── Dispatch Mutation ───────────────────────────────────────────────────────
|
|
30
|
+
|
|
31
|
+
function mutateDispatch(mutator) {
|
|
32
|
+
const defaultDispatch = { pending: [], active: [], completed: [] };
|
|
33
|
+
return mutateJsonFileLocked(DISPATCH_PATH, (dispatch) => {
|
|
34
|
+
dispatch.pending = Array.isArray(dispatch.pending) ? dispatch.pending : [];
|
|
35
|
+
dispatch.active = Array.isArray(dispatch.active) ? dispatch.active : [];
|
|
36
|
+
dispatch.completed = Array.isArray(dispatch.completed) ? dispatch.completed : [];
|
|
37
|
+
return mutator(dispatch) || dispatch;
|
|
38
|
+
}, { defaultValue: defaultDispatch });
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// ─── Add to Dispatch ─────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
function addToDispatch(item) {
|
|
44
|
+
item.id = item.id || `${item.agent}-${item.type}-${shared.uid()}`;
|
|
45
|
+
item.created_at = ts();
|
|
46
|
+
mutateDispatch((dispatch) => {
|
|
47
|
+
dispatch.pending.push(item);
|
|
48
|
+
});
|
|
49
|
+
log('info', `Queued dispatch: ${item.id} (${item.type} → ${item.agent})`);
|
|
50
|
+
return item.id;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ─── Retryable Failure Classification ────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
function isRetryableFailureReason(reason = '') {
|
|
56
|
+
const r = String(reason || '').toLowerCase();
|
|
57
|
+
if (!r) return true; // unknown error from tool exit — keep retryable
|
|
58
|
+
const nonRetryable = [
|
|
59
|
+
'no playbook rendered',
|
|
60
|
+
'failed to render',
|
|
61
|
+
'no target project available',
|
|
62
|
+
'no plan files found',
|
|
63
|
+
'plan file not found',
|
|
64
|
+
'invalid filename',
|
|
65
|
+
'invalid file path',
|
|
66
|
+
'missing required',
|
|
67
|
+
'validation failed',
|
|
68
|
+
];
|
|
69
|
+
return !nonRetryable.some(s => r.includes(s));
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ─── Complete Dispatch ───────────────────────────────────────────────────────
|
|
73
|
+
|
|
74
|
+
function completeDispatch(id, result = 'success', reason = '', resultSummary = '', opts = {}) {
|
|
75
|
+
const { processWorkItemFailure = true } = opts;
|
|
76
|
+
let item = null;
|
|
77
|
+
|
|
78
|
+
mutateDispatch((dispatch) => {
|
|
79
|
+
// Check active list first
|
|
80
|
+
let idx = dispatch.active.findIndex(d => d.id === id);
|
|
81
|
+
if (idx >= 0) {
|
|
82
|
+
item = dispatch.active.splice(idx, 1)[0];
|
|
83
|
+
} else {
|
|
84
|
+
// Also check pending list (e.g., worktree failure before spawn)
|
|
85
|
+
idx = dispatch.pending.findIndex(d => d.id === id);
|
|
86
|
+
if (idx >= 0) item = dispatch.pending.splice(idx, 1)[0];
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
if (!item) return;
|
|
90
|
+
item.completed_at = ts();
|
|
91
|
+
item.result = result;
|
|
92
|
+
if (reason) item.reason = reason;
|
|
93
|
+
if (resultSummary) item.resultSummary = resultSummary;
|
|
94
|
+
delete item.prompt;
|
|
95
|
+
if (dispatch.completed.length >= 100) {
|
|
96
|
+
dispatch.completed = dispatch.completed.slice(-99);
|
|
97
|
+
}
|
|
98
|
+
dispatch.completed.push(item);
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
if (item) {
|
|
102
|
+
log('info', `Completed dispatch: ${id} (${result}${reason ? ': ' + reason : ''})`);
|
|
103
|
+
|
|
104
|
+
// Update source work item status on failure + auto-retry with backoff
|
|
105
|
+
const retryableFailure = isRetryableFailureReason(reason);
|
|
106
|
+
if (result === 'error' && item.meta?.dispatchKey && retryableFailure) setCooldownFailure(item.meta.dispatchKey);
|
|
107
|
+
|
|
108
|
+
if (processWorkItemFailure && result === 'error' && item.meta?.item?.id) {
|
|
109
|
+
let retries = (item.meta.item._retryCount || 0);
|
|
110
|
+
try {
|
|
111
|
+
const wiPath = item.meta.source === 'central-work-item' || item.meta.source === 'central-work-item-fanout'
|
|
112
|
+
? path.join(MINIONS_DIR, 'work-items.json')
|
|
113
|
+
: item.meta.project?.name ? projectWorkItemsPath({ name: item.meta.project.name, localPath: item.meta.project.localPath }) : null;
|
|
114
|
+
if (wiPath) {
|
|
115
|
+
const items = safeJson(wiPath) || [];
|
|
116
|
+
const wi = items.find(i => i.id === item.meta.item.id);
|
|
117
|
+
if (wi) retries = wi._retryCount || 0;
|
|
118
|
+
}
|
|
119
|
+
} catch (e) { log('warn', 'read retry count: ' + e.message); }
|
|
120
|
+
if (retryableFailure && retries < 3) {
|
|
121
|
+
log('info', `Dispatch error for ${item.meta.item.id} — auto-retry ${retries + 1}/3`);
|
|
122
|
+
lifecycle().updateWorkItemStatus(item.meta, 'pending', '');
|
|
123
|
+
// Remove this dispatch key from completed so dedupe doesn't block immediate redispatch.
|
|
124
|
+
if (item.meta?.dispatchKey) {
|
|
125
|
+
try {
|
|
126
|
+
mutateDispatch((dp) => {
|
|
127
|
+
dp.completed = Array.isArray(dp.completed) ? dp.completed.filter(d => d.meta?.dispatchKey !== item.meta.dispatchKey) : [];
|
|
128
|
+
return dp;
|
|
129
|
+
});
|
|
130
|
+
} catch (e) { log('warn', 'clear dispatch for retry: ' + e.message); }
|
|
131
|
+
}
|
|
132
|
+
// Increment retry counter on the source work item
|
|
133
|
+
try {
|
|
134
|
+
const wiPath = item.meta.source === 'central-work-item' || item.meta.source === 'central-work-item-fanout'
|
|
135
|
+
? path.join(MINIONS_DIR, 'work-items.json')
|
|
136
|
+
: item.meta.project?.name ? projectWorkItemsPath({ name: item.meta.project.name, localPath: item.meta.project.localPath }) : null;
|
|
137
|
+
if (wiPath) {
|
|
138
|
+
const items = safeJson(wiPath) || [];
|
|
139
|
+
const wi = items.find(i => i.id === item.meta.item.id);
|
|
140
|
+
if (wi && wi.status !== 'paused') {
|
|
141
|
+
wi._retryCount = retries + 1;
|
|
142
|
+
wi.status = 'pending';
|
|
143
|
+
wi._lastRetryReason = reason || '';
|
|
144
|
+
wi._lastRetryAt = ts();
|
|
145
|
+
delete wi.failReason;
|
|
146
|
+
delete wi.failedAt;
|
|
147
|
+
delete wi.dispatched_at;
|
|
148
|
+
delete wi.dispatched_to;
|
|
149
|
+
safeWrite(wiPath, items);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
} catch (e) { log('warn', 'increment retry counter: ' + e.message); }
|
|
153
|
+
} else {
|
|
154
|
+
const finalReason = !retryableFailure
|
|
155
|
+
? `Non-retryable failure: ${reason || 'Unknown error'}`
|
|
156
|
+
: (reason || 'Failed after 3 retries');
|
|
157
|
+
lifecycle().updateWorkItemStatus(item.meta, 'failed', finalReason);
|
|
158
|
+
// Alert: find items blocked by this failure and write inbox note
|
|
159
|
+
try {
|
|
160
|
+
const config = getConfig();
|
|
161
|
+
const failedId = item.meta.item.id;
|
|
162
|
+
const blockedItems = [];
|
|
163
|
+
for (const p of getProjects(config)) {
|
|
164
|
+
const items = safeJson(projectWorkItemsPath(p)) || [];
|
|
165
|
+
items.filter(w => w.status === 'pending' && (w.depends_on || []).includes(failedId))
|
|
166
|
+
.forEach(w => blockedItems.push(`- \`${w.id}\` — ${w.title}`));
|
|
167
|
+
}
|
|
168
|
+
const centralItems = safeJson(path.join(MINIONS_DIR, 'work-items.json')) || [];
|
|
169
|
+
centralItems.filter(w => w.status === 'pending' && (w.depends_on || []).includes(failedId))
|
|
170
|
+
.forEach(w => blockedItems.push(`- \`${w.id}\` — ${w.title}`));
|
|
171
|
+
|
|
172
|
+
writeInboxAlert(`failed-${failedId}`,
|
|
173
|
+
`# Work Item Failed — \`${failedId}\`\n\n` +
|
|
174
|
+
`**Item:** ${item.meta.item.title || failedId}\n` +
|
|
175
|
+
`**Reason:** ${finalReason}\n\n` +
|
|
176
|
+
(blockedItems.length > 0
|
|
177
|
+
? `**Blocked dependents (${blockedItems.length}):**\n${blockedItems.join('\n')}\n\n` +
|
|
178
|
+
`These items cannot dispatch until \`${failedId}\` is fixed and reset to \`pending\`.\n`
|
|
179
|
+
: `No downstream items are blocked.\n`)
|
|
180
|
+
);
|
|
181
|
+
} catch (e) { log('warn', 'write failure alert: ' + e.message); }
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ─── Inbox Alert ─────────────────────────────────────────────────────────────
|
|
188
|
+
|
|
189
|
+
function writeInboxAlert(slug, content) {
|
|
190
|
+
try {
|
|
191
|
+
const file = path.join(INBOX_DIR, `engine-alert-${slug}-${dateStamp()}.md`);
|
|
192
|
+
// Dedupe: don't write the same alert twice in the same day
|
|
193
|
+
const existing = safeReadDir(INBOX_DIR).find(f => f.startsWith(`engine-alert-${slug}-${dateStamp()}`));
|
|
194
|
+
if (existing) return;
|
|
195
|
+
safeWrite(file, content);
|
|
196
|
+
} catch (e) { log('warn', 'write inbox alert: ' + e.message); }
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ─── Exports ─────────────────────────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
module.exports = {
|
|
202
|
+
mutateDispatch,
|
|
203
|
+
addToDispatch,
|
|
204
|
+
isRetryableFailureReason,
|
|
205
|
+
completeDispatch,
|
|
206
|
+
writeInboxAlert,
|
|
207
|
+
};
|
|
@@ -0,0 +1,280 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engine/timeout.js — Timeout detection, steering, and idle threshold checks.
|
|
3
|
+
* Extracted from engine.js for modularity. No logic changes.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const fs = require('fs');
|
|
7
|
+
const path = require('path');
|
|
8
|
+
const shared = require('./shared');
|
|
9
|
+
const queries = require('./queries');
|
|
10
|
+
|
|
11
|
+
const { safeRead, safeWrite, safeJson, getProjects, projectWorkItemsPath, ENGINE_DEFAULTS: DEFAULTS } = shared;
|
|
12
|
+
const { getDispatch, getAgentStatus } = queries;
|
|
13
|
+
const AGENTS_DIR = queries.AGENTS_DIR;
|
|
14
|
+
const MINIONS_DIR = shared.MINIONS_DIR;
|
|
15
|
+
|
|
16
|
+
// Lazy require to break circular dependency with engine.js
|
|
17
|
+
let _engine = null;
|
|
18
|
+
function engine() { if (!_engine) _engine = require('../engine'); return _engine; }
|
|
19
|
+
function log(level, msg, meta) { return engine().log(level, msg, meta); }
|
|
20
|
+
function ts() { return engine().ts(); }
|
|
21
|
+
|
|
22
|
+
// Lazy require for dispatch module (also circular via engine)
|
|
23
|
+
let _dispatch = null;
|
|
24
|
+
function dispatch() { if (!_dispatch) _dispatch = require('./dispatch'); return _dispatch; }
|
|
25
|
+
|
|
26
|
+
// ─── Idle Alert State ────────────────────────────────────────────────────────
|
|
27
|
+
|
|
28
|
+
let _lastActivityTime = Date.now();
|
|
29
|
+
let _idleAlertSent = false;
|
|
30
|
+
|
|
31
|
+
// ─── Idle Threshold Check ────────────────────────────────────────────────────
|
|
32
|
+
|
|
33
|
+
function checkIdleThreshold(config) {
|
|
34
|
+
const { isAgentIdle } = require('./routing');
|
|
35
|
+
const thresholdMs = (config.engine?.idleAlertMinutes || 15) * 60 * 1000;
|
|
36
|
+
const agents = Object.keys(config.agents || {});
|
|
37
|
+
const allIdle = agents.every(id => isAgentIdle(id));
|
|
38
|
+
const dispatchData = getDispatch();
|
|
39
|
+
const hasPending = (dispatchData.pending || []).length > 0;
|
|
40
|
+
|
|
41
|
+
if (!allIdle || hasPending) {
|
|
42
|
+
_lastActivityTime = Date.now();
|
|
43
|
+
_idleAlertSent = false;
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const idleMs = Date.now() - _lastActivityTime;
|
|
48
|
+
if (idleMs > thresholdMs && !_idleAlertSent) {
|
|
49
|
+
const mins = Math.round(idleMs / 60000);
|
|
50
|
+
log('warn', `All agents idle for ${mins} minutes — no work sources producing items`);
|
|
51
|
+
_idleAlertSent = true;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// ─── Steering Checker ────────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
function checkSteering(config) {
|
|
58
|
+
const activeProcesses = engine().activeProcesses;
|
|
59
|
+
for (const [id, info] of activeProcesses) {
|
|
60
|
+
const steerPath = path.join(AGENTS_DIR, info.agentId, 'steer.md');
|
|
61
|
+
if (!fs.existsSync(steerPath)) continue;
|
|
62
|
+
|
|
63
|
+
const message = safeRead(steerPath);
|
|
64
|
+
try { fs.unlinkSync(steerPath); } catch { /* cleanup */ }
|
|
65
|
+
if (!message) continue;
|
|
66
|
+
|
|
67
|
+
const sessionId = info.sessionId;
|
|
68
|
+
if (!sessionId) {
|
|
69
|
+
log('warn', `Steering: no sessionId for ${info.agentId} — cannot resume. Message dropped.`);
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
log('info', `Steering: killing ${info.agentId} (${id}) for session resume with human message`);
|
|
74
|
+
|
|
75
|
+
// Kill current process
|
|
76
|
+
try { info.proc.kill('SIGTERM'); } catch { /* process may be dead */ }
|
|
77
|
+
|
|
78
|
+
// Store steering context for re-spawn on close
|
|
79
|
+
info._steeringMessage = message;
|
|
80
|
+
info._steeringSessionId = sessionId;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// ─── Timeout Checker ─────────────────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
function checkTimeouts(config) {
|
|
87
|
+
const activeProcesses = engine().activeProcesses;
|
|
88
|
+
const engineRestartGraceUntil = engine().engineRestartGraceUntil;
|
|
89
|
+
const { completeDispatch } = dispatch();
|
|
90
|
+
const { runPostCompletionHooks } = require('./lifecycle');
|
|
91
|
+
|
|
92
|
+
const timeout = config.engine?.agentTimeout || DEFAULTS.agentTimeout;
|
|
93
|
+
const heartbeatTimeout = config.engine?.heartbeatTimeout || DEFAULTS.heartbeatTimeout;
|
|
94
|
+
|
|
95
|
+
// 1. Check tracked processes for hard timeout (supports per-item deadline from fan-out)
|
|
96
|
+
for (const [id, info] of activeProcesses.entries()) {
|
|
97
|
+
const itemTimeout = info.meta?.deadline ? Math.max(0, info.meta.deadline - new Date(info.startedAt).getTime()) : timeout;
|
|
98
|
+
const elapsed = Date.now() - new Date(info.startedAt).getTime();
|
|
99
|
+
if (elapsed > itemTimeout) {
|
|
100
|
+
log('warn', `Agent ${info.agentId} (${id}) hit hard timeout after ${Math.round(elapsed / 1000)}s — killing`);
|
|
101
|
+
try { info.proc.kill('SIGTERM'); } catch { /* process may be dead */ }
|
|
102
|
+
setTimeout(() => {
|
|
103
|
+
try { info.proc.kill('SIGKILL'); } catch { /* process may be dead */ }
|
|
104
|
+
}, 5000);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// 2. Heartbeat check — for ALL active dispatch items (catches orphans after engine restart)
|
|
109
|
+
// Uses live-output.log mtime as heartbeat. If no output for heartbeatTimeout, agent is dead.
|
|
110
|
+
const dispatchData = getDispatch();
|
|
111
|
+
const deadItems = [];
|
|
112
|
+
|
|
113
|
+
for (const item of (dispatchData.active || [])) {
|
|
114
|
+
if (!item.agent) continue;
|
|
115
|
+
|
|
116
|
+
const hasProcess = activeProcesses.has(item.id);
|
|
117
|
+
const liveLogPath = path.join(AGENTS_DIR, item.agent, 'live-output.log');
|
|
118
|
+
let lastActivity = item.started_at ? new Date(item.started_at).getTime() : 0;
|
|
119
|
+
|
|
120
|
+
// Check live-output.log mtime as heartbeat
|
|
121
|
+
try {
|
|
122
|
+
const stat = fs.statSync(liveLogPath);
|
|
123
|
+
lastActivity = Math.max(lastActivity, stat.mtimeMs);
|
|
124
|
+
} catch { /* optional */ }
|
|
125
|
+
|
|
126
|
+
const silentMs = Date.now() - lastActivity;
|
|
127
|
+
const silentSec = Math.round(silentMs / 1000);
|
|
128
|
+
|
|
129
|
+
// Check if the agent actually completed (result event in live output)
|
|
130
|
+
// Optimization: only read file if recent activity (avoids reading stale 1MB logs)
|
|
131
|
+
let completedViaOutput = false;
|
|
132
|
+
try {
|
|
133
|
+
if (silentMs > 600000) throw 'skip'; // No point reading a file silent for >10min
|
|
134
|
+
const liveLog = safeRead(liveLogPath);
|
|
135
|
+
if (liveLog && liveLog.includes('"type":"result"')) {
|
|
136
|
+
completedViaOutput = true;
|
|
137
|
+
const isSuccess = liveLog.includes('"subtype":"success"');
|
|
138
|
+
log('info', `Agent ${item.agent} (${item.id}) completed via output detection (${isSuccess ? 'success' : 'error'})`);
|
|
139
|
+
|
|
140
|
+
// Extract output text for the output.log
|
|
141
|
+
const outputLogPath = path.join(AGENTS_DIR, item.agent, 'output.log');
|
|
142
|
+
try {
|
|
143
|
+
const resultLine = liveLog.split('\n').find(l => l.includes('"type":"result"'));
|
|
144
|
+
if (resultLine) {
|
|
145
|
+
const result = JSON.parse(resultLine);
|
|
146
|
+
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`);
|
|
147
|
+
}
|
|
148
|
+
} catch (e) { log('warn', 'parse output result: ' + e.message); }
|
|
149
|
+
|
|
150
|
+
completeDispatch(item.id, isSuccess ? 'success' : 'error', 'Completed (detected from output)');
|
|
151
|
+
|
|
152
|
+
// Run post-completion hooks via shared helper
|
|
153
|
+
runPostCompletionHooks(item, item.agent, isSuccess ? 0 : 1, liveLog, config);
|
|
154
|
+
|
|
155
|
+
if (hasProcess) {
|
|
156
|
+
try { activeProcesses.get(item.id)?.proc.kill('SIGTERM'); } catch { /* process may be dead */ }
|
|
157
|
+
activeProcesses.delete(item.id);
|
|
158
|
+
}
|
|
159
|
+
continue; // Skip orphan/hung detection — we handled it
|
|
160
|
+
}
|
|
161
|
+
} catch (e) { log('warn', 'output completion detection: ' + e.message); }
|
|
162
|
+
|
|
163
|
+
// Check if agent is in a blocking tool call (TaskOutput block:true, Bash with long timeout, etc.)
|
|
164
|
+
// These tools produce no stdout for extended periods — don't kill them prematurely
|
|
165
|
+
// Check for BOTH tracked and untracked processes (orphan case after engine restart)
|
|
166
|
+
let isBlocking = false;
|
|
167
|
+
let blockingTimeout = heartbeatTimeout;
|
|
168
|
+
if (silentMs > heartbeatTimeout) {
|
|
169
|
+
try {
|
|
170
|
+
const liveLog = safeRead(liveLogPath);
|
|
171
|
+
if (liveLog) {
|
|
172
|
+
// Find the last tool_use call in the output — check if it's a known blocking tool
|
|
173
|
+
const lines = liveLog.split('\n');
|
|
174
|
+
for (let i = lines.length - 1; i >= Math.max(0, lines.length - 30); i--) {
|
|
175
|
+
const line = lines[i];
|
|
176
|
+
if (!line.includes('"tool_use"')) continue;
|
|
177
|
+
try {
|
|
178
|
+
const parsed = JSON.parse(line);
|
|
179
|
+
const toolUse = parsed?.message?.content?.find?.(c => c.type === 'tool_use');
|
|
180
|
+
if (!toolUse) continue;
|
|
181
|
+
const input = toolUse.input || {};
|
|
182
|
+
const name = toolUse.name || '';
|
|
183
|
+
// TaskOutput with block:true — waiting for a background task
|
|
184
|
+
if (name === 'TaskOutput' && input.block === true) {
|
|
185
|
+
const taskTimeout = input.timeout || 600000; // default 10min
|
|
186
|
+
blockingTimeout = Math.max(heartbeatTimeout, taskTimeout + 60000); // task timeout + 1min grace
|
|
187
|
+
isBlocking = true;
|
|
188
|
+
}
|
|
189
|
+
// Bash with explicit long timeout (>5min)
|
|
190
|
+
if (name === 'Bash' && input.timeout && input.timeout > heartbeatTimeout) {
|
|
191
|
+
blockingTimeout = Math.max(heartbeatTimeout, input.timeout + 60000);
|
|
192
|
+
isBlocking = true;
|
|
193
|
+
}
|
|
194
|
+
break; // only check the most recent tool_use
|
|
195
|
+
} catch { /* JSON parse — line may not be valid JSON */ }
|
|
196
|
+
}
|
|
197
|
+
if (isBlocking) {
|
|
198
|
+
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)`);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
} catch (e) { log('warn', 'blocking tool detection: ' + e.message); }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const effectiveTimeout = isBlocking ? blockingTimeout : heartbeatTimeout;
|
|
205
|
+
|
|
206
|
+
if (!hasProcess && silentMs > effectiveTimeout && Date.now() > engineRestartGraceUntil) {
|
|
207
|
+
// No tracked process AND no recent output past effective timeout AND grace period expired → orphaned
|
|
208
|
+
log('warn', `Orphan detected: ${item.agent} (${item.id}) — no process tracked, silent for ${silentSec}s${isBlocking ? ' (blocking timeout exceeded)' : ''}`);
|
|
209
|
+
deadItems.push({ item, reason: `Orphaned — no process, silent for ${silentSec}s` });
|
|
210
|
+
} else if (hasProcess && silentMs > effectiveTimeout) {
|
|
211
|
+
// Has process but no output past effective timeout → hung
|
|
212
|
+
log('warn', `Hung agent: ${item.agent} (${item.id}) — process exists but no output for ${silentSec}s${isBlocking ? ' (blocking timeout exceeded)' : ''}`);
|
|
213
|
+
const procInfo = activeProcesses.get(item.id);
|
|
214
|
+
if (procInfo) {
|
|
215
|
+
try { procInfo.proc.kill('SIGTERM'); } catch { /* process may be dead */ }
|
|
216
|
+
setTimeout(() => { try { procInfo.proc.kill('SIGKILL'); } catch { /* process may be dead */ } }, 5000);
|
|
217
|
+
activeProcesses.delete(item.id);
|
|
218
|
+
}
|
|
219
|
+
deadItems.push({ item, reason: `Hung — no output for ${silentSec}s` });
|
|
220
|
+
}
|
|
221
|
+
// If has process and recent output → healthy, let it run
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Clean up dead items
|
|
225
|
+
for (const { item, reason } of deadItems) {
|
|
226
|
+
completeDispatch(item.id, 'error', reason);
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
// Agent status is now derived from dispatch.json at read time (getAgentStatus).
|
|
230
|
+
// No reconcile sweep needed — dispatch IS the source of truth.
|
|
231
|
+
|
|
232
|
+
// Reconcile: find work items stuck in "dispatched" with no matching active dispatch
|
|
233
|
+
const activeKeys = new Set((dispatchData.active || []).map(d => d.meta?.dispatchKey).filter(Boolean));
|
|
234
|
+
const allWiPaths = [path.join(MINIONS_DIR, 'work-items.json')];
|
|
235
|
+
for (const project of getProjects(config)) {
|
|
236
|
+
allWiPaths.push(projectWorkItemsPath(project));
|
|
237
|
+
}
|
|
238
|
+
for (const wiPath of allWiPaths) {
|
|
239
|
+
const items = safeJson(wiPath);
|
|
240
|
+
if (!items || !Array.isArray(items)) continue;
|
|
241
|
+
let changed = false;
|
|
242
|
+
for (const item of items) {
|
|
243
|
+
if (item.status !== 'dispatched') continue;
|
|
244
|
+
// Check if any active dispatch references this item
|
|
245
|
+
// Dispatch keys include project name: work-{project}-{id} or central-work-{id}
|
|
246
|
+
const projectNames = getProjects(config).map(p => p.name);
|
|
247
|
+
const possibleKeys = [
|
|
248
|
+
`central-work-${item.id}`,
|
|
249
|
+
...projectNames.map(p => `work-${p}-${item.id}`),
|
|
250
|
+
];
|
|
251
|
+
const isActive = possibleKeys.some(k => activeKeys.has(k)) ||
|
|
252
|
+
(dispatchData.active || []).some(d => d.meta?.item?.id === item.id);
|
|
253
|
+
if (!isActive) {
|
|
254
|
+
const retries = (item._retryCount || 0);
|
|
255
|
+
if (retries < 3) {
|
|
256
|
+
log('info', `Reconcile: work item ${item.id} agent died — auto-retry ${retries + 1}/3`);
|
|
257
|
+
item.status = 'pending';
|
|
258
|
+
item._retryCount = retries + 1;
|
|
259
|
+
delete item.dispatched_at;
|
|
260
|
+
delete item.dispatched_to;
|
|
261
|
+
} else {
|
|
262
|
+
log('warn', `Reconcile: work item ${item.id} failed after ${retries} retries — marking as failed`);
|
|
263
|
+
item.status = 'failed';
|
|
264
|
+
item.failReason = 'Agent died or was killed (3 retries exhausted)';
|
|
265
|
+
item.failedAt = ts();
|
|
266
|
+
}
|
|
267
|
+
changed = true;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
if (changed) safeWrite(wiPath, items);
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
// ─── Exports ─────────────────────────────────────────────────────────────────
|
|
275
|
+
|
|
276
|
+
module.exports = {
|
|
277
|
+
checkTimeouts,
|
|
278
|
+
checkSteering,
|
|
279
|
+
checkIdleThreshold,
|
|
280
|
+
};
|