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
// ═══════════════════════════════════════════════════════════════════════════
-
-
-
-