bosun 0.40.11 → 0.40.13

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/cli.mjs CHANGED
@@ -336,6 +336,19 @@ const DAEMON_MAX_INSTANT_RESTARTS = Math.max(
336
336
  1,
337
337
  Number(process.env.BOSUN_DAEMON_MAX_INSTANT_RESTARTS || 5) || 5,
338
338
  );
339
+ const DAEMON_MISCONFIG_GUARD_ENABLED = !["0", "false", "off", "no"].includes(
340
+ String(process.env.BOSUN_DAEMON_MISCONFIG_GUARD || "1")
341
+ .trim()
342
+ .toLowerCase(),
343
+ );
344
+ const DAEMON_MISCONFIG_GUARD_MIN_RESTARTS = Math.max(
345
+ 1,
346
+ Number(process.env.BOSUN_DAEMON_MISCONFIG_GUARD_MIN_RESTARTS || 3) || 3,
347
+ );
348
+ const DAEMON_MISCONFIG_LOG_SCAN_LINES = Math.max(
349
+ 20,
350
+ Number(process.env.BOSUN_DAEMON_MISCONFIG_LOG_SCAN_LINES || 250) || 250,
351
+ );
339
352
  let daemonRestartCount = 0;
340
353
  const daemonCrashTracker = createDaemonCrashTracker({
341
354
  instantCrashWindowMs: DAEMON_INSTANT_CRASH_WINDOW_MS,
@@ -2080,6 +2093,77 @@ function getMonitorPidFileCandidates(extraCacheDirs = []) {
2080
2093
  ]);
2081
2094
  }
2082
2095
 
2096
+ function tailLinesFromFile(filePath, maxLines = 200) {
2097
+ try {
2098
+ if (!existsSync(filePath)) return [];
2099
+ const raw = readFileSync(filePath, "utf8");
2100
+ if (!raw) return [];
2101
+ const lines = raw.split(/\r?\n/).filter(Boolean);
2102
+ if (lines.length <= maxLines) return lines;
2103
+ return lines.slice(-maxLines);
2104
+ } catch {
2105
+ return [];
2106
+ }
2107
+ }
2108
+
2109
+ function detectDaemonRestartStormSignals(options) {
2110
+ const resolvedOptions = options && typeof options === "object" ? options : {};
2111
+ const logDir = resolvedOptions.logDir || resolve(__dirname, "logs");
2112
+ const maxLines = resolvedOptions.maxLines || DAEMON_MISCONFIG_LOG_SCAN_LINES;
2113
+ const reasons = [];
2114
+ const monitorErrorLines = tailLinesFromFile(
2115
+ resolve(logDir, "monitor-error.log"),
2116
+ maxLines,
2117
+ );
2118
+ const monitorLines = tailLinesFromFile(resolve(logDir, "monitor.log"), maxLines);
2119
+ const combined = [...monitorErrorLines, ...monitorLines].join("\n");
2120
+ if (!combined) {
2121
+ return { hasSignal: false, reasons: [] };
2122
+ }
2123
+
2124
+ if (
2125
+ /missing prerequisites:\s*no API key|codex unavailable:\s*no API key/i.test(
2126
+ combined,
2127
+ )
2128
+ ) {
2129
+ reasons.push("missing_api_key");
2130
+ }
2131
+ if (
2132
+ /another bosun instance holds the lock|duplicate start ignored|another bosun is already running/i
2133
+ .test(combined)
2134
+ ) {
2135
+ reasons.push("duplicate_runtime");
2136
+ }
2137
+ if (/Shared state heartbeat FATAL.*owner_mismatch/i.test(combined)) {
2138
+ reasons.push("shared_state_owner_mismatch");
2139
+ }
2140
+ if (
2141
+ /There is no tracking information for the current branch|git pull <remote> <branch>/i
2142
+ .test(combined)
2143
+ ) {
2144
+ reasons.push("workspace_git_tracking_missing");
2145
+ }
2146
+
2147
+ return {
2148
+ hasSignal: reasons.length > 0,
2149
+ reasons,
2150
+ };
2151
+ }
2152
+
2153
+ function shouldPauseDaemonRestartStorm(options) {
2154
+ const resolvedOptions = options && typeof options === "object" ? options : {};
2155
+ const restartCount = Number(resolvedOptions.restartCount || 0);
2156
+ const logDir = resolvedOptions.logDir;
2157
+ if (!IS_DAEMON_CHILD) return { pause: false, reasons: [] };
2158
+ if (!DAEMON_MISCONFIG_GUARD_ENABLED) return { pause: false, reasons: [] };
2159
+ if (restartCount < DAEMON_MISCONFIG_GUARD_MIN_RESTARTS) {
2160
+ return { pause: false, reasons: [] };
2161
+ }
2162
+ const signals = detectDaemonRestartStormSignals({ logDir });
2163
+ if (!signals.hasSignal) return { pause: false, reasons: [] };
2164
+ return { pause: true, reasons: signals.reasons };
2165
+ }
2166
+
2083
2167
  function detectExistingMonitorLockOwner(excludePid = null) {
2084
2168
  try {
2085
2169
  for (const pidFile of getMonitorPidFileCandidates()) {
@@ -2213,6 +2297,19 @@ function runMonitor({ restartReason = "" } = {}) {
2213
2297
  DAEMON_MAX_RESTART_DELAY_MS,
2214
2298
  );
2215
2299
  const delayMs = isOSKill ? 5000 : backoffDelay;
2300
+ const restartStormGuard = shouldPauseDaemonRestartStorm({
2301
+ restartCount: daemonRestartCount,
2302
+ });
2303
+ if (restartStormGuard.pause) {
2304
+ const reasonLabel = restartStormGuard.reasons.join(", ");
2305
+ console.error(
2306
+ `\n :close: Monitor restart storm paused after ${daemonRestartCount} attempts due to persistent runtime issues (${reasonLabel}).`,
2307
+ );
2308
+ sendCrashNotification(exitCode, signal).finally(() =>
2309
+ process.exit(exitCode),
2310
+ );
2311
+ return;
2312
+ }
2216
2313
  if (IS_DAEMON_CHILD && crashState.exceeded) {
2217
2314
  const durationSec = Math.max(
2218
2315
  1,
@@ -15,6 +15,8 @@ const STRIPPED_GIT_ENV_KEYS = [
15
15
  const BLOCKED_TEST_GIT_IDENTITIES = new Set([
16
16
  "test@example.com",
17
17
  "bosun-tests@example.com",
18
+ "bot@example.com",
19
+ "test@test.com",
18
20
  ]);
19
21
 
20
22
  const TEST_FIXTURE_SENTINEL_PATHS = new Set([
@@ -264,3 +266,18 @@ export function evaluateBranchSafetyForPush(worktreePath, opts = {}) {
264
266
  },
265
267
  };
266
268
  }
269
+
270
+ /**
271
+ * Clear any blocked test git identity from a worktree's local config.
272
+ * Worktrees inherit the parent repo's config, so if a test ever set
273
+ * user.name/email there it will poison all task commits until cleared.
274
+ * Call this after acquiring any worktree.
275
+ */
276
+ export function clearBlockedWorktreeIdentity(worktreePath) {
277
+ const email = getGitConfig(worktreePath, "user.email").toLowerCase();
278
+ if (!BLOCKED_TEST_GIT_IDENTITIES.has(email)) return false;
279
+
280
+ runGit(["config", "--local", "--unset", "user.email"], worktreePath, 5_000);
281
+ runGit(["config", "--local", "--unset", "user.name"], worktreePath, 5_000);
282
+ return true;
283
+ }
package/infra/monitor.mjs CHANGED
@@ -197,7 +197,7 @@ import {
197
197
  installConsoleInterceptor,
198
198
  setErrorLogFile,
199
199
  } from "../lib/logger.mjs";
200
- import { fixGitConfigCorruption } from "../workspace/worktree-manager.mjs";
200
+ import { fixGitConfigCorruption, listActiveWorktrees } from "../workspace/worktree-manager.mjs";
201
201
  // ── Task management subsystem imports ──────────────────────────────────────
202
202
  import {
203
203
  configureTaskStore,
@@ -14062,6 +14062,98 @@ safeSetInterval("workflow-review-merge-reconcile", async () => {
14062
14062
 
14063
14063
  // Legacy merged PR check removed (workflow-only control).
14064
14064
 
14065
+ // ── Periodic stale worktree sync: every 5 min ─────────────────────────────
14066
+ // Detects task worktrees that are diverged from their remote (local commits
14067
+ // not pushed, or remote has newer commits from a previous run). For each
14068
+ // diverged worktree it: fetches remote, rebases local onto the remote tracking
14069
+ // ref for that branch, then pushes with --force-with-lease. This keeps the
14070
+ // VS Code source-control view clean and unblocks tasks that got stuck mid-push.
14071
+ async function syncDivergedWorktrees() {
14072
+ const worktrees = listActiveWorktrees(repoRoot);
14073
+ let synced = 0;
14074
+ let failed = 0;
14075
+
14076
+ for (const wt of worktrees) {
14077
+ const { path: wtPath, branch } = wt;
14078
+ if (!wtPath || !branch || !branch.startsWith("task/")) continue;
14079
+
14080
+ try {
14081
+ // Fetch remote to update tracking refs
14082
+ execSync("git fetch origin --no-tags", {
14083
+ cwd: wtPath, timeout: 30_000, stdio: ["ignore", "pipe", "pipe"],
14084
+ });
14085
+
14086
+ // Check ahead/behind vs remote tracking ref
14087
+ const remoteRef = `origin/${branch}`;
14088
+ let remoteExists = false;
14089
+ try {
14090
+ execSync(`git rev-parse --verify ${remoteRef}`, {
14091
+ cwd: wtPath, timeout: 5_000, stdio: ["ignore", "pipe", "pipe"],
14092
+ });
14093
+ remoteExists = true;
14094
+ } catch { /* branch not yet pushed — nothing to sync */ }
14095
+
14096
+ if (!remoteExists) continue;
14097
+
14098
+ const ahead = parseInt(
14099
+ execSync(`git rev-list --count ${remoteRef}..HEAD`, {
14100
+ cwd: wtPath, encoding: "utf8", timeout: 10_000, stdio: ["ignore", "pipe", "pipe"],
14101
+ }).trim(), 10);
14102
+ const behind = parseInt(
14103
+ execSync(`git rev-list --count HEAD..${remoteRef}`, {
14104
+ cwd: wtPath, encoding: "utf8", timeout: 10_000, stdio: ["ignore", "pipe", "pipe"],
14105
+ }).trim(), 10);
14106
+
14107
+ // Only act on diverged worktrees (behind > 0 AND ahead > 0)
14108
+ if (ahead === 0 || behind === 0) continue;
14109
+
14110
+ console.log(
14111
+ `[monitor:worktree-sync] ${branch} diverged: ${ahead} ahead, ${behind} behind — rebasing and pushing`,
14112
+ );
14113
+
14114
+ // Rebase local onto remote tracking ref to incorporate remote commits
14115
+ let rebased = false;
14116
+ try {
14117
+ execSync(`git rebase ${remoteRef}`, {
14118
+ cwd: wtPath, encoding: "utf8", timeout: 60_000, stdio: ["ignore", "pipe", "pipe"],
14119
+ });
14120
+ rebased = true;
14121
+ } catch (rebaseErr) {
14122
+ try { execSync("git rebase --abort", { cwd: wtPath, timeout: 10_000, stdio: ["ignore", "pipe", "pipe"] }); } catch { /* ok */ }
14123
+ console.warn(
14124
+ `[monitor:worktree-sync] ${branch} rebase conflict — skipping push: ${rebaseErr.message?.slice(0, 200)}`,
14125
+ );
14126
+ failed++;
14127
+ continue;
14128
+ }
14129
+
14130
+ // Push with --force-with-lease (safe: we just fetched fresh remote refs)
14131
+ try {
14132
+ execSync(`git push --force-with-lease --set-upstream origin HEAD`, {
14133
+ cwd: wtPath, encoding: "utf8", timeout: 30_000, stdio: ["ignore", "pipe", "pipe"],
14134
+ });
14135
+ console.log(`[monitor:worktree-sync] ${branch} sync-pushed successfully`);
14136
+ synced++;
14137
+ } catch (pushErr) {
14138
+ console.warn(
14139
+ `[monitor:worktree-sync] ${branch} push failed: ${pushErr.message?.slice(0, 200)}`,
14140
+ );
14141
+ failed++;
14142
+ }
14143
+ } catch (err) {
14144
+ console.warn(`[monitor:worktree-sync] ${branch} error: ${err.message?.slice(0, 200)}`);
14145
+ failed++;
14146
+ }
14147
+ }
14148
+
14149
+ if (synced > 0 || failed > 0) {
14150
+ console.log(`[monitor:worktree-sync] cycle complete — synced=${synced} failed=${failed}`);
14151
+ }
14152
+ }
14153
+
14154
+ const worktreeSyncIntervalMs = 5 * 60 * 1000; // 5 min
14155
+ safeSetInterval("worktree-sync", syncDivergedWorktrees, worktreeSyncIntervalMs);
14156
+
14065
14157
  // ── Periodic epic branch sync/merge: every 15 min ──────────────────────────
14066
14158
  const epicMergeIntervalMs = 15 * 60 * 1000;
14067
14159
  safeSetInterval("epic-merge-check", () => checkEpicBranches("interval"), epicMergeIntervalMs);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosun",
3
- "version": "0.40.11",
3
+ "version": "0.40.13",
4
4
  "description": "Bosun Autonomous Engineering — manages AI agent executors with failover, extremely powerful workflow builder, and a massive amount of included default workflow templates for autonomous engineering, creates PRs via Vibe-Kanban API, and sends Telegram notifications. Supports N executors with weighted distribution, multi-repo projects, and auto-setup.",
5
5
  "type": "module",
6
6
  "license": "Apache-2.0",