bosun 0.40.2 → 0.40.3

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
@@ -864,6 +864,46 @@ function findAllBosunProcessPids() {
864
864
  }
865
865
  }
866
866
 
867
+ function getRunningDaemonPids() {
868
+ const trackedDaemonPid = getDaemonPid();
869
+ if (trackedDaemonPid) return [trackedDaemonPid];
870
+ const ghostPids = findGhostDaemonPids();
871
+ return ghostPids.length > 0 ? ghostPids : [];
872
+ }
873
+
874
+ function stopBosunProcesses(
875
+ pids,
876
+ { reason = null, timeoutMs = 5000 } = {},
877
+ ) {
878
+ const targets = Array.from(
879
+ new Set(
880
+ (Array.isArray(pids) ? pids : []).filter(
881
+ (pid) => Number.isFinite(pid) && pid > 0 && pid !== process.pid,
882
+ ),
883
+ ),
884
+ );
885
+ if (targets.length === 0) return [];
886
+ if (reason) {
887
+ console.log(` ${reason}: ${targets.join(", ")}`);
888
+ }
889
+ for (const pid of targets) {
890
+ try {
891
+ process.kill(pid, "SIGTERM");
892
+ } catch {
893
+ /* already dead */
894
+ }
895
+ }
896
+ const alive = waitForPidsToExit(targets, timeoutMs);
897
+ for (const pid of alive) {
898
+ try {
899
+ process.kill(pid, "SIGKILL");
900
+ } catch {
901
+ /* already dead */
902
+ }
903
+ }
904
+ return targets;
905
+ }
906
+
867
907
  function removeKnownPidFiles(extraCacheDirs = []) {
868
908
  const pidFiles = uniqueResolvedPaths([
869
909
  ...getPidFileCandidates("bosun-daemon.pid", extraCacheDirs),
@@ -1506,78 +1546,57 @@ async function main() {
1506
1546
 
1507
1547
  // Handle --update (force update)
1508
1548
  if (args.includes("--update")) {
1509
- // Pre-stop any sibling Bosun processes to avoid Windows EBUSY locks during
1510
- // global npm install (especially portal/desktop/non-daemon instances).
1511
- const siblingPids = findAllBosunProcessPids().filter((pid) => pid !== process.pid);
1512
- if (siblingPids.length > 0) {
1549
+ const runningDaemonPids = getRunningDaemonPids();
1550
+ const shouldRestartDaemon = runningDaemonPids.length > 0;
1551
+ const runningSiblingPids = findAllBosunProcessPids().filter(
1552
+ (pid) => pid !== process.pid,
1553
+ );
1554
+ const runningNonDaemonPids = runningSiblingPids.filter(
1555
+ (pid) => !runningDaemonPids.includes(pid),
1556
+ );
1557
+ if (shouldRestartDaemon) {
1513
1558
  console.log(
1514
- ` Stopping ${siblingPids.length} running bosun process(es) before update: ${siblingPids.join(", ")}`,
1559
+ " Note: a successful update will restart the bosun daemon automatically.",
1560
+ );
1561
+ }
1562
+ if (runningNonDaemonPids.length > 0) {
1563
+ console.log(
1564
+ ` Note: ${runningNonDaemonPids.length} other running bosun process(es) will be stopped after a successful update.`,
1515
1565
  );
1516
- for (const pid of siblingPids) {
1517
- try {
1518
- process.kill(pid, "SIGTERM");
1519
- } catch {
1520
- /* already dead */
1521
- }
1522
- }
1523
- const deadline = Date.now() + 5000;
1524
- let alive = siblingPids.filter((pid) => isProcessAlive(pid));
1525
- while (alive.length > 0 && Date.now() < deadline) {
1526
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 200);
1527
- alive = alive.filter((pid) => isProcessAlive(pid));
1528
- }
1529
- for (const pid of alive) {
1530
- try {
1531
- process.kill(pid, "SIGKILL");
1532
- } catch {
1533
- /* already dead */
1534
- }
1535
- }
1536
1566
  }
1537
1567
 
1538
1568
  const { forceUpdate } = await import("./infra/update-check.mjs");
1539
1569
  const updated = await forceUpdate(VERSION);
1540
- if (updated) {
1541
- // If a daemon or other bosun process is running, restart it so it picks
1542
- // up the new version automatically — no manual restart needed.
1543
- const daemonPid = getDaemonPid();
1544
- const ghostPids = findGhostDaemonPids();
1545
- const daemonPids = daemonPid
1546
- ? [daemonPid]
1547
- : ghostPids.length > 0
1548
- ? ghostPids
1549
- : [];
1570
+ if (!updated) {
1571
+ process.exit(0);
1572
+ }
1573
+
1574
+ // Only stop sibling processes once npm has actually installed the update.
1575
+ const daemonPids = getRunningDaemonPids();
1576
+ const siblingPids = Array.from(
1577
+ new Set([
1578
+ ...findAllBosunProcessPids().filter((pid) => pid !== process.pid),
1579
+ ...daemonPids,
1580
+ ]),
1581
+ );
1582
+ stopBosunProcesses(siblingPids, {
1583
+ reason: `Stopping ${siblingPids.length} running bosun process(es) to finish update`,
1584
+ timeoutMs: 5000,
1585
+ });
1586
+
1587
+ if (shouldRestartDaemon) {
1550
1588
  if (daemonPids.length > 0) {
1551
- console.log(
1552
- ` Daemon running (PID ${daemonPids.join(", ")}). Stopping for restart...`,
1553
- );
1554
- for (const p of daemonPids) {
1555
- try {
1556
- process.kill(p, "SIGTERM");
1557
- } catch {
1558
- /* already dead */
1559
- }
1560
- }
1561
- // Wait up to 4 s for graceful exit
1562
- const deadline = Date.now() + 4000;
1563
- let alive = daemonPids.filter((p) => isProcessAlive(p));
1564
- while (alive.length > 0 && Date.now() < deadline) {
1565
- Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 200);
1566
- alive = alive.filter((p) => isProcessAlive(p));
1567
- }
1568
- for (const p of alive) {
1569
- try {
1570
- process.kill(p, "SIGKILL");
1571
- } catch {
1572
- /* ok */
1573
- }
1574
- }
1575
1589
  removePidFile();
1576
- console.log(" Restarting daemon with updated version...");
1577
- startDaemon(); // spawns new daemon-child and calls process.exit(0)
1578
- return; // unreachable — startDaemon exits
1579
1590
  }
1591
+ console.log(" Restarting daemon with updated version...");
1592
+ startDaemon(); // spawns new daemon-child and calls process.exit(0)
1593
+ return; // unreachable — startDaemon exits
1594
+ }
1595
+
1596
+ if (siblingPids.length > 0) {
1597
+ console.log(" Restart stopped bosun sessions manually if you still need them.");
1580
1598
  }
1599
+ console.log(" Restart bosun to use the new version.\n");
1581
1600
  process.exit(0);
1582
1601
  }
1583
1602
 
@@ -1610,7 +1629,7 @@ async function main() {
1610
1629
  if (args.includes("--workspace-list") || args.includes("workspace-list")) {
1611
1630
  const { listWorkspaces, getActiveWorkspace } = await import("./workspace/workspace-manager.mjs");
1612
1631
  const configDirArg = getArgValue("--config-dir");
1613
- const configDir = configDirArg || process.env.BOSUN_DIR || resolve(os.homedir(), "bosun");
1632
+ const configDir = configDirArg || process.env.BOSUN_DIR || resolveConfigDirForCli();
1614
1633
  const workspaces = listWorkspaces(configDir);
1615
1634
  const active = getActiveWorkspace(configDir);
1616
1635
  if (workspaces.length === 0) {
@@ -1634,7 +1653,7 @@ async function main() {
1634
1653
  if (args.includes("--workspace-add")) {
1635
1654
  const { createWorkspace } = await import("./workspace/workspace-manager.mjs");
1636
1655
  const configDirArg = getArgValue("--config-dir");
1637
- const configDir = configDirArg || process.env.BOSUN_DIR || resolve(os.homedir(), "bosun");
1656
+ const configDir = configDirArg || process.env.BOSUN_DIR || resolveConfigDirForCli();
1638
1657
  const name = getArgValue("--workspace-add");
1639
1658
  if (!name) {
1640
1659
  console.error(" Error: workspace name is required. Usage: bosun --workspace-add <name>");
@@ -1653,7 +1672,7 @@ async function main() {
1653
1672
  if (args.includes("--workspace-switch")) {
1654
1673
  const { setActiveWorkspace, getWorkspace } = await import("./workspace/workspace-manager.mjs");
1655
1674
  const configDirArg = getArgValue("--config-dir");
1656
- const configDir = configDirArg || process.env.BOSUN_DIR || resolve(os.homedir(), "bosun");
1675
+ const configDir = configDirArg || process.env.BOSUN_DIR || resolveConfigDirForCli();
1657
1676
  const wsId = getArgValue("--workspace-switch");
1658
1677
  if (!wsId) {
1659
1678
  console.error(" Error: workspace ID required. Usage: bosun --workspace-switch <id>");
@@ -1673,7 +1692,7 @@ async function main() {
1673
1692
  if (args.includes("--workspace-add-repo")) {
1674
1693
  const { addRepoToWorkspace, getActiveWorkspace, listWorkspaces } = await import("./workspace/workspace-manager.mjs");
1675
1694
  const configDirArg = getArgValue("--config-dir");
1676
- const configDir = configDirArg || process.env.BOSUN_DIR || resolve(os.homedir(), "bosun");
1695
+ const configDir = configDirArg || process.env.BOSUN_DIR || resolveConfigDirForCli();
1677
1696
  const active = getActiveWorkspace(configDir);
1678
1697
  if (!active) {
1679
1698
  console.error(" No active workspace. Create one first: bosun --workspace-add <name>");
@@ -1702,7 +1721,7 @@ async function main() {
1702
1721
  const { runWorkspaceHealthCheck, formatWorkspaceHealthReport } =
1703
1722
  await import("./config/config-doctor.mjs");
1704
1723
  const configDirArg = getArgValue("--config-dir");
1705
- const configDir = configDirArg || process.env.BOSUN_DIR || resolve(os.homedir(), "bosun");
1724
+ const configDir = configDirArg || process.env.BOSUN_DIR || resolveConfigDirForCli();
1706
1725
  const result = runWorkspaceHealthCheck({ configDir });
1707
1726
  console.log(formatWorkspaceHealthReport(result));
1708
1727
  process.exit(result.ok ? 0 : 1);
package/infra/monitor.mjs CHANGED
@@ -201,6 +201,7 @@ import { fixGitConfigCorruption } from "../workspace/worktree-manager.mjs";
201
201
  // ── Task management subsystem imports ──────────────────────────────────────
202
202
  import {
203
203
  configureTaskStore,
204
+ canStartTask,
204
205
  getTask as getInternalTask,
205
206
  getTasksByStatus as getInternalTasksByStatus,
206
207
  updateTask as updateInternalTask,
@@ -548,6 +549,9 @@ async function ensureWorkflowAutomationEngine() {
548
549
  const services = {
549
550
  telegram: telegramService,
550
551
  kanban: kanbanService,
552
+ taskStore: {
553
+ canStartTask,
554
+ },
551
555
  agentPool: agentPoolService,
552
556
  meeting: meetingService,
553
557
  prompts: Object.keys(promptServices).length > 0 ? promptServices : null,
@@ -13509,9 +13513,10 @@ safeSetInterval("flush-error-queue", () => flushErrorQueue(), 60 * 1000);
13509
13513
  // Legacy periodic maintenance sweep removed (workflow-only control).
13510
13514
 
13511
13515
  // ── Workflow schedule trigger polling ───────────────────────────────────────
13512
- // Check all installed workflows that use trigger.schedule and fire any whose
13513
- // interval has elapsed. This makes schedule-based templates (workspace hygiene,
13514
- // nightly reports, etc.) actually execute without hardcoded safeSetInterval calls.
13516
+ // Check all installed polling workflows (trigger.schedule, trigger.scheduled_once,
13517
+ // trigger.task_available, trigger.task_low) and fire any whose interval has elapsed.
13518
+ // This keeps scheduled and task-poll lifecycle templates executing without hardcoded
13519
+ // per-workflow timers.
13515
13520
  const scheduleCheckIntervalMs = 60 * 1000; // check every 60s
13516
13521
  safeSetInterval("workflow-schedule-check", async () => {
13517
13522
  try {
@@ -463,9 +463,7 @@ export async function forceUpdate(currentVersion) {
463
463
  stdio: "inherit",
464
464
  timeout: 120000,
465
465
  });
466
- console.log(
467
- `\n :check: Updated to v${latest}. Restart bosun to use the new version.\n`,
468
- );
466
+ console.log(`\n :check: Updated to v${latest}.\n`);
469
467
  return true; // update was installed
470
468
  } catch (err) {
471
469
  console.error(`\n :close: Update failed: ${err.message}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "bosun",
3
- "version": "0.40.2",
3
+ "version": "0.40.3",
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",
@@ -188,6 +188,12 @@ const TASK_STORE_DEPENDENCY_EXPORTS = {
188
188
  update: ["updateTask"],
189
189
  };
190
190
  const TASK_STORE_ASSIGN_SPRINT_EXPORTS = ["assignTaskToSprint", "setTaskSprint"];
191
+ const TASK_STORE_EPIC_DEPENDENCY_EXPORTS = Object.freeze({
192
+ list: ["getEpicDependencies", "listEpicDependencies"],
193
+ set: ["setEpicDependencies", "updateEpicDependencies"],
194
+ add: ["addEpicDependency"],
195
+ remove: ["removeEpicDependency"],
196
+ });
191
197
  let taskStoreApi = null;
192
198
  let taskStoreApiPromise = null;
193
199
  let didLogTaskStoreLoadFailure = false;
@@ -6154,6 +6160,68 @@ async function setTaskDependenciesForApi({
6154
6160
  };
6155
6161
  }
6156
6162
 
6163
+ async function listEpicDependenciesForApi() {
6164
+ const listed = await callTaskStoreFunction(TASK_STORE_EPIC_DEPENDENCY_EXPORTS.list, []);
6165
+ if (listed.found && Array.isArray(listed.value)) {
6166
+ return {
6167
+ ok: true,
6168
+ source: `task-store.${listed.found}`,
6169
+ data: listed.value
6170
+ .map((entry) => ({
6171
+ epicId: String(entry?.epicId || entry?.id || "").trim(),
6172
+ dependencies: normalizeTaskIdList(entry?.dependencies || entry?.dependsOn || []),
6173
+ }))
6174
+ .filter((entry) => entry.epicId),
6175
+ };
6176
+ }
6177
+ return { ok: false, source: null, data: [] };
6178
+ }
6179
+
6180
+ async function setEpicDependenciesForApi({ epicId, dependencies }) {
6181
+ const normalizedEpicId = String(epicId || "").trim();
6182
+ if (!normalizedEpicId) return { ok: false, status: 400, error: "epicId required" };
6183
+ const normalizedDependencies = normalizeTaskIdList(dependencies, { exclude: normalizedEpicId });
6184
+
6185
+ const setResult = await callTaskStoreFunction(
6186
+ TASK_STORE_EPIC_DEPENDENCY_EXPORTS.set,
6187
+ [normalizedEpicId, normalizedDependencies],
6188
+ );
6189
+ if (setResult.found) {
6190
+ return {
6191
+ ok: true,
6192
+ source: `task-store.${setResult.found}`,
6193
+ data: {
6194
+ epicId: normalizedEpicId,
6195
+ dependencies: normalizeTaskIdList(setResult.value?.dependencies || normalizedDependencies),
6196
+ },
6197
+ };
6198
+ }
6199
+
6200
+ const listed = await listEpicDependenciesForApi();
6201
+ const current = listed.ok
6202
+ ? normalizeTaskIdList((listed.data.find((entry) => entry.epicId === normalizedEpicId) || {}).dependencies || [])
6203
+ : [];
6204
+
6205
+ const toRemove = current.filter((entry) => !normalizedDependencies.includes(entry));
6206
+ const toAdd = normalizedDependencies.filter((entry) => !current.includes(entry));
6207
+
6208
+ for (const dep of toRemove) {
6209
+ await callTaskStoreFunction(TASK_STORE_EPIC_DEPENDENCY_EXPORTS.remove, [normalizedEpicId, dep]);
6210
+ }
6211
+ for (const dep of toAdd) {
6212
+ await callTaskStoreFunction(TASK_STORE_EPIC_DEPENDENCY_EXPORTS.add, [normalizedEpicId, dep]);
6213
+ }
6214
+
6215
+ const refreshed = await listEpicDependenciesForApi();
6216
+ const row = refreshed.ok
6217
+ ? refreshed.data.find((entry) => entry.epicId === normalizedEpicId)
6218
+ : null;
6219
+ return {
6220
+ ok: true,
6221
+ source: refreshed.source || "task-store.fallback",
6222
+ data: { epicId: normalizedEpicId, dependencies: normalizeTaskIdList(row?.dependencies || []) },
6223
+ };
6224
+ }
6157
6225
  async function getTaskCommentsForApi(taskId, adapter = null) {
6158
6226
  const storeComments = await callTaskStoreFunction(TASK_STORE_COMMENT_EXPORTS, [taskId]);
6159
6227
  if (storeComments.found && Array.isArray(storeComments.value)) {
@@ -9053,6 +9121,55 @@ async function handleApi(req, res, url) {
9053
9121
  }
9054
9122
  return;
9055
9123
  }
9124
+ if (path === "/api/tasks/epic-dependencies" && req.method === "GET") {
9125
+ try {
9126
+ const listed = await listEpicDependenciesForApi();
9127
+ if (!listed.ok) {
9128
+ jsonResponse(res, 501, { ok: false, error: "Epic dependency APIs are unavailable." });
9129
+ return;
9130
+ }
9131
+ jsonResponse(res, 200, { ok: true, source: listed.source, data: listed.data });
9132
+ } catch (err) {
9133
+ jsonResponse(res, 500, { ok: false, error: err.message });
9134
+ }
9135
+ return;
9136
+ }
9137
+
9138
+ if (path === "/api/tasks/epic-dependencies" && req.method === "PUT") {
9139
+ try {
9140
+ const body = await readJsonBody(req);
9141
+ const epicId = String(body?.epicId || body?.id || "").trim();
9142
+ const dependencies = Array.isArray(body?.dependencies)
9143
+ ? body.dependencies
9144
+ : Array.isArray(body?.dependsOn)
9145
+ ? body.dependsOn
9146
+ : [];
9147
+ if (!epicId) {
9148
+ jsonResponse(res, 400, { ok: false, error: "epicId required" });
9149
+ return;
9150
+ }
9151
+ const updated = await setEpicDependenciesForApi({ epicId, dependencies });
9152
+ if (!updated.ok) {
9153
+ jsonResponse(res, updated.status || 400, { ok: false, error: updated.error || "Failed to update epic dependencies" });
9154
+ return;
9155
+ }
9156
+ const globalDag = await getGlobalDagData();
9157
+ jsonResponse(res, 200, {
9158
+ ok: true,
9159
+ source: updated.source,
9160
+ data: updated.data,
9161
+ dag: globalDag?.data || null,
9162
+ });
9163
+ broadcastUiEvent(["tasks", "overview"], "invalidate", {
9164
+ reason: "epic-dependencies-updated",
9165
+ epicId,
9166
+ });
9167
+ } catch (err) {
9168
+ jsonResponse(res, 500, { ok: false, error: err.message });
9169
+ }
9170
+ return;
9171
+ }
9172
+
9056
9173
  if (path === "/api/tasks/start") {
9057
9174
  try {
9058
9175
  const body = await readJsonBody(req);