agents-relay 1.0.161 → 1.0.163

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/blockers.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { extendRetryBudget, transitionTaskEvent } from './scheduler.js';
2
- import { isEffectivelyBlocked, unblockAutonomousTasks } from './unblock.js';
2
+ import { isEffectivelyBlocked, unblockAutonomousTasks, unblockTask } from './unblock.js';
3
3
  function dependentKey(value) {
4
4
  return `${value.repository}#${value.prNumber}:${value.jobId}:${value.taskId}:a${value.blockedAttempt}`;
5
5
  }
@@ -60,12 +60,13 @@ export async function resolveBlockerDependencies(options) {
60
60
  const mode = blocker.blockerResolutionMode ?? 'merge';
61
61
  if (mode === 'verified' && options.resolution !== 'verified') {
62
62
  const waiting = (blocker.unblocks ?? []).filter(item => item.state === 'waiting').length;
63
- return { resumed: 0, obsolete: 0, waiting };
63
+ return { resumed: 0, obsolete: 0, waiting, unblocked: [] };
64
64
  }
65
65
  const now = options.now ?? new Date().toISOString();
66
66
  const relations = blocker.unblocks ?? [];
67
67
  let resumed = 0;
68
68
  let obsolete = 0;
69
+ const unblocked = [];
69
70
  for (const relation of relations) {
70
71
  if (relation.state !== 'waiting')
71
72
  continue;
@@ -83,7 +84,7 @@ export async function resolveBlockerDependencies(options) {
83
84
  continue;
84
85
  }
85
86
  const task = targetJob.tasks.find(item => item.id === relation.taskId);
86
- const matches = !!task && ['DRAFT', 'FAILED', 'BLOCKED'].includes(task.state) && task.attempt === relation.blockedAttempt;
87
+ const matches = !!task && ['DRAFT', 'FAILED', 'BLOCKED', 'UNBLOCKED'].includes(task.state) && task.attempt === relation.blockedAttempt;
87
88
  if (!matches) {
88
89
  relation.state = 'obsolete';
89
90
  relation.resolvedAt = now;
@@ -101,6 +102,7 @@ export async function resolveBlockerDependencies(options) {
101
102
  trigger: { source: 'blocker_job', reason: `Blocker Job ${blocker.id} resolved (${options.resolution})`, blockerJobId: blocker.id, resolution: options.resolution },
102
103
  now,
103
104
  });
105
+ unblocked.push({ repository: relation.repository, prNumber: relation.prNumber, jobId: relation.jobId, taskId: relation.taskId });
104
106
  relation.state = 'resumed';
105
107
  relation.resolvedAt = now;
106
108
  relation.resolution = options.resolution;
@@ -108,7 +110,7 @@ export async function resolveBlockerDependencies(options) {
108
110
  resumed += 1;
109
111
  continue;
110
112
  }
111
- if (task.state === 'BLOCKED' && task.unblock) {
113
+ if (task.state === 'UNBLOCKED' && task.unblock) {
112
114
  relation.state = 'resumed';
113
115
  relation.resolvedAt = now;
114
116
  relation.resolution = options.resolution;
@@ -119,18 +121,26 @@ export async function resolveBlockerDependencies(options) {
119
121
  }
120
122
  if (task.state === 'DRAFT') {
121
123
  await transitionTaskEvent(task, 'release');
124
+ task.error = null;
125
+ task.updatedAt = now;
126
+ delete task.blockedBy;
127
+ await store.saveTask(task);
128
+ }
129
+ else if (task.state === 'BLOCKED') {
130
+ await unblockTask({ store, job: targetJob, task, trigger: { source: 'blocker_job', reason: `Blocker Job ${blocker.id} resolved (${options.resolution})`, blockerJobId: blocker.id, resolution: options.resolution }, now });
131
+ unblocked.push({ repository: relation.repository, prNumber: relation.prNumber, jobId: relation.jobId, taskId: relation.taskId });
122
132
  }
123
133
  else {
124
134
  task.maxAttempts = extendRetryBudget(task);
125
135
  await transitionTaskEvent(task, 'retry');
136
+ task.error = null;
137
+ task.leaseOwner = null;
138
+ task.leaseExpiresAt = null;
139
+ task.executionId = null;
140
+ delete task.blockedBy;
141
+ task.updatedAt = now;
142
+ await store.saveTask(task);
126
143
  }
127
- task.error = null;
128
- task.leaseOwner = null;
129
- task.leaseExpiresAt = null;
130
- task.executionId = null;
131
- delete task.blockedBy;
132
- task.updatedAt = now;
133
- await store.saveTask(task);
134
144
  relation.state = 'resumed';
135
145
  relation.resolvedAt = now;
136
146
  relation.resolution = options.resolution;
@@ -140,7 +150,7 @@ export async function resolveBlockerDependencies(options) {
140
150
  const remaining = relations.filter(relation => relation.state === 'waiting');
141
151
  const consumed = remaining.length !== relations.length;
142
152
  if (!consumed)
143
- return { resumed, obsolete, waiting: remaining.length };
153
+ return { resumed, obsolete, waiting: remaining.length, unblocked };
144
154
  if (remaining.length > 0)
145
155
  blocker.unblocks = remaining;
146
156
  else
@@ -148,5 +158,5 @@ export async function resolveBlockerDependencies(options) {
148
158
  blocker.blockerResolvedAt = now;
149
159
  blocker.updatedAt = now;
150
160
  await options.blockerStore.saveJob(blocker);
151
- return { resumed, obsolete, waiting: remaining.length };
161
+ return { resumed, obsolete, waiting: remaining.length, unblocked };
152
162
  }
@@ -1,7 +1,7 @@
1
1
  import { GitHubStore } from './store.js';
2
2
  import { githubAuthContext } from './github-auth.js';
3
3
  import { registerBlockerDependency } from './blockers.js';
4
- import { arg, repository } from './cli-support.js';
4
+ import { arg, eventBus, publishDependentUnblocked, repository } from './cli-support.js';
5
5
  import { usage } from './cli-help.js';
6
6
  export async function runBlockerCommand(action, args) {
7
7
  if (!['add', 'resolve', 'show'].includes(action))
@@ -19,6 +19,7 @@ export async function runBlockerCommand(action, args) {
19
19
  }
20
20
  if (action === 'resolve') {
21
21
  const summary = await blockerStore.resolveBlockerDependents(id, 'verified');
22
+ await publishDependentUnblocked(eventBus(args), summary.unblocked, `Blocker Job ${id} resolved (verified)`);
22
23
  console.log(JSON.stringify({ blocker: id, resolution: 'verified', ...summary }, null, 2));
23
24
  return;
24
25
  }
@@ -75,6 +75,22 @@ export async function publishWake(bus, job, taskId, message) { if (!bus)
75
75
  catch (error) {
76
76
  process.stderr.write(`warning: event wake unavailable: ${error instanceof Error ? error.message : String(error)}\n`);
77
77
  } }
78
+ export async function publishTaskUnblocked(bus, job, taskId, reason) { if (!bus)
79
+ return; try {
80
+ await bus.publish(eventFor(job.id, taskId, null, 'task.unblocked', 'unblocked', `Task ${taskId} unblocked: ${reason}`, 'orchestrator', { repository: job.repository, prNumber: job.prNumber, reason }));
81
+ }
82
+ catch (error) {
83
+ process.stderr.write(`warning: unblock event unavailable: ${error instanceof Error ? error.message : String(error)}\n`);
84
+ } }
85
+ export async function publishDependentUnblocked(bus, targets, reason) { if (!bus)
86
+ return; for (const target of targets) {
87
+ try {
88
+ await bus.publish(eventFor(target.jobId, target.taskId, null, 'task.unblocked', 'unblocked', `Task ${target.taskId} unblocked: ${reason}`, 'orchestrator', { repository: target.repository, prNumber: target.prNumber, reason }));
89
+ }
90
+ catch (error) {
91
+ process.stderr.write(`warning: dependent unblock event unavailable: ${error instanceof Error ? error.message : String(error)}\n`);
92
+ }
93
+ } }
78
94
  export async function requestManagedReconciliation(bus, job) {
79
95
  if (!bus)
80
96
  throw new Error('Managed GitHub reconciliation requires --events nats so agents-relayd can execute the job');
@@ -1,4 +1,4 @@
1
- import { arg, eventBus, publishWake, saveLocal } from './cli-support.js';
1
+ import { arg, eventBus, publishTaskUnblocked, publishWake, saveLocal } from './cli-support.js';
2
2
  import { isEffectivelyBlocked, unblockAutonomousTasks, unblockTask } from './unblock.js';
3
3
  export async function runTaskUnblockCommand(args, store, job, task, localFile) {
4
4
  const reason = arg(args, '--reason');
@@ -7,7 +7,9 @@ export async function runTaskUnblockCommand(args, store, job, task, localFile) {
7
7
  const result = await unblockTask({ store, job, task, trigger: { source: 'cli', reason } });
8
8
  const refreshed = await store.load(job.id);
9
9
  await saveLocal(localFile, refreshed);
10
- await publishWake(eventBus(args), refreshed, task.id, `Task ${task.id} unblocked${result.replanning ? ' for replanning' : ''}`);
10
+ const bus = eventBus(args);
11
+ await publishTaskUnblocked(bus, refreshed, task.id, reason);
12
+ await publishWake(bus, refreshed, task.id, `Task ${task.id} unblocked${result.replanning ? ' for replanning' : ''}`);
11
13
  console.log(JSON.stringify({ taskId: task.id, ...result, task: refreshed.tasks.find(item => item.id === task.id) }, null, 2));
12
14
  }
13
15
  export async function runJobUnblockCommand(args, store, job) {
@@ -18,15 +20,21 @@ export async function runJobUnblockCommand(args, store, job) {
18
20
  if (blocked.length === 0)
19
21
  throw new Error(`Job ${job.id} has no effective BLOCKED work task`);
20
22
  if (job.executionMode === 'autonomous') {
21
- const changeCycle = await unblockAutonomousTasks({ store, job, tasks: blocked, trigger: { source: 'cli', reason } });
23
+ const planTrigger = await unblockAutonomousTasks({ store, job, tasks: blocked, trigger: { source: 'cli', reason } });
22
24
  const refreshed = await store.load(job.id);
23
- await publishWake(eventBus(args), refreshed, 'job', `Job ${job.id} unblocked for replanning`);
24
- console.log(JSON.stringify({ jobId: job.id, replanning: true, changeCycle }, null, 2));
25
+ const bus = eventBus(args);
26
+ for (const task of blocked)
27
+ await publishTaskUnblocked(bus, refreshed, task.id, reason);
28
+ await publishWake(bus, refreshed, 'job', `Job ${job.id} unblocked for replanning`);
29
+ console.log(JSON.stringify({ jobId: job.id, replanning: true, planTrigger }, null, 2));
25
30
  return;
26
31
  }
27
32
  for (const task of blocked)
28
33
  await unblockTask({ store, job, task, trigger: { source: 'cli', reason } });
29
34
  const refreshed = await store.load(job.id);
30
- await publishWake(eventBus(args), refreshed, 'job', `Job ${job.id} unblocked`);
35
+ const bus = eventBus(args);
36
+ for (const task of blocked)
37
+ await publishTaskUnblocked(bus, refreshed, task.id, reason);
38
+ await publishWake(bus, refreshed, 'job', `Job ${job.id} unblocked`);
31
39
  console.log(JSON.stringify({ jobId: job.id, replanning: false, resumedTasks: blocked.map(task => task.id) }, null, 2));
32
40
  }
package/dist/cli.js CHANGED
@@ -26,7 +26,7 @@ import { helpText, usage } from './cli-help.js';
26
26
  import { parseAgentGraph } from './agent-graph.js';
27
27
  import { runJobUnblockCommand, runTaskUnblockCommand } from './cli-unblock.js';
28
28
  export { helpText } from './cli-help.js';
29
- import { SERVICE_DEFAULTS, ServiceEventBus, arg, hasArg, textOption, integerArg, repository, storeFor, saveLocal, githubStore, eventBus, publishWake, priority, storySize, executionMode, chatGptTabClosePolicy, requestedExecutionMode, continuation, scheduleFromArgs, jobTagFromArgs, requestManagedReconciliation, } from './cli-support.js';
29
+ import { SERVICE_DEFAULTS, ServiceEventBus, arg, hasArg, textOption, integerArg, repository, storeFor, saveLocal, githubStore, eventBus, publishWake, publishDependentUnblocked, priority, storySize, executionMode, chatGptTabClosePolicy, requestedExecutionMode, continuation, scheduleFromArgs, jobTagFromArgs, requestManagedReconciliation, } from './cli-support.js';
30
30
  export { SERVICE_DEFAULTS, eventBus, executionMode, chatGptTabClosePolicy } from './cli-support.js';
31
31
  export async function ensureManagedGitHubJob(client, repositoryName, trustedAuthors, options) {
32
32
  if (!options.id)
@@ -196,10 +196,14 @@ async function runTaskCommand(action, args) {
196
196
  if (action === 'retry') {
197
197
  if (task.state === 'BLOCKED')
198
198
  throw new Error(`Task ${id} is BLOCKED; use task unblock --reason instead of retry`);
199
+ if (task.state === 'UNBLOCKED')
200
+ throw new Error(`Task ${id} is historical UNBLOCKED work; only fixed-job authoritative unblock may requeue it`);
199
201
  task.maxAttempts = extendRetryBudget(task);
200
202
  if (task.kind === 'planner' && task.error)
201
203
  task.plannerRetryFeedback = task.error;
202
204
  }
205
+ if (action === 'cancel' && task.state === 'UNBLOCKED')
206
+ throw new Error(`Task ${id} is historical UNBLOCKED work and is immutable`);
203
207
  await transitionTaskEvent(task, action === 'retry' ? 'retry' : task.state === 'RUNNING' ? 'task.cancelled' : 'cancel');
204
208
  task.error = action === 'retry' ? null : 'Cancelled by user';
205
209
  task.leaseOwner = null;
@@ -215,7 +219,7 @@ async function runTaskCommand(action, args) {
215
219
  console.log(JSON.stringify(task, null, 2));
216
220
  return;
217
221
  }
218
- if (['RUNNING', 'SUCCEEDED', 'CANCELLED'].includes(task.state))
222
+ if (['RUNNING', 'UNBLOCKED', 'SUCCEEDED', 'CANCELLED'].includes(task.state))
219
223
  throw new Error(`Task ${id} is ${task.state}; durable execution fields are immutable in this state`);
220
224
  if (hasArg(args, '--adapter')) {
221
225
  const adapter = arg(args, '--adapter');
@@ -266,6 +270,8 @@ async function runTaskCommand(action, args) {
266
270
  await transitionTaskEvent(task, 'retry');
267
271
  else if (task.state === 'BLOCKED')
268
272
  throw new Error(`Task ${id} is BLOCKED; use task unblock --reason instead of --queue`);
273
+ else if (task.state === 'UNBLOCKED')
274
+ throw new Error(`Task ${id} is historical UNBLOCKED work and cannot be queued`);
269
275
  }
270
276
  if (hasArg(args, '--max-attempts') && task.maxAttempts < task.attempt)
271
277
  throw new Error('--max-attempts cannot be less than the current attempt count');
@@ -384,14 +390,15 @@ async function runJobCommand(action, args) {
384
390
  await store.mergePullRequest(headSha ?? undefined);
385
391
  await transitionJobEvent(job, 'pr.merged');
386
392
  await store.saveJob(job);
387
- await store.resolveBlockerDependents(id, 'merge');
393
+ const resolved = await store.resolveBlockerDependents(id, 'merge');
394
+ await publishDependentUnblocked(eventBus(args), resolved.unblocked, `Blocker Job ${id} resolved (merge)`);
388
395
  console.log(JSON.stringify(await store.load(id), null, 2));
389
396
  return;
390
397
  }
391
398
  if (action === 'cancel') {
392
399
  if (job.state === 'COMPLETED')
393
400
  throw new Error(`Cannot cancel completed job ${job.id}`);
394
- for (const task of job.tasks.filter(item => !['SUCCEEDED', 'CANCELLED'].includes(item.state))) {
401
+ for (const task of job.tasks.filter(item => !['SUCCEEDED', 'UNBLOCKED', 'CANCELLED'].includes(item.state))) {
395
402
  await transitionTaskEvent(task, 'cancel');
396
403
  task.error = 'Job cancelled';
397
404
  task.leaseOwner = null;
@@ -55,7 +55,11 @@ export function dashboardTaskSetState(store) {
55
55
  if (task.state === state)
56
56
  return job;
57
57
  if (task.state === 'BLOCKED' && state !== 'CANCELLED')
58
- throw new Error(`Task ${task.id} is BLOCKED; only the unblock CLI may resume or replan it`);
58
+ throw new Error(`Task ${task.id} is BLOCKED; only authoritative unblock may transition it to UNBLOCKED`);
59
+ if (state === 'UNBLOCKED')
60
+ throw new Error(`Task ${task.id} may enter UNBLOCKED only through authoritative unblock`);
61
+ if (task.state === 'UNBLOCKED')
62
+ throw new Error(`Task ${task.id} is historical UNBLOCKED work and cannot be changed by dashboard state override`);
59
63
  const now = new Date().toISOString();
60
64
  const from = task.state;
61
65
  applyTaskWorkflowDecision(task, {
@@ -62,7 +62,7 @@ export const dashboardHtml = `<!doctype html>
62
62
  .status-pill.ok,.badge.ACTIVE,.badge.RUNNING,.badge.SUCCEEDED,.badge.COMPLETED,.badge.MERGED { background:#e5f3e9; color:var(--ok); }
63
63
  .status-pill.warn,.badge.BLOCKED,.badge.DRAFT,.badge.QUEUED,.badge.WAITING,.badge.OPEN { background:#fff1d2; color:var(--warn); }
64
64
  .status-pill.bad,.badge.FAILED { background:#ffe8e8; color:var(--bad); }
65
- .status-pill.neutral,.badge.IDLE,.badge.CANCELLED { background:#edf0f4; color:var(--muted); }
65
+ .status-pill.neutral,.badge.IDLE,.badge.UNBLOCKED,.badge.CANCELLED { background:#edf0f4; color:var(--muted); }
66
66
  .badge.DRAFT { background:#eef2ff; color:#3f51b5; }
67
67
  .badge.MERGE_PENDING { background:#ffe3a3; color:#7a4a00; }
68
68
  .job-summary,.detail-groups { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:.55rem; }
@@ -282,7 +282,7 @@ function rememberSelection(){if(selectedJob)sessionStorage.setItem('agents-relay
282
282
  function syncMobileJobsView(){const detail=mobileJobs.matches&&Boolean(selectedJob)&&location.hash.startsWith('#/jobs/');document.body.dataset.mobileJobsView=detail?'detail':'list';}
283
283
  function selected(){return jobs.find(job=>job.id===selectedJob)||jobs[0]||null;}
284
284
  function counts(tasks){const out={};for(const task of Array.isArray(tasks)?tasks:[])out[task.state]=(out[task.state]||0)+1;return out;}
285
- function compactCounts(input){const order=['RUNNING','BLOCKED','FAILED','QUEUED','DRAFT','SUCCEEDED','CANCELLED'];return order.filter(state=>input[state]).map(state=>input[state]+' '+state).join(', ')||'No tasks';}
285
+ function compactCounts(input){const order=['RUNNING','BLOCKED','UNBLOCKED','FAILED','QUEUED','DRAFT','SUCCEEDED','CANCELLED'];return order.filter(state=>input[state]).map(state=>input[state]+' '+state).join(', ')||'No tasks';}
286
286
  function relativeTime(timestamp){if(!timestamp)return '—';const parsed=Date.parse(timestamp);if(!Number.isFinite(parsed))return value(timestamp);const seconds=Math.max(0,Math.round((Date.now()-parsed)/1000));if(seconds<60)return 'just now';if(seconds<3600)return Math.floor(seconds/60)+'m ago';if(seconds<86400)return Math.floor(seconds/3600)+'h ago';return Math.floor(seconds/86400)+'d ago';}
287
287
  function cadence(job){const schedule=job?.schedule;if(!schedule?.cron)return '—';const cron=String(schedule.cron).trim();const timezone=value(schedule.timezone||'UTC');const fields=cron.split(/\\s+/);let label=cron;if(fields.length===5){const [minute,hour,day,month,weekday]=fields;if(day==='*'&&month==='*'&&weekday==='*'){const everyMinute=minute.match(/^\\*\\/(\\d+)$/);if(everyMinute&&hour==='*')label='Every '+everyMinute[1]+' min';else if(minute==='0'&&hour==='*')label='Hourly';else if(/^\\d+$/.test(minute)&&/^\\d+$/.test(hour)){const hh=String(Number(hour)).padStart(2,'0');const mm=String(Number(minute)).padStart(2,'0');label='Daily '+hh+':'+mm;}}}return label+' · '+cron+' · '+timezone;}
288
288
  function safePrUrl(repository,prNumber){if(typeof repository!=='string'||!Number.isInteger(prNumber)||prNumber<=0)return null;const parts=repository.split('/');if(parts.length!==2)return null;const [owner,repo]=parts;if(!owner||!repo||owner==='.'||owner==='..'||repo==='.'||repo==='..')return null;if(!/^[A-Za-z0-9-]+$/.test(owner)||!/^[A-Za-z0-9._-]+$/.test(repo))return null;return 'https://github.com/'+owner+'/'+repo+'/pull/'+prNumber;}
@@ -327,9 +327,9 @@ function attemptBudget(task){const attempt=Number(task?.attempt)||0;const max=Nu
327
327
  const JOB_STATES=['DRAFT','ACTIVE','COMPLETED','CANCELLED'];
328
328
  async function setJobState(job,state,select){if(!job||state===job.state)return;select.disabled=true;try{const response=await fetch('/api/job-state?id='+encodeURIComponent(job.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 job lifecycle state: '+error.message;select.value=job.state;}finally{select.disabled=false;}}
329
329
  function jobStateField(job){const box=element('div','detail-group');box.append(element('p','detail-label',text('Lifecycle state')));const select=document.createElement('select');select.className='job-filter job-state-select';select.setAttribute('aria-label','Job lifecycle state');for(const state of JOB_STATES)select.append(new Option(state,state));select.value=job.state;select.addEventListener('change',()=>void setJobState(job,select.value,select));box.append(select);return box;}
330
- const TASK_STATES=['DRAFT','QUEUED','RUNNING','BLOCKED','SUCCEEDED','FAILED','CANCELLED'];
330
+ const TASK_STATES=['DRAFT','QUEUED','RUNNING','BLOCKED','UNBLOCKED','SUCCEEDED','FAILED','CANCELLED'];
331
331
  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;}}
332
- function taskStateField(task){const box=element('div','detail-group');box.append(element('p','detail-label',text('State')));const select=document.createElement('select');select.className='job-filter task-state-select';select.setAttribute('aria-label','Task state');for(const state of TASK_STATES)select.append(new Option(state,state));select.value=task.state;select.addEventListener('change',()=>void setTaskState(task,select.value,select));box.append(select);return box;}
332
+ function taskStateField(task){const box=element('div','detail-group');box.append(element('p','detail-label',text('State')));const select=document.createElement('select');select.className='job-filter task-state-select';select.setAttribute('aria-label','Task state');for(const state of TASK_STATES){const option=new Option(state,state);if(state==='UNBLOCKED'&&task.state!=='UNBLOCKED')option.disabled=true;select.append(option);}select.value=task.state;select.addEventListener('change',()=>void setTaskState(task,select.value,select));box.append(select);return box;}
333
333
  function manualOverrideField(task){const rows=Array.isArray(task.manualStateOverrides)?task.manualStateOverrides:[];const last=rows.at(-1);return field('Manual override',last?last.from+' → '+last.to+' · '+relativeTime(last.at):'—');}
334
334
  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'),taskStateField(task),manualOverrideField(task),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'),taskCommentField(task),chatField(task));const job=selected();if(task.reviewHeadSha&&job)groups.append(commitField('HEAD commit',job,task.reviewHeadSha));const graph=agentGraphField(task);if(graph)groups.append(graph);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);}
335
335
  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();}
@@ -1,4 +1,4 @@
1
- import { createPlannerRoundTask } from './planner.js';
1
+ import { createPlannerRoundTask, pendingPlanForPlannerTask, recoverPendingPlannerTask } from './planner.js';
2
2
  import { transitionJobEvent } from './scheduler.js';
3
3
  export async function executeManualPlan(store, jobId, guidance, timeoutMs, launchPlanner) {
4
4
  let job = await store.load(jobId);
@@ -7,7 +7,10 @@ export async function executeManualPlan(store, jobId, guidance, timeoutMs, launc
7
7
  const active = job.tasks.find(task => task.kind === 'planner' && ['DRAFT', 'QUEUED', 'RUNNING'].includes(task.state));
8
8
  if (active)
9
9
  throw new Error(`Planning round ${active.id} is already ${active.state}`);
10
- const round = createPlannerRoundTask(job, { guidance, manual: true, timeoutMs });
10
+ const recovered = recoverPendingPlannerTask(job, timeoutMs);
11
+ const round = recovered ?? createPlannerRoundTask(job, { guidance, manual: true, timeoutMs });
12
+ if (!recovered)
13
+ await store.appendPlan(pendingPlanForPlannerTask(job, round));
11
14
  await store.appendTask(round);
12
15
  job = await store.load(jobId);
13
16
  if (job.state === 'DRAFT') {
package/dist/markers.js CHANGED
@@ -1,5 +1,6 @@
1
1
  export const JOB_MARKER = 'agents-relay:job:v1';
2
2
  export const TASK_MARKER = 'agents-relay:task:v1';
3
+ export const PLAN_MARKER = 'agents-relay:plan:v1';
3
4
  export const AGENT_MARKER = 'agents-relay:agent:v1';
4
5
  export function marker(label, value) { return `<!-- ${label} ${JSON.stringify(value).replace(/>/g, '\\u003e')} -->`; }
5
6
  export function parseMarker(body, label) {
@@ -33,7 +34,7 @@ function contentBeforeDelimiter(body, heading, end) {
33
34
  return body.slice(contentStart, endAt);
34
35
  }
35
36
  function jobMetadata(job) {
36
- const { objective: _objective, description: _description, tasks: _tasks, ...metadata } = job;
37
+ const { objective: _objective, description: _description, tasks: _tasks, plans: _plans, ...metadata } = job;
37
38
  return metadata;
38
39
  }
39
40
  export function renderJobBody(job) {
@@ -104,5 +105,25 @@ export function parseTask(body) {
104
105
  return null;
105
106
  return { ...task, input, kind: task.kind === 'planner' ? 'planner' : 'work' };
106
107
  }
108
+ export function renderPlanComment(plan) {
109
+ const { planUrl: _planUrl, ...metadata } = plan;
110
+ const triggerLines = [
111
+ `- **State:** ${plan.status}`,
112
+ `- **Source:** ${plan.trigger.source}`,
113
+ plan.trigger.taskIds?.length ? `- **Triggered tasks:** ${plan.trigger.taskIds.join(', ')}` : '',
114
+ plan.trigger.reason ? `- **Reason:** ${plan.trigger.reason}` : '',
115
+ plan.trigger.blockedHeadShas?.length ? `- **Blocked head(s):** ${plan.trigger.blockedHeadShas.join(', ')}` : '',
116
+ plan.trigger.currentHeadSha ? `- **Current head:** ${plan.trigger.currentHeadSha}` : '',
117
+ ].filter(Boolean).join('\n');
118
+ const generated = plan.generatedTaskIds.length ? plan.generatedTaskIds.map(id => `- ${id}`).join('\n') : '- _No tasks generated._';
119
+ const facts = plan.trigger.facts?.length ? `\n\n### Change facts\n\n${plan.trigger.facts.map(fact => `- ${fact}`).join('\n')}` : '';
120
+ return `${marker(PLAN_MARKER, metadata)}\n## Plan ${plan.round}: ${plan.id}\n\n${triggerLines}${facts}\n\n### Assessment\n\n${plan.assessment}\n\n### Generated tasks\n\n${generated}`;
121
+ }
122
+ export function parsePlan(body) {
123
+ const plan = parseMarker(body, PLAN_MARKER);
124
+ if (!plan || typeof plan.jobId !== 'string' || typeof plan.id !== 'string' || !Number.isInteger(plan.round))
125
+ return null;
126
+ return plan;
127
+ }
107
128
  export function renderAgentComment(agent) { return `${marker(AGENT_MARKER, agent)}\n## Registered agent ${agent.name}\nRole: ${agent.role}\nAvailability: ${agent.availability}`; }
108
129
  export function parseAgent(body) { return parseMarker(body, AGENT_MARKER); }
package/dist/planner.js CHANGED
@@ -5,6 +5,29 @@ import { dirname, join } from 'node:path';
5
5
  import { storySizeTimeoutMs, taskOverallTimeoutMs } from './types.js';
6
6
  import { executionContextPrompt } from './execution-context.js';
7
7
  import { parseAgentGraph } from './agent-graph.js';
8
+ function plannerTrigger(job, manual = false) {
9
+ if (manual)
10
+ return { source: 'manual' };
11
+ const consumed = new Set((job.plans ?? []).flatMap(plan => plan.trigger.source === 'unblock' ? plan.trigger.taskIds ?? [] : []));
12
+ const unblocked = job.tasks.filter(task => task.state === 'UNBLOCKED' && task.unblock && !consumed.has(task.id));
13
+ if (unblocked.length > 0) {
14
+ const records = unblocked.map(task => task.unblock);
15
+ const blockedHeadShas = [...new Set(records.map(record => record.baseline.headSha).filter((value) => Boolean(value)))];
16
+ return {
17
+ source: 'unblock', taskIds: unblocked.map(task => task.id),
18
+ reason: [...new Set(records.map(record => record.reason))].join('; '),
19
+ blockedHeadShas,
20
+ currentHeadSha: records.map(record => record.currentHeadSha).find((value) => Boolean(value)),
21
+ unblockSource: records[0].source,
22
+ facts: [...new Set(records.flatMap(record => record.facts ?? [`unblock: ${record.reason}`]))],
23
+ };
24
+ }
25
+ if (job.changeCycle && !job.changeCycle.consumedByPlannerTaskId)
26
+ return { source: 'unblock', taskIds: job.changeCycle.taskIds, reason: job.changeCycle.reason, facts: job.changeCycle.facts, blockedHeadShas: job.changeCycle.baselineHeadShas, currentHeadSha: job.changeCycle.currentHeadSha, unblockSource: job.changeCycle.source };
27
+ if (job.schedule?.currentRunSlot)
28
+ return { source: 'schedule' };
29
+ return (job.plans?.length ?? 0) === 0 && !job.tasks.some(task => task.kind === 'planner') ? { source: 'initial' } : { source: 'continuation' };
30
+ }
8
31
  export function plannerRoundNumber(task) {
9
32
  if (task.kind !== 'planner')
10
33
  return null;
@@ -30,6 +53,7 @@ export function createPlannerRoundTask(job, options = {}) {
30
53
  tasks: job.tasks,
31
54
  objective: job.objective ?? job.description ?? job.title,
32
55
  previousPlannerTaskId: previousPlanner?.id ?? null,
56
+ trigger: plannerTrigger(job, options.manual === true),
33
57
  guidance: options.guidance,
34
58
  };
35
59
  return {
@@ -39,9 +63,31 @@ export function createPlannerRoundTask(job, options = {}) {
39
63
  maxAttempts: options.manual ? 1 : 3, leaseOwner: null, leaseExpiresAt: null, executionId: null, threadId: null,
40
64
  result: null, plannerResult: null, error: null, timeoutMs: options.timeoutMs ?? storySizeTimeoutMs('M'),
41
65
  createdAt: now, updatedAt: now, planningDecisionId: id, planningSource: 'model-planner', planningRound: round, previousPlannerTaskId: previousPlanner?.id ?? null,
42
- plannerGuidance: options.guidance, manualPlanning: options.manual === true,
66
+ plannerGuidance: options.guidance, manualPlanning: options.manual === true, planTrigger: context.trigger,
43
67
  };
44
68
  }
69
+ export function pendingPlanForPlannerTask(job, task) {
70
+ return {
71
+ jobId: job.id, id: task.id, round: task.planningRound ?? 1, source: task.planningSource ?? 'model-planner',
72
+ previousPlanId: task.previousPlannerTaskId ?? null, trigger: task.planTrigger ?? { source: task.manualPlanning ? 'manual' : 'continuation' },
73
+ status: 'PLANNING', guidance: task.plannerGuidance, assessment: 'Planning in progress.', generatedTaskIds: [],
74
+ headSha: task.planTrigger?.currentHeadSha ?? job.headSha, createdAt: task.createdAt, updatedAt: task.updatedAt,
75
+ };
76
+ }
77
+ export function recoverPendingPlannerTask(job, timeoutMs) {
78
+ const pending = (job.plans ?? []).filter(plan => plan.status === 'PLANNING' && !job.tasks.some(task => task.kind === 'planner' && task.id === plan.id)).sort((a, b) => a.round - b.round)[0];
79
+ if (!pending)
80
+ return null;
81
+ const task = createPlannerRoundTask(job, { guidance: pending.guidance, manual: pending.trigger.source === 'manual', timeoutMs, now: pending.createdAt });
82
+ if (task.id !== pending.id || task.planningRound !== pending.round)
83
+ throw new Error(`Pending Plan ${pending.id} cannot be recovered as planner round ${task.id}`);
84
+ task.previousPlannerTaskId = pending.previousPlanId;
85
+ task.planTrigger = pending.trigger;
86
+ task.planningSource = pending.source;
87
+ task.planningDecisionId = pending.id;
88
+ task.input = plannerInput({ job, tasks: job.tasks, objective: job.objective ?? job.description ?? job.title, previousPlannerTaskId: pending.previousPlanId, trigger: pending.trigger, guidance: pending.guidance });
89
+ return task;
90
+ }
45
91
  export function planningSpec(task) {
46
92
  if (!task.output && task.adapter !== 'orchestrator')
47
93
  throw new Error(`Initial task ${task.id} requires an output contract`);
@@ -281,7 +327,8 @@ export function plannerInput(context) {
281
327
  retry_feedback: context.retryFeedback,
282
328
  caller_prompt: context.guidance,
283
329
  previous_planner_task_id: context.previousPlannerTaskId,
284
- change_cycle: context.job.changeCycle,
330
+ plan_trigger: context.trigger,
331
+ legacy_change_cycle: context.job.changeCycle,
285
332
  project: project ? { name: project.name, path: project.path } : undefined,
286
333
  visible_agents: visibleAgents ?? [],
287
334
  tasks: context.tasks.map(task => ({
package/dist/pool.js CHANGED
@@ -226,7 +226,7 @@ export class RepositoryWorkerPool {
226
226
  }
227
227
  if (routedWebhook)
228
228
  return;
229
- const lifecycle = ['task.started', 'task.completed', 'task.failed', 'task.blocked', 'task.cancelled', 'review.started', 'review.approved', 'review.changes_required', 'review.blocked'].includes(event.type);
229
+ const lifecycle = ['task.started', 'task.completed', 'task.failed', 'task.blocked', 'task.unblocked', 'task.cancelled', 'review.started', 'review.approved', 'review.changes_required', 'review.blocked'].includes(event.type);
230
230
  const wake = event.type === 'job.wake' || event.type === 'github.webhook';
231
231
  if (!lifecycle && !wake)
232
232
  return;
@@ -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, validatePlannerHierarchy } from './planner.js';
7
+ import { createPlannerRoundTask, pendingPlanForPlannerTask, recoverPendingPlannerTask, 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,7 +17,7 @@ import { pendingTroubleshooterTasks } from './troubleshooter.js';
17
17
  import { prepareManagedExecutionWorktree } from './workspace.js';
18
18
  import { createReviewChairExecutionPlan } from './review-chair.js';
19
19
  import { samePlannerChildDefinition } from './planner-child.js';
20
- import { consumeChangeCycle, isEffectivelyBlocked } from './unblock.js';
20
+ import { isEffectivelyBlocked } from './unblock.js';
21
21
  import { taskTagWorkerPrompt } from './task-tag.js';
22
22
  const PRIORITY_RANK = { P0: 0, P1: 1, P2: 2, P3: 3 };
23
23
  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); }
@@ -46,14 +46,10 @@ Job ID: ${job.id}
46
46
  Task URL: ${taskUrl}
47
47
  Task ID: ${task.id}
48
48
  Project: ${task.projectName ?? context?.project.name ?? 'unspecified'}
49
-
50
49
  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.
51
-
52
50
  ${context ? executionContextPrompt(context) + '\n\n' : ''}${selectedAgent ? selectedAgent + '\n\n' : ''}${selectedGraph ? selectedGraph + '\n\n' : ''}${chairPlan ? `[Review Chair execution plan]\n${JSON.stringify(chairPlan, null, 2)}\n\nUse this runtime-discovered panel to create sibling durable specialist Tasks. Preserve each specialistTasks entry as review metadata, keep the target immutable, and synthesize structured results with synthesizeReview semantics before returning the unified revision plan.\n\n` : ''}Follow the resolved project instructions and Agents Relay/events-bus worker contracts.
53
-
54
51
  [Output contract]
55
52
  ${output}
56
-
57
53
  ${taskTag}[Execution event contract — mandatory]
58
54
  Use the canonical Neo events-bus with exactly Job ${job.id} and Task ${task.id}.
59
55
  Your exact event source identity is: ${sourceId}
@@ -61,7 +57,6 @@ Every lifecycle event for this execution MUST set source.id to that exact string
61
57
  Follow the events-bus skill/protocol. For sandboxed Codex or hosted ChatGPT,
62
58
  publish through the federated MCP tool events__publish; do not open NATS directly.
63
59
  For a direct non-sandbox local worker, use NEO_EVENTS_EMIT when the caller provides it.
64
-
65
60
  As your FIRST execution action, before reading files, editing code, running commands, or doing other work,
66
61
  you MUST publish exactly one canonical task.started event for this exact Job/Task.
67
62
 
@@ -136,8 +131,11 @@ export class Reconciler extends ReconcilerReviewBase {
136
131
  await this.applyJobEffectOutcomes(job, outcomes);
137
132
  }
138
133
  }
139
- if (prState === 'MERGED' && this.store.resolveBlockerDependents)
140
- await this.store.resolveBlockerDependents(job.id, 'merge');
134
+ if (prState === 'MERGED' && this.store.resolveBlockerDependents) {
135
+ const resolved = await this.store.resolveBlockerDependents(job.id, 'merge');
136
+ for (const target of resolved.unblocked)
137
+ await this.emit(eventFor(target.jobId, target.taskId, null, 'task.unblocked', 'unblocked', `Blocker Job ${job.id} resolved (merge)`, 'orchestrator', { repository: target.repository, prNumber: target.prNumber, blockerJobId: job.id }));
138
+ }
141
139
  return this.store.load(jobId);
142
140
  }
143
141
  if (job.state !== 'ACTIVE')
@@ -266,6 +264,12 @@ export class Reconciler extends ReconcilerReviewBase {
266
264
  return job;
267
265
  if (job.initialPlanning?.status === 'pending')
268
266
  return job;
267
+ const recovered = recoverPendingPlannerTask(job, this.options.plannerTimeoutMs ?? storySizeTimeoutMs('M'));
268
+ if (recovered) {
269
+ await this.store.appendTask(recovered);
270
+ await this.emit(eventFor(job.id, recovered.id, null, 'planner.created', 'queued', `Recovered pending Plan ${recovered.id}`, 'orchestrator'));
271
+ return this.store.load(job.id);
272
+ }
269
273
  const allPlanners = job.tasks.filter(task => task.kind === 'planner').sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
270
274
  if (allPlanners.length === 0 && job.tasks.length === 0)
271
275
  return job;
@@ -284,9 +288,8 @@ export class Reconciler extends ReconcilerReviewBase {
284
288
  if (latest?.plannerResult?.objective_status === 'satisfied')
285
289
  return job;
286
290
  const task = createPlannerRoundTask(job, { timeoutMs: this.options.plannerTimeoutMs ?? storySizeTimeoutMs('M') });
291
+ await this.store.appendPlan(pendingPlanForPlannerTask(job, task));
287
292
  await this.store.appendTask(task);
288
- if (consumeChangeCycle(job, task.id))
289
- await this.store.saveJob(job);
290
293
  await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'planner.created', 'queued', `Planner task ${task.id} created`, 'orchestrator'));
291
294
  return this.store.load(job.id);
292
295
  }
@@ -296,7 +299,15 @@ export class Reconciler extends ReconcilerReviewBase {
296
299
  return;
297
300
  const executionContext = await this.plannerContext(job, contextTask);
298
301
  for (const planner of job.tasks.filter(task => task.kind === 'planner' && task.state === 'SUCCEEDED' && task.plannerResult)) {
299
- const children = planner.plannerResult?.next_tasks.map(spec => this.plannerChild(job, planner, spec, executionContext)) ?? [];
302
+ const result = planner.plannerResult;
303
+ const plan = { jobId: job.id, id: planner.id, round: planner.planningRound ?? 1, source: planner.planningSource ?? 'model-planner', previousPlanId: planner.previousPlannerTaskId ?? null, trigger: planner.planTrigger ?? { source: 'continuation' }, status: 'SUCCEEDED', guidance: planner.plannerGuidance, objectiveStatus: result.objective_status, assessment: result.assessment, generatedTaskIds: result.next_tasks.map(item => item.id), headSha: planner.planTrigger?.currentHeadSha ?? job.headSha, createdAt: planner.createdAt, updatedAt: planner.updatedAt };
304
+ if (!(job.plans ?? []).some(item => item.id === plan.id)) {
305
+ await this.store.appendPlan(plan);
306
+ (job.plans ??= []).push(plan);
307
+ }
308
+ else
309
+ await this.store.savePlan(plan);
310
+ const children = result.next_tasks.map(spec => this.plannerChild(job, planner, spec, executionContext));
300
311
  for (const child of children) {
301
312
  const existing = job.tasks.find(item => item.id === child.id);
302
313
  if (existing) {
@@ -458,7 +469,7 @@ export class Reconciler extends ReconcilerReviewBase {
458
469
  let promise;
459
470
  try {
460
471
  const executionContext = await this.plannerContext(job, task);
461
- const context = { job, tasks: job.tasks, objective: job.objective ?? job.description ?? job.title, previousPlannerTaskId: task.previousPlannerTaskId ?? null, executionContext, retryFeedback: task.plannerRetryFeedback, guidance: task.plannerGuidance };
472
+ const context = { job, tasks: job.tasks, objective: job.objective ?? job.description ?? job.title, previousPlannerTaskId: task.previousPlannerTaskId ?? null, trigger: task.planTrigger, executionContext, retryFeedback: task.plannerRetryFeedback, guidance: task.plannerGuidance };
462
473
  task.input = plannerInput(context);
463
474
  await this.store.saveTask(task);
464
475
  promise = planner.plan(context, controller.signal);
@@ -491,6 +502,8 @@ export class Reconciler extends ReconcilerReviewBase {
491
502
  if (task.attempt < task.maxAttempts)
492
503
  await transitionTaskEvent(task, 'retry');
493
504
  await this.store.saveTask(task);
505
+ const pending = (job.plans ?? []).find(plan => plan.id === task.id) ?? pendingPlanForPlannerTask(job, task);
506
+ await this.store.savePlan({ ...pending, status: task.state === 'FAILED' ? 'FAILED' : 'PLANNING', error: error.message, assessment: task.state === 'FAILED' ? `Planning failed: ${error.message}` : `Planning retry: ${error.message}`, updatedAt: new Date().toISOString() });
494
507
  await this.emit(eventFor(job.id, task.id, task.parentTaskId, task.state === 'FAILED' ? 'planner.failed' : 'planner.retry', task.state === 'FAILED' ? 'failed' : 'queued', `Planner ${task.id}: ${error.message}`, 'user'));
495
508
  this.detach(this.reconcile(jobId), jobId, task.id, 'planner retry reconciliation');
496
509
  return;
@@ -509,7 +522,15 @@ export class Reconciler extends ReconcilerReviewBase {
509
522
  if (existing && !samePlannerChildDefinition(existing, child))
510
523
  throw new Error(`Planner child ${child.id} already exists with a different definition`);
511
524
  }
512
- // Persist the completed decision before exposing executable children.
525
+ // Persist the first-class Plan decision before exposing executable child Task comments.
526
+ const now = new Date().toISOString();
527
+ const plan = { jobId: job.id, id: task.id, round: task.planningRound ?? 1, source: task.planningSource ?? 'model-planner', previousPlanId: task.previousPlannerTaskId ?? null, trigger: task.planTrigger ?? { source: task.manualPlanning ? 'manual' : 'continuation' }, status: 'SUCCEEDED', guidance: task.plannerGuidance, objectiveStatus: result.objective_status, assessment: result.assessment, generatedTaskIds: result.next_tasks.map(item => item.id), headSha: task.planTrigger?.currentHeadSha ?? await this.store.headSha?.() ?? job.headSha, createdAt: task.createdAt, updatedAt: now };
528
+ await this.store.savePlan(plan);
529
+ if (job.changeCycle && !job.changeCycle.consumedByPlannerTaskId && plan.trigger.source === 'unblock') {
530
+ job.changeCycle.consumedByPlannerTaskId = task.id;
531
+ job.updatedAt = now;
532
+ await this.store.saveJob(job);
533
+ }
513
534
  task.plannerResult = result;
514
535
  task.result = { summary: result.assessment, data: { objective_status: result.objective_status, next_tasks: result.next_tasks, unblocks: result.unblocks } };
515
536
  task.error = null;
@@ -543,6 +564,8 @@ export class Reconciler extends ReconcilerReviewBase {
543
564
  await this.store.saveTask(task);
544
565
  await this.emit(eventFor(job.id, task.id, task.parentTaskId, task.state === 'FAILED' ? 'planner.failed' : 'planner.retry', task.state === 'FAILED' ? 'failed' : 'queued', `Planner ${task.id}: ${message}`, 'user'));
545
566
  }
567
+ const pending = (await this.store.load(job.id)).plans?.find(plan => plan.id === task.id) ?? pendingPlanForPlannerTask(job, task);
568
+ await this.store.savePlan({ ...pending, status: task.state === 'QUEUED' ? 'PLANNING' : 'FAILED', error: message, assessment: task.state === 'QUEUED' ? `Planning retry: ${message}` : `Planning failed: ${message}`, updatedAt: new Date().toISOString() });
546
569
  }
547
570
  if (!task.manualPlanning)
548
571
  this.detach(this.reconcile(jobId), jobId, task.id, 'planner follow-up reconciliation');
@@ -5,7 +5,7 @@ export function implementationTasks(job) {
5
5
  return job.tasks.filter(task => task.agentName !== 'reviewer'
6
6
  && task.agentName !== 'troubleshooter'
7
7
  && !isSuperseded(task)
8
- && !(task.state === 'BLOCKED' && task.unblock));
8
+ && task.state !== 'UNBLOCKED');
9
9
  }
10
10
  export function implementationComplete(job) {
11
11
  const tasks = implementationTasks(job);
@@ -23,7 +23,7 @@ export function deriveJobRuntimeStatus(job, prState = 'OPEN') {
23
23
  return 'RUNNING';
24
24
  if (job.tasks.some(task => task.state === 'QUEUED'))
25
25
  return 'QUEUED';
26
- if (job.tasks.some(task => task.state === 'BLOCKED' && !task.unblock))
26
+ if (job.tasks.some(task => task.state === 'BLOCKED'))
27
27
  return 'BLOCKED';
28
28
  if (job.tasks.some(task => task.state === 'FAILED'))
29
29
  return 'FAILED';
@@ -43,7 +43,7 @@ export function deriveJobRuntimeStatus(job, prState = 'OPEN') {
43
43
  return 'RUNNING';
44
44
  if (job.tasks.some(task => task.state === 'QUEUED'))
45
45
  return 'QUEUED';
46
- if (job.tasks.some(task => task.state === 'BLOCKED' && !task.unblock))
46
+ if (job.tasks.some(task => task.state === 'BLOCKED'))
47
47
  return 'BLOCKED';
48
48
  if (job.tasks.some(task => task.state === 'FAILED'))
49
49
  return 'FAILED';
@@ -1,7 +1,8 @@
1
- import { parseAgent, parseJob, parseTask, renderAgentComment, renderJobBody, renderTaskComment, JOB_MARKER } from './markers.js';
1
+ import { parseAgent, parseJob, parsePlan, parseTask, renderAgentComment, renderJobBody, renderPlanComment, renderTaskComment, JOB_MARKER } from './markers.js';
2
2
  import { isTaskForJob, sameTaskDefinition } from './store-task-definition.js';
3
3
  import { registerBlockerDependency, resolveBlockerDependencies } from './blockers.js';
4
4
  const TASK_WRITE_OVERLAY = new Map();
5
+ const PLAN_WRITE_OVERLAY = new Map();
5
6
  export class GitHubStore {
6
7
  client;
7
8
  repository;
@@ -24,7 +25,9 @@ export class GitHubStore {
24
25
  }
25
26
  }
26
27
  taskKey(task) { return `${this.repository}#${this.prNumber}:${task.jobId ?? ''}/${task.id}`; }
28
+ planKey(plan) { return `${this.repository}#${this.prNumber}:${plan.jobId}/${plan.id}`; }
27
29
  taskKeyFromBody(body) { const task = parseTask(body); return task ? this.taskKey(task) : null; }
30
+ planKeyFromBody(body) { const plan = parsePlan(body); return plan ? this.planKey(plan) : null; }
28
31
  overlayTaskWrites(comments) {
29
32
  if (TASK_WRITE_OVERLAY.size === 0)
30
33
  return comments;
@@ -46,6 +49,27 @@ export class GitHubStore {
46
49
  return result;
47
50
  }
48
51
  rememberTaskWrite(task, comment, body) { TASK_WRITE_OVERLAY.set(this.taskKey(task), { ...comment, body }); }
52
+ overlayPlanWrites(comments) {
53
+ if (PLAN_WRITE_OVERLAY.size === 0)
54
+ return comments;
55
+ const result = [...comments];
56
+ for (const [key, overlay] of [...PLAN_WRITE_OVERLAY]) {
57
+ const index = result.findIndex(comment => this.planKeyFromBody(comment.body) === key);
58
+ if (index >= 0) {
59
+ const remote = result[index];
60
+ if (remote.updatedAt && overlay.updatedAt && remote.updatedAt >= overlay.updatedAt) {
61
+ PLAN_WRITE_OVERLAY.delete(key);
62
+ continue;
63
+ }
64
+ result[index] = { ...remote, ...overlay, id: remote.id || overlay.id, nodeId: remote.nodeId ?? overlay.nodeId, url: remote.url ?? overlay.url, authorLogin: remote.authorLogin ?? overlay.authorLogin };
65
+ }
66
+ else {
67
+ PLAN_WRITE_OVERLAY.delete(key);
68
+ }
69
+ }
70
+ return result;
71
+ }
72
+ rememberPlanWrite(plan, comment, body) { PLAN_WRITE_OVERLAY.set(this.planKey(plan), { ...comment, body }); }
49
73
  isTrusted(comment) { return comment.authorLogin !== null && this.trustedAuthors.has(comment.authorLogin); }
50
74
  repoParts() {
51
75
  const [owner, name] = this.repository.split('/', 2);
@@ -86,9 +110,9 @@ export class GitHubStore {
86
110
  }
87
111
  async list() {
88
112
  if (this.commentsCache)
89
- return this.overlayTaskWrites(this.commentsCache);
113
+ return this.overlayPlanWrites(this.overlayTaskWrites(this.commentsCache));
90
114
  if (this.commentsFlight)
91
- return this.overlayTaskWrites(await this.commentsFlight);
115
+ return this.overlayPlanWrites(this.overlayTaskWrites(await this.commentsFlight));
92
116
  const flight = (async () => {
93
117
  const { owner, name } = this.repoParts();
94
118
  const all = [];
@@ -111,7 +135,7 @@ export class GitHubStore {
111
135
  this.commentsFlight = flight;
112
136
  try {
113
137
  this.commentsCache = await flight;
114
- return this.overlayTaskWrites(this.commentsCache);
138
+ return this.overlayPlanWrites(this.overlayTaskWrites(this.commentsCache));
115
139
  }
116
140
  finally {
117
141
  if (this.commentsFlight === flight)
@@ -182,6 +206,8 @@ export class GitHubStore {
182
206
  throw new Error(`JOB_MARKER_MISSING: Managed GitHub job ${jobId} has no durable ${JOB_MARKER} marker in PR #${this.prNumber}`);
183
207
  }
184
208
  const soleTrustedJob = jobs.length === 1;
209
+ job.plans = comments.map(comment => { const plan = parsePlan(comment.body); if (!plan || plan.jobId !== jobId)
210
+ return null; const planUrl = comment.url ?? (comment.id > 0 ? `https://github.com/${this.repository}/pull/${this.prNumber}#issuecomment-${comment.id}` : undefined); return planUrl ? { ...plan, planUrl } : plan; }).filter((x) => x !== null).sort((a, b) => a.round - b.round);
185
211
  job.tasks = comments.map(comment => {
186
212
  const task = parseTask(comment.body);
187
213
  if (!task)
@@ -285,6 +311,36 @@ export class GitHubStore {
285
311
  for (const legacy of jobComments.filter(comment => parseJob(comment.body)?.id === job.id))
286
312
  await this.deleteComment(legacy);
287
313
  }
314
+ async appendPlan(plan) {
315
+ await this.load(plan.jobId);
316
+ const comments = (await this.list()).filter(comment => this.isTrusted(comment));
317
+ const matches = comments.filter(comment => { const existing = parsePlan(comment.body); return existing !== null && existing.jobId === plan.jobId && existing.id === plan.id; });
318
+ if (matches.length === 0) {
319
+ const body = renderPlanComment(plan);
320
+ const persisted = await this.comment(body);
321
+ this.rememberPlanWrite(plan, persisted, body);
322
+ return;
323
+ }
324
+ const existing = parsePlan(matches[0].body);
325
+ if (!existing || JSON.stringify({ ...existing, planUrl: undefined }) !== JSON.stringify({ ...plan, planUrl: undefined }))
326
+ throw new Error(`Plan ${plan.id} already exists with a different definition`);
327
+ for (const duplicate of matches.slice(1))
328
+ await this.deleteComment(duplicate);
329
+ }
330
+ async savePlan(plan) {
331
+ await this.load(plan.jobId);
332
+ const comments = (await this.list()).filter(comment => this.isTrusted(comment));
333
+ const existing = comments.filter(comment => { const markerPlan = parsePlan(comment.body); return markerPlan !== null && markerPlan.jobId === plan.jobId && markerPlan.id === plan.id; });
334
+ if (existing.length > 0) {
335
+ const body = renderPlanComment(plan);
336
+ const persisted = await this.updateComment(existing[0], body);
337
+ this.rememberPlanWrite(plan, persisted, body);
338
+ for (const duplicate of existing.slice(1))
339
+ await this.deleteComment(duplicate);
340
+ }
341
+ else
342
+ await this.appendPlan(plan);
343
+ }
288
344
  async appendTask(task) {
289
345
  if (!task.jobId)
290
346
  throw new Error('GitHub task requires jobId');
package/dist/store.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { createSign } from 'node:crypto';
3
3
  import { AsyncLocalStorage } from 'node:async_hooks';
4
- import { parseAgent, parseJob, parseTask, renderAgentComment, renderJobComment, renderTaskComment, AGENT_MARKER, JOB_MARKER, TASK_MARKER } from './markers.js';
4
+ import { parseAgent, parseJob, parsePlan, parseTask, renderAgentComment, renderJobComment, renderPlanComment, renderTaskComment, AGENT_MARKER, JOB_MARKER, PLAN_MARKER, TASK_MARKER } from './markers.js';
5
5
  import { isTaskForJob, sameTaskDefinition } from './store-task-definition.js';
6
6
  import { parsePackagePublicationMarker } from './github-webhook.js';
7
7
  function publicationFromComments(rows, trustedAuthors) {
@@ -44,6 +44,8 @@ export async function loadManagedGitHubPullRequestSnapshotGraphql(client, reposi
44
44
  const commentJobs = trusted.map(item => parseJob(String(item.body ?? ''))).filter((item) => item !== null);
45
45
  const jobs = bodyJob ? [bodyJob] : commentJobs;
46
46
  const publication = publicationFromComments(commentRows, trustedAuthors);
47
+ const plans = trusted.map(row => { const plan = parsePlan(String(row.body ?? '')); if (!plan)
48
+ return null; const planUrl = typeof row.url === 'string' ? row.url : Number.isInteger(row.databaseId) ? `https://github.com/${repository}/pull/${prNumber}#issuecomment-${row.databaseId}` : undefined; return planUrl ? { ...plan, planUrl } : plan; }).filter((item) => item !== null);
47
49
  const tasks = trusted.map(row => {
48
50
  const task = parseTask(String(row.body ?? ''));
49
51
  if (!task)
@@ -54,6 +56,7 @@ export async function loadManagedGitHubPullRequestSnapshotGraphql(client, reposi
54
56
  const state = String(pr.state ?? '').toUpperCase();
55
57
  const githubState = pr.merged === true ? 'MERGED' : state === 'CLOSED' ? 'CLOSED' : 'OPEN';
56
58
  const overviews = jobs.map(job => {
59
+ job.plans = plans.filter(plan => plan.jobId === job.id).sort((a, b) => a.round - b.round);
57
60
  job.tasks = tasks.filter(task => task.jobId === job.id || (!task.jobId && jobs.length === 1));
58
61
  if (!job.description && typeof pr.body === 'string' && pr.body)
59
62
  job.description = pr.body;
@@ -84,13 +87,22 @@ export class InMemoryStore {
84
87
  constructor(job) {
85
88
  if (job) {
86
89
  this.values.set(`${JOB_MARKER}:${job.id}`, renderJobComment(job));
90
+ for (const plan of job.plans ?? [])
91
+ this.values.set(`${PLAN_MARKER}:${plan.jobId}:${plan.id}`, renderPlanComment(plan));
87
92
  for (const task of job.tasks)
88
93
  this.values.set(`${TASK_MARKER}:${task.jobId ?? ''}:${task.id}`, renderTaskComment(task));
89
94
  }
90
95
  }
91
96
  async load(jobId) { const job = await this.findJob(jobId); if (!job)
92
- throw new Error(`Job ${jobId} not found`); job.tasks = this.tasksForJob(jobId); return job; }
97
+ throw new Error(`Job ${jobId} not found`); job.plans = this.plansForJob(jobId); job.tasks = this.tasksForJob(jobId); return job; }
93
98
  async saveJob(job) { this.values.set(`${JOB_MARKER}:${job.id}`, renderJobComment(job)); }
99
+ async appendPlan(plan) { const key = `${PLAN_MARKER}:${plan.jobId}:${plan.id}`; const existing = this.values.get(key); if (existing) {
100
+ const current = parsePlan(existing);
101
+ if (current && JSON.stringify(current) === JSON.stringify(plan))
102
+ return;
103
+ throw new Error(`Plan ${plan.id} already exists with a different definition`);
104
+ } this.values.set(key, renderPlanComment(plan)); }
105
+ async savePlan(plan) { this.values.set(`${PLAN_MARKER}:${plan.jobId}:${plan.id}`, renderPlanComment(plan)); }
94
106
  async appendTask(task) {
95
107
  const key = `${TASK_MARKER}:${task.jobId ?? ''}:${task.id}`;
96
108
  const existing = this.values.get(key);
@@ -110,6 +122,7 @@ export class InMemoryStore {
110
122
  async saveAgent(agent) { this.values.set(`${AGENT_MARKER}:${agent.id}`, renderAgentComment(agent)); }
111
123
  async comments() { return [...this.values.values()].map((body, i) => ({ id: i + 1, body, updatedAt: new Date().toISOString(), authorLogin: 'local' })); }
112
124
  findJob(jobId) { return [...this.values.values()].map(parseJob).find((x) => x !== null && x.id === jobId) ?? null; }
125
+ plansForJob(jobId) { return [...this.values.values()].map(parsePlan).filter((x) => x !== null && x.jobId === jobId).sort((a, b) => a.round - b.round); }
113
126
  tasksForJob(jobId) {
114
127
  const jobs = [...this.values.values()].map(parseJob).filter((x) => x !== null && typeof x.id === 'string' && x.id.length > 0);
115
128
  return [...this.values.values()].map(parseTask).filter((x) => x !== null).filter(task => isTaskForJob(task, jobId, jobs.length === 1));
package/dist/unblock.js CHANGED
@@ -1,9 +1,9 @@
1
1
  import { extendRetryBudget, transitionTaskEvent } from './scheduler.js';
2
- export function isEffectivelyBlocked(task) { return task.kind !== 'planner' && task.state === 'BLOCKED' && !task.unblock; }
2
+ export function isEffectivelyBlocked(task) { return task.kind !== 'planner' && task.state === 'BLOCKED'; }
3
3
  function baselineFor(task) {
4
4
  return task.blockedBaseline ?? { blockedAt: task.updatedAt, reason: task.error ?? undefined };
5
5
  }
6
- function cycleFacts(job, tasks, currentHeadSha, reason) {
6
+ function unblockFacts(job, tasks, currentHeadSha, reason) {
7
7
  const facts = [`unblock: ${reason}`];
8
8
  const baselines = tasks.map(task => baselineFor(task));
9
9
  const heads = [...new Set(baselines.map(item => item.headSha).filter((item) => Boolean(item)))];
@@ -11,7 +11,7 @@ function cycleFacts(job, tasks, currentHeadSha, reason) {
11
11
  facts.push(`PR head advanced from ${heads.join(', ')} to ${currentHeadSha}`);
12
12
  const times = baselines.map(item => Date.parse(item.blockedAt)).filter(Number.isFinite).sort((a, b) => a - b);
13
13
  const earliest = times[0];
14
- if (earliest !== undefined) {
14
+ if (earliest !== undefined)
15
15
  for (const task of job.tasks) {
16
16
  if (tasks.some(item => item.id === task.id))
17
17
  continue;
@@ -21,9 +21,28 @@ function cycleFacts(job, tasks, currentHeadSha, reason) {
21
21
  const detail = task.result?.summary ?? task.error ?? task.state;
22
22
  facts.push(`task ${task.id} -> ${task.state}: ${detail}`);
23
23
  }
24
- }
25
24
  return [...new Set(facts)];
26
25
  }
26
+ export function unconsumedUnblockTrigger(job) {
27
+ const consumed = new Set((job.plans ?? []).flatMap(plan => plan.trigger.source === 'unblock' ? plan.trigger.taskIds ?? [] : []));
28
+ const tasks = job.tasks.filter(task => task.state === 'UNBLOCKED' && task.unblock && !consumed.has(task.id));
29
+ if (tasks.length === 0) {
30
+ const legacy = job.changeCycle;
31
+ if (!legacy || legacy.consumedByPlannerTaskId)
32
+ return undefined;
33
+ return { source: 'unblock', taskIds: legacy.taskIds, reason: legacy.reason, facts: legacy.facts, blockedHeadShas: legacy.baselineHeadShas, currentHeadSha: legacy.currentHeadSha, unblockSource: legacy.source };
34
+ }
35
+ const records = tasks.map(task => task.unblock);
36
+ return {
37
+ source: 'unblock',
38
+ taskIds: tasks.map(task => task.id),
39
+ reason: [...new Set(records.map(item => item.reason))].join('; '),
40
+ facts: [...new Set(records.flatMap(item => item.facts ?? [`unblock: ${item.reason}`]))],
41
+ blockedHeadShas: [...new Set(records.map(item => item.baseline.headSha).filter((item) => Boolean(item)))],
42
+ currentHeadSha: records.map(item => item.currentHeadSha).find((item) => Boolean(item)),
43
+ unblockSource: records[0].source,
44
+ };
45
+ }
27
46
  export async function unblockAutonomousTasks(options) {
28
47
  const { store, job, trigger } = options;
29
48
  const tasks = options.tasks.filter(isEffectivelyBlocked);
@@ -31,44 +50,43 @@ export async function unblockAutonomousTasks(options) {
31
50
  throw new Error('No effective BLOCKED work task is available to unblock');
32
51
  const now = options.now ?? new Date().toISOString();
33
52
  const currentHeadSha = await store.headSha?.() ?? job.headSha ?? null;
53
+ const facts = unblockFacts(job, tasks, currentHeadSha, trigger.reason);
34
54
  for (const task of tasks) {
35
55
  const baseline = baselineFor(task);
36
- const record = { at: now, source: trigger.source, reason: trigger.reason, baseline, currentHeadSha: currentHeadSha ?? undefined, blockerJobId: trigger.blockerJobId, resolution: trigger.resolution };
37
56
  task.blockedBaseline = baseline;
38
- task.unblock = record;
57
+ task.unblock = { at: now, source: trigger.source, reason: trigger.reason, baseline, currentHeadSha: currentHeadSha ?? undefined, facts, blockerJobId: trigger.blockerJobId, resolution: trigger.resolution };
58
+ await transitionTaskEvent(task, 'task.unblocked');
39
59
  task.updatedAt = now;
40
60
  delete task.blockedBy;
41
61
  await store.saveTask(task);
42
62
  }
43
- const baselineHeadShas = [...new Set(tasks.map(task => baselineFor(task).headSha).filter((item) => Boolean(item)))];
44
- const cycle = { triggeredAt: now, source: trigger.source, reason: trigger.reason, taskIds: tasks.map(task => task.id), baselineHeadShas, currentHeadSha: currentHeadSha ?? undefined, facts: cycleFacts(job, tasks, currentHeadSha, trigger.reason) };
45
- job.changeCycle = cycle;
46
- job.updatedAt = now;
47
- await store.saveJob(job);
48
- return cycle;
63
+ const planTrigger = unconsumedUnblockTrigger(job) ?? {
64
+ source: 'unblock', taskIds: tasks.map(task => task.id), reason: trigger.reason,
65
+ facts, currentHeadSha: currentHeadSha ?? undefined, unblockSource: trigger.source,
66
+ };
67
+ return planTrigger;
49
68
  }
50
69
  export async function unblockTask(options) {
51
70
  if (!isEffectivelyBlocked(options.task))
52
71
  throw new Error(`Task ${options.task.id} is not an effective BLOCKED work task`);
53
- if (options.job.executionMode === 'autonomous') {
54
- const changeCycle = await unblockAutonomousTasks({ store: options.store, job: options.job, tasks: [options.task], trigger: options.trigger, now: options.now });
55
- return { replanning: true, changeCycle };
56
- }
72
+ if (options.job.executionMode === 'autonomous')
73
+ return { replanning: true, planTrigger: await unblockAutonomousTasks({ store: options.store, job: options.job, tasks: [options.task], trigger: options.trigger, now: options.now }) };
74
+ const now = options.now ?? new Date().toISOString();
75
+ const currentHeadSha = await options.store.headSha?.() ?? options.job.headSha ?? null;
76
+ const baseline = baselineFor(options.task);
77
+ options.task.blockedBaseline = baseline;
78
+ const facts = unblockFacts(options.job, [options.task], currentHeadSha, options.trigger.reason);
79
+ options.task.unblock = { at: now, source: options.trigger.source, reason: options.trigger.reason, baseline, currentHeadSha: currentHeadSha ?? undefined, facts, blockerJobId: options.trigger.blockerJobId, resolution: options.trigger.resolution };
80
+ await transitionTaskEvent(options.task, 'task.unblocked');
81
+ await options.store.saveTask(options.task);
57
82
  options.task.maxAttempts = extendRetryBudget(options.task);
58
83
  await transitionTaskEvent(options.task, 'retry');
59
84
  options.task.error = null;
60
85
  options.task.leaseOwner = null;
61
86
  options.task.leaseExpiresAt = null;
62
87
  options.task.executionId = null;
63
- options.task.updatedAt = options.now ?? new Date().toISOString();
88
+ options.task.updatedAt = now;
64
89
  delete options.task.blockedBy;
65
90
  await options.store.saveTask(options.task);
66
91
  return { replanning: false };
67
92
  }
68
- export function consumeChangeCycle(job, plannerTaskId, now = new Date().toISOString()) {
69
- if (!job.changeCycle || job.changeCycle.consumedByPlannerTaskId)
70
- return false;
71
- job.changeCycle.consumedByPlannerTaskId = plannerTaskId;
72
- job.updatedAt = now;
73
- return true;
74
- }
@@ -23,6 +23,7 @@ export const WORKFLOW_EVENT_NAMES = [
23
23
  'task.completed',
24
24
  'task.failed',
25
25
  'task.started',
26
+ 'task.unblocked',
26
27
  'watchdog.failed',
27
28
  'worker.delivery_failed',
28
29
  'worker.start_ack_failed',
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "1.0.161",
3
+ "version": "1.0.163",
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-history round. Returned work Tasks are not automatically children of that round; use `parentTaskId` only when the business Task graph needs hierarchy.
9
+ Relay persists each planning round as a first-class Plan PR comment/marker before generated Task comments. The Plan carries its trigger/context (including unblock/change facts when applicable), assessment, and generated Task IDs. Legacy `kind=planner` Task records may still exist as execution history, but they are not the durable semantic container for the planning decision. Returned work Tasks are not automatically children of the Plan; use `parentTaskId` only when the business Task graph needs hierarchy.
10
10
 
11
11
  ## Planning contract
12
12
 
@@ -66,6 +66,8 @@ Do not create separate Tasks merely for "analyze", "format", "call tool", "check
66
66
  - Otherwise return only the next useful executable batch from the graph-derived Task boundaries.
67
67
  - Do not repeat completed work or recreate an existing Task unless replacement work is explicitly needed.
68
68
  - Prefer independently verifiable Tasks with stable IDs, but do not split work solely to make Tasks smaller.
69
+ - When a Task depends on mutable repository or product state, distinguish a frozen evidence target from the current execution head. A SHA may freeze that Task's own review/source evidence, but do not copy an upstream SHA into a dependent release, deploy, publication, or other follow-on Task unless the objective explicitly requires that same frozen target. After dependencies complete, downstream Tasks must resolve and verify the current durable Job/PR head before acting.
70
+ - When fresh UI, browser, store, runtime, or visual evidence is material to the Task outcome, say so explicitly in the Task input and require evidence from the real current state. Existing screenshots, prepared assets, or prior observations are not substitutes for fresh evidence unless the objective explicitly allows reuse.
69
71
  - When a Job needs multi-perspective review, use the visible `review-chair` agent or an Agent Graph to select specialist roles dynamically. Keep specialist Tasks as independent siblings against the same immutable target/version, and leave worker/model/provider routing to Relay.
70
72
  - A Task describes what an agent must accomplish; never emit a shell command as a worker Task.
71
73
  - Use dependencies only for Task IDs inside the current Job, and only when execution order actually matters. Never encode cross-Job or cross-repository dependencies in `dependencies`.
@@ -112,12 +112,24 @@ Relay reviewer list.
112
112
  A scheduled autonomous job adds cron/timezone/next-run and missed-run/concurrency policy to the trusted job marker, plus the `agents-relay:autonomous` label and one bounded time-of-day `agents-relay:next-run:*` index label. The GitHub heartbeat discovers only indexed due candidates, validates the marker's exact `nextRunAt`, claims the slot idempotently, advances the durable next run, and pulses `agents-relay:schedule-wake`. GitHub webhook delivery then wakes local Relay for planner/worker execution. While the schedule is active, the durable Job remains `ACTIVE`; between runs its derived runtime status is `WAITING`. Scheduled recurring Jobs do not enter one-shot final-review/auto-merge after each satisfied run; older-cycle failures are historical evidence and do not block a later slot.
113
113
 
114
114
 
115
+ ## Task evidence and head identity
116
+
117
+ Task inputs must make evidence freshness and target identity explicit when those facts affect correctness. A Task may bind its own review, audit, or source evidence to an immutable SHA/version. That frozen target belongs to that Task; it must not be propagated mechanically into later dependent work. A downstream release, deploy, publication, or other Task that runs after dependencies complete must resolve the current durable Job/PR head at execution time unless the Job objective explicitly requires the earlier immutable target.
118
+
119
+ When success depends on the actual current UI, browser state, store listing, runtime state, or another externally observable surface, the Task contract must require fresh evidence from that real current surface. Reusing an existing screenshot, prepared asset, cached observation, or older evidence is valid only when the Job explicitly permits it.
120
+
121
+ This does not weaken immutable review semantics: final review and specialist review Tasks should continue to declare and inspect one exact immutable target/version.
122
+
115
123
  ## Dependency vs block and authoritative unblock
116
124
 
117
125
  `dependencies` describe normal DAG ordering. Dependency completion does not create an unblock event or a planning change cycle; Relay simply releases dependent work when its prerequisites succeed. `BLOCKED` is different: it records that execution reached a runtime/external condition that invalidates normal progress.
118
126
 
119
- A BLOCKED Task cannot be resumed through retry, queue, dashboard state editing, PR push, webhook, ordinary task update, or troubleshooter inference. Except for blocker-Job resolution, the authoritative entry points are `task unblock --reason ...` and `job unblock --reason ...`. For autonomous Jobs, unblock does not retry the old Task. Relay preserves its blocked baseline, records current head/change facts in a durable Job Change Cycle, marks that blocked decision as unblocked-for-planning, and wakes the next Planner round. The Planner then decides whether to retry, replace, split, wait, or otherwise re-plan the objective.
127
+ A BLOCKED Task cannot be resumed through retry, queue, dashboard state editing, PR push, webhook, ordinary task update, or troubleshooter inference. Except for blocker-Job resolution, the authoritative entry points are `task unblock --reason ...` and `job unblock --reason ...`. Authoritative unblock records the blocked baseline and current evidence, then transitions `BLOCKED -> UNBLOCKED` through the durable `task.unblocked` lifecycle event. `BLOCKED` therefore always means the blocker is still effective; `UNBLOCKED` means the old blocked attempt is historical and its blocker was explicitly cleared.
128
+
129
+ For autonomous Jobs, an UNBLOCKED Task is never silently retried. Its unblock metadata becomes the trigger/change context for the next Planner round, which decides whether to replace, split, wait, or otherwise re-plan the objective. For fixed Jobs, authoritative unblock records the UNBLOCKED transition first and may then requeue the same Task according to fixed-job retry semantics.
130
+
131
+ Each Planner round is also a first-class durable Plan entity stored in its own `agents-relay:plan:v1` PR comment. Relay creates the Plan comment before the internal planner execution record and before any generated Task comments, then updates that same Plan comment with the final assessment and generated Task IDs. Unblock/change facts belong to the Plan trigger/context, not to new Job-level Change Cycle state. Historical `kind=planner` Task records and legacy Job `changeCycle` fields remain readable for compatibility only.
120
132
 
121
- A blocker Job owns reverse `unblocks` references. When it resolves, Relay checks the dependent Job. If that autonomous Job has an effective BLOCKED work Task, resolution uses the same Change Cycle/replanning primitive. If it does not have BLOCKED work (for example the registered Task is DRAFT or FAILED), existing release/retry behavior remains unchanged.
133
+ A blocker Job owns reverse `unblocks` references. When it resolves, Relay checks the dependent Job. If that autonomous Job has an effective BLOCKED work Task, resolution uses the same authoritative `BLOCKED -> UNBLOCKED -> Planner` path. If it does not have BLOCKED work (for example the registered Task is DRAFT or FAILED), existing release/retry behavior remains unchanged.
122
134
 
123
- Automatic Planner creation treats only an effective BLOCKED work Task as a block gate. FAILED history is not itself a Planner gate; active DRAFT/QUEUED/RUNNING work still prevents a competing planning round.
135
+ Automatic Planner creation treats only a Task whose lifecycle state is exactly `BLOCKED` as a block gate. `UNBLOCKED` and FAILED history do not themselves gate a new Planner round; active DRAFT/QUEUED/RUNNING work still prevents a competing planning round.
@@ -32,9 +32,11 @@ stateDiagram-v2
32
32
  RUNNING --> FAILED: watchdog.failed
33
33
  RUNNING --> CANCELLED: task.cancelled
34
34
 
35
- BLOCKED --> QUEUED: retry / mutation.clearError,mutation.clearFailureReason
35
+ BLOCKED --> UNBLOCKED: task.unblocked
36
36
  BLOCKED --> CANCELLED: cancel
37
37
 
38
+ UNBLOCKED --> QUEUED: retry / mutation.clearError,mutation.clearFailureReason
39
+
38
40
  FAILED --> QUEUED: retry / mutation.clearError,mutation.clearFailureReason
39
41
  FAILED --> RUNNING: task.started [recoverableExecution] / mutation.clearError,mutation.clearFailureReason
40
42
  FAILED --> SUCCEEDED: task.completed [recoverableExecution] / mutation.clearError,mutation.clearFailureReason