@yemi33/minions 0.1.59 → 0.1.61

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
+ };