agents-relay 1.0.1 → 1.0.3
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 +5 -5
- package/dist/adapters.js +161 -125
- package/dist/cli.js +1 -1
- package/dist/dashboard.js +1 -1
- package/dist/reconciler.js +1 -1
- package/dist/relayd.js +1 -1
- package/package.json +1 -1
- package/skills/agents-relay/SKILL.md +3 -3
package/README.md
CHANGED
|
@@ -78,24 +78,24 @@ Codex/model-backed tasks require --provider and --model (or equivalent routing m
|
|
|
78
78
|
|
|
79
79
|
The minimal agent-network surface is machine-driven registration and discovery. `agent-register` persists an agent identity, responsibility boundary, claimed capabilities, endpoint/runtime, availability, and routing metadata in a trusted PR marker; `agent-discover` applies hard filters and returns evidence-backed candidates. Adapters remain runtimes, not agent identities. See [docs/architecture.md](docs/architecture.md) for the constrained future remote submission contract.
|
|
80
80
|
|
|
81
|
-
### ChatGPT workers through
|
|
81
|
+
### ChatGPT workers through the browser-worker agent
|
|
82
82
|
|
|
83
|
-
Use the chatgpt adapter to
|
|
83
|
+
Use the chatgpt adapter to launch the installed `chatgpt-browser-worker` agent through a local model harness. The observed ChatGPT `thread_id` is stored as the task `threadId`, so lineage is durable and a retry continues the same conversation.
|
|
84
84
|
|
|
85
85
|
Managed Codex and ChatGPT worker prompts automatically receive the durable PR URL plus job/task/parent/project context before the original task input. The stored task input is not rewritten.
|
|
86
86
|
|
|
87
|
-
Each ChatGPT task owns its own conversation. Retries reuse that task's conversation; sibling tasks never share a conversation merely because they use the same adapter. When the job reaches COMPLETED, or GitHub reports the PR merged, Agents Relay
|
|
87
|
+
Each ChatGPT task owns its own conversation. Retries reuse that task's conversation; sibling tasks never share a conversation merely because they use the same adapter. When the job reaches COMPLETED, or GitHub reports the PR merged, Agents Relay asks the browser-worker agent to delete every ChatGPT task conversation. The durable threadId remains in the task marker with threadDeletedAt for audit history. Failed deletion records threadCleanupError and is retried on later reconciliation without reopening the terminal job.
|
|
88
88
|
|
|
89
89
|
~~~sh
|
|
90
90
|
npx agents-relay submit --repo OWNER/REPO --pr 5 --id job-1 \
|
|
91
91
|
--task-id research-ui --adapter chatgpt \
|
|
92
|
-
--capabilities model,chatgpt,
|
|
92
|
+
--capabilities model,chatgpt,browser-harness \
|
|
93
93
|
--provider openai --model gpt-5-6-sol --reasoning high \
|
|
94
94
|
--chatgpt-project g-p-EXACT_PROJECT_ID \
|
|
95
95
|
--input "Research the dashboard UX and return implementation guidance."
|
|
96
96
|
~~~
|
|
97
97
|
|
|
98
|
-
The adapter
|
|
98
|
+
The adapter discovers `chatgpt-browser-worker/agents/browser-worker.agent.md` from `AGENTS_RELAY_CHATGPT_BROWSER_WORKER_AGENT` first, then `AGENTS_RELAY_SKILL_ROOTS`, `~/.codex/skills`, `~/.agents/skills`, and the local `skills` directory. It runs the definition and one typed JSON request through the configured local harness (`AGENTS_RELAY_AGENT_HARNESS`, default `codex`). Normal consumers do not need a Neo source checkout.
|
|
99
99
|
|
|
100
100
|
GitHub PR state is authoritative for terminal lifecycle: a merged PR reconciles its managed job to `COMPLETED`; a closed, unmerged PR reconciles to `CANCELLED` and cannot launch queued work. The dashboard exposes `/api/jobs` and shows repository-wide managed PR state beside each durable Agents Relay job state so stale markers are visible instead of being mistaken for current truth.
|
|
101
101
|
|
package/dist/adapters.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
-
import { readFile, readdir } from 'node:fs/promises';
|
|
2
|
+
import { access, readFile, readdir } from 'node:fs/promises';
|
|
3
3
|
import { homedir } from 'node:os';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { randomUUID } from 'node:crypto';
|
|
@@ -151,100 +151,145 @@ export class CodexAdapter {
|
|
|
151
151
|
return null;
|
|
152
152
|
}
|
|
153
153
|
}
|
|
154
|
-
function
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
if (reasoning === 'xhigh' || reasoning === 'extra-high')
|
|
160
|
-
return 'max';
|
|
161
|
-
return 'standard';
|
|
154
|
+
function browserWorkerCandidates(explicit) {
|
|
155
|
+
const roots = (process.env.AGENTS_RELAY_SKILL_ROOTS ?? '').split(':').filter(Boolean);
|
|
156
|
+
return [explicit, ...roots, join(homedir(), '.codex', 'skills'), join(homedir(), '.agents', 'skills'), join(process.cwd(), 'skills')]
|
|
157
|
+
.filter((value) => Boolean(value))
|
|
158
|
+
.map(value => value.endsWith('browser-worker.agent.md') ? value : join(value, 'chatgpt-browser-worker', 'agents', 'browser-worker.agent.md'));
|
|
162
159
|
}
|
|
163
|
-
function
|
|
164
|
-
const
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
url.hash = '';
|
|
173
|
-
return url.toString();
|
|
160
|
+
async function loadBrowserWorkerDefinition(explicit) {
|
|
161
|
+
for (const candidate of browserWorkerCandidates(explicit)) {
|
|
162
|
+
try {
|
|
163
|
+
await access(candidate);
|
|
164
|
+
return readFile(candidate, 'utf8');
|
|
165
|
+
}
|
|
166
|
+
catch { /* try the next installed skill root */ }
|
|
167
|
+
}
|
|
168
|
+
throw new Error('chatgpt-browser-worker agent definition not found; set AGENTS_RELAY_CHATGPT_BROWSER_WORKER_AGENT');
|
|
174
169
|
}
|
|
175
|
-
function
|
|
176
|
-
|
|
170
|
+
function thinkingLevel(reasoning) {
|
|
171
|
+
if (reasoning === 'low' || reasoning === 'medium' || reasoning === 'high')
|
|
172
|
+
return reasoning;
|
|
173
|
+
if (reasoning === 'minimal')
|
|
174
|
+
return 'low';
|
|
175
|
+
return 'default';
|
|
177
176
|
}
|
|
178
|
-
function
|
|
179
|
-
const
|
|
180
|
-
|
|
181
|
-
return undefined;
|
|
177
|
+
function parseBrowserWorkerResponse(output) {
|
|
178
|
+
const text = output.trim().replace(/^```(?:json)?\s*|\s*```$/g, '').trim();
|
|
179
|
+
let value;
|
|
182
180
|
try {
|
|
183
|
-
|
|
184
|
-
return typeof value === 'string' ? value : undefined;
|
|
181
|
+
value = JSON.parse(text);
|
|
185
182
|
}
|
|
186
183
|
catch {
|
|
187
|
-
|
|
184
|
+
throw new Error('browser-worker returned non-JSON output');
|
|
188
185
|
}
|
|
186
|
+
if (!value || typeof value !== 'object')
|
|
187
|
+
throw new Error('browser-worker returned an invalid JSON response');
|
|
188
|
+
const response = value;
|
|
189
|
+
if (!response.operation || !response.status)
|
|
190
|
+
throw new Error('browser-worker response omitted operation or status');
|
|
191
|
+
return response;
|
|
189
192
|
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
193
|
+
function agentPrompt(definition, request) {
|
|
194
|
+
return `${definition}\n\nReturn only the machine-readable JSON response required by the agent contract.\nREQUEST_JSON:\n${JSON.stringify(request)}`;
|
|
195
|
+
}
|
|
196
|
+
function defaultChatGptAgentRunner(harness) {
|
|
197
|
+
return (definition, request, route, signal) => {
|
|
198
|
+
const child = spawn(harness, buildCodexArgs(route, agentPrompt(definition, request)), { shell: false, cwd: route.cwd, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
199
|
+
let stdout = '';
|
|
200
|
+
let stderr = '';
|
|
201
|
+
child.stdout?.on('data', (chunk) => { stdout += chunk.toString(); });
|
|
202
|
+
child.stderr?.on('data', (chunk) => { stderr = appendTail(stderr, chunk.toString()); });
|
|
203
|
+
const promise = new Promise((resolve, reject) => {
|
|
204
|
+
child.on('error', reject);
|
|
205
|
+
child.on('close', code => code === 0 ? resolve(parseCodexAgentMessage(stdout) ?? stdout.trim()) : reject(new Error(stderr.trim() || `browser-worker harness exited ${code}`)));
|
|
206
|
+
if (signal.aborted)
|
|
207
|
+
child.kill('SIGTERM');
|
|
208
|
+
else
|
|
209
|
+
signal.addEventListener('abort', () => child.kill('SIGTERM'), { once: true });
|
|
210
|
+
});
|
|
211
|
+
return { promise, cancel: () => { child.kill('SIGTERM'); } };
|
|
212
|
+
};
|
|
213
|
+
}
|
|
214
|
+
function parseCodexAgentMessage(output) {
|
|
215
|
+
let final;
|
|
216
|
+
for (const line of output.split(/\r?\n/)) {
|
|
217
|
+
try {
|
|
218
|
+
const value = JSON.parse(line);
|
|
219
|
+
const payload = value.payload;
|
|
220
|
+
const item = value.item;
|
|
221
|
+
if (value.type === 'event_msg' && payload?.type === 'task_complete' && typeof payload.last_agent_message === 'string')
|
|
222
|
+
final = payload.last_agent_message;
|
|
223
|
+
if (value.type === 'item.completed' && item?.type === 'agent_message' && typeof item.text === 'string')
|
|
224
|
+
final = item.text;
|
|
225
|
+
}
|
|
226
|
+
catch { /* harness diagnostics are not the agent response */ }
|
|
209
227
|
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
return
|
|
228
|
+
return final;
|
|
229
|
+
}
|
|
230
|
+
function browserWorkerError(response) {
|
|
231
|
+
const detail = response.error?.message || `browser-worker ${response.status}`;
|
|
232
|
+
return new Error(response.error?.code ? `${response.error.code}: ${detail}` : detail);
|
|
215
233
|
}
|
|
216
234
|
export class ChatGptAdapter {
|
|
217
235
|
name = 'chatgpt';
|
|
218
|
-
id = 'chatgpt/
|
|
219
|
-
capabilities = ['model', 'chatgpt', '
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
+
id = 'chatgpt/browser-worker';
|
|
237
|
+
capabilities = ['model', 'chatgpt', 'browser-harness'];
|
|
238
|
+
agentPath;
|
|
239
|
+
harness;
|
|
240
|
+
runner;
|
|
241
|
+
deleted = new Set();
|
|
242
|
+
routes = new Map();
|
|
243
|
+
constructor(options = {}) { this.agentPath = options.agentPath ?? process.env.AGENTS_RELAY_CHATGPT_BROWSER_WORKER_AGENT; this.harness = options.harness ?? process.env.AGENTS_RELAY_AGENT_HARNESS ?? 'codex'; this.runner = options.agentRunner; }
|
|
244
|
+
async run(request, route, signal) {
|
|
245
|
+
const definition = await loadBrowserWorkerDefinition(this.agentPath);
|
|
246
|
+
if (signal.aborted)
|
|
247
|
+
throw new Error('browser-worker execution aborted');
|
|
248
|
+
const run = (this.runner ?? defaultChatGptAgentRunner(this.harness))(definition, request, route, signal);
|
|
249
|
+
const output = await new Promise((resolve, reject) => {
|
|
250
|
+
const abort = () => { run.cancel(); reject(new Error('browser-worker execution aborted')); };
|
|
251
|
+
if (signal.aborted) {
|
|
252
|
+
abort();
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
signal.addEventListener('abort', abort, { once: true });
|
|
256
|
+
run.promise.then(value => { signal.removeEventListener('abort', abort); resolve(value); }, error => { signal.removeEventListener('abort', abort); reject(error); });
|
|
236
257
|
});
|
|
237
|
-
|
|
258
|
+
return parseBrowserWorkerResponse(output);
|
|
259
|
+
}
|
|
260
|
+
project(task, route) {
|
|
261
|
+
return { name: task?.projectName ?? route.projectId ?? 'default', id: route.projectId };
|
|
262
|
+
}
|
|
263
|
+
exposeThread(execution, expected, response) {
|
|
264
|
+
const threadId = typeof response.thread_id === 'string' && response.thread_id.trim() ? response.thread_id : undefined;
|
|
265
|
+
if (!threadId)
|
|
266
|
+
throw new Error('browser-worker response omitted thread_id');
|
|
267
|
+
if (expected && threadId !== expected)
|
|
268
|
+
throw new Error('browser-worker returned a different thread_id');
|
|
269
|
+
execution.threadId = threadId;
|
|
270
|
+
execution.onThreadStarted?.(threadId);
|
|
271
|
+
return threadId;
|
|
272
|
+
}
|
|
273
|
+
validateOperation(response, operation) {
|
|
274
|
+
if (response.operation !== operation)
|
|
275
|
+
throw new Error(`browser-worker response operation mismatch: expected ${operation}, got ${response.operation}`);
|
|
276
|
+
if (response.status === 'blocked' || response.status === 'failed')
|
|
277
|
+
throw browserWorkerError(response);
|
|
278
|
+
}
|
|
279
|
+
async deleteThread(threadId, task) {
|
|
280
|
+
if (this.deleted.has(threadId))
|
|
238
281
|
return;
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
throw new Error(
|
|
247
|
-
|
|
282
|
+
const route = task?.routing ?? this.routes.get(threadId);
|
|
283
|
+
if (!route)
|
|
284
|
+
throw new Error(`Task routing metadata is required to delete browser-worker thread ${threadId}`);
|
|
285
|
+
const response = await this.run({ operation: 'delete', thread_id: threadId, project: this.project(task, route), state: null }, route, new AbortController().signal);
|
|
286
|
+
if (response.operation !== 'delete')
|
|
287
|
+
throw new Error(`browser-worker response operation mismatch: expected delete, got ${response.operation}`);
|
|
288
|
+
if (response.thread_id && response.thread_id !== threadId)
|
|
289
|
+
throw new Error('browser-worker returned a different thread_id');
|
|
290
|
+
if (response.status !== 'deleted' && response.status !== 'not_found')
|
|
291
|
+
throw browserWorkerError(response);
|
|
292
|
+
this.deleted.add(threadId);
|
|
248
293
|
}
|
|
249
294
|
launch(task, signal) {
|
|
250
295
|
if (!task.routing)
|
|
@@ -257,54 +302,45 @@ export class ChatGptAdapter {
|
|
|
257
302
|
else
|
|
258
303
|
signal.addEventListener('abort', abort, { once: true });
|
|
259
304
|
const execution = { id: randomUUID(), promise: Promise.resolve({ summary: '' }), cancel: abort };
|
|
305
|
+
const timeout = setTimeout(abort, Math.max(1, task.timeoutMs));
|
|
260
306
|
execution.promise = (async () => {
|
|
261
|
-
const token = (await readFile(this.tokenFile, 'utf8')).trim();
|
|
262
|
-
if (!token)
|
|
263
|
-
throw new Error(`MacBridge token file is empty: ${this.tokenFile}`);
|
|
264
|
-
const body = {
|
|
265
|
-
prompt: task.input,
|
|
266
|
-
model: route.model,
|
|
267
|
-
thinking_effort: chatGptThinkingEffort(route.reasoning),
|
|
268
|
-
max_runtime_seconds: Math.max(30, Math.min(3600, Math.ceil(task.timeoutMs / 1000))),
|
|
269
|
-
};
|
|
270
|
-
if (route.projectId)
|
|
271
|
-
body.project_id = route.projectId;
|
|
272
|
-
if (task.threadId)
|
|
273
|
-
body.conversation_id = task.threadId;
|
|
274
|
-
const response = await this.fetchImpl(this.endpoint, {
|
|
275
|
-
method: 'POST',
|
|
276
|
-
headers: { authorization: `Bearer ${token}`, 'content-type': 'application/json' },
|
|
277
|
-
body: JSON.stringify(body),
|
|
278
|
-
signal: controller.signal,
|
|
279
|
-
});
|
|
280
|
-
let exposedConversationId;
|
|
281
|
-
const exposeConversationId = (conversationId) => {
|
|
282
|
-
if (exposedConversationId === conversationId)
|
|
283
|
-
return;
|
|
284
|
-
exposedConversationId = conversationId;
|
|
285
|
-
execution.threadId = conversationId;
|
|
286
|
-
execution.onThreadStarted?.(conversationId);
|
|
287
|
-
};
|
|
288
|
-
const text = await readChatGptResponse(response, exposeConversationId);
|
|
289
|
-
let payload;
|
|
290
307
|
try {
|
|
291
|
-
|
|
308
|
+
const expectedThreadId = task.threadId;
|
|
309
|
+
const hasPrompt = Boolean(task.input.trim());
|
|
310
|
+
const operation = expectedThreadId ? (hasPrompt ? 'continue' : 'resume') : 'create';
|
|
311
|
+
const request = {
|
|
312
|
+
operation, thread_id: expectedThreadId, project: this.project(task, route), prompt: hasPrompt ? task.input : undefined,
|
|
313
|
+
thinking_level: thinkingLevel(route.reasoning), state: null,
|
|
314
|
+
};
|
|
315
|
+
const response = await this.run(request, route, controller.signal);
|
|
316
|
+
const threadId = this.exposeThread(execution, expectedThreadId, response);
|
|
317
|
+
this.routes.set(threadId, route);
|
|
318
|
+
this.validateOperation(response, operation);
|
|
319
|
+
let status = response.status;
|
|
320
|
+
while (status !== 'awaiting_result' && status !== 'completed') {
|
|
321
|
+
if (controller.signal.aborted)
|
|
322
|
+
throw new Error('browser-worker execution aborted');
|
|
323
|
+
await new Promise((resolve, reject) => {
|
|
324
|
+
const timer = setTimeout(resolve, 25);
|
|
325
|
+
controller.signal.addEventListener('abort', () => { clearTimeout(timer); reject(new Error('browser-worker execution aborted')); }, { once: true });
|
|
326
|
+
});
|
|
327
|
+
const statusResponse = await this.run({ operation: 'status', thread_id: threadId, project: this.project(task, route), state: null }, route, controller.signal);
|
|
328
|
+
if (statusResponse.thread_id && statusResponse.thread_id !== threadId)
|
|
329
|
+
throw new Error('browser-worker returned a different thread_id');
|
|
330
|
+
this.validateOperation(statusResponse, 'status');
|
|
331
|
+
status = statusResponse.status;
|
|
332
|
+
}
|
|
333
|
+
const resultResponse = await this.run({ operation: 'result', thread_id: threadId, project: this.project(task, route), state: null }, route, controller.signal);
|
|
334
|
+
if (resultResponse.thread_id && resultResponse.thread_id !== threadId)
|
|
335
|
+
throw new Error('browser-worker returned a different thread_id');
|
|
336
|
+
this.validateOperation(resultResponse, 'result');
|
|
337
|
+
if (resultResponse.status !== 'completed' || resultResponse.result?.verified !== true || !resultResponse.result.message_id || !resultResponse.result.text?.trim())
|
|
338
|
+
throw browserWorkerError(resultResponse);
|
|
339
|
+
return { summary: resultResponse.result.text.trim(), data: { threadId, messageId: resultResponse.result.message_id, verified: true, provider: route.provider, model: route.model } };
|
|
292
340
|
}
|
|
293
|
-
|
|
294
|
-
|
|
341
|
+
finally {
|
|
342
|
+
clearTimeout(timeout);
|
|
295
343
|
}
|
|
296
|
-
const conversationId = typeof payload.conversation_id === 'string' ? payload.conversation_id : exposedConversationId;
|
|
297
|
-
if (conversationId)
|
|
298
|
-
exposeConversationId(conversationId);
|
|
299
|
-
if (!response.ok)
|
|
300
|
-
throw new Error(typeof payload.error === 'string' ? payload.error : `MacBridge ChatGPT request failed (${response.status})`);
|
|
301
|
-
if (payload.complete !== true)
|
|
302
|
-
throw new Error('MacBridge ChatGPT conversation did not complete');
|
|
303
|
-
const assistantText = typeof payload.assistant_text === 'string' ? payload.assistant_text.trim() : '';
|
|
304
|
-
return {
|
|
305
|
-
summary: assistantText || 'ChatGPT conversation completed',
|
|
306
|
-
data: { conversationId, provider: route.provider, model: route.model },
|
|
307
|
-
};
|
|
308
344
|
})();
|
|
309
345
|
return execution;
|
|
310
346
|
}
|
package/dist/cli.js
CHANGED
|
@@ -264,7 +264,7 @@ async function runJobCommand(action, args) {
|
|
|
264
264
|
console.log(JSON.stringify({ repository: repo, pr: result.pr.number, job: result.job.id, executionMode: result.job.executionMode, state: result.job.state }, null, 2));
|
|
265
265
|
}
|
|
266
266
|
export function runtimePlanner(args) { const plannerCommand = arg([...args], '--planner-command'); const modelRuntime = arg([...args], '--codex', 'codex'); return plannerCommand ? new CommandObjectivePlanner(plannerCommand) : new AgentObjectivePlanner(modelRuntime); }
|
|
267
|
-
export function runtime(store, args, bus) { const emit = (event) => { process.stderr.write(`${JSON.stringify(event)}\n`); }; const modelRuntime = arg(args, '--codex', 'codex'); const planner = runtimePlanner(args); return new Reconciler(store, { owner: arg(args, '--owner', `cli-${process.pid}`), maxConcurrent: Number(arg(args, '--concurrency', '4')), leaseMs: Number(arg(args, '--lease-ms', '300000')), adapters: [new ShellAdapter(), new CodexAdapter(modelRuntime), new ChatGptAdapter(
|
|
267
|
+
export function runtime(store, args, bus) { const emit = (event) => { process.stderr.write(`${JSON.stringify(event)}\n`); }; const modelRuntime = arg(args, '--codex', 'codex'); const planner = runtimePlanner(args); return new Reconciler(store, { owner: arg(args, '--owner', `cli-${process.pid}`), maxConcurrent: Number(arg(args, '--concurrency', '4')), leaseMs: Number(arg(args, '--lease-ms', '300000')), adapters: [new ShellAdapter(), new CodexAdapter(modelRuntime), new ChatGptAdapter()], continuations: [new CodexThreadContinuation(modelRuntime), new CommandContinuation(), new WebhookContinuation()], planner, eventBus: bus, emit }); }
|
|
268
268
|
export async function createService(args) {
|
|
269
269
|
const loaded = await storeFor(args);
|
|
270
270
|
const upstream = eventBus(args);
|
package/dist/dashboard.js
CHANGED
|
@@ -202,7 +202,7 @@ function syncFilters(){
|
|
|
202
202
|
function renderJobList(){jobList.replaceChildren();const visible=filteredJobs();if(!jobs.length){jobList.append(element('li','empty',text('No durable jobs discovered.')));return;}if(!visible.length){jobList.append(element('li','empty',text('No jobs match the selected repo.')));return;}for(const job of visible){const button=document.createElement('button');button.type='button';button.className='job-button'+(isCompleteUnmerged(job)?' complete-unmerged':'');button.setAttribute('aria-current',String(job.id===selectedJob));button.title=job.title||job.id;button.addEventListener('click',()=>selectJob(job.id));const meta=element('span','job-meta');meta.append(element('span','badge '+value(job.state),text(job.state)),element('span','badge mode-'+value(job.executionMode||'fixed'),text(value(job.executionMode||'fixed').toUpperCase())),element('span',job.draft?'badge DRAFT':'',text(job.draft?'DRAFT':value(job.githubState||'UNKNOWN'))));if(isCompleteUnmerged(job))meta.append(element('span','badge MERGE_PENDING',text('MERGE PENDING')));meta.append(element('span','',text(job.repository||'unknown repo')),element('span','',text(relativeTime(job.updatedAt))));button.append(element('span','job-title',text('PR#'+value(job.prNumber)+': '+value(job.title||job.id))),meta,element('span','subtle',text(compactCounts(counts(job.tasks)))));jobList.append(element('li','',button));}}
|
|
203
203
|
function taskNode(task,children,depth){const li=element('li');const row=document.createElement('button');row.type='button';row.className='task-row';row.setAttribute('aria-current',String(task.id===selectedTaskId));row.title=task.id;row.addEventListener('click',()=>{selectedTaskId=task.id;rememberSelection();renderAll();});const id=element('span','task-id',text(task.id));id.style.paddingLeft=Math.min(depth,4)*6+'px';row.append(id,element('span','badge '+value(task.state),text(task.state)));li.append(row);const nested=children.get(task.id)||[];if(nested.length){const ul=element('ul','tree');for(const child of nested)ul.append(taskNode(child,children,depth+1));li.append(ul);}return li;}
|
|
204
204
|
function buildTree(tasks){taskTree.replaceChildren();if(!Array.isArray(tasks)||!tasks.length){taskTree.append(element('li','empty',text('No tasks in durable state.')));return;}const known=new Set(tasks.map(task=>task.id));const children=new Map();const roots=[];for(const task of tasks){if(!task.parentTaskId||!known.has(task.parentTaskId))roots.push(task);else{const siblings=children.get(task.parentTaskId)||[];siblings.push(task);children.set(task.parentTaskId,siblings);}}for(const task of roots)taskTree.append(taskNode(task,children,0));}
|
|
205
|
-
function attemptBudget(task){const attempt=Number(task?.attempt)||0;const max=Number(task?.maxAttempts)||0;if(max>=3&&max%3===0)return attempt+' / 3×'+(
|
|
205
|
+
function attemptBudget(task){const attempt=Number(task?.attempt)||0;const max=Number(task?.maxAttempts)||0;if(max>=3&&max%3===0){const effectiveMax=Math.max(max,Math.ceil(attempt/3)*3);return attempt+' / 3×'+(effectiveMax/3);}return attempt+' / '+max;}
|
|
206
206
|
function renderTaskDetail(task){taskDetailRoot.replaceChildren();if(!task){taskDetailRoot.append(element('p','empty',text('Select a task to inspect execution details.')));return;}const routing=task.routing||{};const groups=element('div','detail-groups');groups.append(field('Task',task.id),field('Kind',task.kind||'work'),field('State',task.state),field('Project / agent',value(task.projectName)+' / '+value(task.agentName)),field('Parent task',task.parentTaskId||'root'),field('Adapter',task.adapter),field('Provider / model',value(routing.provider)+' / '+value(routing.model)),field('Reasoning / profile',value(routing.reasoning||routing.profile)),field('Attempt',attemptBudget(task)),field('Dependencies',Array.isArray(task.dependencies)&&task.dependencies.length?task.dependencies.join(', '):'none'),chatField(task));if(task.plannerResult)groups.append(field('Objective status',task.plannerResult.objective_status),markdownField('Assessment',task.plannerResult.assessment));if(task.error)groups.append(field('Error',task.error));taskDetailRoot.append(groups);}
|
|
207
207
|
function renderSelectedJob(){jobDetail.replaceChildren();const job=selected();if(!job){selectedJobTitle.textContent='';jobDetail.append(element('div','empty',text('No durable jobs discovered.')));buildTree([]);renderTaskDetail(null);return;}selectedJobTitle.textContent='— '+value(job.title||job.id);if(job.id!==selectedJob){selectedJob=job.id;selectedTaskId=null;rememberSelection();}const summary=element('div','job-summary');summary.append(field('GitHub state',job.githubState||'UNKNOWN'),field('Durable state',job.state),field('Execution mode',job.executionMode||'fixed'),field('Objective',job.objective||job.description||job.title),prField(job),field('Repository',job.repository||'—'),field('Last activity',relativeTime(job.updatedAt)),field('Task states',compactCounts(counts(job.tasks))));jobDetail.append(summary);const selectedTask=(job.tasks||[]).find(task=>task.id===selectedTaskId)||null;if(selectedTaskId&&!selectedTask){selectedTaskId=null;rememberSelection();}buildTree(job.tasks||[]);renderTaskDetail(selectedTask);}
|
|
208
208
|
function renderAll(){jobs.sort(compareJobs);syncFilters();renderJobList();renderSelectedJob();}
|
package/dist/reconciler.js
CHANGED
|
@@ -359,7 +359,7 @@ export class Reconciler {
|
|
|
359
359
|
return;
|
|
360
360
|
for (const task of job.tasks.filter(item => item.adapter === 'chatgpt' && item.threadId && !item.threadDeletedAt)) {
|
|
361
361
|
try {
|
|
362
|
-
await adapter.deleteThread(task.threadId);
|
|
362
|
+
await adapter.deleteThread(task.threadId, task);
|
|
363
363
|
task.threadDeletedAt = new Date().toISOString();
|
|
364
364
|
task.threadCleanupError = null;
|
|
365
365
|
await this.store.saveTask(task);
|
package/dist/relayd.js
CHANGED
|
@@ -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(
|
|
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 });
|
|
68
68
|
const repositories = normalized.repository
|
|
69
69
|
? [normalized.repository]
|
|
70
70
|
: (await discoverWorkspaceRepositories(normalized.workspaceRoot ?? defaultWorkspaceRoot())).map(item => item.repository);
|
package/package.json
CHANGED
|
@@ -10,7 +10,7 @@ description: Create and operate durable asynchronous agent jobs through GitHub P
|
|
|
10
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`.
|
|
11
11
|
|
|
12
12
|
|
|
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
|
|
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
14
|
|
|
15
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.
|
|
16
16
|
|
|
@@ -62,11 +62,11 @@ Use --file PATH only for explicit local demo/test mode. Optional --events nats e
|
|
|
62
62
|
|
|
63
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.
|
|
64
64
|
|
|
65
|
-
For a ChatGPT worker, use the chatgpt adapter with capabilities model,chatgpt,
|
|
65
|
+
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.
|
|
66
66
|
|
|
67
67
|
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.
|
|
68
68
|
|
|
69
|
-
When a job becomes COMPLETED or its GitHub PR is merged, 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.
|
|
69
|
+
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.
|
|
70
70
|
|
|
71
71
|
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.
|
|
72
72
|
|