agents-relay 2.0.115 → 2.0.117

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.
@@ -388,10 +388,10 @@ function jobChatRef(job){const pr=Number(job?.prNumber||0);return repoName(job?.
388
388
  function chatShortcutUrl(prompt){return 'https://chatgpt.com/?prompt='+encodeURIComponent(prompt);}
389
389
  const CHAT_SHORTCUT_TARGET='agents-relay-shortcut';
390
390
  function chatShortcutField(job){const box=element('div','detail-group');box.append(element('p','detail-label',text('ChatGPT shortcut')));const select=document.createElement('select');select.className='job-filter job-chat-shortcut-select';select.setAttribute('aria-label','ChatGPT shortcut');select.append(new Option('',''));for(const item of parseChatShortcuts(chatShortcutText()))select.append(new Option(item.name,item.prompt));select.value='';select.addEventListener('change',()=>{if(!select.value)return;const prompt=select.value.replaceAll('%1',jobChatRef(job));window.open(chatShortcutUrl(prompt),CHAT_SHORTCUT_TARGET,'noopener');select.value='';});box.append(select);return box;}
391
- function renderJobList(){jobList.replaceChildren();const visible=filteredJobs();if(!jobs.length){jobList.append(element('li','empty',text('No durable jobs discovered.')));return;}if(!visible.length){jobList.append(element('li','empty',text('No jobs match the selected repo.')));return;}for(const job of visible){const row=element('div','job-row'+(isCompleteUnmerged(job)?' complete-unmerged':''));row.setAttribute('aria-current',String(jobSelectionKey(job)===selectedJob));row.addEventListener('click',()=>selectJob(job));const content=element('div','job-content');const select=document.createElement('button');select.type='button';select.className='job-button';select.setAttribute('aria-current',String(jobSelectionKey(job)===selectedJob));select.title=job.title||job.id;select.addEventListener('click',event=>{event.stopPropagation();selectJob(job);});select.append(element('span','job-title',text(repoName(job.repository)+'/'+(Number(job.prNumber||0)>0?'PR#'+value(job.prNumber):'Job '+shortManagedId(job.id,job.repository,'j'))+': '+value(job.title||job.id))));const states=element('span','job-status-line');states.append(element('span','badge '+value(job.state),text(value(job.state))),element('span','badge '+value(job.runtimeStatus||'IDLE'),text(value(job.runtimeStatus||'IDLE'))));if(job.publicationFailure)states.append(element('span','badge FAILED',text('PUBLISH FAILED: '+value(job.publicationFailure.stage))));else if(job.publication?.version)states.append(element('span','badge SUCCEEDED',text('v'+job.publication.version)));else if(Number(job.prNumber||0)>0&&job.githubState==='MERGED')states.append(element('span','badge MERGED',text('PR Merged')));else if(job.state==='COMPLETED'&&job.githubState==='CLOSED')states.append(element('span','badge SUCCEEDED',text('PR Archived')));if(job.executionMode==='autonomous')states.append(element('span','badge flag-autonomous',text('AUTONOMOUS')));if(job.state==='ACTIVE'&&job.reviewEnabled!==false)states.append(element('span','badge flag-review',text('REVIEW REQUIRED')));if(isCompleteUnmerged(job))states.append(element('span','badge MERGE_PENDING',text('MERGE PENDING')));const primary=element('div','job-primary');primary.append(states);const lastLine=element('div','job-last-line');lastLine.append(element('span','subtle',text(compactCounts(counts(job.tasks)))),element('span','subtle',text(relativeTime(job.updatedAt))));content.append(select,primary,lastLine);row.append(content);jobList.append(element('li','',row));}}
391
+ function renderJobList(){jobList.replaceChildren();const visible=filteredJobs();if(!jobs.length){jobList.append(element('li','empty',text('No durable jobs discovered.')));return;}if(!visible.length){jobList.append(element('li','empty',text('No jobs match the selected repo.')));return;}for(const job of visible){const row=element('div','job-row'+(isCompleteUnmerged(job)?' complete-unmerged':''));row.setAttribute('aria-current',String(jobSelectionKey(job)===selectedJob));row.addEventListener('click',()=>selectJob(job));const content=element('div','job-content');const select=document.createElement('button');select.type='button';select.className='job-button';select.setAttribute('aria-current',String(jobSelectionKey(job)===selectedJob));select.title=job.title||job.id;select.addEventListener('click',event=>{event.stopPropagation();selectJob(job);});select.append(element('span','job-title',text(repoName(job.repository)+'/'+value(job.id)+(Number(job.prNumber||0)>0?'/PR#'+value(job.prNumber):''))));const states=element('span','job-status-line');states.append(element('span','badge '+value(job.state),text(value(job.state))),element('span','badge '+value(job.runtimeStatus||'IDLE'),text(value(job.runtimeStatus||'IDLE'))));if(job.publicationFailure)states.append(element('span','badge FAILED',text('PUBLISH FAILED: '+value(job.publicationFailure.stage))));else if(job.publication?.version)states.append(element('span','badge SUCCEEDED',text('v'+job.publication.version)));else if(Number(job.prNumber||0)>0&&job.githubState==='MERGED')states.append(element('span','badge MERGED',text('PR Merged')));else if(job.state==='COMPLETED'&&job.githubState==='CLOSED')states.append(element('span','badge SUCCEEDED',text('PR Archived')));if(job.executionMode==='autonomous')states.append(element('span','badge flag-autonomous',text('AUTONOMOUS')));if(job.state==='ACTIVE'&&job.reviewEnabled!==false)states.append(element('span','badge flag-review',text('REVIEW REQUIRED')));if(isCompleteUnmerged(job))states.append(element('span','badge MERGE_PENDING',text('MERGE PENDING')));const primary=element('div','job-primary');primary.append(states);const lastLine=element('div','job-last-line');lastLine.append(element('span','subtle',text(compactCounts(counts(job.tasks)))),element('span','subtle',text(relativeTime(job.updatedAt))));content.append(select,primary,lastLine);row.append(content);jobList.append(element('li','',row));}}
392
392
  function taskTagDisplay(job,task){const display=task.tag&&job?.tag?.values?.[task.tag];return typeof display==='string'&&display?display:null;}
393
393
  function taskSubtreeComplete(task,children){return ['SUCCEEDED','CANCELLED'].includes(task.state)&&(children.get(task.id)||[]).every(child=>taskSubtreeComplete(child,children));}
394
- 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)||(!expandedTasks.has(key)&&taskSubtreeComplete(task,children));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);expandedTasks.delete(key);}else{collapsedTasks.delete(key);expandedTasks.add(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 label=element('span','task-id',text(task.title||task.input?.split(/\\r?\\n/).find(line=>line.trim())?.trim()||shortManagedId(task.id,selected()?.repository,'t')));label.style.paddingLeft=Math.min(depth,4)*6+'px';row.append(label,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;}
394
+ 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)||(!expandedTasks.has(key)&&taskSubtreeComplete(task,children));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);expandedTasks.delete(key);}else{collapsedTasks.delete(key);expandedTasks.add(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 label=element('span','task-id',text(value(task.id)+': '+value(task.title||task.input?.split(/\\r?\\n/).find(line=>line.trim())?.trim()||shortManagedId(task.id,selected()?.repository,'t'))));label.style.paddingLeft=Math.min(depth,4)*6+'px';row.append(label,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;}
395
395
  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));}
396
396
  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;}
397
397
  const JOB_STATES=['DRAFT','ACTIVE','COMPLETED','CANCELLED'];
@@ -176,6 +176,17 @@ export class ReconcilerReviewBase extends ReconcilerRuntimeBase {
176
176
  async ensureFinalReview(job, headSha) {
177
177
  if (headSha && !(await this.projectedReviewHeadReady(job, headSha)))
178
178
  return;
179
+ // Restore the review gate only after an authoritative unblock of the original reviewer.
180
+ const priorReviewTask = job.tasks.find(task => task.id === job.review?.taskId);
181
+ if (job.review?.outcome === 'blocked' && job.review.headSha === headSha
182
+ && priorReviewTask?.agentName === 'reviewer' && priorReviewTask.unblock?.event === 'task.unblocked'
183
+ && ['QUEUED', 'RUNNING', 'FAILED'].includes(priorReviewTask.state)) {
184
+ const recovered = await dispatchReviewLifecycle('blocked', 'review.retry', { reviewUnblocked: true });
185
+ if (recovered.accepted && recovered.target === 'PENDING') {
186
+ job.review = { ...job.review, outcome: 'pending', updatedAt: new Date().toISOString() };
187
+ await this.store.saveJob(job);
188
+ }
189
+ }
179
190
  let reviewTaskId = null;
180
191
  const decision = await dispatchReviewLifecycle(job.review?.outcome, 'reconcile', this.reviewFacts(job, headSha), async (actionId) => {
181
192
  if (actionId !== 'review.create')
@@ -30,6 +30,7 @@ export const WORKFLOW_EVENT_NAMES = [
30
30
  'review.approved',
31
31
  'review.changes_required',
32
32
  'review.blocked',
33
+ 'review.retry',
33
34
  'task.blocked',
34
35
  'task.cancelled',
35
36
  'task.completed',
@@ -49,6 +50,7 @@ export const WORKFLOW_FACT_NAMES = [
49
50
  'autoMerge',
50
51
  'recoverableExecution',
51
52
  'retryAvailable',
53
+ 'reviewUnblocked',
52
54
  'initialPlan',
53
55
  'scheduledRun',
54
56
  'hasUnblock',
@@ -17,9 +17,10 @@ export const reviewLifecycleV2 = new GraphRuntime(source, {
17
17
  currentReviewCancelled: boolGuard('currentReviewCancelled'),
18
18
  noApprovedCurrentReview: boolGuard('noApprovedCurrentReview'),
19
19
  autoMerge: boolGuard('autoMerge'),
20
+ reviewUnblocked: boolGuard('reviewUnblocked'),
20
21
  },
21
22
  actions: Object.fromEntries(['review.create', 'review.cancelStale', 'review.createFix', 'review.requestMerge'].map(id => [id, { id }])),
22
- events: Object.fromEntries(['reconcile', 'review.started', 'review.approved', 'review.changes_required', 'review.blocked', 'head.changed'].map(id => [id, { id }])),
23
+ events: Object.fromEntries(['reconcile', 'review.started', 'review.approved', 'review.changes_required', 'review.blocked', 'review.retry', 'head.changed'].map(id => [id, { id }])),
23
24
  });
24
25
  export function reviewGraphState(outcome) {
25
26
  if (!outcome)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "2.0.115",
3
+ "version": "2.0.117",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "repository": {
6
6
  "type": "git",
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: agents-relay
3
- description: Create and operate durable asynchronous agent jobs through GitHub PR state, agent workers, retries, and authoritative lifecycle events.
3
+ description: Use when work needs durable, multi-agent orchestration across turns or processes: plan and execute Jobs/Tasks, coordinate dependencies and reviews, recover failures, and track authoritative lifecycle events. Also use for content-generation workflows (research, story, production, human review, publication) that need explicit agent roles and handoff boundaries. Not for simple one-shot tasks with no durable coordination.
4
4
  ---
5
5
 
6
6
  # Agents Relay
@@ -13,6 +13,10 @@ For unified agent terminal events and role-specific results, see `contracts/even
13
13
 
14
14
  Before creating, planning, reviewing, or managing a job/task, read and follow `contracts/task-job-contract.md`. This is the canonical producer/runtime protocol for story size, lifecycle events, review, and auto-merge.
15
15
 
16
+ ## Content-generation workflow
17
+
18
+ For content-generation objectives, read `workflows/content-generation.md` before choosing agents, review boundaries, or durable Tasks. This workflow is a planning guide; the shared task/job contract remains authoritative.
19
+
16
20
  ## Core task contract
17
21
 
18
22
  A managed child task is always a descriptive unit of work. It says what an agent must accomplish; it is never a shell command.
@@ -32,3 +32,5 @@ See [task-recovery-flows.md](task-recovery-flows.md) for unified architecture, o
32
32
  Reviewer unavailable evidence is an execution failure (`task.failed`), not a blocked Task. Legacy Reviewer verdict `blocked` is normalized to failure and must dispatch Troubleshooter. Only Relay blocks a Troubleshooter during human escalation.
33
33
 
34
34
  A host-mediated interactive invocation is a separate Relay-controlled wait. It requires an already-bound `interactiveInvocation` with status `awaiting_decision`. It is not permission for a Worker or Reviewer to choose a blocked terminal outcome.
35
+
36
+ A legacy Job-level `review: blocked` gate is reset to `pending` only after authoritative `task.unblocked` metadata exists on its bound Reviewer Task and the immutable review head still matches. The same Reviewer Task then resumes without creating a duplicate.
@@ -37,6 +37,7 @@ cancelStarted["action: review.cancelStale"]
37
37
  cancelApproved["action: review.cancelStale"]
38
38
  cancelChanges["action: review.cancelStale"]
39
39
  cancelBlocked["action: review.cancelStale"]
40
+ reviewUnblocked["guard: reviewUnblocked"]
40
41
  fixPending["action: review.createFix"]
41
42
  fixStarted["action: review.createFix"]
42
43
  fixChanges["action: review.createFix"]
@@ -117,5 +118,8 @@ approved -->|on: head.changed| cancelApproved
117
118
  cancelApproved --> stale
118
119
  changes -->|on: head.changed| cancelChanges
119
120
  cancelChanges --> stale
121
+ blocked -->|on: review.retry| reviewUnblocked
122
+ reviewUnblocked -->|true| pending
123
+ reviewUnblocked -->|false| blocked
120
124
  blocked -->|on: head.changed| cancelBlocked
121
125
  cancelBlocked --> stale