agents-relay 1.0.157 → 1.0.159
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 +2 -2
- package/dist/cli-support.js +2 -2
- package/dist/cli.js +5 -5
- package/dist/dashboard-html.js +2 -2
- package/dist/reconciler-runtime.js +5 -5
- package/dist/reconciler.js +3 -3
- package/dist/task-tag.js +43 -0
- package/package.json +1 -1
- package/skills/agents-relay/contracts/task-job-contract.md +34 -94
- package/dist/task-fields.js +0 -68
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, --
|
|
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, --
|
|
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:
|
package/dist/cli-support.js
CHANGED
|
@@ -3,7 +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 {
|
|
6
|
+
import { parseJobTagJson } from './task-tag.js';
|
|
7
7
|
export const SERVICE_DEFAULTS = { port: 8787, watchdogMs: 300000, webhookWatchdogMs: 300000, dashboardRefreshMs: 300000 };
|
|
8
8
|
export class ServiceEventBus {
|
|
9
9
|
upstream;
|
|
@@ -98,7 +98,7 @@ export function chatGptTabClosePolicy(args) {
|
|
|
98
98
|
return value;
|
|
99
99
|
}
|
|
100
100
|
export function requestedExecutionMode(args) { const value = arg(args, '--mode', arg(args, '--execution-mode')); return value ? executionMode(args) : undefined; }
|
|
101
|
-
export function
|
|
101
|
+
export function jobTagFromArgs(args) { return parseJobTagJson(arg(args, '--tag')); }
|
|
102
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; }
|
|
103
103
|
export function scheduleFromArgs(args) {
|
|
104
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,
|
|
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
|
-
|
|
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
|
-
|
|
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, '--
|
|
412
|
-
job.
|
|
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'))
|
package/dist/dashboard-html.js
CHANGED
|
@@ -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
|
|
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)));
|
|
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,7 +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 {
|
|
8
|
+
import { acceptedReportedTag, eventDataWithoutTag } from './task-tag.js';
|
|
9
9
|
export function executionEventSource(jobId, taskId, executionId) { return `job/${jobId}/task/${taskId}/execution/${executionId}`; }
|
|
10
10
|
function eventSourceId(event) { return typeof event.source?.id === 'string' ? event.source.id : undefined; }
|
|
11
11
|
const terminalExecutionClaims = new Set();
|
|
@@ -339,11 +339,11 @@ export class ReconcilerRuntimeBase {
|
|
|
339
339
|
task.updatedAt = event.timestamp || new Date().toISOString();
|
|
340
340
|
this.terminalWaiters.get(task.id)?.resolve();
|
|
341
341
|
this.terminalWaiters.delete(task.id);
|
|
342
|
-
const
|
|
343
|
-
if (
|
|
344
|
-
task.
|
|
342
|
+
const reportedTag = acceptedReportedTag(job, event.data);
|
|
343
|
+
if (reportedTag)
|
|
344
|
+
task.tag = reportedTag;
|
|
345
345
|
if (event.type === 'task.completed') {
|
|
346
|
-
task.result = { summary: event.message || `Task ${task.id} completed`, data:
|
|
346
|
+
task.result = { summary: event.message || `Task ${task.id} completed`, data: eventDataWithoutTag(event.data) };
|
|
347
347
|
task.error = null;
|
|
348
348
|
task.failureReason = undefined;
|
|
349
349
|
}
|
package/dist/reconciler.js
CHANGED
|
@@ -17,7 +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 {
|
|
20
|
+
import { taskTagWorkerPrompt } from './task-tag.js';
|
|
21
21
|
const PRIORITY_RANK = { P0: 0, P1: 1, P2: 2, P3: 3 };
|
|
22
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); }
|
|
23
23
|
export async function managedWorkerInput(job, task, context) {
|
|
@@ -38,7 +38,7 @@ export async function managedWorkerInput(job, task, context) {
|
|
|
38
38
|
const output = task.output.kind === 'file'
|
|
39
39
|
? `file\nPath: ${task.output.path}`
|
|
40
40
|
: `task/PR\nPR: ${prUrl}\nTask: ${task.id}`;
|
|
41
|
-
const
|
|
41
|
+
const taskTag = taskTagWorkerPrompt(job);
|
|
42
42
|
return `[Agents Relay managed task]
|
|
43
43
|
Job URL: ${prUrl}
|
|
44
44
|
Job ID: ${job.id}
|
|
@@ -53,7 +53,7 @@ ${context ? executionContextPrompt(context) + '\n\n' : ''}${selectedAgent ? sele
|
|
|
53
53
|
[Output contract]
|
|
54
54
|
${output}
|
|
55
55
|
|
|
56
|
-
${
|
|
56
|
+
${taskTag}[Execution event contract — mandatory]
|
|
57
57
|
Use the canonical Neo events-bus with exactly Job ${job.id} and Task ${task.id}.
|
|
58
58
|
Your exact event source identity is: ${sourceId}
|
|
59
59
|
Every lifecycle event for this execution MUST set source.id to that exact string. Do not derive, rename, or replace it.
|
package/dist/task-tag.js
ADDED
|
@@ -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
|
@@ -50,111 +50,51 @@ 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
|
-
### Job-defined Task
|
|
53
|
+
### Job-defined Task tag
|
|
54
54
|
|
|
55
|
-
|
|
55
|
+
`Task.state` is reserved for Relay lifecycle only: `DRAFT`, `QUEUED`, `RUNNING`, `BLOCKED`, `SUCCEEDED`, `FAILED`, and `CANCELLED`. A Job may additionally define one optional `tag` contract for a Job-specific result or classification that should be visible on each Task without changing its lifecycle state.
|
|
56
56
|
|
|
57
|
-
|
|
57
|
+
The Job owns the meaning of the tag. Its trusted Job marker declares a human-readable `description` explaining what the tag means and when workers should set it, plus the allowed durable values and their dashboard display strings:
|
|
58
58
|
|
|
59
|
-
|
|
59
|
+
```json
|
|
60
|
+
{
|
|
61
|
+
"tag": {
|
|
62
|
+
"description": "Deterministic E2E outcome. Set pass when the fixed E2E exits 0; set fail when it still fails after the required retry.",
|
|
63
|
+
"values": {
|
|
64
|
+
"pass": "✅",
|
|
65
|
+
"fail": "❌"
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
```
|
|
60
70
|
|
|
61
|
-
|
|
71
|
+
A worker does **not** edit the Task marker directly. When the Job defines a tag and the tag applies to the work just performed, the worker reports one allowed durable value in the canonical terminal event as `data.tag`:
|
|
62
72
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
73
|
+
```json
|
|
74
|
+
{
|
|
75
|
+
"type": "task.completed",
|
|
76
|
+
"data": {
|
|
77
|
+
"tag": "pass",
|
|
78
|
+
"exit_code": 0
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
```
|
|
69
82
|
|
|
70
|
-
|
|
83
|
+
Relay validates `data.tag` against `Job.tag.values`. If the value is allowed, Relay persists it into the trusted Task marker as the Task's `tag`; undeclared or invalid values are ignored. The transport `data.tag` is not duplicated into `result.data`. The resulting Task metadata may therefore contain both independent concepts at once:
|
|
71
84
|
|
|
72
|
-
|
|
85
|
+
```json
|
|
86
|
+
{
|
|
87
|
+
"state": "SUCCEEDED",
|
|
88
|
+
"tag": "pass"
|
|
89
|
+
}
|
|
90
|
+
```
|
|
73
91
|
|
|
74
|
-
|
|
92
|
+
The dashboard always keeps the normal lifecycle badge. When a valid Task tag is present, it additionally renders the display string from `Job.tag.values` after the lifecycle status. For the example above this appears as `SUCCEEDED ✅`.
|
|
75
93
|
|
|
76
|
-
|
|
77
|
-
- `M`: about 15 minutes of normal-reasoning execution. This is the default when size is absent or uncertain.
|
|
78
|
-
- `L`: about 45 minutes of normal-reasoning execution.
|
|
94
|
+
A tag must describe something specific to the Job contract, not Relay lifecycle. Examples include an E2E result (`pass` / `fail`), publication result, validation classification, or another small Job-defined outcome. The Job description must tell the worker when to set the tag and what each allowed value means. Jobs without a `tag` declaration behave exactly as before.
|
|
79
95
|
|
|
80
|
-
|
|
96
|
+
For the ChatGPT Browser Worker guardian, for example, the Job defines `pass -> ✅` and `fail -> ❌`; each hourly E2E child Task reports `data.tag` after running the fixed deterministic E2E, while the Task lifecycle independently records whether Relay execution itself completed, failed, or became blocked.
|
|
81
97
|
|
|
82
|
-
Raw `timeoutMs` is a compatibility/debug override, not the normal task-production interface.
|
|
83
|
-
|
|
84
|
-
## Task readiness states
|
|
85
|
-
|
|
86
|
-
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.
|
|
87
|
-
|
|
88
|
-
Task readiness is explicit and must use these semantics:
|
|
89
|
-
|
|
90
|
-
- `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`.
|
|
91
|
-
- `QUEUED`: executable now; Relay may launch it immediately subject only to worker capacity/routing.
|
|
92
|
-
- `RUNNING`: worker execution has actually started.
|
|
93
|
-
- `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`.
|
|
94
|
-
- `SUCCEEDED`, `FAILED`, and `CANCELLED` are terminal outcomes, except that retry explicitly requeues a failed/blocked Task.
|
|
95
|
-
|
|
96
|
-
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.
|
|
97
|
-
|
|
98
|
-
## Orchestrator self-task ownership
|
|
99
|
-
|
|
100
|
-
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.
|
|
101
|
-
|
|
102
|
-
- Creating an `adapter: orchestrator` Task is an execution commitment: the creating orchestrator must execute it in the current turn.
|
|
103
|
-
- 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.
|
|
104
|
-
- 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.
|
|
105
|
-
- 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.
|
|
106
|
-
- If work is intended to survive the current turn or be picked up asynchronously, use a real worker adapter rather than `orchestrator`.
|
|
107
|
-
|
|
108
|
-
## Cross-Job blocker dependencies
|
|
109
|
-
|
|
110
|
-
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.
|
|
111
|
-
|
|
112
|
-
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.
|
|
113
|
-
|
|
114
|
-
## Worker lifecycle
|
|
115
|
-
|
|
116
|
-
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.
|
|
117
|
-
|
|
118
|
-
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.
|
|
119
|
-
|
|
120
|
-
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.
|
|
121
|
-
|
|
122
|
-
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.
|
|
123
|
-
|
|
124
|
-
## Job lifecycle and runtime status
|
|
125
|
-
|
|
126
|
-
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.
|
|
127
|
-
|
|
128
|
-
Durable Job lifecycle is intentionally small: `DRAFT | ACTIVE | COMPLETED | CANCELLED`.
|
|
129
|
-
|
|
130
|
-
- `DRAFT`: durable objective exists, but the execution engine must not run it.
|
|
131
|
-
- `ACTIVE`: the execution engine may reconcile the Job. Scheduler, external triggers, manual actions, dependency release, workers, and review may all advance an ACTIVE Job.
|
|
132
|
-
- `COMPLETED`: terminal successful lifecycle. `job merge` merges the backing PR; a merged PR is authoritative and reconciles the Job to COMPLETED.
|
|
133
|
-
- `CANCELLED`: terminal cancellation. `job cancel` closes the backing PR; a closed, unmerged managed PR also reconciles the Job to CANCELLED.
|
|
134
|
-
|
|
135
|
-
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.
|
|
136
|
-
|
|
137
|
-
## Job and review lifecycle
|
|
138
|
-
|
|
139
|
-
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.
|
|
140
|
-
|
|
141
|
-
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.
|
|
142
|
-
|
|
143
|
-
## Job, Task, and planner ownership
|
|
144
|
-
|
|
145
|
-
The public CLI resource surface is:
|
|
146
|
-
|
|
147
|
-
- `job create|update|show|list|merge|cancel`
|
|
148
|
-
- `task create|update|show|list|complete|retry|cancel`
|
|
149
|
-
- `plan`
|
|
150
|
-
|
|
151
|
-
`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.
|
|
152
|
-
|
|
153
|
-
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.
|
|
154
|
-
|
|
155
|
-
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.
|
|
156
|
-
|
|
157
|
-
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.
|
|
158
98
|
|
|
159
99
|
### Optional Review Chair Tasks
|
|
160
100
|
|
package/dist/task-fields.js
DELETED
|
@@ -1,68 +0,0 @@
|
|
|
1
|
-
const FIELD_NAME = /^[A-Za-z0-9_.-]+$/;
|
|
2
|
-
export function parseJobTaskFields(value, option = '--task-fields') {
|
|
3
|
-
if (value === undefined || value === null)
|
|
4
|
-
return undefined;
|
|
5
|
-
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
6
|
-
throw new Error(`${option} must be a JSON object`);
|
|
7
|
-
const out = {};
|
|
8
|
-
for (const [name, rawDefinition] of Object.entries(value)) {
|
|
9
|
-
if (!FIELD_NAME.test(name) || name.length > 64)
|
|
10
|
-
throw new Error(`${option} contains invalid field name ${name}`);
|
|
11
|
-
if (!rawDefinition || typeof rawDefinition !== 'object' || Array.isArray(rawDefinition))
|
|
12
|
-
throw new Error(`${option}.${name} must be an object`);
|
|
13
|
-
const definition = rawDefinition;
|
|
14
|
-
const label = definition.label;
|
|
15
|
-
if (label !== undefined && (typeof label !== 'string' || !label.trim() || label.length > 64))
|
|
16
|
-
throw new Error(`${option}.${name}.label must be a non-empty string up to 64 characters`);
|
|
17
|
-
const rawValues = definition.values;
|
|
18
|
-
if (!rawValues || typeof rawValues !== 'object' || Array.isArray(rawValues) || Object.keys(rawValues).length === 0)
|
|
19
|
-
throw new Error(`${option}.${name}.values must be a non-empty object`);
|
|
20
|
-
const values = {};
|
|
21
|
-
for (const [fieldValue, display] of Object.entries(rawValues)) {
|
|
22
|
-
if (!FIELD_NAME.test(fieldValue) || fieldValue.length > 64)
|
|
23
|
-
throw new Error(`${option}.${name}.values contains invalid value ${fieldValue}`);
|
|
24
|
-
if (typeof display !== 'string' || !display || display.length > 32)
|
|
25
|
-
throw new Error(`${option}.${name}.values.${fieldValue} must be a non-empty string up to 32 characters`);
|
|
26
|
-
values[fieldValue] = display;
|
|
27
|
-
}
|
|
28
|
-
out[name] = { ...(typeof label === 'string' ? { label } : {}), values };
|
|
29
|
-
}
|
|
30
|
-
return out;
|
|
31
|
-
}
|
|
32
|
-
export function parseJobTaskFieldsJson(raw) {
|
|
33
|
-
if (!raw)
|
|
34
|
-
return undefined;
|
|
35
|
-
let parsed;
|
|
36
|
-
try {
|
|
37
|
-
parsed = JSON.parse(raw);
|
|
38
|
-
}
|
|
39
|
-
catch {
|
|
40
|
-
throw new Error('--task-fields must be valid JSON');
|
|
41
|
-
}
|
|
42
|
-
return parseJobTaskFields(parsed);
|
|
43
|
-
}
|
|
44
|
-
export function acceptedReportedTaskFields(job, data) {
|
|
45
|
-
const raw = data?.taskFields;
|
|
46
|
-
if (!raw || typeof raw !== 'object' || Array.isArray(raw) || !job.taskFields)
|
|
47
|
-
return undefined;
|
|
48
|
-
const accepted = {};
|
|
49
|
-
for (const [name, value] of Object.entries(raw)) {
|
|
50
|
-
if (typeof value !== 'string')
|
|
51
|
-
continue;
|
|
52
|
-
const definition = job.taskFields[name];
|
|
53
|
-
if (definition?.values[value] !== undefined)
|
|
54
|
-
accepted[name] = value;
|
|
55
|
-
}
|
|
56
|
-
return Object.keys(accepted).length ? accepted : undefined;
|
|
57
|
-
}
|
|
58
|
-
export function eventDataWithoutTaskFields(data) {
|
|
59
|
-
if (!data || !Object.prototype.hasOwnProperty.call(data, 'taskFields'))
|
|
60
|
-
return data;
|
|
61
|
-
const { taskFields: _taskFields, ...rest } = data;
|
|
62
|
-
return Object.keys(rest).length ? rest : undefined;
|
|
63
|
-
}
|
|
64
|
-
export function taskFieldsWorkerPrompt(job) {
|
|
65
|
-
if (!job.taskFields || Object.keys(job.taskFields).length === 0)
|
|
66
|
-
return '';
|
|
67
|
-
return `[Job-defined Task fields]\nThe Job defines these Task result fields: ${JSON.stringify(job.taskFields)}\nWhen a field applies to this execution, report its exact allowed value in the terminal event under data.taskFields. Example: data: { taskFields: { e2e: \"pass\" } }. These fields are separate from Task lifecycle state.\n\n`;
|
|
68
|
-
}
|