agents-relay 1.0.104 → 1.0.106
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/dashboard-html.js +38 -8
- package/dist/dashboard.js +48 -1
- package/dist/reconciler-review.js +16 -3
- package/dist/reviewer-routing.js +3 -17
- package/dist/routing-policy.js +20 -7
- package/dist/runtime-summary.js +38 -0
- package/dist/worker-routing.js +38 -31
- package/package.json +1 -1
- package/skills/agents-relay/agents/planner.agent.md +3 -3
- package/skills/agents-relay/contracts/task-job-contract.md +8 -7
package/dist/dashboard-html.js
CHANGED
|
@@ -14,6 +14,9 @@ export const dashboardHtml = `<!doctype html>
|
|
|
14
14
|
header { position:sticky; top:0; z-index:3; background:var(--surface); border-bottom:1px solid var(--border); padding:.8rem 1rem; }
|
|
15
15
|
.brand { display:flex; align-items:baseline; gap:.65rem; min-width:0; }
|
|
16
16
|
.version { margin-left:auto; color:var(--muted); font-size:.72rem; font-family:ui-monospace,SFMono-Regular,Menlo,monospace; white-space:nowrap; }
|
|
17
|
+
.runtime-summary { display:flex; gap:.4rem; align-items:center; flex-wrap:wrap; margin-top:.55rem; }
|
|
18
|
+
.runtime-stat { padding:.18rem .45rem; border:1px solid var(--border); border-radius:999px; background:var(--surface2); color:var(--muted); font-size:.72rem; white-space:nowrap; }
|
|
19
|
+
.runtime-stat strong { color:var(--text); }
|
|
17
20
|
h1 { margin:0; font-size:1.08rem; letter-spacing:-.02em; }
|
|
18
21
|
.tagline,.subtle { color:var(--muted); font-size:.78rem; }
|
|
19
22
|
.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; }
|
|
@@ -86,7 +89,7 @@ export const dashboardHtml = `<!doctype html>
|
|
|
86
89
|
.event-meta { display:flex; flex-wrap:wrap; gap:.4rem; color:var(--muted); font-size:.68rem; }
|
|
87
90
|
.event-kind { color:var(--accent); font-weight:800; }
|
|
88
91
|
.event-message { margin:.12rem 0 0; overflow-wrap:anywhere; }
|
|
89
|
-
#usage-view,#rules-view { grid-column:1 / -1; }
|
|
92
|
+
#usage-view,#rules-view,#worker-rules-view { grid-column:1 / -1; }
|
|
90
93
|
.rules-editor { display:grid; gap:.65rem; }
|
|
91
94
|
.rules-help { margin:0; color:var(--muted); font-size:.8rem; }
|
|
92
95
|
.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; }
|
|
@@ -113,11 +116,13 @@ export const dashboardHtml = `<!doctype html>
|
|
|
113
116
|
<body>
|
|
114
117
|
<header>
|
|
115
118
|
<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>
|
|
119
|
+
<div id="runtime-summary" class="runtime-summary" aria-label="Runtime summary"></div>
|
|
116
120
|
<div id="rate-limit-warning" class="warning-banner" role="status" hidden></div>
|
|
117
121
|
<nav role="tablist" aria-label="Console views">
|
|
118
122
|
<button id="jobs-tab" type="button" role="tab" aria-selected="true" aria-controls="jobs-view">Jobs</button>
|
|
119
123
|
<button id="usage-tab" type="button" role="tab" aria-selected="false" aria-controls="usage-view">Usage</button>
|
|
120
124
|
<button id="rules-tab" type="button" role="tab" aria-selected="false" aria-controls="rules-view">Model Router Rules</button>
|
|
125
|
+
<button id="worker-rules-tab" type="button" role="tab" aria-selected="false" aria-controls="worker-rules-view">Worker Router Rules</button>
|
|
121
126
|
</nav>
|
|
122
127
|
</header>
|
|
123
128
|
<main>
|
|
@@ -159,6 +164,18 @@ export const dashboardHtml = `<!doctype html>
|
|
|
159
164
|
</div>
|
|
160
165
|
</div>
|
|
161
166
|
</section>
|
|
167
|
+
<section id="worker-rules-view" role="tabpanel" aria-labelledby="worker-rules-tab" hidden>
|
|
168
|
+
<div class="panel">
|
|
169
|
+
<div class="panel-head"><h2>Worker Router Rules</h2><span id="worker-rules-meta" class="subtle"></span></div>
|
|
170
|
+
<div class="panel-body">
|
|
171
|
+
<div class="rules-editor">
|
|
172
|
+
<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>
|
|
173
|
+
<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>
|
|
174
|
+
<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>
|
|
175
|
+
</div>
|
|
176
|
+
</div>
|
|
177
|
+
</div>
|
|
178
|
+
</section>
|
|
162
179
|
</main>
|
|
163
180
|
<script>
|
|
164
181
|
'use strict';
|
|
@@ -178,9 +195,11 @@ const MAX_EVENTS=60;
|
|
|
178
195
|
const jobsTab=document.querySelector('#jobs-tab');
|
|
179
196
|
const usageTab=document.querySelector('#usage-tab');
|
|
180
197
|
const rulesTab=document.querySelector('#rules-tab');
|
|
198
|
+
const workerRulesTab=document.querySelector('#worker-rules-tab');
|
|
181
199
|
const jobsView=document.querySelector('#jobs-view');
|
|
182
200
|
const usageView=document.querySelector('#usage-view');
|
|
183
201
|
const rulesView=document.querySelector('#rules-view');
|
|
202
|
+
const workerRulesView=document.querySelector('#worker-rules-view');
|
|
184
203
|
const jobsWorkspace=document.querySelector('#jobs-workspace');
|
|
185
204
|
const jobList=document.querySelector('#job-list');
|
|
186
205
|
const jobsRefresh=document.querySelector('#jobs-refresh');
|
|
@@ -204,7 +223,12 @@ const rulesText=document.querySelector('#rules-text');
|
|
|
204
223
|
const rulesMeta=document.querySelector('#rules-meta');
|
|
205
224
|
const rulesStatus=document.querySelector('#rules-status');
|
|
206
225
|
const rulesSave=document.querySelector('#rules-save');
|
|
226
|
+
const workerRulesText=document.querySelector('#worker-rules-text');
|
|
227
|
+
const workerRulesMeta=document.querySelector('#worker-rules-meta');
|
|
228
|
+
const workerRulesStatus=document.querySelector('#worker-rules-status');
|
|
229
|
+
const workerRulesSave=document.querySelector('#worker-rules-save');
|
|
207
230
|
const rateLimitWarning=document.querySelector('#rate-limit-warning');
|
|
231
|
+
const runtimeSummary=document.querySelector('#runtime-summary');
|
|
208
232
|
let jobs=[];
|
|
209
233
|
let selectedJob=sessionStorage.getItem('agents-relay:selected-job');
|
|
210
234
|
let selectedTaskId=sessionStorage.getItem('agents-relay:selected-task');
|
|
@@ -283,8 +307,10 @@ function renderRateLimitStatus(status){
|
|
|
283
307
|
rateLimitWarning.textContent='GitHub API rate limit reached. Showing cached job data.'+suffix;
|
|
284
308
|
rateLimitWarning.hidden=false;
|
|
285
309
|
}
|
|
310
|
+
function runtimePill(label,value){const pill=element('span','runtime-stat');pill.append(document.createTextNode(label+' '),element('strong','',text(value)));return pill;}
|
|
311
|
+
async function drawRuntimeSummary(){try{const summary=await loadJSON('/api/runtime-summary');runtimeSummary.replaceChildren(runtimePill('Active Jobs',summary.activeJobs||0),runtimePill('Running Tasks',summary.runningTasks||0),runtimePill('ChatGPT',summary.workers?.chatgpt||0),runtimePill('Codex',summary.workers?.codex||0));for(const item of Array.isArray(summary.models)?summary.models:[])runtimeSummary.append(runtimePill(item.model,item.count));}catch{runtimeSummary.replaceChildren();}}
|
|
286
312
|
async function refreshStatus(){try{renderRateLimitStatus(await loadJSON('/api/status'));}catch{renderRateLimitStatus(null);}}
|
|
287
|
-
async function refresh(force=false){jobsRefresh.disabled=true;jobsRefresh.textContent='Refreshing…';try{const discovered=await loadJSON('/api/jobs'+(force?'?refresh=1':''));jobs=Array.isArray(discovered)?discovered:[];jobs.sort(compareJobs);if(selectedJob&&!jobs.some(job=>job.id===selectedJob)){selectedJob=null;selectedTaskId=null;}if(!selectedJob){const match=location.hash.match(/^#\\/jobs\\/(.+)$/);const fromHash=match?decodeURIComponent(match[1]):null;selectedJob=jobs.some(job=>job.id===fromHash)?fromHash:(jobs[0]?.id||null);}rememberSelection();renderAll();void loadEventHistory();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';jobList.replaceChildren(element('li','error-state',text('Could not load jobs: '+error.message)));}finally{jobsRefresh.disabled=false;jobsRefresh.textContent='Refresh';}}
|
|
313
|
+
async function refresh(force=false){jobsRefresh.disabled=true;jobsRefresh.textContent='Refreshing…';try{const discovered=await loadJSON('/api/jobs'+(force?'?refresh=1':''));jobs=Array.isArray(discovered)?discovered:[];void drawRuntimeSummary();jobs.sort(compareJobs);if(selectedJob&&!jobs.some(job=>job.id===selectedJob)){selectedJob=null;selectedTaskId=null;}if(!selectedJob){const match=location.hash.match(/^#\\/jobs\\/(.+)$/);const fromHash=match?decodeURIComponent(match[1]):null;selectedJob=jobs.some(job=>job.id===fromHash)?fromHash:(jobs[0]?.id||null);}rememberSelection();renderAll();void loadEventHistory();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';jobList.replaceChildren(element('li','error-state',text('Could not load jobs: '+error.message)));}finally{jobsRefresh.disabled=false;jobsRefresh.textContent='Refresh';}}
|
|
288
314
|
function renderEventScope(){const task=eventTaskId&&selected()?.tasks?.find(candidate=>candidate.id===eventTaskId);eventsAllTasks.disabled=!task;eventsAllTasks.setAttribute('aria-pressed',String(!task));eventsScope.textContent=task?'Task '+task.id+' · newest first · 60 max':'Job-wide · newest first · 60 max';}
|
|
289
315
|
function eventLabel(type){return ({'task.process.launched':'Process launched','task.started':'Worker started','task.process.exited':'Sync process exited','task.process.async_exited':'Async process exited','task.completed':'Worker completed','task.failed':'Worker failed','task.blocked':'Worker blocked','review.started':'Review started','review.approved':'Review approved','review.changes_required':'Review changes required','review.blocked':'Review blocked'})[type]||type||'Event';}
|
|
290
316
|
function addEvent(event,level){if(!event||selectedJob&&event.job_id&&event.job_id!==selectedJob||eventTaskId&&event.task_id!==eventTaskId)return;if(event.event_id&&displayedEventIds.has(event.event_id))return;if(event.event_id)displayedEventIds.add(event.event_id);const item=element('li','event-item');const meta=element('div','event-meta');meta.append(element('span','event-kind',text(eventLabel(event.type))),element('span','event-type',text(event.type||'event')),element('span','',text(relativeTime(event.timestamp))),element('span','',text('task: '+value(event.task_id))));item.append(meta);const message=element('p','event-message',text(event.message||'Event received'));if(level==='error'||event.level==='error')message.classList.add('error-text');item.append(message);eventFeed.querySelector('.empty')?.remove();eventFeed.prepend(item);while(eventFeed.children.length>MAX_EVENTS)eventFeed.lastElementChild?.remove();}
|
|
@@ -296,7 +322,9 @@ function creditProvider(provider){const row=element('section','usage-provider');
|
|
|
296
322
|
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)));}}
|
|
297
323
|
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
324
|
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
|
|
325
|
+
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;}}
|
|
326
|
+
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;}}
|
|
327
|
+
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();}
|
|
300
328
|
jobsTab.addEventListener('click',()=>{location.hash=selectedJob?'#/jobs/'+encodeURIComponent(selectedJob):'#/jobs';showView('jobs');});
|
|
301
329
|
jobsRefresh.addEventListener('click',()=>void refresh(true));
|
|
302
330
|
eventsAllTasks.addEventListener('click',()=>{eventTaskId=null;clearEvents();renderEventScope();void loadEventHistory();});
|
|
@@ -308,16 +336,18 @@ quickStatus.addEventListener('change',()=>setQuickMode(quickStatus.value?'status
|
|
|
308
336
|
selectedJobRefresh.addEventListener('click',()=>void refreshSelected());
|
|
309
337
|
usageTab.addEventListener('click',()=>{location.hash='#/usage';showView('usage');});
|
|
310
338
|
rulesTab.addEventListener('click',()=>{location.hash='#/model-router-rules';showView('rules');});
|
|
339
|
+
workerRulesTab.addEventListener('click',()=>{location.hash='#/worker-router-rules';showView('worker-rules');});
|
|
311
340
|
rulesSave.addEventListener('click',()=>void saveRules());
|
|
312
|
-
|
|
341
|
+
workerRulesSave.addEventListener('click',()=>void saveWorkerRules());
|
|
342
|
+
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);}});
|
|
313
343
|
function parseMessage(message){try{return JSON.parse(message.data);}catch{return null;}}
|
|
314
344
|
const stream=new EventSource('/events');
|
|
315
345
|
stream.onopen=()=>{connection.textContent='';connection.setAttribute('aria-label','Event stream connected');connection.className='status-pill ok';};
|
|
316
346
|
stream.onerror=()=>{connection.textContent='';connection.setAttribute('aria-label','Event stream reconnecting');connection.className='status-pill warn';};
|
|
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();}};
|
|
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();});
|
|
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);
|
|
347
|
+
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();}};
|
|
348
|
+
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();});
|
|
349
|
+
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');
|
|
350
|
+
setInterval(()=>{if(location.hash!=='#/usage'&&location.hash!=='#/model-router-rules'&&location.hash!=='#/worker-router-rules')void refresh();},300000);
|
|
321
351
|
setInterval(()=>{if(location.hash==='#/usage')void drawUsage();},30000);
|
|
322
352
|
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';}}
|
|
323
353
|
</script>
|
package/dist/dashboard.js
CHANGED
|
@@ -5,7 +5,8 @@ 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
|
+
import { loadModelRouterRules, loadWorkerRouterRules, saveModelRouterRules, saveWorkerRouterRules } from './routing-policy.js';
|
|
9
|
+
import { summarizeRuntime } from './runtime-summary.js';
|
|
9
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})$/;
|
|
10
11
|
const SSE_HEARTBEAT_MS = 15000;
|
|
11
12
|
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');
|
|
@@ -309,6 +310,52 @@ export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobI
|
|
|
309
310
|
res.end('Method not allowed');
|
|
310
311
|
return;
|
|
311
312
|
}
|
|
313
|
+
if (req.url === '/api/worker-router-rules') {
|
|
314
|
+
if (req.method === 'GET') {
|
|
315
|
+
try {
|
|
316
|
+
const rules = await loadWorkerRouterRules();
|
|
317
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
318
|
+
res.end(JSON.stringify(rules));
|
|
319
|
+
}
|
|
320
|
+
catch (error) {
|
|
321
|
+
res.writeHead(500);
|
|
322
|
+
res.end(error instanceof Error ? error.message : String(error));
|
|
323
|
+
}
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
if (req.method === 'PUT') {
|
|
327
|
+
const chunks = [];
|
|
328
|
+
let size = 0;
|
|
329
|
+
for await (const chunk of req) {
|
|
330
|
+
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk);
|
|
331
|
+
size += buffer.length;
|
|
332
|
+
if (size > 64 * 1024) {
|
|
333
|
+
res.writeHead(413);
|
|
334
|
+
res.end('Worker router rules exceed 64 KiB');
|
|
335
|
+
return;
|
|
336
|
+
}
|
|
337
|
+
chunks.push(buffer);
|
|
338
|
+
}
|
|
339
|
+
try {
|
|
340
|
+
const rules = await saveWorkerRouterRules(Buffer.concat(chunks).toString('utf8'));
|
|
341
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
342
|
+
res.end(JSON.stringify(rules));
|
|
343
|
+
}
|
|
344
|
+
catch (error) {
|
|
345
|
+
res.writeHead(500);
|
|
346
|
+
res.end(error instanceof Error ? error.message : String(error));
|
|
347
|
+
}
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
res.writeHead(405, { allow: 'GET, PUT' });
|
|
351
|
+
res.end('Method not allowed');
|
|
352
|
+
return;
|
|
353
|
+
}
|
|
354
|
+
if (req.url === '/api/runtime-summary') {
|
|
355
|
+
res.writeHead(200, { 'content-type': 'application/json' });
|
|
356
|
+
res.end(JSON.stringify(summarizeRuntime(lastKnownJobs ?? [])));
|
|
357
|
+
return;
|
|
358
|
+
}
|
|
312
359
|
if (req.url === '/api/status') {
|
|
313
360
|
const status = statusProvider ? statusProvider() : { limited: false, cooldownUntil: null };
|
|
314
361
|
res.writeHead(200, { 'content-type': 'application/json' });
|
|
@@ -109,9 +109,22 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
|
|
|
109
109
|
const registered = await this.store.listAgents();
|
|
110
110
|
const candidates = workerCandidates(this.options.adapters, registered, usage);
|
|
111
111
|
for (const task of job.tasks.filter(item => item.kind !== 'planner' && item.agentName !== 'reviewer' && item.adapter !== 'orchestrator' && ['QUEUED', 'BLOCKED'].includes(item.state) && item.output && !item.workerSelection && (item.workerRequirements || item.planningSource === 'model-planner'))) {
|
|
112
|
-
const requirements = task.workerRequirements ?? { capabilities:
|
|
113
|
-
const taskFacts = {
|
|
114
|
-
|
|
112
|
+
const requirements = task.workerRequirements ?? { capabilities: [] };
|
|
113
|
+
const taskFacts = {
|
|
114
|
+
id: task.id,
|
|
115
|
+
kind: task.kind ?? 'work',
|
|
116
|
+
storySize: task.storySize ?? 'M',
|
|
117
|
+
agentName: task.agentName ?? null,
|
|
118
|
+
input: task.input,
|
|
119
|
+
attachments: task.attachments ?? [],
|
|
120
|
+
output: task.output ?? null,
|
|
121
|
+
};
|
|
122
|
+
const routedWorker = await routeWorker(candidates, requirements, {
|
|
123
|
+
decisionEngine: this.options.decisionEngine,
|
|
124
|
+
decisionMode: this.options.decisionMode,
|
|
125
|
+
taskFacts,
|
|
126
|
+
usage,
|
|
127
|
+
});
|
|
115
128
|
const models = await discoverModelCandidates(routedWorker.candidate, registered, usage);
|
|
116
129
|
const routingConstraints = task.routing ? { provider: task.routing.provider, model: task.routing.model, profile: task.routing.profile, reasoning: task.routing.reasoning } : undefined;
|
|
117
130
|
const routedModel = await routeModel(routedWorker.candidate, models, { decisionEngine: this.options.decisionEngine, decisionMode: this.options.decisionMode, taskFacts, constraints: routingConstraints, auditStore: this.options.decisionAuditStore, auditTask: task });
|
package/dist/reviewer-routing.js
CHANGED
|
@@ -1,21 +1,7 @@
|
|
|
1
1
|
function satisfies(candidate, requirements) {
|
|
2
|
-
if (candidate.availability
|
|
2
|
+
if (candidate.availability === 'offline')
|
|
3
3
|
return false;
|
|
4
|
-
if (candidate.
|
|
5
|
-
return false;
|
|
6
|
-
if (!requirements)
|
|
7
|
-
return true;
|
|
8
|
-
if (!requirements.capabilities.every(capability => candidate.capabilities.includes(capability)))
|
|
9
|
-
return false;
|
|
10
|
-
if (requirements.runtime && candidate.runtime !== requirements.runtime)
|
|
11
|
-
return false;
|
|
12
|
-
if (requirements.taskKind && candidate.taskKind !== requirements.taskKind)
|
|
13
|
-
return false;
|
|
14
|
-
if (requirements.trustDomain && !candidate.trustDomains?.includes(requirements.trustDomain))
|
|
15
|
-
return false;
|
|
16
|
-
if (requirements.region && candidate.region !== requirements.region)
|
|
17
|
-
return false;
|
|
18
|
-
if (requirements.labels && Object.entries(requirements.labels).some(([key, value]) => candidate.labels?.[key] !== value))
|
|
4
|
+
if (requirements?.runtime && candidate.runtime !== requirements.runtime)
|
|
19
5
|
return false;
|
|
20
6
|
return true;
|
|
21
7
|
}
|
|
@@ -29,7 +15,7 @@ function producedRoutes(tasks) {
|
|
|
29
15
|
.filter(task => task.agentName !== 'reviewer' && task.routing)
|
|
30
16
|
.map(task => `${task.routing?.provider}\0${task.routing?.model}`));
|
|
31
17
|
}
|
|
32
|
-
/** Select a
|
|
18
|
+
/** Select a reviewer after authoritative worker constraints, then prefer execution diversity. */
|
|
33
19
|
export function selectReviewer(candidates, implementationTasks, requirements) {
|
|
34
20
|
const eligible = candidates.filter(candidate => satisfies(candidate, requirements));
|
|
35
21
|
if (eligible.length === 0)
|
package/dist/routing-policy.js
CHANGED
|
@@ -30,11 +30,7 @@ function safePolicy(value) {
|
|
|
30
30
|
export function policyPath(explicit) {
|
|
31
31
|
return explicit ?? process.env.AGENTS_RELAY_ROUTING_POLICY ?? join(process.cwd(), '.agents-relay-routing-policy.json');
|
|
32
32
|
}
|
|
33
|
-
|
|
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);
|
|
33
|
+
async function loadRouterRules(path) {
|
|
38
34
|
try {
|
|
39
35
|
const text = await readFile(path, 'utf8');
|
|
40
36
|
return { path, hash: createHash('sha256').update(text).digest('hex'), text };
|
|
@@ -43,12 +39,29 @@ export async function loadModelRouterRules(explicit) {
|
|
|
43
39
|
return { path, hash: 'none', text: '' };
|
|
44
40
|
}
|
|
45
41
|
}
|
|
46
|
-
|
|
47
|
-
const path = modelRouterRulesPath(explicit);
|
|
42
|
+
async function saveRouterRules(path, text) {
|
|
48
43
|
await mkdir(dirname(path), { recursive: true });
|
|
49
44
|
await writeFile(path, text, 'utf8');
|
|
50
45
|
return { path, hash: createHash('sha256').update(text).digest('hex'), text };
|
|
51
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');
|
|
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');
|
|
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);
|
|
64
|
+
}
|
|
52
65
|
export async function loadRoutingPolicy(explicit, explicitRules) {
|
|
53
66
|
const path = policyPath(explicit);
|
|
54
67
|
const rules = await loadModelRouterRules(explicitRules);
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
function runningWorker(task) {
|
|
2
|
+
if (task.state !== 'RUNNING')
|
|
3
|
+
return null;
|
|
4
|
+
const adapter = task.workerSelection?.adapter ?? task.adapter;
|
|
5
|
+
return adapter === 'chatgpt' || adapter === 'codex' ? adapter : null;
|
|
6
|
+
}
|
|
7
|
+
export function summarizeRuntime(jobs) {
|
|
8
|
+
let activeJobs = 0;
|
|
9
|
+
let runningTasks = 0;
|
|
10
|
+
let chatgpt = 0;
|
|
11
|
+
let codex = 0;
|
|
12
|
+
const models = new Map();
|
|
13
|
+
for (const job of jobs) {
|
|
14
|
+
if (job.state === 'ACTIVE')
|
|
15
|
+
activeJobs += 1;
|
|
16
|
+
for (const task of job.tasks) {
|
|
17
|
+
if (task.state !== 'RUNNING')
|
|
18
|
+
continue;
|
|
19
|
+
runningTasks += 1;
|
|
20
|
+
const worker = runningWorker(task);
|
|
21
|
+
if (worker === 'chatgpt')
|
|
22
|
+
chatgpt += 1;
|
|
23
|
+
if (worker === 'codex')
|
|
24
|
+
codex += 1;
|
|
25
|
+
const model = task.routing?.model;
|
|
26
|
+
if (model)
|
|
27
|
+
models.set(model, (models.get(model) ?? 0) + 1);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return {
|
|
31
|
+
activeJobs,
|
|
32
|
+
runningTasks,
|
|
33
|
+
workers: { chatgpt, codex },
|
|
34
|
+
models: [...models.entries()]
|
|
35
|
+
.map(([model, count]) => ({ model, count }))
|
|
36
|
+
.sort((left, right) => right.count - left.count || left.model.localeCompare(right.model)),
|
|
37
|
+
};
|
|
38
|
+
}
|
package/dist/worker-routing.js
CHANGED
|
@@ -1,4 +1,12 @@
|
|
|
1
1
|
import { chooseWithDecision } from './decision.js';
|
|
2
|
+
import { loadWorkerRouterRules } from './routing-policy.js';
|
|
3
|
+
export const DEFAULT_WORKER_ROUTER_PRINCIPLES = [
|
|
4
|
+
'Worker Router chooses only the execution worker. Model Router chooses provider/model/profile after worker selection.',
|
|
5
|
+
'Prefer ChatGPT when both workers can execute the task because it does not consume Codex coding-plan tokens and normally has access to a stronger ChatGPT Sol model.',
|
|
6
|
+
'Prefer Codex when the task requires native/local execution or computer-use abilities that are unavailable through ChatGPT plus MacBridge.',
|
|
7
|
+
'Treat coding-plan/provider quota as decision context, not worker legality. Codex may still use another provider selected later by Model Router.',
|
|
8
|
+
'When Codex/OpenAI coding quota is scarce, prefer ChatGPT unless the Codex execution environment is necessary.',
|
|
9
|
+
].join('\n');
|
|
2
10
|
export function usageForProvider(snapshot, provider, now = Date.now()) {
|
|
3
11
|
const usage = snapshot?.providers.find(item => item.id === provider)?.usage;
|
|
4
12
|
if (usage?.available) {
|
|
@@ -39,15 +47,12 @@ export function workerCandidates(adapters, registered, snapshot) {
|
|
|
39
47
|
return adapters.filter(adapter => adapter.name !== 'orchestrator').flatMap(adapter => {
|
|
40
48
|
const workers = registered.filter(worker => worker.runtime.adapter === adapter.name);
|
|
41
49
|
return (workers.length > 0 ? workers : [undefined]).map(worker => {
|
|
42
|
-
const provider = worker?.runtime.provider ?? (adapter.name === 'chatgpt' ? 'chatgpt' :
|
|
43
|
-
const resource = usageForProvider(snapshot, provider);
|
|
50
|
+
const provider = worker?.runtime.provider ?? (adapter.name === 'chatgpt' ? 'chatgpt' : undefined);
|
|
51
|
+
const resource = provider ? usageForProvider(snapshot, provider) : {};
|
|
44
52
|
return {
|
|
45
53
|
workerId: worker?.id ?? adapter.id,
|
|
46
54
|
adapter: adapter.name,
|
|
47
|
-
capabilities: worker?.capabilities ?? [
|
|
48
|
-
...adapter.capabilities,
|
|
49
|
-
...(adapter.name === 'codex' ? ['local_repo', 'iterative_tools', 'long_running'] : ['web_session', 'attachments', 'interactive_ui', 'one_shot']),
|
|
50
|
-
],
|
|
55
|
+
capabilities: worker?.capabilities ?? [...adapter.capabilities],
|
|
51
56
|
runtime: worker?.runtime.adapter ?? adapter.name,
|
|
52
57
|
trustDomains: worker?.routing.trustDomains,
|
|
53
58
|
region: worker?.routing.regions?.[0],
|
|
@@ -64,23 +69,16 @@ export function workerCandidates(adapters, registered, snapshot) {
|
|
|
64
69
|
});
|
|
65
70
|
});
|
|
66
71
|
}
|
|
72
|
+
/**
|
|
73
|
+
* Worker legality is intentionally narrow. Descriptive capability labels and provider
|
|
74
|
+
* quota are facts for decide(), not hard gates. An explicit runtime is retained as a
|
|
75
|
+
* compatibility/caller-owned constraint, and an explicitly offline worker is illegal.
|
|
76
|
+
*/
|
|
67
77
|
export function satisfiesWorker(candidate, requirements) {
|
|
68
|
-
if (candidate.availability
|
|
69
|
-
return false;
|
|
70
|
-
if (candidate.resourceHealthy === false)
|
|
71
|
-
return false;
|
|
72
|
-
if (!requirements.capabilities.every(capability => candidate.capabilities.includes(capability)))
|
|
78
|
+
if (candidate.availability === 'offline')
|
|
73
79
|
return false;
|
|
74
80
|
if (requirements.runtime && candidate.runtime !== requirements.runtime)
|
|
75
81
|
return false;
|
|
76
|
-
if (requirements.taskKind && candidate.taskKind !== requirements.taskKind)
|
|
77
|
-
return false;
|
|
78
|
-
if (requirements.trustDomain && !candidate.trustDomains?.includes(requirements.trustDomain))
|
|
79
|
-
return false;
|
|
80
|
-
if (requirements.region && candidate.region !== requirements.region)
|
|
81
|
-
return false;
|
|
82
|
-
if (requirements.labels && Object.entries(requirements.labels).some(([key, value]) => candidate.labels?.[key] !== value))
|
|
83
|
-
return false;
|
|
84
82
|
return true;
|
|
85
83
|
}
|
|
86
84
|
export function eligibleWorkerCandidates(candidates, requirements) {
|
|
@@ -88,8 +86,8 @@ export function eligibleWorkerCandidates(candidates, requirements) {
|
|
|
88
86
|
}
|
|
89
87
|
function deterministicWorker(eligible) {
|
|
90
88
|
return [...eligible].map((candidate, index) => ({ candidate, index })).sort((left, right) => {
|
|
91
|
-
const
|
|
92
|
-
return
|
|
89
|
+
const preference = (item) => item.adapter === 'chatgpt' ? 0 : 1;
|
|
90
|
+
return preference(left.candidate) - preference(right.candidate) || left.index - right.index;
|
|
93
91
|
})[0].candidate;
|
|
94
92
|
}
|
|
95
93
|
function selection(selected, selectedBy, decisionEvidence) {
|
|
@@ -113,30 +111,35 @@ function selection(selected, selectedBy, decisionEvidence) {
|
|
|
113
111
|
export function selectWorker(candidates, requirements) {
|
|
114
112
|
const eligible = eligibleWorkerCandidates(candidates, requirements);
|
|
115
113
|
if (eligible.length === 0)
|
|
116
|
-
throw new Error('No eligible worker satisfies the
|
|
114
|
+
throw new Error('No eligible worker satisfies the authoritative worker constraints');
|
|
117
115
|
const selected = deterministicWorker(eligible);
|
|
118
|
-
return { candidate: selected, selection: selection(selected, 'worker-router:
|
|
116
|
+
return { candidate: selected, selection: selection(selected, 'worker-router:fallback') };
|
|
119
117
|
}
|
|
120
|
-
/**
|
|
118
|
+
/** Gather factual worker/task/resource context, then let decide() choose among legal workers. */
|
|
121
119
|
export async function routeWorker(candidates, requirements, options = {}) {
|
|
122
120
|
const eligible = eligibleWorkerCandidates(candidates, requirements);
|
|
123
121
|
if (eligible.length === 0)
|
|
124
|
-
throw new Error('No eligible worker satisfies the
|
|
122
|
+
throw new Error('No eligible worker satisfies the authoritative worker constraints');
|
|
125
123
|
const deterministic = deterministicWorker(eligible);
|
|
126
124
|
if (eligible.length === 1) {
|
|
127
125
|
const evidence = { mode: options.decisionMode ?? 'off', fallback: true };
|
|
128
|
-
return { candidate: deterministic, selection: selection(deterministic, 'worker-router:
|
|
126
|
+
return { candidate: deterministic, selection: selection(deterministic, 'worker-router:constraint', evidence) };
|
|
129
127
|
}
|
|
128
|
+
const rules = await loadWorkerRouterRules(options.rulesPath);
|
|
130
129
|
const facts = JSON.parse(JSON.stringify({
|
|
131
130
|
task: options.taskFacts ?? {},
|
|
132
|
-
requirements,
|
|
131
|
+
explicit_constraints: { runtime: requirements.runtime ?? null },
|
|
132
|
+
routing_principles: DEFAULT_WORKER_ROUTER_PRINCIPLES,
|
|
133
|
+
environment_rules: rules.text,
|
|
134
|
+
usage_snapshot: options.usage ?? null,
|
|
133
135
|
candidates: eligible.map(item => ({
|
|
134
136
|
id: item.workerId,
|
|
135
137
|
adapter: item.adapter,
|
|
136
|
-
capabilities: [...item.capabilities],
|
|
137
138
|
runtime: item.runtime ?? null,
|
|
138
|
-
|
|
139
|
+
availability: item.availability ?? null,
|
|
140
|
+
declaredCapabilities: [...item.capabilities],
|
|
139
141
|
resourceProvider: item.resourceProvider ?? null,
|
|
142
|
+
resourceHealthy: item.resourceHealthy ?? null,
|
|
140
143
|
resourceUsedPercent: item.resourceUsedPercent ?? null,
|
|
141
144
|
resourceRemainingPercent: item.resourceRemainingPercent ?? null,
|
|
142
145
|
resourceResetAt: item.resourceResetAt ?? null,
|
|
@@ -144,8 +147,12 @@ export async function routeWorker(candidates, requirements, options = {}) {
|
|
|
144
147
|
resourceWindows: item.resourceWindows ?? null,
|
|
145
148
|
})),
|
|
146
149
|
}));
|
|
147
|
-
const choice = await chooseWithDecision(options.decisionEngine, options.decisionMode ?? 'off', { facts }, {
|
|
150
|
+
const choice = await chooseWithDecision(options.decisionEngine, options.decisionMode ?? 'off', { facts }, {
|
|
151
|
+
type: 'choice',
|
|
152
|
+
question: 'Choose the best execution worker for this task. Use the task description, current usage, routing principles, and environment rules. Prefer ChatGPT when both can do the work; choose Codex when native/local execution is necessary. Do not choose a provider or model here.',
|
|
153
|
+
choices: eligible.map(item => item.workerId),
|
|
154
|
+
}, () => deterministic.workerId);
|
|
148
155
|
const selected = eligible.find(item => item.workerId === choice.choice) ?? deterministic;
|
|
149
|
-
const selectedBy = choice.evidence.engine && !choice.evidence.fallback ? 'worker-router:laya' : 'worker-router:
|
|
156
|
+
const selectedBy = choice.evidence.engine && !choice.evidence.fallback ? 'worker-router:laya' : 'worker-router:fallback';
|
|
150
157
|
return { candidate: selected, selection: selection(selected, selectedBy, choice.evidence) };
|
|
151
158
|
}
|
package/package.json
CHANGED
|
@@ -68,8 +68,8 @@ Do not create separate Tasks merely for "analyze", "format", "call tool", "check
|
|
|
68
68
|
- Prefer independently verifiable Tasks with stable IDs, but do not split work solely to make Tasks smaller.
|
|
69
69
|
- A Task describes what an agent must accomplish; never emit a shell command as a worker Task.
|
|
70
70
|
- Use dependencies only when execution order actually matters.
|
|
71
|
-
-
|
|
72
|
-
- Do not choose
|
|
71
|
+
- Describe each child Task precisely enough for worker-router to decide from the Task itself. Do not invent Codex/ChatGPT capability labels. workerRequirements is optional and should normally be omitted; use an explicit runtime only when the objective already requires that runtime.
|
|
72
|
+
- Do not choose codex/chatgpt, provider/model, or usage probes.
|
|
73
73
|
- Every child Task must declare exactly one output: `task_pr` or `file`.
|
|
74
74
|
- Every child Task must include `storySize` (`S`, `M`, or `L`); use `M` when uncertain.
|
|
75
75
|
- Do not create bookkeeping, waiting, status-checking, wrapper, or planning Tasks. Your response itself is the planning round.
|
|
@@ -86,4 +86,4 @@ For `objective_status: "in_progress"`, `assessment` must be Markdown containing:
|
|
|
86
86
|
|
|
87
87
|
For `objective_status: "satisfied"`, `assessment` may be a concise evidence-based completion assessment without a graph.
|
|
88
88
|
|
|
89
|
-
Each item in
|
|
89
|
+
Each item in next_tasks contains id, input, output, and storySize. It may also contain priority, projectName, agentName, dependencies, capabilities, workerRequirements, maxAttempts, and timeoutMs. Adapter/routing fields are selected by Relay after planning; do not invent them.
|
|
@@ -54,13 +54,14 @@ Keep these concepts distinct: observation is not root cause; hypothesis is not r
|
|
|
54
54
|
|
|
55
55
|
Every new model-backed executable task follows this order:
|
|
56
56
|
|
|
57
|
-
1. The planner
|
|
58
|
-
2.
|
|
59
|
-
3.
|
|
60
|
-
4.
|
|
61
|
-
5.
|
|
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
|
+
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.
|
|
62
63
|
|
|
63
|
-
Planner output must not hard-code
|
|
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
65
|
|
|
65
66
|
## Story size
|
|
66
67
|
|
|
@@ -131,7 +132,7 @@ Job runtime status is derived, not persisted. Relay computes it from lifecycle p
|
|
|
131
132
|
|
|
132
133
|
A normal task never merges a PR. After all non-superseded work gates pass, Relay creates a SHA-bound final review task. The reviewer emits exactly one of `review.approved`, `review.changes_required`, or `review.blocked`. When `autoMerge` is enabled (default), Relay merges only after `review.approved`, pinned to the exact reviewed head SHA. A new task or new PR head makes earlier final-review evidence stale and requires a fresh review.
|
|
133
134
|
|
|
134
|
-
Final-review routing follows the same worker-selection boundary as executable work.
|
|
135
|
+
Final-review routing follows the same factual worker-selection boundary as executable work. Explicitly offline workers and explicit reviewer runtime constraints are hard gates; provider quota and descriptive capability labels do not by themselves make a worker illegal. Among eligible workers, Relay prefers a reviewer with a different worker identity from the implementation tasks; when that is unavailable it prefers a different provider/model, then falls back to the first eligible worker. The selected worker, selectedBy reason, and reviewerRouting event data are durable evidence. No provider is a universal reviewer default.
|
|
135
136
|
|
|
136
137
|
## Job, Task, and planner ownership
|
|
137
138
|
|