agents-relay 2.0.87 → 2.0.89

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');
@@ -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`),
@@ -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" }));
@@ -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.87",
3
+ "version": "2.0.89",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {
@@ -116,7 +116,7 @@ For `objective_status: "satisfied"`, `assessment` may be a concise evidence-base
116
116
 
117
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
118
 
119
- 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.
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.
120
120
 
121
121
  ### Exact `next_tasks` serialization
122
122
 
@@ -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