agents-relay 2.0.101 → 2.0.103

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/blockers.js CHANGED
@@ -98,16 +98,16 @@ export async function resolveBlockerDependencies(options) {
98
98
  catch (error) {
99
99
  loadError = error instanceof Error ? error.message : String(error);
100
100
  }
101
- const targetMatches = !!task && ['DRAFT', 'FAILED', 'BLOCKED', 'UNBLOCKED'].includes(task.state) && task.attempt === relation.blockedAttempt;
101
+ const targetMatches = !!task && ['DRAFT', 'FAILED', 'BLOCKED', 'QUEUED'].includes(task.state) && task.attempt === relation.blockedAttempt;
102
102
  let note = 'dependent task requeued';
103
103
  const decision = await dispatchBlockerResolution({
104
104
  resolutionAllowed: !(mode === 'verified' && options.resolution !== 'verified'),
105
105
  targetMatches,
106
106
  autonomousJob: targetJob?.executionMode === 'autonomous',
107
107
  effectivelyBlocked: !!task && isEffectivelyBlocked(task),
108
- alreadyUnblocked: task?.state === 'UNBLOCKED' && Boolean(task.unblock),
108
+ alreadyUnblocked: Boolean(task?.unblock && ['DRAFT', 'QUEUED'].includes(task.state)),
109
109
  isDraft: task?.state === 'DRAFT', isBlocked: task?.state === 'BLOCKED',
110
- isFailed: task?.state === 'FAILED', isUnblocked: task?.state === 'UNBLOCKED',
110
+ isFailed: task?.state === 'FAILED', isUnblocked: Boolean(task?.unblock && ['DRAFT', 'QUEUED'].includes(task.state)),
111
111
  }, async (actionId) => {
112
112
  if (!targetJob || !task)
113
113
  throw new Error('Blocker graph requested action without a matching dependent task');
package/dist/cli-help.js CHANGED
@@ -90,7 +90,7 @@ Example:
90
90
  'job merge': `Request an idempotent external pull-request merge; return QUEUED until confirmed, when the Graph Engine completes the Job.
91
91
 
92
92
  Required: --repo OWNER/REPO, --pr NUMBER, and --id JOB_ID. The current PR head SHA is pinned into the merge request.`,
93
- 'job unblock': `Authoritatively unblock the effective BLOCKED work Tasks in one Job.\n\nRequired: --repo OWNER/REPO --pr NUMBER --id JOB_ID --reason TEXT. Autonomous Jobs transition blocked work to historical UNBLOCKED state and wake Planner with unblock facts as Plan trigger/context; fixed Jobs record UNBLOCKED first and then resume according to fixed-job retry semantics.`,
93
+ 'job unblock': `Authoritatively unblock the effective BLOCKED work Tasks in one Job.\n\nRequired: --repo OWNER/REPO --pr NUMBER --id JOB_ID --reason TEXT. Autonomous Jobs move blocked work to planner-waiting DRAFT with durable unblock facts; fixed Jobs resume directly as QUEUED with a reset retry budget.`,
94
94
  'job cancel': `Cancel the durable Job and close its backing pull request.
95
95
 
96
96
  Required: --repo OWNER/REPO, --pr NUMBER, and --id JOB_ID. Nonterminal Tasks are cancelled before the PR is closed.`,
package/dist/cli.js CHANGED
@@ -153,7 +153,7 @@ async function runTaskCommand(action, args) {
153
153
  if (!task)
154
154
  throw new Error(`Task ${taskId} not found`);
155
155
  const expectedUpdatedAt = task.updatedAt;
156
- if (['RUNNING', 'UNBLOCKED', 'SUCCEEDED', 'CANCELLED'].includes(task.state))
156
+ if (['RUNNING', 'SUCCEEDED', 'CANCELLED'].includes(task.state))
157
157
  throw new Error(`Task ${taskId} is ${task.state}; durable execution fields are immutable in this state`);
158
158
  if (hasArg(args, '--title'))
159
159
  task.title = arg(args, '--title');
@@ -260,7 +260,7 @@ async function runTaskCommand(action, args) {
260
260
  return;
261
261
  }
262
262
  const expectedUpdatedAt = task.updatedAt;
263
- if (['RUNNING', 'UNBLOCKED', 'SUCCEEDED', 'CANCELLED'].includes(task.state))
263
+ if (['RUNNING', 'SUCCEEDED', 'CANCELLED'].includes(task.state))
264
264
  throw new Error(`Task ${id} is ${task.state}; durable execution fields are immutable in this state`);
265
265
  if (hasArg(args, '--adapter')) {
266
266
  const adapter = arg(args, '--adapter');
@@ -46,11 +46,7 @@ export function dashboardTaskSetState(store) {
46
46
  if (task.state === state)
47
47
  return job;
48
48
  if (task.state === 'BLOCKED' && state !== 'CANCELLED')
49
- throw new Error(`Task ${task.id} is BLOCKED; only authoritative unblock may transition it to UNBLOCKED`);
50
- if (state === 'UNBLOCKED')
51
- throw new Error(`Task ${task.id} may enter UNBLOCKED only through authoritative unblock`);
52
- if (task.state === 'UNBLOCKED')
53
- throw new Error(`Task ${task.id} is historical UNBLOCKED work and cannot be changed by dashboard state override`);
49
+ throw new Error(`Task ${task.id} is BLOCKED; only authoritative unblock may resume it`);
54
50
  const now = new Date().toISOString();
55
51
  const from = task.state;
56
52
  await dispatchWorkflowTargetOverride(taskLifecycleV2, {
@@ -61,7 +61,7 @@ export const dashboardHtml = `<!doctype html>
61
61
  .status-pill.ok,.badge.ACTIVE,.badge.RUNNING,.badge.SUCCEEDED,.badge.COMPLETED,.badge.MERGED { background:#e5f3e9; color:var(--ok); }
62
62
  .status-pill.warn,.badge.BLOCKED,.badge.DRAFT,.badge.QUEUED,.badge.WAITING,.badge.OPEN { background:#fff1d2; color:var(--warn); }
63
63
  .status-pill.bad,.badge.FAILED { background:#ffe8e8; color:var(--bad); }
64
- .status-pill.neutral,.badge.IDLE,.badge.UNBLOCKED,.badge.CANCELLED { background:#edf0f4; color:var(--muted); }
64
+ .status-pill.neutral,.badge.IDLE,.badge.CANCELLED { background:#edf0f4; color:var(--muted); }
65
65
  .badge.DRAFT { background:#eef2ff; color:#3f51b5; }
66
66
  .badge.MERGE_PENDING { background:#ffe3a3; color:#7a4a00; }
67
67
  .job-summary,.detail-groups { display:grid; grid-template-columns:repeat(2,minmax(0,1fr)); gap:.55rem; }
@@ -339,7 +339,7 @@ function shortManagedId(id,repository,kind){const parts=String(repository||'').s
339
339
  function jobForSelection(selection){return jobs.find(job=>jobSelectionKey(job)===selection)||jobs.find(job=>job.id===selection)||null;}
340
340
  function selected(){return jobForSelection(selectedJob)||jobs[0]||null;}
341
341
  function counts(tasks){const out={};for(const task of Array.isArray(tasks)?tasks:[])out[task.state]=(out[task.state]||0)+1;return out;}
342
- function compactCounts(input){const order=['RUNNING','BLOCKED','UNBLOCKED','FAILED','QUEUED','DRAFT','SUCCEEDED','CANCELLED'];return order.filter(state=>input[state]).map(state=>input[state]+' '+state).join(', ')||'No tasks';}
342
+ function compactCounts(input){const order=['RUNNING','BLOCKED','FAILED','QUEUED','DRAFT','SUCCEEDED','CANCELLED'];return order.filter(state=>input[state]).map(state=>input[state]+' '+state).join(', ')||'No tasks';}
343
343
  function relativeTime(timestamp){if(!timestamp)return '—';const parsed=Date.parse(timestamp);if(!Number.isFinite(parsed))return value(timestamp);const seconds=Math.max(0,Math.round((Date.now()-parsed)/1000));if(seconds<60)return 'just now';if(seconds<3600)return Math.floor(seconds/60)+'m ago';if(seconds<86400)return Math.floor(seconds/3600)+'h ago';return Math.floor(seconds/86400)+'d ago';}
344
344
  function cadence(job){const schedule=job?.schedule;if(!schedule?.cron)return '—';const cron=String(schedule.cron).trim();const timezone=value(schedule.timezone||'UTC');const fields=cron.split(/\\s+/);let label=cron;if(fields.length===5){const [minute,hour,day,month,weekday]=fields;if(day==='*'&&month==='*'&&weekday==='*'){const everyMinute=minute.match(/^\\*\\/(\\d+)$/);if(everyMinute&&hour==='*')label='Every '+everyMinute[1]+' min';else if(minute==='0'&&hour==='*')label='Hourly';else if(/^\\d+$/.test(minute)&&/^\\d+$/.test(hour)){const hh=String(Number(hour)).padStart(2,'0');const mm=String(Number(minute)).padStart(2,'0');label='Daily '+hh+':'+mm;}}}return label+' · '+cron+' · '+timezone;}
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;}
@@ -397,9 +397,9 @@ function attemptBudget(task){const attempt=Number(task?.attempt)||0;const max=Nu
397
397
  const JOB_STATES=['DRAFT','ACTIVE','COMPLETED','CANCELLED'];
398
398
  async function setJobState(job,state,select){if(!job||state===job.state)return;select.disabled=true;try{const params=new URLSearchParams({id:job.id,state,repo:job.repository,pr:String(job.prNumber)});const response=await fetch('/api/job-state?'+params.toString(),{method:'POST'});if(!response.ok)throw new Error(response.status+' '+response.statusText);const updated=await response.json();const index=jobs.findIndex(candidate=>jobSelectionKey(candidate)===jobSelectionKey(job));if(index>=0)jobs[index]=updated;else jobs.push(updated);renderAll();void loadEventHistory();}catch(error){select.title='Set job lifecycle state: '+error.message;select.value=job.state;}finally{select.disabled=false;}}
399
399
  function jobStateField(job){const box=element('div','detail-group');box.append(element('p','detail-label',text('Lifecycle state')));const select=document.createElement('select');select.className='job-filter job-state-select';select.setAttribute('aria-label','Job lifecycle state');for(const state of JOB_STATES)select.append(new Option(state,state));select.value=job.state;select.addEventListener('change',()=>void setJobState(job,select.value,select));box.append(select);return box;}
400
- const TASK_STATES=['DRAFT','QUEUED','RUNNING','BLOCKED','UNBLOCKED','SUCCEEDED','FAILED','CANCELLED'];
400
+ const TASK_STATES=['DRAFT','QUEUED','RUNNING','BLOCKED','SUCCEEDED','FAILED','CANCELLED'];
401
401
  async function setTaskState(task,state,select){const job=selected();if(!job||state===task.state)return;select.disabled=true;try{const params=new URLSearchParams({id:job.id,task:task.id,state,repo:job.repository,pr:String(job.prNumber)});const response=await fetch('/api/task-state?'+params.toString(),{method:'POST'});if(!response.ok)throw new Error(response.status+' '+response.statusText);const updated=await response.json();const index=jobs.findIndex(candidate=>jobSelectionKey(candidate)===jobSelectionKey(job));if(index>=0)jobs[index]=updated;else jobs.push(updated);renderAll();void loadEventHistory();}catch(error){select.title='Set task state: '+error.message;select.value=task.state;}finally{select.disabled=false;}}
402
- function taskStateField(task){const box=element('div','detail-group');box.append(element('p','detail-label',text('State')));const select=document.createElement('select');select.className='job-filter task-state-select';select.setAttribute('aria-label','Task state');for(const state of TASK_STATES){const option=new Option(state,state);if(state==='UNBLOCKED'&&task.state!=='UNBLOCKED')option.disabled=true;select.append(option);}select.value=task.state;select.addEventListener('change',()=>void setTaskState(task,select.value,select));box.append(select);return box;}
402
+ function taskStateField(task){const box=element('div','detail-group');box.append(element('p','detail-label',text('State')));const select=document.createElement('select');select.className='job-filter task-state-select';select.setAttribute('aria-label','Task state');for(const state of TASK_STATES)select.append(new Option(state,state));select.value=task.state;select.addEventListener('change',()=>void setTaskState(task,select.value,select));box.append(select);return box;}
403
403
  function manualOverrideField(task){const rows=Array.isArray(task.manualStateOverrides)?task.manualStateOverrides:[];const last=rows.at(-1);return field('Manual override',last?last.from+' → '+last.to+' · '+relativeTime(last.at):'—');}
404
404
  function renderTaskDetail(task){taskDetailRoot.replaceChildren();if(!task){taskDetailRoot.append(element('p','empty',text('Select a task to inspect execution details.')));return;}const routing=task.routing||{};const groups=element('div','detail-groups');groups.append(field('Title',task.title||'—'),field('Task ID',task.id),field('Kind',task.kind||'work'),taskStateField(task),manualOverrideField(task),field('Planning decision',task.planningDecisionId||'—'),field('Planning source',task.planningSource||'—'),field('Planning round',task.planningRound===undefined?'—':String(task.planningRound)),field('Project / agent',value(task.projectName)+' / '+value(task.agentName)),field('Parent task',task.parentTaskId||'root'),field('Adapter',task.adapter),field('Provider / model',value(routing.provider)+' / '+value(routing.model)),field('Reasoning / profile',value(routing.reasoning||routing.profile)),field('Attempt',attemptBudget(task)),field('Dependencies',Array.isArray(task.dependencies)&&task.dependencies.length?task.dependencies.join(', '):'none'),taskCommentField(task),chatField(task));const job=selected();if(task.reviewHeadSha&&job)groups.append(commitField('HEAD commit',job,task.reviewHeadSha));const graph=agentGraphField(task);if(graph)groups.append(graph);if(task.plannerResult)groups.append(field('Objective status',task.plannerResult.objective_status),markdownField('Assessment',task.plannerResult.assessment));if(task.error)groups.append(field('Error',task.error));taskDetailRoot.append(groups);}
405
405
  function reviewStatus(job){const review=job?.review;if(!review)return 'Not started';if(review.outcome==='approved'&&review.headSha===job.headSha)return 'MERGE READY · approved';const stale=Array.isArray(job?.tasks)&&job.tasks.some(task=>task.agentName==='reviewer'&&task.reviewHeadSha&&job.headSha&&task.reviewHeadSha!==job.headSha);if(stale)return 'STALE · awaiting review';return String(review.outcome||'pending').replace('_',' ').toUpperCase();}
@@ -13,7 +13,7 @@ export async function resumeInteractiveTask(store, jobId, taskId, invocationId,
13
13
  invocation.status !== decision || !invocation.decidedAt)
14
14
  return job;
15
15
  // Both state transitions and retry eligibility are owned by task-lifecycle.mmd.
16
- if (!tryTransitionTaskEvent(task, 'task.unblocked').accepted) {
16
+ if (!tryTransitionTaskEvent(task, 'task.unblocked', { autonomousJob: false }).accepted) {
17
17
  throw new Error('Interactive Task unblock rejected by canonical lifecycle Graph');
18
18
  }
19
19
  applyTaskUnblockRetrySnapshot(task);
@@ -14,7 +14,7 @@ import { nextScheduleRun, scheduleBucket } from './scheduler.js';
14
14
  * not lifecycle storage. An explicit one-time import must use RelayStore directly.
15
15
  */
16
16
  const TASK_STATE_ORDER = {
17
- DRAFT: 0, QUEUED: 1, UNBLOCKED: 2, RUNNING: 3, BLOCKED: 4,
17
+ DRAFT: 0, QUEUED: 1, RUNNING: 2, BLOCKED: 3,
18
18
  SUCCEEDED: 5, FAILED: 5, CANCELLED: 5,
19
19
  };
20
20
  export class LocalAuthorityDurableStore {
@@ -8,6 +8,7 @@ import { samePlannerChildDefinition } from './planner-child.js';
8
8
  import { dispatchJobLifecycleSync } from './workflow/lifecycle.js';
9
9
  import { withExecutionBoundary } from './reconciler-runtime.js';
10
10
  import { storySizeTimeoutMs } from './types.js';
11
+ import { tryTransitionTaskEvent } from './scheduler.js';
11
12
  /** Activate queued planner work inside the same authoritative SQLite mutation. */
12
13
  export function activateCommittedPlannerWork(job) {
13
14
  if (job.state !== 'DRAFT' || !job.tasks.some(item => item.state === 'QUEUED'))
@@ -80,7 +81,7 @@ async function finishPlanner(ports, jobId, planId, executionId, error, raw) {
80
81
  return;
81
82
  const attempt = fresh.attempt ?? 0;
82
83
  const maxAttempts = fresh.maxAttempts ?? (fresh.manualPlanning ? 1 : 3);
83
- retry = attempt < maxAttempts;
84
+ retry = attempt < maxAttempts && !message.startsWith('Planner JSON correction failed after one retry:');
84
85
  fresh.status = retry ? 'PLANNING' : 'FAILED';
85
86
  fresh.retryFeedback = message;
86
87
  fresh.error = message;
@@ -167,6 +168,17 @@ async function finishPlanner(ports, jobId, planId, executionId, error, raw) {
167
168
  else if (!samePlannerChildDefinition(existing, child))
168
169
  throw new Error(`Planner child ${child.id} already exists with a different definition`);
169
170
  }
171
+ const consumedUnblocks = new Set(durablePlan.trigger.source === 'unblock' ? durablePlan.trigger.taskIds ?? [] : []);
172
+ for (const task of current.tasks) {
173
+ if (!consumedUnblocks.has(task.id) || !task.unblock || task.state === 'CANCELLED' || durablePlan.generatedTaskIds.includes(task.id))
174
+ continue;
175
+ if (tryTransitionTaskEvent(task, 'cancel').accepted) {
176
+ task.error = `Superseded by successful unblock plan ${durablePlan.id}`;
177
+ task.leaseOwner = null;
178
+ task.leaseExpiresAt = null;
179
+ task.executionId = null;
180
+ }
181
+ }
170
182
  // Planner children are committed atomically with Job activation.
171
183
  // Otherwise the DRAFT Job is invisible to the ACTIVE-only dispatcher.
172
184
  activateCommittedPlannerWork(current);
@@ -199,6 +211,15 @@ async function finishPlanner(ports, jobId, planId, executionId, error, raw) {
199
211
  job.updatedAt = now;
200
212
  await ports.store.saveJob(job);
201
213
  }
214
+ const consumedUnblocks = new Set(plan.trigger.source === 'unblock' ? plan.trigger.taskIds ?? [] : []);
215
+ for (const task of job.tasks) {
216
+ if (!consumedUnblocks.has(task.id) || !task.unblock || task.state === 'CANCELLED' || plan.generatedTaskIds.includes(task.id))
217
+ continue;
218
+ if (tryTransitionTaskEvent(task, 'cancel').accepted) {
219
+ task.error = `Superseded by successful unblock plan ${plan.id}`;
220
+ await ports.store.saveTask(task);
221
+ }
222
+ }
202
223
  for (const child of children)
203
224
  if (!job.tasks.some(item => item.id === child.id)) {
204
225
  const spec = result.next_tasks.find(item => item.id === child.id);
package/dist/planner.js CHANGED
@@ -21,8 +21,8 @@ export function semanticTaskTree(tasks) {
21
21
  };
22
22
  }
23
23
  function plannerTrigger(job, manual = false) {
24
- const consumed = new Set((job.plans ?? []).flatMap(plan => plan.trigger.source === 'unblock' ? plan.trigger.taskIds ?? [] : []));
25
- const unblocked = job.tasks.filter(task => task.state === 'UNBLOCKED' && task.unblock && !consumed.has(task.id));
24
+ const consumed = new Set((job.plans ?? []).filter(plan => plan.status === 'SUCCEEDED').flatMap(plan => plan.trigger.source === 'unblock' ? plan.trigger.taskIds ?? [] : []));
25
+ const unblocked = job.tasks.filter(task => ['DRAFT', 'QUEUED', 'UNBLOCKED'].includes(task.state) && task.unblock && !consumed.has(task.id));
26
26
  const hasUnblock = unblocked.length > 0 || Boolean(job.changeCycle && !job.changeCycle.consumedByPlannerTaskId);
27
27
  const decision = dispatchPlannerTrigger({
28
28
  manualPlan: manual,
@@ -502,17 +502,14 @@ export class AgentObjectivePlanner {
502
502
  this.agentFile = agentFile;
503
503
  }
504
504
  async plan(context, signal) {
505
- const resolvedPlanner = context.executionContext?.agents.find(agent => agent.name === 'planner')?.path;
506
505
  const effectivePlanner = context.executionContext ? await resolveAgentMarkdown('planner', BUNDLED_AGENTS_ROOT, context.executionContext.project.path) : undefined;
507
506
  const instructions = effectivePlanner?.content ?? await readFile(this.agentFile, 'utf8');
508
507
  const contract = await readFile(DEFAULT_TASK_JOB_CONTRACT, 'utf8');
509
508
  const resolvedContext = context.executionContext ? executionContextPrompt(context.executionContext) + '\n\n' : '';
510
509
  const prompt = (effectivePlanner ? `[Effective planner contract SHA-256: ${effectivePlanner.hash}]\n\n` : '') + instructions.trim() + '\n\n## Shared Task / Job Contract\n\n' + contract.trim() + '\n\n' + resolvedContext + '## Durable planner input\n\n' + plannerInput(context);
511
- return await new Promise((resolve, reject) => {
512
- const child = spawnOwnedProcess(this.command, ['exec', '--json', '--', prompt], { invocation: 'planner.agent', signal, correlation: { jobId: context.job.id }, spawn: { cwd: context.executionContext?.project.path, stdio: ['ignore', 'pipe', 'pipe'] } });
513
- let stdoutBuffer = '';
514
- let finalMessage = '';
515
- let stderrTail = '';
510
+ const runAgent = (input) => new Promise((resolve, reject) => {
511
+ const child = spawnOwnedProcess(this.command, ['exec', '--json', '--', input], { invocation: 'planner.agent', signal, correlation: { jobId: context.job.id }, spawn: { cwd: context.executionContext?.project.path, stdio: ['ignore', 'pipe', 'pipe'] } });
512
+ let stdoutBuffer = '', finalMessage = '', stderrTail = '';
516
513
  const consume = (line) => {
517
514
  const message = parsePlannerAgentMessage(line);
518
515
  if (message)
@@ -538,13 +535,23 @@ export class AgentObjectivePlanner {
538
535
  reject(new Error('Planner agent returned no final message'));
539
536
  return;
540
537
  }
541
- try {
542
- resolve(parsePlannerOutput(finalMessage));
543
- }
544
- catch (error) {
545
- reject(error);
546
- }
538
+ resolve(finalMessage);
547
539
  });
548
540
  });
541
+ const first = await runAgent(prompt);
542
+ try {
543
+ return parsePlannerOutput(first);
544
+ }
545
+ catch (error) {
546
+ if (signal.aborted)
547
+ throw error;
548
+ const correction = `${prompt}\n\n## Format correction (one retry only)\nYour previous final response was not valid Planner JSON. Error: ${error instanceof Error ? error.message : String(error)}\nPrevious response (possibly truncated):\n${first.slice(0, 12000)}\nReturn exactly one valid JSON object conforming to the Planner contract. No prose, no second JSON object.`;
549
+ try {
550
+ return parsePlannerOutput(await runAgent(correction));
551
+ }
552
+ catch (retryError) {
553
+ throw new Error(`Planner JSON correction failed after one retry: ${retryError instanceof Error ? retryError.message : String(retryError)}`);
554
+ }
555
+ }
549
556
  }
550
557
  }
@@ -344,7 +344,7 @@ export class ProjectedDurableStore {
344
344
  tasks.push(structuredClone(task));
345
345
  job.tasks = tasks;
346
346
  const value = structuredClone(task);
347
- const terminal = ['SUCCEEDED', 'FAILED', 'BLOCKED', 'UNBLOCKED', 'CANCELLED'].includes(task.state);
347
+ const terminal = ['SUCCEEDED', 'FAILED', 'BLOCKED', 'CANCELLED'].includes(task.state);
348
348
  await this.relay.saveJobWithProjection(job, {
349
349
  idempotencyKey: `save_task:${task.jobId}:${task.id}`,
350
350
  coalesceKey: taskKey(task),
@@ -12,7 +12,7 @@ export async function cancelActiveTasks(store, jobId, reason, cancelPlanner, can
12
12
  plan.updatedAt = new Date().toISOString();
13
13
  await store.savePlan(plan);
14
14
  }
15
- for (const task of job.tasks.filter(item => item.kind !== 'planner' && !['SUCCEEDED', 'FAILED', 'UNBLOCKED', 'CANCELLED'].includes(item.state))) {
15
+ for (const task of job.tasks.filter(item => item.kind !== 'planner' && !['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(item.state))) {
16
16
  cancelExecutionGeneration(job.id, task.id, task.executionId ?? task.executionHistory?.at(-1)?.id);
17
17
  cancelPlanner(task.id);
18
18
  await applyTaskCancelAction(task, reason);
@@ -2,6 +2,7 @@ import { preserveTaskComments } from './task-comments.js';
2
2
  import { DatabaseSync } from 'node:sqlite';
3
3
  import { dirname } from 'node:path';
4
4
  import { mkdirSync } from 'node:fs';
5
+ import { normalizeLegacyTaskLifecycle } from './types.js';
5
6
  import { recordPublicationInDatabase, loadPublicationFromDatabase, listPublicationEventsFromDatabase } from './publication-store.js';
6
7
  import { observability } from './observability.js';
7
8
  import { createJobIfAbsent } from './relay-create.js';
@@ -140,11 +141,15 @@ export class SQLiteRelayStore {
140
141
  async loadJob(jobId) {
141
142
  this.assertScope(jobId);
142
143
  const row = this.db.prepare('SELECT json FROM job_snapshot WHERE job_id=?').get(jobId);
143
- return row?.json ? normalizeLegacyJobPrNumber(JSON.parse(row.json)) : null;
144
+ if (!row?.json)
145
+ return null;
146
+ const job = normalizeLegacyJobPrNumber(JSON.parse(row.json));
147
+ job.tasks = job.tasks.map(task => normalizeLegacyTaskLifecycle(task, job.executionMode));
148
+ return job;
144
149
  }
145
150
  async listJobs() {
146
151
  const rows = this.db.prepare(this.scopeJobId ? 'SELECT json FROM job_snapshot WHERE job_id=? ORDER BY job_id' : 'SELECT json FROM job_snapshot ORDER BY job_id').all(...(this.scopeJobId ? [this.scopeJobId] : []));
147
- return rows.map(row => normalizeLegacyJobPrNumber(JSON.parse(row.json)));
152
+ return rows.map(row => { const job = normalizeLegacyJobPrNumber(JSON.parse(row.json)); job.tasks = job.tasks.map(task => normalizeLegacyTaskLifecycle(task, job.executionMode)); return job; });
148
153
  }
149
154
  async saveJob(job) {
150
155
  if (!this.scopeJobId) {
@@ -176,6 +181,7 @@ export class SQLiteRelayStore {
176
181
  if (!row?.json)
177
182
  throw new Error(`Local authoritative Job ${jobId} not found`);
178
183
  const current = normalizeLegacyJobPrNumber(JSON.parse(row.json));
184
+ current.tasks = current.tasks.map(task => normalizeLegacyTaskLifecycle(task, current.executionMode));
179
185
  const before = JSON.stringify(current);
180
186
  const next = mutation(current);
181
187
  if (next.id !== jobId)
@@ -7,7 +7,7 @@ export function implementationTasks(job) {
7
7
  && task.agentName !== 'reviewer'
8
8
  && task.agentName !== 'troubleshooter'
9
9
  && !isSuperseded(task)
10
- && task.state !== 'UNBLOCKED');
10
+ && task.state !== 'DRAFT');
11
11
  }
12
12
  export function implementationComplete(job) {
13
13
  const tasks = implementationTasks(job);
package/dist/store.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { normalizeLegacyTaskLifecycle } from './types.js';
1
2
  import { parseAgent, parseJob, parsePlan, parseTask, renderAgentComment, renderJobComment, renderPlanComment, renderTaskComment, AGENT_MARKER, JOB_MARKER, PLAN_MARKER, TASK_MARKER } from './markers.js';
2
3
  import { DurableDefinitionConflictError, isTaskForJob, planDefinitionDivergence, samePlanDefinition, sameTaskDefinition, taskDefinitionDivergence } from './store-task-definition.js';
3
4
  import { parsePackagePublicationMarker } from './github-webhook.js';
@@ -91,7 +92,7 @@ export class InMemoryStore {
91
92
  }
92
93
  }
93
94
  async load(jobId) { const job = await this.findJob(jobId); if (!job)
94
- throw new Error(`Job ${jobId} not found`); job.plans = this.plansForJob(jobId); job.tasks = this.tasksForJob(jobId); return job; }
95
+ throw new Error(`Job ${jobId} not found`); job.plans = this.plansForJob(jobId); job.tasks = this.tasksForJob(jobId).map(task => normalizeLegacyTaskLifecycle(task, job.executionMode)); return job; }
95
96
  async saveJob(job) { this.values.set(`${JOB_MARKER}:${job.id}`, renderJobComment(job)); }
96
97
  async appendPlan(plan) { const key = `${PLAN_MARKER}:${plan.jobId}:${plan.id}`; const existing = this.values.get(key); if (existing) {
97
98
  const current = parsePlan(existing);
@@ -40,9 +40,12 @@ function applyTaskRetrySnapshot(task, policy = {}) {
40
40
  }
41
41
  /** Fixed-job authoritative unblock continuation, applied inside one Job snapshot mutation. */
42
42
  export function applyTaskUnblockRetrySnapshot(task) {
43
- if (!tryTransitionTaskEvent(task, 'retry').accepted)
43
+ if (task.state === 'BLOCKED' && !tryTransitionTaskEvent(task, 'task.unblocked', { autonomousJob: false }).accepted)
44
44
  throw lifecycleRejection(task, 'retryable after unblock');
45
- task.maxAttempts = extendRetryBudget(task);
45
+ if (!['QUEUED', 'DRAFT'].includes(task.state))
46
+ throw lifecycleRejection(task, 'retryable after unblock');
47
+ task.attempt = 0;
48
+ task.maxAttempts = Math.max(3, task.maxAttempts);
46
49
  clearExecution(task);
47
50
  delete task.blockedBy;
48
51
  }
@@ -58,8 +61,12 @@ export async function applyTaskRetryAction(task, policy = {}) {
58
61
  }
59
62
  /** Fixed-job authoritative unblock continuation: legality is owned by the lifecycle graph. */
60
63
  export async function applyTaskUnblockRetryAction(task) {
61
- await transitionTaskAction(task, 'retry', 'retryable after unblock');
62
- task.maxAttempts = extendRetryBudget(task);
64
+ if (task.state === 'BLOCKED')
65
+ await transitionTaskAction(task, 'task.unblocked', 'retryable after unblock', { autonomousJob: false });
66
+ if (!['QUEUED', 'DRAFT'].includes(task.state))
67
+ throw lifecycleRejection(task, 'retryable after unblock');
68
+ task.attempt = 0;
69
+ task.maxAttempts = Math.max(3, task.maxAttempts);
63
70
  clearExecution(task);
64
71
  delete task.blockedBy;
65
72
  return task;
@@ -13,7 +13,7 @@ export async function applyManagedTaskUpdate(store, jobId, taskId, proposed, exp
13
13
  throw new Error('Task update identity mismatch');
14
14
  if (current.updatedAt !== expectedUpdatedAt)
15
15
  throw new Error('Task changed since update preflight; refresh and retry');
16
- if (['RUNNING', 'UNBLOCKED', 'SUCCEEDED', 'CANCELLED'].includes(current.state) || current.executionId || current.leaseOwner)
16
+ if (['RUNNING', 'SUCCEEDED', 'CANCELLED'].includes(current.state) || current.executionId || current.leaseOwner)
17
17
  throw new Error('Task is not mutable while active or terminal');
18
18
  if (current.attempt > 0 && current.adapter !== proposed.adapter)
19
19
  throw new Error('Cannot replace adapter after an execution attempt');
@@ -32,7 +32,7 @@ function belongsToCurrentRun(job, task) {
32
32
  const slot = job.schedule?.currentRunSlot;
33
33
  return !slot || task.createdAt >= slot;
34
34
  }
35
- const ACTIVE_RECOVERY_STATES = new Set(['DRAFT', 'QUEUED', 'RUNNING', 'BLOCKED', 'UNBLOCKED']);
35
+ const ACTIVE_RECOVERY_STATES = new Set(['DRAFT', 'QUEUED', 'RUNNING', 'BLOCKED']);
36
36
  function recoveryTargetsTask(recovery, original) {
37
37
  if (recovery.agentName !== 'troubleshooter')
38
38
  return false;
package/dist/types.js CHANGED
@@ -3,5 +3,16 @@ export const STORY_SIZE_TIMEOUT_MS = { S: 300000, M: 900000, L: 2700000 };
3
3
  export function storySizeTimeoutMs(size) { return STORY_SIZE_TIMEOUT_MS[size]; }
4
4
  export function taskOverallTimeoutMs(size, reasoning) { return storySizeTimeoutMs(size) * (reasoning?.toLowerCase() === 'high' ? 2 : 1); }
5
5
  export function chatGptStartAckTimeoutMs(overallTimeoutMs) { return Math.min(600000, Math.max(120000, Math.floor(overallTimeoutMs / 3))); }
6
+ /** Normalize snapshots written before UNBLOCKED was removed from the lifecycle graph. */
7
+ export function normalizeLegacyTaskLifecycle(task, executionMode) {
8
+ const legacy = task;
9
+ if (legacy.state !== 'UNBLOCKED')
10
+ return task;
11
+ legacy.state = executionMode === 'autonomous' ? 'DRAFT' : 'QUEUED';
12
+ // Preserve the historical execution count when upgrading old snapshots.
13
+ task.maxAttempts = Math.max(3, task.maxAttempts ?? 0, task.attempt + 1);
14
+ task.unblock = task.unblock ? { ...task.unblock, event: 'task.unblocked' } : undefined;
15
+ return task;
16
+ }
6
17
  export function assertJobLifecycleTransition(from, to) { assertV2StateTransition(jobLifecycleV2.graph, from, to, 'job'); }
7
18
  export function assertTaskTransition(from, to) { assertV2StateTransition(taskLifecycleV2.graph, from, to, 'task'); }
package/dist/unblock.js CHANGED
@@ -26,8 +26,8 @@ function unblockFacts(job, tasks, currentHeadSha, reason) {
26
26
  return [...new Set(facts)];
27
27
  }
28
28
  export function unconsumedUnblockTrigger(job) {
29
- const consumed = new Set((job.plans ?? []).flatMap(plan => plan.trigger.source === 'unblock' ? plan.trigger.taskIds ?? [] : []));
30
- const tasks = job.tasks.filter(task => task.state === 'UNBLOCKED' && task.unblock && !consumed.has(task.id));
29
+ const consumed = new Set((job.plans ?? []).filter(plan => plan.status === 'SUCCEEDED').flatMap(plan => plan.trigger.source === 'unblock' ? plan.trigger.taskIds ?? [] : []));
30
+ const tasks = job.tasks.filter(task => task.unblock && !consumed.has(task.id) && ['DRAFT', 'QUEUED', 'UNBLOCKED'].includes(task.state));
31
31
  if (tasks.length === 0) {
32
32
  const legacy = job.changeCycle;
33
33
  if (!legacy || legacy.consumedByPlannerTaskId)
@@ -59,10 +59,10 @@ export async function unblockAutonomousTasks(options) {
59
59
  if (!task || !isEffectivelyBlocked(task))
60
60
  throw new Error(`Task ${requested.id} changed before authoritative unblock`);
61
61
  const baseline = baselineFor(task);
62
- if (!tryTransitionTaskEvent(task, 'task.unblocked').accepted)
62
+ if (!tryTransitionTaskEvent(task, 'task.unblocked', { autonomousJob: true }).accepted)
63
63
  throw new Error(`Task ${task.id} cannot be authoritatively unblocked`);
64
64
  task.blockedBaseline = baseline;
65
- task.unblock = { at: now, source: trigger.source, reason: trigger.reason, baseline, currentHeadSha: currentHeadSha ?? undefined, facts, blockerJobId: trigger.blockerJobId, resolution: trigger.resolution };
65
+ task.unblock = { event: 'task.unblocked', at: now, source: trigger.source, reason: trigger.reason, baseline, currentHeadSha: currentHeadSha ?? undefined, facts, blockerJobId: trigger.blockerJobId, resolution: trigger.resolution };
66
66
  task.updatedAt = now;
67
67
  delete task.blockedBy;
68
68
  }
@@ -98,16 +98,16 @@ export async function unblockTask(options) {
98
98
  const baseline = baselineFor(options.task);
99
99
  options.task.blockedBaseline = baseline;
100
100
  const facts = unblockFacts(options.job, [options.task], currentHeadSha, options.trigger.reason);
101
- options.task.unblock = { at: now, source: options.trigger.source, reason: options.trigger.reason, baseline, currentHeadSha: currentHeadSha ?? undefined, facts, blockerJobId: options.trigger.blockerJobId, resolution: options.trigger.resolution };
101
+ options.task.unblock = { event: 'task.unblocked', at: now, source: options.trigger.source, reason: options.trigger.reason, baseline, currentHeadSha: currentHeadSha ?? undefined, facts, blockerJobId: options.trigger.blockerJobId, resolution: options.trigger.resolution };
102
102
  const apply = (current) => {
103
103
  const task = current.tasks.find(item => item.id === options.task.id);
104
104
  if (!task || !isEffectivelyBlocked(task))
105
105
  throw new Error(`Task ${options.task.id} changed before authoritative unblock`);
106
- if (!tryTransitionTaskEvent(task, 'task.unblocked').accepted)
106
+ if (!tryTransitionTaskEvent(task, 'task.unblocked', { autonomousJob: false }).accepted)
107
107
  throw new Error(`Task ${task.id} cannot be authoritatively unblocked`);
108
108
  const baseline = baselineFor(task);
109
109
  task.blockedBaseline = baseline;
110
- task.unblock = { at: now, source: options.trigger.source, reason: options.trigger.reason, baseline, currentHeadSha: currentHeadSha ?? undefined, facts, blockerJobId: options.trigger.blockerJobId, resolution: options.trigger.resolution };
110
+ task.unblock = { event: 'task.unblocked', at: now, source: options.trigger.source, reason: options.trigger.reason, baseline, currentHeadSha: currentHeadSha ?? undefined, facts, blockerJobId: options.trigger.blockerJobId, resolution: options.trigger.resolution };
111
111
  applyTaskUnblockRetrySnapshot(task);
112
112
  task.updatedAt = now;
113
113
  delete task.blockedBy;
@@ -1,7 +1,7 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { GraphRuntime } from './graph-v2.js';
3
3
  import { WORKFLOW_EFFECT_NAMES, WORKFLOW_EVENT_NAMES, WORKFLOW_MUTATION_NAMES, } from './contracts.js';
4
- const TASK_STATES = ['DRAFT', 'QUEUED', 'RUNNING', 'BLOCKED', 'UNBLOCKED', 'SUCCEEDED', 'FAILED', 'CANCELLED'];
4
+ const TASK_STATES = ['DRAFT', 'QUEUED', 'RUNNING', 'BLOCKED', 'SUCCEEDED', 'FAILED', 'CANCELLED'];
5
5
  const JOB_STATES = ['DRAFT', 'ACTIVE', 'COMPLETED', 'CANCELLED'];
6
6
  const boolGuard = (name) => (facts) => facts.durableFacts[name] === true;
7
7
  function eventRegistry() {
@@ -27,6 +27,7 @@ export const taskLifecycleV2 = load('task-lifecycle', TASK_STATES, {
27
27
  recoverableExecution: boolGuard('recoverableExecution'),
28
28
  troubleshooterRecovery: boolGuard('troubleshooterRecovery'),
29
29
  retryAvailable: boolGuard('retryAvailable'),
30
+ autonomousJob: boolGuard('autonomousJob'),
30
31
  });
31
32
  export const jobLifecycleV2 = load('job-lifecycle', JOB_STATES, {
32
33
  notMerged: boolGuard('notMerged'),
@@ -52,9 +52,9 @@ export function plannerOrchestrationFacts(job, recoverablePendingPlan) {
52
52
  hasSeedWork: !((job.plans?.length ?? 0) === 0 && job.tasks.filter(task => task.kind !== 'planner').length === 0),
53
53
  noActivePlanner: !scopedPlans.some(plan => plan.status === 'PLANNING'),
54
54
  noBlockedWork: Boolean(unconsumedUnblockTrigger(job)) || !scopedTasks.some(task => task.kind !== 'planner' && isEffectivelyBlocked(task)),
55
- noActiveImplementation: !scopedTasks.some(task => task.kind !== 'planner' && ['DRAFT', 'QUEUED', 'RUNNING'].includes(task.state)),
55
+ noActiveImplementation: !scopedTasks.some(task => task.kind !== 'planner' && (['QUEUED', 'RUNNING'].includes(task.state) || (task.state === 'DRAFT' && !task.unblock))),
56
56
  resolutionOwnedByTroubleshooter: scopedTasks.some(task => task.kind !== 'planner' && task.resolution?.owner === 'troubleshooter' && ['active', 'waiting'].includes(task.resolution.state)),
57
- continuationMaterialized: !(latestObjective === 'in_progress' && latestId && !job.tasks.some(task => task.kind !== 'planner' && task.planningDecisionId === latestId)),
57
+ continuationMaterialized: Boolean(unconsumedUnblockTrigger(job)) || !(latestObjective === 'in_progress' && latestId && !job.tasks.some(task => task.kind !== 'planner' && task.planningDecisionId === latestId)),
58
58
  objectiveUnsatisfied: latestObjective !== 'satisfied',
59
59
  };
60
60
  }
@@ -33,6 +33,8 @@ export function reconcileTaskDependencies(job) {
33
33
  for (const task of scopedTasks) {
34
34
  if (task.kind === 'planner')
35
35
  continue;
36
+ if (task.state === 'DRAFT' && task.unblock)
37
+ continue;
36
38
  const ready = dependencyReady(task, tasks);
37
39
  const facts = {
38
40
  dependenciesSatisfied: task.dependencies.length > 0 && ready,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "2.0.101",
3
+ "version": "2.0.103",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {
@@ -4,7 +4,6 @@ draft["state: DRAFT"]
4
4
  queued["state: QUEUED"]
5
5
  running["state: RUNNING"]
6
6
  blocked["state: BLOCKED"]
7
- unblocked["state: UNBLOCKED"]
8
7
  succeeded["state: SUCCEEDED"]
9
8
  failed["state: FAILED"]
10
9
  cancelled["state: CANCELLED"]
@@ -76,6 +75,8 @@ completeClearError["action: mutation.clearError"]
76
75
  completeClearReason["action: mutation.clearFailureReason"]
77
76
  unblockRetryClearError["action: mutation.clearError"]
78
77
  unblockRetryClearReason["action: mutation.clearFailureReason"]
78
+ unblockDraftClearError["action: mutation.clearError"]
79
+ unblockDraftClearReason["action: mutation.clearFailureReason"]
79
80
  failedRetryClearError["action: mutation.clearError"]
80
81
  failedRetryClearReason["action: mutation.clearFailureReason"]
81
82
  recoverStartClearError["action: mutation.clearError"]
@@ -179,12 +180,15 @@ leaseFailRunningClearExecution --> failed
179
180
  running -->|on: task.cancelled| cancelled
180
181
  running -->|on: cancel| cancelled
181
182
 
182
- blocked -->|on: task.unblocked| unblocked
183
- blocked -->|on: cancel| cancelled
184
-
185
- unblocked -->|on: retry| unblockRetryClearError
183
+ autonomousJob["guard: autonomousJob"]
184
+ blocked -->|on: task.unblocked| autonomousJob
185
+ autonomousJob -->|true| unblockDraftClearError
186
+ unblockDraftClearError --> unblockDraftClearReason
187
+ unblockDraftClearReason --> draft
188
+ autonomousJob -->|false| unblockRetryClearError
186
189
  unblockRetryClearError --> unblockRetryClearReason
187
190
  unblockRetryClearReason --> queued
191
+ blocked -->|on: cancel| cancelled
188
192
 
189
193
  failed -->|on: queue| depSat
190
194
  failed -->|on: retry| failedRetryClearError