bosun 0.40.2 → 0.40.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.
package/README.md CHANGED
@@ -181,6 +181,10 @@ npm run hooks:install
181
181
  - `docs/` and `_docs/` — product docs, deep technical references, and long-form source material
182
182
  - `tools/` and `tests/` — build utilities, release checks, and regression coverage
183
183
 
184
+ If you find this project useful or would like to stay up to date with new releases, a star is appreciated!
185
+
186
+ [![Star History Chart](https://api.star-history.com/image?repos=VirtEngine/Bosun&type=date&legend=top-left)](https://www.star-history.com/?repos=VirtEngine%2FBosun&type=date&legend=top-left)
187
+
184
188
  ---
185
189
 
186
190
  ## License
package/cli.mjs CHANGED
@@ -16,7 +16,7 @@
16
16
  * 3. Configuration loading from config.mjs
17
17
  */
18
18
 
19
- import { resolve, dirname } from "node:path";
19
+ import { isAbsolute, resolve, dirname } from "node:path";
20
20
  import {
21
21
  existsSync,
22
22
  readFileSync,
@@ -237,6 +237,9 @@ function isWslInteropRuntime() {
237
237
  }
238
238
 
239
239
  function resolveConfigDirForCli() {
240
+ const configDirArg = getArgValue("--config-dir");
241
+ if (configDirArg) return resolve(configDirArg);
242
+ if (process.env.BOSUN_HOME) return resolve(process.env.BOSUN_HOME);
240
243
  if (process.env.BOSUN_DIR) return resolve(process.env.BOSUN_DIR);
241
244
  const preferWindowsDirs =
242
245
  process.platform === "win32" && !isWslInteropRuntime();
@@ -612,6 +615,40 @@ function removePidFile() {
612
615
  }
613
616
  }
614
617
 
618
+ function absolutizeDaemonArgPath(value) {
619
+ const raw = String(value || "").trim();
620
+ if (!raw) return raw;
621
+ return isAbsolute(raw) ? raw : resolve(process.cwd(), raw);
622
+ }
623
+
624
+ function normalizeDetachedDaemonArgs(rawArgs = []) {
625
+ const normalized = Array.isArray(rawArgs) ? [...rawArgs] : [];
626
+ const pathFlags = new Set(["--config-dir", "--repo-root", "--log-dir"]);
627
+ for (let i = 0; i < normalized.length; i += 1) {
628
+ const arg = String(normalized[i] || "").trim();
629
+ if (!arg.startsWith("--")) continue;
630
+
631
+ const eq = arg.indexOf("=");
632
+ if (eq > 0) {
633
+ const flag = arg.slice(0, eq);
634
+ const value = arg.slice(eq + 1);
635
+ if (pathFlags.has(flag)) {
636
+ normalized[i] = flag + "=" + absolutizeDaemonArgPath(value);
637
+ }
638
+ continue;
639
+ }
640
+
641
+ if (pathFlags.has(arg) && i + 1 < normalized.length) {
642
+ const value = String(normalized[i + 1] || "").trim();
643
+ if (value && !value.startsWith("--")) {
644
+ normalized[i + 1] = absolutizeDaemonArgPath(value);
645
+ i += 1;
646
+ }
647
+ }
648
+ }
649
+ return normalized;
650
+ }
651
+
615
652
  function startDaemon() {
616
653
  const existing = getDaemonPid();
617
654
  if (existing) {
@@ -656,7 +693,9 @@ function startDaemon() {
656
693
  ...runAsNode,
657
694
  "--max-old-space-size=4096",
658
695
  fileURLToPath(new URL("./cli.mjs", import.meta.url)),
659
- ...process.argv.slice(2).filter((a) => a !== "--daemon" && a !== "-d"),
696
+ ...normalizeDetachedDaemonArgs(
697
+ process.argv.slice(2).filter((a) => a !== "--daemon" && a !== "-d"),
698
+ ),
660
699
  "--daemon-child",
661
700
  ],
662
701
  {
@@ -864,6 +903,46 @@ function findAllBosunProcessPids() {
864
903
  }
865
904
  }
866
905
 
906
+ function getRunningDaemonPids() {
907
+ const trackedDaemonPid = getDaemonPid();
908
+ if (trackedDaemonPid) return [trackedDaemonPid];
909
+ const ghostPids = findGhostDaemonPids();
910
+ return ghostPids.length > 0 ? ghostPids : [];
911
+ }
912
+
913
+ function stopBosunProcesses(
914
+ pids,
915
+ { reason = null, timeoutMs = 5000 } = {},
916
+ ) {
917
+ const targets = Array.from(
918
+ new Set(
919
+ (Array.isArray(pids) ? pids : []).filter(
920
+ (pid) => Number.isFinite(pid) && pid > 0 && pid !== process.pid,
921
+ ),
922
+ ),
923
+ );
924
+ if (targets.length === 0) return [];
925
+ if (reason) {
926
+ console.log(` ${reason}: ${targets.join(", ")}`);
927
+ }
928
+ for (const pid of targets) {
929
+ try {
930
+ process.kill(pid, "SIGTERM");
931
+ } catch {
932
+ /* already dead */
933
+ }
934
+ }
935
+ const alive = waitForPidsToExit(targets, timeoutMs);
936
+ for (const pid of alive) {
937
+ try {
938
+ process.kill(pid, "SIGKILL");
939
+ } catch {
940
+ /* already dead */
941
+ }
942
+ }
943
+ return targets;
944
+ }
945
+
867
946
  function removeKnownPidFiles(extraCacheDirs = []) {
868
947
  const pidFiles = uniqueResolvedPaths([
869
948
  ...getPidFileCandidates("bosun-daemon.pid", extraCacheDirs),
@@ -1506,78 +1585,57 @@ async function main() {
1506
1585
 
1507
1586
  // Handle --update (force update)
1508
1587
  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) {
1588
+ const runningDaemonPids = getRunningDaemonPids();
1589
+ const shouldRestartDaemon = runningDaemonPids.length > 0;
1590
+ const runningSiblingPids = findAllBosunProcessPids().filter(
1591
+ (pid) => pid !== process.pid,
1592
+ );
1593
+ const runningNonDaemonPids = runningSiblingPids.filter(
1594
+ (pid) => !runningDaemonPids.includes(pid),
1595
+ );
1596
+ if (shouldRestartDaemon) {
1513
1597
  console.log(
1514
- ` Stopping ${siblingPids.length} running bosun process(es) before update: ${siblingPids.join(", ")}`,
1598
+ " Note: a successful update will restart the bosun daemon automatically.",
1599
+ );
1600
+ }
1601
+ if (runningNonDaemonPids.length > 0) {
1602
+ console.log(
1603
+ ` Note: ${runningNonDaemonPids.length} other running bosun process(es) will be stopped after a successful update.`,
1515
1604
  );
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
1605
  }
1537
1606
 
1538
1607
  const { forceUpdate } = await import("./infra/update-check.mjs");
1539
1608
  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
- : [];
1609
+ if (!updated) {
1610
+ process.exit(0);
1611
+ }
1612
+
1613
+ // Only stop sibling processes once npm has actually installed the update.
1614
+ const daemonPids = getRunningDaemonPids();
1615
+ const siblingPids = Array.from(
1616
+ new Set([
1617
+ ...findAllBosunProcessPids().filter((pid) => pid !== process.pid),
1618
+ ...daemonPids,
1619
+ ]),
1620
+ );
1621
+ stopBosunProcesses(siblingPids, {
1622
+ reason: `Stopping ${siblingPids.length} running bosun process(es) to finish update`,
1623
+ timeoutMs: 5000,
1624
+ });
1625
+
1626
+ if (shouldRestartDaemon) {
1550
1627
  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
1628
  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
1629
  }
1630
+ console.log(" Restarting daemon with updated version...");
1631
+ startDaemon(); // spawns new daemon-child and calls process.exit(0)
1632
+ return; // unreachable — startDaemon exits
1633
+ }
1634
+
1635
+ if (siblingPids.length > 0) {
1636
+ console.log(" Restart stopped bosun sessions manually if you still need them.");
1580
1637
  }
1638
+ console.log(" Restart bosun to use the new version.\n");
1581
1639
  process.exit(0);
1582
1640
  }
1583
1641
 
@@ -1610,7 +1668,7 @@ async function main() {
1610
1668
  if (args.includes("--workspace-list") || args.includes("workspace-list")) {
1611
1669
  const { listWorkspaces, getActiveWorkspace } = await import("./workspace/workspace-manager.mjs");
1612
1670
  const configDirArg = getArgValue("--config-dir");
1613
- const configDir = configDirArg || process.env.BOSUN_DIR || resolve(os.homedir(), "bosun");
1671
+ const configDir = configDirArg || process.env.BOSUN_DIR || resolveConfigDirForCli();
1614
1672
  const workspaces = listWorkspaces(configDir);
1615
1673
  const active = getActiveWorkspace(configDir);
1616
1674
  if (workspaces.length === 0) {
@@ -1634,7 +1692,7 @@ async function main() {
1634
1692
  if (args.includes("--workspace-add")) {
1635
1693
  const { createWorkspace } = await import("./workspace/workspace-manager.mjs");
1636
1694
  const configDirArg = getArgValue("--config-dir");
1637
- const configDir = configDirArg || process.env.BOSUN_DIR || resolve(os.homedir(), "bosun");
1695
+ const configDir = configDirArg || process.env.BOSUN_DIR || resolveConfigDirForCli();
1638
1696
  const name = getArgValue("--workspace-add");
1639
1697
  if (!name) {
1640
1698
  console.error(" Error: workspace name is required. Usage: bosun --workspace-add <name>");
@@ -1653,7 +1711,7 @@ async function main() {
1653
1711
  if (args.includes("--workspace-switch")) {
1654
1712
  const { setActiveWorkspace, getWorkspace } = await import("./workspace/workspace-manager.mjs");
1655
1713
  const configDirArg = getArgValue("--config-dir");
1656
- const configDir = configDirArg || process.env.BOSUN_DIR || resolve(os.homedir(), "bosun");
1714
+ const configDir = configDirArg || process.env.BOSUN_DIR || resolveConfigDirForCli();
1657
1715
  const wsId = getArgValue("--workspace-switch");
1658
1716
  if (!wsId) {
1659
1717
  console.error(" Error: workspace ID required. Usage: bosun --workspace-switch <id>");
@@ -1673,7 +1731,7 @@ async function main() {
1673
1731
  if (args.includes("--workspace-add-repo")) {
1674
1732
  const { addRepoToWorkspace, getActiveWorkspace, listWorkspaces } = await import("./workspace/workspace-manager.mjs");
1675
1733
  const configDirArg = getArgValue("--config-dir");
1676
- const configDir = configDirArg || process.env.BOSUN_DIR || resolve(os.homedir(), "bosun");
1734
+ const configDir = configDirArg || process.env.BOSUN_DIR || resolveConfigDirForCli();
1677
1735
  const active = getActiveWorkspace(configDir);
1678
1736
  if (!active) {
1679
1737
  console.error(" No active workspace. Create one first: bosun --workspace-add <name>");
@@ -1702,7 +1760,7 @@ async function main() {
1702
1760
  const { runWorkspaceHealthCheck, formatWorkspaceHealthReport } =
1703
1761
  await import("./config/config-doctor.mjs");
1704
1762
  const configDirArg = getArgValue("--config-dir");
1705
- const configDir = configDirArg || process.env.BOSUN_DIR || resolve(os.homedir(), "bosun");
1763
+ const configDir = configDirArg || process.env.BOSUN_DIR || resolveConfigDirForCli();
1706
1764
  const result = runWorkspaceHealthCheck({ configDir });
1707
1765
  console.log(formatWorkspaceHealthReport(result));
1708
1766
  process.exit(result.ok ? 0 : 1);
package/config/config.mjs CHANGED
@@ -1292,6 +1292,13 @@ export function loadConfig(argv = process.argv, options = {}) {
1292
1292
  const normalizedRepoRootOverride = repoRootOverride
1293
1293
  ? resolve(repoRootOverride)
1294
1294
  : "";
1295
+ const explicitConfigDirRaw =
1296
+ cli["config-dir"] || process.env.BOSUN_HOME || process.env.BOSUN_DIR || "";
1297
+ const hasExplicitConfigDir = String(explicitConfigDirRaw || "").trim() !== "";
1298
+ const allowRepoEnvWithExplicitConfig = isEnvEnabled(
1299
+ process.env.BOSUN_LOAD_REPO_ENV_WITH_EXPLICIT_CONFIG,
1300
+ false,
1301
+ );
1295
1302
  let detectedRepoRoot = "";
1296
1303
  const getFallbackRepoRoot = () => {
1297
1304
  if (normalizedRepoRootOverride) return normalizedRepoRootOverride;
@@ -1301,8 +1308,7 @@ export function loadConfig(argv = process.argv, options = {}) {
1301
1308
 
1302
1309
  // Determine config directory (where bosun stores its config)
1303
1310
  const configDir =
1304
- cli["config-dir"] ||
1305
- process.env.BOSUN_DIR ||
1311
+ explicitConfigDirRaw ||
1306
1312
  resolveConfigDir(normalizedRepoRootOverride);
1307
1313
 
1308
1314
  const configFile = loadConfigFile(configDir);
@@ -1345,16 +1351,20 @@ export function loadConfig(argv = process.argv, options = {}) {
1345
1351
  repositories[0] ||
1346
1352
  null;
1347
1353
 
1348
- // Resolve repoRoot with workspace-awareness:
1349
- // When workspaces configured and the workspace repo has .git, prefer it
1350
- // over REPO_ROOT (env); REPO_ROOT becomes "developer root" for config only.
1354
+ // Resolve repo root. Explicit repo-root/REPO_ROOT must win over workspace clones
1355
+ // so source-based runs can pin execution to the developer working tree.
1356
+ const explicitRepoRoot = normalizedRepoRootOverride ||
1357
+ (process.env.REPO_ROOT ? resolve(process.env.REPO_ROOT) : "");
1351
1358
  const selectedRepoPath = selectedRepository?.path || "";
1352
1359
  const selectedRepoHasGit = selectedRepoPath && existsSync(resolve(selectedRepoPath, ".git"));
1353
1360
  let repoRoot =
1354
- (selectedRepoHasGit ? selectedRepoPath : null) || getFallbackRepoRoot();
1361
+ explicitRepoRoot ||
1362
+ (selectedRepoHasGit ? selectedRepoPath : null) ||
1363
+ getFallbackRepoRoot();
1355
1364
 
1356
- // Resolve agent execution root (workspace-aware, separate from developer root)
1357
- const agentRepoRoot = resolveAgentRepoRoot();
1365
+ // Resolve agent execution root. Keep workspace-aware behavior by default,
1366
+ // but honor explicit repo-root/REPO_ROOT overrides.
1367
+ const agentRepoRoot = explicitRepoRoot || resolveAgentRepoRoot();
1358
1368
 
1359
1369
  // Load .env from config dir — Bosun's .env is the primary source of truth
1360
1370
  // for Bosun-specific configuration, so it should override any stale shell
@@ -1363,8 +1373,14 @@ export function loadConfig(argv = process.argv, options = {}) {
1363
1373
  const envOverride = reloadEnv || !isEnvEnabled(process.env.BOSUN_ENV_NO_OVERRIDE, false);
1364
1374
  loadDotEnv(configDir, { override: envOverride });
1365
1375
 
1366
- // Also load .env from repo root if different
1367
- if (resolve(repoRoot) !== resolve(configDir)) {
1376
+ const shouldLoadRepoEnv =
1377
+ resolve(repoRoot) !== resolve(configDir) &&
1378
+ (!hasExplicitConfigDir || allowRepoEnvWithExplicitConfig);
1379
+
1380
+ // Also load .env from repo root if different.
1381
+ // When config-dir/BOSUN_HOME is explicit, keep that environment isolated
1382
+ // from the repo root unless explicitly re-enabled.
1383
+ if (shouldLoadRepoEnv) {
1368
1384
  loadDotEnv(repoRoot, { override: envOverride });
1369
1385
  }
1370
1386
 
@@ -1419,14 +1435,17 @@ export function loadConfig(argv = process.argv, options = {}) {
1419
1435
  repoRoot = (selHasGit ? selPath : null) || getFallbackRepoRoot();
1420
1436
  }
1421
1437
 
1422
- if (resolve(repoRoot) !== resolve(initialRepoRoot)) {
1438
+ if (
1439
+ shouldLoadRepoEnv &&
1440
+ resolve(repoRoot) !== resolve(initialRepoRoot)
1441
+ ) {
1423
1442
  loadDotEnv(repoRoot, { override: envOverride });
1424
1443
  }
1425
1444
 
1426
- const envPaths = [
1427
- resolve(configDir, ".env"),
1428
- resolve(repoRoot, ".env"),
1429
- ].filter((p, i, arr) => arr.indexOf(p) === i);
1445
+ const envPaths = [resolve(configDir, ".env")];
1446
+ if (shouldLoadRepoEnv) {
1447
+ envPaths.push(resolve(repoRoot, ".env"));
1448
+ }
1430
1449
  const kanbanSource = resolveKanbanBackendSource({
1431
1450
  envPaths,
1432
1451
  configFilePath: configFile.path,
@@ -2396,3 +2415,4 @@ export {
2396
2415
  resolveAgentRepoRoot,
2397
2416
  };
2398
2417
  export default loadConfig;
2418
+
@@ -22,8 +22,8 @@
22
22
  "electron-updater": "^6.3.9"
23
23
  },
24
24
  "devDependencies": {
25
- "electron": "^30.0.0",
26
- "electron-builder": "^24.13.3"
25
+ "electron": "^35.7.5",
26
+ "electron-builder": "^26.8.1"
27
27
  },
28
28
  "build": {
29
29
  "appId": "com.virtengine.bosun",
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,
@@ -1194,6 +1198,8 @@ const isMonitorTestRuntime =
1194
1198
  .trim()
1195
1199
  .toLowerCase(),
1196
1200
  ) || String(process.env.NODE_ENV || "").trim().toLowerCase() === "test";
1201
+ // Shared schedule poll hook used across startup/timer sections.
1202
+ let pollWorkflowSchedulesOnce = async () => {};
1197
1203
 
1198
1204
  // ── Load unified configuration ──────────────────────────────────────────────
1199
1205
  let config;
@@ -4901,9 +4907,13 @@ function getConfiguredKanbanProjectId(backend) {
4901
4907
  );
4902
4908
  }
4903
4909
 
4910
+ function hasUnresolvedTemplateToken(value) {
4911
+ return /{{[^{}]+}}/.test(String(value || ""));
4912
+ }
4913
+
4904
4914
  function resolveTaskIdForBackend(taskId, backend) {
4905
4915
  const rawId = String(taskId || "").trim();
4906
- if (!rawId) return null;
4916
+ if (!rawId || hasUnresolvedTemplateToken(rawId)) return null;
4907
4917
  if (backend !== "github") return rawId;
4908
4918
  const directMatch = parseGitHubIssueNumber(rawId);
4909
4919
  if (directMatch) return directMatch;
@@ -13509,14 +13519,19 @@ safeSetInterval("flush-error-queue", () => flushErrorQueue(), 60 * 1000);
13509
13519
  // Legacy periodic maintenance sweep removed (workflow-only control).
13510
13520
 
13511
13521
  // ── 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.
13522
+ // Check all installed polling workflows (trigger.schedule, trigger.scheduled_once,
13523
+ // trigger.task_available, trigger.task_low) and fire any whose interval has elapsed.
13524
+ // This keeps scheduled and task-poll lifecycle templates executing without hardcoded
13525
+ // per-workflow timers.
13515
13526
  const scheduleCheckIntervalMs = 60 * 1000; // check every 60s
13516
- safeSetInterval("workflow-schedule-check", async () => {
13527
+ pollWorkflowSchedulesOnce = async function pollWorkflowSchedulesOnce(
13528
+ triggerSource = "schedule-poll",
13529
+ opts = {},
13530
+ ) {
13517
13531
  try {
13518
13532
  const engine = await ensureWorkflowAutomationEngine();
13519
13533
  if (!engine?.evaluateScheduleTriggers) return;
13534
+ const includeTaskPoll = opts?.includeTaskPoll !== false;
13520
13535
 
13521
13536
  const triggered = engine.evaluateScheduleTriggers();
13522
13537
  if (!Array.isArray(triggered) || triggered.length === 0) return;
@@ -13524,9 +13539,18 @@ safeSetInterval("workflow-schedule-check", async () => {
13524
13539
  for (const match of triggered) {
13525
13540
  const workflowId = String(match?.workflowId || "").trim();
13526
13541
  if (!workflowId) continue;
13542
+ if (!includeTaskPoll) {
13543
+ const workflow = typeof engine.get === "function" ? engine.get(workflowId) : null;
13544
+ const triggerNode = Array.isArray(workflow?.nodes)
13545
+ ? workflow.nodes.find((node) => node?.id === match?.triggeredBy)
13546
+ : null;
13547
+ if (triggerNode?.type === "trigger.task_available" || triggerNode?.type === "trigger.task_low") {
13548
+ continue;
13549
+ }
13550
+ }
13527
13551
  void engine
13528
13552
  .execute(workflowId, {
13529
- _triggerSource: "schedule-poll",
13553
+ _triggerSource: triggerSource,
13530
13554
  _triggeredBy: match?.triggeredBy || null,
13531
13555
  _lastRunAt: Date.now(),
13532
13556
  repoRoot,
@@ -13554,9 +13578,12 @@ safeSetInterval("workflow-schedule-check", async () => {
13554
13578
  );
13555
13579
  }
13556
13580
  } catch (err) {
13557
- // Schedule evaluation must not crash the monitor
13558
13581
  console.warn(`[workflows] schedule-check error: ${err?.message || err}`);
13559
13582
  }
13583
+ };
13584
+
13585
+ safeSetInterval("workflow-schedule-check", async () => {
13586
+ await pollWorkflowSchedulesOnce();
13560
13587
  }, scheduleCheckIntervalMs);
13561
13588
 
13562
13589
  // Legacy merged PR check removed (workflow-only control).
@@ -13653,12 +13680,15 @@ if (telegramWeeklyReportEnabled) {
13653
13680
  }
13654
13681
 
13655
13682
  // ── Self-updating: poll npm every 10 min, auto-install + restart ────────────
13683
+ const isDaemonChildForAutoUpdate =
13684
+ process.argv.includes("--daemon-child") || process.env.BOSUN_DAEMON === "1";
13656
13685
  startAutoUpdateLoop({
13657
13686
  onRestart: (reason) => restartSelf(reason),
13658
13687
  onNotify: (msg) =>
13659
13688
  // Priority 1 (critical) bypasses the live digest so the user gets a
13660
13689
  // direct push notification for update-detected and restarting events.
13661
13690
  sendTelegramMessage(msg, { priority: 1, skipDedup: true }).catch(() => {}),
13691
+ trackParent: !isDaemonChildForAutoUpdate,
13662
13692
  });
13663
13693
 
13664
13694
  startWatcher();
@@ -13822,13 +13852,16 @@ let errorDetector = null;
13822
13852
  let agentSupervisor = null;
13823
13853
 
13824
13854
  if (!isMonitorTestRuntime) {
13825
- if (workflowAutomationEnabled) {
13826
- await ensureWorkflowAutomationEngine().catch(() => {});
13827
- } else {
13828
- console.log(
13829
- "[workflows] automation disabled (set WORKFLOW_AUTOMATION_ENABLED=true to enable event-driven workflow triggers)",
13830
- );
13831
- }
13855
+ if (workflowAutomationEnabled) {
13856
+ await ensureWorkflowAutomationEngine().catch(() => {});
13857
+ void pollWorkflowSchedulesOnce("startup", { includeTaskPoll: false }).catch((err) => {
13858
+ console.warn(`[workflows] startup poll error: ${err?.message || err}`);
13859
+ });
13860
+ } else {
13861
+ console.log(
13862
+ "[workflows] automation disabled (set WORKFLOW_AUTOMATION_ENABLED=true to enable event-driven workflow triggers)",
13863
+ );
13864
+ }
13832
13865
  // ── Task Management Subsystem Initialization ────────────────────────────────
13833
13866
  try {
13834
13867
  mkdirSync(monitorStateCacheDir, { recursive: true });
@@ -14065,6 +14098,11 @@ if (isExecutorDisabled()) {
14065
14098
  };
14066
14099
  internalTaskExecutor = getTaskExecutor(execOpts);
14067
14100
  internalTaskExecutor.start();
14101
+ if (workflowOwnsTaskExecutorLifecycle) {
14102
+ void pollWorkflowSchedulesOnce("startup").catch((err) => {
14103
+ console.warn(`[workflows] startup poll error: ${err?.message || err}`);
14104
+ });
14105
+ }
14068
14106
 
14069
14107
  // Write executor slots to status file every 30s for Telegram /tasks
14070
14108
  startStatusFileWriter(30000);
@@ -14629,5 +14667,3 @@ export {
14629
14667
  // Workflow event bridge — for fleet/kanban modules to emit events
14630
14668
  queueWorkflowEvent,
14631
14669
  };
14632
-
14633
-
@@ -551,6 +551,7 @@ export class SessionTracker {
551
551
  status: s.status,
552
552
  workspaceId: String(s?.metadata?.workspaceId || "").trim() || null,
553
553
  workspaceDir: String(s?.metadata?.workspaceDir || "").trim() || null,
554
+ branch: String(s?.metadata?.branch || "").trim() || null,
554
555
  turnCount: s.turnCount || 0,
555
556
  createdAt: s.createdAt || new Date(s.startedAt).toISOString(),
556
557
  lastActiveAt: s.lastActiveAt || new Date(s.lastActivityAt).toISOString(),
@@ -1018,8 +1018,13 @@ export class SyncEngine {
1018
1018
  }
1019
1019
 
1020
1020
  async #createExternalTask(task, baseBranchCandidate = null) {
1021
+ const normalizedTitle = String(task?.title || "").trim();
1022
+ if (!normalizedTitle) {
1023
+ throw new Error(`cannot create external task for ${task?.id || "<unknown>"}: missing title`);
1024
+ }
1025
+
1021
1026
  const payload = {
1022
- title: task.title || "Untitled task",
1027
+ title: normalizedTitle,
1023
1028
  description: task.description || "",
1024
1029
  status: "todo",
1025
1030
  assignee: task.assignee || null,
@@ -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}`);
@@ -556,6 +554,7 @@ function isSuppressedStreamNoiseError(err) {
556
554
  * @param {function} [opts.onNotify] - Called with message string for Telegram/log
557
555
  * @param {number} [opts.intervalMs] - Poll interval (default: 10 min)
558
556
  * @param {number} [opts.parentPid] - Parent process PID to monitor (default: process.ppid)
557
+ * @param {boolean} [opts.trackParent] - Monitor parent liveness (default: true)
559
558
  */
560
559
  export function startAutoUpdateLoop(opts = {}) {
561
560
  if (process.env.BOSUN_SKIP_AUTO_UPDATE === "1") {
@@ -596,14 +595,21 @@ export function startAutoUpdateLoop(opts = {}) {
596
595
 
597
596
  // Register cleanup handlers to prevent zombie processes
598
597
  registerCleanupHandlers();
599
-
600
- // Track parent process if provided
601
- if (opts.parentPid) {
602
- parentPid = opts.parentPid;
603
- console.log(`[auto-update] Monitoring parent process PID ${parentPid}`);
598
+ const trackParent = opts.trackParent !== false;
599
+
600
+ // Track parent process when enabled. Detached daemon-child monitor sessions
601
+ // should opt out so they do not self-terminate when the launcher exits.
602
+ if (trackParent) {
603
+ if (opts.parentPid) {
604
+ parentPid = opts.parentPid;
605
+ console.log(`[auto-update] Monitoring parent process PID ${parentPid}`);
606
+ } else {
607
+ parentPid = process.ppid; // Track parent by default
608
+ console.log(`[auto-update] Monitoring parent process PID ${parentPid}`);
609
+ }
604
610
  } else {
605
- parentPid = process.ppid; // Track parent by default
606
- console.log(`[auto-update] Monitoring parent process PID ${parentPid}`);
611
+ parentPid = null;
612
+ console.log("[auto-update] Parent process monitoring disabled");
607
613
  }
608
614
 
609
615
  console.log(