bosun 0.42.2 → 0.42.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 (49) hide show
  1. package/.env.example +9 -0
  2. package/agent/agent-event-bus.mjs +10 -0
  3. package/agent/agent-supervisor.mjs +20 -0
  4. package/bosun-tui.mjs +107 -105
  5. package/cli.mjs +10 -0
  6. package/config/config.mjs +25 -0
  7. package/config/executor-config.mjs +124 -1
  8. package/infra/container-runner.mjs +565 -1
  9. package/infra/monitor.mjs +18 -0
  10. package/infra/tracing.mjs +544 -240
  11. package/infra/tui-bridge.mjs +13 -1
  12. package/kanban/kanban-adapter.mjs +128 -4
  13. package/lib/repo-map.mjs +114 -3
  14. package/package.json +11 -4
  15. package/server/ui-server.mjs +3 -0
  16. package/task/task-archiver.mjs +18 -6
  17. package/task/task-attachments.mjs +14 -10
  18. package/task/task-cli.mjs +24 -4
  19. package/task/task-executor.mjs +19 -0
  20. package/task/task-store.mjs +194 -37
  21. package/telegram/telegram-bot.mjs +4 -1
  22. package/tui/app.mjs +131 -171
  23. package/tui/components/status-header.mjs +178 -75
  24. package/tui/lib/header-config.mjs +68 -0
  25. package/tui/lib/ws-bridge.mjs +61 -9
  26. package/tui/screens/agents.mjs +127 -0
  27. package/tui/screens/tasks.mjs +1 -48
  28. package/ui/app.js +8 -5
  29. package/ui/components/kanban-board.js +65 -3
  30. package/ui/components/session-list.js +18 -32
  31. package/ui/demo-defaults.js +52 -2
  32. package/ui/modules/session-api.js +100 -0
  33. package/ui/modules/state.js +71 -15
  34. package/ui/tabs/workflows.js +25 -1
  35. package/ui/tui/App.js +298 -0
  36. package/ui/tui/TasksScreen.js +564 -0
  37. package/ui/tui/constants.js +55 -0
  38. package/ui/tui/tasks-screen-helpers.js +301 -0
  39. package/ui/tui/useTasks.js +61 -0
  40. package/ui/tui/useWebSocket.js +166 -0
  41. package/ui/tui/useWorkflows.js +30 -0
  42. package/workflow/workflow-engine.mjs +412 -7
  43. package/workflow/workflow-nodes.mjs +616 -75
  44. package/workflow-templates/agents.mjs +3 -0
  45. package/workflow-templates/planning.mjs +7 -0
  46. package/workflow-templates/sub-workflows.mjs +5 -0
  47. package/workflow-templates/task-execution.mjs +3 -0
  48. package/workspace/command-diagnostics.mjs +1 -1
  49. package/workspace/context-cache.mjs +182 -9
package/.env.example CHANGED
@@ -802,6 +802,11 @@ VK_RECOVERY_PORT=54089
802
802
  # BOSUN_HOOKS_DISABLE_TASK_COMPLETE=false
803
803
  # BOSUN_HOOKS_DISABLE_HEALTH_CHECK=false
804
804
 
805
+ # ── OpenTelemetry Tracing & Metrics ───────────────────────────────────────────
806
+ # External orchestration-layer observability only; never affects agent context.
807
+ # BOSUN_OTEL_ENDPOINT=http://localhost:4318/v1/traces
808
+ # Configure tracing.sampleRate in bosun.config.json to tune sampling.
809
+
805
810
  # Force hooks to fire even for non-managed sessions (debug only):
806
811
  # BOSUN_HOOKS_FORCE=false
807
812
 
@@ -1175,3 +1180,7 @@ COPILOT_CLOUD_DISABLED=true
1175
1180
  # AGENT_STUCK_THRESHOLD_MS=300000
1176
1181
  # Alert if session costs more than $N (default: 1.0)
1177
1182
  # AGENT_COST_ANOMALY_THRESHOLD=1.0
1183
+
1184
+ # OpenTelemetry tracing (optional)
1185
+ # BOSUN_OTEL_ENDPOINT=http://localhost:4318/v1/traces
1186
+
@@ -25,6 +25,7 @@ import {
25
25
  reduceRetryQueue,
26
26
  snapshotRetryQueue,
27
27
  } from "./retry-queue.mjs";
28
+ import { addSpanEvent, recordAgentError, recordIntervention } from "../infra/tracing.mjs";
28
29
 
29
30
  const TAG = "[agent-event-bus]";
30
31
 
@@ -232,6 +233,14 @@ export class AgentEventBus {
232
233
  emit(type, taskId, payload = {}, opts = {}) {
233
234
  const ts = Date.now();
234
235
  const event = { type, taskId, payload, ts };
236
+ addSpanEvent(type, { "bosun.task.id": taskId, ...payload });
237
+ if (type === AGENT_EVENT.AGENT_ERROR) {
238
+ recordAgentError(payload?.errorType || payload?.classification || "agent_error", {
239
+ "bosun.task.id": taskId,
240
+ "bosun.executor": payload?.executor,
241
+ "bosun.agent.sdk": payload?.sdk,
242
+ });
243
+ }
235
244
 
236
245
  // ── Dedup
237
246
  const key = `${type}:${taskId}`;
@@ -1092,3 +1101,4 @@ export function createAgentEventBus(options) {
1092
1101
  return new AgentEventBus(options);
1093
1102
  }
1094
1103
 
1104
+
@@ -29,6 +29,8 @@
29
29
  * @module agent-supervisor
30
30
  */
31
31
 
32
+ import { addSpanEvent, recordIntervention } from "../infra/tracing.mjs";
33
+
32
34
  const TAG = "[agent-supervisor]";
33
35
  const API_ERROR_CONTINUE_COOLDOWNS_MS = Object.freeze([
34
36
  3 * 60_000,
@@ -473,6 +475,12 @@ export class AgentSupervisor {
473
475
  }
474
476
 
475
477
  this._lastDecision.set(taskId, { situation, intervention, ts: Date.now() });
478
+ addSpanEvent("bosun.supervisor.assess", {
479
+ "bosun.task.id": taskId,
480
+ "bosun.health.score": healthScore,
481
+ "bosun.situation": situation,
482
+ "bosun.intervention.type": intervention,
483
+ });
476
484
 
477
485
  return { situation, healthScore, intervention, prompt, reason };
478
486
  }
@@ -489,6 +497,18 @@ export class AgentSupervisor {
489
497
  );
490
498
 
491
499
  try {
500
+ if (intervention !== INTERVENTION.NONE) {
501
+ recordIntervention(intervention, {
502
+ "bosun.task.id": taskId,
503
+ "bosun.situation": situation,
504
+ });
505
+ }
506
+ addSpanEvent("bosun.supervisor.intervention", {
507
+ "bosun.task.id": taskId,
508
+ "bosun.intervention.type": intervention,
509
+ "bosun.situation": situation,
510
+ "bosun.reason": reason,
511
+ });
492
512
  switch (intervention) {
493
513
  case INTERVENTION.NONE:
494
514
  break;
package/bosun-tui.mjs CHANGED
@@ -1,142 +1,144 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- /**
4
- * bosun-tui — Terminal User Interface for Bosun
5
- *
6
- * A terminal-based UI for monitoring Bosun agents, tasks, and workflows.
7
- * Built with Ink (React-like CLI framework).
8
- *
9
- * Usage:
10
- * bosun-tui # Start the TUI
11
- * bosun-tui --help # Show help
12
- * bosun-tui --port 3080 # Connect to specific port
13
- */
14
-
15
- import { resolve, dirname } from "node:path";
16
- import { fileURLToPath } from "node:url";
17
3
  import { readFileSync } from "node:fs";
4
+ import { dirname, resolve } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ import loadConfig from "./config/config.mjs";
8
+ import { resolveWebSocketProtocol } from "./tui/lib/ws-bridge.mjs";
9
+
10
+ const MIN_COLUMNS = 120;
11
+ const MIN_ROWS = 30;
12
+
18
13
 
19
14
  const __filename = fileURLToPath(import.meta.url);
20
15
  const __dirname = dirname(__filename);
21
16
 
22
17
  function showHelp() {
23
- const version = JSON.parse(
24
- readFileSync(resolve(__dirname, "package.json"), "utf8"),
25
- ).version;
26
-
18
+ const version = JSON.parse(readFileSync(resolve(__dirname, "package.json"), "utf8")).version;
27
19
  console.log(`
28
20
  bosun-tui v${version}
29
- Terminal User Interface for Bosun
21
+ Terminal UI for Bosun
30
22
 
31
23
  USAGE
32
- bosun-tui [options]
24
+ bosun tui [options]
25
+ node bosun-tui.mjs [options]
33
26
 
34
27
  OPTIONS
35
- --port <n> UI server port to connect (default: 3080 or TELEGRAM_UI_PORT env)
36
- --host <host> UI server host (default: localhost)
37
- --connect Connect to existing UI server (don't start monitor)
38
- --screen <name> Initial screen (tasks|agents|status)
39
- --refresh <ms> Stats refresh interval (default: 2000ms)
40
- --help Show this help
41
- --version Show version
42
-
43
- SCREENS
44
- tasks Kanban board with task CRUD
45
- agents Live agent session table
46
- status System status overview
47
-
48
- KEYBOARD NAVIGATION
49
- Tab / Shift+Tab Navigate between panels
50
- ↑↓←→ Navigate within panels
51
- Enter Select / Execute action
52
- Esc Back / Close modal
53
- c Create new task (tasks screen)
54
- r Resume selected agent session (Agents screen)
55
- q Quit
56
-
57
- EXAMPLES
58
- bosun-tui --port 3080
59
- bosun-tui --screen tasks
60
- bosun-tui --connect --port 3080
28
+ --host <host> WebSocket host (default: 127.0.0.1)
29
+ --port <n> WebSocket/UI port (default: TELEGRAM_UI_PORT or 3080)
30
+ --screen <name> Initial screen (agents|tasks|logs|workflows|telemetry|settings|help)
31
+ --help Show this help
32
+ --version Show version
61
33
  `);
62
34
  }
63
35
 
64
- function getArgValue(flag, defaultValue = "") {
65
- const args = process.argv.slice(2);
66
- const match = args.find((arg) => arg.startsWith(`${flag}=`));
67
- if (match) {
68
- return match.slice(flag.length + 1).trim();
69
- }
70
- const idx = args.indexOf(flag);
71
- if (idx >= 0 && args[idx + 1] && !args[idx + 1].startsWith("--")) {
72
- return args[idx + 1].trim();
36
+ function getArgValue(args, flag, defaultValue = "") {
37
+ const inline = args.find((arg) => arg.startsWith(`${flag}=`));
38
+ if (inline) return inline.slice(flag.length + 1).trim();
39
+ const index = args.indexOf(flag);
40
+ if (index >= 0 && args[index + 1] && !args[index + 1].startsWith("--")) {
41
+ return args[index + 1].trim();
73
42
  }
74
43
  return defaultValue;
75
44
  }
76
45
 
77
- function getArgFlag(flag) {
78
- const args = process.argv.slice(2);
79
- return args.includes(flag);
46
+ function hasFlag(args, ...flags) {
47
+ return flags.some((flag) => args.includes(flag));
48
+ }
49
+
50
+ function getTerminalSize(stdout = process.stdout) {
51
+ return {
52
+ columns: Math.max(0, Number(stdout?.columns || 0)),
53
+ rows: Math.max(0, Number(stdout?.rows || 0)),
54
+ };
55
+ }
56
+
57
+ function resolvePort(config) {
58
+ return Number(process.env.TELEGRAM_UI_PORT || process.env.BOSUN_PORT || config?.telegramUiPort || "3080") || 3080;
59
+ }
60
+
61
+ function renderApp(instance, React, App, props) {
62
+ instance.rerender(React.createElement(App, props));
80
63
  }
81
64
 
82
- async function main() {
83
- const args = process.argv.slice(2);
65
+ export async function runBosunTui(argv = process.argv.slice(2), options = {}) {
66
+ const stdout = options.stdout || process.stdout;
67
+ const stderr = options.stderr || process.stderr;
68
+ const args = Array.isArray(argv) ? argv : [];
84
69
 
85
- if (getArgFlag("--help") || args.includes("-h")) {
70
+ if (hasFlag(args, "--help", "-h")) {
86
71
  showHelp();
87
- process.exit(0);
72
+ return 0;
88
73
  }
89
74
 
90
- if (getArgFlag("--version") || args.includes("-v")) {
91
- const version = JSON.parse(
92
- readFileSync(resolve(__dirname, "package.json"), "utf8"),
93
- ).version;
75
+ if (hasFlag(args, "--version", "-v")) {
76
+ const version = JSON.parse(readFileSync(resolve(__dirname, "package.json"), "utf8")).version;
94
77
  console.log(`bosun-tui v${version}`);
95
- process.exit(0);
78
+ return 0;
96
79
  }
97
80
 
98
- const port = Number(getArgValue("--port", process.env.TELEGRAM_UI_PORT || "3080")) || 3080;
99
- const host = getArgValue("--host", "localhost");
100
- const connectOnly = getArgFlag("--connect");
101
- const initialScreen = getArgValue("--screen", "status");
102
- const refreshMs = Number(getArgValue("--refresh", "2000")) || 2000;
81
+ if (!stdout?.isTTY) {
82
+ stderr.write("[bosun-tui] Error: stdout is not a TTY. Run `bosun tui` in an interactive terminal.\n");
83
+ return 1;
84
+ }
103
85
 
104
- console.log(`[bosun-tui] Starting...`);
105
- console.log(`[bosun-tui] Connecting to ${host}:${port}`);
86
+ globalThis.WebSocket = globalThis.WebSocket || (await import("ws")).WebSocket;
87
+
88
+ const config = loadConfig([process.argv[0], __filename, ...args]);
89
+ const configDir = String(config?.configDir || process.env.BOSUN_DIR || resolve(process.cwd(), ".bosun")).trim();
90
+ const host = getArgValue(args, "--host", "127.0.0.1");
91
+ const port = Number(getArgValue(args, "--port", String(resolvePort(config)))) || resolvePort(config);
92
+ const protocol = getArgValue(
93
+ args,
94
+ "--protocol",
95
+ resolveWebSocketProtocol({ configDir }),
96
+ );
97
+ const initialScreen = getArgValue(args, "--screen", "agents");
98
+
99
+ const React = await import("react");
100
+ const ink = await import("ink");
101
+ const { default: App } = await import("./ui/tui/App.js");
102
+
103
+ let terminalSize = getTerminalSize(stdout);
104
+ const props = {
105
+ config,
106
+ configDir,
107
+ host,
108
+ port,
109
+ protocol,
110
+ initialScreen,
111
+ terminalSize,
112
+ };
113
+
114
+ const instance = ink.render(React.createElement(App, props), { exitOnCtrlC: true });
115
+
116
+ const onResize = () => {
117
+ terminalSize = getTerminalSize(stdout);
118
+ renderApp(instance, React, App, { ...props, terminalSize });
119
+ };
120
+
121
+ stdout.on?.("resize", onResize);
106
122
 
107
123
  try {
108
- const { render } = await import("ink");
109
- const importErrors = [];
110
-
111
- let App;
112
- try {
113
- const appModule = await import("./tui/app.mjs");
114
- App = appModule.default;
115
- } catch (importErr) {
116
- importErrors.push(`App: ${importErr.message}`);
117
- console.error(`[bosun-tui] Failed to import TUI app: ${importErr.message}`);
118
- console.log(`[bosun-tui] TUI requires ink. Install with: npm install ink`);
119
- process.exit(1);
124
+ if (typeof instance.waitUntilExit === "function") {
125
+ await instance.waitUntilExit();
120
126
  }
121
-
122
- const { waitUntilExit } = await import("ink");
123
-
124
- const app = render(
125
- App({
126
- host,
127
- port,
128
- connectOnly,
129
- initialScreen,
130
- refreshMs,
131
- }),
132
- );
133
-
134
- process.exitCode = await waitUntilExit(app);
135
- } catch (err) {
136
- console.error(`[bosun-tui] Failed to start: ${err.message}`);
137
- console.log(`[bosun-tui] Ensure bosun is running or use --connect to connect to an existing UI server`);
138
- process.exit(1);
127
+ return 0;
128
+ } finally {
129
+ stdout.off?.("resize", onResize);
139
130
  }
140
131
  }
141
132
 
142
- main();
133
+ if (process.argv[1] && resolve(process.argv[1]) === __filename) {
134
+ runBosunTui(process.argv.slice(2))
135
+ .then((code) => {
136
+ process.exit(code ?? 0);
137
+ })
138
+ .catch((error) => {
139
+ console.error(`[bosun-tui] Failed to start: ${error?.message || error}`);
140
+ process.exit(1);
141
+ });
142
+ }
143
+
144
+
package/cli.mjs CHANGED
@@ -79,6 +79,7 @@ function showHelp() {
79
79
  COMMANDS
80
80
  workflow list List declarative pipeline workflows
81
81
  workflow run <name> Run a declarative pipeline workflow
82
+ tui Launch the terminal UI
82
83
  audit <command> Run codebase annotation audit tools (scan|generate|warn|manifest|index|trim|conformity|migrate)
83
84
  --setup Launch the web-based setup wizard (default)
84
85
  --setup-terminal Run the legacy terminal setup wizard
@@ -167,6 +168,7 @@ function showHelp() {
167
168
  workflow run <name> Run a declarative fresh-context workflow
168
169
 
169
170
  Run 'bosun workflow --help' for workflow CLI examples.
171
+ Run 'bosun tui' to launch the terminal UI.
170
172
 
171
173
  VIBE-KANBAN
172
174
  --no-vk-spawn Don't auto-spawn Vibe-Kanban
@@ -1425,6 +1427,12 @@ async function main() {
1425
1427
  process.exit(0);
1426
1428
  }
1427
1429
 
1430
+ if (args[0] === "tui") {
1431
+ const { runBosunTui } = await import("./bosun-tui.mjs");
1432
+ const exitCode = await runBosunTui(args.slice(1));
1433
+ process.exit(exitCode ?? 0);
1434
+ }
1435
+
1428
1436
  // Handle --help
1429
1437
  if (args.includes("--help") || args.includes("-h")) {
1430
1438
  showHelp();
@@ -2710,3 +2718,5 @@ main().catch(async (err) => {
2710
2718
  await sendCrashNotification(1, null).catch(() => {});
2711
2719
  process.exit(1);
2712
2720
  });
2721
+
2722
+
package/config/config.mjs CHANGED
@@ -1811,6 +1811,10 @@ export function loadConfig(argv = process.argv, options = {}) {
1811
1811
  `http://127.0.0.1:${vkRecoveryPort}`;
1812
1812
  const vkPublicUrl = process.env.VK_PUBLIC_URL || process.env.VK_WEB_URL || "";
1813
1813
  const vkTaskUrlTemplate = process.env.VK_TASK_URL_TEMPLATE || "";
1814
+ const tracingEndpoint =
1815
+ process.env.BOSUN_OTEL_ENDPOINT || configData?.tracing?.endpoint || null;
1816
+ const tracingEnabled = configData?.tracing?.enabled ?? Boolean(tracingEndpoint);
1817
+ const tracingSampleRate = Number(configData?.tracing?.sampleRate ?? 1);
1814
1818
  const vkRecoveryCooldownMin = Number(
1815
1819
  process.env.VK_RECOVERY_COOLDOWN_MIN || "10",
1816
1820
  );
@@ -2127,6 +2131,20 @@ export function loadConfig(argv = process.argv, options = {}) {
2127
2131
  // Voice assistant
2128
2132
  voice: Object.freeze(configData.voice || {}),
2129
2133
 
2134
+ // OpenTelemetry tracing
2135
+ tracing: Object.freeze({
2136
+ enabled:
2137
+ typeof configData.tracing?.enabled === "boolean"
2138
+ ? configData.tracing.enabled
2139
+ : Boolean(configData.tracing?.endpoint || process.env.BOSUN_OTEL_ENDPOINT || ""),
2140
+ endpoint:
2141
+ configData.tracing?.endpoint || process.env.BOSUN_OTEL_ENDPOINT || "",
2142
+ sampleRate:
2143
+ Number.isFinite(Number(configData.tracing?.sampleRate))
2144
+ ? Number(configData.tracing.sampleRate)
2145
+ : 1.0,
2146
+ }),
2147
+
2130
2148
  // Merge Strategy
2131
2149
  codexAnalyzeMergeStrategy:
2132
2150
  codexEnabled &&
@@ -2144,6 +2162,11 @@ export function loadConfig(argv = process.argv, options = {}) {
2144
2162
  vkEndpointUrl,
2145
2163
  vkPublicUrl,
2146
2164
  vkTaskUrlTemplate,
2165
+ tracing: {
2166
+ enabled: tracingEnabled,
2167
+ endpoint: tracingEndpoint,
2168
+ sampleRate: Number.isFinite(tracingSampleRate) ? tracingSampleRate : 1,
2169
+ },
2147
2170
  vkRecoveryCooldownMin,
2148
2171
  vkRuntimeRequired,
2149
2172
  vkSpawnEnabled,
@@ -2318,3 +2341,5 @@ export {
2318
2341
  resolveAgentRepoRoot,
2319
2342
  };
2320
2343
  export default loadConfig;
2344
+
2345
+
@@ -122,6 +122,84 @@ const DEFAULT_EXECUTORS = {
122
122
  distribution: "primary-only",
123
123
  };
124
124
 
125
+ const DEFAULT_WORKFLOW_OFFLOAD_POLICY = Object.freeze({
126
+ enabled: true,
127
+ nodeTypes: ["validation.tests", "validation.build", "validation.lint"],
128
+ commandTypes: ["test", "build", "qualityGate"],
129
+ commandPatterns: [
130
+ "\\b(?:npm|pnpm|yarn|bun)\\s+(?:run\\s+)?test\\b",
131
+ "\\b(?:npm|pnpm|yarn|bun)\\s+run\\s+build\\b",
132
+ "\\b(?:npm|pnpm|yarn|bun)\\s+run\\s+lint\\b",
133
+ "\\bvitest\\b",
134
+ "\\bjest\\b",
135
+ "\\btsc\\b",
136
+ "\\bpre-?push\\b",
137
+ "\\bgit\\s+diff\\b",
138
+ ],
139
+ });
140
+
141
+ function parseBooleanFlag(value, fallback = false) {
142
+ if (value == null || value === "") return fallback;
143
+ if (typeof value === "boolean") return value;
144
+ if (typeof value === "number") return value !== 0;
145
+ const normalized = String(value || "").trim().toLowerCase();
146
+ if (!normalized) return fallback;
147
+ if (["1", "true", "yes", "y", "on"].includes(normalized)) return true;
148
+ if (["0", "false", "no", "n", "off"].includes(normalized)) return false;
149
+ return fallback;
150
+ }
151
+
152
+ function normalizeWorkflowOffloadPolicy(configData = {}) {
153
+ const rawPolicy =
154
+ configData?.workflowOffloadPolicy && typeof configData.workflowOffloadPolicy === "object"
155
+ ? configData.workflowOffloadPolicy
156
+ : configData?.executors?.workflowOffloadPolicy &&
157
+ typeof configData.executors.workflowOffloadPolicy === "object"
158
+ ? configData.executors.workflowOffloadPolicy
159
+ : {};
160
+
161
+ const parsePatterns = (value) =>
162
+ parseListValue(value)
163
+ .map((entry) => {
164
+ try {
165
+ return new RegExp(entry, "i");
166
+ } catch {
167
+ return null;
168
+ }
169
+ })
170
+ .filter(Boolean);
171
+
172
+ const nodeTypes = parseListValue(
173
+ process.env.HEAVY_RUNNER_OFFLOAD_NODE_TYPES ??
174
+ rawPolicy.nodeTypes ??
175
+ DEFAULT_WORKFLOW_OFFLOAD_POLICY.nodeTypes,
176
+ );
177
+ const commandTypes = parseListValue(
178
+ process.env.HEAVY_RUNNER_OFFLOAD_COMMAND_TYPES ??
179
+ rawPolicy.commandTypes ??
180
+ DEFAULT_WORKFLOW_OFFLOAD_POLICY.commandTypes,
181
+ );
182
+ const commandPatterns = parsePatterns(
183
+ process.env.HEAVY_RUNNER_OFFLOAD_COMMAND_PATTERNS ??
184
+ rawPolicy.commandPatterns ??
185
+ DEFAULT_WORKFLOW_OFFLOAD_POLICY.commandPatterns,
186
+ );
187
+
188
+ return {
189
+ enabled: parseBooleanFlag(
190
+ process.env.HEAVY_RUNNER_OFFLOAD_ENABLED ?? rawPolicy.enabled,
191
+ DEFAULT_WORKFLOW_OFFLOAD_POLICY.enabled,
192
+ ),
193
+ nodeTypes: new Set(
194
+ nodeTypes.map((entry) => String(entry || "").trim()).filter(Boolean),
195
+ ),
196
+ commandTypes: new Set(
197
+ commandTypes.map((entry) => String(entry || "").trim()).filter(Boolean),
198
+ ),
199
+ commandPatterns,
200
+ };
201
+ }
202
+
125
203
  function parseExecutorsFromEnv() {
126
204
  // EXECUTORS=CODEX:DEFAULT:100:gpt-5.2-codex|gpt-5.1-codex-mini
127
205
  const raw = process.env.EXECUTORS;
@@ -256,7 +334,12 @@ export function loadExecutorConfig(configDir, configData) {
256
334
  process.env.EXECUTOR_DISTRIBUTION ||
257
335
  DEFAULT_EXECUTORS.distribution;
258
336
 
259
- return { executors, failover, distribution };
337
+ return {
338
+ executors,
339
+ failover,
340
+ distribution,
341
+ workflowOffloadPolicy: normalizeWorkflowOffloadPolicy(configData),
342
+ };
260
343
  }
261
344
 
262
345
  // ── Executor Scheduler ───────────────────────────────────────────────────────
@@ -266,6 +349,7 @@ export class ExecutorScheduler {
266
349
  this.executors = config.executors.filter((e) => e.enabled !== false);
267
350
  this.failover = config.failover;
268
351
  this.distribution = config.distribution;
352
+ this.workflowOffloadPolicy = normalizeWorkflowOffloadPolicy(config);
269
353
  this._roundRobinIndex = 0;
270
354
  this._failureCounts = new Map(); // name → consecutive failures
271
355
  this._disabledUntil = new Map(); // name → timestamp
@@ -273,6 +357,45 @@ export class ExecutorScheduler {
273
357
  this._workspaceConfigs = new Map(); // workspaceId → { maxConcurrent, pool, weight }
274
358
  }
275
359
 
360
+ selectWorkflowLane({ nodeType = "", commandType = "", command = "" } = {}) {
361
+ const policy = this.workflowOffloadPolicy || DEFAULT_WORKFLOW_OFFLOAD_POLICY;
362
+ if (!policy.enabled) {
363
+ return { lane: "main", reason: "offload_disabled", heavy: false };
364
+ }
365
+
366
+ const normalizedNodeType = String(nodeType || "").trim();
367
+ if (normalizedNodeType && policy.nodeTypes.has(normalizedNodeType)) {
368
+ return {
369
+ lane: "isolated",
370
+ reason: `workflow_node:${normalizedNodeType}`,
371
+ heavy: true,
372
+ };
373
+ }
374
+
375
+ const normalizedCommandType = String(commandType || "").trim();
376
+ if (normalizedCommandType && policy.commandTypes.has(normalizedCommandType)) {
377
+ return {
378
+ lane: "isolated",
379
+ reason: `command_type:${normalizedCommandType}`,
380
+ heavy: true,
381
+ };
382
+ }
383
+
384
+ const rawCommand = String(command || "");
385
+ if (rawCommand) {
386
+ const matched = policy.commandPatterns.find((pattern) => pattern.test(rawCommand));
387
+ if (matched) {
388
+ return {
389
+ lane: "isolated",
390
+ reason: `command_pattern:${matched.source}`,
391
+ heavy: true,
392
+ };
393
+ }
394
+ }
395
+
396
+ return { lane: "main", reason: "lightweight", heavy: false };
397
+ }
398
+
276
399
  /**
277
400
  * Register workspace executor config for concurrency tracking.
278
401
  * @param {string} workspaceId