agents-relay 1.0.31 → 1.0.32

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
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');
@@ -126,12 +126,17 @@ export class Reconciler {
126
126
  }
127
127
  }
128
128
  await this.recoverLostExecutions(job, now);
129
+ await this.recoverPlannerChildren(job);
129
130
  for (const task of job.tasks)
130
131
  if (task.state === 'CANCELLED') {
131
132
  this.live.get(task.id)?.execution.cancel();
132
133
  this.plannerLive.get(task.id)?.controller.abort();
133
134
  }
134
135
  let scheduled = schedule(await this.store.load(jobId));
136
+ if (prState === 'OPEN') {
137
+ await this.ensureFinalReview(scheduled, prState, headSha);
138
+ scheduled = schedule(await this.store.load(jobId));
139
+ }
135
140
  if (prState === 'OPEN' && scheduled.executionMode === 'autonomous') {
136
141
  scheduled = schedule(await this.maybeCreatePlanner(scheduled));
137
142
  }
@@ -285,16 +290,24 @@ export class Reconciler {
285
290
  async maybeCreatePlanner(job) {
286
291
  if (job.executionMode !== 'autonomous')
287
292
  return job;
293
+ if (job.initialPlanning?.status === 'pending')
294
+ return job;
288
295
  const planners = job.tasks.filter(task => task.kind === 'planner').sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
289
296
  if (planners.some(task => ['QUEUED', 'READY', 'RUNNING', 'WAITING'].includes(task.state)))
290
297
  return job;
291
298
  if (job.tasks.some(task => task.state === 'FAILED' || task.state === 'BLOCKED'))
292
299
  return job;
300
+ if (job.tasks.length > 0 && !planners.some(task => task.state === 'SUCCEEDED'))
301
+ return job;
293
302
  if (job.tasks.some(task => task.kind !== 'planner' && !['SUCCEEDED', 'CANCELLED'].includes(task.state)))
294
303
  return job;
304
+ if (planners.length > 0 && planners.at(-1)?.plannerResult?.objective_status === 'in_progress' && !job.tasks.some(task => task.planningDecisionId === planners.at(-1)?.id))
305
+ return job;
295
306
  const latest = planners.at(-1);
296
307
  if (latest?.plannerResult?.objective_status === 'satisfied')
297
308
  return job;
309
+ if (latest && job.review && job.review.outcome !== 'approved')
310
+ return job;
298
311
  const used = new Set(job.tasks.map(task => task.id));
299
312
  let number = planners.length + 1;
300
313
  let id = `planner-${number}`;
@@ -303,11 +316,26 @@ export class Reconciler {
303
316
  id = `planner-${number}`;
304
317
  }
305
318
  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 };
319
+ 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
320
  await this.store.appendTask(task);
308
321
  await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'planner.created', 'queued', `Planner task ${task.id} created`, 'orchestrator'));
309
322
  return this.store.load(job.id);
310
323
  }
324
+ async recoverPlannerChildren(job) {
325
+ for (const planner of job.tasks.filter(task => task.kind === 'planner' && task.state === 'SUCCEEDED' && task.plannerResult)) {
326
+ const children = planner.plannerResult?.next_tasks.map(spec => this.plannerChild(job, planner, spec)) ?? [];
327
+ for (const child of children) {
328
+ const existing = job.tasks.find(item => item.id === child.id);
329
+ if (existing) {
330
+ if (!samePlannerChildDefinition(existing, child))
331
+ throw new Error(`Planner task ${child.id} already exists with a different definition`);
332
+ continue;
333
+ }
334
+ await this.store.appendTask(child);
335
+ job.tasks.push(child);
336
+ }
337
+ }
338
+ }
311
339
  async resolvedContext(job, task, role) {
312
340
  const projectPath = this.options.projectPathForJob ? await this.options.projectPathForJob(job) : task.routing?.cwd ?? process.cwd();
313
341
  return resolveExecutionContext(projectPath, role, {
@@ -481,14 +509,15 @@ export class Reconciler {
481
509
  }
482
510
  try {
483
511
  const result = parsePlannerResult(raw);
484
- for (const spec of result.next_tasks) {
485
- const child = this.plannerChild(job, task, spec);
512
+ const children = result.next_tasks.map(spec => this.plannerChild(job, task, spec));
513
+ for (const child of children) {
486
514
  const existing = job.tasks.find(item => item.id === child.id);
487
515
  if (!existing)
488
- await this.store.appendTask(child);
516
+ continue;
489
517
  else if (!samePlannerChildDefinition(existing, child))
490
518
  throw new Error(`Planner task ${child.id} already exists with a different definition`);
491
519
  }
520
+ // Persist the completed decision before exposing executable children.
492
521
  task.plannerResult = result;
493
522
  task.result = { summary: result.assessment, data: { objective_status: result.objective_status, next_tasks: result.next_tasks } };
494
523
  task.error = null;
@@ -496,22 +525,32 @@ export class Reconciler {
496
525
  task.leaseExpiresAt = null;
497
526
  transitionTask(task, 'SUCCEEDED');
498
527
  await this.store.saveTask(task);
528
+ for (const child of children)
529
+ if (!job.tasks.some(item => item.id === child.id))
530
+ await this.store.appendTask(child);
499
531
  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
532
  }
501
533
  catch (caught) {
502
534
  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'));
535
+ if (task.state === 'SUCCEEDED') {
536
+ task.error = `Child materialization failed: ${message}`;
537
+ await this.store.saveTask(task);
538
+ await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'planner.children_failed', 'failed', `Planner ${task.id} child materialization failed: ${message}`, 'user'));
539
+ }
540
+ else {
541
+ task.error = message;
542
+ task.leaseOwner = null;
543
+ task.leaseExpiresAt = null;
544
+ transitionTask(task, task.attempt < task.maxAttempts ? 'READY' : 'FAILED');
545
+ await this.store.saveTask(task);
546
+ await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'planner.failed', 'failed', `Planner ${task.id}: ${message}`, 'user'));
547
+ }
509
548
  }
510
549
  this.detach(this.reconcile(jobId), jobId, task.id, 'planner follow-up reconciliation');
511
550
  }
512
551
  plannerChild(job, planner, spec) {
513
552
  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 };
553
+ 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
554
  }
516
555
  clearExecution(taskId, executionId) {
517
556
  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
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "1.0.31",
3
+ "version": "1.0.32",
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