agents-relay 1.0.151 → 1.0.153

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/README.md CHANGED
@@ -81,7 +81,7 @@ Planner-created tasks use stable IDs and the existing parent/subtask tree, so
81
81
  retries and daemon restarts do not create duplicate work.
82
82
  Task producers must follow `skills/agents-relay/contracts/task-job-contract.md`; that shared skill contract defines story size, lifecycle events, review, and auto-merge semantics.
83
83
 
84
- Worker/model routing is code-driven: Relay gathers authoritative worker/model/resource facts, applies hard gates, then can call the generic typed `decide(request, contract)` boundary for judgment among legal candidates. The optional Laya implementation is a transient CLI subprocess per decision (no resident model/service), with active Laya decisions and deterministic fallback on decision failure. See [Worker Router, Model Router, and typed decisions](docs/worker-model-routers.md).
84
+ Execution routing is code-driven: Relay builds complete executable worker+model routes from authoritative runtime/model/resource facts, removes only impossible routes, then calls the generic typed `decide(request, contract)` boundary once. Free-text routing guidance and task/resource/capability signals are soft decision facts; decision failure falls back deterministically to a usable Codex default route. See [Execution Router and typed decisions](docs/worker-model-routers.md).
85
85
  All task producers use the same story-size contract: `S`≈5 minutes, `M`≈15 minutes, and `L`≈45 minutes of normal-reasoning execution, with `M` as the default. `high` reasoning doubles that overall window. ChatGPT Browser Worker start-ack timeout is derived as one third of the effective overall timeout, clamped between 1 and 5 minutes. Orchestrators and planners should choose `storySize`, not raw timeout milliseconds; `--timeout` remains only as an explicit compatibility/debug override.
86
86
 
87
87
  Autonomous jobs can also be scheduled without a Relay database. The job marker
package/dist/cli-help.js CHANGED
@@ -99,7 +99,7 @@ Actions:
99
99
  'task create': `Create one durable Task under an existing Job.
100
100
 
101
101
  Required: normal Job storage options plus --input-file PATH|- (preferred) or --input MARKDOWN, and --output task-pr|file. --input and --input-file are mutually exclusive.
102
- Common: --task-id ID, --priority P0|P1|P2|P3, --story-size S|M|L, --agent NAME, --agent-graph-file PATH|-, --deps a,b, --parent ID, --draft. Normal model-backed Tasks omit adapter/provider/model so Relay runs worker-router then model-router. Use --adapter orchestrator only for synchronous self-work; explicit adapter/provider/model remain compatibility/debug constraints.\nTasks with dependencies start DRAFT; tasks without dependencies start QUEUED unless --draft is given.
102
+ Common: --task-id ID, --priority P0|P1|P2|P3, --story-size S|M|L, --agent NAME, --agent-graph-file PATH|-, --deps a,b, --parent ID, --draft. Normal model-backed Tasks omit adapter/provider/model so Relay runs one Execution Router decision over complete worker+model routes. Use --adapter orchestrator only for synchronous self-work; explicit adapter/provider/model remain compatibility/debug constraints.\nTasks with dependencies start DRAFT; tasks without dependencies start QUEUED unless --draft is given.
103
103
  Directly-created Tasks are not wrapped in a synthetic Plan. Planner-generated Tasks are created internally by Relay from planner output.
104
104
 
105
105
  Example:
@@ -170,8 +170,7 @@ export const dashboardHtml = `<!doctype html>
170
170
  <nav role="tablist" aria-label="Console views">
171
171
  <button id="jobs-tab" type="button" role="tab" aria-selected="true" aria-controls="jobs-view">Jobs</button>
172
172
  <button id="usage-tab" type="button" role="tab" aria-selected="false" aria-controls="usage-view">Usage</button>
173
- <button id="rules-tab" type="button" role="tab" aria-selected="false" aria-controls="rules-view">Model Rules</button>
174
- <button id="worker-rules-tab" type="button" role="tab" aria-selected="false" aria-controls="worker-rules-view">Worker Rules</button>
173
+ <button id="rules-tab" type="button" role="tab" aria-selected="false" aria-controls="rules-view">Execution Rules</button>
175
174
  </nav>
176
175
  </header>
177
176
  <main>
@@ -203,28 +202,16 @@ export const dashboardHtml = `<!doctype html>
203
202
  </section>
204
203
  <section id="rules-view" role="tabpanel" aria-labelledby="rules-tab" hidden>
205
204
  <div class="panel">
206
- <div class="panel-head"><h2>Model Router Rules</h2><span id="rules-meta" class="subtle"></span></div>
205
+ <div class="panel-head"><h2>Execution Router Rules</h2><span id="rules-meta" class="subtle"></span></div>
207
206
  <div class="panel-body">
208
207
  <div class="rules-editor">
209
- <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>
210
- <textarea id="rules-text" class="rules-textarea" spellcheck="true" aria-label="Model router rules" placeholder="Example: ZAI only glm-5.3-flash high thinking. Prefer it while paid quota is abundant. Non-OpenAI models enter the candidate set only when the provider is configured and the concrete model is named here."></textarea>
208
+ <p class="rules-help">Free-text soft policy loaded by the Execution Router for every worker+model routing decision. Impossible routes remain excluded by code; Codex remains the deterministic fallback if decision fails.</p>
209
+ <textarea id="rules-text" class="rules-textarea" spellcheck="true" aria-label="Execution router rules" placeholder="Example: Prefer ZAI glm-5.3-flash high while quota is available. Use ChatGPT for research when appropriate. Codex is fallback only."></textarea>
211
210
  <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>
212
211
  </div>
213
212
  </div>
214
213
  </div>
215
214
  </section>
216
- <section id="worker-rules-view" role="tabpanel" aria-labelledby="worker-rules-tab" hidden>
217
- <div class="panel">
218
- <div class="panel-head"><h2>Worker Router Rules</h2><span id="worker-rules-meta" class="subtle"></span></div>
219
- <div class="panel-body">
220
- <div class="rules-editor">
221
- <p class="rules-help">Free-text guidance loaded by the worker decider when choosing ChatGPT versus Codex. Use this for local preferences; worker legality and Model Router remain separate.</p>
222
- <textarea id="worker-rules-text" class="rules-textarea" spellcheck="true" aria-label="Worker router rules" placeholder="Example: Prefer ChatGPT unless the task requires native local execution or computer-use that ChatGPT plus MacBridge cannot perform."></textarea>
223
- <div class="rules-footer"><span id="worker-rules-status" class="subtle" aria-live="polite"></span><button id="worker-rules-save" class="rules-save" type="button">Save rules</button></div>
224
- </div>
225
- </div>
226
- </div>
227
- </section>
228
215
  </main>
229
216
  <script>
230
217
  'use strict';
@@ -245,11 +232,9 @@ const DASHBOARD_VERSION=${JSON.stringify(dashboardVersion)};
245
232
  const jobsTab=document.querySelector('#jobs-tab');
246
233
  const usageTab=document.querySelector('#usage-tab');
247
234
  const rulesTab=document.querySelector('#rules-tab');
248
- const workerRulesTab=document.querySelector('#worker-rules-tab');
249
235
  const jobsView=document.querySelector('#jobs-view');
250
236
  const usageView=document.querySelector('#usage-view');
251
237
  const rulesView=document.querySelector('#rules-view');
252
- const workerRulesView=document.querySelector('#worker-rules-view');
253
238
  const jobsWorkspace=document.querySelector('#jobs-workspace');
254
239
  const jobList=document.querySelector('#job-list');
255
240
  const jobsRefresh=document.querySelector('#jobs-refresh');
@@ -275,10 +260,6 @@ const rulesText=document.querySelector('#rules-text');
275
260
  const rulesMeta=document.querySelector('#rules-meta');
276
261
  const rulesStatus=document.querySelector('#rules-status');
277
262
  const rulesSave=document.querySelector('#rules-save');
278
- const workerRulesText=document.querySelector('#worker-rules-text');
279
- const workerRulesMeta=document.querySelector('#worker-rules-meta');
280
- const workerRulesStatus=document.querySelector('#worker-rules-status');
281
- const workerRulesSave=document.querySelector('#worker-rules-save');
282
263
  const rateLimitWarning=document.querySelector('#rate-limit-warning');
283
264
  const runtimeSummary=document.querySelector('#runtime-summary');
284
265
  let jobs=[];
@@ -397,11 +378,9 @@ function githubUsageCard(snapshot,status){
397
378
  return card;
398
379
  }
399
380
  async function drawUsage(){try{const overview=await loadJSON('/api/usage');usageRoot.replaceChildren();usageMeta.textContent='Real account telemetry when available';const providers=Array.isArray(overview.providers)?overview.providers:[];const credits=Array.isArray(overview.credits)?overview.credits:[];const grid=element('div','usage-providers');if(overview.github||overview.githubStatus)grid.append(githubUsageCard(overview.github,overview.githubStatus));for(const provider of providers)grid.append(usageProvider(provider));for(const provider of credits)grid.append(creditProvider(provider));if(!grid.children.length){usageRoot.append(element('p','empty',text('Usage telemetry is unavailable.')));return;}usageRoot.append(grid);}catch(error){usageMeta.textContent='Unavailable';usageRoot.replaceChildren(element('p','error-state',text('Usage unavailable: '+error.message)));}}
400
- 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;}}
401
- 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;}}
402
- async function drawWorkerRules(){workerRulesStatus.textContent='';try{const rules=await loadJSON('/api/worker-router-rules');workerRulesText.value=typeof rules.text==='string'?rules.text:'';workerRulesMeta.textContent=rules.hash&&rules.hash!=='none'?'Loaded · '+rules.hash.slice(0,12):'No custom rules saved';}catch(error){workerRulesMeta.textContent='Unavailable';workerRulesStatus.textContent='Could not load rules: '+error.message;}}
403
- async function saveWorkerRules(){workerRulesSave.disabled=true;workerRulesStatus.textContent='Saving…';try{const response=await fetch('/api/worker-router-rules',{method:'PUT',headers:{'content-type':'text/plain; charset=utf-8'},body:workerRulesText.value});if(!response.ok)throw new Error(response.status+' '+await response.text());const rules=await response.json();workerRulesMeta.textContent='Saved · '+rules.hash.slice(0,12);workerRulesStatus.textContent='Saved. New worker decisions will load these rules.';}catch(error){workerRulesStatus.textContent='Save failed: '+error.message;}finally{workerRulesSave.disabled=false;}}
404
- function showView(view){const isJobs=view==='jobs';const isUsage=view==='usage';const isRules=view==='rules';const isWorkerRules=view==='worker-rules';jobsView.hidden=!isJobs;jobsWorkspace.hidden=!isJobs;usageView.hidden=!isUsage;rulesView.hidden=!isRules;workerRulesView.hidden=!isWorkerRules;jobsTab.setAttribute('aria-selected',String(isJobs));usageTab.setAttribute('aria-selected',String(isUsage));rulesTab.setAttribute('aria-selected',String(isRules));workerRulesTab.setAttribute('aria-selected',String(isWorkerRules));if(isJobs)void refresh();else if(isUsage)void drawUsage();else if(isRules)void drawRules();else void drawWorkerRules();}
381
+ async function drawRules(){rulesStatus.textContent='';try{const rules=await loadJSON('/api/execution-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;}}
382
+ async function saveRules(){rulesSave.disabled=true;rulesStatus.textContent='Saving…';try{const response=await fetch('/api/execution-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;}}
383
+ 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();}
405
384
  jobsTab.addEventListener('click',()=>{location.hash=selectedJob?'#/jobs/'+encodeURIComponent(selectedJob):'#/jobs';showView('jobs');});
406
385
  jobsRefresh.addEventListener('click',()=>void refresh(true));
407
386
  eventsAllTasks.addEventListener('click',()=>{eventTaskId=null;clearEvents();renderEventScope();void loadEventHistory();});
@@ -415,19 +394,17 @@ mobileJobsBack.addEventListener('click',()=>{location.hash='#/jobs';syncMobileJo
415
394
  mobileJobs.addEventListener('change',syncMobileJobsView);
416
395
  window.addEventListener('hashchange',syncMobileJobsView);
417
396
  usageTab.addEventListener('click',()=>{location.hash='#/usage';showView('usage');});
418
- rulesTab.addEventListener('click',()=>{location.hash='#/model-router-rules';showView('rules');});
419
- workerRulesTab.addEventListener('click',()=>{location.hash='#/worker-router-rules';showView('worker-rules');});
397
+ rulesTab.addEventListener('click',()=>{location.hash='#/execution-router-rules';showView('rules');});
420
398
  rulesSave.addEventListener('click',()=>void saveRules());
421
- workerRulesSave.addEventListener('click',()=>void saveWorkerRules());
422
- window.addEventListener('keydown',event=>{if(event.key==='Tab'&&event.altKey){event.preventDefault();const next=location.hash==='#/usage'?'rules':location.hash==='#/model-router-rules'?'worker-rules':location.hash==='#/worker-router-rules'?'jobs':'usage';location.hash=next==='usage'?'#/usage':next==='rules'?'#/model-router-rules':next==='worker-rules'?'#/worker-router-rules':(selectedJob?'#/jobs/'+encodeURIComponent(selectedJob):'#/jobs');showView(next);}});
399
+ window.addEventListener('keydown',event=>{if(event.key==='Tab'&&event.altKey){event.preventDefault();const next=location.hash==='#/usage'?'rules':location.hash==='#/execution-router-rules'?'jobs':'usage';location.hash=next==='usage'?'#/usage':next==='rules'?'#/execution-router-rules':(selectedJob?'#/jobs/'+encodeURIComponent(selectedJob):'#/jobs');showView(next);}});
423
400
  function parseMessage(message){try{return JSON.parse(message.data);}catch{return null;}}
424
401
  const stream=new EventSource('/events');
425
402
  stream.onopen=()=>{connection.textContent='';connection.setAttribute('aria-label','Event stream connected');connection.className='status-pill ok';};
426
403
  stream.onerror=()=>{connection.textContent='';connection.setAttribute('aria-label','Event stream reconnecting');connection.className='status-pill warn';};
427
404
  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();void drawRuntimeSummary();}};
428
405
  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();void drawRuntimeSummary();});
429
- if(location.hash==='#/usage')showView('usage');else if(location.hash==='#/model-router-rules')showView('rules');else if(location.hash==='#/worker-router-rules')showView('worker-rules');else showView('jobs');
430
- setInterval(()=>{if(location.hash!=='#/usage'&&location.hash!=='#/model-router-rules'&&location.hash!=='#/worker-router-rules')void refresh();},300000);
406
+ if(location.hash==='#/usage')showView('usage');else if(location.hash==='#/execution-router-rules')showView('rules');else showView('jobs');
407
+ setInterval(()=>{if(location.hash!=='#/usage'&&location.hash!=='#/execution-router-rules')void refresh();},300000);
431
408
  setInterval(()=>{if(location.hash==='#/usage')void drawUsage();},30000);
432
409
  setInterval(()=>void refreshVersion(),30000);
433
410
  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';}}
package/dist/dashboard.js CHANGED
@@ -5,7 +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, loadWorkerRouterRules, saveModelRouterRules, saveWorkerRouterRules } from './routing-policy.js';
8
+ import { loadExecutionRouterRules, saveExecutionRouterRules } from './routing-policy.js';
9
9
  import { summarizeRuntime } from './runtime-summary.js';
10
10
  const GITHUB_REPOSITORY_PATTERN = /^([A-Za-z0-9](?:[A-Za-z0-9-]{0,36}[A-Za-z0-9])?)\/([A-Za-z0-9._-]{1,100})$/;
11
11
  const SSE_HEARTBEAT_MS = 15000;
@@ -327,10 +327,10 @@ export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobI
327
327
  res.end(JSON.stringify(events));
328
328
  return;
329
329
  }
330
- if (req.url === '/api/model-router-rules') {
330
+ if (req.url === '/api/execution-router-rules') {
331
331
  if (req.method === 'GET') {
332
332
  try {
333
- const rules = await loadModelRouterRules();
333
+ const rules = await loadExecutionRouterRules();
334
334
  res.writeHead(200, { 'content-type': 'application/json' });
335
335
  res.end(JSON.stringify(rules));
336
336
  }
@@ -348,54 +348,13 @@ export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobI
348
348
  size += buffer.length;
349
349
  if (size > 64 * 1024) {
350
350
  res.writeHead(413);
351
- res.end('Model router rules exceed 64 KiB');
351
+ res.end('Execution router rules exceed 64 KiB');
352
352
  return;
353
353
  }
354
354
  chunks.push(buffer);
355
355
  }
356
356
  try {
357
- const rules = await saveModelRouterRules(Buffer.concat(chunks).toString('utf8'));
358
- res.writeHead(200, { 'content-type': 'application/json' });
359
- res.end(JSON.stringify(rules));
360
- }
361
- catch (error) {
362
- res.writeHead(500);
363
- res.end(error instanceof Error ? error.message : String(error));
364
- }
365
- return;
366
- }
367
- res.writeHead(405, { allow: 'GET, PUT' });
368
- res.end('Method not allowed');
369
- return;
370
- }
371
- if (req.url === '/api/worker-router-rules') {
372
- if (req.method === 'GET') {
373
- try {
374
- const rules = await loadWorkerRouterRules();
375
- res.writeHead(200, { 'content-type': 'application/json' });
376
- res.end(JSON.stringify(rules));
377
- }
378
- catch (error) {
379
- res.writeHead(500);
380
- res.end(error instanceof Error ? error.message : String(error));
381
- }
382
- return;
383
- }
384
- if (req.method === 'PUT') {
385
- const chunks = [];
386
- let size = 0;
387
- for await (const chunk of req) {
388
- const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
389
- size += buffer.length;
390
- if (size > 64 * 1024) {
391
- res.writeHead(413);
392
- res.end('Worker router rules exceed 64 KiB');
393
- return;
394
- }
395
- chunks.push(buffer);
396
- }
397
- try {
398
- const rules = await saveWorkerRouterRules(Buffer.concat(chunks).toString('utf8'));
357
+ const rules = await saveExecutionRouterRules(Buffer.concat(chunks).toString('utf8'));
399
358
  res.writeHead(200, { 'content-type': 'application/json' });
400
359
  res.end(JSON.stringify(rules));
401
360
  }
@@ -0,0 +1,163 @@
1
+ import { chooseWithDecision } from './decision.js';
2
+ import { makeDecisionAudit } from './decision-audit.js';
3
+ import { discoverModelCandidates } from './model-routing.js';
4
+ import { loadExecutionRouterRules, loadRoutingPolicy, policyAllows } from './routing-policy.js';
5
+ import { DEFAULT_CHATGPT_PREFERRED_CONCURRENCY, eligibleWorkerCandidates } from './worker-routing.js';
6
+ function sameModelRoute(candidate, route) {
7
+ return (!route.provider || candidate.provider === route.provider)
8
+ && (!route.model || candidate.model === route.model)
9
+ && (route.profile === undefined || candidate.profile === route.profile);
10
+ }
11
+ function modelUsable(candidate, constraints, excludedRoutes, policy) {
12
+ if (candidate.resourceHealthy === false)
13
+ return false;
14
+ if ((excludedRoutes ?? []).some(route => sameModelRoute(candidate, route)))
15
+ return false;
16
+ if (!policyAllows(candidate, policy.policy))
17
+ return false;
18
+ if (constraints?.provider && candidate.provider !== constraints.provider)
19
+ return false;
20
+ if (constraints?.model && candidate.model !== constraints.model)
21
+ return false;
22
+ if (constraints?.profile && candidate.profile !== constraints.profile)
23
+ return false;
24
+ if (constraints?.reasoning && candidate.reasoning && candidate.reasoning !== constraints.reasoning)
25
+ return false;
26
+ return true;
27
+ }
28
+ function routeId(worker, model) {
29
+ return `${worker.workerId}::${model.id}`;
30
+ }
31
+ function fallbackRoute(routes) {
32
+ const codex = routes.filter(route => route.worker.adapter === 'codex');
33
+ return codex.find(route => route.model.id.startsWith('default:')) ?? codex[0] ?? routes[0];
34
+ }
35
+ function workerSelection(worker, selectedBy, evidence) {
36
+ return {
37
+ workerId: worker.workerId,
38
+ adapter: worker.adapter,
39
+ capabilities: [...worker.capabilities],
40
+ selectedBy,
41
+ selectedAt: new Date().toISOString(),
42
+ resourceHealthy: worker.resourceHealthy,
43
+ resourceProvider: worker.resourceProvider,
44
+ resourceUsedPercent: worker.resourceUsedPercent,
45
+ resourceRemainingPercent: worker.resourceRemainingPercent,
46
+ resourceResetAt: worker.resourceResetAt,
47
+ resourceResetInMinutes: worker.resourceResetInMinutes,
48
+ resourceWindows: worker.resourceWindows,
49
+ browserWorkspaceCapacity: worker.browserWorkspaceCapacity,
50
+ decisionEvidence: evidence,
51
+ };
52
+ }
53
+ export async function discoverExecutionRoutes(workers, requirements, options = {}) {
54
+ const legalWorkers = eligibleWorkerCandidates(workers, requirements);
55
+ const registered = options.registered ?? [];
56
+ const policy = await loadRoutingPolicy(options.policyPath, options.rulesPath);
57
+ const routes = [];
58
+ for (const worker of legalWorkers) {
59
+ const models = await discoverModelCandidates(worker, registered, options.usage, undefined, options.rulesPath);
60
+ for (const model of models) {
61
+ if (!modelUsable(model, options.routingConstraints, options.excludedRoutes, policy))
62
+ continue;
63
+ routes.push({ id: routeId(worker, model), worker, model });
64
+ }
65
+ }
66
+ return { routes, policy };
67
+ }
68
+ export async function routeExecution(workers, requirements, options = {}) {
69
+ const { routes, policy } = await discoverExecutionRoutes(workers, requirements, options);
70
+ if (routes.length === 0)
71
+ throw new Error('No executable worker+model route satisfies authoritative constraints');
72
+ const fallback = fallbackRoute(routes);
73
+ const rules = await loadExecutionRouterRules(options.rulesPath);
74
+ const environmentRules = rules.text;
75
+ const preferredChatgptConcurrency = options.preferredChatgptConcurrency ?? DEFAULT_CHATGPT_PREFERRED_CONCURRENCY;
76
+ const chatgptRunning = options.chatgptRunning ?? 0;
77
+ const facts = JSON.parse(JSON.stringify({
78
+ task: options.taskFacts ?? {},
79
+ worker_requirements: requirements,
80
+ explicit_routing_constraints: options.routingConstraints ?? {},
81
+ environment_rules: environmentRules,
82
+ structured_policy: policy.snapshot,
83
+ usage_snapshot: options.usage ?? null,
84
+ runtime_load: {
85
+ chatgpt_running: chatgptRunning,
86
+ preferred_chatgpt_concurrency: preferredChatgptConcurrency,
87
+ },
88
+ routing_principles: [
89
+ 'Choose one complete executable route: worker plus provider/model/profile/reasoning.',
90
+ 'Only authoritative impossibility constraints are hard gates; all other task fit, capability, trust, region, labels, quota, cost, latency, load, reviewer diversity, and quality signals are soft facts.',
91
+ 'User free-text environment rules are soft routing guidance and should be followed when they express a clear preference.',
92
+ 'Codex is only the deterministic fallback when the decision engine fails; Codex is not a normal preference when decide() succeeds.',
93
+ ],
94
+ candidates: routes.map(route => ({
95
+ id: route.id,
96
+ worker: {
97
+ id: route.worker.workerId,
98
+ adapter: route.worker.adapter,
99
+ runtime: route.worker.runtime ?? null,
100
+ availability: route.worker.availability ?? null,
101
+ capabilities: [...route.worker.capabilities],
102
+ trustDomains: route.worker.trustDomains ?? [],
103
+ region: route.worker.region ?? null,
104
+ labels: route.worker.labels ?? {},
105
+ browserWorkspaceCapacity: route.worker.browserWorkspaceCapacity ?? null,
106
+ },
107
+ model: {
108
+ provider: route.model.provider,
109
+ model: route.model.model,
110
+ profile: route.model.profile ?? null,
111
+ reasoning: route.model.reasoning ?? null,
112
+ resourceHealthy: route.model.resourceHealthy ?? null,
113
+ resourceUsedPercent: route.model.resourceUsedPercent ?? null,
114
+ resourceRemainingPercent: route.model.resourceRemainingPercent ?? null,
115
+ resourceResetAt: route.model.resourceResetAt ?? null,
116
+ resourceResetInMinutes: route.model.resourceResetInMinutes ?? null,
117
+ resourceWindows: route.model.resourceWindows ?? null,
118
+ },
119
+ })),
120
+ }));
121
+ const choice = await chooseWithDecision(options.decisionEngine, options.decisionMode ?? 'off', { facts }, {
122
+ type: 'choice',
123
+ question: 'Choose the best complete execution route for this task. The listed candidates are already executable. Treat all non-impossibility signals as soft facts, follow free-text user guidance, and choose worker+model together in this one decision.',
124
+ choices: routes.map(route => route.id),
125
+ }, () => fallback.id);
126
+ const selected = routes.find(route => route.id === choice.choice) ?? fallback;
127
+ const selectedBy = choice.evidence.engine && !choice.evidence.fallback ? 'execution-router:laya' : 'execution-router:fallback';
128
+ const routing = {
129
+ provider: selected.model.provider,
130
+ model: selected.model.model,
131
+ profile: selected.model.profile,
132
+ reasoning: options.routingConstraints?.reasoning ?? selected.model.reasoning,
133
+ decidedBy: selectedBy,
134
+ decidedAt: new Date().toISOString(),
135
+ decisionEvidence: choice.evidence,
136
+ };
137
+ const audit = makeDecisionAudit({
138
+ task: options.auditTask,
139
+ worker: { id: selected.worker.workerId, adapter: selected.worker.adapter, capabilities: [...selected.worker.capabilities], route_id: selected.id },
140
+ contract: {
141
+ question: 'execution-route-selection',
142
+ task: options.taskFacts ?? {},
143
+ workerRequirements: requirements,
144
+ constraints: options.routingConstraints ?? {},
145
+ excludedRoutes: options.excludedRoutes ?? [],
146
+ candidateRoutes: routes.map(route => ({ id: route.id, workerId: route.worker.workerId, adapter: route.worker.adapter })),
147
+ },
148
+ policy,
149
+ candidates: routes.map(route => ({ ...route.model, id: route.id })),
150
+ eligibleCandidates: routes.map(route => ({ ...route.model, id: route.id })),
151
+ selected: { ...selected.model, id: selected.id },
152
+ routing,
153
+ });
154
+ routing.decisionId = audit.decision_id;
155
+ if (options.auditStore)
156
+ await options.auditStore.append(audit);
157
+ return {
158
+ candidate: { ...selected.worker, routing },
159
+ selection: workerSelection(selected.worker, selectedBy, choice.evidence),
160
+ model: selected.model,
161
+ audit,
162
+ };
163
+ }
@@ -4,7 +4,7 @@ import { readdir, readFile } from 'node:fs/promises';
4
4
  import { chooseWithDecision } from './decision.js';
5
5
  import { usageForProvider } from './worker-routing.js';
6
6
  import { makeDecisionAudit } from './decision-audit.js';
7
- import { loadModelRouterRules, loadRoutingPolicy, policyAllows, policyRank } from './routing-policy.js';
7
+ import { loadExecutionRouterRules, loadRoutingPolicy, policyAllows, policyRank } from './routing-policy.js';
8
8
  function topLevelCodexConfig(text) {
9
9
  const result = {};
10
10
  let inSection = false;
@@ -63,7 +63,7 @@ function withResource(candidate, usage) {
63
63
  export async function discoverModelCandidates(worker, registered, usage, codexHome = process.env.CODEX_HOME ?? join(homedir(), '.codex'), rulesPath) {
64
64
  const registration = registered.find(item => item.id === worker.workerId);
65
65
  if (registration?.runtime.provider && registration.runtime.model) {
66
- const rules = await loadModelRouterRules(rulesPath);
66
+ const rules = await loadExecutionRouterRules(rulesPath);
67
67
  if (!nonOpenAiModelAuthorized(registration.runtime.provider, registration.runtime.model, rules.text))
68
68
  return [];
69
69
  return [withResource({
@@ -76,7 +76,7 @@ export async function discoverModelCandidates(worker, registered, usage, codexHo
76
76
  return [withResource({ id: 'chatgpt/account-defaults', provider: 'chatgpt', model: 'account-defaults' }, usage)];
77
77
  }
78
78
  const base = await readConfig(join(codexHome, 'config.toml')) ?? {};
79
- const rules = await loadModelRouterRules(rulesPath);
79
+ const rules = await loadExecutionRouterRules(rulesPath);
80
80
  const candidates = [];
81
81
  if (base.model) {
82
82
  const provider = base.provider ?? 'openai';
@@ -2,8 +2,8 @@ import { storySizeTimeoutMs, taskOverallTimeoutMs } from './types.js';
2
2
  import { completionGateSatisfied, schedule, transitionTaskEvent, transitionJobEvent, tryTransitionTaskEvent } from './scheduler.js';
3
3
  import { implementationComplete, implementationTasks } from './runtime-status.js';
4
4
  import { eventFor } from './events.js';
5
- import { routeWorker, workerCandidates } from './worker-routing.js';
6
- import { discoverModelCandidates, routeModel } from './model-routing.js';
5
+ import { workerCandidates } from './worker-routing.js';
6
+ import { routeExecution } from './execution-routing.js';
7
7
  import { ReconcilerRuntimeBase } from './reconciler-runtime.js';
8
8
  export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
9
9
  async currentHeadSha(job) { return await this.store.headSha?.() ?? job.headSha ?? job.review?.headSha ?? null; }
@@ -84,23 +84,18 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
84
84
  },
85
85
  };
86
86
  const runtimeSummary = this.options.runtimeSummaryProvider?.();
87
- const routedWorker = await routeWorker(workers, configured?.requirements ?? { capabilities: ['model'] }, {
87
+ const routed = await routeExecution(workers, configured?.requirements ?? { capabilities: ['model'] }, {
88
88
  decisionEngine: this.options.decisionEngine,
89
89
  decisionMode: this.options.decisionMode,
90
90
  taskFacts,
91
91
  usage,
92
+ registered,
92
93
  chatgptRunning: runtimeSummary?.chatgptRunning ?? 0,
93
- });
94
- const models = await discoverModelCandidates(routedWorker.candidate, registered, usage);
95
- const routedModel = await routeModel(routedWorker.candidate, models, {
96
- decisionEngine: this.options.decisionEngine,
97
- decisionMode: this.options.decisionMode,
98
- constraints: configured?.routing,
99
- taskFacts,
94
+ routingConstraints: configured?.routing,
100
95
  });
101
96
  return {
102
- candidate: { ...routedWorker.candidate, routing: routedModel.routing },
103
- selection: routedWorker.selection,
97
+ candidate: routed.candidate,
98
+ selection: routed.selection,
104
99
  reason: 'worker-router',
105
100
  };
106
101
  }
@@ -120,28 +115,31 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
120
115
  attachments: task.attachments ?? [],
121
116
  output: task.output ?? null,
122
117
  };
123
- const routedWorker = await routeWorker(candidates, requirements, {
124
- decisionEngine: this.options.decisionEngine,
125
- decisionMode: this.options.decisionMode,
126
- taskFacts,
127
- usage,
128
- chatgptRunning: runtimeSummary?.chatgptRunning ?? 0,
129
- });
130
- const models = await discoverModelCandidates(routedWorker.candidate, registered, usage);
131
118
  const routingConstraints = task.routing ? { provider: task.routing.provider, model: task.routing.model, profile: task.routing.profile, reasoning: task.routing.reasoning } : undefined;
132
119
  const parent = task.agentName === 'troubleshooter' && task.parentTaskId ? job.tasks.find(item => item.id === task.parentTaskId) : undefined;
133
120
  const parentRouteFailedBeforeStart = Boolean(parent?.routing && /exited before publishing task\.started|worker_exit_before_start|launch failed/i.test(parent.error ?? parent.failureReason ?? ''));
134
121
  const excludedRoutes = parentRouteFailedBeforeStart && parent?.routing
135
122
  ? [{ provider: parent.routing.provider, model: parent.routing.model, profile: parent.routing.profile }]
136
123
  : [];
137
- const routedModel = await routeModel(routedWorker.candidate, models, { decisionEngine: this.options.decisionEngine, decisionMode: this.options.decisionMode, taskFacts: { ...taskFacts, recovery_parent_failed_route: excludedRoutes[0] ?? null }, constraints: routingConstraints, excludedRoutes, auditStore: this.options.decisionAuditStore, auditTask: task });
138
- task.workerSelection = routedWorker.selection;
139
- task.adapter = routedWorker.candidate.adapter;
140
- task.routing = routedModel.routing;
124
+ const routed = await routeExecution(candidates, requirements, {
125
+ decisionEngine: this.options.decisionEngine,
126
+ decisionMode: this.options.decisionMode,
127
+ taskFacts: { ...taskFacts, recovery_parent_failed_route: excludedRoutes[0] ?? null },
128
+ usage,
129
+ registered,
130
+ chatgptRunning: runtimeSummary?.chatgptRunning ?? 0,
131
+ routingConstraints,
132
+ excludedRoutes,
133
+ auditStore: this.options.decisionAuditStore,
134
+ auditTask: task,
135
+ });
136
+ task.workerSelection = routed.selection;
137
+ task.adapter = routed.candidate.adapter;
138
+ task.routing = routed.candidate.routing;
141
139
  task.timeoutMs = task.timeoutMs || taskOverallTimeoutMs(task.storySize ?? 'M', task.routing.reasoning);
142
140
  task.updatedAt = new Date().toISOString();
143
141
  await this.store.saveTask(task);
144
- await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'worker.selected', 'queued', `Worker ${routedWorker.selection.workerId} selected for task ${task.id}`, 'user', { workerSelection: routedWorker.selection, workerRequirements: requirements }, { agent: 'relay', component: 'agents-relay' }));
142
+ await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'worker.selected', 'queued', `Worker ${routed.selection.workerId} selected for task ${task.id}`, 'user', { workerSelection: routed.selection, workerRequirements: requirements }, { agent: 'relay', component: 'agents-relay' }));
145
143
  await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'model.selected', 'queued', `Selected ${task.routing.model} · thinking ${task.routing.reasoning ?? 'unknown'}`, 'user', { routing: task.routing }, { agent: 'relay', component: 'agents-relay' }));
146
144
  }
147
145
  }
@@ -44,27 +44,18 @@ async function saveRouterRules(path, text) {
44
44
  await writeFile(path, text, 'utf8');
45
45
  return { path, hash: createHash('sha256').update(text).digest('hex'), text };
46
46
  }
47
- export function modelRouterRulesPath(explicit) {
48
- return explicit ?? process.env.AGENTS_RELAY_MODEL_ROUTER_RULES ?? join(process.cwd(), '.agents-relay-model-router-rules.md');
47
+ export function executionRouterRulesPath(explicit) {
48
+ return explicit ?? process.env.AGENTS_RELAY_EXECUTION_ROUTER_RULES ?? join(process.cwd(), '.agents-relay-execution-router-rules.md');
49
49
  }
50
- export function workerRouterRulesPath(explicit) {
51
- return explicit ?? process.env.AGENTS_RELAY_WORKER_ROUTER_RULES ?? join(process.cwd(), '.agents-relay-worker-router-rules.md');
50
+ export async function loadExecutionRouterRules(explicit) {
51
+ return await loadRouterRules(executionRouterRulesPath(explicit));
52
52
  }
53
- export async function loadModelRouterRules(explicit) {
54
- return await loadRouterRules(modelRouterRulesPath(explicit));
55
- }
56
- export async function saveModelRouterRules(text, explicit) {
57
- return await saveRouterRules(modelRouterRulesPath(explicit), text);
58
- }
59
- export async function loadWorkerRouterRules(explicit) {
60
- return await loadRouterRules(workerRouterRulesPath(explicit));
61
- }
62
- export async function saveWorkerRouterRules(text, explicit) {
63
- return await saveRouterRules(workerRouterRulesPath(explicit), text);
53
+ export async function saveExecutionRouterRules(text, explicit) {
54
+ return await saveRouterRules(executionRouterRulesPath(explicit), text);
64
55
  }
65
56
  export async function loadRoutingPolicy(explicit, explicitRules) {
66
57
  const path = policyPath(explicit);
67
- const rules = await loadModelRouterRules(explicitRules);
58
+ const rules = await loadExecutionRouterRules(explicitRules);
68
59
  try {
69
60
  const text = await readFile(path, 'utf8');
70
61
  const policy = safePolicy(JSON.parse(text));
@@ -1,5 +1,5 @@
1
1
  import { chooseWithDecision } from './decision.js';
2
- import { loadWorkerRouterRules } from './routing-policy.js';
2
+ import { loadExecutionRouterRules } from './routing-policy.js';
3
3
  export const DEFAULT_CHATGPT_PREFERRED_CONCURRENCY = 10;
4
4
  export const DEFAULT_WORKER_ROUTER_PRINCIPLES = [
5
5
  'User-provided environment_rules are higher-priority routing guidance than these built-in defaults. Follow user guidance when it conflicts with a default unless doing so would violate an authoritative hard constraint.',
@@ -137,7 +137,7 @@ export async function routeWorker(candidates, requirements, options = {}) {
137
137
  const evidence = { mode: options.decisionMode ?? 'off', fallback: true };
138
138
  return { candidate: deterministic, selection: selection(deterministic, 'worker-router:constraint', evidence) };
139
139
  }
140
- const rules = await loadWorkerRouterRules(options.rulesPath);
140
+ const rules = await loadExecutionRouterRules(options.rulesPath);
141
141
  const facts = JSON.parse(JSON.stringify({
142
142
  task: options.taskFacts ?? {},
143
143
  explicit_constraints: { runtime: requirements.runtime ?? null },
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "1.0.151",
3
+ "version": "1.0.153",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {
@@ -28,7 +28,7 @@ Every model-backed task producer normally declares:
28
28
  - `task_pr`: the managed task/PR is the authoritative result; or
29
29
  - `file`: an exact file path is the authoritative result.
30
30
 
31
- Relay then runs worker-router followed by model-router and persists the selected worker/adapter and model routing before launch. Explicit adapter/provider/model values are compatibility/debug constraints, not normal producer requirements.
31
+ Relay then runs one Execution Router decision over complete worker+model routes and persists the selected worker/adapter and model routing before launch. Explicit adapter/provider/model values are compatibility/debug constraints, not normal producer requirements.
32
32
 
33
33
  ## Lifecycle contract
34
34
 
@@ -105,7 +105,7 @@ Each successful planner invocation is one real `plan-N` planning round. Relay pe
105
105
 
106
106
  A zero-task autonomous Job remains idle. After a direct orchestrator-created initial batch completes, Relay may start the first real planner round. Fixed Jobs do not automatically create planner rounds.
107
107
 
108
- Planner-created child Tasks remain descriptive agent work and must include worker requirements, output contract, and `storySize`; worker-router/model-router choose execution routing afterward.
108
+ Planner-created child Tasks remain descriptive agent work and must include the output contract and `storySize`; worker requirements are optional soft routing facts unless an explicit runtime constraint is known. Execution Router chooses the complete runtime route afterward.
109
109
 
110
110
  ## Durable truth
111
111
 
@@ -56,12 +56,12 @@ Every new model-backed executable task follows this order:
56
56
 
57
57
  1. The planner describes the executable Task precisely. It does not invent a capability matrix for Codex versus ChatGPT. workerRequirements is optional compatibility metadata; an explicit runtime may be used only when the caller already knows the Task must use that runtime.
58
58
  2. worker-router gathers factual Task context, discovered worker availability, the current shared usage snapshot, built-in routing principles, and optional local free-text rules. Only authoritative legality facts such as an explicitly offline worker or an explicit caller-owned runtime constraint are hard gates. Descriptive capability labels, region/trust labels, and provider quota are decision facts rather than ordinary worker hard gates.
59
- 3. When more than one legal model-backed worker exists, worker-router calls the generic typed decide(request, contract) primitive to choose the execution worker. Its default principles prefer ChatGPT when both workers can execute the Task, prefer Codex when native/local execution is necessary, and treat Codex/OpenAI quota pressure as a preference rather than proof that the Codex worker is unusable.
60
- 4. model-router runs only after worker selection and independently chooses provider/model/profile for that worker. In particular, an exhausted OpenAI quota must not eliminate the Codex worker before Model Router has a chance to choose another Codex provider/profile.
61
- 5. decide() owns no routing facts or legality policy. Its optional Laya implementation is a transient CLI subprocess per decision; unavailable/invalid decisions fall back deterministically. src/usage.ts remains the shared resource-fact source.
62
- 6. Relay persists the selected worker and model routing on the durable task before launch.
59
+ 3. Execution Router builds complete executable routes that pair a discovered worker with one configured/authorized model profile. Only authoritative impossibility facts are hard gates: explicit runtime/routing constraints, offline/unavailable execution surfaces, zero allocatable Browser Workspace capacity, unconfigured/unauthorized models, or provider/profile state known to make execution impossible.
60
+ 4. Capabilities, task kind, trust/region/labels, quota pressure, cost, latency, runtime load, reviewer diversity, and free-text environment guidance are soft decision facts unless the caller has explicitly promoted one to a hard constraint. The full workerRequirements object must be passed into the decision facts.
61
+ 5. When more than one executable route exists, Execution Router calls the generic typed decide(request, contract) primitive exactly once to choose worker+provider/model/profile/reasoning together. If the decision engine is unavailable, invalid, or fails, Relay deterministically falls back to a usable Codex route, preferring Codex's configured default profile. Codex fallback is not a normal preference when decide() succeeds.
62
+ 6. decide() owns no discovery facts or legality policy. Its optional Laya implementation is a transient CLI subprocess per decision; src/usage.ts remains the shared resource-fact source. Relay persists the selected worker and model routing on the durable task before launch.
63
63
 
64
- Planner output must not hard-code codex/chatgpt, provider preference, inferred worker capabilities, or usage probes. Worker Router and Model Router are separate responsibilities. Legacy task records that already contain adapter/routing/worker-requirement metadata remain readable.
64
+ Planner output must not hard-code codex/chatgpt, provider preference, inferred worker capabilities, or usage probes. Execution Router owns runtime route selection; legacy task records that already contain adapter/routing/worker-requirement metadata remain readable.
65
65
 
66
66
  ## Story size
67
67