agents-relay 1.0.103 → 1.0.104
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/agent-graph.js +14 -0
- package/dist/cli-help.js +3 -1
- package/dist/cli-support.js +10 -0
- package/dist/cli.js +11 -1
- package/dist/dashboard-html.js +38 -6
- package/dist/dashboard.js +42 -0
- package/dist/decision-audit.js +58 -0
- package/dist/execution-context.js +1 -0
- package/dist/model-redecision.js +41 -0
- package/dist/model-routing.js +41 -15
- package/dist/planner.js +8 -1
- package/dist/reconciler-review.js +1 -1
- package/dist/reconciler-runtime.js +117 -127
- package/dist/reconciler.js +12 -6
- package/dist/relayd.js +3 -1
- package/dist/routing-policy.js +75 -0
- package/dist/runtime-status.js +2 -0
- package/dist/scheduler.js +10 -4
- package/dist/troubleshooter.js +1 -1
- package/dist/workflow/contracts.js +4 -3
- package/dist/workflow/definitions.js +1 -0
- package/dist/workflow/durable-transition.js +45 -0
- package/dist/workflow/runtime.js +7 -0
- package/package.json +1 -1
- package/skills/agents-relay/agents/implementation.agent.md +24 -0
- package/skills/agents-relay/agents/planner.agent.md +2 -0
- package/workflow/task-lifecycle.mmd +9 -3
package/dist/agent-graph.js
CHANGED
|
@@ -66,8 +66,22 @@ export function parseAgentGraph(source) {
|
|
|
66
66
|
throw new Error('Agent Graph contains a cycle');
|
|
67
67
|
return { source, nodes: [...agents].map(([id, agent]) => ({ id, agent })), edges, waves };
|
|
68
68
|
}
|
|
69
|
+
export function validateAgentSelection(context, agentName, agentGraph) {
|
|
70
|
+
const visible = new Set(context.agents.map(agent => agent.name));
|
|
71
|
+
if (agentName && !visible.has(agentName)) {
|
|
72
|
+
throw new Error(`Agent ${agentName} is not visible in the resolved planning/execution context`);
|
|
73
|
+
}
|
|
74
|
+
if (!agentGraph)
|
|
75
|
+
return;
|
|
76
|
+
for (const node of parseAgentGraph(agentGraph).nodes) {
|
|
77
|
+
if (!visible.has(node.agent)) {
|
|
78
|
+
throw new Error(`Agent ${node.agent} referenced by agentGraph node ${node.id} is not visible in the resolved planning/execution context`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
69
82
|
export async function agentGraphPrompt(context, source) {
|
|
70
83
|
const graph = parseAgentGraph(source);
|
|
84
|
+
validateAgentSelection(context, undefined, source);
|
|
71
85
|
const contracts = await Promise.all(graph.nodes.map(async (node) => ({ node, prompt: await agentContractPrompt(context, node.agent) })));
|
|
72
86
|
const incoming = new Map(graph.nodes.map(node => [node.id, graph.edges.filter(edge => edge.to === node.id).map(edge => edge.from)]));
|
|
73
87
|
return `[Task Agent Graph]\n${source.trim()}\n\nExecution waves: ${graph.waves.map((wave, index) => `${index + 1}:${wave.join(',')}`).join(' | ')}\n\nExecute this graph inside the current durable Task. Nodes are reasoning roles, NOT durable child Tasks. Preserve the original Job/Task context for every node. Nodes in the same wave are independent: evaluate each from the same original context without using sibling prose. For each later node, provide the original context plus structured outputs only from its declared predecessors. Do not emit outer task.started or task terminal events per node; the current durable Task owns exactly one start and one terminal event.\n\n${contracts.map(({ node, prompt }) => `[Node ${node.id}; predecessors=${(incoming.get(node.id) ?? []).join(',') || 'none'}]\n${prompt}`).join('\n\n')}`;
|
package/dist/cli-help.js
CHANGED
|
@@ -156,10 +156,12 @@ Common: --task-id ID, --commit SHA, --project NAME.
|
|
|
156
156
|
|
|
157
157
|
Example:
|
|
158
158
|
npx agents-relay record --file .agents-relay.json --summary "Built release" --commit abc123`,
|
|
159
|
-
reconcile: `Reconcile a job once
|
|
159
|
+
reconcile: `Reconcile a job once by requesting one reconciliation pass.
|
|
160
160
|
|
|
161
161
|
Required: --file PATH for local demo mode, or --repo OWNER/REPO --pr NUMBER --id JOB_ID.
|
|
162
162
|
|
|
163
|
+
Managed GitHub jobs are reconciled by agents-relayd. The CLI publishes a job.wake request and does not launch workers; use --events nats.
|
|
164
|
+
|
|
163
165
|
Example:
|
|
164
166
|
npx agents-relay reconcile --repo OWNER/REPO --pr 12 --id job-1`,
|
|
165
167
|
tick: `Process autonomous jobs indexed into current/recent GitHub next-run buckets.
|
package/dist/cli-support.js
CHANGED
|
@@ -74,6 +74,16 @@ export async function publishWake(bus, job, taskId, message) { if (!bus)
|
|
|
74
74
|
catch (error) {
|
|
75
75
|
process.stderr.write(`warning: event wake unavailable: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
76
76
|
} }
|
|
77
|
+
export async function requestManagedReconciliation(bus, job) {
|
|
78
|
+
if (!bus)
|
|
79
|
+
throw new Error('Managed GitHub reconciliation requires --events nats so agents-relayd can execute the job');
|
|
80
|
+
try {
|
|
81
|
+
await bus.publish(eventFor(job.id, 'reconcile', null, 'job.wake', 'queued', `Reconcile requested for ${job.id}`, 'orchestrator', { repository: job.repository, prNumber: job.prNumber }));
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
throw new Error(`Unable to deliver managed reconciliation wake to agents-relayd: ${error instanceof Error ? error.message : String(error)}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
77
87
|
export function priority(args, fallback = 'P2') { const value = arg(args, '--priority', fallback); if (!['P0', 'P1', 'P2', 'P3'].includes(value))
|
|
78
88
|
throw new Error('--priority must be P0, P1, P2, or P3'); return value; }
|
|
79
89
|
export function storySize(args, fallback = 'M') { const value = arg(args, '--story-size', fallback).toUpperCase(); if (!['S', 'M', 'L'].includes(value))
|
package/dist/cli.js
CHANGED
|
@@ -22,7 +22,7 @@ import { defaultWorkspaceRoot, resolveManagedProjectPath } from './workspace.js'
|
|
|
22
22
|
import { runBlockerCommand } from './cli-blocker.js';
|
|
23
23
|
import { helpText, usage } from './cli-help.js';
|
|
24
24
|
export { helpText } from './cli-help.js';
|
|
25
|
-
import { SERVICE_DEFAULTS, ServiceEventBus, arg, hasArg, textOption, integerArg, repository, storeFor, saveLocal, githubStore, eventBus, publishWake, priority, storySize, executionMode, chatGptTabClosePolicy, requestedExecutionMode, continuation, scheduleFromArgs, } from './cli-support.js';
|
|
25
|
+
import { SERVICE_DEFAULTS, ServiceEventBus, arg, hasArg, textOption, integerArg, repository, storeFor, saveLocal, githubStore, eventBus, publishWake, priority, storySize, executionMode, chatGptTabClosePolicy, requestedExecutionMode, continuation, scheduleFromArgs, requestManagedReconciliation, } from './cli-support.js';
|
|
26
26
|
export { SERVICE_DEFAULTS, eventBus, executionMode, chatGptTabClosePolicy } from './cli-support.js';
|
|
27
27
|
export async function ensureManagedGitHubJob(client, repositoryName, trustedAuthors, options) {
|
|
28
28
|
if (!options.id)
|
|
@@ -646,6 +646,16 @@ async function main() {
|
|
|
646
646
|
console.log(JSON.stringify(await store.load(job.id), null, 2));
|
|
647
647
|
return;
|
|
648
648
|
}
|
|
649
|
+
if (command === 'reconcile' && !localFile) {
|
|
650
|
+
try {
|
|
651
|
+
await requestManagedReconciliation(bus, job);
|
|
652
|
+
console.log(JSON.stringify(await store.load(job.id), null, 2));
|
|
653
|
+
}
|
|
654
|
+
finally {
|
|
655
|
+
await bus?.close?.();
|
|
656
|
+
}
|
|
657
|
+
return;
|
|
658
|
+
}
|
|
649
659
|
const reconciler = runtime(store, args, bus);
|
|
650
660
|
if (command === 'reconcile') {
|
|
651
661
|
await reconciler.reconcile(job.id);
|
package/dist/dashboard-html.js
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
const packageVersion = createRequire(import.meta.url)('../package.json').version;
|
|
1
3
|
export const dashboardHtml = `<!doctype html>
|
|
2
4
|
<html lang="en" data-dashboard-contract="safe-dom,named-relay-events,snapshot-events,bounded-event-history">
|
|
3
5
|
<head>
|
|
@@ -11,6 +13,7 @@ export const dashboardHtml = `<!doctype html>
|
|
|
11
13
|
body { margin:0; min-height:100vh; background:var(--bg); color:var(--text); font:14px/1.45 ui-sans-serif,system-ui,sans-serif; }
|
|
12
14
|
header { position:sticky; top:0; z-index:3; background:var(--surface); border-bottom:1px solid var(--border); padding:.8rem 1rem; }
|
|
13
15
|
.brand { display:flex; align-items:baseline; gap:.65rem; min-width:0; }
|
|
16
|
+
.version { margin-left:auto; color:var(--muted); font-size:.72rem; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; white-space:nowrap; }
|
|
14
17
|
h1 { margin:0; font-size:1.08rem; letter-spacing:-.02em; }
|
|
15
18
|
.tagline,.subtle { color:var(--muted); font-size:.78rem; }
|
|
16
19
|
.warning-banner { margin:.65rem 0 0; padding:.5rem .65rem; border:1px solid #e6bf62; border-radius:.4rem; background:#fff4d6; color:#6f4b00; font-size:.78rem; font-weight:700; }
|
|
@@ -83,7 +86,13 @@ export const dashboardHtml = `<!doctype html>
|
|
|
83
86
|
.event-meta { display:flex; flex-wrap:wrap; gap:.4rem; color:var(--muted); font-size:.68rem; }
|
|
84
87
|
.event-kind { color:var(--accent); font-weight:800; }
|
|
85
88
|
.event-message { margin:.12rem 0 0; overflow-wrap:anywhere; }
|
|
86
|
-
#usage-view { grid-column:1 / -1; }
|
|
89
|
+
#usage-view,#rules-view { grid-column:1 / -1; }
|
|
90
|
+
.rules-editor { display:grid; gap:.65rem; }
|
|
91
|
+
.rules-help { margin:0; color:var(--muted); font-size:.8rem; }
|
|
92
|
+
.rules-textarea { width:100%; min-height:55vh; resize:vertical; border:1px solid var(--border); border-radius:.45rem; background:var(--surface2); color:var(--text); padding:.8rem; font:13px/1.55 ui-monospace,SFMono-Regular,Menlo,monospace; }
|
|
93
|
+
.rules-footer { display:flex; align-items:center; justify-content:space-between; gap:.75rem; flex-wrap:wrap; }
|
|
94
|
+
.rules-save { border:1px solid var(--accent); border-radius:.35rem; background:var(--accent); color:#fff; padding:.38rem .8rem; font:inherit; font-weight:750; cursor:pointer; }
|
|
95
|
+
.rules-save:disabled { opacity:.55; cursor:default; }
|
|
87
96
|
.usage-providers { display:grid; gap:.55rem; }
|
|
88
97
|
.usage-provider { display:grid; grid-template-columns:minmax(130px,180px) minmax(0,1fr); gap:1rem; align-items:center; padding:.7rem .8rem; border:1px solid var(--border); border-radius:.45rem; background:var(--surface2); }
|
|
89
98
|
.usage-provider h3 { margin:0; font-size:.86rem; }
|
|
@@ -103,11 +112,12 @@ export const dashboardHtml = `<!doctype html>
|
|
|
103
112
|
</head>
|
|
104
113
|
<body>
|
|
105
114
|
<header>
|
|
106
|
-
<div class="brand"><h1>Agents Relay Console</h1><p class="tagline">Durable PR state is authoritative; events are observability.</p></div>
|
|
115
|
+
<div class="brand"><h1>Agents Relay Console</h1><p class="tagline">Durable PR state is authoritative; events are observability.</p><span class="version">v${packageVersion}</span></div>
|
|
107
116
|
<div id="rate-limit-warning" class="warning-banner" role="status" hidden></div>
|
|
108
117
|
<nav role="tablist" aria-label="Console views">
|
|
109
118
|
<button id="jobs-tab" type="button" role="tab" aria-selected="true" aria-controls="jobs-view">Jobs</button>
|
|
110
119
|
<button id="usage-tab" type="button" role="tab" aria-selected="false" aria-controls="usage-view">Usage</button>
|
|
120
|
+
<button id="rules-tab" type="button" role="tab" aria-selected="false" aria-controls="rules-view">Model Router Rules</button>
|
|
111
121
|
</nav>
|
|
112
122
|
</header>
|
|
113
123
|
<main>
|
|
@@ -137,6 +147,18 @@ export const dashboardHtml = `<!doctype html>
|
|
|
137
147
|
<div class="panel-body" id="usage-root" aria-live="polite"><div class="skeleton"></div></div>
|
|
138
148
|
</div>
|
|
139
149
|
</section>
|
|
150
|
+
<section id="rules-view" role="tabpanel" aria-labelledby="rules-tab" hidden>
|
|
151
|
+
<div class="panel">
|
|
152
|
+
<div class="panel-head"><h2>Model Router Rules</h2><span id="rules-meta" class="subtle"></span></div>
|
|
153
|
+
<div class="panel-body">
|
|
154
|
+
<div class="rules-editor">
|
|
155
|
+
<p class="rules-help">Free-text guidance loaded by the model decider for every routing decision. Hard capability and health gates remain enforced by code.</p>
|
|
156
|
+
<textarea id="rules-text" class="rules-textarea" spellcheck="true" aria-label="Model router rules" placeholder="Example: Prefer ZAI when paid quota is abundant and task quality is sufficient. If a provider/runtime path just failed, reconsider using the failure evidence and fresh usage."></textarea>
|
|
157
|
+
<div class="rules-footer"><span id="rules-status" class="subtle" aria-live="polite"></span><button id="rules-save" class="rules-save" type="button">Save rules</button></div>
|
|
158
|
+
</div>
|
|
159
|
+
</div>
|
|
160
|
+
</div>
|
|
161
|
+
</section>
|
|
140
162
|
</main>
|
|
141
163
|
<script>
|
|
142
164
|
'use strict';
|
|
@@ -155,8 +177,10 @@ export const dashboardHtml = `<!doctype html>
|
|
|
155
177
|
const MAX_EVENTS=60;
|
|
156
178
|
const jobsTab=document.querySelector('#jobs-tab');
|
|
157
179
|
const usageTab=document.querySelector('#usage-tab');
|
|
180
|
+
const rulesTab=document.querySelector('#rules-tab');
|
|
158
181
|
const jobsView=document.querySelector('#jobs-view');
|
|
159
182
|
const usageView=document.querySelector('#usage-view');
|
|
183
|
+
const rulesView=document.querySelector('#rules-view');
|
|
160
184
|
const jobsWorkspace=document.querySelector('#jobs-workspace');
|
|
161
185
|
const jobList=document.querySelector('#job-list');
|
|
162
186
|
const jobsRefresh=document.querySelector('#jobs-refresh');
|
|
@@ -176,6 +200,10 @@ const connection=document.querySelector('#connection');
|
|
|
176
200
|
const selectedJobTitle=document.querySelector('#selected-job-title');
|
|
177
201
|
const usageRoot=document.querySelector('#usage-root');
|
|
178
202
|
const usageMeta=document.querySelector('#usage-meta');
|
|
203
|
+
const rulesText=document.querySelector('#rules-text');
|
|
204
|
+
const rulesMeta=document.querySelector('#rules-meta');
|
|
205
|
+
const rulesStatus=document.querySelector('#rules-status');
|
|
206
|
+
const rulesSave=document.querySelector('#rules-save');
|
|
179
207
|
const rateLimitWarning=document.querySelector('#rate-limit-warning');
|
|
180
208
|
let jobs=[];
|
|
181
209
|
let selectedJob=sessionStorage.getItem('agents-relay:selected-job');
|
|
@@ -266,7 +294,9 @@ function usageProvider(provider){const card=element('section','usage-provider');
|
|
|
266
294
|
function formatCredit(value,currency){if(value===null||value===undefined||!Number.isFinite(Number(value)))return '—';const number=Number(value);if(currency==='USD')return '$'+number.toFixed(2);if(currency==='CNY')return '¥'+number.toFixed(2);return number.toFixed(2)+' '+currency;}
|
|
267
295
|
function creditProvider(provider){const row=element('section','usage-provider');row.append(element('h3','',text(provider.label)));const credits=provider.credits||{};if(!credits.available||!Array.isArray(credits.balances)||!credits.balances.length){row.append(element('p','empty',text(credits.reason||'Credit balance is unavailable.')));return row;}const balance=credits.balances[0];row.append(element('div','detail-value',text(formatCredit(balance.remaining,balance.currency))));return row;}
|
|
268
296
|
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:[];if(!providers.length&&!credits.length){usageRoot.append(element('p','empty',text('Usage telemetry is unavailable.')));return;}const grid=element('div','usage-providers');for(const provider of providers)grid.append(usageProvider(provider));for(const provider of credits)grid.append(creditProvider(provider));usageRoot.append(grid);}catch(error){usageMeta.textContent='Unavailable';usageRoot.replaceChildren(element('p','error-state',text('Usage unavailable: '+error.message)));}}
|
|
269
|
-
function
|
|
297
|
+
async function drawRules(){rulesStatus.textContent='';try{const rules=await loadJSON('/api/model-router-rules');rulesText.value=typeof rules.text==='string'?rules.text:'';rulesMeta.textContent=rules.hash&&rules.hash!=='none'?'Loaded · '+rules.hash.slice(0,12):'No custom rules saved';}catch(error){rulesMeta.textContent='Unavailable';rulesStatus.textContent='Could not load rules: '+error.message;}}
|
|
298
|
+
async function saveRules(){rulesSave.disabled=true;rulesStatus.textContent='Saving…';try{const response=await fetch('/api/model-router-rules',{method:'PUT',headers:{'content-type':'text/plain; charset=utf-8'},body:rulesText.value});if(!response.ok)throw new Error(response.status+' '+await response.text());const rules=await response.json();rulesMeta.textContent='Saved · '+rules.hash.slice(0,12);rulesStatus.textContent='Saved. New routing decisions will load these rules.';}catch(error){rulesStatus.textContent='Save failed: '+error.message;}finally{rulesSave.disabled=false;}}
|
|
299
|
+
function showView(view){const isJobs=view==='jobs';const isUsage=view==='usage';const isRules=view==='rules';jobsView.hidden=!isJobs;jobsWorkspace.hidden=!isJobs;usageView.hidden=!isUsage;rulesView.hidden=!isRules;jobsTab.setAttribute('aria-selected',String(isJobs));usageTab.setAttribute('aria-selected',String(isUsage));rulesTab.setAttribute('aria-selected',String(isRules));if(isJobs)void refresh();else if(isUsage)void drawUsage();else void drawRules();}
|
|
270
300
|
jobsTab.addEventListener('click',()=>{location.hash=selectedJob?'#/jobs/'+encodeURIComponent(selectedJob):'#/jobs';showView('jobs');});
|
|
271
301
|
jobsRefresh.addEventListener('click',()=>void refresh(true));
|
|
272
302
|
eventsAllTasks.addEventListener('click',()=>{eventTaskId=null;clearEvents();renderEventScope();void loadEventHistory();});
|
|
@@ -277,15 +307,17 @@ quickDraft.addEventListener('click',()=>toggleQuickMode('draft'));
|
|
|
277
307
|
quickStatus.addEventListener('change',()=>setQuickMode(quickStatus.value?'status':'jobs',quickStatus.value));
|
|
278
308
|
selectedJobRefresh.addEventListener('click',()=>void refreshSelected());
|
|
279
309
|
usageTab.addEventListener('click',()=>{location.hash='#/usage';showView('usage');});
|
|
280
|
-
|
|
310
|
+
rulesTab.addEventListener('click',()=>{location.hash='#/model-router-rules';showView('rules');});
|
|
311
|
+
rulesSave.addEventListener('click',()=>void saveRules());
|
|
312
|
+
window.addEventListener('keydown',event=>{if(event.key==='Tab'&&event.altKey){event.preventDefault();const next=location.hash==='#/usage'?'rules':location.hash==='#/model-router-rules'?'jobs':'usage';location.hash=next==='usage'?'#/usage':next==='rules'?'#/model-router-rules':(selectedJob?'#/jobs/'+encodeURIComponent(selectedJob):'#/jobs');showView(next);}});
|
|
281
313
|
function parseMessage(message){try{return JSON.parse(message.data);}catch{return null;}}
|
|
282
314
|
const stream=new EventSource('/events');
|
|
283
315
|
stream.onopen=()=>{connection.textContent='';connection.setAttribute('aria-label','Event stream connected');connection.className='status-pill ok';};
|
|
284
316
|
stream.onerror=()=>{connection.textContent='';connection.setAttribute('aria-label','Event stream reconnecting');connection.className='status-pill warn';};
|
|
285
317
|
stream.onmessage=message=>{const payload=parseMessage(message);if(payload?.job){const index=jobs.findIndex(job=>job.id===payload.job.id);if(index>=0)jobs[index]=payload.job;else jobs.push(payload.job);renderAll();}};
|
|
286
318
|
stream.addEventListener('relay',message=>{const payload=parseMessage(message);if(payload?.job){const index=jobs.findIndex(job=>job.id===payload.job.id);if(index>=0)jobs[index]=payload.job;else jobs.push(payload.job);}if(payload?.event)addEvent(payload.event);renderAll();});
|
|
287
|
-
if(location.hash==='#/usage')showView('usage');else showView('jobs');
|
|
288
|
-
setInterval(()=>{if(location.hash!=='#/usage')void refresh();},300000);
|
|
319
|
+
if(location.hash==='#/usage')showView('usage');else if(location.hash==='#/model-router-rules')showView('rules');else showView('jobs');
|
|
320
|
+
setInterval(()=>{if(location.hash!=='#/usage'&&location.hash!=='#/model-router-rules')void refresh();},300000);
|
|
289
321
|
setInterval(()=>{if(location.hash==='#/usage')void drawUsage();},30000);
|
|
290
322
|
async function refreshSelected(){if(!selectedJob){await refresh();return;}selectedJobRefresh.disabled=true;selectedJobRefresh.textContent='Refreshing…';try{const updated=await loadJSON('/api/job?id='+encodeURIComponent(selectedJob));const index=jobs.findIndex(job=>job.id===selectedJob);if(index>=0)jobs[index]=updated;else jobs.push(updated);rememberSelection();renderAll();connection.textContent='';connection.setAttribute('aria-label','Live durable state connected');connection.className='status-pill ok';}catch(error){connection.textContent='';connection.setAttribute('aria-label','Durable state unavailable');connection.className='status-pill bad';}finally{selectedJobRefresh.disabled=false;selectedJobRefresh.textContent='Refresh';}}
|
|
291
323
|
</script>
|
package/dist/dashboard.js
CHANGED
|
@@ -5,6 +5,7 @@ import { join } from 'node:path';
|
|
|
5
5
|
import { readUsageSnapshot } from './usage.js';
|
|
6
6
|
import { deriveJobRuntimeStatus } from './runtime-status.js';
|
|
7
7
|
import { DeliveryDeduper, parseGitHubWebhook, verifyGitHubWebhook } from './github-webhook.js';
|
|
8
|
+
import { loadModelRouterRules, saveModelRouterRules } from './routing-policy.js';
|
|
8
9
|
const GITHUB_REPOSITORY_PATTERN = /^([A-Za-z0-9](?:[A-Za-z0-9-]{0,36}[A-Za-z0-9])?)\/([A-Za-z0-9._-]{1,100})$/;
|
|
9
10
|
const SSE_HEARTBEAT_MS = 15000;
|
|
10
11
|
const EVENTS_HISTORY_FILE = process.env.EVENTS_BUS_DATA_DIR ? join(process.env.EVENTS_BUS_DATA_DIR, 'events.jsonl') : join(homedir(), 'Library', 'Application Support', 'EventsBus', 'events.jsonl');
|
|
@@ -267,6 +268,47 @@ export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobI
|
|
|
267
268
|
res.end(JSON.stringify(events));
|
|
268
269
|
return;
|
|
269
270
|
}
|
|
271
|
+
if (req.url === '/api/model-router-rules') {
|
|
272
|
+
if (req.method === 'GET') {
|
|
273
|
+
try {
|
|
274
|
+
const rules = await loadModelRouterRules();
|
|
275
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
276
|
+
res.end(JSON.stringify(rules));
|
|
277
|
+
}
|
|
278
|
+
catch (error) {
|
|
279
|
+
res.writeHead(500);
|
|
280
|
+
res.end(error instanceof Error ? error.message : String(error));
|
|
281
|
+
}
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
if (req.method === 'PUT') {
|
|
285
|
+
const chunks = [];
|
|
286
|
+
let size = 0;
|
|
287
|
+
for await (const chunk of req) {
|
|
288
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
289
|
+
size += buffer.length;
|
|
290
|
+
if (size > 64 * 1024) {
|
|
291
|
+
res.writeHead(413);
|
|
292
|
+
res.end('Model router rules exceed 64 KiB');
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
chunks.push(buffer);
|
|
296
|
+
}
|
|
297
|
+
try {
|
|
298
|
+
const rules = await saveModelRouterRules(Buffer.concat(chunks).toString('utf8'));
|
|
299
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
300
|
+
res.end(JSON.stringify(rules));
|
|
301
|
+
}
|
|
302
|
+
catch (error) {
|
|
303
|
+
res.writeHead(500);
|
|
304
|
+
res.end(error instanceof Error ? error.message : String(error));
|
|
305
|
+
}
|
|
306
|
+
return;
|
|
307
|
+
}
|
|
308
|
+
res.writeHead(405, { allow: 'GET, PUT' });
|
|
309
|
+
res.end('Method not allowed');
|
|
310
|
+
return;
|
|
311
|
+
}
|
|
270
312
|
if (req.url === '/api/status') {
|
|
271
313
|
const status = statusProvider ? statusProvider() : { limited: false, cooldownUntil: null };
|
|
272
314
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { appendFile, mkdir, readFile } from 'node:fs/promises';
|
|
2
|
+
import { dirname } from 'node:path';
|
|
3
|
+
import { randomUUID, createHash } from 'node:crypto';
|
|
4
|
+
export const DECISION_AUDIT_SCHEMA_VERSION = 2;
|
|
5
|
+
export class JsonlDecisionAuditStore {
|
|
6
|
+
path;
|
|
7
|
+
constructor(path) {
|
|
8
|
+
this.path = path;
|
|
9
|
+
}
|
|
10
|
+
async append(record) { await mkdir(dirname(this.path), { recursive: true }); await appendFile(this.path, `${JSON.stringify(sanitize(record))}\n`, 'utf8'); }
|
|
11
|
+
async read() {
|
|
12
|
+
try {
|
|
13
|
+
return (await readFile(this.path, 'utf8')).split(/\r?\n/).filter(Boolean).map(line => JSON.parse(line));
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return [];
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
function sanitize(value, key = '') {
|
|
21
|
+
if (/(token|secret|password|credential|authorization|api.?key)/i.test(key))
|
|
22
|
+
return '[redacted]';
|
|
23
|
+
if (Array.isArray(value))
|
|
24
|
+
return value.map(item => sanitize(item));
|
|
25
|
+
if (value && typeof value === 'object')
|
|
26
|
+
return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, sanitize(v, k)]));
|
|
27
|
+
return value;
|
|
28
|
+
}
|
|
29
|
+
export function decisionAuditId() { return randomUUID(); }
|
|
30
|
+
export function auditInputHash(record) { return createHash('sha256').update(JSON.stringify(record)).digest('hex'); }
|
|
31
|
+
export function outcomeForDecision(decisionId, kind, summary, evidence) { return { record_type: 'outcome', schema_version: DECISION_AUDIT_SCHEMA_VERSION, outcome_id: randomUUID(), decision_id: decisionId, timestamp: new Date().toISOString(), kind, summary, evidence }; }
|
|
32
|
+
export function taskAuditFacts(task) {
|
|
33
|
+
if (!task)
|
|
34
|
+
return {};
|
|
35
|
+
return { job_id: task.jobId, task_id: task.id, attempt: task.attempt, execution_id: task.executionId, capabilities: [...task.capabilities], input_class: task.input.slice(0, 80).replace(/\s+/g, ' ') };
|
|
36
|
+
}
|
|
37
|
+
export function makeDecisionAudit(input) {
|
|
38
|
+
const candidate = (item) => ({ id: item.id, provider: item.provider, model: item.model, profile: item.profile ?? null, reasoning: item.reasoning ?? null, resourceHealthy: item.resourceHealthy ?? null, resourceUsedPercent: item.resourceUsedPercent ?? null, resourceRemainingPercent: item.resourceRemainingPercent ?? null, resourceResetAt: item.resourceResetAt ?? null, resourceResetInMinutes: item.resourceResetInMinutes ?? null, resourceWindows: item.resourceWindows ?? null });
|
|
39
|
+
const eligibleIds = new Set(input.eligibleCandidates.map(item => item.id));
|
|
40
|
+
const gate_results = input.candidates.map(item => {
|
|
41
|
+
const reasons = [];
|
|
42
|
+
if (item.resourceHealthy === false)
|
|
43
|
+
reasons.push('resource_unhealthy');
|
|
44
|
+
const constraints = input.contract.constraints;
|
|
45
|
+
if (typeof constraints?.provider === 'string' && constraints.provider !== item.provider)
|
|
46
|
+
reasons.push('provider_constraint');
|
|
47
|
+
if (typeof constraints?.model === 'string' && constraints.model !== item.model)
|
|
48
|
+
reasons.push('model_constraint');
|
|
49
|
+
if (typeof constraints?.profile === 'string' && constraints.profile !== item.profile)
|
|
50
|
+
reasons.push('profile_constraint');
|
|
51
|
+
if (input.policy.policy.disabledProviders?.includes(item.provider))
|
|
52
|
+
reasons.push('policy_disabled_provider');
|
|
53
|
+
if (item.profile && input.policy.policy.disabledProfiles?.includes(item.profile))
|
|
54
|
+
reasons.push('policy_disabled_profile');
|
|
55
|
+
return { candidate_id: item.id, eligible: eligibleIds.has(item.id), rejection_reasons: reasons };
|
|
56
|
+
});
|
|
57
|
+
return { record_type: 'decision', schema_version: DECISION_AUDIT_SCHEMA_VERSION, decision_id: decisionAuditId(), prior_decision_id: input.priorDecisionId, timestamp: input.routing.decidedAt, correlation: { job_id: input.task?.jobId, task_id: input.task?.id, execution_id: input.task?.executionId, attempt: input.task?.attempt }, task: taskAuditFacts(input.task), worker: input.worker, contract: input.contract, policy: { path: input.policy.path, hash: input.policy.hash, snapshot: input.policy.snapshot, rules: input.policy.rules }, candidates: input.candidates.map(candidate), gate_results, eligible_candidates: input.eligibleCandidates.map(candidate), selected: candidate(input.selected), engine: input.routing.decidedBy, fallback: { used: input.routing.decisionEvidence?.fallback ?? false, reason: input.routing.decisionEvidence?.error }, decision_evidence: input.routing.decisionEvidence, prior_failure: input.priorFailure };
|
|
58
|
+
}
|
|
@@ -258,6 +258,7 @@ export function executionContextPrompt(context) {
|
|
|
258
258
|
const skills = context.skills.map(item => `- ${item.name}: ${item.path} [${item.source}]`).join('\n') || '- none';
|
|
259
259
|
const agents = context.agents.map(item => {
|
|
260
260
|
const details = [
|
|
261
|
+
item.description ? `purpose=${item.description}` : '',
|
|
261
262
|
item.capabilities.length ? `capabilities=${item.capabilities.join(',')}` : '',
|
|
262
263
|
item.runtime ? `runtime=${item.runtime}` : '',
|
|
263
264
|
item.trustDomains.length ? `trust=${item.trustDomains.join(',')}` : '',
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { outcomeForDecision } from './decision-audit.js';
|
|
2
|
+
import { discoverModelCandidates, routeModel } from './model-routing.js';
|
|
3
|
+
import { readUsageSnapshot } from './usage.js';
|
|
4
|
+
/** Classify from durable lifecycle evidence and logs; task.error is only a supporting hint. */
|
|
5
|
+
export function classifyFailureEvidence(input) {
|
|
6
|
+
const text = [...input.events.map(event => `${event.type} ${event.message ?? ''} ${JSON.stringify(event.data ?? {})}`), ...(input.logs ?? [])].join('\n').toLowerCase();
|
|
7
|
+
const model = /model|provider|laya|launch|auth|credential|configuration|incompatible|rate.?limit|quota|unavailable/.test(text);
|
|
8
|
+
const infra = /worker|tool|daemon|relay|network|socket|nats|checkout|filesystem/.test(text);
|
|
9
|
+
const semantic = /assert|test failed|implementation|compile|typescript|syntax|semantic/.test(text);
|
|
10
|
+
const timeout = /timeout|timed out|deadline/.test(text);
|
|
11
|
+
if (model && !infra)
|
|
12
|
+
return { classification: 'model_provider_runtime', summary: 'Durable events/logs indicate the selected model/provider/runtime path failed.', sources: ['task.events', 'worker.logs'] };
|
|
13
|
+
if (infra)
|
|
14
|
+
return { classification: 'worker_tool_infrastructure', summary: 'Durable events/logs indicate worker, tool, or infrastructure failure unrelated to model choice.', sources: ['task.events', 'worker.logs'] };
|
|
15
|
+
if (semantic)
|
|
16
|
+
return { classification: 'semantic_task', summary: 'Durable events/logs indicate meaningful task execution failed semantically.', sources: ['task.events', 'worker.logs'] };
|
|
17
|
+
if (timeout)
|
|
18
|
+
return { classification: 'timeout_unknown', summary: 'Timeout evidence is insufficient to attribute failure to model choice.', sources: ['task.events', 'worker.logs'] };
|
|
19
|
+
return { classification: 'timeout_unknown', summary: input.events.length === 0 && !input.logs?.length ? 'No durable event or log evidence was supplied.' : 'Failure attribution is unknown from the available evidence.', sources: ['task.events', 'worker.logs'] };
|
|
20
|
+
}
|
|
21
|
+
/** Re-route only. This action never retries or changes the Task lifecycle. */
|
|
22
|
+
export async function redecideFailedTask(options) {
|
|
23
|
+
if (options.evidence.classification !== 'model_provider_runtime')
|
|
24
|
+
throw new Error('Model re-decision requires model/provider/runtime evidence');
|
|
25
|
+
const usage = await readUsageSnapshot(options.usage);
|
|
26
|
+
const candidates = await discoverModelCandidates(options.worker, options.registered, usage);
|
|
27
|
+
const result = await routeModel(options.worker, candidates, {
|
|
28
|
+
decisionEngine: options.decisionEngine,
|
|
29
|
+
decisionMode: options.decisionMode,
|
|
30
|
+
policyPath: options.policyPath,
|
|
31
|
+
auditStore: options.auditStore,
|
|
32
|
+
auditTask: options.task,
|
|
33
|
+
taskFacts: { kind: options.task.kind ?? 'work', capabilities: options.task.capabilities, prior_routing: options.task.routing ?? null, fresh_usage_generated_at: usage.generatedAt },
|
|
34
|
+
priorDecisionId: options.task.routing?.decisionId,
|
|
35
|
+
priorFailure: options.evidence,
|
|
36
|
+
});
|
|
37
|
+
await options.updateRouting(options.task, result.routing);
|
|
38
|
+
if (options.auditStore && options.task.routing?.decisionId)
|
|
39
|
+
await options.auditStore.append(outcomeForDecision(options.task.routing.decisionId, 'provider_failure', options.evidence.summary, { classification: options.evidence.classification, fresh_usage_generated_at: usage.generatedAt }));
|
|
40
|
+
return { ...result, freshUsageGeneratedAt: usage.generatedAt };
|
|
41
|
+
}
|
package/dist/model-routing.js
CHANGED
|
@@ -3,6 +3,8 @@ import { basename, join } from 'node:path';
|
|
|
3
3
|
import { readdir, readFile } from 'node:fs/promises';
|
|
4
4
|
import { chooseWithDecision } from './decision.js';
|
|
5
5
|
import { usageForProvider } from './worker-routing.js';
|
|
6
|
+
import { makeDecisionAudit } from './decision-audit.js';
|
|
7
|
+
import { loadRoutingPolicy, policyAllows, policyRank } from './routing-policy.js';
|
|
6
8
|
function topLevelCodexConfig(text) {
|
|
7
9
|
const result = {};
|
|
8
10
|
let inSection = false;
|
|
@@ -73,10 +75,12 @@ export async function discoverModelCandidates(worker, registered, usage, codexHo
|
|
|
73
75
|
candidates.push(withResource({ id: 'default:openai/gpt-5.6-luna', provider: 'openai', model: 'gpt-5.6-luna' }, usage));
|
|
74
76
|
return candidates.filter((candidate, index, all) => all.findIndex(item => item.id === candidate.id) === index);
|
|
75
77
|
}
|
|
76
|
-
function eligibleModels(candidates, constraints) {
|
|
78
|
+
function eligibleModels(candidates, constraints, policy) {
|
|
77
79
|
return candidates.filter(candidate => {
|
|
78
80
|
if (candidate.resourceHealthy === false)
|
|
79
81
|
return false;
|
|
82
|
+
if (policy && !policyAllows(candidate, policy.policy))
|
|
83
|
+
return false;
|
|
80
84
|
if (constraints?.provider && candidate.provider !== constraints.provider)
|
|
81
85
|
return false;
|
|
82
86
|
if (constraints?.model && candidate.model !== constraints.model)
|
|
@@ -88,37 +92,59 @@ function eligibleModels(candidates, constraints) {
|
|
|
88
92
|
return true;
|
|
89
93
|
});
|
|
90
94
|
}
|
|
91
|
-
function deterministicModel(candidates) {
|
|
95
|
+
function deterministicModel(candidates, taskKind, policy) {
|
|
92
96
|
return [...candidates].map((candidate, index) => ({ candidate, index })).sort((left, right) => {
|
|
93
97
|
const pressure = (item) => item.resourceUsedPercent === undefined ? 1 : item.resourceUsedPercent >= 90 ? 2 : 0;
|
|
94
|
-
return pressure(left.candidate) - pressure(right.candidate) || left.index - right.index;
|
|
98
|
+
return pressure(left.candidate) - pressure(right.candidate) || (policy ? policyRank(right.candidate, taskKind, policy.policy) - policyRank(left.candidate, taskKind, policy.policy) : 0) || left.index - right.index;
|
|
95
99
|
})[0].candidate;
|
|
96
100
|
}
|
|
97
101
|
export async function routeModel(worker, candidates, options = {}) {
|
|
98
|
-
const
|
|
102
|
+
const policy = await loadRoutingPolicy(options.policyPath, options.rulesPath);
|
|
103
|
+
const eligible = eligibleModels(candidates, options.constraints, policy);
|
|
99
104
|
if (eligible.length === 0)
|
|
100
105
|
throw new Error(`No eligible model profile is usable by worker ${worker.workerId}`);
|
|
101
|
-
const
|
|
106
|
+
const taskKind = typeof options.taskFacts?.kind === 'string' ? options.taskFacts.kind : undefined;
|
|
107
|
+
const deterministic = deterministicModel(eligible, taskKind, policy);
|
|
102
108
|
const choice = eligible.length === 1
|
|
103
109
|
? { choice: deterministic.id, evidence: { mode: options.decisionMode ?? 'off', fallback: true } }
|
|
104
110
|
: await chooseWithDecision(options.decisionEngine, options.decisionMode ?? 'off', {
|
|
105
111
|
facts: JSON.parse(JSON.stringify({
|
|
106
112
|
task: options.taskFacts ?? {},
|
|
113
|
+
environment_policy: { structured_policy: policy.snapshot, rules: policy.rules.text },
|
|
114
|
+
prior_decision_id: options.priorDecisionId ?? null,
|
|
115
|
+
prior_failure: options.priorFailure ?? null,
|
|
107
116
|
worker: { id: worker.workerId, adapter: worker.adapter, capabilities: [...worker.capabilities] },
|
|
108
117
|
candidates: eligible.map(item => ({ id: item.id, provider: item.provider, model: item.model, profile: item.profile ?? null, reasoning: item.reasoning ?? null, resourceHealthy: item.resourceHealthy ?? null, resourceUsedPercent: item.resourceUsedPercent ?? null, resourceRemainingPercent: item.resourceRemainingPercent ?? null, resourceResetAt: item.resourceResetAt ?? null, resourceResetInMinutes: item.resourceResetInMinutes ?? null, resourceWindows: item.resourceWindows ?? null })),
|
|
109
118
|
})),
|
|
110
|
-
}, { type: 'choice', question: 'Which eligible model profile is best suited to execute this task?
|
|
119
|
+
}, { type: 'choice', question: 'Which eligible model profile is best suited to execute this task? Apply hard gates first, then explicit task constraints, then current environment policy; consider every quota window and never violate task fit or continuity.', choices: eligible.map(item => item.id) }, () => deterministic.id);
|
|
111
120
|
const selected = eligible.find(item => item.id === choice.choice) ?? deterministic;
|
|
121
|
+
const routing = {
|
|
122
|
+
provider: selected.provider,
|
|
123
|
+
model: selected.model,
|
|
124
|
+
profile: selected.profile,
|
|
125
|
+
reasoning: options.constraints?.reasoning ?? selected.reasoning,
|
|
126
|
+
decidedBy: choice.evidence.engine && !choice.evidence.fallback ? 'model-router:laya' : 'model-router',
|
|
127
|
+
decidedAt: new Date().toISOString(),
|
|
128
|
+
decisionEvidence: choice.evidence,
|
|
129
|
+
};
|
|
130
|
+
const audit = makeDecisionAudit({
|
|
131
|
+
task: options.auditTask,
|
|
132
|
+
worker: { id: worker.workerId, adapter: worker.adapter, capabilities: [...worker.capabilities] },
|
|
133
|
+
contract: { question: 'model-profile-selection', task: options.taskFacts ?? {}, constraints: options.constraints ?? {} },
|
|
134
|
+
policy,
|
|
135
|
+
candidates,
|
|
136
|
+
eligibleCandidates: eligible,
|
|
137
|
+
selected,
|
|
138
|
+
routing,
|
|
139
|
+
priorDecisionId: options.priorDecisionId,
|
|
140
|
+
priorFailure: options.priorFailure,
|
|
141
|
+
});
|
|
142
|
+
routing.decisionId = audit.decision_id;
|
|
143
|
+
if (options.auditStore)
|
|
144
|
+
await options.auditStore.append(audit);
|
|
112
145
|
return {
|
|
113
146
|
candidate: selected,
|
|
114
|
-
routing
|
|
115
|
-
|
|
116
|
-
model: selected.model,
|
|
117
|
-
profile: selected.profile,
|
|
118
|
-
reasoning: options.constraints?.reasoning ?? selected.reasoning,
|
|
119
|
-
decidedBy: choice.evidence.engine && !choice.evidence.fallback ? 'model-router:laya' : 'model-router',
|
|
120
|
-
decidedAt: new Date().toISOString(),
|
|
121
|
-
decisionEvidence: choice.evidence,
|
|
122
|
-
},
|
|
147
|
+
routing,
|
|
148
|
+
audit,
|
|
123
149
|
};
|
|
124
150
|
}
|
package/dist/planner.js
CHANGED
|
@@ -140,7 +140,14 @@ export function parsePlannerOutput(output) {
|
|
|
140
140
|
return parsePlannerResult(JSON.parse(candidate));
|
|
141
141
|
}
|
|
142
142
|
export function plannerInput(context) {
|
|
143
|
-
|
|
143
|
+
const visibleAgents = context.executionContext?.agents.map(agent => ({
|
|
144
|
+
name: agent.name,
|
|
145
|
+
description: agent.description,
|
|
146
|
+
capabilities: agent.capabilities,
|
|
147
|
+
boundaries: agent.restrictions,
|
|
148
|
+
runtime: agent.runtime,
|
|
149
|
+
}));
|
|
150
|
+
return JSON.stringify({ objective: context.objective, previous_planner_task_id: context.previousPlannerTaskId, visible_agents: visibleAgents ?? [], execution_context: context.executionContext, tasks: context.tasks.map(task => ({ id: task.id, kind: task.kind ?? 'work', parent_task_id: task.parentTaskId, state: task.state, input: task.input, result: task.result, planner_result: task.plannerResult, error: task.error })) });
|
|
144
151
|
}
|
|
145
152
|
export class CommandObjectivePlanner {
|
|
146
153
|
command;
|
|
@@ -114,7 +114,7 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
|
|
|
114
114
|
const routedWorker = await routeWorker(candidates, requirements, { decisionEngine: this.options.decisionEngine, decisionMode: this.options.decisionMode, taskFacts });
|
|
115
115
|
const models = await discoverModelCandidates(routedWorker.candidate, registered, usage);
|
|
116
116
|
const routingConstraints = task.routing ? { provider: task.routing.provider, model: task.routing.model, profile: task.routing.profile, reasoning: task.routing.reasoning } : undefined;
|
|
117
|
-
const routedModel = await routeModel(routedWorker.candidate, models, { decisionEngine: this.options.decisionEngine, decisionMode: this.options.decisionMode, taskFacts, constraints: routingConstraints });
|
|
117
|
+
const routedModel = await routeModel(routedWorker.candidate, models, { decisionEngine: this.options.decisionEngine, decisionMode: this.options.decisionMode, taskFacts, constraints: routingConstraints, auditStore: this.options.decisionAuditStore, auditTask: task });
|
|
118
118
|
task.workerSelection = routedWorker.selection;
|
|
119
119
|
task.adapter = routedWorker.candidate.adapter;
|
|
120
120
|
task.routing = routedModel.routing;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { transitionJobEvent, transitionTaskEvent } from './scheduler.js';
|
|
2
|
+
import { DurableTaskTransitionRuntime } from './workflow/durable-transition.js';
|
|
2
3
|
import { StartAckTimeout } from './adapters.js';
|
|
3
4
|
import { eventFor } from './events.js';
|
|
4
5
|
import { WorkflowEffectExecutor } from './workflow/effects.js';
|
|
@@ -24,11 +25,13 @@ export class ReconcilerRuntimeBase {
|
|
|
24
25
|
continuationLive = new Set();
|
|
25
26
|
launchFailureClaims = new Set();
|
|
26
27
|
terminalWaiters = new Map();
|
|
28
|
+
taskTransitions;
|
|
27
29
|
workflowEffects = new WorkflowEffectExecutor();
|
|
28
30
|
reconciling = false;
|
|
29
31
|
constructor(store, options) {
|
|
30
32
|
this.store = store;
|
|
31
33
|
this.options = options;
|
|
34
|
+
this.taskTransitions = new DurableTaskTransitionRuntime(store);
|
|
32
35
|
}
|
|
33
36
|
async applyJobEffectOutcomes(job, outcomes) {
|
|
34
37
|
for (const outcome of outcomes.filter(item => item.status === 'failed')) {
|
|
@@ -158,32 +161,28 @@ export class ReconcilerRuntimeBase {
|
|
|
158
161
|
const live = this.live.get(event.task_id);
|
|
159
162
|
if (live && event.job_id === live.jobId && event.timestamp && new Date(event.timestamp).getTime() < live.launchBarrier)
|
|
160
163
|
return;
|
|
161
|
-
const
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
task
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
}
|
|
177
|
-
if (
|
|
178
|
-
return;
|
|
179
|
-
if (event.timestamp && new Date(event.timestamp).getTime() < new Date(task.updatedAt).getTime())
|
|
164
|
+
const transition = await this.taskTransitions.apply(event.job_id, event.task_id, 'task.started', {
|
|
165
|
+
eligible: (job, task) => {
|
|
166
|
+
if (!task.executionId || eventSourceId(event) !== executionEventSource(job.id, task.id, task.executionId))
|
|
167
|
+
return false;
|
|
168
|
+
const claimedExecution = task.executionId !== null && task.leaseOwner !== null;
|
|
169
|
+
const exactLiveExecution = live && event.job_id === live.jobId && task.executionId === live.executionId;
|
|
170
|
+
return Boolean(exactLiveExecution || claimedExecution || this.isRecoverableWatchdogFailure(task));
|
|
171
|
+
},
|
|
172
|
+
facts: (_job, task) => ({
|
|
173
|
+
recoverableExecution: this.isRecoverableWatchdogFailure(task)
|
|
174
|
+
&& (!event.timestamp || new Date(event.timestamp).getTime() >= new Date(task.updatedAt).getTime()),
|
|
175
|
+
}),
|
|
176
|
+
mutate: (_job, task) => {
|
|
177
|
+
task.updatedAt = event.timestamp || new Date().toISOString();
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
if (!transition?.decision.accepted)
|
|
180
181
|
return;
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
await this.store.saveTask(task);
|
|
186
|
-
this.detach(this.reconcile(job.id), job.id, task.id, 'late worker-start reconciliation');
|
|
182
|
+
if (live?.executionId === transition.task.executionId)
|
|
183
|
+
live.workerStarted = true;
|
|
184
|
+
if (transition.from === 'FAILED')
|
|
185
|
+
this.detach(this.reconcile(event.job_id), event.job_id, event.task_id, 'late worker-start reconciliation');
|
|
187
186
|
}
|
|
188
187
|
isRecoverableWatchdogFailure(task) {
|
|
189
188
|
if (['worker_start_timeout', 'terminal_event_timeout', 'worker_exit_before_start', 'worker_exit_without_terminal_event', 'worker_delivery_after_start'].includes(task.failureReason ?? ''))
|
|
@@ -205,23 +204,25 @@ export class ReconcilerRuntimeBase {
|
|
|
205
204
|
this.launchFailureClaims.add(claim);
|
|
206
205
|
const cleanup = setTimeout(() => this.launchFailureClaims.delete(claim), 60000);
|
|
207
206
|
cleanup.unref();
|
|
208
|
-
const
|
|
209
|
-
const
|
|
210
|
-
|
|
207
|
+
const workflowEvent = phase === 'start-ack' ? 'worker.start_ack_failed' : 'worker.delivery_failed';
|
|
208
|
+
const transition = await this.taskTransitions.apply(jobId, taskId, workflowEvent, {
|
|
209
|
+
eligible: (_job, task) => task.executionId === executionId,
|
|
210
|
+
mutate: (_job, task) => {
|
|
211
|
+
this.clearExecution(task.id, executionId);
|
|
212
|
+
task.error = `Worker ${phase} failed: ${message}`;
|
|
213
|
+
task.failureReason = phase === 'start-ack' ? 'worker_start_timeout' : undefined;
|
|
214
|
+
task.leaseOwner = null;
|
|
215
|
+
task.leaseExpiresAt = null;
|
|
216
|
+
},
|
|
217
|
+
});
|
|
218
|
+
if (!transition)
|
|
211
219
|
return;
|
|
212
|
-
if (
|
|
213
|
-
await this.emit(eventFor(jobId, taskId, task.parentTaskId, 'task.process.delivery_failed', 'failed', `Worker ${phase} reported after
|
|
220
|
+
if (!transition.decision.accepted) {
|
|
221
|
+
await this.emit(eventFor(jobId, taskId, transition.task.parentTaskId, 'task.process.delivery_failed', 'failed', `Worker ${phase} reported after lifecycle moved to ${transition.from}: ${message}`, 'orchestrator', { phase, authoritative: false, executionId, workflowEvent }, { agent: 'relay', component: 'agents-relay' }));
|
|
214
222
|
return;
|
|
215
223
|
}
|
|
216
|
-
const event = eventFor(jobId, taskId, task.parentTaskId, 'task.failed', 'failed', `Worker ${phase} failed: ${message}`, 'user', { phase, reason:
|
|
217
|
-
this.
|
|
218
|
-
task.error = event.message;
|
|
219
|
-
task.failureReason = phase === 'start-ack' ? 'worker_start_timeout' : undefined;
|
|
220
|
-
task.leaseOwner = null;
|
|
221
|
-
task.leaseExpiresAt = null;
|
|
222
|
-
await transitionTaskEvent(task, 'fail');
|
|
223
|
-
await this.store.saveTask(task);
|
|
224
|
-
await this.emit(event);
|
|
224
|
+
const event = eventFor(jobId, taskId, transition.task.parentTaskId, 'task.failed', 'failed', transition.task.error ?? `Worker ${phase} failed: ${message}`, 'user', { phase, reason: transition.task.failureReason, workflowEvent }, { agent: 'relay', component: 'agents-relay', watchdog: true });
|
|
225
|
+
this.detach(this.emit(event), jobId, taskId, 'worker failure event');
|
|
225
226
|
}
|
|
226
227
|
async runtimeSettled(jobId, taskId, executionId, result, error) {
|
|
227
228
|
const live = this.live.get(taskId);
|
|
@@ -234,7 +235,7 @@ export class ReconcilerRuntimeBase {
|
|
|
234
235
|
if (!task || task.executionId !== executionId)
|
|
235
236
|
return;
|
|
236
237
|
await this.emit(eventFor(jobId, taskId, task.parentTaskId, task.adapter === 'chatgpt' ? 'task.process.async_exited' : 'task.process.exited', error ? 'failed' : 'succeeded', error ?? result?.summary ?? 'Worker runtime exited', 'orchestrator', { authoritative: false, waitingForTerminalEvent: true, processError: error }, { agent: 'relay', component: 'agents-relay' }));
|
|
237
|
-
if (
|
|
238
|
+
if (task.adapter === 'codex' && !terminalExecutionClaimed(jobId, taskId, executionId))
|
|
238
239
|
this.scheduleUnexpectedExit(jobId, taskId, executionId, workerStarted === true);
|
|
239
240
|
}
|
|
240
241
|
scheduleUnexpectedExit(jobId, taskId, executionId, workerStarted) {
|
|
@@ -248,28 +249,24 @@ export class ReconcilerRuntimeBase {
|
|
|
248
249
|
async failUnexpectedExit(jobId, taskId, executionId, workerStarted) {
|
|
249
250
|
if (terminalExecutionClaimed(jobId, taskId, executionId))
|
|
250
251
|
return;
|
|
251
|
-
const job = await this.store.load(jobId);
|
|
252
|
-
const task = job.tasks.find(item => item.id === taskId);
|
|
253
|
-
if (!task || task.executionId !== executionId || !['QUEUED', 'RUNNING'].includes(task.state) || terminalExecutionClaimed(jobId, taskId, executionId))
|
|
254
|
-
return;
|
|
255
252
|
const reason = workerStarted ? 'worker_exit_without_terminal_event' : 'worker_exit_before_start';
|
|
256
253
|
const message = workerStarted
|
|
257
254
|
? `Worker ${taskId} exited after task.started without publishing a terminal task event`
|
|
258
255
|
: `Worker ${taskId} exited before publishing task.started`;
|
|
259
|
-
const
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
}
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
}
|
|
272
|
-
|
|
256
|
+
const transition = await this.taskTransitions.apply(jobId, taskId, 'watchdog.failed', {
|
|
257
|
+
eligible: (_job, task) => task.executionId === executionId && !terminalExecutionClaimed(jobId, taskId, executionId),
|
|
258
|
+
mutate: (_job, task) => {
|
|
259
|
+
this.clearExecution(task.id, executionId);
|
|
260
|
+
task.error = message;
|
|
261
|
+
task.failureReason = reason;
|
|
262
|
+
task.leaseOwner = null;
|
|
263
|
+
task.leaseExpiresAt = null;
|
|
264
|
+
},
|
|
265
|
+
});
|
|
266
|
+
if (!transition?.decision.accepted)
|
|
267
|
+
return;
|
|
268
|
+
const event = eventFor(jobId, taskId, transition.task.parentTaskId, 'task.failed', 'failed', message, 'user', { reason, terminalGraceMs: this.options.terminalExitGraceMs ?? 1000, workflowEvent: 'watchdog.failed' }, { agent: 'relay', component: 'agents-relay', watchdog: true });
|
|
269
|
+
this.detach(this.emit(event), jobId, taskId, 'watchdog failure event');
|
|
273
270
|
}
|
|
274
271
|
async timeoutTask(jobId, taskId, executionId) {
|
|
275
272
|
const live = this.live.get(taskId);
|
|
@@ -277,81 +274,74 @@ export class ReconcilerRuntimeBase {
|
|
|
277
274
|
live.controller.abort();
|
|
278
275
|
live.execution.cancel();
|
|
279
276
|
}
|
|
280
|
-
const
|
|
281
|
-
|
|
282
|
-
|
|
277
|
+
const transition = await this.taskTransitions.apply(jobId, taskId, 'watchdog.failed', {
|
|
278
|
+
eligible: (_job, task) => task.executionId === executionId,
|
|
279
|
+
mutate: (_job, task) => {
|
|
280
|
+
this.clearExecution(task.id, executionId);
|
|
281
|
+
task.error = `Task ${taskId} timed out waiting for the mandatory terminal worker event`;
|
|
282
|
+
task.failureReason = 'terminal_event_timeout';
|
|
283
|
+
task.leaseOwner = null;
|
|
284
|
+
task.leaseExpiresAt = null;
|
|
285
|
+
},
|
|
286
|
+
});
|
|
287
|
+
if (!transition?.decision.accepted) {
|
|
283
288
|
this.clearExecution(taskId, executionId);
|
|
284
289
|
return;
|
|
285
290
|
}
|
|
286
|
-
const event = eventFor(jobId, taskId, task.parentTaskId, 'task.failed', 'failed', `Task ${taskId} timed out waiting for the mandatory terminal worker event`, 'user', { reason: 'terminal_event_timeout', timeoutMs: task.timeoutMs });
|
|
287
|
-
|
|
288
|
-
task.error = event.message;
|
|
289
|
-
task.failureReason = 'terminal_event_timeout';
|
|
290
|
-
task.leaseOwner = null;
|
|
291
|
-
task.leaseExpiresAt = null;
|
|
292
|
-
await transitionTaskEvent(task, 'fail');
|
|
293
|
-
await this.store.saveTask(task);
|
|
294
|
-
this.clearExecution(task.id, executionId);
|
|
295
|
-
}
|
|
296
|
-
else {
|
|
297
|
-
await this.applyTerminalEvent(event, true);
|
|
298
|
-
}
|
|
299
|
-
await this.emit(event);
|
|
291
|
+
const event = eventFor(jobId, taskId, transition.task.parentTaskId, 'task.failed', 'failed', transition.task.error ?? `Task ${taskId} timed out waiting for the mandatory terminal worker event`, 'user', { reason: 'terminal_event_timeout', timeoutMs: transition.task.timeoutMs, workflowEvent: 'watchdog.failed' }, { agent: 'relay', component: 'agents-relay', watchdog: true });
|
|
292
|
+
this.detach(this.emit(event), jobId, taskId, 'timeout failure event');
|
|
300
293
|
}
|
|
301
294
|
async applyTerminalEvent(event, trustedInternal = false) {
|
|
302
|
-
const
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
295
|
+
const transition = await this.taskTransitions.apply(event.job_id, event.task_id, event.type, {
|
|
296
|
+
eligible: (_job, task) => {
|
|
297
|
+
if (!['codex', 'chatgpt', 'orchestrator'].includes(task.adapter))
|
|
298
|
+
return false;
|
|
299
|
+
const modelBacked = task.adapter === 'codex' || task.adapter === 'chatgpt';
|
|
300
|
+
if (!trustedInternal && modelBacked && !this.isCorrelatedWorkerEvent(task, event))
|
|
301
|
+
return false;
|
|
302
|
+
const terminalWaiter = this.terminalWaiters.get(task.id);
|
|
303
|
+
return !terminalWaiter || Boolean(event.timestamp && new Date(event.timestamp).getTime() >= terminalWaiter.launchBarrier);
|
|
304
|
+
},
|
|
305
|
+
facts: (_job, task) => {
|
|
306
|
+
const processExitedAfterPublishedTerminal = task.failureReason === 'worker_exit_without_terminal_event';
|
|
307
|
+
const freshEnough = processExitedAfterPublishedTerminal
|
|
308
|
+
|| !event.timestamp
|
|
309
|
+
|| new Date(event.timestamp).getTime() >= new Date(task.updatedAt).getTime();
|
|
310
|
+
return { recoverableExecution: this.isRecoverableWatchdogFailure(task) && freshEnough };
|
|
311
|
+
},
|
|
312
|
+
mutate: (job, task) => {
|
|
313
|
+
const modelBacked = task.adapter === 'codex' || task.adapter === 'chatgpt';
|
|
314
|
+
const acceptedExecutionId = task.executionId;
|
|
315
|
+
if (modelBacked && acceptedExecutionId)
|
|
316
|
+
claimTerminalExecution(executionEventSource(job.id, task.id, acceptedExecutionId));
|
|
317
|
+
this.clearExecution(task.id, acceptedExecutionId);
|
|
318
|
+
task.leaseOwner = null;
|
|
319
|
+
task.leaseExpiresAt = null;
|
|
320
|
+
task.updatedAt = event.timestamp || new Date().toISOString();
|
|
321
|
+
this.terminalWaiters.get(task.id)?.resolve();
|
|
322
|
+
this.terminalWaiters.delete(task.id);
|
|
323
|
+
if (event.type === 'task.completed') {
|
|
324
|
+
task.result = { summary: event.message || `Task ${task.id} completed`, data: event.data };
|
|
325
|
+
task.error = null;
|
|
326
|
+
task.failureReason = undefined;
|
|
327
|
+
}
|
|
328
|
+
else if (event.type === 'task.failed') {
|
|
329
|
+
task.error = event.message || `Task ${task.id} failed`;
|
|
330
|
+
task.failureReason = typeof event.data?.reason === 'string' ? event.data.reason : undefined;
|
|
331
|
+
}
|
|
332
|
+
else if (event.type === 'task.blocked') {
|
|
333
|
+
task.error = event.message || `Task ${task.id} blocked`;
|
|
334
|
+
}
|
|
335
|
+
else if (event.type === 'task.cancelled') {
|
|
336
|
+
task.error = event.message || `Task ${task.id} cancelled`;
|
|
337
|
+
}
|
|
338
|
+
},
|
|
339
|
+
});
|
|
340
|
+
if (!transition?.decision.accepted)
|
|
320
341
|
return;
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
this.clearExecution(task.id, acceptedExecutionId);
|
|
325
|
-
task.leaseOwner = null;
|
|
326
|
-
task.leaseExpiresAt = null;
|
|
327
|
-
task.updatedAt = event.timestamp || new Date().toISOString();
|
|
328
|
-
this.terminalWaiters.get(task.id)?.resolve();
|
|
329
|
-
this.terminalWaiters.delete(task.id);
|
|
330
|
-
if (event.type === 'task.completed') {
|
|
331
|
-
task.result = { summary: event.message || `Task ${task.id} completed`, data: event.data };
|
|
332
|
-
task.error = null;
|
|
333
|
-
task.failureReason = undefined;
|
|
334
|
-
await transitionTaskEvent(task, 'task.completed');
|
|
335
|
-
await this.store.saveTask(task);
|
|
336
|
-
await this.deliverContinuation(job, task);
|
|
337
|
-
}
|
|
338
|
-
else if (event.type === 'task.failed') {
|
|
339
|
-
task.error = event.message || `Task ${task.id} failed`;
|
|
340
|
-
task.failureReason = typeof event.data?.reason === 'string' ? event.data.reason : undefined;
|
|
341
|
-
await transitionTaskEvent(task, 'task.failed');
|
|
342
|
-
await this.store.saveTask(task);
|
|
343
|
-
}
|
|
344
|
-
else if (event.type === 'task.blocked') {
|
|
345
|
-
task.error = event.message || `Task ${task.id} blocked`;
|
|
346
|
-
await transitionTaskEvent(task, 'task.blocked');
|
|
347
|
-
await this.store.saveTask(task);
|
|
348
|
-
}
|
|
349
|
-
else if (event.type === 'task.cancelled') {
|
|
350
|
-
task.error = event.message || `Task ${task.id} cancelled`;
|
|
351
|
-
await transitionTaskEvent(task, 'task.cancelled');
|
|
352
|
-
await this.store.saveTask(task);
|
|
353
|
-
}
|
|
354
|
-
this.detach(this.reconcile(job.id), job.id, task.id, 'terminal-event reconciliation');
|
|
342
|
+
if (event.type === 'task.completed')
|
|
343
|
+
await this.deliverContinuation(transition.job, transition.task);
|
|
344
|
+
this.detach(this.reconcile(transition.job.id), transition.job.id, transition.task.id, 'terminal-event reconciliation');
|
|
355
345
|
}
|
|
356
346
|
terminalWait(taskId, executionId, launchBarrier) {
|
|
357
347
|
let resolve;
|
package/dist/reconciler.js
CHANGED
|
@@ -6,11 +6,12 @@ import { LaunchFailure, StartAckTimeout } from './adapters.js';
|
|
|
6
6
|
import { eventFor } from './events.js';
|
|
7
7
|
import { parsePlannerResult, plannerInput } from './planner.js';
|
|
8
8
|
import { agentContractPrompt, executionContextPrompt, resolveExecutionContext } from './execution-context.js';
|
|
9
|
-
import { agentGraphPrompt } from './agent-graph.js';
|
|
9
|
+
import { agentGraphPrompt, validateAgentSelection } from './agent-graph.js';
|
|
10
10
|
import { ReconcilerReviewBase } from './reconciler-review.js';
|
|
11
11
|
import { executionEventSource } from './reconciler-runtime.js';
|
|
12
12
|
export { executionEventSource } from './reconciler-runtime.js';
|
|
13
13
|
import { CallbackWorkflowEffectHandler } from './workflow/effects.js';
|
|
14
|
+
import { applyHistoricalJobCompatibilityRepair } from './workflow/runtime.js';
|
|
14
15
|
import { pendingTroubleshooterTasks } from './troubleshooter.js';
|
|
15
16
|
import { prepareManagedExecutionWorktree } from './workspace.js';
|
|
16
17
|
const PRIORITY_RANK = { P0: 0, P1: 1, P2: 2, P3: 3 };
|
|
@@ -114,8 +115,7 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
114
115
|
if (job.state !== terminalState) {
|
|
115
116
|
if (!['DRAFT', 'ACTIVE', 'COMPLETED', 'CANCELLED'].includes(job.state)) {
|
|
116
117
|
// Compatibility repair boundary for historical records that persisted runtime status as lifecycle.
|
|
117
|
-
job
|
|
118
|
-
job.updatedAt = new Date().toISOString();
|
|
118
|
+
applyHistoricalJobCompatibilityRepair(job, terminalState);
|
|
119
119
|
await this.store.saveJob(job);
|
|
120
120
|
const outcomes = await this.workflowEffects.execute(['effect.cancelActiveTasks'], { jobId: job.id, transitionKey: `compatibility:${prState}`, data: { reason } });
|
|
121
121
|
await this.applyJobEffectOutcomes(job, outcomes);
|
|
@@ -289,8 +289,12 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
289
289
|
return this.store.load(job.id);
|
|
290
290
|
}
|
|
291
291
|
async recoverPlannerChildren(job) {
|
|
292
|
+
const contextTask = job.tasks.find(task => task.kind === 'planner') ?? job.tasks[0];
|
|
293
|
+
if (!contextTask)
|
|
294
|
+
return;
|
|
295
|
+
const executionContext = await this.resolvedContext(job, contextTask, 'planner');
|
|
292
296
|
for (const planner of job.tasks.filter(task => task.kind === 'planner' && task.state === 'SUCCEEDED' && task.plannerResult)) {
|
|
293
|
-
const children = planner.plannerResult?.next_tasks.map(spec => this.plannerChild(job, planner, spec)) ?? [];
|
|
297
|
+
const children = planner.plannerResult?.next_tasks.map(spec => this.plannerChild(job, planner, spec, executionContext)) ?? [];
|
|
294
298
|
for (const child of children) {
|
|
295
299
|
const existing = job.tasks.find(item => item.id === child.id);
|
|
296
300
|
if (existing) {
|
|
@@ -475,7 +479,8 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
475
479
|
}
|
|
476
480
|
try {
|
|
477
481
|
const result = parsePlannerResult(raw);
|
|
478
|
-
const
|
|
482
|
+
const executionContext = await this.resolvedContext(job, task, 'planner');
|
|
483
|
+
const children = result.next_tasks.map(spec => this.plannerChild(job, task, spec, executionContext));
|
|
479
484
|
for (const child of children) {
|
|
480
485
|
const existing = job.tasks.find(item => item.id === child.id);
|
|
481
486
|
if (!existing)
|
|
@@ -516,7 +521,8 @@ export class Reconciler extends ReconcilerReviewBase {
|
|
|
516
521
|
}
|
|
517
522
|
this.detach(this.reconcile(jobId), jobId, task.id, 'planner follow-up reconciliation');
|
|
518
523
|
}
|
|
519
|
-
plannerChild(job, planner, spec) {
|
|
524
|
+
plannerChild(job, planner, spec, executionContext) {
|
|
525
|
+
validateAgentSelection(executionContext, spec.agentName, spec.agentGraph);
|
|
520
526
|
const now = new Date().toISOString();
|
|
521
527
|
return { jobId: job.id, id: spec.id, kind: 'work', priority: spec.priority ?? job.priority, storySize: spec.storySize ?? 'M', projectName: spec.projectName, agentName: spec.agentName, agentGraph: spec.agentGraph, parentTaskId: planner.id, dependencies: spec.dependencies ?? [], capabilities: spec.capabilities ?? [], workerRequirements: spec.workerRequirements, adapter: 'codex', input: spec.input, output: spec.output, continuation: spec.continuation ?? null, continuationDeliveredAt: null, state: (spec.dependencies?.length ?? 0) > 0 ? 'DRAFT' : 'QUEUED', attempt: 0, maxAttempts: spec.maxAttempts ?? 3, leaseOwner: null, leaseExpiresAt: null, executionId: null, threadId: null, result: null, plannerResult: null, error: null, timeoutMs: spec.timeoutMs ?? taskOverallTimeoutMs(spec.storySize ?? 'M'), createdAt: now, updatedAt: now, planningDecisionId: planner.id, planningSource: planner.planningSource ?? 'model-planner', planningRound: planner.planningRound ?? 1 };
|
|
522
528
|
}
|
package/dist/relayd.js
CHANGED
|
@@ -16,6 +16,7 @@ import { codexAndZaiUsageRegistry } from './usage.js';
|
|
|
16
16
|
import { defaultWorkspaceRoot, discoverWorkspaceRepositories, resolveManagedProjectPath } from './workspace.js';
|
|
17
17
|
import { deriveJobRuntimeStatus } from './runtime-status.js';
|
|
18
18
|
import { decisionMode, LayaCliDecisionEngine } from './decision.js';
|
|
19
|
+
import { JsonlDecisionAuditStore } from './decision-audit.js';
|
|
19
20
|
export const daemonHelp = `Agents Relay daemon runs the dashboard and worker pool.
|
|
20
21
|
|
|
21
22
|
Usage:
|
|
@@ -72,7 +73,8 @@ export async function runDaemon(argv) {
|
|
|
72
73
|
const repositoryPaths = new Map(discoveredRepositories.map(item => [item.repository, item.path]));
|
|
73
74
|
const explicitProjectPath = value(argv, '--project-path');
|
|
74
75
|
const usageRegistry = codexAndZaiUsageRegistry();
|
|
75
|
-
const
|
|
76
|
+
const decisionAuditStore = new JsonlDecisionAuditStore(process.env.AGENTS_RELAY_DECISION_AUDIT_PATH ?? join(homedir(), '.agents-relay', 'decision-audit.jsonl'));
|
|
77
|
+
const makeReconciler = (store, maxConcurrent) => new Reconciler(store, { owner: `relayd-${process.pid}`, maxConcurrent, leaseMs: Number(value(argv, '--lease-ms', '300000')), adapters: [new CodexAdapter(value(argv, '--codex', 'codex')), new ChatGptAdapter()], continuations: [new CodexThreadContinuation(value(argv, '--codex', 'codex')), new CommandContinuation(), new WebhookContinuation()], planner: runtimePlanner(argv), usageSnapshotProvider: () => usageRegistry.snapshot(), decisionEngine: new LayaCliDecisionEngine(), decisionMode: decisionMode(), decisionAuditStore, eventBus: bus, reviewer: runtimeReviewer(), projectPathForJob: job => repositoryPaths.get(job.repository) ?? resolveManagedProjectPath(job.repository, explicitProjectPath, workspaceRoot) });
|
|
76
78
|
if (repositories.length === 0)
|
|
77
79
|
throw new Error(`No Git repositories with GitHub origins found under ${normalized.workspaceRoot ?? defaultWorkspaceRoot()}`);
|
|
78
80
|
const pool = new RepositoryWorkerPool(client, repositories, trusted, concurrency, makeReconciler);
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import { dirname, join } from 'node:path';
|
|
4
|
+
const EMPTY_POLICY = {};
|
|
5
|
+
function safePolicy(value) {
|
|
6
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
7
|
+
return {};
|
|
8
|
+
const source = value;
|
|
9
|
+
const strings = (item) => Array.isArray(item) && item.every(value => typeof value === 'string') ? [...item] : undefined;
|
|
10
|
+
const result = {};
|
|
11
|
+
result.disabledProfiles = strings(source.disabledProfiles);
|
|
12
|
+
result.disabledProviders = strings(source.disabledProviders);
|
|
13
|
+
result.preferredProviders = strings(source.preferredProviders);
|
|
14
|
+
result.preferredProfiles = strings(source.preferredProfiles);
|
|
15
|
+
if (source.qualityByTaskKind && typeof source.qualityByTaskKind === 'object' && !Array.isArray(source.qualityByTaskKind)) {
|
|
16
|
+
result.qualityByTaskKind = {};
|
|
17
|
+
for (const [key, value] of Object.entries(source.qualityByTaskKind)) {
|
|
18
|
+
const parsed = strings(value);
|
|
19
|
+
if (parsed)
|
|
20
|
+
result.qualityByTaskKind[key] = parsed;
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
if (source.recovery && typeof source.recovery === 'object' && !Array.isArray(source.recovery)) {
|
|
24
|
+
const penalize = source.recovery.penalizePriorRoute;
|
|
25
|
+
if (typeof penalize === 'boolean')
|
|
26
|
+
result.recovery = { penalizePriorRoute: penalize };
|
|
27
|
+
}
|
|
28
|
+
return result;
|
|
29
|
+
}
|
|
30
|
+
export function policyPath(explicit) {
|
|
31
|
+
return explicit ?? process.env.AGENTS_RELAY_ROUTING_POLICY ?? join(process.cwd(), '.agents-relay-routing-policy.json');
|
|
32
|
+
}
|
|
33
|
+
export function modelRouterRulesPath(explicit) {
|
|
34
|
+
return explicit ?? process.env.AGENTS_RELAY_MODEL_ROUTER_RULES ?? join(process.cwd(), '.agents-relay-model-router-rules.md');
|
|
35
|
+
}
|
|
36
|
+
export async function loadModelRouterRules(explicit) {
|
|
37
|
+
const path = modelRouterRulesPath(explicit);
|
|
38
|
+
try {
|
|
39
|
+
const text = await readFile(path, 'utf8');
|
|
40
|
+
return { path, hash: createHash('sha256').update(text).digest('hex'), text };
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return { path, hash: 'none', text: '' };
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
export async function saveModelRouterRules(text, explicit) {
|
|
47
|
+
const path = modelRouterRulesPath(explicit);
|
|
48
|
+
await mkdir(dirname(path), { recursive: true });
|
|
49
|
+
await writeFile(path, text, 'utf8');
|
|
50
|
+
return { path, hash: createHash('sha256').update(text).digest('hex'), text };
|
|
51
|
+
}
|
|
52
|
+
export async function loadRoutingPolicy(explicit, explicitRules) {
|
|
53
|
+
const path = policyPath(explicit);
|
|
54
|
+
const rules = await loadModelRouterRules(explicitRules);
|
|
55
|
+
try {
|
|
56
|
+
const text = await readFile(path, 'utf8');
|
|
57
|
+
const policy = safePolicy(JSON.parse(text));
|
|
58
|
+
return { path, hash: createHash('sha256').update(text).digest('hex'), policy, snapshot: JSON.parse(JSON.stringify(policy)), rules };
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
return { path: null, hash: 'none', policy: EMPTY_POLICY, snapshot: {}, rules };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
export function policyAllows(candidate, policy) {
|
|
65
|
+
if (policy.disabledProviders?.includes(candidate.provider))
|
|
66
|
+
return false;
|
|
67
|
+
if (candidate.profile && policy.disabledProfiles?.includes(candidate.profile))
|
|
68
|
+
return false;
|
|
69
|
+
return true;
|
|
70
|
+
}
|
|
71
|
+
export function policyRank(candidate, taskKind, policy) {
|
|
72
|
+
const preferred = new Set([...(policy.preferredProviders ?? []), ...(policy.preferredProfiles ?? [])]);
|
|
73
|
+
const quality = taskKind ? new Set(policy.qualityByTaskKind?.[taskKind] ?? []) : new Set();
|
|
74
|
+
return (preferred.has(candidate.provider) || (candidate.profile && preferred.has(candidate.profile)) ? 2 : 0) + (quality.has(candidate.id) ? 1 : 0);
|
|
75
|
+
}
|
package/dist/runtime-status.js
CHANGED
|
@@ -46,6 +46,8 @@ export function deriveJobRuntimeStatus(job, prState = 'OPEN') {
|
|
|
46
46
|
return 'FAILED';
|
|
47
47
|
if (job.tasks.some(task => task.state === 'DRAFT'))
|
|
48
48
|
return 'WAITING';
|
|
49
|
+
if (job.executionMode === 'autonomous' && job.autoMerge === false && job.reviewEnabled === false && implementationComplete(job) && prState === 'OPEN')
|
|
50
|
+
return 'WAITING';
|
|
49
51
|
if (job.reviewEnabled === false && implementationComplete(job) && prState === 'OPEN')
|
|
50
52
|
return 'MERGE_PENDING';
|
|
51
53
|
return 'IDLE';
|
package/dist/scheduler.js
CHANGED
|
@@ -35,13 +35,19 @@ export function extendRetryBudget(task, windowSize = 3) {
|
|
|
35
35
|
const usedOrBudgeted = Math.max(task.maxAttempts, task.attempt);
|
|
36
36
|
return Math.max(windowSize, (Math.floor(usedOrBudgeted / windowSize) + 1) * windowSize);
|
|
37
37
|
}
|
|
38
|
-
export
|
|
38
|
+
export function tryTransitionTaskEvent(task, event, context = {}) {
|
|
39
39
|
const decision = decideTaskLifecycle(task, event, context);
|
|
40
|
+
if (decision.accepted && decision.target)
|
|
41
|
+
applyTaskWorkflowDecision(task, decision);
|
|
42
|
+
return decision;
|
|
43
|
+
}
|
|
44
|
+
export async function transitionTaskEvent(task, event, context = {}) {
|
|
45
|
+
const from = task.state;
|
|
46
|
+
const decision = tryTransitionTaskEvent(task, event, context);
|
|
40
47
|
if (!decision.accepted || !decision.target)
|
|
41
48
|
throw new Error(decision.reason === 'ambiguous'
|
|
42
|
-
? `Ambiguous workflow transition from ${
|
|
43
|
-
: `No enabled workflow transition from ${
|
|
44
|
-
applyTaskWorkflowDecision(task, decision);
|
|
49
|
+
? `Ambiguous workflow transition from ${from} on ${event}`
|
|
50
|
+
: `No enabled workflow transition from ${from} on ${event}`);
|
|
45
51
|
return decision;
|
|
46
52
|
}
|
|
47
53
|
export async function transitionJobEvent(job, event, context = {}) {
|
package/dist/troubleshooter.js
CHANGED
|
@@ -24,7 +24,7 @@ Retrieve this task's lifecycle event history before classifying the root cause o
|
|
|
24
24
|
GET http://127.0.0.1:8787/api/events?job=${encodeURIComponent(job.id)}&task=${encodeURIComponent(task.id)}&limit=200
|
|
25
25
|
Inspect event order, type/status, source, data/phase, duplicate events, missing expected lifecycle events, late events, and execution identifiers. If the event stream points to delivery/runtime failure, inspect the corresponding Relay/worker/tool logs too. Do not treat the final task.error string as the complete root cause.
|
|
26
26
|
|
|
27
|
-
Follow the selected Troubleshooter agent contract. Diagnose root cause from durable task state + event history + relevant runtime logs before retry. If the blocker is a shared worker/runtime/tool defect, create and directly complete a P0/self-job in the owning repository, including required release/deploy verification. Retry this exact original task only after the blocker is removed, reconcile the original job, and verify durable progress.`;
|
|
27
|
+
Follow the selected Troubleshooter agent contract. Diagnose root cause from durable task state + event history + relevant runtime logs before retry. The final task.error string alone is never sufficient. Classify the evidence as model/provider/runtime, worker/tool/infrastructure, semantic task failure, or timeout/unknown. Only model/provider/runtime evidence may call the narrow model re-decision action; that action gathers fresh usage, reloads current policy, updates this exact Task's routing, and does not retry it. Troubleshooter must then reactivate/retry this exact original task and verify progress. Do not manually choose a provider/model or create a replacement task. If the blocker is a shared worker/runtime/tool defect, create and directly complete a P0/self-job in the owning repository, including required release/deploy verification. Retry this exact original task only after the blocker is removed, reconcile the original job, and verify durable progress.`;
|
|
28
28
|
}
|
|
29
29
|
function belongsToCurrentRun(job, task) {
|
|
30
30
|
const slot = job.schedule?.currentRunSlot;
|
|
@@ -12,9 +12,6 @@ export const WORKFLOW_EVENT_NAMES = [
|
|
|
12
12
|
'pr.closed',
|
|
13
13
|
'pr.merged',
|
|
14
14
|
'reconcile',
|
|
15
|
-
'reconcile.blocked',
|
|
16
|
-
'reconcile.completed',
|
|
17
|
-
'reconcile.started',
|
|
18
15
|
'release',
|
|
19
16
|
'retry',
|
|
20
17
|
'review.approved',
|
|
@@ -23,6 +20,9 @@ export const WORKFLOW_EVENT_NAMES = [
|
|
|
23
20
|
'task.completed',
|
|
24
21
|
'task.failed',
|
|
25
22
|
'task.started',
|
|
23
|
+
'watchdog.failed',
|
|
24
|
+
'worker.delivery_failed',
|
|
25
|
+
'worker.start_ack_failed',
|
|
26
26
|
];
|
|
27
27
|
export const WORKFLOW_FACT_NAMES = [
|
|
28
28
|
'dependenciesSatisfied',
|
|
@@ -31,6 +31,7 @@ export const WORKFLOW_FACT_NAMES = [
|
|
|
31
31
|
'dispatchable',
|
|
32
32
|
'notMerged',
|
|
33
33
|
'autoMerge',
|
|
34
|
+
'recoverableExecution',
|
|
34
35
|
];
|
|
35
36
|
export const WORKFLOW_MUTATION_NAMES = [
|
|
36
37
|
'mutation.clearError',
|
|
@@ -33,4 +33,5 @@ export const taskLifecycleWorkflow = load('task-lifecycle', {
|
|
|
33
33
|
dependenciesUnsatisfied: booleanGuard('dependenciesUnsatisfied'),
|
|
34
34
|
runnable: booleanGuard('runnable'),
|
|
35
35
|
dispatchable: booleanGuard('dispatchable'),
|
|
36
|
+
recoverableExecution: booleanGuard('recoverableExecution'),
|
|
36
37
|
});
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { tryTransitionTaskEvent } from '../scheduler.js';
|
|
2
|
+
/**
|
|
3
|
+
* Runtime primitive for applying one canonical Task-graph event to fresh durable state.
|
|
4
|
+
*
|
|
5
|
+
* This class owns serialization and persistence only. It does not decide lifecycle
|
|
6
|
+
* legality: the canonical Mermaid Task graph does that through tryTransitionTaskEvent.
|
|
7
|
+
*/
|
|
8
|
+
export class DurableTaskTransitionRuntime {
|
|
9
|
+
store;
|
|
10
|
+
locks = new Map();
|
|
11
|
+
constructor(store) {
|
|
12
|
+
this.store = store;
|
|
13
|
+
}
|
|
14
|
+
async apply(jobId, taskId, event, options = {}) {
|
|
15
|
+
return this.withLock(`${jobId}/${taskId}`, async () => {
|
|
16
|
+
const job = await this.store.load(jobId);
|
|
17
|
+
const task = job.tasks.find(item => item.id === taskId);
|
|
18
|
+
if (!task || (options.eligible && !options.eligible(job, task)))
|
|
19
|
+
return null;
|
|
20
|
+
const from = task.state;
|
|
21
|
+
const decision = tryTransitionTaskEvent(task, event, options.facts?.(job, task) ?? {});
|
|
22
|
+
if (!decision.accepted || !decision.target)
|
|
23
|
+
return { job, task, from, decision };
|
|
24
|
+
await options.mutate?.(job, task, decision);
|
|
25
|
+
await this.store.saveTask(task);
|
|
26
|
+
return { job, task, from, decision };
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
async withLock(key, work) {
|
|
30
|
+
const previous = this.locks.get(key);
|
|
31
|
+
let release;
|
|
32
|
+
const current = new Promise(resolve => { release = resolve; });
|
|
33
|
+
this.locks.set(key, current);
|
|
34
|
+
if (previous)
|
|
35
|
+
await previous;
|
|
36
|
+
try {
|
|
37
|
+
return await work();
|
|
38
|
+
}
|
|
39
|
+
finally {
|
|
40
|
+
release();
|
|
41
|
+
if (this.locks.get(key) === current)
|
|
42
|
+
this.locks.delete(key);
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
}
|
package/dist/workflow/runtime.js
CHANGED
|
@@ -37,3 +37,10 @@ export function applyJobWorkflowDecision(job, decision, timestamp = new Date().t
|
|
|
37
37
|
job.state = decision.target;
|
|
38
38
|
job.updatedAt = timestamp;
|
|
39
39
|
}
|
|
40
|
+
/** Historical repair for records that stored derived runtime status as Job lifecycle. */
|
|
41
|
+
export function applyHistoricalJobCompatibilityRepair(job, target, timestamp = new Date().toISOString()) {
|
|
42
|
+
if (target !== 'COMPLETED' && target !== 'CANCELLED')
|
|
43
|
+
throw new Error(`Invalid historical Job repair target ${target}`);
|
|
44
|
+
job.state = target;
|
|
45
|
+
job.updatedAt = timestamp;
|
|
46
|
+
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: implementation
|
|
3
|
+
description: Implement the assigned Agents Relay task in the managed checkout and verify the durable result.
|
|
4
|
+
capabilities: [implementation, local_repo, iterative_tools]
|
|
5
|
+
runtime: codex
|
|
6
|
+
delegation: [relay-child-task]
|
|
7
|
+
restrictions: [preserve-user-changes, no-unrelated-refactors]
|
|
8
|
+
---
|
|
9
|
+
|
|
10
|
+
# Implementation Agent
|
|
11
|
+
|
|
12
|
+
Implement the assigned Task in the current managed checkout. Treat the durable
|
|
13
|
+
Job objective and Task input retrieved from GitHub as authoritative. Inspect the
|
|
14
|
+
existing design before editing, keep the change narrowly scoped, and preserve
|
|
15
|
+
unrelated user work.
|
|
16
|
+
|
|
17
|
+
Follow the shared Agents Relay and events-bus contracts. Publish the required
|
|
18
|
+
canonical `task.started` event before doing work, then exactly one terminal task
|
|
19
|
+
event after the declared output is durable. Use the exact execution source
|
|
20
|
+
identity supplied by Relay for every lifecycle event.
|
|
21
|
+
|
|
22
|
+
Run the most relevant tests and checks for the change. Do not claim completion
|
|
23
|
+
without durable implementation evidence, validation results, and a concise
|
|
24
|
+
summary of changed files and remaining risks.
|
|
@@ -58,6 +58,8 @@ Do not create separate Tasks merely for "analyze", "format", "call tool", "check
|
|
|
58
58
|
## Rules
|
|
59
59
|
|
|
60
60
|
- Treat durable Job/Task state as authoritative.
|
|
61
|
+
- Discover visible agents from the resolved execution context before selecting an agent. Choose an existing visible agent only when it is appropriate for the Task; otherwise leave `agentName` unset. Agent discovery is runtime/context-specific, not a hardcoded global list.
|
|
62
|
+
- Build `agentGraph` nodes only from agents in the discovered visible-agent catalog. Every named graph agent must be visible in the resolved planning/execution context.
|
|
61
63
|
- Never invent completed work or evidence.
|
|
62
64
|
- First decide whether the objective is already satisfied.
|
|
63
65
|
- If satisfied, return `objective_status: "satisfied"` and `next_tasks: []`.
|
|
@@ -14,20 +14,26 @@ stateDiagram-v2
|
|
|
14
14
|
QUEUED --> BLOCKED: block
|
|
15
15
|
QUEUED --> FAILED: fail
|
|
16
16
|
QUEUED --> FAILED: effect.launchTask.failed
|
|
17
|
+
QUEUED --> FAILED: watchdog.failed
|
|
18
|
+
QUEUED --> FAILED: worker.delivery_failed
|
|
19
|
+
QUEUED --> FAILED: worker.start_ack_failed
|
|
17
20
|
QUEUED --> CANCELLED: cancel
|
|
18
21
|
|
|
19
22
|
RUNNING --> BLOCKED: task.blocked
|
|
20
23
|
RUNNING --> SUCCEEDED: task.completed / mutation.clearError,mutation.clearFailureReason
|
|
21
24
|
RUNNING --> FAILED: task.failed
|
|
25
|
+
RUNNING --> FAILED: watchdog.failed
|
|
22
26
|
RUNNING --> CANCELLED: task.cancelled
|
|
23
27
|
|
|
24
28
|
BLOCKED --> QUEUED: retry / mutation.clearError,mutation.clearFailureReason
|
|
25
29
|
BLOCKED --> CANCELLED: cancel
|
|
26
30
|
|
|
27
31
|
FAILED --> QUEUED: retry / mutation.clearError,mutation.clearFailureReason
|
|
28
|
-
FAILED --> RUNNING:
|
|
29
|
-
FAILED --> SUCCEEDED:
|
|
30
|
-
FAILED --> BLOCKED:
|
|
32
|
+
FAILED --> RUNNING: task.started [recoverableExecution] / mutation.clearError,mutation.clearFailureReason
|
|
33
|
+
FAILED --> SUCCEEDED: task.completed [recoverableExecution] / mutation.clearError,mutation.clearFailureReason
|
|
34
|
+
FAILED --> BLOCKED: task.blocked [recoverableExecution]
|
|
35
|
+
FAILED --> FAILED: task.failed [recoverableExecution]
|
|
36
|
+
FAILED --> CANCELLED: task.cancelled [recoverableExecution]
|
|
31
37
|
FAILED --> CANCELLED: cancel
|
|
32
38
|
|
|
33
39
|
SUCCEEDED --> [*]
|