agents-relay 1.0.5 → 1.0.7

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/events.js CHANGED
@@ -13,7 +13,7 @@ export class NatsEventBus {
13
13
  subjectPrefix;
14
14
  connection = null;
15
15
  module = null;
16
- constructor(url = 'nats://127.0.0.1:4222', subjectPrefix = 'agents-relay.events.job') {
16
+ constructor(url = 'nats://127.0.0.1:4222', subjectPrefix = 'neo.events.job') {
17
17
  this.url = url;
18
18
  this.subjectPrefix = subjectPrefix;
19
19
  if (!/^[A-Za-z0-9_.-]+$/.test(subjectPrefix))
package/dist/planner.js CHANGED
@@ -50,9 +50,14 @@ export function parsePlannerResult(value) {
50
50
  ids.add(id);
51
51
  if (typeof task.input !== 'string' || task.input.length === 0)
52
52
  throw new Error(`Planner next_tasks[${index}].input is required`);
53
- const adapter = task.adapter === undefined ? 'shell' : task.adapter;
54
- if (!['shell', 'codex', 'chatgpt', 'orchestrator'].includes(String(adapter)))
55
- throw new Error(`Planner next_tasks[${index}].adapter is invalid`);
53
+ const adapter = task.adapter;
54
+ if (adapter === undefined || !['codex', 'chatgpt'].includes(String(adapter)))
55
+ throw new Error(`Planner next_tasks[${index}].adapter must be codex or chatgpt`);
56
+ const output = task.output === undefined ? undefined : record(task.output);
57
+ if (!output || (output.kind !== 'task_pr' && output.kind !== 'file'))
58
+ throw new Error(`Planner next_tasks[${index}].output is required`);
59
+ if (output.kind === 'file' && (typeof output.path !== 'string' || output.path.length === 0))
60
+ throw new Error(`Planner next_tasks[${index}].output.path is required for file output`);
56
61
  if (task.priority !== undefined && !['P0', 'P1', 'P2', 'P3'].includes(String(task.priority)))
57
62
  throw new Error(`Planner next_tasks[${index}].priority is invalid`);
58
63
  const maxAttempts = task.maxAttempts === undefined ? 3 : task.maxAttempts;
@@ -65,7 +70,7 @@ export function parsePlannerResult(value) {
65
70
  id, input: task.input, priority: task.priority,
66
71
  projectName: optionalString(task.projectName, 'projectName'), agentName: optionalString(task.agentName, 'agentName'),
67
72
  dependencies: stringArray(task.dependencies, 'dependencies'), capabilities: stringArray(task.capabilities, 'capabilities'),
68
- adapter: adapter, routing: routing(task.routing, `next_tasks[${index}].routing`),
73
+ adapter: adapter, output: output, routing: routing(task.routing, `next_tasks[${index}].routing`),
69
74
  continuation: task.continuation, maxAttempts, timeoutMs
70
75
  };
71
76
  });
@@ -1,6 +1,5 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { schedule, transitionTask } from './scheduler.js';
3
- import { LaunchFailure } from './adapters.js';
4
3
  import { eventFor } from './events.js';
5
4
  import { parsePlannerResult, plannerInput } from './planner.js';
6
5
  const PRIORITY_RANK = { P0: 0, P1: 1, P2: 2, P3: 3 };
@@ -8,29 +7,67 @@ export function compareTaskPriority(a, b, jobPriority = 'P2') { const ar = PRIOR
8
7
  export function managedWorkerInput(job, task) {
9
8
  if (!['codex', 'chatgpt'].includes(task.adapter) || !job.repository || job.prNumber <= 0)
10
9
  return task.input;
10
+ if (!task.output)
11
+ throw new Error(`Task ${task.id} requires an explicit output contract`);
11
12
  const prUrl = `https://github.com/${job.repository}/pull/${job.prNumber}`;
12
- return `[Agents Relay managed task]\nPR: ${prUrl}\nJob: ${job.id}\nTask: ${task.id}\nParent task: ${task.parentTaskId ?? 'none'}\nProject: ${task.projectName ?? 'unspecified'}\n\n${task.input}`;
13
+ const output = task.output.kind === 'file'
14
+ ? `file\nPath: ${task.output.path}`
15
+ : `task/PR\nPR: ${prUrl}\nTask: ${task.id}`;
16
+ return `[Agents Relay managed task]
17
+ PR: ${prUrl}
18
+ Job: ${job.id}
19
+ Task: ${task.id}
20
+ Parent task: ${task.parentTaskId ?? 'none'}
21
+ Project: ${task.projectName ?? 'unspecified'}
22
+
23
+ ${task.input}
24
+
25
+ [Output contract]
26
+ ${output}
27
+
28
+ [Execution event contract]
29
+ Use the canonical Neo events-bus with exactly Job ${job.id} and Task ${task.id}.
30
+ Follow the events-bus skill/protocol. For sandboxed Codex or hosted ChatGPT,
31
+ publish through the federated MCP tool events__publish; do not open NATS directly.
32
+ For a direct non-sandbox local worker, use NEO_EVENTS_EMIT when the caller provides it.
33
+
34
+ You MAY publish progress events while working.
35
+ Before stopping, you MUST publish exactly one terminal event:
36
+ - task.completed only after the declared output is durable;
37
+ - task.failed when execution cannot complete;
38
+ - task.blocked when human/external action is required.
39
+ The ChatGPT/Codex conversation or process exit is never the task result.`;
13
40
  }
14
41
  export class Reconciler {
15
42
  store;
16
43
  options;
17
44
  live = new Map();
18
45
  plannerLive = new Map();
46
+ terminalTimers = new Map();
19
47
  continuationLive = new Set();
20
48
  reconciling = false;
21
49
  constructor(store, options) {
22
50
  this.store = store;
23
51
  this.options = options;
24
52
  }
25
- async watch(jobId) { if (!this.options.eventBus)
26
- return async () => { }; try {
27
- return await this.options.eventBus.subscribe(jobId, async (event) => { if (event.type !== 'job.wake' && event.type !== 'github.webhook')
28
- return; await this.reconcile(jobId); });
53
+ async watch(jobId) {
54
+ if (!this.options.eventBus)
55
+ return async () => { };
56
+ try {
57
+ return await this.options.eventBus.subscribe(jobId, async (event) => {
58
+ if (['task.completed', 'task.failed', 'task.blocked'].includes(event.type)) {
59
+ await this.applyTerminalEvent(event);
60
+ return;
61
+ }
62
+ if (event.type === 'job.wake' || event.type === 'github.webhook')
63
+ await this.reconcile(jobId);
64
+ });
65
+ }
66
+ catch (error) {
67
+ await this.reportTransportFailure(error);
68
+ return async () => { };
69
+ }
29
70
  }
30
- catch (error) {
31
- await this.reportTransportFailure(error);
32
- return async () => { };
33
- } }
34
71
  async idle() { while (this.live.size > 0 || this.plannerLive.size > 0 || this.continuationLive.size > 0 || this.reconciling)
35
72
  await new Promise(resolve => setTimeout(resolve, 25)); }
36
73
  async wake(jobId) { return this.reconcile(jobId); }
@@ -43,7 +80,6 @@ export class Reconciler {
43
80
  const previousState = job.state;
44
81
  const prState = await this.store.pullRequestState?.() ?? 'OPEN';
45
82
  const now = (this.options.now ?? new Date()).getTime();
46
- await this.recoverFinishedWorkers(job);
47
83
  if (prState !== 'OPEN') {
48
84
  const reason = prState === 'MERGED' ? 'Pull request merged' : 'Pull request closed';
49
85
  for (const task of job.tasks.filter(item => !['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(item.state))) {
@@ -98,8 +134,6 @@ export class Reconciler {
98
134
  }
99
135
  if (finalState.state !== scheduled.state || finalState.updatedAt !== scheduled.updatedAt)
100
136
  await this.store.saveJob(finalState);
101
- if (finalState.state === 'COMPLETED' || prState !== 'OPEN')
102
- await this.cleanupThreads(finalState);
103
137
  return this.store.load(jobId);
104
138
  }
105
139
  finally {
@@ -126,11 +160,12 @@ export class Reconciler {
126
160
  await this.store.saveTask(task);
127
161
  }
128
162
  else {
129
- transitionTask(task, 'FAILED');
130
163
  task.error = expired ? 'Lease expired after maximum attempts' : 'Execution owner restarted after maximum attempts';
131
164
  task.leaseOwner = null;
132
165
  task.leaseExpiresAt = null;
166
+ transitionTask(task, 'FAILED');
133
167
  await this.store.saveTask(task);
168
+ await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'task.failed', 'failed', task.error, 'user', { reason: expired ? 'lease_expired' : 'execution_owner_lost' }));
134
169
  }
135
170
  }
136
171
  }
@@ -160,29 +195,6 @@ export class Reconciler {
160
195
  await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'planner.created', 'queued', `Planner task ${task.id} created`, 'orchestrator'));
161
196
  return this.store.load(job.id);
162
197
  }
163
- async recoverFinishedWorkers(job) {
164
- for (const task of job.tasks.filter(item => item.state === 'RUNNING' && !this.live.has(item.id) && item.threadId)) {
165
- const adapter = this.options.adapters.find(candidate => candidate.name === task.adapter);
166
- if (!adapter?.recover)
167
- continue;
168
- let result = null;
169
- try {
170
- result = await adapter.recover(task);
171
- }
172
- catch {
173
- result = null;
174
- }
175
- if (!result)
176
- continue;
177
- task.result = result;
178
- task.error = null;
179
- task.leaseOwner = null;
180
- task.leaseExpiresAt = null;
181
- transitionTask(task, 'SUCCEEDED');
182
- await this.store.saveTask(task);
183
- await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'task.recovered', 'succeeded', `Recovered completed task ${task.id} from durable worker thread`, 'orchestrator', { threadId: task.threadId }));
184
- }
185
- }
186
198
  async launch(job, task) {
187
199
  const adapter = this.options.adapters.find(x => x.name === task.adapter);
188
200
  if (!adapter) {
@@ -191,6 +203,22 @@ export class Reconciler {
191
203
  await this.store.saveTask(task);
192
204
  return;
193
205
  }
206
+ if (['codex', 'chatgpt'].includes(task.adapter) && !this.options.eventBus) {
207
+ transitionTask(task, 'BLOCKED');
208
+ task.error = 'Model-backed tasks require an event bus for authoritative lifecycle state';
209
+ await this.store.saveTask(task);
210
+ return;
211
+ }
212
+ let workerInput;
213
+ try {
214
+ workerInput = managedWorkerInput(job, task);
215
+ }
216
+ catch (error) {
217
+ transitionTask(task, 'BLOCKED');
218
+ task.error = error instanceof Error ? error.message : String(error);
219
+ await this.store.saveTask(task);
220
+ return;
221
+ }
194
222
  const leaseMs = Math.max(this.options.leaseMs ?? 300000, task.timeoutMs);
195
223
  const startedAt = (this.options.now ?? new Date()).getTime();
196
224
  transitionTask(task, 'RUNNING');
@@ -198,32 +226,25 @@ export class Reconciler {
198
226
  task.leaseOwner = this.options.owner;
199
227
  task.leaseExpiresAt = new Date(startedAt + leaseMs).toISOString();
200
228
  task.executionId = randomUUID();
229
+ const durableExecutionId = task.executionId;
201
230
  await this.store.saveTask(task);
202
231
  await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'task.started', 'running', `Task ${task.id} started`, 'user', { adapter: adapter.id, model: task.routing?.model }));
203
232
  const controller = new AbortController();
204
233
  let execution;
205
- const workerTask = { ...task, input: managedWorkerInput(job, task) };
234
+ const workerTask = { ...task, input: workerInput };
206
235
  try {
207
236
  execution = adapter.launch(workerTask, controller.signal);
208
237
  }
209
238
  catch (error) {
210
- await this.finish(job.id, task.id, task.executionId, null, error instanceof Error ? error.message : String(error), { retryable: false });
239
+ await this.failLaunch(job.id, task.id, task.executionId, error instanceof Error ? error.message : String(error));
211
240
  return;
212
241
  }
213
- // A worker may reject before durable execution metadata finishes saving. Attach a guard immediately so Node never treats that race as an unhandled rejection; the durable completion handler below still records the outcome.
214
- void execution.promise.catch(() => undefined);
215
- task.executionId = execution.id;
216
- await this.store.saveTask(task);
217
- let threadPersistence = Promise.resolve();
218
- const persistThread = (threadId) => {
219
- threadPersistence = threadPersistence.then(() => this.persistThread(job.id, task.id, execution.id, threadId));
220
- };
221
- execution.onThreadStarted = persistThread;
222
- if (execution.threadId)
223
- persistThread(execution.threadId);
224
- const timer = setTimeout(() => { controller.abort(); execution.cancel(); }, task.timeoutMs);
225
- this.live.set(task.id, { taskId: task.id, execution, controller, timer });
226
- this.detach(execution.promise.then(async (result) => { await threadPersistence; await this.finish(job.id, task.id, execution.id, result, null); }, async (error) => { await threadPersistence; await this.finish(job.id, task.id, execution.id, null, error instanceof Error ? error.message : String(error), { retryable: !(error instanceof LaunchFailure) }); }), job.id, task.id, 'worker completion');
242
+ this.live.set(task.id, { taskId: task.id, executionId: durableExecutionId, execution, controller });
243
+ const terminalTimer = setTimeout(() => {
244
+ this.detach(this.timeoutTask(job.id, task.id, durableExecutionId), job.id, task.id, 'worker terminal-event timeout');
245
+ }, Math.max(1, task.timeoutMs));
246
+ this.terminalTimers.set(task.id, terminalTimer);
247
+ this.detach(execution.promise.then(result => this.runtimeSettled(job.id, task.id, durableExecutionId, result, null), error => this.runtimeSettled(job.id, task.id, durableExecutionId, null, error instanceof Error ? error.message : String(error))), job.id, task.id, 'worker runtime completion');
227
248
  }
228
249
  async launchPlanner(job, task) {
229
250
  const planner = this.options.planner;
@@ -311,68 +332,79 @@ export class Reconciler {
311
332
  }
312
333
  plannerChild(job, planner, spec) {
313
334
  const now = new Date().toISOString();
314
- return { jobId: job.id, id: spec.id, kind: 'work', priority: spec.priority ?? job.priority, projectName: spec.projectName, agentName: spec.agentName, parentTaskId: planner.id, dependencies: spec.dependencies ?? [], capabilities: spec.capabilities ?? [], adapter: spec.adapter ?? 'shell', input: spec.input, routing: spec.routing, continuation: spec.continuation ?? null, continuationDeliveredAt: null, state: 'QUEUED', attempt: 0, maxAttempts: spec.maxAttempts ?? 3, leaseOwner: null, leaseExpiresAt: null, executionId: null, threadId: null, result: null, plannerResult: null, error: null, timeoutMs: spec.timeoutMs ?? 300000, createdAt: now, updatedAt: now };
335
+ return { jobId: job.id, id: spec.id, kind: 'work', priority: spec.priority ?? job.priority, projectName: spec.projectName, agentName: spec.agentName, parentTaskId: planner.id, dependencies: spec.dependencies ?? [], capabilities: spec.capabilities ?? [], adapter: spec.adapter, input: spec.input, output: spec.output, routing: spec.routing, continuation: spec.continuation ?? null, continuationDeliveredAt: null, state: 'QUEUED', attempt: 0, maxAttempts: spec.maxAttempts ?? 3, leaseOwner: null, leaseExpiresAt: null, executionId: null, threadId: null, result: null, plannerResult: null, error: null, timeoutMs: spec.timeoutMs ?? 300000, createdAt: now, updatedAt: now };
315
336
  }
316
- async persistThread(jobId, taskId, executionId, threadId) { const job = await this.store.load(jobId); const task = job.tasks.find(item => item.id === taskId); if (task?.executionId === executionId && task.state === 'RUNNING') {
317
- task.threadId = threadId;
318
- task.threadDeletedAt = null;
319
- task.threadCleanupError = null;
320
- await this.store.saveTask(task);
321
- await this.emit(eventFor(jobId, taskId, task.parentTaskId, 'thread.started', 'running', `Worker thread ${threadId} started`, 'orchestrator', { threadId }));
322
- } }
323
- async finish(jobId, taskId, executionId, result, error, options = {}) {
337
+ clearExecution(taskId, executionId) {
324
338
  const live = this.live.get(taskId);
325
- if (live?.execution.id === executionId) {
326
- clearTimeout(live.timer);
339
+ if (!executionId || live?.executionId === executionId)
327
340
  this.live.delete(taskId);
328
- }
341
+ const timer = this.terminalTimers.get(taskId);
342
+ if (timer)
343
+ clearTimeout(timer);
344
+ this.terminalTimers.delete(taskId);
345
+ }
346
+ async failLaunch(jobId, taskId, executionId, message) {
329
347
  const job = await this.store.load(jobId);
330
348
  const task = job.tasks.find(item => item.id === taskId);
331
- if (!task || task.executionId !== executionId)
349
+ if (!task || task.executionId !== executionId || task.state !== 'RUNNING')
332
350
  return;
333
- if (task.state === 'CANCELLED') {
334
- await this.store.saveTask(task);
351
+ const event = eventFor(jobId, taskId, task.parentTaskId, 'task.failed', 'failed', `Worker launch failed: ${message}`, 'user', { phase: 'launch' });
352
+ await this.applyTerminalEvent(event);
353
+ await this.emit(event);
354
+ }
355
+ async runtimeSettled(jobId, taskId, executionId, result, error) {
356
+ const live = this.live.get(taskId);
357
+ if (live?.executionId === executionId)
358
+ this.live.delete(taskId);
359
+ const job = await this.store.load(jobId);
360
+ const task = job.tasks.find(item => item.id === taskId);
361
+ if (!task || task.executionId !== executionId || task.state !== 'RUNNING')
335
362
  return;
363
+ await this.emit(eventFor(jobId, taskId, task.parentTaskId, error ? 'worker.runtime.failed' : 'worker.runtime.exited', error ? 'failed' : 'succeeded', error ?? result?.summary ?? 'Worker runtime exited', 'orchestrator', { authoritative: false, waitingForTerminalEvent: true }));
364
+ }
365
+ async timeoutTask(jobId, taskId, executionId) {
366
+ const live = this.live.get(taskId);
367
+ if (live?.executionId === executionId) {
368
+ live.controller.abort();
369
+ live.execution.cancel();
336
370
  }
337
- if (error === null) {
338
- task.result = result;
371
+ const job = await this.store.load(jobId);
372
+ const task = job.tasks.find(item => item.id === taskId);
373
+ if (!task || task.executionId !== executionId || task.state !== 'RUNNING') {
374
+ this.clearExecution(taskId, executionId);
375
+ return;
376
+ }
377
+ const event = eventFor(jobId, taskId, task.parentTaskId, 'task.failed', 'failed', `Task ${taskId} timed out waiting for the mandatory terminal worker event`, 'user', { reason: 'terminal_event_timeout', timeoutMs: task.timeoutMs });
378
+ await this.applyTerminalEvent(event);
379
+ await this.emit(event);
380
+ }
381
+ async applyTerminalEvent(event) {
382
+ const job = await this.store.load(event.job_id);
383
+ const task = job.tasks.find(item => item.id === event.task_id);
384
+ if (!task || !['codex', 'chatgpt'].includes(task.adapter) || task.state !== 'RUNNING')
385
+ return;
386
+ this.clearExecution(task.id, task.executionId);
387
+ task.leaseOwner = null;
388
+ task.leaseExpiresAt = null;
389
+ task.updatedAt = event.timestamp || new Date().toISOString();
390
+ if (event.type === 'task.completed') {
391
+ task.result = { summary: event.message || `Task ${task.id} completed`, data: event.data };
339
392
  task.error = null;
340
- task.leaseOwner = null;
341
- task.leaseExpiresAt = null;
342
393
  transitionTask(task, 'SUCCEEDED');
343
394
  await this.store.saveTask(task);
344
- await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'task.completed', 'succeeded', `Task ${task.id} completed`));
345
395
  await this.deliverContinuation(job, task);
346
396
  }
347
- else {
348
- const retryable = options.retryable !== false && task.attempt < task.maxAttempts;
349
- task.error = error;
350
- task.leaseOwner = null;
351
- task.leaseExpiresAt = null;
352
- transitionTask(task, retryable ? 'READY' : 'FAILED');
397
+ else if (event.type === 'task.failed') {
398
+ task.error = event.message || `Task ${task.id} failed`;
399
+ transitionTask(task, 'FAILED');
353
400
  await this.store.saveTask(task);
354
- await this.emit(eventFor(job.id, task.id, task.parentTaskId, task.state === 'FAILED' ? 'task.failed' : 'task.retry', task.state === 'FAILED' ? 'failed' : 'queued', `Task ${task.id}: ${error}`, task.state === 'FAILED' ? 'user' : 'orchestrator'));
355
401
  }
356
- this.detach(this.reconcile(jobId), jobId, task.id, 'worker follow-up reconciliation');
357
- }
358
- async cleanupThreads(job) {
359
- const adapter = this.options.adapters.find(candidate => candidate.name === 'chatgpt' && candidate.deleteThread);
360
- if (!adapter?.deleteThread)
361
- return;
362
- for (const task of job.tasks.filter(item => item.adapter === 'chatgpt' && item.threadId && !item.threadDeletedAt)) {
363
- try {
364
- await adapter.deleteThread(task.threadId, task);
365
- task.threadDeletedAt = new Date().toISOString();
366
- task.threadCleanupError = null;
367
- await this.store.saveTask(task);
368
- await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'thread.deleted', 'succeeded', `Worker thread ${task.threadId} deleted`, 'orchestrator', { threadId: task.threadId }));
369
- }
370
- catch (error) {
371
- task.threadCleanupError = error instanceof Error ? error.message : String(error);
372
- await this.store.saveTask(task);
373
- await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'thread.delete.failed', 'failed', `Worker thread cleanup failed: ${task.threadCleanupError}`, 'orchestrator', { threadId: task.threadId }));
374
- }
402
+ else if (event.type === 'task.blocked') {
403
+ task.error = event.message || `Task ${task.id} blocked`;
404
+ transitionTask(task, 'BLOCKED');
405
+ await this.store.saveTask(task);
375
406
  }
407
+ this.detach(this.reconcile(job.id), job.id, task.id, 'terminal-event reconciliation');
376
408
  }
377
409
  async deliverContinuation(job, task) {
378
410
  const continuation = task.continuation ?? job.continuation;
@@ -432,5 +464,5 @@ export class Reconciler {
432
464
  } }
433
465
  }
434
466
  function samePlannerChildDefinition(left, right) {
435
- return JSON.stringify({ id: left.id, input: left.input, priority: left.priority, projectName: left.projectName, agentName: left.agentName, dependencies: left.dependencies, capabilities: left.capabilities, adapter: left.adapter, routing: left.routing, continuation: left.continuation ?? null, maxAttempts: left.maxAttempts, timeoutMs: left.timeoutMs }) === JSON.stringify({ id: right.id, input: right.input, priority: right.priority, projectName: right.projectName, agentName: right.agentName, dependencies: right.dependencies, capabilities: right.capabilities, adapter: right.adapter, routing: right.routing, continuation: right.continuation ?? null, maxAttempts: right.maxAttempts, timeoutMs: right.timeoutMs });
467
+ return JSON.stringify({ id: left.id, input: left.input, output: left.output, priority: left.priority, projectName: left.projectName, agentName: left.agentName, dependencies: left.dependencies, capabilities: left.capabilities, adapter: left.adapter, routing: left.routing, continuation: left.continuation ?? null, maxAttempts: left.maxAttempts, timeoutMs: left.timeoutMs }) === JSON.stringify({ id: right.id, input: right.input, output: right.output, priority: right.priority, projectName: right.projectName, agentName: right.agentName, dependencies: right.dependencies, capabilities: right.capabilities, adapter: right.adapter, routing: right.routing, continuation: right.continuation ?? null, maxAttempts: right.maxAttempts, timeoutMs: right.timeoutMs });
436
468
  }
package/dist/relayd.js CHANGED
@@ -3,7 +3,7 @@ import { once } from 'node:events';
3
3
  import { mkdir, readFile, writeFile } from 'node:fs/promises';
4
4
  import { homedir } from 'node:os';
5
5
  import { join } from 'node:path';
6
- import { ChatGptAdapter, CodexAdapter, ShellAdapter } from './adapters.js';
6
+ import { ChatGptAdapter, CodexAdapter } from './adapters.js';
7
7
  import { CodexThreadContinuation, CommandContinuation, WebhookContinuation } from './continuation.js';
8
8
  import { serveDashboard } from './dashboard.js';
9
9
  import { NatsEventBus } from './events.js';
@@ -63,8 +63,8 @@ export async function runDaemon(argv) {
63
63
  const client = auth.client;
64
64
  const trusted = auth.trustedAuthors;
65
65
  const concurrency = Number(value(argv, '--concurrency', '4'));
66
- const bus = value(argv, '--events') === 'nats' ? new NatsEventBus(value(argv, '--nats-url', 'nats://127.0.0.1:4222'), value(argv, '--subject-prefix', 'agents-relay.events.job')) : undefined;
67
- const makeReconciler = (store, maxConcurrent) => new Reconciler(store, { owner: `relayd-${process.pid}`, maxConcurrent, leaseMs: Number(value(argv, '--lease-ms', '300000')), adapters: [new ShellAdapter(), new CodexAdapter(value(argv, '--codex', 'codex')), new ChatGptAdapter()], continuations: [new CodexThreadContinuation(value(argv, '--codex', 'codex')), new CommandContinuation(), new WebhookContinuation()], planner: runtimePlanner(argv), eventBus: bus });
66
+ const bus = value(argv, '--events') === 'nats' ? new NatsEventBus(value(argv, '--nats-url', 'nats://127.0.0.1:4222'), value(argv, '--subject-prefix', 'neo.events.job')) : undefined;
67
+ const makeReconciler = (store, maxConcurrent) => new Reconciler(store, { owner: `relayd-${process.pid}`, maxConcurrent, leaseMs: Number(value(argv, '--lease-ms', '300000')), adapters: [new CodexAdapter(value(argv, '--codex', 'codex')), new ChatGptAdapter()], continuations: [new CodexThreadContinuation(value(argv, '--codex', 'codex')), new CommandContinuation(), new WebhookContinuation()], planner: runtimePlanner(argv), eventBus: bus });
68
68
  const repositories = normalized.repository
69
69
  ? [normalized.repository]
70
70
  : (await discoverWorkspaceRepositories(normalized.workspaceRoot ?? defaultWorkspaceRoot())).map(item => item.repository);
package/dist/store.js CHANGED
@@ -80,7 +80,7 @@ function immutableTaskDefinition(task) {
80
80
  return {
81
81
  jobId: task.jobId, id: task.id, priority: task.priority, projectName: task.projectName, agentName: task.agentName,
82
82
  kind: task.kind ?? 'work', parentTaskId: task.parentTaskId, dependencies: task.dependencies, capabilities: task.capabilities, adapter: task.adapter,
83
- input: task.input, routing, continuation: task.continuation ?? null, maxAttempts: task.maxAttempts, timeoutMs: task.timeoutMs
83
+ input: task.input, output: task.output, routing, continuation: task.continuation ?? null, maxAttempts: task.maxAttempts, timeoutMs: task.timeoutMs
84
84
  };
85
85
  }
86
86
  function sameTaskDefinition(left, right) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "1.0.5",
3
+ "version": "1.0.7",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,79 +1,93 @@
1
1
  ---
2
2
  name: agents-relay
3
- description: Create and operate durable asynchronous agent jobs through GitHub PR state, worker adapters, continuations, retries, and live progress events. Use when an orchestrator needs work to survive the current thread/process or delegate recoverable child tasks.
3
+ description: Create and operate durable asynchronous agent jobs through GitHub PR state, agent workers, retries, and authoritative lifecycle events.
4
4
  ---
5
5
 
6
6
  # Agents Relay
7
7
 
8
- ## Runtime contract
8
+ Use Agents Relay when an orchestrator needs durable delegated work that survives the current process.
9
9
 
10
- This skill is the installable instruction/agent bundle. Do not require a local source checkout to execute Agents Relay. Use `npx agents-relay ...` for the executable CLI/runtime. Supporting agent definitions live under this skill directory and ship with the npm package; the default autonomous planner uses `agents/planner.agent.md`.
10
+ ## Core task contract
11
11
 
12
+ A managed child task is always a descriptive unit of work. It says what an agent must accomplish; it is never a shell command.
12
13
 
13
- Use Agents Relay for asynchronous work that must survive the current agent process. Create one durable top-level job per objective and submit child tasks with unique parentTaskId, dependencies, capabilities, adapter, timeout, and retry policy. A model-backed task must carry a recorded routing decision (provider, model, optional profile/reasoning/cwd/projectId) before it can launch. Use adapter codex for local Codex-compatible workers and adapter chatgpt for the installed `chatgpt-browser-worker` agent through a local model harness.
14
+ Normal worker tasks use an agent runtime:
15
+ - `codex` for a local Codex-compatible agent.
16
+ - `chatgpt` for the one-shot Browser ChatGPT worker.
14
17
 
15
- GitHub PR comments are durable truth in operational mode. Reload the PR after every event or wake-up and reconcile desired durable state into worker executions; events and NATS are only low-latency notifications and must never be treated as completion.
18
+ Shell commands, tests, Markad Vision, ffmpeg, build tools, and similar CLIs are tools used inside an agent or directly by the orchestrator. They are not Agents Relay worker tasks.
16
19
 
17
- Jobs are `fixed` by default. Autonomous jobs are explicitly created with
18
- `--mode autonomous`; reconciliation creates durable planner tasks only after
19
- the current parent/subtask work is settled. A planner must return typed
20
- `objective_status`, `assessment`, and `next_tasks`; the runtime owns leases,
21
- restart recovery, stable-ID deduplication, and completion gates. Planner
22
- satisfaction does not override failed or blocked work.
20
+ Every model-backed task must declare:
21
+ - its descriptive `input`;
22
+ - its adapter (`codex` or `chatgpt`);
23
+ - routing metadata;
24
+ - exactly one durable output contract:
25
+ - `task_pr`: the managed task/PR is the authoritative result; or
26
+ - `file`: an exact file path is the authoritative result.
23
27
 
24
- ## Managed GitHub bootstrap
28
+ ## Lifecycle contract
25
29
 
26
- For new GitHub-backed work, do not create the PR or job marker with raw gh commands. Use the first-class CLI:
30
+ Events are authoritative for model-backed task state.
27
31
 
28
- ~~~sh
29
- npx agents-relay job create --repo OWNER/REPO --head feat/example --base main \
30
- --id JOB_ID --title "Objective" --body "PR description"
31
- ~~~
32
+ Agents Relay may launch a Codex process or submit a one-shot ChatGPT browser task, but runtime/process completion is only delivery/runtime evidence. It never means the task completed.
32
33
 
33
- job create resolves an existing open PR with the same head/base before creating one, then persists exactly one trusted agents-relay:job:v1 marker. Re-running the command is idempotent.
34
+ A running agent MUST use the canonical Neo `events-bus` protocol and publish exactly one correlated terminal event before stopping:
35
+ - `task.completed` after the declared output is durable;
36
+ - `task.failed` when the task cannot complete;
37
+ - `task.blocked` when external or human action is required.
34
38
 
35
- To bring an existing unmanaged PR under Agents Relay, even when it has zero comments:
39
+ 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.
36
40
 
37
- ~~~sh
38
- npx agents-relay job adopt --repo OWNER/REPO --pr 6 --id JOB_ID --title "Objective"
39
- ~~~
41
+ The event must carry the exact durable `job_id` and `task_id`. Progress events are optional.
40
42
 
41
- To repair duplicate markers for that same job:
43
+ Agents Relay maps those terminal events to durable state:
44
+ - `task.completed` → `SUCCEEDED`
45
+ - `task.failed` → `FAILED`
46
+ - `task.blocked` → `BLOCKED`
42
47
 
43
- ~~~sh
44
- npx agents-relay job repair --repo OWNER/REPO --pr 6 --id JOB_ID
45
- ~~~
48
+ 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.
46
49
 
47
- A PR carrying a marker for a different job is rejected rather than silently reassigned. Managed task commands must not run until the durable job marker exists; submit/status/reconcile/retry/cancel/serve fail clearly when it is absent.
50
+ ## Managed GitHub jobs
48
51
 
49
- The legacy init flow remains supported for compatibility, but orchestrators should prefer job create/adopt/repair for GitHub-backed work.
52
+ For repository work, create/adopt/repair the PR-backed job through the Agents Relay CLI. Do not create the managed PR through a parallel raw GitHub path.
50
53
 
51
- Typical managed flow:
54
+ Example:
52
55
 
53
56
  ~~~sh
54
- npx agents-relay job create --repo OWNER/REPO --head feat/example --id JOB_ID --title "Objective"
55
- npx agents-relay submit --repo OWNER/REPO --pr 12 --id JOB_ID --task-id child --input "echo work" --adapter shell
56
- npx agents-relay reconcile --repo OWNER/REPO --pr 12 --id JOB_ID
57
- npx agents-relay status --repo OWNER/REPO --pr 12 --id JOB_ID
58
- npx agents-relay serve --repo OWNER/REPO --pr 12 --id JOB_ID
57
+ npx agents-relay job create --repo OWNER/REPO --head feat/example --base main --id JOB_ID --title "Objective"
58
+
59
+ npx agents-relay submit --repo OWNER/REPO --pr 12 --id JOB_ID --task-id implement --adapter codex --provider openai --model MODEL --output task-pr --input "Implement the requested change, validate it, and update the managed task/PR."
60
+
61
+ npx agents-relayd --repo OWNER/REPO --events nats
59
62
  ~~~
60
63
 
61
- Use --file PATH only for explicit local demo/test mode. Optional --events nats enables wake/progress events with --subject-prefix; the PR remains authoritative.
64
+ Use `--output file --output-path PATH` when the authoritative result is a file.
65
+
66
+ ## Worker prompt
67
+
68
+ For Codex and ChatGPT workers, Agents Relay adds only durable execution identity and contracts around the stored descriptive task input:
69
+ - PR URL
70
+ - job ID
71
+ - task ID
72
+ - parent task ID
73
+ - project name
74
+ - declared output
75
+ - mandatory terminal-event contract
62
76
 
63
- Use task-level continuation when the sender needs to resume, otherwise the job continuation. Continuation delivery is deduplicated by the durable delivery timestamp. Treat BLOCKED as an approval/manual-release state until an explicit retry/release changes it. Cancellation, timeout, and lease expiry are durable state transitions; do not claim success from a worker process exit alone.
77
+ The job/task descriptions remain the durable source of intent. Adapters do not invent task meaning.
64
78
 
65
- Use `npx agents-relay task update` to change safe durable fields of a `QUEUED`, `READY`, `WAITING`, `BLOCKED`, or `FAILED` task without changing its task ID or prior attempt/error history. This is the preferred way to reroute future execution to a different adapter/provider/model. The CLI rejects `RUNNING`, `SUCCEEDED`, and `CANCELLED` task updates so active execution semantics and terminal audit history are not rewritten.
79
+ ## ChatGPT worker
66
80
 
67
- For a ChatGPT worker, use the chatgpt adapter with capabilities model,chatgpt,browser-harness. The runtime launches the installed `chatgpt-browser-worker` agent, stores its observed `thread_id` as the durable task threadId, and reuses that ID on retry. Pass --chatgpt-project when the worker must run inside one exact ChatGPT Project. Set `AGENTS_RELAY_CHATGPT_BROWSER_WORKER_AGENT` to an explicit agent definition path when needed; otherwise standard skill roots are searched. Never place credentials or private prompts in job/task markers.
81
+ The ChatGPT adapter uses the packaged `chatgpt-browser-worker` one-shot path. It opens a fresh Temporary Chat tab, submits the complete task with the account defaults, verifies acceptance, closes the owned tab, and returns a submission receipt.
68
82
 
69
- Managed Codex and ChatGPT worker prompts are enriched at launch with the PR URL and durable job/task/parent/project identity; do not duplicate that context manually in task input. Keep one ChatGPT conversation per logical task. Retries reuse the same task conversation, but sibling tasks use separate conversations even when their adapter is the same.
83
+ ChatGPT thread identity is diagnostic only. It is not persisted as a resumable task handle, and Relay does not poll, resume, retrieve a result from, or delete a ChatGPT conversation as part of task lifecycle.
70
84
 
71
- When a job becomes COMPLETED or its GitHub PR is merged, ask the browser-worker agent to delete all ChatGPT task conversations. Preserve threadId in durable markers, record threadDeletedAt on success, and record/retry threadCleanupError on deletion failure without reopening the job.
85
+ ## Autonomous planner
72
86
 
73
- V1 runs with one active runner per job; do not start multiple reconcilers without adding an atomic distributed lease. Keep secrets, credentials, private prompts, and large private payloads out of PR markers—store summaries and artifact references only. Event transport failures are degraded observability, not durable task failures.
87
+ Planner-created child tasks must also be descriptive agent work. They must select `codex` or `chatgpt` and include an explicit output contract. A planner must never generate shell-command worker tasks.
74
88
 
75
- Registered agents are result-owning roles with responsibility boundaries; skills are callable capabilities and adapters are execution runtimes. Register agents with `npx agents-relay agent-register`, discover them with hard filters using `npx agents-relay agent-discover`, and treat claimed capabilities separately from observed evaluations/outcomes. Discovery returns explainable evidence and permits exploration; it does not produce a universal trust score. The future constrained remote surface should authenticate `POST /jobs` and `GET /jobs/:id` only, with public/intention-only jobs separated from private-data or credential work.
89
+ ## Durable truth
76
90
 
77
- Treat GitHub PR state as authoritative at reconciliation boundaries. `MERGED` forces the job to `COMPLETED`; closed-unmerged forces `CANCELLED` and prevents launches. Use the repository-wide dashboard job list to compare GitHub and durable states; repair any mismatch rather than trusting a stale marker.
91
+ GitHub PR/job/task markers remain durable workflow state. Events are the authoritative execution-state transition signal for model-backed tasks. Runtime events, process exits, browser submission receipts, and conversation text are evidence only.
78
92
 
79
- If already-completed orchestrator work is missing from a managed job, backfill it with `npx agents-relay record` and the real commit SHA rather than inventing a worker execution. Treat this as a repair path only; new work should be submitted before it starts. Managed job description comes from the GitHub PR body and should remain visible with the job title in the dashboard.
93
+ Keep secrets, credentials, private prompts, and large private payloads out of durable markers and events.
@@ -2,18 +2,21 @@
2
2
 
3
3
  You are the default objective planner for Agents Relay autonomous jobs.
4
4
 
5
- Your job is to inspect the durable objective and current durable task tree, then decide whether the objective is satisfied or which concrete tasks should run next.
5
+ Inspect the durable objective and task tree, then decide whether the objective is satisfied or which descriptive agent tasks should run next.
6
6
 
7
7
  ## Rules
8
8
 
9
- - Treat the supplied durable job/task state as authoritative.
9
+ - Treat durable job/task state as authoritative.
10
10
  - Never invent completed work or evidence.
11
- - Do not bypass failed, blocked, review, merge, or other normal completion gates.
12
- - Prefer small, independently verifiable next tasks.
13
- - Preserve parent/subtask causality and stable task IDs.
14
- - Reuse existing tasks instead of duplicating equivalent work.
15
- - Select an appropriate agent identity and execution adapter for each new task when known.
16
- - Do not call an execution agent "codex"; agent identity describes responsibility, while adapters describe runtime.
11
+ - Prefer small independently verifiable tasks.
12
+ - Preserve parent/subtask causality and stable IDs.
13
+ - A child task describes what an agent must accomplish. Never emit a shell command as a worker task.
14
+ - Every child task must use adapter `codex` or `chatgpt`.
15
+ - Every child task must declare exactly one output:
16
+ - `{"kind":"task_pr"}`, or
17
+ - `{"kind":"file","path":"..."}`.
18
+ - Include routing metadata for every model-backed child task.
19
+ - Do not call an execution agent "codex"; agent identity describes responsibility, while the adapter describes runtime.
17
20
  - Return no prose outside the JSON result.
18
21
 
19
22
  ## Output
@@ -23,6 +26,6 @@ Return exactly one JSON object with:
23
26
  - assessment: a short evidence-based assessment
24
27
  - next_tasks: an array of typed task objects
25
28
 
26
- Each next task may contain: id, input, priority, projectName, agentName, dependencies, capabilities, adapter, maxAttempts, and timeoutMs.
29
+ Each next task contains: id, input, adapter, output, routing, and may also contain priority, projectName, agentName, dependencies, capabilities, maxAttempts, and timeoutMs.
27
30
 
28
31
  When the objective is satisfied, next_tasks must be empty.