@yemi33/minions 0.1.1622 → 0.1.1624
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 +12 -3
- package/dashboard/js/command-center.js +3 -2
- package/dashboard.js +10 -33
- package/docs/auto-discovery.md +3 -3
- package/docs/design-state-storage.md +2 -2
- package/docs/pr-review-fix-loop.md +1 -1
- package/engine/ado.js +77 -19
- package/engine/copilot-models.json +1 -1
- package/engine/dispatch.js +11 -0
- package/engine/issues.js +206 -0
- package/engine.js +37 -6
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,12 +1,21 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
## 0.1.
|
|
3
|
+
## 0.1.1624 (2026-04-29)
|
|
4
4
|
|
|
5
5
|
### Features
|
|
6
|
-
- harden
|
|
6
|
+
- harden ADO repository polling (#1872)
|
|
7
|
+
- resilient file-bug label preflight (#1840) (#1867)
|
|
8
|
+
|
|
9
|
+
### Fixes
|
|
10
|
+
- use elapsed time for PR pollers (#1870)
|
|
7
11
|
|
|
8
12
|
### Other
|
|
9
|
-
-
|
|
13
|
+
- test(dispatch): add unit tests for cancelPendingWorkItems, writeInboxAlert, cleanDispatchEntries (#1874)
|
|
14
|
+
|
|
15
|
+
## 0.1.1621 (2026-04-29)
|
|
16
|
+
|
|
17
|
+
### Features
|
|
18
|
+
- harden action block parsing (#1863)
|
|
10
19
|
|
|
11
20
|
## 0.1.1620 (2026-04-29)
|
|
12
21
|
|
|
@@ -1126,10 +1126,11 @@ async function ccExecuteAction(action, targetTabId) {
|
|
|
1126
1126
|
case 'file-bug': {
|
|
1127
1127
|
var res10 = await _ccFetch('/api/issues/create', { title: action.title, description: action.description, labels: action.labels });
|
|
1128
1128
|
var d10 = await res10.json();
|
|
1129
|
+
var labelWarning = d10.warning ? ' <span style="color:var(--orange)">(' + escHtml(d10.warning) + ')</span>' : '';
|
|
1129
1130
|
if (d10.url) {
|
|
1130
|
-
status.innerHTML = '🐛 Bug filed: <a href="' + escHtml(d10.url) + '" target="_blank" style="color:var(--blue)">' + escHtml(action.title) + '</a>';
|
|
1131
|
+
status.innerHTML = '🐛 Bug filed: <a href="' + escHtml(d10.url) + '" target="_blank" style="color:var(--blue)">' + escHtml(action.title) + '</a>' + labelWarning;
|
|
1131
1132
|
} else {
|
|
1132
|
-
status.innerHTML = '🐛 Bug filed: <strong>' + escHtml(action.title) + '</strong> — <a href="https://github.com/yemi33/minions/issues" target="_blank" style="color:var(--blue)">view issues</a>';
|
|
1133
|
+
status.innerHTML = '🐛 Bug filed: <strong>' + escHtml(action.title) + '</strong> — <a href="https://github.com/yemi33/minions/issues" target="_blank" style="color:var(--blue)">view issues</a>' + labelWarning;
|
|
1133
1134
|
}
|
|
1134
1135
|
status.style.color = 'var(--green)';
|
|
1135
1136
|
break;
|
package/dashboard.js
CHANGED
|
@@ -23,6 +23,7 @@ const queries = require('./engine/queries');
|
|
|
23
23
|
const teams = require('./engine/teams');
|
|
24
24
|
const ado = require('./engine/ado');
|
|
25
25
|
const gh = require('./engine/github');
|
|
26
|
+
const issues = require('./engine/issues');
|
|
26
27
|
const watchesMod = require('./engine/watches');
|
|
27
28
|
const os = require('os');
|
|
28
29
|
|
|
@@ -4467,40 +4468,16 @@ What would you like to discuss or change? When you're happy, say "approve" and I
|
|
|
4467
4468
|
try {
|
|
4468
4469
|
const body = await readBody(req);
|
|
4469
4470
|
if (!body.title) return jsonReply(res, 400, { error: 'title required' });
|
|
4470
|
-
|
|
4471
|
-
|
|
4472
|
-
|
|
4473
|
-
|
|
4474
|
-
|
|
4475
|
-
|
|
4476
|
-
|
|
4477
|
-
|
|
4478
|
-
|
|
4479
|
-
// Write body to temp file to avoid shell escaping issues with quotes, backticks, newlines
|
|
4480
|
-
const tmpBody = path.join(ENGINE_DIR, 'tmp', `bug-body-${Date.now()}.md`);
|
|
4481
|
-
safeWrite(tmpBody, bugBody);
|
|
4482
|
-
const safeTitle = body.title.replace(/["`$\\]/g, '');
|
|
4483
|
-
try {
|
|
4484
|
-
const cmd = `gh issue create --repo "${repo}" --title "${safeTitle}" --body-file "${tmpBody}" --label "${labels}" 2>&1`;
|
|
4485
|
-
const result = shared.exec(cmd, { encoding: 'utf-8', timeout: 30000, windowsHide: true });
|
|
4486
|
-
shared.safeUnlink(tmpBody);
|
|
4487
|
-
// Detect gh errors in output
|
|
4488
|
-
if (result.includes('authentication') || result.includes('auth login')) {
|
|
4489
|
-
return jsonReply(res, 401, { error: 'GitHub auth required. Run: gh auth login' });
|
|
4490
|
-
}
|
|
4491
|
-
const urlMatch = result.match(/https:\/\/github\.com\/\S+/);
|
|
4492
|
-
if (!urlMatch) {
|
|
4493
|
-
return jsonReply(res, 500, { error: 'Issue may not have been created: ' + result.trim().slice(0, 200) });
|
|
4494
|
-
}
|
|
4495
|
-
return jsonReply(res, 200, { ok: true, url: urlMatch[0], output: result.trim() });
|
|
4496
|
-
} catch (e) {
|
|
4497
|
-
shared.safeUnlink(tmpBody);
|
|
4498
|
-
throw e;
|
|
4499
|
-
}
|
|
4471
|
+
const result = issues.createGitHubIssue({
|
|
4472
|
+
title: body.title,
|
|
4473
|
+
description: body.description || '',
|
|
4474
|
+
labels: body.labels,
|
|
4475
|
+
repo: 'yemi33/minions',
|
|
4476
|
+
tmpDir: path.join(ENGINE_DIR, 'tmp'),
|
|
4477
|
+
});
|
|
4478
|
+
return jsonReply(res, 200, result);
|
|
4500
4479
|
} catch (e) {
|
|
4501
|
-
|
|
4502
|
-
if (msg.includes('ENOENT') || msg.includes('not found')) return jsonReply(res, 500, { error: 'gh CLI not found. Install from https://cli.github.com/' });
|
|
4503
|
-
return jsonReply(res, 500, { error: msg });
|
|
4480
|
+
return jsonReply(res, e.statusCode || 500, { error: e.message || 'Issue creation failed' });
|
|
4504
4481
|
}
|
|
4505
4482
|
}
|
|
4506
4483
|
|
package/docs/auto-discovery.md
CHANGED
|
@@ -11,8 +11,8 @@ tick()
|
|
|
11
11
|
1. checkTimeouts() Kill stale/hung agents (>heartbeatTimeout)
|
|
12
12
|
2. consolidateInbox() Merge learnings into notes.md (Haiku-powered)
|
|
13
13
|
2.5 runCleanup() Periodic cleanup (every 10 ticks ≈ 10min)
|
|
14
|
-
2.6 pollPrStatus() Poll ADO + GitHub for build, review, merge status (
|
|
15
|
-
2.7 pollPrHumanComments() Poll PR threads for human @minions comments (
|
|
14
|
+
2.6 pollPrStatus() Poll ADO + GitHub for build, review, merge status (wall-clock cadence from prPollStatusEvery × tickInterval, default ≈ 12min)
|
|
15
|
+
2.7 pollPrHumanComments() Poll PR threads for human @minions comments (wall-clock cadence from prPollCommentsEvery × tickInterval, default ≈ 12min)
|
|
16
16
|
3. discoverWork() Scan ALL linked projects for new tasks
|
|
17
17
|
4. updateSnapshot() Write identity/now.md
|
|
18
18
|
5. dispatch Spawn agents for pending items (up to maxConcurrent)
|
|
@@ -124,7 +124,7 @@ Both write to `work-items.json` and are picked up by Source 3 on the same or nex
|
|
|
124
124
|
|
|
125
125
|
## PR Status Polling (`pollPrStatus`)
|
|
126
126
|
|
|
127
|
-
**Runs:**
|
|
127
|
+
**Runs:** On a wall-clock cadence derived from `prPollStatusEvery × engine.tickInterval` (default 12 × 60s, ≈ 12 minutes), independently of work discovery or file-change wakeups. ADO polling lives in `engine/ado.js`; GitHub polling lives in `engine/github.js` — both run in parallel each cycle (`Promise.allSettled`) and write to the same per-project `pull-requests.json` schema. Replaces the retired agent-based `pr-sync`.
|
|
128
128
|
|
|
129
129
|
The engine directly polls the host REST API for **all** PR metadata: build/CI status, human review votes, and completion state. Two API calls per PR — no agent dispatch needed.
|
|
130
130
|
|
|
@@ -22,7 +22,7 @@ Minions persists all runtime state as flat JSON files guarded by file-lock-based
|
|
|
22
22
|
| `engine/metrics.json` | 5 KB | Per-agent stats | R/W on PR approval/merge | Low | Low |
|
|
23
23
|
| `engine/control.json` | 169 B | Single object | R/W on start/stop/heartbeat | Low | Low |
|
|
24
24
|
| `projects/*/work-items.json` | 370 KB | 180 items | R/W every 1-2 ticks; dashboard reads on-demand | **High** — engine + lifecycle + dashboard | High |
|
|
25
|
-
| `projects/*/pull-requests.json` | 241 KB | 128 PRs | R/W
|
|
25
|
+
| `projects/*/pull-requests.json` | 241 KB | 128 PRs | R/W on the `prPollStatusEvery × tickInterval` wall-clock cadence (default ≈12min); lifecycle writes | Medium | Medium |
|
|
26
26
|
| `engine/pipeline-runs.json` | 36 KB | Pipeline state | R/W on pipeline execution | Low | Low |
|
|
27
27
|
| `engine/schedule-runs.json` | 115 B | Last-run times | R every 10 ticks; W on schedule execution | Low | Low |
|
|
28
28
|
|
|
@@ -56,7 +56,7 @@ Key properties:
|
|
|
56
56
|
| Engine tick cycle | ~15 | ~3 | Heavy read, selective write |
|
|
57
57
|
| Dashboard (per page load) | ~8 | 0 | Read-only display |
|
|
58
58
|
| Dashboard (user action) | ~2 | ~2 | Read-modify-write |
|
|
59
|
-
| PR polling (every `prPollStatusEvery
|
|
59
|
+
| PR polling (every `prPollStatusEvery × tickInterval`, default ≈12min) | ~4 | ~2 | Batch read-modify-write |
|
|
60
60
|
| Consolidation (every 10 ticks) | ~3 | ~2 | Read inbox files, write notes.md |
|
|
61
61
|
|
|
62
62
|
**Read:write ratio is approximately 8:1.** This strongly favors a system that can serve reads without locking (e.g., WAL mode).
|
|
@@ -71,7 +71,7 @@ When multiple problems coexist, earlier triggers get the first chance to enqueue
|
|
|
71
71
|
|
|
72
72
|
## 6. Re-review cycle
|
|
73
73
|
|
|
74
|
-
- Poller (
|
|
74
|
+
- Poller (wall-clock cadence from `prPollStatusEvery × tickInterval`, default ~12min): detects new commit (`head.sha` changed) → sets `lastPushedAt`
|
|
75
75
|
- Platform review state drives next action:
|
|
76
76
|
- Reviewer approves → `approved` → done
|
|
77
77
|
- Reviewer re-requests changes → `changes-requested` → triggers another fix
|
package/engine/ado.js
CHANGED
|
@@ -27,6 +27,24 @@ const getAdoPrUrl = (project, prNumber) => {
|
|
|
27
27
|
return `https://dev.azure.com/${project.adoOrg}/${project.adoProject}/_git/${repoPath}/pullrequest/${prNumber}`;
|
|
28
28
|
};
|
|
29
29
|
|
|
30
|
+
function isGitHubProject(project) {
|
|
31
|
+
return String(project?.repoHost || '').toLowerCase() === 'github';
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function getAdoRepositoryId(project) {
|
|
35
|
+
const repositoryId = String(project?.repositoryId || '').trim();
|
|
36
|
+
if (repositoryId) return repositoryId;
|
|
37
|
+
return String(project?.repoName || '').trim();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function getAdoProjectLabel(project) {
|
|
41
|
+
return project?.name || project?.repoName || `${project?.adoOrg || 'unknown-org'}/${project?.adoProject || 'unknown-project'}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function logMissingAdoRepository(project, purpose) {
|
|
45
|
+
log('error', `${purpose} disabled for project ${getAdoProjectLabel(project)}: missing project.repositoryId and project.repoName; configure one so Azure DevOps repository API calls can target the repo`);
|
|
46
|
+
}
|
|
47
|
+
|
|
30
48
|
// ── Build/Review Status Helpers ───────────────────────────────────────────────
|
|
31
49
|
|
|
32
50
|
/** Classify an array of ADO build records into a single status string. */
|
|
@@ -238,7 +256,7 @@ async function fetchAdoBuildErrorLog(orgBase, project, failedStatus, token, pr,
|
|
|
238
256
|
// ─── Shared PR Polling Loop ──────────────────────────────────────────────────
|
|
239
257
|
|
|
240
258
|
/**
|
|
241
|
-
* Iterate active PRs across all projects. Calls `callback(project, pr, prNum, orgBase)`
|
|
259
|
+
* Iterate active PRs across all projects. Calls `callback(project, pr, prNum, orgBase, adoRepositoryId)`
|
|
242
260
|
* for each active PR. If callback returns truthy, the PR file is saved after the project loop.
|
|
243
261
|
*/
|
|
244
262
|
async function forEachActivePr(config, token, callback) {
|
|
@@ -246,12 +264,19 @@ async function forEachActivePr(config, token, callback) {
|
|
|
246
264
|
let totalUpdated = 0;
|
|
247
265
|
|
|
248
266
|
for (const project of projects) {
|
|
249
|
-
if (
|
|
267
|
+
if (isGitHubProject(project)) continue;
|
|
268
|
+
if (!project.adoOrg || !project.adoProject) continue;
|
|
250
269
|
|
|
251
270
|
const prs = getPrs(project);
|
|
252
271
|
const activePrs = prs.filter(pr => shared.PR_POLLABLE_STATUSES.has(pr.status));
|
|
253
272
|
if (activePrs.length === 0) continue;
|
|
254
273
|
|
|
274
|
+
const adoRepositoryId = getAdoRepositoryId(project);
|
|
275
|
+
if (!adoRepositoryId) {
|
|
276
|
+
logMissingAdoRepository(project, 'ADO PR polling');
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
|
|
255
280
|
let projectUpdated = 0;
|
|
256
281
|
const orgBase = getAdoOrgBase(project);
|
|
257
282
|
|
|
@@ -262,7 +287,7 @@ async function forEachActivePr(config, token, callback) {
|
|
|
262
287
|
const results = await Promise.allSettled(batch.map(async (pr) => {
|
|
263
288
|
const prNum = shared.getPrNumber(pr);
|
|
264
289
|
if (!prNum) return false;
|
|
265
|
-
return callback(project, pr, prNum, orgBase);
|
|
290
|
+
return callback(project, pr, prNum, orgBase, adoRepositoryId);
|
|
266
291
|
}));
|
|
267
292
|
for (const r of results) {
|
|
268
293
|
if (r.status === 'fulfilled' && r.value) projectUpdated++;
|
|
@@ -275,7 +300,11 @@ async function forEachActivePr(config, token, callback) {
|
|
|
275
300
|
// Merge back updated PRs — preserve disk values that may have been changed
|
|
276
301
|
// by other writers between poll start and this write
|
|
277
302
|
for (const updatedPr of activePrs) {
|
|
278
|
-
const
|
|
303
|
+
const updatedPrNumber = shared.getPrNumber(updatedPr);
|
|
304
|
+
const idx = currentPrs.findIndex(p =>
|
|
305
|
+
p.id === updatedPr.id
|
|
306
|
+
|| (updatedPrNumber != null && shared.getPrNumber(p) === updatedPrNumber)
|
|
307
|
+
);
|
|
279
308
|
if (idx >= 0) {
|
|
280
309
|
// Never downgrade reviewStatus from 'approved' — it's a permanent terminal state
|
|
281
310
|
// The disk version may have been set to 'approved' by another writer after we read
|
|
@@ -317,9 +346,10 @@ async function pollPrStatus(config) {
|
|
|
317
346
|
return;
|
|
318
347
|
}
|
|
319
348
|
|
|
320
|
-
const totalUpdated = await forEachActivePr(config, token, async (project, pr, prNum, orgBase) => {
|
|
349
|
+
const totalUpdated = await forEachActivePr(config, token, async (project, pr, prNum, orgBase, adoRepositoryId) => {
|
|
321
350
|
try {
|
|
322
|
-
const
|
|
351
|
+
const encodedRepoId = encodeURIComponent(adoRepositoryId);
|
|
352
|
+
const repoBase = `${orgBase}/${project.adoProject}/_apis/git/repositories/${encodedRepoId}/pullrequests/${prNum}`;
|
|
323
353
|
let updated = false;
|
|
324
354
|
|
|
325
355
|
// Clear stale flag — we're attempting a fresh poll
|
|
@@ -453,7 +483,7 @@ async function pollPrStatus(config) {
|
|
|
453
483
|
if (prNumber && mergeCommitId) {
|
|
454
484
|
try {
|
|
455
485
|
const mergeRef = encodeURIComponent(`refs/pull/${prNumber}/merge`);
|
|
456
|
-
const buildsUrl = `${orgBase}/${project.adoProject}/_apis/build/builds?branchName=${mergeRef}&repositoryId=${
|
|
486
|
+
const buildsUrl = `${orgBase}/${project.adoProject}/_apis/build/builds?branchName=${mergeRef}&repositoryId=${encodedRepoId}&repositoryType=TfsGit&$top=25&api-version=7.1`;
|
|
457
487
|
const buildsData = await adoFetch(buildsUrl, token);
|
|
458
488
|
const allBuilds = buildsData?.value || [];
|
|
459
489
|
const prBuilds = allBuilds.filter(b => b.sourceVersion === mergeCommitId);
|
|
@@ -544,7 +574,7 @@ async function pollPrStatus(config) {
|
|
|
544
574
|
const mergeStrategy = config.engine?.prMergeMethod === 'merge' ? 1 : config.engine?.prMergeMethod === 'rebase' ? 2 : 3; // 3 = squash
|
|
545
575
|
const identityUrl = `${orgBase}/_apis/connectionData?api-version=7.1`;
|
|
546
576
|
const identity = await adoFetch(identityUrl, token).catch(() => null);
|
|
547
|
-
const autoCompleteUrl = `${orgBase}/${project.adoProject}/_apis/git/repositories/${
|
|
577
|
+
const autoCompleteUrl = `${orgBase}/${project.adoProject}/_apis/git/repositories/${encodedRepoId}/pullrequests/${prNum}?api-version=7.1`;
|
|
548
578
|
await adoFetch(autoCompleteUrl, token, {
|
|
549
579
|
method: 'PATCH',
|
|
550
580
|
body: JSON.stringify({
|
|
@@ -599,8 +629,8 @@ async function pollPrHumanComments(config) {
|
|
|
599
629
|
const token = await getAdoToken();
|
|
600
630
|
if (!token) return;
|
|
601
631
|
|
|
602
|
-
const totalUpdated = await forEachActivePr(config, token, async (project, pr, prNum, orgBase) => {
|
|
603
|
-
const threadsUrl = `${orgBase}/${project.adoProject}/_apis/git/repositories/${
|
|
632
|
+
const totalUpdated = await forEachActivePr(config, token, async (project, pr, prNum, orgBase, adoRepositoryId) => {
|
|
633
|
+
const threadsUrl = `${orgBase}/${project.adoProject}/_apis/git/repositories/${encodeURIComponent(adoRepositoryId)}/pullrequests/${prNum}/threads?api-version=7.1`;
|
|
604
634
|
const threadsData = await adoFetch(threadsUrl, token);
|
|
605
635
|
const threads = threadsData.value || [];
|
|
606
636
|
|
|
@@ -698,10 +728,16 @@ async function reconcilePrs(config) {
|
|
|
698
728
|
let totalAdded = 0;
|
|
699
729
|
|
|
700
730
|
for (const project of projects) {
|
|
701
|
-
if (
|
|
731
|
+
if (isGitHubProject(project)) continue;
|
|
732
|
+
if (!project.adoOrg || !project.adoProject) continue;
|
|
733
|
+
const adoRepositoryId = getAdoRepositoryId(project);
|
|
734
|
+
if (!adoRepositoryId) {
|
|
735
|
+
logMissingAdoRepository(project, 'ADO PR reconciliation');
|
|
736
|
+
continue;
|
|
737
|
+
}
|
|
702
738
|
|
|
703
739
|
const orgBase = shared.getAdoOrgBase(project);
|
|
704
|
-
const url = `${orgBase}/${project.adoProject}/_apis/git/repositories/${
|
|
740
|
+
const url = `${orgBase}/${project.adoProject}/_apis/git/repositories/${encodeURIComponent(adoRepositoryId)}/pullrequests?searchCriteria.status=active&api-version=7.1`;
|
|
705
741
|
|
|
706
742
|
let prData;
|
|
707
743
|
try {
|
|
@@ -829,7 +865,12 @@ async function checkLiveReviewStatus(pr, project) {
|
|
|
829
865
|
const orgBase = shared.getAdoOrgBase(project);
|
|
830
866
|
const prNum = shared.getPrNumber(pr);
|
|
831
867
|
if (!prNum) return null;
|
|
832
|
-
const
|
|
868
|
+
const adoRepositoryId = getAdoRepositoryId(project);
|
|
869
|
+
if (!adoRepositoryId) {
|
|
870
|
+
logMissingAdoRepository(project, 'ADO live review check');
|
|
871
|
+
return null;
|
|
872
|
+
}
|
|
873
|
+
const url = `${orgBase}/${project.adoProject}/_apis/git/repositories/${encodeURIComponent(adoRepositoryId)}/pullrequests/${prNum}?api-version=7.1`;
|
|
833
874
|
// SEC-02: use in-process adoFetch rather than a shell-out — keeps the bearer
|
|
834
875
|
// token out of the process argv list where any local process could read it.
|
|
835
876
|
// 4s timeout preserves the original request-cancellation semantics via AbortSignal.
|
|
@@ -868,7 +909,13 @@ async function checkLiveBuildAndConflict(pr, project) {
|
|
|
868
909
|
const orgBase = shared.getAdoOrgBase(project);
|
|
869
910
|
const prNum = shared.getPrNumber(pr);
|
|
870
911
|
if (!prNum) return null;
|
|
871
|
-
const
|
|
912
|
+
const adoRepositoryId = getAdoRepositoryId(project);
|
|
913
|
+
if (!adoRepositoryId) {
|
|
914
|
+
logMissingAdoRepository(project, 'ADO live build/conflict check');
|
|
915
|
+
return null;
|
|
916
|
+
}
|
|
917
|
+
const encodedRepoId = encodeURIComponent(adoRepositoryId);
|
|
918
|
+
const repoBase = `${orgBase}/${project.adoProject}/_apis/git/repositories/${encodedRepoId}`;
|
|
872
919
|
const prUrl = `${repoBase}/pullrequests/${prNum}?api-version=7.1`;
|
|
873
920
|
// 4s timeout — same budget as checkLiveReviewStatus. This is a pre-dispatch
|
|
874
921
|
// gate; we'd rather miss a freshness signal and fall back to cache than
|
|
@@ -889,7 +936,7 @@ async function checkLiveBuildAndConflict(pr, project) {
|
|
|
889
936
|
if (mergeCommitId) {
|
|
890
937
|
try {
|
|
891
938
|
const mergeRef = encodeURIComponent(`refs/pull/${prNum}/merge`);
|
|
892
|
-
const buildsUrl = `${orgBase}/${project.adoProject}/_apis/build/builds?branchName=${mergeRef}&repositoryId=${
|
|
939
|
+
const buildsUrl = `${orgBase}/${project.adoProject}/_apis/build/builds?branchName=${mergeRef}&repositoryId=${encodedRepoId}&repositoryType=TfsGit&$top=25&api-version=7.1`;
|
|
893
940
|
const buildsData = await adoFetch(buildsUrl, token, { timeout: 4000 });
|
|
894
941
|
const allBuilds = buildsData?.value || [];
|
|
895
942
|
const prBuilds = allBuilds.filter(b => b.sourceVersion === mergeCommitId);
|
|
@@ -940,9 +987,15 @@ async function fetchSinglePrBuildStatus(project, prNumber) {
|
|
|
940
987
|
if (!token) return null;
|
|
941
988
|
|
|
942
989
|
const orgBase = getAdoOrgBase(project);
|
|
943
|
-
const
|
|
990
|
+
const adoRepositoryId = getAdoRepositoryId(project);
|
|
991
|
+
if (!adoRepositoryId) {
|
|
992
|
+
logMissingAdoRepository(project, 'ADO single PR status fetch');
|
|
993
|
+
return null;
|
|
994
|
+
}
|
|
995
|
+
const encodedRepoId = encodeURIComponent(adoRepositoryId);
|
|
996
|
+
const repoBase = `${orgBase}/${project.adoProject}/_apis/git/repositories/${encodedRepoId}`;
|
|
944
997
|
const mergeRef = encodeURIComponent(`refs/pull/${prNumber}/merge`);
|
|
945
|
-
const buildsUrl = `${orgBase}/${project.adoProject}/_apis/build/builds?branchName=${mergeRef}&repositoryId=${
|
|
998
|
+
const buildsUrl = `${orgBase}/${project.adoProject}/_apis/build/builds?branchName=${mergeRef}&repositoryId=${encodedRepoId}&repositoryType=TfsGit&$top=25&api-version=7.1`;
|
|
946
999
|
|
|
947
1000
|
// Fetch PR metadata and builds in parallel
|
|
948
1001
|
const [prData, buildsData] = await Promise.all([
|
|
@@ -1011,7 +1064,12 @@ const getAdoThrottleState = () => _adoThrottle.getState();
|
|
|
1011
1064
|
* @returns {{ prNumber: number, url: string }|null}
|
|
1012
1065
|
*/
|
|
1013
1066
|
async function findOpenPrOnBranch(project, branch) {
|
|
1014
|
-
if (!project.adoOrg || !project.adoProject || !
|
|
1067
|
+
if (!project.adoOrg || !project.adoProject || !branch) return null;
|
|
1068
|
+
const adoRepositoryId = getAdoRepositoryId(project);
|
|
1069
|
+
if (!adoRepositoryId) {
|
|
1070
|
+
logMissingAdoRepository(project, 'ADO branch PR lookup');
|
|
1071
|
+
return null;
|
|
1072
|
+
}
|
|
1015
1073
|
if (isAdoThrottled()) {
|
|
1016
1074
|
log('debug', `[ado] Skipping branch PR lookup for ${project.name || project.repoName || 'unknown project'}:${branch} — throttled`);
|
|
1017
1075
|
return null;
|
|
@@ -1020,7 +1078,7 @@ async function findOpenPrOnBranch(project, branch) {
|
|
|
1020
1078
|
if (!token) return null;
|
|
1021
1079
|
const orgBase = shared.getAdoOrgBase(project);
|
|
1022
1080
|
const sourceRef = encodeURIComponent(`refs/heads/${branch}`);
|
|
1023
|
-
const url = `${orgBase}/${project.adoProject}/_apis/git/repositories/${
|
|
1081
|
+
const url = `${orgBase}/${project.adoProject}/_apis/git/repositories/${encodeURIComponent(adoRepositoryId)}/pullrequests?searchCriteria.status=active&searchCriteria.sourceRefName=${sourceRef}&api-version=7.1`;
|
|
1024
1082
|
const data = await adoFetch(url, token);
|
|
1025
1083
|
const pr = (data.value || [])[0];
|
|
1026
1084
|
if (!pr) return null;
|
package/engine/dispatch.js
CHANGED
|
@@ -111,6 +111,17 @@ function isRetryableFailureReason(reason = '', failureClass = '') {
|
|
|
111
111
|
'invalid file path',
|
|
112
112
|
'missing required',
|
|
113
113
|
'validation failed',
|
|
114
|
+
'auth failure',
|
|
115
|
+
'authentication failed',
|
|
116
|
+
'authentication failure',
|
|
117
|
+
'unauthorized',
|
|
118
|
+
'invalid api key',
|
|
119
|
+
'please log in',
|
|
120
|
+
'budget-exceeded',
|
|
121
|
+
'budget exceeded',
|
|
122
|
+
'budget cap exceeded',
|
|
123
|
+
'max-budget-usd',
|
|
124
|
+
'cost limit',
|
|
114
125
|
];
|
|
115
126
|
return !nonRetryable.some(s => r.includes(s));
|
|
116
127
|
}
|
package/engine/issues.js
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* engine/issues.js — GitHub issue creation helpers.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
const fs = require('fs');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const { execFileSync: _execFileSync } = require('child_process');
|
|
8
|
+
|
|
9
|
+
const DEFAULT_REPO = 'yemi33/minions';
|
|
10
|
+
const DEFAULT_LABELS = ['bug'];
|
|
11
|
+
|
|
12
|
+
class GitHubIssueError extends Error {
|
|
13
|
+
constructor(message, statusCode = 500) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.name = 'GitHubIssueError';
|
|
16
|
+
this.statusCode = statusCode;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function normalizeLabels(labels, defaultLabels = DEFAULT_LABELS) {
|
|
21
|
+
let raw;
|
|
22
|
+
if (labels == null) raw = defaultLabels;
|
|
23
|
+
else if (Array.isArray(labels)) raw = labels;
|
|
24
|
+
else if (typeof labels === 'string') raw = labels.split(',');
|
|
25
|
+
else raw = [];
|
|
26
|
+
|
|
27
|
+
const seen = new Set();
|
|
28
|
+
const out = [];
|
|
29
|
+
for (const label of raw) {
|
|
30
|
+
const value = String(label || '').trim();
|
|
31
|
+
if (!value) continue;
|
|
32
|
+
const key = value.toLowerCase();
|
|
33
|
+
if (seen.has(key)) continue;
|
|
34
|
+
seen.add(key);
|
|
35
|
+
out.push(value);
|
|
36
|
+
}
|
|
37
|
+
return out;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function ghMessage(err) {
|
|
41
|
+
if (!err) return '';
|
|
42
|
+
return [err.message, err.stdout, err.stderr]
|
|
43
|
+
.filter(Boolean)
|
|
44
|
+
.map(String)
|
|
45
|
+
.join('\n');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function conciseGhMessage(errOrText) {
|
|
49
|
+
const text = typeof errOrText === 'string' ? errOrText : ghMessage(errOrText);
|
|
50
|
+
return text.replace(/\s+/g, ' ').trim().slice(0, 240);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isAuthError(errOrText) {
|
|
54
|
+
return /(authentication|auth login|not authenticated|bad credentials|http 401|requires authentication)/i.test(
|
|
55
|
+
typeof errOrText === 'string' ? errOrText : ghMessage(errOrText)
|
|
56
|
+
);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function isLabelUnavailableError(errOrText) {
|
|
60
|
+
const msg = typeof errOrText === 'string' ? errOrText : ghMessage(errOrText);
|
|
61
|
+
return /label/i.test(msg) && /(not found|not exist|unavailable|invalid|could not add|could not resolve|does not exist)/i.test(msg);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function extractIssueUrl(output) {
|
|
65
|
+
const match = String(output || '').match(/https:\/\/github\.com\/\S+\/issues\/\d+/);
|
|
66
|
+
return match ? match[0] : null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function runGh(execFileSync, args, timeout) {
|
|
70
|
+
return execFileSync('gh', args, {
|
|
71
|
+
encoding: 'utf8',
|
|
72
|
+
timeout,
|
|
73
|
+
windowsHide: true,
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function listRepoLabels({ repo, execFileSync }) {
|
|
78
|
+
const output = runGh(execFileSync, ['label', 'list', '--repo', repo, '--json', 'name', '--limit', '1000'], 15000);
|
|
79
|
+
const parsed = JSON.parse(output || '[]');
|
|
80
|
+
if (!Array.isArray(parsed)) {
|
|
81
|
+
throw new GitHubIssueError('GitHub label list returned an unexpected response shape');
|
|
82
|
+
}
|
|
83
|
+
const labelsByLower = new Map();
|
|
84
|
+
for (const item of parsed) {
|
|
85
|
+
if (!item || typeof item.name !== 'string') continue;
|
|
86
|
+
labelsByLower.set(item.name.toLowerCase(), item.name);
|
|
87
|
+
}
|
|
88
|
+
return labelsByLower;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function resolveLabels({ labels, repo, execFileSync }) {
|
|
92
|
+
const requested = normalizeLabels(labels);
|
|
93
|
+
if (requested.length === 0) {
|
|
94
|
+
return { requested, labelsToApply: [], labelsSkipped: [], validationUnavailable: false };
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
try {
|
|
98
|
+
const available = listRepoLabels({ repo, execFileSync });
|
|
99
|
+
const labelsToApply = [];
|
|
100
|
+
const labelsSkipped = [];
|
|
101
|
+
for (const label of requested) {
|
|
102
|
+
const matched = available.get(label.toLowerCase());
|
|
103
|
+
if (matched) labelsToApply.push(matched);
|
|
104
|
+
else labelsSkipped.push(label);
|
|
105
|
+
}
|
|
106
|
+
return { requested, labelsToApply, labelsSkipped, validationUnavailable: false };
|
|
107
|
+
} catch (e) {
|
|
108
|
+
if (e instanceof GitHubIssueError) throw e;
|
|
109
|
+
if (isAuthError(e)) throw new GitHubIssueError('GitHub auth required. Run: gh auth login', 401);
|
|
110
|
+
return { requested, labelsToApply: requested, labelsSkipped: [], validationUnavailable: true };
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function buildWarning(labelsSkipped, filedWithoutLabels) {
|
|
115
|
+
if (!labelsSkipped.length) return undefined;
|
|
116
|
+
const base = `Skipped unavailable GitHub label(s): ${labelsSkipped.join(', ')}.`;
|
|
117
|
+
return filedWithoutLabels ? `${base} Filed without labels.` : base;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function createIssueWithLabels({ title, bodyFile, repo, labels, execFileSync }) {
|
|
121
|
+
const args = ['issue', 'create', '--repo', repo, '--title', title, '--body-file', bodyFile];
|
|
122
|
+
if (labels.length > 0) args.push('--label', labels.join(','));
|
|
123
|
+
const output = runGh(execFileSync, args, 30000);
|
|
124
|
+
const url = extractIssueUrl(output);
|
|
125
|
+
if (!url) {
|
|
126
|
+
throw new GitHubIssueError(`Issue may not have been created: ${conciseGhMessage(output)}`);
|
|
127
|
+
}
|
|
128
|
+
return { url, output: String(output || '').trim() };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function createGitHubIssue({
|
|
132
|
+
title,
|
|
133
|
+
description = '',
|
|
134
|
+
labels,
|
|
135
|
+
repo = DEFAULT_REPO,
|
|
136
|
+
tmpDir,
|
|
137
|
+
execFileSync = _execFileSync,
|
|
138
|
+
} = {}) {
|
|
139
|
+
if (!title) throw new GitHubIssueError('title required', 400);
|
|
140
|
+
|
|
141
|
+
try {
|
|
142
|
+
runGh(execFileSync, ['--version'], 5000);
|
|
143
|
+
} catch (e) {
|
|
144
|
+
throw new GitHubIssueError('gh CLI not found. Install from https://cli.github.com/');
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const issueBody = `${description || ''}\n\n---\n_Filed via Minions dashboard_`;
|
|
148
|
+
const dir = tmpDir || path.join(__dirname, 'tmp');
|
|
149
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
150
|
+
const bodyFile = path.join(dir, `bug-body-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}.md`);
|
|
151
|
+
fs.writeFileSync(bodyFile, issueBody);
|
|
152
|
+
|
|
153
|
+
let resolved;
|
|
154
|
+
try {
|
|
155
|
+
resolved = resolveLabels({ labels, repo, execFileSync });
|
|
156
|
+
const created = createIssueWithLabels({
|
|
157
|
+
title,
|
|
158
|
+
bodyFile,
|
|
159
|
+
repo,
|
|
160
|
+
labels: resolved.labelsToApply,
|
|
161
|
+
execFileSync,
|
|
162
|
+
});
|
|
163
|
+
const filedWithoutLabels = resolved.requested.length > 0 && resolved.labelsToApply.length === 0;
|
|
164
|
+
return {
|
|
165
|
+
ok: true,
|
|
166
|
+
url: created.url,
|
|
167
|
+
output: created.output,
|
|
168
|
+
labelsRequested: resolved.requested,
|
|
169
|
+
labelsApplied: resolved.labelsToApply,
|
|
170
|
+
labelsSkipped: resolved.labelsSkipped,
|
|
171
|
+
warning: buildWarning(resolved.labelsSkipped, filedWithoutLabels),
|
|
172
|
+
};
|
|
173
|
+
} catch (e) {
|
|
174
|
+
if (e instanceof GitHubIssueError) throw e;
|
|
175
|
+
if (isAuthError(e)) throw new GitHubIssueError('GitHub auth required. Run: gh auth login', 401);
|
|
176
|
+
if (resolved && resolved.labelsToApply.length > 0 && isLabelUnavailableError(e)) {
|
|
177
|
+
try {
|
|
178
|
+
const created = createIssueWithLabels({ title, bodyFile, repo, labels: [], execFileSync });
|
|
179
|
+
const skipped = normalizeLabels([...resolved.labelsSkipped, ...resolved.labelsToApply], []);
|
|
180
|
+
return {
|
|
181
|
+
ok: true,
|
|
182
|
+
url: created.url,
|
|
183
|
+
output: created.output,
|
|
184
|
+
labelsRequested: resolved.requested,
|
|
185
|
+
labelsApplied: [],
|
|
186
|
+
labelsSkipped: skipped,
|
|
187
|
+
warning: buildWarning(skipped, true),
|
|
188
|
+
};
|
|
189
|
+
} catch (retryErr) {
|
|
190
|
+
if (isAuthError(retryErr)) throw new GitHubIssueError('GitHub auth required. Run: gh auth login', 401);
|
|
191
|
+
throw new GitHubIssueError(`GitHub issue creation failed after retrying without labels: ${conciseGhMessage(retryErr)}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
throw new GitHubIssueError(`GitHub issue creation failed: ${conciseGhMessage(e)}`);
|
|
195
|
+
} finally {
|
|
196
|
+
try { fs.unlinkSync(bodyFile); } catch {}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
module.exports = {
|
|
201
|
+
DEFAULT_LABELS,
|
|
202
|
+
GitHubIssueError,
|
|
203
|
+
normalizeLabels,
|
|
204
|
+
isLabelUnavailableError,
|
|
205
|
+
createGitHubIssue,
|
|
206
|
+
};
|
package/engine.js
CHANGED
|
@@ -3337,11 +3337,30 @@ let tickCount = 0;
|
|
|
3337
3337
|
// In-memory cache of plan filenames confirmed completed — avoids redundant
|
|
3338
3338
|
// checkPlanCompletion calls. Cleared automatically on engine restart.
|
|
3339
3339
|
const completedPlanCache = new Set();
|
|
3340
|
+
let lastWatchCheckAt = 0;
|
|
3341
|
+
let lastPrStatusPollAt = 0;
|
|
3342
|
+
let lastPrCommentsPollAt = 0;
|
|
3340
3343
|
|
|
3341
3344
|
let tickRunning = false;
|
|
3342
3345
|
let _tickStartedAt = 0;
|
|
3343
3346
|
const TICK_TIMEOUT_MS = 300000; // 5 min — force-release tick lock if stuck
|
|
3344
3347
|
|
|
3348
|
+
function _pollIntervalMsFromTicks(ticks, tickIntervalMs) {
|
|
3349
|
+
const normalizedTicks = Math.max(1, Number(ticks) || 1);
|
|
3350
|
+
const normalizedTickInterval = Math.max(1, Number(tickIntervalMs) || ENGINE_DEFAULTS.tickInterval);
|
|
3351
|
+
return normalizedTicks * normalizedTickInterval;
|
|
3352
|
+
}
|
|
3353
|
+
|
|
3354
|
+
function _shouldRunPeriodicPhase(now, lastRunAt, intervalMs) {
|
|
3355
|
+
const current = Number(now);
|
|
3356
|
+
const previous = Number(lastRunAt) || 0;
|
|
3357
|
+
const interval = Math.max(1, Number(intervalMs) || 1);
|
|
3358
|
+
if (!Number.isFinite(current)) return false;
|
|
3359
|
+
if (!previous || !Number.isFinite(previous)) return true;
|
|
3360
|
+
if (current < previous) return true;
|
|
3361
|
+
return current - previous >= interval;
|
|
3362
|
+
}
|
|
3363
|
+
|
|
3345
3364
|
async function tick() {
|
|
3346
3365
|
if (tickRunning) {
|
|
3347
3366
|
if (_tickStartedAt && Date.now() - _tickStartedAt > TICK_TIMEOUT_MS) {
|
|
@@ -3375,6 +3394,8 @@ async function tickInner() {
|
|
|
3375
3394
|
|
|
3376
3395
|
const config = getConfig();
|
|
3377
3396
|
tickCount++;
|
|
3397
|
+
const now = Date.now();
|
|
3398
|
+
const tickIntervalMs = Math.max(1, Number(config.engine?.tickInterval) || ENGINE_DEFAULTS.tickInterval);
|
|
3378
3399
|
_failedRefCache.clear(); // Reset per-tick failed-ref cache
|
|
3379
3400
|
|
|
3380
3401
|
// Helper: run a phase, log + continue on error
|
|
@@ -3402,8 +3423,10 @@ async function tickInner() {
|
|
|
3402
3423
|
safe('runCleanup', () => runCleanup(config));
|
|
3403
3424
|
}
|
|
3404
3425
|
|
|
3405
|
-
// 2.55. Check persistent watches (
|
|
3406
|
-
|
|
3426
|
+
// 2.55. Check persistent watches (3 tick-equivalents, default ~3 minutes)
|
|
3427
|
+
const watchPollIntervalMs = _pollIntervalMsFromTicks(3, tickIntervalMs);
|
|
3428
|
+
if (_shouldRunPeriodicPhase(now, lastWatchCheckAt, watchPollIntervalMs)) {
|
|
3429
|
+
lastWatchCheckAt = now;
|
|
3407
3430
|
safe('checkWatches', () => {
|
|
3408
3431
|
const { checkWatches } = require('./engine/watches');
|
|
3409
3432
|
const projects = getProjects(config);
|
|
@@ -3432,10 +3455,14 @@ async function tickInner() {
|
|
|
3432
3455
|
Number(config.engine?.prPollCommentsEvery ?? config.engine?.adoPollCommentsEvery) || ENGINE_DEFAULTS.prPollCommentsEvery
|
|
3433
3456
|
);
|
|
3434
3457
|
|
|
3435
|
-
// 2.6. Poll PR status: build, review, merge (every prPollStatusEvery
|
|
3458
|
+
// 2.6. Poll PR status: build, review, merge (every prPollStatusEvery tick-equivalents, default ~12 minutes)
|
|
3436
3459
|
// Awaited so PR state is consistent before discoverWork reads it
|
|
3437
3460
|
// Also re-polls early if previous tick had ADO auth failures (stale build status recovery)
|
|
3438
|
-
|
|
3461
|
+
const prPollStatusIntervalMs = _pollIntervalMsFromTicks(prPollStatusEvery, tickIntervalMs);
|
|
3462
|
+
const prStatusPollDue = _shouldRunPeriodicPhase(now, lastPrStatusPollAt, prPollStatusIntervalMs);
|
|
3463
|
+
const adoPollRetryDue = needsAdoPollRetry();
|
|
3464
|
+
if (prStatusPollDue || adoPollRetryDue) {
|
|
3465
|
+
lastPrStatusPollAt = now;
|
|
3439
3466
|
// Build promise array — enabled+unthrottled polls run concurrently via Promise.allSettled
|
|
3440
3467
|
const statusPolls = [];
|
|
3441
3468
|
if (adoPollEnabled && !isAdoThrottled()) {
|
|
@@ -3475,8 +3502,11 @@ async function tickInner() {
|
|
|
3475
3502
|
} catch (err) { log('warn', `Plan completion check error: ${err?.message || err}`); }
|
|
3476
3503
|
}
|
|
3477
3504
|
|
|
3478
|
-
|
|
3479
|
-
|
|
3505
|
+
const prPollCommentsIntervalMs = _pollIntervalMsFromTicks(prPollCommentsEvery, tickIntervalMs);
|
|
3506
|
+
|
|
3507
|
+
// 2.7. Poll PR threads for human comments (every prPollCommentsEvery tick-equivalents, default ~12 minutes)
|
|
3508
|
+
if (_shouldRunPeriodicPhase(now, lastPrCommentsPollAt, prPollCommentsIntervalMs)) {
|
|
3509
|
+
lastPrCommentsPollAt = now;
|
|
3480
3510
|
// Build promise array — enabled+unthrottled comment polls run concurrently via Promise.allSettled
|
|
3481
3511
|
const commentPolls = [];
|
|
3482
3512
|
if (adoPollEnabled && !isAdoThrottled()) {
|
|
@@ -3923,6 +3953,7 @@ module.exports = {
|
|
|
3923
3953
|
|
|
3924
3954
|
// Tick
|
|
3925
3955
|
tick,
|
|
3956
|
+
_pollIntervalMsFromTicks, _shouldRunPeriodicPhase, // exported for testing
|
|
3926
3957
|
};
|
|
3927
3958
|
|
|
3928
3959
|
// ─── Entrypoint ─────────────────────────────────────────────────────────────
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1624",
|
|
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"
|