@yemi33/minions 0.1.532 → 0.1.534
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 +6 -0
- package/engine/ado.js +13 -12
- package/engine/github.js +15 -15
- package/engine/lifecycle.js +4 -4
- package/engine/pipeline.js +1 -2
- package/engine/shared.js +27 -1
- package/engine/timeout.js +2 -2
- package/engine.js +107 -59
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
package/engine/ado.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
|
|
6
6
|
const path = require('path');
|
|
7
7
|
const shared = require('./shared');
|
|
8
|
-
const { exec, getAdoOrgBase, addPrLink, log, ts, dateStamp, PR_STATUS } = shared;
|
|
8
|
+
const { exec, execAsync, getAdoOrgBase, addPrLink, log, ts, dateStamp, PR_STATUS } = shared;
|
|
9
9
|
const { getPrs } = require('./queries');
|
|
10
10
|
const { mutateJsonFileLocked } = shared;
|
|
11
11
|
|
|
@@ -21,7 +21,7 @@ function engine() {
|
|
|
21
21
|
let _adoTokenCache = { token: null, expiresAt: 0 };
|
|
22
22
|
let _adoTokenFailedUntil = 0; // backoff: skip azureauth calls until this timestamp
|
|
23
23
|
|
|
24
|
-
function getAdoToken() {
|
|
24
|
+
async function getAdoToken() {
|
|
25
25
|
if (_adoTokenCache.token && Date.now() < _adoTokenCache.expiresAt) {
|
|
26
26
|
return _adoTokenCache.token;
|
|
27
27
|
}
|
|
@@ -30,8 +30,9 @@ function getAdoToken() {
|
|
|
30
30
|
try {
|
|
31
31
|
// azureauth supports multiple --mode flags as an ordered fallback chain:
|
|
32
32
|
// tries IWA (Integrated Windows Auth) first, falls back to broker if unavailable.
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
// Uses execAsync to avoid blocking the event loop on Windows (spawnSync ETIMEDOUT).
|
|
34
|
+
const token = (await execAsync('azureauth ado token --mode iwa --mode broker --output token --timeout 1', {
|
|
35
|
+
timeout: 15000, encoding: 'utf-8', windowsHide: true })).trim();
|
|
35
36
|
if (token && token.startsWith('eyJ')) {
|
|
36
37
|
_adoTokenCache = { token, expiresAt: Date.now() + 30 * 60 * 1000 };
|
|
37
38
|
_adoTokenFailedUntil = 0;
|
|
@@ -56,7 +57,7 @@ async function adoFetch(url, token, _retryCount = 0) {
|
|
|
56
57
|
// Invalidate cached token — it's likely expired
|
|
57
58
|
_adoTokenCache = { token: null, expiresAt: 0 };
|
|
58
59
|
if (_retryCount < MAX_RETRIES) {
|
|
59
|
-
const freshToken = getAdoToken();
|
|
60
|
+
const freshToken = await getAdoToken();
|
|
60
61
|
if (freshToken) {
|
|
61
62
|
log('info', 'ADO token expired mid-session — refreshed and retrying');
|
|
62
63
|
return adoFetch(url, freshToken, _retryCount + 1);
|
|
@@ -132,7 +133,7 @@ async function forEachActivePr(config, token, callback) {
|
|
|
132
133
|
// ─── PR Status Polling ───────────────────────────────────────────────────────
|
|
133
134
|
|
|
134
135
|
async function pollPrStatus(config) {
|
|
135
|
-
const token = getAdoToken();
|
|
136
|
+
const token = await getAdoToken();
|
|
136
137
|
if (!token) {
|
|
137
138
|
log('warn', 'Skipping PR status poll — no ADO token available');
|
|
138
139
|
return;
|
|
@@ -287,7 +288,7 @@ async function pollPrStatus(config) {
|
|
|
287
288
|
// ─── Poll Human Comments on PRs ──────────────────────────────────────────────
|
|
288
289
|
|
|
289
290
|
async function pollPrHumanComments(config) {
|
|
290
|
-
const token = getAdoToken();
|
|
291
|
+
const token = await getAdoToken();
|
|
291
292
|
if (!token) return;
|
|
292
293
|
|
|
293
294
|
const totalUpdated = await forEachActivePr(config, token, async (project, pr, prNum, orgBase) => {
|
|
@@ -361,7 +362,7 @@ async function pollPrHumanComments(config) {
|
|
|
361
362
|
* in pull-requests.json, and add them. Matches PRs to work items by branch name.
|
|
362
363
|
*/
|
|
363
364
|
async function reconcilePrs(config) {
|
|
364
|
-
const token = getAdoToken();
|
|
365
|
+
const token = await getAdoToken();
|
|
365
366
|
if (!token) {
|
|
366
367
|
log('warn', 'Skipping PR reconciliation — no ADO token available');
|
|
367
368
|
return;
|
|
@@ -486,19 +487,19 @@ async function reconcilePrs(config) {
|
|
|
486
487
|
}
|
|
487
488
|
|
|
488
489
|
/**
|
|
489
|
-
* Fetch live review status for a single PR from ADO (
|
|
490
|
+
* Fetch live review status for a single PR from ADO (async).
|
|
490
491
|
* Returns 'approved', 'changes-requested', 'waiting', or 'pending'.
|
|
491
492
|
* Returns null if the check fails (token unavailable, API error).
|
|
492
493
|
* Used as a pre-dispatch gate to avoid dispatching reviews for already-approved PRs.
|
|
493
494
|
*/
|
|
494
|
-
function checkLiveReviewStatus(pr, project) {
|
|
495
|
+
async function checkLiveReviewStatus(pr, project) {
|
|
495
496
|
try {
|
|
496
|
-
const token = getAdoToken();
|
|
497
|
+
const token = await getAdoToken();
|
|
497
498
|
if (!token) return null;
|
|
498
499
|
const orgBase = shared.getAdoOrgBase(project);
|
|
499
500
|
const prNum = (pr.id || '').replace(/^PR-/, '');
|
|
500
501
|
const url = `${orgBase}/${project.adoProject}/_apis/git/repositories/${project.repositoryId}/pullrequests/${prNum}?api-version=7.1`;
|
|
501
|
-
const result =
|
|
502
|
+
const result = await execAsync(`curl -s --max-time 4 -H "Authorization: Bearer ${token}" "${url}"`, { encoding: 'utf-8', timeout: 5000, windowsHide: true });
|
|
502
503
|
const prData = JSON.parse(result);
|
|
503
504
|
const votes = (prData.reviewers || []).map(r => r.vote).filter(v => v !== undefined);
|
|
504
505
|
if (votes.length === 0) return 'pending';
|
package/engine/github.js
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
const shared = require('./shared');
|
|
8
|
-
const { exec, getProjects, projectPrPath, projectWorkItemsPath, safeJson, safeWrite, mutateJsonFileLocked, MINIONS_DIR, addPrLink, getPrLinks, log, ts, dateStamp, PR_STATUS } = shared;
|
|
8
|
+
const { exec, execAsync, getProjects, projectPrPath, projectWorkItemsPath, safeJson, safeWrite, mutateJsonFileLocked, MINIONS_DIR, addPrLink, getPrLinks, log, ts, dateStamp, PR_STATUS } = shared;
|
|
9
9
|
const { getPrs } = require('./queries');
|
|
10
10
|
const path = require('path');
|
|
11
11
|
|
|
@@ -69,10 +69,10 @@ function resetSlugBackoff(slug) {
|
|
|
69
69
|
}
|
|
70
70
|
|
|
71
71
|
/** Run a `gh api` call and parse JSON result. Returns null on failure. */
|
|
72
|
-
function ghApi(endpoint, slug) {
|
|
72
|
+
async function ghApi(endpoint, slug) {
|
|
73
73
|
try {
|
|
74
74
|
const cmd = `gh api "repos/${slug}${endpoint}"`;
|
|
75
|
-
const result =
|
|
75
|
+
const result = await execAsync(cmd, { timeout: 30000, encoding: 'utf-8' });
|
|
76
76
|
return JSON.parse(result);
|
|
77
77
|
} catch (e) {
|
|
78
78
|
log('warn', `GitHub API error (${endpoint}): ${e.message}`);
|
|
@@ -84,8 +84,8 @@ function ghApi(endpoint, slug) {
|
|
|
84
84
|
* Run a `gh api` call with per-slug backoff tracking. Returns null on failure.
|
|
85
85
|
* On success, resets the slug's backoff. On failure, increments it.
|
|
86
86
|
*/
|
|
87
|
-
function ghApiWithBackoff(endpoint, slug) {
|
|
88
|
-
const result = ghApi(endpoint, slug);
|
|
87
|
+
async function ghApiWithBackoff(endpoint, slug) {
|
|
88
|
+
const result = await ghApi(endpoint, slug);
|
|
89
89
|
if (result === null) {
|
|
90
90
|
recordSlugFailure(slug);
|
|
91
91
|
} else {
|
|
@@ -112,7 +112,7 @@ async function forEachActiveGhPr(config, callback) {
|
|
|
112
112
|
if (activePrs.length === 0) continue;
|
|
113
113
|
|
|
114
114
|
// Probe repo accessibility before iterating PRs — avoids N warnings per inaccessible repo
|
|
115
|
-
const probe = ghApi('', slug);
|
|
115
|
+
const probe = await ghApi('', slug);
|
|
116
116
|
if (probe === null) {
|
|
117
117
|
recordSlugFailure(slug);
|
|
118
118
|
continue;
|
|
@@ -171,7 +171,7 @@ async function forEachActiveGhPr(config, callback) {
|
|
|
171
171
|
if (updated) {
|
|
172
172
|
// Also update title/author/branch if still placeholder
|
|
173
173
|
if (pr.title.includes('polling...') || pr.agent === 'human') {
|
|
174
|
-
const prData = ghApi(`/pulls/${prNum}`, slug);
|
|
174
|
+
const prData = await ghApi(`/pulls/${prNum}`, slug);
|
|
175
175
|
if (prData) {
|
|
176
176
|
if (pr.title.includes('polling...')) pr.title = (prData.title || pr.title).slice(0, 120);
|
|
177
177
|
if (pr.agent === 'human' && prData.user?.login) pr.agent = prData.user.login;
|
|
@@ -203,7 +203,7 @@ async function forEachActiveGhPr(config, callback) {
|
|
|
203
203
|
|
|
204
204
|
async function pollPrStatus(config) {
|
|
205
205
|
const totalUpdated = await forEachActiveGhPr(config, async (project, pr, prNum, slug) => {
|
|
206
|
-
const prData = ghApi(`/pulls/${prNum}`, slug);
|
|
206
|
+
const prData = await ghApi(`/pulls/${prNum}`, slug);
|
|
207
207
|
if (!prData) return false;
|
|
208
208
|
|
|
209
209
|
let updated = false;
|
|
@@ -243,7 +243,7 @@ async function pollPrStatus(config) {
|
|
|
243
243
|
}
|
|
244
244
|
|
|
245
245
|
// Review status from GitHub reviews
|
|
246
|
-
const reviews = ghApi(`/pulls/${prNum}/reviews`, slug);
|
|
246
|
+
const reviews = await ghApi(`/pulls/${prNum}/reviews`, slug);
|
|
247
247
|
if (reviews && Array.isArray(reviews)) {
|
|
248
248
|
// Get latest review per user
|
|
249
249
|
const latestByUser = new Map();
|
|
@@ -306,7 +306,7 @@ async function pollPrStatus(config) {
|
|
|
306
306
|
|
|
307
307
|
// Check status / checks
|
|
308
308
|
if (prData.state === 'open' && prData.head?.sha) {
|
|
309
|
-
const checksData = ghApi(`/commits/${prData.head.sha}/check-runs`, slug);
|
|
309
|
+
const checksData = await ghApi(`/commits/${prData.head.sha}/check-runs`, slug);
|
|
310
310
|
if (checksData && checksData.check_runs) {
|
|
311
311
|
const runs = checksData.check_runs;
|
|
312
312
|
let buildStatus = 'none';
|
|
@@ -352,11 +352,11 @@ async function pollPrStatus(config) {
|
|
|
352
352
|
async function pollPrHumanComments(config) {
|
|
353
353
|
const totalUpdated = await forEachActiveGhPr(config, async (project, pr, prNum, slug) => {
|
|
354
354
|
// Get issue comments (general PR comments)
|
|
355
|
-
const comments = ghApi(`/issues/${prNum}/comments`, slug);
|
|
355
|
+
const comments = await ghApi(`/issues/${prNum}/comments`, slug);
|
|
356
356
|
if (!comments || !Array.isArray(comments)) return false;
|
|
357
357
|
|
|
358
358
|
// Also get review comments (inline code comments)
|
|
359
|
-
const reviewComments = ghApi(`/pulls/${prNum}/comments`, slug);
|
|
359
|
+
const reviewComments = await ghApi(`/pulls/${prNum}/comments`, slug);
|
|
360
360
|
const allComments = [
|
|
361
361
|
...(comments || []).map(c => ({ ...c, _type: 'issue' })),
|
|
362
362
|
...(Array.isArray(reviewComments) ? reviewComments : []).map(c => ({ ...c, _type: 'review' }))
|
|
@@ -438,7 +438,7 @@ async function reconcilePrs(config) {
|
|
|
438
438
|
if (isSlugInBackoff(slug)) continue;
|
|
439
439
|
|
|
440
440
|
// Fetch open PRs
|
|
441
|
-
const prsData = ghApi('/pulls?state=open&per_page=100', slug);
|
|
441
|
+
const prsData = await ghApi('/pulls?state=open&per_page=100', slug);
|
|
442
442
|
if (!prsData || !Array.isArray(prsData)) {
|
|
443
443
|
recordSlugFailure(slug);
|
|
444
444
|
continue;
|
|
@@ -541,12 +541,12 @@ async function reconcilePrs(config) {
|
|
|
541
541
|
* Fetch live review status for a single PR from GitHub. Returns 'approved', 'changes-requested',
|
|
542
542
|
* 'waiting', or 'pending'. Returns null if the check fails.
|
|
543
543
|
*/
|
|
544
|
-
function checkLiveReviewStatus(pr, project) {
|
|
544
|
+
async function checkLiveReviewStatus(pr, project) {
|
|
545
545
|
try {
|
|
546
546
|
const slug = getRepoSlug(project);
|
|
547
547
|
if (!slug) return null;
|
|
548
548
|
const prNum = (pr.id || '').replace(/^PR-/, '');
|
|
549
|
-
const reviews = ghApi(`/pulls/${prNum}/reviews`, slug);
|
|
549
|
+
const reviews = await ghApi(`/pulls/${prNum}/reviews`, slug);
|
|
550
550
|
if (!reviews || !Array.isArray(reviews)) return null;
|
|
551
551
|
const latestByUser = new Map();
|
|
552
552
|
for (const r of reviews) {
|
package/engine/lifecycle.js
CHANGED
|
@@ -689,7 +689,7 @@ function syncPrsFromOutput(output, agentId, meta, config) {
|
|
|
689
689
|
|
|
690
690
|
// ─── Post-Completion Hooks ──────────────────────────────────────────────────
|
|
691
691
|
|
|
692
|
-
function updatePrAfterReview(agentId, pr, project, config) {
|
|
692
|
+
async function updatePrAfterReview(agentId, pr, project, config) {
|
|
693
693
|
|
|
694
694
|
if (!pr?.id) return;
|
|
695
695
|
|
|
@@ -707,7 +707,7 @@ function updatePrAfterReview(agentId, pr, project, config) {
|
|
|
707
707
|
const checkFn = host === 'github'
|
|
708
708
|
? require('./github').checkLiveReviewStatus
|
|
709
709
|
: require('./ado').checkLiveReviewStatus;
|
|
710
|
-
const liveStatus = checkFn(pr, projectObj);
|
|
710
|
+
const liveStatus = await checkFn(pr, projectObj);
|
|
711
711
|
// Use live status only if it's a decisive verdict (not 'pending' — review may not have propagated yet)
|
|
712
712
|
if (liveStatus && liveStatus !== 'pending') postReviewStatus = liveStatus;
|
|
713
713
|
}
|
|
@@ -1142,7 +1142,7 @@ function handleDecompositionResult(stdout, meta, config) {
|
|
|
1142
1142
|
return 0;
|
|
1143
1143
|
}
|
|
1144
1144
|
|
|
1145
|
-
function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
|
|
1145
|
+
async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
|
|
1146
1146
|
|
|
1147
1147
|
const type = dispatchItem.type;
|
|
1148
1148
|
const meta = dispatchItem.meta;
|
|
@@ -1319,7 +1319,7 @@ function runPostCompletionHooks(dispatchItem, agentId, code, stdout, config) {
|
|
|
1319
1319
|
}
|
|
1320
1320
|
}
|
|
1321
1321
|
|
|
1322
|
-
if (type === WORK_TYPE.REVIEW) updatePrAfterReview(agentId, meta?.pr, meta?.project, config);
|
|
1322
|
+
if (type === WORK_TYPE.REVIEW) await updatePrAfterReview(agentId, meta?.pr, meta?.project, config);
|
|
1323
1323
|
if (type === WORK_TYPE.FIX) updatePrAfterFix(meta?.pr, meta?.project, meta?.source);
|
|
1324
1324
|
checkForLearnings(agentId, config.agents[agentId], dispatchItem.task);
|
|
1325
1325
|
if (effectiveSuccess) {
|
package/engine/pipeline.js
CHANGED
|
@@ -184,7 +184,6 @@ function executeTaskStage(stage, stageState, run, config) {
|
|
|
184
184
|
createdIds.push(id);
|
|
185
185
|
}
|
|
186
186
|
});
|
|
187
|
-
|
|
188
187
|
return { status: PIPELINE_STATUS.RUNNING, artifacts: { workItems: createdIds } };
|
|
189
188
|
}
|
|
190
189
|
|
|
@@ -278,7 +277,7 @@ async function executePlanStage(stage, stageState, run, config) {
|
|
|
278
277
|
|
|
279
278
|
safeWrite(filePath, content);
|
|
280
279
|
|
|
281
|
-
// Create plan-to-prd work item
|
|
280
|
+
// Create plan-to-prd work item — atomic write to prevent race with dispatch status updates
|
|
282
281
|
const wiPath = path.join(__dirname, '..', 'work-items.json');
|
|
283
282
|
const wiId = `PL-${run.runId.slice(4, 12)}-${stage.id}-prd`;
|
|
284
283
|
mutateWorkItems(wiPath, workItems => {
|
package/engine/shared.js
CHANGED
|
@@ -304,7 +304,7 @@ function writeToInbox(agentId, slug, content, _inboxDir) {
|
|
|
304
304
|
// ── Process Spawning ────────────────────────────────────────────────────────
|
|
305
305
|
// All child process calls go through these to ensure windowsHide: true
|
|
306
306
|
|
|
307
|
-
const { execSync: _execSync, spawnSync: _spawnSync, spawn: _spawn } = require('child_process');
|
|
307
|
+
const { execSync: _execSync, spawnSync: _spawnSync, spawn: _spawn, exec: _cbExec } = require('child_process');
|
|
308
308
|
|
|
309
309
|
function exec(cmd, opts = {}) {
|
|
310
310
|
return _execSync(cmd, { windowsHide: true, ...opts });
|
|
@@ -322,6 +322,31 @@ function execSilent(cmd, opts = {}) {
|
|
|
322
322
|
return _execSync(cmd, { stdio: 'pipe', windowsHide: true, ...opts });
|
|
323
323
|
}
|
|
324
324
|
|
|
325
|
+
/**
|
|
326
|
+
* Async version of exec() — runs a shell command without blocking the event loop.
|
|
327
|
+
* Returns a Promise that resolves with { stdout, stderr } or rejects on error/timeout.
|
|
328
|
+
* Drop-in replacement for sync `exec()` in async contexts.
|
|
329
|
+
*
|
|
330
|
+
* @param {string} cmd - Shell command to run
|
|
331
|
+
* @param {object} opts - Options (same as child_process.exec: timeout, cwd, encoding, env, etc.)
|
|
332
|
+
* @returns {Promise<string>} stdout (trimmed if encoding is set)
|
|
333
|
+
*/
|
|
334
|
+
function execAsync(cmd, opts = {}) {
|
|
335
|
+
const { timeout, ...rest } = opts;
|
|
336
|
+
return new Promise((resolve, reject) => {
|
|
337
|
+
const child = _cbExec(cmd, { windowsHide: true, encoding: 'utf8', ...rest, timeout: timeout || 30000 }, (err, stdout, stderr) => {
|
|
338
|
+
if (err) {
|
|
339
|
+
err.stderr = stderr;
|
|
340
|
+
err.stdout = stdout;
|
|
341
|
+
return reject(err);
|
|
342
|
+
}
|
|
343
|
+
resolve(stdout);
|
|
344
|
+
});
|
|
345
|
+
// Safety: ensure child is killed if parent process exits
|
|
346
|
+
child.unref && child.unref();
|
|
347
|
+
});
|
|
348
|
+
}
|
|
349
|
+
|
|
325
350
|
/**
|
|
326
351
|
* Detect the default branch for a git repo. Tries in order:
|
|
327
352
|
* 1. The configured mainBranch (if it exists as a local or remote ref)
|
|
@@ -787,6 +812,7 @@ module.exports = {
|
|
|
787
812
|
uniquePath,
|
|
788
813
|
writeToInbox,
|
|
789
814
|
exec,
|
|
815
|
+
execAsync,
|
|
790
816
|
execSilent,
|
|
791
817
|
resolveMainBranch,
|
|
792
818
|
run,
|
package/engine/timeout.js
CHANGED
|
@@ -149,8 +149,8 @@ function checkTimeouts(config) {
|
|
|
149
149
|
|
|
150
150
|
completeDispatch(item.id, isSuccess ? DISPATCH_RESULT.SUCCESS : DISPATCH_RESULT.ERROR, 'Completed (detected from output)');
|
|
151
151
|
|
|
152
|
-
// Run post-completion hooks via shared helper
|
|
153
|
-
runPostCompletionHooks(item, item.agent, isSuccess ? 0 : 1, liveLog, config);
|
|
152
|
+
// Run post-completion hooks via shared helper (async — fire and forget in timeout context)
|
|
153
|
+
runPostCompletionHooks(item, item.agent, isSuccess ? 0 : 1, liveLog, config).catch(e => log('warn', 'post-completion hooks: ' + e.message));
|
|
154
154
|
|
|
155
155
|
if (hasProcess) {
|
|
156
156
|
shared.killImmediate(activeProcesses.get(item.id)?.proc);
|
package/engine.js
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
const fs = require('fs');
|
|
25
25
|
const path = require('path');
|
|
26
26
|
const shared = require('./engine/shared');
|
|
27
|
-
const { exec, execSilent, runFile, ts, ENGINE_DEFAULTS: DEFAULTS,
|
|
27
|
+
const { exec, execAsync, execSilent, runFile, ts, ENGINE_DEFAULTS: DEFAULTS,
|
|
28
28
|
WI_STATUS, DONE_STATUSES, WORK_TYPE, PLAN_STATUS, PR_STATUS, DISPATCH_RESULT } = shared;
|
|
29
29
|
const queries = require('./engine/queries');
|
|
30
30
|
|
|
@@ -141,6 +141,10 @@ const activeProcesses = new Map(); // dispatchId → { proc, agentId, startedAt
|
|
|
141
141
|
// tempAgents imported from engine/routing.js
|
|
142
142
|
let engineRestartGraceUntil = 0; // timestamp — suppress orphan detection until this time
|
|
143
143
|
|
|
144
|
+
// Per-tick cache of refs that failed to fetch — avoids repeating 30s ETIMEDOUT for same missing ref
|
|
145
|
+
// Cleared at the start of each tick cycle (see tickInner)
|
|
146
|
+
const _failedRefCache = new Set();
|
|
147
|
+
|
|
144
148
|
// Resolve dependency plan item IDs to their PR branches
|
|
145
149
|
function resolveDependencyBranches(depIds, sourcePlan, project, config) {
|
|
146
150
|
const results = []; // [{ branch, prId }]
|
|
@@ -171,9 +175,9 @@ function resolveDependencyBranches(depIds, sourcePlan, project, config) {
|
|
|
171
175
|
}
|
|
172
176
|
|
|
173
177
|
// Find an existing worktree already checked out on a given branch
|
|
174
|
-
function findExistingWorktree(repoDir, branchName) {
|
|
178
|
+
async function findExistingWorktree(repoDir, branchName) {
|
|
175
179
|
try {
|
|
176
|
-
const out =
|
|
180
|
+
const out = await execAsync(`git worktree list --porcelain`, { cwd: repoDir, timeout: 10000 });
|
|
177
181
|
const branchRef = `branch refs/heads/${branchName}`;
|
|
178
182
|
const lines = out.split('\n');
|
|
179
183
|
for (let i = 0; i < lines.length; i++) {
|
|
@@ -215,17 +219,17 @@ function removeStaleIndexLock(rootDir) {
|
|
|
215
219
|
} catch (e) { log('warn', 'git: ' + e.message); }
|
|
216
220
|
}
|
|
217
221
|
|
|
218
|
-
function runWorktreeAdd(rootDir, worktreePath, args, gitOpts, worktreeCreateRetries) {
|
|
222
|
+
async function runWorktreeAdd(rootDir, worktreePath, args, gitOpts, worktreeCreateRetries) {
|
|
219
223
|
let lastErr = null;
|
|
220
224
|
const retries = Math.max(0, Number(worktreeCreateRetries) || 0);
|
|
221
225
|
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
222
226
|
try {
|
|
223
227
|
if (attempt > 0) {
|
|
224
|
-
try {
|
|
228
|
+
try { await execAsync('git worktree prune', { ...gitOpts, cwd: rootDir, timeout: 15000 }); } catch (e) { log('warn', 'git: ' + e.message); }
|
|
225
229
|
removeStaleIndexLock(rootDir);
|
|
226
230
|
log('warn', `Retrying git worktree add (attempt ${attempt + 1}/${retries + 1}) for ${path.basename(worktreePath)}`);
|
|
227
231
|
}
|
|
228
|
-
|
|
232
|
+
await execAsync(`git worktree add "${worktreePath}" ${args}`, { ...gitOpts, cwd: rootDir });
|
|
229
233
|
return;
|
|
230
234
|
} catch (err) {
|
|
231
235
|
lastErr = err;
|
|
@@ -235,14 +239,14 @@ function runWorktreeAdd(rootDir, worktreePath, args, gitOpts, worktreeCreateRetr
|
|
|
235
239
|
if (lastErr) throw lastErr;
|
|
236
240
|
}
|
|
237
241
|
|
|
238
|
-
function recoverPartialWorktree(rootDir, worktreePath, branchName, gitOpts) {
|
|
242
|
+
async function recoverPartialWorktree(rootDir, worktreePath, branchName, gitOpts) {
|
|
239
243
|
if (!branchName) return false;
|
|
240
|
-
const existingWt = findExistingWorktree(rootDir, branchName);
|
|
244
|
+
const existingWt = await findExistingWorktree(rootDir, branchName);
|
|
241
245
|
if (existingWt && fs.existsSync(existingWt)) return true;
|
|
242
246
|
if (!fs.existsSync(worktreePath)) return false;
|
|
243
247
|
try {
|
|
244
|
-
|
|
245
|
-
|
|
248
|
+
await execAsync(`git -C "${worktreePath}" rev-parse --is-inside-work-tree`, { ...gitOpts, timeout: 10000 });
|
|
249
|
+
await execAsync(`git -C "${worktreePath}" rev-parse --abbrev-ref HEAD`, { ...gitOpts, timeout: 10000 });
|
|
246
250
|
log('warn', `Recovered partially-created worktree for ${branchName} at ${worktreePath}`);
|
|
247
251
|
return true;
|
|
248
252
|
} catch {
|
|
@@ -250,7 +254,7 @@ function recoverPartialWorktree(rootDir, worktreePath, branchName, gitOpts) {
|
|
|
250
254
|
}
|
|
251
255
|
}
|
|
252
256
|
|
|
253
|
-
function spawnAgent(dispatchItem, config) {
|
|
257
|
+
async function spawnAgent(dispatchItem, config) {
|
|
254
258
|
const { id, agent: agentId, prompt: taskPrompt, type, meta } = dispatchItem;
|
|
255
259
|
const claudeConfig = config.claude || {};
|
|
256
260
|
const engineConfig = config.engine || {};
|
|
@@ -279,12 +283,12 @@ function spawnAgent(dispatchItem, config) {
|
|
|
279
283
|
worktreePath = path.resolve(rootDir, engineConfig.worktreeRoot || '../worktrees', wtDirName);
|
|
280
284
|
|
|
281
285
|
// If branch is already checked out in an existing worktree, reuse it
|
|
282
|
-
const existingWt = findExistingWorktree(rootDir, branchName);
|
|
286
|
+
const existingWt = await findExistingWorktree(rootDir, branchName);
|
|
283
287
|
if (existingWt) {
|
|
284
288
|
worktreePath = existingWt;
|
|
285
289
|
log('info', `Reusing existing worktree for ${branchName}: ${existingWt}`);
|
|
286
|
-
try {
|
|
287
|
-
try {
|
|
290
|
+
try { await execAsync(`git fetch origin "${branchName}"`, { ..._gitOpts, cwd: rootDir }); } catch (e) { log('warn', 'git: ' + e.message); }
|
|
291
|
+
try { await execAsync(`git pull origin "${branchName}"`, { ..._gitOpts, cwd: existingWt }); } catch (e) { log('warn', 'git: ' + e.message); }
|
|
288
292
|
} else if (['meeting', 'ask', 'explore', 'plan-to-prd', 'plan'].includes(type)) {
|
|
289
293
|
// Read-only tasks — no worktree needed, run in rootDir
|
|
290
294
|
log('info', `${type}: read-only task, no worktree needed — running in rootDir`);
|
|
@@ -295,18 +299,18 @@ function spawnAgent(dispatchItem, config) {
|
|
|
295
299
|
if (!fs.existsSync(worktreePath)) {
|
|
296
300
|
const isSharedBranch = meta?.branchStrategy === 'shared-branch' || meta?.useExistingBranch;
|
|
297
301
|
// Prune stale worktree entries before creating (handles leftover entries from crashed runs)
|
|
298
|
-
try {
|
|
302
|
+
try { await execAsync(`git worktree prune`, { ..._gitOpts, cwd: rootDir, timeout: 10000 }); } catch (e) { log('warn', 'git: ' + e.message); }
|
|
299
303
|
// Remove stale index.lock before creating worktree (Windows crashes can leave this behind)
|
|
300
304
|
removeStaleIndexLock(rootDir);
|
|
301
305
|
|
|
302
306
|
if (isSharedBranch) {
|
|
303
307
|
log('info', `Creating worktree for shared branch: ${worktreePath} on ${branchName}`);
|
|
304
|
-
try {
|
|
308
|
+
try { await execAsync(`git fetch origin "${branchName}"`, { ..._gitOpts, cwd: rootDir }); } catch (e) { log('warn', 'git: ' + e.message); }
|
|
305
309
|
try {
|
|
306
|
-
runWorktreeAdd(rootDir, worktreePath, `"${branchName}"`, _worktreeGitOpts, worktreeCreateRetries);
|
|
310
|
+
await runWorktreeAdd(rootDir, worktreePath, `"${branchName}"`, _worktreeGitOpts, worktreeCreateRetries);
|
|
307
311
|
} catch (eShared) {
|
|
308
312
|
if (eShared.message?.includes('already used by worktree') || eShared.message?.includes('already checked out')) {
|
|
309
|
-
const existingWtPath = findExistingWorktree(rootDir, branchName);
|
|
313
|
+
const existingWtPath = await findExistingWorktree(rootDir, branchName);
|
|
310
314
|
if (existingWtPath && fs.existsSync(existingWtPath)) {
|
|
311
315
|
log('info', `Shared branch ${branchName} already checked out at ${existingWtPath} — reusing`);
|
|
312
316
|
worktreePath = existingWtPath;
|
|
@@ -315,42 +319,42 @@ function spawnAgent(dispatchItem, config) {
|
|
|
315
319
|
// Branch doesn't exist yet (first item in plan) — create it from main
|
|
316
320
|
const mainRef = sanitizeBranch(shared.resolveMainBranch(rootDir, project.mainBranch));
|
|
317
321
|
log('info', `Shared branch ${branchName} not found — creating from ${mainRef}`);
|
|
318
|
-
runWorktreeAdd(rootDir, worktreePath, `-b "${branchName}" ${mainRef}`, _worktreeGitOpts, worktreeCreateRetries);
|
|
322
|
+
await runWorktreeAdd(rootDir, worktreePath, `-b "${branchName}" ${mainRef}`, _worktreeGitOpts, worktreeCreateRetries);
|
|
319
323
|
} else { throw eShared; }
|
|
320
324
|
}
|
|
321
325
|
} else {
|
|
322
326
|
log('info', `Creating worktree: ${worktreePath} on branch ${branchName}`);
|
|
323
327
|
const mainRef = sanitizeBranch(shared.resolveMainBranch(rootDir, project.mainBranch));
|
|
324
328
|
try {
|
|
325
|
-
runWorktreeAdd(rootDir, worktreePath, `-b "${branchName}" ${mainRef}`, _worktreeGitOpts, worktreeCreateRetries);
|
|
329
|
+
await runWorktreeAdd(rootDir, worktreePath, `-b "${branchName}" ${mainRef}`, _worktreeGitOpts, worktreeCreateRetries);
|
|
326
330
|
} catch (e1) {
|
|
327
331
|
const branchExists = e1.message?.includes('already exists');
|
|
328
332
|
log('warn', `Worktree -b failed for ${branchName}: ${e1.message?.split('\n')[0]}`);
|
|
329
333
|
if (!branchExists) {
|
|
330
334
|
// Transient error (lock, timeout) — prune, clean, and retry -b once more
|
|
331
335
|
log('info', `Retrying -b create after prune for ${branchName}`);
|
|
332
|
-
try {
|
|
336
|
+
try { await execAsync(`git worktree prune`, { ..._gitOpts, cwd: rootDir, timeout: 15000 }); } catch { /* optional */ }
|
|
333
337
|
removeStaleIndexLock(rootDir);
|
|
334
338
|
// Clean up partial worktree directory from failed attempt
|
|
335
339
|
try { if (fs.existsSync(worktreePath)) fs.rmSync(worktreePath, { recursive: true, force: true }); } catch { /* optional */ }
|
|
336
340
|
try {
|
|
337
|
-
runWorktreeAdd(rootDir, worktreePath, `-b "${branchName}" ${mainRef}`, _worktreeGitOpts, 0);
|
|
341
|
+
await runWorktreeAdd(rootDir, worktreePath, `-b "${branchName}" ${mainRef}`, _worktreeGitOpts, 0);
|
|
338
342
|
} catch (e1b) {
|
|
339
343
|
log('error', `Worktree -b retry also failed for ${branchName}: ${e1b.message?.split('\n')[0]}`);
|
|
340
344
|
throw e1b;
|
|
341
345
|
}
|
|
342
346
|
} else {
|
|
343
347
|
// Branch already exists — try checkout without -b
|
|
344
|
-
try {
|
|
348
|
+
try { await execAsync(`git fetch origin "${branchName}"`, { ..._gitOpts, cwd: rootDir }); } catch (e) { log('warn', 'git: ' + e.message); }
|
|
345
349
|
try {
|
|
346
|
-
runWorktreeAdd(rootDir, worktreePath, `"${branchName}"`, _worktreeGitOpts, worktreeCreateRetries);
|
|
350
|
+
await runWorktreeAdd(rootDir, worktreePath, `"${branchName}"`, _worktreeGitOpts, worktreeCreateRetries);
|
|
347
351
|
log('info', `Reusing existing branch: ${branchName}`);
|
|
348
352
|
} catch (e2) {
|
|
349
353
|
// "already checked out" or "already used by worktree" — find and reuse or recover
|
|
350
354
|
const alreadyUsed = e2.message?.includes('already checked out') || e2.message?.includes('already used by worktree')
|
|
351
355
|
|| e1.message?.includes('already checked out') || e1.message?.includes('already used by worktree');
|
|
352
356
|
if (alreadyUsed) {
|
|
353
|
-
const existingWtPath = findExistingWorktree(rootDir, branchName);
|
|
357
|
+
const existingWtPath = await findExistingWorktree(rootDir, branchName);
|
|
354
358
|
if (existingWtPath && fs.existsSync(existingWtPath)) {
|
|
355
359
|
// Bug fix: read dispatch under file lock so check-and-act is atomic
|
|
356
360
|
let activelyUsed = false;
|
|
@@ -369,12 +373,12 @@ function spawnAgent(dispatchItem, config) {
|
|
|
369
373
|
worktreePath = existingWtPath;
|
|
370
374
|
} else if (existingWtPath && !fs.existsSync(existingWtPath)) {
|
|
371
375
|
log('warn', `Branch ${branchName} tracked in missing dir ${existingWtPath} — pruning and recreating`);
|
|
372
|
-
try {
|
|
373
|
-
runWorktreeAdd(rootDir, worktreePath, `"${branchName}"`, _worktreeGitOpts, worktreeCreateRetries);
|
|
376
|
+
try { await execAsync(`git worktree prune`, { ..._gitOpts, cwd: rootDir, timeout: 10000 }); } catch (e) { log('warn', 'git: ' + e.message); }
|
|
377
|
+
await runWorktreeAdd(rootDir, worktreePath, `"${branchName}"`, _worktreeGitOpts, worktreeCreateRetries);
|
|
374
378
|
log('info', `Recovered worktree for ${branchName} after stale entry prune`);
|
|
375
379
|
} else {
|
|
376
|
-
try {
|
|
377
|
-
runWorktreeAdd(rootDir, worktreePath, `"${branchName}"`, _worktreeGitOpts, worktreeCreateRetries);
|
|
380
|
+
try { await execAsync(`git worktree prune`, { ..._gitOpts, cwd: rootDir, timeout: 10000 }); } catch (e) { log('warn', 'git: ' + e.message); }
|
|
381
|
+
await runWorktreeAdd(rootDir, worktreePath, `"${branchName}"`, _worktreeGitOpts, worktreeCreateRetries);
|
|
378
382
|
}
|
|
379
383
|
} else {
|
|
380
384
|
throw e2;
|
|
@@ -385,10 +389,10 @@ function spawnAgent(dispatchItem, config) {
|
|
|
385
389
|
}
|
|
386
390
|
} else if (meta?.branchStrategy === 'shared-branch') {
|
|
387
391
|
log('info', `Pulling latest on shared branch ${branchName}`);
|
|
388
|
-
try {
|
|
392
|
+
try { await execAsync(`git pull origin "${branchName}"`, { ..._gitOpts, cwd: worktreePath }); } catch (e) { log('warn', 'git: ' + e.message); }
|
|
389
393
|
}
|
|
390
394
|
} catch (err) {
|
|
391
|
-
if (recoverPartialWorktree(rootDir, worktreePath, branchName, _gitOpts)) {
|
|
395
|
+
if (await recoverPartialWorktree(rootDir, worktreePath, branchName, _gitOpts)) {
|
|
392
396
|
cwd = worktreePath;
|
|
393
397
|
log('warn', `Proceeding with recovered worktree after add failure for ${branchName}`);
|
|
394
398
|
} else {
|
|
@@ -407,11 +411,17 @@ function spawnAgent(dispatchItem, config) {
|
|
|
407
411
|
try {
|
|
408
412
|
const depBranches = resolveDependencyBranches(depIds, meta?.item?.sourcePlan, project, config);
|
|
409
413
|
for (const { branch: depBranch, prId } of depBranches) {
|
|
414
|
+
// Skip refs already known to be missing this tick (avoids repeated 30s ETIMEDOUT)
|
|
415
|
+
if (_failedRefCache.has(depBranch)) {
|
|
416
|
+
log('warn', `Skipping dependency ${depBranch} — already failed to fetch this tick`);
|
|
417
|
+
continue;
|
|
418
|
+
}
|
|
410
419
|
try {
|
|
411
|
-
|
|
412
|
-
|
|
420
|
+
await execAsync(`git fetch origin "${depBranch}"`, { ..._gitOpts, cwd: rootDir });
|
|
421
|
+
await execAsync(`git merge "origin/${depBranch}" --no-edit`, { ..._gitOpts, cwd: worktreePath });
|
|
413
422
|
log('info', `Merged dependency branch ${depBranch} (${prId}) into worktree ${branchName}`);
|
|
414
423
|
} catch (mergeErr) {
|
|
424
|
+
_failedRefCache.add(depBranch);
|
|
415
425
|
log('warn', `Failed to merge dependency ${depBranch} into ${branchName}: ${mergeErr.message}`);
|
|
416
426
|
}
|
|
417
427
|
}
|
|
@@ -704,7 +714,7 @@ function spawnAgent(dispatchItem, config) {
|
|
|
704
714
|
}
|
|
705
715
|
|
|
706
716
|
// Parse output and run all post-completion hooks
|
|
707
|
-
const { resultSummary, autoRecovered } = runPostCompletionHooks(dispatchItem, agentId, code, stdout, config);
|
|
717
|
+
const { resultSummary, autoRecovered } = await runPostCompletionHooks(dispatchItem, agentId, code, stdout, config);
|
|
708
718
|
|
|
709
719
|
// Move from active to completed in dispatch (single source of truth for agent status)
|
|
710
720
|
// autoRecovered: agent failed (e.g. heartbeat timeout) but created PRs — treat as success
|
|
@@ -1929,13 +1939,26 @@ function discoverCentralWorkItems(config) {
|
|
|
1929
1939
|
const items = safeJson(centralPath) || [];
|
|
1930
1940
|
const projects = getProjects(config);
|
|
1931
1941
|
const newWork = [];
|
|
1942
|
+
// Collect mutations to apply atomically inside lock callback (avoids TOCTOU)
|
|
1943
|
+
const mutations = new Map(); // item.id → { field: value, ... }
|
|
1932
1944
|
|
|
1933
1945
|
for (const item of items) {
|
|
1934
1946
|
try {
|
|
1935
1947
|
if (item.status !== WI_STATUS.QUEUED && item.status !== WI_STATUS.PENDING) continue;
|
|
1936
1948
|
|
|
1937
1949
|
const key = `central-work-${item.id}`;
|
|
1938
|
-
if
|
|
1950
|
+
// Self-heal: if already dispatched but work item is still pending, fix the status
|
|
1951
|
+
if (isAlreadyDispatched(key)) {
|
|
1952
|
+
const m = {};
|
|
1953
|
+
if (item.status === WI_STATUS.PENDING) { m.status = WI_STATUS.DISPATCHED; }
|
|
1954
|
+
if (!item.dispatched_to) {
|
|
1955
|
+
const existing = getDispatch().active?.find(d => d.meta?.dispatchKey === key);
|
|
1956
|
+
if (existing?.agent) { m.dispatched_to = existing.agent; }
|
|
1957
|
+
}
|
|
1958
|
+
if (Object.keys(m).length > 0) mutations.set(item.id, m);
|
|
1959
|
+
continue;
|
|
1960
|
+
}
|
|
1961
|
+
if (isOnCooldown(key, 0)) continue;
|
|
1939
1962
|
|
|
1940
1963
|
const workType = item.type || 'implement';
|
|
1941
1964
|
const isFanOut = item.scope === 'fan-out';
|
|
@@ -2019,11 +2042,13 @@ function discoverCentralWorkItems(config) {
|
|
|
2019
2042
|
});
|
|
2020
2043
|
}
|
|
2021
2044
|
|
|
2022
|
-
item.
|
|
2023
|
-
|
|
2024
|
-
|
|
2025
|
-
|
|
2026
|
-
|
|
2045
|
+
mutations.set(item.id, {
|
|
2046
|
+
status: WI_STATUS.DISPATCHED,
|
|
2047
|
+
dispatched_at: ts(),
|
|
2048
|
+
dispatched_to: idleAgents.map(a => a.id).join(', '),
|
|
2049
|
+
scope: 'fan-out',
|
|
2050
|
+
fanOutAgents: idleAgents.map(a => a.id),
|
|
2051
|
+
});
|
|
2027
2052
|
setCooldown(key);
|
|
2028
2053
|
log('info', `Fan-out: ${item.id} dispatched to ${idleAgents.length} agents: ${idleAgents.map(a => a.name).join(', ')}`);
|
|
2029
2054
|
|
|
@@ -2075,11 +2100,10 @@ function discoverCentralWorkItems(config) {
|
|
|
2075
2100
|
const cpCount = (item._checkpointCount || 0) + 1;
|
|
2076
2101
|
if (cpCount > 3) {
|
|
2077
2102
|
log('warn', `Work item ${item.id} exceeded 3 checkpoint-resumes — marking as needs-human-review`);
|
|
2078
|
-
item.status
|
|
2079
|
-
item._checkpointCount = cpCount;
|
|
2103
|
+
mutations.set(item.id, { status: WI_STATUS.NEEDS_REVIEW, _checkpointCount: cpCount });
|
|
2080
2104
|
continue;
|
|
2081
2105
|
}
|
|
2082
|
-
item._checkpointCount
|
|
2106
|
+
mutations.set(item.id, Object.assign(mutations.get(item.id) || {}, { _checkpointCount: cpCount }));
|
|
2083
2107
|
const cpSummary = [
|
|
2084
2108
|
`## Checkpoint (Resume #${cpCount}/3)`,
|
|
2085
2109
|
'',
|
|
@@ -2107,7 +2131,7 @@ function discoverCentralWorkItems(config) {
|
|
|
2107
2131
|
vars.notes_content = '';
|
|
2108
2132
|
try { vars.notes_content = fs.readFileSync(path.join(MINIONS_DIR, 'notes.md'), 'utf8'); } catch { /* optional */ }
|
|
2109
2133
|
// Track expected plan filename in meta for chainPlanToPrd
|
|
2110
|
-
item._planFileName
|
|
2134
|
+
mutations.set(item.id, Object.assign(mutations.get(item.id) || {}, { _planFileName: planFileName }));
|
|
2111
2135
|
}
|
|
2112
2136
|
|
|
2113
2137
|
// Inject plan-to-prd variables — read the plan file content for the playbook
|
|
@@ -2162,6 +2186,13 @@ function discoverCentralWorkItems(config) {
|
|
|
2162
2186
|
continue;
|
|
2163
2187
|
}
|
|
2164
2188
|
|
|
2189
|
+
const dispatchMutation = {
|
|
2190
|
+
status: WI_STATUS.DISPATCHED,
|
|
2191
|
+
dispatched_at: ts(),
|
|
2192
|
+
dispatched_to: agentId,
|
|
2193
|
+
};
|
|
2194
|
+
mutations.set(item.id, Object.assign(mutations.get(item.id) || {}, dispatchMutation));
|
|
2195
|
+
|
|
2165
2196
|
newWork.push({
|
|
2166
2197
|
type: workType,
|
|
2167
2198
|
agent: agentId,
|
|
@@ -2169,18 +2200,25 @@ function discoverCentralWorkItems(config) {
|
|
|
2169
2200
|
agentRole,
|
|
2170
2201
|
task: item.title || item.description?.slice(0, 80) || item.id,
|
|
2171
2202
|
prompt,
|
|
2172
|
-
meta: { dispatchKey: key, source: 'central-work-item', item, planFileName: item.planFile || item._planFileName || null, branch: item.branch || item.featureBranch || `work/${item.id}` }
|
|
2203
|
+
meta: { dispatchKey: key, source: 'central-work-item', item, planFileName: item.planFile || mutations.get(item.id)?._planFileName || null, branch: item.branch || item.featureBranch || `work/${item.id}` }
|
|
2173
2204
|
});
|
|
2174
2205
|
|
|
2175
|
-
item.status = WI_STATUS.DISPATCHED;
|
|
2176
|
-
item.dispatched_at = ts();
|
|
2177
|
-
item.dispatched_to = agentId;
|
|
2178
2206
|
setCooldown(key);
|
|
2179
2207
|
}
|
|
2180
2208
|
} catch (err) { log('warn', `discoverCentralWorkItems: skipping ${item.id}: ${err.message}`); }
|
|
2181
2209
|
}
|
|
2182
2210
|
|
|
2183
|
-
if (
|
|
2211
|
+
if (mutations.size > 0) {
|
|
2212
|
+
// True atomic read-modify-write — applies mutations to fresh locked data
|
|
2213
|
+
mutateJsonFileLocked(centralPath, (freshItems) => {
|
|
2214
|
+
if (!Array.isArray(freshItems)) freshItems = [];
|
|
2215
|
+
for (const fi of freshItems) {
|
|
2216
|
+
const m = mutations.get(fi.id);
|
|
2217
|
+
if (m) Object.assign(fi, m);
|
|
2218
|
+
}
|
|
2219
|
+
return freshItems;
|
|
2220
|
+
}, { defaultValue: [] });
|
|
2221
|
+
}
|
|
2184
2222
|
return newWork;
|
|
2185
2223
|
}
|
|
2186
2224
|
|
|
@@ -2228,24 +2266,33 @@ function discoverWork(config) {
|
|
|
2228
2266
|
if (scheduledWork.length > 0) {
|
|
2229
2267
|
const { createMeeting, getMeetings } = require('./engine/meeting');
|
|
2230
2268
|
const centralPath = path.join(MINIONS_DIR, 'work-items.json');
|
|
2231
|
-
|
|
2232
|
-
|
|
2269
|
+
// Separate meetings (no work-items write) from task items
|
|
2270
|
+
const taskItems = [];
|
|
2233
2271
|
for (const item of scheduledWork) {
|
|
2234
2272
|
if (item.type === WORK_TYPE.MEETING) {
|
|
2235
|
-
// Create a real multi-agent meeting instead of a single-agent work item
|
|
2236
2273
|
const sched = (config.schedules || []).find(s => s.id === item._scheduleId);
|
|
2237
2274
|
const participants = (sched && sched.participants) || [];
|
|
2238
2275
|
const meeting = createMeeting({ title: item.title, agenda: item.description, participants });
|
|
2239
2276
|
log('info', `Scheduled meeting created: ${item._scheduleId} → ${meeting.id} (${participants.length} participants)`);
|
|
2240
2277
|
} else {
|
|
2241
|
-
|
|
2242
|
-
items.push(item);
|
|
2243
|
-
added++;
|
|
2244
|
-
log('info', `Scheduled task fired: ${item._scheduleId} → ${item.title}`);
|
|
2245
|
-
}
|
|
2278
|
+
taskItems.push(item);
|
|
2246
2279
|
}
|
|
2247
2280
|
}
|
|
2248
|
-
if (
|
|
2281
|
+
if (taskItems.length > 0) {
|
|
2282
|
+
// Atomic write — prevents race with dispatch status updates on central work-items.json
|
|
2283
|
+
mutateJsonFileLocked(centralPath, (items) => {
|
|
2284
|
+
if (!Array.isArray(items)) items = [];
|
|
2285
|
+
let added = 0;
|
|
2286
|
+
for (const item of taskItems) {
|
|
2287
|
+
if (!items.some(i => i._scheduleId === item._scheduleId && i.status !== WI_STATUS.DONE && i.status !== WI_STATUS.FAILED)) {
|
|
2288
|
+
items.push(item);
|
|
2289
|
+
added++;
|
|
2290
|
+
log('info', `Scheduled task fired: ${item._scheduleId} → ${item.title}`);
|
|
2291
|
+
}
|
|
2292
|
+
}
|
|
2293
|
+
return items;
|
|
2294
|
+
}, { defaultValue: [] });
|
|
2295
|
+
}
|
|
2249
2296
|
}
|
|
2250
2297
|
} catch (e) { log('warn', 'discover scheduled work: ' + e.message); }
|
|
2251
2298
|
|
|
@@ -2351,6 +2398,7 @@ async function tickInner() {
|
|
|
2351
2398
|
|
|
2352
2399
|
const config = getConfig();
|
|
2353
2400
|
tickCount++;
|
|
2401
|
+
_failedRefCache.clear(); // Reset per-tick failed-ref cache
|
|
2354
2402
|
|
|
2355
2403
|
// Helper: run a phase, log + continue on error
|
|
2356
2404
|
const safe = (label, fn) => { try { fn(); } catch (e) { log('warn', `${label}: ${e.message}`); } };
|
|
@@ -2563,7 +2611,7 @@ async function tickInner() {
|
|
|
2563
2611
|
for (const item of toDispatch) {
|
|
2564
2612
|
if (!dispatched.has(item.id)) {
|
|
2565
2613
|
let proc;
|
|
2566
|
-
try { proc = spawnAgent(item, config); } catch (spawnErr) {
|
|
2614
|
+
try { proc = await spawnAgent(item, config); } catch (spawnErr) {
|
|
2567
2615
|
log('error', `spawnAgent exception for ${item.id}: ${spawnErr.message}`);
|
|
2568
2616
|
proc = null;
|
|
2569
2617
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.534",
|
|
4
4
|
"description": "Multi-agent AI dev team that runs from ~/.minions/ — five autonomous agents share a single engine, dashboard, and knowledge base",
|
|
5
5
|
"bin": {
|
|
6
6
|
"minions": "bin/minions.js"
|