agents-relay 1.0.55 → 1.0.56
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +3 -1
- package/dist/cli.js +47 -14
- package/dist/dashboard.js +7 -7
- package/dist/pool.js +2 -2
- package/dist/reconciler.js +22 -22
- package/dist/relayd.js +2 -1
- package/dist/runtime-status.js +52 -0
- package/dist/scheduled-jobs.js +3 -2
- package/dist/scheduler.js +3 -21
- package/dist/store.js +4 -0
- package/dist/types.js +2 -2
- package/package.json +1 -1
- package/skills/agents-relay/contracts/task-job-contract.md +14 -3
package/README.md
CHANGED
|
@@ -36,6 +36,8 @@ npx agents-relay task update --repo OWNER/REPO --pr 12 --id job-1 --task-id chil
|
|
|
36
36
|
npx agents-relay task update --repo OWNER/REPO --pr 12 --id job-1 --task-id child --queue
|
|
37
37
|
~~~
|
|
38
38
|
|
|
39
|
+
Jobs separate durable lifecycle from derived runtime status. Durable lifecycle is `DRAFT | ACTIVE | COMPLETED | CANCELLED`: a newly created unscheduled Job is DRAFT, creating executable work activates it, and scheduled Jobs are ACTIVE because the scheduler is allowed to advance them. Runtime status (`IDLE`, `WAITING`, `QUEUED`, `RUNNING`, `BLOCKED`, `FAILED`, `REVIEWING`, `MERGE_PENDING`, `MERGED`, or `CANCELLED`) is computed from Tasks, Review, PR, and schedule facts and is never persisted as lifecycle truth.
|
|
40
|
+
|
|
39
41
|
Jobs default to `fixed`. `job create` persists only the durable job and never
|
|
40
42
|
creates executable tasks. The top-level orchestrator decides whether the user's
|
|
41
43
|
request should start immediately and, if so, creates the first task batch
|
|
@@ -155,6 +157,6 @@ npx agents-relay task create --repo OWNER/REPO --pr 5 --id job-1 \
|
|
|
155
157
|
--input "Research the dashboard UX and write the final report to the declared output file."
|
|
156
158
|
~~~
|
|
157
159
|
|
|
158
|
-
GitHub PR state is authoritative for terminal lifecycle: a merged PR reconciles its managed job to `COMPLETED`; a closed, unmerged PR reconciles to `CANCELLED` and cannot launch queued work. The dashboard exposes `/api/jobs` and shows
|
|
160
|
+
GitHub PR state is authoritative for terminal lifecycle: a merged PR reconciles its managed job to `COMPLETED`; a closed, unmerged PR reconciles to `CANCELLED` and cannot launch queued work. The dashboard exposes `/api/jobs` and shows PR state, durable Job lifecycle, and derived runtime status separately. A merged PR reconciles lifecycle to `COMPLETED`; a closed, unmerged PR reconciles lifecycle to `CANCELLED`.
|
|
159
161
|
|
|
160
162
|
For work that was completed outside the relay but must be represented truthfully in durable history, use `npx agents-relay record --task-id <id> --summary <summary> --commit <sha>`. Recorded tasks use the `orchestrator` adapter and terminal `SUCCEEDED` state; they do not pretend a shell/model worker executed the work. This is a repair/backfill mechanism—normal work should be created as a durable Task before execution. Managed jobs also retain the GitHub PR body as the job description for dashboard display.
|
package/dist/cli.js
CHANGED
|
@@ -27,7 +27,7 @@ Usage:
|
|
|
27
27
|
npx agents-relay <command> [options]
|
|
28
28
|
|
|
29
29
|
Commands:
|
|
30
|
-
job create|update|show|list Manage durable GitHub-backed jobs
|
|
30
|
+
job create|update|show|list|merge|cancel Manage durable GitHub-backed jobs
|
|
31
31
|
task create|update|show|list|retry|cancel Manage durable tasks
|
|
32
32
|
context resolve Resolve shared project instructions, skills, agents, and runtimes
|
|
33
33
|
init Create a local demo job or initialize a GitHub job
|
|
@@ -43,13 +43,15 @@ Use 'node dist/cli.js' only for explicit local development/demo mode.`,
|
|
|
43
43
|
job: `Manage GitHub-backed durable jobs.
|
|
44
44
|
|
|
45
45
|
Usage:
|
|
46
|
-
npx agents-relay job <create|update|show|list> [options]
|
|
46
|
+
npx agents-relay job <create|update|show|list|merge|cancel> [options]
|
|
47
47
|
|
|
48
48
|
Actions:
|
|
49
49
|
create Create a durable Job, optionally binding an existing unmanaged PR
|
|
50
50
|
update Partially update mutable metadata on a healthy existing Job
|
|
51
51
|
show Show one durable Job
|
|
52
52
|
list List managed Jobs in a repository
|
|
53
|
+
merge Merge the backing PR and complete the Job
|
|
54
|
+
cancel Close the backing PR and cancel the Job
|
|
53
55
|
|
|
54
56
|
Common options:
|
|
55
57
|
--repo OWNER/REPO GitHub repository
|
|
@@ -87,6 +89,12 @@ Required: --repo OWNER/REPO.
|
|
|
87
89
|
|
|
88
90
|
Example:
|
|
89
91
|
npx agents-relay job list --repo OWNER/REPO`,
|
|
92
|
+
'job merge': `Merge the backing pull request and complete the durable Job.
|
|
93
|
+
|
|
94
|
+
Required: --repo OWNER/REPO, --pr NUMBER, and --id JOB_ID. The current PR head SHA is pinned into the merge request.`,
|
|
95
|
+
'job cancel': `Cancel the durable Job and close its backing pull request.
|
|
96
|
+
|
|
97
|
+
Required: --repo OWNER/REPO, --pr NUMBER, and --id JOB_ID. Nonterminal Tasks are cancelled before the PR is closed.`,
|
|
90
98
|
task: `Manage durable tasks.
|
|
91
99
|
|
|
92
100
|
Usage:
|
|
@@ -307,7 +315,7 @@ export async function ensureManagedGitHubJob(client, repositoryName, trustedAuth
|
|
|
307
315
|
reviewEnabled: options.reviewEnabled ?? true,
|
|
308
316
|
prNumber: pr.number,
|
|
309
317
|
repository: repositoryName,
|
|
310
|
-
state: '
|
|
318
|
+
state: options.schedule ? 'ACTIVE' : 'DRAFT',
|
|
311
319
|
continuation: options.continuation ?? null,
|
|
312
320
|
createdAt: now,
|
|
313
321
|
updatedAt: now,
|
|
@@ -386,18 +394,15 @@ async function runTaskCommand(action, args) {
|
|
|
386
394
|
if (!id)
|
|
387
395
|
throw new Error(`task ${action} requires --task-id`);
|
|
388
396
|
if (action === 'create') {
|
|
397
|
+
if (['COMPLETED', 'CANCELLED'].includes(job.state))
|
|
398
|
+
throw new Error(`Cannot create task on terminal job ${job.id} (${job.state})`);
|
|
389
399
|
const now = new Date().toISOString();
|
|
390
400
|
const task = workerTaskFromArgs(job, args, now);
|
|
391
401
|
task.id = id;
|
|
392
402
|
await store.appendTask(task);
|
|
393
403
|
const refreshed = await store.load(job.id);
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
const hasDraft = refreshed.tasks.some(item => item.state === 'DRAFT');
|
|
397
|
-
const hasFailed = refreshed.tasks.some(item => item.state === 'FAILED');
|
|
398
|
-
const nextState = hasActive ? 'RUNNING' : hasBlocked ? 'BLOCKED' : hasDraft ? 'WAITING' : hasFailed ? 'FAILED' : refreshed.state;
|
|
399
|
-
if (refreshed.state !== nextState) {
|
|
400
|
-
refreshed.state = nextState;
|
|
404
|
+
if (refreshed.state === 'DRAFT') {
|
|
405
|
+
refreshed.state = 'ACTIVE';
|
|
401
406
|
refreshed.updatedAt = now;
|
|
402
407
|
await store.saveJob(refreshed);
|
|
403
408
|
}
|
|
@@ -421,8 +426,8 @@ async function runTaskCommand(action, args) {
|
|
|
421
426
|
task.error = action === 'retry' ? null : 'Cancelled by user';
|
|
422
427
|
task.updatedAt = new Date().toISOString();
|
|
423
428
|
await store.saveTask(task);
|
|
424
|
-
if (action === 'retry' && job.state
|
|
425
|
-
job.state = '
|
|
429
|
+
if (action === 'retry' && job.state === 'DRAFT') {
|
|
430
|
+
job.state = 'ACTIVE';
|
|
426
431
|
job.updatedAt = task.updatedAt;
|
|
427
432
|
await store.saveJob(job);
|
|
428
433
|
}
|
|
@@ -520,7 +525,7 @@ async function runContextCommand(action, args) {
|
|
|
520
525
|
console.log(JSON.stringify(context, null, 2));
|
|
521
526
|
}
|
|
522
527
|
async function runJobCommand(action, args) {
|
|
523
|
-
if (!['create', 'update', 'show', 'list'].includes(action))
|
|
528
|
+
if (!['create', 'update', 'show', 'list', 'merge', 'cancel'].includes(action))
|
|
524
529
|
throw new Error(usage);
|
|
525
530
|
const repo = repository(args);
|
|
526
531
|
if (action === 'create') {
|
|
@@ -571,6 +576,34 @@ async function runJobCommand(action, args) {
|
|
|
571
576
|
return;
|
|
572
577
|
}
|
|
573
578
|
await store.validateJobBinding(id);
|
|
579
|
+
if (action === 'merge') {
|
|
580
|
+
if (job.state === 'CANCELLED')
|
|
581
|
+
throw new Error(`Cannot merge cancelled job ${job.id}`);
|
|
582
|
+
const headSha = await store.headSha();
|
|
583
|
+
await store.mergePullRequest(headSha ?? undefined);
|
|
584
|
+
job.state = 'COMPLETED';
|
|
585
|
+
job.updatedAt = new Date().toISOString();
|
|
586
|
+
await store.saveJob(job);
|
|
587
|
+
console.log(JSON.stringify(await store.load(id), null, 2));
|
|
588
|
+
return;
|
|
589
|
+
}
|
|
590
|
+
if (action === 'cancel') {
|
|
591
|
+
if (job.state === 'COMPLETED')
|
|
592
|
+
throw new Error(`Cannot cancel completed job ${job.id}`);
|
|
593
|
+
for (const task of job.tasks.filter(item => !['SUCCEEDED', 'CANCELLED'].includes(item.state))) {
|
|
594
|
+
transitionTask(task, 'CANCELLED');
|
|
595
|
+
task.error = 'Job cancelled';
|
|
596
|
+
task.leaseOwner = null;
|
|
597
|
+
task.leaseExpiresAt = null;
|
|
598
|
+
await store.saveTask(task);
|
|
599
|
+
}
|
|
600
|
+
await store.closePullRequest();
|
|
601
|
+
job.state = 'CANCELLED';
|
|
602
|
+
job.updatedAt = new Date().toISOString();
|
|
603
|
+
await store.saveJob(job);
|
|
604
|
+
console.log(JSON.stringify(await store.load(id), null, 2));
|
|
605
|
+
return;
|
|
606
|
+
}
|
|
574
607
|
if (hasArg(args, '--title'))
|
|
575
608
|
job.title = arg(args, '--title');
|
|
576
609
|
if (hasArg(args, '--objective'))
|
|
@@ -705,7 +738,7 @@ async function main() {
|
|
|
705
738
|
}
|
|
706
739
|
if (command === 'init') {
|
|
707
740
|
const now = new Date().toISOString();
|
|
708
|
-
const job = { id: arg(args, '--id', randomUUID()), title: arg(args, '--title', 'Agents Relay job'), objective: arg(args, '--objective') || undefined, priority: priority(args), executionMode: executionMode(args), schedule: scheduleFromArgs(args), autoMerge: !hasArg(args, '--no-auto-merge'), reviewEnabled: !hasArg(args, '--no-review'), prNumber: Number(arg(args, '--pr', '0')), repository: arg(args, '--repo'), state: '
|
|
741
|
+
const job = { id: arg(args, '--id', randomUUID()), title: arg(args, '--title', 'Agents Relay job'), objective: arg(args, '--objective') || undefined, priority: priority(args), executionMode: executionMode(args), schedule: scheduleFromArgs(args), autoMerge: !hasArg(args, '--no-auto-merge'), reviewEnabled: !hasArg(args, '--no-review'), prNumber: Number(arg(args, '--pr', '0')), repository: arg(args, '--repo'), state: scheduleFromArgs(args) ? 'ACTIVE' : 'DRAFT', continuation: continuation(args), createdAt: now, updatedAt: now, tasks: [] };
|
|
709
742
|
let store;
|
|
710
743
|
let file = null;
|
|
711
744
|
if (arg(args, '--repo') && arg(args, '--pr'))
|
package/dist/dashboard.js
CHANGED
|
@@ -3,6 +3,7 @@ import { readFile } from 'node:fs/promises';
|
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { readUsageSnapshot } from './usage.js';
|
|
6
|
+
import { deriveJobRuntimeStatus } from './runtime-status.js';
|
|
6
7
|
import { DeliveryDeduper, parseGitHubWebhook, verifyGitHubWebhook } from './github-webhook.js';
|
|
7
8
|
const GITHUB_REPOSITORY_PATTERN = /^([A-Za-z0-9](?:[A-Za-z0-9-]{0,36}[A-Za-z0-9])?)\/([A-Za-z0-9._-]{1,100})$/;
|
|
8
9
|
const SSE_HEARTBEAT_MS = 15000;
|
|
@@ -94,10 +95,10 @@ export const dashboardHtml = `<!doctype html>
|
|
|
94
95
|
.job-meta { display:flex; gap:.4rem; flex-wrap:wrap; color:var(--muted); font-size:.7rem; }
|
|
95
96
|
.job-meta span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
|
96
97
|
.status-pill,.badge { display:inline-flex; align-items:center; border-radius:999px; padding:.06rem .42rem; min-height:1.35rem; font-size:.68rem; font-weight:800; }
|
|
97
|
-
.status-pill.ok,.badge.RUNNING,.badge.SUCCEEDED,.badge.COMPLETED { background:#e5f3e9; color:var(--ok); }
|
|
98
|
+
.status-pill.ok,.badge.ACTIVE,.badge.RUNNING,.badge.SUCCEEDED,.badge.COMPLETED,.badge.MERGED { background:#e5f3e9; color:var(--ok); }
|
|
98
99
|
.status-pill.warn,.badge.BLOCKED,.badge.DRAFT,.badge.QUEUED,.badge.WAITING,.badge.OPEN { background:#fff1d2; color:var(--warn); }
|
|
99
100
|
.status-pill.bad,.badge.FAILED { background:#ffe8e8; color:var(--bad); }
|
|
100
|
-
.status-pill.neutral,.badge.CANCELLED { background:#edf0f4; color:var(--muted); }
|
|
101
|
+
.status-pill.neutral,.badge.IDLE,.badge.CANCELLED { background:#edf0f4; color:var(--muted); }
|
|
101
102
|
.badge.DRAFT { background:#eef2ff; color:#3f51b5; }
|
|
102
103
|
.badge.MERGE_PENDING { background:#ffe3a3; color:#7a4a00; }
|
|
103
104
|
.job-summary,.detail-groups { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:.55rem; }
|
|
@@ -221,7 +222,6 @@ function rememberSelection(){if(selectedJob)sessionStorage.setItem('agents-relay
|
|
|
221
222
|
function selected(){return jobs.find(job=>job.id===selectedJob)||jobs[0]||null;}
|
|
222
223
|
function counts(tasks){const out={};for(const task of Array.isArray(tasks)?tasks:[])out[task.state]=(out[task.state]||0)+1;return out;}
|
|
223
224
|
function compactCounts(input){const order=['RUNNING','BLOCKED','FAILED','QUEUED','DRAFT','SUCCEEDED','CANCELLED'];return order.filter(state=>input[state]).map(state=>input[state]+' '+state).join(', ')||'No tasks';}
|
|
224
|
-
function awaitingInitialTask(job){return job?.state==='OPEN'&&Array.isArray(job?.tasks)&&job.tasks.length===0;}
|
|
225
225
|
function relativeTime(timestamp){if(!timestamp)return '—';const parsed=Date.parse(timestamp);if(!Number.isFinite(parsed))return value(timestamp);const seconds=Math.max(0,Math.round((Date.now()-parsed)/1000));if(seconds<60)return 'just now';if(seconds<3600)return Math.floor(seconds/60)+'m ago';if(seconds<86400)return Math.floor(seconds/3600)+'h ago';return Math.floor(seconds/86400)+'d ago';}
|
|
226
226
|
function safePrUrl(repository,prNumber){if(typeof repository!=='string'||!Number.isInteger(prNumber)||prNumber<=0)return null;const parts=repository.split('/');if(parts.length!==2)return null;const [owner,repo]=parts;if(!owner||!repo||owner==='.'||owner==='..'||repo==='.'||repo==='..')return null;if(!/^[A-Za-z0-9-]+$/.test(owner)||!/^[A-Za-z0-9._-]+$/.test(repo))return null;return 'https://github.com/'+owner+'/'+repo+'/pull/'+prNumber;}
|
|
227
227
|
function prField(job){const box=element('div','detail-group');box.append(element('p','detail-label',text('GitHub PR')));const line=element('p','detail-value');const url=safePrUrl(job.repository,job.prNumber);if(url){const link=document.createElement('a');link.href=url;link.target='_blank';link.rel='noopener noreferrer';link.textContent='PR #'+job.prNumber;line.append(link);}else line.append(text('unavailable'));box.append(line);return box;}
|
|
@@ -237,15 +237,15 @@ function syncFilters(){
|
|
|
237
237
|
selectedRepo=repos.includes(previousRepo)?previousRepo:'';
|
|
238
238
|
repoFilter.value=selectedRepo;
|
|
239
239
|
}
|
|
240
|
-
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 button=document.createElement('button');button.type='button';button.className='job-button'+(isCompleteUnmerged(job)?' complete-unmerged':'');button.setAttribute('aria-current',String(job.id===selectedJob));button.title=job.title||job.id;button.addEventListener('click',()=>selectJob(job.id));const meta=element('span','job-meta');meta.append(element('span','badge '+value(job.state),text(job.state)),element('span','badge mode-'+value(job.executionMode||'fixed'),text(value(job.executionMode||'fixed').toUpperCase())),element('span',job.draft?'badge DRAFT':'',text(job.draft?'DRAFT':value(job.githubState||'UNKNOWN'))));if(isCompleteUnmerged(job))meta.append(element('span','badge MERGE_PENDING',text('MERGE PENDING')));
|
|
240
|
+
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 button=document.createElement('button');button.type='button';button.className='job-button'+(isCompleteUnmerged(job)?' complete-unmerged':'');button.setAttribute('aria-current',String(job.id===selectedJob));button.title=job.title||job.id;button.addEventListener('click',()=>selectJob(job.id));const meta=element('span','job-meta');meta.append(element('span','badge '+value(job.runtimeStatus||'IDLE'),text(value(job.runtimeStatus||'IDLE'))),element('span','badge '+value(job.state),text(job.state)),element('span','badge mode-'+value(job.executionMode||'fixed'),text(value(job.executionMode||'fixed').toUpperCase())),element('span',job.draft?'badge DRAFT':'',text(job.draft?'DRAFT':value(job.githubState||'UNKNOWN'))));if(isCompleteUnmerged(job))meta.append(element('span','badge MERGE_PENDING',text('MERGE PENDING')));meta.append(element('span','',text(job.repository||'unknown repo')),element('span','',text(relativeTime(job.updatedAt))));button.append(element('span','job-title',text('PR#'+value(job.prNumber)+': '+value(job.title||job.id))),meta,element('span','subtle',text(compactCounts(counts(job.tasks)))));jobList.append(element('li','',button));}}
|
|
241
241
|
function taskNode(task,children,depth){const li=element('li');const row=document.createElement('button');row.type='button';row.className='task-row';row.setAttribute('aria-current',String(task.id===selectedTaskId));row.title=task.id;row.addEventListener('click',()=>{selectedTaskId=task.id;eventTaskId=task.id;rememberSelection();clearEvents();renderAll();void loadEventHistory();});const id=element('span','task-id',text(task.id));id.style.paddingLeft=Math.min(depth,4)*6+'px';row.append(id,element('span','badge '+value(task.state),text(task.state)));li.append(row);const nested=children.get(task.id)||[];if(nested.length){const ul=element('ul','tree');for(const child of nested)ul.append(taskNode(child,children,depth+1));li.append(ul);}return li;}
|
|
242
242
|
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));}
|
|
243
243
|
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;}
|
|
244
244
|
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('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'),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);}
|
|
245
245
|
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();}
|
|
246
|
-
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('
|
|
246
|
+
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'),field('Lifecycle state',job.state),field('Runtime status',job.runtimeStatus||'IDLE'),field('Execution mode',job.executionMode||'fixed'),field('Review',reviewStatus(job)),field('Review head SHA',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();}
|
|
247
247
|
function renderAll(){jobs.sort(compareJobs);syncFilters();renderJobList();renderSelectedJob();}
|
|
248
|
-
function isCompleteUnmerged(job){return job.
|
|
248
|
+
function isCompleteUnmerged(job){return job.runtimeStatus==='MERGE_PENDING';}
|
|
249
249
|
function isTerminalJob(job){return ['COMPLETED','CANCELLED'].includes(job.state)||['MERGED','CLOSED'].includes(job.githubState);}
|
|
250
250
|
function compareJobs(a,b){const mergePendingDelta=Number(isCompleteUnmerged(b))-Number(isCompleteUnmerged(a));if(mergePendingDelta)return mergePendingDelta;const terminalDelta=Number(isTerminalJob(a))-Number(isTerminalJob(b));return terminalDelta||String(b.updatedAt).localeCompare(String(a.updatedAt));}
|
|
251
251
|
async function loadJSON(url){const response=await fetch(url);if(!response.ok)throw new Error(response.status+' '+response.statusText);return response.json();}
|
|
@@ -295,7 +295,7 @@ export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobI
|
|
|
295
295
|
const discoveredJobs = async (forceRefresh = false) => {
|
|
296
296
|
try {
|
|
297
297
|
const jobs = overviewProvider
|
|
298
|
-
? (await overviewProvider(forceRefresh)).map(item => ({ ...item.job, githubState: item.githubState, mergedAt: item.mergedAt, closedAt: item.closedAt, draft: item.draft }))
|
|
298
|
+
? (await overviewProvider(forceRefresh)).map(item => ({ ...item.job, githubState: item.githubState, runtimeStatus: deriveJobRuntimeStatus(item.job, item.githubState), mergedAt: item.mergedAt, closedAt: item.closedAt, draft: item.draft }))
|
|
299
299
|
: jobId ? [await store.load(jobId)] : [];
|
|
300
300
|
lastKnownJobs = jobs;
|
|
301
301
|
return jobs;
|
package/dist/pool.js
CHANGED
|
@@ -2,9 +2,9 @@ import { GitHubStore, listManagedGitHubJobs } from './store.js';
|
|
|
2
2
|
import { eventFor } from './events.js';
|
|
3
3
|
const RANK = { P0: 0, P1: 1, P2: 2, P3: 3 };
|
|
4
4
|
export function compareJobPriority(a, b) { return RANK[a.priority ?? 'P2'] - RANK[b.priority ?? 'P2'] || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id); }
|
|
5
|
-
export function runnableManagedJobs(items) { return items.filter(item => item.githubState === 'OPEN' &&
|
|
5
|
+
export function runnableManagedJobs(items) { return items.filter(item => item.githubState === 'OPEN' && item.job.state === 'ACTIVE').map(item => item.job).sort(compareJobPriority); }
|
|
6
6
|
export function needsLifecycleReconciliation(job, now = Date.now()) {
|
|
7
|
-
//
|
|
7
|
+
// DRAFT create-only Jobs stay dormant until a caller explicitly creates work.
|
|
8
8
|
if (job.tasks.length === 0)
|
|
9
9
|
return false;
|
|
10
10
|
if (job.tasks.some(task => task.state === 'QUEUED' || (task.state === 'DRAFT' && task.dependencies.length > 0)))
|
package/dist/reconciler.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { randomUUID } from 'node:crypto';
|
|
2
2
|
import { storySizeTimeoutMs, taskOverallTimeoutMs, chatGptStartAckTimeoutMs } from './types.js';
|
|
3
3
|
import { dependencyReady, schedule, transitionTask } from './scheduler.js';
|
|
4
|
+
import { implementationComplete } from './runtime-status.js';
|
|
4
5
|
import { LaunchFailure, StartAckTimeout } from './adapters.js';
|
|
5
6
|
import { eventFor } from './events.js';
|
|
6
7
|
import { parsePlannerResult, plannerInput } from './planner.js';
|
|
@@ -129,7 +130,16 @@ export class Reconciler {
|
|
|
129
130
|
task.leaseExpiresAt = null;
|
|
130
131
|
await this.store.saveTask(task);
|
|
131
132
|
}
|
|
133
|
+
const terminalState = prState === 'MERGED' ? 'COMPLETED' : 'CANCELLED';
|
|
134
|
+
if (job.state !== terminalState) {
|
|
135
|
+
job.state = terminalState;
|
|
136
|
+
job.updatedAt = new Date().toISOString();
|
|
137
|
+
await this.store.saveJob(job);
|
|
138
|
+
}
|
|
139
|
+
return this.store.load(jobId);
|
|
132
140
|
}
|
|
141
|
+
if (job.state !== 'ACTIVE')
|
|
142
|
+
return job;
|
|
133
143
|
await this.recoverLostExecutions(job, now);
|
|
134
144
|
await this.recoverPlannerChildren(job);
|
|
135
145
|
await this.routeExecutableTasks(await this.store.load(jobId));
|
|
@@ -138,7 +148,11 @@ export class Reconciler {
|
|
|
138
148
|
this.live.get(task.id)?.execution.cancel();
|
|
139
149
|
this.plannerLive.get(task.id)?.controller.abort();
|
|
140
150
|
}
|
|
141
|
-
|
|
151
|
+
const beforeSchedule = await this.store.load(jobId);
|
|
152
|
+
const scheduleBefore = JSON.stringify(beforeSchedule.schedule ?? null);
|
|
153
|
+
let scheduled = schedule(beforeSchedule);
|
|
154
|
+
if (scheduled.state !== previousState || JSON.stringify(scheduled.schedule ?? null) !== scheduleBefore)
|
|
155
|
+
await this.store.saveJob(scheduled);
|
|
142
156
|
if (prState === 'OPEN') {
|
|
143
157
|
await this.ensureFinalReview(scheduled, prState, headSha);
|
|
144
158
|
scheduled = schedule(await this.store.load(jobId));
|
|
@@ -146,15 +160,7 @@ export class Reconciler {
|
|
|
146
160
|
if (prState === 'OPEN' && scheduled.executionMode === 'autonomous') {
|
|
147
161
|
scheduled = schedule(await this.maybeCreatePlanner(scheduled));
|
|
148
162
|
}
|
|
149
|
-
if (
|
|
150
|
-
scheduled.state = 'COMPLETED';
|
|
151
|
-
scheduled.updatedAt = new Date().toISOString();
|
|
152
|
-
}
|
|
153
|
-
if (prState === 'CLOSED') {
|
|
154
|
-
scheduled.state = 'CANCELLED';
|
|
155
|
-
scheduled.updatedAt = new Date().toISOString();
|
|
156
|
-
}
|
|
157
|
-
if (scheduled.state !== previousState)
|
|
163
|
+
if (scheduled.state !== previousState || scheduled.updatedAt !== job.updatedAt)
|
|
158
164
|
await this.store.saveJob(scheduled);
|
|
159
165
|
if (prState === 'OPEN') {
|
|
160
166
|
for (const task of scheduled.tasks.filter(item => item.state === 'SUCCEEDED' && (item.continuation || scheduled.continuation) && !item.continuationDeliveredAt))
|
|
@@ -176,17 +182,13 @@ export class Reconciler {
|
|
|
176
182
|
}
|
|
177
183
|
}
|
|
178
184
|
const finalState = schedule(await this.store.load(jobId));
|
|
179
|
-
if (
|
|
180
|
-
finalState.state = 'COMPLETED';
|
|
181
|
-
finalState.updatedAt = new Date().toISOString();
|
|
182
|
-
}
|
|
183
|
-
if (prState === 'CLOSED') {
|
|
184
|
-
finalState.state = 'CANCELLED';
|
|
185
|
-
finalState.updatedAt = new Date().toISOString();
|
|
186
|
-
}
|
|
187
|
-
if (finalState.state !== scheduled.state || finalState.updatedAt !== scheduled.updatedAt)
|
|
185
|
+
if (finalState.updatedAt !== scheduled.updatedAt)
|
|
188
186
|
await this.store.saveJob(finalState);
|
|
189
187
|
await this.ensureFinalReview(finalState, prState, headSha);
|
|
188
|
+
if (prState === 'OPEN' && finalState.reviewEnabled === false && implementationComplete(finalState) && finalState.autoMerge !== false && this.store.mergePullRequest && headSha) {
|
|
189
|
+
await this.store.mergePullRequest(headSha);
|
|
190
|
+
await this.emit(eventFor(finalState.id, 'job', null, 'job.merged', 'succeeded', 'Completed work merged PR #' + finalState.prNumber, 'user', { headSha, autoMerge: true }, { agent: 'relay', component: 'agents-relay' }));
|
|
191
|
+
}
|
|
190
192
|
return this.store.load(jobId);
|
|
191
193
|
}
|
|
192
194
|
finally {
|
|
@@ -210,7 +212,7 @@ export class Reconciler {
|
|
|
210
212
|
await this.store.saveJob(job);
|
|
211
213
|
}
|
|
212
214
|
async ensureFinalReview(job, prState, headSha) {
|
|
213
|
-
if (job.schedule || job.reviewEnabled === false || prState !== 'OPEN' || job.state !== '
|
|
215
|
+
if (job.schedule || job.reviewEnabled === false || prState !== 'OPEN' || job.state !== 'ACTIVE' || !implementationComplete(job) || !headSha)
|
|
214
216
|
return;
|
|
215
217
|
if (!this.preReviewGatesPass(job))
|
|
216
218
|
return;
|
|
@@ -229,7 +231,6 @@ export class Reconciler {
|
|
|
229
231
|
const task = { jobId: job.id, id, kind: 'work', priority: job.priority, storySize: 'M', projectName: job.title, agentName: 'reviewer', parentTaskId: null, dependencies: implementationTasks.map(item => item.id), capabilities: ['review'], workerSelection: reviewerRouting.selection, adapter: reviewerRouting.candidate.adapter, input: `Review PR #${job.prNumber} at head SHA ${headSha}. Objective: ${job.objective ?? job.description ?? job.title}. First publish task.started as the worker start acknowledgement with data.worker_owned=true, then verify the deterministic pre-review gates have passed and 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}. For review.changes_required, include every actionable finding in data.review.issues as an array of concise issue strings; Relay will create one durable fix task containing all findings. Do not modify code. When auto-merge is enabled, Relay will merge this exact reviewed head after review.approved.`, output: { kind: 'task_pr' }, routing: reviewerRouting.candidate.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 ?? storySizeTimeoutMs('M'), createdAt: now, updatedAt: now };
|
|
230
232
|
await this.store.appendTask(task);
|
|
231
233
|
job.review = { outcome: 'pending', headSha, taskId: id, evidence: null, updatedAt: now };
|
|
232
|
-
job.state = 'RUNNING';
|
|
233
234
|
job.updatedAt = now;
|
|
234
235
|
await this.store.saveJob(job);
|
|
235
236
|
await this.emit(eventFor(job.id, id, null, 'review.created', 'queued', `Final review task ${id} created for ${headSha} using ${reviewerRouting.reason}`, 'orchestrator', { headSha, agentName: 'reviewer', reviewerRouting: { workerId: reviewerRouting.selection.workerId, reason: reviewerRouting.reason, selectedBy: reviewerRouting.selection.selectedBy } }));
|
|
@@ -340,7 +341,6 @@ export class Reconciler {
|
|
|
340
341
|
await this.store.appendTask(fixTask);
|
|
341
342
|
await this.emit(eventFor(job.id, id, task.id, 'review.fix_task_created', 'queued', `Created one fix task ${id} for ${issues.length} reviewer finding(s)`, 'orchestrator', { headSha, issues }, { agent: 'relay', component: 'agents-relay' }));
|
|
342
343
|
}
|
|
343
|
-
job.state = 'RUNNING';
|
|
344
344
|
}
|
|
345
345
|
job.updatedAt = job.review.updatedAt;
|
|
346
346
|
await this.store.saveJob(job);
|
package/dist/relayd.js
CHANGED
|
@@ -14,6 +14,7 @@ import { githubAuthContext } from './github-auth.js';
|
|
|
14
14
|
import { isEntrypoint, runtimePlanner, runtimeReviewer, startService, SERVICE_DEFAULTS } from './cli.js';
|
|
15
15
|
import { codexAndZaiUsageRegistry } from './usage.js';
|
|
16
16
|
import { defaultWorkspaceRoot, discoverWorkspaceRepositories, resolveManagedProjectPath } from './workspace.js';
|
|
17
|
+
import { deriveJobRuntimeStatus } from './runtime-status.js';
|
|
17
18
|
export const daemonHelp = `Agents Relay daemon runs the dashboard and worker pool.
|
|
18
19
|
|
|
19
20
|
Usage:
|
|
@@ -151,7 +152,7 @@ export async function runDaemon(argv) {
|
|
|
151
152
|
const refreshed = (await refreshPullRequest(cached.job.repository, cached.job.prNumber)).find(item => item.job.id === jobId);
|
|
152
153
|
if (!refreshed)
|
|
153
154
|
return null;
|
|
154
|
-
return { ...refreshed.job, githubState: refreshed.githubState, mergedAt: refreshed.mergedAt, closedAt: refreshed.closedAt, draft: refreshed.draft };
|
|
155
|
+
return { ...refreshed.job, githubState: refreshed.githubState, runtimeStatus: deriveJobRuntimeStatus(refreshed.job, refreshed.githubState), mergedAt: refreshed.mergedAt, closedAt: refreshed.closedAt, draft: refreshed.draft };
|
|
155
156
|
}
|
|
156
157
|
catch (error) {
|
|
157
158
|
process.stderr.write(`warning: selected job refresh unavailable: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
function isSuperseded(task) {
|
|
2
|
+
return task.state === 'CANCELLED' && typeof task.error === 'string' && task.error.startsWith('Superseded ');
|
|
3
|
+
}
|
|
4
|
+
export function implementationTasks(job) {
|
|
5
|
+
return job.tasks.filter(task => task.agentName !== 'reviewer' && !isSuperseded(task));
|
|
6
|
+
}
|
|
7
|
+
export function implementationComplete(job) {
|
|
8
|
+
const tasks = implementationTasks(job);
|
|
9
|
+
return tasks.length > 0 && tasks.every(task => task.state === 'SUCCEEDED');
|
|
10
|
+
}
|
|
11
|
+
export function deriveJobRuntimeStatus(job, prState = 'OPEN') {
|
|
12
|
+
if (prState === 'MERGED')
|
|
13
|
+
return 'MERGED';
|
|
14
|
+
if (prState === 'CLOSED' || job.state === 'CANCELLED')
|
|
15
|
+
return 'CANCELLED';
|
|
16
|
+
if (job.state === 'DRAFT')
|
|
17
|
+
return 'IDLE';
|
|
18
|
+
if (job.schedule) {
|
|
19
|
+
if (job.tasks.some(task => task.state === 'RUNNING'))
|
|
20
|
+
return 'RUNNING';
|
|
21
|
+
if (job.tasks.some(task => task.state === 'QUEUED'))
|
|
22
|
+
return 'QUEUED';
|
|
23
|
+
if (job.tasks.some(task => task.state === 'BLOCKED'))
|
|
24
|
+
return 'BLOCKED';
|
|
25
|
+
if (job.tasks.some(task => task.state === 'FAILED'))
|
|
26
|
+
return 'FAILED';
|
|
27
|
+
if (job.tasks.some(task => task.state === 'DRAFT'))
|
|
28
|
+
return 'WAITING';
|
|
29
|
+
return 'WAITING';
|
|
30
|
+
}
|
|
31
|
+
const reviewTasks = job.tasks.filter(task => task.agentName === 'reviewer');
|
|
32
|
+
const reviewActive = reviewTasks.some(task => ['DRAFT', 'QUEUED', 'RUNNING'].includes(task.state))
|
|
33
|
+
|| job.review?.outcome === 'pending'
|
|
34
|
+
|| job.review?.outcome === 'started';
|
|
35
|
+
if (job.review?.outcome === 'approved' && prState === 'OPEN')
|
|
36
|
+
return 'MERGE_PENDING';
|
|
37
|
+
if (reviewActive || (job.reviewEnabled !== false && implementationComplete(job) && job.review?.outcome !== 'approved'))
|
|
38
|
+
return 'REVIEWING';
|
|
39
|
+
if (job.tasks.some(task => task.state === 'RUNNING'))
|
|
40
|
+
return 'RUNNING';
|
|
41
|
+
if (job.tasks.some(task => task.state === 'QUEUED'))
|
|
42
|
+
return 'QUEUED';
|
|
43
|
+
if (job.tasks.some(task => task.state === 'BLOCKED'))
|
|
44
|
+
return 'BLOCKED';
|
|
45
|
+
if (job.tasks.some(task => task.state === 'FAILED'))
|
|
46
|
+
return 'FAILED';
|
|
47
|
+
if (job.tasks.some(task => task.state === 'DRAFT'))
|
|
48
|
+
return 'WAITING';
|
|
49
|
+
if (job.reviewEnabled === false && implementationComplete(job) && prState === 'OPEN')
|
|
50
|
+
return 'MERGE_PENDING';
|
|
51
|
+
return 'IDLE';
|
|
52
|
+
}
|
package/dist/scheduled-jobs.js
CHANGED
|
@@ -147,7 +147,9 @@ export async function runScheduledBucket(client, repository, trustedAuthors, ref
|
|
|
147
147
|
await syncScheduleLabels(client, repository, job);
|
|
148
148
|
return { job, status: 'skipped-idempotent' };
|
|
149
149
|
}
|
|
150
|
-
|
|
150
|
+
if (job.state !== 'ACTIVE')
|
|
151
|
+
return { job, status: 'skipped-idempotent' };
|
|
152
|
+
const active = job.tasks.some(task => ['QUEUED', 'RUNNING'].includes(task.state));
|
|
151
153
|
if (active && schedule.concurrencyPolicy === 'skip') {
|
|
152
154
|
schedule.lastScheduledSlot = slot.toISOString();
|
|
153
155
|
schedule.nextRunAt = nextScheduleRun(schedule, now).toISOString();
|
|
@@ -166,7 +168,6 @@ export async function runScheduledBucket(client, repository, trustedAuthors, ref
|
|
|
166
168
|
schedule.nextRunAt = nextScheduleRun(schedule, missedRunPolicy === 'run' ? slot : now).toISOString();
|
|
167
169
|
job.updatedAt = now.toISOString();
|
|
168
170
|
const schedulerStore = new GitHubStore(client, repository, ref.prNumber, trustedAuthors);
|
|
169
|
-
job.state = 'RUNNING';
|
|
170
171
|
await schedulerStore.saveJob(job);
|
|
171
172
|
job = await schedulerStore.load(job.id);
|
|
172
173
|
await syncScheduleLabels(client, repository, job);
|
package/dist/scheduler.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { assertTaskTransition } from './types.js';
|
|
2
2
|
export function dependencyReady(task, tasks) { return task.dependencies.every(id => tasks.get(id)?.state === 'SUCCEEDED'); }
|
|
3
3
|
export function detectCycles(tasks) { const map = new Map(tasks.map(t => [t.id, t])); const visiting = new Set(); const done = new Set(); const visit = (id) => { if (visiting.has(id))
|
|
4
4
|
throw new Error(`Dependency cycle at ${id}`); if (done.has(id))
|
|
@@ -22,14 +22,6 @@ export function completionGateSatisfied(job) {
|
|
|
22
22
|
export function schedule(job) {
|
|
23
23
|
detectCycles(job.tasks);
|
|
24
24
|
const scopedTasks = scheduledCycleTasks(job);
|
|
25
|
-
if (scopedTasks.length === 0) {
|
|
26
|
-
if (job.executionMode === 'autonomous' && job.state !== 'WAITING') {
|
|
27
|
-
assertJobTransition(job.state, 'WAITING');
|
|
28
|
-
job.state = 'WAITING';
|
|
29
|
-
job.updatedAt = new Date().toISOString();
|
|
30
|
-
}
|
|
31
|
-
return job;
|
|
32
|
-
}
|
|
33
25
|
const map = new Map(job.tasks.map(t => [t.id, t]));
|
|
34
26
|
for (const task of scopedTasks) {
|
|
35
27
|
const ready = dependencyReady(task, map);
|
|
@@ -40,13 +32,7 @@ export function schedule(job) {
|
|
|
40
32
|
else if (task.state === 'QUEUED' && ready && task.error !== null)
|
|
41
33
|
transitionTask(task, 'QUEUED');
|
|
42
34
|
}
|
|
43
|
-
|
|
44
|
-
const blocked = scopedTasks.some(t => t.state === 'BLOCKED');
|
|
45
|
-
const waiting = scopedTasks.some(t => t.state === 'DRAFT');
|
|
46
|
-
const failed = scopedTasks.some(t => t.state === 'FAILED');
|
|
47
|
-
const cancelled = scopedTasks.every(t => t.state === 'CANCELLED');
|
|
48
|
-
const next = active ? 'RUNNING' : failed ? 'FAILED' : blocked ? 'BLOCKED' : waiting ? 'WAITING' : cancelled ? 'CANCELLED' : completionGateSatisfied(job) ? 'COMPLETED' : 'WAITING';
|
|
49
|
-
if (job.schedule && next === 'WAITING') {
|
|
35
|
+
if (job.schedule) {
|
|
50
36
|
const planners = scopedTasks.filter(task => task.kind === 'planner');
|
|
51
37
|
const latest = planners.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id)).at(-1);
|
|
52
38
|
if (latest?.state === 'SUCCEEDED' && latest.plannerResult?.objective_status === 'satisfied') {
|
|
@@ -55,13 +41,9 @@ export function schedule(job) {
|
|
|
55
41
|
if (Number.isFinite(slot) && (!Number.isFinite(lastRun) || lastRun < slot))
|
|
56
42
|
job.schedule.lastRunAt = new Date().toISOString();
|
|
57
43
|
job.schedule.currentRunSlot = null;
|
|
44
|
+
job.updatedAt = new Date().toISOString();
|
|
58
45
|
}
|
|
59
46
|
}
|
|
60
|
-
if (job.state !== next) {
|
|
61
|
-
assertJobTransition(job.state, next);
|
|
62
|
-
job.state = next;
|
|
63
|
-
job.updatedAt = new Date().toISOString();
|
|
64
|
-
}
|
|
65
47
|
return job;
|
|
66
48
|
}
|
|
67
49
|
export function extendRetryBudget(task, windowSize = 3) {
|
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 closePullRequest() { }
|
|
68
69
|
async headSha() { const jobs = [...this.values.values()].map(parseJob).filter((x) => x !== null); return jobs[0]?.headSha ?? jobs[0]?.review?.headSha ?? null; }
|
|
69
70
|
async listAgents() { return [...this.values.values()].map(parseAgent).filter((x) => x !== null); }
|
|
70
71
|
async saveAgent(agent) { this.values.set(`${AGENT_MARKER}:${agent.id}`, renderAgentComment(agent)); }
|
|
@@ -473,6 +474,9 @@ export class GitHubStore {
|
|
|
473
474
|
if (result.merged !== true)
|
|
474
475
|
throw new Error(typeof result.message === 'string' ? result.message : 'Pull request #' + this.prNumber + ' was not merged');
|
|
475
476
|
}
|
|
477
|
+
async closePullRequest() {
|
|
478
|
+
await this.client.request('repos/' + this.repository + '/pulls/' + this.prNumber, { method: 'PATCH', body: JSON.stringify({ state: 'closed' }) });
|
|
479
|
+
}
|
|
476
480
|
async headSha() {
|
|
477
481
|
const { owner, name } = this.repoParts();
|
|
478
482
|
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 });
|
package/dist/types.js
CHANGED
|
@@ -2,9 +2,9 @@ export const STORY_SIZE_TIMEOUT_MS = { S: 300000, M: 900000, L: 2700000 };
|
|
|
2
2
|
export function storySizeTimeoutMs(size) { return STORY_SIZE_TIMEOUT_MS[size]; }
|
|
3
3
|
export function taskOverallTimeoutMs(size, reasoning) { return storySizeTimeoutMs(size) * (reasoning?.toLowerCase() === 'high' ? 2 : 1); }
|
|
4
4
|
export function chatGptStartAckTimeoutMs(overallTimeoutMs) { return Math.min(600000, Math.max(120000, Math.floor(overallTimeoutMs / 3))); }
|
|
5
|
-
const
|
|
5
|
+
const jobLifecycleTransitions = { DRAFT: ['ACTIVE', 'COMPLETED', 'CANCELLED'], ACTIVE: ['COMPLETED', 'CANCELLED'], COMPLETED: [], CANCELLED: ['COMPLETED'] };
|
|
6
6
|
const taskTransitions = { DRAFT: ['QUEUED', 'BLOCKED', 'CANCELLED'], QUEUED: ['DRAFT', 'RUNNING', 'BLOCKED', 'FAILED', 'CANCELLED'], RUNNING: ['BLOCKED', 'SUCCEEDED', 'FAILED', 'CANCELLED', 'QUEUED'], BLOCKED: ['QUEUED', 'CANCELLED'], SUCCEEDED: [], FAILED: ['QUEUED', 'RUNNING', 'CANCELLED'], CANCELLED: [] };
|
|
7
|
-
export function
|
|
7
|
+
export function assertJobLifecycleTransition(from, to) { if (from !== to && !jobLifecycleTransitions[from].includes(to))
|
|
8
8
|
throw new Error(`Invalid job transition ${from} -> ${to}`); }
|
|
9
9
|
export function assertTaskTransition(from, to) { if (from !== to && !taskTransitions[from].includes(to))
|
|
10
10
|
throw new Error(`Invalid task transition ${from} -> ${to}`); }
|
package/package.json
CHANGED
|
@@ -40,12 +40,23 @@ Task readiness is explicit and must use these semantics:
|
|
|
40
40
|
- `BLOCKED`: execution cannot continue without an external resolution or intervention. Once a Task has entered `RUNNING`, it must not return to `DRAFT`; waiting discovered during execution is `BLOCKED`.
|
|
41
41
|
- `SUCCEEDED`, `FAILED`, and `CANCELLED` are terminal outcomes, except that retry explicitly requeues a failed/blocked Task.
|
|
42
42
|
|
|
43
|
-
Do not use `WAITING` as a Task state. `WAITING`
|
|
43
|
+
Do not use `WAITING` as a Task state or durable Job lifecycle state. `WAITING` is a derived Job runtime status for an `ACTIVE` Job whose next progress depends on a future trigger, schedule slot, dependency release, or other condition.
|
|
44
44
|
|
|
45
45
|
## Worker lifecycle
|
|
46
46
|
|
|
47
47
|
Relay owns process lifecycle. Workers own semantic task lifecycle. A worker must publish canonical `task.started` as its first execution action, then exactly one of `task.completed`, `task.failed`, `task.blocked`, or `task.cancelled`. Process launch/exit events do not substitute for worker semantic events.
|
|
48
48
|
|
|
49
|
+
## Job lifecycle and runtime status
|
|
50
|
+
|
|
51
|
+
Durable Job lifecycle is intentionally small: `DRAFT | ACTIVE | COMPLETED | CANCELLED`.
|
|
52
|
+
|
|
53
|
+
- `DRAFT`: durable objective exists, but the execution engine must not run it.
|
|
54
|
+
- `ACTIVE`: the execution engine may reconcile the Job. Scheduler, external triggers, manual actions, dependency release, workers, and review may all advance an ACTIVE Job.
|
|
55
|
+
- `COMPLETED`: terminal successful lifecycle. `job merge` merges the backing PR; a merged PR is authoritative and reconciles the Job to COMPLETED.
|
|
56
|
+
- `CANCELLED`: terminal cancellation. `job cancel` closes the backing PR; a closed, unmerged managed PR also reconciles the Job to CANCELLED.
|
|
57
|
+
|
|
58
|
+
Job runtime status is derived, not persisted. Relay computes it from lifecycle plus Task, Review, PR, and schedule facts. Runtime values include `IDLE`, `WAITING`, `QUEUED`, `RUNNING`, `BLOCKED`, `FAILED`, `REVIEWING`, `MERGE_PENDING`, `MERGED`, and `CANCELLED`. A blocked Task does not by itself make the Job runtime BLOCKED when other runnable/running work can still advance the objective. Lifecycle is the execution gate; runtime status is observation.
|
|
59
|
+
|
|
49
60
|
## Job and review lifecycle
|
|
50
61
|
|
|
51
62
|
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.
|
|
@@ -56,7 +67,7 @@ Final-review routing follows the same worker-selection boundary as executable wo
|
|
|
56
67
|
|
|
57
68
|
The public CLI resource surface is:
|
|
58
69
|
|
|
59
|
-
- `job create|update|show|list`
|
|
70
|
+
- `job create|update|show|list|merge|cancel`
|
|
60
71
|
- `task create|update|show|list|retry|cancel`
|
|
61
72
|
|
|
62
73
|
`job create` persists only durable Job state. It never creates executable work. `task create` creates a real executable Task directly under the Job; direct orchestrator-created Tasks are not wrapped in synthetic planner Tasks and have no planner-round provenance.
|
|
@@ -67,4 +78,4 @@ A zero-task autonomous Job stays idle. An autonomous Job may begin with one or m
|
|
|
67
78
|
|
|
68
79
|
Planner-produced Tasks obey this same contract, including explicit `storySize`.
|
|
69
80
|
|
|
70
|
-
A scheduled autonomous job adds cron/timezone/next-run and missed-run/concurrency policy to the trusted job marker, plus the `agents-relay:autonomous` label and one bounded time-of-day `agents-relay:next-run:*` index label. The GitHub heartbeat discovers only indexed due candidates, validates the marker's exact `nextRunAt`, claims the slot idempotently, advances the durable next run, and pulses `agents-relay:schedule-wake`. GitHub webhook delivery then wakes local Relay for planner/worker execution. While the schedule is active,
|
|
81
|
+
A scheduled autonomous job adds cron/timezone/next-run and missed-run/concurrency policy to the trusted job marker, plus the `agents-relay:autonomous` label and one bounded time-of-day `agents-relay:next-run:*` index label. The GitHub heartbeat discovers only indexed due candidates, validates the marker's exact `nextRunAt`, claims the slot idempotently, advances the durable next run, and pulses `agents-relay:schedule-wake`. GitHub webhook delivery then wakes local Relay for planner/worker execution. 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.
|