agents-relay 1.0.156 → 1.0.158

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli-help.js CHANGED
@@ -46,7 +46,7 @@ Example:
46
46
 
47
47
  Required: --repo OWNER/REPO, --id JOB_ID, and either --head BRANCH or --pr NUMBER.
48
48
  With --head, reuse/create the PR for that head. With --pr, bind an existing unmanaged PR.
49
- Common: --base BRANCH (default: main), --title TEXT, --objective-file PATH|- (preferred), --objective MARKDOWN (short/simple only), --project-path RELATIVE_PATH, --priority P0|P1|P2|P3, --mode fixed|autonomous, --no-auto-merge, --no-review, --task-status-markers JSON. --objective and --objective-file are mutually exclusive. For a new --head Job, one is required and must contain enough standalone context to complete the Job without the originating chat/thread. Objective Markdown is also the default PR body; --body is an explicit override.
49
+ Common: --base BRANCH (default: main), --title TEXT, --objective-file PATH|- (preferred), --objective MARKDOWN (short/simple only), --project-path RELATIVE_PATH, --priority P0|P1|P2|P3, --mode fixed|autonomous, --no-auto-merge, --no-review, --tag JSON. --objective and --objective-file are mutually exclusive. For a new --head Job, one is required and must contain enough standalone context to complete the Job without the originating chat/thread. Objective Markdown is also the default PR body; --body is an explicit override.
50
50
  Job creation never creates executable tasks. The orchestrator decides the initial task batch separately. Re-running create against an already healthy Job is idempotent and does not mutate its metadata.
51
51
 
52
52
  Example:
@@ -54,7 +54,7 @@ Example:
54
54
  'job update': `Partially update mutable metadata on a healthy existing Job.
55
55
 
56
56
  Required: --repo OWNER/REPO, --pr NUMBER, and --id JOB_ID.
57
- Mutable: --title, --objective-file PATH|- (preferred) or --objective MARKDOWN, --project-path RELATIVE_PATH, --priority, --mode fixed|autonomous, --schedule, --task-status-markers JSON, --auto-merge|--no-auto-merge, --review|--no-review, and continuation options. Updating the objective also updates the PR body.
57
+ Mutable: --title, --objective-file PATH|- (preferred) or --objective MARKDOWN, --project-path RELATIVE_PATH, --priority, --mode fixed|autonomous, --schedule, --tag JSON, --auto-merge|--no-auto-merge, --review|--no-review, and continuation options. Updating the objective also updates the PR body.
58
58
  Unspecified fields are preserved. Marker/binding corruption is never repaired implicitly; update fails with a precise diagnostic instead.
59
59
 
60
60
  Example:
@@ -3,6 +3,7 @@ import { InMemoryStore, GitHubStore } from './store.js';
3
3
  import { githubAuthContext } from './github-auth.js';
4
4
  import { NatsEventBus, eventFor } from './events.js';
5
5
  import { parseJobSchedule } from './scheduler.js';
6
+ import { parseJobTagJson } from './task-tag.js';
6
7
  export const SERVICE_DEFAULTS = { port: 8787, watchdogMs: 300000, webhookWatchdogMs: 300000, dashboardRefreshMs: 300000 };
7
8
  export class ServiceEventBus {
8
9
  upstream;
@@ -97,30 +98,7 @@ export function chatGptTabClosePolicy(args) {
97
98
  return value;
98
99
  }
99
100
  export function requestedExecutionMode(args) { const value = arg(args, '--mode', arg(args, '--execution-mode')); return value ? executionMode(args) : undefined; }
100
- export function taskStatusMarkers(args) {
101
- const raw = arg(args, '--task-status-markers');
102
- if (!raw)
103
- return undefined;
104
- let parsed;
105
- try {
106
- parsed = JSON.parse(raw);
107
- }
108
- catch {
109
- throw new Error('--task-status-markers must be a JSON object');
110
- }
111
- if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
112
- throw new Error('--task-status-markers must be a JSON object');
113
- const allowed = new Set(['DRAFT', 'QUEUED', 'RUNNING', 'BLOCKED', 'SUCCEEDED', 'FAILED', 'CANCELLED']);
114
- const out = {};
115
- for (const [state, marker] of Object.entries(parsed)) {
116
- if (!allowed.has(state))
117
- throw new Error('--task-status-markers contains invalid Task state ' + state);
118
- if (typeof marker !== 'string' || marker.length === 0 || marker.length > 32)
119
- throw new Error('--task-status-markers value for ' + state + ' must be a non-empty string up to 32 characters');
120
- out[state] = marker;
121
- }
122
- return { taskStatusMarkers: out };
123
- }
101
+ export function jobTagFromArgs(args) { return parseJobTagJson(arg(args, '--tag')); }
124
102
  export function continuation(args) { const kind = arg(args, '--continue-kind'); const target = arg(args, '--continue-target'); return kind && target ? { kind: kind, target, input: arg(args, '--continue-input') || undefined } : null; }
125
103
  export function scheduleFromArgs(args) {
126
104
  const cron = arg(args, '--schedule');
package/dist/cli.js CHANGED
@@ -25,7 +25,7 @@ import { runBlockerCommand } from './cli-blocker.js';
25
25
  import { helpText, usage } from './cli-help.js';
26
26
  import { parseAgentGraph } from './agent-graph.js';
27
27
  export { helpText } from './cli-help.js';
28
- import { SERVICE_DEFAULTS, ServiceEventBus, arg, hasArg, textOption, integerArg, repository, storeFor, saveLocal, githubStore, eventBus, publishWake, priority, storySize, executionMode, chatGptTabClosePolicy, requestedExecutionMode, continuation, scheduleFromArgs, taskStatusMarkers, requestManagedReconciliation, } from './cli-support.js';
28
+ import { SERVICE_DEFAULTS, ServiceEventBus, arg, hasArg, textOption, integerArg, repository, storeFor, saveLocal, githubStore, eventBus, publishWake, priority, storySize, executionMode, chatGptTabClosePolicy, requestedExecutionMode, continuation, scheduleFromArgs, jobTagFromArgs, requestManagedReconciliation, } from './cli-support.js';
29
29
  export { SERVICE_DEFAULTS, eventBus, executionMode, chatGptTabClosePolicy } from './cli-support.js';
30
30
  export async function ensureManagedGitHubJob(client, repositoryName, trustedAuthors, options) {
31
31
  if (!options.id)
@@ -51,7 +51,7 @@ export async function ensureManagedGitHubJob(client, repositoryName, trustedAuth
51
51
  description: pr.body || undefined,
52
52
  priority: options.priority ?? 'P2',
53
53
  executionMode: options.executionMode ?? 'fixed',
54
- display: options.display,
54
+ tag: options.tag,
55
55
  autoMerge: options.autoMerge ?? true,
56
56
  reviewEnabled: options.reviewEnabled ?? true,
57
57
  prNumber: pr.number,
@@ -343,7 +343,7 @@ async function runJobCommand(action, args) {
343
343
  priority: priority(args),
344
344
  executionMode: requestedExecutionMode(args),
345
345
  schedule: scheduleFromArgs(args),
346
- display: taskStatusMarkers(args),
346
+ tag: jobTagFromArgs(args),
347
347
  autoMerge: !hasArg(args, '--no-auto-merge'),
348
348
  reviewEnabled: !hasArg(args, '--no-review'),
349
349
  continuation: continuation(args),
@@ -408,8 +408,8 @@ async function runJobCommand(action, args) {
408
408
  job.executionMode = executionMode(args, job.executionMode ?? 'fixed');
409
409
  if (hasArg(args, '--schedule'))
410
410
  job.schedule = scheduleFromArgs(args);
411
- if (hasArg(args, '--task-status-markers'))
412
- job.display = taskStatusMarkers(args);
411
+ if (hasArg(args, '--tag'))
412
+ job.tag = jobTagFromArgs(args);
413
413
  if (hasArg(args, '--auto-merge') && hasArg(args, '--no-auto-merge'))
414
414
  throw new Error('choose only one of --auto-merge or --no-auto-merge');
415
415
  if (hasArg(args, '--auto-merge'))
@@ -320,8 +320,8 @@ function syncFilters(){
320
320
  }
321
321
  function repoName(repository){if(typeof repository!=='string'||!repository)return 'unknown-repo';const parts=repository.split('/').filter(Boolean);return parts.at(-1)||repository;}
322
322
  function renderJobList(){jobList.replaceChildren();const visible=filteredJobs();if(!jobs.length){jobList.append(element('li','empty',text('No durable jobs discovered.')));return;}if(!visible.length){jobList.append(element('li','empty',text('No jobs match the selected repo.')));return;}for(const job of visible){const row=element('div','job-row'+(isCompleteUnmerged(job)?' complete-unmerged':''));row.setAttribute('aria-current',String(job.id===selectedJob));row.addEventListener('click',()=>selectJob(job.id));const content=element('div','job-content');const select=document.createElement('button');select.type='button';select.className='job-button';select.setAttribute('aria-current',String(job.id===selectedJob));select.title=job.title||job.id;select.addEventListener('click',event=>{event.stopPropagation();selectJob(job.id);});select.append(element('span','job-title',text(repoName(job.repository)+'/PR#'+value(job.prNumber)+': '+value(job.title||job.id))));const states=element('span','job-status-line');states.append(element('span','badge '+value(job.state),text(value(job.state))),element('span','badge '+value(job.runtimeStatus||'IDLE'),text(value(job.runtimeStatus||'IDLE'))));if(job.executionMode==='autonomous')states.append(element('span','badge flag-autonomous',text('AUTONOMOUS')));if(job.reviewEnabled!==false&&job.githubState!=='MERGED')states.append(element('span','badge flag-review',text('REVIEW REQUIRED')));if(job.githubState==='MERGED'&&job.publication?.version)states.append(element('span','badge SUCCEEDED',text('v'+job.publication.version)));if(isCompleteUnmerged(job))states.append(element('span','badge MERGE_PENDING',text('MERGE PENDING')));const primary=element('div','job-primary');primary.append(states);const lastLine=element('div','job-last-line');lastLine.append(element('span','subtle',text(compactCounts(counts(job.tasks)))),element('span','subtle',text(relativeTime(job.updatedAt))));content.append(select,primary,lastLine);row.append(content);jobList.append(element('li','',row));}}
323
- function taskStatusMarker(job,task){const marker=job?.display?.taskStatusMarkers?.[task.state];return typeof marker==='string'&&marker?marker:null;}
324
- function taskNode(task,children,depth){const li=element('li');const nested=children.get(task.id)||[];const line=element('div','task-line');if(nested.length){const key=(selectedJob||'')+':'+task.id;const collapsed=collapsedTasks.has(key);const fold=document.createElement('button');fold.type='button';fold.className='task-fold';fold.setAttribute('aria-expanded',String(!collapsed));fold.setAttribute('aria-label',(collapsed?'Expand ':'Collapse ')+task.id);fold.textContent=collapsed?'▸':'▾';line.append(fold);const ul=element('ul','tree');ul.hidden=collapsed;for(const child of nested)ul.append(taskNode(child,children,depth+1));fold.addEventListener('click',()=>{const next=!ul.hidden;ul.hidden=next;fold.setAttribute('aria-expanded',String(!next));fold.setAttribute('aria-label',(next?'Expand ':'Collapse ')+task.id);fold.textContent=next?'▸':'▾';if(next)collapsedTasks.add(key);else collapsedTasks.delete(key);});li.append(line,ul);}else li.append(line);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',()=>{const deselect=selectedTaskId===task.id;selectedTaskId=deselect?null:task.id;eventTaskId=deselect?null: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)));const marker=taskStatusMarker(selected(),task);if(marker)row.append(element('span','task-status-marker',text(marker)));line.append(row);return li;}
323
+ function taskTagDisplay(job,task){const display=task.tag&&job?.tag?.values?.[task.tag];return typeof display==='string'&&display?display:null;}
324
+ function taskNode(task,children,depth){const li=element('li');const nested=children.get(task.id)||[];const line=element('div','task-line');if(nested.length){const key=(selectedJob||'')+':'+task.id;const collapsed=collapsedTasks.has(key);const fold=document.createElement('button');fold.type='button';fold.className='task-fold';fold.setAttribute('aria-expanded',String(!collapsed));fold.setAttribute('aria-label',(collapsed?'Expand ':'Collapse ')+task.id);fold.textContent=collapsed?'▸':'▾';line.append(fold);const ul=element('ul','tree');ul.hidden=collapsed;for(const child of nested)ul.append(taskNode(child,children,depth+1));fold.addEventListener('click',()=>{const next=!ul.hidden;ul.hidden=next;fold.setAttribute('aria-expanded',String(!next));fold.setAttribute('aria-label',(next?'Expand ':'Collapse ')+task.id);fold.textContent=next?'▸':'▾';if(next)collapsedTasks.add(key);else collapsedTasks.delete(key);});li.append(line,ul);}else li.append(line);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',()=>{const deselect=selectedTaskId===task.id;selectedTaskId=deselect?null:task.id;eventTaskId=deselect?null: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)));const tag=taskTagDisplay(selected(),task);if(tag){const badge=element('span','task-tag',text(tag));badge.title=(selected()?.tag?.description||'Task tag')+': '+task.tag;row.append(badge);}line.append(row);return li;}
325
325
  function buildTree(tasks){taskTree.replaceChildren();if(!Array.isArray(tasks)||!tasks.length){taskTree.append(element('li','empty',text('No tasks in durable state.')));return;}const work=tasks.filter(task=>task.kind!=='planner');if(!work.length){taskTree.append(element('li','empty',text('No work tasks in durable state.')));return;}const known=new Set(work.map(task=>task.id));const children=new Map();const roots=[];for(const task of work){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));}
326
326
  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;}
327
327
  const TASK_STATES=['DRAFT','QUEUED','RUNNING','BLOCKED','SUCCEEDED','FAILED','CANCELLED'];
@@ -5,6 +5,7 @@ import { eventFor } from './events.js';
5
5
  import { WorkflowEffectExecutor } from './workflow/effects.js';
6
6
  import { workflowEffectFailureEvent } from './workflow/contracts.js';
7
7
  import { ReconcileCoordinator } from './reconcile-coordinator.js';
8
+ import { acceptedReportedTag, eventDataWithoutTag } from './task-tag.js';
8
9
  export function executionEventSource(jobId, taskId, executionId) { return `job/${jobId}/task/${taskId}/execution/${executionId}`; }
9
10
  function eventSourceId(event) { return typeof event.source?.id === 'string' ? event.source.id : undefined; }
10
11
  const terminalExecutionClaims = new Set();
@@ -338,8 +339,11 @@ export class ReconcilerRuntimeBase {
338
339
  task.updatedAt = event.timestamp || new Date().toISOString();
339
340
  this.terminalWaiters.get(task.id)?.resolve();
340
341
  this.terminalWaiters.delete(task.id);
342
+ const reportedTag = acceptedReportedTag(job, event.data);
343
+ if (reportedTag)
344
+ task.tag = reportedTag;
341
345
  if (event.type === 'task.completed') {
342
- task.result = { summary: event.message || `Task ${task.id} completed`, data: event.data };
346
+ task.result = { summary: event.message || `Task ${task.id} completed`, data: eventDataWithoutTag(event.data) };
343
347
  task.error = null;
344
348
  task.failureReason = undefined;
345
349
  }
@@ -17,6 +17,7 @@ import { pendingTroubleshooterTasks } from './troubleshooter.js';
17
17
  import { prepareManagedExecutionWorktree } from './workspace.js';
18
18
  import { createReviewChairExecutionPlan } from './review-chair.js';
19
19
  import { samePlannerChildDefinition } from './planner-child.js';
20
+ import { taskTagWorkerPrompt } from './task-tag.js';
20
21
  const PRIORITY_RANK = { P0: 0, P1: 1, P2: 2, P3: 3 };
21
22
  export function compareTaskPriority(a, b, jobPriority = 'P2') { const ar = PRIORITY_RANK[a.priority ?? jobPriority]; const br = PRIORITY_RANK[b.priority ?? jobPriority]; return ar - br || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id); }
22
23
  export async function managedWorkerInput(job, task, context) {
@@ -37,6 +38,7 @@ export async function managedWorkerInput(job, task, context) {
37
38
  const output = task.output.kind === 'file'
38
39
  ? `file\nPath: ${task.output.path}`
39
40
  : `task/PR\nPR: ${prUrl}\nTask: ${task.id}`;
41
+ const taskTag = taskTagWorkerPrompt(job);
40
42
  return `[Agents Relay managed task]
41
43
  Job URL: ${prUrl}
42
44
  Job ID: ${job.id}
@@ -51,7 +53,7 @@ ${context ? executionContextPrompt(context) + '\n\n' : ''}${selectedAgent ? sele
51
53
  [Output contract]
52
54
  ${output}
53
55
 
54
- [Execution event contract — mandatory]
56
+ ${taskTag}[Execution event contract — mandatory]
55
57
  Use the canonical Neo events-bus with exactly Job ${job.id} and Task ${task.id}.
56
58
  Your exact event source identity is: ${sourceId}
57
59
  Every lifecycle event for this execution MUST set source.id to that exact string. Do not derive, rename, or replace it.
@@ -0,0 +1,43 @@
1
+ const TAG_VALUE = /^[A-Za-z0-9_.-]+$/;
2
+ export function parseJobTagJson(raw) {
3
+ if (!raw)
4
+ return undefined;
5
+ let parsed;
6
+ try {
7
+ parsed = JSON.parse(raw);
8
+ }
9
+ catch {
10
+ throw new Error('--tag must be valid JSON');
11
+ }
12
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
13
+ throw new Error('--tag must be a JSON object');
14
+ const value = parsed;
15
+ if (typeof value.description !== 'string' || !value.description.trim() || value.description.length > 500)
16
+ throw new Error('--tag.description must be a non-empty string up to 500 characters');
17
+ if (!value.values || typeof value.values !== 'object' || Array.isArray(value.values) || Object.keys(value.values).length === 0)
18
+ throw new Error('--tag.values must be a non-empty object');
19
+ const values = {};
20
+ for (const [name, display] of Object.entries(value.values)) {
21
+ if (!TAG_VALUE.test(name) || name.length > 64)
22
+ throw new Error(`--tag.values contains invalid value ${name}`);
23
+ if (typeof display !== 'string' || !display || display.length > 32)
24
+ throw new Error(`--tag.values.${name} must be a non-empty string up to 32 characters`);
25
+ values[name] = display;
26
+ }
27
+ return { description: value.description, values };
28
+ }
29
+ export function acceptedReportedTag(job, data) {
30
+ const tag = data?.tag;
31
+ return typeof tag === 'string' && job.tag?.values[tag] !== undefined ? tag : undefined;
32
+ }
33
+ export function eventDataWithoutTag(data) {
34
+ if (!data || !Object.prototype.hasOwnProperty.call(data, 'tag'))
35
+ return data;
36
+ const { tag: _tag, ...rest } = data;
37
+ return Object.keys(rest).length ? rest : undefined;
38
+ }
39
+ export function taskTagWorkerPrompt(job) {
40
+ if (!job.tag)
41
+ return '';
42
+ return `[Job-defined Task tag]\nMeaning/when to set it: ${job.tag.description}\nAllowed values: ${JSON.stringify(Object.keys(job.tag.values))}\nWhen applicable, report exactly one allowed value in the terminal event as data.tag. The tag is separate from Task lifecycle state.\n\n`;
43
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "1.0.156",
3
+ "version": "1.0.158",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {
@@ -50,105 +50,12 @@ For feature/refactor work with no failure to diagnose, root cause is not require
50
50
 
51
51
  Keep these concepts distinct: observation is not root cause; hypothesis is not root cause; requested approach is not implemented solution; implementation is not validation.
52
52
 
53
- ## Worker routing order
53
+ ### Job-defined Task tag
54
54
 
55
- Every new model-backed executable task follows this order:
55
+ Canonical `Task.state` is reserved for Relay lifecycle only. A Job may optionally declare one `tag` contract with a human-readable `description` explaining what the tag means and when workers should set it, plus an allowed `values` map from durable tag values to display strings. A Task stores the reported durable value in `tag`.
56
56
 
57
- 1. The planner describes the executable Task precisely. It does not invent a capability matrix for Codex versus ChatGPT. workerRequirements is optional compatibility metadata; an explicit runtime may be used only when the caller already knows the Task must use that runtime.
58
- 2. worker-router gathers factual Task context, discovered worker availability, the current shared usage snapshot, built-in routing principles, and optional local free-text rules. Only authoritative legality facts such as an explicitly offline worker or an explicit caller-owned runtime constraint are hard gates. Descriptive capability labels, region/trust labels, and provider quota are decision facts rather than ordinary worker hard gates.
59
- 3. Execution Router builds complete executable routes that pair a discovered worker with one configured/authorized model profile. Only authoritative impossibility facts are hard gates: explicit runtime/routing constraints, offline/unavailable execution surfaces, zero allocatable Browser Workspace capacity, unconfigured/unauthorized models, or provider/profile state known to make execution impossible.
60
- 4. Capabilities, task kind, trust/region/labels, quota pressure, cost, latency, runtime load, reviewer diversity, and free-text environment guidance are soft decision facts unless the caller has explicitly promoted one to a hard constraint. The full workerRequirements object must be passed into the decision facts.
61
- 5. When more than one executable route exists, Execution Router calls the generic typed decide(request, contract) primitive exactly once to choose worker+provider/model/profile/reasoning together. If the decision engine is unavailable, invalid, or fails, Relay deterministically falls back to a usable Codex route, preferring Codex's configured default profile. Codex fallback is not a normal preference when decide() succeeds.
62
- 6. decide() owns no discovery facts or legality policy. Its optional Laya implementation is a transient CLI subprocess per decision; src/usage.ts remains the shared resource-fact source. Relay persists the selected worker and model routing on the durable task before launch.
57
+ A model-backed worker reports the applicable value in its canonical terminal event under `data.tag`, for example `{"tag":"pass"}`. Relay validates it against `Job.tag.values`, persists only valid values into the Task marker's `tag`, and does not reinterpret the tag as lifecycle state. The transport `data.tag` is not duplicated inside `result.data`. Jobs without a tag contract are unchanged.
63
58
 
64
- Planner output must not hard-code codex/chatgpt, provider preference, inferred worker capabilities, or usage probes. Execution Router owns runtime route selection; legacy task records that already contain adapter/routing/worker-requirement metadata remain readable.
65
-
66
- ## Story size
67
-
68
- Every worker task has one of three story sizes:
69
-
70
- - `S`: about 5 minutes of normal-reasoning execution.
71
- - `M`: about 15 minutes of normal-reasoning execution. This is the default when size is absent or uncertain.
72
- - `L`: about 45 minutes of normal-reasoning execution.
73
-
74
- Relay derives the overall execution timeout from `storySize`. If `routing.reasoning` is `high`, Relay doubles the overall execution timeout. Other reasoning levels do not change it.
75
-
76
- Raw `timeoutMs` is a compatibility/debug override, not the normal task-production interface.
77
-
78
- ## Task readiness states
79
-
80
- 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.
81
-
82
- Task readiness is explicit and must use these semantics:
83
-
84
- - `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`.
85
- - `QUEUED`: executable now; Relay may launch it immediately subject only to worker capacity/routing.
86
- - `RUNNING`: worker execution has actually started.
87
- - `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`.
88
- - `SUCCEEDED`, `FAILED`, and `CANCELLED` are terminal outcomes, except that retry explicitly requeues a failed/blocked Task.
89
-
90
- 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.
91
-
92
- ## Orchestrator self-task ownership
93
-
94
- A Task with `adapter: orchestrator` is synchronous self-work owned by the exact orchestrator turn that creates it. It is not a durable handoff to Relay, another worker, a later reconcile cycle, or a future orchestrator thread.
95
-
96
- - Creating an `adapter: orchestrator` Task is an execution commitment: the creating orchestrator must execute it in the current turn.
97
- - The creating orchestrator must not end its turn while any orchestrator Task it created remains `QUEUED` or `RUNNING`; it must drive each such Task to a terminal state before returning control to the user.
98
- - Relay must not auto-start, lease, route, or emit synthetic `task.started` / assignment evidence for orchestrator Tasks. A queued orchestrator Task means the creator has not yet terminalized its self-work.
99
- - Successful self-work is terminalized on the same durable Task with `task complete --task-id ... --summary ...`; do not use `record` as a substitute for a Task that was created before execution. `record` remains a backfill mechanism for work that genuinely happened outside Relay.
100
- - If work is intended to survive the current turn or be picked up asynchronously, use a real worker adapter rather than `orchestrator`.
101
-
102
- ## Cross-Job blocker dependencies
103
-
104
- Task `dependencies` remain local to one Job and must contain only Task IDs from that Job. Shared runtime/tool/platform blockers use a separate durable cross-Job relation. The blocker Job owns reverse `unblocks` records identifying dependent repository, PR, Job, Task, and blocked attempt. The dependent Task may carry a lightweight `blockedBy` reference for observability and deduplication. Planner output follows the same ownership rule: when the current Job is the prerequisite, Planner declares dependents through top-level `unblocks`; Relay persists those declarations on the blocker Job via the blocker subsystem. Planner must never encode cross-Job dependencies as local `dependencies`. A dependent may be registered while its Task is still `DRAFT`, and blocker resolution releases that Task when the blocker resolves.
105
-
106
- Blocker resolution is generation-safe and idempotent: Relay releases a dependent only when it is still DRAFT/FAILED/BLOCKED on the recorded attempt. The blocker-side `unblocks` record is authoritative; dependent-side `blockedBy` may be absent. Otherwise the relation becomes obsolete. Merge-mode blockers resolve when the blocker PR is observed merged. Blockers requiring package publication, deployment, runtime health, human/external verification, or another post-merge fact use verified mode and resolve only through the explicit verified-resolution path. Existing failure evidence is preserved; DRAFT dependents use the normal release transition and FAILED/BLOCKED dependents use the normal retry transition to QUEUED.
107
-
108
- ## Worker lifecycle
109
-
110
- 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. Because the ChatGPT worker continues asynchronously after its submit process exits, its managed execution prompt must also require the shared events-bus liveness rule: publish meaningful progress when available, and publish non-terminal `task.heartbeat` after each 30 seconds of otherwise silent active work until a terminal task event is emitted.
111
-
112
- Relay allocates the durable `executionId` before launching model-backed work and gives that worker one exact event source identity: `job/<job-id>/task/<task-id>/execution/<execution-id>`. Every semantic lifecycle event from that execution must copy the identity verbatim in `source.id`. Relay correlates model-worker lifecycle only by this execution identity; `source.agent`, `source.component`, provider/adapter names, and `data.worker_owned` are diagnostic/legacy metadata and MUST NOT authorize or correlate lifecycle state. A retry receives a new execution identity, so events from an older execution are ignored.
113
-
114
- A durable execution lease survives Relay process/owner restarts until its `leaseExpiresAt`. A different Relay owner is not evidence that the worker died and MUST NOT by itself reclaim or retry that execution. Recovery may reclaim only after the durable lease expires or another authoritative terminal/liveness fact proves the execution ended.
115
-
116
- For a GitHub-backed Job, every mutating model-backed execution runs from an isolated worktree pinned to that PR's current head SHA. It must not run from or switch the user's primary checkout. Relay verifies the remote repository and exact PR head before launch and fails closed when it cannot prepare that checkout. Concurrent PR Jobs for the same repository use separate execution worktrees.
117
-
118
- ## Job lifecycle and runtime status
119
-
120
- 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.
121
-
122
- Durable Job lifecycle is intentionally small: `DRAFT | ACTIVE | COMPLETED | CANCELLED`.
123
-
124
- - `DRAFT`: durable objective exists, but the execution engine must not run it.
125
- - `ACTIVE`: the execution engine may reconcile the Job. Scheduler, external triggers, manual actions, dependency release, workers, and review may all advance an ACTIVE Job.
126
- - `COMPLETED`: terminal successful lifecycle. `job merge` merges the backing PR; a merged PR is authoritative and reconciles the Job to COMPLETED.
127
- - `CANCELLED`: terminal cancellation. `job cancel` closes the backing PR; a closed, unmerged managed PR also reconciles the Job to CANCELLED.
128
-
129
- 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.
130
-
131
- ## Job and review lifecycle
132
-
133
- 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.
134
-
135
- Final-review routing follows the same factual worker-selection boundary as executable work. Explicitly offline workers and explicit reviewer runtime constraints are hard gates; provider quota and descriptive capability labels do not by themselves make a worker illegal. Among eligible workers, Relay prefers a reviewer with a different worker identity from the implementation tasks; when that is unavailable it prefers a different provider/model, then falls back to the first eligible worker. The selected worker, selectedBy reason, and reviewerRouting event data are durable evidence. No provider is a universal reviewer default.
136
-
137
- ## Job, Task, and planner ownership
138
-
139
- The public CLI resource surface is:
140
-
141
- - `job create|update|show|list|merge|cancel`
142
- - `task create|update|show|list|complete|retry|cancel`
143
- - `plan`
144
-
145
- `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. `plan` is the explicit public operation for invoking the Planner against an existing Job; it is not generic CRUD for planner records.
146
-
147
- Relay invokes the planner agent, validates its structured result, persists that invocation as one real `plan-N` round, then materializes `next_tasks`. Planner rounds are planning-history records, not business-tree parents. Their history is linked by planner provenance (`previousPlannerTaskId` / planning-round metadata); business Task hierarchy is expressed only through work Task `parentTaskId`. `--prompt` / `--prompt-file` add caller guidance to the durable planning input without replacing the Job objective. The planner agent itself does not call Relay CLI or persist Tasks. Only an actual planner invocation creates a planning round. Historical `planner-N` rounds remain readable and participate in round numbering.
148
-
149
- A zero-task autonomous Job stays idle under ordinary reconciliation, but an explicit `plan` command can start it immediately. An autonomous Job may also begin with one or more direct orchestrator-created Tasks and no Plan. After that direct batch completes, Relay may create the next real planning round through the same round-construction/execution path used by manual `plan`. Later rounds continue until the objective is satisfied. Fixed Jobs never create autonomous planner rounds automatically.
150
-
151
- Planner-produced Tasks obey this same contract, including explicit `storySize`. A planner may set `parentTaskId` to another Task ID in the current Job to create durable business hierarchy. Omit it for root Tasks. Never use a `plan-N` Task as a business parent.
152
59
 
153
60
  ### Optional Review Chair Tasks
154
61