@yemi33/minions 0.1.1713 → 0.1.1715
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 +5 -0
- package/README.md +1 -1
- package/dashboard/js/settings.js +1 -1
- package/dashboard.js +27 -7
- package/engine/ado.js +212 -0
- package/engine/cleanup.js +27 -9
- package/engine/copilot-models.json +1 -1
- package/engine/lifecycle.js +11 -7
- package/engine/shared.js +23 -0
- package/engine.js +5 -3
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
package/README.md
CHANGED
|
@@ -541,7 +541,7 @@ Engine behavior is controlled via `config.json`. Key settings:
|
|
|
541
541
|
| `inboxConsolidateThreshold` | 5 | Inbox files needed before consolidation |
|
|
542
542
|
| `worktreeCreateTimeout` | 300000 (5min) | Timeout for each `git worktree add` attempt |
|
|
543
543
|
| `worktreeCreateRetries` | 1 | Retry count for transient `git worktree add` failures (0-3) |
|
|
544
|
-
| `worktreeRoot` | `../worktrees` | Where git worktrees are created |
|
|
544
|
+
| `worktreeRoot` | `../worktrees` | Where git worktrees are created; may be absolute, and Windows users should prefer a short root such as `C:\wt` |
|
|
545
545
|
| `idleAlertMinutes` | 15 | Alert after no dispatch for this many minutes |
|
|
546
546
|
| `restartGracePeriod` | 1200000 (20min) | Grace period for agent re-attachment after engine restart |
|
|
547
547
|
| `shutdownTimeout` | 300000 (5min) | Max wait for active agents during graceful shutdown (SIGTERM/SIGINT) |
|
package/dashboard/js/settings.js
CHANGED
|
@@ -75,7 +75,7 @@ async function openSettings() {
|
|
|
75
75
|
settingsField('Heartbeat Timeout', 'set-heartbeatTimeout', e.heartbeatTimeout || 300000, 'ms', 'No output = dead after this') +
|
|
76
76
|
settingsField('Worktree Create Timeout', 'set-worktreeCreateTimeout', e.worktreeCreateTimeout || 300000, 'ms', 'Timeout for git worktree add (increase for large repos/Windows)') +
|
|
77
77
|
settingsField('Worktree Create Retries', 'set-worktreeCreateRetries', e.worktreeCreateRetries || 1, '', 'Retry count for transient worktree add failures (0-3)') +
|
|
78
|
-
settingsField('Worktree Root', 'set-worktreeRoot', e.worktreeRoot || '../worktrees', '', 'Relative path for git worktrees') +
|
|
78
|
+
settingsField('Worktree Root', 'set-worktreeRoot', e.worktreeRoot || '../worktrees', '', 'Relative or absolute path for git worktrees; on Windows prefer a short path like C:\\wt') +
|
|
79
79
|
settingsField('Idle Alert', 'set-idleAlertMinutes', e.idleAlertMinutes || 15, 'min', 'Alert after agent idle this long') +
|
|
80
80
|
settingsField('Shutdown Timeout', 'set-shutdownTimeout', e.shutdownTimeout || 300000, 'ms', 'Max wait for agents during graceful shutdown') +
|
|
81
81
|
settingsField('Restart Grace Period', 'set-restartGracePeriod', e.restartGracePeriod || 1200000, 'ms', 'Grace period before orphan detection on restart') +
|
package/dashboard.js
CHANGED
|
@@ -135,6 +135,12 @@ function normalizeWorkItemDedupText(value) {
|
|
|
135
135
|
.trim();
|
|
136
136
|
}
|
|
137
137
|
|
|
138
|
+
function normalizeWorkItemDedupTitle(value) {
|
|
139
|
+
return normalizeWorkItemDedupText(value)
|
|
140
|
+
.replace(/\s+/g, ' ')
|
|
141
|
+
.toLowerCase();
|
|
142
|
+
}
|
|
143
|
+
|
|
138
144
|
function resolveWorkItemDedupProject(item, wiPath = '') {
|
|
139
145
|
const projectName = normalizeWorkItemDedupText(item?.project || item?._project || item?._source);
|
|
140
146
|
if (projectName) {
|
|
@@ -181,15 +187,30 @@ function normalizeWorkItemDedupPrIdentity(item, project = null) {
|
|
|
181
187
|
function workItemCreateFingerprint(item, options = {}) {
|
|
182
188
|
const project = resolveWorkItemDedupProject(item, options.wiPath);
|
|
183
189
|
return {
|
|
184
|
-
title:
|
|
190
|
+
title: normalizeWorkItemDedupTitle(item?.title),
|
|
185
191
|
type: routing.normalizeWorkType(item?.type || item?.workType, WORK_TYPE.IMPLEMENT),
|
|
186
|
-
|
|
187
|
-
description: normalizeWorkItemDedupText(item?.description),
|
|
192
|
+
source: normalizeWorkItemDedupText(project?.name || item?.project || item?._project || item?._source).toLowerCase(),
|
|
188
193
|
scope: normalizeWorkItemDedupText(item?.scope).toLowerCase(),
|
|
189
194
|
prIdentity: normalizeWorkItemDedupPrIdentity(item, project),
|
|
190
195
|
};
|
|
191
196
|
}
|
|
192
197
|
|
|
198
|
+
function isCompatibleWorkItemCreateScope(existingFingerprint, candidateFingerprint) {
|
|
199
|
+
const existingFanOut = existingFingerprint.scope === 'fan-out';
|
|
200
|
+
const candidateFanOut = candidateFingerprint.scope === 'fan-out';
|
|
201
|
+
if (existingFanOut || candidateFanOut) {
|
|
202
|
+
return existingFingerprint.scope === candidateFingerprint.scope;
|
|
203
|
+
}
|
|
204
|
+
return true;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function isCompatibleWorkItemCreatePrIdentity(existingFingerprint, candidateFingerprint) {
|
|
208
|
+
if (existingFingerprint.prIdentity || candidateFingerprint.prIdentity) {
|
|
209
|
+
return existingFingerprint.prIdentity === candidateFingerprint.prIdentity;
|
|
210
|
+
}
|
|
211
|
+
return true;
|
|
212
|
+
}
|
|
213
|
+
|
|
193
214
|
function isActiveWorkItemCreateStatus(status) {
|
|
194
215
|
return status === WI_STATUS.PENDING || status === WI_STATUS.DISPATCHED || status === WI_STATUS.QUEUED;
|
|
195
216
|
}
|
|
@@ -211,10 +232,9 @@ function findDuplicateWorkItemCreate(items, candidate, options = {}) {
|
|
|
211
232
|
const existingFingerprint = workItemCreateFingerprint(item, options);
|
|
212
233
|
return existingFingerprint.title === candidateFingerprint.title &&
|
|
213
234
|
existingFingerprint.type === candidateFingerprint.type &&
|
|
214
|
-
existingFingerprint.
|
|
215
|
-
existingFingerprint
|
|
216
|
-
existingFingerprint
|
|
217
|
-
existingFingerprint.prIdentity === candidateFingerprint.prIdentity;
|
|
235
|
+
existingFingerprint.source === candidateFingerprint.source &&
|
|
236
|
+
isCompatibleWorkItemCreateScope(existingFingerprint, candidateFingerprint) &&
|
|
237
|
+
isCompatibleWorkItemCreatePrIdentity(existingFingerprint, candidateFingerprint);
|
|
218
238
|
}) || null;
|
|
219
239
|
}
|
|
220
240
|
|
package/engine/ado.js
CHANGED
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
*/
|
|
5
5
|
|
|
6
6
|
const path = require('path');
|
|
7
|
+
const childProcess = require('child_process');
|
|
7
8
|
const shared = require('./shared');
|
|
8
9
|
const { exec, execAsync, getAdoOrgBase, log, ts, dateStamp, PR_STATUS, createThrottleTracker } = shared;
|
|
9
10
|
const { getPrs } = require('./queries');
|
|
@@ -74,6 +75,207 @@ function sameAdoProject(a, b) {
|
|
|
74
75
|
&& String(a.repoHost || 'ado').toLowerCase() === String(b.repoHost || 'ado').toLowerCase();
|
|
75
76
|
}
|
|
76
77
|
|
|
78
|
+
function decodeUrlSegment(segment) {
|
|
79
|
+
try { return decodeURIComponent(segment); } catch { return segment; }
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function stripGitSuffix(value) {
|
|
83
|
+
return String(value || '').trim().replace(/\.git$/i, '');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function encodeAdoPathSegment(value) {
|
|
87
|
+
return encodeURIComponent(String(value || '').trim());
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function buildAdoPrUrlBase(meta) {
|
|
91
|
+
const repo = meta.repoName || meta.repositoryId || '';
|
|
92
|
+
if (!meta.adoOrg || !meta.adoProject || !repo) return '';
|
|
93
|
+
if (meta.host === 'visualstudio') {
|
|
94
|
+
const collection = meta.defaultCollection ? 'DefaultCollection/' : '';
|
|
95
|
+
return `https://${meta.adoOrg}.visualstudio.com/${collection}${encodeAdoPathSegment(meta.adoProject)}/_git/${encodeAdoPathSegment(repo)}/pullrequest/`;
|
|
96
|
+
}
|
|
97
|
+
return `https://dev.azure.com/${encodeAdoPathSegment(meta.adoOrg)}/${encodeAdoPathSegment(meta.adoProject)}/_git/${encodeAdoPathSegment(repo)}/pullrequest/`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function adoRepoCandidateFromParts(parts, source) {
|
|
101
|
+
const repo = stripGitSuffix(decodeUrlSegment(parts.repo || ''));
|
|
102
|
+
const repositoryId = isAdoGuid(repo) ? repo : '';
|
|
103
|
+
const repoName = repositoryId ? '' : repo;
|
|
104
|
+
const candidate = {
|
|
105
|
+
adoOrg: decodeUrlSegment(parts.org || '').trim(),
|
|
106
|
+
adoProject: decodeUrlSegment(parts.project || '').trim(),
|
|
107
|
+
repoName,
|
|
108
|
+
repositoryId,
|
|
109
|
+
host: parts.host || 'dev.azure',
|
|
110
|
+
defaultCollection: parts.defaultCollection === true,
|
|
111
|
+
source,
|
|
112
|
+
};
|
|
113
|
+
candidate.prUrlBase = buildAdoPrUrlBase(candidate);
|
|
114
|
+
return candidate;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function parseAdoRepoMetadata(value, source) {
|
|
118
|
+
const raw = String(value || '').trim();
|
|
119
|
+
if (!raw) return null;
|
|
120
|
+
|
|
121
|
+
const devAzure = raw.match(/https?:\/\/(?:[^/@]+@)?dev\.azure\.com\/([^/?#]+)\/([^/?#]+)\/_git\/([^/?#\s]+)/i);
|
|
122
|
+
if (devAzure) {
|
|
123
|
+
return adoRepoCandidateFromParts({
|
|
124
|
+
host: 'dev.azure',
|
|
125
|
+
org: devAzure[1],
|
|
126
|
+
project: devAzure[2],
|
|
127
|
+
repo: devAzure[3],
|
|
128
|
+
}, source);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
const visualStudio = raw.match(/https?:\/\/(?:[^/@]+@)?([^/.@]+)\.visualstudio\.com\/(?:(DefaultCollection)\/)?([^/?#]+)\/_git\/([^/?#\s]+)/i);
|
|
132
|
+
if (visualStudio) {
|
|
133
|
+
return adoRepoCandidateFromParts({
|
|
134
|
+
host: 'visualstudio',
|
|
135
|
+
defaultCollection: !!visualStudio[2],
|
|
136
|
+
org: visualStudio[1],
|
|
137
|
+
project: visualStudio[3],
|
|
138
|
+
repo: visualStudio[4],
|
|
139
|
+
}, source);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const ssh = raw.match(/^(?:ssh:\/\/)?git@ssh\.dev\.azure\.com(?::|\/)v3\/([^/]+)\/([^/]+)\/([^/\s]+)$/i);
|
|
143
|
+
if (ssh) {
|
|
144
|
+
return adoRepoCandidateFromParts({
|
|
145
|
+
host: 'dev.azure',
|
|
146
|
+
org: ssh[1],
|
|
147
|
+
project: ssh[2],
|
|
148
|
+
repo: ssh[3],
|
|
149
|
+
}, source);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function parseCanonicalAdoPrId(value, source) {
|
|
156
|
+
const match = String(value || '').trim().match(/^ado:([^/#]+)\/([^/#]+)\/([^/#]+)#\d+$/i);
|
|
157
|
+
if (!match) return null;
|
|
158
|
+
return adoRepoCandidateFromParts({
|
|
159
|
+
host: 'dev.azure',
|
|
160
|
+
org: match[1],
|
|
161
|
+
project: match[2],
|
|
162
|
+
repo: match[3],
|
|
163
|
+
}, source);
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
function normalizeAdoOrgForCompare(value) {
|
|
167
|
+
return String(value || '').trim().toLowerCase().replace(/\.visualstudio\.com$/i, '');
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
function normalizeAdoFieldForCompare(value) {
|
|
171
|
+
return String(value || '').trim().toLowerCase();
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
function isAdoRepairCandidateCompatible(project, candidate) {
|
|
175
|
+
if (!project || !candidate) return false;
|
|
176
|
+
if (project.adoOrg && candidate.adoOrg
|
|
177
|
+
&& normalizeAdoOrgForCompare(project.adoOrg) !== normalizeAdoOrgForCompare(candidate.adoOrg)) return false;
|
|
178
|
+
if (project.adoProject && candidate.adoProject
|
|
179
|
+
&& normalizeAdoFieldForCompare(project.adoProject) !== normalizeAdoFieldForCompare(candidate.adoProject)) return false;
|
|
180
|
+
if (project.repoName && candidate.repoName
|
|
181
|
+
&& normalizeAdoFieldForCompare(project.repoName) !== normalizeAdoFieldForCompare(candidate.repoName)) return false;
|
|
182
|
+
if (project.repositoryId && candidate.repositoryId
|
|
183
|
+
&& normalizeAdoFieldForCompare(project.repositoryId) !== normalizeAdoFieldForCompare(candidate.repositoryId)) return false;
|
|
184
|
+
return true;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function getMissingAdoProjectConfigFields(project) {
|
|
188
|
+
return ['adoOrg', 'adoProject', 'repoName', 'prUrlBase', 'repositoryId']
|
|
189
|
+
.filter(field => !String(project?.[field] || '').trim());
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function getOriginRemoteUrl(project) {
|
|
193
|
+
const localPath = String(project?.localPath || '').trim();
|
|
194
|
+
if (!localPath) return '';
|
|
195
|
+
try {
|
|
196
|
+
const result = childProcess.spawnSync('git', ['-C', localPath, 'config', '--get', 'remote.origin.url'], {
|
|
197
|
+
encoding: 'utf8',
|
|
198
|
+
windowsHide: true,
|
|
199
|
+
timeout: 3000,
|
|
200
|
+
});
|
|
201
|
+
if (result.status !== 0) return '';
|
|
202
|
+
return String(result.stdout || '').trim().split(/\r?\n/)[0] || '';
|
|
203
|
+
} catch {
|
|
204
|
+
return '';
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function collectAdoRepairCandidates(project, prs = []) {
|
|
209
|
+
const candidates = [];
|
|
210
|
+
const add = (candidate) => {
|
|
211
|
+
if (candidate && isAdoRepairCandidateCompatible(project, candidate)) candidates.push(candidate);
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
if (project?.prUrlBase) add(parseAdoRepoMetadata(project.prUrlBase, 'configured prUrlBase'));
|
|
215
|
+
add(parseAdoRepoMetadata(getOriginRemoteUrl(project), 'origin remote'));
|
|
216
|
+
|
|
217
|
+
for (const pr of Array.isArray(prs) ? prs : []) {
|
|
218
|
+
add(parseAdoRepoMetadata(pr?.url, 'tracked PR URL'));
|
|
219
|
+
add(parseCanonicalAdoPrId(pr?.id, 'tracked canonical PR ID'));
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
return candidates;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function persistAdoProjectRepair(project, repairs, source) {
|
|
226
|
+
Object.assign(project, repairs);
|
|
227
|
+
|
|
228
|
+
try {
|
|
229
|
+
let persisted = false;
|
|
230
|
+
mutateJsonFileLocked(adoConfigPath(), (config) => {
|
|
231
|
+
if (!config || typeof config !== 'object' || Array.isArray(config)) return config;
|
|
232
|
+
if (!Array.isArray(config.projects)) return config;
|
|
233
|
+
const target = config.projects.find(p => sameAdoProject(p, project));
|
|
234
|
+
if (!target) return config;
|
|
235
|
+
for (const [field, value] of Object.entries(repairs)) {
|
|
236
|
+
if (!String(target[field] || '').trim()) target[field] = value;
|
|
237
|
+
}
|
|
238
|
+
persisted = true;
|
|
239
|
+
return config;
|
|
240
|
+
}, { defaultValue: { projects: [] }, skipWriteIfUnchanged: true });
|
|
241
|
+
const repairedFields = Object.keys(repairs).join(', ');
|
|
242
|
+
if (persisted) {
|
|
243
|
+
log('info', `Auto-repaired ADO project config for ${getAdoProjectLabel(project)} from ${source}: ${repairedFields}`);
|
|
244
|
+
} else {
|
|
245
|
+
log('warn', `Auto-repaired ADO project config for ${getAdoProjectLabel(project)} from ${source} in memory but could not find the project in config.json to persist it`);
|
|
246
|
+
}
|
|
247
|
+
} catch (e) {
|
|
248
|
+
log('warn', `Auto-repaired ADO project config for ${getAdoProjectLabel(project)} but failed to persist it: ${e.message}`);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function repairAdoProjectConfig(project, purpose, prs = null) {
|
|
253
|
+
if (!project || isGitHubProject(project)) return false;
|
|
254
|
+
const missingBefore = getMissingAdoProjectConfigFields(project);
|
|
255
|
+
if (missingBefore.length === 0) return false;
|
|
256
|
+
|
|
257
|
+
const trackedPrs = prs || shared.safeJson(shared.projectPrPath(project)) || [];
|
|
258
|
+
for (const candidate of collectAdoRepairCandidates(project, trackedPrs)) {
|
|
259
|
+
const repairs = {};
|
|
260
|
+
if (!project.adoOrg && candidate.adoOrg) repairs.adoOrg = candidate.adoOrg;
|
|
261
|
+
if (!project.adoProject && candidate.adoProject) repairs.adoProject = candidate.adoProject;
|
|
262
|
+
if (!project.repoName && candidate.repoName) repairs.repoName = candidate.repoName;
|
|
263
|
+
if (!project.repositoryId && candidate.repositoryId) repairs.repositoryId = candidate.repositoryId;
|
|
264
|
+
if (!project.prUrlBase && candidate.prUrlBase) repairs.prUrlBase = candidate.prUrlBase;
|
|
265
|
+
if (Object.keys(repairs).length > 0) {
|
|
266
|
+
persistAdoProjectRepair(project, repairs, candidate.source);
|
|
267
|
+
return true;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const missingAfter = getMissingAdoProjectConfigFields(project)
|
|
272
|
+
.filter(field => field !== 'repositoryId' || !project.repoName);
|
|
273
|
+
if (missingAfter.some(field => field === 'adoOrg' || field === 'adoProject' || field === 'repoName')) {
|
|
274
|
+
log('warn', `${purpose} cannot auto-repair ADO project config for ${getAdoProjectLabel(project)}: missing ${missingAfter.map(f => `project.${f}`).join(', ')} and no trusted origin remote, tracked PR URL, or canonical ADO PR ID supplied enough metadata`);
|
|
275
|
+
}
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
278
|
+
|
|
77
279
|
function persistAdoRepositoryGuid(project, guid, repoName) {
|
|
78
280
|
if (!isAdoGuid(guid)) return;
|
|
79
281
|
const previous = String(project?.repositoryId || '').trim();
|
|
@@ -387,6 +589,7 @@ async function forEachActivePr(config, token, callback) {
|
|
|
387
589
|
|
|
388
590
|
for (const project of projects) {
|
|
389
591
|
if (isGitHubProject(project)) continue;
|
|
592
|
+
repairAdoProjectConfig(project, 'ADO PR polling');
|
|
390
593
|
if (!project.adoOrg || !project.adoProject) continue;
|
|
391
594
|
|
|
392
595
|
const prs = getPrs(project);
|
|
@@ -870,6 +1073,7 @@ async function reconcilePrs(config) {
|
|
|
870
1073
|
|
|
871
1074
|
for (const project of projects) {
|
|
872
1075
|
if (isGitHubProject(project)) continue;
|
|
1076
|
+
repairAdoProjectConfig(project, 'ADO PR reconciliation');
|
|
873
1077
|
if (!project.adoOrg || !project.adoProject) continue;
|
|
874
1078
|
const adoRepositoryId = getAdoRepositoryId(project);
|
|
875
1079
|
if (!adoRepositoryId) {
|
|
@@ -1020,6 +1224,8 @@ async function reconcilePrs(config) {
|
|
|
1020
1224
|
*/
|
|
1021
1225
|
async function checkLiveReviewStatus(pr, project) {
|
|
1022
1226
|
try {
|
|
1227
|
+
repairAdoProjectConfig(project, 'ADO live review check', pr ? [pr] : null);
|
|
1228
|
+
if (!project.adoOrg || !project.adoProject) return null;
|
|
1023
1229
|
const token = await getAdoToken();
|
|
1024
1230
|
if (!token) return null;
|
|
1025
1231
|
const orgBase = shared.getAdoOrgBase(project);
|
|
@@ -1066,6 +1272,8 @@ async function checkLiveReviewStatus(pr, project) {
|
|
|
1066
1272
|
*/
|
|
1067
1273
|
async function checkLiveBuildAndConflict(pr, project) {
|
|
1068
1274
|
try {
|
|
1275
|
+
repairAdoProjectConfig(project, 'ADO live build/conflict check', pr ? [pr] : null);
|
|
1276
|
+
if (!project.adoOrg || !project.adoProject) return null;
|
|
1069
1277
|
const token = await getAdoToken();
|
|
1070
1278
|
if (!token) return null;
|
|
1071
1279
|
const orgBase = shared.getAdoOrgBase(project);
|
|
@@ -1163,6 +1371,9 @@ async function fetchAdoPrMetadata(prNum, adoOrg, adoProj, adoRepo) {
|
|
|
1163
1371
|
* mergeConflict, url, project } or null on auth failure.
|
|
1164
1372
|
*/
|
|
1165
1373
|
async function fetchSinglePrBuildStatus(project, prNumber) {
|
|
1374
|
+
repairAdoProjectConfig(project, 'ADO single PR status fetch');
|
|
1375
|
+
if (!project.adoOrg || !project.adoProject) return null;
|
|
1376
|
+
|
|
1166
1377
|
const token = await getAdoToken();
|
|
1167
1378
|
if (!token) return null;
|
|
1168
1379
|
|
|
@@ -1261,6 +1472,7 @@ const getAdoThrottleState = () => _adoThrottle.getState();
|
|
|
1261
1472
|
* @returns {{ prNumber: number, url: string }|null}
|
|
1262
1473
|
*/
|
|
1263
1474
|
async function findOpenPrOnBranch(project, branch) {
|
|
1475
|
+
repairAdoProjectConfig(project, 'ADO branch PR lookup');
|
|
1264
1476
|
if (!project.adoOrg || !project.adoProject || !branch) return null;
|
|
1265
1477
|
const adoRepositoryId = getAdoRepositoryId(project);
|
|
1266
1478
|
if (!adoRepositoryId) {
|
package/engine/cleanup.js
CHANGED
|
@@ -39,6 +39,23 @@ function worktreeDirMatchesBranch(dirLower, branch) {
|
|
|
39
39
|
return dirLower === branchSlug || dirLower.includes(branchSlug + '-') || dirLower.endsWith('-' + branchSlug);
|
|
40
40
|
}
|
|
41
41
|
|
|
42
|
+
function worktreeBranchMatches(actualBranch, branch) {
|
|
43
|
+
if (!actualBranch || !branch) return false;
|
|
44
|
+
return sanitizeBranch(actualBranch).toLowerCase() === sanitizeBranch(branch).toLowerCase();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function worktreeMatchesBranch(dirLower, branch, actualBranch = '') {
|
|
48
|
+
return worktreeBranchMatches(actualBranch, branch) || worktreeDirMatchesBranch(dirLower, branch);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function getWorktreeBranch(wtPath) {
|
|
52
|
+
try {
|
|
53
|
+
return exec(`git -C "${wtPath}" branch --show-current`, { encoding: 'utf8', stdio: 'pipe', timeout: 5000, windowsHide: true }).trim();
|
|
54
|
+
} catch {
|
|
55
|
+
return '';
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
42
59
|
let _orphanPidProcessNamesCache = null;
|
|
43
60
|
function _orphanPidProcessNames() {
|
|
44
61
|
if (_orphanPidProcessNamesCache) return _orphanPidProcessNamesCache;
|
|
@@ -235,12 +252,13 @@ function runCleanup(config, verbose = false) {
|
|
|
235
252
|
|
|
236
253
|
let shouldClean = false;
|
|
237
254
|
let isProtected = false;
|
|
255
|
+
const actualBranch = getWorktreeBranch(wtPath);
|
|
238
256
|
|
|
239
257
|
// Check if this worktree's branch is merged/abandoned
|
|
240
|
-
//
|
|
258
|
+
// Prefer actual git branch metadata; compact Windows dirs intentionally omit branch names.
|
|
241
259
|
const dirLower = dir.toLowerCase();
|
|
242
260
|
for (const branch of mergedBranches) {
|
|
243
|
-
if (
|
|
261
|
+
if (worktreeMatchesBranch(dirLower, branch, actualBranch)) {
|
|
244
262
|
shouldClean = true;
|
|
245
263
|
break;
|
|
246
264
|
}
|
|
@@ -249,8 +267,7 @@ function runCleanup(config, verbose = false) {
|
|
|
249
267
|
// Check if referenced by active/pending dispatch (use sanitized branch comparison)
|
|
250
268
|
const isReferenced = [...dispatch.pending, ...(dispatch.active || [])].some(d => {
|
|
251
269
|
if (!d.meta?.branch) return false;
|
|
252
|
-
|
|
253
|
-
return dirLower.includes(dispBranch);
|
|
270
|
+
return worktreeMatchesBranch(dirLower, d.meta.branch, actualBranch);
|
|
254
271
|
});
|
|
255
272
|
if (isReferenced) isProtected = true;
|
|
256
273
|
|
|
@@ -275,8 +292,7 @@ function runCleanup(config, verbose = false) {
|
|
|
275
292
|
for (const pf of fs.readdirSync(checkDir).filter(f => f.endsWith('.json'))) {
|
|
276
293
|
const plan = safeJson(path.join(checkDir, pf));
|
|
277
294
|
if (plan?.branch_strategy === 'shared-branch' && plan?.feature_branch && plan?.status !== 'completed') {
|
|
278
|
-
|
|
279
|
-
if (dirLower.includes(planBranch)) {
|
|
295
|
+
if (worktreeMatchesBranch(dirLower, plan.feature_branch, actualBranch)) {
|
|
280
296
|
isProtected = true;
|
|
281
297
|
if (shouldClean) {
|
|
282
298
|
shouldClean = false;
|
|
@@ -291,7 +307,7 @@ function runCleanup(config, verbose = false) {
|
|
|
291
307
|
} catch (e) { log('warn', 'check shared-branch protection: ' + e.message); }
|
|
292
308
|
}
|
|
293
309
|
|
|
294
|
-
wtEntries.push({ dir, wtPath, mtime, shouldClean, isProtected });
|
|
310
|
+
wtEntries.push({ dir, wtPath, mtime, shouldClean, isProtected, actualBranch });
|
|
295
311
|
}
|
|
296
312
|
|
|
297
313
|
// Enforce max worktree cap — if over limit, mark oldest unprotected for cleanup
|
|
@@ -323,7 +339,7 @@ function runCleanup(config, verbose = false) {
|
|
|
323
339
|
const entryDirLower = entry.dir.toLowerCase();
|
|
324
340
|
let stillMerged = false;
|
|
325
341
|
for (const branch of freshMergedBranches) {
|
|
326
|
-
if (
|
|
342
|
+
if (worktreeMatchesBranch(entryDirLower, branch, entry.actualBranch)) {
|
|
327
343
|
stillMerged = true;
|
|
328
344
|
break;
|
|
329
345
|
}
|
|
@@ -331,7 +347,7 @@ function runCleanup(config, verbose = false) {
|
|
|
331
347
|
// If originally marked due to merged branch but PR was reopened, skip deletion
|
|
332
348
|
if (!stillMerged) {
|
|
333
349
|
// Check if it was marked for age/cap cleanup (not branch-based) — those are still valid
|
|
334
|
-
const wasMarkedByBranch = [...mergedBranches].some(branch =>
|
|
350
|
+
const wasMarkedByBranch = [...mergedBranches].some(branch => worktreeMatchesBranch(entryDirLower, branch, entry.actualBranch));
|
|
335
351
|
if (wasMarkedByBranch) {
|
|
336
352
|
if (verbose) console.log(` Skipping worktree ${entry.dir}: PR was reopened since initial check`);
|
|
337
353
|
log('info', `Worktree deletion skipped — PR reopened: ${entry.dir}`);
|
|
@@ -832,4 +848,6 @@ module.exports = {
|
|
|
832
848
|
runCleanup,
|
|
833
849
|
scrubStaleMetrics,
|
|
834
850
|
worktreeDirMatchesBranch, // exported for testing
|
|
851
|
+
worktreeMatchesBranch, // exported for testing
|
|
852
|
+
getWorktreeBranch, // exported for lifecycle cleanup
|
|
835
853
|
};
|
package/engine/lifecycle.js
CHANGED
|
@@ -14,7 +14,7 @@ const { trackEngineUsage } = require('./llm');
|
|
|
14
14
|
const { resolveRuntime } = require('./runtimes');
|
|
15
15
|
const queries = require('./queries');
|
|
16
16
|
const { isBranchActive } = require('./cooldown');
|
|
17
|
-
const {
|
|
17
|
+
const { worktreeMatchesBranch, getWorktreeBranch } = require('./cleanup');
|
|
18
18
|
const { getConfig, getInboxFiles, getNotes, getPrs, getDispatch,
|
|
19
19
|
MINIONS_DIR, ENGINE_DIR, PLANS_DIR, PRD_DIR, INBOX_DIR, AGENTS_DIR } = queries;
|
|
20
20
|
|
|
@@ -446,10 +446,12 @@ function cleanupPlanWorktrees(planFile, plan, projects, config) {
|
|
|
446
446
|
if (!fs.existsSync(wtRoot)) continue;
|
|
447
447
|
const dirs = fs.readdirSync(wtRoot);
|
|
448
448
|
for (const dir of dirs) {
|
|
449
|
+
const wtPath = path.join(wtRoot, dir);
|
|
449
450
|
const dirLower = dir.toLowerCase();
|
|
450
|
-
const
|
|
451
|
+
const actualBranch = getWorktreeBranch(wtPath);
|
|
452
|
+
const actualBranchSlug = actualBranch ? shared.sanitizeBranch(actualBranch).toLowerCase() : '';
|
|
453
|
+
const matches = [...branchSlugs].some(slug => dirLower.includes(slug) || actualBranchSlug === slug);
|
|
451
454
|
if (matches) {
|
|
452
|
-
const wtPath = path.join(wtRoot, dir);
|
|
453
455
|
if (shared.removeWorktree(wtPath, root, wtRoot)) cleanedWt++;
|
|
454
456
|
}
|
|
455
457
|
}
|
|
@@ -1587,13 +1589,13 @@ async function handlePostMerge(pr, project, config, newStatus) {
|
|
|
1587
1589
|
if (pr.branch && project) {
|
|
1588
1590
|
const root = path.resolve(project.localPath);
|
|
1589
1591
|
const wtRoot = path.resolve(root, config.engine?.worktreeRoot || '../worktrees');
|
|
1590
|
-
// Find worktrees matching this branch
|
|
1592
|
+
// Find worktrees matching this branch; compact Windows dirs require branch metadata.
|
|
1591
1593
|
try {
|
|
1592
1594
|
const dirs = require('fs').readdirSync(wtRoot);
|
|
1593
1595
|
for (const dir of dirs) {
|
|
1596
|
+
const wtPath = path.join(wtRoot, dir);
|
|
1594
1597
|
const dirLower = dir.toLowerCase();
|
|
1595
|
-
if (
|
|
1596
|
-
const wtPath = path.join(wtRoot, dir);
|
|
1598
|
+
if (worktreeMatchesBranch(dirLower, pr.branch, getWorktreeBranch(wtPath)) || dir === pr.branch || dir === `bt-${prNum}`) {
|
|
1597
1599
|
try {
|
|
1598
1600
|
if (!require('fs').statSync(wtPath).isDirectory()) continue;
|
|
1599
1601
|
execSilent(`git worktree remove "${wtPath}" --force`, { cwd: root, stdio: 'pipe', timeout: 15000 });
|
|
@@ -2724,7 +2726,9 @@ async function runPostCompletionHooks(dispatchItem, agentId, code, stdout, confi
|
|
|
2724
2726
|
// Find the worktree directory for this dispatch's branch
|
|
2725
2727
|
const branchSlug = shared.sanitizeBranch ? shared.sanitizeBranch(meta.branch) : meta.branch.replace(/[^a-zA-Z0-9._\-\/]/g, '-');
|
|
2726
2728
|
const dirs = fs.readdirSync(worktreeRoot).filter(d => {
|
|
2727
|
-
|
|
2729
|
+
const wtPath = path.join(worktreeRoot, d);
|
|
2730
|
+
return fs.statSync(wtPath).isDirectory()
|
|
2731
|
+
&& worktreeMatchesBranch(d.toLowerCase(), meta.branch, getWorktreeBranch(wtPath));
|
|
2728
2732
|
});
|
|
2729
2733
|
// Only remove if no other active dispatch uses this branch
|
|
2730
2734
|
const dispatch = getDispatch();
|
package/engine/shared.js
CHANGED
|
@@ -1702,6 +1702,28 @@ function sanitizeBranch(name) {
|
|
|
1702
1702
|
return String(name).replace(/[^a-zA-Z0-9._\-\/]/g, '-').slice(0, 200);
|
|
1703
1703
|
}
|
|
1704
1704
|
|
|
1705
|
+
function _worktreeNameSuffix(dispatchId, projectName, branchName) {
|
|
1706
|
+
const id = String(dispatchId || '').split('-').filter(Boolean).pop();
|
|
1707
|
+
if (id) return safeSlugComponent(id, 32);
|
|
1708
|
+
const hash = crypto.createHash('sha1')
|
|
1709
|
+
.update(`${projectName || 'default'}\n${branchName || 'worktree'}`)
|
|
1710
|
+
.digest('hex')
|
|
1711
|
+
.slice(0, 12);
|
|
1712
|
+
return hash;
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
function buildWorktreeDirName({
|
|
1716
|
+
dispatchId = '',
|
|
1717
|
+
projectName = 'default',
|
|
1718
|
+
branchName = 'worktree',
|
|
1719
|
+
platform = process.platform,
|
|
1720
|
+
} = {}) {
|
|
1721
|
+
const suffix = _worktreeNameSuffix(dispatchId, projectName, branchName);
|
|
1722
|
+
if (platform === 'win32') return `W-${suffix}`;
|
|
1723
|
+
const projectSlug = String(projectName || 'default').replace(/[^a-zA-Z0-9_-]/g, '-');
|
|
1724
|
+
return `${projectSlug}-${sanitizeBranch(branchName || 'worktree')}-${suffix}`;
|
|
1725
|
+
}
|
|
1726
|
+
|
|
1705
1727
|
// ── HTTP Origin Allowlist & Security Headers ─────────────────────────────────
|
|
1706
1728
|
// Pure helpers used by dashboard.js to gate mutating requests against an
|
|
1707
1729
|
// explicit allowlist of local origins and to attach uniform security response
|
|
@@ -2661,6 +2683,7 @@ module.exports = {
|
|
|
2661
2683
|
getAdoOrgBase,
|
|
2662
2684
|
sanitizePath,
|
|
2663
2685
|
sanitizeBranch,
|
|
2686
|
+
buildWorktreeDirName, // exported for testing
|
|
2664
2687
|
isLiveCommandCenterPath,
|
|
2665
2688
|
describeCcProtectedPaths,
|
|
2666
2689
|
renderCcSystemPrompt,
|
package/engine.js
CHANGED
|
@@ -513,9 +513,11 @@ async function spawnAgent(dispatchItem, config) {
|
|
|
513
513
|
|
|
514
514
|
if (branchName) {
|
|
515
515
|
updateAgentStatus(id, AGENT_STATUS.WORKTREE_SETUP, `Setting up worktree for branch ${branchName}`);
|
|
516
|
-
const
|
|
517
|
-
|
|
518
|
-
|
|
516
|
+
const wtDirName = shared.buildWorktreeDirName({
|
|
517
|
+
dispatchId: id,
|
|
518
|
+
projectName: project.name || 'default',
|
|
519
|
+
branchName,
|
|
520
|
+
});
|
|
519
521
|
worktreePath = path.resolve(rootDir, engineConfig.worktreeRoot || '../worktrees', wtDirName);
|
|
520
522
|
|
|
521
523
|
// If branch is already checked out in an existing worktree, reuse it
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@yemi33/minions",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1715",
|
|
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"
|