agents-relay 2.0.141 → 2.0.143
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli-workflow-action.js +15 -0
- package/dist/cli-workflow.js +88 -0
- package/dist/cli.js +10 -0
- package/dist/dashboard-html.js +5 -5
- package/dist/dashboard-style.js +1 -0
- package/dist/escalation-notifier.js +92 -0
- package/dist/local-authority-store.js +29 -0
- package/dist/local-merge-delivery.js +33 -10
- package/dist/planner-child-recovery.js +44 -0
- package/dist/planner-recovery.js +30 -0
- package/dist/reconciler-review.js +9 -0
- package/dist/reconciler-runtime.js +7 -1
- package/dist/reconciler-workflow-outbox.js +11 -0
- package/dist/reconciler.js +31 -45
- package/dist/relay-job-snapshot-reads.js +11 -0
- package/dist/relay-store.js +28 -37
- package/dist/relay-workflow-operations.js +104 -0
- package/dist/relayd.js +2 -1
- package/dist/task-recovery.js +65 -15
- package/dist/troubleshooter-fallback.js +13 -5
- package/dist/workflow/action-ledger.js +75 -0
- package/dist/workflow/authoritative-conditions.js +38 -0
- package/dist/workflow/conditions.js +32 -0
- package/dist/workflow/definitions-v2.js +6 -1
- package/dist/workflow/dispatcher.js +53 -3
- package/dist/workflow/durable-publication.js +85 -0
- package/dist/workflow/durable-transition.js +9 -0
- package/dist/workflow/planner-orchestration-v2.js +15 -2
- package/dist/workflow/usage-index.js +35 -0
- package/package.json +9 -5
- package/workflow/main.mmd +54 -27
- package/workflow/planner-orchestration.mmd +21 -1
- package/workflow/review-lifecycle.mmd +4 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Graph-authorized public CLI action IDs delegate to canonical Task commands. */
|
|
2
|
+
export async function runWorkflowAction(args, execute) {
|
|
3
|
+
const value = (flag) => { const i = args.indexOf(flag); return i < 0 ? '' : args[i + 1] ?? ''; };
|
|
4
|
+
const actions = {
|
|
5
|
+
'task.retry': 'retry', 'task.recover': 'recover', 'task.cancel': 'cancel',
|
|
6
|
+
'task.unblock': 'unblock', 'task.escalate': 'escalate',
|
|
7
|
+
'task.resume-troubleshooter': 'resume-troubleshooter',
|
|
8
|
+
};
|
|
9
|
+
const operation = actions[value('--action')];
|
|
10
|
+
if (!operation)
|
|
11
|
+
throw Error('workflow action requires a supported Graph-authorized task Action ID');
|
|
12
|
+
if (!value('--repo') || !value('--id') || !value('--task-id'))
|
|
13
|
+
throw Error('workflow action requires --repo, --id and --task-id');
|
|
14
|
+
await execute(operation);
|
|
15
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/** Read-only workflow discovery/evaluation; executable effects require durable authorization. */
|
|
2
|
+
import { buildWorkflowUsageIndex, defaultWorkflowDirectory, executableGraphs, whereWorkflowSymbol } from './workflow/usage-index.js';
|
|
3
|
+
import { ConditionRegistry, booleanCondition } from './workflow/conditions.js';
|
|
4
|
+
import { GraphRuntime } from './workflow/graph-v2.js';
|
|
5
|
+
import { dispatchWorkflowGraphSync } from './workflow/dispatcher.js';
|
|
6
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
7
|
+
import { existsSync } from 'node:fs';
|
|
8
|
+
import { verifyAuthoritativeCondition } from './workflow/authoritative-conditions.js';
|
|
9
|
+
import { WORKFLOW_EVENT_NAMES, WORKFLOW_EFFECT_NAMES, WORKFLOW_MUTATION_NAMES } from './workflow/contracts.js';
|
|
10
|
+
export function workflowCliResult(args) {
|
|
11
|
+
const [verb, ...options] = args;
|
|
12
|
+
const value = (flag) => {
|
|
13
|
+
const offset = options.indexOf(flag);
|
|
14
|
+
return offset >= 0 ? options[offset + 1] ?? '' : '';
|
|
15
|
+
};
|
|
16
|
+
const graphs = executableGraphs(defaultWorkflowDirectory());
|
|
17
|
+
const index = buildWorkflowUsageIndex(graphs);
|
|
18
|
+
if (verb === 'discover')
|
|
19
|
+
return { graphs: [...graphs.keys()], symbols: [...new Set(index.map(x => x.id))].sort() };
|
|
20
|
+
if (verb === 'where')
|
|
21
|
+
return whereWorkflowSymbol(index, value('--id'));
|
|
22
|
+
if (verb === 'inspect') {
|
|
23
|
+
const name = value('--graph');
|
|
24
|
+
const graph = graphs.get(name);
|
|
25
|
+
if (!graph)
|
|
26
|
+
throw new Error('unknown executable graph');
|
|
27
|
+
return { graph: name, initial: graph.initial, nodes: [...graph.nodes.values()], edges: graph.edges };
|
|
28
|
+
}
|
|
29
|
+
if (verb === 'evaluate') {
|
|
30
|
+
const name = value('--graph');
|
|
31
|
+
const graph = graphs.get(name);
|
|
32
|
+
if (!graph)
|
|
33
|
+
throw new Error('unknown executable graph');
|
|
34
|
+
const inputId = value('--event');
|
|
35
|
+
const state = value('--state');
|
|
36
|
+
if (!inputId || !state)
|
|
37
|
+
throw new Error('evaluate requires --event and --state');
|
|
38
|
+
const evidence = value('--facts');
|
|
39
|
+
const facts = evidence ? JSON.parse(evidence) : {};
|
|
40
|
+
if (!facts || typeof facts !== 'object' || Array.isArray(facts))
|
|
41
|
+
throw new Error('facts must be a JSON object');
|
|
42
|
+
const record = facts;
|
|
43
|
+
const guards = [...new Set([...graph.nodes.values()].filter(n => n.kind === 'guard').map(n => n.semanticId))];
|
|
44
|
+
const conditions = new ConditionRegistry(guards.map(booleanCondition));
|
|
45
|
+
const runtime = new GraphRuntime(graph, {
|
|
46
|
+
states: new Set([...graph.nodes.values()].filter(n => n.kind === 'state').map(n => n.semanticId)),
|
|
47
|
+
guards: Object.fromEntries(guards.map(id => [id, conditions.guard(id)])),
|
|
48
|
+
actions: Object.fromEntries([...new Set([...WORKFLOW_EFFECT_NAMES, ...WORKFLOW_MUTATION_NAMES, ...[...graph.nodes.values()].filter(n => n.kind === 'action').map(n => n.semanticId)])].map(id => [id, { id }])),
|
|
49
|
+
events: Object.fromEntries([...new Set([...WORKFLOW_EVENT_NAMES, inputId, ...graph.edges.filter(e => e.kind === 'on' || e.kind === 'emit').map(e => e.semanticId).filter((id) => !!id)])].map(id => [id, { id }])),
|
|
50
|
+
});
|
|
51
|
+
return { mode: 'dry-run', decision: dispatchWorkflowGraphSync(runtime, { initialState: state, input: { transport: 'event', id: inputId, payload: {} }, durableFacts: record }) };
|
|
52
|
+
}
|
|
53
|
+
if (verb === 'verify') {
|
|
54
|
+
const path = value('--sqlite');
|
|
55
|
+
const jobId = value('--job');
|
|
56
|
+
const predicate = value('--condition');
|
|
57
|
+
const allowed = new Set(['task.queued', 'task.succeeded', 'task.failed', 'task.routing-cleared', 'task.execution-stopped', 'job.objective-verified', 'job.completed']);
|
|
58
|
+
if (!path || !existsSync(path) || !jobId || !allowed.has(predicate))
|
|
59
|
+
throw new Error('verify requires existing --sqlite, --job and supported --condition');
|
|
60
|
+
const db = new DatabaseSync(path, { readOnly: true });
|
|
61
|
+
try {
|
|
62
|
+
return { condition: predicate, jobId, taskId: value('--task') || null, outcome: verifyAuthoritativeCondition(db, jobId, predicate, value('--task'), value('--execution')) };
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
db.close();
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (verb === 'trace') {
|
|
69
|
+
const path = value('--sqlite');
|
|
70
|
+
const key = value('--attempt-key');
|
|
71
|
+
if (!path || !key || !existsSync(path))
|
|
72
|
+
throw new Error('trace requires existing --sqlite path and --attempt-key');
|
|
73
|
+
const db = new DatabaseSync(path, { readOnly: true });
|
|
74
|
+
try {
|
|
75
|
+
const table = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='workflow_action_trace'").get();
|
|
76
|
+
if (!table)
|
|
77
|
+
return { attemptKey: key, trace: null };
|
|
78
|
+
const record = db.prepare('SELECT attempt_key,execution_id,generation,graph,action_id,condition_id,outcome,observed_at FROM workflow_action_trace WHERE attempt_key=?').get(key) ?? null;
|
|
79
|
+
return { attemptKey: key, trace: record };
|
|
80
|
+
}
|
|
81
|
+
finally {
|
|
82
|
+
db.close();
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
if (verb === 'action')
|
|
86
|
+
throw new Error('durable graph authorization required; unsafe action request refused');
|
|
87
|
+
throw new Error('workflow supports discover, inspect, where, evaluate; action/trace require durable authorization');
|
|
88
|
+
}
|
package/dist/cli.js
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
+
import { runWorkflowAction } from './cli-workflow-action.js';
|
|
3
|
+
import { workflowCliResult } from './cli-workflow.js';
|
|
2
4
|
import { configureLocalGitMerge } from './cli-local-merge-config.js';
|
|
3
5
|
import { noGitHubMode } from './no-github.js';
|
|
4
6
|
import { discoverLocalRelayStoreSnapshots, relayStoreDirectory } from './relay-store.js';
|
|
@@ -712,6 +714,14 @@ async function main() {
|
|
|
712
714
|
});
|
|
713
715
|
return;
|
|
714
716
|
}
|
|
717
|
+
if (command === 'workflow') {
|
|
718
|
+
if (args[0] === 'action') {
|
|
719
|
+
await runWorkflowAction(args, operation => runTaskCommand(operation, args));
|
|
720
|
+
return;
|
|
721
|
+
}
|
|
722
|
+
console.log(JSON.stringify(workflowCliResult(args), null, 2));
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
715
725
|
if (command === 'job') {
|
|
716
726
|
const [action, ...jobArgs] = args;
|
|
717
727
|
await runJobCommand(action ?? '', jobArgs);
|
package/dist/dashboard-html.js
CHANGED
|
@@ -294,7 +294,7 @@ function githubUsageCard(effective,status){
|
|
|
294
294
|
}
|
|
295
295
|
async function drawUsage(){try{const overview=await loadJSON('/api/usage');usageRoot.replaceChildren();usageMeta.textContent='Real account telemetry when available';const providers=Array.isArray(overview.providers)?overview.providers:[];const credits=Array.isArray(overview.credits)?overview.credits:[];const grid=element('div','usage-providers');if(overview.githubEffective||overview.githubStatus)grid.append(githubUsageCard(overview.githubEffective,overview.githubStatus));for(const provider of providers)grid.append(usageProvider(provider));for(const provider of credits)grid.append(creditProvider(provider));if(!grid.children.length){usageRoot.append(element('p','empty',text('Usage telemetry is unavailable.')));return;}usageRoot.append(grid);}catch(error){usageMeta.textContent='Unavailable';usageRoot.replaceChildren(element('p','error-state',text('Usage unavailable: '+error.message)));}}
|
|
296
296
|
function selectedWorkflowGraph(){return workflowGraphs.find(graph=>graph.id===selectedGraphId)||null;}
|
|
297
|
-
function graphLinks(source){const links=new Map();for(const line of source.split('\\n')){const match=/^\\s*click\\s+([A-Za-z0-9_]+)\\s+href\\s+"([a-z0-9-]+\\.mmd)"\\s*$/.exec(line);if(!match)continue;const id=match[2].slice(0,-4);if(workflowGraphs.some(graph=>graph.id===id))links.set(match[1],id);}return links;}
|
|
297
|
+
function graphLinks(source){const links=new Map();for(const line of source.split('\\n')){const match=/^\\s*click\\s+([A-Za-z0-9_]+)\\s+href\\s+"([a-z0-9-]+\\.mmd)"(?:\\s+"[^"\\n]*")?\\s*$/.exec(line);if(!match)continue;const id=match[2].slice(0,-4);if(workflowGraphs.some(graph=>graph.id===id))links.set(match[1],id);}return links;}
|
|
298
298
|
function graphTargets(id){const graph=selectedWorkflowGraph();return graph?[...new Set(graphLinks(graph.source).values())]:[];}
|
|
299
299
|
function graphFromHash(){if(location.hash==='#/graphs'||location.hash==='#/graphs/entry')return 'main';if(!location.hash.startsWith('#/graphs/'))return null;const id=location.hash.slice(9);return /^[a-z0-9-]+$/.test(id)?id:null;}
|
|
300
300
|
const WORKFLOW_NAV_KEY='agents-relay:workflow-navigation';
|
|
@@ -308,11 +308,11 @@ function renderGraphNavigation(){const graph=selectedWorkflowGraph();initializeG
|
|
|
308
308
|
function renderGraphList(){graphList.replaceChildren();if(!workflowGraphs.length){graphList.append(element('p','empty',text('No workflow graphs found.')));return;}for(const graph of workflowGraphs){const button=document.createElement('button');button.type='button';button.className='graph-button';button.setAttribute('aria-current',String(graph.id===selectedGraphId));button.append(element('strong','',text(graph.name)),element('span','',text(graph.path)));button.addEventListener('click',()=>navigateGraph(graph.id));graphList.append(button);}}
|
|
309
309
|
async function graphDetail(kind,name){const panel=document.querySelector('#graph-detail');panel.hidden=false;panel.replaceChildren(element('strong','',text(kind+': '+name)),element('p','subtle',text('Loading authoritative references…')));try{const params=new URLSearchParams({graph:selectedGraphId,kind,name});const result=await loadJSON('/api/graph-symbol?'+params);panel.replaceChildren(element('strong','',text(kind+': '+name)));const sections=[['Graph',[result.graph]],['Canonical contract',result.contracts],['Implementation',result.implementation],['Tests',result.tests]];for(const [heading,refs] of sections){panel.append(element('p','detail-label',text(heading)));if(!refs?.length){panel.append(element('p','subtle',text('No exact symbol reference found.')));continue;}for(const ref of refs){const link=element('a','',text(ref.path+(ref.line>0?':'+ref.line:'')+' ['+(ref.evidence==='exact-token'?'verified graph token':'text-search suggestion')+']'));if(WORKFLOW_SOURCE_REF){link.href='https://github.com/lalalic/agents-relay/blob/'+WORKFLOW_SOURCE_REF+'/'+ref.path+(ref.line>0?'#L'+ref.line:'');link.target='_blank';link.rel='noopener noreferrer';}else{link.removeAttribute('href');link.title='Source revision unavailable';}link.target='_blank';link.rel='noopener noreferrer';panel.append(link,document.createTextNode(' '));}}panel.scrollIntoView({block:'nearest'});}catch(error){panel.append(element('p','error-state',text('Reference lookup unavailable: '+error.message)));}}
|
|
310
310
|
function interactiveNode(node,callback,label){node.classList.add('drillable');node.setAttribute('tabindex','0');node.setAttribute('role','button');node.setAttribute('aria-label',label);node.addEventListener('click',callback);node.addEventListener('keydown',event=>{if(event.key==='Enter'||event.key===' '){event.preventDefault();callback();}});}
|
|
311
|
-
function graphNodeDefinitions(source){const result=new Map();for(const line of source.split('\\n')){const pattern=/([A-Za-z0-9_]+)
|
|
311
|
+
function graphNodeDefinitions(source){const result=new Map();for(const line of source.split('\\n')){const pattern=/([A-Za-z0-9_]+)(?:\\["([^"\\n]+)"\\]|\\{"([^"\\n]+)"\\})/g;for(const match of line.matchAll(pattern))result.set(match[1],match[2]||match[3]);}return result;}
|
|
312
312
|
function addNodeLabel(node,label){const ns='http://www.w3.org/2000/svg';const old=node.querySelector('g.label');if(old)old.remove();const textNode=document.createElementNS(ns,'text');textNode.setAttribute('x','0');textNode.setAttribute('y','0');textNode.setAttribute('text-anchor','middle');textNode.setAttribute('dominant-baseline','middle');textNode.setAttribute('fill','#182536');textNode.setAttribute('font-size','13');const rect=node.querySelector('rect.label-container,rect.basic,rect');const width=Math.max(65,Number(rect?.getAttribute('width')||135)-14);const maxChars=Math.max(9,Math.floor(width/7.2));const words=label.split(/\\s+/);const lines=[];let current='';for(const word of words){if(current&&(current+' '+word).length>maxChars){lines.push(current);current=word;}else current=current?current+' '+word:word;}if(current)lines.push(current);for(let i=0;i<lines.length;i++){const tspan=document.createElementNS(ns,'tspan');tspan.setAttribute('x','0');tspan.setAttribute('dy',i===0?String(-((lines.length-1)*7)):'14');tspan.textContent=lines[i];textNode.append(tspan);}node.append(textNode);}
|
|
313
|
-
function sanitizeGraphSvg(markup){const parsed=new DOMParser().parseFromString(markup,'image/svg+xml');const source=parsed.documentElement;if(source.localName!=='svg'||parsed.querySelector('parsererror'))throw new Error('Invalid SVG');const ns='http://www.w3.org/2000/svg';const elements=new Set(['svg','g','path','rect','circle','ellipse','line','polyline','polygon','text','tspan','defs','marker','title','desc','clipPath']);const attributes=new Set(['id','class','viewBox','width','height','x','y','x1','y1','x2','y2','cx','cy','r','rx','ry','d','points','transform','fill','stroke','stroke-width','stroke-dasharray','stroke-linecap','stroke-linejoin','opacity','fill-opacity','stroke-opacity','font-size','font-family','font-weight','text-anchor','dominant-baseline','markerWidth','markerHeight','markerUnits','refX','refY','orient','preserveAspectRatio','clip-path','marker-start','marker-mid','marker-end','role','aria-label']);const clean=node=>{if(node.nodeType===3)return document.createTextNode(node.textContent||'');if(node.nodeType!==1||node.namespaceURI!==ns||!elements.has(node.localName))return null;const out=document.createElementNS(ns,node.localName);for(const attr of node.attributes){if(!attr.namespaceURI&&attr.name==='style'){for(const declaration of attr.value.split(';')){const split=declaration.indexOf(':');if(split<0)continue;const property=declaration.slice(0,split).trim();const cssValue=declaration.slice(split+1).trim();if((property==='stroke'||property==='fill')&&/^(?:#[0-9a-fA-F]{3,8}|rgb\\([0-9.,%\\s]+\\)|none|currentColor)$/.test(cssValue))out.setAttribute(property,cssValue);else if(property==='stroke-width'&&/^[0-9]+(?:\\.[0-9]+)?(?:px)?$/.test(cssValue))out.setAttribute(property,cssValue);}continue;}if(attr.namespaceURI||!attributes.has(attr.name))continue;if(['marker-start','marker-mid','marker-end','clip-path'].includes(attr.name)){if(!attr.value.startsWith('url(#')||!attr.value.endsWith(')')||!(/^[a-zA-Z0-9_-]+$/.test(attr.value.slice(5,-1))))continue;}else if(attr.value.toLowerCase().includes('url(')||attr.value.includes('<')||attr.value.includes('>'))continue;out.setAttribute(attr.name,attr.value);}for(const child of node.childNodes){const safe=clean(child);if(safe)out.appendChild(safe);}return out;};return clean(source);}
|
|
314
|
-
function applyGraphPalette(svg){const ns='http://www.w3.org/2000/svg';for(const path of svg.querySelectorAll('path')){if(path.closest('marker')){if(!path.hasAttribute('fill'))path.setAttribute('fill','#65768a');continue;}if(!path.hasAttribute('fill'))path.setAttribute('fill','none');if(!path.hasAttribute('stroke'))path.setAttribute('stroke','#65768a');}for(const node of svg.querySelectorAll('g.node')){const error=node.classList.contains('failure');for(const shape of node.querySelectorAll('rect,polygon,circle,ellipse')){shape.setAttribute('fill',error?'#ffe4e4':'#f9fbff');shape.setAttribute('stroke',error?'#be3030':'#657f9e');shape.setAttribute('stroke-width','1.3');}for(const label of node.querySelectorAll('text'))label.setAttribute('fill',error?'#7c1818':'#182536');}for(const rect of svg.querySelectorAll('rect')){if(!rect.closest('g.node')&&!rect.hasAttribute('fill'))rect.setAttribute('fill','#ffffff');}for(const label of svg.querySelectorAll('text'))if(!label.hasAttribute('fill'))label.setAttribute('fill','#182536');}
|
|
315
|
-
async function renderSelectedGraph(){const graph=selectedWorkflowGraph();renderGraphNavigation();document.querySelector('#graph-detail').hidden=true;if(!graph){graphTitle.textContent='Graph not found';graphPath.textContent='';graphSource.textContent='';graphCanvas.replaceChildren(element('p','error-state',text('Graph not found: '+selectedGraphId+'. Select Entry or another graph.')));return;}graphTitle.textContent=graph.name;graphPath.textContent=graph.path;graphSource.textContent=graph.source;graphCanvas.replaceChildren(element('p','subtle',text('Rendering…')));try{if(!window.mermaid)throw new Error('Mermaid renderer unavailable');if(!window.__relayMermaidReady){window.mermaid.initialize({startOnLoad:false,securityLevel:'strict',flowchart:{htmlLabels:false}});window.__relayMermaidReady=true;}const rendered=await window.mermaid.render('relay-graph-'+graph.id.replace(/[^A-Za-z0-9_-]/g,'-'),graph.source.replace(/^\\s*click\\s+[
|
|
313
|
+
function sanitizeGraphSvg(markup){const parsed=new DOMParser().parseFromString(markup.replace(/^<svg\\b/,'<svg xmlns:xlink="http://www.w3.org/1999/xlink"'),'image/svg+xml');const source=parsed.documentElement;if(source.localName!=='svg'||parsed.querySelector('parsererror'))throw new Error('Invalid SVG');const ns='http://www.w3.org/2000/svg';const elements=new Set(['svg','g','path','rect','circle','ellipse','line','polyline','polygon','text','tspan','defs','marker','title','desc','clipPath']);const attributes=new Set(['id','class','viewBox','width','height','x','y','x1','y1','x2','y2','cx','cy','r','rx','ry','d','points','transform','fill','stroke','stroke-width','stroke-dasharray','stroke-linecap','stroke-linejoin','opacity','fill-opacity','stroke-opacity','font-size','font-family','font-weight','text-anchor','dominant-baseline','markerWidth','markerHeight','markerUnits','refX','refY','orient','preserveAspectRatio','clip-path','marker-start','marker-mid','marker-end','role','aria-label']);const clean=node=>{if(node.nodeType===3)return document.createTextNode(node.textContent||'');if(node.nodeType!==1||node.namespaceURI!==ns||!elements.has(node.localName))return null;const out=document.createElementNS(ns,node.localName);for(const attr of node.attributes){if(!attr.namespaceURI&&attr.name==='style'){for(const declaration of attr.value.split(';')){const split=declaration.indexOf(':');if(split<0)continue;const property=declaration.slice(0,split).trim();const cssValue=declaration.slice(split+1).trim();if((property==='stroke'||property==='fill')&&/^(?:#[0-9a-fA-F]{3,8}|rgb\\([0-9.,%\\s]+\\)|none|currentColor)$/.test(cssValue))out.setAttribute(property,cssValue);else if(property==='stroke-width'&&/^[0-9]+(?:\\.[0-9]+)?(?:px)?$/.test(cssValue))out.setAttribute(property,cssValue);}continue;}if(attr.namespaceURI||!attributes.has(attr.name))continue;if(['marker-start','marker-mid','marker-end','clip-path'].includes(attr.name)){if(!attr.value.startsWith('url(#')||!attr.value.endsWith(')')||!(/^[a-zA-Z0-9_-]+$/.test(attr.value.slice(5,-1))))continue;}else if(attr.value.toLowerCase().includes('url(')||attr.value.includes('<')||attr.value.includes('>'))continue;out.setAttribute(attr.name,attr.value);}for(const child of node.childNodes){const safe=clean(child);if(safe)out.appendChild(safe);}return out;};return clean(source);}
|
|
314
|
+
function applyGraphPalette(svg){const ns='http://www.w3.org/2000/svg';for(const path of svg.querySelectorAll('path')){if(path.closest('marker')){if(!path.hasAttribute('fill'))path.setAttribute('fill','#65768a');continue;}if(!path.hasAttribute('fill'))path.setAttribute('fill','none');if(!path.hasAttribute('stroke'))path.setAttribute('stroke','#65768a');}for(const node of svg.querySelectorAll('g.node')){const error=node.classList.contains('failure')||node.classList.contains('exception');for(const shape of node.querySelectorAll('rect,polygon,circle,ellipse')){shape.setAttribute('fill',error?'#ffe4e4':'#f9fbff');shape.setAttribute('stroke',error?'#be3030':'#657f9e');shape.setAttribute('stroke-width','1.3');}for(const label of node.querySelectorAll('text'))label.setAttribute('fill',node.classList.contains('drillable')?'#2367b1':error?'#7c1818':'#182536');}for(const rect of svg.querySelectorAll('rect')){if(!rect.closest('g.node')&&!rect.hasAttribute('fill'))rect.setAttribute('fill','#ffffff');}for(const label of svg.querySelectorAll('text'))if(!label.hasAttribute('fill'))label.setAttribute('fill','#182536');}
|
|
315
|
+
async function renderSelectedGraph(){const graph=selectedWorkflowGraph();renderGraphNavigation();document.querySelector('#graph-detail').hidden=true;if(!graph){graphTitle.textContent='Graph not found';graphPath.textContent='';graphSource.textContent='';graphCanvas.replaceChildren(element('p','error-state',text('Graph not found: '+selectedGraphId+'. Select Entry or another graph.')));return;}graphTitle.textContent=graph.name;graphPath.textContent=graph.path;graphSource.textContent=graph.source;graphCanvas.replaceChildren(element('p','subtle',text('Rendering…')));try{if(!window.mermaid)throw new Error('Mermaid renderer unavailable');if(!window.__relayMermaidReady){window.mermaid.initialize({startOnLoad:false,securityLevel:'strict',flowchart:{htmlLabels:false}});window.__relayMermaidReady=true;}const rendered=await window.mermaid.render('relay-graph-'+graph.id.replace(/[^A-Za-z0-9_-]/g,'-'),graph.source.replace(/^\\s*click\\s+[^\\n]*$/gm,''));if(graph.id!==selectedGraphId)return;const svg=sanitizeGraphSvg(rendered.svg);graphCanvas.replaceChildren(svg);if(graph.id!=='entry'){const events=[...new Set([...graph.source.matchAll(/\\|on:\\s*([A-Za-z0-9_.-]+)\\|/g)].map(m=>m[1]))];const links=document.querySelector('#graph-links');for(const event of events){const button=element('button','refresh-button',text('Event: '+event));button.addEventListener('click',()=>graphDetail('Event',event));links.append(button);}}const definitions=graphNodeDefinitions(graph.source);for(const node of svg.querySelectorAll('g.node')){const entry=Array.from(definitions.entries()).find(([key])=>node.id.includes('-flowchart-'+key+'-'));const label=entry?.[1]||'';if(label)addNodeLabel(node,label);const target=entry?.[0];const next=target?graphLinks(graph.source).get(target):null;if(next){interactiveNode(node,()=>navigateGraph(next),'Explore '+target);}else{const match=/^(state|guard|action):\\s*([A-Za-z0-9_.-]+)/.exec(label);if(match)interactiveNode(node,()=>graphDetail(match[1]==='guard'?'Guard':match[1]==='action'?'Action':'State',match[2]),'Inspect '+match[1]+' '+match[2]);}}applyGraphPalette(svg);}catch(error){graphCanvas.replaceChildren(element('p','error-state',text('Visual preview unavailable: '+error.message+'. Mermaid source remains available below.')));}}
|
|
316
316
|
async function drawGraphs(){try{const graphs=await loadJSON('/api/graphs');workflowGraphs=Array.isArray(graphs)?graphs:[];const requested=graphFromHash();selectedGraphId=requested||'main';graphsMeta.textContent=(workflowGraphs.length-1)+' executable workflow graphs · main is conceptual';renderGraphList();await renderSelectedGraph();}catch(error){graphsMeta.textContent='Unavailable';workflowGraphs=[];selectedGraphId='main';renderGraphList();await renderSelectedGraph();graphPath.textContent='Executable graphs unavailable: '+error.message;}}
|
|
317
317
|
function renderAudit(records){auditLog.replaceChildren();if(!Array.isArray(records)||!records.length){auditLog.append(element('p','empty',text('No schema v5 routing decisions yet.')));return;}for(const record of records){const card=element('article','audit-entry');const head=element('div','audit-entry-head');head.append(element('strong','',text(routeWorker(record)+' · '+value(record?.selected?.model?.model))),element('span','subtle',text(new Date(record.timestamp).toLocaleString())));card.append(head);const correlation=record.correlation||{};card.append(element('div','subtle',text([correlation.job_id,correlation.task_id].filter(Boolean).join(' / ')||'uncorrelated')));const probabilities=record.decision_evidence?.probabilities||{};const gates=(record.gate_results||[]).filter(item=>!item.eligible).map(item=>item.candidate_id+': '+item.rejection_reasons.join(', '));const nl=String.fromCharCode(10);const details=document.createElement('pre');details.textContent='fallback: '+String(Boolean(record.decision_evidence?.fallback))+(Object.keys(probabilities).length?nl+'probabilities: '+JSON.stringify(probabilities,null,2):'')+(gates.length?nl+'rejected: '+gates.join(nl):'');card.append(details);auditLog.append(card);}}
|
|
318
318
|
async function drawAudit(){auditRefresh.disabled=true;try{renderAudit(await loadJSON('/api/decision-audit'));}catch(error){auditLog.replaceChildren(element('p','error-state',text('Audit unavailable: '+error.message)));}finally{auditRefresh.disabled=false;}}
|
package/dist/dashboard-style.js
CHANGED
|
@@ -100,6 +100,7 @@ export const dashboardStyles = ` <style>
|
|
|
100
100
|
.graph-canvas { min-height:24rem; display:grid; place-items:center; overflow:auto; padding:1rem; border:1px solid var(--border); border-radius:.45rem; background:var(--surface2); }
|
|
101
101
|
.graph-canvas svg { max-width:none; height:auto; }
|
|
102
102
|
.graph-canvas svg rect.background { display:none; }
|
|
103
|
+
.graph-canvas svg .edgeLabel rect, .graph-canvas svg .edgeLabel .labelBkg, .graph-canvas svg .edgeLabel .background { fill:transparent !important; opacity:0 !important; }
|
|
103
104
|
.graph-navigation { display:flex; flex-wrap:wrap; align-items:center; gap:.5rem; margin-bottom:.7rem; }
|
|
104
105
|
.graph-canvas .node.drillable { cursor:pointer; }
|
|
105
106
|
.graph-canvas .node.drillable text, .graph-canvas .node.drillable tspan { fill:#2367b1; }
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { recordEscalationNotificationDelivery } from './task-recovery.js';
|
|
2
|
+
function receiptValid(value) {
|
|
3
|
+
return typeof value.channel === 'string' && value.channel.trim().length > 0
|
|
4
|
+
&& typeof value.deliveryId === 'string' && value.deliveryId.trim().length > 0
|
|
5
|
+
&& typeof value.deliveredAt === 'string' && Number.isFinite(Date.parse(value.deliveredAt));
|
|
6
|
+
}
|
|
7
|
+
/** Configured trusted downstream notifier. HTTP success alone is never a delivery receipt. */
|
|
8
|
+
export function escalationNotifierFromEnvironment(env = process.env) {
|
|
9
|
+
const raw = env.AGENTS_RELAY_ESCALATION_NOTIFY_URL?.trim();
|
|
10
|
+
if (!raw)
|
|
11
|
+
return undefined;
|
|
12
|
+
const url = new URL(raw);
|
|
13
|
+
if (url.username || url.password || url.hash || !(url.protocol === 'https:' || (url.protocol === 'http:' && ['localhost', '127.0.0.1', '::1', '[::1]'].includes(url.hostname))))
|
|
14
|
+
throw Error('Escalation notifier requires HTTPS or a loopback HTTP endpoint without URL credentials');
|
|
15
|
+
const token = env.AGENTS_RELAY_ESCALATION_NOTIFY_TOKEN?.trim();
|
|
16
|
+
return async (request) => {
|
|
17
|
+
const response = await fetch(url, { method: 'POST', redirect: 'error',
|
|
18
|
+
signal: AbortSignal.timeout(10000),
|
|
19
|
+
headers: { 'Content-Type': 'application/json', 'Idempotency-Key': request.idempotencyKey,
|
|
20
|
+
...(token ? { 'Authorization': `Bearer ${token}` } : {}) },
|
|
21
|
+
body: JSON.stringify(request),
|
|
22
|
+
});
|
|
23
|
+
if (!response.ok)
|
|
24
|
+
throw Error(`Notification endpoint HTTP ${response.status}`);
|
|
25
|
+
const body = await response.json();
|
|
26
|
+
if (body.delivered !== true || !receiptValid(body))
|
|
27
|
+
throw Error('Notification endpoint did not provide a verified delivery ID');
|
|
28
|
+
return { channel: body.channel, deliveryId: body.deliveryId, deliveredAt: body.deliveredAt };
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
const retryDelayMs = (attempts) => Math.min(3600000, 60000 * 2 ** Math.min(6, Math.max(0, attempts - 1)));
|
|
32
|
+
function pending(notification) {
|
|
33
|
+
return notification.status === 'pending' && notification.notification?.status !== 'delivered';
|
|
34
|
+
}
|
|
35
|
+
/** Durable delivery-attempt ledger; a transport ACK without provider receipt leaves status pending. */
|
|
36
|
+
export async function deliverPendingEscalations(store, jobId, notifier, now = new Date()) {
|
|
37
|
+
const job = await store.load(jobId);
|
|
38
|
+
const result = { delivered: 0, pending: 0, failed: 0 };
|
|
39
|
+
const candidates = job.tasks.filter(t => t.agentName === 'troubleshooter' && t.escalation && pending(t.escalation));
|
|
40
|
+
if (candidates.length > 0 && !store.mutateJob) {
|
|
41
|
+
if (notifier)
|
|
42
|
+
throw Error('Escalation delivery with an actual notifier requires atomic SQLite mutation');
|
|
43
|
+
return { delivered: 0, pending: candidates.length, failed: 0 };
|
|
44
|
+
}
|
|
45
|
+
for (const task of candidates) {
|
|
46
|
+
const escalation = task.escalation;
|
|
47
|
+
const details = escalation.notification ?? {};
|
|
48
|
+
if (typeof details.nextAttemptAt === 'string' && Date.parse(details.nextAttemptAt) > now.getTime()) {
|
|
49
|
+
result.pending++;
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (!notifier) {
|
|
53
|
+
if (details.lastError !== 'Notification delivery channel is not configured') {
|
|
54
|
+
await store.mutateJob(jobId, current => {
|
|
55
|
+
const candidate = current.tasks.find(t => t.id === task.id);
|
|
56
|
+
if (candidate?.escalation?.id === escalation.id && pending(candidate.escalation))
|
|
57
|
+
candidate.escalation.notification = { ...candidate.escalation.notification, status: 'pending', lastError: 'Notification delivery channel is not configured' };
|
|
58
|
+
return current;
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
result.pending++;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const request = {
|
|
65
|
+
jobId, taskId: task.id, escalationId: escalation.id,
|
|
66
|
+
idempotencyKey: `escalation/${escalation.id}`,
|
|
67
|
+
requiredAction: escalation.requiredAction, resolutionCondition: escalation.resolutionCondition, context: escalation.context,
|
|
68
|
+
};
|
|
69
|
+
try {
|
|
70
|
+
const receipt = await notifier(request);
|
|
71
|
+
if (!receiptValid(receipt))
|
|
72
|
+
throw Error('Notifier returned no verifiable delivery receipt');
|
|
73
|
+
await recordEscalationNotificationDelivery(store, jobId, task.id, escalation.id, receipt);
|
|
74
|
+
result.delivered++;
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
78
|
+
await store.mutateJob(jobId, current => {
|
|
79
|
+
const candidate = current.tasks.find(t => t.id === task.id);
|
|
80
|
+
if (candidate?.escalation?.id !== escalation.id || !pending(candidate.escalation))
|
|
81
|
+
return current;
|
|
82
|
+
const previous = candidate.escalation.notification ?? {};
|
|
83
|
+
const attempts = (typeof previous.attempts === 'number' ? previous.attempts : 0) + 1;
|
|
84
|
+
candidate.escalation.notification = { ...previous, status: 'pending', attempts, lastError: message,
|
|
85
|
+
lastAttemptAt: now.toISOString(), nextAttemptAt: new Date(now.getTime() + retryDelayMs(attempts)).toISOString() };
|
|
86
|
+
return current;
|
|
87
|
+
});
|
|
88
|
+
result.failed++;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return result;
|
|
92
|
+
}
|
|
@@ -28,6 +28,35 @@ export class LocalAuthorityDurableStore {
|
|
|
28
28
|
this.noGitHub = noGitHub;
|
|
29
29
|
this.projection = new GitHubProjectionWriter(authority, github, undefined, true);
|
|
30
30
|
}
|
|
31
|
+
async reserveWorkflowLaunch(identity) {
|
|
32
|
+
if (!this.authority.reserveWorkflowLaunch)
|
|
33
|
+
throw Error('authoritative workflow launch ledger unavailable');
|
|
34
|
+
return this.authority.reserveWorkflowLaunch(identity);
|
|
35
|
+
}
|
|
36
|
+
async verifyWorkflowLaunch(jobId, taskId, executionId) {
|
|
37
|
+
if (!this.authority.verifyWorkflowLaunch)
|
|
38
|
+
throw Error('authoritative workflow launch verifier unavailable');
|
|
39
|
+
return this.authority.verifyWorkflowLaunch(jobId, taskId, executionId);
|
|
40
|
+
}
|
|
41
|
+
async commitWorkflowMutationAndEvents(jobId, mutation, events) {
|
|
42
|
+
if (!this.authority.commitWorkflowMutationAndEvents)
|
|
43
|
+
throw new Error('SQLite workflow outbox capability unavailable');
|
|
44
|
+
const committed = await this.authority.commitWorkflowMutationAndEvents(jobId, mutation, events);
|
|
45
|
+
publishLocalCommit(committed);
|
|
46
|
+
return structuredClone(committed);
|
|
47
|
+
}
|
|
48
|
+
async commitWorkflowMutationWithEvents(jobId, mutation) {
|
|
49
|
+
if (!this.authority.commitWorkflowMutationWithEvents)
|
|
50
|
+
throw Error('SQLite workflow outbox capability unavailable');
|
|
51
|
+
const result = await this.authority.commitWorkflowMutationWithEvents(jobId, mutation);
|
|
52
|
+
publishLocalCommit(result);
|
|
53
|
+
return structuredClone(result);
|
|
54
|
+
}
|
|
55
|
+
async drainWorkflowEvents(publish) {
|
|
56
|
+
if (!this.authority.drainWorkflowEvents)
|
|
57
|
+
throw new Error('SQLite workflow outbox capability unavailable');
|
|
58
|
+
return this.authority.drainWorkflowEvents(publish);
|
|
59
|
+
}
|
|
31
60
|
/** Queue one retryable PR merge; do not mutate Job lifecycle until external confirmation. */
|
|
32
61
|
async mergePullRequest(expectedHeadSha) {
|
|
33
62
|
if (this.noGitHub)
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
1
2
|
import { eventFor } from './events.js';
|
|
2
3
|
import { transitionJobEvent } from './scheduler.js';
|
|
3
4
|
import { deliverLocalMerge, localMergeEligible, requiresLocalGitMerge } from './local-git-merge.js';
|
|
4
5
|
import { errorMessage } from './observability.js';
|
|
5
6
|
/** Graph-selected durable local merge and completion; no checked-out worktree is ever changed. */
|
|
6
|
-
export async function reconcileLocalGitCompletion(store, finalState, headSha, noGitHub, projectPathForJob, emit) {
|
|
7
|
+
export async function reconcileLocalGitCompletion(store, finalState, headSha, noGitHub, projectPathForJob, emit, mergeEffect = deliverLocalMerge, now = Date.now) {
|
|
7
8
|
if (localMergeEligible(finalState) && !requiresLocalGitMerge(finalState)) {
|
|
8
9
|
await transitionJobEvent(finalState, 'local.completed', { localCompletionAllowed: true });
|
|
9
10
|
await store.saveJob(finalState);
|
|
@@ -14,7 +15,7 @@ export async function reconcileLocalGitCompletion(store, finalState, headSha, no
|
|
|
14
15
|
const receipt = finalState.localMerge;
|
|
15
16
|
let activeReceipt = receipt;
|
|
16
17
|
if (receipt.status !== 'succeeded' &&
|
|
17
|
-
(!receipt.nextAttemptAt || Date.parse(receipt.nextAttemptAt) <=
|
|
18
|
+
(!receipt.nextAttemptAt || Date.parse(receipt.nextAttemptAt) <= now())) {
|
|
18
19
|
await transitionJobEvent(finalState, 'local.merge.requested', { localMergeReady: true }, async (actionId) => {
|
|
19
20
|
if (actionId !== 'effect.deliverLocalMerge')
|
|
20
21
|
throw new Error('Unsupported local merge Graph effect ' + actionId);
|
|
@@ -28,7 +29,7 @@ export async function reconcileLocalGitCompletion(store, finalState, headSha, no
|
|
|
28
29
|
if (!projectPathForJob)
|
|
29
30
|
throw new Error('Local Git merge requires an explicit project path');
|
|
30
31
|
const repoPath = await projectPathForJob(finalState);
|
|
31
|
-
const delivered =
|
|
32
|
+
const delivered = mergeEffect(repoPath, { ...activeReceipt, sourceSha: activeReceipt.sourceSha ?? headSha ?? undefined,
|
|
32
33
|
correlationJobId: finalState.id, displayPrNumber: finalState.prNumber > 0 ? finalState.prNumber : undefined });
|
|
33
34
|
finalState.localMerge = { ...activeReceipt, ...delivered, status: 'succeeded', projectionStrategy: 'target_ref_push',
|
|
34
35
|
error: undefined, nextAttemptAt: undefined, updatedAt: new Date().toISOString() };
|
|
@@ -37,17 +38,39 @@ export async function reconcileLocalGitCompletion(store, finalState, headSha, no
|
|
|
37
38
|
const attempts = (activeReceipt.attempts ?? 0) + 1;
|
|
38
39
|
finalState.localMerge = { ...activeReceipt, status: 'failed', attempts,
|
|
39
40
|
error: errorMessage(error),
|
|
40
|
-
nextAttemptAt: new Date(
|
|
41
|
+
nextAttemptAt: new Date(now() + Math.min(300_000, 1000 * (2 ** Math.min(attempts, 8)))).toISOString(),
|
|
41
42
|
updatedAt: new Date().toISOString() };
|
|
42
43
|
}
|
|
43
44
|
finalState.updatedAt = new Date().toISOString();
|
|
44
|
-
await store.saveJob(finalState);
|
|
45
45
|
const merge = finalState.localMerge;
|
|
46
|
-
if (merge
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
46
|
+
if (!merge)
|
|
47
|
+
throw new Error('Graph local merge effect produced no receipt');
|
|
48
|
+
const succeeded = merge.status === 'succeeded';
|
|
49
|
+
const reason = merge.error?.includes('tracked or index changes') ? 'dirty_target_worktree' : 'merge_delivery_failed';
|
|
50
|
+
// Event identity is deterministic for one committed effect outcome.
|
|
51
|
+
const idempotencyBasis = `${finalState.id}:${merge.sourceSha ?? ''}:${merge.attempts ?? 0}:${merge.status}:${merge.mergedCommitSha ?? ''}`;
|
|
52
|
+
const eventKey = `workflow-local-merge-${createHash('sha256').update(idempotencyBasis).digest('hex')}`;
|
|
53
|
+
const outcomeEvent = eventFor(finalState.id, 'merge', null, succeeded ? 'job.merge.succeeded' : 'job.merge.failed', succeeded ? 'succeeded' : 'failed', succeeded ? 'Local merge succeeded' : (merge.error ?? 'Local merge failed'), 'user', succeeded ? { mergedCommitSha: merge.mergedCommitSha, sourceSha: merge.sourceSha }
|
|
54
|
+
: { reason, message: merge.error, attempts: merge.attempts, retryable: true, nextAttemptAt: merge.nextAttemptAt }, { id: `job/${finalState.id}/task/merge/execution/attempt-${merge.attempts ?? 0}`, agent: 'relay' });
|
|
55
|
+
outcomeEvent.event_id = eventKey;
|
|
56
|
+
if (store.commitWorkflowMutationAndEvents) {
|
|
57
|
+
// Persist only the Graph-selected merge receipt into the latest
|
|
58
|
+
// authoritative Job; never replace concurrent Task/Review changes.
|
|
59
|
+
const current = await store.commitWorkflowMutationAndEvents(finalState.id, existing => {
|
|
60
|
+
if (existing.state !== 'ACTIVE' || !existing.localMerge
|
|
61
|
+
|| existing.localMerge.sourceRef !== receipt.sourceRef
|
|
62
|
+
|| existing.localMerge.targetRef !== receipt.targetRef
|
|
63
|
+
|| existing.localMerge.status === 'succeeded')
|
|
64
|
+
throw new Error('stale local merge outcome');
|
|
65
|
+
existing.localMerge = structuredClone(merge);
|
|
66
|
+
existing.updatedAt = finalState.updatedAt;
|
|
67
|
+
return existing;
|
|
68
|
+
}, [{ key: eventKey, event: outcomeEvent }]);
|
|
69
|
+
Object.assign(finalState, structuredClone(current));
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
await store.saveJob(finalState);
|
|
73
|
+
await emit?.(outcomeEvent);
|
|
51
74
|
}
|
|
52
75
|
});
|
|
53
76
|
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { materializePlannerChild } from './planner.js';
|
|
2
|
+
import { attachResolutionOwnership } from './workflow/task-resolution.js';
|
|
3
|
+
/** Recover persisted child definitions without inventing new task identities. */
|
|
4
|
+
export async function recoverPlannerChildren(store, job, plannerContext) {
|
|
5
|
+
for (const plan of (job.plans ?? []).filter(item => item.status === 'SUCCEEDED' && item.plannerResult)) {
|
|
6
|
+
const executionContext = await plannerContext(job, plan);
|
|
7
|
+
// Historical plans were validated on commit. Rechecking against later Task failures
|
|
8
|
+
// blocks recovery before the Troubleshooter graph can run.
|
|
9
|
+
const children = plan.plannerResult.next_tasks.map(spec => materializePlannerChild(job, plan, spec, executionContext));
|
|
10
|
+
for (const child of children) {
|
|
11
|
+
const existing = job.tasks.find(item => item.id === child.id);
|
|
12
|
+
if (existing)
|
|
13
|
+
continue;
|
|
14
|
+
const spec = plan.plannerResult.next_tasks.find(item => item.id === child.id);
|
|
15
|
+
if (spec?.resolution)
|
|
16
|
+
await store.saveTask(attachResolutionOwnership(job, child, spec.resolution, 'planner'));
|
|
17
|
+
await store.appendTask(child);
|
|
18
|
+
job.tasks.push(child);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
const historicalPlanners = job.tasks.filter(task => task.kind === 'planner' && task.state === 'SUCCEEDED' && task.plannerResult);
|
|
22
|
+
if (historicalPlanners.length === 0)
|
|
23
|
+
return;
|
|
24
|
+
const executionContext = await plannerContext(job, historicalPlanners[0]);
|
|
25
|
+
for (const planner of historicalPlanners) {
|
|
26
|
+
const result = planner.plannerResult;
|
|
27
|
+
const plan = { jobId: job.id, id: planner.id, round: planner.planningRound ?? 1, source: planner.planningSource ?? 'model-planner', previousPlanId: planner.previousPlannerTaskId ?? null, trigger: planner.planTrigger ?? { source: 'continuation' }, status: 'SUCCEEDED', guidance: planner.plannerGuidance, objectiveStatus: result.objective_status, plannerResult: result, assessment: result.assessment, generatedTaskIds: result.next_tasks.map(item => item.id), headSha: planner.planTrigger?.currentHeadSha ?? job.headSha, attempt: planner.attempt, maxAttempts: planner.maxAttempts, leaseOwner: null, leaseExpiresAt: null, executionId: null, timeoutMs: planner.timeoutMs, manualPlanning: planner.manualPlanning, createdAt: planner.createdAt, updatedAt: planner.updatedAt };
|
|
28
|
+
if (!(job.plans ?? []).some(item => item.id === plan.id)) {
|
|
29
|
+
await store.appendPlan(plan);
|
|
30
|
+
(job.plans ??= []).push(plan);
|
|
31
|
+
}
|
|
32
|
+
else
|
|
33
|
+
continue;
|
|
34
|
+
const children = result.next_tasks.map(spec => materializePlannerChild(job, plan, spec, executionContext));
|
|
35
|
+
for (const child of children)
|
|
36
|
+
if (!job.tasks.some(item => item.id === child.id)) {
|
|
37
|
+
const spec = result.next_tasks.find(item => item.id === child.id);
|
|
38
|
+
if (spec?.resolution)
|
|
39
|
+
await store.saveTask(attachResolutionOwnership(job, child, spec.resolution, 'planner'));
|
|
40
|
+
await store.appendTask(child);
|
|
41
|
+
job.tasks.push(child);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { taskOverallTimeoutMs } from './types.js';
|
|
2
|
+
import { createTaskEntity } from './workflow/entity-creation.js';
|
|
3
|
+
/** Only the Planner orchestration Graph may select this durable recovery effect. */
|
|
4
|
+
export function createFailedPlanRecovery(job, plan) {
|
|
5
|
+
if (plan.status !== 'FAILED')
|
|
6
|
+
throw Error('Plan recovery requires a terminal FAILED Plan');
|
|
7
|
+
const id = 'recover-' + plan.id;
|
|
8
|
+
if (job.tasks.some(task => task.id === id || task.recoveryPlanId === plan.id))
|
|
9
|
+
throw Error('Plan recovery identity already exists');
|
|
10
|
+
const now = new Date().toISOString();
|
|
11
|
+
return createTaskEntity(job, {
|
|
12
|
+
jobId: job.id, id, title: 'Diagnose and recover failed Planner round',
|
|
13
|
+
recoveryPlanId: plan.id,
|
|
14
|
+
kind: 'work', priority: 'P0', storySize: 'M', projectName: job.title,
|
|
15
|
+
agentName: 'troubleshooter', parentTaskId: null, dependencies: [],
|
|
16
|
+
capabilities: ['troubleshoot', 'recovery'],
|
|
17
|
+
workerRequirements: { capabilities: ['model', 'local_repo', 'iterative_tools'], runtime: 'codex' },
|
|
18
|
+
adapter: 'codex',
|
|
19
|
+
input: `Recover FAILED Plan ${plan.id} in Job ${job.id}, repository ${job.repository}.
|
|
20
|
+
Original Plan failure: ${plan.error ?? plan.assessment}.
|
|
21
|
+
Inspect durable Plan history and original model/runtime error; repair or verify the actual blocker.
|
|
22
|
+
Do not fabricate Planner success, create another Job, or publish blocked.
|
|
23
|
+
Report task.completed only after verified technical recovery, with data.verified=true and non-empty data.evidence explaining the repair.
|
|
24
|
+
Relay will create a NEW authoritative Plan round after your completed recovery evidence; it will not re-label the FAILED Plan as successful.`,
|
|
25
|
+
output: { kind: 'task_pr' }, continuation: null, continuationDeliveredAt: null,
|
|
26
|
+
attempt: 0, maxAttempts: 3, leaseOwner: null, leaseExpiresAt: null,
|
|
27
|
+
executionId: null, threadId: null, result: null, plannerResult: null, error: null,
|
|
28
|
+
timeoutMs: taskOverallTimeoutMs('M'), createdAt: now, updatedAt: now,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
@@ -14,6 +14,10 @@ import { reviewGenerationMatches } from './review-generation-fence.js';
|
|
|
14
14
|
import { codexFallbackRouting, quarantineUnavailableCodexFallback } from './chatgpt-platform-fallback.js';
|
|
15
15
|
export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
|
|
16
16
|
async currentHeadSha(job) {
|
|
17
|
+
// Tests may replace the external Git effect entirely; in that case its
|
|
18
|
+
// immutable input SHA is already present in the authoritative Job.
|
|
19
|
+
if (this.options.noGitHub && this.options.localMergeEffect)
|
|
20
|
+
return job.headSha ?? job.review?.headSha ?? null;
|
|
17
21
|
// GitHub PR head facts are audit-only. Source identity is local Git.
|
|
18
22
|
if (!this.options.projectPathForJob)
|
|
19
23
|
return job.headSha ?? job.review?.headSha ?? null;
|
|
@@ -463,6 +467,11 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
|
|
|
463
467
|
await this.store.saveTask(task);
|
|
464
468
|
await this.store.saveJob(job);
|
|
465
469
|
}
|
|
470
|
+
if (event.type === 'review.started' && this.store.verifyWorkflowLaunch) {
|
|
471
|
+
const outcome = await this.store.verifyWorkflowLaunch(job.id, task.id, task.executionId);
|
|
472
|
+
if (outcome === 'Failed')
|
|
473
|
+
throw Error('reviewer start postcondition violated');
|
|
474
|
+
}
|
|
466
475
|
// Review approval records evidence only. The subsequent Graph reconcile
|
|
467
476
|
// drives durable local Git merge delivery and terminalization.
|
|
468
477
|
if (outcome === 'approved')
|
|
@@ -124,7 +124,7 @@ export class ReconcilerRuntimeBase {
|
|
|
124
124
|
return async () => { };
|
|
125
125
|
}
|
|
126
126
|
}
|
|
127
|
-
async idle() { while ([...this.live.values()].some(item => !item.processSettled) || this.plannerLive.size > 0 || this.continuationLive.size > 0 || this.reconciling || !this.eventCoordinator.isIdle())
|
|
127
|
+
async idle() { while ([...this.live.values()].some(item => !item.processSettled) || this.plannerLive.size > 0 || this.continuationLive.size > 0 || this.reconciling || this.detachedReconciles.size > 0 || !this.eventCoordinator.isIdle())
|
|
128
128
|
await new Promise(resolve => setTimeout(resolve, 25)); }
|
|
129
129
|
async close() {
|
|
130
130
|
await this.eventCoordinator.settle();
|
|
@@ -220,6 +220,12 @@ export class ReconcilerRuntimeBase {
|
|
|
220
220
|
});
|
|
221
221
|
if (!transition?.decision.accepted)
|
|
222
222
|
return { applied: false, reconcile: false, reason: 'task.started.ignored' };
|
|
223
|
+
if (this.store.verifyWorkflowLaunch && transition.task.executionId) {
|
|
224
|
+
const outcome = await this.store.verifyWorkflowLaunch(event.job_id, event.task_id, transition.task.executionId);
|
|
225
|
+
if (outcome === 'Failed')
|
|
226
|
+
throw Error('worker start postcondition violated');
|
|
227
|
+
// Unknown remains durable and never authorizes repeating this generation.
|
|
228
|
+
}
|
|
223
229
|
if (live?.executionId === transition.task.executionId)
|
|
224
230
|
live.workerStarted = true;
|
|
225
231
|
return { applied: true, reconcile: transition.from === 'FAILED', reason: transition.from === 'FAILED' ? 'task.started.recovery' : 'task.started', reconcileMode: transition.from === 'FAILED' ? 'detached' : undefined };
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
/** Propagate transport failures. Pending SQLite events replay after restart. */
|
|
2
|
+
export async function drainProductionWorkflowOutbox(store, options) {
|
|
3
|
+
if (!store.drainWorkflowEvents || (!options.eventBus && !options.emit))
|
|
4
|
+
return 0;
|
|
5
|
+
return store.drainWorkflowEvents(async (event) => {
|
|
6
|
+
if (options.eventBus)
|
|
7
|
+
await options.eventBus.publish(event);
|
|
8
|
+
if (options.emit)
|
|
9
|
+
await options.emit(event);
|
|
10
|
+
});
|
|
11
|
+
}
|