agents-relay 1.0.18 → 1.0.20

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
@@ -99,9 +99,9 @@ Managed tasks are descriptive work for agents, not shell commands. Use `codex` o
99
99
 
100
100
  Every model-backed task declares a durable output with `--output task-pr` or `--output file --output-path PATH`. Agents Relay enriches the prompt with the PR URL, job/task identity, output contract, and mandatory terminal-event contract.
101
101
 
102
- For Codex and ChatGPT, **events are authoritative task state**. Event publication uses the canonical Neo `events-bus` surface (`events__publish` for sandboxed/hosted workers, or `NEO_EVENTS_EMIT` for direct local workers); Agents Relay only subscribes and reconciles. Relay publishes `task.process.launched` only after successful adapter launch; `task.started` is worker-owned and must be a new, correlated start acknowledgement. Codex publishes `task.process.exited` when its synchronous runtime exits, while ChatGPT publishes `task.process.async_exited` after its submission runtime exits; neither substitutes for `task.completed`, `task.failed`, or `task.blocked`. The ChatGPT worker-owned tab remains open until the correlated worker start acknowledgement arrives, or is closed and failed with `worker_start_timeout` after 60 seconds. The executing agent must publish exactly one correlated terminal event. Relay persists the matching durable state only from that terminal event. Model-backed tasks require an event bus and time out explicitly if no terminal event arrives.
102
+ For Codex and ChatGPT, **events are authoritative task state**. Event publication uses the canonical Neo `events-bus` surface (`events__publish` for sandboxed/hosted workers, or `NEO_EVENTS_EMIT` for direct local workers); Agents Relay only subscribes and reconciles. Relay publishes `task.process.launched` only after successful adapter launch; `task.started` is worker-owned and must be a new, correlated start acknowledgement. Codex publishes `task.process.exited` when its synchronous runtime exits, while ChatGPT publishes `task.process.async_exited` after its submission runtime exits; neither substitutes for `task.completed`, `task.failed`, `task.blocked`, or `task.cancelled`. The ChatGPT worker-owned tab follows the configured close policy: `after-start` waits for the correlated start acknowledgement, `never` keeps it open for debug, and `after-terminal` waits for a new exact-task terminal event; start-ack failure closes only the owned tab after 60 seconds. The executing agent must publish exactly one correlated terminal event. Relay persists the matching durable state only from that terminal event. Model-backed tasks require an event bus and time out explicitly if no terminal event arrives.
103
103
 
104
- The ChatGPT adapter uses the packaged `chatgpt-browser-worker` as a one-shot submitter. It opens a fresh Temporary Chat tab, uses the account defaults, submits the prompt, verifies acceptance, closes the owned tab, and returns. It does not poll for the assistant response, resume/reopen a thread, or use conversation text as the result channel. Any observed ChatGPT thread ID is diagnostic only.
104
+ The ChatGPT adapter uses the packaged `chatgpt-browser-worker` as a one-shot submitter. It opens a fresh Temporary Chat tab, uses the account defaults, submits the prompt, verifies acceptance, and applies `--chatgpt-tab-close-policy` (or `AGENTS_RELAY_CHATGPT_TAB_CLOSE_POLICY`): `after-start` by default, `never` for test/debug, or `after-terminal` after a new exact-task terminal event. It never closes a user tab and does not poll for the assistant response, resume/reopen a thread, or use conversation text as the result channel. Any observed ChatGPT thread ID is diagnostic only.
105
105
 
106
106
  ~~~sh
107
107
  npx agents-relay submit --repo OWNER/REPO --pr 5 --id job-1 \
package/dist/adapters.js CHANGED
@@ -98,6 +98,12 @@ export class CodexAdapter {
98
98
  return execution;
99
99
  }
100
100
  }
101
+ export function chatGptTabClosePolicy(value, fallback = 'after-start') {
102
+ const policy = value || fallback;
103
+ if (!['after-start', 'never', 'after-terminal'].includes(policy))
104
+ throw new Error(`Invalid ChatGPT tab close policy: ${policy}`);
105
+ return policy;
106
+ }
101
107
  export class LaunchFailure extends Error {
102
108
  }
103
109
  export class StartAckTimeout extends LaunchFailure {
@@ -148,7 +154,7 @@ function parseDriverJson(output) {
148
154
  function directBrowserWorkerRunner(root) {
149
155
  return (_definition, request, _route, signal) => {
150
156
  const releaseFile = join(process.env.TMPDIR ?? '/tmp', `agents-relay-chatgpt-release-${randomUUID()}`);
151
- const args = [join(root, 'scripts', 'temporary_bh.py'), '--prompt', request.prompt, '--release-file', releaseFile];
157
+ const args = [join(root, 'scripts', 'temporary_bh.py'), '--prompt', request.prompt, '--release-file', releaseFile, '--close-policy', request.tabClosePolicy ?? 'after-start'];
152
158
  for (const file of request.files ?? [])
153
159
  args.push('--file', file);
154
160
  const child = spawn('python3', args, { shell: false, stdio: ['ignore', 'pipe', 'pipe'] });
@@ -228,9 +234,11 @@ export class ChatGptAdapter {
228
234
  capabilities = ['model', 'chatgpt', 'browser-harness'];
229
235
  agentPath;
230
236
  runner;
237
+ tabClosePolicy;
231
238
  constructor(options = {}) {
232
239
  this.agentPath = options.agentPath ?? process.env.AGENTS_RELAY_CHATGPT_BROWSER_WORKER_AGENT;
233
240
  this.runner = options.agentRunner;
241
+ this.tabClosePolicy = chatGptTabClosePolicy(options.tabClosePolicy ?? process.env.AGENTS_RELAY_CHATGPT_TAB_CLOSE_POLICY);
234
242
  }
235
243
  launch(task, signal, context = {}) {
236
244
  if (!task.routing)
@@ -255,14 +263,21 @@ export class ChatGptAdapter {
255
263
  const runtime = await loadBrowserWorkerRuntime(this.agentPath);
256
264
  if (controller.signal.aborted)
257
265
  throw new Error('browser-worker execution aborted');
258
- const run = (this.runner ?? directBrowserWorkerRunner(runtime.root))(runtime.definition, { prompt: task.input }, route, controller.signal);
266
+ const policy = chatGptTabClosePolicy(task.chatgptTabClosePolicy, this.tabClosePolicy);
267
+ const run = (this.runner ?? directBrowserWorkerRunner(runtime.root))(runtime.definition, { prompt: task.input, tabClosePolicy: policy }, route, controller.signal);
259
268
  try {
260
269
  const output = await (run.submitted ?? run.promise);
261
270
  const response = parseBrowserWorkerResponse(output);
262
271
  launchResolve();
263
- if (context.waitForWorkerStart)
272
+ if (policy === 'after-start' && context.waitForWorkerStart)
264
273
  await context.waitForWorkerStart;
265
- run.release?.();
274
+ else if (policy === 'after-terminal') {
275
+ if (!context.waitForTerminal)
276
+ throw new Error('ChatGPT after-terminal tab close policy requires a terminal event barrier');
277
+ await context.waitForTerminal;
278
+ }
279
+ if (policy !== 'never')
280
+ run.release?.();
266
281
  if (run.submitted)
267
282
  await run.promise;
268
283
  return {
@@ -276,7 +291,8 @@ export class ChatGptAdapter {
276
291
  }
277
292
  catch (error) {
278
293
  launchReject(error);
279
- run.release?.();
294
+ if (policy !== 'never')
295
+ run.release?.();
280
296
  if (controller.signal.aborted)
281
297
  throw error;
282
298
  if (error instanceof StartAckTimeout)
package/dist/cli.js CHANGED
@@ -90,7 +90,7 @@ Actions:
90
90
  'task update': `Update mutable durable task fields in place.
91
91
 
92
92
  Required: --task-id ID and the normal job storage options.
93
- Mutable while not RUNNING/SUCCEEDED/CANCELLED: --adapter, --priority, --input, --project, --agent, --parent, --deps, --capabilities, --timeout, --max-attempts, --provider, --model, --profile, --reasoning, --cwd, --chatgpt-project.
93
+ Mutable while not RUNNING/SUCCEEDED/CANCELLED: --adapter, --priority, --input, --project, --agent, --parent, --deps, --capabilities, --timeout, --max-attempts, --provider, --model, --profile, --reasoning, --cwd, --chatgpt-project, --chatgpt-tab-close-policy.
94
94
  Use --clear-routing to remove routing metadata. A model update requires --model; provider defaults to the existing provider or openai.
95
95
 
96
96
  Example:
@@ -226,6 +226,12 @@ function priority(args, fallback = 'P2') { const value = arg(args, '--priority',
226
226
  throw new Error('--priority must be P0, P1, P2, or P3'); return value; }
227
227
  export function executionMode(args, fallback = 'fixed') { const value = arg(args, '--mode', arg(args, '--execution-mode', fallback)); if (value !== 'fixed' && value !== 'autonomous')
228
228
  throw new Error('--mode must be fixed or autonomous'); return value; }
229
+ export function chatGptTabClosePolicy(args) {
230
+ const value = arg(args, '--chatgpt-tab-close-policy', process.env.AGENTS_RELAY_CHATGPT_TAB_CLOSE_POLICY || 'after-start');
231
+ if (!['after-start', 'never', 'after-terminal'].includes(value))
232
+ throw new Error('--chatgpt-tab-close-policy must be after-start, never, or after-terminal');
233
+ return value;
234
+ }
229
235
  function requestedExecutionMode(args) { const value = arg(args, '--mode', arg(args, '--execution-mode')); return value ? executionMode(args) : undefined; }
230
236
  function continuation(args) { const kind = arg(args, '--continue-kind'); const target = arg(args, '--continue-target'); return kind && target ? { kind: kind, target, input: arg(args, '--continue-input') || undefined } : null; }
231
237
  export async function ensureManagedGitHubJob(client, repositoryName, trustedAuthors, options) {
@@ -276,6 +282,7 @@ function workerTaskFromArgs(job, args, now, requireInput = false) {
276
282
  if (outputKind === 'file' && !outputPath)
277
283
  throw new Error('--output file requires --output-path');
278
284
  const routing = { provider: arg(args, '--provider', 'openai'), model, profile: arg(args, '--profile') || undefined, reasoning: arg(args, '--reasoning') || undefined, cwd: arg(args, '--cwd') || undefined, projectId: arg(args, '--chatgpt-project') || undefined, decidedBy: 'cli', decidedAt: now };
285
+ const closePolicy = hasArg(args, '--chatgpt-tab-close-policy') ? chatGptTabClosePolicy(args) : undefined;
279
286
  return {
280
287
  jobId: job.id,
281
288
  id: arg(args, '--task-id', 'initial'),
@@ -286,6 +293,7 @@ function workerTaskFromArgs(job, args, now, requireInput = false) {
286
293
  dependencies: arg(args, '--deps').split(',').filter(Boolean),
287
294
  capabilities: arg(args, '--capabilities').split(',').filter(Boolean),
288
295
  adapter,
296
+ chatgptTabClosePolicy: closePolicy,
289
297
  input: input || 'true',
290
298
  output: outputKind === 'file' ? { kind: 'file', path: outputPath } : { kind: 'task_pr' },
291
299
  routing,
@@ -337,6 +345,8 @@ async function runTaskCommand(action, args) {
337
345
  task.dependencies = arg(args, '--deps').split(',').filter(Boolean);
338
346
  if (hasArg(args, '--capabilities'))
339
347
  task.capabilities = arg(args, '--capabilities').split(',').filter(Boolean);
348
+ if (hasArg(args, '--chatgpt-tab-close-policy'))
349
+ task.chatgptTabClosePolicy = chatGptTabClosePolicy(args);
340
350
  task.timeoutMs = integerArg(args, '--timeout', task.timeoutMs);
341
351
  task.maxAttempts = integerArg(args, '--max-attempts', task.maxAttempts);
342
352
  if (hasArg(args, '--max-attempts') && task.maxAttempts < task.attempt)
@@ -414,7 +424,7 @@ async function runJobCommand(action, args) {
414
424
  console.log(JSON.stringify({ repository: repo, pr: result.pr.number, job: result.job.id, executionMode: result.job.executionMode, state: result.job.state, task: start ? arg(args, '--task-id', 'initial') : undefined }, null, 2));
415
425
  }
416
426
  export function runtimePlanner(args) { const plannerCommand = arg([...args], '--planner-command'); const modelRuntime = arg([...args], '--codex', 'codex'); return plannerCommand ? new CommandObjectivePlanner(plannerCommand) : new AgentObjectivePlanner(modelRuntime); }
417
- 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 CodexAdapter(modelRuntime), new ChatGptAdapter()], continuations: [new CodexThreadContinuation(modelRuntime), new CommandContinuation(), new WebhookContinuation()], planner, eventBus: bus, emit }); }
427
+ export function runtime(store, args, bus) { const emit = (event) => { process.stderr.write(`${JSON.stringify(event)}\n`); }; const modelRuntime = arg(args, '--codex', 'codex'); const policy = chatGptTabClosePolicy(args); 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 CodexAdapter(modelRuntime), new ChatGptAdapter({ tabClosePolicy: policy })], continuations: [new CodexThreadContinuation(modelRuntime), new CommandContinuation(), new WebhookContinuation()], planner, eventBus: bus, emit, chatgptTabClosePolicy: policy }); }
418
428
  export async function createService(args) {
419
429
  const loaded = await storeFor(args);
420
430
  const upstream = eventBus(args);
package/dist/dashboard.js CHANGED
@@ -354,6 +354,12 @@ export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobI
354
354
  throw new Error(`Managed job ${requestedJobId ?? ''} not found`);
355
355
  return { id: jobId, json: JSON.stringify(await store.load(jobId)) };
356
356
  };
357
+ const broadcastSnapshots = async (jobIds = []) => {
358
+ const snapshots = await Promise.all((jobIds.length > 0 ? jobIds : [undefined]).map(requestedJobId => snapshot(requestedJobId)));
359
+ for (const value of snapshots)
360
+ for (const client of clients)
361
+ client.write(`data: ${value.json}\n\n`);
362
+ };
357
363
  const usage = async () => {
358
364
  if (!usageRegistry)
359
365
  throw new Error('Account usage telemetry is unavailable');
@@ -394,7 +400,13 @@ export function serveDashboard(store, port = 8787, bus, refreshMs = 300000, jobI
394
400
  }
395
401
  res.writeHead(202);
396
402
  res.end('Accepted');
397
- queueMicrotask(() => { void webhook.wake(event); });
403
+ queueMicrotask(() => {
404
+ void (webhook.refresh ? webhook.refresh(event) : Promise.resolve([]))
405
+ .then(jobIds => broadcastSnapshots(jobIds))
406
+ .catch(error => process.stderr.write(`warning: webhook dashboard snapshot unavailable: ${error instanceof Error ? error.message : String(error)}\n`))
407
+ .then(() => webhook.wake(event))
408
+ .catch(error => process.stderr.write(`warning: webhook wake unavailable: ${error instanceof Error ? error.message : String(error)}\n`));
409
+ });
398
410
  return;
399
411
  }
400
412
  if (new URL(req.url ?? '/', 'http://127.0.0.1').pathname === '/api/jobs') {
@@ -46,13 +46,14 @@ export class Reconciler {
46
46
  plannerLive = new Map();
47
47
  terminalTimers = new Map();
48
48
  continuationLive = new Set();
49
+ terminalWaiters = new Map();
49
50
  reconciling = false;
50
51
  constructor(store, options) {
51
52
  this.store = store;
52
53
  this.options = options;
53
54
  }
54
55
  async handleEvent(event) {
55
- if (['task.completed', 'task.failed', 'task.blocked'].includes(event.type)) {
56
+ if (['task.completed', 'task.failed', 'task.blocked', 'task.cancelled'].includes(event.type)) {
56
57
  await this.applyTerminalEvent(event);
57
58
  return;
58
59
  }
@@ -306,12 +307,20 @@ export class Reconciler {
306
307
  let execution;
307
308
  const workerTask = { ...task, input: workerInput };
308
309
  const startAckSetup = task.adapter === 'chatgpt' ? await this.startAck(job.id, task.id, Date.now()) : null;
309
- const launchContext = startAckSetup ? { waitForWorkerStart: startAckSetup.ack } : {};
310
+ const closePolicy = task.chatgptTabClosePolicy ?? this.options.chatgptTabClosePolicy ?? adapter.tabClosePolicy ?? 'after-start';
311
+ const terminalWait = task.adapter === 'chatgpt' && closePolicy === 'after-terminal'
312
+ ? this.terminalWait(task.id, durableExecutionId, startedAt - 1)
313
+ : null;
314
+ const launchContext = {
315
+ ...(startAckSetup ? { waitForWorkerStart: startAckSetup.ack } : {}),
316
+ ...(terminalWait ? { waitForTerminal: terminalWait.promise } : {}),
317
+ };
310
318
  try {
311
319
  execution = adapter.launch(workerTask, controller.signal, launchContext);
312
320
  }
313
321
  catch (error) {
314
322
  await startAckSetup?.stop();
323
+ terminalWait?.cancel();
315
324
  await this.failLaunch(job.id, task.id, task.executionId, error instanceof Error ? error.message : String(error));
316
325
  return;
317
326
  }
@@ -502,10 +511,15 @@ export class Reconciler {
502
511
  const task = job.tasks.find(item => item.id === event.task_id);
503
512
  if (!task || !['codex', 'chatgpt'].includes(task.adapter) || task.state !== 'RUNNING')
504
513
  return;
514
+ const terminalWaiter = this.terminalWaiters.get(task.id);
515
+ if (terminalWaiter && (!event.timestamp || new Date(event.timestamp).getTime() < terminalWaiter.launchBarrier))
516
+ return;
505
517
  this.clearExecution(task.id, task.executionId);
506
518
  task.leaseOwner = null;
507
519
  task.leaseExpiresAt = null;
508
520
  task.updatedAt = event.timestamp || new Date().toISOString();
521
+ this.terminalWaiters.get(task.id)?.resolve();
522
+ this.terminalWaiters.delete(task.id);
509
523
  if (event.type === 'task.completed') {
510
524
  task.result = { summary: event.message || `Task ${task.id} completed`, data: event.data };
511
525
  task.error = null;
@@ -523,8 +537,26 @@ export class Reconciler {
523
537
  transitionTask(task, 'BLOCKED');
524
538
  await this.store.saveTask(task);
525
539
  }
540
+ else if (event.type === 'task.cancelled') {
541
+ task.error = event.message || `Task ${task.id} cancelled`;
542
+ transitionTask(task, 'CANCELLED');
543
+ await this.store.saveTask(task);
544
+ }
526
545
  this.detach(this.reconcile(job.id), job.id, task.id, 'terminal-event reconciliation');
527
546
  }
547
+ terminalWait(taskId, executionId, launchBarrier) {
548
+ let resolve;
549
+ const promise = new Promise(resolver => { resolve = resolver; });
550
+ this.terminalWaiters.set(taskId, { executionId, launchBarrier, resolve });
551
+ return {
552
+ promise,
553
+ cancel: () => {
554
+ const current = this.terminalWaiters.get(taskId);
555
+ if (current?.executionId === executionId)
556
+ this.terminalWaiters.delete(taskId);
557
+ },
558
+ };
559
+ }
528
560
  async deliverContinuation(job, task) {
529
561
  const continuation = task.continuation ?? job.continuation;
530
562
  if (!continuation || task.continuationDeliveredAt || this.continuationLive.has(task.id))
package/dist/relayd.js CHANGED
@@ -121,16 +121,22 @@ export async function runDaemon(argv) {
121
121
  const webhook = secret ? {
122
122
  secret,
123
123
  repositories: new Set(repositories),
124
+ refresh: async (event) => {
125
+ if (!event.repository || !event.pullRequest)
126
+ return [];
127
+ const refreshed = await refreshPullRequest(event.repository, event.pullRequest);
128
+ return refreshed.map(item => item.job.id);
129
+ },
124
130
  wake: async (event) => {
125
131
  if (!event.repository || !event.pullRequest)
126
132
  return;
127
133
  try {
128
- const refreshed = await refreshPullRequest(event.repository, event.pullRequest);
134
+ const refreshed = cachedOverviews.filter(item => item.job.repository === event.repository && item.job.prNumber === event.pullRequest);
129
135
  for (const item of refreshed)
130
136
  await pool.webhookWake(bus, item.job, event.deliveryId, event.event, event.action);
131
137
  }
132
138
  catch (error) {
133
- process.stderr.write(`warning: webhook PR refresh unavailable: ${error instanceof Error ? error.message : String(error)}\n`);
139
+ process.stderr.write(`warning: webhook wake unavailable: ${error instanceof Error ? error.message : String(error)}\n`);
134
140
  }
135
141
  }
136
142
  } : undefined;
package/dist/store.js CHANGED
@@ -83,7 +83,7 @@ function immutableTaskDefinition(task) {
83
83
  return {
84
84
  jobId: task.jobId, id: task.id, priority: task.priority, projectName: task.projectName, agentName: task.agentName,
85
85
  kind: task.kind ?? 'work', parentTaskId: task.parentTaskId, dependencies: task.dependencies, capabilities: task.capabilities, adapter: task.adapter,
86
- input: task.input, output: task.output, routing, continuation: task.continuation ?? null, maxAttempts: task.maxAttempts, timeoutMs: task.timeoutMs
86
+ input: task.input, output: task.output, routing, chatgptTabClosePolicy: task.chatgptTabClosePolicy ?? 'after-start', continuation: task.continuation ?? null, maxAttempts: task.maxAttempts, timeoutMs: task.timeoutMs
87
87
  };
88
88
  }
89
89
  function sameTaskDefinition(left, right) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agents-relay",
3
- "version": "1.0.18",
3
+ "version": "1.0.20",
4
4
  "description": "Durable async agent jobs coordinated through GitHub pull requests",
5
5
  "type": "module",
6
6
  "bin": {
@@ -78,7 +78,7 @@ The job/task descriptions remain the durable source of intent. Adapters do not i
78
78
 
79
79
  ## ChatGPT worker
80
80
 
81
- The ChatGPT adapter uses the packaged `chatgpt-browser-worker` one-shot path. It opens a fresh Temporary Chat tab, submits the complete task with the account defaults, verifies acceptance, closes the owned tab, and returns a submission receipt.
81
+ The ChatGPT adapter uses the packaged `chatgpt-browser-worker` one-shot path. It opens a fresh Temporary Chat tab, submits the complete task with the account defaults, verifies acceptance, applies the configured `after-start` (default), `never`, or `after-terminal` owned-tab close policy, and returns a submission receipt.
82
82
 
83
83
  ChatGPT thread identity is diagnostic only. It is not persisted as a resumable task handle, and Relay does not poll, resume, retrieve a result from, or delete a ChatGPT conversation as part of task lifecycle.
84
84
 
@@ -48,12 +48,14 @@ Neo event contract. Events are execution observability, not a third output mode.
48
48
  ## Runtime behavior
49
49
 
50
50
  The worker is one-shot. It opens an isolated worker-owned ChatGPT tab, submits
51
- the complete task, verifies that ChatGPT accepted the submission, waits for the
52
- exact post-submission worker `task.started` acknowledgement, then closes its
53
- owned tab and returns. ChatGPT continues the task independently and publishes
54
- the result through the declared output contract. If the acknowledgement does
55
- not arrive within 60 seconds, close only the owned tab and fail with
56
- `worker_start_timeout`.
51
+ the complete task, verifies that ChatGPT accepted the submission, and applies
52
+ the configured tab-close policy. `after-start` waits for the exact
53
+ post-submission worker `task.started` acknowledgement, `never` leaves the owned
54
+ tab open for debugging, and `after-terminal` waits for a new exact-task
55
+ `task.completed`, `task.failed`, `task.blocked`, or `task.cancelled` event.
56
+ ChatGPT continues the task independently and publishes the result through the
57
+ declared output contract. If the start acknowledgement does not arrive within
58
+ 60 seconds, close only the owned tab and fail with `worker_start_timeout`.
57
59
 
58
60
  The worker must never interact through a pre-existing user ChatGPT tab.
59
61
  Browser details, transient conversation identity, submission verification,
@@ -28,11 +28,13 @@ If the output declaration is missing or ambiguous, do not invent one.
28
28
  settings. Do not change model or thinking settings.
29
29
  - Upload requested files, submit the complete task prompt, and verify that the
30
30
  submission became a new user turn.
31
- - Once submission is verified, keep the worker-owned tab open until Relay has
32
- observed a new worker-owned `task.started` event for the exact job/task, then
33
- close the tab and return. Do not wait for the assistant response and do not
34
- reopen or poll the conversation. If the start acknowledgement does not arrive
35
- within 60 seconds, close only the owned tab and report a start-ack timeout.
31
+ - Once submission is verified, follow the configured owned-tab close policy:
32
+ `after-start` closes after a new worker-owned `task.started` event, `never`
33
+ leaves the owned tab open for test/debug use, and `after-terminal` closes only
34
+ after a new exact-task `task.completed`, `task.failed`, `task.blocked`, or
35
+ `task.cancelled` event. Never close a user tab. Do not wait for the assistant
36
+ response or reopen/poll the conversation. If the start acknowledgement does
37
+ not arrive within 60 seconds, close only the owned tab and report a timeout.
36
38
  - Treat any observed conversation/thread identity only as diagnostic evidence,
37
39
  never as a resumable handle.
38
40
  - Progress and terminal success/failure for the actual delegated task are
@@ -9,6 +9,7 @@ from browser_harness import *
9
9
 
10
10
  CFG = json.load(open("__CFG_PATH__", encoding="utf-8"))
11
11
  _OWNED_TABS = []
12
+ _KEEP_OWNED_TAB_OPEN = CFG.get("close_policy", "after-start") == "never"
12
13
 
13
14
 
14
15
  def _new_owned_tab(url):
@@ -21,6 +22,8 @@ def _new_owned_tab(url):
21
22
 
22
23
 
23
24
  def _close_owned_tabs():
25
+ if _KEEP_OWNED_TAB_OPEN:
26
+ return
24
27
  while _OWNED_TABS:
25
28
  try:
26
29
  close_tab(_OWNED_TABS.pop())
@@ -185,8 +188,7 @@ print(json.dumps({
185
188
  release_file = CFG.get("release_file")
186
189
  if not release_file:
187
190
  raise RuntimeError("worker release file was not configured")
188
- deadline = time.time() + 90
189
- while time.time() < deadline and not os.path.exists(release_file):
191
+ if CFG.get("close_policy", "after-start") == "never":
192
+ raise SystemExit(0)
193
+ while not os.path.exists(release_file):
190
194
  time.sleep(.1)
191
- if not os.path.exists(release_file):
192
- raise RuntimeError("worker start acknowledgement release was not observed")
@@ -17,6 +17,7 @@ def main() -> int:
17
17
  parser.add_argument("--prompt", required=True)
18
18
  parser.add_argument("--file", action="append", default=[])
19
19
  parser.add_argument("--release-file", required=True)
20
+ parser.add_argument("--close-policy", choices=["after-start", "never", "after-terminal"], default="after-start")
20
21
  args = parser.parse_args()
21
22
  with tempfile.NamedTemporaryFile(mode="w", suffix=".json", delete=False) as handle:
22
23
  json.dump(vars(args), handle, ensure_ascii=False)