@yemi33/minions 0.1.2066 → 0.1.2068
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/README.md +3 -3
- package/docs/runtime-adapters.md +20 -18
- package/engine/db/index.js +55 -8
- package/engine/db/migrations/002-dispatches.js +113 -0
- package/engine/db/migrations/003-work-items.js +128 -0
- package/engine/dispatch-store.js +316 -0
- package/engine/dispatch.js +50 -3
- package/engine/llm.js +4 -3
- package/engine/queries.js +47 -19
- package/engine/restart-health.js +8 -2
- package/engine/runtimes/codex.js +862 -0
- package/engine/runtimes/index.js +1 -0
- package/engine/shared.js +37 -8
- package/engine/work-items-store.js +351 -0
- package/package.json +1 -1
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
// engine/dispatch-store.js — SQL-backed implementation of the
|
|
2
|
+
// section-shaped dispatch object. Same external contract as the legacy
|
|
3
|
+
// dispatch.json reader:
|
|
4
|
+
//
|
|
5
|
+
// readDispatchSectioned() -> { pending, active, completed, review }
|
|
6
|
+
// applyDispatchMutation(fn) -> runs fn against the section object,
|
|
7
|
+
// computes a diff, applies INSERTs/UPDATEs/
|
|
8
|
+
// DELETEs in one transaction, returns the
|
|
9
|
+
// mutated object. Returns { wrote, result }
|
|
10
|
+
// so callers can suppress side effects
|
|
11
|
+
// (event emission, cache invalidation)
|
|
12
|
+
// when nothing actually changed.
|
|
13
|
+
//
|
|
14
|
+
// All callers go through engine/dispatch.js#mutateDispatch and
|
|
15
|
+
// engine/queries.js#getDispatch as before — those are thin wrappers
|
|
16
|
+
// over the helpers here. Zero changes at any call site.
|
|
17
|
+
|
|
18
|
+
const SECTIONS = ['pending', 'active', 'completed', 'review'];
|
|
19
|
+
|
|
20
|
+
function _emptySectioned() {
|
|
21
|
+
return { pending: [], active: [], completed: [], review: [] };
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function _toMs(v) {
|
|
25
|
+
if (v == null) return null;
|
|
26
|
+
if (typeof v === 'number') return Number.isFinite(v) ? v : null;
|
|
27
|
+
const parsed = Date.parse(v);
|
|
28
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function _parseRow(row) {
|
|
32
|
+
if (!row || !row.data) return null;
|
|
33
|
+
try { return JSON.parse(row.data); }
|
|
34
|
+
catch { return null; }
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function readDispatchSectioned() {
|
|
38
|
+
const { getDb } = require('./db');
|
|
39
|
+
let db;
|
|
40
|
+
try { db = getDb(); }
|
|
41
|
+
catch { return _readDispatchJsonFallback(); } // SQLite unavailable
|
|
42
|
+
|
|
43
|
+
// All rows, all statuses. Matches the legacy dispatch.json reader's
|
|
44
|
+
// semantics — returns the complete dispatch state regardless of how old
|
|
45
|
+
// completed entries are. Cross-reference consumers like state-integrity
|
|
46
|
+
// need the full completed list to match against work-items, and the
|
|
47
|
+
// dashboard's slim slice handles its own payload sizing downstream.
|
|
48
|
+
const rows = db.prepare(`
|
|
49
|
+
SELECT data, status FROM dispatches
|
|
50
|
+
ORDER BY status, created_at
|
|
51
|
+
`).all();
|
|
52
|
+
|
|
53
|
+
if (rows.length === 0) {
|
|
54
|
+
// SQL has no live dispatches. Two scenarios:
|
|
55
|
+
// (a) Production fresh install (or all pruned) — JSON file is empty/missing,
|
|
56
|
+
// fallback returns the same empty sectioned object. No harm.
|
|
57
|
+
// (b) Test that wrote dispatch.json directly (legacy test helpers in
|
|
58
|
+
// timeout-behavioral / orphan-* / etc. — they bypass the proper
|
|
59
|
+
// mutateDispatch API and seed via fs.writeFileSync). Picking up
|
|
60
|
+
// the JSON keeps those tests working through the transition without
|
|
61
|
+
// touching every helper. Phase 1.5 migrates them to the proper API.
|
|
62
|
+
const fallback = _readDispatchJsonFallback();
|
|
63
|
+
const hasContent = (
|
|
64
|
+
(fallback.pending && fallback.pending.length > 0) ||
|
|
65
|
+
(fallback.active && fallback.active.length > 0) ||
|
|
66
|
+
(fallback.completed && fallback.completed.length > 0) ||
|
|
67
|
+
(fallback.review && fallback.review.length > 0)
|
|
68
|
+
);
|
|
69
|
+
if (hasContent) return fallback;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const out = _emptySectioned();
|
|
73
|
+
for (const row of rows) {
|
|
74
|
+
const rec = _parseRow(row);
|
|
75
|
+
if (!rec) continue;
|
|
76
|
+
if (out[row.status]) out[row.status].push(rec);
|
|
77
|
+
}
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function _readDispatchJsonFallback() {
|
|
82
|
+
const path = require('path');
|
|
83
|
+
const fs = require('fs');
|
|
84
|
+
const shared = require('./shared');
|
|
85
|
+
const dispatchPath = path.join(shared.MINIONS_DIR, 'engine', 'dispatch.json');
|
|
86
|
+
let raw;
|
|
87
|
+
try {
|
|
88
|
+
raw = fs.readFileSync(dispatchPath, 'utf8');
|
|
89
|
+
} catch {
|
|
90
|
+
// ENOENT / permission / etc. — file genuinely absent. Silently
|
|
91
|
+
// return an empty sectioned object; that's the legacy semantics.
|
|
92
|
+
return _emptySectioned();
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
const parsed = JSON.parse(raw);
|
|
96
|
+
const out = _emptySectioned();
|
|
97
|
+
for (const s of SECTIONS) {
|
|
98
|
+
if (Array.isArray(parsed[s])) out[s] = parsed[s];
|
|
99
|
+
}
|
|
100
|
+
return out;
|
|
101
|
+
} catch (e) {
|
|
102
|
+
// File exists but parse failed — that's user-visible corruption.
|
|
103
|
+
// Surface to logs the same way the legacy queries.getDispatch path
|
|
104
|
+
// did via shared.readJsonNoRestore. (Phase 1 contract test
|
|
105
|
+
// "getDispatch warns on corrupt dispatch.json".)
|
|
106
|
+
try {
|
|
107
|
+
// eslint-disable-next-line no-console
|
|
108
|
+
console.warn(`[dispatch-store] corrupt JSON in ${dispatchPath}: ${e.message}`);
|
|
109
|
+
} catch { /* console may be wrapped in tests */ }
|
|
110
|
+
return _emptySectioned();
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// Internal: read the full (unfiltered) dispatch state from SQL.
|
|
115
|
+
// Used by applyDispatchMutation so the diff sees ALL completed rows,
|
|
116
|
+
// not just the recent window. Mutators that prune old completed
|
|
117
|
+
// dispatches MUST see those rows or the prune is a no-op.
|
|
118
|
+
function _readDispatchSectionedFull() {
|
|
119
|
+
const { getDb } = require('./db');
|
|
120
|
+
let db;
|
|
121
|
+
try { db = getDb(); }
|
|
122
|
+
catch { return _emptySectioned(); }
|
|
123
|
+
const rows = db.prepare('SELECT data, status FROM dispatches').all();
|
|
124
|
+
const out = _emptySectioned();
|
|
125
|
+
for (const row of rows) {
|
|
126
|
+
const rec = _parseRow(row);
|
|
127
|
+
if (!rec) continue;
|
|
128
|
+
if (out[row.status]) out[row.status].push(rec);
|
|
129
|
+
}
|
|
130
|
+
return out;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Build a flat { id -> { status, record } } map from a sectioned object.
|
|
134
|
+
function _indexBySection(sectioned) {
|
|
135
|
+
const out = new Map();
|
|
136
|
+
for (const status of SECTIONS) {
|
|
137
|
+
const rows = sectioned[status];
|
|
138
|
+
if (!Array.isArray(rows)) continue;
|
|
139
|
+
for (const rec of rows) {
|
|
140
|
+
if (!rec || !rec.id) continue;
|
|
141
|
+
out.set(String(rec.id), { status, record: rec });
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
return out;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// Compute the diff between before and after sectioned states.
|
|
148
|
+
// Returns { toUpsert: [{id, status, record}], toDelete: [id] }.
|
|
149
|
+
// toUpsert covers brand-new rows, status flips, and field changes.
|
|
150
|
+
// We compare JSON-stringified records to detect content edits; any
|
|
151
|
+
// change to ANY field flips the comparison and emits an UPSERT.
|
|
152
|
+
function _computeDispatchDiff(before, after) {
|
|
153
|
+
const beforeMap = _indexBySection(before);
|
|
154
|
+
const afterMap = _indexBySection(after);
|
|
155
|
+
const toUpsert = [];
|
|
156
|
+
const toDelete = [];
|
|
157
|
+
|
|
158
|
+
for (const [id, { status: afterStatus, record: afterRec }] of afterMap) {
|
|
159
|
+
const beforeEntry = beforeMap.get(id);
|
|
160
|
+
if (!beforeEntry) {
|
|
161
|
+
toUpsert.push({ id, status: afterStatus, record: afterRec });
|
|
162
|
+
continue;
|
|
163
|
+
}
|
|
164
|
+
const sameStatus = beforeEntry.status === afterStatus;
|
|
165
|
+
const sameContent = JSON.stringify(beforeEntry.record) === JSON.stringify(afterRec);
|
|
166
|
+
if (!sameStatus || !sameContent) {
|
|
167
|
+
toUpsert.push({ id, status: afterStatus, record: afterRec });
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
for (const [id] of beforeMap) {
|
|
171
|
+
if (!afterMap.has(id)) toDelete.push(id);
|
|
172
|
+
}
|
|
173
|
+
return { toUpsert, toDelete };
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function _applyDispatchDiff(db, diff) {
|
|
177
|
+
if (diff.toUpsert.length === 0 && diff.toDelete.length === 0) return false;
|
|
178
|
+
const now = Date.now();
|
|
179
|
+
const upsertStmt = db.prepare(`
|
|
180
|
+
INSERT INTO dispatches (id, status, agent, type, created_at, started_at, completed_at, data, updated_at)
|
|
181
|
+
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
182
|
+
ON CONFLICT(id) DO UPDATE SET
|
|
183
|
+
status = excluded.status,
|
|
184
|
+
agent = excluded.agent,
|
|
185
|
+
type = excluded.type,
|
|
186
|
+
created_at = excluded.created_at,
|
|
187
|
+
started_at = excluded.started_at,
|
|
188
|
+
completed_at = excluded.completed_at,
|
|
189
|
+
data = excluded.data,
|
|
190
|
+
updated_at = excluded.updated_at
|
|
191
|
+
`);
|
|
192
|
+
const deleteStmt = db.prepare('DELETE FROM dispatches WHERE id = ?');
|
|
193
|
+
|
|
194
|
+
for (const { id, status, record } of diff.toUpsert) {
|
|
195
|
+
upsertStmt.run(
|
|
196
|
+
String(id),
|
|
197
|
+
status,
|
|
198
|
+
record.agent || null,
|
|
199
|
+
record.type || null,
|
|
200
|
+
_toMs(record.created_at),
|
|
201
|
+
_toMs(record.started_at),
|
|
202
|
+
_toMs(record.completed_at),
|
|
203
|
+
JSON.stringify(record),
|
|
204
|
+
now,
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
for (const id of diff.toDelete) {
|
|
208
|
+
deleteStmt.run(String(id));
|
|
209
|
+
}
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Apply a mutation to the dispatch state. The mutator receives the
|
|
214
|
+
// section-shaped object, mutates it in place (or returns a replacement),
|
|
215
|
+
// and we diff against the pre-mutation snapshot to compute the SQL
|
|
216
|
+
// operations. Wrapped in a single transaction so the diff and the apply
|
|
217
|
+
// can't race against a concurrent reader/writer.
|
|
218
|
+
//
|
|
219
|
+
// Returns { wrote, result }:
|
|
220
|
+
// wrote — true iff at least one INSERT/UPDATE/DELETE landed.
|
|
221
|
+
// Callers (mutateDispatch) gate event emission + cache
|
|
222
|
+
// invalidation on this flag so no-op mutators don't bump
|
|
223
|
+
// the cache version (mirrors the onWrote contract from
|
|
224
|
+
// mutateJsonFileLocked).
|
|
225
|
+
// result — the mutator's return value (or the original object if the
|
|
226
|
+
// mutator mutated in place + returned undefined). Same
|
|
227
|
+
// contract as the legacy mutateJsonFileLocked-based path.
|
|
228
|
+
function applyDispatchMutation(mutator) {
|
|
229
|
+
const { getDb, withTransaction } = require('./db');
|
|
230
|
+
let db;
|
|
231
|
+
try { db = getDb(); }
|
|
232
|
+
catch (e) {
|
|
233
|
+
throw new Error(`engine/dispatch-store: SQLite unavailable (${e.message}); cannot mutate dispatch state`);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// withTransaction is nestable: shared.mutateWorkItems → mutator → cli
|
|
237
|
+
// codepath that itself calls mutateDispatch composes into a single
|
|
238
|
+
// outer BEGIN/COMMIT instead of throwing "transaction within transaction".
|
|
239
|
+
return withTransaction(db, () => {
|
|
240
|
+
const before = _readDispatchSectionedFull();
|
|
241
|
+
// Deep-clone before handing to the mutator so in-place mutations don't
|
|
242
|
+
// contaminate our snapshot. JSON round-trip is fine here — dispatch
|
|
243
|
+
// records are JSON-serializable by construction.
|
|
244
|
+
const beforeSnapshot = JSON.parse(JSON.stringify(before));
|
|
245
|
+
const next = mutator(before);
|
|
246
|
+
// Mutator may return a replacement, mutate in place + return undefined,
|
|
247
|
+
// or return a falsy value (legacy: same as undefined → use `before`).
|
|
248
|
+
const after = (next === undefined || next === null) ? before : next;
|
|
249
|
+
// Normalize: callers may return objects missing some sections (e.g.
|
|
250
|
+
// older code only touched pending). Fill in defaults so the diff
|
|
251
|
+
// doesn't mis-classify a missing section as "everything deleted".
|
|
252
|
+
for (const s of SECTIONS) {
|
|
253
|
+
if (!Array.isArray(after[s])) after[s] = beforeSnapshot[s] || [];
|
|
254
|
+
}
|
|
255
|
+
const diff = _computeDispatchDiff(beforeSnapshot, after);
|
|
256
|
+
const wrote = _applyDispatchDiff(db, diff);
|
|
257
|
+
return { wrote, result: after };
|
|
258
|
+
});
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Mirror the canonical SQL state back to dispatch.json after a successful
|
|
262
|
+
// mutation. Phase 1 keeps the JSON file as a *read-only derivative* of SQL
|
|
263
|
+
// — never an independent source of truth — so legacy callers that
|
|
264
|
+
// readFileSync the file directly (a couple of internal paths + the broader
|
|
265
|
+
// test suite) keep seeing consistent data. There's no drift opportunity
|
|
266
|
+
// because:
|
|
267
|
+
//
|
|
268
|
+
// - The mirror is always regenerated from the SQL table, never from
|
|
269
|
+
// in-memory state, so a write that lands transactionally in SQL but
|
|
270
|
+
// then crashes before the mirror is also written produces a stale
|
|
271
|
+
// mirror — but the SQL state is correct, and the next successful
|
|
272
|
+
// mutation overwrites the mirror.
|
|
273
|
+
// - No code path writes to dispatch.json independently anymore;
|
|
274
|
+
// mutateDispatch is the only writer.
|
|
275
|
+
//
|
|
276
|
+
// Phase 1.5 will remove the mirror once we've migrated the remaining
|
|
277
|
+
// direct-readers (engine/queries.js fallback path, engine/routing.js,
|
|
278
|
+
// the test infrastructure) to use readDispatchSectioned() / getDispatch().
|
|
279
|
+
function _mirrorJsonFromSql() {
|
|
280
|
+
try {
|
|
281
|
+
const path = require('path');
|
|
282
|
+
const fs = require('fs');
|
|
283
|
+
const shared = require('./shared');
|
|
284
|
+
// The full sectioned view (not the 24h-windowed one) so the JSON
|
|
285
|
+
// mirror matches what a legacy reader would have seen before Phase 1.
|
|
286
|
+
const full = _readDispatchSectionedFull();
|
|
287
|
+
const dispatchPath = path.join(shared.MINIONS_DIR, 'engine', 'dispatch.json');
|
|
288
|
+
// safeWrite handles the atomic rename + .backup sidecar. Best-effort —
|
|
289
|
+
// failure here doesn't propagate (SQL is source of truth, mirror is
|
|
290
|
+
// optional refresh material).
|
|
291
|
+
shared.safeWrite(dispatchPath, full);
|
|
292
|
+
// Re-derive what the parent shared.safeWrite would have done so an
|
|
293
|
+
// operator copying the file out gets matched fs.statSync mtimes
|
|
294
|
+
// (the dashboard's slow-state tracker watches this file as part of
|
|
295
|
+
// the dispatch path list). No-op if safeWrite already advanced mtime.
|
|
296
|
+
try { fs.utimesSync(dispatchPath, new Date(), new Date()); } catch { /* best-effort */ }
|
|
297
|
+
} catch {
|
|
298
|
+
// Mirror failures are non-fatal: SQL has already committed. Tests +
|
|
299
|
+
// legacy readers will see the previous mirror until the next write.
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// Test seam — clear the dispatches table. Production code never calls this.
|
|
304
|
+
function _resetDispatchTableForTest() {
|
|
305
|
+
const { getDb } = require('./db');
|
|
306
|
+
try { getDb().exec('DELETE FROM dispatches'); } catch { /* not initialized */ }
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
module.exports = {
|
|
310
|
+
readDispatchSectioned,
|
|
311
|
+
applyDispatchMutation,
|
|
312
|
+
_readDispatchSectionedFull,
|
|
313
|
+
_computeDispatchDiff,
|
|
314
|
+
_mirrorJsonFromSql,
|
|
315
|
+
_resetDispatchTableForTest,
|
|
316
|
+
};
|
package/engine/dispatch.js
CHANGED
|
@@ -58,7 +58,57 @@ function _sidecarChangedPrompts(dispatch, prevSnap) {
|
|
|
58
58
|
}
|
|
59
59
|
}
|
|
60
60
|
|
|
61
|
+
// SQL-backed mutator (Phase 1). Same external contract as the legacy
|
|
62
|
+
// JSON path: mutator receives a `{ pending, active, completed, review }`
|
|
63
|
+
// object, mutates in place (or returns a replacement), and the changes
|
|
64
|
+
// land transactionally in the dispatches table. The diff-then-apply trick
|
|
65
|
+
// in dispatch-store preserves every field via the `data` JSON column, so
|
|
66
|
+
// new dispatch fields the engine adds in future commits don't need any
|
|
67
|
+
// migration — they round-trip automatically.
|
|
68
|
+
//
|
|
69
|
+
// Fallback path: if SQLite is unavailable (Node < 22.5, the DB init failed,
|
|
70
|
+
// the table doesn't exist yet because the migration hasn't run) we fall
|
|
71
|
+
// back to the legacy mutateJsonFileLocked flow against dispatch.json.
|
|
72
|
+
// The migration leaves dispatch.json.pre-sql-<ts> on disk, so an operator
|
|
73
|
+
// pinning to an older release can rename it back.
|
|
61
74
|
function mutateDispatch(mutator) {
|
|
75
|
+
// Try the SQL path first.
|
|
76
|
+
try {
|
|
77
|
+
const store = require('./dispatch-store');
|
|
78
|
+
const { wrote, result } = store.applyDispatchMutation((dispatch) => {
|
|
79
|
+
dispatch.pending = Array.isArray(dispatch.pending) ? dispatch.pending : [];
|
|
80
|
+
dispatch.active = Array.isArray(dispatch.active) ? dispatch.active : [];
|
|
81
|
+
dispatch.completed = Array.isArray(dispatch.completed) ? dispatch.completed : [];
|
|
82
|
+
dispatch.review = Array.isArray(dispatch.review) ? dispatch.review : [];
|
|
83
|
+
const prevSnap = _snapshotPrompts(dispatch);
|
|
84
|
+
const next = mutator(dispatch) ?? dispatch;
|
|
85
|
+
// Prompt-size guard: only scan items whose prompt changed (or new items),
|
|
86
|
+
// so a 100-item status-flip doesn't re-byte-count every prompt.
|
|
87
|
+
_sidecarChangedPrompts(next, prevSnap);
|
|
88
|
+
return next;
|
|
89
|
+
});
|
|
90
|
+
if (wrote) {
|
|
91
|
+
try { require('./queries').invalidateDispatchCache(); } catch {}
|
|
92
|
+
try { require('./db-events').emitStateEvent('dispatch'); } catch { /* optional */ }
|
|
93
|
+
// Phase 1 dual-write: mirror the canonical SQL state to dispatch.json
|
|
94
|
+
// so legacy direct-readers (engine/queries.js fallback, engine/routing.js,
|
|
95
|
+
// test infrastructure that fs.readFileSync's the file) stay in sync.
|
|
96
|
+
// SQL is source of truth; the JSON file is regenerated from SQL on
|
|
97
|
+
// every successful mutation, never independently mutated.
|
|
98
|
+
try { store._mirrorJsonFromSql(); } catch { /* best-effort */ }
|
|
99
|
+
}
|
|
100
|
+
return result;
|
|
101
|
+
} catch (e) {
|
|
102
|
+
// Only fall back if the failure looks like a "DB not available" case,
|
|
103
|
+
// not a programming error. Surface mutator exceptions to the caller.
|
|
104
|
+
if (e && /SQLite unavailable|no such table|node:sqlite/.test(String(e.message))) {
|
|
105
|
+
// fall through to legacy JSON path below
|
|
106
|
+
} else {
|
|
107
|
+
throw e;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// Legacy JSON path (pre-Phase 1 fallback). Same code as before.
|
|
62
112
|
const defaultDispatch = { pending: [], active: [], completed: [] };
|
|
63
113
|
const result = mutateJsonFileLocked(DISPATCH_PATH, (dispatch) => {
|
|
64
114
|
dispatch.pending = Array.isArray(dispatch.pending) ? dispatch.pending : [];
|
|
@@ -66,8 +116,6 @@ function mutateDispatch(mutator) {
|
|
|
66
116
|
dispatch.completed = Array.isArray(dispatch.completed) ? dispatch.completed : [];
|
|
67
117
|
const prevSnap = _snapshotPrompts(dispatch);
|
|
68
118
|
const next = mutator(dispatch) ?? dispatch;
|
|
69
|
-
// Prompt-size guard: only scan items whose prompt changed (or new items),
|
|
70
|
-
// so a 100-item status-flip doesn't re-byte-count every prompt.
|
|
71
119
|
_sidecarChangedPrompts(next, prevSnap);
|
|
72
120
|
return next;
|
|
73
121
|
}, {
|
|
@@ -76,7 +124,6 @@ function mutateDispatch(mutator) {
|
|
|
76
124
|
try { require('./db-events').emitStateEvent('dispatch'); } catch { /* optional */ }
|
|
77
125
|
},
|
|
78
126
|
});
|
|
79
|
-
// Invalidate the read cache so next getDispatch() sees fresh data
|
|
80
127
|
try { require('./queries').invalidateDispatchCache(); } catch {}
|
|
81
128
|
return result;
|
|
82
129
|
}
|
package/engine/llm.js
CHANGED
|
@@ -224,6 +224,7 @@ function _missingRuntimeMessage(runtimeName, runtime, reason) {
|
|
|
224
224
|
'',
|
|
225
225
|
'Choose a supported runtime in Settings -> Engine -> defaultCli/ccCli, then install its CLI:',
|
|
226
226
|
'- Claude Code: npm install -g @anthropic-ai/claude-code or download from https://claude.ai/download',
|
|
227
|
+
'- OpenAI Codex CLI: npm install -g @openai/codex or brew install --cask codex',
|
|
227
228
|
'- GitHub Copilot CLI: install via GitHub CLI/gh-copilot or download from https://github.com/github/copilot-cli/releases',
|
|
228
229
|
'',
|
|
229
230
|
'After installing, restart Minions so the dashboard and engine inherit the updated PATH.',
|
|
@@ -238,10 +239,10 @@ function _missingRuntimeMessage(runtimeName, runtime, reason) {
|
|
|
238
239
|
'',
|
|
239
240
|
'After installing, restart Minions so the dashboard and engine inherit the updated PATH.',
|
|
240
241
|
];
|
|
241
|
-
if (selected
|
|
242
|
-
lines.push('Or switch Settings -> Engine -> defaultCli/ccCli
|
|
242
|
+
if (selected === 'claude') {
|
|
243
|
+
lines.push('Or switch Settings -> Engine -> defaultCli/ccCli to "copilot" or "codex" after installing another supported runtime.');
|
|
243
244
|
} else {
|
|
244
|
-
lines.push('Or switch Settings -> Engine -> defaultCli/ccCli to "
|
|
245
|
+
lines.push('Or switch Settings -> Engine -> defaultCli/ccCli back to "claude" after installing Claude Code.');
|
|
245
246
|
}
|
|
246
247
|
return lines.join('\n');
|
|
247
248
|
}
|
package/engine/queries.js
CHANGED
|
@@ -174,10 +174,24 @@ function getControl() {
|
|
|
174
174
|
let _dispatchCache = null;
|
|
175
175
|
let _dispatchCacheAt = 0;
|
|
176
176
|
function getDispatch() {
|
|
177
|
-
// Short-lived cache —
|
|
177
|
+
// Short-lived cache — getDispatch is called 10+ times per tick (per-agent
|
|
178
|
+
// status derivation, work-item PR enrichment, etc.) but only changes on
|
|
179
|
+
// mutateDispatch. SQL reads are sub-millisecond, so the cache is now
|
|
180
|
+
// mostly a per-request memo rather than the disk-read amortizer it used
|
|
181
|
+
// to be when dispatch.json could be 1+ MB.
|
|
178
182
|
const now = Date.now();
|
|
179
183
|
if (_dispatchCache && (now - _dispatchCacheAt) < 2000) return _dispatchCache;
|
|
180
|
-
|
|
184
|
+
// Try SQL first (post-Phase 1). If the table doesn't exist (greenfield
|
|
185
|
+
// install, Node < 22.5, or DB init failed) fall back to the legacy JSON
|
|
186
|
+
// reader. The migration leaves dispatch.json.pre-sql-<ts> on disk so
|
|
187
|
+
// operators rolling back to a pre-migration release can rename it back.
|
|
188
|
+
try {
|
|
189
|
+
const store = require('./dispatch-store');
|
|
190
|
+
const sectioned = store.readDispatchSectioned();
|
|
191
|
+
_dispatchCache = sectioned;
|
|
192
|
+
} catch {
|
|
193
|
+
_dispatchCache = readJsonNoRestore(DISPATCH_PATH) || { pending: [], active: [], completed: [] };
|
|
194
|
+
}
|
|
181
195
|
_dispatchCacheAt = now;
|
|
182
196
|
return _dispatchCache;
|
|
183
197
|
}
|
|
@@ -1247,30 +1261,44 @@ function getWorkItems(config) {
|
|
|
1247
1261
|
if (!_workItemsMtimesDiffer(_workItemsCacheMtimes, currMtimes)) return _workItemsCache;
|
|
1248
1262
|
}
|
|
1249
1263
|
const projects = getProjects(config);
|
|
1250
|
-
|
|
1264
|
+
let allItems = [];
|
|
1251
1265
|
|
|
1252
|
-
//
|
|
1253
|
-
|
|
1254
|
-
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
1266
|
+
// Phase 2: prefer the SQL store. Returns every scope's items tagged
|
|
1267
|
+
// with `_source = <scope>`, matching the legacy decoration. Empty SQL
|
|
1268
|
+
// table OR a SQLite failure falls through to the JSON path below so a
|
|
1269
|
+
// fresh install + a node:sqlite-broken environment both keep working.
|
|
1270
|
+
try {
|
|
1271
|
+
const store = require('./work-items-store');
|
|
1272
|
+
const sqlItems = store.readAllWorkItems();
|
|
1273
|
+
if (Array.isArray(sqlItems) && sqlItems.length > 0) {
|
|
1274
|
+
allItems = sqlItems;
|
|
1275
|
+
}
|
|
1276
|
+
} catch { /* fall through to JSON */ }
|
|
1262
1277
|
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
const
|
|
1266
|
-
if (
|
|
1278
|
+
if (allItems.length === 0) {
|
|
1279
|
+
// Central work items
|
|
1280
|
+
const centralData = safeRead(path.join(MINIONS_DIR, 'work-items.json'));
|
|
1281
|
+
if (centralData) {
|
|
1267
1282
|
try {
|
|
1268
|
-
for (const item of JSON.parse(
|
|
1269
|
-
item._source =
|
|
1283
|
+
for (const item of JSON.parse(centralData)) {
|
|
1284
|
+
item._source = 'central';
|
|
1270
1285
|
allItems.push(item);
|
|
1271
1286
|
}
|
|
1272
1287
|
} catch {}
|
|
1273
1288
|
}
|
|
1289
|
+
|
|
1290
|
+
// Per-project work items
|
|
1291
|
+
for (const project of projects) {
|
|
1292
|
+
const data = safeRead(projectWorkItemsPath(project));
|
|
1293
|
+
if (data) {
|
|
1294
|
+
try {
|
|
1295
|
+
for (const item of JSON.parse(data)) {
|
|
1296
|
+
item._source = project.name || 'project';
|
|
1297
|
+
allItems.push(item);
|
|
1298
|
+
}
|
|
1299
|
+
} catch {}
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1274
1302
|
}
|
|
1275
1303
|
|
|
1276
1304
|
// Cross-reference with dispatch (fill in agent from active dispatch if missing on work item)
|
package/engine/restart-health.js
CHANGED
|
@@ -55,10 +55,16 @@ function isPortListening(port) {
|
|
|
55
55
|
const re6 = new RegExp(`\\s\\[::1?\\]:${n}\\s+\\S+\\s+LISTENING`, 'i');
|
|
56
56
|
return re.test(out) || re6.test(out);
|
|
57
57
|
}
|
|
58
|
-
|
|
58
|
+
try {
|
|
59
|
+
const out = execSync(`lsof -nP -iTCP:${n} -sTCP:LISTEN`, {
|
|
60
|
+
encoding: 'utf8', timeout: 3000,
|
|
61
|
+
});
|
|
62
|
+
if (/\bLISTEN\b/i.test(out)) return true;
|
|
63
|
+
} catch {}
|
|
64
|
+
const out = execSync(`ss -ltn 'sport = :${n}' 2>/dev/null || netstat -ltn 2>/dev/null || netstat -an -p tcp 2>/dev/null`, {
|
|
59
65
|
encoding: 'utf8', timeout: 3000, shell: true,
|
|
60
66
|
});
|
|
61
|
-
return new RegExp(`[:.]${n}\\b
|
|
67
|
+
return new RegExp(`(?:[:.])${n}\\b[^\\n]*\\b(?:LISTEN|LISTENING)\\b`, 'i').test(out);
|
|
62
68
|
} catch { return false; }
|
|
63
69
|
}
|
|
64
70
|
|