agents-relay 1.0.67 → 1.0.69

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
@@ -49,6 +49,10 @@ npx agents-relay task update --repo OWNER/REPO --pr 12 --id job-1 --task-id chil
49
49
 
50
50
  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.
51
51
 
52
+ Lifecycle transition authority lives in the directly renderable canonical sources [`workflow/job-lifecycle.mmd`](workflow/job-lifecycle.mmd) and [`workflow/task-lifecycle.mmd`](workflow/task-lifecycle.mmd); see [`docs/workflow-engine.md`](docs/workflow-engine.md) for the executable Mermaid profile and runtime boundary.
53
+
54
+ Graph-engine rollout is confidence-gated with `AGENTS_RELAY_WORKFLOW_MODE=legacy|shadow|graph` (default `legacy`). `shadow` keeps legacy authoritative while comparing the Mermaid decision and exposing `{mode,matches,mismatches}` at `/api/workflow-rollout`; `graph` is explicit opt-in until the shadow cutover gate is approved.
55
+
52
56
  Job objectives and Task inputs are Markdown. Prefer `--objective-file` / `--input-file` (use `-` for stdin) for durable long-form context; inline `--objective` / `--input` remain for short/simple text and are mutually exclusive with the file variants. When a diagram materially clarifies the work, put Mermaid first and then Markdown detail. A newly created Job must capture enough context to finish independently of the originating chat; its objective becomes the default PR body and objective updates keep the PR body synchronized.
53
57
 
54
58
  Jobs default to `fixed`. `job create` persists only the durable job and never
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, transitionTask } from './scheduler.js';
18
+ import { extendRetryBudget, parseJobSchedule, transitionJobEvent, transitionTaskEvent } 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';
@@ -419,8 +419,7 @@ async function runTaskCommand(action, args) {
419
419
  await store.appendTask(task);
420
420
  const refreshed = await store.load(job.id);
421
421
  if (refreshed.state === 'DRAFT') {
422
- refreshed.state = 'ACTIVE';
423
- refreshed.updatedAt = now;
422
+ await transitionJobEvent(refreshed, 'activate');
424
423
  await store.saveJob(refreshed);
425
424
  }
426
425
  const durableTask = (await store.load(job.id)).tasks.find(item => item.id === task.id) ?? task;
@@ -439,13 +438,14 @@ async function runTaskCommand(action, args) {
439
438
  if (action === 'retry' || action === 'cancel') {
440
439
  if (action === 'retry')
441
440
  task.maxAttempts = extendRetryBudget(task);
442
- task.state = action === 'retry' ? 'QUEUED' : 'CANCELLED';
441
+ await transitionTaskEvent(task, action === 'retry' ? 'retry' : 'cancel');
443
442
  task.error = action === 'retry' ? null : 'Cancelled by user';
444
- task.updatedAt = new Date().toISOString();
443
+ task.leaseOwner = null;
444
+ task.leaseExpiresAt = null;
445
+ task.executionId = null;
445
446
  await store.saveTask(task);
446
447
  if (action === 'retry' && job.state === 'DRAFT') {
447
- job.state = 'ACTIVE';
448
- job.updatedAt = task.updatedAt;
448
+ await transitionJobEvent(job, 'activate');
449
449
  await store.saveJob(job);
450
450
  }
451
451
  await saveLocal(localFile, job);
@@ -486,13 +486,16 @@ async function runTaskCommand(action, args) {
486
486
  task.maxAttempts = integerArg(args, '--max-attempts', task.maxAttempts);
487
487
  if (hasArg(args, '--draft') && hasArg(args, '--queue'))
488
488
  throw new Error('--draft and --queue are mutually exclusive');
489
- if (hasArg(args, '--draft'))
490
- transitionTask(task, 'DRAFT');
489
+ if (hasArg(args, '--draft') && task.state === 'QUEUED')
490
+ await transitionTaskEvent(task, 'hold');
491
491
  if (hasArg(args, '--queue')) {
492
492
  const taskMap = new Map(job.tasks.map(item => [item.id, item]));
493
493
  if (!task.dependencies.every(dep => taskMap.get(dep)?.state === 'SUCCEEDED'))
494
494
  throw new Error('Task dependencies must succeed before queueing');
495
- transitionTask(task, 'QUEUED');
495
+ if (task.state === 'DRAFT')
496
+ await transitionTaskEvent(task, 'release');
497
+ else if (task.state === 'FAILED' || task.state === 'BLOCKED')
498
+ await transitionTaskEvent(task, 'retry');
496
499
  }
497
500
  if (hasArg(args, '--max-attempts') && task.maxAttempts < task.attempt)
498
501
  throw new Error('--max-attempts cannot be less than the current attempt count');
@@ -603,8 +606,7 @@ async function runJobCommand(action, args) {
603
606
  throw new Error(`Cannot merge cancelled job ${job.id}`);
604
607
  const headSha = await store.headSha();
605
608
  await store.mergePullRequest(headSha ?? undefined);
606
- job.state = 'COMPLETED';
607
- job.updatedAt = new Date().toISOString();
609
+ await transitionJobEvent(job, 'pr.merged');
608
610
  await store.saveJob(job);
609
611
  console.log(JSON.stringify(await store.load(id), null, 2));
610
612
  return;
@@ -613,15 +615,15 @@ async function runJobCommand(action, args) {
613
615
  if (job.state === 'COMPLETED')
614
616
  throw new Error(`Cannot cancel completed job ${job.id}`);
615
617
  for (const task of job.tasks.filter(item => !['SUCCEEDED', 'CANCELLED'].includes(item.state))) {
616
- transitionTask(task, 'CANCELLED');
618
+ await transitionTaskEvent(task, 'cancel');
617
619
  task.error = 'Job cancelled';
618
620
  task.leaseOwner = null;
619
621
  task.leaseExpiresAt = null;
622
+ task.executionId = null;
620
623
  await store.saveTask(task);
621
624
  }
622
625
  await store.closePullRequest();
623
- job.state = 'CANCELLED';
624
- job.updatedAt = new Date().toISOString();
626
+ await transitionJobEvent(job, 'cancel');
625
627
  await store.saveJob(job);
626
628
  console.log(JSON.stringify(await store.load(id), null, 2));
627
629
  return;
@@ -674,23 +676,22 @@ export function dashboardJobAction(store, reconciler, action) {
674
676
  if (action === 'start') {
675
677
  if (job.state !== 'DRAFT')
676
678
  throw new Error(`Job ${job.id} is not startable (${job.state})`);
677
- job.state = 'ACTIVE';
678
- job.updatedAt = new Date().toISOString();
679
+ await transitionJobEvent(job, 'activate');
679
680
  await store.saveJob(job);
680
681
  }
681
682
  else if (action === 'cancel') {
682
683
  if (job.state === 'COMPLETED' || job.state === 'CANCELLED')
683
684
  throw new Error(`Job ${job.id} is not cancellable (${job.state})`);
684
685
  for (const task of job.tasks.filter(item => !['SUCCEEDED', 'CANCELLED'].includes(item.state))) {
685
- transitionTask(task, 'CANCELLED');
686
+ await transitionTaskEvent(task, 'cancel');
686
687
  task.error = 'Job cancelled';
687
688
  task.leaseOwner = null;
688
689
  task.leaseExpiresAt = null;
690
+ task.executionId = null;
689
691
  await store.saveTask(task);
690
692
  }
691
693
  await store.closePullRequest?.();
692
- job.state = 'CANCELLED';
693
- job.updatedAt = new Date().toISOString();
694
+ await transitionJobEvent(job, 'cancel');
694
695
  await store.saveJob(job);
695
696
  return await store.load(job.id);
696
697
  }
package/dist/dashboard.js CHANGED
@@ -5,6 +5,7 @@ import { join } from 'node:path';
5
5
  import { readUsageSnapshot } from './usage.js';
6
6
  import { deriveJobRuntimeStatus } from './runtime-status.js';
7
7
  import { DeliveryDeduper, parseGitHubWebhook, verifyGitHubWebhook } from './github-webhook.js';
8
+ import { workflowRolloutSnapshot } from './workflow/rollout.js';
8
9
  const GITHUB_REPOSITORY_PATTERN = /^([A-Za-z0-9](?:[A-Za-z0-9-]{0,36}[A-Za-z0-9])?)\/([A-Za-z0-9._-]{1,100})$/;
9
10
  const SSE_HEARTBEAT_MS = 15000;
10
11
  const EVENTS_HISTORY_FILE = process.env.EVENTS_BUS_DATA_DIR ? join(process.env.EVENTS_BUS_DATA_DIR, 'events.jsonl') : join(homedir(), 'Library', 'Application Support', 'EventsBus', 'events.jsonl');
@@ -95,6 +96,9 @@ export const dashboardHtml = `<!doctype html>
95
96
  .job-title { font-weight:750; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
96
97
  .job-meta { display:flex; gap:.4rem; flex-wrap:wrap; color:var(--muted); font-size:.7rem; }
97
98
  .job-meta span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
99
+ .job-last-line { display:flex; align-items:center; justify-content:space-between; gap:.5rem; min-width:0; }
100
+ .job-last-line .subtle:first-child { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
101
+ .job-last-line .subtle:last-child { flex:none; white-space:nowrap; }
98
102
  .job-primary { display:flex; align-items:center; justify-content:space-between; gap:.45rem; min-width:0; }
99
103
  .job-status-line { display:flex; align-items:center; gap:.3rem; min-width:0; white-space:nowrap; }
100
104
  .job-status-line .badge { flex:0 0 auto; }
@@ -249,7 +253,8 @@ function actionEnabled(job,action){if(action==='start')return job.state==='DRAFT
249
253
  function actionButton(job,action,label,icon){const button=document.createElement('button');button.type='button';button.className='job-action';button.textContent=icon;button.title=label;button.setAttribute('aria-label',label);button.disabled=!actionEnabled(job,action);button.addEventListener('click',event=>{event.stopPropagation();void runJobAction(job,action,button);});return button;}
250
254
  async function runJobAction(job,action,button){button.disabled=true;try{const response=await fetch('/api/job-action?id='+encodeURIComponent(job.id)+'&action='+encodeURIComponent(action),{method:'POST'});if(!response.ok)throw new Error(response.status+' '+response.statusText);await refresh(true);}catch(error){button.title=labelForAction(action)+': '+error.message;}finally{renderAll();}}
251
255
  function labelForAction(action){return action==='start'?'Start':action==='reconcile'?'Reconcile':'Cancel';}
252
- function renderJobList(){jobList.replaceChildren();const visible=filteredJobs();if(!jobs.length){jobList.append(element('li','empty',text('No durable jobs discovered.')));return;}if(!visible.length){jobList.append(element('li','empty',text('No jobs match the selected repo.')));return;}for(const job of visible){const row=element('div','job-row'+(isCompleteUnmerged(job)?' complete-unmerged':''));row.setAttribute('aria-current',String(job.id===selectedJob));const content=element('div','job-content');const select=document.createElement('button');select.type='button';select.className='job-button';select.setAttribute('aria-current',String(job.id===selectedJob));select.title=job.title||job.id;select.addEventListener('click',()=>selectJob(job.id));select.append(element('span','job-title',text('PR#'+value(job.prNumber)+': '+value(job.title||job.id))));const states=element('span','job-status-line');states.append(element('span','badge '+value(job.state),text(value(job.state))),element('span','badge '+value(job.runtimeStatus||'IDLE'),text(value(job.runtimeStatus||'IDLE'))),element('span','badge '+value(job.githubState||'UNKNOWN'),text(value(job.githubState||'UNKNOWN'))));const actions=element('span','job-actions');actions.append(actionButton(job,'start','Start','▶'),actionButton(job,'reconcile','Reconcile','↻'),actionButton(job,'cancel','Cancel','×'));const primary=element('div','job-primary');primary.append(states,actions);const meta=element('span','job-meta');meta.append(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))));content.append(select,primary,meta,element('span','subtle',text(compactCounts(counts(job.tasks)))));row.append(content);jobList.append(element('li','',row));}}
256
+ function repoName(repository){if(typeof repository!=='string'||!repository)return 'unknown-repo';const parts=repository.split('/').filter(Boolean);return parts.at(-1)||repository;}
257
+ function renderJobList(){jobList.replaceChildren();const visible=filteredJobs();if(!jobs.length){jobList.append(element('li','empty',text('No durable jobs discovered.')));return;}if(!visible.length){jobList.append(element('li','empty',text('No jobs match the selected repo.')));return;}for(const job of visible){const row=element('div','job-row'+(isCompleteUnmerged(job)?' complete-unmerged':''));row.setAttribute('aria-current',String(job.id===selectedJob));const content=element('div','job-content');const select=document.createElement('button');select.type='button';select.className='job-button';select.setAttribute('aria-current',String(job.id===selectedJob));select.title=job.title||job.id;select.addEventListener('click',()=>selectJob(job.id));select.append(element('span','job-title',text(repoName(job.repository)+'/PR#'+value(job.prNumber)+': '+value(job.title||job.id))));const states=element('span','job-status-line');states.append(element('span','badge '+value(job.state),text(value(job.state))),element('span','badge '+value(job.runtimeStatus||'IDLE'),text(value(job.runtimeStatus||'IDLE'))),element('span','badge '+value(job.githubState||'UNKNOWN'),text(value(job.githubState||'UNKNOWN'))));const actions=element('span','job-actions');actions.append(actionButton(job,'start','Start','▶'),actionButton(job,'reconcile','Reconcile','↻'),actionButton(job,'cancel','Cancel','×'));const primary=element('div','job-primary');primary.append(states,actions);const meta=element('span','job-meta');meta.append(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')));const lastLine=element('div','job-last-line');lastLine.append(element('span','subtle',text(compactCounts(counts(job.tasks)))),element('span','subtle',text(relativeTime(job.updatedAt))));content.append(select,primary,meta,lastLine);row.append(content);jobList.append(element('li','',row));}}
253
258
  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;}
254
259
  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));}
255
260
  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;}
@@ -492,6 +497,11 @@ export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobI
492
497
  res.end(JSON.stringify(events));
493
498
  return;
494
499
  }
500
+ if (req.url === '/api/workflow-rollout') {
501
+ res.writeHead(200, { 'content-type': 'application/json' });
502
+ res.end(JSON.stringify(workflowRolloutSnapshot()));
503
+ return;
504
+ }
495
505
  if (req.url === '/api/status') {
496
506
  const status = statusProvider ? statusProvider() : { limited: false, cooldownUntil: null };
497
507
  res.writeHead(200, { 'content-type': 'application/json' });
package/dist/markers.js CHANGED
@@ -34,7 +34,7 @@ function contentBeforeDelimiter(body, heading, end) {
34
34
  }
35
35
  export function renderJobComment(job) {
36
36
  const objective = job.objective?.trim() || '_No objective recorded._';
37
- const { objective: _objective, tasks: _tasks, ...metadata } = job;
37
+ const { objective: _objective, description: _description, tasks: _tasks, ...metadata } = job;
38
38
  return `${marker(JOB_MARKER, metadata)}
39
39
  # Agents Relay job: ${job.title}
40
40
 
@@ -70,7 +70,7 @@ export function parseJob(body) {
70
70
  const job = parseMarker(body, JOB_MARKER);
71
71
  if (!job)
72
72
  return null;
73
- const objective = job.objective ?? contentBeforeDelimiter(body, '\n## Objective\n\n', JOB_OBJECTIVE_END) ?? markdownSection(body, '\n## Objective\n');
73
+ const objective = job.objective ?? job.description ?? contentBeforeDelimiter(body, '\n## Objective\n\n', JOB_OBJECTIVE_END) ?? markdownSection(body, '\n## Objective\n');
74
74
  const parsed = { ...job, objective };
75
75
  if (parsed.executionMode === undefined)
76
76
  return { ...parsed, executionMode: 'fixed' };
package/dist/pool.js CHANGED
@@ -7,9 +7,9 @@ export function needsLifecycleReconciliation(job, now = Date.now()) {
7
7
  // DRAFT create-only Jobs stay dormant until a caller explicitly creates work.
8
8
  if (job.tasks.length === 0)
9
9
  return false;
10
- if (job.tasks.some(task => task.state === 'QUEUED' || (task.state === 'DRAFT' && task.dependencies.length > 0)))
10
+ if (job.tasks.some(task => (task.state === 'QUEUED' && task.executionId === null) || (task.state === 'DRAFT' && task.dependencies.length > 0)))
11
11
  return true;
12
- return job.tasks.some(task => task.state === 'RUNNING' && task.leaseExpiresAt !== null && new Date(task.leaseExpiresAt).getTime() <= now);
12
+ return job.tasks.some(task => ['RUNNING', 'QUEUED'].includes(task.state) && task.executionId !== null && task.leaseExpiresAt !== null && new Date(task.leaseExpiresAt).getTime() <= now);
13
13
  }
14
14
  export function terminalManagedJobs(items) { return items.filter(item => item.githubState !== 'OPEN' && ((item.githubState === 'MERGED' && item.job.state !== 'COMPLETED') || (item.githubState === 'CLOSED' && item.job.state !== 'CANCELLED'))).map(item => item.job).sort(compareJobPriority); }
15
15
  export async function aggregateManagedGitHubJobs(client, repositories, trustedAuthors) {
@@ -48,8 +48,8 @@ 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 => task.state === 'QUEUED' || (task.state === 'DRAFT' && task.dependencies.length > 0)).length;
52
- const expiredRunning = job.tasks.some(task => task.state === 'RUNNING' && task.leaseExpiresAt !== null && new Date(task.leaseExpiresAt).getTime() <= Date.now());
51
+ const ready = job.tasks.filter(task => (task.state === 'QUEUED' && task.executionId === null) || (task.state === 'DRAFT' && task.dependencies.length > 0)).length;
52
+ const expiredRunning = job.tasks.some(task => ['RUNNING', 'QUEUED'].includes(task.state) && task.executionId !== null && task.leaseExpiresAt !== null && new Date(task.leaseExpiresAt).getTime() <= Date.now());
53
53
  if (!needsLifecycleReconciliation(job))
54
54
  continue;
55
55
  const allowance = Math.max(1, Math.min(capacity, ready || (expiredRunning ? 1 : 0) || 1));
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { storySizeTimeoutMs, taskOverallTimeoutMs, chatGptStartAckTimeoutMs } from './types.js';
3
- import { dependencyReady, schedule, transitionTask } from './scheduler.js';
3
+ import { dependencyReady, schedule, transitionJobEvent, transitionTaskEvent } from './scheduler.js';
4
4
  import { implementationComplete } from './runtime-status.js';
5
5
  import { LaunchFailure, StartAckTimeout } from './adapters.js';
6
6
  import { eventFor } from './events.js';
@@ -125,16 +125,23 @@ export class Reconciler {
125
125
  for (const task of job.tasks.filter(item => !['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(item.state))) {
126
126
  this.live.get(task.id)?.execution.cancel();
127
127
  this.plannerLive.get(task.id)?.controller.abort();
128
- transitionTask(task, 'CANCELLED');
128
+ await transitionTaskEvent(task, 'cancel');
129
129
  task.error = reason;
130
130
  task.leaseOwner = null;
131
131
  task.leaseExpiresAt = null;
132
+ task.executionId = null;
132
133
  await this.store.saveTask(task);
133
134
  }
134
135
  const terminalState = prState === 'MERGED' ? 'COMPLETED' : 'CANCELLED';
135
136
  if (job.state !== terminalState) {
136
- job.state = terminalState;
137
- job.updatedAt = new Date().toISOString();
137
+ if (!['DRAFT', 'ACTIVE', 'COMPLETED', 'CANCELLED'].includes(job.state)) {
138
+ // Compatibility repair for legacy records that persisted derived runtime status as lifecycle.
139
+ job.state = terminalState;
140
+ job.updatedAt = new Date().toISOString();
141
+ }
142
+ else {
143
+ await transitionJobEvent(job, prState === 'MERGED' ? 'pr.merged' : 'pr.closed', prState === 'CLOSED' ? { notMerged: true } : {});
144
+ }
138
145
  await this.store.saveJob(job);
139
146
  }
140
147
  return this.store.load(jobId);
@@ -167,11 +174,11 @@ export class Reconciler {
167
174
  for (const task of scheduled.tasks.filter(item => item.state === 'SUCCEEDED' && (item.continuation || scheduled.continuation) && !item.continuationDeliveredAt))
168
175
  this.detach(this.deliverContinuation(scheduled, task), scheduled.id, task.id, 'continuation');
169
176
  const capacity = Math.max(0, (this.options.maxConcurrent ?? 4) - this.live.size - this.plannerLive.size);
170
- for (const task of scheduled.tasks.filter(t => t.state === 'QUEUED' && dependencyReady(t, new Map(scheduled.tasks.map(item => [item.id, item])))).sort((a, b) => compareTaskPriority(a, b, scheduled.priority ?? 'P2')).slice(0, capacity)) {
177
+ for (const task of scheduled.tasks.filter(t => t.state === 'QUEUED' && t.executionId === null && dependencyReady(t, new Map(scheduled.tasks.map(item => [item.id, item])))).sort((a, b) => compareTaskPriority(a, b, scheduled.priority ?? 'P2')).slice(0, capacity)) {
171
178
  if (task.kind === 'planner')
172
179
  await this.launchPlanner(scheduled, task);
173
180
  else if (task.adapter === 'orchestrator') {
174
- transitionTask(task, 'RUNNING');
181
+ await transitionTaskEvent(task, 'task.started');
175
182
  task.leaseOwner = null;
176
183
  task.leaseExpiresAt = null;
177
184
  task.executionId = null;
@@ -204,7 +211,7 @@ export class Reconciler {
204
211
  const staleTask = job.review.taskId ? job.tasks.find(task => task.id === job.review?.taskId) : undefined;
205
212
  if (staleTask && ['DRAFT', 'QUEUED', 'RUNNING'].includes(staleTask.state)) {
206
213
  this.live.get(staleTask.id)?.execution.cancel();
207
- transitionTask(staleTask, 'CANCELLED');
214
+ await transitionTaskEvent(staleTask, 'cancel');
208
215
  staleTask.error = 'Review evidence became stale after the PR head changed';
209
216
  staleTask.leaseOwner = null;
210
217
  staleTask.leaseExpiresAt = null;
@@ -303,15 +310,6 @@ export class Reconciler {
303
310
  return;
304
311
  const evidence = { summary: event.message || `Review ${outcome}`, data: { ...(event.data ?? {}), review: reviewData ?? { headSha }, headSha, outcome } };
305
312
  job.review = { outcome, headSha, taskId: task.id, evidence, updatedAt: event.timestamp || new Date().toISOString() };
306
- if (outcome !== 'started') {
307
- task.state = 'SUCCEEDED';
308
- task.result = evidence;
309
- task.error = null;
310
- task.leaseOwner = null;
311
- task.leaseExpiresAt = null;
312
- task.updatedAt = job.review.updatedAt;
313
- await this.store.saveTask(task);
314
- }
315
313
  if (outcome === 'changes_required') {
316
314
  const rawIssues = Array.isArray(reviewData?.issues) ? reviewData.issues : [];
317
315
  const issues = rawIssues.map(item => {
@@ -335,7 +333,7 @@ export class Reconciler {
335
333
  agentName: 'review-fix', parentTaskId: task.id, dependencies: [task.id], capabilities: ['implementation'],
336
334
  adapter: 'codex', input, output: { kind: 'task_pr' },
337
335
  routing: { provider: 'openai', model: 'gpt-5.6-luna', reasoning: 'medium', decidedBy: 'review-fix', decidedAt: now },
338
- continuation: null, continuationDeliveredAt: null, state: 'QUEUED', attempt: 0, maxAttempts: 3,
336
+ continuation: null, continuationDeliveredAt: null, state: 'DRAFT', attempt: 0, maxAttempts: 3,
339
337
  leaseOwner: null, leaseExpiresAt: null, executionId: null, threadId: null, result: null, plannerResult: null,
340
338
  error: null, timeoutMs: storySizeTimeoutMs('M'), createdAt: now, updatedAt: now
341
339
  };
@@ -353,7 +351,7 @@ export class Reconciler {
353
351
  }
354
352
  }
355
353
  async recoverLostExecutions(job, now) {
356
- for (const task of job.tasks.filter(item => item.state === 'RUNNING')) {
354
+ for (const task of job.tasks.filter(item => item.state === 'RUNNING' || (item.state === 'QUEUED' && item.executionId !== null))) {
357
355
  const expired = task.leaseExpiresAt !== null && new Date(task.leaseExpiresAt).getTime() <= now;
358
356
  const ownerLost = task.leaseOwner !== null && task.leaseOwner !== this.options.owner;
359
357
  if (!expired && !ownerLost)
@@ -365,17 +363,18 @@ export class Reconciler {
365
363
  planner.controller.abort();
366
364
  this.plannerLive.delete(task.id);
367
365
  }
366
+ const failedEvent = task.state === 'RUNNING' ? 'task.failed' : 'fail';
367
+ await transitionTaskEvent(task, failedEvent);
368
+ task.error = expired ? 'Lease expired' : 'Execution owner restarted';
369
+ task.leaseOwner = null;
370
+ task.leaseExpiresAt = null;
371
+ task.executionId = null;
368
372
  if (task.attempt < task.maxAttempts) {
369
- transitionTask(task, 'QUEUED');
370
- task.leaseOwner = null;
371
- task.leaseExpiresAt = null;
373
+ await transitionTaskEvent(task, 'retry');
372
374
  await this.store.saveTask(task);
373
375
  }
374
376
  else {
375
- task.error = expired ? 'Lease expired after maximum attempts' : 'Execution owner restarted after maximum attempts';
376
- task.leaseOwner = null;
377
- task.leaseExpiresAt = null;
378
- transitionTask(task, 'FAILED');
377
+ task.error += ' after maximum attempts';
379
378
  await this.store.saveTask(task);
380
379
  await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'task.failed', 'failed', task.error, 'user', { reason: expired ? 'lease_expired' : 'execution_owner_lost' }));
381
380
  }
@@ -445,13 +444,13 @@ export class Reconciler {
445
444
  async launch(job, task) {
446
445
  const adapter = this.options.adapters.find(x => x.name === task.adapter);
447
446
  if (!adapter) {
448
- transitionTask(task, 'FAILED');
447
+ await transitionTaskEvent(task, 'fail');
449
448
  task.error = `No adapter ${task.adapter}`;
450
449
  await this.store.saveTask(task);
451
450
  return;
452
451
  }
453
452
  if (['codex', 'chatgpt'].includes(task.adapter) && !this.options.eventBus) {
454
- transitionTask(task, 'BLOCKED');
453
+ await transitionTaskEvent(task, 'block');
455
454
  task.error = 'Model-backed tasks require an event bus for authoritative lifecycle state';
456
455
  await this.store.saveTask(task);
457
456
  return;
@@ -464,7 +463,7 @@ export class Reconciler {
464
463
  workerInput = managedWorkerInput(job, task, executionContext);
465
464
  }
466
465
  catch (error) {
467
- transitionTask(task, 'BLOCKED');
466
+ await transitionTaskEvent(task, 'block');
468
467
  task.error = error instanceof Error ? error.message : String(error);
469
468
  await this.store.saveTask(task);
470
469
  return;
@@ -481,7 +480,8 @@ export class Reconciler {
481
480
  return;
482
481
  const leaseMs = Math.max(this.options.leaseMs ?? 300000, freshTask.timeoutMs);
483
482
  const startedAt = (this.options.now ?? new Date()).getTime();
484
- transitionTask(freshTask, 'RUNNING');
483
+ if (freshTask.executionId !== null)
484
+ return;
485
485
  freshTask.attempt += 1;
486
486
  freshTask.leaseOwner = this.options.owner;
487
487
  freshTask.leaseExpiresAt = new Date(startedAt + leaseMs).toISOString();
@@ -574,13 +574,13 @@ export class Reconciler {
574
574
  if (!planner) {
575
575
  task.attempt = task.maxAttempts;
576
576
  task.error = 'No objective planner configured';
577
- transitionTask(task, 'FAILED');
577
+ await transitionTaskEvent(task, 'fail');
578
578
  await this.store.saveTask(task);
579
579
  await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'planner.failed', 'failed', `Planner ${task.id}: ${task.error}`, 'user'));
580
580
  return;
581
581
  }
582
582
  const startedAt = (this.options.now ?? new Date()).getTime();
583
- transitionTask(task, 'RUNNING');
583
+ await transitionTaskEvent(task, 'task.started');
584
584
  task.attempt += 1;
585
585
  task.leaseOwner = this.options.owner;
586
586
  task.leaseExpiresAt = new Date(startedAt + Math.max(this.options.leaseMs ?? 300000, task.timeoutMs)).toISOString();
@@ -618,7 +618,10 @@ export class Reconciler {
618
618
  task.error = error.message;
619
619
  task.leaseOwner = null;
620
620
  task.leaseExpiresAt = null;
621
- transitionTask(task, task.attempt < task.maxAttempts ? 'QUEUED' : 'FAILED');
621
+ task.executionId = null;
622
+ await transitionTaskEvent(task, 'task.failed');
623
+ if (task.attempt < task.maxAttempts)
624
+ await transitionTaskEvent(task, 'retry');
622
625
  await this.store.saveTask(task);
623
626
  await this.emit(eventFor(job.id, task.id, task.parentTaskId, task.state === 'FAILED' ? 'planner.failed' : 'planner.retry', task.state === 'FAILED' ? 'failed' : 'queued', `Planner ${task.id}: ${error.message}`, 'user'));
624
627
  this.detach(this.reconcile(jobId), jobId, task.id, 'planner retry reconciliation');
@@ -638,7 +641,8 @@ export class Reconciler {
638
641
  task.error = null;
639
642
  task.leaseOwner = null;
640
643
  task.leaseExpiresAt = null;
641
- transitionTask(task, 'SUCCEEDED');
644
+ task.executionId = null;
645
+ await transitionTaskEvent(task, 'task.completed');
642
646
  await this.store.saveTask(task);
643
647
  for (const child of children)
644
648
  if (!job.tasks.some(item => item.id === child.id))
@@ -656,9 +660,12 @@ export class Reconciler {
656
660
  task.error = message;
657
661
  task.leaseOwner = null;
658
662
  task.leaseExpiresAt = null;
659
- transitionTask(task, task.attempt < task.maxAttempts ? 'QUEUED' : 'FAILED');
663
+ task.executionId = null;
664
+ await transitionTaskEvent(task, 'task.failed');
665
+ if (task.attempt < task.maxAttempts)
666
+ await transitionTaskEvent(task, 'retry');
660
667
  await this.store.saveTask(task);
661
- await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'planner.failed', 'failed', `Planner ${task.id}: ${message}`, 'user'));
668
+ await this.emit(eventFor(job.id, task.id, task.parentTaskId, task.state === 'FAILED' ? 'planner.failed' : 'planner.retry', task.state === 'FAILED' ? 'failed' : 'queued', `Planner ${task.id}: ${message}`, 'user'));
662
669
  }
663
670
  }
664
671
  this.detach(this.reconcile(jobId), jobId, task.id, 'planner follow-up reconciliation');
@@ -686,19 +693,29 @@ export class Reconciler {
686
693
  if (sourceAgent === 'relay' || sourceComponent === 'agents-relay')
687
694
  return;
688
695
  const live = this.live.get(event.task_id);
689
- if (live && event.job_id === live.jobId) {
690
- if (event.timestamp && new Date(event.timestamp).getTime() < live.launchBarrier)
691
- return;
692
- live.workerStarted = true;
696
+ if (live && event.job_id === live.jobId && event.timestamp && new Date(event.timestamp).getTime() < live.launchBarrier)
693
697
  return;
694
- }
695
698
  const job = await this.store.load(event.job_id);
696
699
  const task = job.tasks.find(item => item.id === event.task_id);
697
- if (!task || task.state !== 'FAILED' || !this.isRecoverableWatchdogFailure(task))
700
+ if (!task)
701
+ return;
702
+ const claimedExecution = task.state === 'QUEUED' && task.executionId !== null && task.leaseOwner !== null;
703
+ const exactLiveExecution = live && event.job_id === live.jobId && task.executionId === live.executionId;
704
+ if ((exactLiveExecution || claimedExecution) && task.state === 'QUEUED') {
705
+ await transitionTaskEvent(task, 'task.started');
706
+ task.failureReason = undefined;
707
+ task.error = null;
708
+ task.updatedAt = event.timestamp || new Date().toISOString();
709
+ await this.store.saveTask(task);
710
+ if (live)
711
+ live.workerStarted = true;
712
+ return;
713
+ }
714
+ if (task.state !== 'FAILED' || !this.isRecoverableWatchdogFailure(task))
698
715
  return;
699
716
  if (event.timestamp && new Date(event.timestamp).getTime() < new Date(task.updatedAt).getTime())
700
717
  return;
701
- transitionTask(task, 'RUNNING');
718
+ await transitionTaskEvent(task, 'reconcile.started');
702
719
  task.failureReason = undefined;
703
720
  task.error = null;
704
721
  task.updatedAt = event.timestamp || new Date().toISOString();
@@ -728,23 +745,35 @@ export class Reconciler {
728
745
  cleanup.unref();
729
746
  const job = await this.store.load(jobId);
730
747
  const task = job.tasks.find(item => item.id === taskId);
731
- if (!task || task.executionId !== executionId || task.state !== 'RUNNING')
748
+ if (!task || task.executionId !== executionId || !['QUEUED', 'RUNNING'].includes(task.state))
732
749
  return;
733
750
  const event = eventFor(jobId, taskId, task.parentTaskId, 'task.failed', 'failed', `Worker ${phase} failed: ${message}`, 'user', { phase, reason: phase === 'start-ack' ? 'worker_start_timeout' : undefined }, { agent: 'relay', component: 'agents-relay', watchdog: true });
734
- await this.applyTerminalEvent(event);
751
+ if (task.state === 'QUEUED') {
752
+ this.clearExecution(task.id, executionId);
753
+ task.error = event.message;
754
+ task.failureReason = phase === 'start-ack' ? 'worker_start_timeout' : undefined;
755
+ task.leaseOwner = null;
756
+ task.leaseExpiresAt = null;
757
+ await transitionTaskEvent(task, 'fail');
758
+ await this.store.saveTask(task);
759
+ }
760
+ else {
761
+ await this.applyTerminalEvent(event);
762
+ }
735
763
  await this.emit(event);
736
764
  }
737
765
  async runtimeSettled(jobId, taskId, executionId, result, error) {
738
766
  const live = this.live.get(taskId);
739
- const workerStarted = live?.executionId === executionId && live.workerStarted;
767
+ const liveWorkerStarted = live?.executionId === executionId && live.workerStarted;
740
768
  if (live?.executionId === executionId)
741
769
  this.live.delete(taskId);
742
770
  const job = await this.store.load(jobId);
743
771
  const task = job.tasks.find(item => item.id === taskId);
772
+ const workerStarted = liveWorkerStarted || task?.state === 'RUNNING';
744
773
  if (!task || task.executionId !== executionId)
745
774
  return;
746
775
  await this.emit(eventFor(jobId, taskId, task.parentTaskId, task.adapter === 'chatgpt' ? 'task.process.async_exited' : 'task.process.exited', error ? 'failed' : 'succeeded', error ?? result?.summary ?? 'Worker runtime exited', 'orchestrator', { authoritative: false, waitingForTerminalEvent: true, processError: error }, { agent: 'relay', component: 'agents-relay' }));
747
- if (task.state === 'RUNNING' && task.adapter === 'codex')
776
+ if (['QUEUED', 'RUNNING'].includes(task.state) && task.adapter === 'codex')
748
777
  this.scheduleUnexpectedExit(jobId, taskId, executionId, workerStarted === true);
749
778
  }
750
779
  scheduleUnexpectedExit(jobId, taskId, executionId, workerStarted) {
@@ -758,14 +787,25 @@ export class Reconciler {
758
787
  async failUnexpectedExit(jobId, taskId, executionId, workerStarted) {
759
788
  const job = await this.store.load(jobId);
760
789
  const task = job.tasks.find(item => item.id === taskId);
761
- if (!task || task.executionId !== executionId || task.state !== 'RUNNING')
790
+ if (!task || task.executionId !== executionId || !['QUEUED', 'RUNNING'].includes(task.state))
762
791
  return;
763
792
  const reason = workerStarted ? 'worker_exit_without_terminal_event' : 'worker_exit_before_start';
764
793
  const message = workerStarted
765
794
  ? `Worker ${taskId} exited after task.started without publishing a terminal task event`
766
795
  : `Worker ${taskId} exited before publishing task.started`;
767
796
  const event = eventFor(jobId, taskId, task.parentTaskId, 'task.failed', 'failed', message, 'user', { reason, terminalGraceMs: this.options.terminalExitGraceMs ?? 1000 }, { agent: 'relay', component: 'agents-relay', watchdog: true });
768
- await this.applyTerminalEvent(event);
797
+ if (task.state === 'QUEUED') {
798
+ this.clearExecution(task.id, executionId);
799
+ task.error = message;
800
+ task.failureReason = reason;
801
+ task.leaseOwner = null;
802
+ task.leaseExpiresAt = null;
803
+ await transitionTaskEvent(task, 'fail');
804
+ await this.store.saveTask(task);
805
+ }
806
+ else {
807
+ await this.applyTerminalEvent(event);
808
+ }
769
809
  await this.emit(event);
770
810
  }
771
811
  async timeoutTask(jobId, taskId, executionId) {
@@ -776,12 +816,23 @@ export class Reconciler {
776
816
  }
777
817
  const job = await this.store.load(jobId);
778
818
  const task = job.tasks.find(item => item.id === taskId);
779
- if (!task || task.executionId !== executionId || task.state !== 'RUNNING') {
819
+ if (!task || task.executionId !== executionId || !['QUEUED', 'RUNNING'].includes(task.state)) {
780
820
  this.clearExecution(taskId, executionId);
781
821
  return;
782
822
  }
783
823
  const event = eventFor(jobId, taskId, task.parentTaskId, 'task.failed', 'failed', `Task ${taskId} timed out waiting for the mandatory terminal worker event`, 'user', { reason: 'terminal_event_timeout', timeoutMs: task.timeoutMs });
784
- await this.applyTerminalEvent(event);
824
+ if (task.state === 'QUEUED') {
825
+ task.error = event.message;
826
+ task.failureReason = 'terminal_event_timeout';
827
+ task.leaseOwner = null;
828
+ task.leaseExpiresAt = null;
829
+ await transitionTaskEvent(task, 'fail');
830
+ await this.store.saveTask(task);
831
+ this.clearExecution(task.id, executionId);
832
+ }
833
+ else {
834
+ await this.applyTerminalEvent(event);
835
+ }
785
836
  await this.emit(event);
786
837
  }
787
838
  async applyTerminalEvent(event) {
@@ -792,7 +843,7 @@ export class Reconciler {
792
843
  if (task.state === 'FAILED' && this.isRecoverableWatchdogFailure(task) && this.isWorkerOwnedEvent(event)) {
793
844
  if (event.timestamp && new Date(event.timestamp).getTime() < new Date(task.updatedAt).getTime())
794
845
  return;
795
- transitionTask(task, 'RUNNING');
846
+ await transitionTaskEvent(task, 'reconcile.started');
796
847
  task.failureReason = undefined;
797
848
  task.error = null;
798
849
  }
@@ -811,24 +862,24 @@ export class Reconciler {
811
862
  task.result = { summary: event.message || `Task ${task.id} completed`, data: event.data };
812
863
  task.error = null;
813
864
  task.failureReason = undefined;
814
- transitionTask(task, 'SUCCEEDED');
865
+ await transitionTaskEvent(task, 'task.completed');
815
866
  await this.store.saveTask(task);
816
867
  await this.deliverContinuation(job, task);
817
868
  }
818
869
  else if (event.type === 'task.failed') {
819
870
  task.error = event.message || `Task ${task.id} failed`;
820
871
  task.failureReason = typeof event.data?.reason === 'string' ? event.data.reason : undefined;
821
- transitionTask(task, 'FAILED');
872
+ await transitionTaskEvent(task, 'task.failed');
822
873
  await this.store.saveTask(task);
823
874
  }
824
875
  else if (event.type === 'task.blocked') {
825
876
  task.error = event.message || `Task ${task.id} blocked`;
826
- transitionTask(task, 'BLOCKED');
877
+ await transitionTaskEvent(task, 'task.blocked');
827
878
  await this.store.saveTask(task);
828
879
  }
829
880
  else if (event.type === 'task.cancelled') {
830
881
  task.error = event.message || `Task ${task.id} cancelled`;
831
- transitionTask(task, 'CANCELLED');
882
+ await transitionTaskEvent(task, 'task.cancelled');
832
883
  await this.store.saveTask(task);
833
884
  }
834
885
  this.detach(this.reconcile(job.id), job.id, task.id, 'terminal-event reconciliation');
package/dist/scheduler.js CHANGED
@@ -1,4 +1,4 @@
1
- import { assertTaskTransition } from './types.js';
1
+ import { decideJobLifecycle, decideTaskLifecycle } from './workflow/rollout.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))
@@ -25,12 +25,16 @@ export function schedule(job) {
25
25
  const map = new Map(job.tasks.map(t => [t.id, t]));
26
26
  for (const task of scopedTasks) {
27
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)
33
- transitionTask(task, 'QUEUED');
28
+ const facts = {
29
+ dependenciesSatisfied: task.dependencies.length > 0 && ready,
30
+ dependenciesUnsatisfied: task.dependencies.length > 0 && !ready,
31
+ runnable: ready,
32
+ };
33
+ const decision = decideTaskLifecycle(task, 'reconcile', facts);
34
+ if (decision.reason === 'ambiguous')
35
+ throw new Error(`Ambiguous automatic task transition from ${task.state}`);
36
+ if (decision.accepted && decision.target)
37
+ applyTaskState(task, decision.target);
34
38
  }
35
39
  if (job.schedule) {
36
40
  const planners = scopedTasks.filter(task => task.kind === 'planner');
@@ -52,8 +56,25 @@ export function extendRetryBudget(task, windowSize = 3) {
52
56
  const usedOrBudgeted = Math.max(task.maxAttempts, task.attempt);
53
57
  return Math.max(windowSize, (Math.floor(usedOrBudgeted / windowSize) + 1) * windowSize);
54
58
  }
55
- export function transitionTask(task, state) { assertTaskTransition(task.state, state); task.state = state; if (['QUEUED', 'RUNNING', 'SUCCEEDED'].includes(state))
59
+ function applyTaskState(task, state) { task.state = state; if (['QUEUED', 'RUNNING', 'SUCCEEDED'].includes(state))
56
60
  task.error = null; task.updatedAt = new Date().toISOString(); }
61
+ export async function transitionTaskEvent(task, event, context = {}) {
62
+ const decision = decideTaskLifecycle(task, event, context);
63
+ if (!decision.accepted || !decision.target)
64
+ throw new Error(decision.reason === 'ambiguous'
65
+ ? `Ambiguous workflow transition from ${task.state} on ${event}`
66
+ : `No enabled workflow transition from ${task.state} on ${event}`);
67
+ applyTaskState(task, decision.target);
68
+ }
69
+ export async function transitionJobEvent(job, event, context = {}) {
70
+ const decision = decideJobLifecycle(job, event, context);
71
+ if (!decision.accepted || !decision.target)
72
+ throw new Error(decision.reason === 'ambiguous'
73
+ ? `Ambiguous workflow transition from ${job.state} on ${event}`
74
+ : `No enabled workflow transition from ${job.state} on ${event}`);
75
+ job.state = decision.target;
76
+ job.updatedAt = new Date().toISOString();
77
+ }
57
78
  export const SCHEDULE_RESOLUTION_MS = 600_000;
58
79
  export const RUN_BUCKET_LABEL_COUNT = 144;
59
80
  const CRON_PART = /^(?:\*|(\d+)(?:-(\d+))?)(?:\/(\d+))?$/;
package/dist/types.js CHANGED
@@ -1,10 +1,7 @@
1
+ import { jobLifecycleWorkflow, taskLifecycleWorkflow } from './workflow/definitions.js';
1
2
  export const STORY_SIZE_TIMEOUT_MS = { S: 300000, M: 900000, L: 2700000 };
2
3
  export function storySizeTimeoutMs(size) { return STORY_SIZE_TIMEOUT_MS[size]; }
3
4
  export function taskOverallTimeoutMs(size, reasoning) { return storySizeTimeoutMs(size) * (reasoning?.toLowerCase() === 'high' ? 2 : 1); }
4
5
  export function chatGptStartAckTimeoutMs(overallTimeoutMs) { return Math.min(600000, Math.max(120000, Math.floor(overallTimeoutMs / 3))); }
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
- throw new Error(`Invalid job transition ${from} -> ${to}`); }
9
- export function assertTaskTransition(from, to) { if (from !== to && !taskTransitions[from].includes(to))
10
- throw new Error(`Invalid task transition ${from} -> ${to}`); }
6
+ export function assertJobLifecycleTransition(from, to) { jobLifecycleWorkflow.assertStateTransition(from, to, 'job'); }
7
+ export function assertTaskTransition(from, to) { taskLifecycleWorkflow.assertStateTransition(from, to, 'task'); }
@@ -0,0 +1,14 @@
1
+ import { readFileSync } from 'node:fs';
2
+ import { parseExecutableMermaid, WorkflowEngine } from './mermaid.js';
3
+ function load(name, guards = {}, actions = {}) {
4
+ const url = new URL(`../../workflow/${name}.mmd`, import.meta.url);
5
+ return new WorkflowEngine(parseExecutableMermaid(readFileSync(url, 'utf8')), guards, actions);
6
+ }
7
+ const booleanGuard = (name) => context => context[name] === true;
8
+ const lifecycleAction = () => { };
9
+ export const jobLifecycleWorkflow = load('job-lifecycle', { notMerged: booleanGuard('notMerged') }, { cancelJob: lifecycleAction });
10
+ export const taskLifecycleWorkflow = load('task-lifecycle', {
11
+ dependenciesSatisfied: booleanGuard('dependenciesSatisfied'),
12
+ dependenciesUnsatisfied: booleanGuard('dependenciesUnsatisfied'),
13
+ runnable: booleanGuard('runnable'),
14
+ }, { release: lifecycleAction, hold: lifecycleAction });
@@ -0,0 +1,180 @@
1
+ const STATE = String.raw `(?:[A-Za-z_][A-Za-z0-9_.-]*|\[\*\])`;
2
+ const TRANSITION = new RegExp(`^(${STATE})\\s*-->\\s*(${STATE})(?:\\s*:\\s*(.+))?$`);
3
+ const NAME = /^[A-Za-z_][A-Za-z0-9_.-]*$/;
4
+ function parseLabel(label, line, terminal) {
5
+ if (!label?.trim()) {
6
+ if (terminal)
7
+ return { event: '', guard: null, actions: [] };
8
+ throw new Error(`Executable Mermaid transition at line ${line} requires an event label`);
9
+ }
10
+ const pieces = label.split('/');
11
+ if (pieces.length > 2)
12
+ throw new Error(`Executable Mermaid label at line ${line} may contain at most one '/' action separator`);
13
+ const left = pieces[0].trim();
14
+ const guardMatch = left.match(/^(.*?)(?:\s+\[([A-Za-z_][A-Za-z0-9_.-]*)\])?$/);
15
+ if (!guardMatch)
16
+ throw new Error(`Invalid executable Mermaid label at line ${line}`);
17
+ const event = guardMatch[1].trim();
18
+ const guard = guardMatch[2] ?? null;
19
+ if (!event || !NAME.test(event))
20
+ throw new Error(`Invalid event '${event}' at line ${line}`);
21
+ const actions = pieces[1]?.split(',').map(value => value.trim()).filter(Boolean) ?? [];
22
+ for (const action of actions)
23
+ if (!NAME.test(action))
24
+ throw new Error(`Invalid action '${action}' at line ${line}`);
25
+ return { event, guard, actions };
26
+ }
27
+ export function parseExecutableMermaid(source) {
28
+ const lines = source.split(/\r?\n/);
29
+ let headerSeen = false;
30
+ const states = new Set();
31
+ const finals = new Set();
32
+ const transitions = [];
33
+ const initials = [];
34
+ for (let index = 0; index < lines.length; index += 1) {
35
+ const lineNumber = index + 1;
36
+ const line = lines[index].trim();
37
+ if (!line || line.startsWith('%%'))
38
+ continue;
39
+ if (!headerSeen) {
40
+ if (line !== 'stateDiagram-v2')
41
+ throw new Error(`Executable Mermaid must start with stateDiagram-v2 (line ${lineNumber})`);
42
+ headerSeen = true;
43
+ continue;
44
+ }
45
+ const match = line.match(TRANSITION);
46
+ if (!match)
47
+ throw new Error(`Unsupported executable Mermaid syntax at line ${lineNumber}: ${line}`);
48
+ const [, from, to, label] = match;
49
+ const fromTerminal = from === '[*]';
50
+ const toTerminal = to === '[*]';
51
+ if (fromTerminal && toTerminal)
52
+ throw new Error(`Invalid [*] -> [*] transition at line ${lineNumber}`);
53
+ if (!fromTerminal)
54
+ states.add(from);
55
+ if (!toTerminal)
56
+ states.add(to);
57
+ if (fromTerminal) {
58
+ if (label?.trim())
59
+ throw new Error(`Initial transition at line ${lineNumber} must not have an event label`);
60
+ initials.push(to);
61
+ continue;
62
+ }
63
+ if (toTerminal) {
64
+ if (label?.trim())
65
+ throw new Error(`Final transition at line ${lineNumber} must not have an event label`);
66
+ finals.add(from);
67
+ continue;
68
+ }
69
+ transitions.push({ from, to, ...parseLabel(label, lineNumber, false), line: lineNumber });
70
+ }
71
+ if (!headerSeen)
72
+ throw new Error('Executable Mermaid is empty');
73
+ if (initials.length !== 1)
74
+ throw new Error(`Executable Mermaid requires exactly one [*] initial transition; found ${initials.length}`);
75
+ if (transitions.length === 0)
76
+ throw new Error('Executable Mermaid requires at least one executable transition');
77
+ const seen = new Set();
78
+ const deterministicKeys = new Set();
79
+ for (const transition of transitions) {
80
+ const key = `${transition.from}\u0000${transition.event}\u0000${transition.guard ?? ''}\u0000${transition.to}`;
81
+ if (seen.has(key))
82
+ throw new Error(`Duplicate executable transition at line ${transition.line}: ${transition.from} --${transition.event}--> ${transition.to}`);
83
+ seen.add(key);
84
+ const deterministicKey = `${transition.from}\u0000${transition.event}\u0000${transition.guard ?? ''}`;
85
+ if (deterministicKeys.has(deterministicKey))
86
+ throw new Error(`Ambiguous executable transition at line ${transition.line}: duplicate source/event/guard ${transition.from} --${transition.event} [${transition.guard ?? 'unguarded'}]`);
87
+ deterministicKeys.add(deterministicKey);
88
+ }
89
+ return { source, states, initial: initials[0], finals, transitions };
90
+ }
91
+ export class WorkflowEngine {
92
+ graph;
93
+ guards;
94
+ actions;
95
+ constructor(graph, guards = {}, actions = {}) {
96
+ this.graph = graph;
97
+ this.guards = guards;
98
+ this.actions = actions;
99
+ this.validateRegistryBindings();
100
+ }
101
+ validateRegistryBindings() {
102
+ for (const transition of this.graph.transitions) {
103
+ if (transition.guard && !this.guards[transition.guard])
104
+ throw new Error(`Unknown workflow guard ${transition.guard} referenced at line ${transition.line}`);
105
+ for (const action of transition.actions)
106
+ if (!this.actions[action])
107
+ throw new Error(`Unknown workflow action ${action} referenced at line ${transition.line}`);
108
+ }
109
+ }
110
+ transitionsFrom(state) {
111
+ if (!this.graph.states.has(state))
112
+ throw new Error(`Unknown workflow state ${state}`);
113
+ return this.graph.transitions.filter(transition => transition.from === state);
114
+ }
115
+ assertStateTransition(from, to, kind = 'workflow') {
116
+ if (from === to)
117
+ return;
118
+ if (!this.graph.states.has(from) || !this.graph.states.has(to))
119
+ throw new Error(`Unknown ${kind} state ${!this.graph.states.has(from) ? from : to}`);
120
+ if (!this.graph.transitions.some(transition => transition.from === from && transition.to === to))
121
+ throw new Error(`Invalid ${kind} transition ${from} -> ${to}`);
122
+ }
123
+ enabledSync(state, event, context = {}) {
124
+ const candidates = this.transitionsFrom(state).filter(transition => transition.event === event);
125
+ const enabled = [];
126
+ for (const transition of candidates) {
127
+ if (!transition.guard) {
128
+ enabled.push(transition);
129
+ continue;
130
+ }
131
+ const value = this.guards[transition.guard](context);
132
+ if (value instanceof Promise)
133
+ throw new Error(`Workflow guard ${transition.guard} is asynchronous and cannot be used in a synchronous transition`);
134
+ if (value)
135
+ enabled.push(transition);
136
+ }
137
+ return enabled;
138
+ }
139
+ transitionSync(state, event, context = {}) {
140
+ const enabled = this.enabledSync(state, event, context);
141
+ if (enabled.length === 0)
142
+ throw new Error(`No enabled workflow transition from ${state} on ${event}`);
143
+ if (enabled.length > 1)
144
+ throw new Error(`Ambiguous workflow transition from ${state} on ${event}`);
145
+ const transition = enabled[0];
146
+ for (const actionName of transition.actions) {
147
+ const value = this.actions[actionName](context, transition);
148
+ if (value instanceof Promise)
149
+ throw new Error(`Workflow action ${actionName} is asynchronous and cannot be used in a synchronous transition`);
150
+ }
151
+ return transition.to;
152
+ }
153
+ async enabled(state, event, context = {}) {
154
+ const candidates = this.transitionsFrom(state).filter(transition => transition.event === event);
155
+ const enabled = [];
156
+ for (const transition of candidates) {
157
+ if (!transition.guard) {
158
+ enabled.push(transition);
159
+ continue;
160
+ }
161
+ const guard = this.guards[transition.guard];
162
+ if (await guard(context))
163
+ enabled.push(transition);
164
+ }
165
+ return enabled;
166
+ }
167
+ async transition(state, event, context = {}) {
168
+ const enabled = await this.enabled(state, event, context);
169
+ if (enabled.length === 0)
170
+ throw new Error(`No enabled workflow transition from ${state} on ${event}`);
171
+ if (enabled.length > 1)
172
+ throw new Error(`Ambiguous workflow transition from ${state} on ${event}`);
173
+ const transition = enabled[0];
174
+ for (const actionName of transition.actions) {
175
+ const action = this.actions[actionName];
176
+ await action(context, transition);
177
+ }
178
+ return transition.to;
179
+ }
180
+ }
@@ -0,0 +1,171 @@
1
+ import { jobLifecycleWorkflow, taskLifecycleWorkflow } from './definitions.js';
2
+ let modeOverride = null;
3
+ let reporter = null;
4
+ let matches = 0;
5
+ let mismatches = 0;
6
+ export function parseWorkflowRolloutMode(value) {
7
+ if (value === undefined || value === '')
8
+ return 'legacy';
9
+ if (value === 'legacy' || value === 'shadow' || value === 'graph')
10
+ return value;
11
+ throw new Error(`Invalid AGENTS_RELAY_WORKFLOW_MODE '${value}'; expected legacy, shadow, or graph`);
12
+ }
13
+ export function workflowRolloutMode() {
14
+ return modeOverride ?? parseWorkflowRolloutMode(process.env.AGENTS_RELAY_WORKFLOW_MODE);
15
+ }
16
+ export function setWorkflowRolloutMode(mode) {
17
+ modeOverride = mode;
18
+ }
19
+ export function setWorkflowShadowReporter(value) {
20
+ reporter = value;
21
+ }
22
+ export function resetWorkflowRolloutDiagnostics() {
23
+ matches = 0;
24
+ mismatches = 0;
25
+ }
26
+ export function workflowRolloutSnapshot() {
27
+ return { mode: workflowRolloutMode(), matches, mismatches };
28
+ }
29
+ function graphDecision(engine, from, event, context) {
30
+ const enabled = engine.enabledSync(from, event, context);
31
+ if (enabled.length === 0)
32
+ return { accepted: false, target: null, guard: null, actions: [], reason: 'no_transition' };
33
+ if (enabled.length > 1)
34
+ return { accepted: false, target: null, guard: null, actions: [], reason: 'ambiguous' };
35
+ const transition = enabled[0];
36
+ return {
37
+ accepted: true,
38
+ target: transition.to,
39
+ guard: transition.guard,
40
+ actions: [...transition.actions],
41
+ reason: 'accepted',
42
+ };
43
+ }
44
+ const taskLegacy = {
45
+ DRAFT: {
46
+ release: { target: 'QUEUED' },
47
+ block: { target: 'BLOCKED' },
48
+ cancel: { target: 'CANCELLED' },
49
+ },
50
+ QUEUED: {
51
+ hold: { target: 'DRAFT' },
52
+ 'task.started': { target: 'RUNNING' },
53
+ block: { target: 'BLOCKED' },
54
+ fail: { target: 'FAILED' },
55
+ cancel: { target: 'CANCELLED' },
56
+ },
57
+ RUNNING: {
58
+ 'task.blocked': { target: 'BLOCKED' },
59
+ 'task.completed': { target: 'SUCCEEDED' },
60
+ 'task.failed': { target: 'FAILED' },
61
+ 'task.cancelled': { target: 'CANCELLED' },
62
+ },
63
+ BLOCKED: {
64
+ retry: { target: 'QUEUED' },
65
+ cancel: { target: 'CANCELLED' },
66
+ },
67
+ SUCCEEDED: {},
68
+ FAILED: {
69
+ retry: { target: 'QUEUED' },
70
+ 'reconcile.started': { target: 'RUNNING' },
71
+ 'reconcile.completed': { target: 'SUCCEEDED' },
72
+ 'reconcile.blocked': { target: 'BLOCKED' },
73
+ cancel: { target: 'CANCELLED' },
74
+ },
75
+ CANCELLED: {},
76
+ };
77
+ const jobLegacy = {
78
+ DRAFT: {
79
+ activate: { target: 'ACTIVE' },
80
+ 'pr.merged': { target: 'COMPLETED' },
81
+ cancel: { target: 'CANCELLED' },
82
+ },
83
+ ACTIVE: {
84
+ 'pr.merged': { target: 'COMPLETED' },
85
+ cancel: { target: 'CANCELLED' },
86
+ },
87
+ COMPLETED: {},
88
+ CANCELLED: {
89
+ 'pr.merged': { target: 'COMPLETED' },
90
+ },
91
+ };
92
+ function taskLegacyDecision(from, event, context) {
93
+ if (event === 'reconcile') {
94
+ if (from === 'DRAFT' && context.dependenciesSatisfied === true)
95
+ return { accepted: true, target: 'QUEUED', guard: 'dependenciesSatisfied', actions: ['release'], reason: 'accepted' };
96
+ if (from === 'QUEUED' && context.dependenciesUnsatisfied === true)
97
+ return { accepted: true, target: 'DRAFT', guard: 'dependenciesUnsatisfied', actions: ['hold'], reason: 'accepted' };
98
+ if (from === 'QUEUED' && context.runnable === true)
99
+ return { accepted: true, target: 'QUEUED', guard: 'runnable', actions: ['release'], reason: 'accepted' };
100
+ return { accepted: false, target: null, guard: null, actions: [], reason: 'no_transition' };
101
+ }
102
+ const transition = taskLegacy[from][event];
103
+ if (!transition)
104
+ return { accepted: false, target: null, guard: null, actions: [], reason: 'no_transition' };
105
+ return { accepted: true, target: transition.target, guard: transition.guard ?? null, actions: transition.actions ?? [], reason: 'accepted' };
106
+ }
107
+ function jobLegacyDecision(from, event, context) {
108
+ if (event === 'pr.closed') {
109
+ if ((from === 'DRAFT' || from === 'ACTIVE') && context.notMerged === true) {
110
+ return { accepted: true, target: 'CANCELLED', guard: 'notMerged', actions: ['cancelJob'], reason: 'accepted' };
111
+ }
112
+ return { accepted: false, target: null, guard: null, actions: [], reason: 'no_transition' };
113
+ }
114
+ const transition = jobLegacy[from][event];
115
+ if (!transition)
116
+ return { accepted: false, target: null, guard: null, actions: [], reason: 'no_transition' };
117
+ return { accepted: true, target: transition.target, guard: transition.guard ?? null, actions: transition.actions ?? [], reason: 'accepted' };
118
+ }
119
+ function sameDecision(a, b) {
120
+ return a.accepted === b.accepted
121
+ && a.target === b.target
122
+ && a.guard === b.guard
123
+ && a.reason === b.reason
124
+ && a.actions.length === b.actions.length
125
+ && a.actions.every((value, index) => value === b.actions[index]);
126
+ }
127
+ function report(kind, jobId, taskId, event, from, facts, legacy, graph) {
128
+ const match = sameDecision(legacy, graph);
129
+ if (match)
130
+ matches += 1;
131
+ else
132
+ mismatches += 1;
133
+ const diagnostic = {
134
+ type: match ? 'workflow.shadow.match' : 'workflow.shadow.mismatch',
135
+ kind,
136
+ jobId,
137
+ ...(taskId ? { taskId } : {}),
138
+ event,
139
+ from,
140
+ facts: { ...facts },
141
+ legacy,
142
+ graph,
143
+ };
144
+ reporter?.(diagnostic);
145
+ if (!match && !reporter)
146
+ console.warn(JSON.stringify(diagnostic));
147
+ }
148
+ export function decideTaskLifecycle(task, event, context = {}) {
149
+ const legacy = taskLegacyDecision(task.state, event, context);
150
+ const mode = workflowRolloutMode();
151
+ if (mode === 'legacy')
152
+ return legacy;
153
+ const graph = graphDecision(taskLifecycleWorkflow, task.state, event, context);
154
+ if (mode === 'shadow') {
155
+ report('task', task.jobId ?? 'unknown', task.id, event, task.state, context, legacy, graph);
156
+ return legacy;
157
+ }
158
+ return graph;
159
+ }
160
+ export function decideJobLifecycle(job, event, context = {}) {
161
+ const legacy = jobLegacyDecision(job.state, event, context);
162
+ const mode = workflowRolloutMode();
163
+ if (mode === 'legacy')
164
+ return legacy;
165
+ const graph = graphDecision(jobLifecycleWorkflow, job.state, event, context);
166
+ if (mode === 'shadow') {
167
+ report('job', job.id, undefined, event, job.state, context, legacy, graph);
168
+ return legacy;
169
+ }
170
+ return graph;
171
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "1.0.67",
3
+ "version": "1.0.69",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {
@@ -24,6 +24,7 @@
24
24
  "files": [
25
25
  "dist",
26
26
  "skills",
27
+ "workflow",
27
28
  "README.md",
28
29
  "LICENSE"
29
30
  ]
@@ -47,6 +47,8 @@ Raw `timeoutMs` is a compatibility/debug/hotfix override, not the normal task-pr
47
47
 
48
48
  ## Task readiness states
49
49
 
50
+ Canonical Job/Task lifecycle legality is defined by the directly renderable runtime sources `workflow/job-lifecycle.mmd` and `workflow/task-lifecycle.mmd`. Producers, runtime code, docs, and agents must treat those files as the transition source of truth; the prose below explains semantics and must not become a second transition table.
51
+
50
52
  Task readiness is explicit and must use these semantics:
51
53
 
52
54
  - `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`.
@@ -63,6 +65,8 @@ Relay owns process lifecycle. Workers own semantic task lifecycle. A worker must
63
65
 
64
66
  ## Job lifecycle and runtime status
65
67
 
68
+ The canonical durable lifecycle transition graphs are `workflow/job-lifecycle.mmd` and `workflow/task-lifecycle.mmd`. They use Relay's minimal executable Mermaid `stateDiagram-v2` profile and are the single source of truth for transition legality; do not maintain a parallel JSON/YAML transition table. Runtime status is not part of those lifecycle graphs.
69
+
66
70
  Durable Job lifecycle is intentionally small: `DRAFT | ACTIVE | COMPLETED | CANCELLED`.
67
71
 
68
72
  - `DRAFT`: durable objective exists, but the execution engine must not run it.
@@ -0,0 +1,15 @@
1
+ stateDiagram-v2
2
+ [*] --> DRAFT
3
+
4
+ DRAFT --> ACTIVE: activate
5
+ DRAFT --> COMPLETED: pr.merged
6
+ DRAFT --> CANCELLED: cancel
7
+ DRAFT --> CANCELLED: pr.closed [notMerged] / cancelJob
8
+
9
+ ACTIVE --> COMPLETED: pr.merged
10
+ ACTIVE --> CANCELLED: cancel
11
+ ACTIVE --> CANCELLED: pr.closed [notMerged] / cancelJob
12
+
13
+ CANCELLED --> COMPLETED: pr.merged
14
+
15
+ COMPLETED --> [*]
@@ -0,0 +1,32 @@
1
+ stateDiagram-v2
2
+ [*] --> DRAFT
3
+
4
+ DRAFT --> QUEUED: release
5
+ DRAFT --> QUEUED: reconcile [dependenciesSatisfied] / release
6
+ DRAFT --> BLOCKED: block
7
+ DRAFT --> CANCELLED: cancel
8
+
9
+ QUEUED --> DRAFT: hold
10
+ QUEUED --> DRAFT: reconcile [dependenciesUnsatisfied] / hold
11
+ QUEUED --> QUEUED: reconcile [runnable] / release
12
+ QUEUED --> RUNNING: task.started
13
+ QUEUED --> BLOCKED: block
14
+ QUEUED --> FAILED: fail
15
+ QUEUED --> CANCELLED: cancel
16
+
17
+ RUNNING --> BLOCKED: task.blocked
18
+ RUNNING --> SUCCEEDED: task.completed
19
+ RUNNING --> FAILED: task.failed
20
+ RUNNING --> CANCELLED: task.cancelled
21
+
22
+ BLOCKED --> QUEUED: retry
23
+ BLOCKED --> CANCELLED: cancel
24
+
25
+ FAILED --> QUEUED: retry
26
+ FAILED --> RUNNING: reconcile.started
27
+ FAILED --> SUCCEEDED: reconcile.completed
28
+ FAILED --> BLOCKED: reconcile.blocked
29
+ FAILED --> CANCELLED: cancel
30
+
31
+ SUCCEEDED --> [*]
32
+ CANCELLED --> [*]