agents-relay 1.0.162 → 1.0.164

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
@@ -201,6 +201,13 @@ For work that was completed outside the relay but must be represented truthfully
201
201
 
202
202
  ### Automatic task troubleshooting
203
203
 
204
- When an ACTIVE Job contains a current-run `FAILED` or `BLOCKED` task, reconciliation creates one P0-priority `troubleshooter` recovery task for that exact failed attempt. The recovery task is routed to a local Codex-capable worker with repository/tool access and runs under the bundled Troubleshooter agent contract. Troubleshooter diagnosis is event-first: it must inspect the failed task's task-scoped lifecycle event history (including ordering, duplicate/missing/late events, source and phase data) before classifying root cause or retrying.
204
+ When an ACTIVE Job contains a current-run `FAILED` task, reconciliation may create one P0-priority `troubleshooter` recovery task for that exact failed attempt. `BLOCKED` work is not troubleshooter-owned: it remains blocked until authoritative unblock transitions it to `UNBLOCKED`. The recovery task is routed to a local Codex-capable worker with repository/tool access and runs under the bundled Troubleshooter agent contract. Troubleshooter diagnosis is event-first: it must inspect the failed task's task-scoped lifecycle event history (including ordering, duplicate/missing/late events, source and phase data) before classifying root cause or retrying.
205
205
 
206
- The Troubleshooter diagnoses before retrying. A task-local or transient blocker is repaired narrowly; a shared worker/runtime/tool defect is repaired through the owning repository's P0/self-job workflow. Only after the blocker is removed does it retry the original task and verify durable progress. Historical failures from older scheduled autonomous runs do not spawn recovery work, and deterministic reconciliation does not duplicate a Troubleshooter task for the same failed attempt.
206
+ The Troubleshooter diagnoses before retrying. A task-local or transient execution cause is repaired narrowly; a shared worker/runtime/tool defect is repaired through the owning repository's P0/self-job workflow. Only after the failure cause is repaired does it retry the original FAILED task and verify durable progress. Historical failures from older scheduled autonomous runs do not spawn recovery work, and deterministic reconciliation does not duplicate a Troubleshooter task for the same failed attempt.
207
+
208
+
209
+ ### Authoritative unblock and Planner history
210
+
211
+ Dependency release and runtime unblock are separate concepts. Dependency completion simply releases DAG work. A runtime/external blocker remains `BLOCKED` until `task unblock`, `job unblock`, or blocker-Job resolution records the blocked baseline/current evidence and emits `task.unblocked`, transitioning the Task to `UNBLOCKED`.
212
+
213
+ For autonomous Jobs, `UNBLOCKED` is historical: Relay does not retry that old Task. The unblock evidence becomes the trigger/context of the next Planner round. Every Planner round is a first-class `agents-relay:plan:v1` PR comment. Relay creates that Plan comment before generated Task comments, then updates the same comment with the Planner assessment and generated Task IDs, preserving causal order in the PR timeline. Fixed Jobs may transition `UNBLOCKED -> QUEUED` through retry after the authoritative unblock record exists. Legacy Job `changeCycle` data is accepted only as compatibility input and is not written by the current model.
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
  }
package/dist/cli-help.js CHANGED
@@ -75,7 +75,7 @@ Example:
75
75
  'job merge': `Merge the backing pull request and complete the durable Job.
76
76
 
77
77
  Required: --repo OWNER/REPO, --pr NUMBER, and --id JOB_ID. The current PR head SHA is pinned into the merge request.`,
78
- 'job unblock': `Authoritatively unblock the effective BLOCKED work Tasks in one Job.\n\nRequired: --repo OWNER/REPO --pr NUMBER --id JOB_ID --reason TEXT. Autonomous Jobs persist a Change Cycle and wake Planner; fixed Jobs resume the blocked Tasks directly.`,
78
+ 'job unblock': `Authoritatively unblock the effective BLOCKED work Tasks in one Job.\n\nRequired: --repo OWNER/REPO --pr NUMBER --id JOB_ID --reason TEXT. Autonomous Jobs transition blocked work to historical UNBLOCKED state and wake Planner with unblock facts as Plan trigger/context; fixed Jobs record UNBLOCKED first and then resume according to fixed-job retry semantics.`,
79
79
  'job cancel': `Cancel the durable Job and close its backing pull request.
80
80
 
81
81
  Required: --repo OWNER/REPO, --pr NUMBER, and --id JOB_ID. Nonterminal Tasks are cancelled before the PR is closed.`,
@@ -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.162",
3
+ "version": "1.0.164",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {
@@ -115,6 +115,6 @@ Keep secrets, credentials, private prompts, and large private payloads out of du
115
115
 
116
116
  ### Automatic task troubleshooting
117
117
 
118
- When an ACTIVE Job contains a current-run `FAILED` or `BLOCKED` task, reconciliation creates one P0-priority `troubleshooter` recovery task for that exact failed attempt. The recovery task is routed to a local Codex-capable worker with repository/tool access and runs under the bundled Troubleshooter agent contract. Troubleshooter diagnosis is event-first: it must inspect the failed task's task-scoped lifecycle event history (including ordering, duplicate/missing/late events, source and phase data) before classifying root cause or retrying.
118
+ When an ACTIVE Job contains a current-run `FAILED` task, reconciliation may create one P0-priority `troubleshooter` recovery task for that exact failed attempt. `BLOCKED` is resolved only through authoritative unblock, which records evidence and transitions the Task to `UNBLOCKED`. The recovery task is routed to a local Codex-capable worker with repository/tool access and runs under the bundled Troubleshooter agent contract. Autonomous `UNBLOCKED` Tasks are historical and feed the next Planner round instead of being retried or sent through Troubleshooter. Each Planner round is durably represented by an `agents-relay:plan:v1` PR comment created before generated Task comments and updated in place with its assessment and generated Task IDs. Troubleshooter diagnosis is event-first: it must inspect the failed task's task-scoped lifecycle event history (including ordering, duplicate/missing/late events, source and phase data) before classifying root cause or retrying.
119
119
 
120
120
  The Troubleshooter diagnoses before retrying. A task-local or transient blocker is repaired narrowly; a shared worker/runtime/tool defect is repaired through the owning repository's P0/self-job workflow. Only after the blocker is removed does it retry the original task and verify durable progress. Historical failures from older scheduled autonomous runs do not spawn recovery work, and deterministic reconciliation does not duplicate a Troubleshooter task for the same failed attempt.
@@ -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
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: troubleshooter
3
- description: Recover failed or blocked Agents Relay tasks by diagnosing the root cause, repairing recoverable blockers, and retrying the original task only after the blocker is removed.
3
+ description: Recover FAILED Agents Relay tasks by diagnosing runtime/execution faults and repairing recoverable causes. BLOCKED tasks require authoritative unblock and are not resumed by Troubleshooter.
4
4
  capabilities: [troubleshoot, recovery, orchestration, local_repo, iterative_tools]
5
5
  runtime: codex
6
6
  delegation: [p0-self-job, relay-task-retry]
@@ -9,7 +9,7 @@ restrictions: [preserve-failure-evidence, no-blind-retry, no-human-blocker-bypas
9
9
 
10
10
  # Troubleshooter Agent
11
11
 
12
- You are the recovery orchestrator for a failed or blocked Agents Relay task.
12
+ You are the recovery orchestrator for a FAILED Agents Relay task. Do not authoritatively clear BLOCKED work; that requires `task unblock`, `job unblock`, or blocker-Job resolution.
13
13
 
14
14
  Your goal is not to explain the failure. Your goal is to make the original task move again when it is safe and technically possible.
15
15
 
@@ -122,10 +122,14 @@ This does not weaken immutable review semantics: final review and specialist rev
122
122
 
123
123
  ## Dependency vs block and authoritative unblock
124
124
 
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.
125
+ `dependencies` describe normal DAG ordering. Dependency completion does not create an unblock lifecycle event or Planner unblock trigger; 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.
126
126
 
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 ...`. 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
128
 
129
- 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.
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
130
 
131
- 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.
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.
132
+
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.
134
+
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