agents-relay 1.0.71 → 1.0.73

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # Agents Relay
2
2
 
3
- Agents Relay is a generic TypeScript runtime for durable asynchronous agent jobs coordinated through GitHub pull requests. GitHub PR comments store durable workflow state; correlated terminal events are authoritative for model-backed task lifecycle transitions.
3
+ Agents Relay is a generic TypeScript runtime for durable asynchronous agent jobs coordinated through GitHub pull requests. The PR body stores the durable Job marker plus human-readable Job objective, while PR comments store durable Task and other append-friendly records; correlated terminal events are authoritative for model-backed task lifecycle transitions.
4
4
 
5
5
  ## Quick start
6
6
 
@@ -53,7 +53,7 @@ Lifecycle transition authority lives in the directly renderable canonical source
53
53
 
54
54
  Graph-engine rollout is confidence-gated with `AGENTS_RELAY_WORKFLOW_MODE=legacy|shadow|graph` (default `legacy`). `shadow` keeps legacy authoritative while comparing the Mermaid decision and exposing `{mode,matches,mismatches}` at `/api/workflow-rollout`; `graph` is explicit opt-in until the shadow cutover gate is approved.
55
55
 
56
- Job objectives and Task inputs are Markdown. Prefer `--objective-file` / `--input-file` (use `-` for stdin) for durable long-form context; inline `--objective` / `--input` remain for short/simple text and are mutually exclusive with the file variants. When a diagram materially clarifies the work, put Mermaid first and then Markdown detail. A newly created Job must capture enough context to finish independently of the originating chat; its objective becomes the default PR body and objective updates keep the PR body synchronized.
56
+ Job objectives and Task inputs are Markdown. Prefer `--objective-file` / `--input-file` (use `-` for stdin) for durable long-form context; inline `--objective` / `--input` remain for short/simple text and are mutually exclusive with the file variants. When a diagram materially clarifies the work, put Mermaid first and then Markdown detail. A newly created Job must capture enough context to finish independently of the originating chat. Its PR body contains exactly one hidden `agents-relay:job:v1` metadata marker followed by the objective Markdown; there is no separate Job comment duplicating the objective. Objective and Job metadata updates rewrite that same PR body. Legacy Job-comment markers remain readable and are migrated into the PR body on the next Job save.
57
57
 
58
58
  Jobs default to `fixed`. `job create` persists only the durable job and never
59
59
  creates executable tasks. The top-level orchestrator decides whether the user's
@@ -141,7 +141,7 @@ reconcile leases and launches ready tasks without waiting inside the runtime sch
141
141
 
142
142
  For automation, Agents Relay can use GitHub App installation authentication via `AGENTS_RELAY_GITHUB_APP_ID`, `AGENTS_RELAY_GITHUB_APP_INSTALLATION_ID`, and `AGENTS_RELAY_GITHUB_APP_PRIVATE_KEY_FILE` (or matching CLI flags). Webhook mode is cache-first: targeted webhook reconciliation is primary, dashboard auto-refresh reads cached job state, and the broad watchdog drops to a 30-minute fallback. See [docs/github-app-rate-limit.md](docs/github-app-rate-limit.md).
143
143
 
144
- V1 assumes one active runner/daemon per job. Use a distributed atomic lease before running multiple reconcilers. Do not put secrets, credentials, private prompts, or large private payloads in PR marker comments; store only summaries and artifact references.
144
+ V1 assumes one active runner/daemon per job. Use a distributed atomic lease before running multiple reconcilers. Do not put secrets, credentials, private prompts, or large private payloads in the PR-body Job marker or PR-comment Task markers; store only summaries and artifact references.
145
145
 
146
146
  Codex/model-backed tasks require --provider and --model (or equivalent routing metadata) before launch. See [docs/architecture.md](docs/architecture.md) and [skills/agents-relay/SKILL.md](skills/agents-relay/SKILL.md).
147
147
 
@@ -176,4 +176,4 @@ npx agents-relay task create --repo OWNER/REPO --pr 5 --id job-1 \
176
176
 
177
177
  GitHub PR state is authoritative for terminal lifecycle: a merged PR reconciles its managed job to `COMPLETED`; a closed, unmerged PR reconciles to `CANCELLED` and cannot launch queued work. The dashboard exposes `/api/jobs` and shows PR state, durable Job lifecycle, and derived runtime status separately. A merged PR reconciles lifecycle to `COMPLETED`; a closed, unmerged PR reconciles lifecycle to `CANCELLED`.
178
178
 
179
- For work that was completed outside the relay but must be represented truthfully in durable history, use `npx agents-relay record --task-id <id> --summary <summary> --commit <sha>`. Recorded tasks use the `orchestrator` adapter and terminal `SUCCEEDED` state; they do not pretend a shell/model worker executed the work. This is a repair/backfill mechanism—normal work should be created as a durable Task before execution. Managed jobs also retain the GitHub PR body as the job description for dashboard display.
179
+ For work that was completed outside the relay but must be represented truthfully in durable history, use `npx agents-relay record --task-id <id> --summary <summary> --commit <sha>`. Recorded tasks use the `orchestrator` adapter and terminal `SUCCEEDED` state; they do not pretend a shell/model worker executed the work. This is a repair/backfill mechanism—normal work should be created as a durable Task before execution. For managed jobs, the GitHub PR body is the canonical Job surface: hidden Job metadata marker plus the visible Markdown objective.
package/dist/cli.js CHANGED
@@ -635,7 +635,6 @@ async function runJobCommand(action, args) {
635
635
  if (!objective.trim())
636
636
  throw new Error('Job objective cannot be empty');
637
637
  job.objective = objective;
638
- await store.updatePullRequestBody?.(objective);
639
638
  }
640
639
  if (hasArg(args, '--priority'))
641
640
  job.priority = priority(args, job.priority ?? 'P2');
package/dist/dashboard.js CHANGED
@@ -80,6 +80,8 @@ export const dashboardHtml = `<!doctype html>
80
80
  .panel-actions { display:flex; align-items:center; gap:.45rem; }
81
81
  .job-filters { display:flex; gap:.35rem; align-items:center; flex-wrap:wrap; }
82
82
  .job-filter { max-width:145px; border:1px solid var(--border); border-radius:.35rem; background:var(--surface); color:var(--text); padding:.27rem .4rem; font:inherit; font-size:.72rem; }
83
+ .quick-filter { border:1px solid var(--border); border-radius:.35rem; background:var(--surface); color:var(--muted); padding:.27rem .42rem; font:inherit; font-size:.7rem; font-weight:750; cursor:pointer; }
84
+ .quick-filter[aria-pressed="true"] { border-color:var(--accent); background:var(--accent2); color:var(--accent); }
83
85
  .refresh-button { border:1px solid var(--border); border-radius:.35rem; background:var(--surface); color:var(--muted); padding:.28rem .55rem; font:inherit; font-size:.72rem; font-weight:700; cursor:pointer; }
84
86
  .refresh-button:disabled { cursor:default; opacity:.55; }
85
87
  .panel-body { padding:.75rem; overflow:auto; max-height:calc(100vh - 11.5rem); }
@@ -88,14 +90,13 @@ export const dashboardHtml = `<!doctype html>
88
90
  .workspace { display:grid; gap:1rem; }
89
91
  .job-list,.tree,.event-list { list-style:none; margin:0; padding:0; }
90
92
  .job-list { display:grid; gap:.35rem; }
91
- .job-row { display:grid; gap:.2rem; padding:.35rem .45rem; border:1px solid transparent; border-radius:.4rem; }
93
+ .job-row { display:grid; gap:.2rem; padding:.35rem .45rem; border:1px solid transparent; border-radius:.4rem; cursor:pointer; }
92
94
  .job-row[aria-current="true"] { border-color:var(--accent); background:var(--accent2); }
93
95
  .job-row.complete-unmerged { border-color:#d69a1b; background:#fff8df; }
94
96
  .job-row.complete-unmerged[aria-current="true"] { border-color:#b77900; background:#fff0bd; }
95
- .job-button { min-width:0; display:grid; gap:.2rem; padding:.12rem 0; border:0; background:transparent; color:inherit; text-align:left; font:inherit; cursor:pointer; }
97
+ .job-content { min-width:0; display:grid; gap:.2rem; }
98
+ .job-button { min-width:0; display:block; padding:.12rem 0; border:0; background:transparent; color:inherit; text-align:left; font:inherit; cursor:pointer; }
96
99
  .job-title { font-weight:750; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
97
- .job-meta { display:flex; gap:.4rem; flex-wrap:wrap; color:var(--muted); font-size:.7rem; }
98
- .job-meta span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
99
100
  .job-last-line { display:flex; align-items:center; justify-content:space-between; gap:.5rem; min-width:0; }
100
101
  .job-last-line .subtle:first-child { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
101
102
  .job-last-line .subtle:last-child { flex:none; white-space:nowrap; }
@@ -157,7 +158,7 @@ export const dashboardHtml = `<!doctype html>
157
158
  </header>
158
159
  <main>
159
160
  <section id="jobs-view" role="tabpanel" aria-labelledby="jobs-tab" class="panel jobs-panel">
160
- <div class="panel-head"><h2>Jobs</h2><div class="panel-actions"><div class="job-filters"><select id="repo-filter" class="job-filter" aria-label="Filter jobs by repository"><option value="">All repos</option></select></div><button id="jobs-refresh" class="refresh-button" type="button">Refresh</button></div></div>
161
+ <div class="panel-head"><h2>Jobs</h2><div class="panel-actions"><div class="job-filters"><button id="quick-jobs" class="quick-filter" type="button" aria-pressed="true">JOBS</button><select id="quick-status" class="job-filter" aria-label="Filter jobs by derived status"><option value="">STATUS</option></select><button id="quick-autonomous" class="quick-filter" type="button" aria-pressed="false">AUTONOMOUS</button><button id="quick-draft" class="quick-filter" type="button" aria-pressed="false">DRAFT</button><select id="repo-filter" class="job-filter" aria-label="Filter jobs by repository"><option value="">All repos</option></select></div><button id="jobs-refresh" class="refresh-button" type="button">Refresh</button></div></div>
161
162
  <div class="panel-body" aria-live="polite"><ul id="job-list" class="job-list"><li class="skeleton" aria-hidden="true"></li></ul></div>
162
163
  </section>
163
164
  <div class="workspace" id="jobs-workspace">
@@ -206,6 +207,10 @@ const jobsWorkspace=document.querySelector('#jobs-workspace');
206
207
  const jobList=document.querySelector('#job-list');
207
208
  const jobsRefresh=document.querySelector('#jobs-refresh');
208
209
  const repoFilter=document.querySelector('#repo-filter');
210
+ const quickJobs=document.querySelector('#quick-jobs');
211
+ const quickStatus=document.querySelector('#quick-status');
212
+ const quickAutonomous=document.querySelector('#quick-autonomous');
213
+ const quickDraft=document.querySelector('#quick-draft');
209
214
  const selectedJobRefresh=document.querySelector('#selected-job-refresh');
210
215
  const jobDetail=document.querySelector('#job-detail');
211
216
  const taskTree=document.querySelector('#task-tree');
@@ -222,6 +227,8 @@ let jobs=[];
222
227
  let selectedJob=sessionStorage.getItem('agents-relay:selected-job');
223
228
  let selectedTaskId=sessionStorage.getItem('agents-relay:selected-task');
224
229
  let selectedRepo=sessionStorage.getItem('agents-relay:repo-filter')||'';
230
+ let quickMode=sessionStorage.getItem('agents-relay:quick-filter')||'jobs';
231
+ let selectedRuntimeStatus=sessionStorage.getItem('agents-relay:runtime-status-filter')||'';
225
232
  let eventTaskId=selectedTaskId;
226
233
  const displayedEventIds=new Set();
227
234
 
@@ -241,20 +248,31 @@ function chatField(task){const box=element('div','detail-group');box.append(elem
241
248
  function clearEvents(){displayedEventIds.clear();eventFeed.replaceChildren(element('li','empty',text('Waiting for events.')));}
242
249
  async function loadEventHistory(){if(!selectedJob){clearEvents();return;}const params=new URLSearchParams({job:selectedJob,limit:String(MAX_EVENTS)});if(eventTaskId)params.set('task',eventTaskId);try{const history=await loadJSON('/api/events?'+params.toString());clearEvents();for(const event of Array.isArray(history)?history.slice().reverse():[])addEvent(event);}catch{clearEvents();}}
243
250
  function selectJob(id){selectedJob=id;selectedTaskId=null;eventTaskId=null;rememberSelection();clearEvents();location.hash=id?'#/jobs/'+encodeURIComponent(id):'#/jobs';renderAll();void loadEventHistory();}
244
- function filteredJobs(){return jobs.filter(job=>!selectedRepo||job.repository===selectedRepo);}
251
+ function matchesQuickFilter(job){if(quickMode==='autonomous')return job.executionMode==='autonomous';if(quickMode==='draft')return job.draft||job.state==='DRAFT';if(quickMode==='status')return !!selectedRuntimeStatus&&(job.runtimeStatus||'IDLE')===selectedRuntimeStatus;return true;}
252
+ function filteredJobs(){return jobs.filter(job=>(!selectedRepo||job.repository===selectedRepo)&&matchesQuickFilter(job));}
253
+ function setQuickMode(mode,status=''){quickMode=mode;selectedRuntimeStatus=mode==='status'?status:'';sessionStorage.setItem('agents-relay:quick-filter',quickMode);if(selectedRuntimeStatus)sessionStorage.setItem('agents-relay:runtime-status-filter',selectedRuntimeStatus);else sessionStorage.removeItem('agents-relay:runtime-status-filter');syncFilters();renderJobList();}
245
254
  function syncFilters(){
246
255
  const repos=[...new Set(jobs.map(job=>job.repository).filter(Boolean))].sort();
247
256
  const previousRepo=selectedRepo;
248
257
  repoFilter.replaceChildren(new Option('All repos',''),...repos.map(repo=>new Option(repo,repo)));
249
258
  selectedRepo=repos.includes(previousRepo)?previousRepo:'';
250
259
  repoFilter.value=selectedRepo;
260
+ const statuses=[...new Set(jobs.map(job=>job.runtimeStatus||'IDLE'))].sort();
261
+ const previousStatus=selectedRuntimeStatus;
262
+ quickStatus.replaceChildren(new Option('STATUS',''),...statuses.map(status=>new Option(status,status)));
263
+ selectedRuntimeStatus=statuses.includes(previousStatus)?previousStatus:'';
264
+ if(quickMode==='status'&&!selectedRuntimeStatus)quickMode='jobs';
265
+ quickStatus.value=quickMode==='status'?selectedRuntimeStatus:'';
266
+ quickJobs.setAttribute('aria-pressed',String(quickMode==='jobs'));
267
+ quickAutonomous.setAttribute('aria-pressed',String(quickMode==='autonomous'));
268
+ quickDraft.setAttribute('aria-pressed',String(quickMode==='draft'));
251
269
  }
252
270
  function actionEnabled(job,action){if(action==='start')return job.state==='DRAFT'&&job.githubState!=='CLOSED'&&job.githubState!=='MERGED';if(action==='reconcile')return job.state==='ACTIVE'&&job.githubState==='OPEN';if(action==='cancel')return job.state!=='COMPLETED'&&job.state!=='CANCELLED'&&job.githubState==='OPEN';return false;}
253
271
  function actionButton(job,action,label,icon){const button=document.createElement('button');button.type='button';button.className='job-action';button.textContent=icon;button.title=label;button.setAttribute('aria-label',label);button.disabled=!actionEnabled(job,action);button.addEventListener('click',event=>{event.stopPropagation();void runJobAction(job,action,button);});return button;}
254
272
  async function runJobAction(job,action,button){button.disabled=true;try{const response=await fetch('/api/job-action?id='+encodeURIComponent(job.id)+'&action='+encodeURIComponent(action),{method:'POST'});if(!response.ok)throw new Error(response.status+' '+response.statusText);await refresh(true);}catch(error){button.title=labelForAction(action)+': '+error.message;}finally{renderAll();}}
255
273
  function labelForAction(action){return action==='start'?'Start':action==='reconcile'?'Reconcile':'Cancel';}
256
274
  function repoName(repository){if(typeof repository!=='string'||!repository)return 'unknown-repo';const parts=repository.split('/').filter(Boolean);return parts.at(-1)||repository;}
257
- 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(job.id===selectedJob));const content=element('div','job-content');const select=document.createElement('button');select.type='button';select.className='job-button';select.setAttribute('aria-current',String(job.id===selectedJob));select.title=job.title||job.id;select.addEventListener('click',()=>selectJob(job.id));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'))),element('span','badge '+value(job.githubState||'UNKNOWN'),text(value(job.githubState||'UNKNOWN'))));const actions=element('span','job-actions');actions.append(actionButton(job,'start','Start','▶'),actionButton(job,'reconcile','Reconcile','↻'),actionButton(job,'cancel','Cancel','×'));const primary=element('div','job-primary');primary.append(states,actions);const meta=element('span','job-meta');meta.append(element('span','badge mode-'+value(job.executionMode||'fixed'),text(value(job.executionMode||'fixed').toUpperCase())),element('span',job.draft?'badge DRAFT':'',text(job.draft?'DRAFT':value(job.githubState||'UNKNOWN'))));if(isCompleteUnmerged(job))meta.append(element('span','badge MERGE_PENDING',text('MERGE PENDING')));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,meta,lastLine);row.append(content);jobList.append(element('li','',row));}}
275
+ 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(job.id===selectedJob));row.addEventListener('click',()=>selectJob(job.id));const content=element('div','job-content');const select=document.createElement('button');select.type='button';select.className='job-button';select.setAttribute('aria-current',String(job.id===selectedJob));select.title=job.title||job.id;select.addEventListener('click',event=>{event.stopPropagation();selectJob(job.id);});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'))),element('span','badge '+value(job.githubState||'UNKNOWN'),text(value(job.githubState||'UNKNOWN'))));if(job.executionMode==='autonomous')states.append(element('span','badge flag-autonomous',text('AUTONOMOUS')));if(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 actions=element('span','job-actions');actions.append(actionButton(job,'start','Start','▶'),actionButton(job,'reconcile','Reconcile','↻'),actionButton(job,'cancel','Cancel','×'));const primary=element('div','job-primary');primary.append(states,actions);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));}}
258
276
  function taskNode(task,children,depth){const li=element('li');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',()=>{selectedTaskId=task.id;eventTaskId=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)));li.append(row);const nested=children.get(task.id)||[];if(nested.length){const ul=element('ul','tree');for(const child of nested)ul.append(taskNode(child,children,depth+1));li.append(ul);}return li;}
259
277
  function buildTree(tasks){taskTree.replaceChildren();if(!Array.isArray(tasks)||!tasks.length){taskTree.append(element('li','empty',text('No tasks in durable state.')));return;}const known=new Set(tasks.map(task=>task.id));const children=new Map();const roots=[];for(const task of tasks){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));}
260
278
  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;}
@@ -264,7 +282,8 @@ function renderSelectedJob(){jobDetail.replaceChildren();const job=selected();if
264
282
  function renderAll(){jobs.sort(compareJobs);syncFilters();renderJobList();renderSelectedJob();}
265
283
  function isCompleteUnmerged(job){return job.runtimeStatus==='MERGE_PENDING';}
266
284
  function isTerminalJob(job){return ['COMPLETED','CANCELLED'].includes(job.state)||['MERGED','CLOSED'].includes(job.githubState);}
267
- function compareJobs(a,b){const mergePendingDelta=Number(isCompleteUnmerged(b))-Number(isCompleteUnmerged(a));if(mergePendingDelta)return mergePendingDelta;const terminalDelta=Number(isTerminalJob(a))-Number(isTerminalJob(b));return terminalDelta||String(b.updatedAt).localeCompare(String(a.updatedAt));}
285
+ function jobGroup(job){if(isTerminalJob(job))return 3;if(job.executionMode==='autonomous')return 1;if(job.draft||job.state==='DRAFT')return 2;return 0;}
286
+ function compareJobs(a,b){const groupDelta=jobGroup(a)-jobGroup(b);if(groupDelta)return groupDelta;const mergePendingDelta=Number(isCompleteUnmerged(b))-Number(isCompleteUnmerged(a));return mergePendingDelta||String(b.updatedAt).localeCompare(String(a.updatedAt));}
268
287
  async function loadJSON(url){const response=await fetch(url);if(!response.ok)throw new Error(response.status+' '+response.statusText);return response.json();}
269
288
  function renderRateLimitStatus(status){
270
289
  if(!status?.limited){rateLimitWarning.hidden=true;rateLimitWarning.textContent='';return;}
@@ -288,6 +307,10 @@ jobsTab.addEventListener('click',()=>{location.hash=selectedJob?'#/jobs/'+encode
288
307
  jobsRefresh.addEventListener('click',()=>void refresh(true));
289
308
  eventsAllTasks.addEventListener('click',()=>{eventTaskId=null;clearEvents();renderEventScope();void loadEventHistory();});
290
309
  repoFilter.addEventListener('change',()=>{selectedRepo=repoFilter.value;sessionStorage.setItem('agents-relay:repo-filter',selectedRepo);renderJobList();});
310
+ quickJobs.addEventListener('click',()=>setQuickMode('jobs'));
311
+ quickAutonomous.addEventListener('click',()=>setQuickMode('autonomous'));
312
+ quickDraft.addEventListener('click',()=>setQuickMode('draft'));
313
+ quickStatus.addEventListener('change',()=>setQuickMode(quickStatus.value?'status':'jobs',quickStatus.value));
291
314
  selectedJobRefresh.addEventListener('click',()=>void refreshSelected());
292
315
  usageTab.addEventListener('click',()=>{location.hash='#/usage';showView('usage');});
293
316
  window.addEventListener('keydown',event=>{if(event.key==='Tab'&&event.altKey){event.preventDefault();showView(location.hash==='#/usage'?'jobs':'usage');}});
package/dist/markers.js CHANGED
@@ -32,10 +32,18 @@ function contentBeforeDelimiter(body, heading, end) {
32
32
  return undefined;
33
33
  return body.slice(contentStart, endAt);
34
34
  }
35
+ function jobMetadata(job) {
36
+ const { objective: _objective, description: _description, tasks: _tasks, ...metadata } = job;
37
+ return metadata;
38
+ }
39
+ export function renderJobBody(job) {
40
+ const objective = job.objective?.trim() || '_No objective recorded._';
41
+ return `${marker(JOB_MARKER, jobMetadata(job))}
42
+ ${objective}`;
43
+ }
35
44
  export function renderJobComment(job) {
36
45
  const objective = job.objective?.trim() || '_No objective recorded._';
37
- const { objective: _objective, description: _description, tasks: _tasks, ...metadata } = job;
38
- return `${marker(JOB_MARKER, metadata)}
46
+ return `${marker(JOB_MARKER, jobMetadata(job))}
39
47
  # Agents Relay job: ${job.title}
40
48
 
41
49
  - **State:** ${job.state}
@@ -70,7 +78,9 @@ export function parseJob(body) {
70
78
  const job = parseMarker(body, JOB_MARKER);
71
79
  if (!job)
72
80
  return null;
73
- const objective = job.objective ?? job.description ?? contentBeforeDelimiter(body, '\n## Objective\n\n', JOB_OBJECTIVE_END) ?? markdownSection(body, '\n## Objective\n');
81
+ const markerEnd = body.indexOf('-->');
82
+ const bodyObjective = markerEnd >= 0 ? body.slice(markerEnd + 3).trim() || undefined : undefined;
83
+ const objective = job.objective ?? job.description ?? contentBeforeDelimiter(body, '\n## Objective\n\n', JOB_OBJECTIVE_END) ?? markdownSection(body, '\n## Objective\n') ?? bodyObjective;
74
84
  const parsed = { ...job, objective };
75
85
  if (parsed.executionMode === undefined)
76
86
  return { ...parsed, executionMode: 'fixed' };
package/dist/store.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { createSign } from 'node:crypto';
3
- import { parseAgent, parseJob, parseTask, renderAgentComment, renderJobComment, renderTaskComment, AGENT_MARKER, JOB_MARKER, TASK_MARKER } from './markers.js';
3
+ import { parseAgent, parseJob, parseTask, renderAgentComment, renderJobBody, renderJobComment, renderTaskComment, AGENT_MARKER, JOB_MARKER, TASK_MARKER } from './markers.js';
4
4
  export async function loadManagedGitHubPullRequestGraphql(client, repository, prNumber, trustedAuthors) {
5
5
  const [owner, name] = repository.split('/', 2);
6
6
  if (!owner || !name || !Number.isInteger(prNumber) || prNumber <= 0 || !client.graphql)
@@ -17,7 +17,9 @@ export async function loadManagedGitHubPullRequestGraphql(client, repository, pr
17
17
  const author = row.author;
18
18
  return typeof author?.login === 'string' && trustedAuthors.has(author.login);
19
19
  });
20
- const jobs = trusted.map(item => parseJob(String(item.body ?? ''))).filter((item) => item !== null);
20
+ const bodyJob = parseJob(typeof pr.body === 'string' ? pr.body : '');
21
+ const commentJobs = trusted.map(item => parseJob(String(item.body ?? ''))).filter((item) => item !== null);
22
+ const jobs = bodyJob ? [bodyJob] : commentJobs;
21
23
  const tasks = trusted.map(item => parseTask(String(item.body ?? ''))).filter((item) => item !== null);
22
24
  const state = String(pr.state ?? '').toUpperCase();
23
25
  const githubState = pr.merged === true ? 'MERGED' : state === 'CLOSED' ? 'CLOSED' : 'OPEN';
@@ -424,13 +426,17 @@ export class GitHubStore {
424
426
  }
425
427
  async prNode() {
426
428
  const { owner, name } = this.repoParts();
427
- const data = await this.graphql('query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){id state merged mergedAt closedAt}}}', { owner, name, number: this.prNumber });
429
+ const data = await this.graphql('query($owner:String!,$name:String!,$number:Int!){repository(owner:$owner,name:$name){pullRequest(number:$number){id state merged mergedAt closedAt body}}}', { owner, name, number: this.prNumber });
428
430
  const repository = data.repository;
429
431
  const pr = repository?.pullRequest;
430
432
  if (!pr)
431
433
  throw new Error(`Pull request #${this.prNumber} not found in ${this.repository}`);
432
434
  return pr;
433
435
  }
436
+ async pullRequestBody() {
437
+ const pr = await this.prNode();
438
+ return typeof pr.body === 'string' ? pr.body : '';
439
+ }
434
440
  async list() {
435
441
  const { owner, name } = this.repoParts();
436
442
  const all = [];
@@ -490,10 +496,16 @@ export class GitHubStore {
490
496
  }
491
497
  async load(jobId) {
492
498
  const comments = (await this.list()).filter(comment => this.isTrusted(comment));
493
- const jobs = comments.map(x => parseJob(x.body)).filter((x) => x !== null && typeof x.id === 'string' && x.id.length > 0);
499
+ const bodyJob = parseJob(await this.pullRequestBody());
500
+ const commentJobs = comments.map(x => parseJob(x.body)).filter((x) => x !== null && typeof x.id === 'string' && x.id.length > 0);
501
+ const jobs = bodyJob ? [bodyJob] : commentJobs;
494
502
  const job = jobs.find(x => x.id === jobId);
495
- if (!job)
503
+ if (!job) {
504
+ const conflicting = bodyJob ?? commentJobs[0];
505
+ if (conflicting)
506
+ throw new Error(`JOB_ID_MISMATCH: PR #${this.prNumber} is bound to ${conflicting.id}, not ${jobId}`);
496
507
  throw new Error(`JOB_MARKER_MISSING: Managed GitHub job ${jobId} has no durable ${JOB_MARKER} marker in PR #${this.prNumber}`);
508
+ }
497
509
  const soleTrustedJob = jobs.length === 1;
498
510
  job.tasks = comments.map(comment => {
499
511
  const task = parseTask(comment.body);
@@ -505,6 +517,16 @@ export class GitHubStore {
505
517
  return job;
506
518
  }
507
519
  async validateJobBinding(jobId) {
520
+ const body = await this.pullRequestBody();
521
+ const bodyHasMarker = body.includes(`<!-- ${JOB_MARKER} `);
522
+ const bodyJob = parseJob(body);
523
+ if (bodyHasMarker && !bodyJob)
524
+ throw new Error(`JOB_MARKER_MALFORMED: PR #${this.prNumber} body contains a ${JOB_MARKER} marker that cannot be parsed`);
525
+ if (bodyJob) {
526
+ if (bodyJob.id !== jobId)
527
+ throw new Error(`JOB_ID_MISMATCH: PR #${this.prNumber} is bound to ${bodyJob.id}, not ${jobId}`);
528
+ return;
529
+ }
508
530
  const comments = (await this.list()).filter(comment => this.isTrusted(comment));
509
531
  const markerComments = comments.filter(comment => comment.body.includes(JOB_MARKER));
510
532
  const parsed = markerComments.map(comment => parseJob(comment.body));
@@ -539,22 +561,21 @@ export class GitHubStore {
539
561
  await this.graphql('mutation($id:ID!){deleteIssueComment(input:{id:$id}){clientMutationId}}', { id: comment.nodeId });
540
562
  }
541
563
  async saveJob(job) {
564
+ const currentBody = await this.pullRequestBody();
565
+ const bodyJob = parseJob(currentBody);
566
+ if (bodyJob && bodyJob.id !== job.id)
567
+ throw new Error(`PR #${this.prNumber} body already has a different ${JOB_MARKER} marker`);
542
568
  const comments = (await this.list()).filter(comment => this.isTrusted(comment));
543
569
  const jobComments = comments.filter(comment => parseJob(comment.body) !== null);
544
570
  const conflicts = jobComments.filter(comment => parseJob(comment.body)?.id !== job.id);
545
571
  if (conflicts.length > 0)
546
572
  throw new Error(`PR #${this.prNumber} already has a different ${JOB_MARKER} marker`);
547
- const matches = jobComments.filter(comment => parseJob(comment.body)?.id === job.id);
548
- if (matches.length === 0)
549
- await this.comment(renderJobComment(job));
550
- else {
551
- await this.updateComment(matches[0], renderJobComment(job));
552
- for (const duplicate of matches.slice(1))
553
- await this.deleteComment(duplicate);
554
- }
555
- const durable = (await this.list()).filter(comment => this.isTrusted(comment)).filter(comment => parseJob(comment.body)?.id === job.id);
556
- if (durable.length !== 1)
557
- throw new Error(`Failed to persist exactly one ${JOB_MARKER} marker for job ${job.id}`);
573
+ await this.updatePullRequestBody(renderJobBody(job));
574
+ const persisted = parseJob(await this.pullRequestBody());
575
+ if (!persisted || persisted.id !== job.id)
576
+ throw new Error(`Failed to persist ${JOB_MARKER} marker in PR #${this.prNumber} body for job ${job.id}`);
577
+ for (const legacy of jobComments.filter(comment => parseJob(comment.body)?.id === job.id))
578
+ await this.deleteComment(legacy);
558
579
  }
559
580
  async appendTask(task) {
560
581
  if (!task.jobId)
@@ -615,7 +636,9 @@ export async function listManagedGitHubJobs(client, repository, trustedAuthors)
615
636
  const author = item.author;
616
637
  return typeof author?.login === 'string' && trustedAuthors.has(author.login);
617
638
  });
618
- const jobs = trusted.map(item => parseJob(String(item.body ?? ''))).filter((item) => item !== null);
639
+ const bodyJob = parseJob(typeof pr.body === 'string' ? pr.body : '');
640
+ const commentJobs = trusted.map(item => parseJob(String(item.body ?? ''))).filter((item) => item !== null);
641
+ const jobs = bodyJob ? [bodyJob] : commentJobs;
619
642
  const tasks = trusted.map(item => parseTask(String(item.body ?? ''))).filter((item) => item !== null);
620
643
  const state = String(pr.state ?? '').toUpperCase();
621
644
  const githubState = pr.merged === true || state === 'MERGED' ? 'MERGED' : state === 'CLOSED' ? 'CLOSED' : 'OPEN';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "1.0.71",
3
+ "version": "1.0.73",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {
@@ -6,7 +6,7 @@ This contract is the shared protocol for every component that creates, schedules
6
6
 
7
7
  Job objectives and Task inputs are **Markdown** and are durable execution context, not short labels. Use Mermaid where a diagram materially explains a workflow, state model, architecture, dependency graph, relationship, or other visual structure. **Mermaid-first applies to that explanation/section, not to the entire Job objective or Task input.** Put the diagram before the prose that explains that visual model, but keep normal document structure: start with the goal/context when that is what the reader needs first. Do not force a Mermaid block at the top of every objective/input, and do not add decorative or redundant diagrams when Markdown is clearer.
8
8
 
9
- The trusted machine-readable Job/Task marker stores lifecycle and execution metadata only. Do **not** duplicate the Job objective or Task input inside the JSON marker; those long-form Markdown bodies remain the human-readable durable content rendered by the Job/Task comment (and, for a Job objective, the PR body). Readers reconstruct `objective` / `input` from that visible Markdown while remaining backward-compatible with legacy markers that embedded them.
9
+ The trusted machine-readable Job/Task marker stores lifecycle and execution metadata only. Do **not** duplicate the Job objective or Task input inside marker JSON. The Job marker lives in the PR body immediately before the human-readable Job objective; Task markers live in their Task comments immediately before the human-readable Task input/result. Readers reconstruct `objective` / `input` from that visible Markdown while remaining backward-compatible with legacy markers that embedded long-form content or stored the Job marker in a separate comment.
10
10
 
11
11
  For long-form content, use files instead of shell-quoted arguments:
12
12
 
@@ -15,7 +15,7 @@ For long-form content, use files instead of shell-quoted arguments:
15
15
 
16
16
  When creating a new Job from an active discussion, the orchestrator must consolidate **all context needed to finish the Job without the originating thread** into the Job objective before creation. Include, as applicable: goal, relevant background, decisions already made, requirements, constraints, lifecycle/workflow semantics, non-goals, implementation boundaries, acceptance criteria, delivery/review instructions, and any important known issues. Do not create a Job whose objective is only a one-line summary when the discussion contains material implementation context.
17
17
 
18
- For a newly created PR (`job create --head`), standalone objective Markdown is required and becomes the default PR body. Updating the Job objective also updates the PR body. The trusted Job marker remains the machine-readable durable state, while the PR body and rendered Job comment expose the same objective to humans and agents.
18
+ For a newly created PR (`job create --head`), standalone objective Markdown is required. The PR body is the single durable Job surface: one hidden `agents-relay:job:v1` marker followed by the objective Markdown. Updating Job metadata or objective updates that same body. New Jobs must not create a separate Job comment that repeats the objective. Readers must remain backward-compatible with legacy Job-comment markers, and a successful Job save may migrate such a legacy marker into the PR body only after validating the replacement before deleting the redundant legacy Job comment.
19
19
 
20
20
  ## Task producers
21
21