agents-relay 1.0.159 → 1.0.161

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,4 +1,5 @@
1
1
  import { extendRetryBudget, transitionTaskEvent } from './scheduler.js';
2
+ import { isEffectivelyBlocked, unblockAutonomousTasks } from './unblock.js';
2
3
  function dependentKey(value) {
3
4
  return `${value.repository}#${value.prNumber}:${value.jobId}:${value.taskId}:a${value.blockedAttempt}`;
4
5
  }
@@ -91,6 +92,31 @@ export async function resolveBlockerDependencies(options) {
91
92
  obsolete += 1;
92
93
  continue;
93
94
  }
95
+ if (targetJob.executionMode === 'autonomous') {
96
+ if (isEffectivelyBlocked(task)) {
97
+ await unblockAutonomousTasks({
98
+ store,
99
+ job: targetJob,
100
+ tasks: [task],
101
+ trigger: { source: 'blocker_job', reason: `Blocker Job ${blocker.id} resolved (${options.resolution})`, blockerJobId: blocker.id, resolution: options.resolution },
102
+ now,
103
+ });
104
+ relation.state = 'resumed';
105
+ relation.resolvedAt = now;
106
+ relation.resolution = options.resolution;
107
+ relation.note = 'dependent task unblocked for replanning';
108
+ resumed += 1;
109
+ continue;
110
+ }
111
+ if (task.state === 'BLOCKED' && task.unblock) {
112
+ relation.state = 'resumed';
113
+ relation.resolvedAt = now;
114
+ relation.resolution = options.resolution;
115
+ relation.note = 'dependent Job already unblocked for replanning';
116
+ resumed += 1;
117
+ continue;
118
+ }
119
+ }
94
120
  if (task.state === 'DRAFT') {
95
121
  await transitionTaskEvent(task, 'release');
96
122
  }
package/dist/cli-help.js CHANGED
@@ -6,8 +6,8 @@ Usage:
6
6
  npx agents-relay <command> [options]
7
7
 
8
8
  Commands:
9
- job create|update|show|list|merge|cancel Manage durable GitHub-backed jobs
10
- task create|update|show|list|complete|retry|cancel Manage durable tasks
9
+ job create|update|show|list|merge|unblock|cancel Manage durable GitHub-backed jobs
10
+ task create|update|show|list|complete|retry|unblock|cancel Manage durable tasks
11
11
  plan Invoke the Planner for an existing managed Job
12
12
  blocker add|resolve|show Manage cross-Job blocker dependencies
13
13
  context resolve Resolve shared project instructions, skills, agents, and runtimes
@@ -25,7 +25,7 @@ Use 'node dist/cli.js' only for explicit local development/demo mode.`,
25
25
  job: `Manage GitHub-backed durable jobs.
26
26
 
27
27
  Usage:
28
- npx agents-relay job <create|update|show|list|merge|cancel> [options]
28
+ npx agents-relay job <create|update|show|list|merge|unblock|cancel> [options]
29
29
 
30
30
  Actions:
31
31
  create Create a durable Job, optionally binding an existing unmanaged PR
@@ -33,6 +33,7 @@ Actions:
33
33
  show Show one durable Job
34
34
  list List managed Jobs in a repository
35
35
  merge Merge the backing PR and complete the Job
36
+ unblock Authoritatively invalidate current BLOCKED decisions and trigger replanning/resume
36
37
  cancel Close the backing PR and cancel the Job
37
38
 
38
39
  Common options:
@@ -74,6 +75,7 @@ Example:
74
75
  'job merge': `Merge the backing pull request and complete the durable Job.
75
76
 
76
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.`,
77
79
  'job cancel': `Cancel the durable Job and close its backing pull request.
78
80
 
79
81
  Required: --repo OWNER/REPO, --pr NUMBER, and --id JOB_ID. Nonterminal Tasks are cancelled before the PR is closed.`,
@@ -86,14 +88,15 @@ Returns the same summary used by the dashboard header plus a running array with
86
88
  task: `Manage durable tasks.
87
89
 
88
90
  Usage:
89
- npx agents-relay task <create|update|show|list|retry|cancel> [options]
91
+ npx agents-relay task <create|update|show|list|retry|unblock|cancel> [options]
90
92
 
91
93
  Actions:
92
94
  create Create one durable Task under an existing Job
93
95
  update Update mutable Task fields
94
96
  show Show one Task
95
97
  list List Tasks for a Job
96
- retry Retry a failed or blocked Task
98
+ retry Retry a FAILED Task
99
+ unblock Authoritatively invalidate a BLOCKED decision
97
100
  complete Complete an orchestrator-owned Task
98
101
  cancel Cancel a Task`,
99
102
  'task create': `Create one durable Task under an existing Job.
@@ -0,0 +1,32 @@
1
+ import { arg, eventBus, publishWake, saveLocal } from './cli-support.js';
2
+ import { isEffectivelyBlocked, unblockAutonomousTasks, unblockTask } from './unblock.js';
3
+ export async function runTaskUnblockCommand(args, store, job, task, localFile) {
4
+ const reason = arg(args, '--reason');
5
+ if (!reason.trim())
6
+ throw new Error('task unblock requires --reason');
7
+ const result = await unblockTask({ store, job, task, trigger: { source: 'cli', reason } });
8
+ const refreshed = await store.load(job.id);
9
+ await saveLocal(localFile, refreshed);
10
+ await publishWake(eventBus(args), refreshed, task.id, `Task ${task.id} unblocked${result.replanning ? ' for replanning' : ''}`);
11
+ console.log(JSON.stringify({ taskId: task.id, ...result, task: refreshed.tasks.find(item => item.id === task.id) }, null, 2));
12
+ }
13
+ export async function runJobUnblockCommand(args, store, job) {
14
+ const reason = arg(args, '--reason');
15
+ if (!reason.trim())
16
+ throw new Error('job unblock requires --reason');
17
+ const blocked = job.tasks.filter(isEffectivelyBlocked);
18
+ if (blocked.length === 0)
19
+ throw new Error(`Job ${job.id} has no effective BLOCKED work task`);
20
+ if (job.executionMode === 'autonomous') {
21
+ const changeCycle = await unblockAutonomousTasks({ store, job, tasks: blocked, trigger: { source: 'cli', reason } });
22
+ 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
+ return;
26
+ }
27
+ for (const task of blocked)
28
+ await unblockTask({ store, job, task, trigger: { source: 'cli', reason } });
29
+ const refreshed = await store.load(job.id);
30
+ await publishWake(eventBus(args), refreshed, 'job', `Job ${job.id} unblocked`);
31
+ console.log(JSON.stringify({ jobId: job.id, replanning: false, resumedTasks: blocked.map(task => task.id) }, null, 2));
32
+ }
package/dist/cli.js CHANGED
@@ -7,8 +7,8 @@ import { dashboardManagedJobs, InMemoryStore, GitHubStore, resolvePullRequest, l
7
7
  import { githubAuthContext } from './github-auth.js';
8
8
  import { taskOverallTimeoutMs } from './types.js';
9
9
  import { Reconciler } from './reconciler.js';
10
- import { dashboardTaskAction, dashboardTaskSetState } from './dashboard-actions.js';
11
- export { dashboardTaskAction, dashboardTaskSetState } from './dashboard-actions.js';
10
+ import { dashboardJobSetState, dashboardTaskAction, dashboardTaskSetState } from './dashboard-actions.js';
11
+ export { dashboardJobSetState, dashboardTaskAction, dashboardTaskSetState } from './dashboard-actions.js';
12
12
  import { CodexAdapter, ChatGptAdapter } from './adapters.js';
13
13
  import { CodexThreadContinuation, CommandContinuation, WebhookContinuation } from './continuation.js';
14
14
  import { eventFor } from './events.js';
@@ -24,6 +24,7 @@ import { defaultWorkspaceRoot, resolveManagedProjectPath, resolveRepositoryProje
24
24
  import { runBlockerCommand } from './cli-blocker.js';
25
25
  import { helpText, usage } from './cli-help.js';
26
26
  import { parseAgentGraph } from './agent-graph.js';
27
+ import { runJobUnblockCommand, runTaskUnblockCommand } from './cli-unblock.js';
27
28
  export { helpText } from './cli-help.js';
28
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
30
  export { SERVICE_DEFAULTS, eventBus, executionMode, chatGptTabClosePolicy } from './cli-support.js';
@@ -130,7 +131,7 @@ async function workerTaskFromArgs(job, args, now) {
130
131
  };
131
132
  }
132
133
  async function runTaskCommand(action, args) {
133
- if (!['create', 'update', 'show', 'list', 'complete', 'retry', 'cancel'].includes(action))
134
+ if (!['create', 'update', 'show', 'list', 'complete', 'retry', 'unblock', 'cancel'].includes(action))
134
135
  throw new Error(usage);
135
136
  const { store, job, localFile } = await storeFor(args);
136
137
  if (action === 'list') {
@@ -165,6 +166,10 @@ async function runTaskCommand(action, args) {
165
166
  console.log(JSON.stringify(task, null, 2));
166
167
  return;
167
168
  }
169
+ if (action === 'unblock') {
170
+ await runTaskUnblockCommand(args, store, job, task, localFile);
171
+ return;
172
+ }
168
173
  if (action === 'complete') {
169
174
  if (task.adapter !== 'orchestrator')
170
175
  throw new Error('task complete is only valid for adapter=orchestrator self-tasks');
@@ -189,6 +194,8 @@ async function runTaskCommand(action, args) {
189
194
  }
190
195
  if (action === 'retry' || action === 'cancel') {
191
196
  if (action === 'retry') {
197
+ if (task.state === 'BLOCKED')
198
+ throw new Error(`Task ${id} is BLOCKED; use task unblock --reason instead of retry`);
192
199
  task.maxAttempts = extendRetryBudget(task);
193
200
  if (task.kind === 'planner' && task.error)
194
201
  task.plannerRetryFeedback = task.error;
@@ -255,8 +262,10 @@ async function runTaskCommand(action, args) {
255
262
  throw new Error('Task dependencies must succeed before queueing');
256
263
  if (task.state === 'DRAFT')
257
264
  await transitionTaskEvent(task, 'release');
258
- else if (task.state === 'FAILED' || task.state === 'BLOCKED')
265
+ else if (task.state === 'FAILED')
259
266
  await transitionTaskEvent(task, 'retry');
267
+ else if (task.state === 'BLOCKED')
268
+ throw new Error(`Task ${id} is BLOCKED; use task unblock --reason instead of --queue`);
260
269
  }
261
270
  if (hasArg(args, '--max-attempts') && task.maxAttempts < task.attempt)
262
271
  throw new Error('--max-attempts cannot be less than the current attempt count');
@@ -306,7 +315,7 @@ async function runContextCommand(action, args) {
306
315
  console.log(JSON.stringify(context, null, 2));
307
316
  }
308
317
  async function runJobCommand(action, args) {
309
- if (!['create', 'update', 'show', 'list', 'merge', 'cancel'].includes(action))
318
+ if (!['create', 'update', 'show', 'list', 'merge', 'unblock', 'cancel'].includes(action))
310
319
  throw new Error(usage);
311
320
  const repo = repository(args);
312
321
  let createObjective;
@@ -359,6 +368,10 @@ async function runJobCommand(action, args) {
359
368
  throw new Error(`job ${action} requires --pr`);
360
369
  const store = new GitHubStore(auth.client, repo, prNumber, auth.trustedAuthors);
361
370
  const job = await store.load(id);
371
+ if (action === 'unblock') {
372
+ await runJobUnblockCommand(args, store, job);
373
+ return;
374
+ }
362
375
  if (action === 'show') {
363
376
  console.log(JSON.stringify(job, null, 2));
364
377
  return;
@@ -488,6 +501,7 @@ export async function createService(args) {
488
501
  start: dashboardJobAction(loaded.store, reconciler, 'start'),
489
502
  reconcile: dashboardJobAction(loaded.store, reconciler, 'reconcile'),
490
503
  cancel: dashboardJobAction(loaded.store, reconciler, 'cancel'),
504
+ jobSetState: dashboardJobSetState(loaded.store),
491
505
  taskRetry: dashboardTaskAction(loaded.store, reconciler, 'retry'),
492
506
  taskCancel: dashboardTaskAction(loaded.store, reconciler, 'cancel'),
493
507
  taskSetState: dashboardTaskSetState(loaded.store),
@@ -1,5 +1,5 @@
1
1
  import { extendRetryBudget, transitionTaskEvent } from './scheduler.js';
2
- import { applyTaskWorkflowDecision } from './workflow/runtime.js';
2
+ import { applyJobWorkflowDecision, applyTaskWorkflowDecision } from './workflow/runtime.js';
3
3
  export function dashboardTaskAction(store, reconciler, action) {
4
4
  return async (jobId, taskId) => {
5
5
  const job = await store.load(jobId);
@@ -7,7 +7,9 @@ export function dashboardTaskAction(store, reconciler, action) {
7
7
  if (!task)
8
8
  throw new Error(`Task ${taskId} not found`);
9
9
  if (action === 'retry') {
10
- if (!['FAILED', 'BLOCKED'].includes(task.state))
10
+ if (task.state === 'BLOCKED')
11
+ throw new Error(`Task ${task.id} is BLOCKED; use the unblock CLI with a reason`);
12
+ if (task.state !== 'FAILED')
11
13
  throw new Error(`Task ${task.id} is not restartable (${task.state})`);
12
14
  task.maxAttempts = extendRetryBudget(task);
13
15
  await transitionTaskEvent(task, 'retry');
@@ -26,6 +28,24 @@ export function dashboardTaskAction(store, reconciler, action) {
26
28
  return await reconciler.wake(job.id);
27
29
  };
28
30
  }
31
+ export function dashboardJobSetState(store) {
32
+ return async (jobId, state) => {
33
+ const job = await store.load(jobId);
34
+ if (job.state === state)
35
+ return job;
36
+ applyJobWorkflowDecision(job, {
37
+ accepted: true,
38
+ target: state,
39
+ guard: null,
40
+ actions: [],
41
+ mutations: [],
42
+ effects: [],
43
+ reason: 'accepted',
44
+ });
45
+ await store.saveJob(job);
46
+ return await store.load(job.id);
47
+ };
48
+ }
29
49
  export function dashboardTaskSetState(store) {
30
50
  return async (jobId, taskId, state) => {
31
51
  const job = await store.load(jobId);
@@ -34,6 +54,8 @@ export function dashboardTaskSetState(store) {
34
54
  throw new Error(`Task ${taskId} not found`);
35
55
  if (task.state === state)
36
56
  return job;
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`);
37
59
  const now = new Date().toISOString();
38
60
  const from = task.state;
39
61
  applyTaskWorkflowDecision(task, {
@@ -324,13 +324,16 @@ function taskTagDisplay(job,task){const display=task.tag&&job?.tag?.values?.[tas
324
324
  function taskNode(task,children,depth){const li=element('li');const nested=children.get(task.id)||[];const line=element('div','task-line');if(nested.length){const key=(selectedJob||'')+':'+task.id;const collapsed=collapsedTasks.has(key);const fold=document.createElement('button');fold.type='button';fold.className='task-fold';fold.setAttribute('aria-expanded',String(!collapsed));fold.setAttribute('aria-label',(collapsed?'Expand ':'Collapse ')+task.id);fold.textContent=collapsed?'▸':'▾';line.append(fold);const ul=element('ul','tree');ul.hidden=collapsed;for(const child of nested)ul.append(taskNode(child,children,depth+1));fold.addEventListener('click',()=>{const next=!ul.hidden;ul.hidden=next;fold.setAttribute('aria-expanded',String(!next));fold.setAttribute('aria-label',(next?'Expand ':'Collapse ')+task.id);fold.textContent=next?'▸':'▾';if(next)collapsedTasks.add(key);else collapsedTasks.delete(key);});li.append(line,ul);}else li.append(line);const row=document.createElement('button');row.type='button';row.className='task-row';row.setAttribute('aria-current',String(task.id===selectedTaskId));row.title=task.id;row.addEventListener('click',()=>{const deselect=selectedTaskId===task.id;selectedTaskId=deselect?null:task.id;eventTaskId=deselect?null:task.id;rememberSelection();clearEvents();renderAll();void loadEventHistory();});const id=element('span','task-id',text(task.id));id.style.paddingLeft=Math.min(depth,4)*6+'px';row.append(id,element('span','badge '+value(task.state),text(task.state)));const tag=taskTagDisplay(selected(),task);if(tag){const badge=element('span','task-tag',text(tag));badge.title=(selected()?.tag?.description||'Task tag')+': '+task.tag;row.append(badge);}line.append(row);return li;}
325
325
  function buildTree(tasks){taskTree.replaceChildren();if(!Array.isArray(tasks)||!tasks.length){taskTree.append(element('li','empty',text('No tasks in durable state.')));return;}const work=tasks.filter(task=>task.kind!=='planner');if(!work.length){taskTree.append(element('li','empty',text('No work tasks in durable state.')));return;}const known=new Set(work.map(task=>task.id));const children=new Map();const roots=[];for(const task of work){if(!task.parentTaskId||!known.has(task.parentTaskId))roots.push(task);else{const siblings=children.get(task.parentTaskId)||[];siblings.push(task);children.set(task.parentTaskId,siblings);}}for(const task of roots)taskTree.append(taskNode(task,children,0));}
326
326
  function attemptBudget(task){const attempt=Number(task?.attempt)||0;const max=Number(task?.maxAttempts)||0;if(max>=3&&max%3===0){const effectiveMax=Math.max(max,Math.ceil(attempt/3)*3);return attempt+' / 3×'+(effectiveMax/3);}return attempt+' / '+max;}
327
+ const JOB_STATES=['DRAFT','ACTIVE','COMPLETED','CANCELLED'];
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
+ 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;}
327
330
  const TASK_STATES=['DRAFT','QUEUED','RUNNING','BLOCKED','SUCCEEDED','FAILED','CANCELLED'];
328
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;}}
329
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;}
330
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):'—');}
331
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);}
332
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();}
333
- function renderSelectedJob(){jobDetail.replaceChildren();const job=selected();if(!job){selectedJobTitle.textContent='';jobDetail.append(element('div','empty',text('No durable jobs discovered.')));buildTree([]);renderTaskDetail(null);return;}selectedJobTitle.textContent=value(job.title||job.id);if(job.id!==selectedJob){selectedJob=job.id;selectedTaskId=null;eventTaskId=null;clearEvents();rememberSelection();}const summary=element('div','job-summary');summary.append(field('PR state',job.githubState||'UNKNOWN'),field('Lifecycle state',job.state),field('Runtime status',job.runtimeStatus||'IDLE'),field('Execution mode',job.executionMode||'fixed'),field('Cadence',cadence(job)),field('Review',reviewStatus(job)),commitField('Review head SHA',job,job.review?.headSha||job.headSha||''),prField(job),field('Repository',job.repository||'—'),field('Last activity',relativeTime(job.updatedAt)),field('Task states',compactCounts(job.tasks)));jobDetail.append(summary);const selectedTask=(job.tasks||[]).find(task=>task.id===selectedTaskId)||null;if(selectedTaskId&&!selectedTask){selectedTaskId=null;eventTaskId=null;clearEvents();rememberSelection();}buildTree(job.tasks||[]);renderTaskDetail(selectedTask);renderEventScope();}
336
+ function renderSelectedJob(){jobDetail.replaceChildren();const job=selected();if(!job){selectedJobTitle.textContent='';jobDetail.append(element('div','empty',text('No durable jobs discovered.')));buildTree([]);renderTaskDetail(null);return;}selectedJobTitle.textContent=value(job.title||job.id);if(job.id!==selectedJob){selectedJob=job.id;selectedTaskId=null;eventTaskId=null;clearEvents();rememberSelection();}const summary=element('div','job-summary');summary.append(field('PR state',job.githubState||'UNKNOWN'),jobStateField(job),field('Runtime status',job.runtimeStatus||'IDLE'),field('Execution mode',job.executionMode||'fixed'),field('Cadence',cadence(job)),field('Review',reviewStatus(job)),commitField('Review head SHA',job,job.review?.headSha||job.headSha||''),prField(job),field('Repository',job.repository||'—'),field('Last activity',relativeTime(job.updatedAt)),field('Task states',compactCounts(job.tasks)));jobDetail.append(summary);const selectedTask=(job.tasks||[]).find(task=>task.id===selectedTaskId)||null;if(selectedTaskId&&!selectedTask){selectedTaskId=null;eventTaskId=null;clearEvents();rememberSelection();}buildTree(job.tasks||[]);renderTaskDetail(selectedTask);renderEventScope();}
334
337
  function renderAll(){jobs.sort(compareJobs);syncFilters();renderJobList();renderSelectedJob();syncMobileJobsView();}
335
338
  function isCompleteUnmerged(job){return job.runtimeStatus==='MERGE_PENDING';}
336
339
  function isTerminalJob(job){return ['COMPLETED','CANCELLED'].includes(job.state)||['MERGED','CLOSED'].includes(job.githubState);}
package/dist/dashboard.js CHANGED
@@ -269,6 +269,31 @@ export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobI
269
269
  }
270
270
  return;
271
271
  }
272
+ if (new URL(req.url ?? '/', 'http://127.0.0.1').pathname === '/api/job-state') {
273
+ const url = new URL(req.url ?? '/', 'http://127.0.0.1');
274
+ const requestedJobId = url.searchParams.get('id') ?? '';
275
+ const requestedState = url.searchParams.get('state') ?? '';
276
+ const validState = ['DRAFT', 'ACTIVE', 'COMPLETED', 'CANCELLED'].includes(requestedState);
277
+ if (req.method !== 'POST' || !/^[A-Za-z0-9_-]+$/.test(requestedJobId) || !validState || !resolvedActions?.jobSetState) {
278
+ res.writeHead(404);
279
+ res.end('Job state override unavailable');
280
+ return;
281
+ }
282
+ try {
283
+ const result = await resolvedActions.jobSetState(requestedJobId, requestedState);
284
+ if (!res.headersSent)
285
+ res.writeHead(200, { 'content-type': 'application/json' });
286
+ if (!res.writableEnded)
287
+ res.end(JSON.stringify(result));
288
+ }
289
+ catch (error) {
290
+ if (!res.headersSent)
291
+ res.writeHead(409);
292
+ if (!res.writableEnded)
293
+ res.end(error instanceof Error ? error.message : String(error));
294
+ }
295
+ return;
296
+ }
272
297
  if (new URL(req.url ?? '/', 'http://127.0.0.1').pathname === '/api/task-state') {
273
298
  const url = new URL(req.url ?? '/', 'http://127.0.0.1');
274
299
  const requestedJobId = url.searchParams.get('id') ?? '';
package/dist/planner.js CHANGED
@@ -281,6 +281,7 @@ export function plannerInput(context) {
281
281
  retry_feedback: context.retryFeedback,
282
282
  caller_prompt: context.guidance,
283
283
  previous_planner_task_id: context.previousPlannerTaskId,
284
+ change_cycle: context.job.changeCycle,
284
285
  project: project ? { name: project.name, path: project.path } : undefined,
285
286
  visible_agents: visibleAgents ?? [],
286
287
  tasks: context.tasks.map(task => ({
@@ -296,6 +297,8 @@ export function plannerInput(context) {
296
297
  next_task_ids: task.plannerResult.next_tasks.map(item => item.id),
297
298
  } : undefined,
298
299
  error: boundedText(task.error, 2000),
300
+ blocked_baseline: task.blockedBaseline,
301
+ unblock: task.unblock,
299
302
  })),
300
303
  });
301
304
  }
@@ -353,6 +353,16 @@ export class ReconcilerRuntimeBase {
353
353
  }
354
354
  else if (event.type === 'task.blocked') {
355
355
  task.error = event.message || `Task ${task.id} blocked`;
356
+ const data = event.data ?? {};
357
+ const head = ['candidate_head', 'candidate_head_sha', 'pr_head', 'head_sha']
358
+ .map(key => data[key])
359
+ .find(value => typeof value === 'string' && value.length > 0);
360
+ task.blockedBaseline = {
361
+ blockedAt: event.timestamp || task.updatedAt,
362
+ headSha: typeof head === 'string' ? head : job.headSha,
363
+ reason: task.error,
364
+ };
365
+ task.unblock = undefined;
356
366
  }
357
367
  else if (event.type === 'task.cancelled') {
358
368
  task.error = event.message || `Task ${task.id} cancelled`;
@@ -17,6 +17,7 @@ import { pendingTroubleshooterTasks } from './troubleshooter.js';
17
17
  import { prepareManagedExecutionWorktree } from './workspace.js';
18
18
  import { createReviewChairExecutionPlan } from './review-chair.js';
19
19
  import { samePlannerChildDefinition } from './planner-child.js';
20
+ import { consumeChangeCycle, isEffectivelyBlocked } from './unblock.js';
20
21
  import { taskTagWorkerPrompt } from './task-tag.js';
21
22
  const PRIORITY_RANK = { P0: 0, P1: 1, P2: 2, P3: 3 };
22
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); }
@@ -271,13 +272,11 @@ export class Reconciler extends ReconcilerReviewBase {
271
272
  const slot = job.schedule?.currentRunSlot ? Date.parse(job.schedule.currentRunSlot) : Number.NaN;
272
273
  const scopedTasks = Number.isFinite(slot) ? job.tasks.filter(task => Date.parse(task.createdAt) >= slot) : job.tasks;
273
274
  const planners = scopedTasks.filter(task => task.kind === 'planner').sort((a, b) => a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id));
274
- if (planners.some(task => ['DRAFT', 'QUEUED', 'RUNNING'].includes(task.state)))
275
+ if (planners.some(task => ['DRAFT', 'QUEUED', 'RUNNING', 'FAILED'].includes(task.state)))
275
276
  return job;
276
- if (scopedTasks.some(task => task.state === 'FAILED' || task.state === 'BLOCKED'))
277
+ if (scopedTasks.some(isEffectivelyBlocked))
277
278
  return job;
278
- if (planners.some(task => task.state === 'FAILED' || task.state === 'BLOCKED'))
279
- return job;
280
- if (scopedTasks.some(task => task.kind !== 'planner' && !['SUCCEEDED', 'CANCELLED'].includes(task.state)))
279
+ if (scopedTasks.some(task => task.kind !== 'planner' && ['DRAFT', 'QUEUED', 'RUNNING'].includes(task.state)))
281
280
  return job;
282
281
  if (planners.length > 0 && planners.at(-1)?.plannerResult?.objective_status === 'in_progress' && !job.tasks.some(task => task.planningDecisionId === planners.at(-1)?.id))
283
282
  return job;
@@ -286,6 +285,8 @@ export class Reconciler extends ReconcilerReviewBase {
286
285
  return job;
287
286
  const task = createPlannerRoundTask(job, { timeoutMs: this.options.plannerTimeoutMs ?? storySizeTimeoutMs('M') });
288
287
  await this.store.appendTask(task);
288
+ if (consumeChangeCycle(job, task.id))
289
+ await this.store.saveJob(job);
289
290
  await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'planner.created', 'queued', `Planner task ${task.id} created`, 'orchestrator'));
290
291
  return this.store.load(job.id);
291
292
  }
package/dist/relayd.js CHANGED
@@ -12,7 +12,7 @@ import { Reconciler } from './reconciler.js';
12
12
  import { dashboardManagedJobs, GitHubStore, InMemoryStore, loadManagedGitHubPullRequestSnapshotGraphql, withGitHubRequestContext } from './store.js';
13
13
  import { githubAuthContext } from './github-auth.js';
14
14
  import { dashboardJobAction, isEntrypoint, runtimePlanner, runtimeReviewer, startService, SERVICE_DEFAULTS } from './cli.js';
15
- import { dashboardTaskAction, dashboardTaskSetState } from './dashboard-actions.js';
15
+ import { dashboardJobSetState, dashboardTaskAction, dashboardTaskSetState } from './dashboard-actions.js';
16
16
  import { codexAndZaiUsageRegistry } from './usage.js';
17
17
  import { defaultWorkspaceRoot, discoverWorkspaceRepositories, resolveManagedProjectPath, resolveRepositoryProjectPath } from './workspace.js';
18
18
  import { deriveJobRuntimeStatus } from './runtime-status.js';
@@ -200,6 +200,15 @@ export async function runDaemon(argv) {
200
200
  }
201
201
  return await dashboardJobAction(store, makeReconciler(store, concurrency), action)(jobId);
202
202
  };
203
+ const workspaceJobSetState = async (jobId, state) => {
204
+ const overview = cachedOverviews.find(item => item.job.id === jobId);
205
+ if (!overview)
206
+ throw new Error(`Managed job ${jobId} not found`);
207
+ const store = new GitHubStore(client, overview.job.repository, overview.job.prNumber, trusted);
208
+ const result = await dashboardJobSetState(store)(jobId, state);
209
+ await refreshPullRequest(overview.job.repository, overview.job.prNumber, 'dashboard.job-state', { force: true });
210
+ return result;
211
+ };
203
212
  const workspaceTaskAction = (action) => async (jobId, taskId) => {
204
213
  const overview = cachedOverviews.find(item => item.job.id === jobId);
205
214
  if (!overview)
@@ -255,6 +264,7 @@ export async function runDaemon(argv) {
255
264
  start: workspaceJobAction('start'),
256
265
  reconcile: workspaceJobAction('reconcile'),
257
266
  cancel: workspaceJobAction('cancel'),
267
+ jobSetState: workspaceJobSetState,
258
268
  taskRetry: workspaceTaskAction('retry'),
259
269
  taskCancel: workspaceTaskAction('cancel'),
260
270
  taskSetState: workspaceTaskSetState,
@@ -2,7 +2,10 @@ function isSuperseded(task) {
2
2
  return task.state === 'CANCELLED' && typeof task.error === 'string' && task.error.startsWith('Superseded ');
3
3
  }
4
4
  export function implementationTasks(job) {
5
- return job.tasks.filter(task => task.agentName !== 'reviewer' && task.agentName !== 'troubleshooter' && !isSuperseded(task));
5
+ return job.tasks.filter(task => task.agentName !== 'reviewer'
6
+ && task.agentName !== 'troubleshooter'
7
+ && !isSuperseded(task)
8
+ && !(task.state === 'BLOCKED' && task.unblock));
6
9
  }
7
10
  export function implementationComplete(job) {
8
11
  const tasks = implementationTasks(job);
@@ -20,7 +23,7 @@ export function deriveJobRuntimeStatus(job, prState = 'OPEN') {
20
23
  return 'RUNNING';
21
24
  if (job.tasks.some(task => task.state === 'QUEUED'))
22
25
  return 'QUEUED';
23
- if (job.tasks.some(task => task.state === 'BLOCKED'))
26
+ if (job.tasks.some(task => task.state === 'BLOCKED' && !task.unblock))
24
27
  return 'BLOCKED';
25
28
  if (job.tasks.some(task => task.state === 'FAILED'))
26
29
  return 'FAILED';
@@ -40,7 +43,7 @@ export function deriveJobRuntimeStatus(job, prState = 'OPEN') {
40
43
  return 'RUNNING';
41
44
  if (job.tasks.some(task => task.state === 'QUEUED'))
42
45
  return 'QUEUED';
43
- if (job.tasks.some(task => task.state === 'BLOCKED'))
46
+ if (job.tasks.some(task => task.state === 'BLOCKED' && !task.unblock))
44
47
  return 'BLOCKED';
45
48
  if (job.tasks.some(task => task.state === 'FAILED'))
46
49
  return 'FAILED';
@@ -48,6 +48,8 @@ export function pendingTroubleshooterTasks(job) {
48
48
  for (const task of job.tasks) {
49
49
  if (!['FAILED', 'BLOCKED'].includes(task.state) || task.kind === 'planner' || task.agentName === 'troubleshooter' || !belongsToCurrentRun(job, task))
50
50
  continue;
51
+ if (task.state === 'BLOCKED' && task.unblock)
52
+ continue;
51
53
  const id = troubleshooterTaskId(task);
52
54
  if (existing.has(id) || hasRecoveryBarrier(job, task))
53
55
  continue;
@@ -0,0 +1,74 @@
1
+ import { extendRetryBudget, transitionTaskEvent } from './scheduler.js';
2
+ export function isEffectivelyBlocked(task) { return task.kind !== 'planner' && task.state === 'BLOCKED' && !task.unblock; }
3
+ function baselineFor(task) {
4
+ return task.blockedBaseline ?? { blockedAt: task.updatedAt, reason: task.error ?? undefined };
5
+ }
6
+ function cycleFacts(job, tasks, currentHeadSha, reason) {
7
+ const facts = [`unblock: ${reason}`];
8
+ const baselines = tasks.map(task => baselineFor(task));
9
+ const heads = [...new Set(baselines.map(item => item.headSha).filter((item) => Boolean(item)))];
10
+ if (currentHeadSha && heads.some(head => head !== currentHeadSha))
11
+ facts.push(`PR head advanced from ${heads.join(', ')} to ${currentHeadSha}`);
12
+ const times = baselines.map(item => Date.parse(item.blockedAt)).filter(Number.isFinite).sort((a, b) => a - b);
13
+ const earliest = times[0];
14
+ if (earliest !== undefined) {
15
+ for (const task of job.tasks) {
16
+ if (tasks.some(item => item.id === task.id))
17
+ continue;
18
+ const changedAt = Date.parse(task.updatedAt);
19
+ if (!Number.isFinite(changedAt) || changedAt < earliest)
20
+ continue;
21
+ const detail = task.result?.summary ?? task.error ?? task.state;
22
+ facts.push(`task ${task.id} -> ${task.state}: ${detail}`);
23
+ }
24
+ }
25
+ return [...new Set(facts)];
26
+ }
27
+ export async function unblockAutonomousTasks(options) {
28
+ const { store, job, trigger } = options;
29
+ const tasks = options.tasks.filter(isEffectivelyBlocked);
30
+ if (tasks.length === 0)
31
+ throw new Error('No effective BLOCKED work task is available to unblock');
32
+ const now = options.now ?? new Date().toISOString();
33
+ const currentHeadSha = await store.headSha?.() ?? job.headSha ?? null;
34
+ for (const task of tasks) {
35
+ 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
+ task.blockedBaseline = baseline;
38
+ task.unblock = record;
39
+ task.updatedAt = now;
40
+ delete task.blockedBy;
41
+ await store.saveTask(task);
42
+ }
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;
49
+ }
50
+ export async function unblockTask(options) {
51
+ if (!isEffectivelyBlocked(options.task))
52
+ 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
+ }
57
+ options.task.maxAttempts = extendRetryBudget(options.task);
58
+ await transitionTaskEvent(options.task, 'retry');
59
+ options.task.error = null;
60
+ options.task.leaseOwner = null;
61
+ options.task.leaseExpiresAt = null;
62
+ options.task.executionId = null;
63
+ options.task.updatedAt = options.now ?? new Date().toISOString();
64
+ delete options.task.blockedBy;
65
+ await options.store.saveTask(options.task);
66
+ return { replanning: false };
67
+ }
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
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "1.0.159",
3
+ "version": "1.0.161",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {
@@ -110,3 +110,14 @@ review-chair -> unified revision plan` is a project pattern, not a hard-coded
110
110
  Relay reviewer list.
111
111
 
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
+
114
+
115
+ ## Dependency vs block and authoritative unblock
116
+
117
+ `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
+
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.
120
+
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.
122
+
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.