agents-relay 1.0.17 → 1.0.18
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.js +3 -2
- package/dist/pool.js +1 -1
- package/dist/reconciler.js +72 -0
- package/dist/store.js +8 -0
- package/package.json +1 -1
package/dist/dashboard.js
CHANGED
|
@@ -241,7 +241,8 @@ function taskNode(task,children,depth){const li=element('li');const row=document
|
|
|
241
241
|
function buildTree(tasks){taskTree.replaceChildren();if(!Array.isArray(tasks)||!tasks.length){taskTree.append(element('li','empty',text('No tasks in durable state.')));return;}const known=new Set(tasks.map(task=>task.id));const children=new Map();const roots=[];for(const task of tasks){if(!task.parentTaskId||!known.has(task.parentTaskId))roots.push(task);else{const siblings=children.get(task.parentTaskId)||[];siblings.push(task);children.set(task.parentTaskId,siblings);}}for(const task of roots)taskTree.append(taskNode(task,children,0));}
|
|
242
242
|
function attemptBudget(task){const attempt=Number(task?.attempt)||0;const max=Number(task?.maxAttempts)||0;if(max>=3&&max%3===0){const effectiveMax=Math.max(max,Math.ceil(attempt/3)*3);return attempt+' / 3×'+(effectiveMax/3);}return attempt+' / '+max;}
|
|
243
243
|
function renderTaskDetail(task){taskDetailRoot.replaceChildren();if(!task){taskDetailRoot.append(element('p','empty',text('Select a task to inspect execution details.')));return;}const routing=task.routing||{};const groups=element('div','detail-groups');groups.append(field('Task',task.id),field('Kind',task.kind||'work'),field('State',task.state),field('Project / agent',value(task.projectName)+' / '+value(task.agentName)),field('Parent task',task.parentTaskId||'root'),field('Adapter',task.adapter),field('Provider / model',value(routing.provider)+' / '+value(routing.model)),field('Reasoning / profile',value(routing.reasoning||routing.profile)),field('Attempt',attemptBudget(task)),field('Dependencies',Array.isArray(task.dependencies)&&task.dependencies.length?task.dependencies.join(', '):'none'),chatField(task));if(task.plannerResult)groups.append(field('Objective status',task.plannerResult.objective_status),markdownField('Assessment',task.plannerResult.assessment));if(task.error)groups.append(field('Error',task.error));taskDetailRoot.append(groups);}
|
|
244
|
-
function
|
|
244
|
+
function reviewStatus(job){const review=job?.review;if(!review)return 'Not started';if(review.outcome==='approved'&&review.headSha===job.headSha)return 'MERGE READY · approved';const stale=Array.isArray(job?.tasks)&&job.tasks.some(task=>task.agentName==='reviewer'&&task.reviewHeadSha&&job.headSha&&task.reviewHeadSha!==job.headSha);if(stale)return 'STALE · awaiting review';return String(review.outcome||'pending').replace('_',' ').toUpperCase();}
|
|
245
|
+
function renderSelectedJob(){jobDetail.replaceChildren();const job=selected();if(!job){selectedJobTitle.textContent='';jobDetail.append(element('div','empty',text('No durable jobs discovered.')));buildTree([]);renderTaskDetail(null);return;}selectedJobTitle.textContent='— '+value(job.title||job.id);if(job.id!==selectedJob){selectedJob=job.id;selectedTaskId=null;eventTaskId=null;clearEvents();rememberSelection();}const summary=element('div','job-summary');summary.append(field('GitHub state',job.githubState||'UNKNOWN'),field('Durable state',job.state),field('Execution mode',job.executionMode||'fixed'),field('Status',awaitingInitialTask(job)?'Awaiting initial task':job.state),field('Review',reviewStatus(job)),field('Review head SHA',job.review?.headSha||job.headSha||'—'),field('Objective',job.objective||job.description||job.title),prField(job),field('Repository',job.repository||'—'),field('Last activity',relativeTime(job.updatedAt)),field('Task states',compactCounts(job.tasks)));jobDetail.append(summary);const selectedTask=(job.tasks||[]).find(task=>task.id===selectedTaskId)||null;if(selectedTaskId&&!selectedTask){selectedTaskId=null;eventTaskId=null;clearEvents();rememberSelection();}buildTree(job.tasks||[]);renderTaskDetail(selectedTask);renderEventScope();}
|
|
245
246
|
function renderAll(){jobs.sort(compareJobs);syncFilters();renderJobList();renderSelectedJob();}
|
|
246
247
|
function isCompleteUnmerged(job){return job.state==='COMPLETED'&&job.githubState==='OPEN';}
|
|
247
248
|
function isTerminalJob(job){return ['COMPLETED','CANCELLED'].includes(job.state)||['MERGED','CLOSED'].includes(job.githubState);}
|
|
@@ -257,7 +258,7 @@ function renderRateLimitStatus(status){
|
|
|
257
258
|
async function refreshStatus(){try{renderRateLimitStatus(await loadJSON('/api/status'));}catch{renderRateLimitStatus(null);}}
|
|
258
259
|
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='Live · durable state connected';connection.className='status-pill ok';}catch(error){connection.textContent='Error · 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';}}
|
|
259
260
|
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';}
|
|
260
|
-
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'})[type]||type||'Event';}
|
|
261
|
+
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';}
|
|
261
262
|
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();}
|
|
262
263
|
function usageBar(label,windowData){const percent=Math.max(0,Math.min(100,100-windowData.usedPercent));const row=element('div','usage-row');const heading=element('div','usage-label');heading.append(element('span','',text(label)),element('span','',text(Math.round(percent)+'%')));const track=element('div','usage-track');track.setAttribute('role','progressbar');track.setAttribute('aria-label',label+' available');track.setAttribute('aria-valuemin','0');track.setAttribute('aria-valuemax','100');track.setAttribute('aria-valuenow',String(percent));const fill=element('div','usage-fill');fill.style.width=percent+'%';track.append(fill);row.append(heading,track);return row;}
|
|
263
264
|
function usageProvider(provider){const card=element('section','usage-provider');card.append(element('h3','',text(provider.label)));const usage=provider.usage||{};if(!usage.available||!usage.fiveHour||!usage.weekly){card.append(element('p','empty',text(usage.reason||'Account usage telemetry is unavailable.')));return card;}const bars=element('div','usage-bars');bars.append(usageBar('5-hour remaining',usage.fiveHour),usageBar('Weekly remaining',usage.weekly));card.append(bars);return card;}
|
package/dist/pool.js
CHANGED
|
@@ -92,7 +92,7 @@ export class RepositoryWorkerPool {
|
|
|
92
92
|
}
|
|
93
93
|
async watch(bus) {
|
|
94
94
|
return bus.subscribeAll(async (event) => {
|
|
95
|
-
if (['task.completed', 'task.failed', 'task.blocked'].includes(event.type)) {
|
|
95
|
+
if (['task.completed', 'task.failed', 'task.blocked', 'review.started', 'review.approved', 'review.changes_required', 'review.blocked'].includes(event.type)) {
|
|
96
96
|
await this.handleTerminalEvent(event);
|
|
97
97
|
return;
|
|
98
98
|
}
|
package/dist/reconciler.js
CHANGED
|
@@ -56,6 +56,10 @@ export class Reconciler {
|
|
|
56
56
|
await this.applyTerminalEvent(event);
|
|
57
57
|
return;
|
|
58
58
|
}
|
|
59
|
+
if (['review.started', 'review.approved', 'review.changes_required', 'review.blocked'].includes(event.type)) {
|
|
60
|
+
await this.applyReviewEvent(event);
|
|
61
|
+
return;
|
|
62
|
+
}
|
|
59
63
|
if (event.type === 'job.wake' || event.type === 'github.webhook')
|
|
60
64
|
await this.reconcile(event.job_id);
|
|
61
65
|
}
|
|
@@ -81,6 +85,10 @@ export class Reconciler {
|
|
|
81
85
|
const job = await this.store.load(jobId);
|
|
82
86
|
const previousState = job.state;
|
|
83
87
|
const prState = await this.store.pullRequestState?.() ?? 'OPEN';
|
|
88
|
+
const headSha = prState === 'OPEN' ? await this.currentHeadSha(job) : null;
|
|
89
|
+
if (headSha)
|
|
90
|
+
job.headSha = headSha;
|
|
91
|
+
await this.invalidateStaleReview(job, headSha);
|
|
84
92
|
const now = (this.options.now ?? new Date()).getTime();
|
|
85
93
|
if (prState !== 'OPEN') {
|
|
86
94
|
const reason = prState === 'MERGED' ? 'Pull request merged' : 'Pull request closed';
|
|
@@ -136,12 +144,76 @@ export class Reconciler {
|
|
|
136
144
|
}
|
|
137
145
|
if (finalState.state !== scheduled.state || finalState.updatedAt !== scheduled.updatedAt)
|
|
138
146
|
await this.store.saveJob(finalState);
|
|
147
|
+
await this.ensureFinalReview(finalState, prState, headSha);
|
|
139
148
|
return this.store.load(jobId);
|
|
140
149
|
}
|
|
141
150
|
finally {
|
|
142
151
|
this.reconciling = false;
|
|
143
152
|
}
|
|
144
153
|
}
|
|
154
|
+
async currentHeadSha(job) { return await this.store.headSha?.() ?? job.headSha ?? job.review?.headSha ?? null; }
|
|
155
|
+
async invalidateStaleReview(job, headSha) {
|
|
156
|
+
if (!headSha || !job.review || job.review.headSha === headSha || job.review.outcome === 'stale')
|
|
157
|
+
return;
|
|
158
|
+
job.review = { ...job.review, outcome: 'stale', updatedAt: new Date().toISOString() };
|
|
159
|
+
const staleTask = job.review.taskId ? job.tasks.find(task => task.id === job.review?.taskId) : undefined;
|
|
160
|
+
if (staleTask && ['QUEUED', 'READY', 'RUNNING', 'WAITING'].includes(staleTask.state)) {
|
|
161
|
+
this.live.get(staleTask.id)?.execution.cancel();
|
|
162
|
+
transitionTask(staleTask, 'CANCELLED');
|
|
163
|
+
staleTask.error = 'Review evidence became stale after the PR head changed';
|
|
164
|
+
staleTask.leaseOwner = null;
|
|
165
|
+
staleTask.leaseExpiresAt = null;
|
|
166
|
+
await this.store.saveTask(staleTask);
|
|
167
|
+
}
|
|
168
|
+
await this.store.saveJob(job);
|
|
169
|
+
}
|
|
170
|
+
async ensureFinalReview(job, prState, headSha) {
|
|
171
|
+
if (prState !== 'OPEN' || job.state !== 'COMPLETED' || !headSha)
|
|
172
|
+
return;
|
|
173
|
+
if (!this.preReviewGatesPass(job))
|
|
174
|
+
return;
|
|
175
|
+
const review = job.review;
|
|
176
|
+
if (review?.headSha === headSha && ['started', 'approved', 'changes_required', 'blocked'].includes(review.outcome))
|
|
177
|
+
return;
|
|
178
|
+
const reviewTasks = job.tasks.filter(task => task.agentName === 'reviewer' && task.reviewHeadSha === headSha);
|
|
179
|
+
if (reviewTasks.some(task => ['QUEUED', 'READY', 'RUNNING', 'WAITING'].includes(task.state)))
|
|
180
|
+
return;
|
|
181
|
+
if (reviewTasks.some(task => task.state === 'SUCCEEDED' && review?.headSha === headSha && review.outcome === 'approved'))
|
|
182
|
+
return;
|
|
183
|
+
const id = `final-review-${headSha.slice(0, 12)}`;
|
|
184
|
+
const now = new Date().toISOString();
|
|
185
|
+
const task = { jobId: job.id, id, kind: 'work', priority: job.priority, projectName: job.title, agentName: 'reviewer', parentTaskId: null, dependencies: job.tasks.filter(item => item.agentName !== 'reviewer').map(item => item.id), capabilities: ['review'], adapter: this.options.reviewer?.adapter ?? 'codex', input: `Review PR #${job.prNumber} at head SHA ${headSha}. Objective: ${job.objective ?? job.description ?? job.title}. First verify the deterministic pre-review gates have passed, then inspect the completed work. Emit review.started, then exactly one of review.approved, review.changes_required, or review.blocked with data.review.headSha set to ${headSha}. Do not modify code and do not merge.`, output: { kind: 'task_pr' }, routing: this.options.reviewer?.routing, continuation: null, continuationDeliveredAt: null, reviewHeadSha: headSha, state: 'QUEUED', attempt: 0, maxAttempts: this.options.reviewer?.maxAttempts ?? 2, leaseOwner: null, leaseExpiresAt: null, executionId: null, threadId: null, result: null, plannerResult: null, error: null, timeoutMs: this.options.reviewer?.timeoutMs ?? 300000, createdAt: now, updatedAt: now };
|
|
186
|
+
await this.store.appendTask(task);
|
|
187
|
+
job.review = { outcome: 'pending', headSha, taskId: id, evidence: null, updatedAt: now };
|
|
188
|
+
job.state = 'RUNNING';
|
|
189
|
+
job.updatedAt = now;
|
|
190
|
+
await this.store.saveJob(job);
|
|
191
|
+
await this.emit(eventFor(job.id, id, null, 'review.created', 'queued', `Final review task ${id} created for ${headSha}`, 'orchestrator', { headSha, agentName: 'reviewer' }));
|
|
192
|
+
const ready = schedule(await this.store.load(job.id));
|
|
193
|
+
await this.store.saveJob(ready);
|
|
194
|
+
const reviewTask = ready.tasks.find(item => item.id === id && item.state === 'READY');
|
|
195
|
+
if (reviewTask && (this.options.maxConcurrent ?? 4) > this.live.size + this.plannerLive.size)
|
|
196
|
+
await this.launch(ready, reviewTask);
|
|
197
|
+
}
|
|
198
|
+
preReviewGatesPass(job) {
|
|
199
|
+
return job.tasks.filter(task => task.agentName !== 'reviewer').every(task => task.state === 'SUCCEEDED');
|
|
200
|
+
}
|
|
201
|
+
async applyReviewEvent(event) {
|
|
202
|
+
const job = await this.store.load(event.job_id);
|
|
203
|
+
const task = job.tasks.find(item => item.id === event.task_id && item.agentName === 'reviewer');
|
|
204
|
+
const reviewData = event.data?.review && typeof event.data.review === 'object' ? event.data.review : undefined;
|
|
205
|
+
const headSha = typeof reviewData?.headSha === 'string' ? reviewData.headSha : typeof event.data?.headSha === 'string' ? event.data.headSha : task?.reviewHeadSha;
|
|
206
|
+
if (!task || !headSha || task.reviewHeadSha !== headSha)
|
|
207
|
+
return;
|
|
208
|
+
const outcome = event.type === 'review.started' ? 'started' : event.type === 'review.approved' ? 'approved' : event.type === 'review.changes_required' ? 'changes_required' : 'blocked';
|
|
209
|
+
const currentHeadSha = await this.store.headSha?.();
|
|
210
|
+
if ((currentHeadSha && currentHeadSha !== headSha) || (job.headSha && job.headSha !== headSha))
|
|
211
|
+
return;
|
|
212
|
+
const evidence = { summary: event.message || `Review ${outcome}`, data: { ...(event.data ?? {}), review: reviewData ?? { headSha }, headSha, outcome } };
|
|
213
|
+
job.review = { outcome, headSha, taskId: task.id, evidence, updatedAt: event.timestamp || new Date().toISOString() };
|
|
214
|
+
job.updatedAt = job.review.updatedAt;
|
|
215
|
+
await this.store.saveJob(job);
|
|
216
|
+
}
|
|
145
217
|
async recoverLostExecutions(job, now) {
|
|
146
218
|
for (const task of job.tasks.filter(item => item.state === 'RUNNING')) {
|
|
147
219
|
const expired = task.leaseExpiresAt !== null && new Date(task.leaseExpiresAt).getTime() <= now;
|
package/dist/store.js
CHANGED
|
@@ -65,6 +65,7 @@ export class InMemoryStore {
|
|
|
65
65
|
this.values.set(key, renderTaskComment(task));
|
|
66
66
|
}
|
|
67
67
|
async saveTask(task) { this.values.set(`${TASK_MARKER}:${task.jobId ?? ''}:${task.id}`, renderTaskComment(task)); }
|
|
68
|
+
async headSha() { const jobs = [...this.values.values()].map(parseJob).filter((x) => x !== null); return jobs[0]?.headSha ?? jobs[0]?.review?.headSha ?? null; }
|
|
68
69
|
async listAgents() { return [...this.values.values()].map(parseAgent).filter((x) => x !== null); }
|
|
69
70
|
async saveAgent(agent) { this.values.set(`${AGENT_MARKER}:${agent.id}`, renderAgentComment(agent)); }
|
|
70
71
|
async comments() { return [...this.values.values()].map((body, i) => ({ id: i + 1, body, updatedAt: new Date().toISOString(), authorLogin: 'local' })); }
|
|
@@ -460,6 +461,13 @@ export class GitHubStore {
|
|
|
460
461
|
return 'MERGED';
|
|
461
462
|
return String(pr.state ?? '').toUpperCase() === 'CLOSED' ? 'CLOSED' : 'OPEN';
|
|
462
463
|
}
|
|
464
|
+
async headSha() {
|
|
465
|
+
const { owner, name } = this.repoParts();
|
|
466
|
+
const data = await this.graphql('query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){headRefOid}}}', { owner, name, number: this.prNumber });
|
|
467
|
+
const repository = data.repository;
|
|
468
|
+
const pr = repository?.pullRequest;
|
|
469
|
+
return typeof pr?.headRefOid === 'string' ? pr.headRefOid : null;
|
|
470
|
+
}
|
|
463
471
|
async load(jobId) {
|
|
464
472
|
const comments = (await this.list()).filter(comment => this.isTrusted(comment));
|
|
465
473
|
const jobs = comments.map(x => parseJob(x.body)).filter((x) => x !== null && typeof x.id === 'string' && x.id.length > 0);
|