bosun 0.42.0 → 0.42.2

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 (63) hide show
  1. package/.env.example +12 -0
  2. package/README.md +2 -0
  3. package/agent/agent-pool.mjs +34 -1
  4. package/agent/agent-work-report.mjs +89 -3
  5. package/agent/analyze-agent-work-helpers.mjs +14 -0
  6. package/agent/analyze-agent-work.mjs +23 -3
  7. package/agent/primary-agent.mjs +23 -1
  8. package/bosun-tui.mjs +4 -3
  9. package/bosun.schema.json +1 -1
  10. package/config/config.mjs +58 -0
  11. package/config/workspace-health.mjs +36 -6
  12. package/git/diff-stats.mjs +550 -124
  13. package/github/github-app-auth.mjs +9 -5
  14. package/infra/maintenance.mjs +13 -6
  15. package/infra/monitor.mjs +398 -10
  16. package/infra/runtime-accumulator.mjs +9 -1
  17. package/infra/session-tracker.mjs +163 -1
  18. package/infra/tui-bridge.mjs +415 -0
  19. package/infra/worktree-recovery-state.mjs +159 -0
  20. package/kanban/kanban-adapter.mjs +41 -8
  21. package/lib/repo-map.mjs +411 -0
  22. package/package.json +140 -137
  23. package/server/ui-server.mjs +953 -59
  24. package/shell/codex-config.mjs +34 -8
  25. package/task/task-cli.mjs +93 -19
  26. package/task/task-executor.mjs +397 -8
  27. package/task/task-store.mjs +194 -1
  28. package/telegram/telegram-bot.mjs +267 -18
  29. package/tools/vitest-runner.mjs +108 -0
  30. package/tui/app.mjs +252 -148
  31. package/tui/components/status-header.mjs +88 -131
  32. package/tui/lib/ws-bridge.mjs +125 -35
  33. package/tui/screens/agents-screen-helpers.mjs +219 -0
  34. package/tui/screens/agents.mjs +287 -270
  35. package/tui/screens/status.mjs +51 -189
  36. package/tui/screens/tasks.mjs +41 -253
  37. package/ui/app.js +52 -23
  38. package/ui/components/chat-view.js +263 -84
  39. package/ui/components/diff-viewer.js +324 -140
  40. package/ui/components/kanban-board.js +13 -9
  41. package/ui/components/session-list.js +111 -41
  42. package/ui/demo-defaults.js +481 -59
  43. package/ui/demo.html +32 -0
  44. package/ui/modules/session-api.js +320 -5
  45. package/ui/modules/stream-timeline.js +356 -0
  46. package/ui/modules/telegram.js +5 -2
  47. package/ui/modules/worktree-recovery.js +85 -0
  48. package/ui/styles.css +44 -0
  49. package/ui/tabs/chat.js +19 -4
  50. package/ui/tabs/dashboard.js +22 -0
  51. package/ui/tabs/infra.js +25 -0
  52. package/ui/tabs/tasks.js +119 -11
  53. package/voice/voice-auth-manager.mjs +10 -5
  54. package/workflow/workflow-engine.mjs +179 -1
  55. package/workflow/workflow-nodes.mjs +872 -16
  56. package/workflow/workflow-templates.mjs +4 -0
  57. package/workflow-templates/github.mjs +2 -1
  58. package/workflow-templates/planning.mjs +2 -1
  59. package/workflow-templates/sub-workflows.mjs +10 -0
  60. package/workflow-templates/task-batch.mjs +9 -8
  61. package/workflow-templates/task-execution.mjs +30 -12
  62. package/workflow-templates/task-lifecycle.mjs +59 -4
  63. package/workspace/shared-knowledge.mjs +409 -155
package/.env.example CHANGED
@@ -1039,6 +1039,18 @@ COPILOT_CLOUD_DISABLED=true
1039
1039
  # WORKFLOW_AUTOMATION_ENABLED=true
1040
1040
  # Optional dedup window to avoid event storms (milliseconds).
1041
1041
  # WORKFLOW_EVENT_DEDUP_WINDOW_MS=15000
1042
+ # Monitor self-healing retry policy for startup unstick operations
1043
+ # (stale dispatch polling and workflow-history resume).
1044
+ # Max bounded retry attempts before terminal escalation.
1045
+ # WORKFLOW_RECOVERY_MAX_ATTEMPTS=5
1046
+ # Escalation warning threshold for repeated failures (must be <= max attempts).
1047
+ # WORKFLOW_RECOVERY_ESCALATION_THRESHOLD=3
1048
+ # Exponential backoff base delay in ms.
1049
+ # WORKFLOW_RECOVERY_BACKOFF_BASE_MS=5000
1050
+ # Exponential backoff maximum delay in ms.
1051
+ # WORKFLOW_RECOVERY_BACKOFF_MAX_MS=60000
1052
+ # Random jitter ratio (0.0-0.9) applied to backoff to prevent retry storms.
1053
+ # WORKFLOW_RECOVERY_BACKOFF_JITTER_RATIO=0.2
1042
1054
 
1043
1055
  # ─── GitHub Issue Reconciler ─────────────────────────────────────────────────
1044
1056
  # Periodically reconciles open GitHub issues against open/merged PRs.
package/README.md CHANGED
@@ -144,6 +144,8 @@ Set `primaryAgent` in `.bosun/bosun.config.json` or choose an executor preset du
144
144
  - `bosun --daemon --sentinel` starts daemon + sentinel together (recommended for unattended operation).
145
145
  - `bosun --terminate` is the clean reset command when you suspect stale/ghost processes.
146
146
 
147
+ Telegram operators can pull the weekly agent work summary with `/weekly [days]` or `/report weekly [days]`. To post it automatically once per week, set `TELEGRAM_WEEKLY_REPORT_ENABLED=true` together with `TELEGRAM_WEEKLY_REPORT_DAY`, `TELEGRAM_WEEKLY_REPORT_HOUR`, and optional `TELEGRAM_WEEKLY_REPORT_DAYS`.
148
+
147
149
  ## Documentation
148
150
 
149
151
  **Published docs (website):** https://bosun.engineer/docs/
@@ -2871,6 +2871,19 @@ function sdkSupportsPersistentThreads(sdkName) {
2871
2871
 
2872
2872
  /** @type {Map<string, ActiveSession>} */
2873
2873
  const activeSessions = new Map();
2874
+ const ACTIVE_SESSION_LISTENERS = new Set();
2875
+
2876
+ function notifyActiveSessionListeners(reason = "update", taskKey = null) {
2877
+ if (ACTIVE_SESSION_LISTENERS.size === 0) return;
2878
+ const snapshot = getActiveSessions();
2879
+ for (const listener of ACTIVE_SESSION_LISTENERS) {
2880
+ try {
2881
+ listener(snapshot, { reason, taskKey });
2882
+ } catch {
2883
+ /* best effort */
2884
+ }
2885
+ }
2886
+ }
2874
2887
 
2875
2888
  /** Threshold for approaching-exhaustion warning (80% of MAX_THREAD_TURNS). */
2876
2889
  const THREAD_EXHAUSTION_WARNING_THRESHOLD = Math.floor(MAX_THREAD_TURNS * 0.8);
@@ -2892,6 +2905,7 @@ function registerActiveSession(taskKey, sdk, threadId, sendFn) {
2892
2905
  send: sendFn,
2893
2906
  registeredAt: Date.now(),
2894
2907
  });
2908
+ notifyActiveSessionListeners("start", taskKey);
2895
2909
  }
2896
2910
 
2897
2911
  /**
@@ -2900,6 +2914,7 @@ function registerActiveSession(taskKey, sdk, threadId, sendFn) {
2900
2914
  */
2901
2915
  function unregisterActiveSession(taskKey) {
2902
2916
  activeSessions.delete(taskKey);
2917
+ notifyActiveSessionListeners("end", taskKey);
2903
2918
  }
2904
2919
 
2905
2920
  /**
@@ -2949,15 +2964,31 @@ export function hasActiveSession(taskKey) {
2949
2964
  * Get info about which tasks have active (steerable) sessions.
2950
2965
  * @returns {Array<{ taskKey: string, sdk: string, threadId: string|null, age: number }>}
2951
2966
  */
2967
+ export function addActiveSessionListener(listener) {
2968
+ if (typeof listener !== "function") return () => {};
2969
+ ACTIVE_SESSION_LISTENERS.add(listener);
2970
+ return () => ACTIVE_SESSION_LISTENERS.delete(listener);
2971
+ }
2972
+
2952
2973
  export function getActiveSessions() {
2953
2974
  const now = Date.now();
2954
2975
  const result = [];
2955
2976
  for (const [key, session] of activeSessions) {
2977
+ const record = threadRegistry.get(key) || null;
2978
+ const createdAt = Number(record?.createdAt || session.registeredAt || now);
2979
+ const lastUsedAt = Number(record?.lastUsedAt || now);
2956
2980
  result.push({
2981
+ id: key,
2982
+ taskId: key,
2957
2983
  taskKey: key,
2958
2984
  sdk: session.sdk,
2959
2985
  threadId: session.threadId,
2960
- age: now - session.registeredAt,
2986
+ type: "task",
2987
+ status: "active",
2988
+ turnCount: Number(record?.turnCount || 0),
2989
+ createdAt: new Date(createdAt).toISOString(),
2990
+ lastActiveAt: new Date(lastUsedAt).toISOString(),
2991
+ age: Math.max(0, now - createdAt),
2961
2992
  });
2962
2993
  }
2963
2994
  return result;
@@ -3973,3 +4004,5 @@ export function getActiveThreads() {
3973
4004
  }
3974
4005
  return result;
3975
4006
  }
4007
+
4008
+
@@ -1,5 +1,6 @@
1
- import { readFile } from "node:fs/promises";
2
- import { resolve } from "node:path";
1
+ import { readFile, writeFile, mkdir } from "node:fs/promises";
2
+ import { dirname, resolve } from "node:path";
3
+ import { fileURLToPath } from "node:url";
3
4
  import { resolveRepoRoot } from "../config/repo-root.mjs";
4
5
 
5
6
  const DAY_MS = 24 * 60 * 60 * 1000;
@@ -89,6 +90,12 @@ function defaultLogPaths() {
89
90
  };
90
91
  }
91
92
 
93
+ export function getWeeklyReportStatePath(options = {}) {
94
+ if (options?.statePath) return resolve(String(options.statePath));
95
+ const repoRoot = resolveRepoRoot();
96
+ return resolve(repoRoot, ".cache", "agent-work-logs", "weekly-report-state.json");
97
+ }
98
+
92
99
  async function defaultLoadMetrics() {
93
100
  const { metricsPath } = defaultLogPaths();
94
101
  return await readJsonlFile(metricsPath);
@@ -99,6 +106,44 @@ async function defaultLoadErrors() {
99
106
  return await readJsonlFile(errorsPath);
100
107
  }
101
108
 
109
+ export async function readWeeklyReportScheduleState(options = {}) {
110
+ const statePath = getWeeklyReportStatePath(options);
111
+ try {
112
+ const raw = await readFile(statePath, "utf8");
113
+ const parsed = JSON.parse(raw);
114
+ const lastSentAt = String(parsed?.lastSentAt || "").trim();
115
+ if (!lastSentAt) {
116
+ return { statePath, lastSentAt: null };
117
+ }
118
+ const lastSentMs = Date.parse(lastSentAt);
119
+ return {
120
+ statePath,
121
+ lastSentAt: Number.isFinite(lastSentMs) ? new Date(lastSentMs).toISOString() : null,
122
+ };
123
+ } catch (err) {
124
+ if (err?.code === "ENOENT") {
125
+ return { statePath, lastSentAt: null };
126
+ }
127
+ return { statePath, lastSentAt: null };
128
+ }
129
+ }
130
+
131
+ export async function writeWeeklyReportScheduleState(lastSentAt, options = {}) {
132
+ const statePath = getWeeklyReportStatePath(options);
133
+ const normalized = String(lastSentAt || "").trim();
134
+ const lastSentMs = Date.parse(normalized);
135
+ const payload = {
136
+ lastSentAt: Number.isFinite(lastSentMs) ? new Date(lastSentMs).toISOString() : null,
137
+ updatedAt: new Date().toISOString(),
138
+ };
139
+ await mkdir(dirname(statePath), { recursive: true });
140
+ await writeFile(statePath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
141
+ return {
142
+ statePath,
143
+ lastSentAt: payload.lastSentAt,
144
+ };
145
+ }
146
+
102
147
  export function buildWeeklyAgentWorkSummary(options = {}) {
103
148
  const now = toSafeDate(options.now);
104
149
  const days = toPositiveDays(options.days, DEFAULT_DAYS);
@@ -359,4 +404,45 @@ export function shouldSendWeeklyReport(options = {}) {
359
404
  return true;
360
405
  }
361
406
  return lastSentMs < scheduledThisWeek.getTime();
362
- }
407
+ }
408
+
409
+ export async function runWeeklyAgentWorkReportCli(options = {}) {
410
+ const argv = Array.isArray(options.argv) ? options.argv : process.argv.slice(2);
411
+ const stdout = options.stdout || process.stdout;
412
+ const stderr = options.stderr || process.stderr;
413
+ const rawDays = String(argv[0] || "").trim();
414
+ const parsedDays = Number.parseInt(rawDays, 10);
415
+ const days = Number.isFinite(parsedDays) && parsedDays > 0 ? parsedDays : undefined;
416
+
417
+ try {
418
+ const report = await generateWeeklyAgentWorkReport({
419
+ ...(days ? { days } : {}),
420
+ ...(options.now ? { now: options.now } : {}),
421
+ ...(typeof options.loadMetrics === "function"
422
+ ? { loadMetrics: options.loadMetrics }
423
+ : {}),
424
+ ...(typeof options.loadErrors === "function"
425
+ ? { loadErrors: options.loadErrors }
426
+ : {}),
427
+ });
428
+ stdout.write(`${report.text}\n`);
429
+ if (Array.isArray(report.warnings) && report.warnings.length > 0) {
430
+ stderr.write(`Warnings: ${report.warnings.join(" | ")}\n`);
431
+ }
432
+ return 0;
433
+ } catch (err) {
434
+ stderr.write(`Failed to generate weekly agent work report: ${err?.message || err}\n`);
435
+ return 1;
436
+ }
437
+ }
438
+
439
+ const isDirectRun =
440
+ process.argv[1] &&
441
+ fileURLToPath(import.meta.url) === resolve(process.argv[1]);
442
+
443
+ if (isDirectRun) {
444
+ const exitCode = await runWeeklyAgentWorkReportCli();
445
+ if (exitCode !== 0) {
446
+ process.exitCode = exitCode;
447
+ }
448
+ }
@@ -203,8 +203,10 @@ function buildErrorCorrelationEntries(errors, taskProfiles) {
203
203
  count: 0,
204
204
  task_ids: new Set(),
205
205
  by_executor: {},
206
+ by_model: {},
206
207
  by_size: {},
207
208
  by_complexity: {},
209
+ avg_task_duration_ms: 0,
208
210
  sample_message: "",
209
211
  first_seen_ts: null,
210
212
  last_seen_ts: null,
@@ -240,13 +242,23 @@ function buildErrorCorrelationEntries(errors, taskProfiles) {
240
242
  extractSizeLabelFromTitle(error.task_title),
241
243
  );
242
244
  const executor = resolveKnownValue(profile?.executor, error.executor);
245
+ const model = resolveKnownValue(profile?.model, error.model);
243
246
  const complexity = resolveKnownValue(profile?.complexity);
244
247
 
245
248
  incrementCounter(entry.by_size, sizeLabel);
246
249
  incrementCounter(entry.by_executor, executor);
250
+ incrementCounter(entry.by_model, model);
247
251
  incrementCounter(entry.by_complexity, complexity);
248
252
  }
249
253
 
254
+ for (const entry of correlations.values()) {
255
+ const durations = [...entry.task_ids]
256
+ .map((taskId) => taskProfiles.get(taskId)?.avg_duration_ms || 0)
257
+ .filter((duration) => duration > 0);
258
+ entry.avg_task_duration_ms =
259
+ durations.length > 0 ? average(durations) : 0;
260
+ }
261
+
250
262
  return correlations;
251
263
  }
252
264
 
@@ -300,7 +312,9 @@ export function buildErrorCorrelationJsonPayload(summary, { now } = {}) {
300
312
  last_seen: entry.last_seen_ts
301
313
  ? new Date(entry.last_seen_ts).toISOString()
302
314
  : null,
315
+ avg_task_duration_ms: entry.avg_task_duration_ms || 0,
303
316
  by_executor: buildDistribution(entry.by_executor, entry.count),
317
+ by_model: buildDistribution(entry.by_model, entry.count),
304
318
  by_size: buildDistribution(entry.by_size, entry.count),
305
319
  by_complexity: buildDistribution(entry.by_complexity, entry.count),
306
320
  })),
@@ -21,7 +21,7 @@
21
21
  import { readFile, readdir } from "fs/promises";
22
22
  import { createReadStream, existsSync } from "fs";
23
23
  import { createInterface } from "readline";
24
- import { resolve, dirname } from "path";
24
+ import { resolve, dirname, isAbsolute } from "path";
25
25
  import { fileURLToPath } from "url";
26
26
  import {
27
27
  buildErrorClusters,
@@ -44,8 +44,16 @@ const __filename = fileURLToPath(import.meta.url);
44
44
  const __dirname = dirname(__filename);
45
45
  const repoRoot = resolve(__dirname, "../..");
46
46
 
47
+ function resolveAgentWorkLogDir() {
48
+ const configured = String(process.env.AGENT_WORK_LOG_DIR || "").trim();
49
+ if (!configured) {
50
+ return resolve(repoRoot, ".cache/agent-work-logs");
51
+ }
52
+ return isAbsolute(configured) ? configured : resolve(repoRoot, configured);
53
+ }
54
+
47
55
  // ── Log Paths ───────────────────────────────────────────────────────────────
48
- const LOG_DIR = resolve(repoRoot, ".cache/agent-work-logs");
56
+ const LOG_DIR = resolveAgentWorkLogDir();
49
57
  const STREAM_LOG = resolve(LOG_DIR, "agent-work-stream.jsonl");
50
58
  const ERRORS_LOG = resolve(LOG_DIR, "agent-errors.jsonl");
51
59
  const METRICS_LOG = resolve(LOG_DIR, "agent-metrics.jsonl");
@@ -339,6 +347,14 @@ function formatDistribution(counts, total, limit = 5) {
339
347
  .join(", ");
340
348
  }
341
349
 
350
+ function formatDurationMs(durationMs) {
351
+ const value = Number(durationMs) || 0;
352
+ if (value <= 0) return "unknown";
353
+ if (value < 1000) return `${value.toFixed(0)}ms`;
354
+ if (value < 60_000) return `${(value / 1000).toFixed(1)}s`;
355
+ return `${(value / 60_000).toFixed(1)}m`;
356
+ }
357
+
342
358
  function topN(obj, n) {
343
359
  return Object.entries(obj)
344
360
  .sort((a, b) => b[1] - a[1])
@@ -478,7 +494,7 @@ async function correlateErrors(options) {
478
494
  const errors = await loadErrors({ days: windowDays });
479
495
 
480
496
  if (errors.length === 0) {
481
- const message = "No error data found";
497
+ const message = "No data found for selected window";
482
498
  if (useJson) {
483
499
  console.log(
484
500
  JSON.stringify(
@@ -522,10 +538,14 @@ async function correlateErrors(options) {
522
538
  console.log(
523
539
  ` Executors: ${formatDistribution(entry.by_executor, entry.count)}`,
524
540
  );
541
+ console.log(` Models: ${formatDistribution(entry.by_model, entry.count)}`);
525
542
  console.log(` Sizes: ${formatDistribution(entry.by_size, entry.count)}`);
526
543
  console.log(
527
544
  ` Complexity: ${formatDistribution(entry.by_complexity, entry.count)}`,
528
545
  );
546
+ console.log(
547
+ ` Avg task duration: ${formatDurationMs(entry.avg_task_duration_ms)}`,
548
+ );
529
549
  if (entry.sample_message) {
530
550
  console.log(
531
551
  ` Sample: ${entry.sample_message.slice(0, 100)}${entry.sample_message.length > 100 ? "..." : ""}`,
@@ -9,6 +9,7 @@ import { loadConfig } from "../config/config.mjs";
9
9
  import { ensureCodexConfig, printConfigSummary } from "../shell/codex-config.mjs";
10
10
  import { ensureRepoConfigs, printRepoConfigSummary } from "../config/repo-config.mjs";
11
11
  import { resolveRepoRoot } from "../config/repo-root.mjs";
12
+ import { buildArchitectEditorFrame } from "../lib/repo-map.mjs";
12
13
  import { getAgentToolConfig, getEffectiveTools } from "./agent-tool-config.mjs";
13
14
  import { getSessionTracker } from "../infra/session-tracker.mjs";
14
15
  import { getEntry, getEntryContent, resolveAgentProfileLibraryMetadata } from "../infra/library-manager.mjs";
@@ -191,6 +192,10 @@ function appendAttachmentsToPrompt(message, attachments) {
191
192
  return { message: `${message}${lines.join("\n")}`, appended: true };
192
193
  }
193
194
 
195
+
196
+
197
+
198
+
194
199
  function summarizeContextCompressionItems(items) {
195
200
  if (!Array.isArray(items) || items.length === 0) return null;
196
201
 
@@ -1062,8 +1067,9 @@ export async function execPrimaryPrompt(userMessage, options = {}) {
1062
1067
  const messageWithAttachments = attachments.length && !attachmentsAppended
1063
1068
  ? appendAttachmentsToPrompt(userMessage, attachments).message
1064
1069
  : userMessage;
1070
+ const architectEditorFrame = buildArchitectEditorFrame(options, effectiveMode);
1065
1071
  const toolContract = buildPrimaryToolCapabilityContract(options);
1066
- const messageWithToolContract = [selectedProfile.block, toolContract, messageWithAttachments]
1072
+ const messageWithToolContract = [selectedProfile.block, architectEditorFrame, toolContract, messageWithAttachments]
1067
1073
  .filter(Boolean)
1068
1074
  .join("\n\n");
1069
1075
  const framedMessage = modePrefix ? modePrefix + messageWithToolContract : messageWithToolContract;
@@ -1257,6 +1263,18 @@ export async function execPrimaryPrompt(userMessage, options = {}) {
1257
1263
  timestamp: new Date().toISOString(),
1258
1264
  _sessionType: sessionType,
1259
1265
  });
1266
+ const compressionSummary = summarizeContextCompressionItems(retryResult?.items);
1267
+ if (compressionSummary) {
1268
+ tracker.recordEvent(sessionId, {
1269
+ role: "system",
1270
+ type: "system",
1271
+ content: compressionSummary.content,
1272
+ timestamp: new Date().toISOString(),
1273
+ meta: {
1274
+ contextCompression: compressionSummary,
1275
+ },
1276
+ });
1277
+ }
1260
1278
  clearAdapterFailureState(adapterName);
1261
1279
  return retryResult;
1262
1280
  } catch (retryErr) {
@@ -1538,3 +1556,7 @@ export async function execSdkCommand(command, args = "", adapterName, options =
1538
1556
  }
1539
1557
  return adapter.execSdkCommand(cmd, args, options);
1540
1558
  }
1559
+
1560
+
1561
+
1562
+
package/bosun-tui.mjs CHANGED
@@ -14,9 +14,10 @@
14
14
 
15
15
  import { resolve, dirname } from "node:path";
16
16
  import { fileURLToPath } from "node:url";
17
- import { readFileSync, existsSync } from "node:fs";
17
+ import { readFileSync } from "node:fs";
18
18
 
19
- const __dirname = dirname(fileURLToPath(new URL(".", import.meta.url)));
19
+ const __filename = fileURLToPath(import.meta.url);
20
+ const __dirname = dirname(__filename);
20
21
 
21
22
  function showHelp() {
22
23
  const version = JSON.parse(
@@ -50,7 +51,7 @@ function showHelp() {
50
51
  Enter Select / Execute action
51
52
  Esc Back / Close modal
52
53
  c Create new task (tasks screen)
53
- r Refresh data
54
+ r Resume selected agent session (Agents screen)
54
55
  q Quit
55
56
 
56
57
  EXAMPLES
package/bosun.schema.json CHANGED
@@ -855,7 +855,7 @@
855
855
  },
856
856
  "defaults": {
857
857
  "type": "object",
858
- "additionalProperties": false,
858
+ "additionalProperties": true,
859
859
  "properties": {
860
860
  "executor": {
861
861
  "type": "string"
package/config/config.mjs CHANGED
@@ -454,6 +454,24 @@ function parseEnvBoolean(value, defaultValue) {
454
454
  return defaultValue;
455
455
  }
456
456
 
457
+ function parseBoundedInteger(value, defaultValue, { min = null, max = null } = {}) {
458
+ const parsed = Number.parseInt(String(value ?? "").trim(), 10);
459
+ if (!Number.isFinite(parsed)) return defaultValue;
460
+ if (Number.isFinite(min) && parsed < min) return defaultValue;
461
+ if (Number.isFinite(max) && parsed > max) return defaultValue;
462
+ return parsed;
463
+ }
464
+
465
+ function parseBoundedNumber(value, defaultValue, { min = null, max = null } = {}) {
466
+ const normalized = String(value ?? "").trim();
467
+ if (!normalized) return defaultValue;
468
+ const parsed = Number(normalized);
469
+ if (!Number.isFinite(parsed)) return defaultValue;
470
+ if (Number.isFinite(min) && parsed < min) return defaultValue;
471
+ if (Number.isFinite(max) && parsed > max) return defaultValue;
472
+ return parsed;
473
+ }
474
+
457
475
  function isEnvEnabled(value, defaultValue = false) {
458
476
  return parseEnvBoolean(value, defaultValue);
459
477
  }
@@ -1578,6 +1596,10 @@ export function loadConfig(argv = process.argv, options = {}) {
1578
1596
  validateKanbanBackendConfig({ kanbanBackend, kanban, jira });
1579
1597
 
1580
1598
  const internalExecutorConfig = configData.internalExecutor || {};
1599
+ const workflowRecoveryConfig =
1600
+ configData.workflowRecovery && typeof configData.workflowRecovery === "object"
1601
+ ? configData.workflowRecovery
1602
+ : {};
1581
1603
  const envInternalExecutorParallel = configFileHadInvalidJson
1582
1604
  ? undefined
1583
1605
  : process.env.INTERNAL_EXECUTOR_PARALLEL;
@@ -1629,6 +1651,41 @@ export function loadConfig(argv = process.argv, options = {}) {
1629
1651
  String(reviewAgentToggleRaw).trim() !== ""
1630
1652
  ? isEnvEnabled(reviewAgentToggleRaw, true)
1631
1653
  : internalExecutorConfig.reviewAgentEnabled !== false;
1654
+ const workflowRecoveryMaxAttempts = parseBoundedInteger(
1655
+ process.env.WORKFLOW_RECOVERY_MAX_ATTEMPTS ??
1656
+ workflowRecoveryConfig.maxAttempts,
1657
+ 5,
1658
+ { min: 1, max: 20 },
1659
+ );
1660
+ const workflowRecoveryEscalationThreshold = parseBoundedInteger(
1661
+ process.env.WORKFLOW_RECOVERY_ESCALATION_THRESHOLD ??
1662
+ workflowRecoveryConfig.escalationWarnAfterAttempts ??
1663
+ workflowRecoveryConfig.escalationThreshold,
1664
+ 3,
1665
+ { min: 1, max: workflowRecoveryMaxAttempts },
1666
+ );
1667
+ const workflowRecovery = Object.freeze({
1668
+ maxAttempts: workflowRecoveryMaxAttempts,
1669
+ escalationWarnAfterAttempts: workflowRecoveryEscalationThreshold,
1670
+ baseBackoffMs: parseBoundedInteger(
1671
+ process.env.WORKFLOW_RECOVERY_BACKOFF_BASE_MS ??
1672
+ workflowRecoveryConfig.baseBackoffMs,
1673
+ 5000,
1674
+ { min: 50, max: 60_000 },
1675
+ ),
1676
+ maxBackoffMs: parseBoundedInteger(
1677
+ process.env.WORKFLOW_RECOVERY_BACKOFF_MAX_MS ??
1678
+ workflowRecoveryConfig.maxBackoffMs,
1679
+ 60_000,
1680
+ { min: 1000, max: 30 * 60 * 1000 },
1681
+ ),
1682
+ jitterRatio: parseBoundedNumber(
1683
+ process.env.WORKFLOW_RECOVERY_BACKOFF_JITTER_RATIO ??
1684
+ workflowRecoveryConfig.jitterRatio,
1685
+ 0.2,
1686
+ { min: 0, max: 0.9 },
1687
+ ),
1688
+ });
1632
1689
  const internalExecutor = {
1633
1690
  mode: ["vk", "internal", "hybrid"].includes(executorMode)
1634
1691
  ? executorMode
@@ -2059,6 +2116,7 @@ export function loadConfig(argv = process.argv, options = {}) {
2059
2116
 
2060
2117
  // Internal Executor
2061
2118
  internalExecutor,
2119
+ workflowRecovery,
2062
2120
  executorMode: internalExecutor.mode,
2063
2121
  kanban,
2064
2122
  kanbanSource,
@@ -1,11 +1,42 @@
1
1
  import { existsSync, readFileSync } from "node:fs";
2
- import { join } from "node:path";
2
+ import { isAbsolute, join, resolve } from "node:path";
3
3
  import { execSync } from "node:child_process";
4
4
  import { homedir } from "node:os";
5
5
 
6
+ export function normalizeWorkspaceHealthPath(value) {
7
+ const trimmed = String(value || "").trim();
8
+ if (!trimmed) return "";
9
+ const isWindowsDrivePath = /^[A-Za-z]:[\\/]/.test(trimmed);
10
+ const isUncPath = trimmed.startsWith("\\\\");
11
+ const normalized = (isWindowsDrivePath || isUncPath ? trimmed : resolve(trimmed))
12
+ .replace(/\\/g, "/");
13
+ if (/^[A-Z]:/.test(normalized)) {
14
+ return normalized[0].toLowerCase() + normalized.slice(1);
15
+ }
16
+ return normalized;
17
+ }
18
+
19
+ export function isAbsoluteWorkspaceHealthPath(value) {
20
+ const trimmed = String(value || "").trim();
21
+ if (!trimmed) return false;
22
+ if (/^[A-Za-z]:[\\/]/.test(trimmed)) return true;
23
+ if (trimmed.startsWith("\\\\")) return true;
24
+ return isAbsolute(trimmed);
25
+ }
26
+
27
+ export function writableRootContainsPath(root, candidate) {
28
+ const normalizedRoot = normalizeWorkspaceHealthPath(root);
29
+ const normalizedCandidate = normalizeWorkspaceHealthPath(candidate);
30
+ if (!normalizedRoot || !normalizedCandidate) return false;
31
+ return (
32
+ normalizedCandidate === normalizedRoot ||
33
+ normalizedCandidate.startsWith(`${normalizedRoot}/`)
34
+ );
35
+ }
36
+
6
37
  export function runWorkspaceHealthCheck(options = {}) {
7
38
  const configDir = options.configDir || process.env.BOSUN_DIR || join(homedir(), "bosun");
8
- const issues = { errors: [], warnings: [], infos: [] };
39
+ const issues = { errors: [], warnings: [], infos: [] };
9
40
  const workspaceResults = [];
10
41
 
11
42
  // 1. Check if workspaces are configured
@@ -128,7 +159,7 @@ export function runWorkspaceHealthCheck(options = {}) {
128
159
  for (const repo of ws.repos || []) {
129
160
  const repoPath = join(wsPath, repo.name || repo.slug || "");
130
161
  const gitPath = join(repoPath, ".git");
131
- if (existsSync(gitPath) && !roots.some(r => gitPath.startsWith(r) || r === gitPath)) {
162
+ if (existsSync(gitPath) && !roots.some((root) => writableRootContainsPath(root, gitPath))) {
132
163
  issues.warnings.push({
133
164
  code: "WS_SANDBOX_MISSING_ROOT",
134
165
  message: `Workspace repo .git not in Codex writable_roots: ${gitPath}`,
@@ -140,13 +171,13 @@ export function runWorkspaceHealthCheck(options = {}) {
140
171
 
141
172
  // Check for phantom/relative writable roots
142
173
  for (const root of roots) {
143
- if (!root.startsWith("/")) {
174
+ if (!isAbsoluteWorkspaceHealthPath(root)) {
144
175
  issues.warnings.push({
145
176
  code: "WS_SANDBOX_RELATIVE_ROOT",
146
177
  message: `Relative path in Codex writable_roots: "${root}" — may resolve incorrectly`,
147
178
  fix: `Remove "${root}" from writable_roots in ~/.codex/config.toml and run 'bosun --setup'`,
148
179
  });
149
- } else if (!existsSync(root) && root !== "/tmp") {
180
+ } else if (!existsSync(root) && normalizeWorkspaceHealthPath(root) !== normalizeWorkspaceHealthPath("/tmp")) {
150
181
  issues.infos.push({
151
182
  code: "WS_SANDBOX_PHANTOM_ROOT",
152
183
  message: `Codex writable_root path does not exist: ${root}`,
@@ -239,4 +270,3 @@ export function formatWorkspaceHealthReport(result) {
239
270
 
240
271
  return lines.join("\n");
241
272
  }
242
-