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/README.md +14 -13
- package/dist/adapters.js +107 -257
- package/dist/cli.js +19 -8
- package/dist/events.js +1 -1
- package/dist/planner.js +9 -4
- package/dist/reconciler.js +132 -100
- package/dist/relayd.js +3 -3
- package/dist/store.js +1 -1
- package/package.json +1 -1
- package/skills/agents-relay/SKILL.md +58 -44
- package/skills/agents-relay/agents/planner.agent.md +12 -9
- package/skills/chatgpt-browser-worker/SKILL.md +56 -96
- package/skills/chatgpt-browser-worker/agents/browser-worker.agent.md +30 -101
- package/skills/chatgpt-browser-worker/scripts/_temporary_bh.py +171 -0
- package/skills/chatgpt-browser-worker/scripts/{create_bh.py → temporary_bh.py} +5 -8
- package/skills/chatgpt-browser-worker/references/contract.md +0 -132
- package/skills/chatgpt-browser-worker/references/orchestration.md +0 -101
- package/skills/chatgpt-browser-worker/scripts/_create_bh.py +0 -158
- package/skills/chatgpt-browser-worker/scripts/_operate_bh.py +0 -198
- package/skills/chatgpt-browser-worker/scripts/contract.py +0 -151
- package/skills/chatgpt-browser-worker/scripts/create.py +0 -84
- package/skills/chatgpt-browser-worker/scripts/operate_bh.py +0 -36
- package/skills/chatgpt-browser-worker/scripts/operations.py +0 -179
- package/skills/chatgpt-browser-worker/tests/fixtures/relay_lifecycle.json +0 -21
- package/skills/chatgpt-browser-worker/tests/test_agent_definition.py +0 -27
- package/skills/chatgpt-browser-worker/tests/test_contract.py +0 -315
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 = '
|
|
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
|
|
54
|
-
if (!['
|
|
55
|
-
throw new Error(`Planner next_tasks[${index}].adapter
|
|
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
|
});
|
package/dist/reconciler.js
CHANGED
|
@@ -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
|
-
|
|
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) {
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
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:
|
|
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.
|
|
239
|
+
await this.failLaunch(job.id, task.id, task.executionId, error instanceof Error ? error.message : String(error));
|
|
211
240
|
return;
|
|
212
241
|
}
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
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
|
|
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
|
-
|
|
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?.
|
|
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
|
-
|
|
334
|
-
|
|
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
|
-
|
|
338
|
-
|
|
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
|
-
|
|
349
|
-
task
|
|
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
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
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
|
|
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', '
|
|
67
|
-
const makeReconciler = (store, maxConcurrent) => new Reconciler(store, { owner: `relayd-${process.pid}`, maxConcurrent, leaseMs: Number(value(argv, '--lease-ms', '300000')), adapters: [new
|
|
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,79 +1,93 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: agents-relay
|
|
3
|
-
description: Create and operate durable asynchronous agent jobs through GitHub PR state,
|
|
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
|
-
|
|
8
|
+
Use Agents Relay when an orchestrator needs durable delegated work that survives the current process.
|
|
9
9
|
|
|
10
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
##
|
|
28
|
+
## Lifecycle contract
|
|
25
29
|
|
|
26
|
-
|
|
30
|
+
Events are authoritative for model-backed task state.
|
|
27
31
|
|
|
28
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
50
|
+
## Managed GitHub jobs
|
|
48
51
|
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
npx agents-relay
|
|
57
|
-
|
|
58
|
-
npx agents-
|
|
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
|
|
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
|
-
|
|
77
|
+
The job/task descriptions remain the durable source of intent. Adapters do not invent task meaning.
|
|
64
78
|
|
|
65
|
-
|
|
79
|
+
## ChatGPT worker
|
|
66
80
|
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
|
|
85
|
+
## Autonomous planner
|
|
72
86
|
|
|
73
|
-
|
|
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
|
-
|
|
89
|
+
## Durable truth
|
|
76
90
|
|
|
77
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
9
|
+
- Treat durable job/task state as authoritative.
|
|
10
10
|
- Never invent completed work or evidence.
|
|
11
|
-
-
|
|
12
|
-
-
|
|
13
|
-
-
|
|
14
|
-
-
|
|
15
|
-
-
|
|
16
|
-
-
|
|
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
|
|
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.
|