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/README.md +14 -13
- package/dist/adapters.js +133 -234
- package/dist/cli.js +18 -7
- package/dist/planner.js +9 -4
- package/dist/reconciler.js +128 -98
- package/dist/relayd.js +2 -2
- package/dist/store.js +1 -1
- package/package.json +1 -1
- package/skills/agents-relay/SKILL.md +57 -45
- package/skills/agents-relay/agents/planner.agent.md +12 -9
- package/skills/chatgpt-browser-worker/SKILL.md +62 -0
- package/skills/chatgpt-browser-worker/agents/browser-worker.agent.md +42 -0
- package/skills/chatgpt-browser-worker/scripts/_temporary_bh.py +171 -0
- package/skills/chatgpt-browser-worker/scripts/temporary_bh.py +31 -0
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
|
@@ -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
|
-
|
|
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) {
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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:
|
|
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.
|
|
235
|
+
await this.failLaunch(job.id, task.id, task.executionId, error instanceof Error ? error.message : String(error));
|
|
210
236
|
return;
|
|
211
237
|
}
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
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
|
|
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
|
-
|
|
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?.
|
|
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
|
-
|
|
333
|
-
|
|
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
|
-
|
|
337
|
-
|
|
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 =
|
|
348
|
-
task
|
|
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
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
|
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
|
|
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,91 @@
|
|
|
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 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
|
+
The event must carry the exact durable `job_id` and `task_id`. Progress events are optional.
|
|
36
40
|
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
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
|
-
|
|
44
|
-
npx agents-relay job repair --repo OWNER/REPO --pr 6 --id JOB_ID
|
|
45
|
-
~~~
|
|
48
|
+
## Managed GitHub jobs
|
|
46
49
|
|
|
47
|
-
|
|
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
|
-
|
|
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
|
-
|
|
56
|
-
npx agents-relay
|
|
57
|
-
|
|
58
|
-
npx agents-
|
|
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
|
|
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
|
-
|
|
75
|
+
The job/task descriptions remain the durable source of intent. Adapters do not invent task meaning.
|
|
64
76
|
|
|
65
|
-
|
|
77
|
+
## ChatGPT worker
|
|
66
78
|
|
|
67
|
-
|
|
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
|
-
|
|
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
|
-
|
|
83
|
+
## Autonomous planner
|
|
72
84
|
|
|
73
|
-
|
|
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
|
-
|
|
87
|
+
## Durable truth
|
|
76
88
|
|
|
77
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|