bosun 0.41.2 → 0.41.4
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-pool.mjs +9 -2
- package/agent/agent-prompt-catalog.mjs +971 -0
- package/agent/agent-prompts.mjs +2 -970
- package/agent/agent-supervisor.mjs +119 -6
- 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 +35 -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/setup-web-server.mjs +58 -5
- package/server/ui-server.mjs +1394 -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 +28 -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 +338 -84
- 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 +43 -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 +848 -141
- 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 +358 -63
- 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 +44 -11
- package/workflow-templates/task-lifecycle.mjs +31 -6
- package/workspace/worktree-manager.mjs +277 -3
package/config/config.mjs
CHANGED
|
@@ -27,20 +27,12 @@ import {
|
|
|
27
27
|
import { resolveAgentRepoRoot, resolveRepoLocalBosunDir } from "./repo-root.mjs";
|
|
28
28
|
import { applyAllCompatibility } from "../compat.mjs";
|
|
29
29
|
import { ensureTestRuntimeSandbox } from "../infra/test-runtime.mjs";
|
|
30
|
-
import {
|
|
31
|
-
|
|
32
|
-
getModelsForExecutor,
|
|
33
|
-
MODEL_ALIASES,
|
|
34
|
-
} from "../task/task-complexity.mjs";
|
|
30
|
+
import { CONFIG_FILES } from "./config-file-names.mjs";
|
|
31
|
+
import { ExecutorScheduler, loadExecutorConfig } from "./executor-config.mjs";
|
|
35
32
|
import { normalizePipelineWorkflows } from "../workflow/pipeline-workflows.mjs";
|
|
36
33
|
|
|
37
34
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
38
35
|
|
|
39
|
-
const CONFIG_FILES = [
|
|
40
|
-
"bosun.config.json",
|
|
41
|
-
".bosun.json",
|
|
42
|
-
"bosun.json",
|
|
43
|
-
];
|
|
44
36
|
|
|
45
37
|
function hasSetupMarkers(dir) {
|
|
46
38
|
const markers = [".env", ...CONFIG_FILES];
|
|
@@ -466,100 +458,6 @@ function isEnvEnabled(value, defaultValue = false) {
|
|
|
466
458
|
return parseEnvBoolean(value, defaultValue);
|
|
467
459
|
}
|
|
468
460
|
|
|
469
|
-
function parseListValue(value) {
|
|
470
|
-
if (Array.isArray(value)) {
|
|
471
|
-
return value
|
|
472
|
-
.map((item) => String(item || "").trim())
|
|
473
|
-
.filter(Boolean);
|
|
474
|
-
}
|
|
475
|
-
return String(value || "")
|
|
476
|
-
.split(/[,|]/)
|
|
477
|
-
.map((item) => item.trim())
|
|
478
|
-
.filter(Boolean);
|
|
479
|
-
}
|
|
480
|
-
|
|
481
|
-
function inferExecutorModelsFromVariant(executor, variant) {
|
|
482
|
-
const normalizedExecutor = normalizeExecutorKey(executor);
|
|
483
|
-
if (!normalizedExecutor) return [];
|
|
484
|
-
const normalizedVariant = String(variant || "DEFAULT")
|
|
485
|
-
.trim()
|
|
486
|
-
.toUpperCase();
|
|
487
|
-
if (!normalizedVariant || normalizedVariant === "DEFAULT") return [];
|
|
488
|
-
|
|
489
|
-
const known = getModelsForExecutor(normalizedExecutor);
|
|
490
|
-
const inferred = known.filter((model) => {
|
|
491
|
-
const alias = MODEL_ALIASES[model];
|
|
492
|
-
return (
|
|
493
|
-
String(alias?.variant || "")
|
|
494
|
-
.trim()
|
|
495
|
-
.toUpperCase() === normalizedVariant
|
|
496
|
-
);
|
|
497
|
-
});
|
|
498
|
-
if (inferred.length > 0) return inferred;
|
|
499
|
-
|
|
500
|
-
// Fallback for variants encoded as model slug with underscores.
|
|
501
|
-
const slugGuess = normalizedVariant.toLowerCase().replaceAll("_", "-");
|
|
502
|
-
if (known.includes(slugGuess)) return [slugGuess];
|
|
503
|
-
|
|
504
|
-
return [];
|
|
505
|
-
}
|
|
506
|
-
|
|
507
|
-
function normalizeExecutorModels(executor, models, variant = "DEFAULT") {
|
|
508
|
-
const normalizedExecutor = normalizeExecutorKey(executor);
|
|
509
|
-
if (!normalizedExecutor) return [];
|
|
510
|
-
const input = parseListValue(models);
|
|
511
|
-
const known = new Set(getModelsForExecutor(normalizedExecutor));
|
|
512
|
-
if (input.length === 0) {
|
|
513
|
-
const inferred = inferExecutorModelsFromVariant(
|
|
514
|
-
normalizedExecutor,
|
|
515
|
-
variant,
|
|
516
|
-
);
|
|
517
|
-
return inferred.length > 0 ? inferred : [...known];
|
|
518
|
-
}
|
|
519
|
-
// Preserve custom/deployment slugs in addition to known models so user-provided
|
|
520
|
-
// model routing survives normalization (for example Azure deployment names).
|
|
521
|
-
return [...new Set(input.filter(Boolean))];
|
|
522
|
-
}
|
|
523
|
-
|
|
524
|
-
function normalizeExecutorEntry(entry, index = 0, total = 1) {
|
|
525
|
-
if (!entry || typeof entry !== "object") return null;
|
|
526
|
-
const executorType = String(entry.executor || "").trim().toUpperCase();
|
|
527
|
-
if (!executorType) return null;
|
|
528
|
-
const variant = String(entry.variant || "DEFAULT").trim() || "DEFAULT";
|
|
529
|
-
const normalized = normalizeExecutorKey(executorType) || "codex";
|
|
530
|
-
const weight = Number(entry.weight);
|
|
531
|
-
const safeWeight = Number.isFinite(weight) ? weight : Math.floor(100 / Math.max(1, total));
|
|
532
|
-
const role =
|
|
533
|
-
String(entry.role || "").trim() ||
|
|
534
|
-
(index === 0 ? "primary" : index === 1 ? "backup" : `executor-${index + 1}`);
|
|
535
|
-
const name =
|
|
536
|
-
String(entry.name || "").trim() ||
|
|
537
|
-
`${normalized}-${String(variant || "default").toLowerCase()}`;
|
|
538
|
-
const models = normalizeExecutorModels(executorType, entry.models, variant);
|
|
539
|
-
const codexProfile = String(
|
|
540
|
-
entry.codexProfile || entry.modelProfile || "",
|
|
541
|
-
).trim();
|
|
542
|
-
|
|
543
|
-
// Provider configuration for the executor (e.g. opencode with specific provider)
|
|
544
|
-
const provider = String(entry.provider || "").trim() || null;
|
|
545
|
-
const providerConfig = entry.providerConfig && typeof entry.providerConfig === "object"
|
|
546
|
-
? { ...entry.providerConfig }
|
|
547
|
-
: null;
|
|
548
|
-
|
|
549
|
-
return {
|
|
550
|
-
name,
|
|
551
|
-
executor: executorType,
|
|
552
|
-
variant,
|
|
553
|
-
weight: safeWeight,
|
|
554
|
-
role,
|
|
555
|
-
enabled: entry.enabled !== false,
|
|
556
|
-
models,
|
|
557
|
-
codexProfile,
|
|
558
|
-
provider,
|
|
559
|
-
providerConfig,
|
|
560
|
-
};
|
|
561
|
-
}
|
|
562
|
-
|
|
563
461
|
function buildDefaultTriggerTemplates() {
|
|
564
462
|
return [
|
|
565
463
|
{
|
|
@@ -643,6 +541,82 @@ function toBoundedInt(value, fallback, min, max) {
|
|
|
643
541
|
return Math.min(max, Math.max(min, rounded));
|
|
644
542
|
}
|
|
645
543
|
|
|
544
|
+
const WORKTREE_BOOTSTRAP_STACK_IDS = Object.freeze([
|
|
545
|
+
"node",
|
|
546
|
+
"python",
|
|
547
|
+
"go",
|
|
548
|
+
"rust",
|
|
549
|
+
"java",
|
|
550
|
+
"dotnet",
|
|
551
|
+
"ruby",
|
|
552
|
+
"php",
|
|
553
|
+
"make",
|
|
554
|
+
]);
|
|
555
|
+
|
|
556
|
+
function normalizeStringListConfig(value) {
|
|
557
|
+
const source = Array.isArray(value)
|
|
558
|
+
? value
|
|
559
|
+
: typeof value === "string"
|
|
560
|
+
? value.split(/\r?\n|,/)
|
|
561
|
+
: [];
|
|
562
|
+
const values = [];
|
|
563
|
+
for (const entry of source) {
|
|
564
|
+
const normalized = String(entry || "").trim();
|
|
565
|
+
if (!normalized || values.includes(normalized)) continue;
|
|
566
|
+
values.push(normalized);
|
|
567
|
+
}
|
|
568
|
+
return values;
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function freezeNestedStringListMap(value) {
|
|
572
|
+
const source = value && typeof value === "object" && !Array.isArray(value)
|
|
573
|
+
? value
|
|
574
|
+
: {};
|
|
575
|
+
const normalized = {};
|
|
576
|
+
for (const [rawKey, rawValue] of Object.entries(source)) {
|
|
577
|
+
const key = String(rawKey || "").trim().toLowerCase();
|
|
578
|
+
if (!key) continue;
|
|
579
|
+
const values = normalizeStringListConfig(rawValue);
|
|
580
|
+
if (values.length === 0) continue;
|
|
581
|
+
normalized[key] = Object.freeze(values);
|
|
582
|
+
}
|
|
583
|
+
return Object.freeze(normalized);
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
function resolveWorktreeBootstrapConfig(configData = {}) {
|
|
587
|
+
const raw = configData.worktreeBootstrap && typeof configData.worktreeBootstrap === "object"
|
|
588
|
+
? configData.worktreeBootstrap
|
|
589
|
+
: {};
|
|
590
|
+
const commandsByStack = {
|
|
591
|
+
...freezeNestedStringListMap(raw.commandsByStack),
|
|
592
|
+
};
|
|
593
|
+
for (const stackId of WORKTREE_BOOTSTRAP_STACK_IDS) {
|
|
594
|
+
const envName = `WORKTREE_BOOTSTRAP_${stackId.toUpperCase()}_COMMAND`;
|
|
595
|
+
const envValues = normalizeStringListConfig(process.env[envName]);
|
|
596
|
+
if (envValues.length > 0) {
|
|
597
|
+
commandsByStack[stackId] = Object.freeze(envValues);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
return Object.freeze({
|
|
601
|
+
enabled: isEnvEnabled(
|
|
602
|
+
process.env.WORKTREE_BOOTSTRAP_ENABLED ?? raw.enabled,
|
|
603
|
+
false,
|
|
604
|
+
),
|
|
605
|
+
linkSharedPaths: isEnvEnabled(
|
|
606
|
+
process.env.WORKTREE_BOOTSTRAP_LINK_SHARED_PATHS ?? raw.linkSharedPaths,
|
|
607
|
+
true,
|
|
608
|
+
),
|
|
609
|
+
commandTimeoutMs: toBoundedInt(
|
|
610
|
+
process.env.WORKTREE_BOOTSTRAP_COMMAND_TIMEOUT_MS ?? raw.commandTimeoutMs,
|
|
611
|
+
10 * 60 * 1000,
|
|
612
|
+
1000,
|
|
613
|
+
60 * 60 * 1000,
|
|
614
|
+
),
|
|
615
|
+
commandsByStack: Object.freeze(commandsByStack),
|
|
616
|
+
sharedPathsByStack: freezeNestedStringListMap(raw.sharedPathsByStack),
|
|
617
|
+
});
|
|
618
|
+
}
|
|
619
|
+
|
|
646
620
|
function normalizeStatusList(rawStates) {
|
|
647
621
|
const source = Array.isArray(rawStates)
|
|
648
622
|
? rawStates
|
|
@@ -860,55 +834,6 @@ function detectRepoRoot() {
|
|
|
860
834
|
* }
|
|
861
835
|
*/
|
|
862
836
|
|
|
863
|
-
const DEFAULT_EXECUTORS = {
|
|
864
|
-
executors: [
|
|
865
|
-
{
|
|
866
|
-
name: "codex-default",
|
|
867
|
-
executor: "CODEX",
|
|
868
|
-
variant: "DEFAULT",
|
|
869
|
-
weight: 100,
|
|
870
|
-
role: "primary",
|
|
871
|
-
enabled: true,
|
|
872
|
-
},
|
|
873
|
-
],
|
|
874
|
-
failover: {
|
|
875
|
-
strategy: "next-in-line",
|
|
876
|
-
maxRetries: 3,
|
|
877
|
-
cooldownMinutes: 5,
|
|
878
|
-
disableOnConsecutiveFailures: 3,
|
|
879
|
-
},
|
|
880
|
-
distribution: "primary-only",
|
|
881
|
-
};
|
|
882
|
-
|
|
883
|
-
function parseExecutorsFromEnv() {
|
|
884
|
-
// EXECUTORS=CODEX:DEFAULT:100:gpt-5.2-codex|gpt-5.1-codex-mini
|
|
885
|
-
const raw = process.env.EXECUTORS;
|
|
886
|
-
if (!raw) return null;
|
|
887
|
-
const entries = raw.split(",").map((e) => e.trim());
|
|
888
|
-
const executors = [];
|
|
889
|
-
const roles = ["primary", "backup", "tertiary"];
|
|
890
|
-
for (let i = 0; i < entries.length; i++) {
|
|
891
|
-
const parts = entries[i].split(":");
|
|
892
|
-
if (parts.length < 2) continue;
|
|
893
|
-
const executorType = parts[0].toUpperCase();
|
|
894
|
-
const models = normalizeExecutorModels(
|
|
895
|
-
executorType,
|
|
896
|
-
parts[3] || "",
|
|
897
|
-
parts[1] || "DEFAULT",
|
|
898
|
-
);
|
|
899
|
-
executors.push({
|
|
900
|
-
name: `${parts[0].toLowerCase()}-${parts[1].toLowerCase()}`,
|
|
901
|
-
executor: executorType,
|
|
902
|
-
variant: parts[1],
|
|
903
|
-
weight: parts[2] ? Number(parts[2]) : Math.floor(100 / entries.length),
|
|
904
|
-
role: roles[i] || `executor-${i + 1}`,
|
|
905
|
-
enabled: true,
|
|
906
|
-
models,
|
|
907
|
-
});
|
|
908
|
-
}
|
|
909
|
-
return executors.length ? executors : null;
|
|
910
|
-
}
|
|
911
|
-
|
|
912
837
|
function normalizePrimaryAgent(value) {
|
|
913
838
|
const raw = String(value || "")
|
|
914
839
|
.trim()
|
|
@@ -969,345 +894,6 @@ function normalizeProjectRequirementsProfile(value) {
|
|
|
969
894
|
return "feature";
|
|
970
895
|
}
|
|
971
896
|
|
|
972
|
-
function findExecutorMetadataMatch(entry, candidates, index = 0) {
|
|
973
|
-
const entryExecutor = normalizeExecutorKey(entry?.executor);
|
|
974
|
-
const entryVariant = String(entry?.variant || "DEFAULT")
|
|
975
|
-
.trim()
|
|
976
|
-
.toUpperCase();
|
|
977
|
-
const entryRole = String(entry?.role || "")
|
|
978
|
-
.trim()
|
|
979
|
-
.toLowerCase();
|
|
980
|
-
|
|
981
|
-
const exact = candidates.find((candidate) =>
|
|
982
|
-
normalizeExecutorKey(candidate?.executor) === entryExecutor &&
|
|
983
|
-
String(candidate?.variant || "DEFAULT").trim().toUpperCase() === entryVariant &&
|
|
984
|
-
String(candidate?.role || "").trim().toLowerCase() === entryRole
|
|
985
|
-
);
|
|
986
|
-
if (exact) return exact;
|
|
987
|
-
|
|
988
|
-
const byExecutorAndVariant = candidates.find((candidate) =>
|
|
989
|
-
normalizeExecutorKey(candidate?.executor) === entryExecutor &&
|
|
990
|
-
String(candidate?.variant || "DEFAULT").trim().toUpperCase() === entryVariant
|
|
991
|
-
);
|
|
992
|
-
if (byExecutorAndVariant) return byExecutorAndVariant;
|
|
993
|
-
|
|
994
|
-
return candidates[index] || null;
|
|
995
|
-
}
|
|
996
|
-
|
|
997
|
-
function loadExecutorConfig(configDir, configData) {
|
|
998
|
-
// 1. Try env var
|
|
999
|
-
const fromEnv = parseExecutorsFromEnv();
|
|
1000
|
-
|
|
1001
|
-
// 2. Try config file
|
|
1002
|
-
let fromFile = null;
|
|
1003
|
-
if (configData && typeof configData === "object") {
|
|
1004
|
-
fromFile = configData.executors ? configData : null;
|
|
1005
|
-
}
|
|
1006
|
-
if (!fromFile) {
|
|
1007
|
-
for (const name of CONFIG_FILES) {
|
|
1008
|
-
const p = resolve(configDir, name);
|
|
1009
|
-
if (existsSync(p)) {
|
|
1010
|
-
try {
|
|
1011
|
-
const raw = JSON.parse(readFileSync(p, "utf8"));
|
|
1012
|
-
fromFile = raw.executors ? raw : null;
|
|
1013
|
-
break;
|
|
1014
|
-
} catch {
|
|
1015
|
-
/* invalid JSON — skip */
|
|
1016
|
-
}
|
|
1017
|
-
}
|
|
1018
|
-
}
|
|
1019
|
-
}
|
|
1020
|
-
|
|
1021
|
-
const baseExecutors =
|
|
1022
|
-
fromEnv || fromFile?.executors || DEFAULT_EXECUTORS.executors;
|
|
1023
|
-
const executors = (Array.isArray(baseExecutors) ? baseExecutors : [])
|
|
1024
|
-
.map((entry, index, arr) => normalizeExecutorEntry(entry, index, arr.length))
|
|
1025
|
-
.filter(Boolean);
|
|
1026
|
-
|
|
1027
|
-
// Preserve file-defined metadata (for example codexProfile) even when
|
|
1028
|
-
// execution topology comes from EXECUTORS env.
|
|
1029
|
-
if (fromEnv && Array.isArray(fromFile?.executors) && executors.length > 0) {
|
|
1030
|
-
const fileExecutors = fromFile.executors
|
|
1031
|
-
.map((entry, index, arr) => normalizeExecutorEntry(entry, index, arr.length))
|
|
1032
|
-
.filter(Boolean);
|
|
1033
|
-
|
|
1034
|
-
for (let index = 0; index < executors.length; index++) {
|
|
1035
|
-
const current = executors[index];
|
|
1036
|
-
const match = findExecutorMetadataMatch(current, fileExecutors, index);
|
|
1037
|
-
if (!match) continue;
|
|
1038
|
-
const merged = { ...current };
|
|
1039
|
-
if (typeof match.name === "string" && match.name.trim()) {
|
|
1040
|
-
merged.name = match.name.trim();
|
|
1041
|
-
}
|
|
1042
|
-
if (typeof match.enabled === "boolean") {
|
|
1043
|
-
merged.enabled = match.enabled;
|
|
1044
|
-
}
|
|
1045
|
-
if (Array.isArray(match.models) && match.models.length > 0) {
|
|
1046
|
-
merged.models = [...new Set(match.models)];
|
|
1047
|
-
}
|
|
1048
|
-
if (match.codexProfile) {
|
|
1049
|
-
merged.codexProfile = match.codexProfile;
|
|
1050
|
-
}
|
|
1051
|
-
executors[index] = {
|
|
1052
|
-
...merged,
|
|
1053
|
-
};
|
|
1054
|
-
}
|
|
1055
|
-
}
|
|
1056
|
-
const failover = fromFile?.failover || {
|
|
1057
|
-
strategy:
|
|
1058
|
-
process.env.FAILOVER_STRATEGY || DEFAULT_EXECUTORS.failover.strategy,
|
|
1059
|
-
maxRetries: Number(
|
|
1060
|
-
process.env.FAILOVER_MAX_RETRIES || DEFAULT_EXECUTORS.failover.maxRetries,
|
|
1061
|
-
),
|
|
1062
|
-
cooldownMinutes: Number(
|
|
1063
|
-
process.env.FAILOVER_COOLDOWN_MIN ||
|
|
1064
|
-
DEFAULT_EXECUTORS.failover.cooldownMinutes,
|
|
1065
|
-
),
|
|
1066
|
-
disableOnConsecutiveFailures: Number(
|
|
1067
|
-
process.env.FAILOVER_DISABLE_AFTER ||
|
|
1068
|
-
DEFAULT_EXECUTORS.failover.disableOnConsecutiveFailures,
|
|
1069
|
-
),
|
|
1070
|
-
};
|
|
1071
|
-
const distribution =
|
|
1072
|
-
fromFile?.distribution ||
|
|
1073
|
-
process.env.EXECUTOR_DISTRIBUTION ||
|
|
1074
|
-
DEFAULT_EXECUTORS.distribution;
|
|
1075
|
-
|
|
1076
|
-
return { executors, failover, distribution };
|
|
1077
|
-
}
|
|
1078
|
-
|
|
1079
|
-
// ── Executor Scheduler ───────────────────────────────────────────────────────
|
|
1080
|
-
|
|
1081
|
-
class ExecutorScheduler {
|
|
1082
|
-
constructor(config) {
|
|
1083
|
-
this.executors = config.executors.filter((e) => e.enabled !== false);
|
|
1084
|
-
this.failover = config.failover;
|
|
1085
|
-
this.distribution = config.distribution;
|
|
1086
|
-
this._roundRobinIndex = 0;
|
|
1087
|
-
this._failureCounts = new Map(); // name → consecutive failures
|
|
1088
|
-
this._disabledUntil = new Map(); // name → timestamp
|
|
1089
|
-
this._workspaceActiveCount = new Map(); // workspaceId → current active executor count
|
|
1090
|
-
this._workspaceConfigs = new Map(); // workspaceId → { maxConcurrent, pool, weight }
|
|
1091
|
-
}
|
|
1092
|
-
|
|
1093
|
-
/**
|
|
1094
|
-
* Register workspace executor config for concurrency tracking.
|
|
1095
|
-
* @param {string} workspaceId
|
|
1096
|
-
* @param {{ maxConcurrent?: number, pool?: string, weight?: number }} wsExecutorConfig
|
|
1097
|
-
*/
|
|
1098
|
-
registerWorkspace(workspaceId, wsExecutorConfig = {}) {
|
|
1099
|
-
if (!workspaceId) return;
|
|
1100
|
-
this._workspaceConfigs.set(workspaceId, {
|
|
1101
|
-
maxConcurrent: wsExecutorConfig.maxConcurrent ?? 3,
|
|
1102
|
-
pool: wsExecutorConfig.pool ?? "shared",
|
|
1103
|
-
weight: wsExecutorConfig.weight ?? 1.0,
|
|
1104
|
-
executors: wsExecutorConfig.executors ?? null,
|
|
1105
|
-
});
|
|
1106
|
-
if (!this._workspaceActiveCount.has(workspaceId)) {
|
|
1107
|
-
this._workspaceActiveCount.set(workspaceId, 0);
|
|
1108
|
-
}
|
|
1109
|
-
}
|
|
1110
|
-
|
|
1111
|
-
/**
|
|
1112
|
-
* Check if a workspace has available executor slots.
|
|
1113
|
-
* @param {string} [workspaceId]
|
|
1114
|
-
* @returns {boolean}
|
|
1115
|
-
*/
|
|
1116
|
-
hasAvailableSlot(workspaceId) {
|
|
1117
|
-
if (!workspaceId) return true; // no workspace scope — always available
|
|
1118
|
-
const config = this._workspaceConfigs.get(workspaceId);
|
|
1119
|
-
if (!config) return true; // no config registered — no limit
|
|
1120
|
-
const active = this._workspaceActiveCount.get(workspaceId) || 0;
|
|
1121
|
-
return active < config.maxConcurrent;
|
|
1122
|
-
}
|
|
1123
|
-
|
|
1124
|
-
/**
|
|
1125
|
-
* Acquire an executor slot for a workspace.
|
|
1126
|
-
* @param {string} [workspaceId]
|
|
1127
|
-
* @returns {boolean} true if slot acquired, false if at limit
|
|
1128
|
-
*/
|
|
1129
|
-
acquireSlot(workspaceId) {
|
|
1130
|
-
if (!workspaceId) return true;
|
|
1131
|
-
if (!this.hasAvailableSlot(workspaceId)) return false;
|
|
1132
|
-
this._workspaceActiveCount.set(
|
|
1133
|
-
workspaceId,
|
|
1134
|
-
(this._workspaceActiveCount.get(workspaceId) || 0) + 1,
|
|
1135
|
-
);
|
|
1136
|
-
return true;
|
|
1137
|
-
}
|
|
1138
|
-
|
|
1139
|
-
/**
|
|
1140
|
-
* Release an executor slot for a workspace.
|
|
1141
|
-
* @param {string} [workspaceId]
|
|
1142
|
-
*/
|
|
1143
|
-
releaseSlot(workspaceId) {
|
|
1144
|
-
if (!workspaceId) return;
|
|
1145
|
-
const current = this._workspaceActiveCount.get(workspaceId) || 0;
|
|
1146
|
-
this._workspaceActiveCount.set(workspaceId, Math.max(0, current - 1));
|
|
1147
|
-
}
|
|
1148
|
-
|
|
1149
|
-
/**
|
|
1150
|
-
* Get workspace executor usage summary.
|
|
1151
|
-
* @returns {Array<{ workspaceId: string, active: number, maxConcurrent: number, pool: string, weight: number }>}
|
|
1152
|
-
*/
|
|
1153
|
-
getWorkspaceSummary() {
|
|
1154
|
-
const result = [];
|
|
1155
|
-
for (const [wsId, config] of this._workspaceConfigs) {
|
|
1156
|
-
result.push({
|
|
1157
|
-
workspaceId: wsId,
|
|
1158
|
-
active: this._workspaceActiveCount.get(wsId) || 0,
|
|
1159
|
-
...config,
|
|
1160
|
-
});
|
|
1161
|
-
}
|
|
1162
|
-
return result;
|
|
1163
|
-
}
|
|
1164
|
-
|
|
1165
|
-
/** Get the next executor based on distribution strategy */
|
|
1166
|
-
next(workspaceId) {
|
|
1167
|
-
// Check workspace slot availability before selecting
|
|
1168
|
-
if (workspaceId && !this.hasAvailableSlot(workspaceId)) {
|
|
1169
|
-
return null; // workspace at executor capacity
|
|
1170
|
-
}
|
|
1171
|
-
|
|
1172
|
-
const available = this._getAvailable();
|
|
1173
|
-
if (!available.length) {
|
|
1174
|
-
// All disabled — reset and use primary
|
|
1175
|
-
this._disabledUntil.clear();
|
|
1176
|
-
this._failureCounts.clear();
|
|
1177
|
-
return this.executors[0];
|
|
1178
|
-
}
|
|
1179
|
-
|
|
1180
|
-
// For dedicated pools, filter to workspace-assigned executors
|
|
1181
|
-
if (workspaceId) {
|
|
1182
|
-
const wsConfig = this._workspaceConfigs.get(workspaceId);
|
|
1183
|
-
if (wsConfig?.pool === "dedicated" && wsConfig.executors) {
|
|
1184
|
-
const dedicated = available.filter((e) =>
|
|
1185
|
-
wsConfig.executors.includes(e.name),
|
|
1186
|
-
);
|
|
1187
|
-
if (dedicated.length) {
|
|
1188
|
-
return this._selectByStrategy(dedicated);
|
|
1189
|
-
}
|
|
1190
|
-
}
|
|
1191
|
-
}
|
|
1192
|
-
|
|
1193
|
-
return this._selectByStrategy(available);
|
|
1194
|
-
}
|
|
1195
|
-
|
|
1196
|
-
_selectByStrategy(available) {
|
|
1197
|
-
switch (this.distribution) {
|
|
1198
|
-
case "round-robin":
|
|
1199
|
-
return this._roundRobin(available);
|
|
1200
|
-
case "primary-only":
|
|
1201
|
-
return available[0];
|
|
1202
|
-
case "weighted":
|
|
1203
|
-
default:
|
|
1204
|
-
return this._weightedSelect(available);
|
|
1205
|
-
}
|
|
1206
|
-
}
|
|
1207
|
-
|
|
1208
|
-
/** Report a failure for an executor */
|
|
1209
|
-
recordFailure(executorName) {
|
|
1210
|
-
const count = (this._failureCounts.get(executorName) || 0) + 1;
|
|
1211
|
-
this._failureCounts.set(executorName, count);
|
|
1212
|
-
if (count >= this.failover.disableOnConsecutiveFailures) {
|
|
1213
|
-
const until = Date.now() + this.failover.cooldownMinutes * 60 * 1000;
|
|
1214
|
-
this._disabledUntil.set(executorName, until);
|
|
1215
|
-
this._failureCounts.set(executorName, 0);
|
|
1216
|
-
}
|
|
1217
|
-
}
|
|
1218
|
-
|
|
1219
|
-
/** Report a success for an executor */
|
|
1220
|
-
recordSuccess(executorName) {
|
|
1221
|
-
this._failureCounts.set(executorName, 0);
|
|
1222
|
-
this._disabledUntil.delete(executorName);
|
|
1223
|
-
}
|
|
1224
|
-
|
|
1225
|
-
/** Get failover executor when current one fails */
|
|
1226
|
-
getFailover(currentName) {
|
|
1227
|
-
const available = this._getAvailable().filter(
|
|
1228
|
-
(e) => e.name !== currentName,
|
|
1229
|
-
);
|
|
1230
|
-
if (!available.length) return null;
|
|
1231
|
-
|
|
1232
|
-
switch (this.failover.strategy) {
|
|
1233
|
-
case "weighted-random":
|
|
1234
|
-
return this._weightedSelect(available);
|
|
1235
|
-
case "round-robin":
|
|
1236
|
-
return available[0];
|
|
1237
|
-
case "next-in-line":
|
|
1238
|
-
default: {
|
|
1239
|
-
// Find the next one by role priority
|
|
1240
|
-
const roleOrder = [
|
|
1241
|
-
"primary",
|
|
1242
|
-
"backup",
|
|
1243
|
-
"tertiary",
|
|
1244
|
-
...Array.from({ length: 20 }, (_, i) => `executor-${i + 1}`),
|
|
1245
|
-
];
|
|
1246
|
-
available.sort(
|
|
1247
|
-
(a, b) => roleOrder.indexOf(a.role) - roleOrder.indexOf(b.role),
|
|
1248
|
-
);
|
|
1249
|
-
return available[0];
|
|
1250
|
-
}
|
|
1251
|
-
}
|
|
1252
|
-
}
|
|
1253
|
-
|
|
1254
|
-
/** Get summary for display */
|
|
1255
|
-
getSummary() {
|
|
1256
|
-
const total = this.executors.reduce((s, e) => s + e.weight, 0);
|
|
1257
|
-
return this.executors.map((e) => {
|
|
1258
|
-
const pct = total > 0 ? Math.round((e.weight / total) * 100) : 0;
|
|
1259
|
-
const disabled = this._isDisabled(e.name);
|
|
1260
|
-
return {
|
|
1261
|
-
...e,
|
|
1262
|
-
percentage: pct,
|
|
1263
|
-
status: disabled ? "cooldown" : e.enabled ? "active" : "disabled",
|
|
1264
|
-
consecutiveFailures: this._failureCounts.get(e.name) || 0,
|
|
1265
|
-
};
|
|
1266
|
-
});
|
|
1267
|
-
}
|
|
1268
|
-
|
|
1269
|
-
/** Format a display string like "COPILOT ⇄ CODEX (50/50)" */
|
|
1270
|
-
toDisplayString() {
|
|
1271
|
-
const summary = this.getSummary().filter((e) => e.status === "active");
|
|
1272
|
-
if (!summary.length) return "No executors available";
|
|
1273
|
-
return summary
|
|
1274
|
-
.map((e) => `${e.executor}:${e.variant}(${e.percentage}%)`)
|
|
1275
|
-
.join(" ⇄ ");
|
|
1276
|
-
}
|
|
1277
|
-
|
|
1278
|
-
_getAvailable() {
|
|
1279
|
-
return this.executors.filter(
|
|
1280
|
-
(e) => e.enabled !== false && !this._isDisabled(e.name),
|
|
1281
|
-
);
|
|
1282
|
-
}
|
|
1283
|
-
|
|
1284
|
-
_isDisabled(name) {
|
|
1285
|
-
const until = this._disabledUntil.get(name);
|
|
1286
|
-
if (!until) return false;
|
|
1287
|
-
if (Date.now() >= until) {
|
|
1288
|
-
this._disabledUntil.delete(name);
|
|
1289
|
-
return false;
|
|
1290
|
-
}
|
|
1291
|
-
return true;
|
|
1292
|
-
}
|
|
1293
|
-
|
|
1294
|
-
_roundRobin(available) {
|
|
1295
|
-
const idx = this._roundRobinIndex % available.length;
|
|
1296
|
-
this._roundRobinIndex++;
|
|
1297
|
-
return available[idx];
|
|
1298
|
-
}
|
|
1299
|
-
|
|
1300
|
-
_weightedSelect(available) {
|
|
1301
|
-
const totalWeight = available.reduce((s, e) => s + (e.weight || 1), 0);
|
|
1302
|
-
let r = Math.random() * totalWeight;
|
|
1303
|
-
for (const e of available) {
|
|
1304
|
-
r -= e.weight || 1;
|
|
1305
|
-
if (r <= 0) return e;
|
|
1306
|
-
}
|
|
1307
|
-
return available[available.length - 1];
|
|
1308
|
-
}
|
|
1309
|
-
}
|
|
1310
|
-
|
|
1311
897
|
// ── Multi-Repo Support ───────────────────────────────────────────────────────
|
|
1312
898
|
|
|
1313
899
|
/**
|
|
@@ -1723,7 +1309,7 @@ export function loadConfig(argv = process.argv, options = {}) {
|
|
|
1723
1309
|
|
|
1724
1310
|
// ── Timing ───────────────────────────────────────────────
|
|
1725
1311
|
const restartDelayMs = Number(
|
|
1726
|
-
cli["restart-delay"] || process.env.RESTART_DELAY_MS || "
|
|
1312
|
+
cli["restart-delay"] || process.env.RESTART_DELAY_MS || "180000",
|
|
1727
1313
|
);
|
|
1728
1314
|
const maxRestarts = Number(
|
|
1729
1315
|
cli["max-restarts"] || process.env.MAX_RESTARTS || "0",
|
|
@@ -2211,6 +1797,14 @@ export function loadConfig(argv = process.argv, options = {}) {
|
|
|
2211
1797
|
triggerSystemDefaults,
|
|
2212
1798
|
);
|
|
2213
1799
|
const workflows = resolveWorkflowConfig(configData);
|
|
1800
|
+
const workflowWorktreeRecoveryCooldownMin = toBoundedInt(
|
|
1801
|
+
process.env.WORKFLOW_WORKTREE_RECOVERY_COOLDOWN_MIN ??
|
|
1802
|
+
configData.workflowWorktreeRecoveryCooldownMin,
|
|
1803
|
+
15,
|
|
1804
|
+
1,
|
|
1805
|
+
1440,
|
|
1806
|
+
);
|
|
1807
|
+
const worktreeBootstrap = resolveWorktreeBootstrapConfig(configData);
|
|
2214
1808
|
|
|
2215
1809
|
// ── GitHub Reconciler ───────────────────────────────────
|
|
2216
1810
|
const ghReconcileEnabled = isEnvEnabled(
|
|
@@ -2493,6 +2087,8 @@ export function loadConfig(argv = process.argv, options = {}) {
|
|
|
2493
2087
|
|
|
2494
2088
|
triggerSystem,
|
|
2495
2089
|
workflows,
|
|
2090
|
+
workflowWorktreeRecoveryCooldownMin,
|
|
2091
|
+
worktreeBootstrap,
|
|
2496
2092
|
|
|
2497
2093
|
// GitHub Reconciler
|
|
2498
2094
|
githubReconcile: {
|