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 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 repository-wide managed PR state beside each durable Agents Relay job state so stale markers are visible instead of being mistaken for current truth.
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: 'OPEN',
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
- const hasActive = refreshed.tasks.some(item => ['QUEUED', 'RUNNING'].includes(item.state));
395
- const hasBlocked = refreshed.tasks.some(item => item.state === 'BLOCKED');
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 !== 'RUNNING') {
425
- job.state = 'RUNNING';
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: 'OPEN', continuation: continuation(args), createdAt: now, updatedAt: now, tasks: [] };
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')));if(awaitingInitialTask(job))meta.append(element('span','badge WAITING',text('AWAITING INITIAL TASK')));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));}}
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('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||'—'),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();}
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.state==='COMPLETED'&&job.githubState==='OPEN';}
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' && !item.draft && !['COMPLETED', 'CANCELLED'].includes(item.job.state)).map(item => item.job).sort(compareJobPriority); }
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
- // Create-only Jobs stay OPEN until a caller explicitly creates work.
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)))
@@ -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
- let scheduled = schedule(await this.store.load(jobId));
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 (prState === 'MERGED') {
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 (prState === 'MERGED') {
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 !== 'COMPLETED' || !headSha)
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
+ }
@@ -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
- const active = job.state === 'RUNNING' || job.tasks.some(task => ['QUEUED', 'RUNNING'].includes(task.state));
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 { assertJobTransition, assertTaskTransition } from './types.js';
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
- const active = scopedTasks.some(t => ['QUEUED', 'RUNNING'].includes(t.state));
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 jobTransitions = { OPEN: ['RUNNING', 'WAITING', 'BLOCKED', 'COMPLETED', 'CANCELLED'], RUNNING: ['OPEN', 'WAITING', 'BLOCKED', 'COMPLETED', 'FAILED', 'CANCELLED'], WAITING: ['RUNNING', 'BLOCKED', 'COMPLETED', 'CANCELLED'], BLOCKED: ['RUNNING', 'COMPLETED', 'CANCELLED'], COMPLETED: ['RUNNING', 'CANCELLED'], FAILED: ['RUNNING', 'COMPLETED', 'CANCELLED'], CANCELLED: ['RUNNING', 'COMPLETED'] };
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 assertJobTransition(from, to) { if (from !== to && !jobTransitions[from].includes(to))
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "1.0.55",
3
+ "version": "1.0.56",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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` remains a Job state for an active Job that is intentionally awaiting a future trigger, schedule slot, or condition.
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, each satisfied run returns the job to `WAITING` instead of entering final-review/auto-merge; older-cycle failures are historical evidence and do not block a later slot.
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.