bosun 0.40.12 → 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,
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.12",
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",
@@ -79,7 +79,7 @@
79
79
  "type": "action.run_command",
80
80
  "label": "Fetch, Classify & Label PRs",
81
81
  "config": {
82
- "command": "node -e \" const fs=require('fs'); const path=require('path'); const {execFileSync}=require('child_process'); const LABEL_FIX='{{labelNeedsFix}}'; const MAX_PRS=Math.max(1,Number('{{maxPrs}}')||25); const REPO_SCOPE=String('{{repoScope}}'||'auto').trim(); const FIELDS='number,title,headRefName,baseRefName,isDraft,mergeable,statusCheckRollup,labels,url'; const FAIL_STATES=new Set(['FAILURE','ERROR','TIMED_OUT','CANCELLED','STARTUP_FAILURE']); const PEND_STATES=new Set(['PENDING','IN_PROGRESS','QUEUED','WAITING','REQUESTED','EXPECTED']); const CONFLICT_MERGEABLES=new Set(['CONFLICTING','BEHIND','DIRTY']); function ghJson(args){const out=execFileSync('gh',args,{encoding:'utf8',stdio:['pipe','pipe','pipe']}).trim();return out?JSON.parse(out):[];} function configPath(){ const home=String(process.env.BOSUN_HOME||process.env.VK_PROJECT_DIR||'').trim(); return home?path.join(home,'bosun.config.json'):path.join(process.cwd(),'bosun.config.json'); } function collectReposFromConfig(){ const repos=[]; try{ const cfg=JSON.parse(fs.readFileSync(configPath(),'utf8')); const workspaces=Array.isArray(cfg?.workspaces)?cfg.workspaces:[]; if(workspaces.length>0){ const active=String(cfg?.activeWorkspace||'').trim().toLowerCase(); const activeWs=active?workspaces.find(w=>String(w?.id||'').trim().toLowerCase()===active):null; const wsList=activeWs?[activeWs]:workspaces; for(const ws of wsList){ for(const repo of (Array.isArray(ws?.repos)?ws.repos:[])){ const slug=typeof repo==='string'?String(repo).trim():String(repo?.slug||'').trim(); if(slug) repos.push(slug); } } } if(repos.length===0){ for(const repo of (Array.isArray(cfg?.repos)?cfg.repos:[])){ const slug=typeof repo==='string'?String(repo).trim():String(repo?.slug||'').trim(); if(slug) repos.push(slug); } } }catch{} return repos; } function resolveRepoTargets(){ if(REPO_SCOPE&&REPO_SCOPE!=='auto'&&REPO_SCOPE!=='all'&&REPO_SCOPE!=='current'){ return [...new Set(REPO_SCOPE.split(',').map(v=>v.trim()).filter(Boolean))]; } if(REPO_SCOPE==='current') return ['']; const fromConfig=collectReposFromConfig(); if(fromConfig.length>0) return [...new Set(fromConfig)]; const envRepo=String(process.env.GITHUB_REPOSITORY||'').trim(); if(envRepo) return [envRepo]; return ['']; } function parseRepoFromUrl(url){ const raw=String(url||''); const marker='github.com/'; const idx=raw.toLowerCase().indexOf(marker); if(idx<0) return ''; const tail=raw.slice(idx+marker.length).split('/'); if(tail.length<2) return ''; const owner=String(tail[0]||'').trim(); const repo=String(tail[1]||'').trim(); return owner&&repo?(owner+'/'+repo):''; } const repoTargets=resolveRepoTargets(); const prs=[]; const repoErrors=[]; for(const target of repoTargets){ const repo=String(target||'').trim(); const args=['pr','list','--label','bosun-attached','--state','open','--json',FIELDS,'--limit',String(MAX_PRS)]; if(repo) args.push('--repo',repo); try{ const list=ghJson(args); for(const pr of (Array.isArray(list)?list:[])){ const prRepo=repo||parseRepoFromUrl(pr?.url)||String(process.env.GITHUB_REPOSITORY||'').trim(); prs.push({...pr,__repo:prRepo}); } }catch(e){ repoErrors.push({repo:repo||'current',error:String(e?.message||e)}); } } const readyCandidates=[],conflicts=[],ciFailures=[],pending=[],drafted=[]; let newlyLabeled=0; for(const pr of prs){ const labels=(pr.labels||[]).map(l=>typeof l==='string'?l:l?.name).filter(Boolean); const hasFixLabel=labels.includes(LABEL_FIX); const checks=pr.statusCheckRollup||[]; const hasFail=checks.some(c=>FAIL_STATES.has(c.conclusion||c.state||'')); const hasPend=checks.some(c=>PEND_STATES.has(c.conclusion||c.state||'')); const isConflict=CONFLICT_MERGEABLES.has(String(pr.mergeable||'').toUpperCase()); const isDraft=pr.isDraft===true; const repo=String(pr.__repo||'').trim(); if(isDraft){drafted.push({n:pr.number,repo});continue;} if(isConflict){ conflicts.push({n:pr.number,repo,branch:pr.headRefName,base:pr.baseRefName,url:pr.url}); if(!hasFixLabel){ try{const editArgs=['pr','edit',String(pr.number),'--add-label',LABEL_FIX];if(repo)editArgs.push('--repo',repo);execFileSync('gh',editArgs,{encoding:'utf8',stdio:['pipe','pipe','pipe']});newlyLabeled++;} catch(e){process.stderr.write('label err '+(repo?repo+' ':'')+'#'+pr.number+': '+(e?.message||e)+'\\\\n');} } } else if(hasFail){ ciFailures.push({n:pr.number,repo,branch:pr.headRefName,url:pr.url}); if(!hasFixLabel){ try{const editArgs=['pr','edit',String(pr.number),'--add-label',LABEL_FIX];if(repo)editArgs.push('--repo',repo);execFileSync('gh',editArgs,{encoding:'utf8',stdio:['pipe','pipe','pipe']});newlyLabeled++;} catch(e){process.stderr.write('label err '+(repo?repo+' ':'')+'#'+pr.number+': '+(e?.message||e)+'\\\\n');} } } else if(checks.length>0&&!hasFixLabel){ if(hasPend) pending.push({n:pr.number,repo}); readyCandidates.push({n:pr.number,repo,branch:pr.headRefName,base:pr.baseRefName,url:pr.url,title:pr.title,pendingChecks:hasPend}); } } console.log(JSON.stringify({ total:prs.length, reposScanned:repoTargets.length, repoErrors, readyCandidates, conflicts, ciFailures, pending:pending.length, drafted:drafted.length, newlyLabeled, fixNeeded:conflicts.length+ciFailures.length })); \"",
82
+ "command": "node -e \" const fs=require('fs'); const path=require('path'); const {execFileSync}=require('child_process'); const LABEL_FIX='{{labelNeedsFix}}'; const MAX_PRS=Math.max(1,Number('{{maxPrs}}')||25); const REPO_SCOPE=String('{{repoScope}}'||'auto').trim(); const FIELDS='number,title,headRefName,baseRefName,isDraft,mergeable,statusCheckRollup,labels,url'; const FAIL_STATES=new Set(['FAILURE','ERROR','TIMED_OUT','CANCELLED','STARTUP_FAILURE']); const PEND_STATES=new Set(['PENDING','IN_PROGRESS','QUEUED','WAITING','REQUESTED','EXPECTED']); const CONFLICT_MERGEABLES=new Set(['CONFLICTING','BEHIND','DIRTY']); function ghJson(args){const out=execFileSync('gh',args,{encoding:'utf8',stdio:['pipe','pipe','pipe']}).trim();return out?JSON.parse(out):[];} function configPath(){ const home=String(process.env.BOSUN_HOME||process.env.VK_PROJECT_DIR||'').trim(); return home?path.join(home,'bosun.config.json'):path.join(process.cwd(),'bosun.config.json'); } function collectReposFromConfig(){ const repos=[]; try{ const cfg=JSON.parse(fs.readFileSync(configPath(),'utf8')); const workspaces=Array.isArray(cfg?.workspaces)?cfg.workspaces:[]; if(workspaces.length>0){ const active=String(cfg?.activeWorkspace||'').trim().toLowerCase(); const activeWs=active?workspaces.find(w=>String(w?.id||'').trim().toLowerCase()===active):null; const wsList=activeWs?[activeWs]:workspaces; for(const ws of wsList){ for(const repo of (Array.isArray(ws?.repos)?ws.repos:[])){ const slug=typeof repo==='string'?String(repo).trim():String(repo?.slug||'').trim(); if(slug) repos.push(slug); } } } if(repos.length===0){ for(const repo of (Array.isArray(cfg?.repos)?cfg.repos:[])){ const slug=typeof repo==='string'?String(repo).trim():String(repo?.slug||'').trim(); if(slug) repos.push(slug); } } }catch{} return repos; } function resolveRepoTargets(){ if(REPO_SCOPE&&REPO_SCOPE!=='auto'&&REPO_SCOPE!=='all'&&REPO_SCOPE!=='current'){ return [...new Set(REPO_SCOPE.split(',').map(v=>v.trim()).filter(Boolean))]; } if(REPO_SCOPE==='current') return ['']; const fromConfig=collectReposFromConfig(); if(fromConfig.length>0) return [...new Set(fromConfig)]; const envRepo=String(process.env.GITHUB_REPOSITORY||'').trim(); if(envRepo) return [envRepo]; return ['']; } function parseRepoFromUrl(url){ const raw=String(url||''); const marker='github.com/'; const idx=raw.toLowerCase().indexOf(marker); if(idx<0) return ''; const tail=raw.slice(idx+marker.length).split('/'); if(tail.length<2) return ''; const owner=String(tail[0]||'').trim(); const repo=String(tail[1]||'').trim(); return owner&&repo?(owner+'/'+repo):''; } const repoTargets=resolveRepoTargets(); const prs=[]; const repoErrors=[]; for(const target of repoTargets){ const repo=String(target||'').trim(); const args=['pr','list','--label','bosun-attached','--state','open','--json',FIELDS,'--limit',String(MAX_PRS)]; if(repo) args.push('--repo',repo); try{ const list=ghJson(args); for(const pr of (Array.isArray(list)?list:[])){ const prRepo=repo||parseRepoFromUrl(pr?.url)||String(process.env.GITHUB_REPOSITORY||'').trim(); prs.push({...pr,__repo:prRepo}); } }catch(e){ repoErrors.push({repo:repo||'current',error:String(e?.message||e)}); } } const readyCandidates=[],conflicts=[],ciFailures=[],pending=[],drafted=[]; let newlyLabeled=0,staleLabelCleared=0,ciKicked=0; for(const pr of prs){ const labels=(pr.labels||[]).map(l=>typeof l==='string'?l:l?.name).filter(Boolean); const hasFixLabel=labels.includes(LABEL_FIX); const checks=pr.statusCheckRollup||[]; const hasFail=checks.some(c=>FAIL_STATES.has(c.conclusion||c.state||'')); const hasPend=checks.some(c=>PEND_STATES.has(c.conclusion||c.state||'')); const isConflict=CONFLICT_MERGEABLES.has(String(pr.mergeable||'').toUpperCase()); const isDraft=pr.isDraft===true; const repo=String(pr.__repo||'').trim(); if(isDraft){drafted.push({n:pr.number,repo});continue;} if(isConflict){ conflicts.push({n:pr.number,repo,branch:pr.headRefName,base:pr.baseRefName,url:pr.url}); if(!hasFixLabel){ try{const editArgs=['pr','edit',String(pr.number),'--add-label',LABEL_FIX];if(repo)editArgs.push('--repo',repo);execFileSync('gh',editArgs,{encoding:'utf8',stdio:['pipe','pipe','pipe']});newlyLabeled++;} catch(e){process.stderr.write('label err '+(repo?repo+' ':'')+'#'+pr.number+': '+(e?.message||e)+'\\\\n');} } } else if(hasFail){ ciFailures.push({n:pr.number,repo,branch:pr.headRefName,url:pr.url}); if(!hasFixLabel){ try{const editArgs=['pr','edit',String(pr.number),'--add-label',LABEL_FIX];if(repo)editArgs.push('--repo',repo);execFileSync('gh',editArgs,{encoding:'utf8',stdio:['pipe','pipe','pipe']});newlyLabeled++;} catch(e){process.stderr.write('label err '+(repo?repo+' ':'')+'#'+pr.number+': '+(e?.message||e)+'\\\\n');} } } else { if(hasFixLabel&&!hasPend){ try{ const rmArgs=['pr','edit',String(pr.number),'--remove-label',LABEL_FIX]; if(repo)rmArgs.push('--repo',repo); execFileSync('gh',rmArgs,{encoding:'utf8',stdio:['pipe','pipe','pipe']}); staleLabelCleared++; }catch(e){process.stderr.write('stale-label-rm err '+(repo?repo+' ':'')+'#'+pr.number+': '+(e?.message||e)+'\\\\n');} } else if(checks.length>0&&!hasFixLabel){ if(hasPend) pending.push({n:pr.number,repo}); readyCandidates.push({n:pr.number,repo,branch:pr.headRefName,base:pr.baseRefName,url:pr.url,title:pr.title,pendingChecks:hasPend}); } if(checks.length===0&&repo&&pr.headRefName&&!isDraft){ try{execFileSync('gh',['workflow','run','ci.yaml','--repo',repo,'--ref',pr.headRefName],{encoding:'utf8',stdio:['pipe','pipe','pipe']});ciKicked++;} catch{} } } } console.log(JSON.stringify({ total:prs.length, reposScanned:repoTargets.length, repoErrors, readyCandidates, conflicts, ciFailures, pending:pending.length, drafted:drafted.length, newlyLabeled, staleLabelCleared, ciKicked, fixNeeded:conflicts.length+ciFailures.length })); \"",
83
83
  "continueOnError": false,
84
84
  "failOnError": true
85
85
  },
@@ -17000,7 +17000,7 @@
17000
17000
  "type": "action.run_command",
17001
17001
  "label": "Fetch, Classify & Label PRs",
17002
17002
  "config": {
17003
- "command": "node -e \" const fs=require('fs'); const path=require('path'); const {execFileSync}=require('child_process'); const LABEL_FIX='{{labelNeedsFix}}'; const MAX_PRS=Math.max(1,Number('{{maxPrs}}')||25); const REPO_SCOPE=String('{{repoScope}}'||'auto').trim(); const FIELDS='number,title,headRefName,baseRefName,isDraft,mergeable,statusCheckRollup,labels,url'; const FAIL_STATES=new Set(['FAILURE','ERROR','TIMED_OUT','CANCELLED','STARTUP_FAILURE']); const PEND_STATES=new Set(['PENDING','IN_PROGRESS','QUEUED','WAITING','REQUESTED','EXPECTED']); const CONFLICT_MERGEABLES=new Set(['CONFLICTING','BEHIND','DIRTY']); function ghJson(args){const out=execFileSync('gh',args,{encoding:'utf8',stdio:['pipe','pipe','pipe']}).trim();return out?JSON.parse(out):[];} function configPath(){ const home=String(process.env.BOSUN_HOME||process.env.VK_PROJECT_DIR||'').trim(); return home?path.join(home,'bosun.config.json'):path.join(process.cwd(),'bosun.config.json'); } function collectReposFromConfig(){ const repos=[]; try{ const cfg=JSON.parse(fs.readFileSync(configPath(),'utf8')); const workspaces=Array.isArray(cfg?.workspaces)?cfg.workspaces:[]; if(workspaces.length>0){ const active=String(cfg?.activeWorkspace||'').trim().toLowerCase(); const activeWs=active?workspaces.find(w=>String(w?.id||'').trim().toLowerCase()===active):null; const wsList=activeWs?[activeWs]:workspaces; for(const ws of wsList){ for(const repo of (Array.isArray(ws?.repos)?ws.repos:[])){ const slug=typeof repo==='string'?String(repo).trim():String(repo?.slug||'').trim(); if(slug) repos.push(slug); } } } if(repos.length===0){ for(const repo of (Array.isArray(cfg?.repos)?cfg.repos:[])){ const slug=typeof repo==='string'?String(repo).trim():String(repo?.slug||'').trim(); if(slug) repos.push(slug); } } }catch{} return repos; } function resolveRepoTargets(){ if(REPO_SCOPE&&REPO_SCOPE!=='auto'&&REPO_SCOPE!=='all'&&REPO_SCOPE!=='current'){ return [...new Set(REPO_SCOPE.split(',').map(v=>v.trim()).filter(Boolean))]; } if(REPO_SCOPE==='current') return ['']; const fromConfig=collectReposFromConfig(); if(fromConfig.length>0) return [...new Set(fromConfig)]; const envRepo=String(process.env.GITHUB_REPOSITORY||'').trim(); if(envRepo) return [envRepo]; return ['']; } function parseRepoFromUrl(url){ const raw=String(url||''); const marker='github.com/'; const idx=raw.toLowerCase().indexOf(marker); if(idx<0) return ''; const tail=raw.slice(idx+marker.length).split('/'); if(tail.length<2) return ''; const owner=String(tail[0]||'').trim(); const repo=String(tail[1]||'').trim(); return owner&&repo?(owner+'/'+repo):''; } const repoTargets=resolveRepoTargets(); const prs=[]; const repoErrors=[]; for(const target of repoTargets){ const repo=String(target||'').trim(); const args=['pr','list','--label','bosun-attached','--state','open','--json',FIELDS,'--limit',String(MAX_PRS)]; if(repo) args.push('--repo',repo); try{ const list=ghJson(args); for(const pr of (Array.isArray(list)?list:[])){ const prRepo=repo||parseRepoFromUrl(pr?.url)||String(process.env.GITHUB_REPOSITORY||'').trim(); prs.push({...pr,__repo:prRepo}); } }catch(e){ repoErrors.push({repo:repo||'current',error:String(e?.message||e)}); } } const readyCandidates=[],conflicts=[],ciFailures=[],pending=[],drafted=[]; let newlyLabeled=0; for(const pr of prs){ const labels=(pr.labels||[]).map(l=>typeof l==='string'?l:l?.name).filter(Boolean); const hasFixLabel=labels.includes(LABEL_FIX); const checks=pr.statusCheckRollup||[]; const hasFail=checks.some(c=>FAIL_STATES.has(c.conclusion||c.state||'')); const hasPend=checks.some(c=>PEND_STATES.has(c.conclusion||c.state||'')); const isConflict=CONFLICT_MERGEABLES.has(String(pr.mergeable||'').toUpperCase()); const isDraft=pr.isDraft===true; const repo=String(pr.__repo||'').trim(); if(isDraft){drafted.push({n:pr.number,repo});continue;} if(isConflict){ conflicts.push({n:pr.number,repo,branch:pr.headRefName,base:pr.baseRefName,url:pr.url}); if(!hasFixLabel){ try{const editArgs=['pr','edit',String(pr.number),'--add-label',LABEL_FIX];if(repo)editArgs.push('--repo',repo);execFileSync('gh',editArgs,{encoding:'utf8',stdio:['pipe','pipe','pipe']});newlyLabeled++;} catch(e){process.stderr.write('label err '+(repo?repo+' ':'')+'#'+pr.number+': '+(e?.message||e)+'\\\\n');} } } else if(hasFail){ ciFailures.push({n:pr.number,repo,branch:pr.headRefName,url:pr.url}); if(!hasFixLabel){ try{const editArgs=['pr','edit',String(pr.number),'--add-label',LABEL_FIX];if(repo)editArgs.push('--repo',repo);execFileSync('gh',editArgs,{encoding:'utf8',stdio:['pipe','pipe','pipe']});newlyLabeled++;} catch(e){process.stderr.write('label err '+(repo?repo+' ':'')+'#'+pr.number+': '+(e?.message||e)+'\\\\n');} } } else if(checks.length>0&&!hasFixLabel){ if(hasPend) pending.push({n:pr.number,repo}); readyCandidates.push({n:pr.number,repo,branch:pr.headRefName,base:pr.baseRefName,url:pr.url,title:pr.title,pendingChecks:hasPend}); } } console.log(JSON.stringify({ total:prs.length, reposScanned:repoTargets.length, repoErrors, readyCandidates, conflicts, ciFailures, pending:pending.length, drafted:drafted.length, newlyLabeled, fixNeeded:conflicts.length+ciFailures.length })); \"",
17003
+ "command": "node -e \" const fs=require('fs'); const path=require('path'); const {execFileSync}=require('child_process'); const LABEL_FIX='{{labelNeedsFix}}'; const MAX_PRS=Math.max(1,Number('{{maxPrs}}')||25); const REPO_SCOPE=String('{{repoScope}}'||'auto').trim(); const FIELDS='number,title,headRefName,baseRefName,isDraft,mergeable,statusCheckRollup,labels,url'; const FAIL_STATES=new Set(['FAILURE','ERROR','TIMED_OUT','CANCELLED','STARTUP_FAILURE']); const PEND_STATES=new Set(['PENDING','IN_PROGRESS','QUEUED','WAITING','REQUESTED','EXPECTED']); const CONFLICT_MERGEABLES=new Set(['CONFLICTING','BEHIND','DIRTY']); function ghJson(args){const out=execFileSync('gh',args,{encoding:'utf8',stdio:['pipe','pipe','pipe']}).trim();return out?JSON.parse(out):[];} function configPath(){ const home=String(process.env.BOSUN_HOME||process.env.VK_PROJECT_DIR||'').trim(); return home?path.join(home,'bosun.config.json'):path.join(process.cwd(),'bosun.config.json'); } function collectReposFromConfig(){ const repos=[]; try{ const cfg=JSON.parse(fs.readFileSync(configPath(),'utf8')); const workspaces=Array.isArray(cfg?.workspaces)?cfg.workspaces:[]; if(workspaces.length>0){ const active=String(cfg?.activeWorkspace||'').trim().toLowerCase(); const activeWs=active?workspaces.find(w=>String(w?.id||'').trim().toLowerCase()===active):null; const wsList=activeWs?[activeWs]:workspaces; for(const ws of wsList){ for(const repo of (Array.isArray(ws?.repos)?ws.repos:[])){ const slug=typeof repo==='string'?String(repo).trim():String(repo?.slug||'').trim(); if(slug) repos.push(slug); } } } if(repos.length===0){ for(const repo of (Array.isArray(cfg?.repos)?cfg.repos:[])){ const slug=typeof repo==='string'?String(repo).trim():String(repo?.slug||'').trim(); if(slug) repos.push(slug); } } }catch{} return repos; } function resolveRepoTargets(){ if(REPO_SCOPE&&REPO_SCOPE!=='auto'&&REPO_SCOPE!=='all'&&REPO_SCOPE!=='current'){ return [...new Set(REPO_SCOPE.split(',').map(v=>v.trim()).filter(Boolean))]; } if(REPO_SCOPE==='current') return ['']; const fromConfig=collectReposFromConfig(); if(fromConfig.length>0) return [...new Set(fromConfig)]; const envRepo=String(process.env.GITHUB_REPOSITORY||'').trim(); if(envRepo) return [envRepo]; return ['']; } function parseRepoFromUrl(url){ const raw=String(url||''); const marker='github.com/'; const idx=raw.toLowerCase().indexOf(marker); if(idx<0) return ''; const tail=raw.slice(idx+marker.length).split('/'); if(tail.length<2) return ''; const owner=String(tail[0]||'').trim(); const repo=String(tail[1]||'').trim(); return owner&&repo?(owner+'/'+repo):''; } const repoTargets=resolveRepoTargets(); const prs=[]; const repoErrors=[]; for(const target of repoTargets){ const repo=String(target||'').trim(); const args=['pr','list','--label','bosun-attached','--state','open','--json',FIELDS,'--limit',String(MAX_PRS)]; if(repo) args.push('--repo',repo); try{ const list=ghJson(args); for(const pr of (Array.isArray(list)?list:[])){ const prRepo=repo||parseRepoFromUrl(pr?.url)||String(process.env.GITHUB_REPOSITORY||'').trim(); prs.push({...pr,__repo:prRepo}); } }catch(e){ repoErrors.push({repo:repo||'current',error:String(e?.message||e)}); } } const readyCandidates=[],conflicts=[],ciFailures=[],pending=[],drafted=[]; let newlyLabeled=0,staleLabelCleared=0,ciKicked=0; for(const pr of prs){ const labels=(pr.labels||[]).map(l=>typeof l==='string'?l:l?.name).filter(Boolean); const hasFixLabel=labels.includes(LABEL_FIX); const checks=pr.statusCheckRollup||[]; const hasFail=checks.some(c=>FAIL_STATES.has(c.conclusion||c.state||'')); const hasPend=checks.some(c=>PEND_STATES.has(c.conclusion||c.state||'')); const isConflict=CONFLICT_MERGEABLES.has(String(pr.mergeable||'').toUpperCase()); const isDraft=pr.isDraft===true; const repo=String(pr.__repo||'').trim(); if(isDraft){drafted.push({n:pr.number,repo});continue;} if(isConflict){ conflicts.push({n:pr.number,repo,branch:pr.headRefName,base:pr.baseRefName,url:pr.url}); if(!hasFixLabel){ try{const editArgs=['pr','edit',String(pr.number),'--add-label',LABEL_FIX];if(repo)editArgs.push('--repo',repo);execFileSync('gh',editArgs,{encoding:'utf8',stdio:['pipe','pipe','pipe']});newlyLabeled++;} catch(e){process.stderr.write('label err '+(repo?repo+' ':'')+'#'+pr.number+': '+(e?.message||e)+'\\\\n');} } } else if(hasFail){ ciFailures.push({n:pr.number,repo,branch:pr.headRefName,url:pr.url}); if(!hasFixLabel){ try{const editArgs=['pr','edit',String(pr.number),'--add-label',LABEL_FIX];if(repo)editArgs.push('--repo',repo);execFileSync('gh',editArgs,{encoding:'utf8',stdio:['pipe','pipe','pipe']});newlyLabeled++;} catch(e){process.stderr.write('label err '+(repo?repo+' ':'')+'#'+pr.number+': '+(e?.message||e)+'\\\\n');} } } else { if(hasFixLabel&&!hasPend){ try{ const rmArgs=['pr','edit',String(pr.number),'--remove-label',LABEL_FIX]; if(repo)rmArgs.push('--repo',repo); execFileSync('gh',rmArgs,{encoding:'utf8',stdio:['pipe','pipe','pipe']}); staleLabelCleared++; }catch(e){process.stderr.write('stale-label-rm err '+(repo?repo+' ':'')+'#'+pr.number+': '+(e?.message||e)+'\\\\n');} } else if(checks.length>0&&!hasFixLabel){ if(hasPend) pending.push({n:pr.number,repo}); readyCandidates.push({n:pr.number,repo,branch:pr.headRefName,base:pr.baseRefName,url:pr.url,title:pr.title,pendingChecks:hasPend}); } if(checks.length===0&&repo&&pr.headRefName&&!isDraft){ try{execFileSync('gh',['workflow','run','ci.yaml','--repo',repo,'--ref',pr.headRefName],{encoding:'utf8',stdio:['pipe','pipe','pipe']});ciKicked++;} catch{} } } } console.log(JSON.stringify({ total:prs.length, reposScanned:repoTargets.length, repoErrors, readyCandidates, conflicts, ciFailures, pending:pending.length, drafted:drafted.length, newlyLabeled, staleLabelCleared, ciKicked, fixNeeded:conflicts.length+ciFailures.length })); \"",
17004
17004
  "continueOnError": false,
17005
17005
  "failOnError": true
17006
17006
  },
@@ -28,6 +28,7 @@ import { buildRelevantSkillsPromptBlock, findRelevantSkills } from "../agent/bos
28
28
  import { getSessionTracker } from "../infra/session-tracker.mjs";
29
29
  import { fixGitConfigCorruption } from "../workspace/worktree-manager.mjs";
30
30
  import { clearBlockedWorktreeIdentity } from "../git/git-safety.mjs";
31
+ import { getGitHubToken } from "../github/github-auth-manager.mjs";
31
32
 
32
33
  const TAG = "[workflow-nodes]";
33
34
  const PORTABLE_WORKTREE_COUNT_COMMAND = "node -e \"const cp=require('node:child_process');const wt=cp.execSync('git worktree list --porcelain',{encoding:'utf8'});const count=(wt.match(/^worktree /gm)||[]).length;process.stdout.write(String(count)+'\\\\n');\"";
@@ -3436,11 +3437,29 @@ registerNodeType("action.create_pr", {
3436
3437
  BOSUN_ATTACHED_PR_LABEL,
3437
3438
  ]));
3438
3439
  const reviewers = toList(ctx.resolve(node.config?.reviewers || ""));
3440
+
3441
+ // Resolve Bosun's best available GitHub token and inject as GH_TOKEN so that
3442
+ // `gh pr create` uses a user OAuth / App installation token rather than the
3443
+ // ambient GITHUB_TOKEN. GitHub suppresses pull_request CI workflow triggers
3444
+ // for events caused by GITHUB_TOKEN (loop-prevention), so using a real user
3445
+ // token here is what allows CI to fire automatically on the created PR.
3446
+ let ghTokenEnv = {};
3447
+ try {
3448
+ const [ghOwner, ghRepo] = repoSlug ? repoSlug.split("/") : [];
3449
+ const { token, type } = await getGitHubToken({ owner: ghOwner, repo: ghRepo });
3450
+ // Only inject when we have a real user/app token, not an env-fallback
3451
+ // (which would be GITHUB_TOKEN itself — injecting it would be redundant).
3452
+ if (type !== "env") {
3453
+ ghTokenEnv = { GH_TOKEN: token };
3454
+ }
3455
+ } catch {
3456
+ // No auth available — fall back to ambient environment
3457
+ }
3439
3458
  const execOptions = {
3440
3459
  cwd,
3441
3460
  encoding: "utf8",
3442
3461
  timeout: 60000,
3443
- env: makeIsolatedGitEnv(),
3462
+ env: makeIsolatedGitEnv(ghTokenEnv),
3444
3463
  stdio: ["pipe", "pipe", "pipe"],
3445
3464
  };
3446
3465
 
@@ -9426,12 +9445,47 @@ registerNodeType("action.push_branch", {
9426
9445
  return { success: false, error: `Protected branch: ${cleanBranch}`, pushed: false };
9427
9446
  }
9428
9447
 
9448
+ // ── Fetch (always, independent of rebase) ──
9449
+ // Must succeed before push so --force-with-lease has fresh remote tracking refs.
9450
+ try {
9451
+ execSync(`git fetch ${remote} --no-tags`, {
9452
+ cwd: worktreePath, timeout: 30000, stdio: ["ignore", "pipe", "pipe"],
9453
+ });
9454
+ } catch (fetchErr) {
9455
+ ctx.log(node.id, `Fetch failed (will push anyway): ${fetchErr.message?.slice(0, 200)}`);
9456
+ }
9457
+
9429
9458
  // ── Rebase-before-push ──
9430
9459
  if (rebaseBeforePush) {
9460
+ // Step 1: if the remote already has commits on this branch (previous run / partial push),
9461
+ // rebase local onto origin/${cleanBranch} first so we incorporate those commits and
9462
+ // the subsequent push is a clean fast-forward instead of a diverged force-push.
9463
+ const remoteTrackingRef = `${remote}/${cleanBranch}`;
9431
9464
  try {
9432
- execSync(`git fetch ${remote} --no-tags`, {
9433
- cwd: worktreePath, timeout: 30000, stdio: ["ignore", "pipe", "pipe"],
9465
+ execSync(`git rev-parse --verify ${remoteTrackingRef}`, {
9466
+ cwd: worktreePath, timeout: 5000, stdio: ["ignore", "pipe", "pipe"],
9434
9467
  });
9468
+ // Remote branch exists — check if it diverges from local
9469
+ const behindCount = execSync(
9470
+ `git rev-list --count HEAD..${remoteTrackingRef}`,
9471
+ { cwd: worktreePath, encoding: "utf8", timeout: 10000, stdio: ["ignore", "pipe", "pipe"] }
9472
+ ).trim();
9473
+ if (parseInt(behindCount, 10) > 0) {
9474
+ try {
9475
+ execSync(`git rebase ${remoteTrackingRef}`, {
9476
+ cwd: worktreePath, encoding: "utf8", timeout: 60000,
9477
+ stdio: ["ignore", "pipe", "pipe"],
9478
+ });
9479
+ ctx.log(node.id, `Synced local with ${remoteTrackingRef} (was ${behindCount} behind)`);
9480
+ } catch (syncErr) {
9481
+ try { execSync("git rebase --abort", { cwd: worktreePath, timeout: 10000, stdio: ["ignore", "pipe", "pipe"] }); } catch { /* ok */ }
9482
+ ctx.log(node.id, `Sync with ${remoteTrackingRef} conflicted, skipping: ${syncErr.message?.slice(0, 200)}`);
9483
+ }
9484
+ }
9485
+ } catch { /* remote branch doesn't exist yet — normal for first push */ }
9486
+
9487
+ // Step 2: rebase onto base branch (e.g. origin/main)
9488
+ try {
9435
9489
  execSync(`git rebase ${baseBranch}`, {
9436
9490
  cwd: worktreePath, encoding: "utf8", timeout: 60000,
9437
9491
  stdio: ["ignore", "pipe", "pipe"],
@@ -9444,7 +9498,7 @@ registerNodeType("action.push_branch", {
9444
9498
  cwd: worktreePath, timeout: 10000, stdio: ["ignore", "pipe", "pipe"],
9445
9499
  });
9446
9500
  } catch { /* already aborted */ }
9447
- ctx.log(node.id, `Rebase conflict, skipping: ${rebaseErr.message?.slice(0, 200)}`);
9501
+ ctx.log(node.id, `Rebase onto ${baseBranch} conflicted, skipping: ${rebaseErr.message?.slice(0, 200)}`);
9448
9502
  }
9449
9503
  }
9450
9504
 
@@ -830,7 +830,7 @@ export const BOSUN_PR_WATCHDOG_TEMPLATE = {
830
830
  " }",
831
831
  "}",
832
832
  "const readyCandidates=[],conflicts=[],ciFailures=[],pending=[],drafted=[];",
833
- "let newlyLabeled=0;",
833
+ "let newlyLabeled=0,staleLabelCleared=0,ciKicked=0;",
834
834
  "for(const pr of prs){",
835
835
  " const labels=(pr.labels||[]).map(l=>typeof l==='string'?l:l?.name).filter(Boolean);",
836
836
  " const hasFixLabel=labels.includes(LABEL_FIX);",
@@ -853,9 +853,22 @@ export const BOSUN_PR_WATCHDOG_TEMPLATE = {
853
853
  " try{const editArgs=['pr','edit',String(pr.number),'--add-label',LABEL_FIX];if(repo)editArgs.push('--repo',repo);execFileSync('gh',editArgs,{encoding:'utf8',stdio:['pipe','pipe','pipe']});newlyLabeled++;}",
854
854
  " catch(e){process.stderr.write('label err '+(repo?repo+' ':'')+'#'+pr.number+': '+(e?.message||e)+'\\\\n');}",
855
855
  " }",
856
- " } else if(checks.length>0&&!hasFixLabel){",
857
- " if(hasPend) pending.push({n:pr.number,repo});",
858
- " readyCandidates.push({n:pr.number,repo,branch:pr.headRefName,base:pr.baseRefName,url:pr.url,title:pr.title,pendingChecks:hasPend});",
856
+ " } else {",
857
+ " if(hasFixLabel&&!hasPend){",
858
+ " try{",
859
+ " const rmArgs=['pr','edit',String(pr.number),'--remove-label',LABEL_FIX];",
860
+ " if(repo)rmArgs.push('--repo',repo);",
861
+ " execFileSync('gh',rmArgs,{encoding:'utf8',stdio:['pipe','pipe','pipe']});",
862
+ " staleLabelCleared++;",
863
+ " }catch(e){process.stderr.write('stale-label-rm err '+(repo?repo+' ':'')+'#'+pr.number+': '+(e?.message||e)+'\\\\n');}",
864
+ " } else if(checks.length>0&&!hasFixLabel){",
865
+ " if(hasPend) pending.push({n:pr.number,repo});",
866
+ " readyCandidates.push({n:pr.number,repo,branch:pr.headRefName,base:pr.baseRefName,url:pr.url,title:pr.title,pendingChecks:hasPend});",
867
+ " }",
868
+ " if(checks.length===0&&repo&&pr.headRefName&&!isDraft){",
869
+ " try{execFileSync('gh',['workflow','run','ci.yaml','--repo',repo,'--ref',pr.headRefName],{encoding:'utf8',stdio:['pipe','pipe','pipe']});ciKicked++;}",
870
+ " catch{}",
871
+ " }",
859
872
  " }",
860
873
  "}",
861
874
  "console.log(JSON.stringify({",
@@ -868,6 +881,8 @@ export const BOSUN_PR_WATCHDOG_TEMPLATE = {
868
881
  " pending:pending.length,",
869
882
  " drafted:drafted.length,",
870
883
  " newlyLabeled,",
884
+ " staleLabelCleared,",
885
+ " ciKicked,",
871
886
  " fixNeeded:conflicts.length+ciFailures.length",
872
887
  "}));",
873
888
  "\"",