agents-relay 1.0.168 → 1.0.170
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/cli-help.js +1 -9
- package/dist/cli.js +0 -24
- package/dist/dashboard-html.js +45 -1
- package/dist/scheduled-jobs.js +13 -158
- package/dist/scheduler.js +0 -19
- package/package.json +1 -1
- package/skills/agents-relay/contracts/task-job-contract.md +2 -2
package/dist/cli-help.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export const usage = 'agents-relay <job|task|plan|blocker|context|init|record|agent-register|agent-discover|status|reconcile|
|
|
1
|
+
export const usage = 'agents-relay <job|task|plan|blocker|context|init|record|agent-register|agent-discover|status|reconcile|serve> [options]';
|
|
2
2
|
const help = {
|
|
3
3
|
root: `Agents Relay coordinates durable asynchronous agent jobs through GitHub pull requests.
|
|
4
4
|
|
|
@@ -17,7 +17,6 @@ Commands:
|
|
|
17
17
|
agent-discover Find registered agents matching hard filters
|
|
18
18
|
status Show live aggregate runtime status from agents-relayd
|
|
19
19
|
reconcile Run reconciliation once
|
|
20
|
-
tick Run the indexed autonomous scheduler heartbeat
|
|
21
20
|
serve Run the dashboard and reconciliation service
|
|
22
21
|
|
|
23
22
|
Run 'npx agents-relay <command> --help' for command options and examples.
|
|
@@ -184,13 +183,6 @@ Managed GitHub jobs are reconciled by agents-relayd. The CLI publishes a job.wak
|
|
|
184
183
|
|
|
185
184
|
Example:
|
|
186
185
|
npx agents-relay reconcile --repo OWNER/REPO --pr 12 --id job-1`,
|
|
187
|
-
tick: `Process autonomous jobs indexed into current/recent GitHub next-run buckets.
|
|
188
|
-
|
|
189
|
-
Required: --repo OWNER/REPO.
|
|
190
|
-
Common: --lookback-buckets N (default 3).
|
|
191
|
-
|
|
192
|
-
Example:
|
|
193
|
-
npx agents-relay tick --repo OWNER/REPO`,
|
|
194
186
|
init: `Initialize a new job. GitHub mode requires --repo and --pr; local mode requires explicit --file.
|
|
195
187
|
|
|
196
188
|
Common: --id ID, --title TEXT, --objective TEXT, --mode fixed|autonomous, --priority P0|P1|P2|P3.
|
package/dist/cli.js
CHANGED
|
@@ -18,7 +18,6 @@ import { decisionMode, LayaCliDecisionEngine } from './decision.js';
|
|
|
18
18
|
import { discoverAgents } from './registry.js';
|
|
19
19
|
import { AgentObjectivePlanner, CommandObjectivePlanner } from './planner.js';
|
|
20
20
|
import { extendRetryBudget, transitionJobEvent, transitionTaskEvent } from './scheduler.js';
|
|
21
|
-
import { dueBuckets, processDueScheduledJobs, syncScheduleLabels } from './scheduled-jobs.js';
|
|
22
21
|
import { resolveExecutionContext } from './execution-context.js';
|
|
23
22
|
import { defaultWorkspaceRoot, resolveManagedProjectPath, resolveRepositoryProjectPath } from './workspace.js';
|
|
24
23
|
import { runBlockerCommand } from './cli-blocker.js';
|
|
@@ -35,8 +34,6 @@ export async function ensureManagedGitHubJob(client, repositoryName, trustedAuth
|
|
|
35
34
|
const store = new GitHubStore(client, repositoryName, pr.number, trustedAuthors);
|
|
36
35
|
try {
|
|
37
36
|
const existing = await store.load(options.id);
|
|
38
|
-
if (existing.schedule)
|
|
39
|
-
await syncScheduleLabels(client, repositoryName, existing);
|
|
40
37
|
return { job: existing, pr };
|
|
41
38
|
}
|
|
42
39
|
catch (error) {
|
|
@@ -66,8 +63,6 @@ export async function ensureManagedGitHubJob(client, repositoryName, trustedAuth
|
|
|
66
63
|
if (options.schedule)
|
|
67
64
|
job.schedule = options.schedule;
|
|
68
65
|
await store.saveJob(job);
|
|
69
|
-
if (job.schedule)
|
|
70
|
-
await syncScheduleLabels(client, repositoryName, job);
|
|
71
66
|
return { job: await store.load(options.id), pr };
|
|
72
67
|
}
|
|
73
68
|
async function workerTaskFromArgs(job, args, now) {
|
|
@@ -450,8 +445,6 @@ async function runJobCommand(action, args) {
|
|
|
450
445
|
}
|
|
451
446
|
job.updatedAt = new Date().toISOString();
|
|
452
447
|
await store.saveJob(job);
|
|
453
|
-
if (hasArg(args, '--schedule') && job.schedule)
|
|
454
|
-
await syncScheduleLabels(auth.client, repo, job);
|
|
455
448
|
console.log(JSON.stringify(await store.load(id), null, 2));
|
|
456
449
|
}
|
|
457
450
|
export function runtimePlanner(args) { const plannerCommand = arg([...args], '--planner-command'); const modelRuntime = arg([...args], '--codex', 'codex'); return plannerCommand ? new CommandObjectivePlanner(plannerCommand) : new AgentObjectivePlanner(modelRuntime); }
|
|
@@ -629,23 +622,6 @@ async function main() {
|
|
|
629
622
|
console.log(JSON.stringify(await response.json(), null, 2));
|
|
630
623
|
return;
|
|
631
624
|
}
|
|
632
|
-
if (command === 'tick') {
|
|
633
|
-
const repo = repository(args);
|
|
634
|
-
const auth = await githubAuthContext(args);
|
|
635
|
-
const nowValue = arg(args, '--now');
|
|
636
|
-
const now = nowValue ? new Date(nowValue) : new Date();
|
|
637
|
-
if (Number.isNaN(now.getTime()))
|
|
638
|
-
throw new Error('--now must be a valid ISO timestamp');
|
|
639
|
-
const results = await processDueScheduledJobs({
|
|
640
|
-
client: auth.client,
|
|
641
|
-
repository: repo,
|
|
642
|
-
trustedAuthors: auth.trustedAuthors,
|
|
643
|
-
buckets: dueBuckets(now, integerArg(args, '--lookback-buckets', 3)),
|
|
644
|
-
now,
|
|
645
|
-
});
|
|
646
|
-
console.log(JSON.stringify({ repository: repo, processed: results.length, results }, null, 2));
|
|
647
|
-
return;
|
|
648
|
-
}
|
|
649
625
|
const loaded = await storeFor(args);
|
|
650
626
|
const { store, job, localFile } = loaded;
|
|
651
627
|
if (command === 'plan') {
|
package/dist/dashboard-html.js
CHANGED
|
@@ -70,6 +70,14 @@ export const dashboardHtml = `<!doctype html>
|
|
|
70
70
|
.detail-label { margin:0; color:var(--muted); font-size:.66rem; text-transform:uppercase; letter-spacing:.04em; }
|
|
71
71
|
.detail-value { margin:.12rem 0 0; font-weight:650; overflow-wrap:anywhere; }
|
|
72
72
|
.section-title { margin:.8rem 0 .35rem; color:var(--muted); font-size:.72rem; letter-spacing:.05em; text-transform:uppercase; }
|
|
73
|
+
.chat-shortcuts { margin-top:.7rem; padding-top:.65rem; border-top:1px solid var(--border); }
|
|
74
|
+
.chat-shortcut-tabs,.chat-shortcut-list { display:flex; gap:.35rem; flex-wrap:wrap; }
|
|
75
|
+
.chat-shortcut-tab,.chat-shortcut-button { border:1px solid var(--border); border-radius:.35rem; background:var(--surface); color:var(--text); padding:.3rem .5rem; font:inherit; font-size:.72rem; cursor:pointer; }
|
|
76
|
+
.chat-shortcut-tab[aria-selected="true"] { border-color:var(--accent); background:var(--accent2); color:var(--accent); font-weight:750; }
|
|
77
|
+
.chat-shortcut-button { color:var(--accent); font-weight:700; }
|
|
78
|
+
.chat-shortcut-editor { display:grid; gap:.4rem; margin-top:.45rem; }
|
|
79
|
+
.chat-shortcut-editor textarea { width:100%; min-height:7rem; resize:vertical; border:1px solid var(--border); border-radius:.4rem; background:var(--surface2); color:var(--text); padding:.55rem; font:12px/1.45 ui-monospace,SFMono-Regular,Menlo,monospace; }
|
|
80
|
+
.chat-shortcut-help { margin:0; color:var(--muted); font-size:.7rem; }
|
|
73
81
|
ul.tree { display:grid; gap:.25rem; min-width:0; max-width:100%; }
|
|
74
82
|
ul.tree li { min-width:0; max-width:100%; }
|
|
75
83
|
ul.tree ul { width:calc(100% - .6rem); min-width:0; max-width:calc(100% - .6rem); list-style:none; margin:.2rem 0 .1rem .6rem; padding-left:.6rem; border-left:1px solid var(--border); }
|
|
@@ -320,6 +328,42 @@ function syncFilters(){
|
|
|
320
328
|
jobSearch.value=searchQuery;
|
|
321
329
|
}
|
|
322
330
|
function repoName(repository){if(typeof repository!=='string'||!repository)return 'unknown-repo';const parts=repository.split('/').filter(Boolean);return parts.at(-1)||repository;}
|
|
331
|
+
|
|
332
|
+
const CHAT_SHORTCUTS_KEY='agents-relay:chatgpt-shortcuts';
|
|
333
|
+
const DEFAULT_CHAT_SHORTCUTS=[
|
|
334
|
+
'Why blocked?: %1: why is it blocked? Find the root cause and move it forward.',
|
|
335
|
+
'Move forward: %1: check the current state and move it forward.',
|
|
336
|
+
'What is wrong?: %1: check the current state and explain what is wrong.'
|
|
337
|
+
].join('\\n');
|
|
338
|
+
function chatShortcutText(){return localStorage.getItem(CHAT_SHORTCUTS_KEY)||DEFAULT_CHAT_SHORTCUTS;}
|
|
339
|
+
function parseChatShortcuts(source){return String(source||'').split(/\\r?\\n/).map(line=>line.trim()).filter(Boolean).map(line=>{const split=line.indexOf(':');return split>0?{name:line.slice(0,split).trim(),prompt:line.slice(split+1).trim()}:null;}).filter(item=>item&&item.name&&item.prompt);}
|
|
340
|
+
function jobChatRef(job){return repoName(job?.repository)+'/pr#'+value(job?.prNumber);}
|
|
341
|
+
function chatShortcutUrl(prompt){return 'https://chatgpt.com/?prompt='+encodeURIComponent(prompt);}
|
|
342
|
+
function renderChatShortcuts(job){
|
|
343
|
+
const root=element('section','chat-shortcuts');
|
|
344
|
+
const tabs=element('div','chat-shortcut-tabs');
|
|
345
|
+
const shortcutsTab=element('button','chat-shortcut-tab',text('Shortcuts'));shortcutsTab.type='button';shortcutsTab.setAttribute('aria-selected','true');
|
|
346
|
+
const editTab=element('button','chat-shortcut-tab',text('Edit'));editTab.type='button';editTab.setAttribute('aria-selected','false');
|
|
347
|
+
tabs.append(shortcutsTab,editTab);
|
|
348
|
+
const body=element('div');
|
|
349
|
+
function showShortcuts(){
|
|
350
|
+
shortcutsTab.setAttribute('aria-selected','true');editTab.setAttribute('aria-selected','false');
|
|
351
|
+
const list=element('div','chat-shortcut-list');
|
|
352
|
+
for(const item of parseChatShortcuts(chatShortcutText())){const button=element('button','chat-shortcut-button',text(item.name));button.type='button';button.addEventListener('click',()=>{const prompt=item.prompt.replaceAll('%1',jobChatRef(job));window.open(chatShortcutUrl(prompt),'_blank','noopener');});list.append(button);}
|
|
353
|
+
body.replaceChildren(list.children.length?list:element('p','empty',text('No shortcuts configured.')));
|
|
354
|
+
}
|
|
355
|
+
function showEditor(){
|
|
356
|
+
shortcutsTab.setAttribute('aria-selected','false');editTab.setAttribute('aria-selected','true');
|
|
357
|
+
const editor=element('div','chat-shortcut-editor');
|
|
358
|
+
const help=element('p','chat-shortcut-help',text('One per line: <name>: <prompt>. Use %1 for '+jobChatRef(job)+'.'));
|
|
359
|
+
const textarea=document.createElement('textarea');textarea.value=chatShortcutText();textarea.setAttribute('aria-label','ChatGPT shortcut entries');
|
|
360
|
+
const save=element('button','refresh-button',text('Save'));save.type='button';save.addEventListener('click',()=>{const value=textarea.value.trim();if(value)localStorage.setItem(CHAT_SHORTCUTS_KEY,value);else localStorage.removeItem(CHAT_SHORTCUTS_KEY);showShortcuts();});
|
|
361
|
+
editor.append(help,textarea,save);body.replaceChildren(editor);
|
|
362
|
+
}
|
|
363
|
+
shortcutsTab.addEventListener('click',showShortcuts);editTab.addEventListener('click',showEditor);
|
|
364
|
+
root.append(tabs,body);showShortcuts();return root;
|
|
365
|
+
}
|
|
366
|
+
|
|
323
367
|
function renderJobList(){jobList.replaceChildren();const visible=filteredJobs();if(!jobs.length){jobList.append(element('li','empty',text('No durable jobs discovered.')));return;}if(!visible.length){jobList.append(element('li','empty',text('No jobs match the selected repo.')));return;}for(const job of visible){const row=element('div','job-row'+(isCompleteUnmerged(job)?' complete-unmerged':''));row.setAttribute('aria-current',String(job.id===selectedJob));row.addEventListener('click',()=>selectJob(job.id));const content=element('div','job-content');const select=document.createElement('button');select.type='button';select.className='job-button';select.setAttribute('aria-current',String(job.id===selectedJob));select.title=job.title||job.id;select.addEventListener('click',event=>{event.stopPropagation();selectJob(job.id);});select.append(element('span','job-title',text(repoName(job.repository)+'/PR#'+value(job.prNumber)+': '+value(job.title||job.id))));const states=element('span','job-status-line');states.append(element('span','badge '+value(job.state),text(value(job.state))),element('span','badge '+value(job.runtimeStatus||'IDLE'),text(value(job.runtimeStatus||'IDLE'))));if(job.executionMode==='autonomous')states.append(element('span','badge flag-autonomous',text('AUTONOMOUS')));if(job.reviewEnabled!==false&&job.githubState!=='MERGED')states.append(element('span','badge flag-review',text('REVIEW REQUIRED')));if(job.githubState==='MERGED'&&job.publication?.version)states.append(element('span','badge SUCCEEDED',text('v'+job.publication.version)));if(isCompleteUnmerged(job))states.append(element('span','badge MERGE_PENDING',text('MERGE PENDING')));const primary=element('div','job-primary');primary.append(states);const lastLine=element('div','job-last-line');lastLine.append(element('span','subtle',text(compactCounts(counts(job.tasks)))),element('span','subtle',text(relativeTime(job.updatedAt))));content.append(select,primary,lastLine);row.append(content);jobList.append(element('li','',row));}}
|
|
324
368
|
function taskTagDisplay(job,task){const display=task.tag&&job?.tag?.values?.[task.tag];return typeof display==='string'&&display?display:null;}
|
|
325
369
|
function taskSubtreeComplete(task,children){return ['SUCCEEDED','CANCELLED'].includes(task.state)&&(children.get(task.id)||[]).every(child=>taskSubtreeComplete(child,children));}
|
|
@@ -335,7 +379,7 @@ function taskStateField(task){const box=element('div','detail-group');box.append
|
|
|
335
379
|
function manualOverrideField(task){const rows=Array.isArray(task.manualStateOverrides)?task.manualStateOverrides:[];const last=rows.at(-1);return field('Manual override',last?last.from+' → '+last.to+' · '+relativeTime(last.at):'—');}
|
|
336
380
|
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'),taskStateField(task),manualOverrideField(task),field('Planning decision',task.planningDecisionId||'—'),field('Planning source',task.planningSource||'—'),field('Planning round',task.planningRound===undefined?'—':String(task.planningRound)),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'),taskCommentField(task),chatField(task));const job=selected();if(task.reviewHeadSha&&job)groups.append(commitField('HEAD commit',job,task.reviewHeadSha));const graph=agentGraphField(task);if(graph)groups.append(graph);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);}
|
|
337
381
|
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();}
|
|
338
|
-
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('PR state',job.githubState||'UNKNOWN'),jobStateField(job),field('Runtime status',job.runtimeStatus||'IDLE'),field('Execution mode',job.executionMode||'fixed'),field('Cadence',cadence(job)),field('Review',reviewStatus(job)),commitField('Review head SHA',job,job.review?.headSha||job.headSha||''),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();}
|
|
382
|
+
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('PR state',job.githubState||'UNKNOWN'),jobStateField(job),field('Runtime status',job.runtimeStatus||'IDLE'),field('Execution mode',job.executionMode||'fixed'),field('Cadence',cadence(job)),field('Review',reviewStatus(job)),commitField('Review head SHA',job,job.review?.headSha||job.headSha||''),prField(job),field('Repository',job.repository||'—'),field('Last activity',relativeTime(job.updatedAt)),field('Task states',compactCounts(job.tasks)));jobDetail.append(summary,renderChatShortcuts(job));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();}
|
|
339
383
|
function renderAll(){jobs.sort(compareJobs);syncFilters();renderJobList();renderSelectedJob();syncMobileJobsView();}
|
|
340
384
|
function isCompleteUnmerged(job){return job.runtimeStatus==='MERGE_PENDING';}
|
|
341
385
|
function isTerminalJob(job){return ['COMPLETED','CANCELLED'].includes(job.state)||['MERGED','CLOSED'].includes(job.githubState);}
|
package/dist/scheduled-jobs.js
CHANGED
|
@@ -1,134 +1,5 @@
|
|
|
1
|
-
import { GitHubStore
|
|
2
|
-
import {
|
|
3
|
-
export const AUTONOMOUS_LABEL = 'agents-relay:autonomous';
|
|
4
|
-
function searchPath(query, page) {
|
|
5
|
-
return `search/issues?q=${encodeURIComponent(query)}&sort=created&order=asc&per_page=100&page=${page}`;
|
|
6
|
-
}
|
|
7
|
-
export function dueBuckets(now, lookbackBuckets) {
|
|
8
|
-
if (!Number.isInteger(lookbackBuckets) || lookbackBuckets < 1 || lookbackBuckets > 36)
|
|
9
|
-
throw new Error('--lookback-buckets must be an integer from 1 to 36');
|
|
10
|
-
const result = [];
|
|
11
|
-
for (let age = lookbackBuckets - 1; age >= 0; age -= 1) {
|
|
12
|
-
result.push(scheduleBucket(new Date(bucketStart(now).getTime() - age * 600_000)));
|
|
13
|
-
}
|
|
14
|
-
return result;
|
|
15
|
-
}
|
|
16
|
-
export function indexedDueQuery(repository, bucket) {
|
|
17
|
-
const label = runBucketLabel(bucket);
|
|
18
|
-
return `repo:${repository} is:open is:pr label:"${AUTONOMOUS_LABEL}" label:"${label}"`;
|
|
19
|
-
}
|
|
20
|
-
async function issueLabels(client, repository, prNumber) {
|
|
21
|
-
const labels = [];
|
|
22
|
-
let page = 1;
|
|
23
|
-
for (;;) {
|
|
24
|
-
const response = await client.request(`repos/${repository}/issues/${prNumber}/labels?per_page=100&page=${page}`);
|
|
25
|
-
const items = Array.isArray(response) ? response : response.items;
|
|
26
|
-
if (!Array.isArray(items))
|
|
27
|
-
throw new Error(`GitHub label response for PR #${prNumber} was invalid`);
|
|
28
|
-
for (const item of items) {
|
|
29
|
-
const name = item.name;
|
|
30
|
-
if (typeof name === 'string')
|
|
31
|
-
labels.push(name);
|
|
32
|
-
}
|
|
33
|
-
if (items.length < 100)
|
|
34
|
-
return labels;
|
|
35
|
-
page += 1;
|
|
36
|
-
}
|
|
37
|
-
}
|
|
38
|
-
async function addLabel(client, repository, prNumber, label) {
|
|
39
|
-
await client.request(`repos/${repository}/issues/${prNumber}/labels`, {
|
|
40
|
-
method: 'POST',
|
|
41
|
-
body: JSON.stringify({ labels: [label] }),
|
|
42
|
-
});
|
|
43
|
-
}
|
|
44
|
-
async function removeLabel(client, repository, prNumber, label) {
|
|
45
|
-
await client.request(`repos/${repository}/issues/${prNumber}/labels/${encodeURIComponent(label)}`, { method: 'DELETE' });
|
|
46
|
-
}
|
|
47
|
-
async function ensureRepositoryLabel(client, repository, label, color) {
|
|
48
|
-
try {
|
|
49
|
-
await client.request(`repos/${repository}/labels/${encodeURIComponent(label)}`);
|
|
50
|
-
}
|
|
51
|
-
catch (error) {
|
|
52
|
-
const message = error instanceof Error ? error.message : String(error);
|
|
53
|
-
if (!/404|Not Found/i.test(message))
|
|
54
|
-
throw error;
|
|
55
|
-
await client.request(`repos/${repository}/labels`, {
|
|
56
|
-
method: 'POST',
|
|
57
|
-
body: JSON.stringify({ name: label, color }),
|
|
58
|
-
});
|
|
59
|
-
}
|
|
60
|
-
}
|
|
61
|
-
export async function syncScheduleLabels(client, repository, job) {
|
|
62
|
-
const current = await issueLabels(client, repository, job.prNumber);
|
|
63
|
-
const target = job.schedule ? runBucketLabel(scheduleBucket(new Date(job.schedule.nextRunAt))) : null;
|
|
64
|
-
const expected = new Set(job.schedule ? [AUTONOMOUS_LABEL, target] : []);
|
|
65
|
-
if (job.schedule || current.includes(AUTONOMOUS_LABEL))
|
|
66
|
-
await ensureRepositoryLabel(client, repository, AUTONOMOUS_LABEL, '0E8A16');
|
|
67
|
-
if (target !== null)
|
|
68
|
-
await ensureRepositoryLabel(client, repository, target, 'D4C5F9');
|
|
69
|
-
for (const label of current) {
|
|
70
|
-
const isSchedulerLabel = label === AUTONOMOUS_LABEL || parseRunBucketLabel(label) !== null || /^agents-relay:next-run:\d{8}T\d{4}Z$/.test(label);
|
|
71
|
-
if (isSchedulerLabel && !expected.has(label))
|
|
72
|
-
await removeLabel(client, repository, job.prNumber, label);
|
|
73
|
-
}
|
|
74
|
-
if (job.schedule) {
|
|
75
|
-
if (!current.includes(AUTONOMOUS_LABEL))
|
|
76
|
-
await addLabel(client, repository, job.prNumber, AUTONOMOUS_LABEL);
|
|
77
|
-
if (target !== null && !current.includes(target))
|
|
78
|
-
await addLabel(client, repository, job.prNumber, target);
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
const SCHEDULE_WAKE_MARKER = '<!-- agents-relay:schedule-wake:v1 -->';
|
|
82
|
-
async function pulseScheduleWake(client, repository, job) {
|
|
83
|
-
const body = `${SCHEDULE_WAKE_MARKER}
|
|
84
|
-
Scheduled autonomous wake for \`${job.id}\` at \`${job.schedule?.lastScheduledSlot ?? 'unknown'}\`.`;
|
|
85
|
-
let page = 1;
|
|
86
|
-
for (;;) {
|
|
87
|
-
const response = await client.request(`repos/${repository}/issues/${job.prNumber}/comments?per_page=100&page=${page}`);
|
|
88
|
-
const items = Array.isArray(response) ? response : [];
|
|
89
|
-
const existing = items.find(item => typeof item.body === 'string' && String(item.body).includes(SCHEDULE_WAKE_MARKER));
|
|
90
|
-
if (existing && Number.isInteger(Number(existing.id))) {
|
|
91
|
-
await client.request(`repos/${repository}/issues/comments/${Number(existing.id)}`, { method: 'PATCH', body: JSON.stringify({ body }) });
|
|
92
|
-
return;
|
|
93
|
-
}
|
|
94
|
-
if (items.length < 100)
|
|
95
|
-
break;
|
|
96
|
-
page += 1;
|
|
97
|
-
}
|
|
98
|
-
await client.request(`repos/${repository}/issues/${job.prNumber}/comments`, { method: 'POST', body: JSON.stringify({ body }) });
|
|
99
|
-
}
|
|
100
|
-
export async function listIndexedScheduledJobs(client, repository, trustedAuthors, buckets) {
|
|
101
|
-
const found = new Map();
|
|
102
|
-
const seenPrs = new Set();
|
|
103
|
-
for (const bucket of buckets) {
|
|
104
|
-
const query = indexedDueQuery(repository, bucket);
|
|
105
|
-
for (let page = 1; page <= 10; page += 1) {
|
|
106
|
-
const response = await client.request(searchPath(query, page));
|
|
107
|
-
const items = Array.isArray(response?.items) ? response.items : [];
|
|
108
|
-
for (const item of items) {
|
|
109
|
-
const prNumber = Number(item.number);
|
|
110
|
-
if (!Number.isInteger(prNumber) || prNumber <= 0 || seenPrs.has(prNumber))
|
|
111
|
-
continue;
|
|
112
|
-
seenPrs.add(prNumber);
|
|
113
|
-
const overviews = await loadManagedGitHubPullRequestGraphql(client, repository, prNumber, trustedAuthors).catch((error) => {
|
|
114
|
-
if (error instanceof Error && error.message.includes('has no durable'))
|
|
115
|
-
return [];
|
|
116
|
-
throw error;
|
|
117
|
-
});
|
|
118
|
-
const job = overviews.find(overview => overview.githubState === 'OPEN' && overview.job.schedule !== undefined)?.job;
|
|
119
|
-
if (!job?.schedule)
|
|
120
|
-
continue;
|
|
121
|
-
const slotBucket = scheduleBucket(new Date(job.schedule.nextRunAt));
|
|
122
|
-
if (!buckets.includes(slotBucket))
|
|
123
|
-
continue;
|
|
124
|
-
found.set(`${prNumber}:${job.id}`, { prNumber, jobId: job.id, bucket: slotBucket });
|
|
125
|
-
}
|
|
126
|
-
if (items.length < 100)
|
|
127
|
-
break;
|
|
128
|
-
}
|
|
129
|
-
}
|
|
130
|
-
return [...found.values()];
|
|
131
|
-
}
|
|
1
|
+
import { GitHubStore } from './store.js';
|
|
2
|
+
import { nextScheduleRun, scheduleBucket } from './scheduler.js';
|
|
132
3
|
async function refresh(client, repository, trustedAuthors, ref) {
|
|
133
4
|
const store = new GitHubStore(client, repository, ref.prNumber, trustedAuthors);
|
|
134
5
|
return await store.load(ref.jobId);
|
|
@@ -140,11 +11,9 @@ export async function runScheduledBucket(client, repository, trustedAuthors, ref
|
|
|
140
11
|
throw new Error(`Scheduled job ${job.id} has no schedule metadata`);
|
|
141
12
|
const slot = new Date(schedule.nextRunAt);
|
|
142
13
|
if (scheduleBucket(slot) !== ref.bucket || slot.getTime() > now.getTime()) {
|
|
143
|
-
await syncScheduleLabels(client, repository, job);
|
|
144
14
|
return { job, status: 'skipped-idempotent' };
|
|
145
15
|
}
|
|
146
16
|
if (schedule.lastScheduledSlot && new Date(schedule.lastScheduledSlot).getTime() >= slot.getTime()) {
|
|
147
|
-
await syncScheduleLabels(client, repository, job);
|
|
148
17
|
return { job, status: 'skipped-idempotent' };
|
|
149
18
|
}
|
|
150
19
|
if (job.state !== 'ACTIVE')
|
|
@@ -155,11 +24,9 @@ export async function runScheduledBucket(client, repository, trustedAuthors, ref
|
|
|
155
24
|
schedule.nextRunAt = nextScheduleRun(schedule, now).toISOString();
|
|
156
25
|
job.updatedAt = now.toISOString();
|
|
157
26
|
await new GitHubStore(client, repository, ref.prNumber, trustedAuthors).saveJob(job);
|
|
158
|
-
await syncScheduleLabels(client, repository, job);
|
|
159
27
|
return { job, status: 'skipped-active' };
|
|
160
28
|
}
|
|
161
29
|
if (active && schedule.concurrencyPolicy === 'queue') {
|
|
162
|
-
await syncScheduleLabels(client, repository, job);
|
|
163
30
|
return { job, status: 'skipped-active' };
|
|
164
31
|
}
|
|
165
32
|
const missedRunPolicy = schedule.missedRunPolicy;
|
|
@@ -170,27 +37,15 @@ export async function runScheduledBucket(client, repository, trustedAuthors, ref
|
|
|
170
37
|
const schedulerStore = new GitHubStore(client, repository, ref.prNumber, trustedAuthors);
|
|
171
38
|
await schedulerStore.saveJob(job);
|
|
172
39
|
job = await schedulerStore.load(job.id);
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
}
|
|
185
|
-
await pulseScheduleWake(client, repository, job);
|
|
186
|
-
return { job, status: 'dispatched' };
|
|
187
|
-
}
|
|
188
|
-
export async function processDueScheduledJobs(options) {
|
|
189
|
-
const due = await listIndexedScheduledJobs(options.client, options.repository, options.trustedAuthors, options.buckets);
|
|
190
|
-
const results = [];
|
|
191
|
-
for (const ref of due) {
|
|
192
|
-
const engine = options.engine ? options.engine(await refresh(options.client, options.repository, options.trustedAuthors, ref)) : undefined;
|
|
193
|
-
results.push(await runScheduledBucket(options.client, options.repository, options.trustedAuthors, ref, engine, options.now));
|
|
194
|
-
}
|
|
195
|
-
return results;
|
|
40
|
+
if (!engine)
|
|
41
|
+
throw new Error('Scheduled jobs require the local relayd scheduler engine');
|
|
42
|
+
await engine.reconcile(job.id);
|
|
43
|
+
await engine.idle();
|
|
44
|
+
const finished = await schedulerStore.load(job.id);
|
|
45
|
+
if (!finished.schedule)
|
|
46
|
+
throw new Error(`Scheduled job ${job.id} lost its schedule metadata`);
|
|
47
|
+
finished.schedule.lastRunAt = now.toISOString();
|
|
48
|
+
finished.updatedAt = now.toISOString();
|
|
49
|
+
await schedulerStore.saveJob(finished);
|
|
50
|
+
return { job: finished, status: 'completed' };
|
|
196
51
|
}
|
package/dist/scheduler.js
CHANGED
|
@@ -62,7 +62,6 @@ export async function transitionJobEvent(job, event, context = {}) {
|
|
|
62
62
|
return decision;
|
|
63
63
|
}
|
|
64
64
|
export const SCHEDULE_RESOLUTION_MS = 600_000;
|
|
65
|
-
export const RUN_BUCKET_LABEL_COUNT = 144;
|
|
66
65
|
const CRON_PART = /^(?:\*|(\d+)(?:-(\d+))?)(?:\/(\d+))?$/;
|
|
67
66
|
function cronField(raw, name, minimum, maximum) {
|
|
68
67
|
const values = new Set();
|
|
@@ -189,21 +188,3 @@ export function scheduleBucket(time) {
|
|
|
189
188
|
const bucket = bucketStart(time);
|
|
190
189
|
return `${bucket.toISOString().slice(0, 4)}${bucket.toISOString().slice(5, 7)}${bucket.toISOString().slice(8, 10)}T${bucket.toISOString().slice(11, 13)}${bucket.toISOString().slice(14, 16)}Z`;
|
|
191
190
|
}
|
|
192
|
-
export function runBucketLabel(bucket) {
|
|
193
|
-
if (!/^\d{8}T\d{4}Z$/.test(bucket))
|
|
194
|
-
throw new Error(`Invalid schedule bucket: ${bucket}`);
|
|
195
|
-
const hour = Number(bucket.slice(9, 11));
|
|
196
|
-
const minute = Number(bucket.slice(11, 13));
|
|
197
|
-
const slot = hour * 6 + minute / 10;
|
|
198
|
-
return `agents-relay:next-run:${slot}`;
|
|
199
|
-
}
|
|
200
|
-
export function parseRunBucketLabel(label) {
|
|
201
|
-
const prefix = 'agents-relay:next-run:';
|
|
202
|
-
if (!label.startsWith(prefix))
|
|
203
|
-
return null;
|
|
204
|
-
const value = label.slice(prefix.length);
|
|
205
|
-
if (!/^\d{1,3}$/.test(value))
|
|
206
|
-
return null;
|
|
207
|
-
const slot = Number(value);
|
|
208
|
-
return Number.isInteger(slot) && slot >= 0 && slot < RUN_BUCKET_LABEL_COUNT ? value : null;
|
|
209
|
-
}
|
package/package.json
CHANGED
|
@@ -109,9 +109,9 @@ SHA-bound final reviewer. For example, `video-director + market + style ->
|
|
|
109
109
|
review-chair -> unified revision plan` is a project pattern, not a hard-coded
|
|
110
110
|
Relay reviewer list.
|
|
111
111
|
|
|
112
|
-
A scheduled autonomous job adds cron/timezone/next-run and missed-run/concurrency policy to the trusted job marker
|
|
112
|
+
A scheduled autonomous job adds cron/timezone/next-run and missed-run/concurrency policy to the trusted job marker. The always-on `agents-relayd` process is the sole heartbeat for repositories it manages: on startup and every ten minutes it evaluates cached managed Jobs and reconciles due work through the local worker pool. There is no GitHub Actions scheduler fallback. Unscheduled autonomous Jobs and fixed Jobs do not use the scheduler heartbeat.
|
|
113
113
|
|
|
114
|
-
The scheduler heartbeat
|
|
114
|
+
The scheduler heartbeat filters cached managed Jobs, reloads each due Job to validate the marker's exact `nextRunAt`, claims the slot idempotently, advances the durable next run, and reconciles planner/worker execution locally. While the schedule is active, the durable Job remains `ACTIVE`; between runs its derived runtime status is `WAITING`. Scheduled recurring Jobs do not enter one-shot final-review/auto-merge after each satisfied run; older-cycle failures are historical evidence and do not block a later slot.
|
|
115
115
|
|
|
116
116
|
|
|
117
117
|
## Task evidence and head identity
|