agents-relay 2.0.68 → 2.0.70

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.
Files changed (56) hide show
  1. package/README.md +32 -1
  2. package/dist/adapters.js +39 -3
  3. package/dist/blockers.js +17 -4
  4. package/dist/cli-blocker.js +12 -28
  5. package/dist/cli-help.js +11 -8
  6. package/dist/cli-job-create.js +19 -13
  7. package/dist/cli-local-merge-config.js +4 -2
  8. package/dist/cli-projection.js +23 -55
  9. package/dist/cli-review.js +7 -24
  10. package/dist/cli-support.js +59 -4
  11. package/dist/cli-worker-task.js +63 -0
  12. package/dist/cli.js +179 -129
  13. package/dist/dashboard-html.js +15 -12
  14. package/dist/dashboard.js +26 -1
  15. package/dist/e2e-injection.js +76 -0
  16. package/dist/github-binding-order.js +15 -0
  17. package/dist/github-client.js +7 -2
  18. package/dist/github-pr-archive.js +22 -0
  19. package/dist/github-webhook.js +31 -6
  20. package/dist/job-actions.js +29 -0
  21. package/dist/local-authority-store.js +10 -1
  22. package/dist/local-commit-events.js +2 -0
  23. package/dist/local-git-merge.js +17 -6
  24. package/dist/local-job-path.js +5 -0
  25. package/dist/local-job-registry.js +10 -4
  26. package/dist/local-managed-store.js +2 -4
  27. package/dist/local-merge-delivery.js +3 -2
  28. package/dist/local-merge-projection.js +60 -0
  29. package/dist/managed-worker-input.js +13 -6
  30. package/dist/manual-plan.js +29 -7
  31. package/dist/planner-runtime.js +182 -41
  32. package/dist/planner.js +35 -2
  33. package/dist/pool.js +22 -6
  34. package/dist/reconciler-cancellation.js +21 -0
  35. package/dist/reconciler-review.js +74 -18
  36. package/dist/reconciler-runtime.js +69 -34
  37. package/dist/reconciler.js +92 -26
  38. package/dist/relay-store.js +1 -1
  39. package/dist/relayd-control.js +184 -0
  40. package/dist/relayd-dashboard-actions.js +108 -0
  41. package/dist/relayd-utilities.js +107 -0
  42. package/dist/relayd.js +220 -210
  43. package/dist/review-generation-fence.js +12 -0
  44. package/dist/runtime-status-events.js +21 -0
  45. package/dist/runtime-status.js +13 -0
  46. package/dist/service-publication.js +2 -4
  47. package/dist/task-actions.js +59 -1
  48. package/dist/troubleshooter.js +21 -2
  49. package/dist/unblock.js +47 -15
  50. package/dist/worker-rejection.js +11 -0
  51. package/dist/workflow-effect-failures.js +30 -0
  52. package/dist/workspace.js +95 -6
  53. package/package.json +5 -4
  54. package/skills/agents-relay/agents/planner.agent.md +3 -0
  55. package/skills/agents-relay/contracts/orchestrator-workflows.md +4 -7
  56. package/workflow/review-lifecycle.mmd +3 -0
@@ -1,6 +1,5 @@
1
1
  import { createRequire } from 'node:module';
2
- const packageVersion = createRequire(import.meta.url)('../package.json').version;
3
- export const dashboardVersion = packageVersion;
2
+ export const dashboardVersion = createRequire(import.meta.url)('../package.json').version;
4
3
  export const dashboardHtml = `<!doctype html>
5
4
  <html lang="en" data-dashboard-contract="safe-dom,named-relay-events,snapshot-events,bounded-event-history">
6
5
  <head>
@@ -87,7 +86,11 @@ export const dashboardHtml = `<!doctype html>
87
86
  .agent-arrow { color:var(--muted); font-weight:800; }
88
87
  .agent-graph-source { margin:.1rem 0 0; padding:.45rem; border-radius:.35rem; background:var(--surface); color:var(--muted); font:.7rem/1.45 ui-monospace,SFMono-Regular,Menlo,monospace; white-space:pre; overflow:auto; }
89
88
  .event-list { display:grid; gap:.45rem; }
90
- .event-item { padding-bottom:.4rem; border-bottom:1px solid var(--border); }
89
+ .event-item { padding:.34rem .42rem .42rem; border-left:3px solid transparent; border-bottom:1px solid var(--border); border-radius:.28rem; }
90
+ .event-item.event-error { border-left-color:var(--bad); background:#fff4f4; }
91
+ .event-item.event-error .event-kind,.event-item.event-error .event-message { color:var(--bad); }
92
+ .event-item.event-warn { border-left-color:var(--warn); background:#fff9eb; }
93
+ .event-item.event-warn .event-kind { color:var(--warn); }
91
94
  .event-meta { display:flex; flex-wrap:wrap; gap:.4rem; color:var(--muted); font-size:.68rem; }
92
95
  .event-kind { color:var(--accent); font-weight:800; }
93
96
  .event-message { margin:.12rem 0 0; overflow-wrap:anywhere; }
@@ -176,7 +179,6 @@ export const dashboardHtml = `<!doctype html>
176
179
  .tagline { display:none; }
177
180
  }
178
181
  @media (prefers-reduced-motion:reduce) { .skeleton { animation:none; } .usage-fill { transition:none; } }
179
-
180
182
  .router-rules-workspace{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr);gap:16px;align-items:start;min-width:0}.router-rules-pane{min-width:0;max-width:100%;overflow:hidden}.rules-editor .rules-textarea{min-height:420px}.audit-log{display:flex;flex-direction:column;gap:8px;max-height:500px;overflow:auto;min-width:0}.audit-entry{border:1px solid var(--border);border-radius:10px;padding:10px;background:var(--surface)}.audit-entry-head{display:flex;justify-content:space-between;gap:8px;align-items:center}.audit-entry pre{white-space:pre-wrap;word-break:break-word;margin:8px 0 0;font-size:11px}.router-test-panel{margin-top:16px;border-top:1px solid var(--border);padding-top:12px}.router-test-results{margin-top:8px;display:flex;flex-direction:column;gap:6px;max-height:480px;overflow:auto;min-width:0}.router-test-results pre{white-space:pre;overflow:auto;max-width:100%;font-size:11px;line-height:1.45;background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:12px}.router-test-row{display:grid;grid-template-columns:minmax(0,1fr) auto auto;gap:8px;padding:7px 0;border-bottom:1px solid var(--border)}@media(max-width:900px){.router-rules-workspace{grid-template-columns:1fr}.router-rules-pane{max-height:none}.router-test-results{max-height:420px}}@media(max-width:650px){#rules-view .panel-head .subtle{display:none}#rules-view .panel-body{max-height:none;overflow:visible;padding:.6rem}#rules-view .router-rules-workspace{gap:.9rem}#rules-view .rules-help{font-size:.74rem;line-height:1.4}#rules-view .rules-textarea{min-height:52vh;padding:.65rem;font-size:12px}#rules-view .rules-footer{align-items:stretch}#rules-view .rules-footer>.subtle{width:100%}#rules-view .rules-footer>div{width:100%;display:grid;grid-template-columns:1fr 1fr;gap:.5rem}#rules-view .rules-save{min-height:44px;width:100%;padding:.55rem .7rem}#rules-view .router-rules-pane>.panel-head{padding:.55rem 0}#rules-view .audit-log{max-height:none}#rules-view .audit-entry-head{align-items:flex-start}#rules-view .audit-entry-head .subtle{display:block;flex:0 0 auto}#rules-view .router-test-panel{min-width:0;max-width:100%;overflow:hidden}#rules-view .router-test-results{max-height:none;width:100%;max-width:100%;overflow-x:hidden}#rules-view .router-test-results>*{min-width:0;max-width:100%}#rules-view .router-test-results pre{width:100%;box-sizing:border-box;white-space:pre-wrap;overflow-wrap:anywhere;word-break:break-word;font-size:11px;padding:.65rem}#rules-view .router-test-row{grid-template-columns:1fr}}
181
183
  </style>
182
184
  </head>
@@ -263,7 +265,6 @@ export const dashboardHtml = `<!doctype html>
263
265
  // link.textContent = 'PR #' + job.prNumber;
264
266
  // while (eventFeed.children.length > MAX_EVENTS)
265
267
  // function usageBar(label, window)
266
-
267
268
  const MAX_EVENTS=60;
268
269
  const DASHBOARD_VERSION=${JSON.stringify(dashboardVersion)};
269
270
  const jobsTab=document.querySelector('#jobs-tab');
@@ -325,7 +326,6 @@ let eventTaskId=selectedTaskId;
325
326
  const collapsedTasks=new Set();
326
327
  const expandedTasks=new Set();
327
328
  const displayedEventIds=new Set();
328
-
329
329
  function value(input){return input===null||input===undefined||input===''?'—':String(input);}
330
330
  function text(input){return document.createTextNode(value(input));}
331
331
  function element(name,className,child){const node=document.createElement(name);if(className)node.className=className;if(child)node.append(child);return node;}
@@ -345,7 +345,7 @@ function cadence(job){const schedule=job?.schedule;if(!schedule?.cron)return '
345
345
  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;}
346
346
  function safeCommitUrl(repository,sha){if(typeof repository!=='string'||typeof sha!=='string'||!/^[0-9a-f]{7,64}$/i.test(sha))return null;const parts=repository.split('/');if(parts.length!==2||!parts[0]||!parts[1])return null;return 'https://github.com/'+parts[0]+'/'+parts[1]+'/commit/'+sha;}
347
347
  function commitField(label,job,sha){const box=element('div','detail-group');box.append(element('p','detail-label',text(label)));const line=element('p','detail-value');const url=safeCommitUrl(job?.repository,sha);if(url){const link=document.createElement('a');link.href=url;link.target='_blank';link.rel='noopener noreferrer';link.textContent=sha.slice(0,7);line.append(link);}else line.append(text(sha||'—'));box.append(line);return box;}
348
- function prField(job){const box=element('div','detail-group');box.append(element('p','detail-label',text('GitHub PR')));const line=element('p','detail-value');const url=safePrUrl(job.repository,job.prNumber);if(url){const link=document.createElement('a');link.href=url;link.target='_blank';link.rel='noopener noreferrer';link.textContent='PR #'+job.prNumber;line.append(link);}else line.append(text('unavailable'));box.append(line);return box;}
348
+ function prField(job){const box=element('div','detail-group');box.append(element('p','detail-label',text('GitHub PR')));const line=element('p','detail-value');const prNumber=Number(job.externalPrNumber||job.prNumber||0);const url=safePrUrl(job.repository,prNumber);if(url){const link=document.createElement('a');link.href=url;link.target='_blank';link.rel='noopener noreferrer';link.textContent='PR #'+prNumber;line.append(link);}else line.append(text('unavailable'));box.append(line);return box;}
349
349
  function chatField(task){const box=element('div','detail-group');box.append(element('p','detail-label',text('Chat')));const line=element('p','detail-value');if(task.adapter==='chatgpt'&&typeof task.threadId==='string'&&task.threadId){const link=document.createElement('a');link.href='https://chatgpt.com/c/'+encodeURIComponent(task.threadId);link.target='_blank';link.rel='noopener noreferrer';link.textContent='Open chat';line.append(link);}else line.append(text('—'));box.append(line);return box;}
350
350
  function taskCommentField(task){const box=element('div','detail-group');box.append(element('p','detail-label',text('PR comment')));const line=element('p','detail-value');if(typeof task.taskUrl==='string'&&task.taskUrl.startsWith('https://github.com/')){const link=document.createElement('a');link.className='task-link';link.href=task.taskUrl;link.target='_blank';link.rel='noopener noreferrer';link.textContent='Open task comment';line.append(link);}else line.append(text('—'));box.append(line);return box;}
351
351
  function parseAgentGraphSource(source){if(typeof source!=='string'||!source.trim())return null;const agents=new Map(),edges=[];for(const raw of source.split(/\\r?\\n/)){const line=raw.trim();if(!line||line.startsWith('%%')||line.startsWith('flowchart '))continue;let match=line.match(/^([A-Za-z_][A-Za-z0-9_.-]*)(?:\\[agent:([A-Za-z_][A-Za-z0-9_.-]*)\\])?\\s*-->\\s*([A-Za-z_][A-Za-z0-9_.-]*)(?:\\[agent:([A-Za-z_][A-Za-z0-9_.-]*)\\])?$/);if(match){agents.set(match[1],match[2]||agents.get(match[1])||match[1]);agents.set(match[3],match[4]||agents.get(match[3])||match[3]);edges.push([match[1],match[3]]);continue;}match=line.match(/^([A-Za-z_][A-Za-z0-9_.-]*)\\[agent:([A-Za-z_][A-Za-z0-9_.-]*)\\]$/);if(match){agents.set(match[1],match[2]);continue;}return null;}const incoming=new Map([...agents.keys()].map(id=>[id,0]));for(const [,to] of edges)incoming.set(to,(incoming.get(to)||0)+1);const remaining=new Map(incoming),waves=[];let ready=[...remaining].filter(([,degree])=>degree===0).map(([id])=>id).sort();while(ready.length){waves.push(ready);const next=[];for(const id of ready)for(const [from,to] of edges)if(from===id){const degree=(remaining.get(to)||0)-1;remaining.set(to,degree);if(degree===0)next.push(to);}ready=[...new Set(next)].sort();}return {agents,waves};}
@@ -388,7 +388,7 @@ function jobChatRef(job){return repoName(job?.repository)+'/pr#'+value(job?.prNu
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)+'/PR#'+value(job.prNumber)+': '+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.executionMode==='autonomous')states.append(element('span','badge flag-autonomous',text('AUTONOMOUS')));if(job.reviewEnabled!==false&&job.githubState!=='MERGED')states.append(element('span','badge flag-review',text('REVIEW REQUIRED')));if(job.publication?.version)states.append(element('span','badge SUCCEEDED',text('v'+job.publication.version)));if(job.publicationFailure)states.append(element('span','badge FAILED',text('PUBLISH FAILED: '+value(job.publicationFailure.stage))));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)+'/'+(Number(job.externalPrNumber||job.prNumber||0)>0?'PR#'+value(job.externalPrNumber||job.prNumber):'Job '+value(job.id))+': '+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.externalPrNumber||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
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 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;}
@@ -424,7 +424,8 @@ async function refreshStatus(){try{renderRateLimitStatus(await loadJSON('/api/st
424
424
  async function refresh(force=false){jobsRefresh.disabled=true;jobsRefresh.textContent='Refreshing…';try{const discovered=await loadJSON('/api/jobs'+(force?'?refresh=1':''));jobs=Array.isArray(discovered)?discovered:[];void drawRuntimeSummary();jobs.sort(compareJobs);if(selectedJob&&!jobForSelection(selectedJob)){selectedJob=null;selectedTaskId=null;}if(!selectedJob){const match=location.hash.match(/^#\\/jobs\\/(.+)$/);const fromHash=match?decodeURIComponent(match[1]):null;const fromRoute=jobForSelection(fromHash);selectedJob=jobSelectionKey(fromRoute||jobs[0]);}rememberSelection();renderAll();void loadEventHistory();connection.textContent='';connection.setAttribute('aria-label','Live durable state connected');connection.className='status-pill ok';}catch(error){connection.textContent='';connection.setAttribute('aria-label','Durable state unavailable');connection.className='status-pill bad';jobList.replaceChildren(element('li','error-state',text('Could not load jobs: '+error.message)));}finally{jobsRefresh.disabled=false;jobsRefresh.textContent='Refresh';}}
425
425
  function renderEventScope(){const task=eventTaskId&&selected()?.tasks?.find(candidate=>candidate.id===eventTaskId);eventsAllTasks.disabled=!task;eventsAllTasks.setAttribute('aria-pressed',String(!task));eventsScope.textContent=task?'Task '+task.id+' · newest first · 60 max':'Job-wide · newest first · 60 max';}
426
426
  function eventLabel(type){return ({'task.process.launched':'Process launched','task.started':'Worker started','task.process.exited':'Sync process exited','task.process.async_exited':'Async process exited','task.completed':'Worker completed','task.failed':'Worker failed','task.blocked':'Worker blocked','review.started':'Review started','review.approved':'Review approved','review.changes_required':'Review changes required','review.blocked':'Review blocked'})[type]||type||'Event';}
427
- function addEvent(event,level){const current=selected();if(!event||current&&event.job_id&&event.job_id!==current.id||eventTaskId&&event.task_id!==eventTaskId)return;if(event.event_id&&displayedEventIds.has(event.event_id))return;if(event.event_id)displayedEventIds.add(event.event_id);const item=element('li','event-item');const meta=element('div','event-meta');meta.append(element('span','event-kind',text(eventLabel(event.type))),element('span','event-type',text(event.type||'event')),element('span','',text(relativeTime(event.timestamp))),element('span','',text('task: '+value(event.task_id))));item.append(meta);const message=element('p','event-message',text(event.message||'Event received'));if(level==='error'||event.level==='error')message.classList.add('error-text');item.append(message);eventFeed.querySelector('.empty')?.remove();eventFeed.prepend(item);while(eventFeed.children.length>MAX_EVENTS)eventFeed.lastElementChild?.remove();}
427
+ function eventSeverity(event,level){const type=String(event?.type||'').toLowerCase(),eventLevel=String(event?.level||level||'').toLowerCase(),outcome=String(event?.outcome||event?.status||'').toLowerCase();if(eventLevel==='error'||outcome==='failed'||type.endsWith('.failed')||type==='task.failed')return 'error';if(eventLevel==='warn'||outcome==='blocked'||type.endsWith('.blocked')||type==='task.blocked'||type==='review.blocked')return 'warn';return 'normal';}
428
+ function addEvent(event,level){const current=selected();if(!event||current&&event.job_id&&event.job_id!==current.id||eventTaskId&&event.task_id!==eventTaskId)return;if(event.event_id&&displayedEventIds.has(event.event_id))return;if(event.event_id)displayedEventIds.add(event.event_id);const severity=eventSeverity(event,level);const item=element('li','event-item'+(severity==='error'?' event-error':severity==='warn'?' event-warn':''));const meta=element('div','event-meta');meta.append(element('span','event-kind',text(eventLabel(event.type))),element('span','event-type',text(event.type||'event')),element('span','',text(relativeTime(event.timestamp))),element('span','',text('task: '+value(event.task_id))));item.append(meta);const message=element('p','event-message',text(event.message||'Event received'));item.append(message);eventFeed.querySelector('.empty')?.remove();eventFeed.prepend(item);while(eventFeed.children.length>MAX_EVENTS)eventFeed.lastElementChild?.remove();}
428
429
  function resetText(windowData){if(!Number.isFinite(windowData.resetsAt))return '';const at=new Date(windowData.resetsAt*1000);const minutes=Math.max(0,Math.ceil((at.getTime()-Date.now())/60000));const relative=minutes<60?minutes+'m':minutes<1440?Math.floor(minutes/60)+'h '+minutes%60+'m':Math.floor(minutes/1440)+'d '+Math.floor((minutes%1440)/60)+'h';return 'Resets '+at.toLocaleString()+' · in '+relative;}
429
430
  function usageBar(label,windowData){const percent=Math.max(0,Math.min(100,100-windowData.usedPercent));const row=element('div','usage-row');const heading=element('div','usage-label');heading.append(element('span','',text(label)),element('span','',text(Math.round(percent)+'%')));const track=element('div','usage-track');track.setAttribute('role','progressbar');track.setAttribute('aria-label',label+' available');track.setAttribute('aria-valuemin','0');track.setAttribute('aria-valuemax','100');track.setAttribute('aria-valuenow',String(percent));const fill=element('div','usage-fill');fill.style.width=percent+'%';track.append(fill);row.append(heading,track);const reset=resetText(windowData);if(reset)row.append(element('div','subtle',text(reset)));return row;}
430
431
  function usageProvider(provider){const card=element('section','usage-provider');card.append(element('h3','',text(provider.label)));const usage=provider.usage||{};if(!usage.available||!usage.fiveHour||!usage.weekly){card.append(element('p','empty',text(usage.reason||'Account usage telemetry is unavailable.')));return card;}const bars=element('div','usage-bars');bars.append(usageBar('5-hour remaining',usage.fiveHour),usageBar('Weekly remaining',usage.weekly));card.append(bars);return card;}
@@ -481,12 +482,14 @@ auditRefresh.addEventListener('click',()=>void drawAudit());
481
482
  shortcutsSave.addEventListener('click',saveShortcuts);
482
483
  window.addEventListener('keydown',event=>{if(event.key==='Tab'&&event.altKey){event.preventDefault();const views=['jobs','usage','graphs','rules','shortcuts'];const current=location.hash==='#/usage'?'usage':location.hash==='#/graphs'?'graphs':location.hash==='#/execution-router-rules'?'rules':location.hash==='#/shortcuts'?'shortcuts':'jobs';const next=views[(views.indexOf(current)+1)%views.length];location.hash=next==='usage'?'#/usage':next==='graphs'?'#/graphs':next==='rules'?'#/execution-router-rules':next==='shortcuts'?'#/shortcuts':(selectedJob?'#/jobs/'+encodeURIComponent(selectedJob):'#/jobs');showView(next);}});
483
484
  function parseMessage(message){try{return JSON.parse(message.data);}catch{return null;}}
484
- function applyStatePatch(patch){if(!patch?.jobId)return false;const job=jobs.find(candidate=>candidate.id===patch.jobId);if(!job)return false;if(patch.jobState)job['state']=patch.jobState;if(patch.jobUpdatedAt)job.updatedAt=patch.jobUpdatedAt;if(patch.taskId&&patch.taskState){const task=(job.tasks||[]).find(candidate=>candidate.id===patch.taskId);if(task){task['state']=patch.taskState;if(patch.taskUpdatedAt)task.updatedAt=patch.taskUpdatedAt;}}return true;}
485
+ function isFreshEnough(incoming,current){const a=Date.parse(incoming||''),b=Date.parse(current||'');return !Number.isFinite(b)||!Number.isFinite(a)||a>=b;}
486
+ function applyStatePatch(patch){if(!patch?.jobId)return false;const job=jobs.find(candidate=>candidate.id===patch.jobId);if(!job)return false;if(patch.jobState&&isFreshEnough(patch.jobUpdatedAt,job.updatedAt)){job['state']=patch.jobState;if(patch.jobUpdatedAt)job.updatedAt=patch.jobUpdatedAt;}if(patch.taskId&&patch.taskState){const task=(job.tasks||[]).find(candidate=>candidate.id===patch.taskId);if(task&&isFreshEnough(patch.taskUpdatedAt,task.updatedAt)){task['state']=patch.taskState;if(patch.taskUpdatedAt)task.updatedAt=patch.taskUpdatedAt;}}return true;}
487
+ function upsertFreshJob(incoming){if(!incoming)return;const index=jobs.findIndex(job=>jobSelectionKey(job)===jobSelectionKey(incoming));if(index<0){jobs.push(incoming);return;}if(isFreshEnough(incoming.updatedAt,jobs[index].updatedAt))jobs[index]=incoming;}
485
488
  const stream=new EventSource('/events');
486
489
  stream.onopen=()=>{connection.textContent='';connection.setAttribute('aria-label','Event stream connected');connection.className='status-pill ok';};
487
490
  stream.onerror=()=>{connection.textContent='';connection.setAttribute('aria-label','Event stream reconnecting');connection.className='status-pill warn';};
488
- stream.onmessage=message=>{const payload=parseMessage(message);if(payload?.job){const index=jobs.findIndex(job=>jobSelectionKey(job)===jobSelectionKey(payload.job));if(index>=0)jobs[index]=payload.job;else jobs.push(payload.job);renderAll();void drawRuntimeSummary();}};
489
- stream.addEventListener('relay',message=>{const payload=parseMessage(message);if(payload?.job){const index=jobs.findIndex(job=>jobSelectionKey(job)===jobSelectionKey(payload.job));if(index>=0)jobs[index]=payload.job;else jobs.push(payload.job);}else if(payload?.statePatch)applyStatePatch(payload.statePatch);if(payload?.event)addEvent(payload.event);renderAll();void drawRuntimeSummary();});
491
+ stream.onmessage=message=>{const payload=parseMessage(message);if(payload?.job){upsertFreshJob(payload.job);renderAll();void drawRuntimeSummary();}};
492
+ stream.addEventListener('relay',message=>{const payload=parseMessage(message);if(payload?.job)upsertFreshJob(payload.job);else if(payload?.statePatch)applyStatePatch(payload.statePatch);if(payload?.event)addEvent(payload.event);renderAll();void drawRuntimeSummary();});
490
493
  if(location.hash==='#/usage')showView('usage');else if(location.hash==='#/graphs')showView('graphs');else if(location.hash==='#/execution-router-rules')showView('rules');else if(location.hash==='#/shortcuts')showView('shortcuts');else showView('jobs');
491
494
  setInterval(()=>{if(location.hash==='#/usage')void drawUsage();},30000);
492
495
  setInterval(()=>void refreshVersion(),30000);
package/dist/dashboard.js CHANGED
@@ -202,7 +202,7 @@ export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobI
202
202
  res.end('Ignored');
203
203
  return;
204
204
  }
205
- if (!event.repository || !webhook.repositories.has(event.repository) || (webhook.pullRequest !== undefined && event.pullRequest !== null && event.pullRequest !== webhook.pullRequest)) {
205
+ if (!event.repository || !webhook.repositories.has(event.repository) || (webhook.jobId !== undefined && event.jobId !== undefined && event.jobId !== null && event.jobId !== webhook.jobId) || (webhook.pullRequest !== undefined && event.pullRequest !== null && event.pullRequest !== webhook.pullRequest)) {
206
206
  res.writeHead(202);
207
207
  res.end('Ignored');
208
208
  return;
@@ -238,6 +238,31 @@ export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobI
238
238
  }
239
239
  return;
240
240
  }
241
+ if (new URL(req.url ?? '/', 'http://127.0.0.1').pathname === '/api/control') {
242
+ if (req.method !== 'POST' || !resolvedActions?.control) {
243
+ res.writeHead(404);
244
+ res.end('Control API unavailable');
245
+ return;
246
+ }
247
+ try {
248
+ const chunks = [];
249
+ for await (const chunk of req)
250
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
251
+ const body = JSON.parse(Buffer.concat(chunks).toString('utf8'));
252
+ const result = await resolvedActions.control(body);
253
+ if (!res.headersSent)
254
+ res.writeHead(200, { 'content-type': 'application/json' });
255
+ if (!res.writableEnded)
256
+ res.end(JSON.stringify(result));
257
+ }
258
+ catch (error) {
259
+ if (!res.headersSent)
260
+ res.writeHead(409);
261
+ if (!res.writableEnded)
262
+ res.end(error instanceof Error ? error.message : String(error));
263
+ }
264
+ return;
265
+ }
241
266
  if (new URL(req.url ?? '/', 'http://127.0.0.1').pathname === '/api/job-action') {
242
267
  const url = new URL(req.url ?? '/', 'http://127.0.0.1');
243
268
  const requestedJobId = url.searchParams.get('id') ?? '';
@@ -0,0 +1,76 @@
1
+ const legacy = {
2
+ quota_exceeded: { method: 'worker.codex.launch', action: 'return', execution: 'skip', mock: 'worker_quota' },
3
+ model_not_found: { method: 'worker.codex.launch', action: 'return', execution: 'skip', mock: 'worker_model_not_found' },
4
+ provider_unavailable: { method: 'worker.codex.launch', action: 'return', execution: 'skip', mock: 'worker_provider_unavailable' },
5
+ github_projection_failure: { method: 'github.projectLocalMerge', action: 'throw', execution: 'skip', error: { code: 'SERVICE_UNAVAILABLE', message: 'E2E transient GitHub projection transport failure', status: 503 } },
6
+ };
7
+ export function parseTestInjection(value) {
8
+ if (typeof value === 'string')
9
+ return legacy[value];
10
+ if (!value || typeof value !== 'object' || Array.isArray(value))
11
+ return undefined;
12
+ const v = value;
13
+ if (v.method !== 'worker.codex.launch' && v.method !== 'github.projectLocalMerge' && v.method !== 'browserWorkspace.command')
14
+ return undefined;
15
+ if (v.action !== 'throw' && v.action !== 'return' && v.action !== 'pass')
16
+ return undefined;
17
+ if (v.execution !== 'skip' && v.execution !== 'after' && v.execution !== 'run')
18
+ return undefined;
19
+ const error = v.error;
20
+ if (v.action === 'throw' && (!error || typeof error !== 'object' || typeof error.message !== 'string' || typeof error.code !== 'string'))
21
+ return undefined;
22
+ const mock = v.mock;
23
+ if (v.action === 'return' && (v.method !== 'worker.codex.launch' || !['worker_quota', 'worker_model_not_found', 'worker_provider_unavailable'].includes(String(mock))))
24
+ return undefined;
25
+ if (v.method === 'worker.codex.launch' && v.action !== 'pass' && (v.action !== 'return' || v.execution !== 'skip'))
26
+ return undefined;
27
+ if (v.action === 'pass' && v.execution !== 'run')
28
+ return undefined;
29
+ if (v.action !== 'pass' && v.execution === 'run')
30
+ return undefined;
31
+ return { method: v.method, action: v.action, execution: v.execution, error: error, mock: mock };
32
+ }
33
+ export function authorizedInjection(repository, raw, env = process.env) {
34
+ return env.E2E_REPO && repository === env.E2E_REPO ? parseTestInjection(raw) : undefined;
35
+ }
36
+ function mockWorker(name) {
37
+ const message = name === 'worker_quota' ? 'rate_limit_exceeded: usage limit reached' : name === 'worker_model_not_found' ? 'model_not_found: model is unavailable' : 'provider_unavailable: provider refused the request';
38
+ const promise = Promise.reject(new Error(message));
39
+ promise.catch(() => { });
40
+ return { id: 'e2e-injected', launched: Promise.resolve(), promise, cancel: () => { }, processExit: { exitCode: 1, signal: null, stderr: message, terminationReason: null } };
41
+ }
42
+ /** Caller must supply a configuration already consumed durably before this invocation. */
43
+ export function withTestInjection(method, config, execute) {
44
+ if (!config || config.method !== method || config.action === 'pass')
45
+ return execute();
46
+ const apply = (original) => {
47
+ if (config.action === 'throw') {
48
+ const error = Object.assign(new Error(config.error.message), { code: config.error.code, status: config.error.status });
49
+ throw error;
50
+ }
51
+ if (method === 'worker.codex.launch' && config.mock)
52
+ return mockWorker(config.mock);
53
+ return original;
54
+ };
55
+ return config.execution === 'after' ? Promise.resolve(execute()).then(apply) : apply();
56
+ }
57
+ /** Shared atomic consumer, scoped solely by E2E_REPO and exact method. */
58
+ export async function consumeMethodInjection(relay, repository, jobId, method) {
59
+ if (!process.env.E2E_REPO || process.env.E2E_REPO !== repository || !relay.mutateJob)
60
+ return undefined;
61
+ let config;
62
+ await relay.mutateJob(jobId, job => {
63
+ if (job.repository !== repository)
64
+ return job;
65
+ const task = job.tasks.find(t => authorizedInjection(job.repository, t.testInjection)?.method === method);
66
+ if (!task)
67
+ return job;
68
+ config = authorizedInjection(job.repository, task.testInjection);
69
+ delete task.testInjection;
70
+ task.updatedAt = new Date(Math.max(Date.now(), Date.parse(task.updatedAt) + 1)).toISOString();
71
+ return job;
72
+ });
73
+ if (config && (await relay.loadJob(jobId))?.tasks.some(t => authorizedInjection(repository, t.testInjection)?.method === method))
74
+ throw Error(`E2E injection consumption not committed: ${jobId}`);
75
+ return config;
76
+ }
@@ -0,0 +1,15 @@
1
+ /** Fresh unbound ACTIVE jobs are highest priority; terminal projection debt is last. */
2
+ export function orderGitHubBindingCandidates(items) {
3
+ return [...items].sort((a, b) => score(a.job) - score(b.job) || a.job.createdAt.localeCompare(b.job.createdAt));
4
+ }
5
+ function score(job) {
6
+ const bound = (job.externalPrNumber ?? (job.prNumber > 0 ? job.prNumber : 0)) > 0;
7
+ const terminal = ['COMPLETED', 'CANCELLED'].includes(job.state);
8
+ if (!terminal && !bound)
9
+ return 0;
10
+ if (!terminal && bound)
11
+ return 1;
12
+ if (terminal && bound)
13
+ return 2;
14
+ return 3;
15
+ }
@@ -241,13 +241,18 @@ export class GhApiClient {
241
241
  let stdout = '';
242
242
  child.stdout?.on('data', (chunk) => { stdout += chunk.toString(); });
243
243
  child.on('error', () => resolve(null));
244
- child.on('close', () => {
244
+ child.on('close', (code) => {
245
+ if (code !== 0) {
246
+ resolve(null);
247
+ return;
248
+ }
245
249
  try {
246
250
  const value = JSON.parse(stdout);
247
251
  const hosts = value.hosts;
248
252
  const github = hosts?.['github.com'];
249
253
  const active = Array.isArray(github) ? github.find(item => item.active === true) : undefined;
250
- resolve(typeof active?.login === 'string' && active.login.length > 0 ? active.login : null);
254
+ const healthy = active && active.state !== 'error' && typeof active.error !== 'string';
255
+ resolve(healthy && typeof active?.login === 'string' && active.login.length > 0 ? active.login : null);
251
256
  }
252
257
  catch {
253
258
  resolve(null);
@@ -0,0 +1,22 @@
1
+ /** A PR is a read-only collaboration record after authoritative local completion. */
2
+ export async function archiveDisplayPullRequest(client, repository, number, jobId, publishedSha, state) {
3
+ if (state !== 'OPEN')
4
+ return 'already_closed';
5
+ const marker = `Agents Relay local completion: ${jobId}`;
6
+ const path = `repos/${repository}/issues/${number}/comments?per_page=100`;
7
+ const comments = await client.request(path);
8
+ if (!Array.isArray(comments))
9
+ throw new Error('Cannot verify PR archive comment idempotency');
10
+ const alreadyCommented = comments.some(item => item && typeof item === 'object'
11
+ && 'body' in item && typeof item.body === 'string' && item.body.includes(marker));
12
+ if (!alreadyCommented) {
13
+ await client.request(`repos/${repository}/issues/${number}/comments`, {
14
+ method: 'POST',
15
+ body: JSON.stringify({ body: `${marker}\nPublished local main commit: ${publishedSha}.\nThis PR is closed as a review record; GitHub did not perform a merge.` }),
16
+ });
17
+ }
18
+ await client.request(`repos/${repository}/pulls/${number}`, {
19
+ method: 'PATCH', body: JSON.stringify({ state: 'closed' }),
20
+ });
21
+ return 'archived';
22
+ }
@@ -14,7 +14,7 @@ export function parsePackagePublishFailureMarker(body) {
14
14
  const data = JSON.parse(body.slice(start + prefix.length, end));
15
15
  if (typeof data.runId !== 'string' || !/^\d+$/.test(data.runId) || !Number.isInteger(data.attempt) || Number(data.attempt) < 1 || typeof data.sha !== 'string' || !/^[a-f0-9]{7,40}$/i.test(data.sha) || typeof data.stage !== 'string' || !data.stage)
16
16
  return null;
17
- return { runId: data.runId, attempt: Number(data.attempt), sha: data.sha, stage: data.stage };
17
+ return { runId: data.runId, attempt: Number(data.attempt), sha: data.sha, stage: data.stage, ...(typeof data.jobId === 'string' && data.jobId ? { jobId: data.jobId } : {}) };
18
18
  }
19
19
  catch {
20
20
  return null;
@@ -45,6 +45,7 @@ export function parsePackagePublicationMarker(body) {
45
45
  version: value.version,
46
46
  registry: typeof value.registry === 'string' ? value.registry : null,
47
47
  sha: typeof value.sha === 'string' ? value.sha : null,
48
+ ...(typeof value.jobId === 'string' && value.jobId ? { jobId: value.jobId } : {}),
48
49
  };
49
50
  }
50
51
  export function verifyGitHubWebhook(secret, body, signature) {
@@ -74,7 +75,7 @@ function pullRequestFacts(payload, action) {
74
75
  export function parseGitHubWebhook(headers, body) {
75
76
  const deliveryId = String(headers['x-github-delivery'] ?? '');
76
77
  const event = String(headers['x-github-event'] ?? '');
77
- if (!deliveryId || (event !== 'pull_request' && event !== 'issue_comment'))
78
+ if (!deliveryId || (event !== 'pull_request' && event !== 'issue_comment' && event !== 'commit_comment'))
78
79
  return null;
79
80
  let payload;
80
81
  try {
@@ -86,26 +87,50 @@ export function parseGitHubWebhook(headers, body) {
86
87
  const repository = payload.repository;
87
88
  const repositoryName = typeof repository?.full_name === 'string' ? repository.full_name : null;
88
89
  const action = typeof payload.action === 'string' ? payload.action : null;
90
+ if (!repositoryName)
91
+ return null;
92
+ if (event === 'commit_comment') {
93
+ const comment = payload.comment;
94
+ const trustedPublisher = comment?.user?.login === 'github-actions[bot]';
95
+ const publication = trustedPublisher ? parsePackagePublicationMarker(comment?.body) : null;
96
+ const publishFailure = trustedPublisher ? parsePackagePublishFailureMarker(comment?.body) : null;
97
+ const jobId = publication?.jobId ?? publishFailure?.jobId;
98
+ if ((!publication && !publishFailure) || !jobId)
99
+ return null;
100
+ const commentTime = comment?.created_at;
101
+ const publicationObservedAt = typeof commentTime === 'string' && Number.isFinite(Date.parse(commentTime))
102
+ ? new Date(commentTime).toISOString() : undefined;
103
+ return { deliveryId, event, action, repository: repositoryName, pullRequest: null, jobId, publication,
104
+ ...(publicationObservedAt ? { publicationObservedAt } : {}),
105
+ ...(publishFailure ? { publishFailure } : {}), pullRequestFacts: null };
106
+ }
89
107
  const pr = payload.pull_request;
90
108
  const issue = payload.issue;
91
- const number = Number(pr?.number ?? (issue?.pull_request ? issue.number : 0));
92
- if (!repositoryName || !Number.isInteger(number) || number <= 0)
93
- return null;
94
109
  if (event === 'issue_comment') {
110
+ const issueNumber = Number(issue?.number ?? 0);
111
+ if (!Number.isInteger(issueNumber) || issueNumber <= 0)
112
+ return null;
95
113
  const comment = payload.comment;
96
114
  const trustedPublisher = comment?.user?.login === 'github-actions[bot]';
97
115
  const publication = trustedPublisher ? parsePackagePublicationMarker(comment?.body) : null;
98
116
  const publishFailure = trustedPublisher
99
117
  ? parsePackagePublishFailureMarker(comment?.body) : null;
118
+ const jobId = publication?.jobId ?? publishFailure?.jobId;
100
119
  if (!publication && !publishFailure)
101
120
  return null;
121
+ if (!issue?.pull_request && !jobId)
122
+ return null;
102
123
  const commentTime = comment?.created_at;
103
124
  const publicationObservedAt = typeof commentTime === 'string' && Number.isFinite(Date.parse(commentTime))
104
125
  ? new Date(commentTime).toISOString() : undefined;
105
- return { deliveryId, event, action, repository: repositoryName, pullRequest: number, publication,
126
+ return { deliveryId, event, action, repository: repositoryName,
127
+ pullRequest: issue?.pull_request ? issueNumber : null, ...(jobId ? { jobId } : {}), publication,
106
128
  ...(publicationObservedAt ? { publicationObservedAt } : {}),
107
129
  ...(publishFailure ? { publishFailure } : {}), pullRequestFacts: null };
108
130
  }
131
+ const number = Number(pr?.number ?? 0);
132
+ if (!Number.isInteger(number) || number <= 0)
133
+ return null;
109
134
  const facts = pullRequestFacts(payload, action);
110
135
  if (!facts)
111
136
  return null;
@@ -1,5 +1,6 @@
1
1
  import { transitionJobEvent } from './scheduler.js';
2
2
  import { tryApplyTaskCancelAction } from './task-actions.js';
3
+ import { decideJobLifecycle, dispatchJobLifecycleSync } from './workflow/lifecycle.js';
3
4
  export const JOB_ACTIVATE_ACTION = 'job.activate';
4
5
  export const JOB_CANCEL_ACTION = 'job.cancel';
5
6
  async function transitionJobAction(job, event, action) {
@@ -34,8 +35,36 @@ async function cancelActiveWork(store, job, reason) {
34
35
  await store.saveTask(task);
35
36
  }
36
37
  }
38
+ function cancelActiveWorkSnapshot(job, reason) {
39
+ for (const plan of (job.plans ?? []).filter(item => item.status === 'PLANNING')) {
40
+ plan.status = 'FAILED';
41
+ plan.error = reason;
42
+ plan.assessment = `Planning failed: ${reason}`;
43
+ plan.leaseOwner = null;
44
+ plan.leaseExpiresAt = null;
45
+ plan.executionId = null;
46
+ plan.updatedAt = new Date().toISOString();
47
+ }
48
+ for (const task of job.tasks)
49
+ tryApplyTaskCancelAction(task, reason);
50
+ }
37
51
  export async function applyJobCancelAction(store, job, reason = 'Job cancelled') {
38
52
  const from = job.state;
53
+ if (store.mutateJob) {
54
+ const updated = await store.mutateJob(job.id, current => {
55
+ const decision = decideJobLifecycle(current, 'cancel');
56
+ if (!decision.accepted)
57
+ throw new Error(`Job ${current.id} is not cancellable (${current.state})`);
58
+ dispatchJobLifecycleSync(current, 'cancel');
59
+ if (!decision.actions.includes('effect.cancelActiveTasks'))
60
+ throw new Error('Unsupported job.cancel effect');
61
+ cancelActiveWorkSnapshot(current, reason);
62
+ current.updatedAt = new Date().toISOString();
63
+ return current;
64
+ });
65
+ Object.assign(job, structuredClone(updated));
66
+ return job;
67
+ }
39
68
  try {
40
69
  await transitionJobEvent(job, 'cancel', {}, async (actionId) => {
41
70
  if (actionId !== 'effect.cancelActiveTasks')
@@ -262,11 +262,20 @@ export class LocalAuthorityDurableStore {
262
262
  return job;
263
263
  });
264
264
  }
265
- async mutate(jobId, mutation) {
265
+ /**
266
+ * Apply all related lifecycle changes to one freshly loaded Job snapshot.
267
+ * RelayStore owns the SQLite transaction; callers must not call saveJob,
268
+ * savePlan, or saveTask from inside this callback.
269
+ */
270
+ async mutateJob(jobId, mutation) {
266
271
  if (!this.authority.mutateJob)
267
272
  throw new Error('Atomic SQLite mutation is required for local lifecycle authority');
268
273
  const changed = await this.authority.mutateJob(jobId, mutation);
269
274
  publishLocalCommit(changed);
275
+ return structuredClone(changed);
276
+ }
277
+ async mutate(jobId, mutation) {
278
+ await this.mutateJob(jobId, mutation);
270
279
  }
271
280
  async listAgents() {
272
281
  return structuredClone(await this.authority.loadAgents() ?? []);
@@ -1,6 +1,8 @@
1
1
  const listeners = new Set();
2
2
  /** Notify dashboard listeners only after authoritative SQLite state is committed. */
3
3
  export function publishLocalCommit(job) {
4
+ // Notify after commit. A listener failure may be surfaced to the caller,
5
+ // but it can never roll back the already-committed SQLite snapshot.
4
6
  for (const listener of listeners)
5
7
  listener(structuredClone(job));
6
8
  }
@@ -29,6 +29,14 @@ export function localMergeEligible(job) {
29
29
  implementationComplete(job) && completionGateSatisfied(job) &&
30
30
  (job.reviewEnabled === false || job.review?.outcome === 'approved');
31
31
  }
32
+ export function localMergeCommitMessage(request, sourceSha) {
33
+ const lines = ['Agents Relay local merge', '', `Agents-Relay-Source-SHA: ${sourceSha}`];
34
+ if (request.correlationJobId)
35
+ lines.push(`Agents-Relay-Job: ${request.correlationJobId}`);
36
+ if (Number.isInteger(request.displayPrNumber) && Number(request.displayPrNumber) > 0)
37
+ lines.push(`Agents-Relay-Display-PR: ${request.displayPrNumber}`);
38
+ return lines.join('\n');
39
+ }
32
40
  function checkedOutTarget(repoPath, targetRef) {
33
41
  const records = git(repoPath, 'worktree', 'list', '--porcelain').split(/\n\n/);
34
42
  const paths = records.filter(record => record.split('\n').includes('branch ' + targetRef))
@@ -39,7 +47,7 @@ function checkedOutTarget(repoPath, targetRef) {
39
47
  return paths[0] ?? null;
40
48
  }
41
49
  /** Merge a checked-out branch using Git porcelain so its worktree and index stay coherent. */
42
- function mergeCheckedOutTarget(repoPath, targetRef, sourceSha, targetSha) {
50
+ function mergeCheckedOutTarget(repoPath, targetRef, sourceSha, targetSha, message) {
43
51
  const worktree = checkedOutTarget(repoPath, targetRef);
44
52
  if (!worktree)
45
53
  throw new Error('Target worktree disappeared before local merge');
@@ -47,8 +55,8 @@ function mergeCheckedOutTarget(repoPath, targetRef, sourceSha, targetSha) {
47
55
  || git(worktree, 'rev-parse', 'HEAD') !== targetSha) {
48
56
  throw new Error('Local merge target moved before delivery');
49
57
  }
50
- if (git(worktree, 'status', '--porcelain', '--untracked-files=all')) {
51
- throw new Error('Local merge target worktree is not clean');
58
+ if (git(worktree, 'status', '--porcelain', '--untracked-files=no')) {
59
+ throw new Error('Local merge target worktree has tracked or index changes');
52
60
  }
53
61
  if (existsSync(resolve(worktree, git(worktree, 'rev-parse', '--git-path', 'MERGE_HEAD')))) {
54
62
  throw new Error('Existing MERGE_HEAD is not owned by Relay; manual resolution required');
@@ -56,7 +64,7 @@ function mergeCheckedOutTarget(repoPath, targetRef, sourceSha, targetSha) {
56
64
  // No checkout, reset or force update. Git controls index and working tree,
57
65
  // reports conflicts and leaves a recoverable merge state when interrupted.
58
66
  try {
59
- git(worktree, 'merge', '--no-ff', '--no-edit', '-m', 'Agents Relay local merge', sourceSha);
67
+ git(worktree, 'merge', '--no-ff', '--no-edit', '-m', message, sourceSha);
60
68
  }
61
69
  catch (error) {
62
70
  // Only a MERGE_HEAD matching our exact source SHA can be attributed to
@@ -86,6 +94,8 @@ function mergeCheckedOutTarget(repoPath, targetRef, sourceSha, targetSha) {
86
94
  /** Deliver to a Git ref without ever updating a checked-out ref behind Git's back. */
87
95
  export function deliverLocalMerge(repoPath, request) {
88
96
  const sourceRef = ref(request.sourceRef), targetRef = ref(request.targetRef);
97
+ if (targetRef !== 'refs/heads/main')
98
+ throw new Error('Local Git merge target must always be refs/heads/main');
89
99
  if (sourceRef === targetRef)
90
100
  throw new Error('Local merge source and target must differ');
91
101
  const commonDir = resolve(repoPath, git(repoPath, 'rev-parse', '--git-common-dir'));
@@ -99,14 +109,15 @@ export function deliverLocalMerge(repoPath, request) {
99
109
  return { sourceSha, mergedCommitSha: targetSha }; // Idempotent after acknowledged or crashed delivery.
100
110
  }
101
111
  catch { /* merge required */ }
112
+ const message = localMergeCommitMessage(request, sourceSha);
102
113
  if (checkedOutTarget(repoPath, targetRef)) {
103
- return { sourceSha, mergedCommitSha: mergeCheckedOutTarget(repoPath, targetRef, sourceSha, targetSha) };
114
+ return { sourceSha, mergedCommitSha: mergeCheckedOutTarget(repoPath, targetRef, sourceSha, targetSha, message) };
104
115
  }
105
116
  // Unchecked-out refs can be updated atomically without touching any worktree.
106
117
  const tree = git(repoPath, 'merge-tree', '--write-tree', targetSha, sourceSha).split('\n')[0];
107
118
  if (!/^[0-9a-f]{40,64}$/.test(tree))
108
119
  throw new Error('Local merge-tree returned no valid tree');
109
- const mergedCommitSha = git(repoPath, 'commit-tree', tree, '-p', targetSha, '-p', sourceSha, '-m', 'Agents Relay local merge');
120
+ const mergedCommitSha = git(repoPath, 'commit-tree', tree, '-p', targetSha, '-p', sourceSha, '-m', message);
110
121
  git(repoPath, 'update-ref', targetRef, mergedCommitSha, targetSha);
111
122
  return { sourceSha, mergedCommitSha };
112
123
  });
@@ -1,7 +1,12 @@
1
1
  import { createHash } from 'node:crypto';
2
+ import { homedir } from 'node:os';
3
+ import { join } from 'node:path';
2
4
  /** Primary local store key is immutable Job ID, independent of a PR number. */
3
5
  export function relayJobStorePath(directory, jobId) {
4
6
  if (!jobId.trim() || jobId.length > 1024)
5
7
  throw new Error('Invalid immutable Job ID');
6
8
  return directory + '/job-' + createHash('sha256').update(jobId).digest('hex') + '.db';
7
9
  }
10
+ export function relayStoreDirectory(env = process.env) {
11
+ return env.AGENTS_RELAY_STORE_DIR || join(homedir(), '.agents-relay', 'stores');
12
+ }
@@ -1,8 +1,8 @@
1
- import { InMemoryStore } from './store.js';
1
+ import { GitHubStore, InMemoryStore } from './store.js';
2
2
  import { LocalAuthorityDurableStore } from './local-authority-store.js';
3
3
  import { discoverLocalRelayStoreSnapshots, SQLiteRelayStore } from './relay-store.js';
4
4
  /** Resolve exactly one SQLite-backed Job from its immutable ID, independent of PR binding. */
5
- export function createJobIdRegistryResolver(directory, openStores) {
5
+ export function createJobIdRegistryResolver(directory, openStores, options = {}) {
6
6
  return async (jobId) => {
7
7
  const snapshots = await discoverLocalRelayStoreSnapshots(directory);
8
8
  const matches = snapshots.flatMap(snapshot => snapshot.jobs.filter(job => job.id === jobId).map(job => ({ snapshot, job })));
@@ -17,7 +17,13 @@ export function createJobIdRegistryResolver(directory, openStores) {
17
17
  relay = new SQLiteRelayStore(snapshot.path);
18
18
  openStores.set(snapshot.path, relay);
19
19
  }
20
- // The GitHub adapter cannot enter the lifecycle execution store.
21
- return { job, relay, store: new LocalAuthorityDurableStore(relay, new InMemoryStore(), undefined, true) };
20
+ // SQLite remains lifecycle authority. A positive PR binding only enables
21
+ // the external collaboration adapter for projection/merge side effects.
22
+ const externalPrNumber = job.externalPrNumber ?? (job.prNumber > 0 ? job.prNumber : 0);
23
+ const githubEnabled = !options.noGitHub && externalPrNumber > 0 && options.client;
24
+ const github = githubEnabled
25
+ ? new GitHubStore(options.client, job.repository, externalPrNumber, options.trustedAuthors ?? new Set())
26
+ : new InMemoryStore();
27
+ return { job, relay, store: new LocalAuthorityDurableStore(relay, github, undefined, !githubEnabled) };
22
28
  };
23
29
  }
@@ -1,16 +1,14 @@
1
- import { homedir } from 'node:os';
2
- import { join } from 'node:path';
3
1
  import { GhApiClient } from './github-client.js';
4
2
  import { DisabledGitHubClient, noGitHubMode } from './no-github.js';
5
3
  import { GitHubStore } from './store.js';
6
4
  import { LocalAuthorityDurableStore } from './local-authority-store.js';
7
- import { relayStorePath, relayJobStorePath, SQLiteRelayStore } from './relay-store.js';
5
+ import { relayStoreDirectory, relayStorePath, relayJobStorePath, SQLiteRelayStore } from './relay-store.js';
8
6
  /** Open the canonical V2 SQLite authority without making any GitHub request. */
9
7
  export function openLocalManagedStore(repository, prNumber, options = {}) {
10
8
  if (!/^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/.test(repository) || repository.includes('..') || !Number.isInteger(prNumber) || prNumber < 0 || (prNumber === 0 && !options.jobId)) {
11
9
  throw new Error('Local managed store requires a repository and either a positive PR number or immutable jobId');
12
10
  }
13
- const directory = options.directory ?? join(homedir(), '.agents-relay', 'stores');
11
+ const directory = options.directory ?? relayStoreDirectory();
14
12
  const relay = new SQLiteRelayStore(prNumber === 0 ? relayJobStorePath(directory, options.jobId) : relayStorePath(directory, repository, prNumber));
15
13
  const client = prNumber === 0 || options.noGitHub || noGitHubMode() ? new DisabledGitHubClient() : options.client ?? new GhApiClient();
16
14
  const github = new GitHubStore(client, repository, prNumber, options.trustedAuthors ?? new Set());