bosun 0.31.4 → 0.31.6

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.
@@ -240,6 +240,22 @@ export const PATTERN_SEVERITY = {
240
240
  unknown: "low",
241
241
  };
242
242
 
243
+ // ── Remediation hints ──────────────────────────────────────────────────────
244
+
245
+ /**
246
+ * Human-readable remediation hints for each error type.
247
+ * Surfaced in UI and Telegram notifications to guide users.
248
+ */
249
+ const REMEDIATION_HINTS = {
250
+ rate_limit: "Wait a few minutes before retrying. Consider reducing MAX_PARALLEL.",
251
+ oom: "Reduce MAX_PARALLEL or increase available memory. Use --max-old-space-size.",
252
+ oom_kill: "Process was killed by the OS. Reduce memory usage or increase system RAM.",
253
+ git_conflict: "Manual conflict resolution required. Run: git mergetool",
254
+ push_failure: "Rebase failed or push rejected. Run: git rebase --abort then try again.",
255
+ auth_error: "Authentication failed. Check your API tokens and credentials.",
256
+ api_error: "Network connectivity issue. Check your internet connection.",
257
+ };
258
+
243
259
  // ── Helpers ─────────────────────────────────────────────────────────────────
244
260
 
245
261
  /** Safely truncate a string for logging / details. */
@@ -364,6 +380,7 @@ export class ErrorDetector {
364
380
  return {
365
381
  ...result,
366
382
  severity: PATTERN_SEVERITY[result.pattern] ?? "low",
383
+ remediation: REMEDIATION_HINTS[result.pattern] || null,
367
384
  };
368
385
  }
369
386
 
@@ -903,7 +903,7 @@ class GitHubIssuesAdapter {
903
903
  process.env.BOSUN_TASK_LABEL || "bosun";
904
904
  this._taskScopeLabels = normalizeLabels(
905
905
  process.env.BOSUN_TASK_LABELS ||
906
- `${this._canonicalTaskLabel},codex-mointor`,
906
+ `${this._canonicalTaskLabel},codex-monitor`,
907
907
  );
908
908
  this._enforceTaskLabel = parseBooleanEnv(
909
909
  process.env.BOSUN_ENFORCE_TASK_LABEL,
@@ -915,7 +915,7 @@ class GitHubIssuesAdapter {
915
915
  true,
916
916
  );
917
917
  this._defaultAssignee =
918
- process.env.GITHUB_DEFAULT_ASSIGNEE || this._owner || null;
918
+ String(process.env.GITHUB_DEFAULT_ASSIGNEE || "").trim() || null;
919
919
 
920
920
  this._projectMode = String(process.env.GITHUB_PROJECT_MODE || "issues")
921
921
  .trim()
package/monitor.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import { execSync, spawn, spawnSync } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
2
3
  import {
3
4
  existsSync,
4
5
  mkdirSync,
@@ -7902,10 +7903,8 @@ function extractPlannerTasksFromOutput(output, maxTasks) {
7902
7903
  return normalized;
7903
7904
  }
7904
7905
 
7905
- async function materializePlannerTasksToKanban(projectId, tasks) {
7906
- const existingOpenTasks = await listKanbanTasks(projectId, {
7907
- status: "todo",
7908
- });
7906
+ async function materializePlannerTasksToKanban(tasks) {
7907
+ const existingOpenTasks = getInternalTasksByStatus("todo");
7909
7908
  const existingTitles = new Set(
7910
7909
  (Array.isArray(existingOpenTasks) ? existingOpenTasks : [])
7911
7910
  .map((task) => normalizePlannerTitleForComparison(task?.title))
@@ -7925,16 +7924,26 @@ async function materializePlannerTasksToKanban(projectId, tasks) {
7925
7924
  skipped.push({ title: task.title, reason: "duplicate_title" });
7926
7925
  continue;
7927
7926
  }
7928
- const createdTask = await createKanbanTask(projectId, {
7927
+ const baseBranch = task.baseBranch || task.base_branch || extractModuleBaseBranchFromTitle(task.title);
7928
+ const plannerMeta = {
7929
+ source: "task-planner",
7930
+ plannerMode: "codex-sdk",
7931
+ kind: "planned-task",
7932
+ externalSyncPending: true,
7933
+ createdAt: new Date().toISOString(),
7934
+ };
7935
+ const createdTask = addInternalTask({
7936
+ id: randomUUID(),
7929
7937
  title: task.title,
7930
7938
  description: task.description,
7931
7939
  status: "todo",
7932
- // Honour explicit baseBranch from planner JSON, then fall back to
7933
- // module auto-detection via conventional commit scope in the title.
7934
- ...(() => {
7935
- const b = task.baseBranch || task.base_branch || extractModuleBaseBranchFromTitle(task.title);
7936
- return b ? { baseBranch: b } : {};
7937
- })(),
7940
+ projectId: process.env.INTERNAL_EXECUTOR_PROJECT_ID || "internal",
7941
+ ...(baseBranch ? { baseBranch } : {}),
7942
+ syncDirty: true,
7943
+ meta: {
7944
+ planner: plannerMeta,
7945
+ ...(baseBranch ? { base_branch: baseBranch, baseBranch } : {}),
7946
+ },
7938
7947
  });
7939
7948
  if (createdTask?.id) {
7940
7949
  created.push({ id: createdTask.id, title: task.title });
@@ -8987,13 +8996,6 @@ async function triggerTaskPlannerViaKanban(
8987
8996
  details,
8988
8997
  numTasks,
8989
8998
  );
8990
- // Get project ID using the kanban adapter
8991
- const projectId = await findKanbanProjectId();
8992
- if (!projectId) {
8993
- throw new Error(
8994
- `Cannot reach kanban backend (${getActiveKanbanBackend()}) or no project found`,
8995
- );
8996
- }
8997
8999
 
8998
9000
  const desiredTitle = userPrompt
8999
9001
  ? `[${plannerTaskSizeLabel}] Plan next tasks (${reason || "backlog-empty"}) — ${userPrompt.slice(0, 60)}${userPrompt.length > 60 ? "…" : ""}`
@@ -9008,7 +9010,7 @@ async function triggerTaskPlannerViaKanban(
9008
9010
 
9009
9011
  // Check for existing planner tasks to avoid duplicates
9010
9012
  // Only block on TODO tasks whose title matches the exact format we create
9011
- const existingTasks = await listKanbanTasks(projectId, { status: "todo" });
9013
+ const existingTasks = getInternalTasksByStatus("todo");
9012
9014
  const existingPlanner = (
9013
9015
  Array.isArray(existingTasks) ? existingTasks : []
9014
9016
  ).find((t) => {
@@ -9042,7 +9044,7 @@ async function triggerTaskPlannerViaKanban(
9042
9044
  );
9043
9045
  }
9044
9046
 
9045
- const taskUrl = buildTaskUrl(existingPlanner, projectId);
9047
+ const taskUrl = null;
9046
9048
  if (notify) {
9047
9049
  const suffix = taskUrl ? `\n${taskUrl}` : "";
9048
9050
  await sendTelegramMessage(
@@ -9061,7 +9063,7 @@ async function triggerTaskPlannerViaKanban(
9061
9063
  taskId: existingPlanner.id,
9062
9064
  taskTitle: existingPlanner.title,
9063
9065
  taskUrl,
9064
- projectId,
9066
+ projectId: process.env.INTERNAL_EXECUTOR_PROJECT_ID || "internal",
9065
9067
  };
9066
9068
  }
9067
9069
 
@@ -9071,7 +9073,24 @@ async function triggerTaskPlannerViaKanban(
9071
9073
  status: "todo",
9072
9074
  };
9073
9075
 
9074
- const createdTask = await createKanbanTask(projectId, taskData);
9076
+ const createdTask = addInternalTask({
9077
+ id: randomUUID(),
9078
+ title: taskData.title,
9079
+ description: taskData.description,
9080
+ status: "todo",
9081
+ projectId: process.env.INTERNAL_EXECUTOR_PROJECT_ID || "internal",
9082
+ syncDirty: true,
9083
+ meta: {
9084
+ planner: {
9085
+ source: "task-planner",
9086
+ plannerMode: "kanban",
9087
+ kind: "planner-request",
9088
+ triggerReason: reason || "manual",
9089
+ externalSyncPending: true,
9090
+ createdAt: new Date().toISOString(),
9091
+ },
9092
+ },
9093
+ });
9075
9094
 
9076
9095
  if (createdTask && createdTask.id) {
9077
9096
  console.log(`[monitor] task planner task created: ${createdTask.id}`);
@@ -9082,7 +9101,7 @@ async function triggerTaskPlannerViaKanban(
9082
9101
  last_result: "kanban_task_created",
9083
9102
  });
9084
9103
  const createdId = createdTask.id;
9085
- const createdUrl = buildTaskUrl(createdTask, projectId);
9104
+ const createdUrl = null;
9086
9105
  if (notify) {
9087
9106
  const suffix = createdUrl ? `\n${createdUrl}` : "";
9088
9107
  await sendTelegramMessage(
@@ -9094,7 +9113,7 @@ async function triggerTaskPlannerViaKanban(
9094
9113
  taskId: createdId,
9095
9114
  taskTitle: taskData.title,
9096
9115
  taskUrl: createdUrl,
9097
- projectId,
9116
+ projectId: process.env.INTERNAL_EXECUTOR_PROJECT_ID || "internal",
9098
9117
  };
9099
9118
  }
9100
9119
  throw new Error("Task creation failed");
@@ -9194,14 +9213,7 @@ async function triggerTaskPlannerViaCodex(
9194
9213
  "utf8",
9195
9214
  );
9196
9215
 
9197
- const projectId = await findKanbanProjectId();
9198
- if (!projectId) {
9199
- throw new Error(
9200
- `Task planner produced ${parsedTasks.length} tasks, but no kanban project is reachable for backend "${getActiveKanbanBackend()}"`,
9201
- );
9202
- }
9203
9216
  const { created, skipped } = await materializePlannerTasksToKanban(
9204
- projectId,
9205
9217
  parsedTasks,
9206
9218
  );
9207
9219
 
@@ -9228,7 +9240,7 @@ async function triggerTaskPlannerViaCodex(
9228
9240
  status: "completed",
9229
9241
  outputPath: outPath,
9230
9242
  artifactPath,
9231
- projectId,
9243
+ projectId: process.env.INTERNAL_EXECUTOR_PROJECT_ID || "internal",
9232
9244
  parsedTaskCount: parsedTasks.length,
9233
9245
  createdTaskCount: created.length,
9234
9246
  skippedTaskCount: skipped.length,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosun",
3
- "version": "0.31.4",
3
+ "version": "0.31.6",
4
4
  "description": "AI-powered orchestrator supervisor — manages AI agent executors with failover, auto-restarts on failure, analyzes crashes with Codex SDK, 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",
package/sync-engine.mjs CHANGED
@@ -16,7 +16,6 @@
16
16
  import {
17
17
  getTask,
18
18
  getAllTasks,
19
- addTask,
20
19
  updateTask,
21
20
  getDirtyTasks,
22
21
  markSynced,
@@ -29,6 +28,7 @@ import {
29
28
  getKanbanAdapter,
30
29
  getKanbanBackendName,
31
30
  listTasks,
31
+ createTask as createExternalTask,
32
32
  updateTaskStatus as updateExternalStatus,
33
33
  } from "./kanban-adapter.mjs";
34
34
 
@@ -585,6 +585,11 @@ export class SyncEngine {
585
585
  baseBranchCandidate &&
586
586
  String(baseBranchCandidate) !== String(metaBaseBranch);
587
587
 
588
+ const shouldCreateExternal = this.#shouldCreateExternalTask(
589
+ task,
590
+ backendName,
591
+ );
592
+
588
593
  // Check shared state for conflicts before pushing
589
594
  if (SHARED_STATE_ENABLED) {
590
595
  try {
@@ -618,9 +623,48 @@ export class SyncEngine {
618
623
  // Continue with push on error (graceful degradation)
619
624
  }
620
625
  }
626
+ let pushId = task.externalId || task.id;
627
+
628
+ if (shouldCreateExternal) {
629
+ try {
630
+ const created = await this.#createExternalTask(task, baseBranchCandidate);
631
+ const createdId = this.#extractExternalTaskId(created);
632
+ if (!isIdValidForBackend(createdId, backendName)) {
633
+ throw new Error(
634
+ `external create returned incompatible id for ${backendName}: ${createdId || "<empty>"}`,
635
+ );
636
+ }
637
+
638
+ const plannerMeta = {
639
+ ...(task.meta?.planner || {}),
640
+ externalSyncPending: false,
641
+ externalCreatedAt: new Date().toISOString(),
642
+ externalTaskId: String(createdId),
643
+ };
644
+ updateTask(task.id, {
645
+ externalId: String(createdId),
646
+ externalBackend: backendName,
647
+ meta: {
648
+ ...(task.meta || {}),
649
+ planner: plannerMeta,
650
+ },
651
+ });
652
+
653
+ pushId = String(createdId);
654
+ console.log(
655
+ TAG,
656
+ `Created external task for ${task.id} → ${pushId} (${backendName})`,
657
+ );
658
+ } catch (err) {
659
+ const msg = `External create failed for ${task.id}: ${err.message}`;
660
+ console.warn(TAG, msg);
661
+ result.errors.push(msg);
662
+ continue;
663
+ }
664
+ }
665
+
621
666
  // Skip tasks whose IDs are incompatible with the active backend.
622
667
  // e.g. VK UUID tasks cannot be pushed to GitHub Issues (needs numeric IDs).
623
- const pushId = task.externalId || task.id;
624
668
  if (!isIdValidForBackend(pushId, backendName)) {
625
669
  // If the task originated from a different backend, silently clear dirty
626
670
  // flag — it will be synced when that backend is active.
@@ -973,6 +1017,28 @@ export class SyncEngine {
973
1017
  return await updateExternalStatus(taskId, status);
974
1018
  }
975
1019
 
1020
+ async #createExternalTask(task, baseBranchCandidate = null) {
1021
+ const payload = {
1022
+ title: task.title || "Untitled task",
1023
+ description: task.description || "",
1024
+ status: "todo",
1025
+ assignee: task.assignee || null,
1026
+ priority: task.priority || null,
1027
+ tags: Array.isArray(task.tags) ? task.tags : [],
1028
+ labels: Array.isArray(task.tags) ? task.tags : [],
1029
+ ...(baseBranchCandidate ? { baseBranch: baseBranchCandidate } : {}),
1030
+ };
1031
+
1032
+ if (
1033
+ this.#kanbanAdapter &&
1034
+ typeof this.#kanbanAdapter.createTask === "function"
1035
+ ) {
1036
+ return await this.#kanbanAdapter.createTask(this.#projectId, payload);
1037
+ }
1038
+
1039
+ return await createExternalTask(this.#projectId, payload);
1040
+ }
1041
+
976
1042
  async #updateExternalPatch(taskId, patch) {
977
1043
  if (
978
1044
  this.#kanbanAdapter &&
@@ -1022,6 +1088,25 @@ export class SyncEngine {
1022
1088
  );
1023
1089
  }
1024
1090
 
1091
+ #shouldCreateExternalTask(task, backend) {
1092
+ if (!task || backend === "internal") return false;
1093
+ if (task.externalId) return false;
1094
+ const plannerMeta = task.meta?.planner;
1095
+ if (!plannerMeta || typeof plannerMeta !== "object") return false;
1096
+ return plannerMeta.externalSyncPending === true;
1097
+ }
1098
+
1099
+ #extractExternalTaskId(created) {
1100
+ if (!created) return null;
1101
+ if (created.id != null && String(created.id).trim()) {
1102
+ return String(created.id).trim();
1103
+ }
1104
+ if (created.externalId != null && String(created.externalId).trim()) {
1105
+ return String(created.externalId).trim();
1106
+ }
1107
+ return null;
1108
+ }
1109
+
1025
1110
  /** Simple async sleep. */
1026
1111
  #sleep(ms) {
1027
1112
  return new Promise((resolve) => setTimeout(resolve, ms));
package/task-executor.mjs CHANGED
@@ -68,7 +68,7 @@ import {
68
68
  collectDiffStats,
69
69
  } from "./diff-stats.mjs";
70
70
  import { createAnomalyDetector } from "./anomaly-detector.mjs";
71
- import { normalizeDedupKey, yieldToEventLoop } from "./utils.mjs";
71
+ import { normalizeDedupKey, yieldToEventLoop, withRetry } from "./utils.mjs";
72
72
  import {
73
73
  resolveExecutorForTask,
74
74
  executorToSdk,
@@ -3169,10 +3169,20 @@ class TaskExecutor {
3169
3169
  return;
3170
3170
  }
3171
3171
 
3172
- // Fetch todo tasks
3172
+ // Fetch todo tasks (with transient-error retry)
3173
3173
  let tasks;
3174
3174
  try {
3175
- tasks = await listTasks(projectId, { status: "todo" });
3175
+ tasks = await withRetry(
3176
+ () => listTasks(projectId, { status: "todo" }),
3177
+ {
3178
+ maxAttempts: 3,
3179
+ baseMs: 2000,
3180
+ retryIf: (err) => {
3181
+ const cat = categorizeError(err);
3182
+ return cat === "transient" || cat === "network";
3183
+ },
3184
+ },
3185
+ );
3176
3186
  this._resetListTasksBackoff();
3177
3187
  } catch (err) {
3178
3188
  this._noteListTasksFailure(err);
@@ -3252,14 +3262,19 @@ class TaskExecutor {
3252
3262
  for (const task of toDispatch) {
3253
3263
  // Normalize task id
3254
3264
  task.id = task.id || task.task_id;
3255
- // Fire and forget — executeTask handles its own lifecycle
3256
- this.executeTask(task).catch((err) => {
3257
- console.error(
3258
- `${TAG} unhandled error in executeTask for "${task.title}": ${err.message}`,
3259
- );
3260
- });
3261
- // Yield between slot dispatches so WebSocket/HTTP work can proceed
3262
- await yieldToEventLoop();
3265
+ try {
3266
+ // Fire and forget — executeTask handles its own lifecycle
3267
+ this.executeTask(task).catch((err) => {
3268
+ console.error(
3269
+ `${TAG} unhandled error in executeTask for "${task.title}": ${err.message}`,
3270
+ );
3271
+ });
3272
+ } catch (err) {
3273
+ console.warn(`${TAG} slot ${task.id} dispatch error: ${err.message}`);
3274
+ } finally {
3275
+ // ALWAYS yield, even on error, to prevent event loop starvation
3276
+ await yieldToEventLoop();
3277
+ }
3263
3278
  }
3264
3279
  } catch (err) {
3265
3280
  console.error(`${TAG} poll loop error: ${err.message}`);
package/telegram-bot.mjs CHANGED
@@ -789,6 +789,20 @@ function getBrowserUiUrl() {
789
789
  const base = telegramUiUrl;
790
790
  if (!base) return null;
791
791
  const token = getSessionToken();
792
+
793
+ // Prefer LAN for browser opens when available (same-network path),
794
+ // and fall back to the configured/public URL.
795
+ try {
796
+ const parsed = new URL(base);
797
+ const lanIp = getLocalLanIp?.();
798
+ if (lanIp && parsed.port) {
799
+ const lanBase = `${parsed.protocol}//${lanIp}:${parsed.port}`;
800
+ return appendTokenToUrl(lanBase, token) || lanBase;
801
+ }
802
+ } catch {
803
+ // fall through to base URL
804
+ }
805
+
792
806
  if (!token) return base;
793
807
  return appendTokenToUrl(base, token) || base;
794
808
  }
@@ -3042,6 +3056,7 @@ async function refreshMenuButton() {
3042
3056
 
3043
3057
  const FAST_COMMANDS = new Set([
3044
3058
  "/menu",
3059
+ "/background",
3045
3060
  "/status",
3046
3061
  "/tasks",
3047
3062
  "/agents",
@@ -3063,39 +3078,36 @@ const FAST_COMMANDS = new Set([
3063
3078
  ]);
3064
3079
 
3065
3080
  function getTelegramWebAppUrl(url) {
3066
- // Prefer cloudflared tunnel URL (valid cert, works in Telegram Mini App)
3081
+ // Telegram Mini App must be HTTPS and publicly reachable.
3082
+ // Priority: explicit env URL -> tunnel URL -> provided URL.
3083
+ const explicit =
3084
+ process.env.TELEGRAM_WEBAPP_URL || process.env.TELEGRAM_UI_BASE_URL || "";
3067
3085
  const tUrl = getTunnelUrl();
3068
- if (tUrl) {
3069
- const normalizedTunnel = String(tUrl || "").trim().replace(/\/+$/, "");
3070
- return appendTokenToUrl(normalizedTunnel, getSessionToken()) || normalizedTunnel;
3071
- }
3086
+ const candidates = [explicit, tUrl, url];
3072
3087
 
3073
- const normalized = String(url || "")
3074
- .trim()
3075
- .replace(/\/+$/, "");
3076
- if (!normalized) return null;
3077
- try {
3078
- const parsed = new URL(normalized);
3079
- if (parsed.protocol !== "https:") {
3080
- if (telegramWebAppUrlWarned !== normalized) {
3081
- telegramWebAppUrlWarned = normalized;
3082
- console.warn(
3083
- `[telegram-bot] mini app URL must be HTTPS for Telegram WebApp buttons; got "${normalized}". Falling back to normal URL buttons only.`,
3084
- );
3085
- }
3086
- return null;
3087
- }
3088
- const normalizedUrl = parsed.toString().replace(/\/+$/, "");
3089
- return appendTokenToUrl(normalizedUrl, getSessionToken()) || normalizedUrl;
3090
- } catch {
3091
- if (telegramWebAppUrlWarned !== normalized) {
3092
- telegramWebAppUrlWarned = normalized;
3093
- console.warn(
3094
- `[telegram-bot] mini app URL is invalid: "${normalized}". Falling back to normal URL buttons only.`,
3095
- );
3088
+ for (const candidate of candidates) {
3089
+ const normalized = String(candidate || "")
3090
+ .trim()
3091
+ .replace(/\/+$/, "");
3092
+ if (!normalized) continue;
3093
+ try {
3094
+ const parsed = new URL(normalized);
3095
+ if (parsed.protocol !== "https:") continue;
3096
+ const normalizedUrl = parsed.toString().replace(/\/+$/, "");
3097
+ return appendTokenToUrl(normalizedUrl, getSessionToken()) || normalizedUrl;
3098
+ } catch {
3099
+ // try next candidate
3096
3100
  }
3097
- return null;
3098
3101
  }
3102
+
3103
+ const warnSource = String(explicit || tUrl || url || "").trim();
3104
+ if (warnSource && telegramWebAppUrlWarned !== warnSource) {
3105
+ telegramWebAppUrlWarned = warnSource;
3106
+ console.warn(
3107
+ `[telegram-bot] mini app URL requires a valid HTTPS URL. Got "${warnSource}". Falling back to browser URL buttons only.`,
3108
+ );
3109
+ }
3110
+ return null;
3099
3111
  }
3100
3112
 
3101
3113
  function appendTokenToUrl(inputUrl, token) {
@@ -4142,6 +4154,11 @@ Object.assign(UI_SCREENS, {
4142
4154
  },
4143
4155
  uiButton("✖", "cb:close_menu"),
4144
4156
  ]);
4157
+ if (telegramUiUrl) {
4158
+ rows.unshift([
4159
+ { text: "🌐 Open in Browser", url: getBrowserUiUrl() || telegramUiUrl },
4160
+ ]);
4161
+ }
4145
4162
  } else if (telegramUiUrl) {
4146
4163
  rows.unshift([
4147
4164
  { text: "🌐 Open Control Center", url: getBrowserUiUrl() || telegramUiUrl },