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.
Files changed (73) hide show
  1. package/.env.example +1 -1
  2. package/agent/agent-pool.mjs +9 -2
  3. package/agent/agent-prompt-catalog.mjs +971 -0
  4. package/agent/agent-prompts.mjs +2 -970
  5. package/agent/agent-supervisor.mjs +119 -6
  6. package/agent/autofix-git.mjs +33 -0
  7. package/agent/autofix-prompts.mjs +151 -0
  8. package/agent/autofix.mjs +11 -175
  9. package/agent/bosun-skills.mjs +3 -2
  10. package/bosun.config.example.json +17 -0
  11. package/bosun.schema.json +87 -188
  12. package/cli.mjs +34 -1
  13. package/config/config-doctor.mjs +5 -250
  14. package/config/config-file-names.mjs +5 -0
  15. package/config/config.mjs +89 -493
  16. package/config/executor-config.mjs +493 -0
  17. package/config/repo-root.mjs +1 -2
  18. package/config/workspace-health.mjs +242 -0
  19. package/git/git-safety.mjs +15 -0
  20. package/github/github-oauth-portal.mjs +46 -0
  21. package/infra/library-manager-utils.mjs +22 -0
  22. package/infra/library-manager-well-known-sources.mjs +578 -0
  23. package/infra/library-manager.mjs +512 -1030
  24. package/infra/monitor.mjs +35 -9
  25. package/infra/session-tracker.mjs +10 -7
  26. package/kanban/kanban-adapter.mjs +17 -1
  27. package/lib/codebase-audit-manifests.mjs +117 -0
  28. package/lib/codebase-audit.mjs +18 -115
  29. package/package.json +18 -3
  30. package/server/setup-web-server.mjs +58 -5
  31. package/server/ui-server.mjs +1394 -79
  32. package/shell/codex-config-file.mjs +178 -0
  33. package/shell/codex-config.mjs +538 -575
  34. package/task/task-cli.mjs +54 -3
  35. package/task/task-executor.mjs +143 -13
  36. package/task/task-store.mjs +409 -1
  37. package/telegram/telegram-bot.mjs +127 -0
  38. package/tools/apply-pr-suggestions.mjs +401 -0
  39. package/tools/syntax-check.mjs +28 -9
  40. package/ui/app.js +3 -14
  41. package/ui/components/kanban-board.js +227 -4
  42. package/ui/components/session-list.js +85 -5
  43. package/ui/demo-defaults.js +338 -84
  44. package/ui/demo.html +155 -0
  45. package/ui/modules/session-api.js +96 -0
  46. package/ui/modules/settings-schema.js +1 -2
  47. package/ui/modules/state.js +43 -3
  48. package/ui/setup.html +4 -5
  49. package/ui/styles/components.css +58 -4
  50. package/ui/tabs/agents.js +12 -15
  51. package/ui/tabs/control.js +1 -0
  52. package/ui/tabs/library.js +484 -22
  53. package/ui/tabs/manual-flows.js +105 -29
  54. package/ui/tabs/tasks.js +848 -141
  55. package/ui/tabs/telemetry.js +129 -11
  56. package/ui/tabs/workflow-canvas-utils.mjs +130 -0
  57. package/ui/tabs/workflows.js +293 -23
  58. package/voice/voice-tool-definitions.mjs +757 -0
  59. package/voice/voice-tools.mjs +34 -778
  60. package/workflow/manual-flow-audit.mjs +165 -0
  61. package/workflow/manual-flows.mjs +164 -259
  62. package/workflow/workflow-engine.mjs +147 -58
  63. package/workflow/workflow-nodes/definitions.mjs +1207 -0
  64. package/workflow/workflow-nodes/transforms.mjs +612 -0
  65. package/workflow/workflow-nodes.mjs +358 -63
  66. package/workflow/workflow-templates.mjs +313 -191
  67. package/workflow-templates/_helpers.mjs +154 -0
  68. package/workflow-templates/agents.mjs +61 -4
  69. package/workflow-templates/code-quality.mjs +7 -7
  70. package/workflow-templates/github.mjs +20 -10
  71. package/workflow-templates/task-batch.mjs +44 -11
  72. package/workflow-templates/task-lifecycle.mjs +31 -6
  73. 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
- normalizeExecutorKey,
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 || "10000",
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: {