agents-relay 1.0.4 → 1.0.6

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/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
  });
@@ -7,29 +7,63 @@ export function compareTaskPriority(a, b, jobPriority = 'P2') { const ar = PRIOR
7
7
  export function managedWorkerInput(job, task) {
8
8
  if (!['codex', 'chatgpt'].includes(task.adapter) || !job.repository || job.prNumber <= 0)
9
9
  return task.input;
10
+ if (!task.output)
11
+ throw new Error(`Task ${task.id} requires an explicit output contract`);
10
12
  const prUrl = `https://github.com/${job.repository}/pull/${job.prNumber}`;
11
- 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 normal events bus with exactly Job ${job.id} and Task ${task.id}.
30
+ You MAY publish progress events while working.
31
+ Before stopping, you MUST publish exactly one terminal event:
32
+ - task.completed only after the declared output is durable;
33
+ - task.failed when execution cannot complete;
34
+ - task.blocked when human/external action is required.
35
+ The ChatGPT/Codex conversation or process exit is never the task result.`;
12
36
  }
13
37
  export class Reconciler {
14
38
  store;
15
39
  options;
16
40
  live = new Map();
17
41
  plannerLive = new Map();
42
+ terminalTimers = new Map();
18
43
  continuationLive = new Set();
19
44
  reconciling = false;
20
45
  constructor(store, options) {
21
46
  this.store = store;
22
47
  this.options = options;
23
48
  }
24
- async watch(jobId) { if (!this.options.eventBus)
25
- return async () => { }; try {
26
- return await this.options.eventBus.subscribe(jobId, async (event) => { if (event.type !== 'job.wake' && event.type !== 'github.webhook')
27
- return; await this.reconcile(jobId); });
49
+ async watch(jobId) {
50
+ if (!this.options.eventBus)
51
+ return async () => { };
52
+ try {
53
+ return await this.options.eventBus.subscribe(jobId, async (event) => {
54
+ if (['task.completed', 'task.failed', 'task.blocked'].includes(event.type)) {
55
+ await this.applyTerminalEvent(event);
56
+ return;
57
+ }
58
+ if (event.type === 'job.wake' || event.type === 'github.webhook')
59
+ await this.reconcile(jobId);
60
+ });
61
+ }
62
+ catch (error) {
63
+ await this.reportTransportFailure(error);
64
+ return async () => { };
65
+ }
28
66
  }
29
- catch (error) {
30
- await this.reportTransportFailure(error);
31
- return async () => { };
32
- } }
33
67
  async idle() { while (this.live.size > 0 || this.plannerLive.size > 0 || this.continuationLive.size > 0 || this.reconciling)
34
68
  await new Promise(resolve => setTimeout(resolve, 25)); }
35
69
  async wake(jobId) { return this.reconcile(jobId); }
@@ -42,7 +76,6 @@ export class Reconciler {
42
76
  const previousState = job.state;
43
77
  const prState = await this.store.pullRequestState?.() ?? 'OPEN';
44
78
  const now = (this.options.now ?? new Date()).getTime();
45
- await this.recoverFinishedWorkers(job);
46
79
  if (prState !== 'OPEN') {
47
80
  const reason = prState === 'MERGED' ? 'Pull request merged' : 'Pull request closed';
48
81
  for (const task of job.tasks.filter(item => !['SUCCEEDED', 'FAILED', 'CANCELLED'].includes(item.state))) {
@@ -97,8 +130,6 @@ export class Reconciler {
97
130
  }
98
131
  if (finalState.state !== scheduled.state || finalState.updatedAt !== scheduled.updatedAt)
99
132
  await this.store.saveJob(finalState);
100
- if (finalState.state === 'COMPLETED' || prState !== 'OPEN')
101
- await this.cleanupThreads(finalState);
102
133
  return this.store.load(jobId);
103
134
  }
104
135
  finally {
@@ -125,11 +156,12 @@ export class Reconciler {
125
156
  await this.store.saveTask(task);
126
157
  }
127
158
  else {
128
- transitionTask(task, 'FAILED');
129
159
  task.error = expired ? 'Lease expired after maximum attempts' : 'Execution owner restarted after maximum attempts';
130
160
  task.leaseOwner = null;
131
161
  task.leaseExpiresAt = null;
162
+ transitionTask(task, 'FAILED');
132
163
  await this.store.saveTask(task);
164
+ await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'task.failed', 'failed', task.error, 'user', { reason: expired ? 'lease_expired' : 'execution_owner_lost' }));
133
165
  }
134
166
  }
135
167
  }
@@ -159,29 +191,6 @@ export class Reconciler {
159
191
  await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'planner.created', 'queued', `Planner task ${task.id} created`, 'orchestrator'));
160
192
  return this.store.load(job.id);
161
193
  }
162
- async recoverFinishedWorkers(job) {
163
- for (const task of job.tasks.filter(item => item.state === 'RUNNING' && !this.live.has(item.id) && item.threadId)) {
164
- const adapter = this.options.adapters.find(candidate => candidate.name === task.adapter);
165
- if (!adapter?.recover)
166
- continue;
167
- let result = null;
168
- try {
169
- result = await adapter.recover(task);
170
- }
171
- catch {
172
- result = null;
173
- }
174
- if (!result)
175
- continue;
176
- task.result = result;
177
- task.error = null;
178
- task.leaseOwner = null;
179
- task.leaseExpiresAt = null;
180
- transitionTask(task, 'SUCCEEDED');
181
- await this.store.saveTask(task);
182
- 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 }));
183
- }
184
- }
185
194
  async launch(job, task) {
186
195
  const adapter = this.options.adapters.find(x => x.name === task.adapter);
187
196
  if (!adapter) {
@@ -190,6 +199,22 @@ export class Reconciler {
190
199
  await this.store.saveTask(task);
191
200
  return;
192
201
  }
202
+ if (['codex', 'chatgpt'].includes(task.adapter) && !this.options.eventBus) {
203
+ transitionTask(task, 'BLOCKED');
204
+ task.error = 'Model-backed tasks require an event bus for authoritative lifecycle state';
205
+ await this.store.saveTask(task);
206
+ return;
207
+ }
208
+ let workerInput;
209
+ try {
210
+ workerInput = managedWorkerInput(job, task);
211
+ }
212
+ catch (error) {
213
+ transitionTask(task, 'BLOCKED');
214
+ task.error = error instanceof Error ? error.message : String(error);
215
+ await this.store.saveTask(task);
216
+ return;
217
+ }
193
218
  const leaseMs = Math.max(this.options.leaseMs ?? 300000, task.timeoutMs);
194
219
  const startedAt = (this.options.now ?? new Date()).getTime();
195
220
  transitionTask(task, 'RUNNING');
@@ -197,32 +222,25 @@ export class Reconciler {
197
222
  task.leaseOwner = this.options.owner;
198
223
  task.leaseExpiresAt = new Date(startedAt + leaseMs).toISOString();
199
224
  task.executionId = randomUUID();
225
+ const durableExecutionId = task.executionId;
200
226
  await this.store.saveTask(task);
201
227
  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 }));
202
228
  const controller = new AbortController();
203
229
  let execution;
204
- const workerTask = { ...task, input: managedWorkerInput(job, task) };
230
+ const workerTask = { ...task, input: workerInput };
205
231
  try {
206
232
  execution = adapter.launch(workerTask, controller.signal);
207
233
  }
208
234
  catch (error) {
209
- await this.finish(job.id, task.id, task.executionId, null, error instanceof Error ? error.message : String(error));
235
+ await this.failLaunch(job.id, task.id, task.executionId, error instanceof Error ? error.message : String(error));
210
236
  return;
211
237
  }
212
- // 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.
213
- void execution.promise.catch(() => undefined);
214
- task.executionId = execution.id;
215
- await this.store.saveTask(task);
216
- let threadPersistence = Promise.resolve();
217
- const persistThread = (threadId) => {
218
- threadPersistence = threadPersistence.then(() => this.persistThread(job.id, task.id, execution.id, threadId));
219
- };
220
- execution.onThreadStarted = persistThread;
221
- if (execution.threadId)
222
- persistThread(execution.threadId);
223
- const timer = setTimeout(() => { controller.abort(); execution.cancel(); }, task.timeoutMs);
224
- this.live.set(task.id, { taskId: task.id, execution, controller, timer });
225
- 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)); }), job.id, task.id, 'worker completion');
238
+ this.live.set(task.id, { taskId: task.id, executionId: durableExecutionId, execution, controller });
239
+ const terminalTimer = setTimeout(() => {
240
+ this.detach(this.timeoutTask(job.id, task.id, durableExecutionId), job.id, task.id, 'worker terminal-event timeout');
241
+ }, Math.max(1, task.timeoutMs));
242
+ this.terminalTimers.set(task.id, terminalTimer);
243
+ 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');
226
244
  }
227
245
  async launchPlanner(job, task) {
228
246
  const planner = this.options.planner;
@@ -310,67 +328,79 @@ export class Reconciler {
310
328
  }
311
329
  plannerChild(job, planner, spec) {
312
330
  const now = new Date().toISOString();
313
- 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 };
331
+ 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 };
314
332
  }
315
- 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') {
316
- task.threadId = threadId;
317
- task.threadDeletedAt = null;
318
- task.threadCleanupError = null;
319
- await this.store.saveTask(task);
320
- await this.emit(eventFor(jobId, taskId, task.parentTaskId, 'thread.started', 'running', `Worker thread ${threadId} started`, 'orchestrator', { threadId }));
321
- } }
322
- async finish(jobId, taskId, executionId, result, error) {
333
+ clearExecution(taskId, executionId) {
323
334
  const live = this.live.get(taskId);
324
- if (live?.execution.id === executionId) {
325
- clearTimeout(live.timer);
335
+ if (!executionId || live?.executionId === executionId)
326
336
  this.live.delete(taskId);
327
- }
337
+ const timer = this.terminalTimers.get(taskId);
338
+ if (timer)
339
+ clearTimeout(timer);
340
+ this.terminalTimers.delete(taskId);
341
+ }
342
+ async failLaunch(jobId, taskId, executionId, message) {
328
343
  const job = await this.store.load(jobId);
329
344
  const task = job.tasks.find(item => item.id === taskId);
330
- if (!task || task.executionId !== executionId)
345
+ if (!task || task.executionId !== executionId || task.state !== 'RUNNING')
331
346
  return;
332
- if (task.state === 'CANCELLED') {
333
- await this.store.saveTask(task);
347
+ const event = eventFor(jobId, taskId, task.parentTaskId, 'task.failed', 'failed', `Worker launch failed: ${message}`, 'user', { phase: 'launch' });
348
+ await this.applyTerminalEvent(event);
349
+ await this.emit(event);
350
+ }
351
+ async runtimeSettled(jobId, taskId, executionId, result, error) {
352
+ const live = this.live.get(taskId);
353
+ if (live?.executionId === executionId)
354
+ this.live.delete(taskId);
355
+ const job = await this.store.load(jobId);
356
+ const task = job.tasks.find(item => item.id === taskId);
357
+ if (!task || task.executionId !== executionId || task.state !== 'RUNNING')
358
+ return;
359
+ 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 }));
360
+ }
361
+ async timeoutTask(jobId, taskId, executionId) {
362
+ const live = this.live.get(taskId);
363
+ if (live?.executionId === executionId) {
364
+ live.controller.abort();
365
+ live.execution.cancel();
366
+ }
367
+ const job = await this.store.load(jobId);
368
+ const task = job.tasks.find(item => item.id === taskId);
369
+ if (!task || task.executionId !== executionId || task.state !== 'RUNNING') {
370
+ this.clearExecution(taskId, executionId);
334
371
  return;
335
372
  }
336
- if (error === null) {
337
- task.result = result;
373
+ 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 });
374
+ await this.applyTerminalEvent(event);
375
+ await this.emit(event);
376
+ }
377
+ async applyTerminalEvent(event) {
378
+ const job = await this.store.load(event.job_id);
379
+ const task = job.tasks.find(item => item.id === event.task_id);
380
+ if (!task || !['codex', 'chatgpt'].includes(task.adapter) || task.state !== 'RUNNING')
381
+ return;
382
+ this.clearExecution(task.id, task.executionId);
383
+ task.leaseOwner = null;
384
+ task.leaseExpiresAt = null;
385
+ task.updatedAt = event.timestamp || new Date().toISOString();
386
+ if (event.type === 'task.completed') {
387
+ task.result = { summary: event.message || `Task ${task.id} completed`, data: event.data };
338
388
  task.error = null;
339
- task.leaseOwner = null;
340
- task.leaseExpiresAt = null;
341
389
  transitionTask(task, 'SUCCEEDED');
342
390
  await this.store.saveTask(task);
343
- await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'task.completed', 'succeeded', `Task ${task.id} completed`));
344
391
  await this.deliverContinuation(job, task);
345
392
  }
346
- else {
347
- task.error = error;
348
- task.leaseOwner = null;
349
- task.leaseExpiresAt = null;
350
- transitionTask(task, task.attempt < task.maxAttempts ? 'READY' : 'FAILED');
393
+ else if (event.type === 'task.failed') {
394
+ task.error = event.message || `Task ${task.id} failed`;
395
+ transitionTask(task, 'FAILED');
351
396
  await this.store.saveTask(task);
352
- 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'));
353
397
  }
354
- this.detach(this.reconcile(jobId), jobId, task.id, 'worker follow-up reconciliation');
355
- }
356
- async cleanupThreads(job) {
357
- const adapter = this.options.adapters.find(candidate => candidate.name === 'chatgpt' && candidate.deleteThread);
358
- if (!adapter?.deleteThread)
359
- return;
360
- for (const task of job.tasks.filter(item => item.adapter === 'chatgpt' && item.threadId && !item.threadDeletedAt)) {
361
- try {
362
- await adapter.deleteThread(task.threadId, task);
363
- task.threadDeletedAt = new Date().toISOString();
364
- task.threadCleanupError = null;
365
- await this.store.saveTask(task);
366
- await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'thread.deleted', 'succeeded', `Worker thread ${task.threadId} deleted`, 'orchestrator', { threadId: task.threadId }));
367
- }
368
- catch (error) {
369
- task.threadCleanupError = error instanceof Error ? error.message : String(error);
370
- await this.store.saveTask(task);
371
- await this.emit(eventFor(job.id, task.id, task.parentTaskId, 'thread.delete.failed', 'failed', `Worker thread cleanup failed: ${task.threadCleanupError}`, 'orchestrator', { threadId: task.threadId }));
372
- }
398
+ else if (event.type === 'task.blocked') {
399
+ task.error = event.message || `Task ${task.id} blocked`;
400
+ transitionTask(task, 'BLOCKED');
401
+ await this.store.saveTask(task);
373
402
  }
403
+ this.detach(this.reconcile(job.id), job.id, task.id, 'terminal-event reconciliation');
374
404
  }
375
405
  async deliverContinuation(job, task) {
376
406
  const continuation = task.continuation ?? job.continuation;
@@ -430,5 +460,5 @@ export class Reconciler {
430
460
  } }
431
461
  }
432
462
  function samePlannerChildDefinition(left, right) {
433
- 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 });
463
+ 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 });
434
464
  }
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';
@@ -64,7 +64,7 @@ export async function runDaemon(argv) {
64
64
  const trusted = auth.trustedAuthors;
65
65
  const concurrency = Number(value(argv, '--concurrency', '4'));
66
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 });
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.4",
3
+ "version": "1.0.6",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {
@@ -1,79 +1,91 @@
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 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
+ The event must carry the exact durable `job_id` and `task_id`. Progress events are optional.
36
40
 
37
- ~~~sh
38
- npx agents-relay job adopt --repo OWNER/REPO --pr 6 --id JOB_ID --title "Objective"
39
- ~~~
41
+ Agents Relay maps those terminal events to durable state:
42
+ - `task.completed` `SUCCEEDED`
43
+ - `task.failed` → `FAILED`
44
+ - `task.blocked` → `BLOCKED`
40
45
 
41
- To repair duplicate markers for that same job:
46
+ 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.
42
47
 
43
- ~~~sh
44
- npx agents-relay job repair --repo OWNER/REPO --pr 6 --id JOB_ID
45
- ~~~
48
+ ## Managed GitHub jobs
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
+ 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.
48
51
 
49
- The legacy init flow remains supported for compatibility, but orchestrators should prefer job create/adopt/repair for GitHub-backed work.
50
-
51
- Typical managed flow:
52
+ Example:
52
53
 
53
54
  ~~~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
55
+ npx agents-relay job create --repo OWNER/REPO --head feat/example --base main --id JOB_ID --title "Objective"
56
+
57
+ 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."
58
+
59
+ npx agents-relayd --repo OWNER/REPO --events nats
59
60
  ~~~
60
61
 
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.
62
+ Use `--output file --output-path PATH` when the authoritative result is a file.
63
+
64
+ ## Worker prompt
65
+
66
+ For Codex and ChatGPT workers, Agents Relay adds only durable execution identity and contracts around the stored descriptive task input:
67
+ - PR URL
68
+ - job ID
69
+ - task ID
70
+ - parent task ID
71
+ - project name
72
+ - declared output
73
+ - mandatory terminal-event contract
62
74
 
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.
75
+ The job/task descriptions remain the durable source of intent. Adapters do not invent task meaning.
64
76
 
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.
77
+ ## ChatGPT worker
66
78
 
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.
79
+ 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
80
 
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.
81
+ 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
82
 
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.
83
+ ## Autonomous planner
72
84
 
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.
85
+ 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
86
 
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.
87
+ ## Durable truth
76
88
 
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.
89
+ 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
90
 
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.
91
+ 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.