agents-relay 2.0.86 → 2.0.88

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/adapters.js CHANGED
@@ -274,7 +274,7 @@ async function findExistingReviewTurn(cli, sessionId, delivery, signal) {
274
274
  verified_by: 'existing-user-turn', user_message_id: typeof observation.user_message_id === 'string' ? observation.user_message_id : null };
275
275
  }
276
276
  async function runBrowserWorker(cli, configFile, signal, action = 'temporary-submit', delivery) {
277
- const session = parseWorkspaceJson(await browserWorkspaceCommand(cli, ['session', 'start'], undefined, signal));
277
+ const session = parseWorkspaceJson(await browserWorkspaceCommand(cli, ['session', 'start', '--workspace', CHATGPT_BROWSER_WORKSPACE], undefined, signal));
278
278
  const sessionId = session.session_id;
279
279
  if (typeof sessionId !== 'string' || !sessionId)
280
280
  throw new Error('browser-workspace session start returned no session_id');
@@ -332,7 +332,7 @@ function element(name,className,child){const node=document.createElement(name);i
332
332
  function yamlScalar(input){if(input===null)return 'null';if(typeof input==='boolean'||typeof input==='number')return String(input);return JSON.stringify(String(input));}
333
333
  function yamlDump(input,indent=0){const pad=' '.repeat(indent);if(Array.isArray(input)){if(!input.length)return pad+'[]';return input.map(item=>{if(item&&typeof item==='object'){return pad+'-\\n'+yamlDump(item,indent+2);}return pad+'- '+yamlScalar(item);}).join('\\n');}if(input&&typeof input==='object'){const entries=Object.entries(input);if(!entries.length)return pad+'{}';const lines=[];for(const [key,item] of entries){if(typeof item==='string'&&item.includes('\\n')){lines.push(pad+key+': |');for(const line of item.replace(/\\n$/,'').split('\\n'))lines.push(' '.repeat(indent+2)+line);continue;}if(item&&typeof item==='object'){lines.push(pad+key+':');lines.push(yamlDump(item,indent+2));}else{lines.push(pad+key+': '+yamlScalar(item));}}return lines.join('\\n');}return pad+yamlScalar(input);}
334
334
  function field(label,input){const box=element('div','detail-group');box.append(element('p','detail-label',text(label)),element('p','detail-value',text(input)));return box;}
335
- function markdownField(label,input){const box=element('div','detail-group');box.append(element('p','detail-label',text(label)),markdownValue(input));return box;}
335
+ function threadField(label,threadId){const box=element('div','detail-group');box.append(element('p','detail-label',text(label)));const id=String(threadId||'').trim();if(!/^[0-9a-f]{8}-[0-9a-f-]{27,}$/i.test(id)){box.append(element('p','detail-value',text(id||'—')));return box;}const link=document.createElement('a');link.className='detail-value';link.href='https://chatgpt.com/c/'+encodeURIComponent(id);link.target='implementation';link.rel='noopener noreferrer';link.textContent=id.slice(0,8);link.title=id;box.append(link);return box;}function markdownField(label,input){const box=element('div','detail-group');box.append(element('p','detail-label',text(label)),markdownValue(input));return box;}
336
336
  function rememberSelection(){if(selectedJob)sessionStorage.setItem('agents-relay:selected-job',selectedJob);else sessionStorage.removeItem('agents-relay:selected-job');if(selectedTaskId)sessionStorage.setItem('agents-relay:selected-task',selectedTaskId);else sessionStorage.removeItem('agents-relay:selected-task');}
337
337
  function syncMobileJobsView(){const detail=mobileJobs.matches&&Boolean(selectedJob)&&location.hash.startsWith('#/jobs/');document.body.dataset.mobileJobsView=detail?'detail':'list';}
338
338
  function shortManagedId(id,repository,kind){const parts=String(repository||'').split('/');if(parts.length!==2)return String(id||'');const prefix=kind+'-'+parts[0].length+'-'+parts[0]+'-'+parts[1]+'-';const value=String(id||'');const suffix=value.startsWith(prefix)?value.slice(prefix.length):'';return /^[0-9]+$/.test(suffix)?'#'+suffix:value;}function jobSelectionKey(job){return job?String(job.repository||'')+'|'+String(job.prNumber||'')+'|'+String(job.id||''):'';}
@@ -403,7 +403,7 @@ function taskStateField(task){const box=element('div','detail-group');box.append
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();}
406
- function renderSelectedJob(){jobDetail.replaceChildren();const job=selected();if(!job){selectedJobTitle.textContent='';jobDetail.append(element('div','empty',text('No durable jobs discovered.')));buildTree([]);renderTaskDetail(null);return;}selectedJobTitle.textContent=value(job.title||job.id);if(jobSelectionKey(job)!==selectedJob){selectedJob=jobSelectionKey(job);selectedTaskId=null;eventTaskId=null;clearEvents();rememberSelection();}const summary=element('div','job-summary');summary.append(field('PR state',job.githubState||'UNKNOWN'),jobStateField(job),field('Runtime status',job.runtimeStatus||'IDLE'),field('Execution mode',job.executionMode||'fixed'),field('Cadence',cadence(job)),field('Review',reviewStatus(job)),field('Creator thread',job.creatorThreadId||'—'),field('Implementation thread',job.implementationThreadId||'—'),field('Reviewer thread',job.reviewerThreadId||'—'),commitField('Review head SHA',job,job.review?.headSha||job.headSha||''),prField(job),field('Repository',job.repository||'—'),field('Last activity',relativeTime(job.updatedAt)),field('Task states',compactCounts(job.tasks)),chatShortcutField(job));jobDetail.append(summary);const selectedTask=(job.tasks||[]).find(task=>task.id===selectedTaskId)||null;if(selectedTaskId&&!selectedTask){selectedTaskId=null;eventTaskId=null;clearEvents();rememberSelection();}buildTree(job.tasks||[]);renderTaskDetail(selectedTask);renderEventScope();}
406
+ function renderSelectedJob(){jobDetail.replaceChildren();const job=selected();if(!job){selectedJobTitle.textContent='';jobDetail.append(element('div','empty',text('No durable jobs discovered.')));buildTree([]);renderTaskDetail(null);return;}selectedJobTitle.textContent=value(job.title||job.id);if(jobSelectionKey(job)!==selectedJob){selectedJob=jobSelectionKey(job);selectedTaskId=null;eventTaskId=null;clearEvents();rememberSelection();}const summary=element('div','job-summary');summary.append(field('PR state',job.githubState||'UNKNOWN'),jobStateField(job),field('Runtime status',job.runtimeStatus||'IDLE'),field('Execution mode',job.executionMode||'fixed'),field('Cadence',cadence(job)),field('Review',reviewStatus(job)),field('Creator thread',job.creatorThreadId||'—'),threadField('Implementation thread',job.implementationThreadId),threadField('Reviewer thread',job.reviewerThreadId),commitField('Review head SHA',job,job.review?.headSha||job.headSha||''),prField(job),field('Repository',job.repository||'—'),field('Last activity',relativeTime(job.updatedAt)),field('Task states',compactCounts(job.tasks)),chatShortcutField(job));jobDetail.append(summary);const selectedTask=(job.tasks||[]).find(task=>task.id===selectedTaskId)||null;if(selectedTaskId&&!selectedTask){selectedTaskId=null;eventTaskId=null;clearEvents();rememberSelection();}buildTree(job.tasks||[]);renderTaskDetail(selectedTask);renderEventScope();}
407
407
  function renderAll(){jobs.sort(compareJobs);syncFilters();renderJobList();renderSelectedJob();syncMobileJobsView();}
408
408
  function isCompleteUnmerged(job){return job.runtimeStatus==='MERGE_PENDING';}
409
409
  function isTerminalJob(job){return ['COMPLETED','CANCELLED'].includes(job.state)||['MERGED','CLOSED'].includes(job.githubState);}
@@ -0,0 +1,10 @@
1
+ /** Validate freshly proposed planner titles; legacy persisted Plans are not re-parsed. */
2
+ export function requiredPlannerTaskTitle(value, index) {
3
+ if (typeof value !== 'string' || !value.trim())
4
+ throw new Error(`Planner next_tasks[${index}].title is required`);
5
+ const title = value.trim();
6
+ const words = title.split(/\s+/);
7
+ if (words.length < 5 || words.length > 8)
8
+ throw new Error(`Planner next_tasks[${index}].title must contain 5–8 words (got ${words.length})`);
9
+ return title;
10
+ }
package/dist/planner.js CHANGED
@@ -11,6 +11,7 @@ import { dispatchPlannerTrigger } from './workflow/planner-trigger-v2.js';
11
11
  import { createTaskEntity } from './workflow/entity-creation.js';
12
12
  import { prepareReviewerThreadDelivery, chatgptThreadRequirements } from './reviewer-thread.js';
13
13
  import { spawnOwnedProcess } from './owned-process.js';
14
+ import { requiredPlannerTaskTitle } from './planner-task-title.js';
14
15
  export function semanticTaskTree(tasks) {
15
16
  const work = tasks.filter(task => task.kind !== 'planner');
16
17
  const ids = new Set(work.map(task => task.id));
@@ -309,7 +310,7 @@ export function parsePlannerResult(value) {
309
310
  if (typeof timeoutMs !== 'number' || !Number.isFinite(timeoutMs) || timeoutMs < 0)
310
311
  throw new Error(`Planner next_tasks[${index}].timeoutMs is invalid`);
311
312
  return {
312
- id, title: optionalString(task.title, `next_tasks[${index}].title`), parentTaskId, input: task.input, priority, storySize,
313
+ id, title: requiredPlannerTaskTitle(task.title, index), parentTaskId, input: task.input, priority, storySize,
313
314
  projectName: optionalString(task.projectName, 'projectName'), agentName: optionalString(task.agentName, 'agentName'), agentGraph, review,
314
315
  dependencies: stringArray(task.dependencies, 'dependencies'), capabilities: stringArray(task.capabilities, 'capabilities'), workerRequirements: requirements,
315
316
  testInjection: parseTestInjection(task.testInjection ?? task['--test-injection']) ?? optionalString(task.testInjection ?? task['--test-injection'], `next_tasks[${index}].--test-injection`),
@@ -8,7 +8,7 @@ import { dependencyReady, schedule, transitionTaskEvent } from './scheduler.js';
8
8
  import { applyTaskRetryAction } from './task-actions.js';
9
9
  import { LaunchFailure, StartAckTimeout } from './adapters.js';
10
10
  import { eventFor } from './events.js';
11
- import { createPlanRound, materializePlannerChild, pendingPlan, validatePlannerResolutionMaterialization } from './planner.js';
11
+ import { createPlanRound, materializePlannerChild, pendingPlan } from './planner.js';
12
12
  import { retryPendingPlannerUnblocks } from './planner-runtime.js';
13
13
  import { dispatchPlannerOrchestration, plannerOrchestrationFacts } from './workflow/planner-orchestration-v2.js';
14
14
  import { resolveExecutionContext } from './execution-context.js';
@@ -18,7 +18,7 @@ import { executeManualPlan } from './manual-plan.js';
18
18
  import { executionEventSource, withExecutionBoundary } from './reconciler-runtime.js';
19
19
  export { executionEventSource } from './reconciler-runtime.js';
20
20
  import { CallbackWorkflowEffectHandler } from './workflow/effects.js';
21
- import { exhaustedTroubleshooterRecoveries, pendingTroubleshooterTasks } from './troubleshooter.js';
21
+ import { exhaustedTroubleshooterRecoveries, pendingTroubleshooterTasks, pendingTroubleshooterReopens, refreshTroubleshooterInput } from './troubleshooter.js';
22
22
  import { taskUsesCanonicalProjectRun } from './workspace.js';
23
23
  import { cleanupMergedJobWorktree } from './job-worktree-cleanup.js';
24
24
  import { jobHasActiveExecution } from './job-execution-slot.js';
@@ -106,6 +106,12 @@ export class Reconciler extends ReconcilerReviewBase {
106
106
  detach: (promise, currentJobId, planId, context) => this.detach(promise, currentJobId, planId, context),
107
107
  reconcile: currentJobId => this.reconcile(currentJobId),
108
108
  }, jobId);
109
+ for (const { original, recovery } of pendingTroubleshooterReopens(await this.store.load(jobId))) {
110
+ await transitionTaskEvent(recovery, 'recovery.reopen', { troubleshooterRecovery: recovery.agentName === 'troubleshooter' });
111
+ refreshTroubleshooterInput(job, original, recovery);
112
+ await this.store.saveTask(recovery);
113
+ await this.emit(eventFor(job.id, recovery.id, original.id, 'troubleshooter.reopened', 'queued', `Reusing Troubleshooter ${recovery.id} for Task ${original.id} attempt ${original.attempt}`, 'orchestrator', { originalTaskId: original.id, attempt: original.attempt }));
114
+ }
109
115
  for (const recovery of pendingTroubleshooterTasks(await this.store.load(jobId))) {
110
116
  await this.store.appendTask(recovery);
111
117
  await this.emit(eventFor(job.id, recovery.id, null, "troubleshooter.created", "queued", "Troubleshooter task " + recovery.id + " created for failed/blocked work", "orchestrator", { agentName: "troubleshooter" }));
@@ -269,7 +275,8 @@ export class Reconciler extends ReconcilerReviewBase {
269
275
  async recoverPlannerChildren(job) {
270
276
  for (const plan of (job.plans ?? []).filter(item => item.status === 'SUCCEEDED' && item.plannerResult)) {
271
277
  const executionContext = await this.plannerContext(job, plan);
272
- validatePlannerResolutionMaterialization(job.tasks, plan.plannerResult.next_tasks);
278
+ // Historical plans were validated on commit. Rechecking against later Task failures
279
+ // blocks recovery before the Troubleshooter graph can run.
273
280
  const children = plan.plannerResult.next_tasks.map(spec => materializePlannerChild(job, plan, spec, executionContext));
274
281
  for (const child of children) {
275
282
  const existing = job.tasks.find(item => item.id === child.id);
@@ -5,9 +5,9 @@ function slug(value) {
5
5
  return value.replace(/[^A-Za-z0-9_-]+/g, '-').replace(/^-+|-+$/g, '').slice(0, 72) || 'task';
6
6
  }
7
7
  export function troubleshooterTaskId(task) {
8
- return `troubleshoot-${slug(task.id)}-a${task.attempt}`;
8
+ return `troubleshoot-${slug(task.id)}`;
9
9
  }
10
- function failureInput(job, task) {
10
+ function failureInput(job, task, recoveryId = troubleshooterTaskId(task)) {
11
11
  return `Recover the failed/blocked task below and make it move again if technically safe.
12
12
 
13
13
  Original repository: ${job.repository}
@@ -26,23 +26,51 @@ Retrieve this task's lifecycle event history before classifying the root cause o
26
26
  GET http://127.0.0.1:8787/api/events?job=${encodeURIComponent(job.id)}&task=${encodeURIComponent(task.id)}&limit=200
27
27
  Inspect event order, type/status, source, data/phase, duplicate events, missing expected lifecycle events, late events, and execution identifiers. If the event stream points to delivery/runtime failure, inspect the corresponding Relay/worker/tool logs too. Do not treat the final task.error string as the complete root cause.
28
28
 
29
- Follow the selected Troubleshooter agent contract. Diagnose root cause from durable task state + event history + relevant runtime logs before retry. The final task.error string alone is never sufficient. Classify the evidence as model/provider/runtime, worker/tool/infrastructure, semantic task failure, or timeout/unknown. Select exactly one graph-enabled resolution path: exact_original_retry, same_job_dependency, blocker_job_dependency (with merge or verified mode), escalation_gate, or cancel_supersede. Only model/provider/runtime evidence may call the narrow model re-decision action; that action gathers fresh usage, reloads current policy, updates this exact Task's routing, and does not retry it. Troubleshooter must then reactivate/retry this exact original task and verify progress. Use the dedicated agents-relay task recover path with --task-id ${task.id} and --recovery-task-id ${troubleshooterTaskId(task)}; never use ordinary task retry from a Troubleshooter because manual retry intentionally supersedes active recovery. Do not manually choose a provider/model or create a replacement task; no replacement_task action exists. If the blocker is a shared worker/runtime/tool defect, create and directly complete a P0/self-job in the owning repository, including required release/deploy verification. If diagnosis suggests a genuine human/external blocker, do not publish task.blocked directly and do not implement the result branch procedurally. Call agents-relay escalation with the original intent, blocker, and relevant context. Pass any caller-defined notification plan through --on-needs-user/--on-needs-user-file; do not hard-code a channel. The canonical escalation graph invokes Neo escalation and owns the resolved/failed/needs_user branch; follow its selected outcome. Retry this exact original task only after the blocker is removed, reconcile the original job, and verify durable progress.`;
29
+ Follow the selected Troubleshooter agent contract. Diagnose root cause from durable task state + event history + relevant runtime logs before retry. The final task.error string alone is never sufficient. Classify the evidence as model/provider/runtime, worker/tool/infrastructure, semantic task failure, or timeout/unknown. Select exactly one graph-enabled resolution path: exact_original_retry, same_job_dependency, blocker_job_dependency (with merge or verified mode), escalation_gate, or cancel_supersede. Only model/provider/runtime evidence may call the narrow model re-decision action; that action gathers fresh usage, reloads current policy, updates this exact Task's routing, and does not retry it. Troubleshooter must then reactivate/retry this exact original task and verify progress. Use the dedicated agents-relay task recover path with --task-id ${task.id} and --recovery-task-id ${recoveryId}; never use ordinary task retry from a Troubleshooter because manual retry intentionally supersedes active recovery. Do not manually choose a provider/model or create a replacement task; no replacement_task action exists. If the blocker is a shared worker/runtime/tool defect, create and directly complete a P0/self-job in the owning repository, including required release/deploy verification. If diagnosis suggests a genuine human/external blocker, do not publish task.blocked directly and do not implement the result branch procedurally. Call agents-relay escalation with the original intent, blocker, and relevant context. Pass any caller-defined notification plan through --on-needs-user/--on-needs-user-file; do not hard-code a channel. The canonical escalation graph invokes Neo escalation and owns the resolved/failed/needs_user branch; follow its selected outcome. Retry this exact original task only after the blocker is removed, reconcile the original job, and verify durable progress.`;
30
30
  }
31
31
  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']);
35
+ const ACTIVE_RECOVERY_STATES = new Set(['DRAFT', 'QUEUED', 'RUNNING', 'BLOCKED', 'UNBLOCKED']);
36
36
  function recoveryTargetsTask(recovery, original) {
37
37
  if (recovery.agentName !== 'troubleshooter')
38
38
  return false;
39
39
  if (recovery.parentTaskId)
40
40
  return recovery.parentTaskId === original.id;
41
- return recovery.id.startsWith(`troubleshoot-${slug(original.id)}-a`);
41
+ return recovery.id === troubleshooterTaskId(original) || recovery.id.startsWith(`troubleshoot-${slug(original.id)}-a`);
42
42
  }
43
43
  function hasRecoveryBarrier(job, original) {
44
- return job.tasks.some(task => recoveryTargetsTask(task, original)
45
- && (ACTIVE_RECOVERY_STATES.has(task.state) || ['FAILED', 'CANCELLED'].includes(task.state)));
44
+ // Exactly one durable recovery identity per original Task, across all failures.
45
+ return job.tasks.some(task => recoveryTargetsTask(task, original));
46
+ }
47
+ function recoveryEpisode(recovery) {
48
+ return recovery.recoveryForAttempt ?? Number(/-a(\d+)$/.exec(recovery.id)?.[1] ?? -1);
49
+ }
50
+ /** Reuse the existing recovery rather than creating one child per worker retry. */
51
+ export function pendingTroubleshooterReopens(job) {
52
+ const pending = [];
53
+ for (const original of job.tasks) {
54
+ if (original.kind === 'planner' || original.agentName === 'troubleshooter' || original.state !== 'FAILED')
55
+ continue;
56
+ if (!belongsToCurrentRun(job, original))
57
+ continue;
58
+ const recoveries = job.tasks.filter(candidate => recoveryTargetsTask(candidate, original));
59
+ if (!recoveries.length || recoveries.some(candidate => ACTIVE_RECOVERY_STATES.has(candidate.state)))
60
+ continue;
61
+ const recovery = recoveries.sort((a, b) => recoveryEpisode(b) - recoveryEpisode(a))[0];
62
+ if (!['SUCCEEDED', 'CANCELLED'].includes(recovery.state) || recoveryEpisode(recovery) >= original.attempt)
63
+ continue;
64
+ pending.push({ original, recovery });
65
+ }
66
+ return pending;
67
+ }
68
+ export function refreshTroubleshooterInput(job, original, recovery) {
69
+ recovery.input = failureInput(job, original, recovery.id);
70
+ recovery.recoveryForAttempt = original.attempt;
71
+ recovery.maxAttempts = Math.max(recovery.maxAttempts, recovery.attempt + 3);
72
+ recovery.routing = undefined;
73
+ recovery.workerSelection = undefined;
46
74
  }
47
75
  export function pendingTroubleshooterTasks(job) {
48
76
  const existing = new Set(job.tasks.filter(task => task.agentName === 'troubleshooter').map(task => task.id));
@@ -64,6 +92,8 @@ export function pendingTroubleshooterTasks(job) {
64
92
  recoveries.push(createTaskEntity(job, {
65
93
  jobId: job.id,
66
94
  id,
95
+ title: `Diagnose and recover failed task ${task.id}`,
96
+ recoveryForAttempt: task.attempt,
67
97
  kind: 'work',
68
98
  priority: 'P0',
69
99
  storySize: 'M',
@@ -22,6 +22,7 @@ export const WORKFLOW_EVENT_NAMES = [
22
22
  'planner.failed',
23
23
  'planner.unavailable',
24
24
  'reconcile',
25
+ 'recovery.reopen',
25
26
  'release',
26
27
  'retry',
27
28
  'review.started',
@@ -25,6 +25,7 @@ export const taskLifecycleV2 = load('task-lifecycle', TASK_STATES, {
25
25
  runnable: boolGuard('runnable'),
26
26
  dispatchable: boolGuard('dispatchable'),
27
27
  recoverableExecution: boolGuard('recoverableExecution'),
28
+ troubleshooterRecovery: boolGuard('troubleshooterRecovery'),
28
29
  retryAvailable: boolGuard('retryAvailable'),
29
30
  });
30
31
  export const jobLifecycleV2 = load('job-lifecycle', JOB_STATES, {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "2.0.86",
3
+ "version": "2.0.88",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {
@@ -114,7 +114,9 @@ For `objective_status: "in_progress"`, `assessment` must be Markdown containing:
114
114
 
115
115
  For `objective_status: "satisfied"`, `assessment` may be a concise evidence-based completion assessment without a graph.
116
116
 
117
- Each item in next_tasks contains id, input, output, and storySize. `unblocks`, when present, is an array of `{repository, prNumber, jobId, taskId, resolutionMode?}` records identifying Tasks that must remain waiting until the current Job resolves their blocker. It may also contain parentTaskId, priority, projectName, agentName, dependencies, capabilities, workerRequirements, maxAttempts, and timeoutMs. Adapter/routing fields are selected by Relay after planning; do not invent them.
117
+ Each item in `next_tasks` MUST include a concise, meaningful `title` of **5–8 words** (separate from the detailed `input`). Do not use the input text as a title.
118
+
119
+ Each item in `next_tasks` contains **required `title`**, id, input, output, and storySize. The planner parser rejects missing titles and titles outside 5–8 whitespace-separated words. `unblocks`, when present, is an array of `{repository, prNumber, jobId, taskId, resolutionMode?}` records identifying Tasks that must remain waiting until the current Job resolves their blocker. It may also contain parentTaskId, priority, projectName, agentName, dependencies, capabilities, workerRequirements, maxAttempts, and timeoutMs. Adapter/routing fields are selected by Relay after planning; do not invent them.
118
120
 
119
121
  ### Exact `next_tasks` serialization
120
122
 
@@ -138,6 +140,7 @@ A valid non-satisfied response:
138
140
  "next_tasks": [
139
141
  {
140
142
  "id": "episode-2026-09-26",
143
+ "title": "Produce and verify daily content package",
141
144
  "input": "Produce and verify the dated content package.",
142
145
  "output": {"kind": "file", "path": "runs/2026-09-26/package.md"},
143
146
  "storySize": "L",
@@ -18,6 +18,11 @@ recoverComplete["guard: recoverableExecution"]
18
18
  recoverBlocked["guard: recoverableExecution"]
19
19
  recoverFailed["guard: recoverableExecution"]
20
20
  recoverCancelled["guard: recoverableExecution"]
21
+ troubleshooterRecovery["guard: troubleshooterRecovery"]
22
+ recoveryReopenClearError["action: mutation.clearError"]
23
+ recoveryReopenClearReason["action: mutation.clearFailureReason"]
24
+ recoveryReopenClearLease["action: mutation.clearLease"]
25
+ recoveryReopenClearExecution["action: mutation.clearExecution"]
21
26
 
22
27
  retryAvailableRunning["guard: retryAvailable"]
23
28
  plannerRetryAvailable["guard: retryAvailable"]
@@ -200,3 +205,12 @@ recoverFailed -->|true| failed
200
205
  failed -->|on: task.cancelled| recoverCancelled
201
206
  recoverCancelled -->|true| cancelled
202
207
  failed -->|on: cancel| cancelled
208
+
209
+ %% Only a persistent Troubleshooter may reopen a terminal recovery for a new failure episode.
210
+ succeeded -->|on: recovery.reopen| troubleshooterRecovery
211
+ cancelled -->|on: recovery.reopen| troubleshooterRecovery
212
+ troubleshooterRecovery -->|true| recoveryReopenClearError
213
+ recoveryReopenClearError --> recoveryReopenClearReason
214
+ recoveryReopenClearReason --> recoveryReopenClearLease
215
+ recoveryReopenClearLease --> recoveryReopenClearExecution
216
+ recoveryReopenClearExecution --> queued