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.
@@ -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, launching ready tasks and persisting results.
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.
@@ -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);
@@ -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 showView(view){const isJobs=view==='jobs';jobsView.hidden=!isJobs;jobsWorkspace.hidden=!isJobs;usageView.hidden=isJobs;jobsTab.setAttribute('aria-selected',String(isJobs));usageTab.setAttribute('aria-selected',String(!isJobs));if(isJobs)void refresh();else void drawUsage();}
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
- window.addEventListener('keydown',event=>{if(event.key==='Tab'&&event.altKey){event.preventDefault();showView(location.hash==='#/usage'?'jobs':'usage');}});
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
+ }
@@ -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 eligible = eligibleModels(candidates, options.constraints);
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 deterministic = deterministicModel(eligible);
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? Prefer spending otherwise-expiring paid quota on stronger reasoning when reset is near and substantial capacity remains, but consider every quota window so a tighter longer-horizon limit is not needlessly depleted; never violate task fit or continuity.', choices: eligible.map(item => item.id) }, () => deterministic.id);
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
- provider: selected.provider,
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
- return JSON.stringify({ objective: context.objective, previous_planner_task_id: context.previousPlannerTaskId, 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 })) });
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 job = await this.store.load(event.job_id);
162
- const task = job.tasks.find(item => item.id === event.task_id);
163
- if (!task || !task.executionId || eventSourceId(event) !== executionEventSource(job.id, task.id, task.executionId))
164
- return;
165
- const claimedExecution = task.state === 'QUEUED' && task.executionId !== null && task.leaseOwner !== null;
166
- const exactLiveExecution = live && event.job_id === live.jobId && task.executionId === live.executionId;
167
- if ((exactLiveExecution || claimedExecution) && task.state === 'QUEUED') {
168
- await transitionTaskEvent(task, 'task.started');
169
- task.failureReason = undefined;
170
- task.error = null;
171
- task.updatedAt = event.timestamp || new Date().toISOString();
172
- await this.store.saveTask(task);
173
- if (live)
174
- live.workerStarted = true;
175
- return;
176
- }
177
- if (task.state !== 'FAILED' || !this.isRecoverableWatchdogFailure(task))
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
- await transitionTaskEvent(task, 'reconcile.started');
182
- task.failureReason = undefined;
183
- task.error = null;
184
- task.updatedAt = event.timestamp || new Date().toISOString();
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 job = await this.store.load(jobId);
209
- const task = job.tasks.find(item => item.id === taskId);
210
- if (!task || task.executionId !== executionId || !['QUEUED', 'RUNNING'].includes(task.state))
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 (task.state === 'RUNNING') {
213
- await this.emit(eventFor(jobId, taskId, task.parentTaskId, 'task.process.delivery_failed', 'failed', `Worker ${phase} reported after task.started: ${message}`, 'orchestrator', { phase, authoritative: false, executionId }, { agent: 'relay', component: 'agents-relay' }));
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: phase === 'start-ack' ? 'worker_start_timeout' : undefined }, { agent: 'relay', component: 'agents-relay', watchdog: true });
217
- this.clearExecution(task.id, executionId);
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 (['QUEUED', 'RUNNING'].includes(task.state) && task.adapter === 'codex' && !terminalExecutionClaimed(jobId, taskId, executionId))
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 event = eventFor(jobId, taskId, task.parentTaskId, 'task.failed', 'failed', message, 'user', { reason, terminalGraceMs: this.options.terminalExitGraceMs ?? 1000 }, { agent: 'relay', component: 'agents-relay', watchdog: true });
260
- if (task.state === 'QUEUED') {
261
- this.clearExecution(task.id, executionId);
262
- task.error = message;
263
- task.failureReason = reason;
264
- task.leaseOwner = null;
265
- task.leaseExpiresAt = null;
266
- await transitionTaskEvent(task, 'fail');
267
- await this.store.saveTask(task);
268
- }
269
- else {
270
- await this.applyTerminalEvent(event, true);
271
- }
272
- await this.emit(event);
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 job = await this.store.load(jobId);
281
- const task = job.tasks.find(item => item.id === taskId);
282
- if (!task || task.executionId !== executionId || !['QUEUED', 'RUNNING'].includes(task.state)) {
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
- if (task.state === 'QUEUED') {
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 job = await this.store.load(event.job_id);
303
- const task = job.tasks.find(item => item.id === event.task_id);
304
- if (!task || !['codex', 'chatgpt', 'orchestrator'].includes(task.adapter))
305
- return;
306
- const modelBacked = task.adapter === 'codex' || task.adapter === 'chatgpt';
307
- if (!trustedInternal && modelBacked && !this.isCorrelatedWorkerEvent(task, event))
308
- return;
309
- if (task.state === 'FAILED' && this.isRecoverableWatchdogFailure(task) && (trustedInternal || !modelBacked || this.isCorrelatedWorkerEvent(task, event))) {
310
- if (event.timestamp && new Date(event.timestamp).getTime() < new Date(task.updatedAt).getTime())
311
- return;
312
- await transitionTaskEvent(task, 'reconcile.started');
313
- task.failureReason = undefined;
314
- task.error = null;
315
- }
316
- if (task.state !== 'RUNNING')
317
- return;
318
- const terminalWaiter = this.terminalWaiters.get(task.id);
319
- if (terminalWaiter && (!event.timestamp || new Date(event.timestamp).getTime() < terminalWaiter.launchBarrier))
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
- const acceptedExecutionId = task.executionId;
322
- if (modelBacked && acceptedExecutionId)
323
- claimTerminalExecution(executionEventSource(job.id, task.id, acceptedExecutionId));
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;
@@ -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.state = terminalState;
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 children = result.next_tasks.map(spec => this.plannerChild(job, task, spec));
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 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(), eventBus: bus, reviewer: runtimeReviewer(), projectPathForJob: job => repositoryPaths.get(job.repository) ?? resolveManagedProjectPath(job.repository, explicitProjectPath, workspaceRoot) });
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
+ }
@@ -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 async function transitionTaskEvent(task, event, context = {}) {
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 ${task.state} on ${event}`
43
- : `No enabled workflow transition from ${task.state} on ${event}`);
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 = {}) {
@@ -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
+ }
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "1.0.103",
3
+ "version": "1.0.104",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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: reconcile.started / mutation.clearError,mutation.clearFailureReason
29
- FAILED --> SUCCEEDED: reconcile.completed / mutation.clearError,mutation.clearFailureReason
30
- FAILED --> BLOCKED: reconcile.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 --> [*]