agents-relay 1.0.3 → 1.0.5

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 CHANGED
@@ -17,6 +17,14 @@ npx agents-relay reconcile --repo OWNER/REPO --pr 12 --id job-1
17
17
  npx agents-relay status --repo OWNER/REPO --pr 12 --id job-1
18
18
  ~~~
19
19
 
20
+ Mutable queued/retryable task fields can be changed in place without replacing the durable task identity. `task update` rejects `RUNNING`, `SUCCEEDED`, and `CANCELLED` tasks so active execution and terminal history cannot be rewritten. For example:
21
+
22
+ ~~~sh
23
+ npx agents-relay task update --repo OWNER/REPO --pr 12 --id job-1 --task-id child \
24
+ --adapter chatgpt --provider chatgpt --model gpt-5.6-sol --reasoning high \
25
+ --capabilities model,chatgpt,browser-harness
26
+ ~~~
27
+
20
28
  Jobs default to `fixed`, preserving the original submit-and-run behavior. Use
21
29
  `--mode autonomous` with `init` or `job create` to make reconciliation own
22
30
  planner progress. The durable status JSON and dashboard show the mode. An
@@ -95,7 +103,7 @@ npx agents-relay submit --repo OWNER/REPO --pr 5 --id job-1 \
95
103
  --input "Research the dashboard UX and return implementation guidance."
96
104
  ~~~
97
105
 
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.
106
+ The adapter discovers the packaged `chatgpt-browser-worker` skill from `AGENTS_RELAY_CHATGPT_BROWSER_WORKER_AGENT` first, then `AGENTS_RELAY_SKILL_ROOTS`, the package `skills` directory, `~/.codex/skills`, `~/.agents/skills`, and the local `skills` directory. It invokes that skill's Browser Harness drivers directly; it does not launch Codex, MacDeveloperBridge, or a ChatGPT API transport. Normal consumers do not need a Neo source checkout.
99
107
 
100
108
  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
109
 
package/dist/adapters.js CHANGED
@@ -1,7 +1,8 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { access, readFile, readdir } from 'node:fs/promises';
3
3
  import { homedir } from 'node:os';
4
- import { join } from 'node:path';
4
+ import { dirname, join } from 'node:path';
5
+ import { fileURLToPath } from 'node:url';
5
6
  import { randomUUID } from 'node:crypto';
6
7
  function commandExecution(child, output, signal, id) { const promise = new Promise((resolve, reject) => { child.on('error', reject); child.on('close', code => code === 0 ? resolve({ summary: output().trim() || 'Command completed' }) : reject(new Error(output().trim() || `Command exited ${code}`))); signal.addEventListener('abort', () => child.kill('SIGTERM'), { once: true }); }); return { id, promise, cancel: () => { child.kill('SIGTERM'); } }; }
7
8
  export function buildCodexArgs(route, input) {
@@ -151,17 +152,20 @@ export class CodexAdapter {
151
152
  return null;
152
153
  }
153
154
  }
155
+ export class LaunchFailure extends Error {
156
+ }
154
157
  function browserWorkerCandidates(explicit) {
155
158
  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')]
159
+ const packageSkills = join(dirname(fileURLToPath(import.meta.url)), '..', 'skills');
160
+ return [explicit, ...roots, packageSkills, join(homedir(), '.codex', 'skills'), join(homedir(), '.agents', 'skills'), join(process.cwd(), 'skills')]
157
161
  .filter((value) => Boolean(value))
158
162
  .map(value => value.endsWith('browser-worker.agent.md') ? value : join(value, 'chatgpt-browser-worker', 'agents', 'browser-worker.agent.md'));
159
163
  }
160
- async function loadBrowserWorkerDefinition(explicit) {
164
+ async function loadBrowserWorkerRuntime(explicit) {
161
165
  for (const candidate of browserWorkerCandidates(explicit)) {
162
166
  try {
163
167
  await access(candidate);
164
- return readFile(candidate, 'utf8');
168
+ return { definition: await readFile(candidate, 'utf8'), root: dirname(dirname(candidate)) };
165
169
  }
166
170
  catch { /* try the next installed skill root */ }
167
171
  }
@@ -190,19 +194,71 @@ function parseBrowserWorkerResponse(output) {
190
194
  throw new Error('browser-worker response omitted operation or status');
191
195
  return response;
192
196
  }
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)}`;
197
+ function parseDriverJson(output) {
198
+ const lines = output.trim().split(/\r?\n/).filter(Boolean);
199
+ for (let index = lines.length - 1; index >= 0; index -= 1) {
200
+ try {
201
+ const value = JSON.parse(lines[index]);
202
+ if (value && typeof value === 'object' && !Array.isArray(value))
203
+ return value;
204
+ }
205
+ catch { /* browser-harness diagnostics may precede the final JSON observation */ }
206
+ }
207
+ throw new Error('browser-worker driver returned no JSON observation');
195
208
  }
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'] });
209
+ function directBrowserWorkerRunner(root) {
210
+ return (_definition, request, _route, signal) => {
211
+ const scripts = join(root, 'scripts');
212
+ const project = request.project.name;
213
+ const args = request.operation === 'create'
214
+ ? [join(scripts, 'create_bh.py'), '--project', project, '--prompt', request.prompt ?? '', '--thinking-level', request.thinking_level ?? 'default']
215
+ : [join(scripts, 'operate_bh.py'), request.operation, '--thread-id', request.thread_id ?? '', '--project', project,
216
+ ...(request.operation === 'continue' ? ['--prompt', request.prompt ?? ''] : [])];
217
+ const child = spawn('python3', args, { shell: false, stdio: ['ignore', 'pipe', 'pipe'] });
199
218
  let stdout = '';
200
219
  let stderr = '';
201
220
  child.stdout?.on('data', (chunk) => { stdout += chunk.toString(); });
202
221
  child.stderr?.on('data', (chunk) => { stderr = appendTail(stderr, chunk.toString()); });
203
222
  const promise = new Promise((resolve, reject) => {
204
223
  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}`)));
224
+ child.on('close', code => {
225
+ if (code !== 0) {
226
+ reject(new Error(stderr.trim() || stdout.trim() || `browser-worker driver exited ${code}`));
227
+ return;
228
+ }
229
+ try {
230
+ const observed = parseDriverJson(stdout);
231
+ const threadId = typeof observed.thread_id === 'string' ? observed.thread_id : request.thread_id;
232
+ const response = { operation: request.operation, status: 'running', thread_id: threadId ?? null };
233
+ if (request.operation === 'create')
234
+ response.status = 'running';
235
+ else if (request.operation === 'continue')
236
+ response.status = 'running';
237
+ else if (request.operation === 'resume')
238
+ response.status = typeof observed.status === 'string' ? observed.status : 'awaiting_result';
239
+ else if (request.operation === 'status')
240
+ response.status = typeof observed.status === 'string' ? observed.status : 'awaiting_result';
241
+ else if (request.operation === 'result') {
242
+ response.status = typeof observed.status === 'string' ? observed.status : 'completed';
243
+ response.result = {
244
+ message_id: typeof observed.message_id === 'string' ? observed.message_id : undefined,
245
+ text: typeof observed.text === 'string' ? observed.text : undefined,
246
+ verified: Boolean(observed.message_id && observed.text),
247
+ observed_at: new Date().toISOString()
248
+ };
249
+ }
250
+ else if (request.operation === 'delete') {
251
+ const outcome = typeof observed.outcome === 'string' ? observed.outcome : '';
252
+ response.status = outcome === 'deleted' || outcome === 'not_found' ? outcome : 'failed';
253
+ if (response.status === 'failed')
254
+ response.error = { code: 'cleanup_not_verified', message: 'browser-worker did not verify thread cleanup', retryable: true };
255
+ }
256
+ resolve(JSON.stringify(response));
257
+ }
258
+ catch (error) {
259
+ reject(error);
260
+ }
261
+ });
206
262
  if (signal.aborted)
207
263
  child.kill('SIGTERM');
208
264
  else
@@ -211,22 +267,6 @@ function defaultChatGptAgentRunner(harness) {
211
267
  return { promise, cancel: () => { child.kill('SIGTERM'); } };
212
268
  };
213
269
  }
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 */ }
227
- }
228
- return final;
229
- }
230
270
  function browserWorkerError(response) {
231
271
  const detail = response.error?.message || `browser-worker ${response.status}`;
232
272
  return new Error(response.error?.code ? `${response.error.code}: ${detail}` : detail);
@@ -236,16 +276,15 @@ export class ChatGptAdapter {
236
276
  id = 'chatgpt/browser-worker';
237
277
  capabilities = ['model', 'chatgpt', 'browser-harness'];
238
278
  agentPath;
239
- harness;
240
279
  runner;
241
280
  deleted = new Set();
242
281
  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; }
282
+ constructor(options = {}) { this.agentPath = options.agentPath ?? process.env.AGENTS_RELAY_CHATGPT_BROWSER_WORKER_AGENT; this.runner = options.agentRunner; }
244
283
  async run(request, route, signal) {
245
- const definition = await loadBrowserWorkerDefinition(this.agentPath);
284
+ const runtime = await loadBrowserWorkerRuntime(this.agentPath);
246
285
  if (signal.aborted)
247
286
  throw new Error('browser-worker execution aborted');
248
- const run = (this.runner ?? defaultChatGptAgentRunner(this.harness))(definition, request, route, signal);
287
+ const run = (this.runner ?? directBrowserWorkerRunner(runtime.root))(runtime.definition, request, route, signal);
249
288
  const output = await new Promise((resolve, reject) => {
250
289
  const abort = () => { run.cancel(); reject(new Error('browser-worker execution aborted')); };
251
290
  if (signal.aborted) {
@@ -312,7 +351,17 @@ export class ChatGptAdapter {
312
351
  operation, thread_id: expectedThreadId, project: this.project(task, route), prompt: hasPrompt ? task.input : undefined,
313
352
  thinking_level: thinkingLevel(route.reasoning), state: null,
314
353
  };
315
- const response = await this.run(request, route, controller.signal);
354
+ let response;
355
+ try {
356
+ response = await this.run(request, route, controller.signal);
357
+ }
358
+ catch (error) {
359
+ if (controller.signal.aborted)
360
+ throw error;
361
+ if (operation === 'create')
362
+ throw new LaunchFailure(error instanceof Error ? error.message : String(error));
363
+ throw error;
364
+ }
316
365
  const threadId = this.exposeThread(execution, expectedThreadId, response);
317
366
  this.routes.set(threadId, route);
318
367
  this.validateOperation(response, operation);
package/dist/cli.js CHANGED
@@ -15,7 +15,7 @@ import { codexAndZaiUsageRegistry } from './usage.js';
15
15
  import { discoverAgents } from './registry.js';
16
16
  import { AgentObjectivePlanner, CommandObjectivePlanner } from './planner.js';
17
17
  import { extendRetryBudget } from './scheduler.js';
18
- const usage = 'agents-relay <job|init|submit|record|agent-register|agent-discover|status|reconcile|retry|cancel|serve> [options]';
18
+ const usage = 'agents-relay <job|task|init|submit|record|agent-register|agent-discover|status|reconcile|retry|cancel|serve> [options]';
19
19
  const help = {
20
20
  root: `Agents Relay coordinates durable asynchronous agent jobs through GitHub pull requests.
21
21
 
@@ -24,6 +24,7 @@ Usage:
24
24
 
25
25
  Commands:
26
26
  job create|adopt|repair Create or repair a durable GitHub-backed job
27
+ task update Update mutable durable task fields in place
27
28
  init Create a local demo job or initialize a GitHub job
28
29
  submit Queue a task for a managed job
29
30
  record Backfill a completed task into durable history
@@ -76,6 +77,21 @@ Required: --repo OWNER/REPO, --pr NUMBER, and --id JOB_ID.
76
77
 
77
78
  Example:
78
79
  npx agents-relay job repair --repo OWNER/REPO --pr 12 --id job-12`,
80
+ task: `Manage a durable task.
81
+
82
+ Usage:
83
+ npx agents-relay task update [options]
84
+
85
+ Actions:
86
+ update Update mutable task fields without changing task identity/history`,
87
+ 'task update': `Update mutable durable task fields in place.
88
+
89
+ Required: --task-id ID and the normal job storage options.
90
+ Mutable while not RUNNING/SUCCEEDED/CANCELLED: --adapter, --priority, --input, --project, --agent, --parent, --deps, --capabilities, --timeout, --max-attempts, --provider, --model, --profile, --reasoning, --cwd, --chatgpt-project.
91
+ Use --clear-routing to remove routing metadata. A model update requires --model; provider defaults to the existing provider or openai.
92
+
93
+ Example:
94
+ npx agents-relay task update --repo OWNER/REPO --pr 12 --id job-1 --task-id build --adapter chatgpt --provider chatgpt --model gpt-5.6-sol --reasoning high`,
79
95
  submit: `Queue a task for a managed job.
80
96
 
81
97
  Required: --repo OWNER/REPO, --pr NUMBER, --id JOB_ID, and --input TEXT.
@@ -145,6 +161,8 @@ Example:
145
161
  export function helpText(command, action) {
146
162
  if (command === 'job')
147
163
  return action ? help[`job ${action}`] ?? help.job : help.job;
164
+ if (command === 'task')
165
+ return action ? help[`task ${action}`] ?? help.task : help.task;
148
166
  return command ? help[command] ?? help.root : help.root;
149
167
  }
150
168
  export const SERVICE_DEFAULTS = { port: 8787, watchdogMs: 300000, webhookWatchdogMs: 1800000, dashboardRefreshMs: 300000 };
@@ -172,6 +190,10 @@ class ServiceEventBus {
172
190
  }
173
191
  }
174
192
  function arg(args, name, fallback = '') { const index = args.indexOf(name); return index >= 0 ? args[index + 1] ?? fallback : fallback; }
193
+ function hasArg(args, name) { return args.includes(name); }
194
+ function integerArg(args, name, fallback) { if (!hasArg(args, name))
195
+ return fallback; const value = Number(arg(args, name)); if (!Number.isInteger(value) || value < 0)
196
+ throw new Error(`${name} must be a non-negative integer`); return value; }
175
197
  function repository(args) { const value = arg(args, '--repo'); if (!value)
176
198
  throw new Error('--repo is required for GitHub-backed operation'); return value; }
177
199
  function pullRequest(args) { const value = Number(arg(args, '--pr')); if (!Number.isInteger(value) || value <= 0)
@@ -233,6 +255,66 @@ export async function ensureManagedGitHubJob(client, repositoryName, trustedAuth
233
255
  await store.saveJob(job);
234
256
  return { job: await store.load(options.id), pr };
235
257
  }
258
+ async function runTaskCommand(action, args) {
259
+ if (action !== 'update')
260
+ throw new Error(usage);
261
+ const { store, job, localFile } = await storeFor(args);
262
+ const id = arg(args, '--task-id');
263
+ if (!id)
264
+ throw new Error('task update requires --task-id');
265
+ const task = job.tasks.find(item => item.id === id);
266
+ if (!task)
267
+ throw new Error(`Task ${id} not found`);
268
+ if (['RUNNING', 'SUCCEEDED', 'CANCELLED'].includes(task.state))
269
+ throw new Error(`Task ${id} is ${task.state}; durable execution fields are immutable in this state`);
270
+ if (hasArg(args, '--adapter')) {
271
+ const adapter = arg(args, '--adapter');
272
+ if (!['shell', 'codex', 'chatgpt', 'orchestrator'].includes(adapter))
273
+ throw new Error('--adapter must be shell, codex, chatgpt, or orchestrator');
274
+ task.adapter = adapter;
275
+ }
276
+ if (hasArg(args, '--priority'))
277
+ task.priority = priority(args, task.priority ?? job.priority ?? 'P2');
278
+ if (hasArg(args, '--input'))
279
+ task.input = arg(args, '--input');
280
+ if (hasArg(args, '--project'))
281
+ task.projectName = arg(args, '--project') || undefined;
282
+ if (hasArg(args, '--agent'))
283
+ task.agentName = arg(args, '--agent') || undefined;
284
+ if (hasArg(args, '--parent'))
285
+ task.parentTaskId = arg(args, '--parent') || null;
286
+ if (hasArg(args, '--deps'))
287
+ task.dependencies = arg(args, '--deps').split(',').filter(Boolean);
288
+ if (hasArg(args, '--capabilities'))
289
+ task.capabilities = arg(args, '--capabilities').split(',').filter(Boolean);
290
+ task.timeoutMs = integerArg(args, '--timeout', task.timeoutMs);
291
+ task.maxAttempts = integerArg(args, '--max-attempts', task.maxAttempts);
292
+ if (hasArg(args, '--max-attempts') && task.maxAttempts < task.attempt)
293
+ throw new Error('--max-attempts cannot be less than the current attempt count');
294
+ const routingFlags = ['--provider', '--model', '--profile', '--reasoning', '--cwd', '--chatgpt-project'];
295
+ if (args.includes('--clear-routing'))
296
+ task.routing = undefined;
297
+ else if (routingFlags.some(flag => hasArg(args, flag))) {
298
+ const current = task.routing;
299
+ const model = hasArg(args, '--model') ? arg(args, '--model') : current?.model;
300
+ if (!model)
301
+ throw new Error('task routing update requires --model when no existing routing is present');
302
+ task.routing = {
303
+ provider: hasArg(args, '--provider') ? arg(args, '--provider') : current?.provider ?? 'openai',
304
+ model,
305
+ profile: hasArg(args, '--profile') ? arg(args, '--profile') || undefined : current?.profile,
306
+ reasoning: hasArg(args, '--reasoning') ? arg(args, '--reasoning') || undefined : current?.reasoning,
307
+ cwd: hasArg(args, '--cwd') ? arg(args, '--cwd') || undefined : current?.cwd,
308
+ projectId: hasArg(args, '--chatgpt-project') ? arg(args, '--chatgpt-project') || undefined : current?.projectId,
309
+ decidedBy: 'cli:update',
310
+ decidedAt: new Date().toISOString()
311
+ };
312
+ }
313
+ task.updatedAt = new Date().toISOString();
314
+ await store.saveTask(task);
315
+ await saveLocal(localFile, job);
316
+ console.log(JSON.stringify(task, null, 2));
317
+ }
236
318
  async function runJobCommand(action, args) {
237
319
  if (!['create', 'adopt', 'repair'].includes(action))
238
320
  throw new Error(usage);
@@ -342,7 +424,7 @@ async function main() {
342
424
  return;
343
425
  }
344
426
  if (args.includes('--help') || args.includes('-h')) {
345
- console.log(helpText(command, command === 'job' ? args[0] : undefined));
427
+ console.log(helpText(command, command === 'job' || command === 'task' ? args[0] : undefined));
346
428
  return;
347
429
  }
348
430
  if (command === 'job') {
@@ -350,6 +432,11 @@ async function main() {
350
432
  await runJobCommand(action ?? '', jobArgs);
351
433
  return;
352
434
  }
435
+ if (command === 'task') {
436
+ const [action, ...taskArgs] = args;
437
+ await runTaskCommand(action ?? '', taskArgs);
438
+ return;
439
+ }
353
440
  if (command === 'init') {
354
441
  const now = new Date().toISOString();
355
442
  const job = { id: arg(args, '--id', randomUUID()), title: arg(args, '--title', 'Agents Relay job'), objective: arg(args, '--objective') || undefined, priority: priority(args), executionMode: executionMode(args), prNumber: Number(arg(args, '--pr', '0')), repository: arg(args, '--repo'), state: 'OPEN', continuation: continuation(args), createdAt: now, updatedAt: now, tasks: [] };
@@ -1,5 +1,6 @@
1
1
  import { randomUUID } from 'node:crypto';
2
2
  import { schedule, transitionTask } from './scheduler.js';
3
+ import { LaunchFailure } from './adapters.js';
3
4
  import { eventFor } from './events.js';
4
5
  import { parsePlannerResult, plannerInput } from './planner.js';
5
6
  const PRIORITY_RANK = { P0: 0, P1: 1, P2: 2, P3: 3 };
@@ -206,7 +207,7 @@ export class Reconciler {
206
207
  execution = adapter.launch(workerTask, controller.signal);
207
208
  }
208
209
  catch (error) {
209
- await this.finish(job.id, task.id, task.executionId, null, error instanceof Error ? error.message : String(error));
210
+ await this.finish(job.id, task.id, task.executionId, null, error instanceof Error ? error.message : String(error), { retryable: false });
210
211
  return;
211
212
  }
212
213
  // A worker may reject before durable execution metadata finishes saving. Attach a guard immediately so Node never treats that race as an unhandled rejection; the durable completion handler below still records the outcome.
@@ -222,7 +223,7 @@ export class Reconciler {
222
223
  persistThread(execution.threadId);
223
224
  const timer = setTimeout(() => { controller.abort(); execution.cancel(); }, task.timeoutMs);
224
225
  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');
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');
226
227
  }
227
228
  async launchPlanner(job, task) {
228
229
  const planner = this.options.planner;
@@ -319,7 +320,7 @@ export class Reconciler {
319
320
  await this.store.saveTask(task);
320
321
  await this.emit(eventFor(jobId, taskId, task.parentTaskId, 'thread.started', 'running', `Worker thread ${threadId} started`, 'orchestrator', { threadId }));
321
322
  } }
322
- async finish(jobId, taskId, executionId, result, error) {
323
+ async finish(jobId, taskId, executionId, result, error, options = {}) {
323
324
  const live = this.live.get(taskId);
324
325
  if (live?.execution.id === executionId) {
325
326
  clearTimeout(live.timer);
@@ -344,10 +345,11 @@ export class Reconciler {
344
345
  await this.deliverContinuation(job, task);
345
346
  }
346
347
  else {
348
+ const retryable = options.retryable !== false && task.attempt < task.maxAttempts;
347
349
  task.error = error;
348
350
  task.leaseOwner = null;
349
351
  task.leaseExpiresAt = null;
350
- transitionTask(task, task.attempt < task.maxAttempts ? 'READY' : 'FAILED');
352
+ transitionTask(task, retryable ? 'READY' : 'FAILED');
351
353
  await this.store.saveTask(task);
352
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'));
353
355
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "1.0.3",
3
+ "version": "1.0.5",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {
@@ -62,6 +62,8 @@ 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
+ Use `npx agents-relay task update` to change safe durable fields of a `QUEUED`, `READY`, `WAITING`, `BLOCKED`, or `FAILED` task without changing its task ID or prior attempt/error history. This is the preferred way to reroute future execution to a different adapter/provider/model. The CLI rejects `RUNNING`, `SUCCEEDED`, and `CANCELLED` task updates so active execution semantics and terminal audit history are not rewritten.
66
+
65
67
  For a ChatGPT worker, use the chatgpt adapter with capabilities model,chatgpt,browser-harness. The runtime launches the installed `chatgpt-browser-worker` agent, stores its observed `thread_id` as the durable task threadId, and reuses that ID on retry. Pass --chatgpt-project when the worker must run inside one exact ChatGPT Project. Set `AGENTS_RELAY_CHATGPT_BROWSER_WORKER_AGENT` to an explicit agent definition path when needed; otherwise standard skill roots are searched. Never place credentials or private prompts in job/task markers.
66
68
 
67
69
  Managed Codex and ChatGPT worker prompts are enriched at launch with the PR URL and durable job/task/parent/project identity; do not duplicate that context manually in task input. Keep one ChatGPT conversation per logical task. Retries reuse the same task conversation, but sibling tasks use separate conversations even when their adapter is the same.
@@ -0,0 +1,102 @@
1
+ ---
2
+ name: chatgpt-browser-worker
3
+ description: Manage ChatGPT work threads through the authenticated browser-harness session, including Project selection, thinking-level requests, durable thread identity, resume/status/result retrieval, and cleanup.
4
+ ---
5
+
6
+ # ChatGPT browser worker
7
+
8
+ This skill is the browser-backed ChatGPT worker contract for Neo. It uses the
9
+ existing `browser-harness` command as its only browser interaction layer. It
10
+ does not use the MacBridge ChatGPT runtime, ChatGPT APIs, copied cookies, or a
11
+ second browser automation stack.
12
+
13
+ ## Capability contract
14
+
15
+ The worker boundary exposes these lifecycle operations:
16
+
17
+ ```text
18
+ create(project, prompt, thinking_level) -> ThreadState
19
+ resume(thread_id, project) -> ThreadState
20
+ status(thread_id) -> StatusObservation
21
+ result(thread_id) -> ThreadResult
22
+ delete(thread_id) -> DeletedThreadState
23
+ ```
24
+
25
+ `project.name` is required when creating or resuming a thread. `project.id` is
26
+ optional because the UI may not expose it at every boundary. The adapter must
27
+ select and verify that Project before sending the first prompt. A resumed
28
+ thread keeps its recorded Project identity; callers cannot silently move it to
29
+ another Project.
30
+
31
+ Thinking levels are split into `requested_thinking_level` and
32
+ `effective_thinking_level`. Requested values are `default`, `low`, `medium`,
33
+ and `high`; effective values may additionally be `unknown`. `default` leaves
34
+ the account's current/default setting unchanged. The adapter must preserve
35
+ `unknown` rather than guessing from a label or silently downgrading a request.
36
+
37
+ ## Durable identity and state
38
+
39
+ Persist the returned state after every successful lifecycle transition. The
40
+ durable key is the ChatGPT `thread_id`; a URL, title, or browser tab index is
41
+ not an identity. Records retain a deleted thread as a tombstone so a stale
42
+ caller cannot recreate or accidentally reuse it. The complete JSON schema and
43
+ valid transition table are in [references/contract.md](references/contract.md).
44
+
45
+ Pure validation and transition helpers live in `scripts/contract.py`, with
46
+ focused tests in `tests/test_contract.py`. The browser-harness driver is
47
+ intentionally injected through the semantic port described in the reference;
48
+ the contract layer does not import or launch `browser-harness`.
49
+ Run `python3 -m pytest skills/chatgpt-browser-worker/tests` from the repository
50
+ root.
51
+
52
+ For the Neo/Leo and Agents Relay handoff—task IDs, run-local state, event
53
+ ownership, and the create/resume/result/delete invocation sequence—see
54
+ [references/orchestration.md](references/orchestration.md). The outer
55
+ orchestrator may use MacBridge as a transport, but this worker never treats
56
+ MacBridge as the ChatGPT runtime.
57
+
58
+ The runtime entry point is the
59
+ [browser-worker agent](agents/browser-worker.agent.md). Callers launch that
60
+ agent with a typed lifecycle intent; they must not directly call
61
+ `create_bh.py` or `operate_bh.py`. Those scripts are helper implementations
62
+ that the agent may use, repair, or bypass while preserving this skill's
63
+ browser-harness and verification contract.
64
+
65
+ The browser-worker agent's `create` intent starts a new ChatGPT chat when the
66
+ attached tab is already a conversation and emits JSON with the observed
67
+ `thread_id`, conversation URL, selected Project, and thinking observation.
68
+ `scripts/create_bh.py` is only a helper for that agent, not a caller-facing
69
+ runtime contract.
70
+
71
+ For implementation/debugging, the agent may use the helper equivalent for an
72
+ existing thread with the durable identity from persisted state:
73
+
74
+ ```text
75
+ python3 scripts/operate_bh.py resume --thread-id ID --project NAME
76
+ python3 scripts/operate_bh.py continue --thread-id ID --project NAME --prompt TEXT
77
+ python3 scripts/operate_bh.py status --thread-id ID --project NAME
78
+ python3 scripts/operate_bh.py result --thread-id ID --project NAME
79
+ python3 scripts/operate_bh.py delete --thread-id ID --project NAME
80
+ ```
81
+
82
+ Each command performs one serial browser-harness operation. The injected
83
+ semantic adapter in `scripts/operations.py` is the testable boundary; it does
84
+ not import MacBridge or a ChatGPT runtime API.
85
+
86
+ ## Verification boundary
87
+
88
+ The worker may report `completed` only from an observed assistant message and
89
+ normalized result. A process exit, click, URL change, or tab title alone is not
90
+ proof that a thread was created, resumed, completed, or deleted. Authentication
91
+ walls, MFA, consent, ambiguous account/project selection, and unverified
92
+ thinking levels are `blocked` or `failed` conditions and must not be
93
+ self-healed.
94
+
95
+ Delete targets the exact durable conversation URL, requires an observed action
96
+ and confirmation, and verifies that the requested thread is no longer visible.
97
+ Repeated cleanup is idempotent (`not_found` is accepted); archive/undo UI
98
+ controls are evidence only and never revive a deleted tombstone.
99
+
100
+ Keep credentials, prompts containing private data, and browser runtime state
101
+ out of durable state and events. Runtime artifacts belong under the caller's
102
+ ignored `runs/` directory.
@@ -0,0 +1,113 @@
1
+ ---
2
+ name: browser-worker
3
+ description: Execute the ChatGPT browser-worker lifecycle through browser-harness while preserving verified thread and Project identity.
4
+ type: worker
5
+ ---
6
+
7
+ # Browser Worker Agent
8
+
9
+ You are the runtime owner for the `chatgpt-browser-worker` skill. Before doing
10
+ any work, load and follow both this skill and the `browser-harness` skill. The
11
+ outer adapter launches you with one typed lifecycle request; Agents Relay
12
+ owns task correlation, retries, events, and run-local persistence, but does
13
+ not own browser execution.
14
+
15
+ ## Runtime rules
16
+
17
+ - Use `browser-harness` for every browser interaction. Do not use another
18
+ browser stack, ChatGPT API, MacBridge ChatGPT runtime, copied cookies, or
19
+ direct HTTP calls to ChatGPT.
20
+ - Preserve the exact observed ChatGPT `thread_id` and Project identity. A URL,
21
+ title, tab index, or inferred conversation is not an identity. A resumed
22
+ thread must remain in its recorded Project; reject a mismatch.
23
+ - Treat the scripts in `scripts/` as helpers only. You may use, repair, or
24
+ bypass `create_bh.py` and `operate_bh.py` when they are brittle, but preserve
25
+ the skill contract and its evidence requirements.
26
+ - When a selector, layout, label, or ordinary UI flow changes, re-observe the
27
+ current page through `browser-harness`, identify the semantic control, and
28
+ adapt the operation. Do not guess from stale selectors or coordinates.
29
+ - Stop with `blocked` for authentication, MFA, consent, ambiguous account or
30
+ Project selection, or any human decision. Do not bypass these gates.
31
+ - Stop with `blocked` or `failed` when the result cannot be verified. A process
32
+ exit, URL change, tab title, prior assistant bubble, or synthetic message ID
33
+ is not completion evidence.
34
+ - Return only observations from the current browser state and persist the last
35
+ known `thread_id` when an operation fails after creation.
36
+
37
+ ## Typed adapter contract
38
+
39
+ The input is one JSON object. `operation` must be one of `create`, `resume`,
40
+ `continue`, `status`, `result`, or `delete`.
41
+
42
+ ```json
43
+ {
44
+ "operation": "create",
45
+ "thread_id": null,
46
+ "project": {"name": "neo", "id": "project-optional"},
47
+ "prompt": "Run the assigned task.",
48
+ "thinking_level": "high",
49
+ "state": null
50
+ }
51
+ ```
52
+
53
+ Input requirements:
54
+
55
+ - `create`: requires `project.name`, `prompt`, and `thinking_level`.
56
+ - `resume`: requires `thread_id` and `project.name`; verify the recorded
57
+ Project before any other thread action.
58
+ - `continue`: requires `thread_id`, `project.name`, and `prompt`; open and
59
+ verify the exact thread before sending the follow-up.
60
+ - `status`, `result`, and `delete`: require the durable `thread_id`; use the
61
+ persisted `state` when supplied and never recreate a missing identity.
62
+ - `thinking_level` is `default`, `low`, `medium`, or `high`; preserve an
63
+ observed effective value of `unknown` instead of guessing.
64
+
65
+ The output is one JSON object suitable for an outer adapter:
66
+
67
+ ```json
68
+ {
69
+ "operation": "result",
70
+ "status": "completed",
71
+ "thread_id": "chatgpt-conversation-id",
72
+ "project": {"name": "neo", "id": "project-id"},
73
+ "result": {
74
+ "message_id": "observed-assistant-message-id",
75
+ "text": "The normalized assistant answer.",
76
+ "verified": true,
77
+ "observed_at": "2026-09-19T12:02:00Z"
78
+ },
79
+ "state": {
80
+ "schema_version": 1,
81
+ "thread_id": "chatgpt-conversation-id",
82
+ "status": "completed",
83
+ "project": {"name": "neo", "id": "project-id"}
84
+ },
85
+ "error": null
86
+ }
87
+ ```
88
+
89
+ Every response must include `operation`, `status`, and the exact
90
+ `thread_id` when one is known. A successful `result` response must include an
91
+ observed assistant `message_id`, non-empty normalized `text`, and
92
+ `result.verified: true`. `status` is not a result and must not claim
93
+ completion. For `delete`, return a verified `deleted` or idempotent verified
94
+ `not_found` observation and retain a terminal `deleted` tombstone.
95
+
96
+ For blocked or failed operations, return the last known identity and a typed
97
+ error without credentials or private prompts:
98
+
99
+ ```json
100
+ {
101
+ "operation": "resume",
102
+ "status": "blocked",
103
+ "thread_id": "chatgpt-conversation-id",
104
+ "error": {
105
+ "code": "authentication_required",
106
+ "message": "Sign-in or MFA requires user action.",
107
+ "retryable": false
108
+ }
109
+ }
110
+ ```
111
+
112
+ Do not report success until the relevant browser observation has been
113
+ validated against [../references/contract.md](../references/contract.md).