agents-relay 2.0.108 → 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.
@@ -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,7 @@ 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';
44
45
  import { pauseJob, resumeJob } from './job-pause.js';
45
46
  export { ensureManagedGitHubJob } from './cli-job-create.js';
46
47
  async function runManagedTaskCommand(action, args) {
@@ -69,7 +70,7 @@ async function runManagedTaskCommand(action, args) {
69
70
  console.log(JSON.stringify(task, null, 2));
70
71
  }
71
72
  async function runTaskCommand(action, args) {
72
- 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))
73
74
  throw new Error(usage);
74
75
  const managed = !arg(args, '--file') && Boolean(arg(args, '--repo') && arg(args, '--id'));
75
76
  if (managed) {
@@ -142,9 +143,24 @@ async function runTaskCommand(action, args) {
142
143
  if (!taskId)
143
144
  throw new Error('task unblock requires --task-id');
144
145
  const reason = arg(args, '--reason');
145
- if (!reason.trim())
146
- throw new Error('task unblock requires --reason');
147
- 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') });
148
164
  console.log(JSON.stringify(job.tasks.find(item => item.id === taskId), null, 2));
149
165
  return;
150
166
  }
@@ -235,6 +251,18 @@ async function runTaskCommand(action, args) {
235
251
  await runTaskUnblockCommand(args, store, job, task, localFile);
236
252
  return;
237
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
+ }
238
266
  if (action === 'retry' || action === 'recover' || action === 'cancel') {
239
267
  let outputJob = job;
240
268
  let outputTask = task;
@@ -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.
@@ -80,6 +80,9 @@ export class Reconciler extends ReconcilerReviewBase {
80
80
  this.reconciling = true;
81
81
  try {
82
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);
83
86
  const previousState = job.state;
84
87
  const lifecycleFingerprint = (value) => `${value.state}|${value.tasks.map(task => `${task.id}:${task.state}`).join(',')}`;
85
88
  const beforeFingerprint = lifecycleFingerprint(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: {
@@ -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') {
package/package.json CHANGED
@@ -1,7 +1,11 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "2.0.108",
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.