agents-relay 1.0.31 → 1.0.33

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -31,9 +31,16 @@ npx agents-relay task update --repo OWNER/REPO --pr 12 --id job-1 --task-id chil
31
31
  --capabilities model,chatgpt,browser-harness
32
32
  ~~~
33
33
 
34
- Jobs default to `fixed`, preserving the original submit-and-run behavior. Use
35
- `--mode autonomous` with `init` or `job create` to make reconciliation own
36
- planner progress. The durable status JSON and dashboard show the mode. An
34
+ Jobs default to `fixed`, preserving the original submit-and-run behavior. Every
35
+ executable task is now created by a completed durable planning decision. `job
36
+ create --start` records `planner-0` (supplied by the orchestrator) and the
37
+ initial task atomically from the caller's perspective; retrying the command is
38
+ idempotent. Use `--mode autonomous` with `init` or `job create` to make
39
+ reconciliation own planner progress. Autonomous creation with only an objective
40
+ may create a model planner first, but it cannot materialize work until that
41
+ planner succeeds. Fixed jobs never grow another planning round.
42
+
43
+ The durable status JSON and dashboard show the mode and planning provenance. An
37
44
  autonomous runtime uses the bundled `skills/agents-relay/agents/planner.agent.md` by default; `--planner-command` is an optional override
38
45
  snapshot as JSON on stdin and must return `{ "objective_status": "in_progress"|"satisfied", "assessment": "...", "next_tasks": [...] }`.
39
46
  Planner-created tasks use stable IDs and the existing parent/subtask tree, so
@@ -107,7 +114,7 @@ The minimal agent-network surface is machine-driven registration and discovery.
107
114
 
108
115
  Managed tasks are descriptive work for agents, not shell commands. Use `codex` or `chatgpt` for worker tasks. Commands such as tests, build tools, Markad Vision, ffmpeg, and other CLIs are invoked by the orchestrator or by an executing agent; they are not worker adapters.
109
116
 
110
- Every model-backed task declares a durable output with `--output task-pr` or `--output file --output-path PATH`. Agents Relay enriches the prompt with the PR URL, job/task identity, output contract, and mandatory terminal-event contract.
117
+ Every model-backed task declares a durable output with `--output task-pr` or `--output file --output-path PATH`. Agents Relay bootstraps workers with the managed PR URL, durable task-comment URL, job/task identity, project, output contract, and mandatory terminal-event contract; workers retrieve the task intent from GitHub rather than receiving a duplicate copy of the stored task prompt.
111
118
 
112
119
  For Codex and ChatGPT, **events are authoritative task state**. Event publication uses the canonical Neo `events-bus` surface (`events__publish` for sandboxed/hosted workers, or `NEO_EVENTS_EMIT` for direct local workers); Agents Relay only subscribes and reconciles. Relay publishes `task.process.launched` only after successful adapter launch; `task.started` is worker-owned and must be a new, correlated start acknowledgement. Codex publishes `task.process.exited` when its synchronous runtime exits, while ChatGPT publishes `task.process.async_exited` after its submission runtime exits; neither substitutes for `task.completed`, `task.failed`, `task.blocked`, or `task.cancelled`. The ChatGPT worker-owned tab follows the configured close policy: `after-start` waits for the correlated start acknowledgement, `never` keeps it open for debug, and `after-terminal` waits for a new exact-task terminal event; start-ack failure closes only the owned tab after 60 seconds. The executing agent must publish exactly one correlated terminal event. Relay persists the matching durable state only from that terminal event. Model-backed tasks require an event bus and time out explicitly if no terminal event arrives.
113
120
 
package/dist/adapters.js CHANGED
@@ -108,7 +108,12 @@ export class StartAckTimeout extends LaunchFailure {
108
108
  }
109
109
  async function loadBrowserWorkerRuntime(context, explicit) {
110
110
  const resolvedSkill = context?.skills.find(skill => skill.name === 'chatgpt-browser-worker');
111
- const candidate = explicit ?? (resolvedSkill ? join(dirname(resolvedSkill.path), 'agents', 'browser-worker.agent.md') : undefined);
111
+ // Once a worker receives a resolved execution context, that context owns
112
+ // skill discovery. The explicit path remains the fallback for direct
113
+ // adapter use, where no shared context has been resolved yet.
114
+ const candidate = resolvedSkill
115
+ ? join(dirname(resolvedSkill.path), 'agents', 'browser-worker.agent.md')
116
+ : explicit;
112
117
  if (!candidate)
113
118
  throw new Error('chatgpt-browser-worker is not visible in the resolved execution context');
114
119
  try {
package/dist/cli.js CHANGED
@@ -14,7 +14,7 @@ import { NatsEventBus, eventFor } from './events.js';
14
14
  import { serveDashboard } from './dashboard.js';
15
15
  import { codexAndZaiUsageRegistry } from './usage.js';
16
16
  import { discoverAgents } from './registry.js';
17
- import { AgentObjectivePlanner, CommandObjectivePlanner } from './planner.js';
17
+ import { AgentObjectivePlanner, CommandObjectivePlanner, orchestratorPlanningDecision } from './planner.js';
18
18
  import { extendRetryBudget } from './scheduler.js';
19
19
  import { resolveExecutionContext } from './execution-context.js';
20
20
  import { defaultWorkspaceRoot, resolveManagedProjectPath } from './workspace.js';
@@ -270,7 +270,7 @@ export async function ensureManagedGitHubJob(client, repositoryName, trustedAuth
270
270
  if (!(error instanceof Error) || !error.message.includes('has no durable'))
271
271
  throw error;
272
272
  const now = new Date().toISOString();
273
- job = { id: options.id, title: options.title || pr.title || 'Agents Relay job', objective: options.objective || pr.body || undefined, description: pr.body || undefined, priority: options.priority ?? 'P2', executionMode: options.executionMode ?? 'fixed', autoMerge: options.autoMerge ?? true, prNumber: pr.number, repository: repositoryName, state: 'OPEN', continuation: options.continuation ?? null, createdAt: now, updatedAt: now, tasks: [] };
273
+ job = { id: options.id, title: options.title || pr.title || 'Agents Relay job', objective: options.objective || pr.body || undefined, description: pr.body || undefined, priority: options.priority ?? 'P2', executionMode: options.executionMode ?? 'fixed', autoMerge: options.autoMerge ?? true, prNumber: pr.number, repository: repositoryName, state: 'OPEN', continuation: options.continuation ?? null, initialPlanning: options.initialTasks?.length ? { status: 'pending', decisionId: 'planner-0' } : undefined, createdAt: now, updatedAt: now, tasks: [] };
274
274
  }
275
275
  if (!job.description && pr.body)
276
276
  job.description = pr.body;
@@ -286,9 +286,40 @@ export async function ensureManagedGitHubJob(client, repositoryName, trustedAuth
286
286
  job.priority = 'P2';
287
287
  if (!job.title && pr.title)
288
288
  job.title = pr.title;
289
- await store.saveJob(job);
289
+ if (options.initialTasks?.length) {
290
+ const decisionId = job.initialPlanning?.decisionId ?? 'planner-0';
291
+ job.initialPlanning = { status: 'pending', decisionId };
292
+ job.updatedAt = new Date().toISOString();
293
+ await store.saveJob(job);
294
+ const decision = orchestratorPlanningDecision(job, options.initialTasks, decisionId, null, 0);
295
+ if (!(await store.load(options.id)).tasks.some(task => task.id === decision.id))
296
+ await store.appendTask(decision);
297
+ for (const original of options.initialTasks) {
298
+ const task = { ...original, parentTaskId: decision.id, planningDecisionId: decision.id, planningSource: 'orchestrator', planningRound: 0 };
299
+ if (!(await store.load(options.id)).tasks.some(item => item.id === task.id))
300
+ await store.appendTask(task);
301
+ }
302
+ job.initialPlanning = { status: 'complete', decisionId };
303
+ job.updatedAt = new Date().toISOString();
304
+ await store.saveJob(job);
305
+ }
306
+ else
307
+ await store.saveJob(job);
290
308
  return { job: await store.load(options.id), pr };
291
309
  }
310
+ async function appendPlannedTask(store, job, task) {
311
+ const existing = job.tasks.find(item => item.id === task.id);
312
+ if (existing)
313
+ return existing;
314
+ const decisionId = `plan-${task.id}`;
315
+ const decision = orchestratorPlanningDecision(job, [task], decisionId, task.parentTaskId, job.tasks.filter(item => item.kind === 'planner').length, 'orchestrator');
316
+ if (!job.tasks.some(item => item.id === decision.id))
317
+ await store.appendTask(decision);
318
+ const planned = { ...task, parentTaskId: task.parentTaskId ?? null, planningDecisionId: decision.id, planningSource: 'orchestrator', planningRound: decision.planningRound };
319
+ await store.appendTask(planned);
320
+ job.tasks.push(planned, decision);
321
+ return planned;
322
+ }
292
323
  function workerTaskFromArgs(job, args, now, requireInput = false) {
293
324
  const model = arg(args, '--model');
294
325
  const adapterValue = arg(args, '--adapter');
@@ -438,12 +469,12 @@ async function runJobCommand(action, args) {
438
469
  if (!id)
439
470
  throw new Error('--id is required for managed GitHub jobs');
440
471
  const start = hasArg(args, '--start');
472
+ const initialTask = start ? workerTaskFromArgs({ id, title: '', executionMode: 'fixed', prNumber: 0, repository: repo, state: 'OPEN', continuation: null, createdAt: '', updatedAt: '', tasks: [] }, args, new Date().toISOString(), true) : undefined;
441
473
  if (start && action !== 'create')
442
474
  throw new Error('--start is only supported by job create');
443
475
  if (start) {
444
476
  if (!hasArg(args, '--adapter'))
445
477
  throw new Error('job create --start requires --adapter codex|chatgpt');
446
- workerTaskFromArgs({ id, title: '', executionMode: 'fixed', prNumber: 0, repository: repo, state: 'OPEN', continuation: null, createdAt: '', updatedAt: '', tasks: [] }, args, new Date().toISOString(), true);
447
478
  }
448
479
  const auth = await githubAuthContext(args);
449
480
  const client = auth.client;
@@ -465,18 +496,11 @@ async function runJobCommand(action, args) {
465
496
  prNumber,
466
497
  head: arg(args, '--head') || undefined,
467
498
  base: arg(args, '--base', 'main'),
468
- body: arg(args, '--body') || undefined
499
+ body: arg(args, '--body') || undefined,
500
+ initialTasks: initialTask ? [initialTask] : undefined
469
501
  });
470
- if (start) {
471
- const task = workerTaskFromArgs(result.job, args, new Date().toISOString(), true);
472
- const existing = result.job.tasks.find(item => item.id === task.id);
473
- const store = new GitHubStore(client, repo, result.pr.number, trustedAuthors);
474
- await store.appendTask(task);
475
- if (!existing) {
476
- await publishWake(eventBus(args), result.job, task.id, `Task ${task.id} submitted`);
477
- result.job.tasks.push(task);
478
- }
479
- }
502
+ if (start)
503
+ await publishWake(eventBus(args), result.job, initialTask?.id ?? 'initial', `Initial planning decision and task ${initialTask?.id ?? 'initial'} recorded`);
480
504
  console.log(JSON.stringify({ repository: repo, pr: result.pr.number, job: result.job.id, executionMode: result.job.executionMode, state: result.job.state, task: start ? arg(args, '--task-id', 'initial') : undefined }, null, 2));
481
505
  }
482
506
  export function runtimePlanner(args) { const plannerCommand = arg([...args], '--planner-command'); const modelRuntime = arg([...args], '--codex', 'codex'); return plannerCommand ? new CommandObjectivePlanner(plannerCommand) : new AgentObjectivePlanner(modelRuntime); }
@@ -621,8 +645,7 @@ async function main() {
621
645
  throw new Error('record requires --summary');
622
646
  const commit = arg(args, '--commit');
623
647
  const task = { jobId: job.id, id, projectName: arg(args, '--project') || undefined, agentName: arg(args, '--agent', 'orchestrator'), parentTaskId: arg(args, '--parent') || null, dependencies: arg(args, '--deps').split(',').filter(Boolean), capabilities: [], adapter: 'orchestrator', input: arg(args, '--input', summary), continuation: null, continuationDeliveredAt: null, state: 'SUCCEEDED', attempt: 1, maxAttempts: 1, leaseOwner: null, leaseExpiresAt: null, executionId: commit || null, threadId: null, result: { summary, data: commit ? { commit } : undefined }, error: null, timeoutMs: 0, createdAt: now, updatedAt: now };
624
- await store.appendTask(task);
625
- job.tasks.push(task);
648
+ await appendPlannedTask(store, job, task);
626
649
  await saveLocal(localFile, job);
627
650
  console.log(`Recorded ${id}`);
628
651
  return;
@@ -631,16 +654,15 @@ async function main() {
631
654
  const now = new Date().toISOString();
632
655
  const task = workerTaskFromArgs(job, args, now);
633
656
  task.id = arg(args, '--task-id', randomUUID());
634
- await store.appendTask(task);
635
- job.tasks.push(task);
657
+ const planned = await appendPlannedTask(store, job, task);
636
658
  if (job.state !== 'RUNNING') {
637
659
  job.state = 'RUNNING';
638
660
  job.updatedAt = now;
639
661
  await store.saveJob(job);
640
662
  }
641
663
  await saveLocal(localFile, job);
642
- await publishWake(bus, job, task.id, `Task ${task.id} submitted`);
643
- console.log(`Submitted ${task.id}`);
664
+ await publishWake(bus, job, planned.id, `Planning decision recorded and task ${planned.id} submitted`);
665
+ console.log(`Submitted ${planned.id}`);
644
666
  return;
645
667
  }
646
668
  if (command === 'status') {
package/dist/dashboard.js CHANGED
@@ -240,7 +240,7 @@ function renderJobList(){jobList.replaceChildren();const visible=filteredJobs();
240
240
  function taskNode(task,children,depth){const li=element('li');const row=document.createElement('button');row.type='button';row.className='task-row';row.setAttribute('aria-current',String(task.id===selectedTaskId));row.title=task.id;row.addEventListener('click',()=>{selectedTaskId=task.id;eventTaskId=task.id;rememberSelection();clearEvents();renderAll();void loadEventHistory();});const id=element('span','task-id',text(task.id));id.style.paddingLeft=Math.min(depth,4)*6+'px';row.append(id,element('span','badge '+value(task.state),text(task.state)));li.append(row);const nested=children.get(task.id)||[];if(nested.length){const ul=element('ul','tree');for(const child of nested)ul.append(taskNode(child,children,depth+1));li.append(ul);}return li;}
241
241
  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));}
242
242
  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;}
243
- function renderTaskDetail(task){taskDetailRoot.replaceChildren();if(!task){taskDetailRoot.append(element('p','empty',text('Select a task to inspect execution details.')));return;}const routing=task.routing||{};const groups=element('div','detail-groups');groups.append(field('Task',task.id),field('Kind',task.kind||'work'),field('State',task.state),field('Project / agent',value(task.projectName)+' / '+value(task.agentName)),field('Parent task',task.parentTaskId||'root'),field('Adapter',task.adapter),field('Provider / model',value(routing.provider)+' / '+value(routing.model)),field('Reasoning / profile',value(routing.reasoning||routing.profile)),field('Attempt',attemptBudget(task)),field('Dependencies',Array.isArray(task.dependencies)&&task.dependencies.length?task.dependencies.join(', '):'none'),chatField(task));if(task.plannerResult)groups.append(field('Objective status',task.plannerResult.objective_status),markdownField('Assessment',task.plannerResult.assessment));if(task.error)groups.append(field('Error',task.error));taskDetailRoot.append(groups);}
243
+ function renderTaskDetail(task){taskDetailRoot.replaceChildren();if(!task){taskDetailRoot.append(element('p','empty',text('Select a task to inspect execution details.')));return;}const routing=task.routing||{};const groups=element('div','detail-groups');groups.append(field('Task',task.id),field('Kind',task.kind||'work'),field('State',task.state),field('Planning decision',task.planningDecisionId||'—'),field('Planning source',task.planningSource||'—'),field('Planning round',task.planningRound===undefined?'—':String(task.planningRound)),field('Project / agent',value(task.projectName)+' / '+value(task.agentName)),field('Parent task',task.parentTaskId||'root'),field('Adapter',task.adapter),field('Provider / model',value(routing.provider)+' / '+value(routing.model)),field('Reasoning / profile',value(routing.reasoning||routing.profile)),field('Attempt',attemptBudget(task)),field('Dependencies',Array.isArray(task.dependencies)&&task.dependencies.length?task.dependencies.join(', '):'none'),chatField(task));if(task.plannerResult)groups.append(field('Objective status',task.plannerResult.objective_status),markdownField('Assessment',task.plannerResult.assessment));if(task.error)groups.append(field('Error',task.error));taskDetailRoot.append(groups);}
244
244
  function reviewStatus(job){const review=job?.review;if(!review)return 'Not started';if(review.outcome==='approved'&&review.headSha===job.headSha)return 'MERGE READY · approved';const stale=Array.isArray(job?.tasks)&&job.tasks.some(task=>task.agentName==='reviewer'&&task.reviewHeadSha&&job.headSha&&task.reviewHeadSha!==job.headSha);if(stale)return 'STALE · awaiting review';return String(review.outcome||'pending').replace('_',' ').toUpperCase();}
245
245
  function renderSelectedJob(){jobDetail.replaceChildren();const job=selected();if(!job){selectedJobTitle.textContent='';jobDetail.append(element('div','empty',text('No durable jobs discovered.')));buildTree([]);renderTaskDetail(null);return;}selectedJobTitle.textContent='— '+value(job.title||job.id);if(job.id!==selectedJob){selectedJob=job.id;selectedTaskId=null;eventTaskId=null;clearEvents();rememberSelection();}const summary=element('div','job-summary');summary.append(field('GitHub state',job.githubState||'UNKNOWN'),field('Durable state',job.state),field('Execution mode',job.executionMode||'fixed'),field('Status',awaitingInitialTask(job)?'Awaiting initial task':job.state),field('Review',reviewStatus(job)),field('Review head SHA',job.review?.headSha||job.headSha||'—'),field('Objective',job.objective||job.description||job.title),prField(job),field('Repository',job.repository||'—'),field('Last activity',relativeTime(job.updatedAt)),field('Task states',compactCounts(job.tasks)));jobDetail.append(summary);const selectedTask=(job.tasks||[]).find(task=>task.id===selectedTaskId)||null;if(selectedTaskId&&!selectedTask){selectedTaskId=null;eventTaskId=null;clearEvents();rememberSelection();}buildTree(job.tasks||[]);renderTaskDetail(selectedTask);renderEventScope();}
246
246
  function renderAll(){jobs.sort(compareJobs);syncFilters();renderJobList();renderSelectedJob();}
package/dist/planner.js CHANGED
@@ -4,6 +4,17 @@ import { fileURLToPath } from 'node:url';
4
4
  import { dirname, join } from 'node:path';
5
5
  import { taskOverallTimeoutMs } from './types.js';
6
6
  import { executionContextPrompt } from './execution-context.js';
7
+ export function planningSpec(task) {
8
+ if (!task.output && task.adapter !== 'orchestrator')
9
+ throw new Error(`Initial task ${task.id} requires an output contract`);
10
+ if (!['codex', 'chatgpt', 'orchestrator'].includes(task.adapter))
11
+ throw new Error(`Initial task ${task.id} requires a supported adapter`);
12
+ return { id: task.id, input: task.input, priority: task.priority, storySize: task.storySize, projectName: task.projectName, agentName: task.agentName, dependencies: task.dependencies, capabilities: task.capabilities, adapter: task.adapter, output: task.output ?? { kind: 'task_pr' }, routing: task.routing, continuation: task.continuation, maxAttempts: task.maxAttempts, timeoutMs: task.timeoutMs };
13
+ }
14
+ export function orchestratorPlanningDecision(job, tasks, id, parentTaskId, round, source = 'orchestrator') {
15
+ const now = new Date().toISOString();
16
+ return { jobId: job.id, id, kind: 'planner', priority: job.priority, storySize: 'S', agentName: source === 'orchestrator' ? 'orchestrator' : 'planner', parentTaskId, dependencies: [], capabilities: ['planner'], adapter: 'orchestrator', input: `Planning decision supplied by ${source} for ${tasks.length} task(s)`, state: 'SUCCEEDED', attempt: 1, maxAttempts: 1, leaseOwner: null, leaseExpiresAt: null, executionId: null, threadId: null, result: { summary: source === 'orchestrator' ? 'Initial task set supplied by orchestrator' : 'Planning decision completed' }, plannerResult: { objective_status: 'in_progress', assessment: source === 'orchestrator' ? 'Initial task set supplied by orchestrator' : 'Planning decision completed', next_tasks: tasks.map(planningSpec) }, error: null, timeoutMs: 0, createdAt: now, updatedAt: now, planningDecisionId: id, planningSource: source, planningRound: round };
17
+ }
7
18
  function record(value) {
8
19
  if (!value || typeof value !== 'object' || Array.isArray(value))
9
20
  throw new Error('Planner result must be an object');
@@ -13,17 +13,20 @@ export function managedWorkerInput(job, task, context) {
13
13
  if (!task.output)
14
14
  throw new Error(`Task ${task.id} requires an explicit output contract`);
15
15
  const prUrl = `https://github.com/${job.repository}/pull/${job.prNumber}`;
16
+ const taskUrl = task.taskUrl ?? `${prUrl}#issuecomment-${task.id}`;
16
17
  const output = task.output.kind === 'file'
17
18
  ? `file\nPath: ${task.output.path}`
18
19
  : `task/PR\nPR: ${prUrl}\nTask: ${task.id}`;
19
20
  return `[Agents Relay managed task]
20
- PR: ${prUrl}
21
- Job: ${job.id}
22
- Task: ${task.id}
23
- Parent task: ${task.parentTaskId ?? 'none'}
21
+ Job URL: ${prUrl}
22
+ Job ID: ${job.id}
23
+ Task URL: ${taskUrl}
24
+ Task ID: ${task.id}
24
25
  Project: ${task.projectName ?? context?.project.name ?? 'unspecified'}
25
26
 
26
- ${context ? executionContextPrompt(context) + '\n\n' : ''}${task.input}${task.parentTaskId ? '\n\n[Nested delegation contract]\nAny sub-work must be created as a durable Agents Relay child task with parentTaskId=' + task.id + '; do not directly spawn arbitrary child agents.' : ''}
27
+ Retrieve the durable job and task from GitHub before doing the work. Use whatever GitHub access this environment provides: the URLs, a GitHub integration/tool, or GitHub CLI/API. GitHub is the source of truth for task intent and declared output.
28
+
29
+ ${context ? executionContextPrompt(context) + '\n\n' : ''}Follow the resolved project instructions and Agents Relay/events-bus worker contracts.
27
30
 
28
31
  [Output contract]
29
32
  ${output}
@@ -126,12 +129,17 @@ export class Reconciler {
126
129
  }
127
130
  }
128
131
  await this.recoverLostExecutions(job, now);
132
+ await this.recoverPlannerChildren(job);
129
133
  for (const task of job.tasks)
130
134
  if (task.state === 'CANCELLED') {
131
135
  this.live.get(task.id)?.execution.cancel();
132
136
  this.plannerLive.get(task.id)?.controller.abort();
133
137
  }
134
138
  let scheduled = schedule(await this.store.load(jobId));
139
+ if (prState === 'OPEN') {
140
+ await this.ensureFinalReview(scheduled, prState, headSha);
141
+ scheduled = schedule(await this.store.load(jobId));
142
+ }
135
143
  if (prState === 'OPEN' && scheduled.executionMode === 'autonomous') {
136
144
  scheduled = schedule(await this.maybeCreatePlanner(scheduled));
137
145
  }
@@ -285,16 +293,24 @@ export class Reconciler {
285
293
  async maybeCreatePlanner(job) {
286
294
  if (job.executionMode !== 'autonomous')
287
295
  return job;
296
+ if (job.initialPlanning?.status === 'pending')
297
+ return job;
288
298
  const planners = job.tasks.filter(task => task.kind === 'planner').sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
289
299
  if (planners.some(task => ['QUEUED', 'READY', 'RUNNING', 'WAITING'].includes(task.state)))
290
300
  return job;
291
301
  if (job.tasks.some(task => task.state === 'FAILED' || task.state === 'BLOCKED'))
292
302
  return job;
303
+ if (job.tasks.length > 0 && !planners.some(task => task.state === 'SUCCEEDED'))
304
+ return job;
293
305
  if (job.tasks.some(task => task.kind !== 'planner' && !['SUCCEEDED', 'CANCELLED'].includes(task.state)))
294
306
  return job;
307
+ if (planners.length > 0 && planners.at(-1)?.plannerResult?.objective_status === 'in_progress' && !job.tasks.some(task => task.planningDecisionId === planners.at(-1)?.id))
308
+ return job;
295
309
  const latest = planners.at(-1);
296
310
  if (latest?.plannerResult?.objective_status === 'satisfied')
297
311
  return job;
312
+ if (latest && job.review && job.review.outcome !== 'approved')
313
+ return job;
298
314
  const used = new Set(job.tasks.map(task => task.id));
299
315
  let number = planners.length + 1;
300
316
  let id = `planner-${number}`;
@@ -303,11 +319,26 @@ export class Reconciler {
303
319
  id = `planner-${number}`;
304
320
  }
305
321
  const now = new Date().toISOString();
306
- const task = { jobId: job.id, id, kind: 'planner', storySize: 'M', agentName: 'planner', parentTaskId: latest?.id ?? null, dependencies: [], capabilities: ['planner'], adapter: 'orchestrator', input: plannerInput({ job, tasks: job.tasks, objective: job.objective ?? job.description ?? job.title, previousPlannerTaskId: latest?.id ?? null }), continuation: null, continuationDeliveredAt: null, state: 'QUEUED', attempt: 0, maxAttempts: 3, leaseOwner: null, leaseExpiresAt: null, executionId: null, threadId: null, result: null, plannerResult: null, error: null, timeoutMs: this.options.plannerTimeoutMs ?? storySizeTimeoutMs('M'), createdAt: now, updatedAt: now };
322
+ const task = { jobId: job.id, id, kind: 'planner', storySize: 'M', agentName: 'planner', parentTaskId: latest?.id ?? null, dependencies: [], capabilities: ['planner'], adapter: 'orchestrator', input: plannerInput({ job, tasks: job.tasks, objective: job.objective ?? job.description ?? job.title, previousPlannerTaskId: latest?.id ?? null }), continuation: null, continuationDeliveredAt: null, state: 'QUEUED', attempt: 0, maxAttempts: 3, leaseOwner: null, leaseExpiresAt: null, executionId: null, threadId: null, result: null, plannerResult: null, error: null, timeoutMs: this.options.plannerTimeoutMs ?? storySizeTimeoutMs('M'), createdAt: now, updatedAt: now, planningDecisionId: id, planningSource: 'model-planner', planningRound: planners.length + 1 };
307
323
  await this.store.appendTask(task);
308
324
  await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'planner.created', 'queued', `Planner task ${task.id} created`, 'orchestrator'));
309
325
  return this.store.load(job.id);
310
326
  }
327
+ async recoverPlannerChildren(job) {
328
+ for (const planner of job.tasks.filter(task => task.kind === 'planner' && task.state === 'SUCCEEDED' && task.plannerResult)) {
329
+ const children = planner.plannerResult?.next_tasks.map(spec => this.plannerChild(job, planner, spec)) ?? [];
330
+ for (const child of children) {
331
+ const existing = job.tasks.find(item => item.id === child.id);
332
+ if (existing) {
333
+ if (!samePlannerChildDefinition(existing, child))
334
+ throw new Error(`Planner task ${child.id} already exists with a different definition`);
335
+ continue;
336
+ }
337
+ await this.store.appendTask(child);
338
+ job.tasks.push(child);
339
+ }
340
+ }
341
+ }
311
342
  async resolvedContext(job, task, role) {
312
343
  const projectPath = this.options.projectPathForJob ? await this.options.projectPathForJob(job) : task.routing?.cwd ?? process.cwd();
313
344
  return resolveExecutionContext(projectPath, role, {
@@ -481,14 +512,15 @@ export class Reconciler {
481
512
  }
482
513
  try {
483
514
  const result = parsePlannerResult(raw);
484
- for (const spec of result.next_tasks) {
485
- const child = this.plannerChild(job, task, spec);
515
+ const children = result.next_tasks.map(spec => this.plannerChild(job, task, spec));
516
+ for (const child of children) {
486
517
  const existing = job.tasks.find(item => item.id === child.id);
487
518
  if (!existing)
488
- await this.store.appendTask(child);
519
+ continue;
489
520
  else if (!samePlannerChildDefinition(existing, child))
490
521
  throw new Error(`Planner task ${child.id} already exists with a different definition`);
491
522
  }
523
+ // Persist the completed decision before exposing executable children.
492
524
  task.plannerResult = result;
493
525
  task.result = { summary: result.assessment, data: { objective_status: result.objective_status, next_tasks: result.next_tasks } };
494
526
  task.error = null;
@@ -496,22 +528,32 @@ export class Reconciler {
496
528
  task.leaseExpiresAt = null;
497
529
  transitionTask(task, 'SUCCEEDED');
498
530
  await this.store.saveTask(task);
531
+ for (const child of children)
532
+ if (!job.tasks.some(item => item.id === child.id))
533
+ await this.store.appendTask(child);
499
534
  await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'planner.completed', 'succeeded', `Planner ${task.id} completed: ${result.objective_status}`, 'orchestrator', { objective_status: result.objective_status }));
500
535
  }
501
536
  catch (caught) {
502
537
  const message = caught instanceof Error ? caught.message : String(caught);
503
- task.error = message;
504
- task.leaseOwner = null;
505
- task.leaseExpiresAt = null;
506
- transitionTask(task, task.attempt < task.maxAttempts ? 'READY' : 'FAILED');
507
- await this.store.saveTask(task);
508
- await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'planner.failed', 'failed', `Planner ${task.id}: ${message}`, 'user'));
538
+ if (task.state === 'SUCCEEDED') {
539
+ task.error = `Child materialization failed: ${message}`;
540
+ await this.store.saveTask(task);
541
+ await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'planner.children_failed', 'failed', `Planner ${task.id} child materialization failed: ${message}`, 'user'));
542
+ }
543
+ else {
544
+ task.error = message;
545
+ task.leaseOwner = null;
546
+ task.leaseExpiresAt = null;
547
+ transitionTask(task, task.attempt < task.maxAttempts ? 'READY' : 'FAILED');
548
+ await this.store.saveTask(task);
549
+ await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'planner.failed', 'failed', `Planner ${task.id}: ${message}`, 'user'));
550
+ }
509
551
  }
510
552
  this.detach(this.reconcile(jobId), jobId, task.id, 'planner follow-up reconciliation');
511
553
  }
512
554
  plannerChild(job, planner, spec) {
513
555
  const now = new Date().toISOString();
514
- return { jobId: job.id, id: spec.id, kind: 'work', priority: spec.priority ?? job.priority, storySize: spec.storySize ?? 'M', projectName: spec.projectName, agentName: spec.agentName, parentTaskId: planner.id, dependencies: spec.dependencies ?? [], capabilities: spec.capabilities ?? [], adapter: spec.adapter, input: spec.input, output: spec.output, routing: spec.routing, continuation: spec.continuation ?? null, continuationDeliveredAt: null, state: 'QUEUED', attempt: 0, maxAttempts: spec.maxAttempts ?? 3, leaseOwner: null, leaseExpiresAt: null, executionId: null, threadId: null, result: null, plannerResult: null, error: null, timeoutMs: spec.timeoutMs ?? taskOverallTimeoutMs(spec.storySize ?? 'M', spec.routing?.reasoning), createdAt: now, updatedAt: now };
556
+ return { jobId: job.id, id: spec.id, kind: 'work', priority: spec.priority ?? job.priority, storySize: spec.storySize ?? 'M', projectName: spec.projectName, agentName: spec.agentName, parentTaskId: planner.id, dependencies: spec.dependencies ?? [], capabilities: spec.capabilities ?? [], adapter: spec.adapter, input: spec.input, output: spec.output, routing: spec.routing, continuation: spec.continuation ?? null, continuationDeliveredAt: null, state: 'QUEUED', attempt: 0, maxAttempts: spec.maxAttempts ?? 3, leaseOwner: null, leaseExpiresAt: null, executionId: null, threadId: null, result: null, plannerResult: null, error: null, timeoutMs: spec.timeoutMs ?? taskOverallTimeoutMs(spec.storySize ?? 'M', spec.routing?.reasoning), createdAt: now, updatedAt: now, planningDecisionId: planner.id, planningSource: planner.planningSource ?? 'model-planner', planningRound: planner.planningRound ?? 1 };
515
557
  }
516
558
  clearExecution(taskId, executionId) {
517
559
  const live = this.live.get(taskId);
package/dist/store.js CHANGED
@@ -82,7 +82,7 @@ function immutableTaskDefinition(task) {
82
82
  } : undefined;
83
83
  return {
84
84
  jobId: task.jobId, id: task.id, priority: task.priority, storySize: task.storySize ?? 'M', projectName: task.projectName, agentName: task.agentName,
85
- kind: task.kind ?? 'work', parentTaskId: task.parentTaskId, dependencies: task.dependencies, capabilities: task.capabilities, adapter: task.adapter,
85
+ kind: task.kind ?? 'work', planningDecisionId: task.planningDecisionId, planningSource: task.planningSource, planningRound: task.planningRound, parentTaskId: task.parentTaskId, dependencies: task.dependencies, capabilities: task.capabilities, adapter: task.adapter,
86
86
  input: task.input, output: task.output, routing, chatgptTabClosePolicy: task.chatgptTabClosePolicy ?? 'after-start', continuation: task.continuation ?? null, maxAttempts: task.maxAttempts, timeoutMs: task.timeoutMs
87
87
  };
88
88
  }
@@ -434,7 +434,7 @@ export class GitHubStore {
434
434
  const all = [];
435
435
  let cursor = null;
436
436
  do {
437
- const data = await this.graphql('query($owner:String!,$name:String!,$number:Int!,$cursor:String){repository(owner:$owner,name:$name){pullRequest(number:$number){comments(first:100,after:$cursor){nodes{id databaseId body updatedAt author{login}} pageInfo{hasNextPage endCursor}}}}}', { owner, name, number: this.prNumber, cursor });
437
+ const data = await this.graphql('query($owner:String!,$name:String!,$number:Int!,$cursor:String){repository(owner:$owner,name:$name){pullRequest(number:$number){comments(first:100,after:$cursor){nodes{id databaseId url body updatedAt author{login}} pageInfo{hasNextPage endCursor}}}}}', { owner, name, number: this.prNumber, cursor });
438
438
  const repository = data.repository;
439
439
  const pr = repository?.pullRequest;
440
440
  const comments = pr?.comments;
@@ -444,6 +444,7 @@ export class GitHubStore {
444
444
  all.push({
445
445
  id: Number(value.databaseId ?? 0),
446
446
  nodeId: typeof value.id === 'string' ? value.id : undefined,
447
+ url: typeof value.url === 'string' ? value.url : undefined,
447
448
  body: String(value.body ?? ''),
448
449
  updatedAt: String(value.updatedAt ?? ''),
449
450
  authorLogin: typeof author?.login === 'string' ? author.login : null
@@ -486,7 +487,13 @@ export class GitHubStore {
486
487
  if (!job)
487
488
  throw new Error(`Managed GitHub job ${jobId} has no durable ${JOB_MARKER} marker in PR #${this.prNumber}; run agents-relay job adopt --repo ${this.repository} --pr ${this.prNumber} --id ${jobId}`);
488
489
  const soleTrustedJob = jobs.length === 1;
489
- job.tasks = comments.map(x => parseTask(x.body)).filter((x) => x !== null).filter(task => isTaskForJob(task, jobId, soleTrustedJob));
490
+ job.tasks = comments.map(comment => {
491
+ const task = parseTask(comment.body);
492
+ if (!task)
493
+ return null;
494
+ const taskUrl = comment.url ?? (comment.id > 0 ? `https://github.com/${this.repository}/pull/${this.prNumber}#issuecomment-${comment.id}` : undefined);
495
+ return taskUrl ? { ...task, taskUrl } : task;
496
+ }).filter((x) => x !== null).filter(task => isTaskForJob(task, jobId, soleTrustedJob));
490
497
  return job;
491
498
  }
492
499
  async listAgents() { return (await this.list()).filter(comment => this.isTrusted(comment)).map(x => parseAgent(x.body)).filter((x) => x !== null); }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "1.0.31",
3
+ "version": "1.0.33",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {
@@ -96,6 +96,16 @@ ChatGPT thread identity is diagnostic only. It is not persisted as a resumable t
96
96
 
97
97
  ## Autonomous planner
98
98
 
99
+ No executable task may be materialized without a completed durable planning
100
+ decision. Job creation with caller-supplied initial work records an
101
+ orchestrator-owned `planner-0` decision before its initial tasks; autonomous
102
+ mode must not start a model planner in that case. Objective-only autonomous
103
+ jobs may start a real model planner, and planner children are created only
104
+ after that planner succeeds. Fixed jobs do not automatically create another
105
+ planning round. Each task exposes `planningDecisionId`, `planningSource`, and
106
+ `planningRound` for dashboard lineage. Nested worker delegation must create its
107
+ durable planning decision and children through Relay as well.
108
+
99
109
  Planner-created child tasks must also be descriptive agent work. They must select `codex` or `chatgpt` and include an explicit output contract. A planner must never generate shell-command worker tasks.
100
110
 
101
111
  ## Durable truth