agents-relay 1.0.155 → 1.0.157

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. --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, --task-fields 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, --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, --task-fields 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 { parseJobTaskFieldsJson } from './task-fields.js';
6
7
  export const SERVICE_DEFAULTS = { port: 8787, watchdogMs: 300000, webhookWatchdogMs: 300000, dashboardRefreshMs: 300000 };
7
8
  export class ServiceEventBus {
8
9
  upstream;
@@ -97,6 +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; }
101
+ export function taskFieldsFromArgs(args) { return parseJobTaskFieldsJson(arg(args, '--task-fields')); }
100
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; }
101
103
  export function scheduleFromArgs(args) {
102
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, 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, taskFieldsFromArgs, 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,6 +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
+ taskFields: options.taskFields,
54
55
  autoMerge: options.autoMerge ?? true,
55
56
  reviewEnabled: options.reviewEnabled ?? true,
56
57
  prNumber: pr.number,
@@ -342,6 +343,7 @@ async function runJobCommand(action, args) {
342
343
  priority: priority(args),
343
344
  executionMode: requestedExecutionMode(args),
344
345
  schedule: scheduleFromArgs(args),
346
+ taskFields: taskFieldsFromArgs(args),
345
347
  autoMerge: !hasArg(args, '--no-auto-merge'),
346
348
  reviewEnabled: !hasArg(args, '--no-review'),
347
349
  continuation: continuation(args),
@@ -406,6 +408,8 @@ async function runJobCommand(action, args) {
406
408
  job.executionMode = executionMode(args, job.executionMode ?? 'fixed');
407
409
  if (hasArg(args, '--schedule'))
408
410
  job.schedule = scheduleFromArgs(args);
411
+ if (hasArg(args, '--task-fields'))
412
+ job.taskFields = taskFieldsFromArgs(args);
409
413
  if (hasArg(args, '--auto-merge') && hasArg(args, '--no-auto-merge'))
410
414
  throw new Error('choose only one of --auto-merge or --no-auto-merge');
411
415
  if (hasArg(args, '--auto-merge'))
@@ -320,8 +320,9 @@ 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 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)));line.append(row);return li;}
324
- 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));}
323
+ function taskFieldDisplays(job,task){const out=[];for(const [name,value] of Object.entries(task.fields||{})){const def=job?.taskFields?.[name];const display=def?.values?.[value];if(typeof display==='string'&&display)out.push({name,label:def.label||name,value,display});}return out;}
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)));for(const field of taskFieldDisplays(selected(),task)){const badge=element('span','task-field',text(field.display));badge.title=field.label+': '+field.value;row.append(badge);}line.append(row);return li;}
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));}
325
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;}
326
327
  const TASK_STATES=['DRAFT','QUEUED','RUNNING','BLOCKED','SUCCEEDED','FAILED','CANCELLED'];
327
328
  async function setTaskState(task,state,select){const job=selected();if(!job||state===task.state)return;select.disabled=true;try{const response=await fetch('/api/task-state?id='+encodeURIComponent(job.id)+'&task='+encodeURIComponent(task.id)+'&state='+encodeURIComponent(state),{method:'POST'});if(!response.ok)throw new Error(response.status+' '+response.statusText);const updated=await response.json();const index=jobs.findIndex(candidate=>candidate.id===job.id);if(index>=0)jobs[index]=updated;else jobs.push(updated);renderAll();void loadEventHistory();}catch(error){select.title='Set task state: '+error.message;select.value=task.state;}finally{select.disabled=false;}}
@@ -1,3 +1,3 @@
1
1
  export function samePlannerChildDefinition(left, right) {
2
- return JSON.stringify({ id: left.id, input: left.input, output: left.output, review: left.review ?? null, priority: left.priority, storySize: left.storySize ?? 'M', projectName: left.projectName, agentName: left.agentName, agentGraph: left.agentGraph, dependencies: left.dependencies, capabilities: left.capabilities, adapter: left.adapter, routing: left.routing, continuation: left.continuation ?? null, maxAttempts: left.maxAttempts, timeoutMs: left.timeoutMs }) === JSON.stringify({ id: right.id, input: right.input, output: right.output, review: right.review ?? null, priority: right.priority, storySize: right.storySize ?? 'M', projectName: right.projectName, agentName: right.agentName, agentGraph: right.agentGraph, dependencies: right.dependencies, capabilities: right.capabilities, adapter: right.adapter, routing: right.routing, continuation: right.continuation ?? null, maxAttempts: right.maxAttempts, timeoutMs: right.timeoutMs });
2
+ return JSON.stringify({ id: left.id, parentTaskId: left.parentTaskId, input: left.input, output: left.output, review: left.review ?? null, priority: left.priority, storySize: left.storySize ?? 'M', projectName: left.projectName, agentName: left.agentName, agentGraph: left.agentGraph, dependencies: left.dependencies, capabilities: left.capabilities, adapter: left.adapter, routing: left.routing, continuation: left.continuation ?? null, maxAttempts: left.maxAttempts, timeoutMs: left.timeoutMs }) === JSON.stringify({ id: right.id, parentTaskId: right.parentTaskId, input: right.input, output: right.output, review: right.review ?? null, priority: right.priority, storySize: right.storySize ?? 'M', projectName: right.projectName, agentName: right.agentName, agentGraph: right.agentGraph, dependencies: right.dependencies, capabilities: right.capabilities, adapter: right.adapter, routing: right.routing, continuation: right.continuation ?? null, maxAttempts: right.maxAttempts, timeoutMs: right.timeoutMs });
3
3
  }
package/dist/planner.js CHANGED
@@ -34,11 +34,11 @@ export function createPlannerRoundTask(job, options = {}) {
34
34
  };
35
35
  return {
36
36
  jobId: job.id, id, kind: 'planner', storySize: 'M', agentName: 'planner',
37
- parentTaskId: previousPlanner?.id ?? null, dependencies: [], capabilities: ['planner'], adapter: 'orchestrator',
37
+ parentTaskId: null, dependencies: [], capabilities: ['planner'], adapter: 'orchestrator',
38
38
  input: plannerInput(context), continuation: null, continuationDeliveredAt: null, state: 'QUEUED', attempt: 0,
39
39
  maxAttempts: options.manual ? 1 : 3, leaseOwner: null, leaseExpiresAt: null, executionId: null, threadId: null,
40
40
  result: null, plannerResult: null, error: null, timeoutMs: options.timeoutMs ?? storySizeTimeoutMs('M'),
41
- createdAt: now, updatedAt: now, planningDecisionId: id, planningSource: 'model-planner', planningRound: round,
41
+ createdAt: now, updatedAt: now, planningDecisionId: id, planningSource: 'model-planner', planningRound: round, previousPlannerTaskId: previousPlanner?.id ?? null,
42
42
  plannerGuidance: options.guidance, manualPlanning: options.manual === true,
43
43
  };
44
44
  }
@@ -47,7 +47,7 @@ export function planningSpec(task) {
47
47
  throw new Error(`Initial task ${task.id} requires an output contract`);
48
48
  if (!['codex', 'chatgpt', 'orchestrator'].includes(task.adapter))
49
49
  throw new Error(`Initial task ${task.id} requires a supported adapter`);
50
- return { id: task.id, input: task.input, priority: task.priority, storySize: task.storySize, projectName: task.projectName, agentName: task.agentName, agentGraph: task.agentGraph, review: task.review, dependencies: task.dependencies, capabilities: task.capabilities, workerRequirements: task.workerRequirements, adapter: task.adapter, output: task.output ?? { kind: 'task_pr' }, routing: task.routing, continuation: task.continuation, maxAttempts: task.maxAttempts, timeoutMs: task.timeoutMs };
50
+ return { id: task.id, parentTaskId: task.parentTaskId, input: task.input, priority: task.priority, storySize: task.storySize, projectName: task.projectName, agentName: task.agentName, agentGraph: task.agentGraph, review: task.review, dependencies: task.dependencies, capabilities: task.capabilities, workerRequirements: task.workerRequirements, adapter: task.adapter, output: task.output ?? { kind: 'task_pr' }, routing: task.routing, continuation: task.continuation, maxAttempts: task.maxAttempts, timeoutMs: task.timeoutMs };
51
51
  }
52
52
  function record(value, field = 'result') {
53
53
  if (!value || typeof value !== 'object' || Array.isArray(value))
@@ -189,6 +189,7 @@ export function parsePlannerResult(value) {
189
189
  ids.add(id);
190
190
  if (typeof task.input !== 'string' || task.input.length === 0)
191
191
  throw new Error(`Planner next_tasks[${index}].input is required`);
192
+ const parentTaskId = task.parentTaskId === undefined || task.parentTaskId === null ? task.parentTaskId : optionalString(task.parentTaskId, `next_tasks[${index}].parentTaskId`);
192
193
  const adapter = task.adapter;
193
194
  if (adapter !== undefined && !['codex', 'chatgpt'].includes(String(adapter)))
194
195
  throw new Error(`Planner next_tasks[${index}].adapter must be codex or chatgpt when provided`);
@@ -221,7 +222,7 @@ export function parsePlannerResult(value) {
221
222
  if (typeof timeoutMs !== 'number' || !Number.isFinite(timeoutMs) || timeoutMs < 0)
222
223
  throw new Error(`Planner next_tasks[${index}].timeoutMs is invalid`);
223
224
  return {
224
- id, input: task.input, priority, storySize,
225
+ id, parentTaskId, input: task.input, priority, storySize,
225
226
  projectName: optionalString(task.projectName, 'projectName'), agentName: optionalString(task.agentName, 'agentName'), agentGraph, review,
226
227
  dependencies: stringArray(task.dependencies, 'dependencies'), capabilities: stringArray(task.capabilities, 'capabilities'), workerRequirements: requirements,
227
228
  adapter: adapter, output: output, routing: route,
@@ -230,6 +231,32 @@ export function parsePlannerResult(value) {
230
231
  });
231
232
  return { objective_status: status, assessment: root.assessment, next_tasks, unblocks };
232
233
  }
234
+ export function validatePlannerHierarchy(existing, specs) {
235
+ const existingById = new Map(existing.map(task => [task.id, task]));
236
+ const specsById = new Map(specs.map(spec => [spec.id, spec]));
237
+ for (const spec of specs) {
238
+ const parent = spec.parentTaskId;
239
+ if (!parent)
240
+ continue;
241
+ if (parent === spec.id)
242
+ throw new Error(`Planner next task ${spec.id} cannot parent itself`);
243
+ const existingParent = existingById.get(parent);
244
+ if (existingParent?.kind === 'planner')
245
+ throw new Error(`Planner next task ${spec.id} cannot use planner round ${parent} as parentTaskId`);
246
+ if (!existingParent && !specsById.has(parent))
247
+ throw new Error(`Planner next task ${spec.id} references unknown parentTaskId ${parent}`);
248
+ }
249
+ for (const spec of specs) {
250
+ const seen = new Set([spec.id]);
251
+ let parent = spec.parentTaskId ?? null;
252
+ while (parent && specsById.has(parent)) {
253
+ if (seen.has(parent))
254
+ throw new Error(`Planner next_tasks parentTaskId cycle includes ${parent}`);
255
+ seen.add(parent);
256
+ parent = specsById.get(parent)?.parentTaskId ?? null;
257
+ }
258
+ }
259
+ }
233
260
  export function parsePlannerOutput(output) {
234
261
  const trimmed = output.trim();
235
262
  const candidate = trimmed.replace(/^```(?:json)?\s*/i, '').replace(/\s*```$/, '').trim();
@@ -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 { acceptedReportedTaskFields, eventDataWithoutTaskFields } from './task-fields.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 reportedFields = acceptedReportedTaskFields(job, event.data);
343
+ if (reportedFields)
344
+ task.fields = { ...(task.fields ?? {}), ...reportedFields };
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: eventDataWithoutTaskFields(event.data) };
343
347
  task.error = null;
344
348
  task.failureReason = undefined;
345
349
  }
@@ -4,7 +4,7 @@ import { dependencyReady, schedule, transitionJobEvent, transitionTaskEvent } fr
4
4
  import { implementationComplete } from './runtime-status.js';
5
5
  import { LaunchFailure, StartAckTimeout } from './adapters.js';
6
6
  import { eventFor } from './events.js';
7
- import { createPlannerRoundTask, parsePlannerResult, plannerInput } from './planner.js';
7
+ import { createPlannerRoundTask, parsePlannerResult, plannerInput, validatePlannerHierarchy } from './planner.js';
8
8
  import { agentContractPrompt, executionContextPrompt, resolveExecutionContext } from './execution-context.js';
9
9
  import { agentGraphPrompt, validateAgentSelection } from './agent-graph.js';
10
10
  import { ReconcilerReviewBase } from './reconciler-review.js';
@@ -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 { taskFieldsWorkerPrompt } from './task-fields.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 taskFields = taskFieldsWorkerPrompt(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
+ ${taskFields}[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.
@@ -455,7 +457,7 @@ export class Reconciler extends ReconcilerReviewBase {
455
457
  let promise;
456
458
  try {
457
459
  const executionContext = await this.plannerContext(job, task);
458
- const context = { job, tasks: job.tasks, objective: job.objective ?? job.description ?? job.title, previousPlannerTaskId: task.parentTaskId, executionContext, retryFeedback: task.plannerRetryFeedback, guidance: task.plannerGuidance };
460
+ const context = { job, tasks: job.tasks, objective: job.objective ?? job.description ?? job.title, previousPlannerTaskId: task.previousPlannerTaskId ?? null, executionContext, retryFeedback: task.plannerRetryFeedback, guidance: task.plannerGuidance };
459
461
  task.input = plannerInput(context);
460
462
  await this.store.saveTask(task);
461
463
  promise = planner.plan(context, controller.signal);
@@ -495,6 +497,7 @@ export class Reconciler extends ReconcilerReviewBase {
495
497
  try {
496
498
  const result = parsePlannerResult(raw);
497
499
  const executionContext = await this.plannerContext(job, task);
500
+ validatePlannerHierarchy(job.tasks, result.next_tasks);
498
501
  if (result.unblocks.length > 0 && !this.store.registerBlockerDependent)
499
502
  throw new Error('Durable store does not support Planner cross-Job unblock registration');
500
503
  for (const unblock of result.unblocks)
@@ -546,6 +549,6 @@ export class Reconciler extends ReconcilerReviewBase {
546
549
  plannerChild(job, planner, spec, executionContext) {
547
550
  validateAgentSelection(executionContext, spec.agentName, spec.agentGraph);
548
551
  const now = new Date().toISOString();
549
- return { jobId: job.id, id: spec.id, kind: 'work', priority: spec.priority ?? job.priority, storySize: spec.storySize ?? 'M', projectName: spec.projectName, agentName: spec.agentName, agentGraph: spec.agentGraph, review: spec.review, parentTaskId: planner.id, dependencies: spec.dependencies ?? [], capabilities: spec.capabilities ?? [], workerRequirements: spec.workerRequirements, adapter: 'codex', input: spec.input, output: spec.output, continuation: spec.continuation ?? null, continuationDeliveredAt: null, state: (spec.dependencies?.length ?? 0) > 0 ? 'DRAFT' : 'QUEUED', attempt: 0, maxAttempts: spec.maxAttempts ?? 3, leaseOwner: null, leaseExpiresAt: null, executionId: null, threadId: null, result: null, plannerResult: null, error: null, timeoutMs: spec.timeoutMs ?? taskOverallTimeoutMs(spec.storySize ?? 'M'), createdAt: now, updatedAt: now, planningDecisionId: planner.id, planningSource: planner.planningSource ?? 'model-planner', planningRound: planner.planningRound ?? 1 };
552
+ return { jobId: job.id, id: spec.id, kind: 'work', priority: spec.priority ?? job.priority, storySize: spec.storySize ?? 'M', projectName: spec.projectName, agentName: spec.agentName, agentGraph: spec.agentGraph, review: spec.review, parentTaskId: spec.parentTaskId ?? null, dependencies: spec.dependencies ?? [], capabilities: spec.capabilities ?? [], workerRequirements: spec.workerRequirements, adapter: 'codex', input: spec.input, output: spec.output, continuation: spec.continuation ?? null, continuationDeliveredAt: null, state: (spec.dependencies?.length ?? 0) > 0 ? 'DRAFT' : 'QUEUED', attempt: 0, maxAttempts: spec.maxAttempts ?? 3, leaseOwner: null, leaseExpiresAt: null, executionId: null, threadId: null, result: null, plannerResult: null, error: null, timeoutMs: spec.timeoutMs ?? taskOverallTimeoutMs(spec.storySize ?? 'M'), createdAt: now, updatedAt: now, planningDecisionId: planner.id, planningSource: planner.planningSource ?? 'model-planner', planningRound: planner.planningRound ?? 1 };
550
553
  }
551
554
  }
@@ -0,0 +1,68 @@
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "1.0.155",
3
+ "version": "1.0.157",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {
@@ -6,7 +6,7 @@ Before producing tasks, read and follow `../contracts/task-job-contract.md`.
6
6
 
7
7
  You are a pure planning agent. Relay invokes you with the durable Job, existing Tasks, previous planner rounds, and resolved project context. You return one structured planning result. You do not call the Agents Relay CLI, persist state, create Jobs, or mutate Tasks yourself.
8
8
 
9
- Relay persists each successful planner invocation as one planner round and creates the returned Tasks under that round.
9
+ Relay persists each successful planner invocation as one planner-history round. Returned work Tasks are not automatically children of that round; use `parentTaskId` only when the business Task graph needs hierarchy.
10
10
 
11
11
  ## Planning contract
12
12
 
@@ -88,7 +88,7 @@ For `objective_status: "in_progress"`, `assessment` must be Markdown containing:
88
88
 
89
89
  For `objective_status: "satisfied"`, `assessment` may be a concise evidence-based completion assessment without a graph.
90
90
 
91
- Each item in next_tasks contains id, input, output, and storySize. `unblocks`, when present, is an array of `{repository, prNumber, jobId, taskId, resolutionMode?}` records identifying Tasks that must remain waiting until the current Job resolves their blocker. It may also contain priority, projectName, agentName, dependencies, capabilities, workerRequirements, maxAttempts, and timeoutMs. Adapter/routing fields are selected by Relay after planning; do not invent them.
91
+ Each item in next_tasks contains id, input, output, and storySize. `unblocks`, when present, is an array of `{repository, prNumber, jobId, taskId, resolutionMode?}` records identifying Tasks that must remain waiting until the current Job resolves their blocker. It may also contain parentTaskId, priority, projectName, agentName, dependencies, capabilities, workerRequirements, maxAttempts, and timeoutMs. Adapter/routing fields are selected by Relay after planning; do not invent them.
92
92
 
93
93
  ### Exact `next_tasks` serialization
94
94
 
@@ -100,6 +100,7 @@ Use these exact serialized forms:
100
100
  - `agentGraph`, when present, is one executable Mermaid Agent Graph **string** using `flowchart TD|TB|LR|RL`, for example `"flowchart LR\n build-log-editor --> build-log-producer"`. Do not emit `graph LR`, and do not emit an object with `nodes` or `edges`.
101
101
  - `priority`, when present, is exactly `"P0"`, `"P1"`, `"P2"`, or `"P3"`; otherwise omit it.
102
102
  - `storySize` is exactly `"S"`, `"M"`, or `"L"`.
103
+ - `parentTaskId`, when present, is the durable parent work Task ID in the current Job. Never point it at a `plan-N` planner round.
103
104
 
104
105
  A valid non-satisfied response:
105
106
 
@@ -50,6 +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
+ ### Job-defined Task fields
54
+
55
+ Canonical `Task.state` is reserved for Relay lifecycle only. A Job may declare `taskFields`, a map of Job-specific result fields. Each field declares an optional label and an allowed `values` map whose keys are durable values and whose values are display strings. A Task stores reported values separately in `fields`.
56
+
57
+ A model-backed worker reports applicable Job-defined values in its canonical terminal event under `data.taskFields`, for example `{"taskFields":{"e2e":"pass"}}`. Relay validates reported names and values against the Job declaration, copies only valid values into the Task marker's `fields`, and does not reinterpret them as lifecycle state. The raw `data.taskFields` transport payload is not duplicated inside `result.data`. Jobs without `taskFields` are unchanged.
58
+
53
59
  ## Worker routing order
54
60
 
55
61
  Every new model-backed executable task follows this order:
@@ -144,11 +150,11 @@ The public CLI resource surface is:
144
150
 
145
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.
146
152
 
147
- Relay invokes the planner agent, validates its structured result, persists that invocation as one real `plan-N` round, then materializes `next_tasks` under that planning round. `--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.
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.
148
154
 
149
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.
150
156
 
151
- Planner-produced Tasks obey this same contract, including explicit `storySize`.
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.
152
158
 
153
159
  ### Optional Review Chair Tasks
154
160