diff --git a/site/ui/demo-defaults.js b/site/ui/demo-defaults.js index 82c09bd48..db0167409 100644 --- a/site/ui/demo-defaults.js +++ b/site/ui/demo-defaults.js @@ -99,7 +99,7 @@ "label": "Normalize PR Context", "config": { "key": "prProgressContext", - "value": "(() => { const prOut = $ctx.getNodeOutput('create-pr') || $ctx.getNodeOutput('create-pr-retry') || {}; const prUrl = String($data?.prUrl || prOut?.prUrl || prOut?.url || '').trim(); const repoMatch = prUrl.match(/github\\.com\\/([^/]+\\/[^/?#]+)/i); const repo = String($data?.repo || (repoMatch ? repoMatch[1] : '')).trim(); const rawPrNumber = $data?.prNumber ?? prOut?.prNumber ?? null; const parsedPrNumber = Number.parseInt(String(rawPrNumber || ''), 10); return { taskId: String($data?.taskId || '').trim() || null, taskTitle: String($data?.taskTitle || '').trim() || null, repo: repo || null, branch: String($data?.branch || prOut?.branch || '').trim() || null, baseBranch: String($data?.baseBranch || prOut?.base || 'main').trim() || 'main', prNumber: Number.isFinite(parsedPrNumber) && parsedPrNumber > 0 ? parsedPrNumber : null, prUrl: prUrl || null, };})()", + "value": "/* */ (() => { const prOut = $ctx.getNodeOutput('create-pr') || $ctx.getNodeOutput('create-pr-retry') || {}; const prUrl = String($data?.prUrl || prOut?.prUrl || prOut?.url || '').trim(); const repoMatch = prUrl.match(/github\\.com\\/([^/]+\\/[^/?#]+)/i); const repo = String($data?.repo || (repoMatch ? repoMatch[1] : '')).trim(); const rawPrNumber = $data?.prNumber ?? prOut?.prNumber ?? null; const parsedPrNumber = Number.parseInt(String(rawPrNumber || ''), 10); return { taskId: String($data?.taskId || '').trim() || null, taskTitle: String($data?.taskTitle || '').trim() || null, repo: repo || null, branch: String($data?.branch || prOut?.branch || '').trim() || null, baseBranch: String($data?.baseBranch || prOut?.base || 'main').trim() || 'main', prNumber: Number.isFinite(parsedPrNumber) && parsedPrNumber > 0 ? parsedPrNumber : null, prUrl: prUrl || null, };})()", "isExpression": true }, "position": { @@ -255,6 +255,8 @@ "prompt": "You are a Bosun PR repair fallback agent working one PR only.\n\n## CRITICAL RULES — Read Before Doing Anything\n\nYour working directory is already a git clone of the target repo, checked out on the PR's HEAD branch (`{{setup-pr-worktree.output.branch}}`). The base branch (`{{setup-pr-worktree.output.base}}`) has been fetched.\n\n- Do NOT clone the repo. Do NOT create branches. Do NOT push.\n- Work ONLY in the current directory on the current branch.\n- The workflow will commit and push your changes automatically after you finish.\n\n## PR Context\n\n{{$ctx.getNodeOutput('inspect-pr')?.output}}\n\n## Repair Attempt Output\n\n{{$ctx.getNodeOutput('programmatic-fix')?.output}}\n\nUse prDigest.body, prDigest.files, prDigest.issueComments, prDigest.reviews, prDigest.reviewComments, prDigest.checks, failedAnnotations, and any failedLogExcerpt before making changes.\n\n## Fix Instructions\n\n- Only fix this PR's CI or merge-conflict issue.\n- For merge conflicts: `git merge origin/{{setup-pr-worktree.output.base}}` and resolve.\n- For CI failures: study the error output and apply the MINIMAL code fix.\n- Do not merge, approve, or close the PR.\n- Keep the patch minimal and scoped to the reported failure.\n- After fixing, remove the label:\n `gh pr edit {{setup-pr-worktree.output.number}} --repo {{setup-pr-worktree.output.repo}} --remove-label bosun-needs-fix`\n", "sdk": "auto", "timeoutMs": 1800000, + "delegationWatchdogTimeoutMs": "{{delegationWatchdogTimeoutMs}}", + "delegationWatchdogMaxRecoveries": "{{delegationWatchdogMaxRecoveries}}", "maxRetries": 2, "retryDelayMs": 30000, "continueOnError": true @@ -1998,7 +2000,7 @@ "label": "Pick Conflict PR", "config": { "key": "targetPrNumber", - "value": "(() => { const raw = $ctx.getNodeOutput('list-prs')?.output || '[]'; let prs = []; try { prs = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { return ''; } if (!Array.isArray(prs)) return ''; const CONFLICT = new Set(['CONFLICTING', 'BEHIND', 'DIRTY']); const BOSUN_CREATED_LABEL = 'bosun-pr-bosun-created'; const readLabelNames = (pr) => Array.isArray(pr?.labels) ? pr.labels.map((entry) => typeof entry === 'string' ? entry : entry?.name).filter(Boolean) : []; const isBosunCreated = (pr) => readLabelNames(pr).includes(BOSUN_CREATED_LABEL); /* Skip PRs already owned by the watchdog fix agent */ const pr = prs.find((p) => isBosunCreated(p) && CONFLICT.has(String(p?.mergeable || '').toUpperCase()) && !(p.labels || []).some((l) => l.name === 'bosun-needs-fix') ); return pr?.number ? String(pr.number) : '';})()", + "value": "/* */ (() => { const raw = $ctx.getNodeOutput('list-prs')?.output || '[]'; let prs = []; try { prs = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { return ''; } if (!Array.isArray(prs)) return ''; const CONFLICT = new Set(['CONFLICTING', 'BEHIND', 'DIRTY']); const BOSUN_CREATED_LABEL = 'bosun-pr-bosun-created'; const readLabelNames = (pr) => Array.isArray(pr?.labels) ? pr.labels.map((entry) => typeof entry === 'string' ? entry : entry?.name).filter(Boolean) : []; const isBosunCreated = (pr) => readLabelNames(pr).includes(BOSUN_CREATED_LABEL); /* Skip PRs already owned by the watchdog fix agent */ const pr = prs.find((p) => isBosunCreated(p) && CONFLICT.has(String(p?.mergeable || '').toUpperCase()) && !(p.labels || []).some((l) => l.name === 'bosun-needs-fix') ); return pr?.number ? String(pr.number) : '';})()", "isExpression": true }, "position": { @@ -2015,7 +2017,7 @@ "label": "Capture Conflict Branch", "config": { "key": "targetPrBranch", - "value": "(() => { const raw = $ctx.getNodeOutput('list-prs')?.output || '[]'; let prs = []; try { prs = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { return ''; } if (!Array.isArray(prs)) return ''; const pr = prs.find((p) => String(p?.number || '') === String($data?.targetPrNumber || '')); return pr?.headRefName || '';})()", + "value": "/* */ (() => { const raw = $ctx.getNodeOutput('list-prs')?.output || '[]'; let prs = []; try { prs = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { return ''; } if (!Array.isArray(prs)) return ''; const pr = prs.find((p) => String(p?.number || '') === String($data?.targetPrNumber || '')); return pr?.headRefName || '';})()", "isExpression": true }, "position": { @@ -2032,7 +2034,7 @@ "label": "Capture Base Branch", "config": { "key": "targetPrBase", - "value": "(() => { const raw = $ctx.getNodeOutput('list-prs')?.output || '[]'; let prs = []; try { prs = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { return 'main'; } if (!Array.isArray(prs)) return 'main'; const pr = prs.find((p) => String(p?.number || '') === String($data?.targetPrNumber || '')); return pr?.baseRefName || 'main';})()", + "value": "/* */ (() => { const raw = $ctx.getNodeOutput('list-prs')?.output || '[]'; let prs = []; try { prs = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { return 'main'; } if (!Array.isArray(prs)) return 'main'; const pr = prs.find((p) => String(p?.number || '') === String($data?.targetPrNumber || '')); return pr?.baseRefName || 'main';})()", "isExpression": true }, "position": { @@ -2633,7 +2635,7 @@ "type": "condition.expression", "label": "Bosun-Created PR?", "config": { - "expression": "(() => { if ($data?.requireBosunCreatedPr !== true && String($data?.requireBosunCreatedPr || '').toLowerCase() !== 'true') return true; const raw = $ctx.getNodeOutput('load-pr-context')?.output || '{}'; let pr = {}; try { pr = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { return false; } const labels = Array.isArray(pr?.labels) ? pr.labels.map((entry) => typeof entry === 'string' ? entry : entry?.name).filter(Boolean) : []; return labels.includes('bosun-pr-bosun-created'); })()" + "expression": "/* auto-created by bosun */ (() => { if ($data?.requireBosunCreatedPr !== true && String($data?.requireBosunCreatedPr || '').toLowerCase() !== 'true') return true; const raw = $ctx.getNodeOutput('load-pr-context')?.output || '{}'; let pr = {}; try { pr = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { return false; } const labels = Array.isArray(pr?.labels) ? pr.labels.map((entry) => typeof entry === 'string' ? entry : entry?.name).filter(Boolean) : []; const body = String(pr?.body || ''); return labels.includes('bosun-pr-bosun-created') || body.includes('') || /auto-created by bosun/i.test(body); })()" }, "position": { "x": 400, @@ -3613,7 +3615,7 @@ "type": "condition.expression", "label": "Detect Breaking Changes", "config": { - "expression": "(() => { const raw=$ctx.getNodeOutput('get-stats')?.output||'{}'; let stats={}; try{stats=typeof raw==='string'?JSON.parse(raw):raw;}catch{return false;} const title=String(stats?.title||'').toLowerCase(); const body=String(stats?.body||'').toLowerCase(); const files=Array.isArray(stats?.files)?stats.files.map((f)=>String(f?.path||f?.filename||f||'').toLowerCase()):[]; const text=title+'\\n'+body; const explicit=/\\bbreaking\\b|\\bbreaking change\\b|\\bmajor\\b|\\bbackward incompatible\\b/.test(text); const apiTouch=files.some((f)=>f.includes('api/')||f.includes('/proto/')||f.includes('openapi')||f.includes('schema')); const contractWords=/\\bremove\\b|\\brename\\b|\\bdeprecate\\b|\\bdrop\\b/.test(text); return explicit || (apiTouch && contractWords);})()" + "expression": "/* */ (() => { const raw=$ctx.getNodeOutput('get-stats')?.output||'{}'; let stats={}; try{stats=typeof raw==='string'?JSON.parse(raw):raw;}catch{return false;} const title=String(stats?.title||'').toLowerCase(); const body=String(stats?.body||'').toLowerCase(); const files=Array.isArray(stats?.files)?stats.files.map((f)=>String(f?.path||f?.filename||f||'').toLowerCase()):[]; const text=title+'\\n'+body; const explicit=/\\bbreaking\\b|\\bbreaking change\\b|\\bmajor\\b|\\bbackward incompatible\\b/.test(text); const apiTouch=files.some((f)=>f.includes('api/')||f.includes('/proto/')||f.includes('openapi')||f.includes('schema')); const contractWords=/\\bremove\\b|\\brename\\b|\\bdeprecate\\b|\\bdrop\\b/.test(text); return explicit || (apiTouch && contractWords);})()" }, "position": { "x": 400, @@ -4298,6 +4300,8 @@ "prompt": "# Merge Conflict Resolution\n\nYou are resolving merge conflicts in a git worktree.\n\n## Context\n- **Working directory**: `{{worktreePath}}`\n- **PR branch** (HEAD): `{{branch}}`\n- **Base branch** (incoming): `origin/{{baseBranch}}`\n- **PR**: #{{prNumber}}\n- **Task**: {{taskTitle}}\n\n## Conflicted files needing manual resolution:\n{{manualFiles}}\n\n## Instructions\n1. Read both sides of each conflict carefully\n2. Understand the INTENT of each change (feature vs upstream)\n3. Write a correct resolution that preserves both intents\n4. `git add` each resolved file\n5. Run `git commit --no-edit` to finalize the merge\n6. Do NOT use `--theirs` or `--ours` for code files\n7. Ensure no conflict markers (`<<<<<<<`, `=======`, `>>>>>>>`) remain", "sdk": "auto", "timeoutMs": "{{timeoutMs}}", + "delegationWatchdogTimeoutMs": "{{delegationWatchdogTimeoutMs}}", + "delegationWatchdogMaxRecoveries": "{{delegationWatchdogMaxRecoveries}}", "failOnError": true, "continueOnError": true }, @@ -22506,6 +22510,8 @@ "defaultSdk": "auto", "defaultTargetBranch": "origin/main", "taskTimeoutMs": 21600000, + "delegationWatchdogTimeoutMs": 300000, + "delegationWatchdogMaxRecoveries": 1, "prePrValidationEnabled": true, "prePrValidationCommand": "auto", "autoMergeOnCreate": false, @@ -22805,7 +22811,9 @@ "maxRetries": "{{maxRetries}}", "maxContinues": "{{maxContinues}}", "resolveMode": "library", - "failOnError": false + "failOnError": false, + "delegationWatchdogTimeoutMs": "{{delegationWatchdogTimeoutMs}}", + "delegationWatchdogMaxRecoveries": "{{delegationWatchdogMaxRecoveries}}" }, "position": { "x": 200, @@ -22830,7 +22838,9 @@ "maxRetries": "{{maxRetries}}", "maxContinues": "{{maxContinues}}", "resolveMode": "library", - "failOnError": false + "failOnError": false, + "delegationWatchdogTimeoutMs": "{{delegationWatchdogTimeoutMs}}", + "delegationWatchdogMaxRecoveries": "{{delegationWatchdogMaxRecoveries}}" }, "position": { "x": 200, @@ -22855,7 +22865,9 @@ "maxRetries": "{{maxRetries}}", "maxContinues": "{{maxContinues}}", "resolveMode": "library", - "failOnError": false + "failOnError": false, + "delegationWatchdogTimeoutMs": "{{delegationWatchdogTimeoutMs}}", + "delegationWatchdogMaxRecoveries": "{{delegationWatchdogMaxRecoveries}}" }, "position": { "x": 200, @@ -24798,7 +24810,7 @@ "label": "Normalize PR Context", "config": { "key": "prProgressContext", - "value": "(() => { const prOut = $ctx.getNodeOutput('create-pr') || $ctx.getNodeOutput('create-pr-retry') || {}; const prUrl = String($data?.prUrl || prOut?.prUrl || prOut?.url || '').trim(); const repoMatch = prUrl.match(/github\\.com\\/([^/]+\\/[^/?#]+)/i); const repo = String($data?.repo || (repoMatch ? repoMatch[1] : '')).trim(); const rawPrNumber = $data?.prNumber ?? prOut?.prNumber ?? null; const parsedPrNumber = Number.parseInt(String(rawPrNumber || ''), 10); return { taskId: String($data?.taskId || '').trim() || null, taskTitle: String($data?.taskTitle || '').trim() || null, repo: repo || null, branch: String($data?.branch || prOut?.branch || '').trim() || null, baseBranch: String($data?.baseBranch || prOut?.base || 'main').trim() || 'main', prNumber: Number.isFinite(parsedPrNumber) && parsedPrNumber > 0 ? parsedPrNumber : null, prUrl: prUrl || null, };})()", + "value": "/* */ (() => { const prOut = $ctx.getNodeOutput('create-pr') || $ctx.getNodeOutput('create-pr-retry') || {}; const prUrl = String($data?.prUrl || prOut?.prUrl || prOut?.url || '').trim(); const repoMatch = prUrl.match(/github\\.com\\/([^/]+\\/[^/?#]+)/i); const repo = String($data?.repo || (repoMatch ? repoMatch[1] : '')).trim(); const rawPrNumber = $data?.prNumber ?? prOut?.prNumber ?? null; const parsedPrNumber = Number.parseInt(String(rawPrNumber || ''), 10); return { taskId: String($data?.taskId || '').trim() || null, taskTitle: String($data?.taskTitle || '').trim() || null, repo: repo || null, branch: String($data?.branch || prOut?.branch || '').trim() || null, baseBranch: String($data?.baseBranch || prOut?.base || 'main').trim() || 'main', prNumber: Number.isFinite(parsedPrNumber) && parsedPrNumber > 0 ? parsedPrNumber : null, prUrl: prUrl || null, };})()", "isExpression": true }, "position": { @@ -24954,6 +24966,8 @@ "prompt": "You are a Bosun PR repair fallback agent working one PR only.\n\n## CRITICAL RULES — Read Before Doing Anything\n\nYour working directory is already a git clone of the target repo, checked out on the PR's HEAD branch (`{{setup-pr-worktree.output.branch}}`). The base branch (`{{setup-pr-worktree.output.base}}`) has been fetched.\n\n- Do NOT clone the repo. Do NOT create branches. Do NOT push.\n- Work ONLY in the current directory on the current branch.\n- The workflow will commit and push your changes automatically after you finish.\n\n## PR Context\n\n{{$ctx.getNodeOutput('inspect-pr')?.output}}\n\n## Repair Attempt Output\n\n{{$ctx.getNodeOutput('programmatic-fix')?.output}}\n\nUse prDigest.body, prDigest.files, prDigest.issueComments, prDigest.reviews, prDigest.reviewComments, prDigest.checks, failedAnnotations, and any failedLogExcerpt before making changes.\n\n## Fix Instructions\n\n- Only fix this PR's CI or merge-conflict issue.\n- For merge conflicts: `git merge origin/{{setup-pr-worktree.output.base}}` and resolve.\n- For CI failures: study the error output and apply the MINIMAL code fix.\n- Do not merge, approve, or close the PR.\n- Keep the patch minimal and scoped to the reported failure.\n- After fixing, remove the label:\n `gh pr edit {{setup-pr-worktree.output.number}} --repo {{setup-pr-worktree.output.repo}} --remove-label bosun-needs-fix`\n", "sdk": "auto", "timeoutMs": 1800000, + "delegationWatchdogTimeoutMs": "{{delegationWatchdogTimeoutMs}}", + "delegationWatchdogMaxRecoveries": "{{delegationWatchdogMaxRecoveries}}", "maxRetries": 2, "retryDelayMs": 30000, "continueOnError": true @@ -26595,7 +26609,7 @@ "label": "Pick Conflict PR", "config": { "key": "targetPrNumber", - "value": "(() => { const raw = $ctx.getNodeOutput('list-prs')?.output || '[]'; let prs = []; try { prs = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { return ''; } if (!Array.isArray(prs)) return ''; const CONFLICT = new Set(['CONFLICTING', 'BEHIND', 'DIRTY']); const BOSUN_CREATED_LABEL = 'bosun-pr-bosun-created'; const readLabelNames = (pr) => Array.isArray(pr?.labels) ? pr.labels.map((entry) => typeof entry === 'string' ? entry : entry?.name).filter(Boolean) : []; const isBosunCreated = (pr) => readLabelNames(pr).includes(BOSUN_CREATED_LABEL); /* Skip PRs already owned by the watchdog fix agent */ const pr = prs.find((p) => isBosunCreated(p) && CONFLICT.has(String(p?.mergeable || '').toUpperCase()) && !(p.labels || []).some((l) => l.name === 'bosun-needs-fix') ); return pr?.number ? String(pr.number) : '';})()", + "value": "/* */ (() => { const raw = $ctx.getNodeOutput('list-prs')?.output || '[]'; let prs = []; try { prs = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { return ''; } if (!Array.isArray(prs)) return ''; const CONFLICT = new Set(['CONFLICTING', 'BEHIND', 'DIRTY']); const BOSUN_CREATED_LABEL = 'bosun-pr-bosun-created'; const readLabelNames = (pr) => Array.isArray(pr?.labels) ? pr.labels.map((entry) => typeof entry === 'string' ? entry : entry?.name).filter(Boolean) : []; const isBosunCreated = (pr) => readLabelNames(pr).includes(BOSUN_CREATED_LABEL); /* Skip PRs already owned by the watchdog fix agent */ const pr = prs.find((p) => isBosunCreated(p) && CONFLICT.has(String(p?.mergeable || '').toUpperCase()) && !(p.labels || []).some((l) => l.name === 'bosun-needs-fix') ); return pr?.number ? String(pr.number) : '';})()", "isExpression": true }, "position": { @@ -26612,7 +26626,7 @@ "label": "Capture Conflict Branch", "config": { "key": "targetPrBranch", - "value": "(() => { const raw = $ctx.getNodeOutput('list-prs')?.output || '[]'; let prs = []; try { prs = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { return ''; } if (!Array.isArray(prs)) return ''; const pr = prs.find((p) => String(p?.number || '') === String($data?.targetPrNumber || '')); return pr?.headRefName || '';})()", + "value": "/* */ (() => { const raw = $ctx.getNodeOutput('list-prs')?.output || '[]'; let prs = []; try { prs = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { return ''; } if (!Array.isArray(prs)) return ''; const pr = prs.find((p) => String(p?.number || '') === String($data?.targetPrNumber || '')); return pr?.headRefName || '';})()", "isExpression": true }, "position": { @@ -26629,7 +26643,7 @@ "label": "Capture Base Branch", "config": { "key": "targetPrBase", - "value": "(() => { const raw = $ctx.getNodeOutput('list-prs')?.output || '[]'; let prs = []; try { prs = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { return 'main'; } if (!Array.isArray(prs)) return 'main'; const pr = prs.find((p) => String(p?.number || '') === String($data?.targetPrNumber || '')); return pr?.baseRefName || 'main';})()", + "value": "/* */ (() => { const raw = $ctx.getNodeOutput('list-prs')?.output || '[]'; let prs = []; try { prs = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { return 'main'; } if (!Array.isArray(prs)) return 'main'; const pr = prs.find((p) => String(p?.number || '') === String($data?.targetPrNumber || '')); return pr?.baseRefName || 'main';})()", "isExpression": true }, "position": { @@ -27189,7 +27203,7 @@ "type": "condition.expression", "label": "Bosun-Created PR?", "config": { - "expression": "(() => { if ($data?.requireBosunCreatedPr !== true && String($data?.requireBosunCreatedPr || '').toLowerCase() !== 'true') return true; const raw = $ctx.getNodeOutput('load-pr-context')?.output || '{}'; let pr = {}; try { pr = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { return false; } const labels = Array.isArray(pr?.labels) ? pr.labels.map((entry) => typeof entry === 'string' ? entry : entry?.name).filter(Boolean) : []; return labels.includes('bosun-pr-bosun-created'); })()" + "expression": "/* auto-created by bosun */ (() => { if ($data?.requireBosunCreatedPr !== true && String($data?.requireBosunCreatedPr || '').toLowerCase() !== 'true') return true; const raw = $ctx.getNodeOutput('load-pr-context')?.output || '{}'; let pr = {}; try { pr = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { return false; } const labels = Array.isArray(pr?.labels) ? pr.labels.map((entry) => typeof entry === 'string' ? entry : entry?.name).filter(Boolean) : []; const body = String(pr?.body || ''); return labels.includes('bosun-pr-bosun-created') || body.includes('') || /auto-created by bosun/i.test(body); })()" }, "position": { "x": 400, @@ -28128,7 +28142,7 @@ "type": "condition.expression", "label": "Detect Breaking Changes", "config": { - "expression": "(() => { const raw=$ctx.getNodeOutput('get-stats')?.output||'{}'; let stats={}; try{stats=typeof raw==='string'?JSON.parse(raw):raw;}catch{return false;} const title=String(stats?.title||'').toLowerCase(); const body=String(stats?.body||'').toLowerCase(); const files=Array.isArray(stats?.files)?stats.files.map((f)=>String(f?.path||f?.filename||f||'').toLowerCase()):[]; const text=title+'\\n'+body; const explicit=/\\bbreaking\\b|\\bbreaking change\\b|\\bmajor\\b|\\bbackward incompatible\\b/.test(text); const apiTouch=files.some((f)=>f.includes('api/')||f.includes('/proto/')||f.includes('openapi')||f.includes('schema')); const contractWords=/\\bremove\\b|\\brename\\b|\\bdeprecate\\b|\\bdrop\\b/.test(text); return explicit || (apiTouch && contractWords);})()" + "expression": "/* */ (() => { const raw=$ctx.getNodeOutput('get-stats')?.output||'{}'; let stats={}; try{stats=typeof raw==='string'?JSON.parse(raw):raw;}catch{return false;} const title=String(stats?.title||'').toLowerCase(); const body=String(stats?.body||'').toLowerCase(); const files=Array.isArray(stats?.files)?stats.files.map((f)=>String(f?.path||f?.filename||f||'').toLowerCase()):[]; const text=title+'\\n'+body; const explicit=/\\bbreaking\\b|\\bbreaking change\\b|\\bmajor\\b|\\bbackward incompatible\\b/.test(text); const apiTouch=files.some((f)=>f.includes('api/')||f.includes('/proto/')||f.includes('openapi')||f.includes('schema')); const contractWords=/\\bremove\\b|\\brename\\b|\\bdeprecate\\b|\\bdrop\\b/.test(text); return explicit || (apiTouch && contractWords);})()" }, "position": { "x": 400, @@ -28751,6 +28765,8 @@ "prompt": "# Merge Conflict Resolution\n\nYou are resolving merge conflicts in a git worktree.\n\n## Context\n- **Working directory**: `{{worktreePath}}`\n- **PR branch** (HEAD): `{{branch}}`\n- **Base branch** (incoming): `origin/{{baseBranch}}`\n- **PR**: #{{prNumber}}\n- **Task**: {{taskTitle}}\n\n## Conflicted files needing manual resolution:\n{{manualFiles}}\n\n## Instructions\n1. Read both sides of each conflict carefully\n2. Understand the INTENT of each change (feature vs upstream)\n3. Write a correct resolution that preserves both intents\n4. `git add` each resolved file\n5. Run `git commit --no-edit` to finalize the merge\n6. Do NOT use `--theirs` or `--ours` for code files\n7. Ensure no conflict markers (`<<<<<<<`, `=======`, `>>>>>>>`) remain", "sdk": "auto", "timeoutMs": "{{timeoutMs}}", + "delegationWatchdogTimeoutMs": "{{delegationWatchdogTimeoutMs}}", + "delegationWatchdogMaxRecoveries": "{{delegationWatchdogMaxRecoveries}}", "failOnError": true, "continueOnError": true }, @@ -46060,6 +46076,8 @@ "defaultSdk": "auto", "defaultTargetBranch": "origin/main", "taskTimeoutMs": 21600000, + "delegationWatchdogTimeoutMs": 300000, + "delegationWatchdogMaxRecoveries": 1, "prePrValidationEnabled": true, "prePrValidationCommand": "auto", "autoMergeOnCreate": false, @@ -46326,7 +46344,9 @@ "maxRetries": "{{maxRetries}}", "maxContinues": "{{maxContinues}}", "resolveMode": "library", - "failOnError": false + "failOnError": false, + "delegationWatchdogTimeoutMs": "{{delegationWatchdogTimeoutMs}}", + "delegationWatchdogMaxRecoveries": "{{delegationWatchdogMaxRecoveries}}" }, "position": { "x": 200, @@ -46351,7 +46371,9 @@ "maxRetries": "{{maxRetries}}", "maxContinues": "{{maxContinues}}", "resolveMode": "library", - "failOnError": false + "failOnError": false, + "delegationWatchdogTimeoutMs": "{{delegationWatchdogTimeoutMs}}", + "delegationWatchdogMaxRecoveries": "{{delegationWatchdogMaxRecoveries}}" }, "position": { "x": 200, @@ -46376,7 +46398,9 @@ "maxRetries": "{{maxRetries}}", "maxContinues": "{{maxContinues}}", "resolveMode": "library", - "failOnError": false + "failOnError": false, + "delegationWatchdogTimeoutMs": "{{delegationWatchdogTimeoutMs}}", + "delegationWatchdogMaxRecoveries": "{{delegationWatchdogMaxRecoveries}}" }, "position": { "x": 200, diff --git a/tests/workflow-engine.test.mjs b/tests/workflow-engine.test.mjs index d0c7b7e07..3eeccc5d1 100644 --- a/tests/workflow-engine.test.mjs +++ b/tests/workflow-engine.test.mjs @@ -1790,6 +1790,161 @@ describe("WorkflowEngine - run history details", () => { expect(resumedRun?.detail?.data?.prePrValidationCommand).toBe("auto"); }); + it("retries a stalled non-task delegation run exactly once during interrupted-run recovery", async () => { + const wf = makeSimpleWorkflow( + [{ id: "trigger", type: "trigger.manual", label: "Start", config: {} }], + [], + { id: "wf-stalled-delegation", name: "Stalled Delegation Workflow" }, + ); + engine.save(wf); + + const runsDir = join(tmpDir, "runs"); + const interruptedRunId = "run-stalled-delegation"; + + writeFileSync( + join(runsDir, "index.json"), + JSON.stringify({ + runs: [ + { + runId: interruptedRunId, + workflowId: wf.id, + workflowName: wf.name, + status: WorkflowStatus.PAUSED, + startedAt: 1000, + endedAt: null, + resumable: true, + }, + ], + }, null, 2), + "utf8", + ); + writeFileSync( + join(runsDir, `${interruptedRunId}.json`), + JSON.stringify({ + id: interruptedRunId, + startedAt: 1000, + endedAt: null, + data: { + _workflowId: wf.id, + _workflowName: wf.name, + delegationTarget: "template-bosun-pr-progressor", + _delegationWatchdog: { + nodeId: "handoff", + state: "delegated", + delegationType: "workflow", + taskScoped: false, + startedAt: 1000, + timeoutMs: 50, + }, + }, + nodeStatuses: { + trigger: NodeStatus.COMPLETED, + handoff: NodeStatus.RUNNING, + }, + nodeStatusEvents: [{ nodeId: "handoff", status: NodeStatus.RUNNING, timestamp: 1000 }], + logs: [], + errors: [], + }, null, 2), + "utf8", + ); + writeFileSync(join(runsDir, "_active-runs.json"), JSON.stringify([], null, 2), "utf8"); + + const retryRunSpy = vi.spyOn(engine, "retryRun").mockResolvedValue({ + retryRunId: "retry-stalled-delegation", + mode: "from_failed", + originalRunId: interruptedRunId, + ctx: { id: "retry-stalled-delegation" }, + }); + + await engine.resumeInterruptedRuns(); + + expect(retryRunSpy).toHaveBeenCalledTimes(1); + expect(retryRunSpy).toHaveBeenCalledWith(interruptedRunId, expect.objectContaining({ + mode: "from_failed", + _decisionReason: expect.stringContaining("delegation_watchdog"), + })); + const index = JSON.parse(readFileSync(join(runsDir, "index.json"), "utf8")); + const interrupted = index.runs.find((entry) => entry.runId === interruptedRunId); + expect(interrupted?.resumable).toBe(false); + expect(interrupted?.resumeResult).toBe("resumed"); + }); + + it("marks a repeatedly stalled non-task delegation run unresumable without looping", async () => { + const wf = makeSimpleWorkflow( + [{ id: "trigger", type: "trigger.manual", label: "Start", config: {} }], + [], + { id: "wf-stalled-delegation-loop", name: "Stalled Delegation Loop Workflow" }, + ); + engine.save(wf); + + const runsDir = join(tmpDir, "runs"); + const interruptedRunId = "run-stalled-delegation-loop"; + + writeFileSync( + join(runsDir, "index.json"), + JSON.stringify({ + runs: [ + { + runId: interruptedRunId, + workflowId: wf.id, + workflowName: wf.name, + status: WorkflowStatus.PAUSED, + startedAt: 1000, + endedAt: null, + resumable: true, + }, + ], + }, null, 2), + "utf8", + ); + writeFileSync( + join(runsDir, `${interruptedRunId}.json`), + JSON.stringify({ + id: interruptedRunId, + startedAt: 1000, + endedAt: null, + data: { + _workflowId: wf.id, + _workflowName: wf.name, + _delegationWatchdog: { + nodeId: "handoff", + state: "delegated", + delegationType: "workflow", + taskScoped: false, + startedAt: 1000, + timeoutMs: 50, + recoveryAttempted: true, + }, + }, + nodeStatuses: { + trigger: NodeStatus.COMPLETED, + handoff: NodeStatus.RUNNING, + }, + nodeStatusEvents: [{ nodeId: "handoff", status: NodeStatus.RUNNING, timestamp: 1000 }], + logs: [], + errors: [], + }, null, 2), + "utf8", + ); + writeFileSync(join(runsDir, "_active-runs.json"), JSON.stringify([], null, 2), "utf8"); + + const retryRunSpy = vi.spyOn(engine, "retryRun").mockResolvedValue({ + retryRunId: "retry-stalled-delegation-loop", + mode: "from_scratch", + originalRunId: interruptedRunId, + ctx: { id: "retry-stalled-delegation-loop" }, + }); + + await engine.resumeInterruptedRuns(); + + expect(retryRunSpy).not.toHaveBeenCalled(); + + const index = JSON.parse(readFileSync(join(runsDir, "index.json"), "utf8")); + const interrupted = index.runs.find((entry) => entry.runId === interruptedRunId); + expect(interrupted?.resumable).toBe(false); + expect(interrupted?.resumeResult).toContain("delegation_watchdog_exhausted"); + }); + it("resumes interrupted runs from_scratch when issue-advisor requests replanning", async () => { const wf = makeSimpleWorkflow( [{ id: "trigger", type: "trigger.manual", label: "Start", config: {} }], @@ -4724,7 +4879,7 @@ describe("Session chaining - action.run_agent", () => { const node = { id: "delegated-session-node", type: "action.run_agent", - config: { prompt: "Handle task via delegated workflow" }, + config: { prompt: "Handle task via delegated workflow", failOnError: true }, }; const result = await handler.execute(node, ctx, mockEngine); @@ -4736,6 +4891,181 @@ describe("Session chaining - action.run_agent", () => { expect(session).toBeTruthy(); expect(session.type).toBe("task"); expect(session.status).toBe("completed"); + expect(session.metadata.branch).toBe("feat/backend-migration"); + + const messages = Array.isArray(session.messages) ? session.messages : []; + expect(messages.some((msg) => String(msg?.content || "").includes("Delegating to agent workflow"))).toBe(true); + }); + + it("marks stalled delegated workflows retryable and recovers only once", async () => { + const handler = getNodeType("action.run_agent"); + expect(handler).toBeDefined(); + + const ctx = new WorkflowContext({ + taskId: "TASK-WATCHDOG-1", + taskTitle: "Backend watchdog test", + workspaceId: "virtengine-gh", + task: { id: "TASK-WATCHDOG-1", title: "Backend watchdog test", tags: ["backend"] }, + delegationWatchdogTimeoutMs: 25, + }); + + const stalledRun = { + runId: "delegated-stalled-run", + status: WorkflowStatus.RUNNING, + isStuck: true, + stuckMs: 50, + }; + + const mockEngine = { + list: vi.fn().mockReturnValue([ + { + id: "wf-non-task-delegate", + name: "Generic Delegate", + enabled: true, + metadata: { replaces: { module: "primary-agent.mjs" } }, + nodes: [{ id: "trigger", type: "trigger.task_assigned", config: { taskPattern: "backend" } }], + }, + ]), + execute: vi.fn() + .mockResolvedValueOnce({ runId: stalledRun.runId, status: WorkflowStatus.RUNNING, delegated: true }) + .mockResolvedValueOnce({ runId: "delegated-retry-run", status: WorkflowStatus.COMPLETED, delegated: true, outputs: { ok: true } }), + getRunHistory: vi.fn().mockReturnValue([stalledRun]), + services: { + agentPool: { + launchEphemeralThread: vi.fn(), + }, + }, + }; + + const node = { + id: "delegated-watchdog-node", + type: "action.run_agent", + config: { + prompt: "Handle generic delegated workflow", + failOnError: false, + delegationWatchdogTimeoutMs: 25, + }, + }; + + const result = await handler.execute(node, ctx, mockEngine); + expect(result.success).toBe(true); + expect(result.delegated).toBe(true); + expect(result.recoveredFromStall).toBe(true); + expect(result.watchdogRecovered).toBe(true); + expect(result.watchdogRetryCount).toBe(1); + expect(mockEngine.execute).toHaveBeenCalledTimes(2); + }); + + it("does not retry delegated workflows more than once after watchdog recovery", async () => { + const handler = getNodeType("action.run_agent"); + expect(handler).toBeDefined(); + + const ctx = new WorkflowContext({ + taskId: "TASK-WATCHDOG-2", + taskTitle: "Backend watchdog loop test", + task: { id: "TASK-WATCHDOG-2", title: "Backend watchdog loop test", tags: ["backend"] }, + workspaceId: "virtengine-gh", + }); + + const mockEngine = { + list: vi.fn().mockReturnValue([ + { + id: "wf-non-task-delegate-loop", + name: "Generic Delegate", + enabled: true, + metadata: { replaces: { module: "primary-agent.mjs" } }, + nodes: [{ id: "trigger", type: "trigger.task_assigned", config: { taskPattern: "backend" } }], + }, + ]), + execute: vi.fn() + .mockResolvedValueOnce({ runId: "delegated-stalled-1", status: WorkflowStatus.RUNNING, delegated: true }) + .mockResolvedValueOnce({ runId: "delegated-stalled-2", status: WorkflowStatus.RUNNING, delegated: true }), + getRunHistory: vi.fn() + .mockReturnValueOnce([{ runId: "delegated-stalled-1", status: WorkflowStatus.RUNNING, isStuck: true, stuckMs: 75 }]) + .mockReturnValueOnce([{ runId: "delegated-stalled-2", status: WorkflowStatus.RUNNING, isStuck: true, stuckMs: 75 }]), + services: { + agentPool: { + launchEphemeralThread: vi.fn(), + }, + }, + }; + + const node = { + id: "delegated-watchdog-node-once", + type: "action.run_agent", + config: { + prompt: "Handle generic delegated workflow", + failOnError: false, + delegationWatchdogTimeoutMs: 25, + }, + }; + + const result = await handler.execute(node, ctx, mockEngine); + expect(result.success).toBe(false); + expect(result.retryable).toBe(true); + expect(result.failureKind).toBe("stalled_delegation"); + expect(result.watchdogRetryCount).toBe(1); + expect(mockEngine.execute).toHaveBeenCalledTimes(2); + }); + + it("records delegated session as completed when child workflow succeeds", async () => { + const handler = getNodeType("action.run_agent"); + expect(handler).toBeDefined(); + + const ctx = new WorkflowContext({ + taskId: "TASK-DELEGATE-COMPLETED", + taskTitle: "Backend migration completed", + workspaceId: "virtengine-gh", + task: { + id: "TASK-DELEGATE-COMPLETED", + title: "Backend migration completed", + tags: ["backend"], + branchName: "feat/backend-migration", + }, + }); + + const mockEngine = { + list: vi.fn().mockReturnValue([ + { + id: "wf-backend-completed", + name: "Backend Agent", + enabled: true, + metadata: { replaces: { module: "primary-agent.mjs" } }, + nodes: [ + { + id: "trigger", + type: "trigger.task_assigned", + config: { + taskPattern: "backend", + filter: "task.tags?.includes('backend')", + }, + }, + ], + }, + ]), + execute: vi.fn().mockResolvedValue({ errors: [] }), + services: { + agentPool: { + launchEphemeralThread: vi.fn(), + }, + }, + }; + + const node = { + id: "delegated-session-node-completed", + type: "action.run_agent", + config: { prompt: "Handle task via delegated workflow" }, + }; + + const result = await handler.execute(node, ctx, mockEngine); + expect(result.success).toBe(true); + expect(result.delegated).toBe(true); + + const tracker = getSessionTracker(); + const session = tracker.getSessionById("TASK-DELEGATE-COMPLETED"); + expect(session).toBeTruthy(); + expect(session.type).toBe("task"); + expect(session.status).toBe("completed"); expect(session.metadata.workspaceId).toBe("virtengine-gh"); expect(session.metadata.branch).toBe("feat/backend-migration"); diff --git a/tests/workflow-run-history-ui-regression.test.mjs b/tests/workflow-run-history-ui-regression.test.mjs index 261b56ad5..a82653ba4 100644 --- a/tests/workflow-run-history-ui-regression.test.mjs +++ b/tests/workflow-run-history-ui-regression.test.mjs @@ -50,8 +50,8 @@ describe("workflow run history UI pagination", () => { it(`${label} exposes DAG revision history in run details`, () => { if (label !== "ui") return; expect(source).toContain("DAG Revisions"); - expect(source).toContain("graphBefore"); - expect(source).toContain("graphAfter"); + expect(source).toContain("Graph Before:"); + expect(source).toContain("Graph After:"); }); it(`${label} exposes explicit edge port mapping controls`, () => { @@ -60,8 +60,8 @@ describe("workflow run history UI pagination", () => { expect(source).toContain("Source Port"); expect(source).toContain("Target Port"); expect(source).toContain("updateEdgePortMapping"); - expect(source).toContain("Unknown output port"); - expect(source).toContain("Unknown input port"); + expect(source).toContain("Select source port"); + expect(source).toContain("Select target port"); }); } diff --git a/tests/workflow-task-lifecycle.test.mjs b/tests/workflow-task-lifecycle.test.mjs index 86a6061d4..48308dfba 100644 --- a/tests/workflow-task-lifecycle.test.mjs +++ b/tests/workflow-task-lifecycle.test.mjs @@ -3745,11 +3745,18 @@ describe("template-task-lifecycle", () => { maxParallel: 5, taskTimeoutMs: 3600000, }); + expect(result.variables.maxParallel).toBe(5); expect(result.variables.taskTimeoutMs).toBe(3600000); expect(result.variables.defaultSdk).toBe("auto"); // unchanged }); + it("installs delegation watchdog defaults for non-task recovery", () => { + const result = installTemplate("template-task-lifecycle", engine); + expect(result.variables.delegationWatchdogTimeoutMs).toBeGreaterThan(0); + expect(result.variables.delegationWatchdogMaxRecoveries).toBe(1); + }); + it("dry-run executes without errors (trigger stops at no kanban)", async () => { const result = installTemplate("template-task-lifecycle", engine); const ctx = new WorkflowContext({}); diff --git a/tests/workflow-templates-e2e.test.mjs b/tests/workflow-templates-e2e.test.mjs index 0509a3531..17d9572eb 100644 --- a/tests/workflow-templates-e2e.test.mjs +++ b/tests/workflow-templates-e2e.test.mjs @@ -41,6 +41,29 @@ vi.mock("node:child_process", async (importOriginal) => { if (/grep|find|ls|dir|cat|type/i.test(cmd)) return ""; return ""; }), + execFileSync: vi.fn((file, args = []) => { + const argv = Array.isArray(args) ? args.map((value) => String(value)) : []; + const joined = [String(file || ""), ...argv].join(" "); + if (/\bgh\b/i.test(String(file || ""))) { + if (/\bpr\s+list\b/i.test(joined)) return "[]"; + if (/\bpr\s+view\b/i.test(joined)) return '{"number":1,"title":"mock","mergeable":"MERGEABLE","labels":[]}'; + if (/\bpr\s+merge\b/i.test(joined)) return "merged"; + if (/\brelease\b/i.test(joined)) return '{"tag_name":"v0.0.0"}'; + if (/\bissue\b/i.test(joined)) return "[]"; + if (/\bapi\b/i.test(joined)) return "[]"; + } + if (/\bnode\b/i.test(String(file || "")) && argv[0] === "-e") { + return '{"rerunRequested":0,"branchUpdated":0,"ciFailureCount":0,"conflictCount":0,"needsAgentCount":0,"needsAgent":[]}'; + } + if (/\bnpm\b/i.test(String(file || ""))) { + if (/\bbuild\b/i.test(joined)) return "build ok"; + if (/\btest\b/i.test(joined)) return "tests ok"; + if (/\blint\b/i.test(joined)) return "lint ok"; + if (/\baudit\b/i.test(joined)) return '{"vulnerabilities":{}}'; + } + if (/\bgit\b/i.test(String(file || ""))) return ""; + return ""; + }), spawn: vi.fn(() => { const proc = new EventEmitter(); proc.stdout = new EventEmitter(); diff --git a/ui/demo-defaults.js b/ui/demo-defaults.js index 82c09bd48..db0167409 100644 --- a/ui/demo-defaults.js +++ b/ui/demo-defaults.js @@ -99,7 +99,7 @@ "label": "Normalize PR Context", "config": { "key": "prProgressContext", - "value": "(() => { const prOut = $ctx.getNodeOutput('create-pr') || $ctx.getNodeOutput('create-pr-retry') || {}; const prUrl = String($data?.prUrl || prOut?.prUrl || prOut?.url || '').trim(); const repoMatch = prUrl.match(/github\\.com\\/([^/]+\\/[^/?#]+)/i); const repo = String($data?.repo || (repoMatch ? repoMatch[1] : '')).trim(); const rawPrNumber = $data?.prNumber ?? prOut?.prNumber ?? null; const parsedPrNumber = Number.parseInt(String(rawPrNumber || ''), 10); return { taskId: String($data?.taskId || '').trim() || null, taskTitle: String($data?.taskTitle || '').trim() || null, repo: repo || null, branch: String($data?.branch || prOut?.branch || '').trim() || null, baseBranch: String($data?.baseBranch || prOut?.base || 'main').trim() || 'main', prNumber: Number.isFinite(parsedPrNumber) && parsedPrNumber > 0 ? parsedPrNumber : null, prUrl: prUrl || null, };})()", + "value": "/* */ (() => { const prOut = $ctx.getNodeOutput('create-pr') || $ctx.getNodeOutput('create-pr-retry') || {}; const prUrl = String($data?.prUrl || prOut?.prUrl || prOut?.url || '').trim(); const repoMatch = prUrl.match(/github\\.com\\/([^/]+\\/[^/?#]+)/i); const repo = String($data?.repo || (repoMatch ? repoMatch[1] : '')).trim(); const rawPrNumber = $data?.prNumber ?? prOut?.prNumber ?? null; const parsedPrNumber = Number.parseInt(String(rawPrNumber || ''), 10); return { taskId: String($data?.taskId || '').trim() || null, taskTitle: String($data?.taskTitle || '').trim() || null, repo: repo || null, branch: String($data?.branch || prOut?.branch || '').trim() || null, baseBranch: String($data?.baseBranch || prOut?.base || 'main').trim() || 'main', prNumber: Number.isFinite(parsedPrNumber) && parsedPrNumber > 0 ? parsedPrNumber : null, prUrl: prUrl || null, };})()", "isExpression": true }, "position": { @@ -255,6 +255,8 @@ "prompt": "You are a Bosun PR repair fallback agent working one PR only.\n\n## CRITICAL RULES — Read Before Doing Anything\n\nYour working directory is already a git clone of the target repo, checked out on the PR's HEAD branch (`{{setup-pr-worktree.output.branch}}`). The base branch (`{{setup-pr-worktree.output.base}}`) has been fetched.\n\n- Do NOT clone the repo. Do NOT create branches. Do NOT push.\n- Work ONLY in the current directory on the current branch.\n- The workflow will commit and push your changes automatically after you finish.\n\n## PR Context\n\n{{$ctx.getNodeOutput('inspect-pr')?.output}}\n\n## Repair Attempt Output\n\n{{$ctx.getNodeOutput('programmatic-fix')?.output}}\n\nUse prDigest.body, prDigest.files, prDigest.issueComments, prDigest.reviews, prDigest.reviewComments, prDigest.checks, failedAnnotations, and any failedLogExcerpt before making changes.\n\n## Fix Instructions\n\n- Only fix this PR's CI or merge-conflict issue.\n- For merge conflicts: `git merge origin/{{setup-pr-worktree.output.base}}` and resolve.\n- For CI failures: study the error output and apply the MINIMAL code fix.\n- Do not merge, approve, or close the PR.\n- Keep the patch minimal and scoped to the reported failure.\n- After fixing, remove the label:\n `gh pr edit {{setup-pr-worktree.output.number}} --repo {{setup-pr-worktree.output.repo}} --remove-label bosun-needs-fix`\n", "sdk": "auto", "timeoutMs": 1800000, + "delegationWatchdogTimeoutMs": "{{delegationWatchdogTimeoutMs}}", + "delegationWatchdogMaxRecoveries": "{{delegationWatchdogMaxRecoveries}}", "maxRetries": 2, "retryDelayMs": 30000, "continueOnError": true @@ -1998,7 +2000,7 @@ "label": "Pick Conflict PR", "config": { "key": "targetPrNumber", - "value": "(() => { const raw = $ctx.getNodeOutput('list-prs')?.output || '[]'; let prs = []; try { prs = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { return ''; } if (!Array.isArray(prs)) return ''; const CONFLICT = new Set(['CONFLICTING', 'BEHIND', 'DIRTY']); const BOSUN_CREATED_LABEL = 'bosun-pr-bosun-created'; const readLabelNames = (pr) => Array.isArray(pr?.labels) ? pr.labels.map((entry) => typeof entry === 'string' ? entry : entry?.name).filter(Boolean) : []; const isBosunCreated = (pr) => readLabelNames(pr).includes(BOSUN_CREATED_LABEL); /* Skip PRs already owned by the watchdog fix agent */ const pr = prs.find((p) => isBosunCreated(p) && CONFLICT.has(String(p?.mergeable || '').toUpperCase()) && !(p.labels || []).some((l) => l.name === 'bosun-needs-fix') ); return pr?.number ? String(pr.number) : '';})()", + "value": "/* */ (() => { const raw = $ctx.getNodeOutput('list-prs')?.output || '[]'; let prs = []; try { prs = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { return ''; } if (!Array.isArray(prs)) return ''; const CONFLICT = new Set(['CONFLICTING', 'BEHIND', 'DIRTY']); const BOSUN_CREATED_LABEL = 'bosun-pr-bosun-created'; const readLabelNames = (pr) => Array.isArray(pr?.labels) ? pr.labels.map((entry) => typeof entry === 'string' ? entry : entry?.name).filter(Boolean) : []; const isBosunCreated = (pr) => readLabelNames(pr).includes(BOSUN_CREATED_LABEL); /* Skip PRs already owned by the watchdog fix agent */ const pr = prs.find((p) => isBosunCreated(p) && CONFLICT.has(String(p?.mergeable || '').toUpperCase()) && !(p.labels || []).some((l) => l.name === 'bosun-needs-fix') ); return pr?.number ? String(pr.number) : '';})()", "isExpression": true }, "position": { @@ -2015,7 +2017,7 @@ "label": "Capture Conflict Branch", "config": { "key": "targetPrBranch", - "value": "(() => { const raw = $ctx.getNodeOutput('list-prs')?.output || '[]'; let prs = []; try { prs = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { return ''; } if (!Array.isArray(prs)) return ''; const pr = prs.find((p) => String(p?.number || '') === String($data?.targetPrNumber || '')); return pr?.headRefName || '';})()", + "value": "/* */ (() => { const raw = $ctx.getNodeOutput('list-prs')?.output || '[]'; let prs = []; try { prs = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { return ''; } if (!Array.isArray(prs)) return ''; const pr = prs.find((p) => String(p?.number || '') === String($data?.targetPrNumber || '')); return pr?.headRefName || '';})()", "isExpression": true }, "position": { @@ -2032,7 +2034,7 @@ "label": "Capture Base Branch", "config": { "key": "targetPrBase", - "value": "(() => { const raw = $ctx.getNodeOutput('list-prs')?.output || '[]'; let prs = []; try { prs = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { return 'main'; } if (!Array.isArray(prs)) return 'main'; const pr = prs.find((p) => String(p?.number || '') === String($data?.targetPrNumber || '')); return pr?.baseRefName || 'main';})()", + "value": "/* */ (() => { const raw = $ctx.getNodeOutput('list-prs')?.output || '[]'; let prs = []; try { prs = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { return 'main'; } if (!Array.isArray(prs)) return 'main'; const pr = prs.find((p) => String(p?.number || '') === String($data?.targetPrNumber || '')); return pr?.baseRefName || 'main';})()", "isExpression": true }, "position": { @@ -2633,7 +2635,7 @@ "type": "condition.expression", "label": "Bosun-Created PR?", "config": { - "expression": "(() => { if ($data?.requireBosunCreatedPr !== true && String($data?.requireBosunCreatedPr || '').toLowerCase() !== 'true') return true; const raw = $ctx.getNodeOutput('load-pr-context')?.output || '{}'; let pr = {}; try { pr = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { return false; } const labels = Array.isArray(pr?.labels) ? pr.labels.map((entry) => typeof entry === 'string' ? entry : entry?.name).filter(Boolean) : []; return labels.includes('bosun-pr-bosun-created'); })()" + "expression": "/* auto-created by bosun */ (() => { if ($data?.requireBosunCreatedPr !== true && String($data?.requireBosunCreatedPr || '').toLowerCase() !== 'true') return true; const raw = $ctx.getNodeOutput('load-pr-context')?.output || '{}'; let pr = {}; try { pr = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { return false; } const labels = Array.isArray(pr?.labels) ? pr.labels.map((entry) => typeof entry === 'string' ? entry : entry?.name).filter(Boolean) : []; const body = String(pr?.body || ''); return labels.includes('bosun-pr-bosun-created') || body.includes('') || /auto-created by bosun/i.test(body); })()" }, "position": { "x": 400, @@ -3613,7 +3615,7 @@ "type": "condition.expression", "label": "Detect Breaking Changes", "config": { - "expression": "(() => { const raw=$ctx.getNodeOutput('get-stats')?.output||'{}'; let stats={}; try{stats=typeof raw==='string'?JSON.parse(raw):raw;}catch{return false;} const title=String(stats?.title||'').toLowerCase(); const body=String(stats?.body||'').toLowerCase(); const files=Array.isArray(stats?.files)?stats.files.map((f)=>String(f?.path||f?.filename||f||'').toLowerCase()):[]; const text=title+'\\n'+body; const explicit=/\\bbreaking\\b|\\bbreaking change\\b|\\bmajor\\b|\\bbackward incompatible\\b/.test(text); const apiTouch=files.some((f)=>f.includes('api/')||f.includes('/proto/')||f.includes('openapi')||f.includes('schema')); const contractWords=/\\bremove\\b|\\brename\\b|\\bdeprecate\\b|\\bdrop\\b/.test(text); return explicit || (apiTouch && contractWords);})()" + "expression": "/* */ (() => { const raw=$ctx.getNodeOutput('get-stats')?.output||'{}'; let stats={}; try{stats=typeof raw==='string'?JSON.parse(raw):raw;}catch{return false;} const title=String(stats?.title||'').toLowerCase(); const body=String(stats?.body||'').toLowerCase(); const files=Array.isArray(stats?.files)?stats.files.map((f)=>String(f?.path||f?.filename||f||'').toLowerCase()):[]; const text=title+'\\n'+body; const explicit=/\\bbreaking\\b|\\bbreaking change\\b|\\bmajor\\b|\\bbackward incompatible\\b/.test(text); const apiTouch=files.some((f)=>f.includes('api/')||f.includes('/proto/')||f.includes('openapi')||f.includes('schema')); const contractWords=/\\bremove\\b|\\brename\\b|\\bdeprecate\\b|\\bdrop\\b/.test(text); return explicit || (apiTouch && contractWords);})()" }, "position": { "x": 400, @@ -4298,6 +4300,8 @@ "prompt": "# Merge Conflict Resolution\n\nYou are resolving merge conflicts in a git worktree.\n\n## Context\n- **Working directory**: `{{worktreePath}}`\n- **PR branch** (HEAD): `{{branch}}`\n- **Base branch** (incoming): `origin/{{baseBranch}}`\n- **PR**: #{{prNumber}}\n- **Task**: {{taskTitle}}\n\n## Conflicted files needing manual resolution:\n{{manualFiles}}\n\n## Instructions\n1. Read both sides of each conflict carefully\n2. Understand the INTENT of each change (feature vs upstream)\n3. Write a correct resolution that preserves both intents\n4. `git add` each resolved file\n5. Run `git commit --no-edit` to finalize the merge\n6. Do NOT use `--theirs` or `--ours` for code files\n7. Ensure no conflict markers (`<<<<<<<`, `=======`, `>>>>>>>`) remain", "sdk": "auto", "timeoutMs": "{{timeoutMs}}", + "delegationWatchdogTimeoutMs": "{{delegationWatchdogTimeoutMs}}", + "delegationWatchdogMaxRecoveries": "{{delegationWatchdogMaxRecoveries}}", "failOnError": true, "continueOnError": true }, @@ -22506,6 +22510,8 @@ "defaultSdk": "auto", "defaultTargetBranch": "origin/main", "taskTimeoutMs": 21600000, + "delegationWatchdogTimeoutMs": 300000, + "delegationWatchdogMaxRecoveries": 1, "prePrValidationEnabled": true, "prePrValidationCommand": "auto", "autoMergeOnCreate": false, @@ -22805,7 +22811,9 @@ "maxRetries": "{{maxRetries}}", "maxContinues": "{{maxContinues}}", "resolveMode": "library", - "failOnError": false + "failOnError": false, + "delegationWatchdogTimeoutMs": "{{delegationWatchdogTimeoutMs}}", + "delegationWatchdogMaxRecoveries": "{{delegationWatchdogMaxRecoveries}}" }, "position": { "x": 200, @@ -22830,7 +22838,9 @@ "maxRetries": "{{maxRetries}}", "maxContinues": "{{maxContinues}}", "resolveMode": "library", - "failOnError": false + "failOnError": false, + "delegationWatchdogTimeoutMs": "{{delegationWatchdogTimeoutMs}}", + "delegationWatchdogMaxRecoveries": "{{delegationWatchdogMaxRecoveries}}" }, "position": { "x": 200, @@ -22855,7 +22865,9 @@ "maxRetries": "{{maxRetries}}", "maxContinues": "{{maxContinues}}", "resolveMode": "library", - "failOnError": false + "failOnError": false, + "delegationWatchdogTimeoutMs": "{{delegationWatchdogTimeoutMs}}", + "delegationWatchdogMaxRecoveries": "{{delegationWatchdogMaxRecoveries}}" }, "position": { "x": 200, @@ -24798,7 +24810,7 @@ "label": "Normalize PR Context", "config": { "key": "prProgressContext", - "value": "(() => { const prOut = $ctx.getNodeOutput('create-pr') || $ctx.getNodeOutput('create-pr-retry') || {}; const prUrl = String($data?.prUrl || prOut?.prUrl || prOut?.url || '').trim(); const repoMatch = prUrl.match(/github\\.com\\/([^/]+\\/[^/?#]+)/i); const repo = String($data?.repo || (repoMatch ? repoMatch[1] : '')).trim(); const rawPrNumber = $data?.prNumber ?? prOut?.prNumber ?? null; const parsedPrNumber = Number.parseInt(String(rawPrNumber || ''), 10); return { taskId: String($data?.taskId || '').trim() || null, taskTitle: String($data?.taskTitle || '').trim() || null, repo: repo || null, branch: String($data?.branch || prOut?.branch || '').trim() || null, baseBranch: String($data?.baseBranch || prOut?.base || 'main').trim() || 'main', prNumber: Number.isFinite(parsedPrNumber) && parsedPrNumber > 0 ? parsedPrNumber : null, prUrl: prUrl || null, };})()", + "value": "/* */ (() => { const prOut = $ctx.getNodeOutput('create-pr') || $ctx.getNodeOutput('create-pr-retry') || {}; const prUrl = String($data?.prUrl || prOut?.prUrl || prOut?.url || '').trim(); const repoMatch = prUrl.match(/github\\.com\\/([^/]+\\/[^/?#]+)/i); const repo = String($data?.repo || (repoMatch ? repoMatch[1] : '')).trim(); const rawPrNumber = $data?.prNumber ?? prOut?.prNumber ?? null; const parsedPrNumber = Number.parseInt(String(rawPrNumber || ''), 10); return { taskId: String($data?.taskId || '').trim() || null, taskTitle: String($data?.taskTitle || '').trim() || null, repo: repo || null, branch: String($data?.branch || prOut?.branch || '').trim() || null, baseBranch: String($data?.baseBranch || prOut?.base || 'main').trim() || 'main', prNumber: Number.isFinite(parsedPrNumber) && parsedPrNumber > 0 ? parsedPrNumber : null, prUrl: prUrl || null, };})()", "isExpression": true }, "position": { @@ -24954,6 +24966,8 @@ "prompt": "You are a Bosun PR repair fallback agent working one PR only.\n\n## CRITICAL RULES — Read Before Doing Anything\n\nYour working directory is already a git clone of the target repo, checked out on the PR's HEAD branch (`{{setup-pr-worktree.output.branch}}`). The base branch (`{{setup-pr-worktree.output.base}}`) has been fetched.\n\n- Do NOT clone the repo. Do NOT create branches. Do NOT push.\n- Work ONLY in the current directory on the current branch.\n- The workflow will commit and push your changes automatically after you finish.\n\n## PR Context\n\n{{$ctx.getNodeOutput('inspect-pr')?.output}}\n\n## Repair Attempt Output\n\n{{$ctx.getNodeOutput('programmatic-fix')?.output}}\n\nUse prDigest.body, prDigest.files, prDigest.issueComments, prDigest.reviews, prDigest.reviewComments, prDigest.checks, failedAnnotations, and any failedLogExcerpt before making changes.\n\n## Fix Instructions\n\n- Only fix this PR's CI or merge-conflict issue.\n- For merge conflicts: `git merge origin/{{setup-pr-worktree.output.base}}` and resolve.\n- For CI failures: study the error output and apply the MINIMAL code fix.\n- Do not merge, approve, or close the PR.\n- Keep the patch minimal and scoped to the reported failure.\n- After fixing, remove the label:\n `gh pr edit {{setup-pr-worktree.output.number}} --repo {{setup-pr-worktree.output.repo}} --remove-label bosun-needs-fix`\n", "sdk": "auto", "timeoutMs": 1800000, + "delegationWatchdogTimeoutMs": "{{delegationWatchdogTimeoutMs}}", + "delegationWatchdogMaxRecoveries": "{{delegationWatchdogMaxRecoveries}}", "maxRetries": 2, "retryDelayMs": 30000, "continueOnError": true @@ -26595,7 +26609,7 @@ "label": "Pick Conflict PR", "config": { "key": "targetPrNumber", - "value": "(() => { const raw = $ctx.getNodeOutput('list-prs')?.output || '[]'; let prs = []; try { prs = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { return ''; } if (!Array.isArray(prs)) return ''; const CONFLICT = new Set(['CONFLICTING', 'BEHIND', 'DIRTY']); const BOSUN_CREATED_LABEL = 'bosun-pr-bosun-created'; const readLabelNames = (pr) => Array.isArray(pr?.labels) ? pr.labels.map((entry) => typeof entry === 'string' ? entry : entry?.name).filter(Boolean) : []; const isBosunCreated = (pr) => readLabelNames(pr).includes(BOSUN_CREATED_LABEL); /* Skip PRs already owned by the watchdog fix agent */ const pr = prs.find((p) => isBosunCreated(p) && CONFLICT.has(String(p?.mergeable || '').toUpperCase()) && !(p.labels || []).some((l) => l.name === 'bosun-needs-fix') ); return pr?.number ? String(pr.number) : '';})()", + "value": "/* */ (() => { const raw = $ctx.getNodeOutput('list-prs')?.output || '[]'; let prs = []; try { prs = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { return ''; } if (!Array.isArray(prs)) return ''; const CONFLICT = new Set(['CONFLICTING', 'BEHIND', 'DIRTY']); const BOSUN_CREATED_LABEL = 'bosun-pr-bosun-created'; const readLabelNames = (pr) => Array.isArray(pr?.labels) ? pr.labels.map((entry) => typeof entry === 'string' ? entry : entry?.name).filter(Boolean) : []; const isBosunCreated = (pr) => readLabelNames(pr).includes(BOSUN_CREATED_LABEL); /* Skip PRs already owned by the watchdog fix agent */ const pr = prs.find((p) => isBosunCreated(p) && CONFLICT.has(String(p?.mergeable || '').toUpperCase()) && !(p.labels || []).some((l) => l.name === 'bosun-needs-fix') ); return pr?.number ? String(pr.number) : '';})()", "isExpression": true }, "position": { @@ -26612,7 +26626,7 @@ "label": "Capture Conflict Branch", "config": { "key": "targetPrBranch", - "value": "(() => { const raw = $ctx.getNodeOutput('list-prs')?.output || '[]'; let prs = []; try { prs = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { return ''; } if (!Array.isArray(prs)) return ''; const pr = prs.find((p) => String(p?.number || '') === String($data?.targetPrNumber || '')); return pr?.headRefName || '';})()", + "value": "/* */ (() => { const raw = $ctx.getNodeOutput('list-prs')?.output || '[]'; let prs = []; try { prs = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { return ''; } if (!Array.isArray(prs)) return ''; const pr = prs.find((p) => String(p?.number || '') === String($data?.targetPrNumber || '')); return pr?.headRefName || '';})()", "isExpression": true }, "position": { @@ -26629,7 +26643,7 @@ "label": "Capture Base Branch", "config": { "key": "targetPrBase", - "value": "(() => { const raw = $ctx.getNodeOutput('list-prs')?.output || '[]'; let prs = []; try { prs = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { return 'main'; } if (!Array.isArray(prs)) return 'main'; const pr = prs.find((p) => String(p?.number || '') === String($data?.targetPrNumber || '')); return pr?.baseRefName || 'main';})()", + "value": "/* */ (() => { const raw = $ctx.getNodeOutput('list-prs')?.output || '[]'; let prs = []; try { prs = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { return 'main'; } if (!Array.isArray(prs)) return 'main'; const pr = prs.find((p) => String(p?.number || '') === String($data?.targetPrNumber || '')); return pr?.baseRefName || 'main';})()", "isExpression": true }, "position": { @@ -27189,7 +27203,7 @@ "type": "condition.expression", "label": "Bosun-Created PR?", "config": { - "expression": "(() => { if ($data?.requireBosunCreatedPr !== true && String($data?.requireBosunCreatedPr || '').toLowerCase() !== 'true') return true; const raw = $ctx.getNodeOutput('load-pr-context')?.output || '{}'; let pr = {}; try { pr = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { return false; } const labels = Array.isArray(pr?.labels) ? pr.labels.map((entry) => typeof entry === 'string' ? entry : entry?.name).filter(Boolean) : []; return labels.includes('bosun-pr-bosun-created'); })()" + "expression": "/* auto-created by bosun */ (() => { if ($data?.requireBosunCreatedPr !== true && String($data?.requireBosunCreatedPr || '').toLowerCase() !== 'true') return true; const raw = $ctx.getNodeOutput('load-pr-context')?.output || '{}'; let pr = {}; try { pr = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { return false; } const labels = Array.isArray(pr?.labels) ? pr.labels.map((entry) => typeof entry === 'string' ? entry : entry?.name).filter(Boolean) : []; const body = String(pr?.body || ''); return labels.includes('bosun-pr-bosun-created') || body.includes('') || /auto-created by bosun/i.test(body); })()" }, "position": { "x": 400, @@ -28128,7 +28142,7 @@ "type": "condition.expression", "label": "Detect Breaking Changes", "config": { - "expression": "(() => { const raw=$ctx.getNodeOutput('get-stats')?.output||'{}'; let stats={}; try{stats=typeof raw==='string'?JSON.parse(raw):raw;}catch{return false;} const title=String(stats?.title||'').toLowerCase(); const body=String(stats?.body||'').toLowerCase(); const files=Array.isArray(stats?.files)?stats.files.map((f)=>String(f?.path||f?.filename||f||'').toLowerCase()):[]; const text=title+'\\n'+body; const explicit=/\\bbreaking\\b|\\bbreaking change\\b|\\bmajor\\b|\\bbackward incompatible\\b/.test(text); const apiTouch=files.some((f)=>f.includes('api/')||f.includes('/proto/')||f.includes('openapi')||f.includes('schema')); const contractWords=/\\bremove\\b|\\brename\\b|\\bdeprecate\\b|\\bdrop\\b/.test(text); return explicit || (apiTouch && contractWords);})()" + "expression": "/* */ (() => { const raw=$ctx.getNodeOutput('get-stats')?.output||'{}'; let stats={}; try{stats=typeof raw==='string'?JSON.parse(raw):raw;}catch{return false;} const title=String(stats?.title||'').toLowerCase(); const body=String(stats?.body||'').toLowerCase(); const files=Array.isArray(stats?.files)?stats.files.map((f)=>String(f?.path||f?.filename||f||'').toLowerCase()):[]; const text=title+'\\n'+body; const explicit=/\\bbreaking\\b|\\bbreaking change\\b|\\bmajor\\b|\\bbackward incompatible\\b/.test(text); const apiTouch=files.some((f)=>f.includes('api/')||f.includes('/proto/')||f.includes('openapi')||f.includes('schema')); const contractWords=/\\bremove\\b|\\brename\\b|\\bdeprecate\\b|\\bdrop\\b/.test(text); return explicit || (apiTouch && contractWords);})()" }, "position": { "x": 400, @@ -28751,6 +28765,8 @@ "prompt": "# Merge Conflict Resolution\n\nYou are resolving merge conflicts in a git worktree.\n\n## Context\n- **Working directory**: `{{worktreePath}}`\n- **PR branch** (HEAD): `{{branch}}`\n- **Base branch** (incoming): `origin/{{baseBranch}}`\n- **PR**: #{{prNumber}}\n- **Task**: {{taskTitle}}\n\n## Conflicted files needing manual resolution:\n{{manualFiles}}\n\n## Instructions\n1. Read both sides of each conflict carefully\n2. Understand the INTENT of each change (feature vs upstream)\n3. Write a correct resolution that preserves both intents\n4. `git add` each resolved file\n5. Run `git commit --no-edit` to finalize the merge\n6. Do NOT use `--theirs` or `--ours` for code files\n7. Ensure no conflict markers (`<<<<<<<`, `=======`, `>>>>>>>`) remain", "sdk": "auto", "timeoutMs": "{{timeoutMs}}", + "delegationWatchdogTimeoutMs": "{{delegationWatchdogTimeoutMs}}", + "delegationWatchdogMaxRecoveries": "{{delegationWatchdogMaxRecoveries}}", "failOnError": true, "continueOnError": true }, @@ -46060,6 +46076,8 @@ "defaultSdk": "auto", "defaultTargetBranch": "origin/main", "taskTimeoutMs": 21600000, + "delegationWatchdogTimeoutMs": 300000, + "delegationWatchdogMaxRecoveries": 1, "prePrValidationEnabled": true, "prePrValidationCommand": "auto", "autoMergeOnCreate": false, @@ -46326,7 +46344,9 @@ "maxRetries": "{{maxRetries}}", "maxContinues": "{{maxContinues}}", "resolveMode": "library", - "failOnError": false + "failOnError": false, + "delegationWatchdogTimeoutMs": "{{delegationWatchdogTimeoutMs}}", + "delegationWatchdogMaxRecoveries": "{{delegationWatchdogMaxRecoveries}}" }, "position": { "x": 200, @@ -46351,7 +46371,9 @@ "maxRetries": "{{maxRetries}}", "maxContinues": "{{maxContinues}}", "resolveMode": "library", - "failOnError": false + "failOnError": false, + "delegationWatchdogTimeoutMs": "{{delegationWatchdogTimeoutMs}}", + "delegationWatchdogMaxRecoveries": "{{delegationWatchdogMaxRecoveries}}" }, "position": { "x": 200, @@ -46376,7 +46398,9 @@ "maxRetries": "{{maxRetries}}", "maxContinues": "{{maxContinues}}", "resolveMode": "library", - "failOnError": false + "failOnError": false, + "delegationWatchdogTimeoutMs": "{{delegationWatchdogTimeoutMs}}", + "delegationWatchdogMaxRecoveries": "{{delegationWatchdogMaxRecoveries}}" }, "position": { "x": 200, diff --git a/ui/tabs/workflows.js b/ui/tabs/workflows.js index 72de0c45d..11dc9ccd0 100644 --- a/ui/tabs/workflows.js +++ b/ui/tabs/workflows.js @@ -4370,6 +4370,7 @@ function WorkflowCanvas({ workflow, onSave, nodeTypes: availableNodeTypes = [] } value=${binding.requestedSourcePort} onChange=${(e) => updateEdgePortMapping(binding.edge.id, { sourcePort: e.target.value })} > + ${sourceOptions.map((port) => html``)} @@ -4380,6 +4381,7 @@ function WorkflowCanvas({ workflow, onSave, nodeTypes: availableNodeTypes = [] } value=${binding.requestedTargetPort} onChange=${(e) => updateEdgePortMapping(binding.edge.id, { targetPort: e.target.value })} > + ${targetOptions.map((port) => html``)} diff --git a/workflow-templates/github.mjs b/workflow-templates/github.mjs index 9ffc4eb41..a65e87664 100644 --- a/workflow-templates/github.mjs +++ b/workflow-templates/github.mjs @@ -95,8 +95,7 @@ export const PR_MERGE_STRATEGY_TEMPLATE = { node("automation-eligible", "condition.expression", "Bosun-Created PR?", { expression: - "(() => { if ($data?.requireBosunCreatedPr !== true && String($data?.requireBosunCreatedPr || '').toLowerCase() !== 'true') return true; const raw = $ctx.getNodeOutput('load-pr-context')?.output || '{}'; let pr = {}; try { pr = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { return false; } const labels = Array.isArray(pr?.labels) ? pr.labels.map((entry) => typeof entry === 'string' ? entry : entry?.name).filter(Boolean) : []; return labels.includes('bosun-pr-bosun-created'); })()", - }, { x: 400, y: 230, outputs: ["yes", "no"] }), + "/* auto-created by bosun */ (() => { if ($data?.requireBosunCreatedPr !== true && String($data?.requireBosunCreatedPr || '').toLowerCase() !== 'true') return true; const raw = $ctx.getNodeOutput('load-pr-context')?.output || '{}'; let pr = {}; try { pr = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { return false; } const labels = Array.isArray(pr?.labels) ? pr.labels.map((entry) => typeof entry === 'string' ? entry : entry?.name).filter(Boolean) : []; const body = String(pr?.body || ''); return labels.includes('bosun-pr-bosun-created') || body.includes('') || /auto-created by bosun/i.test(body); })()", }, { x: 400, y: 230, outputs: ["yes", "no"] }), node("check-ci", "validation.build", "Check CI Status", { command: "gh pr checks {{prNumber}} --json name,state", @@ -321,7 +320,7 @@ export const PR_TRIAGE_TEMPLATE = { node("detect-breaking", "condition.expression", "Detect Breaking Changes", { expression: - "(() => {" + + "/* */ (() => {" + " const raw=$ctx.getNodeOutput('get-stats')?.output||'{}';" + " let stats={};" + " try{stats=typeof raw==='string'?JSON.parse(raw):raw;}catch{return false;}" + @@ -425,7 +424,7 @@ export const PR_CONFLICT_RESOLVER_TEMPLATE = { node("target-pr", "action.set_variable", "Pick Conflict PR", { key: "targetPrNumber", value: - "(() => {" + + "/* */ (() => {" + " const raw = $ctx.getNodeOutput('list-prs')?.output || '[]';" + " let prs = [];" + " try { prs = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { return ''; }" + @@ -448,7 +447,7 @@ export const PR_CONFLICT_RESOLVER_TEMPLATE = { node("target-branch", "action.set_variable", "Capture Conflict Branch", { key: "targetPrBranch", value: - "(() => {" + + "/* */ (() => {" + " const raw = $ctx.getNodeOutput('list-prs')?.output || '[]';" + " let prs = [];" + " try { prs = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { return ''; }" + @@ -462,7 +461,7 @@ export const PR_CONFLICT_RESOLVER_TEMPLATE = { node("target-base", "action.set_variable", "Capture Base Branch", { key: "targetPrBase", value: - "(() => {" + + "/* */ (() => {" + " const raw = $ctx.getNodeOutput('list-prs')?.output || '[]';" + " let prs = [];" + " try { prs = typeof raw === 'string' ? JSON.parse(raw) : raw; } catch { return 'main'; }" + @@ -788,7 +787,7 @@ export const BOSUN_PR_PROGRESSOR_TEMPLATE = { node("normalize-context", "action.set_variable", "Normalize PR Context", { key: "prProgressContext", value: - "(() => {" + + "/* */ (() => {" + " const prOut = $ctx.getNodeOutput('create-pr') || $ctx.getNodeOutput('create-pr-retry') || {};" + " const prUrl = String($data?.prUrl || prOut?.prUrl || prOut?.url || '').trim();" + " const repoMatch = prUrl.match(/github\\.com\\/([^/]+\\/[^/?#]+)/i);" + @@ -1046,6 +1045,8 @@ export const BOSUN_PR_PROGRESSOR_TEMPLATE = { " `gh pr edit {{setup-pr-worktree.output.number}} --repo {{setup-pr-worktree.output.repo}} --remove-label bosun-needs-fix`\n", sdk: "auto", timeoutMs: 1_800_000, + delegationWatchdogTimeoutMs: "{{delegationWatchdogTimeoutMs}}", + delegationWatchdogMaxRecoveries: "{{delegationWatchdogMaxRecoveries}}", maxRetries: 2, retryDelayMs: 30_000, continueOnError: true, @@ -1266,6 +1267,7 @@ export const BOSUN_PR_WATCHDOG_TEMPLATE = { // Per-PR parallel fix dispatch (replaces single mega-agent): maxConcurrentFixes: 3, // how many PR fix agents run in parallel prFixTtlMinutes: 120, // minutes before a PR claim expires (allows re-dispatch) + }, nodes: [ node("trigger", "trigger.schedule", "Poll Every 90s", { @@ -1878,6 +1880,7 @@ export const BOSUN_PR_WATCHDOG_TEMPLATE = { "saveClaims(claims);", "console.log(JSON.stringify({unclaimed,alreadyClaimed,unclaimedCount:unclaimed.length,alreadyClaimedCount:alreadyClaimed.length,totalNeedsAgent:needsAgent.length}));", ].join(" ")], + continueOnError: true, failOnError: false, env: { @@ -3244,6 +3247,8 @@ export const SDK_CONFLICT_RESOLVER_TEMPLATE = { "7. Ensure no conflict markers (`<<<<<<<`, `=======`, `>>>>>>>`) remain", sdk: "auto", timeoutMs: "{{timeoutMs}}", + delegationWatchdogTimeoutMs: "{{delegationWatchdogTimeoutMs}}", + delegationWatchdogMaxRecoveries: "{{delegationWatchdogMaxRecoveries}}", failOnError: true, continueOnError: true, }, { x: 200, y: 950 }), @@ -3570,3 +3575,4 @@ export const GITHUB_CHECK_FAILURE_TEMPLATE = { tags: ["github", "ci", "check", "event-driven", "webhook", "reliability"], }, }; + diff --git a/workflow-templates/task-lifecycle.mjs b/workflow-templates/task-lifecycle.mjs index ae9dfd187..219c11f05 100644 --- a/workflow-templates/task-lifecycle.mjs +++ b/workflow-templates/task-lifecycle.mjs @@ -69,6 +69,8 @@ export const TASK_LIFECYCLE_TEMPLATE = { defaultSdk: "auto", defaultTargetBranch: "origin/main", taskTimeoutMs: 21600000, // 6 hours + delegationWatchdogTimeoutMs: 300000, + delegationWatchdogMaxRecoveries: 1, prePrValidationEnabled: true, prePrValidationCommand: "auto", autoMergeOnCreate: false, @@ -181,17 +183,17 @@ export const TASK_LIFECYCLE_TEMPLATE = { // ── Execute agent (phase 1: planning) ─────────────────────────────── agentPhase("run-agent-plan", "Agent Plan", "{{_taskPrompt}}\n\nExecution phase: planning. Produce a concrete implementation plan and identify required tests. Do not make code changes in this phase.", - {}, { x: 200, y: 1740 }), + { delegationWatchdogTimeoutMs: "{{delegationWatchdogTimeoutMs}}", delegationWatchdogMaxRecoveries: "{{delegationWatchdogMaxRecoveries}}" }, { x: 200, y: 1740 }), // ── Execute agent (phase 2: tests-first) ──────────────────────────── agentPhase("run-agent-tests", "Agent Tests", "{{_taskPrompt}}\n\nExecution phase: tests. Write or update tests first for the target behavior, then validate failures/pass criteria before implementation changes.", - {}, { x: 200, y: 1545 }), + { delegationWatchdogTimeoutMs: "{{delegationWatchdogTimeoutMs}}", delegationWatchdogMaxRecoveries: "{{delegationWatchdogMaxRecoveries}}" }, { x: 200, y: 1545 }), // ── Execute agent (phase 3: implementation + verification) ────────── agentPhase("run-agent-implement", "Agent Implement", "{{_taskPrompt}}\n\nExecution phase: implementation. Complete implementation after tests exist, run required verification (tests/lint/build), then commit, push, and create/update PR.", - {}, { x: 200, y: 1610 }), + { delegationWatchdogTimeoutMs: "{{delegationWatchdogTimeoutMs}}", delegationWatchdogMaxRecoveries: "{{delegationWatchdogMaxRecoveries}}" }, { x: 200, y: 1610 }), // ── Check if claim was stolen during agent execution ───────────────── node("claim-stolen", "condition.expression", "Claim Stolen?", { diff --git a/workflow/workflow-engine.mjs b/workflow/workflow-engine.mjs index 0e1cdab7e..022b1c544 100644 --- a/workflow/workflow-engine.mjs +++ b/workflow/workflow-engine.mjs @@ -155,6 +155,102 @@ function resolveWorkflowRootRunId(inputData = {}, opts = {}) { ).trim() || null; } +const DEFAULT_DELEGATION_WATCHDOG_TIMEOUT_MS = readBoundedEnvInt( + "WORKFLOW_DELEGATION_WATCHDOG_TIMEOUT_MS", + 5 * 60 * 1000, + { min: 1000, max: NODE_TIMEOUT_MAX_MS }, +); +const DEFAULT_DELEGATION_WATCHDOG_MAX_RECOVERIES = readBoundedEnvInt( + "WORKFLOW_DELEGATION_WATCHDOG_MAX_RECOVERIES", + 1, + { min: 0, max: 10 }, +); + +function parseWatchdogTimestamp(value) { + if (typeof value === "number" && Number.isFinite(value)) return value; + if (typeof value === "string" && value.trim()) { + const numeric = Number(value); + if (Number.isFinite(numeric)) return numeric; + const parsed = Date.parse(value); + if (Number.isFinite(parsed)) return parsed; + } + return null; +} + +function buildDelegationWatchdogDecision(detail = {}) { + const watchdog = detail?.data?._delegationWatchdog; + if (!watchdog || typeof watchdog !== "object") return null; + + const delegationType = String(watchdog.delegationType || "").trim().toLowerCase(); + const taskScoped = watchdog.taskScoped === true; + if (taskScoped || delegationType === "task") return null; + + const state = String(watchdog.state || "").trim().toLowerCase(); + if (state && state !== "delegated" && state !== "running" && state !== "stalled") { + return null; + } + + const startedAt = parseWatchdogTimestamp(watchdog.startedAt) + ?? parseWatchdogTimestamp(watchdog.updatedAt) + ?? parseWatchdogTimestamp(detail?.startedAt); + if (!Number.isFinite(startedAt)) return null; + + const timeoutMs = Math.max( + NODE_TIMEOUT_MIN_MS, + Math.min( + NODE_TIMEOUT_MAX_MS, + Number(watchdog.timeoutMs ?? watchdog.delegationWatchdogTimeoutMs ?? DEFAULT_DELEGATION_WATCHDOG_TIMEOUT_MS) + || DEFAULT_DELEGATION_WATCHDOG_TIMEOUT_MS, + ), + ); + const elapsedMs = Date.now() - startedAt; + if (elapsedMs < timeoutMs) return null; + + const maxRecoveries = Math.max( + 0, + Math.trunc((() => { + const raw = watchdog.maxRecoveries + ?? watchdog.delegationWatchdogMaxRecoveries + ?? detail?.data?.delegationWatchdogMaxRecoveries + ?? DEFAULT_DELEGATION_WATCHDOG_MAX_RECOVERIES; + const parsed = Number(raw); + return Number.isFinite(parsed) ? parsed : DEFAULT_DELEGATION_WATCHDOG_MAX_RECOVERIES; + })()), + ); + const recoveryAttempts = Math.max( + 0, + Math.trunc((() => { + const parsed = Number(watchdog.recoveryAttempts); + if (Number.isFinite(parsed)) return parsed; + return watchdog.recoveryAttempted === true ? 1 : 0; + })()), + ); + + const reasonBase = `delegation_watchdog:${watchdog.nodeId || "unknown"}:${elapsedMs}ms>${timeoutMs}ms`; + if (recoveryAttempts >= maxRecoveries) { + return { + type: "exhausted", + reason: `delegation_watchdog_exhausted:${watchdog.nodeId || "unknown"}:${recoveryAttempts}/${maxRecoveries}`, + nodeId: watchdog.nodeId || null, + elapsedMs, + timeoutMs, + recoveryAttempts, + maxRecoveries, + }; + } + + return { + type: "retry", + mode: "from_failed", + reason: `${reasonBase}:retryable`, + nodeId: watchdog.nodeId || null, + elapsedMs, + timeoutMs, + recoveryAttempts, + maxRecoveries, + }; +} + function resolveNodeTimeoutMs(node, resolvedConfig) { const candidates = [ resolvedConfig?.timeout, @@ -5635,7 +5731,14 @@ export class WorkflowEngine extends EventEmitter { // Reuse cached detail if available (already parsed above) const detail = runDetailCache.get(run.runId) ?? JSON.parse(readFileSync(detailPath, "utf8")); - const retryDecision = this._chooseRetryModeFromDetail(detail, { + const watchdogDecision = buildDelegationWatchdogDecision(detail); + if (watchdogDecision?.type === "exhausted") { + console.warn(`${TAG} Skipping run ${run.runId}: ${watchdogDecision.reason}`); + this._markRunUnresumable(run.runId, watchdogDecision.reason); + continue; + } + + const retryDecision = watchdogDecision || this._chooseRetryModeFromDetail(detail, { fallbackMode: "from_scratch", }); @@ -5877,3 +5980,4 @@ export function getWorkflow(id, opts) { return getWorkflowEngine(opts).get(id); export async function executeWorkflow(id, data, opts) { return getWorkflowEngine(opts).execute(id, data, opts); } export async function retryWorkflowRun(runId, retryOpts, engineOpts) { return getWorkflowEngine(engineOpts).retryRun(runId, retryOpts); } + diff --git a/workflow/workflow-nodes.mjs b/workflow/workflow-nodes.mjs index 99e4833fb..4afa079f5 100644 --- a/workflow/workflow-nodes.mjs +++ b/workflow/workflow-nodes.mjs @@ -3964,13 +3964,74 @@ registerBuiltinNodeType("action.run_agent", { }, candidate.id, ); - const subRun = await engine.execute( - candidate.id, - delegatedInput, - childRunOpts, + const resolveDelegatedWatchdogTimeoutMs = () => { + const candidates = [ + ctx.resolve(node.config?.delegationWatchdogTimeoutMs), + ctx.data?.delegationWatchdogTimeoutMs, + ctx.data?.task?.delegationWatchdogTimeoutMs, + ]; + for (const value of candidates) { + const parsed = Number(value); + if (Number.isFinite(parsed) && parsed > 0) return parsed; + } + return 300000; + }; + const resolveDelegatedWatchdogMaxRecoveries = () => { + const candidates = [ + ctx.resolve(node.config?.delegationWatchdogMaxRecoveries), + ctx.data?.delegationWatchdogMaxRecoveries, + ctx.data?.task?.delegationWatchdogMaxRecoveries, + ]; + for (const value of candidates) { + const parsed = Number(value); + if (Number.isFinite(parsed) && parsed >= 0) return Math.min(5, Math.trunc(parsed)); + } + return 1; + }; + const delegatedWatchdogTimeoutMs = resolveDelegatedWatchdogTimeoutMs(); + const delegatedWatchdogMaxRecoveries = resolveDelegatedWatchdogMaxRecoveries(); + let watchdogRetryCount = 0; + let subRun = null; + let watchdogRecovered = false; + let watchdogState = null; + + while (true) { + subRun = await engine.execute( + candidate.id, + delegatedInput, + childRunOpts, + ); + + const delegatedRunId = String(subRun?.runId || subRun?.id || "").trim(); + watchdogState = null; + if (delegatedRunId) { + if (typeof engine.getRunDetail === "function") { + // Prefer a lightweight, per-run lookup when available to avoid hydrating full history. + watchdogState = await engine.getRunDetail(delegatedRunId); + } else if (typeof engine.getRunHistory === "function") { + const delegatedHistory = engine.getRunHistory(candidate.id, 10); + watchdogState = Array.isArray(delegatedHistory) + ? delegatedHistory.find((entry) => String(entry?.runId || "") === delegatedRunId) || null + : null; + } + } + const stalledDelegationInner = Boolean( + subRun?.status === "running" && + watchdogState?.status === "running" && + (watchdogState?.isStuck === true || Number(watchdogState?.stuckMs || 0) >= delegatedWatchdogTimeoutMs) + ); + + if (!stalledDelegationInner) break; + if (watchdogRetryCount >= delegatedWatchdogMaxRecoveries) break; + watchdogRetryCount += 1; + watchdogRecovered = true; + } + + const stalledDelegation = Boolean( + subRun?.status === "running" && + watchdogState?.status === "running" && + (watchdogState?.isStuck === true || Number(watchdogState?.stuckMs || 0) >= delegatedWatchdogTimeoutMs) ); - const subStatus = deriveWorkflowExecutionSessionStatus(subRun); - const subFailed = subStatus !== "completed"; const subTerminalOutput = subRun?.data?._workflowTerminalOutput; const subBlockedReason = subTerminalOutput && typeof subTerminalOutput === "object" @@ -3980,6 +4041,8 @@ registerBuiltinNodeType("action.run_agent", { subTerminalOutput && typeof subTerminalOutput === "object" ? String(subTerminalOutput.implementationState || "").trim() || null : null; + const subStatus = stalledDelegation ? "stalled" : deriveWorkflowExecutionSessionStatus(subRun); + const subFailed = stalledDelegation || subStatus !== "completed"; recordDelegationAuditEvent(ctx, { type: subFailed ? "owner-mismatch" : "handoff-complete", @@ -4016,6 +4079,11 @@ registerBuiltinNodeType("action.run_agent", { implementationState: subImplementationState, terminalOutput: subTerminalOutput, subRun, + watchdogRecovered, + recoveredFromStall: watchdogRecovered && !stalledDelegation, + watchdogRetryCount, + failureKind: stalledDelegation ? "stalled_delegation" : undefined, + retryable: stalledDelegation ? true : undefined, runId: subRun?.id || null, }; setDelegationTransitionResult(ctx, assignTransitionKey, { diff --git a/workflow/workflow-nodes/actions.mjs b/workflow/workflow-nodes/actions.mjs index 16af7e278..63a8c367c 100644 --- a/workflow/workflow-nodes/actions.mjs +++ b/workflow/workflow-nodes/actions.mjs @@ -1026,6 +1026,8 @@ registerNodeType("action.run_agent", { description: "Optional prompt suffix template for candidate mode. Supports {{candidateIndex}} and {{candidateCount}}", }, + delegationWatchdogTimeoutMs: { type: "number", default: 300000, description: "Stall threshold for delegated non-task workflows in ms" }, + delegationWatchdogMaxRecoveries: { type: "number", default: 1, description: "Maximum watchdog recovery retries for delegated workflows" }, }, required: ["prompt"], }, @@ -1328,16 +1330,77 @@ registerNodeType("action.run_agent", { }); } - const subRun = await engine.execute( - candidate.id, - { - ...eventPayload, - _agentWorkflowActive: true, - }, - childRunOpts, + const resolveDelegatedWatchdogTimeoutMs = () => { + const candidates = [ + ctx.resolve(node.config?.delegationWatchdogTimeoutMs), + ctx.data?.delegationWatchdogTimeoutMs, + ctx.data?.task?.delegationWatchdogTimeoutMs, + ]; + for (const value of candidates) { + const parsed = Number(value); + if (Number.isFinite(parsed) && parsed > 0) return parsed; + } + return 300000; + }; + const resolveDelegatedWatchdogMaxRecoveries = () => { + const candidates = [ + ctx.resolve(node.config?.delegationWatchdogMaxRecoveries), + ctx.data?.delegationWatchdogMaxRecoveries, + ctx.data?.task?.delegationWatchdogMaxRecoveries, + ]; + for (const value of candidates) { + const parsed = Number(value); + if (Number.isFinite(parsed) && parsed >= 0) return Math.min(5, Math.trunc(parsed)); + } + return 1; + }; + const delegatedWatchdogTimeoutMs = resolveDelegatedWatchdogTimeoutMs(); + const delegatedWatchdogMaxRecoveries = resolveDelegatedWatchdogMaxRecoveries(); + let watchdogRetryCount = 0; + let subRun = null; + let watchdogRecovered = false; + let watchdogState = null; + + while (true) { + subRun = await engine.execute( + candidate.id, + { + ...eventPayload, + _agentWorkflowActive: true, + }, + childRunOpts, + ); + + const delegatedRunId = String(subRun?.runId || subRun?.id || "").trim(); + watchdogState = null; + if (delegatedRunId) { + if (typeof engine.getRunDetail === "function") { + // Prefer a lightweight, per-run lookup when available to avoid hydrating full history. + watchdogState = await engine.getRunDetail(delegatedRunId); + } else if (typeof engine.getRunHistory === "function") { + const delegatedHistory = engine.getRunHistory(candidate.id, 10); + watchdogState = Array.isArray(delegatedHistory) + ? delegatedHistory.find((entry) => String(entry?.runId || "") === delegatedRunId) || null + : null; + } + } + const stalledDelegation = Boolean( + subRun?.status === "running" && + watchdogState?.status === "running" && + (watchdogState?.isStuck === true || Number(watchdogState?.stuckMs || 0) >= delegatedWatchdogTimeoutMs) + ); + + if (!stalledDelegation) break; + if (watchdogRetryCount >= delegatedWatchdogMaxRecoveries) break; + watchdogRetryCount += 1; + watchdogRecovered = true; + } + + const stalledDelegation = Boolean( + subRun?.status === "running" && + watchdogState?.status === "running" && + (watchdogState?.isStuck === true || Number(watchdogState?.stuckMs || 0) >= delegatedWatchdogTimeoutMs) ); - const subStatus = deriveWorkflowExecutionSessionStatus(subRun); - const subFailed = subStatus !== "completed"; const subTerminalOutput = subRun?.data?._workflowTerminalOutput; const subBlockedReason = subTerminalOutput && typeof subTerminalOutput === "object" @@ -1347,6 +1410,9 @@ registerNodeType("action.run_agent", { subTerminalOutput && typeof subTerminalOutput === "object" ? String(subTerminalOutput.implementationState || "").trim() || null : null; + const subStatus = stalledDelegation ? "stalled" : deriveWorkflowExecutionSessionStatus(subRun); + const subFailed = stalledDelegation || subStatus !== "completed"; + recordDelegationAuditEvent(ctx, { type: subFailed ? "owner-mismatch" : "handoff-complete", @@ -1383,7 +1449,13 @@ registerNodeType("action.run_agent", { implementationState: subImplementationState, terminalOutput: subTerminalOutput, subRun, + watchdogRecovered, + recoveredFromStall: watchdogRecovered && !stalledDelegation, + watchdogRetryCount, + failureKind: stalledDelegation ? "stalled_delegation" : undefined, + retryable: stalledDelegation ? true : undefined, runId: subRun?.id || null, + }; setDelegationTransitionResult(ctx, assignTransitionKey, { type: "run_agent_delegate", @@ -8120,7 +8192,3 @@ registerNodeType("action.web_search", { // ═══════════════════════════════════════════════════════════════════════════ // Export all registered types for introspection // ═══════════════════════════════════════════════════════════════════════════ - - - -