agents-relay 2.0.80 → 2.0.82

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.
Files changed (41) hide show
  1. package/README.md +3 -3
  2. package/dist/cli-help.js +5 -8
  3. package/dist/cli-review.js +1 -1
  4. package/dist/cli-support.js +2 -22
  5. package/dist/cli-worker-task.js +7 -6
  6. package/dist/cli.js +22 -59
  7. package/dist/dashboard-actions.js +2 -1
  8. package/dist/dashboard-html.js +5 -5
  9. package/dist/dashboard.js +0 -4
  10. package/dist/github-binding-order.js +1 -1
  11. package/dist/job-pr-number-migration.js +24 -0
  12. package/dist/job-task-id-sequence.js +16 -9
  13. package/dist/legacy-orchestrator-repair.js +51 -0
  14. package/dist/local-job-registry.js +2 -3
  15. package/dist/local-merge-delivery.js +1 -1
  16. package/dist/managed-worker-input.js +3 -4
  17. package/dist/planner.js +14 -8
  18. package/dist/pool.js +3 -1
  19. package/dist/reconciler-review.js +7 -4
  20. package/dist/reconciler-runtime.js +7 -0
  21. package/dist/reconciler.js +2 -8
  22. package/dist/relay-store.js +6 -4
  23. package/dist/relayd-control.js +10 -0
  24. package/dist/relayd-dashboard-actions.js +3 -33
  25. package/dist/relayd.js +17 -20
  26. package/dist/review-generation-fence.js +8 -0
  27. package/dist/review-terminal-normalization.js +27 -0
  28. package/dist/reviewer-thread.js +1 -1
  29. package/dist/runtime-status.js +2 -1
  30. package/dist/task-actions.js +47 -42
  31. package/dist/task-title.js +5 -0
  32. package/dist/troubleshooter.js +2 -2
  33. package/dist/worker-routing.js +1 -1
  34. package/dist/workflow/entity-creation.js +11 -1
  35. package/dist/workflow/orchestrator-rescue.js +62 -18
  36. package/dist/workflow/troubleshooter-v2.js +1 -1
  37. package/dist/workspace.js +5 -4
  38. package/package.json +1 -1
  39. package/skills/agents-relay/contracts/orchestrator-workflows.md +13 -12
  40. package/workflow/orchestrator-rescue.mmd +16 -1
  41. package/workflow/troubleshooter.mmd +4 -4
@@ -7,8 +7,6 @@ export const TASK_UNBLOCK_RETRY_ACTION = 'task.unblock.retry';
7
7
  export const TASK_CANCEL_ACTION = 'task.cancel';
8
8
  export const TASK_RELEASE_ACTION = 'task.release';
9
9
  export const TASK_HOLD_ACTION = 'task.hold';
10
- export const TASK_START_ACTION = 'task.start';
11
- export const TASK_COMPLETE_ACTION = 'task.complete';
12
10
  export const TASK_QUEUE_ACTION = 'task.queue';
13
11
  function lifecycleRejection(task, action) {
14
12
  return new Error(`Task ${task.id} is not ${action} (${task.state})`);
@@ -100,42 +98,6 @@ export async function applyTaskQueueAction(job, task) {
100
98
  clearExecution(task);
101
99
  return task;
102
100
  }
103
- export async function applyOrchestratorStartAction(task) {
104
- if (task.adapter !== 'orchestrator')
105
- throw new Error('task start is only valid for adapter=orchestrator self-tasks');
106
- const originalState = task.state;
107
- try {
108
- await transitionTaskEvent(task, 'task.started');
109
- }
110
- catch (error) {
111
- if (error instanceof Error && error.message.startsWith('No enabled workflow transition')) {
112
- throw new Error(`Task ${task.id} is ${originalState}; only QUEUED orchestrator tasks can be started`);
113
- }
114
- throw error;
115
- }
116
- task.attempt = Math.max(task.attempt, 1);
117
- task.updatedAt = new Date().toISOString();
118
- return task;
119
- }
120
- export async function applyOrchestratorCompleteAction(task, summary) {
121
- if (task.adapter !== 'orchestrator')
122
- throw new Error('task complete is only valid for adapter=orchestrator self-tasks');
123
- const originalState = task.state;
124
- if (task.state !== 'RUNNING')
125
- throw new Error(`Task ${task.id} is ${originalState}; orchestrator task must be started before completion`);
126
- task.result = { summary };
127
- clearExecution(task);
128
- try {
129
- await transitionTaskEvent(task, 'task.completed');
130
- }
131
- catch (error) {
132
- if (error instanceof Error && error.message.startsWith('No enabled workflow transition')) {
133
- throw new Error(`Task ${task.id} is ${originalState}; only RUNNING orchestrator tasks can be completed`);
134
- }
135
- throw error;
136
- }
137
- return task;
138
- }
139
101
  async function applySelectedRelatedTaskMutations(job, mutations) {
140
102
  const changed = [];
141
103
  for (const mutation of mutations) {
@@ -148,6 +110,45 @@ async function applySelectedRelatedTaskMutations(job, mutations) {
148
110
  }
149
111
  return changed;
150
112
  }
113
+ function markCancelledTaskSuperseded(task, reason) {
114
+ if (task.kind === 'planner')
115
+ throw new Error(`Legacy planner Task ${task.id} is read-only`);
116
+ if (task.state !== 'CANCELLED')
117
+ throw new Error(`Task ${task.id} must already be CANCELLED before supersede annotation`);
118
+ if (!reason.trim())
119
+ throw new Error('task supersede requires a reason');
120
+ const proposal = { path: 'cancel_supersede', targetTaskId: task.id, rootCause: reason.trim(), solution: 'Superseded by later corrected durable work' };
121
+ const decision = dispatchTaskResolutionSync(task, proposal, 'external', { cancelSupersede: () => {
122
+ task.resolution = { ...materializeResolution(task, proposal, 'external'), state: 'resolved' };
123
+ task.error = `Superseded ${reason.trim()}`;
124
+ task.updatedAt = new Date().toISOString();
125
+ } });
126
+ if (!decision.accepted)
127
+ throw new Error(`Task resolution graph rejected cancellation supersede for ${task.id}`);
128
+ }
129
+ export async function supersedeCancelledTask(store, jobId, taskId, reason) {
130
+ if (store.mutateJob) {
131
+ let updatedTask;
132
+ const updated = await store.mutateJob(jobId, current => {
133
+ const task = current.tasks.find(item => item.id === taskId);
134
+ if (!task)
135
+ throw new Error(`Task ${taskId} not found`);
136
+ markCancelledTaskSuperseded(task, reason);
137
+ updatedTask = structuredClone(task);
138
+ return current;
139
+ });
140
+ if (!updatedTask)
141
+ throw new Error(`Task ${taskId} was not updated`);
142
+ return { job: updated, task: updatedTask };
143
+ }
144
+ const job = await store.load(jobId);
145
+ const task = job.tasks.find(item => item.id === taskId);
146
+ if (!task)
147
+ throw new Error(`Task ${taskId} not found`);
148
+ markCancelledTaskSuperseded(task, reason);
149
+ await store.saveTask(task);
150
+ return { job: await store.load(jobId), task };
151
+ }
151
152
  /** Compatibility bridge: domain mutation only; adapters own wake/reconcile/output behavior. */
152
153
  export async function dispatchTaskAction(store, request) {
153
154
  if (store.mutateJob && [TASK_RETRY_ACTION, TASK_RECOVER_ACTION, TASK_CANCEL_ACTION].includes(request.id)) {
@@ -162,8 +163,11 @@ export async function dispatchTaskAction(store, request) {
162
163
  throw new Error(`task recover requires an active Troubleshooter recovery for ${task.id}`);
163
164
  }
164
165
  const relatedMutations = request.id === TASK_RECOVER_ACTION ? [] : selectRelatedTaskMutations(current, task, request.id);
165
- if (request.id === TASK_RETRY_ACTION)
166
+ if (request.id === TASK_RETRY_ACTION) {
166
167
  applyTaskRetrySnapshot(task, { extendBudget: true });
168
+ task.routing = undefined;
169
+ task.workerSelection = undefined;
170
+ }
167
171
  else if (request.id === TASK_RECOVER_ACTION) {
168
172
  const proposal = { path: 'exact_original_retry', targetTaskId: task.id, rootCause: task.failureReason ?? task.error ?? 'durable failure', solution: `Recover exact original Task via ${request.recoveryTaskId}` };
169
173
  const decision = dispatchTaskResolutionSync(task, proposal, 'troubleshooter', { activateRecovery: () => { applyTaskRetrySnapshot(task, { extendBudget: true }); task.resolution = { ...materializeResolution(task, proposal, 'troubleshooter'), state: 'resolved' }; } });
@@ -190,8 +194,11 @@ export async function dispatchTaskAction(store, request) {
190
194
  if (!task)
191
195
  throw new Error(`Task ${request.taskId} not found`);
192
196
  const relatedMutations = request.id === TASK_RECOVER_ACTION ? [] : selectRelatedTaskMutations(job, task, request.id);
193
- if (request.id === TASK_RETRY_ACTION)
197
+ if (request.id === TASK_RETRY_ACTION) {
194
198
  await applyTaskRetryAction(task, { extendBudget: true });
199
+ task.routing = undefined;
200
+ task.workerSelection = undefined;
201
+ }
195
202
  else if (request.id === TASK_RECOVER_ACTION) {
196
203
  const recovery = request.recoveryTaskId ? job.tasks.find(item => item.id === request.recoveryTaskId) : undefined;
197
204
  if (!recovery || recovery.agentName !== 'troubleshooter' || recovery.parentTaskId !== task.id || !['QUEUED', 'RUNNING'].includes(recovery.state))
@@ -209,8 +216,6 @@ export async function dispatchTaskAction(store, request) {
209
216
  await applyTaskReleaseAction(task);
210
217
  else if (request.id === TASK_HOLD_ACTION)
211
218
  await applyTaskHoldAction(task);
212
- else if (request.id === TASK_START_ACTION)
213
- await applyOrchestratorStartAction(task);
214
219
  else if (request.id === TASK_QUEUE_ACTION)
215
220
  await applyTaskQueueAction(job, task);
216
221
  else
@@ -0,0 +1,5 @@
1
+ export function deriveTaskTitle(input, fallback = 'Task') {
2
+ const first = String(input ?? '').split(/\r?\n/).map(line => line.trim()).find(Boolean) ?? fallback;
3
+ const cleaned = first.replace(/^#{1,6}\s+/, '').replace(/^[-*+]\s+/, '').replace(/^\d+[.)]\s+/, '').replace(/\s+/g, ' ').trim() || fallback;
4
+ return cleaned.length <= 96 ? cleaned : cleaned.slice(0, 93).trimEnd() + '...';
5
+ }
@@ -51,10 +51,10 @@ export function pendingTroubleshooterTasks(job) {
51
51
  const id = troubleshooterTaskId(task);
52
52
  let create = false;
53
53
  const decision = dispatchTroubleshooter({
54
- failedOrBlocked: ['FAILED', 'BLOCKED'].includes(task.state),
54
+ failedWork: task.state === 'FAILED',
55
55
  ordinaryWork: task.kind !== 'planner' && task.agentName !== 'troubleshooter',
56
56
  currentRun: belongsToCurrentRun(job, task),
57
- needsRecovery: !(task.state === 'BLOCKED' && Boolean(task.unblock)),
57
+ needsRecovery: true,
58
58
  idUnused: !existing.has(id),
59
59
  noRecoveryBarrier: !hasRecoveryBarrier(job, task),
60
60
  }, () => { create = true; });
@@ -48,7 +48,7 @@ export function usageForProvider(snapshot, provider, now = Date.now()) {
48
48
  return {};
49
49
  }
50
50
  export function workerCandidates(adapters, registered, snapshot) {
51
- return adapters.filter(adapter => adapter.name !== 'orchestrator').flatMap(adapter => {
51
+ return adapters.flatMap(adapter => {
52
52
  const workers = registered.filter(worker => worker.runtime.adapter === adapter.name);
53
53
  return (workers.length > 0 ? workers : [undefined]).map(worker => {
54
54
  const provider = worker?.runtime.provider ?? (adapter.name === 'chatgpt' ? 'chatgpt' : undefined);
@@ -1,17 +1,27 @@
1
1
  import { dispatchJobLifecycleSync, dispatchTaskLifecycleSync } from './lifecycle.js';
2
+ import { deriveTaskTitle } from '../task-title.js';
2
3
  function dependenciesSatisfied(job, dependencies) {
3
4
  if (dependencies.length === 0)
4
5
  return true;
5
6
  const byId = new Map(job.tasks.map(task => [task.id, task]));
6
7
  return dependencies.every(id => byId.get(id)?.state === 'SUCCEEDED');
7
8
  }
9
+ function validateTaskRequirements(value) {
10
+ if (value.adapter === 'orchestrator') {
11
+ throw new Error('New Tasks cannot use the Relay-internal orchestrator adapter; declare worker requirements and let Execution Router select the route');
12
+ }
13
+ if (value.workerRequirements && !Array.isArray(value.workerRequirements.capabilities)) {
14
+ throw new Error(`Task ${value.id} workerRequirements.capabilities must be an array`);
15
+ }
16
+ }
8
17
  /**
9
18
  * Canonical creation boundary for new Task entities.
10
19
  * Constructors provide metadata/facts only; the Task lifecycle graph derives
11
20
  * whether a fresh Task remains DRAFT or becomes QUEUED.
12
21
  */
13
22
  export function createTaskEntity(job, value, options = {}) {
14
- const task = { ...value, state: 'DRAFT' };
23
+ validateTaskRequirements(value);
24
+ const task = { ...value, title: value.title?.trim() || deriveTaskTitle(value.input, value.agentName ? `${value.agentName} task` : 'Task'), state: 'DRAFT' };
15
25
  if (!options.defer) {
16
26
  dispatchTaskLifecycleSync(task, 'reconcile', {
17
27
  dependenciesSatisfied: dependenciesSatisfied(job, task.dependencies),
@@ -1,42 +1,86 @@
1
1
  import { readFileSync } from 'node:fs';
2
2
  import { dispatchWorkflowGraphSync } from './dispatcher.js';
3
3
  import { GraphRuntime } from './graph-v2.js';
4
+ import { tryTransitionTaskEvent } from '../scheduler.js';
5
+ import { taskOverallTimeoutMs } from '../types.js';
4
6
  export const STALE_ORCHESTRATOR_TASK_MS = 30 * 60 * 1000;
5
7
  const source = readFileSync(new URL('../../workflow/orchestrator-rescue.mmd', import.meta.url), 'utf8');
6
8
  const boolGuard = (name) => (facts) => facts.durableFacts[name] === true;
7
9
  export const orchestratorRescueV2 = new GraphRuntime(source, {
8
- states: new Set(['idle', 'check', 'skip', 'rescued']),
9
- guards: { rescuable: boolGuard('rescuable') },
10
- actions: { 'task.rescueOrchestrator': { id: 'task.rescueOrchestrator' } },
10
+ states: new Set(['idle', 'check', 'external_completed', 'blocked', 'skip', 'rescued']),
11
+ guards: {
12
+ legacyActive: boolGuard('legacyActive'),
13
+ verifiedExternalCompletion: boolGuard('verifiedExternalCompletion'),
14
+ unknownStartedOutcome: boolGuard('unknownStartedOutcome'),
15
+ rescuable: boolGuard('rescuable'),
16
+ },
17
+ actions: {
18
+ 'task.applyExternalCompletion': { id: 'task.applyExternalCompletion' },
19
+ 'task.blockLegacyOrchestrator': { id: 'task.blockLegacyOrchestrator' },
20
+ 'task.rescueOrchestrator': { id: 'task.rescueOrchestrator' },
21
+ },
11
22
  events: { 'orchestrator.rescue.reconcile': { id: 'orchestrator.rescue.reconcile' } },
12
23
  });
13
24
  export function orchestratorRescueFacts(task, now) {
14
- const createdAt = Date.parse(task.createdAt);
15
- const oldEnough = Number.isFinite(createdAt) && now - createdAt >= STALE_ORCHESTRATOR_TASK_MS;
16
- const urgentRepair = task.priority === 'P0' && task.agentName === 'troubleshooter';
17
- return { rescuable: task.kind !== 'planner' && task.adapter === 'orchestrator' && task.state === 'QUEUED'
18
- && task.executionId === null && (oldEnough || urgentRepair) };
25
+ const legacyActive = task.kind !== 'planner' && task.adapter === 'orchestrator'
26
+ && (task.state === 'QUEUED' || task.state === 'RUNNING');
27
+ const verifiedExternalCompletion = legacyActive && task.result !== null
28
+ && task.result.data?.externalCompletionVerified === true;
29
+ return {
30
+ legacyActive,
31
+ verifiedExternalCompletion,
32
+ unknownStartedOutcome: legacyActive && task.state === 'RUNNING' && !verifiedExternalCompletion
33
+ && (!task.leaseExpiresAt || Date.parse(task.leaseExpiresAt) <= now),
34
+ rescuable: legacyActive && task.state === 'QUEUED' && !verifiedExternalCompletion
35
+ && (!task.leaseExpiresAt || Date.parse(task.leaseExpiresAt) <= now),
36
+ };
19
37
  }
20
38
  export function decideOrchestratorRescue(task, now) {
21
39
  return dispatchWorkflowGraphSync(orchestratorRescueV2, {
22
40
  initialState: 'idle', input: { transport: 'event', id: 'orchestrator.rescue.reconcile', payload: {} },
23
41
  durableFacts: orchestratorRescueFacts(task, now), graphName: 'orchestrator-rescue',
24
- isTransientState: state => state === 'check', maxSteps: 2,
42
+ isTransientState: state => state === 'check', maxSteps: 4,
25
43
  });
26
44
  }
27
- export function applyOrchestratorRescue(task, now) {
45
+ export function prepareOrchestratorRepair(task, now) {
28
46
  const decision = decideOrchestratorRescue(task, now);
29
- const rescued = decision.accepted && decision.actions.some(action => action.id === 'task.rescueOrchestrator');
47
+ const externallyCompleted = decision.accepted
48
+ && decision.actions.some(action => action.id === 'task.applyExternalCompletion');
49
+ const blocked = decision.accepted
50
+ && decision.actions.some(action => action.id === 'task.blockLegacyOrchestrator');
51
+ const rescued = externallyCompleted || blocked
52
+ || (decision.accepted && decision.actions.some(action => action.id === 'task.rescueOrchestrator'));
30
53
  const ageMs = now - Date.parse(task.createdAt);
54
+ if (decision.accepted && externallyCompleted) {
55
+ tryTransitionTaskEvent(task, 'task.completed');
56
+ task.leaseOwner = null;
57
+ task.leaseExpiresAt = null;
58
+ task.executionId = null;
59
+ task.threadId = null;
60
+ task.updatedAt = new Date(now).toISOString();
61
+ return { repaired: true, externallyCompleted: true, blocked: false, ageMs };
62
+ }
31
63
  if (!rescued)
32
- return { rescued: false, ageMs };
33
- const urgentRepair = task.priority === 'P0' && task.agentName === 'troubleshooter';
34
- task.adapter = urgentRepair ? 'codex' : 'chatgpt';
35
- task.workerRequirements = { capabilities: [...task.capabilities, ...(urgentRepair ? ['local_repo'] : [])],
36
- runtime: urgentRepair ? 'codex' : 'chatgpt' };
64
+ return { repaired: false, externallyCompleted: false, blocked: false, ageMs };
65
+ if (blocked) {
66
+ tryTransitionTaskEvent(task, 'task.blocked');
67
+ task.error = 'Legacy orchestrator execution outcome is unknown; automatic replay is unsafe. Verify external effects and create replacement validation/recovery work before unblocking.';
68
+ task.leaseOwner = null;
69
+ task.leaseExpiresAt = null;
70
+ task.executionId = null;
71
+ task.threadId = null;
72
+ task.updatedAt = new Date(now).toISOString();
73
+ return { repaired: true, externallyCompleted: false, blocked: true, ageMs };
74
+ }
75
+ task.adapter = 'codex';
76
+ task.workerRequirements = { capabilities: [...task.capabilities] };
37
77
  task.workerSelection = undefined;
38
78
  task.routing = undefined;
39
- task.timeoutMs = 0;
79
+ task.leaseOwner = null;
80
+ task.leaseExpiresAt = null;
81
+ task.executionId = null;
82
+ task.threadId = null;
83
+ task.timeoutMs = taskOverallTimeoutMs(task.storySize ?? 'M');
40
84
  task.updatedAt = new Date(now).toISOString();
41
- return { rescued: true, ageMs };
85
+ return { repaired: true, externallyCompleted: false, blocked: false, ageMs };
42
86
  }
@@ -6,7 +6,7 @@ const boolGuard = (name) => (facts) => facts.durableFacts[name] === true;
6
6
  export const troubleshooterV2 = new GraphRuntime(source, {
7
7
  states: new Set(['idle', 'check_ordinary', 'check_run', 'check_needs', 'check_id', 'check_barrier', 'skip', 'create']),
8
8
  guards: {
9
- failedOrBlocked: boolGuard('failedOrBlocked'),
9
+ failedWork: boolGuard('failedWork'),
10
10
  ordinaryWork: boolGuard('ordinaryWork'),
11
11
  currentRun: boolGuard('currentRun'),
12
12
  needsRecovery: boolGuard('needsRecovery'),
package/dist/workspace.js CHANGED
@@ -217,7 +217,7 @@ export async function ensureLocalJobSourceBranch(projectPath, job) {
217
217
  return sourceRef;
218
218
  }
219
219
  export async function prepareLocalJobExecutionWorktree(projectPath, job, workspaceRoot = defaultWorkspaceRoot()) {
220
- if (job.prNumber !== 0 || !job.localMerge?.sourceRef)
220
+ if (!job.localMerge?.sourceRef)
221
221
  return resolveRepositoryProjectPath(projectPath, job.projectPath);
222
222
  const sourceRef = job.localMerge.sourceRef;
223
223
  if (!/^refs\/heads\/[A-Za-z0-9][A-Za-z0-9._/-]*$/.test(sourceRef) || sourceRef.includes('..') || sourceRef.endsWith('.lock'))
@@ -285,14 +285,15 @@ export async function prepareLocalJobExecutionWorktree(projectPath, job, workspa
285
285
  export async function cleanupCompletedJobWorktree(projectPath, job, workspaceRoot = defaultWorkspaceRoot()) {
286
286
  if (job.state !== 'COMPLETED')
287
287
  return { status: 'blocked', reason: 'Job is not completed' };
288
- const expectedHead = job.prNumber === 0 ? (job.localMerge?.sourceSha ?? job.review?.headSha ?? job.headSha) : job.headSha;
288
+ const isLocalJob = Boolean(job.localMerge?.sourceRef);
289
+ const expectedHead = isLocalJob ? (job.localMerge?.sourceSha ?? job.review?.headSha ?? job.headSha) : job.headSha;
289
290
  if (!expectedHead)
290
291
  return { status: 'blocked', reason: 'No durable Job head SHA' };
291
292
  if (job.tasks.some(task => task.executionId !== null || task.leaseOwner !== null))
292
293
  return { status: 'blocked', reason: 'Task still owns an execution or lease' };
293
294
  if ((job.plans ?? []).some(plan => plan.executionId !== null || plan.leaseOwner !== null))
294
295
  return { status: 'blocked', reason: 'Planner still owns an execution or lease' };
295
- const worktree = job.prNumber === 0 ? managedLocalJobWorktreePath(job.repository, job.id, workspaceRoot) : managedJobWorktreePath(job.repository, job.prNumber, workspaceRoot);
296
+ const worktree = isLocalJob ? managedLocalJobWorktreePath(job.repository, job.id, workspaceRoot) : managedJobWorktreePath(job.repository, job.prNumber, workspaceRoot);
296
297
  let metadata;
297
298
  try {
298
299
  metadata = await lstat(worktree);
@@ -311,7 +312,7 @@ export async function cleanupCompletedJobWorktree(projectPath, job, workspaceRoo
311
312
  }
312
313
  catch { /* Local-only repositories may have no origin. */ }
313
314
  const originRepository = origin ? githubRepositoryFromRemote(origin) : null;
314
- if ((job.prNumber > 0 && originRepository !== job.repository) || (job.prNumber === 0 && originRepository && originRepository !== job.repository))
315
+ if ((job.prNumber > 0 && !isLocalJob && originRepository !== job.repository) || (isLocalJob && originRepository && originRepository !== job.repository))
315
316
  return { status: 'blocked', reason: 'Canonical repository identity mismatch' };
316
317
  const registered = await git(primary, ['worktree', 'list', '--porcelain']);
317
318
  const ownedRoot = await realpath(worktree);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "2.0.80",
3
+ "version": "2.0.82",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {
@@ -24,18 +24,19 @@ The initial decomposition belongs to that durable Planner round. The orchestrato
24
24
 
25
25
  ## P0 / self job workflow
26
26
 
27
- A **P0 job** (alias: **self job**) is executed directly by the current active orchestrator thread:
28
-
29
- 1. The orchestrator creates the durable job.
30
- 2. The orchestrator immediately creates the required orchestrator-owned task(s); do not leave the P0 job idle.
31
- 3. The current orchestrator thread starts executing those tasks immediately rather than delegating them to another worker. Creating an `adapter: orchestrator` Task is never a handoff: the exact creator owns it for this turn.
32
- 4. The orchestrator must terminalize every orchestrator-owned Task it created before ending the turn. It must not return to the user with one of its self-tasks left `QUEUED` or `RUNNING`; successful work uses `task complete` on that same durable Task.
33
- 5. P0/self jobs do not require the normal reviewer gate unless the user explicitly asks for review.
34
- 6. After implementation and local verification, the orchestrator merges the job itself and completes normal post-merge reconciliation.
35
-
36
- Recovery exception: reconciliation may rescue only a `QUEUED` work Task with `adapter: orchestrator` that has no execution identity and is at least 30 minutes past `createdAt`. It converts that Task in place to a normal ChatGPT-routable Task, preserving its durable intent and Task identity while clearing stale routing state. `RUNNING` and terminal Tasks are not rescued. This is a stale-task safety net, not an asynchronous handoff mechanism for newly created self-work.
37
-
38
- `job create` still only creates durable job state; the P0 workflow requires the orchestrator to follow it immediately with task creation and execution.
27
+ P0/self work is ordinary durable Relay work. The orchestrator may choose normal
28
+ Task requirements, but every newly created work Task is executable by a
29
+ worker/model route selected by Execution Router. Relay has no privileged
30
+ orchestrator-worker lifecycle, and new Task creation rejects
31
+ `adapter: orchestrator`. P0/self jobs do not require the normal reviewer gate
32
+ unless the user explicitly asks for review; Relay-owned merge and completion
33
+ lifecycle remain in their canonical graphs.
34
+
35
+ Legacy recovery: existing `adapter: orchestrator` work Tasks may be repaired only
36
+ through the canonical rescue graph. The repair converts only never-started QUEUED records in place to declarative worker requirements and lets Execution Router select the route. A legacy RUNNING record with an unknown outcome is quarantined as BLOCKED instead of replayed; only explicit durable external-completion verification may complete it without execution. Process reclamation is attempted only after the record is proven eligible for this legacy path, so normal live workers are never touched by the migration scan.
37
+
38
+ `job create --initial-plan` remains the normal way to create durable planning
39
+ state and hand subsequent execution to Relay.
39
40
 
40
41
  ## Merge completion vs deployment evidence
41
42
 
@@ -2,12 +2,27 @@ flowchart LR
2
2
  %% relay: initial idle
3
3
  idle["state: idle"]
4
4
  check["state: check"]
5
+ external["state: external_completed"]
6
+ blocked["state: blocked"]
5
7
  skip["state: skip"]
6
8
  rescued["state: rescued"]
9
+ legacyActive["guard: legacyActive"]
10
+ verifiedExternalCompletion["guard: verifiedExternalCompletion"]
11
+ unknownStartedOutcome["guard: unknownStartedOutcome"]
7
12
  rescuable["guard: rescuable"]
8
13
  rescue["action: task.rescueOrchestrator"]
14
+ externalCompletion["action: task.applyExternalCompletion"]
15
+ quarantine["action: task.blockLegacyOrchestrator"]
9
16
  idle -->|on: orchestrator.rescue.reconcile| check
10
- check -->|on: orchestrator.rescue.reconcile| rescuable
17
+ check -->|on: orchestrator.rescue.reconcile| legacyActive
18
+ legacyActive -->|false| skip
19
+ legacyActive -->|true| verifiedExternalCompletion
20
+ verifiedExternalCompletion -->|true| externalCompletion
21
+ externalCompletion -->|to: external_completed| external
22
+ verifiedExternalCompletion -->|false| unknownStartedOutcome
23
+ unknownStartedOutcome -->|true| quarantine
24
+ quarantine -->|to: blocked| blocked
25
+ unknownStartedOutcome -->|false| rescuable
11
26
  rescuable -->|false| skip
12
27
  rescuable -->|true| rescue
13
28
  rescue -->|to: rescued| rescued
@@ -9,7 +9,7 @@ checkBarrier["state: check_barrier"]
9
9
  skip["state: skip"]
10
10
  create["state: create"]
11
11
 
12
- failedOrBlocked["guard: failedOrBlocked"]
12
+ failed["guard: failedWork"]
13
13
  ordinary["guard: ordinaryWork"]
14
14
  currentRun["guard: currentRun"]
15
15
  needsRecovery["guard: needsRecovery"]
@@ -17,9 +17,9 @@ idUnused["guard: idUnused"]
17
17
  noBarrier["guard: noRecoveryBarrier"]
18
18
  createAction["action: troubleshooter.create"]
19
19
 
20
- idle -->|on: troubleshooter.reconcile| failedOrBlocked
21
- failedOrBlocked -->|false| skip
22
- failedOrBlocked -->|true| checkOrdinary
20
+ idle -->|on: troubleshooter.reconcile| failed
21
+ failed -->|false| skip
22
+ failed -->|true| checkOrdinary
23
23
 
24
24
  checkOrdinary -->|on: troubleshooter.reconcile| ordinary
25
25
  ordinary -->|false| skip