agents-relay 1.0.17 → 1.0.19
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 +16 -3
- package/dist/pool.js +1 -1
- package/dist/reconciler.js +72 -0
- package/dist/relayd.js +8 -2
- 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;}
|
|
@@ -353,6 +354,12 @@ export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobI
|
|
|
353
354
|
throw new Error(`Managed job ${requestedJobId ?? ''} not found`);
|
|
354
355
|
return { id: jobId, json: JSON.stringify(await store.load(jobId)) };
|
|
355
356
|
};
|
|
357
|
+
const broadcastSnapshots = async (jobIds = []) => {
|
|
358
|
+
const snapshots = await Promise.all((jobIds.length > 0 ? jobIds : [undefined]).map(requestedJobId => snapshot(requestedJobId)));
|
|
359
|
+
for (const value of snapshots)
|
|
360
|
+
for (const client of clients)
|
|
361
|
+
client.write(`data: ${value.json}\n\n`);
|
|
362
|
+
};
|
|
356
363
|
const usage = async () => {
|
|
357
364
|
if (!usageRegistry)
|
|
358
365
|
throw new Error('Account usage telemetry is unavailable');
|
|
@@ -393,7 +400,13 @@ export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobI
|
|
|
393
400
|
}
|
|
394
401
|
res.writeHead(202);
|
|
395
402
|
res.end('Accepted');
|
|
396
|
-
queueMicrotask(() => {
|
|
403
|
+
queueMicrotask(() => {
|
|
404
|
+
void (webhook.refresh ? webhook.refresh(event) : Promise.resolve([]))
|
|
405
|
+
.then(jobIds => broadcastSnapshots(jobIds))
|
|
406
|
+
.catch(error => process.stderr.write(`warning: webhook dashboard snapshot unavailable: ${error instanceof Error ? error.message : String(error)}\n`))
|
|
407
|
+
.then(() => webhook.wake(event))
|
|
408
|
+
.catch(error => process.stderr.write(`warning: webhook wake unavailable: ${error instanceof Error ? error.message : String(error)}\n`));
|
|
409
|
+
});
|
|
397
410
|
return;
|
|
398
411
|
}
|
|
399
412
|
if (new URL(req.url ?? '/', 'http://127.0.0.1').pathname === '/api/jobs') {
|
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/relayd.js
CHANGED
|
@@ -121,16 +121,22 @@ export async function runDaemon(argv) {
|
|
|
121
121
|
const webhook = secret ? {
|
|
122
122
|
secret,
|
|
123
123
|
repositories: new Set(repositories),
|
|
124
|
+
refresh: async (event) => {
|
|
125
|
+
if (!event.repository || !event.pullRequest)
|
|
126
|
+
return [];
|
|
127
|
+
const refreshed = await refreshPullRequest(event.repository, event.pullRequest);
|
|
128
|
+
return refreshed.map(item => item.job.id);
|
|
129
|
+
},
|
|
124
130
|
wake: async (event) => {
|
|
125
131
|
if (!event.repository || !event.pullRequest)
|
|
126
132
|
return;
|
|
127
133
|
try {
|
|
128
|
-
const refreshed =
|
|
134
|
+
const refreshed = cachedOverviews.filter(item => item.job.repository === event.repository && item.job.prNumber === event.pullRequest);
|
|
129
135
|
for (const item of refreshed)
|
|
130
136
|
await pool.webhookWake(bus, item.job, event.deliveryId, event.event, event.action);
|
|
131
137
|
}
|
|
132
138
|
catch (error) {
|
|
133
|
-
process.stderr.write(`warning: webhook
|
|
139
|
+
process.stderr.write(`warning: webhook wake unavailable: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
134
140
|
}
|
|
135
141
|
}
|
|
136
142
|
} : undefined;
|
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);
|