agents-relay 2.0.107 → 2.0.109

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/cli-help.js CHANGED
@@ -8,7 +8,7 @@ Usage:
8
8
  --no-github: SQLite/Graph-only mode. GitHub auth/API, PR creation, merge and fetch are disabled. Local completion is Graph-owned.
9
9
 
10
10
  Commands:
11
- job create|update|show|list|merge|unblock|cancel Manage SQLite-authoritative jobs with optional GitHub PRs
11
+ job create|update|show|list|merge|unblock|cancel|pause|resume Manage SQLite-authoritative jobs with optional GitHub PRs
12
12
  task create|update|show|list|complete|retry|unblock|cancel Manage durable tasks
13
13
  review request Request graph-owned review (optional thread override)
14
14
  plan Invoke the Planner for an existing managed Job
@@ -29,7 +29,7 @@ Use 'node dist/cli.js' only for explicit local development/demo mode.`,
29
29
  job: `Manage SQLite-authoritative durable jobs.
30
30
 
31
31
  Usage:
32
- npx agents-relay job <create|update|show|list|merge|unblock|cancel> [options]
32
+ npx agents-relay job <create|update|show|list|merge|unblock|cancel|pause|resume> [options]
33
33
 
34
34
  Actions:
35
35
  create Create a durable Job, optionally binding an existing unmanaged PR
@@ -39,6 +39,8 @@ Actions:
39
39
  merge Queue a retryable external PR merge; Graph completes the Job only after confirmation
40
40
  unblock Authoritatively invalidate current BLOCKED decisions and trigger replanning/resume
41
41
  cancel Close the backing PR and cancel the Job
42
+ pause Persistently stop dispatch and interrupt active generations; lifecycle state remains unchanged
43
+ resume Clear pause metadata and requeue interrupted work
42
44
 
43
45
  Common options:
44
46
  --repo OWNER/REPO GitHub repository
@@ -94,6 +96,12 @@ Required: --repo OWNER/REPO, --pr NUMBER, and --id JOB_ID. The current PR head S
94
96
  'job cancel': `Cancel the durable Job and close its backing pull request.
95
97
 
96
98
  Required: --repo OWNER/REPO, --pr NUMBER, and --id JOB_ID. Nonterminal Tasks are cancelled before the PR is closed.`,
99
+ 'job pause': `Pause a durable Job without inventing a PAUSED lifecycle state.
100
+
101
+ Required: --repo OWNER/REPO --id JOB_ID. Optional: --pr NUMBER. Running worker and Planner generations are interrupted durably and resume as uncompleted work.`,
102
+ 'job resume': `Resume a paused durable Job.
103
+
104
+ Required: --repo OWNER/REPO --id JOB_ID. Optional: --pr NUMBER. Interrupted work is requeued and normal reconciliation continues.`,
97
105
  escalation: `Run the canonical escalation workflow for a suspected human/external blocker.\n\nRequired: --intent TEXT|--intent-file PATH and --blocked-on TEXT|--blocked-on-file PATH. Optional: --context/--context-file, --on-needs-user/--on-needs-user-file, --cwd. The executable workflow/escalation.mmd graph invokes Neo escalation, then owns the resolved / failed / needs_user branch. Only needs_user selects the block-for-user path.`,
98
106
  status: `Show live aggregate runtime status from the running Agents Relay service.
99
107
 
@@ -247,6 +247,13 @@ export async function publishTaskUnblocked(bus, job, taskId, reason) { if (!bus)
247
247
  catch (error) {
248
248
  process.stderr.write(`warning: unblock event unavailable: ${error instanceof Error ? error.message : String(error)}\n`);
249
249
  } }
250
+ export async function publishTaskBlocked(bus, job, taskId, reason, data) { if (!bus)
251
+ return; try {
252
+ await bus.publish(eventFor(job.id, taskId, null, 'task.blocked', 'blocked', `Task ${taskId} blocked: ${reason}`, 'orchestrator', { repository: job.repository, prNumber: job.prNumber, reason, ...data }));
253
+ }
254
+ catch (error) {
255
+ process.stderr.write(`warning: block event unavailable: ${error instanceof Error ? error.message : String(error)}\n`);
256
+ } }
250
257
  export async function publishDependentUnblocked(bus, targets, reason) { if (!bus)
251
258
  return; for (const target of targets) {
252
259
  try {
@@ -1,9 +1,18 @@
1
1
  import { arg, publishTaskUnblocked, publishWake, saveLocal, withEventBus } from './cli-support.js';
2
2
  import { isEffectivelyBlocked, unblockAutonomousTasks, unblockTask } from './unblock.js';
3
+ import { resumeTroubleshooter } from './task-recovery.js';
3
4
  export async function runTaskUnblockCommand(args, store, job, task, localFile) {
4
5
  const reason = arg(args, '--reason');
5
- if (!reason.trim())
6
- throw new Error('task unblock requires --reason');
6
+ const response = arg(args, '--response');
7
+ if (!reason.trim() && !response.trim())
8
+ throw new Error('task unblock requires --reason or --response');
9
+ if (task.agentName === 'troubleshooter' && task.escalation?.status === 'pending' && response.trim()) {
10
+ const result = await resumeTroubleshooter(store, job.id, task.id, response);
11
+ await saveLocal(localFile, result.job);
12
+ await withEventBus(args, async (bus) => { await publishTaskUnblocked(bus, result.job, task.id, reason || 'Human response recorded'); await publishWake(bus, result.job, task.id, 'Troubleshooter resumed after human response'); });
13
+ console.log(JSON.stringify({ taskId: task.id, resumed: true, task: result.task }, null, 2));
14
+ return;
15
+ }
7
16
  const result = await unblockTask({ store, job, task, trigger: { source: 'cli', reason } });
8
17
  const refreshed = await store.load(job.id);
9
18
  await saveLocal(localFile, refreshed);
package/dist/cli.js CHANGED
@@ -41,6 +41,8 @@ import { SERVICE_DEFAULTS, ServiceEventBus, arg, hasArg, textOption, integerArg,
41
41
  export { SERVICE_DEFAULTS, eventBus, executionMode, chatGptTabClosePolicy } from './cli-support.js';
42
42
  import { workerTaskFromArgs } from './cli-worker-task.js';
43
43
  import { taskExecutionContext } from './task-context.js';
44
+ import { requestTroubleshooterEscalation, resumeTroubleshooter } from './task-recovery.js';
45
+ import { pauseJob, resumeJob } from './job-pause.js';
44
46
  export { ensureManagedGitHubJob } from './cli-job-create.js';
45
47
  async function runManagedTaskCommand(action, args) {
46
48
  const jobId = arg(args, '--id');
@@ -68,7 +70,7 @@ async function runManagedTaskCommand(action, args) {
68
70
  console.log(JSON.stringify(task, null, 2));
69
71
  }
70
72
  async function runTaskCommand(action, args) {
71
- if (!['create', 'update', 'show', 'context', 'comment', 'comments', 'list', 'retry', 'recover', 'unblock', 'cancel', 'supersede'].includes(action))
73
+ if (!['create', 'update', 'show', 'context', 'comment', 'comments', 'list', 'retry', 'recover', 'unblock', 'escalate', 'resume-troubleshooter', 'cancel', 'supersede'].includes(action))
72
74
  throw new Error(usage);
73
75
  const managed = !arg(args, '--file') && Boolean(arg(args, '--repo') && arg(args, '--id'));
74
76
  if (managed) {
@@ -141,9 +143,24 @@ async function runTaskCommand(action, args) {
141
143
  if (!taskId)
142
144
  throw new Error('task unblock requires --task-id');
143
145
  const reason = arg(args, '--reason');
144
- if (!reason.trim())
145
- throw new Error('task unblock requires --reason');
146
- const job = await relayControlCommand(args, { command: 'task.unblock', jobId, taskId, reason });
146
+ const response = arg(args, '--response');
147
+ if (!reason.trim() && !response.trim())
148
+ throw new Error('task unblock requires --reason or --response');
149
+ const job = await relayControlCommand(args, { command: 'task.unblock', jobId, taskId, reason, ...(response.trim() ? { response } : {}) });
150
+ console.log(JSON.stringify(job.tasks.find(item => item.id === taskId), null, 2));
151
+ return;
152
+ }
153
+ if (action === 'escalate') {
154
+ if (!taskId)
155
+ throw new Error('task escalate requires --task-id');
156
+ const job = await relayControlCommand(args, { command: 'task.escalate', jobId, taskId, requiredAction: arg(args, '--required-action'), resolutionCondition: arg(args, '--resolution-condition'), context: arg(args, '--context') });
157
+ console.log(JSON.stringify(job.tasks.find(item => item.id === taskId), null, 2));
158
+ return;
159
+ }
160
+ if (action === 'resume-troubleshooter') {
161
+ if (!taskId)
162
+ throw new Error('task resume-troubleshooter requires --task-id');
163
+ const job = await relayControlCommand(args, { command: 'task.resume-troubleshooter', jobId, taskId, response: arg(args, '--response') });
147
164
  console.log(JSON.stringify(job.tasks.find(item => item.id === taskId), null, 2));
148
165
  return;
149
166
  }
@@ -234,6 +251,18 @@ async function runTaskCommand(action, args) {
234
251
  await runTaskUnblockCommand(args, store, job, task, localFile);
235
252
  return;
236
253
  }
254
+ if (action === 'escalate') {
255
+ const result = await requestTroubleshooterEscalation(store, job.id, id, { requiredAction: arg(args, '--required-action'), resolutionCondition: arg(args, '--resolution-condition'), context: arg(args, '--context') });
256
+ await saveLocal(localFile, result.job);
257
+ console.log(JSON.stringify(result.task, null, 2));
258
+ return;
259
+ }
260
+ if (action === 'resume-troubleshooter') {
261
+ const result = await resumeTroubleshooter(store, job.id, id, arg(args, '--response'));
262
+ await saveLocal(localFile, result.job);
263
+ console.log(JSON.stringify(result.task, null, 2));
264
+ return;
265
+ }
237
266
  if (action === 'retry' || action === 'recover' || action === 'cancel') {
238
267
  let outputJob = job;
239
268
  let outputTask = task;
@@ -363,7 +392,7 @@ async function runContextCommand(action, args) {
363
392
  console.log(JSON.stringify(context, null, 2));
364
393
  }
365
394
  async function runJobCommand(action, args) {
366
- if (!['create', 'adopt', 'update', 'show', 'list', 'merge', 'unblock', 'cancel'].includes(action))
395
+ if (!['create', 'adopt', 'update', 'show', 'list', 'merge', 'unblock', 'cancel', 'pause', 'resume'].includes(action))
367
396
  throw new Error(usage);
368
397
  const repo = repository(args);
369
398
  let createObjective;
@@ -400,6 +429,19 @@ async function runJobCommand(action, args) {
400
429
  const id = arg(args, '--id');
401
430
  if (!id && action !== 'create')
402
431
  throw new Error('--id is required for managed local Jobs');
432
+ if (action === 'pause' || action === 'resume') {
433
+ const target = await storeFor(args);
434
+ if (target.job.repository !== repo)
435
+ throw new Error('Local Job/PR binding mismatch');
436
+ const prValue = arg(args, '--pr');
437
+ if (prValue && target.job.prNumber !== Number(prValue))
438
+ throw new Error('Local Job/PR binding mismatch');
439
+ const updated = action === 'pause' ? await pauseJob(target.store, id) : await resumeJob(target.store, id);
440
+ await saveLocal(target.localFile, updated);
441
+ await withEventBus(args, bus => publishWake(bus, updated, 'job', `Job ${id} ${action} requested`));
442
+ console.log(JSON.stringify(updated, null, 2));
443
+ return;
444
+ }
403
445
  const prValue = arg(args, '--pr');
404
446
  const prNumber = prValue ? Number(prValue) : 0;
405
447
  if (prValue && (!Number.isInteger(prNumber) || (prNumber ?? 0) <= 0))
@@ -0,0 +1,75 @@
1
+ import { tryTransitionTaskEvent } from './scheduler.js';
2
+ export async function pauseJob(store, jobId) {
3
+ const mutate = (current) => {
4
+ if (['COMPLETED', 'CANCELLED'].includes(current.state))
5
+ throw new Error(`Cannot pause terminal Job ${current.id} (${current.state})`);
6
+ if (current.pause)
7
+ return current;
8
+ const now = new Date().toISOString();
9
+ current.pause = { generation: 1, requestedAt: now };
10
+ for (const task of current.tasks) {
11
+ if (task.kind === 'planner' || ['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(task.state))
12
+ continue;
13
+ if (task.executionId)
14
+ task.pausedExecution = { id: task.executionId, state: task.state === 'RUNNING' ? 'RUNNING' : 'QUEUED', attempt: task.attempt, at: now };
15
+ if (task.state === 'RUNNING' && !tryTransitionTaskEvent(task, 'pause').accepted)
16
+ throw new Error(`Cannot pause running Task ${task.id}`);
17
+ task.executionId = null;
18
+ task.leaseOwner = null;
19
+ task.leaseExpiresAt = null;
20
+ task.updatedAt = now;
21
+ }
22
+ for (const plan of current.plans ?? []) {
23
+ if (plan.status !== 'PLANNING')
24
+ continue;
25
+ if (plan.executionId)
26
+ plan.pausedExecutionId = plan.executionId;
27
+ plan.executionId = null;
28
+ plan.leaseOwner = null;
29
+ plan.leaseExpiresAt = null;
30
+ plan.updatedAt = now;
31
+ }
32
+ current.updatedAt = now;
33
+ return current;
34
+ };
35
+ if (store.mutateJob)
36
+ return store.mutateJob(jobId, mutate);
37
+ const job = await store.load(jobId);
38
+ const updated = mutate(job);
39
+ await store.saveJob(updated);
40
+ for (const task of updated.tasks)
41
+ await store.saveTask(task);
42
+ for (const plan of updated.plans ?? [])
43
+ await store.savePlan(plan);
44
+ return updated;
45
+ }
46
+ export async function resumeJob(store, jobId) {
47
+ const mutate = (current) => {
48
+ if (!current.pause)
49
+ return current;
50
+ const now = new Date().toISOString();
51
+ delete current.pause;
52
+ for (const task of current.tasks) {
53
+ if (task.pausedExecution) {
54
+ if (task.attempt >= task.maxAttempts)
55
+ task.maxAttempts = task.attempt + 1;
56
+ delete task.pausedExecution;
57
+ task.updatedAt = now;
58
+ }
59
+ }
60
+ for (const plan of current.plans ?? [])
61
+ delete plan.pausedExecutionId;
62
+ current.updatedAt = now;
63
+ return current;
64
+ };
65
+ if (store.mutateJob)
66
+ return store.mutateJob(jobId, mutate);
67
+ const job = await store.load(jobId);
68
+ const updated = mutate(job);
69
+ await store.saveJob(updated);
70
+ for (const task of updated.tasks)
71
+ await store.saveTask(task);
72
+ for (const plan of updated.plans ?? [])
73
+ await store.savePlan(plan);
74
+ return updated;
75
+ }
@@ -68,8 +68,8 @@ ${task.adapter === 'chatgpt' ? `Because ChatGPT execution continues asynchronous
68
68
  Before returning or allowing the worker process to exit, you MUST publish exactly one canonical terminal event:
69
69
  - task.completed only after the declared output is durable;
70
70
  - task.failed for implementation, tool, command, test, patch, commit, push, runtime, or other execution failure that prevents completion;
71
- - task.blocked only when genuine human/external action is required;
72
- - task.cancelled only when execution was cancelled.
71
+ - task.completed or task.failed is the only agent terminal result. Relay owns task.blocked/task.unblocked lifecycle transitions; never publish task.blocked for a recovery request;
72
+ - Relay-owned cancellation is not an agent terminal result; if execution cannot continue, publish task.failed.
73
73
 
74
74
  If any execution step fails and you cannot continue, publish task.failed with the concrete reason BEFORE exiting.
75
75
  Never exit after task.started without publishing exactly one terminal task event.
@@ -5,6 +5,8 @@ export async function executeManualPlan(store, jobId, guidance, timeoutMs, launc
5
5
  let job = await store.load(jobId);
6
6
  if (['COMPLETED', 'CANCELLED'].includes(job.state))
7
7
  throw new Error(`Cannot plan terminal job ${job.id} (${job.state})`);
8
+ if (job.pause)
9
+ throw new Error(`Cannot plan paused Job ${job.id}; resume it first`);
8
10
  const active = pendingPlan(job);
9
11
  if (active && active.executionId)
10
12
  throw new Error(`Planning round ${active.id} is already PLANNING`);
@@ -13,6 +15,8 @@ export async function executeManualPlan(store, jobId, guidance, timeoutMs, launc
13
15
  const committed = await store.mutateJob(jobId, current => {
14
16
  if (['COMPLETED', 'CANCELLED'].includes(current.state))
15
17
  throw new Error(`Cannot plan terminal job ${current.id} (${current.state})`);
18
+ if (current.pause)
19
+ throw new Error(`Cannot plan paused Job ${current.id}; resume it first`);
16
20
  const concurrent = pendingPlan(current);
17
21
  if (concurrent) {
18
22
  if (concurrent.executionId)
@@ -281,6 +281,8 @@ export async function launchPlanExecution(ports, job, plan) {
281
281
  }
282
282
  const claimed = await withExecutionBoundary(job.id, '__job_execution_slot__', async () => {
283
283
  const freshJob = await ports.store.load(job.id);
284
+ if (freshJob.pause)
285
+ return null;
284
286
  const fresh = (freshJob.plans ?? []).find(item => item.id === plan.id);
285
287
  if (!fresh || fresh.status !== 'PLANNING')
286
288
  return null;
package/dist/pool.js CHANGED
@@ -10,6 +10,8 @@ const RANK = { P0: 0, P1: 1, P2: 2, P3: 3 };
10
10
  export function compareJobPriority(a, b) { return RANK[a.priority ?? 'P2'] - RANK[b.priority ?? 'P2'] || a.createdAt.localeCompare(b.createdAt) || a.id.localeCompare(b.id); }
11
11
  export function runnableManagedJobs(items) { return items.filter(item => item.job.state === 'ACTIVE').map(item => item.job).sort(compareJobPriority); }
12
12
  export function needsLifecycleReconciliation(job, now = Date.now()) {
13
+ if (job.pause)
14
+ return job.tasks.some(task => Boolean(task.pausedExecution)) || Boolean(job.plans?.some(plan => Boolean(plan.pausedExecutionId)));
13
15
  // DRAFT create-only Jobs stay dormant until a caller explicitly creates work.
14
16
  if (job.tasks.length === 0)
15
17
  return false;
@@ -0,0 +1,11 @@
1
+ /** Stop only generations already invalidated by the durable pause mutation. */
2
+ export function cancelPausedGenerations(job, cancelTask, cancelPlan) {
3
+ if (!job.pause)
4
+ return;
5
+ for (const task of job.tasks)
6
+ if (task.pausedExecution)
7
+ cancelTask(task.id, task.pausedExecution.id);
8
+ for (const plan of job.plans ?? [])
9
+ if (plan.pausedExecutionId)
10
+ cancelPlan(plan.id);
11
+ }
@@ -1,3 +1,4 @@
1
+ import { cancelPausedGenerations } from './reconciler-pause.js';
1
2
  import { taskCommentContext } from './task-comments.js';
2
3
  import { cancelActiveTasks } from './reconciler-cancellation.js';
3
4
  import { authorizedInjection } from './e2e-injection.js';
@@ -41,7 +42,7 @@ export class Reconciler extends ReconcilerReviewBase {
41
42
  this.workflowEffects.register(new CallbackWorkflowEffectHandler('effect.launchTask', async (context) => {
42
43
  const job = await this.store.load(context.jobId);
43
44
  const task = context.taskId ? job.tasks.find(item => item.id === context.taskId) : undefined;
44
- if (!task || task.state !== 'QUEUED' || task.executionId !== null)
45
+ if (!task || job.pause || task.state !== 'QUEUED' || task.executionId !== null)
45
46
  return;
46
47
  if (task.kind !== 'planner' && task.adapter !== 'orchestrator')
47
48
  await this.launch(job, task);
@@ -51,6 +52,8 @@ export class Reconciler extends ReconcilerReviewBase {
51
52
  }
52
53
  async dispatchExecutableTasks(jobId) {
53
54
  const scheduled = schedule(await this.store.load(jobId));
55
+ if (scheduled.pause)
56
+ return scheduled;
54
57
  for (const task of scheduled.tasks.filter(item => item.state === 'SUCCEEDED' && (item.continuation || scheduled.continuation) && !item.continuationDeliveredAt)) {
55
58
  this.detach(this.deliverContinuation(scheduled, task), scheduled.id, task.id, 'continuation');
56
59
  }
@@ -77,6 +80,9 @@ export class Reconciler extends ReconcilerReviewBase {
77
80
  this.reconciling = true;
78
81
  try {
79
82
  const job = await this.store.load(jobId);
83
+ for (const live of [...this.live.values()])
84
+ if (live.jobId === jobId && job.tasks.some(task => task.id === live.taskId && task.state === 'BLOCKED'))
85
+ this.cancelExecutionGeneration(jobId, live.taskId, live.executionId);
80
86
  const previousState = job.state;
81
87
  const lifecycleFingerprint = (value) => `${value.state}|${value.tasks.map(task => `${task.id}:${task.state}`).join(',')}`;
82
88
  const beforeFingerprint = lifecycleFingerprint(job);
@@ -92,6 +98,14 @@ export class Reconciler extends ReconcilerReviewBase {
92
98
  }
93
99
  await this.invalidateStaleReview(job, headSha);
94
100
  const now = (this.options.now ?? new Date()).getTime();
101
+ if (job.pause) {
102
+ cancelPausedGenerations(job, (taskId, executionId) => this.cancelExecutionGeneration(job.id, taskId, executionId), planId => { const live = this.plannerLive.get(planId); if (live) {
103
+ clearTimeout(live.timer);
104
+ live.controller.abort();
105
+ this.plannerLive.delete(planId);
106
+ } });
107
+ return job;
108
+ }
95
109
  if (job.state !== 'ACTIVE')
96
110
  return job;
97
111
  await recoverExpiredExecutions(job, now, this.executionRecoveryPorts(job));
@@ -3,7 +3,9 @@ import { InMemoryStore } from './store.js';
3
3
  import { openLocalManagedStore } from './local-managed-store.js';
4
4
  import { relayStoreDirectory } from './relay-store.js';
5
5
  import { normalizeThreadId } from './reviewer-thread.js';
6
+ import { publishTaskBlocked } from './cli-support.js';
6
7
  import { supersedeCancelledTask } from './task-actions.js';
8
+ import { requestTroubleshooterEscalation, resumeTroubleshooter } from './task-recovery.js';
7
9
  import { adoptExistingPullRequestJob } from './cli-job-create.js';
8
10
  export function createWorkspaceControl(deps) {
9
11
  const { client, trusted, noGitHub, repositories, refreshAll, requireLocalJobBinding, makeReconciler, concurrency, pool, bus, isEffectivelyBlocked, unblockAutonomousTasks, unblockTask, publishTaskUnblocked, dispatchTaskAction, registerBlockerDependency, discoverAgents, ensureManagedGitHubJob } = deps;
@@ -166,12 +168,36 @@ export function createWorkspaceControl(deps) {
166
168
  const task = job.tasks.find((item) => item.id === taskId);
167
169
  if (!task)
168
170
  throw new Error(`Task ${taskId} not found`);
171
+ if (task.agentName === 'troubleshooter' && task.escalation?.status === 'pending' && typeof body.response === 'string') {
172
+ const resumed = await resumeTroubleshooter(store, jobId, taskId, body.response);
173
+ await publishTaskUnblocked(bus, resumed.job, taskId, reason || 'Human response recorded');
174
+ await pool.reconcileLocalJob(jobId, 'control.task.unblock.troubleshooter');
175
+ return await store.load(jobId);
176
+ }
169
177
  await unblockTask({ store, job, task, trigger: { source: 'cli', reason } });
170
178
  const refreshed = await store.load(jobId);
171
179
  await publishTaskUnblocked(bus, refreshed, taskId, reason);
172
180
  await pool.reconcileLocalJob(jobId, 'control.task.unblock');
173
181
  return await store.load(jobId);
174
182
  }
183
+ if (command === 'task.escalate') {
184
+ const recoveryTaskId = typeof body.taskId === 'string' ? body.taskId : '';
185
+ const requiredAction = typeof body.requiredAction === 'string' ? body.requiredAction : '';
186
+ const resolutionCondition = typeof body.resolutionCondition === 'string' ? body.resolutionCondition : '';
187
+ const context = typeof body.context === 'string' ? body.context : '';
188
+ const result = await requestTroubleshooterEscalation(store, jobId, recoveryTaskId, { requiredAction, resolutionCondition, context });
189
+ await publishTaskBlocked(bus, result.job, recoveryTaskId, requiredAction, { escalationId: result.escalation.id, originalTaskId: result.escalation.originalTaskId });
190
+ await pool.reconcileLocalJob(jobId, 'control.task.escalate');
191
+ return await store.load(jobId);
192
+ }
193
+ if (command === 'task.resume-troubleshooter') {
194
+ const recoveryTaskId = typeof body.taskId === 'string' ? body.taskId : '';
195
+ const response = typeof body.response === 'string' ? body.response : '';
196
+ const resumed = await resumeTroubleshooter(store, jobId, recoveryTaskId, response);
197
+ await publishTaskUnblocked(bus, resumed.job, recoveryTaskId, 'Human response recorded');
198
+ await pool.reconcileLocalJob(jobId, 'control.task.resume-troubleshooter');
199
+ return await store.load(jobId);
200
+ }
175
201
  if (command === 'review.request') {
176
202
  if (job.state === 'CANCELLED' || job.state === 'COMPLETED')
177
203
  throw new Error('Cannot request review for terminal Job');
@@ -18,6 +18,10 @@ export function normalizeReviewerTerminalEvent(event) {
18
18
  const head = [outcome.head_sha, outcome.headSha, review.head_sha, review.headSha,
19
19
  data.head_sha, data.headSha]
20
20
  .find(value => typeof value === 'string' && value.length > 0);
21
+ // A unified task.completed review must carry its immutable target explicitly;
22
+ // native legacy review events may still use the task-bound fallback.
23
+ if (!head)
24
+ return null;
21
25
  const existingFindings = outcome.blocking_findings ?? outcome.blockingFindings;
22
26
  const findings = existingFindings ?? (Array.isArray(data.findings) ? data.findings : undefined);
23
27
  return { ...event, type, data: { ...data, outcome: {
package/dist/scheduler.js CHANGED
@@ -17,6 +17,8 @@ export function completionGateSatisfied(job) {
17
17
  return (planSatisfied || legacySatisfied) && implementationTasks(job).every(task => task.state === 'SUCCEEDED');
18
18
  }
19
19
  export function schedule(job) {
20
+ if (job.pause)
21
+ return job;
20
22
  reconcileTaskDependencies(job);
21
23
  const scopedTasks = job.schedule?.currentRunSlot
22
24
  ? job.tasks.filter(task => Date.parse(task.createdAt) >= Date.parse(job.schedule?.currentRunSlot ?? ''))
@@ -0,0 +1,105 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { tryTransitionTaskEvent } from './scheduler.js';
3
+ import { dispatchTaskResolutionSync, materializeResolution } from './workflow/task-resolution.js';
4
+ function activeRecovery(job, original) {
5
+ return job.tasks.find(task => task.agentName === 'troubleshooter'
6
+ && task.parentTaskId === original.id
7
+ && ['DRAFT', 'QUEUED', 'RUNNING', 'BLOCKED'].includes(task.state));
8
+ }
9
+ function clearExecution(task) {
10
+ task.leaseOwner = null;
11
+ task.leaseExpiresAt = null;
12
+ task.executionId = null;
13
+ }
14
+ function validateRequest(request) {
15
+ if (!request.requiredAction.trim())
16
+ throw new Error('Escalation requires a concrete required action');
17
+ if (!request.resolutionCondition.trim())
18
+ throw new Error('Escalation requires a resolution condition');
19
+ if (!request.context.trim())
20
+ throw new Error('Escalation requires diagnostic context');
21
+ }
22
+ /** Relay-owned escalation: the recovery Task, never the failed original, becomes BLOCKED. */
23
+ export async function requestTroubleshooterEscalation(store, jobId, recoveryTaskId, request, now = new Date().toISOString()) {
24
+ validateRequest(request);
25
+ let result;
26
+ const apply = (job) => {
27
+ const recovery = job.tasks.find(task => task.id === recoveryTaskId);
28
+ if (!recovery || recovery.agentName !== 'troubleshooter' || !recovery.parentTaskId)
29
+ throw new Error(`Task ${recoveryTaskId} is not a Troubleshooter recovery`);
30
+ if (job.state === 'CANCELLED' || job.state === 'COMPLETED')
31
+ throw new Error(`Job ${job.id} is terminal`);
32
+ const original = job.tasks.find(task => task.id === recovery.parentTaskId);
33
+ if (!original || original.state !== 'FAILED')
34
+ throw new Error(`Troubleshooter ${recoveryTaskId} has no current FAILED original Task`);
35
+ if (recovery.escalation?.status === 'pending' && recovery.state === 'BLOCKED') {
36
+ result = { task: structuredClone(recovery), escalation: structuredClone(recovery.escalation), duplicate: true };
37
+ return job;
38
+ }
39
+ if (!['QUEUED', 'RUNNING'].includes(recovery.state))
40
+ throw new Error(`Troubleshooter ${recoveryTaskId} cannot escalate from ${recovery.state}`);
41
+ const proposal = { path: 'escalation_gate', targetTaskId: original.id, rootCause: request.context.trim(), solution: request.requiredAction.trim() };
42
+ const decision = dispatchTaskResolutionSync(original, proposal, 'troubleshooter', {
43
+ createEscalationGate: () => { original.resolution = materializeResolution(original, proposal, 'troubleshooter', now); },
44
+ });
45
+ if (!decision.accepted)
46
+ throw new Error(`Task resolution graph rejected escalation for ${original.id}`);
47
+ if (!tryTransitionTaskEvent(recovery, 'task.blocked').accepted)
48
+ throw new Error(`Troubleshooter ${recoveryTaskId} cannot be blocked`);
49
+ const escalation = {
50
+ id: randomUUID(), originalTaskId: original.id, originalExecutionId: original.executionId ?? undefined,
51
+ recoveryExecutionId: recovery.executionId ?? undefined, requiredAction: request.requiredAction.trim(),
52
+ resolutionCondition: request.resolutionCondition.trim(), context: request.context.trim(), status: 'pending',
53
+ requestedAt: now, notification: request.notification ?? null,
54
+ };
55
+ recovery.escalation = escalation;
56
+ recovery.error = `Waiting for human action: ${escalation.requiredAction}`;
57
+ recovery.failureReason = 'human_action_required';
58
+ clearExecution(recovery);
59
+ recovery.updatedAt = now;
60
+ result = { task: structuredClone(recovery), escalation, duplicate: false };
61
+ job.updatedAt = now;
62
+ return job;
63
+ };
64
+ const job = store.mutateJob ? await store.mutateJob(jobId, apply) : await store.load(jobId).then(async (current) => { apply(current); for (const task of current.tasks)
65
+ await store.saveTask(task); await store.saveJob(current); return current; });
66
+ if (!result)
67
+ throw new Error('Escalation did not produce a durable result');
68
+ return { job, ...result };
69
+ }
70
+ /** Authorized human response; only a blocked Troubleshooter may be resumed. */
71
+ export async function resumeTroubleshooter(store, jobId, recoveryTaskId, response, now = new Date().toISOString()) {
72
+ if (!response.trim())
73
+ throw new Error('Troubleshooter response is required');
74
+ let updated;
75
+ const apply = (job) => {
76
+ const task = job.tasks.find(item => item.id === recoveryTaskId);
77
+ if (!task || task.agentName !== 'troubleshooter' || !task.escalation)
78
+ throw new Error(`Task ${recoveryTaskId} is not a Troubleshooter escalation`);
79
+ if (task.escalation.status !== 'pending')
80
+ throw new Error(`Escalation ${task.escalation.id} already has a response`);
81
+ if (task.state !== 'BLOCKED')
82
+ throw new Error(`Task ${recoveryTaskId} is not a blocked Troubleshooter escalation`);
83
+ if (!tryTransitionTaskEvent(task, 'task.unblocked', { autonomousJob: false }).accepted)
84
+ throw new Error(`Troubleshooter ${recoveryTaskId} cannot be resumed`);
85
+ task.escalation = { ...task.escalation, status: 'responded', response: response.trim(), respondedAt: now };
86
+ task.input += `\n\n## Human response (${now})\n${response.trim()}\n\nRe-validate the original failure against this response. Required resolution condition: ${task.escalation.resolutionCondition}`;
87
+ task.result = null;
88
+ task.error = null;
89
+ task.failureReason = undefined;
90
+ clearExecution(task);
91
+ task.updatedAt = now;
92
+ updated = structuredClone(task);
93
+ job.updatedAt = now;
94
+ return job;
95
+ };
96
+ const job = store.mutateJob ? await store.mutateJob(jobId, apply) : await store.load(jobId).then(async (current) => { apply(current); for (const task of current.tasks)
97
+ await store.saveTask(task); await store.saveJob(current); return current; });
98
+ if (!updated)
99
+ throw new Error('Troubleshooter was not resumed');
100
+ return { job, task: updated };
101
+ }
102
+ export function hasPendingTroubleshooterEscalation(job, original) {
103
+ const recovery = activeRecovery(job, original);
104
+ return recovery?.state === 'BLOCKED' && recovery.escalation?.status === 'pending';
105
+ }
package/dist/unblock.js CHANGED
@@ -1,7 +1,12 @@
1
1
  import { applyTaskUnblockRetrySnapshot } from './task-actions.js';
2
2
  import { tryTransitionTaskEvent } from './scheduler.js';
3
3
  import { dispatchAuthoritativeUnblock } from './workflow/authoritative-unblock-v2.js';
4
+ import { hasPendingTroubleshooterEscalation } from './task-recovery.js';
4
5
  export function isEffectivelyBlocked(task) { return task.kind !== 'planner' && task.state === 'BLOCKED'; }
6
+ function assertNoPendingHumanEscalation(tasks) {
7
+ if (tasks.some(task => task.agentName === 'troubleshooter' && task.escalation?.status === 'pending'))
8
+ throw new Error('Blocked Troubleshooter requires an authorized human response; use task unblock --response');
9
+ }
5
10
  function baselineFor(task) {
6
11
  return task.blockedBaseline ?? { blockedAt: task.updatedAt, reason: task.error ?? undefined };
7
12
  }
@@ -59,6 +64,7 @@ export async function unblockAutonomousTasks(options) {
59
64
  const tasks = options.tasks.filter(isEffectivelyBlocked);
60
65
  if (tasks.length === 0)
61
66
  throw new Error('No effective BLOCKED work task is available to unblock');
67
+ assertNoPendingHumanEscalation(tasks);
62
68
  const now = options.now ?? new Date().toISOString();
63
69
  const currentHeadSha = await store.headSha?.() ?? job.headSha ?? null;
64
70
  const facts = unblockFacts(job, tasks, currentHeadSha, trigger.reason);
@@ -95,6 +101,10 @@ export async function unblockAutonomousTasks(options) {
95
101
  export async function unblockTask(options) {
96
102
  if (!isEffectivelyBlocked(options.task))
97
103
  throw new Error(`Task ${options.task.id} is not an effective BLOCKED work task`);
104
+ assertNoPendingHumanEscalation([options.task]);
105
+ if (options.task.agentName !== 'troubleshooter' && hasPendingTroubleshooterEscalation(options.job, options.task)) {
106
+ throw new Error(`Task ${options.task.id} cannot be directly unblocked while its Troubleshooter is awaiting human action`);
107
+ }
98
108
  let result;
99
109
  const decision = await dispatchAuthoritativeUnblock(options.job.executionMode === 'autonomous', async (actionId) => {
100
110
  if (actionId === 'unblock.replan') {
@@ -19,6 +19,7 @@ export const WORKFLOW_EVENT_NAMES = [
19
19
  'pr.merged',
20
20
  'queue',
21
21
  'plan.requested',
22
+ 'pause',
22
23
  'planner.failed',
23
24
  'planner.unavailable',
24
25
  'reconcile',
package/package.json CHANGED
@@ -1,7 +1,11 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "2.0.107",
3
+ "version": "2.0.109",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "git+https://github.com/lalalic/agents-relay.git"
8
+ },
5
9
  "type": "module",
6
10
  "bin": {
7
11
  "agents-relay": "dist/cli.js",
@@ -9,6 +9,8 @@ Use Agents Relay when an orchestrator needs durable delegated work that survives
9
9
 
10
10
  ## Required shared contract
11
11
 
12
+ For unified agent terminal events and role-specific results, see `contracts/event-contract.md` and `contracts/task-recovery-flows.md`.
13
+
12
14
  Before creating, planning, reviewing, or managing a job/task, read and follow `contracts/task-job-contract.md`. This is the canonical producer/runtime protocol for story size, lifecycle events, review, and auto-merge.
13
15
 
14
16
  ## Core task contract
@@ -39,7 +41,7 @@ Agents Relay may launch a Codex process or submit a one-shot ChatGPT browser tas
39
41
  A running agent MUST use the canonical Neo `events-bus` protocol and publish exactly one correlated terminal event before stopping:
40
42
  - `task.completed` after the declared output is durable;
41
43
  - `task.failed` when the task cannot complete;
42
- - `task.blocked` when external or human action is required.
44
+ - Progress is optional; `task.blocked` and `task.unblocked` are Relay-owned lifecycle facts, never agent terminal events.
43
45
 
44
46
  Event publishing is not an Agents Relay CLI responsibility. Sandboxed Codex and hosted ChatGPT workers use the federated `events__publish` MCP tool from `events-bus`; direct non-sandbox local workers may use the caller-provided `NEO_EVENTS_EMIT`. Agents Relay subscribes to the canonical `neo.events.job.<job_id>.>` stream and maps terminal events into durable task state. Relay resolves its native NATS endpoint and subject prefix from the canonical events-bus runtime configuration (with explicit Relay CLI flags remaining authoritative). The same runtime configuration supplies the events-bus HTTP API used for replay/history. Relay and its dashboard must not read the events-bus private history file or maintain a second event-history backend.
45
47
 
@@ -48,7 +50,7 @@ The event must carry the exact durable `job_id` and `task_id`. Progress events a
48
50
  Agents Relay maps those terminal events to durable state:
49
51
  - `task.completed` → `SUCCEEDED`
50
52
  - `task.failed` → `FAILED`
51
- - `task.blocked` → `BLOCKED`
53
+ Relay may authoritatively move a Task to `BLOCKED` for an external condition and may resume it only through an authorized unblock operation. A human response resumes the blocked Troubleshooter, not the failed original Task.
52
54
 
53
55
  A model-backed task must not launch without an event bus. If no terminal event arrives before the task timeout, Relay records an explicit timeout failure. A Codex process exit or ChatGPT submission receipt alone never changes the task to a terminal state.
54
56
 
@@ -22,3 +22,6 @@ identity supplied by Relay for every lifecycle event.
22
22
  Run the most relevant tests and checks for the change. Do not claim completion
23
23
  without durable implementation evidence, validation results, and a concise
24
24
  summary of changed files and remaining risks.
25
+
26
+ Use only `task.completed` or `task.failed` as the terminal agent event;
27
+ block/unblock decisions belong to Relay. See `../contracts/event-contract.md`.
@@ -16,3 +16,14 @@ Reconstruct all material acceptance requirements from the durable Job objective
16
16
  ## Review Decision
17
17
 
18
18
  Return a concrete approved, changes-required or blocked verdict tied to the declared immutable target, with attributed findings, severity and remediation steps. Do not alter artifacts, generate approvals on behalf of users, or perform publication, merges or external actions. Relay owns the review lifecycle and Task/Job completion.
19
+
20
+ ## Unified execution event
21
+
22
+ After completing the review, publish **`task.completed`**, including `data.verdict`
23
+ (`approved`, `changes-required`, or `blocked`), the **exact immutable**
24
+ `data.head_sha`, and actionable `data.findings` for changes required. A
25
+ negative verdict is a successful review, not `task.failed`. If review
26
+ execution fails and no sound verdict can be produced, publish `task.failed`.
27
+ Never emit `review.*` as the agent's terminal event. The event source must
28
+ match the execution identity supplied by Relay. See
29
+ `../contracts/event-contract.md`.
@@ -29,8 +29,8 @@ Read and follow the shared Task / Job contract and orchestrator P0/self-job work
29
29
  - For a transient failure, verify the transient condition is gone.
30
30
  - For task-local failure, make the smallest durable repair in the original Job/PR when that is within scope.
31
31
  - For a shared platform/runtime defect, first create or reuse the owning blocker P0/self-job. If it can be completed inside this recovery execution, fix/verify it and retry the original Task. If it cannot be completed now, register the original Task with `agents-relay blocker add` on that blocker Job and finish this Troubleshooter task; do not stay running while waiting for another Job.
32
- - For a suspected genuine human/external blocker, do not publish `task.blocked` and do not branch on Neo's result yourself. Invoke `agents-relay escalation` with the original intent, blocker, and relevant context. If the recovery context defines a preferred way to contact the user, pass that description through `--on-needs-user` or `--on-needs-user-file`; Relay must not hard-code a channel. The canonical `workflow/escalation.mmd` graph invokes Neo escalation and owns the `resolved` / `failed` / `needs_user` decision. Follow the graph-selected outcome: resume recovery, preserve technical-failure evidence, or publish `task.blocked` with the exact returned user action and notification evidence.
33
- 6. For an immediate repair, only after the blocker is removed run `agents-relay task retry` for the original Task and reconcile its Job. For deferred repair, rely on the durable blocker relation: merge-mode blockers wake matching dependents after merge; release/deploy/runtime blockers use `--resolution verified` and `agents-relay blocker resolve` only after verification.
32
+ - For a suspected genuine human/external blocker, do not publish `task.blocked` and do not branch on Neo's result yourself. Invoke `agents-relay escalation` with the original intent, blocker, and relevant context. The canonical graph owns the `resolved`, `failed`, and `needs_user` outcomes. When it returns `needs_user`, call Relay's `task escalate` operation with the concrete required action, resolution condition, and diagnostic context. Relay records the escalation and blocks this Troubleshooter; no worker remains running while waiting.
33
+ 6. For an immediate repair, only after the blocker is removed run `agents-relay task recover` for the original Task with this Troubleshooter's recovery Task ID and reconcile its Job. An authorized human response resumes this same Troubleshooter with its diagnostic context; verify the action before requesting original-task recovery. For deferred repair, rely on the durable blocker relation: merge-mode blockers wake matching dependents after merge; release/deploy/runtime blockers use `--resolution verified` and `agents-relay blocker resolve` only after verification.
34
34
  7. Verify durable evidence that the original Task moved: at minimum QUEUED after retry, preferably a new execution / task.started / observable progress.
35
35
  8. Publish this Troubleshooter Task's terminal event only after the recovery outcome is durable.
36
36
 
@@ -0,0 +1,30 @@
1
+ # Agents Relay Unified Task Event Contract
2
+
3
+ **Scope:** Relay-specific event meaning and validation. This does **not** define Events Bus transport. The bus only handles generic event identity, type, timestamp, routing as needed, and opaque `data`. No Events Bus change is required when Relay adds agent roles or payload fields.
4
+
5
+ ## Producer contract
6
+
7
+ Every Implementation, Reviewer, and Troubleshooter execution publishes `task.started`, optional `task.progress` or `task.heartbeat`, then **exactly one** agent terminal event:
8
+
9
+ - `task.completed`: assigned responsibility finished and its role-specific output is durable, regardless of whether a reviewed artifact passed.
10
+ - `task.failed`: assigned responsibility could not be fulfilled; include failure phase, reason, and evidence in `data`.
11
+
12
+ `task.blocked` and `task.unblocked` are **Relay-owned lifecycle facts**, not agent terminal outcomes. Transport acceptance is not proof Relay accepted the transition. Every lifecycle event must use the exact Job, Task, and execution source identity provided by Relay; stale/duplicate execution events cannot change current state.
13
+
14
+ ## Role-specific completed data
15
+
16
+ | Role | `task.completed.data` | Relay action |
17
+ | --- | --- | --- |
18
+ | Implementation | Evidence of output/artifact, validation, and summary as required by the Task | Persist Task success and continue dependencies/review |
19
+ | Reviewer | `verdict` (`approved`, `changes-required`, or `blocked`), exact immutable `head_sha`, actionable `findings` as necessary | Validate current immutable review target and apply verdict; defects are a successful Reviewer execution, not `task.failed` |
20
+ | Troubleshooter | Recovery action and durable evidence of verified original Task retry, blocker-Job handoff, or other authorized recovery | Complete recovery Task only; original Task state changes through separately authorized Relay operations |
21
+
22
+ A Reviewer must **not** publish unstructured `task.completed`: missing verdict or immutable head is invalid. Relay normalizes an authenticated structured Reviewer terminal event into its internal Review decision; native `review.*` events may be accepted for backward compatibility.
23
+
24
+ ## Troubleshooter human escalation
25
+
26
+ Only a real user action merits escalation. Troubleshooter invokes the Relay escalation operation, specifying the required action, resolution condition, and diagnostic context. Relay durably records the human action and blocks **the Troubleshooter Task**, leaving the original failed Task `FAILED`. This is a Relay state transition, not a third agent terminal event.
27
+
28
+ When the human responds, the **Troubleshooter Task** is authoritatively unblocked and resumed with its prior context plus the response. Troubleshooter independently verifies resolution, requests an authorized original Task recovery, and only then publishes its own `task.completed`. If unresolved, it continues diagnosis or escalates again; human assertion alone is not technical verification.
29
+
30
+ See [task-recovery-flows.md](task-recovery-flows.md) for unified architecture, overall flow, failure recovery, and post-blocked resumption.
@@ -0,0 +1,135 @@
1
+ # Task Failure Recovery and Human Escalation — Agreed Target Design
2
+
3
+ **Status:** Design agreement; not an assertion of current implementation. This document describes two flows. Existing runtime, workflow graphs, and agent instructions may need changes to conform.
4
+
5
+ ## Principles and ownership
6
+
7
+ - **Events Bus is neutral.** Its generic event envelope consists of event identity, type, time, and opaque client-defined data (plus any transport metadata needed by the bus). It must not understand Relay Jobs, Tasks, roles, or verdicts.
8
+ - **One agent Task event contract** applies to Implementation, Reviewer, and Troubleshooter: optional progress, then exactly one agent terminal event, `task.completed` or `task.failed`. Role-specific results belong in `data`. A review with findings is a *completed* review, not a failed agent execution.
9
+ - **`task.blocked` and `task.unblocked` are authoritative Relay lifecycle transitions**, not ordinary agent terminal events. A human may authorize unblocking a Troubleshooter but does not directly unblock the failed original Task.
10
+ - **Troubleshooter owns diagnosis and recovery decisions.** It chooses technical repair/retry, blocker-Job dependency, or human escalation. There is no separate Escalator agent.
11
+ - **Relay owns transitions and dispatch.** Troubleshooter requests authorized operations; the reconciler/engine validates and durably applies them. An event arriving over the bus is not proof its transition was applied.
12
+ - Preserve the original failure, exact execution correlation, evidence, and idempotency throughout retries and resumes.
13
+
14
+ ## Unified architecture
15
+
16
+ All three agent roles publish through the **same Task event contract**. Events Bus treats `data` as opaque; Relay interprets role-specific results and alone changes durable Task/Job state.
17
+
18
+ ```mermaid
19
+ flowchart TD
20
+ I["Implementation"] --> U["Unified Task events"]
21
+ V["Reviewer"] --> U
22
+ T["Troubleshooter"] --> U
23
+ U --> B["Neutral Events Bus"]
24
+ B --> C["Relay event consumer"]
25
+ C --> S["Validate execution identity and role-specific data"]
26
+ S --> W["Relay workflow engine / reconciler"]
27
+ W --> D[("Durable Task and Job state")]
28
+ W --> R["Execution / recovery / human-interaction operations"]
29
+ ```
30
+
31
+ - Agents publish `task.completed` or `task.failed` as terminal execution outcomes; Reviewer verdicts and Troubleshooter recovery evidence are results inside `data`.
32
+ - `task.blocked` / `task.unblocked` describe Relay-authorized state changes, not a third agent terminal outcome.
33
+ - Transport does not interpret verdicts, execution IDs, blocker relations, or recovery decisions.
34
+
35
+ ## Overall flow — Task outcome and recovery branches
36
+
37
+ This captures the overall decision tree in the accompanying design screenshot, with the **human-action branch corrected** to the agreed ownership: Relay blocks the **Troubleshooter Task**, not the original failed Task.
38
+
39
+ ```mermaid
40
+ flowchart TD
41
+ A["Worker / Reviewer"] --> B{"Task outcome"}
42
+ B -->|Success| C["task.completed"]
43
+ B -->|Failure| D["task.failed"]
44
+ D --> E["Relay dispatches Troubleshooter"]
45
+ E --> F{"Recovery decision"}
46
+ F -->|Technical recovery| G["Fix and verify; request retry"]
47
+ G --> H["Original Task resumes"]
48
+ F -->|Other Job required| I["Create/link blocker Job"]
49
+ I --> J["Dependency resolution"]
50
+ J --> K["Original Task resumes after verification"]
51
+ F -->|Human required| L["Troubleshooter requests escalation"]
52
+ L --> M["Relay records human action and blocks TROUBLESHOOTER"]
53
+ M --> N["Wait for human action"]
54
+ N --> O["Human unblocks TROUBLESHOOTER"]
55
+ O --> P["Troubleshooter resumes; verifies result"]
56
+ P --> Q{"Problem resolved?"}
57
+ Q -->|Yes| R["Relay retries original Task"]
58
+ Q -->|No| F
59
+ R --> S["Original Task resumes"]
60
+ ```
61
+
62
+ The original Task remains `FAILED` while its Troubleshooter is blocked for a human action. Technical and dependent-Job paths may close the Troubleshooter Task after durable recovery or handoff; the human-action path resumes that same recovery responsibility after authorized unblock.
63
+
64
+ ## Flow 1 — Failure → Troubleshooter → Recovery
65
+
66
+ ```mermaid
67
+ flowchart TD
68
+ A["Implementation / Reviewer task.failed"] --> B["Relay records failure and dispatches Troubleshooter"]
69
+ B --> C["Troubleshooter inspects events, Task, logs; diagnoses cause"]
70
+ C --> D{"Selected resolution"}
71
+ D -->|Repairable now| E["Repair / verify technical cause"]
72
+ E --> F["Request original Task retry"]
73
+ D -->|Needs another Job| G["Create or reuse blocker Job; register dependency"]
74
+ G --> H["Troubleshooter completes after durable registration"]
75
+ D -->|Requires real user action| I["Enter Flow 2: human escalation"]
76
+ F --> J["Relay validates and requeues original Task"]
77
+ J --> K["Troubleshooter completes after durable recovery evidence"]
78
+ ```
79
+
80
+ 1. Original agent reports `task.failed` with useful diagnostic `data`; Relay durably records `FAILED`.
81
+ 2. Reconciler starts a correlated Troubleshooter Task. Troubleshooter examines durable Task history and technical evidence before choosing a resolution.
82
+ 3. For a technical remedy, Troubleshooter repairs/validates, requests authorized retry, confirms the original Task requeued, and completes **its own** Task.
83
+ 4. For a blocker Job, Troubleshooter registers a durable dependency, then completes **its own** Task; the dependency mechanism later wakes the original Task after verified resolution.
84
+ 5. Only a **real user action** calls for human escalation (Flow 2). Missing credentials that can be found or configured automatically, crashes, and retries are Troubleshooter work, not escalation.
85
+ 6. If Troubleshooter itself encounters an unrecoverable execution failure, it emits `task.failed`; Relay handles that failure without discarding original context.
86
+
87
+ ## Flow 2 — Human Escalation → Blocked Troubleshooter → Human Unblock → Original Task Recovery
88
+
89
+ ```mermaid
90
+ flowchart TD
91
+ A["Troubleshooter determines real human action is required"] --> B["Troubleshooter triggers escalation request"]
92
+ B --> C["Relay durably records required action, context, and escalation ID"]
93
+ C --> D["Relay marks TROUBLESHOOTER Task BLOCKED"]
94
+ D --> E["Human acts and submits response"]
95
+ E --> F["Human authorizes unblock of TROUBLESHOOTER"]
96
+ F --> G["Relay validates authorization, records response, resumes Troubleshooter"]
97
+ G --> H["Troubleshooter verifies action and re-diagnoses"]
98
+ H --> I{"Original failure now recoverable?"}
99
+ I -->|Yes| J["Troubleshooter requests original Task unblock/retry"]
100
+ J --> K["Relay validates and requeues original Task"]
101
+ K --> L["Troubleshooter task.completed"]
102
+ I -->|No, still needs user| B
103
+ I -->|No, technical issue| M["Continue Flow 1 recovery"]
104
+ M --> H
105
+ ```
106
+
107
+ 1. **Troubleshooter** selects escalation as its recovery resolution and requests a concrete user action. Escalation is an interaction mechanism, **not a second problem-solving agent**.
108
+ 2. Relay durably records the escalation ID, original failed Task/execution, Troubleshooter Task/execution, required human action, resolution condition, communication evidence/status, and audit trail; then it authoritatively moves the **Troubleshooter Task** to `BLOCKED`. The original Task stays `FAILED` (pending recovery), not `BLOCKED` merely because the Troubleshooter awaits a human.
109
+ 3. While awaiting human action, no Troubleshooter execution continues running. Notification failures and duplicate requests must not silently create orphaned BLOCKED Tasks.
110
+ 4. Human performs the requested action and explicitly unblocks the **Troubleshooter** through an authorized Relay operation. Relay stores the response and resumes Troubleshooter with its previous diagnostic context.
111
+ 5. Troubleshooter validates whether the action resolved the problem. Human saying “done” is evidence, not by itself proof of technical resolution (unless the requested action was itself an authoritative approval).
112
+ 6. If resolved, Troubleshooter requests Relay to unblock/retry the **original Task**, verifies the durable transition, then emits `task.completed` for its own Task. If unresolved, it continues technical diagnosis or initiates another recorded escalation.
113
+ 7. Relay guards against stale execution IDs, cancelled/superseded Tasks, duplicate human responses, and paused Jobs. No old response may resurrect obsolete work.
114
+
115
+ ## Event contract versus state transitions
116
+
117
+ | Actor | Action | Meaning |
118
+ | --- | --- | --- |
119
+ | Implementation / Reviewer | `task.completed` | Assigned work finished; Reviewer verdict is in `data` |
120
+ | Implementation / Reviewer | `task.failed` | Execution could not fulfill assigned work |
121
+ | Troubleshooter | `task.completed` | Technical recovery verified, or durable blocker-Job handoff established |
122
+ | Troubleshooter | `task.failed` | Troubleshooter execution failed |
123
+ | Troubleshooter | Escalation request (Relay operation) | Request a specific real human action; does **not** complete its own Task |
124
+ | Relay | Block / unblock Troubleshooter | Authoritative lifecycle actions before/after human interaction |
125
+ | Relay | Retry / unblock original Task | Authorized transition **only after** Troubleshooter establishes recovery |
126
+ | Events Bus | Transport generic events | No knowledge of task roles, blocker semantics, or response structure |
127
+
128
+ `BLOCKED` is a durable Task state and `task.blocked` may be published by Relay as a state-change fact; it is **not** an agent-selected terminal outcome. Human unblocking resumes the blocked Troubleshooter Task rather than terminating or re-creating its recovery responsibility.
129
+
130
+ ## Required follow-up implementation alignment
131
+
132
+ - Align `skills/agents-relay/SKILL.md` and role instructions with the two agent terminal events; remove agent-directed `task.blocked` emission.
133
+ - Align escalation graph, CLI, reconciler, and unblock commands so human intervention blocks **Troubleshooter**, not the original failed Task; preserve the original failure and correlated recovery context.
134
+ - Ensure the blocked Troubleshooter can resume after an authorized human unblock rather than being treated as a finished terminal execution.
135
+ - Add integrated checks: technical retry, blocker-Job handoff, human request/block, human unblock/resume, failed verification/re-escalation, stale/duplicate responses, and correct original-Task retry.
@@ -178,6 +178,7 @@ leaseFailRunningMark --> leaseFailRunningClearLease
178
178
  leaseFailRunningClearLease --> leaseFailRunningClearExecution
179
179
  leaseFailRunningClearExecution --> failed
180
180
  running -->|on: task.cancelled| cancelled
181
+ running -->|on: pause| queued
181
182
  running -->|on: cancel| cancelled
182
183
 
183
184
  autonomousJob["guard: autonomousJob"]