agents-relay 1.0.54 → 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
@@ -23,14 +23,21 @@ npx agents-relay reconcile --repo OWNER/REPO --pr 12 --id job-1
23
23
  npx agents-relay job show --repo OWNER/REPO --pr 12 --id job-1
24
24
  ~~~
25
25
 
26
- Mutable queued/retryable task fields can be changed in place without replacing the durable task identity. `task update` rejects `RUNNING`, `SUCCEEDED`, and `CANCELLED` tasks so active execution and terminal history cannot be rewritten. For example:
26
+ Task readiness is explicit: `DRAFT` means the durable task exists but is not executable yet; `QUEUED` means Relay may execute it now; `RUNNING` means worker execution has started. Tasks created with dependencies start `DRAFT` and become `QUEUED` when those dependencies succeed. A dependency-free task created with `--draft` stays held until `task update --queue`. Once a task has run, external waiting is represented as `BLOCKED`, never by returning to `DRAFT`.
27
+
28
+ Mutable draft/queued/retryable task fields can be changed in place without replacing the durable task identity. `task update` rejects `RUNNING`, `SUCCEEDED`, and `CANCELLED` tasks so active execution and terminal history cannot be rewritten. For example:
27
29
 
28
30
  ~~~sh
29
31
  npx agents-relay task update --repo OWNER/REPO --pr 12 --id job-1 --task-id child \
30
32
  --adapter chatgpt --provider chatgpt --model gpt-5.6-sol --reasoning high \
31
33
  --capabilities model,chatgpt,browser-harness
34
+
35
+ # Explicitly release a held DRAFT task
36
+ npx agents-relay task update --repo OWNER/REPO --pr 12 --id job-1 --task-id child --queue
32
37
  ~~~
33
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
+
34
41
  Jobs default to `fixed`. `job create` persists only the durable job and never
35
42
  creates executable tasks. The top-level orchestrator decides whether the user's
36
43
  request should start immediately and, if so, creates the first task batch
@@ -150,6 +157,6 @@ npx agents-relay task create --repo OWNER/REPO --pr 5 --id job-1 \
150
157
  --input "Research the dashboard UX and write the final report to the declared output file."
151
158
  ~~~
152
159
 
153
- 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`.
154
161
 
155
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
@@ -15,7 +15,7 @@ import { serveDashboard } from './dashboard.js';
15
15
  import { codexAndZaiUsageRegistry } from './usage.js';
16
16
  import { discoverAgents } from './registry.js';
17
17
  import { AgentObjectivePlanner, CommandObjectivePlanner } from './planner.js';
18
- import { extendRetryBudget, parseJobSchedule } from './scheduler.js';
18
+ import { extendRetryBudget, parseJobSchedule, transitionTask } from './scheduler.js';
19
19
  import { dueBuckets, processDueScheduledJobs, syncScheduleLabels } from './scheduled-jobs.js';
20
20
  import { resolveExecutionContext } from './execution-context.js';
21
21
  import { defaultWorkspaceRoot, resolveManagedProjectPath } from './workspace.js';
@@ -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,22 +89,28 @@ 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:
93
101
  npx agents-relay task <create|update|show|list|retry|cancel> [options]
94
102
 
95
103
  Actions:
96
- create Create one executable Task under an existing Job
104
+ create Create one durable Task under an existing Job
97
105
  update Update mutable Task fields
98
106
  show Show one Task
99
107
  list List Tasks for a Job
100
108
  retry Retry a failed or blocked Task
101
109
  cancel Cancel a Task`,
102
- 'task create': `Create one executable Task under an existing Job.
110
+ 'task create': `Create one durable Task under an existing Job.
103
111
 
104
112
  Required: normal Job storage options plus --input TEXT and --output task-pr|file. Codex/ChatGPT tasks also require --model MODEL.
105
- Common: --task-id ID, --adapter codex|chatgpt|orchestrator, --priority P0|P1|P2|P3, --story-size S|M|L, --deps a,b, --parent ID.
113
+ Common: --task-id ID, --adapter codex|chatgpt|orchestrator, --priority P0|P1|P2|P3, --story-size S|M|L, --deps a,b, --parent ID, --draft.\nTasks with dependencies start DRAFT; tasks without dependencies start QUEUED unless --draft is given.
106
114
  Directly-created Tasks are not wrapped in a synthetic Plan. Planner-generated Tasks are created internally by Relay from planner output.
107
115
 
108
116
  Example:
@@ -110,7 +118,7 @@ Example:
110
118
  'task update': `Update mutable durable Task fields in place.
111
119
 
112
120
  Required: --task-id ID and the normal Job storage options.
113
- Mutable while not RUNNING/SUCCEEDED/CANCELLED: --adapter, --priority, --story-size, --input, --project, --agent, --parent, --deps, --capabilities, --timeout, --max-attempts, --provider, --model, --profile, --reasoning, --cwd, --chatgpt-project, --chatgpt-tab-close-policy.
121
+ Mutable while not RUNNING/SUCCEEDED/CANCELLED: --adapter, --priority, --story-size, --input, --project, --agent, --parent, --deps, --capabilities, --timeout, --max-attempts, --provider, --model, --profile, --reasoning, --cwd, --chatgpt-project, --chatgpt-tab-close-policy, --draft, --queue.
114
122
  Use --clear-routing to remove routing metadata.
115
123
 
116
124
  Example:
@@ -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,
@@ -340,6 +348,7 @@ function workerTaskFromArgs(job, args, now) {
340
348
  const routing = adapter === 'orchestrator' ? undefined : { provider: arg(args, '--provider', 'openai'), model, profile: arg(args, '--profile') || undefined, reasoning: arg(args, '--reasoning') || undefined, cwd: arg(args, '--cwd') || undefined, projectId: arg(args, '--chatgpt-project') || undefined, decidedBy: 'cli', decidedAt: now };
341
349
  const closePolicy = hasArg(args, '--chatgpt-tab-close-policy') ? chatGptTabClosePolicy(args) : undefined;
342
350
  const size = storySize(args);
351
+ const dependencies = arg(args, '--deps').split(',').filter(Boolean);
343
352
  const timeoutMs = adapter === 'orchestrator' ? 0 : hasArg(args, '--timeout') ? integerArg(args, '--timeout', taskOverallTimeoutMs(size, routing?.reasoning)) : taskOverallTimeoutMs(size, routing?.reasoning);
344
353
  return {
345
354
  jobId: job.id,
@@ -349,7 +358,7 @@ function workerTaskFromArgs(job, args, now) {
349
358
  projectName: arg(args, '--project') || undefined,
350
359
  agentName: arg(args, '--agent') || undefined,
351
360
  parentTaskId: arg(args, '--parent') || null,
352
- dependencies: arg(args, '--deps').split(',').filter(Boolean),
361
+ dependencies,
353
362
  capabilities: arg(args, '--capabilities').split(',').filter(Boolean),
354
363
  adapter,
355
364
  chatgptTabClosePolicy: closePolicy,
@@ -359,7 +368,7 @@ function workerTaskFromArgs(job, args, now) {
359
368
  routing,
360
369
  continuation: continuation(args),
361
370
  continuationDeliveredAt: null,
362
- state: 'QUEUED',
371
+ state: hasArg(args, '--draft') || dependencies.length > 0 ? 'DRAFT' : 'QUEUED',
363
372
  attempt: 0,
364
373
  maxAttempts: Number(arg(args, '--max-attempts', '3')),
365
374
  leaseOwner: null,
@@ -385,13 +394,15 @@ async function runTaskCommand(action, args) {
385
394
  if (!id)
386
395
  throw new Error(`task ${action} requires --task-id`);
387
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})`);
388
399
  const now = new Date().toISOString();
389
400
  const task = workerTaskFromArgs(job, args, now);
390
401
  task.id = id;
391
402
  await store.appendTask(task);
392
403
  const refreshed = await store.load(job.id);
393
- if (refreshed.state !== 'RUNNING') {
394
- refreshed.state = 'RUNNING';
404
+ if (refreshed.state === 'DRAFT') {
405
+ refreshed.state = 'ACTIVE';
395
406
  refreshed.updatedAt = now;
396
407
  await store.saveJob(refreshed);
397
408
  }
@@ -415,8 +426,8 @@ async function runTaskCommand(action, args) {
415
426
  task.error = action === 'retry' ? null : 'Cancelled by user';
416
427
  task.updatedAt = new Date().toISOString();
417
428
  await store.saveTask(task);
418
- if (action === 'retry' && job.state !== 'RUNNING') {
419
- job.state = 'RUNNING';
429
+ if (action === 'retry' && job.state === 'DRAFT') {
430
+ job.state = 'ACTIVE';
420
431
  job.updatedAt = task.updatedAt;
421
432
  await store.saveJob(job);
422
433
  }
@@ -456,6 +467,16 @@ async function runTaskCommand(action, args) {
456
467
  task.chatgptTabClosePolicy = chatGptTabClosePolicy(args);
457
468
  task.timeoutMs = integerArg(args, '--timeout', task.timeoutMs);
458
469
  task.maxAttempts = integerArg(args, '--max-attempts', task.maxAttempts);
470
+ if (hasArg(args, '--draft') && hasArg(args, '--queue'))
471
+ throw new Error('--draft and --queue are mutually exclusive');
472
+ if (hasArg(args, '--draft'))
473
+ transitionTask(task, 'DRAFT');
474
+ if (hasArg(args, '--queue')) {
475
+ const taskMap = new Map(job.tasks.map(item => [item.id, item]));
476
+ if (!task.dependencies.every(dep => taskMap.get(dep)?.state === 'SUCCEEDED'))
477
+ throw new Error('Task dependencies must succeed before queueing');
478
+ transitionTask(task, 'QUEUED');
479
+ }
459
480
  if (hasArg(args, '--max-attempts') && task.maxAttempts < task.attempt)
460
481
  throw new Error('--max-attempts cannot be less than the current attempt count');
461
482
  const routingFlags = ['--provider', '--model', '--profile', '--reasoning', '--cwd', '--chatgpt-project'];
@@ -504,11 +525,11 @@ async function runContextCommand(action, args) {
504
525
  console.log(JSON.stringify(context, null, 2));
505
526
  }
506
527
  async function runJobCommand(action, args) {
507
- if (!['create', 'update', 'show', 'list'].includes(action))
528
+ if (!['create', 'update', 'show', 'list', 'merge', 'cancel'].includes(action))
508
529
  throw new Error(usage);
509
530
  const repo = repository(args);
510
531
  if (action === 'create') {
511
- const taskOnlyFlags = ['--start', '--task-id', '--input', '--adapter', '--provider', '--model', '--profile', '--reasoning', '--story-size', '--project', '--agent', '--parent', '--deps', '--capabilities', '--attachments', '--max-attempts', '--timeout', '--cwd', '--chatgpt-project', '--chatgpt-tab-close-policy', '--output', '--output-path'];
532
+ const taskOnlyFlags = ['--start', '--task-id', '--input', '--adapter', '--provider', '--model', '--profile', '--reasoning', '--story-size', '--project', '--agent', '--parent', '--deps', '--capabilities', '--attachments', '--max-attempts', '--timeout', '--cwd', '--chatgpt-project', '--chatgpt-tab-close-policy', '--output', '--output-path', '--draft', '--queue'];
512
533
  const invalid = taskOnlyFlags.find(flag => hasArg(args, flag));
513
534
  if (invalid)
514
535
  throw new Error(`job create does not accept task execution option ${invalid}; create the job first, then let the orchestrator create the initial plan/task batch`);
@@ -555,6 +576,34 @@ async function runJobCommand(action, args) {
555
576
  return;
556
577
  }
557
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
+ }
558
607
  if (hasArg(args, '--title'))
559
608
  job.title = arg(args, '--title');
560
609
  if (hasArg(args, '--objective'))
@@ -689,7 +738,7 @@ async function main() {
689
738
  }
690
739
  if (command === 'init') {
691
740
  const now = new Date().toISOString();
692
- 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: [] };
693
742
  let store;
694
743
  let file = null;
695
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.warn,.badge.BLOCKED,.badge.WAITING,.badge.QUEUED,.badge.READY,.badge.OPEN { background:#fff1d2; color:var(--warn); }
98
+ .status-pill.ok,.badge.ACTIVE,.badge.RUNNING,.badge.SUCCEEDED,.badge.COMPLETED,.badge.MERGED { background:#e5f3e9; color:var(--ok); }
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; }
@@ -220,8 +221,7 @@ function markdownField(label,input){const box=element('div','detail-group');box.
220
221
  function rememberSelection(){if(selectedJob)sessionStorage.setItem('agents-relay:selected-job',selectedJob);else sessionStorage.removeItem('agents-relay:selected-job');if(selectedTaskId)sessionStorage.setItem('agents-relay:selected-task',selectedTaskId);else sessionStorage.removeItem('agents-relay:selected-task');}
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
- function compactCounts(input){const order=['RUNNING','BLOCKED','FAILED','QUEUED','READY','WAITING','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;}
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';}
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,12 +2,12 @@ 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
- if (job.tasks.some(task => ['READY', 'QUEUED', 'WAITING'].includes(task.state)))
10
+ if (job.tasks.some(task => task.state === 'QUEUED' || (task.state === 'DRAFT' && task.dependencies.length > 0)))
11
11
  return true;
12
12
  return job.tasks.some(task => task.state === 'RUNNING' && task.leaseExpiresAt !== null && new Date(task.leaseExpiresAt).getTime() <= now);
13
13
  }
@@ -48,7 +48,7 @@ export class RepositoryWorkerPool {
48
48
  if (capacity <= 0)
49
49
  break;
50
50
  const store = new GitHubStore(this.client, job.repository, job.prNumber, this.trustedAuthors);
51
- const ready = job.tasks.filter(task => ['READY', 'QUEUED', 'WAITING'].includes(task.state)).length;
51
+ const ready = job.tasks.filter(task => task.state === 'QUEUED' || (task.state === 'DRAFT' && task.dependencies.length > 0)).length;
52
52
  const expiredRunning = job.tasks.some(task => task.state === 'RUNNING' && task.leaseExpiresAt !== null && new Date(task.leaseExpiresAt).getTime() <= Date.now());
53
53
  if (!needsLifecycleReconciliation(job))
54
54
  continue;
@@ -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 {
@@ -199,7 +201,7 @@ export class Reconciler {
199
201
  return;
200
202
  job.review = { ...job.review, outcome: 'stale', updatedAt: new Date().toISOString() };
201
203
  const staleTask = job.review.taskId ? job.tasks.find(task => task.id === job.review?.taskId) : undefined;
202
- if (staleTask && ['QUEUED', 'RUNNING', 'WAITING'].includes(staleTask.state)) {
204
+ if (staleTask && ['DRAFT', 'QUEUED', 'RUNNING'].includes(staleTask.state)) {
203
205
  this.live.get(staleTask.id)?.execution.cancel();
204
206
  transitionTask(staleTask, 'CANCELLED');
205
207
  staleTask.error = 'Review evidence became stale after the PR head changed';
@@ -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;
@@ -218,7 +220,7 @@ export class Reconciler {
218
220
  if (review?.headSha === headSha && ['started', 'approved', 'changes_required', 'blocked'].includes(review.outcome))
219
221
  return;
220
222
  const reviewTasks = job.tasks.filter(task => task.agentName === 'reviewer' && task.reviewHeadSha === headSha);
221
- if (reviewTasks.some(task => ['QUEUED', 'RUNNING', 'WAITING'].includes(task.state)))
223
+ if (reviewTasks.some(task => ['DRAFT', 'QUEUED', 'RUNNING'].includes(task.state)))
222
224
  return;
223
225
  if (reviewTasks.some(task => task.state === 'SUCCEEDED' && review?.headSha === headSha && review.outcome === 'approved'))
224
226
  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);
@@ -391,7 +391,7 @@ export class Reconciler {
391
391
  const slot = job.schedule?.currentRunSlot ? Date.parse(job.schedule.currentRunSlot) : Number.NaN;
392
392
  const scopedTasks = Number.isFinite(slot) ? job.tasks.filter(task => Date.parse(task.createdAt) >= slot) : job.tasks;
393
393
  const planners = scopedTasks.filter(task => task.kind === 'planner').sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
394
- if (planners.some(task => ['QUEUED', 'RUNNING', 'WAITING'].includes(task.state)))
394
+ if (planners.some(task => ['DRAFT', 'QUEUED', 'RUNNING'].includes(task.state)))
395
395
  return job;
396
396
  if (scopedTasks.some(task => task.state === 'FAILED' || task.state === 'BLOCKED'))
397
397
  return job;
@@ -645,7 +645,7 @@ export class Reconciler {
645
645
  }
646
646
  plannerChild(job, planner, spec) {
647
647
  const now = new Date().toISOString();
648
- return { jobId: job.id, id: spec.id, kind: 'work', priority: spec.priority ?? job.priority, storySize: spec.storySize ?? 'M', projectName: spec.projectName, agentName: spec.agentName, parentTaskId: planner.id, dependencies: spec.dependencies ?? [], capabilities: spec.capabilities ?? [], workerRequirements: spec.workerRequirements, adapter: 'codex', input: spec.input, output: spec.output, continuation: spec.continuation ?? null, continuationDeliveredAt: null, state: 'QUEUED', attempt: 0, maxAttempts: spec.maxAttempts ?? 3, leaseOwner: null, leaseExpiresAt: null, executionId: null, threadId: null, result: null, plannerResult: null, error: null, timeoutMs: spec.timeoutMs ?? taskOverallTimeoutMs(spec.storySize ?? 'M'), createdAt: now, updatedAt: now, planningDecisionId: planner.id, planningSource: planner.planningSource ?? 'model-planner', planningRound: planner.planningRound ?? 1 };
648
+ return { jobId: job.id, id: spec.id, kind: 'work', priority: spec.priority ?? job.priority, storySize: spec.storySize ?? 'M', projectName: spec.projectName, agentName: spec.agentName, parentTaskId: planner.id, dependencies: spec.dependencies ?? [], capabilities: spec.capabilities ?? [], workerRequirements: spec.workerRequirements, adapter: 'codex', input: spec.input, output: spec.output, continuation: spec.continuation ?? null, continuationDeliveredAt: null, state: (spec.dependencies?.length ?? 0) > 0 ? 'DRAFT' : 'QUEUED', attempt: 0, maxAttempts: spec.maxAttempts ?? 3, leaseOwner: null, leaseExpiresAt: null, executionId: null, threadId: null, result: null, plannerResult: null, error: null, timeoutMs: spec.timeoutMs ?? taskOverallTimeoutMs(spec.storySize ?? 'M'), createdAt: now, updatedAt: now, planningDecisionId: planner.id, planningSource: planner.planningSource ?? 'model-planner', planningRound: planner.planningRound ?? 1 };
649
649
  }
650
650
  clearExecution(taskId, executionId) {
651
651
  const live = this.live.get(taskId);
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 => ['READY', '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,26 +22,17 @@ 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
- if (['QUEUED', 'WAITING'].includes(task.state) && dependencyReady(task, map))
27
+ const ready = dependencyReady(task, map);
28
+ if (task.state === 'DRAFT' && task.dependencies.length > 0 && ready)
29
+ transitionTask(task, 'QUEUED');
30
+ else if (task.state === 'QUEUED' && !ready)
31
+ transitionTask(task, 'DRAFT');
32
+ else if (task.state === 'QUEUED' && ready && task.error !== null)
36
33
  transitionTask(task, 'QUEUED');
37
34
  }
38
- const active = scopedTasks.some(t => ['QUEUED', 'RUNNING'].includes(t.state));
39
- const blocked = scopedTasks.some(t => t.state === 'BLOCKED');
40
- const waiting = scopedTasks.some(t => t.state === 'WAITING');
41
- const failed = scopedTasks.some(t => t.state === 'FAILED');
42
- const cancelled = scopedTasks.every(t => t.state === 'CANCELLED');
43
- const next = active ? 'RUNNING' : failed ? 'FAILED' : blocked ? 'BLOCKED' : waiting ? 'WAITING' : cancelled ? 'CANCELLED' : completionGateSatisfied(job) ? 'COMPLETED' : 'WAITING';
44
- if (job.schedule && next === 'WAITING') {
35
+ if (job.schedule) {
45
36
  const planners = scopedTasks.filter(task => task.kind === 'planner');
46
37
  const latest = planners.sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id)).at(-1);
47
38
  if (latest?.state === 'SUCCEEDED' && latest.plannerResult?.objective_status === 'satisfied') {
@@ -50,13 +41,9 @@ export function schedule(job) {
50
41
  if (Number.isFinite(slot) && (!Number.isFinite(lastRun) || lastRun < slot))
51
42
  job.schedule.lastRunAt = new Date().toISOString();
52
43
  job.schedule.currentRunSlot = null;
44
+ job.updatedAt = new Date().toISOString();
53
45
  }
54
46
  }
55
- if (job.state !== next) {
56
- assertJobTransition(job.state, next);
57
- job.state = next;
58
- job.updatedAt = new Date().toISOString();
59
- }
60
47
  return job;
61
48
  }
62
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'] };
6
- const taskTransitions = { QUEUED: ['RUNNING', 'BLOCKED', 'FAILED', 'CANCELLED'], RUNNING: ['WAITING', 'BLOCKED', 'SUCCEEDED', 'FAILED', 'CANCELLED', 'QUEUED'], WAITING: ['QUEUED', 'BLOCKED', 'CANCELLED'], BLOCKED: ['QUEUED', 'CANCELLED'], SUCCEEDED: [], FAILED: ['QUEUED', 'RUNNING', 'CANCELLED'], CANCELLED: [] };
7
- export function assertJobTransition(from, to) { if (from !== to && !jobTransitions[from].includes(to))
5
+ const jobLifecycleTransitions = { DRAFT: ['ACTIVE', 'COMPLETED', 'CANCELLED'], ACTIVE: ['COMPLETED', 'CANCELLED'], COMPLETED: [], CANCELLED: ['COMPLETED'] };
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 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.54",
3
+ "version": "1.0.56",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {
@@ -30,10 +30,33 @@ Relay derives the overall execution timeout from `storySize`. If `routing.reason
30
30
 
31
31
  Raw `timeoutMs` is a compatibility/debug/hotfix override, not the normal task-production interface.
32
32
 
33
+ ## Task readiness states
34
+
35
+ Task readiness is explicit and must use these semantics:
36
+
37
+ - `DRAFT`: durable Task exists but is not executable now. A DRAFT Task with dependencies is promoted to `QUEUED` when all dependencies succeed. A DRAFT Task with no dependencies remains held until an explicit release to `QUEUED`.
38
+ - `QUEUED`: executable now; Relay may launch it immediately subject only to worker capacity/routing.
39
+ - `RUNNING`: worker execution has actually started.
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
+ - `SUCCEEDED`, `FAILED`, and `CANCELLED` are terminal outcomes, except that retry explicitly requeues a failed/blocked Task.
42
+
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
+
33
45
  ## Worker lifecycle
34
46
 
35
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.
36
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
+
37
60
  ## Job and review lifecycle
38
61
 
39
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.
@@ -44,7 +67,7 @@ Final-review routing follows the same worker-selection boundary as executable wo
44
67
 
45
68
  The public CLI resource surface is:
46
69
 
47
- - `job create|update|show|list`
70
+ - `job create|update|show|list|merge|cancel`
48
71
  - `task create|update|show|list|retry|cancel`
49
72
 
50
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.
@@ -55,4 +78,4 @@ A zero-task autonomous Job stays idle. An autonomous Job may begin with one or m
55
78
 
56
79
  Planner-produced Tasks obey this same contract, including explicit `storySize`.
57
80
 
58
- 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.