bosun 0.41.2 → 0.41.3
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/.env.example +1 -1
- package/agent/agent-prompt-catalog.mjs +971 -0
- package/agent/agent-prompts.mjs +2 -970
- package/agent/agent-supervisor.mjs +6 -3
- package/agent/autofix-git.mjs +33 -0
- package/agent/autofix-prompts.mjs +151 -0
- package/agent/autofix.mjs +11 -175
- package/agent/bosun-skills.mjs +3 -2
- package/bosun.config.example.json +17 -0
- package/bosun.schema.json +87 -188
- package/cli.mjs +34 -1
- package/config/config-doctor.mjs +5 -250
- package/config/config-file-names.mjs +5 -0
- package/config/config.mjs +89 -493
- package/config/executor-config.mjs +493 -0
- package/config/repo-root.mjs +1 -2
- package/config/workspace-health.mjs +242 -0
- package/git/git-safety.mjs +15 -0
- package/github/github-oauth-portal.mjs +46 -0
- package/infra/library-manager-utils.mjs +22 -0
- package/infra/library-manager-well-known-sources.mjs +578 -0
- package/infra/library-manager.mjs +512 -1030
- package/infra/monitor.mjs +28 -9
- package/infra/session-tracker.mjs +10 -7
- package/kanban/kanban-adapter.mjs +17 -1
- package/lib/codebase-audit-manifests.mjs +117 -0
- package/lib/codebase-audit.mjs +18 -115
- package/package.json +18 -3
- package/server/ui-server.mjs +1194 -79
- package/shell/codex-config-file.mjs +178 -0
- package/shell/codex-config.mjs +538 -575
- package/task/task-cli.mjs +54 -3
- package/task/task-executor.mjs +143 -13
- package/task/task-store.mjs +409 -1
- package/telegram/telegram-bot.mjs +127 -0
- package/tools/apply-pr-suggestions.mjs +401 -0
- package/tools/syntax-check.mjs +21 -9
- package/ui/app.js +3 -14
- package/ui/components/kanban-board.js +227 -4
- package/ui/components/session-list.js +85 -5
- package/ui/demo-defaults.js +334 -80
- package/ui/demo.html +155 -0
- package/ui/modules/session-api.js +96 -0
- package/ui/modules/settings-schema.js +1 -2
- package/ui/modules/state.js +21 -3
- package/ui/setup.html +4 -5
- package/ui/styles/components.css +58 -4
- package/ui/tabs/agents.js +12 -15
- package/ui/tabs/control.js +1 -0
- package/ui/tabs/library.js +484 -22
- package/ui/tabs/manual-flows.js +105 -29
- package/ui/tabs/tasks.js +785 -140
- package/ui/tabs/telemetry.js +129 -11
- package/ui/tabs/workflow-canvas-utils.mjs +130 -0
- package/ui/tabs/workflows.js +293 -23
- package/voice/voice-tool-definitions.mjs +757 -0
- package/voice/voice-tools.mjs +34 -778
- package/workflow/manual-flow-audit.mjs +165 -0
- package/workflow/manual-flows.mjs +164 -259
- package/workflow/workflow-engine.mjs +147 -58
- package/workflow/workflow-nodes/definitions.mjs +1207 -0
- package/workflow/workflow-nodes/transforms.mjs +612 -0
- package/workflow/workflow-nodes.mjs +304 -52
- package/workflow/workflow-templates.mjs +313 -191
- package/workflow-templates/_helpers.mjs +154 -0
- package/workflow-templates/agents.mjs +61 -4
- package/workflow-templates/code-quality.mjs +7 -7
- package/workflow-templates/github.mjs +20 -10
- package/workflow-templates/task-batch.mjs +20 -9
- package/workflow-templates/task-lifecycle.mjs +31 -6
- package/workspace/worktree-manager.mjs +277 -3
|
@@ -0,0 +1,493 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { resolve } from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
normalizeExecutorKey,
|
|
5
|
+
getModelsForExecutor,
|
|
6
|
+
MODEL_ALIASES,
|
|
7
|
+
} from "../task/task-complexity.mjs";
|
|
8
|
+
import { CONFIG_FILES } from "./config-file-names.mjs";
|
|
9
|
+
|
|
10
|
+
function parseListValue(value) {
|
|
11
|
+
if (Array.isArray(value)) {
|
|
12
|
+
return value
|
|
13
|
+
.map((item) => String(item || "").trim())
|
|
14
|
+
.filter(Boolean);
|
|
15
|
+
}
|
|
16
|
+
return String(value || "")
|
|
17
|
+
.split(/[,|]/)
|
|
18
|
+
.map((item) => item.trim())
|
|
19
|
+
.filter(Boolean);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function inferExecutorModelsFromVariant(executor, variant) {
|
|
23
|
+
const normalizedExecutor = normalizeExecutorKey(executor);
|
|
24
|
+
if (!normalizedExecutor) return [];
|
|
25
|
+
const normalizedVariant = String(variant || "DEFAULT")
|
|
26
|
+
.trim()
|
|
27
|
+
.toUpperCase();
|
|
28
|
+
if (!normalizedVariant || normalizedVariant === "DEFAULT") return [];
|
|
29
|
+
|
|
30
|
+
const known = getModelsForExecutor(normalizedExecutor);
|
|
31
|
+
const inferred = known.filter((model) => {
|
|
32
|
+
const alias = MODEL_ALIASES[model];
|
|
33
|
+
return (
|
|
34
|
+
String(alias?.variant || "")
|
|
35
|
+
.trim()
|
|
36
|
+
.toUpperCase() === normalizedVariant
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
if (inferred.length > 0) return inferred;
|
|
40
|
+
|
|
41
|
+
// Fallback for variants encoded as model slug with underscores.
|
|
42
|
+
const slugGuess = normalizedVariant.toLowerCase().replaceAll("_", "-");
|
|
43
|
+
if (known.includes(slugGuess)) return [slugGuess];
|
|
44
|
+
|
|
45
|
+
return [];
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function normalizeExecutorModels(executor, models, variant = "DEFAULT") {
|
|
49
|
+
const normalizedExecutor = normalizeExecutorKey(executor);
|
|
50
|
+
if (!normalizedExecutor) return [];
|
|
51
|
+
const input = parseListValue(models);
|
|
52
|
+
const known = new Set(getModelsForExecutor(normalizedExecutor));
|
|
53
|
+
if (input.length === 0) {
|
|
54
|
+
const inferred = inferExecutorModelsFromVariant(
|
|
55
|
+
normalizedExecutor,
|
|
56
|
+
variant,
|
|
57
|
+
);
|
|
58
|
+
return inferred.length > 0 ? inferred : [...known];
|
|
59
|
+
}
|
|
60
|
+
// Preserve custom/deployment slugs in addition to known models so user-provided
|
|
61
|
+
// model routing survives normalization (for example Azure deployment names).
|
|
62
|
+
return [...new Set(input.filter(Boolean))];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function normalizeExecutorEntry(entry, index = 0, total = 1) {
|
|
66
|
+
if (!entry || typeof entry !== "object") return null;
|
|
67
|
+
const executorType = String(entry.executor || "").trim().toUpperCase();
|
|
68
|
+
if (!executorType) return null;
|
|
69
|
+
const variant = String(entry.variant || "DEFAULT").trim() || "DEFAULT";
|
|
70
|
+
const normalized = normalizeExecutorKey(executorType) || "codex";
|
|
71
|
+
const weight = Number(entry.weight);
|
|
72
|
+
const safeWeight = Number.isFinite(weight) ? weight : Math.floor(100 / Math.max(1, total));
|
|
73
|
+
const role =
|
|
74
|
+
String(entry.role || "").trim() ||
|
|
75
|
+
(index === 0 ? "primary" : index === 1 ? "backup" : `executor-${index + 1}`);
|
|
76
|
+
const name =
|
|
77
|
+
String(entry.name || "").trim() ||
|
|
78
|
+
`${normalized}-${String(variant || "default").toLowerCase()}`;
|
|
79
|
+
const models = normalizeExecutorModels(executorType, entry.models, variant);
|
|
80
|
+
const codexProfile = String(
|
|
81
|
+
entry.codexProfile || entry.modelProfile || "",
|
|
82
|
+
).trim();
|
|
83
|
+
|
|
84
|
+
// Provider configuration for the executor (e.g. opencode with specific provider)
|
|
85
|
+
const provider = String(entry.provider || "").trim() || null;
|
|
86
|
+
const providerConfig = entry.providerConfig && typeof entry.providerConfig === "object"
|
|
87
|
+
? { ...entry.providerConfig }
|
|
88
|
+
: null;
|
|
89
|
+
|
|
90
|
+
return {
|
|
91
|
+
name,
|
|
92
|
+
executor: executorType,
|
|
93
|
+
variant,
|
|
94
|
+
weight: safeWeight,
|
|
95
|
+
role,
|
|
96
|
+
enabled: entry.enabled !== false,
|
|
97
|
+
models,
|
|
98
|
+
codexProfile,
|
|
99
|
+
provider,
|
|
100
|
+
providerConfig,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
const DEFAULT_EXECUTORS = {
|
|
106
|
+
executors: [
|
|
107
|
+
{
|
|
108
|
+
name: "codex-default",
|
|
109
|
+
executor: "CODEX",
|
|
110
|
+
variant: "DEFAULT",
|
|
111
|
+
weight: 100,
|
|
112
|
+
role: "primary",
|
|
113
|
+
enabled: true,
|
|
114
|
+
},
|
|
115
|
+
],
|
|
116
|
+
failover: {
|
|
117
|
+
strategy: "next-in-line",
|
|
118
|
+
maxRetries: 3,
|
|
119
|
+
cooldownMinutes: 5,
|
|
120
|
+
disableOnConsecutiveFailures: 3,
|
|
121
|
+
},
|
|
122
|
+
distribution: "primary-only",
|
|
123
|
+
};
|
|
124
|
+
|
|
125
|
+
function parseExecutorsFromEnv() {
|
|
126
|
+
// EXECUTORS=CODEX:DEFAULT:100:gpt-5.2-codex|gpt-5.1-codex-mini
|
|
127
|
+
const raw = process.env.EXECUTORS;
|
|
128
|
+
if (!raw) return null;
|
|
129
|
+
const entries = raw.split(",").map((e) => e.trim());
|
|
130
|
+
const executors = [];
|
|
131
|
+
const roles = ["primary", "backup", "tertiary"];
|
|
132
|
+
for (let i = 0; i < entries.length; i++) {
|
|
133
|
+
const parts = entries[i].split(":");
|
|
134
|
+
if (parts.length < 2) continue;
|
|
135
|
+
const executorType = parts[0].toUpperCase();
|
|
136
|
+
const models = normalizeExecutorModels(
|
|
137
|
+
executorType,
|
|
138
|
+
parts[3] || "",
|
|
139
|
+
parts[1] || "DEFAULT",
|
|
140
|
+
);
|
|
141
|
+
executors.push({
|
|
142
|
+
name: `${parts[0].toLowerCase()}-${parts[1].toLowerCase()}`,
|
|
143
|
+
executor: executorType,
|
|
144
|
+
variant: parts[1],
|
|
145
|
+
weight: parts[2] ? Number(parts[2]) : Math.floor(100 / entries.length),
|
|
146
|
+
role: roles[i] || `executor-${i + 1}`,
|
|
147
|
+
enabled: true,
|
|
148
|
+
models,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
return executors.length ? executors : null;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
function findExecutorMetadataMatch(entry, candidates, index = 0) {
|
|
156
|
+
const entryExecutor = normalizeExecutorKey(entry?.executor);
|
|
157
|
+
const entryVariant = String(entry?.variant || "DEFAULT")
|
|
158
|
+
.trim()
|
|
159
|
+
.toUpperCase();
|
|
160
|
+
const entryRole = String(entry?.role || "")
|
|
161
|
+
.trim()
|
|
162
|
+
.toLowerCase();
|
|
163
|
+
|
|
164
|
+
const exact = candidates.find((candidate) =>
|
|
165
|
+
normalizeExecutorKey(candidate?.executor) === entryExecutor &&
|
|
166
|
+
String(candidate?.variant || "DEFAULT").trim().toUpperCase() === entryVariant &&
|
|
167
|
+
String(candidate?.role || "").trim().toLowerCase() === entryRole
|
|
168
|
+
);
|
|
169
|
+
if (exact) return exact;
|
|
170
|
+
|
|
171
|
+
const byExecutorAndVariant = candidates.find((candidate) =>
|
|
172
|
+
normalizeExecutorKey(candidate?.executor) === entryExecutor &&
|
|
173
|
+
String(candidate?.variant || "DEFAULT").trim().toUpperCase() === entryVariant
|
|
174
|
+
);
|
|
175
|
+
if (byExecutorAndVariant) return byExecutorAndVariant;
|
|
176
|
+
|
|
177
|
+
return candidates[index] || null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
export function loadExecutorConfig(configDir, configData) {
|
|
181
|
+
// 1. Try env var
|
|
182
|
+
const fromEnv = parseExecutorsFromEnv();
|
|
183
|
+
|
|
184
|
+
// 2. Try config file
|
|
185
|
+
let fromFile = null;
|
|
186
|
+
if (configData && typeof configData === "object") {
|
|
187
|
+
fromFile = configData.executors ? configData : null;
|
|
188
|
+
}
|
|
189
|
+
if (!fromFile) {
|
|
190
|
+
for (const name of CONFIG_FILES) {
|
|
191
|
+
const p = resolve(configDir, name);
|
|
192
|
+
if (existsSync(p)) {
|
|
193
|
+
try {
|
|
194
|
+
const raw = JSON.parse(readFileSync(p, "utf8"));
|
|
195
|
+
fromFile = raw.executors ? raw : null;
|
|
196
|
+
break;
|
|
197
|
+
} catch {
|
|
198
|
+
/* invalid JSON — skip */
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const baseExecutors =
|
|
205
|
+
fromEnv || fromFile?.executors || DEFAULT_EXECUTORS.executors;
|
|
206
|
+
const executors = (Array.isArray(baseExecutors) ? baseExecutors : [])
|
|
207
|
+
.map((entry, index, arr) => normalizeExecutorEntry(entry, index, arr.length))
|
|
208
|
+
.filter(Boolean);
|
|
209
|
+
|
|
210
|
+
// Preserve file-defined metadata (for example codexProfile) even when
|
|
211
|
+
// execution topology comes from EXECUTORS env.
|
|
212
|
+
if (fromEnv && Array.isArray(fromFile?.executors) && executors.length > 0) {
|
|
213
|
+
const fileExecutors = fromFile.executors
|
|
214
|
+
.map((entry, index, arr) => normalizeExecutorEntry(entry, index, arr.length))
|
|
215
|
+
.filter(Boolean);
|
|
216
|
+
|
|
217
|
+
for (let index = 0; index < executors.length; index++) {
|
|
218
|
+
const current = executors[index];
|
|
219
|
+
const match = findExecutorMetadataMatch(current, fileExecutors, index);
|
|
220
|
+
if (!match) continue;
|
|
221
|
+
const merged = { ...current };
|
|
222
|
+
if (typeof match.name === "string" && match.name.trim()) {
|
|
223
|
+
merged.name = match.name.trim();
|
|
224
|
+
}
|
|
225
|
+
if (typeof match.enabled === "boolean") {
|
|
226
|
+
merged.enabled = match.enabled;
|
|
227
|
+
}
|
|
228
|
+
if (Array.isArray(match.models) && match.models.length > 0) {
|
|
229
|
+
merged.models = [...new Set(match.models)];
|
|
230
|
+
}
|
|
231
|
+
if (match.codexProfile) {
|
|
232
|
+
merged.codexProfile = match.codexProfile;
|
|
233
|
+
}
|
|
234
|
+
executors[index] = {
|
|
235
|
+
...merged,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
const failover = fromFile?.failover || {
|
|
240
|
+
strategy:
|
|
241
|
+
process.env.FAILOVER_STRATEGY || DEFAULT_EXECUTORS.failover.strategy,
|
|
242
|
+
maxRetries: Number(
|
|
243
|
+
process.env.FAILOVER_MAX_RETRIES || DEFAULT_EXECUTORS.failover.maxRetries,
|
|
244
|
+
),
|
|
245
|
+
cooldownMinutes: Number(
|
|
246
|
+
process.env.FAILOVER_COOLDOWN_MIN ||
|
|
247
|
+
DEFAULT_EXECUTORS.failover.cooldownMinutes,
|
|
248
|
+
),
|
|
249
|
+
disableOnConsecutiveFailures: Number(
|
|
250
|
+
process.env.FAILOVER_DISABLE_AFTER ||
|
|
251
|
+
DEFAULT_EXECUTORS.failover.disableOnConsecutiveFailures,
|
|
252
|
+
),
|
|
253
|
+
};
|
|
254
|
+
const distribution =
|
|
255
|
+
fromFile?.distribution ||
|
|
256
|
+
process.env.EXECUTOR_DISTRIBUTION ||
|
|
257
|
+
DEFAULT_EXECUTORS.distribution;
|
|
258
|
+
|
|
259
|
+
return { executors, failover, distribution };
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
// ── Executor Scheduler ───────────────────────────────────────────────────────
|
|
263
|
+
|
|
264
|
+
export class ExecutorScheduler {
|
|
265
|
+
constructor(config) {
|
|
266
|
+
this.executors = config.executors.filter((e) => e.enabled !== false);
|
|
267
|
+
this.failover = config.failover;
|
|
268
|
+
this.distribution = config.distribution;
|
|
269
|
+
this._roundRobinIndex = 0;
|
|
270
|
+
this._failureCounts = new Map(); // name → consecutive failures
|
|
271
|
+
this._disabledUntil = new Map(); // name → timestamp
|
|
272
|
+
this._workspaceActiveCount = new Map(); // workspaceId → current active executor count
|
|
273
|
+
this._workspaceConfigs = new Map(); // workspaceId → { maxConcurrent, pool, weight }
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Register workspace executor config for concurrency tracking.
|
|
278
|
+
* @param {string} workspaceId
|
|
279
|
+
* @param {{ maxConcurrent?: number, pool?: string, weight?: number }} wsExecutorConfig
|
|
280
|
+
*/
|
|
281
|
+
registerWorkspace(workspaceId, wsExecutorConfig = {}) {
|
|
282
|
+
if (!workspaceId) return;
|
|
283
|
+
this._workspaceConfigs.set(workspaceId, {
|
|
284
|
+
maxConcurrent: wsExecutorConfig.maxConcurrent ?? 3,
|
|
285
|
+
pool: wsExecutorConfig.pool ?? "shared",
|
|
286
|
+
weight: wsExecutorConfig.weight ?? 1.0,
|
|
287
|
+
executors: wsExecutorConfig.executors ?? null,
|
|
288
|
+
});
|
|
289
|
+
if (!this._workspaceActiveCount.has(workspaceId)) {
|
|
290
|
+
this._workspaceActiveCount.set(workspaceId, 0);
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Check if a workspace has available executor slots.
|
|
296
|
+
* @param {string} [workspaceId]
|
|
297
|
+
* @returns {boolean}
|
|
298
|
+
*/
|
|
299
|
+
hasAvailableSlot(workspaceId) {
|
|
300
|
+
if (!workspaceId) return true; // no workspace scope — always available
|
|
301
|
+
const config = this._workspaceConfigs.get(workspaceId);
|
|
302
|
+
if (!config) return true; // no config registered — no limit
|
|
303
|
+
const active = this._workspaceActiveCount.get(workspaceId) || 0;
|
|
304
|
+
return active < config.maxConcurrent;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
/**
|
|
308
|
+
* Acquire an executor slot for a workspace.
|
|
309
|
+
* @param {string} [workspaceId]
|
|
310
|
+
* @returns {boolean} true if slot acquired, false if at limit
|
|
311
|
+
*/
|
|
312
|
+
acquireSlot(workspaceId) {
|
|
313
|
+
if (!workspaceId) return true;
|
|
314
|
+
if (!this.hasAvailableSlot(workspaceId)) return false;
|
|
315
|
+
this._workspaceActiveCount.set(
|
|
316
|
+
workspaceId,
|
|
317
|
+
(this._workspaceActiveCount.get(workspaceId) || 0) + 1,
|
|
318
|
+
);
|
|
319
|
+
return true;
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Release an executor slot for a workspace.
|
|
324
|
+
* @param {string} [workspaceId]
|
|
325
|
+
*/
|
|
326
|
+
releaseSlot(workspaceId) {
|
|
327
|
+
if (!workspaceId) return;
|
|
328
|
+
const current = this._workspaceActiveCount.get(workspaceId) || 0;
|
|
329
|
+
this._workspaceActiveCount.set(workspaceId, Math.max(0, current - 1));
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/**
|
|
333
|
+
* Get workspace executor usage summary.
|
|
334
|
+
* @returns {Array<{ workspaceId: string, active: number, maxConcurrent: number, pool: string, weight: number }>}
|
|
335
|
+
*/
|
|
336
|
+
getWorkspaceSummary() {
|
|
337
|
+
const result = [];
|
|
338
|
+
for (const [wsId, config] of this._workspaceConfigs) {
|
|
339
|
+
result.push({
|
|
340
|
+
workspaceId: wsId,
|
|
341
|
+
active: this._workspaceActiveCount.get(wsId) || 0,
|
|
342
|
+
...config,
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
return result;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/** Get the next executor based on distribution strategy */
|
|
349
|
+
next(workspaceId) {
|
|
350
|
+
// Check workspace slot availability before selecting
|
|
351
|
+
if (workspaceId && !this.hasAvailableSlot(workspaceId)) {
|
|
352
|
+
return null; // workspace at executor capacity
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const available = this._getAvailable();
|
|
356
|
+
if (!available.length) {
|
|
357
|
+
// All disabled — reset and use primary
|
|
358
|
+
this._disabledUntil.clear();
|
|
359
|
+
this._failureCounts.clear();
|
|
360
|
+
return this.executors[0];
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
// For dedicated pools, filter to workspace-assigned executors
|
|
364
|
+
if (workspaceId) {
|
|
365
|
+
const wsConfig = this._workspaceConfigs.get(workspaceId);
|
|
366
|
+
if (wsConfig?.pool === "dedicated" && wsConfig.executors) {
|
|
367
|
+
const dedicated = available.filter((e) =>
|
|
368
|
+
wsConfig.executors.includes(e.name),
|
|
369
|
+
);
|
|
370
|
+
if (dedicated.length) {
|
|
371
|
+
return this._selectByStrategy(dedicated);
|
|
372
|
+
}
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
return this._selectByStrategy(available);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
_selectByStrategy(available) {
|
|
380
|
+
switch (this.distribution) {
|
|
381
|
+
case "round-robin":
|
|
382
|
+
return this._roundRobin(available);
|
|
383
|
+
case "primary-only":
|
|
384
|
+
return available[0];
|
|
385
|
+
case "weighted":
|
|
386
|
+
default:
|
|
387
|
+
return this._weightedSelect(available);
|
|
388
|
+
}
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
/** Report a failure for an executor */
|
|
392
|
+
recordFailure(executorName) {
|
|
393
|
+
const count = (this._failureCounts.get(executorName) || 0) + 1;
|
|
394
|
+
this._failureCounts.set(executorName, count);
|
|
395
|
+
if (count >= this.failover.disableOnConsecutiveFailures) {
|
|
396
|
+
const until = Date.now() + this.failover.cooldownMinutes * 60 * 1000;
|
|
397
|
+
this._disabledUntil.set(executorName, until);
|
|
398
|
+
this._failureCounts.set(executorName, 0);
|
|
399
|
+
}
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
/** Report a success for an executor */
|
|
403
|
+
recordSuccess(executorName) {
|
|
404
|
+
this._failureCounts.set(executorName, 0);
|
|
405
|
+
this._disabledUntil.delete(executorName);
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
/** Get failover executor when current one fails */
|
|
409
|
+
getFailover(currentName) {
|
|
410
|
+
const available = this._getAvailable().filter(
|
|
411
|
+
(e) => e.name !== currentName,
|
|
412
|
+
);
|
|
413
|
+
if (!available.length) return null;
|
|
414
|
+
|
|
415
|
+
switch (this.failover.strategy) {
|
|
416
|
+
case "weighted-random":
|
|
417
|
+
return this._weightedSelect(available);
|
|
418
|
+
case "round-robin":
|
|
419
|
+
return available[0];
|
|
420
|
+
case "next-in-line":
|
|
421
|
+
default: {
|
|
422
|
+
// Find the next one by role priority
|
|
423
|
+
const roleOrder = [
|
|
424
|
+
"primary",
|
|
425
|
+
"backup",
|
|
426
|
+
"tertiary",
|
|
427
|
+
...Array.from({ length: 20 }, (_, i) => `executor-${i + 1}`),
|
|
428
|
+
];
|
|
429
|
+
available.sort(
|
|
430
|
+
(a, b) => roleOrder.indexOf(a.role) - roleOrder.indexOf(b.role),
|
|
431
|
+
);
|
|
432
|
+
return available[0];
|
|
433
|
+
}
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
/** Get summary for display */
|
|
438
|
+
getSummary() {
|
|
439
|
+
const total = this.executors.reduce((s, e) => s + e.weight, 0);
|
|
440
|
+
return this.executors.map((e) => {
|
|
441
|
+
const pct = total > 0 ? Math.round((e.weight / total) * 100) : 0;
|
|
442
|
+
const disabled = this._isDisabled(e.name);
|
|
443
|
+
return {
|
|
444
|
+
...e,
|
|
445
|
+
percentage: pct,
|
|
446
|
+
status: disabled ? "cooldown" : e.enabled ? "active" : "disabled",
|
|
447
|
+
consecutiveFailures: this._failureCounts.get(e.name) || 0,
|
|
448
|
+
};
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/** Format a display string like "COPILOT ⇄ CODEX (50/50)" */
|
|
453
|
+
toDisplayString() {
|
|
454
|
+
const summary = this.getSummary().filter((e) => e.status === "active");
|
|
455
|
+
if (!summary.length) return "No executors available";
|
|
456
|
+
return summary
|
|
457
|
+
.map((e) => `${e.executor}:${e.variant}(${e.percentage}%)`)
|
|
458
|
+
.join(" ⇄ ");
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
_getAvailable() {
|
|
462
|
+
return this.executors.filter(
|
|
463
|
+
(e) => e.enabled !== false && !this._isDisabled(e.name),
|
|
464
|
+
);
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
_isDisabled(name) {
|
|
468
|
+
const until = this._disabledUntil.get(name);
|
|
469
|
+
if (!until) return false;
|
|
470
|
+
if (Date.now() >= until) {
|
|
471
|
+
this._disabledUntil.delete(name);
|
|
472
|
+
return false;
|
|
473
|
+
}
|
|
474
|
+
return true;
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
_roundRobin(available) {
|
|
478
|
+
const idx = this._roundRobinIndex % available.length;
|
|
479
|
+
this._roundRobinIndex++;
|
|
480
|
+
return available[idx];
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
_weightedSelect(available) {
|
|
484
|
+
const totalWeight = available.reduce((s, e) => s + (e.weight || 1), 0);
|
|
485
|
+
let r = Math.random() * totalWeight;
|
|
486
|
+
for (const e of available) {
|
|
487
|
+
r -= e.weight || 1;
|
|
488
|
+
if (r <= 0) return e;
|
|
489
|
+
}
|
|
490
|
+
return available[available.length - 1];
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
|
package/config/repo-root.mjs
CHANGED
|
@@ -2,6 +2,7 @@ import { execSync } from "node:child_process";
|
|
|
2
2
|
import { existsSync, readFileSync } from "node:fs";
|
|
3
3
|
import { resolve, dirname, isAbsolute } from "node:path";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { CONFIG_FILES } from "./config-file-names.mjs";
|
|
5
6
|
|
|
6
7
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
7
8
|
|
|
@@ -130,7 +131,6 @@ export function resolveRepoRoot(options = {}) {
|
|
|
130
131
|
}
|
|
131
132
|
|
|
132
133
|
// Check bosun config for workspace repos
|
|
133
|
-
const CONFIG_FILES = ["bosun.config.json", ".bosun.json", "bosun.json"];
|
|
134
134
|
const configDirs = [...getConfigSearchDirs(), __dirname];
|
|
135
135
|
let fallbackRepo = null;
|
|
136
136
|
for (const cfgName of CONFIG_FILES) {
|
|
@@ -192,7 +192,6 @@ export function resolveAgentRepoRoot(options = {}) {
|
|
|
192
192
|
* @returns {string|null}
|
|
193
193
|
*/
|
|
194
194
|
function _resolveWorkspacePrimaryRepo() {
|
|
195
|
-
const CONFIG_FILES = ["bosun.config.json", ".bosun.json", "bosun.json"];
|
|
196
195
|
const configDirs = [...getConfigSearchDirs(), __dirname];
|
|
197
196
|
for (const cfgName of CONFIG_FILES) {
|
|
198
197
|
for (const dir of configDirs) {
|